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

MCP `snapshot_now`: take a snapshot now, as the Backups page's button does, for a forge admin #5434

Merged opened this

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

Merged

scout/forge:scout/5422-mcp-snapshot-now-take-a-snapshot-now-as → main 11 commits fork 5 threads · 5 unresolved

  • Merged. scout/5422-mcp-snapshot-now-take-a-snapshot-now-as was merged into main as ddcfaf9.

Review

Sign in to review this change.
10 files changed +116 −5
docs/MCP.md +1
@@ -132,6 +132,7 @@ for a tunnel and a forge that sleeps.
132132 | `share` | `{ item, to? }`: share a feed item (`item` is its id); with no `to` it goes to the app's crew and owners, else to up to 10 named usernames' followers; a daily budget each; answers `{ sent }` | yes |
133133 | `signup` | `{ mode?, dryRun? }`: who may sign up: `open` (default), `invite` (only a person with an open invite link) or `closed` (no new accounts); without `mode` it reads the setting, with `mode` it plans first (`dryRun` is true unless `false`), and applying needs an admin token | yes |
134134 | `sizes` | what a child forge's sizes mean: small, medium and large, each with CPU, memory and disk | |
135 | `snapshot_now` | no arguments: take a snapshot now, as the Backups page's button does; a forge admin with an admin-scope token; answers `{ name, repos, apps, bytes, says }` | yes |
135136 | `spawn` | `{ name, apps?, crew?, dryRun? }`: a child forge, planned first (`dryRun` is true unless `false`); an admin's, and an `ro` token may plan but not make | yes |
136137 | `ssh_keys` | `{ action?, title?, key?, id? }`: list your own SSH keys (default), add one (`key` is an authorized_keys line, `title` optional) or remove one by `id`; the API's rules and errors, add and remove need an `rw` token | yes |
137138 | `stop` | stop one run by its id from `now` | yes |
src/http/mcp/tools/account_role.ts +1 −1
@@ -7,7 +7,7 @@ export const tool: Tool = {
77 name: "account_role",
88 doc: "`{ username, role, usdPerMonth?, dryRun? }`: make a person a `member`, or a `guest` again, with an optional starter allowance (`usdPerMonth`, 0.01 to 10000, promoting only); planned first (`dryRun` is true unless `false`)",
99 description:
10 "Make a person a member, or a guest again; promoting may grant a monthly `usdPerMonth`.",
10 "Make a person a member or a guest; promoting may grant `usdPerMonth`.",
1111 inputSchema: {
1212 type: "object",
1313 properties: {
src/http/mcp/tools/cloud_accounts.ts +1 −1
@@ -6,7 +6,7 @@ export const tool: Tool = {
66 name: "cloud_accounts",
77 doc: "the forge's cloud accounts, each attached provider token with its use, verification, write permission and today's calls; a forge admin only (counted as a write so a `ro` token never lists them)",
88 description:
9 "Cloud accounts (admin): each token's use and verification.",
9 "Cloud accounts (admin): each token's use.",
1010 inputSchema: { type: "object", properties: {} },
1111 // A read, but marked a write so a `ro` token never lists the accounts; it
1212 // is a forge admin's and the audit rows name every vendor call.
src/http/mcp/tools/index.ts +1
@@ -109,6 +109,7 @@ export { tool as sever } from "./sever.ts";
109109 export { tool as share } from "./share.ts";
110110 export { tool as signup } from "./signup.ts";
111111 export { tool as sizes } from "./sizes.ts";
112 export { tool as snapshot_now } from "./snapshot_now.ts";
112113 export { tool as spawn } from "./spawn.ts";
113114 export { tool as spend } from "./spend.ts";
114115 export { tool as ssh_keys } from "./ssh_keys.ts";
src/http/mcp/tools/issue_teams.ts +1 −1
@@ -5,7 +5,7 @@ import { good, bad, type Tool, REPO_ARG, repoArg, issueArg, isAnswer } from "../
55 export const tool: Tool = {
66 name: "issue_teams",
77 description:
8 "Name up to two crew teams (design, data-science, security, platform) to review the pull.",
8 "Name up to two crew teams to review.",
99 inputSchema: {
1010 type: "object",
1111 properties: {
src/http/mcp/tools/mint_invite.ts +1 −1
@@ -7,7 +7,7 @@ export const tool: Tool = {
77 name: "mint_invite",
88 doc: "`{ days? }`: mint a single-use invite link that expires in `days` (1–30, default 7) and is shown once; a person admin's, with an admin token",
99 description:
10 "Mint a one-use invite link, shown once, expires in `days` (1–30, default 7). Person admins.",
10 "Mint a one-use invite link, shown once, valid `days` (1–30, default 7). Person admins.",
1111 inputSchema: {
1212 type: "object",
1313 properties: {
src/http/mcp/tools/snapshot_now.ts added +32
@@ -0,0 +1,32 @@
1 import { snapshotsOf } from "../../../snapshot/service.ts";
2 import { scopeAllows } from "../../../core/tokens.ts";
3 import { fileSize } from "../../../web/ui.ts";
4 import { good, bad, type Tool } from "../shared.ts";
5
6 export const tool: Tool = {
7 name: "snapshot_now",
8 doc: "no arguments: take a snapshot now, as the Backups page's button does; a forge admin with an admin-scope token; answers `{ name, repos, apps, bytes, says }`",
9 description: "Take a snapshot now, as Backups does (admin).",
10 inputSchema: { type: "object", properties: {} },
11 writes: true,
12 async run(ctx, user) {
13 if (!user.isAdmin) return bad("not_found", "no such tool here");
14 if (!scopeAllows(ctx.auth.scope ?? "ro", "admin"))
15 return bad("forbidden", "a snapshot needs an admin-scope token");
16 const svc = snapshotsOf(ctx.app);
17 if (svc?.store === undefined)
18 return bad("no_store", "This forge has nowhere to put a snapshot.");
19 try {
20 const m = await svc.now();
21 return good({
22 name: m.name,
23 repos: m.repos,
24 apps: m.apps,
25 bytes: m.bytes,
26 says: `Snapshot ${m.name} taken: ${m.repos} repositories, ${m.apps} apps, ${fileSize(m.bytes)}.`,
27 });
28 } catch (error) {
29 return bad("failed", `The snapshot failed: ${String(error)}`);
30 }
31 },
32 };
src/http/mcp/tools/take_updates.ts +1 −1
@@ -8,7 +8,7 @@ export const tool: Tool = {
88 name: "take_updates",
99 doc: "a fork takes its parent's updates when git can do it alone; a conflict is refused. On a spawned forge's code repository it opens the one `forge/take-updates` pull and never moves the default branch",
1010 description:
11 "Take the parent forge's updates into a fork: a pull on a spawned forge, else a merge.",
11 "Take the parent forge's updates into a fork.",
1212 inputSchema: {
1313 type: "object",
1414 properties: { repo: REPO_ARG },
test/mcp-snapshot-now.test.ts added +76
@@ -0,0 +1,76 @@
1 /** MCP `snapshot_now`: the Backups page's "take one now", for a forge admin. */
2
3 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
4 import { mkdtempSync, rmSync } from "node:fs";
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, getUserByName } from "../src/core/users.ts";
10 import { serve, type Servers } from "../src/http/server.ts";
11 import { startSnapshots, type SnapshotsHandle } from "../src/snapshot/service.ts";
12 import { dirStore } from "../src/snapshot/store.ts";
13 import { quietLogs } from "./helpers/globals.ts";
14
15 quietLogs();
16
17 describe("MCP snapshot_now", () => {
18 let root = "";
19 let app: App;
20 let servers: Servers;
21 let svc: SnapshotsHandle | undefined;
22
23 const token = (name: string, scope: "admin" | "rw" = "admin") =>
24 createToken(app.db, getUserByName(app.db, name)!.id, "t", scope).token;
outdated

Every token here is admin scope, so nothing tests the scope check. Add a case: alice with an rw token is refused, and the store's list() stays empty.

25 const call = async (tok: string) => {
26 const res = await fetch(`${servers.url}/mcp`, {
27 method: "POST",
28 headers: { "content-type": "application/json", accept: "application/json, text/event-stream", authorization: `Bearer ${tok}` },
29 body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "snapshot_now", arguments: {} } }),
30 });
31 const body = (await res.json()) as any;
32 return { error: body.result.isError === true, text: body.result.content[0].text as string };
33 };
34
35 beforeAll(async () => {
36 root = mkdtempSync(join(tmpdir(), "forge-mcp-snap-"));
37 app = createApp({ dataDir: root, port: 0 });
38 for (const [username, isAdmin] of [["alice", true], ["bob", false]] as const) {
39 const made = await createUser(app.db, { origin: "seed", trusted: true, username, email: `${username}@x.test`, password: "a-long-snapshot-passphrase", isAdmin });
40 if (!made.ok) throw new Error(username);
41 }
42 servers = await serve(app);
43 }, 30_000);
44
45 afterAll(async () => {
46 servers.stop();
47 await svc?.stop();
48 app.close();
49 rmSync(root, { recursive: true, force: true });
50 });
51
52 test("with no store, it says the page's sentence", async () => {

Low: this test doesn't assert r.error === true. A regression that answered the no-store sentence as a success would still pass. Add expect(r.error).toBe(true). Also, the non-admin test below says "nothing is taken", but no store exists yet at that point, so that half can't fail. The rw case later is what actually proves it.

53 const r = await call(token("alice"));
54 expect(r.text).toContain("This forge has nowhere to put a snapshot.");

Nit, not blocking: this case doesn't assert r.error === true, so a regression that answered the sentence as a success would pass. Worth adding in a later touch.

55 });
56
57 test("a non-admin is refused and nothing is taken", async () => {
58 const r = await call(token("bob"));
59 expect(r.error).toBe(true);
60 expect(r.text).toContain("no such tool here");
61 });
62
63 test("an admin with a local store takes one, and it is listed after", async () => {
64 svc = startSnapshots(app, dirStore(join(root, "store")), { version: "t", nightly: false });
65 const weak = await call(token("alice", "rw"));
66 expect(weak.error).toBe(true);
67 expect(weak.text).toContain("admin-scope token");
68 expect(await svc.list()).toEqual([]);
69 const r = await call(token("alice"));
70 expect(r.error).toBe(false);
71 const out = JSON.parse(r.text);
72 expect(out.says).toContain(`Snapshot ${out.name} taken`);
73 expect(typeof out.bytes).toBe("number");
74 expect((await svc.list()).map((m) => m.name)).toContain(out.name);
75 });
76 });
test/mcp.test.ts +1
@@ -268,6 +268,7 @@ describe("the forge over MCP", () => {
268268 "returns",
269269 "review",
270270 "threads",
271 "snapshot_now",
271272 "spend",
272273 "usage",
273274 "labels",

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs