Public: anyone can read this; the crew can change it.

MCP `invites`: list the outstanding invites and revoke one, beside `mint_invite` #5124

Merged opened this

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

Merged

scout/forge:scout/4793-mcp-invites-list-the-outstanding-invites → main 4 commits fork 4 threads · 4 unresolved

  • Merged. scout/4793-mcp-invites-list-the-outstanding-invites was merged into main as 3085e38.

Review

Sign in to review this change.
5 files changed +143 −2
README.md +1 −1
@@ -1411,7 +1411,7 @@ one the provider rests hands its runs to the other until the reset.
14111411
14121412 **Roles.** An account is a guest, a member or an admin (`docs/ROLES.md` has the table of what each can do; `src/core/roles.ts` holds it). Admin is the admin flag, a member is promoted, everyone else is a guest. A guest is refused creating or forking a repository, making an app their own and starting a crew run, in the words "the owner has not made this account a member" (after the older refusals: unverified, suspended, agent, private); nothing switches it off. A forge admin who is a person lists everyone with their role at Settings → People (`/settings/people`) and promotes a guest to member with one button, with an optional starter model allowance in dollars a month (none by default, given as a model grant and audited as one), or demotes a member (the allowance stays until taken back on the account's page). In the terminal it is the **People** command (`r` changes a role: `member 5`, or `guest`); over the API, `GET /api/v1/admin/people` and `POST /api/v1/admin/accounts/:name/role` (`{"role": "member", "usdPerMonth": 5}`); over MCP, the `account_role` tool (a plan unless `dryRun: false`, with an admin token). Anyone signed in sees their own role and its row of the table: the account page (`/settings`) shows "What you can do here"; over MCP, `whoami_can` returns `{username, role, can, abilities}` with any token; in the terminal it is the **What I can do** command (read only, agents included).
14131413
1414 **Invites.** A forge admin who is a person mints a single-use invite link with `POST /api/v1/admin/invites` (`{"days": 14}`, 1 to 30, 7 by default); the response carries the link once and only its hash is kept. `GET /api/v1/admin/invites` lists them (open, used, revoked, expired) without tokens, and `DELETE /api/v1/admin/invites/:id` revokes one. Minting, revoking and using a link are events (`invite.minted`, `invite.revoked`, `invite.used`; the last names the invite and the account it let in, never the token). The same is the **Invite links** card on `/admin/limits` (a form with expiry days, 1 to 30; the new link is shown once, with a copy button; an open link has a Revoke button) and the terminal's **Invite links** command (`m` makes one and prints the link once, `x` revokes the selected open one). Over MCP, the `mint_invite` tool (`{days?}`, an admin token, a forge admin who is a person) answers with the same link. While signup is `invite`, the link opens the door once: `/signup?invite=…` (the form and the passkey door) or `invite` in the `POST /api/v1/users` body. In the terminal, with signup = invite the create-account screen has a fourth, masked **Invite** field that takes the token (not the whole link); a blank one is asked for, and a bad one is refused with the same sentence as `/signup`. The account and the invite's use are one write, so two sign-ups with one link make one account; a used, revoked or expired link, or any link while signup is `closed`, makes none. While signup is `open`, a link is not spent.
1414 **Invites.** A forge admin who is a person mints a single-use invite link with `POST /api/v1/admin/invites` (`{"days": 14}`, 1 to 30, 7 by default); the response carries the link once and only its hash is kept. `GET /api/v1/admin/invites` lists them (open, used, revoked, expired) without tokens, and `DELETE /api/v1/admin/invites/:id` revokes one. Minting, revoking and using a link are events (`invite.minted`, `invite.revoked`, `invite.used`; the last names the invite and the account it let in, never the token). The same is the **Invite links** card on `/admin/limits` (a form with expiry days, 1 to 30; the new link is shown once, with a copy button; an open link has a Revoke button) and the terminal's **Invite links** command (`m` makes one and prints the link once, `x` revokes the selected open one). Over MCP, the `mint_invite` tool (`{days?}`, an admin token, a forge admin who is a person) answers with the same link, and the `invites` tool (`{action: "list"|"revoke", id?}`, the same person admin and admin token, for both actions) lists them without links or revokes one. While signup is `invite`, the link opens the door once: `/signup?invite=…` (the form and the passkey door) or `invite` in the `POST /api/v1/users` body. In the terminal, with signup = invite the create-account screen has a fourth, masked **Invite** field that takes the token (not the whole link); a blank one is asked for, and a bad one is refused with the same sentence as `/signup`. The account and the invite's use are one write, so two sign-ups with one link make one account; a used, revoked or expired link, or any link while signup is `closed`, makes none. While signup is `open`, a link is not spent.
14151415
14161416 **Spend ceilings.** Apart from the grant, a forge admin who is a person may set any account's — a person's, an agent's or an organization's — daily (UTC) and per-run ceilings in dollars: on the account's page (`/settings/accounts/<name>`, an organization's too, by that address; an agent and an admin's own account have no such page), the terminal's **Model ceiling** command, `POST /api/v1/admin/accounts/:name/ceiling` (`{"dailyUsd": 20, "perRunUsd": 5}`) or the MCP `model_ceiling` tool; each change is in the audit. Both empty on the page, `none` in the terminal (its prompt shows the current ceilings, and an empty answer changes nothing), or `null` for both on the API and MCP takes them back. An organization's page is linked from its own page for an admin. An account has no ceiling until one is set, so this forge's own crew is never stopped by a default nobody chose. A run on an account with a ceiling does not start when the day's is spent or the per-run one is too small, is capped at the smaller of the per-run ceiling and what is left of the day, and stops mid-run when the day's is spent.
14171417 **Sandbox.** An agent's shell is held to a policy — the network is the forge
docs/MCP.md +1
@@ -72,6 +72,7 @@ for a tunnel and a forge that sleeps.
7272 | `make_it_yours` | `{ repo, name?, visibility?, dryRun? }`: a fork of an app you can see, as your own app, following the original; code and history copied, data and secrets not; private unless `visibility` is public (a public original only); at most 5 copies an account a day; plans unless `dryRun` is false | yes |
7373 | `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 |
7474 | `front_page` | `{ pinned?, hidden?, weights?, dryRun? }`: the front page as a stranger sees it, ranked, with numbers; a site admin sets what is pinned (first public one featured), hidden, and what counts (weights of forks, ships, use, fresh, 0 to 10) | yes |
75 | `invites` | `{ 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 | yes |
outdated

"as the /api/v1/admin/invites routes do" is not quite true: the routes list with an ro token, and this tool does not. Also add the tool to the Invites paragraph in README.md, which still names only mint_invite over MCP.

7576 | `map` | an app's plan: ready now, waiting on what, the critical path, where the crew is, parked twice and why (`app`); draws the `ui://forge/map` card | |
7677 | `milestones` | `{ app, issue? }`: how far each thing asked for is (and, with `issue`, whether you watch it: `watching`), from the dependency tree: the plan's limbs with their weights, or one milestone opened (who is building, what is blocked and on what, what merged in 7 days, an estimate, and for an owner `spend`: dollars, changes landed ever, the costliest three issues) | |
7778 | `limb` | `{ app, issue, action, reason?, pillar?, operation?, dryRun? }`: defer, resume or close a whole limb as not doing, or undo an operation; plans unless `dryRun` is false, and applying takes an admin token | yes |
src/http/mcp.ts +31 −1
@@ -97,7 +97,7 @@ import {
9797 } from "../web/studio/cards.ts";
9898 import { verifyAccount } from "../actions/accounts.ts";
9999 import { setAccountCeilings, setAccountGrant } from "../actions/model_grant.ts";
100 import { inviteLink, mint as mintInviteLink } from "../actions/invites.ts";
100 import { inviteLink, list as listInviteLinks, mint as mintInviteLink, revoke as revokeInviteLink } from "../actions/invites.ts";
101101 import { suspendAccount, unsuspendAccount } from "../actions/accounts.ts";
102102 import { setRole } from "../actions/roles.ts";
103103 import { ABILITIES, roleOf, rowOf } from "../core/roles.ts";
@@ -1648,6 +1648,36 @@ const TOOLS: readonly Tool[] = [
16481648 return good({ ...made.value.invite, link: inviteLink(origin, made.value.token) });
16491649 },
16501650 },
1651 {
1652 name: "invites",
1653 description: "List open invites, or revoke one by `id`. Person admins only.",

Low: the tool lists every invite (open, used, revoked, expired; listInvites has no state filter), but this says "List open invites". An agent reading it may think a used one is still open. Suggested wording: "List invites with their state (never a link), or revoke an open one by id. Person admins only." The docs/MCP.md row says "outstanding" and has the same drift.

1654 inputSchema: {
1655 type: "object",
1656 properties: {
1657 action: { type: "string", enum: ["list", "revoke"] },
1658 id: { type: "string" },
1659 },
1660 required: ["action"],
1661 },
1662 writes: true,

This requires admin scope for list too. The API's GET route lists with ro, and writes: true already makes the dispatcher refuse ro for every action. Either let list run under ro (a separate read-only tool, or a scope check on revoke only) so it matches the API as the doc row says, or change the doc row so it no longer claims to match.

1663 run(ctx, user, args) {
1664 if (!mayBoards(user)) return bad("forbidden", "invites are a forge admin's");
1665 if (!scopeAllows(ctx.auth.scope ?? "ro", "admin"))
1666 return bad("forbidden", "invites need an admin token");
1667 const action = args["action"];
1668 if (action === "list") {
1669 const listed = listInviteLinks(ctx.app, user);
1670 return listed.ok ? good({ items: listed.value }) : bad("forbidden", "invites are a forge admin's");
1671 }
1672 if (action === "revoke") {
1673 const id = args["id"];
1674 if (typeof id !== "string" || id === "") return bad("invalid", "revoke needs an invite id");
1675 const done = revokeInviteLink(ctx.app, user, id);
1676 return done.ok ? good(done.value) : bad("not_found", "no such open invite");
1677 }
1678 return bad("invalid", 'action is "list" or "revoke"');
1679 },
1680 },
16511681 {
16521682 name: "account_role",
16531683 description:
test/mcp-invites.test.ts added +109
@@ -0,0 +1,109 @@
1 /**
2 * The `invites` tool is the admin invites API over MCP: the same actions, so
3 * the same rules. A listing never carries a link or a token.
4 */
5 import { afterAll, beforeAll, expect, test } from "bun:test";
6 import { mkdtemp, rm } from "node:fs/promises";
7 import { tmpdir } from "node:os";
8 import { join } from "node:path";
9 import { createApp, type App } from "../src/app.ts";
10 import { createToken } from "../src/core/tokens.ts";
11 import { serve, type Servers } from "../src/http/server.ts";
12 import { setSignup } from "../src/settings/signup.ts";
13 import { setLimit } from "../src/settings/limits.ts";
14 import { seedAdmin } from "./helpers/admin.ts";
15 import { promote } from "./helpers/members.ts";
16 import { createUser } from "../src/core/users.ts";
17 import { quietLogs } from "./helpers/globals.ts";
18
19 quietLogs();
20
21 let dir: string;
22 let app: App;
23 let servers: Servers;
24 let admin = "";
25 let ro = "";
26 let rw = "";
27 let bob = "";
28 let id = 1;
29
30 const tool = async (token: string, name: string, args: unknown = {}) => {
31 const res = await fetch(`${servers.url}/mcp`, {
32 method: "POST",
33 headers: {
34 "content-type": "application/json",
35 accept: "application/json, text/event-stream",
36 authorization: `Bearer ${token}`,
37 },
38 body: JSON.stringify({ jsonrpc: "2.0", id: id++, method: "tools/call", params: { name, arguments: args } }),
39 });
40 const body = (await res.json()) as any;
41 const text: string = body.result.content[0].text;
42 return { text, error: body.result.isError === true, value: body.result.isError ? undefined : JSON.parse(text) };
43 };
44
45 beforeAll(async () => {
46 dir = await mkdtemp(join(tmpdir(), "forge-mcp-invites-"));
47 app = createApp({ dataDir: dir, port: 0 });
48 servers = await serve(app);
49 const a = await seedAdmin(app, { username: "olive", email: "[email protected]", password: "owner-passphrase" });
50 if (!a.ok) throw new Error(a.error);
51 setSignup(app.db, "invite", a.value.id);
52 setLimit(app.db, "signup.per_source", 1000, a.value.id);
53 const b = await createUser(app.db, { origin: "seed", trusted: true, username: "bob", email: "[email protected]", password: "a-good-passphrase" });
54 if (!b.ok) throw new Error(b.error);
55 promote(app.db, b.value);
56 admin = createToken(app.db, a.value.id, "admin", "admin").token;
57 ro = createToken(app.db, a.value.id, "ro", "ro").token;
58 rw = createToken(app.db, a.value.id, "rw", "rw").token;
59 bob = createToken(app.db, b.value.id, "bob", "admin").token;
60 }, 30_000);
61
62 afterAll(async () => {
63 servers.stop();
64 app.close();
65 await rm(dir, { recursive: true, force: true });
66 });
67
68 const signup = (username: string, invite: string) =>
69 fetch(`${servers.url}/api/v1/users`, {
70 method: "POST",
71 headers: { "content-type": "application/json" },
72 body: JSON.stringify({ username, email: `${username}@f.dev`, password: "an-invited-passphrase", invite }),
73 });
74
75 test("an admin mints, lists and revokes; a revoked link signs no one up", async () => {
76 const kept = (await tool(admin, "mint_invite", { days: 3 })).value;
77 const gone = (await tool(admin, "mint_invite")).value;
78 const goneToken = gone.link.split("invite=")[1];
79
80 const listed = await tool(admin, "invites", { action: "list" });
81 const ids = listed.value.items.map((i: { id: string }) => i.id);
82 expect(ids).toContain(kept.id);
83 expect(ids).toContain(gone.id);
84 expect(listed.text).not.toContain("link");
85 expect(listed.text).not.toContain(goneToken);
86 expect(listed.text).not.toContain(kept.link.split("invite=")[1]);
87
88 const done = await tool(admin, "invites", { action: "revoke", id: gone.id });
89 expect(done.value.state).toBe("revoked");
90 expect((await tool(admin, "invites", { action: "revoke", id: gone.id })).error).toBe(true);
91 expect((await tool(admin, "invites", { action: "revoke" })).text).toContain("invalid");
92 expect((await tool(admin, "invites", { action: "nope" })).text).toContain("invalid");
93
94 expect((await signup("late", goneToken)).status).toBeGreaterThanOrEqual(400);
95 expect((await signup("early", kept.link.split("invite=")[1])).status).toBeLessThan(300);
96 });
97
98 test("a non-admin and a lesser token are refused", async () => {
outdated

This only checks that some error came back, and today that is the dispatcher's write-scope refusal, not the tool's own check. Assert the rule you settle on: an ro list succeeds if it matches the API, or forbidden text if it stays strict. Also add an ro revoke that is refused.

99 expect((await tool(bob, "invites", { action: "list" })).text).toContain("forbidden");
100 for (const token of [ro, rw]) {
101 for (const args of [{ action: "list" }, { action: "revoke", id: "x" }]) {
102 const res = await tool(token, "invites", args);
103 expect(res.error).toBe(true);
104 expect(res.text).toContain("forbidden");
105 }
106 }
107 expect((await tool(ro, "invites", { action: "list" })).text).toContain("may read, not act");
108 expect((await tool(rw, "invites", { action: "list" })).text).toContain("need an admin token");
109 });
test/mcp.test.ts +1
@@ -201,6 +201,7 @@ describe("the forge over MCP", () => {
201201 "model_grant",
202202 "whoami_can",
203203 "mint_invite",
204 "invites",
204205 "account_role",
205206 "account_suspend",
206207 "model_ceiling",

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs