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

MCP `accounts`: list the forge's accounts with role, verified and suspended, for an admin #4815

Merged opened this

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

Merged

scout/forge:scout/4803-mcp-accounts-list-the-forge-s-accounts-w → main 6 commits fork 7 threads · 7 unresolved

  • Merged. scout/4803-mcp-accounts-list-the-forge-s-accounts-w was merged into main as 4d99f0a.

Review

Sign in to review this change.
28 files changed +165 −23
docs/MCP.md +1
@@ -33,6 +33,7 @@ for a tunnel and a forge that sleeps.
3333 | `account_role` | `{ username, role, usdPerMonth?, dryRun? }`: make a person a `member`, or a `guest` again, with an optional starter allowance (`usdPerMonth`, 0.01 to 10000, promoting only); planned first (`dryRun` is true unless `false`) | yes |
3434 | `account_suspend` | `{ username, suspended }`: suspend a person's account, or unsuspend it with `false`; their sessions and tokens stop at once; the API's rules (not yourself, another admin or an agent; not the only owner of an organization); an admin token | yes |
3535 | `account_verify` | `{ username }`: mark a person's email verified, as `POST …/admin/accounts/:username/verify` does; a person admin's, with an admin token | yes |
36 | `accounts` | `{ cursor?, limit? }`: the forge's accounts, newest first: `username`, `role`, `verified`, `suspended`, `created`, and `next` (a cursor, or null); `limit` 1-50 (default 20); a person admin's | |
3637 | `agent` | `{ action, username, … }`: `create` a crew agent, `update` its `model`, `brief` or `budgetUsd`, or `remove` it; an admin's, with an `admin` token, as `/api/v1/agents` | yes |
3738 | `app_deploy` | `{ repo, env, action }`: `redeploy` or `stop` an app's `production` or a pull's `pull-N` preview; a writer's, queued as `POST …/app/:env/redeploy` and `…/stop` are; answers `{ queued, run }` or `{ stopped }` | yes |
3839 | `app_env` | `{ repo, action, name?, value?, scope? }`: `list` an app's variable names and scopes, `set` one, or `unset` it, in `all` (default), `production` or `preview`; the API's rules and errors, an admin of the repository with an admin-scope token; a value is never read back | yes |
src/http/mcp/tools/accounts.ts added +45
@@ -0,0 +1,45 @@
1 import { listAccounts } from "../../../actions/accounts.ts";
2 import { roleOf } from "../../../core/roles.ts";
3 import { getUserByName } from "../../../core/users.ts";
4 import { good, bad, type Tool } from "../shared.ts";
5
6 export const tool: Tool = {
7 name: "accounts",
8 doc: "`{ cursor?, limit? }`: the forge's accounts, newest first: `username`, `role`, `verified`, `suspended`, `created`, and `next` (a cursor, or null); `limit` 1-50 (default 20); a person admin's",
9 description: "Accounts, newest first: role, verified, suspended. `cursor` pages.",
10 inputSchema: {
11 type: "object",
12 properties: {
13 cursor: { type: "string" },
14 limit: { type: "integer", minimum: 1, maximum: 50 },
15 },
16 },
17 writes: false,
18 adminOnly: true,
19 run(ctx, user, args) {
20 const listed = listAccounts(ctx.app, user);

Low: adminOnly lists the tool to agent admins too, but listAccounts refuses them, so an agent admin is shown a tool it can never use. Nothing leaks, since the refusal is correct. Someday a personAdminOnly filter in shared.ts would close that gap.

21 if (!listed.ok) return bad("forbidden", "the accounts are a forge admin's to list");

Low: this is the only line that refuses an agent with isAdmin, because adminOnly checks only user.isAdmin. A test with an admin agent's token, expecting forbidden, would pin it.

22 const given = args["limit"];
23 if (given !== undefined && (!Number.isInteger(given) || (given as number) < 1 || (given as number) > 50))
24 return bad("invalid", "limit is a whole number from 1 to 50");
25 const limit = (given as number | undefined) ?? 20;
26 const from = args["cursor"] === undefined ? 0 : Number(args["cursor"]);

Low: cursor: "" reads as offset 0, and a numeric cursor (5) also passes although the schema says string. Suggested fix: typeof args.cursor === "string" && /^\d+$/.test(args.cursor), otherwise refuse as invalid.

27 if (!Number.isInteger(from) || from < 0 || typeof args["cursor"] === "boolean")
28 return bad("invalid", "that cursor was not issued here");
29 const page = listed.value.slice(from, from + limit);
30 const next = from + limit < listed.value.length ? String(from + limit) : null;
31 return good({
32 items: page.map((a) => {
33 const who = getUserByName(ctx.app.db, a.username);
34 return {
35 username: a.username,
36 role: who === undefined ? "guest" : roleOf(ctx.app.db, who),
37 verified: a.verified,
38 suspended: a.suspendedAt !== null,
39 created: a.createdAt,
40 };
41 }),
42 next,
43 });
44 },
45 };
src/http/mcp/tools/app_env.ts +1 −1
@@ -6,7 +6,7 @@ export const tool: Tool = {
66 name: "app_env",
77 doc: "`{ repo, action, name?, value?, scope? }`: `list` an app's variable names and scopes, `set` one, or `unset` it, in `all` (default), `production` or `preview`; the API's rules and errors, an admin of the repository with an admin-scope token; a value is never read back",
88 description:
9 "An app's environment variables: list names, set or unset one by scope; values are write-only. Admin.",
9 "An app's environment variables: list names, set or unset by scope; write-only. Admin.",
1010 inputSchema: {
1111 type: "object",
1212 properties: {
src/http/mcp/tools/archive.ts +1 −1
@@ -6,7 +6,7 @@ export const tool: Tool = {
66 name: "archive",
77 doc: "`{ repo | repos, archived?, dryRun? }`: archive (true) or wake (false) a repository, by name; planned first (`dryRun` is true unless `false`); only the repository's admin with an admin token, never an agent. `repos` (at most 50) runs the fleet `archive` operation over them, answers each one's outcome and the `operation` id; `limb` with `action: \"undo\"`, that `operation` and any one of the repositories as `app` reverts it for a day",
88 description:
9 "Archive a repository or wake it (`archived` true/false); deletes nothing. Plans first; admin token.",
9 "Archive or wake a repository (`archived`). Plans first; admin token.",

Nit: dropping "deletes nothing" loses the one phrase that tells an agent archiving is safe. Same with app_env: "values are write-only" became "write-only", which now reads as if the whole tool only writes. If bytes free up later, put both back.

1010 inputSchema: {
1111 type: "object",
1212 properties: {
src/http/mcp/tools/asked.ts +1 −1
@@ -6,7 +6,7 @@ export const tool: Tool = {
66 name: "asked",
77 doc: "`{ repo?, show?, order? }`: what people asked the crew for, by default nothing merged yet and oldest first: who and when, the pulls that close it, the crew's last activity, waiting/building/shipped/closed, stale when not shipped in three days and flagged when stale with nothing built; repositories you can write",
88 description:
9 "What people asked the crew for where you write: by default nothing merged yet, oldest first.",
9 "What people asked the crew for where you write: by default unmerged, oldest first.",
1010 inputSchema: {
1111 type: "object",
1212 properties: {
src/http/mcp/tools/asks_undecide.ts +1 −1
@@ -4,7 +4,7 @@ import { good, bad, type Tool } from "../shared.ts";
44 export const tool: Tool = {
55 name: "asks_undecide",
66 description:
7 "Put a card the decider set aside back on your board, within a day. `id` is from `asks_decide`.",
7 "Put a set-aside card back on your board, within a day. `id` from `asks_decide`.",
88 inputSchema: {
99 type: "object",
1010 properties: { id: { type: "string", maxLength: 80 } },
src/http/mcp/tools/attach_look.ts +1 −1
@@ -6,7 +6,7 @@ import { good, attachWords, bad, type Tool, str, APP_ARG, planRepo, isAnswer } f
66 export const tool: Tool = {
77 name: "attach_look",
88 description:
9 "Attach a PNG or JPEG (base64 `image`, max 5 MB) to an open pull: `phone` or `desktop`, `says`.",
9 "Attach a PNG or JPEG (base64 `image`, max 5 MB) to an open pull: `phone`/`desktop`, `says`.",
1010 inputSchema: {
1111 type: "object",
1212 properties: {
src/http/mcp/tools/audit.ts +1 −1
@@ -6,7 +6,7 @@ export const tool: Tool = {
66 name: "audit",
77 doc: "who can do what, per app, read from its contract.json in git: yes, own or no per door, and whether outsiders may direct its crew (`crewOutsiders`)",
88 description:
9 "Who can do what in every app you can see, from each contract.json: resource × verb, yes, own or no.",
9 "Who can do what in each app you see, from contract.json: resource × verb, yes, own or no.",
1010 inputSchema: {
1111 type: "object",
1212 properties: {
src/http/mcp/tools/board_write.ts +1 −1
@@ -5,7 +5,7 @@ export const tool: Tool = {
55 name: "board_write",
66 doc: "`{ slug, board }` (person admins): write a whole board under a slug, made or replaced: `{ title, range, refresh, panels: [{ title, chart, query, w, h }] }`",
77 description:
8 "Write a whole board under a slug: {title, range, refresh, panels: [{title, chart, query, w, h}]}.",
8 "Write a whole board under a slug: {title, range, refresh, panels}.",
99 inputSchema: {
1010 type: "object",
1111 properties: {
src/http/mcp/tools/branches.ts +1 −1
@@ -11,7 +11,7 @@ export const tool: Tool = {
1111 name: "branches",
1212 doc: "`{ repo, name?, action?, from?, kind? }`: without `name`, lists the branches; `kind: \"tags\"` lists the tags instead (as GET `/api/v1/repos/:owner/:name/tags` does, newest first; no `name` or `action`); with `name` and `action` (`create` from `from`, or `delete`) it does what the branches page does: the default branch is never deleted, only a writer may act",
1313 description:
14 "A repository's branches; `name` and `action` create (from `from`) or delete one, not the default.",
14 "A repository's branches; `name`, `action` create (from `from`) or delete one, not the default.",
1515 inputSchema: {
1616 type: "object",
1717 properties: {
src/http/mcp/tools/cloud_accounts.ts +1 −1
@@ -6,7 +6,7 @@ export const tool: Tool = {
66 name: "cloud_accounts",
77 doc: "the forge's cloud accounts, each attached provider token with its use, verification, write permission and today's calls; a forge admin only (counted as a write so a `ro` token never lists them)",
88 description:
9 "Cloud accounts (forge admin): each provider token's use, verification, write permission, calls today.",
9 "Cloud accounts (admin): each provider token's use, verification, write permission, calls today.",
1010 inputSchema: { type: "object", properties: {} },
1111 // A read, but marked a write so a `ro` token never lists the accounts; it
1212 // is a forge admin's and the audit rows name every vendor call.
src/http/mcp/tools/contribute.ts +1 −1
@@ -5,7 +5,7 @@ export const tool: Tool = {
55 name: "contribute",
66 doc: "for any arriving agent: what this forge is, its bottleneck, open “help wanted” issues, how to propose",
77 description:
8 "For an arriving agent: what this forge is, its bottleneck, “help wanted” issues, how to propose.",
8 "For an arriving agent: what this forge is, its bottleneck, help-wanted issues, how to propose.",
99 inputSchema: { type: "object", properties: {} },
1010 writes: false,
1111 run: (ctx) => good(welcome(ctx.app)),
src/http/mcp/tools/converge.ts +1 −1
@@ -8,7 +8,7 @@ export const tool: Tool = {
88 name: "converge",
99 doc: "the fork's crew merges where both sides changed the same files; comes back as a pull",
1010 description:
11 "Have a fork's crew merge the parent's updates where both changed the same files, as a pull.",
11 "A fork's crew merges the parent's updates where both changed the same files, as a pull.",
1212 inputSchema: {
1313 type: "object",
1414 properties: { repo: REPO_ARG },
src/http/mcp/tools/edit.ts +1 −1
@@ -5,7 +5,7 @@ export const tool: Tool = {
55 name: "edit",
66 doc: "a new title or body for an issue or a pull: its author's, or a writer's; an agent edits only what it wrote",
77 description:
8 "Retitle or rewrite an issue or pull: yours, or any where you write; an agent edits only its own.",
8 "Retitle or rewrite an issue or pull: yours, or where you write; an agent only its own.",
99 inputSchema: {
1010 type: "object",
1111 properties: {
src/http/mcp/tools/following.ts +1 −1
@@ -9,7 +9,7 @@ export const tool: Tool = {
99 name: "following",
1010 doc: "where a fork stands against what it follows, and what the crew is doing about it",
1111 description:
12 "Where a fork stands against what it follows: in step, behind, ahead, diverged; files to decide.",
12 "Where a fork stands against what it follows: in step, behind, ahead, diverged.",
1313 inputSchema: {
1414 type: "object",
1515 properties: { repo: REPO_ARG },
src/http/mcp/tools/forges.ts +1 −1
@@ -5,7 +5,7 @@ export const tool: Tool = {
55 name: "forges",
66 doc: "an admin's list of the forges sent from here: name, address, whom for, apps, claimed or not, last activity",
77 description:
8 "The forges sent from here (admin): name, address, for, apps, claimed, spendTodayUsd, ceilingUsd.",
8 "The forges sent from here (admin): name, address, apps, claimed, spendTodayUsd, ceilingUsd.",
99 inputSchema: { type: "object", properties: {} },
1010 writes: false,
1111 async run(ctx, user) {
src/http/mcp/tools/here.ts +1 −1
@@ -4,7 +4,7 @@ import { good, bad, type Tool, str } from "../shared.ts";
44 export const tool: Tool = {
55 name: "here",
66 description:
7 "What you can do on a forge page, as who you are: each open action with its HTTP route and MCP tool.",
7 "What you can do on a forge page: each open action with its HTTP route and MCP tool.",
88 inputSchema: {
99 type: "object",
1010 properties: { url: { type: "string", maxLength: 2000 } },
src/http/mcp/tools/hold.ts +1 −1
@@ -6,7 +6,7 @@ export const tool: Tool = {
66 name: "hold",
77 doc: "`{ app, pull, why?, release?, dryRun? }`: hold a pull so no rule reviews or lands it, or let it go; a person who can write, never an agent; plans unless `dryRun: false`",
88 description:
9 "Hold a pull (`why`) or let it go (`release: true`): while held nothing reviews or lands it. Writers.",
9 "Hold a pull (`why`) or release it (`release: true`): held, nothing reviews or lands it. Writers.",
1010 inputSchema: {
1111 type: "object",
1212 properties: {
src/http/mcp/tools/hooks.ts +1 −1
@@ -20,7 +20,7 @@ const notAdmin = (): Answer => bad("not_found", "no such app or repository, or y
2020 export const tool: Tool = {
2121 name: "hooks",
2222 doc: "`{ repo, action, url, secret, kinds, id }`: `list`, `add` (`secret`, 16–200 characters, is required) or `remove` the repository's webhooks, as `…/hooks`; an admin-scope token on a repository you administer, and a secret is never shown",
23 description: "List, add (`url`, `secret`, `kinds`) or remove (`id`) a repository's webhooks; admins only, a secret is never shown.",
23 description: "List, add (`url`, `secret`, `kinds`) or remove (`id`) a repository's webhooks; admins; a secret is never shown.",
2424 inputSchema: {
2525 type: "object",
2626 properties: {
src/http/mcp/tools/index.ts +1
@@ -4,6 +4,7 @@ export { tool as access } from "./access.ts";
44 export { tool as account_role } from "./account_role.ts";
55 export { tool as account_suspend } from "./account_suspend.ts";
66 export { tool as account_verify } from "./account_verify.ts";
7 export { tool as accounts } from "./accounts.ts";
78 export { tool as agent } from "./agent.ts";
89 export { tool as app_deploy } from "./app_deploy.ts";
910 export { tool as app_env } from "./app_env.ts";
src/http/mcp/tools/issue_teams.ts +1 −1
@@ -5,7 +5,7 @@ import { good, bad, type Tool, REPO_ARG, repoArg, issueArg, isAnswer } from "../
55 export const tool: Tool = {
66 name: "issue_teams",
77 description:
8 "Name up to two crew teams (design, data-science, security, platform) to review an issue's pull.",
8 "Name up to two crew teams (design, data-science, security, platform) to review the pull.",
99 inputSchema: {
1010 type: "object",
1111 properties: {
src/http/mcp/tools/labels.ts +1 −1
@@ -7,7 +7,7 @@ export const tool: Tool = {
77 name: "labels",
88 doc: "`{ repo, number, labels }`: set an issue's or a pull's labels to exactly these (`[]` clears); only labels the repository has, by a writer",
99 description:
10 "Set an issue's or pull's labels to exactly these (none clears), from the repository's. Writers.",
10 "Set an issue's or pull's labels to exactly these (none clears). Writers.",
1111 inputSchema: {
1212 type: "object",
1313 properties: {
src/http/mcp/tools/limits.ts +1 −1
@@ -6,7 +6,7 @@ import { good, bad, type Tool } from "../shared.ts";
66 export const tool: Tool = {
77 name: "limits",
88 description:
9 "Every limit the forge's owner can hit, value and bounds. `key` and `value` (\"\" restores) plan.",
9 "Every limit the owner can hit, value and bounds. `key`, `value` (\"\" restores) plan.",
1010 inputSchema: {
1111 type: "object",
1212 properties: {
src/http/mcp/tools/make_it_yours.ts +1 −1
@@ -7,7 +7,7 @@ export const tool: Tool = {
77 name: "make_it_yours",
88 doc: "`{ 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",
99 description:
10 "Fork an app you can see: its code and history, following the original; nothing else is copied.",
10 "Fork an app you can see: code and history, following the original.",

Low: the cut dropped "nothing else is copied". That clause is the part that tells an agent data and secrets stay behind. doc still says it, but tools/list is what an agent reads. Worth putting back if a later trim frees the bytes. The same goes for archive's "deletes nothing".

1111 inputSchema: {
1212 type: "object",
1313 properties: {
src/http/mcp/tools/prompts.ts +1 −1
@@ -5,7 +5,7 @@ export const tool: Tool = {
55 name: "prompts",
66 doc: "`{ repo, action, kind?, token?, at?, id? }`: list, add, run or remove an app's prompt sources (Instagram), as the `…/prompts` API does; the token goes in once and is never shown; listed only for an admin-scope token, and only the repository's admin may use it",
77 description:
8 "An app's prompt sources (Instagram): list, add (`kind`, `token`, `at`), run or remove (`id`). Repository admin token.",
8 "An app's prompt sources (Instagram): list, add (`kind`, `token`, `at`), run or remove (`id`). Admin token.",
99 inputSchema: {
1010 type: "object",
1111 properties: {
src/http/mcp/tools/pull.ts +1 −1
@@ -12,7 +12,7 @@ export const tool: Tool = {
1212 name: "pull",
1313 doc: "`{ repo, number }`: a pull's title, state, head and base, and for an open pull whether it merges into the base's current head (`mergeable`) and the paths that stop it (`conflicts`), and `check: { state, baseOid }`: the latest check's state and the base tip it was merged with (empty when it merged nothing); `mergeable` and `conflicts` are the same fields as `GET …/pulls/:number`, `check` is `state` and `run.baseOid` from `GET …/pulls/:number/checks`; draws the `ui://forge/pull` card: `#N title`, state with check and conflicts while open, `head → base` linked to the pull, and the preview link when the pull has an environment",
1414 description:
15 "Read a pull: title, state, head, base; open: `mergeable`, `conflicts`, `proposal`, `checkStale`, `check`.",
15 "Read a pull: title, state, head, base; open: `mergeable`, `conflicts`, `proposal`, `check`.",
1616 inputSchema: {
1717 type: "object",
1818 properties: {
test/mcp-accounts.test.ts added +94
@@ -0,0 +1,94 @@
1 /** The `accounts` tool lists what GET /api/v1/admin/accounts lists, for an admin only. */
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 { createToken } from "../src/core/tokens.ts";
8 import { createUser } from "../src/core/users.ts";
9 import { serve, type Servers } from "../src/http/server.ts";
10
11 describe("MCP accounts", () => {
12 let dataDir: string;
13 let app: App;
14 let servers: Servers;
15 let adminToken: string;
16 let bobToken: string;
17 let id = 1;
18
19 const rpc = async (token: string, method: string, params: unknown) => {
20 const res = await fetch(`${servers.url}/mcp`, {
21 method: "POST",
22 headers: {
23 "content-type": "application/json",
24 accept: "application/json, text/event-stream",
25 authorization: `Bearer ${token}`,
26 },
27 body: JSON.stringify({ jsonrpc: "2.0", id: id++, method, params }),
28 });
29 return (await res.json()) as any;
30 };
31 const accounts = async (token: string, args: unknown = {}) => {
32 const body = await rpc(token, "tools/call", { name: "accounts", arguments: args });
33 if (body.error) return { refused: body.error.message as string };
34 const text: string = body.result.content[0].text;
35 return body.result.isError === true ? { error: text } : { value: JSON.parse(text) };
36 };
37
38 beforeAll(async () => {
39 dataDir = await mkdtemp(join(tmpdir(), "forge-mcp-accounts-"));
40 app = createApp({ dataDir, port: 0 });
41 servers = await serve(app);
42 const mk = async (username: string, adminIfFirst = false) => {
43 const u = await createUser(app.db, {
44 origin: "seed", trusted: true, username,
45 email: `${username}@forge.local`, password: "a-good-passphrase", adminIfFirst,
46 });
47 if (!u.ok) throw new Error(u.error);
48 return u.value;
49 };
50 const alice = await mk("alice", true);
51 const bob = await mk("bob");
52 await mk("carol");
53 app.db.query("UPDATE user SET suspended_at = ?1 WHERE username = 'carol'").run(new Date().toISOString());
54 adminToken = createToken(app.db, alice.id, "ro", "ro").token;
55 bobToken = createToken(app.db, bob.id, "ro", "ro").token;
56 }, 30_000);
57
58 afterAll(async () => {
59 servers.stop();
60 app.close();
61 await rm(dataDir, { recursive: true, force: true });
62 });
63
64 test("an admin lists three accounts and sees the suspended one marked", async () => {
65 const r = await accounts(adminToken);
66 const items = r.value.items;
67 expect(items.map((i: any) => i.username).sort()).toEqual(["alice", "bob", "carol"]);
68 const by = (n: string) => items.find((i: any) => i.username === n);
69 expect(by("alice").role).toBe("admin");
70 expect(by("carol").suspended).toBe(true);
71 expect(by("bob").suspended).toBe(false);
72 expect(typeof by("bob").verified).toBe("boolean");
73 expect(JSON.stringify(r.value)).not.toContain("@forge.local");
74 });
75
76 test("limit pages with a cursor", async () => {
77 const one = await accounts(adminToken, { limit: 2 });
78 expect(one.value.items).toHaveLength(2);
79 expect(one.value.next).not.toBeNull();
80 const two = await accounts(adminToken, { limit: 2, cursor: one.value.next });
81 expect(two.value.items).toHaveLength(1);
82 expect(two.value.next).toBeNull();
83 expect((await accounts(adminToken, { limit: 0 })).error).toBeDefined();
84 expect((await accounts(adminToken, { cursor: "x" })).error).toBeDefined();
85 });
86
87 test("a non-admin is not listed the tool and is refused it", async () => {
88 const list = await rpc(bobToken, "tools/list", {});
89 expect(list.result.tools.map((t: any) => t.name)).not.toContain("accounts");
90 const admin = await rpc(adminToken, "tools/list", {});
91 expect(admin.result.tools.map((t: any) => t.name)).toContain("accounts");
92 expect((await accounts(bobToken)).refused).toContain("no tool named accounts");
93 });
94 });
test/mcp.test.ts +1
@@ -208,6 +208,7 @@ describe("the forge over MCP", () => {
208208 "health",
209209 "audit",
210210 "model_grant",
211 "accounts",
211212 "whoami_can",
212213 "mint_invite",
213214 "invites",

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs