Repository navigation
MCP: run_on_device runs a command on the session's lease #959
Description
Part of #951.
Scope
After this PR, an agent on MCP runs one simctl, adb or devicectl command against its session's lease with a fifth tool, run_on_device, and gets stdout, stderr and the exit code in one result. It never passes a device id or a lease id: the session's own lease is used, and the device is filled in on the worker by task 1 (ADR 0047). This works the same through simlock --gateway mcp.
In short
run_on_device {"tool":"simctl","args":["install","/builds/App.app"]} installs on the session's simulator. The result is {exitCode, stdout, stderr, truncated} (feature Shared values, "run_on_device result"): each stream keeps its last 32 768 characters, counted in JavaScript string characters per stream per call, and truncated is true when either lost any. A non-zero exit code is a normal result. Every failure from the daemon or the session is isError with {code, message}, as for the other four tools; with no lease it is NO_LEASE with the message This MCP session holds no lease. Call lease_simulator first. Malformed input (an unknown field such as leaseId, an empty tool) gets the MCP SDK's own input-validation error before the handler runs, as every other tool does (F14).
The tool's description, word for word:
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.
sequenceDiagram
participant A as Agent
participant S as McpSession (CHANGED)
participant C as SimlockClient
participant D as Daemon or gateway
A->>S: run_on_device {tool, args, stdin?}
alt no held lease
S-->>A: isError {NO_LEASE, message}
else
S->>C: exec({leaseId: held, tool, args, stdin}, {onOutput})
C->>D: device.exec
D-->>C: output chunks
C-->>S: onOutput (kept up to the cap; progress if token)
alt daemon error (PASSTHROUGH_REFUSED, EXEC_TIMEOUT, …)
D-->>A: isError {code, message}
else exit
D-->>A: {exitCode, stdout, stderr, truncated}
end
end
Before and after: the feature's "How it works for the user" session. A user would notice a wrong build as a missing tool, output cut wrongly, or lease_status hanging while a command runs.
Technical spec
Facts
Commit cac23e033.
| # | Fact | Source |
|---|---|---|
| F1 | Tools are registered with server.registerTool(name, {title, description, inputSchema, outputSchema}, handler); results go through success (text + structuredContent) and failure (isError, {code,message}) |
src/mcp/server.ts:121-208, :265-278 |
| F2 | Every session call runs on one chain, #mutate |
src/mcp/session.ts:617-624; callers :181, :210, :253, :273 |
| F3 | #heldLeaseId is set when lease() returns and cleared on release, a renewal's lease-gone and a lease-lost push |
src/mcp/session.ts:203, :443, :484, :570 |
| F4 | SimlockClient.exec(input, {onOutput, onStarted}) resolves {exitCode}; a chunk is {stream: "stdout"|"stderr", chunk: string}; there is no abort option |
src/simlock-client/types.ts:116-119, :147-157, :235; src/simlock-client/client.ts:609 |
| F5 | device.exec input is strict {leaseId, tool: string min 1, args: string[], stdin?, requesterId?}, output {exitCode: number} |
src/contract/operations.ts:454-484 |
| F6 | toMcpErrorResult maps a SimlockError or McpSessionError to {code, message}, anything else to INTERNAL |
src/mcp/session.ts:101-105 |
| F7 | Progress goes out with extra.sendNotification on notifications/progress only when extra._meta.progressToken is set; a send failure is swallowed |
src/mcp/server.ts:100-115, :132-136 |
| F8 | MCP tool schemas are cut from the contract's schemas | src/mcp/contracts.ts:1-13 |
| F9 | FakeSimlockClient.exec takes only input; the test that lists the tools pins four |
src/mcp/test-support.ts:150; src/mcp/server.test.ts:19 |
| F10 | Through a gateway, device.exec is forwarded to the owning worker |
src/gateway/fleet-coordinator.ts:467; docs/internal/flows/device-exec.md "Through a gateway" |
| F11 | One lease per requester by default | src/core/config.ts:61 |
| F12 | MCP SDK @modelcontextprotocol/sdk 1.29.0 |
node_modules/@modelcontextprotocol/sdk/package.json:3 |
| F13 | Docs listing the four tools: connect-your-agent.mdx:72-79, the "do not run commands" line :120; reference/cli.mdx:198; introduction/index.mdx:35 ("Four tools: list, lease, release, status.") |
those lines; grep -rni mcp website/src/content/docs/docs |
| F14 | The SDK validates tool input against inputSchema before the handler and answers isError with the text MCP error -32602: Input validation error: … |
node_modules/@modelcontextprotocol/sdk/dist/esm/server/mcp.js:125,141,178 |
| F15 | createMcpServer(session) takes no clock; main.ts builds it with (environment.createServer ?? createMcpServer)(session); the session already has the clock |
src/mcp/server.ts:118; src/mcp/main.ts:95-103 |
| F16 | #147 (task:draft) adds the instructions text, whose MCP section says the tools "lease and release only" | #147 body, section table row ## MCP |
Modules touched
Searches: rg "registerTool|lease_status|release_simulator" src website, grep -rni mcp website/src/content/docs/docs, rg "exec\(" src/mcp src/simlock-client.
flowchart LR
srv["mcp/server.ts (CHANGED)"] --> ses["mcp/session.ts (CHANGED)"]
srv --> con["mcp/contracts.ts (CHANGED)"]
ses --> cli[simlock-client exec]
con --> ops[contract device.exec schema]
src/mcp/contracts.ts:runOnDeviceInputSchema=device.execinput minusleaseId,requesterId; output schema per Shared values.src/mcp/session.ts:runOnDevice(input, onChunk)— reads#heldLeaseId, throwsMcpSessionError("NO_LEASE", …)when unset, callsclient.execthrough#clientForUse(), not through#mutate.src/mcp/server.ts: the tool, the cap, the progress rule (feature Shared values, "run_on_deviceprogress"); its timers stop when the call ends.createMcpServertakes the clock as a second argument (createMcpServer(session, { clock })), andsrc/mcp/main.ts:103passes the session's clock (F15); the progress timers use it.src/mcp/test-support.ts: the fake'sexectakes and callsoptions.onOutput.- Docs:
src/mcp/AGENTS.md(Tools, Serialize),connect-your-agent.mdx(table of five; line 120 replaced),reference/cli.mdx:198,introduction/index.mdx:35. - The instructions text Add
simlock instructions: the rules an agent must follow, printable and served over MCP #147 adds (src/instructions/): its MCP section namesrun_on_deviceand no longer says the tools lease and release only (F16), keeping Addsimlock instructions: the rules an agent must follow, printable and served over MCP #147's 4096-character cap on the server instructions.
Contract and event changes
None in the daemon contract. New MCP tool run_on_device: input tool (string, at least 1 character, as F5), args (string array), stdin (optional string); unknown fields refused by the contract's strict object, answered by the SDK's input-validation error (F14). Output per Shared values. No event.
Other code on the same state
State: #heldLeaseId and the session's client. Searches: rg "#heldLeaseId" src/mcp/session.ts (9 hits), rg "#clientForUse" src/mcp/session.ts (8 hits).
release()/ lease lost while a command runs: the command keeps running on the worker (flow doc "Known gaps"); its result is still returned. Arun_on_devicestarted after the release getsNO_LEASE.close()while a command runs:close()does not wait for it (it waits only on#mutations,session.ts:316-330); the client closes; the call failsDAEMON_CONNECTION_LOSTorSESSION_CLOSED; the command runs on (flow doc "Detach", ADR 0047 §6).lease()granting a new lease during a run: the running call keeps the lease id it read at its start.
Failure modes
| Call | Outcome | What happens | Limit, counted from |
|---|---|---|---|
client.exec |
ok | {exitCode, stdout, stderr, truncated} |
worker exec.timeoutMs (10 min) from spawn |
| same | error reply (PASSTHROUGH_REFUSED, UNKNOWN_PASSTHROUGH_TOOL, UNKNOWN_LEASE, FORBIDDEN, WORKER_UNREACHABLE) |
isError {code, message} |
— |
| same | EXEC_TIMEOUT |
isError {code, message} |
10 min, worker; 11 min gateway backstop |
| same | connection lost | isError {DAEMON_CONNECTION_LOST, …}; next call reconnects |
— |
| connect | daemon absent | auto-launch as for other tools (connectWithAutoLaunch) |
startupTimeoutMs from the call |
| — | no held lease | isError {NO_LEASE, …}, no daemon call |
— |
| MCP cancel | client cancels | the SDK drops the reply; the command runs on to exit or 10 min | — |
Writes no state.
Rules in play
- ADR 0003 §11, "MCP tool schemas are derived from the contract schemas" — input cut from
device.exec. - ADR 0005 §19c, frontends hold no refusal list — refusals come from the daemon unchanged.
- ADR 0047 §3 — the tool sends no device; the worker fills it in.
- ADR 0047 §6 —
run_on_devicedoes not queue; a release or close during a command goes ahead. - Architecture rule 11, "Every wait that crosses a process boundary is bounded" — bounded by the worker's
exec.timeoutMsand the gateway backstop. - Documentation rule 9, "A change to the public surface updates the website in the same change".
Tests
Seam: an MCP client connected to createMcpServer over an in-memory transport with FakeSimlockClient, modelled on src/mcp/server.test.ts:18-123.
- An agent with a lease calls
run_on_deviceand gets the command's stdout, stderr and exit code: exit 0 with stdout; exit 3 with stderr (notisError);stdinpassed through; the call names the session's lease id. - An agent's command writes more than the cap and gets the last 32 768 characters of that stream with
truncated: true: stdout over; stderr over; exactly 32 768 →truncated: false. - An agent without a lease calls
run_on_deviceand getsNO_LEASE: never leased; afterrelease_simulator; after a lease-lost push. - A daemon error comes back as
isError{code, message}:PASSTHROUGH_REFUSED;UNKNOWN_PASSTHROUGH_TOOL;EXEC_TIMEOUT. - An agent that sent a progress token gets progress notifications at most once per second while output arrives and every 10 s while none does; without a token none (fake clock): token, 3 chunks within 1 s → 1; token, chunks at 0 s and 1.5 s → 2; token, silent 25 s → 2; message
Running simctl install, andRunning simctlfor emptyargs; no token → 0. - An agent calls
lease_statuswhilerun_on_deviceis still running and gets its answer before the command ends. - An agent lists the tools and sees five, with
run_on_devicetakingtool,args,stdinand noleaseIdorrequesterId, and its description the text in In short (extendssrc/mcp/server.test.ts:19).
Done when
pnpm checkis green (toolchain.md).- The seven Tests lines pass in
pnpm test. connect-your-agent.mdxlists five tools, describesrun_on_device's result andNO_LEASE, and no longer says the MCP tools do not run commands;reference/cli.mdxandintroduction/index.mdxname five tools;src/mcp/AGENTS.mddescribes the tool and that it does not queue; the instructions text's MCP section namesrun_on_device.
Out of scope
- Binary or file output (a screenshot as an MCP image).
- Cancelling the command on the worker when the MCP call is cancelled.
- A
leaseIdinput for sessions with several leases. - Rewriting the instructions text beyond its MCP section (Add
simlock instructions: the rules an agent must follow, printable and served over MCP #147).
Depends on
- device.exec fills in the lease's device and refuses another #958 (the device is filled in; the docs promise it)
- Add
simlock instructions: the rules an agent must follow, printable and served over MCP #147 (also changescreateMcpServer)
Written by an agent.
Activity
feat(mcp): run_on_device runs a command on the session's lease (#959)
test: an agent's command writes more than the cap and gets the last 3…
feat(mcp): run_on_device keeps the last 32768 characters of each stre…
test: an agent without a lease calls run_on_device and gets NO_LEASE (#…
test: a daemon error comes back as isError with its code and message (#…
test: an agent that sent a progress token gets run_on_device progress…
feat(mcp): run_on_device relays progress when the client sent a token (…
test: lease_status answers while run_on_device is still running (#959)
test: an agent lists the tools and sees five, run_on_device with its …
docs(mcp): five tools, run_on_device in the manuals, the website and …
docs(mcp): the e2e title names the four tools it calls (#959)
test: run_on_device progress straddles its gap and heartbeat, and sto…
docs(adr): 0047 device.exec fills in the lease's device, and refuses …
feat(drivers): device.exec fills in the lease's device and refuses an…
feat(mcp): run_on_device runs a command on the session's lease (#974)