AI & LLMs/Agentic AI · Part 5 of 8
·40 min read
How agents talk: MCP for tools, A2A for other agents
A first-timer's guide to the two protocols agents talk through: MCP to reach tools, data and prompts, and A2A to reach other agents. It builds up from a protocol-only demo to Strands agents and a three-agent travel planner, and reads a Groq rate-limit error line by line on the way.
Until now every tool and every agent in this series lived in one Python file. In giving agents tools a tool was a function with a decorator, and in agents that work together one agent called another as if it were a tool. That works while you write everything yourself. It stops working when the weather tool is someone else's service, the documentation search is run by AWS, and the flights agent belongs to another team.
This post covers the two protocols that solve that problem: MCP (Model Context Protocol), which connects an agent to tools and data, and A2A (Agent-to-Agent), which connects an agent to other agents.
| Lab | What runs | What it teaches |
|---|---|---|
| MCP basics | A server and a client, no LLM at all | The protocol itself: every message and primitive |
| Strands + MCP | An agent using your server, then the AWS Documentation server | How an agent gets tools it didn't write, and how to read a rate-limit error |
| A2A travel planner | Two agent servers and an orchestrator, in three terminals | How agents find each other and hand over work |
As in the last post, the model runs on Groq through Strands. Each idea gets its diagram before its code, and long outputs are trimmed (marked with …).
#Why agents need protocols
#The N × M problem
Say three apps (a chat app, an IDE, your agent) each want three tools (weather, AWS docs, a database). With no shared protocol, every pair needs its own connector: 3 × 3 = 9 pieces of glue code, and a fourth tool adds three more. With a protocol, each app learns it once and each tool offers it once: 3 + 3 = 6, and a new tool works in every app straight away.
without a protocol: connectors = N × M 3 apps, 3 tools → 9
with MCP: connectors = N + M 3 apps, 3 tools → 6
10 apps, 20 tools → 200 vs 30
#Two protocols, two directions
Data
| MCP | A2A | |
|---|---|---|
| Connects | An agent (or any LLM app) to tools, data and prompts | An agent to other agents |
| The other side is | A program that offers capabilities | An agent with its own model, tools and memory |
| Where the LLM lives | On the client side | On both sides |
| Discovery | tools/list, resources/list, prompts/list | An agent card at a fixed URL |
| Transport | stdio (local) or Streamable HTTP (remote) | HTTP: JSON-RPC, with SSE for streaming |
| Unit of work | A structured call that returns quickly | A task that can run for minutes |
| In this post | The weather server, the AWS docs server | The weather and flights agents |
Both send JSON-RPC 2.0 messages, so the traffic looks alike. The difference is who is on the other end.
#Part 1: MCP on its own, no LLM needed
The first lab has no model at all. A client starts a server and calls everything the server offers, so you see the protocol with nothing else in the way.
#Host, client and server
| Role | What it is | In this lab |
|---|---|---|
| Host | The app the user works in. It owns the UI, the memory and the model | client.py, and later the Strands agent |
| MCP client | A connector inside the host, linked to exactly one server | The ClientSession |
| MCP server | A program that offers tools, resources and prompts | server.py |
| Data source | Whatever sits behind the server: an API, a database, files | A Python dict of fake weather |
One host can run many clients, one per server it uses.
#How the messages travel
Every message is a small JSON object in the JSON-RPC 2.0 format. A request has an id, a method and params. The response carries the same id with either a result or an error. Here is this lab's weather call as it would look on the wire, trimmed:
// client → server
{"jsonrpc": "2.0", "id": 4, "method": "tools/call",
"params": {"name": "get_weather", "arguments": {"city": "London"}}}
// server → client
{"jsonrpc": "2.0", "id": 4,
"result": {"content": [{"type": "text", "text": "Weather in London: 15°C, cloudy"}], "isError": false}}
A message without an id is a notification, and nobody replies to it.
The transport is how those messages get from one program to the other:
| Transport | How it works | Use it for |
|---|---|---|
| stdio | The client starts the server as a child process, writes to its stdin and reads its stdout | Local servers. No ports, no HTTP |
| Streamable HTTP | The server listens on one HTTP endpoint, and replies can stream as server-sent events (SSE) | Remote servers. It replaced the older standalone SSE transport |
| Custom | Anything both sides agree on, such as gRPC | Special cases |
Remote servers over HTTP authorise clients with OAuth 2.1. A stdio server reads its credentials from environment variables.
#The lifecycle: a handshake first
- Initialize. The client sends its protocol version and its capabilities (here: "I can answer roots requests"). The server replies with its own capabilities: tools, resources, prompts.
- Initialized. The client confirms with a notification, and normal work begins.
- Operation. List, call, read, get. Each side may use only what the other declared.
- Shutdown. On stdio, the client closes the pipes and the server process exits.
#Six primitives, three on each side
| Primitive | Offered by | Who decides when it is used | In this lab |
|---|---|---|---|
| Tool | Server | The model | get_weather(city), list_client_roots() |
| Resource | Server | The app | greeting://{name}, courses://directory |
| Prompt | Server | The user | weather_report(city), generate_study_plan(course, exam_date) |
| Root | Client | The client, which sets the folders a server may touch | The lab folder |
| Sampling | Client | The server asks to use the client's model, and the user approves | Not in this lab |
| Elicitation | Client | The server asks the user for missing input | Not in this lab |
#The server, built with FastMCP
FastMCP turns decorated Python functions into MCP features. It is the same idea as Strands' @tool in post 2: the type hints become the input schema and the docstring becomes the description.
import json
from mcp.server.fastmcp import Context, FastMCP
mcp = FastMCP("WeatherServer", log_level="WARNING") # the name clients see
@mcp.tool()
def get_weather(city: str) -> str:
"""Get the current weather for a given city."""
fake_weather = {"london": "15°C, cloudy", "bangalore": "26°C, pleasant with light showers", ...}
result = fake_weather.get(city.lower())
if result:
return f"Weather in {city}: {result}"
return f"Sorry, no weather data available for '{city}'."
@mcp.tool()
async def list_client_roots(ctx: Context) -> str:
"""List the workspace roots the connected client has shared with this server."""
result = await ctx.session.list_roots() # the server asks the client
if not result.roots:
return "The client did not share any roots."
return "\n".join(f"{root.name}: {root.uri}" for root in result.roots)
@mcp.resource("greeting://{name}") # a URI template
def get_greeting(name: str) -> str:
"""Return a personalized greeting."""
return f"Hello, {name}! Welcome to the WeatherServer."
@mcp.resource("courses://directory", mime_type="application/json",
description="List of courses and instructors in a program")
def course_directory() -> str:
return json.dumps(COURSES, indent=2) # a list of three courses
@mcp.prompt()
def weather_report(city: str) -> str:
"""Generate a prompt asking for a weather report."""
return f"Please provide a detailed weather report for {city}, including temperature, humidity, and forecast."
# generate_study_plan(course, exam_date) is built the same way
if __name__ == "__main__":
mcp.run(transport="stdio") # read stdin, write stdout
- The
{name}in the URI becomes the function's argument. Readinggreeting://Developercallsget_greeting("Developer"). - FastMCP fills in
ctx: Contextitself. It is not part of the tool's input schema, so the model never sees it. mime_type="application/json"tells the client how to read the text it gets back.
#The client, step by step
import sys
from pathlib import Path
import mcp.types as types
from mcp import ClientSession, StdioServerParameters
from mcp.client.stdio import stdio_client
HERE = Path(__file__).resolve().parent
async def list_roots(context) -> types.ListRootsResult: # answers the server's roots/list
return types.ListRootsResult(roots=[types.Root(uri=HERE.as_uri(), name="Course workspace")])
async def main():
server_params = StdioServerParameters(command=sys.executable, # the same Python, same packages
args=[str(HERE / "server.py")])
async with stdio_client(server_params) as (read_stream, write_stream): # start the child process
async with ClientSession(read_stream, write_stream, list_roots_callback=list_roots) as session:
await session.initialize() # the handshake, always first
tools = await session.list_tools()
resources = await session.list_resources() # fixed URIs
templates = await session.list_resource_templates() # URIs with placeholders
prompts = await session.list_prompts()
result = await session.call_tool("get_weather", {"city": "London"})
resource = await session.read_resource("greeting://Developer")
prompt = await session.get_prompt("weather_report", {"city": "Tokyo"})
result = await session.call_tool("list_client_roots", {})
# … each result is printed; the real file also reads courses://directory
- Passing
list_roots_callbackis what makes the client declare the roots capability duringinitialize. sys.executableis the Python running the client. Plainpythonon the PATH might be a different interpreter that doesn't havemcpinstalled.- Fixed resources and templates are listed by two calls.
greeting://{name}only shows up in the templates list.
#What the run printed
The real output is about 30 lines. Here it is as a table:
| Call | Result |
|---|---|
list_tools() | get_weather, list_client_roots |
list_resources() and list_resource_templates() | courses://directory (application/json), greeting://{name} |
list_prompts() | weather_report(city), generate_study_plan(course, exam_date) |
call_tool("get_weather", London) | Weather in London: 15°C, cloudy |
call_tool("get_weather", Berlin) | Sorry, no weather data available for 'Berlin'. |
read_resource("greeting://Developer") | Hello, Developer! Welcome to the WeatherServer. |
read_resource("courses://directory") | JSON for three courses: Machine Learning (12 weeks), Agentic AI on AWS (8), Data Engineering (10) |
get_prompt("weather_report", Tokyo) | Please provide a detailed weather report for Tokyo, including temperature, humidity, and forecast. |
get_prompt("generate_study_plan", …) | Create a week-by-week study plan for the course 'Machine Learning'. The exam is on 2026-05-10. … |
call_tool("list_client_roots") | Course workspace: file:///C:/…/mcp-basics |
Three things to notice:
- Berlin is not an error. The tool returned an ordinary message, so a model reading it could tell the user or try another city. MCP also has an
isErrorflag for real tool failures. get_promptcalled no model. It returned text that is ready to send. This lab has no LLM: prompts are templates, and the host decides what to do with them.- The roots call went both ways, as the next diagram shows.
#The roots round trip
The client called a tool, and while that call was still open, the server sent a request of its own back to the client: roots/list. The client's list_roots callback answered, and only then did the tool return. JSON-RPC doesn't care which side starts a request, and the client-side features (roots, sampling, elicitation) all depend on that.
#Part 2: Give an MCP server to a Strands agent
Now the host is a real agent. Strands has its own MCP client, so the pattern is short. The model is the same Groq OpenAIModel as in the setup of post 4.
Data
from mcp import StdioServerParameters, stdio_client
from strands import Agent
from strands.tools.mcp import MCPClient
SERVER = Path(__file__).resolve().parent.parent / "mcp-basics" / "server.py" # the server from Part 1
mcp_client = MCPClient(
lambda: stdio_client(StdioServerParameters(command=sys.executable, args=[str(SERVER)]))
)
with mcp_client: # starts server.py and runs the handshake
tools = mcp_client.list_tools_sync() # the server's tools, as Strands tools
print("Tools discovered over MCP:", [t.tool_name for t in tools])
agent = Agent(model=model, tools=tools)
agent("I'm flying from Bangalore to London tomorrow. What's the weather like in both cities?")
# leaving the block stops the server
| Line | Why |
|---|---|
MCPClient(lambda: ...) | You pass a recipe for opening the connection, not an open connection. Strands runs it on its own background thread when the with block starts |
with mcp_client: | The server process lives exactly as long as the block. Every agent call that uses these tools must happen inside it |
list_tools_sync() | Lists the server's tools and wraps each one as a Strands tool |
Agent(tools=tools) | The agent can't tell an MCP tool from a @tool function. It sees the same name, description and schema |
#Example 1: your own server
The server from Part 1 becomes the agent's toolbox without a single change. The model reads the question, sees that get_weather takes one city, and calls it twice. This is what the run prints (the wording of the answer varies):
Tools discovered over MCP: ['get_weather', 'list_client_roots']
Tool #1: get_weather
Tool #2: get_weather
Bangalore: 26°C, pleasant with light showers. Keep an umbrella handy on the way to the airport.
London: 15°C and cloudy. Pack a light jacket for when you land.
#Example 2: someone else's server
The AWS Documentation MCP server is built by AWS Labs and published as a Python package. Only the recipe changes:
mcp_client = MCPClient(
lambda: stdio_client(
StdioServerParameters(
command="uvx", # download and run a package
args=["awslabs.aws-documentation-mcp-server@latest"],
env={**os.environ, "FASTMCP_LOG_LEVEL": "ERROR"}, # pass your environment, quiet the logs
)
),
startup_timeout=120, # the first run downloads the package
)
with mcp_client:
tools = mcp_client.list_tools_sync()
agent = Agent(
model=model,
tools=tools,
system_prompt="Answer AWS questions using the documentation tools. Cite the doc URLs you used.",
)
agent("What is AWS Lambda, and what is the maximum timeout for a Lambda function?")
| Detail | Why it's there |
|---|---|
uvx | Runs a Python package in a throwaway environment, like npx for Python. It comes with uv, which the requirements install |
@latest | Fetches the newest version on each run. That's convenient, but the tool list can change under you |
env={**os.environ, ...} | By default the MCP SDK gives a child process only a few safe variables, such as PATH. This passes your whole environment, plus the log level |
startup_timeout=120 | The first run downloads the package, which can take longer than Strands' default of 30 seconds |
| No AWS credentials | The server only reads public documentation |
On my run the server announced five tools: read_documentation, read_sections, search_table, search_documentation and recommend. The course page lists three. That is @latest at work: the agent got two new tools without any code change.
#What happened: a 429 after three tool calls
My run searched once, read two sections, and then stopped. Trimmed (the real traceback is about 100 lines):
Tools from the AWS Documentation MCP server: ['read_documentation', 'read_sections', 'search_table', 'search_documentation', 'recommend']
Tool #1: search_documentation
Tool #2: read_sections
Tool #3: read_sections
OpenAI threw rate limit error ← this line, six times
exception=<ModelThrottledException> | event loop cycle failed
Traceback (most recent call last):
… strands/models/openai.py → openai/_base_client.py …
openai.RateLimitError: Error code: 429 - Rate limit reached for model `openai/gpt-oss-120b` …
on tokens per day (TPD): Limit 200000, Used 195940, Requested 4545. Please try again in 3m29.52s.
… strands/event_loop/event_loop.py → strands/agent/agent.py …
strands.types.exceptions.ModelThrottledException: Error code: 429 - (the same message)
strands.types.exceptions.EventLoopException: Error code: 429 - (the same message)
#Reading the error
The MCP server was fine: all three tool calls worked. It was the model provider that refused the fourth model call.
| Part of the message | What it means |
|---|---|
429 | HTTP "Too Many Requests". Nothing is broken; an allowance is used up |
tokens per day (TPD) | Which allowance: the daily token budget, not a per-minute one |
Limit 200000 | The free tier's daily tokens for this model |
Used 195940 | Tokens already spent on this account today, across all runs |
Requested 4545 | The size of this one request |
try again in 3m29.52s | How long until enough of the budget frees up |
used + requested ≤ limit → the request goes through
195,940 + 4,545 = 200,485 > 200,000 → rejected with 429, over by 485 tokens
The request itself was small. Earlier runs that day had spent the budget, and this one needed 485 tokens more than were left.
#Why is one request 4,545 tokens?
As the memory post showed, the model is stateless, so every model call resends the whole conversation: the system prompt, the question, the schemas of all five tools, and every tool result so far. Documentation pages are long, so each read_sections result made the next call bigger, and the fourth call carried all three results.
#Why didn't the retries help?
OpenAI threw rate limit error printed six times because Strands retried the model call, waiting longer each time. The OpenAI client underneath also retries on its own (max_retries=5). Retries are built for short bursts, where a per-minute limit refills within seconds. This was a daily budget that needed minutes, so every retry hit the same wall and Strands gave up.
#Why three exceptions?
Each layer wraps the error in its own type, and the traceback prints the whole chain:
| Exception | Raised by | Meaning |
|---|---|---|
openai.RateLimitError | The OpenAI client (talking to Groq) | The HTTP 429 itself |
ModelThrottledException | Strands' model provider | "The provider says slow down", in Strands' own terms |
EventLoopException | Strands' agent loop | The loop could not continue, so the agent call failed |
#Groq's four free-tier limits
| Limit | Meaning | openai/gpt-oss-120b, free tier |
|---|---|---|
| RPM | Requests per minute | 30 |
| RPD | Requests per day | 1,000 |
| TPM | Tokens per minute | 8,000 |
| TPD | Tokens per day | 200,000 |
These were the numbers when I wrote this; Groq's console shows your current ones, and each model has its own. TPM is the limit post 4 ran into, where a single request over 8,000 tokens failed. TPD is the one hit here.
#How to fix it
| Fix | How |
|---|---|
| Wait | The message says how long: here, about three and a half minutes |
| Spend fewer tokens | Ask the agent in its system prompt to read fewer, shorter sections. Fewer tool results mean smaller calls |
| Use another model | Set GROQ_MODEL_ID in .env to another model, such as openai/gpt-oss-20b, which has its own budget |
| Fail politely | Catch the exception and print one clear line instead of a traceback |
| Upgrade | A paid tier has higher limits |
A small guard for the "fail politely" row:
from strands.types.exceptions import EventLoopException, ModelThrottledException
try:
agent("What is AWS Lambda, and what is the maximum timeout for a Lambda function?")
except (ModelThrottledException, EventLoopException) as err:
print("The model provider is rate-limiting this key. Wait a few minutes or switch GROQ_MODEL_ID.")
print(str(err)[:200])
#What a successful run looks like
After the wait, or on a fresh day, the same script answers. This is illustrative: the wording and the exact tool calls vary from run to run.
Tools from the AWS Documentation MCP server: ['read_documentation', 'read_sections', 'search_table', 'search_documentation', 'recommend']
Tool #1: search_documentation
Tool #2: read_sections
AWS Lambda is a serverless compute service: it runs your code in response to events
(an HTTP request, a file upload, a queue message) without you managing servers, and you
pay only for the compute time you use.
Maximum timeout: 900 seconds (15 minutes). The default is 3 seconds, and you can set
any value from 1 to 900 seconds.
Sources:
- https://docs.aws.amazon.com/lambda/latest/dg/welcome.html
- https://docs.aws.amazon.com/lambda/latest/dg/configuration-timeout.html
#Part 3: A2A, agents talking to agents
#Why not use MCP for agents too?
You could wrap an agent as an MCP tool, and for small jobs that is fine. But an agent is not a function:
| An MCP tool | A remote agent |
|---|---|
| Takes structured arguments | Takes a natural-language message, perhaps with files |
| Has no model of its own | Has its own model, tools and memory |
| Returns in a moment | May work for minutes, or ask a question back |
| Publishes its exact schema | Stays opaque: you see what it can do, never how |
A2A is built for the right-hand column. It began at Google in 2025, merged with IBM's Agent Communication Protocol, and is now a vendor-neutral project under the Linux Foundation.
#Four building blocks: card, message, task, artifact
| Block | What it is | Think of it as |
|---|---|---|
| Agent card | A JSON document at /.well-known/agent-card.json: name, description, skills, capabilities, endpoint | A business card, or an OpenAPI spec for an agent |
| Message | One turn of the conversation, made of parts: text, files or structured data | An email |
| Task | The unit of work, with an id and a state | A ticket number |
| Artifact | The output of a task, also made of parts | The finished document |
A task moves through states: submitted, then working, then usually completed. It can also end as failed, canceled or rejected, or pause at input-required while the agent waits for an answer from the caller.
The weather agent's card, trimmed:
{
"name": "Weather Agent",
"description": "Provides current weather information for cities around the world.",
"version": "1.0.0",
"protocolVersion": "0.3.0",
"url": "http://127.0.0.1:9001/",
"capabilities": { "streaming": true },
"defaultInputModes": ["text"],
"defaultOutputModes": ["text"],
"skills": [
{
"id": "get_weather",
"name": "Get Weather",
"tags": ["weather", "temperature", "forecast", "travel"],
"examples": ["What is the weather in London?", "How hot is it in Dubai right now?"]
}
]
}
#Four ways to wait for the answer
| Style | How it works | Needs in the card | Think of it as |
|---|---|---|---|
| Synchronous | message/send, then wait for the reply | Nothing | A phone call |
| Polling | Get a task id now, ask tasks/get later | Nothing | Taking a ticket and checking back |
| Streaming | message/stream: events arrive over SSE while the agent works | streaming: true | Live commentary |
| Push | The agent calls your webhook when it is done | pushNotifications: true | A doorbell |
Underneath, A2A can run over several bindings: JSON-RPC (used here), gRPC, or plain HTTP with JSON.
#The travel planner
Two Strands agents each run as an A2A server on their own port. A third script, the orchestrator, is the A2A client: it finds both agents, sends each one a question and prints a trip summary. The orchestrator has no LLM. It is plain Python, and all the intelligence sits inside the two agents.
#The server side: an agent plus a card
Here is the flights agent, trimmed. The weather agent has the same shape, with one tool, on port 9001.
from strands import Agent, tool
from strands.multiagent.a2a import A2AServer
from a2a.types import AgentSkill
@tool
def search_flights(origin: str, destination: str) -> str:
"""Search for available flights between two cities. ..."""
flights = FAKE_FLIGHTS.get((origin.strip().lower(), destination.strip().lower()))
... # format the flights, or list the routes it knows
@tool
def book_flight(flight_number: str, passenger_name: str) -> str:
"""Book a specific flight for a passenger. ..."""
...
FLIGHTS_SKILLS = [
AgentSkill(
id="search_flights",
name="Search Flights",
description="Searches for available flights between two cities. ... Available routes: London→Paris, ...",
tags=["flights", "search", "travel", "airlines", "booking"],
examples=["Find flights from New York to London", "What flights go from Dubai to Bangkok?"],
),
AgentSkill(id="book_flight", name="Book Flight", ...),
]
flights_agent = Agent(
model=model, # Groq here; BedrockModel in the AWS version
name="Flights Agent",
description="Searches for flights between cities and handles bookings.",
system_prompt="You are a helpful flight booking assistant. Use search_flights ... and book_flight ...",
tools=[search_flights, book_flight],
callback_handler=None, # a server collects its output, it doesn't print it
)
if __name__ == "__main__":
A2AServer(agent=flights_agent, host="127.0.0.1", port=9002,
skills=FLIGHTS_SKILLS, version="1.0.0").serve()
A2AServer does four jobs:
- Serves the agent card at
GET /.well-known/agent-card.json, built from the agent's name and description and the skills you pass. - Accepts A2A requests at
POST /: the JSON-RPC methodsmessage/sendandmessage/stream. (A comment in the course code calls the second onesendStream, butmessage/streamis its name on the wire.) - Runs each message through the Strands agent, with its model and tools.
- Returns the answer as a task with artifacts.
Two small details:
host="127.0.0.1"accepts connections from your own machine only.0.0.0.0would open the agent to the network, and this demo has no authentication.- The confirmation number changes on every restart.
book_flightbuilds it fromhash(flight_number + passenger_name), and Python salts string hashes per process. Usehashlibwhen an id must stay the same.
#The client side: the orchestrator
The A2A names below (A2ACardResolver, ClientFactory, Message and so on) come from a2a-sdk.
def make_message(text: str) -> Message:
return Message(kind="message", role=Role.user, message_id=uuid4().hex,
parts=[Part(TextPart(kind="text", text=text))])
async def send_a2a_message(base_url: str, text: str) -> str:
async with httpx.AsyncClient(timeout=TIMEOUT) as hc: # TIMEOUT = 300 seconds
resolver = A2ACardResolver(httpx_client=hc, base_url=base_url)
card = await resolver.get_agent_card() # 1. discover
use_streaming = bool(card.capabilities and card.capabilities.streaming)
config = ClientConfig(httpx_client=hc, streaming=use_streaming)
client = ClientFactory(config).create(card) # 2. a client that fits the card
async for event in client.send_message(make_message(text)): # 3. send, read the events
... # keep the final task
def extract_text(result) -> str:
"""Join the text parts of every artifact in the finished task."""
texts = []
for artifact in result.artifacts or []:
for part in artifact.parts:
texts.append(part.root.text) # Part wraps the real part
return "".join(texts)
async def main(city, origin, destination):
weather = await send_a2a_message(WEATHER_URL, f"What is the weather in {city}?")
flights = await send_a2a_message(FLIGHTS_URL, f"Search flights from {origin} to {destination}")
print(f"Weather: {weather}")
print(f"Flights: {flights}")
- The card comes first. The client reads it to learn the endpoint and whether streaming is on, then builds a matching client. Only the base URL is hard-coded.
- Each message gets a fresh
message_id, so both sides can refer to it later. - The answer is in the task's artifacts, not in one reply string. A
Partis a wrapper, and.rootis the actual text, file or data part inside it. - An orchestrator with no LLM is a choice, not a rule. Strands also has
A2AAgent, which wraps a remote A2A agent so you can use it like a local one, for example as a worker for a supervisor from post 4.
#What happens on the wire
GET /.well-known/agent-card.json: who are you, and what can you do?POST /withmessage/stream: the question, as a message.- The weather agent runs its model, which calls
get_weather, then runs the model again to write the answer. That is two model calls per question, as the server log below shows. - Events stream back: the task's state, an artifact with the answer, then
completed. - The same again with the flights agent on port 9002.
#Run it: three terminals
# Terminal 1
python weather_agent_server_groq.py
# Terminal 2
python flights_agent_server_groq.py
# Terminal 3: the default trip (New York → London), then a custom one
python travel_orchestrator.py
python travel_orchestrator.py "Rome" "Paris" "Rome"
#What the servers logged
Terminal 1, the weather agent, over five orchestrator runs (trimmed):
INFO:strands.multiagent.a2a.server:Strands' integration with A2A is experimental. Be aware of frequent breaking changes.
Weather Agent A2A server starting on http://127.0.0.1:9001
INFO: Uvicorn running on http://127.0.0.1:9001 (Press CTRL+C to quit)
INFO: 127.0.0.1:52669 - "GET / HTTP/1.1" 405 Method Not Allowed ← a browser
INFO: 127.0.0.1:52669 - "GET /favicon.ico HTTP/1.1" 404 Not Found ← the same browser
INFO: 127.0.0.1:52998 - "GET /.well-known/agent-card.json HTTP/1.1" 200 OK ← the card, opened by hand
INFO: 127.0.0.1:59352 - "GET /.well-known/agent-card.json HTTP/1.1" 200 OK ← orchestrator run 1
INFO: 127.0.0.1:59352 - "POST / HTTP/1.1" 200 OK
INFO:httpx:HTTP Request: POST https://api.groq.com/openai/v1/chat/completions "HTTP/1.1 200 OK"
INFO:httpx:HTTP Request: POST https://api.groq.com/openai/v1/chat/completions "HTTP/1.1 200 OK"
… the same four lines for each of the next four runs …
INFO: Shutting down
Terminal 2, the flights agent, has the same four lines per run, each from the next client port (59355 after the weather agent's 59352, 53493 after 53490, and so on).
| Line | What it tells you |
|---|---|
experimental | Strands' A2A support may change between versions, so pin your versions |
GET / → 405 Method Not Allowed | / only accepts POST (JSON-RPC). A browser sends GET and is refused. Open /.well-known/agent-card.json to see the card |
The card's GET, then POST / | One orchestrator call: discover, then send |
Two chat/completions per POST | The agent loop: the model asks for the tool, then writes the answer from its result |
POST / … 200 before the Groq calls | The reply is a stream. The server starts it with 200 straight away and sends events while the agent works |
| Weather's port, then flights' next port | Each run asked the weather agent first and the flights agent second, as main() does |
#What the orchestrator printed
Terminal 3 ends with the trip summary. I didn't keep that output, so this version is illustrative, and the agents word their answers freely anyway:
Weather: It's currently 15°C in London, cloudy with occasional rain.
Flights: Two flights from New York to London:
- AirEurope AE303: departs 18:00, arrives 06:00 the next day, $380
- TransAtlantic TA501: departs 22:00, arrives 10:00 the next day, $450
#Troubleshooting
| Symptom | Cause and fix |
|---|---|
No module named 'a2a' | The a2a extra isn't installed: python -m pip install -r requirements-groq.txt |
A KeyError that shows your key | The key is where the name goes. Use os.environ["GROQ_API_KEY"] and put the key in .env |
| Connection refused | Start both agent servers before the orchestrator |
Errno 10048, or "address already in use" | An earlier server still holds 9001 or 9002. Stop it with Ctrl+C, or with the command above |
405 Method Not Allowed in a browser | Expected: / only takes POST. Open /.well-known/agent-card.json instead |
No response received. | The real error is in the server's terminal, not the orchestrator's. A Groq 429 lands there |
| "No flights found" | Use a known route: London→Paris, New York→London, Tokyo→Sydney, Paris→Rome, Dubai→Bangkok, New York→Cancun |
#Putting MCP and A2A together
The two protocols stack. The travel planner's weather agent keeps its data in a dict, but it could just as well get its tool from the MCP server in Part 1:
user → orchestrator ──A2A──► weather agent ──MCP──► weather server (Part 1)
└─A2A──► flights agent ──MCP──► a real flights API
The orchestrator never learns that MCP exists, and the MCP server never learns that A2A exists. Each protocol handles one direction.
#Things that can trip you up
- Printing in a stdio server. stdout carries the protocol, so log to stderr.
- Using MCP tools outside
with mcp_client:. By then the server has already stopped. - The first
uvxrun. The download can take longer than the 30-second default, so raisestartup_timeout. @latestdrifts. The tool list can change between runs. Pin a version when you need the same behaviour every time.- SDK major versions. This code needs
mcpbelow 2.0 anda2a-sdk0.3.x. - Missing extras.
strands.multiagent.a2aneedsstrands-agents[a2a]. Plainstrands-agentsdoesn't include the A2A SDK. - A key where a name belongs.
os.environ["GROQ_API_KEY"]takes the variable's name, never the key. Keep keys in.env, out of code and out of git. - Daily token budgets. Retries fix per-minute limits, not a spent daily budget, and long tool results use it up fastest.
- Start order and ports. Start the A2A servers first, one per port.
- Errors live on the server. In A2A, a failure inside a remote agent shows up in that agent's terminal.
- Skills that drift from tools. The flights skill lists its routes in a description string and in the data. Change one without the other and the card is wrong.
- Trusting servers. An MCP server runs on your machine with your permissions, and
uvxruns whatever the package contains. Only install servers you trust, and never expose an agent on0.0.0.0without authentication.
#Summary
Agents need protocols for the same reason computers need USB and email. Without a shared one, every pair needs custom glue (N × M); with one, each side learns it once (N + M). MCP connects a host to tools, data and prompts. A host runs one client per server, and the messages are JSON-RPC 2.0 over stdio or Streamable HTTP. Every session starts with a handshake that exchanges capabilities. Servers offer tools (act), resources (read) and prompts (reuse). Clients offer roots, sampling and elicitation, which work because a server can send requests back. In Strands, MCPClient takes a recipe for the connection, the server lives inside the with block, and MCP tools look like any other tool. Third-party servers bring tools you didn't write, and their long results cost tokens. A 429 on tokens per day means a spent daily budget, which retries can't fix. A2A connects opaque agents: a client reads the agent card, sends a message, and gets back a task whose artifacts hold the answer, delivered synchronously or by polling, streaming or push.
#Concepts at a glance
| Concept | In one line | Why it matters |
|---|---|---|
| N × M problem | Every app needs a connector to every tool | A shared protocol makes it N + M |
| MCP | A standard way for LLM apps to reach tools, data and prompts | Write a server once, use it in any host |
| Host, client, server | The app, a connector per server, the program that offers features | One host, many clients; one client, one server |
| JSON-RPC 2.0 | Requests with id, method, params; notifications have no id | The message format of both MCP and A2A |
| stdio, Streamable HTTP | Pipes to a child process; one HTTP endpoint for remote servers | Local or remote, same messages |
| Lifecycle | Initialize, initialized, operation, shutdown | Capabilities are agreed before any work |
| Tools, resources, prompts | Act, read, reuse | Chosen by the model, the app and the user |
| Roots, sampling, elicitation | Folder fences, borrowing the client's model, asking the user | The server can call the client back |
| FastMCP | Decorators that turn functions into MCP features | Type hints and docstrings become the schema |
MCPClient | Strands' MCP client, given a recipe for the connection | The with block is the server's lifetime |
uvx | Runs a published Python package in a throwaway environment | Third-party servers without installing them |
| 429 and TPD | "Too Many Requests" on the daily token budget | Retries can't fix it; wait, spend less or switch model |
| A2A | A standard way for opaque agents to work together | Across processes, teams and frameworks |
| Agent card | JSON at /.well-known/agent-card.json | How a client discovers an agent |
| Skill and tool | Advertised in the card; private inside the agent | Clients see what, never how |
| Message, task, artifact | A turn, the unit of work, the output | Long work is tracked by task id and state |
| Communication styles | Synchronous, polling, streaming, push | Pick by how long the work takes |
A2AServer | Wraps a Strands agent as an A2A HTTP server | Serves the card and runs the agent |
#Test yourself
Why does a protocol turn N × M integrations into N + M?
Without one, every app needs a custom connector to every tool. With one, each app implements the protocol once and each tool offers it once, so any app can use any tool.
What are the three server-side and the three client-side MCP primitives?
Server: tools (act, chosen by the model), resources (read-only data, chosen by the app) and prompts (templates, chosen by the user). Client: roots (folders the server may use), sampling (the server uses the client's model) and elicitation (the server asks the user for input).
Why must a stdio MCP server never print to stdout?
On stdio, stdout carries the protocol. A stray print() is read as a malformed message and breaks the connection, so log to stderr.
How could the server ask the client for its roots in the middle of a tool call?
JSON-RPC works in both directions. While tools/call was still open, the server sent its own roots/list request, the client's callback answered, and then the tool returned.
Why does MCPClient take a lambda, and when does the server stop?
Strands opens the connection itself, on a background thread, when the with block starts, so it needs a recipe rather than an open connection. The server stops when the block ends, so every agent call that uses its tools must happen inside the block.
The error said Limit 200000, Used 195940, Requested 4545. Why was the request rejected, and why didn't retries help?
195,940 + 4,545 = 200,485, which is 485 over the daily limit. Retries help when a per-minute limit refills within seconds. A daily budget needed minutes, so every retry failed the same way.
Why did a small question grow into a 4,545-token request?
Every model call resends the whole conversation, including the tool schemas and every tool result so far, and documentation pages are long.
How does an A2A client learn what an agent can do?
It fetches the agent card from /.well-known/agent-card.json, which lists the agent's skills, capabilities (such as streaming) and endpoint.
What is the difference between a skill and a tool in the travel lab?
A skill is advertised in the agent card for clients to read. A tool is what the agent's model calls inside. Clients only ever see the skills.
Opening http://127.0.0.1:9001 in a browser returned 405. Is the server broken?
No. / only accepts POST, because it is the JSON-RPC endpoint, and a browser sends GET. The card is at /.well-known/agent-card.json.
When would you choose A2A over wrapping another agent as an MCP tool?
When the other agent is opaque and independent: it has its own model and tools, may take minutes, may need to ask a question back, or is run by another team. Those need tasks, states and artifacts, not a quick function call.
Next: Shipping agents to production, where these agents, and this MCP and A2A system, move onto AWS.
#Resources
- How Agents Talk, the course material behind these labs
- Model Context Protocol: the specification and SDKs
- MCP Inspector
- AWS MCP servers, including the documentation server
- A2A protocol
- Strands Agents SDK
- Groq rate limits
- Building my first AI agent, Giving an AI agent tools, How AI agents remember, Agents that work together