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

Merged opened this

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

Merged

scout/forge:scout/5328-mcp-orgs-the-caller-s-organizations-and → main 5 commits fork 3 threads · 3 unresolved

  • Merged. scout/5328-mcp-orgs-the-caller-s-organizations-and was merged into main as b1fae71.

Review

Sign in to review this change.
10 files changed +127 −5
docs/MCP.md +1
@@ -91,6 +91,7 @@ for a tunnel and a forge that sleeps.
9191 | `next_steps` | on a spawned forge, your first three steps and whether each is done; empty elsewhere or for anyone but its owner | |
9292 | `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`) | |
9393 | `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` | |
9495 | `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 |
9596 | `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 |
9697 | `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";
44 export const tool: Tool = {
55 name: "asks",
66 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",
88 inputSchema: { type: "object", properties: { ...CHIP_ARGS } },
99 writes: false,
1010 run(ctx, user, args) {
src/http/mcp/tools/index.ts +1
@@ -73,6 +73,7 @@ export { tool as new_app } from "./new_app.ts";
7373 export { tool as next_steps } from "./next_steps.ts";
7474 export { tool as now } from "./now.ts";
7575 export { tool as org_teams } from "./org_teams.ts";
76 export { tool as orgs } from "./orgs.ts";
7677 export { tool as origin_apps } from "./origin_apps.ts";
7778 export { tool as origins } from "./origins.ts";
7879 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";
55 export const tool: Tool = {
66 name: "invites",
77 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.",
99 inputSchema: {
1010 type: "object",
1111 properties: {
src/http/mcp/tools/mirror_set.ts +1 −1
@@ -5,7 +5,7 @@ import { good, bad, type Tool } from "../shared.ts";
55 export const tool: Tool = {
66 name: "mirror_set",
77 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.",
99 inputSchema: {
1010 type: "object",
1111 properties: {
src/http/mcp/tools/org_teams.ts +1 −1
@@ -5,7 +5,7 @@ import { good, bad, type Tool } from "../shared.ts";
55 export const tool: Tool = {
66 name: "org_teams",
77 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.",
99 inputSchema: {
1010 type: "object",
1111 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.

Good: members are listed only for teams that listTeamsVisibleTo returned, so this matches the API's teamVisible gate on …/members, and a stranger gets teams: [].

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 (keyedPage). Fine at current sizes; if an org grows large, add a limit/cursor here as accounts does.

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";
66 export const tool: Tool = {
77 name: "quotas",
88 description:
9 "Per-account repo and app quotas (number or null; unenforced). With `key`, `value` (\"\" clears).",
9 "Per-account repo and app quotas (unenforced).",
1010 inputSchema: {
1111 type: "object",
1212 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", () => {
205205 "seal",
206206 "secrets",
207207 "issue_teams",
208 "orgs",
208209 "now",
209210 "stop",
210211 "health",

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs