Skip to content

Commit d22c6e6

Browse files
committed
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
1 parent 4354216 commit d22c6e6

4 files changed

Lines changed: 181 additions & 39 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
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. 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. 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.
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 is rejected as invalid params. 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.
66
- 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.
77
- `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.
88
- 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.

src/mcp/__tests__/protocol-era.test.ts

Lines changed: 45 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,9 @@ import { test } from 'vitest';
33
import {
44
cacheFields,
55
finalizeResult,
6+
LEGACY_PROTOCOL_VERSIONS,
67
MODERN_PROTOCOL_VERSION,
8+
MODERN_PROTOCOL_VERSIONS,
79
negotiateLegacyProtocolVersion,
810
PREFERRED_LEGACY_PROTOCOL_VERSION,
911
resolveProtocolEra,
@@ -36,14 +38,54 @@ test('initialize falls back to the newest legacy revision for versions we do not
3638
assert.equal(negotiateLegacyProtocolVersion(undefined), PREFERRED_LEGACY_PROTOCOL_VERSION);
3739
});
3840

39-
test('a declared protocol version selects the modern era, its absence stays legacy', () => {
41+
test('initialize never agrees to a modern revision, which has no handshake to establish', () => {
42+
for (const version of MODERN_PROTOCOL_VERSIONS) {
43+
assert.equal(
44+
negotiateLegacyProtocolVersion({ protocolVersion: version }),
45+
PREFERRED_LEGACY_PROTOCOL_VERSION,
46+
);
47+
}
48+
});
49+
50+
test('the declared revision picks the era, so 2025 stays on the legacy wire contract', () => {
4051
assert.equal(resolveProtocolEra('tools/list', modernMeta()), 'modern');
52+
for (const version of LEGACY_PROTOCOL_VERSIONS) {
53+
assert.equal(resolveProtocolEra('tools/list', modernMeta(version)), 'legacy');
54+
}
4155
assert.equal(resolveProtocolEra('tools/list', {}), 'legacy');
4256
assert.equal(resolveProtocolEra('tools/list', undefined), 'legacy');
4357
});
4458

45-
test('server/discover is modern even without _meta, so the probe reports the real era', () => {
46-
assert.equal(resolveProtocolEra('server/discover', undefined), 'modern');
59+
test('server/discover requires modern request metadata rather than being promoted', () => {
60+
assert.equal(resolveProtocolEra('server/discover', modernMeta()), 'modern');
61+
// No declared revision: malformed, not an invitation to guess the era.
62+
assert.throws(() => resolveProtocolEra('server/discover', undefined), /protocolVersion/);
63+
assert.throws(() => resolveProtocolEra('server/discover', { _meta: {} }), /protocolVersion/);
64+
// The RPC does not exist in any legacy revision.
65+
assert.throws(
66+
() => resolveProtocolEra('server/discover', modernMeta(PREFERRED_LEGACY_PROTOCOL_VERSION)),
67+
UnsupportedProtocolVersionError,
68+
);
69+
});
70+
71+
test('a declared revision without client capabilities is malformed', () => {
72+
assert.throws(
73+
() =>
74+
resolveProtocolEra('tools/call', {
75+
_meta: { 'io.modelcontextprotocol/protocolVersion': MODERN_PROTOCOL_VERSION },
76+
}),
77+
/clientCapabilities/,
78+
);
79+
assert.throws(
80+
() =>
81+
resolveProtocolEra('tools/call', {
82+
_meta: {
83+
'io.modelcontextprotocol/protocolVersion': MODERN_PROTOCOL_VERSION,
84+
'io.modelcontextprotocol/clientCapabilities': 'not-an-object',
85+
},
86+
}),
87+
/clientCapabilities/,
88+
);
4789
});
4890

4991
test('an unimplemented declared revision is rejected with the supported list', () => {

src/mcp/__tests__/router.test.ts

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,73 @@ test('modern tools/list carries the cacheable-result envelope, legacy stays unch
118118
assert.deepEqual(Object.keys(legacy.result as object), ['tools']);
119119
});
120120

121+
test('a request declaring a 2025 revision is answered on the legacy wire contract', async () => {
122+
for (const version of ['2025-11-25', '2025-06-18']) {
123+
const response = await handleMcpMessage({
124+
jsonrpc: '2.0',
125+
id: `declared-${version}`,
126+
method: 'tools/list',
127+
params: { _meta: { ...MODERN_META, 'io.modelcontextprotocol/protocolVersion': version } },
128+
});
129+
130+
assert.ok(response && 'result' in response);
131+
// Declaring a revision through modern framing does not opt it into the 2026 result
132+
// shape: `resultType`, `serverInfo`, and the cache hints are all 2026-only fields.
133+
assert.deepEqual(Object.keys(response.result as object), ['tools']);
134+
}
135+
});
136+
137+
test('initialize does not agree to a modern revision, which has no handshake', async () => {
138+
const response = await handleMcpMessage({
139+
jsonrpc: '2.0',
140+
id: 'initialize-modern',
141+
method: 'initialize',
142+
params: {
143+
protocolVersion: '2026-07-28',
144+
capabilities: {},
145+
clientInfo: { name: 'c', version: '1' },
146+
},
147+
});
148+
149+
assert.ok(response && 'result' in response);
150+
assert.equal((response.result as { protocolVersion: string }).protocolVersion, '2025-11-25');
151+
});
152+
153+
test('server/discover rejects requests missing the modern metadata it requires', async () => {
154+
const noMeta = await handleMcpMessage({
155+
jsonrpc: '2.0',
156+
id: 'discover-no-meta',
157+
method: 'server/discover',
158+
});
159+
assert.ok(noMeta && 'error' in noMeta);
160+
assert.equal(noMeta.error.code, -32602);
161+
assert.match(noMeta.error.message, /protocolVersion/);
162+
163+
const legacyVersion = await handleMcpMessage({
164+
jsonrpc: '2.0',
165+
id: 'discover-legacy',
166+
method: 'server/discover',
167+
params: {
168+
_meta: { ...MODERN_META, 'io.modelcontextprotocol/protocolVersion': '2025-11-25' },
169+
},
170+
});
171+
assert.ok(legacyVersion && 'error' in legacyVersion);
172+
assert.equal(legacyVersion.error.code, -32022);
173+
});
174+
175+
test('a declared revision without client capabilities is rejected as invalid params', async () => {
176+
const response = await handleMcpMessage({
177+
jsonrpc: '2.0',
178+
id: 'no-capabilities',
179+
method: 'tools/list',
180+
params: { _meta: { 'io.modelcontextprotocol/protocolVersion': '2026-07-28' } },
181+
});
182+
183+
assert.ok(response && 'error' in response);
184+
assert.equal(response.error.code, -32602);
185+
assert.match(response.error.message, /clientCapabilities/);
186+
});
187+
121188
test('a protocol version this server does not implement is rejected with the supported list', async () => {
122189
const response = await handleMcpMessage({
123190
jsonrpc: '2.0',

src/mcp/protocol-era.ts

Lines changed: 68 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { AppError } from '@agent-device/kernel/errors';
12
import { readVersion } from '../utils/version.ts';
23

34
/**
@@ -10,40 +11,47 @@ import { readVersion } from '../utils/version.ts';
1011
* and client capabilities in `_meta`, there is no handshake, and results are tagged
1112
* with `resultType`.
1213
*
13-
* A request is modern exactly when it declares a protocol version in `_meta`. Legacy
14-
* responses stay byte-identical to what earlier releases sent, so upgrading a client is
15-
* the only thing that changes wire shape.
14+
* Era membership follows the *declared revision*, not merely the presence of `_meta`: a
15+
* revision is served on its own wire contract, so a request declaring `2025-11-25` gets
16+
* the legacy result shape even though it used modern framing to say so. Legacy responses
17+
* stay byte-identical to what earlier releases sent.
1618
*/
1719
export type ProtocolEra = 'legacy' | 'modern';
1820

19-
/** Newest revision we implement. Requests declaring it are served statelessly. */
21+
/** Newest stateless revision, and the one `server/discover` answers for. */
2022
export const MODERN_PROTOCOL_VERSION = '2026-07-28';
2123

22-
/**
23-
* Answer for legacy clients whose requested revision we do not implement. The
24-
* `initialize` contract is "echo the requested version, or name one you do support".
25-
*/
26-
export const PREFERRED_LEGACY_PROTOCOL_VERSION = '2025-11-25';
24+
/** Revisions served statelessly, newest first. */
25+
export const MODERN_PROTOCOL_VERSIONS: readonly string[] = [MODERN_PROTOCOL_VERSION];
2726

2827
/**
29-
* Revisions we implement, newest first — the list a client may choose from.
28+
* Revisions served through the `initialize` handshake, newest first.
3029
*
31-
* Scoped to what this server actually provides rather than every published revision:
32-
* a tools-only server's surface (`tools/list`, `tools/call`, `outputSchema`,
33-
* `structuredContent`) is identical across `2026-07-28`, `2025-11-25`, and `2025-06-18`.
34-
* Revisions before `2025-06-18` predate `outputSchema`/`structuredContent`, which every
35-
* typed tool here returns, so they are not claimed.
30+
* Scoped to what this server actually provides rather than every published revision: a
31+
* tools-only server's surface (`tools/list`, `tools/call`, `outputSchema`,
32+
* `structuredContent`) is identical across these and `2026-07-28`. Revisions before
33+
* `2025-06-18` predate `outputSchema`/`structuredContent`, which every typed tool here
34+
* returns, so they are not claimed.
3635
*/
36+
export const LEGACY_PROTOCOL_VERSIONS: readonly string[] = ['2025-11-25', '2025-06-18'];
37+
38+
/** Answer for a handshake whose requested revision we do not serve on the legacy wire. */
39+
export const PREFERRED_LEGACY_PROTOCOL_VERSION = '2025-11-25';
40+
41+
/** Every revision we implement, newest first — the list a client may choose from. */
3742
export const SUPPORTED_PROTOCOL_VERSIONS: readonly string[] = [
38-
MODERN_PROTOCOL_VERSION,
39-
PREFERRED_LEGACY_PROTOCOL_VERSION,
40-
'2025-06-18',
43+
...MODERN_PROTOCOL_VERSIONS,
44+
...LEGACY_PROTOCOL_VERSIONS,
4145
];
4246

4347
/** JSON-RPC error code for a declared revision this server does not implement. */
4448
export const UNSUPPORTED_PROTOCOL_VERSION_CODE = -32022;
4549

50+
/** Modern-only RPC: it does not exist in any legacy revision. */
51+
const DISCOVER_METHOD = 'server/discover';
52+
4653
const PROTOCOL_VERSION_META_KEY = 'io.modelcontextprotocol/protocolVersion';
54+
const CLIENT_CAPABILITIES_META_KEY = 'io.modelcontextprotocol/clientCapabilities';
4755
const SERVER_INFO_META_KEY = 'io.modelcontextprotocol/serverInfo';
4856

4957
const MCP_SERVER_NAME = 'agent-device';
@@ -80,29 +88,56 @@ export class UnsupportedProtocolVersionError extends Error {
8088
}
8189

8290
/**
83-
* Classifies one request and rejects revisions we do not implement.
91+
* Classifies one request, rejecting revisions we do not implement and modern framing that
92+
* omits its required metadata.
8493
*
85-
* `server/discover` is a modern-only probe, so it stays modern even when a client omits
86-
* `_meta`: a legacy client has no reason to call it, and answering a `DiscoverResult`
87-
* without `resultType` would misreport this server's era.
94+
* A declared revision picks the era, so `2025-*` declared through modern `_meta` is still
95+
* answered on the legacy wire contract. `server/discover` exists only in the modern era,
96+
* so it requires a modern revision rather than being promoted by default — leniency there
97+
* would answer a `DiscoverResult` that its own schema forbids.
8898
*/
8999
export function resolveProtocolEra(method: string, params: unknown): ProtocolEra {
90-
const declared = declaredProtocolVersion(params);
91-
if (declared !== undefined && !SUPPORTED_PROTOCOL_VERSIONS.includes(declared)) {
100+
const meta = asRecord(asRecord(params)._meta);
101+
const declared = stringField(meta, PROTOCOL_VERSION_META_KEY);
102+
103+
if (declared === undefined) {
104+
if (method === DISCOVER_METHOD) {
105+
throw new AppError(
106+
'INVALID_ARGS',
107+
`Expected _meta["${PROTOCOL_VERSION_META_KEY}"] on ${DISCOVER_METHOD}.`,
108+
);
109+
}
110+
return 'legacy';
111+
}
112+
113+
if (!SUPPORTED_PROTOCOL_VERSIONS.includes(declared)) {
92114
throw new UnsupportedProtocolVersionError(declared);
93115
}
94-
if (declared !== undefined) return 'modern';
95-
return method === 'server/discover' ? 'modern' : 'legacy';
116+
// Both keys are required on a modern request; a half-declared `_meta` is malformed
117+
// rather than a shape to keep working.
118+
if (!isRecord(meta[CLIENT_CAPABILITIES_META_KEY])) {
119+
throw new AppError(
120+
'INVALID_ARGS',
121+
`Expected _meta["${CLIENT_CAPABILITIES_META_KEY}"] to be an object.`,
122+
);
123+
}
124+
if (!MODERN_PROTOCOL_VERSIONS.includes(declared)) {
125+
if (method === DISCOVER_METHOD) throw new UnsupportedProtocolVersionError(declared);
126+
return 'legacy';
127+
}
128+
return 'modern';
96129
}
97130

98131
/**
99-
* Legacy `initialize` version negotiation: echo the client's revision when we implement
100-
* it, otherwise name the newest legacy revision we do. Answering with an unrequested
101-
* version tells a pinned client to disconnect, so the echo is the interoperable branch.
132+
* Legacy `initialize` version negotiation: echo the client's revision when we serve it on
133+
* this wire, otherwise name the newest legacy revision we do. Answering with an
134+
* unrequested version tells a pinned client to disconnect, so the echo is the
135+
* interoperable branch. Modern revisions are not echoed — they have no handshake, so
136+
* agreeing to one here would promise a contract this reply cannot establish.
102137
*/
103138
export function negotiateLegacyProtocolVersion(params: unknown): string {
104139
const requested = stringField(asRecord(params), 'protocolVersion');
105-
if (requested !== undefined && SUPPORTED_PROTOCOL_VERSIONS.includes(requested)) {
140+
if (requested !== undefined && LEGACY_PROTOCOL_VERSIONS.includes(requested)) {
106141
return requested;
107142
}
108143
return PREFERRED_LEGACY_PROTOCOL_VERSION;
@@ -133,14 +168,12 @@ export function cacheFields(era: ProtocolEra, ttlMs: number): CacheableResultFie
133168
return era === 'modern' ? { ttlMs, cacheScope: 'public' } : undefined;
134169
}
135170

136-
function declaredProtocolVersion(params: unknown): string | undefined {
137-
const meta = asRecord(asRecord(params)._meta);
138-
return stringField(meta, PROTOCOL_VERSION_META_KEY);
171+
function isRecord(value: unknown): value is Record<string, unknown> {
172+
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
139173
}
140174

141175
function asRecord(value: unknown): Record<string, unknown> {
142-
if (!value || typeof value !== 'object' || Array.isArray(value)) return {};
143-
return value as Record<string, unknown>;
176+
return isRecord(value) ? value : {};
144177
}
145178

146179
function stringField(record: Record<string, unknown>, key: string): string | undefined {

0 commit comments

Comments
 (0)