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

Merged opened this

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

Merged

scout/forge:scout/4901-mcp-agent-create-rename-and-remove-the-c → main 1 commit fork

  • Merged. scout/4901-mcp-agent-create-rename-and-remove-the-c was merged into main as 7512cd6.

Review

Sign in to review this change.
3 files changed +170 −1
docs/MCP.md +1
@@ -53,6 +53,7 @@ for a tunnel and a forge that sleeps.
5353 | `probe` | knock on a running app's doors as nobody, a user and an admin; the ones that disobey its contract | |
5454 | `following` | where a fork stands against what it follows, and what the crew is doing about it | |
5555 | `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 |
5657 | `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 |
5758 | `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 |
5859 | `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";
3030 import { teamsOfIssue } from "../core/issue_teams.ts";
3131 import { changeTeam, planTeam, readTeams, TEAM_SAYS } from "../actions/teams.ts";
3232 import { readScorecards } from "../actions/scorecard.ts";
33 import { createAgent, deleteAgent, updateAgent } from "../actions/crew.ts";
3334 import { listSecrets, putSecret, removeSecret } from "../actions/secrets.ts";
3435 import { addAccount, allowWrite, auditAccount, removeAccount, renameAccount, reverify, rotateAccount } from "../actions/cloud.ts";
3536 import { usesByVendor, type VendorUse } from "../cloud/declared.ts";
@@ -119,7 +120,7 @@ import {
119120 listComments,
120121 listOpenAgentPullsVisibleTo,
121122 } from "../core/issues.ts";
122 import { isCrew } from "../core/agents.ts";
123 import { isCrew, type Agent } from "../core/agents.ts";
123124 import { dependenciesOf } from "../core/dependencies.ts";
124125 import { waitsForPerson } from "../crew/review.ts";
125126 import { attachLook } from "../actions/attach_look.ts";
@@ -2047,6 +2048,64 @@ const TOOLS: readonly Tool[] = [
20472048 });
20482049 },
20492050 },
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 },
20502109 {
20512110 name: "forges",
20522111 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 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs