A2A and MCP Across Clouds: Connecting a .NET Agent Framework Agent to an Agent on Bedrock AgentCore

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?

Diagram of a .NET Agent Framework coordinator on Azure calling an Order Status Agent on Amazon Bedrock AgentCore Runtime over A2A, which calls an Orders MCP server over MCP.
The reference flow used in this article. A2A crosses the cloud boundary; MCP stays inside the AWS estate.
QuestionMCPA2A
What is on the other end?A tool server: functions, resources and promptsAnother agent with its own model, memory and policy
What does the caller see?Every tool, with a JSON Schema for its inputsA card listing skills. The internals stay private
Who decides what happens?The calling model picks a tool and its argumentsThe remote agent decides how to do the work
Unit of workA tool call that returns a resultA task that moves through states and emits artifacts
Discoverytools/list after connectingAgent card at /.well-known/agent-card.json
TransportStreamable HTTP, or stdio for local serversJSON-RPC, HTTP+JSON or gRPC, with SSE for streaming
GovernanceAgentic AI Foundation (Linux Foundation) since December 2025Linux 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.

ComponentVersion on 7 July 2026Notes
A2A specification1.0, released 12 March 2026Previous widely deployed version: 0.3
A2A Python SDK (a2a-sdk)1.1.0, 29 May 2026Serves 1.0, with an opt-in 0.3 compatibility layer
Microsoft Agent Framework1.0, generally available since 3 April 2026.NET and Python
Agent Framework A2A packages for .NETPreview. A2A 1.0 support announced 28 April 2026Microsoft.Agents.AI.A2A, installed with --prerelease
AgentCore Runtime, A2A protocolContainer contract: port 9000, JSON-RPC on /AWS documentation examples use A2A 0.3.0 messages
MCP specification2025-11-25Streamable HTTP transport
MCP Python SDK (mcp)1.28.1, 26 June 2026Used for the tool server below
MCP C# SDK (ModelContextProtocol)1.4.0, 4 June 2026Stable since 1.0.0 in February 2026
AgentCore Runtime, MCP protocolContainer contract: port 8000, path /mcpStateless streamable HTTP expected
ℹ️
READ THE TABLE AS A RISK REGISTER

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.

Table comparing A2A 0.3 and A2A 1.0 on the wire: method names, version header, part shape, role and state values, result shape and agent card address, with the version-not-supported error.
The same request in both wire formats. Values on the right are what a2a-sdk 1.1.0 returned in my test.

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:#4C1D95
⚠️
CHECK THAT THE HEADER SURVIVES THE PLATFORM

AgentCore 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.

💡
HOW THIS WAS CHECKED

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));
⚠️
THIS C# HAS NOT BEEN COMPILED

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 + artifact

Identity 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:#4C1D95

Running it in production

  • Task state must outlive the process. InMemoryTaskStore is fine for a test. AgentCore isolates sessions from one another, so a later GetTask that 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 messageId so 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.
⚖️
A CROSS-AGENT CALL IS A DATA TRANSFER

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
SituationUseReason
Look up a record, call an API, run a queryMCP toolThe caller’s model can choose arguments safely from a schema
Another team’s agent with its own prompt and guardrailsA2AThe internals stay private and the owner keeps control
Two agents in one codebase and one deploymentFramework workflowA network protocol adds latency and failure modes for no gain
Work that takes minutes and reports progressA2ATasks, streaming and push notifications are built in
Third-party agent you do not operateA2A, behind your own gatewayYou 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-Version header. 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.

📝
UPDATE, NOVEMBER 2026

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


Discover more from C4: Container, Code, Cloud & Context

Subscribe to get the latest posts sent to your email.

Leave a comment

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.