Skip to content

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

Merged
guglielmo-san merged 10 commits into
modelcontextprotocol:mainfrom
jmrplens:jmrp-mrtr-uses-the-negotiated-version
Nov 3, 2026
Merged

guglielmo-san merged 10 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 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.

Comment thread mcp/server.go Resolved
Comment thread mcp/server.go Resolved
@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 Resolved
Comment thread mcp/server.go Resolved
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>
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>
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.
@jmrplens
jmrplens force-pushed the jmrp-mrtr-uses-the-negotiated-version branch from e679031 to da8f87c Compare September 27, 2026 21:55
jmrplens added a commit to jmrplens/gitlab-mcp-server that referenced this pull request Sep 27, 2026
…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
jmrplens added a commit to jmrplens/gitlab-mcp-server that referenced this pull request Sep 29, 2026
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.
@jmrplens

Copy link
Copy Markdown
Contributor Author

A data point from current main (f286395) that I think makes this pull request more pressing than when I opened it.

Since #1226 (e40f35d), a session negotiated down from 2026-07-28 gets a worse answer than it did on v1.8.0. A client asks for 2026-07-28 in initialize, is answered 2025-11-25, and calls a tool whose handler returns InputRequests, with no _meta on the call. On main the result is:

{"content":null,"requestState":"state-1","inputRequests":{"title":{"method":"elicitation/create","params":{"mode":"form","message":"Enter the title"}}}}

On v1.8.0, and on 5bc078a just before #1226, the same call carried "resultType":"input_required" beside those fields. The dispatcher still chooses the multi round-trip flow from the declared version (clientSupportsMultiRoundTrip in mcp/mrtr.go), while the label is now written only when the request carries the new protocol in its _meta (validatedMeta.usesNewProtocol in ServerSession.handle). So the result goes out shaped for 2026-07-28 with neither the label nor any content, a 2025-11-25 client reads it as a complete result with content: null, and the input request is lost.

With this branch merged into main, the same call sends elicitation/create to the client instead, which is what a 2025-11-25 session expects.

The branch now conflicts with main only in the imports of mcp/mrtr_test.go. Merged locally with main, it passes go vet ./... and go test ./.... I can push that merge whenever it suits you, and I can paste the reproduction too (one test over NewInMemoryTransports that writes the two requests by hand).

@jmrplens

jmrplens commented Nov 1, 2026

Copy link
Copy Markdown
Contributor Author

I merged main into the branch (one import conflict in mcp/mrtr_test.go), and go vet ./... and go test ./... pass.

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 elicitation/create, and since #1226 that result no longer carries resultType: input_required, so on eafb671 it reads as complete with content: null and the question is lost.

Comment thread mcp/server.go Resolved
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.
@guglielmo-san
guglielmo-san merged commit ef18c16 into modelcontextprotocol:main Nov 3, 2026
9 checks passed
@guglielmo-san

Copy link
Copy Markdown
Contributor

@jmrplens thank you for the contribution!

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