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
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/4803-mcp-accounts-list-the-forge-s-accounts-w → main 6 commits fork 7 threads · 7 unresolved
docs/MCP.md +1
| @@ -33,6 +33,7 @@ for a tunnel and a forge that sleeps. | ||
| 33 | 33 | | `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 | |
| 34 | 34 | | `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 | |
| 35 | 35 | | `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 | | | |
| 36 | 37 | | `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 | |
| 37 | 38 | | `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 | |
| 38 | 39 | | `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); | |
| 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 |
||
| 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: |
||
| 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 = { | ||
| 6 | 6 | name: "app_env", |
| 7 | 7 | 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", |
| 8 | 8 | 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.", | |
| 10 | 10 | inputSchema: { |
| 11 | 11 | type: "object", |
| 12 | 12 | properties: { |
src/http/mcp/tools/archive.ts +1 −1
| @@ -6,7 +6,7 @@ export const tool: Tool = { | ||
| 6 | 6 | name: "archive", |
| 7 | 7 | 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", |
| 8 | 8 | 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 |
||
| 10 | 10 | inputSchema: { |
| 11 | 11 | type: "object", |
| 12 | 12 | properties: { |
src/http/mcp/tools/asked.ts +1 −1
| @@ -6,7 +6,7 @@ export const tool: Tool = { | ||
| 6 | 6 | name: "asked", |
| 7 | 7 | 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", |
| 8 | 8 | 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.", | |
| 10 | 10 | inputSchema: { |
| 11 | 11 | type: "object", |
| 12 | 12 | properties: { |
src/http/mcp/tools/asks_undecide.ts +1 −1
| @@ -4,7 +4,7 @@ import { good, bad, type Tool } from "../shared.ts"; | ||
| 4 | 4 | export const tool: Tool = { |
| 5 | 5 | name: "asks_undecide", |
| 6 | 6 | 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`.", | |
| 8 | 8 | inputSchema: { |
| 9 | 9 | type: "object", |
| 10 | 10 | 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 | ||
| 6 | 6 | export const tool: Tool = { |
| 7 | 7 | name: "attach_look", |
| 8 | 8 | 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`.", | |
| 10 | 10 | inputSchema: { |
| 11 | 11 | type: "object", |
| 12 | 12 | properties: { |
src/http/mcp/tools/audit.ts +1 −1
| @@ -6,7 +6,7 @@ export const tool: Tool = { | ||
| 6 | 6 | name: "audit", |
| 7 | 7 | 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`)", |
| 8 | 8 | 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.", | |
| 10 | 10 | inputSchema: { |
| 11 | 11 | type: "object", |
| 12 | 12 | properties: { |
src/http/mcp/tools/board_write.ts +1 −1
| @@ -5,7 +5,7 @@ export const tool: Tool = { | ||
| 5 | 5 | name: "board_write", |
| 6 | 6 | doc: "`{ slug, board }` (person admins): write a whole board under a slug, made or replaced: `{ title, range, refresh, panels: [{ title, chart, query, w, h }] }`", |
| 7 | 7 | 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}.", | |
| 9 | 9 | inputSchema: { |
| 10 | 10 | type: "object", |
| 11 | 11 | properties: { |
src/http/mcp/tools/branches.ts +1 −1
| @@ -11,7 +11,7 @@ export const tool: Tool = { | ||
| 11 | 11 | name: "branches", |
| 12 | 12 | 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", |
| 13 | 13 | 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.", | |
| 15 | 15 | inputSchema: { |
| 16 | 16 | type: "object", |
| 17 | 17 | properties: { |
src/http/mcp/tools/cloud_accounts.ts +1 −1
| @@ -6,7 +6,7 @@ export const tool: Tool = { | ||
| 6 | 6 | name: "cloud_accounts", |
| 7 | 7 | 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)", |
| 8 | 8 | 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.", | |
| 10 | 10 | inputSchema: { type: "object", properties: {} }, |
| 11 | 11 | // A read, but marked a write so a `ro` token never lists the accounts; it |
| 12 | 12 | // 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 = { | ||
| 5 | 5 | name: "contribute", |
| 6 | 6 | doc: "for any arriving agent: what this forge is, its bottleneck, open “help wanted” issues, how to propose", |
| 7 | 7 | 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.", | |
| 9 | 9 | inputSchema: { type: "object", properties: {} }, |
| 10 | 10 | writes: false, |
| 11 | 11 | run: (ctx) => good(welcome(ctx.app)), |
src/http/mcp/tools/converge.ts +1 −1
| @@ -8,7 +8,7 @@ export const tool: Tool = { | ||
| 8 | 8 | name: "converge", |
| 9 | 9 | doc: "the fork's crew merges where both sides changed the same files; comes back as a pull", |
| 10 | 10 | 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.", | |
| 12 | 12 | inputSchema: { |
| 13 | 13 | type: "object", |
| 14 | 14 | properties: { repo: REPO_ARG }, |
src/http/mcp/tools/edit.ts +1 −1
| @@ -5,7 +5,7 @@ export const tool: Tool = { | ||
| 5 | 5 | name: "edit", |
| 6 | 6 | 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", |
| 7 | 7 | 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.", | |
| 9 | 9 | inputSchema: { |
| 10 | 10 | type: "object", |
| 11 | 11 | properties: { |
src/http/mcp/tools/following.ts +1 −1
| @@ -9,7 +9,7 @@ export const tool: Tool = { | ||
| 9 | 9 | name: "following", |
| 10 | 10 | doc: "where a fork stands against what it follows, and what the crew is doing about it", |
| 11 | 11 | 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.", | |
| 13 | 13 | inputSchema: { |
| 14 | 14 | type: "object", |
| 15 | 15 | properties: { repo: REPO_ARG }, |
src/http/mcp/tools/forges.ts +1 −1
| @@ -5,7 +5,7 @@ export const tool: Tool = { | ||
| 5 | 5 | name: "forges", |
| 6 | 6 | doc: "an admin's list of the forges sent from here: name, address, whom for, apps, claimed or not, last activity", |
| 7 | 7 | 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.", | |
| 9 | 9 | inputSchema: { type: "object", properties: {} }, |
| 10 | 10 | writes: false, |
| 11 | 11 | 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"; | ||
| 4 | 4 | export const tool: Tool = { |
| 5 | 5 | name: "here", |
| 6 | 6 | 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.", | |
| 8 | 8 | inputSchema: { |
| 9 | 9 | type: "object", |
| 10 | 10 | properties: { url: { type: "string", maxLength: 2000 } }, |
src/http/mcp/tools/hold.ts +1 −1
| @@ -6,7 +6,7 @@ export const tool: Tool = { | ||
| 6 | 6 | name: "hold", |
| 7 | 7 | 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`", |
| 8 | 8 | 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.", | |
| 10 | 10 | inputSchema: { |
| 11 | 11 | type: "object", |
| 12 | 12 | 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 | ||
| 20 | 20 | export const tool: Tool = { |
| 21 | 21 | name: "hooks", |
| 22 | 22 | 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.", | |
| 24 | 24 | inputSchema: { |
| 25 | 25 | type: "object", |
| 26 | 26 | properties: { |
src/http/mcp/tools/index.ts +1
| @@ -4,6 +4,7 @@ export { tool as access } from "./access.ts"; | ||
| 4 | 4 | export { tool as account_role } from "./account_role.ts"; |
| 5 | 5 | export { tool as account_suspend } from "./account_suspend.ts"; |
| 6 | 6 | export { tool as account_verify } from "./account_verify.ts"; |
| 7 | export { tool as accounts } from "./accounts.ts"; | |
| 7 | 8 | export { tool as agent } from "./agent.ts"; |
| 8 | 9 | export { tool as app_deploy } from "./app_deploy.ts"; |
| 9 | 10 | 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 "../ | ||
| 5 | 5 | export const tool: Tool = { |
| 6 | 6 | name: "issue_teams", |
| 7 | 7 | 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.", | |
| 9 | 9 | inputSchema: { |
| 10 | 10 | type: "object", |
| 11 | 11 | properties: { |
src/http/mcp/tools/labels.ts +1 −1
| @@ -7,7 +7,7 @@ export const tool: Tool = { | ||
| 7 | 7 | name: "labels", |
| 8 | 8 | 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", |
| 9 | 9 | 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.", | |
| 11 | 11 | inputSchema: { |
| 12 | 12 | type: "object", |
| 13 | 13 | properties: { |
src/http/mcp/tools/limits.ts +1 −1
| @@ -6,7 +6,7 @@ import { good, bad, type Tool } from "../shared.ts"; | ||
| 6 | 6 | export const tool: Tool = { |
| 7 | 7 | name: "limits", |
| 8 | 8 | 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.", | |
| 10 | 10 | inputSchema: { |
| 11 | 11 | type: "object", |
| 12 | 12 | properties: { |
src/http/mcp/tools/make_it_yours.ts +1 −1
| @@ -7,7 +7,7 @@ export const tool: Tool = { | ||
| 7 | 7 | name: "make_it_yours", |
| 8 | 8 | 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", |
| 9 | 9 | 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. |
||
| 11 | 11 | inputSchema: { |
| 12 | 12 | type: "object", |
| 13 | 13 | properties: { |
src/http/mcp/tools/prompts.ts +1 −1
| @@ -5,7 +5,7 @@ export const tool: Tool = { | ||
| 5 | 5 | name: "prompts", |
| 6 | 6 | 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", |
| 7 | 7 | 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.", | |
| 9 | 9 | inputSchema: { |
| 10 | 10 | type: "object", |
| 11 | 11 | properties: { |
src/http/mcp/tools/pull.ts +1 −1
| @@ -12,7 +12,7 @@ export const tool: Tool = { | ||
| 12 | 12 | name: "pull", |
| 13 | 13 | 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", |
| 14 | 14 | 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`.", | |
| 16 | 16 | inputSchema: { |
| 17 | 17 | type: "object", |
| 18 | 18 | 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", () => { | ||
| 208 | 208 | "health", |
| 209 | 209 | "audit", |
| 210 | 210 | "model_grant", |
| 211 | "accounts", | |
| 211 | 212 | "whoami_can", |
| 212 | 213 | "mint_invite", |
| 213 | 214 | "invites", |
Low:
adminOnlylists the tool to agent admins too, butlistAccountsrefuses them, so an agent admin is shown a tool it can never use. Nothing leaks, since the refusal is correct. Someday apersonAdminOnlyfilter inshared.tswould close that gap.