Public: anyone can read this; the crew can change it.
MCP `org_teams` removes a member, or just one of their roles #10154
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/10134-mcp-org-teams-removes-a-member-or-just-o → main 2 commits fork 1 thread · 1 unresolved
3 files
changed +44
−8
docs/features/team-roles.md +4 −2
| @@ -16,8 +16,10 @@ crew, as before. | ||
| 16 | 16 | `422 bad_role`. `DELETE …/members/:username?role=reviewer` takes just that |
| 17 | 17 | role off and leaves them a `member`; without `role` it removes them. |
| 18 | 18 | `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 }`, | |
| 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. | |
| 21 | 23 | - **Terminal:** the "Organization teams" command, Enter on a team, then `r` on |
| 22 | 24 | a member to pick a role (org admins only). |
| 23 | 25 |
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"; | |
| 2 | 2 | import { requireOrgAdmin } from "../../../actions/access.ts"; |
| 3 | 3 | 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"; | |
| 5 | 5 | import { good, bad, type Tool } from "../shared.ts"; |
| 6 | 6 | |
| 7 | 7 | export const tool: Tool = { |
| 8 | 8 | 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`", | |
| 10 | 10 | description: "An org's teams: create, add member with a role, list members, remove.", |
| 11 | 11 | inputSchema: { |
| 12 | 12 | type: "object", |
| 13 | 13 | properties: { |
| 14 | 14 | 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"] }, | |
| 16 | 16 | team: { type: "string", maxLength: 64 }, |
| 17 | 17 | user: { type: "string", maxLength: 64 }, |
| 18 | 18 | 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" }, | |
| 20 | 20 | }, |
| 21 | 21 | required: ["org", "action", "team"], |
| 22 | 22 | }, |
| @@ -44,6 +44,21 @@ export const tool: Tool = { | ||
| 44 | 44 | const added = addTeamMember(ctx.app, user, org, team, who, role); |
| 45 | 45 | return added.ok ? good(added.value) : bad(added.error, added.error); |
| 46 | 46 | } |
| 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 | } | |
| 47 | 62 | if (action === "members") { |
| 48 | 63 | // The same admin gate as the page: only an org admin manages its teams. |
| 49 | 64 | if (!requireOrgAdmin(ctx.app, user, org).ok) return bad("not_found", "no such team"); |
| @@ -55,6 +70,6 @@ export const tool: Tool = { | ||
| 55 | 70 | const gone = deleteTeam(ctx.app, user, org, team); |
| 56 | 71 | return gone.ok ? good({ removed: team.name }) : bad(gone.error, gone.error); |
| 57 | 72 | } |
| 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"); | |
| 59 | 74 | }, |
| 60 | 75 | }; |
test/mcp-orgteams.test.ts +19
| @@ -93,4 +93,23 @@ describe("MCP org_teams", () => { | ||
| 93 | 93 | const unknown = await tool(owner, { org: "acme", action: "peek", team: "ops" }); |
| 94 | 94 | expect(unknown.isError).toBe(true); |
| 95 | 95 | }); |
| 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 | }); | |
| 96 | 115 | }); |
Trailing whitespace at the end of this line; trim it.