Skip to content

mcp: choose the interaction pattern from the negotiated protocol version - #1266

Open
jmrplens wants to merge 2 commits into
modelcontextprotocol:mainfrom
jmrplens:jmrp-mrtr-uses-the-negotiated-version
Open

jmrplens wants to merge 2 commits into
modelcontextprotocol:mainfrom
jmrplens:jmrp-mrtr-uses-the-negotiated-version

Conversation

@jmrplens

@jmrplens jmrplens commented Sep 13, 2026

Copy link
Copy Markdown
Contributor

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 when the session ran initialize and falls back to the declared value otherwise, and ServerSession.speaksLegacyProtocol on top of it. That predicate is what all four places now read, so choosing an interaction pattern is one decision rather than 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

Not every session has a negotiated version, and for those the declared one is correct. A session created through server/discover records its version in InitializeParams alone, and so does the state the stateless handler synthesizes for a request carrying MCP-Protocol-Version; reading NegotiatedProtocolVersion alone would hand both of them latestProtocolVersion and break the second. NegotiatedProtocolVersion was added in #1199, so session state persisted before that carries only the declared version and is covered by the same fallback.

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. speaksLegacyProtocol settles it the first way, which is what SEP-2575 says a session without an initialize handshake is.

Server.handle records the declared version on the first call a new-protocol client makes, and server/discover records it in its own handler, so for any session that has issued a call the branch is unreachable either way. What it still covers is a session that has issued none: one that has connected and not yet sent anything, which notifySessions iterates because it walks every session in s.sessions, and one that sends resources/subscribe before initialize, which handle does not gate on initialized. Both are a client talking before the handshake, which the specification does not allow, and in the first case the notification it loses is answered by the tools/list it will send right after initializing.

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 fourth row fails before the change; the others document that nothing else moves. TestSpeaksLegacyProtocol_NoHandshakeIsNotLegacy is the same table read from the notification side, and pins the one session whose group this change moves.

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 moves the other way in the two notification loops: it was counted legacy and is now counted new-protocol, so it is no longer sent a notification on the session channel. As above, that is a session that has issued no call yet.

No other session changes. A client that reaches 2026-07-28 through server/discover, which is what this SDK's own client does, records no negotiated version 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.

Comment thread mcp/server.go Outdated

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should not we use the newly introduced ProtocolVersion() function here as well?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, both places make the same decision this PR is about, and both make it the way the PR is arguing against: they read InitializeParams().ProtocolVersion where the question is what the session actually speaks. A client that asks for protocolVersion20260728 in initialize is negotiated down, because that method is deprecated in that version, and both loops would then count it as modern and leave it out of legacySessions. It would be served the new delivery mechanism for a version it never agreed to speak.

There is one part of it that is not a mechanical substitution, and I would rather raise it than quietly change it. Both loops treat a session with no InitializeParams as legacy, through isNil(). clientSupportsMultiRoundTrip treats that same absence as the new protocol, since it defaults to latestProtocolVersion: a session that ran no handshake is a SEP-2575 session. The unexported protocolVersion() returns "" for that case, so replacing the condition literally would flip how those sessions are classified for notifications.

I believe the SEP-2575 reading is the right one and that these two places are wrong about it too, but that is a wider behaviour change than the one this PR carries, and it deserves a test of its own rather than arriving as a side effect.

On locking, in case it comes up: there is no new lock order here. InitializeParams() already takes ss.mu and is already called inside both loops while s.mu is held, so s.mu then ss.mu is the order in place today. Going through protocolVersion() also takes the session lock once per session instead of twice.

Happy to do it either way: fold both call sites into this PR, with a negotiated-down test for each, or keep this one to the interaction-pattern decision and take the notification paths separately. #1265 already proposes per-session ResourceUpdated delivery, so they may belong together there. Tell me which you prefer and I will push it.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think you can fold both calls in this PR. In case of empty InitializeParams, it should be considered as a new protocolVersion. It won't be a breaking change as the handle() method sets the initializeParams also for new protocol calls, so the isNil() check is effectively dead here.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done, both folded in, and the classification is the one you asked for: no recorded version reads as the new protocol in both loops.

On isNil() being dead, I read it the same way you do for any session that has ever made a call: handle records the declared version on the first new-protocol call and server/discover records it in its own handler, so for those the branch is unreachable. It is not quite dead, though, and I would rather say where it still fires than have it surface later as a surprise:

  • notifySessions walks every session in s.sessions, including one that has connected and not yet sent anything. That session was legacy and got the list-changed notification on the session channel; now it is counted new-protocol and gets nothing, since it has no subscriptions/listen stream. pendingNotifications is a debounce timer rather than a replay queue, so the notification is dropped rather than deferred.
  • resources/subscribe is not behind the initialized gate in handle (its case only rejects new-protocol requests), so a client that subscribes before initialize reaches ResourceUpdated with no InitializeParams either.

Both are a client talking before the handshake, which the specification does not allow, and in the first case the notification it loses is answered by the tools/list it sends right after initializing. So I agree the SEP-2575 reading is the right one; I have only written down what it covers, so it reads as a decision rather than as a side effect.

The change is one predicate rather than two more copies of the condition: ServerSession.speaksLegacyProtocol reports legacy only for a version older than 2026-07-28, clientSupportsMultiRoundTrip is its negation, and all four places read it. The description is updated to match.

Comment thread mcp/server.go Outdated

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

same as above

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed, and this one has a sharper edge than notifySessions. The classification here does not only decide which channel is used: a session sorted into newSessions is sent the notification with its per-session subscription id injected into _meta. A client negotiated down to protocolVersion20251125 would receive a notification carrying metadata its version does not define, rather than simply receiving it on the other path.

The isNil() question from the other thread applies here unchanged, so I would treat the two together. Same offer: in this PR with a test each, or separately alongside #1265.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Folded in here too, in eca2285: ResourceUpdated classifies by ServerSession.speaksLegacyProtocol now, so the negotiated-down session is notified on the session channel and the notification no longer carries io.modelcontextprotocol/subscriptionId. Details in the other thread.

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.
@jmrplens
jmrplens force-pushed the jmrp-mrtr-uses-the-negotiated-version branch from eca2285 to f4111a5 Compare September 14, 2026 18:31
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.

mcp: the declared protocol version, not the negotiated one, decides what a session is served

2 participants