From 21c18c6229e1c6d1d53d9a57475a2f65cc508cf3 Mon Sep 17 00:00:00 2001 From: Guglielmo Colombo Date: Fri, 28 Aug 2026 16:06:10 +0200 Subject: [PATCH] mcp: allow restricting the protocol versions a server supports (#1199) Fixes #945 --- docs/protocol.md | 14 +++ internal/docs/protocol.src.md | 14 +++ mcp/server.go | 69 +++++++++++---- mcp/server_test.go | 161 ++++++++++++++++++++++++++++++++++ mcp/session.go | 7 ++ mcp/shared.go | 30 +++++-- mcp/sse_test.go | 2 +- mcp/streamable.go | 22 +++-- mcp/streamable_test.go | 77 ++++++++++++++++ mcp/transport.go | 8 +- mcp/transport_test.go | 28 +++++- 11 files changed, 397 insertions(+), 35 deletions(-) diff --git a/docs/protocol.md b/docs/protocol.md index 47ae2c3..f60371b 100644 --- a/docs/protocol.md +++ b/docs/protocol.md @@ -128,6 +128,20 @@ request. Servers implementing `2026-07-28` MUST implement it. fails or the server does not support the latest version, the client falls back to the legacy `initialize` handshake. +A server advertises and negotiates every protocol version the SDK supports; +set [`ServerOptions.SupportedProtocolVersions`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ServerOptions) +to narrow that set, for example to stop serving a revision your deployment has +retired. + +```go +server := mcp.NewServer(&mcp.Implementation{Name: "server", Version: "v1.0.0"}, &mcp.ServerOptions{ + SupportedProtocolVersions: []string{"2026-07-28", "2025-11-25"}, +}) +``` + +The set can only narrow support, never widen it; the field's documentation +covers how each protocol era answers a request at an excluded version. + ### Per-request metadata keys When the negotiated protocol version is `2026-07-28` or later, every request diff --git a/internal/docs/protocol.src.md b/internal/docs/protocol.src.md index 446b555..dbc8c6e 100644 --- a/internal/docs/protocol.src.md +++ b/internal/docs/protocol.src.md @@ -72,6 +72,20 @@ request. Servers implementing `2026-07-28` MUST implement it. fails or the server does not support the latest version, the client falls back to the legacy `initialize` handshake. +A server advertises and negotiates every protocol version the SDK supports; +set [`ServerOptions.SupportedProtocolVersions`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ServerOptions) +to narrow that set, for example to stop serving a revision your deployment has +retired. + +```go +server := mcp.NewServer(&mcp.Implementation{Name: "server", Version: "v1.0.0"}, &mcp.ServerOptions{ + SupportedProtocolVersions: []string{"2026-07-28", "2025-11-25"}, +}) +``` + +The set can only narrow support, never widen it; the field's documentation +covers how each protocol era answers a request at an excluded version. + ### Per-request metadata keys When the negotiated protocol version is `2026-07-28` or later, every request diff --git a/mcp/server.go b/mcp/server.go index bfca13d..b935385 100644 --- a/mcp/server.go +++ b/mcp/server.go @@ -44,6 +44,10 @@ type Server struct { // fixed at creation impl *Implementation opts ServerOptions + // protocolVersions is the descending-ordered list of protocol versions + // this server advertises and negotiates: [supportedProtocolVersions], + // narrowed by [ServerOptions.SupportedProtocolVersions]. + protocolVersions []string mu sync.Mutex prompts *featureSet[*serverPrompt] @@ -169,6 +173,23 @@ type ServerOptions struct { // GetSessionID is not consulted when [StreamableHTTPOptions.Stateless] is // true, since stateless servers do not maintain sessions. GetSessionID func() string + + // SupportedProtocolVersions, if non-empty, restricts the protocol versions + // this server advertises. If empty, every version returned by + // [SupportedProtocolVersions] is used. + // The list can only narrow support, never widen it. + // A request using the >= 2026-07-28 protocol at an excluded version is + // rejected with error code [CodeUnsupportedProtocolVersion]. + // + // The legacy initialize handshake never rejects. The lifecycle spec + // requires the server to answer a request it does not support with a + // version it does, so an excluded version is answered with the newest + // listed version that handshake can negotiate, and the client is expected + // to disconnect when it cannot speak it. A list holding no such version + // (one restricted to 2026-07-28 and later) is answered with 2025-11-25, + // the newest handshake-era version, so that the client disconnects rather + // than reading the answer as a negotiation into the new protocol. + SupportedProtocolVersions []string } // NewServer creates a new MCP server. The resulting server has no features: @@ -205,6 +226,18 @@ func NewServer(impl *Implementation, options *ServerOptions) *Server { opts.GetSessionID = rand.Text } + protocolVersions := SupportedProtocolVersions() + if len(opts.SupportedProtocolVersions) > 0 { + for _, v := range opts.SupportedProtocolVersions { + if !slices.Contains(supportedProtocolVersions, v) { + panic(fmt.Errorf("unsupported protocol version %q", v)) + } + } + protocolVersions = slices.DeleteFunc(protocolVersions, func(v string) bool { + return !slices.Contains(opts.SupportedProtocolVersions, v) + }) + } + if opts.Logger == nil { // ensure we have a logger opts.Logger = ensureLogger(nil) } @@ -215,6 +248,7 @@ func NewServer(impl *Implementation, options *ServerOptions) *Server { s := &Server{ impl: impl, opts: opts, + protocolVersions: protocolVersions, prompts: newFeatureSet(func(p *serverPrompt) string { return p.prompt.Name }), tools: newFeatureSet(func(t *serverTool) string { return t.tool.Name }), resources: newFeatureSet(func(r *serverResource) string { return r.resource.URI }), @@ -886,7 +920,7 @@ func (s *Server) discover(_ context.Context, req *ServerRequest[*DiscoverParams] versions := req.Session.supportedVersions req.Session.mu.Unlock() if versions == nil { - versions = slices.Clone(supportedProtocolVersions) + versions = slices.Clone(s.protocolVersions) } // Read the request-scoped identity/capabilities before acquiring the // session lock: these accessors may fall back to Session.InitializeParams @@ -903,7 +937,7 @@ func (s *Server) discover(_ context.Context, req *ServerRequest[*DiscoverParams] // 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 supportedVersion := negotiateMutuallySupportedVersion(versions); supportedVersion >= protocolVersion20260728 { + if slices.ContainsFunc(versions, func(v string) bool { return v >= protocolVersion20260728 }) { req.Session.updateState(func(state *ServerSessionState) { state.InitializeParams = init }) @@ -917,16 +951,16 @@ func (s *Server) discover(_ context.Context, req *ServerRequest[*DiscoverParams] return res, nil } -// filterSupportedVersions returns the subset of [supportedProtocolVersions] -// that the Transport can serve. If t does not implement [ProtocolVersionSupporter], every -// SDK-supported version is included. -func filterSupportedVersions(t Transport) []string { +// filterSupportedVersions returns the subset of versions that the Transport +// can serve. If t does not implement [ProtocolVersionSupporter], every version +// is included. +func filterSupportedVersions(t Transport, versions []string) []string { pvs, ok := t.(ProtocolVersionSupporter) if !ok { - return slices.Clone(supportedProtocolVersions) + return slices.Clone(versions) } - out := make([]string, 0, len(supportedProtocolVersions)) - for _, v := range supportedProtocolVersions { + out := make([]string, 0, len(versions)) + for _, v := range versions { if pvs.SupportsProtocolVersion(v) { out = append(out, v) } @@ -1398,7 +1432,7 @@ func (s *Server) Connect(ctx context.Context, t Transport, opts *ServerSessionOp // but without the lock the Go memory model gives the read goroutine no // guarantee of seeing this write, and -race flags it. ss.mu.Lock() - ss.supportedVersions = filterSupportedVersions(t) + ss.supportedVersions = filterSupportedVersions(t, s.protocolVersions) ss.mu.Unlock() // Start keepalive before returning the session to avoid race conditions with Close. @@ -1492,7 +1526,7 @@ type ServerSession struct { mcpConn Connection keepaliveCancel context.CancelFunc - // supportedVersions is the subset of [supportedProtocolVersions] that the + // supportedVersions is the subset of [Server.protocolVersions] that the // transport can actually serve, computed once at connection time from // [ProtocolVersionSupporter] (if implemented by the transport) and used by // the SEP-2575 server/discover handler. @@ -1896,9 +1930,9 @@ func (ss *ServerSession) handle(ctx context.Context, req *jsonrpc.Request) (any, } if validatedMeta.usesNewProtocol && - !slices.Contains(supportedProtocolVersions, validatedMeta.initializeParams.ProtocolVersion) { + !slices.Contains(ss.server.protocolVersions, validatedMeta.initializeParams.ProtocolVersion) { data, _ := json.Marshal(UnsupportedProtocolVersionData{ - Supported: supportedProtocolVersions, + Supported: ss.server.protocolVersions, Requested: validatedMeta.initializeParams.ProtocolVersion, }) return nil, &jsonrpc.Error{ @@ -2021,12 +2055,17 @@ func (ss *ServerSession) initialize(ctx context.Context, params *InitializeParam if params == nil { return nil, fmt.Errorf("%w: \"params\" must be be provided", jsonrpc2.ErrInvalidParams) } - var wasInit bool + var ( + wasInit bool + negotiated string + ) ss.updateState(func(state *ServerSessionState) { wasInit = state.InitializeParams != nil if !wasInit { state.InitializeParams = params + state.NegotiatedProtocolVersion = negotiatedVersion(params.ProtocolVersion, ss.server.protocolVersions) } + negotiated = state.NegotiatedProtocolVersion }) if wasInit { ss.server.opts.Logger.Error("duplicate initialize request") @@ -2037,7 +2076,7 @@ func (ss *ServerSession) initialize(ctx context.Context, params *InitializeParam return &InitializeResult{ // TODO(rfindley): alter behavior when falling back to an older version: // reject unsupported features. - ProtocolVersion: negotiatedVersion(params.ProtocolVersion), + ProtocolVersion: negotiated, Capabilities: s.capabilities(), Instructions: s.opts.Instructions, ServerInfo: s.impl, diff --git a/mcp/server_test.go b/mcp/server_test.go index 75d7455..41f4951 100644 --- a/mcp/server_test.go +++ b/mcp/server_test.go @@ -1691,3 +1691,164 @@ func TestServerSession_RejectsServerInitiated(t *testing.T) { } } } + +// TestServerSupportedProtocolVersions verifies that +// [ServerOptions.SupportedProtocolVersions] narrows both the versions the +// server advertises in server/discover and the versions it negotiates, +// and that it cannot widen SDK support. +func TestServerSupportedProtocolVersions(t *testing.T) { + ctx := context.Background() + + t.Run("discover advertises the configured subset", func(t *testing.T) { + server := NewServer(testImpl, &ServerOptions{ + // Deliberately unordered: the server must advertise newest first. + SupportedProtocolVersions: []string{protocolVersion20251125, protocolVersion20260728}, + }) + var advertised []string + server.AddReceivingMiddleware(func(next MethodHandler) MethodHandler { + return func(ctx context.Context, method string, req Request) (Result, error) { + res, err := next(ctx, method, req) + if method == methodDiscover && err == nil { + advertised = res.(*DiscoverResult).SupportedVersions + } + return res, err + } + }) + + ct, st := NewInMemoryTransports() + ss, err := server.Connect(ctx, st, nil) + if err != nil { + t.Fatalf("server.Connect: %v", err) + } + defer ss.Close() + + cs, err := NewClient(testImpl, nil).Connect(ctx, ct, &ClientSessionOptions{ + ProtocolVersion: protocolVersion20260728, + }) + if err != nil { + t.Fatalf("client.Connect: %v", err) + } + defer cs.Close() + + want := []string{protocolVersion20260728, protocolVersion20251125} + if diff := cmp.Diff(want, advertised); diff != "" { + t.Errorf("DiscoverResult.SupportedVersions mismatch (-want +got):\n%s", diff) + } + }) + + t.Run("initialize negotiates within the configured subset", func(t *testing.T) { + tests := []struct { + name string + supported []string + client string + want string + }{ + { + name: "requested version supported", + supported: []string{protocolVersion20251125, protocolVersion20250618}, + client: protocolVersion20250618, + want: protocolVersion20250618, + }, + { + name: "requested version excluded", + supported: []string{protocolVersion20251125, protocolVersion20250618}, + client: protocolVersion20241105, + want: protocolVersion20251125, + }, + { + name: "fallback is the newest configured version", + supported: []string{protocolVersion20250326}, + client: protocolVersion20241105, + want: protocolVersion20250326, + }, + { + // Nothing the server supports can carry an initialize + // handshake, so the answer names a handshake-era version the + // client can act on rather than one it could mistake for the + // new protocol. + name: "server with no handshake version", + supported: []string{protocolVersion20260728}, + client: protocolVersion20250618, + want: protocolVersion20251125, + }, + { + name: "unrestricted server keeps the default behavior", + supported: nil, + client: protocolVersion20241105, + want: protocolVersion20241105, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + server := NewServer(testImpl, &ServerOptions{SupportedProtocolVersions: test.supported}) + ct, st := NewInMemoryTransports() + ss, err := server.Connect(ctx, st, nil) + if err != nil { + t.Fatalf("server.Connect: %v", err) + } + defer ss.Close() + + cs, err := NewClient(testImpl, nil).Connect(ctx, ct, &ClientSessionOptions{ + ProtocolVersion: test.client, + }) + if err != nil { + t.Fatalf("client.Connect: %v", err) + } + defer cs.Close() + + if got := cs.InitializeResult().ProtocolVersion; got != test.want { + t.Errorf("negotiated protocol version = %q, want %q", got, test.want) + } + }) + } + }) + + t.Run("unknown version panics", func(t *testing.T) { + defer func() { + if recover() == nil { + t.Error("NewServer did not panic on an unsupported protocol version") + } + }() + NewServer(testImpl, &ServerOptions{SupportedProtocolVersions: []string{"1999-01-01"}}) + }) +} + +// TestServerSupportedProtocolVersions_NewProtocol verifies that a request +// using the SEP-2575 per-request protocol is rejected with +// [CodeUnsupportedProtocolVersion] when its version is excluded by +// [ServerOptions.SupportedProtocolVersions]. +func TestServerSupportedProtocolVersions_NewProtocol(t *testing.T) { + ctx := context.Background() + server := NewServer(testImpl, &ServerOptions{ + SupportedProtocolVersions: []string{protocolVersion20251125}, + }) + ct, st := NewInMemoryTransports() + ss, err := server.Connect(ctx, st, nil) + if err != nil { + t.Fatalf("server.Connect: %v", err) + } + defer ss.Close() + _ = ct + + params := fmt.Sprintf(`{"_meta":{%q:%q,%q:{}}}`, + MetaKeyProtocolVersion, protocolVersion20260728, MetaKeyClientCapabilities) + _, err = ss.handle(ctx, &jsonrpc.Request{ + ID: jsonrpc2.Int64ID(1), + Method: methodListTools, + Params: json.RawMessage(params), + }) + var jerr *jsonrpc.Error + if !errors.As(err, &jerr) { + t.Fatalf("handle returned %v, want a *jsonrpc.Error", err) + } + if jerr.Code != CodeUnsupportedProtocolVersion { + t.Fatalf("error code = %d, want %d", jerr.Code, CodeUnsupportedProtocolVersion) + } + var data UnsupportedProtocolVersionData + if err := json.Unmarshal(jerr.Data, &data); err != nil { + t.Fatalf("unmarshal error data: %v", err) + } + if diff := cmp.Diff([]string{protocolVersion20251125}, data.Supported); diff != "" { + t.Errorf("UnsupportedProtocolVersionData.Supported mismatch (-want +got):\n%s", diff) + } +} diff --git a/mcp/session.go b/mcp/session.go index dcf9888..08eccaf 100644 --- a/mcp/session.go +++ b/mcp/session.go @@ -22,6 +22,13 @@ 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 string `json:"negotiatedProtocolVersion,omitempty"` + // LogLevel is the logging level for the session. LogLevel LoggingLevel `json:"logLevel"` diff --git a/mcp/shared.go b/mcp/shared.go index 5069a47..5d0c1c2 100644 --- a/mcp/shared.go +++ b/mcp/shared.go @@ -63,18 +63,38 @@ var supportedProtocolVersions = []string{ protocolVersion20241105, } +// SupportedProtocolVersions returns the protocol versions supported by this +// version of the SDK, newest first. +// +// Use it to discover the values accepted by +// [ServerOptions.SupportedProtocolVersions]. The returned slice is a copy: +// modifying it does not change what the SDK supports. +func SupportedProtocolVersions() []string { return slices.Clone(supportedProtocolVersions) } + // negotiatedVersion returns the effective protocol version to use, given a -// client version. -func negotiatedVersion(clientVersion string) string { - // In general, prefer to use the clientVersion, but if we don't support the - // client's version, use the latest version. +// client version and the versions the server supports, newest first. +func negotiatedVersion(clientVersion string, supported []string) string { + // In general, prefer to use the clientVersion, but if not supported + // by the server, use the latest version the server supports. // // Cap the supported versions at the legacy protocolVersion20251125, as this // method is used by the initialize method which is deprecated in // version protocolVersion20260728. - if slices.Contains(supportedProtocolVersions, clientVersion) && clientVersion < protocolVersion20260728 { + if slices.Contains(supported, clientVersion) && clientVersion < protocolVersion20260728 { return clientVersion } + for _, v := range supported { + if v < protocolVersion20260728 { + return v + } + } + // If the server was enforced to support only protocolVersion20260728 and + // later, the initialize function should not be called. + // If a client calls the initialize function, return the protocolVersion20251125. + // As according to spec (https://modelcontextprotocol.io/specification/2025-11-25/basic/lifecycle#version-negotiation): + // "If the server supports the requested protocol version, it MUST respond with the same version." + // "Otherwise, the server MUST respond with another protocol version it supports." + // "This SHOULD be the latest version supported by the server." return protocolVersion20251125 } diff --git a/mcp/sse_test.go b/mcp/sse_test.go index c0ff445..9186a9c 100644 --- a/mcp/sse_test.go +++ b/mcp/sse_test.go @@ -24,7 +24,7 @@ import ( // stdio and Streamable HTTP (see #1112). func TestSSEServerTransport_SupportedVersions(t *testing.T) { var tr SSEServerTransport - versions := filterSupportedVersions(&tr) + versions := filterSupportedVersions(&tr, supportedProtocolVersions) if slices.Contains(versions, protocolVersion20260728) { t.Errorf("filterSupportedVersions(SSEServerTransport) = %v, must not include %q", versions, protocolVersion20260728) diff --git a/mcp/streamable.go b/mcp/streamable.go index 8729bb7..b4a2a32 100644 --- a/mcp/streamable.go +++ b/mcp/streamable.go @@ -363,13 +363,21 @@ func (h *StreamableHTTPHandler) ServeHTTP(w http.ResponseWriter, req *http.Reque } // [§2.7] of the spec (2025-06-18): validate the MCP-Protocol-Version - // header. If provided, it must be a supported version. If absent, the - // version is unknown (the request may be an initialize for any version). + // header. If provided, it must be a version the server negotiates, which + // [ServerOptions.SupportedProtocolVersions] may have narrowed. If absent, + // the version is unknown (the request may be an initialize for any + // version). // // [§2.7]: https://modelcontextprotocol.io/specification/2025-06-18/basic/transports#protocol-version-header protocolVersion := req.Header.Get(protocolVersionHeader) - if protocolVersion != "" && !slices.Contains(supportedProtocolVersions, protocolVersion) && protocolVersion < protocolVersion20260728 { - http.Error(w, fmt.Sprintf("Bad Request: Unsupported protocol version (supported versions: %s)", strings.Join(supportedProtocolVersions, ",")), http.StatusBadRequest) + var supportedVersions []string + if server := h.getServer(req); server != nil { + supportedVersions = server.protocolVersions + } else { + supportedVersions = supportedProtocolVersions + } + if protocolVersion != "" && !slices.Contains(supportedVersions, protocolVersion) && protocolVersion < protocolVersion20260728 { + http.Error(w, fmt.Sprintf("Bad Request: Unsupported protocol version (supported versions: %s)", strings.Join(supportedVersions, ",")), http.StatusBadRequest) return } req = req.WithContext(context.WithValue(req.Context(), protocolVersionContextKey{}, protocolVersion)) @@ -1541,7 +1549,11 @@ func (c *streamableServerConn) servePOST(w http.ResponseWriter, req *http.Reques // Advertise only the legacy versions this stateful server accepts // (i.e. exclude 2026-07-28, since that's what the request asked for // and what we're rejecting). - legacyVersions := slices.DeleteFunc(slices.Clone(supportedProtocolVersions), func(v string) bool { + advertised := supportedProtocolVersions + if c.server != nil { + advertised = c.server.protocolVersions + } + legacyVersions := slices.DeleteFunc(slices.Clone(advertised), func(v string) bool { return v >= protocolVersion20260728 }) data, _ := json.Marshal(UnsupportedProtocolVersionData{ diff --git a/mcp/streamable_test.go b/mcp/streamable_test.go index 504cda6..ff96f61 100644 --- a/mcp/streamable_test.go +++ b/mcp/streamable_test.go @@ -4141,3 +4141,80 @@ func TestStreamableMaxRequestBodyBytes(t *testing.T) { }) } } + +// TestStreamableSupportedProtocolVersions_Header verifies that the +// MCP-Protocol-Version header is validated against +// [ServerOptions.SupportedProtocolVersions], and not merely against the +// versions the SDK knows about. +func TestStreamableSupportedProtocolVersions_Header(t *testing.T) { + const initBody = `{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{},"clientInfo":{"name":"test","version":"1.0"}}}` + + newServer := func(t *testing.T, stateless bool) *httptest.Server { + server := NewServer(testImpl, &ServerOptions{ + SupportedProtocolVersions: []string{protocolVersion20251125}, + }) + handler := NewStreamableHTTPHandler(func(*http.Request) *Server { return server }, + &StreamableHTTPOptions{Stateless: stateless}) + httpServer := httptest.NewServer(handler) + t.Cleanup(httpServer.Close) + return httpServer + } + post := func(t *testing.T, url, version, sessionID string) *http.Response { + t.Helper() + req, err := http.NewRequest(http.MethodPost, url, strings.NewReader(initBody)) + if err != nil { + t.Fatal(err) + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json, text/event-stream") + req.Header.Set(protocolVersionHeader, version) + if sessionID != "" { + req.Header.Set(sessionIDHeader, sessionID) + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + return resp + } + + for _, stateless := range []bool{false, true} { + for _, test := range []struct { + version string + wantStatus int + }{ + {protocolVersion20251125, http.StatusOK}, + // Supported by the SDK, but excluded by the server. + {protocolVersion20250618, http.StatusBadRequest}, + // Unknown to the SDK: rejected before any server is consulted. + {"1999-01-01", http.StatusBadRequest}, + } { + t.Run(fmt.Sprintf("stateless=%v/%s", stateless, test.version), func(t *testing.T) { + resp := post(t, newServer(t, stateless).URL, test.version, "") + defer resp.Body.Close() + body, _ := io.ReadAll(resp.Body) + if resp.StatusCode != test.wantStatus { + t.Errorf("status = %d, want %d; body = %s", resp.StatusCode, test.wantStatus, body) + } + }) + } + } + + t.Run("existing session", func(t *testing.T) { + httpServer := newServer(t, false) + resp := post(t, httpServer.URL, protocolVersion20251125, "") + io.Copy(io.Discard, resp.Body) + resp.Body.Close() + sessionID := resp.Header.Get(sessionIDHeader) + if sessionID == "" { + t.Fatalf("initialize response missing %s header", sessionIDHeader) + } + + resp = post(t, httpServer.URL, protocolVersion20250618, sessionID) + defer resp.Body.Close() + body, _ := io.ReadAll(resp.Body) + if resp.StatusCode != http.StatusBadRequest { + t.Errorf("status = %d, want %d; body = %s", resp.StatusCode, http.StatusBadRequest, body) + } + }) +} diff --git a/mcp/transport.go b/mcp/transport.go index d72f9d1..1cf0325 100644 --- a/mcp/transport.go +++ b/mcp/transport.go @@ -114,6 +114,8 @@ type clientConnection interface { // TODO: should this interface be exported? type serverConnection interface { Connection + + // sessionUpdated is called whenever the server session state changes. sessionUpdated(ServerSessionState) } @@ -525,17 +527,13 @@ func newIOConn(rwc io.ReadWriteCloser) *ioConn { func (c *ioConn) SessionID() string { return "" } func (c *ioConn) sessionUpdated(state ServerSessionState) { - protocolVersion := "" - if state.InitializeParams != nil { - protocolVersion = state.InitializeParams.ProtocolVersion - } + protocolVersion := state.NegotiatedProtocolVersion if protocolVersion == "" { // 2025-03-26 is used, because it's the last spec version // where specifying the protocol version in the HTTP header // was not required. protocolVersion = protocolVersion20250326 } - protocolVersion = negotiatedVersion(protocolVersion) c.sessionMu.Lock() c.protocolVersion = protocolVersion c.sessionMu.Unlock() diff --git a/mcp/transport_test.go b/mcp/transport_test.go index 730400e..52228d0 100644 --- a/mcp/transport_test.go +++ b/mcp/transport_test.go @@ -56,10 +56,15 @@ func TestBatchFraming(t *testing.T) { func TestIOConnRead(t *testing.T) { tests := []struct { - name string - input string - want string + name string + input string + want string + // protocolVersion is the version negotiated by 'initialize'; empty + // means no session state was pushed to the connection. protocolVersion string + // requested is the version the client asked for, which differs from + // protocolVersion when the server counter-offered. + requested string }{ { name: "valid json input", @@ -100,6 +105,16 @@ func TestIOConnRead(t *testing.T) { want: "JSON-RPC batching is not supported in 2025-06-18 and later (request version: 2025-06-18)", protocolVersion: protocolVersion20250618, }, + { + // The client asked for a version the server does not negotiate, so + // the connection must follow the server's counter-offer (which + // forbids batching) rather than the client's request. + name: "batching at a counter-offered version", + 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: 2025-11-25)", + requested: protocolVersion20241105, + protocolVersion: protocolVersion20251125, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -108,10 +123,15 @@ func TestIOConnRead(t *testing.T) { }) t.Cleanup(func() { tr.Close() }) if tt.protocolVersion != "" { + requested := tt.requested + if requested == "" { + requested = tt.protocolVersion + } tr.sessionUpdated(ServerSessionState{ InitializeParams: &InitializeParams{ - ProtocolVersion: tt.protocolVersion, + ProtocolVersion: requested, }, + NegotiatedProtocolVersion: tt.protocolVersion, }) } _, err := tr.Read(context.Background())