Skip to content

feat(orchestrator): let extensions gate calls to their own MCP server - #578

Open
ladislav-urban wants to merge 1 commit into
SenteLabsAI:mainfrom
ladislav-urban:feat/gateway-server-hooks
Open

ladislav-urban wants to merge 1 commit into
SenteLabsAI:mainfrom
ladislav-urban:feat/gateway-server-hooks

Conversation

@ladislav-urban

Copy link
Copy Markdown
Contributor

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_tool asks it after its own refusals and before any per-tool gate. The check returns a GatewayCall:

  • with refusal, the call is refused and audited as integration_outbound_blocked;
  • with 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:

  • A broken check refuses the call.
  • A name that only normalizes to a checked server is refused, because the proxy routes by the exact name. load_mcp_server won't take such a name either.
  • A failed extension leaves its servers refused rather than unchecked.
  • Values that only outgoing added are redacted from the answer.

The built-in servers take no check. agents.json, integrations.json and architecture-facts.yaml describe it.

Checklist

  • Working implementation — no stubs or TODO placeholders
  • Tests added/updated for new behavior (pytest packages/core/tests/unit/)
  • ruff check and mypy pass (make lint)
  • UI builds if touched (cd packages/ui && npm run build) — UI not touched
  • Eval scenarios added for a new agent or prompt change (if applicable) — no new agent or prompt change
  • Architecture docs updated if a documented topic changed
    (see the "Architecture Docs" section in CLAUDE.md)
  • No secrets, credentials, or personal data committed

🤖 Generated with Claude Code

…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>
@github-actions

Copy link
Copy Markdown

This pull request now has merge conflicts with main (after 0bb0d5a). GitHub won't run CI until they're resolved: merge main into this branch, fix the conflicts and push.

# 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)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 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.

@github-actions

Copy link
Copy Markdown

Claude review of this fork PR, requested by @johnrufusone: 🔴 1 must fix · 🟡 0 optional · 🟣 0 pre-existing. Push again and it needs a fresh claude-review label to be re-reviewed.

@johnrufusone johnrufusone left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please resolve comments

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants