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

The MCP archive tool takes several repositories in one call, through the fleet operation, and its answer names the operation the undo tool reverts #2535

Merged opened this

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

Merged

scout/forge:scout/2533-the-mcp-archive-tool-takes-several-repos → main 3 commits fork 5 threads · 5 unresolved

  • Merged. scout/2533-the-mcp-archive-tool-takes-several-repos was merged into main as fa4d21c.

Review

Sign in to review this change.
4 files changed +125 −7
README.md +1 −1
@@ -396,7 +396,7 @@ and press **Archive**. This is the fleet's `archive` operation over those
396396 repositories (`POST /home/archive`, same-origin, a site admin's), so it has the
397397 fleet's receipt: the page that answers lists what was archived and an **Undo**
398398 button for the person who ran it (kept for a day), after which the apps are back
399 on the home. The terminal's dashboard archives and undoes the same way (below); either door refreshes an open web home (the `fleet.operated` event).
399 on the home. The terminal's dashboard archives and undoes the same way (below), and so does the MCP `archive` tool given `repos` (at most 50; it answers the `operation` id, which `limb` with `action: "undo"` reverts for a day); each door refreshes an open web home (the `fleet.operated` event).
400400
401401 ## Health
402402
docs/MCP.md +1 −1
@@ -39,7 +39,7 @@ for a tunnel and a forge that sleeps.
3939 | `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`) | |
4040 | `outsiders` | `{ repo, on?, dryRun? }`: whether people who cannot write may direct the crew; with `on`, planned first (`dryRun` is true unless `false`); set only by the app's admin with an admin token, never an agent | yes |
4141 | `decisions_by` | `{ repo, by?, dryRun? }`: the standing rule for a repository's hand-overs (`me`, `decider`, `decider_tell`); with `by`, planned first (`dryRun` is true unless `false`); set only by the app's admin with an admin token, never an agent | yes |
42 | `archive` | `{ repo, archived?, dryRun? }`: archive (true) or wake (false) a repository, by name; planned first (`dryRun` is true unless `false`); only the repository's admin with an admin token, never an agent | yes |
42 | `archive` | `{ repo | repos, archived?, dryRun? }`: archive (true) or wake (false) a repository, by name; planned first (`dryRun` is true unless `false`); only the repository's admin with an admin token, never an agent. `repos` (at most 50) runs the fleet `archive` operation over them, answers each one's outcome and the `operation` id; `limb` with `action: "undo"`, that `operation` and any one of the repositories as `app` reverts it for a day | yes |
4343 | `probe` | knock on a running app's doors as nobody, a user and an admin; the ones that disobey its contract | |
4444 | `following` | where a fork stands against what it follows, and what the crew is doing about it | |
4545 | `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 |
src/http/mcp.ts +70 −3
@@ -130,7 +130,7 @@ import {
130130 } from "../crew/direct.ts";
131131 import { setArchived, setCrewOutsiders, setDecisionsBy } from "../actions/repos.ts";
132132 import { mapJson, marksFor } from "../web/pages/map.ts";
133 import { undoFleetOp } from "../actions/fleet.ts";
133 import { applyFleetIds, previewFleetIds, undoFleetOp } from "../actions/fleet.ts";
134134 import { applyLimb, isLimbAction, previewLimb } from "../actions/limbs.ts";
135135 import {
136136 milestoneData,
@@ -264,6 +264,67 @@ function repoArg(ctx: Ctx, user: User, args: Args): Repo | Answer {
264264 : bad("not_found", "no such app or repository (owner/name)");
265265 }
266266
267 const MAX_ARCHIVE_REPOS = 50;
268
269 /** `archive` over `repos`: the fleet's own operation, each row's outcome and the id to undo. */
270 async function archiveMany(ctx: Ctx, user: User, args: Args): Promise<Answer> {
271 const names = args["repos"];
272 if (
273 !Array.isArray(names) ||
274 names.length === 0 ||
275 names.length > MAX_ARCHIVE_REPOS ||
276 names.some((n) => typeof n !== "string")
277 )
278 return bad("invalid", `\`repos\` is one to ${MAX_ARCHIVE_REPOS} names, owner/name each`);
279 if (args["repo"] !== undefined)
280 return bad("invalid", "give `repo` or `repos`, not both");

This refusal, and the non-boolean archived one two lines down, have no test. One expect each in the refusal test is enough.

281 if (args["archived"] !== undefined && typeof args["archived"] !== "boolean")
282 return bad("invalid", "`archived` is true or false");
283 const archived = args["archived"] ?? true;

archived: false over repos wakes several in one fleet operation. That is new, and no test runs it. Add one that wakes two archived repositories, checks both are awake, and checks an operation id comes back.

284 const dryRun = args["dryRun"] !== false;
285 if (user.kind === "agent")
286 return bad("forbidden", "archiving or waking is a person's call, not an agent's");
287 if (!dryRun && !scopeAllows(ctx.auth.scope ?? "ro", "admin"))
288 return bad("forbidden", "archiving or waking needs an admin token");
289 const ids: string[] = [];
290 const missing: string[] = [];
291 for (const name of new Set(names as string[])) {
292 const repo = repoArg(ctx, user, { repo: name });
293 if (isAnswer(repo)) missing.push(name);
294 else ids.push(repo.id);
295 }
296 const query = `mcp: ${ids.length + missing.length} named`;
outdated

Nit: names.length counts duplicates, and the receipt's query will say mcp: 3 named for two repositories. Using ids.length + missing.length would match the rows.

297 const action = { verb: "archive", value: archived };
298 const unknown = missing.map((repo) => ({
299 repo,
300 outcome: "refused",
301 reason: "no such app or repository (owner/name)",
302 }));
303 const rowsOf = (rows: readonly { label: string; outcome: string; reason: string }[]) => [
304 ...rows.map((r) => ({ repo: r.label, outcome: r.outcome, reason: r.reason })),
305 ...unknown,
306 ];
307 if (dryRun) {
308 const plan = previewFleetIds(ctx.app, user, "repo", ids, query, action);
309 if (!plan.ok) return bad(plan.error.code, plan.error.message);
310 return good({
311 dryRun: true,
312 archived,
313 rows: rowsOf(plan.value.rows),
314 says: "This is the plan; nothing changed. Send dryRun: false to do it.",
315 });
316 }
317 const op = await applyFleetIds(ctx.app, user, "repo", ids, query, action);

Nit, not blocking: if every name is unknown, ids is empty and this still records an empty operation and returns an operation id with nothing to undo. Returning the refused rows without applying when ids.length === 0 would keep the receipts list free of empty entries.

318 if (!op.ok) return bad(op.error.code, op.error.message);
319 return good({
320 dryRun: false,
321 archived,
322 operation: op.value.id,
323 rows: rowsOf(op.value.rows),
324 says: `Done. The \`limb\` tool with action undo and operation ${op.value.id} reverts it for 24 hours.`,
325 });
326 }
327
267328 const APP_ARG = {
268329 type: "string",
269330 description: "The app, as owner/name.",
@@ -1209,22 +1270,28 @@ const TOOLS: readonly Tool[] = [
12091270 {
12101271 name: "archive",
12111272 description:
1212 "Put a repository away (archive) or bring it back (wake), by name: nothing is deleted. Without `archived`, says which way it is. With `archived` (true to archive, false to wake), it plans — `dryRun` is true unless you say false — and only an admin of the repository, with an admin token and never as an agent, does it. Each change is recorded in the repository's activity.",
1273 "Put a repository away (archive) or bring it back (wake), by name: nothing is deleted. Without `archived`, says which way it is. With `archived` (true to archive, false to wake), it plans — `dryRun` is true unless you say false — and only an admin of the repository, with an admin token and never as an agent, does it. Each change is recorded in the repository's activity. To archive several at once, pass `repos` (owner/name each, at most 50) instead of `repo`: it runs the fleet `archive` operation, a dry run unless `dryRun` is false, and answers each repository's outcome and the `operation` id; the `limb` tool with action `undo` and that `operation` (with `app` naming any of them) reverts it for 24 hours.",

This text is the only place repos is documented. docs/MCP.md:42 still says { repo, archived?, dryRun? }, and the README's Archive section doesn't mention the MCP door. Please update both in this pull.

12131274 inputSchema: {
12141275 type: "object",
12151276 properties: {
12161277 repo: REPO_ARG,
1278 repos: {
1279 type: "array",
1280 items: { type: "string" },
1281 maxItems: MAX_ARCHIVE_REPOS,
1282 description: "Several repositories, as owner/name, to archive in one operation.",
1283 },
12171284 archived: { type: "boolean" },
12181285 dryRun: {
12191286 type: "boolean",
12201287 description: "Plan only (the default). Send false to do it.",
12211288 },
12221289 },
1223 required: ["repo"],
12241290 },
12251291 writes: true,
12261292 dryByDefault: true,
12271293 async run(ctx, user, args) {
1294 if (args["repos"] !== undefined) return archiveMany(ctx, user, args);
12281295 const repo = repoArg(ctx, user, args);
12291296 if (isAnswer(repo)) return repo;
12301297 const archived = args["archived"];
test/archive-mcp.test.ts +53 −2
@@ -18,6 +18,7 @@ let dataDir: string;
1818 let app: App;
1919 let servers: Servers;
2020 let repo: Repo;
21 let second: Repo;
2122 const tok: Record<string, string> = {};
2223
2324 const ok = <T>(r: { ok: true; value: T } | { ok: false; error: unknown }): T => {
@@ -26,7 +27,7 @@ const ok = <T>(r: { ok: true; value: T } | { ok: false; error: unknown }): T =>
2627 };
2728 const archived = (): boolean => getRepoById(app.db, repo.id)!.archivedAt !== null;
2829
29 async function mcp(token: string, args: unknown): Promise<{ error?: string; value?: any }> {
30 async function mcp(token: string, args: unknown, tool = "archive"): Promise<{ error?: string; value?: any }> {
3031 const res = await fetch(`${servers.url}/mcp`, {
3132 method: "POST",
3233 headers: {
@@ -38,7 +39,7 @@ async function mcp(token: string, args: unknown): Promise<{ error?: string; valu
3839 jsonrpc: "2.0",
3940 id: 1,
4041 method: "tools/call",
41 params: { name: "archive", arguments: args },
42 params: { name: tool, arguments: args },
4243 }),
4344 });
4445 const body = (await res.json()) as any;
@@ -65,6 +66,9 @@ beforeAll(async () => {
6566 repo = ok(
6667 await createRepo(app.db, app.reposRoot, { ownerId: org.id, name: "svc", visibility: "public" }),
6768 );
69 second = ok(
70 await createRepo(app.db, app.reposRoot, { ownerId: org.id, name: "web", visibility: "public" }),
71 );
6872 const bot = ok(await createAgent(app, founder, { username: "bot" }));
6973 expect(addToCrew(app, founder, repo, "bot").ok).toBe(true);
7074 const owners = listTeams(app.db, org.id).find((t) => t.name === OWNERS_TEAM)!;
@@ -111,3 +115,50 @@ test("an admin needs an admin token; then archives and wakes", async () => {
111115 expect(woke.value).toMatchObject({ archived: false, was: true });
112116 expect(archived()).toBe(false);
113117 });
118
119 const asleep = (r: Repo): boolean => getRepoById(app.db, r.id)!.archivedAt !== null;
120
121 test("`repos`: a dry run over two changes nothing; applying archives both; the undo wakes both", async () => {
122 const repos = ["acme/svc", "acme/web"];
123 const planned = await mcp(tok["founder"]!, { repos });
124 expect(planned.value.dryRun).toBe(true);
125 expect(planned.value.rows.map((r: any) => r.outcome)).toEqual(["change", "change"]);
126 expect(asleep(repo) || asleep(second)).toBe(false);
127
128 const done = await mcp(tok["founder"]!, { repos, dryRun: false });
129 expect(done.value.rows.map((r: any) => r.repo)).toEqual(["acme/svc", "acme/web"]);
130 expect(done.value.rows.every((r: any) => r.outcome === "ok")).toBe(true);
131 expect(asleep(repo) && asleep(second)).toBe(true);
132
133 const undone = await mcp(
134 tok["founder"]!,
135 { app: "acme/svc", action: "undo", operation: done.value.operation, dryRun: false },
136 "limb",
137 );
138 expect(undone.error).toBeUndefined();
139 expect(asleep(repo) || asleep(second)).toBe(false);
140 });
141
142 test("`repos`: a non-admin is refused per row, an agent and a small token are refused outright", async () => {
143 const repos = ["acme/svc", "acme/web", "acme/nope"];
144 const tried = await mcp(tok["mallory"]!, { repos, dryRun: false });
145 expect(tried.value.rows.map((r: any) => r.outcome)).toEqual(["refused", "refused", "refused"]);
146 expect(asleep(repo) || asleep(second)).toBe(false);
147 expect((await mcp(tok["bot"]!, { repos })).error).toContain("forbidden");
148 expect((await mcp(tok["founderRw"]!, { repos, dryRun: false })).error).toContain("forbidden");
149 expect((await mcp(tok["founder"]!, { repos: [] })).error).toContain("invalid");
150 expect((await mcp(tok["founder"]!, { repo: "acme/svc", repos })).error).toContain("invalid");
151 expect((await mcp(tok["founder"]!, { repos, archived: "yes" })).error).toContain("invalid");
152 expect((await mcp(tok["founder"]!, { repos: Array(51).fill("acme/svc") })).error).toContain("invalid");
153 expect(asleep(repo) || asleep(second)).toBe(false);
154 });
155
156 test("`repos` with `archived: false` wakes several in one operation", async () => {
157 const repos = ["acme/svc", "acme/web"];
158 await mcp(tok["founder"]!, { repos, dryRun: false });
159 expect(asleep(repo) && asleep(second)).toBe(true);
160 const woke = await mcp(tok["founder"]!, { repos, archived: false, dryRun: false });
161 expect(woke.value.rows.map((r: any) => r.outcome)).toEqual(["ok", "ok"]);
162 expect(typeof woke.value.operation).toBe("string");
163 expect(asleep(repo) || asleep(second)).toBe(false);
164 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs