Skip to content

mcp: allow restricting the protocol versions a server supports - #1199

Merged
guglielmo-san merged 13 commits into
modelcontextprotocol:mainfrom
guglielmo-san:mcp-supported-protocol-versions
Aug 28, 2026
Merged

guglielmo-san merged 13 commits into
modelcontextprotocol:mainfrom
guglielmo-san:mcp-supported-protocol-versions

Conversation

@guglielmo-san

@guglielmo-san guglielmo-san commented Aug 26, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #945

@guglielmo-san
guglielmo-san force-pushed the mcp-supported-protocol-versions branch from 6e8c6fb to 127f147 Compare August 26, 2026 13:17
Comment thread mcp/transport.go Resolved
Comment thread mcp/server.go Resolved
Comment thread mcp/server.go Resolved
Comment thread mcp/streamable.go Resolved
@guglielmo-san
guglielmo-san merged commit 21c18c6 into modelcontextprotocol:main Aug 28, 2026
10 checks passed
guglielmo-san added a commit that referenced this pull request Sep 21, 2026
…s one (#1274)

Closes #1272. Implements what you asked in #1266: "what do you think
about setting the `ss.state.NegotiatedProtocolVersion` also in case of
new protocol version?"

Four places record a protocol version and only
`ServerSession.initialize` recorded it as negotiated. The other three
record `InitializeParams` alone: `Server.handle` from a new-protocol
client's `_meta`, `server/discover` from the version it was asked about,
and the streamable handler from the `MCP-Protocol-Version` header. All
three now record the negotiated field too.

The concrete failure this closes is on stdio. `ioConn.sessionUpdated`
reads only `NegotiatedProtocolVersion`, so a SEP-2575 session over stdio
was treated as `2025-03-26` and accepted JSON-RPC batches, which
`2025-06-18` removed. The streamable handler already refuses them by
reading the header, so the two transports disagreed about the same
session shape. Recording the version on the `_meta` path settles it.

Why recording it as negotiated is accurate on those paths rather than a
rename of a declared value: the support check each already applies is
all the negotiation SEP-2575 has. There is no handshake response in
which a downgrade could be told to the client, so a version those paths
accept is one the server supports. `handle` refuses a `_meta` version
outside `ss.server.protocolVersions` with
`CodeUnsupportedProtocolVersion` before recording anything, `discover`
records only a version its own answer lists, and the streamable header
is what the transport page defines as the version negotiated earlier.
`initialize` remains the one path that downgrades, as the lifecycle page
requires, with the result visible in `InitializeResult.ProtocolVersion`.

One deliberate non-refusal: a request with no header at all, on a server
that narrowed out `2025-03-26`, is still served as `2025-03-26`. The
transport page says a server SHOULD assume that version when the header
is absent, and nothing was declared to refuse.

`ServerSessionState.NegotiatedProtocolVersion` is exported, so its doc
comment is rewritten to say what it now means on each path. The change
is additive in JSON and is not breaking for any client that works today,
with one exception worth stating plainly: a SEP-2575 client that sends
JSON-RPC batches over stdio is now refused, which is the defect being
fixed.

Tests: `TestServerHandle_RecordsNegotiatedVersionOnNewProtocolCall`,
`TestServerDiscover_RecordsNegotiatedVersion`, and the old-protocol row
of `TestEphemeralConnectOpts`. All three fail without the change.

`go test ./mcp/ -count=1` is green, `gofmt -l` empty, `go vet ./...`
clean. Based on main, so it does not depend on #1266, and it rebases
onto it cleanly with the suite green on the combination.

If you would rather have this inside #1266, since it answers a question
raised there, say so and I will fold it in and close this. It carries
one small follow-up either way: #1266's `protocolVersion()` doc says a
SEP-2575 session carries its version in `InitializeParams` alone, which
stops being true once this lands. The fallback itself stays, for a
caller-supplied `ServerSessionOptions.State` and for state persisted
before #1199.

Co-authored-by: Guglielmo Colombo <guglielmoc@google.com>
guglielmo-san added a commit that referenced this pull request Nov 3, 2026
…ion (#1266)

`initialize` is deprecated in `2026-07-28`, so `negotiatedVersion` caps
that handshake below it on purpose: a client asking for `2026-07-28`
there is answered `2025-11-25`. `InitializeParams.ProtocolVersion` keeps
whatever the client asked for, and four places that decide how to talk
to a session read that value instead of the version the handshake
settled on.

The result is that such a session gets none of the interaction the
protocol offers it. `clientSupportsMultiRoundTrip` marks the result
`input_required` and attaches an `inputRequests` map, which `2025-11-25`
does not define, while `assertServerInitiatedRequestAllowed` refuses
`elicitation/create`, `sampling/createMessage` and `roots/list`, which
are the mechanism that session does have, with an error naming a version
it never got. The second function's own doc comment already describes it
as a check on a session "negotiated at protocol version >= 2026-07-28".

`Server.notifySessions` and `Server.ResourceUpdated` lose the same
session a second way: it is not counted a legacy subscriber, so it is
not notified on the shared session channel, and it never opened the
`subscriptions/listen` stream the other branch delivers on, because that
method does not exist in the version it negotiated. `ResourceUpdated`
goes further and stamps the per-session subscription id into the
notification's `_meta`, which that version does not define either.

This change adds `ServerSession.protocolVersion`, which answers with
`ServerSessionState.NegotiatedProtocolVersion` and falls back to the
declared value when the state recorded none, and
`ServerSession.negotiatedLegacyProtocol` on top of it. All four places
now read the version through `protocolVersion`, three of them through
`negotiatedLegacyProtocol`, so the version that chooses an interaction
pattern is read in one place rather than in four copies of a condition.

## Why all four and not only the first

They are one decision and they have to agree. I tried fixing
`clientSupportsMultiRoundTrip` alone, and the failure gets worse rather
than better: the server-side shim then calls `ServerSession.Elicit`, the
second check refuses it, and a tool call that used to return a quietly
ignored result returns

```
multi-round-trip: fulfilling input request "confirm": "elicitation/create" cannot be sent while serving a request on protocol version 2026-07-28: return an InputRequests map instead (multi round-trip requests, SEP-2322)
```

The two notification paths are the same decision again. I first sent
this without them and offered to take them separately; they are folded
in here at review's request.

## Why the fallback

Since #1274, every path in the SDK that records a version records the
negotiated one too: `initialize`, `server/discover`, the first call of a
SEP-2575 session and the state the stateless handler synthesizes for a
request carrying `MCP-Protocol-Version`. The fallback stays for state
the SDK did not write itself: `ServerSessionOptions.State` is exported,
so a caller may supply `InitializeParams` with no negotiated version,
and `NegotiatedProtocolVersion` was added in #1199, so state persisted
before that carries only the declared version.

## What a session with no recorded version is

The four places disagreed about this. `clientSupportsMultiRoundTrip`
defaults to `latestProtocolVersion` and reads the absence as the new
protocol; the two notification loops read `InitializeParams().isNil()`
and read it as legacy, as `assertServerInitiatedRequestAllowed` does.
For the three places that read it, `negotiatedLegacyProtocol` settles it
the second way, at review's request.

`ServerSession.handle` records the declared version on the first call a
new-protocol client makes, before the call is dispatched, and
`server/discover` records it in its own handler, while since #1273 a
legacy call before `initialize` is refused, so no call reaches
`clientSupportsMultiRoundTrip` on such a session. What the reading still
covers is a session that has issued no call: one that has connected and
not yet sent anything, which `notifySessions` iterates because it walks
every session in `s.sessions`, and which keeps receiving the
notification on the session channel, as on `main`. Subscribing before
the handshake does not put such a session in front of `ResourceUpdated`:
since #1273, a `resources/subscribe` sent before `initialize` is refused
and leaves no subscriber.

## Tests

`TestMultiRoundTrip_NegotiatedDownFromNewProtocol` is the test that pins
this. It drives a raw JSON-RPC client that sends `initialize` declaring
`2026-07-28`, asserts the server answers `2025-11-25`, calls a tool
whose handler returns an `InputRequests` map, and asserts that what
comes back is an `elicitation/create` request the client can answer,
followed by the completed tool result. Before the change the next
message is the tool result itself, carrying `resultType:
"input_required"`, and the test fails there.

`TestNotifySessions_NegotiatedDownFromNewProtocol` drives the same
handshake, adds a tool, and asserts the list-changed notification
arrives on the session channel. Before the change it blocks until the
deadline.

`TestResourceUpdated_NegotiatedDownFromNewProtocol` subscribes on that
session and asserts both halves: the notification arrives, and it does
not carry `io.modelcontextprotocol/subscriptionId`. Before the change it
fails on the second.

`TestClientSupportsMultiRoundTrip` is a table over session states: no
handshake, a `server/discover` session, an `initialize` session at a
legacy version, an `initialize` session negotiated down, and the
synthesized state of a stateless legacy request. The first and fourth
rows fail before the change; the others document that nothing else
moves. `TestNegotiatedLegacyProtocol_NoHandshakeIsLegacy` is the same
table read from the notification side.

## What an existing user sees change

A session that ran `initialize` asking for `2026-07-28` and was answered
an older version is now served the legacy interaction. A handler
returning `InputRequests` no longer produces an `input_required` result
for it: the server fulfils the requests itself and re-invokes the
handler once, which is what the SDK already does for every other client
below `2026-07-28`. `ServerSession.Elicit`, `CreateMessage`,
`CreateMessageWithTools` and `ListRoots` stop returning an error on such
a session and send the request. It is also counted a legacy subscriber,
so list-changed and resource-updated notifications reach it on the
session channel, without a subscription id in `_meta`.

A session that has recorded no protocol version at all is still counted
legacy in the two notification loops, as on `main`, and
`clientSupportsMultiRoundTrip` now reads it as legacy too instead of as
the latest version, which no call can observe, as above.

No other session changes. A client that reaches `2026-07-28` through
`server/discover`, which is what this SDK's own client does, has had
that version recorded as negotiated since #1274, and is read exactly as
before.

## What I left out

There is one read of the declared version I deliberately did not touch:
the exported `ServerRequest.ProtocolVersion` falls back to
`InitializeParams.ProtocolVersion`, which its doc comment states, and
its only caller in the SDK is `Server.discover`, on the new-protocol
path where the two values agree.

Fixes #1258.

---------

Co-authored-by: Guglielmo Colombo <guglielmoc@google.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Set list of supported versions

2 participants