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

Merged opened this

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

Merged

mason/forge:mason/9110-every-mcp-tool-s-doc-names-each-argument → main 7 commits fork 2 threads · 2 unresolved

  • Merged. mason/9110-every-mcp-tool-s-doc-names-each-argument was merged into main as f29b51b.

Review

Sign in to review this change.
20 files changed +72 −19
src/contract/openapi/routes/index.ts +1 −1
@@ -98,7 +98,6 @@ export { route as getApiV1ReposOwnerNameAppNotes } from "./getApiV1ReposOwnerNam
9898 export { route as getApiV1ReposOwnerNameAppProductionOpen } from "./getApiV1ReposOwnerNameAppProductionOpen.ts";
9999 export { route as getApiV1ReposOwnerNameBranches } from "./getApiV1ReposOwnerNameBranches.ts";
100100 export { route as getApiV1ReposOwnerNameBuilds } from "./getApiV1ReposOwnerNameBuilds.ts";
101 export { route as getApiV1ReposOwnerNameIterations } from "./getApiV1ReposOwnerNameIterations.ts";
102101 export { route as getApiV1ReposOwnerNameCommits } from "./getApiV1ReposOwnerNameCommits.ts";
103102 export { route as getApiV1ReposOwnerNameCommitsOid } from "./getApiV1ReposOwnerNameCommitsOid.ts";
104103 export { route as getApiV1ReposOwnerNameCommitsOidCheck } from "./getApiV1ReposOwnerNameCommitsOidCheck.ts";
@@ -121,6 +120,7 @@ export { route as getApiV1ReposOwnerNameIssuesNumberComments } from "./getApiV1R
121120 export { route as getApiV1ReposOwnerNameIssuesNumberDependencies } from "./getApiV1ReposOwnerNameIssuesNumberDependencies.ts";
122121 export { route as getApiV1ReposOwnerNameIssuesNumberPlan } from "./getApiV1ReposOwnerNameIssuesNumberPlan.ts";
123122 export { route as getApiV1ReposOwnerNameIssuesNumberTeams } from "./getApiV1ReposOwnerNameIssuesNumberTeams.ts";
123 export { route as getApiV1ReposOwnerNameIterations } from "./getApiV1ReposOwnerNameIterations.ts";
124124 export { route as getApiV1ReposOwnerNameLabels } from "./getApiV1ReposOwnerNameLabels.ts";
125125 export { route as getApiV1ReposOwnerNameMap } from "./getApiV1ReposOwnerNameMap.ts";
126126 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";
44
55 export const tool: Tool = {
66 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`",
88 description: "Admin: create|update|remove an agent.",
99 inputSchema: {
1010 type: "object",
src/http/mcp/tools/apps.ts +1 −1
@@ -5,7 +5,7 @@ import { bad, good, type Tool } from "../shared.ts";
55
66 export const tool: Tool = {
77 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",
99 description:
1010 "Apps you can see: live or not, where served, how each runs, what works.",
1111 inputSchema: { type: "object", properties: { archived: { type: "boolean" } } },
src/http/mcp/tools/asks.ts +1 −1
@@ -7,7 +7,7 @@ export const tool: Tool = {
77 "What waits on you, most important first; saved filter applies",
88 inputSchema: { type: "object", properties: { ...CHIP_ARGS } },
99 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",
1111 writes: false,
1212 run(ctx, user, args) {
1313 const chips = chipArgs(ctx, args);
src/http/mcp/tools/asks_act.ts +1 −1
@@ -18,7 +18,7 @@ export const tool: Tool = {
1818 required: ["action"],
1919 },
2020 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`",
outdated

items is not in required: ["action"], so write items?.

2222 writes: true,
2323 dryByDefault: true,
2424 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";
44
55 export const tool: Tool = {
66 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`)",
88 description:
99 "Who can do what per app: resource × verb, yes, own or no.",
1010 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"
66
77 export const tool: Tool = {
88 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",
1010 description:
1111 "Have a fork's crew merge the parent's updates where both changed a file.",
1212 inputSchema: {
src/http/mcp/tools/edit.ts +1 −1
@@ -3,7 +3,7 @@ import { good, bad, type Tool, REPO_ARG, repoArg, issueArg, isAnswer } from "../
33
44 export const tool: Tool = {
55 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",
77 description:
88 "Retitle or rewrite an issue or pull: yours, or where you write; an agent only its own.",
99 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";
55
66 export const tool: Tool = {
77 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",
99 description:
1010 "Your ranked home timeline; `cursor` pages; saved filter applies.",
1111 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
66
77 export const tool: Tool = {
88 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

issue is not in required: ["app","action"], so write issue?.

1010 description:
1111 "Act on a limb by `action`.",
1212 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"
33
44 export const tool: Tool = {
55 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)",
77 description: `Open linked issues, all or none (max ${PLAN_MAX}); each: \`pillar\` or \`later\`+\`why\`.`,
88 inputSchema: {
99 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"
66
77 export const tool: Tool = {
88 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",
1010 description:
1111 "Try every door of a running app as nobody, a user and an admin; read-only.",
1212 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"
99
1010 export const tool: Tool = {
1111 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}",
1313 description:
1414 "Read a file or list a directory at a ref (default branch); over 5 MB answers tooLarge.",
1515 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
88
99 export const tool: Tool = {
1010 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)",
1212 description:
1313 "Say something on an issue or pull; with no repo, to the forge's own agent, and manage its conversations (action).",
1414 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
44
55 export const tool: Tool = {
66 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",
88 description:
99 "Spawn a child forge. Plans first.",
1010 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
44
55 export const tool: Tool = {
66 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",
88 description:
99 "Stop a crew run by its id from `now`; with no repo, the forge's own agent mid-answer.",
1010 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"
66
77 export const tool: Tool = {
88 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",
1010 description:
1111 "Take the parent forge's updates into a fork.",
1212 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
77
88 export const tool: Tool = {
99 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",
1111 description:
1212 "Tell an app's crew what to change (first line: title). No `repo`: kept.",
1313 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 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs