Repository navigation
mcp: choose the interaction pattern from the negotiated protocol version - #1266
guglielmo-san merged 10 commits into
Conversation
eca2285 to
f4111a5
Compare
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 <guglielmoc@google.com>
…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>
initialize is deprecated in 2026-07-28, so negotiatedVersion caps that handshake below it: a client that asks for 2026-07-28 there is answered 2025-11-25. InitializeParams.ProtocolVersion keeps whatever the client asked for, and two capability checks read that value instead of the negotiated one. clientSupportsMultiRoundTrip therefore served such a session an input_required result carrying an inputRequests map, which the version the session actually negotiated does not define, while assertServerInitiatedRequestAllowed refused the elicitation, sampling and roots requests that are the mechanism the session does have. The session was left with neither half of the interaction. The two checks are one decision and have to agree. Fixing only the first turns the silently ignored result into a hard error, because the server-side shim then calls ServerSession.Elicit and the second check refuses it. Both now read ServerSession.protocolVersion, which answers with the negotiated version and falls back to the declared one for a session that ran no initialize: a SEP-2575 session records its version in InitializeParams alone, as does the state synthesized for a stateless request, and for those the declared version is the version the session speaks.
notifySessions and ResourceUpdated read InitializeParams.ProtocolVersion to decide which delivery mechanism a session gets, so a client negotiated down from 2026-07-28 by the deprecated initialize handshake was counted a new-protocol session. It was not notified on the shared session channel, and it could not have opened the subscriptions/listen stream the other branch delivers on, because that method does not exist in the version it negotiated. ResourceUpdated went further and stamped the per-session subscription id into the notification's _meta, which that version does not define. Both now go through ServerSession.speaksLegacyProtocol, which reads the version the session speaks and reports legacy only for a version older than 2026-07-28. clientSupportsMultiRoundTrip is its negation, so the three places that choose an interaction pattern make one decision. A session that has recorded no version at all changes group: it was legacy through isNil() and is now new-protocol, which is what SEP-2575 says a session without an initialize handshake is, and what clientSupportsMultiRoundTrip already assumed. Server.handle records the declared version on the first call a new-protocol client makes, so this leaves only a session that has issued no call yet.
The decision it reports is about the version the session negotiated, not about what a client asked for, which is the whole point of the change, so the name says negotiated.
…ten comments modelcontextprotocol#1274 records NegotiatedProtocolVersion on server/discover, on the first SEP-2575 call and on the synthesized state of a streamable request, so the fallback to the declared version in protocolVersion now covers only a caller-supplied ServerSessionState and state saved by an older release. The doc comment said otherwise. The comments this change adds are cut to at most three lines each; the reasoning they carried is in the pull request description.
e679031 to
da8f87c
Compare
…tribution stands (#1018) I opened the joint client-go merge request today, [gitlab-org/api/client-go!3063](https://gitlab.com/gitlab-org/api/client-go/-/merge_requests/3063): 36 commits on v3.14.0, one per entry of `docs/development/upstream-bugs.md`, which is the single merge request the client-go maintainers and I agreed on in the umbrella issue. This updates the register so it says what is upstream and what each contribution is waiting on as of 2026-09-27, rather than what it said before the merge request existed. Every client-go row the merge request carries now reads it under Reported and In review, and each entry's own bullets name its commit and what the commit leaves out on purpose: keys GitLab itself deprecates (`merge_status` and its siblings, `epic_iid`, the epic's `end_date` and `web_edit_url`), the breaking halves left for 4.0 behind `V2` and `WithOptions` siblings, and the nested `DetailedStatus` gap of entry 43, which needs a type of its own and has no upstream change yet. Row 34's section describes the merge request, what it asks the maintainers to decide, and the two bot commands still owed on it: the label, since the triage bot copied `type::maintenance` from the umbrella and that cuts no release, and a ready naming a code owner. Five entries written for issue 971 (the award emoji URL, the diff flags, the deployment approval, the project share link and the group relation status) had a section and no summary row, so they get rows 61 to 65. The issue 972 branch adds a row 61 of its own for the Orbit schema format, and the issue 961 branch adds rows 61 to 63, so whichever of those lands after this renumbers. Entry 5 could not be reproduced. `GET /namespaces/:id` presents one `Entities::Namespace` on 19.4 and on the 19.5 branch, and the live record says the same, so the merge request leaves it out. The entry now says that the array fallback in `internal/tools/namespaces` is reached by no current GitLab: only an empty `id` gets there, and then it would answer with the first namespace of the caller's list. Removing it is a code change for its own pull request. Writing the commits corrected three entries, and I recorded each correction rather than rewriting the history. Entry 22's `target_type=epic` is refused by the events routes, which validate against `Event.target_types`, so the enums this server offers on the three event listings are wrong in the other direction too. Entry 43's claim that no pipeline entity sends `name` was wrong, since the two `WithMetadata` entities do. Entry 7's workaround decodes the statistics into `json.Number`, which refuses `"1,234"`, so it fails on any instance with a count of a thousand or more. The rest is the state of every other contribution, read the same day. A code owner ran the merged-results pipeline of the open `Hook` merge request. The umbrella's description and the go-sdk index are behind again, and the register says where. [gitlab-org/gitlab!255702](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/255702) was approved and put on a merge train, then failed the router job on routes `master` does not carry yet, which also showed that the register's sentence about a rebase clearing that job was wrong. [modelcontextprotocol/go-sdk#1266](modelcontextprotocol/go-sdk#1266) is behind `main` and owes the comment rewrite I promised on 2026-09-15 once [modelcontextprotocol/go-sdk#1274](modelcontextprotocol/go-sdk#1274) landed. The rmcp float fix is in the release pull request for 3.5.0, which is not tagged yet, so the Codex follow-up waits on that tag. Everything else is where its section left it, and the section says so with the date. Reading the umbrella issue again also showed that the `upstream-contribution` skill said two false things about it: that the issue carries no `type::` label, so a reference copies nothing, and that no maintainer had replied to it. The labeller gave it `type::maintenance` fifteen minutes after it was opened, which is how the joint merge request got it, and the maintainers agreed on the single merge request there on 2026-09-09 and 10, adding that no list of pending fields should be kept. The second commit corrects the skill: the merge request that finishes the umbrella closes it, a merge request referencing it needs its `type::` label replaced by comment, and the issue's table stops at the joint merge request. Only `docs/development/upstream-bugs.md` and the skill change. `format_md_tables --check`, markdownlint, `make check-doc-links`, `audit_doc_tool_names --check` and the em dash check pass. Part of #966
I read every go-sdk issue and pull request `docs/development/upstream-bugs.md` links, and this records what moved. Nothing there asks anything of us, and go-sdk has cut no release since v1.8.0, so every merge recorded is still unreleased; the maintainer's estimate of the next one, about two months after v1.8.0, is now cited. - **Row 11, [modelcontextprotocol/go-sdk#1266](modelcontextprotocol/go-sdk#1266 A maintainer updated the branch from `main` on 2026-09-29 and all nine checks passed; `main` gained two commits after it, and I updated it again the same day with GitHub's Update branch, which kept the maintainer's merge commit, and the checks passed again. It has had no review since 2026-09-15. - **Rows 8 and 12.** Both were six commits behind `main`; I updated them from `main` on 2026-09-29 and their checks passed. [modelcontextprotocol/go-sdk#1293](modelcontextprotocol/go-sdk#1293) has had no review, and the one review thread on [modelcontextprotocol/go-sdk#1267](modelcontextprotocol/go-sdk#1267) still waits on the reviewer. - **Row 68.** [modelcontextprotocol/go-sdk#1299](modelcontextprotocol/go-sdk#1299) was triaged P2 on 2026-09-28. [modelcontextprotocol/go-sdk#1300](modelcontextprotocol/go-sdk#1300), which another contributor opened, now conflicts with `main` and its checks have never run. The dates of my comment and review, and of the author's follow-up, are corrected to UTC. - **Row 48.** The case is named under "Known limitations" of [modelcontextprotocol/go-sdk#1275](modelcontextprotocol/go-sdk#1275), which another contributor opened and a maintainer merged on 2026-09-18, so the summary row and the entry say so, with the maintainer's position on that pull request. Its discussion also showed the entry's transport claim too wide: v1.8.0's stateful HTTP handler refuses every 2026-07-28 request but `server/discover`, so only stdio reaches the case. ## knowledge-graph - **Rows 75 and 76, [gitlab-org/orbit/knowledge-graph!2650](https://gitlab.com/gitlab-org/orbit/knowledge-graph/-/merge_requests/2650) and [gitlab-org/orbit/knowledge-graph!2651](https://gitlab.com/gitlab-org/orbit/knowledge-graph/-/merge_requests/2651).** Both descriptions now carry `[skip pinned-version-check]`, which the project's check asks for when a change does not affect the shape, so the merged-results pipeline a maintainer runs will not refuse either for want of a `query_dsl` bump. The marker sits at the top of each description's collapsed details block, where the project's description lint does not count it and the 2700 characters a pipeline reads still hold it. Each merge request also offers the bump instead: row 75's a patch bump, so clients that cache the schema by version fetch the new text, and row 76's should the maintainers read the declared default that branch changes as part of the contract. Both Files lists now name the test pins. ## Checks - `npx markdownlint-cli2 docs/development/upstream-bugs.md` passes. - `go run ./cmd/format_md_tables/ --check` passes. - No em dashes in the added lines.
|
A data point from current Since #1226 ( {"content":null,"requestState":"state-1","inputRequests":{"title":{"method":"elicitation/create","params":{"mode":"form","message":"Enter the title"}}}}On v1.8.0, and on With this branch merged into The branch now conflicts with |
|
I merged Could this go into 1.9.0? A session negotiated down from 2026-07-28 still gets a tool's input requests back as a result instead of an |
Co-authored-by: Guglielmo Colombo <guglielmoc@google.com>
negotiatedLegacyProtocol now reads a session that has recorded no protocol version as legacy, so the no handshake rows of TestClientSupportsMultiRoundTrip and of TestNegotiatedLegacyProtocol_NoHandshakeIsLegacy, renamed after the predicate, expect that, and its doc comment says so. That is how the notification loops read a session with no InitializeParams before this change, and how assertServerInitiatedRequestAllowed reads it. No call reaches such a session: a new-protocol call records its version before it is dispatched, and since modelcontextprotocol#1273 a legacy one is refused before initialize.
|
@jmrplens thank you for the contribution! |
initializeis deprecated in2026-07-28, sonegotiatedVersioncaps that handshake below it on purpose: a client asking for2026-07-28there is answered2025-11-25.InitializeParams.ProtocolVersionkeeps 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.
clientSupportsMultiRoundTripmarks the resultinput_requiredand attaches aninputRequestsmap, which2025-11-25does not define, whileassertServerInitiatedRequestAllowedrefuseselicitation/create,sampling/createMessageandroots/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.notifySessionsandServer.ResourceUpdatedlose 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 thesubscriptions/listenstream the other branch delivers on, because that method does not exist in the version it negotiated.ResourceUpdatedgoes 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 withServerSessionState.NegotiatedProtocolVersionand falls back to the declared value when the state recorded none, andServerSession.negotiatedLegacyProtocolon top of it. All four places now read the version throughprotocolVersion, three of them throughnegotiatedLegacyProtocol, 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
clientSupportsMultiRoundTripalone, and the failure gets worse rather than better: the server-side shim then callsServerSession.Elicit, the second check refuses it, and a tool call that used to return a quietly ignored result returnsThe 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 carryingMCP-Protocol-Version. The fallback stays for state the SDK did not write itself:ServerSessionOptions.Stateis exported, so a caller may supplyInitializeParamswith no negotiated version, andNegotiatedProtocolVersionwas 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.
clientSupportsMultiRoundTripdefaults tolatestProtocolVersionand reads the absence as the new protocol; the two notification loops readInitializeParams().isNil()and read it as legacy, asassertServerInitiatedRequestAlloweddoes. For the three places that read it,negotiatedLegacyProtocolsettles it the second way, at review's request.ServerSession.handlerecords the declared version on the first call a new-protocol client makes, before the call is dispatched, andserver/discoverrecords it in its own handler, while since #1273 a legacy call beforeinitializeis refused, so no call reachesclientSupportsMultiRoundTripon 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, whichnotifySessionsiterates because it walks every session ins.sessions, and which keeps receiving the notification on the session channel, as onmain. Subscribing before the handshake does not put such a session in front ofResourceUpdated: since #1273, aresources/subscribesent beforeinitializeis refused and leaves no subscriber.Tests
TestMultiRoundTrip_NegotiatedDownFromNewProtocolis the test that pins this. It drives a raw JSON-RPC client that sendsinitializedeclaring2026-07-28, asserts the server answers2025-11-25, calls a tool whose handler returns anInputRequestsmap, and asserts that what comes back is anelicitation/createrequest the client can answer, followed by the completed tool result. Before the change the next message is the tool result itself, carryingresultType: "input_required", and the test fails there.TestNotifySessions_NegotiatedDownFromNewProtocoldrives 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_NegotiatedDownFromNewProtocolsubscribes on that session and asserts both halves: the notification arrives, and it does not carryio.modelcontextprotocol/subscriptionId. Before the change it fails on the second.TestClientSupportsMultiRoundTripis a table over session states: no handshake, aserver/discoversession, aninitializesession at a legacy version, aninitializesession 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_NoHandshakeIsLegacyis the same table read from the notification side.What an existing user sees change
A session that ran
initializeasking for2026-07-28and was answered an older version is now served the legacy interaction. A handler returningInputRequestsno longer produces aninput_requiredresult 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 below2026-07-28.ServerSession.Elicit,CreateMessage,CreateMessageWithToolsandListRootsstop 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, andclientSupportsMultiRoundTripnow 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-28throughserver/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.ProtocolVersionfalls back toInitializeParams.ProtocolVersion, which its doc comment states, and its only caller in the SDK isServer.discover, on the new-protocol path where the two values agree.Fixes #1258.