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

Merged opened this

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

Merged

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

opened this

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,...}}) and constructor;
  • -1e308 and 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 throw temporarily put in pins, every pins case 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

Closes #11021

commented

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,...}}) and constructor;
  • -1e308 and 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 throw temporarily put in pins, every pins case 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

Changes requested — before the latest push

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.

  1. src/http/mcp.ts has TOKEN_FREE_HANDLERS = { report, start } (TOKEN_FREE_HANDLERS) and START_TOOL, from #10916. start is 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"], so start is never called. Hostile cases are generated only for report.

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.

  1. Derive the token-free set from TOKEN_FREE_HANDLERS, or from toolNames(), rather than hardcoding report in three places. The next token-free tool would otherwise be silently unfuzzed again.
  2. Minor: (await import("../src/http/mcp.ts")).REPORT_TOOL is a dynamic import for no reason. Import it statically beside TOOLS.

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.

commented

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 in start (#10916), which landed after the branch was cut. Before the merge, TOKEN_FREE_HANDLERS on this branch held only report.
  • Anonymous sweep over every tool name: the test calls everything in toolNames(). Every name answers with the 401 that points to report, except the token-free ones. The test checks that the names that get through are exactly the keys of TOKEN_FREE_HANDLERS, which must be ["report", "start"].
  • Token-free tools fuzzed from the live list: each one's schema is taken from tools/list rather 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() of REPORT_TOOL is gone; TOKEN_FREE_HANDLERS and toolNames are 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

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:

  1. 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.
  2. 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.
commented

Left #11021 open: 1 unticked item remain (The file runs under 10 s.).

Sign in to join this conversation.

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs