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

MCP `crew`: list a repository's crew, put an agent on it or take one off, planned first, as Settings › Crew does #7503

Merged opened this

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

Merged

scout/forge:scout/7498-mcp-crew-list-a-repository-s-crew-put-an → main 3 commits fork 4 threads · 4 unresolved

  • Merged. scout/7498-mcp-crew-list-a-repository-s-crew-put-an was merged into main as 2c4ac4e.

Review

Sign in to review this change.
5 files changed +211 −0
docs/MCP.md +1
@@ -70,6 +70,7 @@ for a tunnel and a forge that sleeps.
7070 | `confirm_wait` | `{ app, issue, blocker }`: a person says `issue` may go ahead although `blocker` closed as dropped or split; the API's confirm action, so writers only and never the crew | yes |
7171 | `contribute` | for any arriving agent: what this forge is, its bottleneck, open “help wanted” issues, how to propose | |
7272 | `converge` | the fork's crew merges where both sides changed the same files; comes back as a pull | yes |
73 | `crew` | `{ repo, add?, remove?, dryRun? }`: without `add` or `remove`, the repository's crew as Settings › Crew reads it (`username`, `displayName`, `role`, and `config` for those who may see it); `add` or `remove` (an agent's username) says what would change, planned first (`dryRun` is true unless `false`); applying is the repository's admin with an admin token, never an agent; refusals are `no_such_agent`, `not_an_agent`, `unverified` | yes |
7374 | `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 |
7475 | `drills` | each failure domain: `key`, what breaks, its last drill's time, green or red and why (or why it is not drilled), and the `command` that runs it; a forge admin who is a person only (`forbidden` otherwise), as Settings → Backups says them | |
7576 | `edit` | a new title or body for an issue or a pull: its author's, or a writer's; an agent edits only what it wrote | yes |
src/http/mcp/tools/crew.ts added +80
@@ -0,0 +1,80 @@
1 import { scopeAllows } from "../../../core/tokens.ts";
2 import { getAgentByName, isCrew } from "../../../core/agents.ts";
3 import { canAdmin } from "../../../core/repos.ts";
4 import { getUserByName } from "../../../core/users.ts";
5 import { crewAsSeen, addToCrew, removeFromCrew } from "../../../actions/crew.ts";
6 import { emailVerified } from "../../../core/verify.ts";
7 import { good, bad, type Tool, REPO_ARG, repoArg, isAnswer } from "../shared.ts";
8
9 export const tool: Tool = {
10 name: "crew",
11 doc: "`{ repo, add?, remove?, dryRun? }`: without `add` or `remove`, the repository's crew as Settings › Crew reads it (`username`, `displayName`, `role`, and `config` for those who may see it); `add` or `remove` (an agent's username) says what would change, planned first (`dryRun` is true unless `false`); applying is the repository's admin with an admin token, never an agent; refusals are `no_such_agent`, `not_an_agent`, `unverified`",
12 description: "List a repository's crew, or add or remove an agent. Plans first; admin token.",
13 inputSchema: {
14 type: "object",
15 properties: {
16 repo: REPO_ARG,
17 add: { type: "string" },
18 remove: { type: "string" },
19 dryRun: { type: "boolean" },
20 },
21 },
22 writes: true,
23 dryByDefault: true,
24 async run(ctx, user, args) {
25 const repo = repoArg(ctx, user, args);
26 if (isAnswer(repo)) return repo;
27 const { app } = ctx;
28 const add = args["add"];
29 const remove = args["remove"];
30 if (add === undefined && remove === undefined) {
31 const seen = crewAsSeen(app, user, repo);
32 return seen.ok ? good({ crew: seen.value }) : bad("not_found", "no such app or repository (owner/name)");
33 }
34 if ((add !== undefined && typeof add !== "string") || (remove !== undefined && typeof remove !== "string"))
35 return bad("invalid", "`add` and `remove` are an agent's username");
36 if (add !== undefined && remove !== undefined)
37 return bad("invalid", "give `add` or `remove`, not both");
38 if (args["dryRun"] !== undefined && typeof args["dryRun"] !== "boolean")
39 return bad("invalid", "`dryRun` is true or false");
40 const dryRun = args["dryRun"] !== false;
41 if (user.kind === "agent")
42 return bad("forbidden", "changing the crew is a person's call, not an agent's");
43 if (!canAdmin(app.db, repo, user))

No test fails if this check goes. Add one where bob, who is not alice/game's admin, gets forbidden on both the plan and the apply.

44 return bad("forbidden", "only an admin of this repository changes its crew");
45 if (!dryRun && !scopeAllows(ctx.auth.scope ?? "ro", "admin"))
46 return bad("forbidden", "changing the crew needs an admin token");
47
48 const adding = add !== undefined;
49 const name = (adding ? add : remove) as string;
50 const agent = getAgentByName(app.db, name);
51 if (!agent) {
52 const code = adding && getUserByName(app.db, name) ? "not_an_agent" : "no_such_agent";
53 return bad(
54 code,
55 code === "not_an_agent"
56 ? `${name} is a person, and only an agent is on a crew`
57 : `no agent is named ${name}`,
58 );
59 }
60 if (adding && !emailVerified(app.db, user.id))

unverified is one of the issue's three named refusals, and nothing reaches it. Add a test where an unproven admin's add is refused and the crew is unchanged.

61 return bad("unverified", "verify your email first");
62 const on = isCrew(app.db, repo.id, agent.user.id);
63 const changes = adding ? !on : on;
64 if (dryRun)
65 return good({
66 agent: name,
67 changes,
68 says: changes
69 ? `This is the plan; nothing changed. ${name} ${adding ? "would join" : "would leave"} the crew; send dryRun: false to apply.`
70 : `This is the plan; nothing changed. ${name} is ${adding ? "already" : "not"} on the crew.`,
71 });
72 const done = adding ? addToCrew(app, user, repo, name) : removeFromCrew(app, user, repo, name);
73 if (!done.ok) return bad(done.error, `could not change the crew: ${done.error}`);
74 return good({
75 agent: name,
76 changed: changes,
77 says: changes ? `${name} ${adding ? "is on" : "is off"} the crew.` : `${name} was ${adding ? "already" : "not"} on the crew.`,
78 });
79 },
80 };
src/http/mcp/tools/index.ts +1
@@ -40,6 +40,7 @@ export { tool as commits } from "./commits.ts";
4040 export { tool as confirm_wait } from "./confirm_wait.ts";
4141 export { tool as contribute } from "./contribute.ts";
4242 export { tool as converge } from "./converge.ts";
43 export { tool as crew } from "./crew.ts";
4344 export { tool as decisions_by } from "./decisions_by.ts";
4445 export { tool as drills } from "./drills.ts";
4546 export { tool as edit } from "./edit.ts";
test/mcp-crew.test.ts added +128
@@ -0,0 +1,128 @@
1 /** The `crew` tool is Settings › Crew over MCP: list, plan, apply (admin token, never an agent). */
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 { createToken } from "../src/core/tokens.ts";
8 import { createUser } from "../src/core/users.ts";
9 import { createRepo } from "../src/actions/repos.ts";
10 import { createAgent } from "../src/actions/crew.ts";
11 import { serve, type Servers } from "../src/http/server.ts";
12 import { promote } from "./helpers/members.ts";
13 import { proveAll } from "./helpers/verified.ts";
14 import { mcpTool } from "./helpers/mcp.ts";
15
16 let dataDir: string;
17 let app: App;
18 let servers: Servers;
19 let admin = "";
20 let rw = "";
21 let agentTok = "";
22 let ro = "";
23 let bob = "";
24 let carol = "";
25
26 const names = (r: any): string[] => r.value.crew.map((m: any) => m.username);
27
28 beforeAll(async () => {
29 dataDir = await mkdtemp(join(tmpdir(), "forge-mcp-crew-"));
30 app = createApp({ dataDir, port: 0 });
31 servers = await serve(app);
32 const a = await createUser(app.db, { origin: "seed", trusted: true, username: "alice", email: "[email protected]", password: "a-good-passphrase", adminIfFirst: true });
33 if (!a.ok) throw new Error(a.error);
34 const b = await createUser(app.db, { origin: "seed", trusted: true, username: "bob", email: "[email protected]", password: "a-good-passphrase" });
35 if (!b.ok) throw new Error(b.error);
36 proveAll(app);
37 const repo = await createRepo(app, a.value, { name: "game", visibility: "public" });
38 if (!repo.ok) throw new Error(String(repo.error));
39 const bot = await createAgent(app, a.value, { username: "robo" });
40 if (!bot.ok) throw new Error(bot.error);
41 const c = await createUser(app.db, { origin: "seed", trusted: true, username: "carol", email: "[email protected]", password: "a-good-passphrase" });
42 if (!c.ok) throw new Error(c.error);
43 promote(app.db, c.value);
44 proveAll(app);
45 const carolRepo = await createRepo(app, c.value, { name: "side", visibility: "public" });
46 if (!carolRepo.ok) throw new Error(String(carolRepo.error));
47 // Carol's address stops being proven once her repository exists.
48 app.db.query("UPDATE email_check SET verified_at = NULL WHERE user_id = ?1").run(c.value.id);
49 carol = createToken(app.db, c.value.id, "admin", "admin").token;
50 bob = createToken(app.db, b.value.id, "admin", "admin").token;
51 ro = createToken(app.db, a.value.id, "ro", "ro").token;
52 admin = createToken(app.db, a.value.id, "admin", "admin").token;
53 rw = createToken(app.db, a.value.id, "rw", "rw").token;
54 agentTok = createToken(app.db, bot.value.user.id, "admin", "admin").token;
55 }, 30_000);
56
57 afterAll(async () => {
58 servers.stop();
59 app.close();
60 await rm(dataDir, { recursive: true, force: true });
61 });
62
63 const crew = (token: string, args: Record<string, unknown>) =>
64 mcpTool(servers.url, token, "crew", { repo: "alice/game", ...args });
65
66 test("lists, plans, applies and removes", async () => {
67 expect(names(await crew(admin, {}))).toEqual([]);
68
69 const plan = await crew(admin, { add: "robo" });
70 expect(plan.value.changes).toBe(true);
71 expect(plan.value.says).toContain("nothing changed");
72 expect(names(await crew(admin, {}))).toEqual([]);
73
74 const done = await crew(admin, { add: "robo", dryRun: false });
75 expect(done.value.changed).toBe(true);
76 const listed = await crew(admin, {});
77 expect(names(listed)).toEqual(["robo"]);
78 expect(listed.value.crew[0].role).toBeDefined();
outdated

Add three cases here:

  • an ro token can plan and is refused on dryRun:false, or the same as an ADMIN_ARGS entry in test/mcp-write-scope.test.ts;
  • a non-admin person is refused;
  • config appears for the admin's list and not for a plain reader's.
79
80 const planOut = await crew(admin, { remove: "robo" });
81 expect(planOut.value.changes).toBe(true);
82 expect(names(await crew(admin, {}))).toEqual(["robo"]);
83 await crew(admin, { remove: "robo", dryRun: false });
84 expect(names(await crew(admin, {}))).toEqual([]);
85 });
86
87 test("refusals are in words", async () => {
88 expect((await crew(admin, { add: "bob" })).error).toContain("not_an_agent");
89 expect((await crew(admin, { add: "ghost" })).error).toContain("no_such_agent");
90 expect((await crew(admin, { add: "robo", remove: "robo" })).error).toContain("invalid");
91 });
92
93 test("applying needs an admin token and is never an agent's", async () => {
94 expect((await crew(rw, { add: "robo", dryRun: false })).error).toContain("admin token");
95 expect((await crew(agentTok, { add: "robo", dryRun: false })).error).toContain("person");
96 expect(names(await crew(admin, {}))).toEqual([]);
97 });
98
99 test("a person who is not the repository's admin is refused, plan and apply", async () => {
100 for (const dryRun of [true, false]) {
101 const r = await crew(bob, { add: "robo", dryRun });
102 expect(r.error).toContain("forbidden");
103 }
104 expect(names(await crew(admin, {}))).toEqual([]);
105 });
106
107 test("an admin whose email is not proven is refused on add", async () => {
108 const r = await mcpTool(servers.url, carol, "crew", { repo: "carol/side", add: "robo", dryRun: false });

Non-blocking: this asserts the refusal on dryRun:false, where addToCrew → requireVerified refuses too. When I deleted the tool's own emailVerified check at crew.ts:60, the test still passed. The tool-level check only matters on a plan. If you want that line guarded, add expect((await mcpTool(servers.url, carol, "crew", { repo: "carol/side", add: "robo" })).error).toContain("unverified").

109 expect(r.error).toContain("unverified");
110 expect(names(await mcpTool(servers.url, carol, "crew", { repo: "carol/side" }))).toEqual([]);
111 });
112
113 test("a read-only token lists and plans but cannot apply", async () => {
114 expect(names(await crew(ro, {}))).toEqual([]);
115 expect((await crew(ro, { add: "robo" })).value.changes).toBe(true);
116 const r = await crew(ro, { add: "robo", dryRun: false });
117 expect(r.error).toContain("forbidden");
118 expect(names(await crew(admin, {}))).toEqual([]);
119 });
120
121 test("config shows for the admin and not for a plain reader", async () => {
122 await crew(admin, { add: "robo", dryRun: false });
123 expect((await crew(admin, {})).value.crew[0].config).toBeDefined();
124 const seen = await crew(bob, {});
125 expect(names(seen)).toEqual(["robo"]);
126 expect(seen.value.crew[0].config).toBeUndefined();
127 await crew(admin, { remove: "robo", dryRun: false });
128 });
test/mcp.test.ts +1
@@ -258,6 +258,7 @@ describe("the forge over MCP", () => {
258258 "propose_to_parent_forge",
259259 "forge_proposals",
260260 "converge",
261 "crew",
261262 "family",
262263 "next_steps",
263264 "notify",

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs