diff --git a/CHANGELOG.md b/CHANGELOG.md index c19924fb34..0e7ac04642 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 ` first line is now `agent-device ` 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. diff --git a/src/mcp/__tests__/protocol-era.test.ts b/src/mcp/__tests__/protocol-era.test.ts new file mode 100644 index 0000000000..ba9c23ec8b --- /dev/null +++ b/src/mcp/__tests__/protocol-era.test.ts @@ -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; + 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; + }; + 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); +}); diff --git a/src/mcp/__tests__/router.test.ts b/src/mcp/__tests__/router.test.ts index 6e939fcfd6..f09a9badf7 100644 --- a/src/mcp/__tests__/router.test.ts +++ b/src/mcp/__tests__/router.test.ts @@ -58,6 +58,263 @@ test('MCP exposes every automatable CLI command as a structured direct tool', as assert.match(JSON.stringify(malformedArgumentsResponse.result), /Expected object parameters/); }); +const MODERN_META = { + 'io.modelcontextprotocol/protocolVersion': '2026-07-28', + 'io.modelcontextprotocol/clientInfo': { name: 'ModernClient', version: '1.0.0' }, + 'io.modelcontextprotocol/clientCapabilities': {}, +}; + +test('server/discover advertises both eras so a dual-era client stays modern', async () => { + const response = await handleMcpMessage({ + jsonrpc: '2.0', + id: 'discover', + method: 'server/discover', + params: { _meta: MODERN_META }, + }); + + assert.ok(response && 'result' in response); + const result = response.result as Record; + assert.equal(result.resultType, 'complete'); + assert.deepEqual(result.supportedVersions, ['2026-07-28', '2025-11-25', '2025-06-18']); + assert.deepEqual(result.capabilities, { tools: {} }); + assert.equal(result.cacheScope, 'public'); + assert.ok(result.ttlMs > 0); + assert.equal(result._meta['io.modelcontextprotocol/serverInfo'].name, 'agent-device'); +}); + +test('MCP initialize answers with the legacy revision the client asked for', async () => { + const response = await handleMcpMessage({ + jsonrpc: '2.0', + id: 1, + method: 'initialize', + params: { + protocolVersion: '2025-06-18', + capabilities: {}, + clientInfo: { name: 'c', version: '1' }, + }, + }); + + assert.ok(response && 'result' in response); + // Answering an unrequested revision makes a pinned client disconnect. + assert.equal((response.result as { protocolVersion: string }).protocolVersion, '2025-06-18'); +}); + +test('modern tools/list carries the cacheable-result envelope, legacy stays unchanged', async () => { + const modern = await handleMcpMessage({ + jsonrpc: '2.0', + id: 'modern', + method: 'tools/list', + params: { _meta: MODERN_META }, + }); + assert.ok(modern && 'result' in modern); + const modernResult = modern.result as Record; + assert.equal(modernResult.resultType, 'complete'); + assert.equal(modernResult.cacheScope, 'public'); + assert.ok(modernResult.ttlMs > 0); + + const legacy = await handleMcpMessage({ jsonrpc: '2.0', id: 'legacy', method: 'tools/list' }); + assert.ok(legacy && 'result' in legacy); + // Legacy clients must see exactly the payload earlier releases sent. + assert.deepEqual(Object.keys(legacy.result as object), ['tools']); +}); + +test('a request declaring a 2025 revision is answered on the legacy wire contract', async () => { + for (const version of ['2025-11-25', '2025-06-18']) { + const response = await handleMcpMessage({ + jsonrpc: '2.0', + id: `declared-${version}`, + method: 'tools/list', + params: { _meta: { ...MODERN_META, 'io.modelcontextprotocol/protocolVersion': version } }, + }); + + assert.ok(response && 'result' in response); + // Declaring a revision through modern framing does not opt it into the 2026 result + // shape: `resultType`, `serverInfo`, and the cache hints are all 2026-only fields. + assert.deepEqual(Object.keys(response.result as object), ['tools']); + } +}); + +test('initialize does not agree to a modern revision, which has no handshake', async () => { + const response = await handleMcpMessage({ + jsonrpc: '2.0', + id: 'initialize-modern', + method: 'initialize', + params: { + protocolVersion: '2026-07-28', + capabilities: {}, + clientInfo: { name: 'c', version: '1' }, + }, + }); + + assert.ok(response && 'result' in response); + assert.equal((response.result as { protocolVersion: string }).protocolVersion, '2025-11-25'); +}); + +test('server/discover rejects requests missing the modern metadata it requires', async () => { + const noMeta = await handleMcpMessage({ + jsonrpc: '2.0', + id: 'discover-no-meta', + method: 'server/discover', + }); + assert.ok(noMeta && 'error' in noMeta); + assert.equal(noMeta.error.code, -32602); + assert.match(noMeta.error.message, /protocolVersion/); + + const legacyVersion = await handleMcpMessage({ + jsonrpc: '2.0', + id: 'discover-legacy', + method: 'server/discover', + params: { + _meta: { ...MODERN_META, 'io.modelcontextprotocol/protocolVersion': '2025-11-25' }, + }, + }); + assert.ok(legacyVersion && 'error' in legacyVersion); + assert.equal(legacyVersion.error.code, -32022); +}); + +test('a declared revision without client capabilities is rejected as invalid params', async () => { + const response = await handleMcpMessage({ + jsonrpc: '2.0', + id: 'no-capabilities', + method: 'tools/list', + params: { _meta: { 'io.modelcontextprotocol/protocolVersion': '2026-07-28' } }, + }); + + assert.ok(response && 'error' in response); + assert.equal(response.error.code, -32602); + assert.match(response.error.message, /clientCapabilities/); +}); + +test('modern-framed calls to the methods 2026-07-28 removed are unknown methods', async () => { + for (const method of ['initialize', 'ping']) { + const modern = await handleMcpMessage({ + jsonrpc: '2.0', + id: `modern-${method}`, + method, + params: { _meta: MODERN_META }, + }); + assert.ok(modern && 'error' in modern); + assert.equal(modern.error.code, -32601); + + // The same method stays available to a legacy-framed caller. + const legacy = await handleMcpMessage({ jsonrpc: '2.0', id: `legacy-${method}`, method }); + assert.ok(legacy && 'result' in legacy); + } +}); + +test('a supplied clientInfo must be a valid Implementation', async () => { + const valid = { name: 'c', version: '1' }; + for (const clientInfo of [ + 42, + { name: 'c' }, + { version: '1' }, + { name: 1, version: 2 }, + // Recognized optional fields are typed, so a wrong scalar is malformed too. + { ...valid, websiteUrl: 42 }, + { ...valid, title: 42 }, + { ...valid, description: 42 }, + // Icons: wrong container, entry missing `src`, and a bad typed member. + { ...valid, icons: 42 }, + { ...valid, icons: [{}] }, + { ...valid, icons: [{ src: 42 }] }, + { ...valid, icons: [{ src: 'https://e.dev/i.png', theme: 'blue' }] }, + { ...valid, icons: [{ src: 'https://e.dev/i.png', sizes: [48] }] }, + ]) { + const response = await handleMcpMessage({ + jsonrpc: '2.0', + id: 'bad-client-info', + method: 'tools/list', + params: { _meta: { ...MODERN_META, 'io.modelcontextprotocol/clientInfo': clientInfo } }, + }); + + assert.ok( + response && 'error' in response, + `expected rejection for ${JSON.stringify(clientInfo)}`, + ); + assert.equal(response.error.code, -32602); + assert.match(response.error.message, /clientInfo/); + } + + // Absent clientInfo is legitimate: the field is optional in 2026-07-28. + const omitted = await handleMcpMessage({ + jsonrpc: '2.0', + id: 'no-client-info', + method: 'tools/list', + params: { + _meta: { + 'io.modelcontextprotocol/protocolVersion': '2026-07-28', + 'io.modelcontextprotocol/clientCapabilities': {}, + }, + }, + }); + assert.ok(omitted && 'result' in omitted); + + // `name` and `version` are required `string` with no minimum length, so an empty + // string is a conforming Implementation and must not be rejected. + const emptyStrings = await handleMcpMessage({ + jsonrpc: '2.0', + id: 'empty-client-info', + method: 'tools/list', + params: { + _meta: { + ...MODERN_META, + 'io.modelcontextprotocol/clientInfo': { name: '', version: '' }, + }, + }, + }); + assert.ok(emptyStrings && 'result' in emptyStrings); + + // A fully populated clientInfo, plus an extension key, must still be served: + // validation must not harden into rejecting what the spec allows. + const rich = await handleMcpMessage({ + jsonrpc: '2.0', + id: 'rich-client-info', + method: 'tools/list', + params: { + _meta: { + ...MODERN_META, + 'io.modelcontextprotocol/clientInfo': { + name: 'c', + version: '1', + title: 'Client', + description: 'A client', + websiteUrl: 'https://example.dev', + icons: [ + { + src: 'https://example.dev/i.png', + mimeType: 'image/png', + sizes: ['48x48'], + theme: 'dark', + }, + ], + 'com.example/extension': { anything: true }, + }, + }, + }, + }); + assert.ok(rich && 'result' in rich); +}); + +test('a protocol version this server does not implement is rejected with the supported list', async () => { + const response = await handleMcpMessage({ + jsonrpc: '2.0', + id: 'bad-version', + method: 'tools/call', + params: { + _meta: { ...MODERN_META, 'io.modelcontextprotocol/protocolVersion': '1900-01-01' }, + name: 'devices', + arguments: {}, + }, + }); + + assert.ok(response && 'error' in response); + assert.equal(response.error.code, -32022); + assert.deepEqual(response.error.data, { + supported: ['2026-07-28', '2025-11-25', '2025-06-18'], + requested: '1900-01-01', + }); +}); + test('MCP JSON-RPC batches return responses in request order and skip notifications', async () => { const response = await handleMcpPayload([ { jsonrpc: '2.0', id: 'first', method: 'ping' }, diff --git a/src/mcp/protocol-era.ts b/src/mcp/protocol-era.ts new file mode 100644 index 0000000000..cd38c7bc3f --- /dev/null +++ b/src/mcp/protocol-era.ts @@ -0,0 +1,256 @@ +import { AppError } from '@agent-device/kernel/errors'; +import { readVersion } from '../utils/version.ts'; + +/** + * MCP has two protocol eras, and agent-device serves both from one stdio process + * (the spec's "dual-era server", MCP 2026-07-28 § Versioning and Compatibility). + * + * - **legacy** (`2025-11-25` and earlier): the client negotiates once via `initialize`, + * and every later request inherits that session state. + * - **modern** (`2026-07-28`+): stateless. Each request carries its own protocol version + * and client capabilities in `_meta`, there is no handshake, and results are tagged + * with `resultType`. + * + * Era membership follows the *declared revision*, not merely the presence of `_meta`: a + * revision is served on its own wire contract, so a request declaring `2025-11-25` gets + * the legacy result shape even though it used modern framing to say so. Legacy responses + * stay byte-identical to what earlier releases sent. + */ +export type ProtocolEra = 'legacy' | 'modern'; + +/** Newest stateless revision, and the one `server/discover` answers for. */ +export const MODERN_PROTOCOL_VERSION = '2026-07-28'; + +/** Revisions served statelessly, newest first. */ +export const MODERN_PROTOCOL_VERSIONS: readonly string[] = [MODERN_PROTOCOL_VERSION]; + +/** + * Revisions served through the `initialize` handshake, newest first. + * + * Scoped to what this server actually provides rather than every published revision: a + * tools-only server's surface (`tools/list`, `tools/call`, `outputSchema`, + * `structuredContent`) is identical across these and `2026-07-28`. Revisions before + * `2025-06-18` predate `outputSchema`/`structuredContent`, which every typed tool here + * returns, so they are not claimed. + */ +export const LEGACY_PROTOCOL_VERSIONS: readonly string[] = ['2025-11-25', '2025-06-18']; + +/** Answer for a handshake whose requested revision we do not serve on the legacy wire. */ +export const PREFERRED_LEGACY_PROTOCOL_VERSION = '2025-11-25'; + +/** Every revision we implement, newest first — the list a client may choose from. */ +export const SUPPORTED_PROTOCOL_VERSIONS: readonly string[] = [ + ...MODERN_PROTOCOL_VERSIONS, + ...LEGACY_PROTOCOL_VERSIONS, +]; + +/** JSON-RPC error code for a declared revision this server does not implement. */ +export const UNSUPPORTED_PROTOCOL_VERSION_CODE = -32022; + +/** Modern-only RPC: it does not exist in any legacy revision. */ +const DISCOVER_METHOD = 'server/discover'; + +/** + * Methods `2026-07-28` removed. They stay available to legacy-framed requests, which + * still need the handshake and the keepalive, and are unknown methods in the modern era. + */ +const LEGACY_ONLY_METHODS: ReadonlySet = new Set(['initialize', 'ping']); + +const PROTOCOL_VERSION_META_KEY = 'io.modelcontextprotocol/protocolVersion'; +const CLIENT_CAPABILITIES_META_KEY = 'io.modelcontextprotocol/clientCapabilities'; +const CLIENT_INFO_META_KEY = 'io.modelcontextprotocol/clientInfo'; +const SERVER_INFO_META_KEY = 'io.modelcontextprotocol/serverInfo'; + +const MCP_SERVER_NAME = 'agent-device'; + +/** + * Freshness hint for the results that depend only on the installed binary. + * + * Both `tools/list` and `server/discover` are derived from the command descriptor + * registry alone — no user config, no device state, no session — so they are constant for + * a given version and `cacheScope: 'public'` is honest: nothing in them is user-specific. + * (Config-backed defaults are resolved per `tools/call`, never baked into the schemas.) + * Clients re-launch the process on upgrade, which is what invalidates the entry. + */ +export const STATIC_RESULT_CACHE_TTL_MS = 3_600_000; + +export type CacheableResultFields = { + ttlMs: number; + cacheScope: 'public' | 'private'; +}; + +export class UnsupportedProtocolVersionError extends Error { + readonly requested: string; + + constructor(requested: string) { + super( + `Unsupported MCP protocol version: ${requested}. Supported: ${SUPPORTED_PROTOCOL_VERSIONS.join(', ')}.`, + ); + this.requested = requested; + } + + get data(): { supported: readonly string[]; requested: string } { + return { supported: SUPPORTED_PROTOCOL_VERSIONS, requested: this.requested }; + } +} + +/** + * Classifies one request, rejecting revisions we do not implement and modern framing that + * omits its required metadata. + * + * A declared revision picks the era, so `2025-*` declared through modern `_meta` is still + * answered on the legacy wire contract. `server/discover` exists only in the modern era, + * so it requires a modern revision rather than being promoted by default — leniency there + * would answer a `DiscoverResult` that its own schema forbids. + */ +export function resolveProtocolEra(method: string, params: unknown): ProtocolEra { + const meta = asRecord(asRecord(params)._meta); + const declared = stringField(meta, PROTOCOL_VERSION_META_KEY); + + if (declared === undefined) { + if (method === DISCOVER_METHOD) { + throw new AppError( + 'INVALID_ARGS', + `Expected _meta["${PROTOCOL_VERSION_META_KEY}"] on ${DISCOVER_METHOD}.`, + ); + } + return 'legacy'; + } + + if (!SUPPORTED_PROTOCOL_VERSIONS.includes(declared)) { + throw new UnsupportedProtocolVersionError(declared); + } + // Both keys are required on a modern request; a half-declared `_meta` is malformed + // rather than a shape to keep working. + if (!isRecord(meta[CLIENT_CAPABILITIES_META_KEY])) { + throw new AppError( + 'INVALID_ARGS', + `Expected _meta["${CLIENT_CAPABILITIES_META_KEY}"] to be an object.`, + ); + } + // Client identity is optional, but a supplied one still has to be an `Implementation`: + // omitting it is a choice, misdeclaring it is a malformed request. + const clientInfo = meta[CLIENT_INFO_META_KEY]; + if (clientInfo !== undefined && !isImplementation(clientInfo)) { + throw new AppError( + 'INVALID_ARGS', + `Expected _meta["${CLIENT_INFO_META_KEY}"] to be a valid Implementation.`, + ); + } + if (!MODERN_PROTOCOL_VERSIONS.includes(declared)) { + if (method === DISCOVER_METHOD) throw new UnsupportedProtocolVersionError(declared); + return 'legacy'; + } + return 'modern'; +} + +/** + * Legacy `initialize` version negotiation: echo the client's revision when we serve it on + * this wire, otherwise name the newest legacy revision we do. Answering with an + * unrequested version tells a pinned client to disconnect, so the echo is the + * interoperable branch. Modern revisions are not echoed — they have no handshake, so + * agreeing to one here would promise a contract this reply cannot establish. + */ +export function negotiateLegacyProtocolVersion(params: unknown): string { + const requested = stringField(asRecord(params), 'protocolVersion'); + if (requested !== undefined && LEGACY_PROTOCOL_VERSIONS.includes(requested)) { + return requested; + } + return PREFERRED_LEGACY_PROTOCOL_VERSION; +} + +/** + * Whether the resolved era removed this method. A modern-framed request reaching + * `initialize` or `ping` is calling a method its own revision deleted, so it gets the + * unknown-method error rather than a `resultType: "complete"` envelope wrapped around a + * legacy handshake reply. + */ +export function isMethodRemovedInEra(method: string, era: ProtocolEra): boolean { + return era === 'modern' && LEGACY_ONLY_METHODS.has(method); +} + +export function serverInfo(): { name: string; version: string } { + return { name: MCP_SERVER_NAME, version: readVersion() }; +} + +/** + * Applies the modern result envelope: every 2026-07-28 result carries `resultType`, and + * servers identify themselves in `_meta`. Legacy results pass through untouched. + */ +export function finalizeResult(result: unknown, era: ProtocolEra): unknown { + if (era === 'legacy' || result === null || typeof result !== 'object') return result; + return { + resultType: 'complete', + ...(result as Record), + _meta: { + ...(result as { _meta?: Record })._meta, + [SERVER_INFO_META_KEY]: serverInfo(), + }, + }; +} + +/** Cache hints belong to the modern `CacheableResult` shape only. */ +export function cacheFields(era: ProtocolEra, ttlMs: number): CacheableResultFields | undefined { + return era === 'modern' ? { ttlMs, cacheScope: 'public' } : undefined; +} + +function isRecord(value: unknown): value is Record { + return Boolean(value) && typeof value === 'object' && !Array.isArray(value); +} + +/** + * Whether a value is a valid `Implementation`: `name` and `version` are required, and + * every other recognized field is type-checked when present. Unrecognized keys pass — + * `_meta` payloads carry extension fields, and rejecting those would reject the future. + * + * Required means *present and a string*, not non-empty: the schema declares plain + * `string` with no minimum length, so `{name: "", version: ""}` is a conforming + * `Implementation` and refusing it would reject a client the spec allows. + */ +function isImplementation(value: unknown): boolean { + if (!isRecord(value)) return false; + if (!isString(value.name) || !isString(value.version)) return false; + return ( + isOptional(value.title, isString) && + isOptional(value.description, isString) && + isOptional(value.websiteUrl, isString) && + isOptional(value.icons, (icons) => Array.isArray(icons) && icons.every(isIcon)) + ); +} + +/** + * An `Icon` requires `src`; the rest are optional but typed when supplied. `src` is + * checked as a string for the same reason as `name`/`version` — its `format: uri` + * annotation is not something this server enforces, so rejecting `""` while accepting + * any other non-URI string would be arbitrary. + */ +function isIcon(value: unknown): boolean { + if (!isRecord(value)) return false; + if (!isString(value.src)) return false; + return ( + isOptional(value.mimeType, isString) && + isOptional(value.sizes, isStringArray) && + isOptional(value.theme, (theme) => theme === 'light' || theme === 'dark') + ); +} + +function isOptional(value: unknown, check: (value: unknown) => boolean): boolean { + return value === undefined || check(value); +} + +function isString(value: unknown): boolean { + return typeof value === 'string'; +} + +function isStringArray(value: unknown): boolean { + return Array.isArray(value) && value.every(isString); +} + +function asRecord(value: unknown): Record { + return isRecord(value) ? value : {}; +} + +function stringField(record: Record, key: string): string | undefined { + const value = record[key]; + return typeof value === 'string' && value.length > 0 ? value : undefined; +} diff --git a/src/mcp/router.ts b/src/mcp/router.ts index f3e1547863..ef6cbd5028 100644 --- a/src/mcp/router.ts +++ b/src/mcp/router.ts @@ -1,17 +1,31 @@ import { listCommandTools, commandToolExecutor, type ToolResult } from './command-tools.ts'; -import { readVersion } from '../utils/version.ts'; import type { JsonRpcId, JsonRpcRequestEnvelope } from '@agent-device/kernel/contracts'; import { AppError } from '@agent-device/kernel/errors'; import { formatToolErrorText, normalizeToolError } from './tool-error.ts'; +import { + cacheFields, + finalizeResult, + isMethodRemovedInEra, + MODERN_PROTOCOL_VERSION, + negotiateLegacyProtocolVersion, + resolveProtocolEra, + serverInfo, + SUPPORTED_PROTOCOL_VERSIONS, + STATIC_RESULT_CACHE_TTL_MS, + UNSUPPORTED_PROTOCOL_VERSION_CODE, + UnsupportedProtocolVersionError, + type ProtocolEra, +} from './protocol-era.ts'; -const MCP_SERVER_NAME = 'agent-device'; -const SUPPORTED_PROTOCOL_VERSION = '2025-11-25'; +const SERVER_INSTRUCTIONS = + 'agent-device drives iOS, Android, tvOS, Android TV, macOS, Linux, and web targets. ' + + 'Each tool mirrors the CLI command of the same name; tool descriptions carry the per-command contract.'; export type JsonRpcMessage = JsonRpcRequestEnvelope; type JsonRpcResponse = | { jsonrpc: '2.0'; id: JsonRpcId; result: unknown } - | { jsonrpc: '2.0'; id: JsonRpcId; error: { code: number; message: string } }; + | { jsonrpc: '2.0'; id: JsonRpcId; error: { code: number; message: string; data?: unknown } }; export async function handleMcpMessage(message: JsonRpcMessage): Promise { if (message.jsonrpc !== '2.0' || typeof message.method !== 'string') { @@ -21,8 +35,18 @@ export async function handleMcpMessage(message: JsonRpcMessage): Promise { +async function handleRequest(method: string, params: unknown, era: ProtocolEra): Promise { + if (isMethodRemovedInEra(method, era)) { + throw new JsonRpcMethodNotFoundError( + `${method} was removed in MCP ${MODERN_PROTOCOL_VERSION}.`, + ); + } switch (method) { + // Modern capability discovery. Servers MUST implement it, and dual-era clients use it + // as the stdio probe that tells a 2026-07-28 server from a handshake-only one. + case 'server/discover': + return { + supportedVersions: SUPPORTED_PROTOCOL_VERSIONS, + capabilities: { tools: {} }, + instructions: SERVER_INSTRUCTIONS, + // A `DiscoverResult` is always a `CacheableResult`: these are required fields + // here, not the era-dependent hints `tools/list` adds. + ttlMs: STATIC_RESULT_CACHE_TTL_MS, + cacheScope: 'public', + }; + // Legacy handshake, gated above to legacy-framed requests. Retained so clients on + // 2025-11-25 and earlier keep connecting. case 'initialize': return { - protocolVersion: supportedProtocolVersion(params), + protocolVersion: negotiateLegacyProtocolVersion(params), capabilities: { tools: {}, }, - serverInfo: { - name: MCP_SERVER_NAME, - version: readVersion(), - }, + serverInfo: serverInfo(), }; + // Removed in 2026-07-28; still the keepalive legacy clients rely on. case 'ping': return {}; case 'tools/list': - return { tools: listCommandTools() }; + return { tools: listCommandTools(), ...cacheFields(era, STATIC_RESULT_CACHE_TTL_MS) }; case 'tools/call': return await callTool(params); default: @@ -71,10 +112,6 @@ async function callTool(params: unknown): Promise { } } -function supportedProtocolVersion(_params: unknown): string { - return SUPPORTED_PROTOCOL_VERSION; -} - function textToolResult(text: string, isError = false): ToolResult { return { isError, @@ -86,8 +123,13 @@ function successResponse(id: JsonRpcId, result: unknown): JsonRpcResponse { return { jsonrpc: '2.0', id, result }; } -function errorResponse(id: JsonRpcId, code: number, message: string): JsonRpcResponse { - return { jsonrpc: '2.0', id, error: { code, message } }; +function errorResponse( + id: JsonRpcId, + code: number, + message: string, + data?: unknown, +): JsonRpcResponse { + return { jsonrpc: '2.0', id, error: { code, message, ...(data ? { data } : {}) } }; } function asRecord(value: unknown): Record {