Skip to content

feat(mcp): serve the stateless 2026-07-28 revision alongside the legacy handshake - #1678

Merged
thymikee merged 5 commits into
mainfrom
claude/mcp-v2-compatibility-604uv6
Aug 8, 2026
Merged

feat(mcp): serve the stateless 2026-07-28 revision alongside the legacy handshake#1678
thymikee merged 5 commits into
mainfrom
claude/mcp-v2-compatibility-604uv6

Conversation

@thymikee

@thymikee thymikee commented Aug 7, 2026

Copy link
Copy Markdown
Member

Summary

agent-device mcp now serves the stateless MCP 2026-07-28 revision alongside the handshake-based revisions it already spoke — the spec's dual-era server.

2026-07-28 removes the initialize handshake. Each request carries its own protocol version and client capabilities in _meta, and clients probe server/discover to 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 to initialize" — so nothing was broken today. The failure case is the compatibility matrix's Modern 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/discover advertises the supported revisions, the tools capability, and server identity.
  • Modern requests — those declaring a version in _meta — get resultType: "complete" and _meta["io.modelcontextprotocol/serverInfo"] on their results.
  • ttlMs/cacheScope on tools/list and server/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.
// → {"method":"server/discover","params":{"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28", ...}}}
{"resultType":"complete","supportedVersions":["2026-07-28","2025-11-25","2025-06-18"],
 "capabilities":{"tools":{}},"instructions":"agent-device drives iOS, Android, …",
 "ttlMs":3600000,"cacheScope":"public",
 "_meta":{"io.modelcontextprotocol/serverInfo":{"name":"agent-device","version":"0.20.6"}}}

Also fixes a live bug the era split surfaced. initialize returned 2025-11-25 whatever the client asked for (supportedProtocolVersion(_params) ignored its argument outright). A client pinned to 2025-06-18 was 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_VERSIONS is deliberately scoped to 2026-07-28, 2025-11-25, and 2025-06-18 rather than every published revision: those three share an identical tools-only surface here, while revisions before 2025-06-18 predate the outputSchema/structuredContent that every typed tool returns. Claiming them would be a registry claim without a semantic check behind it.

Legacy responses are byte-identical. initialize and ping are still served, and no cache, resultType, or _meta field 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.ts with protocol-era.ts removed 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 handleMcpMessage directly, before → after: server/discover -32601 → full DiscoverResult; initialize(2025-06-18) 2025-11-252025-06-18; modern tools/list gains resultType/ttlMs: 3600000/cacheScope: public while the legacy call still returns exactly ["tools"]; modern tools/call returns resultType: complete.

Full unit bundle green (639 files, 5736 passed), plus check:affected, lint, format, and check:fallow --base origin/main clean 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/call dispatch 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

…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
@github-actions

github-actions Bot commented Aug 7, 2026

Copy link
Copy Markdown

Size Report

Metric Base Current Diff
JS raw 1.99 MB 1.99 MB +2.4 kB
JS gzip 644.2 kB 645.0 kB +808 B
npm tarball 781.0 kB 781.9 kB +892 B
npm unpacked 2.73 MB 2.73 MB +2.4 kB

Startup median (7 runs, lower is better):

Scenario Base Current Diff
CLI --version 26.1 ms 28.4 ms +2.4 ms
CLI --help 64.2 ms 66.2 ms +2.0 ms

Top changed chunks: no changes in the largest emitted chunks.

@thymikee

thymikee commented Aug 7, 2026

Copy link
Copy Markdown
Member Author

Not ready at 4354216:

[P2] Keep 2025 revisions on the legacy wire contract. resolveProtocolEra treats every declared supported version as modern, so a request declaring 2025-11-25/2025-06-18 receives 2026-only resultType/serverInfo/cache fields; negotiateLegacyProtocolVersion also accepts 2026 for initialize even though that handshake is removed in the modern era. Split modern and legacy membership and add router tests for declared-2025 requests plus initialize(2026).

[P2] Reject malformed modern request metadata. server/discover without _meta is currently promoted to modern, and declared-modern requests do not require clientCapabilities. The 2026 request contract requires the protocol-version and client-capability metadata; add black-box rejection cases rather than locking the missing-metadata behavior in.

… 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
@thymikee
thymikee force-pushed the claude/mcp-v2-compatibility-604uv6 branch from 547d226 to d22c6e6 Compare August 7, 2026 19:56

thymikee commented Aug 7, 2026

Copy link
Copy Markdown
Member Author

Both findings were real. Fixed in d22c6e6.

[P2] 2025 revisions on the legacy wire contract. Membership is now split — MODERN_PROTOCOL_VERSIONS / LEGACY_PROTOCOL_VERSIONS — and the declared revision picks the era rather than the mere presence of _meta:

  • tools/list declaring 2025-11-25 or 2025-06-18 returns exactly {tools} again; no resultType, no serverInfo, no cache hints.
  • negotiateLegacyProtocolVersion only echoes within the legacy set, so initialize(2026-07-28) answers 2025-11-25 instead of agreeing to a handshake that revision removed.
  • server/discover requires a modern revision, since it exists in no legacy one.

[P2] Malformed modern metadata. server/discover is no longer promoted when _meta is absent, and any declared revision must come with clientCapabilities — both are Required in RequestMetaObject. Missing/ill-typed metadata is -32602, matching how the router already reports malformed params.

Two calls I made that are worth your judgment, since the spec doesn't spell either out:

  1. server/discover declaring a legacy revision returns -32022 rather than -32602. Reasoning: the client is asking for a real revision on an RPC we only serve at 2026, and -32022 carries data.supported so it can retry at 2026-07-28 — which is exactly the "modern server, wrong version" outcome the stdio backward-compat rules tell clients to handle. -32602 would instead read as "legacy server" and send it to initialize.
  2. DiscoverResult.supportedVersions still lists all three. A dual-era server does support all three; a client picking a 2025 entry gets that revision's wire contract, which is now self-consistent. Happy to narrow it to modern-only if you'd rather supportedVersions mean "servable over this wire".

Tests: four black-box router cases (declared-2025 on both revisions, initialize(2026), server/discover with missing and with legacy metadata, declared revision without clientCapabilities) plus the unit coverage. Reverting protocol-era.ts to the previous commit gives 4 failed | 7 passed (11) — the four new cases fail and the earlier seven stay green.

Also fixed a typecheck break I pushed in the first attempt at this: PREFERRED_LEGACY_PROTOCOL_VERSION was derived by indexing a readonly string[], which is string | undefined under noUncheckedIndexedAccess. It broke Typecheck and FreeRange on 547d226 — same three errors in both — and is why that SHA was force-updated. My mistake was piping the gate output into tail before the &&, so the pipeline exit code masked the failure and the push ran anyway.


Generated by Claude Code

@thymikee

thymikee commented Aug 8, 2026

Copy link
Copy Markdown
Member Author

Not G2G yet. The two prior findings are closed, but two protocol-boundary gaps remain:

  1. [P2] Reject malformed supplied client identity. resolveProtocolEra validates protocolVersion and clientCapabilities but ignores io.modelcontextprotocol/clientInfo. In 2026-07-28 it is optional/SHOULD, so absence is fine; when present, however, it must be a valid Implementation. Today clientInfo: 42 is accepted and the request is served. Validate present clientInfo and add a router-level negative regression.

  2. [P2] Gate legacy-only methods by the resolved era. handleRequest receives era but serves initialize and ping unconditionally. A request carrying valid 2026 _meta can therefore call removed initialize/ping and receive a successful modern resultType: complete envelope; initialize can even answer with a legacy protocolVersion inside it. The modern method registry should return -32601 for those methods, while metadata-free legacy calls remain unchanged. Add black-box router cases for modern-framed initialize and ping.

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 ready-for-human.

…-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

thymikee commented Aug 8, 2026

Copy link
Copy Markdown
Member Author

Both fixed in 7494115.

[P2] Malformed clientInfo. A present value must now be an Implementation — string name and string version — matching how clientCapabilities was already validated. clientInfo: 42, {name: 'c'}, {version: '1'}, and {name: 1, version: 2} all return -32602. Absence stays legal, since the field is SHOULD rather than MUST, and that case is asserted too so the optionality can't regress into a requirement.

[P2] Legacy-only methods gated by era. initialize and ping now answer -32601 to modern-framed callers. The gate is isMethodRemovedInEra(method, era), checked before dispatch, so the removed set lives next to the era model rather than being spread across switch cases. Metadata-free legacy calls are untouched — each new test asserts the -32601 and that the same method still succeeds without _meta, so the gate can't silently widen into breaking legacy clients.

You were right that the modern envelope made this worse rather than merely untidy: initialize was reporting a legacy protocolVersion inside a resultType: "complete" result, which is two contradictory era claims in one message.

Red proof: reverting protocol-era.ts and router.ts to d22c6e6 with the new tests kept gives 2 failed | 11 passed (13) — only the two new cases fail, the eleven from prior rounds stay green.

Gates: full unit bundle (639 files, 5746 passed), typecheck, lint, format, and check:fallow --base origin/main clean on the 5 changed files. No device evidence, per your note and for the same reason as before — nothing here leaves the stdio boundary.


Generated by Claude Code

@thymikee

thymikee commented Aug 8, 2026

Copy link
Copy Markdown
Member Author

[P2] Validate the complete supplied clientInfo, not only its required fields. isImplementation now rejects missing/non-string name and version, but still accepts malformed recognized optional fields such as {name:'c', version:'1', websiteUrl:42}, title:42, description:42, icons:42, or malformed icon entries. The final 2026-07-28 Implementation schema types those fields (while allowing unknown extension fields), and official SDK behavior rejects a supplied malformed clientInfo. Validate every recognized field when present—including Icon src and typed optional members—and add router regressions for a wrong scalar plus malformed icons. The previous clientInfo finding is therefore only partially closed; do not apply ready-for-human yet.

…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

thymikee commented Aug 8, 2026

Copy link
Copy Markdown
Member Author

Fixed in ba544ed. You were right that the previous round only closed half of it — I validated the required fields and stopped, which let {name:'c', version:'1', websiteUrl:42} through.

Implementation is now checked field by field when present:

Field Check
name, version required, non-empty string
title, description, websiteUrl string
icons array of Icon
Icon.src required, non-empty string
Icon.mimeType string
Icon.sizes array of string
Icon.theme "light" | "dark"

Unrecognized keys still pass, per your note — _meta payloads carry extension fields, and rejecting those would reject the future.

Regressions at both layers: a wrong scalar for each optional field, icons: 42, icons: [{}], icons: [{src: 42}], and one per malformed typed icon member (mimeType: 42, sizes: [48], sizes: 'any', theme: 'blue'). I also pinned the opposite direction, since over-rejection is the natural failure mode of a change like this: a fully populated clientInfo — every optional field set, plus a com.example/extension key — must still be served, alongside the existing omitted-is-legal case.

Red proof: reverting protocol-era.ts to 7494115 with the new tests kept gives 2 failed | 23 passed (25), failing on title: 42 and websiteUrl: 42 — exactly the gap you identified.

Gates: unit bundle 639 files / 5746 passed, typecheck, lint, format, and fallow clean. One note on that run: the first pnpm test:unit was OOM-killed by the sandbox mid-run (exit 137, Killed, no failing test), so I re-ran it with --maxWorkers=4 to get a real verdict rather than reporting a green from a truncated run.


Generated by Claude Code

thymikee commented Aug 8, 2026

Copy link
Copy Markdown
Member Author

iOS Smoke Tests went red on ba544ed. I've re-run the failed job; my read is that it's the iOS lane, not this diff, but here is the evidence rather than an assertion.

What failed here: the XCUITest suite, exit 65 — RunnerTests.testBareTypeUsesTappedInputWhenSoftwareKeyboardIsHidden(), 33 executed / 2 failures. The log shows the software keyboard never materialising: Checking existence of 'Keyboard (First Match)' (retry 1) / (retry 2) looping for ~8s, then AGENT_DEVICE_RUNNER_TEXT_ENTRY_PHASE phase=focus durationMs=8117.2 mode=none.

Why it isn't this change: the diff is src/mcp/{protocol-era,router}.ts, their tests, and CHANGELOG — git diff --name-only origin/main...HEAD matches nothing under apple/, ios, or *.swift. The failing test is Swift, in the XCUITest runner, exercising keyboard-hidden text entry; there is no path from stdio JSON-RPC framing to it. The same job also passed on d22c6e6 earlier in this PR, and every commit since touched only MCP protocol code and its tests.

State of the lane: the most recent main iOS run (e6b4fa28, ~1h before mine) also failed this job — but I want to be precise, because it is not the same failure. main died in the live E2E scenario smoke:automation-input with COMMAND_FAILED: alert wait timed out (exit 1); mine died in the XCUITest suite (exit 65). Different assertions, same lane, and both are simulator-timing waits — an alert that never appeared there, a keyboard that never appeared here. So I can say the iOS lane is unhealthy on main right now; I cannot say "the identical test fails on main."

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 ba544ed are green.


Generated by Claude Code

@thymikee

thymikee commented Aug 8, 2026

Copy link
Copy Markdown
Member Author

P2: isImplementation still rejects schema-valid empty clientInfo.name / version. stringField requires value.length > 0, but the official 2026-07-28 schema defines both as required string fields without a minimum length. A modern request carrying { name: "", version: "" } therefore gets -32602 even though its Implementation is valid. Use plain string type checks for these two fields and add a router-level positive regression for empty strings. The new optional-field/Icon validation otherwise closes the prior finding.

…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

thymikee commented Aug 8, 2026

Copy link
Copy Markdown
Member Author

Correct, and it's the over-rejection I'd claimed to be guarding against one comment earlier — I reused stringField, which requires length > 0, for fields the schema declares as plain string. Fixed in c18c34f: required now means present and a string.

I extended it one field beyond the literal ask, which is worth your eye. Icon.src had the same stringField check, so {src: ""} was rejected while {src: "not-a-uri"} passed — we don't enforce its format: uri annotation, so refusing only the empty one was arbitrary rather than stricter. src is now a plain string check too. Say the word if you'd rather src stay non-empty and I'll narrow it back to name/version.

Regressions are positive ones this time: empty name/version and an empty Icon.src must all be served, at both the unit and router layers, sitting next to the malformed cases so the validator is pinned in both directions.

Red proof: reverting protocol-era.ts to ba544ed with the new tests kept gives 2 failed | 23 passed (25).

Gates: unit bundle 639 files / 5746 passed, typecheck, lint, format, fallow clean.

Separately — the iOS Smoke Tests failure on ba544ed was re-run and the details are in my previous comment; c18c34f starts a fresh CI pass over that lane anyway.


Generated by Claude Code

@thymikee

thymikee commented Aug 8, 2026

Copy link
Copy Markdown
Member Author

Re-reviewed c18c34f: the remaining protocol-schema finding is closed. clientInfo.name / version and Icon.src now use plain string type checks, so schema-valid empty strings are accepted, while malformed scalar and optional fields remain rejected. The router-level regression exercises the real modern tools/list path. Code review clean; no device evidence applies to this stdio framing change.

@thymikee thymikee added the ready-for-human Valid work that needs human implementation, judgment, or maintainer merge label Aug 8, 2026
@thymikee
thymikee merged commit 588a419 into main Aug 8, 2026
30 checks passed
@thymikee
thymikee deleted the claude/mcp-v2-compatibility-604uv6 branch August 8, 2026 10:48
@github-actions

github-actions Bot commented Aug 8, 2026

Copy link
Copy Markdown
PR Preview Action v1.8.1
Preview removed because the pull request was closed.
2026-08-08 10:48 UTC

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ready-for-human Valid work that needs human implementation, judgment, or maintainer merge

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants