fix(mcp): approval_gated trust actually routes tool calls through the gate - #273
Merged
Merged
Conversation
… 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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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), whilecreateMcpToolDefinition.run()proxied straight toclient.callToolwith norequireApprovalin the path (git grep -n "requireApproval" -- src/mcpreturned nothing). Meanwhilemcp-resource-class.tsand 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:
src/mcp/mcp-tool-adapter.ts): when the resolved trust isapproval_gated,run()calls the sharedrequireApproval(categoryother, qualified tool name, clipped args-JSON preview, server as the affected resource) beforetools/call. A denial folds intostatus: "error"stampeddetails.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.readOnlyHintexemption, fail-closed: only a discovery-timeannotations.readOnlyHint === true(strict boolean) skips the prompt. Missing, malformed,"true"-as-string, ordestructiveHint: falsealone all stay gated — deliberately narrower than the adapter's existingreadonlydescriptor field. Classification happens once at registration from discovery-time data, so descriptor bytes and the KV-cached stable prefix are byte-identical.src/mcp/mcp-manager.ts,src/runtime/bootstrap.ts):McpManagerDepsgains optionaldangerous?: DangerousToolOptions; bootstrap passes the same shared object the native dangerous tools use, so session grants and the approval ladder apply uniformly.McpManager.registerToolsForresolvess.config.trust ?? "approval_gated"and hands both to the adapter.approval_gatedstays the default,pure_readstays ungated.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/denyToolsis 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 === trueexempt, string"true"/ missing annotations /destructiveHint: false-only all fail closed,pure_readungated,approvalRequired: falseseam) andmcp-manager.test.ts(passes deps.dangerous + resolved trust through to registered tools).npm run lint(tsc --noEmit): clean.npx vitest run src/mcp src/approval src/runtime: 24 files, 268 tests, all passing.Fixes #132