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
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/5422-mcp-snapshot-now-take-a-snapshot-now-as → main 11 commits fork 5 threads · 5 unresolved
docs/MCP.md +1
| @@ -132,6 +132,7 @@ for a tunnel and a forge that sleeps. | ||
| 132 | 132 | | `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 | |
| 133 | 133 | | `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 | |
| 134 | 134 | | `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 | | |
| 135 | 136 | | `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 | |
| 136 | 137 | | `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 | |
| 137 | 138 | | `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 = { | ||
| 7 | 7 | name: "account_role", |
| 8 | 8 | 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`)", |
| 9 | 9 | 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`.", | |
| 11 | 11 | inputSchema: { |
| 12 | 12 | type: "object", |
| 13 | 13 | properties: { |
src/http/mcp/tools/cloud_accounts.ts +1 −1
| @@ -6,7 +6,7 @@ export const tool: Tool = { | ||
| 6 | 6 | name: "cloud_accounts", |
| 7 | 7 | 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)", |
| 8 | 8 | description: |
| 9 | "Cloud accounts (admin): each token's use and verification.", | |
| 9 | "Cloud accounts (admin): each token's use.", | |
| 10 | 10 | inputSchema: { type: "object", properties: {} }, |
| 11 | 11 | // A read, but marked a write so a `ro` token never lists the accounts; it |
| 12 | 12 | // 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"; | ||
| 109 | 109 | export { tool as share } from "./share.ts"; |
| 110 | 110 | export { tool as signup } from "./signup.ts"; |
| 111 | 111 | export { tool as sizes } from "./sizes.ts"; |
| 112 | export { tool as snapshot_now } from "./snapshot_now.ts"; | |
| 112 | 113 | export { tool as spawn } from "./spawn.ts"; |
| 113 | 114 | export { tool as spend } from "./spend.ts"; |
| 114 | 115 | 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 "../ | ||
| 5 | 5 | export const tool: Tool = { |
| 6 | 6 | name: "issue_teams", |
| 7 | 7 | 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.", | |
| 9 | 9 | inputSchema: { |
| 10 | 10 | type: "object", |
| 11 | 11 | properties: { |
src/http/mcp/tools/mint_invite.ts +1 −1
| @@ -7,7 +7,7 @@ export const tool: Tool = { | ||
| 7 | 7 | name: "mint_invite", |
| 8 | 8 | 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", |
| 9 | 9 | 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.", | |
| 11 | 11 | inputSchema: { |
| 12 | 12 | type: "object", |
| 13 | 13 | 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 = { | ||
| 8 | 8 | name: "take_updates", |
| 9 | 9 | 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", |
| 10 | 10 | 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.", | |
| 12 | 12 | inputSchema: { |
| 13 | 13 | type: "object", |
| 14 | 14 | 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; | |
| 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 |
||
| 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 |
||
| 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", () => { | ||
| 268 | 268 | "returns", |
| 269 | 269 | "review", |
| 270 | 270 | "threads", |
| 271 | "snapshot_now", | |
| 271 | 272 | "spend", |
| 272 | 273 | "usage", |
| 273 | 274 | "labels", |
Every token here is
adminscope, so nothing tests the scope check. Add a case: alice with anrwtoken is refused, and the store'slist()stays empty.