A customer asks about a refund for headphones they ordered last week. Your support agent receives the message, but it doesn't own the refund calculation logic. A finance agent does. So the support agent delegates the task to the finance agent, which calls a payment tool to look up the order, calculates the refund, and returns the result. The customer gets an answer. Three components worked together, and none of them had to know how the others were built.
That coordination requires two protocols. MCP handles how agents connect to tools. A2A handles how agents talk to each other. In the first article of this series, we built an MCP server from scratch. Now we'll add the second protocol and wire them together.
A2A (Agent-to-Agent Protocol) was released by Google in April 2025, reaching v1.0 by early 2026 with 50+ partners including Atlassian, Salesforce, and SAP. Both protocols now live under the Linux Foundation's Agentic AI Foundation. They're not competing standards. They're complementary layers of the same stack.
In this article, you'll build both. An MCP server that exposes tools, and an A2A agent that delegates tasks to other agents. By the end, you'll have a working system where a client uses A2A to delegate work to a specialized agent, which uses MCP to execute tools. The full stack.
| Layer | Protocol | What You'll Build |
|---|---|---|
| Tool execution | MCP | An order lookup MCP server with Streamable HTTP transport |
| Agent delegation | A2A | A support agent that accepts tasks via A2A and calls MCP tools |
| Orchestration | A2A client | A client that discovers agents and delegates work |
| Discovery | Agent Cards | JSON metadata so agents can find each other |
| Auth | OAuth 2.1 + API keys | Production patterns for securing both protocols |
How do MCP and A2A actually fit together?
MCP connects an agent to its tools (vertical). A2A connects agents to each other (horizontal). They sit at different layers of the same stack, like TCP and HTTP. You need both when specialized agents own different tools and need to coordinate on a single request. Think of MCP as the hands and A2A as the conversation between coworkers.
| MCP | A2A | |
|---|---|---|
| Direction | Vertical (agent to tools) | Horizontal (agent to agent) |
| Transport | Streamable HTTP, stdio | JSON-RPC over HTTP, SSE for streaming |
| Discovery | Protocol handshake (initialize) | Agent Card at /.well-known/agent.json |
| Auth | OAuth 2.1 with PKCE (public servers) | Declared in Agent Card securitySchemes |
| Foundation | Linux Foundation (Agentic AI) | Linux Foundation (Agentic AI) |
Here's the flow from the scenario above. A support agent receives a customer request through A2A. It doesn't own every tool itself, so it delegates "calculate refund" to a finance agent (also via A2A). The finance agent uses MCP to call its refund calculation tool and payment gateway tool. The result flows back through A2A to the support agent, which responds to the customer.
The key insight: MCP and A2A don't compete. One handles the connection (agent to tools), the other handles the conversation (agent to agent). Most "MCP vs A2A" articles stop at comparison tables. We're going to build both layers and wire them together.
How do you build an MCP server with Streamable HTTP?
Production MCP servers run over Streamable HTTP, not stdio. Streamable HTTP replaced SSE in the March 2025 spec update and lets any network client reach your tools through standard HTTP POST requests. You deploy the server as a standalone service, put it behind a load balancer, and add auth.
In the first MCP tutorial, we built a weather server over stdio transport. That works for local development, but not for agents calling tools across the network.
We'll build an order lookup service. This is the kind of tool a customer support agent actually needs: look up an order by ID, check its status, calculate a refund amount. If you haven't read the first MCP article yet, that's fine. Each code block is self-contained and we'll explain the MCP concepts as we go. But if you want the deep dive on MCP primitives (tools, resources, prompts), start there.
TypeScript MCP server with Streamable HTTP
Set up the project and install dependencies. We're using the same @modelcontextprotocol/sdk package from the first article, plus Express for the HTTP server.
mkdir mcp-order-server && cd mcp-order-server
npm init -y
npm install @modelcontextprotocol/sdk zod express
npm install -D typescript @types/node @types/express
npx tsc --initUpdate tsconfig.json for ES modules:
{
"compilerOptions": {
"target": "ES2022",
"module": "Node16",
"moduleResolution": "Node16",
"outDir": "./build",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true
},
"include": ["src/**/*"]
}And set "type": "module" in your package.json.
Now the server. We register three tools, then expose them over Streamable HTTP instead of stdio. The key difference from the first article: instead of piping through stdin/stdout, clients reach this server via HTTP POST.
// src/index.ts
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
import express from "express";
import { z } from "zod";
// Mock order database
const orders: Record<string, {
id: string;
customer: string;
status: string;
total: number;
items: { name: string; price: number; qty: number }[];
createdAt: string;
}> = {
"ORD-1001": {
id: "ORD-1001",
customer: "alice@example.com",
status: "delivered",
total: 149.97,
items: [
{ name: "Wireless Headphones", price: 79.99, qty: 1 },
{ name: "USB-C Cable", price: 14.99, qty: 2 },
{ name: "Phone Case", price: 39.99, qty: 1 },
],
createdAt: "2026-03-15T10:30:00Z",
},
"ORD-1002": {
id: "ORD-1002",
customer: "bob@example.com",
status: "shipped",
total: 299.99,
items: [{ name: "Mechanical Keyboard", price: 299.99, qty: 1 }],
createdAt: "2026-03-28T14:15:00Z",
},
};
// Create the MCP server
const server = new McpServer({
name: "order-service",
version: "1.0.0",
});
// Tool 1: Look up an order by ID
server.tool(
"lookup-order",
"Look up an order by its order ID. Returns order details including status, items, and total.",
{ orderId: z.string().describe("The order ID, e.g. ORD-1001") },
async ({ orderId }) => {
const order = orders[orderId.toUpperCase()];
if (!order) {
return {
content: [{ type: "text", text: `No order found with ID: ${orderId}` }],
};
}
return {
content: [{ type: "text", text: JSON.stringify(order, null, 2) }],
};
}
);
// Tool 2: Check order status
server.tool(
"check-order-status",
"Check the current status of an order. Returns the status and estimated delivery.",
{ orderId: z.string().describe("The order ID to check") },
async ({ orderId }) => {
const order = orders[orderId.toUpperCase()];
if (!order) {
return {
isError: true,
content: [{ type: "text", text: `Order ${orderId} not found` }],
};
}
const estimates: Record<string, string> = {
pending: "3-5 business days",
shipped: "1-2 business days",
delivered: "Already delivered",
};
return {
content: [{
type: "text",
text: JSON.stringify({
orderId: order.id,
status: order.status,
estimatedDelivery: estimates[order.status] || "Unknown",
}, null, 2),
}],
};
}
);
// Tool 3: Calculate refund
server.tool(
"calculate-refund",
"Calculate the refund amount for returned items from an order.",
{
orderId: z.string().describe("The order ID"),
itemNames: z.array(z.string()).describe("Names of items being returned"),
},
async ({ orderId, itemNames }) => {
const order = orders[orderId.toUpperCase()];
if (!order) {
return {
isError: true,
content: [{ type: "text", text: `Order ${orderId} not found` }],
};
}
const returnItems = order.items.filter((item) =>
itemNames.some((name) =>
item.name.toLowerCase().includes(name.toLowerCase())
)
);
const refundAmount = returnItems.reduce(
(sum, item) => sum + item.price * item.qty, 0
);
return {
content: [{
type: "text",
text: JSON.stringify({
orderId: order.id,
returnedItems: returnItems,
refundAmount: refundAmount.toFixed(2),
originalTotal: order.total.toFixed(2),
}, null, 2),
}],
};
}
);
// Set up Streamable HTTP transport with Express
const app = express();
app.use(express.json());
app.post("/mcp", async (req, res) => {
const transport = new StreamableHTTPServerTransport({
sessionIdGenerator: undefined, // stateless mode
});
res.on("close", () => transport.close());
await server.connect(transport);
await transport.handleRequest(req, res, req.body);
});
app.get("/health", (_req, res) => {
res.json({ status: "ok", server: "order-service", tools: 3 });
});
app.listen(3050, () => {
console.error("MCP order-service running on http://localhost:3050");
console.error("MCP endpoint: POST http://localhost:3050/mcp");
});Build and run it:
npx tsc && node build/index.jsTest the health endpoint: curl http://localhost:3050/health. You should see {"status":"ok","server":"order-service","tools":3}.
Notice the difference from our stdio server in the first article. With stdio, the client spawned the server as a child process and communicated through stdin/stdout pipes. With Streamable HTTP, the server runs independently as an HTTP service. Any client on the network can reach it. This is the transport you'd use in production: deploy the MCP server as a service, put it behind a load balancer, add authentication. The tools are the same, the schemas are the same, only the transport layer changes.
Python MCP server with Streamable HTTP
The Python equivalent is shorter, as usual. FastMCP infers schemas from type hints and docstrings.
# server.py
from mcp.server.fastmcp import FastMCP
mcp = FastMCP("order-service")
# Mock data
ORDERS = {
"ORD-1001": {
"id": "ORD-1001",
"customer": "alice@example.com",
"status": "delivered",
"total": 149.97,
"items": [
{"name": "Wireless Headphones", "price": 79.99, "qty": 1},
{"name": "USB-C Cable", "price": 14.99, "qty": 2},
{"name": "Phone Case", "price": 39.99, "qty": 1},
],
"createdAt": "2026-03-15T10:30:00Z",
},
"ORD-1002": {
"id": "ORD-1002",
"customer": "bob@example.com",
"status": "shipped",
"total": 299.99,
"items": [{"name": "Mechanical Keyboard", "price": 299.99, "qty": 1}],
"createdAt": "2026-03-28T14:15:00Z",
},
}
@mcp.tool()
def lookup_order(order_id: str) -> str:
"""Look up an order by its order ID.
Args:
order_id: The order ID, e.g. ORD-1001
"""
import json
order = ORDERS.get(order_id.upper())
if not order:
return f"No order found with ID: {order_id}"
return json.dumps(order, indent=2)
@mcp.tool()
def check_order_status(order_id: str) -> str:
"""Check the current status and estimated delivery for an order.
Args:
order_id: The order ID to check
"""
import json
order = ORDERS.get(order_id.upper())
if not order:
return f"Order {order_id} not found"
estimates = {
"pending": "3-5 business days",
"shipped": "1-2 business days",
"delivered": "Already delivered",
}
return json.dumps({
"orderId": order["id"],
"status": order["status"],
"estimatedDelivery": estimates.get(order["status"], "Unknown"),
}, indent=2)
@mcp.tool()
def calculate_refund(order_id: str, item_names: list[str]) -> str:
"""Calculate the refund amount for returned items from an order.
Args:
order_id: The order ID
item_names: Names of items being returned
"""
import json
order = ORDERS.get(order_id.upper())
if not order:
return f"Order {order_id} not found"
returned = [
item for item in order["items"]
if any(name.lower() in item["name"].lower() for name in item_names)
]
refund = sum(item["price"] * item["qty"] for item in returned)
return json.dumps({
"orderId": order["id"],
"returnedItems": returned,
"refundAmount": f"{refund:.2f}",
"originalTotal": f"{order['total']:.2f}",
}, indent=2)
if __name__ == "__main__":
mcp.run(transport="streamable-http", host="0.0.0.0", port=3050)Run it with python server.py (or uv run server.py if you use uv). Same three tools, same port, same protocol. An MCP client can't tell which version is running.
You can verify both servers work by hitting them with the MCP Inspector:
npx @modelcontextprotocol/inspector --url http://localhost:3050/mcpThis opens the Inspector at localhost:6274 where you can discover tools and call lookup-order with {"orderId": "ORD-1001"}.
How do you build an A2A agent that calls MCP tools?
An A2A agent is a service that other agents can discover, send tasks to, and receive results from. It publishes an Agent Card (JSON metadata at a well-known URL), accepts JSON-RPC messages, and runs your logic to process tasks. Our support agent will accept customer queries via A2A and call the MCP order server from Part 1 to look up answers.
What does an A2A agent need?
An A2A agent has three parts. First, an Agent Card: a JSON document that describes what the agent can do, published at a well-known URL so other agents can discover it. Second, a request handler that accepts JSON-RPC messages (task submissions, status queries). Third, the actual agent logic that processes tasks.
The Agent Card is the key concept. It's A2A's answer to the question "how does one agent know what another agent can do?" In MCP, the client discovers tools through a protocol handshake. In A2A, agents discover each other through Agent Cards.
{
"name": "Customer Support Agent",
"description": "Handles order inquiries, status checks, and refund calculations",
"version": "1.0.0",
"capabilities": {
"streaming": false,
"pushNotifications": false
},
"defaultInputModes": ["text"],
"defaultOutputModes": ["text"],
"supportedInterfaces": [
{
"protocolBinding": "JSONRPC",
"url": "http://localhost:4000"
}
],
"skills": [
{
"id": "order-lookup",
"name": "Order Lookup",
"description": "Look up order details, status, and calculate refunds for customer orders",
"tags": ["orders", "support", "refunds"]
}
]
}The skills array is like a menu. A client agent reads these descriptions to decide whether this agent can handle a particular task. It's the same principle as MCP tool descriptions: the LLM reads them to make routing decisions.
TypeScript A2A agent
Install the official A2A SDK alongside the MCP client SDK (so our agent can call MCP tools):
mkdir a2a-support-agent && cd a2a-support-agent
npm init -y
npm install @a2a-js/sdk express uuid
npm install @modelcontextprotocol/sdk
npm install -D typescript @types/node @types/express @types/uuid
npx tsc --initUse the same tsconfig.json from Part 1 and set "type": "module" in package.json.
The A2A agent has two main pieces: the Agent Card definition and the AgentExecutor that processes incoming tasks. The executor is where your agent's logic lives. When another agent sends a task, A2A routes it to your executor.
// src/agent.ts
import express from "express";
import { v4 as uuidv4 } from "uuid";
import {
type AgentExecutor,
type RequestContext,
type ExecutionEventBus,
DefaultRequestHandler,
InMemoryTaskStore,
} from "@a2a-js/sdk/server";
import { type AgentCard, type Message } from "@a2a-js/sdk";
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
// Define the Agent Card
const agentCard: AgentCard = {
name: "Customer Support Agent",
description:
"Handles order inquiries, status checks, and refund calculations. " +
"Send a customer question about an order and get a detailed response.",
version: "1.0.0",
capabilities: { streaming: false, pushNotifications: false },
defaultInputModes: ["text"],
defaultOutputModes: ["text"],
supportedInterfaces: [
{ protocolBinding: "JSONRPC", url: "http://localhost:4000" },
],
skills: [
{
id: "order-lookup",
name: "Order Lookup",
description:
"Look up order details, check status, and calculate refunds",
tags: ["orders", "support", "refunds"],
},
],
};
// MCP client helper: call a tool on the order service
async function callMcpTool(
toolName: string,
args: Record<string, unknown>
): Promise<string> {
const transport = new StreamableHTTPClientTransport(
new URL("http://localhost:3050/mcp")
);
const client = new Client(
{ name: "support-agent", version: "1.0.0" },
{ capabilities: {} }
);
try {
await client.connect(transport);
const result = await client.callTool({ name: toolName, arguments: args });
const textContent = result.content.find(
(c: { type: string }) => c.type === "text"
);
return textContent ? (textContent as { text: string }).text : "No result";
} finally {
await client.close();
}
}
// Simple intent detection from the customer message
function detectIntent(message: string): {
tool: string;
args: Record<string, unknown>;
} | null {
const lower = message.toLowerCase();
// Extract order ID pattern
const orderMatch = message.match(/ORD-\d+/i);
const orderId = orderMatch ? orderMatch[0].toUpperCase() : null;
if (!orderId) return null;
if (lower.includes("refund") || lower.includes("return")) {
// Extract item names after "return" or "refund"
const itemMatch = lower.match(
/(?:return|refund).*?(?:the|my)?\s+(.+?)(?:\s+from|\s+on|\s*$)/
);
const itemNames = itemMatch
? itemMatch[1].split(/,\s*| and /).map((s) => s.trim())
: [];
return {
tool: "calculate-refund",
args: { orderId, itemNames },
};
}
if (lower.includes("status") || lower.includes("where")) {
return { tool: "check-order-status", args: { orderId } };
}
return { tool: "lookup-order", args: { orderId } };
}
// The AgentExecutor processes incoming A2A tasks
class SupportAgentExecutor implements AgentExecutor {
async execute(
context: RequestContext,
eventBus: ExecutionEventBus
): Promise<void> {
const userMessage =
context.message?.parts
?.filter((p: { kind: string }) => p.kind === "text")
.map((p: { text: string }) => p.text)
.join(" ") || "";
console.error(`[SupportAgent] Received: "${userMessage}"`);
// Detect what the customer wants and which MCP tool to call
const intent = detectIntent(userMessage);
let responseText: string;
if (!intent) {
responseText =
"I can help with order lookups, status checks, and refund " +
"calculations. Please include your order ID (e.g., ORD-1001).";
} else {
try {
console.error(
`[SupportAgent] Calling MCP tool: ${intent.tool}`,
intent.args
);
const toolResult = await callMcpTool(intent.tool, intent.args);
responseText =
`Here's what I found:\n\n${toolResult}`;
} catch (error) {
responseText =
`I encountered an error looking up your order. ` +
`Please try again or contact us directly.`;
console.error("[SupportAgent] MCP error:", error);
}
}
// Publish the response back through A2A
const responseMessage: Message = {
kind: "message",
messageId: uuidv4(),
role: "agent",
parts: [{ kind: "text", text: responseText }],
contextId: context.contextId,
};
eventBus.publish(responseMessage);
eventBus.finished();
}
// Required by AgentExecutor interface
cancelTask = async (): Promise<void> => {};
}
// Wire it all together with an Express server
const taskStore = new InMemoryTaskStore();
const handler = new DefaultRequestHandler(
agentCard,
taskStore,
new SupportAgentExecutor()
);
const app = express();
app.use(express.json());
// A2A well-known endpoint for agent discovery
app.get("/.well-known/agent.json", (_req, res) => {
res.json(agentCard);
});
// A2A JSON-RPC endpoint
app.post("/", async (req, res) => {
const result = await handler.handleRequest(req.body);
res.json(result);
});
app.listen(4000, () => {
console.error("A2A Support Agent running on http://localhost:4000");
console.error(
"Agent Card: http://localhost:4000/.well-known/agent.json"
);
});Four pieces worth noting in that code:
The Agent Card is the metadata other agents read to discover this agent. The skills array describes what tasks it can handle, published at /.well-known/agent.json.
The MCP client helper (callMcpTool) creates a Streamable HTTP connection to our order server from Part 1, calls a tool, and closes. In production you'd pool these connections.
The intent detection is deliberately simple: extract an order ID, route to the right MCP tool. In a real system, you'd use an LLM here. The point is to show the protocol plumbing without hiding it behind AI inference.
The AgentExecutor is the core of A2A. When another agent sends a task, the executor receives the message, calls MCP tools, and publishes the response back through A2A's event bus.
Python A2A agent
The Python A2A SDK uses Starlette and Uvicorn. Install with:
pip install "a2a-sdk[http-server]" uvicorn
pip install mcp# agent.py
import json
import re
import uvicorn
from a2a.server.apps import A2AStarletteApplication
from a2a.server.request_handlers import DefaultRequestHandler
from a2a.server.agent_execution import AgentExecutor, RequestContext
from a2a.server.events import EventQueue
from a2a.server.tasks import InMemoryTaskStore
from a2a.types import (
AgentCard, AgentSkill, AgentCapabilities, AgentInterface,
Message, TextPart,
)
from mcp import ClientSession
from mcp.client.streamable_http import streamablehttp_client
# Agent Card definition
agent_card = AgentCard(
name="Customer Support Agent",
description=(
"Handles order inquiries, status checks, and refund calculations. "
"Send a customer question about an order and get a detailed response."
),
version="1.0.0",
capabilities=AgentCapabilities(streaming=False),
default_input_modes=["text"],
default_output_modes=["text"],
supported_interfaces=[
AgentInterface(
protocol_binding="JSONRPC",
url="http://localhost:4000",
)
],
skills=[
AgentSkill(
id="order-lookup",
name="Order Lookup",
description="Look up order details, check status, and calculate refunds",
tags=["orders", "support", "refunds"],
)
],
)
async def call_mcp_tool(
tool_name: str, args: dict
) -> str:
"""Call a tool on the MCP order service."""
async with streamablehttp_client("http://localhost:3050/mcp") as (
read_stream, write_stream, _
):
async with ClientSession(read_stream, write_stream) as session:
await session.initialize()
result = await session.call_tool(tool_name, arguments=args)
for content in result.content:
if content.type == "text":
return content.text
return "No result"
def detect_intent(message: str) -> tuple[str, dict] | None:
"""Simple intent detection from customer message."""
lower = message.lower()
match = re.search(r"ORD-\d+", message, re.IGNORECASE)
if not match:
return None
order_id = match.group(0).upper()
if "refund" in lower or "return" in lower:
item_match = re.search(
r"(?:return|refund).*?(?:the|my)?\s+(.+?)(?:\s+from|\s+on|\s*$)",
lower,
)
item_names = (
[s.strip() for s in re.split(r",\s*| and ", item_match.group(1))]
if item_match
else []
)
return "calculate-refund", {"orderId": order_id, "itemNames": item_names}
if "status" in lower or "where" in lower:
return "check-order-status", {"orderId": order_id}
return "lookup-order", {"orderId": order_id}
class SupportAgentExecutor(AgentExecutor):
"""Processes incoming A2A tasks by calling MCP tools."""
async def execute(
self, context: RequestContext, event_queue: EventQueue
) -> None:
user_text = ""
if context.message and context.message.parts:
user_text = " ".join(
p.text for p in context.message.parts if hasattr(p, "text")
)
print(f'[SupportAgent] Received: "{user_text}"')
intent = detect_intent(user_text)
if not intent:
response_text = (
"I can help with order lookups, status checks, and refund "
"calculations. Please include your order ID (e.g., ORD-1001)."
)
else:
tool_name, args = intent
try:
print(f"[SupportAgent] Calling MCP tool: {tool_name}", args)
tool_result = await call_mcp_tool(tool_name, args)
response_text = f"Here's what I found:\n\n{tool_result}"
except Exception as e:
response_text = (
"I encountered an error looking up your order. "
"Please try again or contact us directly."
)
print(f"[SupportAgent] MCP error: {e}")
await event_queue.enqueue_event(
Message(
role="agent",
parts=[TextPart(text=response_text)],
)
)
# Wire it all up
request_handler = DefaultRequestHandler(
agent_executor=SupportAgentExecutor(),
task_store=InMemoryTaskStore(),
)
a2a_app = A2AStarletteApplication(
agent_card=agent_card, http_handler=request_handler
)
if __name__ == "__main__":
uvicorn.run(a2a_app.build(), host="0.0.0.0", port=4000)Same structure, same logic, different language. The A2A protocol doesn't care. This is one of the strongest arguments for protocol standards over framework abstractions. Your TypeScript orchestrator can delegate to a Python agent, which calls tools on a Go MCP server. Each team picks their language. The protocol is the contract.
How does an A2A client discover agents and send tasks?
An A2A client finds agents by fetching their Agent Card at /.well-known/agent.json, then delegates work by sending JSON-RPC messages to the agent's endpoint. The client reads the card's skills array to decide if the agent can handle a task, then posts a message/send request with the user's query. In a real system, this client would be your orchestrator agent.
TypeScript A2A client
This client discovers the support agent, sends a customer query, and prints the response. In a real system, this would be your orchestrator agent.
// src/client.ts
// Step 1: Discover the agent by fetching its Agent Card
async function discoverAgent(baseUrl: string) {
const res = await fetch(`${baseUrl}/.well-known/agent.json`);
if (!res.ok) throw new Error(`Agent discovery failed: ${res.status}`);
const card = await res.json();
console.log(`Discovered agent: ${card.name}`);
console.log(`Skills: ${card.skills.map((s: { name: string }) => s.name).join(", ")}`);
return card;
}
// Step 2: Send a task using A2A's JSON-RPC format
async function sendTask(agentUrl: string, message: string) {
const requestBody = {
jsonrpc: "2.0",
id: crypto.randomUUID(),
method: "message/send",
params: {
message: {
role: "user",
parts: [{ kind: "text", text: message }],
},
},
};
const res = await fetch(agentUrl, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(requestBody),
});
const response = await res.json();
if (response.error) {
throw new Error(`A2A error: ${response.error.message}`);
}
return response.result;
}
// Run the client
async function main() {
const agentUrl = "http://localhost:4000";
// Discover
const card = await discoverAgent(agentUrl);
// Send tasks
const queries = [
"What's the status of order ORD-1002?",
"I'd like to return the Wireless Headphones from ORD-1001",
"Can you look up order ORD-1001?",
];
for (const query of queries) {
console.log(`\n--- Sending: "${query}" ---`);
const result = await sendTask(agentUrl, query);
// Extract the agent's text response
if (result.message) {
const text = result.message.parts
.filter((p: { kind: string }) => p.kind === "text")
.map((p: { text: string }) => p.text)
.join("\n");
console.log("Response:", text);
}
}
}
main().catch(console.error);Python A2A client
# client.py
import asyncio
import json
import uuid
import httpx
async def discover_agent(base_url: str) -> dict:
"""Fetch the agent's Agent Card."""
async with httpx.AsyncClient() as client:
res = await client.get(f"{base_url}/.well-known/agent.json")
res.raise_for_status()
card = res.json()
print(f"Discovered agent: {card['name']}")
skills = ", ".join(s["name"] for s in card["skills"])
print(f"Skills: {skills}")
return card
async def send_task(agent_url: str, message: str) -> dict:
"""Send a task to an A2A agent using JSON-RPC."""
request_body = {
"jsonrpc": "2.0",
"id": str(uuid.uuid4()),
"method": "message/send",
"params": {
"message": {
"role": "user",
"parts": [{"kind": "text", "text": message}],
},
},
}
async with httpx.AsyncClient() as client:
res = await client.post(
agent_url,
json=request_body,
headers={"Content-Type": "application/json"},
)
response = res.json()
if "error" in response:
raise RuntimeError(f"A2A error: {response['error']['message']}")
return response["result"]
async def main():
agent_url = "http://localhost:4000"
# Discover
await discover_agent(agent_url)
# Send tasks
queries = [
"What's the status of order ORD-1002?",
"I'd like to return the Wireless Headphones from ORD-1001",
"Can you look up order ORD-1001?",
]
for query in queries:
print(f'\n--- Sending: "{query}" ---')
result = await send_task(agent_url, query)
if "message" in result:
text = "\n".join(
p["text"]
for p in result["message"]["parts"]
if p.get("kind") == "text"
)
print("Response:", text)
asyncio.run(main())Running the full stack
Open three terminals:
# Terminal 1: MCP order server (from Part 1)
cd mcp-order-server && node build/index.js
# Terminal 2: A2A support agent (from Part 2)
cd a2a-support-agent && npx tsc && node build/agent.js
# Terminal 3: A2A client
cd a2a-support-agent && npx tsc && node build/client.jsYou should see the client discover the agent, send three queries, and receive tool-powered responses. The data flow is: Client (A2A) -> Support Agent (A2A) -> Order Service (MCP) -> back through the chain.
That's the protocol stack in action. A2A for delegation, MCP for execution.
How do you secure MCP and A2A in production?
Neither protocol invents new auth. Both use standard HTTP mechanisms, which means your existing identity provider (Auth0, Okta, Azure AD) works unchanged.
MCP mandates OAuth 2.1 with PKCE for public remote servers. The MCP server acts as a resource server (validates tokens, never issues them). For private or internal servers, API keys work fine. In practice, this means adding standard bearer token middleware to your Express MCP server:
// Add to your MCP server's Express setup
function requireAuth(
req: express.Request,
res: express.Response,
next: express.NextFunction
) {
const authHeader = req.headers.authorization;
if (!authHeader?.startsWith("Bearer ")) {
res.status(401).json({ error: "Missing bearer token" });
return;
}
const token = authHeader.slice(7);
// In production: validate JWT against your auth provider's JWKS
// const payload = await jwtVerify(token, JWKS);
next();
}
app.post("/mcp", requireAuth, async (req, res) => {
// ... existing handler
});A2A agents declare auth requirements in the Agent Card itself, so clients discover what credentials to send before making the first request:
{
"name": "Customer Support Agent",
"supportedInterfaces": [
{ "protocolBinding": "JSONRPC", "url": "http://localhost:4000" }
],
"securitySchemes": {
"bearer": { "type": "http", "scheme": "bearer", "bearerFormat": "JWT" }
},
"security": [{ "bearer": [] }]
}The client reads the securitySchemes, then attaches the token via standard Authorization: Bearer headers. No new concepts. The protocol tells you where to put the token and how agents discover what auth is required.
What changes when you add streaming?
A2A supports streaming through message/stream, which returns Server-Sent Events as the agent works. Instead of blocking until the task finishes, the client receives status updates and partial results in real time. This matters for any task that takes more than a few seconds, like a refund workflow that calls three external APIs.
// Streaming A2A client
async function sendStreamingTask(agentUrl: string, message: string) {
const requestBody = {
jsonrpc: "2.0",
id: crypto.randomUUID(),
method: "message/stream",
params: {
message: {
role: "user",
parts: [{ kind: "text", text: message }],
},
},
};
const res = await fetch(agentUrl, {
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "text/event-stream",
},
body: JSON.stringify(requestBody),
});
// Read the SSE stream
const reader = res.body?.getReader();
const decoder = new TextDecoder();
while (reader) {
const { done, value } = await reader.read();
if (done) break;
const chunk = decoder.decode(value);
const lines = chunk.split("\n").filter((l) => l.startsWith("data: "));
for (const line of lines) {
const event = JSON.parse(line.slice(6));
switch (event.kind) {
case "status-update":
console.log(`Status: ${event.status.state}`);
break;
case "artifact-update":
console.log("Received artifact:", event.artifact);
break;
case "message":
const text = event.message.parts
.filter((p: { kind: string }) => p.kind === "text")
.map((p: { text: string }) => p.text)
.join("\n");
console.log("Agent:", text);
break;
}
}
}
}The streaming pattern lets you build UIs that show "Checking order status..." then "Calculating refund..." then the final result, rather than a spinner for 30 seconds followed by a wall of text.
On the MCP side, Streamable HTTP already supports this. When an MCP tool call takes time (say, a database migration or a large file processing), the server can stream progress events back to the client through the same HTTP connection. The two streaming models (A2A for task progress, MCP for tool execution progress) layer naturally. Your A2A agent publishes "working" status updates while it waits for a streaming MCP tool call to finish.
What should the production architecture look like?
A production agent system puts MCP servers behind an API gateway with rate limiting and auth, runs A2A agents as independent services with their own Agent Cards, and uses an orchestrator agent that discovers and routes to specialized agents based on their skill descriptions. The key principle: each agent owns its tools (MCP) and advertises its capabilities (A2A).
Here's the architecture we've been building toward:
A few production considerations that go beyond what we built:
Agent Card caching. The orchestrator shouldn't fetch Agent Cards on every request. Cache them with a TTL (5 minutes is reasonable) and refresh on cache miss.
Connection pooling for MCP. Our example creates a new MCP client per tool call. In production, maintain a pool of persistent connections to each MCP server.
Retry and circuit breaking. If an MCP tool call fails or an A2A agent is unresponsive, the orchestrator needs fallback behavior. Scenario testing is how you verify these failure modes before production.
Observability. Every A2A delegation and every MCP tool call needs tracing. Without it, when a customer query returns wrong data, you can't tell whether the orchestrator routed to the wrong agent, the agent called the wrong tool, or the tool returned stale data. This is where scorecards that track per-tool reliability become essential. Our article on building an eval framework covers the scoring methodology.
Tool management at scale. When you have 20 agents with 5 tools each, keeping track of tool definitions, versions, and credentials becomes its own infrastructure problem. This is exactly the problem a centralized tool management layer solves. Manage tools and credentials in one place, assign them to agents via toolsets, and monitor execution across the board. We covered the DIY version (and its failure modes) in the tool system article.
What about using A2A without MCP (or vice versa)?
You can absolutely use either protocol independently. MCP alone works perfectly for single-agent systems where one agent needs tools. A2A alone works for multi-agent orchestration where agents produce results from their own reasoning rather than external tool calls. The combination becomes necessary when specialized agents need specialized tools.
MCP only is the right choice when:
- You have one agent (or a few) that needs to call external tools
- Your tool catalog is the scaling challenge, not agent coordination
- You're building MCP servers that multiple AI clients (Claude, ChatGPT, VS Code) consume
A2A only makes sense when:
- Your agents produce results from reasoning, not external tool calls
- You need to orchestrate across vendor boundaries (your agent delegates to a partner's agent)
- Agent specialization is more important than tool management
Both together is what you need when:
- Specialized agents own specialized tools (the finance agent owns payment tools)
- You want clean separation of concerns (the orchestrator doesn't know about tool schemas)
- You're building a platform where third-party agents bring their own tools
The most common production pattern we're seeing is the "hub and spoke" model. One orchestrator agent handles routing via A2A. Each spoke agent is a specialist with its own MCP servers. The support agent has order tools. The finance agent has payment and invoice tools. The analytics agent has reporting and dashboard tools. The orchestrator reads their Agent Cards, picks the right spoke for each customer request, and delegates. No spoke agent needs to know about the others. No tool definitions leak across boundaries. It's clean, it scales, and you can add new agents without touching existing ones.
What should you build next?
You now have the full stack: MCP for tool execution, A2A for agent delegation, and a client that discovers agents through Agent Cards. The code runs. Where you go next depends on whether you're scaling tools, scaling agents, or measuring quality.
If you're focused on tools, MCP adoption is well past early-adopter stage. With 97 million monthly SDK downloads and 10,000+ active servers, there's an MCP server for most popular services. The challenge shifts from "how do I build a server" to "how do I manage 30 servers across 8 agents." That's where MCP hosting and tool management infrastructure earns its keep.
If you're focused on multi-agent systems, A2A tooling is earlier but moving fast. The official SDKs (@a2a-js/sdk for TypeScript, a2a-sdk for Python) are production-ready, and the spec is now under the same Linux Foundation umbrella as MCP. Start by building a two-agent system. Get the delegation flow right before adding more agents.
If you're focused on quality, the protocols don't solve the hardest problem: does the agent actually give correct answers? The eval framework article in this series covers how to measure that systematically, and scenario testing lets you simulate multi-agent conversations before they reach real customers.
Remember the customer asking about their headphone refund? With the code in this article, that request flows through two protocols and three components without any of them knowing how the others are built. The support agent doesn't know the finance agent runs Python. The finance agent doesn't care that the MCP server uses Express. The protocol is the contract.
That's the real payoff of standards over frameworks. The plumbing disappears. What remains is the hard part: making sure each agent gives the right answer.
Run MCP servers and agent tools in production
Chanl hosts your MCP servers, manages tool credentials, monitors execution, and gives every agent the right tools. Focus on capabilities, not infrastructure.
Explore Chanl MCPCo-founder
Building the platform for AI agents at Chanl — tools, testing, and observability for customer experience.
Aprende IA Agéntica
Una lección por semana: técnicas prácticas para construir, probar y lanzar agentes IA. Desde ingeniería de prompts hasta monitoreo en producción. Aprende haciendo.



