Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 9 additions & 7 deletions mcp/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -949,15 +949,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{
Expand Down Expand Up @@ -2011,6 +2009,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
})
}
}
Expand Down
132 changes: 132 additions & 0 deletions mcp/server_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2503,6 +2503,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
Expand Down
8 changes: 3 additions & 5 deletions mcp/session.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
4 changes: 4 additions & 0 deletions mcp/streamable.go
Original file line number Diff line number Diff line change
Expand Up @@ -531,6 +531,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)
Expand Down
10 changes: 10 additions & 0 deletions mcp/streamable_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
})
}
}
Expand Down
9 changes: 9 additions & 0 deletions mcp/transport_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
Loading