Skip to content

Commit a924f0b

Browse files
authored
fix(mcp): restore result and config parity (#1343)
* fix(mcp): restore result and config parity * refactor(mcp): narrow parsed input types * refactor(mcp): simplify parity boundaries * refactor: narrow validated MCP tool inputs * refactor: preserve command result types in MCP * refactor: remove impossible MCP result fallbacks
1 parent ab80434 commit a924f0b

11 files changed

Lines changed: 300 additions & 64 deletions

src/commands/command-metadata.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,8 @@ export function isCommandName(name: string): name is CommandName {
3737
return commandMetadataMap.has(name as CommandName);
3838
}
3939

40+
export function findCommandMetadata(name: CommandName): AnyCommandMetadata;
41+
export function findCommandMetadata(name: string): AnyCommandMetadata | undefined;
4042
export function findCommandMetadata(name: string): AnyCommandMetadata | undefined {
4143
return commandMetadataMap.get(name as CommandName);
4244
}

src/commands/command-surface.ts

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,16 +7,28 @@ const commandSurface = listCommandFamilyDefinitions();
77

88
export type { BatchCommandName, CommandName };
99

10+
type CommandDefinitionFor<Name extends CommandName> = Extract<
11+
CommandFamilyDefinition,
12+
{ name: Name }
13+
>;
14+
15+
export type CommandExecutionResult<Name extends CommandName = CommandName> = Awaited<
16+
ReturnType<CommandDefinitionFor<Name>['invoke']>
17+
>;
18+
1019
const commandMap: ReadonlyMap<CommandName, CommandFamilyDefinition> = new Map(
1120
commandSurface.map((definition) => [definition.name, definition]),
1221
);
1322

14-
export async function runCommand(
23+
export async function runCommand<Name extends CommandName>(
1524
client: AgentDeviceClient,
16-
name: CommandName,
25+
name: Name,
1726
input: unknown,
18-
): Promise<unknown> {
19-
return await getCommandDefinition(name).invoke(client, input);
27+
): Promise<CommandExecutionResult<Name>> {
28+
// The map is total over CommandName, but Map#get cannot retain the correlation
29+
// between a runtime key and that definition's return type. Re-establish it at
30+
// this single lookup seam; callers keep the per-command result type.
31+
return (await getCommandDefinition(name).invoke(client, input)) as CommandExecutionResult<Name>;
2032
}
2133

2234
/**
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
import assert from 'node:assert/strict';
2+
import fs from 'node:fs';
3+
import os from 'node:os';
4+
import path from 'node:path';
5+
import { afterEach, test, vi } from 'vitest';
6+
import { createAgentDeviceClient } from '../../agent-device-client.ts';
7+
import type { AgentDeviceClient, AgentDeviceDaemonTransport } from '../../client/client-types.ts';
8+
import type { CommandExecutionResult } from '../../commands/command-surface.ts';
9+
import { createCommandToolExecutor, listCommandTools } from '../command-tools.ts';
10+
import { validateAgainstSchema } from './output-schema-validator.ts';
11+
12+
afterEach(() => {
13+
vi.unstubAllEnvs();
14+
if (temporaryDirectory) {
15+
fs.rmSync(temporaryDirectory, { recursive: true, force: true });
16+
temporaryDirectory = undefined;
17+
}
18+
});
19+
20+
let temporaryDirectory: string | undefined;
21+
22+
test('MCP collection results use object envelopes without changing object results or text', async () => {
23+
const results = {
24+
devices: [
25+
{
26+
id: 'device-1',
27+
name: 'iPhone',
28+
platform: 'ios',
29+
target: 'mobile',
30+
kind: 'device',
31+
identifiers: {},
32+
},
33+
],
34+
apps: ['com.example.app'],
35+
wait: { waitedMs: 10 },
36+
} satisfies Record<'devices' | 'apps' | 'wait', CommandExecutionResult>;
37+
const executor = createCommandToolExecutor({
38+
createClient: () => ({}) as AgentDeviceClient,
39+
runCommand: async (_client, name) => results[name as keyof typeof results],
40+
});
41+
42+
const devices = await executor.execute('devices', {});
43+
const apps = await executor.execute('apps', {});
44+
const wait = await executor.execute('wait', {});
45+
46+
assert.deepEqual(devices.structuredContent, {
47+
devices: [
48+
{ id: 'device-1', name: 'iPhone', platform: 'ios', target: 'mobile', kind: 'device' },
49+
],
50+
});
51+
assert.deepEqual(apps.structuredContent, { apps: results.apps });
52+
assert.deepEqual(wait.structuredContent, results.wait);
53+
assert.equal(devices.content[0]?.text, 'iPhone (ios device target=mobile)');
54+
assert.equal(apps.content[0]?.text, 'com.example.app');
55+
56+
for (const [name, result] of [
57+
['devices', devices],
58+
['apps', apps],
59+
] as const) {
60+
const schema = listCommandTools().find((tool) => tool.name === name)?.outputSchema;
61+
assert.ok(schema, `${name} advertises an output schema`);
62+
assert.deepEqual(validateAgainstSchema(result.structuredContent, schema), []);
63+
}
64+
});
65+
66+
test('MCP applies config-backed command defaults with explicit-input precedence and applicability', async () => {
67+
const home = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-mcp-config-'));
68+
temporaryDirectory = home;
69+
const configuredXctestrun = path.join(home, 'configured.xctestrun');
70+
fs.mkdirSync(path.join(home, '.agent-device'));
71+
fs.writeFileSync(
72+
path.join(home, '.agent-device', 'config.json'),
73+
JSON.stringify({ iosXctestrunFile: configuredXctestrun, appsFilter: 'all' }),
74+
);
75+
vi.stubEnv('HOME', home);
76+
77+
const calls: Array<Parameters<AgentDeviceDaemonTransport>[0]> = [];
78+
const transport: AgentDeviceDaemonTransport = async (request) => {
79+
calls.push(request);
80+
return { ok: true, data: { nodes: [], truncated: false } };
81+
};
82+
const executor = createCommandToolExecutor({
83+
createClient: (config) => createAgentDeviceClient(config, { transport }),
84+
});
85+
86+
await executor.execute('snapshot', {});
87+
await executor.execute('snapshot', { iosXctestrunFile: '/explicit/runner.xctestrun' });
88+
89+
assert.deepEqual(
90+
calls.map((request) => request.flags?.iosXctestrunFile),
91+
[configuredXctestrun, '/explicit/runner.xctestrun'],
92+
);
93+
assert.ok(calls.every((request) => request.command === 'snapshot'));
94+
assert.ok(calls.every((request) => request.flags?.appsFilter === undefined));
95+
});

src/mcp/__tests__/command-tools.test.ts

Lines changed: 6 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -209,7 +209,7 @@ test('MCP includeCost:true opts into agent-cost: sets client.cost, strips the ar
209209
},
210210
runCommand: async (_client, name, input) => {
211211
calls.push({ name, input });
212-
return { message: `Ran ${name}`, cost: { wallClockMs: 42 } };
212+
return { waitedMs: 42, cost: { wallClockMs: 42, runnerRoundTrips: 0 } };
213213
},
214214
});
215215

@@ -220,7 +220,10 @@ test('MCP includeCost:true opts into agent-cost: sets client.cost, strips the ar
220220
// includeCost is an MCP-boundary field and must not leak into the command input.
221221
assert.deepEqual(calls, [{ name: 'wait', input: {} }]);
222222
// The daemon-provided cost rides through structuredContent unchanged.
223-
assert.deepEqual(result.structuredContent, { message: 'Ran wait', cost: { wallClockMs: 42 } });
223+
assert.deepEqual(result.structuredContent, {
224+
waitedMs: 42,
225+
cost: { wallClockMs: 42, runnerRoundTrips: 0 },
226+
});
224227
});
225228

226229
test('MCP includeCost absent/false leaves the request shape untouched (no cost config)', async () => {
@@ -409,18 +412,13 @@ test('MCP prepare outputSchema stays complete for the typed non-exposed command'
409412
assert.ok(prepareSchema.required?.includes('timing'));
410413
});
411414

412-
test('MCP untyped tools stay byte-identical: no outputSchema key', () => {
415+
test('MCP untyped object tools stay byte-identical: no outputSchema key', () => {
413416
const tools = listCommandTools();
414417

415418
// snapshot is intentionally absent from the typed registry (dynamic shape).
416419
const snapshot = tools.find((tool) => tool.name === 'snapshot');
417420
assert.ok(snapshot);
418421
assert.equal('outputSchema' in snapshot, false);
419-
420-
// devices is likewise untyped.
421-
const devices = tools.find((tool) => tool.name === 'devices');
422-
assert.ok(devices);
423-
assert.equal('outputSchema' in devices, false);
424422
});
425423

426424
test('MCP boot structuredContent is consistent with its advertised outputSchema', async () => {

src/mcp/__tests__/router.test.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,16 @@ test('MCP exposes every automatable CLI command as a structured direct tool', as
4545
assert.ok(invalidFillResponse && 'result' in invalidFillResponse);
4646
assert.equal((invalidFillResponse.result as { isError: boolean }).isError, true);
4747
assert.match(JSON.stringify(invalidFillResponse.result), /Expected target to be set/);
48+
49+
const malformedArgumentsResponse = await handleMcpMessage({
50+
jsonrpc: '2.0',
51+
id: 3,
52+
method: 'tools/call',
53+
params: { name: 'devices', arguments: [] },
54+
});
55+
assert.ok(malformedArgumentsResponse && 'result' in malformedArgumentsResponse);
56+
assert.equal((malformedArgumentsResponse.result as { isError: boolean }).isError, true);
57+
assert.match(JSON.stringify(malformedArgumentsResponse.result), /Expected object parameters/);
4858
});
4959

5060
test('MCP JSON-RPC batches return responses in request order and skip notifications', async () => {

src/mcp/command-output-schemas.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ import { DEVICE_TARGETS, PUBLIC_PLATFORMS } from '../kernel/device.ts';
2727
* and discriminated-union branches mirror the source contract types.
2828
*/
2929

30-
const DEVICE_KINDS = ['simulator', 'emulator', 'device'] as const;
30+
export const DEVICE_KINDS = ['simulator', 'emulator', 'device'] as const;
3131

3232
function numberSchema(description?: string): JsonSchema {
3333
return { type: 'number', ...(description ? { description } : {}) };

0 commit comments

Comments
 (0)