A2A and MCP are often described as complementary, and the one-line summary is accurate: MCP standardises how an agent uses a tool or a data source, and A2A standardises how one agent delegates work to another agent it does not control. The summary stops being useful the moment you build something real. An MCP-only coding assistant wants a remote A2A legal agent; a platform team wants one front door for both. Something must translate, and it has to decide what a task is, who the user is, and what happens when a connection drops halfway through.
This article treats interop as an engineering problem. It covers the four topologies you will meet, a field-by-field mapping between A2A 1.0 and MCP, a bridge that exposes an A2A agent as an MCP tool, how identity and trace context cross the hop, and the specific ways bridges fail. Wire details were checked against A2A 1.0.0 and MCP revisions 2025-11-25 and 2026-07-28; many hosts still speak the older one, so a bridge must handle both.
Two protocols, two jobs
MCP connects a host application (an IDE, a chat client, an agent runtime) to servers that offer tools, resources and prompts. The host's model decides which tool to call; the server executes it and returns content. The server is a capability, not a peer.
A2A connects an agent to another agent that may belong to another team or company. The remote agent publishes an Agent Card at /.well-known/agent-card.json describing its skills, the interfaces it serves (supportedInterfaces, each with a url and a protocolBinding of JSONRPC, GRPC or HTTP+JSON) and its security schemes. The caller sends messages with SendMessage or SendStreamingMessage; the remote agent may answer with a direct message or create a task that moves through TASK_STATE_* states and produces artifacts. The remote agent stays opaque.
So the boundary is ownership: if you control it and your model drives it step by step, it is a tool (MCP); if someone else owns it and you want an outcome, it is an agent (A2A). Interop problems appear when a component and its caller sit on opposite sides of that line.
The four topologies
Almost every deployment is one of four shapes, shown in Figure 1.
- A. MCP inside an A2A agent. The agent is an A2A server on the outside and an MCP host on the inside. Its tools never appear on the A2A wire. This needs no bridge and should be your default.
- B. A2A agent behind an MCP tool. An MCP server exposes a tool such as
contract_review; calling it sends an A2A message to a remote agent. This is how MCP-only hosts gain access to agents. The bridge owns a lifecycle mapping and is the subject of most of this article. - C. MCP server published as an A2A agent. A thin agent wraps a set of MCP tools behind a few coarse skills, usually with a small model or fixed logic choosing which tools to call. Do this when outside callers should get outcomes, not raw tool access.
- D. Dual-protocol gateway. One service terminates both protocols, authenticates callers, applies policy and routes to backends. It is B and C run as shared infrastructure, with the same mapping rules applied in one place.
Mapping the vocabularies
Both use JSON-RPC 2.0, but almost every concept differs a little. Table 1 is the mapping a topology B bridge implements.
| A2A 1.0 | MCP | Translation note |
|---|---|---|
| Agent Card skills | tools/list entries | One tool per skill; write the input schema by hand, because skills carry descriptions and examples, not JSON Schema |
SendMessage | tools/call | Tool arguments become message parts; use a stable messageId for retries |
Direct message result | Ordinary CallToolResult | Return immediately |
task result | Task handle | 2025-11-25: client opts in per request; 2026-07-28: server returns resultType: "task" |
TASK_STATE_INPUT_REQUIRED | input_required | Remote question becomes an elicitation; answer goes back as a new message with the same taskId |
TASK_STATE_AUTH_REQUIRED | No direct equivalent | Surface as a URL-mode elicitation or fail with an actionable error |
TASK_STATE_REJECTED | failed | Keep the agent's reason in statusMessage |
TASK_STATE_CANCELED | cancelled | Note the spelling difference |
| Artifacts with text, data, url or raw parts | Content blocks and structuredContent | Data parts map to structured content; url parts to resource links |
contextId | No equivalent | MCP 2026-07-28 has no sessions; store the mapping in the bridge |
Two rows deserve attention. MCP's task model changed between revisions. In 2025-11-25, tasks are experimental: the client adds a task field with a ttl to tools/call, then uses tasks/get and a blocking tasks/result. In 2026-07-28, tasks moved to the io.modelcontextprotocol/tasks extension: the client declares the extension in its per-request capabilities, the server decides whether to return a task, tasks/get returns the final result inline once the task is terminal, and input goes back through tasks/update instead of a server-initiated request. tasks/result and tasks/list are gone. Second, nothing in MCP carries A2A's contextId, so the bridge must remember which caller owns which context.
Topology B in code: a tool that delegates to an agent
The sketch below is the core of a bridge server. It uses A2A's JSON-RPC binding, asks the remote agent not to block, and returns either a finished result or a task handle. The MCP framework wiring is omitted; ctx stands for whatever your SDK passes to a tool handler.
import secrets, uuid, httpx
A2A_HEADERS = {"A2A-Version": "1.0", "Content-Type": "application/json"}
async def a2a_rpc(http, url, method, params, token):
body = {"jsonrpc": "2.0", "id": str(uuid.uuid4()), "method": method, "params": params}
r = await http.post(url, json=body,
headers={**A2A_HEADERS, "Authorization": f"Bearer {token}"})
r.raise_for_status()
reply = r.json()
if "error" in reply:
raise RemoteAgentError(reply["error"])
return reply["result"]
async def contract_review(args, ctx):
card = await card_cache.get("https://legal.example.com/.well-known/agent-card.json")
iface = next(i for i in card["supportedInterfaces"] if i["protocolBinding"] == "JSONRPC")
token = await exchange_token(ctx.user_token, audience=iface["url"]) # never forward ctx.user_token
result = await a2a_rpc(http, iface["url"], "SendMessage", {
"message": {
"messageId": f"mcp-{ctx.principal}-{args['request_key']}", # stable across retries
"role": "ROLE_USER",
"parts": [{"text": args["instructions"]},
{"url": args["document_url"], "mediaType": "application/pdf"}],
},
"configuration": {"returnImmediately": True,
"acceptedOutputModes": ["application/json", "text/plain"]},
}, token)
if "message" in result: # agent answered without a task
return complete(parts_to_content(result["message"]["parts"]))
remote = result["task"]
local_id = secrets.token_urlsafe(24) # unguessable, bound to the caller
await store.put(local_id, owner=ctx.principal, remote_task=remote["id"],
context=remote["contextId"], url=iface["url"])
if ctx.client_supports_tasks:
return task_handle(local_id, MAP[remote["status"]["state"]], poll_interval_ms=2000)
return await wait_bounded(local_id, seconds=25) # older hosts: block briefly, then fail clearlyThe poll handler is the other half. It checks that the caller owns the local task, calls GetTask on the remote agent, and translates the state:
MAP = {
"TASK_STATE_SUBMITTED": "working", "TASK_STATE_WORKING": "working",
"TASK_STATE_INPUT_REQUIRED": "input_required",
"TASK_STATE_AUTH_REQUIRED": "input_required", # surfaced as a URL elicitation
"TASK_STATE_COMPLETED": "completed",
"TASK_STATE_FAILED": "failed", "TASK_STATE_REJECTED": "failed",
"TASK_STATE_CANCELED": "cancelled",
}
async def poll(local_id, ctx):
rec = await store.get(local_id)
if rec is None or rec.owner != ctx.principal:
raise InvalidParams("unknown task") # same answer for missing and foreign ids
token = await exchange_token(ctx.user_token, audience=rec.url)
task = await a2a_rpc(http, rec.url, "GetTask", {"id": rec.remote_task}, token)
state = MAP.get(task["status"]["state"], "failed")
out = {"taskId": local_id, "status": state}
if state == "completed":
out["result"] = artifacts_to_result(task.get("artifacts", []))
if state == "input_required":
out["inputRequests"] = question_from(task["status"].get("message"))
return outUnknown states map to failed rather than raising, since newer agents may add states. Where the remote agent supports it, use SubscribeToTask or a push config instead of calling GetTask on every poll.
Identity, authorization and trace context
The most common security mistake in a bridge is token passthrough: taking the bearer token the MCP host sent and forwarding it to the remote agent. MCP's authorization guidance forbids it: the token was issued for the bridge, and forwarding it lets the remote agent replay it elsewhere and hides the bridge from audit logs.
Instead, exchange it. With OAuth 2.0 Token Exchange (RFC 8693), the bridge presents the user's token and receives a new one whose audience is the remote agent's interface URL and whose scopes match the skill. The Agent Card's security schemes tell you what the remote agent accepts: API key, HTTP auth, OAuth 2.0, OpenID Connect or mutual TLS. Keep the user as the subject of the exchanged token so the remote side can apply per-user policy.
Trace context needs the same deliberate handling. MCP 2026-07-28 documents traceparent, tracestate and baggage as _meta keys. On the A2A side over HTTP, send the standard W3C traceparent header. The bridge should start a child span for the remote call, so one trace runs from the user's prompt through the tool call into the remote agent. Filter baggage at organisational boundaries.
Worked example: a contract review from an IDE
A developer asks an IDE assistant, an MCP host on revision 2026-07-28, to check a vendor contract. The host has the bridge's contract_review tool, and the bridge points at a legal agent run by another company.
- The model calls
contract_reviewwith instructions, a document URL and arequest_key. The bridge exchanges the developer's token for one scoped to the legal agent and sendsSendMessagewithreturnImmediatelyset. - The legal agent creates a task in
TASK_STATE_SUBMITTED. The bridge stores it under a fresh local id bound to the developer and returns a task handle in stateworking. The host hands the model a placeholder result and moves on. - On a later poll the remote task is in
TASK_STATE_INPUT_REQUIRED, with a status message asking which jurisdiction applies. The bridge returnsinput_requiredwith aninputRequestsentry carrying an elicitation for that question. - The developer answers "Delaware" and the host sends
tasks/updatewith the answer. The bridge sends a secondSendMessagewith the sametaskIdandcontextIdand a newmessageId. - The remote task completes with two artifacts: a data part listing risky clauses and a text summary. The bridge maps the data part to
structuredContentand the summary to a text block, and the nexttasks/getreturns them as the final result.
Had the host spoken 2025-11-25 instead, steps 3 and 4 would run differently. The host would call the blocking tasks/result, and the bridge would send elicitation/create as a server-initiated request tied to the task through the io.modelcontextprotocol/related-task metadata key. The A2A side does not change, so keep mapping and MCP framing in separate modules.
Failure modes
- Duplicate remote work on retry. Under MCP 2026-07-28 a broken response stream loses the in-flight request, and the client re-issues it with a new request id. If the bridge mints a fresh
messageIdeach time, the remote agent starts a second review. Derive themessageIdfrom a caller-supplied key, and make the remote side deduplicate on it; see A2A idempotency. - Orphaned remote tasks. The MCP client cancels or its task TTL expires, but nobody calls
CancelTaskon the remote agent, and the remote agent keeps working and billing. Propagate cancellation, and sweep stored tasks whose TTL has expired. - Context bleed. The bridge caches a
contextIdper tool rather than per user, so one developer's follow-up lands in another's conversation. Key every stored context by principal. - Guessable task ids. Reusing the remote task id as the local id leaks it and lets anyone who learns it poll. Mint unguessable local ids and check ownership on every call.
- Prompt injection through artifacts. Remote agent output goes straight into your model's context as a tool result. Treat it as untrusted input: strip instructions in data you only meant to display, and never let it trigger privileged tools without confirmation.
- Version skew. Send
A2A-Versionexplicitly and test against both MCP revisions.
Trade-offs and when not to bridge
Every bridge adds a hop, a store and a mapping that can drift from either specification. If you own both sides, prefer topology A. Bridge only across real ownership boundaries.
Topology C is tempting as a quick way to share tools across companies, but exposing forty fine-grained tools as forty skills recreates a tool API with worse ergonomics. Group them into a few outcome-shaped skills. Topology D pays off once several teams run their own bridges and repeat the same token exchange, audit and mapping code; the MCP gateway design covers the MCP half of that infrastructure.
What to do next
- List every place an MCP host needs a remote agent or an outside caller needs your tools, and assign each to topology A, B, C or D. Choose A wherever you own both sides.
- For each bridge, write the mapping table for your actual skills and tools, including how you handle
AUTH_REQUIREDandREJECTED. - Implement token exchange with audience-scoped tokens, and add a test that fails if the inbound token ever appears in an outbound request.
- Store bridged tasks under unguessable local ids keyed by principal, with TTLs and a sweeper that cancels orphaned remote tasks.
- Derive
messageIdfrom a caller-supplied idempotency key and test a retry after a dropped stream. - Propagate
traceparentin both directions and check one end-to-end trace in your tracing backend. - Run the bridge against an MCP 2025-11-25 host and a 2026-07-28 host, and against an A2A agent that returns a direct message, a task, and an input request.
- Read the A2A task model, the Agent Card specification and MCP tools to fill in details for your stack.