From 793040508d1c1c7fd0964f939211ceb8dbf8d5bb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20M=2E=20Requena=20Plens?= Date: Tue, 15 Sep 2026 17:16:14 +0200 Subject: [PATCH] mcp: record the negotiated protocol version on every path that records one Only the initialize handshake wrote NegotiatedProtocolVersion. The three other paths that record a session's version, the first new-protocol call in ServerSession.handle, server/discover, and the state the streamable handler synthesizes from MCP-Protocol-Version, wrote InitializeParams alone, so every reader of the negotiated field saw a SEP-2575 session as having no version. The stdio connection reads nothing else: it took such a session for 2025-03-26 and accepted the JSON-RPC batches that 2025-06-18 and later forbid, which the streamable handler already refuses from the header. Each path now records the version it serves under. handle records the declared version once the check against the server's supported list has accepted it, which is the only negotiation SEP-2575 has: a version the server does not speak is refused with the list it does. It is not put through negotiatedVersion, which serves the deprecated handshake and caps its answer below 2026-07-28; that would downgrade every new-protocol session with no handshake response to tell the client. discover records InitializeParams and the negotiated version together, and only when its answer lists the version the client declared, since such a client keeps speaking it; one whose version is not listed picks another on its next call, so nothing is recorded for it. That replaces a guard which asked whether the transport served any new version rather than the client's, and keeps what it was for: a stateful streamable session a discover request created is never surfaced through Mcp-Session-Id, and a nil InitializeParams is what lets serveStatefulPOST close it. The synthesized state records the header, which the transport spec defines as the version negotiated earlier, or the 2025-03-26 it has a server assume without one. The field stays empty in state a caller supplies with InitializeParams alone and in state persisted before it existed, so a reader that needs the version a session speaks still has the declared one to fall back on. --- mcp/server.go | 16 ++--- mcp/server_test.go | 132 +++++++++++++++++++++++++++++++++++++++++ mcp/session.go | 8 +-- mcp/streamable.go | 4 ++ mcp/streamable_test.go | 10 ++++ mcp/transport_test.go | 9 +++ 6 files changed, 167 insertions(+), 12 deletions(-) diff --git a/mcp/server.go b/mcp/server.go index 569abab5..0f46f07d 100644 --- a/mcp/server.go +++ b/mcp/server.go @@ -939,15 +939,13 @@ func (s *Server) discover(ctx context.Context, req *ServerRequest[*DiscoverParam Capabilities: req.ClientCapabilities(), ClientInfo: req.ClientInfo(), } - // Only persist InitializeParams when the transport can actually serve - // the new protocol. On transports that cannot (notably stateful - // StreamableHTTPHandler), a discover request creates a session that - // is never surfaced to the client via Mcp-Session-Id; leaving - // InitializeParams nil lets serveStatefulPOST's safety-net cleanup - // close it instead of leaking. - if slices.ContainsFunc(versions, func(v string) bool { return v >= protocolVersion20260728 }) { + // Record the session only when this transport serves the version the + // client declared: one whose version is not listed picks another on its + // next call, and a nil InitializeParams lets serveStatefulPOST close it. + if slices.Contains(versions, init.ProtocolVersion) { req.Session.updateState(func(state *ServerSessionState) { state.InitializeParams = init + state.NegotiatedProtocolVersion = init.ProtocolVersion }) } res := &DiscoverResult{ @@ -2001,6 +1999,10 @@ func (ss *ServerSession) handle(ctx context.Context, req *jsonrpc.Request) (any, if !initialized && validatedMeta.usesNewProtocol && validatedMeta.initializeParams != nil { ss.updateState(func(state *ServerSessionState) { state.InitializeParams = validatedMeta.initializeParams + // Accepted above and served as declared, which is all the negotiation + // SEP-2575 has. Not through negotiatedVersion: that caps its answer + // below 2026-07-28 and would downgrade the session unannounced. + state.NegotiatedProtocolVersion = validatedMeta.initializeParams.ProtocolVersion }) } } diff --git a/mcp/server_test.go b/mcp/server_test.go index 4d09cbca..abeb47f3 100644 --- a/mcp/server_test.go +++ b/mcp/server_test.go @@ -2354,6 +2354,138 @@ func TestServerHandle_NewProtocolCallWithoutInitialize(t *testing.T) { } } +// negotiatedProtocolVersion reads the version a session has recorded as the +// one it speaks. +func negotiatedProtocolVersion(ss *ServerSession) string { + ss.mu.Lock() + defer ss.mu.Unlock() + return ss.state.NegotiatedProtocolVersion +} + +// TestServerHandle_RecordsNegotiatedVersionOnNewProtocolCall asserts that the +// first new-protocol call a session serves records the declared version as the +// negotiated one, as declared and not through the handshake's negotiation. +func TestServerHandle_RecordsNegotiatedVersionOnNewProtocolCall(t *testing.T) { + ctx := context.Background() + server := NewServer(testImpl, nil) + _, st := NewInMemoryTransports() + ss, err := server.Connect(ctx, st, nil) + if err != nil { + t.Fatalf("server.Connect: %v", err) + } + defer ss.Close() + + params, err := json.Marshal(newProtocolParams(nil)) + if err != nil { + t.Fatalf("marshalling params: %v", err) + } + if _, err := ss.handle(ctx, &jsonrpc.Request{ + ID: jsonrpc2.Int64ID(1), + Method: methodListTools, + Params: params, + }); err != nil { + t.Fatalf("handle(%q) error = %v", methodListTools, err) + } + if got := negotiatedProtocolVersion(ss); got != protocolVersion20260728 { + t.Errorf("NegotiatedProtocolVersion = %q, want %q", got, protocolVersion20260728) + } + if got := ss.InitializeParams(); got == nil || got.ProtocolVersion != protocolVersion20260728 { + t.Errorf("InitializeParams = %+v, want ProtocolVersion %q", got, protocolVersion20260728) + } +} + +// TestServerDiscover_RecordsNegotiatedVersion asserts that server/discover +// records the version it was asked about as the negotiated one when its answer +// lists that version, since a client whose version is listed keeps speaking it. +func TestServerDiscover_RecordsNegotiatedVersion(t *testing.T) { + ctx := context.Background() + server := NewServer(testImpl, nil) + _, st := NewInMemoryTransports() + ss, err := server.Connect(ctx, st, nil) + if err != nil { + t.Fatalf("server.Connect: %v", err) + } + defer ss.Close() + + params, err := json.Marshal(newProtocolParams(nil)) + if err != nil { + t.Fatalf("marshalling params: %v", err) + } + res, err := ss.handle(ctx, &jsonrpc.Request{ + ID: jsonrpc2.Int64ID(1), + Method: methodDiscover, + Params: params, + }) + if err != nil { + t.Fatalf("handle(%q) error = %v", methodDiscover, err) + } + dres, ok := res.(*DiscoverResult) + if !ok { + t.Fatalf("handle(%q) returned %T, want *DiscoverResult", methodDiscover, res) + } + if !slices.Contains(dres.SupportedVersions, protocolVersion20260728) { + t.Fatalf("DiscoverResult.SupportedVersions = %v, want it to list %q", dres.SupportedVersions, protocolVersion20260728) + } + if got := negotiatedProtocolVersion(ss); got != protocolVersion20260728 { + t.Errorf("NegotiatedProtocolVersion = %q, want %q", got, protocolVersion20260728) + } +} + +// versionFilteringTransport narrows the versions a transport serves, the way +// a stateful StreamableHTTPHandler declines every version from 2026-07-28 on. +type versionFilteringTransport struct { + Transport + serves func(version string) bool +} + +func (t *versionFilteringTransport) SupportsProtocolVersion(version string) bool { + return t.serves(version) +} + +// TestServerDiscover_UnservedVersionRecordsNothing asserts that a discover +// request declaring a version this transport does not serve records neither +// InitializeParams nor a negotiated version, so the safety net can close it. +func TestServerDiscover_UnservedVersionRecordsNothing(t *testing.T) { + ctx := context.Background() + server := NewServer(testImpl, nil) + _, st := NewInMemoryTransports() + legacyOnly := &versionFilteringTransport{ + Transport: st, + serves: func(version string) bool { return version < protocolVersion20260728 }, + } + ss, err := server.Connect(ctx, legacyOnly, nil) + if err != nil { + t.Fatalf("server.Connect: %v", err) + } + defer ss.Close() + + params, err := json.Marshal(newProtocolParams(nil)) + if err != nil { + t.Fatalf("marshalling params: %v", err) + } + res, err := ss.handle(ctx, &jsonrpc.Request{ + ID: jsonrpc2.Int64ID(1), + Method: methodDiscover, + Params: params, + }) + if err != nil { + t.Fatalf("handle(%q) error = %v", methodDiscover, err) + } + dres, ok := res.(*DiscoverResult) + if !ok { + t.Fatalf("handle(%q) returned %T, want *DiscoverResult", methodDiscover, res) + } + if slices.Contains(dres.SupportedVersions, protocolVersion20260728) { + t.Fatalf("DiscoverResult.SupportedVersions = %v, want the transport's filter applied", dres.SupportedVersions) + } + if got := ss.InitializeParams(); got != nil { + t.Errorf("InitializeParams = %+v, want nil for a version this transport does not serve", got) + } + if got := negotiatedProtocolVersion(ss); got != "" { + t.Errorf("NegotiatedProtocolVersion = %q, want none recorded", got) + } +} + // TestServerUnknownProtocolVersion_NewProtocol verifies that a request whose // `_meta.protocolVersion` names a version the SDK does not know is rejected // with [CodeUnsupportedProtocolVersion], and not served as a legacy handshake diff --git a/mcp/session.go b/mcp/session.go index 08eccafd..c7c76c79 100644 --- a/mcp/session.go +++ b/mcp/session.go @@ -22,11 +22,9 @@ type ServerSessionState struct { // InitializedParams are the parameters from 'notifications/initialized'. InitializedParams *InitializedParams `json:"initializedParams"` - // NegotiatedProtocolVersion is the protocol version agreed during - // 'initialize', which may differ from the version the client requested if - // the server does not support it. - // - // It is empty for sessions that never ran the initialize handshake. + // NegotiatedProtocolVersion is the protocol version the session speaks: the + // one 'initialize' settled on, or the one recorded from a new-protocol + // request, a 'server/discover' answer listing it, or the version header. NegotiatedProtocolVersion string `json:"negotiatedProtocolVersion,omitempty"` // LogLevel is the logging level for the session. diff --git a/mcp/streamable.go b/mcp/streamable.go index f24badbb..0f940401 100644 --- a/mcp/streamable.go +++ b/mcp/streamable.go @@ -508,6 +508,10 @@ func (h *StreamableHTTPHandler) ephemeralConnectOpts(req *http.Request) (*epheme state.InitializeParams = &InitializeParams{ ProtocolVersion: protocolVersion, } + // The header carries the version an earlier handshake settled on, or the + // 2025-03-26 the transports spec has a server assume without one; either + // way it is the version this request is served under, not only declared. + state.NegotiatedProtocolVersion = protocolVersion } if !hasInitialized && !usesNewProtocol { state.InitializedParams = new(InitializedParams) diff --git a/mcp/streamable_test.go b/mcp/streamable_test.go index 7af9dffd..49e4d4c9 100644 --- a/mcp/streamable_test.go +++ b/mcp/streamable_test.go @@ -3428,6 +3428,16 @@ func TestEphemeralConnectOpts(t *testing.T) { t.Errorf("InitializedParams non-nil = %v, want %v (value = %+v)", got, tt.wantInitializedParams, info.opts.State.InitializedParams) } + // The header names the version an earlier handshake settled on, so + // synthesized state records it as negotiated and not only as + // declared; state that synthesizes no handshake records no version. + var wantNegotiated string + if tt.wantInitializeParams { + wantNegotiated = pver + } + if got := info.opts.State.NegotiatedProtocolVersion; got != wantNegotiated { + t.Errorf("NegotiatedProtocolVersion = %q, want %q", got, wantNegotiated) + } }) } } diff --git a/mcp/transport_test.go b/mcp/transport_test.go index a20ee5f6..dadf196b 100644 --- a/mcp/transport_test.go +++ b/mcp/transport_test.go @@ -119,6 +119,15 @@ func TestIOConnRead(t *testing.T) { requested: protocolVersion20241105, protocolVersion: protocolVersion20251125, }, + { + // A SEP-2575 session runs no initialize. The version its first call + // declared is recorded as negotiated once the server accepts it, and + // the connection follows it like any version from 2025-06-18 on. + name: "batching on a new-protocol session", + input: `[{"jsonrpc":"2.0","id":1,"method":"test1"},{"jsonrpc":"2.0","id":2,"method":"test2"}]`, + want: "JSON-RPC batching is not supported in 2025-06-18 and later (request version: 2026-07-28)", + protocolVersion: protocolVersion20260728, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) {