How agents
talk
Section 5. Two open protocols do for agents what USB and HTTP did for computers. MCP plugs an agent into tools and data that live in other programs. A2A lets independent agents find each other and work together. You'll build both.
Why agents need protocols
You've built agents, given them tools and memory, and organised them into teams. The next step is communication: how agents connect reliably to outside tools, data and other agents, in a structured, reusable way.
As agents get more capable, this communication layer becomes critical. Without a standard, every new tool, service or partner agent means more custom integration code, more maintenance and more complexity. Two protocols tackle this from different directions:
MModel Context Protocol (MCP)
Standardises how agents and models connect to tools, resources and prompt templates, so an agent can discover and use outside capabilities without hard-coding every integration.
AAgent-to-Agent protocol (A2A)
Standardises how agents talk to other agents, so specialised agents can collaborate across teams and systems while keeping their internals private.
✦Think of it like this
MCP is the universal charging port: any device that has it works with any charger that has it, and nobody needs a drawer full of special cables. A2A is more like the way two companies email each other: each keeps its own staff and processes private, but they agree on how a request looks, how to reply, and how to follow up.
What you'll learn here
- MCP: standardising the interface between models and tools
- How MCP works: host, client, server, data sources, and the protocol layer
- The difference between a host and an MCP client
- Transports, lifecycle and authorisation
- MCP server and client features
- FastMCP vs FastAPI, pre-built and custom servers
- Hands-on: an MCP server and client in Python with FastMCP, then Strands agents that use MCP tools
- The A2A protocol: architecture, protocol bindings and communication model
- Hands-on: a travel assistant built from Strands agents talking A2A
Before you start
- 1
A working Strands setup from earlier sections
- 2
Basic Python functions and classes
The MCP lab also uses
async/await; you met it in Section 2. - 3
AWS credentials with Amazon Bedrock access
Needed for the Strands + MCP lab and the A2A lab (Claude Sonnet 4.6, override with
BEDROCK_MODEL_ID). The MCP basics lab needs no AWS and no LLM at all. - 4
An IDE and three terminal windows
VS Code, Kiro or Claude Code. The A2A lab runs three programs side by side.
- 5
REST and JSON (helpful, not required)
| Folder | What it shows | On this page |
|---|---|---|
mcp-basics/ | An MCP server exposing every primitive (tools, resources, prompts, roots) and a client that uses them. No LLM involved. | 5.6 |
mcp-with-strands-agent/ | Strands agents that get their tools from MCP servers: our local server, and the AWS Documentation MCP server | 5.7 |
a2a-travel-agents/ | Two A2A agent servers (weather, flights) and an orchestrator that discovers and calls them | 5.11 |
# MCP basics (no AWS needed)
cd mcp-basics && pip install -r requirements.txt && python client.py
# Strands + MCP
cd ../mcp-with-strands-agent && pip install -r requirements.txt
python agent_with_local_mcp.py
python agent_with_aws_docs_mcp.py
# A2A — three terminals
cd ../a2a-travel-agents && pip install -r requirements.txt
python weather_agent_server.py # terminal 1
python flights_agent_server.py # terminal 2
python travel_orchestrator.py # terminal 3iLibrary versions matter here
The MCP labs pin mcp[cli]>=1.23.0,<2.0.0 because version 2 renamed FastMCP and changed client APIs; this code targets the 1.x line. The A2A lab pins a2a-sdk>=0.3.0,<0.4.0 and installs Strands with its [a2a] extra.
| MCP | A2A | |
|---|---|---|
| Purpose | Connect an LLM app to tools and data | Connect agents to other agents |
| Transport | stdio, Streamable HTTP | HTTP (JSON-RPC, SSE) |
| Discovery | list_tools(), list_resources(), list_prompts() | Agent card at /.well-known/agent-card.json |
| Who has the LLM | The client side | Both sides; each agent has its own |
| Messages | Structured calls (tool name + arguments) | Natural-language messages and tasks |
→In one line
MCP is LLM ↔ tool. A2A is agent ↔ agent.
MCP: why tools need a standard
As agents grow, managing dozens or hundreds of tools gets hard. What if an agent could discover tools at run time, including tools built by other teams, without hard-coding each one? That's what the is for.
Agents need to reach APIs, databases and developer platforms. Without a standard, every model needs a custom integration for every tool, which quickly gets complex, costly and hard to maintain. A standard protocol gives one common interface for tool use: less integration work, better interoperability, and easier growth as new tools and models arrive.
Count the integrations
What problem does MCP solve?
Imagine an enterprise agent that must work with your CRM, inventory database, email, calendar, document storage and more. Traditionally you'd write custom integration code for each system, keep it working as each system changes, handle authentication and authorisation for every one, juggle different API formats and conventions, and update the agent whenever a new system is added. That siloed approach leads to fragmented app development: every integration built from scratch, with no shared standard.
Worse, each AI app builds its entire stack on its own:
This doesn't scale. It's slow, error-prone, and couples your agent tightly to every system it touches. MCP replaces it with one standard layer that every AI app shares, a common protocol for tool calls and data access. The benefits:
Dynamic tool discovery
Your agent automatically sees every tool on the servers it's connected to.
No integration code
Connect to new tool sources without writing custom adapters.
Automatic updates
When a server adds or changes tools, your agent sees the change.
Unified interface
Tools from AWS, GitHub or your internal systems all work the same way.
Flexible deployment
MCP servers can run locally on your machine or remotely in the cloud.
MCP is a universal adapter for agents, much like USB for devices. Through it, models reach databases (such as DynamoDB), CRMs, calendars, code repositories (GitHub), messaging (Slack) and document storage (Google Drive), all through one integration layer:
How MCP works
MCP sets up a standard protocol between your agent and tool providers, much as USB set a universal standard for connecting devices to computers.
- 1
Host
The AI application or agent that needs to get things done: answer questions, retrieve information, use tools. It doesn't connect to outside systems directly; it talks to an MCP client.
- 2
MCP client (tool consumer)
Built into your agent. It connects to an MCP server, discovers the available tools and handles the protocol. When your agent needs a tool, the client calls the right server with the required parameters.
- 3
MCP server (tool provider)
A service that exposes a catalogue of tools with descriptions, parameters and capabilities. For example, AWS Labs publishes an MCP server for AWS documentation, GitHub publishes servers for repository operations, and you can build your own for internal systems.
- 4
Data sources
The real databases, APIs, files or enterprise systems behind the server. The server fetches what's needed and sends results back through the client to the host.
These parts work through one standardised communication layer, the core idea of MCP. Instead of every host and tool provider inventing its own integration, MCP defines one protocol for how clients and servers discover capabilities, exchange requests and return results. That shared protocol is what makes it scalable, reusable and easier to maintain.
The difference between a host and an MCP client
An MCP client never runs on its own. It's created and managed by a application, such as a code editor, an AI assistant or a browser extension. The host owns the user experience: the interface, session memory and prompt orchestration. It can run several clients at once, each connected to a different server.
Each , in contrast, is a protocol-level component with a one-to-one connection to a single . It discovers that server's tools, resources and prompts, gathers context for the model, and asks the user for missing information when needed. In short: the host is what users interact with; the client lets the host talk to one specific server.
iRemember it this way
The host is the application layer (UI, workflows, user interaction). The client is the integration layer inside the host that speaks MCP. The host orchestrates; each client talks to exactly one server.
Transports, lifecycle and authorisation
A client and its server exchange messages over a . MCP messages use and must be UTF-8 encoded. The client and server define what is said; the transport defines how it travels. There are two standard transports plus a custom option, all for the same job in different deployments:
| Transport | Best for | Example |
|---|---|---|
| (standard input/output) | Local integrations where client and server run on the same machine. Simple, direct, lightweight, like command-line programs piping data to each other. | An agent running on your laptop, as in this section's labs |
| Streamable HTTP | Web-based or remote servers. Uses HTTP and can stream results incrementally with . (It replaced the older stand-alone SSE transport.) | A live assistant receiving updates as they happen |
| Custom | When the built-in options don't fit | An enterprise team using for high performance |
Managing the lifecycle
management covers how a client and server start, maintain and end a connection: agreeing on protocol versions, learning each other's capabilities, and managing session state. Three stages:
- 1
Initialisation
Client and server agree on the protocol version and capabilities. Example: an agent connecting to a server for a company database performs the handshake, and the server confirms which features it offers, for example tools such as a
run_querytool. - 2
Operation
Normal protocol traffic. The agent asks for customer records; the server processes the requests and returns structured responses.
- 3
Shutdown
The client closes the connection gracefully, letting ongoing operations finish and releasing resources.
A real MCP conversation, message by message
These are the real JSON-RPC messages that go over the pipes when you run this section's client.py against server.py (captured from a real run, lightly shortened).
Securing the connection
MCP defines an framework for HTTP-based transports, built on . Servers reached over HTTP should follow it. Servers using stdio should not; they take any credentials they need from the environment instead, such as environment variables set when the client starts them. Clients and servers may also agree their own custom authentication and authorisation.
MCP server features
An MCP server offers clients three kinds of capability: tools to do things, resources to read things, and prompt templates to ask things the same way every time.
Tools
the agent can execute to act on outside systems: retrieving or searching data, sending messages, updating records.
Resources
Read-only, persistent data the server exposes: files, database records, API responses. They don't perform actions; they make information available to browse, search and reference, so a model can look up facts without triggering an operation.
Prompt templates
Predefined prompts the agent can use through the client to guide how it handles data or a task: document Q&A, transcript summarisation, structured JSON output.
A client typically uses them together: discover what the server offers, retrieve resources to gather context, invoke tools to act or fetch more data, and use prompt templates to structure the model's work.
Tools in MCP
You already know tools from Section 2. In MCP, tools are actions a server exposes, like built-in functions or APIs the LLM can call mid-conversation. When a request needs action (fetching data, triggering an operation, a calculation), the model picks the right tool. MCP expects a human in the loop: users should be able to see and approve tool calls, so they stay in control. Two protocol methods cover tools:
| Method | What it does |
|---|---|
tools/list | Discover the server's tools. Returns tool definitions with their input schemas. |
tools/call | Run one tool with arguments. Returns the tool's result. |
Resources
A server can expose collections and documents as , which clients fetch, filter or search on demand. Clients send resources/list to discover them (and resources/templates/list for templates, covered below) and resources/read to get their contents.
Every resource has a unique (for example file:///path/to/document.md) and can declare a , a standard label that tells the client what kind of content it is (Markdown, JSON, PDF…) and how to handle it. For example, a server could share a course directory like this:
{
"uri": "file:///data/student-directory.json",
"name": "courseDirectory",
"title": "Course Directory",
"description": "List of courses and instructors in a program",
"mimeType": "application/json"
}Resource templates
expose parameterised resources through URI templates. The client fills in the variables to ask for specific data. For example, travel://activities/{city}/{category} covers many cities and categories, and travel://activities/barcelona/museums asks for one. In this section's lab, greeting://{name} works the same way.
Scenario 1: directly querying a resource
A student assistant helps students find course and instructor information. A student asks: "Who is the instructor for the Machine Learning course?" The model realises it needs course information, the client fetches the right resource from the server, and the assistant answers from it:
Conceptually the request looks like findResource("courseDirectory", {"course": "Machine Learning"}). The resource holds structured information (course names, instructors, schedules, departments) that rarely changes, so reading it is enough.
Scenario 2: a resource and a tool together
Now the student asks: "Find the instructor for the Machine Learning course and send them a message asking about office hours." Reading isn't enough this time; something must happen.
- 1
Query the resource
courseDirectoryidentifies the instructor. - 2
Invoke a tool
Something like
sendMessagesends the instructor the question. Tools perform actions such as sending messages or scheduling meetings.
iRule of thumb
Resources provide information (course directories, schedules). Use them for static or semi-static data so the AI gets context quickly, without running unnecessary operations. Use tools when something must happen.
Prompt templates
MCP are predefined instruction templates a server exposes. They standardise recurring interactions and save you rewriting the same instructions. Each can have variables that the client fills in at run time; the client fetches the prompt from the server and passes the completed text to the model.
Prompts can be versioned and updated centrally without changing client code. They're explicitly chosen by the user or client; they never run on their own. And they can be context-aware, referring to available resources and tools to build richer workflows. Here's a template for a student study assistant:
{
"name": "generateStudyPlan",
"title": "Generate Study Plan",
"description": "Create a personalized study plan for a student based on their course and exam date",
"arguments": [
{ "name": "course", "description": "Course the student is preparing for", "required": true },
{ "name": "examDate", "description": "Date of the upcoming exam", "required": true }
]
}The client fills in the arguments, generateStudyPlan({"course": "Machine Learning", "examDate": "2026-05-10"}), and the template guides the model to a structured schedule leading up to the exam. In the lab, the same idea appears as generate_study_plan(course, exam_date).
Tool, resource or prompt?
1. get_weather(city): fetch the current conditions when the model decides it needs them.
2. greeting://{name}: a short read-only text the app can load into context.
3. weather_report(city): a ready-made request for a detailed weather report that the user picks from a menu.
4. Send an email to the course coordinator.
5. The student directory as a JSON file, for the model to look things up in.
6. A study-plan instruction that takes a course name and an exam date.
0 of 6 answered
MCP client features
Clients aren't just consumers. They can offer servers three things of their own: borrowed model access, safe folder boundaries, and a way to ask the user a question.
Remember that a client lives inside a host, such as a code editor or browser extension. That host has the AI model, the user's permissions and the user interface, so the client can offer:
Sampling
Let MCP servers use your AI model, safely.
Roots
Define the filesystem boundaries a server may work in.
Elicitation
Let a server request input from the user at run time.
Sampling
lets a server temporarily use the AI model and permissions the client already has, instead of calling a model of its own. It reverses the usual direction: rather than the client asking the server for data, the server asks the client to run an AI task for it. That's ideal when the server has the domain data or business logic, but the client has the model, the user's permissions and the UI. The user stays in control of model usage, approvals and final outputs.
A travel server retrieves many Rome activities but has no AI to rank them. It sends a sampling request asking the client's model to choose the top five for someone who loves history, art and food. The client asks the user for approval, runs the model, shows the results, and once the user approves, sends the final AI-generated list back to the server.
Roots
are the client's standard way to tell servers where in the filesystem they may operate: which directories and files they have access to. Servers can ask for the list of roots, and get notified when it changes.
Example: an event planner uses an MCP-powered assistant. The folder file:///Users/planner/conference-2025 is the workspace for the current event, so the client defines it as a root:
[
{
"uri": "file:///Users/planner/conference-2025",
"name": "2025 Conference Workspace"
}
]What may the server touch?
The planner's computer (root highlighted)
- /Users/planner/
- └ conference-2025/
- └ attendees.csv
- └ schedule.md
- └ venues/
- └ hotel-quotes.pdf
- └ gala-2025/
- └ budget.xlsx
- └ personal/
- └ tax-return.pdf
The server tries to…
Inside the root, the server can save updated attendee lists to conference-2025. Outside it, personal documents and other projects, it has no access. That's deliberate. When the planner switches projects, the client updates the roots list and notifies the server, so access stays tightly scoped.
</>In the lab
The lab's client shares its own folder as a root ("Course workspace"), and the server's list_client_roots tool asks the client for it, so you can watch a server-to-client request happen.
Elicitation
lets a server ask for missing information during a session instead of guessing. The server notices that required context is missing or ambiguous and sends an elicitation request; the client asks the user, who can review, edit, reject or cancel; the answer goes back and the server continues. There are two modes:
MCP at a glance
| MCP component | Purpose | What it handles |
|---|---|---|
| Base protocol | Defines the core communication structure | JSON-RPC message types between clients and servers |
| Transport | The channel between client and server | stdio and Streamable HTTP |
| Lifecycle management | Manages the connection | Initialisation, capability negotiation, session control |
| Authorisation | Secures communication | Authentication and authorisation for HTTP-based transports |
| Server features | Capabilities servers expose | Resources, prompts and tools |
| Client features | Capabilities clients provide | Sampling, roots and elicitation |
Using and building MCP servers
Two ways to work with servers: use one that already exists, or build your own for your systems.
Using pre-built servers
Many ready-made servers exist, from Anthropic, the open-source community and AWS (through the official MCP servers for AWS from AWS Labs). You can connect, say, the GitHub MCP server or a Slack MCP server to an MCP host such as Claude Code, VS Code or Kiro, and every MCP-compatible agent can use their tools.
A concrete example is the AWS Documentation MCP server, which exposes tools for searching and reading official AWS documentation. Your code launches it as a local process with uvx, creates an MCP client that talks to it over stdio, lists its tools, and hands them to a Strands agent. The agent then answers AWS questions from the docs rather than only from its training. You'll run exactly this in 5.7. You write no AWS-documentation integration code yourself: the server packages that capability as reusable tools.
Building your own server
To expose your organisation's tools and data, you build a custom server: use an SDK to define tools (actions) and resources (data), then run it with a chosen transport. Official MCP SDKs exist for many languages, including Python, TypeScript, Java, C# and Go; this course uses Python.
FastMCP vs FastAPI
| What it is | Built specifically for MCP, with MCP-native ways to define tools, resources and prompts | A general-purpose web framework |
| Choose it when | You want an MCP server quickly, with little boilerplate, and the service mainly exposes MCP capabilities | MCP is one part of a bigger service that also has REST APIs, custom auth flows, health checks or dashboards |
| Trade-off | Focused on MCP | More flexibility, but you write more of the MCP plumbing yourself |
✦Think of it like this
FastMCP is a ready-made food-truck kit: perfect when all you want to do is serve one menu, fast. FastAPI is a full restaurant kitchen: more to set up, but it can also run the bar, the bakery and the delivery counter.
Using MCP from agent frameworks
Frameworks such as Strands and LangChain act as MCP clients (or adapters): they let agents discover and call tools exposed by MCP servers. They don't define the server; they help agents consume MCP capabilities inside a bigger reasoning and orchestration flow.
Strands
Strands Agents ships with MCP support built on the official MCP Python SDK (which includes FastMCP). Use Strands for the agent, tool discovery and AWS connections, and FastMCP for the fast, focused tools the agent calls.
LangChain
The langchain-mcp-adapters library converts MCP tools into standard LangChain tools, so LangChain and LangGraph agents can use any MCP server.
Lab: an MCP server and client
Build a weather server with FastMCP that exposes every MCP primitive, and a client that discovers and uses each one. No LLM, no AWS: just the protocol.
| Primitive | Name | What it is |
|---|---|---|
| Tool | get_weather(city) | A function the LLM can call |
| Tool | list_client_roots() | Asks the client which folders it shared (roots) |
| Resource (template) | greeting://{name} | Data the app can read, like a GET endpoint |
| Resource (JSON) | courses://directory | A fixed resource with application/json content |
| Prompt | weather_report(city) | A reusable prompt template |
| Prompt | generate_study_plan(course, exam_date) | A template with two arguments |
And the client exposes roots: the folders the server is allowed to work in.
server.py, step by step
"""
MCP Server — exposes all the MCP primitives over stdio transport:
* Tools — get_weather, list_client_roots
* Resources — greeting://{name} and courses://directory (JSON)
* Prompts — weather_report, generate_study_plan
MCP (Model Context Protocol) lets LLM applications discover and call tools,
read resources, and use prompt templates through one standard protocol.
Run: python client.py (the client starts this server for you)
"""
import json
# FastMCP: high-level helper for building MCP servers. It handles JSON-RPC
# parsing, registration of tools/resources/prompts, and the transport.
from mcp.server.fastmcp import Context, FastMCP
# The name identifies this server to clients when they connect.
mcp = FastMCP("WeatherServer", log_level="WARNING")FastMCP is the high-level API from the official MCP Python SDK. It handles the protocol details for you: JSON-RPC messages, capability negotiation, and generating schemas from your type hints. The name "WeatherServer" is how the server introduces itself during the handshake; log_level="WARNING" keeps its own logs quiet.
# --- Tool: get_weather ---------------------------------------------------
# Tools are functions an LLM can call. @mcp.tool() registers the function so
# it appears in list_tools() and can be invoked with call_tool(). The
# docstring becomes the description the LLM sees.
@mcp.tool()
def get_weather(city: str) -> str:
"""Get the current weather for a given city."""
# Fake data for demo purposes — a real server would call a weather API
fake_weather = {
"london": "15°C, cloudy",
"paris": "18°C, sunny",
"tokyo": "22°C, humid",
"new york": "12°C, windy",
"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() registers the function so it appears in tools/list and can be run with tools/call. Just like Strands' @tool, the type hints become the input schema and the docstring becomes the description. The data is fake on purpose (including Bangalore, which the Strands lab asks about); a real server would call a weather API.
# --- Tool that uses a client capability: roots ---------------------------
# Roots are folders/URIs the *client* says the server may work within.
# A server can ask the client for its roots at any time.
@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()
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)This tool shows the protocol running backwards. FastMCP injects a Context object (it is hidden from the input schema, which is why list_client_roots shows no arguments in tools/list); ctx.session.list_roots() sends a roots/list request from the server to the client and waits for the answer. Because it waits for a round trip to the client over the pipes, the function is async.
# --- Resource: greeting ---------------------------------------------------
# Resources are data an LLM application can read (like GET endpoints).
# "greeting://{name}" is a URI template: read_resource("greeting://Developer")
# fills in {name}. Use resources for data, tools for actions.
@mcp.resource("greeting://{name}")
def get_greeting(name: str) -> str:
"""Return a personalized greeting."""
return f"Hello, {name}! Welcome to the WeatherServer."Resources are read-only data endpoints, like GET requests in a REST API. Because the URI contains {name}, this is a template: reading greeting://Developer calls the function with name="Developer".
# --- Resource: course directory (structured JSON) -------------------------
# A fixed URI with a MIME type tells the client how to interpret the content.
COURSES = [
{"course": "Machine Learning", "instructor": "Dr. Rao", "weeks": 12},
{"course": "Agentic AI on AWS", "instructor": "Shivank Agrawal", "weeks": 8},
{"course": "Data Engineering", "instructor": "Ms. Iyer", "weeks": 10},
]
@mcp.resource(
"courses://directory",
name="courseDirectory",
title="Course Directory",
description="List of courses and instructors in a program",
mime_type="application/json",
)
def course_directory() -> str:
return json.dumps(COURSES, indent=2)A fixed URI with a name, title, description and mime_type="application/json", the same shape as the resource description in 5.3, with a custom courses:// URI instead of a file path. The function returns the course list as JSON text.
# --- Prompts ---------------------------------------------------------------
# Prompts are reusable templates. Clients call get_prompt(name, arguments)
# and get back ready-to-send messages.
@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."
@mcp.prompt(title="Generate Study Plan")
def generate_study_plan(course: str, exam_date: str) -> str:
"""Create a personalized study plan for a student based on their course and exam date."""
return (
f"Create a week-by-week study plan for the course '{course}'. "
f"The exam is on {exam_date}. Include revision weeks, practice tests, "
"and one concrete goal per week."
)@mcp.prompt() registers reusable templates. A client calls get_prompt("weather_report", {"city": "Tokyo"}) and gets back ready-to-send messages. generate_study_plan is the lab's version of the study-plan template from 5.3, with a title.
# --- Start the server ------------------------------------------------------
# "stdio" = read JSON-RPC messages from stdin, write responses to stdout.
# The client spawns this script as a subprocess and talks to it through pipes.
if __name__ == "__main__":
mcp.run(transport="stdio")stdio means: read JSON-RPC messages from standard input and write responses to standard output. The server can connect to an MCP host (Claude Code, Kiro) or to your own client, which is next.
client.py, step by step
"""
MCP Client — starts server.py over stdio and exercises every MCP primitive:
tools, resources, prompts, and roots.
Run: python client.py
"""
import asyncio
import json
import sys
from pathlib import Path
import mcp.types as types
# ClientSession: the MCP protocol session (initialize, list, call, ...)
# StdioServerParameters: how to start the server subprocess
from mcp import ClientSession, StdioServerParameters
# stdio_client: starts the server process and returns read/write streams
from mcp.client.stdio import stdio_client
HERE = Path(__file__).resolve().parent| Import | Its job |
|---|---|
ClientSession | The high-level MCP client that speaks the protocol for you |
StdioServerParameters | Settings that tell the client how to start the server |
stdio_client | The transport: starts the subprocess and manages its stdin/stdout pipes |
mcp.types | Protocol data types, used here to describe roots |
# --- Roots -----------------------------------------------------------------
# The client decides which folders the server may work within. When the
# server asks (list_roots), this callback answers.
async def list_roots(context) -> types.ListRootsResult:
return types.ListRootsResult(
roots=[
types.Root(uri=HERE.as_uri(), name="Course workspace"),
]
)The client decides which folders the server may use. When the server asks (through list_client_roots), this callback answers with one root: the lab folder, named "Course workspace". HERE.as_uri() turns the folder path into a file:// URI.
async def main():
# Start "python server.py" as a subprocess. sys.executable is the same
# Python that runs this client, so the server sees the same packages.
server_params = StdioServerParameters(
command=sys.executable,
args=[str(HERE / "server.py")],
)
async with stdio_client(server_params) as (read_stream, write_stream):
async with ClientSession(read_stream, write_stream, list_roots_callback=list_roots) as session:
# Required first step: exchange protocol version and capabilities
await session.initialize()
# --- 1. Discover tools ----------------------------------------
tools = await session.list_tools()
print("=== Available Tools ===")
for tool in tools.tools:
print(f" - {tool.name}: {tool.description}")
# --- 2. Discover resources ------------------------------------
# Fixed resources and URI templates are listed separately.
print("\n=== Available Resources ===")
resources = await session.list_resources()
for r in resources.resources:
print(f" - {r.uri} ({r.mimeType}): {r.description}")
templates = await session.list_resource_templates()
for rt in templates.resourceTemplates:
print(f" - {rt.uriTemplate}: {rt.description}")
# --- 3. Discover prompts --------------------------------------
print("\n=== Available Prompts ===")
prompts = await session.list_prompts()
for p in prompts.prompts:
args = ", ".join(a.name for a in (p.arguments or []))
print(f" - {p.name}({args}): {p.description}")- a
StdioServerParameters(...)Doesn't start anything yet. It says: when you connect, run
server.pywith the same Python (sys.executable), so the server sees the same packages. - b
async with stdio_client(...)Starts the server subprocess and hands back raw read/write streams. The
async withblocks guarantee clean-up: streams closed, process stopped, even if something fails. That's the shutdown stage of the lifecycle. - c
ClientSession(..., list_roots_callback=list_roots)andinitialize()Wraps the streams in the MCP protocol and performs the handshake: versions and capabilities are exchanged. Passing the roots callback is what tells the server "this client supports roots".
- d
Discovery
list_tools(),list_resources(),list_resource_templates()andlist_prompts()ask the server "what do you offer?". Fixed resources and URI templates are listed separately.
# --- 4. Call a tool (known city) ------------------------------
print("\n=== Calling get_weather('London') ===")
result = await session.call_tool("get_weather", {"city": "London"})
print(f" Result: {result.content[0].text}")
# --- 5. Call a tool (unknown city) ----------------------------
print("\n=== Calling get_weather('Berlin') ===")
result = await session.call_tool("get_weather", {"city": "Berlin"})
print(f" Result: {result.content[0].text}")
# --- 6. Read a templated resource -----------------------------
print("\n=== Reading greeting://Developer ===")
resource = await session.read_resource("greeting://Developer")
print(f" Result: {resource.contents[0].text}")
# --- 7. Read a JSON resource ----------------------------------
print("\n=== Reading courses://directory ===")
resource = await session.read_resource("courses://directory")
courses = json.loads(resource.contents[0].text)
for c in courses:
print(f" - {c['course']} — {c['instructor']} ({c['weeks']} weeks)")call_tool sends tools/call with a name and arguments; result.content is a list of content blocks, so [0].text is the first block's text. Berlin isn't in the fake data, so you'll see the polite "no data" message: tools return errors as normal results. read_resource sends resources/read; the server matches greeting://Developer against the template and fills in the name. The course directory comes back as JSON text, which json.loads turns into Python data.
# --- 8. Get prompts -------------------------------------------
print("\n=== Getting weather_report prompt ===")
prompt = await session.get_prompt("weather_report", {"city": "Tokyo"})
print(f" Result: {prompt.messages[0].content.text}")
print("\n=== Getting generate_study_plan prompt ===")
prompt = await session.get_prompt(
"generate_study_plan", {"course": "Machine Learning", "exam_date": "2026-05-10"}
)
print(f" Result: {prompt.messages[0].content.text}")
# --- 9. Roots: the server asks the client ---------------------
print("\n=== Calling list_client_roots ===")
result = await session.call_tool("list_client_roots", {})
print(f" Result: {result.content[0].text}")
if __name__ == "__main__":
asyncio.run(main())get_prompt fetches a template with arguments; the server fills it in and returns a list of messages. In a real app you'd send those messages to an LLM. The final call triggers the roots round trip. asyncio.run(main()) starts an event loop (the scheduler that runs async code and switches between tasks while they wait), runs main(), and closes the loop when done.
Run it
pip install -r requirements.txt
python client.py # starts server.py automatically=== Available Tools ===
- get_weather: Get the current weather for a given city.
- list_client_roots: List the workspace roots the connected client has shared with this server.
=== Available Resources ===
- courses://directory (application/json): List of courses and instructors in a program
- greeting://{name}: Return a personalized greeting.
=== Available Prompts ===
- weather_report(city): Generate a prompt asking for a weather report.
- generate_study_plan(course, exam_date): Create a personalized study plan for a student based on their course and exam date.
=== Calling get_weather('London') ===
Result: Weather in London: 15°C, cloudy
=== Calling get_weather('Berlin') ===
Result: Sorry, no weather data available for 'Berlin'.
=== Reading greeting://Developer ===
Result: Hello, Developer! Welcome to the WeatherServer.
=== Reading courses://directory ===
- Machine Learning — Dr. Rao (12 weeks)
- Agentic AI on AWS — Shivank Agrawal (8 weeks)
- Data Engineering — Ms. Iyer (10 weeks)
=== Getting weather_report prompt ===
Result: Please provide a detailed weather report for Tokyo, including temperature, humidity, and forecast.
=== Getting generate_study_plan prompt ===
Result: Create a week-by-week study plan for the course 'Machine Learning'. The exam is on 2026-05-10. Include revision weeks, practice tests, and one concrete goal per week.
=== Calling list_client_roots ===
Result: Course workspace: file:///…/mcp-basicsNotice that the client never imported the server's functions. Everything it knows (names, descriptions, argument lists) came over the protocol at run time. That's dynamic discovery.
</>Want a visual tool?
If you have Node.js installed, run npx @modelcontextprotocol/inspector python server.py in the mcp-basics folder. The MCP Inspector opens in your browser and lets you click through the server's tools, resources and prompts.
Lab: Strands agents with MCP tools
Now add the LLM. A Strands agent gets its tools from MCP servers, first from your own server, then from AWS's documentation server, without knowing anything about those tools in advance.
Both scripts follow the same pattern:
mcp_client = MCPClient(lambda: stdio_client(StdioServerParameters(command=..., args=[...])))
with mcp_client:
tools = mcp_client.list_tools_sync()
agent = Agent(tools=tools)
agent("...")Script 1: an agent using your own MCP server
"""
A Strands agent that gets its tools from an MCP server.
It starts the server from ../mcp-basics/server.py over stdio, discovers its
tools, and lets the model decide when to call them. The agent code knows
nothing about the weather tool in advance — it all comes over MCP.
Run: python agent_with_local_mcp.py
"""
import os
import sys
from pathlib import Path
from mcp import StdioServerParameters, stdio_client
from strands import Agent
from strands.models.bedrock import BedrockModel
from strands.tools.mcp import MCPClient
MODEL_ID = os.getenv("BEDROCK_MODEL_ID", "global.anthropic.claude-sonnet-4-6")
REGION = os.getenv("AWS_REGION", os.getenv("AWS_DEFAULT_REGION", "us-east-1"))
SERVER = Path(__file__).resolve().parent.parent / "mcp-basics" / "server.py"
# MCPClient takes a function that opens the transport. Here: spawn the server.
mcp_client = MCPClient(
lambda: stdio_client(StdioServerParameters(command=sys.executable, args=[str(SERVER)]))
)
with mcp_client:
tools = mcp_client.list_tools_sync()
print("Tools discovered over MCP:", [t.tool_name for t in tools])
agent = Agent(model=BedrockModel(model_id=MODEL_ID, region_name=REGION), tools=tools)
agent("I'm flying from Bangalore to London tomorrow. What's the weather like in both cities?")
print()- a
SERVER = …/"mcp-basics"/"server.py"Reuses the server you just built.
Path(__file__).resolve().parent.parentclimbs from this script's folder to the section folder. - b
MCPClient(lambda: stdio_client(...))Strands' MCP client. It takes a function that opens the transport (that's what the
lambdais), so Strands can open the connection when it's ready, on its own background thread. - c
with mcp_client:Starts the server and keeps the MCP session open for the whole block. The agent must be used inside it, because its tools are live connections to the server.
- d
list_tools_sync()Discovers the server's tools and wraps each as a Strands tool.
t.tool_namegives each one's name. Pass the list straight toAgent(tools=...).
Tools discovered over MCP: ['get_weather', 'list_client_roots'] I'll check the weather in both cities for you. Tool #1: get_weather Tool #2: get_weather Here's the weather for your trip: 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.
The agent's code never mentioned weather. It learned about get_weather over MCP, and Claude decided to call it twice, once per city.
Script 2: an agent using the AWS Documentation MCP server
"""
A Strands agent connected to the AWS Documentation MCP server.
The AWS Labs documentation server (https://github.com/awslabs/mcp) lets the
agent search and read official AWS docs, so its answers come from current
documentation instead of only the model's training data.
Prerequisite: `uv` installed (it provides the `uvx` command that downloads and
runs the server on demand).
Run: python agent_with_aws_docs_mcp.py
"""
import os
import shutil
from mcp import StdioServerParameters, stdio_client
from strands import Agent
from strands.models.bedrock import BedrockModel
from strands.tools.mcp import MCPClient
MODEL_ID = os.getenv("BEDROCK_MODEL_ID", "global.anthropic.claude-sonnet-4-6")
REGION = os.getenv("AWS_REGION", os.getenv("AWS_DEFAULT_REGION", "us-east-1"))
if shutil.which("uvx") is None:
raise SystemExit("`uvx` not found. Install uv first: pip install uv")
# Create MCP client
mcp_client = MCPClient(
lambda: stdio_client(
StdioServerParameters(
command="uvx",
args=["awslabs.aws-documentation-mcp-server@latest"],
env={**os.environ, "FASTMCP_LOG_LEVEL": "ERROR"},
)
),
startup_timeout=120, # first run downloads the server package
)
# Use with agent
with mcp_client:
tools = mcp_client.list_tools_sync()
print("Tools from the AWS Documentation MCP server:", [t.tool_name for t in tools])
agent = Agent(
model=BedrockModel(model_id=MODEL_ID, region_name=REGION),
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?")
print()| Line | Why it's there |
|---|---|
shutil.which("uvx") | The server is started with , which comes with uv (in this lab's requirements.txt). The check fails early with a clear message if it's missing. |
args=["awslabs.aws-documentation-mcp-server@latest"] | Tells uvx to download (first time) and run the latest AWS Labs documentation server. |
env={**os.environ, "FASTMCP_LOG_LEVEL": "ERROR"} | Passes your environment through to the server, and quiets its logs. |
startup_timeout=120 | The first run downloads the server package, which can take 30 seconds or more and exceed Strands' default 30-second startup timeout. |
system_prompt=…"Cite the doc URLs you used." | Asks the agent to answer from the documentation tools and show its sources. |
Tools from the AWS Documentation MCP server: ['read_documentation', 'search_documentation', 'recommend'] Tool #1: search_documentation Tool #2: read_documentation AWS Lambda is a serverless compute service that runs your code in response to events and automatically manages the underlying compute resources. The maximum timeout for a Lambda function is 900 seconds (15 minutes). Sources: - https://docs.aws.amazon.com/lambda/latest/dg/welcome.html - https://docs.aws.amazon.com/lambda/latest/dg/configuration-timeout.html
iWhy this matters
Model knowledge has a cut-off date; documentation doesn't. By plugging in a documentation server, the agent's answers come from current docs, and you wrote zero integration code for it.
A2A: agents talking to agents
MCP connects an agent to tools. But what about connecting an agent to another agent, perhaps built by a different team, in a different framework, running somewhere else?
The is a standard bridge that lets agents built on different stacks (LangGraph, CrewAI, Google ADK, Strands and others) communicate, discover each other and manage shared workflows, even across organisations. Google introduced it in 2025; it has since merged with IBM's Agent Communication Protocol (ACP) and is now a vendor-neutral project governed by the Linux Foundation.
A2A is about peer-to-peer collaboration between agents: they can delegate complex tasks and solve open-ended problems while keeping their internals private. MCP, in contrast, connects agents to specific tools and functions.
MCP vs A2A: why not just wrap an agent as a tool?
MCP reduces the complexity of connecting agents to tools and data. Tools are usually and do one predefined thing (a calculator, a database query). So could you simply expose an agent as an MCP tool? Sometimes, and Section 4 did something similar inside one program. But as a general approach for independent agents it's limiting: tools execute actions; agents solve problems. Squeezing an agent into a tool call hides what it can really do: handle ambiguity, ask follow-up questions, run multi-step and long-running work, report progress.
A2A complements MCP. It lets agents collaborate in their natural mode, talking as agents (or as users) rather than as function calls, which enables multi-turn interactions where agents reason, plan and delegate.
- 1
The patient's request goes to the diagnosis agent
- 2
It talks to the insurance agent over A2A
- 3
Each agent acts with its own MCP-connected tools
The diagnosis agent reaches the hospital's systems; the insurance agent reaches insurance and pharmacy APIs.
- 4
Information flows back and forth over A2A until the task is complete
Why agents need a standard protocol
As agents become more capable they also become more specialised, and more likely to work inside a larger multi-agent system. But the ecosystem is fragmented across frameworks, vendors and styles. A2A gives them a common protocol to communicate and coordinate across organisational and technical boundaries, without sharing their internals.
| Reason | In detail |
|---|---|
| Solve framework fragmentation | Agent frameworks (LangGraph, CrewAI, Microsoft's framework and others) aren't compatible out of the box. A2A lets agents talk whatever model or language they use. |
| Enable complex collaboration | Tasks like planning a whole trip (flights, hotels, activities) need several specialised agents; A2A gives the structure and methods to coordinate them. |
| A common language (the HTTP analogy) | The web needed HTTP to move data between different systems; the agent world needs A2A as a universal bridge beyond siloed apps. |
| Cross-organisation workflows | Agents collaborate across teams and companies while staying opaque: no internal implementation or private code is exposed. |
| Preserve agent capabilities | Agents handle ambiguity, multi-step workflows, coordination and open-ended goals. Reducing them to one tool call loses that; A2A supports the richer interaction. |
| Vendor neutrality | Hosted by the Linux Foundation and governed by a technical steering committee, so no single company controls how agents interact. |
A2A by example: a product launch
A marketing manager asks an AI system: "Create and execute a launch plan for our new eco-friendly smartphone in the European market." That needs many specialities, so (just like the multi-agent patterns in Section 4) a lead orchestrator agent breaks it down and works with:
Each specialist sits on its own system: market research on proprietary data, supply chain on the company's , creative content on an AI creative tool, legal on a secure legal model. Without A2A, each agent has its own bespoke development and deployment, so they can't collaborate, let alone discover what each other can do. A2A acts as the common communication layer: the orchestrator can discover agents by capability, agents exchange information in a shared format, and one agent can even verify or approve another's output. The orchestrator then combines everything into one launch playbook, turning weeks of human coordination into a fast, automated workflow.
iSwarm vs A2A
The swarm pattern from Section 4 organises agents inside one multi-agent system. A2A solves a different problem: standard communication between independently built agents, across frameworks, platforms and organisations. You could even run a swarm whose members talk A2A.
How A2A works
The protocol standardises only the bridge between agents. Agent A doesn't need Agent B's code, prompts or database; it only needs to format an A2A request and read an A2A response.
Agent A: the client agent
Starts the conversation and asks for work. Usually the front-facing agent that talks to the end user. It discovers other agents by reading their to see what they can do.
Agent B: the remote (server) agent
Receives the request and does the work. It hosts its capabilities at a URL and publishes its agent card at /.well-known/agent-card.json. It decides how to fulfil a request: a simple one gets a message straight back; a complex one becomes a task it manages until it produces an artifact (the result).
Here's a simplified agent card for a weather agent (you'll see a complete one from the lab in 5.11):
{
"name": "Weather Agent",
"capabilities": {
"streaming": true,
"pushNotifications": false
},
"skills": [
{
"id": "get_weather",
"name": "Get Weather",
"description": "Returns current weather conditions for a given city."
}
],
"url": "https://api.example.com/weather-agent/"
}?Capabilities
How this agent communicates: does it support streaming, push notifications…
?Skills
What work it can do: each skill has an id, name and description (and often tags and examples).
iOpaque by design
A remote agent never has to show how it works inside: which code, which model. It only has to produce the output the protocol defines.
Discovery and protocol bindings
Agents find each other through agent cards: JSON files at a well-known URL, a bit like an , acting as a digital business card. A card details the agent's name, capabilities, supported protocols, media types and authentication requirements, including its URI (endpoint) and its supported .
A binding tells the client agent exactly which technical format to use with that remote agent. A2A provides the language; the binding is the physical connection that carries it.
✦Think of it like this
You've written a letter in English (the common language, A2A). How do you send it? HTTP binding is ordinary post: the most common and easiest. gRPC binding is a high-speed courier: faster and more efficient for heavy data. JSON-RPC binding is a telegram: it follows a strict set of remote-control rules.
| Binding | In practice |
|---|---|
| JSON-RPC | JSON-RPC 2.0 over HTTP, with server-sent events for streaming. The binding this section's lab uses. |
| gRPC | Binary, high-performance remote calls (); good for heavy traffic |
| HTTP + JSON (REST) | Plain HTTP endpoints with JSON bodies |
!Both sides must share a binding
If one agent only speaks gRPC and the other only HTTP+JSON (REST), they can't talk directly. They must agree on a binding, which is exactly what their agent cards declare.
The A2A data model: messages, tasks, artifacts
- 1
Message
The client sends a (one turn of interaction) made of one or more parts: text, JSON data, files.
- 2
Task
When the remote agent starts working, it may create a to track progress over time, with an ID and a status.
- 3
Artifacts
As the task progresses or completes it produces , output containers that also hold parts.
- 4
GetTask
The client can call
GetTaskat any time to retrieve the latest status and whatever artifacts exist so far.
Communication styles
Tasks differ: some finish in a second, some take hours. A2A supports four ways of exchanging them:
Synchronous
For simple, immediate answers. Agent A sends a message with SendMessage; Agent B processes it and replies right away. The client waits for the result, so it suits quick, short-lived tasks.
Asynchronous (polling)
For complex or long-running jobs where the client shouldn't block. Agent B replies with a task object instead of the final result: a task ID plus a status. Agent A carries on and periodically calls GetTask with that ID. When the task reaches a completed state, the results come back as artifacts.
In the Python A2A SDK, a plain message/send waits for the answer by default. To poll instead, the client sets ClientConfig(polling=True); the request then asks the server not to block, and the client calls GetTask (tasks/get) in a loop until the task is finished.
Streaming
Uses server-sent events to deliver real-time updates or text in chunks as it's generated, so you see live progress. It's only available if the server sets capabilities.streaming = true in its agent card. Agent A calls SendStreamingMessage; the server keeps an HTTP connection open (text/event-stream) and sends structured events (status changes, intermediate messages, partial results) until the task completes.
Push notifications
For tasks that take a long time, or when the client can't stay connected. Instead of polling or streaming, the client gives a URL and the server calls it when something important happens, such as completion or "input required". A pharmacy agent might notify a healthcare system hours later when a prescription is ready; a flights agent might notify when a booking is confirmed. Ideal for long-running, event-driven work.
Push notifications only work if the server declares capabilities.pushNotifications = true in its agent card. Agent A supplies a PushNotificationConfig (webhook URL plus optional authentication) with the first request or through a separate call. Agent B then POSTs to that webhook on significant updates, and Agent A usually follows up with GetTask to fetch the full task and results.
?Method names on the wire
The A2A spec names its operations SendMessage, SendStreamingMessage and GetTask. With the JSON-RPC binding and the a2a-sdk 0.3 used in this section, they travel as the methods message/send, message/stream and tasks/get.
Four ways to ask the flights agent to book a ticket
- Agent A
SendMessage“Book flight AE303 for Asha Rao”. The connection stays open while it waits. - Agent AWaiting… can't do anything else with this request until the reply arrives.
- Agent BChecks the flight, reserves a seat, issues the booking.
- Agent BReply on the same connection: task completed, artifact “Booking confirmed: AE303, Asha Rao”.
Implementing A2A
The easiest way to understand the options is as three layers, each built on the one below.
- 1
Core protocol layer: the A2A SDK
The
a2a-sdkpackage is the low-level protocol library, the most direct way to build an A2A client or server. It provides data types such asMessage,TextPart,AgentSkillandAgentCapabilities, and client utilities such asA2ACardResolver,ClientFactoryandClientConfig. Using only the SDK gives full control, but you wire everything yourself: how your server publishes capabilities, routes requests, creates and updates tasks, and returns responses. - 2
Framework integrations
Libraries on top of the SDK that implement the protocol for you while keeping your framework's programming model, for example Google ADK and Strands A2A (
strands-agents[a2a]). Strands provides two key pieces:A2AServerexposes a Strands agent as an A2A-compliant server, andA2AAgentlets you use a remote A2A agent as if it were a local Strands agent. - 3
Platform-specific implementations
Hosted platforms that expose A2A endpoints for agents deployed on them. For example, LangSmith Agent Server is the main way LangChain offers A2A today: the platform handles JSON-RPC messaging and task management, and your agent still takes part in A2A.
| Layer | Example | Gives you |
|---|---|---|
| Protocol primitives | A2A SDK | Every building block, full control, most wiring |
| Framework integration | Strands A2A | An agent becomes a server in a few lines |
| Managed platform | LangSmith Agent Server | Endpoints for agents deployed on that platform |
The lab uses both of the first two layers: Strands A2A for the two agent servers (the quickest path from an agent definition to a working A2A server), and the raw A2A SDK for the orchestrator, so you can see the protocol objects directly.
Lab: a travel assistant over A2A
Two independent Strands agents (weather and flights) run as A2A servers. A travel orchestrator discovers them through their and asks each for its part of a trip.
| File | Role | Description |
|---|---|---|
weather_agent_server.py | A2A server | Weather lookup agent on port 9001 |
flights_agent_server.py | A2A server | Flight search and booking agent on port 9002 |
travel_orchestrator.py | A2A client | Calls both agents and prints a trip summary |
The weather agent server
"""
Weather Agent — A2A Server on port 9001.
Exposes a weather lookup tool via the Strands A2A protocol.
Run: python weather_agent_server.py
"""
import logging
import os
# Agent: the core Strands agent class that wraps an LLM with tools
# tool: decorator that registers a Python function as a tool the LLM can call
from strands import Agent, tool
# A2AServer: wraps a Strands Agent into an HTTP server that speaks the A2A protocol
from strands.multiagent.a2a import A2AServer
# AgentSkill: A2A SDK type that describes a capability in the agent card
from a2a.types import AgentSkill
# BedrockModel: the LLM behind this agent (Amazon Bedrock)
from strands.models.bedrock import BedrockModel
MODEL_ID = os.getenv("BEDROCK_MODEL_ID", "global.anthropic.claude-sonnet-4-6")
REGION = os.getenv("AWS_REGION", os.getenv("AWS_DEFAULT_REGION", "us-east-1"))
# Set up logging so we can see what's happening during requests
logging.basicConfig(level=logging.INFO)Note the new import: A2AServer from strands.multiagent.a2a, and AgentSkill from the A2A SDK (a2a.types). The model is set explicitly from BEDROCK_MODEL_ID, as everywhere in this course.
# --- Fake Weather Data ---------------------------------------------------
# In a real app this would call a weather API.
# Here we use a dictionary for demo purposes.
FAKE_WEATHER = {
"london": "15°C, cloudy with occasional rain",
"paris": "18°C, sunny with light breeze",
"tokyo": "22°C, humid and partly cloudy",
"new york": "12°C, windy and cool",
"dubai": "38°C, hot and sunny",
"sydney": "20°C, mild and clear",
"cancun": "30°C, tropical and sunny",
"rome": "24°C, warm and sunny",
"bangkok": "33°C, hot and humid",
"reykjavik": "5°C, cold and overcast",
}
# --- Weather Tool --------------------------------------------------------
# The @tool decorator registers this function so the LLM can call it.
# The docstring becomes the tool description the LLM sees when deciding
# which tool to use. Args/Returns are parsed for the tool's input schema.
@tool
def get_weather(city: str) -> str:
"""Get the current weather for a given city.
Args:
city: Name of the city to get weather for.
Returns:
A string describing the current weather conditions.
"""
# Normalize input: strip whitespace, lowercase for dictionary lookup
result = FAKE_WEATHER.get(city.strip().lower())
if result:
return f"Weather in {city}: {result}"
# If city not found, tell the user what cities are available
return f"No weather data available for '{city}'. Available cities: {', '.join(FAKE_WEATHER.keys())}"# --- Agent Card Skills ---------------------------------------------------
# Skills are published in the agent card at /.well-known/agent-card.json.
# Any A2A client fetches this card first to discover what the agent can do
# BEFORE sending any messages. Think of it like an API spec for agents.
WEATHER_SKILLS = [
AgentSkill(
# Unique identifier for this skill
id="get_weather",
# Human-readable name shown in the agent card
name="Get Weather",
# Detailed description — helps clients understand what this skill does
description=(
"Returns current weather conditions (temperature, sky, wind) "
"for a given city. Supports: London, Paris, Tokyo, New York, "
"Dubai, Sydney, Cancun, Rome, Bangkok, Reykjavik."
),
# Tags for categorization and discovery
tags=["weather", "temperature", "forecast", "travel"],
# Example prompts — hints to clients on how to use this skill
examples=[
"What is the weather in London?",
"How hot is it in Dubai right now?",
"Tell me the weather conditions in Tokyo",
],
),
]AgentSkill describes each capability, or , with an id, name, description, tags and example prompts. Passed to the server as skills=WEATHER_SKILLS, they appear in the agent card, which clients fetch first to understand what the agent can do. Think of it as an API spec for agents.
# --- Strands Agent -------------------------------------------------------
# This is the actual AI agent. It combines:
# - An LLM (Amazon Bedrock, chosen by BEDROCK_MODEL_ID)
# - A system prompt (instructions for the LLM)
# - Tools (functions the LLM can call)
#
# When a message comes in, the agent sends it to the LLM. The LLM reads
# the system prompt, sees the available tools, and decides whether to call
# get_weather. If it does, the tool runs, and the LLM formats the final answer.
weather_agent = Agent(
# The LLM that powers this agent
model=BedrockModel(model_id=MODEL_ID, region_name=REGION),
# Name and description — used in the A2A agent card
name="Weather Agent",
description="Provides current weather information for cities around the world.",
# System prompt — instructions the LLM follows for every request
system_prompt=(
"You are a helpful weather assistant. Use the get_weather tool to look up "
"weather conditions for any city the user asks about. Be concise and friendly."
),
# Register the get_weather tool so the LLM can call it
tools=[get_weather],
# Disable streaming callback on the server side (responses are collected, not printed)
callback_handler=None,
)An ordinary Strands agent. Two details matter for A2A: name and description become the agent card's name and description (Strands refuses to build a card if either is empty), and callback_handler=None stops the server printing every response, since answers go back over HTTP instead.
# --- A2A Server ----------------------------------------------------------
# Only runs when you execute this file directly (python weather_agent_server.py).
# Importing this file from another module won't start the server.
if __name__ == "__main__":
# A2AServer wraps the Strands Agent into an HTTP server that:
# 1. Serves an agent card at GET /.well-known/agent-card.json
# 2. Accepts A2A messages at POST / (JSON-RPC: message/send, message/sendStream)
# 3. Routes messages through the Strands Agent (LLM + tools)
# 4. Returns responses as A2A task objects with artifacts
server = A2AServer(
# The Strands Agent that handles all incoming messages
agent=weather_agent,
# Bind to localhost only (use "0.0.0.0" to expose to network)
host="127.0.0.1",
# HTTP port to listen on
port=9001,
# Skills published in the agent card for client discovery
skills=WEATHER_SKILLS,
# Version string in the agent card
version="1.0.0",
# Use A2A-spec-compliant SSE streaming with artifact updates
# (vs legacy status-only streaming). The agent card will advertise
# {"capabilities": {"streaming": true}} so clients know SSE is supported.
enable_a2a_compliant_streaming=True,
)
print("Weather Agent A2A server starting on http://127.0.0.1:9001")
# Start the uvicorn HTTP server. This is a blocking call —
# the process stays alive serving requests until you Ctrl+C.
server.serve()| Argument | What it does |
|---|---|
agent=weather_agent | The Strands agent that handles every incoming message |
host="127.0.0.1" | Listen on this machine only ("0.0.0.0" would expose it to the network) |
port=9001 | The HTTP port |
skills=WEATHER_SKILLS | Published in the agent card for discovery |
version="1.0.0" | The agent's version, shown in the card |
enable_a2a_compliant_streaming=True | When a client asks for streaming, reply with spec-compliant SSE events including artifact updates (instead of older status-only streaming). Strands' A2AServer always advertises "streaming": true in the card; this flag only chooses the event format. |
iMethod names in the comments
The comment above mentions message/sendStream, and flights_agent_server.py has the same comment. In the A2A SDK version this lab installs (0.3), the streaming method on the wire is called message/stream; the non-streaming one is message/send. The client picks the right one for you, so nothing in the code needs changing.
server.serve() starts a web server () and blocks until you press Ctrl+C. Your agent is now a stand-alone HTTP microservice that speaks A2A. Its card, at http://127.0.0.1:9001/.well-known/agent-card.json, looks like this:
{
"name": "Weather Agent",
"description": "Provides current weather information for cities around the world.",
"version": "1.0.0",
"protocolVersion": "0.3.0",
"preferredTransport": "JSONRPC",
"capabilities": { "streaming": true },
"skills": [
{
"id": "get_weather",
"name": "Get Weather",
"description": "Returns current weather conditions...",
"tags": ["weather", "temperature", "forecast", "travel"],
"examples": ["What is the weather in London?", "..."]
}
],
"defaultInputModes": ["text"],
"defaultOutputModes": ["text"],
"url": "http://127.0.0.1:9001/"
}iagent= vs agent_factory=
In recent Strands versions, passing a single agent= is deprecated (you may see a DeprecationWarning) in favour of A2AServer(agent_factory=...), a function that builds a fresh agent per conversation, for servers that many callers use at once. For this one-user demo, agent= still works; every caller simply shares the same agent.
The flights agent server
Same pattern, with two tools and two skills, on port 9002.
# --- Fake Flight Data ----------------------------------------------------
# In a real app this would query a flights API or database.
# Here we use a dictionary keyed by (origin, destination) tuples.
FAKE_FLIGHTS = {
("london", "paris"): [
{"airline": "AirEurope", "flight": "AE101", "depart": "08:00", "arrive": "10:30", "price": "$120"},
{"airline": "SkyWings", "flight": "SW202", "depart": "14:00", "arrive": "16:30", "price": "$95"},
],
("new york", "london"): [
{"airline": "TransAtlantic", "flight": "TA501", "depart": "22:00", "arrive": "10:00+1", "price": "$450"},
{"airline": "AirEurope", "flight": "AE303", "depart": "18:00", "arrive": "06:00+1", "price": "$380"},
],
("tokyo", "sydney"): [
{"airline": "PacificAir", "flight": "PA701", "depart": "09:00", "arrive": "20:00", "price": "$520"},
],
("paris", "rome"): [
{"airline": "AirEurope", "flight": "AE150", "depart": "07:30", "arrive": "09:30", "price": "$85"},
{"airline": "MedFly", "flight": "MF44", "depart": "12:00", "arrive": "14:00", "price": "$70"},
],
("dubai", "bangkok"): [
{"airline": "GulfAir", "flight": "GA880", "depart": "02:00", "arrive": "12:00", "price": "$340"},
],
("new york", "cancun"): [
{"airline": "SunJet", "flight": "SJ610", "depart": "06:00", "arrive": "10:00", "price": "$250"},
{"airline": "SkyWings", "flight": "SW415", "depart": "11:00", "arrive": "15:00", "price": "$210"},
],
}# --- Flight Tools --------------------------------------------------------
# Each @tool-decorated function becomes a tool the LLM can call.
# The docstring is what the LLM sees when deciding which tool to use.
# Args/Returns are parsed to build the tool's input schema automatically.
@tool
def search_flights(origin: str, destination: str) -> str:
"""Search for available flights between two cities.
Args:
origin: Departure city name.
destination: Arrival city name.
Returns:
A formatted list of available flights or a message if none found.
"""
# Normalize both cities to lowercase for dictionary lookup
key = (origin.strip().lower(), destination.strip().lower())
flights = FAKE_FLIGHTS.get(key)
# If no flights found, show the user what routes are available
if not flights:
available = [f"{o.title()} -> {d.title()}" for o, d in FAKE_FLIGHTS.keys()]
return (
f"No flights found from {origin} to {destination}. "
f"Available routes: {', '.join(available)}"
)
# Format each flight as a readable line
lines = [f"Flights from {origin.title()} to {destination.title()}:"]
for f in flights:
lines.append(
f" {f['airline']} {f['flight']}: depart {f['depart']}, "
f"arrive {f['arrive']}, price {f['price']}"
)
return "\n".join(lines)
@tool
def book_flight(flight_number: str, passenger_name: str) -> str:
"""Book a specific flight for a passenger.
Args:
flight_number: The flight number to book (e.g. AE101).
passenger_name: Name of the passenger.
Returns:
Booking confirmation or error message.
"""
# Search all routes for the matching flight number
for flights in FAKE_FLIGHTS.values():
for f in flights:
if f["flight"].upper() == flight_number.strip().upper():
# Generate a fake confirmation number from a hash
return (
f"Booking confirmed! {passenger_name} is booked on "
f"{f['airline']} flight {f['flight']} "
f"(depart {f['depart']}, arrive {f['arrive']}). "
f"Total: {f['price']}. Confirmation #: TRV-{hash(flight_number + passenger_name) % 100000:05d}"
)
return f"Flight {flight_number} not found. Please search for available flights first."Routes are keyed by (origin, destination) in lower case, so "New York" and "new york" both work. An unknown route returns the list of available routes, a friendly error the LLM can relay. book_flight searches every route for the flight number and returns a confirmation with a made-up number (built from a hash, so it changes between Python runs).
See the rest of flights_agent_server.py (skills, agent and server)
# --- Agent Card Skills ---------------------------------------------------
# Skills are published in the agent card at /.well-known/agent-card.json.
# Any A2A client fetches this card first to discover what the agent can do
# BEFORE sending any messages. Think of it like an API spec for agents.
FLIGHTS_SKILLS = [
AgentSkill(
# Unique identifier for this skill
id="search_flights",
# Human-readable name shown in the agent card
name="Search Flights",
# Detailed description — helps clients understand what this skill does
description=(
"Searches for available flights between two cities. "
"Returns airline, flight number, departure/arrival times, and price. "
"Available routes: London→Paris, New York→London, Tokyo→Sydney, "
"Paris→Rome, Dubai→Bangkok, New York→Cancun."
),
# Tags for categorization and discovery
tags=["flights", "search", "travel", "airlines", "booking"],
# Example prompts — hints to clients on how to use this skill
examples=[
"Find flights from New York to London",
"Search for flights from Paris to Rome",
"What flights go from Dubai to Bangkok?",
],
),
AgentSkill(
id="book_flight",
name="Book Flight",
description=(
"Books a specific flight for a passenger given a flight number "
"and passenger name. Returns a booking confirmation with a "
"confirmation number."
),
tags=["booking", "reservation", "flights", "travel"],
examples=[
"Book flight AE303 for John Smith",
"Reserve flight TA501 for Jane Doe",
],
),
]
# --- Strands Agent -------------------------------------------------------
# This is the actual AI agent. It combines:
# - An LLM (Amazon Bedrock, chosen by BEDROCK_MODEL_ID)
# - A system prompt (instructions for the LLM)
# - Tools (functions the LLM can call)
#
# When a message comes in, the agent sends it to the LLM. The LLM reads
# the system prompt, sees the available tools, and decides whether to call
# search_flights or book_flight. The tool runs, and the LLM formats the answer.
flights_agent = Agent(
# The LLM that powers this agent
model=BedrockModel(model_id=MODEL_ID, region_name=REGION),
# Name and description — used in the A2A agent card
name="Flights Agent",
description="Searches for flights between cities and handles bookings.",
# System prompt — instructions the LLM follows for every request
system_prompt=(
"You are a helpful flight booking assistant. Use search_flights to find "
"available flights between cities, and book_flight to make reservations. "
"Be concise and provide clear flight options."
),
# Register both tools so the LLM can call them
tools=[search_flights, book_flight],
# Disable streaming callback on the server side (responses are collected, not printed)
callback_handler=None,
)
# --- A2A Server ----------------------------------------------------------
# Only runs when you execute this file directly (python flights_agent_server.py).
if __name__ == "__main__":
# A2AServer wraps the Strands Agent into an HTTP server that:
# 1. Serves an agent card at GET /.well-known/agent-card.json
# 2. Accepts A2A messages at POST / (JSON-RPC: message/send, message/sendStream)
# 3. Routes messages through the Strands Agent (LLM + tools)
# 4. Returns responses as A2A task objects with artifacts
server = A2AServer(
# The Strands Agent that handles all incoming messages
agent=flights_agent,
# Bind to localhost only (use "0.0.0.0" to expose to network)
host="127.0.0.1",
# HTTP port to listen on
port=9002,
# Skills published in the agent card for client discovery
skills=FLIGHTS_SKILLS,
# Version string in the agent card
version="1.0.0",
# Use A2A-spec-compliant SSE streaming with artifact updates
enable_a2a_compliant_streaming=True,
)
print("Flights Agent A2A server starting on http://127.0.0.1:9002")
# Start the uvicorn HTTP server — blocking call, runs until Ctrl+C
server.serve()The travel orchestrator (A2A client)
This program has no LLM. It just sends A2A messages to the two servers and prints what comes back, so you can see the protocol objects directly.
"""
Travel Orchestrator — A2A client that talks to Weather + Flights agents.
This is the A2A CLIENT side. It connects to two remote A2A agent servers,
sends them messages using the A2A protocol, and prints the results.
No LLM here — just direct A2A request/response.
Prerequisites:
1. Start weather agent: python weather_agent_server.py
2. Start flights agent: python flights_agent_server.py
3. Run this: python travel_orchestrator.py
Usage:
python travel_orchestrator.py
python travel_orchestrator.py "London" "New York" "London"
"""
import sys
import asyncio
import logging
from uuid import uuid4
# httpx: async HTTP client used to make requests to the A2A servers
import httpx
# A2ACardResolver: fetches the agent card from /.well-known/agent-card.json
# ClientConfig: configures the A2A client (streaming vs sync, timeout, etc.)
# ClientFactory: creates an A2A client from an agent card
from a2a.client import A2ACardResolver, ClientConfig, ClientFactory
# A2A message types used to construct the request
# Message: the envelope for a user or agent message
# Part/TextPart: wraps the actual text content
# Role: identifies the sender (user or agent)
from a2a.types import Message, Part, Role, TextPart
# Set up logging so we can see A2A protocol activity
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
# URLs of the two A2A agent servers
WEATHER_URL = "http://127.0.0.1:9001"
FLIGHTS_URL = "http://127.0.0.1:9002"
# Timeout for HTTP requests (5 minutes — agents may take time with LLM calls)
TIMEOUT = 300# --- Helper: Build an A2A Message ----------------------------------------
# Creates a standard A2A Message object that the server expects.
# This is the equivalent of a user typing a question in a chat.
def make_message(text: str) -> Message:
return Message(
kind="message", # Always "message" for chat messages
role=Role.user, # We're the user sending a question
parts=[Part(TextPart( # Wrap the text in a TextPart inside a Part
kind="text",
text=text,
))],
message_id=uuid4().hex, # Unique ID for this message
)
# --- Helper: Extract Text from A2A Response ------------------------------
# A2A servers return a "task" object. The actual answer text lives inside
# task.artifacts[].parts[].text. This function digs it out.
def extract_text(result) -> str:
"""Pull text out of an A2A task response (from artifacts)."""
if hasattr(result, "artifacts") and result.artifacts:
texts = []
for artifact in result.artifacts:
for part in artifact.parts:
# The a2a SDK wraps parts in a discriminated union (Part.root)
if hasattr(part, "root") and hasattr(part.root, "text"):
texts.append(part.root.text)
elif hasattr(part, "text"):
texts.append(part.text)
if texts:
return "".join(texts)
# Fallback: return the raw string representation
return str(result)- 1
make_message: build an A2A messageA
Messagewithrole=Role.user, one text part (Part(TextPart(...))), and a uniquemessage_id. It's the A2A equivalent of a user typing in a chat box. - 3
extract_text: dig the answer outA2A servers return a task; the answer lives in
task.artifacts[].parts[]. The SDK wraps each part in a container that can hold any kind of part (text, file or data) and keeps the real part inpart.root, so the code checks both shapes and falls back to the raw text if needed.
# --- Core: Send a Message to an A2A Agent --------------------------------
# This is the main A2A client logic. It:
# 1. Fetches the agent card to discover capabilities
# 2. Auto-selects streaming or sync mode based on the card
# 3. Sends the message using the A2A protocol
# 4. Extracts and returns the text response
async def send_a2a_message(base_url: str, text: str) -> str:
"""Send a message to an A2A agent, auto-selecting streaming or sync based on the agent card."""
async with httpx.AsyncClient(timeout=TIMEOUT) as hc:
# Step 1: Fetch the agent card from /.well-known/agent-card.json
# This tells us the agent's name, skills, and whether it supports streaming
resolver = A2ACardResolver(httpx_client=hc, base_url=base_url)
card = await resolver.get_agent_card()
# Step 2: Check the agent card capabilities to decide the mode
# If the server advertises {"capabilities": {"streaming": true}},
# we use SSE streaming; otherwise we use sync (single response)
use_streaming = bool(card.capabilities and card.capabilities.streaming)
mode = "streaming" if use_streaming else "sync"
logger.info(f"Connected to: {card.name} — mode: {mode}")
# Step 3: Create an A2A client configured for this agent
# ClientConfig sets streaming mode; ClientFactory builds the client from the card
config = ClientConfig(httpx_client=hc, streaming=use_streaming)
client = ClientFactory(config).create(card)
# Step 4: Send the message and process response events
# In sync mode: yields one task with artifacts (the final answer)
# In streaming mode: yields intermediate Messages + final task with artifacts
msg = make_message(text)
final_text = ""
async for event in client.send_message(msg):
# Task with artifacts — this is the final complete response
if hasattr(event, "artifacts") and event.artifacts:
final_text = extract_text(event)
# (Task, UpdateEvent) tuple — check for artifacts on the task
elif isinstance(event, tuple) and len(event) == 2:
task, _ = event
if hasattr(task, "artifacts") and task.artifacts:
final_text = extract_text(task)
# Intermediate streaming Message — we skip these and wait for
# the final artifacts which contain the complete answer
elif isinstance(event, Message):
pass
return final_text if final_text else "No response received."- a
Fetch the agent card
A2ACardResolver(...).get_agent_card()downloads/.well-known/agent-card.json: name, skills, capabilities. - b
Pick the mode from the card
If the card says
streaming: true, use streaming (SSE); otherwise a single synchronous response. That's capability negotiation in two lines. - c
Create a client for that agent
ClientFactory(ClientConfig(...)).create(card)builds a client configured from the card: URL, binding, streaming. - d
Send and read the events
client.send_message(msg)sends the JSON-RPC request and yields events. Both cards in this lab saystreaming: true, so it is alwaysmessage/streamhere; against an agent without streaming, the same code would sendmessage/send. A task with artifacts, or a(task, update)pair, carries the answer; intermediate streaming messages are skipped until the final artifacts arrive.
# --- Main: Orchestrate Calls to Both Agents ------------------------------
# Sends one request to the Weather Agent and one to the Flights Agent,
# then prints a combined trip summary.
async def main(city: str, origin: str, destination: str):
print("=" * 60)
print(" Travel Orchestrator (A2A)")
print("=" * 60)
# 1. Ask Weather Agent for current conditions at the destination
print(f"\n>>> Asking Weather Agent: weather in {city}")
weather = await send_a2a_message(WEATHER_URL, f"What is the weather in {city}?")
print(f"<<< Weather Agent response:\n{weather}")
# 2. Ask Flights Agent for available flights on the route
print(f"\n>>> Asking Flights Agent: flights from {origin} to {destination}")
flights = await send_a2a_message(FLIGHTS_URL, f"Search flights from {origin} to {destination}")
print(f"<<< Flights Agent response:\n{flights}")
# 3. Print a combined trip summary
print("\n" + "=" * 60)
print(" Trip Summary")
print("=" * 60)
print(f"\nDestination: {city}")
print(f"Weather: {weather.strip()}")
print(f"\nFlights ({origin} → {destination}):")
print(flights.strip())
# --- Entry Point ---------------------------------------------------------
# Supports two modes:
# - Default: demo with London / New York → London
# - Custom: pass city, origin, destination as arguments
if __name__ == "__main__":
if len(sys.argv) == 4:
# python travel_orchestrator.py <city> <origin> <destination>
asyncio.run(main(sys.argv[1], sys.argv[2], sys.argv[3]))
else:
# Default demo
asyncio.run(main("London", "New York", "London"))What happens on the wire
- 1
The client fetches the agent card to discover what the agent can do
- 2
It sends a JSON-RPC message request (
message/streamhere, because the card says streaming) with the user's question - 3
The server's Strands agent processes it, calls its tools, and streams back events that end in a task with artifacts holding the final text
Run it in three terminals
!Every terminal needs the same setup
Each new terminal starts fresh. In all three, cd into section-05-mcp-and-a2a/a2a-travel-agents, activate the course's virtual environment, and make sure your AWS credentials and AWS_REGION are set there too.
pip install -r requirements.txt
# Terminal 1
python weather_agent_server.py
# Terminal 2
python flights_agent_server.py
# Terminal 3 — default demo (New York → London)
python travel_orchestrator.py
# Or specify city, origin, destination
python travel_orchestrator.py "Rome" "Paris" "Rome"You need AWS credentials with Bedrock access; each agent uses BEDROCK_MODEL_ID (default Claude Sonnet 4.6). Start terminals 1 and 2 first and wait until each shows Uvicorn running on http://127.0.0.1:9001 (or :9002). The results print in terminal 3; the grey INFO lines are the protocol at work: fetch the card, decide the mode, POST the message.
============================================================ Travel Orchestrator (A2A) ============================================================ >>> Asking Weather Agent: weather in London INFO:httpx:HTTP Request: GET http://127.0.0.1:9001/.well-known/agent-card.json "HTTP/1.1 200 OK" INFO:a2a.client.card_resolver:Successfully fetched agent card data from http://127.0.0.1:9001/.well-known/agent-card.json: {…} INFO:__main__:Connected to: Weather Agent — mode: streaming INFO:httpx:HTTP Request: POST http://127.0.0.1:9001/ "HTTP/1.1 200 OK" <<< Weather Agent response: The weather in London is currently 15°C, cloudy with occasional rain. You may want an umbrella! >>> Asking Flights Agent: flights from New York to London INFO:httpx:HTTP Request: GET http://127.0.0.1:9002/.well-known/agent-card.json "HTTP/1.1 200 OK" INFO:a2a.client.card_resolver:Successfully fetched agent card data from … INFO:__main__:Connected to: Flights Agent — mode: streaming INFO:httpx:HTTP Request: POST http://127.0.0.1:9002/ "HTTP/1.1 200 OK" <<< Flights Agent response: Here are the available flights from New York to London: 1. TransAtlantic TA501: departs 22:00, arrives 10:00 (+1 day), $450 2. AirEurope AE303: departs 18:00, arrives 06:00 (+1 day), $380 ============================================================ Trip Summary ============================================================ Destination: London Weather: The weather in London is currently 15°C, cloudy with occasional rain. … Flights (New York → London): Here are the available flights from New York to London: …
| Available data | |
|---|---|
| Weather cities | London, Paris, Tokyo, New York, Dubai, Sydney, Cancun, Rome, Bangkok, Reykjavik |
| Flight routes | London→Paris, New York→London, Tokyo→Sydney, Paris→Rome, Dubai→Bangkok, New York→Cancun |
Run the orchestrator
python travel_orchestrator.py "London" "New York" "London"
GET /.well-known/agent-card.json{ "name": "Weather Agent", "capabilities": { "streaming": true },
"skills": [ { "id": "get_weather", … } ] }streaming: true, so the orchestrator logs mode: streaming and will use message/stream.POST / · JSON-RPC message/stream{ "role": "user", "parts": [ { "kind": "text",
"text": "What is the weather in London?" } ] }The weather in London is currently 15°C, cloudy with occasional rain.
get_weather("London") and got Weather in London: 15°C, cloudy with occasional rain. The orchestrator never sees that call. (The real wording varies; the facts come from the tool.)GET /.well-known/agent-card.json{ "name": "Flights Agent", "capabilities": { "streaming": true },
"skills": [ { "id": "search_flights", … }, { "id": "book_flight", … } ] }POST / · JSON-RPC message/stream{ "role": "user", "parts": [ { "kind": "text",
"text": "Search flights from New York to London" } ] }Here are the available flights from New York to London: 1. TransAtlantic TA501: departs 22:00, arrives 10:00+1, $450 2. AirEurope AE303: departs 18:00, arrives 06:00+1, $380
search_flights("New York", "London").Destination: London Weather: The weather in London is currently 15°C, cloudy with occasional rain. Flights (New York → London): Here are the available flights from New York to London: 1. TransAtlantic TA501: departs 22:00, arrives 10:00+1, $450 2. AirEurope AE303: departs 18:00, arrives 06:00+1, $380
Both protocols together: A2A outside, MCP inside
In this lab the weather agent keeps its data in a Python dictionary. In a real system it would more likely get weather from an MCP server, which is exactly what you built in Lab 5.6. The two protocols then sit at different layers: A2A between agents, MCP between each agent and its tools. The orchestrator doesn't notice the change at all, because the weather agent is opaque.
To try it, change only the agent in weather_agent_server.py, reusing the MCPClient pattern from Lab 5.7. Recent Strands versions accept an MCPClient directly in tools=[...] and open and close the connection for you:
import sys
from pathlib import Path
from mcp import StdioServerParameters, stdio_client
from strands.tools.mcp import MCPClient
# The MCP server from Lab 5.6, started as a child process over stdio
SERVER = Path(__file__).resolve().parent.parent / "mcp-basics" / "server.py"
mcp_client = MCPClient(
lambda: stdio_client(StdioServerParameters(command=sys.executable, args=[str(SERVER)]))
)
weather_agent = Agent(
model=BedrockModel(model_id=MODEL_ID, region_name=REGION),
name="Weather Agent",
description="Provides current weather information for cities around the world.",
system_prompt="You are a helpful weather assistant. Be concise and friendly.",
tools=[mcp_client], # tools now arrive over MCP instead of @tool
callback_handler=None,
)!The data changes too
The MCP server only knows London, Paris, Tokyo, New York and Bangalore, with shorter descriptions ("15°C, cloudy"). Ask the orchestrator for Bangalore and it now works; ask for Rome and the weather agent will say it has no data. This is an experiment for you to try, not part of the course lab.
</>Extend it yourself
Give the orchestrator an LLM: create a Strands agent whose tools wrap the two remote agents, for example one small @tool per agent that calls send_a2a_message with that agent's URL. Then it can decide on its own when to check the weather, when to search flights, and even when to call book_flight.
Recap and self-check
Your agents can now plug into any tool that speaks MCP, and talk to any agent that speaks A2A.
Standard protocols make agentic systems easier to build, extend and scale. You started with MCP, which solves the explosion of custom integrations between many AI apps and many tools: instead of N × M connectors, everything speaks one shared interface through hosts, clients and servers. You saw how messages travel (JSON-RPC over stdio or Streamable HTTP), how a connection is set up and closed (the lifecycle), how remote servers are protected (authorization), and what servers offer: tools to act, resources to read and prompt templates to ask consistently. Clients can offer things back: sampling, roots and elicitation.
Then you met A2A, which solves a different problem: letting agents, open-ended problem solvers rather than simple tools, collaborate without exposing their insides. Agents discover each other through agent cards, exchange messages, track work as tasks and return artifacts, in four interaction styles: synchronous, asynchronous polling, streaming and push notifications.
In the labs you built an MCP weather server with FastMCP, called it from a raw client and from Strands agents, and then ran a travel orchestrator that talks to weather and flights agents over A2A. The two protocols complement each other: A2A for collaboration between agents, MCP for each agent's own work with tools and data.
A2AServer plus the A2A SDK on the other.MCP or A2A?
For each situation, pick the protocol that fits best.
1. Give a coding assistant access to your GitHub issues and pull requests.
2. A hospital's scheduling agent asks an insurance company's agent to verify a patient's coverage.
3. Let a chat app read your team's database schema as context.
4. Your travel orchestrator delegates weather questions to another team's weather agent, without knowing how it works inside.
5. Share a “summarise this meeting” instruction template that every AI app in your company can reuse.
6. A booking agent that takes hours to confirm must tell the requesting agent when it's done.
0 of 6 answered
You should now be able to explain
| Idea | In one line |
|---|---|
| Why MCP | N apps × M tools custom integrations become N + M implementations of one standard |
| Host, client, server | The app the user sees; one connector per server inside it; the program that offers capabilities |
| Transports | stdio for local child processes, Streamable HTTP for remote servers, custom (e.g. gRPC) when needed |
| Lifecycle | initialize → result → notifications/initialized → normal work → close the connection |
| Tools, resources, prompts | Do things; read things without side effects; reuse good instructions |
| Sampling, roots, elicitation | Server borrows the client's model; client limits where the server may work; server asks the user for missing details |
| FastMCP | Decorators (@mcp.tool(), @mcp.resource(), @mcp.prompt()) turn Python functions into MCP features |
| Strands + MCP | MCPClient starts or connects to a server; list_tools_sync() hands its tools to an Agent |
| Why A2A | Agents are opaque, open-ended problem solvers; they need a standard way to discover and delegate to each other |
| Agent card | A JSON document at /.well-known/agent-card.json: name, skills, capabilities, endpoint |
| Message, task, artifact | The request; the unit of work with an ID and status; the output |
| Four styles | Synchronous, polling with GetTask, streaming over SSE, push to a webhook |
A2AServer | Wraps a Strands agent as an A2A HTTP server that publishes its own card |
When something goes wrong
| What you see | What it usually means and how to fix it |
|---|---|
Import errors or unexpected API errors from mcp | A different major version of the MCP SDK is installed. Run pip install -r requirements.txt in the lab folder; it pins mcp below 2.0. |
`uvx` not found. Install uv first | The AWS documentation lab launches its server with uvx. Install the folder's requirements (they include uv) or run pip install uv. |
| The AWS docs agent is slow or times out the first time | uvx downloads the server package on first run. The script already allows 120 seconds; run it again once the download has finished. |
AccessDeniedException or credential errors from Bedrock | Your AWS credentials can't call the model. Check AWS_REGION, your credentials, and that BEDROCK_MODEL_ID is available to your account. |
address already in use when starting an A2A server | An earlier copy is still running on port 9001 or 9002. Stop it with Ctrl+C in its terminal (or close that terminal), then start again. |
| The orchestrator fails with a connection error | It can't reach an agent. Start both servers first (terminals 1 and 2) and wait until each shows Uvicorn running on … before running the orchestrator. |
No response received. | The server answered without any artifacts, usually because its agent hit an error. Look at that server's terminal for the real message (often a Bedrock error). |
| "No flights found" or "No weather data available" | The city or route isn't in the lab's fake data. Use one from the Available data table; the agent lists what it has. |
iNothing to clean up
Everything in this section runs on your own machine and only calls Bedrock (plus the public AWS documentation site in one lab). No AWS resources are created, so there's nothing to delete. Stop the A2A servers with Ctrl+C when you're done.
Self-check
Ten quick questions. Pick an answer to see why it's right or wrong.
1. What problem does MCP mainly solve?
2. How many MCP servers does a single MCP client talk to?
3. Your client starts the server as a child process on the same machine. Which transport is that?
4. Which message completes the MCP initialization handshake?
5. A server wants to share a read-only student directory that the model can look things up in, with no side effects. Which feature fits?
6. A server has the data but no AI model. It wants an AI to rank activities, with the user approving. Which client feature allows that?
7. Where does an A2A client look first to learn what a remote agent can do?
8. A remote agent returns a task with status input-required. What does that mean?
9. In the lab, how does travel_orchestrator.py decide whether to use streaming?
10. Which sentence best describes how MCP and A2A relate?
Score: 0 of 10 answered
→Up next: Section 6, shipping agents to production
Everything so far has run on your laptop. Next you'll deploy agents on AWS: Amazon Bedrock AgentCore Runtime, Amazon ECS on Fargate and AWS Lambda, plus AgentCore Gateway, which turns an existing REST API into MCP tools. The final lab puts this section's ideas in the cloud: an orchestrator calls a stock agent over A2A, and that agent gets its tools over MCP. Folder: section-06-production-deployment.
Glossary
Every underlined word on this page, in one place.
An open standard that lets independent AI agents, built by different teams on different frameworks, discover each other and work together over HTTP.
A JSON document an A2A agent publishes, usually at /.well-known/agent-card.json, describing its name, skills, capabilities, endpoint and how to authenticate. Clients read it before sending anything.
One entry in an agent card describing something the agent can do: an ID, a name, a description, tags and example requests. In Python it's an AgentSkill object.
An output a remote agent produces for a task, such as a report, a booking confirmation or a file, made up of parts.
Checking that a caller is allowed to use a server. Remote MCP servers over HTTP can use OAuth 2.1 access tokens; local stdio servers take credentials from their environment instead.
An MCP client feature that lets a server ask the user for missing information in the middle of a task, either through a structured form or by sending them to a URL.
Electronic medical record / electronic health record: the hospital systems that store patients' histories, diagnoses and treatments.
Enterprise resource planning: a company's central software for inventory, orders, shipping, finance and other operations.
A popular Python framework for building web APIs for any client. Its design inspired FastMCP, but it speaks plain HTTP, not MCP.
The high-level part of the Python MCP SDK. Decorators like @mcp.tool(), @mcp.resource() and @mcp.prompt() turn ordinary Python functions into MCP features.
A fast way for programs to call each other over the network, using compact binary messages instead of JSON text. Popular for high-traffic internal services.
A simple standard for remote calls written in JSON. A request names a method and its parameters and carries an ID; the response carries the same ID with a result or an error. Notifications have no ID and expect no reply.
The fixed order of an MCP connection: initialize (agree on versions and capabilities), normal operation, then shutdown.
An open standard for connecting AI applications to tools, data and prompt templates. Build a connector once as an MCP server and any MCP-compatible app can use it.
The connector inside a host that talks to exactly one MCP server: it opens the connection, discovers what the server offers and passes requests and results back and forth.
The AI application the user actually works in, such as a chat app, an IDE or your own agent. It contains one MCP client for each server it connects to.
A program that offers tools, resources and prompt templates to MCP clients. It can run on your machine (started as a child process) or remotely over HTTP.
One turn of communication between agents, with a role (user or agent) and one or more parts: text, files or structured data.
A standard label for what kind of content something is, such as text/plain, application/json or application/pdf, so the receiver knows how to handle it.
The standard way for an app to get a limited, expiring access token to act on a user's behalf, instead of handling their password. MCP uses it to protect remote servers.
An agent that collaborates without revealing its internals: other agents see only its card, its messages and its results, never its memory, tools or prompts.
A standard JSON or YAML document that describes a web API: its endpoints, inputs and outputs. Tools can read it to learn how to call the API.
A reusable, predefined prompt that an MCP server offers, with arguments to fill in, such as a weather report for a given city. Users usually pick them explicitly.
The concrete format A2A messages travel in: JSON-RPC, gRPC or plain HTTP+JSON. The meaning is the same; only the wrapping differs.
Read-only data an MCP server exposes, such as a file, a database record or an API response. Reading a resource gives context without changing anything.
A pattern for a family of resources with a placeholder, such as greeting://{name}. The client fills in the placeholder to read a specific one.
Folders or URIs the client tells a server it may work within. They scope what the server should touch, and the client can update them when the user switches projects.
An MCP client feature that lets a server ask the client's model to generate text for it, with the user able to review and approve. The server needs no model of its own.
A way for a server to keep an HTTP response open and push a stream of small events to the client as they happen, instead of one reply at the end.
Keeps no memory between calls: the same input always gets handled the same way, with nothing remembered from earlier requests.
The text pipes every program has for input and output. With the stdio transport, the client starts the server as a child process and they write JSON-RPC messages to each other through these pipes.
The unit of work a remote agent creates to track a request, with an ID and a status such as submitted, working, input-required or completed.
A function an MCP server exposes for the model to call, such as searching, sending a message or updating a record. Clients find them with tools/list and run them with tools/call.
How MCP messages physically travel between client and server: through standard input/output pipes (stdio), over HTTP, or a custom channel. The messages themselves are the same.
A unique address for a piece of data, written like file:///docs/notes.md or greeting://Asha. MCP uses URIs to name resources.
A fast Python web server that runs async web apps. A2AServer.serve() uses it to listen for HTTP requests.
A command that comes with the uv package manager. It downloads a Python tool into a temporary environment and runs it in one step, which is handy for starting MCP servers.
A URL you give to another system so it can call you when something happens. It's how A2A push notifications reach the client.