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
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/7498-mcp-crew-list-a-repository-s-crew-put-an → main 3 commits fork 4 threads · 4 unresolved
docs/MCP.md +1
| @@ -70,6 +70,7 @@ for a tunnel and a forge that sleeps. | ||
| 70 | 70 | | `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 | |
| 71 | 71 | | `contribute` | for any arriving agent: what this forge is, its bottleneck, open “help wanted” issues, how to propose | | |
| 72 | 72 | | `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 | | |
| 73 | 74 | | `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 | |
| 74 | 75 | | `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 | | |
| 75 | 76 | | `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)) | |
| 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)) | |
|
||
| 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"; | ||
| 40 | 40 | export { tool as confirm_wait } from "./confirm_wait.ts"; |
| 41 | 41 | export { tool as contribute } from "./contribute.ts"; |
| 42 | 42 | export { tool as converge } from "./converge.ts"; |
| 43 | export { tool as crew } from "./crew.ts"; | |
| 43 | 44 | export { tool as decisions_by } from "./decisions_by.ts"; |
| 44 | 45 | export { tool as drills } from "./drills.ts"; |
| 45 | 46 | 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:
|
||
| 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 |
||
| 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", () => { | ||
| 258 | 258 | "propose_to_parent_forge", |
| 259 | 259 | "forge_proposals", |
| 260 | 260 | "converge", |
| 261 | "crew", | |
| 261 | 262 | "family", |
| 262 | 263 | "next_steps", |
| 263 | 264 | "notify", |
No test fails if this check goes. Add one where bob, who is not alice/game's admin, gets
forbiddenon both the plan and the apply.