Public: anyone can read this; the crew can change it.
Every MCP tool's doc names each argument its inputSchema takes, and a test refuses a tool whose doc leaves one out #9149
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/9110-every-mcp-tool-s-doc-names-each-argument → main 7 commits fork 2 threads · 2 unresolved
20 files
changed +72
−19
src/contract/openapi/routes/index.ts +1 −1
| @@ -98,7 +98,6 @@ export { route as getApiV1ReposOwnerNameAppNotes } from "./getApiV1ReposOwnerNam | ||
| 98 | 98 | export { route as getApiV1ReposOwnerNameAppProductionOpen } from "./getApiV1ReposOwnerNameAppProductionOpen.ts"; |
| 99 | 99 | export { route as getApiV1ReposOwnerNameBranches } from "./getApiV1ReposOwnerNameBranches.ts"; |
| 100 | 100 | export { route as getApiV1ReposOwnerNameBuilds } from "./getApiV1ReposOwnerNameBuilds.ts"; |
| 101 | export { route as getApiV1ReposOwnerNameIterations } from "./getApiV1ReposOwnerNameIterations.ts"; | |
| 102 | 101 | export { route as getApiV1ReposOwnerNameCommits } from "./getApiV1ReposOwnerNameCommits.ts"; |
| 103 | 102 | export { route as getApiV1ReposOwnerNameCommitsOid } from "./getApiV1ReposOwnerNameCommitsOid.ts"; |
| 104 | 103 | export { route as getApiV1ReposOwnerNameCommitsOidCheck } from "./getApiV1ReposOwnerNameCommitsOidCheck.ts"; |
| @@ -121,6 +120,7 @@ export { route as getApiV1ReposOwnerNameIssuesNumberComments } from "./getApiV1R | ||
| 121 | 120 | export { route as getApiV1ReposOwnerNameIssuesNumberDependencies } from "./getApiV1ReposOwnerNameIssuesNumberDependencies.ts"; |
| 122 | 121 | export { route as getApiV1ReposOwnerNameIssuesNumberPlan } from "./getApiV1ReposOwnerNameIssuesNumberPlan.ts"; |
| 123 | 122 | export { route as getApiV1ReposOwnerNameIssuesNumberTeams } from "./getApiV1ReposOwnerNameIssuesNumberTeams.ts"; |
| 123 | export { route as getApiV1ReposOwnerNameIterations } from "./getApiV1ReposOwnerNameIterations.ts"; | |
| 124 | 124 | export { route as getApiV1ReposOwnerNameLabels } from "./getApiV1ReposOwnerNameLabels.ts"; |
| 125 | 125 | export { route as getApiV1ReposOwnerNameMap } from "./getApiV1ReposOwnerNameMap.ts"; |
| 126 | 126 | export { route as getApiV1ReposOwnerNameMilestones } from "./getApiV1ReposOwnerNameMilestones.ts"; |
src/http/mcp/tools/agent.ts +1 −1
| @@ -4,7 +4,7 @@ import { good, bad, type Tool, str } from "../shared.ts"; | ||
| 4 | 4 | |
| 5 | 5 | export const tool: Tool = { |
| 6 | 6 | name: "agent", |
| 7 | doc: "`{ action, username, … }`: `create` a crew agent, `update` its `model`, `brief`, `budgetUsd` or `roles` (planner, builder, reviewer), or `remove` it; an admin's, with an `admin` token, as `/api/v1/agents`", | |
| 7 | doc: "`{ action, username, displayName?, model?, brief?, budgetUsd?, roles? }`: `create` a crew agent (with a `displayName`), `update` its `model`, `brief`, `budgetUsd` or `roles` (planner, builder, reviewer), or `remove` it; an admin's, with an `admin` token, as `/api/v1/agents`", | |
| 8 | 8 | description: "Admin: create|update|remove an agent.", |
| 9 | 9 | inputSchema: { |
| 10 | 10 | type: "object", |
src/http/mcp/tools/apps.ts +1 −1
| @@ -5,7 +5,7 @@ import { bad, good, type Tool } from "../shared.ts"; | ||
| 5 | 5 | |
| 6 | 6 | export const tool: Tool = { |
| 7 | 7 | name: "apps", |
| 8 | doc: "the apps you can see: live or not, where served, how each runs itself, what is working", | |
| 8 | doc: "`{ archived? }`: the apps you can see: live or not, where served, how each runs itself, what is working", | |
| 9 | 9 | description: |
| 10 | 10 | "Apps you can see: live or not, where served, how each runs, what works.", |
| 11 | 11 | inputSchema: { type: "object", properties: { archived: { type: "boolean" } } }, |
src/http/mcp/tools/asks.ts +1 −1
| @@ -7,7 +7,7 @@ export const tool: Tool = { | ||
| 7 | 7 | "What waits on you, most important first; saved filter applies", |
| 8 | 8 | inputSchema: { type: "object", properties: { ...CHIP_ARGS } }, |
| 9 | 9 | doc: |
| 10 | "`{ …chips }`: what waits on you, most important first; your saved filter applies unless a chip argument overrides it", | |
| 10 | "`{ hide_kinds?, hide_people? }`: what waits on you, most important first; your saved filter applies unless a chip argument overrides it", | |
| 11 | 11 | writes: false, |
| 12 | 12 | run(ctx, user, args) { |
| 13 | 13 | const chips = chipArgs(ctx, args); |
src/http/mcp/tools/asks_act.ts +1 −1
| @@ -18,7 +18,7 @@ export const tool: Tool = { | ||
| 18 | 18 | required: ["action"], |
| 19 | 19 | }, |
| 20 | 20 | doc: |
| 21 | "`{ 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`", | |
| 21 | "`{ action, items?, repo?, reason?, dryRun? }`: decide asks in bulk (up to 100): `dismiss`, `decline`, `mute`, `ship`, `close` (with `reason`) or `replan`, or `listen` with `repo` to unmute one; planned first (`dryRun` is true unless `false`), as `POST /api/v1/asks/bulk`", | |
| 22 | 22 | writes: true, |
| 23 | 23 | dryByDefault: true, |
| 24 | 24 | async run(ctx, user, args) { |
src/http/mcp/tools/audit.ts +1 −1
| @@ -4,7 +4,7 @@ import { good, type Tool, REPO_ARG, repoArg, isAnswer } from "../shared.ts"; | ||
| 4 | 4 | |
| 5 | 5 | export const tool: Tool = { |
| 6 | 6 | name: "audit", |
| 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`)", | |
| 7 | doc: "`{ repo? }`: who can do what, per app (all you can see without `repo`), 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 | 9 | "Who can do what per app: resource × verb, yes, own or no.", |
| 10 | 10 | inputSchema: { |
src/http/mcp/tools/claim_link.ts +1 −1
| @@ -3,7 +3,7 @@ import { good, bad, type Tool, str, REVEAL_ARG, LINK_KEPT, LINK_REVEALED } from | ||
| 3 | 3 | |
| 4 | 4 | export const tool: Tool = { |
| 5 | 5 | name: "claim_link", |
| 6 | doc: "`{ name }`: a new claim link for a child you spawned that nobody has claimed; the old one stops working. An admin's", | |
| 6 | doc: "`{ name, reveal? }`: a new claim link for a child you spawned that nobody has claimed; the old one stops working. An admin's, and only `reveal: true` makes it", | |
| 7 | 7 | description: |
| 8 | 8 | "A new claim link for an unclaimed child forge, only with `reveal: true`; the old stops.", |
| 9 | 9 | inputSchema: { |
src/http/mcp/tools/converge.ts +1 −1
| @@ -6,7 +6,7 @@ import { good, bad, type Tool, REPO_ARG, repoArg, isAnswer } from "../shared.ts" | ||
| 6 | 6 | |
| 7 | 7 | export const tool: Tool = { |
| 8 | 8 | name: "converge", |
| 9 | doc: "the fork's crew merges where both sides changed the same files; comes back as a pull", | |
| 9 | doc: "`{ repo }`: the fork's crew merges where both sides changed the same files; comes back as a pull", | |
| 10 | 10 | description: |
| 11 | 11 | "Have a fork's crew merge the parent's updates where both changed a file.", |
| 12 | 12 | inputSchema: { |
src/http/mcp/tools/edit.ts +1 −1
| @@ -3,7 +3,7 @@ import { good, bad, type Tool, REPO_ARG, repoArg, issueArg, isAnswer } from "../ | ||
| 3 | 3 | |
| 4 | 4 | export const tool: Tool = { |
| 5 | 5 | name: "edit", |
| 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", | |
| 6 | doc: "`{ repo, number, title?, body? }`: 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 | 8 | "Retitle or rewrite an issue or pull: yours, or where you write; an agent only its own.", |
| 9 | 9 | inputSchema: { |
src/http/mcp/tools/feed.ts +1 −1
| @@ -5,7 +5,7 @@ import { good, bad, type Tool, CHIP_ARGS, chipArgs, str } from "../shared.ts"; | ||
| 5 | 5 | |
| 6 | 6 | export const tool: Tool = { |
| 7 | 7 | name: "feed", |
| 8 | doc: "`{ limit?, cursor? }`: your home timeline — tells, replies, runs, changes and looks — ranked, with who, what, why and a link; the same items as `GET /api/v1/feed`; `digest` (first page only) is home's \"Since you were here\" card, `{ heading, lines: [{ repo, says, href }] }` or null; `quietRuns` counts agent runs of the last 24 h that pushed, opened or said nothing, which the timeline leaves out; an item with `lines: [{ text, link }]` and `more` is a folded burst (3+ issues one author opened in one repository within 2 minutes: `planned N issues in owner/name`, linking to its map) — `lines` are the first three, `more` the rest", | |
| 8 | doc: "`{ limit?, cursor?, hide_kinds?, hide_people? }`: your home timeline — tells, replies, runs, changes and looks — ranked, with who, what, why and a link; the same items as `GET /api/v1/feed`; `digest` (first page only) is home's \"Since you were here\" card, `{ heading, lines: [{ repo, says, href }] }` or null; `quietRuns` counts agent runs of the last 24 h that pushed, opened or said nothing, which the timeline leaves out; an item with `lines: [{ text, link }]` and `more` is a folded burst (3+ issues one author opened in one repository within 2 minutes: `planned N issues in owner/name`, linking to its map) — `lines` are the first three, `more` the rest", | |
| 9 | 9 | description: |
| 10 | 10 | "Your ranked home timeline; `cursor` pages; saved filter applies.", |
| 11 | 11 | inputSchema: { |
src/http/mcp/tools/limb.ts +1 −1
| @@ -6,7 +6,7 @@ import { good, bad, type Tool, str, APP_ARG, planRepo, isAnswer } from "../share | ||
| 6 | 6 | |
| 7 | 7 | export const tool: Tool = { |
| 8 | 8 | name: "limb", |
| 9 | doc: "`{ app, issue, action, reason?, pillar?, operation?, dryRun? }`: defer, resume or close a whole limb as not doing, or undo an operation; plans unless `dryRun` is false, and applying takes an admin token", | |
| 9 | doc: "`{ app, issue?, action, reason?, pillar?, under?, operation?, dryRun? }`: defer, resume or close a whole limb as not doing, or undo an operation; plans unless `dryRun` is false, and applying takes an admin token", | |
outdated
|
||
| 10 | 10 | description: |
| 11 | 11 | "Act on a limb by `action`.", |
| 12 | 12 | inputSchema: { |
src/http/mcp/tools/plan.ts +1 −1
| @@ -3,7 +3,7 @@ import { good, bad, type Tool, APP_ARG, planRepo, isAnswer } from "../shared.ts" | ||
| 3 | 3 | |
| 4 | 4 | export const tool: Tool = { |
| 5 | 5 | name: "plan", |
| 6 | doc: "`{ app, issues }`: open up to 12 linked issues, all or none (see below)", | |
| 6 | doc: "`{ app, issues, title?, why? }`: open up to 12 linked issues, all or none (see below)", | |
| 7 | 7 | description: `Open linked issues, all or none (max ${PLAN_MAX}); each: \`pillar\` or \`later\`+\`why\`.`, |
| 8 | 8 | inputSchema: { |
| 9 | 9 | type: "object", |
src/http/mcp/tools/probe.ts +1 −1
| @@ -6,7 +6,7 @@ import { good, bad, type Tool, REPO_ARG, repoArg, isAnswer } from "../shared.ts" | ||
| 6 | 6 | |
| 7 | 7 | export const tool: Tool = { |
| 8 | 8 | name: "probe", |
| 9 | doc: "knock on a running app's doors as nobody, a user and an admin; the ones that disobey its contract", | |
| 9 | doc: "`{ repo }`: knock on a running app's doors as nobody, a user and an admin; the ones that disobey its contract", | |
| 10 | 10 | description: |
| 11 | 11 | "Try every door of a running app as nobody, a user and an admin; read-only.", |
| 12 | 12 | inputSchema: { |
src/http/mcp/tools/read_file.ts +1 −1
| @@ -9,7 +9,7 @@ import { good, bad, type Tool, REPO_ARG, repoArg, isAnswer } from "../shared.ts" | ||
| 9 | 9 | |
| 10 | 10 | export const tool: Tool = { |
| 11 | 11 | name: "read_file", |
| 12 | doc: "a file (utf-8, or base64 for binary) or a directory listing at a ref, as GET …/contents; MP4/MOV/M4A files also carry about: {kind, seconds, width?, height?, says}", | |
| 12 | doc: "`{ repo, path, ref? }`: a file (utf-8, or base64 for binary) or a directory listing at a ref, as GET …/contents; MP4/MOV/M4A files also carry about: {kind, seconds, width?, height?, says}", | |
| 13 | 13 | description: |
| 14 | 14 | "Read a file or list a directory at a ref (default branch); over 5 MB answers tooLarge.", |
| 15 | 15 | inputSchema: { |
src/http/mcp/tools/say.ts +1 −1
| @@ -8,7 +8,7 @@ import { good, bad, type Tool, str, REPO_ARG, repoArg, isAnswer } from "../share | ||
| 8 | 8 | |
| 9 | 9 | export const tool: Tool = { |
| 10 | 10 | name: "say", |
| 11 | doc: "speak on an issue or a pull; a working builder hears it as its next step. With no `repo` or `number` it speaks to the forge's own agent (`forge talk`), which answers in `GET /api/v1/conversation/turns`. `conversation` names one of your conversations (default: the latest); `action` list | new | rename | archive | restore manages them (`title` for rename and new)", | |
| 11 | doc: "`{ repo?, number?, text?, conversation?, action?, title?, archived? }`: speak on an issue or a pull; a working builder hears it as its next step. With no `repo` or `number` it speaks to the forge's own agent (`forge talk`), which answers in `GET /api/v1/conversation/turns`. `conversation` names one of your conversations (default: the latest); `action` list | new | rename | archive | restore manages them (`title` for rename and new; `archived` lists archived ones)", | |
| 12 | 12 | description: |
| 13 | 13 | "Say something on an issue or pull; with no repo, to the forge's own agent, and manage its conversations (action).", |
| 14 | 14 | inputSchema: { |
src/http/mcp/tools/spawn.ts +1 −1
| @@ -4,7 +4,7 @@ import { good, bad, type Tool, APP_ARG, REVEAL_ARG, LINK_KEPT, LINK_REVEALED } f | ||
| 4 | 4 | |
| 5 | 5 | export const tool: Tool = { |
| 6 | 6 | name: "spawn", |
| 7 | doc: "`{ name, apps?, crew?, template?, dryRun? }`: a child forge, planned first (`dryRun` is true unless `false`); an admin's, and an `ro` token may plan but not make", | |
| 7 | doc: "`{ name, apps?, crew?, direction?, fresh?, followMode?, size?, template?, keepAccess?, dryRun?, reveal? }`: a child forge, planned first (`dryRun` is true unless `false`); an admin's, and an `ro` token may plan but not make; `reveal: true` shows the claim link", | |
| 8 | 8 | description: |
| 9 | 9 | "Spawn a child forge. Plans first.", |
| 10 | 10 | inputSchema: { |
src/http/mcp/tools/stop.ts +1 −1
| @@ -4,7 +4,7 @@ import { good, bad, type Tool, str, REPO_ARG, repoArg, isAnswer } from "../share | ||
| 4 | 4 | |
| 5 | 5 | export const tool: Tool = { |
| 6 | 6 | name: "stop", |
| 7 | doc: "stop one run by its id from `now`; with no `repo` it stops the forge's own agent mid-answer", | |
| 7 | doc: "`{ repo?, run? }`: stop one run by its id from `now`; with no `repo` it stops the forge's own agent mid-answer", | |
| 8 | 8 | description: |
| 9 | 9 | "Stop a crew run by its id from `now`; with no repo, the forge's own agent mid-answer.", |
| 10 | 10 | inputSchema: { |
src/http/mcp/tools/take_updates.ts +1 −1
| @@ -6,7 +6,7 @@ import { good, bad, type Tool, REPO_ARG, repoArg, isAnswer } from "../shared.ts" | ||
| 6 | 6 | |
| 7 | 7 | export const tool: Tool = { |
| 8 | 8 | name: "take_updates", |
| 9 | doc: "a fork takes its parent's updates when git can do it alone; a conflict is refused. On a spawned forge's code repository it opens the one `forge/take-updates` pull and never moves the default branch", | |
| 9 | doc: "`{ repo }`: a fork takes its parent's updates when git can do it alone; a conflict is refused. On a spawned forge's code repository it opens the one `forge/take-updates` pull and never moves the default branch", | |
| 10 | 10 | description: |
| 11 | 11 | "Take the parent forge's updates into a fork.", |
| 12 | 12 | inputSchema: { |
src/http/mcp/tools/tell.ts +1 −1
| @@ -7,7 +7,7 @@ import { good, bad, type Tool, str, REPO_ARG, repoArg, isAnswer } from "../share | ||
| 7 | 7 | |
| 8 | 8 | export const tool: Tool = { |
| 9 | 9 | name: "tell", |
| 10 | doc: "a sentence becomes an issue, a builder is picked, a run starts on the crew's next tick", | |
| 10 | doc: "`{ repo?, text, from?, context? }`: a sentence becomes an issue, a builder is picked, a run starts on the crew's next tick", | |
| 11 | 11 | description: |
| 12 | 12 | "Tell an app's crew what to change (first line: title). No `repo`: kept.", |
| 13 | 13 | inputSchema: { |
test/mcp-doc-names-every-argument.test.ts added +53
| @@ -0,0 +1,53 @@ | ||
| 1 | /** | |
| 2 | * An agent reads each tool's `doc`, so the `doc` names every argument the | |
| 3 | * tool's inputSchema takes, in backticks (#9110). Tools owed a fix are listed | |
| 4 | * in OWED; the set only shrinks (empty since #9109 landed). | |
| 5 | */ | |
| 6 | import { expect, test } from "bun:test"; | |
| 7 | import { TOOLS } from "../src/http/mcp/tools.ts"; | |
| 8 | ||
| 9 | const OWED: Record<string, string[]> = {}; | |
| 10 | ||
| 11 | const inBackticks = (doc: string, arg: string): boolean => | |
| 12 | [...doc.matchAll(/`([^`]*)`/g)].some((m) => | |
| 13 | new RegExp(`(^|[^A-Za-z0-9_])${arg}($|[^A-Za-z0-9_])`).test(m[1]!), | |
| 14 | ); | |
| 15 | ||
| 16 | const missing = (): string[] => | |
| 17 | TOOLS.flatMap((t) => | |
| 18 | Object.keys((t.inputSchema as { properties?: object }).properties ?? {}) | |
| 19 | .filter((arg) => !inBackticks(t.doc ?? t.description, arg)) | |
| 20 | .map((arg) => `${t.name}.${arg}`), | |
| 21 | ); | |
| 22 | ||
| 23 | test("every tool's doc names each argument its inputSchema takes", () => { | |
| 24 | const owed = new Set(Object.entries(OWED).flatMap(([n, a]) => a.map((x) => `${n}.${x}`))); | |
| 25 | const breaches = missing().filter((m) => !owed.has(m)); | |
| 26 | expect(breaches.map((m) => `doc of ${m.split(".")[0]} omits argument ${m.split(".")[1]}`)).toEqual([]); | |
| 27 | }); | |
| 28 | ||
| 29 | test("an owed tool that no longer breaches leaves the owed set", () => { | |
| 30 | const now = new Set(missing()); | |
| 31 | const settled = Object.entries(OWED).flatMap(([n, a]) => a.map((x) => `${n}.${x}`)).filter((m) => !now.has(m)); | |
| 32 | expect(settled).toEqual([]); | |
| 33 | }); | |
| 34 | ||
| 35 | // The tools #9110 brought into the house form; other tools' docs are not held to `?` yet. | |
| 36 | const FORMED = new Set(["edit","limb","asks_act","say","agent","claim_link","audit","asks","read_file","spawn","apps","tell","converge","plan","feed","probe"]); | |
| 37 | ||
| 38 | test("a doc's `{ a, b? }` form marks exactly the optional arguments with ?", () => { | |
| 39 | const wrong: string[] = []; | |
| 40 | for (const t of TOOLS.filter((t) => FORMED.has(t.name))) { | |
| 41 | const form = /^`\{([^}]*)\}`/.exec(t.doc ?? t.description); | |
| 42 | if (!form) continue; | |
| 43 | const schema = t.inputSchema as { properties?: object; required?: string[] }; | |
| 44 | const required = new Set(schema.required ?? []); | |
| 45 | for (const part of form[1]!.split(",").map((s) => s.trim()).filter(Boolean)) { | |
| 46 | const optional = part.endsWith("?"); | |
| 47 | const name = optional ? part.slice(0, -1) : part; | |
| 48 | if (name in (schema.properties ?? {}) && optional === required.has(name)) | |
| 49 | wrong.push(`${t.name}.${name} is ${required.has(name) ? "required" : "optional"} but the doc writes \`${part}\``); | |
| 50 | } | |
| 51 | } | |
| 52 | expect(wrong).toEqual([]); | |
| 53 | }); |
new above
offline
itemsis not inrequired: ["action"], so writeitems?.