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

MCP `org_teams` removes a member, or just one of their roles #10154

Merged opened this

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

Merged

scout/forge:scout/10134-mcp-org-teams-removes-a-member-or-just-o → main 2 commits fork 1 thread · 1 unresolved

  • Merged. scout/10134-mcp-org-teams-removes-a-member-or-just-o was merged into main as c6cb42c.

Review

Sign in to review this change.
3 files changed +44 −8
docs/features/team-roles.md +4 −2
@@ -16,8 +16,10 @@ crew, as before.
1616 `422 bad_role`. `DELETE …/members/:username?role=reviewer` takes just that
1717 role off and leaves them a `member`; without `role` it removes them.
1818 `GET …/members` lists each member's `role`.
19 - **MCP:** `org_teams { org, action: "add_member", team, user, role }`, and
20 `action: "members"` to list members with their roles.
19 - **MCP:** `org_teams { org, action: "add_member", team, user, role }`,

Trailing whitespace at the end of this line; trim it.

20 `action: "remove_member", team, user, role?` (the same as the DELETE above:
21 with `role` only that role comes off), and `action: "members"` to list
22 members with their roles.
2123 - **Terminal:** the "Organization teams" command, Enter on a team, then `r` on
2224 a member to pick a role (org admins only).
2325
src/http/mcp/tools/org_teams.ts +21 −6
@@ -1,22 +1,22 @@
1 import { addTeamMember, createTeam, deleteTeam } from "../../../actions/orgs.ts";
1 import { addTeamMember, createTeam, deleteTeam, removeTeamMember } from "../../../actions/orgs.ts";
22 import { requireOrgAdmin } from "../../../actions/access.ts";
33 import { isTeamRole, TEAM_ROLES } from "../../../core/agents.ts";
4 import { getOrg, listTeamMemberRoles, listTeams, type Permission } from "../../../core/orgs.ts";
4 import { getOrg, listTeamMemberRoles, listTeamMembers, listTeams, type Permission } from "../../../core/orgs.ts";
55 import { good, bad, type Tool } from "../shared.ts";
66
77 export const tool: Tool = {
88 name: "org_teams",
9 doc: "`{ org, action, team, user?, permission?, role? }` (admin-scope token, an org owner): `create` a team (`permission` read, write or admin; read by default), `add_member` a `user` with an optional `role` (planner, builder, reviewer or member; setting it on someone already there changes their role), list its `members` with their roles, or `remove` the team; the same as `/api/v1/orgs/:org/teams` and its `members`",
9 doc: "`{ org, action, team, user?, permission?, role? }` (admin-scope token, an org owner): `create` a team (`permission` read, write or admin; read by default), `add_member` a `user` with an optional `role` (planner, builder, reviewer or member; setting it on someone already there changes their role), list its `members` with their roles, `remove_member` a `user` (with `role`, only that role comes off and they stay a member; without it they leave the team), or `remove` the team; the same as `/api/v1/orgs/:org/teams` and its `members`",
1010 description: "An org's teams: create, add member with a role, list members, remove.",
1111 inputSchema: {
1212 type: "object",
1313 properties: {
1414 org: { type: "string", maxLength: 64 },
15 action: { type: "string", enum: ["create", "add_member", "members", "remove"] },
15 action: { type: "string", enum: ["create", "add_member", "members", "remove_member", "remove"] },
1616 team: { type: "string", maxLength: 64 },
1717 user: { type: "string", maxLength: 64 },
1818 permission: { type: "string", enum: ["read", "write", "admin"], description: "create" },
19 role: { type: "string", enum: [...TEAM_ROLES], description: "add_member: the seat on the crew" },
19 role: { type: "string", enum: [...TEAM_ROLES], description: "add_member, remove_member: the seat on the crew" },
2020 },
2121 required: ["org", "action", "team"],
2222 },
@@ -44,6 +44,21 @@ export const tool: Tool = {
4444 const added = addTeamMember(ctx.app, user, org, team, who, role);
4545 return added.ok ? good(added.value) : bad(added.error, added.error);
4646 }
47 if (action === "remove_member") {
48 const role = args["role"];
49 if (role !== undefined && role !== "" && !isTeamRole(role))
50 return bad("bad_role", `role is one of ${TEAM_ROLES.join(", ")}`);
51 const who = typeof args["user"] === "string" ? args["user"] : "";
52 const isMember = listTeamMembers(ctx.app.db, team.id).some(
53 (m) => m.username.toLowerCase() === who.toLowerCase(),
54 );
55 const kept = role === "" ? undefined : role;
56 const removed = removeTeamMember(ctx.app, user, org, team, who, kept);
57 if (!removed.ok) return bad(removed.error, removed.error);
58 if (!isMember || (kept !== undefined && !removed.value.removed))
59 return bad("not_found", "no such member");
60 return good({ removed: who, ...(kept ? { role: kept } : {}) });
61 }
4762 if (action === "members") {
4863 // The same admin gate as the page: only an org admin manages its teams.
4964 if (!requireOrgAdmin(ctx.app, user, org).ok) return bad("not_found", "no such team");
@@ -55,6 +70,6 @@ export const tool: Tool = {
5570 const gone = deleteTeam(ctx.app, user, org, team);
5671 return gone.ok ? good({ removed: team.name }) : bad(gone.error, gone.error);
5772 }
58 return bad("invalid", "action is create, add_member, members or remove");
73 return bad("invalid", "action is create, add_member, remove_member, members or remove");
5974 },
6075 };
test/mcp-orgteams.test.ts +19
@@ -93,4 +93,23 @@ describe("MCP org_teams", () => {
9393 const unknown = await tool(owner, { org: "acme", action: "peek", team: "ops" });
9494 expect(unknown.isError).toBe(true);
9595 });
96
97 test("remove_member takes one role off, then the member", async () => {
98 await tool(owner, { org: "acme", action: "create", team: "crew", permission: "write" });
99 await tool(owner, { org: "acme", action: "add_member", team: "crew", user: "carol", role: "reviewer" });
100 const bad = await tool(owner, { org: "acme", action: "remove_member", team: "crew", user: "carol", role: "boss" });
101 expect(bad.isError).toBe(true);
102 expect(bad.text).toContain("bad_role");
103 const off = await tool(owner, { org: "acme", action: "remove_member", team: "crew", user: "carol", role: "reviewer" });
104 expect(off.isError).toBe(false);
105 const kept = JSON.parse((await tool(owner, { org: "acme", action: "members", team: "crew" })).text);
106 expect(kept).toEqual([{ user: "carol", role: "member" }]);
107 const gone = await tool(owner, { org: "acme", action: "remove_member", team: "crew", user: "carol" });
108 expect(gone.isError).toBe(false);
109 expect(JSON.parse((await tool(owner, { org: "acme", action: "members", team: "crew" })).text)).toEqual([]);
110 const none = await tool(owner, { org: "acme", action: "remove_member", team: "crew", user: "carol" });
111 expect(none.isError).toBe(true);
112 const denied = await tool(member, { org: "acme", action: "remove_member", team: "crew", user: "carol" });
113 expect(denied.isError).toBe(true);
114 });
96115 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs