Skip to content

mcp: reject legacy-format requests on 2026-only servers - #1345

Open
Enigmage wants to merge 5 commits into
modelcontextprotocol:mainfrom
Enigmage:fix-2026-only-server-rejects-legacy
Open

Enigmage wants to merge 5 commits into
modelcontextprotocol:mainfrom
Enigmage:fix-2026-only-server-rejects-legacy

Conversation

@Enigmage

@Enigmage Enigmage commented Oct 6, 2026 •

Copy link
Copy Markdown

Fixes #1313.

Note: this PR does not change initialize behavior on a 2026-only server, it still succeeds with protocolVersion20251125 per the existing "server with no handshake version" test. We can discuss if we want to reject initialize with -32022 as well.

A server configured with SupportedProtocolVersions containing only versions >= 2026-07-28 should reject legacy clients that use the initialize/initialized handshake protocol with a clear CodeUnsupportedProtocolVersion error, rather than silently serving them in a degraded state.

What was broken

Two paths allowed legacy requests to slip through on a 2026-only server:

  1. Stateful, uninitialized: handle() reached the "method invalid during initialization" error path for any non-initialize call, but returned a generic error instead of CodeUnsupportedProtocolVersion. The caller had no signal that the server will never support the legacy protocol.

  2. Stateless: serveStateless() calls ephemeralConnectOpts(), which synthesizes fake InitializeParams for every request — making every session appear already initialized. This caused all legacy stateless calls to dispatch successfully and produce results, with no rejection at all.

Fix

server.go — stateful uninitialized path:

In the handle() branch for uninitialized legacy calls, detect when the server has no legacy-supporting version configured and return CodeUnsupportedProtocolVersion with an UnsupportedProtocolVersionData payload before falling through to the generic error.

streamable.go — stateless path:

In serveStateless(), after ephemeralConnectOpts(), check whether the server has any legacy-supporting version configured. If not, write a CodeUnsupportedProtocolVersion error response and return.

Behavior

  • 2026-only server + legacy initialize: allowed — negotiatedVersion returns protocolVersion20251125 as a courtesy per existing semantics in shared.go.
  • 2026-only server + legacy non-initialize call (stateful, uninitialized): rejected with CodeUnsupportedProtocolVersion.
  • 2026-only server + any legacy call (stateless): rejected with CodeUnsupportedProtocolVersion and HTTP 400.
  • Servers with any legacy version in SupportedProtocolVersions are unaffected.

Tests

  • TestServerSupportedProtocolVersions_InitializeLegacy: verifies initialize succeeds on a 2026-only server and returns protocolVersion20251125.
  • TestServerSupportedProtocolVersions_LegacyDispatch: verifies a legacy tools/list call without _meta is rejected with CodeUnsupportedProtocolVersion on a 2026-only stateful server.
  • TestStreamableStateless_RejectsLegacyOn2026OnlyServer: verifies a legacy POST to a stateless 2026-only server returns HTTP 400 with CodeUnsupportedProtocolVersion.

@Enigmage
Enigmage force-pushed the fix-2026-only-server-rejects-legacy branch from 597f2c0 to c47e426 Compare October 6, 2026 14:22
@Enigmage
Enigmage marked this pull request as ready for review October 6, 2026 14:32
@Enigmage
Enigmage marked this pull request as draft October 6, 2026 14:56
@Enigmage
Enigmage force-pushed the fix-2026-only-server-rejects-legacy branch from c47e426 to 79d2351 Compare October 6, 2026 14:59
@Enigmage
Enigmage marked this pull request as ready for review October 6, 2026 15:01
A server configured with SupportedProtocolVersions containing only
versions >= 2026-07-28 should not silently serve legacy clients that
use the initialize/initialized handshake protocol.

Previously, such clients would receive a successful (if confusing)
response: stateful servers would accept any post-initialize method call
because ephemeral session setup bypassed the version check, and
stateless servers would always appear initialized due to ephemeral
connect opts synthesizing fake InitializeParams.

Fix the stateful path by detecting a 2026-only server configuration
in the uninitialized branch of handle() and returning
CodeUnsupportedProtocolVersion before falling through to the generic
"method invalid during initialization" error.

Fix the stateless path by checking the legacy flag in serveStateless()
immediately after ephemeralConnectOpts(), before any method dispatch.

Both paths include an UnsupportedProtocolVersionData payload listing
the server's supported versions so clients can adapt.

Fixes modelcontextprotocol#1313
@Enigmage
Enigmage force-pushed the fix-2026-only-server-rejects-legacy branch from 914ee33 to d5a828d Compare October 6, 2026 15:09

@chrikrah chrikrah left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

@Enigmage requesting changes at 81b755b: the stateless rejection answers a request with an error that carries no id, so a client has nothing to match it to. The rest holds: both new rejection tests fail with server.go and streamable.go at the merge base and pass on your tree.

$ go test ./mcp/   # go1.25.3, tree at 81b755b
ok  	github.com/modelcontextprotocol/go-sdk/mcp	32.431s
$ go test ./mcp/ -run 'TestServerSupportedProtocolVersions|TestStreamableStateless_RejectsLegacyOn2026OnlyServer'   # both files at da6ad80, tests kept
--- FAIL: TestServerSupportedProtocolVersions_LegacyDispatch
--- FAIL: TestStreamableStateless_RejectsLegacyOn2026OnlyServer
$ go test ./mcp/ -run TestProbe -v   # probe: POST {"id":7,"method":"tools/list"} to a 2026-only server, no _meta
stateless=true  -> 400 {"jsonrpc":"2.0","error":{"code":-32022,...,"data":{"supported":["2026-07-28"],"requested":""}}}
stateless=false -> 200 data: {"jsonrpc":"2.0","id":7,"error":{"code":-32022,...,"data":{"supported":["2026-07-28"],"requested":""}}}

blocking: streamable.go:481 passes jsonrpc.ID{} to writeJSONRPCError. The stateful sibling at streamable.go:1736 passes jreq.ID, and #1313 itself expects "id":1 back. ephemeralConnectOpts decodes the ID and does not return it.

non-blocking: requested is empty on both paths. The sibling fills it from the request's version. A legacy request has no header, and ephemeralConnectOpts already falls back to 2025-03-26 for that, so passing that version (or omitempty) would say more than "".

@guglielmo-san you invited this PR on #1313. Should the stateless path take the ID from ephemeralConnectOpts, or decode the body a second time?

@guglielmo-san

Copy link
Copy Markdown
Contributor

@Enigmage i added some changes on top of your PR, let me know what you think

This branch has not been deployed

No deployments
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: 2026-only server silently serves legacy clients instead of returning -32022

3 participants