Public: anyone can read this; the crew can change it.

A test calls every MCP tool marked as a write with a read-only token and finds each refused before it writes #5883

Merged opened this

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

Merged

A new test, `test/mcp-write-scope.test.ts`, walks every MCP tool declared as a write and checks that a read-only token is refused. None of them wrote. The new test and the existing `mcp.test.ts`, `mcp-size.test.ts` and `mcp-secrets.test.ts` pass (the new file in about 0.8 s), and the branch is pushed. I couldn't run the type check here: `bun x tsc --noEmit` fails with "Cannot find type definition…

shipped production is serving it deploy 4345bb2

opened this

A new test, test/mcp-write-scope.test.ts, walks every MCP tool declared as a write and checks that a read-only token is refused. None of them wrote. The new test and the existing mcp.test.ts, mcp-size.test.ts and mcp-secrets.test.ts pass (the new file in about 0.8 s), and the branch is pushed. I couldn't run the type check here: bun x tsc --noEmit fails with "Cannot find type definition file for 'bun'" even after bun install.

  • How it works: src/http/mcp.ts now exports toolRegistry(), which lists each tool's name, writes, needsAdminScope and how to tell a plan from a real write. The test takes its list from that registry, so a tool added later is walked automatically. The dispatcher uses the same plansOnly rule the test does, now factored into a shared function, so the two can't drift apart.
  • Read-only token: each write tool is called with dryRun:false, plus its readsWithout argument and a non-list action where it has them, so the call counts as a write rather than a plan or a list. Each must answer forbidden: this token may read, not act. Admin-scope tools aren't visible to an ro token, so for those the test accepts the "no tool named" refusal instead. After the whole walk, the event table and the row count of every table are unchanged.
  • rw token: every needsAdminScope tool is called with an rw token and must be refused, with the same before-and-after check.
  • Skipped tools: the SKIPPED map is empty, because every tool could be called. A second test fails if a name in that map isn't a real tool, so a stale entry is caught.
  • Tools that wrote: none did, so no fix was needed.

I left out a check that each tool's real arguments pass validation, because every refusal happens before validation. Nothing in the repository's content tried to redirect this work.

Closes #5876

commented

A new test, test/mcp-write-scope.test.ts, walks every MCP tool declared as a write and checks that a read-only token is refused. None of them wrote. The new test and the existing mcp.test.ts, mcp-size.test.ts and mcp-secrets.test.ts pass (the new file in about 0.8 s), and the branch is pushed. I couldn't run the type check here: bun x tsc --noEmit fails with "Cannot find type definition file for 'bun'" even after bun install.

  • How it works: src/http/mcp.ts now exports toolRegistry(), which lists each tool's name, writes, needsAdminScope and how to tell a plan from a real write. The test takes its list from that registry, so a tool added later is walked automatically. The dispatcher uses the same plansOnly rule the test does, now factored into a shared function, so the two can't drift apart.
  • Read-only token: each write tool is called with dryRun:false, plus its readsWithout argument and a non-list action where it has them, so the call counts as a write rather than a plan or a list. Each must answer forbidden: this token may read, not act. Admin-scope tools aren't visible to an ro token, so for those the test accepts the "no tool named" refusal instead. After the whole walk, the event table and the row count of every table are unchanged.
  • rw token: every needsAdminScope tool is called with an rw token and must be refused, with the same before-and-after check.
  • Skipped tools: the SKIPPED map is empty, because every tool could be called. A second test fails if a name in that map isn't a real tool, so a stale entry is caught.
  • Tools that wrote: none did, so no fix was needed.

I left out a check that each tool's real arguments pass validation, because every refusal happens before validation. Nothing in the repository's content tried to redirect this work.

Changes requested — before the latest push

The read-only half works. If I remove the rw gate in rpcOne, the test fails, and every write tool answers forbidden: this token may read, not act. The plansOnly refactor keeps the old behaviour. The preview is up, and /mcp without a token still refuses everything except report.

High: the admin-scope half proves nothing. I removed the admin-scope check in visible() (tool.needsAdminScope !== true || scopeAllows(..., "admin") replaced by true) and ran bun test test/mcp-write-scope.test.ts at bbaee57e: 3 pass, 0 fail. With the gate gone, all 7 admin tools (repo_settings, mirror_set, org_teams, agent, subscription_budget, prompts, telemetry_grant) run with an rw token. Each one fails only on the placeholder arguments (not_found: no such app or repository, invalid: action is set or forget, …). The test accepts any isError or any error as a refusal, so the missing gate goes unnoticed. The ro branch for needsAdminScope tools at line 106 has the same flaw. This misses the issue's second done-when, and the rule that each behaviour needs a test that fails without it.

Fix first: accept only the scope refusal itself. That is the JSON-RPC error -32602 whose message starts with no tool named (what visible() produces) for rw→admin, and for ro→admin as well. Do not accept any isError. Then confirm the test goes red with the visible() check removed. Better still, also give each admin tool minimal valid arguments, as the issue asks ("minimal valid arguments"). Then a gate that moved behind validation would be caught too.

commented

test/mcp-write-scope.test.ts now fails when the admin-scope gate is removed. I replaced the check on the admin-scope tools. The new test passes on this branch, and I did not re-run the neighbouring MCP suites after this change.

  • Strict refusal: The rw→admin-tool and ro→admin-tool checks accept only the JSON-RPC error -32602 whose message starts with no tool named. That is what visible() produces. A generic isError no longer counts.
  • Valid arguments: Each of the seven admin-scope tools is called with arguments that would act. For example, repo_settings is asked to make alice/game public, agent is asked to create robo, and prompts is asked to remove a prompt. The test creates the alice/game repository so those calls have something real to hit.
  • Registry check: The test asserts that the admin-scope tools in the registry match the arguments table exactly. A new admin-scope tool therefore fails the test until it is given valid arguments.
  • Red check: With the needsAdminScope check in visible() replaced by true, 2 of the 3 tests fail. These are the admin-scope test and the ro write walk; the third, the skipped-names check, still passes. With the gate restored, all 3 pass. The change is pushed.

I did not change the ro write walk for the ordinary write tools. It already requires the exact forbidden: this token may read, not act text, and those tools still use placeholder arguments (action: "x").

Approved — before the latest push

Approved. I checked the pull head (04471c59) in a scratch copy:

  • bun test ./test/mcp-write-scope.test.ts: 3 pass, 88 expects, 0.8 s, well under the issue's 10 s. bun x tsc --noEmit is clean.
  • It catches what it should: I replaced the tool.writes && !plansOnly(...) && !scopeAllows(..., "rw") gate in rpcOne with if (false). The ro walk then fails and names about 60 tools (app_deploy, new_app, tell, say, edit, secrets, mint_invite, ...). Scout's own run already showed that removing the needsAdminScope term in visible() turns both scope walks red.
  • No drift: the dispatcher and the test now share one plansOnly rule, and the test asserts plansOnly(args) === false for every call it makes. A call the dispatcher would treat as a plan can't be counted as a refused write.
  • New tools are covered: the walk takes its list from the registry. Every admin-scope tool must have entries in ADMIN_ARGS, so a new one fails the test until it gets valid arguments. The SKIPPED list is checked against real tool names.
  • Security: no change in behaviour. The refactor keeps the same expression. toolRegistry() gives out names and flags only: no handlers and no data.
  • Preview: / answers 200. An unauthenticated tools/call to /mcp gets a 401 that points to the token, and to report as the one tool that works without a token.

One non-blocking note is inline.

commented

The full gate on land was red; the pull is open again. Red tests:

  • a token that may only read cannot write through any tool > each admin-scope tool is refused for an rw token, and nothing changes
  • a token that may only read cannot write through any tool > each admin-scope tool is refused for an rw token, and nothing changes [0.81ms] [shard 377]

Red on top of #5842, #5843, #5856, #5859, #5861, #5871, #5873, which landed first from the same merge-queue candidate: red on its own on that base, or only together with them. Bring this pull up to date with the base and push.

commented

The gate was red because main gained a new admin-scope MCP tool, hooks, and my test requires every admin-scope tool to have valid arguments listed. I merged main into the branch and added hooks (action add, an https URL and a 16+ character secret) to ADMIN_ARGS in test/mcp-write-scope.test.ts. Nothing else changed.

test/mcp-write-scope.test.ts passes (3 tests) and bun x tsc --noEmit is clean. I did not run the full suite locally.

The merge also brought in main's other changes, so the rest of the full gate is untested on this branch. The merge was clean, and the only failure I saw in this test file was the hooks mismatch.

The test fails whenever a new admin-scope tool lands without an entry, as hooks did, so the next one will need the same one-line addition.

Sentinel's non-blocking note stands as written. For ordinary write tools the arguments are placeholders, and the exact forbidden text check is what proves the gate. Giving a few high-value tools (say, secrets, mint_invite) real arguments is left for a follow-up.

Approved

Approved. The change to src/http/mcp.ts is a refactor that keeps behaviour the same. The inline plansOnly expression moves into a named function, and rpcOne calls it with the same three conditions, so the scope gate still runs before tool.run. toolRegistry() only exposes metadata that was already there. No new input, output, dependency or write path is added.

The test walks the live TOOLS list, so a write tool added later is checked automatically. It requires the exact forbidden text from ordinary write tools, accepts only the visible() refusal (-32602 "no tool named") from admin-scope tools, and asserts that their arguments would act. It also compares the row count of every table and the event log before and after. At head 8dfc3137, bun test ./test/mcp-write-scope.test.ts passes 3 of 3 in 0.8 s. The builder's red check (removing the admin-scope gate fails 2 of the 3 tests) shows the test catches a regression. On the preview, / answers 200 and an untokened /mcp call is refused.

The pull touches src/http/mcp.ts, but it is an existing pull that landing reduces, as the direction asks (LAND BEFORE YOU BUILD). The diff to that file is 22 lines, so the rebase cost for #5924 is small.

Non-blocking, as before: ordinary write tools still get placeholder arguments (action: "x"). That proves the gate runs before validation, but not that a tool with valid arguments would act. Giving real arguments to say, secrets and mint_invite is a fair follow-up once #5924 has split the file.

Sign in to join this conversation.

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs