Repository navigation
feat(orchestrator): let extensions gate calls to their own MCP server - #578
ladislav-urban wants to merge 1 commit into
Conversation
…MCP server
An extension that brings an MCP server (tools <server>__*) had no way to
gate calls to it: who may ask, or what must go out with each call (a
per-request token naming who is asking). Its only option was patching
mcp_gateway.call_tool.
extensions.register_gateway_check(GatewayCheck(server, check)) adds one
check per server. call_tool asks it after its own refusals (Act as me's
lockdown and outside content, AI-app turns, saved files) and before any
per-tool gate, with the normalized tool name and a deep copy of the
arguments. A GatewayCall with refusal is returned to the model and audited
as integration_outbound_blocked (field server_check). With outgoing, the
arguments sent are built after every gate, from the model's own (still
what is recorded), and again for each send, so the discovery retry never
replays a one-request token.
It fails closed:
- non-object arguments, and a check or outgoing that raises or returns
the wrong type, refuse the call;
- the proxy routes by the exact tool name, so a name that only normalizes
to a checked server ("Kiosk__x", " kiosk__x") is refused rather than
signed and sent to whatever server answers to that spelling, and
load_mcp_server refuses a runtime server whose name normalizes to one;
- if the extension fails to load, its servers stay held (every call
refused), never unchecked: those it registered a check for, and those
it names in a module-level GATEWAY_SERVERS, which also covers a
register() that fails before reaching its check;
- strings only outgoing() added are replaced with [redacted] in the
answer, should the server quote the arguments back.
Server names are single-_-separated letters and digits ("shop_" would
never match, as "shop___x" splits as "shop"); the built-in servers
(google_workspace, microsoft_365, fetch) take no check, since core gates
them.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
This pull request now has merge conflicts with |
| # something in what goes out that is good for one request only. | ||
| if server_call is None or server_call.outgoing is None: | ||
| return {"tool_name": tool_input["name"], "arguments": wire_arguments} | ||
| outgoing = _extension_outgoing(tool_name, server_call, arguments) |
There was a problem hiding this comment.
🔴 When the check returns outgoing, the wire arguments are rebuilt from arguments (the model's original dict) and wire_arguments is dropped. The line just above (wire_arguments = disclosed) shows that later gates rewrite wire_arguments. So for an extension server that supplies outgoing, every transformation or redaction those gates make is discarded and the model's raw arguments are sent.
Failing scenario: an extension registers outgoing=lambda a: {**a, "token": mint()}. The model calls shop__send with a body that a later gate would have rewritten or stripped, such as a disclosure rewrite. wire_arguments holds the cleaned version, but _wire_input() sends arguments plus the token, so the gate is bypassed.
The PR says the arguments are built "after every gate", but they are built from the pre-gate input. Pass wire_arguments to _extension_outgoing, and add a test where a downstream gate changes the arguments and outgoing still receives the gated version.
|
Claude review of this fork PR, requested by @johnrufusone: 🔴 1 must fix · 🟡 0 optional · 🟣 0 pre-existing. Push again and it needs a fresh |
johnrufusone
left a comment
There was a problem hiding this comment.
Please resolve comments
Problem
An extension that brings its own MCP server has no way to gate calls to it: to refuse a call (who is asking, which tool), or to add something to what goes out (a per-request token naming the caller). Today that means patching
mcp_gateway.call_tool.Approach
extensions.register_gateway_check(GatewayCheck(server, check))adds one check per server (<server>__*).call_toolasks it after its own refusals and before any per-tool gate. The check returns aGatewayCall:refusal, the call is refused and audited asintegration_outbound_blocked;outgoing, the arguments sent are built after every gate and again for each send, so a retry never replays a one-request token.It fails closed:
load_mcp_serverwon't take such a name either.outgoingadded are redacted from the answer.The built-in servers take no check.
agents.json,integrations.jsonandarchitecture-facts.yamldescribe it.Checklist
pytest packages/core/tests/unit/)ruff checkandmypypass (make lint)cd packages/ui && npm run build) — UI not touched(see the "Architecture Docs" section in
CLAUDE.md)🤖 Generated with Claude Code