Vineet Ganti
How agents talk: MCP for tools, A2A for other agents
All posts

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.

Agentic AILLMsAI EngineeringStrandsMCP

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.

LabWhat runsWhat it teaches
MCP basicsA server and a client, no LLM at allThe protocol itself: every message and primitive
Strands + MCPAn agent using your server, then the AWS Documentation serverHow an agent gets tools it didn't write, and how to read a rate-limit error
A2A travel plannerTwo agent servers and an orchestrator, in three terminalsHow 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.

text
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

MCPA2A
ConnectsAn agent (or any LLM app) to tools, data and promptsAn agent to other agents
The other side isA program that offers capabilitiesAn agent with its own model, tools and memory
Where the LLM livesOn the client sideOn both sides
Discoverytools/list, resources/list, prompts/listAn agent card at a fixed URL
Transportstdio (local) or Streamable HTTP (remote)HTTP: JSON-RPC, with SSE for streaming
Unit of workA structured call that returns quicklyA task that can run for minutes
In this postThe weather server, the AWS docs serverThe 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

RoleWhat it isIn this lab
HostThe app the user works in. It owns the UI, the memory and the modelclient.py, and later the Strands agent
MCP clientA connector inside the host, linked to exactly one serverThe ClientSession
MCP serverA program that offers tools, resources and promptsserver.py
Data sourceWhatever sits behind the server: an API, a database, filesA 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:

json
// 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:

TransportHow it worksUse it for
stdioThe client starts the server as a child process, writes to its stdin and reads its stdoutLocal servers. No ports, no HTTP
Streamable HTTPThe server listens on one HTTP endpoint, and replies can stream as server-sent events (SSE)Remote servers. It replaced the older standalone SSE transport
CustomAnything both sides agree on, such as gRPCSpecial 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

  1. 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.
  2. Initialized. The client confirms with a notification, and normal work begins.
  3. Operation. List, call, read, get. Each side may use only what the other declared.
  4. Shutdown. On stdio, the client closes the pipes and the server process exits.

#Six primitives, three on each side

PrimitiveOffered byWho decides when it is usedIn this lab
ToolServerThe modelget_weather(city), list_client_roots()
ResourceServerThe appgreeting://{name}, courses://directory
PromptServerThe userweather_report(city), generate_study_plan(course, exam_date)
RootClientThe client, which sets the folders a server may touchThe lab folder
SamplingClientThe server asks to use the client's model, and the user approvesNot in this lab
ElicitationClientThe server asks the user for missing inputNot 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.

python
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. Reading greeting://Developer calls get_greeting("Developer").
  • FastMCP fills in ctx: Context itself. 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

python
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_callback is what makes the client declare the roots capability during initialize.
  • sys.executable is the Python running the client. Plain python on the PATH might be a different interpreter that doesn't have mcp installed.
  • 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:

CallResult
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 isError flag for real tool failures.
  • get_prompt called 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

python
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
LineWhy
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):

text
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:

python
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?")
DetailWhy it's there
uvxRuns a Python package in a throwaway environment, like npx for Python. It comes with uv, which the requirements install
@latestFetches 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=120The first run downloads the package, which can take longer than Strands' default of 30 seconds
No AWS credentialsThe 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):

text
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 messageWhat it means
429HTTP "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 200000The free tier's daily tokens for this model
Used 195940Tokens already spent on this account today, across all runs
Requested 4545The size of this one request
try again in 3m29.52sHow long until enough of the budget frees up
text
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:

ExceptionRaised byMeaning
openai.RateLimitErrorThe OpenAI client (talking to Groq)The HTTP 429 itself
ModelThrottledExceptionStrands' model provider"The provider says slow down", in Strands' own terms
EventLoopExceptionStrands' agent loopThe loop could not continue, so the agent call failed

#Groq's four free-tier limits

LimitMeaningopenai/gpt-oss-120b, free tier
RPMRequests per minute30
RPDRequests per day1,000
TPMTokens per minute8,000
TPDTokens per day200,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

FixHow
WaitThe message says how long: here, about three and a half minutes
Spend fewer tokensAsk the agent in its system prompt to read fewer, shorter sections. Fewer tool results mean smaller calls
Use another modelSet GROQ_MODEL_ID in .env to another model, such as openai/gpt-oss-20b, which has its own budget
Fail politelyCatch the exception and print one clear line instead of a traceback
UpgradeA paid tier has higher limits

A small guard for the "fail politely" row:

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

text
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 toolA remote agent
Takes structured argumentsTakes a natural-language message, perhaps with files
Has no model of its ownHas its own model, tools and memory
Returns in a momentMay work for minutes, or ask a question back
Publishes its exact schemaStays 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

BlockWhat it isThink of it as
Agent cardA JSON document at /.well-known/agent-card.json: name, description, skills, capabilities, endpointA business card, or an OpenAPI spec for an agent
MessageOne turn of the conversation, made of parts: text, files or structured dataAn email
TaskThe unit of work, with an id and a stateA ticket number
ArtifactThe output of a task, also made of partsThe 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:

json
{
  "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

StyleHow it worksNeeds in the cardThink of it as
Synchronousmessage/send, then wait for the replyNothingA phone call
PollingGet a task id now, ask tasks/get laterNothingTaking a ticket and checking back
Streamingmessage/stream: events arrive over SSE while the agent worksstreaming: trueLive commentary
PushThe agent calls your webhook when it is donepushNotifications: trueA 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.

python
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:

  1. Serves the agent card at GET /.well-known/agent-card.json, built from the agent's name and description and the skills you pass.
  2. Accepts A2A requests at POST /: the JSON-RPC methods message/send and message/stream. (A comment in the course code calls the second one sendStream, but message/stream is its name on the wire.)
  3. Runs each message through the Strands agent, with its model and tools.
  4. 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.0 would open the agent to the network, and this demo has no authentication.
  • The confirmation number changes on every restart. book_flight builds it from hash(flight_number + passenger_name), and Python salts string hashes per process. Use hashlib when 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.

python
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 Part is a wrapper, and .root is 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

  1. GET /.well-known/agent-card.json: who are you, and what can you do?
  2. POST / with message/stream: the question, as a message.
  3. 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.
  4. Events stream back: the task's state, an artifact with the answer, then completed.
  5. The same again with the flights agent on port 9002.

#Run it: three terminals

bash
# 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):

text
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).

LineWhat it tells you
experimentalStrands' 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 POSTThe agent loop: the model asks for the tool, then writes the answer from its result
POST / … 200 before the Groq callsThe 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 portEach 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:

text
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

SymptomCause 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 keyThe key is where the name goes. Use os.environ["GROQ_API_KEY"] and put the key in .env
Connection refusedStart 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 browserExpected: / 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:

text
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 uvx run. The download can take longer than the 30-second default, so raise startup_timeout.
  • @latest drifts. The tool list can change between runs. Pin a version when you need the same behaviour every time.
  • SDK major versions. This code needs mcp below 2.0 and a2a-sdk 0.3.x.
  • Missing extras. strands.multiagent.a2a needs strands-agents[a2a]. Plain strands-agents doesn'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 uvx runs whatever the package contains. Only install servers you trust, and never expose an agent on 0.0.0.0 without 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

ConceptIn one lineWhy it matters
N × M problemEvery app needs a connector to every toolA shared protocol makes it N + M
MCPA standard way for LLM apps to reach tools, data and promptsWrite a server once, use it in any host
Host, client, serverThe app, a connector per server, the program that offers featuresOne host, many clients; one client, one server
JSON-RPC 2.0Requests with id, method, params; notifications have no idThe message format of both MCP and A2A
stdio, Streamable HTTPPipes to a child process; one HTTP endpoint for remote serversLocal or remote, same messages
LifecycleInitialize, initialized, operation, shutdownCapabilities are agreed before any work
Tools, resources, promptsAct, read, reuseChosen by the model, the app and the user
Roots, sampling, elicitationFolder fences, borrowing the client's model, asking the userThe server can call the client back
FastMCPDecorators that turn functions into MCP featuresType hints and docstrings become the schema
MCPClientStrands' MCP client, given a recipe for the connectionThe with block is the server's lifetime
uvxRuns a published Python package in a throwaway environmentThird-party servers without installing them
429 and TPD"Too Many Requests" on the daily token budgetRetries can't fix it; wait, spend less or switch model
A2AA standard way for opaque agents to work togetherAcross processes, teams and frameworks
Agent cardJSON at /.well-known/agent-card.jsonHow a client discovers an agent
Skill and toolAdvertised in the card; private inside the agentClients see what, never how
Message, task, artifactA turn, the unit of work, the outputLong work is tracked by task id and state
Communication stylesSynchronous, polling, streaming, pushPick by how long the work takes
A2AServerWraps a Strands agent as an A2A HTTP serverServes 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