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.
This commit is contained in:
parent
3210c2b34c
commit
a82c86a6cd
3 changed files with 140 additions and 8 deletions
|
|
@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in a new issue