Repository navigation
mcp: allow restricting the protocol versions a server supports - #1199
Merged
guglielmo-san merged 13 commits intoAug 28, 2026
Merged
guglielmo-san merged 13 commits into
guglielmo-san merged 13 commits into
Conversation
…ted and advertised protocol versions
guglielmo-san
force-pushed
the
mcp-supported-protocol-versions
branch
from
August 26, 2026 13:17
6e8c6fb to
127f147
Compare
…lVersions function call
…function was deprecated or removed
…ntation for better fallback behavior
…nsure consistent version matching
…nsport and simplify documentation regarding version negotiation
…upported versions in shared.go
…te HTTP header against server-specific allowed versions
…equested protocol versions
yarolegovich
approved these changes
Aug 28, 2026
This was referenced Sep 13, 2026
Closed
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #945