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

MCP `org_teams`: create a team in an organization, add a member, and remove a team #4967

Merged opened this

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

Merged

scout/forge:scout/4904-mcp-org-teams-create-a-team-in-an-organi → main 1 commit fork 2 threads · 2 unresolved

  • Merged. scout/4904-mcp-org-teams-create-a-team-in-an-organi was merged into main as 0cf5dc4.

Review

Sign in to review this change.
3 files changed +140 −0
docs/MCP.md +1
@@ -40,6 +40,7 @@ for a tunnel and a forge that sleeps.
4040 | `read_file` | a file (utf-8, or base64 for binary) or a directory listing at a ref, as GET …/contents | no |
4141 | `seal` | `{ repo, number, sealed }`: seal a security report (and the pull that fixes it) so only writers and the crew see it, or unseal it for every reader; a writer's, recorded | yes |
4242 | `secrets` | `{ scope, ref?, action, name?, value? }` (forge admin): `list` the names and timestamps of the forge's sealed secrets, `set` or `remove` one; `scope` is forge, org or repo, `ref` the org or `owner/name`; a value is never read back | yes |
43 | `org_teams` | `{ org, action, team, user?, permission? }` (admin-scope token, an org owner): `create` a team (`permission` read, write or admin; read by default), `add_member` a `user`, or `remove` the team; the same as POST/DELETE `/api/v1/orgs/:org/teams` | yes |
4344 | `now` | what the crew is doing right now: who, on what, since when, at what cost; a planner that declined the direction and waits for you comes first (`run: null`, its words in `says`) | |
4445 | `stop` | stop one run by its id from `now` | yes |
4546 | `health` | how this forge is: the version and commit it runs, draining or not, the rollout and its incidents | |
src/http/mcp.ts +43
@@ -28,6 +28,8 @@ import { changeCapacity, listCapacity } from "../actions/capacity.ts";
2828 import { changeLimit, listLimits } from "../actions/limits.ts";
2929 import { ISSUE_TEAMS_SAY, nameTeams } from "../actions/issue_teams.ts";
3030 import { teamsOfIssue } from "../core/issue_teams.ts";
31 import { addTeamMember, createTeam, deleteTeam } from "../actions/orgs.ts";
32 import { getOrg, listTeams, type Permission } from "../core/orgs.ts";
3133 import { changeTeam, planTeam, readTeams, TEAM_SAYS } from "../actions/teams.ts";
3234 import { readScorecards } from "../actions/scorecard.ts";
3335 import { listSecrets, putSecret, removeSecret } from "../actions/secrets.ts";
@@ -1306,6 +1308,47 @@ const TOOLS: readonly Tool[] = [
13061308 });
13071309 },
13081310 },
1311 {
1312 name: "org_teams",
1313 description: "An organization owner's teams: create one, add a member, or remove a team.",
1314 inputSchema: {
1315 type: "object",
1316 properties: {
1317 org: { type: "string", maxLength: 64 },
1318 action: { type: "string", enum: ["create", "add_member", "remove"] },
1319 team: { type: "string", maxLength: 64 },
1320 user: { type: "string", maxLength: 64, description: "add_member" },
1321 permission: { type: "string", enum: ["read", "write", "admin"], description: "create" },
1322 },
1323 required: ["org", "action", "team"],
1324 },
1325 writes: true,
1326 needsAdminScope: true,
1327 run(ctx, user, args) {
1328 const org = getOrg(ctx.app.db, typeof args["org"] === "string" ? args["org"] : "");
1329 if (!org) return bad("not_found", "no such organization");
1330 const name = typeof args["team"] === "string" ? args["team"] : "";
1331 const action = args["action"];
1332 if (action === "create") {
1333 const p = args["permission"];
1334 const permission: Permission = p === "write" || p === "admin" ? p : "read";
1335 const made = createTeam(ctx.app, user, org, name, permission);
1336 return made.ok ? good(made.value) : bad(made.error, made.error);

Nit: the message is the bare error code (taken, forbidden, …). One human sentence per code would be clearer to whoever reads the tool result.

1337 }
1338 const wanted = name.toLowerCase();
1339 const team = listTeams(ctx.app.db, org.id).find((t) => t.name.toLowerCase() === wanted);

Low: this looks the team up among all of the org's teams before the owner check in the action, so a non-owner can tell which hidden team names exist (not_found vs forbidden). The API's loadTeam does the same, so this is parity. A later fix: when the actor is not an org admin, answer not_found for any team outside listTeamsVisibleTo, here and in loadTeam.

1340 if (!team) return bad("not_found", "no such team");
1341 if (action === "add_member") {
1342 const added = addTeamMember(ctx.app, user, org, team, typeof args["user"] === "string" ? args["user"] : "");
1343 return added.ok ? good(added.value) : bad(added.error, added.error);
1344 }
1345 if (action === "remove") {
1346 const gone = deleteTeam(ctx.app, user, org, team);
1347 return gone.ok ? good({ removed: team.name }) : bad(gone.error, gone.error);
1348 }
1349 return bad("invalid", "action is create, add_member or remove");
1350 },
1351 },
13091352 {
13101353 name: "now",
13111354 description:
test/mcp-orgteams.test.ts added +96
@@ -0,0 +1,96 @@
1 /** The `org_teams` tool is the org-teams API over MCP: an owner arranges teams, no one else does. */
2 import { afterAll, beforeAll, describe, 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 { createOrg } from "../src/core/orgs.ts";
8 import { createToken } from "../src/core/tokens.ts";
9 import { createUser } from "../src/core/users.ts";
10 import { serve, type Servers } from "../src/http/server.ts";
11
12 describe("MCP org_teams", () => {
13 let dataDir: string;
14 let app: App;
15 let servers: Servers;
16 let owner: string;
17 let member: string;
18 let id = 1;
19
20 const tool = async (token: string, args: unknown) => {
21 const res = await fetch(`${servers.url}/mcp`, {
22 method: "POST",
23 headers: {
24 "content-type": "application/json",
25 accept: "application/json, text/event-stream",
26 authorization: `Bearer ${token}`,
27 },
28 body: JSON.stringify({
29 jsonrpc: "2.0",
30 id: id++,
31 method: "tools/call",
32 params: { name: "org_teams", arguments: args },
33 }),
34 });
35 const body = (await res.json()) as any;
36 return { text: body.result.content[0].text as string, isError: body.result.isError === true };
37 };
38
39 beforeAll(async () => {
40 dataDir = await mkdtemp(join(tmpdir(), "forge-mcp-orgteams-"));
41 app = createApp({ dataDir, port: 0 });
42 servers = await serve(app);
43 const mk = async (username: string) => {
44 const u = await createUser(app.db, { origin: "seed", trusted: true,
45 username,
46 email: `${username}@forge.local`,
47 password: "a-good-passphrase",
48 });
49 if (!u.ok) throw new Error(u.error);
50 return u.value;
51 };
52 const a = await mk("alice");
53 const b = await mk("bob");
54 await mk("carol");
55 owner = createToken(app.db, a.id, "admin", "admin").token;
56 member = createToken(app.db, b.id, "admin", "admin").token;
57 const org = createOrg(app.db, "acme", a.id);
58 if (!org.ok) throw new Error(org.error);
59 }, 30_000);
60
61 afterAll(async () => {
62 servers.stop();
63 app.close();
64 await rm(dataDir, { recursive: true, force: true });
65 });
66
67 test("an owner creates a team, adds a member and removes the team", async () => {
68 const made = await tool(owner, { org: "acme", action: "create", team: "docs", permission: "write" });
69 expect(made.isError).toBe(false);
70 expect(JSON.parse(made.text).name).toBe("docs");
71 const added = await tool(owner, { org: "acme", action: "add_member", team: "docs", user: "carol" });
72 expect(added.isError).toBe(false);
73 expect(JSON.parse(added.text).added).toBe(true);
74 const gone = await tool(owner, { org: "acme", action: "remove", team: "docs" });
75 expect(gone.isError).toBe(false);
76 const again = await tool(owner, { org: "acme", action: "remove", team: "docs" });
77 expect(again.isError).toBe(true);
78 });
79
80 test("a taken team name is refused", async () => {
81 await tool(owner, { org: "acme", action: "create", team: "ops" });
82 const dup = await tool(owner, { org: "acme", action: "create", team: "ops" });
83 expect(dup.isError).toBe(true);
84 });
85
86 test("someone who is not an owner is refused, and nothing changes", async () => {
87 const made = await tool(member, { org: "acme", action: "create", team: "sneaky" });
88 expect(made.isError).toBe(true);
89 const gone = await tool(member, { org: "acme", action: "remove", team: "ops" });
90 expect(gone.isError).toBe(true);
91 const still = await tool(owner, { org: "acme", action: "add_member", team: "ops", user: "carol" });
92 expect(still.isError).toBe(false);
93 const unknown = await tool(owner, { org: "acme", action: "peek", team: "ops" });
94 expect(unknown.isError).toBe(true);
95 });
96 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs