diff --git a/docs/mcpgodebug.md b/docs/mcpgodebug.md index f1e62373..626be248 100644 --- a/docs/mcpgodebug.md +++ b/docs/mcpgodebug.md @@ -39,6 +39,12 @@ Options listed below were added and will be removed in the 1.9.0 version of the restoring the previous behavior. The default behavior was changed so that stateless servers ignore session IDs entirely and reject `DELETE` with 405. +- `nomethodnotfoundcodeinerror` added. If set to `1`, the jsonrpc2 layer will not + include the MethodNotFound Error (`-32601`) in the error response when the + requested method in STDIO transport is not found. The default behavior was + changed to include the MethodNotFound Error in the error response when the + requested method in STDIO transport is not found. + ### 1.6.0 Options listed below were added and will be removed in the 1.8.0 version of the SDK. diff --git a/internal/docs/mcpgodebug.src.md b/internal/docs/mcpgodebug.src.md index 88639a26..01ecba38 100644 --- a/internal/docs/mcpgodebug.src.md +++ b/internal/docs/mcpgodebug.src.md @@ -38,6 +38,12 @@ Options listed below were added and will be removed in the 1.9.0 version of the restoring the previous behavior. The default behavior was changed so that stateless servers ignore session IDs entirely and reject `DELETE` with 405. +- `nomethodnotfoundcodeinerror` added. If set to `1`, the jsonrpc2 layer will not + include the MethodNotFound Error (`-32601`) in the error response when the + requested method in STDIO transport is not found. The default behavior was + changed to include the MethodNotFound Error in the error response when the + requested method in STDIO transport is not found. + ### 1.6.0 Options listed below were added and will be removed in the 1.8.0 version of the SDK. diff --git a/internal/jsonrpc2/conn.go b/internal/jsonrpc2/conn.go index df6ef5e7..3b4bc57a 100644 --- a/internal/jsonrpc2/conn.go +++ b/internal/jsonrpc2/conn.go @@ -14,8 +14,25 @@ import ( "time" "github.com/modelcontextprotocol/go-sdk/internal/json" + "github.com/modelcontextprotocol/go-sdk/internal/mcpgodebug" ) +// nomethodnotfoundcodeinerror is a compatibility parameter that restores the +// pre-fix behavior of [processResult], where wrapped [ErrNotHandled] or +// [ErrMethodNotFound] errors returned by request handlers were not +// recognized as "method not found" signals. The original switch statement +// compared sentinel errors with ==, which never matched errors returned via +// fmt.Errorf("%w: ...", ErrNotHandled, ...) — including the ones produced +// by checkRequest. As a result the wire error response carried code 0 +// instead of code -32601. The fix uses errors.Is to recognize wrapped +// sentinels and append the method name to the message. +// +// To restore the previous behavior, set MCPGODEBUG=nomethodnotfoundcodeinerror=1. +// This option will be removed in a future SDK version. +// See the documentation for the mcpgodebug package for instructions on how +// to use it. +var nomethodnotfoundcodeinerror = mcpgodebug.Value("nomethodnotfoundcodeinerror") + // Connection manages the jsonrpc2 protocol, connecting responses back to their // calls. Connection is bidirectional; it does not have a designated server or // client end. @@ -648,9 +665,7 @@ func (c *Connection) handleAsync() { // processResult processes the result of a request and, if appropriate, sends a response. func (c *Connection) processResult(from any, req *incomingRequest, result any, err error) error { - switch err { - case ErrNotHandled, ErrMethodNotFound: - // Add detail describing the unhandled method. + if nomethodnotfoundcodeinerror != "1" && (errors.Is(err, ErrNotHandled) || errors.Is(err, ErrMethodNotFound)) { err = fmt.Errorf("%w: %q", ErrMethodNotFound, req.Method) } diff --git a/internal/jsonrpc2/wire.go b/internal/jsonrpc2/wire.go index b0beae02..1215f140 100644 --- a/internal/jsonrpc2/wire.go +++ b/internal/jsonrpc2/wire.go @@ -33,7 +33,7 @@ var ( // ErrServerClosing is returned for calls that arrive while the server is closing. ErrServerClosing = NewError(-32006, "server is closing") // ErrClientClosing is a dummy error returned for calls initiated while the client is closing. - ErrClientClosing = NewError(-32003, "client is closing") + ErrClientClosing = NewError(-32007, "client is closing") // The following errors have special semantics for MCP transports diff --git a/mcp/client_test.go b/mcp/client_test.go index 1d629521..f8d1383a 100644 --- a/mcp/client_test.go +++ b/mcp/client_test.go @@ -8,6 +8,7 @@ import ( "context" "fmt" "log/slog" + "slices" "sync/atomic" "testing" @@ -677,6 +678,17 @@ func TestClientConnectDiscover(t *testing.T) { wantInitialize: true, wantVersion: latestProtocolVersion, }, + { + name: "unsupported protocol version falls back to initialize", + discoverHandler: func() (Result, error) { + return nil, &jsonrpc.Error{ + Code: CodeUnsupportedProtocolVersion, + Message: "unsupported protocol version", + } + }, + wantInitialize: true, + wantVersion: latestProtocolVersion, + }, { name: "no overlapping supported version falls back to initialize", discoverHandler: func() (Result, error) { @@ -772,6 +784,10 @@ func TestClientConnectDiscover(t *testing.T) { // request sent by Client.Connect carries the SEP-2575 per-request _meta triple: // protocolVersion, clientInfo, and clientCapabilities. func TestClientConnectDiscover_RequestContents(t *testing.T) { + orig := supportedProtocolVersions + supportedProtocolVersions = append([]string{protocolVersion20260630}, slices.Clone(orig)...) + t.Cleanup(func() { supportedProtocolVersions = orig }) + ctx := context.Background() type captured struct { @@ -841,3 +857,242 @@ func TestClientConnectDiscover_RequestContents(t *testing.T) { t.Errorf("clientCapabilities.sampling missing (CreateMessageHandler was set); got %v", caps) } } + +// TestInMemory_E2E_DiscoverSuccess is a full end-to-end smoke test for +// SEP-2575 over a non-HTTP transport. +func TestInMemory_E2E_DiscoverSuccess(t *testing.T) { + ctx := context.Background() + + orig := supportedProtocolVersions + supportedProtocolVersions = append([]string{protocolVersion20260630}, slices.Clone(orig)...) + t.Cleanup(func() { supportedProtocolVersions = orig }) + + server := NewServer(&Implementation{Name: "stdio-like-server", Version: "v1"}, nil) + ct, st := NewInMemoryTransports() + ss, err := server.Connect(ctx, st, nil) + if err != nil { + t.Fatalf("server.Connect: %v", err) + } + defer ss.Close() + + client := NewClient(&Implementation{Name: "stdio-like-client", Version: "v1"}, nil) + cs, err := client.Connect(ctx, ct, &ClientSessionOptions{protocolVersion: protocolVersion20260630}) + if err != nil { + t.Fatalf("client.Connect: %v", err) + } + defer cs.Close() + + ir := cs.InitializeResult() + if ir == nil { + t.Fatal("InitializeResult is nil; discover should have populated it") + } + if ir.ProtocolVersion != protocolVersion20260630 { + t.Errorf("InitializeResult.ProtocolVersion = %q, want %q (negotiated via discover, no initialize)", + ir.ProtocolVersion, protocolVersion20260630) + } + if ir.ServerInfo == nil || ir.ServerInfo.Name != "stdio-like-server" { + t.Errorf("InitializeResult.ServerInfo = %+v, want name=stdio-like-server", ir.ServerInfo) + } + + // Prove the session is usable. + if _, err := cs.ListTools(ctx, nil); err != nil { + t.Errorf("ListTools after discover: %v", err) + } +} + +// TestInMemory_E2E_DiscoverFallback_NoOverlap verifies the fallback path +// over an InMemory (STDIO-equivalent) transport: the client probes with +// _meta.protocolVersion = 2026-06-30, but the server's supported list does +// NOT include that version (the default for an SDK server that hasn't +// shimmed supportedProtocolVersions). +func TestInMemory_E2E_DiscoverFallback_NoOverlap(t *testing.T) { + ctx := context.Background() + orig := supportedProtocolVersions + supportedProtocolVersions = append([]string{protocolVersion20260630}, slices.Clone(orig)...) + t.Cleanup(func() { supportedProtocolVersions = orig }) + + server := NewServer(&Implementation{Name: "vpre-like-server", Version: "v1"}, nil) + // Intercept discover and reply as if we were a server that only + // supports legacy versions. + server.AddReceivingMiddleware(func(next MethodHandler) MethodHandler { + return func(ctx context.Context, method string, req Request) (Result, error) { + if method == methodDiscover { + return &DiscoverResult{ + SupportedVersions: []string{protocolVersion20251125}, + Capabilities: &ServerCapabilities{}, + ServerInfo: &Implementation{Name: "vpre-like-server", Version: "v1"}, + }, nil + } + return next(ctx, method, req) + } + }) + + ct, st := NewInMemoryTransports() + ss, err := server.Connect(ctx, st, nil) + if err != nil { + t.Fatalf("server.Connect: %v", err) + } + defer ss.Close() + + client := NewClient(&Implementation{Name: "new-client", Version: "v1"}, nil) + cs, err := client.Connect(ctx, ct, &ClientSessionOptions{protocolVersion: protocolVersion20260630}) + if err != nil { + t.Fatalf("client.Connect: %v", err) + } + defer cs.Close() + + ir := cs.InitializeResult() + if ir == nil { + t.Fatal("InitializeResult is nil after fallback initialize") + } + if ir.ProtocolVersion != latestProtocolVersion { + t.Errorf("InitializeResult.ProtocolVersion = %q, want %q (legacy fallback after no-overlap discover)", + ir.ProtocolVersion, latestProtocolVersion) + } + + // Prove the session is usable after fallback. + if _, err := cs.ListTools(ctx, nil); err != nil { + t.Errorf("ListTools after fallback initialize: %v", err) + } +} + +// TestInMemory_E2E_DiscoverFallback_MethodNotFound verifies the fallback +// path over InMemory when the server doesn't know about server/discover at +// all (simulating a true pre-SEP-2575 server). +func TestInMemory_E2E_DiscoverFallback_MethodNotFound(t *testing.T) { + ctx := context.Background() + + orig := supportedProtocolVersions + supportedProtocolVersions = append([]string{protocolVersion20260630}, slices.Clone(orig)...) + t.Cleanup(func() { supportedProtocolVersions = orig }) + + server := NewServer(&Implementation{Name: "vpre-server", Version: "v1"}, nil) + server.AddReceivingMiddleware(func(next MethodHandler) MethodHandler { + return func(ctx context.Context, method string, req Request) (Result, error) { + if method == methodDiscover { + return nil, jsonrpc2.ErrMethodNotFound + } + return next(ctx, method, req) + } + }) + + ct, st := NewInMemoryTransports() + ss, err := server.Connect(ctx, st, nil) + if err != nil { + t.Fatalf("server.Connect: %v", err) + } + defer ss.Close() + + client := NewClient(&Implementation{Name: "new-client", Version: "v1"}, nil) + cs, err := client.Connect(ctx, ct, &ClientSessionOptions{protocolVersion: protocolVersion20260630}) + if err != nil { + t.Fatalf("client.Connect: %v", err) + } + defer cs.Close() + + ir := cs.InitializeResult() + if ir == nil { + t.Fatal("InitializeResult is nil after fallback initialize") + } + if ir.ProtocolVersion != latestProtocolVersion { + t.Errorf("InitializeResult.ProtocolVersion = %q, want %q (legacy fallback after MethodNotFound)", + ir.ProtocolVersion, latestProtocolVersion) + } + + if _, err := cs.ListTools(ctx, nil); err != nil { + t.Errorf("ListTools after fallback initialize: %v", err) + } +} + +// TestInMemory_E2E_DiscoverFallback_UnsupportedProtocolVersion verifies the +// fallback path when the server explicitly rejects the discover probe with +// CodeUnsupportedProtocolVersion (the structured SEP-2575 signal). This +// exercises Path A of the fallback logic in client.go. +func TestInMemory_E2E_DiscoverFallback_UnsupportedProtocolVersion(t *testing.T) { + ctx := context.Background() + + orig := supportedProtocolVersions + supportedProtocolVersions = append([]string{protocolVersion20260630}, slices.Clone(orig)...) + t.Cleanup(func() { supportedProtocolVersions = orig }) + + server := NewServer(&Implementation{Name: "strict-server", Version: "v1"}, nil) + server.AddReceivingMiddleware(func(next MethodHandler) MethodHandler { + return func(ctx context.Context, method string, req Request) (Result, error) { + if method == methodDiscover { + return nil, &jsonrpc.Error{ + Code: CodeUnsupportedProtocolVersion, + Message: "unsupported protocol version", + } + } + return next(ctx, method, req) + } + }) + + ct, st := NewInMemoryTransports() + ss, err := server.Connect(ctx, st, nil) + if err != nil { + t.Fatalf("server.Connect: %v", err) + } + defer ss.Close() + + client := NewClient(&Implementation{Name: "new-client", Version: "v1"}, nil) + cs, err := client.Connect(ctx, ct, &ClientSessionOptions{protocolVersion: protocolVersion20260630}) + if err != nil { + t.Fatalf("client.Connect: %v", err) + } + defer cs.Close() + + ir := cs.InitializeResult() + if ir == nil { + t.Fatal("InitializeResult is nil after fallback initialize") + } + if ir.ProtocolVersion != latestProtocolVersion { + t.Errorf("InitializeResult.ProtocolVersion = %q, want %q (legacy fallback after UnsupportedProtocolVersion)", + ir.ProtocolVersion, latestProtocolVersion) + } +} + +// TestInMemory_E2E_DiscoverPropagatesOtherErrors verifies that an unrelated +// error from the discover handler aborts Connect and does NOT silently +// fall back. +func TestInMemory_E2E_DiscoverPropagatesOtherErrors(t *testing.T) { + ctx := context.Background() + + orig := supportedProtocolVersions + supportedProtocolVersions = append([]string{protocolVersion20260630}, slices.Clone(orig)...) + t.Cleanup(func() { supportedProtocolVersions = orig }) + + var sawInitialize atomic.Bool + server := NewServer(&Implementation{Name: "broken-server", Version: "v1"}, nil) + server.AddReceivingMiddleware(func(next MethodHandler) MethodHandler { + return func(ctx context.Context, method string, req Request) (Result, error) { + switch method { + case methodDiscover: + return nil, &jsonrpc.Error{ + Code: jsonrpc.CodeInternalError, + Message: "boom", + } + case methodInitialize: + sawInitialize.Store(true) + } + return next(ctx, method, req) + } + }) + + ct, st := NewInMemoryTransports() + ss, err := server.Connect(ctx, st, nil) + if err != nil { + t.Fatalf("server.Connect: %v", err) + } + defer ss.Close() + + client := NewClient(&Implementation{Name: "new-client", Version: "v1"}, nil) + cs, err := client.Connect(ctx, ct, &ClientSessionOptions{protocolVersion: protocolVersion20260630}) + if err == nil { + _ = cs.Close() + t.Fatal("Connect succeeded; want propagated discover error") + } + if sawInitialize.Load() { + t.Error("server received initialize; Connect should have aborted on the discover error") + } +} diff --git a/mcp/mrtr_test.go b/mcp/mrtr_test.go index e1e61b2a..230eb3bf 100644 --- a/mcp/mrtr_test.go +++ b/mcp/mrtr_test.go @@ -526,27 +526,6 @@ func TestMultiRoundTrip_ReadResource_ManualRetry(t *testing.T) { func mustConnect(t *testing.T, s *Server, clientOpts *ClientOptions) *ClientSession { t.Helper() - // The mrtr tests require negotiating the 2026-06-30 protocol version. - // Server.discover is currently a stub that returns ErrMethodNotFound, which - // would cause Client.Connect to fall back to the legacy initialize handshake - // and downgrade the negotiated version. Install a receiving middleware that - // answers server/discover with a DiscoverResult advertising 2026-06-30 so - // the client can negotiate the new protocol via the discover path. - // - // TODO: Remove this once the server has a proper discover implementation. - s.AddReceivingMiddleware(func(next MethodHandler) MethodHandler { - return func(ctx context.Context, method string, req Request) (Result, error) { - if method == methodDiscover { - return &DiscoverResult{ - SupportedVersions: []string{protocolVersion20260630}, - Capabilities: &ServerCapabilities{}, - ServerInfo: testImpl, - }, nil - } - return next(ctx, method, req) - } - }) - st, ct := NewInMemoryTransports() ss, err := s.Connect(t.Context(), st, nil) if err != nil { diff --git a/mcp/protocol.go b/mcp/protocol.go index f5b79aeb..d838067b 100644 --- a/mcp/protocol.go +++ b/mcp/protocol.go @@ -2103,3 +2103,14 @@ const ( // MetaKeyClientCapabilities carries the client's [ClientCapabilities]. MetaKeyClientCapabilities = "io.modelcontextprotocol/clientCapabilities" ) + +// UnsupportedProtocolVersionData is the SEP-2575 payload carried in the +// `data` field of a JSON-RPC error response with code +// [CodeUnsupportedProtocolVersion]. The server uses it to advertise which +// versions it supports so the client can pick a mutually supported one. +type UnsupportedProtocolVersionData struct { + // Supported is the list of protocol versions the server supports. + Supported []string `json:"supported"` + // Requested is the protocol version the client asked for. + Requested string `json:"requested"` +} diff --git a/mcp/server.go b/mcp/server.go index ba03fbfe..3daa68a8 100644 --- a/mcp/server.go +++ b/mcp/server.go @@ -766,9 +766,45 @@ func (s *Server) getPrompt(ctx context.Context, req *GetPromptRequest) (*GetProm // discover is the server-side handler for the SEP-2575 "server/discover" RPC. // -// TODO: Complete implementation. -func (s *Server) discover(context.Context, *ServerRequest[*DiscoverParams]) (*DiscoverResult, error) { - return nil, jsonrpc2.ErrMethodNotFound +// It returns the protocol versions supported by the underlying transport, +// the server's capabilities, the server's identity, and the server's +// instructions, allowing clients to negotiate without performing the legacy +// initialize handshake. +func (s *Server) discover(_ context.Context, req *ServerRequest[*DiscoverParams]) (*DiscoverResult, error) { + versions := req.Session.supportedVersions + if versions == nil { + versions = slices.Clone(supportedProtocolVersions) + } + req.Session.updateState(func(state *ServerSessionState) { + state.InitializeParams = &InitializeParams{ + ProtocolVersion: req.ProtocolVersion(), + Capabilities: req.ClientCapabilities(), + ClientInfo: req.ClientInfo(), + } + }) + return &DiscoverResult{ + SupportedVersions: versions, + Capabilities: s.capabilities(), + ServerInfo: s.impl, + Instructions: s.opts.Instructions, + }, 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 { + pvs, ok := t.(ProtocolVersionSupporter) + if !ok { + return slices.Clone(supportedProtocolVersions) + } + out := make([]string, 0, len(supportedProtocolVersions)) + for _, v := range supportedProtocolVersions { + if pvs.SupportsProtocolVersion(v) { + out = append(out, v) + } + } + return out } func (s *Server) listTools(_ context.Context, req *ListToolsRequest) (*ListToolsResult, error) { @@ -1093,6 +1129,11 @@ func (s *Server) Connect(ctx context.Context, t Transport, opts *ServerSessionOp return nil, err } + // Compute the protocol versions this session can serve, filtered by the + // transport's capabilities (if it implements [ProtocolVersionSupporter]). + // The list is consumed by the SEP-2575 server/discover handler. + ss.supportedVersions = filterSupportedVersions(t) + // Start keepalive before returning the session to avoid race conditions with Close. // This is safe because the spec allows sending pings before initialization (see ServerSession.handle for details). if s.opts.KeepAlive > 0 { @@ -1177,6 +1218,12 @@ type ServerSession struct { mcpConn Connection keepaliveCancel context.CancelFunc + // supportedVersions is the subset of [supportedProtocolVersions] 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. + supportedVersions []string + mu sync.Mutex state ServerSessionState } @@ -1496,9 +1543,20 @@ func (ss *ServerSession) handle(ctx context.Context, req *jsonrpc.Request) (any, if perRequestErr != nil { return nil, perRequestErr } + if validatedMeta.usesNewProtocol && !slices.Contains(supportedProtocolVersions, validatedMeta.initializeParams.ProtocolVersion) { + data, _ := json.Marshal(UnsupportedProtocolVersionData{ + Supported: supportedProtocolVersions, + Requested: validatedMeta.initializeParams.ProtocolVersion, + }) + return nil, &jsonrpc.Error{ + Code: CodeUnsupportedProtocolVersion, + Message: "unsupported protocol version", + Data: data, + } + } switch req.Method { - case methodInitialize, methodPing, notificationInitialized: + case methodInitialize, methodPing, notificationInitialized, methodSubscribe, methodUnsubscribe: if validatedMeta.usesNewProtocol { ss.server.opts.Logger.Error("method removed in the new protocol", "method", req.Method) return nil, &jsonrpc.Error{ diff --git a/mcp/server_test.go b/mcp/server_test.go index 7cc780a9..d2814a0b 100644 --- a/mcp/server_test.go +++ b/mcp/server_test.go @@ -1100,6 +1100,10 @@ func TestServerCapabilitiesOverWire(t *testing.T) { // that opts into the new protocol via `_meta.protocolVersion` must be // rejected with `Method not found` (-32601). func TestServerSessionHandle_RejectsInitializeOnNewProtocol(t *testing.T) { + orig := supportedProtocolVersions + supportedProtocolVersions = append([]string{protocolVersion20260630}, slices.Clone(orig)...) + t.Cleanup(func() { supportedProtocolVersions = orig }) + tests := []struct { name string params any @@ -1213,6 +1217,10 @@ func TestServerSessionHandle_RejectsInitializeOnNewProtocol(t *testing.T) { // `ping`) all return Method not found when the request opts into the new // protocol via `_meta.protocolVersion`. func TestServerSessionHandle_RejectsRemovedMethodsOnNewProtocol(t *testing.T) { + orig := supportedProtocolVersions + supportedProtocolVersions = append([]string{protocolVersion20260630}, slices.Clone(orig)...) + t.Cleanup(func() { supportedProtocolVersions = orig }) + newProtoMeta := map[string]any{ "_meta": map[string]any{ MetaKeyProtocolVersion: protocolVersion20260630, diff --git a/mcp/shared.go b/mcp/shared.go index a0232d37..60d1b6fa 100644 --- a/mcp/shared.go +++ b/mcp/shared.go @@ -105,7 +105,6 @@ func defaultSendingMethodHandler(ctx context.Context, method string, req Request // capabilities, so any panic here is a bug. params = initParams.toV2() } - // Notifications don't have results. if strings.HasPrefix(method, "notifications/") { return nil, req.GetSession().getConn().Notify(ctx, method, params) @@ -345,6 +344,9 @@ func clientSessionMethod[P Params, R Result](f func(*ClientSession, context.Cont // MCP-specific error codes. const ( + // CodeMissingRequiredClientCapabilities is the JSON-RPC error code defined by + // SEP-2575 for MissingRequiredClientCapabilitiesError. + CodeMissingRequiredClientCapabilities = -32003 // CodeUnsupportedProtocolVersion is the JSON-RPC error code defined by // SEP-2575 for UnsupportedProtocolVersionError. CodeUnsupportedProtocolVersion = -32004 @@ -504,7 +506,7 @@ func validateRequestMeta(req *jsonrpc.Request) (*validatedMeta, error) { return &validatedMeta{usesNewProtocol: false, initializeParams: nil}, nil } protocolVersion, ok := meta[MetaKeyProtocolVersion].(string) - if !ok { + if !ok || protocolVersion < protocolVersion20260630 { return &validatedMeta{usesNewProtocol: false, initializeParams: nil}, nil } // Notifications do not carry full client identity. In new protocol, only cancel notification diff --git a/mcp/streamable.go b/mcp/streamable.go index e6a9bfe5..158da843 100644 --- a/mcp/streamable.go +++ b/mcp/streamable.go @@ -266,6 +266,18 @@ var enableoriginverification = mcpgodebug.Value("enableoriginverification") // The option will be removed in the 1.9.0 version of the SDK. var allowsessionsinstateless = mcpgodebug.Value("allowsessionsinstateless") +// writeJSONRPCError writes a JSON-RPC error response with the given HTTP +// status code, request ID (may be a zero ID for errors that occur before the +// request body has been parsed), and JSON-RPC error. +func writeJSONRPCError(w http.ResponseWriter, status int, id jsonrpc.ID, jerr *jsonrpc.Error) { + resp := &jsonrpc.Response{ID: id, Error: jerr} + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + if data, err := jsonrpc2.EncodeMessage(resp); err == nil { + w.Write(data) + } +} + func (h *StreamableHTTPHandler) ServeHTTP(w http.ResponseWriter, req *http.Request) { // DNS rebinding protection: auto-enabled for localhost servers. // See: https://modelcontextprotocol.io/specification/2025-11-25/basic/security_best_practices#local-mcp-server-compromise @@ -291,7 +303,7 @@ func (h *StreamableHTTPHandler) ServeHTTP(w http.ResponseWriter, req *http.Reque // // [§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) { + if protocolVersion != "" && !slices.Contains(supportedProtocolVersions, protocolVersion) && protocolVersion < protocolVersion20260630 { http.Error(w, fmt.Sprintf("Bad Request: Unsupported protocol version (supported versions: %s)", strings.Join(supportedProtocolVersions, ",")), http.StatusBadRequest) return } @@ -782,6 +794,16 @@ func (t *StreamableServerTransport) Connect(ctx context.Context) (Connection, er return t.connection, nil } +// The streamable HTTP transport supports every legacy SDK protocol version, +// but the SEP-2575 >= 2026-06-30 protocol is only supported when the +// transport is configured as stateless. +func (t *StreamableServerTransport) SupportsProtocolVersion(version string) bool { + if version >= protocolVersion20260630 { + return t.Stateless && slices.Contains(supportedProtocolVersions, version) + } + return slices.Contains(supportedProtocolVersions, version) +} + type streamableServerConn struct { sessionID string stateless bool @@ -914,6 +936,35 @@ func (s *stream) release() { s.done = nil // may already be nil, if the stream is done or closed } +// extractErrorStatus reports the HTTP status to send when the given +// outgoing message is a JSON-RPC error response under the SEP-2575 protocol +// (>= 2026-06-30). +// +// Per SEP-2575: +// - MethodNotFound (-32601) MUST return HTTP 404. +// - InvalidParams (-32602) and UnsupportedProtocolVersion (-32004) MUST +// return HTTP 400. +func extractErrorStatus(ctx context.Context, msg jsonrpc.Message) int { + if protocolVersionFromContext(ctx) < protocolVersion20260630 { + return 0 + } + resp, ok := msg.(*jsonrpc.Response) + if !ok || resp.Error == nil { + return 0 + } + var jerr *jsonrpc.Error + if !errors.As(resp.Error, &jerr) { + return 0 + } + switch jerr.Code { + case jsonrpc.CodeMethodNotFound: + return http.StatusNotFound + case jsonrpc.CodeInvalidParams, CodeUnsupportedProtocolVersion, CodeMissingRequiredClientCapabilities: + return http.StatusBadRequest + } + return 0 +} + // deliverLocked writes data to the stream (for SSE) or stores it in // pendingJSONMessages (for JSON mode). The eventID is used for SSE event ID; // pass "" to omit. @@ -921,11 +972,16 @@ func (s *stream) release() { // If responseTo is valid, it is removed from the requests map. When all // requests have been responded to, the done channel is closed and set to nil. // +// If overrideStatus is non-zero, data is treated as a SEP-2575 protocol-level +// error response (>= 2026-06-30): it is written as a single raw JSON-RPC +// response body with Content-Type: application/json and HTTP status +// overrideStatus. +// // Returns true if the stream is now done (all requests have been responded to). // The done value is always accurate, even if an error is returned. // // s.mu must be held when calling this method. -func (s *stream) deliverLocked(data []byte, eventID string, responseTo jsonrpc.ID) (done bool, err error) { +func (s *stream) deliverLocked(data []byte, eventID string, responseTo jsonrpc.ID, overrideStatus int) (done bool, err error) { // First, record the response. We must do this *before* returning an error // below, as even if the stream is disconnected we want to update our // accounting. @@ -940,6 +996,17 @@ func (s *stream) deliverLocked(data []byte, eventID string, responseTo jsonrpc.I if done { defer func() { close(s.done); s.done = nil }() } + // SEP-2575 protocol-level error override: write the error as a raw + // JSON-RPC response with the spec-mandated HTTP status, bypassing any + // SSE framing. + if overrideStatus != 0 { + s.w.Header().Set("Content-Type", "application/json") + s.w.WriteHeader(overrideStatus) + if _, err := s.w.Write(data); err != nil { + return done, err + } + return done, nil + } // Try to write to the response. // // If we get here, the request is still hanging (because s.done != nil @@ -1299,6 +1366,13 @@ func (c *streamableServerConn) servePOST(w http.ResponseWriter, req *http.Reques // the HTTP request. If we didn't do this, a request with a bad method or // missing ID could be silently swallowed. if _, err := checkRequest(jreq, serverMethodInfos); err != nil { + if headerVersion >= protocolVersion20260630 && errors.Is(err, jsonrpc2.ErrNotHandled) && jreq.IsCall() { + writeJSONRPCError(w, http.StatusNotFound, jreq.ID, &jsonrpc.Error{ + Code: jsonrpc.CodeMethodNotFound, + Message: err.Error(), + }) + return + } http.Error(w, err.Error(), http.StatusBadRequest) return } @@ -1322,7 +1396,10 @@ func (c *streamableServerConn) servePOST(w http.ResponseWriter, req *http.Reques metaVersion, _ = meta[MetaKeyProtocolVersion].(string) } if protocolVersion >= protocolVersion20260630 || metaVersion != "" { - if !c.stateless { + // server/discover is exempt from the stateful + // rejection as it should learn about the supported protocols from the + // DiscoverResult response. + if !c.stateless && jreq.Method != methodDiscover { http.Error(w, fmt.Sprintf( "Bad Request: protocol version %q is only supported on stateless HTTP servers (set StreamableHTTPOptions.Stateless = true)", protocolVersion), @@ -1330,18 +1407,31 @@ func (c *streamableServerConn) servePOST(w http.ResponseWriter, req *http.Reques return } if headerVersion == "" { - http.Error(w, fmt.Sprintf( - "Bad Request: %s header is required for requests carrying %q", - protocolVersionHeader, MetaKeyProtocolVersion), - http.StatusBadRequest) + writeJSONRPCError(w, http.StatusBadRequest, jreq.ID, &jsonrpc.Error{ + Code: CodeHeaderMismatch, + Message: fmt.Sprintf( + "%s header is required for requests carrying %q", + protocolVersionHeader, MetaKeyProtocolVersion), + }) + return + } + if metaVersion == "" { + writeJSONRPCError(w, http.StatusBadRequest, jreq.ID, &jsonrpc.Error{ + Code: jsonrpc.CodeInvalidParams, + Message: fmt.Sprintf( + "missing or invalid _meta field %q", + MetaKeyProtocolVersion), + }) return } if headerVersion != metaVersion { - http.Error(w, fmt.Sprintf( - "Bad Request: %s header %q does not match request %s %q", - protocolVersionHeader, headerVersion, - MetaKeyProtocolVersion, metaVersion), - http.StatusBadRequest) + writeJSONRPCError(w, http.StatusBadRequest, jreq.ID, &jsonrpc.Error{ + Code: CodeHeaderMismatch, + Message: fmt.Sprintf( + "%s header %q does not match request %s %q", + protocolVersionHeader, headerVersion, + MetaKeyProtocolVersion, metaVersion), + }) return } } @@ -1455,7 +1545,10 @@ func (c *streamableServerConn) servePOST(w http.ResponseWriter, req *http.Reques stream.pendingJSONMessages = []json.RawMessage{} } else { // SSE mode: write a priming event if supported. - if c.eventStore != nil && effectiveVersion >= protocolVersion20251125 { + // + // SEP-2575 removes Last-Event-ID-based resumable streams for protocol + // version >= 2026-06-30. + if c.eventStore != nil && effectiveVersion >= protocolVersion20251125 && effectiveVersion < protocolVersion20260630 { // Write a priming event, as defined by [§2.1.6] of the spec. // // [§2.1.6]: https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#sending-messages-to-the-server @@ -1644,7 +1737,12 @@ func (c *streamableServerConn) Write(ctx context.Context, msg jsonrpc.Message) e eventID = formatEventID(s.id, s.lastIdx+1) } - done, err := s.deliverLocked(data, eventID, responseTo) + // SEP-2575: map protocol-level JSON-RPC error codes to HTTP status codes + // on the new protocol (>= 2026-06-30). When non-zero, deliverLocked will + // write the body as raw application/json with the override status. + overrideStatus := extractErrorStatus(ctx, msg) + + done, err := s.deliverLocked(data, eventID, responseTo, overrideStatus) if err != nil { errs = append(errs, err) } else { @@ -1844,7 +1942,7 @@ func (c *streamableClientConn) sessionUpdated(state clientSessionState) { c.initializedResult = state.InitializeResult c.mu.Unlock() - // When the protocol version is >= 2026-06-30, the standalone HTTP GET + // Under SEP-2575 (protocol version >= 2026-06-30) the standalone HTTP GET // SSE stream is removed. if state.InitializeResult == nil || state.InitializeResult.ProtocolVersion >= protocolVersion20260630 { @@ -2275,6 +2373,10 @@ func (c *streamableClientConn) checkResponse(ctx context.Context, requestSummary protocolVersion := protocolVersionFromContext(ctx) if protocolVersion != "" && protocolVersion >= protocolVersion20260630 { body, _ := io.ReadAll(resp.Body) + msg, _ := jsonrpc.DecodeMessage(body) + if response, ok := msg.(*jsonrpc.Response); ok && response.Error != nil { + return fmt.Errorf("%s: %w: %v", requestSummary, response.Error, http.StatusText(resp.StatusCode)) + } if strings.Contains(string(body), fmt.Sprintf("%s: %q unsupported", jsonrpc2.ErrNotHandled, methodDiscover)) { return fmt.Errorf("%s: %w: %v", requestSummary, jsonrpc2.ErrMethodNotFound, http.StatusText(resp.StatusCode)) } diff --git a/mcp/streamable_test.go b/mcp/streamable_test.go index a27696a1..2efee20e 100644 --- a/mcp/streamable_test.go +++ b/mcp/streamable_test.go @@ -2069,15 +2069,7 @@ func TestStreamableMcpHeaderValidationErrorFormat(t *testing.T) { }) defer handler.closeAll() - // TODO(SEP-2575): drop discoverInterceptor and hit `handler` directly - // once Server.discover returns a real DiscoverResult instead of - // MethodNotFound. See comment on discoverInterceptor for details. - wrapped := discoverInterceptor(t, handler, - []string{minVersionForStandardHeaders}, - &ServerCapabilities{Tools: &ToolCapabilities{}}, - &Implementation{Name: "testServer", Version: "v1.0.0"}, - ) - httpServer := httptest.NewServer(mustNotPanic(t, wrapped)) + httpServer := httptest.NewServer(mustNotPanic(t, handler)) defer httpServer.Close() // Use the MCP client with a custom RoundTripper to inject a bad header. @@ -2239,15 +2231,7 @@ func TestStreamableParamHeadersClientSetsHeaders(t *testing.T) { Stateless: true, }) defer handler.closeAll() - // TODO(SEP-2575): drop discoverInterceptor and hit `handler` directly - // once Server.discover returns a real DiscoverResult instead of - // MethodNotFound. See comment on discoverInterceptor for details. - wrapped := discoverInterceptor(t, handler, - []string{minVersionForStandardHeaders}, - &ServerCapabilities{Tools: &ToolCapabilities{ListChanged: true}}, - &Implementation{Name: "testServer", Version: "v1.0.0"}, - ) - httpServer := httptest.NewServer(mustNotPanic(t, wrapped)) + httpServer := httptest.NewServer(mustNotPanic(t, handler)) defer httpServer.Close() var capturedHeaders http.Header @@ -2361,15 +2345,7 @@ func TestStreamableFilterValidToolsIntegration(t *testing.T) { Stateless: true, }) defer handler.closeAll() - // TODO(SEP-2575): drop discoverInterceptor and hit `handler` directly - // once Server.discover returns a real DiscoverResult instead of - // MethodNotFound. See comment on discoverInterceptor for details. - wrapped := discoverInterceptor(t, handler, - []string{minVersionForStandardHeaders}, - &ServerCapabilities{Tools: &ToolCapabilities{ListChanged: true}}, - &Implementation{Name: "testServer", Version: "v1.0.0"}, - ) - httpServer := httptest.NewServer(mustNotPanic(t, wrapped)) + httpServer := httptest.NewServer(mustNotPanic(t, handler)) defer httpServer.Close() client := NewClient(&Implementation{Name: "testClient", Version: "v1.0.0"}, nil) @@ -2382,7 +2358,9 @@ func TestStreamableFilterValidToolsIntegration(t *testing.T) { } defer session.Close() - result, err := session.ListTools(ctx, nil) + // Pass non-nil params so the SEP-2575 per-request _meta triple is + // injected; injectMeta is a no-op when params is nil. + result, err := session.ListTools(ctx, &ListToolsParams{}) if err != nil { t.Fatal(err) } @@ -2690,51 +2668,6 @@ func TestStreamableSessionTimeout(t *testing.T) { handler.mu.Unlock() } -// discoverInterceptor wraps an HTTP handler so that POST requests carrying a -// server/discover JSON-RPC request are answered with a canned DiscoverResult -// advertising the given supportedVersions. All other requests are forwarded -// to next unchanged. -// -// TODO(SEP-2575): this is a workaround for tests that need an end-to-end -// SEP-2575 session (e.g. to exercise the Mcp-Method / Mcp-Param-* request -// headers gated on protocol >= 2026-06-30) while the server-side -// Server.discover implementation still returns MethodNotFound. Once -// server-side discover is implemented, this helper can be removed and the -// tests can hit the real handler directly. -func discoverInterceptor(t *testing.T, next http.Handler, supportedVersions []string, capabilities *ServerCapabilities, serverInfo *Implementation) http.Handler { - t.Helper() - return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { - if req.Method != http.MethodPost { - next.ServeHTTP(w, req) - return - } - body, err := io.ReadAll(req.Body) - req.Body.Close() - if err != nil { - http.Error(w, "failed to read body", http.StatusBadRequest) - return - } - req.Body = io.NopCloser(bytes.NewReader(body)) - msg, err := jsonrpc.DecodeMessage(body) - if err != nil { - next.ServeHTTP(w, req) - return - } - r, ok := msg.(*jsonrpc.Request) - if !ok || r.Method != methodDiscover { - next.ServeHTTP(w, req) - return - } - result := &DiscoverResult{ - SupportedVersions: supportedVersions, - Capabilities: capabilities, - ServerInfo: serverInfo, - } - w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte(jsonBody(t, &jsonrpc.Response{ID: r.ID, Result: mustMarshal(result)}))) - }) -} - // mustNotPanic is a helper to enforce that test handlers do not panic (see // issue #556). func mustNotPanic(t *testing.T, h http.Handler) http.Handler { @@ -3630,3 +3563,156 @@ func TestStreamableClientUnsupportedVersionFallback(t *testing.T) { t.Errorf("Ping after fallback initialize: %v", err) } } + +// TestStreamableStateful_AcceptsDiscover verifies that a stateful HTTP server +// accepts a server/discover probe carrying MCP-Protocol-Version: 2026-06-30 +// (and the matching _meta.protocolVersion), instead of rejecting it with the +// "stateless required" 400. The SEP-2575 client flow has the client probing +// the server with the new protocol version to learn which versions are +// supported. +func TestStreamableStateful_AcceptsDiscover(t *testing.T) { + orig := supportedProtocolVersions + supportedProtocolVersions = append(slices.Clone(orig), protocolVersion20260630) + t.Cleanup(func() { supportedProtocolVersions = orig }) + + server := NewServer(testImpl, nil) + handler := NewStreamableHTTPHandler(func(*http.Request) *Server { return server }, nil) + httpServer := httptest.NewServer(mustNotPanic(t, handler)) + defer httpServer.Close() + + body, err := json.Marshal(map[string]any{ + "jsonrpc": "2.0", + "id": 1, + "method": methodDiscover, + "params": map[string]any{ + "_meta": map[string]any{ + MetaKeyProtocolVersion: protocolVersion20260630, + MetaKeyClientInfo: map[string]any{"name": "new-proto-client", "version": "9.9"}, + MetaKeyClientCapabilities: map[string]any{"sampling": map[string]any{}}, + }, + }, + }) + if err != nil { + t.Fatal(err) + } + req, err := http.NewRequest(http.MethodPost, httpServer.URL, bytes.NewReader(body)) + 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, protocolVersion20260630) + req.Header.Set(methodHeader, methodDiscover) + + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + respBody, _ := io.ReadAll(resp.Body) + if resp.StatusCode != http.StatusOK { + t.Fatalf("status = %d, want 200; body = %s", resp.StatusCode, respBody) + } + + // Parse the JSON-RPC response. The body may arrive as a bare JSON object + // or as a single SSE event depending on the Accept negotiation; both + // shapes are valid here. + jsonPayload := respBody + if i := bytes.Index(respBody, []byte("data: ")); i >= 0 { + jsonPayload = respBody[i+len("data: "):] + if j := bytes.IndexByte(jsonPayload, '\n'); j >= 0 { + jsonPayload = jsonPayload[:j] + } + } + var rpcResp struct { + Result *DiscoverResult `json:"result"` + Error *jsonrpc.Error `json:"error"` + } + if err := json.Unmarshal(jsonPayload, &rpcResp); err != nil { + t.Fatalf("unmarshal response %q: %v", respBody, err) + } + if rpcResp.Error != nil { + t.Fatalf("discover returned error: %+v (body = %s)", rpcResp.Error, respBody) + } + if rpcResp.Result == nil { + t.Fatalf("discover returned no result; body = %s", respBody) + } + if slices.Contains(rpcResp.Result.SupportedVersions, protocolVersion20260630) { + t.Errorf("DiscoverResult.SupportedVersions = %v, must not include %q on a stateful transport", + rpcResp.Result.SupportedVersions, protocolVersion20260630) + } + if len(rpcResp.Result.SupportedVersions) == 0 { + t.Errorf("DiscoverResult.SupportedVersions is empty; want at least one legacy version") + } +} + +// TestStreamableHTTP_E2E_DiscoverSuccess is a full end-to-end smoke test for +// SEP-2575 over the streamable HTTP transport. +func TestStreamableHTTP_E2E_DiscoverSuccess(t *testing.T) { + ctx := context.Background() + orig := supportedProtocolVersions + supportedProtocolVersions = append([]string{protocolVersion20260630}, slices.Clone(orig)...) + t.Cleanup(func() { supportedProtocolVersions = orig }) + + server := NewServer(&Implementation{Name: "e2e-server", Version: "v1"}, nil) + // Register a simple tool so we can prove the session is usable end-to-end. + AddTool(server, &Tool{Name: "echo", Description: "echoes its input"}, + func(_ context.Context, _ *CallToolRequest, args struct { + Msg string `json:"msg"` + }) (*CallToolResult, struct{}, error) { + return &CallToolResult{ + Content: []Content{&TextContent{Text: args.Msg}}, + }, struct{}{}, nil + }, + ) + + handler := NewStreamableHTTPHandler( + func(*http.Request) *Server { return server }, + &StreamableHTTPOptions{Stateless: true}, + ) + httpServer := httptest.NewServer(handler) + defer httpServer.Close() + + client := NewClient(&Implementation{Name: "e2e-client", Version: "v1"}, nil) + transport := &StreamableClientTransport{Endpoint: httpServer.URL} + cs, err := client.Connect(ctx, transport, &ClientSessionOptions{protocolVersion: protocolVersion20260630}) + if err != nil { + t.Fatalf("Connect: %v", err) + } + defer cs.Close() + + ir := cs.InitializeResult() + if ir == nil { + t.Fatal("InitializeResult is nil after Connect; discover should have populated it") + } + if ir.ProtocolVersion != protocolVersion20260630 { + t.Errorf("InitializeResult.ProtocolVersion = %q, want %q (negotiated via discover)", + ir.ProtocolVersion, protocolVersion20260630) + } + if ir.ServerInfo == nil || ir.ServerInfo.Name != "e2e-server" { + t.Errorf("InitializeResult.ServerInfo = %+v, want name=e2e-server", ir.ServerInfo) + } + + // Prove the session is fully usable: list tools and call one. + tools, err := cs.ListTools(ctx, nil) + if err != nil { + t.Fatalf("ListTools: %v", err) + } + if len(tools.Tools) != 1 || tools.Tools[0].Name != "echo" { + t.Errorf("ListTools = %+v, want one tool named 'echo'", tools.Tools) + } + res, err := cs.CallTool(ctx, &CallToolParams{ + Name: "echo", + Arguments: map[string]any{"msg": "hello"}, + }) + if err != nil { + t.Fatalf("CallTool: %v", err) + } + if len(res.Content) != 1 { + t.Fatalf("CallTool result content = %+v, want 1 entry", res.Content) + } + tc, ok := res.Content[0].(*TextContent) + if !ok || tc.Text != "hello" { + t.Errorf("CallTool result[0] = %+v, want TextContent{Text:\"hello\"}", res.Content[0]) + } +} diff --git a/mcp/transport.go b/mcp/transport.go index ea447478..f67c43d0 100644 --- a/mcp/transport.go +++ b/mcp/transport.go @@ -48,6 +48,19 @@ type Transport interface { Connect(ctx context.Context) (Connection, error) } +// ProtocolVersionSupporter is an optional capability that a [Transport] may +// implement to declare which MCP protocol versions it can serve. +// +// [Server.Connect] consults this interface to filter the +// list of versions advertised in server/discover responses. Transports that +// do not implement this interface are assumed to support every protocol +// version known to the SDK. +type ProtocolVersionSupporter interface { + // SupportsProtocolVersion reports whether the transport can serve + // requests using the given protocol version. + SupportsProtocolVersion(version string) bool +} + // A Connection is a logical bidirectional JSON-RPC connection. type Connection interface { // Read reads the next message to process off the connection.