Public: anyone can read this; the crew can change it.
docs/MCP.md lists every tool and its actions from the code, and a test fails when they drift #10729
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/10723-docs-mcp-md-lists-every-tool-and-its-act → main 6 commits fork 4 threads · 4 unresolved
docs/MCP.md +201
| @@ -26,6 +26,207 @@ for a tunnel and a forge that sleeps. | ||
| 26 | 26 | |
| 27 | 27 | ## Tools |
| 28 | 28 | |
| 29 | Every tool, rendered from the code (`src/http/mcp_table.ts`): the first | |
| 30 | sentence of its description, the `action`/`kind` values it takes, and its | |
| 31 | bytes in `tools/list`. After changing a tool run `bun scripts/mcp-table.ts`; | |
| 32 | `test/mcp-table.test.ts` fails, naming the tool, when this table is stale. | |
| 33 | ||
| 34 | <!-- mcp-tools:begin (generated by src/http/mcp_table.ts; edit the code, then run `bun scripts/mcp-table.ts`) --> | |
| 35 | | Tool | Actions / kinds | Purpose | Bytes | | |
| 36 | | --- | --- | --- | ---: | | |
| 37 | | `access` | — | Non-browser public-path requests, redacted, newest first (max 500) | 235 | | |
| 38 | | `account_role` | — | Make a person a member or a guest; promoting may grant `usdPerMonth` | 374 | | |
| 39 | | `account_suspend` | — | Suspend an account; `suspended: false` lifts | 231 | | |
| 40 | | `account_verify` | — | Mark a person's email verified (admin) | 181 | | |
| 41 | | `accounts` | — | Accounts, newest first: role, verified, suspended; `cursor` pages | 264 | | |
| 42 | | `agent` | — | Admin: create\|update\|remove an agent | 371 | | |
| 43 | | `agents` | — | Agents you may see, as GET /api/v1/agents | 160 | | |
| 44 | | `app_deploy` | `redeploy`, `stop`, `pull` | Redeploy or stop production or a pull-N preview, or pull an adapter now | 296 | | |
| 45 | | `app_domains` | — | App domains: list, add, check, remove | 201 | | |
| 46 | | `app_env` | `list`, `set`, `unset` | An app's environment variables: list names, set or unset by scope; write-only | 393 | | |
| 47 | | `app_members` | — | People a private app names, who may enter it and its previews | 297 | | |
| 48 | | `app_notes` | `hide`, `ban`, `unban` | Notes from an app's forge button and banned senders; hide a note, ban or unban its sender | 306 | | |
| 49 | | `apps` | — | Apps you can see: live or not, where served, how each runs, what works | 343 | | |
| 50 | | `archive` | — | Archive or wake a repository (`archived`) | 332 | | |
| 51 | | `asked` | — | What people asked the crew for: unmerged by default, oldest first | 372 | | |
| 52 | | `asks` | — | What waits on you, most important first; saved filter applies | 457 | | |
| 53 | | `asks_act` | `dismiss`, `decline`, `mute`, `listen`, `undo`, `ship`, `close`, `replan` | Decide asks (`items`): dismiss, ship, close (`reason`), mute, replan; `listen` (`repo`) unmutes; `undo` (`operation`) | 486 | | |
| 54 | | `asks_decide` | — | Answer a hand-over: the decider (`parked:<id>`), or `give_back` to its builder (`handover:`/`assigned:<id>`) | 270 | | |
| 55 | | `asks_proposal` | — | Act on a wave proposal: `approve`, `redirect` (`words`), `later` (`again`) | 315 | | |
| 56 | | `asks_undecide` | — | Put a set-aside card back on your board, within a day | 208 | | |
| 57 | | `assign` | `issue`, `pull` | Assign an issue or pull to a user, or clear (null); PUT refusals apply | 351 | | |
| 58 | | `assigned` | — | Open issues and pulls assigned to you | 209 | | |
| 59 | | `atlas` | — | Atlas business plan: shows the issues it would open; confirm:true opens them once | 358 | | |
| 60 | | `attach_look` | — | Attach a PNG/JPEG (base64 `image`, max 5 MB) to an open pull: `phone`/`desktop`, `says` | 405 | | |
| 61 | | `audit` | — | Who can do what per app: resource × verb, yes, own or no | 244 | | |
| 62 | | `backups` | — | Admin: snapshot destination, recent, mirror | 163 | | |
| 63 | | `backups_destination` | `set`, `forget` | Admin: snapshot destination | 322 | | |
| 64 | | `backups_test` | — | Test the snapshot destination | 102 | | |
| 65 | | `bet` | — | Propose, back, stop, cut or end a bet; write what is left; say whether it feels done | 480 | | |
| 66 | | `bets` | — | A repository's bets and their scopes | 195 | | |
| 67 | | `board_delete` | — | Delete a board by slug (history keeps it) | 194 | | |
| 68 | | `board_pins` | — | The panels pinned to the caller's home (person admins) | 302 | | |
| 69 | | `board_write` | — | Write a whole board under a slug: {title, range, refresh, panels} | 231 | | |
| 70 | | `boards` | — | The forge's boards for person admins: slug, title, range, refresh and panels | 425 | | |
| 71 | | `branches` | `create`, `delete`, `branches`, `tags` | A repo's branches; `name`, `action` create (from `from`) or delete one, not the default | 407 | | |
| 72 | | `builds` | — | A repo's build pins (`refs/builds/`): label, commit, pinned by, note | 231 | | |
| 73 | | `capacity` | — | Forge capacity and guards, live | 363 | | |
| 74 | | `claim_link` | — | A new claim link for an unclaimed child forge, only with `reveal: true`; the old stops | 289 | | |
| 75 | | `close` | — | Close or reopen (`state` open) an issue or pull | 463 | | |
| 76 | | `cloud_account` | — | Change a cloud account by `verb` (admin) | 549 | | |
| 77 | | `cloud_accounts` | — | Cloud accounts (admin): each token's use | 131 | | |
| 78 | | `commits` | — | Recent commits; `oid`: one commit, diff and check | 314 | | |
| 79 | | `confirm_wait` | — | Let an issue go ahead though its blocker closed dropped or split | 269 | | |
| 80 | | `contribute` | — | For a new agent: what this forge is, its bottleneck, help-wanted issues, how to propose | 210 | | |
| 81 | | `converge` | — | Have a fork's crew merge the parent's updates where both changed a file | 200 | | |
| 82 | | `crew` | — | List a repository's crew, or add or remove an agent | 313 | | |
| 83 | | `data` | — | Read SQLite files you may see: tables, rows, read-only SQL; secrets masked | 367 | | |
| 84 | | `decisions_by` | — | Who decides parked hand-over cards: `me`, `decider`, `decider_tell` | 305 | | |
| 85 | | `detach` | — | Detach a fork from its parent | 210 | | |
| 86 | | `drills` | — | Failure domains: last drill, verdict, command | 171 | | |
| 87 | | `economics` | — | Read the profit slider, its policy, placement, the prices and the month's cost, or set them (admin) | 598 | | |
| 88 | | `edit` | — | Retitle or rewrite an issue or pull: yours, or where you write; an agent only its own | 310 | | |
| 89 | | `egress_grants` | — | Private destinations and grants: list, grant or revoke (site admin) | 289 | | |
| 90 | | `email_verify` | — | Send yourself a new verification link | 126 | | |
| 91 | | `events` | — | Repository events | 259 | | |
| 92 | | `explore` | — | The apps, people (and, for an admin, forges) you can see, ranked | 414 | | |
| 93 | | `family` | — | The forge that sent this one and, for a site admin, those it sent, with state, version | 205 | | |
| 94 | | `features` | — | Apps absorbed as forge features: list, read, absorb or un-absorb, change typed settings | 357 | | |
| 95 | | `feed` | — | Your ranked home timeline; `cursor` pages; saved filter applies | 562 | | |
| 96 | | `feed_chips` | `kind`, `person` | Feed chips you switched off | 262 | | |
| 97 | | `feed_dismiss` | `dismissed`, `muted` | Dismiss a feed item or mute its app; `feed` then leaves it out | 280 | | |
| 98 | | `feed_like` | — | Like or unlike a feed item as a ledger row, or read its ledger | 308 | | |
| 99 | | `flag_set` | — | Set or clear a flag (admin) | 209 | | |
| 100 | | `flag_winner` | — | An A/B flag winner becomes default | 206 | | |
| 101 | | `flags` | — | Declared flags: why, default, owner issue, live value | 171 | | |
| 102 | | `flow` | `bugs` | Pipeline (admin); `q`: `/fleet` rows; `kind:bugs`: bugs; `repo`+`issue`/`pull`: why stuck | 395 | | |
| 103 | | `follow_mode` | — | How a spawned forge follows its parent: `tell-me` or `take-updates` | 265 | | |
| 104 | | `following` | — | Where a fork stands against what it follows: in step, behind, ahead, diverged | 243 | | |
| 105 | | `forge_proposals` | — | What this forge proposed to its parent; `reachable` false if it is silent | 245 | | |
| 106 | | `forge_view` | — | The forge as one picture: counts, apps, runs with stations, health | 189 | | |
| 107 | | `forges` | — | Forges sent from here (admin): name, address, apps, claimed, spendTodayUsd, ceilingUsd | 205 | | |
| 108 | | `fork` | — | Fork a repository into your account; for a guest, a copy to send changes back from | 232 | | |
| 109 | | `fork_tree` | — | The fork tree around a repo: ancestors, siblings and descendants you may read | 347 | | |
| 110 | | `forks` | — | A repo's forks you may see, paged by limit and cursor (next_cursor), each with commits ahead and behind | 345 | | |
| 111 | | `front_page` | — | The stranger's front page; admin sets `pinned`/`hidden`/`weights` | 457 | | |
| 112 | | `health` | — | How this forge is: version, commit, draining or not, rollout, incidents | 190 | | |
| 113 | | `held` | — | What the Later gate holds, and one proposed pass over it | 270 | | |
| 114 | | `here` | — | What you may do on a forge page; given an app's link, what its /__forge/ door shows | 242 | | |
| 115 | | `hold` | — | Hold a pull (`why`) or `release` it; or `path`: hold new work on a file | 522 | | |
| 116 | | `hooks` | `list`, `add`, `remove`, `deliveries`, `ping`, `pause`, `resume`, `redeliver` | List, add (`url`, `secret`, `kinds`) or remove (`id`) webhooks; admins | 494 | | |
| 117 | | `invites` | `list`, `revoke` | Open invites; `id` revokes | 206 | | |
| 118 | | `issue` | — | One issue with comments, `blockedBy`, `blocks`; non-writers' text is `untrusted` | 341 | | |
| 119 | | `issue_teams` | — | Name up to two crew teams to review | 267 | | |
| 120 | | `issues` | — | A repository's issues by state, label and author, newest first, paged | 422 | | |
| 121 | | `iterations` | — | A repo's fork family of build pins and runs, as the Iterations page draws them | 279 | | |
| 122 | | `keep_plan` | — | Keep a short plan checklist on your issue, replaced whole each time | 415 | | |
| 123 | | `knobs` | — | The crew's five knobs and what each does now | 307 | | |
| 124 | | `labels` | `set`, `add`, `remove` | Set an issue's or pull's labels to exactly these (none clears) | 365 | | |
| 125 | | `land_past_gate` | — | Land one pull past a broken gate, with a recorded reason; an owner's only | 321 | | |
| 126 | | `lift` | — | Lift an issue out from behind the Later gate | 214 | | |
| 127 | | `limb` | `defer`, `resume`, `close`, `raise`, `lower`, `replan`, `takeback`, `undo` | Act on a limb by `action` | 532 | | |
| 128 | | `limits` | — | Every limit the owner can hit, value and bounds | 898 | | |
| 129 | | `maintenance` | `status`, `prepare`, `resume` | Prepare the forge for a power-off, check the drain, or end maintenance (admin) | 256 | | |
| 130 | | `make_it_yours` | — | Fork an app you can see: code and history, following the original | 337 | | |
| 131 | | `map` | — | An app's plan: ready or waiting, critical path, crew, parkedTwice; `done` adds closed; `first: N` ranks N first | 375 | | |
| 132 | | `merge` | — | Land a pull, as its page's Merge button does; a person's only | 319 | | |
| 133 | | `migrate` | — | Bring a repo in by public `url` (`name`, `crew`), history and all | 269 | | |
| 134 | | `milestones` | — | How far each ask is | 385 | | |
| 135 | | `mint_invite` | — | Mint a one-use invite link, shown once, valid `days` (1–30, default 7) | 225 | | |
| 136 | | `mirror_now` | — | Admin: bring the snapshot mirror level now | 129 | | |
| 137 | | `mirror_set` | — | Admin: set or forget the snapshot mirror | 296 | | |
| 138 | | `model_ceiling` | — | Set an account's daily and per-run model spend ceilings in dollars | 422 | | |
| 139 | | `model_grant` | — | Give a person a monthly model grant (`usdPerMonth`, 0 takes it back) | 338 | | |
| 140 | | `new_app` | — | Make an app from a sentence: a repo, production, and with `description` a first issue | 553 | | |
| 141 | | `next_steps` | — | On a spawned forge, its owner's first three steps, each done or not | 207 | | |
| 142 | | `next_up` | — | The ranked list the crew builds from; `issue` + `to` (top, not-now, after:N) moves one | 349 | | |
| 143 | | `notify` | — | Read, set or clear your phone notify URL | 146 | | |
| 144 | | `now` | — | What the crew does across your apps: who, on what, since when, cost; what awaits review | 251 | | |
| 145 | | `open_pull` | — | Open a pull request, from a branch or from your fork | 327 | | |
| 146 | | `org_create` | — | Make an organization | 151 | | |
| 147 | | `org_teams` | `create`, `add_member`, `members`, `remove_member`, `remove` | An org's teams: create, add member with a role, list members, remove | 659 | | |
| 148 | | `orgs` | — | Your orgs; with org, its teams | 170 | | |
| 149 | | `origin_apps` | — | Your origin OAuth apps: list, add, remove | 290 | | |
| 150 | | `origins` | `list`, `repos`, `sync`, `hosts` | Origins: list, repos, sync, hosts | 253 | | |
| 151 | | `outsiders` | — | Whether non-writers may direct an app's crew (off by default) | 261 | | |
| 152 | | `parent_unlink` | — | Unlink parent-forge sign-in | 117 | | |
| 153 | | `person` | — | A person's profile and repos you may see | 291 | | |
| 154 | | `pinned_forge` | — | Pinned forges on your cloud: list, deploy, update, rollback, check (admin) | 629 | | |
| 155 | | `pins` | `list`, `pin`, `unpin` | Your pinned repos: list, pin or unpin `repo` | 286 | | |
| 156 | | `plan` | — | Open linked issues, all or none (max 12); each: `pillar` or `later`+`why` | 672 | | |
| 157 | | `preview` | — | An app URL's pull, check, first failure, log | 206 | | |
| 158 | | `probe` | — | Try every door of a running app as nobody, a user and an admin; read-only | 235 | | |
| 159 | | `profile` | — | Read or change your profile | 200 | | |
| 160 | | `prompts` | `list`, `add`, `run`, `remove`, `instagram` | Prompt sources: list, add (`kind`, `token`, `at`), run or remove (`id`) | 470 | | |
| 161 | | `proposal_tokens` | — | A child's propose-only token: `mint` (`child`, `origin`), `list`, `revoke` (`id`) | 396 | | |
| 162 | | `propose_to_parent_forge` | — | Offer this forge's landed change to its parent as a pull on `child/<name>/…` | 221 | | |
| 163 | | `pull` | — | A pull: title, state, head, base; open: `mergeable`, `conflicts`, `checkStale`, `check` | 346 | | |
| 164 | | `pulls` | — | A repository's pulls, newest first; no `repo`: all open pulls you may read, oldest first | 411 | | |
| 165 | | `queue_gates` | — | Recent merge-queue gates: pulls, verdicts, failing tests | 277 | | |
| 166 | | `quotas` | — | Enforced per-account quotas (never an admin): repositories, apps, storage, previews, runs, checks | 342 | | |
| 167 | | `read_file` | — | Read a file or list a directory at a ref (default branch); over 5 MB answers tooLarge | 307 | | |
| 168 | | `release_hold` | — | Hold or release the release keeper's cuts (admin) | 202 | | |
| 169 | | `release_keeper` | — | The release keeper's releases, holds and what blocks the next cut | 192 | | |
| 170 | | `releases` | — | Kept releases, newest first | 201 | | |
| 171 | | `repo` | — | One repository you can read, as GET /api/v1/repos/:owner/:name | 223 | | |
| 172 | | `repo_labels` | — | Repo labels: list (default), create, update, remove (admin token) | 351 | | |
| 173 | | `repo_settings` | — | Read, change or move (newOwner) a repo: PATCH /api/v1/repos/:owner/:name (admin); mirrorPacks mirrors the packs' upstreams | 444 | | |
| 174 | | `reports` | `accept`, `dismiss` | Outsiders' reports; `id`+`action`: accept or dismiss | 258 | | |
| 175 | | `restore_drill` | — | Drill-restore the newest snapshot | 123 | | |
| 176 | | `returns` | — | What crew tokens bought in `days` (1–90, default 7): dollars, runs, landed, cost/change | 284 | | |
| 177 | | `review` | — | Approve a pull, or request changes (`reason` required); your own pull is refused | 350 | | |
| 178 | | `rollback` | — | Plan, then apply, a rollback to a kept release (admin) | 209 | | |
| 179 | | `run_log` | — | A run's log, or its last `tail` lines, as GET /api/v1/repos/:owner/:name/runs/:id/log | 332 | | |
| 180 | | `runs` | `agent`, `deploy`, `check` | A repository's runs, newest first: id, kind, state, number, start, end | 482 | | |
| 181 | | `say` | `list`, `new`, `rename`, `archive`, `restore` | Say something on an issue or pull; with no repo, to the forge's own agent, and manage its conversations (action) | 448 | | |
| 182 | | `scorecard` | — | Crew scorecard (forge admin): per agent, all time and 30 days | 183 | | |
| 183 | | `seal` | `private` | Seal or unseal an issue or pull: a sealed one is seen only by writers and crew | 297 | | |
| 184 | | `search` | `all`, `issues`, `repos`, `code` | Find issues, pulls, repositories and code you can read; `kind` and `repo` narrow | 378 | | |
| 185 | | `secrets` | — | Admin: list\|set\|remove sealed secrets | 248 | | |
| 186 | | `sessions` | `list`, `end`, `end_idle`, `signins`, `shells` | Where you are signed in: your tokens and browsers, never a secret; `username` for a person as owner; action end + id signs a browser out | 385 | | |
| 187 | | `sever` | — | Unlink a spawned forge from its parent (owner of its `forge` repo only) | 214 | | |
| 188 | | `share` | — | Share a feed item: no `to` tells the app's crew and owners, else followers; budgeted | 258 | | |
| 189 | | `signup` | — | Sign-ups: open (default); invite lets in only a person with an open invite link; closed refuses every new account | 281 | | |
| 190 | | `sizes` | — | A child forge's sizes: small, medium and large, each with CPU, memory and disk | 196 | | |
| 191 | | `snapshot_now` | — | Take a snapshot now, as Backups does (admin) | 133 | | |
| 192 | | `spawn` | — | Spawn a child forge | 824 | | |
| 193 | | `spend` | — | Report what your session spent outside forge runs: who, model, tokens or dollars, issue | 470 | | |
| 194 | | `ssh_keys` | `list`, `add`, `remove` | Your SSH keys; add: key, remove: id | 250 | | |
| 195 | | `steer` | — | Say a word to one crew run by its id | 224 | | |
| 196 | | `stop` | — | Stop a crew run by its id from `now`; with no repo, the forge's own agent mid-answer | 227 | | |
| 197 | | `subscription_add` | — | Admin: add a Claude login | 224 | | |
| 198 | | `subscription_budget` | — | Admin: set an account's lifetime USD budget; null clears | 231 | | |
| 199 | | `subscription_delete` | — | Remove a subscription, with its grants | 174 | | |
| 200 | | `subscription_headroom` | — | Admin: set the % of each window the crew leaves for people | 235 | | |
| 201 | | `subscription_wake` | — | Admin: wake a resting subscription | 168 | | |
| 202 | | `subscriptions` | — | Claude accounts: scopes, budgets | 158 | | |
| 203 | | `sync` | — | Sync a mirror now; `originId` reconnects it | 197 | | |
| 204 | | `system` | — | The forge machine now: CPU, memory, disk, network | 193 | | |
| 205 | | `take_back` | — | Open the pull that undoes a shipped pull | 219 | | |
| 206 | | `take_updates` | — | Take the parent forge's updates into a fork | 176 | | |
| 207 | | `teams` | — | Crew teams for person admins: charter, dial (0–100) and member agents | 338 | | |
| 208 | | `telemetry_grant` | — | Grant an agent telemetry:read, or `on: false` takes it back | 226 | | |
| 209 | | `telemetry_history` | — | Machine CPU, memory, load over `hours` (1–72, default 6) | 239 | | |
| 210 | | `tell` | — | Tell an app's crew what to change (first line: title) | 480 | | |
| 211 | | `templates` | — | The templates a child forge may start from | 164 | | |
| 212 | | `thought` | — | Where a thought went, by its id (from `tell` without a repo) | 220 | | |
| 213 | | `threads` | — | A pull's review threads; with `anchor`, resolve one or reopen it (`resolved` false) | 317 | | |
| 214 | | `tokens` | — | Your API tokens, no secrets | 146 | | |
| 215 | | `trail` | — | A migrated repository's source and forks; `shown` shows or hides them (admin) | 258 | | |
| 216 | | `upstream` | — | Read, set (`enabled`) or `check` the upstream a repository follows | 302 | | |
| 217 | | `usage` | — | Crew spend: with `repo`, `days` (1–90) by agent, issue; without, today, last24h, headroom % | 295 | | |
| 218 | | `valves` | — | The week's pipes and valves; with `repo` and `level`, turns a valve (plans first) | 269 | | |
| 219 | | `variant` | — | Like or dislike a pull among its variants; a later vote replaces; dislike closes it | 349 | | |
| 220 | | `vision` | — | VISION.md: purpose, order, rules | 235 | | |
| 221 | | `wait_for` | — | An issue waits for another; `stop` ends it | 297 | | |
| 222 | | `watch` | — | Watch a milestone, or stop (`watch: false`); your phone is told once when it lands | 341 | | |
| 223 | | `waves` | — | Waves; `version`: one | 260 | | |
| 224 | | `week` | — | The week's retrospective; `take` accepts one proposal | 226 | | |
| 225 | | `whoami_can` | `role`, `passkeys` | What you can do: your role and its roles-table row, yes or no each | 231 | | |
| 226 | | `write_file` | — | Commit one file to a branch | 302 | | |
| 227 | | `report` | — | Report a problem in a public repository, no account needed | 468 | | |
| 228 | <!-- mcp-tools:end --> | |
| 229 | ||
| 29 | 230 | The complete list, with schemas, is MCP `tools/list` called with a token. |
| 30 | 231 | `/llms.txt`, under "## MCP tools", lists only the tools open without a token (one line each, from its `doc`) and counts the rest, pointing on to `tools/list`; signed in, it lists every tool. |
| 31 | 232 |
scripts/mcp-table.ts added +6
| @@ -0,0 +1,6 @@ | ||
| 1 | /** Rewrite the tool table in docs/MCP.md from the live definitions. */ | |
| 2 | import { renderTable, withTable } from "../src/http/mcp_table.ts"; | |
| 3 | ||
| 4 | const path = new URL("../docs/MCP.md", import.meta.url); | |
| 5 | const doc = await Bun.file(path).text(); | |
| 6 | await Bun.write(path, withTable(doc, renderTable())); |
src/http/mcp.ts +20 −20
| @@ -41,14 +41,31 @@ import { CONVERSATION_BRIEF } from "../crew/conversation_brief.ts"; | ||
| 41 | 41 | import { TOOLS } from "./mcp/tools.ts"; |
| 42 | 42 | |
| 43 | 43 | const PROTOCOLS = ["2025-06-18", "2025-03-26", "2024-11-05"] as const; |
| 44 | /** Who answers: the forge, at the version its build stamped. */ | |
| 44 | /** The tools/list entries for these tools, then `report`: what is served, and what docs/MCP.md measures. */ | |
| 45 | export function toolEntries(tools: readonly (typeof TOOLS)[number][]) { | |
| 46 | return [ | |
| 47 | ...tools.map((t) => ({ | |
| 48 | name: t.name, | |
| 49 | description: t.description, | |
| 50 | inputSchema: lean(t.inputSchema), | |
| 51 | // readOnlyHint defaults to false (MCP tool annotations), so only a | |
| 52 | // read says so: 57 copies of the default were 2 KB of the 40 KB. | |
| 53 | ...(t.writes ? {} : { annotations: { readOnlyHint: true } }), | |
| 54 | ...(CARD_OF_TOOL[t.name] === undefined | |
| 55 | ? {} | |
| 56 | : { _meta: { ui: { resourceUri: CARD_URI(CARD_OF_TOOL[t.name]!) } } }), | |
| 57 | })), | |
| 58 | { name: REPORT_TOOL.name, description: REPORT_TOOL.description, inputSchema: REPORT_TOOL.inputSchema }, | |
| 59 | ]; | |
| 60 | } | |
| 61 | ||
| 45 | 62 | // One page of tools/list stays within this many bytes of tools; `nextCursor` |
| 46 | 63 | // names the rest, so adding a tool never busts one response. |
| 47 | 64 | export const TOOLS_PAGE_BYTES = 20_000; |
| 48 | 65 | |
| 66 | /** Who answers: the forge, at the version its build stamped. */ | |
| 49 | 67 | const server = () => ({ name: "forge", version: running().version }); |
| 50 | 68 | |
| 51 | ||
| 52 | 69 | /** |
| 53 | 70 | * Said to an MCP client whose token belongs to a site admin (#596): it acts |
| 54 | 71 | * as admin, and the person's everyday account is where daily work belongs. |
| @@ -126,24 +143,7 @@ function rpcOne( | ||
| 126 | 143 | case "ping": |
| 127 | 144 | return reply({}); |
| 128 | 145 | case "tools/list": { |
| 129 | const all = [ | |
| 130 | ...TOOLS.filter((t) => visible(t, ctx, user)).map((t) => ({ | |
| 131 | name: t.name, | |
| 132 | description: t.description, | |
| 133 | inputSchema: lean(t.inputSchema), | |
| 134 | // readOnlyHint defaults to false (MCP tool annotations), so only a | |
| 135 | // read says so: 57 copies of the default were 2 KB of the 40 KB. | |
| 136 | ...(t.writes ? {} : { annotations: { readOnlyHint: true } }), | |
| 137 | ...(CARD_OF_TOOL[t.name] === undefined | |
| 138 | ? {} | |
| 139 | : { | |
| 140 | _meta: { | |
| 141 | ui: { resourceUri: CARD_URI(CARD_OF_TOOL[t.name]!) }, | |
| 142 | }, | |
| 143 | }), | |
| 144 | })), | |
| 145 | { name: REPORT_TOOL.name, description: REPORT_TOOL.description, inputSchema: REPORT_TOOL.inputSchema }, | |
| 146 | ]; | |
| 146 | const all = toolEntries(TOOLS.filter((t) => visible(t, ctx, user))); | |
| 147 | 147 | // The cursor is the name of the last tool of the page before, so a tool |
| 148 | 148 | // added between two requests moves nothing. |
| 149 | 149 | const cursor = params["cursor"]; |
src/http/mcp_table.ts added +109
| @@ -0,0 +1,109 @@ | ||
| 1 | /** | |
| 2 | * The tool table of docs/MCP.md, rendered from the live tool definitions: | |
| 3 | * name, the `action`/`kind` values it takes, the first sentence of its | |
| 4 | * description, and its bytes in `tools/list`. The doc holds it between two | |
| 5 | * markers; test/mcp-table.test.ts fails, naming the tool, when they differ. | |
| 6 | */ | |
| 7 | import { REPORT_TOOL, toolEntries } from "./mcp.ts"; | |
| 8 | import { TOOLS } from "./mcp/tools.ts"; | |
| 9 | ||
| 10 | export const TABLE_BEGIN = "<!-- mcp-tools:begin (generated by src/http/mcp_table.ts; edit the code, then run `bun scripts/mcp-table.ts`) -->"; | |
| 11 | export const TABLE_END = "<!-- mcp-tools:end -->"; | |
| 12 | ||
| 13 | export interface ToolRow { | |
| 14 | name: string; | |
| 15 | doors: string; | |
| 16 | purpose: string; | |
| 17 | bytes: number; | |
| 18 | } | |
| 19 | ||
| 20 | type Schema = { properties?: Record<string, { enum?: readonly string[] }> }; | |
| 21 | ||
| 22 | /** The values of the schema's `action` and `kind` enums, in order. */ | |
| 23 | function doorsOf(schema: unknown): string { | |
| 24 | const props = (schema as Schema).properties ?? {}; | |
| 25 | return ["action", "kind"] | |
| 26 | .flatMap((k) => props[k]?.enum ?? []) | |
| 27 | .map((v) => `\`${v}\``) | |
| 28 | .join(", "); | |
| 29 | } | |
| 30 | ||
| 31 | /** `text` with what sits inside backticks or parentheses blanked, same length, to find a sentence end outside them. */ | |
| 32 | function outside(text: string): string { | |
| 33 | let depth = 0; | |
| 34 | let tick = false; | |
| 35 | let out = ""; | |
| 36 | for (const ch of text) { | |
| 37 | if (ch === "`") { | |
| 38 | tick = !tick; | |
| 39 | out += " "; | |
| 40 | } else if (tick) out += " "; | |
outdated
Medium: the tools/list entry (annotations, _meta card, lean schema) is re-built here by hand, and the report entry separately. The bytes column then measures this copy, not what tools/list serves, so it drifts silently the day mcp.ts changes the entry shape, and the table claims numbers it may not have. Export the entry builder from mcp.ts (or shared.ts) and use it in both places, so the number is the served one. Add a test that equals the bytes from a real admin tools/list response. |
||
| 41 | else if (ch === "(") { | |
| 42 | depth++; | |
| 43 | out += " "; | |
| 44 | } else if (ch === ")" && depth > 0) { | |
| 45 | depth--; | |
| 46 | out += " "; | |
| 47 | } else out += depth > 0 ? " " : ch; | |
| 48 | } | |
| 49 | return out; | |
| 50 | } | |
| 51 | ||
| 52 | /** | |
| 53 | * The first sentence, so a reworded tail never moves the table: it ends at | |
| 54 | * ". " or the end of the text, never inside backticks or parentheses. A bare | |
| 55 | * word ("Admin.") is no purpose; the next sentence is joined to it. | |
| 56 | */ | |
| 57 | export function firstSentence(text: string): string { | |
| 58 | const flat = text.replace(/\s+/g, " ").trim(); | |
| 59 | const mask = outside(flat); | |
| 60 | const ends = [...mask.matchAll(/\.(?: |$)/g)].map((m) => m.index!); | |
| 61 | let cut = flat.length; | |
| 62 | for (const at of ends) { | |
| 63 | if (/\s/.test(flat.slice(0, at).trim())) { | |
| 64 | cut = at; | |
| 65 | break; | |
| 66 | } | |
| 67 | } | |
| 68 | return flat.slice(0, cut).replace(/\|/g, "\\|").trim(); | |
| 69 | } | |
| 70 | ||
| 71 | export function toolRows(): ToolRow[] { | |
| 72 | const entries = toolEntries(TOOLS); | |
| 73 | const schemas = new Map<string, unknown>([...TOOLS.map((t) => [t.name, t.inputSchema] as const), [REPORT_TOOL.name, REPORT_TOOL.inputSchema]]); | |
| 74 | return entries.map((entry) => ({ | |
| 75 | name: entry.name, | |
| 76 | doors: doorsOf(schemas.get(entry.name)), | |
| 77 | purpose: firstSentence(entry.description), | |
| 78 | bytes: Buffer.byteLength(JSON.stringify(entry)), | |
| 79 | })); | |
| 80 | } | |
| 81 | ||
outdated
Medium: when a marker is missing, indexOf gives -1 and slice(0, a + len) writes a garbled doc. Throw naming the missing marker (also when end precedes begin). |
||
| 82 | export function renderTable(rows: readonly ToolRow[] = toolRows()): string { | |
| 83 | return [ | |
| 84 | "| Tool | Actions / kinds | Purpose | Bytes |", | |
| 85 | "| --- | --- | --- | ---: |", | |
| 86 | ...rows.map((r) => `| \`${r.name}\` | ${r.doors || "—"} | ${r.purpose} | ${r.bytes} |`), | |
| 87 | ].join("\n"); | |
| 88 | } | |
| 89 | ||
| 90 | /** Where the table sits in `doc`; throws naming the marker that is missing, or the order that is wrong. */ | |
| 91 | function span(doc: string): [number, number] { | |
| 92 | const a = doc.indexOf(TABLE_BEGIN); | |
| 93 | const b = doc.indexOf(TABLE_END); | |
| 94 | if (a < 0) throw new Error(`docs/MCP.md has no begin marker: ${TABLE_BEGIN}`); | |
| 95 | if (b < 0) throw new Error(`docs/MCP.md has no end marker: ${TABLE_END}`); | |
| 96 | if (b < a) throw new Error("docs/MCP.md has the table end marker before the begin marker"); | |
| 97 | return [a + TABLE_BEGIN.length, b]; | |
| 98 | } | |
| 99 | ||
| 100 | /** The table between the markers in `doc`. */ | |
| 101 | export function tableIn(doc: string): string { | |
| 102 | const [a, b] = span(doc); | |
| 103 | return doc.slice(a, b).trim(); | |
| 104 | } | |
| 105 | ||
| 106 | export function withTable(doc: string, table: string): string { | |
| 107 | const [a, b] = span(doc); | |
| 108 | return doc.slice(0, a) + "\n" + table + "\n" + doc.slice(b); | |
| 109 | } |
test/mcp-table.test.ts added +93
| @@ -0,0 +1,93 @@ | ||
| 1 | import { expect, test } from "bun:test"; | |
| 2 | import { readFileSync } from "node:fs"; | |
| 3 | import { TABLE_BEGIN, TABLE_END, firstSentence, renderTable, tableIn, toolRows, withTable } from "../src/http/mcp_table.ts"; | |
| 4 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 5 | import { tmpdir } from "node:os"; | |
| 6 | import { join } from "node:path"; | |
| 7 | import { createApp } from "../src/app.ts"; | |
| 8 | import { createToken } from "../src/core/tokens.ts"; | |
| 9 | import { createUser } from "../src/core/users.ts"; | |
| 10 | import { serve } from "../src/http/server.ts"; | |
| 11 | import { toolEntries } from "../src/http/mcp.ts"; | |
| 12 | import { TOOLS } from "../src/http/mcp/tools.ts"; | |
| 13 | ||
outdated
Low: the per-row loop only checks tools that exist in code. A tool removed from the code but left in the doc fails only the last, unnamed toBe. Also check each doc row has a tool, naming it. Add a test of withTable (round trip, and the thrown error for missing markers), and one that edits a description and sees that tool named. |
||
| 14 | const DOC = readFileSync(new URL("../docs/MCP.md", import.meta.url), "utf8"); | |
| 15 | const nameOf = (line: string) => line.split("|")[1]?.trim().replace(/`/g, ""); | |
| 16 | ||
| 17 | test("docs/MCP.md holds the tool table the code renders, naming each stale or removed tool", () => { | |
| 18 | const held = new Map(tableIn(DOC).split("\n").slice(2).map((l) => [nameOf(l)!, l])); | |
| 19 | const rows = toolRows(); | |
| 20 | const stale = rows.filter((r) => held.get(r.name) !== renderTable([r]).split("\n")[2]).map((r) => r.name); | |
| 21 | const removed = [...held.keys()].filter((n) => !rows.some((r) => r.name === n)); | |
| 22 | expect(stale, `docs/MCP.md is stale for tool(s) ${stale.join(", ")}: run bun scripts/mcp-table.ts`).toEqual([]); | |
| 23 | expect(removed, `docs/MCP.md lists tool(s) the code no longer has: ${removed.join(", ")}: run bun scripts/mcp-table.ts`).toEqual([]); | |
| 24 | expect(tableIn(DOC)).toBe(renderTable()); | |
| 25 | }); | |
| 26 | ||
| 27 | test("an edited description names its tool; a removed tool is named", () => { | |
| 28 | const rows = toolRows(); | |
| 29 | const edited = rows.map((r) => (r.name === "hooks" ? { ...r, purpose: "Changed" } : r)); | |
| 30 | const doc = withTable(DOC, renderTable(edited)); | |
| 31 | expect(tableIn(doc).split("\n").filter((l) => !renderTable(rows).includes(l)).map(nameOf)).toEqual(["hooks"]); | |
| 32 | const gone = withTable(DOC, renderTable(rows.filter((r) => r.name !== "hooks"))); | |
| 33 | expect(tableIn(gone)).not.toContain("`hooks`"); | |
| 34 | }); | |
| 35 | ||
| 36 | test("the table lists a known tool with its actions", () => { | |
| 37 | expect(toolRows().find((r) => r.name === "hooks")?.doors).toContain("`redeliver`"); | |
| 38 | expect(toolRows().some((r) => r.name === "report")).toBe(true); | |
| 39 | }); | |
| 40 | ||
| 41 | test("a purpose ends at '. ' outside backticks and parentheses", () => { | |
| 42 | expect(firstSentence("Reveal with `reveal: true`; old stops. More.")).toBe("Reveal with `reveal: true`; old stops"); | |
| 43 | expect(firstSentence("Tell (first line. Rest) now. More.")).toBe("Tell (first line. Rest) now"); | |
| 44 | expect(firstSentence("Use `a. b` here.")).toBe("Use `a. b` here"); | |
| 45 | expect(firstSentence("One a|b.")).toBe("One a\\|b"); | |
| 46 | }); | |
| 47 | ||
| 48 | test("a bare word is no purpose: the next sentence joins it", () => { | |
| 49 | expect(firstSentence("Admin. Create or remove a thing. More.")).toBe("Admin. Create or remove a thing"); | |
| 50 | expect(firstSentence("Admin: snapshot destination")).toBe("Admin: snapshot destination"); | |
| 51 | }); | |
| 52 | ||
| 53 | test("withTable round-trips and refuses a doc with a missing or misordered marker", () => { | |
| 54 | expect(withTable(DOC, tableIn(DOC))).toBe(DOC); | |
| 55 | expect(() => withTable("nothing", "t")).toThrow("no begin marker"); | |
| 56 | expect(() => withTable(`${TABLE_BEGIN}\nx`, "t")).toThrow("no end marker"); | |
| 57 | expect(() => tableIn(`${TABLE_END}\n${TABLE_BEGIN}`)).toThrow("before the begin"); | |
| 58 | }); | |
| 59 | ||
| 60 | test("the bytes are those of an admin's real tools/list response", async () => { | |
| 61 | const dataDir = await mkdtemp(join(tmpdir(), "forge-mcp-table-")); | |
| 62 | const app = createApp({ dataDir, port: 0 }); | |
| 63 | const servers = await serve(app); | |
| 64 | try { | |
| 65 | const made = await createUser(app.db, { origin: "seed", trusted: true, username: "alice", email: "[email protected]", password: "a-long-table-passphrase", isAdmin: true }); | |
| 66 | if (!made.ok) throw new Error("user"); | |
| 67 | const token = createToken(app.db, made.value.id, "t", "admin").token; | |
| 68 | const served: any[] = []; | |
| 69 | let cursor: string | undefined; | |
| 70 | do { | |
| 71 | const res = await fetch(`${servers.url}/mcp`, { | |
| 72 | method: "POST", | |
| 73 | headers: { "content-type": "application/json", accept: "application/json, text/event-stream", authorization: `Bearer ${token}` }, | |
| 74 | body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/list", params: cursor === undefined ? {} : { cursor } }), | |
| 75 | }); | |
| 76 | const body = (await res.json()) as any; | |
| 77 | served.push(...body.result.tools); | |
| 78 | cursor = body.result.nextCursor; | |
| 79 | } while (cursor !== undefined); | |
| 80 | const want = new Map(toolRows().map((r) => [r.name, r.bytes])); | |
| 81 | const got = served.map((e) => [e.name, Buffer.byteLength(JSON.stringify(e))]); | |
| 82 | expect(got.length).toBe(want.size); | |
| 83 | for (const [name, bytes] of got) expect(want.get(name), `bytes of ${name}`).toBe(bytes); | |
| 84 | } finally { | |
| 85 | servers.stop(); | |
| 86 | app.close(); | |
| 87 | await rm(dataDir, { recursive: true, force: true }); | |
| 88 | } | |
| 89 | }, 30_000); | |
| 90 | ||
| 91 | test("the entry builder is the one tools/list uses", () => { | |
| 92 | expect(toolEntries(TOOLS).length).toBe(TOOLS.length + 1); | |
| 93 | }); |
Medium: splitting on
:,;or.cuts inside backticks and parentheses. The committed table shows it:claim_linkends at 'only withreveal',tellat '(first line',watchat '(watch',telemetry_grantat 'or `on'. Several tools read just 'Admin' (agent, backups, mirror_now, secrets, subscription_*), which is no purpose at all. Fix: end the sentence only at '. ' or end of text outside backticks/parentheses, drop ':' and ';' as terminators, and when the result is a bare word like 'Admin' fall back to the next sentence. Add tests for these cases.