feat(mcp): serve the stateless 2026-07-28 revision alongside the legacy handshake - #1678
Conversation
…cy handshake MCP 2026-07-28 drops the initialize handshake: each request carries its protocol version and client capabilities in `_meta`, and clients probe `server/discover` to tell a modern server from a handshake-only one. agent-device answered that probe with -32601, so a dual-era client fell back to `initialize` and a modern-only client had no way to connect at all. Serve both eras from the one stdio process, which is what the spec calls a dual-era server: - `server/discover` advertises the supported revisions, the tools capability, and server identity. - A request declaring a protocol version in `_meta` is served modern: its result carries `resultType: "complete"` and `_meta["io.modelcontextprotocol/serverInfo"]`. - `tools/list` and `server/discover` return `ttlMs`/`cacheScope`, so clients can cache the 55-tool ~223KB list instead of re-fetching it every start. The list was already emitted sorted, which is the other half of what makes it cacheable. - A declared revision we do not implement is rejected with `UnsupportedProtocolVersionError` (-32022) naming the ones we do. Also fixes legacy version negotiation, which the era split surfaced: `initialize` returned 2025-11-25 whatever the client asked for, so a client pinned to 2025-06-18 was answered with a revision it had not requested — the lifecycle contract's cue to disconnect. It now echoes the requested revision when we implement it, and otherwise names the newest legacy one we do. Legacy responses are otherwise byte-identical: `initialize` and `ping` are still served, and no cache, `resultType`, or `_meta` field is added to them. The stdio transport, the tool set, and every tool's schema are untouched, so the CLI, Node, and daemon surfaces are unaffected. Era handling lives in its own module so the router stays a dispatcher. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TKWswqMZ6Z9UAQDAda83Xz
Size Report
Startup median (7 runs, lower is better):
Top changed chunks: no changes in the largest emitted chunks. |
|
Not ready at 4354216: [P2] Keep 2025 revisions on the legacy wire contract. [P2] Reject malformed modern request metadata. |
… modern metadata Review found the era model was too loose in two ways. Membership was one flat set, so a request declaring 2025-11-25 or 2025-06-18 through modern `_meta` was served the 2026-only envelope (`resultType`, `serverInfo`, cache hints) — fields absent from those revisions' schemas — and `initialize` would echo 2026-07-28, agreeing to a revision whose handshake the modern era removed. Split modern and legacy membership so the declared revision picks the wire contract: 2025 declared through modern framing is answered legacy-shaped, `server/discover` requires a modern revision because it exists in no legacy one, and `initialize` negotiates only within the legacy set. Modern request metadata is now required rather than guessed. `_meta` carries `protocolVersion` and `clientCapabilities` as required fields, so `server/discover` without them is malformed instead of being promoted to modern, and a half-declared `_meta` is rejected as invalid params (-32602) rather than having its lenient handling locked in by tests. Adds black-box router cases for declared-2025 requests, initialize(2026), `server/discover` with missing and with legacy metadata, and a declared revision without client capabilities. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TKWswqMZ6Z9UAQDAda83Xz
547d226 to
d22c6e6
Compare
|
Both findings were real. Fixed in [P2] 2025 revisions on the legacy wire contract. Membership is now split —
[P2] Malformed modern metadata. Two calls I made that are worth your judgment, since the spec doesn't spell either out:
Tests: four black-box router cases (declared-2025 on both revisions, Also fixed a typecheck break I pushed in the first attempt at this: Generated by Claude Code |
|
Not G2G yet. The two prior findings are closed, but two protocol-boundary gaps remain:
No device evidence is needed for this stdio protocol-only change. All current checks are green and the branch is clean/mergeable, but these source findings block |
…-07-28 removed Two protocol-boundary gaps from review. `clientInfo` was read but never checked. The field is optional in 2026-07-28, so its absence is fine, but a supplied one must be an `Implementation` — `clientInfo: 42` was accepted and the request served. A present value now has to carry string `name` and `version`, matching how `clientCapabilities` is already validated; omitting it stays legal. `initialize` and `ping` were served regardless of era, so a request carrying valid modern `_meta` could call methods its own revision deleted and get a `resultType: "complete"` envelope back — with `initialize` reporting a legacy `protocolVersion` inside a modern result. Both are now gated by the resolved era and answer -32601 to modern-framed callers, while metadata-free legacy calls keep working unchanged. Adds black-box router cases for modern-framed `initialize`/`ping` (each paired with its still-working legacy call) and for malformed `clientInfo`, including the omitted-is-legal case. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TKWswqMZ6Z9UAQDAda83Xz
|
Both fixed in [P2] Malformed [P2] Legacy-only methods gated by era. You were right that the modern envelope made this worse rather than merely untidy: Red proof: reverting Gates: full unit bundle (639 files, 5746 passed), typecheck, lint, format, and Generated by Claude Code |
|
[P2] Validate the complete supplied |
…quired ones
The previous round checked `name` and `version` but let malformed recognized
optional fields through, so `{name:'c', version:'1', websiteUrl:42}` — and the
same for `title`, `description`, and `icons` — was accepted and served.
`Implementation` validation now type-checks each recognized field when
present: `title`, `description`, and `websiteUrl` as strings, and `icons` as
an array of `Icon`, where `src` is required and `mimeType`, `sizes`, and
`theme` are typed when supplied (`theme` against its `light`/`dark` union).
Unrecognized keys still pass — `_meta` payloads carry extension fields, and
rejecting those would reject the future.
Adds regressions across both layers: a wrong scalar per optional field, a
wrong icons container, an icon entry missing `src`, and each malformed typed
icon member. The positive cases pin the other direction — a fully populated
clientInfo carrying an extension key must still be served, so the validator
cannot harden into rejecting what the spec allows.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TKWswqMZ6Z9UAQDAda83Xz
|
Fixed in
Unrecognized keys still pass, per your note — Regressions at both layers: a wrong scalar for each optional field, Red proof: reverting Gates: unit bundle 639 files / 5746 passed, typecheck, lint, format, and fallow clean. One note on that run: the first Generated by Claude Code |
|
iOS Smoke Tests went red on What failed here: the XCUITest suite, exit 65 — Why it isn't this change: the diff is State of the lane: the most recent I can't reproduce either locally — this container is Linux with no Xcode or simulator, so the honest position is that the re-run is the evidence. If it comes back green, transient. If it reproduces on the same test, that's a signal about the test rather than about this PR, and I'll say so instead of retrying again. All other checks on Generated by Claude Code |
|
P2: |
…elds
`isImplementation` reused `stringField`, which requires a non-empty string, for
the required `name` and `version`. The 2026-07-28 schema declares both as plain
`string` with no minimum length, so `{name: "", version: ""}` is a conforming
`Implementation` and was being answered -32602. Required now means present and
a string.
`Icon.src` gets the same treatment. Its `format: uri` annotation is not
something this server enforces — any other non-URI string is accepted — so
rejecting the empty one alone was arbitrary rather than stricter.
Adds positive regressions at both layers for empty `name`/`version` and an
empty `Icon.src`, alongside the existing malformed cases, so the validator is
pinned against over-rejection as well as under-rejection.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TKWswqMZ6Z9UAQDAda83Xz
|
Correct, and it's the over-rejection I'd claimed to be guarding against one comment earlier — I reused I extended it one field beyond the literal ask, which is worth your eye. Regressions are positive ones this time: empty Red proof: reverting Gates: unit bundle 639 files / 5746 passed, typecheck, lint, format, fallow clean. Separately — the iOS Smoke Tests failure on Generated by Claude Code |
|
Re-reviewed |
|
Summary
agent-device mcpnow serves the stateless MCP2026-07-28revision alongside the handshake-based revisions it already spoke — the spec's dual-era server.2026-07-28removes theinitializehandshake. Each request carries its own protocol version and client capabilities in_meta, and clients probeserver/discoverto tell a modern server from a handshake-only one. We answered that probe with-32601, which is exactly the signal a dual-era client reads as "legacy server, fall back toinitialize" — so nothing was broken today. The failure case is the compatibility matrix'sModern client → Legacy server, which the spec marks Fails: a client that has dropped legacy support has no way to reach us.For a stdio-only, tools-only server, almost none of the revision's blast radius applies. Session removal,
Mcp-Session-Id, Streamable HTTP, SSE resumability, routing headers, OAuth/DCR→CIMD,subscriptions/listen, MRTR, and tasks are all remote-transport or stateful-feature territory we don't use. What's left is a small additive delta:server/discoveradvertises the supported revisions, the tools capability, and server identity._meta— getresultType: "complete"and_meta["io.modelcontextprotocol/serverInfo"]on their results.ttlMs/cacheScopeontools/listandserver/discover. This is the one part that pays for itself rather than being compliance: the tool list is 55 tools / ~223KB, re-fetched on every client start, and is constant for a given binary. It was already emitted in sorted order, which is the other half of what SEP-2549 needs to make it cacheable.UnsupportedProtocolVersionError(-32022) naming the revisions we do implement, instead of serving a client under a version it did not ask for.Also fixes a live bug the era split surfaced.
initializereturned2025-11-25whatever the client asked for (supportedProtocolVersion(_params)ignored its argument outright). A client pinned to2025-06-18was answered with a revision it had not requested — which the lifecycle contract answers by disconnecting. It now echoes the requested revision when we implement it, and otherwise names the newest legacy one we do.SUPPORTED_PROTOCOL_VERSIONSis deliberately scoped to2026-07-28,2025-11-25, and2025-06-18rather than every published revision: those three share an identical tools-only surface here, while revisions before2025-06-18predate theoutputSchema/structuredContentthat every typed tool returns. Claiming them would be a registry claim without a semantic check behind it.Legacy responses are byte-identical.
initializeandpingare still served, and no cache,resultType, or_metafield is added to their results — verified by assertion, not by inspection. The stdio transport, the tool set, and every tool's input/output schema are untouched, so the CLI, Node, and daemon surfaces are unaffected.Note this adds compatibility behavior, which AGENTS.md asks for approval on first: dual-era was the explicitly requested shape, since the alternative (modern-only) would strand every client shipping today.
Validation
The four router tests were proven red against pre-fix code —
git checkout src/mcp/router.tswithprotocol-era.tsremoved gives 4 failed | 3 passed (7), with the three pre-existing tests still green, so the new ones pin the shipped dispatch path rather than the helper module.Driving
handleMcpMessagedirectly, before → after:server/discover-32601→ fullDiscoverResult;initialize(2025-06-18)2025-11-25→2025-06-18; moderntools/listgainsresultType/ttlMs: 3600000/cacheScope: publicwhile the legacy call still returns exactly["tools"]; moderntools/callreturnsresultType: complete.Full unit bundle green (639 files, 5736 passed), plus
check:affected, lint, format, andcheck:fallow --base origin/mainclean on the 5 changed files.No device verification applies — this is a protocol-framing change at the stdio boundary that touches no backend, no session flow, and no command execution path.
tools/calldispatch is unchanged apart from the result envelope.Scope: 5 files (2 source, 2 test, CHANGELOG), all within
src/mcp/. No follow-ups; the deprecated Roots/Sampling/Logging features and the HTTP+SSE transport are ones we never implemented.Generated by Claude Code