External Agent Gateway (A2A + MCP)¶
Context and Problem Statement¶
backend-core already fronts external LLM providers behind one selectable
surface — the LLM gateway (/v1, ARC-ADR-021) — so the browser never holds a
key, every call is guardrailed + rate-limited + audited, and adding a new
provider is a registry row, not a code change. We now want the same shape
for external agents and tool servers:
- A2A — Google Agent-to-Agent protocol (JSON-RPC; AgentCard at
.well-known/agent.json; methodsmessage/send,tasks/get). - MCP — Model Context Protocol (JSON-RPC; methods
tools/list,tools/call).
These are two protocols, but isomorphic on the wire: a named capability that takes JSON in and returns JSON out. Today consumers wire each new agent into middle-core by hand. That spreads credentials across spokes, scatters policy, makes safe selection impossible, and admits the "tool-card injection" attack where a hostile upstream description prompt-injects the orchestrator the first time it surfaces a tool menu.
How should we structure an external agent gateway that lets us select agents easily, route through one safe surface, and add new agents without code changes?
Decision Drivers¶
- Same pattern as the LLM gateway — operators and consumers shouldn't have to learn a new shape; new agents are a registry row, not code.
- Two protocols, one surface — A2A skills and MCP tools differ in discovery semantics but not in invocation semantics; one router with two thin adapters wins over two parallel surfaces.
- Selectability — the UI / orchestrator must be able to filter agents and tools by tag / kind / role without a round-trip to the upstream.
- Safety — credentials by pointer (never plaintext at rest), SSRF on every outbound, RBAC + per-agent role allow-list, per-subject rate limit + daily budget, and tool-card injection scanning (the agent-gateway-specific guardrail) on every description surfaced to the orchestrator.
- Contract-first — vendor a dedicated
agent-gateway.openapi.yamlso middle-core / frontend-core codegen against a stable shape. - Extractable — like the LLM gateway, the module is isolated so it can later run as its own function-tier container (ARC-ADR-023).
Considered Options¶
- Add a single combined gateway for A2A and MCP (recommended) — one registry, one router, two adapters; agents are a config row.
- Two separate gateways (
/a2a/v1+/mcp/v1) — purer separation but doubles the policy / audit / RBAC surface for the same wire concept. - Wire each agent into middle-core directly — fastest first integration but loses the safety / selectability / centralisation benefits the LLM gateway already proved out.
- Adopt a vendor agent-orchestration framework end-to-end — e.g. LangGraph or CrewAI as the gateway — couples backend-core to a heavy framework and conflates orchestration (middle-core's job) with the network boundary (backend-core's job).
Decision Outcome¶
Chosen: Option 1 — one combined agent gateway at /a2a/v1, with two
adapters under the same router and registry. The router never knows whether
an agent speaks A2A or MCP; only the client adapter does.
Architecture¶
- Registry (
app/agents/config.py): AgentTargetdataclass:slug,name,kind(a2a|mcp),base_url,secret_ref,tags,capability_hints,allowed_roles,timeout_seconds,auth_scheme.- Default targets +
AGENT_GATEWAY_AGENTSJSON env override (same pattern asLLM_GATEWAY_PROVIDERS). - Client adapters (
app/agents/client.py): - One shared
httpx.AsyncClient, lazy import. _mcp_list_tools/_mcp_call_toolfor MCP._a2a_fetch_card/_a2a_list_skills/_a2a_send_message/_a2a_get_taskfor A2A.- One normalised public surface —
list_skills(target),invoke(target, capability, args),get_task(target, id). - Guardrails (
app/agents/guardrails.py): - Reuses the LLM gateway's
_INJECTION_PATTERNS(one source of truth). - Adds
scrub_description(tool-card injection) andscrub_input(rejects injection in caller-supplied tool arguments). - Per-subject rate limit + daily budget mirroring the LLM gateway.
- Router (
app/agents/router.py) —/a2a/v1/*surface: GET /agents,GET /agents/{slug}— selection-first discovery.GET /tools— flat cross-agent inventory.POST /agents/{slug}/invoke— sync invoke.POST /agents/{slug}/tasks+GET /agents/{slug}/tasks/{task_id}— async lifecycle (native for A2A, emulated locally for MCP).- Standalone entrypoint (
app/main_agent_gateway.py) — function-tier container, parallelsmain_llm_gateway.py. - Contract artifact —
contracts/agent-gateway.openapi.yamlextracted from the regenerated backend-core OpenAPI, parallelsllm-gateway.openapi.yaml.
Safety boundaries (defence-in-depth)¶
| Layer | Control |
|---|---|
| Network | SSRF guard on every outbound base_url (re-uses app.ssrf) |
| Identity | Route RBAC (reader / contributor / admin) |
| Authorisation | Per-agent allowed_roles allow-list |
| Cost | Per-subject rate limit + daily invocation budget |
| Input | scrub_input — recursive injection-heuristic scan on tool arguments |
| Output | scrub_description — blanks any tool/agent description that matches an injection heuristic; blocked=true surfaced to callers |
| Secrets | secret_ref only — resolved at use-time inside one helper, never stored on the target object (semgrep no-persisted-credential enforced) |
| Errors | Upstream failures normalised to UpstreamAgentError → mapped to RFC-9457 problem responses |
Consequences¶
Good:
- Adding a new agent is a registry row (or one env-JSON entry); no code change
in middle-core or the gateway.
- One place to audit / rate-limit / kill-switch (AGENT_GATEWAY_ENABLED=false).
- Tool-card injection is contained at the network boundary, before the
orchestrator ever sees the description.
- Mirrors the LLM gateway exactly — operators and reviewers know the shape.
Bad / risks:
- A bad upstream can still produce malicious tool outputs that the
orchestrator processes; output scrubbing for tool-call results is a future
layer (the WebSearchResult.blocked pattern can be extended here).
- The in-process task store and rate-limit buckets are per-replica; scaling
the gateway horizontally requires externalising both (ArcadeDB or Redis).
- Streaming (A2A message/stream) is not in v1 — added later with the
pre-flighted SSE pattern used by /v1/chat/completions.
Acceptance criteria¶
/a2a/v1paths land in the regenerated backend-core OpenAPI without contract drift.agent-gateway.openapi.yamlextractor produces a valid OpenAPI document (openapi-spec-validator).no-persisted-credentialsemgrep rule stays green.- Test suite covers registry, both adapters, guardrails, and the HTTP surface with fakes (no live agents required in CI).
Phased Plan¶
- v1 (this ADR) — registry + A2A/MCP adapters + sync invoke + emulated tasks + tool-card scanning + standalone entrypoint + contract artifact.
- Streaming — A2A
message/stream+ MCP SSE transport with the same pre-flight pattern as the LLM gateway's chat streaming. - Output scrubbing — extend
scan_tool_results(LLM gateway) to agent tool results so an upstream's reply cannot inject the orchestrator. - External rate-limit / task store — back the in-process counters with ArcadeDB so the gateway scales horizontally.
More Information¶
- Parallels ARC-ADR-021 (LLM gateway in backend-core for guardrails) and ARC-ADR-023 (function-tier containers).
- Uses the secrets / SSRF / audit machinery already in place from ADR 0001.
- A2A protocol reference: https://a2a-protocol.org
- MCP protocol reference: https://modelcontextprotocol.io