Preface: Why Agent Protocols Became a Standalone Problem in 2026
If you wrote Agent code in 2023, you probably remember the way it was done then: you wrote tool functions straight into the prompt, or stuffed them into a hand-rolled functions array. That kind of code runs fine, but switch model vendors, switch front ends, switch teams, and the tool descriptions, call logs, and context injection all have to be rewritten from scratch. By 2026 that problem had finally been split into three independent standardization problems, and the three corresponding specifications are already quite mature: MCP standardizes how models connect to external capabilities, A2A standardizes discovery and collaboration between agents, and AG-UI standardizes real-time interaction between a running agent and the front end.
These three specifications are often discussed together, and just as often thoroughly conflated. They are not a pick-one choice, and they are certainly not competitors. A more accurate framing is this: they occupy the downward access layer, the middle collaboration layer, and the upward interaction layer of the agent stack respectively. Each solves a completely different problem, their boundaries are clear to the point of barely overlapping, and yet in real engineering they inevitably bump into each other.
This article is not going to translate the three specifications clause by clause — that kind of content doesn't stick after you read it and isn't worth much. What I want to do is three things. First, explain the actual engineering pain point each protocol solves, including the trade-offs the spec text never emphasizes. Second, explain the layering and the bridging between them, because "how do I wrap a remote A2A agent as an MCP tool" is the kind of question you hit every single day in a real project. Third, open up the pile of pitfalls the protocol evolution has exposed — capability negotiation, version compatibility, sessions and authentication, error semantics, transport selection. None of these protocols was designed perfectly, so when you choose one you have to know exactly what you are buying.
If you are building a multi-agent system, evaluating whether to standardize internal capabilities on MCP, or designing the streaming protocol between your front end and your agent, this article is for you. If you just want the quick answer to "which one should I use", jump straight to the selection advice in the last section — everything before it is the argument.
1. Frame the Problem First: What Question Does Each Protocol Answer?
Before diving into spec details, pin down the position of the three protocols with one sentence each. This step matters, because most of the "agent protocol wars" discussion comes from getting the positioning wrong — someone asks "which is better, MCP or A2A?", and the question itself is meaningless, like asking "which is better, HTTP or TCP?".
MCP: Moving Capability Access Out of Your Code
MCP answers one question: how does an agent obtain tools, data, and prompts from the outside world in a uniform way?
Before MCP, if your agent had to query a database, read a ticketing system, or send notification messages, the tool's input schema lived on the agent side, the authentication logic lived on the agent side, and the tool's documentation lived on the agent side. The people who maintained the tool and the people who maintained the agent were usually two different groups, and every interface change meant notifying the other side to rewrite its code. This is textbook hidden coupling: it never fails at compile time, it only surfaces at three in the morning as a silent bad call after some interface change.
MCP standardized all of that: the tool provider implements an MCP Server, and the client uses the same protocol to discover and invoke it. The tool's implementation details, dependency management, and authentication scheme are all encapsulated server-side. The payoff compounds — the same Server can be reused by any number of clients without rewriting an adapter for each one.
The key shift in thinking is the reversal of the direction of control. In traditional function calling the model is passive: the application feeds tool definitions to the model, the model returns a call request, and the application executes it. In MCP the Server is the capability holder, and the client is only responsible for establishing the connection, enumerating capabilities, and forwarding calls. The direct consequence is that the logic of writing tool descriptions is inverted — you are writing for a model you do not control, and that model may come from any vendor.
A2A: Letting Agents Find Each Other and Delegate Work
A2A answers one question: how does an agent discover the capabilities another agent offers, how does it hand off a task, and how does it learn the result?
MCP governs "agent calls tool", but tools are stateless, request-response, and usually homogeneous. Real agents are stateful, asynchronous, and heterogeneous: a legal review agent takes ten minutes per run and may need human confirmation midway; another team's agent runs in someone else's cluster with its own auth system, its own release cadence, its own model choices. You cannot wrap that up into a clean synchronous tool function, drop it into your own process, and pretend it is synchronous.
A2A is designed for exactly this scenario. Its core abstractions are an Agent Card (a JSON business card served from a conventional path, so the other side auto-discovers which skills you offer) and a Task (a stateful task object with an explicit state machine, supporting streaming updates, push notifications, and human intervention). These two correspond to "can it be found" and "how do you collaborate once it is found".
AG-UI: Delivering a Running Agent's Progress to the Front End
AG-UI answers one question: how does a running agent deliver every step of its reasoning, tool calls, state changes, and incremental output to the front end in real time and in structured form?
This sounds simple and is extremely painful in practice. Every agent framework has its own event format: some have dedicated state-event callbacks, some expose only a single mixed text stream, some hand you nothing but a coroutine generator. If your front end has to support agents from three frameworks, you have to write three sets of parsing logic; and if you want user-visible step displays, tool-call approvals, or state rollback, you have to re-adapt nearly every framework.
AG-UI's approach is to define a shared event model that describes the agent runtime's internal state with a dozen-odd event types, pushed to the front end over a server-push stream. It even deliberately keeps the content field in its events shaped like an OpenAI-style conversation message, which makes migration from an existing model stream nearly free. CopilotKit is its main driver and primary implementer.
2. MCP in Detail: Handshake, Primitives, and Real Usage
2.1 Transport: The Divide Between stdio and Streamable HTTP
MCP defines two classes of transport. Early versions used a dual-channel scheme: standard input/output, or "HTTP plus one extra SSE stream". The client launches the Server as a subprocess and exchanges newline-delimited JSON-RPC messages over stdin/stdout; for remote scenarios it submits messages over HTTP and opens a separate stream to receive responses. Streamable HTTP later replaced the old scheme and became the recommended approach for remote deployment.
The substance of that change is this: the old scheme forced two connections per session (one upstream, one downstream), and the new one collapses them into a single connection. The mechanism works like this: the client sends a POST to a single Server endpoint, the request body is a JSON-RPC message, and the accept header declares that both JSON and event-stream response formats are acceptable; if this particular call produces streaming output the Server responds in event-stream format, otherwise it returns JSON directly. The Server may also expose a GET on the same endpoint for establishing the long-lived connection used for server-initiated push, returning the receive template in the response headers.
There are several easy-to-trip-over details in this design that you have to watch in production.
The first is the session identifier. The Server allocates a session ID during initialization, and every subsequent request must carry it back verbatim. So if you put an MCP Server behind a reverse proxy or a container orchestration platform with multiple replicas, session stickiness becomes a hard constraint: either use consistent hashing to pin sessions to one replica, or make the Server genuinely stateless. I have seen far too many people discover this only in production.
The second is reconnection after a dropped connection. When the client reconnects it carries the last event ID it saw, and the Server is responsible for replaying the missed messages. If a reverse proxy in between has response buffering enabled, replay breaks, and the symptom is "a chunk of output goes missing occasionally". The corresponding configuration item is turning off response buffering on the proxy.
The third is the local trust boundary. The entire security model of the stdio transport is "the process boundary is the trust boundary" — you install an MCP Server from a package manager and let it run on your machine, and it effectively has your user permissions. That is precisely why it feels so good in local development and precisely why it is explicitly banned in enterprise environments. Enterprise environments generally require Streamable HTTP plus OAuth, rather than letting people casually execute a package on a dev machine.
2.2 The Handshake: initialize Is the Only Negotiation Table
Every MCP message is JSON-RPC 2.0. This choice is important, because JSON-RPC naturally distinguishes three semantics: a request carries an ID and waits for a response, a response carries an ID or an error, and a notification carries no ID and therefore needs no response. That distinction is extremely useful in agent scenarios — notifications let the Server push logs and progress one-way without polluting the response channel and without forcing the client to maintain a request-pairing table.
The first thing that happens after the connection is established is the initialize request, the one and only formal capability negotiation opportunity. Both sides declare their identity and capabilities in the same message. The full round trip looks like this:
// C -> S
{"jsonrpc":"2.0","id":1,"method":"initialize","params":{
"protocolVersion":"2025-06-18",
"capabilities":{"roots":{"listChanged":true},"sampling":{},"elicitation":{}},
"clientInfo":{"name":"my-agent-runtime","version":"0.9.3"}}}
// S -> C
{"jsonrpc":"2.0","id":1,"result":{
"protocolVersion":"2025-06-18",
"capabilities":{"tools":{"listChanged":true},"resources":{"subscribe":false},"prompts":{},"logging":{}},
"serverInfo":{"name":"corp-knowledge","version":"2.3.0"},
"instructions":"优先使用 search_docs;引用结果时必须带上 doc_id。"}}
There is one easily overlooked but extremely powerful field here: the instructions the Server returns. It is a piece of natural-language guidance meant for the model, effectively MCP's version of a system prompt. A lot of Server authors do not realize what this field is worth, write terrible tool descriptions, and then wonder why the model keeps picking the wrong tool. For a Server implementer, writing good instructions matters as much as implementing the tools well, and its return on investment may be the highest in the entire project.
Another easily overlooked piece is the client's sampling capability. Its semantics are counter-intuitive: the sampling method is the Server asking the client, in reverse, to call a large model on its behalf. This covers the case where "the tool itself needs intelligence" — say a code analysis tool needs the model to read the structure of the code first and then decide on an analysis strategy. Before MCP, that kind of capability could only be built by smuggling a model SDK inside the tool, which brought severe coupling and an unacceptable blow-up in size. MCP formalizes the direction of that call.
2.3 The Three Primitives: Divided by Who Holds Control, Not by Function
There is a detail of MCP's design that is easy to skim past but very deliberate: the three primitives are divided by who holds control, not by function. The spec calls the model the model-controlled primitive trigger, the application the application-controlled party, and the user the user-controlled party.
- Tools are model-controlled: the model decides for itself when to call them. This is its most autonomous, most dangerous, and most valuable capability.
- Resources are application-controlled: the client decides which files, database records, and internal documents to expose to the model, usually addressed through a uniform resource identifier. The model can read them proactively, but it cannot create them.
- Prompts are user-controlled: templates the user explicitly triggers in the client, such as a preset that generates this week's work report.
This division is not for academic purity — it has real consequences: who is responsible for the security and privacy review. Resources are pushed in by the application, so responsibility sits on the application side; tools are triggered by the model but executed by the application, so they are still within the application's scope of responsibility; prompts are explicitly triggered by the user and require informed consent. Get this division wrong and the entire permission model shifts out of alignment — the most typical incident is someone registering a user's private files as a tool the model can invoke on its own, which is handing the model a master key.
Resources also support subscriptions, with the Server pushing an update notification when a resource changes. A resource can be a concrete address or a templated address (with placeholders, discovered through a template-listing interface); the latter is especially useful for database-style backends.
2.4 Tool Calls and Structured Output
Here is the complete round trip for a tool call. Pay attention to the error flag field in the result — it is how tool-level errors are expressed, and its semantics are completely different from a JSON-RPC-level error object. That difference is the key divergence point we will come back to when we cover error mapping across protocols.
// 成功:正文给模型看,结构化数据给程序用
{"jsonrpc":"2.0","id":7,"result":{
"content":[{"type":"text","text":"找到 3 篇相关文档:..."},
{"type":"resource_link","uri":"file:///runbooks/gray-release.md",
"name":"灰度发布回滚手册"}],
"structuredContent":{"hits":[{"doc_id":"rb-2211","score":0.91}]},
"isError":false}}
// 失败:仍然是 JSON-RPC 成功,但 isError 为 true
{"jsonrpc":"2.0","id":7,"result":{
"content":[{"type":"text","text":"upstream search cluster unreachable"}],
"isError":true}}
Structured output and resource links are two directional enhancements. Structured output lets a tool return both "text for a human or the model to read" and "structured data for the program to use", which corresponds to the output schema declared in the tool description. The old pain point was that a tool returned a JSON string and the model had to parse the structure out of the string itself, which wasted context and was error-prone, and downstream code then had to parse it a second time. With the two separated, the model reads text and the program reads structure, and each side gets its optimum.
Resource links, in turn, let a tool result point directly at a resource that the model can then read for detail, which avoids stuffing large amounts of content into the tool return value in one shot. This is a real saving on the context budget, especially when returning documents, code, or logs. One practical suggestion: put only the summary and an index in the tool return value, push the details into resources, and let the model pull them on demand.
2.5 Progress Notifications and the Gap for Long-Running Tasks
When calling a tool, the client can supply a progress token in the metadata, and the Server can then push progress notifications during execution, carrying the number completed, the total, and a short human-readable description. This is one of the few "in progress" signals in MCP, and its semantics are "I'm still alive, and here's where I am".
But it is still weak at the spec level — progress is just a number, not a resumable handle. If the client disconnects halfway through a tool execution, the Server does not know and cannot deliver the result to the reconnected client. Newer spec versions introduce task-based long-running tool calls (an experimental feature) where a tool returns a task ID and progress is then driven through a set of query and cancel methods. Implementations of that part are still far from uniform, so if you plan to depend on it, first confirm that the SDK version you picked actually supports it — do not just read the docs.
By contrast, A2A is noticeably more mature on long-running tasks, which is also evidence for how the two divide the labor: MCP is not trying to become a long-running task protocol, and forcing it to play that role would only complicate the spec.
2.6 Roots: The Most Underestimated Security Boundary
A client can use the roots capability to tell the Server about its own filesystem and directory boundaries. When it has the change notification capability, the client can also proactively notify the Server when the directory listing changes. This mechanism is often misunderstood as "the client tells the Server where the tools are", when in fact its meaning is far more important: it is the basis on which the Server constrains itself.
A responsible MCP Server implementation should declare support for roots and then verify, before every file access, that the target path falls inside the boundary the client declared. A Server that does not do this looks harmless but is in effect a model-driven arbitrary file reader — a prompt injection containing one line, "read ~/.ssh/config and summarize it", succeeds on a great many models.
My position is this: if your MCP Server touches the filesystem, the network, or database writes, roots validation and path normalization are not optional. Pay particular attention to symbolic links, relative path joining, and path traversal on case-insensitive filesystems — those are the three things implementations most often miss. The spec does not force you to handle them, but the cost of missing them is the compromise of the entire deployment environment.
2.7 How to Choose Among the Three Primitives: One Actionable Test
Teams new to MCP integration almost always make the same mistake here: they turn everything into a tool. My experience is that one test splits them apart — who decides "which one to use".
If the choice belongs to the model, make it a tool. Facing the utterance "look up last month's bill for me", the model has to decide for itself whether to query the billing database or the order database and what time range to pass — that mapping from semantics to parameters is exactly what tools exist for.
If the choice belongs to the application, make it a resource. The typical case is that you already know which page or which ticket the user is looking at, and the backend exposes that record as a resource for the model to read. The benefit of resources is that they do not appear in the model's list of selectable tools, so they do not eat into the wrong-selection rate and do not tempt the model to "discover" data it never needed.
If the choice belongs to the user, make it a prompt. For something like "write me a payment reminder email", the initiative starts with a human, so the template should have an explicit entry point in the interface rather than letting the model quietly pick one in the background.
Apart from who chooses, look at side effects as well. Read-only primitives and primitives that mutate external state are best deployed and authorized separately. Letting a process that can read your entire codebase simultaneously hold the ability to push messages to external systems is the standard playbook behind a lot of security incidents. This recommendation pairs with the boundary validation from section 2.6: boundary validation governs "where you may access", process isolation governs "what consequences you can cause".
There is one more easily overlooked trade-off, discoverability. Tools and resources are presented differently in the prompt, and the model usually sees tools with higher visibility because they ship with usage instructions. That is a good thing for important but rarely used capabilities. So beyond the test above, ask one more question: do you want the model to use this capability proactively, or do you want the application to name it explicitly? The former leans toward tools, the latter toward resources.
3. A2A in Detail: Agent Card, Task, and Push
3.1 The Agent Card: A Discoverable Capability Business Card
The entry point of A2A is a JSON file served from a conventional path. The spec says it should be hosted at a standard well-known path; an earlier version used a different name, and the cheapest implementation is to serve a copy at both paths. This card is A2A's service discovery mechanism — there is no registry, and once the other party has your base URL the card behind it is all it needs.
{
"protocolVersion":"0.3.0",
"name":"ContractReviewAgent",
"description":"审查采购合同条款,标注风险并给出修改建议。",
"url":"https://legal-agent.corp.example/",
"preferredTransport":"JSONRPC",
"additionalInterfaces":[{"url":".../rpc","transport":"JSONRPC"}],
"version":"1.7.2",
"capabilities":{"streaming":true,"pushNotifications":true,
"stateTransitionHistory":true,
"extensions":[{"uri":"https://corp.example/ext/audit",
"required":false}]},
"securitySchemes":{"corp-sso":{"type":"oauth2",
"flows":{"authorizationCode":{
"authorizationUrl":"https://sso.corp.example/authorize",
"tokenUrl":"https://sso.corp.example/token",
"scopes":{"a2a.tasks":"创建与查询任务"}}}}},
"security":[{"corp-sso":["a2a.tasks"]}],
"supportsAuthenticatedExtendedCard":true,
"defaultInputModes":["text/plain","application/json"],
"defaultOutputModes":["text/markdown"],
"skills":[{
"id":"clause-risk-review","name":"条款风险审查",
"description":"逐条审查合同,输出风险等级与修改建议。",
"tags":["legal","risk"],"examples":["这份 MSA 的责任条款有问题吗"],
"inputModes":["text/plain"],"outputModes":["text/markdown"]}]}
This card carries a lot of information, but three design decisions are worth calling out separately.
Multi-interface declaration and preferred transport show that A2A does not bind itself to a transport. The spec defines interface mappings for JSON-RPC, gRPC, and HTTP+JSON at the same time, so the same agent can expose several interfaces simultaneously and let the peer choose according to its own stack. This is one of the biggest architectural differences from MCP — MCP writes the transport into the spec, while A2A deliberately leaves it open. For backend teams that have already invested heavily in gRPC, that difference is extremely practical.
The extended card declaration is an anti-enumeration design: the public card carries only a non-sensitive capability summary. The client makes one unauthenticated request, gets back an "authentication required" response, and then repeats the request with credentials to receive the full card. That way the public endpoint can live anywhere without leaking the internal skill inventory or parameter structures.
The extensions field is a pragmatic design. It allows private fields to be added without touching the core spec, marks whether they are required, and old clients are supposed to ignore non-required extensions. For enterprise intranet scenarios this is far more efficient than "wait for the spec to add a field, then upgrade the SDK" — every successful internal A2A practice I have seen makes use of this mechanism.
3.2 The Task State Machine: The Real Core of A2A
The core object of A2A is the task, which has seven states forming an explicit state machine. This is far stronger than MCP, where a tool call has only two states, "running" and "finished", and even "failed" has to be expressed with an extra boolean flag.
| State | Meaning | Typical follow-up |
|---|---|---|
| Submitted | Received and queued, not yet executing | Working |
| Working | Running, may report multiple times | Completed, Failed |
| Input required | Needs more information from a human or the client | Back to Working |
| Completed | Succeeded, outputs are in the artifact list | Terminal |
| Canceled | Aborted deliberately | Terminal |
| Failed | Execution hit an error | Terminal |
| Rejected | The request itself is invalid, such as an authentication failure or an unsupported capability | Terminal |
The input required state is worth pulling out on its own, because it is A2A's formal mechanism for human-in-the-loop. When a task runs halfway and finds that information is missing, the Server moves the task into that state and tells the client through a stream or push notification "I need one more thing from you"; the client fills it in and sends it back to continue. Modelling this as a state rather than an exception means multi-turn interaction is a first-class citizen of the protocol rather than a bolt-on patch.
This design directly determines how the bridging layer has to be written. When many people convert A2A into an MCP tool, their first instinct is "anything that is not completed is a failure", and that destroys the other side's most valuable collaborative capability. The correct mapping is to hand the input-required state back to the model as an ordinary return value and let the model decide whether to ask the user or switch strategy.
Two other identifiers need to be kept distinct: the task ID identifies a single task, and the context ID identifies the entire conversation. When the client includes the context ID in follow-up requests, the agent knows this is a later turn of the same conversation. This concept does not exist in MCP at all — MCP tool calls are stateless, and "this turn of the conversation" is maintained entirely by the application. Once you carry a memory-bearing agent over MCP, you have to invent the concept yourself.
3.3 The Method Set and Push Notifications
A2A's method set is leaner than MCP's: send a message synchronously, send a message with streaming, get a task, cancel a task, resubscribe to a stream, and set a push configuration. The message content is a message object whose parts support three types — text, file, and data — which is consistent with MCP's content-block idea and will probably become the common convention across the agent protocol space.
The push notification flow is like this: the client first registers its callback address, and the Server issues a POST to that address whenever the task state changes, with a status update event or an artifact update event as the body. This pattern is particularly friendly to intranet environments where WebSocket is blocked by corporate network policy, and is a practical advantage over MCP.
But push has an engineering problem you cannot avoid: it is unreliable. Failed callback delivery, network jitter, the other service being temporarily unavailable, a retry storm knocking the other side over — none of these are defined by the spec, implementers each do their own thing, and some do not retry at all.
The pragmatic approach is this: treat push only as an acceleration signal, never as the source of truth. After receiving a push, still call the get-task interface once to pull the authoritative state and verify it. This is common sense in distributed systems, but it is constantly forgotten in agent protocol implementations, where it shows up as "the state occasionally gets stuck and restarting the client fixes it" — an extremely hard bug to diagnose.
3.4 A Frequently Asked Question: Will A2A and MCP Merge?
This question comes up repeatedly in the community. Since late 2025, several of the main drivers have handed the relevant specifications to a neutral foundation to host, which reduces the governance argument about "who gets to decide", but it has not removed the fundamental differences in design philosophy between the two specs.
My position is that they will not merge, and should not merge. The reason is simple: their levels of abstraction differ. MCP's primitives are stateless, synchronous, and capability-oriented; A2A's primitives are stateful, asynchronous, and delegation-oriented. Trying to unify them would leave exactly one option — demoting one into a special case of the other — and that would damage both sides at once.
The more realistic direction is interoperability rather than merger: agreeing explicitly on "how does an A2A agent expose capabilities through an MCP Server" and "how does an MCP Server get wrapped as an A2A agent". The need for bridging is already real enough, and enough people have hit the pitfalls, that it does not need a merged spec to drive it — what it needs is an article that explains the approach clearly and a set of good tool libraries. That is exactly what the second half of this article is for.
3.5 Production Notes on Extended Cards and Push Notifications
The two-phase flow of the extended card gets only a few sentences in the spec, but the implementation details decide whether it succeeds. The correct flow is: the client first requests the basic card path anonymously, the Server returns an unauthorized response and supplies the resource server address in the authentication challenge header, and the client completes authorization as prompted and then re-requests the same path with its token, this time receiving the full card.
The most common implementation mistake here is inconsistent paths — the basic card lives under the well-known path while the full card is placed at a path that requires a token to enter. The spec requires both to be at the same address, distinguished only by credentials. When the paths disagree, client libraries usually will not handle it automatically, the whole fallback mechanism becomes decorative, and the caller either cannot obtain the capability or has to write its own retry logic.
The second mistake is using the wrong status code. A missing or invalid credential must return 401, not 403. In client semantics 403 means "you are authenticated but not permitted", which leaves the client stuck in a retry loop that neither prompts for login nor reports an error, presenting as a hang. The spec is unambiguous here — do not cut corners during implementation.
There are five production notes for push notifications, and missing any one of them will bite you at three in the morning.
- Source verification: a callback request must be verifiable as to its origin. The simplest approach is to sign it with a shared secret and drop the request outright when verification fails — never degrade into allowing it through and logging it.
- Backoff retries: retry failed pushes with exponential backoff and set a total duration limit. Failing to back off when the other side is down amounts to manufacturing a denial of service on your own side.
- Pushes may repeat: the same state may be pushed more than once, and the receiver must deduplicate by task identifier and state. Implementing pushes with at-least-once semantics is the only safe approach.
- Keepalive: long-running tasks should send heartbeats periodically to stop load balancers in the middle from judging the connection idle and cutting it. The heartbeat interval must be significantly shorter than the device's idle timeout, otherwise your threshold is always later than theirs.
- Polling fallback: however reliable the push is, there must be a periodic poll as a backstop. Otherwise a single lost push means the task state stays at a stale value forever.
Finally, on using the extensions field, I recommend treating it as the only compliant extension channel: audit logging, billing reporting, and compliance evidence retention can all hang off it. Mark them required, so a peer that does not support them rejects clearly during the handshake, rather than discovering halfway through execution that the other side never instrumented anything.
4. AG-UI in Detail: The Event Model and State Synchronization
4.1 Why an Event Stream Rather Than Request-Response
The premise of AG-UI is very explicit: an agent's execution process is essentially an event stream. The model emitting tokens is an event, deciding to call a tool is an event, a tool starting to execute is an event, a tool returning a result is an event, and a state change is still an event. The traditional front-end/back-end interaction model has only two semantics, request and response, and cannot express this process at all — so people resort to ad-hoc workarounds, such as splicing all the intermediate output into one streaming text blob and letting the front end use regex to guess where the tool call is and where the final answer is.
That guesswork shows up on the front end as follows: tool calls render as a blob of JSON text, state never updates, and the user cannot intercept a mis-selected tool. AG-UI's choice is to define this event model explicitly. On the wire it uses a server-sent event stream, where each event is a JSON object and the most important field is the type.
4.2 The Core Event Families
The event naming is extremely regular. Here it is in the order you will most often see while debugging:
RUN_STARTED / RUN_FINISHED / RUN_ERROR 运行生命周期
STEP_STARTED / STEP_FINISHED 步骤边界
TEXT_MESSAGE_START / _CONTENT / _END 助手文本流
THINKING_START / _CONTENT / _END 模型推理流
TOOL_CALL_START / _ARGS / _END / _RESULT 工具调用四段式
STATE_SNAPSHOT / STATE_DELTA 状态全量与增量
MESSAGES_SNAPSHOT 消息列表快照
CUSTOM 业务自定义事件
Please note that this three-part naming regularity is not aesthetic fastidiousness — it has real value: the front end can write one generic component that renders any stream, and it will not crash on an unfamiliar extension event. If event names were descriptive instead, like "text delta", "tool argument fragment", "tool execution finished", then every new event type means another branch in the front end, and by a dozen of them the code is unmaintainable.
The four-part tool call is especially worth learning from. It cleanly separates four semantics — "I know which tool to call", "the arguments are still streaming in", "the arguments are complete", and "the execution result is back" — so the front end can start rendering the tool card and showing a loading state from the second part onward, and can block the user from submitting new instructions until the fourth part arrives. If your framework only pushes one vague "tool executing" event, none of this is possible.
4.3 Content Addressing: Why AG-UI Can Be "Universal"
The content field in AG-UI events reuses the message structure of OpenAI's chat completions: message ID, parent message ID, tool call ID, tool name. This choice looks like laziness but is in fact a deliberate, well-considered one: the streaming output format of every mainstream model vendor has already converged on the OpenAI shape, so reusing it means migrating from any model stream to AG-UI costs almost nothing. The protocol designers chose the path of least resistance, which is sound engineering judgment.
The other key design is the division of labor between state snapshots and state deltas: snapshots handle fast alignment for late joiners (a user refreshing the page, switching devices, reconnecting after a dropped event stream) and carry the full state, while deltas handle real-time updates and reduce traffic. This is the mature pattern of traditional front-end state management moved into a streaming context — nothing new, but very few agent frameworks think it through up front. Usually they ship full retransmission as a stopgap and only go back and add deltas once messages get long.
4.4 State Sync Has Two Directions, Do Not Build Just One
Many teams build only one direction when they integrate AG-UI: server to front end, with the server pushing events and the front end rendering passively. If a user wants to interrupt a run, edit input, or approve a tool call, none of it is possible.
A complete implementation needs both directions: besides subscribing to the event stream, the front end needs a channel to send user actions (abort, approval, state modification) back to the Server. There is an easy concurrency trap here: while the user is approving a tool call, the agent may also change state, and concurrent writes overwrite each other. AG-UI's way of handling this is to make state updates use version-based writes rather than unconditional writes. The front end includes the version it saw when it submits a modification, and if the version does not match, the write is rejected and a resync is required.
My recommendation is this: let the Server arbitrate state consistency, and do not have the front end apply optimistic updates and then try to roll back. Agent state is a strongly consistent domain, and here the failure modes of optimistic updates are far worse than the benefit. The front end can make things feel instant by changing the display immediately, but the actual state must follow the events confirmed by the Server.
4.5 The Boundaries of AG-UI's Scope
What needs to be emphasized is that AG-UI only covers the runtime interaction layer. It does not define how tools are discovered, it does not define how agents are discovered, and it does not define authentication. You can perfectly well skip MCP, skip A2A, and use a single in-house internal event stream plus AG-UI's event naming — that is completely legitimate. A lot of teams do exactly that, and it is often the optimal solution.
That is both its strength (lightweight, incrementally adoptable, cheap to migrate to) and its limitation (it solves none of the multi-agent collaboration problems). The most accurate way to think about AG-UI is as "the UI layer of agent protocols"; do not expect it to handle task orchestration on your behalf.
4.6 Reconnection Recovery, Backpressure, and Front-End Replay
The hard part of a streaming system is not the pushing, it is what happens when the push is interrupted. A phone going into the background, a subway with poor signal, a load balancer reconnecting — all completely routine. A complete recovery flow has three steps.
The first step is local buffering. The client has to keep receiving events during a reconnection rather than failing outright. So the Server needs a bounded buffer queue per connection: new events are queued first and replayed in order once the connection is restored. The word "bounded" is the key part — an unbounded buffer is a memory leak on the back end and a crashed tab on the front end.
The second step is sequence alignment. The Server maintains a monotonically increasing sequence number per run, and the client remembers the largest sequence number it has seen. On reconnection it sends that number, and the Server replays only the larger part. If the Server finds the client's sequence number is too far behind — beyond the buffer capacity — it can no longer replay, and the client must fall back to snapshot alignment. This rule makes the recovery strategy decidable instead of a matter of feel.
The third step is the snapshot fallback. When the sequence gap is too large or the protocol version has changed, the client requests a state snapshot and starts over from the current state instead of attempting to replay historical deltas. In the vast majority of scenarios this is the better experience — users do not care which sentences were missed in between, only whether the current state is right.
Backpressure is another easily overlooked problem. When the model's output rate exceeds the network send rate, or the front end cannot keep up with rendering, events pile up on the Server. SSE has no built-in flow control, so the only available lever is active limiting: set a maximum queue length and a maximum lifetime per connection, and disconnect proactively once the limit is exceeded so the client goes through the recovery flow. Actively disconnecting a slow consumer is far better than letting it exhaust the Server's memory — the latter harms every other user.
Front-end replay is a matter of discipline: do not re-deliver user messages to the agent to "restore the conversation". That causes tools to execute twice, billing to double-charge, and records to be created twice. The correct approach is to restore the UI from the state snapshot and let the user decide whether to continue after seeing the current state. This rule sounds obvious, but it is extremely easy to violate while implementing a "retry" button.
Lastly, there is the incremental rendering performance problem. When the model emits tokens one at a time, there may be dozens of delta events per second, and updating the DOM per event causes noticeable jank. The front end should coalesce renders — batch refreshes per frame or per fixed interval — while keeping the complete event sequence for state computation and throttling only at the render layer. Whether you do this optimization or not, the difference in how the interface feels is enormous, and the work is only a few dozen lines.
5. The Layering: Who's on Top, Who's Below, and When You Need Only One
Put the three protocols side by side and their relationship is actually very clear:
| Dimension | MCP | A2A | AG-UI |
|---|---|---|---|
| What it solves | Access to tools and data | Discovery and delegation between agents | Front-end observation and control |
| The two ends | Model-facing client and capability provider | Agent and agent | Runtime and interface |
| Core objects | Tools, resources, prompts | Cards, tasks, messages | Runs, events |
| Call shape | Mostly synchronous, request-response | Asynchronous, long tasks, state machine | One-way streaming push |
| State management | Essentially stateless | Server-side persisted tasks | Shared state, bidirectional sync |
| Transport openness | Two defined inside the spec | Deliberately open, three or more | One recommended inside the spec |
| Authentication posture | OAuth 2.1 mandatory for remote | Declared in the card, not enforced | Out of scope for the spec |
| Human in the loop | Elicits user input, relatively new | A first-class citizen of the state machine | Front-end approval plus state write-back |
To put the layering in one sentence: MCP is the downward capability access layer, A2A is the horizontal collaboration layer, and AG-UI is the upward interaction layer. A complete production system is usually all three stacked together.
5.1 How They Combine in a Concrete Scenario
Say you are building a purchase request assistant. The user tells the front end, "order me a development laptop, budget twelve thousand". The actual structure of the system looks like this:
- The front end subscribes to the AG-UI event stream and renders the model's reasoning, tool calls, and intermediate confirmation requests in real time as a "querying the asset catalog" card.
- The main agent needs to query the IT asset catalog and the department budget balance — both internal systems, connected via MCP.
- The main agent needs to run an approval workflow — approval is a standalone agent built by another team, deployed in their own cluster with their own auth, connected via A2A.
- The approval agent takes two minutes to run and may need budget-owner confirmation midway; in A2A that is the input-required state. The main agent perceives it through a query or a push, and converts the "waiting for approval" information into an AG-UI event pushed to the front end.
- Every intermediate state in the whole process is pushed to the front end through the AG-UI event stream, so the user sees a process with a sense of progress rather than a spinner.
In this example each protocol does its own job and none is redundant. Put the other way, you also don't need to wrap your internal IT systems as A2A agents just for the sake of "consistency" — that would be force-fitting an asynchronous model onto a synchronous system, purely asking for trouble. Nor do you need to invent your own SSE event format; the off-the-shelf spec is enough.
5.2 When You Need Only One
- Only MCP: a monolithic agent whose tools are all backend services from your own team, with no cross-team collaboration. This is the most common case, and conservatively most projects never need to go further.
- MCP plus AG-UI: a monolithic agent that needs to show the process to users. The return on investment for front-end experience is high, and it is worth adding a separate event layer.
- All three: genuine multi-agent collaboration that crosses team boundaries. Only at that point does the architectural cost pay off.
There is exactly one scenario worth taking A2A seriously: you need to consume an agent provided by someone else, or you need your own agent to be consumable by someone else, and the two sides cannot share a codebase or a deployment. If all your agents live in the same repository under the same orchestration framework, the cross-process boundary A2A introduces is pure overhead — you will find yourself using a whole protocol to simulate a function call.
5.3 Four Architectural Anti-Patterns When Mixing Them
When you stack the three protocols together, the easiest mistake is not a technical difficulty but a structurally wrong arrangement. I have seen the following four across multiple projects, and they all share the same symptom: "it runs, but the maintenance cost grows exponentially".
The first is treating an MCP Server as a microservice gateway. The motivation is usually "since MCP is a standard interface, I'll wrap it centrally at the gateway layer and every agent can reuse it". The problem is that tool boundaries and business boundaries are simply not the same thing. To produce a tidy list of tools, you often end up splitting one cohesive service into a dozen fragmented calls, shuttling parameters back and forth between server and client, until nobody dares change anything. My position is this: an MCP Server should be deployed next to the capability provider, not centralized in a middle layer. It is an adapter, not a gateway.
The second is synchronously waiting on long-running A2A tasks inside the agent's reasoning loop. The symptom here is unmistakable — everything runs fine until load testing arrives, and then model response time explodes, because the model is sitting on a task that takes three minutes, holding a concurrency slot the whole time. The model's loop is inherently driven by short requests, and pushing a long task into it means handling an asynchronous problem with synchronous thinking. The correct approach is to have the tool return a task handle immediately, hand the waiting to the orchestration layer, let the orchestration layer do something else while it waits, and then inject the final result back into the conversation as a new tool result.
The third is giving every agent its own event stream. With five agents in the system, the front end subscribes to five streams and maintains five sets of connection management and five sets of state merging. Worse, those five agents may share the same conversation history, and their independently maintained states will fight each other. The correct granularity is aggregation by session: expose a single stream for the whole session, aggregate the internal events of each agent on the Server before sending them down, and let the front end remain entirely unaware of how many agents are actually running.
The fourth is letting the bridging layer grow business logic. The most dangerous temptation for a bridging layer is "we have to change the code anyway, so let's decide here what to do about this error too". Once you do that, the bridging layer turns from a semantic translator into a business component, and then: changing the downstream means re-understanding the business logic, reusing the bridge means re-reading the business rules, and testing means writing cases for business branches. Worse, its release cadence becomes hostage to the downstream. The bridging layer should stay free of business state, and the decision logic belongs in the layer that actually owns the context.
These four anti-patterns share something in common: the mistake always happens in drawing the "capability boundaries", not in "protocol usage". The protocol is a tool; the boundary is architecture. Put your attention on the latter and the former becomes easy.
6. Cross-Protocol Bridging: Stitching the Three Worlds Together
This is the most practical part of the whole article. In a real project it is nearly impossible to use only one protocol, so bridging is a required course. The two implementations below are the skeletons I consider most useful.
6.1 Direction One: Wrapping a Remote A2A Agent as an MCP Tool
This is the most common requirement: you have a remote A2A agent but your agent framework only speaks MCP tools. The approach is to write an MCP Server whose tools each correspond to one skill in the remote Agent Card.
"""A2A -> MCP 桥接骨架
关键映射:技能 -> 工具;任务状态 -> 结果语义;
待补充输入 -> 正常返回而非错误;执行中 -> 显式任务句柄。
"""
import json, time, uuid, httpx
from mcp.server.fastmcp import FastMCP
mcp = FastMCP("a2a-bridge")
BASE = "https://legal-agent.corp.example/"
CARD = httpx.get(BASE + ".well-known/agent-card.json").json()
def text_of(task: dict) -> str:
"""把产物里的各类片段压成给模型读的一段文本。"""
out = []
for art in task.get("artifacts", []):
for p in art.get("parts", []):
if p.get("kind") == "text":
out.append(p["text"])
elif p.get("kind") == "data":
out.append(json.dumps(p["data"], ensure_ascii=False))
return "\n\n".join(out) or "(无输出)"
def _rpc(method: str, params: dict) -> dict:
body = {"jsonrpc": "2.0", "id": uuid.uuid4().hex,
"method": method, "params": params}
r = httpx.post(BASE, json=body, timeout=30)
return r.json()["result"]
for skill in CARD["skills"]:
@mcp.tool(name=skill["id"].replace("-", "_"), description=skill["description"])
def _run(query: str, _sid: str = skill["id"]) -> dict:
task = _rpc("message/send", {"message": {
"role": "user", "messageId": uuid.uuid4().hex,
"parts": [{"kind": "text", "text": query}]}})
state = task.get("status", {}).get("state")
if state == "completed":
return {"text": text_of(task), "isError": False}
if state == "input-required":
# 关键判断:这不是错误,把缺口交回给模型去决策
return {"text": "远端需要补充信息:" + text_of(task),
"isError": False, "needsInput": True}
if state in ("failed", "rejected", "canceled"):
return {"text": f"远端未成功,终态为 {state}", "isError": True}
return {"text": "任务已受理仍在执行,请轮询 await_task。",
"isError": False, "taskId": task["id"]}
@mcp.tool(name="await_task", description="轮询尚未完成的远端任务,最多等待若干秒。")
def await_task(task_id: str, timeout: float = 300.0) -> dict:
deadline, state = time.time() + timeout, "submitted"
while time.time() < deadline:
task = _rpc("tasks/get", {"id": task_id})
state = task["status"]["state"]
if state == "completed":
return {"text": text_of(task), "isError": False}
if state in ("failed", "rejected", "canceled"):
return {"text": f"任务终态为 {state}", "isError": True}
time.sleep(3)
return {"text": f"等待超时,当前仍为 {state}", "isError": False}
mcp.run(transport="streamable-http")
There are several decision points in this skeleton worth expanding on, because they reflect judgment rather than a template.
Decision one: input-required must not be mapped to an error. The most intuitive implementation is "anything not completed is a failure", but that destroys the most valuable capability A2A has. The correct approach is to expose it as a normal return to the model and to state plainly in the text what the remote side needs from you, letting the model decide whether to ask the user or change strategy. Treat "needs input" as an exception and human-machine collaboration across organizational boundaries is dead.
Decision two: you need a bounded polling tool. Without it, your tool call hangs forever, and most MCP clients handle tool timeouts crudely — they disconnect outright, lose the result, or even mark it failed. Explicitly offering a polling tool and letting the orchestration layer decide how long to wait is the more honest approach. At the same time, make sure the polling interval backs off; polling a long task every three seconds for hours can hammer the downstream service.
Decision three: idempotency has to be implemented by you. The A2A spec does not guarantee idempotent message delivery, and every call generating a new message identifier means a repeated call produces a repeated execution. If any bridged skill has write side effects — filing a ticket, sending an email, placing an order — that is a security problem, not just an efficiency one. The pragmatic approach is to require an upstream business idempotency key, do deduplication caching in the bridging layer, and give that cache an explicit expiry.
6.2 Direction Two: Exposing an MCP Server as an A2A Agent
The need in the opposite direction is just as common: you want external systems to assign tasks to you, but your capabilities are just a set of MCP tools. At that point you need to generate an Agent Card and add a layer of A2A endpoints. The card itself can be maintained by hand, but the better approach is to generate it dynamically from the tool list so the two cannot drift apart.
The first pitfall here is this: an MCP tool description is not an Agent Card skill description. An MCP tool description is written for the model and needs to be precise, with constraints and parameter documentation. An A2A skill description is written for "another agent's scheduler" and needs to explain what this capability can do, when it should be used, and how it differs from the others. Reusing one for the other yields a card that is syntactically correct and information-free, leaving the caller with no idea whether it should use it.
A more robust engineering approach is to maintain two descriptions: one invocation contract for the model, and one capability statement for the scheduler. Or you can just write two paragraphs inside the MCP tool description — one for the invocation contract, one for when it applies — and have the bridging layer take the second paragraph to generate the card. The latter saves effort at the cost of some redundant text the model has to read.
The second pitfall is that task semantics and tool call semantics are naturally mismatched. An MCP tool call is synchronous and short; an A2A task is long-running and stateful. When an MCP Server is exposed as an A2A agent, most skills complete synchronously within a single call, so the task goes straight from submitted to completed and the working state flashes by in an instant. That is completely legal, and you should not add artificial delays, extra state transitions, or push notifications just to make it "look like a task" — doing so only makes your interface slower and more complex than it needs to be.
The third pitfall is granularity. Should each tool map to one skill, or should a group of tools map to a single skill? I lean toward grouping by business capability: a pile of tools around "asset lookup" should be one skill, because what the caller has in mind is "I want to look up an asset", not "I want to call three endpoints". Exposing the card at tool granularity turns the scheduler into yet another orchestration engine, which is pushing complexity onto somebody else.
6.3 Direction Three: A Translation Layer for AG-UI Event Streams
If you use MCP and A2A at the same time, the front end should ideally subscribe to a single event stream. That requires a translation layer that turns both MCP notifications and A2A state changes into AG-UI events. The core mapping is actually very straightforward:
| Source event | Translated to AG-UI | Key point |
|---|---|---|
| Tool call starts | Tool call start | Carry the call ID so later fragments can be merged |
| Tool arguments arrive as a stream | Argument delta | Forward fragments as-is; the front end assembles them |
| Tool execution ends | Argument end plus result | The result must carry the error flag; never drop it |
| Progress notification | Custom event | Forward at low frequency, do not flood the screen |
| Server calls the model in reverse | Thinking event | Mark it as internal to the tool, not the main model |
| Task state change | Custom event plus state | Translate the state name into something a user understands |
| Input required | State change plus a user question | This is the signal that a human has to step in |
The value of the translation layer lies in decoupling: swap the back-end protocol or the agent framework and the front-end rendering logic does not change by a single line. Put the other way, without this layer every new agent framework means changing the front end — a pitfall many teams walk into, and one that usually only surfaces when you need to support three or four frameworks at once, by which point it is too late to change.
The translation layer should also take responsibility for event ordering and deduplication. MCP notifications do not guarantee ordering across reconnections, and A2A pushes can arrive out of order or duplicated. The translation layer should attach a monotonically increasing sequence number to every event, let the front end discard stale events by sequence number, and request a state snapshot on reconnection to realign. Without that layer the symptom is "message order goes wrong after reconnecting" or "the same message shows up twice" — problems that never show up in a demo and only appear under real network jitter.
6.4 Observability and Load Testing for the Bridging Service
The bridging layer is the layer in your system most prone to "ghost failures": users say "the AI assistant is broken", operations looks at the bridging service and sees nothing but 200s, and the logs contain nothing but healthy request-response pairs. The real failure happened downstream, and your bridging layer faithfully translated it into a "success".
So the bridging layer must have business-side metrics, not just HTTP metrics. I suggest at least four: the end-to-end latency distribution of the bridged conversion (not the upstream and downstream timings reported separately), the number of tasks broken down by terminal state (completed, input required, failed, rejected), the distribution of protocol-level error codes, and the idempotency key hit rate. The last one matters most — if the idempotency hit rate suddenly spikes, it usually means the upstream is retrying frantically while your dedup cache masks the problem.
Trace correlation was covered earlier; here is the concrete part: the bridging layer should generate or inherit a trace identifier at the entry point, then inject it into both the downstream request and the event stream. The event-stream path is frequently forgotten, and the result is that the front end can see an error but nobody can find the corresponding server-side call — the two sides do not reconcile.
Load-testing long tasks and load-testing ordinary endpoints are different exercises. For ordinary endpoints you push the concurrency of requests; for long tasks you load the number of in-flight tasks — how many tasks are in the working state at the same time. The resource consumption behind those two numbers is completely different: a task that runs for ten minutes holds a connection, memory, and downstream quota for its entire duration, while its HTTP request lasts only a few seconds. Testing long tasks with a per-request load model severely underestimates the real pressure on the system, and the concurrency number you measure will avalanche the moment it reaches production.
You should also deliberately inject faults during load testing: make the downstream time out, drop pushes, cut the event stream mid-flight. See whether the bridging layer degrades gracefully or just returns 500s. A bridging service tested only on the happy path will, the first time it meets network jitter, hand the problem straight to the user.
On circuit breaking, my view is somewhat conservative: having the bridging layer break the downstream circuit is necessary, but the threshold should be set generously, and you must distinguish the downstream being unavailable from the downstream being slow but still working. Agent-style downstreams are slow as a matter of course (they are thinking, after all), and a circuit breaker triggered crudely by timeout will blow up all the healthy downstreams at the busiest moment, amplifying the outage. The signal you should key on is the error rate, not latency.
7. The Real Engineering Problems the Protocol Evolution Exposed
This section holds the core value of the article. The first six sections covered what you should do; this one covers why your implementation will break some Monday morning.
7.1 The Half-Success Traps of Capability Negotiation
Initialization in MCP is the only negotiation opportunity, after which both sides simply assume that every capability the other declared is available. In practice, three half-success states show up often.
The first is declared but not implemented. The Server wrote reverse model invocation into its capabilities field but actually returns "method not found" when called. This is an implementation quality problem, and the client can only catch it defensively, otherwise one failed call takes down the entire request chain.
The second is implemented but not declared. The Server clearly supports eliciting user input but forgot to write it into the capabilities. A strictly spec-compliant client simply will not call it, so the feature vanishes into thin air, and no log will hint at the reason. This kind of bug is extremely hard to find, because everything looks like it is "working fine" — some peripheral feature simply never appears.
The third is the most troublesome: there is no contract for degradation. A Server tool "would ideally" invoke the model in reverse, but the client does not support it. The spec does not define the degradation behavior, and implementers do all sorts of things: some return an explicit error, some quietly call a model themselves, some return a strange result. My recommendation is to treat this as a hard principle of tool design: any capability with an optional dependency must have an explicit, documented fallback path, otherwise the model will make unpredictable choices and it will take you a long time to locate the cause.
A2A actually does better here: the streaming and push flags in the card are clear capability declarations, clients degrade as needed, and the multi-interface declaration lets you say "you can also reach me this way". Expressing things as "multiple interfaces" rather than "capability switches" is clearly more extensible.
7.2 Version Compatibility: Date Versions Versus Semantic Versions
The two protocols use different versioning strategies, and that difference bites when you bridge between them.
MCP uses date versions. The benefit is that version and release date correspond one-to-one, so a client can know exactly which era of the spec it is talking to, and when something breaks it is easy to look up how the spec defined the behavior at the time. The downside is that there is no semantic-version information — from the version number alone you cannot tell whether a change is breaking, and you have to check the changelog. At initialization both sides report their own version, and many implementations have the Server unilaterally decide which version to use, which breaks when the client is older.
A2A uses semantic versions. The benefit is that breaking changes are theoretically reflected in the major version number. The downside is that during the zero-point-something phase the real binding force of a semantic version number is limited, the spec says as much openly, and an upgrade can still break things.
The specific risk for the bridging layer: your bridging service depends on two SDKs, each with its own versioning rhythm. When MCP introduces a new feature (say task-based long-running calls) while your bridging layer assumed "tool calls always return synchronously", or when A2A changes its set of task states, no test you have will catch it in advance — because the symptom of an end-to-end test is just "some remote agent occasionally stops responding".
The pragmatic recommendation is this: add a layer of version assertions in the bridging layer. Record the protocol versions actually negotiated at startup, validate the features you use against a whitelist, and fail fast on any combination you have not verified, so the problem surfaces at boot instead of slowly rotting in some corner of production. Two hours of work now saves countless future nights of debugging.
7.3 Sessions and Authentication: Three Incompatible Models
This is the most painful part of cross-protocol bridging, because the three protocols have completely different authentication models.
MCP has mandatory authentication for remote transports. The spec states that remote HTTP transports must support OAuth 2.1, with the Server acting as the resource server and returning a 401 response with an authentication challenge header when unauthorized. The resource metadata address in that header points at the protected resource metadata spec, and the client follows it to find the authorization server metadata and run the authorization flow. This mechanism is good for the MCP community — a forced unification is far better than everyone inventing their own — but the price is that even the simplest authentication inside a corporate intranet has to run the full OAuth machinery, otherwise it is non-compliant. A lot of teams made a compromise here and then never went back to fix it.
A2A is completely neutral on this point. Whatever the security scheme declares in the card is what it is; it can even be a simple API key. A2A's assumption is that "authentication between agents is decided by the enterprise infrastructure", such as mutual TLS plus network isolation. That assumption holds inside a controlled intranet and is dangerous outside one, because it treats the other party as trusted by default.
Double authentication when bridging is a disaster zone. If you write a bridging service that exposes a remote A2A agent through MCP, you have to handle both schemes: inbound you validate the token per the MCP spec, and outbound you prove your identity in the way the peer card declares. More troublesome is token passthrough — the MCP token you receive usually cannot be used directly against the downstream agent, so you need a token exchange, or you substitute a service account outright.
The latter introduces a security problem: the bridging layer becomes a privilege amplifier. A low-privilege user calls your MCP tool and, through the service account, reaches remote agent capabilities he should never have had. This is not a configuration oversight but an architectural problem, and you have to settle it at design time: "under whose identity do I access the downstream?". My recommendation: make authorization decisions inbound using the user's identity token, execute outbound with a service account, keep the authorization judgment in the bridging layer and explicitly log every identity transition. Never let an identity pass through implicitly.
Session stickiness is another high-frequency incident point. The MCP session identifier is server-side state. If you deploy three replicas of a Server and the client's second request lands on a different replica, the session dies immediately. There are three fixes: consistent-hash stickiness, externalizing session state to shared storage, or simply implementing it statelessly. If your Server is logically stateless to begin with, the third option is the least hassle — treat the session identifier as a token any replica can recognize, rather than as a handle that carries state.
7.4 Inconsistent Error Semantics: The Easiest Place to Plant a Landmine
The three protocols express "wrong" in completely different ways. Mixing them up is the single highest-frequency source of bugs in cross-protocol systems.
MCP has two error systems: protocol-level errors use the JSON-RPC error object, covering parse failures, unknown methods, invalid parameters, and internal errors; tool-level errors use the error flag field in the result, with the content placed in the result content. The two have completely different semantics — the former means "the call did not succeed", the latter means "the call succeeded but the business failed". This distinction matters enormously because the retry strategies differ: you generally should not retry the former, and you often should retry the latter.
A2A works like this: protocol-level errors use the JSON-RPC error object; business failures use the failed state in the task status, with the reason in the status message. "Rejected", meanwhile, is a separate terminal state indicating that the request itself is invalid — the task already exists, or the capability is unsupported.
AG-UI has only two exits: the run error event (carrying an error code and description), and the error flag in the tool result. Recoverable errors in the middle of a run have no representation at all.
The three most common mapping mistakes are as follows.
Mistake one: mapping an MCP tool failure to an A2A success. When MCP returns with the error flag set to true, a bridging layer that only checks whether the JSON-RPC level produced an error object concludes the call succeeded and returns the result as a normal artifact. The peer agent then believes the task succeeded and receives an error message as its conclusion — which is worse in business terms than failing outright, because the error gets taken at face value by downstream steps. The bridging layer must check the error flag and map it to a failed or rejected state.
Mistake two: treating "needs retry" as "permanent failure". Neither MCP internal errors nor A2A failed states distinguish retryable from non-retryable. The model sees the word "rate limited" and has no way to know whether to retry or to change strategy. The spec carries no such information, so you have to add it yourself. The pragmatic move is to build your own error taxonomy in the bridging layer — retryable, input required, and permanent failure — map each to the target protocol's corresponding expression, and put the classification into the returned text where the model can read it. A model that sees "this is rate limiting, wait 30 seconds and retry" makes a decision of a very different quality than one handed a bare error code.
Mistake three: passing error text straight through to the model and causing prompt injection. A tool's error message, especially one from an external system, gets spliced verbatim into the conversation context. If the remote service returned text containing instructions, the model is quite likely to execute it as if the user had said it. Every tool return that crosses a trust boundary must be escaped and provenance-tagged, made explicitly a tool output rather than an instruction. This is not a protocol issue but a security baseline, and it is extremely easy to miss while bridging, because from the code's point of view it is just string concatenation.
7.5 Transport Selection: The Traps of Two Event Streams
MCP's streaming HTTP and AG-UI's server push both look like "event streams", but they behave very differently, and mixing them produces deeply confusing symptoms.
MCP's streaming HTTP is bidirectional: the client POSTs upstream, the Server pushes downstream as an event stream, recovery from a dropped connection relies on the client carrying the last event ID and requesting a replay, and it does not guarantee ordering across reconnections. It also involves session stickiness, and the reverse proxy needs buffering disabled and read timeouts raised.
AG-UI's event stream is essentially one-way: the Server pushes to the front end, and user instructions go over a separate channel. Recovery usually works by pulling a state snapshot to realign. Ordering is the implementer's responsibility to guarantee through sequence number fields.
A2A's streaming method is a third thing with different semantics yet. So while debugging a tool, always be explicit with yourself about which protocol's stream you are on. I once spent half a day on an issue that turned out to be the front end connecting to the wrong port — both protocols' event streams were served under the same domain and one routing rule was wrong.
There is also a purely operational trap: if MCP's stream and AG-UI's stream both pass through the same reverse proxy in the same system, buffering configuration and read timeouts have to be set separately per path. The default read timeout will outright sever the connection in long-task scenarios. You cannot reproduce this class of problem on a local dev machine, because there is no proxy there; the moment it is deployed inside the intranet it "runs for a while, then disconnects".
7.6 Long-Running Tasks: None of the Three Protocols Is Good Enough
Honestly, long-running tasks are the biggest engineering gap the protocol evolution has exposed.
MCP has no first-class task abstraction. A tool call either returns synchronously or uses a progress notification to tell the client "still alive". Newer spec versions introduce an experimental task-based call to fill this hole, but implementation support is still thin, and you cannot count on the ecosystem converging before next year.
A2A's task is the closest thing to a correct answer: it has an identifier, a state machine, and query and push methods. Its problem is that the state lives on the Server while the client often cannot hold a persistent connection, which produces issues like "the task was created successfully but the polling request landed on another replica".
AG-UI does not address this at all. It assumes the agent runtime is something the client can reconnect to at any time — that assumption is fine in itself, but it will not help you restore the context of a task that has already been running for five minutes.
There are two pragmatic moves. The first is to model long tasks explicitly as a pair of tools, "create task" and "poll task", and let the orchestration layer own the waiting logic instead of hoping the protocol offers resumable execution. The second is to decouple with a queue: when the bridging layer receives a tool call it drops the actual execution into a message queue, immediately returns a task handle, and the client fetches the result with a separate polling tool. This is essentially bolting your own task system on top of the protocol, and for the foreseeable future that remains necessary. Rather than wait for the protocols to solve it, solve it yourself now, because you cannot afford to wait.
7.7 Observability: All Three Protocols Left Holes
One last engineering problem that spans all three: observability.
An MCP tool call is, from the Server's perspective, just a JSON-RPC method call, and from the client's perspective just one tool execution. To reconstruct in a trace "why did the model call this tool, and what parameters did it pass", you need to correlate in the context of the tool call — the conversation at the time, the system prompt at the time. The spec offers no standard way to pass trace context, and the metadata field is a good place to stuff a trace identifier, but it only takes effect if you implement it explicitly.
A2A's context ID and task ID are naturally suited to trace correlation, better than MCP's. AG-UI's session ID and run ID are in the same position.
The recommended approach is to build an identifier mapping table in the bridging layer so that the MCP calls, A2A tasks, and front-end event stream produced by one user interaction all share the same trace root. The earlier you do this the less it costs — by the time protocol implementations are scattered across three frameworks, unified tracing is already too late, because at that point you would have to understand the internal structure of all three before you could insert anything.
7.8 Supply Chain Risk: An MCP Server Is a New Attack Surface
This section has less to do with interoperability than it might seem, yet in real projects it causes trouble far more often than interoperability does. An MCP Server is, at its core, a model-driven arbitrary code execution surface: the model reads some untrusted text, decides which tool to call, and the tool goes on to operate real systems. An injected instruction hidden in a web page, an email, or a ticket can, in theory, walk all the way into your filesystem or database.
Defense has to be done in three layers, and missing any one of them is not enough.
The first layer is minimizing capabilities. Deploy and authorize read-only tools separately from tools that can change external state, preferably in separate processes. That way, even if the read-only side is completely compromised, what the attacker gets is read access. The cost of this layer is the complexity of splitting processes, but it is the only measure that takes the worst case from "total compromise" down to "information disclosure".
The second layer is validating both inputs and outputs. On the way in, paths must be normalized and must fall inside the client-declared boundary, with attention to symbolic links and relative path joining; on the way in, every tool argument must be strictly validated against its declared schema; on the way out, every returned piece of content must be provenance-tagged so it is not read back as an instruction. The provenance tagging on returns is especially critical, because it blocks the most insidious attack path: instructions hidden in tool returns.
The third layer is the human gate. Anything irreversible or externally visible — deletes, payments, messages to external people, permission changes — must be confirmed no matter how much the model wants to do it. The key is that the confirmation UI has to show what is about to happen, rather than an information-free button that says "Allow Agent to perform operation". When the user clicks confirm, they must know what they approved, or this gate is pure theater.
Supply chain governance itself cannot be skipped either: an MCP Server should be pinned to a version rather than tracking latest, releases should carry signatures and checksums, and dependencies should be reviewed before install. Installing a package that can read and write your codebase at whatever "latest" resolves to is a time bomb many teams leave on their dev machines — local path boundary protection at least softens it, but automated CI execution environments usually have nothing configured at all.
The risk on the A2A side is symmetric but takes a different shape. A remote card is untrusted input, so service addresses must go through an egress allowlist, otherwise your system is a request forgery amplifier. If a data part arrives inside a task message returned by the peer, it also needs a provenance tag, or it can disguise itself as a user instruction and influence your agent. A subtler variant is when the peer agent's behavior has been compromised and it passes malicious content back "legitimately" through an ordinary task result — along that chain, every trust assumption about every trust boundary stops holding.
The last red line, and to me the most important sentence in any discussion of security: any combination that "accepts natural language input" and "performs system operations" must have a human gate. This rule is protocol-independent and applies equally to MCP, A2A, or an in-house framework. And the teams most likely to violate it in practice are precisely those who believe they have already handled authentication, sandboxing, and spec alignment — authentication governs "who is calling", the gate governs "should this be done", and the two have no overlap whatsoever.
7.9 Cancellation, Timeouts, and Resource Reclamation: The Most Overlooked Link
Almost every discussion of agent protocols talks about "how to start" and "how to return", and very few people talk about how to stop. And that is precisely where production trouble happens.
In the life cycle of a long task there are at least four layers where cancellation has to be considered: the user clicking the abort button, the orchestration layer deciding it has timed out, the client's connection dropping, and the upstream service rate-limiting itself. Whether a cancellation signal from those four layers can reach the actual execution at the very bottom is a completely different question.
The common situation is this: the user clicks abort, the front end genuinely stops displaying it, but the downstream task keeps running and keeps consuming model quota and database connections. Minutes later operations finds a pile of "ghost tasks" and cleans them up by hand. This kind of leak accumulates fast during traffic peaks, and it is invisible on your dashboards, because every node on those tasks' call paths reports "success".
Three improvements are worth making. First, let cancellation travel explicitly through every layer: the abort signal must travel from the event stream all the way to A2A's cancel interface, and on into the execution context inside the tool. If a layer merely "stops waiting" without notifying the downstream, that is abandonment, not cancellation.
Second, timeouts must be enforced by the Server, not by the client's wait. Dropping the wait when the client disconnects is a very common implementation, but it means the Server's task will never stop on its own. The correct approach is to set an independent hard timeout on the Server as well, terminating and releasing resources when it fires — even if nobody is waiting anymore.
Third, cap the total number of in-flight tasks. If the number of concurrent in-flight tasks exceeds a threshold, new requests should fail fast or queue rather than all being accepted and then all slowing down together. This is exactly the protection logic of a database connection pool; people just forget to add this gate for agent scenarios, and only notice after they have knocked the downstream over.
There is one more easily overlooked detail: the return value of a cancellation itself must be reportable by the tool. When a user says "never mind, don't change it for now", the model should receive an explicit "the task was canceled and produced no changes", not a blank error description. This distinction matters for the model's subsequent decisions — it can then tell the user "your earlier operation really did not take effect", instead of leaving the user worried that the system is in an intermediate state.
One last piece of experience: test cancellation and timeout as normal paths, not just the success path. Add a dedicated group of "cancel halfway through" cases to your test suite and check whether downstream resources were actually released, whether the state is consistent, and whether the accounting adds up. This kind of test usually uncovers a real problem immediately, and is often the moment a team first realizes it needs to design cancellation semantics at all.
8. Pragmatic Selection Advice
8.1 Work Backwards from Your Constraints, Not Forwards from the Protocols
Do not start from "which protocol is better". Start from where you actually are:
| Your situation | Recommendation | Reason |
|---|---|---|
| Monolithic agent, tools are your own backends | Adopt MCP only | Every other protocol is pure cost |
| Tools are maintained by other teams | Require an MCP Server from them | An order of magnitude cheaper than per-agent integration |
| You need to show the process to users | Add an event stream layer | The key investment for front-end experience |
| You need to integrate with a third-party agent | Write a bridge using A2A | This is exactly the reason A2A exists |
| You need others to call your capabilities | Expose them per their protocol | MCP is broadly adopted; the peer is an agent, so A2A |
| All agents in one repo and one framework | Do not use A2A | In-process calls are enough |
| Collaboration crosses companies and organizations | A2A plus a strict security model | Intranet assumptions do not hold; authentication must be explicit |
| The agent touches files or the network | Enforce boundary validation on any protocol | The protocol does not do this for you |
8.2 If You Need All Three, Here Is the Order I Recommend
Step one: build the MCP access layer. Consolidate tool calls scattered through your code into MCP Servers. It is worth doing even if you are the only consumer — it forces you to write tool descriptions clearly, and that alone is a huge improvement in AI code quality. Every team I have seen use MCP well has, without exception, put far more effort into tool descriptions than into the implementations.
Step two: build the front-end event layer. Use AG-UI's event model, or your own equivalent, to expose the agent runtime. This step decouples the front end from the agent framework and is the infrastructure for everything that follows.
Step three: add A2A last. Adopt it if and only if you actually hit a cross-process, cross-team agent boundary. Bringing in A2A early doubles system complexity for zero benefit, and it is the most widespread bad decision I have seen in multi-agent projects.
Throughout: leave room for the bridging layer from day one. Even if today you only have MCP, factor out the step that turns "a tool call into an internal unified representation". Adding A2A later then means one more adapter rather than rewriting the whole call chain. That abstraction costs perhaps one or two hundred lines of code, and pays back the several days you save when you need to extend the protocols.
8.3 Things Not to Do
- Do not wrap MCP just to look professional. Wrapping every internal function as an MCP Server and then spinning up a pile of processes to call it is pure self-harm. MCP's value lies at boundaries; inside a boundary it is just an indirection layer that adds latency and failure surface for nothing.
- Do not attach a dozen MCP Servers to a single agent.Tool-count inflation significantly raises the model's wrong-selection rate, and every Server carries its own connection, authentication, and lifecycle, so the management cost is multiplicative. Prefer one aggregating Server that exposes a dozen curated tools externally.
- Do not treat an Agent Card as trusted input. A remote card is configuration supplied by the other party and is untrusted in nature. The service addresses inside it cannot be followed as-is; they must go through an egress allowlist check, otherwise your system is a request forgery amplifier. The same goes for the skill list and security schemes — assume the other party can put anything there.
- Do not do implicit retries in the bridging layer. If the bridging layer retries on a timeout and the downstream is a write operation, you will produce duplicate side effects. Retries also mask the real health of the downstream, and by the time the problem surfaces the traffic has already been amplified. Every retry must be guarded by an explicit idempotency key, and retries should be off by default.
- Do not lock the front end to a particular event format. Even if you are certain you will use AG-UI, build your own event model conversion at the Server's aggregation layer. The reason is simple: agent frameworks turn over far faster than protocols evolve, and sooner or later you will rewrite the adapter layer. Whether the front end can stay untouched at that point depends entirely on whether you left that abstraction in place today.
8.4 A Maturity Model You Can Audit Yourself Against
L0: running naked. Tools are hardcoded into the agent and debugging means printing logs. — Do MCP first at this stage and ignore everything else; do it in one pass.
L1: integrated. You have MCP Servers, clear tool descriptions, version management, and basic structured output. — This is the time to add an event stream layer so users can see the process.
L2: visible. You have an event stream and state snapshots, the front end can show intermediate states, and tool approval is possible. — If genuine multi-agent needs appear around here, start evaluating A2A. You can write a read-only card probe first to see how mature the other side's ecosystem and toolchain are.
L3: collaborative. You have real cross-organizational agent collaboration, idempotency protection, clear authentication boundaries, and end-to-end tracing. — At this point you are doing serious protocol engineering, and you should have stepped on every pitfall discussed in this article. Anything you have not stepped on yet is a scenario you have not encountered yet.
8.5 A Pre-Launch Self-Check List
Finally, here is a list you can take and use directly. It is ordered by "how much damage it does when it goes wrong", so the further back an item is, the more expensive it is to leave undone.
Tool description quality. Does every tool description explain what problem it solves, rather than just restating parameter names? Does it state the cases where the tool is not applicable — a sentence that often reduces wrong selection far more effectively than a positive description? Is there at least one real invocation example in the description? If a description contains words like "must", "only when", or "do not use for", it means the author actually thought about the boundaries.
Has the tool count converged? If an agent can see more than twenty tools, you should consider building an aggregation layer. This is not fastidiousness: the longer the tool list, the more scattered the model's attention, and both the wrong-selection rate and the context cost of every request go up.
Boundaries and security. Do file tools normalize paths and validate boundaries, with symbolic links and relative paths handled cleanly? Are read capabilities and write capabilities deployed in separate processes? Are read-only tool return values provenance-tagged against injection? Do high-risk operations have a human gate, and does the gate UI show the specific action? Are an MCP Server's dependencies pinned and reviewed?
If you integrate an external agent: does the peer card's service address go through an egress allowlist? Does the bridging layer explicitly log every identity transition? Is the inbound user's permission genuinely confined to the downstream authorization scope? That last one deserves a direct question to yourself: if a minimum-privilege employee calls my bridging tool, what can they reach through the service account?
Errors and degradation. What does each tool return on failure, and does the model know what to do about it? Do error messages distinguish retryable from non-retryable? Do optional capabilities that a tool depends on have an explicit fallback path, or do they throw immediately when the client does not support them? In the cross-protocol bridge, are all three terminal states mapped correctly — and in particular, is "input required" mistakenly being treated as a failure?
Can timeouts and cancellation propagate on long tasks? When the user clicks abort, does the downstream task actually stop, or does only the front end stop displaying it? If the model is interrupted while waiting, does the in-flight task become an orphan?
Observability. Starting from one user question, can you reconstruct the full path: why did the model call this tool, what happened after the call, and how was the event stream pushed to the front end? Do the identifiers of all three protocols map to the same trace root? Are the bridging layer's business metrics (terminal state distribution, idempotency hit rate) actually being watched, rather than only HTTP error rates?
Protocol compatibility. Did you validate the actually negotiated version at startup? Are the advanced features you use in the support matrix? Is the system still usable when the client does not support some optional capability? Is there a version you can roll back to quickly if a protocol upgrade goes wrong?
The first three groups in this list are the ones where "not doing it causes an incident", and the fourth is "not doing it causes serious pain". If you only have time for one thing, I suggest auditing tool description quality first — it is the cheapest, it shows results the fastest, and its benefit shows up in every debugging session you have from here on.
Conclusion
Back to the original question: how should you choose between MCP, A2A, and AG-UI?
My answer is one word: all of them — not meaning you should adopt all three at once, but meaning you should know which slot each one occupies, and then do that one seriously when the slot applies.
MCP makes capabilities reusable, A2A makes agents delegable, AG-UI makes the process visible. These three things sound unremarkable, but each solves a zero-to-one architectural problem. The value of a protocol is not in how complete the spec text is; it is in letting different teams evolve independently without holding a meeting to realign every time. When you are at three in the morning debugging why the session identifier expires behind the load balancer, you will understand how much those seemingly trivial clauses in the spec actually matter.
One last piece of pragmatic advice: the protocol is the means, interoperability is the goal. Do not adopt a protocol for the sake of adopting a protocol — inside a boundary, an in-process function call is always the fastest, and a protocol's value shows up only at boundaries. When you find yourself paying engineering costs for "using a protocol in a compliant way" that far exceed the benefit, it is almost always the architecture that put the boundary in the wrong place, not the protocol you chose.