Skip to content

MCP: a run_on_device tool runs simctl, adb or devicectl against the session's lease #951

Closed
2 of 2 issues completed

Description

@V3RON

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.exec caller may leave the device out of simctl, adb and devicectl commands; 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
Loading

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 simctl verb 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 booted today, 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; list and runtime are not.
  • refused: answered PASSTHROUGH_REFUSED before any process starts.
  • truncated: the tool result kept only the last 32 768 characters of a stream.

Non-goals

Completion conditions

  • Over MCP, an agent with a lease runs simctl install, simctl launch and adb shell through run_on_device without passing a device id, and gets stdout, stderr and the exit code.
  • POST /v1/leases/{id}/exec with {"tool":"simctl","args":["install","/builds/App.app"]} installs on the lease's device; with booted it answers 422 PASSTHROUGH_REFUSED.
  • run_on_device without a lease answers NO_LEASE.
  • The website's MCP and remote-agents pages describe both.

Open questions

Decisions

  • ADR 0047 — device.exec fills 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; every device.exec caller gets it; no contract change.
  • ADR 0047 §6: run_on_device runs 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 isError with {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]
Loading

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:

Written by an agent.

Activity

  1. added
    feature:specSpec in progress, business and technical together.
    on Oct 23, 2026
  2. V3RON commented on Oct 23, 2026

    @V3RON
    ContributorAuthor

    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.

  3. added
    feature:plannedSplit into tasks. Never picked up itself.
    and removed
    feature:specSpec in progress, business and technical together.
    on Oct 23, 2026
  4. V3RON commented on Oct 23, 2026

    @V3RON
    ContributorAuthor

    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.

  5. github-actions commented on Oct 24, 2026

    @github-actions

    All 2 sub-issues are closed. If the completion conditions hold on main, close this feature and flip its ADRs to Accepted.

  6. github-actions commented on Oct 24, 2026

    @github-actions

    All 2 sub-issues are closed. If the completion conditions hold on main, close this feature and flip its ADRs to Accepted.

  7. V3RON commented on Oct 24, 2026

    @V3RON
    ContributorAuthor

    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 --list and reconnect offline get no -s, and -t <own serial> is refused (pending the maintainer)
    Asleep: 381 min

    Written by an agent.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    feature:plannedSplit into tasks. Never picked up itself.

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions