Conversation
There was a problem hiding this comment.
should not we use the newly introduced ProtocolVersion() function here as well?
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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:
notifySessionswalks every session ins.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 nosubscriptions/listenstream.pendingNotificationsis a debounce timer rather than a replay queue, so the notification is dropped rather than deferred.resources/subscribeis not behind theinitializedgate inhandle(its case only rejects new-protocol requests), so a client that subscribes beforeinitializereachesResourceUpdatedwith noInitializeParamseither.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
eca2285 to
f4111a5
Compare
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.NegotiatedProtocolVersionwhen the session raninitializeand falls back to the declared value otherwise, andServerSession.speaksLegacyProtocolon 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
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
Not every session has a negotiated version, and for those the declared one is correct. A session created through
server/discoverrecords its version inInitializeParamsalone, and so does the state the stateless handler synthesizes for a request carryingMCP-Protocol-Version; readingNegotiatedProtocolVersionalone would hand both of themlatestProtocolVersionand break the second.NegotiatedProtocolVersionwas 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.
clientSupportsMultiRoundTripdefaults tolatestProtocolVersionand reads the absence as the new protocol; the two notification loops readInitializeParams().isNil()and read it as legacy.speaksLegacyProtocolsettles it the first way, which is what SEP-2575 says a session without aninitializehandshake is.Server.handlerecords the declared version on the first call a new-protocol client makes, andserver/discoverrecords 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, whichnotifySessionsiterates because it walks every session ins.sessions, and one that sendsresources/subscribebeforeinitialize, whichhandledoes not gate oninitialized. 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 thetools/listit will send right after initializing.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 fourth row fails before the change; the others document that nothing else moves.TestSpeaksLegacyProtocol_NoHandshakeIsNotLegacyis 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
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 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-28throughserver/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.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.