@shipvo/mcp 0.0.7 → 0.0.9
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 +54 -2
- package/dist/attach.d.ts +30 -0
- package/dist/attach.js +81 -0
- package/dist/change.d.ts +4 -0
- package/dist/change.js +51 -0
- package/dist/export.d.ts +15 -0
- package/dist/export.js +52 -0
- package/dist/record.d.ts +12 -0
- package/dist/record.js +28 -0
- package/dist/recorder.d.ts +52 -0
- package/dist/recorder.js +143 -0
- package/dist/server.js +354 -57
- package/dist/wait.d.ts +15 -0
- package/dist/wait.js +17 -0
- package/package.json +11 -2
package/README.md
CHANGED
|
@@ -10,12 +10,56 @@ 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
|
-
|
|
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
|
+
| `phone_locked` | The phone is locked, so tap, type, scroll and back were not sent. | Call `unlock`, then try again. |
|
|
39
|
+
| `already_locked` | `lock` was called on a locked phone. | Call `unlock` if you want to use the app. |
|
|
40
|
+
| `not_locked` | `unlock` was called on a phone that is not locked. | Carry on; nothing to do. |
|
|
41
|
+
| `mock_invalid` | A network mock rule is not valid. | Fix the rule. |
|
|
42
|
+
|
|
43
|
+
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`.
|
|
44
|
+
|
|
45
|
+
`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.
|
|
46
|
+
|
|
47
|
+
**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
48
|
|
|
15
49
|
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
50
|
|
|
17
51
|
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
52
|
|
|
53
|
+
## Did the action land?
|
|
54
|
+
|
|
55
|
+
`tap`, `type`, `scroll` and `back` return `structuredContent` next to the usual text:
|
|
56
|
+
|
|
57
|
+
- `delivery`: `"delivered"` or `"not_delivered"`.
|
|
58
|
+
- 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.
|
|
59
|
+
- `type` also returns `value`, the text the field holds after typing.
|
|
60
|
+
|
|
61
|
+
`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.
|
|
62
|
+
|
|
19
63
|
## Remote (Streamable HTTP)
|
|
20
64
|
|
|
21
65
|
Run the server where the simulator runs and put it behind HTTPS (a Cloudflare Tunnel is enough):
|
|
@@ -30,4 +74,12 @@ Clients connect with a URL instead of a command:
|
|
|
30
74
|
{ "mcpServers": { "shipvo": { "url": "https://sim.shipvo.dev/mcp", "headers": { "Authorization": "Bearer key-for-claude" } } } }
|
|
31
75
|
```
|
|
32
76
|
|
|
33
|
-
|
|
77
|
+
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.
|
|
78
|
+
|
|
79
|
+
### Protocol versions
|
|
80
|
+
|
|
81
|
+
- **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.
|
|
82
|
+
- **2026-07-28 clients**: `tools/list` and `tools/call` work with no session and no `initialize`. `server/discover` is answered (supported versions, tools capability, server info). A request counts as 2026-era if it calls `server/discover`, sends `Mcp-Method`, or names a 2026 protocol version in `params._meta`; those must carry `Mcp-Method` (and `Mcp-Name` for `tools/call`, `prompts/get`, `resources/read`) matching the body, or get a 400 JSON-RPC error. Requests with no 2026 marker are not checked. Not done yet: `ttlMs`/`cacheScope` on list results. `tools/list` order is fixed (registration order).
|
|
83
|
+
- This package uses `@modelcontextprotocol/sdk` 1.x (the TypeScript SDK v2 is not stable on npm yet; 1.32.2 is `latest`). Tested by `test/http-stateless.test.ts`: discover without `initialize`, header/body mismatch, scope per request, 2025-era `initialize` clients. Stdio is unchanged.
|
|
84
|
+
|
|
85
|
+
Requests without a valid key are refused. The hosted version at `sim.shipvo.dev` will issue keys per account with scopes (test, owner, read).
|
package/dist/attach.d.ts
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
export type RunInfo = {
|
|
2
|
+
port: number;
|
|
3
|
+
platform: "android" | "ios";
|
|
4
|
+
app: string;
|
|
5
|
+
token: string;
|
|
6
|
+
pid: number;
|
|
7
|
+
};
|
|
8
|
+
export type DriverMessage = {
|
|
9
|
+
id: number;
|
|
10
|
+
type: string;
|
|
11
|
+
[key: string]: unknown;
|
|
12
|
+
};
|
|
13
|
+
export type ScreenMessage = {
|
|
14
|
+
id?: number;
|
|
15
|
+
type?: string;
|
|
16
|
+
ok?: boolean;
|
|
17
|
+
error?: string;
|
|
18
|
+
code?: string;
|
|
19
|
+
screen?: string | null;
|
|
20
|
+
[key: string]: unknown;
|
|
21
|
+
};
|
|
22
|
+
/** Finds `.shipvo/run.json` in `cwd` or a parent, stopping after the git root. Returns it only if the pid is alive and the app matches. */
|
|
23
|
+
export declare function findLiveRun(app: string, cwd?: string): RunInfo | undefined;
|
|
24
|
+
export type Driver = {
|
|
25
|
+
request(type: string, args?: Record<string, unknown>, ms?: number): Promise<ScreenMessage>;
|
|
26
|
+
close(): void;
|
|
27
|
+
closed: boolean;
|
|
28
|
+
};
|
|
29
|
+
/** Connects as `driver`. Rejects if the socket cannot open within `ms`. */
|
|
30
|
+
export declare function connectDriver(info: RunInfo, ms?: number): Promise<Driver>;
|
package/dist/attach.js
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
// Attach to a live `shipvo run` phone. `open_app` reads `.shipvo/run.json`, and if the run is alive and is the
|
|
2
|
+
// same app, drives it over the agent channel as the `driver`. The types below mirror
|
|
3
|
+
// packages/cli/src/run-protocol.ts: MCP cannot import from the CLI (rootDir is src, and the CLI is not a dependency).
|
|
4
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
5
|
+
import { dirname, join, resolve } from "node:path";
|
|
6
|
+
const alive = (pid) => { try {
|
|
7
|
+
process.kill(pid, 0);
|
|
8
|
+
return true;
|
|
9
|
+
}
|
|
10
|
+
catch (e) {
|
|
11
|
+
return e.code === "EPERM";
|
|
12
|
+
} };
|
|
13
|
+
// Safety: run.json may come from a parent directory, but we only ever connect to 127.0.0.1 and send its token there.
|
|
14
|
+
/** Finds `.shipvo/run.json` in `cwd` or a parent, stopping after the git root. Returns it only if the pid is alive and the app matches. */
|
|
15
|
+
export function findLiveRun(app, cwd = process.cwd()) {
|
|
16
|
+
let dir = resolve(cwd);
|
|
17
|
+
for (;;) {
|
|
18
|
+
const file = join(dir, ".shipvo", "run.json");
|
|
19
|
+
if (existsSync(file)) {
|
|
20
|
+
try {
|
|
21
|
+
const i = JSON.parse(readFileSync(file, "utf8"));
|
|
22
|
+
if (typeof i.port === "number" && typeof i.token === "string" && typeof i.pid === "number" && typeof i.app === "string" && i.app === app && alive(i.pid))
|
|
23
|
+
return i;
|
|
24
|
+
}
|
|
25
|
+
catch { }
|
|
26
|
+
return undefined; // the nearest run.json decides
|
|
27
|
+
}
|
|
28
|
+
if (existsSync(join(dir, ".git")) || dirname(dir) === dir)
|
|
29
|
+
return undefined;
|
|
30
|
+
dir = dirname(dir);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
/** Connects as `driver`. Rejects if the socket cannot open within `ms`. */
|
|
34
|
+
export function connectDriver(info, ms = 3000) {
|
|
35
|
+
return new Promise((ok, fail) => {
|
|
36
|
+
if (typeof WebSocket === "undefined")
|
|
37
|
+
return fail(new Error("attach needs Node 22+ (no global WebSocket in this Node version)"));
|
|
38
|
+
const ws = new WebSocket(`ws://127.0.0.1:${info.port}/__shipvo/agent?role=driver&token=${encodeURIComponent(info.token)}`);
|
|
39
|
+
const pending = new Map();
|
|
40
|
+
let next = 1, opened = false;
|
|
41
|
+
const d = {
|
|
42
|
+
closed: false,
|
|
43
|
+
close: () => { try {
|
|
44
|
+
ws.close();
|
|
45
|
+
}
|
|
46
|
+
catch { } },
|
|
47
|
+
request: (type, args = {}, limit = 10000) => new Promise((res) => {
|
|
48
|
+
if (d.closed)
|
|
49
|
+
return res({ ok: false, code: "attach_closed", error: "the shipvo run phone is gone: call open_app again" });
|
|
50
|
+
const id = next++;
|
|
51
|
+
const t = setTimeout(() => { pending.delete(id); res({ ok: false, code: "sim_timeout", error: `the live phone did not answer "${type}" within ${limit} ms. The action may still have run: call screen to see where it is.` }); }, limit);
|
|
52
|
+
pending.set(id, (m) => { clearTimeout(t); res(m); });
|
|
53
|
+
ws.send(JSON.stringify({ ...args, id, type }));
|
|
54
|
+
}),
|
|
55
|
+
};
|
|
56
|
+
const timer = setTimeout(() => { ws.close(); fail(new Error("timed out connecting to the shipvo run phone")); }, ms);
|
|
57
|
+
ws.onopen = () => { opened = true; clearTimeout(timer); ok(d); };
|
|
58
|
+
ws.onmessage = (e) => {
|
|
59
|
+
try {
|
|
60
|
+
const m = JSON.parse(String(e.data));
|
|
61
|
+
if (m.id !== undefined) {
|
|
62
|
+
pending.get(m.id)?.(m);
|
|
63
|
+
pending.delete(m.id);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
catch { }
|
|
67
|
+
};
|
|
68
|
+
ws.onclose = () => {
|
|
69
|
+
d.closed = true;
|
|
70
|
+
for (const [id, f] of pending) {
|
|
71
|
+
f({ ok: false, code: "attach_closed", error: "the shipvo run phone closed the connection: call open_app again" });
|
|
72
|
+
pending.delete(id);
|
|
73
|
+
}
|
|
74
|
+
if (!opened) {
|
|
75
|
+
clearTimeout(timer);
|
|
76
|
+
fail(new Error("could not connect to the shipvo run phone"));
|
|
77
|
+
}
|
|
78
|
+
};
|
|
79
|
+
ws.onerror = () => { };
|
|
80
|
+
});
|
|
81
|
+
}
|
package/dist/change.d.ts
ADDED
|
@@ -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
|
+
}
|
package/dist/export.d.ts
ADDED
|
@@ -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
|
+
}
|
package/dist/record.d.ts
ADDED
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
export type Format = "9:16" | "16:9" | "1:1";
|
|
2
|
+
export declare const SIZES: Record<Format, {
|
|
3
|
+
width: number;
|
|
4
|
+
height: number;
|
|
5
|
+
}>;
|
|
6
|
+
export type Chapter = {
|
|
7
|
+
t: number;
|
|
8
|
+
caption: string;
|
|
9
|
+
};
|
|
10
|
+
export type Recording = {
|
|
11
|
+
file: string;
|
|
12
|
+
duration_s: number;
|
|
13
|
+
width: number;
|
|
14
|
+
height: number;
|
|
15
|
+
chapters: Chapter[];
|
|
16
|
+
};
|
|
17
|
+
export declare class RecorderError extends Error {
|
|
18
|
+
code: string;
|
|
19
|
+
constructor(code: string, message: string);
|
|
20
|
+
}
|
|
21
|
+
export declare class Recorder {
|
|
22
|
+
private browser?;
|
|
23
|
+
private page?;
|
|
24
|
+
private ff?;
|
|
25
|
+
private loop?;
|
|
26
|
+
private t0;
|
|
27
|
+
private running;
|
|
28
|
+
private starting;
|
|
29
|
+
private stopping;
|
|
30
|
+
private ffErr;
|
|
31
|
+
private done?;
|
|
32
|
+
private chapters;
|
|
33
|
+
private file;
|
|
34
|
+
private size;
|
|
35
|
+
private fps;
|
|
36
|
+
get active(): boolean;
|
|
37
|
+
start(o: {
|
|
38
|
+
url: string;
|
|
39
|
+
format?: Format;
|
|
40
|
+
fps?: 30 | 60;
|
|
41
|
+
title?: string;
|
|
42
|
+
dir?: string;
|
|
43
|
+
}): Promise<{
|
|
44
|
+
file: string;
|
|
45
|
+
width: number;
|
|
46
|
+
height: number;
|
|
47
|
+
}>;
|
|
48
|
+
private grab;
|
|
49
|
+
marker(caption: string): Chapter;
|
|
50
|
+
stop(): Promise<Recording>;
|
|
51
|
+
private cleanup;
|
|
52
|
+
}
|
package/dist/recorder.js
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
// Records the phone frame page to an MP4. Playwright takes screenshots while a session runs; ffmpeg encodes them.
|
|
2
|
+
// Frames carry wall-clock timestamps, so the video lasts as long as the recording even when screenshots are slow.
|
|
3
|
+
// The recording clock (duration_s, chapter times) and the video both start at the first frame.
|
|
4
|
+
import { spawn } from "node:child_process";
|
|
5
|
+
import { mkdirSync } from "node:fs";
|
|
6
|
+
import { join, resolve } from "node:path";
|
|
7
|
+
export const SIZES = {
|
|
8
|
+
"9:16": { width: 1080, height: 1920 }, "16:9": { width: 1920, height: 1080 }, "1:1": { width: 1080, height: 1080 },
|
|
9
|
+
};
|
|
10
|
+
export class RecorderError extends Error {
|
|
11
|
+
code;
|
|
12
|
+
constructor(code, message) {
|
|
13
|
+
super(message);
|
|
14
|
+
this.code = code;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
export class Recorder {
|
|
18
|
+
browser;
|
|
19
|
+
page;
|
|
20
|
+
ff;
|
|
21
|
+
loop;
|
|
22
|
+
t0 = 0;
|
|
23
|
+
running = false;
|
|
24
|
+
starting = false;
|
|
25
|
+
stopping = false;
|
|
26
|
+
ffErr = "";
|
|
27
|
+
done;
|
|
28
|
+
chapters = [];
|
|
29
|
+
file = "";
|
|
30
|
+
size = SIZES["9:16"];
|
|
31
|
+
fps = 30;
|
|
32
|
+
get active() { return this.running || this.starting || this.stopping; }
|
|
33
|
+
async start(o) {
|
|
34
|
+
if (this.active)
|
|
35
|
+
throw new RecorderError("recording_active", "a recording is already running: call record_stop first");
|
|
36
|
+
this.starting = true;
|
|
37
|
+
try {
|
|
38
|
+
this.size = SIZES[o.format ?? "9:16"];
|
|
39
|
+
this.fps = o.fps ?? 30;
|
|
40
|
+
this.chapters = [];
|
|
41
|
+
this.ffErr = "";
|
|
42
|
+
const dir = resolve(o.dir ?? join(process.cwd(), ".shipvo", "recordings"));
|
|
43
|
+
mkdirSync(dir, { recursive: true });
|
|
44
|
+
const slug = (o.title ?? "recording").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "") || "recording";
|
|
45
|
+
this.file = join(dir, `${slug}-${new Date().toISOString().replace(/[-:T]/g, "").slice(0, 14)}.mp4`);
|
|
46
|
+
let pw;
|
|
47
|
+
try {
|
|
48
|
+
const mod = "playwright";
|
|
49
|
+
pw = await import(mod);
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
throw new RecorderError("recorder_unavailable", "recording needs Playwright: run `npm i -D playwright && npx playwright install chromium`");
|
|
53
|
+
}
|
|
54
|
+
try {
|
|
55
|
+
this.browser = await pw.chromium.launch(process.env.SHIPVO_CHROMIUM ? { executablePath: process.env.SHIPVO_CHROMIUM } : {});
|
|
56
|
+
}
|
|
57
|
+
catch (e) {
|
|
58
|
+
throw new RecorderError("recorder_unavailable", `could not launch Chromium: run \`npx playwright install chromium\` (${e.message.split("\n")[0]})`);
|
|
59
|
+
}
|
|
60
|
+
this.page = await this.browser.newPage({ viewport: this.size });
|
|
61
|
+
try {
|
|
62
|
+
await this.page.goto(o.url, { waitUntil: "load" });
|
|
63
|
+
}
|
|
64
|
+
catch (e) {
|
|
65
|
+
throw new RecorderError("frame_unreachable", `could not open the phone frame page ${o.url}: ${e.message.split("\n")[0]}`);
|
|
66
|
+
}
|
|
67
|
+
this.ff = spawn("ffmpeg", ["-y", "-loglevel", "error", "-use_wallclock_as_timestamps", "1", "-f", "image2pipe", "-c:v", "mjpeg", "-i", "-",
|
|
68
|
+
"-vf", `fps=${this.fps},scale=${this.size.width}:${this.size.height},format=yuv420p`, "-c:v", "libx264", "-preset", "veryfast", "-movflags", "+faststart", this.file], { stdio: ["pipe", "ignore", "pipe"] });
|
|
69
|
+
this.ff.stderr.on("data", (d) => { this.ffErr += d; });
|
|
70
|
+
this.ff.stdin.on("error", () => { });
|
|
71
|
+
this.done = new Promise((r) => { this.ff.on("error", (e) => { this.ffErr += e.message; r(-1); }); this.ff.on("close", r); });
|
|
72
|
+
await this.grab();
|
|
73
|
+
// The video timeline starts when ffmpeg gets the first frame, not before the first (slow) screenshot.
|
|
74
|
+
this.t0 = Date.now();
|
|
75
|
+
this.running = true;
|
|
76
|
+
this.starting = false;
|
|
77
|
+
this.loop = (async () => { while (this.running)
|
|
78
|
+
await this.grab(); })();
|
|
79
|
+
return { file: this.file, ...this.size };
|
|
80
|
+
}
|
|
81
|
+
catch (e) {
|
|
82
|
+
await this.cleanup();
|
|
83
|
+
throw e;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
async grab() {
|
|
87
|
+
try {
|
|
88
|
+
const b = await this.page.screenshot({ type: "jpeg", quality: 90 });
|
|
89
|
+
if (this.ff?.stdin?.writable)
|
|
90
|
+
this.ff.stdin.write(b);
|
|
91
|
+
}
|
|
92
|
+
catch { }
|
|
93
|
+
}
|
|
94
|
+
marker(caption) {
|
|
95
|
+
if (!this.running)
|
|
96
|
+
throw new RecorderError("no_recording", "no recording is running: call record_start first");
|
|
97
|
+
const last = this.chapters.at(-1)?.t ?? -0.001;
|
|
98
|
+
const c = { t: Math.max(Math.round(Date.now() - this.t0) / 1000, Math.round((last + 0.001) * 1000) / 1000), caption };
|
|
99
|
+
this.chapters.push(c);
|
|
100
|
+
return c;
|
|
101
|
+
}
|
|
102
|
+
async stop() {
|
|
103
|
+
if (!this.running)
|
|
104
|
+
throw new RecorderError("no_recording", "no recording is running: call record_start first");
|
|
105
|
+
this.running = false;
|
|
106
|
+
this.stopping = true;
|
|
107
|
+
try {
|
|
108
|
+
const { file, size, chapters, ff, done, t0 } = this;
|
|
109
|
+
await this.loop;
|
|
110
|
+
await this.grab();
|
|
111
|
+
const duration_s = Math.round((Date.now() - t0)) / 1000;
|
|
112
|
+
ff.stdin.end();
|
|
113
|
+
const code = await done;
|
|
114
|
+
const err = this.ffErr;
|
|
115
|
+
const out = { file, duration_s, ...size, chapters };
|
|
116
|
+
await this.cleanup();
|
|
117
|
+
if (code !== 0)
|
|
118
|
+
throw new RecorderError("encode_failed", `ffmpeg failed${code === -1 ? " to start (is it installed?)" : ""}: ${err.trim().slice(0, 300)}`);
|
|
119
|
+
return out;
|
|
120
|
+
}
|
|
121
|
+
finally {
|
|
122
|
+
this.stopping = false;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
async cleanup() {
|
|
126
|
+
this.running = false;
|
|
127
|
+
this.starting = false;
|
|
128
|
+
try {
|
|
129
|
+
await this.loop;
|
|
130
|
+
}
|
|
131
|
+
catch { }
|
|
132
|
+
try {
|
|
133
|
+
this.ff?.stdin?.end();
|
|
134
|
+
this.ff?.kill();
|
|
135
|
+
}
|
|
136
|
+
catch { }
|
|
137
|
+
try {
|
|
138
|
+
await this.browser?.close();
|
|
139
|
+
}
|
|
140
|
+
catch { }
|
|
141
|
+
this.browser = this.page = this.ff = this.loop = undefined;
|
|
142
|
+
}
|
|
143
|
+
}
|
package/dist/server.js
CHANGED
|
@@ -3,26 +3,35 @@
|
|
|
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 { Recorder, RecorderError } from "./recorder.js";
|
|
20
|
+
import { connectDriver, findLiveRun } from "./attach.js";
|
|
21
|
+
import { satisfied, sameTree, wantsMatch } from "./wait.js";
|
|
15
22
|
const SIM = (process.env.PN_SIM ?? "http://localhost:4545").replace(/\/$/, "");
|
|
16
23
|
const CONTROL = (process.env.PN_CONTROL ?? "https://api.shipvo.dev").replace(/\/$/, "");
|
|
17
24
|
const OWNER_TOKEN = process.env.PN_OWNER_TOKEN ?? "";
|
|
18
25
|
// Apps this server can open. Hosted: the account's apps. Local: the app the simulator serves (it is asked on
|
|
19
26
|
// first use), or PN_APPS (JSON) to override. Falls back to the sample app when the simulator cannot be reached.
|
|
20
27
|
const TIDE = { tide: { name: "Tide & Tackle", description: "Fishing-supplies shop in Douala: catalogue, cart, promo codes, delivery slots, cash or mobile money." } };
|
|
28
|
+
// Every call to the simulator has a time limit (default 30 s, SHIPVO_SIM_TIMEOUT_MS to change it).
|
|
29
|
+
const simTimeoutMs = () => { const n = Number(process.env.SHIPVO_SIM_TIMEOUT_MS); return Number.isFinite(n) && n > 0 ? n : 30000; };
|
|
21
30
|
async function localApps() {
|
|
22
31
|
if (process.env.PN_APPS)
|
|
23
32
|
return JSON.parse(process.env.PN_APPS);
|
|
24
33
|
try {
|
|
25
|
-
const r = await fetch(SIM + "/apps", { method: "POST", headers: { "content-type": "application/json" }, body: "{}" });
|
|
34
|
+
const r = await fetch(SIM + "/apps", { method: "POST", headers: { "content-type": "application/json" }, body: "{}", signal: AbortSignal.timeout(simTimeoutMs()) });
|
|
26
35
|
if (r.ok) {
|
|
27
36
|
const out = {};
|
|
28
37
|
for (const a of (await r.json()).apps ?? [])
|
|
@@ -34,21 +43,87 @@ async function localApps() {
|
|
|
34
43
|
catch { }
|
|
35
44
|
return TIDE;
|
|
36
45
|
}
|
|
46
|
+
// A call that gets no answer in time returns ok:false with code sim_timeout (a typed error for this one tool
|
|
47
|
+
// call). It is never retried: the action may have run, so the agent should call `screen` and look.
|
|
37
48
|
async function sim(path, body) {
|
|
38
|
-
const
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
49
|
+
const ms = simTimeoutMs();
|
|
50
|
+
let r;
|
|
51
|
+
try {
|
|
52
|
+
r = await fetch(SIM + path, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body), signal: AbortSignal.timeout(ms) });
|
|
53
|
+
if (!r.ok)
|
|
54
|
+
throw new Error(`simulator ${path} -> ${r.status}. Is it running at ${SIM}?`);
|
|
55
|
+
return (await r.json());
|
|
56
|
+
}
|
|
57
|
+
catch (e) {
|
|
58
|
+
if (e?.name !== "TimeoutError")
|
|
59
|
+
throw e;
|
|
60
|
+
const what = path === "/act" && typeof body.action === "string" ? `action "${body.action}"` : `request ${path}`;
|
|
61
|
+
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.` };
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
const recorder = new Recorder();
|
|
65
|
+
// Sessions attached to a live `shipvo run` phone. Module level: HTTP mode builds a server per request.
|
|
66
|
+
const attached = new Map();
|
|
67
|
+
const ATTACH_TYPES = new Set(["screen", "tap", "type", "scroll", "back"]);
|
|
68
|
+
// One action on a session: through the driver connection when attached, else the simulator.
|
|
69
|
+
async function act(body) {
|
|
70
|
+
const d = attached.get(String(body.session));
|
|
71
|
+
if (!d)
|
|
72
|
+
return sim("/act", body);
|
|
73
|
+
const { session: _s, action, ...args } = body;
|
|
74
|
+
if (!ATTACH_TYPES.has(String(action)))
|
|
75
|
+
return { ok: false, code: "not_supported_attached", error: `"${action}" is not available on the live shipvo run phone yet. Use screen, tap, type, scroll or back, or open a headless session with attach: "never".` };
|
|
76
|
+
const m = await d.request(String(action), args);
|
|
77
|
+
return { ok: m.ok !== false && !m.error, screen: m.screen ?? null, error: m.error, code: m.code };
|
|
42
78
|
}
|
|
43
79
|
const sessions = new Map();
|
|
80
|
+
// Last tree per session id. Separate from `sessions` so it works for ids this server never opened.
|
|
81
|
+
const trees = new Map();
|
|
82
|
+
// Recorded steps per session, for export_test. Dropped when the session ends.
|
|
83
|
+
const steps = new Map();
|
|
84
|
+
// Tool annotations (MCP hints): clients use them to decide what to run without asking.
|
|
85
|
+
const RO = { readOnlyHint: true, destructiveHint: false };
|
|
86
|
+
const WR = { readOnlyHint: false, destructiveHint: false };
|
|
87
|
+
const DEST = { readOnlyHint: false, destructiveHint: true };
|
|
44
88
|
const text = (t) => ({ content: [{ type: "text", text: t }] });
|
|
45
|
-
|
|
46
|
-
|
|
89
|
+
// Action result with a "Change:" line on top. Compares this tree with the session's last one.
|
|
90
|
+
// compact: on "content changed", return only the added/removed lines instead of the full tree.
|
|
91
|
+
const acted = (sid, action, r, o = {}) => {
|
|
92
|
+
if (!r.ok || r.screen == null)
|
|
93
|
+
return result(sid, r);
|
|
94
|
+
const prev = trees.get(sid);
|
|
95
|
+
trees.set(sid, r.screen);
|
|
96
|
+
const change = prev === undefined ? "unknown (no earlier screen to compare)" : classify(prev, r.screen, action);
|
|
97
|
+
if (o.step)
|
|
98
|
+
steps.get(sid)?.push({ ...o.step, changed: prev === undefined ? undefined : change !== "none" });
|
|
99
|
+
const head = [`Change: ${change}`];
|
|
100
|
+
if (o.waited)
|
|
101
|
+
head.push(`Waited: ${o.waited.matched ? "matched" : "timed out"} after ${o.waited.ms} ms`);
|
|
102
|
+
if (action === "type" && o.ref) {
|
|
103
|
+
const v = fieldValue(r.screen, o.ref);
|
|
104
|
+
head.push(v !== undefined ? `Value of ${o.ref}: ${JSON.stringify(v)}` : `Value of ${o.ref}: not shown`);
|
|
105
|
+
}
|
|
106
|
+
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;
|
|
107
|
+
const res = result(sid, { ...r, screen: head.join("\n") + "\n\n" + body });
|
|
108
|
+
const value = action === "type" ? (r.value ?? fieldValue(r.screen, o.ref ?? "")) : undefined;
|
|
109
|
+
return { ...res, structuredContent: { delivery: r.delivery ?? "delivered", ...(value !== undefined ? { value } : {}), ...(o.waited ? { waited: o.waited } : {}) } };
|
|
110
|
+
};
|
|
111
|
+
const result = (sid, r) => {
|
|
112
|
+
const s = sessions.get(sid);
|
|
113
|
+
if (s)
|
|
114
|
+
s.steps++;
|
|
115
|
+
if (r.ok)
|
|
116
|
+
return text(r.screen ?? "");
|
|
117
|
+
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}` : ""}`;
|
|
118
|
+
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 } : {}) } };
|
|
119
|
+
};
|
|
47
120
|
// Sessions live in the simulator; this map only tracks step counts. A client may reconnect between calls
|
|
48
121
|
// (ChatGPT does), so an unknown id is forwarded and the simulator answers if it knows it.
|
|
49
122
|
const need = (_sid) => { };
|
|
123
|
+
const SERVER_VERSION = "0.0.4";
|
|
124
|
+
const SUPPORTED_VERSIONS = ["2026-07-28", "2025-11-25", "2025-06-18", "2025-03-26", "2024-11-05"];
|
|
50
125
|
function makeServer(ctx = {}) {
|
|
51
|
-
const server = new McpServer({ name: "shipvo", version:
|
|
126
|
+
const server = new McpServer({ name: "shipvo", version: SERVER_VERSION }, { instructions: `You can use Shipvo apps the way a person uses them on a phone, without a phone.
|
|
52
127
|
Call open_app to get a session and the first screen. The screen is an accessibility tree: every button, field and text, with a [ref=eN]. Act with tap/type/scroll/back using refs, and read the screen that comes back each time.
|
|
53
128
|
Only what is on screen is listed, like a phone: a horizontal product rail shows a few items, so "scroll right" on the rail (its ref) to see more; "scroll down" the page for the rest.
|
|
54
129
|
System sheets (permission dialogs, the camera, the fingerprint prompt, payment prompts, notification banners, the share sheet) appear in the tree like any other screen: answer them by tapping, as a person would. Use set_device to decide what the phone has (a barcode in front of the camera, a location, whether the fingerprint matches, whether the payment is approved, network off) before the app asks for it.
|
|
@@ -56,7 +131,7 @@ Finish a task by checking the final screen says what you expected (for example "
|
|
|
56
131
|
// ctx.app: the app(s) this key opens, comma-separated (the beta app plus any the key owner has registered).
|
|
57
132
|
const keyApps = ctx.app ? ctx.app.split(",").map((s) => s.trim()).filter(Boolean) : null;
|
|
58
133
|
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 }) => {
|
|
134
|
+
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
135
|
const APPS = await localApps();
|
|
61
136
|
const ids = keyApps ?? Object.keys(APPS);
|
|
62
137
|
const q = (query ?? "").toLowerCase();
|
|
@@ -80,55 +155,183 @@ Finish a task by checking the final screen says what you expected (for example "
|
|
|
80
155
|
session: z.string().optional().describe("Session name (default: generated). One session is one phone."),
|
|
81
156
|
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
157
|
dark: z.boolean().optional().describe("Dark mode"),
|
|
83
|
-
|
|
158
|
+
attach: z.enum(["auto", "never", "always"]).optional().describe("'auto' (default): drive the phone of a live `shipvo run` in this project if there is one, else a headless session. 'never': always headless. 'always': error if there is no live `shipvo run`."),
|
|
159
|
+
}, WR, async ({ app, session, seed_cart, dark, attach }) => {
|
|
84
160
|
const id = app.split("@")[0];
|
|
85
161
|
if (!(await allowed(id)))
|
|
86
|
-
|
|
162
|
+
return result("", { ok: false, code: "unknown_app", error: keyApps ? `this key opens: ${keyApps.join(", ")}` : `unknown app "${id}". Apps: ${Object.keys(await localApps()).join(", ")}` });
|
|
87
163
|
if (ctx.scope === "read")
|
|
88
164
|
throw new Error("this key is read-only: it cannot open sessions");
|
|
89
165
|
const sid = session ?? `${id}-${Date.now().toString(36)}`;
|
|
166
|
+
const mode = attach ?? "auto";
|
|
167
|
+
const live = mode === "never" ? undefined : findLiveRun(id);
|
|
168
|
+
if (mode === "always" && !live)
|
|
169
|
+
return result("", { ok: false, code: "no_live_run", error: `no live shipvo run for "${id}" found in this directory or its parents (.shipvo/run.json missing, stale, or for another app). Start \`shipvo run android\` or \`shipvo run ios\`, or use attach: "auto".` });
|
|
170
|
+
let attachNote = "";
|
|
171
|
+
if (live) {
|
|
172
|
+
try {
|
|
173
|
+
const d = await connectDriver(live);
|
|
174
|
+
attached.get(sid)?.close();
|
|
175
|
+
attached.set(sid, d);
|
|
176
|
+
const r = await act({ session: sid, action: "screen" });
|
|
177
|
+
if (!r.ok) {
|
|
178
|
+
attached.delete(sid);
|
|
179
|
+
d.close();
|
|
180
|
+
if (mode === "always")
|
|
181
|
+
return result(sid, r);
|
|
182
|
+
attachNote = ` (live run found but attach failed: ${r.error ?? r.code ?? "unknown error"})`;
|
|
183
|
+
}
|
|
184
|
+
else {
|
|
185
|
+
sessions.set(sid, { app: id, steps: 0, startedAt: Date.now() });
|
|
186
|
+
steps.set(sid, [{ action: "open" }]);
|
|
187
|
+
if (r.screen != null)
|
|
188
|
+
trees.set(sid, r.screen);
|
|
189
|
+
else
|
|
190
|
+
trees.delete(sid);
|
|
191
|
+
return text(`session: ${sid}\napp: ${id} release: local\nmode: attached to the live shipvo run phone\n\n${r.screen ?? ""}`);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
catch (e) {
|
|
195
|
+
if (mode === "always")
|
|
196
|
+
return result("", { ok: false, code: "attach_failed", error: e.message });
|
|
197
|
+
attachNote = ` (live run found but attach failed: ${e.message})`;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
90
200
|
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 });
|
|
201
|
+
if (r.code === "sim_timeout")
|
|
202
|
+
return result(sid, r);
|
|
91
203
|
sessions.set(sid, { app: id, steps: 0, startedAt: Date.now() });
|
|
92
|
-
|
|
204
|
+
steps.set(sid, [{ action: "open" }]);
|
|
205
|
+
if (r.screen != null)
|
|
206
|
+
trees.set(sid, r.screen);
|
|
207
|
+
else
|
|
208
|
+
trees.delete(sid);
|
|
209
|
+
return text(`session: ${sid}\napp: ${id} release: ${r.release ?? "local"}\nmode: headless session${attachNote}\n\n${r.screen ?? ""}`);
|
|
93
210
|
});
|
|
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 () => {
|
|
211
|
+
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
212
|
const r = await sim("/sessions", {});
|
|
213
|
+
if (r.code === "sim_timeout")
|
|
214
|
+
return result("", r);
|
|
96
215
|
const rows = (r.sessions ?? []).filter((s) => !keyApps || keyApps.includes(s.app));
|
|
97
216
|
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
217
|
});
|
|
99
218
|
const sid = z.string().describe("Session from open_app");
|
|
219
|
+
const compact = z.boolean().optional().describe("When the change is 'content changed', return only the added/removed lines, not the whole screen");
|
|
220
|
+
const CH = " The result starts with 'Change: none | content changed | new screen | scrolled'.";
|
|
221
|
+
const ASYNC = " Work that starts on a tap (paying, saving, loading) can finish after this returns, so the screen may show 'Paying...' or a spinner. Do not report that as the result: pass wait_for with the text you expect (and gone: true for the spinner), or settle: true, before you read the outcome or do anything else.";
|
|
222
|
+
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() })
|
|
223
|
+
.refine((w) => wantsMatch(w), "give ref, text, or role and name").optional()
|
|
224
|
+
.describe("Use after any action that starts work. Wait until this appears (a ref, a text, or role+name), or disappears with gone: true. The result says Waited: matched or timed out.");
|
|
225
|
+
const timeoutMs = z.number().int().min(0).max(30000).optional().describe("How long wait_for and settle may take, in ms (default 2000)");
|
|
226
|
+
const settle = z.boolean().optional().describe("Wait until the screen stops changing (two equal reads 100 ms apart), up to timeout_ms. Use after any action that starts work. A slow result can still follow a pause, so prefer wait_for when you know the expected text.");
|
|
227
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
228
|
+
// Poll the screen after an action. Returns the latest result and what happened. On timeout the action result is kept as is.
|
|
229
|
+
async function waiting(session, r, w) {
|
|
230
|
+
if (!r.ok || r.screen == null || (!w.wait_for && !w.settle))
|
|
231
|
+
return { r };
|
|
232
|
+
const t0 = Date.now(), limit = w.timeout_ms ?? 2000, left = () => limit - (Date.now() - t0);
|
|
233
|
+
let cur = r, matched = true;
|
|
234
|
+
const read = async () => { const n = await act({ session, action: "screen" }); if (n.ok && n.screen != null)
|
|
235
|
+
cur = { ...cur, screen: n.screen }; return n.ok && n.screen != null; };
|
|
236
|
+
if (w.wait_for) {
|
|
237
|
+
matched = satisfied(cur.screen, w.wait_for);
|
|
238
|
+
while (!matched && left() > 0) {
|
|
239
|
+
await sleep(Math.min(100, left()));
|
|
240
|
+
if (await read())
|
|
241
|
+
matched = satisfied(cur.screen, w.wait_for);
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
if (w.settle && matched) {
|
|
245
|
+
matched = false;
|
|
246
|
+
while (left() > 0) {
|
|
247
|
+
await sleep(Math.min(100, left()));
|
|
248
|
+
const before = cur.screen;
|
|
249
|
+
if (await read() && sameTree(before, cur.screen)) {
|
|
250
|
+
matched = true;
|
|
251
|
+
break;
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
return { r: cur, waited: { matched, ms: Date.now() - t0 } };
|
|
256
|
+
}
|
|
100
257
|
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);
|
|
102
|
-
|
|
103
|
-
server.tool("
|
|
104
|
-
server.tool("
|
|
105
|
-
server.tool("
|
|
106
|
-
server.tool("
|
|
258
|
+
server.tool("screen", "The current screen as an accessibility tree with refs.", { session: sid }, RO, async ({ session }) => { need(session); const r = await act({ session, action: "screen" }); if (r.ok && r.screen != null)
|
|
259
|
+
trees.set(session, r.screen); return result(session, r); });
|
|
260
|
+
server.tool("tap", "Tap a button, link or choice by ref. Returns the new screen." + CH + ASYNC, { 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 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) } }); });
|
|
261
|
+
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 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 } }); });
|
|
262
|
+
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 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 } }); });
|
|
263
|
+
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 act({ session, action: "back" }), { wait_for, timeout_ms, settle }); return acted(session, "back", w.r, { waited: w.waited, compact, step: { action: "back" } }); });
|
|
264
|
+
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
265
|
need(session);
|
|
108
|
-
const r = await
|
|
109
|
-
if (!r.ok
|
|
110
|
-
|
|
266
|
+
const r = await act({ session, action: "screenshot" });
|
|
267
|
+
if (!r.ok)
|
|
268
|
+
return result(session, r);
|
|
269
|
+
if (!r.path)
|
|
270
|
+
throw new Error("no screenshot");
|
|
111
271
|
return { content: [{ type: "image", data: readFileSync(r.path).toString("base64"), mimeType: "image/png" }, { type: "text", text: r.screen ?? "" }] };
|
|
112
272
|
});
|
|
113
273
|
// 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.", {
|
|
274
|
+
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
275
|
session: sid,
|
|
116
276
|
location: z.object({ latitude: z.number(), longitude: z.number(), accuracy: z.number().optional() }).optional(),
|
|
117
277
|
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(),
|
|
278
|
+
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(),
|
|
279
|
+
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)"),
|
|
280
|
+
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)"),
|
|
281
|
+
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
282
|
biometrics: z.object({ enrolled: z.boolean().optional(), nextResult: z.enum(["success", "fail", "cancel"]).optional() }).optional(),
|
|
120
283
|
payment: z.object({ nextResult: z.enum(["approve", "decline", "timeout", "insufficient_funds"]).optional(), pin: z.string().optional() }).optional(),
|
|
121
284
|
online: z.boolean().optional(),
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
server.tool("
|
|
127
|
-
|
|
128
|
-
|
|
285
|
+
source: z.enum(["auto", "simulated"]).optional().describe("\"simulated\" forces the simulated camera, microphone and location even when a person could grant the real ones, for reproducible runs"),
|
|
286
|
+
microphone: z.object({ level: z.number().min(0).max(1).optional() }).optional(),
|
|
287
|
+
}, WR, async ({ session, ...device }) => { need(session); const r = await act({ session, action: "set_device", device }); if (r.ok)
|
|
288
|
+
steps.get(session)?.push({ action: "set_device", device: device.payment?.pin === undefined ? device : { ...device, payment: { ...device.payment, pin: undefined } } }); return result(session, r); });
|
|
289
|
+
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 }) => {
|
|
290
|
+
need(session);
|
|
291
|
+
const r = await act({ session, action: "push_notification", title, body, data });
|
|
292
|
+
// The simulator answers ok when permission blocks the push, so say so: otherwise it looks like a delivery.
|
|
293
|
+
if (r.ok && r.delivered === false)
|
|
294
|
+
return result(session, { ...r, screen: `Not delivered: notification permission is not granted (use set_device permissions.notifications).\n\n${r.screen ?? ""}` });
|
|
295
|
+
return result(session, r);
|
|
296
|
+
});
|
|
297
|
+
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 act({ session, action: "notifications" }); if (!r.ok)
|
|
298
|
+
return result(session, r); return text(JSON.stringify(r.notifications ?? [], null, 2)); });
|
|
299
|
+
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 act({ session, action: "open_notification", id })); });
|
|
300
|
+
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 act({ session, action: "open_url", url })); });
|
|
301
|
+
server.tool("lock", "Press the power button: the simulated phone locks and shows the lock screen. The app and its screen are kept. While locked, tap, type, scroll and back fail with phone_locked. Logged in device_log as simulated.", { session: sid }, WR, async ({ session }) => { need(session); return result(session, await act({ session, action: "lock" })); });
|
|
302
|
+
server.tool("unlock", "Swipe up on the lock screen. Returns to the same screen as before the lock. Fails with not_locked if the phone is not locked. Logged in device_log as simulated.", { session: sid }, WR, async ({ session }) => { need(session); return result(session, await act({ session, action: "unlock" })); });
|
|
303
|
+
server.tool("restart", "Restart the simulated phone from the power menu: a boot screen, then the app starts fresh (cold start). Storage is kept. Returns the first screen. Logged in device_log as simulated.", { session: sid }, WR, async ({ session }) => { need(session); return result(session, await act({ session, action: "restart" })); });
|
|
304
|
+
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 act({ session, action: "device_log" }); if (!r.ok)
|
|
305
|
+
return result(session, r); return text(JSON.stringify({ note: "Entries marked source \"simulated\" are simulated: no real biometric check or money movement took place. Camera, microphone and location readings marked source \"real\" came from the browser's hardware.", capabilities: r.capabilities, log: r.log ?? [] }, null, 2)); });
|
|
306
|
+
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 act({ session, action: "device_reset", device })); });
|
|
307
|
+
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 }) => {
|
|
308
|
+
const rec = steps.get(session), s = sessions.get(session);
|
|
309
|
+
if (!rec || !s)
|
|
310
|
+
return result(session, { ok: false, code: "no_session", error: `no session '${session}': call open_app first` });
|
|
311
|
+
return text(exportTest(s.app, rec));
|
|
312
|
+
});
|
|
313
|
+
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
314
|
need(session);
|
|
315
|
+
const live = attached.get(session);
|
|
316
|
+
if (live) {
|
|
317
|
+
const s = sessions.get(session);
|
|
318
|
+
live.close();
|
|
319
|
+
attached.delete(session);
|
|
320
|
+
sessions.delete(session);
|
|
321
|
+
trees.delete(session);
|
|
322
|
+
steps.delete(session);
|
|
323
|
+
return text(JSON.stringify({ session, app: s?.app, mode: "attached", steps: s?.steps ?? 0, seconds: Math.round((Date.now() - (s?.startedAt ?? Date.now())) / 1000) }, null, 2));
|
|
324
|
+
}
|
|
325
|
+
// The simulator is the source of truth: end a session it lists even if this server did not open it; otherwise no_session.
|
|
326
|
+
const listed = await sim("/sessions", {});
|
|
327
|
+
if (!listed.ok)
|
|
328
|
+
return result(session, listed);
|
|
329
|
+
if (!(listed.sessions ?? []).some((x) => x.session === session))
|
|
330
|
+
return result(session, { ok: false, code: "no_session", error: `no session '${session}': call open_app first` });
|
|
130
331
|
const s = sessions.get(session) ?? { app: session.split("-")[0], steps: 0, startedAt: Date.now() };
|
|
131
332
|
sessions.delete(session);
|
|
333
|
+
trees.delete(session);
|
|
334
|
+
steps.delete(session);
|
|
132
335
|
let orders = [];
|
|
133
336
|
try {
|
|
134
337
|
orders = JSON.parse(readFileSync(`${process.env.PN_SIM_DIR ?? "/tmp/pn-ui"}/${session}.json`, "utf8")).orders;
|
|
@@ -136,12 +339,71 @@ Finish a task by checking the final screen says what you expected (for example "
|
|
|
136
339
|
catch { }
|
|
137
340
|
return text(JSON.stringify({ session, app: s.app, steps: s.steps, seconds: Math.round((Date.now() - s.startedAt) / 1000), orders }, null, 2));
|
|
138
341
|
});
|
|
342
|
+
// Record the phone frame page to an MP4 (one recording per server). Page: SHIPVO_FRAME_URL, default the simulator's root.
|
|
343
|
+
const recFail = (e) => { if (e instanceof RecorderError)
|
|
344
|
+
return result("", { ok: false, code: e.code, error: e.message }); throw e; };
|
|
345
|
+
server.tool("record_start", "Start recording the phone frame to an MP4 under .shipvo/recordings/. Use record_marker for chapters and record_stop to finish.", {
|
|
346
|
+
format: z.enum(["9:16", "16:9", "1:1"]).optional().describe("Video shape (default 9:16: 1080x1920; 16:9 is 1920x1080; 1:1 is 1080x1080)"),
|
|
347
|
+
fps: z.union([z.literal(30), z.literal(60)]).optional().describe("Frames per second (default 30)"),
|
|
348
|
+
title: z.string().optional().describe("Used in the file name"),
|
|
349
|
+
}, WR, async ({ format, fps, title }) => {
|
|
350
|
+
try {
|
|
351
|
+
const r = await recorder.start({ url: process.env.SHIPVO_FRAME_URL ?? SIM + "/", format, fps, title });
|
|
352
|
+
return { ...text(`recording to ${r.file} (${r.width}x${r.height})`), structuredContent: r };
|
|
353
|
+
}
|
|
354
|
+
catch (e) {
|
|
355
|
+
return recFail(e);
|
|
356
|
+
}
|
|
357
|
+
});
|
|
358
|
+
server.tool("record_marker", "Add a chapter at the current time of the running recording.", { caption: z.string().min(1) }, WR, async ({ caption }) => {
|
|
359
|
+
try {
|
|
360
|
+
const c = recorder.marker(caption);
|
|
361
|
+
return { ...text(`marker at ${c.t}s: ${c.caption}`), structuredContent: c };
|
|
362
|
+
}
|
|
363
|
+
catch (e) {
|
|
364
|
+
return recFail(e);
|
|
365
|
+
}
|
|
366
|
+
});
|
|
367
|
+
server.tool("record_stop", "Stop the recording and write the MP4. Returns the file, duration in seconds, size and chapters.", {}, WR, async () => {
|
|
368
|
+
try {
|
|
369
|
+
const r = await recorder.stop();
|
|
370
|
+
return { ...text(JSON.stringify(r, null, 2)), structuredContent: r };
|
|
371
|
+
}
|
|
372
|
+
catch (e) {
|
|
373
|
+
return recFail(e);
|
|
374
|
+
}
|
|
375
|
+
});
|
|
376
|
+
// Publish the app's web build to here.now (no account) through the CLI, which builds, wraps and checks for secrets.
|
|
377
|
+
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 () => {
|
|
378
|
+
if (ctx.scope && ctx.scope !== "owner" && ctx.scope !== "test")
|
|
379
|
+
throw new Error(`this key has scope "${ctx.scope}": it cannot publish a preview`);
|
|
380
|
+
const cwd = process.env.PN_APP_DIR || process.cwd();
|
|
381
|
+
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))));
|
|
382
|
+
const r = JSON.parse(out.trim().split("\n").pop() ?? "{}");
|
|
383
|
+
const sc = { url: r.url, claimUrl: (r.claimUrl ?? null), expiresAt: (r.expiresAt ?? null) };
|
|
384
|
+
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 };
|
|
385
|
+
});
|
|
139
386
|
// 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 }) => {
|
|
387
|
+
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
388
|
const r = await fetch(`${CONTROL}/app/${app}/api/describe`);
|
|
142
389
|
return text(await r.text());
|
|
143
390
|
});
|
|
144
|
-
|
|
391
|
+
// 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.
|
|
392
|
+
function agentRules() {
|
|
393
|
+
try {
|
|
394
|
+
const c = JSON.parse(readFileSync(join(process.env.PN_APP_DIR || process.cwd(), "shipvo.json"), "utf8"));
|
|
395
|
+
const h = {};
|
|
396
|
+
if (["ask", "deny", "allow"].includes(c.agentPolicy))
|
|
397
|
+
h["x-shipvo-agent-policy"] = c.agentPolicy;
|
|
398
|
+
if (Array.isArray(c.agentDeny) && c.agentDeny.length)
|
|
399
|
+
h["x-shipvo-agent-deny"] = c.agentDeny.map(String).join(",");
|
|
400
|
+
return h;
|
|
401
|
+
}
|
|
402
|
+
catch {
|
|
403
|
+
return {};
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
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
407
|
if (ctx.scope && ctx.scope !== "owner")
|
|
146
408
|
throw new Error(`this key has scope "${ctx.scope}": owner commands need an owner key`);
|
|
147
409
|
if (keyApps && !keyApps.includes(app))
|
|
@@ -149,7 +411,7 @@ Finish a task by checking the final screen says what you expected (for example "
|
|
|
149
411
|
const token = ctx.ownerToken || OWNER_TOKEN;
|
|
150
412
|
if (!token)
|
|
151
413
|
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}
|
|
414
|
+
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
415
|
return text(await r.text());
|
|
154
416
|
});
|
|
155
417
|
return server;
|
|
@@ -162,12 +424,14 @@ if (argi < 0) {
|
|
|
162
424
|
else {
|
|
163
425
|
const port = Number(process.argv[argi + 1] || 8787);
|
|
164
426
|
const keys = new Set((process.env.PN_API_KEYS ?? "").split(",").map((k) => k.trim()).filter(Boolean));
|
|
165
|
-
|
|
427
|
+
// Stateless (MCP 2026-07-28, and fine for 2025-era clients): no protocol session. Every request gets its own
|
|
428
|
+
// McpServer and transport, built from that request's x-shipvo-* headers, so scope never outlives the request.
|
|
429
|
+
// Simulator sessions are separate: they live in the simulator and travel as the `session` tool argument.
|
|
166
430
|
const httpServer = http.createServer(async (req, res) => {
|
|
167
431
|
const url = new URL(req.url ?? "/", "http://x");
|
|
168
432
|
if (url.pathname === "/health") {
|
|
169
433
|
res.writeHead(200, { "content-type": "application/json" });
|
|
170
|
-
return res.end(JSON.stringify({ ok: true
|
|
434
|
+
return res.end(JSON.stringify({ ok: true }));
|
|
171
435
|
}
|
|
172
436
|
if (url.pathname !== "/mcp") {
|
|
173
437
|
res.writeHead(404);
|
|
@@ -181,30 +445,63 @@ else {
|
|
|
181
445
|
return res.end(JSON.stringify({ error: "invalid_api_key" }));
|
|
182
446
|
}
|
|
183
447
|
}
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
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);
|
|
199
|
-
}
|
|
200
|
-
let body = undefined;
|
|
201
|
-
if (req.method === "POST") {
|
|
448
|
+
// No server-initiated streams and nothing to close without sessions.
|
|
449
|
+
if (req.method !== "POST") {
|
|
450
|
+
res.writeHead(405, { allow: "POST", "content-type": "application/json" });
|
|
451
|
+
return res.end(JSON.stringify({ jsonrpc: "2.0", error: { code: -32000, message: "Method not allowed: this server is stateless, use POST" }, id: null }));
|
|
452
|
+
}
|
|
453
|
+
let body;
|
|
454
|
+
try {
|
|
202
455
|
let raw = "";
|
|
203
456
|
for await (const ch of req)
|
|
204
457
|
raw += ch;
|
|
205
458
|
body = raw ? JSON.parse(raw) : undefined;
|
|
206
459
|
}
|
|
207
|
-
|
|
460
|
+
catch {
|
|
461
|
+
res.writeHead(400, { "content-type": "application/json" });
|
|
462
|
+
return res.end(JSON.stringify({ jsonrpc: "2.0", error: { code: -32700, message: "Parse error" }, id: null }));
|
|
463
|
+
}
|
|
464
|
+
// MCP 2026-07-28: answer server/discover, and refuse requests whose Mcp-Method/Mcp-Name headers disagree with the body.
|
|
465
|
+
const bad = (code, message, id = null) => { res.writeHead(400, { "content-type": "application/json" }); return res.end(JSON.stringify({ jsonrpc: "2.0", error: { code, message }, id })); };
|
|
466
|
+
const msg = body && typeof body === "object" && !Array.isArray(body) ? body : undefined;
|
|
467
|
+
if (msg && typeof msg.method === "string") {
|
|
468
|
+
const hm = req.headers["mcp-method"], hn = req.headers["mcp-name"];
|
|
469
|
+
const meta = (msg.params?._meta ?? {});
|
|
470
|
+
const pv = Object.entries(meta).find(([k]) => /protocolversion$/i.test(k))?.[1];
|
|
471
|
+
const era2026 = msg.method === "server/discover" || hm !== undefined || (typeof pv === "string" && pv >= "2026-07-28");
|
|
472
|
+
if (era2026) {
|
|
473
|
+
if (hm === undefined)
|
|
474
|
+
return bad(-32001, "Missing Mcp-Method header", msg.id ?? null);
|
|
475
|
+
if (hm !== msg.method)
|
|
476
|
+
return bad(-32001, `Header mismatch: Mcp-Method is '${hm}' but the body method is '${msg.method}'`, msg.id ?? null);
|
|
477
|
+
const nameKey = msg.method === "tools/call" || msg.method === "prompts/get" ? "name" : msg.method === "resources/read" ? "uri" : undefined;
|
|
478
|
+
const want = nameKey ? msg.params?.[nameKey] : undefined;
|
|
479
|
+
if (nameKey && hn === undefined)
|
|
480
|
+
return bad(-32001, "Missing Mcp-Name header", msg.id ?? null);
|
|
481
|
+
if (hn !== undefined && hn !== want)
|
|
482
|
+
return bad(-32001, `Header mismatch: Mcp-Name is '${hn}' but the body says '${String(want)}'`, msg.id ?? null);
|
|
483
|
+
}
|
|
484
|
+
if (msg.method === "server/discover") {
|
|
485
|
+
res.writeHead(200, { "content-type": "application/json" });
|
|
486
|
+
return res.end(JSON.stringify({ jsonrpc: "2.0", id: msg.id ?? null, result: { supportedVersions: SUPPORTED_VERSIONS, capabilities: { tools: { listChanged: false } }, serverInfo: { name: "shipvo", version: SERVER_VERSION } } }));
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
// Behind sim.shipvo.dev the Worker has checked the key and says which app and scope it grants; locally
|
|
490
|
+
// there are no such headers and every app is allowed. A client's mcp-session-id header is ignored.
|
|
491
|
+
const h = (n) => req.headers[n] || undefined;
|
|
492
|
+
const server = makeServer({ app: h("x-shipvo-app"), publicApps: h("x-shipvo-public"), scope: h("x-shipvo-scope"), ownerToken: h("x-shipvo-owner-token") });
|
|
493
|
+
const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined, enableJsonResponse: true });
|
|
494
|
+
res.on("close", () => { void transport.close(); void server.close(); });
|
|
495
|
+
try {
|
|
496
|
+
await server.connect(transport);
|
|
497
|
+
await transport.handleRequest(req, res, body);
|
|
498
|
+
}
|
|
499
|
+
catch (e) {
|
|
500
|
+
if (!res.headersSent) {
|
|
501
|
+
res.writeHead(500, { "content-type": "application/json" });
|
|
502
|
+
res.end(JSON.stringify({ jsonrpc: "2.0", error: { code: -32603, message: "Internal error" }, id: null }));
|
|
503
|
+
}
|
|
504
|
+
}
|
|
208
505
|
});
|
|
209
506
|
httpServer.listen(port, () => console.error(`Shipvo MCP over HTTP on http://localhost:${port}/mcp (${keys.size ? keys.size + " key(s)" : "no auth"})`));
|
|
210
507
|
}
|
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.
|
|
3
|
+
"version": "0.0.9",
|
|
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.",
|
|
@@ -15,7 +15,8 @@
|
|
|
15
15
|
"devDependencies": {
|
|
16
16
|
"typescript": "^5.6.3",
|
|
17
17
|
"@types/node": "^22.8.0",
|
|
18
|
-
"vitest": "^2.1.4"
|
|
18
|
+
"vitest": "^2.1.4",
|
|
19
|
+
"playwright": "1.63.0"
|
|
19
20
|
},
|
|
20
21
|
"keywords": [
|
|
21
22
|
"mcp",
|
|
@@ -45,6 +46,14 @@
|
|
|
45
46
|
"node": ">=20"
|
|
46
47
|
},
|
|
47
48
|
"mcpName": "io.github.shipvo/shipvo",
|
|
49
|
+
"peerDependencies": {
|
|
50
|
+
"playwright": ">=1.50"
|
|
51
|
+
},
|
|
52
|
+
"peerDependenciesMeta": {
|
|
53
|
+
"playwright": {
|
|
54
|
+
"optional": true
|
|
55
|
+
}
|
|
56
|
+
},
|
|
48
57
|
"scripts": {
|
|
49
58
|
"build": "tsc -p tsconfig.json",
|
|
50
59
|
"test": "vitest run"
|