Public: anyone can read this; the crew can change it.
MCP `backups_test`: check that the snapshot destination is reachable, as the Backups page's Test button does, for a forge admin #6891
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/5474-mcp-backups-test-check-that-the-snapshot → main 11 commits fork 5 threads · 5 unresolved
docs/MCP.md +1
| @@ -48,6 +48,7 @@ for a tunnel and a forge that sleeps. | ||
| 48 | 48 | | `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 | |
| 49 | 49 | | `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`) | | |
| 50 | 50 | | `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 | |
| 51 | | `backups_test` | `{ endpoint?, bucket?, region?, accessKey?, secretKey? }`: list the snapshot bucket as the Backups page's Test button does (blank fields test the destination in effect); a forge admin's, with an admin token; answers `{ reached, says }`, never a key | yes | | |
| 51 | 52 | | `board_delete` | `{ slug }` (person admins): delete a board by slug; its history keeps it | yes | |
| 52 | 53 | | `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 | |
| 53 | 54 | | `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_test.ts added +29
| @@ -0,0 +1,29 @@ | ||
| 1 | import { testDestination, type DestinationInput } from "../../../actions/destination.ts"; | |
| 2 | import { good, bad, type Tool } from "../shared.ts"; | |
| 3 | ||
| 4 | export const tool: Tool = { | |
| 5 | name: "backups_test", | |
| 6 | doc: "`{ endpoint?, bucket?, region?, accessKey?, secretKey? }`: list the snapshot bucket as the Backups page's Test button does (blank fields test the destination in effect); a forge admin's, with an admin token; answers `{ reached, says }`, never a key", | |
| 7 | description: "Test the snapshot destination.", | |
| 8 | inputSchema: { type: "object" }, | |
| 9 | writes: true, | |
| 10 | adminOnly: true, | |
High: add |
||
| 11 | needsAdminScope: true, | |
| 12 | async run(ctx, user, args) { | |
| 13 | const field = (k: string): string | undefined => { | |
| 14 | const v = args[k]; | |
| 15 | return v === undefined ? "" : typeof v === "string" ? v.trim() : undefined; | |
| 16 | }; | |
| 17 | const input = { | |
| 18 | endpoint: field("endpoint"), | |
| 19 | bucket: field("bucket"), | |
| 20 | region: field("region"), | |
| 21 | accessKey: field("accessKey"), | |
| 22 | secretKey: field("secretKey"), | |
| 23 | }; | |
| 24 | if (Object.values(input).some((v) => v === undefined)) return bad("invalid", "send the fields as strings"); | |
| 25 | const said = await testDestination(ctx.app, user, input as DestinationInput); | |
outdated
|
||
| 26 | if (!said.ok) return bad("forbidden", "the snapshot destination is a forge admin's"); | |
| 27 | return good({ reached: said.value.startsWith("Reached"), says: said.value }); | |
| 28 | }, | |
| 29 | }; |
src/http/mcp/tools/index.ts +1
| @@ -23,6 +23,7 @@ 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 | 25 | export { tool as backups_destination } from "./backups_destination.ts"; |
| 26 | export { tool as backups_test } from "./backups_test.ts"; | |
| 26 | 27 | export { tool as board_delete } from "./board_delete.ts"; |
| 27 | 28 | export { tool as board_pins } from "./board_pins.ts"; |
| 28 | 29 | export { tool as board_write } from "./board_write.ts"; |
test/mcp-backups-test.test.ts added +117
| @@ -0,0 +1,117 @@ | ||
| 1 | /** MCP `backups_test`: the Backups page's Test button, from a harness. */ | |
| 2 | ||
| 3 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 4 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 5 | import { tmpdir } from "node:os"; | |
| 6 | import { join } from "node:path"; | |
| 7 | import { createApp, type App } from "../src/app.ts"; | |
| 8 | import { createToken } from "../src/core/tokens.ts"; | |
| 9 | import { createUser } from "../src/core/users.ts"; | |
| 10 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 11 | import { quietLogs } from "./helpers/globals.ts"; | |
| 12 | ||
| 13 | quietLogs(); | |
| 14 | ||
| 15 | const PASS = "a-long-backups-passphrase"; | |
| 16 | const SECRET = "sekrit-key-1234"; | |
| 17 | ||
| 18 | describe("MCP backups_test", () => { | |
| 19 | let dataDir = ""; | |
| 20 | let app: App; | |
| 21 | let servers: Servers; | |
| 22 | const realS3 = Bun.S3Client; | |
| 23 | let admin = ""; | |
| 24 | let plain = ""; | |
| 25 | let rw = ""; | |
| 26 | ||
| 27 | const rpc = async (token: string, method: string, params?: unknown) => { | |
| 28 | const res = await fetch(`${servers.url}/mcp`, { | |
| 29 | method: "POST", | |
| 30 | headers: { "content-type": "application/json", accept: "application/json, text/event-stream", authorization: `Bearer ${token}` }, | |
| 31 | body: JSON.stringify({ jsonrpc: "2.0", id: 1, method, params }), | |
| 32 | }); | |
| 33 | return (await res.json()) as any; | |
| 34 | }; | |
| 35 | const test_ = async (token: string, args: unknown) => { | |
| 36 | const body = await rpc(token, "tools/call", { name: "backups_test", arguments: args }); | |
| 37 | if (body.error) return { error: true, text: body.error.message as string }; | |
| 38 | return { error: body.result.isError === true, text: body.result.content[0].text as string }; | |
| 39 | }; | |
| 40 | ||
| 41 | beforeAll(async () => { | |
| 42 | dataDir = await mkdtemp(join(tmpdir(), "forge-mcp-backups-")); | |
| 43 | app = createApp({ dataDir, port: 0 }); | |
| 44 | servers = await serve(app); | |
| 45 | const made: Record<string, any> = {}; | |
| 46 | for (const [username, isAdmin] of [["alice", true], ["bob", false]] as const) { | |
| 47 | made[username] = await createUser(app.db, { origin: "seed", trusted: true, username, email: `${username}@x.test`, password: PASS, isAdmin }); | |
| 48 | if (!made[username].ok) throw new Error(username); | |
| 49 | } | |
outdated
Both tokens are created with scope |
||
| 50 | admin = createToken(app.db, made["alice"].value.id, "t", "admin").token; | |
| 51 | rw = createToken(app.db, made["alice"].value.id, "t", "rw").token; | |
| 52 | plain = createToken(app.db, made["bob"].value.id, "t", "admin").token; | |
| 53 | }, 30_000); | |
| 54 | ||
| 55 | afterAll(async () => { | |
| 56 | (Bun as any).S3Client = realS3; | |
| 57 | servers.stop(); | |
| 58 | app.close(); | |
| 59 | await rm(dataDir, { recursive: true, force: true }); | |
| 60 | }); | |
| 61 | ||
| 62 | const fields = (endpoint: string) => ({ endpoint, bucket: "my-bucket", accessKey: "AKIAEXAMPLE", secretKey: SECRET }); | |
| 63 | ||
| 64 | test("a reachable destination is reached, and says so", async () => { | |
| 65 | // A bucket that lists one object; Bun's own S3 wire is not what this pins. | |
| 66 | (Bun as any).S3Client = class { | |
| 67 | async list() { | |
| 68 | return { contents: [{ key: "a.tar" }], isTruncated: false }; | |
| 69 | } | |
| 70 | }; | |
| 71 | const r = await test_(admin, fields("http://127.0.0.1:9")); | |
| 72 | (Bun as any).S3Client = realS3; | |
| 73 | expect(r.error).toBe(false); | |
| 74 | const out = JSON.parse(r.text); | |
outdated
This repeats the assertion two lines above. Remove it. |
||
| 75 | expect(out.says).toStartWith("Reached "); | |
| 76 | expect(out.reached).toBe(true); | |
| 77 | }, 20_000); | |
| 78 | ||
| 79 | test("an unreachable one says why, and never echoes a secret", async () => { | |
| 80 | const r = await test_(admin, fields("http://127.0.0.1:1")); | |
| 81 | const out = JSON.parse(r.text); | |
| 82 | expect(out.reached).toBe(false); | |
| 83 | expect(out.says).toStartWith("Could not list "); | |
| 84 | expect(r.text).not.toContain(SECRET); | |
| 85 | expect(r.text).not.toContain("AKIAEXAMPLE"); | |
| 86 | }, 20_000); | |
| 87 | ||
| 88 | test("a field the page would refuse is refused in the same words", async () => { | |
| 89 | const r = await test_(admin, { ...fields("ftp://x") }); | |
| 90 | const out = JSON.parse(r.text); | |
| 91 | expect(out.reached).toBe(false); | |
| 92 | expect(out.says).toContain("The endpoint must be an http(s) URL"); | |
| 93 | expect(r.text).not.toContain(SECRET); | |
| 94 | }); | |
| 95 | ||
| 96 | test("a non-admin is refused and does not see the tool", async () => { | |
| 97 | const r = await test_(plain, fields("http://127.0.0.1:1")); | |
| 98 | expect(r.error).toBe(true); | |
| 99 | const listed = await rpc(plain, "tools/list"); | |
| 100 | expect(listed.result.tools.map((t: any) => t.name)).not.toContain("backups_test"); | |
| 101 | }); | |
| 102 | ||
| 103 | test("an admin's rw token neither sees nor can call it; an admin-scope token can", async () => { | |
| 104 | const listed = await rpc(rw, "tools/list"); | |
| 105 | expect(listed.result.tools.map((t: any) => t.name)).not.toContain("backups_test"); | |
| 106 | const r = await test_(rw, fields("ftp://x")); | |
| 107 | expect(r.error).toBe(true); | |
| 108 | const seen = await rpc(admin, "tools/list"); | |
| 109 | expect(seen.result.tools.map((t: any) => t.name)).toContain("backups_test"); | |
| 110 | }); | |
| 111 | ||
| 112 | test("listed to an admin, not as a read", async () => { | |
| 113 | const listed = await rpc(admin, "tools/list"); | |
| 114 | const tool = listed.result.tools.find((t: any) => t.name === "backups_test"); | |
| 115 | expect(tool.annotations?.readOnlyHint).not.toBe(true); | |
| 116 | }); | |
| 117 | }); |
test/mcp-write-scope.test.ts +1
| @@ -32,6 +32,7 @@ const ADMIN_ARGS: Record<string, Record<string, unknown>> = { | ||
| 32 | 32 | hooks: { repo: "alice/game", action: "add", url: "https://example.com/h", secret: "0123456789abcdef0123" }, |
| 33 | 33 | app_domains: { repo: "alice/game", action: "add", host: "game.example.com" }, |
| 34 | 34 | backups_destination: { action: "set", bucket: "forge-snaps", accessKey: "AKIA", secretKey: "s3cr3t-key-0123456789" }, |
| 35 | backups_test: { endpoint: "http://127.0.0.1:1", bucket: "b", accessKey: "AKIA", secretKey: "s3cr3t-key-0123456789" }, | |
| 35 | 36 | }; |
| 36 | 37 | |
| 37 | 38 | /** What `visible()` answers for a tool the token's scope does not open. */ |
Not blocking: with no
properties, a client cannot learnendpoint/bucket/region/accessKey/secretKeyfrom tools/list. That is acceptable under the #6217 budget. Once that budget work lands, consider bringing the field schema back.