feat(security): add first-invoke approval for MCP server tools - #43045
feat(security): add first-invoke approval for MCP server tools#43045tgmerritt wants to merge 1 commit into
Conversation
Code Review: VerificationReviewed by: automated PR review This PR is clean — no substantive issues found. What it does: Adds a first-invoke approval guard for MCP server tools. The first call to each Security posture is sound:
Integration in mcp_tool.py: The guard check runs in the tool-executor thread (which holds session context and the per-thread approval callback). The Test coverage: 350 lines covering the full decision matrix — config toggle, yolo/off bypass, headless auto-approve, session and wildcard approval, gateway approve/deny/timeout, CLI callback, persistence scopes. Minor note: The approval check is synchronous and blocks the tool-executor thread waiting for user response. In gateway contexts with a long approval timeout, this could delay other tool calls in the same agent turn. This matches the existing terminal-command approval behavior, so it's consistent — just worth being aware of. |
egilewski
left a comment
There was a problem hiding this comment.
Approve. I reviewed current GitHub main d1383a6 against PR head 88fc209da5d3b2d1719f45ceff7022b07a34bfe2. The MCP first-invoke guard is wired before tools/call dispatch, uses per-session/per-tool approval keys with explicit wildcard support, and the deny path returns an error without bumping the MCP server circuit breaker.
Validation:
- git merge-tree --write-tree upstream/main refs/remotes/upstream/pr/43045 => f27fcd6041bba5f4bbf28992d194720819655e21
- git diff --check upstream/main...refs/remotes/upstream/pr/43045 passed
- pytest -q tests/tools/test_mcp_tool_approval.py -p no:cacheprovider => 21 passed
- pytest -q tests/tools/test_approval.py tests/tools/test_execute_code_approval_cluster.py tests/tools/test_approval_plugin_hooks.py tests/tools/test_cron_approval_mode.py tests/tools/test_yolo_mode.py tests/tools/test_hardline_blocklist.py tests/gateway/test_approve_deny_commands.py -p no:cacheprovider => 395 passed
- pytest -q tests/tools/test_mcp_*.py -p no:cacheprovider => 488 passed, 1 existing cli.py SyntaxWarning
- direct probes confirmed gateway-without-notifier blocks as pending approval and
mcp:<server>:*wildcard approval covers other tools on that server.
Signed: GPT-5.5-xhigh in Codex
|
Following this PR with interest. We run a dual-agent Hermes deployment (two Docker containers) with GitHub and banking MCP servers connected. An agent posted an unapproved GitHub comment despite prompt-level rules prohibiting it — exactly the gap this PR addresses. The first-invoke approval model would solve our use case well. One thing we'd find useful: a config option to keep certain tools permanently gated (require approval every session, not just first invoke) — e.g. banking writes should never get "approve for this server," while GitHub reads could be pre-approved. Happy to test on our setup when this is ready. |
|
The permanent-gating point from @bedpan seems worth making explicit before this lands. First-invoke approval and per-action approval are solving different problems:
A config shape that tends to avoid footguns is to classify tools by approval durability, not only by allow/deny:
For the The regression test I would add is: approve first invoke for Disclosure: I work on Armorer Labs. |
teknium1
left a comment
There was a problem hiding this comment.
Thanks for implementing the first-use MCP approval flow. The underlying gap still exists on current main: registered MCP handlers reach server.session.call_tool(...) directly at tools/mcp_tool.py:3943.
Problems
- Blocking:
tools/approval.py:1835readsHERMES_INTERACTIVEdirectly. Current main explicitly replaced process-global interactive detection with_is_interactive_cli()because concurrent ACP sessions can race the environment and auto-approve an action without the callback (tools/approval.py:54-93;acp_adapter/server.py:1504). The MCP guard must use that ContextVar-aware helper.
Suggested changes
- Replace the direct environment check with
_is_interactive_cli()and add an ACP-context regression whereset_hermes_interactive_context(True)is set while the environment flag is absent. - Apply the guard against the current handler lifecycle, after the reconnect/live-session checks at
tools/mcp_tool.py:3893-3932and beforecall_toolat line 3943.
Automated hermes-sweeper review.
| or is_approved(session_key, server_wildcard)): | ||
| return {"approved": True, "message": None} | ||
|
|
||
| is_cli = env_var_enabled("HERMES_INTERACTIVE") |
There was a problem hiding this comment.
Blocking: use _is_interactive_cli() here rather than the process-global environment flag. Current main moved interactive state to a ContextVar because concurrent ACP sessions can race HERMES_INTERACTIVE and incorrectly take an auto-approve path (tools/approval.py:54-93).
MCP tools are registered dynamically at connect time and were callable
by the LLM with no human confirmation — unlike terminal commands, which
pass through DANGEROUS_PATTERNS and the approval system. SECURITY.md
assigns MCP servers lower trust than installed skills; this makes that
trust distinction real in the dispatch path.
The first call to each (server, tool) pair per session now triggers the
same approval flow used for dangerous commands, showing server, tool,
and a truncated JSON dump of the arguments. Approval scopes:
- once: this single dispatch only (no persistence)
- session: this (server, tool) pair until /new (clear_session)
- always: persisted to command_allowlist; mcp:<server>:* trusts a
whole server
Behavior notes:
- Interactive surfaces only: CLI prompts via the per-thread approval
callback; gateway blocks on /approve//deny via the existing
per-session queue. Headless and cron contexts auto-approve with a
log line so existing automation keeps working (restrict unattended
profiles via mcp_servers.<name>.tools.include/exclude).
- First-use consent is a visibility decision, not a risk assessment,
so smart mode does not auto-approve it; only yolo/mode=off bypass,
plus the dedicated approvals.mcp_first_invoke: false toggle.
- A denial is user intent, not a server fault: it does not bump the
MCP circuit breaker.
Closes NousResearch#16462
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
88fc209 to
e912550
Compare
|
Rebased onto current |
What does this PR do?
Implements first-invoke approval for MCP server tools, as proposed in #16462.
Today, when a user adds an MCP server, its tools are registered in the tool registry and become immediately callable by the LLM with no human confirmation — unlike the terminal tool, which is gated by
DANGEROUS_PATTERNSand the approval system. SECURITY.md assigns MCP servers lower trust than installed skills, but the dispatch path didn't reflect that.With this change, the first call to each
(server, tool)pair per session triggers the same approval flow already used for dangerous commands. The prompt shows the server id, tool name, and a truncated JSON dump of the arguments, so the user sees exactly what a newly added server is about to do before anything runs. Approval scopes map onto the existing once/session/always machinery:(server, tool)pair until the session is cleared (/newresets it viaclear_session, matching the issue's "reset on /new")command_allowlistin config.yamlPer-tool keys (
mcp:<server>:<tool>) are deliberately narrower than per-server, following the review comments on the issue ("approve for this server should not silently approve newly added tools" — @kayalopez, @Ram9199). Users who want server-wide permanent trust can addmcp:<server>:*tocommand_allowlistexplicitly.Design decisions worth flagging for review:
approvals.mcp_first_invoke: true). Friction is one prompt per(server, tool)pair per session, on interactive surfaces only. Happy to flip the default to opt-in if preferred — it's one line.mcp_servers.<name>.tools.include/excludeinstead (docs updated to say so).smartmode does not auto-approve first invokes. First-use consent is a visibility decision, not a risk assessment the aux LLM can make; only--yolo/mode: off/the dedicated toggle bypass it.tools/calldispatch only; the resources/prompts utility tools are unchanged (can follow up if desired).Related Issue
Fixes #16462
Type of Change
Changes Made
tools/approval.py— newcheck_mcp_tool_guard()(modeled oncheck_execute_code_guard), plusmcp_approval_key()and theapprovals.mcp_first_invokeconfig read. Reuses the existing session/permanent approval stores, gateway approval queue (_await_gateway_decision), CLI prompt path, andpre/post_approval_requestplugin hooks.tools/mcp_tool.py—_make_tool_handler()calls the guard before dispatchingtools/call; a block returns the standard{"error": ...}JSON to the model without reaching the MCP loop.website/docs/user-guide/security.md— documents the new key in the approvals table and example YAML.tests/tools/test_mcp_tool_approval.py— 21 new tests covering the decision matrix (toggle, yolo/off, headless/cron auto-approve, once/session/always persistence, per-tool vs wildcard keys,/newreset, gateway deny/timeout/missing-notify, CLI callback path, args preview + truncation) and handler integration (deny blocks before dispatch; breaker not bumped).How to Test
scripts/run_tests.sh tests/tools/test_mcp_tool_approval.py— 21/21 pass.scripts/run_tests.sh tests/tools/test_approval.py tests/tools/test_execute_code_approval_cluster.py tests/tools/test_approval_plugin_hooks.py tests/tools/test_cron_approval_mode.py tests/tools/test_yolo_mode.py tests/tools/test_hardline_blocklist.py tests/gateway/test_approve_deny_commands.py— 395/395 pass.tests/tools/test_mcp_*.pyhandler/breaker/discovery/stability suites) — 283/283 pass (hermetic test env is headless, so existing handler tests hit the auto-approve path unchanged)./approveonce allows that call only, asking again re-prompts; approve session silences that pair until/new.Checklist
Code
fix(scope):,feat(scope):, etc.)scripts/run_tests.shand all tests passDocumentation & Housekeeping
docs/, docstrings) — or N/Acli-config.yaml.exampleif I added/changed config keys — N/A (theapprovals:block is not present in the example file; documented inwebsite/docs/user-guide/security.mdalongside the other approvals keys)CONTRIBUTING.mdorAGENTS.mdif I changed architecture or workflows — N/A🤖 Generated with Claude Code