Skip to content

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; methods message/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.yaml so 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

  1. Add a single combined gateway for A2A and MCP (recommended) — one registry, one router, two adapters; agents are a config row.
  2. Two separate gateways (/a2a/v1 + /mcp/v1) — purer separation but doubles the policy / audit / RBAC surface for the same wire concept.
  3. Wire each agent into middle-core directly — fastest first integration but loses the safety / selectability / centralisation benefits the LLM gateway already proved out.
  4. 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):
  • AgentTarget dataclass: slug, name, kind (a2a | mcp), base_url, secret_ref, tags, capability_hints, allowed_roles, timeout_seconds, auth_scheme.
  • Default targets + AGENT_GATEWAY_AGENTS JSON env override (same pattern as LLM_GATEWAY_PROVIDERS).
  • Client adapters (app/agents/client.py):
  • One shared httpx.AsyncClient, lazy import.
  • _mcp_list_tools / _mcp_call_tool for MCP.
  • _a2a_fetch_card / _a2a_list_skills / _a2a_send_message / _a2a_get_task for 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) and scrub_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, parallels main_llm_gateway.py.
  • Contract artifactcontracts/agent-gateway.openapi.yaml extracted from the regenerated backend-core OpenAPI, parallels llm-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/v1 paths land in the regenerated backend-core OpenAPI without contract drift.
  • agent-gateway.openapi.yaml extractor produces a valid OpenAPI document (openapi-spec-validator).
  • no-persisted-credential semgrep rule stays green.
  • Test suite covers registry, both adapters, guardrails, and the HTTP surface with fakes (no live agents required in CI).

Phased Plan

  1. v1 (this ADR) — registry + A2A/MCP adapters + sync invoke + emulated tasks + tool-card scanning + standalone entrypoint + contract artifact.
  2. Streaming — A2A message/stream + MCP SSE transport with the same pre-flight pattern as the LLM gateway's chat streaming.
  3. Output scrubbing — extend scan_tool_results (LLM gateway) to agent tool results so an upstream's reply cannot inject the orchestrator.
  4. 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