Repository: modelcontextprotocol/go-sdk

Issue:
SEP-1699: Don't send priming message if client protocol version is <2025-11-25
Reported by @KKonstantinov in discord: several SDKs implemented SEP-1699 by sending a priming message with empty `data` field, which breaks the client if it tries to interpret this field as a JSON-RPC message.

We should investigate this for the Go SDK implementation. IIRC, I thought about this and chose to use a "prime" event type. That may or may not be an acceptable fix, depending on how clients treat it in the wild. Probably we should:

1. Only issue priming/retry events if the client protocol version is >= 2025-11-25.
2. Be consistent with other SDKs in the structure of our events: if they don't use "prime", then neither should we (but shouldn't they use something other than the default "message" event type?)

Discussion:

Solution patch:
diff --git a/auth/auth.go b/auth/auth.go
index 0eea1d8..8766512 100644
--- a/auth/auth.go
+++ b/auth/auth.go
@@ -6,17 +6,25 @@ 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.
 type TokenInfo struct {
 	Scopes     []string
 	Expiration time.Time
+	// UserID is an optional identifier for the authenticated user.
+	// If set by a TokenVerifier, it can be used by transports to prevent
+	// session hijacking by ensuring that all requests for a given session
+	// come from the same user.
+	UserID string
 	// TODO: add standard JWT fields
 	Extra map[string]any
 }
@@ -118,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..d96e26f 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.
 
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..d8d3006 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.
 
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 9e210ab..b48b7c1 100644
--- a/mcp/streamable.go
+++ b/mcp/streamable.go
@@ -51,6 +51,10 @@ type StreamableHTTPHandler struct {
 type sessionInfo struct {
 	session   *ServerSession
 	transport *StreamableServerTransport
+	// userID is the user ID from the TokenInfo when the session was created.
+	// If non-empty, subsequent requests must have the same user ID to prevent
+	// session hijacking.
+	userID string
 
 	// If timeout is set, automatically close the session after an idle period.
 	timeout time.Duration
@@ -238,6 +242,15 @@ func (h *StreamableHTTPHandler) ServeHTTP(w http.ResponseWriter, req *http.Reque
 			http.Error(w, "session not found", http.StatusNotFound)
 			return
 		}
+		// Prevent session hijacking: if the session was created with a user ID,
+		// verify that subsequent requests come from the same user.
+		if sessInfo != nil && sessInfo.userID != "" {
+			tokenInfo := auth.TokenInfoFromContext(req.Context())
+			if tokenInfo == nil || tokenInfo.UserID != sessInfo.userID {
+				http.Error(w, "session user mismatch", http.StatusForbidden)
+				return
+			}
+		}
 	}
 
 	if req.Method == http.MethodDelete {
@@ -404,9 +417,16 @@ func (h *StreamableHTTPHandler) ServeHTTP(w http.ResponseWriter, req *http.Reque
 			http.Error(w, "failed connection", http.StatusInternalServerError)
 			return
 		}
+		// Capture the user ID from the token info to enable session hijacking
+		// prevention on subsequent requests.
+		var userID string
+		if tokenInfo := auth.TokenInfoFromContext(req.Context()); tokenInfo != nil {
+			userID = tokenInfo.UserID
+		}
 		sessInfo = &sessionInfo{
 			session:   session,
 			transport: transport,
+			userID:    userID,
 		}
 
 		if stateless {
@@ -710,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
 	}
@@ -772,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.
@@ -878,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
 }
@@ -939,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
@@ -950,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,
@@ -974,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 {
@@ -1049,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.
@@ -1072,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 d2234b8..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)},
@@ -1649,11 +1710,87 @@ func TestTokenInfo(t *testing.T) {
 	if !ok {
 		t.Fatal("not TextContent")
 	}
-	if g, w := tc.Text, "&{[scope] 5000-01-02 03:04:05 +0000 UTC map[]}"; g != w {
+	if g, w := tc.Text, "&{[scope] 5000-01-02 03:04:05 +0000 UTC  map[]}"; g != w {
 		t.Errorf("got %q, want %q", g, w)
 	}
 }
 
+func TestSessionHijackingPrevention(t *testing.T) {
+	// This test verifies that sessions bound to a user ID cannot be accessed
+	// by a different user (session hijacking prevention).
+	ctx := context.Background()
+
+	server := NewServer(testImpl, nil)
+	streamHandler := NewStreamableHTTPHandler(func(req *http.Request) *Server { return server }, nil)
+
+	// Use the bearer token directly as the user ID. This simulates how a real
+	// verifier might extract a user ID from a JWT "sub" claim or introspection.
+	verifier := func(_ context.Context, token string, _ *http.Request) (*auth.TokenInfo, error) {
+		return &auth.TokenInfo{
+			Scopes:     []string{"scope"},
+			UserID:     token,
+			Expiration: time.Date(5000, 1, 2, 3, 4, 5, 0, time.UTC),
+		}, nil
+	}
+	handler := auth.RequireBearerToken(verifier, nil)(streamHandler)
+	httpServer := httptest.NewServer(mustNotPanic(t, handler))
+	defer httpServer.Close()
+
+	// Helper to send a JSON-RPC request as a given user.
+	doRequest := func(msg jsonrpc.Message, sessionID, userID string) *http.Response {
+		t.Helper()
+		data, _ := jsonrpc2.EncodeMessage(msg)
+		req, _ := http.NewRequestWithContext(ctx, http.MethodPost, httpServer.URL, bytes.NewReader(data))
+		req.Header.Set("Content-Type", "application/json")
+		req.Header.Set("Accept", "application/json, text/event-stream")
+		req.Header.Set("Authorization", "Bearer "+userID)
+		if sessionID != "" {
+			req.Header.Set("Mcp-Session-Id", sessionID)
+		}
+		resp, err := http.DefaultClient.Do(req)
+		if err != nil {
+			t.Fatalf("request failed: %v", err)
+		}
+		return resp
+	}
+
+	// Create a session as user1.
+	initReq := &jsonrpc.Request{Method: "initialize", ID: jsonrpc2.Int64ID(1)}
+	initReq.Params, _ = json.Marshal(&InitializeParams{
+		ProtocolVersion: protocolVersion20250618,
+		ClientInfo:      &Implementation{Name: "test", Version: "1.0"},
+	})
+	resp := doRequest(initReq, "", "user1")
+	defer resp.Body.Close()
+	if resp.StatusCode != http.StatusOK {
+		body, _ := io.ReadAll(resp.Body)
+		t.Fatalf("initialize failed with status %d: %s", resp.StatusCode, body)
+	}
+	sessionID := resp.Header.Get("Mcp-Session-Id")
+	if sessionID == "" {
+		t.Fatal("no session ID in response")
+	}
+
+	pingReq := &jsonrpc.Request{Method: "ping", ID: jsonrpc2.Int64ID(2)}
+	pingReq.Params, _ = json.Marshal(&PingParams{})
+
+	// Try to access the session as user2 - should fail.
+	resp2 := doRequest(pingReq, sessionID, "user2")
+	defer resp2.Body.Close()
+	if resp2.StatusCode != http.StatusForbidden {
+		body, _ := io.ReadAll(resp2.Body)
+		t.Errorf("expected status %d for user mismatch, got %d: %s", http.StatusForbidden, resp2.StatusCode, body)
+	}
+
+	// Access as original user1 should succeed.
+	resp3 := doRequest(pingReq, sessionID, "user1")
+	defer resp3.Body.Close()
+	if resp3.StatusCode != http.StatusOK {
+		body, _ := io.ReadAll(resp3.Body)
+		t.Errorf("expected status %d for matching user, got %d: %s", http.StatusOK, resp3.StatusCode, body)
+	}
+}
+
 func TestStreamableGET(t *testing.T) {
 	// This test checks the fix for problematic behavior described in #410:
 	// Hanging GET headers should be written immediately, even if there are no