@shipvo/mcp 0.0.8 → 0.0.10
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 +14 -5
- package/dist/attach.d.ts +30 -0
- package/dist/attach.js +85 -0
- package/dist/recorder.d.ts +96 -0
- package/dist/recorder.js +410 -0
- package/dist/server.js +1636 -346
- package/dist/wait.d.ts +0 -1
- package/dist/wait.js +0 -1
- package/package.json +15 -3
- package/skills/shipvo-mcp/SKILL.md +32 -0
package/README.md
CHANGED
|
@@ -32,16 +32,25 @@ The result gets a line `Waited: matched | timed out after <ms> ms` and `structur
|
|
|
32
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
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
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
|
|
35
|
+
| `needs_confirm` | An owner command that cannot be undone did nothing and returned a plan and a one-time `challenge`. | Show the plan to the owner and ask them to approve it at `challenge.approval_url` in the admin. Then call again with the same args and `approval: <challenge id>`. `confirm: true` does nothing. |
|
|
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. |
|
|
41
|
+
| `app_not_foreground` | The home screen or app switcher is showing, so tap, type, scroll, back, `open_url` and `open_notification` were not sent. | Call `resume_app`. |
|
|
42
|
+
| `recents_not_open` | `kill_app` was called without the app switcher open. | Call `open_recents` first. |
|
|
38
43
|
| `mock_invalid` | A network mock rule is not valid. | Fix the rule. |
|
|
39
44
|
|
|
40
|
-
Tools: `list_apps`, `open_app`, `screen`, `tap`, `type`, `scroll`, `back`, `screenshot`, `set_device`, `push_notification`, `notifications`, `open_notification`, `device_log`, `export_test`, `end_session`, `owner_describe`, `owner_command
|
|
45
|
+
Tools: `list_apps`, `open_app`, `attach_session`, `screen`, `tap`, `type`, `scroll`, `back`, `screenshot`, `set_device`, `push_notification`, `notifications`, `open_notification`, `device_log`, `export_test`, `end_session`, `owner_describe`, `owner_command`, `approvals_list`, `approval_approve` (both need an owner token, and the MCP agent counts as an agent: it cannot approve an agent's hold).
|
|
41
46
|
|
|
42
47
|
`export_test { session }` is read-only. It turns the taps, typing, scrolls, `back` and `set_device` calls recorded in a session into a `@shipvo/test` script and returns the source. Targets are named by visible text; an ambiguous target becomes a `// TODO` comment, and a password-like field uses `process.env.SHIPVO_TEST_<LABEL>` so the typed value is never in the output. Only sessions opened through this server are recorded; an unknown session returns `no_session`. Steps are dropped when the session ends. `payment.pin` is removed from recorded `set_device` calls. Only labels and roles that look like a password, passcode or PIN are treated as secret; other secret-like fields (card number, token) are exported as typed. `type` with `enter: true` and `scroll` with a `ref` are recorded without the Enter or the ref, so a replay can differ from the session.
|
|
43
48
|
|
|
44
|
-
|
|
49
|
+
## Record a demo video
|
|
50
|
+
|
|
51
|
+
`record_start`, `record_marker` and `record_stop` write an MP4 of the phone to `.shipvo/recordings/`. `record_stop` takes `music: true` (a ducked music bed and a `.credits.json`) and `narration` (a path to a wav). It needs ffmpeg and `playwright`. Music works only from a repo checkout, or when `SHIPVO_BGMUSIC` points at `scripts/video/bgmusic.py`; otherwise `record_stop` returns `music_unavailable` (and `music_failed` if the mix fails). A runnable example is in [`docs/record-a-demo-video.md`](../../docs/record-a-demo-video.md).
|
|
52
|
+
|
|
53
|
+
**Owner commands that cannot be undone need the owner's approval.** `publish`, price changes, offers, deletes and API keys first return `{ code: "needs_confirm", plan, challenge }` and change nothing. The owner approves the challenge in the admin (an agent cannot), then `owner_command` is called again with the same args and `approval: "<challenge id>"`. An approval works once, for 10 minutes, for exactly those arguments. `approvals_list` shows pending challenges. With `agentPolicy: "allow"` agents act without asking.
|
|
45
54
|
|
|
46
55
|
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).
|
|
47
56
|
|
|
@@ -76,7 +85,7 @@ The HTTP endpoint is stateless. There is no protocol session: each POST is handl
|
|
|
76
85
|
### Protocol versions
|
|
77
86
|
|
|
78
87
|
- **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`.
|
|
80
|
-
- This package uses `@modelcontextprotocol/sdk` 1.x. Stdio is unchanged.
|
|
88
|
+
- **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. Header mismatches return `-32020`; naming a protocol version we do not support returns `-32022`. 2026-era results carry `resultType: "complete"`, and `tools/list` results also carry `ttlMs` and `cacheScope: "private"` (the list depends on the per-request scope headers). 2025-era responses have none of these. `tools/list` is sorted by tool name on stdio and HTTP.
|
|
89
|
+
- 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
90
|
|
|
82
91
|
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 | undefined, 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,85 @@
|
|
|
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" && (app === undefined || 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) => {
|
|
53
|
+
clearTimeout(t);
|
|
54
|
+
// The person using the phone paused the agent. Say what to do instead of retrying.
|
|
55
|
+
res(m.code === "taken_over" ? { ...m, ok: false, error: "the person took over: they are using the live phone now. Stop acting and ask them to press Hand back, then call screen to see where the app is." } : m);
|
|
56
|
+
});
|
|
57
|
+
ws.send(JSON.stringify({ ...args, id, type }));
|
|
58
|
+
}),
|
|
59
|
+
};
|
|
60
|
+
const timer = setTimeout(() => { ws.close(); fail(new Error("timed out connecting to the shipvo run phone")); }, ms);
|
|
61
|
+
ws.onopen = () => { opened = true; clearTimeout(timer); ok(d); };
|
|
62
|
+
ws.onmessage = (e) => {
|
|
63
|
+
try {
|
|
64
|
+
const m = JSON.parse(String(e.data));
|
|
65
|
+
if (m.id !== undefined) {
|
|
66
|
+
pending.get(m.id)?.(m);
|
|
67
|
+
pending.delete(m.id);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
catch { }
|
|
71
|
+
};
|
|
72
|
+
ws.onclose = () => {
|
|
73
|
+
d.closed = true;
|
|
74
|
+
for (const [id, f] of pending) {
|
|
75
|
+
f({ ok: false, code: "attach_closed", error: "the shipvo run phone closed the connection: call open_app again" });
|
|
76
|
+
pending.delete(id);
|
|
77
|
+
}
|
|
78
|
+
if (!opened) {
|
|
79
|
+
clearTimeout(timer);
|
|
80
|
+
fail(new Error("could not connect to the shipvo run phone"));
|
|
81
|
+
}
|
|
82
|
+
};
|
|
83
|
+
ws.onerror = () => { };
|
|
84
|
+
});
|
|
85
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
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
|
+
credits?: string;
|
|
17
|
+
notes?: string[];
|
|
18
|
+
lumi?: boolean;
|
|
19
|
+
lumi_lines?: number;
|
|
20
|
+
};
|
|
21
|
+
export declare class RecorderError extends Error {
|
|
22
|
+
code: string;
|
|
23
|
+
constructor(code: string, message: string);
|
|
24
|
+
}
|
|
25
|
+
export declare const MAX_IDLE_S = 0.6;
|
|
26
|
+
export type Background = string;
|
|
27
|
+
export type Point = {
|
|
28
|
+
x: number;
|
|
29
|
+
y: number;
|
|
30
|
+
};
|
|
31
|
+
export type Step = {
|
|
32
|
+
at: number;
|
|
33
|
+
text: string;
|
|
34
|
+
};
|
|
35
|
+
/** Lines Lumi speaks: one per step caption, dropped when the previous line would still be playing (about 0.09 s a character plus a 0.6 s gap). Times are seconds on the final video. */
|
|
36
|
+
export declare function voiceCues(steps: Step[], t0: number, shift: (ms: number) => number, total: number): [number, string, string][];
|
|
37
|
+
export declare class Recorder {
|
|
38
|
+
private browser?;
|
|
39
|
+
private page?;
|
|
40
|
+
private loop?;
|
|
41
|
+
private t0;
|
|
42
|
+
private running;
|
|
43
|
+
private starting;
|
|
44
|
+
private stopping;
|
|
45
|
+
private ffErr;
|
|
46
|
+
private frames;
|
|
47
|
+
private framesDir;
|
|
48
|
+
private chapters;
|
|
49
|
+
private file;
|
|
50
|
+
private raw;
|
|
51
|
+
private size;
|
|
52
|
+
private fps;
|
|
53
|
+
private showTaps;
|
|
54
|
+
private lumi;
|
|
55
|
+
private lumiVoice;
|
|
56
|
+
private lumiOn;
|
|
57
|
+
private acts;
|
|
58
|
+
get active(): boolean;
|
|
59
|
+
start(o: {
|
|
60
|
+
url: string;
|
|
61
|
+
format?: Format;
|
|
62
|
+
fps?: 30 | 60;
|
|
63
|
+
title?: string;
|
|
64
|
+
dir?: string;
|
|
65
|
+
showTaps?: boolean;
|
|
66
|
+
background?: Background;
|
|
67
|
+
lumi?: boolean;
|
|
68
|
+
lumiVoice?: boolean;
|
|
69
|
+
}): Promise<{
|
|
70
|
+
file: string;
|
|
71
|
+
width: number;
|
|
72
|
+
height: number;
|
|
73
|
+
}>;
|
|
74
|
+
private grab;
|
|
75
|
+
/** True while a recording is running and taps should be drawn: callers use it to skip the extra locate call. */
|
|
76
|
+
get drawing(): boolean;
|
|
77
|
+
/** Soft finger dot and ripple at p (0..1 of the phone). No-op when not recording or show_taps is false. */
|
|
78
|
+
tap(p: Point): Promise<void>;
|
|
79
|
+
/** Finger trail from a to b (0..1 of the phone), for swipes and scrolls. */
|
|
80
|
+
trail(a: Point, b: Point): Promise<void>;
|
|
81
|
+
private draw;
|
|
82
|
+
/** The agent did something now. Gaps between actions longer than MAX_IDLE_S are cut from the video. */
|
|
83
|
+
action(): void;
|
|
84
|
+
/** An action that takes time. Its whole span is kept; call the returned function when it ends. */
|
|
85
|
+
actionSpan(): () => void;
|
|
86
|
+
marker(caption: string): Chapter;
|
|
87
|
+
stop(o?: {
|
|
88
|
+
music?: boolean;
|
|
89
|
+
narration?: string;
|
|
90
|
+
}): Promise<Recording>;
|
|
91
|
+
/** Encode the saved frames, each shown from the time it was taken until the next one. Resolves to ffmpeg's exit code (-1 if it did not start). */
|
|
92
|
+
private encode;
|
|
93
|
+
private cleanup;
|
|
94
|
+
}
|
|
95
|
+
/** Speak the cues with Lumi's voice: scripts/video/dialogue.py with the `energetic` preset. SHIPVO_DIALOGUE points at the script. Returns the wav path (next to the video). */
|
|
96
|
+
export declare function lumiVoice(file: string, cues: [number, string, string][], total: number): string;
|