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] 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,