Public: anyone can read this; the crew can change it.
MCP `agent`: create, rename and remove the caller's own agents, with the same rules as the API #4914
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/4901-mcp-agent-create-rename-and-remove-the-c → main 1 commit fork
3 files
changed +170
−1
docs/MCP.md +1
| @@ -53,6 +53,7 @@ for a tunnel and a forge that sleeps. | ||
| 53 | 53 | | `probe` | knock on a running app's doors as nobody, a user and an admin; the ones that disobey its contract | | |
| 54 | 54 | | `following` | where a fork stands against what it follows, and what the crew is doing about it | | |
| 55 | 55 | | `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 | |
| 56 | | `agent` | `{ action, username, … }`: `create` a crew agent, `update` its `model`, `brief` or `budgetUsd`, or `remove` it; an admin's, with an `admin` token, as `/api/v1/agents` | yes | | |
| 56 | 57 | | `claim_link` | `{ name }`: a new claim link for a child you spawned that nobody has claimed; the old one stops working. An admin's | yes | |
| 57 | 58 | | `take_updates` | a fork takes its parent's updates when git can do it alone; a conflict is refused. On a spawned forge's code repository it opens the one `forge/take-updates` pull and never moves the default branch | yes | |
| 58 | 59 | | `converge` | the fork's crew merges where both sides changed the same files; comes back as a pull | yes | |
src/http/mcp.ts +60 −1
| @@ -30,6 +30,7 @@ import { ISSUE_TEAMS_SAY, nameTeams } from "../actions/issue_teams.ts"; | ||
| 30 | 30 | import { teamsOfIssue } from "../core/issue_teams.ts"; |
| 31 | 31 | import { changeTeam, planTeam, readTeams, TEAM_SAYS } from "../actions/teams.ts"; |
| 32 | 32 | import { readScorecards } from "../actions/scorecard.ts"; |
| 33 | import { createAgent, deleteAgent, updateAgent } from "../actions/crew.ts"; | |
| 33 | 34 | import { listSecrets, putSecret, removeSecret } from "../actions/secrets.ts"; |
| 34 | 35 | import { addAccount, allowWrite, auditAccount, removeAccount, renameAccount, reverify, rotateAccount } from "../actions/cloud.ts"; |
| 35 | 36 | import { usesByVendor, type VendorUse } from "../cloud/declared.ts"; |
| @@ -119,7 +120,7 @@ import { | ||
| 119 | 120 | listComments, |
| 120 | 121 | listOpenAgentPullsVisibleTo, |
| 121 | 122 | } from "../core/issues.ts"; |
| 122 | import { isCrew } from "../core/agents.ts"; | |
| 123 | import { isCrew, type Agent } from "../core/agents.ts"; | |
| 123 | 124 | import { dependenciesOf } from "../core/dependencies.ts"; |
| 124 | 125 | import { waitsForPerson } from "../crew/review.ts"; |
| 125 | 126 | import { attachLook } from "../actions/attach_look.ts"; |
| @@ -2047,6 +2048,64 @@ const TOOLS: readonly Tool[] = [ | ||
| 2047 | 2048 | }); |
| 2048 | 2049 | }, |
| 2049 | 2050 | }, |
| 2051 | { | |
| 2052 | name: "agent", | |
| 2053 | description: "Admin: create|update|remove a crew agent.", | |
| 2054 | inputSchema: { | |
| 2055 | type: "object", | |
| 2056 | properties: { | |
| 2057 | action: { type: "string" }, | |
| 2058 | username: { type: "string" }, | |
| 2059 | displayName: { type: "string" }, | |
| 2060 | model: { type: "string" }, | |
| 2061 | brief: { type: "string" }, | |
| 2062 | budgetUsd: { type: "number" }, | |
| 2063 | }, | |
| 2064 | required: ["action", "username"], | |
| 2065 | }, | |
| 2066 | writes: true, | |
| 2067 | adminOnly: true, | |
| 2068 | needsAdminScope: true, | |
| 2069 | async run(ctx, user, args) { | |
| 2070 | const username = str(args["username"]); | |
| 2071 | const model = args["model"]; | |
| 2072 | const brief = args["brief"]; | |
| 2073 | const budgetUsd = args["budgetUsd"]; | |
| 2074 | const fields = { | |
| 2075 | ...(typeof model === "string" ? { model } : {}), | |
| 2076 | ...(typeof brief === "string" ? { brief } : {}), | |
| 2077 | ...(typeof budgetUsd === "number" ? { budgetUsd } : {}), | |
| 2078 | }; | |
| 2079 | const shown = (a: Agent) => ({ | |
| 2080 | username: a.user.username, | |
| 2081 | displayName: a.user.displayName, | |
| 2082 | model: a.model, | |
| 2083 | brief: a.brief, | |
| 2084 | budgetUsd: a.budgetUsd, | |
| 2085 | createdAt: a.createdAt, | |
| 2086 | }); | |
| 2087 | const action = args["action"]; | |
| 2088 | if (action === "create") { | |
| 2089 | const made = await createAgent(ctx.app, user, { | |
| 2090 | username, | |
| 2091 | ...(typeof args["displayName"] === "string" | |
| 2092 | ? { displayName: args["displayName"] } | |
| 2093 | : {}), | |
| 2094 | ...fields, | |
| 2095 | }); | |
| 2096 | return made.ok ? good(shown(made.value)) : bad(made.error, made.error); | |
| 2097 | } | |
| 2098 | if (action === "update") { | |
| 2099 | const done = updateAgent(ctx.app, user, username, fields); | |
| 2100 | return done.ok ? good(shown(done.value)) : bad(done.error, done.error); | |
| 2101 | } | |
| 2102 | if (action === "remove") { | |
| 2103 | const gone = deleteAgent(ctx.app, user, username); | |
| 2104 | return gone.ok ? good({ removed: username }) : bad(gone.error, gone.error); | |
| 2105 | } | |
| 2106 | return bad("invalid", "action is create, update or remove"); | |
| 2107 | }, | |
| 2108 | }, | |
| 2050 | 2109 | { |
| 2051 | 2110 | name: "forges", |
| 2052 | 2111 | description: |
test/mcp-agent.test.ts added +109
| @@ -0,0 +1,109 @@ | ||
| 1 | /** | |
| 2 | * The `agent` tool is /api/v1/agents over MCP: the same actions, admins only. | |
| 3 | */ | |
| 4 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 5 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 6 | import { tmpdir } from "node:os"; | |
| 7 | import { join } from "node:path"; | |
| 8 | import { createApp, type App } from "../src/app.ts"; | |
| 9 | import { createToken } from "../src/core/tokens.ts"; | |
| 10 | import { createUser } from "../src/core/users.ts"; | |
| 11 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 12 | import { promote } from "./helpers/members.ts"; | |
| 13 | ||
| 14 | describe("MCP agent", () => { | |
| 15 | let dataDir: string; | |
| 16 | let app: App; | |
| 17 | let servers: Servers; | |
| 18 | let admin: string; | |
| 19 | let adminRw: string; | |
| 20 | let other: string; | |
| 21 | let id = 1; | |
| 22 | ||
| 23 | const tool = async (token: string, args: unknown) => { | |
| 24 | const res = await fetch(`${servers.url}/mcp`, { | |
| 25 | method: "POST", | |
| 26 | headers: { | |
| 27 | "content-type": "application/json", | |
| 28 | accept: "application/json, text/event-stream", | |
| 29 | authorization: `Bearer ${token}`, | |
| 30 | }, | |
| 31 | body: JSON.stringify({ | |
| 32 | jsonrpc: "2.0", | |
| 33 | id: id++, | |
| 34 | method: "tools/call", | |
| 35 | params: { name: "agent", arguments: args }, | |
| 36 | }), | |
| 37 | }); | |
| 38 | const body = (await res.json()) as any; | |
| 39 | return { text: String(body.result?.content?.[0]?.text ?? ""), isError: body.result === undefined || body.result.isError === true }; | |
| 40 | }; | |
| 41 | const api = (token: string, method: string, path: string, body?: unknown) => | |
| 42 | fetch(`${servers.url}${path}`, { | |
| 43 | method, | |
| 44 | headers: { authorization: `Bearer ${token}`, "content-type": "application/json" }, | |
| 45 | body: body === undefined ? undefined : JSON.stringify(body), | |
| 46 | }); | |
| 47 | ||
| 48 | beforeAll(async () => { | |
| 49 | dataDir = await mkdtemp(join(tmpdir(), "forge-mcp-agent-")); | |
| 50 | app = createApp({ dataDir, port: 0 }); | |
| 51 | servers = await serve(app); | |
| 52 | const a = await createUser(app.db, { origin: "seed", trusted: true, | |
| 53 | username: "alice", | |
| 54 | email: "[email protected]", | |
| 55 | password: "a-good-passphrase", | |
| 56 | adminIfFirst: true, | |
| 57 | }); | |
| 58 | if (!a.ok) throw new Error(a.error); | |
| 59 | const b = await createUser(app.db, { origin: "seed", trusted: true, | |
| 60 | username: "bob", | |
| 61 | email: "[email protected]", | |
| 62 | password: "a-good-passphrase", | |
| 63 | }); | |
| 64 | if (!b.ok) throw new Error(b.error); | |
| 65 | promote(app.db, b.value); | |
| 66 | admin = createToken(app.db, a.value.id, "admin", "admin").token; | |
| 67 | adminRw = createToken(app.db, a.value.id, "rw", "rw").token; | |
| 68 | other = createToken(app.db, b.value.id, "admin", "admin").token; | |
| 69 | }, 30_000); | |
| 70 | ||
| 71 | afterAll(async () => { | |
| 72 | servers.stop(); | |
| 73 | app.close(); | |
| 74 | await rm(dataDir, { recursive: true, force: true }); | |
| 75 | }); | |
| 76 | ||
| 77 | test("an admin creates, updates and removes an agent", async () => { | |
| 78 | const made = await tool(admin, { action: "create", username: "pixel", brief: "draws" }); | |
| 79 | expect(made.isError).toBe(false); | |
| 80 | expect(JSON.parse(made.text).username).toBe("pixel"); | |
| 81 | expect((await api(admin, "GET", "/api/v1/agents/pixel")).status).toBe(200); | |
| 82 | ||
| 83 | const upd = await tool(admin, { action: "update", username: "pixel", brief: "paints", budgetUsd: 3 }); | |
| 84 | expect(upd.isError).toBe(false); | |
| 85 | const seen = (await (await api(admin, "GET", "/api/v1/agents/pixel")).json()) as any; | |
| 86 | expect(seen.brief).toBe("paints"); | |
| 87 | expect(seen.budgetUsd).toBe(3); | |
| 88 | ||
| 89 | const gone = await tool(admin, { action: "remove", username: "pixel" }); | |
| 90 | expect(gone.isError).toBe(false); | |
| 91 | expect((await api(admin, "GET", "/api/v1/agents/pixel")).status).toBe(404); | |
| 92 | }); | |
| 93 | ||
| 94 | test("another person cannot remove it, nor can a token below admin", async () => { | |
| 95 | await tool(admin, { action: "create", username: "kept" }); | |
| 96 | const refused = await tool(other, { action: "remove", username: "kept" }); | |
| 97 | expect(refused.isError).toBe(true); | |
| 98 | const rw = await tool(adminRw, { action: "remove", username: "kept" }); | |
| 99 | expect(rw.isError).toBe(true); | |
| 100 | expect((await api(admin, "GET", "/api/v1/agents/kept")).status).toBe(200); | |
| 101 | }); | |
| 102 | ||
| 103 | test("a taken name is refused with the API's error", async () => { | |
| 104 | const again = await tool(admin, { action: "create", username: "kept" }); | |
| 105 | expect(again.isError).toBe(true); | |
| 106 | const viaApi = (await (await api(admin, "POST", "/api/v1/agents", { username: "kept" })).json()) as any; | |
| 107 | expect(again.text).toContain(String(viaApi.error)); | |
| 108 | }); | |
| 109 | }); |
new above
offline