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

Merged opened this

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

Merged

scout/forge:scout/5329-mcp-org-create-make-an-organization-with → main 2 commits fork 1 thread · 1 unresolved

  • Merged. scout/5329-mcp-org-create-make-an-organization-with was merged into main as 966997f.

Review

Sign in to review this change.
5 files changed +41 −2
docs/MCP.md +1
@@ -107,6 +107,7 @@ for a tunnel and a forge that sleeps.
107107 | `next_steps` | on a spawned forge, your first three steps and whether each is done; empty elsewhere or for anyone but its owner | |
108108 | `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 |
109109 | `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 |
110111 | `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 |
111112 | `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` | |
112113 | `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";
8080 export { tool as next_steps } from "./next_steps.ts";
8181 export { tool as notify } from "./notify.ts";
8282 export { tool as now } from "./now.ts";
83 export { tool as org_create } from "./org_create.ts";
8384 export { tool as org_teams } from "./org_teams.ts";
8485 export { tool as orgs } from "./orgs.ts";
8586 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);

Low: the docs row promises the same words as POST /api/v1/orgs. The API turns unverified into VERIFY_FIRST (through respondAccess), but this tool answers unverified/unverified, so an unverified person isn't told what to do. Fix: do what new_app.ts:45 does, if (made.error === "unverified") return bad("unverified", VERIFY_FIRST); with VERIFY_FIRST imported from actions/verify_words.ts. Also add one assertion that an agent token gets agent_cannot_own.

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", () => {
1818 let stranger: string;
1919 let id = 1;
2020
21 const tool = async (token: string, args: unknown) => {
21 const tool = async (token: string, args: unknown, name = "orgs") => {
2222 const res = await fetch(`${servers.url}/mcp`, {
2323 method: "POST",
2424 headers: {
@@ -30,7 +30,7 @@ describe("MCP orgs", () => {
3030 jsonrpc: "2.0",
3131 id: id++,
3232 method: "tools/call",
33 params: { name: "orgs", arguments: args },
33 params: { name, arguments: args },
3434 }),
3535 });
3636 const body = (await res.json()) as any;
@@ -90,4 +90,19 @@ describe("MCP orgs", () => {
9090 expect((await tool(owner, { org: "nope" })).isError).toBe(true);
9191 expect((await tool(owner, { org: 7 })).isError).toBe(true);
9292 });
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 });
93108 });
test/mcp.test.ts +1
@@ -214,6 +214,7 @@ describe("the forge over MCP", () => {
214214 "issue_teams",
215215 "orgs",
216216 "now",
217 "org_create",
217218 "stop",
218219 "health",
219220 "audit",

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs