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

Merged opened this

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

Merged

scout/forge:scout/5474-mcp-backups-test-check-that-the-snapshot → main 11 commits fork 5 threads · 5 unresolved

  • Merged. scout/5474-mcp-backups-test-check-that-the-snapshot was merged into main as f0a6dbc.

Review

Sign in to review this change.
5 files changed +149 −0
docs/MCP.md +1
@@ -48,6 +48,7 @@ for a tunnel and a forge that sleeps.
4848 | `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 |
4949 | `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`) | |
5050 | `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 |
5152 | `board_delete` | `{ slug }` (person admins): delete a board by slug; its history keeps it | yes |
5253 | `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 |
5354 | `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" },

Not blocking: with no properties, a client cannot learn endpoint/bucket/region/accessKey/secretKey from tools/list. That is acceptable under the #6217 budget. Once that budget work lands, consider bringing the field schema back.

9 writes: true,
10 adminOnly: true,

High: add needsAdminScope: true, as backups_destination has. Without it, an admin's plain rw token can make the forge dial any http(s) URL it names (internal hosts included), read back up to 200 characters of the error, and run the sealed S3 credentials. The page gates this behind an admin session, so the MCP door should require an admin-scope token.

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

backups_destination answers forbidden with "the snapshot destination is a forge admin's" in this case. Use one wording for both tools.

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";
2323 export { tool as attach_look } from "./attach_look.ts";
2424 export { tool as audit } from "./audit.ts";
2525 export { tool as backups_destination } from "./backups_destination.ts";
26 export { tool as backups_test } from "./backups_test.ts";
2627 export { tool as board_delete } from "./board_delete.ts";
2728 export { tool as board_pins } from "./board_pins.ts";
2829 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 admin, so nothing pins the scope gate. Add an admin rw token and assert that it neither lists nor can call backups_test.

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>> = {
3232 hooks: { repo: "alice/game", action: "add", url: "https://example.com/h", secret: "0123456789abcdef0123" },
3333 app_domains: { repo: "alice/game", action: "add", host: "game.example.com" },
3434 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" },
3536 };
3637
3738 /** What `visible()` answers for a tool the token's scope does not open. */

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs