From 2a8107a1b6ab664c976f31b48ab3eac7cee760f2 Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Sat, 24 Oct 2026 08:51:09 +0200 Subject: [PATCH 01/13] test: an agent with a lease calls run_on_device and gets the command's stdout, stderr and exit code (#959) --- src/mcp/server.test.ts | 47 +++++++++++++++++++++++++++++++++++++++++ src/mcp/test-support.ts | 8 +++---- 2 files changed, 51 insertions(+), 4 deletions(-) diff --git a/src/mcp/server.test.ts b/src/mcp/server.test.ts index aa7e57efa..eb60203d5 100644 --- a/src/mcp/server.test.ts +++ b/src/mcp/server.test.ts @@ -66,6 +66,7 @@ describe("MCP server (smoke)", () => { "list_devices", "release_simulator", "lease_status", + "run_on_device", ]); expect(tools.tools.find((tool) => tool.name === "list_devices")?.description).toContain( "modelClasses", @@ -559,6 +560,52 @@ async function connectedServer(client: FakeSimlockClient) { }; } +describe("MCP run_on_device", () => { + it("returns the command's stdout, stderr and exit code, run on the session's lease", async () => { + const client = new FakeSimlockClient(); + client.requestLeaseImpl = () => Promise.resolve(sampleGrant()); + const { mcpClient, close } = await connectedServer(client); + try { + await call(mcpClient, "lease_simulator", { model: "iPhone 17 Pro", platform: "ios" }); + const cases = [ + { + input: { args: ["listapps", "booted"], tool: "simctl" }, + chunks: [{ chunk: "com.x\n", stream: "stdout" as const }], + exitCode: 0, + expected: { exitCode: 0, stderr: "", stdout: "com.x\n", truncated: false }, + }, + { + input: { args: ["install", "/b/App.app"], tool: "simctl" }, + chunks: [{ chunk: "no such file", stream: "stderr" as const }], + exitCode: 3, + expected: { exitCode: 3, stderr: "no such file", stdout: "", truncated: false }, + }, + { + input: { args: ["shell", "cat"], stdin: "hello", tool: "adb" }, + chunks: [], + exitCode: 0, + expected: { exitCode: 0, stderr: "", stdout: "", truncated: false }, + }, + ]; + for (const entry of cases) { + client.execImpl = (_input, options) => { + for (const chunk of entry.chunks) options.onOutput?.(chunk); + return Promise.resolve({ exitCode: entry.exitCode }); + }; + const result = await call(mcpClient, "run_on_device", entry.input); + expect(result.isError).not.toBe(true); + expect(result.structuredContent).toEqual(entry.expected); + expect(client.calls.filter((c) => c.method === "exec").at(-1)?.input).toEqual({ + ...entry.input, + leaseId: "lease-1", + }); + } + } finally { + await close(); + } + }); +}); + function call(client: Client, name: string, arguments_: Record) { return client.request( { method: "tools/call", params: { arguments: arguments_, name } }, diff --git a/src/mcp/test-support.ts b/src/mcp/test-support.ts index 41a266642..98e2a6206 100644 --- a/src/mcp/test-support.ts +++ b/src/mcp/test-support.ts @@ -15,6 +15,7 @@ import { type DoctorReport, type DoctorRunInput, type ExecInput, + type ExecOptions, type ExecOutput, type DriverPassthroughInput, type LeaseCancelInput, @@ -76,7 +77,7 @@ export class FakeSimlockClient implements SimlockClient { runDoctorImpl: (input: DoctorRunInput) => Promise = notStubbed("runDoctor"); resolvePassthroughImpl: (input: DriverPassthroughInput) => Promise = notStubbed("resolvePassthrough"); - execImpl: (input: ExecInput) => Promise = notStubbed("exec"); + execImpl: (input: ExecInput, options: ExecOptions) => Promise = notStubbed("exec"); readonly #leaseLostListeners = new Set<(push: LeaseLostPush) => void>(); readonly #deviceUnhealthyListeners = new Set<(push: DeviceUnhealthyPush) => void>(); @@ -146,10 +147,9 @@ export class FakeSimlockClient implements SimlockClient { return this.#dead ? this.#deadConnection() : this.resolvePassthroughImpl(input); } - // fallow-ignore-next-line unused-class-member -- part of the SimlockClient interface this fake implements; MCP exposes no device.exec tool of its own. - exec(input: ExecInput): Promise { + exec(input: ExecInput, options: ExecOptions = {}): Promise { this.calls.push({ input, method: "exec" }); - return this.#dead ? this.#deadConnection() : this.execImpl(input); + return this.#dead ? this.#deadConnection() : this.execImpl(input, options); } onLeaseLost(listener: (push: LeaseLostPush) => void): () => void { From cc449581907ec11e21646f453c910de04d7086bb Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Sat, 24 Oct 2026 08:53:19 +0200 Subject: [PATCH 02/13] feat(mcp): run_on_device runs a command on the session's lease (#959) --- src/mcp/contracts.ts | 13 +++++++++++++ src/mcp/server.ts | 26 ++++++++++++++++++++++++++ src/mcp/session.ts | 21 +++++++++++++++++++++ 3 files changed, 60 insertions(+) diff --git a/src/mcp/contracts.ts b/src/mcp/contracts.ts index 0edf272a0..82a171164 100644 --- a/src/mcp/contracts.ts +++ b/src/mcp/contracts.ts @@ -84,6 +84,18 @@ export const leaseStatusInputSchema = OPERATIONS["lease.list"].input; */ export const leaseStatusOutputSchema = leaseRecordSchema.partial().extend({ held: z.boolean() }); +export const runOnDeviceInputSchema = OPERATIONS["device.exec"].input.omit({ + leaseId: true, + requesterId: true, +}); + +export const runOnDeviceOutputSchema = z.object({ + exitCode: z.number(), + stderr: z.string(), + stdout: z.string(), + truncated: z.boolean(), +}); + export type LeaseSimulatorInput = z.infer; export type LeaseSimulatorOutput = z.infer; export type ListDevicesInput = z.infer; @@ -91,3 +103,4 @@ export type ListDevicesOutput = z.infer; export type ReleaseSimulatorInput = z.infer; export type ReleaseSimulatorOutput = z.infer; export type LeaseStatusOutput = z.infer; +export type RunOnDeviceInput = z.infer; diff --git a/src/mcp/server.ts b/src/mcp/server.ts index 128bdeb1f..3bcdc4566 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -14,6 +14,8 @@ import { listDevicesOutputSchema, releaseSimulatorInputSchema, releaseSimulatorOutputSchema, + runOnDeviceInputSchema, + runOnDeviceOutputSchema, } from "./contracts.js"; import { McpSession, @@ -224,6 +226,30 @@ export function createMcpServer(session: McpSession): McpServer { }, ); + server.registerTool( + "run_on_device", + { + title: "Run on device", + description: + "Run one simctl, adb or devicectl command on this session's leased device, on the machine that holds it. Leave the device out: Simlock fills in the lease's UDID or serial, and refuses a command that names another device. File paths are on that machine. Output is text: stdout and stderr each keep their last 32768 characters, and truncated says when any was cut. A non-zero exitCode is the command's own answer, not a tool error. There is no terminal: a bare adb shell is refused, and an interactive command hangs until the time limit (10 minutes by default) stops it.", + inputSchema: runOnDeviceInputSchema, + outputSchema: runOnDeviceOutputSchema, + }, + async (input) => { + try { + let stdout = ""; + let stderr = ""; + const { exitCode } = await session.runOnDevice(input, (chunk) => { + if (chunk.stream === "stdout") stdout += chunk.chunk; + else stderr += chunk.chunk; + }); + return success({ exitCode, stderr, stdout, truncated: false }); + } catch (error: unknown) { + return failure(error); + } + }, + ); + session.onLeaseLost((notice) => { void server.server.sendLoggingMessage({ data: { diff --git a/src/mcp/session.ts b/src/mcp/session.ts index 028ea223f..d730b0450 100644 --- a/src/mcp/session.ts +++ b/src/mcp/session.ts @@ -1,6 +1,8 @@ import { isSimlockError, SimlockError, + type DeviceOutputChunk, + type ExecOutput, type LeaseProgress, type LeaseRecord, type LeaseRequestInput, @@ -21,6 +23,7 @@ import type { ListDevicesOutput, ReleaseSimulatorInput, ReleaseSimulatorOutput, + RunOnDeviceInput, } from "./contracts.js"; /** @@ -279,6 +282,24 @@ export class McpSession { }); } + // why: not on `#mutate` -- a command can run for minutes, and `lease_status` or a release + // must not wait behind it (ADR 0047 §6). + async runOnDevice( + input: RunOnDeviceInput, + onOutput: (chunk: DeviceOutputChunk) => void, + ): Promise { + this.#throwIfClosed(); + const leaseId = this.#heldLeaseId; + if (leaseId === undefined) { + throw new McpSessionError( + "NO_LEASE", + "This MCP session holds no lease. Call lease_simulator first.", + ); + } + const client = await this.#clientForUse(); + return client.exec({ ...input, leaseId }, { onOutput }); + } + /** Notifies when this session's held lease ends elsewhere (expiry or a force-release). */ onLeaseLost(listener: (notice: LeaseLostNotice) => void): () => void { this.#leaseLostListeners.add(listener); From 83cda8949630622773bc9d64b547139ac42dfd77 Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Sat, 24 Oct 2026 08:53:44 +0200 Subject: [PATCH 03/13] test: an agent's command writes more than the cap and gets the last 32768 characters of that stream with truncated true (#959) --- src/mcp/server.test.ts | 46 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/src/mcp/server.test.ts b/src/mcp/server.test.ts index eb60203d5..999a15fa9 100644 --- a/src/mcp/server.test.ts +++ b/src/mcp/server.test.ts @@ -606,6 +606,52 @@ describe("MCP run_on_device", () => { }); }); +describe("MCP run_on_device output cap", () => { + it("keeps the last 32768 characters of each stream and sets truncated when either lost any", async () => { + const client = new FakeSimlockClient(); + client.requestLeaseImpl = () => Promise.resolve(sampleGrant()); + const { mcpClient, close } = await connectedServer(client); + try { + await call(mcpClient, "lease_simulator", { model: "iPhone 17 Pro", platform: "ios" }); + const cap = 32_768; + const cases = [ + { + chunks: [ + { chunk: "a".repeat(cap), stream: "stdout" as const }, + { chunk: "tail", stream: "stdout" as const }, + ], + expected: { + stderr: "", + stdout: "a".repeat(cap - 4) + "tail", + truncated: true, + }, + }, + { + chunks: [ + { chunk: "out", stream: "stdout" as const }, + { chunk: "b".repeat(cap + 1), stream: "stderr" as const }, + ], + expected: { stderr: "b".repeat(cap), stdout: "out", truncated: true }, + }, + { + chunks: [{ chunk: "c".repeat(cap), stream: "stdout" as const }], + expected: { stderr: "", stdout: "c".repeat(cap), truncated: false }, + }, + ]; + for (const entry of cases) { + client.execImpl = (_input, options) => { + for (const chunk of entry.chunks) options.onOutput?.(chunk); + return Promise.resolve({ exitCode: 0 }); + }; + const result = await call(mcpClient, "run_on_device", { args: [], tool: "adb" }); + expect(result.structuredContent).toEqual({ exitCode: 0, ...entry.expected }); + } + } finally { + await close(); + } + }); +}); + function call(client: Client, name: string, arguments_: Record) { return client.request( { method: "tools/call", params: { arguments: arguments_, name } }, From deb785cf67a3c3d31c1abd7ee127467820ec3b99 Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Sat, 24 Oct 2026 08:54:16 +0200 Subject: [PATCH 04/13] feat(mcp): run_on_device keeps the last 32768 characters of each stream (#959) --- src/mcp/server.ts | 36 +++++++++++++++++++++++++++++++----- 1 file changed, 31 insertions(+), 5 deletions(-) diff --git a/src/mcp/server.ts b/src/mcp/server.ts index 3bcdc4566..56581b60f 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -40,6 +40,28 @@ const STAGE_BASE_PROGRESS: Record = { }; const TOTAL_PROGRESS = 4_000; +const OUTPUT_CAP_CHARACTERS = 32_768; + +class TailBuffer { + text = ""; + lostAny = false; + readonly #cap: number; + + constructor(cap: number) { + this.#cap = cap; + } + + append(chunk: string): void { + const joined = this.text + chunk; + if (joined.length > this.#cap) { + this.lostAny = true; + this.text = joined.slice(joined.length - this.#cap); + } else { + this.text = joined; + } + } +} + /** Maps a lease progress push onto an MCP progress notification's `params`. */ function leaseProgressParams(progress: LeaseProgressNotice): { readonly message: string; @@ -237,13 +259,17 @@ export function createMcpServer(session: McpSession): McpServer { }, async (input) => { try { - let stdout = ""; - let stderr = ""; + const stdout = new TailBuffer(OUTPUT_CAP_CHARACTERS); + const stderr = new TailBuffer(OUTPUT_CAP_CHARACTERS); const { exitCode } = await session.runOnDevice(input, (chunk) => { - if (chunk.stream === "stdout") stdout += chunk.chunk; - else stderr += chunk.chunk; + (chunk.stream === "stdout" ? stdout : stderr).append(chunk.chunk); + }); + return success({ + exitCode, + stderr: stderr.text, + stdout: stdout.text, + truncated: stdout.lostAny || stderr.lostAny, }); - return success({ exitCode, stderr, stdout, truncated: false }); } catch (error: unknown) { return failure(error); } From b4c8c35faea80d6fd4c99c7338df51baff45184a Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Sat, 24 Oct 2026 08:54:53 +0200 Subject: [PATCH 05/13] test: an agent without a lease calls run_on_device and gets NO_LEASE (#959) --- src/mcp/server.test.ts | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/src/mcp/server.test.ts b/src/mcp/server.test.ts index 999a15fa9..703e1aaf1 100644 --- a/src/mcp/server.test.ts +++ b/src/mcp/server.test.ts @@ -652,6 +652,41 @@ describe("MCP run_on_device output cap", () => { }); }); +describe("MCP run_on_device without a lease", () => { + it("answers NO_LEASE with no daemon call when the session holds no lease", async () => { + const client = new FakeSimlockClient(); + client.requestLeaseImpl = () => Promise.resolve(sampleGrant()); + client.releaseLeaseImpl = (input) => Promise.resolve({ leaseId: input.leaseId }); + client.execImpl = () => Promise.resolve({ exitCode: 0 }); + const { mcpClient, close } = await connectedServer(client); + const noLease = { + code: "NO_LEASE", + message: "This MCP session holds no lease. Call lease_simulator first.", + }; + const run = () => call(mcpClient, "run_on_device", { args: [], tool: "simctl" }); + try { + const never = await run(); + expect(never.isError).toBe(true); + expect(JSON.parse(text(never))).toEqual(noLease); + + await call(mcpClient, "lease_simulator", { model: "iPhone 17 Pro", platform: "ios" }); + expect((await run()).isError).not.toBe(true); + + await call(mcpClient, "release_simulator", { leaseId: "lease-1" }); + expect(JSON.parse(text(await run()))).toEqual(noLease); + + await call(mcpClient, "lease_simulator", { model: "iPhone 17 Pro", platform: "ios" }); + expect((await run()).isError).not.toBe(true); + client.emitLeaseLost({ deviceId: "SIM-1", leaseId: "lease-1", reason: "expired" }); + expect(JSON.parse(text(await run()))).toEqual(noLease); + + expect(client.calls.filter((entry) => entry.method === "exec")).toHaveLength(2); + } finally { + await close(); + } + }); +}); + function call(client: Client, name: string, arguments_: Record) { return client.request( { method: "tools/call", params: { arguments: arguments_, name } }, From 64ea0f0bbe86b7219eebedc8b2ca418baba8101f Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Sat, 24 Oct 2026 08:55:13 +0200 Subject: [PATCH 06/13] test: a daemon error comes back as isError with its code and message (#959) --- src/mcp/server.test.ts | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/src/mcp/server.test.ts b/src/mcp/server.test.ts index 703e1aaf1..257251ad4 100644 --- a/src/mcp/server.test.ts +++ b/src/mcp/server.test.ts @@ -687,6 +687,38 @@ describe("MCP run_on_device without a lease", () => { }); }); +describe("MCP run_on_device daemon errors", () => { + it("comes back as isError with the daemon's code and message", async () => { + const client = new FakeSimlockClient(); + client.requestLeaseImpl = () => Promise.resolve(sampleGrant()); + const { mcpClient, close } = await connectedServer(client); + try { + await call(mcpClient, "lease_simulator", { model: "iPhone 17 Pro", platform: "ios" }); + const refusal = new SimlockError( + "PASSTHROUGH_REFUSED", + "domain", + "Refusing `simctl boot booted`: it names device booted.", + { tool: "simctl" }, + ); + const cases = [ + refusal, + new SimlockError("UNKNOWN_PASSTHROUGH_TOOL", "domain", "No driver wraps rm.", { + tool: "rm", + }), + new SimlockError("EXEC_TIMEOUT", "domain", "The command ran past its time limit.", {}), + ]; + for (const error of cases) { + client.execImpl = () => Promise.reject(error); + const result = await call(mcpClient, "run_on_device", { args: [], tool: "simctl" }); + expect(result.isError).toBe(true); + expect(JSON.parse(text(result))).toEqual({ code: error.code, message: error.message }); + } + } finally { + await close(); + } + }); +}); + function call(client: Client, name: string, arguments_: Record) { return client.request( { method: "tools/call", params: { arguments: arguments_, name } }, From 4e54f466ac14e97ef7351846d95e1d407b0f1ae6 Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Sat, 24 Oct 2026 08:56:21 +0200 Subject: [PATCH 07/13] test: an agent that sent a progress token gets run_on_device progress at most once per second while output arrives and every 10 s while none does (#959) --- src/mcp/main.ts | 4 +- src/mcp/server.test.ts | 106 ++++++++++++++++++++++++++++++++++++++++- src/mcp/server.ts | 6 ++- 3 files changed, 111 insertions(+), 5 deletions(-) diff --git a/src/mcp/main.ts b/src/mcp/main.ts index d9a226d81..d1f08199b 100644 --- a/src/mcp/main.ts +++ b/src/mcp/main.ts @@ -49,7 +49,7 @@ export interface McpStdioEnvironment { * the non-launching default, never to `connect`, so injecting `connect` alone cannot hand * the timer an auto-launching path; a test that wants both usually injects both. */ readonly connectForRenew?: () => Promise; - readonly createServer?: (session: McpSession) => McpServer; + readonly createServer?: (session: McpSession, options: { readonly clock: Clock }) => McpServer; readonly createTransport?: () => McpTransport; /** Source for `SIMLOCK_AGENT_ID` and the agent tool's session id (`resolveRequesterId`) when * `requesterId` is not given explicitly. */ @@ -100,7 +100,7 @@ export async function startMcpStdio( // non-launching default rather than a timer that could undo an operator's `daemon stop`. connectForRenew: environment.connectForRenew ?? defaults.connectForRenew, }); - const server = (environment.createServer ?? createMcpServer)(session); + const server = (environment.createServer ?? createMcpServer)(session, { clock }); const transport = (environment.createTransport ?? defaults.createTransport)(); const signals = environment.signals ?? process; const stdin = environment.stdin ?? process.stdin; diff --git a/src/mcp/server.test.ts b/src/mcp/server.test.ts index 257251ad4..9af1e6a5e 100644 --- a/src/mcp/server.test.ts +++ b/src/mcp/server.test.ts @@ -543,16 +543,18 @@ describe("MCP server (smoke)", () => { }); async function connectedServer(client: FakeSimlockClient) { + const clock = new FakeClock(); const session = new McpSession({ - clock: new FakeClock(), + clock, connect: async () => client, connectForRenew: async () => client, }); - const server = createMcpServer(session); + const server = createMcpServer(session, { clock }); const mcpClient = new Client({ name: "mcp-test-client", version: "1.0.0" }); const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); await Promise.all([mcpClient.connect(clientTransport), server.connect(serverTransport)]); return { + clock, close: async () => { await Promise.all([mcpClient.close(), server.close(), session.close()]); }, @@ -719,6 +721,106 @@ describe("MCP run_on_device daemon errors", () => { }); }); +describe("MCP run_on_device progress", () => { + it("sends progress at most once per second while output arrives and every 10 s while none does, only with a progress token", async () => { + const client = new FakeSimlockClient(); + client.requestLeaseImpl = () => Promise.resolve(sampleGrant()); + const { clock, mcpClient, close } = await connectedServer(client); + try { + await call(mcpClient, "lease_simulator", { model: "iPhone 17 Pro", platform: "ios" }); + const install = ["install", "/b/App.app"]; + const cases = [ + { + args: install, + message: "Running simctl install", + script: [ + { at: 0, chunk: true }, + { at: 400, chunk: true }, + { at: 900, chunk: true }, + ], + token: true, + notifications: 1, + }, + { + args: install, + message: "Running simctl install", + script: [ + { at: 0, chunk: true }, + { at: 1500, chunk: true }, + ], + token: true, + notifications: 2, + }, + { + args: install, + message: "Running simctl install", + script: [{ at: 25_000, chunk: false }], + token: true, + notifications: 2, + }, + { + args: [], + message: "Running simctl", + script: [{ at: 0, chunk: true }], + token: true, + notifications: 1, + }, + { + args: install, + message: "", + script: [ + { at: 0, chunk: true }, + { at: 1500, chunk: true }, + { at: 25_000, chunk: false }, + ], + token: false, + notifications: 0, + }, + ]; + for (const entry of cases) { + let emit: ((chunk: { stream: "stdout"; chunk: string }) => void) | undefined; + let finish: (() => void) | undefined; + client.execImpl = (_input, options) => + new Promise((resolve) => { + emit = (chunk) => options.onOutput?.(chunk); + finish = () => resolve({ exitCode: 0 }); + }); + const events: Array<{ + message?: string | undefined; + progress: number; + total?: number | undefined; + }> = []; + const pending = mcpClient.request( + { + method: "tools/call", + params: { arguments: { args: entry.args, tool: "simctl" }, name: "run_on_device" }, + }, + CallToolResultSchema, + entry.token ? { onprogress: (progress) => events.push(progress) } : undefined, + ); + await waitFor(() => emit !== undefined); + let elapsed = 0; + for (const step of entry.script) { + clock.advance(step.at - elapsed); + elapsed = step.at; + if (step.chunk) emit?.({ chunk: "x", stream: "stdout" }); + } + finish?.(); + await pending; + expect(events.map((event) => event.progress)).toEqual( + Array.from({ length: entry.notifications }, (_, index) => index + 1), + ); + for (const event of events) { + expect(event.message).toBe(entry.message); + expect(event.total).toBeUndefined(); + } + } + } finally { + await close(); + } + }); +}); + function call(client: Client, name: string, arguments_: Record) { return client.request( { method: "tools/call", params: { arguments: arguments_, name } }, diff --git a/src/mcp/server.ts b/src/mcp/server.ts index 56581b60f..03ff2d3c6 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -2,6 +2,7 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import type { ProgressNotification } from "@modelcontextprotocol/sdk/types.js"; import { SimlockError } from "../client/index.js"; +import type { Clock } from "../ports/index.js"; import { describeSchemaIssues } from "../contract/index.js"; import { AGENT_INSTRUCTIONS, MCP_SERVER_INSTRUCTIONS } from "../instructions/index.js"; import { @@ -140,7 +141,10 @@ function createLeaseProgressReporter( } /** Creates the MCP tool surface for one lease-owning session. */ -export function createMcpServer(session: McpSession): McpServer { +export function createMcpServer( + session: McpSession, + _options: { readonly clock: Clock }, +): McpServer { const server = new McpServer(SERVER_INFO, { capabilities: { logging: {} }, instructions: MCP_SERVER_INSTRUCTIONS, From 25171fbc03e0203891c918f2b2bec0820e8a59cc Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Sat, 24 Oct 2026 08:58:01 +0200 Subject: [PATCH 08/13] feat(mcp): run_on_device relays progress when the client sent a token (#959) --- src/mcp/server.test.ts | 7 +++++- src/mcp/server.ts | 55 +++++++++++++++++++++++++++++++++++++++--- 2 files changed, 58 insertions(+), 4 deletions(-) diff --git a/src/mcp/server.test.ts b/src/mcp/server.test.ts index 9af1e6a5e..a062ac9df 100644 --- a/src/mcp/server.test.ts +++ b/src/mcp/server.test.ts @@ -724,7 +724,12 @@ describe("MCP run_on_device daemon errors", () => { describe("MCP run_on_device progress", () => { it("sends progress at most once per second while output arrives and every 10 s while none does, only with a progress token", async () => { const client = new FakeSimlockClient(); - client.requestLeaseImpl = () => Promise.resolve(sampleGrant()); + const grant = sampleGrant(); + client.requestLeaseImpl = () => + Promise.resolve({ + ...grant, + lease: { ...grant.lease, ttlDeadline: 3_600_000, ttlMs: 3_600_000 }, + }); const { clock, mcpClient, close } = await connectedServer(client); try { await call(mcpClient, "lease_simulator", { model: "iPhone 17 Pro", platform: "ios" }); diff --git a/src/mcp/server.ts b/src/mcp/server.ts index 03ff2d3c6..83b7f4147 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -2,7 +2,7 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import type { ProgressNotification } from "@modelcontextprotocol/sdk/types.js"; import { SimlockError } from "../client/index.js"; -import type { Clock } from "../ports/index.js"; +import type { Clock, TimerHandle } from "../ports/index.js"; import { describeSchemaIssues } from "../contract/index.js"; import { AGENT_INSTRUCTIONS, MCP_SERVER_INSTRUCTIONS } from "../instructions/index.js"; import { @@ -140,10 +140,46 @@ function createLeaseProgressReporter( }; } +const RUN_PROGRESS_MIN_GAP_MS = 1_000; +const RUN_PROGRESS_HEARTBEAT_MS = 10_000; + +function createRunProgressReporter( + clock: Clock, + progressToken: string | number, + sendNotification: (notification: ProgressNotification) => Promise, + message: string, +): { readonly onOutput: () => void; readonly stop: () => void } { + let sent = 0; + let lastSentAt: number | undefined; + let heartbeat: TimerHandle | undefined; + const send = (): void => { + sent += 1; + lastSentAt = clock.now(); + if (heartbeat !== undefined) clock.cancel(heartbeat); + heartbeat = clock.setTimer(RUN_PROGRESS_HEARTBEAT_MS, send); + // why: progress is advisory -- a transport that went away mid-command must not surface here + // as an unhandled rejection. + void sendNotification({ + method: "notifications/progress", + params: { message, progress: sent, progressToken }, + }).catch(() => undefined); + }; + heartbeat = clock.setTimer(RUN_PROGRESS_HEARTBEAT_MS, send); + return { + onOutput: () => { + if (lastSentAt === undefined || clock.now() - lastSentAt >= RUN_PROGRESS_MIN_GAP_MS) send(); + }, + stop: () => { + if (heartbeat !== undefined) clock.cancel(heartbeat); + heartbeat = undefined; + }, + }; +} + /** Creates the MCP tool surface for one lease-owning session. */ export function createMcpServer( session: McpSession, - _options: { readonly clock: Clock }, + options: { readonly clock: Clock }, ): McpServer { const server = new McpServer(SERVER_INFO, { capabilities: { logging: {} }, @@ -261,12 +297,23 @@ export function createMcpServer( inputSchema: runOnDeviceInputSchema, outputSchema: runOnDeviceOutputSchema, }, - async (input) => { + async (input, extra) => { + const progressToken = extra._meta?.progressToken; + const progress = + progressToken === undefined + ? undefined + : createRunProgressReporter( + options.clock, + progressToken, + extra.sendNotification, + `Running ${[input.tool, input.args[0]].filter((word) => word !== undefined).join(" ")}`, + ); try { const stdout = new TailBuffer(OUTPUT_CAP_CHARACTERS); const stderr = new TailBuffer(OUTPUT_CAP_CHARACTERS); const { exitCode } = await session.runOnDevice(input, (chunk) => { (chunk.stream === "stdout" ? stdout : stderr).append(chunk.chunk); + progress?.onOutput(); }); return success({ exitCode, @@ -276,6 +323,8 @@ export function createMcpServer( }); } catch (error: unknown) { return failure(error); + } finally { + progress?.stop(); } }, ); From 2443825911db1dc75216aa2b58e41c4eeab0c354 Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Sat, 24 Oct 2026 08:58:21 +0200 Subject: [PATCH 09/13] test: lease_status answers while run_on_device is still running (#959) --- src/mcp/server.test.ts | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/src/mcp/server.test.ts b/src/mcp/server.test.ts index a062ac9df..b0a5fcc15 100644 --- a/src/mcp/server.test.ts +++ b/src/mcp/server.test.ts @@ -826,6 +826,35 @@ describe("MCP run_on_device progress", () => { }); }); +describe("MCP run_on_device alongside other tools", () => { + it("lets lease_status answer while the command is still running", async () => { + const client = new FakeSimlockClient(); + const grant = sampleGrant(); + client.requestLeaseImpl = () => Promise.resolve(grant); + client.listLeasesImpl = () => + Promise.resolve({ + leases: [{ ...grant.lease, deviceId: grant.device.id }], + }); + let finish: (() => void) | undefined; + client.execImpl = () => + new Promise((resolve) => { + finish = () => resolve({ exitCode: 0 }); + }); + const { mcpClient, close } = await connectedServer(client); + try { + await call(mcpClient, "lease_simulator", { model: "iPhone 17 Pro", platform: "ios" }); + const running = call(mcpClient, "run_on_device", { args: [], tool: "simctl" }); + await waitFor(() => finish !== undefined); + const status = await call(mcpClient, "lease_status", {}); + expect(status.structuredContent).toMatchObject({ held: true, id: "lease-1" }); + finish?.(); + expect((await running).structuredContent).toMatchObject({ exitCode: 0 }); + } finally { + await close(); + } + }); +}); + function call(client: Client, name: string, arguments_: Record) { return client.request( { method: "tools/call", params: { arguments: arguments_, name } }, From 4bb45c9d33225d6a792c763a3fe04babfc4e5620 Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Sat, 24 Oct 2026 08:58:34 +0200 Subject: [PATCH 10/13] test: an agent lists the tools and sees five, run_on_device with its description (#959) --- src/mcp/server.test.ts | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/src/mcp/server.test.ts b/src/mcp/server.test.ts index b0a5fcc15..e2c24d9ba 100644 --- a/src/mcp/server.test.ts +++ b/src/mcp/server.test.ts @@ -16,7 +16,7 @@ import { leaseSimulatorInputSchema } from "./contracts.js"; import { createMcpServer } from "./server.js"; describe("MCP server (smoke)", () => { - it("advertises exactly the lease, catalog, and status tools, and walks lease -> list -> status -> release", async () => { + it("advertises exactly the lease, catalog, status and run_on_device tools, and walks lease -> list -> status -> release", async () => { const client = new FakeSimlockClient(); const grant = sampleGrant(); client.requestLeaseImpl = () => Promise.resolve(grant); @@ -78,6 +78,15 @@ describe("MCP server (smoke)", () => { expect(Object.keys(leaseTool?.inputSchema.properties ?? {})).toEqual( expect.arrayContaining(["platform", "model", "class"]), ); + const runTool = tools.tools.find((tool) => tool.name === "run_on_device"); + expect(Object.keys(runTool?.inputSchema.properties ?? {}).sort()).toEqual([ + "args", + "stdin", + "tool", + ]); + expect(runTool?.description).toBe( + "Run one simctl, adb or devicectl command on this session's leased device, on the machine that holds it. Leave the device out: Simlock fills in the lease's UDID or serial, and refuses a command that names another device. File paths are on that machine. Output is text: stdout and stderr each keep their last 32768 characters, and truncated says when any was cut. A non-zero exitCode is the command's own answer, not a tool error. There is no terminal: a bare adb shell is refused, and an interactive command hangs until the time limit (10 minutes by default) stops it.", + ); for (const tool of tools.tools) { expect(tool.inputSchema).toEqual(expect.any(Object)); expect(tool.outputSchema).toEqual(expect.any(Object)); From ee095a5b90c7f558442105f28de34712722bcc9a Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Sat, 24 Oct 2026 08:59:05 +0200 Subject: [PATCH 11/13] docs(mcp): five tools, run_on_device in the manuals, the website and the instructions text (#959) --- src/instructions/index.test.ts | 8 +++++++- src/instructions/index.ts | 5 +++-- src/mcp/AGENTS.md | 8 +++++++- .../docs/docs/getting-started/connect-your-agent.mdx | 5 +++-- website/src/content/docs/docs/introduction/index.mdx | 2 +- website/src/content/docs/docs/reference/cli.mdx | 2 +- website/src/site/content.ts | 2 +- 7 files changed, 23 insertions(+), 9 deletions(-) diff --git a/src/instructions/index.test.ts b/src/instructions/index.test.ts index 718b5d8bb..2737ca856 100644 --- a/src/instructions/index.test.ts +++ b/src/instructions/index.test.ts @@ -25,7 +25,13 @@ const HEADINGS = [ "## MCP", ] as const; -const TOOL_NAMES = ["lease_simulator", "list_devices", "release_simulator", "lease_status"]; +const TOOL_NAMES = [ + "lease_simulator", + "list_devices", + "release_simulator", + "lease_status", + "run_on_device", +]; const REQUIRED: Readonly> = { "## Rules for every agent": [ diff --git a/src/instructions/index.ts b/src/instructions/index.ts index 88a5b825d..ef73f73d0 100644 --- a/src/instructions/index.ts +++ b/src/instructions/index.ts @@ -59,14 +59,15 @@ Every failure writes one JSON line on stderr, \`{"error":{"code":"...","message" const MCP = `## MCP -When Simlock is connected to you as an MCP server, it has four tools: +When Simlock is connected to you as an MCP server, it has five tools: - \`list_devices\`: what can be leased. Call it before leasing. - \`lease_simulator\`: lease one device. It is renewed for you and released when the server exits. - \`release_simulator\`: give the device back. - \`lease_status\`: whether you still hold a lease, and which. Call it after a context compaction. +- \`run_on_device\`: run one \`simctl\`, \`adb\` or \`devicectl\` command on your leased device, for example install and launch an app. Leave the device out; Simlock fills it in. It returns \`exitCode\`, \`stdout\`, \`stderr\` and \`truncated\`; a non-zero exit code is the command's answer, not an error. -The tools lease and release only. Install and launch through \`simlock simctl\` and \`simlock adb\` with \`device.address\`. Under Codex, the MCP server and the CLI are two identities: use only one of them per session.`; +Under Codex, the MCP server and the CLI are two identities: use only one of them per session.`; const MORE = "More: https://callstackincubator.github.io/simlock/docs/"; diff --git a/src/mcp/AGENTS.md b/src/mcp/AGENTS.md index 69e362549..5171e4df6 100644 --- a/src/mcp/AGENTS.md +++ b/src/mcp/AGENTS.md @@ -36,7 +36,13 @@ Surface: [connect-your-agent.mdx](../../website/src/content/docs/docs/getting-st - `list_devices` → `catalog.get`. - `release_simulator` → `lease.release`. A lease not this principal's: the daemon's `FORBIDDEN`. - `lease_status` → `lease.list`, filtered to the held lease; `{ held: false }` when none. -4. **Serialize** — every tool call queues on one chain in `McpSession`. + - `run_on_device` → `device.exec` on the held lease. Input: `tool`, `args`, `stdin?`, never a lease + or device. Result `{ exitCode, stdout, stderr, truncated }`, each stream cut to its last + 32 768 characters; a non-zero exit is a result. No held lease: `NO_LEASE`. With a progress + token: `notifications/progress` on the first output, then at most one a second, and every 10 s + while silent (the session's clock). +4. **Serialize** — every tool call but `run_on_device` queues on one chain in `McpSession`; + `run_on_device` does not queue, so `lease_status` and `release_simulator` run while a command does. 5. **Renew** — a grant starts `startLeaseRenewal` (lease-policy): every third of the TTL. 6. **Pushes** → MCP logging messages, logger `simlock`: lease lost (warning), device unhealthy (warning), device recovered (info). diff --git a/website/src/content/docs/docs/getting-started/connect-your-agent.mdx b/website/src/content/docs/docs/getting-started/connect-your-agent.mdx index 188c67717..bd2faa6dc 100644 --- a/website/src/content/docs/docs/getting-started/connect-your-agent.mdx +++ b/website/src/content/docs/docs/getting-started/connect-your-agent.mdx @@ -63,7 +63,7 @@ While the request waits in the queue or a device boots, `simlock lease` blocks a ## Use the MCP server -`simlock mcp` runs Simlock's MCP server over stdio. It has four tools: +`simlock mcp` runs Simlock's MCP server over stdio. It has five tools: | Tool | What it does | | --- | --- | @@ -71,6 +71,7 @@ While the request waits in the queue or a device boots, `simlock lease` blocks a | `lease_simulator` | Leases one simulator or emulator for this session. Takes `platform` and, optionally, `model` or `class`, `osVersion`, `ttlMs`, `noWait`, `timeoutMs` and `allowDownload`, the same options as `simlock lease`. Returns the same lease the CLI prints. | | `release_simulator` | Releases this session's lease. | | `lease_status` | Reports this session's lease, or `held: false` when it has none. Safe to call any time, for example after the agent's context was compacted. | +| `run_on_device` | Runs one `simctl`, `adb` or `devicectl` command on this session's lease, on the machine that holds the device. Takes `tool`, `args` and, optionally, `stdin`. Never takes a device or a lease id: Simlock fills in the lease's device and refuses a command that names another. Returns `{exitCode, stdout, stderr, truncated}`; stdout and stderr each keep their last 32768 characters, and `truncated` says when any was cut. A non-zero `exitCode` is the command's own answer, not an error. Without a lease it answers `NO_LEASE`. There is no terminal, and a command that waits for input is stopped after the time limit (10 minutes by default). | The server sends the short version of the rules to the client when it connects, and the full text as the resource `simlock://instructions`. It renews its lease while it runs, and releases it when the agent tool closes the connection. It also starts the daemon if it is not running. @@ -115,7 +116,7 @@ This works when the client starts one server process per agent session. If your -The MCP tools lease and release devices. They do not run commands on the device. To install and launch apps, the agent still runs `simlock simctl` or `simlock adb` in its shell with the lease's `device.address`, as in the CLI instructions above. +To install and launch apps, the agent calls `run_on_device`, for example `{"tool":"simctl","args":["install","/builds/App.app"]}`. File paths are on the machine that holds the device. It works the same through `simlock --gateway mcp`. If the agent's lease ends for another reason, such as an operator releasing it, the server sends the agent a warning log message. If the device crashes and Simlock reboots it, the agent gets a warning, then a notice when it is ready again under the same lease. diff --git a/website/src/content/docs/docs/introduction/index.mdx b/website/src/content/docs/docs/introduction/index.mdx index f860d601e..ae51c80fc 100644 --- a/website/src/content/docs/docs/introduction/index.mdx +++ b/website/src/content/docs/docs/introduction/index.mdx @@ -32,7 +32,7 @@ Simlock works by agreement, not by force. It does not hide other devices or bloc | Way in | Who it is for | | --- | --- | | CLI (`simlock lease`, `simlock release`) | Any agent that can run shell commands, and you at a terminal. | -| MCP server (`simlock mcp`) | Agents that use the Model Context Protocol. Four tools: list, lease, release, status. | +| MCP server (`simlock mcp`) | Agents that use the Model Context Protocol. Five tools: list, lease, release, status, run a command. | | HTTP API | Agents on another machine. They need a token from the operator. | | Programmatic client (`simlock/client`) | Node.js tools on the same machine that want to lease devices without running the CLI. | diff --git a/website/src/content/docs/docs/reference/cli.mdx b/website/src/content/docs/docs/reference/cli.mdx index e219f0de6..4dafd13a6 100644 --- a/website/src/content/docs/docs/reference/cli.mdx +++ b/website/src/content/docs/docs/reference/cli.mdx @@ -197,7 +197,7 @@ simlock catalog [--platform ] [--json] ## simlock mcp -Start Simlock's MCP server on stdin and stdout, for an agent to call. It offers the tools `list_devices`, `lease_simulator`, `release_simulator` and `lease_status`. See [Connect your agent](/simlock/docs/getting-started/connect-your-agent). +Start Simlock's MCP server on stdin and stdout, for an agent to call. It offers the tools `list_devices`, `lease_simulator`, `release_simulator`, `lease_status` and `run_on_device`. See [Connect your agent](/simlock/docs/getting-started/connect-your-agent). ```text simlock mcp diff --git a/website/src/site/content.ts b/website/src/site/content.ts index f79792df4..86bde6c90 100644 --- a/website/src/site/content.ts +++ b/website/src/site/content.ts @@ -195,7 +195,7 @@ export const home = { lang: "json", code: code.mcp, pkg: "simlock mcp", - note: "Four tools for agents that speak MCP: list, lease, release and status.", + note: "Five tools for agents that speak MCP: list, lease, release, status and run a command.", href: docs.connect, }, { From 939d8333a7fdb73b9a2f36ee4bd6844091c6c50d Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Sat, 24 Oct 2026 08:59:17 +0200 Subject: [PATCH 12/13] docs(mcp): the e2e title names the four tools it calls (#959) --- e2e/mcp-session.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/e2e/mcp-session.test.ts b/e2e/mcp-session.test.ts index 45c15c116..d13c90bfc 100644 --- a/e2e/mcp-session.test.ts +++ b/e2e/mcp-session.test.ts @@ -9,7 +9,7 @@ interface McpErrorPayload { } describe("MCP session semantics", () => { - it("exercises all four tools, streams strictly increasing progress with human messages, and its lease is the one the CLI lists", async () => { + it("exercises the lease, list, status and release tools, streams strictly increasing progress with human messages, and its lease is the one the CLI lists", async () => { const env = await withDaemon(); await env.driverScript.set({ ios: { knownModels: ["iPhone 16"], availableOsVersions: ["18.4"] }, From ed1f7361a54eab7e9fd84c936bfe07b2e1399a25 Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Sat, 24 Oct 2026 09:03:34 +0200 Subject: [PATCH 13/13] test: run_on_device progress straddles its gap and heartbeat, and stops with the call (#959) --- docs/internal/flows/device-exec.md | 2 ++ src/mcp/server.test.ts | 42 ++++++++++++++++++++++++++++++ 2 files changed, 44 insertions(+) diff --git a/docs/internal/flows/device-exec.md b/docs/internal/flows/device-exec.md index f10944c5f..94d9985cb 100644 --- a/docs/internal/flows/device-exec.md +++ b/docs/internal/flows/device-exec.md @@ -11,6 +11,8 @@ Runs the command on the machine that owns the device and streams its output back the daemon is a gateway, on `--lease ` or the caller's one lease. Against a worker the CLI asks `driver.passthrough` for the command and spawns it itself, with its own terminal. +- The MCP tool `run_on_device` ([mcp](../../../src/mcp/AGENTS.md)): the same `exec` on the + session's held lease, which the caller never names. - HTTP `POST /v1/leases/{id}/exec` → SSE: `output`, then `exit` or `error`. The requester is the token's; only an `operator` token may send `requesterId`. Every failure before the process exists is a plain JSON error with its status; diff --git a/src/mcp/server.test.ts b/src/mcp/server.test.ts index e2c24d9ba..dce4303ea 100644 --- a/src/mcp/server.test.ts +++ b/src/mcp/server.test.ts @@ -772,6 +772,41 @@ describe("MCP run_on_device progress", () => { token: true, notifications: 2, }, + { + args: install, + message: "Running simctl install", + script: [ + { at: 0, chunk: true }, + { at: 1000, chunk: true }, + ], + token: true, + notifications: 2, + }, + { + args: install, + message: "Running simctl install", + script: [{ at: 9_999, chunk: false }], + token: true, + notifications: 0, + }, + { + args: install, + message: "Running simctl install", + script: [{ at: 10_000, chunk: false }], + token: true, + notifications: 1, + }, + { + args: install, + message: "Running simctl install", + script: [ + { at: 0, chunk: true }, + { at: 5000, chunk: true }, + { at: 12_000, chunk: false }, + ], + token: true, + notifications: 2, + }, { args: [], message: "Running simctl", @@ -804,6 +839,8 @@ describe("MCP run_on_device progress", () => { progress: number; total?: number | undefined; }> = []; + const unexpected: Error[] = []; + mcpClient.onerror = (error) => unexpected.push(error); const pending = mcpClient.request( { method: "tools/call", @@ -821,6 +858,11 @@ describe("MCP run_on_device progress", () => { } finish?.(); await pending; + // why: a notification the server sends without a token, or after the call ended, reaches + // the client as an error for an unknown token. + clock.advance(30_000); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(unexpected).toEqual([]); expect(events.map((event) => event.progress)).toEqual( Array.from({ length: entry.notifications }, (_, index) => index + 1), );