diff --git a/mcp/server.go b/mcp/server.go index c1ded2a2..899c67b3 100644 --- a/mcp/server.go +++ b/mcp/server.go @@ -2048,6 +2048,22 @@ func (ss *ServerSession) handle(ctx context.Context, req *jsonrpc.Request) (any, } } + // A server that supports only 2026-07-28 and later serves no legacy call, + // initialize included. + if !validatedMeta.usesNewProtocol && req.IsCall() && + !slices.ContainsFunc(ss.server.protocolVersions, func(v string) bool { + return v < protocolVersion20260728 + }) { + data, _ := json.Marshal(UnsupportedProtocolVersionData{ + Supported: ss.server.protocolVersions, + }) + return nil, &jsonrpc.Error{ + Code: CodeUnsupportedProtocolVersion, + Message: "legacy protocol version requests are not supported by this server", + Data: data, + } + } + switch req.Method { case methodInitialize, methodPing, notificationInitialized, notificationRootsListChanged, methodSetLevel, methodSubscribe, methodUnsubscribe: if validatedMeta.usesNewProtocol { diff --git a/mcp/server_test.go b/mcp/server_test.go index c3bfcee8..02a461c4 100644 --- a/mcp/server_test.go +++ b/mcp/server_test.go @@ -2128,16 +2128,6 @@ func TestServerSupportedProtocolVersions(t *testing.T) { 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, @@ -2220,7 +2210,93 @@ func TestServerSupportedProtocolVersions_NewProtocol(t *testing.T) { } } -// TestNotifySessionsIsolatesStalledPeer verifies that a session whose write +// TestServerSupportedProtocolVersions_InitializeLegacy verifies that a legacy +// initialize request is rejected with [CodeUnsupportedProtocolVersion], naming +// the supported versions, when [ServerOptions.SupportedProtocolVersions] +// leaves the server no version that has an initialize handshake. +func TestServerSupportedProtocolVersions_InitializeLegacy(t *testing.T) { + ctx := context.Background() + server := NewServer(testImpl, &ServerOptions{ + SupportedProtocolVersions: []string{protocolVersion20260728}, + }) + ct, st := NewInMemoryTransports() + ss, err := server.Connect(ctx, st, nil) + if err != nil { + t.Fatalf("server.Connect: %v", err) + } + defer ss.Close() + _ = ct + + params, err := json.Marshal(&InitializeParams{ + ProtocolVersion: protocolVersion20251125, + Capabilities: &ClientCapabilities{}, + ClientInfo: &Implementation{Name: "test", Version: "1"}, + }) + if err != nil { + t.Fatal(err) + } + _, err = ss.handle(ctx, &jsonrpc.Request{ + ID: jsonrpc2.Int64ID(1), + Method: methodInitialize, + 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{protocolVersion20260728}, data.Supported); diff != "" { + t.Errorf("UnsupportedProtocolVersionData.Supported mismatch (-want +got):\n%s", diff) + } + if ss.InitializeParams() != nil { + t.Errorf("session initialized after a rejected initialize: %+v", ss.InitializeParams()) + } +} + +// TestServerSupportedProtocolVersions_LegacyDispatch verifies that a legacy +// request (no _meta) is rejected with [CodeUnsupportedProtocolVersion] when +// the server only supports protocol versions >= 2026-07-28 via +// [ServerOptions.SupportedProtocolVersions]. +func TestServerSupportedProtocolVersions_LegacyDispatch(t *testing.T) { + ctx := context.Background() + server := NewServer(testImpl, &ServerOptions{ + SupportedProtocolVersions: []string{protocolVersion20260728}, + }) + ct, st := NewInMemoryTransports() + ss, err := server.Connect(ctx, st, nil) + if err != nil { + t.Fatalf("server.Connect: %v", err) + } + defer ss.Close() + _ = ct + + _, err = ss.handle(ctx, &jsonrpc.Request{ + ID: jsonrpc2.Int64ID(1), + Method: methodListTools, + Params: json.RawMessage(`{}`), + }) + 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{protocolVersion20260728}, data.Supported); diff != "" { + t.Errorf("UnsupportedProtocolVersionData.Supported mismatch (-want +got):\n%s", diff) + } +} + // stalls — here a peer that never reads its end of the pipe — does not delay // or fail delivery to the other sessions in the same broadcast. func TestNotifySessionsIsolatesStalledPeer(t *testing.T) { diff --git a/mcp/shared.go b/mcp/shared.go index f3b2a348..8cc11764 100644 --- a/mcp/shared.go +++ b/mcp/shared.go @@ -89,14 +89,10 @@ func negotiatedVersion(clientVersion string, supported []string) string { 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 + // A server that supports no version before protocolVersion20260728 has + // nothing to negotiate. [ServerSession.handle] rejects initialize on such + // a server with CodeUnsupportedProtocolVersion before it gets here. + return "" } // negotiateMutuallySupportedVersion returns a protocol version that is supported diff --git a/mcp/streamable.go b/mcp/streamable.go index 813a3824..a7ab2275 100644 --- a/mcp/streamable.go +++ b/mcp/streamable.go @@ -1212,10 +1212,10 @@ func (s *stream) keepAlive(ctx context.Context, interval time.Duration, committe // - InvalidParams (-32602), UnsupportedProtocolVersion (-32022) and // CodeMissingRequiredClientCapabilities (-32021) MUST // return HTTP 400. +// +// UnsupportedProtocolVersion is the one code mapped for every protocol +// version. func extractErrorStatus(ctx context.Context, msg jsonrpc.Message) int { - if protocolVersionFromContext(ctx) < protocolVersion20260728 { - return 0 - } resp, ok := msg.(*jsonrpc.Response) if !ok || resp.Error == nil { return 0 @@ -1224,10 +1224,20 @@ func extractErrorStatus(ctx context.Context, msg jsonrpc.Message) int { if !errors.As(resp.Error, &jerr) { return 0 } + // A legacy request gets this code only from a server that supports no + // version before 2026-07-28, which rejects it in ServerSession.handle + // before any handler runs, so nothing has been written to the response + // yet. + if jerr.Code == CodeUnsupportedProtocolVersion { + return http.StatusBadRequest + } + if protocolVersionFromContext(ctx) < protocolVersion20260728 { + return 0 + } switch jerr.Code { case jsonrpc.CodeMethodNotFound: return http.StatusNotFound - case jsonrpc.CodeInvalidParams, CodeUnsupportedProtocolVersion, CodeMissingRequiredClientCapabilities: + case jsonrpc.CodeInvalidParams, CodeMissingRequiredClientCapabilities: return http.StatusBadRequest } return 0 diff --git a/mcp/streamable_test.go b/mcp/streamable_test.go index a5d987ab..5ad4ae10 100644 --- a/mcp/streamable_test.go +++ b/mcp/streamable_test.go @@ -1757,6 +1757,53 @@ func TestStreamableStateless(t *testing.T) { testClientCompatibility(t, statelessHandler) } +// TestStreamableStateless_RejectsLegacyOn2026OnlyServer verifies that a +// stateless server configured with [ServerOptions.SupportedProtocolVersions] +// set to only ["2026-07-28"] rejects legacy-format requests (no _meta, no +// Mcp-Protocol-Version header) with [CodeUnsupportedProtocolVersion]. +func TestStreamableStateless_RejectsLegacyOn2026OnlyServer(t *testing.T) { + server := NewServer(testImpl, &ServerOptions{ + SupportedProtocolVersions: []string{protocolVersion20260728}, + }) + handler := NewStreamableHTTPHandler(func(*http.Request) *Server { return server }, &StreamableHTTPOptions{ + Stateless: true, + JSONResponse: true, + }) + defer handler.closeAll() + + ts := httptest.NewServer(handler) + defer ts.Close() + + body, _ := jsonrpc2.EncodeMessage(req(1, methodListTools, &ListToolsParams{})) + httpReq, err := http.NewRequest(http.MethodPost, ts.URL, bytes.NewReader(body)) + if err != nil { + t.Fatalf("NewRequest: %v", err) + } + httpReq.Header.Set("Content-Type", "application/json") + httpReq.Header.Set("Accept", "application/json, text/event-stream") + resp, err := http.DefaultClient.Do(httpReq) + if err != nil { + t.Fatalf("POST failed: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusBadRequest { + t.Errorf("status = %d, want %d", resp.StatusCode, http.StatusBadRequest) + } + got, _ := io.ReadAll(resp.Body) + var errResp struct { + Error struct { + Code int64 `json:"code"` + } `json:"error"` + } + if err := json.Unmarshal(got, &errResp); err != nil { + t.Fatalf("unmarshal response: %v\nbody: %s", err, got) + } + if errResp.Error.Code != CodeUnsupportedProtocolVersion { + t.Errorf("error code = %d, want %d", errResp.Error.Code, CodeUnsupportedProtocolVersion) + } +} + func textContent(t *testing.T, res *CallToolResult) string { t.Helper() if len(res.Content) != 1 {