On this page
Part 3 of the seven-part series Building an Agentic NOC. Each part builds on the ones before it; the full list is at the end.
We built the vertical axis last time: a minimal MCP server, one tool, and the JSON-RPC that carries a function call from an agent down to a tool. This post builds the horizontal axis. A2A is how one agent discovers another and hands it work, and the difference from MCP is not cosmetic. An MCP tool is a typed function the model invokes and whose result the model reads. An A2A agent is a peer you send a message to, and it decides what to do with it. We are going to build the smallest agent that another agent can find and delegate to, and again every line of output below is captured from a real run.
What we are building
An agent that answers subnet questions. You send it a message like "what is the network for 10.110.0.0/24?" and it replies with the network, broadcast, netmask, and host count. Deliberately the same problem domain as the MCP post, because the contrast is the lesson: last time we exposed a typed subnet_info(cidr) function, this time we expose an agent you talk to in a message and that figures out the CIDR itself. Three dependencies.
pip install a2a-sdk fastapi uvicornOne word on the SDK version, because it will save you an afternoon. Pin a2a-sdk==0.3.26. The 1.x line is protobuf-first and its server wiring is awkward to construct by hand, while 0.3.x exposes the ergonomic pydantic types that every official sample uses. The core JSON-RPC methods and message shape are the same across the two, so pinning to 0.3.26 costs you nothing in fidelity and buys a clean API. The fastapi dependency is needed because the SDK's apps package imports its FastAPI application classes at import time, and without fastapi installed the fallback fails on Python 3.10, so it is required even when you serve with Starlette.
The server
An A2A server has two parts that matter: the Agent Card, which describes the agent to the world, and the executor, which is the logic that runs when a message arrives. Here is the whole thing, saved as server.py.
import ipaddress
import re
import uvicorn
from a2a.server.agent_execution import AgentExecutor, RequestContext
from a2a.server.apps import A2AStarletteApplication
from a2a.server.events import EventQueue
from a2a.server.request_handlers import DefaultRequestHandler
from a2a.server.tasks import InMemoryTaskStore
from a2a.types import AgentCapabilities, AgentCard, AgentSkill
from a2a.utils import new_agent_text_message
HOST, PORT = "127.0.0.1", 9000
def build_card() -> AgentCard:
skill = AgentSkill(
id="subnet-info",
name="Subnet information",
description="Given a message that mentions an IPv4 CIDR, return its network, "
"broadcast, netmask, and usable host count.",
tags=["network", "ipv4", "read-only"],
examples=["what is the network for 10.110.0.0/24?"],
)
return AgentCard(
name="subnet-agent",
description="A tiny A2A agent that answers subnet questions.",
url=f"http://{HOST}:{PORT}/",
version="0.1.0",
capabilities=AgentCapabilities(streaming=False),
default_input_modes=["text/plain"],
default_output_modes=["text/plain"],
skills=[skill],
)
class SubnetExecutor(AgentExecutor):
async def execute(self, context: RequestContext, event_queue: EventQueue) -> None:
text = context.get_user_input() or ""
m = re.search(r"\b(\d{1,3}(?:\.\d{1,3}){3}/\d{1,2})\b", text)
if not m:
await event_queue.enqueue_event(new_agent_text_message(
"subnet-agent: no IPv4 CIDR found in the message."))
return
net = ipaddress.ip_network(m.group(1), strict=False)
await event_queue.enqueue_event(new_agent_text_message(
f"{net}: network {net.network_address}, broadcast {net.broadcast_address}, "
f"netmask {net.netmask}, usable hosts {max(net.num_addresses - 2, 0)}"))
async def cancel(self, context: RequestContext, event_queue: EventQueue) -> None:
await event_queue.enqueue_event(new_agent_text_message("nothing to cancel"))
def build_app():
handler = DefaultRequestHandler(agent_executor=SubnetExecutor(), task_store=InMemoryTaskStore())
return A2AStarletteApplication(agent_card=build_card(), http_handler=handler).build()
if __name__ == "__main__":
uvicorn.run(build_app(), host=HOST, port=PORT, log_level="warning")The executor is where the difference from MCP lives. It does not receive typed arguments. It receives whatever text the caller sent, pulls the CIDR out itself with a regex, and enqueues a reply as a message. In a richer agent this is where a language model would sit, interpreting the request and deciding how to answer. The A2A contract is looser than MCP on purpose, because the whole idea is to delegate a task to a peer that knows its own domain, not to call a function with a fixed signature.
The Agent Card, the thing that makes discovery work
Start the server and fetch one specific URL. This is the mechanism that lets an agent be found by an agent that has never seen it before.
$ python3 server.py &
$ curl -s http://127.0.0.1:9000/.well-known/agent-card.jsonThe card is served at /.well-known/agent-card.json, a conventional location a client knows to check. This is the real document our server returns.
{
"name": "subnet-agent",
"description": "A tiny A2A agent that answers subnet questions.",
"url": "http://127.0.0.1:9000/",
"version": "0.1.0",
"protocolVersion": "0.3.0",
"preferredTransport": "JSONRPC",
"capabilities": {"streaming": false},
"defaultInputModes": ["text/plain"],
"defaultOutputModes": ["text/plain"],
"skills": [
{
"id": "subnet-info",
"name": "Subnet information",
"description": "Given a message that mentions an IPv4 CIDR, return its network, broadcast, netmask, and usable host count.",
"tags": ["network", "ipv4", "read-only"],
"examples": ["what is the network for 10.110.0.0/24?"]
}
]
}Read it as a contract. The name and description say who this is. The url and preferredTransport say where and how to reach it. The protocolVersion, here 0.3.0, is the A2A spec version the SDK implements. And skills is the list of things the agent can do, each with an id, a human description, tags, and examples. A supervisor that wants a subnet answered can fetch this card, see the subnet-info skill, and know it has found the right agent, all without a line of hardcoded integration. Discovery is the protocol's first-class feature, not an afterthought.
The client
A client fetches the card, builds a connection from it, and sends a message. Save this as client.py.
import asyncio
from uuid import uuid4
import httpx
from a2a.client import A2ACardResolver
from a2a.client.client import ClientConfig
from a2a.client.client_factory import ClientFactory
from a2a.types import Message, Role, TextPart
async def main():
async with httpx.AsyncClient(timeout=30) as hx:
card = await A2ACardResolver(hx, "http://127.0.0.1:9000").get_agent_card()
print("discovered:", card.name, "| skills:", [s.id for s in card.skills])
client = ClientFactory(ClientConfig(httpx_client=hx, streaming=False)).create(card)
msg = Message(message_id=uuid4().hex, role=Role.user,
parts=[TextPart(text="what is the network for 10.110.0.0/24?")])
async for event in client.send_message(msg):
parts = getattr(event, "parts", None) or []
for p in parts:
t = getattr(getattr(p, "root", p), "text", None)
if t:
print("reply:", t)
asyncio.run(main())$ python3 client.py
discovered: subnet-agent | skills: ['subnet-info']
reply: 10.110.0.0/24: network 10.110.0.0, broadcast 10.110.0.255, netmask 255.255.255.0, usable hosts 254The client never imported the server and was never told what the agent could do. It read the card, saw the skill, and sent a plain message. That is agent-to-agent discovery and delegation in about twenty lines.
What is on the wire
As with MCP, remove the SDK and speak the protocol directly, so you can see there is nothing hidden. A2A also uses JSON-RPC 2.0, but over HTTP rather than stdio, and the method is message/send. Here is the request, a plain HTTP POST to the agent's URL.
curl -s http://127.0.0.1:9000/ -H "Content-Type: application/json" -d '{
"jsonrpc":"2.0","id":1,"method":"message/send",
"params":{"message":{"messageId":"abc123","role":"user",
"parts":[{"kind":"text","text":"subnet for 10.110.0.0/24?"}]}}}'And the real response.
{
"jsonrpc": "2.0",
"id": 1,
"result": {
"kind": "message",
"messageId": "887ebfc0-d2fa-487f-9e6d-90b8eece7f8c",
"role": "agent",
"parts": [
{"kind": "text",
"text": "10.110.0.0/24: network 10.110.0.0, broadcast 10.110.0.255, netmask 255.255.255.0, usable hosts 254"}
]
}
}Compare this to the MCP call from the last post. There, the method was tools/call and the parameters were a tool name and a typed argument object. Here the method is message/send and the parameter is a message with text parts. There, the model was invoking a function. Here, an agent is talking to an agent. The response carries role: agent and a message made of parts, which is the shape A2A uses whether the answer is one line or a long streamed task.
The two layers, and how they compose
You have now built both axes, and the important realisation is that they stack. Our subnet-agent computed the answer itself, but it did not have to. It could just as easily have been an MCP client that called the subnet_info tool from the last post, and nothing about its Agent Card or its A2A interface would change. That is the architecture the rest of this series is built on: an agent speaks A2A upward to whoever delegates to it, and MCP downward to its own tools, with a clean seam between the two.
| MCP | A2A | |
|---|---|---|
| Axis | vertical, agent to tools | horizontal, agent to agent |
| You call | a typed function (tools/call) | a peer with a message (message/send) |
| Discovery | tools/list | Agent Card at a well-known URL |
| Transport here | stdio | HTTP |
| Result | typed content block | a message with parts |
Reproduce it
pip install "a2a-sdk==0.3.26" fastapi uvicorn
# save server.py and client.py from above
python3 server.py &
curl -s http://127.0.0.1:9000/.well-known/agent-card.json # the card
python3 client.py # discover + sendYou now have both protocols in your hands, one tool server and one agent, and you have watched the JSON-RPC that each one speaks. The next post steps back from code to trace how MCP, A2A, and ACP actually emerged and converged, because the history explains why the ecosystem looks the way it does. Then we build the real thing.
Building an Agentic NOC, the full series: