Skip to content

Commit 4e7c612

Browse files
committed
fix: serialize mcp stdin requests
1 parent 62b77b5 commit 4e7c612

5 files changed

Lines changed: 173 additions & 19 deletions

File tree

src/__tests__/client.test.ts

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { test } from 'vitest';
22
import assert from 'node:assert/strict';
33
import { createAgentDeviceClient, type AgentDeviceClientConfig } from '../client.ts';
4+
import { runCommand } from '../commands/command-surface.ts';
45
import type { DaemonRequest, DaemonResponse } from '../contracts.ts';
56
import { AppError } from '../utils/errors.ts';
67

@@ -202,6 +203,67 @@ test('apps.open forwards explicit runtime hints through the daemon request', asy
202203
});
203204
});
204205

206+
test('structured command input accepts target as deviceTarget alias when no UI target exists', async () => {
207+
const setup = createTransport(async (req) => {
208+
if (req.command === 'open') {
209+
return {
210+
ok: true,
211+
data: {
212+
session: 'qa',
213+
appName: 'Settings',
214+
appBundleId: 'com.apple.Preferences',
215+
platform: 'ios',
216+
target: 'tv',
217+
device: 'Apple TV',
218+
id: 'TV-001',
219+
kind: 'simulator',
220+
},
221+
};
222+
}
223+
throw new Error(`Unexpected command: ${req.command}`);
224+
});
225+
const client = createAgentDeviceClient(setup.config, { transport: setup.transport });
226+
227+
await runCommand(client, 'open', { app: 'Settings', target: 'tv' });
228+
229+
assert.equal(setup.calls.length, 1);
230+
assert.equal(setup.calls[0]?.command, 'open');
231+
assert.deepEqual(setup.calls[0]?.positionals, ['Settings']);
232+
assert.equal(setup.calls[0]?.flags?.target, 'tv');
233+
});
234+
235+
test('structured interaction input keeps UI target separate from deviceTarget', async () => {
236+
const setup = createTransport(async (req) => {
237+
if (req.command === 'get' || req.command === 'longpress') {
238+
return {
239+
ok: true,
240+
data: { ok: true },
241+
};
242+
}
243+
throw new Error(`Unexpected command: ${req.command}`);
244+
});
245+
const client = createAgentDeviceClient(setup.config, { transport: setup.transport });
246+
247+
await runCommand(client, 'get', {
248+
deviceTarget: 'mobile',
249+
format: 'text',
250+
target: { kind: 'ref', ref: '@e1' },
251+
});
252+
await runCommand(client, 'longpress', {
253+
deviceTarget: 'mobile',
254+
durationMs: 800,
255+
target: { kind: 'ref', ref: '@e2' },
256+
});
257+
258+
assert.equal(setup.calls.length, 2);
259+
assert.equal(setup.calls[0]?.command, 'get');
260+
assert.deepEqual(setup.calls[0]?.positionals, ['text', '@e1']);
261+
assert.equal(setup.calls[0]?.flags?.target, 'mobile');
262+
assert.equal(setup.calls[1]?.command, 'longpress');
263+
assert.deepEqual(setup.calls[1]?.positionals, ['@e2', '800']);
264+
assert.equal(setup.calls[1]?.flags?.target, 'mobile');
265+
});
266+
205267
test('apps.installFromSource forwards source payload and normalizes launch identity', async () => {
206268
const setup = createTransport(async () => ({
207269
ok: true,

src/commands/command-input.ts

Lines changed: 28 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ export type SelectorSnapshotInput = {
4848
};
4949

5050
export type PointInput = { x: number; y: number };
51+
type CommonInputOptions = { readTargetAlias?: boolean };
5152

5253
function commandInputSchema(
5354
properties: Record<string, JsonSchema>,
@@ -249,7 +250,9 @@ export function readFieldInput<TFields extends CommandFieldMap>(
249250
return value === undefined ? [] : [[key, value]];
250251
}),
251252
);
252-
const commonInput = readCommonInput(record);
253+
const commonInput = readCommonInput(record, {
254+
readTargetAlias: !Object.hasOwn(fields, 'target'),
255+
});
253256
return compactRecord({
254257
...commonInput,
255258
...commonToClientOptions(commonInput),
@@ -265,11 +268,14 @@ export function readInputRecord(input: unknown): Record<string, unknown> {
265268
return input as Record<string, unknown>;
266269
}
267270

268-
export function readCommonInput(record: Record<string, unknown>): CommonCommandInput {
271+
export function readCommonInput(
272+
record: Record<string, unknown>,
273+
options: CommonInputOptions = {},
274+
): CommonCommandInput {
269275
return {
270276
session: optionalString(record, 'session'),
271277
platform: optionalEnum(record, 'platform', PLATFORM_VALUES),
272-
deviceTarget: optionalEnum(record, 'deviceTarget', DEVICE_TARGET_VALUES),
278+
deviceTarget: readDeviceTarget(record, options),
273279
device: optionalString(record, 'device'),
274280
udid: optionalString(record, 'udid'),
275281
serial: optionalString(record, 'serial'),
@@ -285,6 +291,19 @@ export function readCommonInput(record: Record<string, unknown>): CommonCommandI
285291
};
286292
}
287293

294+
function readDeviceTarget(
295+
record: Record<string, unknown>,
296+
options: CommonInputOptions,
297+
): DeviceTarget | undefined {
298+
const deviceTarget = optionalEnum(record, 'deviceTarget', DEVICE_TARGET_VALUES);
299+
if (options.readTargetAlias === false || record.target === undefined) return deviceTarget;
300+
const targetAlias = optionalEnum(record, 'target', DEVICE_TARGET_VALUES);
301+
if (deviceTarget !== undefined && targetAlias !== deviceTarget) {
302+
throw new Error('Expected target alias to match deviceTarget when both are set.');
303+
}
304+
return deviceTarget ?? targetAlias;
305+
}
306+
288307
function readInteractionTarget(
289308
record: Record<string, unknown>,
290309
key: string,
@@ -548,6 +567,12 @@ function commonProperties(): Record<string, JsonSchema> {
548567
enum: DEVICE_TARGET_VALUES,
549568
description: 'Device target form. Maps to the CLI --target flag.',
550569
},
570+
target: {
571+
type: 'string',
572+
enum: DEVICE_TARGET_VALUES,
573+
description:
574+
'Alias for deviceTarget on commands without a UI target field. Interaction commands reserve target for the UI element.',
575+
},
551576
device: { type: 'string', description: 'Device name selector.' },
552577
udid: { type: 'string', description: 'iOS device UDID selector.' },
553578
serial: { type: 'string', description: 'Android serial selector.' },

src/mcp/__tests__/router.test.ts

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
import assert from 'node:assert/strict';
2+
import { setImmediate } from 'node:timers/promises';
23
import { test } from 'vitest';
34
import { listMcpExposedCommandNames } from '../../command-catalog.ts';
45
import { handleMcpMessage } from '../router.ts';
5-
import { handleMcpPayload } from '../server.ts';
6+
import { createMcpPayloadQueue, handleMcpPayload } from '../server.ts';
67

78
test('MCP exposes every automatable CLI command as a structured direct tool', async () => {
89
const response = await handleMcpMessage({
@@ -58,3 +59,38 @@ test('MCP JSON-RPC batches return responses in request order and skip notificati
5859
['first', 'second'],
5960
);
6061
});
62+
63+
test('MCP stdio payload queue serializes separate messages', async () => {
64+
const started: JsonRpcId[] = [];
65+
const writes: unknown[] = [];
66+
const completions = new Map<JsonRpcId, (response: unknown) => void>();
67+
const queue = createMcpPayloadQueue({
68+
handlePayload: async (message) => {
69+
const id = Array.isArray(message) ? null : (message.id ?? null);
70+
started.push(id);
71+
return await new Promise((resolve) => completions.set(id, resolve));
72+
},
73+
write: (message) => {
74+
writes.push(message);
75+
},
76+
});
77+
78+
queue.push({ jsonrpc: '2.0', id: 'first', method: 'tools/call' });
79+
queue.push({ jsonrpc: '2.0', id: 'second', method: 'tools/call' });
80+
await Promise.resolve();
81+
82+
assert.deepEqual(started, ['first']);
83+
completions.get('first')?.({ jsonrpc: '2.0', id: 'first', result: {} });
84+
await setImmediate();
85+
86+
assert.deepEqual(started, ['first', 'second']);
87+
completions.get('second')?.({ jsonrpc: '2.0', id: 'second', result: {} });
88+
await queue.idle();
89+
90+
assert.deepEqual(
91+
writes.map((message) => (message as { id: JsonRpcId }).id),
92+
['first', 'second'],
93+
);
94+
});
95+
96+
type JsonRpcId = string | number | null;

src/mcp/server.ts

Lines changed: 44 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -3,24 +3,15 @@ import { handleMcpMessage, type JsonRpcMessage } from './router.ts';
33
type JsonRpcResponse = Awaited<NonNullable<ReturnType<typeof handleMcpMessage>>>;
44
type JsonRpcId = string | number | null;
55
type MessageSink = (message: JsonRpcMessage | JsonRpcMessage[]) => void;
6+
type PayloadHandler = (
7+
messageOrBatch: JsonRpcMessage | JsonRpcMessage[],
8+
) => Promise<unknown | null>;
9+
type MessageWriter = (message: unknown) => void;
610

711
export async function runAgentDeviceMcpServer(): Promise<void> {
12+
const payloadQueue = createMcpPayloadQueue();
813
const decoder = new McpMessageDecoder((messageOrBatch) => {
9-
const fallbackId = fallbackErrorId(messageOrBatch);
10-
void handleMcpPayload(messageOrBatch)
11-
.then((response) => {
12-
if (response) writeMessage(response);
13-
})
14-
.catch((error: unknown) => {
15-
writeMessage({
16-
jsonrpc: '2.0',
17-
id: fallbackId,
18-
error: {
19-
code: -32603,
20-
message: error instanceof Error ? error.message : String(error),
21-
},
22-
});
23-
});
14+
payloadQueue.push(messageOrBatch);
2415
});
2516

2617
process.stdin.setEncoding('utf8');
@@ -44,6 +35,44 @@ export async function runAgentDeviceMcpServer(): Promise<void> {
4435
process.stdin.on('close', resolve);
4536
process.stdin.resume();
4637
});
38+
await payloadQueue.idle();
39+
}
40+
41+
export function createMcpPayloadQueue(
42+
options: {
43+
handlePayload?: PayloadHandler;
44+
write?: MessageWriter;
45+
} = {},
46+
): {
47+
push: (messageOrBatch: JsonRpcMessage | JsonRpcMessage[]) => void;
48+
idle: () => Promise<void>;
49+
} {
50+
const handlePayload = options.handlePayload ?? handleMcpPayload;
51+
const write = options.write ?? writeMessage;
52+
let pending = Promise.resolve();
53+
return {
54+
push: (messageOrBatch) => {
55+
const fallbackId = fallbackErrorId(messageOrBatch);
56+
pending = pending
57+
.then(async () => {
58+
const response = await handlePayload(messageOrBatch);
59+
if (response) write(response);
60+
})
61+
.catch((error: unknown) => {
62+
write({
63+
jsonrpc: '2.0',
64+
id: fallbackId,
65+
error: {
66+
code: -32603,
67+
message: error instanceof Error ? error.message : String(error),
68+
},
69+
});
70+
});
71+
},
72+
idle: async () => {
73+
await pending;
74+
},
75+
};
4776
}
4877

4978
export function handleMcpPayload(

website/docs/docs/agent-setup.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,8 @@ Use MCP tools or the CLI in the integrated terminal. If `agent-device` is not on
5656

5757
`agent-device mcp` starts the official stdio MCP server. It exposes direct structured tools for installed CLI commands. Tools run through command contracts and `AgentDeviceClient`; local-only workflows stay CLI-only rather than subprocess fallbacks.
5858

59+
Tool execution failures are returned as MCP tool results with `isError: true`; clients and agents should inspect the tool result, not only the successful JSON-RPC envelope.
60+
5961
MCP clients must not use this server as a generic shell runner. If the CLI is missing, agents should ask a human before installing or updating packages, then verify with `agent-device --version` and start with `agent-device help workflow`.
6062

6163
Global install configuration:

0 commit comments

Comments
 (0)