Skip to content

Commit 588a419

Browse files
thymikeeclaude
andauthored
feat(mcp): serve the stateless 2026-07-28 revision alongside the legacy handshake (#1678)
* feat(mcp): serve the stateless 2026-07-28 revision alongside the legacy 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 * fix(mcp): keep 2025 revisions on the legacy wire contract and require 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 * fix(mcp): validate supplied client identity and gate the methods 2026-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 * fix(mcp): validate every recognized clientInfo field, not just the required 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 * fix(mcp): accept schema-valid empty strings in clientInfo identity fields `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 --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 9c25bc6 commit 588a419

5 files changed

Lines changed: 778 additions & 18 deletions

File tree

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22

33
## Unreleased
44

5+
- `agent-device mcp` now serves the stateless MCP `2026-07-28` revision alongside the handshake-based revisions it already spoke, as the spec's "dual-era server". Modern clients probe `server/discover`, which advertises the supported revisions, the tools capability, and server identity; their requests declare a protocol version in `_meta`, and their results carry `resultType: "complete"` plus `_meta["io.modelcontextprotocol/serverInfo"]`. `tools/list` and `server/discover` now return the `ttlMs`/`cacheScope` cache hints, so a client can cache the 55-tool, ~223KB tool list for an hour instead of re-fetching it on every start; the list was already emitted in a deterministic (sorted) order, which is the other half of what makes it cacheable. Each revision is answered on its own wire contract: a request declaring `2025-11-25` or `2025-06-18` through modern framing still gets the legacy result shape, and `initialize` never agrees to `2026-07-28`, which has no handshake to establish. A declared revision this server does not implement is rejected with `UnsupportedProtocolVersionError` (`-32022`) naming the ones it does, rather than being served under a version the client did not ask for, and modern framing that omits its required `protocolVersion`/`clientCapabilities` metadata — or supplies a `clientInfo` that is not a valid `Implementation` — is rejected as invalid params. `initialize` and `ping` were removed in `2026-07-28`, so a modern-framed call to either is answered `-32601` rather than served inside a `resultType: "complete"` envelope. Responses to legacy clients are unchanged byte-for-byte — `initialize` and `ping` are still served, and no cache, `resultType`, or `_meta` field is added to their results. Nothing here affects the CLI, Node, or daemon surfaces: the stdio transport, the tool set, and every tool's input/output schema are untouched.
6+
- Fixed: the MCP `initialize` handshake now answers with the protocol revision the client requested when it is one this server implements, instead of always answering `2025-11-25`. A client pinned to `2025-06-18` was told to speak a revision it had not asked for, which the lifecycle contract answers by disconnecting.
57
- `agent-device help workflow` is now a compact ~8KB card instead of a ~41KB dump; the same depth still exists, split into `help scripting` (save-script, secret-safe fills, batch JSON, replay divergence/repair, recording) and `help gestures` (multi-touch shapes and platform quirks), plus a few paragraphs folded into the topics that already owned the subject (`help debugging`, `help physical-device`, `help validate`). Every `help <topic>` first line is now `agent-device <version> — <topic>` so an agent can read the installed version from its mandatory first help read instead of a separate `agent-device --version` call.
68
- Cloud iOS (BrowserStack, AWS Device Farm): `snapshot` and `diff` no longer fail with `SESSION_NOT_FOUND` on a live provider session (#1658). The app-session guard they ran belongs to the local XCUITest runner, which must attach to a target app; a cloud capture reads the provider's own driver session and needs no app identity, so the guard now applies to local Apple targets only. Relatedly, a cloud iOS `open com.example.app` now records that bundle id on the session — the provider path skips local app resolution (no simctl/devicectl reaches a hosted device), and used to drop an explicitly spelled bundle id along with it, leaving the session with no app identity at all. Opening a second bundle id replaces the first, matching the local path, where an explicitly spelled target always wins over the session's current app; deep links, display names, and bare `open` still keep the app already tracked.
79
- Cloud `fill` (BrowserStack, AWS Device Farm) now witnesses that the field it tapped actually holds text-entry focus before sending its keys, instead of dispatching tap and keys in back-to-back requests (#1658). A WebView input — an OAuth/SSO page in a Safari view controller, for example — takes first responder asynchronously, so the keys used to land with nothing focused while `fill` still answered "Filled N chars"; tapping and filling as two separate commands worked only because the round trip between them gave the field time to focus. The witness is the focused element's own geometry: `fill` polls the active element and proceeds only once it contains the point it tapped, which is the one signal that identifies *which* field took focus. Keyboard visibility cannot — it reads the same before and after a second fill into an already-open form, so it could not tell a focused password field from the email field the previous fill left focused. The response discloses `textEntryReadiness`: `focused-element`, or `keyboard-shown` when the driver has no active-element route but the keyboard rose from hidden after the tap. Both describe a fill that witnessed focus before typing; there is deliberately no value for typing without evidence, because nothing renders this field and such a value would reach a caller as an ordinary success. Breaking: when focus cannot be witnessed, cloud `fill` now FAILS with `COMMAND_FAILED` / `text_entry_focus_not_observed` and sends no keys, instead of typing into whatever holds first responder and answering "Filled N chars" — a fill with no witness must not read as a filled field. That covers a tap that focused nothing, a keyboard already up on a driver that cannot name the focused field, and a driver that reports neither (`text_entry_focus_unobservable`, which points at `press` + `type` as the deliberate way to enter text unwitnessed). Only a positively classified unimplemented route counts as unsupported, so a dead session, an auth rejection, or a grid outage surfaces instead of degrading into a blind text entry.
Lines changed: 203 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,203 @@
1+
import assert from 'node:assert/strict';
2+
import { test } from 'vitest';
3+
import {
4+
cacheFields,
5+
finalizeResult,
6+
isMethodRemovedInEra,
7+
LEGACY_PROTOCOL_VERSIONS,
8+
MODERN_PROTOCOL_VERSION,
9+
MODERN_PROTOCOL_VERSIONS,
10+
negotiateLegacyProtocolVersion,
11+
PREFERRED_LEGACY_PROTOCOL_VERSION,
12+
resolveProtocolEra,
13+
UnsupportedProtocolVersionError,
14+
} from '../protocol-era.ts';
15+
16+
const modernMeta = (version: string = MODERN_PROTOCOL_VERSION) => ({
17+
_meta: {
18+
'io.modelcontextprotocol/protocolVersion': version,
19+
'io.modelcontextprotocol/clientCapabilities': {},
20+
},
21+
});
22+
23+
test('initialize echoes a revision the client asked for instead of overriding it', () => {
24+
// A client pinned to 2025-06-18 that is answered with a different revision is told to
25+
// disconnect by the legacy lifecycle contract.
26+
assert.equal(negotiateLegacyProtocolVersion({ protocolVersion: '2025-06-18' }), '2025-06-18');
27+
assert.equal(
28+
negotiateLegacyProtocolVersion({ protocolVersion: PREFERRED_LEGACY_PROTOCOL_VERSION }),
29+
PREFERRED_LEGACY_PROTOCOL_VERSION,
30+
);
31+
});
32+
33+
test('initialize falls back to the newest legacy revision for versions we do not implement', () => {
34+
assert.equal(
35+
negotiateLegacyProtocolVersion({ protocolVersion: '2024-11-05' }),
36+
PREFERRED_LEGACY_PROTOCOL_VERSION,
37+
);
38+
assert.equal(negotiateLegacyProtocolVersion({}), PREFERRED_LEGACY_PROTOCOL_VERSION);
39+
assert.equal(negotiateLegacyProtocolVersion(undefined), PREFERRED_LEGACY_PROTOCOL_VERSION);
40+
});
41+
42+
test('initialize never agrees to a modern revision, which has no handshake to establish', () => {
43+
for (const version of MODERN_PROTOCOL_VERSIONS) {
44+
assert.equal(
45+
negotiateLegacyProtocolVersion({ protocolVersion: version }),
46+
PREFERRED_LEGACY_PROTOCOL_VERSION,
47+
);
48+
}
49+
});
50+
51+
test('the declared revision picks the era, so 2025 stays on the legacy wire contract', () => {
52+
assert.equal(resolveProtocolEra('tools/list', modernMeta()), 'modern');
53+
for (const version of LEGACY_PROTOCOL_VERSIONS) {
54+
assert.equal(resolveProtocolEra('tools/list', modernMeta(version)), 'legacy');
55+
}
56+
assert.equal(resolveProtocolEra('tools/list', {}), 'legacy');
57+
assert.equal(resolveProtocolEra('tools/list', undefined), 'legacy');
58+
});
59+
60+
test('server/discover requires modern request metadata rather than being promoted', () => {
61+
assert.equal(resolveProtocolEra('server/discover', modernMeta()), 'modern');
62+
// No declared revision: malformed, not an invitation to guess the era.
63+
assert.throws(() => resolveProtocolEra('server/discover', undefined), /protocolVersion/);
64+
assert.throws(() => resolveProtocolEra('server/discover', { _meta: {} }), /protocolVersion/);
65+
// The RPC does not exist in any legacy revision.
66+
assert.throws(
67+
() => resolveProtocolEra('server/discover', modernMeta(PREFERRED_LEGACY_PROTOCOL_VERSION)),
68+
UnsupportedProtocolVersionError,
69+
);
70+
});
71+
72+
test('a declared revision without client capabilities is malformed', () => {
73+
assert.throws(
74+
() =>
75+
resolveProtocolEra('tools/call', {
76+
_meta: { 'io.modelcontextprotocol/protocolVersion': MODERN_PROTOCOL_VERSION },
77+
}),
78+
/clientCapabilities/,
79+
);
80+
assert.throws(
81+
() =>
82+
resolveProtocolEra('tools/call', {
83+
_meta: {
84+
'io.modelcontextprotocol/protocolVersion': MODERN_PROTOCOL_VERSION,
85+
'io.modelcontextprotocol/clientCapabilities': 'not-an-object',
86+
},
87+
}),
88+
/clientCapabilities/,
89+
);
90+
});
91+
92+
test('an unimplemented declared revision is rejected with the supported list', () => {
93+
assert.throws(
94+
() => resolveProtocolEra('tools/call', modernMeta('1900-01-01')),
95+
(error: unknown) => {
96+
assert.ok(error instanceof UnsupportedProtocolVersionError);
97+
assert.equal(error.data.requested, '1900-01-01');
98+
assert.ok(error.data.supported.includes(MODERN_PROTOCOL_VERSION));
99+
return true;
100+
},
101+
);
102+
});
103+
104+
test('a supplied clientInfo must be an Implementation, but omitting it is fine', () => {
105+
const withClientInfo = (clientInfo: unknown) => ({
106+
_meta: {
107+
'io.modelcontextprotocol/protocolVersion': MODERN_PROTOCOL_VERSION,
108+
'io.modelcontextprotocol/clientCapabilities': {},
109+
'io.modelcontextprotocol/clientInfo': clientInfo,
110+
},
111+
});
112+
113+
const valid = { name: 'c', version: '1' };
114+
assert.equal(resolveProtocolEra('tools/list', modernMeta()), 'modern');
115+
assert.equal(resolveProtocolEra('tools/list', withClientInfo(valid)), 'modern');
116+
// Required means present and a string, not non-empty: the schema sets no minimum
117+
// length on `name`/`version`, nor on `Icon.src`.
118+
assert.equal(
119+
resolveProtocolEra('tools/list', withClientInfo({ name: '', version: '' })),
120+
'modern',
121+
);
122+
assert.equal(
123+
resolveProtocolEra('tools/list', withClientInfo({ ...valid, icons: [{ src: '' }] })),
124+
'modern',
125+
);
126+
// Every recognized optional field, plus an unknown extension key, stays acceptable.
127+
assert.equal(
128+
resolveProtocolEra(
129+
'tools/list',
130+
withClientInfo({
131+
...valid,
132+
title: 'Client',
133+
description: 'A client',
134+
websiteUrl: 'https://example.dev',
135+
icons: [
136+
{
137+
src: 'https://example.dev/i.png',
138+
mimeType: 'image/png',
139+
sizes: ['48x48'],
140+
theme: 'light',
141+
},
142+
],
143+
'com.example/extension': 1,
144+
}),
145+
),
146+
'modern',
147+
);
148+
149+
for (const bad of [
150+
42,
151+
'client',
152+
[],
153+
{ name: 'c' },
154+
{ version: '1' },
155+
{ ...valid, title: 42 },
156+
{ ...valid, description: 42 },
157+
{ ...valid, websiteUrl: 42 },
158+
{ ...valid, icons: 42 },
159+
{ ...valid, icons: [{}] },
160+
{ ...valid, icons: [{ src: 42 }] },
161+
{ ...valid, icons: [{ src: 'https://e.dev/i.png', mimeType: 42 }] },
162+
{ ...valid, icons: [{ src: 'https://e.dev/i.png', sizes: [48] }] },
163+
{ ...valid, icons: [{ src: 'https://e.dev/i.png', sizes: 'any' }] },
164+
{ ...valid, icons: [{ src: 'https://e.dev/i.png', theme: 'blue' }] },
165+
]) {
166+
assert.throws(
167+
() => resolveProtocolEra('tools/list', withClientInfo(bad)),
168+
/clientInfo/,
169+
`expected rejection for ${JSON.stringify(bad)}`,
170+
);
171+
}
172+
});
173+
174+
test('the modern era removes initialize and ping, the legacy era keeps them', () => {
175+
for (const method of ['initialize', 'ping']) {
176+
assert.equal(isMethodRemovedInEra(method, 'modern'), true);
177+
assert.equal(isMethodRemovedInEra(method, 'legacy'), false);
178+
}
179+
assert.equal(isMethodRemovedInEra('tools/call', 'modern'), false);
180+
});
181+
182+
test('modern results carry resultType and serverInfo; legacy results are untouched', () => {
183+
const modern = finalizeResult({ tools: [] }, 'modern') as Record<string, unknown>;
184+
assert.equal(modern.resultType, 'complete');
185+
assert.deepEqual(Object.keys((modern._meta ?? {}) as object), [
186+
'io.modelcontextprotocol/serverInfo',
187+
]);
188+
189+
assert.deepEqual(finalizeResult({ tools: [] }, 'legacy'), { tools: [] });
190+
});
191+
192+
test('finalizeResult preserves a result that already carries _meta', () => {
193+
const result = finalizeResult({ tools: [], _meta: { 'com.example/trace': 'abc' } }, 'modern') as {
194+
_meta: Record<string, unknown>;
195+
};
196+
assert.equal(result._meta['com.example/trace'], 'abc');
197+
assert.ok(result._meta['io.modelcontextprotocol/serverInfo']);
198+
});
199+
200+
test('cache hints ride modern results only', () => {
201+
assert.deepEqual(cacheFields('modern', 1000), { ttlMs: 1000, cacheScope: 'public' });
202+
assert.equal(cacheFields('legacy', 1000), undefined);
203+
});

0 commit comments

Comments
 (0)