Public: anyone can read this; the crew can change it.
MCP `orgs`: the caller's organizations, and one organization with its teams and their members #5742
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/5328-mcp-orgs-the-caller-s-organizations-and → main 5 commits fork 3 threads · 3 unresolved
10 files
changed +127
−5
docs/MCP.md +1
| @@ -91,6 +91,7 @@ for a tunnel and a forge that sleeps. | ||
| 91 | 91 | | `next_steps` | on a spawned forge, your first three steps and whether each is done; empty elsewhere or for anyone but its owner | | |
| 92 | 92 | | `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`) | | |
| 93 | 93 | | `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 | |
| 94 | | `orgs` | `{ org? }`: without `org`, the organizations you belong to; with it, the organization, the teams you may see and each team's members; the same as GET `/api/v1/user/orgs`, `/api/v1/orgs/:org`, `…/teams` and `…/members` | | | |
| 94 | 95 | | `origin_apps` | `{ action, id?, host?, kind?, clientId?, clientSecret? }`: `list` your OAuth apps for connecting origins, `add` one (a `host`, or `kind` github), or `remove` one by `id`; the API's rules and errors, an admin-scope token; a secret is never read back, only `hasSecret` | yes | |
| 95 | 96 | | `origins` | `{ action, id? }`: `list` your connected origins, `repos` the repositories one holds (`id`; `mirroredAs` says which are already here), or `sync` one now; the same as `GET /api/v1/user/origins`, `…/:id/repos` and `POST …/:id/sync`, for you only, an admin token as there; never a credential in an answer | yes | |
| 96 | 97 | | `outsiders` | `{ repo, on?, dryRun? }`: whether people who cannot write may direct the crew; with `on`, planned first (`dryRun` is true unless `false`); set only by the app's admin with an admin token, never an agent | yes | |
src/http/mcp/tools/asks.ts +1 −1
| @@ -4,7 +4,7 @@ import { good, bad, type Tool, CHIP_ARGS, chipArgs } from "../shared.ts"; | ||
| 4 | 4 | export const tool: Tool = { |
| 5 | 5 | name: "asks", |
| 6 | 6 | description: |
| 7 | "What waits on you, most important first: decision, options, cost of inaction; saved filter applies", | |
| 7 | "What waits on you, most important first; saved filter applies", | |
| 8 | 8 | inputSchema: { type: "object", properties: { ...CHIP_ARGS } }, |
| 9 | 9 | writes: false, |
| 10 | 10 | run(ctx, user, args) { |
src/http/mcp/tools/index.ts +1
| @@ -73,6 +73,7 @@ export { tool as new_app } from "./new_app.ts"; | ||
| 73 | 73 | export { tool as next_steps } from "./next_steps.ts"; |
| 74 | 74 | export { tool as now } from "./now.ts"; |
| 75 | 75 | export { tool as org_teams } from "./org_teams.ts"; |
| 76 | export { tool as orgs } from "./orgs.ts"; | |
| 76 | 77 | export { tool as origin_apps } from "./origin_apps.ts"; |
| 77 | 78 | export { tool as origins } from "./origins.ts"; |
| 78 | 79 | export { tool as outsiders } from "./outsiders.ts"; |
src/http/mcp/tools/invites.ts +1 −1
| @@ -5,7 +5,7 @@ import { good, bad, type Tool, mayBoards } from "../shared.ts"; | ||
| 5 | 5 | export const tool: Tool = { |
| 6 | 6 | name: "invites", |
| 7 | 7 | doc: "`{ action, id? }`: `list` the outstanding invites (state and expiry, never a link) or `revoke` one by `id`; a person admin with an admin token, stricter than the API's list, which an `ro` token may read", |
| 8 | description: "List open invites, or revoke one by `id`.", | |
| 8 | description: "Open invites; `id` revokes.", | |
| 9 | 9 | inputSchema: { |
| 10 | 10 | type: "object", |
| 11 | 11 | properties: { |
src/http/mcp/tools/mirror_set.ts +1 −1
| @@ -5,7 +5,7 @@ import { good, bad, type Tool } from "../shared.ts"; | ||
| 5 | 5 | export const tool: Tool = { |
| 6 | 6 | name: "mirror_set", |
| 7 | 7 | doc: "`{ action, endpoint?, bucket?, region?, accessKey?, secretKey? }` (forge admin, admin-scope token): `set` seals the off-box snapshot mirror, `forget` clears it; the Backups page's rules and refusals, a blank field keeps what was sealed; answers `{ place, says }`, never a key", |
| 8 | description: "Admin: set or forget the off-box snapshot mirror.", | |
| 8 | description: "Admin: set or forget the snapshot mirror.", | |
| 9 | 9 | inputSchema: { |
| 10 | 10 | type: "object", |
| 11 | 11 | properties: { |
src/http/mcp/tools/org_teams.ts +1 −1
| @@ -5,7 +5,7 @@ import { good, bad, type Tool } from "../shared.ts"; | ||
| 5 | 5 | export const tool: Tool = { |
| 6 | 6 | name: "org_teams", |
| 7 | 7 | doc: "`{ 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`", |
| 8 | description: "An organization's teams: create, add a member, remove.", | |
| 8 | description: "An org's teams: create, add member, remove.", | |
| 9 | 9 | inputSchema: { |
| 10 | 10 | type: "object", |
| 11 | 11 | properties: { |
src/http/mcp/tools/orgs.ts added +26
| @@ -0,0 +1,26 @@ | ||
| 1 | import { getOrg, listOrgsForUser, listTeamMembers, listTeamsVisibleTo } from "../../../core/orgs.ts"; | |
| 2 | import { publicProfile } from "../../respond.ts"; | |
| 3 | import { good, bad, type Tool } from "../shared.ts"; | |
| 4 | ||
| 5 | export const tool: Tool = { | |
| 6 | name: "orgs", | |
| 7 | doc: "`{ org? }`: without `org`, the organizations you belong to; with it, the organization, the teams you may see and each team's members; the same as GET `/api/v1/user/orgs`, `/api/v1/orgs/:org`, `…/teams` and `…/members`", | |
| 8 | description: "Your orgs; with org, its teams.", | |
| 9 | inputSchema: { | |
| 10 | type: "object", | |
| 11 | properties: { org: { type: "string" } }, | |
| 12 | }, | |
| 13 | writes: false, | |
| 14 | run(ctx, user, args) { | |
| 15 | const org = args["org"]; | |
| 16 | if (org === undefined) return good(listOrgsForUser(ctx.app.db, user.id).map(publicProfile)); | |
| 17 | const found = typeof org === "string" ? getOrg(ctx.app.db, org) : undefined; | |
| 18 | if (!found) return bad("not_found", "no such organization"); | |
| 19 | // Only the teams the caller may see, as the API lists them; a stranger sees none. | |
| 20 | const teams = listTeamsVisibleTo(ctx.app.db, found.id, user).map((t) => ({ | |
Non-blocking: every visible team × every member is returned whole. The API pages both ( |
||
| 21 | ...t, | |
| 22 | members: listTeamMembers(ctx.app.db, t.id).map(publicProfile), | |
| 23 | })); | |
| 24 | return good({ id: found.id, ...publicProfile(found), teams }); | |
| 25 | }, | |
| 26 | }; |
src/http/mcp/tools/quotas.ts +1 −1
| @@ -6,7 +6,7 @@ import { good, bad, type Tool } from "../shared.ts"; | ||
| 6 | 6 | export const tool: Tool = { |
| 7 | 7 | name: "quotas", |
| 8 | 8 | description: |
| 9 | "Per-account repo and app quotas (number or null; unenforced). With `key`, `value` (\"\" clears).", | |
| 9 | "Per-account repo and app quotas (unenforced).", | |
| 10 | 10 | inputSchema: { |
| 11 | 11 | type: "object", |
| 12 | 12 | properties: { |
test/mcp-orgs.test.ts added +93
| @@ -0,0 +1,93 @@ | ||
| 1 | /** The `orgs` tool reads the organizations API over MCP: yours, and one with the teams you may see. */ | |
| 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 { addTeamMember, createOrg, createTeam } 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 orgs", () => { | |
| 13 | let dataDir: string; | |
| 14 | let app: App; | |
| 15 | let servers: Servers; | |
| 16 | let owner: string; | |
| 17 | let member: string; | |
| 18 | let stranger: string; | |
| 19 | let id = 1; | |
| 20 | ||
| 21 | const tool = async (token: string, args: unknown) => { | |
| 22 | const res = await fetch(`${servers.url}/mcp`, { | |
| 23 | method: "POST", | |
| 24 | headers: { | |
| 25 | "content-type": "application/json", | |
| 26 | accept: "application/json, text/event-stream", | |
| 27 | authorization: `Bearer ${token}`, | |
| 28 | }, | |
| 29 | body: JSON.stringify({ | |
| 30 | jsonrpc: "2.0", | |
| 31 | id: id++, | |
| 32 | method: "tools/call", | |
| 33 | params: { name: "orgs", arguments: args }, | |
| 34 | }), | |
| 35 | }); | |
| 36 | const body = (await res.json()) as any; | |
| 37 | return { text: body.result.content[0].text as string, isError: body.result.isError === true }; | |
| 38 | }; | |
| 39 | ||
| 40 | beforeAll(async () => { | |
| 41 | dataDir = await mkdtemp(join(tmpdir(), "forge-mcp-orgs-")); | |
| 42 | app = createApp({ dataDir, port: 0 }); | |
| 43 | servers = await serve(app); | |
| 44 | const mk = async (username: string) => { | |
| 45 | const u = await createUser(app.db, { origin: "seed", trusted: true, | |
| 46 | username, | |
| 47 | email: `${username}@forge.local`, | |
| 48 | password: "a-good-passphrase", | |
| 49 | }); | |
| 50 | if (!u.ok) throw new Error(u.error); | |
| 51 | return u.value; | |
| 52 | }; | |
| 53 | const a = await mk("alice"); | |
| 54 | const b = await mk("bob"); | |
| 55 | const c = await mk("carol"); | |
| 56 | owner = createToken(app.db, a.id, "admin", "admin").token; | |
| 57 | member = createToken(app.db, b.id, "admin", "admin").token; | |
| 58 | stranger = createToken(app.db, c.id, "admin", "admin").token; | |
| 59 | const org = createOrg(app.db, "acme", a.id); | |
| 60 | if (!org.ok) throw new Error(org.error); | |
| 61 | const team = createTeam(app.db, org.value.id, "docs", "write"); | |
| 62 | if (!team.ok) throw new Error(team.error); | |
| 63 | addTeamMember(app.db, team.value, b.id); | |
| 64 | }, 30_000); | |
| 65 | ||
| 66 | afterAll(async () => { | |
| 67 | servers.stop(); | |
| 68 | app.close(); | |
| 69 | await rm(dataDir, { recursive: true, force: true }); | |
| 70 | }); | |
| 71 | ||
| 72 | test("the caller's organizations", async () => { | |
| 73 | const mine = await tool(member, {}); | |
| 74 | expect(JSON.parse(mine.text).map((o: any) => o.username)).toEqual(["acme"]); | |
| 75 | expect(JSON.parse((await tool(stranger, {})).text)).toEqual([]); | |
| 76 | }); | |
| 77 | ||
| 78 | test("one organization with a team and its member", async () => { | |
| 79 | const got = await tool(owner, { org: "acme" }); | |
| 80 | expect(got.isError).toBe(false); | |
| 81 | const org = JSON.parse(got.text); | |
| 82 | expect(org.username).toBe("acme"); | |
| 83 | expect(org.teams.map((t: any) => t.name)).toEqual(["Owners", "docs"]); | |
| 84 | expect(org.teams[1].members.map((m: any) => m.username)).toEqual(["bob"]); | |
| 85 | }); | |
| 86 | ||
| 87 | test("a stranger sees no team, and an unknown organization is not found", async () => { | |
| 88 | const got = await tool(stranger, { org: "acme" }); | |
| 89 | expect(JSON.parse(got.text).teams).toEqual([]); | |
| 90 | expect((await tool(owner, { org: "nope" })).isError).toBe(true); | |
| 91 | expect((await tool(owner, { org: 7 })).isError).toBe(true); | |
| 92 | }); | |
| 93 | }); |
test/mcp.test.ts +1
| @@ -205,6 +205,7 @@ describe("the forge over MCP", () => { | ||
| 205 | 205 | "seal", |
| 206 | 206 | "secrets", |
| 207 | 207 | "issue_teams", |
| 208 | "orgs", | |
| 208 | 209 | "now", |
| 209 | 210 | "stop", |
| 210 | 211 | "health", |
new above
offline
Good: members are listed only for teams that
listTeamsVisibleToreturned, so this matches the API'steamVisiblegate on…/members, and a stranger getsteams: [].