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
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/5441-mcp-backups-destination-set-or-forget-wh → main 6 commits fork 7 threads · 7 unresolved
5 files
changed +207
−0
docs/MCP.md +1
| @@ -46,6 +46,7 @@ for a tunnel and a forge that sleeps. | ||
| 46 | 46 | | `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` | | |
| 47 | 47 | | `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 | |
| 48 | 48 | | `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 | | |
| 49 | 50 | | `board_delete` | `{ slug }` (person admins): delete a board by slug; its history keeps it | yes | |
| 50 | 51 | | `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 | |
| 51 | 52 | | `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.", | |
| 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 |
||
| 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"; | ||
| 22 | 22 | export { tool as assigned } from "./assigned.ts"; |
| 23 | 23 | export { tool as attach_look } from "./attach_look.ts"; |
| 24 | 24 | export { tool as audit } from "./audit.ts"; |
| 25 | export { tool as backups_destination } from "./backups_destination.ts"; | |
| 25 | 26 | export { tool as board_delete } from "./board_delete.ts"; |
| 26 | 27 | export { tool as board_pins } from "./board_pins.ts"; |
| 27 | 28 | 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 |
||
| 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>> = { | ||
| 29 | 29 | telemetry_grant: { agent: "robo", on: true }, |
| 30 | 30 | hooks: { repo: "alice/game", action: "add", url: "https://example.com/h", secret: "0123456789abcdef0123" }, |
| 31 | 31 | 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" }, | |
| 32 | 33 | }; |
| 33 | 34 | |
| 34 | 35 | /** What `visible()` answers for a tool the token's scope does not open. */ |
new above
offline
High (reachability). This line and
inputSchemabelow are all an MCP client receives;docis not intools/list. Neither namesaction,set/forgetor any field, so an agent cannot call this tool without guessing. Name the arguments in a minimal schema (actionenum, required, plus five bare string properties), or failing that in this description, and add atools/listtest that would fail today.