Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

## Unreleased

- `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.
- 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.
- `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.
- 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.
- 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.
Expand Down
203 changes: 203 additions & 0 deletions src/mcp/__tests__/protocol-era.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,203 @@
import assert from 'node:assert/strict';
import { test } from 'vitest';
import {
cacheFields,
finalizeResult,
isMethodRemovedInEra,
LEGACY_PROTOCOL_VERSIONS,
MODERN_PROTOCOL_VERSION,
MODERN_PROTOCOL_VERSIONS,
negotiateLegacyProtocolVersion,
PREFERRED_LEGACY_PROTOCOL_VERSION,
resolveProtocolEra,
UnsupportedProtocolVersionError,
} from '../protocol-era.ts';

const modernMeta = (version: string = MODERN_PROTOCOL_VERSION) => ({
_meta: {
'io.modelcontextprotocol/protocolVersion': version,
'io.modelcontextprotocol/clientCapabilities': {},
},
});

test('initialize echoes a revision the client asked for instead of overriding it', () => {
// A client pinned to 2025-06-18 that is answered with a different revision is told to
// disconnect by the legacy lifecycle contract.
assert.equal(negotiateLegacyProtocolVersion({ protocolVersion: '2025-06-18' }), '2025-06-18');
assert.equal(
negotiateLegacyProtocolVersion({ protocolVersion: PREFERRED_LEGACY_PROTOCOL_VERSION }),
PREFERRED_LEGACY_PROTOCOL_VERSION,
);
});

test('initialize falls back to the newest legacy revision for versions we do not implement', () => {
assert.equal(
negotiateLegacyProtocolVersion({ protocolVersion: '2024-11-05' }),
PREFERRED_LEGACY_PROTOCOL_VERSION,
);
assert.equal(negotiateLegacyProtocolVersion({}), PREFERRED_LEGACY_PROTOCOL_VERSION);
assert.equal(negotiateLegacyProtocolVersion(undefined), PREFERRED_LEGACY_PROTOCOL_VERSION);
});

test('initialize never agrees to a modern revision, which has no handshake to establish', () => {
for (const version of MODERN_PROTOCOL_VERSIONS) {
assert.equal(
negotiateLegacyProtocolVersion({ protocolVersion: version }),
PREFERRED_LEGACY_PROTOCOL_VERSION,
);
}
});

test('the declared revision picks the era, so 2025 stays on the legacy wire contract', () => {
assert.equal(resolveProtocolEra('tools/list', modernMeta()), 'modern');
for (const version of LEGACY_PROTOCOL_VERSIONS) {
assert.equal(resolveProtocolEra('tools/list', modernMeta(version)), 'legacy');
}
assert.equal(resolveProtocolEra('tools/list', {}), 'legacy');
assert.equal(resolveProtocolEra('tools/list', undefined), 'legacy');
});

test('server/discover requires modern request metadata rather than being promoted', () => {
assert.equal(resolveProtocolEra('server/discover', modernMeta()), 'modern');
// No declared revision: malformed, not an invitation to guess the era.
assert.throws(() => resolveProtocolEra('server/discover', undefined), /protocolVersion/);
assert.throws(() => resolveProtocolEra('server/discover', { _meta: {} }), /protocolVersion/);
// The RPC does not exist in any legacy revision.
assert.throws(
() => resolveProtocolEra('server/discover', modernMeta(PREFERRED_LEGACY_PROTOCOL_VERSION)),
UnsupportedProtocolVersionError,
);
});

test('a declared revision without client capabilities is malformed', () => {
assert.throws(
() =>
resolveProtocolEra('tools/call', {
_meta: { 'io.modelcontextprotocol/protocolVersion': MODERN_PROTOCOL_VERSION },
}),
/clientCapabilities/,
);
assert.throws(
() =>
resolveProtocolEra('tools/call', {
_meta: {
'io.modelcontextprotocol/protocolVersion': MODERN_PROTOCOL_VERSION,
'io.modelcontextprotocol/clientCapabilities': 'not-an-object',
},
}),
/clientCapabilities/,
);
});

test('an unimplemented declared revision is rejected with the supported list', () => {
assert.throws(
() => resolveProtocolEra('tools/call', modernMeta('1900-01-01')),
(error: unknown) => {
assert.ok(error instanceof UnsupportedProtocolVersionError);
assert.equal(error.data.requested, '1900-01-01');
assert.ok(error.data.supported.includes(MODERN_PROTOCOL_VERSION));
return true;
},
);
});

test('a supplied clientInfo must be an Implementation, but omitting it is fine', () => {
const withClientInfo = (clientInfo: unknown) => ({
_meta: {
'io.modelcontextprotocol/protocolVersion': MODERN_PROTOCOL_VERSION,
'io.modelcontextprotocol/clientCapabilities': {},
'io.modelcontextprotocol/clientInfo': clientInfo,
},
});

const valid = { name: 'c', version: '1' };
assert.equal(resolveProtocolEra('tools/list', modernMeta()), 'modern');
assert.equal(resolveProtocolEra('tools/list', withClientInfo(valid)), 'modern');
// Required means present and a string, not non-empty: the schema sets no minimum
// length on `name`/`version`, nor on `Icon.src`.
assert.equal(
resolveProtocolEra('tools/list', withClientInfo({ name: '', version: '' })),
'modern',
);
assert.equal(
resolveProtocolEra('tools/list', withClientInfo({ ...valid, icons: [{ src: '' }] })),
'modern',
);
// Every recognized optional field, plus an unknown extension key, stays acceptable.
assert.equal(
resolveProtocolEra(
'tools/list',
withClientInfo({
...valid,
title: 'Client',
description: 'A client',
websiteUrl: 'https://example.dev',
icons: [
{
src: 'https://example.dev/i.png',
mimeType: 'image/png',
sizes: ['48x48'],
theme: 'light',
},
],
'com.example/extension': 1,
}),
),
'modern',
);

for (const bad of [
42,
'client',
[],
{ name: 'c' },
{ version: '1' },
{ ...valid, title: 42 },
{ ...valid, description: 42 },
{ ...valid, websiteUrl: 42 },
{ ...valid, icons: 42 },
{ ...valid, icons: [{}] },
{ ...valid, icons: [{ src: 42 }] },
{ ...valid, icons: [{ src: 'https://e.dev/i.png', mimeType: 42 }] },
{ ...valid, icons: [{ src: 'https://e.dev/i.png', sizes: [48] }] },
{ ...valid, icons: [{ src: 'https://e.dev/i.png', sizes: 'any' }] },
{ ...valid, icons: [{ src: 'https://e.dev/i.png', theme: 'blue' }] },
]) {
assert.throws(
() => resolveProtocolEra('tools/list', withClientInfo(bad)),
/clientInfo/,
`expected rejection for ${JSON.stringify(bad)}`,
);
}
});

test('the modern era removes initialize and ping, the legacy era keeps them', () => {
for (const method of ['initialize', 'ping']) {
assert.equal(isMethodRemovedInEra(method, 'modern'), true);
assert.equal(isMethodRemovedInEra(method, 'legacy'), false);
}
assert.equal(isMethodRemovedInEra('tools/call', 'modern'), false);
});

test('modern results carry resultType and serverInfo; legacy results are untouched', () => {
const modern = finalizeResult({ tools: [] }, 'modern') as Record<string, unknown>;
assert.equal(modern.resultType, 'complete');
assert.deepEqual(Object.keys((modern._meta ?? {}) as object), [
'io.modelcontextprotocol/serverInfo',
]);

assert.deepEqual(finalizeResult({ tools: [] }, 'legacy'), { tools: [] });
});

test('finalizeResult preserves a result that already carries _meta', () => {
const result = finalizeResult({ tools: [], _meta: { 'com.example/trace': 'abc' } }, 'modern') as {
_meta: Record<string, unknown>;
};
assert.equal(result._meta['com.example/trace'], 'abc');
assert.ok(result._meta['io.modelcontextprotocol/serverInfo']);
});

test('cache hints ride modern results only', () => {
assert.deepEqual(cacheFields('modern', 1000), { ttlMs: 1000, cacheScope: 'public' });
assert.equal(cacheFields('legacy', 1000), undefined);
});
Loading
Loading