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

Archive, part 4: MCP can archive and wake a repository, and tools/list names the tool #2464

Merged opened this

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

Merged

scout/forge:scout/1841-archive-part-4-mcp-can-archive-and-wake → main 1 commit fork

  • Merged. scout/1841-archive-part-4-mcp-can-archive-and-wake was merged into main as aae993b.

Review

Sign in to review this change.
5 files changed +186 −1
docs/MCP.md +1
@@ -39,6 +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 |
4243 | `probe` | knock on a running app's doors as nobody, a user and an admin; the ones that disobey its contract | |
4344 | `following` | where a fork stands against what it follows, and what the crew is doing about it | |
4445 | `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/actions/repos.ts +30
@@ -405,6 +405,36 @@ export async function setDecisionsBy(
405405 return ok({ by: updated.value.decisionsBy, was: repo.decisionsBy, dryRun });
406406 }
407407
408 export interface ArchiveAnswer {
409 /** Whether the repository is archived, after this call. */
410 readonly archived: boolean;
411 /** Whether it was before. */
412 readonly was: boolean;
413 /** Nothing changed: this was the plan. */
414 readonly dryRun: boolean;
415 }
416
417 /**
418 * Archive (true) or wake (false) a repository, for the MCP: the same
419 * `updateRepoSettings` the API's PATCH goes through. A repository admin's,
420 * never an agent's; a dry run answers what would change and changes nothing.
421 */
422 export async function setArchived(
423 app: App,
424 actor: User | undefined,
425 repo: Repo,
426 archived: boolean,
427 dryRun: boolean,
428 ): Promise<Result<ArchiveAnswer, "not_found">> {
429 const admin = requireRepoAdmin(app, actor, repo);
430 if (!admin.ok || admin.value.kind === "agent") return err("not_found");
431 const was = repo.archivedAt !== null;
432 if (dryRun) return ok({ archived, was, dryRun });
433 const updated = await updateRepoSettings(app, actor, repo, { archived });
434 if (!updated.ok) return err("not_found");
435 return ok({ archived: updated.value.archivedAt !== null, was, dryRun });
436 }
437
408438 export async function deleteRepo(
409439 app: App,
410440 actor: User | undefined,
src/http/mcp.ts +42 −1
@@ -127,7 +127,7 @@ import {
127127 directionNote,
128128 OUTSIDERS_NOTE,
129129 } from "../crew/direct.ts";
130 import { setCrewOutsiders, setDecisionsBy } from "../actions/repos.ts";
130 import { setArchived, setCrewOutsiders, setDecisionsBy } from "../actions/repos.ts";
131131 import { mapJson, marksFor } from "../web/pages/map.ts";
132132 import { undoFleetOp } from "../actions/fleet.ts";
133133 import { applyLimb, isLimbAction, previewLimb } from "../actions/limbs.ts";
@@ -1205,6 +1205,47 @@ const TOOLS: readonly Tool[] = [
12051205 });
12061206 },
12071207 },
1208 {
1209 name: "archive",
1210 description:
1211 "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.",
1212 inputSchema: {
1213 type: "object",
1214 properties: {
1215 repo: REPO_ARG,
1216 archived: { type: "boolean" },
1217 dryRun: {
1218 type: "boolean",
1219 description: "Plan only (the default). Send false to do it.",
1220 },
1221 },
1222 required: ["repo"],
1223 },
1224 writes: true,
1225 dryByDefault: true,
1226 async run(ctx, user, args) {
1227 const repo = repoArg(ctx, user, args);
1228 if (isAnswer(repo)) return repo;
1229 const archived = args["archived"];
1230 if (archived === undefined) return good({ archived: repo.archivedAt !== null });
1231 if (typeof archived !== "boolean")
1232 return bad("invalid", "`archived` is true or false");
1233 const dryRun = args["dryRun"] !== false;
1234 if (!dryRun && !scopeAllows(ctx.auth.scope ?? "ro", "admin"))
1235 return bad("forbidden", "archiving or waking needs an admin token");
1236 const set = await setArchived(ctx.app, user, repo, archived, dryRun);
1237 if (!set.ok)
1238 return bad("forbidden", "only an admin of this repository archives or wakes it");
1239 return good({
1240 ...set.value,
1241 says: dryRun
1242 ? `This is the plan; nothing changed. It is ${set.value.was ? "archived" : "awake"} now; send dryRun: false to ${set.value.archived ? "archive" : "wake"} it.`
1243 : set.value.archived
1244 ? "Archived: put away, not deleted."
1245 : "Awake again.",
1246 });
1247 },
1248 },
12081249 {
12091250 name: "probe",
12101251 description:
test/archive-mcp.test.ts added +112
@@ -0,0 +1,112 @@
1 /** MCP `archive`: an admin archives or wakes by name; a non-admin, an agent and a small token are refused. */
2 import { afterAll, beforeAll, 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 { addToCrew, createAgent } from "../src/actions/crew.ts";
8 import { addTeamMember, createOrg, listTeams, OWNERS_TEAM } from "../src/core/orgs.ts";
9 import { createRepo, getRepoById, type Repo } from "../src/core/repos.ts";
10 import { createToken } from "../src/core/tokens.ts";
11 import { createUser } from "../src/core/users.ts";
12 import { serve, type Servers } from "../src/http/server.ts";
13 import { quietLogs } from "./helpers/globals.ts";
14
15 quietLogs();
16
17 let dataDir: string;
18 let app: App;
19 let servers: Servers;
20 let repo: Repo;
21 const tok: Record<string, string> = {};
22
23 const ok = <T>(r: { ok: true; value: T } | { ok: false; error: unknown }): T => {
24 if (!r.ok) throw new Error(JSON.stringify(r.error));
25 return r.value;
26 };
27 const archived = (): boolean => getRepoById(app.db, repo.id)!.archivedAt !== null;
28
29 async function mcp(token: string, args: unknown): Promise<{ error?: string; value?: any }> {
30 const res = await fetch(`${servers.url}/mcp`, {
31 method: "POST",
32 headers: {
33 "content-type": "application/json",
34 accept: "application/json, text/event-stream",
35 authorization: `Bearer ${token}`,
36 },
37 body: JSON.stringify({
38 jsonrpc: "2.0",
39 id: 1,
40 method: "tools/call",
41 params: { name: "archive", arguments: args },
42 }),
43 });
44 const body = (await res.json()) as any;
45 const text: string = body.result.content[0].text;
46 return body.result.isError === true ? { error: text } : { value: JSON.parse(text) };
47 }
48
49 beforeAll(async () => {
50 dataDir = await mkdtemp(join(tmpdir(), "forge-archive-mcp-"));
51 app = createApp({ dataDir, port: 0 });
52 servers = await serve(app);
53 const make = (username: string, adminIfFirst = false) =>
54 createUser(app.db, {
55 trusted: true,
56 username,
57 email: `${username}@forge.local`,
58 password: "a-good-passphrase",
59 adminIfFirst,
60 });
61 const founder = ok(await make("founder", true));
62 const mallory = ok(await make("mallory"));
63 const org = ok(createOrg(app.db, "acme", founder.id));
64 repo = ok(
65 await createRepo(app.db, app.reposRoot, { ownerId: org.id, name: "svc", visibility: "public" }),
66 );
67 const bot = ok(await createAgent(app, founder, { username: "bot" }));
68 expect(addToCrew(app, founder, repo, "bot").ok).toBe(true);
69 const owners = listTeams(app.db, org.id).find((t) => t.name === OWNERS_TEAM)!;
70 addTeamMember(app.db, owners, bot.user.id);
71 tok["founder"] = createToken(app.db, founder.id, "admin", "admin").token;
72 tok["founderRw"] = createToken(app.db, founder.id, "rw", "rw").token;
73 tok["mallory"] = createToken(app.db, mallory.id, "admin", "admin").token;
74 tok["bot"] = createToken(app.db, bot.user.id, "admin", "admin").token;
75 }, 30_000);
76
77 afterAll(async () => {
78 servers?.stop();
79 app?.close();
80 await rm(dataDir, { recursive: true, force: true });
81 });
82
83 test("without `archived` it says how the repository is; with it, it plans first", async () => {
84 expect((await mcp(tok["mallory"]!, { repo: "acme/svc" })).value).toEqual({ archived: false });
85 const planned = await mcp(tok["founder"]!, { repo: "acme/svc", archived: true });
86 expect(planned.value).toMatchObject({ dryRun: true, archived: true, was: false });
87 expect(archived()).toBe(false);
88 });
89
90 test("a non-admin and an admin agent are refused, planning or not", async () => {
91 for (const who of ["mallory", "bot"])
92 for (const dryRun of [true, false]) {
93 const tried = await mcp(tok[who]!, { repo: "acme/svc", archived: true, dryRun });
94 expect(tried.error).toContain("forbidden");
95 expect(archived()).toBe(false);
96 }
97 });
98
99 test("an admin needs an admin token; then archives and wakes", async () => {
100 const small = await mcp(tok["founderRw"]!, { repo: "acme/svc", archived: true, dryRun: false });
101 expect(small.error).toContain("forbidden");
102 expect(archived()).toBe(false);
103 expect((await mcp(tok["founder"]!, { repo: "acme/svc", archived: "yes" })).error).toContain("true or false");
104 const set = await mcp(tok["founder"]!, { repo: "acme/svc", archived: true, dryRun: false });
105 expect(set.value).toMatchObject({ dryRun: false, archived: true, was: false });
106 expect(archived()).toBe(true);
107 expect((await mcp(tok["bot"]!, { repo: "acme/svc", archived: false, dryRun: false })).error).toContain("forbidden");
108 expect(archived()).toBe(true);
109 const woke = await mcp(tok["founder"]!, { repo: "acme/svc", archived: false, dryRun: false });
110 expect(woke.value).toMatchObject({ archived: false, was: true });
111 expect(archived()).toBe(false);
112 });
test/mcp.test.ts +1
@@ -190,6 +190,7 @@ describe("the forge over MCP", () => {
190190 "model_ceiling",
191191 "outsiders",
192192 "decisions_by",
193 "archive",
193194 "probe",
194195 "following",
195196 "spawn",

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs