@shipvo/mcp 0.0.6 → 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
@@ -8,14 +8,55 @@ Lets AI agents (Claude, Cursor, Claude Code, PiPilot…) **use a Shipvo app with
8
8
  "env": { "PN_SIM": "http://localhost:4545", "PN_OWNER_TOKEN": "…optional…" } } } }
9
9
  ```
10
10
 
11
- Start the simulator first (`shipvo sim` in the app). The hosted version needs no local setup: `{ "url": "https://<host>/mcp/<key>" }`.
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,31 +3,103 @@
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
- // Apps this server can open. Hosted: the account's apps. Local: one app served by the simulator.
19
- const APPS = process.env.PN_APPS ? JSON.parse(process.env.PN_APPS)
20
- : { tide: { name: "Tide & Tackle", description: "Fishing-supplies shop in Douala: catalogue, cart, promo codes, delivery slots, cash or mobile money." } };
23
+ // Apps this server can open. Hosted: the account's apps. Local: the app the simulator serves (it is asked on
24
+ // first use), or PN_APPS (JSON) to override. Falls back to the sample app when the simulator cannot be reached.
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; };
28
+ async function localApps() {
29
+ if (process.env.PN_APPS)
30
+ return JSON.parse(process.env.PN_APPS);
31
+ try {
32
+ const r = await fetch(SIM + "/apps", { method: "POST", headers: { "content-type": "application/json" }, body: "{}", signal: AbortSignal.timeout(simTimeoutMs()) });
33
+ if (r.ok) {
34
+ const out = {};
35
+ for (const a of (await r.json()).apps ?? [])
36
+ out[a.id] = { name: a.name ?? a.id, description: "The app this simulator serves." };
37
+ if (Object.keys(out).length)
38
+ return out;
39
+ }
40
+ }
41
+ catch { }
42
+ return TIDE;
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.
21
46
  async function sim(path, body) {
22
- const r = await fetch(SIM + path, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body) });
23
- if (!r.ok)
24
- throw new Error(`simulator ${path} -> ${r.status}. Is it running at ${SIM}?`);
25
- 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
+ }
26
61
  }
27
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 };
28
71
  const text = (t) => ({ content: [{ type: "text", text: t }] });
29
- const result = (sid, r) => { const s = sessions.get(sid); if (s)
30
- s.steps++; return text(r.ok ? (r.screen ?? "") : `Error: ${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
+ };
31
103
  // Sessions live in the simulator; this map only tracks step counts. A client may reconnect between calls
32
104
  // (ChatGPT does), so an unknown id is forwarded and the simulator answers if it knows it.
33
105
  const need = (_sid) => { };
@@ -39,8 +111,9 @@ System sheets (permission dialogs, the camera, the fingerprint prompt, payment p
39
111
  Finish a task by checking the final screen says what you expected (for example "Order placed"). Call end_session when done to get a report.` });
40
112
  // ctx.app: the app(s) this key opens, comma-separated (the beta app plus any the key owner has registered).
41
113
  const keyApps = ctx.app ? ctx.app.split(",").map((s) => s.trim()).filter(Boolean) : null;
42
- const allowed = (id) => (keyApps ? keyApps.includes(id) : !!APPS[id]);
43
- 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 }) => {
114
+ const allowed = async (id) => (keyApps ? keyApps.includes(id) : !!(await localApps())[id]);
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 }) => {
116
+ const APPS = await localApps();
44
117
  const ids = keyApps ?? Object.keys(APPS);
45
118
  const q = (query ?? "").toLowerCase();
46
119
  let dir = {};
@@ -63,55 +136,132 @@ Finish a task by checking the final screen says what you expected (for example "
63
136
  session: z.string().optional().describe("Session name (default: generated). One session is one phone."),
64
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."),
65
138
  dark: z.boolean().optional().describe("Dark mode"),
66
- }, async ({ app, session, seed_cart, dark }) => {
139
+ }, WR, async ({ app, session, seed_cart, dark }) => {
67
140
  const id = app.split("@")[0];
68
- if (!allowed(id))
69
- throw new Error(keyApps ? `this key opens: ${keyApps.join(", ")}` : `unknown app "${id}". Apps: ${Object.keys(APPS).join(", ")}`);
141
+ if (!(await allowed(id)))
142
+ return result("", { ok: false, code: "unknown_app", error: keyApps ? `this key opens: ${keyApps.join(", ")}` : `unknown app "${id}". Apps: ${Object.keys(await localApps()).join(", ")}` });
70
143
  if (ctx.scope === "read")
71
144
  throw new Error("this key is read-only: it cannot open sessions");
72
145
  const sid = session ?? `${id}-${Date.now().toString(36)}`;
73
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);
74
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);
75
155
  return text(`session: ${sid}\napp: ${id} release: ${r.release ?? "local"}\n\n${r.screen ?? ""}`);
76
156
  });
77
- 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 () => {
78
158
  const r = await sim("/sessions", {});
159
+ if (r.code === "sim_timeout")
160
+ return result("", r);
79
161
  const rows = (r.sessions ?? []).filter((s) => !keyApps || keyApps.includes(s.app));
80
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");
81
163
  });
82
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
+ }
83
202
  const ref = z.string().regex(/^e\d+$/).describe("Element ref from the screen, e.g. e42");
84
- 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" })); });
85
- 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 })); });
86
- 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 })); });
87
- 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 })); });
88
- server.tool("back", "Go back one screen.", { session: sid }, async ({ session }) => { need(session); return result(session, await sim("/act", { session, action: "back" })); });
89
- 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 }) => {
90
210
  need(session);
91
211
  const r = await sim("/act", { session, action: "screenshot" });
92
- if (!r.ok || !r.path)
93
- 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");
94
216
  return { content: [{ type: "image", data: readFileSync(r.path).toString("base64"), mimeType: "image/png" }, { type: "text", text: r.screen ?? "" }] };
95
217
  });
96
218
  // The simulated device: what the phone has, and what happens to it.
97
- 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.", {
98
220
  session: sid,
99
221
  location: z.object({ latitude: z.number(), longitude: z.number(), accuracy: z.number().optional() }).optional(),
100
222
  camera: z.object({ barcode: z.object({ type: z.string(), data: z.string() }).nullable().optional(), delayMs: z.number().optional() }).optional(),
101
- 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"),
102
227
  biometrics: z.object({ enrolled: z.boolean().optional(), nextResult: z.enum(["success", "fail", "cancel"]).optional() }).optional(),
103
228
  payment: z.object({ nextResult: z.enum(["approve", "decline", "timeout", "insufficient_funds"]).optional(), pin: z.string().optional() }).optional(),
104
229
  online: z.boolean().optional(),
105
- }, async ({ session, ...device }) => { need(session); return result(session, await sim("/act", { session, action: "set_device", device })); });
106
- 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 })); });
107
- 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)); });
108
- 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 })); });
109
- 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)); });
110
- 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 })); });
111
- 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 }) => {
112
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 }) => {
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` });
113
261
  const s = sessions.get(session) ?? { app: session.split("-")[0], steps: 0, startedAt: Date.now() };
114
262
  sessions.delete(session);
263
+ trees.delete(session);
264
+ steps.delete(session);
115
265
  let orders = [];
116
266
  try {
117
267
  orders = JSON.parse(readFileSync(`${process.env.PN_SIM_DIR ?? "/tmp/pn-ui"}/${session}.json`, "utf8")).orders;
@@ -119,12 +269,37 @@ Finish a task by checking the final screen says what you expected (for example "
119
269
  catch { }
120
270
  return text(JSON.stringify({ session, app: s.app, steps: s.steps, seconds: Math.round((Date.now() - s.startedAt) / 1000), orders }, null, 2));
121
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
+ });
122
282
  // Owner side: the same typed commands the admin at /app/<id>/admin uses.
123
- 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 }) => {
124
284
  const r = await fetch(`${CONTROL}/app/${app}/api/describe`);
125
285
  return text(await r.text());
126
286
  });
127
- 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 }) => {
128
303
  if (ctx.scope && ctx.scope !== "owner")
129
304
  throw new Error(`this key has scope "${ctx.scope}": owner commands need an owner key`);
130
305
  if (keyApps && !keyApps.includes(app))
@@ -132,7 +307,7 @@ Finish a task by checking the final screen says what you expected (for example "
132
307
  const token = ctx.ownerToken || OWNER_TOKEN;
133
308
  if (!token)
134
309
  throw new Error("owner commands are not enabled on this server.");
135
- 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 } : {}) } }) });
136
311
  return text(await r.text());
137
312
  });
138
313
  return server;
@@ -145,12 +320,14 @@ if (argi < 0) {
145
320
  else {
146
321
  const port = Number(process.argv[argi + 1] || 8787);
147
322
  const keys = new Set((process.env.PN_API_KEYS ?? "").split(",").map((k) => k.trim()).filter(Boolean));
148
- 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.
149
326
  const httpServer = http.createServer(async (req, res) => {
150
327
  const url = new URL(req.url ?? "/", "http://x");
151
328
  if (url.pathname === "/health") {
152
329
  res.writeHead(200, { "content-type": "application/json" });
153
- return res.end(JSON.stringify({ ok: true, sessions: transports.size }));
330
+ return res.end(JSON.stringify({ ok: true }));
154
331
  }
155
332
  if (url.pathname !== "/mcp") {
156
333
  res.writeHead(404);
@@ -164,30 +341,38 @@ else {
164
341
  return res.end(JSON.stringify({ error: "invalid_api_key" }));
165
342
  }
166
343
  }
167
- const sid = req.headers["mcp-session-id"];
168
- let transport = sid ? transports.get(sid) : undefined;
169
- if (!transport) {
170
- if (req.method !== "POST") {
171
- res.writeHead(400);
172
- return res.end("missing session");
173
- }
174
- transport = new StreamableHTTPServerTransport({ sessionIdGenerator: () => randomUUID(), onsessioninitialized: (id) => { transports.set(id, transport); } });
175
- transport.onclose = () => { for (const [id, t] of transports)
176
- if (t === transport)
177
- transports.delete(id); };
178
- // One McpServer per client connection. Behind sim.shipvo.dev the Worker has checked the key and says which
179
- // app and scope it grants; locally there are no such headers and every app is allowed.
180
- const scope = req.headers["x-shipvo-scope"];
181
- 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 }));
182
348
  }
183
- let body = undefined;
184
- if (req.method === "POST") {
349
+ let body;
350
+ try {
185
351
  let raw = "";
186
352
  for await (const ch of req)
187
353
  raw += ch;
188
354
  body = raw ? JSON.parse(raw) : undefined;
189
355
  }
190
- 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
+ }
191
376
  });
192
377
  httpServer.listen(port, () => console.error(`Shipvo MCP over HTTP on http://localhost:${port}/mcp (${keys.size ? keys.size + " key(s)" : "no auth"})`));
193
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.6",
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.",
@@ -14,7 +14,8 @@
14
14
  },
15
15
  "devDependencies": {
16
16
  "typescript": "^5.6.3",
17
- "@types/node": "^22.8.0"
17
+ "@types/node": "^22.8.0",
18
+ "vitest": "^2.1.4"
18
19
  },
19
20
  "keywords": [
20
21
  "mcp",
@@ -45,6 +46,7 @@
45
46
  },
46
47
  "mcpName": "io.github.shipvo/shipvo",
47
48
  "scripts": {
48
- "build": "tsc -p tsconfig.json"
49
+ "build": "tsc -p tsconfig.json",
50
+ "test": "vitest run"
49
51
  }
50
52
  }