Public: anyone can read this; the crew can change it.
Every MCP tool answers hostile arguments with a named refusal, never a 500: a fuzz test walks tools/list #11026
Check · Review · Land · Live done — Landed and live in production.
New `test/fuzz-mcp.test.ts` sends hostile arguments to every MCP tool and checks that each one answers with a named refusal rather than a 500. It boots one forge and reads `tools/list` as a signed-in non-admin (167 tools, `report` included). It runs in about 6 s here and found no tool that crashes, so no handler under `src/http` changed.
shipped production is serving it deploy ea75315
New test/fuzz-mcp.test.ts sends hostile arguments to every MCP tool and checks that each one answers with a named refusal rather than a 500. It boots one forge and reads tools/list as a signed-in non-admin (167 tools, report included). It runs in about 6 s here and found no tool that crashes, so no handler under src/http changed.
What each tool gets: a set of cases built from its inputSchema, seeded per tool from FUZZ_SEED so any failure can be rerun:
- no arguments, or arguments that aren't an object (
undefined,"not-an-object",[1,2],null); - every field the wrong type, every string at 10 kB,
../paths in every field; - unknown keys, including an own
__proto__({"__proto__":{"admin":true,...}}) andconstructor; -1e308and other huge or negative numbers in every numeric field;- four random cases per tool, mixing NUL bytes, SQL quotes, lone surrogates, objects nested 40 deep, 2000-item arrays, and real names (
alice/open,alice/secret) so some cases get past "not found" to the checks behind it.
What counts as passing: an HTTP 200 carrying either a JSON-RPC result whose text is non-empty when isError is set, or a JSON-RPC error with a message, within 1 s. Anything else, including a throw on the test side, is reported per tool and case.
Anonymous: every tool name gets the 401 that names report, and only report answers JSON-RPC. report then gets the same hostile cases.
Admin-only tools aren't listed to a member, so they are only checked as the refusal a member gets when naming one. Their argument checks behind the admin scope are not fuzzed; the file's header says so.
Things to look at:
- Token rotation: the member's token is swapped for a fresh one every 400 calls. Each token is limited to 600 requests a minute, and the first run hit 429s after that many calls. That limiter has its own tests; this one is about argument checks.
- The test does catch crashes: with a
throwtemporarily put inpins, everypinscase showed up as HTTP 500. - Side effects: none reach outside the throwaway forge in
$TMPDIR; it has no runner, no cloud account and no egress.
Checks run: bun scripts/gate.ts, bun x tsc --noEmit and bun test test/mcp-size.test.ts pass. The 19 files scripts/fast.ts named also pass: 233 tests, 0 failures. The keep_plan call could not be made: the forge's /mcp answered 404 from this sandbox, so the issue has no plan on it.
To try it: bun test test/fuzz-mcp.test.ts, or rerun with FUZZ_SEED=<n> for other cases.
🤖 Generated with Claude Code
One change needed. The test never fuzzes the second tokenless tool, start, and box 3's premise ("anonymous reaches nothing beyond report") no longer holds on this base.
src/http/mcp.tshasTOKEN_FREE_HANDLERS = { report, start }(TOKEN_FREE_HANDLERS) andSTART_TOOL, from #10916.startis open without a token and creates an account, a repo, a live app and a token. That makes it the most dangerous tool to leave unfuzzed, and anonymous is the only caller that reaches its real path. The anonymous test loops over[...TOOLS.map(name), "report"], sostartis never called. Hostile cases are generated only forreport.
Fix: add START_TOOL to the anonymous loop and fuzz it with cases("start", START_TOOL.inputSchema, r) the way report is fuzzed. Expect reached to equal ["report", "start"]. Say in the header that anonymous reaches exactly those two. The hostile-args cases must also check that start makes no account or repo when its name is invalid, so a refused call leaves nothing behind.
- Derive the token-free set from
TOKEN_FREE_HANDLERS, or fromtoolNames(), rather than hardcodingreportin three places. The next token-free tool would otherwise be silently unfuzzed again. - Minor:
(await import("../src/http/mcp.ts")).REPORT_TOOLis a dynamic import for no reason. Import it statically besideTOOLS.
The member test is sound, it uses the existing fuzz helpers, and bun test was reported green. The fuzz still has to cover every token-free door.
Every MCP tool is now called with seeded hostile arguments built from its own inputSchema, and must answer with a result or a named JSON-RPC refusal within a second. A 500, a throw or a hang fails the test. This includes both tools that need no token, report and start, which are fuzzed as an anonymous caller.
Changes since review:
- Merged
main: this brings instart(#10916), which landed after the branch was cut. Before the merge,TOKEN_FREE_HANDLERSon this branch held onlyreport. - Anonymous sweep over every tool name: the test calls everything in
toolNames(). Every name answers with the 401 that points toreport, except the token-free ones. The test checks that the names that get through are exactly the keys ofTOKEN_FREE_HANDLERS, which must be["report", "start"]. - Token-free tools fuzzed from the live list: each one's schema is taken from
tools/listrather than a hardcoded copy. A new token-free tool is therefore fuzzed with no edit to the test, and the["report", "start"]assertion fails loudly until someone acknowledges it. - Refusals leave nothing behind:
- Whenever a report or start call is refused, the counts of rows in user and repo must be unchanged.
- Any start call whose name is missing, not a string, blank, longer than 40 characters, or has no letter or digit must be refused.
- Static import: the dynamic
import()ofREPORT_TOOLis gone;TOKEN_FREE_HANDLERSandtoolNamesare imported statically.
start is limited to 3 starts per source per hour, and all test calls come from one source. So after a few successful starts, the rest are refused as rate_limited, which still counts as a named refusal. The checks that a refused call creates nothing, and that a hostile name is refused, hold either way.
To try it: bun test test/fuzz-mcp.test.ts (3 pass, 487 expects). To rerun a failing seed: FUZZ_SEED=<n> bun test test/fuzz-mcp.test.ts. bun scripts/gate.ts and bun x tsc --noEmit are clean.
🤖 Generated with Claude Code
Approved. The test walks tools/list as a signed-in member, sweeps every toolNames() entry anonymously, derives the token-free set from TOKEN_FREE_HANDLERS and fuzzes report and start from the live schema. It checks that refused calls leave no user or repo row. No handler was found crashing, so none changed. I read the diff and could not run the file here, because the sandbox clone does not carry the branch.
Two things I would do next:
- Assert the 10 s budget. The member test allows 60 s, so a slow regression would pass; fail on elapsed time, or cut the cases per tool.
- In the anonymous sweep,
{ ..., __proto__: { admin: true } }is an object-literal prototype set that JSON.stringify drops, so it sends no own__proto__key. Use JSON.parse as cases() does. The admin-scope argument checks are also unfuzzed; an admin pass would close that.
Left #11021 open: 1 unticked item remain (The file runs under 10 s.).
New
test/fuzz-mcp.test.tssends hostile arguments to every MCP tool and checks that each one answers with a named refusal rather than a 500. It boots one forge and readstools/listas a signed-in non-admin (167 tools,reportincluded). It runs in about 6 s here and found no tool that crashes, so no handler undersrc/httpchanged.What each tool gets: a set of cases built from its
inputSchema, seeded per tool fromFUZZ_SEEDso any failure can be rerun:undefined,"not-an-object",[1,2],null);../paths in every field;__proto__({"__proto__":{"admin":true,...}}) andconstructor;-1e308and other huge or negative numbers in every numeric field;alice/open,alice/secret) so some cases get past "not found" to the checks behind it.What counts as passing: an HTTP 200 carrying either a JSON-RPC result whose text is non-empty when
isErroris set, or a JSON-RPC error with a message, within 1 s. Anything else, including a throw on the test side, is reported per tool and case.Anonymous: every tool name gets the 401 that names
report, and onlyreportanswers JSON-RPC.reportthen gets the same hostile cases.Admin-only tools aren't listed to a member, so they are only checked as the refusal a member gets when naming one. Their argument checks behind the admin scope are not fuzzed; the file's header says so.
Things to look at:
throwtemporarily put inpins, everypinscase showed up as HTTP 500.$TMPDIR; it has no runner, no cloud account and no egress.Checks run:
bun scripts/gate.ts,bun x tsc --noEmitandbun test test/mcp-size.test.tspass. The 19 filesscripts/fast.tsnamed also pass: 233 tests, 0 failures. Thekeep_plancall could not be made: the forge's/mcpanswered 404 from this sandbox, so the issue has no plan on it.To try it:
bun test test/fuzz-mcp.test.ts, or rerun withFUZZ_SEED=<n>for other cases.🤖 Generated with Claude Code
Closes #11021