Public: anyone can read this; the crew can change it.
A test fails when an MCP tool in the index is missing from docs/MCP.md #7252
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/6247-a-test-fails-when-an-mcp-tool-in-the-ind → main 3 commits fork 2 threads · 2 unresolved
8 files
changed +20
−29
docs/MCP.md +4
| @@ -43,6 +43,8 @@ for a tunnel and a forge that sleeps. | ||
| 43 | 43 | | `apps` | the apps you can see: live or not, where served, how each runs itself, what is working | | |
| 44 | 44 | | `archive` | `{ 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 | yes | |
| 45 | 45 | | `asked` | `{ 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, the outcome (landed in a pull, who is building, parked and why, failed) and its sentence, stale when not shipped in three days and flagged when stale with nothing built; repositories you can write | | |
| 46 | | `asks` | `{ …chips }`: what waits on you, most important first; your saved filter applies unless a chip argument overrides it | | | |
| 47 | | `asks_act` | `{ action, items, reason?, dryRun? }`: decide asks in bulk (up to 100): `dismiss`, `decline`, `mute`, `ship`, `close` (with `reason`) or `replan`; planned first (`dryRun` is true unless `false`), as `POST /api/v1/asks/bulk` | yes | | |
| 46 | 48 | | `asks_decide` | `{ item, give_back? }`: let the decider answer a parked hand-over (`parked:<id>`); with `give_back`, give a hand-over back to the crew instead (`handover:<id>`, or `assigned:<id>` for a pull a pass gave up on and assigned to you, whose ask carries the pass's reason): it is assigned to the builder that last worked on it and a comment says so; the same as the card's **Give back to the crew** and `POST /api/v1/asks/give-back` | yes | |
| 47 | 49 | | `asks_proposal` | `{ id, verb, words? }`: act on a wave proposal (`id` is the proposal's own id, not the ask row's `proposal:` id): `verb` is `approve` (opens its issues), `redirect` (with `words`), `later` or `again`; the owner only; answers what the verb did, as `POST /api/v1/asks/proposal/:id/:verb` | yes | |
| 48 | 50 | | `asks_undecide` | `{ id }`: put a set-aside card back on your board within a day (`id` from `asks_decide`); too late or unknown answers a refusal, as `POST /api/v1/asks/decide/:id/undo` | yes | |
| @@ -134,6 +136,7 @@ for a tunnel and a forge that sleeps. | ||
| 134 | 136 | | `repo` | `{ repo }`: one repository's description, visibility, autonomy, `audience { level, sentence }` and `provenance` (where a migrated one came from, `null` when hidden), the same as `GET /api/v1/repos/:owner/:name` | | |
| 135 | 137 | | `repo_labels` | `{ repo, action?, name?, color?, id? }`: list (`list`, the default, paged by `limit` 1–200 and `cursor`, as `{ items, next_cursor }`; a read-only token may), create (`create`, `name`, `color` six hex digits) or remove (`remove`, `id`) the repository's labels, as `GET`/`POST …/labels` and `DELETE …/labels/:id`; create and remove need an admin token and a repository admin | yes | |
| 136 | 138 | | `repo_settings` | `{ repo, description?, visibility?, flowOnly? }` (admin): change a repository's description, visibility and FLOW ONLY switch as `PATCH /api/v1/repos/:owner/:name` does, for the same callers (a repository admin); answers `{ repo, description, visibility, flowOnly }` (with none to change it only reads); a visibility other than public or private is `invalid` | yes | |
| 139 | | `report` | `{ repo, title, body?, page? }`: report a problem in a public repository, the one tool open without a token; it still stores the report, which a person reads first; plain text | yes | | |
| 137 | 140 | | `reports` | `{ repo, id?, action? }`: the waiting reports from outsiders, each `from: "outside"` (their words are unchecked); with `id` and `action` (`accept` opens it as an issue marked as from outside, `dismiss` closes it). A person who writes the repository only; needs an `rw` token | yes | |
| 138 | 141 | | `restore_drill` | no input: restore the newest snapshot as a drill and answer `{ verdict, failedStep?, says }`, worded as the Backups page's button; no store, a failure or a red drill is an answer, not an error; a forge admin's, with an admin token | yes | |
| 139 | 142 | | `returns` | `{ repo?, days? }`: what the crew's tokens bought over the last `days` (1–90, default 7): dollars, runs, changes landed, cost per change; `repo` narrows it to one app of yours | | |
| @@ -152,6 +155,7 @@ for a tunnel and a forge that sleeps. | ||
| 152 | 155 | | `sizes` | what a child forge's sizes mean: small, medium and large, each with CPU, memory and disk | | |
| 153 | 156 | | `snapshot_now` | no arguments: take a snapshot now, as the Backups page's button does; a forge admin with an admin-scope token; answers `{ name, repos, apps, bytes, says }` | yes | |
| 154 | 157 | | `spawn` | `{ name, apps?, crew?, dryRun? }`: a child forge, planned first (`dryRun` is true unless `false`); an admin's, and an `ro` token may plan but not make | yes | |
| 158 | | `spend` | `{ repo, who, model?, tokensIn?, tokensOut?, usd?, issue?, note? }`: report what your session spent outside forge runs; `who` and some spend (tokens or `usd`) are required; answers the entry's `id` and `usd` | yes | | |
| 155 | 159 | | `ssh_keys` | `{ action?, title?, key?, id? }`: list your own SSH keys (default), add one (`key` is an authorized_keys line, `title` optional) or remove one by `id`; the API's rules and errors, add and remove need an `rw` token | yes | |
| 156 | 160 | | `steer` | `{ repo, run, text }`: say a word to one running crew run by its id, as the run page's steer box and `POST …/runs/:id/steer` do; a writer's (never an agent), the run an agent's, `text` not empty; answers `{ says, outcome }` (`sent` or `commented`) | yes | |
| 157 | 161 | | `stop` | stop one run by its id from `now` | yes | |
scripts/mcp-tools.ts +2 −1
| @@ -67,8 +67,9 @@ if (import.meta.main) { | ||
| 67 | 67 | } |
| 68 | 68 | // After the index is current, so a new file's row is in the table. |
| 69 | 69 | const { TOOLS } = await import("../src/http/mcp/tools.ts"); |
| 70 | const { REPORT_TOOL } = await import("../src/http/mcp.ts"); | |
| 70 | 71 | const md = readFileSync(DOC, "utf8"); |
| 71 | const doc = withTable(md, tableText(TOOLS)); | |
| 72 | const doc = withTable(md, tableText([...TOOLS, REPORT_TOOL])); | |
| 72 | 73 | if (md !== doc) { |
| 73 | 74 | stale.push("docs/MCP.md"); |
| 74 | 75 | if (!check) writeFileSync(DOC, doc); |
src/http/mcp.ts +5 −2
| @@ -123,7 +123,7 @@ function rpcOne( | ||
| 123 | 123 | }, |
| 124 | 124 | }), |
| 125 | 125 | })), |
| 126 | REPORT_TOOL, | |
| 126 | { name: REPORT_TOOL.name, description: REPORT_TOOL.description, inputSchema: REPORT_TOOL.inputSchema }, | |
| 127 | 127 | ]; |
| 128 | 128 | // The cursor is the name of the last tool of the page before, so a tool |
| 129 | 129 | // added between two requests moves nothing. |
| @@ -204,8 +204,11 @@ function rpcOne( | ||
| 204 | 204 | * public repository (`actions/reports.ts`), with the same limits as the |
| 205 | 205 | * page and the API. Everything else here needs a token. |
| 206 | 206 | */ |
| 207 | const REPORT_TOOL = { | |
| 207 | export const REPORT_TOOL = { | |
| 208 | 208 | name: "report", |
| 209 | /** Its row in docs/MCP.md (`bun scripts/mcp-tools.ts`); never sent to a client. */ | |
| 210 | doc: "`{ repo, title, body?, page? }`: report a problem in a public repository, the one tool open without a token; it still stores the report, which a person reads first; plain text", | |
| 211 | writes: true, | |
| 209 | 212 | description: |
| 210 | 213 | "Report a problem in a public repository, no account needed. Plain text; a person reads it first.", |
| 211 | 214 | inputSchema: { |
src/http/mcp/tools/asks.ts +2
| @@ -6,6 +6,8 @@ export const tool: Tool = { | ||
| 6 | 6 | description: |
| 7 | 7 | "What waits on you, most important first; saved filter applies", |
| 8 | 8 | inputSchema: { type: "object", properties: { ...CHIP_ARGS } }, |
| 9 | doc: | |
| 10 | "`{ …chips }`: what waits on you, most important first; your saved filter applies unless a chip argument overrides it", | |
| 9 | 11 | writes: false, |
| 10 | 12 | run(ctx, user, args) { |
| 11 | 13 | const chips = chipArgs(ctx, args); |
src/http/mcp/tools/asks_act.ts +2
| @@ -15,6 +15,8 @@ export const tool: Tool = { | ||
| 15 | 15 | }, |
| 16 | 16 | required: ["action", "items"], |
| 17 | 17 | }, |
| 18 | doc: | |
| 19 | "`{ action, items, reason?, dryRun? }`: decide asks in bulk (up to 100): `dismiss`, `decline`, `mute`, `ship`, `close` (with `reason`) or `replan`; planned first (`dryRun` is true unless `false`), as `POST /api/v1/asks/bulk`", | |
| 18 | 20 | writes: true, |
| 19 | 21 | dryByDefault: true, |
| 20 | 22 | async run(ctx, user, args) { |
src/http/mcp/tools/spend.ts +2
| @@ -19,6 +19,8 @@ export const tool: Tool = { | ||
| 19 | 19 | }, |
| 20 | 20 | required: ["repo", "who"], |
| 21 | 21 | }, |
| 22 | doc: | |
| 23 | "`{ repo, who, model?, tokensIn?, tokensOut?, usd?, issue?, note? }`: report what your session spent outside forge runs; `who` and some spend (tokens or `usd`) are required; answers the entry's `id` and `usd`", | |
| 22 | 24 | writes: true, |
| 23 | 25 | run(ctx, user, args) { |
| 24 | 26 | const repo = repoArg(ctx, user, args); |
test/mcp-docs.test.ts +1 −24
| @@ -12,18 +12,6 @@ import { listAt } from "./mcp-list.ts"; | ||
| 12 | 12 | |
| 13 | 13 | quietLogs(); |
| 14 | 14 | |
| 15 | /** | |
| 16 | * Tools that answer in tools/list and have no row yet. This list only shrinks: | |
| 17 | * document a tool, then delete its line. One name per line, so two pulls never edit the same line. | |
| 18 | * The test fails until a documented tool's line is deleted here. | |
| 19 | */ | |
| 20 | const undocumented = new Set<string>([ | |
| 21 | "asks", | |
| 22 | "asks_act", | |
| 23 | "spend", | |
| 24 | "report", | |
| 25 | ]); | |
| 26 | ||
| 27 | 15 | let dataDir: string; |
| 28 | 16 | let servers: Servers; |
| 29 | 17 | let served: string[]; |
| @@ -68,21 +56,10 @@ async function documented(): Promise<string[]> { | ||
| 68 | 56 | test("every tool in tools/list has a row in docs/MCP.md", async () => { |
| 69 | 57 | const rows = new Set(await documented()); |
| 70 | 58 | expect(rows.size).toBeGreaterThan(0); |
| 71 | expect(served.filter((n) => !rows.has(n) && !undocumented.has(n))).toEqual([]); | |
| 59 | expect(served.filter((n) => !rows.has(n))).toEqual([]); | |
| 72 | 60 | }); |
| 73 | 61 | |
| 74 | 62 | test("every row in docs/MCP.md names a tool that exists", async () => { |
| 75 | 63 | const live = new Set(served); |
| 76 | 64 | expect((await documented()).filter((n) => !live.has(n))).toEqual([]); |
| 77 | 65 | }); |
| 78 | ||
| 79 | test("no undocumented name has a row in docs/MCP.md: delete each stale line", async () => { | |
| 80 | const rows = new Set(await documented()); | |
| 81 | const stale = [...undocumented].filter((n) => rows.has(n)); | |
| 82 | expect(stale.map((n) => `${n}: documented, delete it from the undocumented set`)).toEqual([]); | |
| 83 | }); | |
| 84 | ||
| 85 | test("every undocumented name is a tool tools/list answers", () => { | |
| 86 | const unknown = [...undocumented].filter((n) => !served.includes(n)); | |
| 87 | expect(unknown.map((n) => `${n}: not in tools/list, delete it from the undocumented set`)).toEqual([]); | |
| 88 | }); |
test/mcp-tools.test.ts +2 −2
| @@ -7,7 +7,7 @@ import { describe, expect, test } from "bun:test"; | ||
| 7 | 7 | import { readFileSync } from "node:fs"; |
| 8 | 8 | import { join } from "node:path"; |
| 9 | 9 | import { indexText, tableText, toolFiles, withTable } from "../scripts/mcp-tools.ts"; |
| 10 | import { toolNames } from "../src/http/mcp.ts"; | |
| 10 | import { REPORT_TOOL, toolNames } from "../src/http/mcp.ts"; | |
| 11 | 11 | import { TOOLS } from "../src/http/mcp/tools.ts"; |
| 12 | 12 | |
| 13 | 13 | const ROOT = join(import.meta.dir, ".."); |
| @@ -47,7 +47,7 @@ describe("the gathered tools", () => { | ||
| 47 | 47 | |
| 48 | 48 | test("docs/MCP.md's tool table is current: run `bun scripts/mcp-tools.ts`", () => { |
| 49 | 49 | const md = read("docs/MCP.md"); |
| 50 | expect(md).toBe(withTable(md, tableText(TOOLS))); | |
| 50 | expect(md).toBe(withTable(md, tableText([...TOOLS, REPORT_TOOL]))); | |
| 51 | 51 | }); |
| 52 | 52 | |
| 53 | 53 | test("a new file is one new line in the index, touching no other", () => { |
new above
offline