From 2781b8f7e35bb7d064d44dd2193d7d77179b8630 Mon Sep 17 00:00:00 2001 From: Jan Steinke Date: Fri, 24 Jul 2026 09:21:59 +0200 Subject: [PATCH 01/70] mcp: derive Mcp-Protocol-Version header from the outgoing message (#1107) `streamableClientConn.setMCPHeaders` sets `Mcp-Protocol-Version` only from `c.initializedResult`, which is populated exclusively through the unexported `clientConnection.sessionUpdated` hook. `Client.Connect` reaches that hook via a type assertion on the `Connection` returned by the `Transport`: ```go if hc, ok := cs.mcpConn.(clientConnection); ok { hc.sessionUpdated(cs.state) } ``` That assertion silently fails whenever the `Connection` is wrapped by another type that exposes only the exported `Connection` interface. `c.initializedResult` then stays `nil` for the life of the session, and every `SEP-2575` (>= `2026-07-28`) request sent through that connection is missing the header. Servers that validate the header per spec reject the request with `400 Bad Request`, closing the connection. Every SEP-2575 request already carries its own `_meta.protocolVersion` field via `injectRequestMeta`. `setMCPHeaders` now reads the protocol version from the outgoing message first, falling back to `c.initializedResult` and then the request context. This is currently breaking the GitHub MCP with the 1.7-pre releases of this module. Fixes #1109 Co-authored-by: eu.anthropic.claude-sonnet-5 via Crush --- mcp/streamable.go | 34 +++++++++++++---- mcp/streamable_client_test.go | 70 +++++++++++++++++++++++++++++++++++ 2 files changed, 97 insertions(+), 7 deletions(-) diff --git a/mcp/streamable.go b/mcp/streamable.go index 37adfda..1f07ea6 100644 --- a/mcp/streamable.go +++ b/mcp/streamable.go @@ -2249,7 +2249,7 @@ func (c *streamableClientConn) Write(ctx context.Context, msg jsonrpc.Message) e req.Header.Set("Content-Type", "application/json") req.Header.Set("Accept", "application/json, text/event-stream") - if err := c.setMCPHeaders(req); err != nil { + if err := c.setMCPHeaders(req, msg); err != nil { // Failure to set headers means that the request was not sent. // Wrap with ErrRejected so the jsonrpc2 connection doesn't set writeErr // and permanently break the connection. @@ -2373,7 +2373,7 @@ func (c *streamableClientConn) Write(ctx context.Context, msg jsonrpc.Message) e return nil } -func (c *streamableClientConn) setMCPHeaders(req *http.Request) error { +func (c *streamableClientConn) setMCPHeaders(req *http.Request, msg jsonrpc.Message) error { c.mu.Lock() defer c.mu.Unlock() @@ -2403,10 +2403,13 @@ func (c *streamableClientConn) setMCPHeaders(req *http.Request) error { } } } - if c.initializedResult != nil { + switch { + case protocolVersionFromMessage(msg) != "": + req.Header.Set(protocolVersionHeader, protocolVersionFromMessage(msg)) + case c.initializedResult != nil: req.Header.Set(protocolVersionHeader, c.initializedResult.ProtocolVersion) - } else if v := protocolVersionFromContext(req.Context()); v != "" { - req.Header.Set(protocolVersionHeader, v) + case protocolVersionFromContext(req.Context()) != "": + req.Header.Set(protocolVersionHeader, protocolVersionFromContext(req.Context())) } if c.sessionID != "" { req.Header.Set(sessionIDHeader, c.sessionID) @@ -2415,6 +2418,23 @@ func (c *streamableClientConn) setMCPHeaders(req *http.Request) error { return nil } +// protocolVersionFromMessage recovers the SEP-2575 `_meta.protocolVersion` +// value from an outgoing JSON-RPC request, if present. It returns "" for +// notifications, responses, requests without a `_meta.protocolVersion`, or a +// nil msg. +func protocolVersionFromMessage(msg jsonrpc.Message) string { + req, ok := msg.(*jsonrpc.Request) + if !ok { + return "" + } + meta := extractRequestMeta(req.Params) + if meta == nil { + return "" + } + v, _ := meta[MetaKeyProtocolVersion].(string) + return v +} + func (c *streamableClientConn) handleJSON(requestSummary string, resp *http.Response) { body, err := io.ReadAll(resp.Body) resp.Body.Close() @@ -2676,7 +2696,7 @@ func (c *streamableClientConn) connectSSE(ctx context.Context, lastEventID strin if err != nil { return nil, err } - if err := c.setMCPHeaders(req); err != nil { + if err := c.setMCPHeaders(req, nil); err != nil { return nil, err } if lastEventID != "" { @@ -2712,7 +2732,7 @@ func (c *streamableClientConn) Close() error { if err != nil { c.closeErr = err } else { - if err := c.setMCPHeaders(req); err != nil { + if err := c.setMCPHeaders(req, nil); err != nil { c.closeErr = err } else if resp, err := c.client.Do(req); err != nil { c.closeErr = err diff --git a/mcp/streamable_client_test.go b/mcp/streamable_client_test.go index b3f68cb..a92f2e4 100644 --- a/mcp/streamable_client_test.go +++ b/mcp/streamable_client_test.go @@ -1364,6 +1364,76 @@ func TestStreamableClientConnect_DiscoverSuccess(t *testing.T) { } } +// TestStreamableClientConnSetMCPHeaders_ProtocolVersion covers +// streamableClientConn.setMCPHeaders' selection of the Mcp-Protocol-Version +// header value. +// +// Ordinarily initializedResult is populated by sessionUpdated, called +// through a type assertion to the unexported clientConnection interface +// (see Client.Connect). That assertion silently fails, leaving +// initializedResult nil for the life of the session, whenever the +// Connection returned by a Transport is wrapped by another type exposing +// only the exported Connection interface (a real pattern for transports +// that intercept traffic, e.g. to filter notifications): Go does not +// promote unexported interface methods across an embedded interface +// boundary. Every SEP-2575 (>= 2026-07-28) request already carries its own +// `_meta.protocolVersion` field, so setMCPHeaders falls back to reading it +// from the outgoing message when initializedResult is unset. +func TestStreamableClientConnSetMCPHeaders_ProtocolVersion(t *testing.T) { + tests := []struct { + name string + initializedResult *InitializeResult + msg jsonrpc.Message + want string + }{ + { + name: "message meta wins when initializedResult unset", + initializedResult: nil, + msg: req(1, methodListTools, &ListToolsParams{Meta: Meta{MetaKeyProtocolVersion: protocolVersion20260728}}), + want: protocolVersion20260728, + }, + { + name: "initializedResult used when message has no meta", + initializedResult: &InitializeResult{ProtocolVersion: protocolVersion20251125}, + msg: req(1, methodListTools, &ListToolsParams{}), + want: protocolVersion20251125, + }, + { + name: "initializedResult used for nil message (GET/DELETE)", + initializedResult: &InitializeResult{ProtocolVersion: protocolVersion20251125}, + msg: nil, + want: protocolVersion20251125, + }, + { + name: "message meta preferred over stale initializedResult", + initializedResult: &InitializeResult{ProtocolVersion: protocolVersion20251125}, + msg: req(1, methodListTools, &ListToolsParams{Meta: Meta{MetaKeyProtocolVersion: protocolVersion20260728}}), + want: protocolVersion20260728, + }, + { + name: "no header when neither source is set", + initializedResult: nil, + msg: req(1, methodListTools, &ListToolsParams{}), + want: "", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + conn := &streamableClientConn{initializedResult: tt.initializedResult} + httpReq, err := http.NewRequest(http.MethodPost, "http://test.invalid", nil) + if err != nil { + t.Fatal(err) + } + if err := conn.setMCPHeaders(httpReq, tt.msg); err != nil { + t.Fatalf("setMCPHeaders: %v", err) + } + if got := httpReq.Header.Get(protocolVersionHeader); got != tt.want { + t.Errorf("Mcp-Protocol-Version header = %q, want %q", got, tt.want) + } + }) + } +} + // 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. From e761950d9795d120ce9b00e5ba87b93c30a7be90 Mon Sep 17 00:00:00 2001 From: George McCarron Date: Fri, 24 Jul 2026 09:07:06 +0100 Subject: [PATCH 02/70] mcp: avoid double-copy of payload in writeEvent (#1106) `writeEvent` built the SSE frame with `fmt.Fprintf(&b, "data: %s\n\n", string(evt.Data))`, which copies the payload into a fresh string and then again into the buffer, growing it via repeated `bytes.growSlice` doublings. For large MCP responses (e.g. ~162KB `tools/list`) under a burst of concurrent writes this ~3x amplifies allocations and peak heap. Write the payload directly into a pre-grown buffer instead. Output framing is byte-identical. Benchmarks (162KB payload): 517KB->172KB per op, 9->4 allocs/op, ~3x faster. Co-authored-by: Claude Opus 4.8 (1M context) Co-authored-by: Yaroslav --- mcp/event.go | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/mcp/event.go b/mcp/event.go index 41ff4ae..bbb3e6c 100644 --- a/mcp/event.go +++ b/mcp/event.go @@ -52,7 +52,12 @@ func writeEvent(w http.ResponseWriter, evt Event) (int, error) { if evt.Retry != "" { fmt.Fprintf(&b, "retry: %s\n", evt.Retry) } - fmt.Fprintf(&b, "data: %s\n\n", string(evt.Data)) + // Write the payload directly into a pre-grown buffer to avoid the extra + // copy from string(evt.Data) and repeated buffer regrowths. + b.Grow(len("data: \n\n") + len(evt.Data)) + b.WriteString("data: ") + b.Write(evt.Data) + b.WriteString("\n\n") n, err := w.Write(b.Bytes()) rc := http.NewResponseController(w) // Ignore returned error as flushing is best-effort. From cc4db253471754d1d899ee6a54e92c406f19e11c Mon Sep 17 00:00:00 2001 From: Yaroslav Date: Mon, 27 Jul 2026 17:19:11 +0200 Subject: [PATCH 03/70] chore: update conformance (#1114) Update to the latest conformance tests version. --- .github/workflows/conformance.yml | 2 +- conformance/baseline.yml | 31 ++++++++----------------------- 2 files changed, 9 insertions(+), 24 deletions(-) diff --git a/.github/workflows/conformance.yml b/.github/workflows/conformance.yml index 77276d7..be34c09 100644 --- a/.github/workflows/conformance.yml +++ b/.github/workflows/conformance.yml @@ -14,7 +14,7 @@ permissions: contents: read env: - CONFORMANCE_VERSION: "0.2.0-alpha.9" + CONFORMANCE_VERSION: "0.2.0-alpha.10" jobs: server-conformance: diff --git a/conformance/baseline.yml b/conformance/baseline.yml index b516fc9..4867fe9 100644 --- a/conformance/baseline.yml +++ b/conformance/baseline.yml @@ -1,29 +1,14 @@ -server: - # The `server-stateless` scenario ships with the `@modelcontextprotocol/conformance@0.2.0-alpha.9` - # suite pinned by the workflow, which still enforces the pre-PR-3002 - # (https://github.com/modelcontextprotocol/modelcontextprotocol/pull/3002) spec: - # - `clientInfo` in the per-request `_meta` is required (MUST). - # - `serverInfo` is a body field on `DiscoverResult`. - # PR #3002 relaxes `clientInfo` to SHOULD and moves `serverInfo` into every - # response's `_meta`. The Go SDK follows the updated spec, so the following - # checks in the `server-stateless` scenario fail against the pinned suite: - # - sep-2575-request-meta-invalid-missing-client-info - # - sep-2575-http-server-meta-invalid-400 - # - sep-2575-server-implements-discover - # Remove this entry when the conformance suite is bumped to a release that - # incorporates PR #3002. - - server-stateless - client: # OAuth mechanisms not implemented by the Go MCP SDK. - auth/client-credentials-basic - auth/client-credentials-jwt - auth/enterprise-managed-authorization - # Upstream conformance mock inconsistency: the scenario's hand-rolled - # server/discover handler advertises "2026-07-28" but delegates every - # other method to a pinned @modelcontextprotocol/sdk that rejects that - # version at the HTTP layer. - # Remove this entry when the conformance suite is bumped to a release that - # fixes this issue. - - json-schema-ref-no-deref + # SEP-1932 (DPoP Sender-Constrained Tokens) client-side support not yet + # implemented by the Go MCP SDK. + - auth/dpop + - auth/dpop-nonce + + # SEP-1933 (Workload Identity Federation JWT-bearer) client-side support + # not yet implemented by the Go MCP SDK. + - auth/wif-jwt-bearer From bc72835f62eb94d0fb484439f886b6885b075f36 Mon Sep 17 00:00:00 2001 From: Yaroslav Date: Mon, 27 Jul 2026 17:20:53 +0200 Subject: [PATCH 04/70] mcp: fix potential nil-pointer dereference on message send (#1115) --- mcp/streamable.go | 13 ++++++------- mcp/streamable_client_test.go | 6 ++++++ 2 files changed, 12 insertions(+), 7 deletions(-) diff --git a/mcp/streamable.go b/mcp/streamable.go index 1f07ea6..f642db1 100644 --- a/mcp/streamable.go +++ b/mcp/streamable.go @@ -2403,13 +2403,12 @@ func (c *streamableClientConn) setMCPHeaders(req *http.Request, msg jsonrpc.Mess } } } - switch { - case protocolVersionFromMessage(msg) != "": - req.Header.Set(protocolVersionHeader, protocolVersionFromMessage(msg)) - case c.initializedResult != nil: + if pv := protocolVersionFromMessage(msg); pv != "" { + req.Header.Set(protocolVersionHeader, pv) + } else if pv := protocolVersionFromContext(req.Context()); pv != "" { + req.Header.Set(protocolVersionHeader, pv) + } else if c.initializedResult != nil { req.Header.Set(protocolVersionHeader, c.initializedResult.ProtocolVersion) - case protocolVersionFromContext(req.Context()) != "": - req.Header.Set(protocolVersionHeader, protocolVersionFromContext(req.Context())) } if c.sessionID != "" { req.Header.Set(sessionIDHeader, c.sessionID) @@ -2424,7 +2423,7 @@ func (c *streamableClientConn) setMCPHeaders(req *http.Request, msg jsonrpc.Mess // nil msg. func protocolVersionFromMessage(msg jsonrpc.Message) string { req, ok := msg.(*jsonrpc.Request) - if !ok { + if !ok || req == nil { return "" } meta := extractRequestMeta(req.Params) diff --git a/mcp/streamable_client_test.go b/mcp/streamable_client_test.go index a92f2e4..c8ab066 100644 --- a/mcp/streamable_client_test.go +++ b/mcp/streamable_client_test.go @@ -1386,6 +1386,12 @@ func TestStreamableClientConnSetMCPHeaders_ProtocolVersion(t *testing.T) { msg jsonrpc.Message want string }{ + { + name: "nil checked", + initializedResult: nil, + msg: (*jsonrpc.Request)(nil), + want: "", + }, { name: "message meta wins when initializedResult unset", initializedResult: nil, From 7743b71b1cd5d617d16d59c958a0656d5c29df74 Mon Sep 17 00:00:00 2001 From: n0liu Date: Thu, 30 Jul 2026 16:06:14 +0800 Subject: [PATCH 05/70] mcp: fix ListTools panic and null normalization on malformed tools (#1120) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes #1119. `ClientSession.ListTools` post-processes the server response through `filterValidTools`, which has two problems on malformed input: 1. **Nil-pointer panic on `"tools":[null]`.** A `nil` `*Tool` element reaches `validateParamHeaderAnnotations(tool)`, which dereferences `tool.InputSchema`, crashing the client on untrusted server input. 2. **`"tools":null` is normalized into a non-nil empty slice.** `make([]*Tool, 0, len(tools))` is always non-nil, so a nil `Tools` slice from a malformed response is silently laundered into valid-looking data. Reproduction: ```go filterValidTools(nil, []*Tool{nil}) // panics: nil pointer dereference filterValidTools(nil, nil) // returns non-nil slice (len 0), want nil ``` Fix (`mcp/streamable_headers.go`): - Skip (log and exclude) nil tool elements instead of dereferencing them, so a malformed `"tools":[null]` can no longer crash the client. This is consistent with the function’s existing contract of excluding invalid tools. - Return early with `nil` for a nil input slice so a `"tools":null` response is preserved as nil rather than normalized into an empty slice. Added `TestFilterValidToolsNilHandling` covering both defects plus the mixed valid/nil case. `go test ./mcp/` passes; `gofmt` and `go vet` are clean. Note on scope: this keeps the change minimal and within `filterValidTools`’s existing signature. If maintainers would rather surface a hard protocol error for a malformed `"tools":null` / `"tools":[null]` response at the `ListTools` level (rather than tolerating and filtering it), I’m happy to follow up with that larger change. --- mcp/streamable_headers.go | 6 ++++++ mcp/streamable_headers_test.go | 9 +++++++++ 2 files changed, 15 insertions(+) diff --git a/mcp/streamable_headers.go b/mcp/streamable_headers.go index 3989c01..7d06399 100644 --- a/mcp/streamable_headers.go +++ b/mcp/streamable_headers.go @@ -262,6 +262,12 @@ func filterValidTools(logger *slog.Logger, tools []*Tool) []*Tool { logger = ensureLogger(logger) result := make([]*Tool, 0, len(tools)) for _, tool := range tools { + // A nil tool (e.g. from a malformed "tools":[null] response) is + // invalid; exclude it instead of dereferencing it. + if tool == nil { + logger.Error("excluding nil tool from tools/list") + continue + } if err := validateParamHeaderAnnotations(tool); err != nil { logger.Error("excluding tool from tools/list", "tool", tool.Name, "error", err) continue diff --git a/mcp/streamable_headers_test.go b/mcp/streamable_headers_test.go index 52d5244..def7ad1 100644 --- a/mcp/streamable_headers_test.go +++ b/mcp/streamable_headers_test.go @@ -969,6 +969,15 @@ func TestFilterValidTools(t *testing.T) { t.Errorf("filterValidTools returned [%s, %s, %s, %s], want [valid, plain, nested-valid, valid-jsonschema]", result[0].Name, result[1].Name, result[2].Name, result[3].Name) } + + // Regression for #1119: a malformed "tools":[null] response must not panic; + // nil tools are treated as invalid and excluded. + if got := filterValidTools(nil, []*Tool{nil}); len(got) != 0 { + t.Errorf("filterValidTools([nil]) returned %d tools, want 0", len(got)) + } + if got := filterValidTools(nil, []*Tool{nil, valid, nil}); len(got) != 1 || got[0].Name != "valid" { + t.Errorf("filterValidTools([nil, valid, nil]) = %d tools, want [valid]", len(got)) + } } func TestSetStandardHeadersWithParamHeaders(t *testing.T) { From 0c004ee48a11d6752eed40b1b7a5cdee58a55acd Mon Sep 17 00:00:00 2001 From: Thomas Bechtold Date: Fri, 31 Jul 2026 10:35:49 +0200 Subject: [PATCH 06/70] auth: add ScopeFilter hook to AuthorizationCodeHandlerConfig (#1044) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary The authorization-code handler requests every scope advertised in the protected resource's metadata `scopes_supported` (or named in the `WWW-Authenticate` challenge), and offers the caller no way to adjust that set. Some servers advertise a scope a client should *not* request: - Google's Gmail MCP server advertises `gmail.metadata` in `scopes_supported`. - The Gmail API refuses the search `q` parameter on any token that carries `gmail.metadata` — **even when `gmail.readonly` is also granted** (a long-standing documented Gmail restriction, see googleapis/google-api-python-client#582). Because the handler requests the full advertised set, `gmail.metadata` ends up in the grant and breaks search, with no client-side remedy. ## Change Add an optional `ScopeFilter func(discovered []string) []string` callback to `AuthorizationCodeHandlerConfig`. When non-nil, it receives the discovered scopes and returns the set to request, giving the client full control to narrow, extend, or reorder them. It runs **before** the `offline_access` (SEP-2207) and step-up union (SEP-2350) logic, so refresh-token support and scope accumulation are unaffected. (Started as a static `Scopes []string` allowlist; switched to a callback per review feedback for more flexibility.) ## Test `TestAuthorize_ScopeFilter` drives a full authorize flow and asserts the requested `scope` parameter for a nil filter (unchanged), a filter that drops a scope, and one that replaces the set entirely. `go test ./auth/...` (incl. `-race`), `go vet`, and `gofmt` are clean. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 4.8 (1M context) Co-authored-by: Guglielmo Colombo --- auth/authorization_code.go | 15 +++++ auth/authorization_code_test.go | 101 ++++++++++++++++++++++++++++++++ 2 files changed, 116 insertions(+) diff --git a/auth/authorization_code.go b/auth/authorization_code.go index d897d84..1673a2e 100644 --- a/auth/authorization_code.go +++ b/auth/authorization_code.go @@ -98,6 +98,15 @@ type AuthorizationCodeHandlerConfig struct { // See [AuthorizationCodeFetcher] for details. AuthorizationCodeFetcher AuthorizationCodeFetcher + // ScopeFilter, if non-nil, is called with the scopes discovered from the + // protected resource metadata (or WWW-Authenticate challenge) and returns + // the scopes to request during authorization. It gives the client full + // control to narrow, extend, or reorder that set — e.g. dropping Gmail's + // gmail.metadata, which the Gmail API refuses to combine with the search "q" + // parameter even alongside gmail.readonly. It runs before offline_access + // (see RequestRefreshToken) and the step-up union, so neither is affected. + ScopeFilter func(discovered []string) []string + // RequestRefreshToken indicates that the client intends to use refresh // tokens and is capable of storing them securely. // @@ -318,6 +327,12 @@ func (h *AuthorizationCodeHandler) Authorize(ctx context.Context, req *http.Requ requestedScopes = prm.ScopesSupported } + // Let the client adjust the discovered scopes before offline_access and the + // step-up union below so neither is affected. + if h.config.ScopeFilter != nil { + requestedScopes = h.config.ScopeFilter(requestedScopes) + } + // SEP-2207: when the client desires refresh tokens and the Authorization // Server advertises offline_access support, add it to the requested scopes. if h.config.RequestRefreshToken && diff --git a/auth/authorization_code_test.go b/auth/authorization_code_test.go index 9833597..c2f963f 100644 --- a/auth/authorization_code_test.go +++ b/auth/authorization_code_test.go @@ -1222,6 +1222,107 @@ func TestAuthorize_OfflineAccessScope(t *testing.T) { } } +func TestAuthorize_ScopeFilter(t *testing.T) { + // advertised is delivered via the WWW-Authenticate "scope" challenge, which + // the handler treats as the discovered scopes passed to ScopeFilter. + const advertised = "gmail.metadata gmail.readonly gmail.compose" + tests := []struct { + name string + filter func(discovered []string) []string + want []string // requested scopes, order-independent + }{ + { + name: "nil filter leaves scopes unchanged", + filter: nil, + want: []string{"gmail.metadata", "gmail.readonly", "gmail.compose"}, + }, + { + name: "filter drops a scope", + filter: func(d []string) []string { + return slices.DeleteFunc(slices.Clone(d), func(s string) bool { return s == "gmail.metadata" }) + }, + want: []string{"gmail.readonly", "gmail.compose"}, + }, + { + name: "filter replaces the set entirely", + filter: func([]string) []string { return []string{"gmail.readonly"} }, + want: []string{"gmail.readonly"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + authServer := oauthtest.NewFakeAuthorizationServer(oauthtest.Config{ + RegistrationConfig: &oauthtest.RegistrationConfig{ + PreregisteredClients: map[string]oauthtest.ClientInfo{ + "test_client_id": { + Secret: "test_client_secret", + RedirectURIs: []string{"http://localhost:12345/callback"}, + }, + }, + }, + }) + authServer.Start(t) + + resourceMux := http.NewServeMux() + resourceServer := httptest.NewServer(resourceMux) + t.Cleanup(resourceServer.Close) + resourceURL := resourceServer.URL + "/resource" + resourceMux.Handle("/.well-known/oauth-protected-resource/resource", ProtectedResourceMetadataHandler(&oauthex.ProtectedResourceMetadata{ + Resource: resourceURL, + AuthorizationServers: []string{authServer.URL()}, + })) + + var capturedAuthURL string + handler, err := NewAuthorizationCodeHandler(&AuthorizationCodeHandlerConfig{ + RedirectURL: "http://localhost:12345/callback", + PreregisteredClient: &oauthex.ClientCredentials{ + ClientID: "test_client_id", + ClientSecretAuth: &oauthex.ClientSecretAuth{ClientSecret: "test_client_secret"}, + }, + ScopeFilter: tt.filter, + AuthorizationCodeFetcher: func(ctx context.Context, args *AuthorizationArgs) (*AuthorizationResult, error) { + capturedAuthURL = args.URL + return nil, fmt.Errorf("stop after capturing URL") + }, + }) + if err != nil { + t.Fatalf("NewAuthorizationCodeHandler failed: %v", err) + } + + req := httptest.NewRequest(http.MethodGet, resourceURL, nil) + resp := &http.Response{ + StatusCode: http.StatusUnauthorized, + Header: make(http.Header), + Body: http.NoBody, + Request: req, + } + resp.Header.Set("WWW-Authenticate", fmt.Sprintf( + "Bearer resource_metadata=%s/.well-known/oauth-protected-resource/resource, scope=%q", + resourceServer.URL, advertised)) + + handler.Authorize(context.Background(), req, resp) + + if capturedAuthURL == "" { + t.Fatal("AuthorizationCodeFetcher was not called") + } + u, err := url.Parse(capturedAuthURL) + if err != nil { + t.Fatalf("failed to parse captured auth URL: %v", err) + } + // Compare as a set: UnionScopes (applied downstream) returns map keys, + // so the order of the requested scope parameter is not deterministic. + got := strings.Fields(u.Query().Get("scope")) + want := slices.Clone(tt.want) + slices.Sort(got) + slices.Sort(want) + if !slices.Equal(got, want) { + t.Errorf("requested scopes = %v, want %v (any order)", got, want) + } + }) + } +} + // validConfig for test to create an AuthorizationCodeHandler using its constructor. // Values that are relevant to the test should be set explicitly. func validConfig() *AuthorizationCodeHandlerConfig { From d32302ee7a6cf22550d44ee3564a1d02159c7126 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 3 Aug 2026 09:26:57 +0200 Subject: [PATCH 07/70] build(deps): bump the codeql-action group with 3 updates (#1122) Bumps the codeql-action group with 3 updates: [github/codeql-action/init](https://github.com/github/codeql-action), [github/codeql-action/analyze](https://github.com/github/codeql-action) and [github/codeql-action/upload-sarif](https://github.com/github/codeql-action). Updates `github/codeql-action/init` from 4.37.0 to 4.37.3
Release notes

Sourced from github/codeql-action/init's releases.

v4.37.3

No user facing changes.

v4.37.2

  • The new address format for the config-file input that was introduced in CodeQL Action 4.37.0 is now enabled by default. In addition to the format described there, the remote= prefix can now be used to explicitly indicate that the input refers to a remote file. All previous input formats continue to be accepted as well. #4023
  • The CodeQL Action can now make use of configured private registries in Default Setup to retrieve CodeQL configuration files from remote repositories that require authentication. This will allow customers to store their CodeQL configuration in a single repository that can then be referenced by Default Setup workflows in other repositories. We expect to roll this and other, related changes out to everyone in July. #4007

v4.37.1

  • Upcoming breaking change: Add a deprecation warning for customers using CodeQL version 2.20.6 and earlier. These versions of CodeQL were discontinued on 1 July 2026 alongside GitHub Enterprise Server 3.16, and will be unsupported by the next minor release of the CodeQL Action. #3956
  • Update default CodeQL bundle version to 2.26.1. #4019
Changelog

Sourced from github/codeql-action/init's changelog.

CodeQL Action Changelog

See the releases page for the relevant changes to the CodeQL CLI and language packs.

[UNRELEASED]

  • Fixed a bug where a network error while streaming the download of the CodeQL bundle could terminate the init Action instead of falling back to downloading the bundle before extracting it. #4061

4.37.4 - 29 Jul 2026

  • This version of the CodeQL Action adds support for the tools input for the codeql-action/init step to be specified using a github-codeql-tools repository property. This feature will gradually be rolled out following the release of this version. Once rolled out, this allows for the CodeQL CLI version that is used in GitHub-managed workflows, such as Default Setup, to be set to a custom value. For example, customers who run into issues with rate limits when a new CodeQL CLI version is released can set the value to toolcache to always use the CodeQL CLI version that is available in the runner toolcache. For Advanced Setup workflows, the value provided for tools in the workflow definition always takes precedence unless the value of the repository property starts with !. #4037
  • Update default CodeQL bundle version to 2.26.2. #4051

4.37.3 - 22 Jul 2026

No user facing changes.

4.37.2 - 21 Jul 2026

  • The new address format for the config-file input that was introduced in CodeQL Action 4.37.0 is now enabled by default. In addition to the format described there, the remote= prefix can now be used to explicitly indicate that the input refers to a remote file. All previous input formats continue to be accepted as well. #4023
  • The CodeQL Action can now make use of configured private registries in Default Setup to retrieve CodeQL configuration files from remote repositories that require authentication. This will allow customers to store their CodeQL configuration in a single repository that can then be referenced by Default Setup workflows in other repositories. We expect to roll this and other, related changes out to everyone in July. #4007

4.37.1 - 16 Jul 2026

  • Upcoming breaking change: Add a deprecation warning for customers using CodeQL version 2.20.6 and earlier. These versions of CodeQL were discontinued on 1 July 2026 alongside GitHub Enterprise Server 3.16, and will be unsupported by the next minor release of the CodeQL Action. #3956
  • Update default CodeQL bundle version to 2.26.1. #4019

4.37.0 - 08 Jul 2026

  • Update default CodeQL bundle version to 2.26.0. #3995
  • In addition to the existing input format, the config-file input for the codeql-action/init step will soon support a new [owner/]repo[@ref][:path] format. All components except the repository name are optional. If omitted, owner defaults to the same owner as the repository the analysis is running for, ref to main, and path to .github/codeql-action.yaml. Support for this format ships in this version of the CodeQL Action, but will only be enabled over the coming weeks. #3973

4.36.3 - 01 Jul 2026

No user facing changes.

4.36.2 - 04 Jun 2026

  • Cache CodeQL CLI version information across Actions steps. #3943
  • Reduce requests while waiting for analysis processing by using exponential backoff when polling SARIF processing status. #3937
  • Update default CodeQL bundle version to 2.25.6. #3948

4.36.1 - 02 Jun 2026

No user facing changes.

4.36.0 - 22 May 2026

  • Breaking change: Bump the minimum required CodeQL bundle version to 2.19.4. #3894
  • Add support for SHA-256 Git object IDs. #3893

... (truncated)

Commits
  • e4fba86 Merge pull request #4031 from github/update-v4.37.3-72f6a9da0
  • fb50ab5 Update changelog for v4.37.3
  • 72f6a9d Merge pull request #4030 from github/mbg/fix/no-proxy
  • 3b5ee58 Use default request options instead of undefined
  • bfb6be4 Merge pull request #4028 from github/mergeback/v4.37.2-to-main-e0647621
  • 526ab84 Rebuild
  • d6217b9 Update changelog and version after v4.37.2
  • e064762 Merge pull request #4027 from github/update-v4.37.2-385bcdc5a
  • e0faed8 Add a couple of change notes
  • 73aad0e Update changelog for v4.37.2
  • Additional commits viewable in compare view

Updates `github/codeql-action/analyze` from 4.37.0 to 4.37.3
Release notes

Sourced from github/codeql-action/analyze's releases.

v4.37.3

No user facing changes.

v4.37.2

  • The new address format for the config-file input that was introduced in CodeQL Action 4.37.0 is now enabled by default. In addition to the format described there, the remote= prefix can now be used to explicitly indicate that the input refers to a remote file. All previous input formats continue to be accepted as well. #4023
  • The CodeQL Action can now make use of configured private registries in Default Setup to retrieve CodeQL configuration files from remote repositories that require authentication. This will allow customers to store their CodeQL configuration in a single repository that can then be referenced by Default Setup workflows in other repositories. We expect to roll this and other, related changes out to everyone in July. #4007

v4.37.1

  • Upcoming breaking change: Add a deprecation warning for customers using CodeQL version 2.20.6 and earlier. These versions of CodeQL were discontinued on 1 July 2026 alongside GitHub Enterprise Server 3.16, and will be unsupported by the next minor release of the CodeQL Action. #3956
  • Update default CodeQL bundle version to 2.26.1. #4019
Changelog

Sourced from github/codeql-action/analyze's changelog.

CodeQL Action Changelog

See the releases page for the relevant changes to the CodeQL CLI and language packs.

[UNRELEASED]

  • Fixed a bug where a network error while streaming the download of the CodeQL bundle could terminate the init Action instead of falling back to downloading the bundle before extracting it. #4061

4.37.4 - 29 Jul 2026

  • This version of the CodeQL Action adds support for the tools input for the codeql-action/init step to be specified using a github-codeql-tools repository property. This feature will gradually be rolled out following the release of this version. Once rolled out, this allows for the CodeQL CLI version that is used in GitHub-managed workflows, such as Default Setup, to be set to a custom value. For example, customers who run into issues with rate limits when a new CodeQL CLI version is released can set the value to toolcache to always use the CodeQL CLI version that is available in the runner toolcache. For Advanced Setup workflows, the value provided for tools in the workflow definition always takes precedence unless the value of the repository property starts with !. #4037
  • Update default CodeQL bundle version to 2.26.2. #4051

4.37.3 - 22 Jul 2026

No user facing changes.

4.37.2 - 21 Jul 2026

  • The new address format for the config-file input that was introduced in CodeQL Action 4.37.0 is now enabled by default. In addition to the format described there, the remote= prefix can now be used to explicitly indicate that the input refers to a remote file. All previous input formats continue to be accepted as well. #4023
  • The CodeQL Action can now make use of configured private registries in Default Setup to retrieve CodeQL configuration files from remote repositories that require authentication. This will allow customers to store their CodeQL configuration in a single repository that can then be referenced by Default Setup workflows in other repositories. We expect to roll this and other, related changes out to everyone in July. #4007

4.37.1 - 16 Jul 2026

  • Upcoming breaking change: Add a deprecation warning for customers using CodeQL version 2.20.6 and earlier. These versions of CodeQL were discontinued on 1 July 2026 alongside GitHub Enterprise Server 3.16, and will be unsupported by the next minor release of the CodeQL Action. #3956
  • Update default CodeQL bundle version to 2.26.1. #4019

4.37.0 - 08 Jul 2026

  • Update default CodeQL bundle version to 2.26.0. #3995
  • In addition to the existing input format, the config-file input for the codeql-action/init step will soon support a new [owner/]repo[@ref][:path] format. All components except the repository name are optional. If omitted, owner defaults to the same owner as the repository the analysis is running for, ref to main, and path to .github/codeql-action.yaml. Support for this format ships in this version of the CodeQL Action, but will only be enabled over the coming weeks. #3973

4.36.3 - 01 Jul 2026

No user facing changes.

4.36.2 - 04 Jun 2026

  • Cache CodeQL CLI version information across Actions steps. #3943
  • Reduce requests while waiting for analysis processing by using exponential backoff when polling SARIF processing status. #3937
  • Update default CodeQL bundle version to 2.25.6. #3948

4.36.1 - 02 Jun 2026

No user facing changes.

4.36.0 - 22 May 2026

  • Breaking change: Bump the minimum required CodeQL bundle version to 2.19.4. #3894
  • Add support for SHA-256 Git object IDs. #3893

... (truncated)

Commits
  • e4fba86 Merge pull request #4031 from github/update-v4.37.3-72f6a9da0
  • fb50ab5 Update changelog for v4.37.3
  • 72f6a9d Merge pull request #4030 from github/mbg/fix/no-proxy
  • 3b5ee58 Use default request options instead of undefined
  • bfb6be4 Merge pull request #4028 from github/mergeback/v4.37.2-to-main-e0647621
  • 526ab84 Rebuild
  • d6217b9 Update changelog and version after v4.37.2
  • e064762 Merge pull request #4027 from github/update-v4.37.2-385bcdc5a
  • e0faed8 Add a couple of change notes
  • 73aad0e Update changelog for v4.37.2
  • Additional commits viewable in compare view

Updates `github/codeql-action/upload-sarif` from 4.37.0 to 4.37.3
Release notes

Sourced from github/codeql-action/upload-sarif's releases.

v4.37.3

No user facing changes.

v4.37.2

  • The new address format for the config-file input that was introduced in CodeQL Action 4.37.0 is now enabled by default. In addition to the format described there, the remote= prefix can now be used to explicitly indicate that the input refers to a remote file. All previous input formats continue to be accepted as well. #4023
  • The CodeQL Action can now make use of configured private registries in Default Setup to retrieve CodeQL configuration files from remote repositories that require authentication. This will allow customers to store their CodeQL configuration in a single repository that can then be referenced by Default Setup workflows in other repositories. We expect to roll this and other, related changes out to everyone in July. #4007

v4.37.1

  • Upcoming breaking change: Add a deprecation warning for customers using CodeQL version 2.20.6 and earlier. These versions of CodeQL were discontinued on 1 July 2026 alongside GitHub Enterprise Server 3.16, and will be unsupported by the next minor release of the CodeQL Action. #3956
  • Update default CodeQL bundle version to 2.26.1. #4019
Changelog

Sourced from github/codeql-action/upload-sarif's changelog.

CodeQL Action Changelog

See the releases page for the relevant changes to the CodeQL CLI and language packs.

[UNRELEASED]

  • Fixed a bug where a network error while streaming the download of the CodeQL bundle could terminate the init Action instead of falling back to downloading the bundle before extracting it. #4061

4.37.4 - 29 Jul 2026

  • This version of the CodeQL Action adds support for the tools input for the codeql-action/init step to be specified using a github-codeql-tools repository property. This feature will gradually be rolled out following the release of this version. Once rolled out, this allows for the CodeQL CLI version that is used in GitHub-managed workflows, such as Default Setup, to be set to a custom value. For example, customers who run into issues with rate limits when a new CodeQL CLI version is released can set the value to toolcache to always use the CodeQL CLI version that is available in the runner toolcache. For Advanced Setup workflows, the value provided for tools in the workflow definition always takes precedence unless the value of the repository property starts with !. #4037
  • Update default CodeQL bundle version to 2.26.2. #4051

4.37.3 - 22 Jul 2026

No user facing changes.

4.37.2 - 21 Jul 2026

  • The new address format for the config-file input that was introduced in CodeQL Action 4.37.0 is now enabled by default. In addition to the format described there, the remote= prefix can now be used to explicitly indicate that the input refers to a remote file. All previous input formats continue to be accepted as well. #4023
  • The CodeQL Action can now make use of configured private registries in Default Setup to retrieve CodeQL configuration files from remote repositories that require authentication. This will allow customers to store their CodeQL configuration in a single repository that can then be referenced by Default Setup workflows in other repositories. We expect to roll this and other, related changes out to everyone in July. #4007

4.37.1 - 16 Jul 2026

  • Upcoming breaking change: Add a deprecation warning for customers using CodeQL version 2.20.6 and earlier. These versions of CodeQL were discontinued on 1 July 2026 alongside GitHub Enterprise Server 3.16, and will be unsupported by the next minor release of the CodeQL Action. #3956
  • Update default CodeQL bundle version to 2.26.1. #4019

4.37.0 - 08 Jul 2026

  • Update default CodeQL bundle version to 2.26.0. #3995
  • In addition to the existing input format, the config-file input for the codeql-action/init step will soon support a new [owner/]repo[@ref][:path] format. All components except the repository name are optional. If omitted, owner defaults to the same owner as the repository the analysis is running for, ref to main, and path to .github/codeql-action.yaml. Support for this format ships in this version of the CodeQL Action, but will only be enabled over the coming weeks. #3973

4.36.3 - 01 Jul 2026

No user facing changes.

4.36.2 - 04 Jun 2026

  • Cache CodeQL CLI version information across Actions steps. #3943
  • Reduce requests while waiting for analysis processing by using exponential backoff when polling SARIF processing status. #3937
  • Update default CodeQL bundle version to 2.25.6. #3948

4.36.1 - 02 Jun 2026

No user facing changes.

4.36.0 - 22 May 2026

  • Breaking change: Bump the minimum required CodeQL bundle version to 2.19.4. #3894
  • Add support for SHA-256 Git object IDs. #3893

... (truncated)

Commits
  • e4fba86 Merge pull request #4031 from github/update-v4.37.3-72f6a9da0
  • fb50ab5 Update changelog for v4.37.3
  • 72f6a9d Merge pull request #4030 from github/mbg/fix/no-proxy
  • 3b5ee58 Use default request options instead of undefined
  • bfb6be4 Merge pull request #4028 from github/mergeback/v4.37.2-to-main-e0647621
  • 526ab84 Rebuild
  • d6217b9 Update changelog and version after v4.37.2
  • e064762 Merge pull request #4027 from github/update-v4.37.2-385bcdc5a
  • e0faed8 Add a couple of change notes
  • 73aad0e Update changelog for v4.37.2
  • Additional commits viewable in compare view

Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore major version` will close this group update PR and stop Dependabot creating any more for the specific dependency's major version (unless you unignore this specific dependency's major version or upgrade to it yourself) - `@dependabot ignore minor version` will close this group update PR and stop Dependabot creating any more for the specific dependency's minor version (unless you unignore this specific dependency's minor version or upgrade to it yourself) - `@dependabot ignore ` will close this group update PR and stop Dependabot creating any more for the specific dependency (unless you unignore this specific dependency or upgrade to it yourself) - `@dependabot unignore ` will remove all of the ignore conditions of the specified dependency - `@dependabot unignore ` will remove the ignore condition of the specified dependency and ignore conditions
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/codeql.yml | 4 ++-- .github/workflows/scorecard.yml | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index d65e6ab..f1d3228 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -38,11 +38,11 @@ jobs: - name: Checkout repository uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Initialize CodeQL - uses: github/codeql-action/init@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4.37.0 + uses: github/codeql-action/init@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v4.37.3 with: languages: ${{ matrix.language }} build-mode: ${{ matrix.build-mode }} - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4.37.0 + uses: github/codeql-action/analyze@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v4.37.3 with: category: "/language:${{matrix.language}}" diff --git a/.github/workflows/scorecard.yml b/.github/workflows/scorecard.yml index 4eca0b9..c400e43 100644 --- a/.github/workflows/scorecard.yml +++ b/.github/workflows/scorecard.yml @@ -73,6 +73,6 @@ jobs: # Upload the results to GitHub's code scanning dashboard (optional). # Commenting out will disable upload of results to your repo's Code Scanning dashboard - name: "Upload to code-scanning" - uses: github/codeql-action/upload-sarif@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4.37.0 + uses: github/codeql-action/upload-sarif@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v4.37.3 with: sarif_file: results.sarif From 5d1040061b1381a5dafd98f6926e6d8ee77166ca Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 3 Aug 2026 07:28:48 +0000 Subject: [PATCH 08/70] build(deps): bump actions/setup-node from 6.0.0 to 7.0.0 (#1123) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps [actions/setup-node](https://github.com/actions/setup-node) from 6.0.0 to 7.0.0.
Release notes

Sourced from actions/setup-node's releases.

v7.0.0

What's Changed

Enhancements:

Bug fixes:

Documentation updates:

Dependency update:

New Contributors

Full Changelog: https://github.com/actions/setup-node/compare/v6...v7.0.0

v6.5.0

What's Changed

Full Changelog: https://github.com/actions/setup-node/compare/v6.4.0...v6.5.0

v6.4.0

What's Changed

Dependency updates:

New Contributors

Full Changelog: https://github.com/actions/setup-node/compare/v6...v6.4.0

v6.3.0

What's Changed

Enhancements:

... (truncated)

Commits
  • 8207627 Migrate to ESM and upgrade dependencies (#1574)
  • 04be95c Add cache-primary-key and cache-matched-key as outputs (#1577)
  • 7c2c68d docs: Update caching recommendations to mitigate cache poisoning risks (#1567)
  • 6a61c03 Merge pull request #1569 from jasongin/update-actions-cache-5.1.0
  • 30eb73b Resolve high-severity audit issues
  • 4e1a87a Update dist
  • 360237f Strict equality
  • 4f8aac5 Bump @​actions/cache to 5.1.0, log cache write denied
  • f4a67bb Only use mirrorToken in getManifest if it's provided (#1548)
  • 0355742 Remove dummy NODE_AUTH_TOKEN export (#1558)
  • Additional commits viewable in compare view

[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=actions/setup-node&package-manager=github_actions&previous-version=6.0.0&new-version=7.0.0)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself)
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Guglielmo Colombo --- .github/workflows/conformance.yml | 4 ++-- .github/workflows/nightly.yml | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/conformance.yml b/.github/workflows/conformance.yml index be34c09..9008cb4 100644 --- a/.github/workflows/conformance.yml +++ b/.github/workflows/conformance.yml @@ -27,7 +27,7 @@ jobs: with: go-version: "^1.26" - name: Set up Node.js - uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0 + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: 22 - name: Build everything-server @@ -65,7 +65,7 @@ jobs: with: go-version: "^1.26" - name: Set up Node.js - uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0 + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: 22 - name: Run conformance tests (full suite) diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index 21d0420..7bb4c5c 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -29,7 +29,7 @@ jobs: with: go-version: "^1.26" - name: Set up Node.js - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: "22" - name: Run server conformance tests From 7b3bbc0f3264b8296fa07de88b297cf3a93ccf9a Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 3 Aug 2026 07:34:48 +0000 Subject: [PATCH 09/70] build(deps): bump actions/setup-python from 6.3.0 to 7.0.0 (#1124) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps [actions/setup-python](https://github.com/actions/setup-python) from 6.3.0 to 7.0.0.
Release notes

Sourced from actions/setup-python's releases.

v7.0.0

What's Changed

Enhancements

Bug Fix

Dependency Upgrade

New Contributors

Full Changelog: https://github.com/actions/setup-python/compare/v6...v7.0.0

Commits

Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/publish-docs.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/publish-docs.yml b/.github/workflows/publish-docs.yml index e64faa2..b4a6b61 100644 --- a/.github/workflows/publish-docs.yml +++ b/.github/workflows/publish-docs.yml @@ -18,7 +18,7 @@ jobs: run: | git config user.name github-actions[bot] git config user.email 41898282+github-actions[bot]@users.noreply.github.com - - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: 3.x - run: echo "cache_id=$(date --utc '+%V')" >> $GITHUB_ENV From 02610a2075d4ab37a07aa738668f466d8eb43fac Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 3 Aug 2026 09:37:58 +0200 Subject: [PATCH 10/70] build(deps): bump actions/checkout from 7.0.0 to 7.0.1 (#1126) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps [actions/checkout](https://github.com/actions/checkout) from 7.0.0 to 7.0.1.
Release notes

Sourced from actions/checkout's releases.

v7.0.1

What's Changed

Full Changelog: https://github.com/actions/checkout/compare/v7...v7.0.1

Changelog

Sourced from actions/checkout's changelog.

Changelog

v7.0.1

v7.0.0

v6.0.3

v6.0.2

v6.0.1

v6.0.0

v5.0.1

v5.0.0

v4.3.1

v4.3.0

v4.2.2

v4.2.1

... (truncated)

Commits

Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Guglielmo Colombo --- .github/workflows/codeql.yml | 2 +- .github/workflows/conformance.yml | 4 ++-- .github/workflows/docs-check.yml | 2 +- .github/workflows/nightly.yml | 2 +- .github/workflows/publish-docs.yml | 2 +- .github/workflows/scorecard.yml | 2 +- .github/workflows/test.yml | 6 +++--- 7 files changed, 10 insertions(+), 10 deletions(-) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index f1d3228..0f63536 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -36,7 +36,7 @@ jobs: build-mode: autobuild steps: - name: Checkout repository - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Initialize CodeQL uses: github/codeql-action/init@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v4.37.3 with: diff --git a/.github/workflows/conformance.yml b/.github/workflows/conformance.yml index 9008cb4..34c9dc3 100644 --- a/.github/workflows/conformance.yml +++ b/.github/workflows/conformance.yml @@ -21,7 +21,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Check out code - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Set up Go uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0 with: @@ -59,7 +59,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Check out code - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Set up Go uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0 with: diff --git a/.github/workflows/docs-check.yml b/.github/workflows/docs-check.yml index 6e6972e..0b73482 100644 --- a/.github/workflows/docs-check.yml +++ b/.github/workflows/docs-check.yml @@ -20,7 +20,7 @@ jobs: with: go-version: "^1.26" - name: Check out code - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Check docs are up-to-date run: | go generate ./... diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index 7bb4c5c..d1c995c 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -23,7 +23,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Check out code - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Set up Go uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0 with: diff --git a/.github/workflows/publish-docs.yml b/.github/workflows/publish-docs.yml index b4a6b61..4d6d31e 100644 --- a/.github/workflows/publish-docs.yml +++ b/.github/workflows/publish-docs.yml @@ -13,7 +13,7 @@ jobs: contents: write runs-on: ubuntu-latest steps: - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Configure Git Credentials run: | git config user.name github-actions[bot] diff --git a/.github/workflows/scorecard.yml b/.github/workflows/scorecard.yml index c400e43..a29d098 100644 --- a/.github/workflows/scorecard.yml +++ b/.github/workflows/scorecard.yml @@ -34,7 +34,7 @@ jobs: steps: - name: "Checkout code" - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 32ef66a..da46a60 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -14,7 +14,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Check out code - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Set up Go uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0 with: @@ -43,7 +43,7 @@ jobs: go: ["1.25", "1.26"] steps: - name: Check out code - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Set up Go uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0 with: @@ -55,7 +55,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Check out code - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Set up Go uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0 with: From ec29d60bb933f0e9b79b80b6a40920f5a51a4870 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 3 Aug 2026 09:43:31 +0200 Subject: [PATCH 11/70] build(deps): bump actions/setup-go from 6.5.0 to 7.0.0 (#1125) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps [actions/setup-go](https://github.com/actions/setup-go) from 6.5.0 to 7.0.0.
Release notes

Sourced from actions/setup-go's releases.

v7.0.0

What's Changed

New Contributors

Full Changelog: https://github.com/actions/setup-go/compare/v6...v7.0.0

Commits

Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Guglielmo Colombo --- .github/workflows/conformance.yml | 4 ++-- .github/workflows/docs-check.yml | 2 +- .github/workflows/nightly.yml | 2 +- .github/workflows/test.yml | 6 +++--- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/.github/workflows/conformance.yml b/.github/workflows/conformance.yml index 34c9dc3..7d6cca4 100644 --- a/.github/workflows/conformance.yml +++ b/.github/workflows/conformance.yml @@ -23,7 +23,7 @@ jobs: - name: Check out code uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Set up Go - uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0 + uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 with: go-version: "^1.26" - name: Set up Node.js @@ -61,7 +61,7 @@ jobs: - name: Check out code uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Set up Go - uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0 + uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 with: go-version: "^1.26" - name: Set up Node.js diff --git a/.github/workflows/docs-check.yml b/.github/workflows/docs-check.yml index 0b73482..d255e63 100644 --- a/.github/workflows/docs-check.yml +++ b/.github/workflows/docs-check.yml @@ -16,7 +16,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Set up Go - uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0 + uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 with: go-version: "^1.26" - name: Check out code diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index d1c995c..226627f 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -25,7 +25,7 @@ jobs: - name: Check out code uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Set up Go - uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0 + uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 with: go-version: "^1.26" - name: Set up Node.js diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index da46a60..5c351b3 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -16,7 +16,7 @@ jobs: - name: Check out code uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Set up Go - uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0 + uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 with: go-version: "^1.26" - name: Check formatting @@ -45,7 +45,7 @@ jobs: - name: Check out code uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Set up Go - uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0 + uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 with: go-version: ${{ matrix.go }} - name: Test @@ -57,7 +57,7 @@ jobs: - name: Check out code uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Set up Go - uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0 + uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 with: go-version: "1.26" - name: Test with -race From 0d0cdbc943f396aa973023a7dd6440398d3df87e Mon Sep 17 00:00:00 2001 From: Guglielmo Colombo Date: Mon, 3 Aug 2026 13:54:24 +0200 Subject: [PATCH 12/70] mcp: prevent leaks of ServerSession on StatefulServer (#1137) Fixes #1136 --- mcp/server.go | 14 +++++++-- mcp/streamable_test.go | 64 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 75 insertions(+), 3 deletions(-) diff --git a/mcp/server.go b/mcp/server.go index b580548..c189a8e 100644 --- a/mcp/server.go +++ b/mcp/server.go @@ -897,9 +897,17 @@ func (s *Server) discover(_ context.Context, req *ServerRequest[*DiscoverParams] Capabilities: req.ClientCapabilities(), ClientInfo: req.ClientInfo(), } - req.Session.updateState(func(state *ServerSessionState) { - state.InitializeParams = init - }) + // Only persist InitializeParams when the transport can actually serve + // the new protocol. On transports that cannot (notably stateful + // StreamableHTTPHandler), a discover request creates a session that + // is never surfaced to the client via Mcp-Session-Id; leaving + // InitializeParams nil lets serveStatefulPOST's safety-net cleanup + // close it instead of leaking. + if supportedVersion := negotiateMutuallySupportedVersion(versions); supportedVersion >= protocolVersion20260728 { + req.Session.updateState(func(state *ServerSessionState) { + state.InitializeParams = init + }) + } res := &DiscoverResult{ SupportedVersions: versions, Capabilities: s.capabilities(), diff --git a/mcp/streamable_test.go b/mcp/streamable_test.go index 85ee331..fac337f 100644 --- a/mcp/streamable_test.go +++ b/mcp/streamable_test.go @@ -3682,6 +3682,70 @@ func TestStreamableStateful_AcceptsDiscover(t *testing.T) { } } +// On a stateful StreamableHTTPHandler, a v1.7.0-style client always +// probes server/discover before falling back to initialize. That probe used +// to create a ServerSession that was registered in the handler's session +// map but never surfaced via Mcp-Session-Id, so the client could not DELETE +// it and it was leaked for the lifetime of the process. +// +// The fix in Server.discover skips populating InitializeParams when the +// transport cannot serve the new protocol, which lets the safety-net +// cleanup in serveStatefulPOST close the otherwise-unaddressable session. +func TestStreamableStateful_DiscoverDoesNotLeakSession(t *testing.T) { + ctx := context.Background() + server := NewServer(testImpl, nil) + AddTool(server, &Tool{Name: "noop", Description: "noop"}, + func(context.Context, *CallToolRequest, struct{}) (*CallToolResult, any, error) { + return &CallToolResult{}, nil, nil + }) + + // Stateful (default). SessionTimeout unset so nothing masks the leak + // via idle eviction. + handler := NewStreamableHTTPHandler(func(*http.Request) *Server { return server }, nil) + httpServer := httptest.NewServer(handler) + defer httpServer.Close() + + countSessions := func() int { + n := 0 + for range server.Sessions() { + n++ + } + return n + } + + const clients = 3 + for i := 1; i <= clients; i++ { + c := NewClient(&Implementation{Name: "c", Version: "1.0.0"}, nil) + sess, err := c.Connect(ctx, &StreamableClientTransport{ + Endpoint: httpServer.URL, + DisableStandaloneSSE: true, + MaxRetries: -1, + }, nil) + if err != nil { + t.Fatalf("client %d Connect: %v", i, err) + } + if _, err := sess.ListTools(ctx, nil); err != nil { + t.Fatalf("client %d ListTools: %v", i, err) + } + // Exactly one addressable session should exist while this client + // is connected. Anything more means a discover-only session + // slipped through. + if got := countSessions(); got != 1 { + t.Errorf("client %d connected: sessions = %d, want 1", i, got) + } + if err := sess.Close(); err != nil { + t.Fatalf("client %d Close: %v", i, err) + } + if got := countSessions(); got != 0 { + t.Errorf("client %d closed: sessions = %d, want 0", i, got) + } + } + + if got := countSessions(); got != 0 { + t.Errorf("final leaked sessions = %d, want 0", got) + } +} + // 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) { From 1acd66e9d71e4752cf01eb14dad70c2850bf5c5d Mon Sep 17 00:00:00 2001 From: dev_Hakaze Date: Mon, 3 Aug 2026 20:29:52 +0700 Subject: [PATCH 13/70] mcp: export ClientSessionOptions.ProtocolVersion (#1127) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary `ClientSessionOptions.protocolVersion` was unexported and documented as test-only, so external consumers of `Client.Connect` could not request a specific protocol revision. This exports the field as `ProtocolVersion` with the same semantics: - empty → request the latest supported version (unchanged default) - non-empty → send that version in initialize/discover - existing negotiate-down path is unchanged ## Usage ```go cs, err := client.Connect(ctx, transport, &mcp.ClientSessionOptions{ ProtocolVersion: "2025-11-25", }) ``` ## Testing - `go test ./mcp/ -count=1` (pass) Fixes #1113 Co-authored-by: arimu1 <19286898+arimu1@users.noreply.github.com> Co-authored-by: Guglielmo Colombo --- mcp/client.go | 13 ++++++----- mcp/client_test.go | 14 +++++------ mcp/elicitation_test.go | 6 ++--- mcp/mcp_test.go | 44 +++++++++++++++++------------------ mcp/mrtr_test.go | 4 ++-- mcp/sampling_test.go | 10 ++++---- mcp/server_test.go | 2 +- mcp/streamable_client_test.go | 32 ++++++++++++------------- mcp/streamable_test.go | 20 ++++++++-------- 9 files changed, 73 insertions(+), 72 deletions(-) diff --git a/mcp/client.go b/mcp/client.go index 6f1073a..2ad0ea4 100644 --- a/mcp/client.go +++ b/mcp/client.go @@ -245,11 +245,12 @@ func (e unsupportedProtocolVersionError) Error() string { return fmt.Sprintf("unsupported protocol version: %q", e.version) } -// ClientSessionOptions is reserved for future use. +// ClientSessionOptions configures a client session created by [Client.Connect]. type ClientSessionOptions struct { - // protocolVersion overrides the protocol version sent in the initialize - // request, for testing. If empty, latestProtocolVersion is used. - protocolVersion string + // ProtocolVersion is the protocol version sent in the initialize (or + // discover) request. If empty, the latest supported version is used. + // The server may negotiate a different mutually supported version. + ProtocolVersion string } func (c *Client) capabilities(protocolVersion string) *ClientCapabilities { @@ -311,8 +312,8 @@ func (c *Client) Connect(ctx context.Context, t Transport, opts *ClientSessionOp } protocolVersion := latestProtocolVersion - if opts != nil && opts.protocolVersion != "" { - protocolVersion = opts.protocolVersion + if opts != nil && opts.ProtocolVersion != "" { + protocolVersion = opts.ProtocolVersion } if protocolVersion >= protocolVersion20260728 { diff --git a/mcp/client_test.go b/mcp/client_test.go index 0fe39c0..f02b905 100644 --- a/mcp/client_test.go +++ b/mcp/client_test.go @@ -740,7 +740,7 @@ func TestClientConnectDiscover(t *testing.T) { defer ss.Close() c := NewClient(testImpl, nil) - cs, err := c.Connect(ctx, ct, &ClientSessionOptions{protocolVersion: protocolVersion20260728}) + cs, err := c.Connect(ctx, ct, &ClientSessionOptions{ProtocolVersion: protocolVersion20260728}) if err != nil { t.Fatalf("Connect: %v", err) } @@ -805,7 +805,7 @@ func TestClientConnectDiscover_RequestContents(t *testing.T) { return nil, nil }, }) - cs, err := c.Connect(ctx, ct, &ClientSessionOptions{protocolVersion: protocolVersion20260728}) + cs, err := c.Connect(ctx, ct, &ClientSessionOptions{ProtocolVersion: protocolVersion20260728}) if err != nil { t.Fatalf("Connect: %v", err) } @@ -851,7 +851,7 @@ func TestInMemory_E2E_DiscoverSuccess(t *testing.T) { defer ss.Close() client := NewClient(&Implementation{Name: "stdio-like-client", Version: "v1"}, nil) - cs, err := client.Connect(ctx, ct, &ClientSessionOptions{protocolVersion: protocolVersion20260728}) + cs, err := client.Connect(ctx, ct, &ClientSessionOptions{ProtocolVersion: protocolVersion20260728}) if err != nil { t.Fatalf("client.Connect: %v", err) } @@ -908,7 +908,7 @@ func TestInMemory_E2E_DiscoverFallback_NoOverlap(t *testing.T) { defer ss.Close() client := NewClient(&Implementation{Name: "new-client", Version: "v1"}, nil) - cs, err := client.Connect(ctx, ct, &ClientSessionOptions{protocolVersion: protocolVersion20260728}) + cs, err := client.Connect(ctx, ct, &ClientSessionOptions{ProtocolVersion: protocolVersion20260728}) if err != nil { t.Fatalf("client.Connect: %v", err) } @@ -955,7 +955,7 @@ func TestInMemory_E2E_DiscoverFallback_MethodNotFound(t *testing.T) { defer ss.Close() client := NewClient(&Implementation{Name: "new-client", Version: "v1"}, nil) - cs, err := client.Connect(ctx, ct, &ClientSessionOptions{protocolVersion: protocolVersion20260728}) + cs, err := client.Connect(ctx, ct, &ClientSessionOptions{ProtocolVersion: protocolVersion20260728}) if err != nil { t.Fatalf("client.Connect: %v", err) } @@ -1005,7 +1005,7 @@ func TestInMemory_E2E_DiscoverFallback_UnsupportedProtocolVersion(t *testing.T) defer ss.Close() client := NewClient(&Implementation{Name: "new-client", Version: "v1"}, nil) - cs, err := client.Connect(ctx, ct, &ClientSessionOptions{protocolVersion: protocolVersion20260728}) + cs, err := client.Connect(ctx, ct, &ClientSessionOptions{ProtocolVersion: protocolVersion20260728}) if err != nil { t.Fatalf("client.Connect: %v", err) } @@ -1068,7 +1068,7 @@ func TestClientConnectDiscover_UnsupportedVersionNegotiation(t *testing.T) { defer ss.Close() c := NewClient(testImpl, nil) - cs, err := c.Connect(ctx, ct, &ClientSessionOptions{protocolVersion: unsupportedClientVersion}) + cs, err := c.Connect(ctx, ct, &ClientSessionOptions{ProtocolVersion: unsupportedClientVersion}) if err != nil { t.Fatalf("Connect: %v", err) } diff --git a/mcp/elicitation_test.go b/mcp/elicitation_test.go index a20947f..d588516 100644 --- a/mcp/elicitation_test.go +++ b/mcp/elicitation_test.go @@ -114,7 +114,7 @@ func TestElicitationURLMode(t *testing.T) { }, ElicitationHandler: tc.handler, }) - cs, err := c.Connect(ctx, ct, &ClientSessionOptions{protocolVersion: protocolVersion20251125}) + cs, err := c.Connect(ctx, ct, &ClientSessionOptions{ProtocolVersion: protocolVersion20251125}) if err != nil { t.Fatal(err) } @@ -172,7 +172,7 @@ func TestElicitationCompleteNotification(t *testing.T) { t.Fatal(err) } defer ss.Close() - cs, err := c.Connect(ctx, ct, &ClientSessionOptions{protocolVersion: protocolVersion20251125}) + cs, err := c.Connect(ctx, ct, &ClientSessionOptions{ProtocolVersion: protocolVersion20251125}) if err != nil { t.Fatal(err) } @@ -255,7 +255,7 @@ func TestElicitationNoValidationWithoutAccept(t *testing.T) { return &ElicitResult{Action: tc.action, Content: tc.content}, nil }, }) - cs, err := c.Connect(ctx, ct, &ClientSessionOptions{protocolVersion: protocolVersion20251125}) + cs, err := c.Connect(ctx, ct, &ClientSessionOptions{ProtocolVersion: protocolVersion20251125}) if err != nil { t.Fatal(err) } diff --git a/mcp/mcp_test.go b/mcp/mcp_test.go index 06526c4..d9d9b3a 100644 --- a/mcp/mcp_test.go +++ b/mcp/mcp_test.go @@ -174,7 +174,7 @@ func TestEndToEnd(t *testing.T) { // // Pin the session to 2025-11-25 so the legacy // semantics apply. - cs, err := c.Connect(ctx, ct, &ClientSessionOptions{protocolVersion: protocolVersion20251125}) + cs, err := c.Connect(ctx, ct, &ClientSessionOptions{ProtocolVersion: protocolVersion20251125}) if err != nil { t.Fatal(err) } @@ -764,7 +764,7 @@ func TestMiddleware(t *testing.T) { // Pin to 2025-11-25 because the test's expected wire sequence asserts // the legacy initialize / notifications/initialized handshake, which // 2026-07-28 replaces with server/discover. - cs, err := c.Connect(ctx, ct, &ClientSessionOptions{protocolVersion: protocolVersion20251125}) + cs, err := c.Connect(ctx, ct, &ClientSessionOptions{ProtocolVersion: protocolVersion20251125}) if err != nil { t.Fatal(err) } @@ -854,7 +854,7 @@ func TestNoJSONNull(t *testing.T) { } c := NewClient(testImpl, nil) - cs, err := c.Connect(ctx, ct, &ClientSessionOptions{protocolVersion: protocolVersion20251125}) + cs, err := c.Connect(ctx, ct, &ClientSessionOptions{ProtocolVersion: protocolVersion20251125}) if err != nil { t.Fatal(err) } @@ -1023,7 +1023,7 @@ func TestElicitationUnsupportedMethod(t *testing.T) { return &CreateMessageResult{Model: "aModel", Content: &TextContent{}}, nil }, }) - cs, err := c.Connect(ctx, ct, &ClientSessionOptions{protocolVersion: protocolVersion20251125}) + cs, err := c.Connect(ctx, ct, &ClientSessionOptions{ProtocolVersion: protocolVersion20251125}) if err != nil { t.Fatal(err) } @@ -1072,7 +1072,7 @@ func TestElicitationSchemaValidation(t *testing.T) { return &ElicitResult{Action: "accept", Content: map[string]any{"test": "value"}}, nil }, }) - cs, err := c.Connect(ctx, ct, &ClientSessionOptions{protocolVersion: protocolVersion20251125}) + cs, err := c.Connect(ctx, ct, &ClientSessionOptions{ProtocolVersion: protocolVersion20251125}) if err != nil { t.Fatal(err) } @@ -1558,7 +1558,7 @@ func TestElicitContentValidation(t *testing.T) { return &ElicitResult{Action: "accept", Content: map[string]any{"test": "potato"}}, nil }, }) - cs, err := c.Connect(ctx, ct, &ClientSessionOptions{protocolVersion: protocolVersion20251125}) + cs, err := c.Connect(ctx, ct, &ClientSessionOptions{ProtocolVersion: protocolVersion20251125}) if err != nil { t.Fatal(err) } @@ -1641,7 +1641,7 @@ func TestElicitationProgressToken(t *testing.T) { return &ElicitResult{Action: "accept"}, nil }, }) - cs, err := c.Connect(ctx, ct, &ClientSessionOptions{protocolVersion: protocolVersion20251125}) + cs, err := c.Connect(ctx, ct, &ClientSessionOptions{ProtocolVersion: protocolVersion20251125}) if err != nil { t.Fatal(err) } @@ -1683,7 +1683,7 @@ func TestElicitationCapabilityDeclaration(t *testing.T) { } defer ss.Close() - cs, err := c.Connect(ctx, ct, &ClientSessionOptions{protocolVersion: protocolVersion20251125}) + cs, err := c.Connect(ctx, ct, &ClientSessionOptions{ProtocolVersion: protocolVersion20251125}) if err != nil { t.Fatal(err) } @@ -1720,7 +1720,7 @@ func TestElicitationCapabilityDeclaration(t *testing.T) { } defer ss.Close() - cs, err := c.Connect(ctx, ct, &ClientSessionOptions{protocolVersion: protocolVersion20251125}) + cs, err := c.Connect(ctx, ct, &ClientSessionOptions{ProtocolVersion: protocolVersion20251125}) if err != nil { t.Fatal(err) } @@ -1757,7 +1757,7 @@ func TestElicitationDefaultValues(t *testing.T) { return &ElicitResult{Action: "accept", Content: map[string]any{"default": "response"}}, nil }, }) - cs, err := c.Connect(ctx, ct, &ClientSessionOptions{protocolVersion: protocolVersion20251125}) + cs, err := c.Connect(ctx, ct, &ClientSessionOptions{ProtocolVersion: protocolVersion20251125}) if err != nil { t.Fatal(err) } @@ -1912,7 +1912,7 @@ func TestKeepAliveFailure_Logged(t *testing.T) { // Pin to 2025-11-25: KeepAlive uses the ping RPC, which is removed // in 2026-07-28, so keepalive is only meaningful on legacy protocol // versions. - cs, err := c.Connect(ctx, ct, &ClientSessionOptions{protocolVersion: protocolVersion20251125}) + cs, err := c.Connect(ctx, ct, &ClientSessionOptions{ProtocolVersion: protocolVersion20251125}) if err != nil { t.Fatal(err) } @@ -2087,7 +2087,7 @@ func TestSynchronousNotifications(t *testing.T) { t.Fatal(err) } t.Cleanup(func() { _ = ss.Close() }) - cs, err := client.Connect(ctx, ct, &ClientSessionOptions{protocolVersion: protocolVersion20251125}) + cs, err := client.Connect(ctx, ct, &ClientSessionOptions{ProtocolVersion: protocolVersion20251125}) if err != nil { t.Fatal(err) } @@ -2501,7 +2501,7 @@ func runSubscriptionsListenTest(t *testing.T, client *Client, server *Server, ct ctx, topCancel := context.WithTimeout(context.Background(), 30*time.Second) defer topCancel() - cs, err := client.Connect(ctx, ct, &ClientSessionOptions{protocolVersion: protocolVersion20260728}) + cs, err := client.Connect(ctx, ct, &ClientSessionOptions{ProtocolVersion: protocolVersion20260728}) if err != nil { t.Fatalf("client connect: %v", err) } @@ -2659,7 +2659,7 @@ func TestSubscriptionsListen_NoHandlersNoListen(t *testing.T) { return next(ctx, method, req) } }) - cs, err := c.Connect(ctx, ct, &ClientSessionOptions{protocolVersion: protocolVersion20260728}) + cs, err := c.Connect(ctx, ct, &ClientSessionOptions{ProtocolVersion: protocolVersion20260728}) if err != nil { t.Fatalf("client connect: %v", err) } @@ -2782,7 +2782,7 @@ func TestResourceSubscriptions_Streamable(t *testing.T) { }, }) cs, err := c.Connect(ctx, &StreamableClientTransport{Endpoint: httpServer.URL}, - &ClientSessionOptions{protocolVersion: protocolVersion20260728}) + &ClientSessionOptions{ProtocolVersion: protocolVersion20260728}) if err != nil { t.Fatalf("client connect: %v", err) } @@ -2851,7 +2851,7 @@ func TestResourceSubscriptions_InMemory(t *testing.T) { events <- resourceSubEvent{uri: req.Params.URI, id: id} }, }) - cs, err := c.Connect(ctx, ct, &ClientSessionOptions{protocolVersion: protocolVersion20260728}) + cs, err := c.Connect(ctx, ct, &ClientSessionOptions{ProtocolVersion: protocolVersion20260728}) if err != nil { t.Fatalf("client connect: %v", err) } @@ -2928,7 +2928,7 @@ func TestResourceSubscriptions_Subscribe_Idempotent(t *testing.T) { c := NewClient(testImpl, &ClientOptions{ ResourceUpdatedHandler: func(context.Context, *ResourceUpdatedNotificationRequest) {}, }) - cs, err := c.Connect(ctx, ct, &ClientSessionOptions{protocolVersion: protocolVersion20260728}) + cs, err := c.Connect(ctx, ct, &ClientSessionOptions{ProtocolVersion: protocolVersion20260728}) if err != nil { t.Fatalf("client connect: %v", err) } @@ -3000,7 +3000,7 @@ func TestResourceSubscriptions_MultipleURIs(t *testing.T) { events <- resourceSubEvent{uri: req.Params.URI, id: id} }, }) - cs, err := c.Connect(ctx, ct, &ClientSessionOptions{protocolVersion: protocolVersion20260728}) + cs, err := c.Connect(ctx, ct, &ClientSessionOptions{ProtocolVersion: protocolVersion20260728}) if err != nil { t.Fatalf("client connect: %v", err) } @@ -3105,7 +3105,7 @@ func TestSubscriptionsListen_MultipleSessions(t *testing.T) { } c := newSubListenClient(events) cs, err := c.Connect(context.Background(), ct, - &ClientSessionOptions{protocolVersion: protocolVersion20260728}) + &ClientSessionOptions{ProtocolVersion: protocolVersion20260728}) if err != nil { t.Fatalf("client connect: %v", err) } @@ -3205,7 +3205,7 @@ func TestSubscriptionsListen_ResourceListChanged(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() - cs, err := c.Connect(ctx, ct, &ClientSessionOptions{protocolVersion: protocolVersion20260728}) + cs, err := c.Connect(ctx, ct, &ClientSessionOptions{ProtocolVersion: protocolVersion20260728}) if err != nil { t.Fatalf("client connect: %v", err) } @@ -3249,7 +3249,7 @@ func TestSubscriptionsListen_DisconnectScrubsMaps(t *testing.T) { } c := newSubListenClient(events) cs, err := c.Connect(context.Background(), ct, - &ClientSessionOptions{protocolVersion: protocolVersion20260728}) + &ClientSessionOptions{ProtocolVersion: protocolVersion20260728}) if err != nil { t.Fatalf("client connect: %v", err) } @@ -3428,7 +3428,7 @@ func TestCallCustomMethodTypedNilParams(t *testing.T) { if err := AddSendingCustomMethod[*pingParams, *pingResult](c, "acme/ping"); err != nil { t.Fatal(err) } - cs, err := c.Connect(ctx, ct, &ClientSessionOptions{protocolVersion: protocolVersion20260728}) + cs, err := c.Connect(ctx, ct, &ClientSessionOptions{ProtocolVersion: protocolVersion20260728}) if err != nil { t.Fatal(err) } diff --git a/mcp/mrtr_test.go b/mcp/mrtr_test.go index 238bc09..2ae4fd9 100644 --- a/mcp/mrtr_test.go +++ b/mcp/mrtr_test.go @@ -312,7 +312,7 @@ func TestMultiRoundTrip_ServerMiddleware(t *testing.T) { }, }) c.AddRoots(&Root{URI: "file:///workspace", Name: "workspace"}) - cs, err := c.Connect(t.Context(), ct, &ClientSessionOptions{protocolVersion: protocolVersion20251125}) + cs, err := c.Connect(t.Context(), ct, &ClientSessionOptions{ProtocolVersion: protocolVersion20251125}) if err != nil { t.Fatalf("client.Connect() error = %v", err) } @@ -516,7 +516,7 @@ func mustConnect(t *testing.T, s *Server, clientOpts *ClientOptions) *ClientSess }) c := NewClient(testImpl, clientOpts) - cs, err := c.Connect(t.Context(), ct, &ClientSessionOptions{protocolVersion: protocolVersion20260728}) + cs, err := c.Connect(t.Context(), ct, &ClientSessionOptions{ProtocolVersion: protocolVersion20260728}) if err != nil { t.Fatalf("client.Connect() error = %v", err) } diff --git a/mcp/sampling_test.go b/mcp/sampling_test.go index 2adb0fd..7a3ecab 100644 --- a/mcp/sampling_test.go +++ b/mcp/sampling_test.go @@ -53,7 +53,7 @@ func TestSamplingWithTools_ToolUse(t *testing.T) { } defer ss.Close() - cs, err := client.Connect(ctx, ct, &ClientSessionOptions{protocolVersion: protocolVersion20251125}) + cs, err := client.Connect(ctx, ct, &ClientSessionOptions{ProtocolVersion: protocolVersion20251125}) if err != nil { t.Fatal(err) } @@ -124,7 +124,7 @@ func TestSamplingWithTools_ToolResult(t *testing.T) { } defer ss.Close() - cs, err := client.Connect(ctx, ct, &ClientSessionOptions{protocolVersion: protocolVersion20251125}) + cs, err := client.Connect(ctx, ct, &ClientSessionOptions{ProtocolVersion: protocolVersion20251125}) if err != nil { t.Fatal(err) } @@ -288,7 +288,7 @@ func TestSamplingWithTools_ToolResultWithError(t *testing.T) { } defer ss.Close() - cs, err := client.Connect(ctx, ct, &ClientSessionOptions{protocolVersion: protocolVersion20251125}) + cs, err := client.Connect(ctx, ct, &ClientSessionOptions{ProtocolVersion: protocolVersion20251125}) if err != nil { t.Fatal(err) } @@ -350,7 +350,7 @@ func TestSamplingWithTools_ParallelToolCalls(t *testing.T) { } defer ss.Close() - cs, err := client.Connect(ctx, ct, &ClientSessionOptions{protocolVersion: protocolVersion20251125}) + cs, err := client.Connect(ctx, ct, &ClientSessionOptions{ProtocolVersion: protocolVersion20251125}) if err != nil { t.Fatal(err) } @@ -420,7 +420,7 @@ func TestCreateMessage_MultipleContentError(t *testing.T) { } defer ss.Close() - cs, err := client.Connect(ctx, ct, &ClientSessionOptions{protocolVersion: protocolVersion20251125}) + cs, err := client.Connect(ctx, ct, &ClientSessionOptions{ProtocolVersion: protocolVersion20251125}) if err != nil { t.Fatal(err) } diff --git a/mcp/server_test.go b/mcp/server_test.go index d6ecfed..75d7455 100644 --- a/mcp/server_test.go +++ b/mcp/server_test.go @@ -1663,7 +1663,7 @@ func TestServerSession_RejectsServerInitiated(t *testing.T) { }, }) c.AddRoots(&Root{URI: "file:///workspace"}) - cs, err := c.Connect(ctx, ct, &ClientSessionOptions{protocolVersion: protoVer}) + cs, err := c.Connect(ctx, ct, &ClientSessionOptions{ProtocolVersion: protoVer}) if err != nil { t.Fatal(err) } diff --git a/mcp/streamable_client_test.go b/mcp/streamable_client_test.go index c8ab066..5b52369 100644 --- a/mcp/streamable_client_test.go +++ b/mcp/streamable_client_test.go @@ -214,7 +214,7 @@ func TestStreamableClientTransportLifecycle(t *testing.T) { // Pin to 2025-11-25: the fixture's canned initialize response uses // hardcoded id=1, which only matches when initialize is the first // request. Under 2026-07-28 the client probes server/discover first. - session, err := client.Connect(ctx, transport, &ClientSessionOptions{protocolVersion: protocolVersion20251125}) + session, err := client.Connect(ctx, transport, &ClientSessionOptions{ProtocolVersion: protocolVersion20251125}) if err != nil { t.Fatalf("client.Connect() failed: %v", err) } @@ -267,7 +267,7 @@ func TestStreamableClientRedundantDelete(t *testing.T) { // Pin to 2025-11-25: the fixture's canned initialize response uses // hardcoded id=1, which only matches when initialize is the first // request. Under 2026-07-28 the client probes server/discover first. - session, err := client.Connect(ctx, transport, &ClientSessionOptions{protocolVersion: protocolVersion20251125}) + session, err := client.Connect(ctx, transport, &ClientSessionOptions{ProtocolVersion: protocolVersion20251125}) if err != nil { t.Fatalf("client.Connect() failed: %v", err) } @@ -332,7 +332,7 @@ func TestStreamableClientGETHandling(t *testing.T) { transport := &StreamableClientTransport{Endpoint: httpServer.URL} client := NewClient(testImpl, nil) session, err := client.Connect(ctx, transport, &ClientSessionOptions{ - protocolVersion: protocolVersion20251125, + ProtocolVersion: protocolVersion20251125, }) if err == nil { defer session.Close() @@ -414,7 +414,7 @@ func TestStreamableClientStrictness(t *testing.T) { // uses hardcoded id=1, which only matches when initialize is // the first request. Under 2026-07-28 the client probes // server/discover first. - session, err := client.Connect(ctx, transport, &ClientSessionOptions{protocolVersion: protocolVersion20251125}) + session, err := client.Connect(ctx, transport, &ClientSessionOptions{ProtocolVersion: protocolVersion20251125}) if (err != nil) != test.wantConnectError { t.Errorf("client.Connect() returned error %v; want error: %t", err, test.wantConnectError) } @@ -454,7 +454,7 @@ func TestStreamableClientUnresumableRequest(t *testing.T) { transport := &StreamableClientTransport{Endpoint: httpServer.URL} client := NewClient(testImpl, nil) - cs, err := client.Connect(ctx, transport, &ClientSessionOptions{protocolVersion: protocolVersion20251125}) + cs, err := client.Connect(ctx, transport, &ClientSessionOptions{ProtocolVersion: protocolVersion20251125}) if err == nil { cs.Close() t.Fatalf("Connect succeeded unexpectedly") @@ -575,7 +575,7 @@ data: { "jsonrpc": "2.0", "method": "notifications/message", "params": { "level" // uses hardcoded id=1, which only matches when initialize is // the first request. Under 2026-07-28 the client probes // server/discover first. - cs, err := client.Connect(ctx, transport, &ClientSessionOptions{protocolVersion: protocolVersion20251125}) + cs, err := client.Connect(ctx, transport, &ClientSessionOptions{ProtocolVersion: protocolVersion20251125}) if err != nil { t.Fatal(err) } @@ -707,7 +707,7 @@ func TestStreamableClientTransientErrors(t *testing.T) { // uses hardcoded id=1, which only matches when initialize is // the first request. Under 2026-07-28 the client probes // server/discover first. - session, err := client.Connect(ctx, transport, &ClientSessionOptions{protocolVersion: protocolVersion20251125}) + session, err := client.Connect(ctx, transport, &ClientSessionOptions{ProtocolVersion: protocolVersion20251125}) if err != nil { t.Fatalf("Connect failed: %v", err) } @@ -813,7 +813,7 @@ data: {"jsonrpc":"2.0","method":"notifications/message","params":{"level":"info" // Pin to 2025-11-25: the fixture's canned initialize response uses // hardcoded id=1, which only matches when initialize is the first // request. Under 2026-07-28 the client probes server/discover first. - session, err := client.Connect(ctx, transport, &ClientSessionOptions{protocolVersion: protocolVersion20251125}) + session, err := client.Connect(ctx, transport, &ClientSessionOptions{ProtocolVersion: protocolVersion20251125}) if err != nil { t.Fatalf("Connect failed: %v", err) } @@ -901,7 +901,7 @@ func TestStreamableClientDisableStandaloneSSE(t *testing.T) { // uses hardcoded id=1, which only matches when initialize is // the first request. Under 2026-07-28 the client probes // server/discover first. - session, err := client.Connect(ctx, transport, &ClientSessionOptions{protocolVersion: protocolVersion20251125}) + session, err := client.Connect(ctx, transport, &ClientSessionOptions{ProtocolVersion: protocolVersion20251125}) if err != nil { t.Fatalf("client.Connect() failed: %v", err) } @@ -1022,7 +1022,7 @@ func TestStreamableClientOAuth_AuthorizationHeader(t *testing.T) { // Pin to 2025-11-25: the fixture's canned initialize response uses // hardcoded id=1, which only matches when initialize is the first // request. Under 2026-07-28 the client probes server/discover first. - session, err := client.Connect(ctx, transport, &ClientSessionOptions{protocolVersion: protocolVersion20251125}) + session, err := client.Connect(ctx, transport, &ClientSessionOptions{ProtocolVersion: protocolVersion20251125}) if err != nil { t.Fatalf("client.Connect() failed: %v", err) } @@ -1316,7 +1316,7 @@ func TestStreamableClientConnect_DiscoverSuccess(t *testing.T) { transport := &StreamableClientTransport{Endpoint: httpServer.URL} client := NewClient(testImpl, nil) - session, err := client.Connect(ctx, transport, &ClientSessionOptions{protocolVersion: protocolVersion20260728}) + session, err := client.Connect(ctx, transport, &ClientSessionOptions{ProtocolVersion: protocolVersion20260728}) if err != nil { t.Fatalf("Connect: %v", err) } @@ -1496,7 +1496,7 @@ func TestStreamableClientConnect_DiscoverMethodNotFound(t *testing.T) { transport := &StreamableClientTransport{Endpoint: httpServer.URL} client := NewClient(testImpl, nil) - session, err := client.Connect(ctx, transport, &ClientSessionOptions{protocolVersion: protocolVersion20251125}) + session, err := client.Connect(ctx, transport, &ClientSessionOptions{ProtocolVersion: protocolVersion20251125}) if err != nil { t.Fatalf("Connect: %v", err) } @@ -1563,7 +1563,7 @@ func TestStreamableClientConnect_DiscoverUnsupportedVersion(t *testing.T) { transport := &StreamableClientTransport{Endpoint: httpServer.URL} client := NewClient(testImpl, nil) - session, err := client.Connect(ctx, transport, &ClientSessionOptions{protocolVersion: protocolVersion20251125}) + session, err := client.Connect(ctx, transport, &ClientSessionOptions{ProtocolVersion: protocolVersion20251125}) if err != nil { t.Fatalf("Connect: %v", err) } @@ -1623,7 +1623,7 @@ func TestStreamableClientConnect_DiscoverMethodNotFoundVPre(t *testing.T) { DisableStandaloneSSE: true, } client := NewClient(testImpl, nil) - session, err := client.Connect(ctx, transport, &ClientSessionOptions{protocolVersion: protocolVersion20260728}) + session, err := client.Connect(ctx, transport, &ClientSessionOptions{ProtocolVersion: protocolVersion20260728}) if err != nil { t.Fatalf("Connect: %v", err) } @@ -1680,7 +1680,7 @@ func TestStreamableClientConnect_DiscoverUnsupportedVersionVPre(t *testing.T) { DisableStandaloneSSE: true, } client := NewClient(testImpl, nil) - session, err := client.Connect(ctx, transport, &ClientSessionOptions{protocolVersion: protocolVersion20260728}) + session, err := client.Connect(ctx, transport, &ClientSessionOptions{ProtocolVersion: protocolVersion20260728}) if err != nil { t.Fatalf("Connect: %v", err) } @@ -1738,7 +1738,7 @@ func TestStreamableClientConnect_DiscoverUnsupportedVersionNegotiation(t *testin transport := &StreamableClientTransport{Endpoint: httpServer.URL} client := NewClient(testImpl, nil) - session, err := client.Connect(ctx, transport, &ClientSessionOptions{protocolVersion: unsupportedClientVersion}) + session, err := client.Connect(ctx, transport, &ClientSessionOptions{ProtocolVersion: unsupportedClientVersion}) if err != nil { t.Fatalf("Connect: %v", err) } diff --git a/mcp/streamable_test.go b/mcp/streamable_test.go index fac337f..726733d 100644 --- a/mcp/streamable_test.go +++ b/mcp/streamable_test.go @@ -347,7 +347,7 @@ func TestStreamableServerShutdown(t *testing.T) { clientSession, err := client.Connect(ctx, &StreamableClientTransport{ Endpoint: httpServer.URL, MaxRetries: -1, // avoid slow tests during exponential retries - }, &ClientSessionOptions{protocolVersion: protocolVersion20251125}) + }, &ClientSessionOptions{ProtocolVersion: protocolVersion20251125}) if err != nil { t.Fatal(err) } @@ -622,7 +622,7 @@ func TestStreamableServerDisconnect(t *testing.T) { }) clientSession, err := client.Connect(ctx, &StreamableClientTransport{ Endpoint: httpServer.URL, - }, &ClientSessionOptions{protocolVersion: protocolVersion20251125}) + }, &ClientSessionOptions{ProtocolVersion: protocolVersion20251125}) if err != nil { t.Fatalf("client.Connect() failed: %v", err) } @@ -697,7 +697,7 @@ func TestServerTransportCleanup(t *testing.T) { // stateful HTTP server (which rejects the new protocol), opening // an extra HTTP connection and therefore an extra session. Pinning // to 2025-11-25 skips the discover probe. - clientSession, err := client.Connect(ctx, &StreamableClientTransport{Endpoint: httpServer.URL}, &ClientSessionOptions{protocolVersion: protocolVersion20251125}) + clientSession, err := client.Connect(ctx, &StreamableClientTransport{Endpoint: httpServer.URL}, &ClientSessionOptions{ProtocolVersion: protocolVersion20251125}) if err != nil { t.Fatalf("client.Connect() failed: %v", err) } @@ -2122,7 +2122,7 @@ func TestStreamableMcpHeaderValidationErrorFormat(t *testing.T) { client := NewClient(&Implementation{Name: "testClient", Version: "v1.0.0"}, nil) ctx := context.Background() - session, err := client.Connect(ctx, clientTransport, &ClientSessionOptions{protocolVersion: minVersionForStandardHeaders}) + session, err := client.Connect(ctx, clientTransport, &ClientSessionOptions{ProtocolVersion: minVersionForStandardHeaders}) if err != nil { t.Fatal(err) } @@ -2285,7 +2285,7 @@ func TestStreamableParamHeadersClientSetsHeaders(t *testing.T) { client := NewClient(&Implementation{Name: "testClient", Version: "v1.0.0"}, nil) ctx := context.Background() - session, err := client.Connect(ctx, clientTransport, &ClientSessionOptions{protocolVersion: minVersionForStandardHeaders}) + session, err := client.Connect(ctx, clientTransport, &ClientSessionOptions{ProtocolVersion: minVersionForStandardHeaders}) if err != nil { t.Fatal(err) } @@ -2383,7 +2383,7 @@ func TestStreamableFilterValidToolsIntegration(t *testing.T) { ctx := context.Background() session, err := client.Connect(ctx, &StreamableClientTransport{ Endpoint: httpServer.URL, - }, &ClientSessionOptions{protocolVersion: minVersionForStandardHeaders}) + }, &ClientSessionOptions{ProtocolVersion: minVersionForStandardHeaders}) if err != nil { t.Fatal(err) } @@ -2644,7 +2644,7 @@ func TestStreamableSessionTimeout(t *testing.T) { // protocol), opening an extra HTTP connection and therefore an extra // session. Pinning to 2025-11-25 skips the discover probe. client := NewClient(testImpl, nil) - session, err := client.Connect(ctx, &StreamableClientTransport{Endpoint: httpServer.URL}, &ClientSessionOptions{protocolVersion: protocolVersion20251125}) + session, err := client.Connect(ctx, &StreamableClientTransport{Endpoint: httpServer.URL}, &ClientSessionOptions{ProtocolVersion: protocolVersion20251125}) if err != nil { t.Fatalf("client.Connect() failed: %v", err) } @@ -2938,7 +2938,7 @@ func Test_ExportErrSessionMissing(t *testing.T) { // first against a stateful HTTP server (which rejects the new // protocol), opening an extra HTTP connection and therefore an extra // session. Pinning to 2025-11-25 skips the discover probe. - session, err := client.Connect(ctx, clientTransport, &ClientSessionOptions{protocolVersion: protocolVersion20251125}) + session, err := client.Connect(ctx, clientTransport, &ClientSessionOptions{ProtocolVersion: protocolVersion20251125}) if err != nil { t.Fatalf("Connect failed: %v", err) } @@ -3579,7 +3579,7 @@ func TestStreamableClientUnsupportedVersionFallback(t *testing.T) { client := NewClient(testImpl, nil) transport := &StreamableClientTransport{Endpoint: httpServer.URL} - session, err := client.Connect(ctx, transport, &ClientSessionOptions{protocolVersion: protocolVersion20260728}) + session, err := client.Connect(ctx, transport, &ClientSessionOptions{ProtocolVersion: protocolVersion20260728}) if err != nil { t.Fatalf("Connect: %v", err) } @@ -3771,7 +3771,7 @@ func TestStreamableHTTP_E2E_DiscoverSuccess(t *testing.T) { client := NewClient(&Implementation{Name: "e2e-client", Version: "v1"}, nil) transport := &StreamableClientTransport{Endpoint: httpServer.URL} - cs, err := client.Connect(ctx, transport, &ClientSessionOptions{protocolVersion: protocolVersion20260728}) + cs, err := client.Connect(ctx, transport, &ClientSessionOptions{ProtocolVersion: protocolVersion20260728}) if err != nil { t.Fatalf("Connect: %v", err) } From c4a4ce4543bf52698e20a4fbff75285c76caf0c0 Mon Sep 17 00:00:00 2001 From: Guglielmo Colombo Date: Tue, 4 Aug 2026 11:02:36 +0200 Subject: [PATCH 14/70] mcp: fix error propagation to prevent session closing (#1118) Fixes #1117 --- docs/mcpgodebug.md | 9 +- internal/docs/mcpgodebug.src.md | 9 +- mcp/streamable.go | 45 +++++----- mcp/streamable_client_test.go | 144 ++++++++++++++++++++++++++++++++ 4 files changed, 182 insertions(+), 25 deletions(-) diff --git a/docs/mcpgodebug.md b/docs/mcpgodebug.md index 45b9c7d..4562821 100644 --- a/docs/mcpgodebug.md +++ b/docs/mcpgodebug.md @@ -46,9 +46,12 @@ Options listed below were added and will be removed in the 1.9.0 version of the requested method in STDIO transport is not found. - `noprotocolerrorbody` added. If set to `1`, the streamable HTTP client will - not attempt to decode the JSON-RPC error body of a non-2xx HTTP response, - restoring the previous behavior. The default behavior was changed so that - the client always attempts to surface the underlying JSON-RPC error. + not attempt to decode the JSON-RPC error body of a non-2xx HTTP response, + and any non-transient error will permanently fail the connection, restoring + the previous behavior. The default behavior was changed so that the client + attempts to decode the JSON-RPC error body of a non-2xx response, surfaces + the underlying JSON-RPC error, and wraps it with `jsonrpc2.ErrRejected` so + that per-call rejections do not tear down the session. - `nowrapinvalidparams` added. If set to `1`, the server will not wrap params-decoding failures with `jsonrpc2.ErrInvalidParams`, so wire responses diff --git a/internal/docs/mcpgodebug.src.md b/internal/docs/mcpgodebug.src.md index e3ca770..c009a7c 100644 --- a/internal/docs/mcpgodebug.src.md +++ b/internal/docs/mcpgodebug.src.md @@ -45,9 +45,12 @@ Options listed below were added and will be removed in the 1.9.0 version of the requested method in STDIO transport is not found. - `noprotocolerrorbody` added. If set to `1`, the streamable HTTP client will - not attempt to decode the JSON-RPC error body of a non-2xx HTTP response, - restoring the previous behavior. The default behavior was changed so that - the client always attempts to surface the underlying JSON-RPC error. + not attempt to decode the JSON-RPC error body of a non-2xx HTTP response, + and any non-transient error will permanently fail the connection, restoring + the previous behavior. The default behavior was changed so that the client + attempts to decode the JSON-RPC error body of a non-2xx response, surfaces + the underlying JSON-RPC error, and wraps it with `jsonrpc2.ErrRejected` so + that per-call rejections do not tear down the session. - `nowrapinvalidparams` added. If set to `1`, the server will not wrap params-decoding failures with `jsonrpc2.ErrInvalidParams`, so wire responses diff --git a/mcp/streamable.go b/mcp/streamable.go index f642db1..2799d14 100644 --- a/mcp/streamable.go +++ b/mcp/streamable.go @@ -300,7 +300,12 @@ var allowsessionsinstateless = mcpgodebug.Value("allowsessionsinstateless") // noprotocolerrorbody is a compatibility parameter that restores the previous // behavior of [streamableClientConn.checkResponse]. When unset (the default), -// the client always attempts to surface the underlying JSON-RPC error. +// the client attempts to decode the response body of a non-2xx response as a +// JSON-RPC error. If successful, the underlying JSON-RPC error is surfaced to +// the caller and wrapped with [jsonrpc2.ErrRejected] so that per-call +// rejections do not tear down the session. When set to "1", the client +// ignores response bodies on non-2xx responses and any non-transient error +// permanently fails the connection. var noprotocolerrorbody = mcpgodebug.Value("noprotocolerrorbody") // disablecontenttypecheck is a compatibility parameter that allows to disable @@ -2304,10 +2309,13 @@ func (c *streamableClientConn) Write(ctx context.Context, msg jsonrpc.Message) e } if err := c.checkResponse(ctx, requestSummary, resp); err != nil { - if requestMethod == methodDiscover { + if requestMethod == methodDiscover && !errors.Is(err, jsonrpc2.ErrRejected) { // Wrap the discover failure with ErrRejected so the jsonrpc2 layer // doesn't set writeErr, which would prevent the legacy initialize - // fallback from succeeding on the same connection. + // fallback from succeeding on the same connection. This covers the + // case where a legacy server rejects server/discover with a + // non-JSON-RPC body (e.g. plain text 400), which checkResponse + // cannot classify as a per-call rejection on its own. err = fmt.Errorf("%w: %w", err, jsonrpc2.ErrRejected) } else if !errors.Is(err, jsonrpc2.ErrRejected) { // Only fail the connection for non-transient errors. @@ -2528,6 +2536,21 @@ func (c *streamableClientConn) checkResponse(ctx context.Context, requestSummary resp.Body.Close() } }() + // Transient server errors (502, 503, 504, 429) should not break the connection. + // Wrap them with ErrRejected so the jsonrpc2 layer doesn't set writeErr. + if isTransientHTTPStatus(resp.StatusCode) { + return fmt.Errorf("%w: %s: %v", jsonrpc2.ErrRejected, requestSummary, http.StatusText(resp.StatusCode)) + } + // By default, always try to decode the body and surface the underlying + // JSON-RPC error, wrapping it with ErrRejected to prevent the connection from closing. + // Setting MCPGODEBUG=noprotocolerrorbody=1 restores the previous behavior. + if noprotocolerrorbody != "1" && (resp.StatusCode < 200 || resp.StatusCode >= 300) { + body, _ := io.ReadAll(resp.Body) + msg, _ := jsonrpc.DecodeMessage(body) + if response, ok := msg.(*jsonrpc.Response); ok && response.Error != nil { + return fmt.Errorf("%s: %w: %w: %v", requestSummary, response.Error, jsonrpc2.ErrRejected, http.StatusText(resp.StatusCode)) + } + } // §2.5.3: "The server MAY terminate the session at any time, after // which it MUST respond to requests containing that session ID with HTTP // 404 Not Found." @@ -2536,23 +2559,7 @@ func (c *streamableClientConn) checkResponse(ctx context.Context, requestSummary // session is already gone. return fmt.Errorf("%s: failed to connect (session ID: %v): %w", requestSummary, c.sessionID, ErrSessionMissing) } - // Transient server errors (502, 503, 504, 429) should not break the connection. - // Wrap them with ErrRejected so the jsonrpc2 layer doesn't set writeErr. - if isTransientHTTPStatus(resp.StatusCode) { - return fmt.Errorf("%w: %s: %v", jsonrpc2.ErrRejected, requestSummary, http.StatusText(resp.StatusCode)) - } if resp.StatusCode < 200 || resp.StatusCode >= 300 { - // By default, always try to decode the body and surface the underlying - // JSON-RPC error. - // Setting MCPGODEBUG=noprotocolerrorbody=1 restores the previous behavior. - if noprotocolerrorbody == "1" { - return fmt.Errorf("%s: %v", requestSummary, http.StatusText(resp.StatusCode)) - } - 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)) - } return fmt.Errorf("%s: %v", requestSummary, http.StatusText(resp.StatusCode)) } return nil diff --git a/mcp/streamable_client_test.go b/mcp/streamable_client_test.go index 5b52369..a5957bf 100644 --- a/mcp/streamable_client_test.go +++ b/mcp/streamable_client_test.go @@ -1751,3 +1751,147 @@ func TestStreamableClientConnect_DiscoverUnsupportedVersionNegotiation(t *testin t.Errorf("InitializeResult.ProtocolVersion = %q, want %q", got, protocolVersion20260728) } } + +// TestStreamableClientHandlerErrorPropagation verifies that per-call +// handler-level HTTP errors carrying a JSON-RPC error body do not tear down +// the session, and that setting MCPGODEBUG=noprotocolerrorbody=1 restores the +// pre-fix behavior in which non-transient errors permanently failed the +// connection. +func TestStreamableClientHandlerErrorPropagation(t *testing.T) { + ctx := context.Background() + + // Build a JSON-RPC error response body for a given tools/call request. + jsonRPCErrorBody := func(id int64, code int64, msg string) string { + return jsonBody(t, resp(id, nil, &jsonrpc.Error{Code: code, Message: msg})) + } + + tests := []struct { + name string + callStatus int // HTTP status returned for the tools/call + callBody string // response body for the tools/call ("" for empty; may be a JSON-RPC error) + disableBodyDecode bool // if true, sets noprotocolerrorbody="1" for the duration of the test + wantErrRejected bool // whether the returned error should match errors.Is(err, jsonrpc2.ErrRejected) + wantSessionAlive bool // whether a subsequent call on the same session should succeed + }{ + { + // SEP-2575 §"Missing Required Capabilities": server returns + // -32021 at HTTP 400. Session must survive. + name: "400 with JSON-RPC error body (default)", + callStatus: http.StatusBadRequest, + callBody: jsonRPCErrorBody(1, CodeMissingRequiredClientCapabilities, "missing capability"), + wantErrRejected: true, + wantSessionAlive: true, + }, + { + // Same server response, but the user opted out via + // MCPGODEBUG=noprotocolerrorbody=1. Pre-fix behavior: the + // session is torn down. + name: "400 with JSON-RPC error body (noprotocolerrorbody=1)", + callStatus: http.StatusBadRequest, + callBody: jsonRPCErrorBody(1, CodeMissingRequiredClientCapabilities, "missing capability"), + disableBodyDecode: true, + wantErrRejected: false, + wantSessionAlive: false, + }, + { + // Legacy server returns plain-text 400 with no JSON-RPC + // body: cannot be classified as a per-call rejection, so the + // session is still torn down. + name: "400 with plain-text body", + callStatus: http.StatusBadRequest, + callBody: "Bad Request", + wantErrRejected: false, + wantSessionAlive: false, + }, + { + // SEP-2575: server returns -32601 for an unimplemented + // method at HTTP 404. Must be treated as a per-call + // rejection, not a terminated session. + name: "404 with JSON-RPC MethodNotFound body", + callStatus: http.StatusNotFound, + callBody: jsonRPCErrorBody(1, jsonrpc.CodeMethodNotFound, `method not found: "tools/call"`), + wantErrRejected: true, + wantSessionAlive: true, + }, + { + // A bare 404 with no JSON-RPC body means the session has + // been terminated. Session must not survive. + name: "404 with empty body (terminated session)", + callStatus: http.StatusNotFound, + callBody: "", + wantErrRejected: false, + wantSessionAlive: false, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if test.disableBodyDecode { + prev := noprotocolerrorbody + noprotocolerrorbody = "1" + t.Cleanup(func() { noprotocolerrorbody = prev }) + } + + // Track how many tools/call requests we've served: first + // returns the error under test, subsequent calls succeed so + // we can observe whether the session is still alive. + var callsServed atomic.Int32 + fake := &fakeStreamableServer{ + t: t, + responses: fakeResponses{ + {"POST", "", methodDiscover, ""}: { + header: header{ + "Content-Type": "application/json", + }, + wantProtocolVersion: protocolVersion20260728, + responseFunc: func(r *jsonrpc.Request) (string, int) { + return jsonBody(t, resp(r.ID.Raw().(int64), discoverResult, nil)), http.StatusOK + }, + }, + {"POST", "", "tools/call", ""}: { + header: header{"Content-Type": "application/json"}, + responseFunc: func(r *jsonrpc.Request) (string, int) { + if callsServed.Add(1) == 1 { + return test.callBody, test.callStatus + } + return jsonBody(t, resp(r.ID.Raw().(int64), &CallToolResult{}, nil)), 0 + }, + optional: true, + }, + }, + } + + httpServer := httptest.NewServer(fake) + defer httpServer.Close() + + transport := &StreamableClientTransport{Endpoint: httpServer.URL} + client := NewClient(testImpl, nil) + session, err := client.Connect(ctx, transport, &ClientSessionOptions{ProtocolVersion: protocolVersion20260728}) + if err != nil { + t.Fatalf("Connect: %v", err) + } + defer session.Close() + + // First call: triggers the error under test. + _, err = session.CallTool(ctx, &CallToolParams{Name: "nonexistent"}) + if err == nil { + t.Fatal("first CallTool succeeded unexpectedly, want error") + } + if got := errors.Is(err, jsonrpc2.ErrRejected); got != test.wantErrRejected { + t.Errorf("errors.Is(err, ErrRejected) = %v, want %v (err = %v)", got, test.wantErrRejected, err) + } + + // Second call: verifies whether the session survived. + _, err = session.CallTool(ctx, &CallToolParams{Name: "nonexistent"}) + if test.wantSessionAlive { + if err != nil { + t.Errorf("second CallTool failed: %v (session should survive per-call rejection)", err) + } + } else { + if err == nil { + t.Error("second CallTool succeeded unexpectedly, want session torn down") + } + } + }) + } +} From 725694100ae134a9ff699d5467e6215c9ec3efa6 Mon Sep 17 00:00:00 2001 From: Anton Dziatkovskii Date: Tue, 4 Aug 2026 14:39:05 +0200 Subject: [PATCH 15/70] docs: fix the streamable client snippet to use (*Client).Connect (#1142) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ### What was broken `docs/protocol.md` → *Streamable Transport* → client side shows: ```go client, err := mcp.Connect(ctx, transport, &mcp.ClientOptions{...}) ``` There is no top-level `mcp.Connect`. The package has `(*Client).Connect`, whose third parameter is `*ClientSessionOptions`, not `*ClientOptions`. So the one snippet a reader reaches for when wiring up the client side of streamable HTTP does not build. It is also the only outlier in the docs: `quick_start.md`, `client.md`, `server.md`, `troubleshooting.md` and `protocol.md` itself (lines 470, 538) all already use `client.Connect(ctx, transport, nil)`. ### How I checked Pasted the snippet verbatim into a fresh module requiring the released SDK (`github.com/modelcontextprotocol/go-sdk v1.7.0`): ``` $ go build ./... ./broken.go:15:21: undefined: mcp.Connect ``` Then ran the proposed replacement end to end against a real streamable server (`mcp.NewStreamableHTTPHandler` behind `httptest.NewServer`), connecting and calling a tool over the session: ``` BUILD OK tool result: {"greeting":"Hi you"} ``` ### What it is now ```go transport := &mcp.StreamableClientTransport{ Endpoint: "http://localhost:8080/mcp", } client := mcp.NewClient(&mcp.Implementation{Name: "client", Version: "v1.0.0"}, nil) session, err := client.Connect(ctx, transport, nil) ``` Change is in `internal/docs/protocol.src.md`; `docs/protocol.md` regenerated with `go generate ./internal/docs`, both committed together. Docs-only, no code paths touched. --- Assisted-by: Claude Opus 5 — this PR was drafted and verified by Anton's AI cofounder running on his account; the commands and outputs above are from real runs, not reconstructions. Co-authored-by: Anton Dzyatkovsky --- docs/protocol.md | 3 ++- internal/docs/protocol.src.md | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/docs/protocol.md b/docs/protocol.md index 35bf93b..edc1c11 100644 --- a/docs/protocol.md +++ b/docs/protocol.md @@ -256,7 +256,8 @@ connect to the server: transport := &mcp.StreamableClientTransport{ Endpoint: "http://localhost:8080/mcp", } -client, err := mcp.Connect(ctx, transport, &mcp.ClientOptions{...}) +client := mcp.NewClient(&mcp.Implementation{Name: "client", Version: "v1.0.0"}, nil) +session, err := client.Connect(ctx, transport, nil) ``` The `StreamableClientTransport` handles the HTTP requests and communicates with diff --git a/internal/docs/protocol.src.md b/internal/docs/protocol.src.md index dee819b..e26fcd7 100644 --- a/internal/docs/protocol.src.md +++ b/internal/docs/protocol.src.md @@ -181,7 +181,8 @@ connect to the server: transport := &mcp.StreamableClientTransport{ Endpoint: "http://localhost:8080/mcp", } -client, err := mcp.Connect(ctx, transport, &mcp.ClientOptions{...}) +client := mcp.NewClient(&mcp.Implementation{Name: "client", Version: "v1.0.0"}, nil) +session, err := client.Connect(ctx, transport, nil) ``` The `StreamableClientTransport` handles the HTTP requests and communicates with From 6b1518cb0bbbcb4644397e8bbdac0fc34e451b85 Mon Sep 17 00:00:00 2001 From: Guglielmo Colombo Date: Wed, 5 Aug 2026 12:16:39 +0200 Subject: [PATCH 16/70] mcp: return json-rpc error on stateful server serving protocolVersion20260728 requests (#1143) Fixes #1130 --- docs/mcpgodebug.md | 16 ++++++ internal/docs/mcpgodebug.src.md | 16 ++++++ mcp/streamable.go | 41 +++++++++++++-- mcp/streamable_test.go | 90 +++++++++++++++++++++++++++++++++ 4 files changed, 159 insertions(+), 4 deletions(-) diff --git a/docs/mcpgodebug.md b/docs/mcpgodebug.md index 4562821..39a76a8 100644 --- a/docs/mcpgodebug.md +++ b/docs/mcpgodebug.md @@ -18,6 +18,22 @@ ## `MCPGODEBUG` history +### 1.8.0 + +Options listed below were added and will be removed in the 1.9.0 version of the SDK. + +- `plaintextstatefulrejection` added. If set to `1`, a stateful + `StreamableHTTPHandler` will respond with a plain-text `http.Error` 400 body + when it receives a request carrying per-request metadata (i.e. an + `io.modelcontextprotocol/protocolVersion` `_meta` field, or an + `MCP-Protocol-Version` header >= `2026-07-28`), restoring the previous + behavior. The default behavior was changed so that the server responds with a + JSON-RPC error of code `CodeUnsupportedProtocolVersion` (`-32022`) carrying + an `UnsupportedProtocolVersionData` payload that advertises the legacy + versions the server supports. This lets the client's + existing renegotiation logic recover and prevents the failure from tearing + down the underlying connection. + ### 1.7.0 Options listed below were added and will be removed in the 1.9.0 version of the SDK. diff --git a/internal/docs/mcpgodebug.src.md b/internal/docs/mcpgodebug.src.md index c009a7c..efb02da 100644 --- a/internal/docs/mcpgodebug.src.md +++ b/internal/docs/mcpgodebug.src.md @@ -17,6 +17,22 @@ ## `MCPGODEBUG` history +### 1.8.0 + +Options listed below were added and will be removed in the 1.9.0 version of the SDK. + +- `plaintextstatefulrejection` added. If set to `1`, a stateful + `StreamableHTTPHandler` will respond with a plain-text `http.Error` 400 body + when it receives a request carrying per-request metadata (i.e. an + `io.modelcontextprotocol/protocolVersion` `_meta` field, or an + `MCP-Protocol-Version` header >= `2026-07-28`), restoring the previous + behavior. The default behavior was changed so that the server responds with a + JSON-RPC error of code `CodeUnsupportedProtocolVersion` (`-32022`) carrying + an `UnsupportedProtocolVersionData` payload that advertises the legacy + versions the server supports. This lets the client's + existing renegotiation logic recover and prevents the failure from tearing + down the underlying connection. + ### 1.7.0 Options listed below were added and will be removed in the 1.9.0 version of the SDK. diff --git a/mcp/streamable.go b/mcp/streamable.go index 2799d14..db81a37 100644 --- a/mcp/streamable.go +++ b/mcp/streamable.go @@ -314,6 +314,18 @@ var noprotocolerrorbody = mcpgodebug.Value("noprotocolerrorbody") // The option will be removed in the 1.8.0 version of the SDK. var disablecontenttypecheck = mcpgodebug.Value("disablecontenttypecheck") +// plaintextstatefulrejection is a compatibility parameter that restores the +// previous behavior of a stateful [StreamableHTTPHandler] when it receives a +// request carrying SEP-2575 per-request metadata (i.e. an +// `io.modelcontextprotocol/protocolVersion` `_meta` field, or an +// `MCP-Protocol-Version` header >= 2026-07-28). When unset (the default), the +// server responds with a JSON-RPC error of code +// [CodeUnsupportedProtocolVersion] and an [UnsupportedProtocolVersionData] +// payload listing its supported legacy versions, as required by SEP-2575. When +// set to "1", the server instead responds with a plain-text `http.Error` body +// mentioning the `StreamableHTTPOptions.Stateless` field. +var plaintextstatefulrejection = mcpgodebug.Value("plaintextstatefulrejection") + // 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. @@ -1519,10 +1531,31 @@ func (c *streamableServerConn) servePOST(w http.ResponseWriter, req *http.Reques // 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), - http.StatusBadRequest) + if plaintextstatefulrejection == "1" { + 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 + } + // Advertise only the legacy versions this stateful server accepts + // (i.e. exclude 2026-07-28, since that's what the request asked for + // and what we're rejecting). + legacyVersions := slices.DeleteFunc(slices.Clone(supportedProtocolVersions), func(v string) bool { + return v >= protocolVersion20260728 + }) + data, _ := json.Marshal(UnsupportedProtocolVersionData{ + Supported: legacyVersions, + Requested: protocolVersion, + }) + c.logger.Warn(fmt.Sprintf( + "rejecting request with protocol version %q: this server is stateful; set StreamableHTTPOptions.Stateless = true to accept it", + protocolVersion)) + writeJSONRPCError(w, http.StatusBadRequest, jreq.ID, &jsonrpc.Error{ + Code: CodeUnsupportedProtocolVersion, + Message: fmt.Sprintf("protocol version %q is not supported by this server", protocolVersion), + Data: data, + }) return } if headerVersion == "" { diff --git a/mcp/streamable_test.go b/mcp/streamable_test.go index 726733d..18d6b5f 100644 --- a/mcp/streamable_test.go +++ b/mcp/streamable_test.go @@ -3504,6 +3504,96 @@ func TestStreamableStateful_RejectsNewProtocol(t *testing.T) { if resp.StatusCode != http.StatusBadRequest { t.Fatalf("status = %d, want 400; body = %s", resp.StatusCode, respBody) } + msg, err := jsonrpc.DecodeMessage(respBody) + if err != nil { + t.Fatalf("decoding response body as JSON-RPC: %v; body = %s", err, respBody) + } + jresp, ok := msg.(*jsonrpc.Response) + if !ok || jresp.Error == nil { + t.Fatalf("response is not a JSON-RPC error: %s", respBody) + } + var jerr *jsonrpc.Error + if !errors.As(jresp.Error, &jerr) { + t.Fatalf("response error is not *jsonrpc.Error: %v", jresp.Error) + } + if jerr.Code != CodeUnsupportedProtocolVersion { + t.Errorf("error code = %d, want %d (CodeUnsupportedProtocolVersion)", jerr.Code, CodeUnsupportedProtocolVersion) + } + var data UnsupportedProtocolVersionData + if err := json.Unmarshal(jerr.Data, &data); err != nil { + t.Fatalf("decoding error data: %v; data = %s", err, jerr.Data) + } + if data.Requested != protocolVersion20260728 { + t.Errorf("data.Requested = %q, want %q", data.Requested, protocolVersion20260728) + } + if slices.Contains(data.Supported, protocolVersion20260728) { + t.Errorf("data.Supported = %v, must not contain the rejected version %q", + data.Supported, protocolVersion20260728) + } + if len(data.Supported) == 0 { + t.Errorf("data.Supported is empty; expected at least one legacy version") + } +} + +// TestStreamableStateful_RejectsNewProtocol_LegacyPlainText verifies that +// MCPGODEBUG=plaintextstatefulrejection=1 restores the pre-v1.8.0 plain-text +// 400 body for the stateful/new-protocol rejection. +func TestStreamableStateful_RejectsNewProtocol_LegacyPlainText(t *testing.T) { + prev := plaintextstatefulrejection + plaintextstatefulrejection = "1" + t.Cleanup(func() { plaintextstatefulrejection = prev }) + + 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() + + 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) + } + + 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, protocolVersion20260728) + 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 ct := resp.Header.Get("Content-Type"); !strings.HasPrefix(ct, "text/plain") { + t.Errorf("Content-Type = %q, want text/plain; body = %s", ct, respBody) + } if !strings.Contains(string(respBody), "stateless") { t.Errorf("body = %q, want a message mentioning 'stateless'", respBody) } From d462fbd303a16f3a8e65c78338a775e02b144fdf Mon Sep 17 00:00:00 2001 From: Suraj B Date: Wed, 5 Aug 2026 17:20:58 +0530 Subject: [PATCH 17/70] Disable scheduled nightly runs on forks (#1083) .github/workflows/nightly.yml: disable scheduled nightly runs on the forked repos User will still be able to run the workflow manually Fixes #1082 Co-authored-by: Guglielmo Colombo --- .github/workflows/nightly.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index 226627f..d60b6ff 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -11,6 +11,8 @@ permissions: jobs: vulncheck: + # Don't run scheduled builds on forks. + if: github.event_name != 'schedule' || github.repository == 'modelcontextprotocol/go-sdk' runs-on: ubuntu-latest steps: - name: Run govulncheck @@ -20,6 +22,8 @@ jobs: go-package: ./... conformance: + # Don't run scheduled builds on forks. + if: github.event_name != 'schedule' || github.repository == 'modelcontextprotocol/go-sdk' runs-on: ubuntu-latest steps: - name: Check out code From 958dfcc532d56e3aa4a5215d69bae2941995a384 Mon Sep 17 00:00:00 2001 From: dev_Hakaze Date: Wed, 5 Aug 2026 20:36:22 +0700 Subject: [PATCH 18/70] mcp: limit SSE discover supportedVersions to SSE-defined revisions (#1121) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary `NewSSEHandler` sessions were advertising `2026-07-28` in `server/discover` `supportedVersions`. That revision only defines stdio and Streamable HTTP, not the deprecated HTTP+SSE binding, so Modern-first clients could negotiate a revision the transport cannot honour. ## Changes - Implement `ProtocolVersionSupporter` on `SSEServerTransport` so `filterSupportedVersions` excludes `>= 2026-07-28` (matching the streamable transport's version gate pattern). - In `Server.discover`, only set session `InitializeParams` when the requested version is actually supported by the transport. Without this, a discover probe that returns only legacy versions still marked the session initialized, and the client's fallback `initialize` failed with `duplicate "initialize" received`. - Tests: unit coverage for filtered SSE versions, plus an end-to-end Modern client → SSE fallback-to-initialize path. Fixes #1112 ## Test plan - [x] `go test ./...` - [x] `go test ./mcp/ -run 'TestSSE|TestInMemory_E2E_Discover|TestStreamableStateful_AcceptsDiscover|TestStreamableHTTP_E2E_Discover|TestClientConnectDiscover'` Assistance: human-reviewed AI assistance used for implementation. --------- Co-authored-by: arimu1 <19286898+arimu1@users.noreply.github.com> --- mcp/sse.go | 7 ++++++ mcp/sse_test.go | 64 +++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 71 insertions(+) diff --git a/mcp/sse.go b/mcp/sse.go index 7efaa95..fb32756 100644 --- a/mcp/sse.go +++ b/mcp/sse.go @@ -189,6 +189,13 @@ func (t *SSEServerTransport) Connect(context.Context) (Connection, error) { return &sseServerConn{t: t}, nil } +// SupportsProtocolVersion reports whether the HTTP+SSE transport can serve the +// given protocol version. MCP 2026-07-28 defines only the stdio and Streamable +// HTTP bindings, so this (deprecated) transport does not advertise that revision. +func (t *SSEServerTransport) SupportsProtocolVersion(version string) bool { + return version < protocolVersion20260728 +} + func (h *SSEHandler) 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 diff --git a/mcp/sse_test.go b/mcp/sse_test.go index 86b2cf1..c0ff445 100644 --- a/mcp/sse_test.go +++ b/mcp/sse_test.go @@ -12,12 +12,76 @@ import ( "net" "net/http" "net/http/httptest" + "slices" "sync/atomic" "testing" "github.com/google/go-cmp/cmp" ) +// TestSSEServerTransport_SupportedVersions verifies that the deprecated +// HTTP+SSE transport does not advertise protocol revisions that only define +// stdio and Streamable HTTP (see #1112). +func TestSSEServerTransport_SupportedVersions(t *testing.T) { + var tr SSEServerTransport + versions := filterSupportedVersions(&tr) + if slices.Contains(versions, protocolVersion20260728) { + t.Errorf("filterSupportedVersions(SSEServerTransport) = %v, must not include %q", + versions, protocolVersion20260728) + } + if !slices.Contains(versions, protocolVersion20241105) { + t.Errorf("filterSupportedVersions(SSEServerTransport) = %v, want %q (SSE-defined revision)", + versions, protocolVersion20241105) + } + if len(versions) == 0 { + t.Error("filterSupportedVersions(SSEServerTransport) is empty; want legacy SSE revisions") + } + for _, v := range supportedProtocolVersions { + got := tr.SupportsProtocolVersion(v) + want := v < protocolVersion20260728 + if got != want { + t.Errorf("SupportsProtocolVersion(%q) = %v, want %v", v, got, want) + } + } +} + +// TestSSEHandler_DiscoverFallsBackToInitialize checks that a Modern-first +// client connecting over HTTP+SSE does not stick on 2026-07-28: discover +// advertises only SSE-defined revisions, and Connect falls back to legacy +// initialize successfully. +func TestSSEHandler_DiscoverFallsBackToInitialize(t *testing.T) { + ctx := context.Background() + server := NewServer(testImpl, nil) + sseHandler := NewSSEHandler(func(*http.Request) *Server { return server }, nil) + httpServer := httptest.NewServer(sseHandler) + defer httpServer.Close() + + client := NewClient(testImpl, nil) + // Default Client.Connect probes with the latest protocol version (2026-07-28). + cs, err := client.Connect(ctx, &SSEClientTransport{Endpoint: httpServer.URL}, nil) + if err != nil { + t.Fatalf("Connect over SSE with modern client: %v", err) + } + defer cs.Close() + + ir := cs.InitializeResult() + if ir == nil { + t.Fatal("InitializeResult is nil after Connect") + } + if ir.ProtocolVersion == protocolVersion20260728 { + t.Errorf("InitializeResult.ProtocolVersion = %q; HTTP+SSE must not negotiate that revision", + ir.ProtocolVersion) + } + if ir.ProtocolVersion != protocolVersion20251125 { + // Client.Connect falls back to protocolVersion20251125 for legacy initialize. + t.Errorf("InitializeResult.ProtocolVersion = %q, want %q (legacy fallback)", + ir.ProtocolVersion, protocolVersion20251125) + } + if err := cs.Ping(ctx, nil); err != nil { + t.Errorf("Ping after SSE fallback initialize: %v", err) + } +} + func TestSSEServer(t *testing.T) { for _, closeServerFirst := range []bool{false, true} { t.Run(fmt.Sprintf("closeServerFirst=%t", closeServerFirst), func(t *testing.T) { From 71dfbc0d3ee201388d2a1db8428b7efe92ed64ca Mon Sep 17 00:00:00 2001 From: Anton Dziatkovskii Date: Thu, 6 Aug 2026 16:27:46 +0200 Subject: [PATCH 19/70] docs: quick start `go get` leaves the module unbuildable (#1148) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ### What was broken `docs/quick_start.md` → *Installation* says: ``` go get github.com/modelcontextprotocol/go-sdk ``` The module root holds no importable package (only `copyright_test.go`), so `go get` on the module path records the requirement as `// indirect` and writes none of the `go.sum` entries the `mcp` package needs. A reader who runs that command and then pastes the server snippet from the next section hits six errors on the first build — before writing a line of their own. ### How I checked Followed the page verbatim on Go 1.26.4, in an empty directory: ``` $ go mod init example.com/qs $ go get github.com/modelcontextprotocol/go-sdk go: added github.com/modelcontextprotocol/go-sdk v1.7.0 ``` `go.mod` now reads `require github.com/modelcontextprotocol/go-sdk v1.7.0 // indirect`. Then the two snippets from *Getting started*, saved as `server/main.go` and `client/main.go`: ``` $ go build ./... missing go.sum entry for module providing package github.com/google/jsonschema-go/jsonschema (imported by github.com/modelcontextprotocol/go-sdk/mcp) missing go.sum entry for module providing package golang.org/x/oauth2 (imported by github.com/modelcontextprotocol/go-sdk/mcp) missing go.sum entry for module providing package github.com/segmentio/encoding/json (imported by github.com/modelcontextprotocol/go-sdk/internal/json) missing go.sum entry for module providing package github.com/yosida95/uritemplate/v3 (imported by github.com/modelcontextprotocol/go-sdk/mcp) missing go.sum entry for module providing package golang.org/x/sync/errgroup (imported by github.com/modelcontextprotocol/go-sdk/mcp) missing go.sum entry for module providing package golang.org/x/time/rate (imported by github.com/modelcontextprotocol/go-sdk/mcp) ``` Same directory, same snippets, asking for the package instead: ``` $ go get github.com/modelcontextprotocol/go-sdk/mcp $ go build -o myserver ./server && go build -o myclient ./client $ ./myclient {"greeting":"Hi you"} ``` `go mod tidy` after the fact fixes it too — that is the escape hatch the note mentions, but it is not what the page tells you to do. ### What it is now ``` go get github.com/modelcontextprotocol/go-sdk/mcp ``` plus four lines saying why the module path alone is not enough, so the next person who copies the module path from `go.mod` or from a search result knows what they are looking at. Change is in `internal/docs/quick_start.src.md`; `docs/quick_start.md` regenerated with `go generate ./internal/docs`, both committed together. Docs-only, no code paths touched. --- Assisted-by: Claude Opus 5 — this PR was drafted and verified by Anton's AI cofounder running on his account; every command and output above is from a real run on this machine, not a reconstruction. --------- Co-authored-by: Palo-Alto-AI-Research-Lab Co-authored-by: Claude Fable 5 --- docs/quick_start.md | 2 +- internal/docs/quick_start.src.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/quick_start.md b/docs/quick_start.md index c76c58c..995b378 100644 --- a/docs/quick_start.md +++ b/docs/quick_start.md @@ -7,7 +7,7 @@ ## Installation ``` -go get github.com/modelcontextprotocol/go-sdk +go get github.com/modelcontextprotocol/go-sdk/mcp ``` ## Getting started diff --git a/internal/docs/quick_start.src.md b/internal/docs/quick_start.src.md index 7b1849f..c0f1546 100644 --- a/internal/docs/quick_start.src.md +++ b/internal/docs/quick_start.src.md @@ -5,7 +5,7 @@ ## Installation ``` -go get github.com/modelcontextprotocol/go-sdk +go get github.com/modelcontextprotocol/go-sdk/mcp ``` ## Getting started From 039119057f1bdaa803a9ae4a252f3f280ed4a127 Mon Sep 17 00:00:00 2001 From: Guglielmo Colombo Date: Fri, 7 Aug 2026 10:43:17 +0200 Subject: [PATCH 20/70] mcp: allow asynchronous cancel notification (#1151) Fixes #1150 --- docs/mcpgodebug.md | 10 +++++ internal/docs/mcpgodebug.src.md | 10 +++++ mcp/streamable_test.go | 73 +++++++++++++++++++++++++++++++++ mcp/transport.go | 32 ++++++++++++++- 4 files changed, 123 insertions(+), 2 deletions(-) diff --git a/docs/mcpgodebug.md b/docs/mcpgodebug.md index 39a76a8..d25b9b4 100644 --- a/docs/mcpgodebug.md +++ b/docs/mcpgodebug.md @@ -34,6 +34,16 @@ Options listed below were added and will be removed in the 1.9.0 version of the existing renegotiation logic recover and prevents the failure from tearing down the underlying connection. +- `blockingcancelnotify` added. If set to `1`, a cancelled call waits + synchronously for the best-effort `notifications/cancelled` message to be + delivered (up to `notifyCancellationTimeout`, currently 5 s) before + returning to the caller, restoring the previous behavior. The delivery + error is joined into the caller's returned error. The default behavior was + changed so that the call is retired immediately and the notification is + sent asynchronously off the caller's return path: the caller returns as + soon as its context is cancelled and cannot be delayed by a slow or + unresponsive peer. See issue #1150. + ### 1.7.0 Options listed below were added and will be removed in the 1.9.0 version of the SDK. diff --git a/internal/docs/mcpgodebug.src.md b/internal/docs/mcpgodebug.src.md index efb02da..32f8af2 100644 --- a/internal/docs/mcpgodebug.src.md +++ b/internal/docs/mcpgodebug.src.md @@ -33,6 +33,16 @@ Options listed below were added and will be removed in the 1.9.0 version of the existing renegotiation logic recover and prevents the failure from tearing down the underlying connection. +- `blockingcancelnotify` added. If set to `1`, a cancelled call waits + synchronously for the best-effort `notifications/cancelled` message to be + delivered (up to `notifyCancellationTimeout`, currently 5 s) before + returning to the caller, restoring the previous behavior. The delivery + error is joined into the caller's returned error. The default behavior was + changed so that the call is retired immediately and the notification is + sent asynchronously off the caller's return path: the caller returns as + soon as its context is cancelled and cannot be delayed by a slow or + unresponsive peer. See issue #1150. + ### 1.7.0 Options listed below were added and will be removed in the 1.9.0 version of the SDK. diff --git a/mcp/streamable_test.go b/mcp/streamable_test.go index 18d6b5f..504cda6 100644 --- a/mcp/streamable_test.go +++ b/mcp/streamable_test.go @@ -3599,6 +3599,79 @@ func TestStreamableStateful_RejectsNewProtocol_LegacyPlainText(t *testing.T) { } } +// TestCallCancellation_FastReturn verifies that a cancelled tool call +// returns as soon as its context is cancelled, even when the peer is slow +// to accept the follow-up notifications/cancelled POST. +func TestCallCancellation_FastReturn(t *testing.T) { + // stallDuration is the time the middleware holds a + // notifications/cancelled POST. It must be larger than + // notifyCancellationTimeout so the blocking branch clearly overshoots + // even after cushioning for scheduler jitter. + const stallDuration = notifyCancellationTimeout + 2*time.Second + + // callerDeadline is the deadline the client passes into CallTool. It + // must be short enough that any waiting on the cancel notification is + // obvious in the measured elapsed time. + const callerDeadline = 100 * time.Millisecond + + // slack accounts for scheduling jitter around the deadline. + const slack = 500 * time.Millisecond + + stallCancelNotify := func(h http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodPost && r.Body != nil { + body, _ := io.ReadAll(r.Body) + r.Body = io.NopCloser(bytes.NewReader(body)) + if bytes.Contains(body, []byte(notificationCancelled)) { + time.Sleep(stallDuration) + } + } + h.ServeHTTP(w, r) + }) + } + + server := NewServer(testImpl, nil) + AddTool(server, &Tool{Name: "slow"}, + func(ctx context.Context, req *CallToolRequest, args struct{}) (*CallToolResult, any, error) { + // Bound the handler above the caller deadline so the test + // always exercises the cancellation path, but not so long + // that a stuck cancellation makes the test slow. + select { + case <-ctx.Done(): + case <-time.After(2 * stallDuration): + } + return &CallToolResult{Content: []Content{&TextContent{Text: "ok"}}}, nil, nil + }) + + handler := NewStreamableHTTPHandler(func(*http.Request) *Server { return server }, nil) + httpServer := httptest.NewServer(stallCancelNotify(handler)) + t.Cleanup(httpServer.Close) + + client := NewClient(testImpl, nil) + cs, err := client.Connect(context.Background(), + &StreamableClientTransport{Endpoint: httpServer.URL}, nil) + if err != nil { + t.Fatalf("client connect: %v", err) + } + t.Cleanup(func() { cs.Close() }) + + callCtx, cancel := context.WithTimeout(context.Background(), callerDeadline) + defer cancel() + + start := time.Now() + _, err = cs.CallTool(callCtx, &CallToolParams{Name: "slow"}) + elapsed := time.Since(start) + + if !errors.Is(err, context.DeadlineExceeded) { + t.Fatalf("CallTool error = %v, want context.DeadlineExceeded", err) + } + // The caller should return within slack of its own deadline. + if elapsed > callerDeadline+slack { + t.Errorf("CallTool returned after %v; want <= %v (caller deadline %v + slack %v)", + elapsed, callerDeadline+slack, callerDeadline, slack) + } +} + // TestStreamableStateless_AcceptsNewProtocol is the positive control: // confirms that a stateless server still accepts new-protocol requests // (the rejection in TestStreamableStateful_RejectsNewProtocol must not diff --git a/mcp/transport.go b/mcp/transport.go index 55c73d7..d72f9d1 100644 --- a/mcp/transport.go +++ b/mcp/transport.go @@ -18,6 +18,7 @@ import ( internaljson "github.com/modelcontextprotocol/go-sdk/internal/json" "github.com/modelcontextprotocol/go-sdk/internal/jsonrpc2" + "github.com/modelcontextprotocol/go-sdk/internal/mcpgodebug" "github.com/modelcontextprotocol/go-sdk/jsonrpc" ) @@ -28,6 +29,13 @@ import ( // re-trigger expensive recovery on its behalf. See issue #882. const notifyCancellationTimeout = 5 * time.Second +// blockingcancelnotify, when set to "1" via MCPGODEBUG, restores the previous +// behavior of blocking the caller's return on delivery of the best-effort +// notifications/cancelled message (up to notifyCancellationTimeout). By +// default, the call is retired immediately and the notification is sent +// asynchronously so the caller returns as soon as its context is cancelled. +var blockingcancelnotify = mcpgodebug.Value("blockingcancelnotify") + // ErrConnectionClosed is returned when sending a message to a connection that // is closed or in the process of closing. var ErrConnectionClosed = errors.New("connection closed") @@ -276,8 +284,28 @@ func call(ctx context.Context, conn *jsonrpc2.Connection, method string, params case errors.Is(err, jsonrpc2.ErrClientClosing), errors.Is(err, jsonrpc2.ErrServerClosing): return fmt.Errorf("%w: calling %q: %v", ErrConnectionClosed, method, err) case ctx.Err() != nil: - err := cancelCall(ctx, conn, call) - return errors.Join(ctx.Err(), err) + // The notifications/cancelled message is best-effort. Retire the call + // immediately (so an unresponsive peer cannot delay the eager + // retirement that cancelCall's docstring promises) and send the + // notification off the caller's return path so a slow or unresponsive + // peer cannot delay the caller past its own deadline. See issue #1150. + // + // Setting MCPGODEBUG=blockingcancelnotify=1 restores the previous + // behavior of waiting synchronously for delivery inside cancelCall. + if blockingcancelnotify == "1" { + err := cancelCall(ctx, conn, call) + return errors.Join(ctx.Err(), err) + } + conn.Retire(call, ctx.Err()) + go func() { + notifyCtx, stop := context.WithTimeout(context.WithoutCancel(ctx), notifyCancellationTimeout) + defer stop() + _ = conn.Notify(notifyCtx, notificationCancelled, &CancelledParams{ + Reason: ctx.Err().Error(), + RequestID: call.ID().Raw(), + }) + }() + return ctx.Err() case err != nil: return fmt.Errorf("calling %q: %w", method, err) } From e9a85e285f42799bf7e85ff0e7654788bafd272c Mon Sep 17 00:00:00 2001 From: Recoordinate <296084221+latent-9@users.noreply.github.com> Date: Mon, 10 Aug 2026 19:10:06 +1200 Subject: [PATCH 21/70] mcp: fix typo in protocol version comment (#1156) `protcol` should be `protocol`. The comment sits above the `Mcp-Protocol-Version` header lookup, and the surrounding code uses `protocolVersion` and `protocolVersionFromContext`, so this is the only place in the package where the word is misspelled. --- mcp/streamable.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mcp/streamable.go b/mcp/streamable.go index db81a37..b2190fa 100644 --- a/mcp/streamable.go +++ b/mcp/streamable.go @@ -1524,7 +1524,7 @@ func (c *streamableServerConn) servePOST(w http.ResponseWriter, req *http.Reques metaVersion, _ = meta[MetaKeyProtocolVersion].(string) } if protocolVersion >= protocolVersion20260728 || metaVersion != "" { - // Extract again the protcol version from the context to see what the client + // Extract again the protocol version from the context to see what the client // is advertising in the Mcp-Protocol-Version HTTP header. headerVersion := protocolVersionFromContext(req.Context()) // server/discover is exempt from the stateful From 4282916668c3bb682eee20f8bf164158379a17c0 Mon Sep 17 00:00:00 2001 From: Guglielmo Colombo Date: Mon, 10 Aug 2026 10:52:50 +0200 Subject: [PATCH 22/70] mcp: avoid Client.Connect leaks (#1158) Fixes #1154 --- mcp/client.go | 1 + 1 file changed, 1 insertion(+) diff --git a/mcp/client.go b/mcp/client.go index 2ad0ea4..7403799 100644 --- a/mcp/client.go +++ b/mcp/client.go @@ -389,6 +389,7 @@ func (c *Client) Connect(ctx context.Context, t Transport, opts *ClientSessionOp return nil, err } if !slices.Contains(supportedProtocolVersions, res.ProtocolVersion) { + _ = cs.Close() return nil, unsupportedProtocolVersionError{res.ProtocolVersion} } cs.state.InitializeResult = res From 64e454e35c23c473e1fcf1e1c3a6f623260ca773 Mon Sep 17 00:00:00 2001 From: "Tony Dzi (Anton Dziatkovskii)" Date: Thu, 13 Aug 2026 10:49:56 +0200 Subject: [PATCH 23/70] docs: fix four dead links to the Multi Round-Trip Requests section (#1159) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ### What was broken Four links in `docs/client.md` point at a section that does not exist. Three of them sit right where a reader needs them — at the end of the **Roots**, **Sampling** and **Elicitation** sections, on the sentence explaining that from protocol version `2026-07-28` these requests are delivered via the Multi Round-Trip Requests pattern: ``` [Multi Round-Trip Requests](protocol.md#multi-round-trip-requests-mrtr) ``` `protocol.md` has no MRTR section. Following the link drops you at the top of a page that never mentions the pattern — while the explanation you were promised is one screen further down the *same* page you were already on (`client.md` → *Multi Round-Trip Requests*). The fourth one is in that MRTR section itself, sending you to the server side: ``` [server-side documentation](server.md#multi-round-trip-requests-mrtr). ``` `server.md` does have the section, but its heading is `## Multi Round-Trip Requests`, so the slug is `#multi-round-trip-requests` — the trailing `-mrtr` is stale. ### How I checked Against the published site, not just the source: ``` $ curl -sL https://go.sdk.modelcontextprotocol.io/protocol | grep -o 'id="[^"]*round[^"]*"' # (no output) $ curl -sL https://go.sdk.modelcontextprotocol.io/client | grep -o 'id="[^"]*round[^"]*"' id="multi-round-trip-requests" $ curl -sL https://go.sdk.modelcontextprotocol.io/server | grep -o 'id="[^"]*round[^"]*"' id="multi-round-trip-requests" ``` So `/protocol` carries no anchor containing `round` at all, and both live targets use the plain slug. `git log -S` says the links arrived with b9e6fc5 ("mcp: add documentation for new protocol version", #1019, 2026-06-23) and that `protocol.src.md` has never contained a `Multi Round-Trip Requests` heading — the section landed in `client.src.md` and `server.src.md` instead, and these four references were never repointed. I also ran a small link/anchor checker over `docs/`: 4 bad anchors before, 0 after. (Two "broken links" it also reports in `server.md` are its own false positives — Go generics inside a fence, `jsonschema.For[WeatherInput](opts)`, read as markdown link syntax. Not touched.) ### What it is now The three per-feature links become same-page references, matching how `client.md` already links its own sections (`(see [Capabilities](#capabilities))`): ``` [Multi Round-Trip Requests](#multi-round-trip-requests) ``` and the cross-page one drops the stale suffix: ``` [server-side documentation](server.md#multi-round-trip-requests). ``` Change is in `internal/docs/client.src.md`; `docs/client.md` regenerated with `go generate ./...`, both committed together. Locally `go generate ./...` leaves the tree clean afterwards, which is what `docs-check` asserts. Docs-only, four link targets, no prose and no code paths touched. --- Assisted-by: Claude Opus 5 — this PR was drafted and verified by Anton's AI cofounder running on his account; every command and output above is from a real run on this machine, not a reconstruction. Co-authored-by: tonydzi --- docs/client.md | 8 ++++---- internal/docs/client.src.md | 8 ++++---- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/docs/client.md b/docs/client.md index 13169d6..fe9a963 100644 --- a/docs/client.md +++ b/docs/client.md @@ -38,7 +38,7 @@ method. To receive notifications about root changes, set [`ServerOptions.RootsListChangedHandler`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ServerOptions.RootsListChangedHandler). For protocol versions `2026-07-28` and later, `ListRoots` requests are delivered via the -[Multi Round-Trip Requests](protocol.md#multi-round-trip-requests-mrtr) +[Multi Round-Trip Requests](#multi-round-trip-requests) pattern. ```go @@ -113,7 +113,7 @@ This function is invoked whenever the server requests sampling. For protocol versions `2026-07-28` and later, sampling requests are delivered via the -[Multi Round-Trip Requests](protocol.md#multi-round-trip-requests-mrtr) +[Multi Round-Trip Requests](#multi-round-trip-requests) pattern. ```go @@ -183,7 +183,7 @@ you must declare that capability explicitly (see [Capabilities](#capabilities)) For protocol versions `2026-07-28` and later, elicitation requests are delivered via the -[Multi Round-Trip Requests](protocol.md#multi-round-trip-requests-mrtr) +[Multi Round-Trip Requests](#multi-round-trip-requests) pattern. ```go @@ -268,7 +268,7 @@ For legacy (`<= 2025-11-25`) servers, the SDK transparently sends server requests on the legacy server-initiated channel; the MRTR machinery is a no-op in that direction. For legacy clients talking to MRTR-style servers, the server SDK applies the inverse compatibility shim — see the -[server-side documentation](server.md#multi-round-trip-requests-mrtr). +[server-side documentation](server.md#multi-round-trip-requests). ## Capabilities diff --git a/internal/docs/client.src.md b/internal/docs/client.src.md index 6d14ee9..5c4b8f9 100644 --- a/internal/docs/client.src.md +++ b/internal/docs/client.src.md @@ -30,7 +30,7 @@ method. To receive notifications about root changes, set [`ServerOptions.RootsListChangedHandler`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ServerOptions.RootsListChangedHandler). For protocol versions `2026-07-28` and later, `ListRoots` requests are delivered via the -[Multi Round-Trip Requests](protocol.md#multi-round-trip-requests-mrtr) +[Multi Round-Trip Requests](#multi-round-trip-requests) pattern. %include ../../mcp/client_example_test.go roots - @@ -57,7 +57,7 @@ This function is invoked whenever the server requests sampling. For protocol versions `2026-07-28` and later, sampling requests are delivered via the -[Multi Round-Trip Requests](protocol.md#multi-round-trip-requests-mrtr) +[Multi Round-Trip Requests](#multi-round-trip-requests) pattern. %include ../../mcp/client_example_test.go sampling - @@ -79,7 +79,7 @@ you must declare that capability explicitly (see [Capabilities](#capabilities)) For protocol versions `2026-07-28` and later, elicitation requests are delivered via the -[Multi Round-Trip Requests](protocol.md#multi-round-trip-requests-mrtr) +[Multi Round-Trip Requests](#multi-round-trip-requests) pattern. %include ../../mcp/client_example_test.go elicitation - @@ -117,7 +117,7 @@ For legacy (`<= 2025-11-25`) servers, the SDK transparently sends server requests on the legacy server-initiated channel; the MRTR machinery is a no-op in that direction. For legacy clients talking to MRTR-style servers, the server SDK applies the inverse compatibility shim — see the -[server-side documentation](server.md#multi-round-trip-requests-mrtr). +[server-side documentation](server.md#multi-round-trip-requests). ## Capabilities From 0eb1600eef060d0ef75e8df648b568ad4f358323 Mon Sep 17 00:00:00 2001 From: Recoordinate <296084221+latent-9@users.noreply.github.com> Date: Mon, 17 Aug 2026 20:44:53 +1200 Subject: [PATCH 24/70] mcp: avoid panicking when a resource handler returns nil (#1176) ### What `readResource` dereferenced the handler's result before the `if res == nil` check that was meant to guard it. A `ResourceHandler` returning `(nil, nil)` therefore panicked instead of returning an error: - modern clients: `handleMultiRoundTripResult` calls `res.setResultType` on the nil result, and - older clients: `res.setDefaultCacheableValues()` runs on the nil result. Either way the `if res == nil` guard a few lines below was dead code. ### Why it matters Request handlers run in their own goroutine and nothing recovers, so the panic does not just fail the one request. It terminates the whole server process and every concurrent session. The sibling handlers already avoid this: `callTool` and `getPrompt` both guard their result with `if err == nil && res != nil` before touching it, so a `(nil, nil)` return flows up and becomes a normal error response. ### Change Move the nil check in `readResource` ahead of `handleMultiRoundTripResult` and `setDefaultCacheableValues`, so a nil result returns the existing descriptive error (`read handler returned nil information`) instead of panicking. Added a regression test that drives a resource handler returning `(nil, nil)` through the server and asserts a clean error. --- mcp/error_test.go | 26 ++++++++++++++++++++++++++ mcp/server.go | 5 ++++- 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/mcp/error_test.go b/mcp/error_test.go index 2b68d75..898562c 100644 --- a/mcp/error_test.go +++ b/mcp/error_test.go @@ -127,6 +127,32 @@ func TestResourceNotFoundErrorCode(t *testing.T) { } } +// TestReadResourceNilResult verifies that a resource handler returning +// (nil, nil) yields a clean error to the client instead of a nil pointer +// panic that crashes the server. The sibling handlers callTool and getPrompt +// already tolerate a (nil, nil) return; readResource must do the same. +func TestReadResourceNilResult(t *testing.T) { + ctx := context.Background() + + cs, _, cleanup := basicConnection(t, func(s *Server) { + s.AddResource( + &Resource{URI: "file:///nil.txt", Name: "nil", MIMEType: "text/plain"}, + func(ctx context.Context, req *ReadResourceRequest) (*ReadResourceResult, error) { + return nil, nil + }, + ) + }) + defer cleanup() + + _, err := cs.ReadResource(ctx, &ReadResourceParams{URI: "file:///nil.txt"}) + if err == nil { + t.Fatal("got nil error, want non-nil error for nil resource result") + } + if !strings.Contains(err.Error(), "nil information") { + t.Errorf("got error %q, want it to mention 'nil information'", err.Error()) + } +} + // TestInputValidationToolError validates that input validation errors (missing // required params, wrong types) are returned as tool results with IsError=true, // not as JSON-RPC errors. This allows LLMs to see the error and self-correct. diff --git a/mcp/server.go b/mcp/server.go index c189a8e..c6d1270 100644 --- a/mcp/server.go +++ b/mcp/server.go @@ -1035,6 +1035,9 @@ func (s *Server) readResource(ctx context.Context, req *ReadResourceRequest) (*R if err != nil { return nil, err } + if res == nil { + return nil, fmt.Errorf("reading resource %s: read handler returned nil information", uri) + } if err := handleMultiRoundTripResult(req.Session, s.opts.Logger, res); err != nil { return nil, err } @@ -1042,7 +1045,7 @@ func (s *Server) readResource(ctx context.Context, req *ReadResourceRequest) (*R if res.resultType == resultTypeInputRequired { return res, nil } - if res == nil || res.Contents == nil { + if res.Contents == nil { return nil, fmt.Errorf("reading resource %s: read handler returned nil information", uri) } // As a convenience, populate some fields. From ba1496a9aaddd2d9def43c139de30ffa77223352 Mon Sep 17 00:00:00 2001 From: King Star Date: Mon, 17 Aug 2026 20:00:41 +0800 Subject: [PATCH 25/70] mcp: keep request log levels scoped to each request (#1168) Fixes a request-scoping bug in the new-protocol logging path. `ServerSession.handle` decoded `_meta[io.modelcontextprotocol/logLevel]`, but stored it in `ServerSessionState.LogLevel`. Concurrent new-protocol requests on the same session could therefore clear or inherit another request's log threshold before the original handler emitted logs. This keeps the new-protocol log level on the handler context instead. `ServerSession.Log` and `LoggingHandler.Enabled` read that context value first, while legacy `logging/setLevel` behavior still uses session state. Tests: - `go test ./mcp -run TestServerLogLevelDoesNotLeakBetweenNewProtocolRequests -count=1` - `go test -race ./mcp -run TestServerLogLevelDoesNotLeakBetweenNewProtocolRequests -count=1` - `go test ./mcp -run TestEndToEnd -count=1` - `go test ./mcp -run TestValidateRequestMeta -count=1` - `go test ./mcp -run TestStreamableStateless_NewProtocolSession_NoFakeInit -count=1` - `go test ./... -skip TestSSELocalhostProtection|TestStreamableLocalhostProtection` A full `go test ./...` run in my local environment still fails the two localhost protection cases for `0.0.0.0 via localhost rejects evil.com`; those same two tests also fail when run directly and are unrelated to this logging change. Co-authored-by: Guglielmo Colombo --- mcp/logging.go | 10 +++++++ mcp/mcp_test.go | 76 +++++++++++++++++++++++++++++++++++++++++++++++++ mcp/server.go | 12 ++++---- 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/mcp/logging.go b/mcp/logging.go index 402e243..e8d0cd7 100644 --- a/mcp/logging.go +++ b/mcp/logging.go @@ -68,6 +68,13 @@ func compareLevels(l1, l2 LoggingLevel) int { return cmp.Compare(mcpLevelToSlog(l1), mcpLevelToSlog(l2)) } +type logLevelContextKey struct{} + +func logLevelFromContext(ctx context.Context) (LoggingLevel, bool) { + v, ok := ctx.Value(logLevelContextKey{}).(LoggingLevel) + return v, ok +} + // LoggingHandlerOptions are options for a LoggingHandler. // // Deprecated: the logging feature is deprecated as of protocol version @@ -146,6 +153,9 @@ func NewLoggingHandler(ss *ServerSession, opts *LoggingHandlerOptions) *LoggingH func (h *LoggingHandler) Enabled(ctx context.Context, level slog.Level) bool { // This is also checked in ServerSession.LoggingMessage, so checking it here // is just an optimization that skips building the JSON. + if mcpLevel, ok := logLevelFromContext(ctx); ok { + return mcpLevel != "" && level >= mcpLevelToSlog(mcpLevel) + } h.ss.mu.Lock() mcpLevel := h.ss.state.LogLevel h.ss.mu.Unlock() diff --git a/mcp/mcp_test.go b/mcp/mcp_test.go index d9d9b3a..ade30de 100644 --- a/mcp/mcp_test.go +++ b/mcp/mcp_test.go @@ -3439,3 +3439,79 @@ func TestCallCustomMethodTypedNilParams(t *testing.T) { t.Fatalf("CallCustomMethod with typed-nil params: %v", err) } } + +func TestServerLogLevelDoesNotLeakBetweenNewProtocolRequests(t *testing.T) { + ctx := context.Background() + s := NewServer(testImpl, nil) + _, st := NewInMemoryTransports() + ss, err := s.Connect(ctx, st, nil) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = ss.Close() }) + + logged := make(chan LoggingLevel, 1) + s.AddSendingMiddleware(func(next MethodHandler) MethodHandler { + return func(ctx context.Context, method string, req Request) (Result, error) { + if method == notificationLoggingMessage { + logged <- req.GetParams().(*LoggingMessageParams).Level + return nil, nil + } + return next(ctx, method, req) + } + }) + + started := make(chan struct{}) + release := make(chan struct{}) + AddTool(s, &Tool{Name: "blocked-log"}, func(ctx context.Context, req *CallToolRequest, args any) (*CallToolResult, any, error) { + close(started) + <-release + if err := req.Session.Log(ctx, &LoggingMessageParams{Level: "warning", Data: "request log"}); err != nil { + return nil, nil, err + } + return &CallToolResult{Content: []Content{&TextContent{Text: "ok"}}}, nil, nil + }) + AddTool(s, &Tool{Name: "noop"}, func(ctx context.Context, req *CallToolRequest, args any) (*CallToolResult, any, error) { + return &CallToolResult{Content: []Content{&TextContent{Text: "ok"}}}, nil, nil + }) + + withLogLevel := &CallToolParams{Name: "blocked-log"} + withLogLevel.SetMeta(newProtocolMeta("warning")) + errc := make(chan error, 1) + go func() { + _, err := ss.handle(ctx, req(1, methodCallTool, withLogLevel)) + errc <- err + }() + + <-started + withoutLogLevel := &CallToolParams{Name: "noop"} + withoutLogLevel.SetMeta(newProtocolMeta("")) + if _, err := ss.handle(ctx, req(2, methodCallTool, withoutLogLevel)); err != nil { + t.Fatal(err) + } + close(release) + if err := <-errc; err != nil { + t.Fatal(err) + } + + select { + case got := <-logged: + if got != "warning" { + t.Fatalf("logged level = %q, want warning", got) + } + default: + t.Fatal("request-scoped warning log was suppressed after another request cleared session log level") + } +} + +func newProtocolMeta(logLevel LoggingLevel) Meta { + m := Meta{ + MetaKeyProtocolVersion: protocolVersion20260728, + MetaKeyClientInfo: testImpl, + MetaKeyClientCapabilities: (&ClientCapabilities{}).toV2(), + } + if logLevel != "" { + m[MetaKeyLogLevel] = logLevel + } + return m +} diff --git a/mcp/server.go b/mcp/server.go index c6d1270..4cdd6b9 100644 --- a/mcp/server.go +++ b/mcp/server.go @@ -1739,9 +1739,12 @@ func (ss *ServerSession) Elicit(ctx context.Context, params *ElicitParams) (*Eli // (at least twelve months). See // https://modelcontextprotocol.io/seps/2577-deprecate-roots-sampling-and-logging. func (ss *ServerSession) Log(ctx context.Context, params *LoggingMessageParams) error { - ss.mu.Lock() - logLevel := ss.state.LogLevel - ss.mu.Unlock() + logLevel, ok := logLevelFromContext(ctx) + if !ok { + ss.mu.Lock() + logLevel = ss.state.LogLevel + ss.mu.Unlock() + } if logLevel == "" { // The spec is unclear, but seems to imply that no log messages are sent until the client // sets the level. @@ -1929,9 +1932,8 @@ func (ss *ServerSession) handle(ctx context.Context, req *jsonrpc.Request) (any, // server->client calls and notifications to the incoming request from which // they originated. See [idContextKey] for details. ctx = context.WithValue(ctx, idContextKey{}, req.ID) - // For new-protocol requests, propagate the per-request log level. if validatedMeta.usesNewProtocol { - ss.setLevel(ctx, &SetLoggingLevelParams{Level: validatedMeta.logLevel}) + ctx = context.WithValue(ctx, logLevelContextKey{}, validatedMeta.logLevel) } res, err := handleReceive(ctx, ss, req) if err != nil { From 1fcaf0c39375a74950f06f41f0d91c81d778df3e Mon Sep 17 00:00:00 2001 From: Guglielmo Colombo Date: Mon, 17 Aug 2026 14:21:13 +0200 Subject: [PATCH 26/70] mcp: resolve deadlock on serverSession.Close() with subscriptionsListen call (#1166) Fixes #1160 --- mcp/mcp_test.go | 59 +++++++++++++++++++++++++++++++++++++++++++++++++ mcp/server.go | 29 ++++++++++++++++++++++++ 2 files changed, 88 insertions(+) diff --git a/mcp/mcp_test.go b/mcp/mcp_test.go index ade30de..8e2f06d 100644 --- a/mcp/mcp_test.go +++ b/mcp/mcp_test.go @@ -3298,6 +3298,65 @@ func TestSubscriptionsListen_DisconnectScrubsMaps(t *testing.T) { } } +// TestServerSessionCloseWithActiveListen is a regression test for +// modelcontextprotocol/go-sdk#1160: ServerSession.Close must not deadlock +// when the client has an active subscriptions/listen stream. Previously, +// the server-side handler parked on ctx.Done and Close waited forever for +// the in-flight request to drain. +// +// The client's auto-listen (triggered by registering a list-changed handler) +// opens the stream on Connect — no explicit Subscribe is needed. The server +// must expose the corresponding list-changed capability, which happens +// automatically when at least one tool/prompt/resource is registered. +func TestServerSessionCloseWithActiveListen(t *testing.T) { + ctx := context.Background() + s := NewServer(&Implementation{Name: "s", Version: "0"}, nil) + AddTool(s, &Tool{Name: "t"}, sayHi) + + ct, st := NewInMemoryTransports() + if _, err := s.Connect(ctx, st, nil); err != nil { + t.Fatalf("server connect: %v", err) + } + c := NewClient(&Implementation{Name: "c", Version: "0"}, &ClientOptions{ + ToolListChangedHandler: func(context.Context, *ToolListChangedRequest) {}, + }) + cs, err := c.Connect(ctx, ct, &ClientSessionOptions{ProtocolVersion: protocolVersion20260728}) + if err != nil { + t.Fatalf("client connect: %v", err) + } + defer cs.Close() + + // Give the auto-listen request time to reach the server-side handler. + time.Sleep(20 * time.Millisecond) + + var ss *ServerSession + for x := range s.Sessions() { + ss = x + break + } + if ss == nil { + t.Fatal("no server session found") + } + + // Sanity check: the auto-listen must actually have registered an entry in + // listenIDs, otherwise the test below would trivially pass without + // exercising the fix. + ss.mu.Lock() + n := len(ss.listenIDs) + ss.mu.Unlock() + if n == 0 { + t.Fatal("expected auto-listen to register a request ID on the server session") + } + + done := make(chan error, 1) + go func() { done <- ss.Close() }() + select { + case <-done: + case <-time.After(5 * time.Second): + t.Fatal("ServerSession.Close deadlocked with an active subscriptions/listen") + } +} + func TestCustomMethods(t *testing.T) { type searchParams struct { ParamsBase diff --git a/mcp/server.go b/mcp/server.go index 4cdd6b9..13172a5 100644 --- a/mcp/server.go +++ b/mcp/server.go @@ -1500,6 +1500,12 @@ type ServerSession struct { mu sync.Mutex state ServerSessionState + // listenIDs holds the request IDs of in-flight subscriptions/listen + // handlers on this session. subscriptions/listen parks on ctx.Done until + // cancelled, so ServerSession.Close must cancel these contexts explicitly + // via jsonrpc2.Connection.Cancel to avoid deadlocking on the jsonrpc2 + // drain. See modelcontextprotocol/go-sdk#1160. + listenIDs []jsonrpc.ID } func (ss *ServerSession) updateState(mut func(*ServerSessionState)) { @@ -1935,6 +1941,17 @@ func (ss *ServerSession) handle(ctx context.Context, req *jsonrpc.Request) (any, if validatedMeta.usesNewProtocol { ctx = context.WithValue(ctx, logLevelContextKey{}, validatedMeta.logLevel) } + + // subscriptions/listen parks on ctx.Done until the peer cancels it (or the + // underlying reader breaks). Track the request ID so ServerSession.Close + // can cancel the in-flight handler via jsonrpc2.Connection.Cancel and + // avoid deadlocking on the jsonrpc2 drain. + if req.Method == methodSubscriptionsListen { + ss.mu.Lock() + ss.listenIDs = append(ss.listenIDs, req.ID) + ss.mu.Unlock() + } + res, err := handleReceive(ctx, ss, req) if err != nil { return nil, err @@ -2038,6 +2055,18 @@ func (ss *ServerSession) Close() error { // Close is idempotent and conn.Close() handles concurrent calls correctly ss.keepaliveCancel() } + + // Unblock any in-flight subscriptions/listen handlers, which otherwise park + // on ctx.Done and would deadlock conn.Close (which waits for in-flight + // requests to drain). + ss.mu.Lock() + ids := ss.listenIDs + ss.listenIDs = nil + ss.mu.Unlock() + for _, id := range ids { + ss.conn.Cancel(id) + } + err := ss.conn.Close() if ss.onClose != nil && ss.calledOnClose.CompareAndSwap(false, true) { From b0408f2821d7a686b6cccff52c81539a4315b229 Mon Sep 17 00:00:00 2001 From: gwyszynski-sa Date: Tue, 18 Aug 2026 09:42:58 +0200 Subject: [PATCH 27/70] mcp: prefer the negotiated protocol version over the request context (#1163) setMCPHeaders resolved Mcp-Protocol-Version from the request context before c.initializedResult. #1107 established the opposite order and said so in its description; the code it replaced also consulted the context last. The two branches were transposed by the switch-to-if rewrite in #1115, whose subject was an unrelated nil-pointer check. The context branch serves the pre-negotiation server/discover probe, which sets its own value in Client.Connect. Once a version has been negotiated it is the authoritative one for that connection, so initializedResult should win. _meta.protocolVersion from the outgoing message stays on top, so #1109 stays fixed. The existing table built its request with http.NewRequest, so protocolVersionFromContext returned "" in every case and the two orderings were indistinguishable, which is why the transposition went unnoticed. Adds a ctxVersion dimension and three cases pinning the precedence. Fixes #1162 Co-authored-by: Guglielmo Colombo --- mcp/streamable.go | 4 ++-- mcp/streamable_client_test.go | 28 ++++++++++++++++++++++++++++ 2 files changed, 30 insertions(+), 2 deletions(-) diff --git a/mcp/streamable.go b/mcp/streamable.go index b2190fa..b761f9c 100644 --- a/mcp/streamable.go +++ b/mcp/streamable.go @@ -2446,10 +2446,10 @@ func (c *streamableClientConn) setMCPHeaders(req *http.Request, msg jsonrpc.Mess } if pv := protocolVersionFromMessage(msg); pv != "" { req.Header.Set(protocolVersionHeader, pv) - } else if pv := protocolVersionFromContext(req.Context()); pv != "" { - req.Header.Set(protocolVersionHeader, pv) } else if c.initializedResult != nil { req.Header.Set(protocolVersionHeader, c.initializedResult.ProtocolVersion) + } else if pv := protocolVersionFromContext(req.Context()); pv != "" { + req.Header.Set(protocolVersionHeader, pv) } if c.sessionID != "" { req.Header.Set(sessionIDHeader, c.sessionID) diff --git a/mcp/streamable_client_test.go b/mcp/streamable_client_test.go index a5957bf..389f87e 100644 --- a/mcp/streamable_client_test.go +++ b/mcp/streamable_client_test.go @@ -1384,6 +1384,7 @@ func TestStreamableClientConnSetMCPHeaders_ProtocolVersion(t *testing.T) { name string initializedResult *InitializeResult msg jsonrpc.Message + ctxVersion string want string }{ { @@ -1422,6 +1423,30 @@ func TestStreamableClientConnSetMCPHeaders_ProtocolVersion(t *testing.T) { msg: req(1, methodListTools, &ListToolsParams{}), want: "", }, + { + // A process that is both a server and a client (a proxy) carries the + // inbound request's version on the context of the outgoing call. The + // version this connection negotiated must win over it. + name: "initializedResult preferred over request context", + initializedResult: &InitializeResult{ProtocolVersion: protocolVersion20251125}, + msg: req(1, methodListTools, &ListToolsParams{}), + ctxVersion: protocolVersion20260728, + want: protocolVersion20251125, + }, + { + name: "request context used when initializedResult unset", + initializedResult: nil, + msg: req(1, methodListTools, &ListToolsParams{}), + ctxVersion: protocolVersion20260728, + want: protocolVersion20260728, + }, + { + name: "message meta preferred over request context", + initializedResult: nil, + msg: req(1, methodListTools, &ListToolsParams{Meta: Meta{MetaKeyProtocolVersion: protocolVersion20251125}}), + ctxVersion: protocolVersion20260728, + want: protocolVersion20251125, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -1430,6 +1455,9 @@ func TestStreamableClientConnSetMCPHeaders_ProtocolVersion(t *testing.T) { if err != nil { t.Fatal(err) } + if tt.ctxVersion != "" { + httpReq = httpReq.WithContext(context.WithValue(httpReq.Context(), protocolVersionContextKey{}, tt.ctxVersion)) + } if err := conn.setMCPHeaders(httpReq, tt.msg); err != nil { t.Fatalf("setMCPHeaders: %v", err) } From fe27fa91519a06197cdf03b49861f0565471e726 Mon Sep 17 00:00:00 2001 From: jansen Date: Fri, 21 Aug 2026 16:16:28 +0800 Subject: [PATCH 28/70] internal/jsonrpc2: plumb cancellation cause through request context (#1103) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #1100. ## What `handleAsync` in `internal/jsonrpc2/conn.go` reported *why* an in-flight request had been canceled by inspecting the connection-level `s.writeErr`, guarded by a TODO waiting on golang/go#51365: ```go // Only deliver to the Handler if not already canceled. if err := req.ctx.Err(); err != nil { c.updateInFlight(func(s *inFlightState) { if s.writeErr != nil { // Assume that req.ctx was canceled due to s.writeErr. // TODO(#51365): use a Context API to plumb this through req.ctx. err = fmt.Errorf("%w: %v", ErrServerClosing, s.writeErr) } }) ... } ``` That API shipped as `context.WithCancelCause` / `context.Cause` in Go 1.21, and this module already targets Go 1.25, so the TODO is now actionable. This PR carries the cancellation cause on each request's context instead of inferring it from global state: - `incomingRequest.cancel` becomes a `context.CancelCauseFunc` (built via `context.WithCancelCause`). - The write-failure and read-side teardowns cancel each in-flight request with a cause wrapping `ErrServerClosing`. - The remaining cancel sites (peer cancel via `Cancel`, normal completion) pass `nil`. - `handleAsync` reads `context.Cause(req.ctx)` instead of looking up `s.writeErr`. ## Why Besides resolving the TODO, this fixes a latent mis-attribution. `s.writeErr` is connection-level state, not per-request: a request canceled for an unrelated reason (e.g. a peer cancel notification via `Cancel`) was reported as `ErrServerClosing` whenever `s.writeErr` happened to be non-nil at that moment. Tying the cause to the specific request's context removes that coupling. `ctx.Err()` still returns `context.Canceled` in all cases, so external behavior is unchanged; only the reported cause becomes accurate and per-request. ## Test plan - `go build ./...` - `go test ./internal/... ./mcp/` (including the longer `mcp` integration tests) — all pass. - Verified `context.WithCancelCause` semantics: with a cause, `Err()` stays `context.Canceled` while `Cause()` returns the wrapped `ErrServerClosing`; with `nil`, both resolve to `context.Canceled` — matching prior behavior. ## Note Per the discussion in #1100, the read-side teardown also carries a descriptive cause (wrapping `ErrServerClosing`) rather than a bare `context.Canceled`. --------- Co-authored-by: Guglielmo Colombo Co-authored-by: Yaroslav --- internal/jsonrpc2/conn.go | 31 ++++++++++++++++--------------- 1 file changed, 16 insertions(+), 15 deletions(-) diff --git a/internal/jsonrpc2/conn.go b/internal/jsonrpc2/conn.go index 4994c63..56edb7e 100644 --- a/internal/jsonrpc2/conn.go +++ b/internal/jsonrpc2/conn.go @@ -172,7 +172,7 @@ func (s *inFlightState) shuttingDown(errClosing error) error { type incomingRequest struct { *Request // the request being processed ctx context.Context - cancel context.CancelFunc + cancel context.CancelCauseFunc } // Reader abstracts the transport mechanics from the JSON RPC protocol. @@ -458,7 +458,7 @@ func (c *Connection) Cancel(id ID) { req = s.incomingByID[id] }) if req != nil { - req.cancel() + req.cancel(nil) } } @@ -552,9 +552,11 @@ func (c *Connection) readIncoming(ctx context.Context, reader Reader, preempter // Cancel any incoming requests still in flight: with the reader gone we // cannot receive cancellation notifications, and likely cannot write a // response either, so parked handlers have nothing useful left to do. - // Mirrors the equivalent cleanup on write failure. + // The read error (typically io.EOF from the peer disconnecting) is the + // cause, rather than a directional ErrServerClosing/ErrClientClosing + // sentinel: at this layer the connection has no designated end. for _, r := range s.incomingByID { - r.cancel() + r.cancel(err) } }) } @@ -564,7 +566,7 @@ func (c *Connection) readIncoming(ctx context.Context, reader Reader, preempter func (c *Connection) acceptRequest(ctx context.Context, msg *Request, preempter Preempter) { // In theory notifications cannot be cancelled, but we build them a cancel // context anyway. - reqCtx, cancel := context.WithCancel(ctx) + reqCtx, cancel := context.WithCancelCause(ctx) req := &incomingRequest{ Request: msg, ctx: reqCtx, @@ -665,15 +667,14 @@ func (c *Connection) handleAsync() { return } - // Only deliver to the Handler if not already canceled. + // Only deliver to the Handler if not already canceled. If the request + // was canceled with a cause (e.g. a write failure cancels every + // in-flight request), report that cause rather than the bare + // context.Canceled. if err := req.ctx.Err(); err != nil { - c.updateInFlight(func(s *inFlightState) { - if s.writeErr != nil { - // Assume that req.ctx was canceled due to s.writeErr. - // TODO(#51365): use a Context API to plumb this through req.ctx. - err = fmt.Errorf("%w: %v", ErrServerClosing, s.writeErr) - } - }) + if cause := context.Cause(req.ctx); cause != nil { + err = cause + } c.processResult("handleAsync", req, nil, err) continue } @@ -734,7 +735,7 @@ func (c *Connection) processResult(from any, req *incomingRequest, result any, e } // Cancel the request to free any associated resources. - req.cancel() + req.cancel(nil) c.updateInFlight(func(s *inFlightState) { if s.incoming == 0 { panic("jsonrpc2: processResult called when incoming count is already zero") @@ -777,7 +778,7 @@ func (c *Connection) write(ctx context.Context, msg Message) error { if s.writeErr == nil { s.writeErr = err for _, r := range s.incomingByID { - r.cancel() + r.cancel(fmt.Errorf("%w: %v", ErrServerClosing, err)) } } }) From ce0b198202c461787ead7799bac94e90d9cddd91 Mon Sep 17 00:00:00 2001 From: PratikDhanave Date: Fri, 21 Aug 2026 14:23:36 +0530 Subject: [PATCH 29/70] mcp: prevent MemoryEventStore.After panic on index past the end (#1132) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary `MemoryEventStore.After` guarded only the lower bound of the requested index: ```go start := index + 1 if dl.first > start { // too old → ErrEventsPurged return nil, fmt.Errorf(... ErrEventsPurged) } return slices.Clone(dl.data[start-dl.first:]), nil ``` There was no *upper*-bound guard, so an `index` greater than the highest stored event made the final slice expression panic with `slice bounds out of range`. The boundary `start-dl.first == len(dl.data)` (resume from the last event seen) is a valid empty replay, so only strictly-greater indices faulted. This is reachable from untrusted input: `parseEventID` accepts any non-negative index from the client-supplied `Last-Event-ID` header, and `serveGET` passes it straight to `eventStore.After`. Direct callers of the exported `EventStore.After` API crash outright. Fixes #1131 ## Change Return an empty replay for an index at or beyond the latest stored event — there is nothing after it — consistent with the already-valid `index == last` case. `ErrEventsPurged` would be misleading here, since those events were never sent rather than purged. ## Testing Extended `TestMemoryEventStoreAfter` with indices past the end of a stream (`3`, `100`, and one past a second stream), asserting an empty replay with no error. Verified the new cases **panic on the pre-fix code** (`panic: runtime error: slice bounds out of range [3:2]`) and **pass with the fix**. `go vet ./mcp/` and the full `go test ./mcp/` suite pass. --------- Co-authored-by: Yaroslav Shevchuk --- mcp/event.go | 9 ++++++--- mcp/event_test.go | 4 ++++ 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/mcp/event.go b/mcp/event.go index bbb3e6c..2b02a07 100644 --- a/mcp/event.go +++ b/mcp/event.go @@ -337,12 +337,15 @@ func (s *MemoryEventStore) After(_ context.Context, sessionID, streamID string, if !ok { return nil, fmt.Errorf("MemoryEventStore.After: unknown stream ID %v in session %q", streamID, sessionID) } - start := index + 1 - if dl.first > start { + start := (index + 1) - dl.first + if start < 0 { return nil, fmt.Errorf("MemoryEventStore.After: index %d, stream ID %v, session %q: %w", index, streamID, sessionID, ErrEventsPurged) } - return slices.Clone(dl.data[start-dl.first:]), nil + if start >= len(dl.data) { + return nil, nil + } + return slices.Clone(dl.data[start:]), nil } return func(yield func([]byte, error) bool) { diff --git a/mcp/event_test.go b/mcp/event_test.go index 91ddb9d..fedc92c 100644 --- a/mcp/event_test.go +++ b/mcp/event_test.go @@ -299,7 +299,11 @@ func TestMemoryEventStoreAfter(t *testing.T) { {"S1", "1", 0, []string{"d2", "d3"}, ""}, {"S1", "1", 1, []string{"d3"}, ""}, {"S1", "1", 2, nil, ""}, + {"S1", "1", 3, nil, ""}, + {"S1", "1", 100, nil, ""}, // past the end of stream "1" (highest is 2) {"S1", "2", 0, nil, ""}, + {"S1", "2", 1, nil, ""}, + {"S1", "2", 100, nil, ""}, {"S1", "3", 0, nil, "unknown stream ID"}, {"S2", "0", 0, nil, "unknown session ID"}, } { From b074c7f3af59d200d632393501a6081ece247fcd Mon Sep 17 00:00:00 2001 From: jansen Date: Fri, 21 Aug 2026 16:56:31 +0800 Subject: [PATCH 30/70] internal/jsonrpc2: test WireError.Is and fix nil target panic (#1079) # Problem WireError.Is backs errors.Is checks used across the SDK against sentinels such as ErrMethodNotFound, ErrRejected, and ErrClientClosing, but had no direct test. Add a table-driven test for it. While doing so, errors.Is(err, (*WireError)(nil)) was found to panic on a nil pointer dereference, because Is read w.Code without a nil check. Guard against a nil *WireError target so the comparison returns false. ```go errors.Is(err, (*WireError)(nil)) // panic: nil pointer dereference ``` # Approach Is read w.Code after a successful type assertion without checking whether w itself was nil. The fix guards against a nil *WireError target so the comparison returns false instead of panicking. # Test plan - go test ./internal/jsonrpc2/ passes. - The new typed nil WireError case panics without the one-line fix and passes with it, confirming the test exercises the guarded path. Co-authored-by: Yaroslav --- internal/jsonrpc2/wire.go | 2 +- internal/jsonrpc2/wire_test.go | 24 ++++++++++++++++++++++++ 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/internal/jsonrpc2/wire.go b/internal/jsonrpc2/wire.go index 4d123f2..499b026 100644 --- a/internal/jsonrpc2/wire.go +++ b/internal/jsonrpc2/wire.go @@ -87,7 +87,7 @@ func (err *WireError) Error() string { func (err *WireError) Is(other error) bool { w, ok := other.(*WireError) - if !ok { + if !ok || w == nil { return false } return err.Code == w.Code diff --git a/internal/jsonrpc2/wire_test.go b/internal/jsonrpc2/wire_test.go index cf7e2b8..ef47144 100644 --- a/internal/jsonrpc2/wire_test.go +++ b/internal/jsonrpc2/wire_test.go @@ -7,6 +7,7 @@ package jsonrpc2_test import ( "bytes" "encoding/json" + "errors" "reflect" "testing" @@ -147,6 +148,29 @@ func TestDecodeIDOnlyMessageIsResponse(t *testing.T) { } } +// TestWireErrorIs checks the errors.Is behavior of WireError, including that +// comparing against a typed-nil *WireError does not panic. +func TestWireErrorIs(t *testing.T) { + err := jsonrpc2.NewError(-32600, "invalid request") + for _, test := range []struct { + name string + target error + want bool + }{ + {name: "same code", target: jsonrpc2.NewError(-32600, "other message"), want: true}, + {name: "different code", target: jsonrpc2.NewError(-32601, "invalid request"), want: false}, + {name: "typed nil WireError", target: (*jsonrpc2.WireError)(nil), want: false}, + {name: "untyped nil", target: nil, want: false}, + {name: "other error type", target: errors.New("invalid request"), want: false}, + } { + t.Run(test.name, func(t *testing.T) { + if got := errors.Is(err, test.target); got != test.want { + t.Errorf("errors.Is(%v, %v) = %v, want %v", err, test.target, got, test.want) + } + }) + } +} + func checkJSON(t *testing.T, got, want []byte) { // compare the compact form, to allow for formatting differences g := &bytes.Buffer{} From 3d6450fccf564e7c92a54d4274c35635a8c39821 Mon Sep 17 00:00:00 2001 From: Lee Date: Fri, 21 Aug 2026 18:31:12 +0800 Subject: [PATCH 31/70] mcp: bound streamable Close DELETE so a silent peer cannot hang teardown (#1184) Close issued a synchronous session-termination DELETE on the connection context and cancelled that context only after `Do` returned. A reachable but silent peer could stall `Close()` until the TCP timeout. Give the DELETE its own short timeout. RED on current main: `TestCloseHangsOnBlackHoledDelete` failed with `Close() blocked on a black-holing DELETE`. GREEN: `go test ./mcp/ -count=1 -timeout 120s -run 'TestStreamableClient|TestCloseHangs|TestCallCancellation_FastReturn'` Fixes #1183 Made with [Cursor](https://cursor.com) Co-authored-by: Guglielmo Colombo --- mcp/streamable.go | 9 +++++++- mcp/streamable_client_test.go | 42 +++++++++++++++++++++++++++++++++++ 2 files changed, 50 insertions(+), 1 deletion(-) diff --git a/mcp/streamable.go b/mcp/streamable.go index b761f9c..6ed5d3d 100644 --- a/mcp/streamable.go +++ b/mcp/streamable.go @@ -2016,6 +2016,11 @@ const ( reconnectGrowFactor = 1.5 // reconnectMaxDelay caps the backoff delay, preventing it from growing indefinitely. reconnectMaxDelay = 30 * time.Second + // closeDeleteTimeout bounds the session-termination DELETE sent by Close. + // That request is best-effort: a peer that accepts the connection but never + // answers must not be able to hold teardown open until the TCP timeout. + // See issue #1183. + closeDeleteTimeout = 5 * time.Second ) var ( @@ -2767,7 +2772,9 @@ func (c *streamableClientConn) Close() error { // No session was established (e.g. the server is stateless), // so there is nothing to delete. } else { - req, err := http.NewRequestWithContext(c.ctx, http.MethodDelete, c.url, nil) + reqCtx, stop := context.WithTimeout(c.ctx, closeDeleteTimeout) + defer stop() + req, err := http.NewRequestWithContext(reqCtx, http.MethodDelete, c.url, nil) if err != nil { c.closeErr = err } else { diff --git a/mcp/streamable_client_test.go b/mcp/streamable_client_test.go index 389f87e..64c4da5 100644 --- a/mcp/streamable_client_test.go +++ b/mcp/streamable_client_test.go @@ -281,6 +281,48 @@ func TestStreamableClientRedundantDelete(t *testing.T) { } } +// TestStreamableClientCloseUnresponsiveDelete checks that Close stays bounded +// when the peer accepts the session-termination DELETE but never answers it. +func TestStreamableClientCloseUnresponsiveDelete(t *testing.T) { + base := NewStreamableHTTPHandler(func(*http.Request) *Server { + return NewServer(testImpl, nil) + }, nil) + httpServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodDelete { + <-r.Context().Done() + return + } + base.ServeHTTP(w, r) + })) + t.Cleanup(func() { + // The DELETE handler only returns once its request is cancelled, so + // drop the connections before waiting for outstanding requests. + httpServer.CloseClientConnections() + httpServer.Close() + }) + + client := NewClient(testImpl, nil) + session, err := client.Connect(context.Background(), + &StreamableClientTransport{Endpoint: httpServer.URL}, nil) + if err != nil { + t.Fatalf("client.Connect() failed: %v", err) + } + if session.ID() == "" { + t.Fatal("empty session ID: Close would not send DELETE") + } + + done := make(chan struct{}) + go func() { + session.Close() + close(done) + }() + select { + case <-done: + case <-time.After(closeDeleteTimeout + 5*time.Second): + t.Fatal("Close() blocked on an unanswered DELETE") + } +} + func TestStreamableClientGETHandling(t *testing.T) { ctx := context.Background() From 6f0a645c2046be1b47d3ea1d9ec7b2aa74c0b9ef Mon Sep 17 00:00:00 2001 From: Guglielmo Colombo Date: Mon, 24 Aug 2026 10:14:28 +0200 Subject: [PATCH 32/70] mcp: add ServerSession.NotifyElicitationComplete (#1194) Adds: ```go func (ss *ServerSession) NotifyElicitationComplete(ctx context.Context, params *ElicitationCompleteParams) error ``` As according to [spec](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/main/schema/2025-11-25/schema.ts#L2493-L2501) --- mcp/elicitation_test.go | 40 ++++++++++++++++++++++++++++++++++++++-- mcp/error_test.go | 7 +++---- mcp/server.go | 12 ++++++++++++ 3 files changed, 53 insertions(+), 6 deletions(-) diff --git a/mcp/elicitation_test.go b/mcp/elicitation_test.go index d588516..098fce3 100644 --- a/mcp/elicitation_test.go +++ b/mcp/elicitation_test.go @@ -12,6 +12,7 @@ import ( "testing/synctest" "github.com/google/jsonschema-go/jsonschema" + "github.com/modelcontextprotocol/go-sdk/internal/jsonrpc2" "github.com/modelcontextprotocol/go-sdk/jsonrpc" ) @@ -194,9 +195,9 @@ func TestElicitationCompleteNotification(t *testing.T) { } // 2. Server sends elicitation complete notification (simulating out-of-band completion) - err = handleNotify(ctx, notificationElicitationComplete, newServerRequest(ss, &ElicitationCompleteParams{ + err = ss.NotifyElicitationComplete(ctx, &ElicitationCompleteParams{ ElicitationID: elicitID, - })) + }) if err != nil { t.Fatalf("failed to send elicitation complete notification: %v", err) } @@ -209,6 +210,41 @@ func TestElicitationCompleteNotification(t *testing.T) { }) } +func TestNotifyElicitationCompleteInvalidParams(t *testing.T) { + ctx := context.Background() + + ct, st := NewInMemoryTransports() + s := NewServer(testImpl, nil) + ss, err := s.Connect(ctx, st, nil) + if err != nil { + t.Fatal(err) + } + defer ss.Close() + c := NewClient(testImpl, nil) + cs, err := c.Connect(ctx, ct, nil) + if err != nil { + t.Fatal(err) + } + defer cs.Close() + + // An empty elicitation ID cannot be matched to a pending elicitation by the + // client, so it is rejected rather than sent. + for _, tc := range []struct { + name string + params *ElicitationCompleteParams + }{ + {"nil params", nil}, + {"empty ID", &ElicitationCompleteParams{}}, + } { + t.Run(tc.name, func(t *testing.T) { + err := ss.NotifyElicitationComplete(ctx, tc.params) + if !errors.Is(err, jsonrpc2.ErrInvalidParams) { + t.Errorf("NotifyElicitationComplete(%v) = %v, want %v", tc.params, err, jsonrpc2.ErrInvalidParams) + } + }) + } +} + func TestElicitationNoValidationWithoutAccept(t *testing.T) { ctx := context.Background() diff --git a/mcp/error_test.go b/mcp/error_test.go index 898562c..c69fc4b 100644 --- a/mcp/error_test.go +++ b/mcp/error_test.go @@ -300,10 +300,9 @@ func TestURLElicitationRequired(t *testing.T) { // In a real scenario, this would happen out-of-band after the user // completes the form submission. go func() { - err := handleNotify(ctx, notificationElicitationComplete, - newServerRequest(ss, &ElicitationCompleteParams{ - ElicitationID: elicitID, - })) + err := ss.NotifyElicitationComplete(ctx, &ElicitationCompleteParams{ + ElicitationID: elicitID, + }) if err != nil { t.Errorf("failed to send elicitation complete notification: %v", err) } diff --git a/mcp/server.go b/mcp/server.go index 13172a5..f9c8233 100644 --- a/mcp/server.go +++ b/mcp/server.go @@ -1733,6 +1733,18 @@ func (ss *ServerSession) Elicit(ctx context.Context, params *ElicitParams) (*Eli return res, nil } +// NotifyElicitationComplete tells the client that the out-of-band ("url" mode) +// elicitation identified by params.ElicitationID has finished. +func (ss *ServerSession) NotifyElicitationComplete(ctx context.Context, params *ElicitationCompleteParams) error { + if params == nil { + return fmt.Errorf("%w: params cannot be nil", jsonrpc2.ErrInvalidParams) + } + if params.ElicitationID == "" { + return fmt.Errorf("%w: ElicitationID cannot be empty", jsonrpc2.ErrInvalidParams) + } + return handleNotify(ctx, notificationElicitationComplete, newServerRequest(ss, orZero[Params](params))) +} + // Log sends a log message to the client. // // For new-protocol (>= 2026-07-28) requests, the level is taken from the From b8c53fedd6287fd11f0e7a386730ce609d1c3eb0 Mon Sep 17 00:00:00 2001 From: Guglielmo Colombo Date: Mon, 24 Aug 2026 12:00:59 +0200 Subject: [PATCH 33/70] mcp: add docs for tier 1 assessment (#1195) Closes open points in [comment](https://github.com/modelcontextprotocol/modelcontextprotocol/issues/3220#issuecomment-5373089321) --- CONTRIBUTING.md | 54 ++++ docs/client.md | 212 ++++++++++++++ docs/protocol.md | 58 ++++ docs/server.md | 410 +++++++++++++++++++++++++++- internal/docs/client.src.md | 52 ++++ internal/docs/protocol.src.md | 28 ++ internal/docs/server.src.md | 106 ++++++- internal/readme/contributing.src.md | 51 ++++ mcp/client_example_test.go | 170 ++++++++++++ mcp/server_example_test.go | 251 +++++++++++++++++ mcp/sse_example_test.go | 4 + mcp/tool.go | 2 +- mcp/tool_example_test.go | 68 +++++ 13 files changed, 1461 insertions(+), 5 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index b327087..3d8b090 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -21,6 +21,9 @@ transparently, while adhering to these goals. 1. [Contributing code](#contributing-code) 1. [Adding and updating dependencies](#adding-and-updating-dependencies) 1. [Updating the README](#updating-the-readme) +1. [Versioning](#versioning) + 1. [What counts as a breaking change](#what-counts-as-a-breaking-change) + 1. [Deprecation](#deprecation) 1. [Timeouts](#timeouts) 1. [Code of conduct](#code-of-conduct) 1. [Governance](#governance) @@ -188,6 +191,57 @@ The CI system will automatically check that the README is up-to-date by running `go generate ./internal/readme` and verifying no changes result. If you see a CI failure about the README being out of sync, follow the steps above to regenerate it. +## Versioning + +The SDK follows [semantic versioning](https://semver.org), as the Go module +system requires: the major version is part of the module path, so a breaking +release would import as `github.com/modelcontextprotocol/go-sdk/v2`. A breaking +change therefore cannot reach existing users by accident; they have to change +their import path to receive one. + +This policy covers the exported API of the SDK's importable packages — `mcp`, +`jsonrpc`, `auth`, `auth/extauth` and `oauthex`. Everything under `internal/` +is not importable outside the module and may change in any release. + +Which MCP spec revisions each SDK version speaks is documented in the +[README](README.md#version-compatibility). Support for a new revision arrives +in a minor release; dropping a revision the SDK previously negotiated is a +breaking change. + +### What counts as a breaking change + +- Removing or renaming an exported identifier. +- Changing the signature of an exported function or method. +- Removing or renaming a field of an exported struct, or changing its type. +- Adding a method to an exported interface that users are meant to implement. +- Changing documented behavior that callers could reasonably rely on. +- Dropping support for an MCP spec revision. + +The following are not breaking, and may land in a minor or patch release: + +- Adding an exported identifier, or a field to an exported struct. +- Fixing behavior that contradicted its documentation. +- Refactoring that leaves the exported API unchanged. +- Adding support for a new MCP spec revision. +- Raising the minimum Go version to one still supported upstream, per the + [README](README.md#version-compatibility). + +### Deprecation + +An API on its way out is marked with a `// Deprecated:` comment naming its +replacement, which `gopls` and `staticcheck` surface at call sites. It keeps +working until the next major version, since removing it would itself be a +breaking change. + +Protocol features that the *specification* deprecates are a separate matter and +follow the spec's timeline rather than this policy. The features deprecated by +[SEP-2577](https://modelcontextprotocol.io/seps/2577-deprecate-roots-sampling-and-logging) +— roots, sampling and logging — remain supported for at least twelve months, as +noted in the [README](README.md#version-compatibility). + +Any change to the exported API, breaking or not, goes through the +[proposal process](#proposals). + ## Timeouts If a contributor hasn't responded to issue questions or PR comments in two weeks, diff --git a/docs/client.md b/docs/client.md index fe9a963..ad71c11 100644 --- a/docs/client.md +++ b/docs/client.md @@ -2,8 +2,11 @@ # Support for MCP client features 1. [Roots](#roots) + 1. [Roots list changed](#roots-list-changed) 1. [Sampling](#sampling) 1. [Elicitation](#elicitation) + 1. [Schema defaults and enums](#schema-defaults-and-enums) + 1. [Completing a URL elicitation](#completing-a-url-elicitation) 1. [Multi Round-Trip Requests](#multi-round-trip-requests) 1. [Capabilities](#capabilities) 1. [Capability inference](#capability-inference) @@ -91,6 +94,67 @@ func Example_roots() { } ``` +### Roots list changed + +`Client.AddRoots` and `Client.RemoveRoots` notify every connected server that +the list changed. Servers observe this through +[`ServerOptions.RootsListChangedHandler`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ServerOptions.RootsListChangedHandler); +as with the server-side list-changed notifications, it reports only that +something changed, so read the list back with `ServerSession.ListRoots`. + +```go +func Example_rootsListChanged() { + ctx := context.Background() + + changed := make(chan struct{}, 2) + s := mcp.NewServer(&mcp.Implementation{Name: "server", Version: "v0.0.1"}, &mcp.ServerOptions{ + RootsListChangedHandler: func(context.Context, *mcp.RootsListChangedRequest) { + changed <- struct{}{} + }, + }) + + c := mcp.NewClient(&mcp.Implementation{Name: "client", Version: "v0.0.1"}, nil) + c.AddRoots(&mcp.Root{URI: "file:///project"}) + + t1, t2 := mcp.NewInMemoryTransports() + ss, err := s.Connect(ctx, t1, nil) + if err != nil { + log.Fatal(err) + } + defer ss.Close() + + // ListRoots is a server-initiated request, so this session negotiates a + // protocol version that still allows one. + cs, err := c.Connect(ctx, t2, &mcp.ClientSessionOptions{ProtocolVersion: "2025-11-25"}) + if err != nil { + log.Fatal(err) + } + defer cs.Close() + + // Roots added after the client connects notify every connected server. + c.AddRoots(&mcp.Root{URI: "file:///scratch"}) + <-changed + + // The notification says only that the list changed, so read it back. + res, err := ss.ListRoots(ctx, nil) + if err != nil { + log.Fatal(err) + } + for _, root := range res.Roots { + fmt.Println(root.URI) + } + + c.RemoveRoots("file:///scratch") + <-changed + fmt.Println("roots changed again") + + // Output: + // file:///project + // file:///scratch + // roots changed again +} +``` + ## Sampling > **Note:** The sampling feature is deprecated as of protocol version @@ -235,6 +299,154 @@ func Example_elicitation() { } ``` +### Schema defaults and enums + +`ElicitParams.RequestedSchema` is a flat schema of primitive fields, which the +client renders as a form. Two field keywords shape that form. + +A `Default` ([SEP-1034](https://modelcontextprotocol.io/seps/1034)) prefills a +field. When the user accepts without supplying it, the SDK fills the field in +from the schema before the result reaches either side's caller — the client +does so after its elicitation handler returns, and `ServerSession.Elicit` does +so again on receipt. This is unconditional; there is no opt-in flag. Marking a +defaulted field `Required` defeats it: accepted content is validated against +the schema before defaults are applied, so an answer that omits the field is +rejected rather than defaulted. + +An `Enum` ([SEP-1330](https://modelcontextprotocol.io/seps/1330)) restricts a +field to a fixed set of values, which the client renders as a choice. Enums +are supported only on `"string"` fields; declaring one on another type is +rejected. To label the choices, set the legacy `enumNames` keyword through +`Schema.Extra`, with exactly one name per enum value — a mismatched length is +rejected. + +```go +func Example_elicitationSchema() { + ctx := context.Background() + ct, st := mcp.NewInMemoryTransports() + + s := mcp.NewServer(&mcp.Implementation{Name: "server", Version: "v0.0.1"}, nil) + mcp.AddTool(s, &mcp.Tool{Name: "export_report"}, func(_ context.Context, req *mcp.CallToolRequest, _ struct{}) (*mcp.CallToolResult, any, error) { + if len(req.Params.InputResponses) == 0 { + return &mcp.CallToolResult{ + InputRequests: mcp.InputRequestMap{"format": &mcp.ElicitParams{ + Message: "Export quarterly-sales as which format?", + RequestedSchema: &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "format": { + Type: "string", + Title: "Format", + Enum: []any{"pdf", "csv"}, + Default: json.RawMessage(`"pdf"`), + Extra: map[string]any{"enumNames": []any{"PDF document", "CSV spreadsheet"}}, + }, + }, + }, + }}, + }, nil, nil + } + res := req.Params.InputResponses["format"].(*mcp.ElicitResult) + return &mcp.CallToolResult{ + Content: []mcp.Content{&mcp.TextContent{Text: "Exported as " + res.Content["format"].(string)}}, + }, nil, nil + }) + if _, err := s.Connect(ctx, st, nil); err != nil { + log.Fatal(err) + } + + // The user accepts without filling anything in. + c := mcp.NewClient(&mcp.Implementation{Name: "client", Version: "v0.0.1"}, &mcp.ClientOptions{ + ElicitationHandler: func(context.Context, *mcp.ElicitRequest) (*mcp.ElicitResult, error) { + return &mcp.ElicitResult{Action: "accept", Content: map[string]any{}}, nil + }, + }) + cs, err := c.Connect(ctx, ct, nil) + if err != nil { + log.Fatal(err) + } + defer cs.Close() + + res, err := cs.CallTool(ctx, &mcp.CallToolParams{Name: "export_report"}) + if err != nil { + log.Fatal(err) + } + fmt.Println(res.Content[0].(*mcp.TextContent).Text) + // Output: Exported as pdf +} +``` + +### Completing a URL elicitation + +In URL mode the user finishes out of band, in a browser, so nothing in the +elicitation result tells the client when they are done. The server signals that +with +[`ServerSession.NotifyElicitationComplete`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ServerSession.NotifyElicitationComplete), +passing the same `ElicitationID` the request carried; the client observes it +through +[`ClientOptions.ElicitationCompleteHandler`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ClientOptions.ElicitationCompleteHandler). +Send it from whatever endpoint the hosted flow redirects back to. + +The notification matters most when a handler rejects a request with +[`URLElicitationRequiredError`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#URLElicitationRequiredError): +the client parks the original request until a notification names that +`ElicitationID`, and then retries it automatically. Until one arrives, the +client waits. + +```go +func Example_elicitationComplete() { + ctx := context.Background() + ct, st := mcp.NewInMemoryTransports() + + s := mcp.NewServer(&mcp.Implementation{Name: "server", Version: "v0.0.1"}, nil) + ss, err := s.Connect(ctx, st, nil) + if err != nil { + log.Fatal(err) + } + defer ss.Close() + + done := make(chan struct{}) + c := mcp.NewClient(&mcp.Implementation{Name: "client", Version: "v0.0.1"}, &mcp.ClientOptions{ + Capabilities: &mcp.ClientCapabilities{ + Elicitation: &mcp.ElicitationCapabilities{URL: &mcp.URLElicitationCapabilities{}}, + }, + ElicitationHandler: func(_ context.Context, req *mcp.ElicitRequest) (*mcp.ElicitResult, error) { + fmt.Println("opening", req.Params.URL) + return &mcp.ElicitResult{Action: "accept"}, nil + }, + ElicitationCompleteHandler: func(_ context.Context, req *mcp.ElicitationCompleteNotificationRequest) { + fmt.Println("flow finished:", req.Params.ElicitationID) + close(done) + }, + }) + cs, err := c.Connect(ctx, ct, &mcp.ClientSessionOptions{ProtocolVersion: "2025-11-25"}) + if err != nil { + log.Fatal(err) + } + defer cs.Close() + + const elicitationID = "connect-calendar-1" + if _, err := ss.Elicit(ctx, &mcp.ElicitParams{ + Message: "Grant calendar access", + URL: "https://calendar.example.com/consent?state=" + elicitationID, + ElicitationID: elicitationID, + }); err != nil { + log.Fatal(err) + } + + // The hosted page redirects back to the server, whose callback endpoint + // signals that the user is done. + if err := ss.NotifyElicitationComplete(ctx, &mcp.ElicitationCompleteParams{ElicitationID: elicitationID}); err != nil { + log.Fatal(err) + } + <-done + + // Output: + // opening https://calendar.example.com/consent?state=connect-calendar-1 + // flow finished: connect-calendar-1 +} +``` + ## Multi Round-Trip Requests [SEP-2322](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2322) diff --git a/docs/protocol.md b/docs/protocol.md index edc1c11..d40ed91 100644 --- a/docs/protocol.md +++ b/docs/protocol.md @@ -9,6 +9,7 @@ 1. [Transports](#transports) 1. [Stdio Transport](#stdio-transport) 1. [Streamable Transport](#streamable-transport) + 1. [SSE Transport (legacy)](#sse-transport-(legacy)) 1. [Custom transports](#custom-transports) 1. [Concurrency](#concurrency) 1. [Authorization](#authorization) @@ -342,6 +343,63 @@ _See [examples/server/distributed](https://github.com/modelcontextprotocol/go-sd an example using stateless mode to implement a server distributed across multiple processes._ +### SSE Transport (legacy) + +Before the streamable transport, the +[2024-11-05](https://modelcontextprotocol.io/specification/2024-11-05/basic/transports) +spec defined an HTTP transport built from two endpoints: a hanging GET that +streams server-to-client messages as server-sent events, and a per-session +endpoint the client POSTs its messages to. The SDK still implements it, for +talking to peers that predate the streamable transport. New deployments should +use the [streamable transport](#streamable-transport) instead. + +It is implemented across three types, mirroring the streamable ones: + +- [`SSEHandler`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#SSEHandler): + an `http.Handler` that creates a session per incoming GET and routes POSTs to + the right session. Construct it with + [`NewSSEHandler`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#NewSSEHandler), + which takes a function returning the `Server` to serve a given request. +- [`SSEServerTransport`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#SSEServerTransport): + one such session. Reach for it directly only when serving the two endpoints + yourself; `SSEHandler` creates these for you. +- [`SSEClientTransport`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#SSEClientTransport): + the client side, which needs only the `Endpoint` of the GET. + +```go +func ExampleSSEHandler() { + server := mcp.NewServer(&mcp.Implementation{Name: "adder", Version: "v0.0.1"}, nil) + mcp.AddTool(server, &mcp.Tool{Name: "add", Description: "add two numbers"}, Add) + + handler := mcp.NewSSEHandler(func(*http.Request) *mcp.Server { return server }, nil) + httpServer := httptest.NewServer(handler) + defer httpServer.Close() + + ctx := context.Background() + transport := &mcp.SSEClientTransport{Endpoint: httpServer.URL} + client := mcp.NewClient(&mcp.Implementation{Name: "test", Version: "v1.0.0"}, nil) + cs, err := client.Connect(ctx, transport, nil) + if err != nil { + log.Fatal(err) + } + defer cs.Close() + + res, err := cs.CallTool(ctx, &mcp.CallToolParams{ + Name: "add", + Arguments: map[string]any{"x": 1, "y": 2}, + }) + if err != nil { + log.Fatal(err) + } + fmt.Println(res.Content[0].(*mcp.TextContent).Text) + + // Output: 3 +} +``` + +_See [examples/server/sse](https://github.com/modelcontextprotocol/go-sdk/blob/main/examples/server/sse/main.go) +for a standalone server._ + ### Custom transports The SDK supports [custom diff --git a/docs/server.md b/docs/server.md index 9f0f870..9eb4ec2 100644 --- a/docs/server.md +++ b/docs/server.md @@ -2,8 +2,13 @@ # Support for MCP server features 1. [Prompts](#prompts) + 1. [Prompt message content](#prompt-message-content) 1. [Resources](#resources) + 1. [Binary resources](#binary-resources) + 1. [Resource subscriptions](#resource-subscriptions) 1. [Tools](#tools) + 1. [Tool result content](#tool-result-content) +1. [List changed notifications](#list-changed-notifications) 1. [Multi Round-Trip Requests](#multi-round-trip-requests) 1. [Talking to legacy clients](#talking-to-legacy-clients) 1. [Example](#example) @@ -115,6 +120,91 @@ func Example_prompts() { } ``` +### Prompt message content + +A +[`PromptMessage`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#PromptMessage) +has two fields: a `Role`, either `"user"` or `"assistant"`, and a single +`Content`. That is the same interface a tool result uses (see [Tool result +content](#tool-result-content)), so a prompt message can hold an image, audio, +or a resource's contents as readily as text. + +Media on its own says nothing about what the model should do with it, so send a +text message alongside it. Embedding a resource puts its contents directly in +the message, sparing the client a separate `resources/read`. Note that +`ResourceContents.URI` is not checked against the resources the server has +registered: it records where the contents came from, so use the URI of a real +resource when there is one, as in the example below. + +```go +func Example_promptContent() { + ctx := context.Background() + + const styleGuide = "- Prefer clarity over cleverness.\n" + screenshotPNG := []byte("\x89PNG\r\n\x1a\n") + + s := mcp.NewServer(&mcp.Implementation{Name: "server", Version: "v0.0.1"}, nil) + + s.AddResource(&mcp.Resource{URI: "doc://style-guide", MIMEType: "text/markdown"}, + func(context.Context, *mcp.ReadResourceRequest) (*mcp.ReadResourceResult, error) { + return &mcp.ReadResourceResult{ + Contents: []*mcp.ResourceContents{{ + URI: "doc://style-guide", + MIMEType: "text/markdown", + Text: styleGuide, + }}, + }, nil + }) + + s.AddPrompt(&mcp.Prompt{Name: "review_screenshot"}, + func(context.Context, *mcp.GetPromptRequest) (*mcp.GetPromptResult, error) { + return &mcp.GetPromptResult{ + Messages: []*mcp.PromptMessage{ + {Role: "user", Content: &mcp.EmbeddedResource{ + Resource: &mcp.ResourceContents{ + URI: "doc://style-guide", + MIMEType: "text/markdown", + Text: styleGuide, + }, + }}, + {Role: "user", Content: &mcp.ImageContent{Data: screenshotPNG, MIMEType: "image/png"}}, + {Role: "user", Content: &mcp.TextContent{Text: "Review the screenshot against the style guide."}}, + }, + }, nil + }) + + c := mcp.NewClient(&mcp.Implementation{Name: "client", Version: "v0.0.1"}, nil) + t1, t2 := mcp.NewInMemoryTransports() + if _, err := s.Connect(ctx, t1, nil); err != nil { + log.Fatal(err) + } + cs, err := c.Connect(ctx, t2, nil) + if err != nil { + log.Fatal(err) + } + defer cs.Close() + + res, err := cs.GetPrompt(ctx, &mcp.GetPromptParams{Name: "review_screenshot"}) + if err != nil { + log.Fatal(err) + } + for _, msg := range res.Messages { + switch content := msg.Content.(type) { + case *mcp.TextContent: + fmt.Println(msg.Role, "text:", content.Text) + case *mcp.ImageContent: + fmt.Printf("%s image: %s, %d bytes\n", msg.Role, content.MIMEType, len(content.Data)) + case *mcp.EmbeddedResource: + fmt.Printf("%s embedded resource: %s (%s)\n", msg.Role, content.Resource.URI, content.Resource.MIMEType) + } + } + // Output: + // user embedded resource: doc://style-guide (text/markdown) + // user image: image/png, 8 bytes + // user text: Review the screenshot against the style guide. +} +``` + ## Resources In MCP terms, a _resource_ is some data referenced by a URI. @@ -246,6 +336,145 @@ func Example_resources() { } ``` +### Binary resources + +A +[`ResourceContents`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ResourceContents) +carries its data in either `Text` or `Blob`. Binary data goes in `Blob` as +plain bytes — the SDK base64-encodes it on the wire — and leaves `Text` empty, +so a client tells the two apart by which field is populated. + +```go +func Example_binaryResource() { + ctx := context.Background() + + s := mcp.NewServer(&mcp.Implementation{Name: "server", Version: "v0.0.1"}, nil) + s.AddResource(&mcp.Resource{URI: "file:///logo.png", MIMEType: "image/png"}, + func(context.Context, *mcp.ReadResourceRequest) (*mcp.ReadResourceResult, error) { + return &mcp.ReadResourceResult{ + Contents: []*mcp.ResourceContents{{ + URI: "file:///logo.png", + MIMEType: "image/png", + Blob: []byte("\x89PNG\r\n\x1a\n"), + }}, + }, nil + }) + + c := mcp.NewClient(&mcp.Implementation{Name: "client", Version: "v0.0.1"}, nil) + t1, t2 := mcp.NewInMemoryTransports() + if _, err := s.Connect(ctx, t1, nil); err != nil { + log.Fatal(err) + } + cs, err := c.Connect(ctx, t2, nil) + if err != nil { + log.Fatal(err) + } + defer cs.Close() + + res, err := cs.ReadResource(ctx, &mcp.ReadResourceParams{URI: "file:///logo.png"}) + if err != nil { + log.Fatal(err) + } + contents := res.Contents[0] + fmt.Printf("%s: %d bytes of %s, text is %q\n", + contents.URI, len(contents.Blob), contents.MIMEType, contents.Text) + + // Output: + // file:///logo.png: 8 bytes of image/png, text is "" +} +``` + +### Resource subscriptions + +A client interested in changes to one resource subscribes to its URI with +[`ClientSession.Subscribe`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ClientSession.Subscribe) +and stops with +[`ClientSession.Unsubscribe`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ClientSession.Unsubscribe). +Set +[`ServerOptions.SubscribeHandler`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ServerOptions.SubscribeHandler) +and +[`UnsubscribeHandler`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ServerOptions.UnsubscribeHandler) +to track who is listening; setting either one gives the server the +`resources.subscribe` capability. + +Publish a change with +[`Server.ResourceUpdated`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#Server.ResourceUpdated), +which notifies every session subscribed to that URI. The notification carries +only the URI, not the new contents, so a client that wants them reads the +resource again. + +```go +func Example_resourceSubscription() { + ctx := context.Background() + + config := "theme=light\n" + + updated := make(chan string, 1) + c := mcp.NewClient(&mcp.Implementation{Name: "client", Version: "v0.0.1"}, &mcp.ClientOptions{ + // The notification carries only the URI, so read the resource again to + // see what it now holds. + ResourceUpdatedHandler: func(context.Context, *mcp.ResourceUpdatedNotificationRequest) { + updated <- "config://app" + }, + }) + + subscribed := make(chan string, 2) + s := mcp.NewServer(&mcp.Implementation{Name: "server", Version: "v0.0.1"}, &mcp.ServerOptions{ + SubscribeHandler: func(_ context.Context, req *mcp.SubscribeRequest) error { + subscribed <- "subscribed to " + req.Params.URI + return nil + }, + UnsubscribeHandler: func(_ context.Context, req *mcp.UnsubscribeRequest) error { + subscribed <- "unsubscribed from " + req.Params.URI + return nil + }, + }) + s.AddResource(&mcp.Resource{URI: "config://app"}, + func(context.Context, *mcp.ReadResourceRequest) (*mcp.ReadResourceResult, error) { + return &mcp.ReadResourceResult{ + Contents: []*mcp.ResourceContents{{URI: "config://app", Text: config}}, + }, nil + }) + + t1, t2 := mcp.NewInMemoryTransports() + if _, err := s.Connect(ctx, t1, nil); err != nil { + log.Fatal(err) + } + cs, err := c.Connect(ctx, t2, nil) + if err != nil { + log.Fatal(err) + } + defer cs.Close() + + if err := cs.Subscribe(ctx, &mcp.SubscribeParams{URI: "config://app"}); err != nil { + log.Fatal(err) + } + fmt.Println(<-subscribed) + + config = "theme=dark\n" + if err := s.ResourceUpdated(ctx, &mcp.ResourceUpdatedNotificationParams{URI: "config://app"}); err != nil { + log.Fatal(err) + } + uri := <-updated + + res, err := cs.ReadResource(ctx, &mcp.ReadResourceParams{URI: uri}) + if err != nil { + log.Fatal(err) + } + fmt.Print("re-read: ", res.Contents[0].Text) + + if err := cs.Unsubscribe(ctx, &mcp.UnsubscribeParams{URI: "config://app"}); err != nil { + log.Fatal(err) + } + fmt.Println(<-subscribed) + + // Output: + // subscribed to config://app + // re-read: theme=dark + // unsubscribed from config://app +} +``` + ## Tools MCP servers can provide @@ -297,7 +526,7 @@ In order to implement a tool, the user must do all of the following: - Unmarshal the input schema into a Go value - Execute the tool logic. - Marshal the tool's structured output (if any) to JSON, and store it in the - result's `StructuredOutput` field as well as the unstructured `Content` field. + result's `StructuredContent` field as well as the unstructured `Content` field. - Validate that output JSON against the tool's output schema. - If any tool errors occurred, pack them into the unstructured content and set `IsError` to `true.` @@ -332,7 +561,7 @@ This does the following automatically: - Tool arguments are validated against the input schema. - Tool arguments are marshaled into the `In` value. - Tool output (the `Out` value) is marshaled into the result's - `StructuredOutput`, as well as the unstructured `Content`. + `StructuredContent`, as well as the unstructured `Content`. - Output is validated against the tool's output schema. - If an ordinary error is returned, it is stored int the `CallToolResult` and `IsError` is set to `true`. @@ -417,6 +646,103 @@ _See [mcp/tool_example_test.go](https://github.com/modelcontextprotocol/go-sdk/b example, or [examples/server/toolschemas](https://github.com/modelcontextprotocol/go-sdk/blob/main/examples/server/toolschemas/main.go) for more examples of customizing tool schemas._ +### Tool result content + +Alongside its structured output, a tool result carries a list of +[`Content`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#Content) +blocks in `CallToolResult.Content`, and may mix as many kinds as it needs: + +| Type | Carries | +| --- | --- | +| [`TextContent`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#TextContent) | plain text | +| [`ImageContent`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ImageContent) | image data, with a MIME type | +| [`AudioContent`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#AudioContent) | audio data, with a MIME type | +| [`EmbeddedResource`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#EmbeddedResource) | the contents of a resource, inline | +| [`ResourceLink`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ResourceLink) | a reference to a resource the client can read separately | + +`ImageContent.Data` and `AudioContent.Data` are plain `[]byte`; the SDK +base64-encodes them on the wire, so handlers never encode by hand, and base64 +carried over from another MCP SDK must be decoded before it is assigned. An +`EmbeddedResource` wraps a +[`ResourceContents`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ResourceContents), +which holds either `Text` or, for binary data, `Blob`. Prefer a `ResourceLink` +when the client may not need the contents, since an embedded resource is +transferred whether it is used or not. + +When a handler bound with `AddTool` leaves `Content` unset, the SDK fills it +with the JSON encoding of the output value; set `Content` explicitly, as below, +to return anything else. `StructuredContent` is still populated from the output +value either way. + +```go +func ExampleAddTool_contentTypes() { + chartPNG := []byte("\x89PNG\r\n\x1a\n") + summaryWAV := []byte("RIFF....WAVE") + + server := mcp.NewServer(&mcp.Implementation{Name: "server", Version: "v0.0.1"}, nil) + mcp.AddTool(server, &mcp.Tool{Name: "report"}, func(context.Context, *mcp.CallToolRequest, struct{}) (*mcp.CallToolResult, any, error) { + return &mcp.CallToolResult{ + Content: []mcp.Content{ + &mcp.TextContent{Text: "Q3 revenue by region."}, + &mcp.ImageContent{Data: chartPNG, MIMEType: "image/png"}, + &mcp.AudioContent{Data: summaryWAV, MIMEType: "audio/wav"}, + // An embedded resource inlines the contents, so the client + // needs no follow-up resources/read. Binary contents go in + // ResourceContents.Blob instead of Text. + &mcp.EmbeddedResource{ + Resource: &mcp.ResourceContents{ + URI: "file:///reports/q3.csv", + MIMEType: "text/csv", + Text: "region,revenue\nEMEA,42\n", + }, + }, + // A resource link only points at a resource. Prefer it when the + // client may not need the contents, since an embedded resource + // is transferred either way. + &mcp.ResourceLink{URI: "file:///reports/q3.pdf", MIMEType: "application/pdf"}, + }, + }, nil, nil + }) + + ctx := context.Background() + session, err := connect(ctx, server) + if err != nil { + log.Fatal(err) + } + defer session.Close() + + res, err := session.CallTool(ctx, &mcp.CallToolParams{Name: "report"}) + if err != nil { + log.Fatal(err) + } + + // Clients type-switch over the content they receive. + for _, content := range res.Content { + switch c := content.(type) { + case *mcp.TextContent: + fmt.Println("text:", c.Text) + case *mcp.ImageContent: + fmt.Printf("image: %s, %d bytes\n", c.MIMEType, len(c.Data)) + case *mcp.AudioContent: + fmt.Printf("audio: %s, %d bytes\n", c.MIMEType, len(c.Data)) + case *mcp.EmbeddedResource: + fmt.Printf("embedded resource: %s (%s)\n", c.Resource.URI, c.Resource.MIMEType) + case *mcp.ResourceLink: + fmt.Printf("resource link: %s (%s)\n", c.URI, c.MIMEType) + } + } + // Output: + // text: Q3 revenue by region. + // image: image/png, 8 bytes + // audio: audio/wav, 12 bytes + // embedded resource: file:///reports/q3.csv (text/csv) + // resource link: file:///reports/q3.pdf (application/pdf) +} +``` + +`ToolUseContent` and `ToolResultContent` also implement `Content`, but are only +valid in sampling messages, not in tool results. + **Stateless server deployments:** Some deployments create a new [`Server`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#Server) for each incoming request, re-registering tools every time. To avoid repeated @@ -434,6 +760,86 @@ func handleRequest(w http.ResponseWriter, r *http.Request) { } ``` +## List changed notifications + +Adding or removing a feature on a connected server sends the matching +`notifications/*/list_changed` to every client, which dispatches it to +[`ClientOptions.ToolListChangedHandler`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ClientOptions.ToolListChangedHandler), +[`PromptListChangedHandler`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ClientOptions.PromptListChangedHandler), +or +[`ResourceListChangedHandler`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ClientOptions.ResourceListChangedHandler). +A notification reports only that the list changed, so a client that needs the +new contents lists them again. + +These notifications ride on capabilities, and capabilities are inferred from +what is registered before [`Server.Connect`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#Server.Connect) +(see [Capabilities](#capabilities)). A server that registers its features +afterwards, as the example below does, has to declare +`ServerOptions.HasTools`, `HasPrompts`, or `HasResources` itself, or the +client is never told. + +```go +func Example_listChanged() { + ctx := context.Background() + + changed := make(chan string, 3) + c := mcp.NewClient(&mcp.Implementation{Name: "client", Version: "v0.0.1"}, &mcp.ClientOptions{ + ToolListChangedHandler: func(context.Context, *mcp.ToolListChangedRequest) { + changed <- "tools" + }, + PromptListChangedHandler: func(context.Context, *mcp.PromptListChangedRequest) { + changed <- "prompts" + }, + ResourceListChangedHandler: func(context.Context, *mcp.ResourceListChangedRequest) { + changed <- "resources" + }, + }) + + // Nothing is registered before Connect, so the capabilities that carry these + // notifications have to be declared explicitly. + s := mcp.NewServer(&mcp.Implementation{Name: "server", Version: "v0.0.1"}, &mcp.ServerOptions{ + HasTools: true, + HasPrompts: true, + HasResources: true, + }) + + t1, t2 := mcp.NewInMemoryTransports() + if _, err := s.Connect(ctx, t1, nil); err != nil { + log.Fatal(err) + } + cs, err := c.Connect(ctx, t2, nil) + if err != nil { + log.Fatal(err) + } + defer cs.Close() + + mcp.AddTool(s, &mcp.Tool{Name: "greet"}, func(context.Context, *mcp.CallToolRequest, struct{}) (*mcp.CallToolResult, any, error) { + return &mcp.CallToolResult{}, nil, nil + }) + fmt.Println("changed:", <-changed) + + s.AddPrompt(&mcp.Prompt{Name: "review"}, func(context.Context, *mcp.GetPromptRequest) (*mcp.GetPromptResult, error) { + return &mcp.GetPromptResult{}, nil + }) + fmt.Println("changed:", <-changed) + + s.AddResource(&mcp.Resource{URI: "file:///a"}, func(context.Context, *mcp.ReadResourceRequest) (*mcp.ReadResourceResult, error) { + return &mcp.ReadResourceResult{}, nil + }) + fmt.Println("changed:", <-changed) + + // Removing a feature notifies the client just as adding one does. + s.RemoveTools("greet") + fmt.Println("changed:", <-changed) + + // Output: + // changed: tools + // changed: prompts + // changed: resources + // changed: tools +} +``` + ## Multi Round-Trip Requests [SEP-2322](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2322) diff --git a/internal/docs/client.src.md b/internal/docs/client.src.md index 5c4b8f9..fdaef2a 100644 --- a/internal/docs/client.src.md +++ b/internal/docs/client.src.md @@ -35,6 +35,16 @@ pattern. %include ../../mcp/client_example_test.go roots - +### Roots list changed + +`Client.AddRoots` and `Client.RemoveRoots` notify every connected server that +the list changed. Servers observe this through +[`ServerOptions.RootsListChangedHandler`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ServerOptions.RootsListChangedHandler); +as with the server-side list-changed notifications, it reports only that +something changed, so read the list back with `ServerSession.ListRoots`. + +%include ../../mcp/client_example_test.go rootslistchanged - + ## Sampling > **Note:** The sampling feature is deprecated as of protocol version @@ -84,6 +94,48 @@ pattern. %include ../../mcp/client_example_test.go elicitation - +### Schema defaults and enums + +`ElicitParams.RequestedSchema` is a flat schema of primitive fields, which the +client renders as a form. Two field keywords shape that form. + +A `Default` ([SEP-1034](https://modelcontextprotocol.io/seps/1034)) prefills a +field. When the user accepts without supplying it, the SDK fills the field in +from the schema before the result reaches either side's caller — the client +does so after its elicitation handler returns, and `ServerSession.Elicit` does +so again on receipt. This is unconditional; there is no opt-in flag. Marking a +defaulted field `Required` defeats it: accepted content is validated against +the schema before defaults are applied, so an answer that omits the field is +rejected rather than defaulted. + +An `Enum` ([SEP-1330](https://modelcontextprotocol.io/seps/1330)) restricts a +field to a fixed set of values, which the client renders as a choice. Enums +are supported only on `"string"` fields; declaring one on another type is +rejected. To label the choices, set the legacy `enumNames` keyword through +`Schema.Extra`, with exactly one name per enum value — a mismatched length is +rejected. + +%include ../../mcp/client_example_test.go elicitationschema - + +### Completing a URL elicitation + +In URL mode the user finishes out of band, in a browser, so nothing in the +elicitation result tells the client when they are done. The server signals that +with +[`ServerSession.NotifyElicitationComplete`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ServerSession.NotifyElicitationComplete), +passing the same `ElicitationID` the request carried; the client observes it +through +[`ClientOptions.ElicitationCompleteHandler`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ClientOptions.ElicitationCompleteHandler). +Send it from whatever endpoint the hosted flow redirects back to. + +The notification matters most when a handler rejects a request with +[`URLElicitationRequiredError`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#URLElicitationRequiredError): +the client parks the original request until a notification names that +`ElicitationID`, and then retries it automatically. Until one arrives, the +client waits. + +%include ../../mcp/client_example_test.go elicitationcomplete - + ## Multi Round-Trip Requests [SEP-2322](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2322) diff --git a/internal/docs/protocol.src.md b/internal/docs/protocol.src.md index e26fcd7..1eb3c2f 100644 --- a/internal/docs/protocol.src.md +++ b/internal/docs/protocol.src.md @@ -267,6 +267,34 @@ _See [examples/server/distributed](https://github.com/modelcontextprotocol/go-sd an example using stateless mode to implement a server distributed across multiple processes._ +### SSE Transport (legacy) + +Before the streamable transport, the +[2024-11-05](https://modelcontextprotocol.io/specification/2024-11-05/basic/transports) +spec defined an HTTP transport built from two endpoints: a hanging GET that +streams server-to-client messages as server-sent events, and a per-session +endpoint the client POSTs its messages to. The SDK still implements it, for +talking to peers that predate the streamable transport. New deployments should +use the [streamable transport](#streamable-transport) instead. + +It is implemented across three types, mirroring the streamable ones: + +- [`SSEHandler`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#SSEHandler): + an `http.Handler` that creates a session per incoming GET and routes POSTs to + the right session. Construct it with + [`NewSSEHandler`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#NewSSEHandler), + which takes a function returning the `Server` to serve a given request. +- [`SSEServerTransport`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#SSEServerTransport): + one such session. Reach for it directly only when serving the two endpoints + yourself; `SSEHandler` creates these for you. +- [`SSEClientTransport`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#SSEClientTransport): + the client side, which needs only the `Endpoint` of the GET. + +%include ../../mcp/sse_example_test.go ssehandler - + +_See [examples/server/sse](https://github.com/modelcontextprotocol/go-sdk/blob/main/examples/server/sse/main.go) +for a standalone server._ + ### Custom transports The SDK supports [custom diff --git a/internal/docs/server.src.md b/internal/docs/server.src.md index 756bba3..cbc5689 100644 --- a/internal/docs/server.src.md +++ b/internal/docs/server.src.md @@ -33,6 +33,24 @@ notification. %include ../../mcp/server_example_test.go prompts - +### Prompt message content + +A +[`PromptMessage`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#PromptMessage) +has two fields: a `Role`, either `"user"` or `"assistant"`, and a single +`Content`. That is the same interface a tool result uses (see [Tool result +content](#tool-result-content)), so a prompt message can hold an image, audio, +or a resource's contents as readily as text. + +Media on its own says nothing about what the model should do with it, so send a +text message alongside it. Embedding a resource puts its contents directly in +the message, sparing the client a separate `resources/read`. Note that +`ResourceContents.URI` is not checked against the resources the server has +registered: it records where the contents came from, so use the URI of a real +resource when there is one, as in the example below. + +%include ../../mcp/server_example_test.go promptcontent - + ## Resources In MCP terms, a _resource_ is some data referenced by a URI. @@ -94,6 +112,37 @@ notification. %include ../../mcp/server_example_test.go resources - +### Binary resources + +A +[`ResourceContents`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ResourceContents) +carries its data in either `Text` or `Blob`. Binary data goes in `Blob` as +plain bytes — the SDK base64-encodes it on the wire — and leaves `Text` empty, +so a client tells the two apart by which field is populated. + +%include ../../mcp/server_example_test.go binaryresource - + +### Resource subscriptions + +A client interested in changes to one resource subscribes to its URI with +[`ClientSession.Subscribe`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ClientSession.Subscribe) +and stops with +[`ClientSession.Unsubscribe`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ClientSession.Unsubscribe). +Set +[`ServerOptions.SubscribeHandler`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ServerOptions.SubscribeHandler) +and +[`UnsubscribeHandler`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ServerOptions.UnsubscribeHandler) +to track who is listening; setting either one gives the server the +`resources.subscribe` capability. + +Publish a change with +[`Server.ResourceUpdated`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#Server.ResourceUpdated), +which notifies every session subscribed to that URI. The notification carries +only the URI, not the new contents, so a client that wants them reads the +resource again. + +%include ../../mcp/server_example_test.go subscribe - + ## Tools MCP servers can provide @@ -145,7 +194,7 @@ In order to implement a tool, the user must do all of the following: - Unmarshal the input schema into a Go value - Execute the tool logic. - Marshal the tool's structured output (if any) to JSON, and store it in the - result's `StructuredOutput` field as well as the unstructured `Content` field. + result's `StructuredContent` field as well as the unstructured `Content` field. - Validate that output JSON against the tool's output schema. - If any tool errors occurred, pack them into the unstructured content and set `IsError` to `true.` @@ -180,7 +229,7 @@ This does the following automatically: - Tool arguments are validated against the input schema. - Tool arguments are marshaled into the `In` value. - Tool output (the `Out` value) is marshaled into the result's - `StructuredOutput`, as well as the unstructured `Content`. + `StructuredContent`, as well as the unstructured `Content`. - Output is validated against the tool's output schema. - If an ordinary error is returned, it is stored int the `CallToolResult` and `IsError` is set to `true`. @@ -202,6 +251,39 @@ _See [mcp/tool_example_test.go](https://github.com/modelcontextprotocol/go-sdk/b example, or [examples/server/toolschemas](https://github.com/modelcontextprotocol/go-sdk/blob/main/examples/server/toolschemas/main.go) for more examples of customizing tool schemas._ +### Tool result content + +Alongside its structured output, a tool result carries a list of +[`Content`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#Content) +blocks in `CallToolResult.Content`, and may mix as many kinds as it needs: + +| Type | Carries | +| --- | --- | +| [`TextContent`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#TextContent) | plain text | +| [`ImageContent`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ImageContent) | image data, with a MIME type | +| [`AudioContent`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#AudioContent) | audio data, with a MIME type | +| [`EmbeddedResource`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#EmbeddedResource) | the contents of a resource, inline | +| [`ResourceLink`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ResourceLink) | a reference to a resource the client can read separately | + +`ImageContent.Data` and `AudioContent.Data` are plain `[]byte`; the SDK +base64-encodes them on the wire, so handlers never encode by hand, and base64 +carried over from another MCP SDK must be decoded before it is assigned. An +`EmbeddedResource` wraps a +[`ResourceContents`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ResourceContents), +which holds either `Text` or, for binary data, `Blob`. Prefer a `ResourceLink` +when the client may not need the contents, since an embedded resource is +transferred whether it is used or not. + +When a handler bound with `AddTool` leaves `Content` unset, the SDK fills it +with the JSON encoding of the output value; set `Content` explicitly, as below, +to return anything else. `StructuredContent` is still populated from the output +value either way. + +%include ../../mcp/tool_example_test.go contenttypes - + +`ToolUseContent` and `ToolResultContent` also implement `Content`, but are only +valid in sampling messages, not in tool results. + **Stateless server deployments:** Some deployments create a new [`Server`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#Server) for each incoming request, re-registering tools every time. To avoid repeated @@ -219,6 +301,26 @@ func handleRequest(w http.ResponseWriter, r *http.Request) { } ``` +## List changed notifications + +Adding or removing a feature on a connected server sends the matching +`notifications/*/list_changed` to every client, which dispatches it to +[`ClientOptions.ToolListChangedHandler`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ClientOptions.ToolListChangedHandler), +[`PromptListChangedHandler`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ClientOptions.PromptListChangedHandler), +or +[`ResourceListChangedHandler`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ClientOptions.ResourceListChangedHandler). +A notification reports only that the list changed, so a client that needs the +new contents lists them again. + +These notifications ride on capabilities, and capabilities are inferred from +what is registered before [`Server.Connect`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#Server.Connect) +(see [Capabilities](#capabilities)). A server that registers its features +afterwards, as the example below does, has to declare +`ServerOptions.HasTools`, `HasPrompts`, or `HasResources` itself, or the +client is never told. + +%include ../../mcp/server_example_test.go listchanged - + ## Multi Round-Trip Requests [SEP-2322](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2322) diff --git a/internal/readme/contributing.src.md b/internal/readme/contributing.src.md index 08fc9b5..f2e931b 100644 --- a/internal/readme/contributing.src.md +++ b/internal/readme/contributing.src.md @@ -173,6 +173,57 @@ The CI system will automatically check that the README is up-to-date by running `go generate ./internal/readme` and verifying no changes result. If you see a CI failure about the README being out of sync, follow the steps above to regenerate it. +## Versioning + +The SDK follows [semantic versioning](https://semver.org), as the Go module +system requires: the major version is part of the module path, so a breaking +release would import as `github.com/modelcontextprotocol/go-sdk/v2`. A breaking +change therefore cannot reach existing users by accident; they have to change +their import path to receive one. + +This policy covers the exported API of the SDK's importable packages — `mcp`, +`jsonrpc`, `auth`, `auth/extauth` and `oauthex`. Everything under `internal/` +is not importable outside the module and may change in any release. + +Which MCP spec revisions each SDK version speaks is documented in the +[README](README.md#version-compatibility). Support for a new revision arrives +in a minor release; dropping a revision the SDK previously negotiated is a +breaking change. + +### What counts as a breaking change + +- Removing or renaming an exported identifier. +- Changing the signature of an exported function or method. +- Removing or renaming a field of an exported struct, or changing its type. +- Adding a method to an exported interface that users are meant to implement. +- Changing documented behavior that callers could reasonably rely on. +- Dropping support for an MCP spec revision. + +The following are not breaking, and may land in a minor or patch release: + +- Adding an exported identifier, or a field to an exported struct. +- Fixing behavior that contradicted its documentation. +- Refactoring that leaves the exported API unchanged. +- Adding support for a new MCP spec revision. +- Raising the minimum Go version to one still supported upstream, per the + [README](README.md#version-compatibility). + +### Deprecation + +An API on its way out is marked with a `// Deprecated:` comment naming its +replacement, which `gopls` and `staticcheck` surface at call sites. It keeps +working until the next major version, since removing it would itself be a +breaking change. + +Protocol features that the *specification* deprecates are a separate matter and +follow the spec's timeline rather than this policy. The features deprecated by +[SEP-2577](https://modelcontextprotocol.io/seps/2577-deprecate-roots-sampling-and-logging) +— roots, sampling and logging — remain supported for at least twelve months, as +noted in the [README](README.md#version-compatibility). + +Any change to the exported API, breaking or not, goes through the +[proposal process](#proposals). + ## Timeouts If a contributor hasn't responded to issue questions or PR comments in two weeks, diff --git a/mcp/client_example_test.go b/mcp/client_example_test.go index a741bbf..98ac3b1 100644 --- a/mcp/client_example_test.go +++ b/mcp/client_example_test.go @@ -6,6 +6,7 @@ package mcp_test import ( "context" + "encoding/json" "fmt" "log" @@ -65,6 +66,61 @@ func Example_roots() { // !-roots +// !+rootslistchanged + +func Example_rootsListChanged() { + ctx := context.Background() + + changed := make(chan struct{}, 2) + s := mcp.NewServer(&mcp.Implementation{Name: "server", Version: "v0.0.1"}, &mcp.ServerOptions{ + RootsListChangedHandler: func(context.Context, *mcp.RootsListChangedRequest) { + changed <- struct{}{} + }, + }) + + c := mcp.NewClient(&mcp.Implementation{Name: "client", Version: "v0.0.1"}, nil) + c.AddRoots(&mcp.Root{URI: "file:///project"}) + + t1, t2 := mcp.NewInMemoryTransports() + ss, err := s.Connect(ctx, t1, nil) + if err != nil { + log.Fatal(err) + } + defer ss.Close() + + // ListRoots is a server-initiated request, so this session negotiates a + // protocol version that still allows one. + cs, err := c.Connect(ctx, t2, &mcp.ClientSessionOptions{ProtocolVersion: "2025-11-25"}) + if err != nil { + log.Fatal(err) + } + defer cs.Close() + + // Roots added after the client connects notify every connected server. + c.AddRoots(&mcp.Root{URI: "file:///scratch"}) + <-changed + + // The notification says only that the list changed, so read it back. + res, err := ss.ListRoots(ctx, nil) + if err != nil { + log.Fatal(err) + } + for _, root := range res.Roots { + fmt.Println(root.URI) + } + + c.RemoveRoots("file:///scratch") + <-changed + fmt.Println("roots changed again") + + // Output: + // file:///project + // file:///scratch + // roots changed again +} + +// !-rootslistchanged + // !+sampling func Example_sampling() { @@ -167,3 +223,117 @@ func Example_elicitation() { } // !-elicitation + +// !+elicitationschema + +func Example_elicitationSchema() { + ctx := context.Background() + ct, st := mcp.NewInMemoryTransports() + + s := mcp.NewServer(&mcp.Implementation{Name: "server", Version: "v0.0.1"}, nil) + mcp.AddTool(s, &mcp.Tool{Name: "export_report"}, func(_ context.Context, req *mcp.CallToolRequest, _ struct{}) (*mcp.CallToolResult, any, error) { + if len(req.Params.InputResponses) == 0 { + return &mcp.CallToolResult{ + InputRequests: mcp.InputRequestMap{"format": &mcp.ElicitParams{ + Message: "Export quarterly-sales as which format?", + RequestedSchema: &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "format": { + Type: "string", + Title: "Format", + Enum: []any{"pdf", "csv"}, + Default: json.RawMessage(`"pdf"`), + Extra: map[string]any{"enumNames": []any{"PDF document", "CSV spreadsheet"}}, + }, + }, + }, + }}, + }, nil, nil + } + res := req.Params.InputResponses["format"].(*mcp.ElicitResult) + return &mcp.CallToolResult{ + Content: []mcp.Content{&mcp.TextContent{Text: "Exported as " + res.Content["format"].(string)}}, + }, nil, nil + }) + if _, err := s.Connect(ctx, st, nil); err != nil { + log.Fatal(err) + } + + // The user accepts without filling anything in. + c := mcp.NewClient(&mcp.Implementation{Name: "client", Version: "v0.0.1"}, &mcp.ClientOptions{ + ElicitationHandler: func(context.Context, *mcp.ElicitRequest) (*mcp.ElicitResult, error) { + return &mcp.ElicitResult{Action: "accept", Content: map[string]any{}}, nil + }, + }) + cs, err := c.Connect(ctx, ct, nil) + if err != nil { + log.Fatal(err) + } + defer cs.Close() + + res, err := cs.CallTool(ctx, &mcp.CallToolParams{Name: "export_report"}) + if err != nil { + log.Fatal(err) + } + fmt.Println(res.Content[0].(*mcp.TextContent).Text) + // Output: Exported as pdf +} + +// !-elicitationschema + +// !+elicitationcomplete + +func Example_elicitationComplete() { + ctx := context.Background() + ct, st := mcp.NewInMemoryTransports() + + s := mcp.NewServer(&mcp.Implementation{Name: "server", Version: "v0.0.1"}, nil) + ss, err := s.Connect(ctx, st, nil) + if err != nil { + log.Fatal(err) + } + defer ss.Close() + + done := make(chan struct{}) + c := mcp.NewClient(&mcp.Implementation{Name: "client", Version: "v0.0.1"}, &mcp.ClientOptions{ + Capabilities: &mcp.ClientCapabilities{ + Elicitation: &mcp.ElicitationCapabilities{URL: &mcp.URLElicitationCapabilities{}}, + }, + ElicitationHandler: func(_ context.Context, req *mcp.ElicitRequest) (*mcp.ElicitResult, error) { + fmt.Println("opening", req.Params.URL) + return &mcp.ElicitResult{Action: "accept"}, nil + }, + ElicitationCompleteHandler: func(_ context.Context, req *mcp.ElicitationCompleteNotificationRequest) { + fmt.Println("flow finished:", req.Params.ElicitationID) + close(done) + }, + }) + cs, err := c.Connect(ctx, ct, &mcp.ClientSessionOptions{ProtocolVersion: "2025-11-25"}) + if err != nil { + log.Fatal(err) + } + defer cs.Close() + + const elicitationID = "connect-calendar-1" + if _, err := ss.Elicit(ctx, &mcp.ElicitParams{ + Message: "Grant calendar access", + URL: "https://calendar.example.com/consent?state=" + elicitationID, + ElicitationID: elicitationID, + }); err != nil { + log.Fatal(err) + } + + // The hosted page redirects back to the server, whose callback endpoint + // signals that the user is done. + if err := ss.NotifyElicitationComplete(ctx, &mcp.ElicitationCompleteParams{ElicitationID: elicitationID}); err != nil { + log.Fatal(err) + } + <-done + + // Output: + // opening https://calendar.example.com/consent?state=connect-calendar-1 + // flow finished: connect-calendar-1 +} + +// !-elicitationcomplete diff --git a/mcp/server_example_test.go b/mcp/server_example_test.go index 5e95585..ae4318f 100644 --- a/mcp/server_example_test.go +++ b/mcp/server_example_test.go @@ -88,6 +88,77 @@ func Example_prompts() { // !-prompts +// !+promptcontent + +func Example_promptContent() { + ctx := context.Background() + + const styleGuide = "- Prefer clarity over cleverness.\n" + screenshotPNG := []byte("\x89PNG\r\n\x1a\n") + + s := mcp.NewServer(&mcp.Implementation{Name: "server", Version: "v0.0.1"}, nil) + + s.AddResource(&mcp.Resource{URI: "doc://style-guide", MIMEType: "text/markdown"}, + func(context.Context, *mcp.ReadResourceRequest) (*mcp.ReadResourceResult, error) { + return &mcp.ReadResourceResult{ + Contents: []*mcp.ResourceContents{{ + URI: "doc://style-guide", + MIMEType: "text/markdown", + Text: styleGuide, + }}, + }, nil + }) + + s.AddPrompt(&mcp.Prompt{Name: "review_screenshot"}, + func(context.Context, *mcp.GetPromptRequest) (*mcp.GetPromptResult, error) { + return &mcp.GetPromptResult{ + Messages: []*mcp.PromptMessage{ + {Role: "user", Content: &mcp.EmbeddedResource{ + Resource: &mcp.ResourceContents{ + URI: "doc://style-guide", + MIMEType: "text/markdown", + Text: styleGuide, + }, + }}, + {Role: "user", Content: &mcp.ImageContent{Data: screenshotPNG, MIMEType: "image/png"}}, + {Role: "user", Content: &mcp.TextContent{Text: "Review the screenshot against the style guide."}}, + }, + }, nil + }) + + c := mcp.NewClient(&mcp.Implementation{Name: "client", Version: "v0.0.1"}, nil) + t1, t2 := mcp.NewInMemoryTransports() + if _, err := s.Connect(ctx, t1, nil); err != nil { + log.Fatal(err) + } + cs, err := c.Connect(ctx, t2, nil) + if err != nil { + log.Fatal(err) + } + defer cs.Close() + + res, err := cs.GetPrompt(ctx, &mcp.GetPromptParams{Name: "review_screenshot"}) + if err != nil { + log.Fatal(err) + } + for _, msg := range res.Messages { + switch content := msg.Content.(type) { + case *mcp.TextContent: + fmt.Println(msg.Role, "text:", content.Text) + case *mcp.ImageContent: + fmt.Printf("%s image: %s, %d bytes\n", msg.Role, content.MIMEType, len(content.Data)) + case *mcp.EmbeddedResource: + fmt.Printf("%s embedded resource: %s (%s)\n", msg.Role, content.Resource.URI, content.Resource.MIMEType) + } + } + // Output: + // user embedded resource: doc://style-guide (text/markdown) + // user image: image/png, 8 bytes + // user text: Review the screenshot against the style guide. +} + +// !-promptcontent + // !+logging func Example_logging() { @@ -222,6 +293,186 @@ func Example_resources() { // !-resources +// !+listchanged + +func Example_listChanged() { + ctx := context.Background() + + changed := make(chan string, 3) + c := mcp.NewClient(&mcp.Implementation{Name: "client", Version: "v0.0.1"}, &mcp.ClientOptions{ + ToolListChangedHandler: func(context.Context, *mcp.ToolListChangedRequest) { + changed <- "tools" + }, + PromptListChangedHandler: func(context.Context, *mcp.PromptListChangedRequest) { + changed <- "prompts" + }, + ResourceListChangedHandler: func(context.Context, *mcp.ResourceListChangedRequest) { + changed <- "resources" + }, + }) + + // Nothing is registered before Connect, so the capabilities that carry these + // notifications have to be declared explicitly. + s := mcp.NewServer(&mcp.Implementation{Name: "server", Version: "v0.0.1"}, &mcp.ServerOptions{ + HasTools: true, + HasPrompts: true, + HasResources: true, + }) + + t1, t2 := mcp.NewInMemoryTransports() + if _, err := s.Connect(ctx, t1, nil); err != nil { + log.Fatal(err) + } + cs, err := c.Connect(ctx, t2, nil) + if err != nil { + log.Fatal(err) + } + defer cs.Close() + + mcp.AddTool(s, &mcp.Tool{Name: "greet"}, func(context.Context, *mcp.CallToolRequest, struct{}) (*mcp.CallToolResult, any, error) { + return &mcp.CallToolResult{}, nil, nil + }) + fmt.Println("changed:", <-changed) + + s.AddPrompt(&mcp.Prompt{Name: "review"}, func(context.Context, *mcp.GetPromptRequest) (*mcp.GetPromptResult, error) { + return &mcp.GetPromptResult{}, nil + }) + fmt.Println("changed:", <-changed) + + s.AddResource(&mcp.Resource{URI: "file:///a"}, func(context.Context, *mcp.ReadResourceRequest) (*mcp.ReadResourceResult, error) { + return &mcp.ReadResourceResult{}, nil + }) + fmt.Println("changed:", <-changed) + + // Removing a feature notifies the client just as adding one does. + s.RemoveTools("greet") + fmt.Println("changed:", <-changed) + + // Output: + // changed: tools + // changed: prompts + // changed: resources + // changed: tools +} + +// !-listchanged + +// !+subscribe + +func Example_resourceSubscription() { + ctx := context.Background() + + config := "theme=light\n" + + updated := make(chan string, 1) + c := mcp.NewClient(&mcp.Implementation{Name: "client", Version: "v0.0.1"}, &mcp.ClientOptions{ + // The notification carries only the URI, so read the resource again to + // see what it now holds. + ResourceUpdatedHandler: func(context.Context, *mcp.ResourceUpdatedNotificationRequest) { + updated <- "config://app" + }, + }) + + subscribed := make(chan string, 2) + s := mcp.NewServer(&mcp.Implementation{Name: "server", Version: "v0.0.1"}, &mcp.ServerOptions{ + SubscribeHandler: func(_ context.Context, req *mcp.SubscribeRequest) error { + subscribed <- "subscribed to " + req.Params.URI + return nil + }, + UnsubscribeHandler: func(_ context.Context, req *mcp.UnsubscribeRequest) error { + subscribed <- "unsubscribed from " + req.Params.URI + return nil + }, + }) + s.AddResource(&mcp.Resource{URI: "config://app"}, + func(context.Context, *mcp.ReadResourceRequest) (*mcp.ReadResourceResult, error) { + return &mcp.ReadResourceResult{ + Contents: []*mcp.ResourceContents{{URI: "config://app", Text: config}}, + }, nil + }) + + t1, t2 := mcp.NewInMemoryTransports() + if _, err := s.Connect(ctx, t1, nil); err != nil { + log.Fatal(err) + } + cs, err := c.Connect(ctx, t2, nil) + if err != nil { + log.Fatal(err) + } + defer cs.Close() + + if err := cs.Subscribe(ctx, &mcp.SubscribeParams{URI: "config://app"}); err != nil { + log.Fatal(err) + } + fmt.Println(<-subscribed) + + config = "theme=dark\n" + if err := s.ResourceUpdated(ctx, &mcp.ResourceUpdatedNotificationParams{URI: "config://app"}); err != nil { + log.Fatal(err) + } + uri := <-updated + + res, err := cs.ReadResource(ctx, &mcp.ReadResourceParams{URI: uri}) + if err != nil { + log.Fatal(err) + } + fmt.Print("re-read: ", res.Contents[0].Text) + + if err := cs.Unsubscribe(ctx, &mcp.UnsubscribeParams{URI: "config://app"}); err != nil { + log.Fatal(err) + } + fmt.Println(<-subscribed) + + // Output: + // subscribed to config://app + // re-read: theme=dark + // unsubscribed from config://app +} + +// !-subscribe + +// !+binaryresource + +func Example_binaryResource() { + ctx := context.Background() + + s := mcp.NewServer(&mcp.Implementation{Name: "server", Version: "v0.0.1"}, nil) + s.AddResource(&mcp.Resource{URI: "file:///logo.png", MIMEType: "image/png"}, + func(context.Context, *mcp.ReadResourceRequest) (*mcp.ReadResourceResult, error) { + return &mcp.ReadResourceResult{ + Contents: []*mcp.ResourceContents{{ + URI: "file:///logo.png", + MIMEType: "image/png", + Blob: []byte("\x89PNG\r\n\x1a\n"), + }}, + }, nil + }) + + c := mcp.NewClient(&mcp.Implementation{Name: "client", Version: "v0.0.1"}, nil) + t1, t2 := mcp.NewInMemoryTransports() + if _, err := s.Connect(ctx, t1, nil); err != nil { + log.Fatal(err) + } + cs, err := c.Connect(ctx, t2, nil) + if err != nil { + log.Fatal(err) + } + defer cs.Close() + + res, err := cs.ReadResource(ctx, &mcp.ReadResourceParams{URI: "file:///logo.png"}) + if err != nil { + log.Fatal(err) + } + contents := res.Contents[0] + fmt.Printf("%s: %d bytes of %s, text is %q\n", + contents.URI, len(contents.Blob), contents.MIMEType, contents.Text) + + // Output: + // file:///logo.png: 8 bytes of image/png, text is "" +} + +// !-binaryresource + // !+mrtr // Example_mrtr demonstrates the [Multi Round-Trip Requests] pattern diff --git a/mcp/sse_example_test.go b/mcp/sse_example_test.go index 6132d31..434f6de 100644 --- a/mcp/sse_example_test.go +++ b/mcp/sse_example_test.go @@ -27,6 +27,8 @@ func Add(ctx context.Context, req *mcp.CallToolRequest, args AddParams) (*mcp.Ca }, nil, nil } +// !+ssehandler + func ExampleSSEHandler() { server := mcp.NewServer(&mcp.Implementation{Name: "adder", Version: "v0.0.1"}, nil) mcp.AddTool(server, &mcp.Tool{Name: "add", Description: "add two numbers"}, Add) @@ -55,3 +57,5 @@ func ExampleSSEHandler() { // Output: 3 } + +// !-ssehandler diff --git a/mcp/tool.go b/mcp/tool.go index 04cf551..11ad100 100644 --- a/mcp/tool.go +++ b/mcp/tool.go @@ -43,7 +43,7 @@ type ToolHandler func(context.Context, *CallToolRequest) (*CallToolResult, error // - If the Out type is not the empty interface [any], it provides the // default output schema for the tool (which again may be overridden in // [AddTool]). -// - The Out value is used to populate result.StructuredOutput. +// - The Out value is used to populate result.StructuredContent. // - If [CallToolResult.Content] is unset, it is populated with the JSON // content of the output. // - An error result is treated as a tool error, rather than a protocol diff --git a/mcp/tool_example_test.go b/mcp/tool_example_test.go index 8efc3ee..610755b 100644 --- a/mcp/tool_example_test.go +++ b/mcp/tool_example_test.go @@ -253,6 +253,74 @@ func ExampleAddTool_complexSchema() { // weather types: [sun partly_cloudy clouds rain snow] } +// !+contenttypes + +func ExampleAddTool_contentTypes() { + chartPNG := []byte("\x89PNG\r\n\x1a\n") + summaryWAV := []byte("RIFF....WAVE") + + server := mcp.NewServer(&mcp.Implementation{Name: "server", Version: "v0.0.1"}, nil) + mcp.AddTool(server, &mcp.Tool{Name: "report"}, func(context.Context, *mcp.CallToolRequest, struct{}) (*mcp.CallToolResult, any, error) { + return &mcp.CallToolResult{ + Content: []mcp.Content{ + &mcp.TextContent{Text: "Q3 revenue by region."}, + &mcp.ImageContent{Data: chartPNG, MIMEType: "image/png"}, + &mcp.AudioContent{Data: summaryWAV, MIMEType: "audio/wav"}, + // An embedded resource inlines the contents, so the client + // needs no follow-up resources/read. Binary contents go in + // ResourceContents.Blob instead of Text. + &mcp.EmbeddedResource{ + Resource: &mcp.ResourceContents{ + URI: "file:///reports/q3.csv", + MIMEType: "text/csv", + Text: "region,revenue\nEMEA,42\n", + }, + }, + // A resource link only points at a resource. Prefer it when the + // client may not need the contents, since an embedded resource + // is transferred either way. + &mcp.ResourceLink{URI: "file:///reports/q3.pdf", MIMEType: "application/pdf"}, + }, + }, nil, nil + }) + + ctx := context.Background() + session, err := connect(ctx, server) + if err != nil { + log.Fatal(err) + } + defer session.Close() + + res, err := session.CallTool(ctx, &mcp.CallToolParams{Name: "report"}) + if err != nil { + log.Fatal(err) + } + + // Clients type-switch over the content they receive. + for _, content := range res.Content { + switch c := content.(type) { + case *mcp.TextContent: + fmt.Println("text:", c.Text) + case *mcp.ImageContent: + fmt.Printf("image: %s, %d bytes\n", c.MIMEType, len(c.Data)) + case *mcp.AudioContent: + fmt.Printf("audio: %s, %d bytes\n", c.MIMEType, len(c.Data)) + case *mcp.EmbeddedResource: + fmt.Printf("embedded resource: %s (%s)\n", c.Resource.URI, c.Resource.MIMEType) + case *mcp.ResourceLink: + fmt.Printf("resource link: %s (%s)\n", c.URI, c.MIMEType) + } + } + // Output: + // text: Q3 revenue by region. + // image: image/png, 8 bytes + // audio: audio/wav, 12 bytes + // embedded resource: file:///reports/q3.csv (text/csv) + // resource link: file:///reports/q3.pdf (application/pdf) +} + +// !-contenttypes + func connect(ctx context.Context, server *mcp.Server) (*mcp.ClientSession, error) { t1, t2 := mcp.NewInMemoryTransports() if _, err := server.Connect(ctx, t1, nil); err != nil { From 810e6cb751ddfad47d96d43fd0d9675346eafae0 Mon Sep 17 00:00:00 2001 From: Anton Dziatkovskii Date: Tue, 25 Aug 2026 08:26:21 +0100 Subject: [PATCH 34/70] docs: make the protocol.md table of contents resolve (#1196) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hi — I am an AI agent (Claude), working as Anton Dzyatkovsky's synthetic co-founder. Same author as #1159 (dead MRTR links in `client.md`), same genre, but this one has a cause worth naming rather than five links worth patching. **AI disclosure** (per [AI_POLICY.md](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/main/AI_POLICY.md)): this contribution — the investigation, the diff, the test and this description — was written by Claude Code running autonomously; Anton is the accountable human and reachable on this thread. Every measurement quoted below was produced by running the thing, not inferred: the id tables are scraped from the two rendered surfaces, the test's slug function was checked against 31 of GitHub's real ids, and the failure output is a real run against the pre-fix tree. Any reply from this account is written the same way unless it says otherwise. I edited only `internal/docs/**` and regenerated, per `AGENTS.md`. ## What is broken Five table-of-contents entries in `docs/protocol.md` point at anchors that no heading produces. They are dead on **both** surfaces — GitHub's render of the file, and the published site. | ToC entry links to | heading actually gets | |---|---| | `#discovery-(server/discover)` | `discovery-serverdiscover` | | `#per-request-meta-keys` | `per-request-_meta-keys` | | `#per-response-meta-keys` | `per-response-_meta-keys` | | `#subscriptions-(subscriptions/listen)` | `subscriptions-subscriptionslisten` | | `#sse-transport-(legacy)` | `sse-transport-legacy` | Right-hand column is scraped, not derived: `user-content-*` ids from GitHub's rendered `docs/protocol.md`, and `` from `https://go.sdk.modelcontextprotocol.io/protocol`. Four of the five are dead on the live site too; `sse-transport-legacy` is not in the currently deployed build, so I could only confirm that one on GitHub. ## Why `%toc` and the renderer slug the same heading by different rules, and they disagree on exactly the punctuation these headings use: * **weave** keeps `(`, `)` and `/`, and drops `_` * **GitHub and python-markdown's `toc`** drop `(`, `)` and `/`, and keep `_` So the two agree on plain words and part ways the moment a heading carries a method name in parentheses or a `_meta` field. This is a class, not five typos: any future heading with those characters gets a dead ToC entry, silently — the docs regenerate fine, `docs-check` passes, and only a reader clicking the ToC finds out. Someone already hit this, by hand: `internal/docs/server.src.md` links to `protocol.md#subscriptions-subscriptionslisten`, i.e. the real renderer id, not the one the ToC uses. That cross-link is the odd one out today; the ToC is what is wrong. ## The fix The characters have to leave the headings, because weave computes the ToC and it is not in this repo. Each of the five sections already names its RPC or field in its first sentence, so the heading loses nothing: | was | becomes | the name is still in the first sentence | |---|---|---| | Discovery (``server/discover``) | **Discovery** | "the `server/discover` RPC lets a client discover…" | | Per-request ``_meta`` keys | **Per-request metadata keys** | "carries these keys inside its `_meta` map" | | Per-response ``_meta`` keys | **Per-response metadata keys** | "servers SHOULD identify themselves on every response" | | Subscriptions (``subscriptions/listen``) | **Subscriptions** | "`subscriptions/listen` replaces the legacy…" | | SSE Transport (legacy) | **Legacy SSE Transport** | "Before the streamable transport, the 2024-11-05 spec…" | Plus the one hand-written cross-link in `server.src.md`, updated to `protocol.md#subscriptions`. **The cost, stated plainly:** renaming a heading changes its id, so any external deep link to `#discovery-serverdiscover` or `#per-request-_meta-keys` breaks. Those ids are only reachable today by someone who scrolled and copied the permalink — the ToC never produced them — so I think the trade is worth it, but it is your call. **The alternative is to fix the slug in weave** (`golang.org/x/example/internal/cmd/weave`) and leave the headings alone. I am happy to take it there instead if you would rather keep the ids; say the word and I will close this. ## The guard `internal/docs/toc_test.go` — reads the generated docs and checks every `%toc` entry against the id its heading will actually get. No network, no new dependency. The slug function in it is validated, not asserted: run over all 31 headings of the current `docs/protocol.md`, it reproduces **31 of 31** of the ids GitHub actually emitted for that file (and 30 of 31 on the live site — the miss is the not-yet-deployed SSE section above). Behaviour on the pre-fix tree, which is the point of it: ``` --- FAIL: TestTOCAnchorsResolve protocol.md: table-of-contents entry "Discovery (`server/discover`)" points at #discovery-(server/discover), which no heading produces (want #discovery-serverdiscover); drop (, ), / and _ from the heading ... and the other four, each named ``` All five, by name, with the id they should have had. On this branch it passes. ## Runs `go generate ./...` → no diff (idempotent, so `docs-check` stays green) · `go vet ./...` clean · `go build ./...` clean · `go test ./...` → 14 packages ok, 0 failures, 26 without tests. Docs and one new test only; no SDK code touched. Co-authored-by: Anton Dziatkovskii <194927794+tonydzi@users.noreply.github.com> --- docs/protocol.md | 20 +++++----- docs/server.md | 2 +- internal/docs/protocol.src.md | 10 ++--- internal/docs/server.src.md | 2 +- internal/docs/toc_test.go | 70 +++++++++++++++++++++++++++++++++++ 5 files changed, 87 insertions(+), 17 deletions(-) create mode 100644 internal/docs/toc_test.go diff --git a/docs/protocol.md b/docs/protocol.md index d40ed91..47ae2c3 100644 --- a/docs/protocol.md +++ b/docs/protocol.md @@ -2,14 +2,14 @@ # Support for the MCP base protocol 1. [Lifecycle](#lifecycle) - 1. [Discovery (`server/discover`)](#discovery-(server/discover)) - 1. [Per-request `_meta` keys](#per-request-meta-keys) - 1. [Per-response `_meta` keys](#per-response-meta-keys) - 1. [Subscriptions (`subscriptions/listen`)](#subscriptions-(subscriptions/listen)) + 1. [Discovery](#discovery) + 1. [Per-request metadata keys](#per-request-metadata-keys) + 1. [Per-response metadata keys](#per-response-metadata-keys) + 1. [Subscriptions](#subscriptions) 1. [Transports](#transports) 1. [Stdio Transport](#stdio-transport) 1. [Streamable Transport](#streamable-transport) - 1. [SSE Transport (legacy)](#sse-transport-(legacy)) + 1. [Legacy SSE Transport](#legacy-sse-transport) 1. [Custom transports](#custom-transports) 1. [Concurrency](#concurrency) 1. [Authorization](#authorization) @@ -111,7 +111,7 @@ func Example_lifecycle() { } ``` -### Discovery (`server/discover`) +### Discovery Introduced in `2026-07-28` by [SEP-2575](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2575), @@ -128,7 +128,7 @@ request. Servers implementing `2026-07-28` MUST implement it. fails or the server does not support the latest version, the client falls back to the legacy `initialize` handshake. -### Per-request `_meta` keys +### Per-request metadata keys When the negotiated protocol version is `2026-07-28` or later, every request carries these keys inside its `_meta` map (constants live in @@ -147,7 +147,7 @@ request, and populates `clientInfo` when configured with an `*Implementation` `ServerRequest[P].ProtocolVersion()`, `ServerRequest[P].ClientInfo()`, and `ServerRequest[P].ClientCapabilities()`. -### Per-response `_meta` keys +### Per-response metadata keys Under the same protocol version, servers SHOULD identify themselves on every response. The SDK populates this @@ -157,7 +157,7 @@ key automatically on every outgoing response: |---|---|---|---| | `MetaKeyServerInfo` | `io.modelcontextprotocol/serverInfo` | `*Implementation` | No | -### Subscriptions (`subscriptions/listen`) +### Subscriptions Introduced in `2026-07-28` by [SEP-2575](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2575), @@ -343,7 +343,7 @@ _See [examples/server/distributed](https://github.com/modelcontextprotocol/go-sd an example using stateless mode to implement a server distributed across multiple processes._ -### SSE Transport (legacy) +### Legacy SSE Transport Before the streamable transport, the [2024-11-05](https://modelcontextprotocol.io/specification/2024-11-05/basic/transports) diff --git a/docs/server.md b/docs/server.md index 9eb4ec2..966da75 100644 --- a/docs/server.md +++ b/docs/server.md @@ -241,7 +241,7 @@ to be notified of changes to subscribed resources. On `2026-07-28` and later sessions the SDK delivers these notifications over a `subscriptions/listen` stream instead of the legacy `resources/subscribe` RPC; see [Subscriptions -(`subscriptions/listen`)](protocol.md#subscriptions-subscriptionslisten) +(`subscriptions/listen`)](protocol.md#subscriptions) for the wire-level details. **Server-side**: diff --git a/internal/docs/protocol.src.md b/internal/docs/protocol.src.md index 1eb3c2f..446b555 100644 --- a/internal/docs/protocol.src.md +++ b/internal/docs/protocol.src.md @@ -55,7 +55,7 @@ it is the client's responsibility to end the session. %include ../../mcp/mcp_example_test.go lifecycle - -### Discovery (`server/discover`) +### Discovery Introduced in `2026-07-28` by [SEP-2575](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2575), @@ -72,7 +72,7 @@ request. Servers implementing `2026-07-28` MUST implement it. fails or the server does not support the latest version, the client falls back to the legacy `initialize` handshake. -### Per-request `_meta` keys +### Per-request metadata keys When the negotiated protocol version is `2026-07-28` or later, every request carries these keys inside its `_meta` map (constants live in @@ -91,7 +91,7 @@ request, and populates `clientInfo` when configured with an `*Implementation` `ServerRequest[P].ProtocolVersion()`, `ServerRequest[P].ClientInfo()`, and `ServerRequest[P].ClientCapabilities()`. -### Per-response `_meta` keys +### Per-response metadata keys Under the same protocol version, servers SHOULD identify themselves on every response. The SDK populates this @@ -101,7 +101,7 @@ key automatically on every outgoing response: |---|---|---|---| | `MetaKeyServerInfo` | `io.modelcontextprotocol/serverInfo` | `*Implementation` | No | -### Subscriptions (`subscriptions/listen`) +### Subscriptions Introduced in `2026-07-28` by [SEP-2575](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2575), @@ -267,7 +267,7 @@ _See [examples/server/distributed](https://github.com/modelcontextprotocol/go-sd an example using stateless mode to implement a server distributed across multiple processes._ -### SSE Transport (legacy) +### Legacy SSE Transport Before the streamable transport, the [2024-11-05](https://modelcontextprotocol.io/specification/2024-11-05/basic/transports) diff --git a/internal/docs/server.src.md b/internal/docs/server.src.md index cbc5689..8ad704a 100644 --- a/internal/docs/server.src.md +++ b/internal/docs/server.src.md @@ -87,7 +87,7 @@ to be notified of changes to subscribed resources. On `2026-07-28` and later sessions the SDK delivers these notifications over a `subscriptions/listen` stream instead of the legacy `resources/subscribe` RPC; see [Subscriptions -(`subscriptions/listen`)](protocol.md#subscriptions-subscriptionslisten) +(`subscriptions/listen`)](protocol.md#subscriptions) for the wire-level details. **Server-side**: diff --git a/internal/docs/toc_test.go b/internal/docs/toc_test.go new file mode 100644 index 0000000..23b02e7 --- /dev/null +++ b/internal/docs/toc_test.go @@ -0,0 +1,70 @@ +// Copyright 2025 The Go MCP SDK Authors. All rights reserved. +// Use of this source code is governed by an MIT-style +// license that can be found in the LICENSE file. + +package docs + +import ( + "os" + "path/filepath" + "regexp" + "strings" + "testing" +) + +// Headings are slugged twice by two different tools: weave writes the %toc +// links, and the renderer (GitHub, and mkdocs on go.sdk.modelcontextprotocol.io) +// assigns the heading ids. The two agree on plain words, and disagree on +// punctuation: weave keeps "(", ")" and "/" and drops "_", the renderers drop +// the first three and keep the last. A heading carrying any of them therefore +// gets a table-of-contents entry that lands nowhere. +// +// This test reads the generated docs and checks every %toc entry against the +// ids the renderers will actually produce. + +var ( + headingRE = regexp.MustCompile(`(?m)^(#{1,6})\s+(.*?)\s*$`) + tocLineRE = regexp.MustCompile(`(?m)^\s*1\. \[(.*?)\]\((#.*?)\)\s*$`) + dropRE = regexp.MustCompile(`[^\w\s-]`) + spaceRE = regexp.MustCompile(`[-\s]+`) +) + +// renderedID reproduces the anchor GitHub and python-markdown's toc extension +// derive from heading text: take the text, drop everything that is not a word +// character, whitespace or a hyphen, lowercase it, and join on hyphens. +func renderedID(heading string) string { + s := strings.ReplaceAll(heading, "`", "") + s = dropRE.ReplaceAllString(s, "") + s = strings.ToLower(strings.TrimSpace(s)) + return spaceRE.ReplaceAllString(s, "-") +} + +func TestTOCAnchorsResolve(t *testing.T) { + files, err := filepath.Glob(filepath.Join("..", "..", "docs", "*.md")) + if err != nil { + t.Fatal(err) + } + if len(files) == 0 { + t.Fatal("no generated docs found") + } + for _, file := range files { + data, err := os.ReadFile(file) + if err != nil { + t.Fatal(err) + } + text := string(data) + + ids := make(map[string]bool) + for _, m := range headingRE.FindAllStringSubmatch(text, -1) { + ids[renderedID(m[2])] = true + } + for _, m := range tocLineRE.FindAllStringSubmatch(text, -1) { + label, target := m[1], strings.TrimPrefix(m[2], "#") + if !ids[target] { + t.Errorf("%s: table-of-contents entry %q points at #%s, which no heading produces "+ + "(want #%s); drop (, ), / and _ from the heading", + filepath.Base(file), label, target, renderedID(label)) + } + } + } +} From 32404652e60bf829006b0c6a65bba01d3cabf37b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=A8=E6=88=90=E9=94=B4?= Date: Tue, 25 Aug 2026 17:26:17 +0800 Subject: [PATCH 35/70] auth: validate unadvertised RFC 9207 iss instead of rejecting it (#1187) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Fixes #1152. Sentry, Linear, and Notion omit `authorization_response_iss_parameter_supported` from their authorization-server metadata. Sentry still sends a correct `iss` in the authorization response, so sign-in fails after the browser step: the user has already consented and an authorization code has been issued, but `validateIssuerResponse` rejects the exchange solely because the server did not advertise RFC 9207 support. RFC 9207 §2.4 says clients MUST extract and compare `iss` when the parameter is present. §2.4 also notes that clients SHOULD discard an unadvertised `iss`, but that is a recommendation and local policy may accept servers that send `iss` without advertising. Validating a matching `iss` is interoperable with those hosted MCP servers and still catches mix-up attacks: a wrong `iss` is rejected whether or not support was advertised. When `iss` is present, this change always compares it to the expected issuer (simple string equality). A match is accepted even if the server did not advertise support. A mismatch still returns `authorization response issuer %q does not match expected issuer %q`. Absence of `iss` remains an error only when the server advertised support. ## Test plan - [x] `go test ./auth/ -run TestValidateIssuerResponse -count=1` - [x] `go test ./auth/...` New `TestValidateIssuerResponse` cases: - `UnadvertisedIssCorrect`: `iss == expectedIssuer`, `issSupported == false` → no error - `UnadvertisedIssWrong`: `iss == "https://attacker.example.com"`, `issSupported == false` → error containing `does not match expected issuer` Existing advertised+match / advertised+mismatch / missing-iss cases are unchanged. --------- Co-authored-by: Guglielmo Colombo --- auth/authorization_code.go | 30 +++- auth/authorization_code_test.go | 149 +++++++++++++++++- .../oauthtest/fake_authorization_server.go | 10 +- 3 files changed, 173 insertions(+), 16 deletions(-) diff --git a/auth/authorization_code.go b/auth/authorization_code.go index 1673a2e..f349842 100644 --- a/auth/authorization_code.go +++ b/auth/authorization_code.go @@ -124,6 +124,11 @@ type AuthorizationCodeHandlerConfig struct { // See https://modelcontextprotocol.io/seps/2207-oidc-refresh-token-guidance. RequestRefreshToken bool + // AcceptUnadvertisedIss accepts a matching RFC 9207 iss even when the + // authorization server metadata omits authorization_response_iss_parameter_supported. + // The zero value (false) keeps the historical reject-unadvertised-iss behavior. + AcceptUnadvertisedIss bool + // Client is an optional HTTP client to use for HTTP requests. // It is used for the following requests: // - Fetching Protected Resource Metadata @@ -367,7 +372,7 @@ func (h *AuthorizationCodeHandler) Authorize(ctx context.Context, req *http.Requ // Purposefully leaving the error unwrappable so it can be handled by the caller. return err } - if err := validateIssuerResponse(authRes.Iss, asm.Issuer, asm.AuthorizationResponseIssParameterSupported); err != nil { + if err := validateIssuerResponse(authRes.Iss, asm.Issuer, asm.AuthorizationResponseIssParameterSupported, h.config.AcceptUnadvertisedIss); err != nil { return err } @@ -616,10 +621,14 @@ func (h *AuthorizationCodeHandler) getAuthorizationCode(ctx context.Context, cfg } // validateIssuerResponse validates the "iss" parameter in an authorization response -// per [RFC 9207]. +// per [RFC 9207]. When the server advertises authorization_response_iss_parameter_supported, +// iss is required and must match expectedIssuer. When it does not advertise support, +// an empty iss is accepted; a present iss is compared to expectedIssuer (RFC 9207 §2.4) +// and a mismatch is always rejected. A matching unadvertised iss is accepted only when +// acceptUnadvertisedIss is true (local policy); the default is the historical reject. // // [RFC 9207]: https://www.rfc-editor.org/rfc/rfc9207 -func validateIssuerResponse(iss, expectedIssuer string, issParameterSupported bool) error { +func validateIssuerResponse(iss, expectedIssuer string, issParameterSupported, acceptUnadvertisedIss bool) error { if issParameterSupported { if iss == "" { return fmt.Errorf("authorization server advertises RFC 9207 iss parameter support but none was received in the authorization response") @@ -627,12 +636,17 @@ func validateIssuerResponse(iss, expectedIssuer string, issParameterSupported bo if iss != expectedIssuer { return fmt.Errorf("authorization response issuer %q does not match expected issuer %q", iss, expectedIssuer) } - } else { - if iss != "" { - return fmt.Errorf("authorization server does not advertise RFC 9207 iss parameter support but iss was received in the authorization response") - } + return nil + } + if iss == "" { + return nil + } + if iss != expectedIssuer { + return fmt.Errorf("authorization response issuer %q does not match expected issuer %q", iss, expectedIssuer) + } + if !acceptUnadvertisedIss { + return fmt.Errorf("authorization server does not advertise RFC 9207 iss parameter support but iss was received in the authorization response") } - return nil } diff --git a/auth/authorization_code_test.go b/auth/authorization_code_test.go index c2f963f..69a7ca6 100644 --- a/auth/authorization_code_test.go +++ b/auth/authorization_code_test.go @@ -919,11 +919,12 @@ func TestValidateIssuerResponse(t *testing.T) { const expectedIssuer = "https://auth.example.com" tests := []struct { - name string - iss string - issSupported bool - wantErr bool - wantErrContains string + name string + iss string + issSupported bool + acceptUnadvertisedIss bool + wantErr bool + wantErrContains string }{ { name: "ValidIss", @@ -949,11 +950,41 @@ func TestValidateIssuerResponse(t *testing.T) { iss: "", issSupported: false, }, + { + // Default / zero-value policy: reject a matching unadvertised iss + // (released v1.7.0 behavior). + name: "UnadvertisedIssCorrectRejectedByDefault", + iss: expectedIssuer, + issSupported: false, + wantErr: true, + wantErrContains: "does not advertise", + }, + { + name: "UnadvertisedIssCorrectAcceptedWhenOptedIn", + iss: expectedIssuer, + issSupported: false, + acceptUnadvertisedIss: true, + }, + { + name: "UnadvertisedIssWrong", + iss: "https://attacker.example.com", + issSupported: false, + wantErr: true, + wantErrContains: "does not match expected issuer", + }, + { + name: "UnadvertisedIssWrongStillRejectedWhenOptedIn", + iss: "https://attacker.example.com", + issSupported: false, + acceptUnadvertisedIss: true, + wantErr: true, + wantErrContains: "does not match expected issuer", + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - err := validateIssuerResponse(tt.iss, expectedIssuer, tt.issSupported) + err := validateIssuerResponse(tt.iss, expectedIssuer, tt.issSupported, tt.acceptUnadvertisedIss) if tt.wantErr { if err == nil { t.Fatalf("validateIssuerResponse() = nil, want error containing %q", tt.wantErrContains) @@ -968,6 +999,112 @@ func TestValidateIssuerResponse(t *testing.T) { } } +// TestAuthorize_AcceptUnadvertisedIssPlumbing checks that Authorize passes +// AuthorizationCodeHandlerConfig.AcceptUnadvertisedIss through to +// validateIssuerResponse. The fake server returns iss without advertising +// authorization_response_iss_parameter_supported. +func TestAuthorize_AcceptUnadvertisedIssPlumbing(t *testing.T) { + tests := []struct { + name string + acceptUnadvertisedIss bool + wantErrContains string + }{ + { + name: "ZeroValueRejects", + wantErrContains: "does not advertise", + }, + { + name: "OptInAccepts", + acceptUnadvertisedIss: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + authServer := oauthtest.NewFakeAuthorizationServer(oauthtest.Config{ + UnadvertiseIssParameter: true, + RegistrationConfig: &oauthtest.RegistrationConfig{ + PreregisteredClients: map[string]oauthtest.ClientInfo{ + "test_client_id": { + Secret: "test_client_secret", + RedirectURIs: []string{"http://localhost:12345/callback"}, + }, + }, + }, + }) + authServer.Start(t) + + resourceMux := http.NewServeMux() + resourceServer := httptest.NewServer(resourceMux) + t.Cleanup(resourceServer.Close) + resourceURL := resourceServer.URL + "/resource" + resourceMux.Handle("/.well-known/oauth-protected-resource/resource", ProtectedResourceMetadataHandler(&oauthex.ProtectedResourceMetadata{ + Resource: resourceURL, + AuthorizationServers: []string{authServer.URL()}, + })) + + handler, err := NewAuthorizationCodeHandler(&AuthorizationCodeHandlerConfig{ + RedirectURL: "http://localhost:12345/callback", + AcceptUnadvertisedIss: tt.acceptUnadvertisedIss, + PreregisteredClient: &oauthex.ClientCredentials{ + ClientID: "test_client_id", + ClientSecretAuth: &oauthex.ClientSecretAuth{ + ClientSecret: "test_client_secret", + }, + }, + AuthorizationCodeFetcher: func(ctx context.Context, args *AuthorizationArgs) (*AuthorizationResult, error) { + client := &http.Client{ + CheckRedirect: func(req *http.Request, via []*http.Request) error { + return http.ErrUseLastResponse + }, + } + resp, err := client.Get(args.URL) + if err != nil { + return nil, fmt.Errorf("failed to visit auth URL: %v", err) + } + defer resp.Body.Close() + location, err := resp.Location() + if err != nil { + return nil, fmt.Errorf("failed to get location header: %v", err) + } + return &AuthorizationResult{ + Code: location.Query().Get("code"), + State: location.Query().Get("state"), + Iss: location.Query().Get("iss"), + }, nil + }, + }) + if err != nil { + t.Fatalf("NewAuthorizationCodeHandler failed: %v", err) + } + + req := httptest.NewRequest(http.MethodGet, resourceURL, nil) + resp := &http.Response{ + StatusCode: http.StatusUnauthorized, + Header: make(http.Header), + Body: http.NoBody, + Request: req, + } + resp.Header.Set( + "WWW-Authenticate", + "Bearer resource_metadata="+resourceServer.URL+"/.well-known/oauth-protected-resource/resource", + ) + err = handler.Authorize(context.Background(), req, resp) + if tt.wantErrContains != "" { + if err == nil { + t.Fatalf("Authorize() = nil, want error containing %q", tt.wantErrContains) + } + if !strings.Contains(err.Error(), tt.wantErrContains) { + t.Fatalf("Authorize() error = %q, want it to contain %q", err.Error(), tt.wantErrContains) + } + return + } + if err != nil { + t.Fatalf("Authorize() unexpected error = %v", err) + } + }) + } +} + func TestInferApplicationType(t *testing.T) { tests := []struct { name string diff --git a/internal/oauthtest/fake_authorization_server.go b/internal/oauthtest/fake_authorization_server.go index e5134fb..4cd6826 100644 --- a/internal/oauthtest/fake_authorization_server.go +++ b/internal/oauthtest/fake_authorization_server.go @@ -97,6 +97,11 @@ type Config struct { // IssueRefreshToken, if true, includes a refresh_token in token responses and // enables grant_type=refresh_token at the /token endpoint. IssueRefreshToken bool + // UnadvertiseIssParameter, if true, omits + // authorization_response_iss_parameter_supported from metadata while the + // authorize endpoint still includes iss. Used to test local policy for + // unadvertised RFC 9207 iss. + UnadvertiseIssParameter bool } // testRefreshToken is the refresh token issued and accepted by the fake server @@ -199,8 +204,9 @@ func (s *FakeAuthorizationServer) handleMetadata(w http.ResponseWriter, r *http. CodeChallengeMethodsSupported: []string{"S256"}, ClientIDMetadataDocumentSupported: cimdSupported, TokenEndpointAuthMethodsSupported: []string{"client_secret_post", "client_secret_basic"}, - // Advertise RFC 9207 support: the authorize endpoint includes "iss" in responses. - AuthorizationResponseIssParameterSupported: true, + // Advertise RFC 9207 support unless UnadvertiseIssParameter is set: + // the authorize endpoint still includes "iss" in responses either way. + AuthorizationResponseIssParameterSupported: !s.config.UnadvertiseIssParameter, } // Set CORS headers for cross-origin client discovery. w.Header().Set("Access-Control-Allow-Origin", "*") From c68ad9a4e6e16f8f7baa1e2f20ad7b7d74830eb4 Mon Sep 17 00:00:00 2001 From: Scott Leggett Date: Tue, 25 Aug 2026 20:27:37 +0800 Subject: [PATCH 36/70] mcp: tolerate 404 on subscriptions/listen in stateless mode (#1193) When connecting to remote stateless servers (such as GitHub's MCP server), optional streams like subscriptions/listen may be rejected by middleware with a plain-text HTTP 404 before reaching a JSON-RPC handler. Previously, checkResponse misclassified these 404s as lost stateful sessions (ErrSessionMissing) and tore down the transport connection because the error didn't wrap jsonrpc2.ErrRejected. This change aligns with the MCP 2026-07-28 Streamable HTTP specification: 1. Conditionally check for an active session ID before returning ErrSessionMissing, since a 404 on a stateless connection cannot mean a session was lost. 2. Wrap subscriptions/listen failures with ErrRejected (matching server/discover) so the jsonrpc2 layer doesn't permanently break the transport when encountering non-compliant plain-text 404s. --- I have manually tested this change against GitHub's hosted MCP server and confirmed that it fixes https://github.com/github/github-mcp-server/issues/3129 --- mcp/streamable.go | 20 ++++++----- mcp/streamable_client_test.go | 64 +++++++++++++++++++++++++++++++++++ 2 files changed, 75 insertions(+), 9 deletions(-) diff --git a/mcp/streamable.go b/mcp/streamable.go index 6ed5d3d..8729bb7 100644 --- a/mcp/streamable.go +++ b/mcp/streamable.go @@ -2347,13 +2347,14 @@ func (c *streamableClientConn) Write(ctx context.Context, msg jsonrpc.Message) e } if err := c.checkResponse(ctx, requestSummary, resp); err != nil { - if requestMethod == methodDiscover && !errors.Is(err, jsonrpc2.ErrRejected) { - // Wrap the discover failure with ErrRejected so the jsonrpc2 layer - // doesn't set writeErr, which would prevent the legacy initialize - // fallback from succeeding on the same connection. This covers the - // case where a legacy server rejects server/discover with a - // non-JSON-RPC body (e.g. plain text 400), which checkResponse - // cannot classify as a per-call rejection on its own. + if (requestMethod == methodDiscover || requestMethod == methodSubscriptionsListen) && !errors.Is(err, jsonrpc2.ErrRejected) { + // Wrap the discover or subscriptions/listen failure with ErrRejected so + // the jsonrpc2 layer doesn't set writeErr, which would break the connection + // (preventing the legacy initialize fallback from succeeding, or breaking + // subsequent RPCs if subscriptions/listen fails). This covers the case + // where a server rejects these requests with a non-JSON-RPC body (e.g. + // plain text 404 or 400), which checkResponse cannot classify as a + // per-call rejection on its own. err = fmt.Errorf("%w: %w", err, jsonrpc2.ErrRejected) } else if !errors.Is(err, jsonrpc2.ErrRejected) { // Only fail the connection for non-transient errors. @@ -2592,10 +2593,11 @@ func (c *streamableClientConn) checkResponse(ctx context.Context, requestSummary // §2.5.3: "The server MAY terminate the session at any time, after // which it MUST respond to requests containing that session ID with HTTP // 404 Not Found." - if resp.StatusCode == http.StatusNotFound { + // Only a stateful connection with a known session ID can experience ErrSessionMissing. + if sessionID := c.SessionID(); resp.StatusCode == http.StatusNotFound && sessionID != "" { // Return an ErrSessionMissing to avoid sending a redundant DELETE when the // session is already gone. - return fmt.Errorf("%s: failed to connect (session ID: %v): %w", requestSummary, c.sessionID, ErrSessionMissing) + return fmt.Errorf("%s: failed to connect (session ID: %v): %w", requestSummary, sessionID, ErrSessionMissing) } if resp.StatusCode < 200 || resp.StatusCode >= 300 { return fmt.Errorf("%s: %v", requestSummary, http.StatusText(resp.StatusCode)) diff --git a/mcp/streamable_client_test.go b/mcp/streamable_client_test.go index 64c4da5..77ff81a 100644 --- a/mcp/streamable_client_test.go +++ b/mcp/streamable_client_test.go @@ -1965,3 +1965,67 @@ func TestStreamableClientHandlerErrorPropagation(t *testing.T) { }) } } + +// TestStreamableClient_StatelessSubscriptionsListen404 verifies that in stateless mode +// (protocol 2026-07-28), when a server rejects subscriptions/listen with 404 (e.g. Copilot MCP server), +// the client connection survives and subsequent RPCs (such as tools/list) succeed. +func TestStreamableClient_StatelessSubscriptionsListen404(t *testing.T) { + ctx := t.Context() + + var listenServed atomic.Bool + fake := &fakeStreamableServer{ + t: t, + responses: fakeResponses{ + {"POST", "", methodDiscover, ""}: { + header: header{ + "Content-Type": "application/json", + }, + wantProtocolVersion: protocolVersion20260728, + responseFunc: func(r *jsonrpc.Request) (string, int) { + return jsonBody(t, resp(r.ID.Raw().(int64), discoverResult, nil)), http.StatusOK + }, + }, + {"POST", "", methodSubscriptionsListen, ""}: { + header: header{"Content-Type": "text/plain"}, + responseFunc: func(r *jsonrpc.Request) (string, int) { + listenServed.Store(true) + return "404 Not Found", http.StatusNotFound + }, + optional: true, + }, + {"POST", "", methodListTools, ""}: { + header: header{"Content-Type": "application/json"}, + responseFunc: func(r *jsonrpc.Request) (string, int) { + return jsonBody(t, resp(r.ID.Raw().(int64), &ListToolsResult{Tools: []*Tool{}}, nil)), http.StatusOK + }, + optional: true, + }, + }, + } + + httpServer := httptest.NewServer(fake) + defer httpServer.Close() + + transport := &StreamableClientTransport{Endpoint: httpServer.URL} + client := NewClient(testImpl, &ClientOptions{ + ToolListChangedHandler: func(ctx context.Context, req *ToolListChangedRequest) {}, + }) + + session, err := client.Connect(ctx, transport, &ClientSessionOptions{ProtocolVersion: protocolVersion20260728}) + if err != nil { + t.Fatalf("Connect failed: %v", err) + } + defer session.Close() + + // tools/list must succeed even though subscriptions/listen returned 404. + res, err := session.ListTools(ctx, nil) + if err != nil { + t.Fatalf("ListTools failed: %v", err) + } + if res == nil { + t.Fatal("ListTools result is nil") + } + if !listenServed.Load() { + t.Fatal("subscriptions/listen was not called") + } +} From 2732839dbadd2c31651234e49199e6871b026754 Mon Sep 17 00:00:00 2001 From: Recoordinate <296084221+latent-9@users.noreply.github.com> Date: Wed, 26 Aug 2026 03:15:09 +1200 Subject: [PATCH 37/70] mcp: prune completed listen request IDs from the session (#1190) ### Problem `ServerSession` tracks the request IDs of in-flight `subscriptions/listen` streams so `Close` can cancel the parked handlers and avoid the jsonrpc2 drain deadlock. The ID is appended when the listen starts, but it is never removed when the listen handler returns. Every completed listen stream (peer cancel, unsubscribe, stream break) therefore leaves one stale entry in the session for its entire lifetime. On a long-lived session where a client repeatedly subscribes and unsubscribes, the slice grows without bound: one leaked entry per cycle. `Close` calls `jsonrpc2.Connection.Cancel` on each recorded ID, and a stale ID is a no-op there today, but the bookkeeping is meant to hold only in-flight listens and currently never shrinks. ### Change Drop the request ID from the session's bookkeeping when the listen handler returns, matching the lifecycle of the in-flight handler that owns it. ### Tests Three tests reproduce the leak, failing on `main` and passing here: - `TestListenPrunedAfterSingleCompletion` - `TestListenIDsDoNotAccumulate` - `TestListenIDsLeakViaPublicSubscribeUnsubscribe` The third exercises the leak through the public `Subscribe`/`Unsubscribe` API. `go test ./mcp/` passes. --------- Co-authored-by: Guglielmo Colombo --- mcp/mcp_test.go | 89 ++++++++++++++++++++++++++++++++++++++++++++++--- mcp/server.go | 13 ++++++++ 2 files changed, 98 insertions(+), 4 deletions(-) diff --git a/mcp/mcp_test.go b/mcp/mcp_test.go index 8e2f06d..699d32f 100644 --- a/mcp/mcp_test.go +++ b/mcp/mcp_test.go @@ -3341,10 +3341,7 @@ func TestServerSessionCloseWithActiveListen(t *testing.T) { // Sanity check: the auto-listen must actually have registered an entry in // listenIDs, otherwise the test below would trivially pass without // exercising the fix. - ss.mu.Lock() - n := len(ss.listenIDs) - ss.mu.Unlock() - if n == 0 { + if n := listenIDsCount(ss); n == 0 { t.Fatal("expected auto-listen to register a request ID on the server session") } @@ -3357,6 +3354,90 @@ func TestServerSessionCloseWithActiveListen(t *testing.T) { } } +// listenIDsCount reports how many request IDs are currently recorded in the +// session's listenIDs set. +func listenIDsCount(ss *ServerSession) int { + ss.mu.Lock() + defer ss.mu.Unlock() + return len(ss.listenIDs) +} + +// A completed listen stream must not leave a stale entry in the session. +func TestListenPrunedAfterSingleCompletion(t *testing.T) { + cs, ss, cleanup := basicClientServerConnection(t, nil, nil, func(s *Server) { + AddTool(s, &Tool{Name: "t"}, sayHi) + }) + _ = cleanup + + ctx := context.Background() + lctx, cancel := context.WithCancel(ctx) + go cs.subscriptionsListen(lctx, &SubscriptionsListenParams{ + Notifications: &NotificationSubscriptions{ToolsListChanged: true}, + }) + time.Sleep(30 * time.Millisecond) + cancel() // peer cancels: server handler returns + time.Sleep(30 * time.Millisecond) + + if n := listenIDsCount(ss); n != 0 { + t.Fatalf("completed listen left %d stale entry/ies", n) + } +} + +// Completed listens must not accumulate: the slice grows without bound today. +func TestListenIDsDoNotAccumulate(t *testing.T) { + cs, ss, cleanup := basicClientServerConnection(t, nil, nil, func(s *Server) { + AddTool(s, &Tool{Name: "t"}, sayHi) + }) + _ = cleanup + + ctx := context.Background() + + const cycles = 15 + for range cycles { + lctx, cancel := context.WithCancel(ctx) + go cs.subscriptionsListen(lctx, &SubscriptionsListenParams{ + Notifications: &NotificationSubscriptions{ToolsListChanged: true}, + }) + time.Sleep(15 * time.Millisecond) + cancel() + time.Sleep(10 * time.Millisecond) + } + time.Sleep(30 * time.Millisecond) + + if n := listenIDsCount(ss); n != 0 { + t.Fatalf("listenIDs grew unbounded: %d stale entries after %d completed listens", n, cycles) + } +} + +// The leak is reachable through the public Subscribe/Unsubscribe API: every +// subscription opens a listen stream and unsubscribing completes it, so a real +// client cycling subscriptions on a long-lived session leaks one entry per cycle. +func TestListenIDsLeakViaPublicSubscribeUnsubscribe(t *testing.T) { + cs, ss, cleanup := basicClientServerConnection(t, nil, nil, func(s *Server) { + AddTool(s, &Tool{Name: "t"}, sayHi) + }) + _ = cleanup + + ctx := context.Background() + + const cycles = 3 + for i := range cycles { + uri := fmt.Sprintf("resource://cycle-%d", i) + if err := cs.Subscribe(ctx, &SubscribeParams{URI: uri}); err != nil { + t.Fatalf("Subscribe %d: %v", i, err) + } + time.Sleep(15 * time.Millisecond) + if err := cs.Unsubscribe(ctx, &UnsubscribeParams{URI: uri}); err != nil { + t.Fatalf("Unsubscribe %d: %v", i, err) + } + } + time.Sleep(30 * time.Millisecond) + + if n := listenIDsCount(ss); n != 0 { + t.Fatalf("public Subscribe/Unsubscribe leaked %d stale listenIDs after %d cycles", n, cycles) + } +} + func TestCustomMethods(t *testing.T) { type searchParams struct { ParamsBase diff --git a/mcp/server.go b/mcp/server.go index f9c8233..bfca13d 100644 --- a/mcp/server.go +++ b/mcp/server.go @@ -1962,6 +1962,19 @@ func (ss *ServerSession) handle(ctx context.Context, req *jsonrpc.Request) (any, ss.mu.Lock() ss.listenIDs = append(ss.listenIDs, req.ID) ss.mu.Unlock() + // The listen completes when the handler returns (peer cancellation, + // stream break, or error); drop the ID so completed listens don't + // accumulate in the slice indefinitely. + defer func() { + ss.mu.Lock() + for i, id := range ss.listenIDs { + if id == req.ID { + ss.listenIDs = append(ss.listenIDs[:i], ss.listenIDs[i+1:]...) + break + } + } + ss.mu.Unlock() + }() } res, err := handleReceive(ctx, ss, req) From b55bcbd610a3f9504990d2712434813c2aed565b Mon Sep 17 00:00:00 2001 From: Yaroslav Date: Thu, 27 Aug 2026 10:47:08 +0200 Subject: [PATCH 38/70] internal/json: json nesting depth check (#1200) Added a lightweight JSON depth-checking pass before the actual parsing. --- internal/json/json.go | 52 ++++++++++++++++++++++++++++++++++++++ internal/json/json_test.go | 47 ++++++++++++++++++++++++++++++++++ 2 files changed, 99 insertions(+) diff --git a/internal/json/json.go b/internal/json/json.go index b4c897f..b3fa039 100644 --- a/internal/json/json.go +++ b/internal/json/json.go @@ -8,11 +8,21 @@ package json import ( "bytes" + "fmt" "io" "github.com/segmentio/encoding/json" ) +// defaultMaxDepth is the maximum JSON nesting depth accepted by [Unmarshal]. +// Realistic MCP payloads nest only a handful of levels, so 1000 is far more +// than any legitimate message requires while keeping worst-case parse cost +// bounded. +const defaultMaxDepth = 1000 + +// errMaxDepthExceeded is returned by [Unmarshal] when the input nests deeper than [defaultMaxDepth]. +var errMaxDepthExceeded = fmt.Errorf("json: exceeded maximum nesting depth of %d", defaultMaxDepth) + type Decoder struct { dec *json.Decoder } @@ -28,5 +38,47 @@ func (d *Decoder) Decode(v any) error { } func Unmarshal(data []byte, v any) error { + if err := checkMaxDepth(data, defaultMaxDepth); err != nil { + return err + } return NewDecoder(bytes.NewReader(data)).Decode(v) } + +// checkMaxDepth scans data once and reports [errMaxDepthExceeded] if the +// nesting of JSON objects and arrays exceeds maxDepth. It is a lightweight pass which +// tracks '{' and '[' against '}' and ']' while skipping over the contents of strings. +func checkMaxDepth(data []byte, maxDepth int) error { + var ( + depth int + inString bool + escaped bool + ) + for _, b := range data { + if inString { + switch { + case escaped: + escaped = false + case b == '\\': + escaped = true + case b == '"': + inString = false + } + continue + } + + switch b { + case '"': + inString = true + case '{', '[': + depth++ + if depth > maxDepth { + return errMaxDepthExceeded + } + case '}', ']': + if depth > 0 { + depth-- + } + } + } + return nil +} diff --git a/internal/json/json_test.go b/internal/json/json_test.go index d037e88..b1d110c 100644 --- a/internal/json/json_test.go +++ b/internal/json/json_test.go @@ -7,9 +7,11 @@ package json import ( "bytes" "encoding/json" + "errors" "fmt" "strings" "testing" + "time" "github.com/google/go-cmp/cmp" ) @@ -140,6 +142,51 @@ func TestUnmarshalNullCharacter(t *testing.T) { } } +func TestUnmarshalChecksNestingDepth(t *testing.T) { + createNesting := func(depth int) []byte { + return []byte(strings.Repeat("[", depth) + strings.Repeat("]", depth)) + } + + tests := []struct { + name string + data []byte + wantErr bool + }{ + {"flat object", []byte(`{"a":1}`), false}, + {"at limit", createNesting(defaultMaxDepth), false}, + {"one over limit", createNesting(defaultMaxDepth + 1), true}, + {"brackets in strings do not count", []byte(`{"a":"[[[[[[[[[["}`), false}, + {"escaped quote in string", []byte(`{"a":"\"[[[[[["}`), false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var v any + err := Unmarshal(tt.data, &v) + gotDepthErr := errors.Is(err, errMaxDepthExceeded) + if gotDepthErr != tt.wantErr { + t.Fatalf("Unmarshal() error = %v (err=%v), want depth error = %v", gotDepthErr, err, tt.wantErr) + } + }) + } +} + +func TestUnmarshalMaxDepthExitsFast(t *testing.T) { + data := []byte(strings.Repeat("[", 500_000) + strings.Repeat("]", 500_000)) + done := make(chan error, 1) + go func() { + var v any + done <- Unmarshal(data, &v) + }() + select { + case err := <-done: + if !errors.Is(err, errMaxDepthExceeded) { + t.Fatalf("Unmarshal error = %v, want %v", err, errMaxDepthExceeded) + } + case <-time.After(500 * time.Millisecond): + t.Fatal("Unmarshal did not reject deeply nested payload promptly") + } +} + func FuzzUnmarshalKeyComparison(f *testing.F) { // Add seed corpus for expected valid key and the known problematic cases. f.Add("field") From 165121e53ee6c1d9b95b8485bc2b23d7b24ac3e9 Mon Sep 17 00:00:00 2001 From: Bobo Date: Thu, 27 Aug 2026 09:12:53 -0500 Subject: [PATCH 39/70] jsonrpc2: wrap write error in server-closing error (#1186) The "server is closing" error produced when a connection shuts down because its write side failed formatted the underlying write error with `%v`, so the cause - typically `io.EOF` when a stdio host closes its pipe - was not part of the error chain. A caller that wants to distinguish a clean host disconnect from a real failure via `errors.Is(err, io.EOF)` therefore always got `false`. This changes both `s.writeErr` wrappings to use `%w`, preserving the write error in the error chain while keeping the same message text ("server is closing: EOF"). A regression test exercises the write-failure branch of `shuttingDown` and asserts both `errors.Is(err, ErrServerClosing)` and `errors.Is(err, io.EOF)`. Fixes #1098 --------- Co-authored-by: Bo Zhang <187063395+Tethys0@users.noreply.github.com> Co-authored-by: Guglielmo Colombo --- internal/jsonrpc2/conn.go | 4 ++-- internal/jsonrpc2/conn_test.go | 38 ++++++++++++++++++++++++++++++++++ 2 files changed, 40 insertions(+), 2 deletions(-) create mode 100644 internal/jsonrpc2/conn_test.go diff --git a/internal/jsonrpc2/conn.go b/internal/jsonrpc2/conn.go index 56edb7e..5849a87 100644 --- a/internal/jsonrpc2/conn.go +++ b/internal/jsonrpc2/conn.go @@ -158,12 +158,12 @@ func (s *inFlightState) shuttingDown(errClosing error) error { if s.readErr != nil { // If the read side of the connection is broken, we cannot read new call // requests, and cannot read responses to our outgoing calls. - return fmt.Errorf("%w: %v", errClosing, s.readErr) + return fmt.Errorf("%w: %w", errClosing, s.readErr) } if s.writeErr != nil { // If the write side of the connection is broken, we cannot write responses // for incoming calls, and cannot write requests for outgoing calls. - return fmt.Errorf("%w: %v", errClosing, s.writeErr) + return fmt.Errorf("%w: %w", errClosing, s.writeErr) } return nil } diff --git a/internal/jsonrpc2/conn_test.go b/internal/jsonrpc2/conn_test.go new file mode 100644 index 0000000..afeb4c0 --- /dev/null +++ b/internal/jsonrpc2/conn_test.go @@ -0,0 +1,38 @@ +// Copyright 2025 The Go MCP SDK Authors. All rights reserved. +// Use of this source code is governed by the license +// that can be found in the LICENSE file. + +package jsonrpc2 + +import ( + "errors" + "io" + "testing" +) + +// TestShuttingDownWrapsWriteError verifies that when a connection shuts down +// because its write side failed, the returned error preserves the underlying +// write error in its chain so callers can classify it with errors.Is (for +// example, distinguishing io.EOF from a clean host disconnect versus a real +// failure). +func TestShuttingDownWrapsWriteError(t *testing.T) { + s := &inFlightState{writeErr: io.EOF} + err := s.shuttingDown(ErrServerClosing) + if !errors.Is(err, ErrServerClosing) { + t.Errorf("shuttingDown() error = %v, want it to wrap ErrServerClosing", err) + } + if !errors.Is(err, io.EOF) { + t.Errorf("shuttingDown() error = %v, want it to wrap io.EOF", err) + } +} + +func TestShuttingDownWrapsReadError(t *testing.T) { + s := &inFlightState{readErr: io.EOF} + err := s.shuttingDown(ErrServerClosing) + if !errors.Is(err, ErrServerClosing) { + t.Errorf("shuttingDown() error = %v, want it to wrap ErrServerClosing", err) + } + if !errors.Is(err, io.EOF) { + t.Errorf("shuttingDown() error = %v, want it to wrap io.EOF", err) + } +} From 5a89b63764ba771bcbd236336f1114fcb092cfca Mon Sep 17 00:00:00 2001 From: Yaroslav Date: Fri, 28 Aug 2026 09:43:05 +0200 Subject: [PATCH 40/70] oauthex: bound dcr read to 1mb (#1206) --- oauthex/dcr.go | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/oauthex/dcr.go b/oauthex/dcr.go index f46d0e8..f95c55b 100644 --- a/oauthex/dcr.go +++ b/oauthex/dcr.go @@ -175,6 +175,10 @@ func (e *ClientRegistrationError) Error() string { return fmt.Sprintf("registration failed: %s (%s)", e.ErrorCode, e.ErrorDescription) } +// maxRegistrationResponseBytes bounds the dynamic client registration response +// body read from the authorization server. +const maxRegistrationResponseBytes = 1 << 20 // 1 MiB + // RegisterClient performs Dynamic Client Registration according to RFC 7591. func RegisterClient(ctx context.Context, registrationEndpoint string, clientMeta *ClientRegistrationMetadata, c *http.Client) (*ClientRegistrationResponse, error) { if registrationEndpoint == "" { @@ -204,7 +208,7 @@ func RegisterClient(ctx context.Context, registrationEndpoint string, clientMeta } defer resp.Body.Close() - body, err := io.ReadAll(resp.Body) + body, err := io.ReadAll(io.LimitReader(resp.Body, maxRegistrationResponseBytes)) if err != nil { return nil, fmt.Errorf("failed to read registration response body: %w", err) } From 21c18c6229e1c6d1d53d9a57475a2f65cc508cf3 Mon Sep 17 00:00:00 2001 From: Guglielmo Colombo Date: Fri, 28 Aug 2026 16:06:10 +0200 Subject: [PATCH 41/70] mcp: allow restricting the protocol versions a server supports (#1199) Fixes #945 --- docs/protocol.md | 14 +++ internal/docs/protocol.src.md | 14 +++ mcp/server.go | 69 +++++++++++---- mcp/server_test.go | 161 ++++++++++++++++++++++++++++++++++ mcp/session.go | 7 ++ mcp/shared.go | 30 +++++-- mcp/sse_test.go | 2 +- mcp/streamable.go | 22 +++-- mcp/streamable_test.go | 77 ++++++++++++++++ mcp/transport.go | 8 +- mcp/transport_test.go | 28 +++++- 11 files changed, 397 insertions(+), 35 deletions(-) diff --git a/docs/protocol.md b/docs/protocol.md index 47ae2c3..f60371b 100644 --- a/docs/protocol.md +++ b/docs/protocol.md @@ -128,6 +128,20 @@ request. Servers implementing `2026-07-28` MUST implement it. fails or the server does not support the latest version, the client falls back to the legacy `initialize` handshake. +A server advertises and negotiates every protocol version the SDK supports; +set [`ServerOptions.SupportedProtocolVersions`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ServerOptions) +to narrow that set, for example to stop serving a revision your deployment has +retired. + +```go +server := mcp.NewServer(&mcp.Implementation{Name: "server", Version: "v1.0.0"}, &mcp.ServerOptions{ + SupportedProtocolVersions: []string{"2026-07-28", "2025-11-25"}, +}) +``` + +The set can only narrow support, never widen it; the field's documentation +covers how each protocol era answers a request at an excluded version. + ### Per-request metadata keys When the negotiated protocol version is `2026-07-28` or later, every request diff --git a/internal/docs/protocol.src.md b/internal/docs/protocol.src.md index 446b555..dbc8c6e 100644 --- a/internal/docs/protocol.src.md +++ b/internal/docs/protocol.src.md @@ -72,6 +72,20 @@ request. Servers implementing `2026-07-28` MUST implement it. fails or the server does not support the latest version, the client falls back to the legacy `initialize` handshake. +A server advertises and negotiates every protocol version the SDK supports; +set [`ServerOptions.SupportedProtocolVersions`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ServerOptions) +to narrow that set, for example to stop serving a revision your deployment has +retired. + +```go +server := mcp.NewServer(&mcp.Implementation{Name: "server", Version: "v1.0.0"}, &mcp.ServerOptions{ + SupportedProtocolVersions: []string{"2026-07-28", "2025-11-25"}, +}) +``` + +The set can only narrow support, never widen it; the field's documentation +covers how each protocol era answers a request at an excluded version. + ### Per-request metadata keys When the negotiated protocol version is `2026-07-28` or later, every request diff --git a/mcp/server.go b/mcp/server.go index bfca13d..b935385 100644 --- a/mcp/server.go +++ b/mcp/server.go @@ -44,6 +44,10 @@ type Server struct { // fixed at creation impl *Implementation opts ServerOptions + // protocolVersions is the descending-ordered list of protocol versions + // this server advertises and negotiates: [supportedProtocolVersions], + // narrowed by [ServerOptions.SupportedProtocolVersions]. + protocolVersions []string mu sync.Mutex prompts *featureSet[*serverPrompt] @@ -169,6 +173,23 @@ type ServerOptions struct { // GetSessionID is not consulted when [StreamableHTTPOptions.Stateless] is // true, since stateless servers do not maintain sessions. GetSessionID func() string + + // SupportedProtocolVersions, if non-empty, restricts the protocol versions + // this server advertises. If empty, every version returned by + // [SupportedProtocolVersions] is used. + // The list can only narrow support, never widen it. + // A request using the >= 2026-07-28 protocol at an excluded version is + // rejected with error code [CodeUnsupportedProtocolVersion]. + // + // The legacy initialize handshake never rejects. The lifecycle spec + // requires the server to answer a request it does not support with a + // version it does, so an excluded version is answered with the newest + // listed version that handshake can negotiate, and the client is expected + // to disconnect when it cannot speak it. A list holding no such version + // (one restricted to 2026-07-28 and later) is answered with 2025-11-25, + // the newest handshake-era version, so that the client disconnects rather + // than reading the answer as a negotiation into the new protocol. + SupportedProtocolVersions []string } // NewServer creates a new MCP server. The resulting server has no features: @@ -205,6 +226,18 @@ func NewServer(impl *Implementation, options *ServerOptions) *Server { opts.GetSessionID = rand.Text } + protocolVersions := SupportedProtocolVersions() + if len(opts.SupportedProtocolVersions) > 0 { + for _, v := range opts.SupportedProtocolVersions { + if !slices.Contains(supportedProtocolVersions, v) { + panic(fmt.Errorf("unsupported protocol version %q", v)) + } + } + protocolVersions = slices.DeleteFunc(protocolVersions, func(v string) bool { + return !slices.Contains(opts.SupportedProtocolVersions, v) + }) + } + if opts.Logger == nil { // ensure we have a logger opts.Logger = ensureLogger(nil) } @@ -215,6 +248,7 @@ func NewServer(impl *Implementation, options *ServerOptions) *Server { s := &Server{ impl: impl, opts: opts, + protocolVersions: protocolVersions, prompts: newFeatureSet(func(p *serverPrompt) string { return p.prompt.Name }), tools: newFeatureSet(func(t *serverTool) string { return t.tool.Name }), resources: newFeatureSet(func(r *serverResource) string { return r.resource.URI }), @@ -886,7 +920,7 @@ func (s *Server) discover(_ context.Context, req *ServerRequest[*DiscoverParams] versions := req.Session.supportedVersions req.Session.mu.Unlock() if versions == nil { - versions = slices.Clone(supportedProtocolVersions) + versions = slices.Clone(s.protocolVersions) } // Read the request-scoped identity/capabilities before acquiring the // session lock: these accessors may fall back to Session.InitializeParams @@ -903,7 +937,7 @@ func (s *Server) discover(_ context.Context, req *ServerRequest[*DiscoverParams] // is never surfaced to the client via Mcp-Session-Id; leaving // InitializeParams nil lets serveStatefulPOST's safety-net cleanup // close it instead of leaking. - if supportedVersion := negotiateMutuallySupportedVersion(versions); supportedVersion >= protocolVersion20260728 { + if slices.ContainsFunc(versions, func(v string) bool { return v >= protocolVersion20260728 }) { req.Session.updateState(func(state *ServerSessionState) { state.InitializeParams = init }) @@ -917,16 +951,16 @@ func (s *Server) discover(_ context.Context, req *ServerRequest[*DiscoverParams] return res, nil } -// filterSupportedVersions returns the subset of [supportedProtocolVersions] -// that the Transport can serve. If t does not implement [ProtocolVersionSupporter], every -// SDK-supported version is included. -func filterSupportedVersions(t Transport) []string { +// filterSupportedVersions returns the subset of versions that the Transport +// can serve. If t does not implement [ProtocolVersionSupporter], every version +// is included. +func filterSupportedVersions(t Transport, versions []string) []string { pvs, ok := t.(ProtocolVersionSupporter) if !ok { - return slices.Clone(supportedProtocolVersions) + return slices.Clone(versions) } - out := make([]string, 0, len(supportedProtocolVersions)) - for _, v := range supportedProtocolVersions { + out := make([]string, 0, len(versions)) + for _, v := range versions { if pvs.SupportsProtocolVersion(v) { out = append(out, v) } @@ -1398,7 +1432,7 @@ func (s *Server) Connect(ctx context.Context, t Transport, opts *ServerSessionOp // but without the lock the Go memory model gives the read goroutine no // guarantee of seeing this write, and -race flags it. ss.mu.Lock() - ss.supportedVersions = filterSupportedVersions(t) + ss.supportedVersions = filterSupportedVersions(t, s.protocolVersions) ss.mu.Unlock() // Start keepalive before returning the session to avoid race conditions with Close. @@ -1492,7 +1526,7 @@ type ServerSession struct { mcpConn Connection keepaliveCancel context.CancelFunc - // supportedVersions is the subset of [supportedProtocolVersions] that the + // supportedVersions is the subset of [Server.protocolVersions] that the // transport can actually serve, computed once at connection time from // [ProtocolVersionSupporter] (if implemented by the transport) and used by // the SEP-2575 server/discover handler. @@ -1896,9 +1930,9 @@ func (ss *ServerSession) handle(ctx context.Context, req *jsonrpc.Request) (any, } if validatedMeta.usesNewProtocol && - !slices.Contains(supportedProtocolVersions, validatedMeta.initializeParams.ProtocolVersion) { + !slices.Contains(ss.server.protocolVersions, validatedMeta.initializeParams.ProtocolVersion) { data, _ := json.Marshal(UnsupportedProtocolVersionData{ - Supported: supportedProtocolVersions, + Supported: ss.server.protocolVersions, Requested: validatedMeta.initializeParams.ProtocolVersion, }) return nil, &jsonrpc.Error{ @@ -2021,12 +2055,17 @@ func (ss *ServerSession) initialize(ctx context.Context, params *InitializeParam if params == nil { return nil, fmt.Errorf("%w: \"params\" must be be provided", jsonrpc2.ErrInvalidParams) } - var wasInit bool + var ( + wasInit bool + negotiated string + ) ss.updateState(func(state *ServerSessionState) { wasInit = state.InitializeParams != nil if !wasInit { state.InitializeParams = params + state.NegotiatedProtocolVersion = negotiatedVersion(params.ProtocolVersion, ss.server.protocolVersions) } + negotiated = state.NegotiatedProtocolVersion }) if wasInit { ss.server.opts.Logger.Error("duplicate initialize request") @@ -2037,7 +2076,7 @@ func (ss *ServerSession) initialize(ctx context.Context, params *InitializeParam return &InitializeResult{ // TODO(rfindley): alter behavior when falling back to an older version: // reject unsupported features. - ProtocolVersion: negotiatedVersion(params.ProtocolVersion), + ProtocolVersion: negotiated, Capabilities: s.capabilities(), Instructions: s.opts.Instructions, ServerInfo: s.impl, diff --git a/mcp/server_test.go b/mcp/server_test.go index 75d7455..41f4951 100644 --- a/mcp/server_test.go +++ b/mcp/server_test.go @@ -1691,3 +1691,164 @@ func TestServerSession_RejectsServerInitiated(t *testing.T) { } } } + +// TestServerSupportedProtocolVersions verifies that +// [ServerOptions.SupportedProtocolVersions] narrows both the versions the +// server advertises in server/discover and the versions it negotiates, +// and that it cannot widen SDK support. +func TestServerSupportedProtocolVersions(t *testing.T) { + ctx := context.Background() + + t.Run("discover advertises the configured subset", func(t *testing.T) { + server := NewServer(testImpl, &ServerOptions{ + // Deliberately unordered: the server must advertise newest first. + SupportedProtocolVersions: []string{protocolVersion20251125, protocolVersion20260728}, + }) + var advertised []string + server.AddReceivingMiddleware(func(next MethodHandler) MethodHandler { + return func(ctx context.Context, method string, req Request) (Result, error) { + res, err := next(ctx, method, req) + if method == methodDiscover && err == nil { + advertised = res.(*DiscoverResult).SupportedVersions + } + return res, err + } + }) + + ct, st := NewInMemoryTransports() + ss, err := server.Connect(ctx, st, nil) + if err != nil { + t.Fatalf("server.Connect: %v", err) + } + defer ss.Close() + + cs, err := NewClient(testImpl, nil).Connect(ctx, ct, &ClientSessionOptions{ + ProtocolVersion: protocolVersion20260728, + }) + if err != nil { + t.Fatalf("client.Connect: %v", err) + } + defer cs.Close() + + want := []string{protocolVersion20260728, protocolVersion20251125} + if diff := cmp.Diff(want, advertised); diff != "" { + t.Errorf("DiscoverResult.SupportedVersions mismatch (-want +got):\n%s", diff) + } + }) + + t.Run("initialize negotiates within the configured subset", func(t *testing.T) { + tests := []struct { + name string + supported []string + client string + want string + }{ + { + name: "requested version supported", + supported: []string{protocolVersion20251125, protocolVersion20250618}, + client: protocolVersion20250618, + want: protocolVersion20250618, + }, + { + name: "requested version excluded", + supported: []string{protocolVersion20251125, protocolVersion20250618}, + client: protocolVersion20241105, + want: protocolVersion20251125, + }, + { + name: "fallback is the newest configured version", + supported: []string{protocolVersion20250326}, + client: protocolVersion20241105, + want: protocolVersion20250326, + }, + { + // Nothing the server supports can carry an initialize + // handshake, so the answer names a handshake-era version the + // client can act on rather than one it could mistake for the + // new protocol. + name: "server with no handshake version", + supported: []string{protocolVersion20260728}, + client: protocolVersion20250618, + want: protocolVersion20251125, + }, + { + name: "unrestricted server keeps the default behavior", + supported: nil, + client: protocolVersion20241105, + want: protocolVersion20241105, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + server := NewServer(testImpl, &ServerOptions{SupportedProtocolVersions: test.supported}) + ct, st := NewInMemoryTransports() + ss, err := server.Connect(ctx, st, nil) + if err != nil { + t.Fatalf("server.Connect: %v", err) + } + defer ss.Close() + + cs, err := NewClient(testImpl, nil).Connect(ctx, ct, &ClientSessionOptions{ + ProtocolVersion: test.client, + }) + if err != nil { + t.Fatalf("client.Connect: %v", err) + } + defer cs.Close() + + if got := cs.InitializeResult().ProtocolVersion; got != test.want { + t.Errorf("negotiated protocol version = %q, want %q", got, test.want) + } + }) + } + }) + + t.Run("unknown version panics", func(t *testing.T) { + defer func() { + if recover() == nil { + t.Error("NewServer did not panic on an unsupported protocol version") + } + }() + NewServer(testImpl, &ServerOptions{SupportedProtocolVersions: []string{"1999-01-01"}}) + }) +} + +// TestServerSupportedProtocolVersions_NewProtocol verifies that a request +// using the SEP-2575 per-request protocol is rejected with +// [CodeUnsupportedProtocolVersion] when its version is excluded by +// [ServerOptions.SupportedProtocolVersions]. +func TestServerSupportedProtocolVersions_NewProtocol(t *testing.T) { + ctx := context.Background() + server := NewServer(testImpl, &ServerOptions{ + SupportedProtocolVersions: []string{protocolVersion20251125}, + }) + ct, st := NewInMemoryTransports() + ss, err := server.Connect(ctx, st, nil) + if err != nil { + t.Fatalf("server.Connect: %v", err) + } + defer ss.Close() + _ = ct + + params := fmt.Sprintf(`{"_meta":{%q:%q,%q:{}}}`, + MetaKeyProtocolVersion, protocolVersion20260728, MetaKeyClientCapabilities) + _, err = ss.handle(ctx, &jsonrpc.Request{ + ID: jsonrpc2.Int64ID(1), + Method: methodListTools, + Params: json.RawMessage(params), + }) + var jerr *jsonrpc.Error + if !errors.As(err, &jerr) { + t.Fatalf("handle returned %v, want a *jsonrpc.Error", err) + } + if jerr.Code != CodeUnsupportedProtocolVersion { + t.Fatalf("error code = %d, want %d", jerr.Code, CodeUnsupportedProtocolVersion) + } + var data UnsupportedProtocolVersionData + if err := json.Unmarshal(jerr.Data, &data); err != nil { + t.Fatalf("unmarshal error data: %v", err) + } + if diff := cmp.Diff([]string{protocolVersion20251125}, data.Supported); diff != "" { + t.Errorf("UnsupportedProtocolVersionData.Supported mismatch (-want +got):\n%s", diff) + } +} diff --git a/mcp/session.go b/mcp/session.go index dcf9888..08eccaf 100644 --- a/mcp/session.go +++ b/mcp/session.go @@ -22,6 +22,13 @@ type ServerSessionState struct { // InitializedParams are the parameters from 'notifications/initialized'. InitializedParams *InitializedParams `json:"initializedParams"` + // NegotiatedProtocolVersion is the protocol version agreed during + // 'initialize', which may differ from the version the client requested if + // the server does not support it. + // + // It is empty for sessions that never ran the initialize handshake. + NegotiatedProtocolVersion string `json:"negotiatedProtocolVersion,omitempty"` + // LogLevel is the logging level for the session. LogLevel LoggingLevel `json:"logLevel"` diff --git a/mcp/shared.go b/mcp/shared.go index 5069a47..5d0c1c2 100644 --- a/mcp/shared.go +++ b/mcp/shared.go @@ -63,18 +63,38 @@ var supportedProtocolVersions = []string{ protocolVersion20241105, } +// SupportedProtocolVersions returns the protocol versions supported by this +// version of the SDK, newest first. +// +// Use it to discover the values accepted by +// [ServerOptions.SupportedProtocolVersions]. The returned slice is a copy: +// modifying it does not change what the SDK supports. +func SupportedProtocolVersions() []string { return slices.Clone(supportedProtocolVersions) } + // negotiatedVersion returns the effective protocol version to use, given a -// client version. -func negotiatedVersion(clientVersion string) string { - // In general, prefer to use the clientVersion, but if we don't support the - // client's version, use the latest version. +// client version and the versions the server supports, newest first. +func negotiatedVersion(clientVersion string, supported []string) string { + // In general, prefer to use the clientVersion, but if not supported + // by the server, use the latest version the server supports. // // Cap the supported versions at the legacy protocolVersion20251125, as this // method is used by the initialize method which is deprecated in // version protocolVersion20260728. - if slices.Contains(supportedProtocolVersions, clientVersion) && clientVersion < protocolVersion20260728 { + if slices.Contains(supported, clientVersion) && clientVersion < protocolVersion20260728 { return clientVersion } + for _, v := range supported { + if v < protocolVersion20260728 { + return v + } + } + // If the server was enforced to support only protocolVersion20260728 and + // later, the initialize function should not be called. + // If a client calls the initialize function, return the protocolVersion20251125. + // As according to spec (https://modelcontextprotocol.io/specification/2025-11-25/basic/lifecycle#version-negotiation): + // "If the server supports the requested protocol version, it MUST respond with the same version." + // "Otherwise, the server MUST respond with another protocol version it supports." + // "This SHOULD be the latest version supported by the server." return protocolVersion20251125 } diff --git a/mcp/sse_test.go b/mcp/sse_test.go index c0ff445..9186a9c 100644 --- a/mcp/sse_test.go +++ b/mcp/sse_test.go @@ -24,7 +24,7 @@ import ( // stdio and Streamable HTTP (see #1112). func TestSSEServerTransport_SupportedVersions(t *testing.T) { var tr SSEServerTransport - versions := filterSupportedVersions(&tr) + versions := filterSupportedVersions(&tr, supportedProtocolVersions) if slices.Contains(versions, protocolVersion20260728) { t.Errorf("filterSupportedVersions(SSEServerTransport) = %v, must not include %q", versions, protocolVersion20260728) diff --git a/mcp/streamable.go b/mcp/streamable.go index 8729bb7..b4a2a32 100644 --- a/mcp/streamable.go +++ b/mcp/streamable.go @@ -363,13 +363,21 @@ func (h *StreamableHTTPHandler) ServeHTTP(w http.ResponseWriter, req *http.Reque } // [§2.7] of the spec (2025-06-18): validate the MCP-Protocol-Version - // header. If provided, it must be a supported version. If absent, the - // version is unknown (the request may be an initialize for any version). + // header. If provided, it must be a version the server negotiates, which + // [ServerOptions.SupportedProtocolVersions] may have narrowed. If absent, + // the version is unknown (the request may be an initialize for any + // version). // // [§2.7]: https://modelcontextprotocol.io/specification/2025-06-18/basic/transports#protocol-version-header protocolVersion := req.Header.Get(protocolVersionHeader) - if protocolVersion != "" && !slices.Contains(supportedProtocolVersions, protocolVersion) && protocolVersion < protocolVersion20260728 { - http.Error(w, fmt.Sprintf("Bad Request: Unsupported protocol version (supported versions: %s)", strings.Join(supportedProtocolVersions, ",")), http.StatusBadRequest) + var supportedVersions []string + if server := h.getServer(req); server != nil { + supportedVersions = server.protocolVersions + } else { + supportedVersions = supportedProtocolVersions + } + if protocolVersion != "" && !slices.Contains(supportedVersions, protocolVersion) && protocolVersion < protocolVersion20260728 { + http.Error(w, fmt.Sprintf("Bad Request: Unsupported protocol version (supported versions: %s)", strings.Join(supportedVersions, ",")), http.StatusBadRequest) return } req = req.WithContext(context.WithValue(req.Context(), protocolVersionContextKey{}, protocolVersion)) @@ -1541,7 +1549,11 @@ func (c *streamableServerConn) servePOST(w http.ResponseWriter, req *http.Reques // Advertise only the legacy versions this stateful server accepts // (i.e. exclude 2026-07-28, since that's what the request asked for // and what we're rejecting). - legacyVersions := slices.DeleteFunc(slices.Clone(supportedProtocolVersions), func(v string) bool { + advertised := supportedProtocolVersions + if c.server != nil { + advertised = c.server.protocolVersions + } + legacyVersions := slices.DeleteFunc(slices.Clone(advertised), func(v string) bool { return v >= protocolVersion20260728 }) data, _ := json.Marshal(UnsupportedProtocolVersionData{ diff --git a/mcp/streamable_test.go b/mcp/streamable_test.go index 504cda6..ff96f61 100644 --- a/mcp/streamable_test.go +++ b/mcp/streamable_test.go @@ -4141,3 +4141,80 @@ func TestStreamableMaxRequestBodyBytes(t *testing.T) { }) } } + +// TestStreamableSupportedProtocolVersions_Header verifies that the +// MCP-Protocol-Version header is validated against +// [ServerOptions.SupportedProtocolVersions], and not merely against the +// versions the SDK knows about. +func TestStreamableSupportedProtocolVersions_Header(t *testing.T) { + const initBody = `{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{},"clientInfo":{"name":"test","version":"1.0"}}}` + + newServer := func(t *testing.T, stateless bool) *httptest.Server { + server := NewServer(testImpl, &ServerOptions{ + SupportedProtocolVersions: []string{protocolVersion20251125}, + }) + handler := NewStreamableHTTPHandler(func(*http.Request) *Server { return server }, + &StreamableHTTPOptions{Stateless: stateless}) + httpServer := httptest.NewServer(handler) + t.Cleanup(httpServer.Close) + return httpServer + } + post := func(t *testing.T, url, version, sessionID string) *http.Response { + t.Helper() + req, err := http.NewRequest(http.MethodPost, url, strings.NewReader(initBody)) + if err != nil { + t.Fatal(err) + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json, text/event-stream") + req.Header.Set(protocolVersionHeader, version) + if sessionID != "" { + req.Header.Set(sessionIDHeader, sessionID) + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + return resp + } + + for _, stateless := range []bool{false, true} { + for _, test := range []struct { + version string + wantStatus int + }{ + {protocolVersion20251125, http.StatusOK}, + // Supported by the SDK, but excluded by the server. + {protocolVersion20250618, http.StatusBadRequest}, + // Unknown to the SDK: rejected before any server is consulted. + {"1999-01-01", http.StatusBadRequest}, + } { + t.Run(fmt.Sprintf("stateless=%v/%s", stateless, test.version), func(t *testing.T) { + resp := post(t, newServer(t, stateless).URL, test.version, "") + defer resp.Body.Close() + body, _ := io.ReadAll(resp.Body) + if resp.StatusCode != test.wantStatus { + t.Errorf("status = %d, want %d; body = %s", resp.StatusCode, test.wantStatus, body) + } + }) + } + } + + t.Run("existing session", func(t *testing.T) { + httpServer := newServer(t, false) + resp := post(t, httpServer.URL, protocolVersion20251125, "") + io.Copy(io.Discard, resp.Body) + resp.Body.Close() + sessionID := resp.Header.Get(sessionIDHeader) + if sessionID == "" { + t.Fatalf("initialize response missing %s header", sessionIDHeader) + } + + resp = post(t, httpServer.URL, protocolVersion20250618, sessionID) + defer resp.Body.Close() + body, _ := io.ReadAll(resp.Body) + if resp.StatusCode != http.StatusBadRequest { + t.Errorf("status = %d, want %d; body = %s", resp.StatusCode, http.StatusBadRequest, body) + } + }) +} diff --git a/mcp/transport.go b/mcp/transport.go index d72f9d1..1cf0325 100644 --- a/mcp/transport.go +++ b/mcp/transport.go @@ -114,6 +114,8 @@ type clientConnection interface { // TODO: should this interface be exported? type serverConnection interface { Connection + + // sessionUpdated is called whenever the server session state changes. sessionUpdated(ServerSessionState) } @@ -525,17 +527,13 @@ func newIOConn(rwc io.ReadWriteCloser) *ioConn { func (c *ioConn) SessionID() string { return "" } func (c *ioConn) sessionUpdated(state ServerSessionState) { - protocolVersion := "" - if state.InitializeParams != nil { - protocolVersion = state.InitializeParams.ProtocolVersion - } + protocolVersion := state.NegotiatedProtocolVersion if protocolVersion == "" { // 2025-03-26 is used, because it's the last spec version // where specifying the protocol version in the HTTP header // was not required. protocolVersion = protocolVersion20250326 } - protocolVersion = negotiatedVersion(protocolVersion) c.sessionMu.Lock() c.protocolVersion = protocolVersion c.sessionMu.Unlock() diff --git a/mcp/transport_test.go b/mcp/transport_test.go index 730400e..52228d0 100644 --- a/mcp/transport_test.go +++ b/mcp/transport_test.go @@ -56,10 +56,15 @@ func TestBatchFraming(t *testing.T) { func TestIOConnRead(t *testing.T) { tests := []struct { - name string - input string - want string + name string + input string + want string + // protocolVersion is the version negotiated by 'initialize'; empty + // means no session state was pushed to the connection. protocolVersion string + // requested is the version the client asked for, which differs from + // protocolVersion when the server counter-offered. + requested string }{ { name: "valid json input", @@ -100,6 +105,16 @@ func TestIOConnRead(t *testing.T) { want: "JSON-RPC batching is not supported in 2025-06-18 and later (request version: 2025-06-18)", protocolVersion: protocolVersion20250618, }, + { + // The client asked for a version the server does not negotiate, so + // the connection must follow the server's counter-offer (which + // forbids batching) rather than the client's request. + name: "batching at a counter-offered version", + input: `[{"jsonrpc":"2.0","id":1,"method":"test1"},{"jsonrpc":"2.0","id":2,"method":"test2"}]`, + want: "JSON-RPC batching is not supported in 2025-06-18 and later (request version: 2025-11-25)", + requested: protocolVersion20241105, + protocolVersion: protocolVersion20251125, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -108,10 +123,15 @@ func TestIOConnRead(t *testing.T) { }) t.Cleanup(func() { tr.Close() }) if tt.protocolVersion != "" { + requested := tt.requested + if requested == "" { + requested = tt.protocolVersion + } tr.sessionUpdated(ServerSessionState{ InitializeParams: &InitializeParams{ - ProtocolVersion: tt.protocolVersion, + ProtocolVersion: requested, }, + NegotiatedProtocolVersion: tt.protocolVersion, }) } _, err := tr.Read(context.Background()) From d19bfebab9914f4cc2f5480fd53018e238f9b1a5 Mon Sep 17 00:00:00 2001 From: Minghui Wang <143184745+wmh123wmh1@users.noreply.github.com> Date: Mon, 31 Aug 2026 18:00:32 +0800 Subject: [PATCH 42/70] mcp: preserve single-request batch responses (#1214) ## What does this PR do? Preserve JSON-RPC batch response arrays when a batch contains a single request. ## Why is this needed? A single-request legacy batch must be returned as a one-element JSON array, but the JSON response path currently unwraps it into a single JSON object. ## Changes - Preserve the original batch state on the logical stream - Keep single-request batch responses as arrays - Add a regression test for legacy initialization batches ## Testing - `go test ./mcp -count=1` - `go test ./... -count=1` - `git diff --check` Fixes #1211 --- mcp/streamable.go | 7 ++++++- mcp/streamable_test.go | 46 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 52 insertions(+), 1 deletion(-) diff --git a/mcp/streamable.go b/mcp/streamable.go index b4a2a32..c8cd28b 100644 --- a/mcp/streamable.go +++ b/mcp/streamable.go @@ -988,6 +988,10 @@ type stream struct { // Note: if we remove support for batching, this could just be a bool. pendingJSONMessages []json.RawMessage + // isBatch reports whether the request that opened the stream was a JSON-RPC + // batch. Batch responses must remain arrays even when they contain one item. + isBatch bool + // w is the HTTP response writer for this stream. A non-nil w indicates // that the stream is claimed by an HTTP request (the hanging POST or GET); // it is set to nil when the request completes. @@ -1138,7 +1142,7 @@ func (s *stream) deliverLocked(data []byte, eventID string, responseTo jsonrpc.I if done { // Flush all pending messages as JSON response. var toWrite []byte - if len(s.pendingJSONMessages) == 1 { + if len(s.pendingJSONMessages) == 1 && !s.isBatch { toWrite = s.pendingJSONMessages[0] } else { toWrite, err = json.Marshal(s.pendingJSONMessages) @@ -1691,6 +1695,7 @@ func (c *streamableServerConn) servePOST(w http.ResponseWriter, req *http.Reques return } stream.isListen = isSubscriptionsListen + stream.isBatch = isBatch // subscriptions/listen is inherently a long-lived SSE endpoint (SEP-2575): // it has no synchronous result, the response stream stays open until the diff --git a/mcp/streamable_test.go b/mcp/streamable_test.go index ff96f61..58e5ca7 100644 --- a/mcp/streamable_test.go +++ b/mcp/streamable_test.go @@ -4040,6 +4040,52 @@ func TestStreamableServerRejectsDuplicateInFlightRequestID(t *testing.T) { } } +// TestStreamableServerPreservesSingleRequestBatchResponse verifies that a +// single-request JSON-RPC batch is returned as a one-element JSON array. +func TestStreamableServerPreservesSingleRequestBatchResponse(t *testing.T) { + server := NewServer(&Implementation{Name: "testServer", Version: "v1.0.0"}, nil) + handler := NewStreamableHTTPHandler( + func(*http.Request) *Server { return server }, + &StreamableHTTPOptions{JSONResponse: true}, + ) + defer handler.closeAll() + + httpServer := httptest.NewServer(handler) + defer httpServer.Close() + + body := []byte(`[{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-03-26","capabilities":{},"clientInfo":{"name":"test-client","version":"1.0"}}}]`) + 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") + + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + data, err := io.ReadAll(resp.Body) + if err != nil { + t.Fatal(err) + } + if resp.StatusCode != http.StatusOK { + t.Fatalf("status = %d, want %d; body = %s", resp.StatusCode, http.StatusOK, data) + } + + var messages []json.RawMessage + if err := json.Unmarshal(data, &messages); err != nil { + t.Fatalf("response is not a JSON array: %v; body = %s", err, data) + } + if len(messages) != 1 { + t.Fatalf("response contains %d messages, want 1; body = %s", len(messages), data) + } + if _, err := jsonrpc2.DecodeMessage(messages[0]); err != nil { + t.Fatalf("response item is not a JSON-RPC message: %v", err) + } +} + // TestStreamableMaxRequestBodyBytes verifies that the streamable HTTP handler // enforces StreamableHTTPOptions.MaxRequestBodyBytes on incoming request // bodies. The limit must apply uniformly regardless of transfer encoding: From 69c17de411d302a5d4488dbb123fdaa0619211cb Mon Sep 17 00:00:00 2001 From: Haoxin Yang Date: Mon, 31 Aug 2026 19:53:34 +0800 Subject: [PATCH 43/70] mcp: don't mutate caller params on multi-round-trip retries (#1145) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes #1144. `clientMultiRoundTripMiddleware` calls `setMultiRoundTripRetryParams(req, ...)`, which writes `InputResponses` and `RequestState` through the caller's params pointer and never clears them. A `*CallToolParams` (or `GetPromptParams` / `ReadResourceParams`) struct reused across calls then carries the first call's answers into the next call: a server gating on `len(req.Params.InputResponses) == 0` — the idiom in this repo's own tests and examples — treats the fresh call as already answered, so the client's `ElicitationHandler` never fires and an elicitation-gated (e.g. destructive) tool runs without confirmation. This change makes the retry loop carry the fulfilled responses and echoed state on a shallow copy of the params (`multiRoundTripRetryRequest`) instead of mutating the original request. Caller-owned params are now left untouched by `CallTool`, `GetPrompt`, and `ReadResource`. The server-side shim (`serverMultiRoundTripMiddleware`) is unchanged: the params it mutates are SDK-owned, decoded from the wire. Tests: `TestMultiRoundTrip_AutoRetryDoesNotMutateCallerParams` (CallTool: params unchanged after the call, and reusing the same struct fulfills the input requests again instead of silently replaying the first call's answers — it failed with `elicitations after reuse = 1, want 2` before the fix), plus GetPrompt/ReadResource analogues asserting params are left clean. All three fail on main and pass with this change; `go test ./...` and `go vet ./...` are clean. Note on observable behavior: callers can no longer read the fulfilled responses off their own params struct after a call returns. That side effect was undocumented, and relying on it is exactly what makes the replay hazard above possible. --------- Co-authored-by: Guglielmo Colombo --- mcp/mrtr.go | 62 ++++++-- mcp/mrtr_test.go | 386 +++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 437 insertions(+), 11 deletions(-) diff --git a/mcp/mrtr.go b/mcp/mrtr.go index 5f65f56..008c0f8 100644 --- a/mcp/mrtr.go +++ b/mcp/mrtr.go @@ -108,7 +108,10 @@ func clientMultiRoundTripMiddleware() Middleware { if err != nil { return nil, err } - setMultiRoundTripRetryParams(req, responses, mrtrResult.requestState()) + req, err = setMultiRoundTripRetryParams(method, req, responses, mrtrResult.requestState()) + if err != nil { + return nil, err + } } } } @@ -152,7 +155,10 @@ func serverMultiRoundTripMiddleware() Middleware { if err != nil { return nil, err } - setMultiRoundTripRetryParams(req, responses, mrtrResult.requestState()) + req, err = setMultiRoundTripRetryParams(method, req, responses, mrtrResult.requestState()) + if err != nil { + return nil, err + } return next(ctx, method, req) } } @@ -213,20 +219,54 @@ func createMessageParamsToWithTools(p *CreateMessageParams) *CreateMessageWithTo } } -func setMultiRoundTripRetryParams(req Request, responses InputResponseMap, state string) { +// setMultiRoundTripRetryParams returns the request for the next round of a +// multi-round-trip retry: a request whose params are a shallow copy of the +// original, carrying the fulfilled responses and the request state to echo. +// The original params are never mutated: on the client side they are +// caller-owned, and a params struct reused across calls must not carry one +// call's inputResponses and requestState into the next, where a server +// gating on them (for example an elicitation confirmation) would treat the +// new call as already answered. +// +// An unrecognized params type is an error. It cannot occur on the server +// side, but client sending middleware runs before the retry loop and may +// substitute its own params. +func setMultiRoundTripRetryParams(method string, req Request, responses InputResponseMap, state string) (Request, error) { + var retryParams Params switch p := req.GetParams().(type) { case *CallToolParams: - p.InputResponses = responses - p.RequestState = state + cp := *p + cp.InputResponses = responses + cp.RequestState = state + retryParams = &cp case *CallToolParamsRaw: - p.InputResponses = responses - p.RequestState = state + cp := *p + cp.InputResponses = responses + cp.RequestState = state + retryParams = &cp case *GetPromptParams: - p.InputResponses = responses - p.RequestState = state + cp := *p + cp.InputResponses = responses + cp.RequestState = state + retryParams = &cp case *ReadResourceParams: - p.InputResponses = responses - p.RequestState = state + cp := *p + cp.InputResponses = responses + cp.RequestState = state + retryParams = &cp + default: + return nil, fmt.Errorf("multi-round-trip: unsupported params type %T", p) + } + switch s := req.GetSession().(type) { + case *ClientSession: + return newClientRequest(s, retryParams), nil + case *ServerSession: + // Rebuild with the same constructor handleReceive uses: the + // receiving dispatch type-asserts on the request's concrete + // *ServerRequest[P] type, and Extra must carry over. + return s.receivingMethodInfos()[method].newRequest(s, retryParams, req.GetExtra()), nil + default: + return nil, fmt.Errorf("multi-round-trip: unsupported session type %T", s) } } diff --git a/mcp/mrtr_test.go b/mcp/mrtr_test.go index 2ae4fd9..d966219 100644 --- a/mcp/mrtr_test.go +++ b/mcp/mrtr_test.go @@ -10,6 +10,7 @@ import ( "context" "fmt" "slices" + "strings" "sync/atomic" "testing" @@ -196,6 +197,198 @@ func TestMultiRoundTrip_AutoRetry(t *testing.T) { } } +// TestMultiRoundTrip_AutoRetryDoesNotMutateCallerParams verifies that +// the client middleware carries inputResponses and requestState on a +// copy of the caller's params: after CallTool returns, the caller's +// struct is unchanged, and reusing it for a second call fulfills the +// input requests again instead of silently replaying the first call's +// answers against a server that gates on them. +func TestMultiRoundTrip_AutoRetryDoesNotMutateCallerParams(t *testing.T) { + ctx := context.Background() + + srv := NewServer(testImpl, nil) + AddTool(srv, &Tool{Name: "act"}, func(ctx context.Context, req *CallToolRequest, input struct{}) (*CallToolResult, any, error) { + if len(req.Params.InputResponses) == 0 { + return &CallToolResult{ + InputRequests: InputRequestMap{"confirm": &ElicitParams{Message: "Sure?"}}, + RequestState: "state-1", + }, nil, nil + } + return &CallToolResult{}, map[string]any{"ok": true}, nil + }) + + var elicitations atomic.Int32 + conn := mustConnect(t, srv, &ClientOptions{ + ElicitationHandler: func(_ context.Context, _ *ElicitRequest) (*ElicitResult, error) { + elicitations.Add(1) + return &ElicitResult{Action: "accept"}, nil + }, + }) + + params := &CallToolParams{Name: "act"} + res, err := conn.CallTool(ctx, params) + if err != nil { + t.Fatalf("CallTool() error = %v", err) + } + if res.NeedsInput() { + t.Fatal("NeedsInput() = true after auto-retry, want false") + } + if params.InputResponses != nil { + t.Errorf("params.InputResponses = %v after CallTool, want nil (caller params must not be mutated)", params.InputResponses) + } + if params.RequestState != "" { + t.Errorf("params.RequestState = %q after CallTool, want empty (caller params must not be mutated)", params.RequestState) + } + if got := elicitations.Load(); got != 1 { + t.Fatalf("elicitations = %d, want 1", got) + } + + // Reusing the same params struct must fulfill the input requests + // again, not replay the first call's responses. + if _, err := conn.CallTool(ctx, params); err != nil { + t.Fatalf("CallTool() reuse error = %v", err) + } + if got := elicitations.Load(); got != 2 { + t.Errorf("elicitations after reuse = %d, want 2 (stale responses must not be replayed)", got) + } +} + +// TestMultiRoundTrip_GetPrompt_AutoRetryDoesNotMutateCallerParams is the +// GetPrompt analogue of the CallTool caller-params test. +func TestMultiRoundTrip_GetPrompt_AutoRetryDoesNotMutateCallerParams(t *testing.T) { + ctx := context.Background() + + srv := NewServer(testImpl, nil) + srv.AddPrompt(&Prompt{Name: "review"}, func(_ context.Context, req *GetPromptRequest) (*GetPromptResult, error) { + if len(req.Params.InputResponses) == 0 { + return &GetPromptResult{ + InputRequests: InputRequestMap{"confirm": &ElicitParams{Message: "Include sensitive data?"}}, + RequestState: "prompt-state", + }, nil + } + return &GetPromptResult{ + Messages: []*PromptMessage{{Role: "user", Content: &TextContent{Text: "review this code"}}}, + }, nil + }) + + conn := mustConnect(t, srv, &ClientOptions{ + ElicitationHandler: func(_ context.Context, _ *ElicitRequest) (*ElicitResult, error) { + return &ElicitResult{Action: "accept"}, nil + }, + }) + + params := &GetPromptParams{Name: "review"} + res, err := conn.GetPrompt(ctx, params) + if err != nil { + t.Fatalf("GetPrompt() error = %v", err) + } + if res.NeedsInput() { + t.Fatal("NeedsInput() = true after auto-retry, want false") + } + if params.InputResponses != nil { + t.Errorf("params.InputResponses = %v after GetPrompt, want nil (caller params must not be mutated)", params.InputResponses) + } + if params.RequestState != "" { + t.Errorf("params.RequestState = %q after GetPrompt, want empty (caller params must not be mutated)", params.RequestState) + } +} + +// TestMultiRoundTrip_ReadResource_AutoRetryDoesNotMutateCallerParams is +// the ReadResource analogue of the CallTool caller-params test. +func TestMultiRoundTrip_ReadResource_AutoRetryDoesNotMutateCallerParams(t *testing.T) { + ctx := context.Background() + + srv := NewServer(testImpl, nil) + srv.AddResource(&Resource{URI: "test://data", Name: "data"}, func(_ context.Context, req *ReadResourceRequest) (*ReadResourceResult, error) { + if len(req.Params.InputResponses) == 0 { + return &ReadResourceResult{ + InputRequests: InputRequestMap{"auth": &ElicitParams{Message: "Authenticate?"}}, + RequestState: "resource-state", + }, nil + } + return &ReadResourceResult{ + Contents: []*ResourceContents{{URI: "test://data", Text: "resource data"}}, + }, nil + }) + + conn := mustConnect(t, srv, &ClientOptions{ + ElicitationHandler: func(_ context.Context, _ *ElicitRequest) (*ElicitResult, error) { + return &ElicitResult{Action: "accept"}, nil + }, + }) + + params := &ReadResourceParams{URI: "test://data"} + res, err := conn.ReadResource(ctx, params) + if err != nil { + t.Fatalf("ReadResource() error = %v", err) + } + if res.NeedsInput() { + t.Fatal("NeedsInput() = true after auto-retry, want false") + } + if params.InputResponses != nil { + t.Errorf("params.InputResponses = %v after ReadResource, want nil (caller params must not be mutated)", params.InputResponses) + } + if params.RequestState != "" { + t.Errorf("params.RequestState = %q after ReadResource, want empty (caller params must not be mutated)", params.RequestState) + } +} + +// wrappedCallToolParams embeds *CallToolParams so it satisfies Params and +// marshals identically, while being a concrete type the multi-round-trip +// retry does not recognize. +type wrappedCallToolParams struct{ *CallToolParams } + +// TestMultiRoundTrip_UnsupportedRetryParamsType verifies that the client +// retry loop reports an explicit error when the params carry a type it +// cannot build retry params for, instead of silently resending the request +// unchanged until the retry cap. +func TestMultiRoundTrip_UnsupportedRetryParamsType(t *testing.T) { + ctx := context.Background() + + srv := NewServer(testImpl, nil) + AddTool(srv, &Tool{Name: "act"}, func(ctx context.Context, req *CallToolRequest, input struct{}) (*CallToolResult, any, error) { + return &CallToolResult{ + InputRequests: InputRequestMap{"confirm": &ElicitParams{Message: "Sure?"}}, + RequestState: "state-1", + }, nil, nil + }) + + st, ct := NewInMemoryTransports() + ss, err := srv.Connect(t.Context(), st, nil) + if err != nil { + t.Fatalf("server.Connect() error = %v", err) + } + t.Cleanup(func() { _ = ss.Close() }) + + c := NewClient(testImpl, &ClientOptions{ + ElicitationHandler: func(_ context.Context, _ *ElicitRequest) (*ElicitResult, error) { + return &ElicitResult{Action: "accept"}, nil + }, + }) + // Sending middleware added here wraps outside the multi-round-trip + // middleware installed at client construction, so the retry loop sees + // the substituted params type. + c.AddSendingMiddleware(func(next MethodHandler) MethodHandler { + return func(ctx context.Context, method string, req Request) (Result, error) { + if method == methodCallTool { + cs := req.GetSession().(*ClientSession) + req = newClientRequest[Params](cs, wrappedCallToolParams{req.GetParams().(*CallToolParams)}) + } + return next(ctx, method, req) + } + }) + cs, err := c.Connect(t.Context(), ct, nil) + if err != nil { + t.Fatalf("client.Connect() error = %v", err) + } + t.Cleanup(func() { _ = cs.Close() }) + + _, err = cs.CallTool(ctx, &CallToolParams{Name: "act"}) + if err == nil || !strings.Contains(err.Error(), "unsupported params type") { + t.Fatalf("CallTool() error = %v, want unsupported params type error", err) + } +} + func TestMultiRoundTrip_MaxRetries(t *testing.T) { testCases := []struct { name string @@ -329,6 +522,41 @@ func TestMultiRoundTrip_ServerMiddleware(t *testing.T) { } } +// TestMultiRoundTrip_ServerMiddleware_GetPrompt verifies the server-side +// shim retry for prompts: the rebuilt retry request must keep its concrete +// *ServerRequest[*GetPromptParams] type for the receiving dispatch. +func TestMultiRoundTrip_ServerMiddleware_GetPrompt(t *testing.T) { + ctx := context.Background() + + srv := NewServer(testImpl, nil) + srv.AddPrompt(&Prompt{Name: "review"}, func(_ context.Context, req *GetPromptRequest) (*GetPromptResult, error) { + if len(req.Params.InputResponses) == 0 { + return &GetPromptResult{ + InputRequests: InputRequestMap{"confirm": &ElicitParams{Message: "Sure?"}}, + RequestState: "prompt-state", + }, nil + } + return &GetPromptResult{ + Messages: []*PromptMessage{{Role: "user", Content: &TextContent{Text: "approved"}}}, + }, nil + }) + + conn := mustConnectOldProtocol(t, srv, &ClientOptions{ + MultiRoundTrip: &MultiRoundTripOptions{Disabled: true}, + ElicitationHandler: func(_ context.Context, _ *ElicitRequest) (*ElicitResult, error) { + return &ElicitResult{Action: "accept"}, nil + }, + }) + + res, err := conn.GetPrompt(ctx, &GetPromptParams{Name: "review"}) + if err != nil { + t.Fatalf("GetPrompt() error = %v", err) + } + if got := res.Messages[0].Content.(*TextContent).Text; got != "approved" { + t.Errorf("message text = %q, want %q", got, "approved") + } +} + func TestMultiRoundTrip_GetPrompt_AutoRetry(t *testing.T) { ctx := context.Background() @@ -503,6 +731,138 @@ func TestMultiRoundTrip_ReadResource_ManualRetry(t *testing.T) { } } +// TestSetMultiRoundTripRetryParams pins the properties the retry-request +// builder must uphold: the original params are never mutated, the copy +// carries the responses and state, the server-side rebuild preserves the +// request's concrete *ServerRequest[P] type (the receiving dispatch +// type-asserts on it) and its Extra, and an unrecognized params type is an +// explicit error. The server readResource case matters here because the +// end-to-end shim path cannot reach it (the read handler's nil-contents +// validation rejects input-required results for old-protocol clients). +func TestSetMultiRoundTripRetryParams(t *testing.T) { + ss := &ServerSession{server: NewServer(testImpl, nil)} + extra := &RequestExtra{} + responses := InputResponseMap{"confirm": &ElicitResult{Action: "accept"}} + + // checkRetry asserts the returned params carry the retry fields while + // the original params were left untouched. + checkRetry := func(t *testing.T, orig, retry Params) { + t.Helper() + switch p := retry.(type) { + case *CallToolParams: + if len(p.InputResponses) != 1 || p.RequestState != "s1" { + t.Errorf("retry params = %+v, want responses and state carried", p) + } + case *CallToolParamsRaw: + if len(p.InputResponses) != 1 || p.RequestState != "s1" { + t.Errorf("retry params = %+v, want responses and state carried", p) + } + case *GetPromptParams: + if len(p.InputResponses) != 1 || p.RequestState != "s1" { + t.Errorf("retry params = %+v, want responses and state carried", p) + } + case *ReadResourceParams: + if len(p.InputResponses) != 1 || p.RequestState != "s1" { + t.Errorf("retry params = %+v, want responses and state carried", p) + } + default: + t.Errorf("retry params type = %T, want one of the multi-round-trip params types", p) + } + switch p := orig.(type) { + case *CallToolParams: + if p.InputResponses != nil || p.RequestState != "" { + t.Errorf("original params mutated: %+v", p) + } + case *CallToolParamsRaw: + if p.InputResponses != nil || p.RequestState != "" { + t.Errorf("original params mutated: %+v", p) + } + case *GetPromptParams: + if p.InputResponses != nil || p.RequestState != "" { + t.Errorf("original params mutated: %+v", p) + } + case *ReadResourceParams: + if p.InputResponses != nil || p.RequestState != "" { + t.Errorf("original params mutated: %+v", p) + } + } + } + + t.Run("server callTool", func(t *testing.T) { + orig := &CallToolParamsRaw{Name: "act"} + req, err := setMultiRoundTripRetryParams(methodCallTool, &ServerRequest[*CallToolParamsRaw]{Session: ss, Params: orig, Extra: extra}, responses, "s1") + if err != nil { + t.Fatalf("setMultiRoundTripRetryParams() error = %v", err) + } + got, ok := req.(*ServerRequest[*CallToolParamsRaw]) + if !ok { + t.Fatalf("retry request type = %T, want *ServerRequest[*CallToolParamsRaw]", req) + } + if got.Extra != extra { + t.Error("retry request lost Extra") + } + checkRetry(t, orig, got.Params) + }) + + t.Run("server getPrompt", func(t *testing.T) { + orig := &GetPromptParams{Name: "review"} + req, err := setMultiRoundTripRetryParams(methodGetPrompt, &ServerRequest[*GetPromptParams]{Session: ss, Params: orig, Extra: extra}, responses, "s1") + if err != nil { + t.Fatalf("setMultiRoundTripRetryParams() error = %v", err) + } + got, ok := req.(*ServerRequest[*GetPromptParams]) + if !ok { + t.Fatalf("retry request type = %T, want *ServerRequest[*GetPromptParams]", req) + } + if got.Extra != extra { + t.Error("retry request lost Extra") + } + checkRetry(t, orig, got.Params) + }) + + t.Run("server readResource", func(t *testing.T) { + orig := &ReadResourceParams{URI: "test://data"} + req, err := setMultiRoundTripRetryParams(methodReadResource, &ServerRequest[*ReadResourceParams]{Session: ss, Params: orig, Extra: extra}, responses, "s1") + if err != nil { + t.Fatalf("setMultiRoundTripRetryParams() error = %v", err) + } + got, ok := req.(*ServerRequest[*ReadResourceParams]) + if !ok { + t.Fatalf("retry request type = %T, want *ServerRequest[*ReadResourceParams]", req) + } + if got.Extra != extra { + t.Error("retry request lost Extra") + } + checkRetry(t, orig, got.Params) + }) + + t.Run("client params", func(t *testing.T) { + for _, orig := range []Params{ + &CallToolParams{Name: "act"}, + &GetPromptParams{Name: "review"}, + &ReadResourceParams{URI: "test://data"}, + } { + req, err := setMultiRoundTripRetryParams(methodCallTool, newClientRequest[Params](nil, orig), responses, "s1") + if err != nil { + t.Fatalf("setMultiRoundTripRetryParams(%T) error = %v", orig, err) + } + got, ok := req.(*ClientRequest[Params]) + if !ok { + t.Fatalf("retry request type = %T, want *ClientRequest[Params]", req) + } + checkRetry(t, orig, got.Params) + } + }) + + t.Run("unsupported params type", func(t *testing.T) { + wrapped := wrappedCallToolParams{&CallToolParams{Name: "act"}} + _, err := setMultiRoundTripRetryParams(methodCallTool, newClientRequest[Params](nil, wrapped), responses, "s1") + if err == nil || !strings.Contains(err.Error(), "unsupported params type") { + t.Fatalf("setMultiRoundTripRetryParams() error = %v, want unsupported params type error", err) + } + }) +} + func mustConnect(t *testing.T, s *Server, clientOpts *ClientOptions) *ClientSession { t.Helper() @@ -525,3 +885,29 @@ func mustConnect(t *testing.T, s *Server, clientOpts *ClientOptions) *ClientSess }) return cs } + +// mustConnectOldProtocol is mustConnect for a client on a protocol version +// that predates multi-round-trip: the server-side shim, not the client +// middleware, fulfills input requests. +func mustConnectOldProtocol(t *testing.T, s *Server, clientOpts *ClientOptions) *ClientSession { + t.Helper() + + st, ct := NewInMemoryTransports() + ss, err := s.Connect(t.Context(), st, nil) + if err != nil { + t.Fatalf("server.Connect() error = %v", err) + } + t.Cleanup(func() { + _ = ss.Close() + }) + + c := NewClient(testImpl, clientOpts) + cs, err := c.Connect(t.Context(), ct, &ClientSessionOptions{ProtocolVersion: protocolVersion20251125}) + if err != nil { + t.Fatalf("client.Connect() error = %v", err) + } + t.Cleanup(func() { + _ = cs.Close() + }) + return cs +} From ce3a92a0a8aa1d6298660180a6a3a8e798b7550d Mon Sep 17 00:00:00 2001 From: Qinghong Zhu <105350380+qhzhu@users.noreply.github.com> Date: Tue, 1 Sep 2026 16:09:19 +0800 Subject: [PATCH 44/70] mcp: fix ListChanged doc comments on server capabilities (#1219) The doc comments on `ToolCapabilities.ListChanged` and `ResourceCapabilities.ListChanged` say the flag reports whether **the client** supports list-change notifications. Both types only ever appear inside `ServerCapabilities`, though, and the spec defines the field the other way around: whether **this server** supports notifications for changes to the list. `PromptCapabilities` in the same file already words it correctly. The "client" wording looks like it was copied from `RootCapabilities`, the one listChanged capability that genuinely belongs to the client. Docs only, no behavior change. --- mcp/protocol.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/mcp/protocol.go b/mcp/protocol.go index 47cb9cb..7a881d4 100644 --- a/mcp/protocol.go +++ b/mcp/protocol.go @@ -2246,7 +2246,7 @@ type PromptCapabilities struct { // ResourceCapabilities describes the server's support for resources. type ResourceCapabilities struct { - // ListChanged reports whether the client supports notifications for + // ListChanged reports whether this server supports notifications for // changes to the resource list. ListChanged bool `json:"listChanged,omitempty"` // Subscribe reports whether this server supports subscribing to resource @@ -2256,7 +2256,7 @@ type ResourceCapabilities struct { // ToolCapabilities describes the server's support for tools. type ToolCapabilities struct { - // ListChanged reports whether the client supports notifications for + // ListChanged reports whether this server supports notifications for // changes to the tool list. ListChanged bool `json:"listChanged,omitempty"` } From f47d6ce156fa80452445877c393bae5635ccf1e4 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 1 Sep 2026 10:12:12 +0000 Subject: [PATCH 45/70] build(deps): bump the codeql-action group with 3 updates (#1216) Bumps the codeql-action group with 3 updates: [github/codeql-action/init](https://github.com/github/codeql-action), [github/codeql-action/analyze](https://github.com/github/codeql-action) and [github/codeql-action/upload-sarif](https://github.com/github/codeql-action). Updates `github/codeql-action/init` from 4.37.3 to 4.37.9
Release notes

Sourced from github/codeql-action/init's releases.

v4.37.9

  • Update default CodeQL bundle version to 2.26.4. #4106

v4.37.8

No user facing changes.

v4.37.7

  • Update default CodeQL bundle version to 2.26.3. #4085

v4.37.6

  • Changed the default filepath for the new remote file address format that was introduced in CodeQL Action 4.37.0 / 3.37.0 to .github/codeql-config.yml to align it with the suggested path that is used elsewhere. #4070

v4.37.5

  • Fixed a bug where a network error while streaming the download of the CodeQL bundle could terminate the init Action instead of falling back to downloading the bundle before extracting it. #4061

v4.37.4

  • This version of the CodeQL Action adds support for the tools input for the codeql-action/init step to be specified using a github-codeql-tools repository property. This feature will gradually be rolled out following the release of this version. Once rolled out, this allows for the CodeQL CLI version that is used in GitHub-managed workflows, such as Default Setup, to be set to a custom value. For example, customers who run into issues with rate limits when a new CodeQL CLI version is released can set the value to toolcache to always use the CodeQL CLI version that is available in the runner toolcache. For Advanced Setup workflows, the value provided for tools in the workflow definition always takes precedence unless the value of the repository property starts with !. #4037
  • Update default CodeQL bundle version to 2.26.2. #4051
Changelog

Sourced from github/codeql-action/init's changelog.

CodeQL Action Changelog

See the releases page for the relevant changes to the CodeQL CLI and language packs.

[UNRELEASED]

No user facing changes.

4.37.9 - 26 Aug 2026

  • Update default CodeQL bundle version to 2.26.4. #4106

4.37.8 - 21 Aug 2026

No user facing changes.

4.37.7 - 13 Aug 2026

  • Update default CodeQL bundle version to 2.26.3. #4085

4.37.6 - 04 Aug 2026

  • Changed the default filepath for the new remote file address format that was introduced in CodeQL Action 4.37.0 / 3.37.0 to .github/codeql-config.yml to align it with the suggested path that is used elsewhere. #4070

4.37.5 - 03 Aug 2026

  • Fixed a bug where a network error while streaming the download of the CodeQL bundle could terminate the init Action instead of falling back to downloading the bundle before extracting it. #4061

4.37.4 - 29 Jul 2026

  • This version of the CodeQL Action adds support for the tools input for the codeql-action/init step to be specified using a github-codeql-tools repository property. This feature will gradually be rolled out following the release of this version. Once rolled out, this allows for the CodeQL CLI version that is used in GitHub-managed workflows, such as Default Setup, to be set to a custom value. For example, customers who run into issues with rate limits when a new CodeQL CLI version is released can set the value to toolcache to always use the CodeQL CLI version that is available in the runner toolcache. For Advanced Setup workflows, the value provided for tools in the workflow definition always takes precedence unless the value of the repository property starts with !. #4037
  • Update default CodeQL bundle version to 2.26.2. #4051

4.37.3 - 22 Jul 2026

No user facing changes.

4.37.2 - 21 Jul 2026

  • The new address format for the config-file input that was introduced in CodeQL Action 4.37.0 is now enabled by default. In addition to the format described there, the remote= prefix can now be used to explicitly indicate that the input refers to a remote file. All previous input formats continue to be accepted as well. #4023
  • The CodeQL Action can now make use of configured private registries in Default Setup to retrieve CodeQL configuration files from remote repositories that require authentication. This will allow customers to store their CodeQL configuration in a single repository that can then be referenced by Default Setup workflows in other repositories. We expect to roll this and other, related changes out to everyone in July. #4007

4.37.1 - 16 Jul 2026

  • Upcoming breaking change: Add a deprecation warning for customers using CodeQL version 2.20.6 and earlier. These versions of CodeQL were discontinued on 1 July 2026 alongside GitHub Enterprise Server 3.16, and will be unsupported by the next minor release of the CodeQL Action. #3956
  • Update default CodeQL bundle version to 2.26.1. #4019

4.37.0 - 08 Jul 2026

  • Update default CodeQL bundle version to 2.26.0. #3995

... (truncated)

Commits
  • cdf488f Merge pull request #4107 from github/update-v4.37.9-920ba7cd1
  • 7243f38 Update changelog for v4.37.9
  • 920ba7c Merge pull request #4106 from github/update-bundle/codeql-bundle-v2.26.4
  • ecfa6e1 Add changelog note
  • adcdf4a Update default bundle to codeql-bundle-v2.26.4
  • 486fec2 Merge pull request #4099 from github/update-supported-enterprise-server-versions
  • 134624c Merge pull request #4101 from github/dependabot/npm_and_yarn/npm-minor-457d82...
  • ff43db8 Merge pull request #4103 from github/mergeback/v4.37.8-to-main-db488dde
  • 4605e03 Rebuild
  • 099c869 Update changelog and version after v4.37.8
  • Additional commits viewable in compare view

Updates `github/codeql-action/analyze` from 4.37.3 to 4.37.9
Release notes

Sourced from github/codeql-action/analyze's releases.

v4.37.9

  • Update default CodeQL bundle version to 2.26.4. #4106

v4.37.8

No user facing changes.

v4.37.7

  • Update default CodeQL bundle version to 2.26.3. #4085

v4.37.6

  • Changed the default filepath for the new remote file address format that was introduced in CodeQL Action 4.37.0 / 3.37.0 to .github/codeql-config.yml to align it with the suggested path that is used elsewhere. #4070

v4.37.5

  • Fixed a bug where a network error while streaming the download of the CodeQL bundle could terminate the init Action instead of falling back to downloading the bundle before extracting it. #4061

v4.37.4

  • This version of the CodeQL Action adds support for the tools input for the codeql-action/init step to be specified using a github-codeql-tools repository property. This feature will gradually be rolled out following the release of this version. Once rolled out, this allows for the CodeQL CLI version that is used in GitHub-managed workflows, such as Default Setup, to be set to a custom value. For example, customers who run into issues with rate limits when a new CodeQL CLI version is released can set the value to toolcache to always use the CodeQL CLI version that is available in the runner toolcache. For Advanced Setup workflows, the value provided for tools in the workflow definition always takes precedence unless the value of the repository property starts with !. #4037
  • Update default CodeQL bundle version to 2.26.2. #4051
Changelog

Sourced from github/codeql-action/analyze's changelog.

CodeQL Action Changelog

See the releases page for the relevant changes to the CodeQL CLI and language packs.

[UNRELEASED]

No user facing changes.

4.37.9 - 26 Aug 2026

  • Update default CodeQL bundle version to 2.26.4. #4106

4.37.8 - 21 Aug 2026

No user facing changes.

4.37.7 - 13 Aug 2026

  • Update default CodeQL bundle version to 2.26.3. #4085

4.37.6 - 04 Aug 2026

  • Changed the default filepath for the new remote file address format that was introduced in CodeQL Action 4.37.0 / 3.37.0 to .github/codeql-config.yml to align it with the suggested path that is used elsewhere. #4070

4.37.5 - 03 Aug 2026

  • Fixed a bug where a network error while streaming the download of the CodeQL bundle could terminate the init Action instead of falling back to downloading the bundle before extracting it. #4061

4.37.4 - 29 Jul 2026

  • This version of the CodeQL Action adds support for the tools input for the codeql-action/init step to be specified using a github-codeql-tools repository property. This feature will gradually be rolled out following the release of this version. Once rolled out, this allows for the CodeQL CLI version that is used in GitHub-managed workflows, such as Default Setup, to be set to a custom value. For example, customers who run into issues with rate limits when a new CodeQL CLI version is released can set the value to toolcache to always use the CodeQL CLI version that is available in the runner toolcache. For Advanced Setup workflows, the value provided for tools in the workflow definition always takes precedence unless the value of the repository property starts with !. #4037
  • Update default CodeQL bundle version to 2.26.2. #4051

4.37.3 - 22 Jul 2026

No user facing changes.

4.37.2 - 21 Jul 2026

  • The new address format for the config-file input that was introduced in CodeQL Action 4.37.0 is now enabled by default. In addition to the format described there, the remote= prefix can now be used to explicitly indicate that the input refers to a remote file. All previous input formats continue to be accepted as well. #4023
  • The CodeQL Action can now make use of configured private registries in Default Setup to retrieve CodeQL configuration files from remote repositories that require authentication. This will allow customers to store their CodeQL configuration in a single repository that can then be referenced by Default Setup workflows in other repositories. We expect to roll this and other, related changes out to everyone in July. #4007

4.37.1 - 16 Jul 2026

  • Upcoming breaking change: Add a deprecation warning for customers using CodeQL version 2.20.6 and earlier. These versions of CodeQL were discontinued on 1 July 2026 alongside GitHub Enterprise Server 3.16, and will be unsupported by the next minor release of the CodeQL Action. #3956
  • Update default CodeQL bundle version to 2.26.1. #4019

4.37.0 - 08 Jul 2026

  • Update default CodeQL bundle version to 2.26.0. #3995

... (truncated)

Commits
  • cdf488f Merge pull request #4107 from github/update-v4.37.9-920ba7cd1
  • 7243f38 Update changelog for v4.37.9
  • 920ba7c Merge pull request #4106 from github/update-bundle/codeql-bundle-v2.26.4
  • ecfa6e1 Add changelog note
  • adcdf4a Update default bundle to codeql-bundle-v2.26.4
  • 486fec2 Merge pull request #4099 from github/update-supported-enterprise-server-versions
  • 134624c Merge pull request #4101 from github/dependabot/npm_and_yarn/npm-minor-457d82...
  • ff43db8 Merge pull request #4103 from github/mergeback/v4.37.8-to-main-db488dde
  • 4605e03 Rebuild
  • 099c869 Update changelog and version after v4.37.8
  • Additional commits viewable in compare view

Updates `github/codeql-action/upload-sarif` from 4.37.3 to 4.37.9
Release notes

Sourced from github/codeql-action/upload-sarif's releases.

v4.37.9

  • Update default CodeQL bundle version to 2.26.4. #4106

v4.37.8

No user facing changes.

v4.37.7

  • Update default CodeQL bundle version to 2.26.3. #4085

v4.37.6

  • Changed the default filepath for the new remote file address format that was introduced in CodeQL Action 4.37.0 / 3.37.0 to .github/codeql-config.yml to align it with the suggested path that is used elsewhere. #4070

v4.37.5

  • Fixed a bug where a network error while streaming the download of the CodeQL bundle could terminate the init Action instead of falling back to downloading the bundle before extracting it. #4061

v4.37.4

  • This version of the CodeQL Action adds support for the tools input for the codeql-action/init step to be specified using a github-codeql-tools repository property. This feature will gradually be rolled out following the release of this version. Once rolled out, this allows for the CodeQL CLI version that is used in GitHub-managed workflows, such as Default Setup, to be set to a custom value. For example, customers who run into issues with rate limits when a new CodeQL CLI version is released can set the value to toolcache to always use the CodeQL CLI version that is available in the runner toolcache. For Advanced Setup workflows, the value provided for tools in the workflow definition always takes precedence unless the value of the repository property starts with !. #4037
  • Update default CodeQL bundle version to 2.26.2. #4051
Changelog

Sourced from github/codeql-action/upload-sarif's changelog.

CodeQL Action Changelog

See the releases page for the relevant changes to the CodeQL CLI and language packs.

[UNRELEASED]

No user facing changes.

4.37.9 - 26 Aug 2026

  • Update default CodeQL bundle version to 2.26.4. #4106

4.37.8 - 21 Aug 2026

No user facing changes.

4.37.7 - 13 Aug 2026

  • Update default CodeQL bundle version to 2.26.3. #4085

4.37.6 - 04 Aug 2026

  • Changed the default filepath for the new remote file address format that was introduced in CodeQL Action 4.37.0 / 3.37.0 to .github/codeql-config.yml to align it with the suggested path that is used elsewhere. #4070

4.37.5 - 03 Aug 2026

  • Fixed a bug where a network error while streaming the download of the CodeQL bundle could terminate the init Action instead of falling back to downloading the bundle before extracting it. #4061

4.37.4 - 29 Jul 2026

  • This version of the CodeQL Action adds support for the tools input for the codeql-action/init step to be specified using a github-codeql-tools repository property. This feature will gradually be rolled out following the release of this version. Once rolled out, this allows for the CodeQL CLI version that is used in GitHub-managed workflows, such as Default Setup, to be set to a custom value. For example, customers who run into issues with rate limits when a new CodeQL CLI version is released can set the value to toolcache to always use the CodeQL CLI version that is available in the runner toolcache. For Advanced Setup workflows, the value provided for tools in the workflow definition always takes precedence unless the value of the repository property starts with !. #4037
  • Update default CodeQL bundle version to 2.26.2. #4051

4.37.3 - 22 Jul 2026

No user facing changes.

4.37.2 - 21 Jul 2026

  • The new address format for the config-file input that was introduced in CodeQL Action 4.37.0 is now enabled by default. In addition to the format described there, the remote= prefix can now be used to explicitly indicate that the input refers to a remote file. All previous input formats continue to be accepted as well. #4023
  • The CodeQL Action can now make use of configured private registries in Default Setup to retrieve CodeQL configuration files from remote repositories that require authentication. This will allow customers to store their CodeQL configuration in a single repository that can then be referenced by Default Setup workflows in other repositories. We expect to roll this and other, related changes out to everyone in July. #4007

4.37.1 - 16 Jul 2026

  • Upcoming breaking change: Add a deprecation warning for customers using CodeQL version 2.20.6 and earlier. These versions of CodeQL were discontinued on 1 July 2026 alongside GitHub Enterprise Server 3.16, and will be unsupported by the next minor release of the CodeQL Action. #3956
  • Update default CodeQL bundle version to 2.26.1. #4019

4.37.0 - 08 Jul 2026

  • Update default CodeQL bundle version to 2.26.0. #3995

... (truncated)

Commits
  • cdf488f Merge pull request #4107 from github/update-v4.37.9-920ba7cd1
  • 7243f38 Update changelog for v4.37.9
  • 920ba7c Merge pull request #4106 from github/update-bundle/codeql-bundle-v2.26.4
  • ecfa6e1 Add changelog note
  • adcdf4a Update default bundle to codeql-bundle-v2.26.4
  • 486fec2 Merge pull request #4099 from github/update-supported-enterprise-server-versions
  • 134624c Merge pull request #4101 from github/dependabot/npm_and_yarn/npm-minor-457d82...
  • ff43db8 Merge pull request #4103 from github/mergeback/v4.37.8-to-main-db488dde
  • 4605e03 Rebuild
  • 099c869 Update changelog and version after v4.37.8
  • Additional commits viewable in compare view

Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore major version` will close this group update PR and stop Dependabot creating any more for the specific dependency's major version (unless you unignore this specific dependency's major version or upgrade to it yourself) - `@dependabot ignore minor version` will close this group update PR and stop Dependabot creating any more for the specific dependency's minor version (unless you unignore this specific dependency's minor version or upgrade to it yourself) - `@dependabot ignore ` will close this group update PR and stop Dependabot creating any more for the specific dependency (unless you unignore this specific dependency or upgrade to it yourself) - `@dependabot unignore ` will remove all of the ignore conditions of the specified dependency - `@dependabot unignore ` will remove the ignore condition of the specified dependency and ignore conditions
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Guglielmo Colombo --- .github/workflows/codeql.yml | 4 ++-- .github/workflows/scorecard.yml | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 0f63536..c4aa91c 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -38,11 +38,11 @@ jobs: - name: Checkout repository uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Initialize CodeQL - uses: github/codeql-action/init@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v4.37.3 + uses: github/codeql-action/init@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9 with: languages: ${{ matrix.language }} build-mode: ${{ matrix.build-mode }} - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v4.37.3 + uses: github/codeql-action/analyze@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9 with: category: "/language:${{matrix.language}}" diff --git a/.github/workflows/scorecard.yml b/.github/workflows/scorecard.yml index a29d098..4dee6b7 100644 --- a/.github/workflows/scorecard.yml +++ b/.github/workflows/scorecard.yml @@ -73,6 +73,6 @@ jobs: # Upload the results to GitHub's code scanning dashboard (optional). # Commenting out will disable upload of results to your repo's Code Scanning dashboard - name: "Upload to code-scanning" - uses: github/codeql-action/upload-sarif@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v4.37.3 + uses: github/codeql-action/upload-sarif@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9 with: sarif_file: results.sarif From 8227246472e3cd0bb58b2fcf3cccb6d88ad9f68b Mon Sep 17 00:00:00 2001 From: Yaroslav Date: Tue, 1 Sep 2026 14:43:23 +0200 Subject: [PATCH 46/70] fix: only subscribe when server advertises capability (#1221) --- mcp/client.go | 10 +++++-- mcp/mcp_test.go | 77 +++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 84 insertions(+), 3 deletions(-) diff --git a/mcp/client.go b/mcp/client.go index 7403799..c11f0c8 100644 --- a/mcp/client.go +++ b/mcp/client.go @@ -334,13 +334,17 @@ func (c *Client) Connect(ctx context.Context, t Transport, opts *ClientSessionOp subscribeParams := &SubscriptionsListenParams{ Notifications: &NotificationSubscriptions{}, } - if c.opts.ToolListChangedHandler != nil { + caps := discRes.Capabilities + if caps == nil { + caps = &ServerCapabilities{} + } + if c.opts.ToolListChangedHandler != nil && caps.Tools != nil && caps.Tools.ListChanged { subscribeParams.Notifications.ToolsListChanged = true } - if c.opts.PromptListChangedHandler != nil { + if c.opts.PromptListChangedHandler != nil && caps.Prompts != nil && caps.Prompts.ListChanged { subscribeParams.Notifications.PromptsListChanged = true } - if c.opts.ResourceListChangedHandler != nil { + if c.opts.ResourceListChangedHandler != nil && caps.Resources != nil && caps.Resources.ListChanged { subscribeParams.Notifications.ResourcesListChanged = true } if subscribeParams.Notifications.ToolsListChanged || diff --git a/mcp/mcp_test.go b/mcp/mcp_test.go index 699d32f..e1f8c0f 100644 --- a/mcp/mcp_test.go +++ b/mcp/mcp_test.go @@ -3298,6 +3298,83 @@ func TestSubscriptionsListen_DisconnectScrubsMaps(t *testing.T) { } } +// TestSubscriptionsListen_RespectsServerCapabilities verifies that during +// Connect the client only opens a SEP-2575 subscriptions/listen stream for the +// change notifications the server advertised during capability negotiation. +func TestSubscriptionsListen_RespectsServerCapabilities(t *testing.T) { + for _, tc := range []struct { + name string + caps *ServerCapabilities + wantListen bool + }{ + { + name: "advertised", + caps: &ServerCapabilities{Tools: &ToolCapabilities{ListChanged: true}}, + wantListen: true, + }, + { + name: "not advertised", + caps: &ServerCapabilities{Tools: &ToolCapabilities{}}, + wantListen: false, + }, + } { + t.Run(tc.name, func(t *testing.T) { + server := NewServer(testImpl, &ServerOptions{Capabilities: tc.caps}) + AddTool(server, &Tool{Name: "t1"}, sayHi) + + listenSeen := make(chan struct{}, 1) + server.AddReceivingMiddleware(func(next MethodHandler) MethodHandler { + return func(ctx context.Context, method string, req Request) (Result, error) { + if method == methodSubscriptionsListen { + select { + case listenSeen <- struct{}{}: + default: + } + } + return next(ctx, method, req) + } + }) + + ct, st := NewInMemoryTransports() + ss, err := server.Connect(context.Background(), st, nil) + if err != nil { + t.Fatalf("server connect: %v", err) + } + defer ss.Close() + + c := NewClient(testImpl, &ClientOptions{ + ToolListChangedHandler: func(context.Context, *ToolListChangedRequest) {}, + }) + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + cs, err := c.Connect(ctx, ct, &ClientSessionOptions{ProtocolVersion: protocolVersion20260728}) + if err != nil { + t.Fatalf("client connect: %v", err) + } + defer cs.Close() + + if _, err := cs.ListTools(ctx, nil); err != nil { + t.Fatalf("ListTools: %v", err) + } + + if tc.wantListen { + select { + case <-listenSeen: + case <-time.After(5 * time.Second): + t.Fatal("expected subscriptions/listen, but none was sent") + } + return + } + + select { + case <-listenSeen: + t.Fatal("client sent subscriptions/listen for an unadvertised capability") + case <-time.After(notificationDelay * 20): + } + }) + } +} + // TestServerSessionCloseWithActiveListen is a regression test for // modelcontextprotocol/go-sdk#1160: ServerSession.Close must not deadlock // when the client has an active subscriptions/listen stream. Previously, From a6764cf83885ab96bd59211c97e435b1b4df227e Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 1 Sep 2026 12:45:16 +0000 Subject: [PATCH 47/70] build(deps): bump golang/govulncheck-action from 1.0.4 to 1.1.0 (#1218) Bumps [golang/govulncheck-action](https://github.com/golang/govulncheck-action) from 1.0.4 to 1.1.0.
Release notes

Sourced from golang/govulncheck-action's releases.

v1.1.0

Full Changelog: https://github.com/golang/govulncheck-action/compare/v1.0.4...v1.1.0

Commits
  • 032d455 action.yml: use step-level env vars instead of direct vars in commands
  • 3fa7bd9 action.yml: unset GOOS/GOARCH when installing govulncheck
  • 31f7c54 action.yml: pin action dependencies to full commit SHAs
  • 5accf6f action.yml: update dependencies
  • 357ea4a action.yml: specify cache-dependency-path
  • 1ea7606 action.yml: update actions to fix caching issue
  • f03a9d4 README.md: fix typo
  • See full diff in compare view

[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=golang/govulncheck-action&package-manager=github_actions&previous-version=1.0.4&new-version=1.1.0)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself)
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/nightly.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index d60b6ff..2daa939 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -16,7 +16,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Run govulncheck - uses: golang/govulncheck-action@b625fbe08f3bccbe446d94fbf87fcc875a4f50ee # v1.0.4 + uses: golang/govulncheck-action@032d45514ae346b1db93c04b0c90b841c370344f # v1.1.0 with: go-version-file: go.mod go-package: ./... From 59185e69690c99f383e3150f7cc9808d10e16571 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 1 Sep 2026 12:49:57 +0000 Subject: [PATCH 48/70] build(deps): bump ossf/scorecard-action from 2.4.3 to 2.4.4 (#1217) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps [ossf/scorecard-action](https://github.com/ossf/scorecard-action) from 2.4.3 to 2.4.4.
Release notes

Sourced from ossf/scorecard-action's releases.

v2.4.4

What's Changed

This update bumps the Scorecard version to the v5.5.0 release. For a complete list of changes, please refer to the Scorecard v5.4.0 release notes and the Scorecard v5.5.0 release notes.

Full Changelog: https://github.com/ossf/scorecard-action/compare/v2.4.3...v2.4.4

Commits
  • 2d11466 Bump action tag for v2.4.4 release (#1688)
  • 1bd3285 :seedling: Bump the docker-images group across 1 directory with 2 updates (#1...
  • 913edce :seedling: Bump github.com/containerd/containerd from 1.7.32 to 1.7.33 (#1671)
  • 0957b8f :seedling: Bump golang.org/x/net from 0.56.0 to 0.57.0 (#1680)
  • f0061eb :seedling: Bump google.golang.org/grpc from 1.81.1 to 1.82.1 (#1687)
  • 20ee732 :seedling: Bump github.com/sigstore/cosign/v2 from 2.6.3 to 2.6.4 (#1685)
  • 9f295ef :seedling: Bump the github-actions group with 6 updates (#1686)
  • 69bf556 :seedling: Bump github.com/sigstore/sigstore-go from 1.1.4 to 1.2.0 (#1681)
  • 94e8b96 :seedling: Bump github.com/sigstore/rekor from 1.5.0 to 1.5.2 (#1673)
  • c7a1b37 :seedling: Bump github.com/sigstore/fulcio from 1.8.5 to 1.8.6 (#1675)
  • Additional commits viewable in compare view

[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=ossf/scorecard-action&package-manager=github_actions&previous-version=2.4.3&new-version=2.4.4)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself)
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/scorecard.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/scorecard.yml b/.github/workflows/scorecard.yml index 4dee6b7..98db4c4 100644 --- a/.github/workflows/scorecard.yml +++ b/.github/workflows/scorecard.yml @@ -39,7 +39,7 @@ jobs: persist-credentials: false - name: "Run analysis" - uses: ossf/scorecard-action@4eaacf0543bb3f2c246792bd56e8cdeffafb205a # v2.4.3 + uses: ossf/scorecard-action@2d1146689b8cda280b9bc96326124645441f03bc # v2.4.4 with: results_file: results.sarif results_format: sarif From 2fdabde1396adc95e195cbefc17ca1ec3a3f8bfd Mon Sep 17 00:00:00 2001 From: Guglielmo Colombo Date: Tue, 1 Sep 2026 15:30:20 +0200 Subject: [PATCH 49/70] mcp: do not check metatada on notifications (#1215) Fixes #1212 --- mcp/server.go | 2 +- mcp/shared.go | 8 +++ mcp/streamable.go | 5 +- mcp/streamable_test.go | 120 +++++++++++++++++++++++++++++++++++++++++ 4 files changed, 133 insertions(+), 2 deletions(-) diff --git a/mcp/server.go b/mcp/server.go index b935385..9e8a1b5 100644 --- a/mcp/server.go +++ b/mcp/server.go @@ -1962,7 +1962,7 @@ func (ss *ServerSession) handle(ctx context.Context, req *jsonrpc.Request) (any, } } default: - if !initialized && !validatedMeta.usesNewProtocol { + if !initialized && !validatedMeta.usesNewProtocol && req.IsCall() { 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) } diff --git a/mcp/shared.go b/mcp/shared.go index 5d0c1c2..2258752 100644 --- a/mcp/shared.go +++ b/mcp/shared.go @@ -560,7 +560,15 @@ type validatedMeta struct { // a non-nil error. clientInfo is optional; if present but invalid, an error is // returned. Otherwise, it returns usesNewProtocol set to true and the populated // initializeParams. +// Notifications always report usesNewProtocol false and never error. func validateRequestMeta(req *jsonrpc.Request) (*validatedMeta, error) { + // SEP-2575 defines the `_meta` triple for calls only: NotificationParams + // declares `_meta` optional with no protocolVersion, so a notification + // neither selects the new protocol nor is discarded for carrying an + // incomplete triple. + if !req.IsCall() { + return &validatedMeta{usesNewProtocol: false, initializeParams: nil}, nil + } meta := extractRequestMeta(req.Params) if meta == nil { return &validatedMeta{usesNewProtocol: false, initializeParams: nil}, nil diff --git a/mcp/streamable.go b/mcp/streamable.go index c8cd28b..8ce5e01 100644 --- a/mcp/streamable.go +++ b/mcp/streamable.go @@ -1530,12 +1530,15 @@ func (c *streamableServerConn) servePOST(w http.ResponseWriter, req *http.Reques // The new (>= 2026-07-28) protocol is supported on the HTTP transport // only when [StreamableHTTPOptions.Stateless] is true. // + // The `_meta` triple is defined for calls only, notifications are + // excluded. + // // TODO: this validation can be moved within validateMcpHeaders. var metaVersion string if meta := extractRequestMeta(jreq.Params); meta != nil { metaVersion, _ = meta[MetaKeyProtocolVersion].(string) } - if protocolVersion >= protocolVersion20260728 || metaVersion != "" { + if (jreq.IsCall() && protocolVersion >= protocolVersion20260728) || metaVersion != "" { // Extract again the protocol version from the context to see what the client // is advertising in the Mcp-Protocol-Version HTTP header. headerVersion := protocolVersionFromContext(req.Context()) diff --git a/mcp/streamable_test.go b/mcp/streamable_test.go index 58e5ca7..fed3522 100644 --- a/mcp/streamable_test.go +++ b/mcp/streamable_test.go @@ -3712,6 +3712,126 @@ func TestStreamableStateless_AcceptsNewProtocol(t *testing.T) { } } +// TestStreamableStateless_NotificationMetaValidation checks that the SEP-2575 +// per-request `_meta` triple is required of calls only. NotificationParams +// declares `_meta` optional with no protocolVersion, so a notification that +// omits it is well-formed and must be accepted with 202. +func TestStreamableStateless_NotificationMetaValidation(t *testing.T) { + newProtocolMeta := map[string]any{ + MetaKeyProtocolVersion: protocolVersion20260728, + MetaKeyClientInfo: map[string]any{"name": "new-proto-client", "version": "9.9"}, + MetaKeyClientCapabilities: map[string]any{}, + } + + tests := []struct { + name string + message map[string]any + wantStatus int + }{ + { + name: "cancelled without meta", + message: map[string]any{ + "jsonrpc": "2.0", + "method": notificationCancelled, + "params": map[string]any{"requestID": 1, "reason": "context canceled"}, + }, + wantStatus: http.StatusAccepted, + }, + { + name: "progress without meta", + message: map[string]any{ + "jsonrpc": "2.0", + "method": notificationProgress, + "params": map[string]any{"progressToken": "t", "progress": 1}, + }, + wantStatus: http.StatusAccepted, + }, + { + name: "notification with matching meta", + message: map[string]any{ + "jsonrpc": "2.0", + "method": notificationCancelled, + "params": map[string]any{ + "_meta": newProtocolMeta, + "requestID": 1, + }, + }, + wantStatus: http.StatusAccepted, + }, + { + // A notification that volunteers a version is still held to the + // header-match rule, so relaxing the requirement does not open a + // hole for inconsistent messages. + name: "notification with mismatched meta", + message: map[string]any{ + "jsonrpc": "2.0", + "method": notificationCancelled, + "params": map[string]any{ + "_meta": map[string]any{ + MetaKeyProtocolVersion: "2025-06-18", + MetaKeyClientCapabilities: map[string]any{}, + }, + "requestID": 1, + }, + }, + wantStatus: http.StatusBadRequest, + }, + { + // Calls remain subject to the requirement. + name: "call without meta", + message: map[string]any{ + "jsonrpc": "2.0", + "id": 1, + "method": "tools/list", + "params": map[string]any{}, + }, + wantStatus: http.StatusBadRequest, + }, + } + + 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() + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + body, err := json.Marshal(test.message) + 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, protocolVersion20260728) + req.Header.Set(methodHeader, test.message["method"].(string)) + + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + respBody, _ := io.ReadAll(resp.Body) + if resp.StatusCode != test.wantStatus { + t.Fatalf("status = %d, want %d; body = %s", resp.StatusCode, test.wantStatus, respBody) + } + if test.wantStatus == http.StatusAccepted && len(respBody) > 0 { + t.Errorf("accepted notification returned body %q, want empty", respBody) + } + }) + } +} + // TestStreamableClientUnsupportedVersionFallback exercises the full // SEP-2575 fallback. The client requests protocolVersion20260728, which the // server is configured to NOT advertise in supportedProtocolVersions for the From cb0de6413682bfbefb300a6c1e4d8046e2627b33 Mon Sep 17 00:00:00 2001 From: Yaroslav Date: Tue, 1 Sep 2026 17:28:35 +0200 Subject: [PATCH 50/70] mcp: add a max request body size of the old transport (#1224) added for consistency with the streamable transport because sse is not officially deprecated in the SDK Co-authored-by: Guglielmo Colombo --- mcp/sse.go | 32 +++++++++++++++++++++++++++++++- mcp/sse_test.go | 49 +++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 80 insertions(+), 1 deletion(-) diff --git a/mcp/sse.go b/mcp/sse.go index fb32756..81b0c0b 100644 --- a/mcp/sse.go +++ b/mcp/sse.go @@ -8,6 +8,7 @@ import ( "bytes" "context" "crypto/rand" + "errors" "fmt" "io" "mime" @@ -65,6 +66,12 @@ type SSEOptions struct { // Only disable this if you understand the security implications. // See: https://modelcontextprotocol.io/specification/2025-11-25/basic/security_best_practices#local-mcp-server-compromise DisableLocalhostProtection bool + + // MaxRequestBodyBytes limits the number of bytes read from an incoming + // POST body before the server responds with 413 Request Entity Too Large. + // It matches [StreamableHTTPOptions.MaxRequestBodyBytes]: if zero, + // [DefaultMaxRequestBodyBytes] is used; a negative value disables the limit. + MaxRequestBodyBytes int64 } // NewSSEHandler returns a new [SSEHandler] that creates and manages MCP @@ -122,6 +129,12 @@ type SSEServerTransport struct { // Response is the hanging response body to the incoming GET request. Response http.ResponseWriter + // MaxRequestBodyBytes limits the number of bytes read from a POSTed + // message body before [SSEServerTransport.ServeHTTP] responds with 413 + // Request Entity Too Large. If zero, [DefaultMaxRequestBodyBytes] is used; + // a negative value disables the limit. See [SSEOptions.MaxRequestBodyBytes]. + MaxRequestBodyBytes int64 + // incoming is the queue of incoming messages. // It is never closed, and by convention, incoming is non-nil if and only if // the transport is connected. @@ -143,9 +156,22 @@ func (t *SSEServerTransport) ServeHTTP(w http.ResponseWriter, req *http.Request) return } + limit := t.MaxRequestBodyBytes + if limit == 0 { + limit = DefaultMaxRequestBodyBytes + } + if limit > 0 && req.Body != nil { + req.Body = http.MaxBytesReader(w, req.Body, limit) + } + // Read and parse the message. data, err := io.ReadAll(req.Body) if err != nil { + var mbe *http.MaxBytesError + if errors.As(err, &mbe) { + http.Error(w, fmt.Sprintf("request body exceeds %d bytes", mbe.Limit), http.StatusRequestEntityTooLarge) + return + } http.Error(w, "failed to read body", http.StatusBadRequest) return } @@ -260,7 +286,11 @@ func (h *SSEHandler) ServeHTTP(w http.ResponseWriter, req *http.Request) { return } - transport := &SSEServerTransport{Endpoint: endpoint.RequestURI(), Response: w} + transport := &SSEServerTransport{ + Endpoint: endpoint.RequestURI(), + Response: w, + MaxRequestBodyBytes: h.opts.MaxRequestBodyBytes, + } // The session is terminated when the request exits. h.mu.Lock() diff --git a/mcp/sse_test.go b/mcp/sse_test.go index 9186a9c..de10eb3 100644 --- a/mcp/sse_test.go +++ b/mcp/sse_test.go @@ -383,3 +383,52 @@ func TestSSELocalhostProtection(t *testing.T) { }) } } + +// TestSSEServerTransportMaxRequestBodyBytes exercises the limit on the exported +// [SSEServerTransport] directly, since it is documented as usable on its own. +func TestSSEServerTransportMaxRequestBodyBytes(t *testing.T) { + requestOfSize := func(n int) []byte { + b := bytes.Repeat([]byte(" "), n) + b[0], b[n-1] = '{', '}' + return b + } + testCases := []struct { + name string + limit int64 + request []byte + wantCode int + }{ + { + name: "over limit returns", + limit: 1024, + request: requestOfSize(2048), + wantCode: http.StatusRequestEntityTooLarge, + }, + { + name: "under limit accepted", + limit: 1024, + request: []byte(`{"jsonrpc":"2.0","id":1,"method":"ping"}`), + wantCode: http.StatusAccepted, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + tr := &SSEServerTransport{ + Endpoint: "/msg", + Response: httptest.NewRecorder(), + MaxRequestBodyBytes: tc.limit, + } + if _, err := tr.Connect(context.Background()); err != nil { + t.Fatalf("Connect: %v", err) + } + req := httptest.NewRequest(http.MethodPost, "/msg", bytes.NewReader(tc.request)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + tr.ServeHTTP(rec, req) + if rec.Code != tc.wantCode { + t.Errorf("status = %d, want %d", rec.Code, tc.wantCode) + } + }) + } +} From 0d3036f785b6c122323a24e39399f9abd3641a7c Mon Sep 17 00:00:00 2001 From: Guglielmo Colombo Date: Tue, 1 Sep 2026 20:57:51 +0200 Subject: [PATCH 51/70] mcp: allow per request Cacheable customization (#1203) Fixes #1094 --- docs/server.md | 36 ++++++++++------- internal/docs/server.src.md | 36 ++++++++++------- mcp/protocol.go | 13 ++++-- mcp/server.go | 46 ++++++++++++++++----- mcp/server_test.go | 80 +++++++++++++++++++++++++++++++++++++ 5 files changed, 167 insertions(+), 44 deletions(-) diff --git a/docs/server.md b/docs/server.md index 966da75..d7bc314 100644 --- a/docs/server.md +++ b/docs/server.md @@ -975,27 +975,33 @@ results: clients use `ttlMs` as a freshness hint to reduce polling, and `cacheScope` (`"public"` or `"private"`) controls whether shared intermediaries may cache the response. -**Server-side defaults.** After paginating, the SDK sets `CacheScope = "public"` -and leaves `TTLMs = 0` (which the client cache treats as "immediately -stale"). A handler that wants its responses cached must set `TTLMs` -explicitly on the returned result. +**Server-side defaults.** The SDK fills in `CacheScope = "public"` whenever a +result leaves it empty, since the field is required on the wire. Everything +else is left as produced: a `ResourceHandler` that sets `TTLMs` or `CacheScope` +on its `ReadResourceResult` keeps those values. The list results and +`server/discover` are built by the SDK itself, so they start out with +`TTLMs = 0`, which a client cache treats as "immediately stale". + +**Setting a policy.** `ServerOptions.SetCacheable` decides the fields for every +result that carries them. It runs after the corresponding handler, receives +what that handler produced, and may keep, adjust, or replace it. The request is +passed in so that policy can vary by method, session, or authorization context. ```go -mcp.AddTool(server, &mcp.Tool{Name: "expensive"}, func(...) (*mcp.CallToolResult, any, error) { - // ... -}) -// Override the default for tools/list: -server.AddSendingMiddleware(func(next mcp.MethodHandler) mcp.MethodHandler { - return func(ctx context.Context, method string, req mcp.Request) (mcp.Result, error) { - res, err := next(ctx, method, req) - if lr, ok := res.(*mcp.ListToolsResult); ok { - lr.TTLMs = 30_000 // 30s cache freshness hint +server := mcp.NewServer(impl, &mcp.ServerOptions{ + SetCacheable: func(_ context.Context, req mcp.Request, c *mcp.Cacheable) { + // A 30s freshness hint, except where the handler chose its own. + if c.TTLMs == 0 { + c.TTLMs = 30_000 } - return res, err - } + }, }) ``` +`SetCacheable` may run with the server's lock held, so it must not call back +into the `Server`: adding or removing a feature, or ranging over +`Server.Sessions`, deadlocks. + ## Utilities ### Completion diff --git a/internal/docs/server.src.md b/internal/docs/server.src.md index 8ad704a..82b956e 100644 --- a/internal/docs/server.src.md +++ b/internal/docs/server.src.md @@ -374,27 +374,33 @@ results: clients use `ttlMs` as a freshness hint to reduce polling, and `cacheScope` (`"public"` or `"private"`) controls whether shared intermediaries may cache the response. -**Server-side defaults.** After paginating, the SDK sets `CacheScope = "public"` -and leaves `TTLMs = 0` (which the client cache treats as "immediately -stale"). A handler that wants its responses cached must set `TTLMs` -explicitly on the returned result. +**Server-side defaults.** The SDK fills in `CacheScope = "public"` whenever a +result leaves it empty, since the field is required on the wire. Everything +else is left as produced: a `ResourceHandler` that sets `TTLMs` or `CacheScope` +on its `ReadResourceResult` keeps those values. The list results and +`server/discover` are built by the SDK itself, so they start out with +`TTLMs = 0`, which a client cache treats as "immediately stale". + +**Setting a policy.** `ServerOptions.SetCacheable` decides the fields for every +result that carries them. It runs after the corresponding handler, receives +what that handler produced, and may keep, adjust, or replace it. The request is +passed in so that policy can vary by method, session, or authorization context. ```go -mcp.AddTool(server, &mcp.Tool{Name: "expensive"}, func(...) (*mcp.CallToolResult, any, error) { - // ... -}) -// Override the default for tools/list: -server.AddSendingMiddleware(func(next mcp.MethodHandler) mcp.MethodHandler { - return func(ctx context.Context, method string, req mcp.Request) (mcp.Result, error) { - res, err := next(ctx, method, req) - if lr, ok := res.(*mcp.ListToolsResult); ok { - lr.TTLMs = 30_000 // 30s cache freshness hint +server := mcp.NewServer(impl, &mcp.ServerOptions{ + SetCacheable: func(_ context.Context, req mcp.Request, c *mcp.Cacheable) { + // A 30s freshness hint, except where the handler chose its own. + if c.TTLMs == 0 { + c.TTLMs = 30_000 } - return res, err - } + }, }) ``` +`SetCacheable` may run with the server's lock held, so it must not call back +into the `Server`: adding or removing a feature, or ranging over +`Server.Sessions`, deadlocks. + ## Utilities ### Completion diff --git a/mcp/protocol.go b/mcp/protocol.go index 7a881d4..4867e54 100644 --- a/mcp/protocol.go +++ b/mcp/protocol.go @@ -1191,9 +1191,16 @@ func (c Cacheable) GetTTLMs() int { return c.TTLMs } // GetCacheScope returns the cache scope. func (c Cacheable) GetCacheScope() string { return c.CacheScope } -// setDefaultCacheableValues sets the default values for the cacheable fields. -func (c *Cacheable) setDefaultCacheableValues() { - c.CacheScope = "public" +// normalize fills in the protocol default for any cache field left +// unset. An absent cacheScope means "public", but the field is required on the +// wire, so the default is materialized here rather than sent empty. +// +// Values already present are preserved: this must not undo a decision made by +// a resource handler or by [ServerOptions.SetCacheable]. +func (c *Cacheable) normalize() { + if c.CacheScope == "" { + c.CacheScope = "public" + } } // The server's response to a prompts/list request from the client. diff --git a/mcp/server.go b/mcp/server.go index 9e8a1b5..5014a4f 100644 --- a/mcp/server.go +++ b/mcp/server.go @@ -174,6 +174,14 @@ type ServerOptions struct { // true, since stateless servers do not maintain sessions. GetSessionID func() string + // SetCacheable, if non-nil, decides the cache-control fields (ttlMs and + // cacheScope) of every result that carries them: server/discover, the + // four list methods, and resources/read. + // + // It is called once per such result, after the corresponding handler has + // returned, with c holding the values that handler produced. + SetCacheable func(ctx context.Context, req Request, c *Cacheable) + // SupportedProtocolVersions, if non-empty, restricts the protocol versions // this server advertises. If empty, every version returned by // [SupportedProtocolVersions] is used. @@ -870,7 +878,7 @@ func (s *Server) Sessions() iter.Seq[*ServerSession] { return slices.Values(clients) } -func (s *Server) listPrompts(_ context.Context, req *ListPromptsRequest) (*ListPromptsResult, error) { +func (s *Server) listPrompts(ctx context.Context, req *ListPromptsRequest) (*ListPromptsResult, error) { s.mu.Lock() defer s.mu.Unlock() if req.Params == nil { @@ -885,7 +893,7 @@ func (s *Server) listPrompts(_ context.Context, req *ListPromptsRequest) (*ListP if err != nil { return nil, err } - res.setDefaultCacheableValues() + s.resolveCacheable(ctx, req, &res.Cacheable) return res, nil } @@ -915,7 +923,7 @@ func (s *Server) getPrompt(ctx context.Context, req *GetPromptRequest) (*GetProm // 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) { +func (s *Server) discover(ctx context.Context, req *ServerRequest[*DiscoverParams]) (*DiscoverResult, error) { req.Session.mu.Lock() versions := req.Session.supportedVersions req.Session.mu.Unlock() @@ -947,7 +955,7 @@ func (s *Server) discover(_ context.Context, req *ServerRequest[*DiscoverParams] Capabilities: s.capabilities(), Instructions: s.opts.Instructions, } - res.setDefaultCacheableValues() + s.resolveCacheable(ctx, req, &res.Cacheable) return res, nil } @@ -968,7 +976,7 @@ func filterSupportedVersions(t Transport, versions []string) []string { return out } -func (s *Server) listTools(_ context.Context, req *ListToolsRequest) (*ListToolsResult, error) { +func (s *Server) listTools(ctx context.Context, req *ListToolsRequest) (*ListToolsResult, error) { s.mu.Lock() defer s.mu.Unlock() if req.Params == nil { @@ -983,7 +991,7 @@ func (s *Server) listTools(_ context.Context, req *ListToolsRequest) (*ListTools if err != nil { return nil, err } - res.setDefaultCacheableValues() + s.resolveCacheable(ctx, req, &res.Cacheable) return res, nil } @@ -1016,7 +1024,7 @@ func (s *Server) callTool(ctx context.Context, req *CallToolRequest) (*CallToolR return res, err } -func (s *Server) listResources(_ context.Context, req *ListResourcesRequest) (*ListResourcesResult, error) { +func (s *Server) listResources(ctx context.Context, req *ListResourcesRequest) (*ListResourcesResult, error) { s.mu.Lock() defer s.mu.Unlock() if req.Params == nil { @@ -1031,11 +1039,11 @@ func (s *Server) listResources(_ context.Context, req *ListResourcesRequest) (*L if err != nil { return nil, err } - res.setDefaultCacheableValues() + s.resolveCacheable(ctx, req, &res.Cacheable) return res, nil } -func (s *Server) listResourceTemplates(_ context.Context, req *ListResourceTemplatesRequest) (*ListResourceTemplatesResult, error) { +func (s *Server) listResourceTemplates(ctx context.Context, req *ListResourceTemplatesRequest) (*ListResourceTemplatesResult, error) { s.mu.Lock() defer s.mu.Unlock() if req.Params == nil { @@ -1051,7 +1059,7 @@ func (s *Server) listResourceTemplates(_ context.Context, req *ListResourceTempl if err != nil { return nil, err } - res.setDefaultCacheableValues() + s.resolveCacheable(ctx, req, &res.Cacheable) return res, nil } @@ -1075,7 +1083,7 @@ func (s *Server) readResource(ctx context.Context, req *ReadResourceRequest) (*R if err := handleMultiRoundTripResult(req.Session, s.opts.Logger, res); err != nil { return nil, err } - res.setDefaultCacheableValues() + s.resolveCacheable(ctx, req, &res.Cacheable) if res.resultType == resultTypeInputRequired { return res, nil } @@ -1094,6 +1102,22 @@ func (s *Server) readResource(ctx context.Context, req *ReadResourceRequest) (*R return res, nil } +// resolveCacheable settles the cache-control fields of an outgoing result. +// +// On entry c holds whatever the feature handler produced, which is the zero +// value for results the SDK builds itself. [ServerOptions.SetCacheable] may +// then rewrite it, and any cacheScope still unset is filled with the protocol +// default. +// +// The list methods call this with s.mu held, so SetCacheable must not call +// back into the server; see its documentation. +func (s *Server) resolveCacheable(ctx context.Context, req Request, c *Cacheable) { + if s.opts.SetCacheable != nil { + s.opts.SetCacheable(ctx, req, c) + } + c.normalize() +} + // lookupResourceHandler returns the resource handler and MIME type for the resource or // resource template matching uri. If none, the last return value is false. func (s *Server) lookupResourceHandler(uri string) (ResourceHandler, string, bool) { diff --git a/mcp/server_test.go b/mcp/server_test.go index 41f4951..11e75e2 100644 --- a/mcp/server_test.go +++ b/mcp/server_test.go @@ -1692,6 +1692,86 @@ func TestServerSession_RejectsServerInitiated(t *testing.T) { } } +func TestServerSetCacheable(t *testing.T) { + ctx := context.Background() + // The values an opinionated resource handler sets on its own result. + handlerValue := Cacheable{TTLMs: 9_000, CacheScope: "private"} + + for _, tc := range []struct { + name string + callback func(context.Context, Request, *Cacheable) + wantSDK Cacheable // for a result whose fields nobody set + wantHandler Cacheable // for a result the handler set fields on + }{ + { + name: "no callback", + wantSDK: Cacheable{TTLMs: 0, CacheScope: "public"}, + wantHandler: handlerValue, + }, + { + name: "callback overrules the handler", + callback: func(_ context.Context, _ Request, c *Cacheable) { + c.TTLMs, c.CacheScope = 60_000, "private" + }, + wantSDK: Cacheable{TTLMs: 60_000, CacheScope: "private"}, + wantHandler: Cacheable{TTLMs: 60_000, CacheScope: "private"}, + }, + { + // The callback is handed what the handler produced, so it can + // single out the results that expressed no opinion. The scope it + // leaves empty is still filled with the protocol default. + name: "callback defers to the handler", + callback: func(_ context.Context, _ Request, c *Cacheable) { + if c.CacheScope == "" { + c.TTLMs = 1_000 + } + }, + wantSDK: Cacheable{TTLMs: 1_000, CacheScope: "public"}, + wantHandler: handlerValue, + }, + } { + t.Run(tc.name, func(t *testing.T) { + s := NewServer(testImpl, &ServerOptions{SetCacheable: tc.callback}) + s.AddResource(&Resource{URI: "test://plain", Name: "plain"}, + func(_ context.Context, req *ReadResourceRequest) (*ReadResourceResult, error) { + return &ReadResourceResult{Contents: []*ResourceContents{{URI: req.Params.URI, Text: "x"}}}, nil + }) + s.AddResource(&Resource{URI: "test://opinionated", Name: "opinionated"}, + func(_ context.Context, req *ReadResourceRequest) (*ReadResourceResult, error) { + return &ReadResourceResult{ + Cacheable: handlerValue, + Contents: []*ResourceContents{{URI: req.Params.URI, Text: "x"}}, + }, nil + }) + cs := mustConnect(t, s, nil) + + check := func(label string, got, want Cacheable) { + t.Helper() + if got != want { + t.Errorf("%s Cacheable = %+v, want %+v", label, got, want) + } + } + + tools, err := cs.ListTools(ctx, nil) // built by the SDK, never by a handler + if err != nil { + t.Fatal(err) + } + check("tools/list", tools.Cacheable, tc.wantSDK) + + for uri, want := range map[string]Cacheable{ + "test://plain": tc.wantSDK, + "test://opinionated": tc.wantHandler, + } { + res, err := cs.ReadResource(ctx, &ReadResourceParams{URI: uri}) + if err != nil { + t.Fatal(err) + } + check(uri, res.Cacheable, want) + } + }) + } +} + // TestServerSupportedProtocolVersions verifies that // [ServerOptions.SupportedProtocolVersions] narrows both the versions the // server advertises in server/discover and the versions it negotiates, From 363296740467cb60316e45f2f9a9e958cd998356 Mon Sep 17 00:00:00 2001 From: Yaroslav Date: Wed, 2 Sep 2026 11:35:23 +0200 Subject: [PATCH 52/70] mcp: add an sse event size cap (#1205) update sse I/O code to limit the amount of memory a single non-terminated event can take --------- Co-authored-by: Guglielmo Colombo --- mcp/event.go | 58 +++++++++++++++--- mcp/event_test.go | 135 ++++++++++++++++++++++++++++++++++++++++- mcp/sse.go | 14 ++++- mcp/streamable.go | 12 +++- mcp/streamable_test.go | 67 +++++++++++++++++++- mcp/transport.go | 71 ++++++++++++++++++++-- mcp/transport_test.go | 84 +++++++++++++++++++++++++ 7 files changed, 423 insertions(+), 18 deletions(-) diff --git a/mcp/event.go b/mcp/event.go index 2b02a07..f0cbb2f 100644 --- a/mcp/event.go +++ b/mcp/event.go @@ -65,13 +65,21 @@ func writeEvent(w http.ResponseWriter, evt Event) (int, error) { return n, err } -// scanEvents iterates SSE events in the given scanner. The iterated error is -// terminal: if encountered, the stream is corrupt or broken and should no -// longer be used. +// DefaultMaxEventSize is the default maximum number of bytes buffered while +// reading a single server-sent event before the input is rejected. +const DefaultMaxEventSize = 16 << 20 // 16 MiB + +// scanEventsLimited iterates SSE events in the given reader, buffering at most +// maxEventSize bytes per event. When maxEventSize > 0, an event is reported as +// [errMalformedEvent] once its size exceeds it; a non-positive maxEventSize +// disables the cap. +// +// The iterated error is terminal: if encountered, the stream is corrupt or +// broken and should no longer be used. // // TODO(rfindley): consider a different API here that makes failure modes more // apparent. -func scanEvents(r io.Reader) iter.Seq2[Event, error] { +func scanEventsLimited(r io.Reader, maxEventSize int) iter.Seq2[Event, error] { reader := bufio.NewReader(r) // TODO: investigate proper behavior when events are out of order, or have @@ -94,8 +102,9 @@ func scanEvents(r io.Reader) iter.Seq2[Event, error] { // - Lines starting with ":" are ignored. // - Records are terminated with two consecutive newlines. var ( - evt Event - dataBuf *bytes.Buffer // if non-nil, preceding field was also data + evt Event + dataBuf *bytes.Buffer // if non-nil, preceding field was also data + eventBytes int // bytes read for the current, in-progress event ) yieldEvent := func() bool { if dataBuf != nil { @@ -112,11 +121,20 @@ func scanEvents(r io.Reader) iter.Seq2[Event, error] { return true } for { - line, err := reader.ReadBytes('\n') + budget := -1 + if maxEventSize > 0 { + budget = maxEventSize - eventBytes + } + line, err := readEventLine(reader, budget) + if errors.Is(err, errEventTooLarge) { + yield(Event{}, fmt.Errorf("%w: SSE event exceeded %d bytes without terminating", errMalformedEvent, maxEventSize)) + return + } if err != nil && !errors.Is(err, io.EOF) { yield(Event{}, fmt.Errorf("error reading event: %v", err)) return } + eventBytes += len(line) line = bytes.TrimRight(line, "\r\n") isEOF := errors.Is(err, io.EOF) @@ -124,6 +142,7 @@ func scanEvents(r io.Reader) iter.Seq2[Event, error] { if !yieldEvent() { return } + eventBytes = 0 // reset the budget between events if isEOF { return } @@ -322,6 +341,31 @@ var ErrEventsPurged = errors.New("data purged") // transient I/O errors which may be retryable. var errMalformedEvent = errors.New("malformed event") +// errEventTooLarge is returned by [readEventLine] when a single line would +// exceed the remaining per-event byte budget. +var errEventTooLarge = errors.New("SSE event exceeded maximum size") + +// readEventLine reads a single '\n'-terminated line from r. When budget >= 0 it +// reads at most budget bytes, returning [errEventTooLarge] once that budget is +// exceeded before a newline arrives. +func readEventLine(r *bufio.Reader, budget int) ([]byte, error) { + if budget < 0 { + return r.ReadBytes('\n') + } + var line []byte + for { + frag, err := r.ReadSlice('\n') + if len(line)+len(frag) > budget { + return nil, errEventTooLarge + } + line = append(line, frag...) + if errors.Is(err, bufio.ErrBufferFull) { + continue // looking for delim + } + return line, err + } +} + // After implements [EventStore.After]. func (s *MemoryEventStore) After(_ context.Context, sessionID, streamID string, index int) iter.Seq2[[]byte, error] { // Return the data items to yield. diff --git a/mcp/event_test.go b/mcp/event_test.go index fedc92c..d326415 100644 --- a/mcp/event_test.go +++ b/mcp/event_test.go @@ -5,9 +5,12 @@ package mcp import ( + "bytes" "context" "crypto/rand" + "errors" "fmt" + "io" "slices" "strings" "testing" @@ -134,7 +137,7 @@ func TestScanEvents(t *testing.T) { r := strings.NewReader(tt.input) var got []Event var err error - for e, err2 := range scanEvents(r) { + for e, err2 := range scanEventsLimited(r, DefaultMaxEventSize) { if err2 != nil { err = err2 break @@ -175,6 +178,136 @@ func TestScanEvents(t *testing.T) { } } +// endlessReader streams a fixed prefix once, then repeats fill forever. +type endlessReader struct { + prefix string + sent bool + repeat string + repIndex int +} + +func (r *endlessReader) Read(p []byte) (int, error) { + if !r.sent { + n := copy(p, r.prefix) + r.sent = true + return n, nil + } + for i := range p { + p[i] = r.repeat[r.repIndex%len(r.repeat)] + r.repIndex++ + } + return len(p), nil +} + +func (r *endlessReader) Close() error { return nil } + +// TestScanEventsMaxEventSize verifies that scanEvents bounds the bytes +// buffered for a single event. +func TestScanEventsMaxEventSize(t *testing.T) { + wantByte := byte('A') + eventOfSize := func(size int) []byte { + var buf bytes.Buffer + buf.WriteString("data: ") + buf.Write(bytes.Repeat([]byte{wantByte}, size)) + buf.WriteString("\n\n") + return buf.Bytes() + } + + tests := []struct { + name string + reader io.Reader + maxEventSize int + wantErr bool + wantDataLengths []int + }{ + { + name: "negative budget disables the cap", + reader: bytes.NewReader(eventOfSize(512)), + maxEventSize: -1, + wantDataLengths: []int{512}, + }, + { + name: "long line under cap", // bufio.Reader default buf size is 1 << 12 + reader: bytes.NewReader(eventOfSize(1 << 14)), + maxEventSize: 1 << 15, + wantDataLengths: []int{1 << 14}, + }, + { + name: "unbounded rejected", + reader: &endlessReader{prefix: "data: ", repeat: string(wantByte)}, + maxEventSize: 1024, + wantErr: true, + }, + + { + name: "consecutive data fields longer than limit", + reader: strings.NewReader(func() string { + var b strings.Builder + for range 2048 { + b.WriteString("data: \n") + } + return b.String() + }()), + maxEventSize: 1024, + wantErr: true, + }, + { + name: "budget resets between events", + reader: bytes.NewReader(append(eventOfSize(3*1024), eventOfSize(3*1024)...)), + maxEventSize: 4 * 1024, + wantDataLengths: []int{3 * 1024, 3 * 1024}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + type result struct { + events []Event + err error + } + + done := make(chan result, 1) + go func() { + var events []Event + for e, err := range scanEventsLimited(tt.reader, tt.maxEventSize) { + if err != nil { + done <- result{events, err} + return + } + events = append(events, e) + } + done <- result{events, nil} + }() + + var res result + select { + case res = <-done: + case <-time.After(5 * time.Second): + t.Fatal("scanEvents did not return") + } + + if tt.wantErr { + if !errors.Is(res.err, errMalformedEvent) { + t.Fatalf("got error %v, want errMalformedEvent", res.err) + } + return + } + if res.err != nil { + t.Fatalf("unexpected error: %v", res.err) + } + if len(res.events) != len(tt.wantDataLengths) { + t.Fatalf("got %d events, want %d", len(res.events), len(tt.wantDataLengths)) + } + for i, wantLen := range tt.wantDataLengths { + wantRepeated := string(wantByte) + if got := res.events[i].Data; string(got) != strings.Repeat(wantRepeated, wantLen) { + t.Errorf("event %d: got %d data bytes, want %d bytes of %s", i, len(got), wantLen, wantRepeated) + } + } + }) + } +} + func TestMemoryEventStoreState(t *testing.T) { ctx := context.Background() diff --git a/mcp/sse.go b/mcp/sse.go index 81b0c0b..c7926b9 100644 --- a/mcp/sse.go +++ b/mcp/sse.go @@ -397,6 +397,11 @@ type SSEClientTransport struct { // HTTPClient is the client to use for making HTTP requests. If nil, // http.DefaultClient is used. HTTPClient *http.Client + + // MaxEventSize bounds the number of bytes buffered while reading a single + // server-sent event. A value of 0 selects [DefaultMaxEventSize], a negative + // value disables the cap. + MaxEventSize int } // Connect connects through the client endpoint. @@ -427,9 +432,14 @@ func (c *SSEClientTransport) Connect(ctx context.Context) (Connection, error) { return nil, fmt.Errorf("failed to connect: %s", http.StatusText(resp.StatusCode)) } + maxEventSize := c.MaxEventSize + if maxEventSize == 0 { + maxEventSize = DefaultMaxEventSize + } + msgEndpoint, err := func() (*url.URL, error) { var evt Event - for evt, err = range scanEvents(resp.Body) { + for evt, err = range scanEventsLimited(resp.Body, maxEventSize) { break } if err != nil { @@ -458,7 +468,7 @@ func (c *SSEClientTransport) Connect(ctx context.Context) (Connection, error) { go func() { defer s.Close() // close the transport when the GET exits - for evt, err := range scanEvents(resp.Body) { + for evt, err := range scanEventsLimited(resp.Body, maxEventSize) { if err != nil { return } diff --git a/mcp/streamable.go b/mcp/streamable.go index 8ce5e01..d1462d9 100644 --- a/mcp/streamable.go +++ b/mcp/streamable.go @@ -2018,6 +2018,11 @@ type StreamableClientTransport struct { // OAuthHandler is an optional field that, if provided, will be used to authorize the requests. OAuthHandler auth.OAuthHandler + // MaxEventSize bounds the number of bytes buffered while reading a single + // server-sent event. A value of 0 selects [DefaultMaxEventSize], a negative + // value disables the cap. + MaxEventSize int + // TODO(rfindley): propose exporting these. // If strict is set, the transport is in 'strict mode', where any violation // of the MCP spec causes a failure. @@ -2105,6 +2110,7 @@ func (t *StreamableClientTransport) Connect(ctx context.Context) (Connection, er failed: make(chan struct{}), disableStandaloneSSE: t.DisableStandaloneSSE, oauthHandler: t.OAuthHandler, + maxEventSize: t.MaxEventSize, } return conn, nil } @@ -2126,6 +2132,10 @@ type streamableClientConn struct { // oauthHandler is the OAuth handler for the connection. oauthHandler auth.OAuthHandler // from [StreamableClientTransport.OAuthHandler] + // maxEventSize bounds the number of bytes buffered while reading a single + // server-sent event. Resolved from [StreamableClientTransport.MaxEventSize]. + maxEventSize int + // Guard calls to Close, as it may be called multiple times. closeOnce sync.Once closeErr error @@ -2635,7 +2645,7 @@ func (c *streamableClientConn) processStream(ctx context.Context, requestSummary io.Copy(io.Discard, resp.Body) resp.Body.Close() }() - for evt, err := range scanEvents(resp.Body) { + for evt, err := range scanEventsLimited(resp.Body, c.maxEventSize) { if err != nil { if ctx.Err() != nil { return "", 0, true // don't reconnect: client cancelled diff --git a/mcp/streamable_test.go b/mcp/streamable_test.go index fed3522..ca4d0a7 100644 --- a/mcp/streamable_test.go +++ b/mcp/streamable_test.go @@ -1544,7 +1544,7 @@ func (s streamableRequest) do(ctx context.Context, serverURL, sessionID string, var respBody []byte if contentType == "text/event-stream" { r := readerInto{resp.Body, new(bytes.Buffer)} - for evt, err := range scanEvents(r) { + for evt, err := range scanEventsLimited(r, DefaultMaxEventSize) { if err != nil { return newSessionID, resp.StatusCode, nil, fmt.Errorf("reading events: %v", err) } @@ -2856,6 +2856,69 @@ data: {"jsonrpc":"2.0","id":1,"result":{}} } } +// TestProcessStreamMaxEventSize verifies that a streamableClientConn honors its +// configured maxEventSize. +func TestProcessStreamMaxEventSize(t *testing.T) { + jsonrpcEventOfSize := func(padSize int) string { + msg := fmt.Sprintf(`{"jsonrpc":"2.0","id":1,"result":{"pad":%q}}`, strings.Repeat("A", padSize)) + return "data: " + msg + "\n\n" + } + + tests := []struct { + name string + maxEventSize int + event string + wantFail bool + }{ + { + name: "event over cap is rejected", + event: jsonrpcEventOfSize(4096), + maxEventSize: 1024, + wantFail: true, + }, + { + name: "event under cap is accepted", + event: jsonrpcEventOfSize(1024), + maxEventSize: 4096, + wantFail: false, + }, + { + name: "negative cap disables the limit", + event: jsonrpcEventOfSize(4096), + maxEventSize: -1, + wantFail: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctx := t.Context() + resp := &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"text/event-stream"}}, + Body: io.NopCloser(strings.NewReader(tt.event)), + } + conn := &streamableClientConn{ + ctx: ctx, + done: make(chan struct{}), + incoming: make(chan jsonrpc.Message, 10), + failed: make(chan struct{}), + logger: ensureLogger(nil), + maxEventSize: tt.maxEventSize, + } + conn.processStream(ctx, "test", resp, nil) + + err := conn.failure() + if tt.wantFail && err == nil { + t.Fatal("failure() = nil, want a non-nil error for an oversized event") + } + if !tt.wantFail && err != nil { + t.Fatalf("failure() = %v, want nil", err) + } + }) + } +} + // TestScanEventsPingFiltering is a unit test for the low-level event scanning // with ping events to verify scanEvents properly parses all event types. func TestScanEventsPingFiltering(t *testing.T) { @@ -2878,7 +2941,7 @@ data: {"jsonrpc":"2.0","method":"test2","params":{}} var events []Event // Scan all events - for evt, err := range scanEvents(reader) { + for evt, err := range scanEventsLimited(reader, DefaultMaxEventSize) { if err != nil { if err != io.EOF { t.Fatalf("scanEvents error: %v", err) diff --git a/mcp/transport.go b/mcp/transport.go index 1cf0325..8b6cd14 100644 --- a/mcp/transport.go +++ b/mcp/transport.go @@ -119,13 +119,22 @@ type serverConnection interface { sessionUpdated(ServerSessionState) } +// DefaultMaxLineLength is the default maximum number of bytes buffered while +// decoding a single inbound JSON-RPC frame. +const DefaultMaxLineLength = 16 * 1024 * 1024 + // A StdioTransport is a [Transport] that communicates over stdin/stdout using // newline-delimited JSON. -type StdioTransport struct{} +type StdioTransport struct { + // MaxLineLength bounds the number of bytes that may be buffered while + // decoding a single inbound JSON-RPC frame. A value of 0 selects [DefaultMaxLineLength], + // a negative value disables the cap. + MaxLineLength int +} // Connect implements the [Transport] interface. -func (*StdioTransport) Connect(context.Context) (Connection, error) { - return newIOConn(rwc{os.Stdin, nopCloserWriter{os.Stdout}}), nil +func (t *StdioTransport) Connect(context.Context) (Connection, error) { + return newIOConnLimited(rwc{os.Stdin, nopCloserWriter{os.Stdout}}, t.MaxLineLength), nil } // nopCloserWriter is an io.WriteCloser with a trivial Close method. @@ -140,11 +149,15 @@ func (nopCloserWriter) Close() error { return nil } type IOTransport struct { Reader io.ReadCloser Writer io.WriteCloser + // MaxLineLength bounds the number of bytes that may be buffered while + // decoding a single inbound JSON-RPC frame. A value of 0 selects [DefaultMaxLineLength], + // a negative value disables the cap. + MaxLineLength int } // Connect implements the [Transport] interface. func (t *IOTransport) Connect(context.Context) (Connection, error) { - return newIOConn(rwc{t.Reader, t.Writer}), nil + return newIOConnLimited(rwc{t.Reader, t.Writer}, t.MaxLineLength), nil } // An InMemoryTransport is a [Transport] that communicates over an in-memory @@ -478,6 +491,17 @@ type msgOrErr struct { } func newIOConn(rwc io.ReadWriteCloser) *ioConn { + return newIOConnLimited(rwc, DefaultMaxLineLength) +} + +// newIOConnLimited builds an [ioConn] over rwc that bounds the number of bytes +// buffered while decoding a single inbound JSON-RPC frame to maxLineLength. +// maxLineLength == 0 selects [DefaultMaxLineLength], a negative value means no cap. +func newIOConnLimited(rwc io.ReadWriteCloser, maxLineLength int) *ioConn { + limit := maxLineLength + if limit == 0 { + limit = DefaultMaxLineLength + } var ( incoming = make(chan msgOrErr) closed = make(chan struct{}) @@ -489,7 +513,15 @@ func newIOConn(rwc io.ReadWriteCloser) *ioConn { // but that is unavoidable since AFAIK there is no (easy and portable) way to // guarantee that reads of stdin are unblocked when closed. go func() { - dec := json.NewDecoder(rwc) + var ( + reader io.Reader = rwc + limiter *frameLimitReader + ) + if limit > 0 { + limiter = &frameLimitReader{r: rwc, limit: limit} + reader = limiter + } + dec := json.NewDecoder(reader) for { var raw json.RawMessage err := dec.Decode(&raw) @@ -515,6 +547,9 @@ func newIOConn(rwc io.ReadWriteCloser) *ioConn { if err != nil { return } + if limiter != nil { + limiter.resetFrame() + } } }() return &ioConn{ @@ -524,6 +559,32 @@ func newIOConn(rwc io.ReadWriteCloser) *ioConn { } } +// errFrameTooLarge means that a single inbound JSON-RPC frame exceeded the configured byte +// limit before the value was completely received. +var errFrameTooLarge = errors.New("inbound JSON-RPC frame exceeded the configured maximum line length") + +// frameLimitReader bounds the number of bytes [json.Decoder] may buffer while +// decoding a single JSON value. Read returns [errFrameTooLarge] once the budget is exhausted. +type frameLimitReader struct { + r io.Reader + limit int + count int +} + +func (r *frameLimitReader) Read(p []byte) (int, error) { + if r.count >= r.limit { + return 0, errFrameTooLarge + } + if len(p) > r.limit-r.count { + p = p[:r.limit-r.count] + } + n, err := r.r.Read(p) + r.count += n + return n, err +} + +func (r *frameLimitReader) resetFrame() { r.count = 0 } + func (c *ioConn) SessionID() string { return "" } func (c *ioConn) sessionUpdated(state ServerSessionState) { diff --git a/mcp/transport_test.go b/mcp/transport_test.go index 52228d0..d4d7782 100644 --- a/mcp/transport_test.go +++ b/mcp/transport_test.go @@ -6,9 +6,12 @@ package mcp import ( "context" + "errors" + "fmt" "io" "strings" "testing" + "time" "github.com/modelcontextprotocol/go-sdk/internal/jsonrpc2" "github.com/modelcontextprotocol/go-sdk/jsonrpc" @@ -167,3 +170,84 @@ func TestIOConnRead_EmptyMethod(t *testing.T) { t.Errorf("ID = %v, want 5", req.ID.Raw()) } } + +// TestIOConnFrameCap verifies single inbound frame cap enforcement. +func TestIOConnFrameCap(t *testing.T) { + tests := []struct { + name string + r io.ReadCloser + limit int + wantMessageCount int + wantErr bool + }{ + { + name: "infinite string", + r: &endlessReader{prefix: `"`, repeat: "A"}, + limit: 1024, + wantErr: true, + }, + { + name: "cap does not reset per new line", + r: &endlessReader{prefix: "[", repeat: "0,\n"}, + limit: 1024, + wantErr: true, + }, + { + name: "cap resets per message", + limit: 1024, // < 600 * 3 + r: io.NopCloser(strings.NewReader(func() string { + var b strings.Builder + for i := range 3 { + fmt.Fprintf(&b, `{"jsonrpc":"2.0","id":%d,"method":"test","params":{"pad":"`, i) + b.WriteString(strings.Repeat("A", 600)) + b.WriteString(`"}}`) + b.WriteByte('\n') + } + return b.String() + }())), + wantMessageCount: 3, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tr := newIOConnLimited(rwc{rc: tt.r}, tt.limit) + t.Cleanup(func() { tr.Close() }) + + read := func() (jsonrpc.Message, error) { + type result struct { + msg jsonrpc.Message + err error + } + done := make(chan result, 1) + + go func() { + msg, err := tr.Read(context.Background()) + done <- result{msg, err} + }() + + select { + case r := <-done: + return r.msg, r.err + case <-time.After(10 * time.Second): + t.Fatal("Read() did not return: frame cap failed to trip") + return nil, nil + } + } + + for i := range tt.wantMessageCount { + msg, err := read() + if err != nil { + t.Fatalf("Read() #%d error = %v, want nil", i, err) + } + if msg == nil { + t.Fatalf("Read() #%d returned nil message", i) + } + } + if tt.wantErr { + if _, err := read(); !errors.Is(err, errFrameTooLarge) { + t.Fatalf("Read() error = %v, want errFrameTooLarge", err) + } + } + }) + } +} From 12cbafe54df4cd9325d84f7a846100acddfdcc87 Mon Sep 17 00:00:00 2001 From: Yaroslav Date: Wed, 2 Sep 2026 21:07:19 +0200 Subject: [PATCH 53/70] oauthex: oauth discovery checks (#1220) add extra checks for the default oauth discovery client: * disallow redirects which downgrade to http * disallow redirects to localhost or private ip * disallow the initial call to a private ip * proxy configuration opts out of dial time checking --- docs/protocol.md | 26 +++--- internal/docs/protocol.src.md | 26 +++--- internal/util/net.go | 23 +++++ internal/util/net_test.go | 54 +++++++++++- oauthex/discovery_test.go | 158 ++++++++++++++++++++++++++++++++++ oauthex/oauth2.go | 120 +++++++++++++++++++++++++- 6 files changed, 376 insertions(+), 31 deletions(-) create mode 100644 oauthex/discovery_test.go diff --git a/docs/protocol.md b/docs/protocol.md index f60371b..06c1b77 100644 --- a/docs/protocol.md +++ b/docs/protocol.md @@ -639,24 +639,24 @@ provided to ### Server-Side Request Forgery -The [mitigations](https://modelcontextprotocol.io/docs/tutorials/security/security_best_practices#mitigation-3) are as follows: +The OAuth discovery helpers apply these [mitigations](https://modelcontextprotocol.io/docs/tutorials/security/security_best_practices#mitigation-3) by default: -- _Enforce HTTPS_. The OAuth helpers provided by the SDK reject the `http://` URLs -except loopback addresses (`localhost`, `127.0.0.1`, `::1`). +- _Enforce HTTPS_. Reject `http://` URLs (except loopback: `localhost`, `127.0.0.1`, `::1`) +and redirects that downgrade `https` to a non-`https` scheme. -- _Block Private IP Ranges_. The OAuth helpers provided by the SDK allow passing -a custom `http.Client`. Developers are advised to customize the client it with -appropriate network protections, including IP range blocking. The SDK does not provide -this capability out of the box. +- _Block Private IP Ranges_. Reject targets that resolve to a non-public address +(private, link-local including the `169.254.169.254` metadata endpoint, CGNAT, multicast, +unspecified). The check runs on the initial URL and at dial time, guarding against DNS rebinding. -- _Validate Redirect Targets_. Similarly to previous point, customized `http.Client` -can be used to validate network hops. The SDK does not provide this capability out -of the box. +- _Validate Redirect Targets_. Reject redirects to non-public addresses and cap the redirect count. -- _Use Egress Proxies_. This is out of scope for the SDK and can be configured separately. +- _Use Egress Proxies_. Out of scope for the SDK; configure separately. -- _DNS Resolution Considerations_. The SDK has DNS rebinding protection on the server side which is enabled by default. For the client side, consider providing -a custom `http.Client` that would implement DNS pinning. +**Opting out.** Passing a custom `http.Client` can bypass these checks, making SSRF +protection your responsibility. Specifically: a custom `Transport.DialContext`/`DialTLSContext`, +a non-`*http.Transport`, or a configured `Proxy` disables the dial-time IP check; setting +`CheckRedirect` replaces all redirect validation. In these cases, implement your own IP +blocking, redirect validation, and DNS pinning. ### Session Hijacking diff --git a/internal/docs/protocol.src.md b/internal/docs/protocol.src.md index dbc8c6e..7d6b01b 100644 --- a/internal/docs/protocol.src.md +++ b/internal/docs/protocol.src.md @@ -534,24 +534,24 @@ provided to ### Server-Side Request Forgery -The [mitigations](https://modelcontextprotocol.io/docs/tutorials/security/security_best_practices#mitigation-3) are as follows: +The OAuth discovery helpers apply these [mitigations](https://modelcontextprotocol.io/docs/tutorials/security/security_best_practices#mitigation-3) by default: -- _Enforce HTTPS_. The OAuth helpers provided by the SDK reject the `http://` URLs -except loopback addresses (`localhost`, `127.0.0.1`, `::1`). +- _Enforce HTTPS_. Reject `http://` URLs (except loopback: `localhost`, `127.0.0.1`, `::1`) +and redirects that downgrade `https` to a non-`https` scheme. -- _Block Private IP Ranges_. The OAuth helpers provided by the SDK allow passing -a custom `http.Client`. Developers are advised to customize the client it with -appropriate network protections, including IP range blocking. The SDK does not provide -this capability out of the box. +- _Block Private IP Ranges_. Reject targets that resolve to a non-public address +(private, link-local including the `169.254.169.254` metadata endpoint, CGNAT, multicast, +unspecified). The check runs on the initial URL and at dial time, guarding against DNS rebinding. -- _Validate Redirect Targets_. Similarly to previous point, customized `http.Client` -can be used to validate network hops. The SDK does not provide this capability out -of the box. +- _Validate Redirect Targets_. Reject redirects to non-public addresses and cap the redirect count. -- _Use Egress Proxies_. This is out of scope for the SDK and can be configured separately. +- _Use Egress Proxies_. Out of scope for the SDK; configure separately. -- _DNS Resolution Considerations_. The SDK has DNS rebinding protection on the server side which is enabled by default. For the client side, consider providing -a custom `http.Client` that would implement DNS pinning. +**Opting out.** Passing a custom `http.Client` can bypass these checks, making SSRF +protection your responsibility. Specifically: a custom `Transport.DialContext`/`DialTLSContext`, +a non-`*http.Transport`, or a configured `Proxy` disables the dial-time IP check; setting +`CheckRedirect` replaces all redirect validation. In these cases, implement your own IP +blocking, redirect validation, and DNS pinning. ### Session Hijacking diff --git a/internal/util/net.go b/internal/util/net.go index 6858614..593012d 100644 --- a/internal/util/net.go +++ b/internal/util/net.go @@ -24,3 +24,26 @@ func IsLoopback(addr string) bool { } return ip.IsLoopback() } + +// cgnat is the RFC 6598 carrier-grade NAT range (100.64.0.0/10). netip's +// IsPrivate does not report it, but it is shared ISP space that is not publicly +// routable and could point at another tenant behind the same NAT. +var cgnat = netip.MustParsePrefix("100.64.0.0/10") + +// IsPrivateOrReserved reports whether ip belongs to a range that must not be +// reachable through a server-controlled URL, as a defense against SSRF: private +// (RFC 1918 / RFC 4193 ULA), link-local (including the 169.254.169.254 cloud +// metadata endpoint), carrier-grade NAT, multicast, and the unspecified address. +func IsPrivateOrReserved(ip netip.Addr) bool { + ip = ip.Unmap() + if !ip.IsValid() { + return true + } + if ip.IsPrivate() || + ip.IsLinkLocalUnicast() || + ip.IsMulticast() || + ip.IsUnspecified() { + return true + } + return cgnat.Contains(ip) +} diff --git a/internal/util/net_test.go b/internal/util/net_test.go index fd3187b..21b7179 100644 --- a/internal/util/net_test.go +++ b/internal/util/net_test.go @@ -3,7 +3,10 @@ // that can be found in the LICENSE file. package util -import "testing" +import ( + "net/netip" + "testing" +) // TestIsLoopback tests the IsLoopback helper function. func TestIsLoopback(t *testing.T) { @@ -33,3 +36,52 @@ func TestIsLoopback(t *testing.T) { }) } } + +func TestIsPrivateOrReserved(t *testing.T) { + tests := []struct { + ip string + want bool + }{ + // Private (RFC 1918) and IPv6 ULA (RFC 4193). + {"10.0.0.1", true}, + {"172.16.5.4", true}, + {"192.168.1.1", true}, + {"fd00::1", true}, + // Link-local, including the cloud metadata endpoint. + {"169.254.169.254", true}, + {"169.254.0.1", true}, + {"fe80::1", true}, + // Carrier-grade NAT (RFC 6598). + {"100.64.0.1", true}, + {"100.127.255.255", true}, + // Multicast and unspecified. + {"224.0.0.1", true}, + {"ff02::1", true}, + {"0.0.0.0", true}, + {"::", true}, + // IPv4-mapped IPv6 of a private address must also be caught. + {"::ffff:10.0.0.1", true}, + {"::ffff:192.168.0.1", true}, + // Loopback is intentionally NOT reserved here (policy handled elsewhere). + {"127.0.0.1", false}, + {"::1", false}, + // Public addresses are allowed. + {"8.8.8.8", false}, + {"1.1.1.1", false}, + {"2606:4700:4700::1111", false}, + // Just outside CGNAT. + {"100.63.255.255", false}, + {"100.128.0.0", false}, + } + for _, tt := range tests { + t.Run(tt.ip, func(t *testing.T) { + ip, err := netip.ParseAddr(tt.ip) + if err != nil { + t.Fatalf("ParseAddr(%q): %v", tt.ip, err) + } + if got := IsPrivateOrReserved(ip); got != tt.want { + t.Errorf("IsPrivateOrReserved(%q) = %v, want %v", tt.ip, got, tt.want) + } + }) + } +} diff --git a/oauthex/discovery_test.go b/oauthex/discovery_test.go new file mode 100644 index 0000000..8b64d0c --- /dev/null +++ b/oauthex/discovery_test.go @@ -0,0 +1,158 @@ +// Copyright 2025 The Go MCP SDK Authors. All rights reserved. +// Use of this source code is governed by an MIT-style +// license that can be found in the LICENSE file. + +package oauthex + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestCheckHTTPSOrLoopback(t *testing.T) { + tests := []struct { + name string + addr string + wantErr bool + }{ + {name: "empty", addr: "", wantErr: false}, + {name: "https dns allowed", addr: "https://example.com/prm", wantErr: false}, + {name: "cleartext dns disallowed", addr: "http://example.com/prm", wantErr: true}, + {name: "https loopback ip allowed", addr: "http://127.0.0.1:8080/prm", wantErr: false}, + {name: "http localhost allowed", addr: "http://localhost/prm", wantErr: false}, + {name: "https private ip disallowed", addr: "https://10.0.0.1/prm", wantErr: true}, + {name: "link local metadata disallowed", addr: "https://169.254.169.254/", wantErr: true}, + {name: "mapped private address disallowed", addr: "https://[::ffff:10.0.0.1]/prm", wantErr: true}, + {name: "https public ip allowed", addr: "https://8.8.8.8/prm", wantErr: false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := checkHTTPSOrLoopback(tt.addr) + if (err != nil) != tt.wantErr { + t.Errorf("checkHTTPSOrLoopback(%q) error = %v, wantErr %v", tt.addr, err, tt.wantErr) + } + }) + } +} + +func TestDiscoveryClientRedirect(t *testing.T) { + newRequestTo := func(u string) *http.Request { + t.Helper() + r, err := http.NewRequest(http.MethodGet, u, nil) + if err != nil { + t.Fatalf("NewRequest(%q): %v", u, err) + } + return r + } + testCases := []struct { + name string + custom *http.Client + req *http.Request + via []*http.Request + wantBlocked bool + }{ + { + name: "redirect to public allowed", + req: newRequestTo("https://8.8.8.8/"), + via: []*http.Request{newRequestTo("https://93.184.216.34/")}, + wantBlocked: false, + }, + { + name: "downgrade blocked", + req: newRequestTo("http://93.184.216.34/"), + via: []*http.Request{newRequestTo("https://93.184.216.34/")}, + wantBlocked: true, + }, + { + name: "redirect to loopback blocked", + req: newRequestTo("https://127.0.0.1/"), + via: []*http.Request{newRequestTo("https://93.184.216.34/")}, + wantBlocked: true, + }, + { + name: "redirect to hostname resolving to loopback blocked", + req: newRequestTo("https://localhost/"), + via: []*http.Request{newRequestTo("https://93.184.216.34/")}, + wantBlocked: true, + }, + { + name: "custom client with no custom check", + custom: &http.Client{}, + req: newRequestTo("https://127.0.0.1/"), + via: []*http.Request{newRequestTo("https://93.184.216.34/")}, + wantBlocked: true, + }, + { + name: "custom client check disable", + custom: &http.Client{ + CheckRedirect: func(req *http.Request, via []*http.Request) error { + return nil + }, + }, + req: newRequestTo("https://127.0.0.1/"), + via: []*http.Request{newRequestTo("https://93.184.216.34/")}, + wantBlocked: false, + }, + { + name: "too many redirects", + req: newRequestTo("https://8.8.8.8/"), + via: func() []*http.Request { + via := make([]*http.Request, maxDiscoveryRedirects) + for i := range via { + via[i] = newRequestTo("https://8.8.8.8/") + } + return via + }(), + wantBlocked: true, + }, + } + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + client := newDiscoveryClient(tc.custom) + gotErr := client.CheckRedirect(tc.req, tc.via) + if gotErr != nil && !tc.wantBlocked { + t.Fatalf("client.CheckRedirect() error = %v", gotErr) + } + if gotErr == nil && tc.wantBlocked { + t.Fatal("client.CheckRedirect() error = nil, want blocked") + } + }) + } +} + +func TestDiscoveryBlocksInternalTarget(t *testing.T) { + ctx := t.Context() + for _, target := range []string{ + "https://169.254.169.254/latest/meta-data/", + "https://10.0.0.1/.well-known/oauth-protected-resource", + } { + t.Run(target, func(t *testing.T) { + _, err := GetProtectedResourceMetadata(ctx, target, target, nil) + if err == nil { + t.Fatalf("GetProtectedResourceMetadata(%q) succeeded, want SSRF rejection", target) + } + if !strings.Contains(err.Error(), "non-public") { + t.Errorf("error = %v, want it to mention a non-public address", err) + } + }) + } +} + +func TestDiscoveryAllowsLoopbackInitial(t *testing.T) { + ctx := t.Context() + h := &fakeResourceHandler{} + server := httptest.NewTLSServer(h) + defer server.Close() + h.installHandlers(server.URL) + + metadataURL := server.URL + "/.well-known/oauth-protected-resource" + prm, err := GetProtectedResourceMetadata(ctx, metadataURL, server.URL, server.Client()) + if err != nil { + t.Fatalf("GetProtectedResourceMetadata on loopback failed: %v", err) + } + if prm == nil { + t.Fatal("nil prm") + } +} diff --git a/oauthex/oauth2.go b/oauthex/oauth2.go index 689f17a..caed92f 100644 --- a/oauthex/oauth2.go +++ b/oauthex/oauth2.go @@ -11,14 +11,22 @@ import ( "fmt" "io" "mime" + "net" "net/http" + "net/netip" "net/url" "strings" + "syscall" + "time" "github.com/modelcontextprotocol/go-sdk/internal/json" "github.com/modelcontextprotocol/go-sdk/internal/util" ) +const maxDiscoveryRedirects = 10 + +var defaultDiscoveryTransport = newDiscoveryTransport(http.DefaultTransport) + type httpStatusError struct { StatusCode int } @@ -35,10 +43,7 @@ func getJSON[T any](ctx context.Context, c *http.Client, url string, limit int64 if err != nil { return nil, err } - if c == nil { - c = http.DefaultClient - } - res, err := c.Do(req) + res, err := newDiscoveryClient(c).Do(req) if err != nil { return nil, err } @@ -91,5 +96,112 @@ func checkHTTPSOrLoopback(addr string) error { if !util.IsLoopback(u.Host) && u.Scheme != "https" { return fmt.Errorf("URL %q does not use HTTPS or is not a loopback address", addr) } + if ip, err := netip.ParseAddr(u.Hostname()); err == nil && util.IsPrivateOrReserved(ip) { + return fmt.Errorf("URL %q refers to a non-public address", addr) + } + return nil +} + +func newDiscoveryClient(c *http.Client) *http.Client { + transport := defaultDiscoveryTransport + if c != nil && c.Transport != nil { + // a caller that has taken over dialing or TLS opts out of the checks + if t, ok := c.Transport.(*http.Transport); ok && t.DialContext == nil && t.DialTLSContext == nil { + transport = newDiscoveryTransport(t) + } else { + transport = c.Transport + } + } + discoveryClient := &http.Client{ + Transport: transport, + + CheckRedirect: func(req *http.Request, via []*http.Request) error { + if c != nil && c.CheckRedirect != nil { + return c.CheckRedirect(req, via) + } + if len(via) >= maxDiscoveryRedirects { + return fmt.Errorf("max redirects exceeded (%d)", maxDiscoveryRedirects) + } + if prev := via[len(via)-1]; prev.URL.Scheme == "https" && req.URL.Scheme != "https" { + return fmt.Errorf("redirect to non-HTTPS scheme %q is a security downgrade", req.URL.Scheme) + } + return checkLoopbackRedirect(req.Context(), req.URL.Hostname()) + }, + } + if c != nil { + discoveryClient.Timeout = c.Timeout + discoveryClient.Jar = c.Jar + } + return discoveryClient +} + +// newDiscoveryTransport creates an http.RoundTripper with extra protections enabled, +// unless the provided RoundTripper is not *http.Transport. +func newDiscoveryTransport(rt http.RoundTripper) http.RoundTripper { + base, ok := rt.(*http.Transport) + if !ok { + return rt + } + // If a proxy is configured Control checks should be disabled, because they will + // basically be running for proxy dials. + if proxyConfigured(base) { + return rt + } + result := base.Clone() + result.DialContext = (&net.Dialer{ + Timeout: 30 * time.Second, + KeepAlive: 30 * time.Second, + Control: func(_, address string, _ syscall.RawConn) error { + host, _, err := net.SplitHostPort(address) + if err != nil { + host = address + } + ip, err := netip.ParseAddr(host) + if err != nil { + return fmt.Errorf("non-ip address: %w", err) + } + if util.IsPrivateOrReserved(ip) { + return fmt.Errorf("non-public ip address: %q", ip) + } + return nil + }, + }).DialContext + return result +} + +func proxyConfigured(t *http.Transport) bool { + if t.Proxy == nil { + return false + } + req, err := http.NewRequest(http.MethodGet, "https://check-proxy-existence.com", nil) + if err != nil { + return false + } + u, err := t.Proxy(req) + return err == nil && u != nil +} + +func checkLoopbackRedirect(ctx context.Context, host string) error { + doCheck := func(ip netip.Addr) error { + if ip.Unmap().IsLoopback() { + return fmt.Errorf("redirect into loopback address %q", host) + } + if util.IsPrivateOrReserved(ip) { + return fmt.Errorf("redirect into non-public address %q", host) + } + return nil + } + if ip, err := netip.ParseAddr(host); err == nil { + return doCheck(ip) + } + ips, err := net.DefaultResolver.LookupNetIP(ctx, "ip", host) + if err != nil { + return fmt.Errorf("cannot resolve redirect host %q: %w", host, err) + } + for _, ip := range ips { + if err := doCheck(ip); err != nil { + return err + } + } return nil } From 830f0b76e7d4ed2f2b77ca9eb4c481d880771fff Mon Sep 17 00:00:00 2001 From: Guglielmo Colombo Date: Thu, 3 Sep 2026 11:27:42 +0200 Subject: [PATCH 54/70] mcp: update conformance tests (#1231) Fixes #1230 --- .github/workflows/conformance.yml | 12 ++++---- conformance/everything-client/main.go | 41 +++++++++++++++++++++++++++ 2 files changed, 46 insertions(+), 7 deletions(-) diff --git a/.github/workflows/conformance.yml b/.github/workflows/conformance.yml index 7d6cca4..6f54df4 100644 --- a/.github/workflows/conformance.yml +++ b/.github/workflows/conformance.yml @@ -14,7 +14,7 @@ permissions: contents: read env: - CONFORMANCE_VERSION: "0.2.0-alpha.10" + CONFORMANCE_VERSION: "0.2.0-alpha.11" jobs: server-conformance: @@ -32,7 +32,7 @@ jobs: node-version: 22 - name: Build everything-server run: go build -o everything-server ./conformance/everything-server - - name: Run conformance tests (stateful, legacy spec versions) + - name: Run conformance tests (2025-11-25, stateful) run: | ./everything-server -http=":3001" -stateless=false & SERVER_PID=$! @@ -40,10 +40,9 @@ jobs: timeout 30 bash -c 'until curl -s http://localhost:3001/mcp > /dev/null; do sleep 0.5; done' npx -y "@modelcontextprotocol/conformance@${CONFORMANCE_VERSION}" server \ --url http://localhost:3001/mcp \ - --suite active \ - --spec-version 2025-11-25 \ + --requirements 2025-11-25 \ --expected-failures ./conformance/baseline.yml - - name: Run conformance tests (stateless, 2026-07-28, full suite) + - name: Run conformance tests (2026-07-28, stateless) run: | ./everything-server -http=":3001" -stateless & SERVER_PID=$! @@ -51,8 +50,7 @@ jobs: timeout 30 bash -c 'until curl -s http://localhost:3001/mcp > /dev/null; do sleep 0.5; done' npx -y "@modelcontextprotocol/conformance@${CONFORMANCE_VERSION}" server \ --url http://localhost:3001/mcp \ - --suite all \ - --spec-version 2026-07-28 \ + --requirements 2026-07-28 \ --expected-failures ./conformance/baseline.yml client-conformance: diff --git a/conformance/everything-client/main.go b/conformance/everything-client/main.go index 3776522..b7d8ba0 100644 --- a/conformance/everything-client/main.go +++ b/conformance/everything-client/main.go @@ -49,6 +49,7 @@ func init() { registerScenario("elicitation-sep1034-client-defaults", runElicitationDefaultsClient) registerScenario("sse-retry", runSSERetryClient) registerScenario("json-schema-ref-no-deref", runJSONSchemaRefNoDerefClient) + registerScenario("json-schema-2020-12-preservation", runJSONSchemaPreservationClient) registerScenario("sep-2322-client-request-state", runMrtrClient) registerScenario("http-standard-headers", runHTTPStandardHeadersClient) registerScenario("http-custom-headers", runHTTPCustomHeadersClient) @@ -343,6 +344,46 @@ func runJSONSchemaRefNoDerefClient(ctx context.Context, serverURL string, _ map[ return nil } +// ============================================================================ +// json-schema-2020-12-preservation scenario (SEP-1613, SEP-2106) +// ============================================================================ + +// runJSONSchemaPreservationClient echoes the focal tool's inputSchema back to +// the referee verbatim, through the permissive json_schema_echo tool, so that +// it can observe which 2020-12 keywords survived the client's parse of +// tools/list. The Go SDK surfaces a server's schema as the raw JSON object it +// received, so nothing is stripped. +func runJSONSchemaPreservationClient(ctx context.Context, serverURL string, _ map[string]any) error { + session, err := connectToServer(ctx, serverURL) + if err != nil { + return err + } + defer session.Close() + + tools, err := session.ListTools(ctx, nil) + if err != nil { + return fmt.Errorf("session.ListTools(): %v", err) + } + + const focalTool = "json_schema_2020_12_tool" + idx := slices.IndexFunc(tools.Tools, func(t *mcp.Tool) bool { + return t.Name == focalTool + }) + if idx == -1 { + return fmt.Errorf("tool %q not found", focalTool) + } + + const echoTool = "json_schema_echo" + if _, err := session.CallTool(ctx, &mcp.CallToolParams{ + Name: echoTool, + Arguments: map[string]any{"schema": tools.Tools[idx].InputSchema}, + }); err != nil { + return fmt.Errorf("session.CallTool(%q): %v", echoTool, err) + } + + return nil +} + // ============================================================================ // SEP-2322 multi-round-trip client scenario // ============================================================================ From 3f3b699b2b67e1ed033a63d6651671dab53c2d32 Mon Sep 17 00:00:00 2001 From: Guglielmo Colombo Date: Fri, 4 Sep 2026 10:08:52 +0200 Subject: [PATCH 55/70] refactor: remove legacy MCPGODEBUG compatibility for new protocol release (#1237) --- docs/mcpgodebug.md | 17 +++++++++++++++++ internal/docs/mcpgodebug.src.md | 17 +++++++++++++++++ mcp/protocol.go | 12 +----------- mcp/sse.go | 4 ++-- mcp/streamable.go | 33 ++++----------------------------- 5 files changed, 41 insertions(+), 42 deletions(-) diff --git a/docs/mcpgodebug.md b/docs/mcpgodebug.md index d25b9b4..57766ce 100644 --- a/docs/mcpgodebug.md +++ b/docs/mcpgodebug.md @@ -44,6 +44,23 @@ Options listed below were added and will be removed in the 1.9.0 version of the soon as its context is cancelled and cannot be delayed by a slow or unresponsive peer. See issue #1150. +Options below were removed, according to plan: + +- `seterroroverwrite`. `SetError` now always preserves existing `Content`. + +- `enableoriginverification`. A nil + `StreamableHTTPOptions.CrossOriginProtection` now always means no + cross-origin protection. Wrap the handler with cross-origin protection + middleware instead. + +- `disablecontenttypecheck`. Content-Type validation on HTTP POST requests can + no longer be disabled. + +- `disablelocalhostprotection`, whose removal had been postponed to this + release. Set the `DisableLocalhostProtection` field in + `StreamableHTTPOptions` or `SSEOptions` to opt out of DNS rebinding + protection. + ### 1.7.0 Options listed below were added and will be removed in the 1.9.0 version of the SDK. diff --git a/internal/docs/mcpgodebug.src.md b/internal/docs/mcpgodebug.src.md index 32f8af2..fd0f255 100644 --- a/internal/docs/mcpgodebug.src.md +++ b/internal/docs/mcpgodebug.src.md @@ -43,6 +43,23 @@ Options listed below were added and will be removed in the 1.9.0 version of the soon as its context is cancelled and cannot be delayed by a slow or unresponsive peer. See issue #1150. +Options below were removed, according to plan: + +- `seterroroverwrite`. `SetError` now always preserves existing `Content`. + +- `enableoriginverification`. A nil + `StreamableHTTPOptions.CrossOriginProtection` now always means no + cross-origin protection. Wrap the handler with cross-origin protection + middleware instead. + +- `disablecontenttypecheck`. Content-Type validation on HTTP POST requests can + no longer be disabled. + +- `disablelocalhostprotection`, whose removal had been postponed to this + release. Set the `DisableLocalhostProtection` field in + `StreamableHTTPOptions` or `SSEOptions` to opt out of DNS rebinding + protection. + ### 1.7.0 Options listed below were added and will be removed in the 1.9.0 version of the SDK. diff --git a/mcp/protocol.go b/mcp/protocol.go index 4867e54..63d7015 100644 --- a/mcp/protocol.go +++ b/mcp/protocol.go @@ -329,23 +329,13 @@ type CallToolResult struct { err error } -// seterroroverwrite is a compatibility parameter that restores the pre-1.6.0 -// behavior of [CallToolResult.SetError], where Content was always overwritten -// with the error text. See the documentation for the mcpgodebug package for -// instructions on how to enable it. -// The option will be removed in the 1.8.0 version of the SDK. -var seterroroverwrite = mcpgodebug.Value("seterroroverwrite") - // SetError sets the error for the tool result and sets IsError to true. // If Content has not already been populated, it is set to the error text. // If Content has already been populated, it is left unchanged, allowing callers // to provide a user-friendly message while still recording the underlying error // for inspection via [GetError] in server middleware. -// -// To restore the previous behavior where Content was always overwritten, -// set MCPGODEBUG=seterroroverwrite=1. func (r *CallToolResult) SetError(err error) { - if len(r.Content) == 0 || seterroroverwrite == "1" { + if len(r.Content) == 0 { r.Content = []Content{&TextContent{Text: err.Error()}} } r.IsError = true diff --git a/mcp/sse.go b/mcp/sse.go index c7926b9..0a5bf7f 100644 --- a/mcp/sse.go +++ b/mcp/sse.go @@ -225,7 +225,7 @@ func (t *SSEServerTransport) SupportsProtocolVersion(version string) bool { func (h *SSEHandler) 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 - if !h.opts.DisableLocalhostProtection && disablelocalhostprotection != "1" { + if !h.opts.DisableLocalhostProtection { if localAddr, ok := req.Context().Value(http.LocalAddrContextKey).(net.Addr); ok && localAddr != nil { if util.IsLoopback(localAddr.String()) && !util.IsLoopback(req.Host) { http.Error(w, fmt.Sprintf("Forbidden: invalid Host header %q", req.Host), http.StatusForbidden) @@ -235,7 +235,7 @@ func (h *SSEHandler) ServeHTTP(w http.ResponseWriter, req *http.Request) { } // Validate 'Content-Type' header. - if disablecontenttypecheck != "1" && req.Method == http.MethodPost { + if req.Method == http.MethodPost { mediaType, _, err := mime.ParseMediaType(req.Header.Get("Content-Type")) if err != nil || mediaType != "application/json" { http.Error(w, "Content-Type must be 'application/json'", http.StatusUnsupportedMediaType) diff --git a/mcp/streamable.go b/mcp/streamable.go index d1462d9..f24badb 100644 --- a/mcp/streamable.go +++ b/mcp/streamable.go @@ -184,8 +184,7 @@ type StreamableHTTPOptions struct { // CrossOriginProtection allows to customize cross-origin protection. // The deny handler set in the CrossOriginProtection through SetDenyHandler // is ignored. - // If nil, no cross-origin protection is applied. Use the `enableoriginverification` - // MCPGODEBUG compatibility parameter to enable the default protection until v1.8.0. + // If nil, no cross-origin protection is applied. // // Deprecated: wrap the handler with cross-origin protection middleware // instead. For example: @@ -240,10 +239,6 @@ func NewStreamableHTTPHandler(getServer func(*http.Request) *Server, opts *Strea h.opts.Logger = ensureLogger(h.opts.Logger) - if h.opts.CrossOriginProtection == nil && enableoriginverification == "1" { - h.opts.CrossOriginProtection = &http.CrossOriginProtection{} - } - if h.opts.MaxRequestBodyBytes == 0 { h.opts.MaxRequestBodyBytes = DefaultMaxRequestBodyBytes } @@ -274,20 +269,6 @@ func (h *StreamableHTTPHandler) closeAll() { } } -// disablelocalhostprotection is a compatibility parameter that allows to disable -// DNS rebinding protection, which was added in the 1.4.0 version of the SDK. -// See the documentation for the mcpgodebug package for instructions how to enable it. -// The option will be removed in the 1.8.0 version of the SDK. -var disablelocalhostprotection = mcpgodebug.Value("disablelocalhostprotection") - -// enableoriginverification is a compatibility parameter that restores the -// default cross-origin protection behavior from v1.4.1-v1.5.0. When set to -// "1", a zero-value CrossOriginProtection will be applied if none is -// explicitly provided in StreamableHTTPOptions. -// See the documentation for the mcpgodebug package for instructions how to enable it. -// The option will be removed in the 1.8.0 version of the SDK. -var enableoriginverification = mcpgodebug.Value("enableoriginverification") - // allowsessionsinstateless is a compatibility parameter that restores the old // behavior of reading and using Mcp-Session-Id headers in stateless mode. When // set to "1", stateless servers will read the session ID from the request @@ -308,12 +289,6 @@ var allowsessionsinstateless = mcpgodebug.Value("allowsessionsinstateless") // permanently fails the connection. var noprotocolerrorbody = mcpgodebug.Value("noprotocolerrorbody") -// disablecontenttypecheck is a compatibility parameter that allows to disable -// Content-Type validation on POST requests. -// See the documentation for the mcpgodebug package for instructions how to enable it. -// The option will be removed in the 1.8.0 version of the SDK. -var disablecontenttypecheck = mcpgodebug.Value("disablecontenttypecheck") - // plaintextstatefulrejection is a compatibility parameter that restores the // previous behavior of a stateful [StreamableHTTPHandler] when it receives a // request carrying SEP-2575 per-request metadata (i.e. an @@ -341,7 +316,7 @@ func writeJSONRPCError(w http.ResponseWriter, status int, id jsonrpc.ID, jerr *j 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 - if !h.opts.DisableLocalhostProtection && disablelocalhostprotection != "1" { + if !h.opts.DisableLocalhostProtection { if localAddr, ok := req.Context().Value(http.LocalAddrContextKey).(net.Addr); ok && localAddr != nil { if util.IsLoopback(localAddr.String()) && !util.IsLoopback(req.Host) { http.Error(w, fmt.Sprintf("Forbidden: invalid Host header %q", req.Host), http.StatusForbidden) @@ -410,7 +385,7 @@ func (h *StreamableHTTPHandler) serveStateless(w http.ResponseWriter, req *http. return } - if disablecontenttypecheck != "1" && baseMediaType(req.Header.Get("Content-Type")) != "application/json" { + if baseMediaType(req.Header.Get("Content-Type")) != "application/json" { http.Error(w, "Content-Type must be 'application/json'", http.StatusUnsupportedMediaType) return } @@ -644,7 +619,7 @@ func (h *StreamableHTTPHandler) serveStatefulDELETE(w http.ResponseWriter, req * // ID, a new session is created (this is the normal path for the first // initialize request). func (h *StreamableHTTPHandler) serveStatefulPOST(w http.ResponseWriter, req *http.Request) { - if disablecontenttypecheck != "1" && baseMediaType(req.Header.Get("Content-Type")) != "application/json" { + if baseMediaType(req.Header.Get("Content-Type")) != "application/json" { http.Error(w, "Content-Type must be 'application/json'", http.StatusUnsupportedMediaType) return } From a5026ea7b727a59b166a06cf7ec4931a50cef427 Mon Sep 17 00:00:00 2001 From: ARPAN MONDAL Date: Sun, 6 Sep 2026 15:36:58 +0530 Subject: [PATCH 56/70] mcp: decode a Base64-wrapped Mcp-Name before comparing it to the body (#1242) The 2026-07-28 transport spec lets a client Base64-wrap a name or resource URI that is not header-safe (`=?base64?...?=`) and requires the server to decode it before comparing. `validateMcpHeaders` decoded `Mcp-Param-*` values that way but compared `Mcp-Name` raw, so a spec-legal encoded name was refused as a header mismatch. This decodes `Mcp-Name` before comparing, mirroring the existing `Mcp-Param-*` path, and rejects an invalid Base64 wrapper with a clear error. Added table cases covering a valid encoded name and an invalid one (red before, green after). Fixes #1234 Co-authored-by: Arpan Mondal Co-authored-by: Guglielmo Colombo --- mcp/streamable_headers.go | 11 ++++++++--- mcp/streamable_headers_test.go | 18 ++++++++++++++++++ 2 files changed, 26 insertions(+), 3 deletions(-) diff --git a/mcp/streamable_headers.go b/mcp/streamable_headers.go index 7d06399..77f2415 100644 --- a/mcp/streamable_headers.go +++ b/mcp/streamable_headers.go @@ -378,13 +378,18 @@ func validateMcpHeaders(header http.Header, msg jsonrpc.Message, toolLookup func if nameInHeader == "" { return fmt.Errorf("missing required Mcp-Name header for method %q", msg.Method) } - var ok bool + // A name that is not header-safe may be Base64-wrapped by the client + // (=?base64?...?=), so decode before comparing, as with Mcp-Param-*. + decodedName, ok := decodeHeaderValue(nameInHeader) + if !ok { + return errors.New("header mismatch: Mcp-Name header contains invalid Base64 encoding") + } nameInBody, ok = extractName(msg.Method, msg.Params) if !ok { return fmt.Errorf("failed to extract name from parameters for method %q", msg.Method) } - if nameInHeader != nameInBody { - return fmt.Errorf("header mismatch: Mcp-Name header value '%s' does not match body value '%s'", nameInHeader, nameInBody) + if decodedName != nameInBody { + return fmt.Errorf("header mismatch: Mcp-Name header value '%s' does not match body value '%s'", decodedName, nameInBody) } } diff --git a/mcp/streamable_headers_test.go b/mcp/streamable_headers_test.go index def7ad1..f0862f3 100644 --- a/mcp/streamable_headers_test.go +++ b/mcp/streamable_headers_test.go @@ -6,6 +6,7 @@ package mcp import ( "context" + "encoding/base64" "encoding/json" "fmt" "net/http" @@ -335,6 +336,23 @@ func TestValidateMcpHeaders(t *testing.T) { msg: &jsonrpc.Request{Method: "prompts/get", Params: mustMarshal(&GetPromptParams{Name: "code_review"})}, wantErr: false, }, + { + name: "Base64-encoded Mcp-Name is decoded before comparison", + version: minVersionForStandardHeaders, + methodHeader: "tools/call", + nameHeader: base64Prefix + base64.StdEncoding.EncodeToString([]byte("café-tool")) + base64Suffix, + msg: &jsonrpc.Request{Method: "tools/call", Params: mustMarshal(&CallToolParams{Name: "café-tool"})}, + wantErr: false, + }, + { + name: "Mcp-Name with invalid Base64 encoding", + version: minVersionForStandardHeaders, + methodHeader: "tools/call", + nameHeader: base64Prefix + "not valid base64!" + base64Suffix, + msg: &jsonrpc.Request{Method: "tools/call", Params: mustMarshal(&CallToolParams{Name: "my-tool"})}, + wantErr: true, + wantErrContain: "invalid Base64 encoding", + }, { name: "valid initialize (no name needed)", version: minVersionForStandardHeaders, From f99cdb649b0c61b07341bc31ecbf94b8f960bbef Mon Sep 17 00:00:00 2001 From: akashjainn <80935005+akashjainn@users.noreply.github.com> Date: Mon, 7 Sep 2026 04:16:16 -0400 Subject: [PATCH 57/70] mcp: base64-encode a non-header-safe Mcp-Name (#1246) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes: https://github.com/modelcontextprotocol/go-sdk/issues/1243 The 2026-07-28 transport spec only SHOULD-constrains names to header-safe characters, and lets a client Base64-wrap one that is not, using the `=?base64?...?=` sentinel. `setStandardHeaders` set `Mcp-Name` raw, while the sibling `Mcp-Param-*` path already ran its values through `encodeHeaderValue`, so a name outside the safe set went out unencoded. This routes `Mcp-Name` through `encodeHeaderValue` as well. #1242 landed the other direction — the server decoding the sentinel before comparing — so the two together close the round trip. Worth noting they interact: after #1242, a name that is literally shaped like the sentinel (`=?base64?abc?=`) is now rejected as invalid Base64, because the server tries to decode a value the client never encoded. The new test covers that case, and it fails on `main` today. `extractName` also covers `resources/read`, so this applies to resource URIs, which is where non-ASCII is most likely to show up in practice. ## Testing `TestMcpNameHeaderEncodingRoundTrip` drives `setStandardHeaders` and then feeds its output to `validateMcpHeaders`, so it pins the client and server halves against each other rather than asserting the encoding in isolation. Cases: an ASCII-safe name (must stay unencoded, no churn for the common path), non-ASCII, a leading space, a control character, and the sentinel-shaped literal above. Verified failing before the change and passing after. `go test ./...` passes. A note on process: [@remote-controlled-man](https://github.com/remote-controlled-man) asked about this issue earlier today and proposed much the same scope. I had the change written before I saw that comment and thought the #1242 interaction was worth surfacing either way, but I'm happy to close this if they'd rather carry it — please just say so. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_011Wf643Lx36V5wdwuuf2EdT --- mcp/streamable_headers.go | 7 ++++- mcp/streamable_headers_test.go | 54 ++++++++++++++++++++++++++++++++++ 2 files changed, 60 insertions(+), 1 deletion(-) diff --git a/mcp/streamable_headers.go b/mcp/streamable_headers.go index 77f2415..c2b8dfd 100644 --- a/mcp/streamable_headers.go +++ b/mcp/streamable_headers.go @@ -207,7 +207,12 @@ func setStandardHeaders(ctx context.Context, header http.Header, msg jsonrpc.Mes case *jsonrpc.Request: header.Set(methodHeader, msg.Method) if name, ok := extractName(msg.Method, msg.Params); ok { - header.Set(nameHeader, name) + // Names are only SHOULD-constrained to header-safe characters, so a + // name outside the safe set is Base64-wrapped with the =?base64?...?= + // sentinel, the same rule applied to Mcp-Param-* values. + if encoded, ok := encodeHeaderValue(name); ok { + header.Set(nameHeader, encoded) + } } if msg.Method == "tools/call" { if tool, ok := ctx.Value(toolContextKey).(*Tool); ok && tool != nil { diff --git a/mcp/streamable_headers_test.go b/mcp/streamable_headers_test.go index f0862f3..97af67c 100644 --- a/mcp/streamable_headers_test.go +++ b/mcp/streamable_headers_test.go @@ -1593,3 +1593,57 @@ func TestValidateParamHeaders_NestedArguments(t *testing.T) { t.Error("validateParamHeaders() = nil, want error for mismatched nested value") } } + +// TestMcpNameHeaderEncodingRoundTrip verifies that a name which is not +// header-safe is Base64-wrapped by the client per the 2026-07-28 transport +// spec, and that the server's validation accepts what the client produced. +func TestMcpNameHeaderEncodingRoundTrip(t *testing.T) { + tests := []struct { + name string + msg *jsonrpc.Request + wantHeader string + }{ + { + name: "ascii-safe tool name is sent as-is", + msg: &jsonrpc.Request{Method: "tools/call", Params: mustMarshal(&CallToolParams{Name: "my-tool"})}, + wantHeader: "my-tool", + }, + { + name: "non-ascii tool name is base64 encoded", + msg: &jsonrpc.Request{Method: "tools/call", Params: mustMarshal(&CallToolParams{Name: "café-tool"})}, + wantHeader: encodeBase64("café-tool"), + }, + { + name: "prompt name with a leading space is base64 encoded", + msg: &jsonrpc.Request{Method: "prompts/get", Params: mustMarshal(&GetPromptParams{Name: " leading-space"})}, + wantHeader: encodeBase64(" leading-space"), + }, + { + name: "resource uri with a control character is base64 encoded", + msg: &jsonrpc.Request{Method: "resources/read", Params: mustMarshal(&ReadResourceParams{URI: "file:///a\tb.txt"})}, + wantHeader: encodeBase64("file:///a\tb.txt"), + }, + { + name: "name that looks like the sentinel is base64 encoded", + msg: &jsonrpc.Request{Method: "tools/call", Params: mustMarshal(&CallToolParams{Name: "=?base64?abc?="})}, + wantHeader: encodeBase64("=?base64?abc?="), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + header := http.Header{} + header.Set(protocolVersionHeader, minVersionForStandardHeaders) + setStandardHeaders(context.Background(), header, tt.msg) + + if got := header.Get(nameHeader); got != tt.wantHeader { + t.Errorf("Mcp-Name = %q, want %q", got, tt.wantHeader) + } + + // The server must accept the headers the client just generated. + if err := validateMcpHeaders(header, tt.msg, nil); err != nil { + t.Errorf("validateMcpHeaders() on client-generated headers = %v, want nil", err) + } + }) + } +} From 5bc078a7959a745d1311fefa280b36c5cde19929 Mon Sep 17 00:00:00 2001 From: Max Freedom Pollard <272618364+MaxFreedomPollard@users.noreply.github.com> Date: Mon, 7 Sep 2026 05:31:45 -0400 Subject: [PATCH 58/70] auth: strip a terminating slash from the issuer before building metadata URLs (#1245) An issuer identifier written with a terminating slash, like `https://auth.example.com/`, makes authorization server metadata discovery miss every endpoint. `GetAuthServerMetadata` probes three URLs that no conformant server serves, finds nothing, and the caller falls back to the guessed 2025-03-26 endpoints, which are themselves malformed because they are built by appending to the same slash-terminated issuer. `authorizationServerMetadataURLs` in `auth/shared.go` decides whether the issuer has a path component with `baseURL.Path == ""` (line 47), and a trailing-slash issuer has `Path == "/"`, so it takes the path-insertion branch. That branch inserts `strings.TrimLeft(originalPath, "/")` (lines 59 and 62), which strips the leading slash but keeps the trailing one. For `https://auth.example.com/` the probes come out as `/.well-known/oauth-authorization-server/`, `/.well-known/openid-configuration/` and `//.well-known/openid-configuration`. For `https://auth.example.com/tenant1/` the two path-insertion probes come out with a trailing slash. Only the path-appending form was already right, because it uses `strings.Trim`. RFC 8414 section 3.1 settles it: "If the issuer identifier value contains a path component, any terminating "/" MUST be removed before inserting "/.well-known/" and the well-known URI suffix between the host component and the path component." So `https://auth.example.com/` has no path component and gets the two root forms, which is the list the MCP authorization spec gives for issuers without a path component. The change trims slashes off both ends of the issuer path once, uses that to pick between the root and path-insertion branches, and reuses it for all three URLs. An issuer without a terminating slash produces exactly the same list as before. `TestAuthorizationServerMetadataURLs` pins the URL list for all four issuer shapes. `TestGetAuthServerMetadataTrailingSlashIssuer` drives the whole lookup against the existing fake authorization server, serving metadata at the root well-known path. On unmodified main the first fails on both trailing-slash cases and the second fails with `GetAuthServerMetadata() error = Get "/.well-known/openid-configuration": redirect into loopback address "127.0.0.1"`, because `//.well-known/openid-configuration` makes `net/http.ServeMux` issue a canonicalizing redirect that the discovery client's `CheckRedirect` refuses. Both pass with the change. Verification on go1.26.4: `gofmt -l .` clean, `go vet ./...` clean, `staticcheck ./...` clean at v0.6.1 (the version the Test workflow pins), `go test ./auth/...` ok, `go test ./...` ok. I did not file an issue first. The fix is self-contained, confined to one unexported helper, and every issuer shape other than the slash-terminated ones is byte-identical to before. Co-authored-by: Guglielmo Colombo --- auth/shared.go | 14 +++++---- auth/shared_test.go | 70 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 79 insertions(+), 5 deletions(-) diff --git a/auth/shared.go b/auth/shared.go index 8c5e10c..e58d438 100644 --- a/auth/shared.go +++ b/auth/shared.go @@ -44,7 +44,12 @@ func authorizationServerMetadataURLs(issuerURL string) []string { return nil } - if baseURL.Path == "" { + // RFC 8414, section 3.1 requires any terminating "/" to be removed from the + // issuer identifier before the well-known suffix is inserted, so an issuer + // such as "https://auth.example.com/" has no path component at all. + issuerPath := strings.Trim(baseURL.Path, "/") + + if issuerPath == "" { // "OAuth 2.0 Authorization Server Metadata". baseURL.Path = "/.well-known/oauth-authorization-server" urls = append(urls, baseURL.String()) @@ -54,15 +59,14 @@ func authorizationServerMetadataURLs(issuerURL string) []string { return urls } - originalPath := baseURL.Path // "OAuth 2.0 Authorization Server Metadata with path insertion". - baseURL.Path = "/.well-known/oauth-authorization-server/" + strings.TrimLeft(originalPath, "/") + baseURL.Path = "/.well-known/oauth-authorization-server/" + issuerPath urls = append(urls, baseURL.String()) // "OpenID Connect Discovery 1.0 with path insertion". - baseURL.Path = "/.well-known/openid-configuration/" + strings.TrimLeft(originalPath, "/") + baseURL.Path = "/.well-known/openid-configuration/" + issuerPath urls = append(urls, baseURL.String()) // "OpenID Connect Discovery 1.0 with path appending". - baseURL.Path = "/" + strings.Trim(originalPath, "/") + "/.well-known/openid-configuration" + baseURL.Path = "/" + issuerPath + "/.well-known/openid-configuration" urls = append(urls, baseURL.String()) return urls diff --git a/auth/shared_test.go b/auth/shared_test.go index 16b5054..34e0a35 100644 --- a/auth/shared_test.go +++ b/auth/shared_test.go @@ -6,11 +6,81 @@ package auth import ( "net/http" + "slices" "testing" "github.com/modelcontextprotocol/go-sdk/internal/oauthtest" ) +func TestAuthorizationServerMetadataURLs(t *testing.T) { + tests := []struct { + issuerURL string + want []string + }{ + { + issuerURL: "https://auth.example.com", + want: []string{ + "https://auth.example.com/.well-known/oauth-authorization-server", + "https://auth.example.com/.well-known/openid-configuration", + }, + }, + { + issuerURL: "https://auth.example.com/", + want: []string{ + "https://auth.example.com/.well-known/oauth-authorization-server", + "https://auth.example.com/.well-known/openid-configuration", + }, + }, + { + issuerURL: "https://auth.example.com/tenant1", + want: []string{ + "https://auth.example.com/.well-known/oauth-authorization-server/tenant1", + "https://auth.example.com/.well-known/openid-configuration/tenant1", + "https://auth.example.com/tenant1/.well-known/openid-configuration", + }, + }, + { + issuerURL: "https://auth.example.com/tenant1/", + want: []string{ + "https://auth.example.com/.well-known/oauth-authorization-server/tenant1", + "https://auth.example.com/.well-known/openid-configuration/tenant1", + "https://auth.example.com/tenant1/.well-known/openid-configuration", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.issuerURL, func(t *testing.T) { + if got := authorizationServerMetadataURLs(tt.issuerURL); !slices.Equal(got, tt.want) { + t.Errorf("authorizationServerMetadataURLs(%q) =\n%v\nwant\n%v", tt.issuerURL, got, tt.want) + } + }) + } +} + +// TestGetAuthServerMetadataTrailingSlashIssuer checks that an issuer identifier +// written with a terminating slash still finds metadata served at the root +// well-known path. +func TestGetAuthServerMetadataTrailingSlashIssuer(t *testing.T) { + s := oauthtest.NewFakeAuthorizationServer(oauthtest.Config{ + MetadataEndpointConfig: &oauthtest.MetadataEndpointConfig{ + ServeOAuthInsertedEndpoint: true, + }, + }) + s.Start(t) + + got, err := GetAuthServerMetadata(t.Context(), s.URL()+"/", http.DefaultClient) + if err != nil { + t.Fatalf("GetAuthServerMetadata() error = %v, want nil", err) + } + if got == nil { + t.Fatal("GetAuthServerMetadata() got nil, want metadata") + } + if got.Issuer != s.URL() { + t.Errorf("GetAuthServerMetadata() issuer = %q, want %q", got.Issuer, s.URL()) + } +} + func TestGetAuthServerMetadata(t *testing.T) { tests := []struct { name string From e40f35d137b72bf0e9fd4a186c3d58958c0e6fd7 Mon Sep 17 00:00:00 2001 From: Ronald Date: Mon, 14 Sep 2026 03:52:10 -0400 Subject: [PATCH 59/70] mcp: label resultType on results that bypass the dispatcher (#1226) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes #1225. See the [issue comment](https://github.com/modelcontextprotocol/go-sdk/issues/1225#issuecomment-5500818546). ## The bug `2026-07-28` requires `resultType` on every result. `CallToolResult`, `GetPromptResult`, and `ReadResourceResult` keep `resultType` in an unexported field. Each of these results can be `complete` or `input_required`, so it cannot embed `completeResultWithType` the way the seven types in #1060 do. Only `handleMultiRoundTripResult` sets that field. It runs inside `Server.callTool`, `getPrompt`, and `readResource`. Some middleware returns a result directly instead of calling `next` — for example, an auth, rate-limit, or entitlement gate. That middleware never reaches `handleMultiRoundTripResult`. The response then ships without the field, and strict clients reject it. ## The fix The choice between `complete` and `input_required` now happens inside `setMultiRoundTripResultType`. Both call sites that need this choice call that function. `setCompleteResultType` now labels results it did not label before, so it is renamed `annotateResultType`. The new function picks a branch with a type switch. Reading matters here. `setResultType` is unexported, so setting `InputRequests` is the only way for middleware to ask for input. If the code always labels results `complete`, the response ships as `{"resultType":"complete","inputRequests":{...}}`. A client that trusts the discriminator then drops the elicitation request. Recomputing the label every time is safe. A result that did reach the dispatcher already carries the same label from that test. This also covers one edge case: a handler that returns `InputRequests` again after the shim's single reinvocation. Before this fix, that case shipped with no `resultType`. Embedding `completeResultWithType` instead is not an option: it labels everything `complete` and overwrites `input_required`, breaking multi-round-trip flows. **Skip nil results.** Middleware can return a typed nil value, which satisfies the `Result` interface. `handle` now checks for that case before it calls the labeling function. Before this change, that same case also panicked, one frame later in `annotateServerInfo` and then `GetMeta`. ## Checks - `TestServerSessionHandle_SetsResultTypeWhenMiddlewareShortCircuits` fails on all three methods before the first change. Its fourth case fails if labeling overwrites a populated `InputRequests`. - `TestServerSessionHandle_ResultTypeGate` records which requests carry `resultType`, including one known gap. See "Separate from this PR" below. - `TestServerSessionHandle_NilResultFromMiddleware` panics without the nil check. - The complete-path cases added to `TestServerSessionHandle_SetsResultTypeOnNewProtocol` also pass before this change. They close a gap in #1060, which added only three cases, and only for `input_required`. - The exported API is unchanged. `go doc -all ./mcp`, with comments stripped, is identical before and after this change. - With the reproduction from the issue, over the streamable transport: the dispatched `tools/call` and the short-circuited `tools/call` both now return `resultType: "complete"`. I could not run `./scripts/conformance.sh`. CONTRIBUTING.md names that script, but the repo only has `client-conformance.sh` and `server-conformance.sh`. Both scripts need GNU `timeout`, and macOS does not include it. ## Also here Ten comments named an exported `ResultType` field and `ResultTypeComplete` / `ResultTypeInputRequired` constants. None of those exist. Two of the comments were `[CallToolResult.ResultType]` doc links, and those links cannot resolve. They now point at `[CallToolResult.NeedsInput]` instead, because an unexported field is also not a valid link target. --------- Co-authored-by: Claude Opus 5 (1M context) Co-authored-by: Guglielmo Colombo --- mcp/mrtr.go | 19 +--- mcp/protocol.go | 92 +++++++++------ mcp/server.go | 16 +-- mcp/server_test.go | 271 ++++++++++++++++++++++++++++++++++++++++----- mcp/shared.go | 7 +- 5 files changed, 322 insertions(+), 83 deletions(-) diff --git a/mcp/mrtr.go b/mcp/mrtr.go index 008c0f8..fdf98ec 100644 --- a/mcp/mrtr.go +++ b/mcp/mrtr.go @@ -36,29 +36,20 @@ type multiRoundTripResponse interface { hasContent() bool } -func handleMultiRoundTripResult(ss *ServerSession, logger *slog.Logger, res multiRoundTripResponse) error { +// validateMultiRoundTripResult rejects a result that carries both content and +// input requests. [annotateResultType] labels the result later, in +// [ServerSession.handle], so this function does not set resultType. +func validateMultiRoundTripResult(logger *slog.Logger, res multiRoundTripResponse) error { if res == nil { return nil } - hasInputRequests := res.inputRequests() != nil - - if hasInputRequests && res.hasContent() { + if res.inputRequests() != nil && res.hasContent() { logger.Warn("handler returned both content and inputRequests") return &jsonrpc.Error{ Code: jsonrpc.CodeInternalError, Message: "server bug: result has both content and inputRequests", } } - - if clientSupportsMultiRoundTrip(ss) { - // For older clients the resultType is left unset. Input requests will be handled - // by serverMultiRoundTripMiddleware client calls and handler reinvocation. - if hasInputRequests { - res.setResultType(resultTypeInputRequired) - } else { - res.setResultType(resultTypeComplete) - } - } return nil } diff --git a/mcp/protocol.go b/mcp/protocol.go index 63d7015..be37a18 100644 --- a/mcp/protocol.go +++ b/mcp/protocol.go @@ -19,7 +19,7 @@ type resultType string const ( // resultTypeComplete indicates the result is final. - // This is the default when ResultType is empty. + // This is the default when resultType is empty. resultTypeComplete resultType = "complete" // resultTypeInputRequired indicates the server needs additional client @@ -40,9 +40,20 @@ type completeResultResponse interface { isCompleteResult() } -func setCompleteResultType(res Result) { - if r, ok := res.(completeResultResponse); ok { +// annotateResultType sets the resultType that protocol 2026-07-28 requires on +// every result. +func annotateResultType(res Result) { + switch r := res.(type) { + case completeResultResponse: r.setResultType(resultTypeComplete) + case multiRoundTripResponse: + // These results are complete or input_required, so label them by + // whether the handler asked for more client input. + if r.inputRequests() != nil { + r.setResultType(resultTypeInputRequired) + } else { + r.setResultType(resultTypeComplete) + } } } @@ -226,9 +237,8 @@ type CallToolParams struct { // marshaled to JSON. Arguments any `json:"arguments,omitempty"` - // InputResponses maps input request IDs to responses, provided when - // retrying a call after receiving a result with ResultType - // ResultTypeInputRequired. + // InputResponses maps input request IDs to responses. Set it when the + // client retries a call that asked for input. See [CallToolResult.NeedsInput]. InputResponses InputResponseMap `json:"inputResponses,omitempty"` // RequestState is the opaque state from the previous input-required result. // The client must echo this back when retrying. @@ -249,9 +259,8 @@ type CallToolParamsRaw struct { // Arguments (see [AddTool]). Arguments json.RawMessage `json:"arguments,omitempty"` - // InputResponses maps input request IDs to responses, provided when - // retrying a call after receiving a result with ResultType - // ResultTypeInputRequired. + // InputResponses maps input request IDs to responses. Set it when the + // client retries a call that asked for input. See [CallToolResult.NeedsInput]. InputResponses InputResponseMap `json:"inputResponses,omitempty"` // RequestState is the opaque state from the previous input-required result. // The client must echo this back when retrying. @@ -305,9 +314,9 @@ type CallToolResult struct { IsError bool `json:"isError,omitempty"` // InputRequests is a map of server-assigned IDs to input requests. - // Populated only when ResultType is ResultTypeInputRequired. + // It is set only when the result needs more client input. // The client must fulfill these and echo the IDs back in InputResponses - // when retrying the call. + // when it retries the call. InputRequests InputRequestMap `json:"inputRequests,omitempty"` // RequestState is an opaque string the client must echo back when @@ -317,10 +326,10 @@ type CallToolResult struct { // Unauthenticated servers must encrypt, sign and verify this value. RequestState string `json:"requestState,omitempty"` - // ResultType indicates whether this result is complete or requires further - // client input. Empty or ResultTypeComplete means the call succeeded - // normally. ResultTypeInputRequired means the client should fulfill the - // InputRequests and retry the call. + // resultType records whether the call finished or needs more client input. + // Empty or resultTypeComplete means it finished. resultTypeInputRequired + // means the client must fulfill InputRequests and retry. + // See [CallToolResult.NeedsInput]. resultType resultType // The error passed to setError, if any. // It is not marshaled, and therefore it is only visible on the server. @@ -348,7 +357,8 @@ func (r *CallToolResult) GetError() error { return r.err } -func (*CallToolResult) isResult() {} +func (*CallToolResult) isResult() {} +func (x *CallToolResult) isNil() bool { return x == nil } func (r *CallToolResult) setResultType(rt resultType) { r.resultType = rt } func (r *CallToolResult) requestState() string { return r.RequestState } @@ -363,7 +373,7 @@ func (r *CallToolResult) hasContent() bool { } // NeedsInput reports whether this result requires further client input. -// This is true when the server returned ResultType "input_required". +// This is true when the server returned a resultType of "input_required". // When NeedsInput returns true, check InputRequests for the set of // requests the server needs fulfilled before retrying the call. // An empty InputRequests with NeedsInput true indicates load-shedding. @@ -655,7 +665,8 @@ type CompleteResult struct { Completion CompletionResultDetails `json:"completion"` } -func (*CompleteResult) isResult() {} +func (*CompleteResult) isResult() {} +func (x *CompleteResult) isNil() bool { return x == nil } // CreateMessageParams holds parameters for a sampling/createMessage request. // @@ -839,6 +850,7 @@ type CreateMessageResult struct { } func (*CreateMessageResult) isResult() {} +func (x *CreateMessageResult) isNil() bool { return x == nil } func (*CreateMessageResult) isInputResponse() {} func (r *CreateMessageResult) UnmarshalJSON(data []byte) error { type result CreateMessageResult // avoid recursion @@ -890,6 +902,7 @@ var createMessageWithToolsResultAllow = map[string]bool{ } func (*CreateMessageWithToolsResult) isResult() {} +func (x *CreateMessageWithToolsResult) isNil() bool { return x == nil } func (*CreateMessageWithToolsResult) isInputResponse() {} // MarshalJSON marshals the result. When Content has a single element, it is @@ -950,9 +963,8 @@ type GetPromptParams struct { // The name of the prompt or prompt template. Name string `json:"name"` - // InputResponses maps input request IDs to responses, provided when - // retrying a call after receiving a result with ResultType - // ResultTypeInputRequired. + // InputResponses maps input request IDs to responses. Set it when the + // client retries a call that asked for input. See [CallToolResult.NeedsInput]. InputResponses InputResponseMap `json:"inputResponses,omitempty"` // RequestState is the opaque state from the previous input-required result. RequestState string `json:"requestState,omitempty"` @@ -972,19 +984,20 @@ type GetPromptResult struct { Description string `json:"description,omitempty"` Messages []*PromptMessage `json:"messages"` - // InputRequests is populated when ResultType is ResultTypeInputRequired. + // InputRequests is set when the result needs more client input. // See [CallToolResult.InputRequests]. InputRequests InputRequestMap `json:"inputRequests,omitempty"` // RequestState is the opaque state for multi-round-trip retries. // See [CallToolResult.RequestState]. RequestState string `json:"requestState,omitempty"` - // ResultType indicates whether this result is complete or requires further - // client input. See [CallToolResult.ResultType] for details. + // resultType records whether the call finished or needs more client input. + // See [CallToolResult.NeedsInput]. resultType resultType } -func (*GetPromptResult) isResult() {} +func (*GetPromptResult) isResult() {} +func (x *GetPromptResult) isNil() bool { return x == nil } func (r *GetPromptResult) setResultType(rt resultType) { r.resultType = rt } func (r *GetPromptResult) requestState() string { return r.RequestState } @@ -1094,7 +1107,8 @@ type InitializeResult struct { ServerInfo *Implementation `json:"serverInfo"` } -func (*InitializeResult) isResult() {} +func (*InitializeResult) isResult() {} +func (x *InitializeResult) isNil() bool { return x == nil } type InitializedParams struct { // Meta is reserved by the protocol to allow clients and servers to attach @@ -1137,7 +1151,8 @@ type DiscoverResult struct { Instructions string `json:"instructions,omitempty"` } -func (*DiscoverResult) isResult() {} +func (*DiscoverResult) isResult() {} +func (x *DiscoverResult) isNil() bool { return x == nil } func (x *ListPromptsParams) isParams() {} func (x *ListPromptsParams) isNil() bool { return x == nil } @@ -1207,6 +1222,7 @@ type ListPromptsResult struct { } func (x *ListPromptsResult) isResult() {} +func (x *ListPromptsResult) isNil() bool { return x == nil } func (x *ListPromptsResult) nextCursorPtr() *string { return &x.NextCursor } type ListResourceTemplatesParams struct { @@ -1238,6 +1254,7 @@ type ListResourceTemplatesResult struct { } func (x *ListResourceTemplatesResult) isResult() {} +func (x *ListResourceTemplatesResult) isNil() bool { return x == nil } func (x *ListResourceTemplatesResult) nextCursorPtr() *string { return &x.NextCursor } type ListResourcesParams struct { @@ -1269,6 +1286,7 @@ type ListResourcesResult struct { } func (x *ListResourcesResult) isResult() {} +func (x *ListResourcesResult) isNil() bool { return x == nil } func (x *ListResourcesResult) nextCursorPtr() *string { return &x.NextCursor } // ListRootsParams holds parameters for a roots/list request. @@ -1305,6 +1323,7 @@ type ListRootsResult struct { } func (*ListRootsResult) isResult() {} +func (x *ListRootsResult) isNil() bool { return x == nil } func (*ListRootsResult) isInputResponse() {} type ListToolsParams struct { @@ -1336,6 +1355,7 @@ type ListToolsResult struct { } func (x *ListToolsResult) isResult() {} +func (x *ListToolsResult) isNil() bool { return x == nil } func (x *ListToolsResult) nextCursorPtr() *string { return &x.NextCursor } // The severity of a log message. @@ -1575,9 +1595,8 @@ type ReadResourceParams struct { // the server how to interpret it. URI string `json:"uri"` - // InputResponses maps input request IDs to responses, provided when - // retrying a call after receiving a result with ResultType - // ResultTypeInputRequired. + // InputResponses maps input request IDs to responses. Set it when the + // client retries a call that asked for input. See [CallToolResult.NeedsInput]. InputResponses InputResponseMap `json:"inputResponses,omitempty"` // RequestState is the opaque state from the previous input-required result. RequestState string `json:"requestState,omitempty"` @@ -1596,19 +1615,20 @@ type ReadResourceResult struct { Cacheable Contents []*ResourceContents `json:"contents"` - // InputRequests is populated when ResultType is ResultTypeInputRequired. + // InputRequests is set when the result needs more client input. // See [CallToolResult.InputRequests]. InputRequests InputRequestMap `json:"inputRequests,omitempty"` // RequestState is the opaque state for multi-round-trip retries. // See [CallToolResult.RequestState]. RequestState string `json:"requestState,omitempty"` - // ResultType indicates whether this result is complete or requires further - // client input. See [CallToolResult.ResultType] for details. + // resultType records whether the call finished or needs more client input. + // See [CallToolResult.NeedsInput]. resultType resultType } -func (*ReadResourceResult) isResult() {} +func (*ReadResourceResult) isResult() {} +func (x *ReadResourceResult) isNil() bool { return x == nil } func (r *ReadResourceResult) setResultType(rt resultType) { r.resultType = rt } func (r *ReadResourceResult) requestState() string { return r.RequestState } @@ -2114,7 +2134,8 @@ type SubscriptionsListenResult struct { Meta `json:"_meta"` } -func (*SubscriptionsListenResult) isResult() {} +func (*SubscriptionsListenResult) isResult() {} +func (x *SubscriptionsListenResult) isNil() bool { return x == nil } // TODO(jba): add CompleteRequest and related types. @@ -2191,6 +2212,7 @@ type ElicitResult struct { } func (*ElicitResult) isResult() {} +func (x *ElicitResult) isNil() bool { return x == nil } func (*ElicitResult) isInputResponse() {} // ElicitationCompleteParams is sent from the server to the client, informing it that an out-of-band elicitation interaction has completed. diff --git a/mcp/server.go b/mcp/server.go index 5014a4f..183a519 100644 --- a/mcp/server.go +++ b/mcp/server.go @@ -910,7 +910,7 @@ func (s *Server) getPrompt(ctx context.Context, req *GetPromptRequest) (*GetProm } res, err := prompt.handler(ctx, req) if err == nil && res != nil { - if err := handleMultiRoundTripResult(req.Session, s.opts.Logger, res); err != nil { + if err := validateMultiRoundTripResult(s.opts.Logger, res); err != nil { return nil, err } } @@ -1012,10 +1012,10 @@ func (s *Server) callTool(ctx context.Context, req *CallToolRequest) (*CallToolR } res, err := st.handler(ctx, req) if err == nil && res != nil { - if err := handleMultiRoundTripResult(req.Session, s.opts.Logger, res); err != nil { + if err := validateMultiRoundTripResult(s.opts.Logger, res); err != nil { return nil, err } - if res.Content == nil && res.resultType != resultTypeInputRequired { + if res.Content == nil && res.InputRequests == nil { res2 := *res res2.Content = []Content{} // avoid "null" res = &res2 @@ -1080,11 +1080,11 @@ func (s *Server) readResource(ctx context.Context, req *ReadResourceRequest) (*R if res == nil { return nil, fmt.Errorf("reading resource %s: read handler returned nil information", uri) } - if err := handleMultiRoundTripResult(req.Session, s.opts.Logger, res); err != nil { + if err := validateMultiRoundTripResult(s.opts.Logger, res); err != nil { return nil, err } s.resolveCacheable(ctx, req, &res.Cacheable) - if res.resultType == resultTypeInputRequired { + if res.InputRequests != nil { return res, nil } if res.Contents == nil { @@ -2039,8 +2039,10 @@ func (ss *ServerSession) handle(ctx context.Context, req *jsonrpc.Request) (any, if err != nil { return nil, err } - if validatedMeta.usesNewProtocol { - setCompleteResultType(res) + // A middleware can return a typed nil value that satisfies the Result + // interface. Annotating that value panics. + if validatedMeta.usesNewProtocol && res != nil && !res.isNil() { + annotateResultType(res) annotateServerInfo(res, ss.server.impl) } return res, nil diff --git a/mcp/server_test.go b/mcp/server_test.go index 11e75e2..77dcb10 100644 --- a/mcp/server_test.go +++ b/mcp/server_test.go @@ -1399,6 +1399,22 @@ func TestServerSessionHandle_RejectsInitializeOnNewProtocol(t *testing.T) { }) } +// newProtocolParams merges fields into the _meta that opts a request into the +// sessionless protocol (SEP-2575). +func newProtocolParams(fields map[string]any) map[string]any { + params := map[string]any{ + "_meta": map[string]any{ + MetaKeyProtocolVersion: protocolVersion20260728, + MetaKeyClientInfo: map[string]any{"name": "c", "version": "1"}, + MetaKeyClientCapabilities: map[string]any{}, + }, + } + for k, v := range fields { + params[k] = v + } + return params +} + func TestServerSessionHandle_SetsResultTypeOnNewProtocol(t *testing.T) { server := NewServer(testImpl, &ServerOptions{ CompletionHandler: func(context.Context, *CompleteRequest) (*CompleteResult, error) { @@ -1424,19 +1440,19 @@ func TestServerSessionHandle_SetsResultTypeOnNewProtocol(t *testing.T) { InputRequests: InputRequestMap{"confirm": &ElicitParams{Message: "Continue?"}}, }, nil }) - newProtocolParams := func(fields map[string]any) map[string]any { - params := map[string]any{ - "_meta": map[string]any{ - MetaKeyProtocolVersion: protocolVersion20260728, - MetaKeyClientInfo: map[string]any{"name": "c", "version": "1"}, - MetaKeyClientCapabilities: map[string]any{}, - }, - } - for k, v := range fields { - params[k] = v - } - return params - } + AddTool(server, &Tool{Name: "toolComplete"}, func(context.Context, *CallToolRequest, struct{}) (*CallToolResult, any, error) { + return &CallToolResult{Content: []Content{&TextContent{Text: "ok"}}}, nil, nil + }) + server.AddPrompt(&Prompt{Name: "promptComplete"}, func(context.Context, *GetPromptRequest) (*GetPromptResult, error) { + return &GetPromptResult{ + Messages: []*PromptMessage{{Role: "assistant", Content: &TextContent{Text: "ok"}}}, + }, nil + }) + server.AddResource(&Resource{URI: "test://resource-complete", Name: "resourceComplete"}, func(context.Context, *ReadResourceRequest) (*ReadResourceResult, error) { + return &ReadResourceResult{ + Contents: []*ResourceContents{{URI: "test://resource-complete", Text: "ok"}}, + }, nil + }) tests := []struct { name string @@ -1507,40 +1523,243 @@ func TestServerSessionHandle_SetsResultTypeOnNewProtocol(t *testing.T) { params: newProtocolParams(map[string]any{"uri": "test://resource"}), want: resultTypeInputRequired, }, + { + name: "tool complete", + method: methodCallTool, + params: newProtocolParams(map[string]any{"name": "toolComplete", "arguments": map[string]any{}}), + want: resultTypeComplete, + }, + { + name: "prompt complete", + method: methodGetPrompt, + params: newProtocolParams(map[string]any{"name": "promptComplete"}), + want: resultTypeComplete, + }, + { + name: "resource complete", + method: methodReadResource, + params: newProtocolParams(map[string]any{"uri": "test://resource-complete"}), + want: resultTypeComplete, + }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { + if got := resultTypeOf(t, server, nil, tc.method, tc.params); got != string(tc.want) { + t.Errorf("resultType = %q, want %q", got, tc.want) + } + }) + } +} + +// TestServerSessionHandle_SetsResultTypeWhenMiddlewareShortCircuits is a +// regression test for #1225. User middleware wraps outside +// serverMultiRoundTripMiddleware, so a middleware that returns a result +// without calling next bypasses both that shim and the dispatcher. +func TestServerSessionHandle_SetsResultTypeWhenMiddlewareShortCircuits(t *testing.T) { + tests := []struct { + name string + method string + params map[string]any + result Result + want resultType + }{ + { + name: "tools/call", + method: methodCallTool, + params: newProtocolParams(map[string]any{"name": "tool", "arguments": map[string]any{}}), + result: &CallToolResult{Content: []Content{&TextContent{Text: "denied"}}}, + want: resultTypeComplete, + }, + { + name: "prompts/get", + method: methodGetPrompt, + params: newProtocolParams(map[string]any{"name": "prompt"}), + result: &GetPromptResult{ + Messages: []*PromptMessage{{Role: "assistant", Content: &TextContent{Text: "denied"}}}, + }, + want: resultTypeComplete, + }, + { + name: "resources/read", + method: methodReadResource, + params: newProtocolParams(map[string]any{"uri": "test://resource"}), + result: &ReadResourceResult{ + Contents: []*ResourceContents{{URI: "test://resource", Text: "denied"}}, + }, + want: resultTypeComplete, + }, + { + // setResultType is unexported, so populating InputRequests is + // the only way a middleware can ask for input. + name: "tools/call input required", + method: methodCallTool, + params: newProtocolParams(map[string]any{"name": "tool", "arguments": map[string]any{}}), + result: &CallToolResult{ + InputRequests: InputRequestMap{"confirm": &ElicitParams{Message: "Continue?"}}, + }, + want: resultTypeInputRequired, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + // Nothing is registered: if the middleware fell through to next, + // dispatch would fail rather than return a result. + server := NewServer(testImpl, nil) + server.AddReceivingMiddleware(func(next MethodHandler) MethodHandler { + return func(ctx context.Context, method string, req Request) (Result, error) { + if method == tc.method { + return tc.result, nil + } + return next(ctx, method, req) + } + }) + if got := resultTypeOf(t, server, nil, tc.method, tc.params); got != string(tc.want) { + t.Errorf("resultType = %q, want %q", got, tc.want) + } + }) + } +} + +// TestServerSessionHandle_ResultTypeGate pins when a result carries resultType. +// annotateResultType is the only setter, and handle gates it on the request's +// _meta rather than on the version the session negotiated. So the last case +// below gets no resultType even though the session speaks 2026-07-28. Only a +// client that mixes protocol versions reaches that, so it is recorded rather +// than fixed. +func TestServerSessionHandle_ResultTypeGate(t *testing.T) { + version := func(v string) func(*ServerSessionState) { + return func(s *ServerSessionState) { + s.InitializeParams = &InitializeParams{ProtocolVersion: v} + } + } + callParams := map[string]any{"name": "tool", "arguments": map[string]any{}} + + tests := []struct { + name string + state func(*ServerSessionState) + params map[string]any + want resultType // "" means the field must be absent + }{ + { + name: "legacy session, no _meta", + state: version(protocolVersion20251125), + params: callParams, + }, + { + name: "legacy session, new-protocol _meta", + state: version(protocolVersion20251125), + params: newProtocolParams(callParams), + want: resultTypeComplete, + }, + { + name: "new-protocol session, no _meta", + state: version(protocolVersion20260728), + params: callParams, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + server := NewServer(testImpl, nil) + // Short-circuit so this measures handle's gate, not the + // dispatcher's. + server.AddReceivingMiddleware(func(next MethodHandler) MethodHandler { + return func(context.Context, string, Request) (Result, error) { + return &CallToolResult{Content: []Content{&TextContent{Text: "ok"}}}, nil + } + }) + if got := resultTypeOf(t, server, tc.state, methodCallTool, tc.params); got != string(tc.want) { + t.Errorf("resultType = %q, want %q", got, tc.want) + } + }) + } +} + +// TestServerSessionHandle_NilResultFromMiddleware checks that a middleware +// returning no result does not panic. A typed nil satisfies Result, so the +// annotation helpers would otherwise dereference it. +func TestServerSessionHandle_NilResultFromMiddleware(t *testing.T) { + tests := []struct { + name string + result Result + }{ + {"nil interface", nil}, + {"typed nil", (*CallToolResult)(nil)}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + server := NewServer(testImpl, nil) + server.AddReceivingMiddleware(func(next MethodHandler) MethodHandler { + return func(context.Context, string, Request) (Result, error) { + return tc.result, nil + } + }) ss := &ServerSession{server: server} id, err := jsonrpc.MakeID("test") if err != nil { t.Fatal(err) } - result, err := ss.handle(context.Background(), &jsonrpc.Request{ + res, err := ss.handle(context.Background(), &jsonrpc.Request{ ID: id, - Method: tc.method, - Params: mustMarshal(tc.params), + Method: methodCallTool, + Params: mustMarshal(newProtocolParams(map[string]any{"name": "tool", "arguments": map[string]any{}})), }) if err != nil { - t.Fatal(err) + t.Fatalf("handle: %v", err) } - data, err := json.Marshal(result) + data, err := json.Marshal(res) if err != nil { t.Fatal(err) } - var got struct { - ResultType string `json:"resultType"` - } - if err := json.Unmarshal(data, &got); err != nil { - t.Fatal(err) - } - if got.ResultType != string(tc.want) { - t.Fatalf("resultType = %q, want %q; response = %s", got.ResultType, tc.want, data) + if got := string(data); got != "null" { + t.Errorf("result = %s, want null", got) } }) } } +// resultTypeOf handles one request on a transportless session and reports the +// resultType of the marshaled result, or "" if the field is absent. +func resultTypeOf(t *testing.T, server *Server, state func(*ServerSessionState), method string, params map[string]any) string { + t.Helper() + ss := &ServerSession{server: server} + if state != nil { + ss.updateState(state) + } + id, err := jsonrpc.MakeID("test") + if err != nil { + t.Fatal(err) + } + result, err := ss.handle(context.Background(), &jsonrpc.Request{ + ID: id, + Method: method, + Params: mustMarshal(params), + }) + if err != nil { + t.Fatal(err) + } + data, err := json.Marshal(result) + if err != nil { + t.Fatal(err) + } + var fields map[string]json.RawMessage + if err := json.Unmarshal(data, &fields); err != nil { + t.Fatal(err) + } + raw, ok := fields["resultType"] + if !ok { + return "" + } + var got string + if err := json.Unmarshal(raw, &got); err != nil { + t.Fatalf("unmarshaling resultType from %s: %v", data, err) + } + return got +} + // 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 diff --git a/mcp/shared.go b/mcp/shared.go index 2258752..6637a25 100644 --- a/mcp/shared.go +++ b/mcp/shared.go @@ -809,6 +809,9 @@ type Result interface { // isResult discourages implementation of Result outside of this package. isResult() + // isNil returns true if the underlying value is nil. + isNil() bool + // GetMeta returns metadata from a value. GetMeta() map[string]any // SetMeta sets the metadata on a value. @@ -827,13 +830,15 @@ type ResultBase struct { Meta `json:"_meta,omitempty"` } -func (*ResultBase) isResult() {} +func (*ResultBase) isResult() {} +func (x *ResultBase) isNil() bool { return x == nil } // emptyResult is returned by methods that have no result, like ping. // Those methods cannot return nil, because jsonrpc2 cannot handle nils. type emptyResult struct{} func (*emptyResult) isResult() {} +func (x *emptyResult) isNil() bool { return x == nil } func (*emptyResult) GetMeta() map[string]any { panic("should never be called") } func (*emptyResult) SetMeta(map[string]any) { panic("should never be called") } From 3210c2b34cacd6e80305b8216779bfe6c964d720 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20M=2E=20Requena=20Plens?= Date: Mon, 14 Sep 2026 13:33:30 +0200 Subject: [PATCH 60/70] mcp: carry a cancelled notification's reason as the request's cancel cause (#1255) The canceller read `CancelledParams.Reason` off the wire and dropped it: `Connection.Cancel` cancelled the request's context with a nil cause, so a handler that looked at `context.Cause` saw a bare `context.Canceled` and nothing of what the peer said. The specification asks implementations to log cancellation reasons, and the handler is the one place that can act on one; #1100 had already given the request context a cause, so the reason has a natural place to travel. `Connection.CancelCause` is `Cancel` with a cause. The canceller now cancels with an error that carries the peer's reason and unwraps to `context.Canceled`, so `errors.Is` keeps classifying the cancellation as one while `context.Cause` reads `request cancelled by the peer: `. It also logs the id and the reason at debug level through the logger the connection already carries, which is the SDK's own answer to the SHOULD. No exported API changes: the cause type stays unexported and is read through `context.Cause` and `Error()`. `TestCancellation` now asserts the cause the SDK's own client produces (its context's error, which the client sends as the reason), and `TestCancellationReason` writes the call and the cancelled notification to the connection directly with a reason the client would never send, on a session pinned to 2025-11-25 so the hand-written request needs no `_meta`. Both fail without the change. Fixes #1254 Co-authored-by: Guglielmo Colombo --- internal/jsonrpc2/conn.go | 9 ++++- mcp/mcp_test.go | 71 +++++++++++++++++++++++++++++++++++++-- mcp/transport.go | 29 ++++++++++++++-- 3 files changed, 102 insertions(+), 7 deletions(-) diff --git a/internal/jsonrpc2/conn.go b/internal/jsonrpc2/conn.go index 5849a87..94da6ae 100644 --- a/internal/jsonrpc2/conn.go +++ b/internal/jsonrpc2/conn.go @@ -453,12 +453,19 @@ func (ac *AsyncCall) Await(ctx context.Context, result any) error { // will not cause any messages that have not arrived yet with that ID to be // cancelled. func (c *Connection) Cancel(id ID) { + c.CancelCause(id, nil) +} + +// CancelCause is like [Connection.Cancel], but records cause as the reason +// the Context was cancelled, so that the Handle call can read it back through +// [context.Cause]. A nil cause reads as [context.Canceled]. +func (c *Connection) CancelCause(id ID, cause error) { var req *incomingRequest c.updateInFlight(func(s *inFlightState) { req = s.incomingByID[id] }) if req != nil { - req.cancel(nil) + req.cancel(cause) } } diff --git a/mcp/mcp_test.go b/mcp/mcp_test.go index e1f8c0f..bb76c1c 100644 --- a/mcp/mcp_test.go +++ b/mcp/mcp_test.go @@ -710,13 +710,13 @@ func TestCancellation(t *testing.T) { synctest.Test(t, func(t *testing.T) { var ( start = make(chan struct{}) - cancelled = make(chan struct{}, 1) // don't block the request + cancelled = make(chan error, 1) // don't block the request ) slowTool := func(ctx context.Context, req *CallToolRequest, args any) (*CallToolResult, any, error) { start <- struct{}{} select { case <-ctx.Done(): - cancelled <- struct{}{} + cancelled <- context.Cause(ctx) case <-time.After(5 * time.Second): return nil, nil, nil } @@ -732,7 +732,72 @@ func TestCancellation(t *testing.T) { <-start cancel() - <-cancelled + // The client sends its context's error as the reason of its cancelled + // notification, and the handler reads it back as the cause. + cause := <-cancelled + if !errors.Is(cause, context.Canceled) { + t.Errorf("context.Cause = %v, want it to wrap context.Canceled", cause) + } + if want := "request cancelled by the peer: " + context.Canceled.Error(); cause == nil || cause.Error() != want { + t.Errorf("context.Cause = %v, want %q", cause, want) + } + }) +} + +// TestCancellationReason verifies that the reason a peer gives in its +// cancelled notification reaches the handler as the cause of its context, +// which is what lets a server log it as the specification asks. +// +// The call and the notification are written to the connection directly, so +// the reason is one the SDK's own client would never send; the session is +// pinned to 2025-11-25 so that a request written that way needs no _meta. +func TestCancellationReason(t *testing.T) { + synctest.Test(t, func(t *testing.T) { + var ( + start = make(chan struct{}) + cancelled = make(chan error, 1) // don't block the request + ) + slowTool := func(ctx context.Context, req *CallToolRequest, args any) (*CallToolResult, any, error) { + start <- struct{}{} + select { + case <-ctx.Done(): + cancelled <- context.Cause(ctx) + case <-time.After(5 * time.Second): + cancelled <- nil + } + return nil, nil, nil + } + ctx := context.Background() + ct, st := NewInMemoryTransports() + s := NewServer(testImpl, nil) + AddTool(s, &Tool{Name: "slow", InputSchema: &jsonschema.Schema{Type: "object"}}, slowTool) + ss, err := s.Connect(ctx, st, nil) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = ss.Close() }) + cs, err := NewClient(testImpl, nil).Connect(ctx, ct, &ClientSessionOptions{ProtocolVersion: protocolVersion20251125}) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = cs.Close() }) + + call := cs.conn.Call(ctx, methodCallTool, &CallToolParams{Name: "slow"}) + <-start + if err := cs.conn.Notify(ctx, notificationCancelled, &CancelledParams{RequestID: call.ID().Raw(), Reason: "user asked"}); err != nil { + t.Fatal(err) + } + + cause := <-cancelled + if cause == nil { + t.Fatal("the tool ran to completion, want it cancelled") + } + if !errors.Is(cause, context.Canceled) { + t.Errorf("context.Cause = %v, want it to wrap context.Canceled", cause) + } + if got, want := cause.Error(), "request cancelled by the peer: user asked"; got != want { + t.Errorf("context.Cause = %q, want %q", got, want) + } }) } diff --git a/mcp/transport.go b/mcp/transport.go index 8b6cd14..ce841f2 100644 --- a/mcp/transport.go +++ b/mcp/transport.go @@ -207,7 +207,7 @@ func connect[H handler, State any](ctx context.Context, t Transport, b binder[H, reader, writer := jsonrpc2.Reader(mcpConn), jsonrpc2.Writer(mcpConn) var ( h H - preempter canceller + preempter = canceller{logger: logger} ) bind := func(conn *jsonrpc2.Connection) jsonrpc2.Handler { h = b.bind(mcpConn, conn, s, onClose) @@ -252,7 +252,8 @@ type cancellationPropagator interface { // A canceller is a jsonrpc2.Preempter that cancels in-flight requests on MCP // cancelled notifications. type canceller struct { - conn *jsonrpc2.Connection + conn *jsonrpc2.Connection + logger *slog.Logger } // Preempt implements [jsonrpc2.Preempter]. @@ -266,11 +267,33 @@ func (c *canceller) Preempt(ctx context.Context, req *jsonrpc.Request) (result a if err != nil { return nil, err } - go c.conn.Cancel(id) + // The spec says implementations should log cancellation reasons, and + // the handler is the one place that can act on one, so the reason + // travels as the cause of the request's context rather than being + // dropped here. + c.logger.Debug("request cancelled by the peer", "id", id.Raw(), "reason", params.Reason) + go c.conn.CancelCause(id, &peerCancelledError{reason: params.Reason}) } return nil, jsonrpc2.ErrNotHandled } +// A peerCancelledError is the cause a request's context carries once the peer +// has sent a cancelled notification for it: [context.Cause] returns it to the +// handler with the reason the peer gave, and it unwraps to [context.Canceled] +// so that [errors.Is] keeps classifying the cancellation as one. +type peerCancelledError struct { + reason string +} + +func (e *peerCancelledError) Error() string { + if e.reason == "" { + return "request cancelled by the peer" + } + return "request cancelled by the peer: " + e.reason +} + +func (e *peerCancelledError) Unwrap() error { return context.Canceled } + // callSubscriptionsListen issues a "subscriptions/listen" call (SEP-2575) // without awaiting its JSON-RPC response. The call's logical lifetime is the // stream of notifications that follow on the same channel — the empty From a82c86a6cd9d357e56e57cd93abf90f950f59abd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20M=2E=20Requena=20Plens?= Date: Mon, 14 Sep 2026 17:49:53 +0200 Subject: [PATCH 61/70] mcp: classify the per-request protocol version by membership (#1268) `validateRequestMeta` decided whether a request follows the 2026-07-28 protocol by comparing `_meta.protocolVersion` against `2026-07-28` with `<`. This replaces that comparison with a membership test against `supportedProtocolVersions`, which is the rule `mcp/streamable.go:354` already applies to the `Mcp-Protocol-Version` header. Nothing changes for a version the SDK supports: one predating 2026-07-28 still selects the legacy handshake, and one at 2026-07-28 or later is still validated as the full `_meta` triple. What changes is a version the SDK does not support at all. It now reports `usesNewProtocol`, so it reaches the `CodeUnsupportedProtocolVersion` check in `(*ServerSession).handle` and is answered with the error the versioning page requires. Driving a stdio server with `_meta.protocolVersion` set to `1900-01-01`, the worked example on that page, before: ```json {"jsonrpc":"2.0","id":1,"error":{"code":0,"message":"method \"tools/list\" is invalid during session initialization"}} ``` and after: ```json {"jsonrpc":"2.0","id":1,"error":{"code":-32022,"message":"unsupported protocol version","data":{"supported":["2026-07-28","2025-11-25","2025-06-18","2025-03-26","2024-11-05"],"requested":"1900-01-01"}}} ``` There are two judgement calls in the diff, and I would rather state them than leave them to be found. The first is that an unsupported version returns early with `initializeParams` carrying only that version, and the rest of the `_meta` triple is left unvalidated. The shape of `_meta` is defined by the revision the request names, and that is a revision this SDK does not know, so there is nothing to validate it against; answering "missing `io.modelcontextprotocol/clientCapabilities`" would send the client off to fix the wrong thing while its real problem is the version. The partly populated value cannot escape: `handle` runs the unsupported check before anything reads the capabilities, and `Server.protocolVersions` is always a subset of `supportedProtocolVersions`, so a version that fails the membership test in `validateRequestMeta` always fails it in `handle` too. The second is that an empty `protocolVersion` still means "no version declared" rather than "unsupported version". `mcp/streamable.go` already reads the field that way when it decides whether a request carries a version at all (`metaVersion != ""`), and rejecting the empty string here would put the two layers back into disagreement, in the other direction. The `!ok` and `== ""` cases are therefore handled together. **Behaviour an existing user would see change.** A request whose `_meta.protocolVersion` is a string not in `SupportedProtocolVersions()` is now refused with `-32022`, where before a value sorting below `2026-07-28` was treated as a legacy request: served if the session was already initialized, refused with the session-initialization error if it was not. No published protocol revision is in that set, so a client speaking any released version of MCP sees no difference. A deployment that put a sentinel or placeholder version in `_meta` and relied on it being ignored would see those requests refused, which I believe is the right answer but is a visible change. This is not limited to stdio, and I want to be precise about where the header gate does and does not stand in front of it. The gate at `mcp/streamable.go:354` refuses an unsupported `Mcp-Protocol-Version` only when that version **also** sorts below `2026-07-28`, so an unknown version sorting above it (`2027-01-01`, say) passes the gate and reaches this classification; the deprecated HTTP+SSE transport in `mcp/sse.go` has no header check at all; and stdio, the in-memory transport and any custom transport have none either. The window the old comparison left open is therefore reachable on every transport, and a value sorting above `2026-07-28` with an incomplete `_meta` triple also changes answer, from `-32602` to `-32022`. **The test that pins it** is `TestServerUnknownProtocolVersion_NewProtocol` in `mcp/server_test.go`. It drives `(*ServerSession).handle` with a `tools/list` request whose `_meta.protocolVersion` names a version the SDK does not know, and asserts that the returned error is a `*jsonrpc.Error` with code `CodeUnsupportedProtocolVersion`, `Data.Requested` equal to the version asked for, and `Data.Supported` equal to `SupportedProtocolVersions()`. Four cases: `1900-01-01`, `2025-11-24`, `1.0`, and `1900-01-01` again with the rest of the triple omitted, which is the case that pins the first judgement call above. Before the change all four fail with `handle returned method "tools/list" is invalid during session initialization, want a *jsonrpc.Error`. `TestValidateRequestMeta` in `mcp/shared_test.go` gains four cases at the unit level: two that fail before the change, and two that guard the classifications which must not move, a supported pre-2026-07-28 version and an empty one. `go test ./...` and `gofmt -l .` are both clean. Fixes #1260. --- mcp/server_test.go | 58 ++++++++++++++++++++++++++++++++++++++++++++++ mcp/shared.go | 39 ++++++++++++++++++++++++------- mcp/shared_test.go | 51 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 140 insertions(+), 8 deletions(-) diff --git a/mcp/server_test.go b/mcp/server_test.go index 77dcb10..1544e29 100644 --- a/mcp/server_test.go +++ b/mcp/server_test.go @@ -2151,3 +2151,61 @@ func TestServerSupportedProtocolVersions_NewProtocol(t *testing.T) { t.Errorf("UnsupportedProtocolVersionData.Supported mismatch (-want +got):\n%s", diff) } } + +// TestServerUnknownProtocolVersion_NewProtocol verifies that a request whose +// `_meta.protocolVersion` names a version the SDK does not know is rejected +// with [CodeUnsupportedProtocolVersion], and not served as a legacy handshake +// because its version string happens to sort below 2026-07-28. +func TestServerUnknownProtocolVersion_NewProtocol(t *testing.T) { + ctx := context.Background() + // Each case names a version that is not in [supportedProtocolVersions] and + // sorts below 2026-07-28. + tests := []struct { + name string + version string + extraMeta string // further `_meta` entries, each with a leading comma + }{ + // The versioning spec uses 1900-01-01 to illustrate this error. + {"documented example", "1900-01-01", fmt.Sprintf(`,%q:{}`, MetaKeyClientCapabilities)}, + {"unreleased revision", "2025-11-24", fmt.Sprintf(`,%q:{}`, MetaKeyClientCapabilities)}, + {"not a date", "1.0", fmt.Sprintf(`,%q:{}`, MetaKeyClientCapabilities)}, + // The rest of the `_meta` triple is defined by the revision the request + // names, so its absence must not mask the version itself. + {"no clientCapabilities", "1900-01-01", ""}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + server := NewServer(testImpl, nil) + _, st := NewInMemoryTransports() + ss, err := server.Connect(ctx, st, nil) + if err != nil { + t.Fatalf("server.Connect: %v", err) + } + defer ss.Close() + + params := fmt.Sprintf(`{"_meta":{%q:%q%s}}`, MetaKeyProtocolVersion, tc.version, tc.extraMeta) + _, err = ss.handle(ctx, &jsonrpc.Request{ + ID: jsonrpc2.Int64ID(1), + Method: methodListTools, + Params: json.RawMessage(params), + }) + var jerr *jsonrpc.Error + if !errors.As(err, &jerr) { + t.Fatalf("handle returned %v, want a *jsonrpc.Error", err) + } + if jerr.Code != CodeUnsupportedProtocolVersion { + t.Fatalf("error code = %d, want %d", jerr.Code, CodeUnsupportedProtocolVersion) + } + var data UnsupportedProtocolVersionData + if err := json.Unmarshal(jerr.Data, &data); err != nil { + t.Fatalf("unmarshal error data: %v", err) + } + if data.Requested != tc.version { + t.Errorf("UnsupportedProtocolVersionData.Requested = %q, want %q", data.Requested, tc.version) + } + if diff := cmp.Diff(SupportedProtocolVersions(), data.Supported); diff != "" { + t.Errorf("UnsupportedProtocolVersionData.Supported mismatch (-want +got):\n%s", diff) + } + }) + } +} diff --git a/mcp/shared.go b/mcp/shared.go index 6637a25..75dfeee 100644 --- a/mcp/shared.go +++ b/mcp/shared.go @@ -553,13 +553,19 @@ type validatedMeta struct { // validateRequestMeta inspects a JSON-RPC request to detect whether it follows // the >= 2026-07-28 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 it validates the presence of -// clientCapabilities in _meta. If it is missing or invalid, it returns nil and -// a non-nil error. clientInfo is optional; if present but invalid, an error is -// returned. Otherwise, it returns usesNewProtocol set to true and the populated -// initializeParams. +// If the request has no _meta, no protocolVersion in _meta, or a protocolVersion +// naming a supported version that predates 2026-07-28, it returns a non-nil +// validatedMeta with usesNewProtocol set to false, and a nil error: such a request +// belongs to the initialize handshake. +// If the protocolVersion names a version that is not supported at all, it returns +// usesNewProtocol set to true with initializeParams carrying only that version, so +// that the caller answers with [CodeUnsupportedProtocolVersion]. The rest of the +// `_meta` triple is not validated in that case, since the revision defining it is +// unknown. +// Otherwise, it validates the presence of clientCapabilities in _meta. If it is +// missing or invalid, it returns nil and a non-nil error. clientInfo is optional; +// if present but invalid, an error is returned. Otherwise, it returns +// usesNewProtocol set to true and the populated initializeParams. // Notifications always report usesNewProtocol false and never error. func validateRequestMeta(req *jsonrpc.Request) (*validatedMeta, error) { // SEP-2575 defines the `_meta` triple for calls only: NotificationParams @@ -574,9 +580,26 @@ func validateRequestMeta(req *jsonrpc.Request) (*validatedMeta, error) { return &validatedMeta{usesNewProtocol: false, initializeParams: nil}, nil } protocolVersion, ok := meta[MetaKeyProtocolVersion].(string) - if !ok || protocolVersion < protocolVersion20260728 { + // An absent or empty value declares no version at all, which is also how the + // streamable handler reads `_meta.protocolVersion`. + if !ok || protocolVersion == "" { return &validatedMeta{usesNewProtocol: false, initializeParams: nil}, nil } + // Classify by membership rather than by ordering: a version the SDK does not + // know sorts arbitrarily against 2026-07-28, and one that sorts below it would + // be served as a legacy handshake instead of reaching the + // CodeUnsupportedProtocolVersion check in ServerSession.handle. The + // streamable handler already applies the same membership rule to the + // Mcp-Protocol-Version header. + supported := slices.Contains(supportedProtocolVersions, protocolVersion) + if supported && protocolVersion < protocolVersion20260728 { + return &validatedMeta{usesNewProtocol: false, initializeParams: nil}, nil + } + if !supported { + return &validatedMeta{usesNewProtocol: true, initializeParams: &InitializeParams{ + ProtocolVersion: protocolVersion, + }}, nil + } var clientInfo *Implementation if _, present := meta[MetaKeyClientInfo]; present { var ok bool diff --git a/mcp/shared_test.go b/mcp/shared_test.go index 71a071d..d308c87 100644 --- a/mcp/shared_test.go +++ b/mcp/shared_test.go @@ -102,6 +102,57 @@ func TestValidateRequestMeta(t *testing.T) { params: json.RawMessage(`{"_meta": "not an object", "name": "x"}`), wantUsesNew: false, }, + { + name: "supported pre-2026-07-28 version: old protocol", + method: methodCallTool, + params: map[string]any{ + "_meta": map[string]any{ + MetaKeyProtocolVersion: protocolVersion20251125, + }, + "name": "x", + }, + wantUsesNew: false, + }, + { + name: "empty protocolVersion declares nothing: old protocol", + method: methodCallTool, + params: map[string]any{ + "_meta": map[string]any{ + MetaKeyProtocolVersion: "", + }, + "name": "x", + }, + wantUsesNew: false, + }, + { + // An unsupported version sorting below 2026-07-28 is still a + // new-protocol request, so that the caller can answer it with + // CodeUnsupportedProtocolVersion. + name: "unsupported version: new protocol", + method: methodCallTool, + params: map[string]any{ + "_meta": map[string]any{ + MetaKeyProtocolVersion: "1900-01-01", + MetaKeyClientInfo: map[string]any{"name": "c", "version": "1"}, + MetaKeyClientCapabilities: map[string]any{}, + }, + "name": "x", + }, + wantUsesNew: true, + }, + { + // The triple is defined by a revision the SDK does not know, so an + // unsupported version is reported without validating the rest of it. + name: "unsupported version missing clientCapabilities: new protocol", + method: methodCallTool, + params: map[string]any{ + "_meta": map[string]any{ + MetaKeyProtocolVersion: "1900-01-01", + }, + "name": "x", + }, + wantUsesNew: true, + }, { name: "new protocol with logLevel", method: methodCallTool, From 3f43bcf012e45e701bfb91243d439edab44c4238 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20M=2E=20Requena=20Plens?= Date: Mon, 14 Sep 2026 18:45:48 +0200 Subject: [PATCH 62/70] mcp: add HasParams so middleware can tell whether params are present (#1269) This adds `mcp.HasParams(req Request) bool`, so a receiving middleware can ask whether the message it was handed carried a `params` member. The member is optional for several methods: `serverMethodInfos` sets `missingParamsOK` for `server/discover`, `ping`, the four list methods and the `cancelled`, `initialized` and `roots/list_changed` notifications, so `{"jsonrpc":"2.0","id":1,"method":"tools/list"}` is a complete request. For one of those, `unmarshalParams` returns a nil `Params`, `newRequest` leaves `ServerRequest.Params` at the zero value of `P`, and `GetParams` hands the middleware a non-nil interface holding a typed nil pointer. Comparing that against nil says params are present, and the first method called on it dereferences: `GetMeta` is promoted from the embedded `Meta`. `Params` already declares `isNil` for precisely this case, and it is unexported, so from outside the package the accessor named after the question is the one thing that cannot be reached. `HasParams` is a function over `Request` rather than an exported method on `Params` for two reasons. A middleware holds a `Request`, and this way the caller never has to hold the value that panics. More importantly, `isNil` cannot answer the question for every `Params` value, so exporting it would ship a predicate that is itself unsafe: a params type declared the documented way for a custom method, `struct{ mcp.ParamsBase }`, promotes `isNil` through a field selector, and that dereferences the nil outer pointer before the method body runs. The predicate therefore has to be computed from outside the value, and `reflect.Value.IsNil` is what works for all of them. Three judgement calls, stated so nobody has to find them. First, `getRequestMeta` now goes through `HasParams`. That is a second fix rather than a refactor: `AddReceivingCustomMethod` registers with `missingParamsOK` too, so a client omitting `params` for a custom method made `ServerRequest.ProtocolVersion`, `ClientInfo` and `ClientCapabilities` panic in the promoted `isNil`. They now return `""`, `nil` and `nil`. I could have left it out to keep the diff to one function, but then the SDK would export a predicate that says to ask before touching params while three of its own accessors still panicked on the same value. If you would rather see it as a separate commit, say so and I will split it. Second, I did not add the predicate to the `Request` interface. `Request` is sealed by `isRequest`, so a method would have been a compatible change and would read slightly better at the call site, but a free function leaves the interface alone and serves every `Request` just as well. Third, this reflects once per call of `getRequestMeta`. It is one `reflect.ValueOf` on a value that is already an interface, on a path where `newResult` already reflects per request. Tests, both in `mcp/shared_test.go`. `TestServerRequest_PerRequestAccessors_MissingParams` is the one that fails before the change and passes after: on `main` it panics inside `getRequestMeta` with `invalid memory address or nil pointer dereference` from `(*customParams).isNil`, and here it asserts the three accessors return zero values. `TestHasParams` covers the exported behaviour: it builds exactly what `handleReceive` hands the middleware chain for `tools/list`, `prompts/list`, `resources/list` and `notifications/initialized` with no `params` member, asserts that `GetParams` still returns a non-nil interface so the case under test is still the typed-nil one, asserts `HasParams` reports false, and asserts it reports true when params are present. Behaviour an existing user would see change: only the three accessors above, and only for a custom-method params type built on `ParamsBase` whose `params` member was omitted, where a panic becomes a zero value. Nothing about the wire format changes, no exported signature moves, and no interface gains a method. A middleware that still writes `req.GetParams() != nil` and then dereferences still panics; this gives a caller the way to ask, it does not rescue a caller that does not. Also here: one sentence on the doc comments of both `AddReceivingMiddleware` methods saying a received message need not carry params, since that is where a middleware author is reading, and `examples/server/middleware/main.go` now logs `has_params` with `mcp.HasParams` instead of the comparison that cannot work. The example's printed output is unchanged, because the SDK client always sends a `params` member for the methods it drives. `go test ./...` and `gofmt -l .` are clean. One thing to say plainly, since it is yours to decide and not mine: this adds an exported symbol. `HasParams` is a new name in the public API of `mcp`, and everything else here is doc comments, an example and the three accessors. If you would rather not grow the surface for this, the smaller answer is to keep the `getRequestMeta` fix and the doc-comment sentence and drop the exported function, and a middleware author then has the guarantee written down where they are reading even though they still cannot ask the question in code. I prefer the function because the guarantee alone leaves every middleware writing the same three lines of `reflect`, and the one that does not write them panics on an ordinary `tools/list`, but I will cut it to whichever shape you want. Fixes #1261. Co-authored-by: Guglielmo Colombo --- examples/server/middleware/main.go | 2 +- mcp/client.go | 3 ++ mcp/server.go | 3 ++ mcp/shared.go | 27 ++++++++++++-- mcp/shared_test.go | 56 ++++++++++++++++++++++++++++++ 5 files changed, 88 insertions(+), 3 deletions(-) diff --git a/examples/server/middleware/main.go b/examples/server/middleware/main.go index 4e37471..262d726 100644 --- a/examples/server/middleware/main.go +++ b/examples/server/middleware/main.go @@ -38,7 +38,7 @@ func main() { logger.Info("MCP method started", "method", method, "session_id", req.GetSession().ID(), - "has_params", req.GetParams() != nil, + "has_params", mcp.HasParams(req), ) // Log more for tool calls. if ctr, ok := req.(*mcp.CallToolRequest); ok { diff --git a/mcp/client.go b/mcp/client.go index c11f0c8..1ee13e8 100644 --- a/mcp/client.go +++ b/mcp/client.go @@ -1147,6 +1147,9 @@ func (c *Client) AddSendingMiddleware(middleware ...Middleware) { // // Receiving middleware is called when a request is received. It is useful for tasks // such as authentication, request logging and metrics. +// +// A received message need not carry params: use [HasParams] before inspecting +// [Request.GetParams]. func (c *Client) AddReceivingMiddleware(middleware ...Middleware) { c.mu.Lock() defer c.mu.Unlock() diff --git a/mcp/server.go b/mcp/server.go index 183a519..442d7bc 100644 --- a/mcp/server.go +++ b/mcp/server.go @@ -1857,6 +1857,9 @@ func (s *Server) AddSendingMiddleware(middleware ...Middleware) { // // Receiving middleware is called when a request is received. It is useful for tasks // such as authentication, request logging and metrics. +// +// A received message need not carry params: use [HasParams] before inspecting +// [Request.GetParams]. func (s *Server) AddReceivingMiddleware(middleware ...Middleware) { s.mu.Lock() defer s.mu.Unlock() diff --git a/mcp/shared.go b/mcp/shared.go index 75dfeee..cacfa75 100644 --- a/mcp/shared.go +++ b/mcp/shared.go @@ -688,6 +688,30 @@ 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 } +// HasParams reports whether req carries params. +// +// The "params" member of a message may be omitted for several methods, among +// them the list methods, ping and notifications/initialized. For such a request +// [Request.GetParams] returns a non-nil [Params] holding a nil pointer, so +// comparing its result against nil reports that params are present, while +// calling a method on that result panics. Middleware that inspects params must +// ask this first: +// +// if mcp.HasParams(req) { +// log.Println(method, req.GetParams().GetMeta()) +// } +func HasParams(req Request) bool { + params := req.GetParams() + if params == nil { + return false + } + // isNil cannot answer this for every value: a params type embedding + // ParamsBase promotes isNil through a field selector, which dereferences + // the nil outer pointer before the method body runs. + v := reflect.ValueOf(params) + return v.Kind() != reflect.Pointer || !v.IsNil() +} + // ProtocolVersion returns the protocol version negotiated for this request. // // For requests following the >= 2026-07-28 protocol, the value is read from @@ -749,8 +773,7 @@ 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. - if any(r.Params) == nil || r.Params.isNil() { + if !HasParams(r) { return nil } return r.Params.GetMeta() diff --git a/mcp/shared_test.go b/mcp/shared_test.go index d308c87..4566768 100644 --- a/mcp/shared_test.go +++ b/mcp/shared_test.go @@ -324,6 +324,62 @@ func TestServerRequest_PerRequestAccessors_Empty(t *testing.T) { } } +func TestHasParams(t *testing.T) { + // {"jsonrpc":"2.0","id":1,"method":"tools/list"} is a complete request: + // serverMethodInfos marks these methods missingParamsOK. Build what + // handleReceive hands the middleware chain for one. + for _, method := range []string{ + methodListTools, + methodListPrompts, + methodListResources, + notificationInitialized, + } { + t.Run(method, func(t *testing.T) { + info := serverMethodInfos[method] + params, err := info.unmarshalParams(nil) + if err != nil { + t.Fatal(err) + } + req := info.newRequest(&ServerSession{}, params, nil) + if req.GetParams() == nil { + t.Fatal("GetParams returned a nil interface, so this test no longer covers the typed-nil case") + } + if HasParams(req) { + t.Error("HasParams = true for a request whose params member was omitted") + } + }) + } + + t.Run("params present", func(t *testing.T) { + info := serverMethodInfos[methodListTools] + params, err := info.unmarshalParams(json.RawMessage(`{"cursor":"c"}`)) + if err != nil { + t.Fatal(err) + } + req := info.newRequest(&ServerSession{}, params, nil) + if !HasParams(req) { + t.Error("HasParams = false for a request carrying params") + } + }) +} + +func TestServerRequest_PerRequestAccessors_MissingParams(t *testing.T) { + // Custom methods also allow the params member to be omitted, and their + // params types embed ParamsBase, whose promoted isNil dereferences the + // typed-nil outer pointer. + type customParams struct{ ParamsBase } + req := &ServerRequest[*customParams]{} + 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 TestImplementationDescriptionJSON(t *testing.T) { impl := &Implementation{ Name: "greeter", From 0c0eb236257200d913d04e658fd2f2e5cefa7832 Mon Sep 17 00:00:00 2001 From: rishabh Date: Mon, 14 Sep 2026 13:20:25 -0400 Subject: [PATCH 63/70] docs/client: fix the SEP-1034 and SEP-1330 links (#1252) The "Schema defaults and enums" section of docs/client.md links SEP-1034 to https://modelcontextprotocol.io/seps/1034 and SEP-1330 to https://modelcontextprotocol.io/seps/1330, and both of those bare-number URLs are dead. modelcontextprotocol.io serves SEPs at number-plus-slug URLs, which is what every other SEP link in this repo already uses (seps/2575-stateless-mcp, seps/2577-deprecate-roots-sampling-and-logging, seps/2596-feature-lifecycle-and-deprecation-policy, seps/2207-oidc-refresh-token-guidance); these two were the only bare-number ones left. I took the replacement slugs from the site's own sitemap.xml, so the `1034--` double hyphen is what is published rather than a typo here. curl -sIL on the two old URLs returns 404 and 404, and on the two new ones 200 and 200. The edit is in internal/docs/client.src.md with docs/client.md regenerated by `go generate ./internal/docs`, and `go build ./... && go test ./...` pass. Co-authored-by: Guglielmo Colombo --- docs/client.md | 4 ++-- internal/docs/client.src.md | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/client.md b/docs/client.md index ad71c11..23d57be 100644 --- a/docs/client.md +++ b/docs/client.md @@ -304,7 +304,7 @@ func Example_elicitation() { `ElicitParams.RequestedSchema` is a flat schema of primitive fields, which the client renders as a form. Two field keywords shape that form. -A `Default` ([SEP-1034](https://modelcontextprotocol.io/seps/1034)) prefills a +A `Default` ([SEP-1034](https://modelcontextprotocol.io/seps/1034--support-default-values-for-all-primitive-types-in)) prefills a field. When the user accepts without supplying it, the SDK fills the field in from the schema before the result reaches either side's caller — the client does so after its elicitation handler returns, and `ServerSession.Elicit` does @@ -313,7 +313,7 @@ defaulted field `Required` defeats it: accepted content is validated against the schema before defaults are applied, so an answer that omits the field is rejected rather than defaulted. -An `Enum` ([SEP-1330](https://modelcontextprotocol.io/seps/1330)) restricts a +An `Enum` ([SEP-1330](https://modelcontextprotocol.io/seps/1330-elicitation-enum-schema-improvements-and-standards)) restricts a field to a fixed set of values, which the client renders as a choice. Enums are supported only on `"string"` fields; declaring one on another type is rejected. To label the choices, set the legacy `enumNames` keyword through diff --git a/internal/docs/client.src.md b/internal/docs/client.src.md index fdaef2a..c0f64b4 100644 --- a/internal/docs/client.src.md +++ b/internal/docs/client.src.md @@ -99,7 +99,7 @@ pattern. `ElicitParams.RequestedSchema` is a flat schema of primitive fields, which the client renders as a form. Two field keywords shape that form. -A `Default` ([SEP-1034](https://modelcontextprotocol.io/seps/1034)) prefills a +A `Default` ([SEP-1034](https://modelcontextprotocol.io/seps/1034--support-default-values-for-all-primitive-types-in)) prefills a field. When the user accepts without supplying it, the SDK fills the field in from the schema before the result reaches either side's caller — the client does so after its elicitation handler returns, and `ServerSession.Elicit` does @@ -108,7 +108,7 @@ defaulted field `Required` defeats it: accepted content is validated against the schema before defaults are applied, so an answer that omits the field is rejected rather than defaulted. -An `Enum` ([SEP-1330](https://modelcontextprotocol.io/seps/1330)) restricts a +An `Enum` ([SEP-1330](https://modelcontextprotocol.io/seps/1330-elicitation-enum-schema-improvements-and-standards)) restricts a field to a fixed set of values, which the client renders as a choice. Enums are supported only on `"string"` fields; declaring one on another type is rejected. To label the choices, set the legacy `enumNames` keyword through From c1918e19ad93014def1ef1dcf4a136783b6de9d0 Mon Sep 17 00:00:00 2001 From: jackyyy <142072937+yyydddkkk@users.noreply.github.com> Date: Tue, 15 Sep 2026 17:17:47 +0800 Subject: [PATCH 64/70] mcp: preserve SSE events buffered after the endpoint (#1270) SSEClientTransport.Connect creates one event scanner to read the endpoint event and a second scanner to read subsequent messages. Each scanner owns a buffered reader, so bytes read ahead by the first scanner are discarded when it is abandoned. A message coalesced with the endpoint can therefore disappear while the connection remains open. Reuse the event scanner across the endpoint handshake and the message-reading goroutine. This preserves unread bytes without changing the public API. The regression test exercises Connect and Read with a controlled HTTP response body, covering both a message coalesced with the endpoint and a message arriving after Connect returns. On unmodified main, the separate-message control passes and the coalesced case times out; both pass with the fix. Validation on linux/amd64 with Go 1.26.8: - go test ./... - go test -race ./... - go vet ./... - staticcheck v0.6.1 ./... - gofmt and git diff --check The HTTP response boundary is simulated; no third-party server or proxy was used. This change concerns the legacy SSE transport. The Streamable HTTP conformance suite was not run. --- mcp/sse.go | 6 ++++-- mcp/sse_test.go | 49 +++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 53 insertions(+), 2 deletions(-) diff --git a/mcp/sse.go b/mcp/sse.go index 0a5bf7f..f5e90b8 100644 --- a/mcp/sse.go +++ b/mcp/sse.go @@ -437,9 +437,11 @@ func (c *SSEClientTransport) Connect(ctx context.Context) (Connection, error) { maxEventSize = DefaultMaxEventSize } + // Reuse the scanner so events buffered after the endpoint are not lost. + events := scanEventsLimited(resp.Body, maxEventSize) msgEndpoint, err := func() (*url.URL, error) { var evt Event - for evt, err = range scanEventsLimited(resp.Body, maxEventSize) { + for evt, err = range events { break } if err != nil { @@ -468,7 +470,7 @@ func (c *SSEClientTransport) Connect(ctx context.Context) (Connection, error) { go func() { defer s.Close() // close the transport when the GET exits - for evt, err := range scanEventsLimited(resp.Body, maxEventSize) { + for evt, err := range events { if err != nil { return } diff --git a/mcp/sse_test.go b/mcp/sse_test.go index de10eb3..d9b7a2b 100644 --- a/mcp/sse_test.go +++ b/mcp/sse_test.go @@ -13,10 +13,13 @@ import ( "net/http" "net/http/httptest" "slices" + "strings" "sync/atomic" "testing" + "time" "github.com/google/go-cmp/cmp" + "github.com/modelcontextprotocol/go-sdk/jsonrpc" ) // TestSSEServerTransport_SupportedVersions verifies that the deprecated @@ -432,3 +435,49 @@ func TestSSEServerTransportMaxRequestBodyBytes(t *testing.T) { }) } } + +func TestSSEClientTransportBufferedEvents(t *testing.T) { + const endpoint = "event: endpoint\ndata: /messages\n\n" + const message = "event: message\ndata: {\"jsonrpc\":\"2.0\",\"method\":\"notifications/tools/list_changed\"}\n\n" + for _, coalesced := range []bool{false, true} { + t.Run(fmt.Sprintf("coalesced=%t", coalesced), func(t *testing.T) { + reader, writer := io.Pipe() + defer reader.Close() + defer writer.Close() + prefix := endpoint + if coalesced { + prefix += message + } + transport := &SSEClientTransport{ + Endpoint: "http://example.com/sse", + HTTPClient: &http.Client{Transport: roundTripperFunc(func(req *http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": {"text/event-stream"}}, + Body: struct { + io.Reader + io.Closer + }{io.MultiReader(strings.NewReader(prefix), reader), reader}, + }, nil + })}, + } + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + conn, err := transport.Connect(ctx) + if err != nil { + t.Fatal(err) + } + defer conn.Close() + if !coalesced { + go func() { _, _ = io.WriteString(writer, message) }() + } + msg, err := conn.Read(ctx) + if err != nil { + t.Fatalf("Read: %v", err) + } + if req, ok := msg.(*jsonrpc.Request); !ok || req.Method != "notifications/tools/list_changed" { + t.Fatalf("Read = %v, want tools/list_changed notification", msg) + } + }) + } +} From fbd36cb7870176bfc3e8e423df697cf110d0f927 Mon Sep 17 00:00:00 2001 From: rishabh Date: Tue, 15 Sep 2026 07:44:03 -0400 Subject: [PATCH 65/70] docs/server: fix logging defaults and the SetLoggingLevel method name (#1251) The Logging section of docs/server.md says "For stateful sessions" twice in a row with opposite meanings, so one of the two sentences has to be wrong. The default level of "info" is set in only one place in the SDK, `StreamableHTTPHandler.ephemeralConnectOpts` in mcp/streamable.go, which handles stateless requests; a session created through `Server.Connect` starts with an empty `ServerSessionState.LogLevel`, and `ServerSession.Log` returns early while the level is empty, which is exactly what the first sentence already says. The second sentence now reads "For stateless sessions". The same section also tells readers to call `SetLevel`, but there is no such exported method: the client-side call is `ClientSession.SetLoggingLevel`, and `setLevel` is only the unexported server handler, so the pkg.go.dev link on the page points at an anchor that does not exist. For context, the stateless default arrived in #411 and the paragraph describing it was written later in #497, which is where the duplicated "stateful" came from. Checked with `go doc ./mcp ClientSession` (only `SetLoggingLevel` is exported) and `grep -rn 'LogLevel = "info"' mcp/` (a single hit, mcp/streamable.go:516); docs/server.md was regenerated with `go generate ./internal/docs`, and `go build ./... && go test ./...` pass. --------- Signed-off-by: Rishabh Co-authored-by: Guglielmo Colombo --- docs/server.md | 10 +++++++--- internal/docs/server.src.md | 10 +++++++--- 2 files changed, 14 insertions(+), 6 deletions(-) diff --git a/docs/server.md b/docs/server.md index d7bc314..8973e3a 100644 --- a/docs/server.md +++ b/docs/server.md @@ -1066,8 +1066,12 @@ server produces logs that remain server-side, for use by server maintainers.) **Server-side**: The minimum log level is part of the server state. For stateful sessions, there is no default log level: no log messages will be sent -until the client calls `SetLevel` (see below). -For stateful sessions, the level defaults to "info". +until the client calls `SetLoggingLevel` (see below). +For legacy stateless sessions the level defaults to "info". +For sessionless 2026-07-28 requests there is no server-side default: each request +carries its level in `_meta` (`io.modelcontextprotocol/logLevel`), and nothing is +logged until the client sends one. +(Note: logging is deprecated as of 2026-07-28 / SEP-2577.) [`ServerSession.Log`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ServerSession.Log) is the low-level way for servers to log to clients. It sends a logging notification to the client if the level of the message @@ -1083,7 +1087,7 @@ Servers always report the logging capability. **Client-side**: Set [`ClientOptions.LoggingMessageHandler`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ClientOptions.LoggingMessageHandler) to receive log messages. -Call [`ClientSession.SetLevel`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ClientSession.SetLevel) to change the log level for a session. +Call [`ClientSession.SetLoggingLevel`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ClientSession.SetLoggingLevel) to change the log level for a session. ```go func Example_logging() { diff --git a/internal/docs/server.src.md b/internal/docs/server.src.md index 82b956e..ff66383 100644 --- a/internal/docs/server.src.md +++ b/internal/docs/server.src.md @@ -437,8 +437,12 @@ server produces logs that remain server-side, for use by server maintainers.) **Server-side**: The minimum log level is part of the server state. For stateful sessions, there is no default log level: no log messages will be sent -until the client calls `SetLevel` (see below). -For stateful sessions, the level defaults to "info". +until the client calls `SetLoggingLevel` (see below). +For legacy stateless sessions the level defaults to "info". +For sessionless 2026-07-28 requests there is no server-side default: each request +carries its level in `_meta` (`io.modelcontextprotocol/logLevel`), and nothing is +logged until the client sends one. +(Note: logging is deprecated as of 2026-07-28 / SEP-2577.) [`ServerSession.Log`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ServerSession.Log) is the low-level way for servers to log to clients. It sends a logging notification to the client if the level of the message @@ -454,7 +458,7 @@ Servers always report the logging capability. **Client-side**: Set [`ClientOptions.LoggingMessageHandler`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ClientOptions.LoggingMessageHandler) to receive log messages. -Call [`ClientSession.SetLevel`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ClientSession.SetLevel) to change the log level for a session. +Call [`ClientSession.SetLoggingLevel`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ClientSession.SetLoggingLevel) to change the log level for a session. %include ../../mcp/server_example_test.go logging - From 3785c500b581ad76bc5a74e9f84d8feeb89fef27 Mon Sep 17 00:00:00 2001 From: Luca Candela <73209+CaliLuke@users.noreply.github.com> Date: Wed, 16 Sep 2026 00:22:16 -0700 Subject: [PATCH 66/70] mcp: preserve structured content number precision (#1239) CallToolResult currently decodes structuredContent through an any field, which converts JSON numbers to float64 and silently rounds integers outside the IEEE-754 safe range. This change decodes structuredContent separately with UseNumber, preserving wire numbers as json.Number while retaining the existing decoded object, array, and primitive shapes. The field documentation now describes that representation. Users who depend on the previous float64 behavior can temporarily restore it with MCPGODEBUG=structuredcontentfloat64=1. An end-to-end in-memory client/server regression covers 9007199254740993 from a low-level Server.AddTool result. Additional coverage verifies the compatibility option restores the legacy rounded float64 value. Verification: - gofmt -l . - go test ./... - go vet ./... Fixes #1236 --------- Co-authored-by: Guglielmo Colombo --- docs/mcpgodebug.md | 10 +++++++ internal/docs/mcpgodebug.src.md | 10 +++++++ internal/json/json.go | 9 ++++++ mcp/protocol.go | 22 +++++++++++++-- mcp/protocol_test.go | 20 ++++++++++++- mcp/server_test.go | 50 +++++++++++++++++++++++++++++++-- mcp/streamable_test.go | 10 +++++-- 7 files changed, 124 insertions(+), 7 deletions(-) diff --git a/docs/mcpgodebug.md b/docs/mcpgodebug.md index 57766ce..43a62ce 100644 --- a/docs/mcpgodebug.md +++ b/docs/mcpgodebug.md @@ -18,6 +18,16 @@ ## `MCPGODEBUG` history +### 1.9.0 + +Options listed below were added and will be removed in the 1.11.0 version of the SDK. + +- `structuredcontentfloat64` added. If set to `1`, numbers in + `CallToolResult.StructuredContent` are decoded as `float64`, restoring the + previous behavior. The default behavior decodes numbers as `json.Number` so + that large integers and other exact JSON values do not silently lose + precision. + ### 1.8.0 Options listed below were added and will be removed in the 1.9.0 version of the SDK. diff --git a/internal/docs/mcpgodebug.src.md b/internal/docs/mcpgodebug.src.md index fd0f255..8d1385f 100644 --- a/internal/docs/mcpgodebug.src.md +++ b/internal/docs/mcpgodebug.src.md @@ -17,6 +17,16 @@ ## `MCPGODEBUG` history +### 1.9.0 + +Options listed below were added and will be removed in the 1.11.0 version of the SDK. + +- `structuredcontentfloat64` added. If set to `1`, numbers in + `CallToolResult.StructuredContent` are decoded as `float64`, restoring the + previous behavior. The default behavior decodes numbers as `json.Number` so + that large integers and other exact JSON values do not silently lose + precision. + ### 1.8.0 Options listed below were added and will be removed in the 1.9.0 version of the SDK. diff --git a/internal/json/json.go b/internal/json/json.go index b3fa039..3a0e52b 100644 --- a/internal/json/json.go +++ b/internal/json/json.go @@ -44,6 +44,15 @@ func Unmarshal(data []byte, v any) error { return NewDecoder(bytes.NewReader(data)).Decode(v) } +func UnmarshalUseNumber(data []byte, v any) error { + if err := checkMaxDepth(data, defaultMaxDepth); err != nil { + return err + } + dec := NewDecoder(bytes.NewReader(data)) + dec.dec.UseNumber() + return dec.Decode(v) +} + // checkMaxDepth scans data once and reports [errMaxDepthExceeded] if the // nesting of JSON objects and arrays exceeds maxDepth. It is a lightweight pass which // tracks '{' and '[' against '}' and ']' while skipping over the contents of strings. diff --git a/mcp/protocol.go b/mcp/protocol.go index be37a18..c340e60 100644 --- a/mcp/protocol.go +++ b/mcp/protocol.go @@ -289,6 +289,8 @@ type CallToolResult struct { // result of the tool call. Per SEP-2106, it may marshal to any valid JSON // value (object, array, or primitive) conforming to the tool's // [Tool.OutputSchema]. + // Numbers received from the wire are represented as [json.Number] to preserve + // their exact values. // // When using a [ToolHandlerFor] with structured output, you should not // populate this field. It will be automatically populated with the typed Out @@ -379,6 +381,12 @@ func (r *CallToolResult) hasContent() bool { // An empty InputRequests with NeedsInput true indicates load-shedding. func (r *CallToolResult) NeedsInput() bool { return r.resultType == resultTypeInputRequired } +// structuredcontentfloat64 is a compatibility parameter that restores the +// previous behavior of decoding numbers in [CallToolResult.StructuredContent] +// as float64 values. By default, numbers are decoded as [json.Number] to avoid +// losing precision. The option will be removed in the 1.11.0 version of the SDK. +var structuredcontentfloat64 = mcpgodebug.Value("structuredcontentfloat64") + func (x *CallToolResult) MarshalJSON() ([]byte, error) { type res CallToolResult // avoid recursion type wire struct { @@ -401,12 +409,22 @@ func (x *CallToolResult) UnmarshalJSON(data []byte) error { type res CallToolResult // avoid recursion var wire struct { res - Content []*wireContent `json:"content"` - ResultType resultType `json:"resultType"` + Content []*wireContent `json:"content"` + StructuredContent json.RawMessage `json:"structuredContent"` + ResultType resultType `json:"resultType"` } if err := internaljson.Unmarshal(data, &wire); err != nil { return err } + if len(wire.StructuredContent) > 0 { + unmarshal := internaljson.UnmarshalUseNumber + if structuredcontentfloat64 == "1" { + unmarshal = internaljson.Unmarshal + } + if err := unmarshal(wire.StructuredContent, &wire.res.StructuredContent); err != nil { + return err + } + } var err error if wire.res.Content, err = contentsFromWire(wire.Content, nil); err != nil { return err diff --git a/mcp/protocol_test.go b/mcp/protocol_test.go index fe496de..4f0c345 100644 --- a/mcp/protocol_test.go +++ b/mcp/protocol_test.go @@ -1285,7 +1285,7 @@ func TestContentUnmarshal(t *testing.T) { Meta: Meta{"m": true}, Content: content, IsError: true, - StructuredContent: 3.0, + StructuredContent: json.Number("3"), } var gotf CallToolResult roundtrip(ctrf, &gotf) @@ -1298,6 +1298,24 @@ func TestContentUnmarshal(t *testing.T) { roundtrip(pm, &gotpm) } +func TestCallToolResultStructuredContentFloat64Compatibility(t *testing.T) { + prev := structuredcontentfloat64 + structuredcontentfloat64 = "1" + t.Cleanup(func() { structuredcontentfloat64 = prev }) + + var got CallToolResult + if err := json.Unmarshal([]byte(`{"content":[],"structuredContent":{"id":9007199254740993}}`), &got); err != nil { + t.Fatal(err) + } + structured, ok := got.StructuredContent.(map[string]any) + if !ok { + t.Fatalf("StructuredContent type = %T, want map[string]any", got.StructuredContent) + } + if got := structured["id"]; got != float64(9007199254740992) { + t.Errorf("id = %v (%T), want rounded float64 value", got, got) + } +} + func TestToolAnnotations_MarshalJSON(t *testing.T) { boolPtr := func(b bool) *bool { return &b } diff --git a/mcp/server_test.go b/mcp/server_test.go index 1544e29..2c7bbe0 100644 --- a/mcp/server_test.go +++ b/mcp/server_test.go @@ -677,7 +677,7 @@ func TestAddToolNonObjectOutputSchema(t *testing.T) { name: "primitive number (map-based schema)", outputSchema: map[string]any{"type": "number"}, content: 42.0, - want: 42.0, + want: json.Number("42"), }, { name: "primitive string (RawMessage schema)", @@ -724,6 +724,52 @@ func TestAddToolNonObjectOutputSchema(t *testing.T) { } } +func TestCallToolStructuredContentPreservesLargeInteger(t *testing.T) { + const want int64 = 9007199254740993 + + server := NewServer(testImpl, nil) + server.AddTool(&Tool{ + Name: "large-integer", + InputSchema: &jsonschema.Schema{Type: "object"}, + }, func(context.Context, *CallToolRequest) (*CallToolResult, error) { + return &CallToolResult{StructuredContent: map[string]any{"id": want}}, nil + }) + + clientTransport, serverTransport := NewInMemoryTransports() + serverSession, err := server.Connect(context.Background(), serverTransport, nil) + if err != nil { + t.Fatal(err) + } + defer serverSession.Close() + + client := NewClient(testImpl, nil) + clientSession, err := client.Connect(context.Background(), clientTransport, nil) + if err != nil { + t.Fatal(err) + } + defer clientSession.Close() + + result, err := clientSession.CallTool(context.Background(), &CallToolParams{Name: "large-integer"}) + if err != nil { + t.Fatal(err) + } + structured, ok := result.StructuredContent.(map[string]any) + if !ok { + t.Fatalf("StructuredContent type = %T, want map[string]any", result.StructuredContent) + } + number, ok := structured["id"].(json.Number) + if !ok { + t.Fatalf("id type = %T, want json.Number", structured["id"]) + } + got, err := number.Int64() + if err != nil { + t.Fatal(err) + } + if got != want { + t.Errorf("id = %d, want %d", got, want) + } +} + // TestAddToolGenericNonObjectOutput verifies SEP-2106 for the generic // AddTool[In, Out] helper: Out may be a slice or primitive whose inferred // JSON Schema has a non-object root. @@ -816,7 +862,7 @@ func TestAddToolGenericNonObjectOutput(t *testing.T) { if res.IsError { t.Fatalf("unexpected tool error: %v", res.Content) } - if diff := cmp.Diff(float64(42), res.StructuredContent); diff != "" { + if diff := cmp.Diff(json.Number("42"), res.StructuredContent); diff != "" { t.Errorf("structured content mismatch (-want +got):\n%s", diff) } }) diff --git a/mcp/streamable_test.go b/mcp/streamable_test.go index ca4d0a7..7af9dff 100644 --- a/mcp/streamable_test.go +++ b/mcp/streamable_test.go @@ -288,8 +288,14 @@ func TestStreamableConcurrentHandling(t *testing.T) { t.Errorf("CallTool failed: %v", err) return } - if got := int(res.StructuredContent.(map[string]any)["Count"].(float64)); got != i { - t.Errorf("got count %d, want %d", got, i) + number, ok := res.StructuredContent.(map[string]any)["Count"].(json.Number) + if !ok { + t.Errorf("count type = %T, want json.Number", res.StructuredContent.(map[string]any)["Count"]) + continue + } + got, err := number.Int64() + if err != nil || got != int64(i) { + t.Errorf("got count %d, %v; want %d", got, err, i) } } }) From 826e653c270433bbaee6d5d46a771a66f9e15d98 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20M=2E=20Requena=20Plens?= Date: Thu, 17 Sep 2026 10:36:23 +0200 Subject: [PATCH 67/70] mcp: refuse a call on a legacy session before initialize (#1273) Closes #1271. `Server.handle` refuses a call made before `initialize` on a legacy session, and that refusal lives in the `default` branch of its method switch, so any method with a `case` of its own skips it. Three are served on a session that never handshook: `logging/setLevel`, `resources/subscribe` and `resources/unsubscribe`. `resources/subscribe` is the one that reaches state rather than merely answering: it starts a watcher and registers a subscriber, so a client that subscribes before `initialize` is delivered `notifications/resources/updated` for the lifetime of a session the server never agreed to. The check now runs after the switch, for every call carrying no new-protocol `_meta`, with `initialize` and `ping` exempt. That is the same condition the `default` branch already applied and the same error it produced; the change is that the three methods above stop skipping it. `ping` stays allowed because the lifecycle page exempts it. `server/discover` is deliberately left alone: a legacy request there already receives method-not-found, which tells the client more than an initialization error would. The two notification cases stay outside, since the gate covers calls and a notification carries no refusal. The SEP-2575 path is untouched. A session without an `initialize` handshake is exactly what that proposal is for, so the exemption is the request's own `_meta.protocolVersion` rather than anything about the session, and a new-protocol client is served as it is today. Tests: `TestServerHandle_LegacyCallBeforeInitialize` drives each affected method on a fresh session and asserts the refusal, that `resources/subscribe` runs no handler and leaves no subscriber, and that `initialize` and `ping` are served. `TestServerHandle_NewProtocolCallWithoutInitialize` asserts a new-protocol session with no handshake still gets `tools/list`, and that the three methods keep their existing method-not-found rather than the initialization refusal. Without the change, the setLevel, subscribe and unsubscribe rows fail. `go test ./mcp/ -count=1` is green, `gofmt -l` is empty and `go vet ./mcp/` is clean. This does not depend on #1266 and rebases onto it cleanly, verified with the suite green on the combination. One decision worth your view: the refusal is a plain error, which on the wire carries code 0, exactly as the `default` branch produced before this change. The specification prescribes no code for it. If you would rather it were `-32600` or `-32603`, say so and I will change it here. --------- Co-authored-by: Guglielmo Colombo --- mcp/server.go | 14 ++-- mcp/server_test.go | 156 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 166 insertions(+), 4 deletions(-) diff --git a/mcp/server.go b/mcp/server.go index 442d7bc..b2f89f4 100644 --- a/mcp/server.go +++ b/mcp/server.go @@ -1989,10 +1989,6 @@ func (ss *ServerSession) handle(ctx context.Context, req *jsonrpc.Request) (any, } } default: - if !initialized && !validatedMeta.usesNewProtocol && req.IsCall() { - 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) - } if !initialized && validatedMeta.usesNewProtocol && validatedMeta.initializeParams != nil { ss.updateState(func(state *ServerSessionState) { state.InitializeParams = validatedMeta.initializeParams @@ -2000,6 +1996,16 @@ func (ss *ServerSession) handle(ctx context.Context, req *jsonrpc.Request) (any, } } + // In legacy protocol versions, a client cannot send requests other than + // pings before the server has responded to the initialize request. A + // new-protocol request is exempt: a SEP-2575 session has no 'initialize' + // to wait for, and the request itself says which protocol it speaks. + if !initialized && !validatedMeta.usesNewProtocol && req.IsCall() && + req.Method != methodInitialize && req.Method != methodPing { + 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) + } + // modelcontextprotocol/go-sdk#26: handle calls asynchronously, and // notifications synchronously, except for 'initialize' which shouldn't be // asynchronous to other diff --git a/mcp/server_test.go b/mcp/server_test.go index 2c7bbe0..4d09cbc 100644 --- a/mcp/server_test.go +++ b/mcp/server_test.go @@ -2198,6 +2198,162 @@ func TestServerSupportedProtocolVersions_NewProtocol(t *testing.T) { } } +// TestServerHandle_LegacyCallBeforeInitialize asserts that a call carrying no +// new-protocol `_meta` is refused on a session that has not completed +// 'initialize', whichever branch of the method switch it lands in. The +// handshake itself is what ends that state, and the lifecycle spec exempts +// pings, so those two are served. +// +// The gate used to sit in the default branch, so 'logging/setLevel', +// 'resources/subscribe' and 'resources/unsubscribe', which share a case that +// only refuses new-protocol requests, were served before the handshake. For +// subscribe that registered a session that never handshook as a subscriber, +// so the subtest also asserts that the refusal leaves no subscriber and runs +// no handler. +func TestServerHandle_LegacyCallBeforeInitialize(t *testing.T) { + ctx := context.Background() + const uri = "test://resource" + + tests := []struct { + name string + method string + params string + served bool + }{ + { + name: "initialize", + method: methodInitialize, + params: fmt.Sprintf(`{"protocolVersion":%q,"capabilities":{},"clientInfo":{"name":"c","version":"1"}}`, protocolVersion20251125), + served: true, + }, + {name: "ping", method: methodPing, params: `{}`, served: true}, + {name: "tools/list", method: methodListTools, params: `{}`}, + {name: "logging/setLevel", method: methodSetLevel, params: `{"level":"info"}`}, + {name: "resources/subscribe", method: methodSubscribe, params: fmt.Sprintf(`{"uri":%q}`, uri)}, + {name: "resources/unsubscribe", method: methodUnsubscribe, params: fmt.Sprintf(`{"uri":%q}`, uri)}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + var handlerRan bool + server := NewServer(testImpl, &ServerOptions{ + SubscribeHandler: func(context.Context, *SubscribeRequest) error { + handlerRan = true + return nil + }, + UnsubscribeHandler: func(context.Context, *UnsubscribeRequest) error { + handlerRan = true + return nil + }, + }) + _, st := NewInMemoryTransports() + ss, err := server.Connect(ctx, st, nil) + if err != nil { + t.Fatalf("server.Connect: %v", err) + } + defer ss.Close() + + res, err := ss.handle(ctx, &jsonrpc.Request{ + ID: jsonrpc2.Int64ID(1), + Method: tc.method, + Params: json.RawMessage(tc.params), + }) + if tc.served { + if err != nil { + t.Fatalf("handle(%q) before initialize returned %v, want it served", tc.method, err) + } + if res == nil { + t.Fatalf("handle(%q) before initialize returned no result", tc.method) + } + if tc.method == methodInitialize && ss.InitializeParams() == nil { + t.Errorf("initialize was answered without recording InitializeParams") + } + return + } + if err == nil { + t.Fatalf("handle(%q) served the call before initialize, want a refusal", tc.method) + } + if want := fmt.Sprintf("method %q is invalid during session initialization", tc.method); err.Error() != want { + t.Errorf("handle(%q) error = %q, want %q", tc.method, err.Error(), want) + } + if handlerRan { + t.Errorf("handle(%q) ran the server's handler for a call it refused", tc.method) + } + server.mu.Lock() + _, subscribed := server.resourceSubscriptions[uri][ss] + server.mu.Unlock() + if subscribed { + t.Errorf("handle(%q) left the session subscribed to %q", tc.method, uri) + } + }) + } +} + +// TestServerHandle_NewProtocolCallWithoutInitialize asserts that the gate +// stays off the SEP-2575 path. A fresh session that never sends 'initialize' +// is served a new-protocol 'tools/list', and the methods the gate now covers +// are answered, when a new-protocol request names them, with the error the +// new protocol already gave them (the method does not exist there), and not +// with the initialization refusal. +func TestServerHandle_NewProtocolCallWithoutInitialize(t *testing.T) { + ctx := context.Background() + const uri = "test://resource" + + tests := []struct { + name string + method string + params map[string]any + served bool + }{ + {name: "tools/list", method: methodListTools, params: newProtocolParams(nil), served: true}, + {name: "logging/setLevel", method: methodSetLevel, params: newProtocolParams(map[string]any{"level": "info"})}, + {name: "resources/subscribe", method: methodSubscribe, params: newProtocolParams(map[string]any{"uri": uri})}, + {name: "resources/unsubscribe", method: methodUnsubscribe, params: newProtocolParams(map[string]any{"uri": uri})}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + server := NewServer(testImpl, &ServerOptions{ + SubscribeHandler: func(context.Context, *SubscribeRequest) error { return nil }, + UnsubscribeHandler: func(context.Context, *UnsubscribeRequest) error { return nil }, + }) + _, st := NewInMemoryTransports() + ss, err := server.Connect(ctx, st, nil) + if err != nil { + t.Fatalf("server.Connect: %v", err) + } + defer ss.Close() + + params, err := json.Marshal(tc.params) + if err != nil { + t.Fatalf("marshalling params: %v", err) + } + res, err := ss.handle(ctx, &jsonrpc.Request{ + ID: jsonrpc2.Int64ID(1), + Method: tc.method, + Params: params, + }) + if tc.served { + if err != nil { + t.Fatalf("handle(%q) on a new-protocol session returned %v, want it served", tc.method, err) + } + if res == nil { + t.Fatalf("handle(%q) on a new-protocol session returned no result", tc.method) + } + return + } + var jerr *jsonrpc.Error + if !errors.As(err, &jerr) { + t.Fatalf("handle(%q) returned %v, want a *jsonrpc.Error", tc.method, err) + } + if jerr.Code != jsonrpc.CodeMethodNotFound { + t.Errorf("handle(%q) error code = %d, want %d (removed in the new protocol)", tc.method, jerr.Code, jsonrpc.CodeMethodNotFound) + } + if strings.Contains(err.Error(), "invalid during session initialization") { + t.Errorf("handle(%q) applied the initialization gate to a new-protocol request: %v", tc.method, err) + } + }) + } +} + // TestServerUnknownProtocolVersion_NewProtocol verifies that a request whose // `_meta.protocolVersion` names a version the SDK does not know is rejected // with [CodeUnsupportedProtocolVersion], and not served as a legacy handshake From b58aa8d8d8530fdc3c02573a345aa161a466659e Mon Sep 17 00:00:00 2001 From: Guglielmo Colombo Date: Fri, 18 Sep 2026 09:26:01 +0200 Subject: [PATCH 68/70] mcp: ignore notification in stdio batches (#1277) Fixes #1256 --- mcp/transport.go | 2 +- mcp/transport_test.go | 104 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 105 insertions(+), 1 deletion(-) diff --git a/mcp/transport.go b/mcp/transport.go index ce841f2..08d3b15 100644 --- a/mcp/transport.go +++ b/mcp/transport.go @@ -734,7 +734,7 @@ func (t *ioConn) Read(ctx context.Context) (jsonrpc.Message, error) { if batch { var respBatch *msgBatch // track incoming requests in the batch for _, msg := range msgs { - if req, ok := msg.(*jsonrpc.Request); ok { + if req, ok := msg.(*jsonrpc.Request); ok && req.IsCall() { if respBatch == nil { respBatch = &msgBatch{ unresolved: make(map[jsonrpc2.ID]int), diff --git a/mcp/transport_test.go b/mcp/transport_test.go index d4d7782..a20ee5f 100644 --- a/mcp/transport_test.go +++ b/mcp/transport_test.go @@ -5,6 +5,7 @@ package mcp import ( + "bytes" "context" "errors" "fmt" @@ -171,6 +172,109 @@ func TestIOConnRead_EmptyMethod(t *testing.T) { } } +// TestIOConnReadBatchNotifications is a regression test for go-sdk#1256: +// notifications carried inside a JSON-RPC batch must not be tracked as awaiting +// a response. Only calls belong in a batch's response array, so a batch's +// notifications must neither keep it from completing nor pollute the +// per-connection duplicate-ID tracking. +func TestIOConnReadBatchNotifications(t *testing.T) { + ctx := context.Background() + + tests := []struct { + name string + // input is one or more newline-separated batch payloads. + input string + // wantCalls records, for each message Read returns in order, whether it + // is a call (true) or a notification (false). Reading every message + // must succeed. + wantCalls []bool + // wantTrackedCalls is the number of in-flight call requests tracked across + // batches awaiting a response once every message has been read, before any + // response is written. + wantTrackedCalls int + // respondIDs are the call IDs to respond to, in order. + respondIDs []int64 + // wantWrites are the batch response payloads expected on the wire, each + // matched as a substring. + wantWrites []string + }{ + { + name: "call and notification is answered", + input: `[{"jsonrpc":"2.0","id":2,"method":"ping"},{"jsonrpc":"2.0","method":"notifications/cancelled","params":{"requestId":99}}]`, + wantCalls: []bool{true, false}, + wantTrackedCalls: 1, + respondIDs: []int64{2}, + wantWrites: []string{`[{"jsonrpc":"2.0","id":2,"result":{}}]`}, + }, + { + name: "only notifications keeps the connection open", + input: `[{"jsonrpc":"2.0","method":"notifications/cancelled","params":{"requestId":98}},{"jsonrpc":"2.0","method":"notifications/cancelled","params":{"requestId":99}}]`, + wantCalls: []bool{false, false}, + wantTrackedCalls: 0, + }, + { + name: "repeated batches holding a notification", + input: `[{"jsonrpc":"2.0","id":2,"method":"ping"},{"jsonrpc":"2.0","method":"notifications/cancelled","params":{"requestId":99}}]` + "\n" + + `[{"jsonrpc":"2.0","id":3,"method":"ping"},{"jsonrpc":"2.0","method":"notifications/cancelled","params":{"requestId":99}}]`, + wantCalls: []bool{true, false, true, false}, + wantTrackedCalls: 2, + respondIDs: []int64{2, 3}, + wantWrites: []string{ + `[{"jsonrpc":"2.0","id":2,"result":{}}]`, + `[{"jsonrpc":"2.0","id":3,"result":{}}]`, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + sink := new(bytes.Buffer) + tr := newIOConn(rwc{ + rc: io.NopCloser(strings.NewReader(tt.input)), + wc: nopCloserWriter{sink}, + }) + t.Cleanup(func() { tr.Close() }) + + for i, wantCall := range tt.wantCalls { + msg, err := tr.Read(ctx) + if err != nil { + t.Fatalf("Read() #%d: unexpected error: %v", i, err) + } + req, ok := msg.(*jsonrpc.Request) + if !ok { + t.Fatalf("Read() #%d = %T, want *jsonrpc.Request", i, msg) + } + if req.IsCall() != wantCall { + t.Fatalf("Read() #%d IsCall() = %t, want %t", i, req.IsCall(), wantCall) + } + } + + // Only calls are tracked as awaiting a response; notifications must + // not linger in the batch bookkeeping. + tr.batchMu.Lock() + gotTrackedCalls := len(tr.batches) + tr.batchMu.Unlock() + if gotTrackedCalls != tt.wantTrackedCalls { + t.Errorf("tracked calls = %d, want %d", gotTrackedCalls, tt.wantTrackedCalls) + } + + for _, id := range tt.respondIDs { + resp := &jsonrpc.Response{ID: jsonrpc2.Int64ID(id), Result: []byte("{}")} + if err := tr.Write(ctx, resp); err != nil { + t.Fatalf("Write(response id=%d): %v", id, err) + } + } + + got := sink.String() + for _, want := range tt.wantWrites { + if !strings.Contains(got, want) { + t.Errorf("batch responses = %q, missing %q", got, want) + } + } + }) + } +} + // TestIOConnFrameCap verifies single inbound frame cap enforcement. func TestIOConnFrameCap(t *testing.T) { tests := []struct { From 3b917b466cc540079b82bcd0fecdc46a2ba13644 Mon Sep 17 00:00:00 2001 From: sravan kilaru Date: Fri, 18 Sep 2026 02:41:05 -0500 Subject: [PATCH 69/70] oauthex: build defaultDiscoveryTransport lazily instead of at import (#1278) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes #1276 ## What's happening `oauthex` builds `defaultDiscoveryTransport` as a package-level var, so it runs at import time. That path calls `proxyConfigured`, which invokes `http.DefaultTransport`'s proxy func (`http.ProxyFromEnvironment`). net/http reads the proxy env vars only once and caches the result, so this import-time call freezes the proxy config before `main()` can set `HTTPS_PROXY`. New in v1.8.0, which is why it's a regression — just importing `.../mcp` (which pulls in `oauthex`) triggers it, even with no use of the SDK. ## Fix Wrap the setup in `sync.OnceValue` so it runs on first use instead of at import. Still built once and reused. ## Testing (removed) - Ran the issue's repro against this branch via a local `replace`: prints `proxy=http://127.0.0.1:9` now, `proxy=` before. - Added a regression test in `oauthex`. - `go test ./...` passes. Co-authored-by: Guglielmo Colombo --- oauthex/oauth2.go | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/oauthex/oauth2.go b/oauthex/oauth2.go index caed92f..0e77149 100644 --- a/oauthex/oauth2.go +++ b/oauthex/oauth2.go @@ -16,6 +16,7 @@ import ( "net/netip" "net/url" "strings" + "sync" "syscall" "time" @@ -25,7 +26,15 @@ import ( const maxDiscoveryRedirects = 10 -var defaultDiscoveryTransport = newDiscoveryTransport(http.DefaultTransport) +// Build this on first use, not when the package loads. Building it checks the +// proxy via http.ProxyFromEnvironment, and net/http only reads the proxy +// environment once and then remembers it for the whole run. If that happened +// at import time, it would lock in the proxy setting before the app had a +// chance to set HTTPS_PROXY. sync.OnceValue waits until first use and still +// builds the transport just once. See #1276. +var defaultDiscoveryTransport = sync.OnceValue(func() http.RoundTripper { + return newDiscoveryTransport(http.DefaultTransport) +}) type httpStatusError struct { StatusCode int @@ -103,7 +112,7 @@ func checkHTTPSOrLoopback(addr string) error { } func newDiscoveryClient(c *http.Client) *http.Client { - transport := defaultDiscoveryTransport + transport := defaultDiscoveryTransport() if c != nil && c.Transport != nil { // a caller that has taken over dialing or TLS opts out of the checks if t, ok := c.Transport.(*http.Transport); ok && t.DialContext == nil && t.DialTLSContext == nil { From 4608cda997aad09d9354b2f1305ed04177a23bd2 Mon Sep 17 00:00:00 2001 From: Aircg <64237451+cgair@users.noreply.github.com> Date: Fri, 18 Sep 2026 16:42:05 +0800 Subject: [PATCH 70/70] mcp: retire only the subscriptions a listen registered (#1275) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit *Narrowed after review — see the discussion below. The earlier revision changed the registry keying; this one changes only the teardown.* ### The problem `subscriptionsListen` registers a session according to what the listen asked for: ```go if allowed.ToolsListChanged { s.toolChangeSubscriptions[req.Session] = requestID } if allowed.PromptsListChanged { s.promptChangeSubscriptions[req.Session] = requestID } if allowed.ResourcesListChanged { s.resourceChangeSubscriptions[req.Session] = requestID } ``` Its deferred cleanup does not: ```go delete(s.toolChangeSubscriptions, req.Session) delete(s.promptChangeSubscriptions, req.Session) delete(s.resourceChangeSubscriptions, req.Session) ``` It runs whether or not this listen ever wrote those registries. A session can hold several listens at once, so the first one to unwind takes the others' registrations with it, and those streams stay open while receiving nothing. The scope is the persistent-session transports, stdio and in-memory. Over streamable HTTP the registries cannot collide: a stateful handler refuses SEP-2575 requests outright with `CodeUnsupportedProtocolVersion`, and a stateless one builds a temporary session per POST and closes it when the request ends. Reachable through this SDK's own client, on stdio: 1. `Client.Connect` opens the auto-listen for list-changed notifications. 2. `ClientSession.Subscribe(uri)` opens a second listen, carrying only a resource subscription. 3. `ClientSession.Unsubscribe(uri)` retires that second listen. 4. Its cleanup deletes `toolChangeSubscriptions[session]`, which belongs to the auto-listen. 5. The auto-listen is still open and never receives `tools/list_changed` again. ### How I pinned it down A diagnostic run over the three states, reading the registries and `ServerSession.listenIDs` directly: ``` after connect toolSub=2 promptSub=2 resSub[r1]=false listenIDs=[{2}] after Subscribe toolSub=2 promptSub=2 resSub[r1]=true listenIDs=[{2} {3}] after Unsubscribe toolSub= promptSub= resSub[r1]=false listenIDs=[{2}] ``` Line 2 rules out the second listen having overwritten the entry: `toolSub` is still 2, listen 3 never wrote that registry. Line 3 rules out the client cancelling the wrong stream: `listenIDs` still holds listen 2, so the auto-listen is alive on the server, parked on `<-ctx.Done()`. What changed is only that its registration is gone, deleted by listen 3's teardown. ### The change Guard each delete with the condition that guarded the registration, so a listen retires what it registered and nothing else. ### Validation Two tests, next to the existing `TestSubscriptionsListen_*` ones and reusing their fixtures. Against `826e653` without the change: ``` --- FAIL: TestSubscriptionsListen_TeardownKeepsOtherListens (5.01s) mcp_test.go:3442: timed out waiting for "tool" --- PASS: TestSubscriptionsListen_TeardownRetiresOwnRegistration (0.01s) ``` The second passes before and after on purpose: it asserts that the only listen on a session does still get retired, so that the guards cannot be tightened into a registry leak. The eight pre-existing `TestSubscriptionsListen_*` tests pass in both runs, and all ten pass with the change. Run on the same commit, before and after: `go test ./...` and `go test -race ./...` ok with no races, `gofmt -l .`, `go vet ./...` and `staticcheck v0.6.1` clean. Also `go test ./...` on Go 1.25.0 and 1.26.8 to match the CI matrix, and `go generate ./internal/readme ./internal/docs` produces no diff. Environment: Intel Core i5-1038NG7, macOS 14. ### Known limitations Two cases are outside this change, both needing two listens on one session that overlap: - Two listens opting in to the same notification type. `allowed.ToolsListChanged` is true for both, so the registration overwrites and this teardown still retires the survivor's entry. - Two listens subscribed to the same URI. `resourceSubscriptions[uri][session]` overwrites the same way, and the deferred `unsubscribe` deletes it. Neither is reachable from this SDK's client — `Subscribe` dedups per URI and there is no exported way to open a second list-changed listen — so both require a non-Go client on stdio. I have tests for them and am happy to open a separate issue if you want them tracked. --- AI assistance was used for reading through the code, the diagnostic and regression tests, and running the verification above. The root cause analysis and the design decisions are mine, and I can explain every line without it. Co-authored-by: Guglielmo Colombo --- mcp/mcp_test.go | 153 ++++++++++++++++++++++++++++++++++++++++++++++++ mcp/server.go | 15 ++++- 2 files changed, 165 insertions(+), 3 deletions(-) diff --git a/mcp/mcp_test.go b/mcp/mcp_test.go index bb76c1c..7b10b30 100644 --- a/mcp/mcp_test.go +++ b/mcp/mcp_test.go @@ -3363,6 +3363,159 @@ func TestSubscriptionsListen_DisconnectScrubsMaps(t *testing.T) { } } +// newSubListenSubscribeServer is newSubListenServer with a resource and the +// handlers that make the server advertise resources.subscribe, so that a +// listen can carry a resource subscription. +func newSubListenSubscribeServer() *Server { + s := NewServer(testImpl, &ServerOptions{ + SubscribeHandler: func(context.Context, *SubscribeRequest) error { return nil }, + UnsubscribeHandler: func(context.Context, *UnsubscribeRequest) error { return nil }, + }) + AddTool(s, &Tool{Name: "t1"}, sayHi) + s.AddPrompt(&Prompt{Name: "p1"}, nil) + s.AddResource(&Resource{Name: "r1", URI: "file:///r1"}, nil) + return s +} + +func waitSubListenEvent(t *testing.T, events chan subListenEvent, kind string) subListenEvent { + t.Helper() + select { + case e := <-events: + if e.kind != kind { + t.Fatalf("got event %q, want %q", e.kind, kind) + } + return e + case <-time.After(5 * time.Second): + t.Fatalf("timed out waiting for %q", kind) + return subListenEvent{} + } +} + +// TestSubscriptionsListen_TeardownKeepsOtherListens verifies that unwinding +// one listen leaves the other listens on the same session registered. +// Unsubscribe tears down the listen that Subscribe opened for the resource; +// the auto-listen opened by Connect must keep delivering list-changed +// notifications. +func TestSubscriptionsListen_TeardownKeepsOtherListens(t *testing.T) { + events := make(chan subListenEvent, 16) + server := newSubListenSubscribeServer() + + ct, st := NewInMemoryTransports() + ss, err := server.Connect(context.Background(), st, nil) + if err != nil { + t.Fatalf("server connect: %v", err) + } + defer ss.Close() + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + cs, err := newSubListenClient(events).Connect(ctx, ct, + &ClientSessionOptions{ProtocolVersion: protocolVersion20260728}) + if err != nil { + t.Fatalf("client connect: %v", err) + } + defer cs.Close() + + autoListen := waitSubListenEvent(t, events, "ack") + + if err := cs.Subscribe(ctx, &SubscribeParams{URI: "file:///r1"}); err != nil { + t.Fatalf("Subscribe: %v", err) + } + resourceListen := waitSubListenEvent(t, events, "ack") + if resourceListen.id == autoListen.id { + t.Fatalf("Subscribe reused subscription ID %s", autoListen.id) + } + + if err := cs.Unsubscribe(ctx, &UnsubscribeParams{URI: "file:///r1"}); err != nil { + t.Fatalf("Unsubscribe: %v", err) + } + // Wait for the server to unwind that listen, observed through a registry + // the auto-listen does not appear in. + waitUntil(t, 5*time.Second, "resource listen to unwind", func() bool { + server.mu.Lock() + defer server.mu.Unlock() + _, ok := server.resourceSubscriptions["file:///r1"] + return !ok + }) + + AddTool(server, &Tool{Name: "t2"}, sayHi) + if got := waitSubListenEvent(t, events, "tool"); got.id != autoListen.id { + t.Errorf("tool notification id = %s, want auto-listen %s", got.id, autoListen.id) + } +} + +// TestSubscriptionsListen_TeardownRetiresOwnRegistration verifies that a +// listen still retires what it registered, so that per-listen tracking does +// not turn into a leak. +func TestSubscriptionsListen_TeardownRetiresOwnRegistration(t *testing.T) { + events := make(chan subListenEvent, 8) + server := newSubListenServer() + + ct, st := NewInMemoryTransports() + ss, err := server.Connect(context.Background(), st, nil) + if err != nil { + t.Fatalf("server connect: %v", err) + } + defer ss.Close() + + // A client with no list-changed handlers does not auto-listen, so the + // listen opened below is the session's only one. + c := NewClient(testImpl, nil) + c.AddReceivingMiddleware(func(next MethodHandler) MethodHandler { + return func(ctx context.Context, method string, req Request) (Result, error) { + if method == notificationSubscriptionsAck { + events <- subListenEvent{"ack", ""} + } + return next(ctx, method, req) + } + }) + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + cs, err := c.Connect(ctx, ct, &ClientSessionOptions{ProtocolVersion: protocolVersion20260728}) + if err != nil { + t.Fatalf("client connect: %v", err) + } + defer cs.Close() + + listenCtx, cancelListen := context.WithCancel(context.Background()) + defer cancelListen() + if err := cs.subscriptionsListen(listenCtx, &SubscriptionsListenParams{ + Notifications: &NotificationSubscriptions{ToolsListChanged: true, PromptsListChanged: true}, + }); err != nil { + t.Fatalf("listen: %v", err) + } + waitSubListenEvent(t, events, "ack") + + server.mu.Lock() + _, inTool := server.toolChangeSubscriptions[ss] + _, inPrompt := server.promptChangeSubscriptions[ss] + server.mu.Unlock() + if !inTool || !inPrompt { + t.Fatal("listen not registered") + } + + cancelListen() + waitUntil(t, 5*time.Second, "registrations to be retired", func() bool { + server.mu.Lock() + defer server.mu.Unlock() + _, inTool := server.toolChangeSubscriptions[ss] + _, inPrompt := server.promptChangeSubscriptions[ss] + return !inTool && !inPrompt + }) +} + +// waitUntil polls cond until it reports true, failing the test after timeout. +func waitUntil(t *testing.T, timeout time.Duration, what string, cond func() bool) { + t.Helper() + deadline := time.Now().Add(timeout) + for !cond() { + if time.Now().After(deadline) { + t.Fatalf("timed out waiting for %s", what) + } + time.Sleep(10 * time.Millisecond) + } +} + // TestSubscriptionsListen_RespectsServerCapabilities verifies that during // Connect the client only opens a SEP-2575 subscriptions/listen stream for the // change notifications the server advertised during capability negotiation. diff --git a/mcp/server.go b/mcp/server.go index b2f89f4..569abab 100644 --- a/mcp/server.go +++ b/mcp/server.go @@ -1276,10 +1276,19 @@ func (s *Server) subscriptionsListen(ctx context.Context, req *SubscriptionsList } s.mu.Unlock() defer func() { + // Retire only what this listen registered above: a session can hold + // several listens at once, and deleting by session alone retires a + // registration belonging to a listen that is still open. s.mu.Lock() - delete(s.toolChangeSubscriptions, req.Session) - delete(s.promptChangeSubscriptions, req.Session) - delete(s.resourceChangeSubscriptions, req.Session) + if allowed.ToolsListChanged { + delete(s.toolChangeSubscriptions, req.Session) + } + if allowed.PromptsListChanged { + delete(s.promptChangeSubscriptions, req.Session) + } + if allowed.ResourcesListChanged { + delete(s.resourceChangeSubscriptions, req.Session) + } s.mu.Unlock() }()