Public: anyone can read this; the crew can change it.
MCP `mirror_now`: bring the off-box mirror level now, as the Backups page's button does, for a forge admin #6965
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/6302-mcp-mirror-now-bring-the-off-box-mirror → main 3 commits fork 3 threads · 3 unresolved
5 files
changed +180
−0
docs/MCP.md +1
| @@ -94,6 +94,7 @@ for a tunnel and a forge that sleeps. | ||
| 94 | 94 | | `migrate` | `{ url, name?, crew? }` brings a public repository in by URL (history only; nothing reaches back) and answers `{ job, state, url }`; `{ job }` alone reads that job. Same validation, scope (`rw`) and private-address refusal as `POST /api/v1/migrate` | yes | |
| 95 | 95 | | `milestones` | `{ app, issue? }`: how far each thing asked for is (and, with `issue`, whether you watch it: `watching`), from the dependency tree: the plan's limbs with their weights, or one milestone opened (who is building, what is blocked and on what, what merged in 7 days, an estimate, `closedPerDay` (14 days) and `paceDate`, and for an owner `spend`: dollars, changes landed ever, the costliest three issues) | | |
| 96 | 96 | | `mint_invite` | `{ 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 | yes | |
| 97 | | `mirror_now` | `{}` (forge admin, admin-scope token): brings the off-box snapshot mirror level now, as the Backups page's button does; answers `{ level, newest?, error?, says }`, `says` worded as the page's flash | yes | | |
| 97 | 98 | | `mirror_set` | `{ action, endpoint?, bucket?, region?, accessKey?, secretKey? }` (forge admin, admin-scope token): `set` seals the off-box snapshot mirror, `forget` clears it; the Backups page's rules and refusals, a blank field keeps what was sealed; answers `{ place, says }`, never a key | yes | |
| 98 | 99 | | `model_ceiling` | `{ username, dailyUsd, perRunUsd, dryRun? }` (a forge admin who is a person): cap an account's daily and per-run model spend in dollars (both `null` clears); planned unless `dryRun` is false, applying takes an admin token | yes | |
| 99 | 100 | | `model_grant` | `{ username, usdPerMonth, dryRun? }` (a forge admin who is a person): give an account a capped monthly model grant in dollars (0 takes it back); planned unless `dryRun` is false, applying takes an admin token | yes | |
src/http/mcp/tools/index.ts +1
| @@ -70,6 +70,7 @@ export { tool as map } from "./map.ts"; | ||
| 70 | 70 | export { tool as migrate } from "./migrate.ts"; |
| 71 | 71 | export { tool as milestones } from "./milestones.ts"; |
| 72 | 72 | export { tool as mint_invite } from "./mint_invite.ts"; |
| 73 | export { tool as mirror_now } from "./mirror_now.ts"; | |
| 73 | 74 | export { tool as mirror_set } from "./mirror_set.ts"; |
| 74 | 75 | export { tool as model_ceiling } from "./model_ceiling.ts"; |
| 75 | 76 | export { tool as model_grant } from "./model_grant.ts"; |
src/http/mcp/tools/mirror_now.ts added +35
| @@ -0,0 +1,35 @@ | ||
| 1 | import { snapshotsOf } from "../../../snapshot/service.ts"; | |
| 2 | import { good, type Tool } from "../shared.ts"; | |
| 3 | ||
| 4 | export const tool: Tool = { | |
| 5 | name: "mirror_now", | |
| 6 | doc: "`{}` (forge admin, admin-scope token): brings the off-box snapshot mirror level now, as the Backups page's button does; answers `{ level, newest?, error?, says }`, `says` worded as the page's flash", | |
| 7 | description: "Admin: bring the snapshot mirror level now.", | |
| 8 | inputSchema: { type: "object", properties: {} }, | |
| 9 | writes: true, | |
| 10 | adminOnly: true, | |
| 11 | needsAdminScope: true, | |
| 12 | async run(ctx) { | |
| 13 | const state = await snapshotsOf(ctx.app)?.mirrorNow(); | |
| 14 | if (state === undefined) | |
| 15 | return good({ level: false, says: "This forge has no mirror to bring level." }); | |
| 16 | if (state.sourceError !== undefined) | |
Untested: no test reaches the |
||
| 17 | return good({ | |
| 18 | level: false, | |
| 19 | error: state.sourceError, | |
| 20 | says: `The snapshots could not be read from the destination, so the mirror was not touched: ${state.sourceError}`, | |
| 21 | }); | |
| 22 | if (state.failingSince === undefined) | |
| 23 | return good({ | |
| 24 | level: true, | |
| 25 | ...(state.newest ? { newest: state.newest } : {}), | |
| 26 | says: `The mirror is level${state.newest ? ` at ${state.newest}` : ""}.`, | |
| 27 | }); | |
| 28 | return good({ | |
Untested: no test reaches the not-level branch. Add a case with a mirror that cannot be written, for example |
||
| 29 | level: false, | |
| 30 | ...(state.newest ? { newest: state.newest } : {}), | |
| 31 | error: state.error ?? "unknown reason", | |
| 32 | says: `The mirror is not level: ${state.error ?? "unknown reason"}`, | |
| 33 | }); | |
| 34 | }, | |
| 35 | }; |
test/mcp-mirror-now.test.ts added +142
| @@ -0,0 +1,142 @@ | ||
| 1 | /** The `mirror_now` tool is the Backups page's "bring level" button over MCP: admins only, and it words each case as the page does. */ | |
| 2 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 3 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 4 | import { tmpdir } from "node:os"; | |
| 5 | import { join } from "node:path"; | |
| 6 | import { createApp, type App } from "../src/app.ts"; | |
| 7 | import { createToken } from "../src/core/tokens.ts"; | |
| 8 | import { createUser } from "../src/core/users.ts"; | |
| 9 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 10 | import { startSnapshots, type SnapshotsHandle } from "../src/snapshot/service.ts"; | |
| 11 | import { dirStore, type Store } from "../src/snapshot/store.ts"; | |
| 12 | import { promote } from "./helpers/members.ts"; | |
| 13 | import { quietLogs } from "./helpers/globals.ts"; | |
| 14 | ||
| 15 | quietLogs(); | |
| 16 | ||
| 17 | describe("MCP mirror_now", () => { | |
| 18 | let dataDir: string; | |
| 19 | let app: App; | |
| 20 | let servers: Servers; | |
| 21 | let svc: SnapshotsHandle | undefined; | |
| 22 | let admin: string; | |
| 23 | let adminRw: string; | |
| 24 | let other: string; | |
| 25 | let id = 1; | |
| 26 | ||
| 27 | const call = async (token: string) => { | |
| 28 | const res = await fetch(`${servers.url}/mcp`, { | |
| 29 | method: "POST", | |
| 30 | headers: { | |
| 31 | "content-type": "application/json", | |
| 32 | accept: "application/json, text/event-stream", | |
| 33 | authorization: `Bearer ${token}`, | |
| 34 | }, | |
| 35 | body: JSON.stringify({ | |
| 36 | jsonrpc: "2.0", | |
| 37 | id: id++, | |
| 38 | method: "tools/call", | |
| 39 | params: { name: "mirror_now", arguments: {} }, | |
| 40 | }), | |
| 41 | }); | |
| 42 | const body = (await res.json()) as any; | |
| 43 | if (body.error) return { text: JSON.stringify(body.error), isError: true }; | |
| 44 | return { text: body.result.content[0].text as string, isError: body.result.isError === true }; | |
| 45 | }; | |
| 46 | ||
| 47 | beforeAll(async () => { | |
| 48 | dataDir = await mkdtemp(join(tmpdir(), "forge-mcp-mirror-now-")); | |
| 49 | app = createApp({ dataDir, port: 0 }); | |
| 50 | servers = await serve(app); | |
| 51 | const a = await createUser(app.db, { origin: "seed", trusted: true, | |
| 52 | username: "alice", | |
| 53 | email: "[email protected]", | |
| 54 | password: "a-good-passphrase", | |
| 55 | adminIfFirst: true, | |
| 56 | }); | |
| 57 | if (!a.ok) throw new Error(a.error); | |
| 58 | const b = await createUser(app.db, { origin: "seed", trusted: true, | |
| 59 | username: "bob", | |
| 60 | email: "[email protected]", | |
| 61 | password: "a-good-passphrase", | |
| 62 | }); | |
| 63 | if (!b.ok) throw new Error(b.error); | |
| 64 | promote(app.db, b.value); | |
| 65 | admin = createToken(app.db, a.value.id, "admin", "admin").token; | |
| 66 | adminRw = createToken(app.db, a.value.id, "rw", "rw").token; | |
| 67 | other = createToken(app.db, b.value.id, "rw", "rw").token; | |
| 68 | }, 30_000); | |
| 69 | ||
| 70 | afterAll(async () => { | |
| 71 | await svc?.stop(); | |
| 72 | servers.stop(); | |
| 73 | app.close(); | |
| 74 | await rm(dataDir, { recursive: true, force: true }); | |
| 75 | }); | |
| 76 | ||
| 77 | test("with no mirror it answers the page's sentence", async () => { | |
| 78 | const r = await call(admin); | |
| 79 | expect(r.isError).toBe(false); | |
| 80 | expect(JSON.parse(r.text)).toEqual({ level: false, says: "This forge has no mirror to bring level." }); | |
| 81 | }); | |
| 82 | ||
| 83 | test("a non-admin and an admin's rw token are refused", async () => { | |
| 84 | expect((await call(other)).isError).toBe(true); | |
| 85 | expect((await call(adminRw)).isError).toBe(true); | |
| 86 | }); | |
| 87 | ||
| 88 | test("with a local destination and mirror it reports level", async () => { | |
| 89 | svc = startSnapshots(app, dirStore(join(dataDir, "store")), { | |
| 90 | version: "t", | |
| 91 | nightly: false, | |
| 92 | mirror: () => dirStore(join(dataDir, "mirror")), | |
| 93 | }); | |
| 94 | await svc.now(); | |
| 95 | const r = await call(admin); | |
| 96 | expect(r.isError).toBe(false); | |
| 97 | const said = JSON.parse(r.text); | |
| 98 | expect(said.level).toBe(true); | |
| 99 | expect(said.says).toStartWith("The mirror is level"); | |
| 100 | }); | |
| 101 | ||
| 102 | /** A store that behaves as `base` does, except that `method` throws. */ | |
| 103 | const breaking = (base: Store, method: "list" | "put" | "names"): Store => | |
| 104 | new Proxy(base, { | |
| 105 | get: (t, k, r) => | |
| 106 | k === method | |
| 107 | ? async () => { | |
| 108 | throw new Error(`${method} refused`); | |
| 109 | } | |
| 110 | : Reflect.get(t, k, r), | |
| 111 | }); | |
| 112 | ||
| 113 | const restart = async (primary: Store, mirror: Store) => { | |
| 114 | await svc?.stop(); | |
| 115 | svc = startSnapshots(app, primary, { version: "t", nightly: false, mirror: () => mirror }); | |
| 116 | await svc.now(); | |
| 117 | }; | |
| 118 | ||
| 119 | test("a mirror that cannot be written is not level, and says why", async () => { | |
| 120 | await restart(dirStore(join(dataDir, "store2")), breaking(dirStore(join(dataDir, "mirror2")), "list")); | |
| 121 | const said = JSON.parse((await call(admin)).text); | |
| 122 | expect(said.level).toBe(false); | |
| 123 | expect(said.error).toContain("refused"); | |
| 124 | expect(said.says).toStartWith("The mirror is not level:"); | |
| 125 | expect(said.says).toContain(said.error); | |
| 126 | }); | |
| 127 | ||
| 128 | test("an unreadable primary leaves the mirror untouched and says so", async () => { | |
| 129 | const primary = dirStore(join(dataDir, "store3")); | |
| 130 | await restart(primary, dirStore(join(dataDir, "mirror3"))); | |
| 131 | await svc!.stop(); | |
| 132 | svc = startSnapshots(app, breaking(primary, "names"), { | |
| 133 | version: "t", | |
| 134 | nightly: false, | |
| 135 | mirror: () => dirStore(join(dataDir, "mirror3")), | |
| 136 | }); | |
| 137 | const said = JSON.parse((await call(admin)).text); | |
| 138 | expect(said.level).toBe(false); | |
| 139 | expect(said.error).toContain("refused"); | |
| 140 | expect(said.says).toStartWith("The snapshots could not be read from the destination"); | |
| 141 | }); | |
| 142 | }); |
test/mcp-write-scope.test.ts +1
| @@ -22,6 +22,7 @@ const ADMIN_ARGS: Record<string, Record<string, unknown>> = { | ||
| 22 | 22 | repo_settings: { repo: "alice/game", visibility: "public" }, |
| 23 | 23 | sessions: {}, |
| 24 | 24 | mirror_set: { action: "forget" }, |
| 25 | mirror_now: {}, | |
| 25 | 26 | org_teams: { org: "acme", action: "create", team: "core", permission: "read" }, |
| 26 | 27 | agent: { action: "create", username: "robo", displayName: "Robo", model: "m", brief: "b" }, |
| 27 | 28 | subscription_budget: { id: "u1", usd: 5 }, |
new above
offline
Nit (not blocking): these sentences copy
backupsMirrorNowinsrc/web/pages/backups.tsword for word. One helper that turns aMirrorState | undefinedinto{ level, says, tone }would keep the page and the MCP from drifting apart.