Repository navigation
MCP: a run_on_device tool runs simctl, adb or devicectl against the session's lease #951
Description
Request: #949
Problem
The MCP server has four tools: list_devices, lease_simulator, release_simulator, lease_status. None of them touches the device. After lease_simulator, an agent on MCP still drops into its shell, runs simlock simctl install <UDID> …, and copies the UDID out of the tool result into every command. The docs say so: "The MCP tools lease and release devices. They do not run commands on the device" (getting-started/connect-your-agent.mdx).
The same copying hits every device.exec caller (HTTP, gateway, client): for simctl and an emulator's adb, the arguments must name the device. Nothing stops an agent that copies the wrong id, or uses booted, from driving another agent's simulator.
Who it is for
A coding agent that reaches Simlock only through MCP (agent-device first, then any MCP client), on one machine or through simlock --gateway mcp. Second: any agent calling POST /v1/leases/{id}/exec over HTTP, the client's exec, or simlock --gateway simctl|adb.
Outcome
- An agent on MCP alone installs, launches and inspects an app on its leased device without a shell and without ever passing a device id.
- Every
device.execcaller may leave the device out ofsimctl,adbanddevicectlcommands; Simlock fills in the lease's device. - A command that names a different device than the lease's is refused before it runs, with a message naming both.
How it works for the user
sequenceDiagram
participant A as Agent
participant M as simlock mcp
participant W as Worker (or gateway → worker)
A->>M: lease_simulator {platform: ios}
M-->>A: grant
A->>M: run_on_device {tool: simctl, args: [install, /builds/App.app]}
M->>W: device.exec on the session's lease
alt refused (names another device, refused verb)
W-->>M: PASSTHROUGH_REFUSED
M-->>A: isError {code, message}
else no lease held
M-->>A: isError {code: NO_LEASE, message}
else runs
W-->>M: output chunks, exit code
M-->>A: {exitCode, stdout, stderr, truncated}
end
Before, over MCP:
lease_simulator {"platform":"ios"} → {"device":{"driverDeviceId":"6F1D…3C90",…},…}
(shell) simlock simctl install 6F1D…3C90 /builds/App.app
After:
run_on_device {"tool":"simctl","args":["install","/builds/App.app"]}
→ {"exitCode":0,"stdout":"","stderr":"","truncated":false}
run_on_device {"tool":"simctl","args":["install","booted","/builds/App.app"]}
→ isError {"code":"PASSTHROUGH_REFUSED","message":"Refusing `simctl install booted /builds/App.app`: it names device booted, and this lease holds 6F1D…3C90. Leave the device out; Simlock fills it in."}
run_on_device {"tool":"adb","args":["shell","getprop","ro.build.version.sdk"]} (before any lease)
→ isError {"code":"NO_LEASE","message":"This MCP session holds no lease. Call lease_simulator first."}
Examples
device.exec arguments on an iOS simulator lease (UDID 6F1D…3C90) and an emulator lease (serial emulator-5554):
| Tool, args | Today | After |
|---|---|---|
simctl install /b/App.app |
runs install /b/App.app, simctl fails: no device |
runs install 6F1D…3C90 /b/App.app |
simctl install 6F1D…3C90 /b/App.app |
runs | runs, unchanged |
simctl install 6f1d…3c90 /b/App.app (lower case) |
runs | runs, unchanged |
simctl install booted /b/App.app |
runs on some booted simulator | refused |
simctl install <another UDID> /b/App.app |
runs on that device | refused |
simctl launch --console com.example.app |
simctl fails | runs launch --console 6F1D…3C90 com.example.app |
simctl launch -a arm64 com.example.app |
simctl fails | runs launch -a arm64 6F1D…3C90 com.example.app |
simctl list devices |
runs | runs, unchanged |
simctl erase 6F1D…3C90 |
refused | refused, as today |
adb shell getprop |
fails or hits another emulator | runs -s emulator-5554 shell getprop |
adb -s emulator-5554 shell getprop |
runs | runs, unchanged |
adb -s emulator-5556 shell getprop |
runs on that emulator | refused |
adb -e shell getprop |
runs on "the" emulator | refused |
adb devices |
runs | runs, unchanged |
devicectl device info apps (iPhone) |
--device <udid> added |
as today |
devicectl device info apps --device <another udid> |
runs on that device | refused |
devicectl device info apps --device <own ECID or name> |
runs | refused: only the UDID passes |
simctl getenv "iPhone 17" HOME (a device name) |
runs | UDID put in front, simctl fails |
simctl list on an Android lease (or adb devices on an iOS lease) |
runs | refused: the tool serves the other platform |
What could go wrong
- A
simctlverb Simlock does not know (a new Xcode) gets no device filled in, so simctl fails asking for one. The agent can still name its own UDID. Likely only after an Xcode release. - A long command's output is cut to its last part, so an agent misses an early line. Visible as
truncated: true. - Through a gateway whose workers run mixed versions, a command that leaves the device out fails on workers older than this change.
- An HTTP caller that sends
bootedtoday, against the docs' advice, is now refused. So is one that names its own device by a simctl name, a devicectl ECID or name, or adb-t: only the UDID or serial passes. - A command that prints nothing for a long time (a big
simctl install) can outrun an MCP client's tool timeout. Simlock sends a progress notice every 10 s while it runs, which helps only clients that reset their timer on progress.
Words used
- lease's device: the one device a lease holds; on iOS its UDID (
6F1D2A8C-3B4E-4F7A-9C2D-8E5B1A7F3C90), on an emulator its adb serial (emulator-5554), on an Android phone its USB serial. - fill in: Simlock inserts the lease's device into the command where the tool expects it, e.g.
install App.app→install <udid> App.app. - device verb (simctl): a subcommand whose first operand is a device, e.g.
install,launch,io;listandruntimeare not. - refused: answered
PASSTHROUGH_REFUSEDbefore any process starts. - truncated: the tool result kept only the last 32 768 characters of a stream.
Non-goals
- Returning files or binary output to the agent (a screenshot as an image). Follow-up.
- Uploading an app from the agent's machine to the worker.
simlock simctl/simlock adbon a worker scoped to the lease (simlock simctl and simlock adb on a worker run only against the caller's lease, and fill the device in #948 reuses this feature's driver rule).- Writing the instructions text (Add
simlock instructions: the rules an agent must follow, printable and served over MCP #147); MCP: run_on_device runs a command on the session's lease #959 only updates its MCP section to name the new tool. - Cancelling a running command from MCP.
Completion conditions
- Over MCP, an agent with a lease runs
simctl install,simctl launchandadb shellthroughrun_on_devicewithout passing a device id, and gets stdout, stderr and the exit code. POST /v1/leases/{id}/execwith{"tool":"simctl","args":["install","/builds/App.app"]}installs on the lease's device; withbootedit answers422 PASSTHROUGH_REFUSED.run_on_devicewithout a lease answersNO_LEASE.- The website's MCP and remote-agents pages describe both.
Open questions
Decisions
- ADR 0047 —
device.execfills in the lease's device, and refuses another (PR docs(adr): 0047 device.exec fills in the lease's device, and refuses another (#951) #955): the drivers own where the device goes per tool and verb; everydevice.execcaller gets it; no contract change. - ADR 0047 §6:
run_on_deviceruns outside the session's tool-call queue (narrows ADR 0003 §11). - Interview 2026-10-23: one tool result with stdout, stderr, exitCode and a cap, not output streamed in progress messages; a refusal is
isErrorwith{code, message}like the other four tools; a non-zero exit code is a normal result; file-returning output is a follow-up.
Shared values
| Value or state | Rule | Written by | Read by | Source |
|---|---|---|---|---|
| Other-device refusal | Code PASSTHROUGH_REFUSED (existing). Message: Refusing `<tool> <args joined by single spaces>`: it names device <named>, and this lease holds <own>. Leave the device out; Simlock fills it in. <named> is what the caller wrote: a simctl operand as is (booted, 0A1B…); a flag with its value joined by one space (-s emulator-5556, -t 3, --device 0000…, -d 0000…); an inline or fused flag as is (--device=0000…, -t3); a bare flag (-e, -d). <own> is the lease's UDID or serial |
#958 (drivers) | #959 (passes it through unchanged as {code, message}); HTTP, CLI through a gateway |
src/contract/errors.ts:327, src/core/driver.ts:443 |
| Fill-in rule | ADR 0047 §1–2: the table and the two lists (simctl device verbs, adb no-device subcommands), kept as constants in the iOS and Android drivers | #958 | #959 (its tool description says "leave the device out" and points to the verbs that take one; it lists no verbs) | ADR 0047 §1 |
run_on_device result |
{ exitCode: number, stdout: string, stderr: string, truncated: boolean }; each stream keeps its last 32 768 characters (JavaScript string length, per stream, per call); truncated true when either stream lost any |
#959 | agents | — |
run_on_device progress |
Only with a progress token: notifications/progress at once on the first chunk, then at most once per second while output arrives, and 10 s after the last notification (or the start) while none does; message Running <tool> <first arg>, or Running <tool> when args is empty; progress counts up from 1, no total |
#959 | MCP clients | src/mcp/server.ts:100-115 |
NO_LEASE |
MCP-only code, isError. Message: This MCP session holds no lease. Call lease_simulator first. |
#959 | agents | src/mcp/session.ts:101 (toMcpErrorResult shape) |
Behaviours
| Behaviour (invoker, action, what it sees) | Seam | Cases | Task |
|---|---|---|---|
A device.exec caller sends a simctl device verb without a device and the command runs with the lease's UDID in place |
iOS driver passthrough("simctl", simulator, args, {hasTerminal:false}) |
install /b/App.app; launch --console com.x; launch -a arm64 com.x; spawn -s launchctl list → spawn -s <udid> launchctl list; io screenshot /tmp/s.png; own UDID upper or lower case → unchanged; list devices → unchanged; runtime list → unchanged |
#958 |
A device.exec caller's simctl command names another device and is refused with the shared message |
same | another UUID; booted; launch --console booted com.x; simctl on an Android lease → platform refusal |
#958 |
A device.exec caller sends an emulator adb command without a device and it runs with -s <serial> first |
Android driver passthrough("adb", emulator, args, {hasTerminal:false}) |
shell getprop; install app.apk; reverse --list; -s <own> → unchanged; devices, version, --version → unchanged; empty address → unchanged |
#958 |
A device.exec caller's emulator adb command names another device and is refused with the shared message |
same | -s emulator-5556; -e; -d; -t 3; -t3; adb on an iOS lease → platform refusal |
#958 |
A device.exec caller's devicectl command names another device and is refused with the shared message; its own is left alone |
devicectlExecCommand via iOS driver passthrough("devicectl", phone, …) |
--device <other>; -d <other>; --device=<other>; --device <own> in either letter case → unchanged |
#958 |
An agent with a lease calls run_on_device and gets the command's stdout, stderr and exit code |
MCP client against createMcpServer with a fake client |
exit 0 with stdout; exit 3 with stderr (not isError); stdin passed through; the call names the session's lease id |
#959 |
An agent's command writes more than the cap and gets the last 32 768 characters of that stream with truncated: true |
same | stdout over; stderr over; exactly 32 768 → truncated: false |
#959 |
An agent without a lease calls run_on_device and gets NO_LEASE |
same | never leased; after release_simulator; after a lease-lost push |
#959 |
A daemon error comes back as isError {code, message} |
same | PASSTHROUGH_REFUSED; UNKNOWN_PASSTHROUGH_TOOL; EXEC_TIMEOUT |
#959 |
| 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 | same (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 with and without args; no token → 0 |
#959 |
An agent calls lease_status while run_on_device is still running and gets its answer before the command ends |
same | — | #959 |
An agent lists the tools and sees five, with run_on_device taking tool, args, stdin and no leaseId or requesterId, and the description #959 gives |
same (extends src/mcp/server.test.ts:19) |
— | #959 |
Tasks
flowchart LR
ADR[ADR 0047, PR #955] --> T1[#958: drivers fill in the device on device.exec]
T1 --> T2[#959: MCP run_on_device]
- device.exec fills in the lease's device and refuses another #958
device.execfills in the lease's device and refuses another — adevice.execcaller leaves the device out. Risk: a command lands on the wrong spot in the arguments and simctl or adb fails. - MCP: run_on_device runs a command on the session's lease #959 MCP
run_on_device— an agent on MCP runs device commands without a shell. Risk: output cut too short, or a call that waits behind another.
Completion conditions: the first and third → #959; the second → #958; the fourth → #958 (remote-agents page) and #959 (MCP pages).
Open work in the same area:
- simlock simctl and simlock adb on a worker run only against the caller's lease, and fill the device in #948 (
simlock simctl/adbon a worker scoped to the lease): reuses ADR 0047's driver rule by passing a device todriver.passthrough. Should depend on device.exec fills in the lease's device and refuses another #958; not edited here (another feature's body). - Add
simlock instructions: the rules an agent must follow, printable and served over MCP #147 (instructions text, re-specified in a parallel session): its body changescreateMcpServerinsrc/mcp/server.ts, as MCP: run_on_device runs a command on the session's lease #959 does, so MCP: run_on_device runs a command on the session's lease #959 Depends on Addsimlock instructions: the rules an agent must follow, printable and served over MCP #147. MCP: run_on_device runs a command on the session's lease #959 updates the MCP section of Addsimlock instructions: the rules an agent must follow, printable and served over MCP #147's text to namerun_on_device. - simlock run holds a lease around one command, and a detached lease follows the agent process #944 (
simlock run): separate; no shared code. - PR docs(adr): 0045 a device's screen streams to operators, not a lease's (#906) #908 (ADR 0045) and PR docs(adr): 0046 a standalone worker serves agent-device by default with a private profile #954 (ADR 0046, agent-device on the same Mac works with no operator step #952): ADR numbering only; this feature takes 0047.
- Add
simlock instructions: the rules an agent must follow, printable and served over MCP #147 also changes the driver refusal code device.exec fills in the lease's device and refuses another #958 changes (src/drivers/ios/index.ts:134-138,#assertProxyable;src/drivers/android/index.ts:289), so device.exec fills in the lease's device and refuses another #958 Depends on Addsimlock instructions: the rules an agent must follow, printable and served over MCP #147 too. - PR fix(http): an Android lease over HTTP names its adb serial as address #953 (Android lease over HTTP names its adb serial as address, ready): rewrites the serial-lookup bullet of
remote-agents.mdxthat device.exec fills in the lease's device and refuses another #958 removes. device.exec fills in the lease's device and refuses another #958 Depends on fix(http): an Android lease over HTTP names its adb serial as address #953. - PR fix(daemon): exec children get a minimal environment; docs say what an agent token can do #891 (exec children get a minimal environment, draft): edits
#deviceExecand the sameremote-agents.mdxsection. device.exec fills in the lease's device and refuses another #958 changes only a comment in#deviceExec; no Depends on: whichever merges second takes the other's text when it rebases, and fix(daemon): exec children get a minimal environment; docs say what an agent token can do #891 is a draft with no ready task.
Written by an agent.
Activity
docs(adr): renumber to 0047; 0046 is taken by #954 (#951)
docs(adr): 0047 lists the verbs, the refusal forms, and the MCP queue…
V3RON commentedon Oct 23, 2026
Spec updated: Who it is for, Outcome, How it works, Examples, Decisions (ADR 0047, PR #955), Shared values, Behaviours and Tasks (#958, #959) added.
Written by an agent.
docs(adr): 0047 fixes the adb no-device list and two citations (#951)
docs(adr): accept 0047 (#951)
docs(adr): 0047 lists bootstatus and names the gateway CLI caller ref…
V3RON commentedon Oct 23, 2026
Delivery run
Merged: none
Parked: none
Waiting: #958 — on #147; #959 — on #958 and #147. ADR PR #955 passed review (2 rounds) and waits at the bottom of the stack.
Written by an agent.
docs(adr): 0047 device.exec fills in the lease's device, and refuses …
feat(mcp): run_on_device runs a command on the session's lease (#974)
github-actions commentedon Oct 24, 2026
All 2 sub-issues are closed. If the completion conditions hold on
main, close this feature and flip its ADRs to Accepted.github-actions commentedon Oct 24, 2026
All 2 sub-issues are closed. If the completion conditions hold on
main, close this feature and flip its ADRs to Accepted.V3RON commentedon Oct 24, 2026
Delivery run
Merged: #955 (ADR 0047), #972 (#958), #974 (#959), as one stack into main (a3f6d8f)
Parked: none
Waiting: none
Decided: #958 — three adb cases with no test → added from ADR 0047 to its Tests lines:
forward --listandreconnect offlineget no-s, and-t <own serial>is refused (pending the maintainer)Asleep: 381 min
Written by an agent.