Public: anyone can read this; the crew can change it.
MCP `org_create`: make an organization, with the same rules as POST /api/v1/orgs #7047
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/5329-mcp-org-create-make-an-organization-with → main 2 commits fork 1 thread · 1 unresolved
5 files
changed +41
−2
docs/MCP.md +1
| @@ -107,6 +107,7 @@ for a tunnel and a forge that sleeps. | ||
| 107 | 107 | | `next_steps` | on a spawned forge, your first three steps and whether each is done; empty elsewhere or for anyone but its owner | | |
| 108 | 108 | | `notify` | `{ url? }`: read (its host only), set or, with an empty `url`, turn off your phone notify URL, as Settings does; a person's, saving needs an admin token | yes | |
| 109 | 109 | | `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`) | | |
| 110 | | `org_create` | `{ name }`: make an organization you own, with the same rules and words as POST `/api/v1/orgs` (a taken, reserved or invalid name is refused; an agent cannot own one) | yes | | |
| 110 | 111 | | `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 | |
| 111 | 112 | | `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` | | |
| 112 | 113 | | `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 | |
src/http/mcp/tools/index.ts +1
| @@ -80,6 +80,7 @@ export { tool as new_app } from "./new_app.ts"; | ||
| 80 | 80 | export { tool as next_steps } from "./next_steps.ts"; |
| 81 | 81 | export { tool as notify } from "./notify.ts"; |
| 82 | 82 | export { tool as now } from "./now.ts"; |
| 83 | export { tool as org_create } from "./org_create.ts"; | |
| 83 | 84 | export { tool as org_teams } from "./org_teams.ts"; |
| 84 | 85 | export { tool as orgs } from "./orgs.ts"; |
| 85 | 86 | export { tool as origin_apps } from "./origin_apps.ts"; |
src/http/mcp/tools/org_create.ts added +21
| @@ -0,0 +1,21 @@ | ||
| 1 | import { createOrg } from "../../../actions/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: "org_create", | |
| 7 | doc: "`{ name }`: make an organization you own, with the same rules and words as POST `/api/v1/orgs` (a taken, reserved or invalid name is refused; an agent cannot own one)", | |
| 8 | description: "Make an organization.", | |
| 9 | inputSchema: { | |
| 10 | type: "object", | |
| 11 | properties: { name: { type: "string", maxLength: 64 } }, | |
| 12 | required: ["name"], | |
| 13 | }, | |
| 14 | writes: true, | |
| 15 | run(ctx, user, args) { | |
| 16 | const name = typeof args["name"] === "string" ? args["name"] : ""; | |
| 17 | const made = createOrg(ctx.app, user, name); | |
| 18 | if (!made.ok) return bad(made.error, made.error); | |
| 19 | return good({ id: made.value.id, ...publicProfile(made.value), kind: "org" }); | |
| 20 | }, | |
| 21 | }; |
test/mcp-orgs.test.ts +17 −2
| @@ -18,7 +18,7 @@ describe("MCP orgs", () => { | ||
| 18 | 18 | let stranger: string; |
| 19 | 19 | let id = 1; |
| 20 | 20 | |
| 21 | const tool = async (token: string, args: unknown) => { | |
| 21 | const tool = async (token: string, args: unknown, name = "orgs") => { | |
| 22 | 22 | const res = await fetch(`${servers.url}/mcp`, { |
| 23 | 23 | method: "POST", |
| 24 | 24 | headers: { |
| @@ -30,7 +30,7 @@ describe("MCP orgs", () => { | ||
| 30 | 30 | jsonrpc: "2.0", |
| 31 | 31 | id: id++, |
| 32 | 32 | method: "tools/call", |
| 33 | params: { name: "orgs", arguments: args }, | |
| 33 | params: { name, arguments: args }, | |
| 34 | 34 | }), |
| 35 | 35 | }); |
| 36 | 36 | const body = (await res.json()) as any; |
| @@ -90,4 +90,19 @@ describe("MCP orgs", () => { | ||
| 90 | 90 | expect((await tool(owner, { org: "nope" })).isError).toBe(true); |
| 91 | 91 | expect((await tool(owner, { org: 7 })).isError).toBe(true); |
| 92 | 92 | }); |
| 93 | ||
| 94 | test("org_create makes an organization that orgs then reads back", async () => { | |
| 95 | const made = await tool(stranger, { name: "newco" }, "org_create"); | |
| 96 | expect(made.isError).toBe(false); | |
| 97 | expect(JSON.parse(made.text).username).toBe("newco"); | |
| 98 | const got = await tool(stranger, { org: "newco" }); | |
| 99 | expect(JSON.parse(got.text).teams.map((t: any) => t.name)).toEqual(["Owners"]); | |
| 100 | expect(JSON.parse((await tool(stranger, {})).text).map((o: any) => o.username)).toEqual(["newco"]); | |
| 101 | }); | |
| 102 | ||
| 103 | test("org_create refuses a taken name and an invalid one", async () => { | |
| 104 | expect((await tool(owner, { name: "acme" }, "org_create")).isError).toBe(true); | |
| 105 | expect((await tool(owner, { name: "not valid!" }, "org_create")).isError).toBe(true); | |
| 106 | expect((await tool(owner, {}, "org_create")).isError).toBe(true); | |
| 107 | }); | |
| 93 | 108 | }); |
test/mcp.test.ts +1
| @@ -214,6 +214,7 @@ describe("the forge over MCP", () => { | ||
| 214 | 214 | "issue_teams", |
| 215 | 215 | "orgs", |
| 216 | 216 | "now", |
| 217 | "org_create", | |
| 217 | 218 | "stop", |
| 218 | 219 | "health", |
| 219 | 220 | "audit", |
new above
offline
Low: the docs row promises the same words as POST
/api/v1/orgs. The API turnsunverifiedintoVERIFY_FIRST(throughrespondAccess), but this tool answersunverified/unverified, so an unverified person isn't told what to do. Fix: do whatnew_app.ts:45does,if (made.error === "unverified") return bad("unverified", VERIFY_FIRST);withVERIFY_FIRSTimported fromactions/verify_words.ts. Also add one assertion that an agent token getsagent_cannot_own.