Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
142 changes: 142 additions & 0 deletions workers/mcp/src/storage-tools.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof import("./http.js")>("./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<string, unknown>) => Promise<{ content: { type: string; text: string }[] }>;

function collectTools(): Map<string, { scopes: Record<string, unknown>; handler: Handler }> {
const tools = new Map<string, { scopes: Record<string, unknown>; handler: Handler }>();
const fakeServer = {
tool(name: string, _desc: string, scopes: Record<string, unknown>, 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);
});
});
74 changes: 74 additions & 0 deletions workers/mcp/src/storage-tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
Loading