Skip to content

fix(mcp): approval_gated trust actually routes tool calls through the gate - #273

Merged
plombeer31 merged 1 commit into
mainfrom
fix/issue-132-mcp-tools-bypass-the
Aug 31, 2026
Merged

fix(mcp): approval_gated trust actually routes tool calls through the gate#273
plombeer31 merged 1 commit into
mainfrom
fix/issue-132-mcp-tools-bypass-the

Conversation

@plombeer31

@plombeer31 plombeer31 commented Aug 28, 2026

Copy link
Copy Markdown
Collaborator

What was broken

trust: "approval_gated" — the default for every MCP server — never gated anything. The resource class only forced solo execution (a scheduling constraint), while createMcpToolDefinition.run() proxied straight to client.callTool with no requireApproval in the path (git grep -n "requireApproval" -- src/mcp returned nothing). Meanwhile mcp-resource-class.ts and AGENTS.md both claimed gated calls were "routed through the approval gate". Net effect: arbitrary third-party server tools (PowerShell, registry writes, browser_run_code_unsafe, synthetic input) ran unattended under a config that reads as gated, while the equivalent built-in tools prompted.

The fix

Implements the maintainer-endorsed blend of options 1 + 3 from the issue thread:

  • Gate (src/mcp/mcp-tool-adapter.ts): when the resolved trust is approval_gated, run() calls the shared requireApproval (category other, qualified tool name, clipped args-JSON preview, server as the affected resource) before tools/call. A denial folds into status: "error" stamped details.approvalDenied — the server is never contacted, and the adapter's never-throws invariant (AGENTS.md §MCP invariant 3) is preserved while denials stay distinguishable from server failures.
  • readOnlyHint exemption, fail-closed: only a discovery-time annotations.readOnlyHint === true (strict boolean) skips the prompt. Missing, malformed, "true"-as-string, or destructiveHint: false alone all stay gated — deliberately narrower than the adapter's existing readonly descriptor field. Classification happens once at registration from discovery-time data, so descriptor bytes and the KV-cached stable prefix are byte-identical.
  • Plumbing (src/mcp/mcp-manager.ts, src/runtime/bootstrap.ts): McpManagerDeps gains optional dangerous?: DangerousToolOptions; bootstrap passes the same shared object the native dangerous tools use, so session grants and the approval ladder apply uniformly. McpManager.registerToolsFor resolves s.config.trust ?? "approval_gated" and hands both to the adapter.
  • Defaults unchanged: approval_gated stays the default, pure_read stays ungated.
  • Docs: mcp-resource-class.ts, the AGENTS.md trust table, §"MCP client" (incl. new locked invariant 14), and the approval-ladder section now describe the enforced behaviour instead of the intended one.

Per-server allowTools / denyTools is left out per the maintainer's request, to be tracked separately.

Test evidence

New coverage in mcp-tool-adapter.test.ts (approval gating, 8 cases: gate-before-callTool with payload + signal propagation, denial → error + approvalDenied + server never contacted, readOnlyHint === true exempt, string "true" / missing annotations / destructiveHint: false-only all fail closed, pure_read ungated, approvalRequired: false seam) and mcp-manager.test.ts (passes deps.dangerous + resolved trust through to registered tools).

  • Without the fix (src stashed, tests kept): 6 of the new tests fail; with it, all pass.
  • npm run lint (tsc --noEmit): clean.
  • npx vitest run src/mcp src/approval src/runtime: 24 files, 268 tests, all passing.

Fixes #132

… gate

MCP tools never reached the approval gate: `trust: "approval_gated"`
(the default) only forced solo execution via the resource class, while
`createMcpToolDefinition.run()` proxied straight to `client.callTool`.
An operator reading the config — and the docs, which claimed the calls
were "routed through the approval gate" — would reasonably conclude
otherwise, so arbitrary third-party server tools (PowerShell, registry
writes, unsafe browser code) ran unattended under a config that reads
as gated.

The adapter now calls the shared `requireApproval` (category `other`,
args JSON as the preview) before `tools/call` when the resolved trust
is `approval_gated`. Bootstrap plumbs the same `DangerousToolOptions`
the native dangerous tools use through `McpManagerDeps`, so session
grants and the approval ladder apply uniformly.

Tools whose discovery-time `annotations.readOnlyHint` is exactly `true`
skip the prompt — gating every read on a read-heavy server pushes
operators toward disabling the gate wholesale, and under operator-set
per-server trust a lying server can only skip approval on calls it
could already make, never widen access. Anything else (missing,
malformed, string "true", or only `destructiveHint: false`) fails
closed to gated. Classification happens once at registration from
discovery-time data, leaving descriptor bytes — and the KV-cached
stable prefix — untouched. `approval_gated` stays the default;
`pure_read` stays ungated.

A denial folds into `status: "error"` with `details.approvalDenied`
(never contacting the server), preserving the never-throws invariant
while keeping operator denials distinguishable from server failures.

Docs in `mcp-resource-class.ts` and AGENTS.md now describe the
enforced behaviour instead of the intended one.

Fixes #132

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@plombeer31
plombeer31 merged commit cf3b57e into main Aug 31, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

1 participant