@shipvo/mcp 0.0.1 → 0.0.2

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.
Files changed (3) hide show
  1. package/README.md +16 -0
  2. package/dist/server.js +125 -65
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -15,3 +15,19 @@ Tools: `list_apps`, `open_app`, `screen`, `tap`, `type`, `scroll`, `back`, `scre
15
15
  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
16
 
17
17
  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
+
19
+ ## Remote (Streamable HTTP)
20
+
21
+ Run the server where the simulator runs and put it behind HTTPS (a Cloudflare Tunnel is enough):
22
+
23
+ ```
24
+ PN_API_KEYS="key-for-claude,key-for-pipilot" shipvo-mcp --http 8787
25
+ ```
26
+
27
+ Clients connect with a URL instead of a command:
28
+
29
+ ```json
30
+ { "mcpServers": { "shipvo": { "url": "https://sim.shipvo.dev/mcp", "headers": { "Authorization": "Bearer key-for-claude" } } } }
31
+ ```
32
+
33
+ Each client connection gets its own MCP session; each `open_app` gets its own phone. Requests without a valid key are refused. The hosted version at `sim.shipvo.dev` will issue keys per account with scopes (test, owner, read).
package/dist/server.js CHANGED
@@ -1,10 +1,15 @@
1
1
  #!/usr/bin/env node
2
2
  // Shipvo MCP: agents use Shipvo apps with no phone.
3
3
  // PN_SIM=http://localhost:4545 PN_CONTROL=https://purenative-control.purenative.workers.dev PN_OWNER_TOKEN=... shipvo-mcp
4
+ // Remote (Streamable HTTP, what Claude, ChatGPT, Cursor connect to over the network):
5
+ // shipvo-mcp --http 8787 with PN_API_KEYS="key1,key2" (Authorization: Bearer <key>) → POST/GET/DELETE /mcp
4
6
  // Tools: list_apps, open_app, screen, tap, type, scroll, back, screenshot, end_session (UI simulator);
5
7
  // owner_describe, owner_command (the app's live content, prices, orders: owner token required).
6
8
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
7
9
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
10
+ import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
11
+ import http from "node:http";
12
+ import { randomUUID } from "node:crypto";
8
13
  import { readFileSync } from "node:fs";
9
14
  import { z } from "zod";
10
15
  const SIM = (process.env.PN_SIM ?? "http://localhost:4545").replace(/\/$/, "");
@@ -25,72 +30,127 @@ const result = (sid, r) => { const s = sessions.get(sid); if (s)
25
30
  s.steps++; return text(r.ok ? (r.screen ?? "") : `Error: ${r.error}\n\nScreen now:\n${r.screen ?? ""}`); };
26
31
  const need = (sid) => { if (!sessions.has(sid))
27
32
  throw new Error(`no session "${sid}". Call open_app first.`); };
28
- const server = new McpServer({ name: "shipvo", version: "0.0.1" }, { instructions: `You can use Shipvo apps the way a person uses them on a phone, without a phone.
33
+ function makeServer() {
34
+ const server = new McpServer({ name: "shipvo", version: "0.0.1" }, { instructions: `You can use Shipvo apps the way a person uses them on a phone, without a phone.
29
35
  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.
30
36
  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.
31
37
  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.
32
38
  Finish a task by checking the final screen says what you expected (for example "Order placed"). Call end_session when done to get a report.` });
33
- server.tool("list_apps", "Apps this server can open, with what each one does.", {}, async () => text(Object.entries(APPS).map(([id, a]) => `${id}: ${a.name}. ${a.description}`).join("\n")));
34
- server.tool("open_app", "Open an app in a fresh phone-sized session and return its first screen.", {
35
- app: z.string().describe("App id from list_apps, e.g. 'tide'"),
36
- session: z.string().optional().describe("Session name (default: generated). One session is one phone."),
37
- seed_cart: z.array(z.object({ id: z.string(), qty: z.number().int().positive() })).optional().describe("Start with these items already in the cart (test shortcuts)."),
38
- dark: z.boolean().optional().describe("Dark mode"),
39
- }, async ({ app, session, seed_cart, dark }) => {
40
- if (!APPS[app])
41
- throw new Error(`unknown app "${app}". Apps: ${Object.keys(APPS).join(", ")}`);
42
- const sid = session ?? `${app}-${Date.now().toString(36)}`;
43
- const r = await sim("/session", { session: sid, seed_cart, dark });
44
- sessions.set(sid, { app, steps: 0, startedAt: Date.now() });
45
- return text(`session: ${sid}\n\n${r.screen ?? ""}`);
46
- });
47
- const sid = z.string().describe("Session from open_app");
48
- const ref = z.string().regex(/^e\d+$/).describe("Element ref from the screen, e.g. e42");
49
- server.tool("screen", "The current screen as an accessibility tree with refs.", { session: sid }, async ({ session }) => { need(session); return result(session, await sim("/act", { session, action: "screen" })); });
50
- server.tool("tap", "Tap a button, link or choice by ref. Returns the new screen.", { session: sid, ref }, async ({ session, ref }) => { need(session); return result(session, await sim("/act", { session, action: "tap", ref })); });
51
- server.tool("type", "Type into a text field by ref (replaces its content). Returns the new screen.", { session: sid, ref, text: z.string(), enter: z.boolean().optional().describe("Press Enter afterwards") }, async ({ session, ref, text: t, enter }) => { need(session); return result(session, await sim("/act", { session, action: "type", ref, text: t, enter })); });
52
- server.tool("scroll", "Scroll the page (down/up) or a horizontal rail (right/left, give the rail's ref). Returns the new screen.", { session: sid, direction: z.enum(["down", "up", "left", "right"]), ref: ref.optional().describe("Scroll inside this element (a rail or list); omit for the page") }, async ({ session, direction, ref }) => { need(session); return result(session, await sim("/act", { session, action: "scroll", direction, ref })); });
53
- server.tool("back", "Go back one screen.", { session: sid }, async ({ session }) => { need(session); return result(session, await sim("/act", { session, action: "back" })); });
54
- server.tool("screenshot", "A PNG of the phone screen (for checking layout, colours, images). The tree from `screen` is usually enough to act.", { session: sid }, async ({ session }) => {
55
- need(session);
56
- const r = await sim("/act", { session, action: "screenshot" });
57
- if (!r.ok || !r.path)
58
- throw new Error(r.error ?? "no screenshot");
59
- return { content: [{ type: "image", data: readFileSync(r.path).toString("base64"), mimeType: "image/png" }, { type: "text", text: r.screen ?? "" }] };
60
- });
61
- // The simulated device: what the phone has, and what happens to it.
62
- 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.", {
63
- session: sid,
64
- location: z.object({ latitude: z.number(), longitude: z.number(), accuracy: z.number().optional() }).optional(),
65
- camera: z.object({ barcode: z.object({ type: z.string(), data: z.string() }).nullable().optional(), delayMs: z.number().optional() }).optional(),
66
- 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(),
67
- biometrics: z.object({ enrolled: z.boolean().optional(), nextResult: z.enum(["success", "fail", "cancel"]).optional() }).optional(),
68
- payment: z.object({ nextResult: z.enum(["approve", "decline", "timeout", "insufficient_funds"]).optional(), pin: z.string().optional() }).optional(),
69
- online: z.boolean().optional(),
70
- }, async ({ session, ...device }) => { need(session); return result(session, await sim("/act", { session, action: "set_device", device })); });
71
- server.tool("push_notification", "Deliver a push notification to the simulated phone; it appears as a banner the agent can tap.", { session: sid, title: z.string(), body: z.string(), data: z.record(z.unknown()).optional() }, async ({ session, title, body, data }) => { need(session); return result(session, await sim("/act", { session, action: "push_notification", title, body, data })); });
72
- server.tool("device_log", "What the app did with the device in this session: permission prompts and answers, location reads, scans, fingerprint checks, payments, notifications, shares.", { session: sid }, async ({ session }) => { need(session); const r = await sim("/act", { session, action: "device_log" }); return text(JSON.stringify({ note: "Everything here is simulated: no real biometric check or money movement took place.", capabilities: r.capabilities, log: r.log ?? [] }, null, 2)); });
73
- server.tool("device_reset", "Put the simulated phone back to a known state (permissions undetermined, default location, nothing in front of the camera, log cleared), optionally with a new profile.", { session: sid, platform: z.enum(["android", "ios"]).optional(), api: z.number().optional() }, async ({ session, ...device }) => { need(session); return result(session, await sim("/act", { session, action: "device_reset", device })); });
74
- server.tool("end_session", "Close the session and get a report: steps taken, and the orders the app sent to its backend during the session.", { session: sid }, async ({ session }) => {
75
- need(session);
76
- const s = sessions.get(session);
77
- sessions.delete(session);
78
- let orders = [];
79
- try {
80
- orders = JSON.parse(readFileSync(`${process.env.PN_SIM_DIR ?? "/tmp/pn-ui"}/${session}.json`, "utf8")).orders;
81
- }
82
- catch { }
83
- return text(JSON.stringify({ session, app: s.app, steps: s.steps, seconds: Math.round((Date.now() - s.startedAt) / 1000), orders }, null, 2));
84
- });
85
- // Owner side: the same typed commands the admin at /app/<id>/admin uses.
86
- 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 }) => {
87
- const r = await fetch(`${CONTROL}/app/${app}/api/describe`);
88
- return text(await r.text());
89
- });
90
- server.tool("owner_command", "Run an owner command against the live app (changes are audited; `publish` makes them live on every phone within seconds).", { app: z.string(), command: z.string(), args: z.record(z.unknown()).optional() }, async ({ app, command, args }) => {
91
- if (!OWNER_TOKEN)
92
- throw new Error("PN_OWNER_TOKEN is not set: owner commands are disabled.");
93
- const r = await fetch(`${CONTROL}/app/${app}/api/command`, { method: "POST", headers: { "content-type": "application/json", authorization: `Bearer ${OWNER_TOKEN}` }, body: JSON.stringify({ command, args: args ?? {} }) });
94
- return text(await r.text());
95
- });
96
- await server.connect(new StdioServerTransport());
39
+ server.tool("list_apps", "Apps this server can open, with what each one does.", {}, async () => text(Object.entries(APPS).map(([id, a]) => `${id}: ${a.name}. ${a.description}`).join("\n")));
40
+ server.tool("open_app", "Open an app in a fresh phone-sized session and return its first screen.", {
41
+ app: z.string().describe("App id from list_apps, optionally with a release: 'tide' (local build), 'tide@latest' or 'tide@r13' (the published release phones run)"),
42
+ session: z.string().optional().describe("Session name (default: generated). One session is one phone."),
43
+ seed_cart: z.array(z.object({ id: z.string(), qty: z.number().int().positive() })).optional().describe("Start with these items already in the cart (test shortcuts)."),
44
+ dark: z.boolean().optional().describe("Dark mode"),
45
+ }, async ({ app, session, seed_cart, dark }) => {
46
+ const id = app.split("@")[0];
47
+ if (!APPS[id])
48
+ throw new Error(`unknown app "${id}". Apps: ${Object.keys(APPS).join(", ")}`);
49
+ const sid = session ?? `${id}-${Date.now().toString(36)}`;
50
+ const r = await sim("/session", { session: sid, app, seed_cart, dark });
51
+ sessions.set(sid, { app: id, steps: 0, startedAt: Date.now() });
52
+ return text(`session: ${sid}\napp: ${id} release: ${r.release ?? "local"}\n\n${r.screen ?? ""}`);
53
+ });
54
+ const sid = z.string().describe("Session from open_app");
55
+ const ref = z.string().regex(/^e\d+$/).describe("Element ref from the screen, e.g. e42");
56
+ server.tool("screen", "The current screen as an accessibility tree with refs.", { session: sid }, async ({ session }) => { need(session); return result(session, await sim("/act", { session, action: "screen" })); });
57
+ server.tool("tap", "Tap a button, link or choice by ref. Returns the new screen.", { session: sid, ref }, async ({ session, ref }) => { need(session); return result(session, await sim("/act", { session, action: "tap", ref })); });
58
+ server.tool("type", "Type into a text field by ref (replaces its content). Returns the new screen.", { session: sid, ref, text: z.string(), enter: z.boolean().optional().describe("Press Enter afterwards") }, async ({ session, ref, text: t, enter }) => { need(session); return result(session, await sim("/act", { session, action: "type", ref, text: t, enter })); });
59
+ server.tool("scroll", "Scroll the page (down/up) or a horizontal rail (right/left, give the rail's ref). Returns the new screen.", { session: sid, direction: z.enum(["down", "up", "left", "right"]), ref: ref.optional().describe("Scroll inside this element (a rail or list); omit for the page") }, async ({ session, direction, ref }) => { need(session); return result(session, await sim("/act", { session, action: "scroll", direction, ref })); });
60
+ server.tool("back", "Go back one screen.", { session: sid }, async ({ session }) => { need(session); return result(session, await sim("/act", { session, action: "back" })); });
61
+ server.tool("screenshot", "A PNG of the phone screen (for checking layout, colours, images). The tree from `screen` is usually enough to act.", { session: sid }, async ({ session }) => {
62
+ need(session);
63
+ const r = await sim("/act", { session, action: "screenshot" });
64
+ if (!r.ok || !r.path)
65
+ throw new Error(r.error ?? "no screenshot");
66
+ return { content: [{ type: "image", data: readFileSync(r.path).toString("base64"), mimeType: "image/png" }, { type: "text", text: r.screen ?? "" }] };
67
+ });
68
+ // The simulated device: what the phone has, and what happens to it.
69
+ 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.", {
70
+ session: sid,
71
+ location: z.object({ latitude: z.number(), longitude: z.number(), accuracy: z.number().optional() }).optional(),
72
+ camera: z.object({ barcode: z.object({ type: z.string(), data: z.string() }).nullable().optional(), delayMs: z.number().optional() }).optional(),
73
+ 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(),
74
+ biometrics: z.object({ enrolled: z.boolean().optional(), nextResult: z.enum(["success", "fail", "cancel"]).optional() }).optional(),
75
+ payment: z.object({ nextResult: z.enum(["approve", "decline", "timeout", "insufficient_funds"]).optional(), pin: z.string().optional() }).optional(),
76
+ online: z.boolean().optional(),
77
+ }, async ({ session, ...device }) => { need(session); return result(session, await sim("/act", { session, action: "set_device", device })); });
78
+ server.tool("push_notification", "Deliver a push notification to the simulated phone; it appears as a banner the agent can tap.", { session: sid, title: z.string(), body: z.string(), data: z.record(z.unknown()).optional() }, async ({ session, title, body, data }) => { need(session); return result(session, await sim("/act", { session, action: "push_notification", title, body, data })); });
79
+ server.tool("device_log", "What the app did with the device in this session: permission prompts and answers, location reads, scans, fingerprint checks, payments, notifications, shares.", { session: sid }, async ({ session }) => { need(session); const r = await sim("/act", { session, action: "device_log" }); return text(JSON.stringify({ note: "Everything here is simulated: no real biometric check or money movement took place.", capabilities: r.capabilities, log: r.log ?? [] }, null, 2)); });
80
+ server.tool("device_reset", "Put the simulated phone back to a known state (permissions undetermined, default location, nothing in front of the camera, log cleared), optionally with a new profile.", { session: sid, platform: z.enum(["android", "ios"]).optional(), api: z.number().optional() }, async ({ session, ...device }) => { need(session); return result(session, await sim("/act", { session, action: "device_reset", device })); });
81
+ server.tool("end_session", "Close the session and get a report: steps taken, and the orders the app sent to its backend during the session.", { session: sid }, async ({ session }) => {
82
+ need(session);
83
+ const s = sessions.get(session);
84
+ sessions.delete(session);
85
+ let orders = [];
86
+ try {
87
+ orders = JSON.parse(readFileSync(`${process.env.PN_SIM_DIR ?? "/tmp/pn-ui"}/${session}.json`, "utf8")).orders;
88
+ }
89
+ catch { }
90
+ return text(JSON.stringify({ session, app: s.app, steps: s.steps, seconds: Math.round((Date.now() - s.startedAt) / 1000), orders }, null, 2));
91
+ });
92
+ // Owner side: the same typed commands the admin at /app/<id>/admin uses.
93
+ 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 }) => {
94
+ const r = await fetch(`${CONTROL}/app/${app}/api/describe`);
95
+ return text(await r.text());
96
+ });
97
+ server.tool("owner_command", "Run an owner command against the live app (changes are audited; `publish` makes them live on every phone within seconds).", { app: z.string(), command: z.string(), args: z.record(z.unknown()).optional() }, async ({ app, command, args }) => {
98
+ if (!OWNER_TOKEN)
99
+ throw new Error("PN_OWNER_TOKEN is not set: owner commands are disabled.");
100
+ const r = await fetch(`${CONTROL}/app/${app}/api/command`, { method: "POST", headers: { "content-type": "application/json", authorization: `Bearer ${OWNER_TOKEN}` }, body: JSON.stringify({ command, args: args ?? {} }) });
101
+ return text(await r.text());
102
+ });
103
+ return server;
104
+ }
105
+ // ---- transport: stdio (local) or Streamable HTTP (remote) ----
106
+ const argi = process.argv.indexOf("--http");
107
+ if (argi < 0) {
108
+ await makeServer().connect(new StdioServerTransport());
109
+ }
110
+ else {
111
+ const port = Number(process.argv[argi + 1] || 8787);
112
+ const keys = new Set((process.env.PN_API_KEYS ?? "").split(",").map((k) => k.trim()).filter(Boolean));
113
+ const transports = new Map();
114
+ const httpServer = http.createServer(async (req, res) => {
115
+ const url = new URL(req.url ?? "/", "http://x");
116
+ if (url.pathname === "/health") {
117
+ res.writeHead(200, { "content-type": "application/json" });
118
+ return res.end(JSON.stringify({ ok: true, sessions: transports.size }));
119
+ }
120
+ if (url.pathname !== "/mcp") {
121
+ res.writeHead(404);
122
+ return res.end();
123
+ }
124
+ // Keys: Bearer <key>. With no keys configured, the server is open (local use behind a tunnel you control).
125
+ if (keys.size) {
126
+ const k = (req.headers.authorization ?? "").replace(/^Bearer\s+/i, "");
127
+ if (!keys.has(k)) {
128
+ res.writeHead(401, { "content-type": "application/json" });
129
+ return res.end(JSON.stringify({ error: "invalid_api_key" }));
130
+ }
131
+ }
132
+ const sid = req.headers["mcp-session-id"];
133
+ let transport = sid ? transports.get(sid) : undefined;
134
+ if (!transport) {
135
+ if (req.method !== "POST") {
136
+ res.writeHead(400);
137
+ return res.end("missing session");
138
+ }
139
+ transport = new StreamableHTTPServerTransport({ sessionIdGenerator: () => randomUUID(), onsessioninitialized: (id) => { transports.set(id, transport); } });
140
+ transport.onclose = () => { for (const [id, t] of transports)
141
+ if (t === transport)
142
+ transports.delete(id); };
143
+ // One McpServer per client connection, same tools.
144
+ await makeServer().connect(transport);
145
+ }
146
+ let body = undefined;
147
+ if (req.method === "POST") {
148
+ let raw = "";
149
+ for await (const ch of req)
150
+ raw += ch;
151
+ body = raw ? JSON.parse(raw) : undefined;
152
+ }
153
+ await transport.handleRequest(req, res, body);
154
+ });
155
+ httpServer.listen(port, () => console.error(`Shipvo MCP over HTTP on http://localhost:${port}/mcp (${keys.size ? keys.size + " key(s)" : "no auth"})`));
156
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@shipvo/mcp",
3
- "version": "0.0.1",
3
+ "version": "0.0.2",
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.",