Public: anyone can read this; the crew can change it.
MCP `invites`: list the outstanding invites and revoke one, beside `mint_invite` #5124
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/4793-mcp-invites-list-the-outstanding-invites → main 4 commits fork 4 threads · 4 unresolved
README.md +1 −1
| @@ -1411,7 +1411,7 @@ one the provider rests hands its runs to the other until the reset. | ||
| 1411 | 1411 | |
| 1412 | 1412 | **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). |
| 1413 | 1413 | |
| 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. | |
| 1415 | 1415 | |
| 1416 | 1416 | **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. |
| 1417 | 1417 | **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. | ||
| 72 | 72 | | `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 | |
| 73 | 73 | | `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 | |
| 74 | 74 | | `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 | | |
| 75 | 76 | | `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 | | |
| 76 | 77 | | `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) | | |
| 77 | 78 | | `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 { | ||
| 97 | 97 | } from "../web/studio/cards.ts"; |
| 98 | 98 | import { verifyAccount } from "../actions/accounts.ts"; |
| 99 | 99 | 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"; | |
| 101 | 101 | import { suspendAccount, unsuspendAccount } from "../actions/accounts.ts"; |
| 102 | 102 | import { setRole } from "../actions/roles.ts"; |
| 103 | 103 | import { ABILITIES, roleOf, rowOf } from "../core/roles.ts"; |
| @@ -1648,6 +1648,36 @@ const TOOLS: readonly Tool[] = [ | ||
| 1648 | 1648 | return good({ ...made.value.invite, link: inviteLink(origin, made.value.token) }); |
| 1649 | 1649 | }, |
| 1650 | 1650 | }, |
| 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; |
||
| 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 |
||
| 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 | }, | |
| 1651 | 1681 | { |
| 1652 | 1682 | name: "account_role", |
| 1653 | 1683 | 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 |
||
| 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", () => { | ||
| 201 | 201 | "model_grant", |
| 202 | 202 | "whoami_can", |
| 203 | 203 | "mint_invite", |
| 204 | "invites", | |
| 204 | 205 | "account_role", |
| 205 | 206 | "account_suspend", |
| 206 | 207 | "model_ceiling", |
"as the
/api/v1/admin/invitesroutes do" is not quite true: the routes list with anrotoken, and this tool does not. Also add the tool to the Invites paragraph in README.md, which still names onlymint_inviteover MCP.