Skip to content

mcp: record the negotiated protocol version on every path that records one - #1274

Open
jmrplens wants to merge 1 commit into
modelcontextprotocol:mainfrom
jmrplens:jmrp-record-the-negotiated-version-on-every-path
Open

jmrplens wants to merge 1 commit into
modelcontextprotocol:mainfrom
jmrplens:jmrp-record-the-negotiated-version-on-every-path

Conversation

@jmrplens

Copy link
Copy Markdown
Contributor

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.

…s one

Only the initialize handshake wrote NegotiatedProtocolVersion. The three
other paths that record a session's version, the first new-protocol call
in ServerSession.handle, server/discover, and the state the streamable
handler synthesizes from MCP-Protocol-Version, wrote InitializeParams
alone, so every reader of the negotiated field saw a SEP-2575 session as
having no version. The stdio connection reads nothing else: it took such a
session for 2025-03-26 and accepted the JSON-RPC batches that 2025-06-18
and later forbid, which the streamable handler already refuses from the
header.

Each path now records the version it serves under. handle records the
declared version once the check against the server's supported list has
accepted it, which is the only negotiation SEP-2575 has: a version the
server does not speak is refused with the list it does. It is not put
through negotiatedVersion, which serves the deprecated handshake and caps
its answer below 2026-07-28; that would downgrade every new-protocol
session with no handshake response to tell the client. discover records
the version it was asked about when its answer lists it, since such a
client keeps speaking it, and leaves the field empty when it does not,
because that client picks another version on its next call. The
synthesized state records the header, which the transport spec defines as
the version negotiated earlier, or the 2025-03-26 it has a server assume
without one.

The field stays empty in state a caller supplies with InitializeParams
alone and in state persisted before it existed, and its doc says so, so a
reader that needs the version a session speaks still has the declared one
to fall back on.
Comment thread mcp/server.go
Comment on lines 1967 to 1968
if validatedMeta.usesNewProtocol &&
!slices.Contains(ss.server.protocolVersions, validatedMeta.initializeParams.ProtocolVersion) {

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 realized here the check should be done against ss.supportedVersions

if validatedMeta.usesNewProtocol &&
		!slices.Contains(ss.supportedVersions, validatedMeta.initializeParams.ProtocolVersion)

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.

I tried this and a test refuses it, so I have left the check where it was for now.

StreamableServerTransport.SupportsProtocolVersion (streamable.go:877) returns t.Stateless && ... for any version at or above 2026-07-28, so on a stateful session ss.supportedVersions does not contain 2026-07-28 at all. Validating an incoming new-protocol request against it refuses the one request whose whole job is to ask what the server supports. TestStreamableStateful_AcceptsDiscover goes from 200 to 400 with:

{"code":-32022,"message":"unsupported protocol version",
 "data":{"supported":["2025-11-25","2025-06-18","2025-03-26","2024-11-05"],"requested":"2026-07-28"}}

That test exists because a SEP-2575 client probes with server/discover carrying _meta.protocolVersion: 2026-07-28 before it knows anything, and a stateful server has to answer it. A discover probe cannot be required to speak the version it is asking about.

Your underlying point stands, though, and I would rather not lose it: server/discover answers with Session.supportedVersions (server.go:928) while this check reads Server.protocolVersions, so a transport that filters versions out advertises one set and validates against another, and the error's Supported field then names versions the client was never offered.

The shape that does work, which I ran against the full package with -race: judge methodDiscover against the server's list and every other new-protocol method against the session's, and carry that same list into Supported. Happy to put it in this PR or in a follow-up, whichever you prefer.

jmrplens added a commit to jmrplens/gitlab-mcp-server that referenced this pull request Sep 15, 2026
… entry (#784)

## What this does

Records three upstream contributions this record did not have, and
corrects one it had wrong.

## The correction

Entry 46, the auto-merge cancellation, said "!255239, open". That merge
request is closed unmerged and the work is now two others, so the record
was stating something false about the state of an upstream contribution,
which is the one thing this file exists to get right.

`!255239` changed the handler so its response matched the documentation.
@phikai answered that this is a breaking change whichever way it is
argued, since it alters the response of a stable endpoint, and
@marc_shaw proposed the shape actually taken: leave the old endpoint
behaving exactly as it does, deprecate it in the documentation, and add
`cancel_auto_merge` under current naming. That is !255702 and !255704.

His reason is recorded with it, because it applies to every future
contribution of this shape and is written nowhere in the code: "we
basically can't deprecate our API, by introducing another endpoint, we
are now maintaining the old and the new". It is why the deprecation is a
documentation notice rather than an entry in
`doc/api/rest/deprecations.md`, which promises removals, and why a
symmetric `add_to_auto_merge` was declined in the same message.

## The two new entries

Reviewing the MRTR pull request turned up two go-sdk defects that are
not about MRTR:

- **49** — three methods served on a legacy session before the handshake
([#1271](modelcontextprotocol/go-sdk#1271),
fixed by
[#1273](modelcontextprotocol/go-sdk#1273)). The
gate sat in the `default` branch of the method switch, so every method
with a `case` of its own escaped it. `resources/subscribe` is the one
that matters: it starts a watcher and registers a subscriber, so a
client that subscribes before `initialize` is delivered updates for the
lifetime of a session the server never agreed to. It reaches our
surface, since ADR-0015 makes the first read the authorization check.
- **50** — the negotiated protocol version recorded on one code path of
four
([#1272](modelcontextprotocol/go-sdk#1272),
fixed by
[#1274](modelcontextprotocol/go-sdk#1274)). This
one names the transport we lead with: `ioConn.sessionUpdated` reads only
`NegotiatedProtocolVersion`, so a SEP-2575 session over **stdio** is
read as `2025-03-26` and accepts JSON-RPC batches, which `2025-06-18`
removed and which the streamable handler already refuses. Two transports
disagreeing about the same session shape is worth writing down whether
or not the fix lands upstream.

## Three findings kept because they are rules, not details

Each entry keeps the reasoning that outlives the change it came from.

On the GitLab side: the old endpoint's own request spec never arms an
auto-merge and cannot notice, because its fixture is already mergeable
with no pipeline in progress, so `availability_details` errors and
`auto_merge_enabled` stays false while the endpoint answers `201`
unconditionally and the spec asserts only `:created`. That spec would
pass with an empty handler, which is the second reason the defect
survived. The false contract was also published in the CI-gated
`doc/api/openapi/openapi_v3.yaml`, and the `406` in the `desc` failure
list can never fire, since `not_acceptable!` is called in exactly one
place in the codebase.

On the go-sdk side: why widening the initialization gate from calls to
notifications was declined, since `notifications/cancelled` has no case
of its own and a client giving up on a slow `initialize` would have its
cancellation refused; and why validating against the session's version
list refuses `server/discover` on a stateful session, which a test
catches and which the obvious reading of the code does not.

## Verification

```
npx markdownlint-cli2 docs/development/upstream-bugs.md   # clean
go run ./cmd/format_md_tables/ --check                     # up to date
go run ./cmd/audit_doc_tool_names/ --check                 # clean
```

Both new table rows were checked to resolve against the headings they
link to.
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 negotiated protocol version is recorded on one path of four, so stdio accepts batches a SEP-2575 session forbids

2 participants