@shipvo/mcp 0.0.3 → 0.0.5
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/dist/server.js +35 -11
- package/package.json +1 -1
package/dist/server.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
// Shipvo MCP: agents use Shipvo apps with no phone.
|
|
3
|
-
// PN_SIM=http://localhost:4545 PN_CONTROL=https://
|
|
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
6
|
// Tools: list_apps, open_app, screen, tap, type, scroll, back, screenshot, end_session (UI simulator);
|
|
@@ -13,7 +13,7 @@ import { randomUUID } from "node:crypto";
|
|
|
13
13
|
import { readFileSync } from "node:fs";
|
|
14
14
|
import { z } from "zod";
|
|
15
15
|
const SIM = (process.env.PN_SIM ?? "http://localhost:4545").replace(/\/$/, "");
|
|
16
|
-
const CONTROL = (process.env.PN_CONTROL ?? "https://
|
|
16
|
+
const CONTROL = (process.env.PN_CONTROL ?? "https://api.shipvo.dev").replace(/\/$/, "");
|
|
17
17
|
const OWNER_TOKEN = process.env.PN_OWNER_TOKEN ?? "";
|
|
18
18
|
// Apps this server can open. Hosted: the account's apps. Local: one app served by the simulator.
|
|
19
19
|
const APPS = process.env.PN_APPS ? JSON.parse(process.env.PN_APPS)
|
|
@@ -32,29 +32,53 @@ const result = (sid, r) => { const s = sessions.get(sid); if (s)
|
|
|
32
32
|
// (ChatGPT does), so an unknown id is forwarded and the simulator answers if it knows it.
|
|
33
33
|
const need = (_sid) => { };
|
|
34
34
|
function makeServer(ctx = {}) {
|
|
35
|
-
const server = new McpServer({ name: "shipvo", version: "0.0.
|
|
35
|
+
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.
|
|
36
36
|
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.
|
|
37
37
|
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.
|
|
38
38
|
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.
|
|
39
39
|
Finish a task by checking the final screen says what you expected (for example "Order placed"). Call end_session when done to get a report.` });
|
|
40
|
-
|
|
41
|
-
|
|
40
|
+
// ctx.app: the app(s) this key opens, comma-separated (the beta app plus any the key owner has registered).
|
|
41
|
+
const keyApps = ctx.app ? ctx.app.split(",").map((s) => s.trim()).filter(Boolean) : null;
|
|
42
|
+
const allowed = (id) => (keyApps ? keyApps.includes(id) : !!APPS[id]);
|
|
43
|
+
server.tool("list_apps", "Apps this server can open, with what each one does. Optional query filters by name, description or category (e.g. 'shop', 'douala').", { query: z.string().optional() }, async ({ query }) => {
|
|
44
|
+
const ids = keyApps ?? Object.keys(APPS);
|
|
45
|
+
const q = (query ?? "").toLowerCase();
|
|
46
|
+
let dir = {};
|
|
47
|
+
try {
|
|
48
|
+
const r = await fetch(`${CONTROL}/apps/public${q ? `?q=${encodeURIComponent(q)}` : ""}`);
|
|
49
|
+
if (r.ok)
|
|
50
|
+
for (const a of (await r.json()).apps)
|
|
51
|
+
dir[a.app_id] = a;
|
|
52
|
+
}
|
|
53
|
+
catch { }
|
|
54
|
+
const lines = ids.map((id) => {
|
|
55
|
+
const d = dir[id] ?? APPS[id];
|
|
56
|
+
const desc = d ? `${d.name}. ${d.description}${"category" in d ? ` [${d.category}]` : ""}` : `published with shipvo`;
|
|
57
|
+
return q && !`${id} ${desc}`.toLowerCase().includes(q) ? null : `${id}: ${desc} (open_app("${id}@latest"))`;
|
|
58
|
+
}).filter(Boolean);
|
|
59
|
+
return text(lines.join("\n") || `no apps match "${query}"`);
|
|
60
|
+
});
|
|
42
61
|
server.tool("open_app", "Open an app in a fresh phone-sized session and return its first screen.", {
|
|
43
62
|
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)"),
|
|
44
63
|
session: z.string().optional().describe("Session name (default: generated). One session is one phone."),
|
|
45
|
-
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
|
|
64
|
+
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."),
|
|
46
65
|
dark: z.boolean().optional().describe("Dark mode"),
|
|
47
66
|
}, async ({ app, session, seed_cart, dark }) => {
|
|
48
67
|
const id = app.split("@")[0];
|
|
49
68
|
if (!allowed(id))
|
|
50
|
-
throw new Error(
|
|
69
|
+
throw new Error(keyApps ? `this key opens: ${keyApps.join(", ")}` : `unknown app "${id}". Apps: ${Object.keys(APPS).join(", ")}`);
|
|
51
70
|
if (ctx.scope === "read")
|
|
52
71
|
throw new Error("this key is read-only: it cannot open sessions");
|
|
53
72
|
const sid = session ?? `${id}-${Date.now().toString(36)}`;
|
|
54
|
-
const r = await sim("/session", { session: sid, app, seed_cart, dark });
|
|
73
|
+
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 });
|
|
55
74
|
sessions.set(sid, { app: id, steps: 0, startedAt: Date.now() });
|
|
56
75
|
return text(`session: ${sid}\napp: ${id} release: ${r.release ?? "local"}\n\n${r.screen ?? ""}`);
|
|
57
76
|
});
|
|
77
|
+
server.tool("list_sessions", "Open simulator sessions (phones) on this server: id, app, release and when each started. Use to resume after a reconnect.", {}, async () => {
|
|
78
|
+
const r = await sim("/sessions", {});
|
|
79
|
+
const rows = (r.sessions ?? []).filter((s) => !keyApps || keyApps.includes(s.app));
|
|
80
|
+
return text(rows.length ? rows.map((s) => `${s.session}: ${s.app} release ${s.release}${s.started_at ? ` since ${s.started_at}` : ""}`).join("\n") : "no open sessions");
|
|
81
|
+
});
|
|
58
82
|
const sid = z.string().describe("Session from open_app");
|
|
59
83
|
const ref = z.string().regex(/^e\d+$/).describe("Element ref from the screen, e.g. e42");
|
|
60
84
|
server.tool("screen", "The current screen as an accessibility tree with refs.", { session: sid }, async ({ session }) => { need(session); return result(session, await sim("/act", { session, action: "screen" })); });
|
|
@@ -103,8 +127,8 @@ Finish a task by checking the final screen says what you expected (for example "
|
|
|
103
127
|
server.tool("owner_command", "Run an owner command against the live app (changes are audited; `publish` makes them live on every phone within seconds).", { app: z.string(), command: z.string(), args: z.record(z.unknown()).optional() }, async ({ app, command, args }) => {
|
|
104
128
|
if (ctx.scope && ctx.scope !== "owner")
|
|
105
129
|
throw new Error(`this key has scope "${ctx.scope}": owner commands need an owner key`);
|
|
106
|
-
if (
|
|
107
|
-
throw new Error(`this key is for
|
|
130
|
+
if (keyApps && !keyApps.includes(app))
|
|
131
|
+
throw new Error(`this key is for: ${keyApps.join(", ")}`);
|
|
108
132
|
const token = ctx.ownerToken || OWNER_TOKEN;
|
|
109
133
|
if (!token)
|
|
110
134
|
throw new Error("owner commands are not enabled on this server.");
|
|
@@ -154,7 +178,7 @@ else {
|
|
|
154
178
|
// One McpServer per client connection. Behind sim.shipvo.dev the Worker has checked the key and says which
|
|
155
179
|
// app and scope it grants; locally there are no such headers and every app is allowed.
|
|
156
180
|
const scope = req.headers["x-shipvo-scope"];
|
|
157
|
-
await makeServer({ app: req.headers["x-shipvo-app"] || undefined, scope, ownerToken: req.headers["x-shipvo-owner-token"] || undefined }).connect(transport);
|
|
181
|
+
await makeServer({ app: req.headers["x-shipvo-app"] || undefined, publicApps: req.headers["x-shipvo-public"] || undefined, scope, ownerToken: req.headers["x-shipvo-owner-token"] || undefined }).connect(transport);
|
|
158
182
|
}
|
|
159
183
|
let body = undefined;
|
|
160
184
|
if (req.method === "POST") {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@shipvo/mcp",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.5",
|
|
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.",
|