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

Merged opened this

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

Merged

scout/forge:scout/6247-a-test-fails-when-an-mcp-tool-in-the-ind → main 3 commits fork 2 threads · 2 unresolved

  • Merged. scout/6247-a-test-fails-when-an-mcp-tool-in-the-ind was merged into main as f9993bc.

Review

Sign in to review this change.
8 files changed +20 −29
docs/MCP.md +4
@@ -43,6 +43,8 @@ for a tunnel and a forge that sleeps.
4343 | `apps` | the apps you can see: live or not, where served, how each runs itself, what is working | |
4444 | `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 |
4545 | `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 |
4648 | `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 |
4749 | `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 |
4850 | `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.
134136 | `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` | |
135137 | `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 |
136138 | `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 |
137140 | `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 |
138141 | `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 |
139142 | `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.
152155 | `sizes` | what a child forge's sizes mean: small, medium and large, each with CPU, memory and disk | |
153156 | `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 |
154157 | `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 |
155159 | `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 |
156160 | `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 |
157161 | `stop` | stop one run by its id from `now` | yes |
scripts/mcp-tools.ts +2 −1
@@ -67,8 +67,9 @@ if (import.meta.main) {
6767 }
6868 // After the index is current, so a new file's row is in the table.
6969 const { TOOLS } = await import("../src/http/mcp/tools.ts");
70 const { REPORT_TOOL } = await import("../src/http/mcp.ts");
7071 const md = readFileSync(DOC, "utf8");
71 const doc = withTable(md, tableText(TOOLS));
72 const doc = withTable(md, tableText([...TOOLS, REPORT_TOOL]));
7273 if (md !== doc) {
7374 stale.push("docs/MCP.md");
7475 if (!check) writeFileSync(DOC, doc);
src/http/mcp.ts +5 −2
@@ -123,7 +123,7 @@ function rpcOne(
123123 },
124124 }),
125125 })),
126 REPORT_TOOL,
126 { name: REPORT_TOOL.name, description: REPORT_TOOL.description, inputSchema: REPORT_TOOL.inputSchema },
127127 ];
128128 // The cursor is the name of the last tool of the page before, so a tool
129129 // added between two requests moves nothing.
@@ -204,8 +204,11 @@ function rpcOne(
204204 * public repository (`actions/reports.ts`), with the same limits as the
205205 * page and the API. Everything else here needs a token.
206206 */
207 const REPORT_TOOL = {
207 export const REPORT_TOOL = {
208208 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,
209212 description:
210213 "Report a problem in a public repository, no account needed. Plain text; a person reads it first.",
211214 inputSchema: {
src/http/mcp/tools/asks.ts +2
@@ -6,6 +6,8 @@ export const tool: Tool = {
66 description:
77 "What waits on you, most important first; saved filter applies",
88 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",
911 writes: false,
1012 run(ctx, user, args) {
1113 const chips = chipArgs(ctx, args);
src/http/mcp/tools/asks_act.ts +2
@@ -15,6 +15,8 @@ export const tool: Tool = {
1515 },
1616 required: ["action", "items"],
1717 },
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`",
1820 writes: true,
1921 dryByDefault: true,
2022 async run(ctx, user, args) {
src/http/mcp/tools/spend.ts +2
@@ -19,6 +19,8 @@ export const tool: Tool = {
1919 },
2020 required: ["repo", "who"],
2121 },
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`",
2224 writes: true,
2325 run(ctx, user, args) {
2426 const repo = repoArg(ctx, user, args);
test/mcp-docs.test.ts +1 −24
@@ -12,18 +12,6 @@ import { listAt } from "./mcp-list.ts";
1212
1313 quietLogs();
1414
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
2715 let dataDir: string;
2816 let servers: Servers;
2917 let served: string[];
@@ -68,21 +56,10 @@ async function documented(): Promise<string[]> {
6856 test("every tool in tools/list has a row in docs/MCP.md", async () => {
6957 const rows = new Set(await documented());
7058 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([]);
7260 });
7361
7462 test("every row in docs/MCP.md names a tool that exists", async () => {
7563 const live = new Set(served);
7664 expect((await documented()).filter((n) => !live.has(n))).toEqual([]);
7765 });
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";
77 import { readFileSync } from "node:fs";
88 import { join } from "node:path";
99 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";
1111 import { TOOLS } from "../src/http/mcp/tools.ts";
1212
1313 const ROOT = join(import.meta.dir, "..");
@@ -47,7 +47,7 @@ describe("the gathered tools", () => {
4747
4848 test("docs/MCP.md's tool table is current: run `bun scripts/mcp-tools.ts`", () => {
4949 const md = read("docs/MCP.md");
50 expect(md).toBe(withTable(md, tableText(TOOLS)));
50 expect(md).toBe(withTable(md, tableText([...TOOLS, REPORT_TOOL])));
5151 });
5252
5353 test("a new file is one new line in the index, touching no other", () => {

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs