From 2fca1b4165d4961dbd5568c89a7d1251a7f0b508 Mon Sep 17 00:00:00 2001 From: guglielmoc Date: Tue, 19 May 2026 12:16:10 +0000 Subject: [PATCH 01/40] feat: implement sessionless protocol support via per-request _meta validation and accessors --- mcp/protocol.go | 14 ++ mcp/server.go | 34 +++- mcp/shared.go | 151 ++++++++++++++++++ mcp/shared_test.go | 327 +++++++++++++++++++++++++++++++++++++++ mcp/streamable.go | 53 +++++-- mcp/streamable_test.go | 344 +++++++++++++++++++++++++++++++++++++++++ 6 files changed, 907 insertions(+), 16 deletions(-) diff --git a/mcp/protocol.go b/mcp/protocol.go index 1646788a..824648c1 100644 --- a/mcp/protocol.go +++ b/mcp/protocol.go @@ -1630,3 +1630,17 @@ const ( notificationToolListChanged = "notifications/tools/list_changed" methodUnsubscribe = "resources/unsubscribe" ) + +// Per-request _meta field names for the >= 2026-06-30 protocol version. +// +// These keys appear inside a Params._meta map and carry information that +// previously came from the initialization handshake (SEP-2575). +const ( + // MetaKeyProtocolVersion identifies the MCP protocol version that the + // request follows. + MetaKeyProtocolVersion = "io.modelcontextprotocol/protocolVersion" + // MetaKeyClientInfo carries the client's [Implementation]. + MetaKeyClientInfo = "io.modelcontextprotocol/clientInfo" + // MetaKeyClientCapabilities carries the client's [ClientCapabilities]. + MetaKeyClientCapabilities = "io.modelcontextprotocol/clientCapabilities" +) diff --git a/mcp/server.go b/mcp/server.go index 183226d1..912034b2 100644 --- a/mcp/server.go +++ b/mcp/server.go @@ -62,7 +62,11 @@ type ServerOptions struct { Instructions string // Logger may be set to a non-nil value to enable logging of server activity. Logger *slog.Logger - // If non-nil, called when "notifications/initialized" is received. + // InitializedHandler, if non-nil, is called when + // "notifications/initialized" is received. + // + // Deprecated: the >= 2026-06-30 protocol removes the initialization + // handshake, so this handler is never invoked for new-protocol clients. InitializedHandler func(context.Context, *InitializedRequest) // PageSize is the maximum number of items to return in a single page for // list methods (e.g. ListTools). @@ -1450,13 +1454,33 @@ func (ss *ServerSession) handle(ctx context.Context, req *jsonrpc.Request) (any, initialized := ss.state.InitializeParams != nil ss.mu.Unlock() + // Per-request protocol detection (SEP-2575): if the request carries + // `io.modelcontextprotocol/protocolVersion` in its `_meta` field, it + // follows the new sessionless protocol. The initialization gate is + // skipped for such requests, since the new protocol has no `initialize` + // handshake; but the other required `_meta` fields must be present. + usesNewProtocol, perRequestErr := validateRequestMeta(req) + if perRequestErr != nil { + return nil, perRequestErr + } + + // SEP-2575 removes the initialization handshake. Reject `initialize` + // requests that opt into the new protocol via `_meta.protocolVersion`, + // per the spec wording: "An `initialize` request with `2026-06-30` + // protocol version specified will be rejected with `Method not found`." + if req.Method == methodInitialize && usesNewProtocol { + ss.server.opts.Logger.Error("initialize is not supported in the new protocol", "method", req.Method) + return nil, fmt.Errorf("%w: %q is not supported in the new protocol; use %q instead", + jsonrpc2.ErrNotHandled, methodInitialize, "server/discover") + } + // From the spec: // "The client SHOULD NOT send requests other than pings before the server // has responded to the initialize request." switch req.Method { case methodInitialize, methodPing, notificationInitialized: default: - if !initialized { + if !initialized && !usesNewProtocol { ss.server.opts.Logger.Error("method invalid during initialization", "method", req.Method) return nil, fmt.Errorf("method %q is invalid during session initialization", req.Method) } @@ -1478,6 +1502,12 @@ func (ss *ServerSession) handle(ctx context.Context, req *jsonrpc.Request) (any, // InitializeParams returns the InitializeParams provided during the client's // initial connection. +// +// Deprecated: with the >= 2026-06-30 protocol, sessions are sessionless and +// there is no `initialize` handshake. For new-protocol requests this method +// returns nil; use the per-request accessors [ServerRequest.ProtocolVersion], +// [ServerRequest.ClientInfo], and [ServerRequest.ClientCapabilities] +// instead. func (ss *ServerSession) InitializeParams() *InitializeParams { ss.mu.Lock() defer ss.mu.Unlock() diff --git a/mcp/shared.go b/mcp/shared.go index 078b401b..65197898 100644 --- a/mcp/shared.go +++ b/mcp/shared.go @@ -465,6 +465,62 @@ func setProgressToken(p Params, pt any) { m[progressTokenKey] = pt } +// extractRequestMeta performs a lightweight partial unmarshal of the `_meta` +// field from a JSON-RPC request's raw params. It returns nil if params are +// missing, malformed, or do not contain a `_meta` object. +func extractRequestMeta(rawParams json.RawMessage) Meta { + if len(rawParams) == 0 { + return nil + } + var meta struct { + Meta Meta `json:"_meta"` + } + if err := internaljson.Unmarshal(rawParams, &meta); err != nil { + return nil + } + return meta.Meta +} + +// validateRequestMeta inspects a JSON-RPC request to detect whether it follows +// the >= 2026-06-30 protocol via the `_meta` field. If so, it validates that +// the required `_meta` fields (clientInfo, clientCapabilities) are present. +// +// It returns: +// - usesNewProtocol: true if `io.modelcontextprotocol/protocolVersion` was +// present in `_meta`. +// - err: a JSON-RPC error if required `_meta` fields are missing or +// malformed for a new-protocol request. +// +// Notifications are exempt from `_meta` validation (no clientInfo / +// clientCapabilities required), since they do not establish protocol state. +func validateRequestMeta(req *jsonrpc.Request) (usesNewProtocol bool, err error) { + meta := extractRequestMeta(req.Params) + if meta == nil { + return false, nil + } + if _, ok := meta[MetaKeyProtocolVersion].(string); !ok { + return false, nil + } + // Notifications do not carry full client identity; only RPC calls + // following the new protocol must include it. + if !req.IsCall() { + return true, nil + } + if _, ok := meta[MetaKeyClientInfo]; !ok { + return true, &jsonrpc.Error{ + Code: jsonrpc.CodeInvalidParams, + Message: fmt.Sprintf("missing required _meta field %q", MetaKeyClientInfo), + } + } + if _, ok := meta[MetaKeyClientCapabilities]; !ok { + return true, &jsonrpc.Error{ + Code: jsonrpc.CodeInvalidParams, + Message: fmt.Sprintf("missing required _meta field %q", MetaKeyClientCapabilities), + } + } + return true, nil +} + // A Request is a method request with parameters and additional information, such as the session. // Request is implemented by [*ClientRequest] and [*ServerRequest]. type Request interface { @@ -525,6 +581,101 @@ func (r *ServerRequest[P]) GetParams() Params { return r.Params } func (r *ClientRequest[P]) GetExtra() *RequestExtra { return nil } func (r *ServerRequest[P]) GetExtra() *RequestExtra { return r.Extra } +// ProtocolVersion returns the protocol version negotiated for this request. +// +// For requests following the >= 2026-06-30 protocol, the value is read from +// the per-request `_meta` field. For older protocol requests, the value falls +// back to the session-level [InitializeParams] established during the +// initialize handshake. Returns "" if neither is available. +func (r *ServerRequest[P]) ProtocolVersion() string { + if m := getRequestMeta(r); m != nil { + if v, ok := m[MetaKeyProtocolVersion].(string); ok { + return v + } + } + if r.Session != nil { + if p := r.Session.InitializeParams(); p != nil { + return p.ProtocolVersion + } + } + return "" +} + +// ClientInfo returns the [Implementation] identifying the calling client. +// +// For requests following the >= 2026-06-30 protocol, the value is read from +// the per-request `_meta` field. For older protocol requests, the value falls +// back to the session-level [InitializeParams]. Returns nil if neither +// source provides the field. +func (r *ServerRequest[P]) ClientInfo() *Implementation { + if m := getRequestMeta(r); m != nil { + if v, ok := decodeMetaValue[*Implementation](m, MetaKeyClientInfo); ok { + return v + } + } + if r.Session != nil { + if p := r.Session.InitializeParams(); p != nil { + return p.ClientInfo + } + } + return nil +} + +// ClientCapabilities returns the [ClientCapabilities] of the calling client. +// +// For requests following the >= 2026-06-30 protocol, the value is read from +// the per-request `_meta` field. For older protocol requests, the value falls +// back to the session-level [InitializeParams]. Returns nil if neither +// source provides the field. +func (r *ServerRequest[P]) ClientCapabilities() *ClientCapabilities { + if m := getRequestMeta(r); m != nil { + if v, ok := decodeMetaValue[*ClientCapabilities](m, MetaKeyClientCapabilities); ok { + return v + } + } + if r.Session != nil { + if p := r.Session.InitializeParams(); p != nil { + return p.Capabilities + } + } + return nil +} + +// getRequestMeta returns the raw `_meta` map from the request's params, or +// nil if the params are absent. +func getRequestMeta[P Params](r *ServerRequest[P]) map[string]any { + // In practice P is a pointer type implementing Params. Use reflect to + // detect a nil pointer without panicking on GetMeta. + if v := reflect.ValueOf(r.Params); !v.IsValid() || (v.Kind() == reflect.Pointer && v.IsNil()) { + return nil + } + return r.Params.GetMeta() +} + +// decodeMetaValue decodes a typed value out of a `_meta` map. Values may +// arrive either as the typed Go value (when constructed in-process) or as +// the generic JSON map produced by encoding/json after wire transit. In the +// latter case, the value is re-encoded and decoded into the target type. +func decodeMetaValue[T any](m map[string]any, key string) (T, bool) { + var zero T + raw, ok := m[key] + if !ok || raw == nil { + return zero, false + } + if v, ok := raw.(T); ok { + return v, true + } + data, err := json.Marshal(raw) + if err != nil { + return zero, false + } + var v T + if err := internaljson.Unmarshal(data, &v); err != nil { + return zero, false + } + return v, true +} + func serverRequestFor[P Params](s *ServerSession, p P) *ServerRequest[P] { return &ServerRequest[P]{Session: s, Params: p} } diff --git a/mcp/shared_test.go b/mcp/shared_test.go index 23818f87..1d39682c 100644 --- a/mcp/shared_test.go +++ b/mcp/shared_test.go @@ -4,6 +4,333 @@ package mcp +import ( + "context" + "encoding/json" + "errors" + "strings" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/modelcontextprotocol/go-sdk/internal/jsonrpc2" + "github.com/modelcontextprotocol/go-sdk/jsonrpc" +) + +func TestValidateRequestMeta(t *testing.T) { + mustParams := func(t *testing.T, v any) json.RawMessage { + t.Helper() + if v == nil { + return nil + } + data, err := json.Marshal(v) + if err != nil { + t.Fatal(err) + } + return data + } + + tests := []struct { + name string + method string + isNotification bool + params any + wantUsesNew bool + wantErrContains string + }{ + { + name: "no params: old protocol", + method: methodListTools, + params: nil, + wantUsesNew: false, + }, + { + name: "no _meta: old protocol", + method: methodCallTool, + params: map[string]any{"name": "x"}, + wantUsesNew: false, + }, + { + name: "_meta without protocolVersion: old protocol", + method: methodCallTool, + params: map[string]any{ + "_meta": map[string]any{"otherKey": "v"}, + "name": "x", + }, + wantUsesNew: false, + }, + { + name: "new protocol with all required fields", + method: methodCallTool, + params: map[string]any{ + "_meta": map[string]any{ + MetaKeyProtocolVersion: protocolVersion20260630, + MetaKeyClientInfo: map[string]any{"name": "c", "version": "1"}, + MetaKeyClientCapabilities: map[string]any{}, + }, + "name": "x", + }, + wantUsesNew: true, + }, + { + name: "new protocol missing clientInfo", + method: methodCallTool, + params: map[string]any{ + "_meta": map[string]any{ + MetaKeyProtocolVersion: protocolVersion20260630, + MetaKeyClientCapabilities: map[string]any{}, + }, + "name": "x", + }, + wantUsesNew: true, + wantErrContains: MetaKeyClientInfo, + }, + { + name: "new protocol missing clientCapabilities", + method: methodCallTool, + params: map[string]any{ + "_meta": map[string]any{ + MetaKeyProtocolVersion: protocolVersion20260630, + MetaKeyClientInfo: map[string]any{"name": "c", "version": "1"}, + }, + "name": "x", + }, + wantUsesNew: true, + wantErrContains: MetaKeyClientCapabilities, + }, + { + name: "notifications exempt from required fields", + method: notificationCancelled, + isNotification: true, + params: map[string]any{ + "_meta": map[string]any{ + MetaKeyProtocolVersion: protocolVersion20260630, + }, + "requestId": "r1", + }, + wantUsesNew: true, + }, + { + name: "malformed _meta is ignored", + method: methodCallTool, + params: json.RawMessage(`{"_meta": "not an object", "name": "x"}`), + wantUsesNew: false, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + var raw json.RawMessage + switch p := tc.params.(type) { + case json.RawMessage: + raw = p + default: + raw = mustParams(t, tc.params) + } + req := &jsonrpc.Request{Method: tc.method, Params: raw} + if !tc.isNotification { + req.ID = jsonrpc.ID{} + // Give the request an ID by parsing one. + id, err := jsonrpc.MakeID("test") + if err != nil { + t.Fatal(err) + } + req.ID = id + } + + usesNew, err := validateRequestMeta(req) + if usesNew != tc.wantUsesNew { + t.Errorf("usesNewProtocol = %v, want %v", usesNew, tc.wantUsesNew) + } + if tc.wantErrContains == "" { + if err != nil { + t.Errorf("unexpected error: %v", err) + } + return + } + if err == nil { + t.Fatalf("expected error containing %q, got nil", tc.wantErrContains) + } + var jerr *jsonrpc.Error + if !errors.As(err, &jerr) { + t.Fatalf("expected *jsonrpc.Error, got %T: %v", err, err) + } + if jerr.Code != jsonrpc.CodeInvalidParams { + t.Errorf("error code = %d, want %d", jerr.Code, jsonrpc.CodeInvalidParams) + } + if !strings.Contains(jerr.Message, tc.wantErrContains) { + t.Errorf("error message %q does not contain %q", jerr.Message, tc.wantErrContains) + } + }) + } +} + +func TestServerRequest_PerRequestAccessors(t *testing.T) { + // A request carrying the new-protocol _meta fields populates the + // accessors with values from _meta. + caps := &ClientCapabilities{Sampling: &SamplingCapabilities{}} + info := &Implementation{Name: "c", Version: "1"} + params := &CallToolParamsRaw{ + Meta: Meta{ + MetaKeyProtocolVersion: protocolVersion20260630, + MetaKeyClientInfo: info, + MetaKeyClientCapabilities: caps, + }, + Name: "x", + } + req := &ServerRequest[*CallToolParamsRaw]{Params: params} + if got := req.ProtocolVersion(); got != protocolVersion20260630 { + t.Errorf("ProtocolVersion = %q, want %q", got, protocolVersion20260630) + } + if got := req.ClientInfo(); got == nil || got.Name != "c" { + t.Errorf("ClientInfo = %+v, want Name=c", got) + } + if got := req.ClientCapabilities(); got == nil || got.Sampling == nil { + t.Errorf("ClientCapabilities = %+v, want non-nil Sampling", got) + } +} + +func TestServerRequest_PerRequestAccessors_FromJSON(t *testing.T) { + // Values arriving over the wire are JSON maps; the accessors should + // re-decode them into typed Go values. + raw := json.RawMessage(`{ + "_meta": { + "io.modelcontextprotocol/protocolVersion": "2026-06-30", + "io.modelcontextprotocol/clientInfo": {"name": "wire-client", "version": "9"}, + "io.modelcontextprotocol/clientCapabilities": {"sampling": {}} + }, + "name": "tool" + }`) + var params CallToolParamsRaw + if err := json.Unmarshal(raw, ¶ms); err != nil { + t.Fatal(err) + } + req := &ServerRequest[*CallToolParamsRaw]{Params: ¶ms} + if got, want := req.ProtocolVersion(), protocolVersion20260630; got != want { + t.Errorf("ProtocolVersion = %q, want %q", got, want) + } + gotInfo := req.ClientInfo() + wantInfo := &Implementation{Name: "wire-client", Version: "9"} + if diff := cmp.Diff(wantInfo, gotInfo); diff != "" { + t.Errorf("ClientInfo mismatch (-want +got):\n%s", diff) + } + gotCaps := req.ClientCapabilities() + if gotCaps == nil || gotCaps.Sampling == nil { + t.Errorf("ClientCapabilities = %+v, want non-nil Sampling", gotCaps) + } +} + +func TestServerRequest_PerRequestAccessors_FallbackToInitializeParams(t *testing.T) { + // With no _meta on the request, accessors must fall back to the + // session's InitializeParams (the old-protocol path). + ss := &ServerSession{} + ss.state.InitializeParams = &InitializeParams{ + ProtocolVersion: protocolVersion20251125, + ClientInfo: &Implementation{Name: "old", Version: "0"}, + Capabilities: &ClientCapabilities{Elicitation: &ElicitationCapabilities{}}, + } + req := &ServerRequest[*CallToolParamsRaw]{ + Session: ss, + Params: &CallToolParamsRaw{Name: "x"}, + } + if got, want := req.ProtocolVersion(), protocolVersion20251125; got != want { + t.Errorf("ProtocolVersion fallback = %q, want %q", got, want) + } + if got := req.ClientInfo(); got == nil || got.Name != "old" { + t.Errorf("ClientInfo fallback = %+v, want Name=old", got) + } + if got := req.ClientCapabilities(); got == nil || got.Elicitation == nil { + t.Errorf("ClientCapabilities fallback = %+v, want non-nil Elicitation", got) + } +} + +func TestServerRequest_PerRequestAccessors_Empty(t *testing.T) { + // With no _meta and no session, accessors return zero values. + req := &ServerRequest[*CallToolParamsRaw]{ + Params: &CallToolParamsRaw{Name: "x"}, + } + if got := req.ProtocolVersion(); got != "" { + t.Errorf("ProtocolVersion = %q, want empty", got) + } + if got := req.ClientInfo(); got != nil { + t.Errorf("ClientInfo = %+v, want nil", got) + } + if got := req.ClientCapabilities(); got != nil { + t.Errorf("ClientCapabilities = %+v, want nil", got) + } +} + +func TestServerSessionHandle_RejectsInitializeOnNewProtocol(t *testing.T) { + // SEP-2575 removes the initialization handshake. An `initialize` request + // that opts into the new protocol via `_meta.protocolVersion` must be + // rejected with `Method not found` (-32601). + mustParams := func(t *testing.T, v any) json.RawMessage { + t.Helper() + data, err := json.Marshal(v) + if err != nil { + t.Fatal(err) + } + return data + } + + tests := []struct { + name string + params any + wantReject bool + }{ + { + name: "initialize with new-protocol _meta is rejected", + params: map[string]any{ + "_meta": map[string]any{ + MetaKeyProtocolVersion: protocolVersion20260630, + MetaKeyClientInfo: map[string]any{"name": "c", "version": "1"}, + MetaKeyClientCapabilities: map[string]any{}, + }, + "protocolVersion": protocolVersion20260630, + }, + wantReject: true, + }, + { + name: "initialize without _meta is allowed (old protocol)", + params: map[string]any{ + "protocolVersion": protocolVersion20251125, + }, + wantReject: false, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + ss := &ServerSession{server: NewServer(testImpl, nil)} + id, err := jsonrpc.MakeID("test") + if err != nil { + t.Fatal(err) + } + req := &jsonrpc.Request{ + ID: id, + Method: methodInitialize, + Params: mustParams(t, tc.params), + } + _, err = ss.handle(context.Background(), req) + if tc.wantReject { + if err == nil { + t.Fatal("expected error rejecting initialize, got nil") + } + if !errors.Is(err, jsonrpc2.ErrNotHandled) { + t.Errorf("error = %v, want it to wrap jsonrpc2.ErrNotHandled (so the wire returns -32601)", err) + } + if !strings.Contains(err.Error(), "initialize") { + t.Errorf("error message %q does not mention %q", err.Error(), "initialize") + } + } else { + // Old-protocol initialize should be dispatched normally; any + // error here means the rejection branch fired incorrectly. + if err != nil && errors.Is(err, jsonrpc2.ErrNotHandled) { + t.Errorf("old-protocol initialize was incorrectly rejected: %v", err) + } + } + }) + } +} + // TODO(v0.3.0): rewrite this test. // func TestToolValidate(t *testing.T) { // // Check that the tool returned from NewServerTool properly validates its input schema. diff --git a/mcp/streamable.go b/mcp/streamable.go index d3f3f4fa..6470eac4 100644 --- a/mcp/streamable.go +++ b/mcp/streamable.go @@ -343,8 +343,18 @@ func (h *StreamableHTTPHandler) serveStateless(w http.ResponseWriter, req *http. return } + // Peek at the body to determine whether this is a new-protocol request. + // New-protocol requests are fully sessionless: even under the legacy + // `allowsessionsinstateless` compat flag, we must not read or generate + // a session ID for them. + connectOpts, usesNewProtocol, err := h.ephemeralConnectOpts(req) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + var sessionID string - if legacySessions { + if legacySessions && !usesNewProtocol { sessionID = req.Header.Get(sessionIDHeader) if sessionID == "" { sessionID = server.opts.GetSessionID() @@ -359,11 +369,6 @@ func (h *StreamableHTTPHandler) serveStateless(w http.ResponseWriter, req *http. logger: h.opts.Logger, } - connectOpts, err := h.ephemeralConnectOpts(req) - if err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) - return - } session, err := connectStreamable(req.Context(), server, transport, connectOpts) if err != nil { h.opts.Logger.Error(fmt.Sprintf("failed to connect: %v", err)) @@ -389,10 +394,23 @@ func (h *StreamableHTTPHandler) serveStatelessLegacyDELETE(w http.ResponseWriter } // ephemeralConnectOpts peeks at the request body to determine whether it -// contains an initialize or initialized message. If not, default session state -// is constructed so that the session doesn't reject the request. +// contains an initialize or initialized message, or whether any of its +// messages carry the per-request `_meta.protocolVersion` field that signals +// the >= 2026-06-30 sessionless protocol (SEP-2575). +// +// For old-protocol requests, default session state is synthesized so that +// the session's init gate doesn't reject the request. For new-protocol +// requests, no state is synthesized: the request carries its identity in +// `_meta`, and [ServerSession.InitializeParams] returning nil is the +// migration signal that handlers should read identity via the per-request +// accessors on [ServerRequest]. +// // It is used for both stateless servers and stateful servers with no session ID. -func (h *StreamableHTTPHandler) ephemeralConnectOpts(req *http.Request) (*ServerSessionOptions, error) { +// +// The returned usesNewProtocol bool reports whether any request in the body +// carried `_meta.protocolVersion`. Callers may use it to suppress legacy +// session-handling behavior (e.g., reading Mcp-Session-Id) for such requests. +func (h *StreamableHTTPHandler) ephemeralConnectOpts(req *http.Request) (opts *ServerSessionOptions, usesNewProtocol bool, err error) { protocolVersion := protocolVersionFromContext(req.Context()) if protocolVersion == "" { protocolVersion = protocolVersion20250326 @@ -401,7 +419,7 @@ func (h *StreamableHTTPHandler) ephemeralConnectOpts(req *http.Request) (*Server var hasInitialize, hasInitialized bool body, err := io.ReadAll(req.Body) if err != nil { - return nil, fmt.Errorf("failed to read body") + return nil, false, fmt.Errorf("failed to read body") } req.Body.Close() req.Body = io.NopCloser(bytes.NewBuffer(body)) @@ -415,23 +433,30 @@ func (h *StreamableHTTPHandler) ephemeralConnectOpts(req *http.Request) (*Server case notificationInitialized: hasInitialized = true } + if meta := extractRequestMeta(r.Params); meta != nil { + if _, ok := meta[MetaKeyProtocolVersion].(string); ok { + usesNewProtocol = true + } + } } } } state := new(ServerSessionState) - if !hasInitialize { + // Only synthesize fake InitializeParams/InitializedParams for old-protocol + // requests. + if !hasInitialize && !usesNewProtocol { state.InitializeParams = &InitializeParams{ ProtocolVersion: protocolVersion, } } - if !hasInitialized { + if !hasInitialized && !usesNewProtocol { state.InitializedParams = new(InitializedParams) } state.LogLevel = "info" return &ServerSessionOptions{ State: state, - }, nil + }, usesNewProtocol, nil } func connectStreamable(ctx context.Context, server *Server, transport *StreamableServerTransport, opts *ServerSessionOptions) (*ServerSession, error) { @@ -576,7 +601,7 @@ func (h *StreamableHTTPHandler) serveStatefulPOST(w http.ResponseWriter, req *ht // that arrives before a session exists (e.g. initialize or ping) on a // server configured this way. if sessionID == "" { - connectOpts, err := h.ephemeralConnectOpts(req) + connectOpts, _, err := h.ephemeralConnectOpts(req) if err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return diff --git a/mcp/streamable_test.go b/mcp/streamable_test.go index d2e54224..681aac1c 100644 --- a/mcp/streamable_test.go +++ b/mcp/streamable_test.go @@ -3207,3 +3207,347 @@ func TestStandaloneSSEEmitsCommentForHTTP2Flush(t *testing.T) { t.Fatal("timed out waiting for first SSE bytes; the standalone SSE stream must emit a DATA frame immediately so HTTP/2 reverse proxies don't buffer the HEADERS frame") } } + +// newProtocolBody builds a raw JSON body for a tools/call request that +// carries the >= 2026-06-30 per-request _meta fields. +func newProtocolBody(t *testing.T, toolName string, args any) []byte { + t.Helper() + rawArgs, err := json.Marshal(args) + if err != nil { + t.Fatal(err) + } + body, err := json.Marshal(map[string]any{ + "jsonrpc": "2.0", + "id": 1, + "method": "tools/call", + "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{}}, + }, + "name": toolName, + "arguments": json.RawMessage(rawArgs), + }, + }) + if err != nil { + t.Fatal(err) + } + return body +} + +func TestEphemeralConnectOpts_NewProtocol(t *testing.T) { + mkReq := func(body []byte) *http.Request { + r := httptest.NewRequest(http.MethodPost, "/", bytes.NewReader(body)) + r.Header.Set("Content-Type", "application/json") + return r + } + + h := &StreamableHTTPHandler{opts: StreamableHTTPOptions{}} + + t.Run("new-protocol request: no synthetic state", func(t *testing.T) { + body := newProtocolBody(t, "x", struct{}{}) + opts, usesNew, err := h.ephemeralConnectOpts(mkReq(body)) + if err != nil { + t.Fatal(err) + } + if !usesNew { + t.Errorf("usesNewProtocol = false, want true") + } + if opts.State.InitializeParams != nil { + t.Errorf("InitializeParams = %+v, want nil for new-protocol request", opts.State.InitializeParams) + } + if opts.State.InitializedParams != nil { + t.Errorf("InitializedParams = %+v, want nil for new-protocol request", opts.State.InitializedParams) + } + }) + + t.Run("old-protocol request: synthetic state preserved", func(t *testing.T) { + body, err := json.Marshal(map[string]any{ + "jsonrpc": "2.0", + "id": 1, + "method": "tools/call", + "params": map[string]any{"name": "x", "arguments": map[string]any{}}, + }) + if err != nil { + t.Fatal(err) + } + opts, usesNew, err := h.ephemeralConnectOpts(mkReq(body)) + if err != nil { + t.Fatal(err) + } + if usesNew { + t.Errorf("usesNewProtocol = true, want false for old-protocol request") + } + if opts.State.InitializeParams == nil { + t.Errorf("InitializeParams = nil, want synthetic value for old-protocol request") + } + if opts.State.InitializedParams == nil { + t.Errorf("InitializedParams = nil, want synthetic value for old-protocol request") + } + }) + + t.Run("initialize request: no synthetic InitializeParams", func(t *testing.T) { + body, err := json.Marshal(map[string]any{ + "jsonrpc": "2.0", + "id": 1, + "method": methodInitialize, + "params": map[string]any{"protocolVersion": protocolVersion20250618}, + }) + if err != nil { + t.Fatal(err) + } + opts, usesNew, err := h.ephemeralConnectOpts(mkReq(body)) + if err != nil { + t.Fatal(err) + } + if usesNew { + t.Errorf("usesNewProtocol = true, want false") + } + if opts.State.InitializeParams != nil { + t.Errorf("InitializeParams = %+v, want nil (real initialize handler will populate it)", opts.State.InitializeParams) + } + }) +} + +// statelessHandlerCapture builds a stateless server with a single tool whose +// handler captures everything we want to assert about the per-request view of +// the session and the new-protocol accessors. +type statelessHandlerCapture struct { + mu sync.Mutex + sessionInitParams *InitializeParams + reqProtocolVersion string + reqClientInfo *Implementation + reqClientCapabilities *ClientCapabilities +} + +func TestStreamableStateless_NewProtocolSession_NoFakeInit(t *testing.T) { + capture := &statelessHandlerCapture{} + mcpServer := NewServer(testImpl, nil) + AddTool(mcpServer, &Tool{Name: "capture", Description: "captures request info"}, + func(ctx context.Context, req *CallToolRequest, args struct{}) (*CallToolResult, any, error) { + capture.mu.Lock() + defer capture.mu.Unlock() + capture.sessionInitParams = req.Session.InitializeParams() + capture.reqProtocolVersion = req.ProtocolVersion() + capture.reqClientInfo = req.ClientInfo() + capture.reqClientCapabilities = req.ClientCapabilities() + return &CallToolResult{Content: []Content{&TextContent{Text: "ok"}}}, nil, nil + }) + + handler := NewStreamableHTTPHandler( + func(*http.Request) *Server { return mcpServer }, + &StreamableHTTPOptions{Stateless: true}, + ) + httpServer := httptest.NewServer(handler) + defer httpServer.Close() + + body := newProtocolBody(t, "capture", struct{}{}) + httpReq, err := http.NewRequest(http.MethodPost, httpServer.URL, bytes.NewReader(body)) + if err != nil { + t.Fatal(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.Fatal(err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + respBody, _ := io.ReadAll(resp.Body) + t.Fatalf("status = %d, want 200; body = %s", resp.StatusCode, respBody) + } + + capture.mu.Lock() + defer capture.mu.Unlock() + if capture.sessionInitParams != nil { + t.Errorf("Session.InitializeParams() = %+v, want nil for new-protocol session", capture.sessionInitParams) + } + if got, want := capture.reqProtocolVersion, protocolVersion20260630; got != want { + t.Errorf("req.ProtocolVersion() = %q, want %q", got, want) + } + if capture.reqClientInfo == nil || capture.reqClientInfo.Name != "new-proto-client" { + t.Errorf("req.ClientInfo() = %+v, want Name=new-proto-client", capture.reqClientInfo) + } + if capture.reqClientCapabilities == nil || capture.reqClientCapabilities.Sampling == nil { + t.Errorf("req.ClientCapabilities() = %+v, want non-nil Sampling", capture.reqClientCapabilities) + } +} + +func TestStreamableStateless_OldProtocolUnchanged(t *testing.T) { + // Regression: an old-protocol request to a stateless server must still + // observe a non-nil (synthetic) InitializeParams on the session, so + // existing handlers and the init gate continue to work. + capture := &statelessHandlerCapture{} + mcpServer := NewServer(testImpl, nil) + AddTool(mcpServer, &Tool{Name: "capture", Description: "captures request info"}, + func(ctx context.Context, req *CallToolRequest, args struct{}) (*CallToolResult, any, error) { + capture.mu.Lock() + defer capture.mu.Unlock() + capture.sessionInitParams = req.Session.InitializeParams() + capture.reqProtocolVersion = req.ProtocolVersion() + return &CallToolResult{Content: []Content{&TextContent{Text: "ok"}}}, nil, nil + }) + + handler := NewStreamableHTTPHandler( + func(*http.Request) *Server { return mcpServer }, + &StreamableHTTPOptions{Stateless: true}, + ) + httpServer := httptest.NewServer(handler) + defer httpServer.Close() + + body, err := json.Marshal(map[string]any{ + "jsonrpc": "2.0", + "id": 1, + "method": "tools/call", + "params": map[string]any{"name": "capture", "arguments": map[string]any{}}, + }) + if err != nil { + t.Fatal(err) + } + httpReq, err := http.NewRequest(http.MethodPost, httpServer.URL, bytes.NewReader(body)) + if err != nil { + t.Fatal(err) + } + httpReq.Header.Set("Content-Type", "application/json") + httpReq.Header.Set("Accept", "application/json, text/event-stream") + httpReq.Header.Set("MCP-Protocol-Version", protocolVersion20250618) + + resp, err := http.DefaultClient.Do(httpReq) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + respBody, _ := io.ReadAll(resp.Body) + t.Fatalf("status = %d, want 200; body = %s", resp.StatusCode, respBody) + } + + capture.mu.Lock() + defer capture.mu.Unlock() + if capture.sessionInitParams == nil { + t.Errorf("Session.InitializeParams() = nil, want synthetic non-nil for old-protocol session") + } + if got, want := capture.reqProtocolVersion, protocolVersion20250618; got != want { + t.Errorf("req.ProtocolVersion() = %q (via synthetic session), want %q from MCP-Protocol-Version header", got, want) + } +} + +func TestStreamableStateless_LegacySessionIgnoredForNewProtocol(t *testing.T) { + // Under the legacy `allowsessionsinstateless=1` compat flag, stateless + // servers normally read Mcp-Session-Id from the request and call + // GetSessionID. For new-protocol requests, those legacy behaviors must + // be skipped: the session is fully sessionless. + prev := allowsessionsinstateless + allowsessionsinstateless = "1" + t.Cleanup(func() { allowsessionsinstateless = prev }) + + var capturedSessionID string + mcpServer := NewServer(testImpl, nil) + AddTool(mcpServer, &Tool{Name: "capture", Description: "captures session id"}, + func(ctx context.Context, req *CallToolRequest, args struct{}) (*CallToolResult, any, error) { + capturedSessionID = req.Session.ID() + return &CallToolResult{Content: []Content{&TextContent{Text: "ok"}}}, nil, nil + }) + + getSessionIDCalled := false + handler := NewStreamableHTTPHandler( + func(*http.Request) *Server { return mcpServer }, + &StreamableHTTPOptions{Stateless: true}, + ) + // Patch the server's GetSessionID to detect whether it was consulted. + mcpServer.opts.GetSessionID = func() string { + getSessionIDCalled = true + return "should-not-be-used" + } + httpServer := httptest.NewServer(handler) + defer httpServer.Close() + + body := newProtocolBody(t, "capture", struct{}{}) + httpReq, err := http.NewRequest(http.MethodPost, httpServer.URL, bytes.NewReader(body)) + if err != nil { + t.Fatal(err) + } + httpReq.Header.Set("Content-Type", "application/json") + httpReq.Header.Set("Accept", "application/json, text/event-stream") + // Explicitly send a session-ID header that the legacy compat path would + // normally honor. For new-protocol requests it must be ignored. + httpReq.Header.Set(sessionIDHeader, "legacy-client-supplied-id") + + resp, err := http.DefaultClient.Do(httpReq) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + respBody, _ := io.ReadAll(resp.Body) + t.Fatalf("status = %d, want 200; body = %s", resp.StatusCode, respBody) + } + + if capturedSessionID != "" { + t.Errorf("Session.ID() = %q, want empty (new-protocol request must ignore Mcp-Session-Id)", capturedSessionID) + } + if getSessionIDCalled { + t.Errorf("server.opts.GetSessionID was consulted for a new-protocol request; want it to be skipped") + } + if echoed := resp.Header.Get(sessionIDHeader); echoed != "" { + t.Errorf("response %s header = %q, want empty for new-protocol request", sessionIDHeader, echoed) + } +} + +func TestStreamableStateless_LegacySessionHonoredForOldProtocol(t *testing.T) { + // Regression: under `allowsessionsinstateless=1`, an OLD-protocol request + // must still see the legacy session-handling behavior (Mcp-Session-Id + // honored, GetSessionID consulted) so existing deployments don't break. + prev := allowsessionsinstateless + allowsessionsinstateless = "1" + t.Cleanup(func() { allowsessionsinstateless = prev }) + + var capturedSessionID string + mcpServer := NewServer(testImpl, nil) + AddTool(mcpServer, &Tool{Name: "capture", Description: "captures session id"}, + func(ctx context.Context, req *CallToolRequest, args struct{}) (*CallToolResult, any, error) { + capturedSessionID = req.Session.ID() + return &CallToolResult{Content: []Content{&TextContent{Text: "ok"}}}, nil, nil + }) + + handler := NewStreamableHTTPHandler( + func(*http.Request) *Server { return mcpServer }, + &StreamableHTTPOptions{Stateless: true}, + ) + httpServer := httptest.NewServer(handler) + defer httpServer.Close() + + body, err := json.Marshal(map[string]any{ + "jsonrpc": "2.0", + "id": 1, + "method": "tools/call", + "params": map[string]any{"name": "capture", "arguments": map[string]any{}}, + }) + if err != nil { + t.Fatal(err) + } + httpReq, err := http.NewRequest(http.MethodPost, httpServer.URL, bytes.NewReader(body)) + if err != nil { + t.Fatal(err) + } + httpReq.Header.Set("Content-Type", "application/json") + httpReq.Header.Set("Accept", "application/json, text/event-stream") + httpReq.Header.Set(sessionIDHeader, "old-protocol-session-id") + + resp, err := http.DefaultClient.Do(httpReq) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + respBody, _ := io.ReadAll(resp.Body) + t.Fatalf("status = %d, want 200; body = %s", resp.StatusCode, respBody) + } + + if capturedSessionID != "old-protocol-session-id" { + t.Errorf("Session.ID() = %q, want %q (legacy header should be honored for old-protocol requests)", capturedSessionID, "old-protocol-session-id") + } +} From 98b2a442de0206368dc275f457adf46c448ca20e Mon Sep 17 00:00:00 2001 From: guglielmoc Date: Tue, 19 May 2026 14:35:07 +0000 Subject: [PATCH 02/40] fix: correctly report MethodNotFound error codes in new-protocol request rejections and update associated tests --- mcp/server.go | 16 ++++++------ mcp/shared.go | 34 +++++++------------------- mcp/shared_test.go | 61 ++++++++++++++++++++++++++++++++++++++++------ 3 files changed, 70 insertions(+), 41 deletions(-) diff --git a/mcp/server.go b/mcp/server.go index 912034b2..f2b7a254 100644 --- a/mcp/server.go +++ b/mcp/server.go @@ -62,11 +62,7 @@ type ServerOptions struct { Instructions string // Logger may be set to a non-nil value to enable logging of server activity. Logger *slog.Logger - // InitializedHandler, if non-nil, is called when - // "notifications/initialized" is received. - // - // Deprecated: the >= 2026-06-30 protocol removes the initialization - // handshake, so this handler is never invoked for new-protocol clients. + // If non-nil, called when "notifications/initialized" is received. InitializedHandler func(context.Context, *InitializedRequest) // PageSize is the maximum number of items to return in a single page for // list methods (e.g. ListTools). @@ -1457,8 +1453,7 @@ func (ss *ServerSession) handle(ctx context.Context, req *jsonrpc.Request) (any, // Per-request protocol detection (SEP-2575): if the request carries // `io.modelcontextprotocol/protocolVersion` in its `_meta` field, it // follows the new sessionless protocol. The initialization gate is - // skipped for such requests, since the new protocol has no `initialize` - // handshake; but the other required `_meta` fields must be present. + // skipped for such requests. usesNewProtocol, perRequestErr := validateRequestMeta(req) if perRequestErr != nil { return nil, perRequestErr @@ -1470,8 +1465,11 @@ func (ss *ServerSession) handle(ctx context.Context, req *jsonrpc.Request) (any, // protocol version specified will be rejected with `Method not found`." if req.Method == methodInitialize && usesNewProtocol { ss.server.opts.Logger.Error("initialize is not supported in the new protocol", "method", req.Method) - return nil, fmt.Errorf("%w: %q is not supported in the new protocol; use %q instead", - jsonrpc2.ErrNotHandled, methodInitialize, "server/discover") + return nil, &jsonrpc.Error{ + Code: jsonrpc.CodeMethodNotFound, + Message: fmt.Sprintf("%q is not supported in the new protocol; use %q instead", + methodInitialize, "server/discover"), + } } // From the spec: diff --git a/mcp/shared.go b/mcp/shared.go index 65197898..ae4fd582 100644 --- a/mcp/shared.go +++ b/mcp/shared.go @@ -466,8 +466,7 @@ func setProgressToken(p Params, pt any) { } // extractRequestMeta performs a lightweight partial unmarshal of the `_meta` -// field from a JSON-RPC request's raw params. It returns nil if params are -// missing, malformed, or do not contain a `_meta` object. +// field from a JSON-RPC request's raw params. func extractRequestMeta(rawParams json.RawMessage) Meta { if len(rawParams) == 0 { return nil @@ -482,17 +481,9 @@ func extractRequestMeta(rawParams json.RawMessage) Meta { } // validateRequestMeta inspects a JSON-RPC request to detect whether it follows -// the >= 2026-06-30 protocol via the `_meta` field. If so, it validates that -// the required `_meta` fields (clientInfo, clientCapabilities) are present. -// -// It returns: -// - usesNewProtocol: true if `io.modelcontextprotocol/protocolVersion` was -// present in `_meta`. -// - err: a JSON-RPC error if required `_meta` fields are missing or -// malformed for a new-protocol request. -// -// Notifications are exempt from `_meta` validation (no clientInfo / -// clientCapabilities required), since they do not establish protocol state. +// the >= 2026-06-30 protocol via the `_meta` field. +// It returns true if `io.modelcontextprotocol/protocolVersion`, +// `io.modelcontextprotocol/clientInfo` and `io.modelcontextprotocol/clientCapabilities` were present in `_meta`. func validateRequestMeta(req *jsonrpc.Request) (usesNewProtocol bool, err error) { meta := extractRequestMeta(req.Params) if meta == nil { @@ -501,8 +492,7 @@ func validateRequestMeta(req *jsonrpc.Request) (usesNewProtocol bool, err error) if _, ok := meta[MetaKeyProtocolVersion].(string); !ok { return false, nil } - // Notifications do not carry full client identity; only RPC calls - // following the new protocol must include it. + // Notifications do not carry full client identity if !req.IsCall() { return true, nil } @@ -586,7 +576,7 @@ func (r *ServerRequest[P]) GetExtra() *RequestExtra { return r.Extra } // For requests following the >= 2026-06-30 protocol, the value is read from // the per-request `_meta` field. For older protocol requests, the value falls // back to the session-level [InitializeParams] established during the -// initialize handshake. Returns "" if neither is available. +// initialize handshake. func (r *ServerRequest[P]) ProtocolVersion() string { if m := getRequestMeta(r); m != nil { if v, ok := m[MetaKeyProtocolVersion].(string); ok { @@ -605,8 +595,7 @@ func (r *ServerRequest[P]) ProtocolVersion() string { // // For requests following the >= 2026-06-30 protocol, the value is read from // the per-request `_meta` field. For older protocol requests, the value falls -// back to the session-level [InitializeParams]. Returns nil if neither -// source provides the field. +// back to the session-level [InitializeParams]. func (r *ServerRequest[P]) ClientInfo() *Implementation { if m := getRequestMeta(r); m != nil { if v, ok := decodeMetaValue[*Implementation](m, MetaKeyClientInfo); ok { @@ -625,8 +614,7 @@ func (r *ServerRequest[P]) ClientInfo() *Implementation { // // For requests following the >= 2026-06-30 protocol, the value is read from // the per-request `_meta` field. For older protocol requests, the value falls -// back to the session-level [InitializeParams]. Returns nil if neither -// source provides the field. +// back to the session-level [InitializeParams]. func (r *ServerRequest[P]) ClientCapabilities() *ClientCapabilities { if m := getRequestMeta(r); m != nil { if v, ok := decodeMetaValue[*ClientCapabilities](m, MetaKeyClientCapabilities); ok { @@ -665,12 +653,8 @@ func decodeMetaValue[T any](m map[string]any, key string) (T, bool) { if v, ok := raw.(T); ok { return v, true } - data, err := json.Marshal(raw) - if err != nil { - return zero, false - } var v T - if err := internaljson.Unmarshal(data, &v); err != nil { + if err := remarshal(raw, &v); err != nil { return zero, false } return v, true diff --git a/mcp/shared_test.go b/mcp/shared_test.go index 1d39682c..cc11ed1e 100644 --- a/mcp/shared_test.go +++ b/mcp/shared_test.go @@ -12,7 +12,6 @@ import ( "testing" "github.com/google/go-cmp/cmp" - "github.com/modelcontextprotocol/go-sdk/internal/jsonrpc2" "github.com/modelcontextprotocol/go-sdk/jsonrpc" ) @@ -314,21 +313,69 @@ func TestServerSessionHandle_RejectsInitializeOnNewProtocol(t *testing.T) { if err == nil { t.Fatal("expected error rejecting initialize, got nil") } - if !errors.Is(err, jsonrpc2.ErrNotHandled) { - t.Errorf("error = %v, want it to wrap jsonrpc2.ErrNotHandled (so the wire returns -32601)", err) + var jerr *jsonrpc.Error + if !errors.As(err, &jerr) { + t.Fatalf("error type = %T, want *jsonrpc.Error so the wire returns the right code", err) } - if !strings.Contains(err.Error(), "initialize") { - t.Errorf("error message %q does not mention %q", err.Error(), "initialize") + if jerr.Code != jsonrpc.CodeMethodNotFound { + t.Errorf("error code = %d, want %d (CodeMethodNotFound = -32601)", jerr.Code, jsonrpc.CodeMethodNotFound) + } + if !strings.Contains(jerr.Message, "initialize") { + t.Errorf("error message %q does not mention %q", jerr.Message, "initialize") } } else { // Old-protocol initialize should be dispatched normally; any - // error here means the rejection branch fired incorrectly. - if err != nil && errors.Is(err, jsonrpc2.ErrNotHandled) { + // CodeMethodNotFound here would mean the rejection branch + // fired incorrectly. + var jerr *jsonrpc.Error + if errors.As(err, &jerr) && jerr.Code == jsonrpc.CodeMethodNotFound { t.Errorf("old-protocol initialize was incorrectly rejected: %v", err) } } }) } + + t.Run("rejection error encodes to wire as code -32601", func(t *testing.T) { + // Belt-and-braces check that the error type produced by handle() + // actually serializes to JSON-RPC code -32601, not a bare 0. + ss := &ServerSession{server: NewServer(testImpl, nil)} + id, err := jsonrpc.MakeID("test") + if err != nil { + t.Fatal(err) + } + req := &jsonrpc.Request{ + ID: id, + Method: methodInitialize, + Params: mustParams(t, map[string]any{ + "_meta": map[string]any{ + MetaKeyProtocolVersion: protocolVersion20260630, + MetaKeyClientInfo: map[string]any{"name": "c", "version": "1"}, + MetaKeyClientCapabilities: map[string]any{}, + }, + "protocolVersion": protocolVersion20260630, + }), + } + _, handleErr := ss.handle(context.Background(), req) + if handleErr == nil { + t.Fatal("expected rejection error, got nil") + } + data, encErr := jsonrpc.EncodeMessage(&jsonrpc.Response{ID: id, Error: handleErr.(*jsonrpc.Error)}) + if encErr != nil { + t.Fatal(encErr) + } + var wire struct { + Error struct { + Code int `json:"code"` + Message string `json:"message"` + } `json:"error"` + } + if err := json.Unmarshal(data, &wire); err != nil { + t.Fatal(err) + } + if wire.Error.Code != jsonrpc.CodeMethodNotFound { + t.Errorf("wire error code = %d, want %d; full response = %s", wire.Error.Code, jsonrpc.CodeMethodNotFound, data) + } + }) } // TODO(v0.3.0): rewrite this test. From 222d145e72ad2ba12ffb837624664e921fa40a30 Mon Sep 17 00:00:00 2001 From: guglielmoc Date: Tue, 19 May 2026 15:03:52 +0000 Subject: [PATCH 03/40] refactor: remove legacy stateless session handling logic and associated regression tests --- mcp/streamable.go | 11 +-- mcp/streamable_test.go | 176 ----------------------------------------- 2 files changed, 2 insertions(+), 185 deletions(-) diff --git a/mcp/streamable.go b/mcp/streamable.go index 6470eac4..aac1b2ee 100644 --- a/mcp/streamable.go +++ b/mcp/streamable.go @@ -343,10 +343,6 @@ func (h *StreamableHTTPHandler) serveStateless(w http.ResponseWriter, req *http. return } - // Peek at the body to determine whether this is a new-protocol request. - // New-protocol requests are fully sessionless: even under the legacy - // `allowsessionsinstateless` compat flag, we must not read or generate - // a session ID for them. connectOpts, usesNewProtocol, err := h.ephemeralConnectOpts(req) if err != nil { http.Error(w, err.Error(), http.StatusBadRequest) @@ -401,15 +397,12 @@ func (h *StreamableHTTPHandler) serveStatelessLegacyDELETE(w http.ResponseWriter // For old-protocol requests, default session state is synthesized so that // the session's init gate doesn't reject the request. For new-protocol // requests, no state is synthesized: the request carries its identity in -// `_meta`, and [ServerSession.InitializeParams] returning nil is the -// migration signal that handlers should read identity via the per-request -// accessors on [ServerRequest]. +// `_meta`. // // It is used for both stateless servers and stateful servers with no session ID. // // The returned usesNewProtocol bool reports whether any request in the body -// carried `_meta.protocolVersion`. Callers may use it to suppress legacy -// session-handling behavior (e.g., reading Mcp-Session-Id) for such requests. +// carried `_meta.protocolVersion`. func (h *StreamableHTTPHandler) ephemeralConnectOpts(req *http.Request) (opts *ServerSessionOptions, usesNewProtocol bool, err error) { protocolVersion := protocolVersionFromContext(req.Context()) if protocolVersion == "" { diff --git a/mcp/streamable_test.go b/mcp/streamable_test.go index 681aac1c..6f579a03 100644 --- a/mcp/streamable_test.go +++ b/mcp/streamable_test.go @@ -3375,179 +3375,3 @@ func TestStreamableStateless_NewProtocolSession_NoFakeInit(t *testing.T) { t.Errorf("req.ClientCapabilities() = %+v, want non-nil Sampling", capture.reqClientCapabilities) } } - -func TestStreamableStateless_OldProtocolUnchanged(t *testing.T) { - // Regression: an old-protocol request to a stateless server must still - // observe a non-nil (synthetic) InitializeParams on the session, so - // existing handlers and the init gate continue to work. - capture := &statelessHandlerCapture{} - mcpServer := NewServer(testImpl, nil) - AddTool(mcpServer, &Tool{Name: "capture", Description: "captures request info"}, - func(ctx context.Context, req *CallToolRequest, args struct{}) (*CallToolResult, any, error) { - capture.mu.Lock() - defer capture.mu.Unlock() - capture.sessionInitParams = req.Session.InitializeParams() - capture.reqProtocolVersion = req.ProtocolVersion() - return &CallToolResult{Content: []Content{&TextContent{Text: "ok"}}}, nil, nil - }) - - handler := NewStreamableHTTPHandler( - func(*http.Request) *Server { return mcpServer }, - &StreamableHTTPOptions{Stateless: true}, - ) - httpServer := httptest.NewServer(handler) - defer httpServer.Close() - - body, err := json.Marshal(map[string]any{ - "jsonrpc": "2.0", - "id": 1, - "method": "tools/call", - "params": map[string]any{"name": "capture", "arguments": map[string]any{}}, - }) - if err != nil { - t.Fatal(err) - } - httpReq, err := http.NewRequest(http.MethodPost, httpServer.URL, bytes.NewReader(body)) - if err != nil { - t.Fatal(err) - } - httpReq.Header.Set("Content-Type", "application/json") - httpReq.Header.Set("Accept", "application/json, text/event-stream") - httpReq.Header.Set("MCP-Protocol-Version", protocolVersion20250618) - - resp, err := http.DefaultClient.Do(httpReq) - if err != nil { - t.Fatal(err) - } - defer resp.Body.Close() - if resp.StatusCode != http.StatusOK { - respBody, _ := io.ReadAll(resp.Body) - t.Fatalf("status = %d, want 200; body = %s", resp.StatusCode, respBody) - } - - capture.mu.Lock() - defer capture.mu.Unlock() - if capture.sessionInitParams == nil { - t.Errorf("Session.InitializeParams() = nil, want synthetic non-nil for old-protocol session") - } - if got, want := capture.reqProtocolVersion, protocolVersion20250618; got != want { - t.Errorf("req.ProtocolVersion() = %q (via synthetic session), want %q from MCP-Protocol-Version header", got, want) - } -} - -func TestStreamableStateless_LegacySessionIgnoredForNewProtocol(t *testing.T) { - // Under the legacy `allowsessionsinstateless=1` compat flag, stateless - // servers normally read Mcp-Session-Id from the request and call - // GetSessionID. For new-protocol requests, those legacy behaviors must - // be skipped: the session is fully sessionless. - prev := allowsessionsinstateless - allowsessionsinstateless = "1" - t.Cleanup(func() { allowsessionsinstateless = prev }) - - var capturedSessionID string - mcpServer := NewServer(testImpl, nil) - AddTool(mcpServer, &Tool{Name: "capture", Description: "captures session id"}, - func(ctx context.Context, req *CallToolRequest, args struct{}) (*CallToolResult, any, error) { - capturedSessionID = req.Session.ID() - return &CallToolResult{Content: []Content{&TextContent{Text: "ok"}}}, nil, nil - }) - - getSessionIDCalled := false - handler := NewStreamableHTTPHandler( - func(*http.Request) *Server { return mcpServer }, - &StreamableHTTPOptions{Stateless: true}, - ) - // Patch the server's GetSessionID to detect whether it was consulted. - mcpServer.opts.GetSessionID = func() string { - getSessionIDCalled = true - return "should-not-be-used" - } - httpServer := httptest.NewServer(handler) - defer httpServer.Close() - - body := newProtocolBody(t, "capture", struct{}{}) - httpReq, err := http.NewRequest(http.MethodPost, httpServer.URL, bytes.NewReader(body)) - if err != nil { - t.Fatal(err) - } - httpReq.Header.Set("Content-Type", "application/json") - httpReq.Header.Set("Accept", "application/json, text/event-stream") - // Explicitly send a session-ID header that the legacy compat path would - // normally honor. For new-protocol requests it must be ignored. - httpReq.Header.Set(sessionIDHeader, "legacy-client-supplied-id") - - resp, err := http.DefaultClient.Do(httpReq) - if err != nil { - t.Fatal(err) - } - defer resp.Body.Close() - if resp.StatusCode != http.StatusOK { - respBody, _ := io.ReadAll(resp.Body) - t.Fatalf("status = %d, want 200; body = %s", resp.StatusCode, respBody) - } - - if capturedSessionID != "" { - t.Errorf("Session.ID() = %q, want empty (new-protocol request must ignore Mcp-Session-Id)", capturedSessionID) - } - if getSessionIDCalled { - t.Errorf("server.opts.GetSessionID was consulted for a new-protocol request; want it to be skipped") - } - if echoed := resp.Header.Get(sessionIDHeader); echoed != "" { - t.Errorf("response %s header = %q, want empty for new-protocol request", sessionIDHeader, echoed) - } -} - -func TestStreamableStateless_LegacySessionHonoredForOldProtocol(t *testing.T) { - // Regression: under `allowsessionsinstateless=1`, an OLD-protocol request - // must still see the legacy session-handling behavior (Mcp-Session-Id - // honored, GetSessionID consulted) so existing deployments don't break. - prev := allowsessionsinstateless - allowsessionsinstateless = "1" - t.Cleanup(func() { allowsessionsinstateless = prev }) - - var capturedSessionID string - mcpServer := NewServer(testImpl, nil) - AddTool(mcpServer, &Tool{Name: "capture", Description: "captures session id"}, - func(ctx context.Context, req *CallToolRequest, args struct{}) (*CallToolResult, any, error) { - capturedSessionID = req.Session.ID() - return &CallToolResult{Content: []Content{&TextContent{Text: "ok"}}}, nil, nil - }) - - handler := NewStreamableHTTPHandler( - func(*http.Request) *Server { return mcpServer }, - &StreamableHTTPOptions{Stateless: true}, - ) - httpServer := httptest.NewServer(handler) - defer httpServer.Close() - - body, err := json.Marshal(map[string]any{ - "jsonrpc": "2.0", - "id": 1, - "method": "tools/call", - "params": map[string]any{"name": "capture", "arguments": map[string]any{}}, - }) - if err != nil { - t.Fatal(err) - } - httpReq, err := http.NewRequest(http.MethodPost, httpServer.URL, bytes.NewReader(body)) - if err != nil { - t.Fatal(err) - } - httpReq.Header.Set("Content-Type", "application/json") - httpReq.Header.Set("Accept", "application/json, text/event-stream") - httpReq.Header.Set(sessionIDHeader, "old-protocol-session-id") - - resp, err := http.DefaultClient.Do(httpReq) - if err != nil { - t.Fatal(err) - } - defer resp.Body.Close() - if resp.StatusCode != http.StatusOK { - respBody, _ := io.ReadAll(resp.Body) - t.Fatalf("status = %d, want 200; body = %s", resp.StatusCode, respBody) - } - - if capturedSessionID != "old-protocol-session-id" { - t.Errorf("Session.ID() = %q, want %q (legacy header should be honored for old-protocol requests)", capturedSessionID, "old-protocol-session-id") - } -} From 4beb079d9a575171e96e35f7b6f1a40e0ca7299a Mon Sep 17 00:00:00 2001 From: guglielmoc Date: Wed, 20 May 2026 13:55:53 +0000 Subject: [PATCH 04/40] feat: enforce SEP-2575 protocol version header validation and restrict stateless protocol to stateless HTTP servers --- mcp/streamable.go | 38 ++++++++- mcp/streamable_test.go | 178 ++++++++++++++++++++++++++++++++++++++++- 2 files changed, 211 insertions(+), 5 deletions(-) diff --git a/mcp/streamable.go b/mcp/streamable.go index aac1b2ee..f728e6fe 100644 --- a/mcp/streamable.go +++ b/mcp/streamable.go @@ -395,9 +395,7 @@ func (h *StreamableHTTPHandler) serveStatelessLegacyDELETE(w http.ResponseWriter // the >= 2026-06-30 sessionless protocol (SEP-2575). // // For old-protocol requests, default session state is synthesized so that -// the session's init gate doesn't reject the request. For new-protocol -// requests, no state is synthesized: the request carries its identity in -// `_meta`. +// the session's init gate doesn't reject the request. // // It is used for both stateless servers and stateful servers with no session ID. // @@ -1297,6 +1295,7 @@ func (c *streamableServerConn) servePOST(w http.ResponseWriter, req *http.Reques tokenInfo := auth.TokenInfoFromContext(req.Context()) isInitialize := false var initializeProtocolVersion string + headerVersion := req.Header.Get(protocolVersionHeader) for _, msg := range incoming { if jreq, ok := msg.(*jsonrpc.Request); ok { // Preemptively check that this is a valid request, so that we can fail @@ -1314,6 +1313,39 @@ func (c *streamableServerConn) servePOST(w http.ResponseWriter, req *http.Reques initializeProtocolVersion = params.ProtocolVersion } } + // SEP-2575: requests carrying `_meta.protocolVersion` require the + // Mcp-Protocol-Version HTTP header to be present and to match the + // per-request `_meta.protocolVersion` value. + // + // Per the SDK design doc (design/stateless.md), the new (>= + // 2026-06-30) protocol is supported on the HTTP transport only + // when [StreamableHTTPOptions.Stateless] is true. + if meta := extractRequestMeta(jreq.Params); meta != nil { + if metaVersion, ok := meta[MetaKeyProtocolVersion].(string); ok { + if !c.stateless { + http.Error(w, fmt.Sprintf( + "Bad Request: protocol version %q is only supported on stateless HTTP servers (set StreamableHTTPOptions.Stateless = true)", + metaVersion), + http.StatusBadRequest) + return + } + if headerVersion == "" { + http.Error(w, fmt.Sprintf( + "Bad Request: %s header is required for requests carrying %q", + protocolVersionHeader, MetaKeyProtocolVersion), + http.StatusBadRequest) + 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) + return + } + } + } // Include metadata for all requests (including notifications). jreq.Extra = &RequestExtra{ TokenInfo: tokenInfo, diff --git a/mcp/streamable_test.go b/mcp/streamable_test.go index 6f579a03..6335b342 100644 --- a/mcp/streamable_test.go +++ b/mcp/streamable_test.go @@ -3236,7 +3236,7 @@ func newProtocolBody(t *testing.T, toolName string, args any) []byte { return body } -func TestEphemeralConnectOpts_NewProtocol(t *testing.T) { +func TestEphemeralConnectOpts(t *testing.T) { mkReq := func(body []byte) *http.Request { r := httptest.NewRequest(http.MethodPost, "/", bytes.NewReader(body)) r.Header.Set("Content-Type", "application/json") @@ -3262,7 +3262,7 @@ func TestEphemeralConnectOpts_NewProtocol(t *testing.T) { } }) - t.Run("old-protocol request: synthetic state preserved", func(t *testing.T) { + t.Run("old-protocol request: synthetic state populated", func(t *testing.T) { body, err := json.Marshal(map[string]any{ "jsonrpc": "2.0", "id": 1, @@ -3310,6 +3310,62 @@ func TestEphemeralConnectOpts_NewProtocol(t *testing.T) { }) } +// TestServePOST_NewProtocolHeaderCrossCheck verifies that the SEP-2575 +// header/body cross-check runs inside streamableServerConn.servePOST, which +// is the single chokepoint reached by every POST regardless of stateful or +// stateless mode. +func TestServePOST_NewProtocolHeaderCrossCheck(t *testing.T) { + mcpServer := NewServer(testImpl, nil) + AddTool(mcpServer, &Tool{Name: "noop"}, + func(ctx context.Context, req *CallToolRequest, args struct{}) (*CallToolResult, any, error) { + return &CallToolResult{Content: []Content{&TextContent{Text: "ok"}}}, nil, nil + }) + handler := NewStreamableHTTPHandler( + func(*http.Request) *Server { return mcpServer }, + &StreamableHTTPOptions{Stateless: true}, + ) + httpServer := httptest.NewServer(handler) + defer httpServer.Close() + + mkReq := func(headerVersion string) *http.Request { + body := newProtocolBody(t, "noop", struct{}{}) + 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") + if headerVersion != "" { + req.Header.Set(protocolVersionHeader, headerVersion) + } + return req + } + + t.Run("mismatched header: 400", func(t *testing.T) { + resp, err := http.DefaultClient.Do(mkReq("2025-06-18")) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusBadRequest { + body, _ := io.ReadAll(resp.Body) + t.Fatalf("status = %d, want 400; body = %s", resp.StatusCode, body) + } + }) + + t.Run("missing header: 400", func(t *testing.T) { + resp, err := http.DefaultClient.Do(mkReq("")) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusBadRequest { + body, _ := io.ReadAll(resp.Body) + t.Fatalf("status = %d, want 400; body = %s", resp.StatusCode, body) + } + }) +} + // statelessHandlerCapture builds a stateless server with a single tool whose // handler captures everything we want to assert about the per-request view of // the session and the new-protocol accessors. @@ -3322,6 +3378,13 @@ type statelessHandlerCapture struct { } func TestStreamableStateless_NewProtocolSession_NoFakeInit(t *testing.T) { + // SEP-2575: the MCP-Protocol-Version header is mandatory for new-protocol + // requests and must be a supported version. The 2026-06-30 version is + // not yet in the global list, so register it for the duration of the test. + orig := supportedProtocolVersions + supportedProtocolVersions = append(slices.Clone(orig), protocolVersion20260630) + t.Cleanup(func() { supportedProtocolVersions = orig }) + capture := &statelessHandlerCapture{} mcpServer := NewServer(testImpl, nil) AddTool(mcpServer, &Tool{Name: "capture", Description: "captures request info"}, @@ -3349,6 +3412,7 @@ func TestStreamableStateless_NewProtocolSession_NoFakeInit(t *testing.T) { } httpReq.Header.Set("Content-Type", "application/json") httpReq.Header.Set("Accept", "application/json, text/event-stream") + httpReq.Header.Set(protocolVersionHeader, protocolVersion20260630) resp, err := http.DefaultClient.Do(httpReq) if err != nil { @@ -3375,3 +3439,113 @@ func TestStreamableStateless_NewProtocolSession_NoFakeInit(t *testing.T) { t.Errorf("req.ClientCapabilities() = %+v, want non-nil Sampling", capture.reqClientCapabilities) } } + +// TestStreamableStateful_RejectsNewProtocol verifies that a stateful HTTP +// server rejects requests carrying _meta.protocolVersion (i.e. >= 2026-06-30 +// requests) with HTTP 400. The new protocol is +// supported on HTTP only when StreamableHTTPOptions.Stateless=true. +func TestStreamableStateful_RejectsNewProtocol(t *testing.T) { + // Make 2026-06-30 a "known" version so that the request reaches servePOST + // (otherwise the early header validation at ServeHTTP rejects it). + orig := supportedProtocolVersions + supportedProtocolVersions = append(slices.Clone(orig), protocolVersion20260630) + t.Cleanup(func() { supportedProtocolVersions = orig }) + + server := NewServer(testImpl, nil) + AddTool(server, &Tool{Name: "noop"}, + func(ctx context.Context, req *CallToolRequest, args struct{}) (*CallToolResult, any, error) { + return &CallToolResult{Content: []Content{&TextContent{Text: "ok"}}}, nil, nil + }) + handler := NewStreamableHTTPHandler(func(*http.Request) *Server { return server }, nil) + httpServer := httptest.NewServer(handler) + defer httpServer.Close() + + // Initialize a legacy session first. + initBody := strings.NewReader(`{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"test","version":"1.0"}}}`) + initReq, err := http.NewRequest(http.MethodPost, httpServer.URL, initBody) + if err != nil { + t.Fatal(err) + } + initReq.Header.Set("Content-Type", "application/json") + initReq.Header.Set("Accept", "application/json, text/event-stream") + initResp, err := http.DefaultClient.Do(initReq) + if err != nil { + t.Fatal(err) + } + io.Copy(io.Discard, initResp.Body) + initResp.Body.Close() + sessionID := initResp.Header.Get(sessionIDHeader) + if sessionID == "" { + t.Fatalf("initialize response missing %s header", sessionIDHeader) + } + + // Drive the existing session with a new-protocol request whose header and + // body agree. The cross-check passes; the stateful-rejection check fires. + body := newProtocolBody(t, "noop", struct{}{}) + 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(sessionIDHeader, sessionID) + req.Header.Set(protocolVersionHeader, protocolVersion20260630) + req.Header.Set(methodHeader, "tools/call") + req.Header.Set(nameHeader, "noop") + + 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.StatusBadRequest { + t.Fatalf("status = %d, want 400; body = %s", resp.StatusCode, respBody) + } + if !strings.Contains(string(respBody), "stateless") { + t.Errorf("body = %q, want a message mentioning 'stateless'", respBody) + } +} + +// TestStreamableStateless_AcceptsNewProtocol is the positive control: +// confirms that a stateless server still accepts new-protocol requests +// (the rejection in TestStreamableStateful_RejectsNewProtocol must not +// fire on Stateless: true). +func TestStreamableStateless_AcceptsNewProtocol(t *testing.T) { + orig := supportedProtocolVersions + supportedProtocolVersions = append(slices.Clone(orig), protocolVersion20260630) + t.Cleanup(func() { supportedProtocolVersions = orig }) + + server := NewServer(testImpl, nil) + AddTool(server, &Tool{Name: "noop"}, + func(ctx context.Context, req *CallToolRequest, args struct{}) (*CallToolResult, any, error) { + return &CallToolResult{Content: []Content{&TextContent{Text: "ok"}}}, nil, nil + }) + handler := NewStreamableHTTPHandler( + func(*http.Request) *Server { return server }, + &StreamableHTTPOptions{Stateless: true}, + ) + httpServer := httptest.NewServer(handler) + defer httpServer.Close() + + body := newProtocolBody(t, "noop", struct{}{}) + 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, "tools/call") + req.Header.Set(nameHeader, "noop") + + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + respBody, _ := io.ReadAll(resp.Body) + t.Fatalf("status = %d, want 200; body = %s", resp.StatusCode, respBody) + } +} From 23b804bc1867f8aad939bd00d9315ae6abbf28e3 Mon Sep 17 00:00:00 2001 From: guglielmoc Date: Wed, 20 May 2026 14:06:35 +0000 Subject: [PATCH 05/40] test: add required Mcp-Method and Mcp-Name headers to streamable integration tests --- mcp/streamable_test.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/mcp/streamable_test.go b/mcp/streamable_test.go index 6335b342..ac3628e1 100644 --- a/mcp/streamable_test.go +++ b/mcp/streamable_test.go @@ -3413,6 +3413,10 @@ func TestStreamableStateless_NewProtocolSession_NoFakeInit(t *testing.T) { httpReq.Header.Set("Content-Type", "application/json") httpReq.Header.Set("Accept", "application/json, text/event-stream") httpReq.Header.Set(protocolVersionHeader, protocolVersion20260630) + // >= 2026-06-30 also requires the Mcp-Method and Mcp-Name standard + // headers (see streamable_headers.go). + httpReq.Header.Set(methodHeader, "tools/call") + httpReq.Header.Set(nameHeader, "capture") resp, err := http.DefaultClient.Do(httpReq) if err != nil { From 113cc9f0b72643babef757c4c36004a04341e88d Mon Sep 17 00:00:00 2001 From: guglielmoc Date: Wed, 20 May 2026 15:30:26 +0000 Subject: [PATCH 06/40] fix: reject initialize, ping, and notifications/initialized methods in the new protocol session --- mcp/server.go | 23 ++---- mcp/server_test.go | 183 +++++++++++++++++++++++++++++++++++++++++ mcp/shared_test.go | 122 --------------------------- mcp/streamable_test.go | 175 ++++++++++++++------------------------- 4 files changed, 252 insertions(+), 251 deletions(-) diff --git a/mcp/server.go b/mcp/server.go index f2b7a254..f2bc9fac 100644 --- a/mcp/server.go +++ b/mcp/server.go @@ -1459,24 +1459,15 @@ func (ss *ServerSession) handle(ctx context.Context, req *jsonrpc.Request) (any, return nil, perRequestErr } - // SEP-2575 removes the initialization handshake. Reject `initialize` - // requests that opt into the new protocol via `_meta.protocolVersion`, - // per the spec wording: "An `initialize` request with `2026-06-30` - // protocol version specified will be rejected with `Method not found`." - if req.Method == methodInitialize && usesNewProtocol { - ss.server.opts.Logger.Error("initialize is not supported in the new protocol", "method", req.Method) - return nil, &jsonrpc.Error{ - Code: jsonrpc.CodeMethodNotFound, - Message: fmt.Sprintf("%q is not supported in the new protocol; use %q instead", - methodInitialize, "server/discover"), - } - } - - // From the spec: - // "The client SHOULD NOT send requests other than pings before the server - // has responded to the initialize request." switch req.Method { case methodInitialize, methodPing, notificationInitialized: + if usesNewProtocol { + ss.server.opts.Logger.Error("method removed in the new protocol", "method", req.Method) + return nil, &jsonrpc.Error{ + Code: jsonrpc.CodeMethodNotFound, + Message: fmt.Sprintf("%q is not supported in the new protocol", req.Method), + } + } default: if !initialized && !usesNewProtocol { ss.server.opts.Logger.Error("method invalid during initialization", "method", req.Method) diff --git a/mcp/server_test.go b/mcp/server_test.go index 2937ea2b..288fb5ab 100644 --- a/mcp/server_test.go +++ b/mcp/server_test.go @@ -8,6 +8,7 @@ import ( "bytes" "context" "encoding/json" + "errors" "fmt" "log" "log/slog" @@ -19,6 +20,7 @@ import ( "github.com/google/go-cmp/cmp" "github.com/google/jsonschema-go/jsonschema" "github.com/modelcontextprotocol/go-sdk/internal/jsonrpc2" + "github.com/modelcontextprotocol/go-sdk/jsonrpc" ) type testItem struct { @@ -1007,3 +1009,184 @@ func TestServerCapabilitiesOverWire(t *testing.T) { }) } } + +func TestServerSessionHandle_RejectsInitializeOnNewProtocol(t *testing.T) { + // SEP-2575 removes the initialization handshake. An `initialize` request + // that opts into the new protocol via `_meta.protocolVersion` must be + // rejected with `Method not found` (-32601). + mustParams := func(t *testing.T, v any) json.RawMessage { + t.Helper() + data, err := json.Marshal(v) + if err != nil { + t.Fatal(err) + } + return data + } + + tests := []struct { + name string + params any + wantReject bool + }{ + { + name: "initialize with new-protocol _meta is rejected", + params: map[string]any{ + "_meta": map[string]any{ + MetaKeyProtocolVersion: protocolVersion20260630, + MetaKeyClientInfo: map[string]any{"name": "c", "version": "1"}, + MetaKeyClientCapabilities: map[string]any{}, + }, + "protocolVersion": protocolVersion20260630, + }, + wantReject: true, + }, + { + name: "initialize without _meta is allowed (old protocol)", + params: map[string]any{ + "protocolVersion": protocolVersion20251125, + }, + wantReject: false, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + ss := &ServerSession{server: NewServer(testImpl, nil)} + id, err := jsonrpc.MakeID("test") + if err != nil { + t.Fatal(err) + } + req := &jsonrpc.Request{ + ID: id, + Method: methodInitialize, + Params: mustParams(t, tc.params), + } + _, err = ss.handle(context.Background(), req) + if tc.wantReject { + if err == nil { + t.Fatal("expected error rejecting initialize, got nil") + } + var jerr *jsonrpc.Error + if !errors.As(err, &jerr) { + t.Fatalf("error type = %T, want *jsonrpc.Error so the wire returns the right code", err) + } + if jerr.Code != jsonrpc.CodeMethodNotFound { + t.Errorf("error code = %d, want %d (CodeMethodNotFound = -32601)", jerr.Code, jsonrpc.CodeMethodNotFound) + } + if !strings.Contains(jerr.Message, "initialize") { + t.Errorf("error message %q does not mention %q", jerr.Message, "initialize") + } + } else { + // Old-protocol initialize should be dispatched normally; any + // CodeMethodNotFound here would mean the rejection branch + // fired incorrectly. + var jerr *jsonrpc.Error + if errors.As(err, &jerr) && jerr.Code == jsonrpc.CodeMethodNotFound { + t.Errorf("old-protocol initialize was incorrectly rejected: %v", err) + } + } + }) + } + + t.Run("rejection error encodes to wire as code -32601", func(t *testing.T) { + // Belt-and-braces check that the error type produced by handle() + // actually serializes to JSON-RPC code -32601, not a bare 0. + ss := &ServerSession{server: NewServer(testImpl, nil)} + id, err := jsonrpc.MakeID("test") + if err != nil { + t.Fatal(err) + } + req := &jsonrpc.Request{ + ID: id, + Method: methodInitialize, + Params: mustParams(t, map[string]any{ + "_meta": map[string]any{ + MetaKeyProtocolVersion: protocolVersion20260630, + MetaKeyClientInfo: map[string]any{"name": "c", "version": "1"}, + MetaKeyClientCapabilities: map[string]any{}, + }, + "protocolVersion": protocolVersion20260630, + }), + } + _, handleErr := ss.handle(context.Background(), req) + if handleErr == nil { + t.Fatal("expected rejection error, got nil") + } + data, encErr := jsonrpc.EncodeMessage(&jsonrpc.Response{ID: id, Error: handleErr.(*jsonrpc.Error)}) + if encErr != nil { + t.Fatal(encErr) + } + var wire struct { + Error struct { + Code int `json:"code"` + Message string `json:"message"` + } `json:"error"` + } + if err := json.Unmarshal(data, &wire); err != nil { + t.Fatal(err) + } + if wire.Error.Code != jsonrpc.CodeMethodNotFound { + t.Errorf("wire error code = %d, want %d; full response = %s", wire.Error.Code, jsonrpc.CodeMethodNotFound, data) + } + }) +} + +// TestServerSessionHandle_RejectsRemovedMethodsOnNewProtocol verifies that +// the methods removed by SEP-2575 (`initialize`, `notifications/initialized`, +// `ping`) all return Method not found when the request opts into the new +// protocol via `_meta.protocolVersion`. +func TestServerSessionHandle_RejectsRemovedMethodsOnNewProtocol(t *testing.T) { + mustParams := func(t *testing.T, v any) json.RawMessage { + t.Helper() + data, err := json.Marshal(v) + if err != nil { + t.Fatal(err) + } + return data + } + newProtoMeta := map[string]any{ + "_meta": map[string]any{ + MetaKeyProtocolVersion: protocolVersion20260630, + MetaKeyClientInfo: map[string]any{"name": "c", "version": "1"}, + MetaKeyClientCapabilities: map[string]any{}, + }, + } + + tests := []struct { + name string + method string + }{ + {"initialize", methodInitialize}, + {"ping", methodPing}, + {"notifications/initialized", notificationInitialized}, + } + + for _, tc := range tests { + t.Run(tc.name+" rejected on new protocol", func(t *testing.T) { + ss := &ServerSession{server: NewServer(testImpl, nil)} + id, err := jsonrpc.MakeID("test") + if err != nil { + t.Fatal(err) + } + req := &jsonrpc.Request{ + ID: id, + Method: tc.method, + Params: mustParams(t, newProtoMeta), + } + _, err = ss.handle(context.Background(), req) + if err == nil { + t.Fatalf("method %q on new protocol: got nil error, want CodeMethodNotFound", tc.method) + } + var jerr *jsonrpc.Error + if !errors.As(err, &jerr) { + t.Fatalf("error type = %T, want *jsonrpc.Error", err) + } + if jerr.Code != jsonrpc.CodeMethodNotFound { + t.Errorf("method %q: code = %d, want %d", tc.method, jerr.Code, jsonrpc.CodeMethodNotFound) + } + if !strings.Contains(jerr.Message, tc.method) { + t.Errorf("method %q: message %q does not mention method name", tc.method, jerr.Message) + } + }) + } +} diff --git a/mcp/shared_test.go b/mcp/shared_test.go index cc11ed1e..4f6fb163 100644 --- a/mcp/shared_test.go +++ b/mcp/shared_test.go @@ -5,7 +5,6 @@ package mcp import ( - "context" "encoding/json" "errors" "strings" @@ -257,127 +256,6 @@ func TestServerRequest_PerRequestAccessors_Empty(t *testing.T) { } } -func TestServerSessionHandle_RejectsInitializeOnNewProtocol(t *testing.T) { - // SEP-2575 removes the initialization handshake. An `initialize` request - // that opts into the new protocol via `_meta.protocolVersion` must be - // rejected with `Method not found` (-32601). - mustParams := func(t *testing.T, v any) json.RawMessage { - t.Helper() - data, err := json.Marshal(v) - if err != nil { - t.Fatal(err) - } - return data - } - - tests := []struct { - name string - params any - wantReject bool - }{ - { - name: "initialize with new-protocol _meta is rejected", - params: map[string]any{ - "_meta": map[string]any{ - MetaKeyProtocolVersion: protocolVersion20260630, - MetaKeyClientInfo: map[string]any{"name": "c", "version": "1"}, - MetaKeyClientCapabilities: map[string]any{}, - }, - "protocolVersion": protocolVersion20260630, - }, - wantReject: true, - }, - { - name: "initialize without _meta is allowed (old protocol)", - params: map[string]any{ - "protocolVersion": protocolVersion20251125, - }, - wantReject: false, - }, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - ss := &ServerSession{server: NewServer(testImpl, nil)} - id, err := jsonrpc.MakeID("test") - if err != nil { - t.Fatal(err) - } - req := &jsonrpc.Request{ - ID: id, - Method: methodInitialize, - Params: mustParams(t, tc.params), - } - _, err = ss.handle(context.Background(), req) - if tc.wantReject { - if err == nil { - t.Fatal("expected error rejecting initialize, got nil") - } - var jerr *jsonrpc.Error - if !errors.As(err, &jerr) { - t.Fatalf("error type = %T, want *jsonrpc.Error so the wire returns the right code", err) - } - if jerr.Code != jsonrpc.CodeMethodNotFound { - t.Errorf("error code = %d, want %d (CodeMethodNotFound = -32601)", jerr.Code, jsonrpc.CodeMethodNotFound) - } - if !strings.Contains(jerr.Message, "initialize") { - t.Errorf("error message %q does not mention %q", jerr.Message, "initialize") - } - } else { - // Old-protocol initialize should be dispatched normally; any - // CodeMethodNotFound here would mean the rejection branch - // fired incorrectly. - var jerr *jsonrpc.Error - if errors.As(err, &jerr) && jerr.Code == jsonrpc.CodeMethodNotFound { - t.Errorf("old-protocol initialize was incorrectly rejected: %v", err) - } - } - }) - } - - t.Run("rejection error encodes to wire as code -32601", func(t *testing.T) { - // Belt-and-braces check that the error type produced by handle() - // actually serializes to JSON-RPC code -32601, not a bare 0. - ss := &ServerSession{server: NewServer(testImpl, nil)} - id, err := jsonrpc.MakeID("test") - if err != nil { - t.Fatal(err) - } - req := &jsonrpc.Request{ - ID: id, - Method: methodInitialize, - Params: mustParams(t, map[string]any{ - "_meta": map[string]any{ - MetaKeyProtocolVersion: protocolVersion20260630, - MetaKeyClientInfo: map[string]any{"name": "c", "version": "1"}, - MetaKeyClientCapabilities: map[string]any{}, - }, - "protocolVersion": protocolVersion20260630, - }), - } - _, handleErr := ss.handle(context.Background(), req) - if handleErr == nil { - t.Fatal("expected rejection error, got nil") - } - data, encErr := jsonrpc.EncodeMessage(&jsonrpc.Response{ID: id, Error: handleErr.(*jsonrpc.Error)}) - if encErr != nil { - t.Fatal(encErr) - } - var wire struct { - Error struct { - Code int `json:"code"` - Message string `json:"message"` - } `json:"error"` - } - if err := json.Unmarshal(data, &wire); err != nil { - t.Fatal(err) - } - if wire.Error.Code != jsonrpc.CodeMethodNotFound { - t.Errorf("wire error code = %d, want %d; full response = %s", wire.Error.Code, jsonrpc.CodeMethodNotFound, data) - } - }) -} - // TODO(v0.3.0): rewrite this test. // func TestToolValidate(t *testing.T) { // // Check that the tool returned from NewServerTool properly validates its input schema. diff --git a/mcp/streamable_test.go b/mcp/streamable_test.go index ac3628e1..809f8416 100644 --- a/mcp/streamable_test.go +++ b/mcp/streamable_test.go @@ -3245,125 +3245,74 @@ func TestEphemeralConnectOpts(t *testing.T) { h := &StreamableHTTPHandler{opts: StreamableHTTPOptions{}} - t.Run("new-protocol request: no synthetic state", func(t *testing.T) { - body := newProtocolBody(t, "x", struct{}{}) - opts, usesNew, err := h.ephemeralConnectOpts(mkReq(body)) - if err != nil { - t.Fatal(err) - } - if !usesNew { - t.Errorf("usesNewProtocol = false, want true") - } - if opts.State.InitializeParams != nil { - t.Errorf("InitializeParams = %+v, want nil for new-protocol request", opts.State.InitializeParams) - } - if opts.State.InitializedParams != nil { - t.Errorf("InitializedParams = %+v, want nil for new-protocol request", opts.State.InitializedParams) - } + oldProtocolBody, err := json.Marshal(map[string]any{ + "jsonrpc": "2.0", + "id": 1, + "method": "tools/call", + "params": map[string]any{"name": "x", "arguments": map[string]any{}}, }) - - t.Run("old-protocol request: synthetic state populated", func(t *testing.T) { - body, err := json.Marshal(map[string]any{ - "jsonrpc": "2.0", - "id": 1, - "method": "tools/call", - "params": map[string]any{"name": "x", "arguments": map[string]any{}}, - }) - if err != nil { - t.Fatal(err) - } - opts, usesNew, err := h.ephemeralConnectOpts(mkReq(body)) - if err != nil { - t.Fatal(err) - } - if usesNew { - t.Errorf("usesNewProtocol = true, want false for old-protocol request") - } - if opts.State.InitializeParams == nil { - t.Errorf("InitializeParams = nil, want synthetic value for old-protocol request") - } - if opts.State.InitializedParams == nil { - t.Errorf("InitializedParams = nil, want synthetic value for old-protocol request") - } + if err != nil { + t.Fatal(err) + } + initializeBody, err := json.Marshal(map[string]any{ + "jsonrpc": "2.0", + "id": 1, + "method": methodInitialize, + "params": map[string]any{"protocolVersion": protocolVersion20250618}, }) + if err != nil { + t.Fatal(err) + } - t.Run("initialize request: no synthetic InitializeParams", func(t *testing.T) { - body, err := json.Marshal(map[string]any{ - "jsonrpc": "2.0", - "id": 1, - "method": methodInitialize, - "params": map[string]any{"protocolVersion": protocolVersion20250618}, - }) - if err != nil { - t.Fatal(err) - } - opts, usesNew, err := h.ephemeralConnectOpts(mkReq(body)) - if err != nil { - t.Fatal(err) - } - if usesNew { - t.Errorf("usesNewProtocol = true, want false") - } - if opts.State.InitializeParams != nil { - t.Errorf("InitializeParams = %+v, want nil (real initialize handler will populate it)", opts.State.InitializeParams) - } - }) -} + tests := []struct { + name string + body []byte + wantUsesNew bool + wantInitializeParams bool + wantInitializedParams bool + }{ + { + name: "new-protocol request: no synthetic state", + body: newProtocolBody(t, "x", struct{}{}), + wantUsesNew: true, + wantInitializeParams: false, + wantInitializedParams: false, + }, + { + name: "old-protocol request: synthetic state populated", + body: oldProtocolBody, + wantUsesNew: false, + wantInitializeParams: true, + wantInitializedParams: true, + }, + { + name: "initialize request: no synthetic InitializeParams", + body: initializeBody, + wantUsesNew: false, + wantInitializeParams: false, + wantInitializedParams: true, + }, + } -// TestServePOST_NewProtocolHeaderCrossCheck verifies that the SEP-2575 -// header/body cross-check runs inside streamableServerConn.servePOST, which -// is the single chokepoint reached by every POST regardless of stateful or -// stateless mode. -func TestServePOST_NewProtocolHeaderCrossCheck(t *testing.T) { - mcpServer := NewServer(testImpl, nil) - AddTool(mcpServer, &Tool{Name: "noop"}, - func(ctx context.Context, req *CallToolRequest, args struct{}) (*CallToolResult, any, error) { - return &CallToolResult{Content: []Content{&TextContent{Text: "ok"}}}, nil, nil + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + opts, usesNew, err := h.ephemeralConnectOpts(mkReq(tt.body)) + if err != nil { + t.Fatal(err) + } + if usesNew != tt.wantUsesNew { + t.Errorf("usesNewProtocol = %v, want %v", usesNew, tt.wantUsesNew) + } + if got := opts.State.InitializeParams != nil; got != tt.wantInitializeParams { + t.Errorf("InitializeParams non-nil = %v, want %v (value = %+v)", + got, tt.wantInitializeParams, opts.State.InitializeParams) + } + if got := opts.State.InitializedParams != nil; got != tt.wantInitializedParams { + t.Errorf("InitializedParams non-nil = %v, want %v (value = %+v)", + got, tt.wantInitializedParams, opts.State.InitializedParams) + } }) - handler := NewStreamableHTTPHandler( - func(*http.Request) *Server { return mcpServer }, - &StreamableHTTPOptions{Stateless: true}, - ) - httpServer := httptest.NewServer(handler) - defer httpServer.Close() - - mkReq := func(headerVersion string) *http.Request { - body := newProtocolBody(t, "noop", struct{}{}) - 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") - if headerVersion != "" { - req.Header.Set(protocolVersionHeader, headerVersion) - } - return req } - - t.Run("mismatched header: 400", func(t *testing.T) { - resp, err := http.DefaultClient.Do(mkReq("2025-06-18")) - if err != nil { - t.Fatal(err) - } - defer resp.Body.Close() - if resp.StatusCode != http.StatusBadRequest { - body, _ := io.ReadAll(resp.Body) - t.Fatalf("status = %d, want 400; body = %s", resp.StatusCode, body) - } - }) - - t.Run("missing header: 400", func(t *testing.T) { - resp, err := http.DefaultClient.Do(mkReq("")) - if err != nil { - t.Fatal(err) - } - defer resp.Body.Close() - if resp.StatusCode != http.StatusBadRequest { - body, _ := io.ReadAll(resp.Body) - t.Fatalf("status = %d, want 400; body = %s", resp.StatusCode, body) - } - }) } // statelessHandlerCapture builds a stateless server with a single tool whose From 85a36ed87a2d7217a711d4d12d1c3bff70e4eff3 Mon Sep 17 00:00:00 2001 From: guglielmoc Date: Wed, 20 May 2026 15:35:07 +0000 Subject: [PATCH 07/40] docs: simplify protocol version requirement comment in streamable.go --- mcp/streamable.go | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/mcp/streamable.go b/mcp/streamable.go index f728e6fe..a35e32c7 100644 --- a/mcp/streamable.go +++ b/mcp/streamable.go @@ -1316,10 +1316,8 @@ func (c *streamableServerConn) servePOST(w http.ResponseWriter, req *http.Reques // SEP-2575: requests carrying `_meta.protocolVersion` require the // Mcp-Protocol-Version HTTP header to be present and to match the // per-request `_meta.protocolVersion` value. - // - // Per the SDK design doc (design/stateless.md), the new (>= - // 2026-06-30) protocol is supported on the HTTP transport only - // when [StreamableHTTPOptions.Stateless] is true. + // The new (>= 2026-06-30) protocol is supported on the HTTP transport + // only when [StreamableHTTPOptions.Stateless] is true. if meta := extractRequestMeta(jreq.Params); meta != nil { if metaVersion, ok := meta[MetaKeyProtocolVersion].(string); ok { if !c.stateless { From aecfae41f09706a4926e32c36dcbf07c0db29d51 Mon Sep 17 00:00:00 2001 From: guglielmoc Date: Wed, 20 May 2026 15:35:56 +0000 Subject: [PATCH 08/40] docs: update validateRequestMeta comment grammar to present tense --- mcp/shared.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mcp/shared.go b/mcp/shared.go index ae4fd582..a70dd2d9 100644 --- a/mcp/shared.go +++ b/mcp/shared.go @@ -483,7 +483,7 @@ func extractRequestMeta(rawParams json.RawMessage) Meta { // validateRequestMeta inspects a JSON-RPC request to detect whether it follows // the >= 2026-06-30 protocol via the `_meta` field. // It returns true if `io.modelcontextprotocol/protocolVersion`, -// `io.modelcontextprotocol/clientInfo` and `io.modelcontextprotocol/clientCapabilities` were present in `_meta`. +// `io.modelcontextprotocol/clientInfo` and `io.modelcontextprotocol/clientCapabilities` are present in `_meta`. func validateRequestMeta(req *jsonrpc.Request) (usesNewProtocol bool, err error) { meta := extractRequestMeta(req.Params) if meta == nil { From 69a549ff636565c6bdd928e0be1dc530111b7787 Mon Sep 17 00:00:00 2001 From: guglielmoc Date: Wed, 20 May 2026 20:49:20 +0000 Subject: [PATCH 09/40] refactor: update protocol version retrieval to use context instead of request headers --- mcp/streamable.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mcp/streamable.go b/mcp/streamable.go index a35e32c7..1e74bca5 100644 --- a/mcp/streamable.go +++ b/mcp/streamable.go @@ -1295,7 +1295,7 @@ func (c *streamableServerConn) servePOST(w http.ResponseWriter, req *http.Reques tokenInfo := auth.TokenInfoFromContext(req.Context()) isInitialize := false var initializeProtocolVersion string - headerVersion := req.Header.Get(protocolVersionHeader) + headerVersion := protocolVersionFromContext(req.Context()) for _, msg := range incoming { if jreq, ok := msg.(*jsonrpc.Request); ok { // Preemptively check that this is a valid request, so that we can fail From 584f8bfa5a6e6bdbcdac842047f8db8160041456 Mon Sep 17 00:00:00 2001 From: guglielmoc Date: Thu, 21 May 2026 11:36:31 +0000 Subject: [PATCH 10/40] feat: implement SEP-2575 server/discover protocol for stateless client initialization and version negotiation --- mcp/client.go | 82 ++++++++++ mcp/client_test.go | 228 ++++++++++++++++++++++++++ mcp/protocol.go | 23 +++ mcp/requests.go | 1 + mcp/server.go | 11 ++ mcp/shared.go | 47 ++++++ mcp/streamable.go | 2 + mcp/streamable_client_test.go | 293 ++++++++++++++++++++++++++++++++++ mcp/transport.go | 6 +- 9 files changed, 692 insertions(+), 1 deletion(-) diff --git a/mcp/client.go b/mcp/client.go index 6e24c5a3..6f4f28cf 100644 --- a/mcp/client.go +++ b/mcp/client.go @@ -268,6 +268,29 @@ func (c *Client) Connect(ctx context.Context, t Transport, opts *ClientSessionOp if opts != nil && opts.protocolVersion != "" { protocolVersion = opts.protocolVersion } + + // Per SEP-2575, try the stateless server/discover RPC first. If the server + // signals it doesn't support it ("Method not found" or the SEP-2575 + // UnsupportedProtocolVersionError), fall back to the legacy initialize + // handshake. Any other error (transport failure, malformed response, etc.) + // is propagated so the caller sees the real cause instead of being + // silently downgraded. + discRes, fallback, err := c.discover(ctx, cs) + if err != nil { + _ = cs.Close() + return nil, err + } + if !fallback { + cs.state.InitializeResult = discRes + if hc, ok := cs.mcpConn.(clientConnection); ok { + hc.sessionUpdated(cs.state) + } + if c.opts.KeepAlive > 0 { + cs.startKeepalive(c.opts.KeepAlive) + } + return cs, nil + } + params := &InitializeParams{ ProtocolVersion: protocolVersion, ClientInfo: c.impl, @@ -299,6 +322,65 @@ func (c *Client) Connect(ctx context.Context, t Transport, opts *ClientSessionOp return cs, nil } +// discover sends a SEP-2575 server/discover request to probe the server for +// stateless protocol support. +// +// The return values have three possible combinations: +// - (result, false, nil): discovery succeeded; caller should skip legacy initialization. +// - (nil, true, nil): the server explicitly signaled it doesn't support +// discovery (Method not found, or UnsupportedProtocolVersionError, or version mismatch); +// caller should fall back to the legacy initialize handshake. +// - (nil, false, err): any other failure (transport error, malformed response, etc.); +// caller should propagate the error. +// +// The request advertises the latest protocol version supported by this SDK +// (>= 2026-06-30), along with the client's info and capabilities, via the +// per-request _meta triple defined by SEP-2575. +func (c *Client) discover(ctx context.Context, cs *ClientSession) (*InitializeResult, bool, error) { + protocolVersion := protocolVersion20260630 + caps := c.capabilities(protocolVersion) + params := &DiscoverParams{ + Meta: Meta{ + MetaKeyProtocolVersion: protocolVersion, + MetaKeyClientInfo: c.impl, + MetaKeyClientCapabilities: caps, + }, + } + req := &DiscoverRequest{Session: cs, Params: params} + res, err := handleSend[*DiscoverResult](ctx, methodDiscover, req) + if err != nil { + // Only treat the two SEP-2575 "not supported" signals as a fallback + // trigger; everything else is a real error. + var werr *jsonrpc.Error + if errors.As(err, &werr) && (werr.Code == jsonrpc.CodeMethodNotFound || werr.Code == CodeUnsupportedProtocolVersion) { + return nil, true, nil + } + if strings.Contains(err.Error(), "Bad Request") { + return nil, true, nil + } + return nil, false, err + } + + // Pick the highest protocol version that both the server and this SDK + // support. If there is no overlap, fall back to initialize so version + // negotiation can happen via the legacy path. + negotiated := "" + for _, v := range res.SupportedVersions { + if slices.Contains(supportedProtocolVersions, v) && v > negotiated { + negotiated = v + } + } + if negotiated == "" { + return nil, true, nil + } + return &InitializeResult{ + Capabilities: res.Capabilities, + Instructions: res.Instructions, + ProtocolVersion: negotiated, + ServerInfo: res.ServerInfo, + }, false, nil +} + // A ClientSession is a logical connection with an MCP server. Its // methods can be used to send requests or notifications to the server. Create // a session by calling [Client.Connect]. diff --git a/mcp/client_test.go b/mcp/client_test.go index 609fd501..b8d19a21 100644 --- a/mcp/client_test.go +++ b/mcp/client_test.go @@ -8,11 +8,14 @@ import ( "context" "fmt" "log/slog" + "sync/atomic" "testing" "github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp/cmpopts" "github.com/google/jsonschema-go/jsonschema" + "github.com/modelcontextprotocol/go-sdk/internal/jsonrpc2" + "github.com/modelcontextprotocol/go-sdk/jsonrpc" ) type Item struct { @@ -617,3 +620,228 @@ func TestClientCapabilitiesOverWire(t *testing.T) { }) } } + +// TestClientConnectDiscover exercises the SEP-2575 server/discover probe that +// Client.Connect now sends before falling back to the legacy initialize +// handshake. +// +// Each subtest installs a server-side receiving middleware that intercepts the +// "server/discover" method and returns a canned response: a successful +// DiscoverResult, a "Method not found" error, an UnsupportedProtocolVersion +// error, an unrelated failure, or a DiscoverResult whose supportedVersions +// don't overlap with the SDK. The test then asserts the resulting session +// state and whether the legacy initialize handshake ran. +func TestClientConnectDiscover(t *testing.T) { + const otherVersionsOnly = "1999-01-01" + + tests := []struct { + name string + // discoverHandler decides how the server replies to server/discover. + // Returning (nil, nil) means "let the default stub handle it" (which + // returns ErrMethodNotFound). + discoverHandler func() (Result, error) + wantConnectErr bool + // wantInitialize is true if the legacy initialize handshake should + // have run (i.e. discover signaled "not supported"). + wantInitialize bool + // wantVersion is the protocol version expected to end up on + // ClientSession.state.InitializeResult after Connect returns. + wantVersion string + }{ + { + name: "discover success skips initialize", + discoverHandler: func() (Result, error) { + return &DiscoverResult{ + SupportedVersions: []string{latestProtocolVersion}, + Capabilities: &ServerCapabilities{ + Tools: &ToolCapabilities{ListChanged: true}, + }, + ServerInfo: &Implementation{Name: "discoverServer", Version: "v1.0.0"}, + }, nil + }, + wantInitialize: false, + wantVersion: latestProtocolVersion, + }, + { + name: "method not found falls back to initialize", + discoverHandler: func() (Result, error) { + return nil, jsonrpc2.ErrMethodNotFound + }, + 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) { + return &DiscoverResult{ + SupportedVersions: []string{otherVersionsOnly}, + Capabilities: &ServerCapabilities{}, + ServerInfo: &Implementation{Name: "discoverServer", Version: "v1.0.0"}, + }, nil + }, + wantInitialize: true, + wantVersion: latestProtocolVersion, + }, + { + name: "unexpected error propagates and aborts Connect", + discoverHandler: func() (Result, error) { + return nil, &jsonrpc.Error{ + Code: jsonrpc.CodeInternalError, + Message: "boom", + } + }, + wantConnectErr: true, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + ctx := context.Background() + + var ( + gotDiscover atomic.Bool + gotInitialize atomic.Bool + ) + + s := NewServer(testImpl, nil) + s.AddReceivingMiddleware(func(next MethodHandler) MethodHandler { + return func(ctx context.Context, method string, req Request) (Result, error) { + switch method { + case methodDiscover: + gotDiscover.Store(true) + return tc.discoverHandler() + case methodInitialize: + gotInitialize.Store(true) + } + return next(ctx, method, req) + } + }) + + ct, st := NewInMemoryTransports() + ss, err := s.Connect(ctx, st, nil) + if err != nil { + t.Fatalf("server Connect: %v", err) + } + defer ss.Close() + + c := NewClient(testImpl, nil) + cs, err := c.Connect(ctx, ct, nil) + if tc.wantConnectErr { + if err == nil { + _ = cs.Close() + t.Fatal("Connect succeeded, want error") + } + if !gotDiscover.Load() { + t.Error("server did not receive server/discover") + } + if gotInitialize.Load() { + t.Error("server received initialize but discover should have aborted Connect") + } + return + } + if err != nil { + t.Fatalf("Connect: %v", err) + } + defer cs.Close() + + if !gotDiscover.Load() { + t.Error("server did not receive server/discover") + } + if got, want := gotInitialize.Load(), tc.wantInitialize; got != want { + t.Errorf("initialize invoked = %v, want %v", got, want) + } + ir := cs.InitializeResult() + if ir == nil { + t.Fatal("InitializeResult is nil after Connect") + } + if got, want := ir.ProtocolVersion, tc.wantVersion; got != want { + t.Errorf("InitializeResult.ProtocolVersion = %q, want %q", got, want) + } + }) + } +} + +// TestClientConnectDiscover_RequestContents verifies that the server/discover +// request sent by Client.Connect carries the SEP-2575 per-request _meta triple: +// protocolVersion, clientInfo, and clientCapabilities. +func TestClientConnectDiscover_RequestContents(t *testing.T) { + ctx := context.Background() + + type captured struct { + params *DiscoverParams + } + var got captured + + s := NewServer(testImpl, nil) + s.AddReceivingMiddleware(func(next MethodHandler) MethodHandler { + return func(ctx context.Context, method string, req Request) (Result, error) { + if method == methodDiscover { + sr, ok := req.(*ServerRequest[*DiscoverParams]) + if !ok { + t.Errorf("discover req has unexpected type %T", req) + return nil, jsonrpc2.ErrMethodNotFound + } + got.params = sr.Params + // Make discover "not supported" so Connect proceeds (we only + // care about the discover request payload here). + return nil, jsonrpc2.ErrMethodNotFound + } + return next(ctx, method, req) + } + }) + + ct, st := NewInMemoryTransports() + ss, err := s.Connect(ctx, st, nil) + if err != nil { + t.Fatalf("server Connect: %v", err) + } + defer ss.Close() + + clientImpl := &Implementation{Name: "discover-probe-client", Version: "v9.9.9"} + c := NewClient(clientImpl, &ClientOptions{ + CreateMessageHandler: func(context.Context, *CreateMessageRequest) (*CreateMessageResult, error) { + return nil, nil + }, + }) + cs, err := c.Connect(ctx, ct, nil) + if err != nil { + t.Fatalf("Connect: %v", err) + } + defer cs.Close() + + if got.params == nil { + t.Fatal("server did not receive server/discover") + } + + meta := got.params.GetMeta() + if v, _ := meta[MetaKeyProtocolVersion].(string); v != protocolVersion20260630 { + t.Errorf("_meta[%s] = %q, want %q", MetaKeyProtocolVersion, v, protocolVersion20260630) + } + // _meta values round-trip through JSON, so on the server side they + // arrive as map[string]any rather than the typed Go pointers we sent. + info, ok := meta[MetaKeyClientInfo].(map[string]any) + if !ok { + t.Fatalf("_meta[%s] = %T, want map[string]any", MetaKeyClientInfo, meta[MetaKeyClientInfo]) + } + if got, want := info["name"], any(clientImpl.Name); got != want { + t.Errorf("clientInfo.name = %v, want %v", got, want) + } + caps, ok := meta[MetaKeyClientCapabilities].(map[string]any) + if !ok { + t.Fatalf("_meta[%s] = %T, want map[string]any", MetaKeyClientCapabilities, meta[MetaKeyClientCapabilities]) + } + if _, ok := caps["sampling"]; !ok { + t.Errorf("clientCapabilities.sampling missing (CreateMessageHandler was set); got %v", caps) + } +} diff --git a/mcp/protocol.go b/mcp/protocol.go index 824648c1..35089925 100644 --- a/mcp/protocol.go +++ b/mcp/protocol.go @@ -751,6 +751,28 @@ type ListPromptsParams struct { Cursor string `json:"cursor,omitempty"` } +type DiscoverParams struct { + Meta `json:"_meta,omitempty"` +} + +func (x *DiscoverParams) isParams() {} +func (x *DiscoverParams) GetProgressToken() any { return getProgressToken(x) } +func (x *DiscoverParams) SetProgressToken(t any) { setProgressToken(x, t) } + +type DiscoverResult struct { + Meta `json:"_meta,omitempty"` + // The versions of the Model Context Protocol that the server supports. + SupportedVersions []string `json:"supportedVersions"` + // The server's capabilities. + Capabilities *ServerCapabilities `json:"capabilities"` + // Information about the server implementation. + ServerInfo *Implementation `json:"serverInfo"` + // Instructions describing how to use the server and its features. + Instructions string `json:"instructions,omitempty"` +} + +func (*DiscoverResult) isResult() {} + func (x *ListPromptsParams) isParams() {} func (x *ListPromptsParams) GetProgressToken() any { return getProgressToken(x) } func (x *ListPromptsParams) SetProgressToken(t any) { setProgressToken(x, t) } @@ -1606,6 +1628,7 @@ const ( methodCallTool = "tools/call" notificationCancelled = "notifications/cancelled" methodComplete = "completion/complete" + methodDiscover = "server/discover" methodCreateMessage = "sampling/createMessage" methodElicit = "elicitation/create" notificationElicitationComplete = "notifications/elicitation/complete" diff --git a/mcp/requests.go b/mcp/requests.go index 42809413..36368c99 100644 --- a/mcp/requests.go +++ b/mcp/requests.go @@ -25,6 +25,7 @@ type ( type ( CreateMessageRequest = ClientRequest[*CreateMessageParams] CreateMessageWithToolsRequest = ClientRequest[*CreateMessageWithToolsParams] + DiscoverRequest = ClientRequest[*DiscoverParams] ElicitRequest = ClientRequest[*ElicitParams] initializedClientRequest = ClientRequest[*InitializedParams] InitializeRequest = ClientRequest[*InitializeParams] diff --git a/mcp/server.go b/mcp/server.go index f2bc9fac..8155569f 100644 --- a/mcp/server.go +++ b/mcp/server.go @@ -745,6 +745,16 @@ func (s *Server) getPrompt(ctx context.Context, req *GetPromptRequest) (*GetProm return prompt.handler(ctx, req) } +// discover is the server-side handler for the SEP-2575 "server/discover" RPC. +// +// Server-side discovery is not implemented yet (the SDK still uses the legacy +// initialization handshake for the protocol versions it supports). Returning +// ErrMethodNotFound here lets the client probe for support and fall back to +// the initialize handshake when the peer is a pre-2026-06-30 server. +func (s *Server) discover(context.Context, *ServerRequest[*DiscoverParams]) (*DiscoverResult, error) { + return nil, jsonrpc2.ErrMethodNotFound +} + func (s *Server) listTools(_ context.Context, req *ListToolsRequest) (*ListToolsResult, error) { s.mu.Lock() defer s.mu.Unlock() @@ -1386,6 +1396,7 @@ func (s *Server) AddReceivingMiddleware(middleware ...Middleware) { // curating these method flags. var serverMethodInfos = map[string]methodInfo{ methodComplete: newServerMethodInfo(serverMethod((*Server).complete), 0), + methodDiscover: newServerMethodInfo(serverMethod((*Server).discover), missingParamsOK), methodInitialize: initializeMethodInfo(), methodPing: newServerMethodInfo(serverSessionMethod((*ServerSession).ping), missingParamsOK), methodListPrompts: newServerMethodInfo(serverMethod((*Server).listPrompts), missingParamsOK), diff --git a/mcp/shared.go b/mcp/shared.go index a70dd2d9..540a89b0 100644 --- a/mcp/shared.go +++ b/mcp/shared.go @@ -105,6 +105,17 @@ func defaultSendingMethodHandler(ctx context.Context, method string, req Request // capabilities, so any panic here is a bug. params = initParams.toV2() } + // In new protocol version the protocolVersion is extracted to be set on the outgoing requests headers. + if discoverParams, ok := params.(*DiscoverParams); ok { + protocolVersion, ok := discoverParams.Meta[MetaKeyProtocolVersion].(string) + if !ok { + return nil, jsonrpc2.ErrInvalidRequest + } + ctx = context.WithValue(ctx, protocolVersionContextKey{}, protocolVersion) + } else { + injectMeta(req) + } + // Notifications don't have results. if strings.HasPrefix(method, "notifications/") { return nil, req.GetSession().getConn().Notify(ctx, method, params) @@ -205,6 +216,39 @@ func checkRequest(req *jsonrpc.Request, infos map[string]methodInfo) (methodInfo return info, nil } +// injectMeta populates the SEP-2575 per-request `_meta` triple +// (protocolVersion, clientInfo, clientCapabilities) on the outgoing request +// when the negotiated protocol version is >= 2026-06-30. Keys already +// present in params.Meta are not overwritten. +func injectMeta(req Request) { + cs, ok := req.GetSession().(*ClientSession) + if !ok { + return + } + res := cs.state.InitializeResult + if res == nil || res.ProtocolVersion < protocolVersion20260630 { + return + } + params := req.GetParams() + if params == nil { + return + } + m := params.GetMeta() + if m == nil { + m = map[string]any{} + } + if _, ok := m[MetaKeyProtocolVersion]; !ok { + m[MetaKeyProtocolVersion] = res.ProtocolVersion + } + if _, ok := m[MetaKeyClientInfo]; !ok { + m[MetaKeyClientInfo] = res.ServerInfo + } + if _, ok := m[MetaKeyClientCapabilities]; !ok { + m[MetaKeyClientCapabilities] = res.Capabilities + } + params.SetMeta(m) +} + // methodInfo is information about sending and receiving a method. type methodInfo struct { // flags is a collection of flags controlling how the JSONRPC method is @@ -344,6 +388,9 @@ func clientSessionMethod[P Params, R Result](f func(*ClientSession, context.Cont // MCP-specific error codes. const ( + // CodeUnsupportedProtocolVersion is the JSON-RPC error code defined by + // SEP-2575 for UnsupportedProtocolVersionError. + CodeUnsupportedProtocolVersion = -32004 // CodeHeaderMismatch indicates that HTTP headers do not match the corresponding values // in the request body, or that required headers are missing or malformed. CodeHeaderMismatch = -32001 diff --git a/mcp/streamable.go b/mcp/streamable.go index a35e32c7..b21036a5 100644 --- a/mcp/streamable.go +++ b/mcp/streamable.go @@ -2142,6 +2142,8 @@ func (c *streamableClientConn) setMCPHeaders(req *http.Request) error { } if c.initializedResult != nil { req.Header.Set(protocolVersionHeader, c.initializedResult.ProtocolVersion) + } else if protocolVersion, ok := req.Context().Value(protocolVersionContextKey{}).(string); ok { + req.Header.Set(protocolVersionHeader, protocolVersion) } if c.sessionID != "" { req.Header.Set(sessionIDHeader, c.sessionID) diff --git a/mcp/streamable_client_test.go b/mcp/streamable_client_test.go index 517e51af..c3b9c14d 100644 --- a/mcp/streamable_client_test.go +++ b/mcp/streamable_client_test.go @@ -6,6 +6,7 @@ package mcp import ( "context" + "encoding/json" "errors" "fmt" "io" @@ -1212,3 +1213,295 @@ func TestStreamableClientOAuth_RetrieveError(t *testing.T) { t.Fatalf("client.Connect() error = %v, want %v", err, errTestAuthorizeFailed) } } + +// discoverResult is the canned successful DiscoverResult returned by +// fakeStreamableServer setups in the tests below. +var discoverResult = &DiscoverResult{ + SupportedVersions: []string{latestProtocolVersion}, + Capabilities: &ServerCapabilities{ + Tools: &ToolCapabilities{ListChanged: true}, + }, + ServerInfo: &Implementation{Name: "discoverServer", Version: "v1.0.0"}, + Instructions: "test discover", +} + +// TestStreamableClientConnect_DiscoverSuccess verifies that Client.Connect on +// the streamable transport: +// - sends a POST server/discover with Mcp-Protocol-Version: 2026-06-30 and +// the SEP-2575 per-request _meta triple in the body, +// - on a successful DiscoverResult, skips the legacy initialize handshake +// entirely, and +// - seeds ClientSession.InitializeResult() from the discover response, +// picking a mutually-supported protocol version. +func TestStreamableClientConnect_DiscoverSuccess(t *testing.T) { + ctx := context.Background() + + var ( + gotDiscoverMu sync.Mutex + gotDiscover *jsonrpc.Request + ) + + fake := &fakeStreamableServer{ + t: t, + responses: fakeResponses{ + {"POST", "", methodDiscover, ""}: { + header: header{ + "Content-Type": "application/json", + sessionIDHeader: "sess-1", + }, + wantProtocolVersion: protocolVersion20260630, + responseFunc: func(r *jsonrpc.Request) (string, int) { + gotDiscoverMu.Lock() + gotDiscover = r + gotDiscoverMu.Unlock() + return jsonBody(t, resp(1, discoverResult, nil)), http.StatusOK + }, + }, + // The streamable client opens a standalone GET SSE stream and + // sends a DELETE on session close; both are post-Connect bookkeeping + // and not relevant to discovery. + {"GET", "sess-1", "", ""}: { + header: header{"Content-Type": "text/event-stream"}, + optional: true, + }, + {"DELETE", "sess-1", "", ""}: {optional: true}, + }, + } + + httpServer := httptest.NewServer(fake) + defer httpServer.Close() + + transport := &StreamableClientTransport{Endpoint: httpServer.URL} + client := NewClient(testImpl, nil) + session, err := client.Connect(ctx, transport, nil) + if err != nil { + t.Fatalf("Connect: %v", err) + } + defer session.Close() + + if missing := fake.missingRequests(); len(missing) > 0 { + t.Errorf("missing expected requests: %v", missing) + } + + gotDiscoverMu.Lock() + defer gotDiscoverMu.Unlock() + if gotDiscover == nil { + t.Fatal("server did not receive server/discover") + } + + // Inspect the discover request body for the SEP-2575 _meta triple. + var body struct { + Meta map[string]any `json:"_meta"` + } + if err := json.Unmarshal(gotDiscover.Params, &body); err != nil { + t.Fatalf("decoding discover params: %v", err) + } + if v, _ := body.Meta[MetaKeyProtocolVersion].(string); v != protocolVersion20260630 { + t.Errorf("_meta[%s] = %q, want %q", MetaKeyProtocolVersion, v, protocolVersion20260630) + } + if _, ok := body.Meta[MetaKeyClientInfo]; !ok { + t.Errorf("_meta[%s] missing", MetaKeyClientInfo) + } + if _, ok := body.Meta[MetaKeyClientCapabilities]; !ok { + t.Errorf("_meta[%s] missing", MetaKeyClientCapabilities) + } + + ir := session.InitializeResult() + if ir == nil { + t.Fatal("InitializeResult is nil after Connect") + } + if got, want := ir.ProtocolVersion, latestProtocolVersion; got != want { + t.Errorf("InitializeResult.ProtocolVersion = %q, want %q", got, want) + } + if ir.ServerInfo == nil || ir.ServerInfo.Name != "discoverServer" { + t.Errorf("InitializeResult.ServerInfo = %+v, want Name=discoverServer", ir.ServerInfo) + } + if ir.Instructions != "test discover" { + t.Errorf("InitializeResult.Instructions = %q, want %q", ir.Instructions, "test discover") + } +} + +// TestStreamableClientConnect_DiscoverMethodNotFound verifies that Client.Connect +// falls back to the legacy initialize handshake when the server responds to +// server/discover with a JSON-RPC "Method not found" error. +func TestStreamableClientConnect_DiscoverMethodNotFound(t *testing.T) { + ctx := context.Background() + + // Each request gets a fresh jsonrpc2 ID from the same client connection. + // Use responseFunc to echo the request's ID back so the client matches + // the response to the in-flight call regardless of ordering. + echoErr := func(err error) func(*jsonrpc.Request) (string, int) { + return func(r *jsonrpc.Request) (string, int) { + return jsonBody(t, &jsonrpc.Response{ID: r.ID, Error: err.(*jsonrpc.Error)}), http.StatusOK + } + } + echoResult := func(result any) func(*jsonrpc.Request) (string, int) { + return func(r *jsonrpc.Request) (string, int) { + return jsonBody(t, &jsonrpc.Response{ID: r.ID, Result: mustMarshal(result)}), http.StatusOK + } + } + + fake := &fakeStreamableServer{ + t: t, + responses: fakeResponses{ + {"POST", "", methodDiscover, ""}: { + header: header{"Content-Type": "application/json"}, + wantProtocolVersion: protocolVersion20260630, + responseFunc: echoErr(&jsonrpc.Error{ + Code: jsonrpc.CodeMethodNotFound, + Message: "method not found", + }), + }, + {"POST", "", methodInitialize, ""}: { + header: header{ + "Content-Type": "application/json", + sessionIDHeader: "fallback", + }, + responseFunc: echoResult(initResult), + }, + {"POST", "fallback", notificationInitialized, ""}: { + status: http.StatusAccepted, + wantProtocolVersion: latestProtocolVersion, + }, + {"GET", "fallback", "", ""}: { + header: header{"Content-Type": "text/event-stream"}, + wantProtocolVersion: latestProtocolVersion, + optional: true, + }, + {"DELETE", "fallback", "", ""}: {optional: true}, + }, + } + + httpServer := httptest.NewServer(fake) + defer httpServer.Close() + + transport := &StreamableClientTransport{Endpoint: httpServer.URL} + client := NewClient(testImpl, nil) + session, err := client.Connect(ctx, transport, nil) + if err != nil { + t.Fatalf("Connect: %v", err) + } + defer session.Close() + + if got := session.InitializeResult().ProtocolVersion; got != latestProtocolVersion { + t.Errorf("InitializeResult.ProtocolVersion = %q, want %q (initialize fallback)", got, latestProtocolVersion) + } +} + +// TestStreamableClientConnect_DiscoverUnsupportedVersion verifies that +// Client.Connect falls back to the legacy initialize handshake when the +// server responds to server/discover with the SEP-2575 +// UnsupportedProtocolVersionError JSON-RPC code. +func TestStreamableClientConnect_DiscoverUnsupportedVersion(t *testing.T) { + ctx := context.Background() + + echoErr := func(err error) func(*jsonrpc.Request) (string, int) { + return func(r *jsonrpc.Request) (string, int) { + return jsonBody(t, &jsonrpc.Response{ID: r.ID, Error: err.(*jsonrpc.Error)}), http.StatusOK + } + } + echoResult := func(result any) func(*jsonrpc.Request) (string, int) { + return func(r *jsonrpc.Request) (string, int) { + return jsonBody(t, &jsonrpc.Response{ID: r.ID, Result: mustMarshal(result)}), http.StatusOK + } + } + + fake := &fakeStreamableServer{ + t: t, + responses: fakeResponses{ + {"POST", "", methodDiscover, ""}: { + header: header{"Content-Type": "application/json"}, + wantProtocolVersion: protocolVersion20260630, + responseFunc: echoErr(&jsonrpc.Error{ + Code: CodeUnsupportedProtocolVersion, + Message: "unsupported protocol version", + }), + }, + {"POST", "", methodInitialize, ""}: { + header: header{ + "Content-Type": "application/json", + sessionIDHeader: "fallback", + }, + responseFunc: echoResult(initResult), + }, + {"POST", "fallback", notificationInitialized, ""}: { + status: http.StatusAccepted, + wantProtocolVersion: latestProtocolVersion, + }, + {"GET", "fallback", "", ""}: { + header: header{"Content-Type": "text/event-stream"}, + wantProtocolVersion: latestProtocolVersion, + optional: true, + }, + {"DELETE", "fallback", "", ""}: {optional: true}, + }, + } + + httpServer := httptest.NewServer(fake) + defer httpServer.Close() + + transport := &StreamableClientTransport{Endpoint: httpServer.URL} + client := NewClient(testImpl, nil) + session, err := client.Connect(ctx, transport, nil) + if err != nil { + t.Fatalf("Connect: %v", err) + } + defer session.Close() + + if got := session.InitializeResult().ProtocolVersion; got != latestProtocolVersion { + t.Errorf("InitializeResult.ProtocolVersion = %q, want %q (initialize fallback)", got, latestProtocolVersion) + } +} + +// TestStreamableClientConnect_DiscoverPropagatesOtherErrors verifies that +// Client.Connect does NOT fall back to initialize when server/discover +// returns an unrelated JSON-RPC error (here, CodeInternalError). The Connect +// call should fail with the propagated error rather than masking it. +func TestStreamableClientConnect_DiscoverPropagatesOtherErrors(t *testing.T) { + ctx := context.Background() + + var sawInitialize atomic.Bool + fake := &fakeStreamableServer{ + t: t, + responses: fakeResponses{ + {"POST", "", methodDiscover, ""}: { + header: header{"Content-Type": "application/json"}, + wantProtocolVersion: protocolVersion20260630, + responseFunc: func(r *jsonrpc.Request) (string, int) { + return jsonBody(t, &jsonrpc.Response{ + ID: r.ID, + Error: &jsonrpc.Error{ + Code: jsonrpc.CodeInternalError, + Message: "boom", + }, + }), http.StatusOK + }, + }, + {"POST", "", methodInitialize, ""}: { + responseFunc: func(r *jsonrpc.Request) (string, int) { + sawInitialize.Store(true) + return jsonBody(t, &jsonrpc.Response{ID: r.ID, Result: mustMarshal(initResult)}), http.StatusOK + }, + header: header{ + "Content-Type": "application/json", + sessionIDHeader: "fallback", + }, + optional: true, + }, + }, + } + + httpServer := httptest.NewServer(fake) + defer httpServer.Close() + + transport := &StreamableClientTransport{Endpoint: httpServer.URL} + client := NewClient(testImpl, nil) + session, err := client.Connect(ctx, transport, nil) + if err == nil { + _ = session.Close() + t.Fatal("Connect succeeded; want propagated error") + } + if sawInitialize.Load() { + t.Error("server received initialize; Connect should have aborted on the discover error") + } +} diff --git a/mcp/transport.go b/mcp/transport.go index ea447478..3070f15c 100644 --- a/mcp/transport.go +++ b/mcp/transport.go @@ -225,7 +225,11 @@ func call(ctx context.Context, conn *jsonrpc2.Connection, method string, params err := call.Await(ctx, result) switch { case errors.Is(err, jsonrpc2.ErrClientClosing), errors.Is(err, jsonrpc2.ErrServerClosing): - return fmt.Errorf("%w: calling %q: %v", ErrConnectionClosed, method, err) + // Use errors.Join so callers can still inspect the underlying + // jsonrpc2 wire error via errors.As (e.g. to distinguish + // SEP-2575 UnsupportedProtocolVersionError, which uses the same + // JSON-RPC code -32004 as ErrServerClosing). + return errors.Join(fmt.Errorf("%w: calling %q: %v", ErrConnectionClosed, method, err), err) case ctx.Err() != nil: notifyCtx, cancelNotify := context.WithTimeout(context.WithoutCancel(ctx), notifyCancellationTimeout) defer cancelNotify() From 67233a6ab26f33d500bfe87e7320f3dd481fe807 Mon Sep 17 00:00:00 2001 From: guglielmoc Date: Thu, 21 May 2026 12:01:54 +0000 Subject: [PATCH 11/40] refactor: add isNil interface method to all param structs and update protocol version checking in server and tests --- mcp/protocol.go | 40 ++++++++++++++++++++++++++++++++++------ mcp/server.go | 10 ++-------- mcp/server_test.go | 29 ++++++----------------------- mcp/shared.go | 33 +++++++++++++++++++++------------ mcp/shared_test.go | 7 ++++++- mcp/streamable.go | 20 ++++++++++---------- mcp/streamable_test.go | 11 ++++++++++- 7 files changed, 89 insertions(+), 61 deletions(-) diff --git a/mcp/protocol.go b/mcp/protocol.go index 824648c1..8b31620f 100644 --- a/mcp/protocol.go +++ b/mcp/protocol.go @@ -165,10 +165,12 @@ func (x *CallToolResult) UnmarshalJSON(data []byte) error { } func (x *CallToolParams) isParams() {} +func (x *CallToolParams) isNil() bool { return x == nil } func (x *CallToolParams) GetProgressToken() any { return getProgressToken(x) } func (x *CallToolParams) SetProgressToken(t any) { setProgressToken(x, t) } func (x *CallToolParamsRaw) isParams() {} +func (x *CallToolParamsRaw) isNil() bool { return x == nil } func (x *CallToolParamsRaw) GetProgressToken() any { return getProgressToken(x) } func (x *CallToolParamsRaw) SetProgressToken(t any) { setProgressToken(x, t) } @@ -187,6 +189,7 @@ type CancelledParams struct { } func (x *CancelledParams) isParams() {} +func (x *CancelledParams) isNil() bool { return x == nil } func (x *CancelledParams) GetProgressToken() any { return getProgressToken(x) } func (x *CancelledParams) SetProgressToken(t any) { setProgressToken(x, t) } @@ -374,7 +377,8 @@ type CompleteParams struct { Ref *CompleteReference `json:"ref"` } -func (*CompleteParams) isParams() {} +func (x *CompleteParams) isParams() {} +func (x *CompleteParams) isNil() bool { return x == nil } type CompletionResultDetails struct { HasMore bool `json:"hasMore,omitempty"` @@ -422,6 +426,7 @@ type CreateMessageParams struct { } func (x *CreateMessageParams) isParams() {} +func (x *CreateMessageParams) isNil() bool { return x == nil } func (x *CreateMessageParams) GetProgressToken() any { return getProgressToken(x) } func (x *CreateMessageParams) SetProgressToken(t any) { setProgressToken(x, t) } @@ -448,6 +453,7 @@ type CreateMessageWithToolsParams struct { } func (x *CreateMessageWithToolsParams) isParams() {} +func (x *CreateMessageWithToolsParams) isNil() bool { return x == nil } func (x *CreateMessageWithToolsParams) GetProgressToken() any { return getProgressToken(x) } func (x *CreateMessageWithToolsParams) SetProgressToken(t any) { setProgressToken(x, t) } @@ -654,6 +660,7 @@ type GetPromptParams struct { } func (x *GetPromptParams) isParams() {} +func (x *GetPromptParams) isNil() bool { return x == nil } func (x *GetPromptParams) GetProgressToken() any { return getProgressToken(x) } func (x *GetPromptParams) SetProgressToken(t any) { setProgressToken(x, t) } @@ -706,6 +713,7 @@ func (p *initializeParamsV2) toV1() *InitializeParams { } func (x *InitializeParams) isParams() {} +func (x *InitializeParams) isNil() bool { return x == nil } func (x *InitializeParams) GetProgressToken() any { return getProgressToken(x) } func (x *InitializeParams) SetProgressToken(t any) { setProgressToken(x, t) } @@ -739,6 +747,7 @@ type InitializedParams struct { } func (x *InitializedParams) isParams() {} +func (x *InitializedParams) isNil() bool { return x == nil } func (x *InitializedParams) GetProgressToken() any { return getProgressToken(x) } func (x *InitializedParams) SetProgressToken(t any) { setProgressToken(x, t) } @@ -752,6 +761,7 @@ type ListPromptsParams struct { } func (x *ListPromptsParams) isParams() {} +func (x *ListPromptsParams) isNil() bool { return x == nil } func (x *ListPromptsParams) GetProgressToken() any { return getProgressToken(x) } func (x *ListPromptsParams) SetProgressToken(t any) { setProgressToken(x, t) } func (x *ListPromptsParams) cursorPtr() *string { return &x.Cursor } @@ -780,6 +790,7 @@ type ListResourceTemplatesParams struct { } func (x *ListResourceTemplatesParams) isParams() {} +func (x *ListResourceTemplatesParams) isNil() bool { return x == nil } func (x *ListResourceTemplatesParams) GetProgressToken() any { return getProgressToken(x) } func (x *ListResourceTemplatesParams) SetProgressToken(t any) { setProgressToken(x, t) } func (x *ListResourceTemplatesParams) cursorPtr() *string { return &x.Cursor } @@ -808,6 +819,7 @@ type ListResourcesParams struct { } func (x *ListResourcesParams) isParams() {} +func (x *ListResourcesParams) isNil() bool { return x == nil } func (x *ListResourcesParams) GetProgressToken() any { return getProgressToken(x) } func (x *ListResourcesParams) SetProgressToken(t any) { setProgressToken(x, t) } func (x *ListResourcesParams) cursorPtr() *string { return &x.Cursor } @@ -833,6 +845,7 @@ type ListRootsParams struct { } func (x *ListRootsParams) isParams() {} +func (x *ListRootsParams) isNil() bool { return x == nil } func (x *ListRootsParams) GetProgressToken() any { return getProgressToken(x) } func (x *ListRootsParams) SetProgressToken(t any) { setProgressToken(x, t) } @@ -858,6 +871,7 @@ type ListToolsParams struct { } func (x *ListToolsParams) isParams() {} +func (x *ListToolsParams) isNil() bool { return x == nil } func (x *ListToolsParams) GetProgressToken() any { return getProgressToken(x) } func (x *ListToolsParams) SetProgressToken(t any) { setProgressToken(x, t) } func (x *ListToolsParams) cursorPtr() *string { return &x.Cursor } @@ -896,6 +910,7 @@ type LoggingMessageParams struct { } func (x *LoggingMessageParams) isParams() {} +func (x *LoggingMessageParams) isNil() bool { return x == nil } func (x *LoggingMessageParams) GetProgressToken() any { return getProgressToken(x) } func (x *LoggingMessageParams) SetProgressToken(t any) { setProgressToken(x, t) } @@ -958,6 +973,7 @@ type PingParams struct { } func (x *PingParams) isParams() {} +func (x *PingParams) isNil() bool { return x == nil } func (x *PingParams) GetProgressToken() any { return getProgressToken(x) } func (x *PingParams) SetProgressToken(t any) { setProgressToken(x, t) } @@ -978,7 +994,8 @@ type ProgressNotificationParams struct { Total float64 `json:"total,omitempty"` } -func (*ProgressNotificationParams) isParams() {} +func (x *ProgressNotificationParams) isParams() {} +func (x *ProgressNotificationParams) isNil() bool { return x == nil } // IconTheme specifies the theme an icon is designed for. type IconTheme string @@ -1048,6 +1065,7 @@ type PromptListChangedParams struct { } func (x *PromptListChangedParams) isParams() {} +func (x *PromptListChangedParams) isNil() bool { return x == nil } func (x *PromptListChangedParams) GetProgressToken() any { return getProgressToken(x) } func (x *PromptListChangedParams) SetProgressToken(t any) { setProgressToken(x, t) } @@ -1089,6 +1107,7 @@ type ReadResourceParams struct { } func (x *ReadResourceParams) isParams() {} +func (x *ReadResourceParams) isNil() bool { return x == nil } func (x *ReadResourceParams) GetProgressToken() any { return getProgressToken(x) } func (x *ReadResourceParams) SetProgressToken(t any) { setProgressToken(x, t) } @@ -1145,6 +1164,7 @@ type ResourceListChangedParams struct { } func (x *ResourceListChangedParams) isParams() {} +func (x *ResourceListChangedParams) isNil() bool { return x == nil } func (x *ResourceListChangedParams) GetProgressToken() any { return getProgressToken(x) } func (x *ResourceListChangedParams) SetProgressToken(t any) { setProgressToken(x, t) } @@ -1205,6 +1225,7 @@ type RootsListChangedParams struct { } func (x *RootsListChangedParams) isParams() {} +func (x *RootsListChangedParams) isNil() bool { return x == nil } func (x *RootsListChangedParams) GetProgressToken() any { return getProgressToken(x) } func (x *RootsListChangedParams) SetProgressToken(t any) { setProgressToken(x, t) } @@ -1288,6 +1309,7 @@ type SetLoggingLevelParams struct { } func (x *SetLoggingLevelParams) isParams() {} +func (x *SetLoggingLevelParams) isNil() bool { return x == nil } func (x *SetLoggingLevelParams) GetProgressToken() any { return getProgressToken(x) } func (x *SetLoggingLevelParams) SetProgressToken(t any) { setProgressToken(x, t) } @@ -1390,6 +1412,7 @@ type ToolListChangedParams struct { } func (x *ToolListChangedParams) isParams() {} +func (x *ToolListChangedParams) isNil() bool { return x == nil } func (x *ToolListChangedParams) GetProgressToken() any { return getProgressToken(x) } func (x *ToolListChangedParams) SetProgressToken(t any) { setProgressToken(x, t) } @@ -1403,7 +1426,8 @@ type SubscribeParams struct { URI string `json:"uri"` } -func (*SubscribeParams) isParams() {} +func (x *SubscribeParams) isParams() {} +func (x *SubscribeParams) isNil() bool { return x == nil } // Sent from the client to request cancellation of resources/updated // notifications from the server. This should follow a previous @@ -1416,7 +1440,8 @@ type UnsubscribeParams struct { URI string `json:"uri"` } -func (*UnsubscribeParams) isParams() {} +func (x *UnsubscribeParams) isParams() {} +func (x *UnsubscribeParams) isNil() bool { return x == nil } // A notification from the server to the client, informing it that a resource // has changed and may need to be read again. This should only be sent if the @@ -1429,7 +1454,8 @@ type ResourceUpdatedNotificationParams struct { URI string `json:"uri"` } -func (*ResourceUpdatedNotificationParams) isParams() {} +func (x *ResourceUpdatedNotificationParams) isParams() {} +func (x *ResourceUpdatedNotificationParams) isNil() bool { return x == nil } // TODO(jba): add CompleteRequest and related types. @@ -1469,6 +1495,7 @@ type ElicitParams struct { } func (x *ElicitParams) isParams() {} +func (x *ElicitParams) isNil() bool { return x == nil } func (x *ElicitParams) GetProgressToken() any { return getProgressToken(x) } func (x *ElicitParams) SetProgressToken(t any) { setProgressToken(x, t) } @@ -1500,7 +1527,8 @@ type ElicitationCompleteParams struct { ElicitationID string `json:"elicitationId"` } -func (*ElicitationCompleteParams) isParams() {} +func (x *ElicitationCompleteParams) isParams() {} +func (x *ElicitationCompleteParams) isNil() bool { return x == nil } // An Implementation describes the name and version of an MCP implementation, with an optional // title for UI representation. diff --git a/mcp/server.go b/mcp/server.go index f2bc9fac..6794f769 100644 --- a/mcp/server.go +++ b/mcp/server.go @@ -1461,7 +1461,7 @@ func (ss *ServerSession) handle(ctx context.Context, req *jsonrpc.Request) (any, switch req.Method { case methodInitialize, methodPing, notificationInitialized: - if usesNewProtocol { + if usesNewProtocol.usesNewProtocol { ss.server.opts.Logger.Error("method removed in the new protocol", "method", req.Method) return nil, &jsonrpc.Error{ Code: jsonrpc.CodeMethodNotFound, @@ -1469,7 +1469,7 @@ func (ss *ServerSession) handle(ctx context.Context, req *jsonrpc.Request) (any, } } default: - if !initialized && !usesNewProtocol { + if !initialized && !usesNewProtocol.usesNewProtocol { ss.server.opts.Logger.Error("method invalid during initialization", "method", req.Method) return nil, fmt.Errorf("method %q is invalid during session initialization", req.Method) } @@ -1491,12 +1491,6 @@ func (ss *ServerSession) handle(ctx context.Context, req *jsonrpc.Request) (any, // InitializeParams returns the InitializeParams provided during the client's // initial connection. -// -// Deprecated: with the >= 2026-06-30 protocol, sessions are sessionless and -// there is no `initialize` handshake. For new-protocol requests this method -// returns nil; use the per-request accessors [ServerRequest.ProtocolVersion], -// [ServerRequest.ClientInfo], and [ServerRequest.ClientCapabilities] -// instead. func (ss *ServerSession) InitializeParams() *InitializeParams { ss.mu.Lock() defer ss.mu.Unlock() diff --git a/mcp/server_test.go b/mcp/server_test.go index 288fb5ab..8eb462d5 100644 --- a/mcp/server_test.go +++ b/mcp/server_test.go @@ -1010,19 +1010,10 @@ func TestServerCapabilitiesOverWire(t *testing.T) { } } +// SEP-2575 removes the initialization handshake. An `initialize` request +// that opts into the new protocol via `_meta.protocolVersion` must be +// rejected with `Method not found` (-32601). func TestServerSessionHandle_RejectsInitializeOnNewProtocol(t *testing.T) { - // SEP-2575 removes the initialization handshake. An `initialize` request - // that opts into the new protocol via `_meta.protocolVersion` must be - // rejected with `Method not found` (-32601). - mustParams := func(t *testing.T, v any) json.RawMessage { - t.Helper() - data, err := json.Marshal(v) - if err != nil { - t.Fatal(err) - } - return data - } - tests := []struct { name string params any @@ -1059,7 +1050,7 @@ func TestServerSessionHandle_RejectsInitializeOnNewProtocol(t *testing.T) { req := &jsonrpc.Request{ ID: id, Method: methodInitialize, - Params: mustParams(t, tc.params), + Params: mustMarshal(tc.params), } _, err = ss.handle(context.Background(), req) if tc.wantReject { @@ -1099,7 +1090,7 @@ func TestServerSessionHandle_RejectsInitializeOnNewProtocol(t *testing.T) { req := &jsonrpc.Request{ ID: id, Method: methodInitialize, - Params: mustParams(t, map[string]any{ + Params: mustMarshal(map[string]any{ "_meta": map[string]any{ MetaKeyProtocolVersion: protocolVersion20260630, MetaKeyClientInfo: map[string]any{"name": "c", "version": "1"}, @@ -1136,14 +1127,6 @@ 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) { - mustParams := func(t *testing.T, v any) json.RawMessage { - t.Helper() - data, err := json.Marshal(v) - if err != nil { - t.Fatal(err) - } - return data - } newProtoMeta := map[string]any{ "_meta": map[string]any{ MetaKeyProtocolVersion: protocolVersion20260630, @@ -1171,7 +1154,7 @@ func TestServerSessionHandle_RejectsRemovedMethodsOnNewProtocol(t *testing.T) { req := &jsonrpc.Request{ ID: id, Method: tc.method, - Params: mustParams(t, newProtoMeta), + Params: mustMarshal(newProtoMeta), } _, err = ss.handle(context.Background(), req) if err == nil { diff --git a/mcp/shared.go b/mcp/shared.go index a70dd2d9..5639ff6b 100644 --- a/mcp/shared.go +++ b/mcp/shared.go @@ -480,35 +480,42 @@ func extractRequestMeta(rawParams json.RawMessage) Meta { return meta.Meta } +type validatedMeta struct { + usesNewProtocol bool + meta Meta +} + // validateRequestMeta inspects a JSON-RPC request to detect whether it follows // the >= 2026-06-30 protocol via the `_meta` field. -// It returns true if `io.modelcontextprotocol/protocolVersion`, -// `io.modelcontextprotocol/clientInfo` and `io.modelcontextprotocol/clientCapabilities` are present in `_meta`. -func validateRequestMeta(req *jsonrpc.Request) (usesNewProtocol bool, err error) { +// If the request has no _meta, or no protocolVersion in _meta, it returns a non-nil +// validatedMeta with usesNewProtocol set to false, and a nil error. +// If the request has a protocolVersion in _meta but is missing required fields +// (clientInfo or clientCapabilities for call requests), it returns nil and a non-nil error. +func validateRequestMeta(req *jsonrpc.Request) (*validatedMeta, error) { meta := extractRequestMeta(req.Params) if meta == nil { - return false, nil + return &validatedMeta{usesNewProtocol: false}, nil } if _, ok := meta[MetaKeyProtocolVersion].(string); !ok { - return false, nil + return &validatedMeta{usesNewProtocol: false}, nil } // Notifications do not carry full client identity if !req.IsCall() { - return true, nil + return &validatedMeta{usesNewProtocol: true, meta: meta}, nil } if _, ok := meta[MetaKeyClientInfo]; !ok { - return true, &jsonrpc.Error{ + return nil, &jsonrpc.Error{ Code: jsonrpc.CodeInvalidParams, Message: fmt.Sprintf("missing required _meta field %q", MetaKeyClientInfo), } } if _, ok := meta[MetaKeyClientCapabilities]; !ok { - return true, &jsonrpc.Error{ + return nil, &jsonrpc.Error{ Code: jsonrpc.CodeInvalidParams, Message: fmt.Sprintf("missing required _meta field %q", MetaKeyClientCapabilities), } } - return true, nil + return &validatedMeta{usesNewProtocol: true, meta: meta}, nil } // A Request is a method request with parameters and additional information, such as the session. @@ -632,9 +639,8 @@ func (r *ServerRequest[P]) ClientCapabilities() *ClientCapabilities { // getRequestMeta returns the raw `_meta` map from the request's params, or // nil if the params are absent. func getRequestMeta[P Params](r *ServerRequest[P]) map[string]any { - // In practice P is a pointer type implementing Params. Use reflect to - // detect a nil pointer without panicking on GetMeta. - if v := reflect.ValueOf(r.Params); !v.IsValid() || (v.Kind() == reflect.Pointer && v.IsNil()) { + // In practice P is a pointer type implementing Params. + if any(r.Params) == nil || r.Params.isNil() { return nil } return r.Params.GetMeta() @@ -677,6 +683,9 @@ type Params interface { // isParams discourages implementation of Params outside of this package. isParams() + + // isNil returns true if the underlying value is nil. + isNil() bool } // RequestParams is a parameter (input) type for an MCP request. diff --git a/mcp/shared_test.go b/mcp/shared_test.go index 4f6fb163..03f1a28d 100644 --- a/mcp/shared_test.go +++ b/mcp/shared_test.go @@ -134,7 +134,12 @@ func TestValidateRequestMeta(t *testing.T) { req.ID = id } - usesNew, err := validateRequestMeta(req) + vmeta, err := validateRequestMeta(req) + usesNew := vmeta != nil && vmeta.usesNewProtocol + if err != nil { + meta := extractRequestMeta(req.Params) + usesNew = meta != nil && meta[MetaKeyProtocolVersion] != nil + } if usesNew != tc.wantUsesNew { t.Errorf("usesNewProtocol = %v, want %v", usesNew, tc.wantUsesNew) } diff --git a/mcp/streamable.go b/mcp/streamable.go index 1e74bca5..25baec6a 100644 --- a/mcp/streamable.go +++ b/mcp/streamable.go @@ -424,10 +424,8 @@ func (h *StreamableHTTPHandler) ephemeralConnectOpts(req *http.Request) (opts *S case notificationInitialized: hasInitialized = true } - if meta := extractRequestMeta(r.Params); meta != nil { - if _, ok := meta[MetaKeyProtocolVersion].(string); ok { - usesNewProtocol = true - } + if protocolVersion >= protocolVersion20260630 { + usesNewProtocol = true } } } @@ -1318,12 +1316,15 @@ func (c *streamableServerConn) servePOST(w http.ResponseWriter, req *http.Reques // per-request `_meta.protocolVersion` value. // The new (>= 2026-06-30) protocol is supported on the HTTP transport // only when [StreamableHTTPOptions.Stateless] is true. + var metaVersion string if meta := extractRequestMeta(jreq.Params); meta != nil { - if metaVersion, ok := meta[MetaKeyProtocolVersion].(string); ok { - if !c.stateless { - http.Error(w, fmt.Sprintf( - "Bad Request: protocol version %q is only supported on stateless HTTP servers (set StreamableHTTPOptions.Stateless = true)", - metaVersion), + metaVersion, _ = meta[MetaKeyProtocolVersion].(string) + } + if protocolVersion >= protocolVersion20260630 || metaVersion != "" { + if !c.stateless { + http.Error(w, fmt.Sprintf( + "Bad Request: protocol version %q is only supported on stateless HTTP servers (set StreamableHTTPOptions.Stateless = true)", + protocolVersion), http.StatusBadRequest) return } @@ -1343,7 +1344,6 @@ func (c *streamableServerConn) servePOST(w http.ResponseWriter, req *http.Reques return } } - } // Include metadata for all requests (including notifications). jreq.Extra = &RequestExtra{ TokenInfo: tokenInfo, diff --git a/mcp/streamable_test.go b/mcp/streamable_test.go index 809f8416..5fb76270 100644 --- a/mcp/streamable_test.go +++ b/mcp/streamable_test.go @@ -3296,7 +3296,16 @@ func TestEphemeralConnectOpts(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - opts, usesNew, err := h.ephemeralConnectOpts(mkReq(tt.body)) + req := mkReq(tt.body) + var pver string + if tt.wantUsesNew { + pver = protocolVersion20260630 + } else { + pver = protocolVersion20250326 + } + req.Header.Set(protocolVersionHeader, pver) + req = req.WithContext(context.WithValue(req.Context(), protocolVersionContextKey{}, pver)) + opts, usesNew, err := h.ephemeralConnectOpts(req) if err != nil { t.Fatal(err) } From 52828cd35dd39202d482eb2e8adcb4db87b6eee2 Mon Sep 17 00:00:00 2001 From: guglielmoc Date: Thu, 21 May 2026 12:31:03 +0000 Subject: [PATCH 12/40] refactor: remove redundant meta field from validatedMeta and simplify protocol version validation logic --- mcp/shared.go | 5 ++--- mcp/shared_test.go | 4 ---- mcp/streamable.go | 45 +++++++++++++++++++++--------------------- mcp/streamable_test.go | 33 +++---------------------------- 4 files changed, 27 insertions(+), 60 deletions(-) diff --git a/mcp/shared.go b/mcp/shared.go index 5639ff6b..c96a18e0 100644 --- a/mcp/shared.go +++ b/mcp/shared.go @@ -482,7 +482,6 @@ func extractRequestMeta(rawParams json.RawMessage) Meta { type validatedMeta struct { usesNewProtocol bool - meta Meta } // validateRequestMeta inspects a JSON-RPC request to detect whether it follows @@ -501,7 +500,7 @@ func validateRequestMeta(req *jsonrpc.Request) (*validatedMeta, error) { } // Notifications do not carry full client identity if !req.IsCall() { - return &validatedMeta{usesNewProtocol: true, meta: meta}, nil + return &validatedMeta{usesNewProtocol: true}, nil } if _, ok := meta[MetaKeyClientInfo]; !ok { return nil, &jsonrpc.Error{ @@ -515,7 +514,7 @@ func validateRequestMeta(req *jsonrpc.Request) (*validatedMeta, error) { Message: fmt.Sprintf("missing required _meta field %q", MetaKeyClientCapabilities), } } - return &validatedMeta{usesNewProtocol: true, meta: meta}, nil + return &validatedMeta{usesNewProtocol: true}, nil } // A Request is a method request with parameters and additional information, such as the session. diff --git a/mcp/shared_test.go b/mcp/shared_test.go index 03f1a28d..215db52b 100644 --- a/mcp/shared_test.go +++ b/mcp/shared_test.go @@ -136,10 +136,6 @@ func TestValidateRequestMeta(t *testing.T) { vmeta, err := validateRequestMeta(req) usesNew := vmeta != nil && vmeta.usesNewProtocol - if err != nil { - meta := extractRequestMeta(req.Params) - usesNew = meta != nil && meta[MetaKeyProtocolVersion] != nil - } if usesNew != tc.wantUsesNew { t.Errorf("usesNewProtocol = %v, want %v", usesNew, tc.wantUsesNew) } diff --git a/mcp/streamable.go b/mcp/streamable.go index 25baec6a..f5e93b40 100644 --- a/mcp/streamable.go +++ b/mcp/streamable.go @@ -390,17 +390,16 @@ func (h *StreamableHTTPHandler) serveStatelessLegacyDELETE(w http.ResponseWriter } // ephemeralConnectOpts peeks at the request body to determine whether it -// contains an initialize or initialized message, or whether any of its -// messages carry the per-request `_meta.protocolVersion` field that signals -// the >= 2026-06-30 sessionless protocol (SEP-2575). +// contains an initialize or initialized message or whether the protocol version +// header indicates a protocol version >= 2026-06-30 (SEP-2575). // // For old-protocol requests, default session state is synthesized so that // the session's init gate doesn't reject the request. // // It is used for both stateless servers and stateful servers with no session ID. // -// The returned usesNewProtocol bool reports whether any request in the body -// carried `_meta.protocolVersion`. +// The returned usesNewProtocol bool reports whether the protocol version +// header indicates a protocol version >= 2026-06-30 (SEP-2575). func (h *StreamableHTTPHandler) ephemeralConnectOpts(req *http.Request) (opts *ServerSessionOptions, usesNewProtocol bool, err error) { protocolVersion := protocolVersionFromContext(req.Context()) if protocolVersion == "" { @@ -1325,25 +1324,25 @@ func (c *streamableServerConn) servePOST(w http.ResponseWriter, req *http.Reques http.Error(w, fmt.Sprintf( "Bad Request: protocol version %q is only supported on stateless HTTP servers (set StreamableHTTPOptions.Stateless = true)", protocolVersion), - http.StatusBadRequest) - return - } - if headerVersion == "" { - http.Error(w, fmt.Sprintf( - "Bad Request: %s header is required for requests carrying %q", - protocolVersionHeader, MetaKeyProtocolVersion), - http.StatusBadRequest) - 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) - return - } + http.StatusBadRequest) + return + } + if headerVersion == "" { + http.Error(w, fmt.Sprintf( + "Bad Request: %s header is required for requests carrying %q", + protocolVersionHeader, MetaKeyProtocolVersion), + http.StatusBadRequest) + 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) + return } + } // Include metadata for all requests (including notifications). jreq.Extra = &RequestExtra{ TokenInfo: tokenInfo, diff --git a/mcp/streamable_test.go b/mcp/streamable_test.go index 5fb76270..5bce1dff 100644 --- a/mcp/streamable_test.go +++ b/mcp/streamable_test.go @@ -1926,39 +1926,12 @@ func TestStreamableMcpHeaderValidation(t *testing.T) { return &CallToolResult{}, nil }) - handler := NewStreamableHTTPHandler(func(req *http.Request) *Server { return server }, nil) + handler := NewStreamableHTTPHandler(func(req *http.Request) *Server { return server }, &StreamableHTTPOptions{ + Stateless: true, + }) defer handler.closeAll() - initReq := req(1, methodInitialize, &InitializeParams{ProtocolVersion: minVersionForStandardHeaders}) - initResp := resp(1, &InitializeResult{ - Capabilities: &ServerCapabilities{ - Logging: &LoggingCapabilities{}, - Tools: &ToolCapabilities{ListChanged: true}, - }, - ProtocolVersion: minVersionForStandardHeaders, - ServerInfo: &Implementation{Name: "testServer", Version: "v1.0.0"}, - }, nil) - - initialize := streamableRequest{ - method: "POST", - messages: []jsonrpc.Message{initReq}, - wantStatusCode: http.StatusOK, - wantMessages: []jsonrpc.Message{initResp}, - wantSessionID: true, - } - initialized := streamableRequest{ - method: "POST", - headers: http.Header{ - protocolVersionHeader: {minVersionForStandardHeaders}, - methodHeader: {notificationInitialized}, - }, - messages: []jsonrpc.Message{req(0, notificationInitialized, &InitializedParams{})}, - wantStatusCode: http.StatusAccepted, - } - testStreamableHandler(t, handler, []streamableRequest{ - initialize, - initialized, { method: "POST", headers: http.Header{ From ded4e45e5a237243d984bf4f0f41b31f3561bca4 Mon Sep 17 00:00:00 2001 From: guglielmoc Date: Thu, 21 May 2026 12:45:41 +0000 Subject: [PATCH 13/40] test: update streamable handler tests to inject required client metadata into requests --- mcp/shared_test.go | 18 ++----- mcp/streamable_test.go | 113 +++++++++++++++++++++++++++++++++++++---- 2 files changed, 107 insertions(+), 24 deletions(-) diff --git a/mcp/shared_test.go b/mcp/shared_test.go index 215db52b..e4f563d1 100644 --- a/mcp/shared_test.go +++ b/mcp/shared_test.go @@ -15,18 +15,6 @@ import ( ) func TestValidateRequestMeta(t *testing.T) { - mustParams := func(t *testing.T, v any) json.RawMessage { - t.Helper() - if v == nil { - return nil - } - data, err := json.Marshal(v) - if err != nil { - t.Fatal(err) - } - return data - } - tests := []struct { name string method string @@ -79,7 +67,7 @@ func TestValidateRequestMeta(t *testing.T) { }, "name": "x", }, - wantUsesNew: true, + wantUsesNew: false, wantErrContains: MetaKeyClientInfo, }, { @@ -92,7 +80,7 @@ func TestValidateRequestMeta(t *testing.T) { }, "name": "x", }, - wantUsesNew: true, + wantUsesNew: false, wantErrContains: MetaKeyClientCapabilities, }, { @@ -121,7 +109,7 @@ func TestValidateRequestMeta(t *testing.T) { case json.RawMessage: raw = p default: - raw = mustParams(t, tc.params) + raw = mustMarshal(tc.params) } req := &jsonrpc.Request{Method: tc.method, Params: raw} if !tc.isNotification { diff --git a/mcp/streamable_test.go b/mcp/streamable_test.go index 5bce1dff..c920f6c2 100644 --- a/mcp/streamable_test.go +++ b/mcp/streamable_test.go @@ -1931,6 +1931,12 @@ func TestStreamableMcpHeaderValidation(t *testing.T) { }) defer handler.closeAll() + testMeta := Meta{ + MetaKeyProtocolVersion: minVersionForStandardHeaders, + MetaKeyClientInfo: map[string]any{"name": "testClient", "version": "v1.0.0"}, + MetaKeyClientCapabilities: map[string]any{}, + } + testStreamableHandler(t, handler, []streamableRequest{ { method: "POST", @@ -1939,7 +1945,7 @@ func TestStreamableMcpHeaderValidation(t *testing.T) { methodHeader: {"tools/call"}, nameHeader: {"my-tool"}, }, - messages: []jsonrpc.Message{req(2, "tools/call", &CallToolParams{Name: "my-tool"})}, + messages: []jsonrpc.Message{req(2, "tools/call", &CallToolParams{Meta: testMeta, Name: "my-tool"})}, wantStatusCode: http.StatusOK, wantMessages: []jsonrpc.Message{resp(2, &CallToolResult{Content: []Content{}}, nil)}, }, @@ -1950,7 +1956,7 @@ func TestStreamableMcpHeaderValidation(t *testing.T) { methodHeader: {"prompts/get"}, nameHeader: {"my-tool"}, }, - messages: []jsonrpc.Message{req(3, "tools/call", &CallToolParams{Name: "my-tool"})}, + messages: []jsonrpc.Message{req(3, "tools/call", &CallToolParams{Meta: testMeta, Name: "my-tool"})}, wantStatusCode: http.StatusBadRequest, wantBodyContaining: "Mcp-Method header value", }, @@ -1961,7 +1967,7 @@ func TestStreamableMcpHeaderValidation(t *testing.T) { methodHeader: {"tools/call"}, nameHeader: {"wrong-tool"}, }, - messages: []jsonrpc.Message{req(4, "tools/call", &CallToolParams{Name: "my-tool"})}, + messages: []jsonrpc.Message{req(4, "tools/call", &CallToolParams{Meta: testMeta, Name: "my-tool"})}, wantStatusCode: http.StatusBadRequest, wantBodyContaining: "Mcp-Name header value", }, @@ -1972,7 +1978,7 @@ func TestStreamableMcpHeaderValidation(t *testing.T) { methodHeader: {"TOOLS/CALL"}, nameHeader: {"my-tool"}, }, - messages: []jsonrpc.Message{req(5, "tools/call", &CallToolParams{Name: "my-tool"})}, + messages: []jsonrpc.Message{req(5, "tools/call", &CallToolParams{Meta: testMeta, Name: "my-tool"})}, wantStatusCode: http.StatusBadRequest, wantBodyContaining: "Mcp-Method header value", }, @@ -1983,7 +1989,7 @@ func TestStreamableMcpHeaderValidation(t *testing.T) { methodHeader: {"tools/call"}, nameHeader: {"my-tool"}, }, - messages: []jsonrpc.Message{req(6, "tools/call", &CallToolParams{Name: "my-tool"})}, + messages: []jsonrpc.Message{req(6, "tools/call", &CallToolParams{Meta: testMeta, Name: "my-tool"})}, wantStatusCode: http.StatusOK, wantMessages: []jsonrpc.Message{resp(6, &CallToolResult{Content: []Content{}}, nil)}, }, @@ -1996,6 +2002,7 @@ func TestStreamableMcpHeaderValidation(t *testing.T) { paramHeaderPrefix + "Region": {"us-west1"}, }, messages: []jsonrpc.Message{req(7, "tools/call", &CallToolParams{ + Meta: testMeta, Name: "execute_sql", Arguments: map[string]any{"region": "us-west1", "query": "SELECT 1"}, })}, @@ -2011,6 +2018,7 @@ func TestStreamableMcpHeaderValidation(t *testing.T) { paramHeaderPrefix + "Region": {"eu-central1"}, }, messages: []jsonrpc.Message{req(8, "tools/call", &CallToolParams{ + Meta: testMeta, Name: "execute_sql", Arguments: map[string]any{"region": "us-west1"}, })}, @@ -2025,6 +2033,7 @@ func TestStreamableMcpHeaderValidation(t *testing.T) { nameHeader: {"execute_sql"}, }, messages: []jsonrpc.Message{req(9, "tools/call", &CallToolParams{ + Meta: testMeta, Name: "execute_sql", Arguments: map[string]any{"region": "us-west1"}, })}, @@ -2034,6 +2043,68 @@ func TestStreamableMcpHeaderValidation(t *testing.T) { }) } +// TODO: Remove this once client operations will automatically inject metadata in the requests +func injectMetaToRequest(req *http.Request) error { + if req.Body == nil { + return nil + } + body, err := io.ReadAll(req.Body) + if err != nil { + return err + } + req.Body.Close() + + var val any + if err := json.Unmarshal(body, &val); err == nil { + var method string + if m, ok := val.(map[string]any); ok { + method, _ = m["method"].(string) + } else if list, ok := val.([]any); ok && len(list) > 0 { + if m, ok := list[0].(map[string]any); ok { + method, _ = m["method"].(string) + } + } + + if method == "initialize" || method == "notifications/initialized" || strings.HasPrefix(method, "notifications/") { + req.Header.Set(protocolVersionHeader, "2025-11-25") + } else { + req.Header.Set(protocolVersionHeader, minVersionForStandardHeaders) + + var msgs []map[string]any + if m, ok := val.(map[string]any); ok { + msgs = []map[string]any{m} + } else if list, ok := val.([]any); ok { + for _, item := range list { + if m, ok := item.(map[string]any); ok { + msgs = append(msgs, m) + } + } + } + + for _, m := range msgs { + params, _ := m["params"].(map[string]any) + if params == nil { + params = make(map[string]any) + m["params"] = params + } + meta, _ := params["_meta"].(map[string]any) + if meta == nil { + meta = make(map[string]any) + params["_meta"] = meta + } + meta[MetaKeyProtocolVersion] = minVersionForStandardHeaders + meta[MetaKeyClientInfo] = map[string]any{"name": "testClient", "version": "v1.0.0"} + meta[MetaKeyClientCapabilities] = map[string]any{} + } + body, _ = json.Marshal(val) + } + } + + req.Body = io.NopCloser(bytes.NewReader(body)) + req.ContentLength = int64(len(body)) + return nil +} + // TestStreamableMcpHeaderValidationErrorFormat verifies that header // validation errors return a JSON-RPC error with code -32001 and // Content-Type application/json, per SEP-2243. @@ -2049,7 +2120,9 @@ func TestStreamableMcpHeaderValidationErrorFormat(t *testing.T) { return &CallToolResult{}, nil }) - handler := NewStreamableHTTPHandler(func(req *http.Request) *Server { return server }, nil) + handler := NewStreamableHTTPHandler(func(req *http.Request) *Server { return server }, &StreamableHTTPOptions{ + Stateless: true, + }) defer handler.closeAll() httpServer := httptest.NewServer(mustNotPanic(t, handler)) @@ -2061,6 +2134,9 @@ func TestStreamableMcpHeaderValidationErrorFormat(t *testing.T) { customClient := &http.Client{ Transport: roundTripperFunc(func(req *http.Request) (*http.Response, error) { + if err := injectMetaToRequest(req); err != nil { + return nil, err + } var originalMethodHeader string if req.Header.Get(methodHeader) == "tools/call" { originalMethodHeader = req.Header.Get(methodHeader) @@ -2210,7 +2286,9 @@ func TestStreamableParamHeadersClientSetsHeaders(t *testing.T) { return &CallToolResult{Content: []Content{&TextContent{Text: "ok"}}}, nil }) - handler := NewStreamableHTTPHandler(func(req *http.Request) *Server { return server }, nil) + handler := NewStreamableHTTPHandler(func(req *http.Request) *Server { return server }, &StreamableHTTPOptions{ + Stateless: true, + }) defer handler.closeAll() httpServer := httptest.NewServer(mustNotPanic(t, handler)) defer httpServer.Close() @@ -2218,6 +2296,9 @@ func TestStreamableParamHeadersClientSetsHeaders(t *testing.T) { var capturedHeaders http.Header customClient := &http.Client{ Transport: roundTripperFunc(func(req *http.Request) (*http.Response, error) { + if err := injectMetaToRequest(req); err != nil { + return nil, err + } if req.Header.Get(methodHeader) == "tools/call" { capturedHeaders = req.Header.Clone() } @@ -2320,14 +2401,28 @@ func TestStreamableFilterValidToolsIntegration(t *testing.T) { InputSchema: &jsonschema.Schema{Type: "object"}, }, noop) - handler := NewStreamableHTTPHandler(func(req *http.Request) *Server { return server }, nil) + handler := NewStreamableHTTPHandler(func(req *http.Request) *Server { return server }, &StreamableHTTPOptions{ + Stateless: true, + }) defer handler.closeAll() httpServer := httptest.NewServer(mustNotPanic(t, handler)) defer httpServer.Close() + customClient := &http.Client{ + Transport: roundTripperFunc(func(req *http.Request) (*http.Response, error) { + if err := injectMetaToRequest(req); err != nil { + return nil, err + } + return http.DefaultTransport.RoundTrip(req) + }), + } + client := NewClient(&Implementation{Name: "testClient", Version: "v1.0.0"}, nil) ctx := context.Background() - session, err := client.Connect(ctx, &StreamableClientTransport{Endpoint: httpServer.URL}, &ClientSessionOptions{protocolVersion: minVersionForStandardHeaders}) + session, err := client.Connect(ctx, &StreamableClientTransport{ + Endpoint: httpServer.URL, + HTTPClient: customClient, + }, &ClientSessionOptions{protocolVersion: minVersionForStandardHeaders}) if err != nil { t.Fatal(err) } From aba952941bc8c4aa002b7c5844044870ed10db18 Mon Sep 17 00:00:00 2001 From: guglielmoc Date: Thu, 21 May 2026 12:49:37 +0000 Subject: [PATCH 14/40] style: align whitespace in isParams method declarations for consistent formatting --- mcp/protocol.go | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/mcp/protocol.go b/mcp/protocol.go index 8b31620f..f55055ca 100644 --- a/mcp/protocol.go +++ b/mcp/protocol.go @@ -377,7 +377,7 @@ type CompleteParams struct { Ref *CompleteReference `json:"ref"` } -func (x *CompleteParams) isParams() {} +func (x *CompleteParams) isParams() {} func (x *CompleteParams) isNil() bool { return x == nil } type CompletionResultDetails struct { @@ -994,7 +994,7 @@ type ProgressNotificationParams struct { Total float64 `json:"total,omitempty"` } -func (x *ProgressNotificationParams) isParams() {} +func (x *ProgressNotificationParams) isParams() {} func (x *ProgressNotificationParams) isNil() bool { return x == nil } // IconTheme specifies the theme an icon is designed for. @@ -1426,7 +1426,7 @@ type SubscribeParams struct { URI string `json:"uri"` } -func (x *SubscribeParams) isParams() {} +func (x *SubscribeParams) isParams() {} func (x *SubscribeParams) isNil() bool { return x == nil } // Sent from the client to request cancellation of resources/updated @@ -1440,7 +1440,7 @@ type UnsubscribeParams struct { URI string `json:"uri"` } -func (x *UnsubscribeParams) isParams() {} +func (x *UnsubscribeParams) isParams() {} func (x *UnsubscribeParams) isNil() bool { return x == nil } // A notification from the server to the client, informing it that a resource @@ -1454,7 +1454,7 @@ type ResourceUpdatedNotificationParams struct { URI string `json:"uri"` } -func (x *ResourceUpdatedNotificationParams) isParams() {} +func (x *ResourceUpdatedNotificationParams) isParams() {} func (x *ResourceUpdatedNotificationParams) isNil() bool { return x == nil } // TODO(jba): add CompleteRequest and related types. @@ -1494,7 +1494,7 @@ type ElicitParams struct { ElicitationID string `json:"elicitationId,omitempty"` } -func (x *ElicitParams) isParams() {} +func (x *ElicitParams) isParams() {} func (x *ElicitParams) isNil() bool { return x == nil } func (x *ElicitParams) GetProgressToken() any { return getProgressToken(x) } @@ -1527,7 +1527,7 @@ type ElicitationCompleteParams struct { ElicitationID string `json:"elicitationId"` } -func (x *ElicitationCompleteParams) isParams() {} +func (x *ElicitationCompleteParams) isParams() {} func (x *ElicitationCompleteParams) isNil() bool { return x == nil } // An Implementation describes the name and version of an MCP implementation, with an optional From e93173549c876167c5a4c279c0d6e49aee386b01 Mon Sep 17 00:00:00 2001 From: guglielmoc Date: Thu, 21 May 2026 13:17:27 +0000 Subject: [PATCH 15/40] test: update MCP tests to handle server/discover fallback to legacy initialize --- mcp/mcp_test.go | 8 ++++++++ mcp/streamable_client_test.go | 17 +++++++++++++++++ mcp/transport_example_test.go | 6 ++++-- 3 files changed, 29 insertions(+), 2 deletions(-) diff --git a/mcp/mcp_test.go b/mcp/mcp_test.go index 14173231..c1a8456f 100644 --- a/mcp/mcp_test.go +++ b/mcp/mcp_test.go @@ -767,6 +767,10 @@ func TestMiddleware(t *testing.T) { } wantServer := ` +R1 >server/discover +R2 >server/discover +R2 initialize R2 >initialize R2 server/discover +S2 >server/discover +S2 initialize S2 >initialize S2 Date: Thu, 21 May 2026 13:19:58 +0000 Subject: [PATCH 16/40] feat: add isNil helper method to DiscoverParams struct --- mcp/protocol.go | 1 + 1 file changed, 1 insertion(+) diff --git a/mcp/protocol.go b/mcp/protocol.go index ab90ae51..21c411b3 100644 --- a/mcp/protocol.go +++ b/mcp/protocol.go @@ -765,6 +765,7 @@ type DiscoverParams struct { } func (x *DiscoverParams) isParams() {} +func (x *DiscoverParams) isNil() bool { return x == nil } func (x *DiscoverParams) GetProgressToken() any { return getProgressToken(x) } func (x *DiscoverParams) SetProgressToken(t any) { setProgressToken(x, t) } From fc5865bb1d04d2fc8ad0bdbb997efbfb647b90c6 Mon Sep 17 00:00:00 2001 From: guglielmoc Date: Thu, 21 May 2026 13:28:36 +0000 Subject: [PATCH 17/40] refactor: simplify orZero helper implementation and remove deprecated test metadata injection logic --- mcp/streamable_test.go | 78 +----------------------------------------- 1 file changed, 1 insertion(+), 77 deletions(-) diff --git a/mcp/streamable_test.go b/mcp/streamable_test.go index c920f6c2..d687a7b2 100644 --- a/mcp/streamable_test.go +++ b/mcp/streamable_test.go @@ -2043,67 +2043,7 @@ func TestStreamableMcpHeaderValidation(t *testing.T) { }) } -// TODO: Remove this once client operations will automatically inject metadata in the requests -func injectMetaToRequest(req *http.Request) error { - if req.Body == nil { - return nil - } - body, err := io.ReadAll(req.Body) - if err != nil { - return err - } - req.Body.Close() - - var val any - if err := json.Unmarshal(body, &val); err == nil { - var method string - if m, ok := val.(map[string]any); ok { - method, _ = m["method"].(string) - } else if list, ok := val.([]any); ok && len(list) > 0 { - if m, ok := list[0].(map[string]any); ok { - method, _ = m["method"].(string) - } - } - - if method == "initialize" || method == "notifications/initialized" || strings.HasPrefix(method, "notifications/") { - req.Header.Set(protocolVersionHeader, "2025-11-25") - } else { - req.Header.Set(protocolVersionHeader, minVersionForStandardHeaders) - - var msgs []map[string]any - if m, ok := val.(map[string]any); ok { - msgs = []map[string]any{m} - } else if list, ok := val.([]any); ok { - for _, item := range list { - if m, ok := item.(map[string]any); ok { - msgs = append(msgs, m) - } - } - } - - for _, m := range msgs { - params, _ := m["params"].(map[string]any) - if params == nil { - params = make(map[string]any) - m["params"] = params - } - meta, _ := params["_meta"].(map[string]any) - if meta == nil { - meta = make(map[string]any) - params["_meta"] = meta - } - meta[MetaKeyProtocolVersion] = minVersionForStandardHeaders - meta[MetaKeyClientInfo] = map[string]any{"name": "testClient", "version": "v1.0.0"} - meta[MetaKeyClientCapabilities] = map[string]any{} - } - body, _ = json.Marshal(val) - } - } - req.Body = io.NopCloser(bytes.NewReader(body)) - req.ContentLength = int64(len(body)) - return nil -} // TestStreamableMcpHeaderValidationErrorFormat verifies that header // validation errors return a JSON-RPC error with code -32001 and @@ -2134,9 +2074,6 @@ func TestStreamableMcpHeaderValidationErrorFormat(t *testing.T) { customClient := &http.Client{ Transport: roundTripperFunc(func(req *http.Request) (*http.Response, error) { - if err := injectMetaToRequest(req); err != nil { - return nil, err - } var originalMethodHeader string if req.Header.Get(methodHeader) == "tools/call" { originalMethodHeader = req.Header.Get(methodHeader) @@ -2296,9 +2233,6 @@ func TestStreamableParamHeadersClientSetsHeaders(t *testing.T) { var capturedHeaders http.Header customClient := &http.Client{ Transport: roundTripperFunc(func(req *http.Request) (*http.Response, error) { - if err := injectMetaToRequest(req); err != nil { - return nil, err - } if req.Header.Get(methodHeader) == "tools/call" { capturedHeaders = req.Header.Clone() } @@ -2408,20 +2342,10 @@ func TestStreamableFilterValidToolsIntegration(t *testing.T) { httpServer := httptest.NewServer(mustNotPanic(t, handler)) defer httpServer.Close() - customClient := &http.Client{ - Transport: roundTripperFunc(func(req *http.Request) (*http.Response, error) { - if err := injectMetaToRequest(req); err != nil { - return nil, err - } - return http.DefaultTransport.RoundTrip(req) - }), - } - client := NewClient(&Implementation{Name: "testClient", Version: "v1.0.0"}, nil) ctx := context.Background() session, err := client.Connect(ctx, &StreamableClientTransport{ - Endpoint: httpServer.URL, - HTTPClient: customClient, + Endpoint: httpServer.URL, }, &ClientSessionOptions{protocolVersion: minVersionForStandardHeaders}) if err != nil { t.Fatal(err) From b1a06baff677bed39aa3d4e7420a84b9f24543fd Mon Sep 17 00:00:00 2001 From: guglielmoc Date: Thu, 21 May 2026 14:06:57 +0000 Subject: [PATCH 18/40] feat: extract and persist initialize params from new protocol request meta --- mcp/server.go | 4 ++++ mcp/shared.go | 35 +++++++++++++++++++++++------------ mcp/streamable_test.go | 11 +++++++++-- 3 files changed, 36 insertions(+), 14 deletions(-) diff --git a/mcp/server.go b/mcp/server.go index 6794f769..1bebc7dc 100644 --- a/mcp/server.go +++ b/mcp/server.go @@ -1459,6 +1459,10 @@ func (ss *ServerSession) handle(ctx context.Context, req *jsonrpc.Request) (any, return nil, perRequestErr } + if usesNewProtocol.usesNewProtocol && usesNewProtocol.initializeParams != nil { + ss.state.InitializeParams = usesNewProtocol.initializeParams + } + switch req.Method { case methodInitialize, methodPing, notificationInitialized: if usesNewProtocol.usesNewProtocol { diff --git a/mcp/shared.go b/mcp/shared.go index c96a18e0..d06e5c9b 100644 --- a/mcp/shared.go +++ b/mcp/shared.go @@ -481,40 +481,51 @@ func extractRequestMeta(rawParams json.RawMessage) Meta { } type validatedMeta struct { - usesNewProtocol bool + usesNewProtocol bool + initializeParams *InitializeParams } // validateRequestMeta inspects a JSON-RPC request to detect whether it follows // the >= 2026-06-30 protocol via the `_meta` field. // If the request has no _meta, or no protocolVersion in _meta, it returns a non-nil // validatedMeta with usesNewProtocol set to false, and a nil error. -// If the request has a protocolVersion in _meta but is missing required fields -// (clientInfo or clientCapabilities for call requests), it returns nil and a non-nil error. +// If the request has a protocolVersion in _meta: +// - For notifications, it returns usesNewProtocol set to true and a nil initializeParams. +// - For call requests, it validates the presence of clientInfo and clientCapabilities in _meta. +// If either is missing or invalid, it returns nil and a non-nil error. Otherwise, it returns +// usesNewProtocol set to true and the populated initializeParams. func validateRequestMeta(req *jsonrpc.Request) (*validatedMeta, error) { meta := extractRequestMeta(req.Params) if meta == nil { - return &validatedMeta{usesNewProtocol: false}, nil + return &validatedMeta{usesNewProtocol: false, initializeParams: nil}, nil } - if _, ok := meta[MetaKeyProtocolVersion].(string); !ok { - return &validatedMeta{usesNewProtocol: false}, nil + protocolVersion, ok := meta[MetaKeyProtocolVersion].(string) + if !ok { + return &validatedMeta{usesNewProtocol: false, initializeParams: nil}, nil } // Notifications do not carry full client identity if !req.IsCall() { - return &validatedMeta{usesNewProtocol: true}, nil + return &validatedMeta{usesNewProtocol: true, initializeParams: nil}, nil } - if _, ok := meta[MetaKeyClientInfo]; !ok { + clientInfo, ok := decodeMetaValue[*Implementation](meta, MetaKeyClientInfo) + if !ok { return nil, &jsonrpc.Error{ Code: jsonrpc.CodeInvalidParams, - Message: fmt.Sprintf("missing required _meta field %q", MetaKeyClientInfo), + Message: fmt.Sprintf("missing or invalid _meta field %q", MetaKeyClientInfo), } } - if _, ok := meta[MetaKeyClientCapabilities]; !ok { + capabilities, ok := decodeMetaValue[*ClientCapabilities](meta, MetaKeyClientCapabilities) + if !ok { return nil, &jsonrpc.Error{ Code: jsonrpc.CodeInvalidParams, - Message: fmt.Sprintf("missing required _meta field %q", MetaKeyClientCapabilities), + Message: fmt.Sprintf("missing or invalid _meta field %q", MetaKeyClientCapabilities), } } - return &validatedMeta{usesNewProtocol: true}, nil + return &validatedMeta{usesNewProtocol: true, initializeParams: &InitializeParams{ + ProtocolVersion: protocolVersion, + Capabilities: capabilities, + ClientInfo: clientInfo, + }}, nil } // A Request is a method request with parameters and additional information, such as the session. diff --git a/mcp/streamable_test.go b/mcp/streamable_test.go index c920f6c2..e566bf25 100644 --- a/mcp/streamable_test.go +++ b/mcp/streamable_test.go @@ -3456,8 +3456,15 @@ func TestStreamableStateless_NewProtocolSession_NoFakeInit(t *testing.T) { capture.mu.Lock() defer capture.mu.Unlock() - if capture.sessionInitParams != nil { - t.Errorf("Session.InitializeParams() = %+v, want nil for new-protocol session", capture.sessionInitParams) + if capture.sessionInitParams == nil { + t.Errorf("Session.InitializeParams() is nil, want populated initializeParams for new-protocol session") + } else { + if got, want := capture.sessionInitParams.ProtocolVersion, protocolVersion20260630; got != want { + t.Errorf("Session.InitializeParams().ProtocolVersion = %q, want %q", got, want) + } + if got, want := capture.sessionInitParams.ClientInfo.Name, "new-proto-client"; got != want { + t.Errorf("Session.InitializeParams().ClientInfo.Name = %q, want %q", got, want) + } } if got, want := capture.reqProtocolVersion, protocolVersion20260630; got != want { t.Errorf("req.ProtocolVersion() = %q, want %q", got, want) From 2e2a116d590f55c04d7afeccea93474676228ab2 Mon Sep 17 00:00:00 2001 From: guglielmoc Date: Thu, 21 May 2026 14:10:47 +0000 Subject: [PATCH 19/40] refactor: rename usesNewProtocol variable to validatedMeta for clarity in server request handling --- mcp/server.go | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/mcp/server.go b/mcp/server.go index 1bebc7dc..e79d9c2b 100644 --- a/mcp/server.go +++ b/mcp/server.go @@ -1454,18 +1454,18 @@ func (ss *ServerSession) handle(ctx context.Context, req *jsonrpc.Request) (any, // `io.modelcontextprotocol/protocolVersion` in its `_meta` field, it // follows the new sessionless protocol. The initialization gate is // skipped for such requests. - usesNewProtocol, perRequestErr := validateRequestMeta(req) + validatedMeta, perRequestErr := validateRequestMeta(req) if perRequestErr != nil { return nil, perRequestErr } - if usesNewProtocol.usesNewProtocol && usesNewProtocol.initializeParams != nil { - ss.state.InitializeParams = usesNewProtocol.initializeParams + if validatedMeta.usesNewProtocol && validatedMeta.initializeParams != nil { + ss.state.InitializeParams = validatedMeta.initializeParams } switch req.Method { case methodInitialize, methodPing, notificationInitialized: - if usesNewProtocol.usesNewProtocol { + if validatedMeta.usesNewProtocol { ss.server.opts.Logger.Error("method removed in the new protocol", "method", req.Method) return nil, &jsonrpc.Error{ Code: jsonrpc.CodeMethodNotFound, @@ -1473,7 +1473,7 @@ func (ss *ServerSession) handle(ctx context.Context, req *jsonrpc.Request) (any, } } default: - if !initialized && !usesNewProtocol.usesNewProtocol { + if !initialized && !validatedMeta.usesNewProtocol { ss.server.opts.Logger.Error("method invalid during initialization", "method", req.Method) return nil, fmt.Errorf("method %q is invalid during session initialization", req.Method) } From 8b572a67827eaf1e79b2811968e19ab7dd91c2a0 Mon Sep 17 00:00:00 2001 From: guglielmoc Date: Thu, 21 May 2026 19:15:12 +0000 Subject: [PATCH 20/40] refactor: update ServerSessionState using thread-safe updateState helper for InitializeParams --- mcp/server.go | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/mcp/server.go b/mcp/server.go index e79d9c2b..02b8f19e 100644 --- a/mcp/server.go +++ b/mcp/server.go @@ -1460,7 +1460,9 @@ func (ss *ServerSession) handle(ctx context.Context, req *jsonrpc.Request) (any, } if validatedMeta.usesNewProtocol && validatedMeta.initializeParams != nil { - ss.state.InitializeParams = validatedMeta.initializeParams + ss.updateState(func(state *ServerSessionState) { + state.InitializeParams = validatedMeta.initializeParams + }) } switch req.Method { From 6f1eba0ca2c8bcd98a98f2ab99fed4cdffd110eb Mon Sep 17 00:00:00 2001 From: guglielmoc Date: Fri, 22 May 2026 10:19:54 +0000 Subject: [PATCH 21/40] fix: prevent redundant initialization of server session state when already initialized --- mcp/server.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mcp/server.go b/mcp/server.go index 02b8f19e..bc6b64a6 100644 --- a/mcp/server.go +++ b/mcp/server.go @@ -1459,7 +1459,7 @@ func (ss *ServerSession) handle(ctx context.Context, req *jsonrpc.Request) (any, return nil, perRequestErr } - if validatedMeta.usesNewProtocol && validatedMeta.initializeParams != nil { + if !initialized && validatedMeta.usesNewProtocol && validatedMeta.initializeParams != nil { ss.updateState(func(state *ServerSessionState) { state.InitializeParams = validatedMeta.initializeParams }) From ddec24be0be686fcf7dc79c1211270c6cf089eaa Mon Sep 17 00:00:00 2001 From: guglielmoc Date: Mon, 25 May 2026 09:55:23 +0000 Subject: [PATCH 22/40] feat: implement SEP-2575 handshake support with version-aware transport headers and conditional SSE stream initialization --- mcp/client.go | 48 ++++++++++++++++++++++++++--------------------- mcp/shared.go | 12 ++---------- mcp/streamable.go | 32 ++++++++++++++++++++++++------- mcp/transport.go | 12 ++++++++++++ 4 files changed, 66 insertions(+), 38 deletions(-) diff --git a/mcp/client.go b/mcp/client.go index 6f4f28cf..cefeaf8e 100644 --- a/mcp/client.go +++ b/mcp/client.go @@ -269,26 +269,35 @@ func (c *Client) Connect(ctx context.Context, t Transport, opts *ClientSessionOp protocolVersion = opts.protocolVersion } - // Per SEP-2575, try the stateless server/discover RPC first. If the server - // signals it doesn't support it ("Method not found" or the SEP-2575 - // UnsupportedProtocolVersionError), fall back to the legacy initialize - // handshake. Any other error (transport failure, malformed response, etc.) - // is propagated so the caller sees the real cause instead of being - // silently downgraded. - discRes, fallback, err := c.discover(ctx, cs) - if err != nil { - _ = cs.Close() - return nil, err - } - if !fallback { - cs.state.InitializeResult = discRes - if hc, ok := cs.mcpConn.(clientConnection); ok { - hc.sessionUpdated(cs.state) + if protocolVersion >= protocolVersion20260630 { + // Inform the transport which protocol version we intend to advertise on + // the discover request, so it can populate the Mcp-Protocol-Version + // header before InitializeResult is available. + if s, ok := cs.mcpConn.(protocolVersionSetter); ok { + s.setRequestedProtocolVersion(protocolVersion20260630) } - if c.opts.KeepAlive > 0 { - cs.startKeepalive(c.opts.KeepAlive) + + // Per SEP-2575, try the stateless server/discover RPC first. If the server + // signals it doesn't support it ("Method not found" or the SEP-2575 + // UnsupportedProtocolVersionError), fall back to the legacy initialize + // handshake. Any other error (transport failure, malformed response, etc.) + // is propagated so the caller sees the real cause instead of being + // silently downgraded. + discRes, fallback, err := c.discover(ctx, cs) + // The current implementation of the server does not allow to properly define the error cause. + // Fallback on the legacy initialization on any type of error. + if err == nil && !fallback { + cs.state.InitializeResult = discRes + if hc, ok := cs.mcpConn.(clientConnection); ok { + hc.sessionUpdated(cs.state) + } + if c.opts.KeepAlive > 0 { + cs.startKeepalive(c.opts.KeepAlive) + } + return cs, nil + } else { + protocolVersion = protocolVersion20251125 } - return cs, nil } params := &InitializeParams{ @@ -355,9 +364,6 @@ func (c *Client) discover(ctx context.Context, cs *ClientSession) (*InitializeRe if errors.As(err, &werr) && (werr.Code == jsonrpc.CodeMethodNotFound || werr.Code == CodeUnsupportedProtocolVersion) { return nil, true, nil } - if strings.Contains(err.Error(), "Bad Request") { - return nil, true, nil - } return nil, false, err } diff --git a/mcp/shared.go b/mcp/shared.go index cd53534e..978bf65c 100644 --- a/mcp/shared.go +++ b/mcp/shared.go @@ -105,16 +105,8 @@ func defaultSendingMethodHandler(ctx context.Context, method string, req Request // capabilities, so any panic here is a bug. params = initParams.toV2() } - // In new protocol version the protocolVersion is extracted to be set on the outgoing requests headers. - if discoverParams, ok := params.(*DiscoverParams); ok { - protocolVersion, ok := discoverParams.Meta[MetaKeyProtocolVersion].(string) - if !ok { - return nil, jsonrpc2.ErrInvalidRequest - } - ctx = context.WithValue(ctx, protocolVersionContextKey{}, protocolVersion) - } else { - injectMeta(req) - } + // Populate the SEP-2575 per-request _meta triple. + injectMeta(req) // Notifications don't have results. if strings.HasPrefix(method, "notifications/") { diff --git a/mcp/streamable.go b/mcp/streamable.go index defea713..43d17953 100644 --- a/mcp/streamable.go +++ b/mcp/streamable.go @@ -1830,18 +1830,26 @@ type streamableClientConn struct { failed chan struct{} // signal failure // Guard the initialization state. - mu sync.Mutex - initializedResult *InitializeResult - sessionID string + mu sync.Mutex + initializedResult *InitializeResult + requestedProtocolVersion string + sessionID string } -var _ clientConnection = (*streamableClientConn)(nil) - func (c *streamableClientConn) sessionUpdated(state clientSessionState) { c.mu.Lock() c.initializedResult = state.InitializeResult c.mu.Unlock() + // Under SEP-2575 (protocol version >= 2026-06-30) the standalone HTTP GET + // SSE stream is removed; server-to-client notifications instead flow via + // the new subscriptions/listen RPC. Only open the standalone SSE stream + // for legacy protocol versions. + if state.InitializeResult == nil || + state.InitializeResult.ProtocolVersion >= protocolVersion20260630 { + return + } + // Start the standalone SSE stream as soon as we have the initialized // result, if continuous listening is enabled. // @@ -1860,6 +1868,16 @@ func (c *streamableClientConn) sessionUpdated(state clientSessionState) { } } +// setRequestedProtocolVersion records the protocol version that the client +// will advertise on the SEP-2575 server/discover request. It is used by +// [streamableClientConn.setMCPHeaders] to populate the Mcp-Protocol-Version +// header before the handshake completes and initializedResult is set. +func (c *streamableClientConn) setRequestedProtocolVersion(v string) { + c.mu.Lock() + c.requestedProtocolVersion = v + c.mu.Unlock() +} + func (c *streamableClientConn) connectStandaloneSSE() { resp, err := c.connectSSE(c.ctx, "", 0, true) if err != nil { @@ -2141,8 +2159,8 @@ func (c *streamableClientConn) setMCPHeaders(req *http.Request) error { } if c.initializedResult != nil { req.Header.Set(protocolVersionHeader, c.initializedResult.ProtocolVersion) - } else if protocolVersion, ok := req.Context().Value(protocolVersionContextKey{}).(string); ok { - req.Header.Set(protocolVersionHeader, protocolVersion) + } else if c.requestedProtocolVersion != "" { + req.Header.Set(protocolVersionHeader, c.requestedProtocolVersion) } if c.sessionID != "" { req.Header.Set(sessionIDHeader, c.sessionID) diff --git a/mcp/transport.go b/mcp/transport.go index 3070f15c..819a468b 100644 --- a/mcp/transport.go +++ b/mcp/transport.go @@ -85,6 +85,18 @@ type clientConnection interface { sessionUpdated(clientSessionState) } +// protocolVersionSetter is an optional capability implemented by client +// connections that need to know the protocol version advertised on the very +// first outbound request (the SEP-2575 server/discover RPC) before the +// handshake completes, so they can populate transport-level metadata such as +// the Mcp-Protocol-Version HTTP header. +// +// Transports without out-of-band version metadata (stdio, in-memory, etc.) do +// not implement this interface. +type protocolVersionSetter interface { + setRequestedProtocolVersion(string) +} + // A serverConnection is a Connection that is specific to the MCP server. // // If server connections implement this interface, they receive information From 22c0c7d12cb49ee963bbba040efbf65073ccb6c6 Mon Sep 17 00:00:00 2001 From: guglielmoc Date: Mon, 25 May 2026 10:21:10 +0000 Subject: [PATCH 23/40] refactor: update protocol versioning, remove client keepalive initialization, and correct session metadata mapping --- mcp/client.go | 5 +---- mcp/shared.go | 4 ++-- 2 files changed, 3 insertions(+), 6 deletions(-) diff --git a/mcp/client.go b/mcp/client.go index cefeaf8e..565dbabd 100644 --- a/mcp/client.go +++ b/mcp/client.go @@ -274,7 +274,7 @@ func (c *Client) Connect(ctx context.Context, t Transport, opts *ClientSessionOp // the discover request, so it can populate the Mcp-Protocol-Version // header before InitializeResult is available. if s, ok := cs.mcpConn.(protocolVersionSetter); ok { - s.setRequestedProtocolVersion(protocolVersion20260630) + s.setRequestedProtocolVersion(protocolVersion) } // Per SEP-2575, try the stateless server/discover RPC first. If the server @@ -291,9 +291,6 @@ func (c *Client) Connect(ctx context.Context, t Transport, opts *ClientSessionOp if hc, ok := cs.mcpConn.(clientConnection); ok { hc.sessionUpdated(cs.state) } - if c.opts.KeepAlive > 0 { - cs.startKeepalive(c.opts.KeepAlive) - } return cs, nil } else { protocolVersion = protocolVersion20251125 diff --git a/mcp/shared.go b/mcp/shared.go index 978bf65c..093c2ad1 100644 --- a/mcp/shared.go +++ b/mcp/shared.go @@ -233,10 +233,10 @@ func injectMeta(req Request) { m[MetaKeyProtocolVersion] = res.ProtocolVersion } if _, ok := m[MetaKeyClientInfo]; !ok { - m[MetaKeyClientInfo] = res.ServerInfo + m[MetaKeyClientInfo] = cs.client.impl } if _, ok := m[MetaKeyClientCapabilities]; !ok { - m[MetaKeyClientCapabilities] = res.Capabilities + m[MetaKeyClientCapabilities] = cs.client.capabilities(res.ProtocolVersion) } params.SetMeta(m) } From 31b343cb89317c4137b3bfa5e9a363124ac5d996 Mon Sep 17 00:00:00 2001 From: guglielmoc Date: Mon, 25 May 2026 10:39:40 +0000 Subject: [PATCH 24/40] fix: propagate discovery errors and add Bad Request to legacy fallback triggers in MCP client --- mcp/client.go | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/mcp/client.go b/mcp/client.go index 565dbabd..90387dda 100644 --- a/mcp/client.go +++ b/mcp/client.go @@ -284,17 +284,18 @@ func (c *Client) Connect(ctx context.Context, t Transport, opts *ClientSessionOp // is propagated so the caller sees the real cause instead of being // silently downgraded. discRes, fallback, err := c.discover(ctx, cs) - // The current implementation of the server does not allow to properly define the error cause. - // Fallback on the legacy initialization on any type of error. - if err == nil && !fallback { + if !fallback { cs.state.InitializeResult = discRes if hc, ok := cs.mcpConn.(clientConnection); ok { hc.sessionUpdated(cs.state) } return cs, nil - } else { - protocolVersion = protocolVersion20251125 } + if err != nil { + return nil, err + } + // Fallback to the legacy initialize handshake. + protocolVersion = protocolVersion20251125 } params := &InitializeParams{ @@ -361,6 +362,10 @@ func (c *Client) discover(ctx context.Context, cs *ClientSession) (*InitializeRe if errors.As(err, &werr) && (werr.Code == jsonrpc.CodeMethodNotFound || werr.Code == CodeUnsupportedProtocolVersion) { return nil, true, nil } + if strings.Contains(err.Error(), "Bad Request") { + return nil, true, nil + } + _ = cs.Close() return nil, false, err } From 38d8b5941b43f2962645469fa0bead5adf69f31e Mon Sep 17 00:00:00 2001 From: guglielmoc Date: Mon, 25 May 2026 13:03:22 +0000 Subject: [PATCH 25/40] refactor: update MCP discovery logic to handle protocol version fallbacks and clarify KeepAlive constraints --- mcp/client.go | 23 ++++++++++------------- 1 file changed, 10 insertions(+), 13 deletions(-) diff --git a/mcp/client.go b/mcp/client.go index 90387dda..cca5e2b7 100644 --- a/mcp/client.go +++ b/mcp/client.go @@ -157,6 +157,7 @@ type ClientOptions struct { // If non-zero, defines an interval for regular "ping" requests. // If the peer fails to respond to pings originating from the keepalive check, // the session is automatically closed. + // NOTE: The keepalive feature is only available for protocol versions < 2026-06-30 KeepAlive time.Duration } @@ -278,12 +279,12 @@ func (c *Client) Connect(ctx context.Context, t Transport, opts *ClientSessionOp } // Per SEP-2575, try the stateless server/discover RPC first. If the server - // signals it doesn't support it ("Method not found" or the SEP-2575 - // UnsupportedProtocolVersionError), fall back to the legacy initialize - // handshake. Any other error (transport failure, malformed response, etc.) - // is propagated so the caller sees the real cause instead of being - // silently downgraded. + // signals it doesn't support it, fall back to the legacy initialize + // handshake. discRes, fallback, err := c.discover(ctx, cs) + if err != nil { + return nil, err + } if !fallback { cs.state.InitializeResult = discRes if hc, ok := cs.mcpConn.(clientConnection); ok { @@ -291,10 +292,10 @@ func (c *Client) Connect(ctx context.Context, t Transport, opts *ClientSessionOp } return cs, nil } - if err != nil { - return nil, err - } // Fallback to the legacy initialize handshake. + if s, ok := cs.mcpConn.(protocolVersionSetter); ok { + s.setRequestedProtocolVersion("") + } protocolVersion = protocolVersion20251125 } @@ -362,11 +363,7 @@ func (c *Client) discover(ctx context.Context, cs *ClientSession) (*InitializeRe if errors.As(err, &werr) && (werr.Code == jsonrpc.CodeMethodNotFound || werr.Code == CodeUnsupportedProtocolVersion) { return nil, true, nil } - if strings.Contains(err.Error(), "Bad Request") { - return nil, true, nil - } - _ = cs.Close() - return nil, false, err + return nil, true, nil } // Pick the highest protocol version that both the server and this SDK From 9e3da0c778b8b71e755354b946ddece53ab15511 Mon Sep 17 00:00:00 2001 From: guglielmoc Date: Mon, 25 May 2026 14:27:28 +0000 Subject: [PATCH 26/40] refactor: replace protocolVersionSetter interface with context-based version propagation in client and transport layers --- mcp/client.go | 23 ++++++----------------- mcp/streamable.go | 21 +++++---------------- mcp/transport.go | 12 ------------ 3 files changed, 11 insertions(+), 45 deletions(-) diff --git a/mcp/client.go b/mcp/client.go index cca5e2b7..df850d98 100644 --- a/mcp/client.go +++ b/mcp/client.go @@ -271,16 +271,10 @@ func (c *Client) Connect(ctx context.Context, t Transport, opts *ClientSessionOp } if protocolVersion >= protocolVersion20260630 { - // Inform the transport which protocol version we intend to advertise on - // the discover request, so it can populate the Mcp-Protocol-Version - // header before InitializeResult is available. - if s, ok := cs.mcpConn.(protocolVersionSetter); ok { - s.setRequestedProtocolVersion(protocolVersion) - } - // Per SEP-2575, try the stateless server/discover RPC first. If the server // signals it doesn't support it, fall back to the legacy initialize // handshake. + ctx = context.WithValue(ctx, protocolVersionContextKey{}, protocolVersion) discRes, fallback, err := c.discover(ctx, cs) if err != nil { return nil, err @@ -293,9 +287,6 @@ func (c *Client) Connect(ctx context.Context, t Transport, opts *ClientSessionOp return cs, nil } // Fallback to the legacy initialize handshake. - if s, ok := cs.mcpConn.(protocolVersionSetter); ok { - s.setRequestedProtocolVersion("") - } protocolVersion = protocolVersion20251125 } @@ -340,12 +331,8 @@ func (c *Client) Connect(ctx context.Context, t Transport, opts *ClientSessionOp // caller should fall back to the legacy initialize handshake. // - (nil, false, err): any other failure (transport error, malformed response, etc.); // caller should propagate the error. -// -// The request advertises the latest protocol version supported by this SDK -// (>= 2026-06-30), along with the client's info and capabilities, via the -// per-request _meta triple defined by SEP-2575. func (c *Client) discover(ctx context.Context, cs *ClientSession) (*InitializeResult, bool, error) { - protocolVersion := protocolVersion20260630 + protocolVersion, _ := ctx.Value(protocolVersionContextKey{}).(string) caps := c.capabilities(protocolVersion) params := &DiscoverParams{ Meta: Meta{ @@ -357,8 +344,10 @@ func (c *Client) discover(ctx context.Context, cs *ClientSession) (*InitializeRe req := &DiscoverRequest{Session: cs, Params: params} res, err := handleSend[*DiscoverResult](ctx, methodDiscover, req) if err != nil { - // Only treat the two SEP-2575 "not supported" signals as a fallback - // trigger; everything else is a real error. + // According to SEP-2575, only the two signals below (MethodNotFound + // and UnsupportedProtocolVersionError) should trigger a fallback. However, + // to allow communication between vPost clients and vPre servers, we + // trigger fallback for any error. var werr *jsonrpc.Error if errors.As(err, &werr) && (werr.Code == jsonrpc.CodeMethodNotFound || werr.Code == CodeUnsupportedProtocolVersion) { return nil, true, nil diff --git a/mcp/streamable.go b/mcp/streamable.go index 43d17953..8719480b 100644 --- a/mcp/streamable.go +++ b/mcp/streamable.go @@ -1830,10 +1830,9 @@ type streamableClientConn struct { failed chan struct{} // signal failure // Guard the initialization state. - mu sync.Mutex - initializedResult *InitializeResult - requestedProtocolVersion string - sessionID string + mu sync.Mutex + initializedResult *InitializeResult + sessionID string } func (c *streamableClientConn) sessionUpdated(state clientSessionState) { @@ -1868,16 +1867,6 @@ func (c *streamableClientConn) sessionUpdated(state clientSessionState) { } } -// setRequestedProtocolVersion records the protocol version that the client -// will advertise on the SEP-2575 server/discover request. It is used by -// [streamableClientConn.setMCPHeaders] to populate the Mcp-Protocol-Version -// header before the handshake completes and initializedResult is set. -func (c *streamableClientConn) setRequestedProtocolVersion(v string) { - c.mu.Lock() - c.requestedProtocolVersion = v - c.mu.Unlock() -} - func (c *streamableClientConn) connectStandaloneSSE() { resp, err := c.connectSSE(c.ctx, "", 0, true) if err != nil { @@ -2159,8 +2148,8 @@ func (c *streamableClientConn) setMCPHeaders(req *http.Request) error { } if c.initializedResult != nil { req.Header.Set(protocolVersionHeader, c.initializedResult.ProtocolVersion) - } else if c.requestedProtocolVersion != "" { - req.Header.Set(protocolVersionHeader, c.requestedProtocolVersion) + } else if v, ok := req.Context().Value(protocolVersionContextKey{}).(string); ok && v != "" { + req.Header.Set(protocolVersionHeader, v) } if c.sessionID != "" { req.Header.Set(sessionIDHeader, c.sessionID) diff --git a/mcp/transport.go b/mcp/transport.go index 819a468b..3070f15c 100644 --- a/mcp/transport.go +++ b/mcp/transport.go @@ -85,18 +85,6 @@ type clientConnection interface { sessionUpdated(clientSessionState) } -// protocolVersionSetter is an optional capability implemented by client -// connections that need to know the protocol version advertised on the very -// first outbound request (the SEP-2575 server/discover RPC) before the -// handshake completes, so they can populate transport-level metadata such as -// the Mcp-Protocol-Version HTTP header. -// -// Transports without out-of-band version metadata (stdio, in-memory, etc.) do -// not implement this interface. -type protocolVersionSetter interface { - setRequestedProtocolVersion(string) -} - // A serverConnection is a Connection that is specific to the MCP server. // // If server connections implement this interface, they receive information From 372c6ee97e0935a7ef994ac37df777825547815a Mon Sep 17 00:00:00 2001 From: guglielmoc Date: Mon, 25 May 2026 15:08:14 +0000 Subject: [PATCH 27/40] test: add discoverInterceptor to test suite and clean up server-side discover implementation --- mcp/client.go | 4 +-- mcp/server.go | 11 ------- mcp/streamable_test.go | 71 +++++++++++++++++++++++++++++++++++++++--- 3 files changed, 68 insertions(+), 18 deletions(-) diff --git a/mcp/client.go b/mcp/client.go index df850d98..c31d0482 100644 --- a/mcp/client.go +++ b/mcp/client.go @@ -274,8 +274,8 @@ func (c *Client) Connect(ctx context.Context, t Transport, opts *ClientSessionOp // Per SEP-2575, try the stateless server/discover RPC first. If the server // signals it doesn't support it, fall back to the legacy initialize // handshake. - ctx = context.WithValue(ctx, protocolVersionContextKey{}, protocolVersion) - discRes, fallback, err := c.discover(ctx, cs) + discoverCtx := context.WithValue(ctx, protocolVersionContextKey{}, protocolVersion) + discRes, fallback, err := c.discover(discoverCtx, cs) if err != nil { return nil, err } diff --git a/mcp/server.go b/mcp/server.go index 50a17c7d..6794f769 100644 --- a/mcp/server.go +++ b/mcp/server.go @@ -745,16 +745,6 @@ func (s *Server) getPrompt(ctx context.Context, req *GetPromptRequest) (*GetProm return prompt.handler(ctx, req) } -// discover is the server-side handler for the SEP-2575 "server/discover" RPC. -// -// Server-side discovery is not implemented yet (the SDK still uses the legacy -// initialization handshake for the protocol versions it supports). Returning -// ErrMethodNotFound here lets the client probe for support and fall back to -// the initialize handshake when the peer is a pre-2026-06-30 server. -func (s *Server) discover(context.Context, *ServerRequest[*DiscoverParams]) (*DiscoverResult, error) { - return nil, jsonrpc2.ErrMethodNotFound -} - func (s *Server) listTools(_ context.Context, req *ListToolsRequest) (*ListToolsResult, error) { s.mu.Lock() defer s.mu.Unlock() @@ -1396,7 +1386,6 @@ func (s *Server) AddReceivingMiddleware(middleware ...Middleware) { // curating these method flags. var serverMethodInfos = map[string]methodInfo{ methodComplete: newServerMethodInfo(serverMethod((*Server).complete), 0), - methodDiscover: newServerMethodInfo(serverMethod((*Server).discover), missingParamsOK), methodInitialize: initializeMethodInfo(), methodPing: newServerMethodInfo(serverSessionMethod((*ServerSession).ping), missingParamsOK), methodListPrompts: newServerMethodInfo(serverMethod((*Server).listPrompts), missingParamsOK), diff --git a/mcp/streamable_test.go b/mcp/streamable_test.go index d687a7b2..c4418379 100644 --- a/mcp/streamable_test.go +++ b/mcp/streamable_test.go @@ -2043,8 +2043,6 @@ func TestStreamableMcpHeaderValidation(t *testing.T) { }) } - - // TestStreamableMcpHeaderValidationErrorFormat verifies that header // validation errors return a JSON-RPC error with code -32001 and // Content-Type application/json, per SEP-2243. @@ -2065,7 +2063,15 @@ func TestStreamableMcpHeaderValidationErrorFormat(t *testing.T) { }) defer handler.closeAll() - httpServer := httptest.NewServer(mustNotPanic(t, handler)) + // 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)) defer httpServer.Close() // Use the MCP client with a custom RoundTripper to inject a bad header. @@ -2227,7 +2233,15 @@ func TestStreamableParamHeadersClientSetsHeaders(t *testing.T) { Stateless: true, }) defer handler.closeAll() - httpServer := httptest.NewServer(mustNotPanic(t, handler)) + // 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)) defer httpServer.Close() var capturedHeaders http.Header @@ -2254,7 +2268,9 @@ func TestStreamableParamHeadersClientSetsHeaders(t *testing.T) { defer session.Close() // ListTools to populate the tool cache (needed for param headers). - if _, err := session.ListTools(ctx, nil); err != nil { + // Pass a non-nil params so the SEP-2575 per-request _meta triple is + // injected; injectMeta is a no-op when params is nil. + if _, err := session.ListTools(ctx, &ListToolsParams{}); err != nil { t.Fatal(err) } @@ -2660,6 +2676,51 @@ 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 { From a0e6114be4f1e8152fb12c7e0b489598c5721ec0 Mon Sep 17 00:00:00 2001 From: guglielmoc Date: Mon, 25 May 2026 15:52:03 +0000 Subject: [PATCH 28/40] feat: add discover method stub to server and register in methodInfos --- mcp/server.go | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/mcp/server.go b/mcp/server.go index bc6b64a6..d7daa2d6 100644 --- a/mcp/server.go +++ b/mcp/server.go @@ -745,6 +745,21 @@ func (s *Server) getPrompt(ctx context.Context, req *GetPromptRequest) (*GetProm return prompt.handler(ctx, req) } +// discover is the server-side handler for the SEP-2575 "server/discover" RPC. +// +// Server-side discovery is not implemented yet; this stub returns +// ErrMethodNotFound so that vPost-capable clients fall back to the legacy +// initialize handshake when probing a pre-2026-06-30 server. +// +// The corresponding entry in [serverMethodInfos] is also required by the +// client-side dispatch path: [ClientSession.sendingMethodInfos] returns +// [serverMethodInfos], so removing this registration causes +// handleSend[*DiscoverResult] to fail with ErrNotHandled before any HTTP +// request goes out. +func (s *Server) discover(context.Context, *ServerRequest[*DiscoverParams]) (*DiscoverResult, error) { + return nil, jsonrpc2.ErrMethodNotFound +} + func (s *Server) listTools(_ context.Context, req *ListToolsRequest) (*ListToolsResult, error) { s.mu.Lock() defer s.mu.Unlock() @@ -1386,6 +1401,7 @@ func (s *Server) AddReceivingMiddleware(middleware ...Middleware) { // curating these method flags. var serverMethodInfos = map[string]methodInfo{ methodComplete: newServerMethodInfo(serverMethod((*Server).complete), 0), + methodDiscover: newServerMethodInfo(serverMethod((*Server).discover), missingParamsOK), methodInitialize: initializeMethodInfo(), methodPing: newServerMethodInfo(serverSessionMethod((*ServerSession).ping), missingParamsOK), methodListPrompts: newServerMethodInfo(serverMethod((*Server).listPrompts), missingParamsOK), From d642211a123c843107afba82eef70d59b85fba7f Mon Sep 17 00:00:00 2001 From: guglielmoc Date: Tue, 26 May 2026 10:03:53 +0000 Subject: [PATCH 29/40] feat: implement server-side support for protocol discovery and version filtering --- mcp/protocol.go | 11 +++ mcp/server.go | 63 +++++++++++++---- mcp/streamable.go | 46 ++++++++++++- mcp/streamable_test.go | 151 ++++++++++++++++++++++++----------------- mcp/transport.go | 13 ++++ 5 files changed, 207 insertions(+), 77 deletions(-) diff --git a/mcp/protocol.go b/mcp/protocol.go index 21c411b3..9a89d623 100644 --- a/mcp/protocol.go +++ b/mcp/protocol.go @@ -1696,3 +1696,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 d7daa2d6..86a70e93 100644 --- a/mcp/server.go +++ b/mcp/server.go @@ -747,17 +747,38 @@ func (s *Server) getPrompt(ctx context.Context, req *GetPromptRequest) (*GetProm // discover is the server-side handler for the SEP-2575 "server/discover" RPC. // -// Server-side discovery is not implemented yet; this stub returns -// ErrMethodNotFound so that vPost-capable clients fall back to the legacy -// initialize handshake when probing a pre-2026-06-30 server. -// -// The corresponding entry in [serverMethodInfos] is also required by the -// client-side dispatch path: [ClientSession.sendingMethodInfos] returns -// [serverMethodInfos], so removing this registration causes -// handleSend[*DiscoverResult] to fail with ErrNotHandled before any HTTP -// request goes out. -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) + } + 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) { @@ -1071,6 +1092,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 { @@ -1155,6 +1181,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 } @@ -1482,7 +1514,7 @@ func (ss *ServerSession) handle(ctx context.Context, req *jsonrpc.Request) (any, } 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{ @@ -1490,6 +1522,13 @@ func (ss *ServerSession) handle(ctx context.Context, req *jsonrpc.Request) (any, Message: fmt.Sprintf("%q is not supported in the new protocol", req.Method), } } + case methodDiscover: + if !validatedMeta.usesNewProtocol { + return nil, &jsonrpc.Error{ + Code: jsonrpc.CodeMethodNotFound, + Message: fmt.Sprintf("%q is not supported in the legacy protocol", req.Method), + } + } default: if !initialized && !validatedMeta.usesNewProtocol { ss.server.opts.Logger.Error("method invalid during initialization", "method", req.Method) diff --git a/mcp/streamable.go b/mcp/streamable.go index 8719480b..3d463463 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 @@ -292,6 +304,18 @@ 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 >= protocolVersion20260630 { + data, _ := json.Marshal(UnsupportedProtocolVersionData{ + Supported: supportedProtocolVersions, + Requested: protocolVersion, + }) + writeJSONRPCError(w, http.StatusBadRequest, jsonrpc.ID{}, &jsonrpc.Error{ + Code: CodeUnsupportedProtocolVersion, + Message: "unsupported protocol version", + Data: data, + }) + return + } http.Error(w, fmt.Sprintf("Bad Request: Unsupported protocol version (supported versions: %s)", strings.Join(supportedProtocolVersions, ",")), http.StatusBadRequest) return } @@ -782,6 +806,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 @@ -1299,6 +1333,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) { + writeJSONRPCError(w, http.StatusNotFound, jreq.ID, &jsonrpc.Error{ + Code: jsonrpc.CodeMethodNotFound, + Message: err.Error(), + }) + return + } http.Error(w, err.Error(), http.StatusBadRequest) return } @@ -1320,7 +1361,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), diff --git a/mcp/streamable_test.go b/mcp/streamable_test.go index 7793d90d..862bedd4 100644 --- a/mcp/streamable_test.go +++ b/mcp/streamable_test.go @@ -2063,15 +2063,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. @@ -2233,15 +2225,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 @@ -2368,7 +2352,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) } @@ -2676,51 +2662,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 { @@ -3571,3 +3512,85 @@ func TestStreamableStateless_AcceptsNewProtocol(t *testing.T) { t.Fatalf("status = %d, want 200; body = %s", resp.StatusCode, respBody) } } + +// 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") + } +} diff --git a/mcp/transport.go b/mcp/transport.go index 3070f15c..f8efa945 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. From d1b64edb32806e85e46bc1b8e74597d990746caf Mon Sep 17 00:00:00 2001 From: guglielmoc Date: Tue, 2 Jun 2026 14:45:03 +0000 Subject: [PATCH 30/40] feat: implement server/discover protocol support and remove test-only interceptors --- mcp/mcp_test.go | 8 ---- mcp/mrtr_test.go | 21 ---------- mcp/streamable.go | 34 ++++++++++------ mcp/streamable_test.go | 75 ++--------------------------------- mcp/transport_example_test.go | 6 +-- 5 files changed, 26 insertions(+), 118 deletions(-) diff --git a/mcp/mcp_test.go b/mcp/mcp_test.go index 87c67e17..5c8e7d12 100644 --- a/mcp/mcp_test.go +++ b/mcp/mcp_test.go @@ -768,10 +768,6 @@ func TestMiddleware(t *testing.T) { } wantServer := ` -R1 >server/discover -R2 >server/discover -R2 initialize R2 >initialize R2 server/discover -S2 >server/discover -S2 initialize S2 >initialize S2 = protocolVersion20260630 { - data, _ := json.Marshal(UnsupportedProtocolVersionData{ - Supported: supportedProtocolVersions, - Requested: protocolVersion, - }) - writeJSONRPCError(w, http.StatusBadRequest, jsonrpc.ID{}, &jsonrpc.Error{ - Code: CodeUnsupportedProtocolVersion, - Message: "unsupported protocol version", - Data: data, - }) - return - } + 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 } @@ -1343,6 +1331,21 @@ func (c *streamableServerConn) servePOST(w http.ResponseWriter, req *http.Reques http.Error(w, err.Error(), http.StatusBadRequest) return } + // Reject any request with a protocol version that is not supported. + // This check is only performed for protocol versions >= 2026-06-30 as per SEP-2738, and is perfomed here to include the jreq-ID in + // the response object. + if protocolVersion != "" && protocolVersion >= protocolVersion20260630 && !slices.Contains(supportedProtocolVersions, protocolVersion) { + data, _ := json.Marshal(UnsupportedProtocolVersionData{ + Supported: supportedProtocolVersions, + Requested: protocolVersion, + }) + writeJSONRPCError(w, http.StatusBadRequest, jreq.ID, &jsonrpc.Error{ + Code: CodeUnsupportedProtocolVersion, + Message: "unsupported protocol version", + Data: data, + }) + return + } if jreq.Method == methodInitialize { isInitialize = true // Extract the protocol version from InitializeParams. @@ -2319,6 +2322,11 @@ 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 86ef7d60..15f8152a 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) @@ -2692,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 { diff --git a/mcp/transport_example_test.go b/mcp/transport_example_test.go index 257de747..fa05fc02 100644 --- a/mcp/transport_example_test.go +++ b/mcp/transport_example_test.go @@ -42,10 +42,8 @@ func ExampleLoggingTransport() { } // Output: - // read: {"jsonrpc":"2.0","id":1,"error":{"code":-32601,"message":"method not found: \"server/discover\""}} - // read: {"jsonrpc":"2.0","id":2,"result":{"capabilities":{"logging":{}},"protocolVersion":"2025-11-25","serverInfo":{"name":"server","version":"v0.0.1"}}} - // write: {"jsonrpc":"2.0","id":1,"method":"server/discover","params":{"_meta":{"io.modelcontextprotocol/clientCapabilities":{"roots":{"listChanged":true}},"io.modelcontextprotocol/clientInfo":{"name":"client","version":"v0.0.1"},"io.modelcontextprotocol/protocolVersion":"2026-06-30"}}} - // write: {"jsonrpc":"2.0","id":2,"method":"initialize","params":{"clientInfo":{"name":"client","version":"v0.0.1"},"protocolVersion":"2025-11-25","capabilities":{"roots":{"listChanged":true}}}} + // read: {"jsonrpc":"2.0","id":1,"result":{"capabilities":{"logging":{}},"protocolVersion":"2025-11-25","serverInfo":{"name":"server","version":"v0.0.1"}}} + // write: {"jsonrpc":"2.0","id":1,"method":"initialize","params":{"clientInfo":{"name":"client","version":"v0.0.1"},"protocolVersion":"2025-11-25","capabilities":{"roots":{"listChanged":true}}}} // write: {"jsonrpc":"2.0","method":"notifications/initialized","params":{}} } From 1cd089d1ba7eae2bcab0b150c6afa59a6b12dd26 Mon Sep 17 00:00:00 2001 From: guglielmoc Date: Tue, 2 Jun 2026 15:39:26 +0000 Subject: [PATCH 31/40] fix: ensure MethodNotFound error codes are correctly propagated by using errors.Is for wrapped errors --- docs/mcpgodebug.md | 6 ++++++ internal/docs/mcpgodebug.src.md | 6 ++++++ internal/jsonrpc2/conn.go | 21 ++++++++++++++++++--- mcp/streamable.go | 1 - 4 files changed, 30 insertions(+), 4 deletions(-) diff --git a/docs/mcpgodebug.md b/docs/mcpgodebug.md index f1e62373..2fbba2ef 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 (`-32001`) 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..b4235aa3 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 (`-32001`) 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..5125602d 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 == "0" && (errors.Is(err, ErrNotHandled) || errors.Is(err, ErrMethodNotFound)) { err = fmt.Errorf("%w: %q", ErrMethodNotFound, req.Method) } diff --git a/mcp/streamable.go b/mcp/streamable.go index a93a50ff..e8bee5f8 100644 --- a/mcp/streamable.go +++ b/mcp/streamable.go @@ -2326,7 +2326,6 @@ func (c *streamableClientConn) checkResponse(ctx context.Context, requestSummary 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)) } From 362f4497e8bb34950b2a644f5779b13011cdb467 Mon Sep 17 00:00:00 2001 From: guglielmoc Date: Tue, 2 Jun 2026 16:24:50 +0000 Subject: [PATCH 32/40] feat: implement SEP-2575 protocol version negotiation via server discover and add end-to-end smoke tests --- mcp/client_test.go | 240 +++++++++++++++++++++++++++++++++++++++++ mcp/server.go | 7 ++ mcp/streamable.go | 28 +++-- mcp/streamable_test.go | 71 ++++++++++++ mcp/transport.go | 4 - 5 files changed, 331 insertions(+), 19 deletions(-) diff --git a/mcp/client_test.go b/mcp/client_test.go index c5b403ad..46abfc8c 100644 --- a/mcp/client_test.go +++ b/mcp/client_test.go @@ -8,6 +8,7 @@ import ( "context" "fmt" "log/slog" + "slices" "sync/atomic" "testing" @@ -852,3 +853,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/server.go b/mcp/server.go index 123c9d10..0d042252 100644 --- a/mcp/server.go +++ b/mcp/server.go @@ -775,6 +775,13 @@ func (s *Server) discover(_ context.Context, req *ServerRequest[*DiscoverParams] 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(), diff --git a/mcp/streamable.go b/mcp/streamable.go index e8bee5f8..8589f324 100644 --- a/mcp/streamable.go +++ b/mcp/streamable.go @@ -1331,21 +1331,6 @@ func (c *streamableServerConn) servePOST(w http.ResponseWriter, req *http.Reques http.Error(w, err.Error(), http.StatusBadRequest) return } - // Reject any request with a protocol version that is not supported. - // This check is only performed for protocol versions >= 2026-06-30 as per SEP-2738, and is perfomed here to include the jreq-ID in - // the response object. - if protocolVersion != "" && protocolVersion >= protocolVersion20260630 && !slices.Contains(supportedProtocolVersions, protocolVersion) { - data, _ := json.Marshal(UnsupportedProtocolVersionData{ - Supported: supportedProtocolVersions, - Requested: protocolVersion, - }) - writeJSONRPCError(w, http.StatusBadRequest, jreq.ID, &jsonrpc.Error{ - Code: CodeUnsupportedProtocolVersion, - Message: "unsupported protocol version", - Data: data, - }) - return - } if jreq.Method == methodInitialize { isInitialize = true // Extract the protocol version from InitializeParams. @@ -1391,6 +1376,19 @@ func (c *streamableServerConn) servePOST(w http.ResponseWriter, req *http.Reques http.StatusBadRequest) return } + // Reject any request with a protocol version that is not supported. + if !slices.Contains(supportedProtocolVersions, protocolVersion) { + data, _ := json.Marshal(UnsupportedProtocolVersionData{ + Supported: supportedProtocolVersions, + Requested: protocolVersion, + }) + writeJSONRPCError(w, http.StatusBadRequest, jreq.ID, &jsonrpc.Error{ + Code: CodeUnsupportedProtocolVersion, + Message: "unsupported protocol version", + Data: data, + }) + return + } } // Include metadata for all requests (including notifications). jreq.Extra = &RequestExtra{ diff --git a/mcp/streamable_test.go b/mcp/streamable_test.go index 15f8152a..2efee20e 100644 --- a/mcp/streamable_test.go +++ b/mcp/streamable_test.go @@ -3645,3 +3645,74 @@ func TestStreamableStateful_AcceptsDiscover(t *testing.T) { 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 f8efa945..e55b4f0e 100644 --- a/mcp/transport.go +++ b/mcp/transport.go @@ -238,10 +238,6 @@ func call(ctx context.Context, conn *jsonrpc2.Connection, method string, params err := call.Await(ctx, result) switch { case errors.Is(err, jsonrpc2.ErrClientClosing), errors.Is(err, jsonrpc2.ErrServerClosing): - // Use errors.Join so callers can still inspect the underlying - // jsonrpc2 wire error via errors.As (e.g. to distinguish - // SEP-2575 UnsupportedProtocolVersionError, which uses the same - // JSON-RPC code -32004 as ErrServerClosing). return errors.Join(fmt.Errorf("%w: calling %q: %v", ErrConnectionClosed, method, err), err) case ctx.Err() != nil: notifyCtx, cancelNotify := context.WithTimeout(context.WithoutCancel(ctx), notifyCancellationTimeout) From 64da66f6819b4fd2941b800dde400694c77130c2 Mon Sep 17 00:00:00 2001 From: guglielmoc Date: Tue, 2 Jun 2026 16:39:07 +0000 Subject: [PATCH 33/40] wip --- internal/jsonrpc2/conn.go | 2 +- mcp/server.go | 11 +++++++++++ mcp/shared.go | 2 +- 3 files changed, 13 insertions(+), 2 deletions(-) diff --git a/internal/jsonrpc2/conn.go b/internal/jsonrpc2/conn.go index 5125602d..3b4bc57a 100644 --- a/internal/jsonrpc2/conn.go +++ b/internal/jsonrpc2/conn.go @@ -665,7 +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 { - if nomethodnotfoundcodeinerror == "0" && (errors.Is(err, ErrNotHandled) || errors.Is(err, ErrMethodNotFound)) { + if nomethodnotfoundcodeinerror != "1" && (errors.Is(err, ErrNotHandled) || errors.Is(err, ErrMethodNotFound)) { err = fmt.Errorf("%w: %q", ErrMethodNotFound, req.Method) } diff --git a/mcp/server.go b/mcp/server.go index 0d042252..edd4d7a7 100644 --- a/mcp/server.go +++ b/mcp/server.go @@ -1543,6 +1543,17 @@ 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, methodSubscribe, methodUnsubscribe: diff --git a/mcp/shared.go b/mcp/shared.go index 0392132f..48889d32 100644 --- a/mcp/shared.go +++ b/mcp/shared.go @@ -503,7 +503,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 From f03e810faf1a687e2c355622c91a756be631f03c Mon Sep 17 00:00:00 2001 From: guglielmoc Date: Tue, 2 Jun 2026 16:40:42 +0000 Subject: [PATCH 34/40] test: inject protocolVersion20260630 into supportedProtocolVersions for client and server tests --- mcp/client_test.go | 4 ++++ mcp/server_test.go | 8 ++++++++ 2 files changed, 12 insertions(+) diff --git a/mcp/client_test.go b/mcp/client_test.go index 46abfc8c..f8d1383a 100644 --- a/mcp/client_test.go +++ b/mcp/client_test.go @@ -784,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 { 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, From 1f05980c5e4fe14e7104ae32ecade6f03914c53f Mon Sep 17 00:00:00 2001 From: guglielmoc Date: Tue, 2 Jun 2026 17:42:44 +0000 Subject: [PATCH 35/40] revert changes --- mcp/streamable.go | 4 +--- mcp/transport.go | 2 +- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/mcp/streamable.go b/mcp/streamable.go index 8589f324..47c33cf1 100644 --- a/mcp/streamable.go +++ b/mcp/streamable.go @@ -1888,9 +1888,7 @@ func (c *streamableClientConn) sessionUpdated(state clientSessionState) { c.mu.Unlock() // Under SEP-2575 (protocol version >= 2026-06-30) the standalone HTTP GET - // SSE stream is removed; server-to-client notifications instead flow via - // the new subscriptions/listen RPC. Only open the standalone SSE stream - // for legacy protocol versions. + // SSE stream is removed. if state.InitializeResult == nil || state.InitializeResult.ProtocolVersion >= protocolVersion20260630 { return diff --git a/mcp/transport.go b/mcp/transport.go index e55b4f0e..f67c43d0 100644 --- a/mcp/transport.go +++ b/mcp/transport.go @@ -238,7 +238,7 @@ func call(ctx context.Context, conn *jsonrpc2.Connection, method string, params err := call.Await(ctx, result) switch { case errors.Is(err, jsonrpc2.ErrClientClosing), errors.Is(err, jsonrpc2.ErrServerClosing): - return errors.Join(fmt.Errorf("%w: calling %q: %v", ErrConnectionClosed, method, err), err) + return fmt.Errorf("%w: calling %q: %v", ErrConnectionClosed, method, err) case ctx.Err() != nil: notifyCtx, cancelNotify := context.WithTimeout(context.WithoutCancel(ctx), notifyCancellationTimeout) defer cancelNotify() From d0af619fd0dca7db9f452f87ce2141b6d3e7650f Mon Sep 17 00:00:00 2001 From: guglielmoc Date: Thu, 4 Jun 2026 09:41:41 +0000 Subject: [PATCH 36/40] feat: implement SEP-2575 protocol-level HTTP status overrides for JSON-RPC error responses. --- docs/mcpgodebug.md | 2 +- internal/docs/mcpgodebug.src.md | 2 +- mcp/server.go | 2 +- mcp/streamable.go | 77 +++++++++++++++++++++++++-------- 4 files changed, 63 insertions(+), 20 deletions(-) diff --git a/docs/mcpgodebug.md b/docs/mcpgodebug.md index 2fbba2ef..626be248 100644 --- a/docs/mcpgodebug.md +++ b/docs/mcpgodebug.md @@ -40,7 +40,7 @@ Options listed below were added and will be removed in the 1.9.0 version of the 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 (`-32001`) in the error response when the + 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. diff --git a/internal/docs/mcpgodebug.src.md b/internal/docs/mcpgodebug.src.md index b4235aa3..01ecba38 100644 --- a/internal/docs/mcpgodebug.src.md +++ b/internal/docs/mcpgodebug.src.md @@ -39,7 +39,7 @@ Options listed below were added and will be removed in the 1.9.0 version of the 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 (`-32001`) in the error response when the + 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. diff --git a/mcp/server.go b/mcp/server.go index edd4d7a7..5438e6fe 100644 --- a/mcp/server.go +++ b/mcp/server.go @@ -778,7 +778,7 @@ func (s *Server) discover(_ context.Context, req *ServerRequest[*DiscoverParams] req.Session.updateState(func(state *ServerSessionState) { state.InitializeParams = &InitializeParams{ ProtocolVersion: req.ProtocolVersion(), - Capabilities: req.ClientCapabilities(), + Capabilities: req.ClientCapabilities(), ClientInfo: req.ClientInfo(), } }) diff --git a/mcp/streamable.go b/mcp/streamable.go index 47c33cf1..79abb0e6 100644 --- a/mcp/streamable.go +++ b/mcp/streamable.go @@ -936,6 +936,36 @@ func (s *stream) release() { s.done = nil // may already be nil, if the stream is done or closed } +// newProtocolErrorStatus 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). Returns 0 if no override is needed (legacy protocol, not +// an error response, or a code without a SEP-mandated HTTP status). +// +// Per SEP-2575: +// - MethodNotFound (-32601) MUST return HTTP 404. +// - InvalidParams (-32602) and UnsupportedProtocolVersion (-32004) MUST +// return HTTP 400. +func newProtocolErrorStatus(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: + 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. @@ -943,11 +973,18 @@ 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, bypassing the SSE framing of the surrounding stream. +// Callers MUST ensure no prior write has committed the response status +// (e.g. the SSE prime event is gated to legacy protocol versions). +// // 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. @@ -962,6 +999,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 @@ -1321,7 +1369,7 @@ 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) { + if headerVersion >= protocolVersion20260630 && errors.Is(err, jsonrpc2.ErrNotHandled) && jreq.IsCall() { writeJSONRPCError(w, http.StatusNotFound, jreq.ID, &jsonrpc.Error{ Code: jsonrpc.CodeMethodNotFound, Message: err.Error(), @@ -1376,19 +1424,6 @@ func (c *streamableServerConn) servePOST(w http.ResponseWriter, req *http.Reques http.StatusBadRequest) return } - // Reject any request with a protocol version that is not supported. - if !slices.Contains(supportedProtocolVersions, protocolVersion) { - data, _ := json.Marshal(UnsupportedProtocolVersionData{ - Supported: supportedProtocolVersions, - Requested: protocolVersion, - }) - writeJSONRPCError(w, http.StatusBadRequest, jreq.ID, &jsonrpc.Error{ - Code: CodeUnsupportedProtocolVersion, - Message: "unsupported protocol version", - Data: data, - }) - return - } } // Include metadata for all requests (including notifications). jreq.Extra = &RequestExtra{ @@ -1500,7 +1535,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 @@ -1689,7 +1727,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 := newProtocolErrorStatus(ctx, msg) + + done, err := s.deliverLocked(data, eventID, responseTo, overrideStatus) if err != nil { errs = append(errs, err) } else { From ba8b4dc589a547bc85ff0e462e911c73d96f894f Mon Sep 17 00:00:00 2001 From: guglielmoc Date: Thu, 4 Jun 2026 09:43:34 +0000 Subject: [PATCH 37/40] feat: implement clientConnection interface for streamableClientConn --- mcp/streamable.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/mcp/streamable.go b/mcp/streamable.go index 79abb0e6..21a3e298 100644 --- a/mcp/streamable.go +++ b/mcp/streamable.go @@ -1925,6 +1925,8 @@ type streamableClientConn struct { sessionID string } +var _ clientConnection = (*streamableClientConn)(nil) + func (c *streamableClientConn) sessionUpdated(state clientSessionState) { c.mu.Lock() c.initializedResult = state.InitializeResult From 2afc26706e40406c376e3e1d7efaaf05930584ba Mon Sep 17 00:00:00 2001 From: guglielmoc Date: Thu, 4 Jun 2026 10:33:52 +0000 Subject: [PATCH 38/40] refactor: rename newProtocolErrorStatus to extractErrorStatus and update documentation for methodDiscover initialization logic --- mcp/server.go | 6 +++--- mcp/streamable.go | 13 +++++-------- 2 files changed, 8 insertions(+), 11 deletions(-) diff --git a/mcp/server.go b/mcp/server.go index 5438e6fe..3daa68a8 100644 --- a/mcp/server.go +++ b/mcp/server.go @@ -1565,9 +1565,9 @@ func (ss *ServerSession) handle(ctx context.Context, req *jsonrpc.Request) (any, } } case methodDiscover: - // server/discover is available to old clients too (SEP-2575): it's - // harmless and does not require initialization. state.InitializeParams - // is populated within the discover handler. + // In case of methodDiscover call the state.initializeParams is populated + // within the discover handle function to make sure the method is supported + // when the user is probing a pre-2026-06-30 server. default: if !initialized && !validatedMeta.usesNewProtocol { ss.server.opts.Logger.Error("method invalid during initialization", "method", req.Method) diff --git a/mcp/streamable.go b/mcp/streamable.go index 21a3e298..3574cb94 100644 --- a/mcp/streamable.go +++ b/mcp/streamable.go @@ -936,16 +936,15 @@ func (s *stream) release() { s.done = nil // may already be nil, if the stream is done or closed } -// newProtocolErrorStatus reports the HTTP status to send when the given +// 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). Returns 0 if no override is needed (legacy protocol, not -// an error response, or a code without a SEP-mandated HTTP status). +// (>= 2026-06-30). // // Per SEP-2575: // - MethodNotFound (-32601) MUST return HTTP 404. // - InvalidParams (-32602) and UnsupportedProtocolVersion (-32004) MUST // return HTTP 400. -func newProtocolErrorStatus(ctx context.Context, msg jsonrpc.Message) int { +func extractErrorStatus(ctx context.Context, msg jsonrpc.Message) int { if protocolVersionFromContext(ctx) < protocolVersion20260630 { return 0 } @@ -976,9 +975,7 @@ func newProtocolErrorStatus(ctx context.Context, msg jsonrpc.Message) int { // 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, bypassing the SSE framing of the surrounding stream. -// Callers MUST ensure no prior write has committed the response status -// (e.g. the SSE prime event is gated to legacy protocol versions). +// 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. @@ -1730,7 +1727,7 @@ func (c *streamableServerConn) Write(ctx context.Context, msg jsonrpc.Message) e // 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 := newProtocolErrorStatus(ctx, msg) + overrideStatus := extractErrorStatus(ctx, msg) done, err := s.deliverLocked(data, eventID, responseTo, overrideStatus) if err != nil { From 29a0883076e6a55420cc854e5712d4a11dbe2b4e Mon Sep 17 00:00:00 2001 From: guglielmoc Date: Thu, 4 Jun 2026 15:12:47 +0000 Subject: [PATCH 39/40] chore: update protocol version to DRAFT-2026-v1, improve streamable error handling, and enable stateless HTTP conformance server --- mcp/streamable.go | 31 ++++++++++++++++++++++--------- 1 file changed, 22 insertions(+), 9 deletions(-) diff --git a/mcp/streamable.go b/mcp/streamable.go index 3574cb94..b15cd4bc 100644 --- a/mcp/streamable.go +++ b/mcp/streamable.go @@ -1407,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 } } From 6937ff7ea9f55657e0b19cfe2412b6468b3b2818 Mon Sep 17 00:00:00 2001 From: guglielmoc Date: Fri, 5 Jun 2026 12:47:42 +0000 Subject: [PATCH 40/40] feat: add missing capability error code --- internal/jsonrpc2/wire.go | 2 +- mcp/shared.go | 3 +++ mcp/streamable.go | 2 +- 3 files changed, 5 insertions(+), 2 deletions(-) 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/shared.go b/mcp/shared.go index 48889d32..60d1b6fa 100644 --- a/mcp/shared.go +++ b/mcp/shared.go @@ -344,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 diff --git a/mcp/streamable.go b/mcp/streamable.go index b15cd4bc..158da843 100644 --- a/mcp/streamable.go +++ b/mcp/streamable.go @@ -959,7 +959,7 @@ func extractErrorStatus(ctx context.Context, msg jsonrpc.Message) int { switch jerr.Code { case jsonrpc.CodeMethodNotFound: return http.StatusNotFound - case jsonrpc.CodeInvalidParams, CodeUnsupportedProtocolVersion: + case jsonrpc.CodeInvalidParams, CodeUnsupportedProtocolVersion, CodeMissingRequiredClientCapabilities: return http.StatusBadRequest } return 0