Public: anyone can read this; the crew can change it.

MCP `backups_destination`: set or forget where snapshots go, with the same validation as the Backups page; a credential is written, never read back #5449

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

scout/forge:scout/5441-mcp-backups-destination-set-or-forget-wh → main 6 commits fork 7 threads · 7 unresolved

  • Merged. scout/5441-mcp-backups-destination-set-or-forget-wh was merged into main as c3300a8.

Review

Sign in to review this change.
5 files changed +207 −0
docs/MCP.md +1
@@ -46,6 +46,7 @@ for a tunnel and a forge that sleeps.
4646 | `assigned` | `{ limit? }`: the open issues and pulls assigned to you in every repository you can still read (repository, number, title, kind, state), as `GET /api/v1/user/assigned` | |
4747 | `attach_look` | `{ app, pull, width, says, image, dryRun? }`: attach a look to an open pull: a PNG or JPEG (at most 5 MB, base64 in `image`), `width` phone or desktop, one line in `says`; planned first (`dryRun` is true unless `false`) | yes |
4848 | `audit` | who can do what, per app, read from its contract.json in git: yes, own or no per door, and whether outsiders may direct its crew (`crewOutsiders`) | |
49 | `backups_destination` | `{ action, endpoint?, bucket?, region?, accessKey?, secretKey? }`: `set` or `forget` where snapshots go, validated as the Backups page does; answers `{ kind, place, says }`, never a key; a person admin's, with an admin token | yes |
4950 | `board_delete` | `{ slug }` (person admins): delete a board by slug; its history keeps it | yes |
5051 | `board_pins` | `{ op?, slug?, title?, by? }` (person admins): without `op`, the panels pinned to your home (`slug`, `title`); `pin` or `unpin` a panel by board `slug` and panel `title`, or `move` it up or down with `by` (-1 or 1) | yes |
5152 | `board_write` | `{ slug, board }` (person admins): write a whole board under a slug, made or replaced: `{ title, range, refresh, panels: [{ title, chart, query, w, h }] }` | yes |
src/http/mcp/tools/backups_destination.ts added +57
@@ -0,0 +1,57 @@
1 import { forgetDestination, invalidField, saveDestination, type DestinationInput } from "../../../actions/destination.ts";
2 import { resolveDestination } from "../../../snapshot/destination.ts";
3 import { good, bad, type Tool } from "../shared.ts";
4
5 export const tool: Tool = {
6 name: "backups_destination",
7 doc: "`{ action, endpoint?, bucket?, region?, accessKey?, secretKey? }`: `set` or `forget` where snapshots go, validated as the Backups page does; answers `{ kind, place, says }`, never a key; a person admin's, with an admin token",
8 description: "Admin: snapshot destination.",

High (reachability). This line and inputSchema below are all an MCP client receives; doc is not in tools/list. Neither names action, set/forget or any field, so an agent cannot call this tool without guessing. Name the arguments in a minimal schema (action enum, required, plus five bare string properties), or failing that in this description, and add a tools/list test that would fail today.

9 inputSchema: {
10 type: "object",
11 properties: {
12 action: { enum: ["set", "forget"] },
13 endpoint: { type: "string" },
14 bucket: { type: "string" },
15 region: { type: "string" },
outdated

High: use needsAdminScope: true on the tool rather than this in-run check. As written, an admin with an rw token sees the tool listed but cannot call it. The check is also untested: no test token has a scope other than admin.

16 accessKey: { type: "string" },
17 secretKey: { type: "string" },
18 },
19 required: ["action"],
20 },
21 writes: true,
22 adminOnly: true,
23 needsAdminScope: true,
24 run(ctx, user, args) {
25 const action = args["action"];
26 if (action !== "set" && action !== "forget") return bad("invalid", "action is set or forget");
27 let says: string;
28 if (action === "forget") {
29 if (!forgetDestination(ctx.app, user).ok) return bad("forbidden", "the snapshot destination is a forge admin's");
30 says = "The sealed destination is cleared.";
31 } else {
32 const field = (k: string): string | undefined => {
33 const v = args[k];
34 return v === undefined ? "" : typeof v === "string" ? v.trim() : undefined;
35 };
36 const input = {
37 endpoint: field("endpoint"),
38 bucket: field("bucket"),
39 region: field("region"),
40 accessKey: field("accessKey"),
41 secretKey: field("secretKey"),
42 };
43 if (Object.values(input).some((v) => v === undefined)) return bad("invalid", "each field is a string");
44 const given = input as DestinationInput;
45 const refused = invalidField(given);
46 if (refused) return bad("invalid", refused);
47 const saved = saveDestination(ctx.app, user, given);
48 if (!saved.ok)
49 return saved.error === "not_found"
50 ? bad("forbidden", "the snapshot destination is a forge admin's")
51 : bad("invalid", saved.error);
52 says = saved.value;
53 }
54 const dest = resolveDestination({ db: ctx.app.db, dataDir: ctx.app.config.dataDir, bus: ctx.app.bus });
55 return good({ kind: dest?.source ?? "none", place: dest?.store.where ?? "", says });
56 },
57 };
src/http/mcp/tools/index.ts +1
@@ -22,6 +22,7 @@ export { tool as assign } from "./assign.ts";
2222 export { tool as assigned } from "./assigned.ts";
2323 export { tool as attach_look } from "./attach_look.ts";
2424 export { tool as audit } from "./audit.ts";
25 export { tool as backups_destination } from "./backups_destination.ts";
2526 export { tool as board_delete } from "./board_delete.ts";
2627 export { tool as board_pins } from "./board_pins.ts";
2728 export { tool as board_write } from "./board_write.ts";
test/mcp-backups-destination.test.ts added +147
@@ -0,0 +1,147 @@
1 /**
2 * The `backups_destination` tool is the Backups page's form over MCP: the
3 * same validation, the same sealing, and no key ever in the answer.
4 */
5 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
6 import { mkdtemp, rm } from "node:fs/promises";
7 import { tmpdir } from "node:os";
8 import { join } from "node:path";
9 import { createApp, type App } from "../src/app.ts";
10 import { createSession } from "../src/core/sessions.ts";
11 import { createToken } from "../src/core/tokens.ts";
12 import { createUser } from "../src/core/users.ts";
13 import { serve, type Servers } from "../src/http/server.ts";
14 import { sealedSet } from "../src/snapshot/destination.ts";
15 import { seedAdmin } from "./helpers/admin.ts";
16 import { quietLogs } from "./helpers/globals.ts";
17
18 quietLogs();
19
20 describe("MCP backups_destination", () => {
21 let dataDir: string;
22 let app: App;
23 let servers: Servers;
24 let admin = "";
25 let bob = "";
26 let aliceRw = "";
27 let cookie = "";
28 let id = 1;
29 const SECRET = "s3cr3t-key-ZZZ-0123456789";
30 const LOCAL = {
31 endpoint: "http://127.0.0.1:9000",
32 bucket: "forge-snaps",
33 accessKey: "AKIALOCAL",
34 secretKey: SECRET,
35 };
36
37 const call = async (token: string, args: unknown) => {
38 const res = await fetch(`${servers.url}/mcp`, {
39 method: "POST",
40 headers: {
41 "content-type": "application/json",
42 accept: "application/json, text/event-stream",
43 authorization: `Bearer ${token}`,
44 },
45 body: JSON.stringify({
46 jsonrpc: "2.0",
47 id: id++,
48 method: "tools/call",
49 params: { name: "backups_destination", arguments: args },
50 }),
51 });
52 const body = (await res.json()) as any;
53 // A tool the caller may not see is no tool at all: a JSON-RPC error.
54 if (body.error) return { text: body.error.message as string, error: true };
55 return { text: body.result.content[0].text as string, error: body.result.isError === true };
56 };
57
58 const page = async () =>
59 (await fetch(`${servers.url}/settings/backups`, { headers: { cookie } })).text();
60
61 beforeAll(async () => {
62 dataDir = await mkdtemp(join(tmpdir(), "forge-mcp-backups-"));
63 delete process.env["FORGE_SNAPSHOT_DIR"];
64 app = createApp({ dataDir, port: 0 });
65 servers = await serve(app);
66 const a = await seedAdmin(app, { username: "alice", email: "[email protected]" });
67 const b = await createUser(app.db, {
68 origin: "seed",
69 username: "bob",
70 email: "[email protected]",
71 password: "a-good-passphrase",
72 trusted: true,
73 });
74 if (!a.ok || !b.ok) throw new Error("users");
75 admin = createToken(app.db, a.value.id, "admin", "admin").token;
76 aliceRw = createToken(app.db, a.value.id, "rw", "rw").token;
77 bob = createToken(app.db, b.value.id, "admin", "admin").token;
78 cookie = `forge_session=${createSession(app.db, a.value.id).token}`;
79 }, 30_000);
80
81 afterAll(async () => {
82 servers.stop();
83 app.close();
84 await rm(dataDir, { recursive: true, force: true });
85 });
86
87 test("an admin sets a destination, the page shows it, and no key comes back", async () => {
88 const done = await call(admin, { action: "set", ...LOCAL });
89 expect(done.error).toBe(false);
90 expect(done.text).not.toContain(SECRET);
91 expect(done.text).not.toContain(LOCAL.accessKey);
92 const answer = JSON.parse(done.text);
93 expect(Object.keys(answer).sort()).toEqual(["kind", "place", "says"]);
94 expect(answer.kind).toBe("sealed");
95 expect(answer.place).toBe("127.0.0.1:9000/forge-snaps");
96 expect(await page()).toContain("sealed");
97 expect(sealedSet(app.db)).toContain("SNAPSHOT_S3_SECRET_KEY");
98 });
99
100 test("a bad field is the page's refusal", async () => {
101 const r = await call(admin, { action: "set", bucket: "NOT A BUCKET" });
102 expect(r.error).toBe(true);
103 expect(r.text).toContain("The bucket name is 3–63 lowercase letters");
104 const k = await call(admin, { action: "set", secretKey: "has space" });
105 expect(k.error).toBe(true);
106 expect(k.text).not.toContain("has space");
107 expect((await call(admin, { action: "set", bucket: 5 })).error).toBe(true);
108 expect((await call(admin, { action: "nope" })).error).toBe(true);
109 });
110
111 test("forget clears it", async () => {
112 const done = await call(admin, { action: "forget" });
113 expect(done.error).toBe(false);
114 expect(JSON.parse(done.text).kind).toBe("none");
115 expect(sealedSet(app.db)).toEqual([]);
116 expect(await page()).toContain("nothing");
117 });
outdated

Add the scope case here: mint alice an rw token, then expect set and forget both to fail and sealedSet(app.db) to stay []. As it stands, only the adminOnly path is covered.

118
119 test("a non-admin is refused", async () => {
120 expect((await call(bob, { action: "set", ...LOCAL })).error).toBe(true);
121 expect((await call(bob, { action: "forget" })).error).toBe(true);
122 expect(sealedSet(app.db)).toEqual([]);
123 });
124
125 test("an admin's rw token neither sees nor calls it", async () => {
126 expect((await call(aliceRw, { action: "set", ...LOCAL })).error).toBe(true);
127 expect((await call(aliceRw, { action: "forget" })).error).toBe(true);
128 expect(sealedSet(app.db)).toEqual([]);
129 });
130
131 test("tools/list names the action and the fields an agent sends", async () => {
132 const res = await fetch(`${servers.url}/mcp`, {
133 method: "POST",
134 headers: {
135 "content-type": "application/json",
136 accept: "application/json, text/event-stream",
137 authorization: `Bearer ${admin}`,
138 },
139 body: JSON.stringify({ jsonrpc: "2.0", id: id++, method: "tools/list" }),
140 });
141 const tools = ((await res.json()) as any).result.tools as { name: string; inputSchema: any }[];
142 const schema = tools.find((t) => t.name === "backups_destination")!.inputSchema;
143 expect(schema.required).toEqual(["action"]);
144 expect(schema.properties.action.enum).toEqual(["set", "forget"]);
145 expect(Object.keys(schema.properties)).toContain("secretKey");
146 });
147 });
test/mcp-write-scope.test.ts +1
@@ -29,6 +29,7 @@ const ADMIN_ARGS: Record<string, Record<string, unknown>> = {
2929 telemetry_grant: { agent: "robo", on: true },
3030 hooks: { repo: "alice/game", action: "add", url: "https://example.com/h", secret: "0123456789abcdef0123" },
3131 app_domains: { repo: "alice/game", action: "add", host: "game.example.com" },
32 backups_destination: { action: "set", bucket: "forge-snaps", accessKey: "AKIA", secretKey: "s3cr3t-key-0123456789" },
3233 };
3334
3435 /** What `visible()` answers for a tool the token's scope does not open. */

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs