Skip to content

MCP server: support protocol revision 2026-07-28 (stateless / modern era) #1337

Description

@psp65

Summary

MCP protocol revision 2026-07-28 makes the protocol stateless: the initialize handshake is replaced by per-request versioning in params._meta, servers MUST implement server/discover, version mismatches return UnsupportedProtocolVersionError (-32022), every result carries a required resultType, and list results carry required ttlMs/cacheScope caching hints (SEP-2549).

smithy-java's mcp-server currently implements the handshake era only (2024-11-05 … 2025-11-25). Python SDK ≥ 2.0.0 clients (released 2026-07-28; the 1.x line is in maintenance mode since then) connect today only via their backward-compatibility fallback path — and that fallback currently triggers off an invalid response rather than clean negotiation. Clients pinned to the modern revision, or any strict client, fail outright.

We run a large internal MCP gateway built on this engine (it also powers our Coral-service MCP servers), have validated the gaps end-to-end against Python SDK 2.0.0, and have working, tested patches we'd like to contribute.

What breaks today

Validated against mcp==2.0.0 (mode=auto / mode=legacy / pinned mode="2026-07-28"):

  1. Unknown JSON-RPC methods return no response at all — both dispatch switches in McpService end in default -> null, so a request with an id (including the SDK's server/discover probe) gets an empty reply. This violates JSON-RPC, and the V2 SDK's auto-mode fallback only survives it by treating the resulting client-side parse error as legacy evidence. The description of Add MCP protocol version 2025-11-25 #1304 documents this exact symptom for initialize.
  2. initialize with an unknown protocolVersion answers the oldest versionhandleInitialize leaves the field unset for unknown versions, so the Smithy model default (2024-11-05) leaks into the result. The spec requires answering with the latest supported version. (Add MCP protocol version 2025-11-25 #1304 fixed one instance by adding 2025-11-25 to the known list; the general fallback remained.)
  3. No server/discover, no -32022 — modern-era requests carrying _meta["io.modelcontextprotocol/protocolVersion"] are served handshake-shaped results, which then fail the SDK's strict wire validation (ListToolsResult: cacheScope/resultType/ttlMs Field required) instead of getting a clean version error.
  4. No modern-era result shapingresultType, ttlMs/cacheScope (on tools/list / prompts/list), and the _meta["io.modelcontextprotocol/serverInfo"] stamp are absent.

Proposed contribution (PR ladder)

  1. Return -32601 Method not found for unknown methods that carry an id (notifications stay silently dropped). Makes the V2 auto-mode fallback deterministic instead of accidental.
  2. initialize with an unknown requested version answers the latest supported version, via a registry-derived ProtocolVersion.latestVersion() (single source, can't drift as versions are added).
  3. server/discover + -32022: discover answers a wire-complete DiscoverResult with supportedVersions from the registry; requests whose _meta names an unsupported version get -32022 with {"supported": [...], "requested": "..."}. A handshake-only server advertising no modern version is the documented "explicit legacy advertisement" that the Python/TypeScript/Go clients all answer by falling back to initialize cleanly — so this PR is safe and useful before full modern support lands.
  4. Dual-era serving (2026-07-28): per-request version resolution (params._meta wins when it names a known version; the transport-provided hint otherwise), era-conditional result stamping (resultType on all results; ttlMs/cacheScope on tools/list/prompts/list; _meta serverInfo) — era-conditional because peers strictly validate results per era in both directions, so handshake-era responses must stay byte-identical; initialize capped at the latest handshake version (modern versions negotiate per request, never via handshake — the stdio transport's captured connection version needs the same clamp); era-aware capabilities (modern advertisement drops listChanged: true, which we can't honor without subscriptions/listen); modern-era tool failures mapped to -32603 with a sanitized message.

Each step is fully covered by unit + integration tests, and we've verified the ladder end-to-end: after (4), a Python 2.0.0 client in auto mode adopts the modern era via discover (no initialize on the wire), pinned-modern clients pass strict wire validation through tools/list and tools/call, and legacy responses are byte-identical to before (verified by byte-diffing captures).

Open questions where we'd like maintainer input

  • (a) Cache-hint configuration: we default ttlMs: 0 / cacheScope: "private" (the Python reference server's defaults — safe for authenticated servers). Should there be a per-method configuration surface on McpServerBuilder, or are constants acceptable for a first pass?
  • (b) Pagination: we deliberately did not add nextCursorPaginate MCP tools/list and prompts/list #1308 added pagination and Revert "Paginate MCP tools/list and prompts/list" #1312 reverted it a day later, and 2026-07-28 makes each page independently cacheable. What's the current thinking there?
  • (c) Error hygiene: tool failures currently return JSON-RPC code 500 (outside the server-error space) with a full stack trace in the message (createErrorResponse has a TODO about this). We map modern-era failures to -32603 with a sanitized message and left legacy behavior untouched in case consumers depend on it — appetite for fixing legacy too?
  • (d) Unknown-tool errors currently share that same internal-error path; arguably they should be -32602 (invalid params).
  • (e) ping on modern-era requests: removed from the 2026-07-28 surface; the Python reference server answers it -32601 there. We stayed lenient (still serve it). Preference?
  • (f) stdio era semantics: the reference implementation locks a stream connection's era on the first request and refuses cross-era claims; our implementation resolves per request (more lenient). Preference?
  • (g) McpServerProxy pins one downstream protocol version at first-initialize and stamps it on every proxied request — modern passthrough would need the per-request version threaded through rpc(). We left this untouched; flagging it as follow-up work.

Timeline context

Python SDK 1.x has been in maintenance mode (security/critical fixes only) since 2026-07-28; the TypeScript v1 line is guaranteed bug/security fixes for at least six months from v2's release. There's no announced date for V2 SDKs dropping the legacy-handshake compatibility path, but when that happens, servers on this engine become unreachable for those clients — hence the interest in landing negotiation correctness (PRs 1–3) soon and the modern era (PR 4) after design alignment here.

We're happy to split, reshape, or re-scope any of this to fit your preferences.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions