Skip to content

MCP: run_on_device runs a command on the session's lease #959

Closed

Description

@V3RON

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
Loading

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]
Loading
  • src/mcp/contracts.ts: runOnDeviceInputSchema = device.exec input minus leaseId, requesterId; output schema per Shared values.
  • src/mcp/session.ts: runOnDevice(input, onChunk) — reads #heldLeaseId, throws McpSessionError("NO_LEASE", …) when unset, calls client.exec through #clientForUse(), not through #mutate.
  • src/mcp/server.ts: the tool, the cap, the progress rule (feature Shared values, "run_on_device progress"); its timers stop when the call ends. createMcpServer takes the clock as a second argument (createMcpServer(session, { clock })), and src/mcp/main.ts:103 passes the session's clock (F15); the progress timers use it.
  • src/mcp/test-support.ts: the fake's exec takes and calls options.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 names run_on_device and no longer says the tools lease and release only (F16), keeping Add simlock 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. A run_on_device started after the release gets NO_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 fails DAEMON_CONNECTION_LOST or SESSION_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_device does 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.timeoutMs and 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_device and gets the command's stdout, stderr and exit code: exit 0 with stdout; exit 3 with stderr (not isError); stdin passed 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_device and gets NO_LEASE: never leased; after release_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, and Running simctl for empty args; no token → 0.
  • An agent calls lease_status while run_on_device is still running and gets its answer before the command ends.
  • An agent lists the tools and sees five, with run_on_device taking tool, args, stdin and no leaseId or requesterId, and its description the text in In short (extends src/mcp/server.test.ts:19).

Done when

  • pnpm check is green (toolchain.md).
  • The seven Tests lines pass in pnpm test.
  • connect-your-agent.mdx lists five tools, describes run_on_device's result and NO_LEASE, and no longer says the MCP tools do not run commands; reference/cli.mdx and introduction/index.mdx name five tools; src/mcp/AGENTS.md describes the tool and that it does not queue; the instructions text's MCP section names run_on_device.

Out of scope

Depends on

Written by an agent.

Activity

  1. added
    task:draftScope written; technical spec or its spec check missing.
    on Oct 23, 2026
  2. added
    task:readySpec checked. An agent may implement it once its Depends on allow.
    and removed
    task:draftScope written; technical spec or its spec check missing.
    on Oct 23, 2026
  3. self-assigned this
    on Oct 24, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

task:readySpec checked. An agent may implement it once its Depends on allow.

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions