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

Merged opened this

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

Merged

scout/forge:scout/6302-mcp-mirror-now-bring-the-off-box-mirror → main 3 commits fork 3 threads · 3 unresolved

  • Merged. scout/6302-mcp-mirror-now-bring-the-off-box-mirror was merged into main as 0b0948a.

Review

Sign in to review this change.
5 files changed +180 −0
docs/MCP.md +1
@@ -94,6 +94,7 @@ for a tunnel and a forge that sleeps.
9494 | `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 |
9595 | `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) | |
9696 | `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 |
9798 | `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 |
9899 | `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 |
99100 | `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";
7070 export { tool as migrate } from "./migrate.ts";
7171 export { tool as milestones } from "./milestones.ts";
7272 export { tool as mint_invite } from "./mint_invite.ts";
73 export { tool as mirror_now } from "./mirror_now.ts";
7374 export { tool as mirror_set } from "./mirror_set.ts";
7475 export { tool as model_ceiling } from "./model_ceiling.ts";
7576 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)

Nit (not blocking): these sentences copy backupsMirrorNow in src/web/pages/backups.ts word for word. One helper that turns a MirrorState | undefined into { level, says, tone } would keep the page and the MCP from drifting apart.

15 return good({ level: false, says: "This forge has no mirror to bring level." });
16 if (state.sourceError !== undefined)

Untested: no test reaches the sourceError branch. Add a case with a primary store whose list throws, and assert level: false, error, and the says prefix.

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 dirStore over a regular file, and assert level: false, a non-empty error, and says starting The mirror is not level:.

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>> = {
2222 repo_settings: { repo: "alice/game", visibility: "public" },
2323 sessions: {},
2424 mirror_set: { action: "forget" },
25 mirror_now: {},
2526 org_teams: { org: "acme", action: "create", team: "core", permission: "read" },
2627 agent: { action: "create", username: "robo", displayName: "Robo", model: "m", brief: "b" },
2728 subscription_budget: { id: "u1", usd: 5 },

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs