@shipvo/mcp 0.0.8 → 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 CHANGED
@@ -35,6 +35,9 @@ The result gets a line `Waited: matched | timed out after <ms> ms` and `structur
35
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
36
  | `bad_url` | `open_url` got a URL the app cannot take. | Fix the URL and call again. |
37
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. |
38
41
  | `mock_invalid` | A network mock rule is not valid. | Fix the rule. |
39
42
 
40
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`.
@@ -76,7 +79,7 @@ The HTTP endpoint is stateless. There is no protocol session: each POST is handl
76
79
  ### Protocol versions
77
80
 
78
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.
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.
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.
81
84
 
82
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).
@@ -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
+ }
@@ -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
+ }
@@ -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
@@ -16,6 +16,8 @@ import { z } from "zod";
16
16
  import { classify, compactDiff, fieldValue } from "./change.js";
17
17
  import { exportTest } from "./export.js";
18
18
  import { findTarget } from "./record.js";
19
+ import { Recorder, RecorderError } from "./recorder.js";
20
+ import { connectDriver, findLiveRun } from "./attach.js";
19
21
  import { satisfied, sameTree, wantsMatch } from "./wait.js";
20
22
  const SIM = (process.env.PN_SIM ?? "http://localhost:4545").replace(/\/$/, "");
21
23
  const CONTROL = (process.env.PN_CONTROL ?? "https://api.shipvo.dev").replace(/\/$/, "");
@@ -59,6 +61,21 @@ async function sim(path, body) {
59
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.` };
60
62
  }
61
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 };
78
+ }
62
79
  const sessions = new Map();
63
80
  // Last tree per session id. Separate from `sessions` so it works for ids this server never opened.
64
81
  const trees = new Map();
@@ -103,8 +120,10 @@ const result = (sid, r) => {
103
120
  // Sessions live in the simulator; this map only tracks step counts. A client may reconnect between calls
104
121
  // (ChatGPT does), so an unknown id is forwarded and the simulator answers if it knows it.
105
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"];
106
125
  function makeServer(ctx = {}) {
107
- const server = new McpServer({ name: "shipvo", version: "0.0.4" }, { instructions: `You can use Shipvo apps the way a person uses them on a phone, without a phone.
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.
108
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.
109
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.
110
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.
@@ -136,13 +155,48 @@ Finish a task by checking the final screen says what you expected (for example "
136
155
  session: z.string().optional().describe("Session name (default: generated). One session is one phone."),
137
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."),
138
157
  dark: z.boolean().optional().describe("Dark mode"),
139
- }, WR, async ({ app, session, seed_cart, dark }) => {
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 }) => {
140
160
  const id = app.split("@")[0];
141
161
  if (!(await allowed(id)))
142
162
  return result("", { ok: false, code: "unknown_app", error: keyApps ? `this key opens: ${keyApps.join(", ")}` : `unknown app "${id}". Apps: ${Object.keys(await localApps()).join(", ")}` });
143
163
  if (ctx.scope === "read")
144
164
  throw new Error("this key is read-only: it cannot open sessions");
145
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
+ }
146
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 });
147
201
  if (r.code === "sim_timeout")
148
202
  return result(sid, r);
@@ -152,7 +206,7 @@ Finish a task by checking the final screen says what you expected (for example "
152
206
  trees.set(sid, r.screen);
153
207
  else
154
208
  trees.delete(sid);
155
- return text(`session: ${sid}\napp: ${id} release: ${r.release ?? "local"}\n\n${r.screen ?? ""}`);
209
+ return text(`session: ${sid}\napp: ${id} release: ${r.release ?? "local"}\nmode: headless session${attachNote}\n\n${r.screen ?? ""}`);
156
210
  });
157
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 () => {
158
212
  const r = await sim("/sessions", {});
@@ -164,11 +218,12 @@ Finish a task by checking the final screen says what you expected (for example "
164
218
  const sid = z.string().describe("Session from open_app");
165
219
  const compact = z.boolean().optional().describe("When the change is 'content changed', return only the added/removed lines, not the whole screen");
166
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.";
167
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() })
168
223
  .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.");
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.");
170
225
  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");
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.");
172
227
  const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
173
228
  // Poll the screen after an action. Returns the latest result and what happened. On timeout the action result is kept as is.
174
229
  async function waiting(session, r, w) {
@@ -176,7 +231,7 @@ Finish a task by checking the final screen says what you expected (for example "
176
231
  return { r };
177
232
  const t0 = Date.now(), limit = w.timeout_ms ?? 2000, left = () => limit - (Date.now() - t0);
178
233
  let cur = r, matched = true;
179
- const read = async () => { const n = await sim("/act", { session, action: "screen" }); if (n.ok && n.screen != null)
234
+ const read = async () => { const n = await act({ session, action: "screen" }); if (n.ok && n.screen != null)
180
235
  cur = { ...cur, screen: n.screen }; return n.ok && n.screen != null; };
181
236
  if (w.wait_for) {
182
237
  matched = satisfied(cur.screen, w.wait_for);
@@ -200,15 +255,15 @@ Finish a task by checking the final screen says what you expected (for example "
200
255
  return { r: cur, waited: { matched, ms: Date.now() - t0 } };
201
256
  }
202
257
  const ref = z.string().regex(/^e\d+$/).describe("Element ref from the screen, e.g. e42");
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)
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)
204
259
  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" } }); });
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" } }); });
209
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 }) => {
210
265
  need(session);
211
- const r = await sim("/act", { session, action: "screenshot" });
266
+ const r = await act({ session, action: "screenshot" });
212
267
  if (!r.ok)
213
268
  return result(session, r);
214
269
  if (!r.path)
@@ -227,23 +282,28 @@ Finish a task by checking the final screen says what you expected (for example "
227
282
  biometrics: z.object({ enrolled: z.boolean().optional(), nextResult: z.enum(["success", "fail", "cancel"]).optional() }).optional(),
228
283
  payment: z.object({ nextResult: z.enum(["approve", "decline", "timeout", "insufficient_funds"]).optional(), pin: z.string().optional() }).optional(),
229
284
  online: z.boolean().optional(),
230
- }, WR, async ({ session, ...device }) => { need(session); const r = await sim("/act", { session, action: "set_device", device }); if (r.ok)
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)
231
288
  steps.get(session)?.push({ action: "set_device", device: device.payment?.pin === undefined ? device : { ...device, payment: { ...device.payment, pin: undefined } } }); return result(session, r); });
232
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 }) => {
233
290
  need(session);
234
- const r = await sim("/act", { session, action: "push_notification", title, body, data });
291
+ const r = await act({ session, action: "push_notification", title, body, data });
235
292
  // The simulator answers ok when permission blocks the push, so say so: otherwise it looks like a delivery.
236
293
  if (r.ok && r.delivered === false)
237
294
  return result(session, { ...r, screen: `Not delivered: notification permission is not granted (use set_device permissions.notifications).\n\n${r.screen ?? ""}` });
238
295
  return result(session, r);
239
296
  });
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)
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)
241
298
  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 })); });
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 })); });
247
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 }) => {
248
308
  const rec = steps.get(session), s = sessions.get(session);
249
309
  if (!rec || !s)
@@ -252,6 +312,16 @@ Finish a task by checking the final screen says what you expected (for example "
252
312
  });
253
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 }) => {
254
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
+ }
255
325
  // The simulator is the source of truth: end a session it lists even if this server did not open it; otherwise no_session.
256
326
  const listed = await sim("/sessions", {});
257
327
  if (!listed.ok)
@@ -269,6 +339,40 @@ Finish a task by checking the final screen says what you expected (for example "
269
339
  catch { }
270
340
  return text(JSON.stringify({ session, app: s.app, steps: s.steps, seconds: Math.round((Date.now() - s.startedAt) / 1000), orders }, null, 2));
271
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
+ });
272
376
  // Publish the app's web build to here.now (no account) through the CLI, which builds, wraps and checks for secrets.
273
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 () => {
274
378
  if (ctx.scope && ctx.scope !== "owner" && ctx.scope !== "test")
@@ -357,6 +461,31 @@ else {
357
461
  res.writeHead(400, { "content-type": "application/json" });
358
462
  return res.end(JSON.stringify({ jsonrpc: "2.0", error: { code: -32700, message: "Parse error" }, id: null }));
359
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
+ }
360
489
  // Behind sim.shipvo.dev the Worker has checked the key and says which app and scope it grants; locally
361
490
  // there are no such headers and every app is allowed. A client's mcp-session-id header is ignored.
362
491
  const h = (n) => req.headers[n] || undefined;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@shipvo/mcp",
3
- "version": "0.0.8",
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"