Skip to content
 
 

Latest commit

 

History

1,854 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Teaching an MCP gateway to speak Agent2Agent

A2A Protocol Support : Exploration Fork

Design Doc Tracking Issue Test Server A2A Spec LFX Mentorship

Important

This is a workshop fork of Kuadrant/mcp-gateway (the original project README lives there). The agreed home for this work is upstream.., the design landed via #1114 (merged), and code now upstreams incrementally, in phases — the router-only passthrough cut landing first (behind an experimental --enable-a2a flag), now merged as #1371 (e2e in review as #1418), with registration/discovery and task ownership following as later phases. The fork is where the whole design was proven end to end ; each phase carves the minimal upstream slice from it. The fork exists so A2A exploration can move fast without carrying MCP regression risk into the main repo ; workshop in the fork, home in-tree.

This fork is where I'm prototyping Agent2Agent (A2A) protocol support for Kuadrant's MCP Gateway, as part of the CNCF LFX mentorship "Prototype A2A protocol support in the agentic gateway" (2026 Term 2), mentored by the Kuadrant maintainers. Everything here traces back to an upstream artifact : the design doc, the test server, the review threads and this README is the map.

The problem, in one paragraph

The MCP Gateway handles the vertical axis of agentic workloads.., a single client consuming federated tools from many upstream MCP servers, with Kuadrant's AuthPolicy, RateLimitPolicy, and observability wrapped around every call. But as agentic architectures grow, a horizontal axis emerges: agents delegating long-running work to other agents, discovering peer capabilities, coordinating over tasks that run for seconds or days. That's what the A2A protocol standardizes ; and today, that traffic bypasses the gateway entirely. No auth, no rate limits, no discovery, no audit trail. Every agent-to-agent delegation is a direct connection outside the policy perimeter. This project puts it back inside.

flowchart LR
    subgraph vertical["MCP — the vertical axis (today)"]
        C1[Client] -->|tools/call| G1[Gateway]
        G1 --> S1[MCP Server A]
        G1 --> S2[MCP Server B]
    end
    subgraph horizontal["A2A — the horizontal axis (this work)"]
        A1[Agent] -->|SendMessage| G2[Gateway]
        G2 --> A2[Weather Agent]
        G2 --> A3[Search Agent]
        A2 -.long-running task.-> A1
    end
Loading

A2A in two minutes

A2A is an open protocol (originally Google, donated to the Linux Foundation, now at v1.0) for communication between opaque agents — agents that collaborate without sharing internal memory, tools, or logic. The pieces that matter for a gateway:

  • Agent Cards .., a JSON manifest served at a well-known path describing what an agent can do (skills), how to reach it (url / supportedInterfaces), and how to authenticate (securitySchemes). Discovery is card-driven: a client reads the card and sends work to whatever URL it advertises.
  • Tasks .., the unit of work. A SendMessage creates a task that moves through a lifecycle (submitted → working → completed/failed/canceled, with input-required and auth-required detours) and may outlive the request that created it by hours or days. Clients poll with GetTask, cancel with CancelTask.
  • Streaming .., SendStreamingMessage subscribes the client to real-time task updates over SSE, carrying multi-modal artifacts (text, files, structured data) as they're produced ; SubscribeToTask reconnects a dropped stream.
  • It complements MCP, not competes : MCP standardizes agent-to-tool, A2A standardizes agent-to-agent. A gateway that already routes one is halfway to routing both.
v0.3.0 -> v1.0: what changed (and why this fork targets v1.0)

The spec moved under us mid-project, and the maintainer review agreed there's little value building against an already-superseded line. The v1.0 deltas we've verified against the spec repo:

Surface v0.3.0 v1.0
Send message/send SendMessage (blocking by default)
Stream message/stream SendStreamingMessage
Task fetch / cancel tasks/get / tasks/cancel GetTask / CancelTask (+ ListTasks)
Resubscribe tasks/resubscribe SubscribeToTask
Well-known card path /.well-known/agent-card.json /.well-known/agent-card.json (unchanged)
Card endpoint field top-level url supportedInterfaces[] (+ tenant for multi-agent hosting)
Security fields — camelCase JSON: securitySchemes map, securityRequirements
Card integrity unsigned JWS signatures over the JCS-canonicalized card
Canonical definition JSON schema protobuf (a2a.proto) ; JSON-RPC binding uses PascalCase methods

The architecture is version-agnostic — routing, task-ID mapping, CRD, policy attachment all survive the rename. The version-specific surface (method names, well-known path, card shape) is isolated behind one mapping so the version is never load-bearing. That's the design's answer to a fast-moving spec.

Why a gateway should carry this traffic

The durable value isn't protocol plumbing.., it's that inter-agent traffic picks up the same Kuadrant policy plane MCP traffic already has, with zero gateway code per policy: AuthPolicy (OIDC/JWT via Authorino) for who may talk to which agent, RateLimitPolicy (Limitador) for how often, OpenTelemetry traces stitching a task's whole lifecycle across requests, and centralized discovery so clients never need upstream addresses. Kuadrant policies attach to Gateway API HTTPRoutes ; that one fact drives most of the design below.

How it works

Two flows carry the whole story. Discovery, the broker serves each registered agent's signed card verbatim and advertises the gateway path through an RFC 9727 catalog, so unmodified A2A clients route through the gateway without the card's signature ever being touched:

sequenceDiagram
    participant Client
    participant Gateway as Gateway (Envoy)
    participant Broker
    participant Agent as Upstream Agent

    Client->>Gateway: GET /.well-known/api-catalog
    Gateway->>Broker: (RFC 9727 catalog)
    Broker-->>Client: links: [/a2a/mcp-test/weather, /a2a/mcp-test/search]
    Client->>Gateway: GET /a2a/mcp-test/weather/.well-known/agent-card.json
    Broker->>Agent: periodic card refresh (ticker, conditional GET)
    Note over Broker: serve the signed card verbatim<br/>(a rewrite would void the JWS signature)
    Broker-->>Client: AgentCard (signature intact) — catalog link routes to the gateway
Loading

And invocation.., the ext_proc router detects A2A by path prefix and routes to the right upstream. The path carries the routing, so the agent's own task IDs pass through unchanged — the router records (agent, task ID) → principal for ownership and tracing, but never rewrites what the client sees:

sequenceDiagram
    participant Client
    participant Envoy
    participant Router as ext_proc Router
    participant Agent as Upstream Agent

    Client->>Envoy: POST /a2a/mcp-test/weather {method: SendMessage}
    Envoy->>Router: RequestHeaders (:path = /a2a/mcp-test/weather)
    Note over Router: detect A2A by path prefix<br/>resolve agent, set :authority
    Envoy->>Router: RequestBody
    Note over Router: authenticate the principal (sub)
    Envoy->>Agent: forward
    Agent-->>Envoy: 200 {result: {id: task-abc}}
    Envoy->>Router: ResponseBody
    Note over Router: record (agent, task-abc) → principal<br/>id passed through, not rewritten
    Envoy-->>Client: {result: {id: task-abc}}
Loading

How we got here

timeline
    title From PoC to workshop
    May 2026 : Working PoC — federated agent-card broker (upstream PR 986, since superseded)
    Early June : Design doc opened upstream (PR 1114) with the open questions flagged for mentors
    Mid June : Line-by-line v0.3.0 spec pass — message/send carries NO skill field : pivot to path-per-agent routing + card url rewriting
    Late June : A2A test server built (PR 1200) — SSE keepalives, heavy multi-modal artifacts, enforced auth modes, deterministic task states
    June 29 : Nine-point maintainer review — v1.0 target, tenant field, signed cards, fork workflow
    July 1 : Revised design up ; two OPEN decisions remain (v1.0 confirm, namespace-qualified paths)
    July 2 : This fork opens for business — spike 1, per-method response ModeOverride : Verified same day against real Envoy — BUFFERED + STREAMED honored mid-request, content-length constraint found + recorded
    July 5 : CRD + controller merged (PR 3) — 56/56 envtest specs, live-verified on Kind, full upstream CI green : Cross-namespace registration gated behind ReferenceGrant consent ; revoking a grant withdraws the config, not just the status
    July 7 : Both open design questions resolved by the maintainers — v1.0 is the target, and paths are namespace-qualified (/a2a/{namespace}/{prefix}) to kill cross-namespace collisions : design doc migrated to the v1.0 surface (method names, well-known path, JWS-signed cards served verbatim)
    July 8 : Design doc corrected to the exact v1.0 wire (well-known path, camelCase fields, streaming envelopes) : A2A test server migrated to the v1.0 ProtoJSON surface — PascalCase methods, TASK_STATE_* enums, flat parts, StreamResponse envelopes — every field verified against the canonical proto
    Mid July : Fork housekeeping merged — credential-rotation controller tests (#14) and the credential-label doc (#15) : upstream's api/v1 gateway promotion adopted via a sync PR (#20), A2A kept at v1alpha1 (the right lifecycle for a new surface)
    July 12 : Design doc sharpened for review — explicit signed-card discovery contract (verbatim + fail-closed), RFC 9264 linkset shape, session model decoupled from MCP's stateless cut ; review requested from David : discovery steel thread underway (#19) — pluggable card store + runtime a2aAgents config layer landed, broker + catalog next
    Mid July : Discovery steel thread completed — card manager (ticker + conditional GET + SHA-256, stale-on-error), verbatim card serving, RFC 9727 catalog, binary + gateway-route wiring : controller hardened — within-namespace agentPrefix collision (deterministic oldest-wins), multi-namespace fan-out coverage, external-agent via Hostname backendRef
    July 17 : Discovery runs end to end on Kind — register an agent, catalog lists it on a hot reload, the card is served byte-for-byte identical to upstream (verbatim/JWS-safe), MCP untouched throughout ; captured as a one-command demo : task-ID model settled with David — path-per-agent already carries the routing, so IDs pass through and the risky body rewrite is dropped
    Late July : Router built + live-verified on Kind — namespace-qualified routing, per-request auth, task-ID passthrough, insert-only ownership store, read-only SSE observer ; invocation + discovery e2e specs merged : test server rebuilt clean on upstream main and approved
    Aug 4 : Design doc merged upstream (#1114) — David approves, with an implementation-phases section landing it incrementally behind an experimental --enable-a2a flag
    Aug 5 : First cut scoped with David + Craig (#1333) — router-only A2A passthrough for auditing, auth and observability : no new CRDs (Craig's concern) and no broker changes (mid-churn on the new MCP spec), so phase 1 touches only the router
Loading

The pivot in the middle is the story worth telling: the original design routed by reading a skill out of the message/send body, and the spec pass revealed that field doesn't exist — MessageSendParams is {message, configuration, metadata}, skills live only in the card. So routing moved to a path per agent (/a2a/{namespace}/{prefix}), which is also what agentgateway converged on, and which turns out to be Kuadrant-optimal anyway.., policies attach to HTTPRoutes, and a path per agent means an operator can attach a distinct AuthPolicy and RateLimitPolicy per agent. The protocol forced a change that made the design better.

Where everything lives

Workstream Where State
Design doc (routing, CRD, card serving, auth, task store) Kuadrant#1114 merged — approved by David ; the ratified design, with an implementation-phases section landing it incrementally behind --enable-a2a (phase 1 passthrough ; registration/discovery and ownership as phases 2–3)
A2A passthrough first cut (router only) Kuadrant#1371 merged — --enable-a2a parses /a2a traffic and lifts x-a2a-method/x-a2a-agent into headers for Istio Telemetry + AuthPolicy ; no CRD, no catalog, ownership deferred to upstream agents ; e2e coverage in review as #1418
A2A test server (e2e target) Kuadrant#1200 approved — rebuilt clean on upstream main (v1.0 ProtoJSON wire, proto-verified against a2a.proto@v1.0.1), David-approved, CI green
Original PoC (federated card broker) Kuadrant#986 closed... pre-pivot, superseded by the design
Spike 1 — per-method response ModeOverride this fork, PR #1 merged : verified against real Envoy, BUFFERED + STREAMED both honored mid-request ; surfaced the content-length constraint (recorded in the design doc)
CRD + controller (A2AAgentRegistration) this fork, #3 + hardening merged : 56/56 envtest specs, live-verified on Kind, consent-gated cross-namespace with revocation withdrawal ; hardened — within-namespace prefix collision (#8), multi-namespace fan-out coverage (#5), external-agent via Hostname backendRef (#6)
Upstream sync + fork hygiene this fork, #20 / #14 / #15 merged : adopted upstream's api/v1 gateway promotion (A2A stays v1alpha1) ; credential-rotation controller tests + the credential-label doc
Discovery steel thread (card cache + catalog + wiring) this fork, #19/#21/#28/#22 merged + live : pluggable card store, runtime a2aAgents config, A2AAgentManager (ticker + conditional GET + SHA-256, stale-on-error, refresh-on-change), verbatim card serving, RFC 9727 catalog, binary + gateway-route wiring
End-to-end demos this fork, #31 + demos/a2a-invocation merged : demos/a2a-discovery/demo.sh (register → catalog hot-reload → card byte-identical → deregister → MCP regression) and demos/a2a-invocation/demo.sh (SendMessage routed → completed, task-ID passthrough, streaming, fail-closed rejects) — both verified live on Kind
Router (invocation + task store + SSE observer) this fork, #42/#44/#45 merged + live-verified : namespace-qualified routing, per-request auth, task-ID passthrough (settled with David — the path carries routing, no ID rewrite), insert-only (agent, id) → principal ownership store, read-only SSE observer. The full prototype ; upstream landed its phase-1 subset via #1371
Stretch + mentor-gated backlog issues deferred scope, each with its why.., plus two follow-ups the live run surfaced (#27 fail-closed card check, #30 refresh-on-change ✓)

The plan

gantt
    title Twelve weeks, three phases
    dateFormat YYYY-MM-DD
    section Phase 1 — design
    Design doc + gap analysis (1114)      :done,   p1a, 2026-06-01, 2026-06-27
    A2A test server (1200)                :done,   p1b, 2026-06-20, 2026-06-28
    section Phase 2 — build (fork)
    Spike ModeOverride                    :done,   p2a, 2026-07-01, 2026-07-02
    CRD + controller                      :done,   p2b, 2026-07-02, 2026-07-05
    Upstream api/v1 sync                  :done,   p2s, 2026-07-12, 1d
    Broker card serving + catalog         :done,   p2c, 2026-07-12, 2026-07-17
    Discovery live + demo                 :done,   p2e, 2026-07-17, 1d
    Router routing + task-ID passthrough  :done,   p2d, 2026-07-17, 2026-07-24
    SSE observer (read-only)              :done,   p2f, 2026-07-24, 2026-07-26
    section Phase 3 — prove & upstream
    Invocation + discovery e2e            :done,   p3a, 2026-07-22, 2026-07-28
    Design merged upstream (1114)         :done,   p3b, 2026-08-04, 1d
    Phase 1 passthrough merged (1371)     :done,   p3c, 2026-08-05, 2026-08-20
    Phase 1 e2e in review (1418)          :active, p3d, 2026-08-21, 2026-08-27
Loading

Landing upstream, in phases. The fork proved the whole design ; upstream takes it incrementally, gated behind an experimental --enable-a2a flag (default off) so nothing A2A touches the mature MCP path unless a user opts in. Phase 1 — router-only passthrough for auditing, auth and observability (#1333) : parse /a2a traffic, lift x-a2a-method/x-a2a-agent into headers for Istio Telemetry and AuthPolicy, no CRD, no catalog, task ownership deferred to the upstream agents. Phase 2 — the A2AAgentRegistration CRD, controller and broker card serving/catalog (gated on where A2A lands long-term and on the broker settling after the new MCP spec work). Phase 3 — gateway-side task ownership and SSE lifecycle observation. Phases 2–3 already exist, proven, in this fork.

  • Analysis of A2A vs MCP traffic patterns (request/response vs long-running tasks, push, multi-modal artifacts)
  • Design doc: ext_proc routing, federated card serving, session implications, CRD design
  • Deterministic A2A test server for e2e
  • Spike: mid-request response mode change (the one piece the review flagged as "haven't seen it done before... good to derisk early") — verified, works ; one constraint found and recorded
  • A2AAgentRegistration CRD + controller (config fan-out per gateway namespace); merged ahead of plan ; immutable identity fields, ReferenceGrant-gated cross-namespace, revocation withdraws config
  • Broker: card cache behind a pluggable interface, RFC 9727 catalog endpoint — merged and running end to end on Kind (demo)
  • Router: namespace-qualified path-per-agent routing, per-request auth, task-ID passthrough with a (agent, id) → principal ownership record — merged + live-verified on Kind (demo) ; read-only SSE observer alongside it
  • E2E: discovery + invocation specs (routing, streaming, fail-closed rejects, MCP regression) — both suites merged in-fork
  • Upstream the phase-1 passthrough cut — merged as #1371 (router + --enable-a2a + guide) ; e2e coverage in review as #1418 ; the design (#1114) and test server (#1200) were already upstream
  • Upstream phases 2–3 (registration/discovery, then task ownership) — proven in-fork, gated on the CRD's long-term home and the broker settling after the new MCP spec work

The design is merged upstream and the whole design is proven in-fork ; the router-only passthrough cut has now landed upstream (#1371, with e2e in review as #1418) — what remains is upstreaming the later phases (registration/discovery, then task ownership).

Design decisions, and why

1 : Path-per-agent routing, not skill dispatch

The protocol never routes by skill... no skill field exists in the send request (both spec versions). The two honest options were a path per agent or a custom header ; a header only works for clients we've specifically taught about the gateway, while a path works for any stock A2A client, because clients already POST to whatever URL discovery advertises. The catalog the gateway serves points at /a2a/{namespace}/{prefix}; so the routing key lives in discovery, not in anything the client has to be told. The path is namespace-qualified (confirmed by the maintainers) so two agents sharing a prefix across different namespaces can never collide. It's also what agentgateway (Linux Foundation) does, and it gives each agent its own HTTPRoute for per-agent policy attachment.

2 : Serve signed cards verbatim, route by path — don't rewrite the card

The obvious move was to rewrite the served card's url to the gateway path, so an unmodified client reading the card routes back through the gateway rather than talking directly to the agent and silently bypassing the policy perimeter (no AuthPolicy, no rate limits, no logs). That works right up until v1.0: cards can carry JWS signatures over the JCS-canonicalized card, and the signature covers the URL — rewriting the card invalidates its signature.

So with v1.0 now the confirmed target, the design serves signed cards verbatim and moves the routing key out of the card entirely: the RFC 9727 catalog advertises the gateway endpoint (/a2a/{namespace}/{prefix}), and v1.0's tenant field — which exists precisely for multiple agents behind one endpoint — carries the per-agent selector. The one residual dependency, that clients discover via the catalog rather than the card's own interface URL, is stated in the design with its two clean resolutions rather than hand-waved.

3 : Task IDs pass through — the gateway doesn't own them

The first design had the gateway mint its own task ID and rewrite the upstream's out of every response — the same way it rewrites MCP session IDs. A maintainer's question unpicked it : that only earns its keep if the gateway routes by the task ID, and it doesn't — path-per-agent already carries the routing, so a GetTask is addressed to /a2a/{namespace}/{prefix} and the agent is resolved from the path. The ID only has to be unique within an agent, which the agent guarantees. And the MCP-session parallel doesn't transfer : the gateway owns session IDs because one client session fans out across several backends — a task lives on exactly one agent, no fan-out, nothing to multiplex.

So task IDs pass through unchanged. The gateway keeps an internal (agent, id) → principal record so GetTask/CancelTask can verify the caller owns the task, and so a task's whole lifecycle correlates in traces — but it never rewrites what the client sees. The payoff : the single riskiest piece of the build — the buffered/SSE task-ID body rewrite — simply disappears. A maintainer's question made the design smaller.

4 : The spike that let us cut the rewrite (mid-request response mode)

Before the task-ID model settled, rewriting IDs meant flipping Envoy's response body mode mid-request — BUFFERED to rewrite a whole non-streaming body, STREAMED to touch SSE chunks as they arrive — and since the method is only known at the request-body phase, the flip has to happen at response-headers via ext_proc ModeOverride. That was the review's flagged unknown ("haven't seen it done before... good to derisk early"), so it became spike 1 : verified against real Envoy (Istio 1.27), both directions honored mid-request, and it surfaced one constraint — a buffered rewrite changes the body length, so content-length must be stripped in the same response, or Envoy fails closed. Then decision #3 dropped the task-ID rewrite — but not the mode flip itself : the filter's default response body mode is NONE, so the router only sees a response body when it asks for one, and binding a task to its creator means reading the agent's task ID out of the SendMessage response. So the override stays on the critical path as a read-only tap — BUFFERED to observe, never to mutate — and since nothing changes the body, the content-length constraint the spike surfaced never applies ; that half sits in reserve for any future body rewrite (the push-notification relay, response filtering). De-risking early paid off twice : the confidence to choose passthrough knowing the harder path was viable, and the exact mechanism the ownership record now rides on. Transcripts in PR #1.

5 : Two auth paths that must never mix

Card fetching (broker → agent, no client involved) uses the registration's credentialRef; a static credential the router can never see. Task invocation (a real client behind every call) forwards the client's identity; bearer pass-through or, recommended, RFC 8693 token exchange re-audienced to the agent via Authorino. Injecting the gateway's static credential into client calls would be the classic confused-deputy: the agent loses the caller's identity and a low-privilege client rides the gateway's credential. Same split MCP already enforces, for the same reason.

6 : Cross-namespace registration needs the route namespace's consent

Being able to create a registration in namespace A is not permission to expose namespace B's agent through the gateway — that would let a tenant register another tenant's backend with no signal and no veto. So a cross-namespace targetRef requires a ReferenceGrant in the route's namespace (from: A2AAgentRegistration, to: HTTPRoute), the Gateway API's own consent primitive and the same model the extension controller already uses.., a boundary the maintainers held firm on for the sibling MCP fix, adopted here from day one. The controller watches grants, so consent takes effect within a reconcile in both directions ; and crucially, revoking a grant withdraws the agent's config, not just the status, everywhere else config is last-known-good on failure (a transient error must never rip a live agent out of the data plane), but consent is an explicit state, and consent withdrawn means exposure withdrawn. Identity fields (agentPrefix, targetRef) are immutable by CEL for the same reason: a retarget across gateways would require cleaning stale namespace fan-out config from the previous target, so replacing an agent means replacing the registration.., blue/green swaps happen at the HTTPRoute's backendRef, which the controller watches.

Reading list

The primary sources this work leans on : the A2A specification and what's new in v1.0 ; RFC 9727 (api-catalog well-known URI) and RFC 9264 (Linkset) for discovery ; agentgateway as prior art for route-per-agent (it rewrites cards ; we serve them verbatim) ; the upstream design doc where the decisions above are argued in full ; and Kuadrant's own MCP Gateway docs for the platform this extends.


Everything here is headed upstream to Kuadrant/mcp-gateway if any of it interests you, the design discussion on #1114 is the room where it's happening, and pushback is genuinely welcome 🙂

About

This is a workshop fork of Kuadrant/mcp-gateway

Resources

Contributing

Stars

1 star

Watchers

1 watching

Forks

Releases

Packages

Contributors

Languages