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/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"] }, 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/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/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 aa7e57efa..dce4303ea 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); @@ -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", @@ -77,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)); @@ -542,16 +552,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()]); }, @@ -559,6 +571,341 @@ 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(); + } + }); +}); + +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(); + } + }); +}); + +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(); + } + }); +}); + +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(); + } + }); +}); + +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(); + 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" }); + 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: 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", + 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 unexpected: Error[] = []; + mcpClient.onerror = (error) => unexpected.push(error); + 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; + // 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), + ); + for (const event of events) { + expect(event.message).toBe(entry.message); + expect(event.total).toBeUndefined(); + } + } + } finally { + await close(); + } + }); +}); + +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 } }, diff --git a/src/mcp/server.ts b/src/mcp/server.ts index 128bdeb1f..83b7f4147 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, TimerHandle } from "../ports/index.js"; import { describeSchemaIssues } from "../contract/index.js"; import { AGENT_INSTRUCTIONS, MCP_SERVER_INSTRUCTIONS } from "../instructions/index.js"; import { @@ -14,6 +15,8 @@ import { listDevicesOutputSchema, releaseSimulatorInputSchema, releaseSimulatorOutputSchema, + runOnDeviceInputSchema, + runOnDeviceOutputSchema, } from "./contracts.js"; import { McpSession, @@ -38,6 +41,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; @@ -115,8 +140,47 @@ 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): McpServer { +export function createMcpServer( + session: McpSession, + options: { readonly clock: Clock }, +): McpServer { const server = new McpServer(SERVER_INFO, { capabilities: { logging: {} }, instructions: MCP_SERVER_INSTRUCTIONS, @@ -224,6 +288,47 @@ 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, 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, + stderr: stderr.text, + stdout: stdout.text, + truncated: stdout.lostAny || stderr.lostAny, + }); + } catch (error: unknown) { + return failure(error); + } finally { + progress?.stop(); + } + }, + ); + 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); 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 { 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, }, {