@shipvo/mcp 0.0.7 → 0.0.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -10,12 +10,53 @@ Lets AI agents (Claude, Cursor, Claude Code, PiPilot…) **use a Shipvo app with
10
10
 
11
11
  Start the simulator first (`shipvo sim` in the app). The server asks the simulator which app it serves, so `open_app("<your app id>")` works; set `PN_APPS` (JSON) to override. If the simulator cannot be reached, only the sample app opens. The hosted version needs no local setup: `{ "url": "https://<host>/mcp/<key>" }`.
12
12
 
13
- Tools: `list_apps`, `open_app`, `screen`, `tap`, `type`, `scroll`, `back`, `screenshot`, `set_device`, `push_notification`, `notifications`, `open_notification`, `device_log`, `end_session`, `owner_describe`, `owner_command`.
13
+ Each `tap`, `type`, `scroll` and `back` result starts with a line `Change: none | content changed | new screen | scrolled`, then the screen. `type` also adds `Value of <ref>: "..."`. Pass `compact: true` to get only the added (`+`) and removed (`-`) lines when the change is `content changed`; refs match the full screen. If the server has no earlier screen for the session (for example after a reconnect), the first line is `Change: unknown`. The `content changed` / `new screen` split is a heuristic (under half the lines kept means a new screen), so on a short screen one change can read as `new screen`.
14
+
15
+ `tap`, `type`, `scroll` and `back` also take optional fields to wait for the effect instead of sleeping:
16
+
17
+ - `wait_for`: `{ ref }`, `{ text }` or `{ role, name }` that must appear on the screen, or add `gone: true` to wait for it to disappear. `text` and `name` match as substrings.
18
+ - `settle: true`: wait until the screen stops changing (two reads 100 ms apart that are equal, ignoring ref numbers).
19
+ - `timeout_ms`: the most time `wait_for` and `settle` may take together (default 2000, at most 30000). One read of the simulator can itself take up to the time limit below, so this is not a hard cap on the call.
20
+
21
+ Refs are renumbered between screens, so with `gone: true` prefer `text` or `role` + `name` over `ref`.
22
+
23
+ The result gets a line `Waited: matched | timed out after <ms> ms` and `structuredContent.waited = { matched, ms }`. On a timeout the action still happened: the result is the latest screen, it is not an error, and the action's own outcome (such as `delivered`) is unchanged. Without these fields the result is the same as before.
24
+
25
+ **Time limit.** Every call to the simulator stops after 30 s; set `SHIPVO_SIM_TIMEOUT_MS` to change it. A call that gets no answer returns an error result (`isError: true`, code `sim_timeout`, also in `structuredContent`). The action may still have run and the session may still be alive, so call `screen` to see where it is. Timed-out actions are not retried.
26
+
27
+ **Error codes.** A failed call has `isError: true` and `structuredContent: { ok: false, code, error }`. Some failures from other causes (for example a read-only key) have `code: null`; read `error`.
28
+
29
+ | Code | Meaning | What the agent should do |
30
+ | --- | --- | --- |
31
+ | `no_session` | The simulator has no session with that id (it expired or was never opened). | Call `list_sessions`, or `open_app` again. |
32
+ | `stale_ref` | The ref is not on the current screen, or the screen changed since it was read. | Call `screen` and use a ref from the new tree. |
33
+ | `unknown_app` | The app id is not one this server or key can open. | Call `list_apps` and use an id from it. |
34
+ | `sim_timeout` | The simulator did not answer in time. The action may have run. | Call `screen` to see where the app is. Do not repeat the action blindly. |
35
+ | `needs_confirm` | An owner command that cannot be undone did nothing and returned a plan. | Show the plan to the owner, then call again with `confirm: true`. |
36
+ | `bad_url` | `open_url` got a URL the app cannot take. | Fix the URL and call again. |
37
+ | `no_url_listener` | The app is not listening for URLs. | Use the app's screens instead. |
38
+ | `mock_invalid` | A network mock rule is not valid. | Fix the rule. |
39
+
40
+ Tools: `list_apps`, `open_app`, `screen`, `tap`, `type`, `scroll`, `back`, `screenshot`, `set_device`, `push_notification`, `notifications`, `open_notification`, `device_log`, `export_test`, `end_session`, `owner_describe`, `owner_command`.
41
+
42
+ `export_test { session }` is read-only. It turns the taps, typing, scrolls, `back` and `set_device` calls recorded in a session into a `@shipvo/test` script and returns the source. Targets are named by visible text; an ambiguous target becomes a `// TODO` comment, and a password-like field uses `process.env.SHIPVO_TEST_<LABEL>` so the typed value is never in the output. Only sessions opened through this server are recorded; an unknown session returns `no_session`. Steps are dropped when the session ends. `payment.pin` is removed from recorded `set_device` calls. Only labels and roles that look like a password, passcode or PIN are treated as secret; other secret-like fields (card number, token) are exported as typed. `type` with `enter: true` and `scroll` with a `ref` are recorded without the Enter or the ref, so a replay can differ from the session.
43
+
44
+ **Owner commands that cannot be undone need `confirm: true`.** `owner_command` takes an optional `confirm`. `publish`, price changes, offers, deletes and API keys first return `{ code: "needs_confirm", plan }` and do nothing; call again with the same `args` and `confirm: true` to run. `agentPolicy` (`ask` default, `deny`, `allow`) and `agentDeny` in `shipvo.json` (read from `PN_APP_DIR` or the current folder) can only tighten the control plane's rules. Details: `docs/agents.md`.
14
45
 
15
46
  Native features work with no phone: the simulated device (see `@shipvo/device`) shows permission dialogs, the camera, the fingerprint prompt, payment prompts, notification banners and the share sheet as real dialogs in the tree; `set_device` decides what the phone has (location, a barcode in view, whether the fingerprint matches, whether a payment is approved, network off).
16
47
 
17
48
  The simulator shows only what a phone would: a horizontal rail lists the visible items, so agents swipe it (`scroll right` with the rail's ref) exactly as on a device. Measured against a real Android emulator and iPhone simulator, see `docs/parity-results.md`.
18
49
 
50
+ ## Did the action land?
51
+
52
+ `tap`, `type`, `scroll` and `back` return `structuredContent` next to the usual text:
53
+
54
+ - `delivery`: `"delivered"` or `"not_delivered"`.
55
+ - On `not_delivered` (the result is also `isError`): `reason` is `stale_ref`, `not_visible`, `disabled` or `covered`, and `safe_to_retry` is `true` because nothing reached the app. Take a new `screen`, then retry.
56
+ - `type` also returns `value`, the text the field holds after typing.
57
+
58
+ `code`, `error` and the text result are unchanged. Other failures (for example `sim_timeout`) carry no `delivery` field: the action may have run, so call `screen` first.
59
+
19
60
  ## Remote (Streamable HTTP)
20
61
 
21
62
  Run the server where the simulator runs and put it behind HTTPS (a Cloudflare Tunnel is enough):
@@ -30,4 +71,12 @@ Clients connect with a URL instead of a command:
30
71
  { "mcpServers": { "shipvo": { "url": "https://sim.shipvo.dev/mcp", "headers": { "Authorization": "Bearer key-for-claude" } } } }
31
72
  ```
32
73
 
33
- Each client connection gets its own MCP session; each `open_app` gets its own phone. Requests without a valid key are refused. The hosted version at `sim.shipvo.dev` will issue keys per account with scopes (test, owner, read).
74
+ The HTTP endpoint is stateless. There is no protocol session: each POST is handled on its own, and the `x-shipvo-*` scope headers are read per request, so a scope never carries over to another request. `GET` and `DELETE` on `/mcp` answer 405. Phones are separate: each `open_app` gets its own phone, and you pass its `session` as a tool argument.
75
+
76
+ ### Protocol versions
77
+
78
+ - **2025-era clients** (`initialize`, then calls, with or without `Mcp-Session-Id`): work. `initialize` is answered, no session id is issued, and any `Mcp-Session-Id` a client sends is ignored.
79
+ - **2026-07-28 clients**: `tools/list` and `tools/call` work with no session and no `initialize`. Not done yet: `server/discover`, the `Mcp-Method`/`Mcp-Name` headers, `ttlMs`/`cacheScope` on list results. `tools/list` order is fixed (registration order).
80
+ - This package uses `@modelcontextprotocol/sdk` 1.x. Stdio is unchanged.
81
+
82
+ Requests without a valid key are refused. The hosted version at `sim.shipvo.dev` will issue keys per account with scopes (test, owner, read).
@@ -0,0 +1,4 @@
1
+ export type Change = "none" | "content changed" | "new screen" | "scrolled";
2
+ export declare function classify(prev: string | undefined, next: string, action: string): Change;
3
+ export declare function compactDiff(prev: string, next: string): string;
4
+ export declare function fieldValue(tree: string, ref: string): string | undefined;
package/dist/change.js ADDED
@@ -0,0 +1,51 @@
1
+ const REF = /\s*\[ref=\w+\]/g;
2
+ // A line without its ref, so a re-numbered but identical element counts as the same.
3
+ const norm = (l) => l.replace(REF, "").trim();
4
+ const lines = (t) => t.split("\n").filter((l) => l.trim());
5
+ export function classify(prev, next, action) {
6
+ if (prev === undefined)
7
+ return "new screen";
8
+ const a = lines(prev).map(norm), b = lines(next).map(norm);
9
+ if (a.length === b.length && a.every((l, i) => l === b[i]))
10
+ return "none";
11
+ if (action === "scroll")
12
+ return "scrolled";
13
+ const seen = new Set(a);
14
+ const kept = b.filter((l) => seen.has(l)).length;
15
+ return b.length === 0 || kept / b.length < 0.5 ? "new screen" : "content changed";
16
+ }
17
+ // Added (+) and removed (-) lines. Added lines keep their refs from the new tree, so refs stay valid.
18
+ export function compactDiff(prev, next) {
19
+ const count = (ls) => { const m = new Map(); for (const l of ls)
20
+ m.set(norm(l), (m.get(norm(l)) ?? 0) + 1); return m; };
21
+ const pl = lines(prev), nl = lines(next);
22
+ const pc = count(pl), nc = count(nl);
23
+ const out = [];
24
+ for (const l of pl) {
25
+ const n = nc.get(norm(l)) ?? 0;
26
+ if (n > 0)
27
+ nc.set(norm(l), n - 1);
28
+ else
29
+ out.push("- " + l.trim());
30
+ }
31
+ for (const l of nl) {
32
+ const n = pc.get(norm(l)) ?? 0;
33
+ if (n > 0)
34
+ pc.set(norm(l), n - 1);
35
+ else
36
+ out.push("+ " + l.trim());
37
+ }
38
+ return out.length ? out.join("\n") : "(same lines, new order)";
39
+ }
40
+ // The value of the field with this ref: the text after its attribute brackets and ":".
41
+ // Handles more attributes after the ref ([ref=e3] [cursor=pointer]: Ana) and YAML-quoted lines.
42
+ export function fieldValue(tree, ref) {
43
+ for (const raw of lines(tree)) {
44
+ const q = /^(\s*- )'(.*)'(:.*)?$/.exec(raw);
45
+ const l = (q ? q[1] + q[2].replace(/''/g, "'") + (q[3] ?? "") : raw).trim();
46
+ const m = new RegExp(`\\[ref=${ref}\\]((?: \\[[^\\]]*\\])*)(?::\\s*(.*))?$`).exec(l);
47
+ if (m)
48
+ return (m[2] ?? "").trim().replace(/^"(.*)"$/, "$1");
49
+ }
50
+ return undefined;
51
+ }
@@ -0,0 +1,15 @@
1
+ export type Step = {
2
+ action: "open" | "tap" | "type" | "scroll" | "back" | "set_device";
3
+ target?: {
4
+ text: string;
5
+ role: string;
6
+ matches: number;
7
+ };
8
+ text?: string;
9
+ fieldLabel?: string;
10
+ passwordLike?: boolean;
11
+ direction?: string;
12
+ device?: object;
13
+ changed?: boolean;
14
+ };
15
+ export declare function exportTest(app: string, steps: Step[]): string;
package/dist/export.js ADDED
@@ -0,0 +1,52 @@
1
+ // JSON.stringify leaves U+2028 and U+2029 raw, but TypeScript treats them as line breaks.
2
+ const q = (s) => JSON.stringify(s).replace(/\u2028/g, "\\u2028").replace(/\u2029/g, "\\u2029");
3
+ // Comment text must stay on one line.
4
+ const oneLine = (s) => s.replace(/[\r\n\u2028\u2029]+/g, " ");
5
+ const envName = (label) => "SHIPVO_TEST_" + (label.replace(/[^A-Za-z0-9]+/g, "_").replace(/^_+|_+$/g, "").toUpperCase() || "FIELD");
6
+ const DIRECTIONS = ["down", "up", "left", "right"];
7
+ function lineFor(s) {
8
+ const t = s.target;
9
+ switch (s.action) {
10
+ case "open": return [];
11
+ case "back": return ["await phone.back();"];
12
+ case "set_device": return [`await phone.device.set(${JSON.stringify(s.device ?? {}).replace(/\u2028/g, "\\u2028").replace(/\u2029/g, "\\u2029")});`];
13
+ case "scroll": {
14
+ const d = s.direction && DIRECTIONS.includes(s.direction) ? s.direction : "down";
15
+ return [`await phone.scroll(${q(d)});`];
16
+ }
17
+ case "tap":
18
+ if (!t)
19
+ return ["// TODO: tap without a target"];
20
+ if (t.matches > 1)
21
+ return [`// TODO: ambiguous target ${oneLine(q(t.text))}`];
22
+ return [`await phone.tap(${q(t.text)});`];
23
+ case "type": {
24
+ const label = s.fieldLabel ?? t?.text;
25
+ if (!label)
26
+ return ["// TODO: type without a field label"];
27
+ if (t && t.matches > 1)
28
+ return [`// TODO: ambiguous target ${oneLine(q(label))}`];
29
+ const value = s.passwordLike ? `process.env.${envName(label)}!` : q(s.text ?? "");
30
+ return [`await phone.type(${q(label)}, ${value});`];
31
+ }
32
+ }
33
+ }
34
+ export function exportTest(app, steps) {
35
+ const out = [
36
+ `import { test } from "@shipvo/test/vitest";`,
37
+ `import { openApp } from "@shipvo/test";`,
38
+ ``,
39
+ `test(${q(`recorded session for ${app}`)}, async () => {`,
40
+ ` const phone = await openApp({ app: ${q(app)} });`,
41
+ ];
42
+ for (const s of steps) {
43
+ const lines = lineFor(s);
44
+ for (const l of lines)
45
+ out.push(" " + l);
46
+ if (s.changed && lines.length && !lines[0].startsWith("//") && s.action !== "open") {
47
+ out.push(" await phone.waitFor(/\\S/); // TODO: name the text that should appear");
48
+ }
49
+ }
50
+ out.push("});", "");
51
+ return out.join("\n");
52
+ }
@@ -0,0 +1,12 @@
1
+ export type Found = {
2
+ text: string;
3
+ role: string;
4
+ matches: number;
5
+ passwordLike: boolean;
6
+ };
7
+ export declare const unquoteLine: (raw: string) => string;
8
+ export declare const parse: (raw: string) => {
9
+ role: string;
10
+ text: string;
11
+ } | undefined;
12
+ export declare function findTarget(tree: string | undefined, ref: string): Found | undefined;
package/dist/record.js ADDED
@@ -0,0 +1,28 @@
1
+ export const unquoteLine = (raw) => {
2
+ const q = /^(\s*- )'(.*)'(:.*)?$/.exec(raw);
3
+ return q ? q[1] + q[2].replace(/''/g, "'") + (q[3] ?? "") : raw;
4
+ };
5
+ export const parse = (raw) => {
6
+ const m = /^\s*- ([\w-]+)(?: ("(?:[^"\\]|\\.)*"))?/.exec(unquoteLine(raw));
7
+ if (!m)
8
+ return undefined;
9
+ let text = "";
10
+ try {
11
+ text = m[2] ? JSON.parse(m[2]) : "";
12
+ }
13
+ catch {
14
+ text = m[2].slice(1, -1);
15
+ }
16
+ return { role: m[1], text };
17
+ };
18
+ export function findTarget(tree, ref) {
19
+ if (!tree)
20
+ return undefined;
21
+ const lines = tree.split("\n");
22
+ const mine = lines.find((l) => unquoteLine(l).includes(`[ref=${ref}]`));
23
+ const t = mine && parse(mine);
24
+ if (!t || !t.text)
25
+ return undefined;
26
+ const matches = lines.filter((l) => parse(l)?.text === t.text).length;
27
+ return { ...t, matches, passwordLike: /pass(word|code)|\bpin\b/i.test(`${t.role} ${t.text}`) };
28
+ }
package/dist/server.js CHANGED
@@ -3,26 +3,33 @@
3
3
  // PN_SIM=http://localhost:4545 PN_CONTROL=https://api.shipvo.dev PN_OWNER_TOKEN=... shipvo-mcp
4
4
  // Remote (Streamable HTTP, what Claude, ChatGPT, Cursor connect to over the network):
5
5
  // shipvo-mcp --http 8787 with PN_API_KEYS="key1,key2" (Authorization: Bearer <key>) → POST/GET/DELETE /mcp
6
- // Tools: list_apps, open_app, screen, tap, type, scroll, back, screenshot, end_session (UI simulator);
6
+ // Tools: list_apps, open_app, screen, tap, type, scroll, back, screenshot, export_test, end_session (UI simulator);
7
7
  // owner_describe, owner_command (the app's live content, prices, orders: owner token required).
8
8
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
9
9
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
10
10
  import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
11
11
  import http from "node:http";
12
- import { randomUUID } from "node:crypto";
13
12
  import { readFileSync } from "node:fs";
13
+ import { execFile } from "node:child_process";
14
+ import { join } from "node:path";
14
15
  import { z } from "zod";
16
+ import { classify, compactDiff, fieldValue } from "./change.js";
17
+ import { exportTest } from "./export.js";
18
+ import { findTarget } from "./record.js";
19
+ import { satisfied, sameTree, wantsMatch } from "./wait.js";
15
20
  const SIM = (process.env.PN_SIM ?? "http://localhost:4545").replace(/\/$/, "");
16
21
  const CONTROL = (process.env.PN_CONTROL ?? "https://api.shipvo.dev").replace(/\/$/, "");
17
22
  const OWNER_TOKEN = process.env.PN_OWNER_TOKEN ?? "";
18
23
  // Apps this server can open. Hosted: the account's apps. Local: the app the simulator serves (it is asked on
19
24
  // first use), or PN_APPS (JSON) to override. Falls back to the sample app when the simulator cannot be reached.
20
25
  const TIDE = { tide: { name: "Tide & Tackle", description: "Fishing-supplies shop in Douala: catalogue, cart, promo codes, delivery slots, cash or mobile money." } };
26
+ // Every call to the simulator has a time limit (default 30 s, SHIPVO_SIM_TIMEOUT_MS to change it).
27
+ const simTimeoutMs = () => { const n = Number(process.env.SHIPVO_SIM_TIMEOUT_MS); return Number.isFinite(n) && n > 0 ? n : 30000; };
21
28
  async function localApps() {
22
29
  if (process.env.PN_APPS)
23
30
  return JSON.parse(process.env.PN_APPS);
24
31
  try {
25
- const r = await fetch(SIM + "/apps", { method: "POST", headers: { "content-type": "application/json" }, body: "{}" });
32
+ const r = await fetch(SIM + "/apps", { method: "POST", headers: { "content-type": "application/json" }, body: "{}", signal: AbortSignal.timeout(simTimeoutMs()) });
26
33
  if (r.ok) {
27
34
  const out = {};
28
35
  for (const a of (await r.json()).apps ?? [])
@@ -34,16 +41,65 @@ async function localApps() {
34
41
  catch { }
35
42
  return TIDE;
36
43
  }
44
+ // A call that gets no answer in time returns ok:false with code sim_timeout (a typed error for this one tool
45
+ // call). It is never retried: the action may have run, so the agent should call `screen` and look.
37
46
  async function sim(path, body) {
38
- const r = await fetch(SIM + path, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body) });
39
- if (!r.ok)
40
- throw new Error(`simulator ${path} -> ${r.status}. Is it running at ${SIM}?`);
41
- return r.json();
47
+ const ms = simTimeoutMs();
48
+ let r;
49
+ try {
50
+ r = await fetch(SIM + path, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body), signal: AbortSignal.timeout(ms) });
51
+ if (!r.ok)
52
+ throw new Error(`simulator ${path} -> ${r.status}. Is it running at ${SIM}?`);
53
+ return (await r.json());
54
+ }
55
+ catch (e) {
56
+ if (e?.name !== "TimeoutError")
57
+ throw e;
58
+ const what = path === "/act" && typeof body.action === "string" ? `action "${body.action}"` : `request ${path}`;
59
+ return { ok: false, code: "sim_timeout", error: `the simulator did not answer ${what} within ${ms} ms. The action may still have run and the session may still be alive: call screen to see where it is.` };
60
+ }
42
61
  }
43
62
  const sessions = new Map();
63
+ // Last tree per session id. Separate from `sessions` so it works for ids this server never opened.
64
+ const trees = new Map();
65
+ // Recorded steps per session, for export_test. Dropped when the session ends.
66
+ const steps = new Map();
67
+ // Tool annotations (MCP hints): clients use them to decide what to run without asking.
68
+ const RO = { readOnlyHint: true, destructiveHint: false };
69
+ const WR = { readOnlyHint: false, destructiveHint: false };
70
+ const DEST = { readOnlyHint: false, destructiveHint: true };
44
71
  const text = (t) => ({ content: [{ type: "text", text: t }] });
45
- const result = (sid, r) => { const s = sessions.get(sid); if (s)
46
- s.steps++; return text(r.ok ? (r.screen ?? "") : `Error${r.code ? ` [${r.code}]` : ""}: ${r.error}${/no session/.test(String(r.error)) ? " (sessions expire with the phone; call open_app again)" : ""}\n\nScreen now:\n${r.screen ?? ""}`); };
72
+ // Action result with a "Change:" line on top. Compares this tree with the session's last one.
73
+ // compact: on "content changed", return only the added/removed lines instead of the full tree.
74
+ const acted = (sid, action, r, o = {}) => {
75
+ if (!r.ok || r.screen == null)
76
+ return result(sid, r);
77
+ const prev = trees.get(sid);
78
+ trees.set(sid, r.screen);
79
+ const change = prev === undefined ? "unknown (no earlier screen to compare)" : classify(prev, r.screen, action);
80
+ if (o.step)
81
+ steps.get(sid)?.push({ ...o.step, changed: prev === undefined ? undefined : change !== "none" });
82
+ const head = [`Change: ${change}`];
83
+ if (o.waited)
84
+ head.push(`Waited: ${o.waited.matched ? "matched" : "timed out"} after ${o.waited.ms} ms`);
85
+ if (action === "type" && o.ref) {
86
+ const v = fieldValue(r.screen, o.ref);
87
+ head.push(v !== undefined ? `Value of ${o.ref}: ${JSON.stringify(v)}` : `Value of ${o.ref}: not shown`);
88
+ }
89
+ const body = o.compact && change === "content changed" && prev !== undefined ? `Changed lines (+ added, - removed; refs as on the full screen):\n${compactDiff(prev, r.screen)}` : r.screen;
90
+ const res = result(sid, { ...r, screen: head.join("\n") + "\n\n" + body });
91
+ const value = action === "type" ? (r.value ?? fieldValue(r.screen, o.ref ?? "")) : undefined;
92
+ return { ...res, structuredContent: { delivery: r.delivery ?? "delivered", ...(value !== undefined ? { value } : {}), ...(o.waited ? { waited: o.waited } : {}) } };
93
+ };
94
+ const result = (sid, r) => {
95
+ const s = sessions.get(sid);
96
+ if (s)
97
+ s.steps++;
98
+ if (r.ok)
99
+ return text(r.screen ?? "");
100
+ const t = `Error${r.code ? ` [${r.code}]` : ""}: ${r.error}${/no session/.test(String(r.error)) ? " (sessions expire with the phone; call open_app again)" : ""}${r.screen ? `\n\nScreen now:\n${r.screen}` : ""}`;
101
+ return { ...text(t), isError: true, structuredContent: { ok: false, code: r.code ?? null, error: r.error ?? null, ...(r.delivery ? { delivery: r.delivery, reason: r.reason, safe_to_retry: r.safe_to_retry === true } : {}) } };
102
+ };
47
103
  // Sessions live in the simulator; this map only tracks step counts. A client may reconnect between calls
48
104
  // (ChatGPT does), so an unknown id is forwarded and the simulator answers if it knows it.
49
105
  const need = (_sid) => { };
@@ -56,7 +112,7 @@ Finish a task by checking the final screen says what you expected (for example "
56
112
  // ctx.app: the app(s) this key opens, comma-separated (the beta app plus any the key owner has registered).
57
113
  const keyApps = ctx.app ? ctx.app.split(",").map((s) => s.trim()).filter(Boolean) : null;
58
114
  const allowed = async (id) => (keyApps ? keyApps.includes(id) : !!(await localApps())[id]);
59
- server.tool("list_apps", "Apps this server can open, with what each one does. Optional query filters by name, description or category (e.g. 'shop', 'douala').", { query: z.string().optional() }, async ({ query }) => {
115
+ server.tool("list_apps", "Apps this server can open, with what each one does. Optional query filters by name, description or category (e.g. 'shop', 'douala').", { query: z.string().optional() }, RO, async ({ query }) => {
60
116
  const APPS = await localApps();
61
117
  const ids = keyApps ?? Object.keys(APPS);
62
118
  const q = (query ?? "").toLowerCase();
@@ -80,55 +136,132 @@ Finish a task by checking the final screen says what you expected (for example "
80
136
  session: z.string().optional().describe("Session name (default: generated). One session is one phone."),
81
137
  seed_cart: z.array(z.object({ product_id: z.string().optional(), id: z.string().optional(), quantity: z.number().int().positive().optional(), qty: z.number().int().positive().optional() }).refine((l) => (l.product_id ?? l.id) && (l.quantity ?? l.qty), "each line needs product_id (or id) and quantity (or qty)")).optional().describe("Start with these items already in the cart (test shortcut). Each line: { product_id, quantity }; { id, qty } also works."),
82
138
  dark: z.boolean().optional().describe("Dark mode"),
83
- }, async ({ app, session, seed_cart, dark }) => {
139
+ }, WR, async ({ app, session, seed_cart, dark }) => {
84
140
  const id = app.split("@")[0];
85
141
  if (!(await allowed(id)))
86
- throw new Error(keyApps ? `this key opens: ${keyApps.join(", ")}` : `unknown app "${id}". Apps: ${Object.keys(await localApps()).join(", ")}`);
142
+ return result("", { ok: false, code: "unknown_app", error: keyApps ? `this key opens: ${keyApps.join(", ")}` : `unknown app "${id}". Apps: ${Object.keys(await localApps()).join(", ")}` });
87
143
  if (ctx.scope === "read")
88
144
  throw new Error("this key is read-only: it cannot open sessions");
89
145
  const sid = session ?? `${id}-${Date.now().toString(36)}`;
90
146
  const r = await sim("/session", { session: sid, app, seed_cart: seed_cart?.map((l) => ({ ...l, product_id: l.product_id ?? l.id, quantity: l.quantity ?? l.qty })), dark });
147
+ if (r.code === "sim_timeout")
148
+ return result(sid, r);
91
149
  sessions.set(sid, { app: id, steps: 0, startedAt: Date.now() });
150
+ steps.set(sid, [{ action: "open" }]);
151
+ if (r.screen != null)
152
+ trees.set(sid, r.screen);
153
+ else
154
+ trees.delete(sid);
92
155
  return text(`session: ${sid}\napp: ${id} release: ${r.release ?? "local"}\n\n${r.screen ?? ""}`);
93
156
  });
94
- server.tool("list_sessions", "Open simulator sessions (phones) on this server: id, app, release and when each started. Use to resume after a reconnect.", {}, async () => {
157
+ server.tool("list_sessions", "Open simulator sessions (phones) on this server: id, app, release and when each started. Use to resume after a reconnect.", {}, RO, async () => {
95
158
  const r = await sim("/sessions", {});
159
+ if (r.code === "sim_timeout")
160
+ return result("", r);
96
161
  const rows = (r.sessions ?? []).filter((s) => !keyApps || keyApps.includes(s.app));
97
162
  return text(rows.length ? rows.map((s) => `${s.session}: ${s.app} release ${s.release}${s.started_at ? ` since ${s.started_at}` : ""}`).join("\n") : "no open sessions");
98
163
  });
99
164
  const sid = z.string().describe("Session from open_app");
165
+ const compact = z.boolean().optional().describe("When the change is 'content changed', return only the added/removed lines, not the whole screen");
166
+ const CH = " The result starts with 'Change: none | content changed | new screen | scrolled'.";
167
+ const waitFor = z.object({ ref: z.string().regex(/^e\d+$/).optional(), text: z.string().optional(), role: z.string().optional(), name: z.string().optional(), gone: z.boolean().optional() })
168
+ .refine((w) => wantsMatch(w), "give ref, text, or role and name").optional()
169
+ .describe("After the action, wait until this appears (a ref, a text, or role+name), or disappears with gone: true. The result says Waited: matched or timed out.");
170
+ const timeoutMs = z.number().int().min(0).max(30000).optional().describe("How long wait_for and settle may take, in ms (default 2000)");
171
+ const settle = z.boolean().optional().describe("Wait until the screen stops changing (two equal reads 100 ms apart), up to timeout_ms");
172
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
173
+ // Poll the screen after an action. Returns the latest result and what happened. On timeout the action result is kept as is.
174
+ async function waiting(session, r, w) {
175
+ if (!r.ok || r.screen == null || (!w.wait_for && !w.settle))
176
+ return { r };
177
+ const t0 = Date.now(), limit = w.timeout_ms ?? 2000, left = () => limit - (Date.now() - t0);
178
+ let cur = r, matched = true;
179
+ const read = async () => { const n = await sim("/act", { session, action: "screen" }); if (n.ok && n.screen != null)
180
+ cur = { ...cur, screen: n.screen }; return n.ok && n.screen != null; };
181
+ if (w.wait_for) {
182
+ matched = satisfied(cur.screen, w.wait_for);
183
+ while (!matched && left() > 0) {
184
+ await sleep(Math.min(100, left()));
185
+ if (await read())
186
+ matched = satisfied(cur.screen, w.wait_for);
187
+ }
188
+ }
189
+ if (w.settle && matched) {
190
+ matched = false;
191
+ while (left() > 0) {
192
+ await sleep(Math.min(100, left()));
193
+ const before = cur.screen;
194
+ if (await read() && sameTree(before, cur.screen)) {
195
+ matched = true;
196
+ break;
197
+ }
198
+ }
199
+ }
200
+ return { r: cur, waited: { matched, ms: Date.now() - t0 } };
201
+ }
100
202
  const ref = z.string().regex(/^e\d+$/).describe("Element ref from the screen, e.g. e42");
101
- server.tool("screen", "The current screen as an accessibility tree with refs.", { session: sid }, async ({ session }) => { need(session); return result(session, await sim("/act", { session, action: "screen" })); });
102
- server.tool("tap", "Tap a button, link or choice by ref. Returns the new screen.", { session: sid, ref }, async ({ session, ref }) => { need(session); return result(session, await sim("/act", { session, action: "tap", ref })); });
103
- server.tool("type", "Type into a text field by ref (replaces its content). Returns the new screen.", { session: sid, ref, text: z.string(), enter: z.boolean().optional().describe("Press Enter afterwards") }, async ({ session, ref, text: t, enter }) => { need(session); return result(session, await sim("/act", { session, action: "type", ref, text: t, enter })); });
104
- server.tool("scroll", "Scroll the page (down/up) or a horizontal rail (right/left, give the rail's ref). Returns the new screen.", { session: sid, direction: z.enum(["down", "up", "left", "right"]), ref: ref.optional().describe("Scroll inside this element (a rail or list); omit for the page") }, async ({ session, direction, ref }) => { need(session); return result(session, await sim("/act", { session, action: "scroll", direction, ref })); });
105
- server.tool("back", "Go back one screen.", { session: sid }, async ({ session }) => { need(session); return result(session, await sim("/act", { session, action: "back" })); });
106
- server.tool("screenshot", "A PNG of the phone screen (for checking layout, colours, images). The tree from `screen` is usually enough to act.", { session: sid }, async ({ session }) => {
203
+ server.tool("screen", "The current screen as an accessibility tree with refs.", { session: sid }, RO, async ({ session }) => { need(session); const r = await sim("/act", { session, action: "screen" }); if (r.ok && r.screen != null)
204
+ trees.set(session, r.screen); return result(session, r); });
205
+ server.tool("tap", "Tap a button, link or choice by ref. Returns the new screen." + CH, { session: sid, ref, compact, wait_for: waitFor, timeout_ms: timeoutMs, settle }, WR, async ({ session, ref, compact, wait_for, timeout_ms, settle }) => { need(session); const w = await waiting(session, await sim("/act", { session, action: "tap", ref }), { wait_for, timeout_ms, settle }); return acted(session, "tap", w.r, { waited: w.waited, compact, step: { action: "tap", target: findTarget(trees.get(session), ref) } }); });
206
+ server.tool("type", "Type into a text field by ref (replaces its content). Returns the new screen." + CH + " Also gives the field's value after typing.", { session: sid, ref, text: z.string(), enter: z.boolean().optional().describe("Press Enter afterwards"), compact, wait_for: waitFor, timeout_ms: timeoutMs, settle }, WR, async ({ session, ref, text: t, enter, compact, wait_for, timeout_ms, settle }) => { need(session); const tgt = findTarget(trees.get(session), ref); const w = await waiting(session, await sim("/act", { session, action: "type", ref, text: t, enter }), { wait_for, timeout_ms, settle }); return acted(session, "type", w.r, { waited: w.waited, compact, ref, step: { action: "type", target: tgt, fieldLabel: tgt?.text, passwordLike: tgt?.passwordLike, text: t } }); });
207
+ server.tool("scroll", "Scroll the page (down/up) or a horizontal rail (right/left, give the rail's ref). Returns the new screen." + CH, { session: sid, direction: z.enum(["down", "up", "left", "right"]), ref: ref.optional().describe("Scroll inside this element (a rail or list); omit for the page"), compact, wait_for: waitFor, timeout_ms: timeoutMs, settle }, WR, async ({ session, direction, ref, compact, wait_for, timeout_ms, settle }) => { need(session); const w = await waiting(session, await sim("/act", { session, action: "scroll", direction, ref }), { wait_for, timeout_ms, settle }); return acted(session, "scroll", w.r, { waited: w.waited, compact, step: { action: "scroll", direction } }); });
208
+ server.tool("back", "Go back one screen." + CH, { session: sid, compact, wait_for: waitFor, timeout_ms: timeoutMs, settle }, WR, async ({ session, compact, wait_for, timeout_ms, settle }) => { need(session); const w = await waiting(session, await sim("/act", { session, action: "back" }), { wait_for, timeout_ms, settle }); return acted(session, "back", w.r, { waited: w.waited, compact, step: { action: "back" } }); });
209
+ server.tool("screenshot", "A PNG of the phone screen (for checking layout, colours, images). The tree from `screen` is usually enough to act.", { session: sid }, RO, async ({ session }) => {
107
210
  need(session);
108
211
  const r = await sim("/act", { session, action: "screenshot" });
109
- if (!r.ok || !r.path)
110
- throw new Error(r.error ?? "no screenshot");
212
+ if (!r.ok)
213
+ return result(session, r);
214
+ if (!r.path)
215
+ throw new Error("no screenshot");
111
216
  return { content: [{ type: "image", data: readFileSync(r.path).toString("base64"), mimeType: "image/png" }, { type: "text", text: r.screen ?? "" }] };
112
217
  });
113
218
  // The simulated device: what the phone has, and what happens to it.
114
- server.tool("set_device", "Change what the simulated phone has: location, a barcode in front of the camera, permission states, whether the fingerprint will match, whether a payment will be approved, network on/off. Takes effect for the next action.", {
219
+ server.tool("set_device", "Change what the simulated phone has: location, the address book (contacts), calendar events, files for the file picker, a barcode in front of the camera, permission states, whether the fingerprint will match, whether a payment will be approved, network on/off. Takes effect for the next action.", {
115
220
  session: sid,
116
221
  location: z.object({ latitude: z.number(), longitude: z.number(), accuracy: z.number().optional() }).optional(),
117
222
  camera: z.object({ barcode: z.object({ type: z.string(), data: z.string() }).nullable().optional(), delayMs: z.number().optional() }).optional(),
118
- permissions: z.object({ camera: z.enum(["granted", "denied", "undetermined"]).optional(), location: z.enum(["granted", "denied", "undetermined"]).optional(), notifications: z.enum(["granted", "denied", "undetermined"]).optional() }).optional(),
223
+ permissions: z.object({ camera: z.enum(["granted", "denied", "undetermined"]).optional(), location: z.enum(["granted", "denied", "undetermined"]).optional(), notifications: z.enum(["granted", "denied", "undetermined"]).optional(), contacts: z.enum(["granted", "denied", "undetermined"]).optional(), calendar: z.enum(["granted", "denied", "undetermined"]).optional() }).optional(),
224
+ contacts: z.array(z.object({ id: z.string(), name: z.string(), phoneNumbers: z.array(z.string()).optional(), emails: z.array(z.string()).optional() })).optional().describe("Replace the address book the app can read (needs contacts permission)"),
225
+ calendar: z.array(z.object({ id: z.string(), title: z.string(), start: z.string().describe("ISO 8601"), end: z.string().describe("ISO 8601"), allDay: z.boolean().optional(), location: z.string().optional(), notes: z.string().optional() })).optional().describe("Replace the calendar events the app can read (needs calendar permission)"),
226
+ files: z.array(z.object({ name: z.string(), mimeType: z.string(), content: z.string().describe("The file's text") })).optional().describe("Replace the files the file picker offers"),
119
227
  biometrics: z.object({ enrolled: z.boolean().optional(), nextResult: z.enum(["success", "fail", "cancel"]).optional() }).optional(),
120
228
  payment: z.object({ nextResult: z.enum(["approve", "decline", "timeout", "insufficient_funds"]).optional(), pin: z.string().optional() }).optional(),
121
229
  online: z.boolean().optional(),
122
- }, async ({ session, ...device }) => { need(session); return result(session, await sim("/act", { session, action: "set_device", device })); });
123
- server.tool("push_notification", "Deliver a push notification to the simulated phone; it appears as a banner the agent can tap.", { session: sid, title: z.string(), body: z.string(), data: z.record(z.unknown()).optional() }, async ({ session, title, body, data }) => { need(session); return result(session, await sim("/act", { session, action: "push_notification", title, body, data })); });
124
- server.tool("notifications", "The notification shade: notifications delivered to the simulated phone in this session (their banners vanish after 5 s, like a heads-up on a real phone).", { session: sid }, async ({ session }) => { need(session); return text(JSON.stringify((await sim("/act", { session, action: "notifications" })).notifications ?? [], null, 2)); });
125
- server.tool("open_notification", "Tap a notification from the shade (newest if no id is given): the app handles it exactly as if its banner was tapped.", { session: sid, id: z.string().optional() }, async ({ session, id }) => { need(session); return result(session, await sim("/act", { session, action: "open_notification", id })); });
126
- server.tool("device_log", "What the app did with the device in this session: permission prompts and answers, location reads, scans, fingerprint checks, payments, notifications, shares.", { session: sid }, async ({ session }) => { need(session); const r = await sim("/act", { session, action: "device_log" }); return text(JSON.stringify({ note: "Everything here is simulated: no real biometric check or money movement took place.", capabilities: r.capabilities, log: r.log ?? [] }, null, 2)); });
127
- server.tool("device_reset", "Put the simulated phone back to a known state (permissions undetermined, default location, nothing in front of the camera, log cleared), optionally with a new profile.", { session: sid, platform: z.enum(["android", "ios"]).optional(), api: z.number().optional() }, async ({ session, ...device }) => { need(session); return result(session, await sim("/act", { session, action: "device_reset", device })); });
128
- server.tool("end_session", "Close the session and get a report: steps taken, and the orders the app sent to its backend during the session.", { session: sid }, async ({ session }) => {
230
+ }, WR, async ({ session, ...device }) => { need(session); const r = await sim("/act", { session, action: "set_device", device }); if (r.ok)
231
+ steps.get(session)?.push({ action: "set_device", device: device.payment?.pin === undefined ? device : { ...device, payment: { ...device.payment, pin: undefined } } }); return result(session, r); });
232
+ server.tool("push_notification", "Deliver a push notification to the simulated phone; it appears as a banner the agent can tap. The app receives it only if notification permission is granted; otherwise nothing is delivered.", { session: sid, title: z.string(), body: z.string(), data: z.record(z.unknown()).optional() }, WR, async ({ session, title, body, data }) => {
233
+ need(session);
234
+ const r = await sim("/act", { session, action: "push_notification", title, body, data });
235
+ // The simulator answers ok when permission blocks the push, so say so: otherwise it looks like a delivery.
236
+ if (r.ok && r.delivered === false)
237
+ return result(session, { ...r, screen: `Not delivered: notification permission is not granted (use set_device permissions.notifications).\n\n${r.screen ?? ""}` });
238
+ return result(session, r);
239
+ });
240
+ server.tool("notifications", "The notification shade: notifications delivered to the simulated phone in this session (their banners vanish after 5 s, like a heads-up on a real phone).", { session: sid }, RO, async ({ session }) => { need(session); const r = await sim("/act", { session, action: "notifications" }); if (!r.ok)
241
+ return result(session, r); return text(JSON.stringify(r.notifications ?? [], null, 2)); });
242
+ server.tool("open_notification", "Tap a notification from the shade (newest if no id is given): the app handles it exactly as if its banner was tapped.", { session: sid, id: z.string().optional() }, WR, async ({ session, id }) => { need(session); return result(session, await sim("/act", { session, action: "open_notification", id })); });
243
+ server.tool("open_url", "Open a deep link (such as myapp://item/3) in the running app, as a push tap, auth callback or share link would. Fails with no_url_listener if the app does not listen for URLs.", { session: sid, url: z.string().describe("The URL to deliver, e.g. myapp://item/3") }, { title: "Open a deep link", readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false }, async ({ session, url }) => { need(session); return result(session, await sim("/act", { session, action: "open_url", url })); });
244
+ server.tool("device_log", "What the app did with the device in this session: permission prompts and answers, location reads, scans, fingerprint checks, payments, notifications, shares.", { session: sid }, RO, async ({ session }) => { need(session); const r = await sim("/act", { session, action: "device_log" }); if (!r.ok)
245
+ return result(session, r); return text(JSON.stringify({ note: "Everything here is simulated: no real biometric check or money movement took place.", capabilities: r.capabilities, log: r.log ?? [] }, null, 2)); });
246
+ server.tool("device_reset", "Put the simulated phone back to a known state (permissions undetermined, default location, nothing in front of the camera, log cleared), optionally with a new profile.", { session: sid, platform: z.enum(["android", "ios"]).optional(), api: z.number().optional() }, WR, async ({ session, ...device }) => { need(session); return result(session, await sim("/act", { session, action: "device_reset", device })); });
247
+ server.tool("export_test", "Turn the steps taken in this session (tap, type, scroll, back, set_device) into a @shipvo/test script and return its source. Read-only. Targets are named by their visible text; ambiguous ones become TODO comments and password fields use an environment variable instead of the typed value.", { session: sid }, RO, async ({ session }) => {
248
+ const rec = steps.get(session), s = sessions.get(session);
249
+ if (!rec || !s)
250
+ return result(session, { ok: false, code: "no_session", error: `no session '${session}': call open_app first` });
251
+ return text(exportTest(s.app, rec));
252
+ });
253
+ server.tool("end_session", "Close the session and get a report: steps taken, and the orders the app sent to its backend during the session.", { session: sid }, DEST, async ({ session }) => {
129
254
  need(session);
255
+ // The simulator is the source of truth: end a session it lists even if this server did not open it; otherwise no_session.
256
+ const listed = await sim("/sessions", {});
257
+ if (!listed.ok)
258
+ return result(session, listed);
259
+ if (!(listed.sessions ?? []).some((x) => x.session === session))
260
+ return result(session, { ok: false, code: "no_session", error: `no session '${session}': call open_app first` });
130
261
  const s = sessions.get(session) ?? { app: session.split("-")[0], steps: 0, startedAt: Date.now() };
131
262
  sessions.delete(session);
263
+ trees.delete(session);
264
+ steps.delete(session);
132
265
  let orders = [];
133
266
  try {
134
267
  orders = JSON.parse(readFileSync(`${process.env.PN_SIM_DIR ?? "/tmp/pn-ui"}/${session}.json`, "utf8")).orders;
@@ -136,12 +269,37 @@ Finish a task by checking the final screen says what you expected (for example "
136
269
  catch { }
137
270
  return text(JSON.stringify({ session, app: s.app, steps: s.steps, seconds: Math.round((Date.now() - s.startedAt) / 1000), orders }, null, 2));
138
271
  });
272
+ // Publish the app's web build to here.now (no account) through the CLI, which builds, wraps and checks for secrets.
273
+ server.tool("publish_preview", "Build the app in PN_APP_DIR (or the current folder) with the simulated device and publish a shareable web preview to here.now, no account. The link is PUBLIC and expires in 24 hours; claimUrl keeps it. Returns { url, claimUrl, expiresAt }. Needs @shipvo/cli (npx).", {}, { title: "Publish a public preview", readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true }, async () => {
274
+ if (ctx.scope && ctx.scope !== "owner" && ctx.scope !== "test")
275
+ throw new Error(`this key has scope "${ctx.scope}": it cannot publish a preview`);
276
+ const cwd = process.env.PN_APP_DIR || process.cwd();
277
+ const out = await new Promise((resolve, reject) => execFile("npx", ["-y", "@shipvo/cli", "preview", "--json"], { cwd, timeout: 600000, maxBuffer: 4 << 20 }, (err, so, se) => (err ? reject(new Error(String(se || err.message).trim().split("\n").slice(-3).join(" "))) : resolve(so))));
278
+ const r = JSON.parse(out.trim().split("\n").pop() ?? "{}");
279
+ const sc = { url: r.url, claimUrl: (r.claimUrl ?? null), expiresAt: (r.expiresAt ?? null) };
280
+ return { ...text(JSON.stringify({ ...sc, note: "Public link, expires in 24 hours unless claimUrl is opened and the owner signs in to here.now. Share claimUrl byte for byte." }, null, 2)), structuredContent: sc };
281
+ });
139
282
  // Owner side: the same typed commands the admin at /app/<id>/admin uses.
140
- server.tool("owner_describe", "List the owner commands of an app (content, prices, offers, orders, publish). Needs PN_OWNER_TOKEN.", { app: z.string() }, async ({ app }) => {
283
+ server.tool("owner_describe", "List the owner commands of an app (content, prices, offers, orders, publish). Needs PN_OWNER_TOKEN.", { app: z.string() }, RO, async ({ app }) => {
141
284
  const r = await fetch(`${CONTROL}/app/${app}/api/describe`);
142
285
  return text(await r.text());
143
286
  });
144
- server.tool("owner_command", "Run an owner command against the live app (changes are audited; `publish` makes them live on every phone within seconds).", { app: z.string(), command: z.string(), args: z.record(z.unknown()).optional() }, async ({ app, command, args }) => {
287
+ // The owner's agent rules from shipvo.json (agentPolicy, agentDeny) in PN_APP_DIR or the current folder. Sent to the control plane, which only lets them tighten its own rules.
288
+ function agentRules() {
289
+ try {
290
+ const c = JSON.parse(readFileSync(join(process.env.PN_APP_DIR || process.cwd(), "shipvo.json"), "utf8"));
291
+ const h = {};
292
+ if (["ask", "deny", "allow"].includes(c.agentPolicy))
293
+ h["x-shipvo-agent-policy"] = c.agentPolicy;
294
+ if (Array.isArray(c.agentDeny) && c.agentDeny.length)
295
+ h["x-shipvo-agent-deny"] = c.agentDeny.map(String).join(",");
296
+ return h;
297
+ }
298
+ catch {
299
+ return {};
300
+ }
301
+ }
302
+ server.tool("owner_command", "Run an owner command against the live app (changes are audited). Commands that cannot be undone or reach users (publish, prices, offers, deletes, API keys) do nothing and return { code: \"needs_confirm\", plan } first: show the plan to the owner, then call again with the same args and confirm: true.", { app: z.string(), command: z.string(), args: z.record(z.unknown()).optional(), confirm: z.boolean().optional().describe("true to run a command that returned needs_confirm; pass the same args") }, DEST, async ({ app, command, args, confirm }) => {
145
303
  if (ctx.scope && ctx.scope !== "owner")
146
304
  throw new Error(`this key has scope "${ctx.scope}": owner commands need an owner key`);
147
305
  if (keyApps && !keyApps.includes(app))
@@ -149,7 +307,7 @@ Finish a task by checking the final screen says what you expected (for example "
149
307
  const token = ctx.ownerToken || OWNER_TOKEN;
150
308
  if (!token)
151
309
  throw new Error("owner commands are not enabled on this server.");
152
- const r = await fetch(`${CONTROL}/app/${app}/api/command`, { method: "POST", headers: { "content-type": "application/json", authorization: `Bearer ${token}` }, body: JSON.stringify({ name: command, args: args ?? {} }) });
310
+ const r = await fetch(`${CONTROL}/app/${app}/api/command`, { method: "POST", headers: { "content-type": "application/json", authorization: `Bearer ${token}`, ...agentRules() }, body: JSON.stringify({ name: command, args: { ...Object.fromEntries(Object.entries(args ?? {}).filter(([k]) => k !== "confirm")), ...(confirm === true ? { confirm: true } : {}) } }) });
153
311
  return text(await r.text());
154
312
  });
155
313
  return server;
@@ -162,12 +320,14 @@ if (argi < 0) {
162
320
  else {
163
321
  const port = Number(process.argv[argi + 1] || 8787);
164
322
  const keys = new Set((process.env.PN_API_KEYS ?? "").split(",").map((k) => k.trim()).filter(Boolean));
165
- const transports = new Map();
323
+ // Stateless (MCP 2026-07-28, and fine for 2025-era clients): no protocol session. Every request gets its own
324
+ // McpServer and transport, built from that request's x-shipvo-* headers, so scope never outlives the request.
325
+ // Simulator sessions are separate: they live in the simulator and travel as the `session` tool argument.
166
326
  const httpServer = http.createServer(async (req, res) => {
167
327
  const url = new URL(req.url ?? "/", "http://x");
168
328
  if (url.pathname === "/health") {
169
329
  res.writeHead(200, { "content-type": "application/json" });
170
- return res.end(JSON.stringify({ ok: true, sessions: transports.size }));
330
+ return res.end(JSON.stringify({ ok: true }));
171
331
  }
172
332
  if (url.pathname !== "/mcp") {
173
333
  res.writeHead(404);
@@ -181,30 +341,38 @@ else {
181
341
  return res.end(JSON.stringify({ error: "invalid_api_key" }));
182
342
  }
183
343
  }
184
- const sid = req.headers["mcp-session-id"];
185
- let transport = sid ? transports.get(sid) : undefined;
186
- if (!transport) {
187
- if (req.method !== "POST") {
188
- res.writeHead(400);
189
- return res.end("missing session");
190
- }
191
- transport = new StreamableHTTPServerTransport({ sessionIdGenerator: () => randomUUID(), onsessioninitialized: (id) => { transports.set(id, transport); } });
192
- transport.onclose = () => { for (const [id, t] of transports)
193
- if (t === transport)
194
- transports.delete(id); };
195
- // One McpServer per client connection. Behind sim.shipvo.dev the Worker has checked the key and says which
196
- // app and scope it grants; locally there are no such headers and every app is allowed.
197
- const scope = req.headers["x-shipvo-scope"];
198
- await makeServer({ app: req.headers["x-shipvo-app"] || undefined, publicApps: req.headers["x-shipvo-public"] || undefined, scope, ownerToken: req.headers["x-shipvo-owner-token"] || undefined }).connect(transport);
344
+ // No server-initiated streams and nothing to close without sessions.
345
+ if (req.method !== "POST") {
346
+ res.writeHead(405, { allow: "POST", "content-type": "application/json" });
347
+ return res.end(JSON.stringify({ jsonrpc: "2.0", error: { code: -32000, message: "Method not allowed: this server is stateless, use POST" }, id: null }));
199
348
  }
200
- let body = undefined;
201
- if (req.method === "POST") {
349
+ let body;
350
+ try {
202
351
  let raw = "";
203
352
  for await (const ch of req)
204
353
  raw += ch;
205
354
  body = raw ? JSON.parse(raw) : undefined;
206
355
  }
207
- await transport.handleRequest(req, res, body);
356
+ catch {
357
+ res.writeHead(400, { "content-type": "application/json" });
358
+ return res.end(JSON.stringify({ jsonrpc: "2.0", error: { code: -32700, message: "Parse error" }, id: null }));
359
+ }
360
+ // Behind sim.shipvo.dev the Worker has checked the key and says which app and scope it grants; locally
361
+ // there are no such headers and every app is allowed. A client's mcp-session-id header is ignored.
362
+ const h = (n) => req.headers[n] || undefined;
363
+ const server = makeServer({ app: h("x-shipvo-app"), publicApps: h("x-shipvo-public"), scope: h("x-shipvo-scope"), ownerToken: h("x-shipvo-owner-token") });
364
+ const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined, enableJsonResponse: true });
365
+ res.on("close", () => { void transport.close(); void server.close(); });
366
+ try {
367
+ await server.connect(transport);
368
+ await transport.handleRequest(req, res, body);
369
+ }
370
+ catch (e) {
371
+ if (!res.headersSent) {
372
+ res.writeHead(500, { "content-type": "application/json" });
373
+ res.end(JSON.stringify({ jsonrpc: "2.0", error: { code: -32603, message: "Internal error" }, id: null }));
374
+ }
375
+ }
208
376
  });
209
377
  httpServer.listen(port, () => console.error(`Shipvo MCP over HTTP on http://localhost:${port}/mcp (${keys.size ? keys.size + " key(s)" : "no auth"})`));
210
378
  }
package/dist/wait.d.ts ADDED
@@ -0,0 +1,15 @@
1
+ export type WaitFor = {
2
+ ref?: string;
3
+ text?: string;
4
+ role?: string;
5
+ name?: string;
6
+ gone?: boolean;
7
+ };
8
+ export type Waited = {
9
+ matched: boolean;
10
+ ms: number;
11
+ };
12
+ export declare function present(tree: string, w: WaitFor): boolean;
13
+ export declare const wantsMatch: (w: WaitFor) => boolean;
14
+ export declare const satisfied: (tree: string, w: WaitFor) => boolean;
15
+ export declare const sameTree: (a: string, b: string) => boolean;
package/dist/wait.js ADDED
@@ -0,0 +1,17 @@
1
+ // wait_for and settle: pure matching on screen trees. No I/O.
2
+ import { parse, unquoteLine } from "./record.js";
3
+ // True when the thing wait_for describes is on the screen (ignoring `gone`).
4
+ export function present(tree, w) {
5
+ const ls = tree.split("\n").filter((l) => l.trim());
6
+ if (w.ref)
7
+ return ls.some((l) => unquoteLine(l).includes(`[ref=${w.ref}]`));
8
+ if (w.text)
9
+ return ls.some((l) => (parse(l)?.text ?? "").includes(w.text));
10
+ if (w.role && w.name)
11
+ return ls.some((l) => { const p = parse(l); return !!p && p.role === w.role && p.text.includes(w.name); });
12
+ return false;
13
+ }
14
+ export const wantsMatch = (w) => !!(w.ref || w.text || (w.role && w.name));
15
+ export const satisfied = (tree, w) => present(tree, w) !== !!w.gone;
16
+ // Two reads are equal when they differ only in ref numbers.
17
+ export const sameTree = (a, b) => a.replace(/\s*\[ref=\w+\]/g, "") === b.replace(/\s*\[ref=\w+\]/g, "");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@shipvo/mcp",
3
- "version": "0.0.7",
3
+ "version": "0.0.8",
4
4
  "type": "module",
5
5
  "license": "Apache-2.0",
6
6
  "description": "MCP server that lets AI agents open, see and operate Shipvo apps without a phone: a phone-sized simulator of the real app, plus the app's typed commands and owner API.",