diff --git a/workers/mcp/src/storage-tools.test.ts b/workers/mcp/src/storage-tools.test.ts new file mode 100644 index 00000000..e6e3c972 --- /dev/null +++ b/workers/mcp/src/storage-tools.test.ts @@ -0,0 +1,142 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +// Record every authedCall so we can assert the instance-scoped tools hit the +// right REST route + method (the tools are thin proxies over the API). +const calls: Array<{ path: string; token: string; opts?: RequestInit }> = []; + +vi.mock("./http.js", async () => { + const actual = await vi.importActual("./http.js"); + return { + ...actual, + authedCall: vi.fn(async (path: string, token: string, opts?: RequestInit) => { + calls.push({ path, token, opts }); + return { ok: true }; + }), + }; +}); + +// Control the permission gate: default allow; flip `deny` to simulate a +// missing scope / read-only mode. +const gate = { deny: false }; +vi.mock("./safety.js", () => ({ + requirePermission: vi.fn(async (_ctx: unknown, scope: string, name: string) => + gate.deny ? { content: [{ type: "text", text: `denied ${scope} ${name}` }] } : undefined, + ), + audit: vi.fn(async () => {}), +})); + +import { registerStorageTools } from "./storage-tools.js"; +import { requirePermission } from "./safety.js"; + +type Handler = (args: Record) => Promise<{ content: { type: string; text: string }[] }>; + +function collectTools(): Map; handler: Handler }> { + const tools = new Map; handler: Handler }>(); + const fakeServer = { + tool(name: string, _desc: string, scopes: Record, handler: Handler) { + tools.set(name, { scopes, handler }); + }, + }; + registerStorageTools( + // biome-ignore lint/suspicious/noExplicitAny: minimal fake server for handler capture + fakeServer as any, + {} as never, + () => "session-token", + () => ({}) as never, + ); + return tools; +} + +describe("instance-scoped collection tools", () => { + beforeEach(() => { + calls.length = 0; + gate.deny = false; + vi.mocked(requirePermission).mockClear(); + }); + + it("registers the three instance collection tools", () => { + const tools = collectTools(); + expect(tools.has("list_instance_collections")).toBe(true); + expect(tools.has("query_instance_records")).toBe(true); + expect(tools.has("insert_instance_record")).toBe(true); + }); + + it("list_instance_collections GETs the instance collections route (read scope)", async () => { + const tools = collectTools(); + await tools.get("list_instance_collections")!.handler({ instance_id: "inst-1" }); + expect(requirePermission).toHaveBeenCalledWith( + expect.anything(), + "read", + "list_instance_collections", + { instance_id: "inst-1" }, + ); + expect(calls).toHaveLength(1); + expect(calls[0].path).toBe("/v1/instances/inst-1/collections"); + expect(calls[0].opts).toEqual({}); + }); + + it("query_instance_records builds query params on the records route (read scope)", async () => { + const tools = collectTools(); + await tools.get("query_instance_records")!.handler({ + instance_id: "inst-1", + collection: "leads", + where: '{"status":"new"}', + order_by: "created_at", + limit: 25, + }); + expect(requirePermission).toHaveBeenCalledWith( + expect.anything(), + "read", + "query_instance_records", + { instance_id: "inst-1", collection: "leads" }, + ); + expect(calls).toHaveLength(1); + const url = new URL(`https://x${calls[0].path}`); + expect(url.pathname).toBe("/v1/instances/inst-1/collections/leads/records"); + expect(url.searchParams.get("where")).toBe('{"status":"new"}'); + expect(url.searchParams.get("order_by")).toBe("created_at"); + expect(url.searchParams.get("limit")).toBe("25"); + }); + + it("insert_instance_record POSTs {data} to the records route (write scope)", async () => { + const tools = collectTools(); + await tools.get("insert_instance_record")!.handler({ + instance_id: "inst-1", + collection: "leads", + data: '{"email":"a@b.com"}', + }); + expect(requirePermission).toHaveBeenCalledWith( + expect.anything(), + "write", + "insert_instance_record", + { instance_id: "inst-1", collection: "leads" }, + ); + expect(calls).toHaveLength(1); + expect(calls[0].path).toBe("/v1/instances/inst-1/collections/leads/records"); + expect(calls[0].opts?.method).toBe("POST"); + expect(JSON.parse(calls[0].opts?.body as string)).toEqual({ data: { email: "a@b.com" } }); + }); + + it("insert_instance_record rejects invalid JSON without calling the API", async () => { + const tools = collectTools(); + const res = await tools.get("insert_instance_record")!.handler({ + instance_id: "inst-1", + collection: "leads", + data: "not json", + }); + expect(res.content[0].text).toBe("Invalid data JSON"); + expect(calls).toHaveLength(0); + }); + + it("blocks the write tool when the scope gate denies it", async () => { + gate.deny = true; + const tools = collectTools(); + const res = await tools.get("insert_instance_record")!.handler({ + instance_id: "inst-1", + collection: "leads", + data: '{"email":"a@b.com"}', + }); + expect(res.content[0].text).toContain("denied write insert_instance_record"); + expect(calls).toHaveLength(0); + }); +}); diff --git a/workers/mcp/src/storage-tools.ts b/workers/mcp/src/storage-tools.ts index 52fd7e91..04a2a00f 100644 --- a/workers/mcp/src/storage-tools.ts +++ b/workers/mcp/src/storage-tools.ts @@ -139,6 +139,80 @@ export function registerStorageTools( }, ); + // ── Instance-scoped collections ─────────────────────────────────────────── + // Same collection storage but scoped to a user-owned subscribed instance + // (its own D1 table), so an owner can read/write a live instance's data over + // MCP instead of hand-rolling auth against /v1/instances/:id/collections/*. + + server.tool( + "list_instance_collections", + "List all data collections (tables) for one of your subscribed instances. Shows schema and record counts.", + { + token: z.string().optional().describe("PAGS session token. Omit when connected with browser sign-in."), + instance_id: z.string().describe("Instance ID from my_instances"), + }, + async ({ token, instance_id }) => { + const t = tokenFor(token); + if (!t) return authRequired(); + const denied = await requirePermission(safetyFor(token), "read", "list_instance_collections", { instance_id }); + if (denied) return denied; + const data = await authedCall(`/v1/instances/${instance_id}/collections`, t, {}, env); + return jsonText(data); + }, + ); + + server.tool( + "query_instance_records", + "Query records from a collection on one of your subscribed instances. Filter by field values, sort, paginate.", + { + token: z.string().optional().describe("PAGS session token. Omit when connected with browser sign-in."), + instance_id: z.string().describe("Instance ID from my_instances"), + collection: z.string().describe("Collection name"), + where: z.string().optional().describe('JSON filter: {"status":"submitted"}'), + order_by: z.string().optional(), + limit: z.number().optional(), + }, + async ({ token, instance_id, collection, where, order_by, limit }) => { + const t = tokenFor(token); + if (!t) return authRequired(); + const denied = await requirePermission(safetyFor(token), "read", "query_instance_records", { instance_id, collection }); + if (denied) return denied; + const params = new URLSearchParams(); + if (where) params.set("where", where); + if (order_by) params.set("order_by", order_by); + if (limit) params.set("limit", String(limit)); + const q = params.toString() ? `?${params}` : ""; + const data = await authedCall(`/v1/instances/${instance_id}/collections/${collection}/records${q}`, t, {}, env); + return jsonText(data); + }, + ); + + server.tool( + "insert_instance_record", + "Insert a new record into a collection on one of your subscribed instances. Respects the collection's unique/dedup constraints.", + { + token: z.string().optional().describe("PAGS session token. Omit when connected with browser sign-in."), + instance_id: z.string().describe("Instance ID from my_instances"), + collection: z.string().describe("Collection name"), + data: z.string().describe("JSON object with field values"), + }, + async ({ token, instance_id, collection, data: dataStr }) => { + const t = tokenFor(token); + if (!t) return authRequired(); + const denied = await requirePermission(safetyFor(token), "write", "insert_instance_record", { instance_id, collection }); + if (denied) return denied; + let parsed: unknown; + try { parsed = JSON.parse(dataStr); } catch { return text("Invalid data JSON"); } + const result = await authedCall(`/v1/instances/${instance_id}/collections/${collection}/records`, t, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ data: parsed }), + }, env); + await audit(safetyFor(token), { tool: "insert_instance_record", action: "write", input: { instance_id, collection } }); + return jsonText(result); + }, + ); + // ── Files ──────────────────────────────────────────────────────────────── server.tool(