Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs/internal/flows/device-exec.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <id>` 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;
Expand Down
2 changes: 1 addition & 1 deletion e2e/mcp-session.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"] },
Expand Down
8 changes: 7 additions & 1 deletion src/instructions/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Record<(typeof HEADINGS)[number], readonly string[]>> = {
"## Rules for every agent": [
Expand Down
5 changes: 3 additions & 2 deletions src/instructions/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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/";

Expand Down
8 changes: 7 additions & 1 deletion src/mcp/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
13 changes: 13 additions & 0 deletions src/mcp/contracts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,10 +84,23 @@ 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<typeof leaseSimulatorInputSchema>;
export type LeaseSimulatorOutput = z.infer<typeof leaseSimulatorOutputSchema>;
export type ListDevicesInput = z.infer<typeof listDevicesInputSchema>;
export type ListDevicesOutput = z.infer<typeof listDevicesOutputSchema>;
export type ReleaseSimulatorInput = z.infer<typeof releaseSimulatorInputSchema>;
export type ReleaseSimulatorOutput = z.infer<typeof releaseSimulatorOutputSchema>;
export type LeaseStatusOutput = z.infer<typeof leaseStatusOutputSchema>;
export type RunOnDeviceInput = z.infer<typeof runOnDeviceInputSchema>;
4 changes: 2 additions & 2 deletions src/mcp/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<SimlockClient>;
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. */
Expand Down Expand Up @@ -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;
Expand Down
Loading
Loading