Two teams in the same organisation build agents. One works in .NET on Azure with Microsoft Agent Framework. The other runs Python agents on Amazon Bedrock AgentCore Runtime. Sooner or later a customer question needs both, and somebody proposes a shared library, a shared queue, or a rewrite. None of those is necessary. Two open protocols already cover the ground: the Model Context Protocol (MCP) for an agent reaching a tool, and the Agent2Agent protocol (A2A) for an agent reaching another agent.
This article connects the two estates with both protocols and reports what happened when I ran it. The protocols themselves behaved. The friction came from three places the announcement posts skip: two incompatible A2A wire formats in circulation at the same time, identity across a cloud boundary, and the fact that an A2A call returns a task with a lifecycle and not a plain string.
Everything here reflects what was released and documented on 7 July 2026. The Python side is tested and the results are shown. The .NET side is written against the published API and is flagged where I could not compile it.
Two protocols, two different jobs
MCP and A2A are often presented as rivals. They answer different questions. MCP asks: what functions can this model call, and what are their schemas? A2A asks: which other agent can take this piece of work, and how do I follow it to completion?

| Question | MCP | A2A |
|---|---|---|
| What is on the other end? | A tool server: functions, resources and prompts | Another agent with its own model, memory and policy |
| What does the caller see? | Every tool, with a JSON Schema for its inputs | A card listing skills. The internals stay private |
| Who decides what happens? | The calling model picks a tool and its arguments | The remote agent decides how to do the work |
| Unit of work | A tool call that returns a result | A task that moves through states and emits artifacts |
| Discovery | tools/list after connecting | Agent card at /.well-known/agent-card.json |
| Transport | Streamable HTTP, or stdio for local servers | JSON-RPC, HTTP+JSON or gRPC, with SSE for streaming |
| Governance | Agentic AI Foundation (Linux Foundation) since December 2025 | Linux Foundation project since June 2025 |
A useful test: if you would be comfortable letting the caller’s model see the function signature and choose the arguments, it is a tool, so use MCP. If the other side must keep its own prompt, its own data access and its own guardrails, it is an agent, so use A2A. In the reference flow the coordinator has no business knowing how the order team looks things up. It asks a question and receives an answer. The order agent, in turn, calls a typed function that the fulfilment team owns.
What each side supported on 7 July 2026
Interoperability claims age quickly, so this is the exact state of the pieces on the publication date.
| Component | Version on 7 July 2026 | Notes |
|---|---|---|
| A2A specification | 1.0, released 12 March 2026 | Previous widely deployed version: 0.3 |
A2A Python SDK (a2a-sdk) | 1.1.0, 29 May 2026 | Serves 1.0, with an opt-in 0.3 compatibility layer |
| Microsoft Agent Framework | 1.0, generally available since 3 April 2026 | .NET and Python |
| Agent Framework A2A packages for .NET | Preview. A2A 1.0 support announced 28 April 2026 | Microsoft.Agents.AI.A2A, installed with --prerelease |
| AgentCore Runtime, A2A protocol | Container contract: port 9000, JSON-RPC on / | AWS documentation examples use A2A 0.3.0 messages |
| MCP specification | 2025-11-25 | Streamable HTTP transport |
MCP Python SDK (mcp) | 1.28.1, 26 June 2026 | Used for the tool server below |
MCP C# SDK (ModelContextProtocol) | 1.4.0, 4 June 2026 | Stable since 1.0.0 in February 2026 |
| AgentCore Runtime, MCP protocol | Container contract: port 8000, path /mcp | Stateless streamable HTTP expected |
The MCP column is stable on both sides. The A2A column is not: the specification reached 1.0 in March, the .NET client that speaks it was still a preview package in July, and the AWS documentation still showed 0.3 examples. Plan for both A2A versions to be in your estate at once.
The version skew that will catch you
A2A 1.0 is not a superset of 0.3. Method names changed, enum values changed, the message part shape changed, and the agent card moved the endpoint address into a list of interfaces. A 0.3 client cannot parse a strict 1.0 card because the top-level url it requires is gone. A 1.0 client sends methods a 0.3 server has never heard of.

Version selection hangs on one HTTP header. A 1.0 client sends A2A-Version: 1.0. The specification says an absent header means 0.3, which keeps old clients working and creates a trap for new ones: if anything between the client and the agent drops that header, a valid 1.0 request is read as 0.3 and refused.
%%{init: {'theme':'base','themeVariables':{'primaryColor':'#FFF3E0','primaryTextColor':'#232F3E','primaryBorderColor':'#FF9900','lineColor':'#232F3E','clusterBkg':'#F8FAFC','clusterBorder':'#CBD5E1','edgeLabelBackground':'#FFFFFF','nodeTextColor':'#232F3E'}}}%%
flowchart TD
A[Request arrives at POST /] --> B{A2A-Version header}
B -- "absent" --> C[Read as 0.3]
B -- "1.0" --> D[Read as 1.0]
C --> E{Method name}
E -- "message/send" --> F[0.3 compatibility adapter]
E -- "SendMessage" --> G[Error -32009
version not supported]
D --> H[1.0 handler]
F --> I[Same agent executor]
H --> I
class A,D,H,I compute
class C,E,F edge
class G alert
classDef compute fill:#FFF3E0,stroke:#FF9900,stroke-width:2px,color:#232F3E
classDef edge fill:#EBF8FF,stroke:#0073BB,stroke-width:2px,color:#004C74
classDef store fill:#F0FFF4,stroke:#1D8102,stroke-width:2px,color:#145202
classDef alert fill:#FEF2F2,stroke:#D13212,stroke-width:2px,color:#991B1B
classDef mesh fill:#F3E8FF,stroke:#7C3AED,stroke-width:2px,color:#4C1D95AgentCore Runtime does not pass every request header to your container. Its documentation describes an allowlist for custom headers, limited to 20 per runtime. I have not deployed this sample to a live runtime, so I cannot tell you whether A2A-Version arrives without configuration. Test it before you depend on 1.0 traffic, and keep the 0.3 path switched on until you have.
The tool side: an MCP server
Start with the piece that changes least. The fulfilment team exposes one function. The server follows the AgentCore Runtime MCP contract: listen on 0.0.0.0:8000, serve /mcp over streamable HTTP, and hold no session state so that any instance can answer any request.
"""orders_mcp_server.py - one tool over streamable HTTP, laid out for the AgentCore MCP contract."""
import re
from mcp.server.fastmcp import FastMCP
ORDER_ID = re.compile(r"\bORD-\d{3,10}\b")
# stateless_http=True: no session affinity is assumed, so any instance can serve any request.
mcp = FastMCP("orders", host="0.0.0.0", port=8000, stateless_http=True)
def lookup_order(order_id: str) -> dict:
"""Replace with a real lookup. Validate and authorise here: the caller is another system's agent."""
if not ORDER_ID.fullmatch(order_id):
raise ValueError("order_id must look like ORD-10042")
return {"order_id": order_id, "status": "SHIPPED", "carrier": "An Post"}
@mcp.tool()
def get_order_status(order_id: str) -> dict:
"""Return the fulfilment status of one order. order_id looks like ORD-10042."""
return lookup_order(order_id) # a ValueError is reported to the caller as a tool error
if __name__ == "__main__":
mcp.run(transport="streamable-http")
Two details matter more than the decorator. The docstring and the type hints become the tool description and JSON Schema that a remote model reads, so write them for a model, not for a colleague. And the argument is validated inside the tool, because the value was produced by a language model in somebody else’s process. When I sent 1; DROP as the order number, the server answered with isError: true and the validation message, and nothing else ran.
The agent side: an A2A server that answers both versions
The order agent has two halves. The first is the reasoning step, which knows nothing about A2A and reaches its tool through an MCP client. A production agent would hand the tool list to a model. The control flow is fixed here so that the sample runs without model credentials and the protocol behaviour stays visible.
"""order_agent.py - the agent's reasoning step. No A2A types in here."""
import json
import os
import re
from mcp import ClientSession
from mcp.client.streamable_http import streamablehttp_client
ORDERS_MCP_URL = os.environ.get("ORDERS_MCP_URL", "http://localhost:8000/mcp")
ORDER_ID = re.compile(r"\bORD-\d{3,10}\b")
async def answer(question: str) -> str:
match = ORDER_ID.search(question or "")
if not match:
return "I can only help with order status. Include an order number such as ORD-10042."
async with streamablehttp_client(ORDERS_MCP_URL) as (read, write, _):
async with ClientSession(read, write) as session:
await session.initialize()
result = await session.call_tool("get_order_status", {"order_id": match.group()})
if result.isError:
return "The order system could not answer that request."
order = json.loads(result.content[0].text)
return f"Order {order['order_id']} has status {order['status']} with carrier {order['carrier']}."
The second half is the A2A server. It follows the AgentCore Runtime A2A contract: 0.0.0.0:9000, JSON-RPC on /, the card at /.well-known/agent-card.json, and a /ping health route.
"""order_agent_a2a.py - A2A 1.0 and 0.3 on one endpoint, laid out for the AgentCore A2A contract."""
import logging
import os
import uvicorn
from a2a.helpers import new_task_from_user_message, new_text_part
from a2a.server.agent_execution import AgentExecutor, RequestContext
from a2a.server.events import EventQueue
from a2a.server.request_handlers import DefaultRequestHandler
from a2a.server.routes import create_agent_card_routes, create_jsonrpc_routes
from a2a.server.tasks import InMemoryTaskStore, TaskUpdater
from a2a.types import AgentCapabilities, AgentCard, AgentInterface, AgentSkill
from starlette.applications import Starlette
from starlette.responses import JSONResponse
from starlette.routing import Route
from order_agent import answer
log = logging.getLogger("order_agent")
# The address callers use. On AgentCore Runtime this is the runtime's invocation URL, not localhost.
PUBLIC_URL = os.environ.get("AGENT_PUBLIC_URL", "http://localhost:9000/")
AGENT_CARD = AgentCard(
name="Order Status Agent",
description="Answers questions about the fulfilment status of customer orders.",
version="1.0.0",
supported_interfaces=[
# Listed in order of preference. Clients take the first entry they can speak.
AgentInterface(url=PUBLIC_URL, protocol_binding="JSONRPC", protocol_version="1.0"),
AgentInterface(url=PUBLIC_URL, protocol_binding="JSONRPC", protocol_version="0.3"),
],
capabilities=AgentCapabilities(streaming=True),
default_input_modes=["text/plain"],
default_output_modes=["text/plain"],
skills=[
AgentSkill(
id="order_status",
name="Order status",
description="Looks up where an order is, given an order number such as ORD-10042.",
tags=["orders", "fulfilment"],
examples=["Where is order ORD-10042?"],
)
],
)
class OrderStatusExecutor(AgentExecutor):
"""Bridges A2A requests to the business logic and reports progress as task events."""
async def execute(self, context: RequestContext, event_queue: EventQueue) -> None:
if context.current_task is None:
# First message of a conversation: the task must exist before any status event refers to it.
await event_queue.enqueue_event(new_task_from_user_message(context.message))
task = TaskUpdater(event_queue, context.task_id, context.context_id)
await task.start_work()
try:
text = await answer(context.get_user_input())
except Exception:
# Log the cause here; tell the caller only that the task failed.
log.exception("order lookup failed task_id=%s", context.task_id)
await task.failed()
return
await task.add_artifact([new_text_part(text)], name="order_status")
await task.complete()
async def cancel(self, context: RequestContext, event_queue: EventQueue) -> None:
await TaskUpdater(event_queue, context.task_id, context.context_id).cancel()
async def ping(_request) -> JSONResponse:
return JSONResponse({"status": "Healthy"})
def build_app() -> Starlette:
handler = DefaultRequestHandler(
agent_executor=OrderStatusExecutor(),
task_store=InMemoryTaskStore(), # per-process; use a database-backed store beyond one instance
agent_card=AGENT_CARD,
)
return Starlette(routes=[
Route("/ping", ping),
*create_agent_card_routes(AGENT_CARD),
*create_jsonrpc_routes(handler, rpc_url="/", enable_v0_3_compat=True),
])
if __name__ == "__main__":
uvicorn.run(build_app(), host="0.0.0.0", port=9000)
Three lines carry the interoperability. supported_interfaces advertises both versions at the same URL. enable_v0_3_compat=True mounts the adapter that translates 0.3 requests into the same handler. And the task is enqueued before the first status update: leave that out and the SDK rejects your own agent with “Agent should enqueue Task before TaskStatusUpdateEvent event”, which is the first error I hit.
With the compatibility layer on, the served card also carries the legacy top-level url, protocolVersion and preferredTransport fields alongside supportedInterfaces. That is what lets a 0.3 client read a card published by a 1.0 server.
What goes over the wire
The same question, sent twice to the same URL. First in the 0.3 format, with no version header:
{"jsonrpc": "2.0", "id": "1", "method": "message/send",
"params": {"message": {"kind": "message", "role": "user", "messageId": "m1",
"parts": [{"kind": "text", "text": "Where is ORD-10042?"}]}}}
Then in the 1.0 format, with A2A-Version: 1.0:
{"jsonrpc": "2.0", "id": "2", "method": "SendMessage",
"params": {"message": {"role": "ROLE_USER", "messageId": "m2",
"parts": [{"text": "Where is ORD-10042?"}]}}}
Trimmed responses from the test run, 0.3 first and 1.0 second:
{"result": {"kind": "task", "status": {"state": "completed"},
"artifacts": [{"name": "order_status",
"parts": [{"kind": "text", "text": "Order ORD-10042 has status SHIPPED with carrier An Post."}]}]}}
{"result": {"task": {"status": {"state": "TASK_STATE_COMPLETED"},
"artifacts": [{"name": "order_status",
"parts": [{"text": "Order ORD-10042 has status SHIPPED with carrier An Post."}]}]}}}
Two further results are worth knowing before you write a client. Sending the 1.0 body without the header returned error -32009, “A2A version ‘0.3’ is not supported by this handler”. And stopping the MCP server produced an HTTP 200 whose task state was TASK_STATE_FAILED. A failed task is a successful HTTP exchange, so a client that checks only the status code will report success.
All Python in this article ran on Python 3.13 with a2a-sdk 1.1.0 and mcp 1.28.1. The MCP server ran on port 8000 and the A2A application was driven through its ASGI interface. The test asserts both wire formats, the card contents, the version error and the failed-task path. It was not deployed to an AgentCore runtime.
Calling it from .NET with Agent Framework
On the Azure side the coordinator treats the remote agent as an ordinary AIAgent. The A2A package resolves the card, picks an interface and wraps the client. What the framework does not do for you is satisfy AgentCore’s entry requirements: a bearer token the runtime trusts, and a session id header of at least 33 characters. A delegating handler adds both.
// dotnet add package Microsoft.Agents.AI.A2A --prerelease
// dotnet add package Azure.Identity
using System.Net.Http.Headers;
using A2A;
using Azure.Core;
using Microsoft.Agents.AI;
/// <summary>Adds what AgentCore Runtime expects on every call: a bearer token and a session id.</summary>
public sealed class AgentCoreAuthHandler(TokenCredential credential, string scope, string sessionId)
: DelegatingHandler
{
protected override async Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request, CancellationToken cancellationToken)
{
// Azure.Identity caches and refreshes the token; asking per request is cheap.
AccessToken token = await credential.GetTokenAsync(new TokenRequestContext([scope]), cancellationToken);
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token.Token);
request.Headers.TryAddWithoutValidation("X-Amzn-Bedrock-AgentCore-Runtime-Session-Id", sessionId);
return await base.SendAsync(request, cancellationToken);
}
}
public static class OrderAgentClient
{
/// <param name="runtimeUrl">The AgentCore invocation URL, ending in /invocations/.</param>
/// <param name="scope">The scope of the app registration the runtime's JWT authorizer trusts.</param>
public static async Task<AIAgent> ConnectAsync(
Uri runtimeUrl, TokenCredential credential, string scope, CancellationToken cancellationToken)
{
// AgentCore rejects session ids shorter than 33 characters. A hyphenated GUID is 36.
string sessionId = Guid.NewGuid().ToString("D");
var http = new HttpClient(new AgentCoreAuthHandler(credential, scope, sessionId)
{
InnerHandler = new SocketsHttpHandler { PooledConnectionLifetime = TimeSpan.FromMinutes(5) }
})
{
Timeout = TimeSpan.FromSeconds(100)
};
// The card sits beneath the invocation URL, so pass a relative card path.
A2ACardResolver resolver = new(runtimeUrl, http, agentCardPath: ".well-known/agent-card.json");
// The agent publishes JSON-RPC interfaces only; ask for that binding explicitly.
A2AClientOptions options = new() { PreferredBindings = [ProtocolBindingNames.JsonRpc] };
return await resolver.GetAIAgentAsync(
httpClient: http, options: options, cancellationToken: cancellationToken);
}
}
AIAgent orderAgent = await OrderAgentClient.ConnectAsync(
runtimeUrl, new DefaultAzureCredential(), "api://order-agent/.default", ct);
Console.WriteLine(await orderAgent.RunAsync("Where is ORD-10042?", cancellationToken: ct));
I had no .NET SDK or NuGet access while writing this. The types and method signatures were read from the public source of the A2A and Microsoft.Agents.AI.A2A packages, both preview packages in July 2026 whose surface was still moving. Treat the listing as a design, build it against the versions you pin, and expect to adjust parameter names. How the resolver combines the base address with the card path is the first thing to verify.
The framework prefers the HTTP+JSON binding when a card offers it and falls back to JSON-RPC. The AgentCore contract is JSON-RPC on /, so the card above advertises only that, and the client asks for it by name. Leaving the choice to defaults on both sides is how two conformant implementations end up unable to talk.
Put together, one question makes this journey:
%%{init: {'theme':'base','themeVariables':{'actorBkg':'#FFF3E0','actorBorder':'#FF9900','actorTextColor':'#232F3E','signalColor':'#0073BB','signalTextColor':'#0F172A','noteBkgColor':'#FFF8E7','noteBorderColor':'#FF9900','noteTextColor':'#232F3E','labelBoxBkgColor':'#FFF3E0','labelBoxBorderColor':'#FF9900','labelTextColor':'#232F3E','loopTextColor':'#232F3E','activationBkgColor':'#EBF8FF','activationBorderColor':'#0073BB','lineColor':'#232F3E','textColor':'#232F3E'}}}%%
sequenceDiagram
participant C as Coordinator (.NET)
participant R as AgentCore Runtime
participant A as Order Status Agent
participant M as Orders MCP server
C->>R: GET agent card + bearer token
R->>A: GET /.well-known/agent-card.json
A-->>C: Card: skills, interfaces, versions
C->>R: POST SendMessage, A2A-Version 1.0
R->>A: POST / on port 9000
A->>M: tools/call get_order_status
M-->>A: Order record
A-->>C: Task COMPLETED + artifactIdentity across the boundary
An agent card is public metadata. Everything behind it needs a caller identity, and the two hops need different ones.
- Coordinator to order agent. AgentCore Runtime accepts either AWS SigV4 or an OAuth bearer token, configured per runtime. For a caller on Azure the bearer route is the natural fit: the coordinator’s managed identity obtains a token from Microsoft Entra ID, and the runtime’s JWT authorizer is pointed at the tenant’s discovery URL with a fixed audience. No AWS access keys live in Azure.
- Order agent to MCP server. The agent calls its tool with its own workload identity. Do not forward the coordinator’s token. It was issued for a different audience, and passing it on turns the order agent into a confused deputy.
- End-user context. If the tool must know which customer is asking, carry that as an explicit, validated claim or parameter. Do not let it ride along inside the natural-language message, where a prompt can rewrite it.
- Card integrity. A2A 1.0 allows agent cards to be signed. Until your clients verify signatures, pin the card URL in configuration and do not discover agents from addresses that arrive in model output.
%%{init: {'theme':'base','themeVariables':{'primaryColor':'#FFF3E0','primaryTextColor':'#232F3E','primaryBorderColor':'#FF9900','lineColor':'#232F3E','clusterBkg':'#F8FAFC','clusterBorder':'#CBD5E1','edgeLabelBackground':'#FFFFFF','nodeTextColor':'#232F3E'}}}%%
flowchart TD
U[User request] --> CO[Coordinator agent
Azure, managed identity]
CO -- "Entra ID token
audience: order agent" --> JW[AgentCore JWT authorizer]
JW --> OA[Order Status Agent
own execution role]
OA -- "workload credential
audience: orders tools" --> MS[Orders MCP server]
MS --> DB[(Order system of record)]
X[Forwarded user token] -. "do not pass through" .-> MS
class CO,OA,MS compute
class JW mesh
class DB edge
class X alert
class U store
classDef compute fill:#FFF3E0,stroke:#FF9900,stroke-width:2px,color:#232F3E
classDef edge fill:#EBF8FF,stroke:#0073BB,stroke-width:2px,color:#004C74
classDef store fill:#F0FFF4,stroke:#1D8102,stroke-width:2px,color:#145202
classDef alert fill:#FEF2F2,stroke:#D13212,stroke-width:2px,color:#991B1B
classDef mesh fill:#F3E8FF,stroke:#7C3AED,stroke-width:2px,color:#4C1D95Running it in production
- Task state must outlive the process.
InMemoryTaskStoreis fine for a test. AgentCore isolates sessions from one another, so a laterGetTaskthat lands elsewhere finds nothing. Use a shared task store before you offer streaming reconnects or long-running tasks. - Read the task, not the status code. Completed, failed, rejected, input-required and auth-required all arrive as successful responses. Map each state to an explicit branch in the coordinator.
- Retry the right things. The AgentCore A2A contract returns throttling and conflicts with real HTTP status codes (429 and 409) and expects the client to retry conflicts. Retry those with backoff and jitter. Do not blindly retry a message that may have started work: reuse the same
messageIdso the receiver can de-duplicate. - Budget the latency. One user question now costs two model loops and three network hops. Give each hop its own timeout, shorter than the caller’s, so that failures surface at the hop that caused them.
- Trace across both protocols. Propagate W3C trace context on the A2A call and the MCP call, and log the A2A task id and context id on every line. Without them you cannot join the Azure trace to the AWS one.
- Watch the bill on the remote side. An A2A agent on AgentCore Runtime is billed for the session, including time spent waiting on its own model and tools. The AgentCore Runtime vs. Lambda article works through that arithmetic.
- Version the card like an API. Skill ids are a contract. Add skills freely, and never change the meaning of an existing id.
When the remote agent belongs to another legal entity, the message parts you send are a disclosure under GDPR, and the remote operator is a processor or a separate controller. Send the minimum: an order number, not the conversation history. Record which agent received what, with the task id, so that an access or erasure request can be traced. In regulated settings such as healthcare, treat any agent whose output can influence care as part of the clinical safety case, and keep a human decision point where the hazard log requires one.
When to use which
%%{init: {'theme':'base','themeVariables':{'primaryColor':'#FFF3E0','primaryTextColor':'#232F3E','primaryBorderColor':'#FF9900','lineColor':'#232F3E','clusterBkg':'#F8FAFC','clusterBorder':'#CBD5E1','edgeLabelBackground':'#FFFFFF','nodeTextColor':'#232F3E'}}}%%
flowchart TD
Q1{Deterministic function
with a clear schema?}
Q1 -- "Yes" --> T[Expose it as an MCP tool]
Q1 -- "No" --> Q2{Owns its own model,
memory and policy?}
Q2 -- "No" --> L[Keep it in-process
as a function]
Q2 -- "Yes" --> Q3{Different team, cloud
or organisation?}
Q3 -- "No" --> W[Use the framework's own
workflow or handoff]
Q3 -- "Yes" --> A[Publish it as an A2A agent]
class Q1,Q2,Q3 edge
class T,L,W compute
class A store
classDef compute fill:#FFF3E0,stroke:#FF9900,stroke-width:2px,color:#232F3E
classDef edge fill:#EBF8FF,stroke:#0073BB,stroke-width:2px,color:#004C74
classDef store fill:#F0FFF4,stroke:#1D8102,stroke-width:2px,color:#145202
classDef alert fill:#FEF2F2,stroke:#D13212,stroke-width:2px,color:#991B1B
classDef mesh fill:#F3E8FF,stroke:#7C3AED,stroke-width:2px,color:#4C1D95| Situation | Use | Reason |
|---|---|---|
| Look up a record, call an API, run a query | MCP tool | The caller’s model can choose arguments safely from a schema |
| Another team’s agent with its own prompt and guardrails | A2A | The internals stay private and the owner keeps control |
| Two agents in one codebase and one deployment | Framework workflow | A network protocol adds latency and failure modes for no gain |
| Work that takes minutes and reports progress | A2A | Tasks, streaming and push notifications are built in |
| Third-party agent you do not operate | A2A, behind your own gateway | You need one place to enforce identity, logging and data limits |
Key takeaways
- MCP connects an agent to tools. A2A connects an agent to another agent. Most cross-team designs need both, at different hops.
- In mid-2026 A2A 0.3 and 1.0 coexist and are not wire-compatible. Serve both from one endpoint and advertise both in the card.
- Version selection depends on the
A2A-Versionheader. Verify that it survives every proxy and platform in the path. - An A2A response is a task with a state. A failed task arrives with HTTP 200.
- Give each hop its own identity and audience. Never forward the caller’s token to the next hop.
If you have run A2A 1.0 traffic through AgentCore Runtime, or built the .NET client against a later package version, I would like to hear what you had to change. Leave a comment below.
Three things changed after this article was written. The A2A project moved into the Agentic AI Foundation in August 2026, joining MCP. A new MCP specification revision dated 2026-07-28 made the protocol stateless by design, and the MCP C# SDK reached 2.0 on the same day. The versions pinned in this article remain the ones the code was tested against. Check current package versions before you start new work.
Glossary
- A2A (Agent2Agent protocol)
- An open protocol for one agent to discover another, send it work and follow that work to completion.
- MCP (Model Context Protocol)
- An open protocol that lets a model-driven application discover and call tools, read resources and use prompts from a server.
- Agent card
- A JSON document describing an A2A agent: its skills, interfaces, protocol versions and security schemes.
- Skill
- A named capability advertised in an agent card. Its id is a contract with callers.
- Task
- The stateful unit of work in A2A. It moves through states such as working, completed and failed.
- Artifact
- An output attached to a task, made of one or more parts such as text, data or a file reference.
- Protocol binding
- The transport used to carry A2A: JSON-RPC, HTTP+JSON or gRPC.
- A2A-Version header
- The HTTP header a client uses to state which A2A version it speaks. When absent, 0.3 is assumed.
- Streamable HTTP
- The MCP transport for remote servers, carrying requests and optional streamed responses over HTTP.
- AgentCore Runtime
- The Amazon Bedrock service that hosts agent and tool containers, with HTTP, MCP and A2A protocol modes.
- Microsoft Agent Framework
- Microsoft’s open-source framework for building agents and multi-agent workflows in .NET and Python.
- Confused deputy
- A service tricked into using its own authority, or a token meant for it, on behalf of a caller who should not have that access.
- SSE (Server-Sent Events)
- A one-way HTTP streaming format. A2A uses it to deliver task updates as they happen.
References & Further Reading
- → Agent2Agent (A2A) Protocol Specification — Version 1.0: methods, task states, agent card and version negotiation
- → a2a-python on GitHub — The Python SDK used for the agent server, including the 0.3 compatibility layer
- → a2a-dotnet on GitHub — The .NET SDK that Agent Framework builds on
- → AgentCore Runtime: A2A protocol contract — Port, paths, authentication and error mapping for A2A containers
- → AgentCore Runtime: MCP protocol contract — Port, path and statelessness requirements for MCP containers
- → AgentCore Runtime: request header allowlist — Which custom headers reach your container and how to allow more
- → Microsoft Agent Framework: A2A agents — Creating an AIAgent from an agent card or an A2A client
- → Agent Framework: migrating to the A2A v1 SDK — API changes between the 0.3 and 1.0 .NET packages
- → A2A v1 is here (Agent Framework blog) — The 28 April 2026 announcement of A2A 1.0 support for .NET
- → Model Context Protocol specification, 2025-11-25 — The MCP revision this article targets
- → MCP Python SDK on GitHub — FastMCP server and streamable HTTP client used here
- → ModelContextProtocol on NuGet — The official C# SDK and its version history
Discover more from C4: Container, Code, Cloud & Context
Subscribe to get the latest posts sent to your email.