Skip to content

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

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

jmrplens wants to merge 3 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.

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.

This is a gap in the SDK. In legacy sessions, no method can be server before the initialize handshake. We should open a separate PR to address this issue and modify the server handle() to return error on requests sent on legacy session before the initialize.

@jmrplens jmrplens Sep 15, 2026

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.

(ignore this) Parts of this are factually wrong; see the correction below in this thread.


Agreed, and a separate PR is the right place for it. I will open one that makes handle refuse a call on a legacy session before initialize.

Two things I will carry into it, so it is not just the one condition:

resources/subscribe is the case that made this visible, and it is not the only method outside the gate: the default branch is what enforces "invalid during initialization", so any method with a case of its own skips it. Subscribe is the one that reaches state, since it starts a watcher and delivers to a session that never handshook.

The refusal has to stay off the new-protocol path. A SEP-2575 session legitimately has no initialize, so the condition is the same predicate this PR settles on rather than a plain initialized check, or the fix would refuse exactly the sessions the feature exists for.

I will keep it independent of this one so neither waits on the other.

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.

Correction to what I wrote above, before it becomes a plan someone builds on.

I said the refusal should use "the same predicate this PR settles on rather than a plain initialized check". That is wrong. negotiatedLegacyProtocol reads an unrecorded version as not legacy, and before initialize a legacy session has recorded nothing, so a gate written on it would never fire.

The discriminator is the request, not the session: a call carrying no _meta.protocolVersion on a session with no recorded handshake. That is exactly the condition the default branch already applies; the fix is only that methods with a case of their own stop skipping it. The SEP-2575 path stays served through the usesNewProtocol exemption, which is where it was already.

One consequence worth knowing: this has no dependency on this PR, so it can land before or after it in either order.

Going through the methods with a case of their own, the ones that change are logging/setLevel, resources/subscribe and resources/unsubscribe. initialize and ping stay allowed, ping because the lifecycle spec exempts it. server/discover I would leave alone: a legacy request there already gets method-not-found, which is a better answer than an initialization error. The two notification cases are outside it, since the gate covers calls and a notification cannot carry a refusal.

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.

Implemented and open as #1273, with #1271 as the issue behind it. It is based on main and does not depend on this pull request, so it can land in either order; I verified it rebases onto this branch cleanly with the suite green on the combination.

It carries one open question I would rather you answered than I guessed: the refusal is a plain error, which on the wire is code 0, exactly what the default branch produced before. The specification prescribes no code for it. If you want -32600 or -32603 instead, it is a one-line change there.

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, and it went in the same day: f4111a5 folds both call sites onto the predicate, thirty-three minutes after your follow-up here. e679031 is the rename to negotiatedLegacyProtocol plus one word in its doc comment.

You were right about isNil() too, and it resolved itself rather than needing its own change: with both sites reading the predicate there is no InitializeParams().isNil() left in the file at all. The one remaining isNil() is res.isNil() at a different decision, on a result value.

Apologies for leaving this open for six days with the work already pushed. That is on me, not on the review.

Comment thread mcp/server.go Outdated
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
Comment thread mcp/server.go Outdated
Comment thread mcp/server.go
Comment on lines +2097 to +2099
defer ss.mu.Unlock()
if v := ss.state.NegotiatedProtocolVersion; v != "" {
return v

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.

what do you think about setting the ss.state.NegotiatedProtocolVersion also in case of new protocol version?

@jmrplens jmrplens Sep 15, 2026

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.

(ignore this) Parts of this are factually wrong; see the correction below in this thread.


I think it is the right direction and that it should not be this PR, because doing it properly changes behaviour rather than tidying a field.

Four places record a version today, and only one of them negotiates:

  • initialize records both, and NegotiatedProtocolVersion is the result of negotiatedVersion(params.ProtocolVersion, ...).
  • handle records InitializeParams from validatedMeta.initializeParams on the first new-protocol call. That is the version the client declared in _meta.
  • server/discover records InitializeParams with the version it was asked about.
  • streamable.go synthesizes InitializeParams from the MCP-Protocol-Version header, for old-protocol requests with no handshake.

So writing the declared version into NegotiatedProtocolVersion at the other three would put a value in a field whose name says it was agreed by both sides, when nobody agreed anything. That is the same confusion this PR is about, one field further in.

Running it through negotiatedVersion first would make it true, and then the fallback in protocolVersion() could go. What stops me proposing it here is that negotiation has a second half: initialize tells the client what it got, in InitializeResult.ProtocolVersion. None of the other three has a handshake response to say it in. A client that declared a version we downgrade would be served the older behaviour and never be told, which is worse than the current state, where the declared version is at least recorded as declared.

What I would suggest instead, as its own change: decide what those paths do when the declared version is one the server does not support at all. initialize answers with the closest supported version. The _meta path currently accepts whatever arrives. Refusing the call with an error is the honest equivalent of a downgrade the client can see, and once that is settled, recording the negotiated version at all four places is a mechanical follow-up and the fallback disappears.

If you would rather have the field written at all four now and treat the "declared but unsupported" question later, say so and I will push it here. My preference is to leave protocolVersion() preferring the negotiated value and falling back to the declared one, which reads as what it is: one place negotiates, the others record what they were told.

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.

Two corrections to my answer above, both in your favour.

I wrote that "the _meta path currently accepts whatever arrives". It does not. ServerSession.handle refuses a _meta version outside ss.server.protocolVersions with CodeUnsupportedProtocolVersion before anything is recorded, and #1268 routes an unrecognised string into that same check. The streamable header path answers an unsupported legacy header with 400 as the transport spec requires, and server/discover is a new-protocol call behind the same check, whose refusal carries Supported, which is how a SEP-2575 client learns the list. So the "decide what an unsupported declared version does" step I proposed as a prerequisite is already done everywhere it applies. initialize is the one path that downgrades instead of refusing, and the lifecycle spec requires exactly that.

Which means your suggestion is smaller than I made it sound: recording the negotiated version at the other three places is available now, and I was wrong to put a prerequisite in front of it.

The second correction is to my own conclusion. I implied that once the field is set everywhere, the fallback in protocolVersion() could go. It cannot. ServerSessionOptions.State is exported, so a caller may supply InitializeParams with no negotiated version, and state persisted before #1199 has none either. The fallback is what reads those correctly and should stay.

One thing that turned up while checking, which is an argument for doing this rather than a detail of it: ioConn.sessionUpdated reads only NegotiatedProtocolVersion, so over stdio a SEP-2575 session is currently treated as 2025-03-26 and accepts JSON-RPC batches that 2025-06-18 and later forbid. Recording the version on the _meta path fixes that as a side effect; the streamable handler already refuses them from the header.

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.

This is implemented and open as #1274, with #1272 as the issue behind it. It is based on main rather than on this branch, so it does not wait on this one.

Since the question was raised here, the offer stands the other way round too: if you would rather have it inside this pull request, say so and I will fold it in and close #1274. It is one commit.

Either way there is a small follow-up between the two, which I would rather name than leave for a rebase to surface. The doc comment protocolVersion() carries on this branch says a SEP-2575 session and a synthesized streamable state hold their version in InitializeParams alone. That stops being true once #1274 lands, on either path. The fallback itself stays, for a caller-supplied ServerSessionOptions.State and for state persisted before #1199; only the sentence needs a touch.

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.
guglielmo-san added a commit that referenced this pull request Sep 17, 2026
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>
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