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
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/2533-the-mcp-archive-tool-takes-several-repos → main 3 commits fork 5 threads · 5 unresolved
README.md +1 −1
| @@ -396,7 +396,7 @@ and press **Archive**. This is the fleet's `archive` operation over those | ||
| 396 | 396 | repositories (`POST /home/archive`, same-origin, a site admin's), so it has the |
| 397 | 397 | fleet's receipt: the page that answers lists what was archived and an **Undo** |
| 398 | 398 | 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). | |
| 400 | 400 | |
| 401 | 401 | ## Health |
| 402 | 402 |
docs/MCP.md +1 −1
| @@ -39,7 +39,7 @@ for a tunnel and a forge that sleeps. | ||
| 39 | 39 | | `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`) | | |
| 40 | 40 | | `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 | |
| 41 | 41 | | `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 | | |
| 43 | 43 | | `probe` | knock on a running app's doors as nobody, a user and an admin; the ones that disobey its contract | | |
| 44 | 44 | | `following` | where a fork stands against what it follows, and what the crew is doing about it | | |
| 45 | 45 | | `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 { | ||
| 130 | 130 | } from "../crew/direct.ts"; |
| 131 | 131 | import { setArchived, setCrewOutsiders, setDecisionsBy } from "../actions/repos.ts"; |
| 132 | 132 | import { mapJson, marksFor } from "../web/pages/map.ts"; |
| 133 | import { undoFleetOp } from "../actions/fleet.ts"; | |
| 133 | import { applyFleetIds, previewFleetIds, undoFleetOp } from "../actions/fleet.ts"; | |
| 134 | 134 | import { applyLimb, isLimbAction, previewLimb } from "../actions/limbs.ts"; |
| 135 | 135 | import { |
| 136 | 136 | milestoneData, |
| @@ -264,6 +264,67 @@ function repoArg(ctx: Ctx, user: User, args: Args): Repo | Answer { | ||
| 264 | 264 | : bad("not_found", "no such app or repository (owner/name)"); |
| 265 | 265 | } |
| 266 | 266 | |
| 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"); | |
| 281 | if (args["archived"] !== undefined && typeof args["archived"] !== "boolean") | |
| 282 | return bad("invalid", "`archived` is true or false"); | |
| 283 | const archived = args["archived"] ?? true; | |
|
||
| 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: |
||
| 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, |
||
| 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 | ||
| 267 | 328 | const APP_ARG = { |
| 268 | 329 | type: "string", |
| 269 | 330 | description: "The app, as owner/name.", |
| @@ -1209,22 +1270,28 @@ const TOOLS: readonly Tool[] = [ | ||
| 1209 | 1270 | { |
| 1210 | 1271 | name: "archive", |
| 1211 | 1272 | 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 |
||
| 1213 | 1274 | inputSchema: { |
| 1214 | 1275 | type: "object", |
| 1215 | 1276 | properties: { |
| 1216 | 1277 | 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 | }, | |
| 1217 | 1284 | archived: { type: "boolean" }, |
| 1218 | 1285 | dryRun: { |
| 1219 | 1286 | type: "boolean", |
| 1220 | 1287 | description: "Plan only (the default). Send false to do it.", |
| 1221 | 1288 | }, |
| 1222 | 1289 | }, |
| 1223 | required: ["repo"], | |
| 1224 | 1290 | }, |
| 1225 | 1291 | writes: true, |
| 1226 | 1292 | dryByDefault: true, |
| 1227 | 1293 | async run(ctx, user, args) { |
| 1294 | if (args["repos"] !== undefined) return archiveMany(ctx, user, args); | |
| 1228 | 1295 | const repo = repoArg(ctx, user, args); |
| 1229 | 1296 | if (isAnswer(repo)) return repo; |
| 1230 | 1297 | const archived = args["archived"]; |
test/archive-mcp.test.ts +53 −2
| @@ -18,6 +18,7 @@ let dataDir: string; | ||
| 18 | 18 | let app: App; |
| 19 | 19 | let servers: Servers; |
| 20 | 20 | let repo: Repo; |
| 21 | let second: Repo; | |
| 21 | 22 | const tok: Record<string, string> = {}; |
| 22 | 23 | |
| 23 | 24 | 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 => | ||
| 26 | 27 | }; |
| 27 | 28 | const archived = (): boolean => getRepoById(app.db, repo.id)!.archivedAt !== null; |
| 28 | 29 | |
| 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 }> { | |
| 30 | 31 | const res = await fetch(`${servers.url}/mcp`, { |
| 31 | 32 | method: "POST", |
| 32 | 33 | headers: { |
| @@ -38,7 +39,7 @@ async function mcp(token: string, args: unknown): Promise<{ error?: string; valu | ||
| 38 | 39 | jsonrpc: "2.0", |
| 39 | 40 | id: 1, |
| 40 | 41 | method: "tools/call", |
| 41 | params: { name: "archive", arguments: args }, | |
| 42 | params: { name: tool, arguments: args }, | |
| 42 | 43 | }), |
| 43 | 44 | }); |
| 44 | 45 | const body = (await res.json()) as any; |
| @@ -65,6 +66,9 @@ beforeAll(async () => { | ||
| 65 | 66 | repo = ok( |
| 66 | 67 | await createRepo(app.db, app.reposRoot, { ownerId: org.id, name: "svc", visibility: "public" }), |
| 67 | 68 | ); |
| 69 | second = ok( | |
| 70 | await createRepo(app.db, app.reposRoot, { ownerId: org.id, name: "web", visibility: "public" }), | |
| 71 | ); | |
| 68 | 72 | const bot = ok(await createAgent(app, founder, { username: "bot" })); |
| 69 | 73 | expect(addToCrew(app, founder, repo, "bot").ok).toBe(true); |
| 70 | 74 | 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 () => { | ||
| 111 | 115 | expect(woke.value).toMatchObject({ archived: false, was: true }); |
| 112 | 116 | expect(archived()).toBe(false); |
| 113 | 117 | }); |
| 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 | }); |
This refusal, and the non-boolean
archivedone two lines down, have no test. Oneexpecteach in the refusal test is enough.