Repository: modelcontextprotocol/go-sdk

Issue:
MCP go-sdk implementation Gap-1: provide APIs to bind event storage with user identifiers
### **Issue Summary**
The MCP Go SDK's default session ID generation mechanism does not comply with the MCP specification's security best practices for session management.

### **Current Implementation Analysis**

#### 1. **Session ID Generation Function**
```go
// go-sdk-main/mcp/util.go:22-30
func randText() string {
	// ⌈log₃₂ 2¹²⁸⌉ = 26 chars
	src := make([]byte, 26)
	rand.Read(src)
	for i := range src {
		src[i] = base32alphabet[src[i]%32]
	}
	return string(src)
}

// go-sdk-main/mcp/util.go:20
const base32alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"
```

#### 2. **Default Assignment Logic**
```go
// go-sdk-main/mcp/server.go:129-131
if opts.GetSessionID == nil {
    opts.GetSessionID = randText
}
```

#### 3. **Actual Call Path**
```go
// go-sdk-main/mcp/streamable.go:221-225
if sessionID == "" {
    // In stateless mode, sessionID may be nonempty even if there's no
    // existing transport.
    sessionID = server.opts.GetSessionID()
}
```

### **Complete Call Chain**
```
HTTP Request → ServeHTTP()
    ↓
Check Mcp-Session-Id header
    ↓
If empty → server.opts.GetSessionID()
    ↓
Call randText() function
    ↓
Generate 26-character Base32 random string
```

### **Generated Session ID Characteristics**

**Format**: 26-character Base32 encoded string  
**Character Set**: `ABCDEFGHIJKLMNOPQRSTUVWXYZ234567`  
**Length**: 26 characters  
**Example**: `ABCDEFGHIJKLMNOPQRSTUVWXYZ` or `2ABCDEFGHIJKLMNOPQRSTUVWX`

### **MCP Specification Requirement**

According to the MCP specification's security best practices:

> **MCP servers SHOULD bind session IDs to user-specific information.**
> 
> When storing or transmitting session-related data (e.g., in a queue), combine the session ID with information unique to the authorized user, such as their internal user ID. Use a key format like `<user_id>:<session_id>`. This ensures that even if an attacker guesses a session ID, they cannot impersonate another user as the user ID is derived from the user token and not provided by the client.

### **Compliance Analysis**

**Current Implementation**:
-  **No user ID binding**
-  **Does not follow `<user_id>:<session_id>` format**

**Specification Requirement**:
- Format: `<user_id>:<session_id>`
- Example: `user123:ABCDEFGHIJKLMNOPQRSTUVWXYZ`

### **Recommendation**

### ** One Possible Solutions**

1. **Modify Default Implementation**:
```go
func (s *Server) generateSecureSessionID(userID string) string {
    sessionID := randText()
    return fmt.Sprintf("%s:%s", userID, sessionID)
}
```

2. **Add Security Configuration Options**:
```go
type ServerOptions struct {
    // Existing fields...
    
    // New: Secure session ID generator
    SecureSessionIDGenerator func(userID string) string
    
    // New: User ID extractor from auth context
    UserIDExtractor func(ctx context.Context) (string, error)
}
```

Hints from issue comments:
@jba: There seems to be some confusion here (possibly ours). We understand that requirement as something that applications must carry out, not the SDK itself. You’ll note that it talks about combining the session ID with something else, which assumes that there already is a session ID. Furthermore, nothing in the spec requires that a user ID or other user specific data is available.@younaman: @jba Thanks for your response!

I understand your point that the specification doesn’t explicitly require the SDK to handle user ID binding.

However, in practice, most developers rely heavily on the SDK’s default behavior. If the default implementation does not follow the spec’s specification, it’s very likely that many real-world deployments will also not follow it. My point is more about incentives and defaults. If the official SDK itself doesn’t provide (or enforce) a compliant default, then it’s unrealistic to expect end users (application developers) to do so manually.

In contrast, as an official SDK, I believe that if the SDK itself provides a secure default implementation that follows the MCP specification, it will offer users an easier way to follow the MCP specification. This way, "follow the rules" isn't left to chance.

Looking forward to your reply. I will be happy to have a further discussion with you.
@findleyr: Sorry, I don't understand. Rereading the spec, I don't think there's a problem with our default session ID generation, though we may need to expose more secure APIs for resumability.

The spec says that session IDs should be cryptographic text. That's consistent with what we do, and what the typescript sdk recommends: https://github.com/modelcontextprotocol/typescript-sdk/blob/c84ef24215396393f28b40bc5ac4cc6ea8fd7b2d/src/server/streamableHttp.ts#L122

The section of the spec you're citing is referring to storing data associated with a session, and combining it with user data. That's largely up to the server author to enforce, with one exception: we should probably provide an implementation of the event store that binds events with user data.@younaman: @findleyr Thanks for your reply!
After carefully reading the MCP specification, I believe the two most relevant sections are:

1. “MCP servers SHOULD bind session IDs to user-specific information.
When storing or transmitting session-related data (e.g., in a queue), combine the session ID with information unique to the authorized user, such as their internal user ID. Use a key format like <user_id>:<session_id>.”

2.  “The session ID SHOULD be globally unique and cryptographically secure (e.g., a securely generated UUID, a JWT, or a cryptographic hash).”
(This is what the Go and TypeScript SDKs currently follow.)

So it seems like there are two separate security requirements:

✅ The session ID itself should be cryptographically secure. (well implemented)

⚠️ The session ID should be bound to the user identity to prevent impersonation. (not implemented)

Currently, both the Go SDK and the TS SDK you provide look only achieve (1). There’s no built-in way to support (2), which means that unless developers manually implement it, this part of the spec won’t be followed in practice. That's the reason I post this issue, making the SDK follow all specifications requested by the MCP protocol.

Looking forward to your reply.@findleyr: I propose to address this with #586.@findleyr: Nevermind, we're not sure how to do this, as many or most authentication schemes don't have a concept of user identifier.
Advice is welcomed.

In #580, we propose disabling replayability by default, which should cut off one form of hijacking with the default configuration. It is still up to the user to ensure that server-side authentication occurs on every request, and not rely on the session ID for identity.@findleyr: Jonathan's #589 is a better solution. If that lands, we'll use it to join session information with the user identifier.@younaman: Thanks for your reply @findleyr. Sorry for the delay because I had surgery two weeks ago and I couldn't go back to work until now. I have read the #589. I think it's a good solution for this issue. Please feel free to let me know if you need me to do something@SamMorrowDrums: As author of the session hijack guidance and now user of the official Go SDK, I am stoked to see first class support for safer session handling! ❤️ @findleyr: Thanks @SamMorrowDrums, you reminded me that we need to update our guidance on hijacking avoidance in our docs!
(EDIT: nevermind, I did that--it's been a long week :))

Solution patch:
diff --git a/auth/auth.go b/auth/auth.go
index 6c34571..8766512 100644
--- a/auth/auth.go
+++ b/auth/auth.go
@@ -6,11 +6,14 @@ package auth
 
 import (
 	"context"
+	"encoding/json"
 	"errors"
 	"net/http"
 	"slices"
 	"strings"
 	"time"
+
+	"github.com/modelcontextprotocol/go-sdk/oauthex"
 )
 
 // TokenInfo holds information from a bearer token.
@@ -123,3 +126,43 @@ func verify(req *http.Request, verifier TokenVerifier, opts *RequireBearerTokenO
 	}
 	return tokenInfo, "", 0
 }
+
+// ProtectedResourceMetadataHandler returns an http.Handler that serves OAuth 2.0
+// protected resource metadata (RFC 9728) with CORS support.
+//
+// This handler allows cross-origin requests from any origin (Access-Control-Allow-Origin: *)
+// because OAuth metadata is public information intended for client discovery (RFC 9728 §3.1).
+// The metadata contains only non-sensitive configuration data about authorization servers
+// and supported scopes.
+//
+// No validation of metadata fields is performed; ensure metadata accuracy at configuration time.
+//
+// For more sophisticated CORS policies or to restrict origins, wrap this handler with a
+// CORS middleware like github.com/rs/cors or github.com/jub0bs/cors.
+func ProtectedResourceMetadataHandler(metadata *oauthex.ProtectedResourceMetadata) http.Handler {
+	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		// Set CORS headers for cross-origin client discovery.
+		// OAuth metadata is public information, so allowing any origin is safe.
+		w.Header().Set("Access-Control-Allow-Origin", "*")
+		w.Header().Set("Access-Control-Allow-Methods", "GET, OPTIONS")
+		w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
+
+		// Handle CORS preflight requests
+		if r.Method == http.MethodOptions {
+			w.WriteHeader(http.StatusNoContent)
+			return
+		}
+
+		// Only GET allowed for metadata retrieval
+		if r.Method != http.MethodGet {
+			http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
+			return
+		}
+
+		w.Header().Set("Content-Type", "application/json")
+		if err := json.NewEncoder(w).Encode(metadata); err != nil {
+			http.Error(w, "Failed to encode metadata", http.StatusInternalServerError)
+			return
+		}
+	})
+}
diff --git a/docs/protocol.md b/docs/protocol.md
index 6d22c4b..16ba0bf 100644
--- a/docs/protocol.md
+++ b/docs/protocol.md
@@ -273,7 +273,34 @@ from `req.Extra.TokenInfo`, where `req` is the handler's request. (For example,
 [`CallToolRequest`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#CallToolRequest).)
 HTTP handlers wrapped by the `RequireBearerToken` middleware can obtain the `TokenInfo` from the context
 with [`auth.TokenInfoFromContext`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/auth#TokenInfoFromContext).
- 
+
+#### OAuth Protected Resource Metadata
+
+Servers implementing OAuth 2.0 authorization should expose a protected resource metadata endpoint
+as specified in [RFC 9728](https://datatracker.ietf.org/doc/html/rfc9728). This endpoint provides
+clients with information about the resource server's OAuth configuration, including which
+authorization servers can be used and what scopes are supported.
+
+The SDK provides [`ProtectedResourceMetadataHandler`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/auth#ProtectedResourceMetadataHandler)
+to serve this metadata. The handler automatically sets CORS headers (`Access-Control-Allow-Origin: *`)
+to support cross-origin client discovery, as the metadata contains only public configuration information.
+
+Example usage:
+
+```go
+metadata := &oauthex.ProtectedResourceMetadata{
+    Resource: "https://example.com/mcp",
+    AuthorizationServers: []string{
+        "https://auth.example.com/.well-known/openid-configuration",
+    },
+    ScopesSupported: []string{"read", "write"},
+}
+http.Handle("/.well-known/oauth-protected-resource",
+    auth.ProtectedResourceMetadataHandler(metadata))
+```
+
+For more sophisticated CORS policies, wrap the handler with a CORS middleware like
+[github.com/rs/cors](https://github.com/rs/cors) or [github.com/jub0bs/cors](https://github.com/jub0bs/cors).
 
 The  [_auth middleware example_](https://github.com/modelcontextprotocol/go-sdk/tree/main/examples/server/auth-middleware) shows how to implement authorization for both JWT tokens and API keys.
 
@@ -316,9 +343,18 @@ If you create your own with
 If you are using Go 1.24 or above,
 we recommend using [`crypto/rand.Text`](https://pkg.go.dev/crypto/rand#Text) 
 
-- _Binding session IDs to user information_. This is an application requirement, out of scope
-for the SDK. You can create your own session IDs by setting
-[`ServerOptions.GetSessionID`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ServerOptions.GetSessionID).
+- _Binding session IDs to user information_. The SDK supports this mitigation through
+[`TokenInfo.UserID`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/auth#TokenInfo.UserID).
+When a [`TokenVerifier`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/auth#TokenVerifier)
+sets `UserID` on the returned `TokenInfo`, the streamable transport will:
+  1. Store the user ID when a new session is created.
+  2. Verify that subsequent requests to that session include a token with the same `UserID`.
+  3. Reject requests with a 403 Forbidden if the user ID doesn't match.
+
+  **Recommendation**: If your `TokenVerifier` can extract a user identifier from the token
+  (such as a `sub` claim in a JWT, or a user ID associated with an API key), set
+  `TokenInfo.UserID` to enable this protection. This prevents an attacker with a valid
+  token from hijacking another user's session by guessing or obtaining their session ID.
 
 ## Utilities
 
diff --git a/examples/server/auth-middleware/README.md b/examples/server/auth-middleware/README.md
index aaae767..001237c 100644
--- a/examples/server/auth-middleware/README.md
+++ b/examples/server/auth-middleware/README.md
@@ -44,6 +44,7 @@ go test -cover
 ### Public Endpoints (No Authentication Required)
 
 - `GET /health` - Health check
+- `GET /.well-known/oauth-protected-resource` - OAuth 2.0 Protected Resource Metadata (RFC 9728)
 
 ### MCP Endpoints (Authentication Required)
 
@@ -228,6 +229,41 @@ authenticatedHandler := authMiddleware(customMiddleware(mcpHandler))
 4. **Token Expiration**: Set appropriate token expiration times
 5. **Principle of Least Privilege**: Grant only the minimum required scopes
 
+## CORS Support
+
+The OAuth metadata endpoint (`/.well-known/oauth-protected-resource`) supports CORS to enable
+cross-origin client discovery. It sets `Access-Control-Allow-Origin: *` by default because
+OAuth metadata is public information meant for client discovery (RFC 9728 §3.1).
+
+### Custom CORS Policies
+
+For more sophisticated CORS requirements (origin validation, credentials, etc.), wrap the handler
+with a CORS middleware library:
+
+**Using github.com/rs/cors:**
+```go
+import "github.com/rs/cors"
+
+c := cors.New(cors.Options{
+    AllowedOrigins: []string{"https://example.com"},
+    AllowedMethods: []string{"GET", "OPTIONS"},
+})
+http.Handle("/.well-known/oauth-protected-resource",
+    c.Handler(auth.ProtectedResourceMetadataHandler(metadata)))
+```
+
+**Using github.com/jub0bs/cors:**
+```go
+import "github.com/jub0bs/cors"
+
+corsMiddleware, err := cors.NewMiddleware(cors.Config{
+    Origins: []string{"https://example.com"},
+    Methods: []string{"GET", "OPTIONS"},
+})
+http.Handle("/.well-known/oauth-protected-resource",
+    corsMiddleware.Wrap(auth.ProtectedResourceMetadataHandler(metadata)))
+```
+
 ## Use Cases
 
 **Ideal for:**
diff --git a/examples/server/auth-middleware/go.mod b/examples/server/auth-middleware/go.mod
index cbc89c7..9975879 100644
--- a/examples/server/auth-middleware/go.mod
+++ b/examples/server/auth-middleware/go.mod
@@ -10,6 +10,7 @@ require (
 require (
 	github.com/google/jsonschema-go v0.3.0 // indirect
 	github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
+	golang.org/x/oauth2 v0.30.0 // indirect
 )
 
 replace github.com/modelcontextprotocol/go-sdk => ../../../
diff --git a/examples/server/auth-middleware/go.sum b/examples/server/auth-middleware/go.sum
index 7b7a8e5..32ceedf 100644
--- a/examples/server/auth-middleware/go.sum
+++ b/examples/server/auth-middleware/go.sum
@@ -6,5 +6,7 @@ github.com/google/jsonschema-go v0.3.0 h1:6AH2TxVNtk3IlvkkhjrtbUc4S8AvO0Xii0DxIy
 github.com/google/jsonschema-go v0.3.0/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE=
 github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=
 github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4=
+golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI=
+golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU=
 golang.org/x/tools v0.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo=
 golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg=
diff --git a/examples/server/auth-middleware/main.go b/examples/server/auth-middleware/main.go
index 75a38b8..ca339df 100644
--- a/examples/server/auth-middleware/main.go
+++ b/examples/server/auth-middleware/main.go
@@ -20,6 +20,7 @@ import (
 	"github.com/golang-jwt/jwt/v5"
 	"github.com/modelcontextprotocol/go-sdk/auth"
 	"github.com/modelcontextprotocol/go-sdk/mcp"
+	"github.com/modelcontextprotocol/go-sdk/oauthex"
 )
 
 // This example demonstrates how to integrate auth.RequireBearerToken middleware
@@ -237,7 +238,8 @@ func main() {
 
 	// Create authentication middleware.
 	jwtAuth := auth.RequireBearerToken(verifyJWT, &auth.RequireBearerTokenOptions{
-		Scopes: []string{"read"}, // Require "read" permission
+		Scopes:              []string{"read"}, // Require "read" permission
+		ResourceMetadataURL: "http://localhost:8080/.well-known/oauth-protected-resource",
 	})
 
 	apiKeyAuth := auth.RequireBearerToken(verifyAPIKey, &auth.RequireBearerTokenOptions{
@@ -340,22 +342,36 @@ func main() {
 		})
 	})
 
+	// OAuth protected resource metadata endpoint.
+	// This endpoint provides OAuth configuration information to clients.
+	// CORS is enabled by default to support cross-origin client discovery.
+	metadata := &oauthex.ProtectedResourceMetadata{
+		Resource: "http://localhost:8080/mcp/jwt",
+		AuthorizationServers: []string{
+			"https://auth.example.com/.well-known/openid-configuration",
+		},
+		ScopesSupported: []string{"read", "write"},
+	}
+	http.Handle("/.well-known/oauth-protected-resource",
+		auth.ProtectedResourceMetadataHandler(metadata))
+
 	// Start the HTTP server.
 	log.Println("Authenticated MCP Server")
 	log.Println("========================")
 	log.Println("Server starting on", *httpAddr)
 	log.Println()
 	log.Println("Available endpoints:")
-	log.Println("  GET  /health                    - Health check (no auth)")
-	log.Println("  GET  /generate-token            - Generate JWT token")
-	log.Println("  POST /generate-api-key          - Generate API key")
-	log.Println("  POST /mcp/jwt                   - MCP endpoint (JWT auth)")
-	log.Println("  POST /mcp/apikey                - MCP endpoint (API key auth)")
+	log.Println("  GET  /health                                    - Health check (no auth)")
+	log.Println("  GET  /.well-known/oauth-protected-resource      - OAuth resource metadata")
+	log.Println("  GET  /generate-token                            - Generate JWT token")
+	log.Println("  POST /generate-api-key                          - Generate API key")
+	log.Println("  POST /mcp/jwt                                   - MCP endpoint (JWT auth)")
+	log.Println("  POST /mcp/apikey                                - MCP endpoint (API key auth)")
 	log.Println()
 	log.Println("Available MCP Tools:")
-	log.Println("  - say_hi                        - Simple greeting (any auth)")
-	log.Println("  - get_user_info                 - Get user info (read scope)")
-	log.Println("  - create_resource               - Create resource (write scope)")
+	log.Println("  - say_hi                                        - Simple greeting (any auth)")
+	log.Println("  - get_user_info                                 - Get user info (read scope)")
+	log.Println("  - create_resource                               - Create resource (write scope)")
 	log.Println()
 	log.Println("Example usage:")
 	log.Println("  # Generate a token")
diff --git a/internal/docs/protocol.src.md b/internal/docs/protocol.src.md
index b0874cc..ada3437 100644
--- a/internal/docs/protocol.src.md
+++ b/internal/docs/protocol.src.md
@@ -199,7 +199,34 @@ from `req.Extra.TokenInfo`, where `req` is the handler's request. (For example,
 [`CallToolRequest`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#CallToolRequest).)
 HTTP handlers wrapped by the `RequireBearerToken` middleware can obtain the `TokenInfo` from the context
 with [`auth.TokenInfoFromContext`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/auth#TokenInfoFromContext).
- 
+
+#### OAuth Protected Resource Metadata
+
+Servers implementing OAuth 2.0 authorization should expose a protected resource metadata endpoint
+as specified in [RFC 9728](https://datatracker.ietf.org/doc/html/rfc9728). This endpoint provides
+clients with information about the resource server's OAuth configuration, including which
+authorization servers can be used and what scopes are supported.
+
+The SDK provides [`ProtectedResourceMetadataHandler`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/auth#ProtectedResourceMetadataHandler)
+to serve this metadata. The handler automatically sets CORS headers (`Access-Control-Allow-Origin: *`)
+to support cross-origin client discovery, as the metadata contains only public configuration information.
+
+Example usage:
+
+```go
+metadata := &oauthex.ProtectedResourceMetadata{
+    Resource: "https://example.com/mcp",
+    AuthorizationServers: []string{
+        "https://auth.example.com/.well-known/openid-configuration",
+    },
+    ScopesSupported: []string{"read", "write"},
+}
+http.Handle("/.well-known/oauth-protected-resource",
+    auth.ProtectedResourceMetadataHandler(metadata))
+```
+
+For more sophisticated CORS policies, wrap the handler with a CORS middleware like
+[github.com/rs/cors](https://github.com/rs/cors) or [github.com/jub0bs/cors](https://github.com/jub0bs/cors).
 
 The  [_auth middleware example_](https://github.com/modelcontextprotocol/go-sdk/tree/main/examples/server/auth-middleware) shows how to implement authorization for both JWT tokens and API keys.
 
@@ -242,9 +269,18 @@ If you create your own with
 If you are using Go 1.24 or above,
 we recommend using [`crypto/rand.Text`](https://pkg.go.dev/crypto/rand#Text) 
 
-- _Binding session IDs to user information_. This is an application requirement, out of scope
-for the SDK. You can create your own session IDs by setting
-[`ServerOptions.GetSessionID`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ServerOptions.GetSessionID).
+- _Binding session IDs to user information_. The SDK supports this mitigation through
+[`TokenInfo.UserID`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/auth#TokenInfo.UserID).
+When a [`TokenVerifier`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/auth#TokenVerifier)
+sets `UserID` on the returned `TokenInfo`, the streamable transport will:
+  1. Store the user ID when a new session is created.
+  2. Verify that subsequent requests to that session include a token with the same `UserID`.
+  3. Reject requests with a 403 Forbidden if the user ID doesn't match.
+
+  **Recommendation**: If your `TokenVerifier` can extract a user identifier from the token
+  (such as a `sub` claim in a JWT, or a user ID associated with an API key), set
+  `TokenInfo.UserID` to enable this protection. This prevents an attacker with a valid
+  token from hijacking another user's session by guessing or obtaining their session ID.
 
 ## Utilities
 
diff --git a/mcp/client.go b/mcp/client.go
index a3d36b2..7349ba9 100644
--- a/mcp/client.go
+++ b/mcp/client.go
@@ -120,7 +120,11 @@ func (e unsupportedProtocolVersionError) Error() string {
 }
 
 // ClientSessionOptions is reserved for future use.
-type ClientSessionOptions struct{}
+type ClientSessionOptions struct {
+	// protocolVersion overrides the protocol version sent in the initialize
+	// request, for testing. If empty, latestProtocolVersion is used.
+	protocolVersion string
+}
 
 func (c *Client) capabilities() *ClientCapabilities {
 	caps := &ClientCapabilities{}
@@ -151,14 +155,18 @@ func (c *Client) capabilities() *ClientCapabilities {
 // when it is no longer needed. However, if the connection is closed by the
 // server, calls or notifications will return an error wrapping
 // [ErrConnectionClosed].
-func (c *Client) Connect(ctx context.Context, t Transport, _ *ClientSessionOptions) (cs *ClientSession, err error) {
+func (c *Client) Connect(ctx context.Context, t Transport, opts *ClientSessionOptions) (cs *ClientSession, err error) {
 	cs, err = connect(ctx, t, c, (*clientSessionState)(nil), nil)
 	if err != nil {
 		return nil, err
 	}
 
+	protocolVersion := latestProtocolVersion
+	if opts != nil && opts.protocolVersion != "" {
+		protocolVersion = opts.protocolVersion
+	}
 	params := &InitializeParams{
-		ProtocolVersion: latestProtocolVersion,
+		ProtocolVersion: protocolVersion,
 		ClientInfo:      c.impl,
 		Capabilities:    c.capabilities(),
 	}
diff --git a/mcp/streamable.go b/mcp/streamable.go
index 9ab3303..b48b7c1 100644
--- a/mcp/streamable.go
+++ b/mcp/streamable.go
@@ -730,7 +730,15 @@ func (c *streamableServerConn) serveGET(w http.ResponseWriter, req *http.Request
 	ctx, cancel := context.WithCancel(req.Context())
 	defer cancel()
 
-	stream, done := c.acquireStream(ctx, w, streamID, &lastIdx)
+	// Read the protocol version from the header. For GET requests, this should
+	// always be present since GET only happens after initialization.
+	protocolVersion := req.Header.Get(protocolVersionHeader)
+	if protocolVersion == "" {
+		protocolVersion = protocolVersion20250326
+	}
+	supportsPrimeClose := protocolVersion >= protocolVersion20251125
+
+	stream, done := c.acquireStream(ctx, w, streamID, &lastIdx, supportsPrimeClose)
 	if stream == nil {
 		return
 	}
@@ -792,7 +800,10 @@ func (c *streamableServerConn) writeCloseEvent(w http.ResponseWriter, reconnectA
 // Importantly, this function must hold the stream mutex until done replaying
 // all messages, so that no delivery or storage of new messages occurs while
 // the stream is still replaying.
-func (c *streamableServerConn) acquireStream(ctx context.Context, w http.ResponseWriter, streamID string, lastIdx *int) (*stream, chan struct{}) {
+//
+// supportsPrimeClose indicates whether the client supports the prime and close
+// events (protocol version 2025-11-25 or later).
+func (c *streamableServerConn) acquireStream(ctx context.Context, w http.ResponseWriter, streamID string, lastIdx *int, supportsPrimeClose bool) (*stream, chan struct{}) {
 	// if tempStream is set, the stream is done and we're just replaying messages.
 	//
 	// We record a temporary stream to claim exclusive replay rights.
@@ -898,16 +909,18 @@ func (c *streamableServerConn) acquireStream(ctx context.Context, w http.Respons
 		}
 		return c.writeEvent(w, s.id, Event{Name: "message", Data: data}, lastIdx)
 	}
-	s.closeLocked = func(reconnectAfter time.Duration) {
-		select {
-		case <-done:
-			return
-		default:
-		}
-		if reconnectAfter > 0 {
-			c.writeCloseEvent(w, reconnectAfter)
+	if supportsPrimeClose {
+		s.closeLocked = func(reconnectAfter time.Duration) {
+			select {
+			case <-done:
+				return
+			default:
+			}
+			if reconnectAfter > 0 {
+				c.writeCloseEvent(w, reconnectAfter)
+			}
+			close(done)
 		}
-		close(done)
 	}
 	return s, done
 }
@@ -959,6 +972,7 @@ func (c *streamableServerConn) servePOST(w http.ResponseWriter, req *http.Reques
 	calls := make(map[jsonrpc.ID]struct{})
 	tokenInfo := auth.TokenInfoFromContext(req.Context())
 	isInitialize := false
+	var initializeProtocolVersion string
 	for _, msg := range incoming {
 		if jreq, ok := msg.(*jsonrpc.Request); ok {
 			// Preemptively check that this is a valid request, so that we can fail
@@ -970,6 +984,11 @@ func (c *streamableServerConn) servePOST(w http.ResponseWriter, req *http.Reques
 			}
 			if jreq.Method == methodInitialize {
 				isInitialize = true
+				// Extract the protocol version from InitializeParams.
+				var params InitializeParams
+				if err := json.Unmarshal(jreq.Params, &params); err == nil {
+					initializeProtocolVersion = params.ProtocolVersion
+				}
 			}
 			jreq.Extra = &RequestExtra{
 				TokenInfo: tokenInfo,
@@ -994,6 +1013,15 @@ func (c *streamableServerConn) servePOST(w http.ResponseWriter, req *http.Reques
 		}
 	}
 
+	// The prime and close events were added in protocol version 2025-11-25 (SEP-1699).
+	// Use the version from InitializeParams if this is an initialize request,
+	// otherwise use the protocol version header.
+	effectiveVersion := protocolVersion
+	if isInitialize && initializeProtocolVersion != "" {
+		effectiveVersion = initializeProtocolVersion
+	}
+	supportsPrimeClose := effectiveVersion >= protocolVersion20251125
+
 	// If we don't have any calls, we can just publish the incoming messages and return.
 	// No need to track a logical stream.
 	if len(calls) == 0 {
@@ -1069,7 +1097,7 @@ func (c *streamableServerConn) servePOST(w http.ResponseWriter, req *http.Reques
 	} else {
 		// Write events in the order we receive them.
 		lastIndex := -1
-		if c.eventStore != nil {
+		if c.eventStore != nil && supportsPrimeClose {
 			// Write a priming event.
 			// We must also write it to the event store in order for indexes to
 			// align.
@@ -1092,16 +1120,18 @@ func (c *streamableServerConn) servePOST(w http.ResponseWriter, req *http.Reques
 			}
 			return c.writeEvent(w, stream.id, Event{Name: "message", Data: data}, &lastIndex)
 		}
-		stream.closeLocked = func(reconnectAfter time.Duration) {
-			select {
-			case <-done:
-				return
-			default:
-			}
-			if reconnectAfter > 0 {
-				c.writeCloseEvent(w, reconnectAfter)
+		if supportsPrimeClose {
+			stream.closeLocked = func(reconnectAfter time.Duration) {
+				select {
+				case <-done:
+					return
+				default:
+				}
+				if reconnectAfter > 0 {
+					c.writeCloseEvent(w, reconnectAfter)
+				}
+				close(done)
 			}
-			close(done)
 		}
 	}
 



Test patch:
diff --git a/auth/auth_test.go b/auth/auth_test.go
index ef8ea7b..a943404 100644
--- a/auth/auth_test.go
+++ b/auth/auth_test.go
@@ -6,10 +6,15 @@ package auth
 
 import (
 	"context"
+	"encoding/json"
 	"errors"
 	"net/http"
+	"net/http/httptest"
+	"strings"
 	"testing"
 	"time"
+
+	"github.com/modelcontextprotocol/go-sdk/oauthex"
 )
 
 func TestVerify(t *testing.T) {
@@ -76,3 +81,110 @@ func TestVerify(t *testing.T) {
 		})
 	}
 }
+
+func TestProtectedResourceMetadataHandler(t *testing.T) {
+	metadata := &oauthex.ProtectedResourceMetadata{
+		Resource: "https://example.com/mcp",
+		AuthorizationServers: []string{
+			"https://auth.example.com/.well-known/openid-configuration",
+		},
+		ScopesSupported: []string{"read", "write"},
+	}
+
+	handler := ProtectedResourceMetadataHandler(metadata)
+
+	tests := []struct {
+		name       string
+		method     string
+		wantStatus int
+		checkJSON  bool
+	}{
+		{
+			name:       "GET returns metadata",
+			method:     http.MethodGet,
+			wantStatus: http.StatusOK,
+			checkJSON:  true,
+		},
+		{
+			name:       "OPTIONS for CORS preflight",
+			method:     http.MethodOptions,
+			wantStatus: http.StatusNoContent,
+		},
+		{
+			name:       "POST not allowed",
+			method:     http.MethodPost,
+			wantStatus: http.StatusMethodNotAllowed,
+		},
+		{
+			name:       "PUT not allowed",
+			method:     http.MethodPut,
+			wantStatus: http.StatusMethodNotAllowed,
+		},
+		{
+			name:       "DELETE not allowed",
+			method:     http.MethodDelete,
+			wantStatus: http.StatusMethodNotAllowed,
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			req := httptest.NewRequest(tt.method, "/.well-known/oauth-protected-resource", nil)
+			rec := httptest.NewRecorder()
+
+			handler.ServeHTTP(rec, req)
+
+			if rec.Code != tt.wantStatus {
+				t.Errorf("status = %d, want %d", rec.Code, tt.wantStatus)
+			}
+
+			// All responses should have CORS headers
+			if got := rec.Header().Get("Access-Control-Allow-Origin"); got != "*" {
+				t.Errorf("Access-Control-Allow-Origin = %q, want %q", got, "*")
+			}
+
+			if got := rec.Header().Get("Access-Control-Allow-Methods"); got != "GET, OPTIONS" {
+				t.Errorf("Access-Control-Allow-Methods = %q, want %q", got, "GET, OPTIONS")
+			}
+
+			// Validate error response body for disallowed methods
+			if tt.wantStatus == http.StatusMethodNotAllowed {
+				if !strings.Contains(rec.Body.String(), "Method not allowed") {
+					t.Errorf("error body = %q, want to contain %q", rec.Body.String(), "Method not allowed")
+				}
+			}
+
+			if tt.checkJSON {
+				if got := rec.Header().Get("Content-Type"); got != "application/json" {
+					t.Errorf("Content-Type = %q, want %q", got, "application/json")
+				}
+
+				var got oauthex.ProtectedResourceMetadata
+				if err := json.NewDecoder(rec.Body).Decode(&got); err != nil {
+					t.Fatalf("failed to decode response: %v", err)
+				}
+
+				if got.Resource != metadata.Resource {
+					t.Errorf("Resource = %q, want %q", got.Resource, metadata.Resource)
+				}
+
+				if len(got.AuthorizationServers) != len(metadata.AuthorizationServers) {
+					t.Errorf("AuthorizationServers length = %d, want %d",
+						len(got.AuthorizationServers), len(metadata.AuthorizationServers))
+				}
+
+				for i, server := range got.AuthorizationServers {
+					if server != metadata.AuthorizationServers[i] {
+						t.Errorf("AuthorizationServers[%d] = %q, want %q",
+							i, server, metadata.AuthorizationServers[i])
+					}
+				}
+
+				if len(got.ScopesSupported) != len(metadata.ScopesSupported) {
+					t.Errorf("ScopesSupported length = %d, want %d",
+						len(got.ScopesSupported), len(metadata.ScopesSupported))
+				}
+			}
+		})
+	}
+}
diff --git a/mcp/streamable_test.go b/mcp/streamable_test.go
index 9a90d24..9eff74b 100644
--- a/mcp/streamable_test.go
+++ b/mcp/streamable_test.go
@@ -584,7 +584,7 @@ func TestStreamableServerDisconnect(t *testing.T) {
 			})
 			clientSession, err := client.Connect(ctx, &StreamableClientTransport{
 				Endpoint: httpServer.URL,
-			}, nil)
+			}, &ClientSessionOptions{protocolVersion: protocolVersion20251125})
 			if err != nil {
 				t.Fatalf("client.Connect() failed: %v", err)
 			}
@@ -752,7 +752,7 @@ func TestStreamableServerTransport(t *testing.T) {
 	// requests.
 
 	// Predefined steps, to avoid repetition below.
-	initReq := req(1, methodInitialize, &InitializeParams{})
+	initReq := req(1, methodInitialize, &InitializeParams{ProtocolVersion: protocolVersion20250618})
 	initResp := resp(1, &InitializeResult{
 		Capabilities: &ServerCapabilities{
 			Logging: &LoggingCapabilities{},
@@ -775,6 +775,30 @@ func TestStreamableServerTransport(t *testing.T) {
 		wantStatusCode: http.StatusAccepted,
 	}
 
+	// Protocol version 2025-11-25 variants, for testing prime/close events (SEP-1699).
+	initReq20251125 := req(1, methodInitialize, &InitializeParams{ProtocolVersion: protocolVersion20251125})
+	initResp20251125 := resp(1, &InitializeResult{
+		Capabilities: &ServerCapabilities{
+			Logging: &LoggingCapabilities{},
+			Tools:   &ToolCapabilities{ListChanged: true},
+		},
+		ProtocolVersion: protocolVersion20251125,
+		ServerInfo:      &Implementation{Name: "testServer", Version: "v1.0.0"},
+	}, nil)
+	initialize20251125 := streamableRequest{
+		method:         "POST",
+		messages:       []jsonrpc.Message{initReq20251125},
+		wantStatusCode: http.StatusOK,
+		wantMessages:   []jsonrpc.Message{initResp20251125},
+		wantSessionID:  true,
+	}
+	initialized20251125 := streamableRequest{
+		method:         "POST",
+		headers:        http.Header{protocolVersionHeader: {protocolVersion20251125}},
+		messages:       []jsonrpc.Message{initializedMsg},
+		wantStatusCode: http.StatusAccepted,
+	}
+
 	tests := []struct {
 		name         string
 		replay       bool                                                // if set, use a MemoryEventStore to enable replay
@@ -1026,13 +1050,49 @@ func TestStreamableServerTransport(t *testing.T) {
 			wantSessions: 0, // session deleted
 		},
 		{
-			name:   "priming message",
+			name:   "no priming message on old protocol",
 			replay: true,
 			requests: []streamableRequest{
 				initialize,
 				initialized,
+				{
+					method:                "POST",
+					messages:              []jsonrpc.Message{req(2, "tools/call", &CallToolParams{Name: "tool"})},
+					wantStatusCode:        http.StatusOK,
+					wantMessages:          []jsonrpc.Message{resp(2, &CallToolResult{Content: []Content{}}, nil)},
+					wantBodyNotContaining: "prime",
+				},
+			},
+			wantSessions: 1,
+		},
+		{
+			name:   "no close message on old protocol",
+			replay: true,
+			tool: func(t *testing.T, _ context.Context, req *CallToolRequest) {
+				req.Extra.CloseStream(time.Millisecond)
+			},
+			requests: []streamableRequest{
+				initialize,
+				initialized,
+				{
+					method:                "POST",
+					messages:              []jsonrpc.Message{req(2, "tools/call", &CallToolParams{Name: "tool"})},
+					wantStatusCode:        http.StatusOK,
+					wantMessages:          []jsonrpc.Message{resp(2, &CallToolResult{Content: []Content{}}, nil)},
+					wantBodyNotContaining: "close",
+				},
+			},
+			wantSessions: 1,
+		},
+		{
+			name:   "priming message on 2025-11-25",
+			replay: true,
+			requests: []streamableRequest{
+				initialize20251125,
+				initialized20251125,
 				{
 					method:             "POST",
+					headers:            http.Header{protocolVersionHeader: {protocolVersion20251125}},
 					messages:           []jsonrpc.Message{req(2, "tools/call", &CallToolParams{Name: "tool"})},
 					wantStatusCode:     http.StatusOK,
 					wantMessages:       []jsonrpc.Message{resp(2, &CallToolResult{Content: []Content{}}, nil)},
@@ -1042,16 +1102,17 @@ func TestStreamableServerTransport(t *testing.T) {
 			wantSessions: 1,
 		},
 		{
-			name:   "close message",
+			name:   "close message on 2025-11-25",
 			replay: true,
 			tool: func(t *testing.T, _ context.Context, req *CallToolRequest) {
 				req.Extra.CloseStream(time.Millisecond)
 			},
 			requests: []streamableRequest{
-				initialize,
-				initialized,
+				initialize20251125,
+				initialized20251125,
 				{
 					method:                "POST",
+					headers:               http.Header{protocolVersionHeader: {protocolVersion20251125}},
 					messages:              []jsonrpc.Message{req(2, "tools/call", &CallToolParams{Name: "tool"})},
 					wantStatusCode:        http.StatusOK,
 					wantMessages:          []jsonrpc.Message{resp(2, &CallToolResult{Content: []Content{}}, nil)},