Lesson 4: MCP
What MCP is and why it exists
Every tool you write with the registry above is bound to your application. Someone else's file search tool, database connector, or ticketing integration cannot be dropped into your agent, and yours cannot be used in theirs.
The Model Context Protocol standardises the interface between an application using a model and a process providing capabilities to it. Write a tool once as an MCP server and any MCP-speaking client can use it.
[VOLATILE: MCP is evolving rapidly. The specification revision dated 2026-07-28 made significant changes, including removing the protocol-level session and the initialize handshake, deprecating the HTTP with SSE transport, and deprecating roots, sampling, and logging. The Python SDK v2 is the current stable line and pip install mcp now installs 2.x, which is a breaking change from 1.x. Every code example and API detail in this lesson must be verified against the current SDK and specification before publishing.]
Servers, tools, resources, and transports
A server exposes capabilities. It is an ordinary process, run locally as a subprocess or hosted over HTTP.
A client connects to servers and makes their capabilities available to the model. Your application is the client.
Tools are functions the model can call, exactly like the registry in Lesson 3.
Resources are readable data identified by URI, such as a file or a database record. The distinction from tools is intent: a tool performs an action, a resource provides content. In practice, use a resource when the model needs to read something and a tool when it needs to do something.
Prompts are reusable templates a server can offer, which connects to the prompt module from Module 3.
Transports are how client and server communicate. Two matter now.
stdio runs the server as a subprocess and communicates over standard input and output. It is the right choice for local tools: no network, no ports, and the server's lifetime is tied to the client's.
Streamable HTTP is the transport for remote servers. It is what you deploy. The older HTTP with SSE transport is deprecated and should not be used for anything new.
The significant recent change is that the protocol has moved to a stateless model, removing the session that previously tied a client to a particular server instance. The practical consequence is operational: a modern MCP server can sit behind an ordinary round-robin load balancer without sticky sessions. When a server genuinely needs to carry state across calls, the current guidance is to have a tool return an explicit handle and have the model pass it back as an argument, which keeps the state visible in the conversation rather than hidden in the transport.
[IMAGE PROMPT M6-3
Purpose: Show how an application acts as an MCP client connecting to several servers over different transports, and what each server exposes.
Visual type: Architecture diagram.
Prompt: A clean educational architecture diagram. On the left, a large box labelled "your application" containing two inner boxes stacked vertically: an upper one labelled "model" and a lower one labelled "MCP client". From the MCP client box, three labelled arrows extend to the right to three separate server boxes. The first arrow is labelled "stdio (subprocess)" and reaches a box labelled "local file server" listing two items inside: "tools: read_file, list_dir" and "resources: file:// URIs". The second arrow is labelled "stdio (subprocess)" and reaches a box labelled "recipe index server" listing "tools: search_recipes, get_recipe". The third arrow is labelled "streamable HTTP" and reaches a box labelled "remote analytics server" listing "tools: run_query", drawn slightly separated with a small cloud or boundary marker between it and the others labelled "network boundary". A note beneath the third server reads "stateless: any replica can answer".
Required elements: The application containing model and client, three servers with their exposed tools and resources listed, three labelled transport arrows with the correct transport per server, a network boundary marker before the remote server, the stateless note.
Style: Clean educational illustration, professional, uncluttered, high contrast, flat vector, generous whitespace.
Layout: Horizontal left to right, application on the left, three servers stacked vertically on the right.
Text labels: "your application", "model", "MCP client", "stdio (subprocess)", "streamable HTTP", "local file server", "tools: read_file, list_dir", "resources: file:// URIs", "recipe index server", "tools: search_recipes, get_recipe", "remote analytics server", "tools: run_query", "network boundary", "stateless: any replica can answer".
Aspect ratio: 16:9
Accessibility: Distinguish transports by their text labels rather than line style or colour alone.
Avoid: Vendor logos, decorative icons, screenshots, tiny text, watermarks.
Alt text: Architecture diagram showing an application containing a model and an MCP client connecting over stdio to two local servers exposing tools and resources, and over streamable HTTP to a remote stateless analytics server.
END IMAGE PROMPT]
Consuming an MCP server from Python
import asyncio
from mcp import Client
async def main() -> None:
async with Client("http://localhost:8000/mcp") as client:
tools = await client.list_tools()
result = await client.call_tool("search_recipes", {"query": "carbonara"})
print(result.structured_content)
asyncio.run(main())
[VOLATILE: this reflects the Python SDK v2 client API. Confirm against the installed version, and note that v1 and v2 differ substantially.]
A URL means streamable HTTP. The same client can launch a local server as a stdio subprocess instead, which is the usual choice during development.
Integrating with your own registry. The important design point is that MCP tools should flow into the same dispatch and validation path as your local ones, not around it.
async def register_mcp_tools(registry: ToolRegistry, client: Client) -> None:
"""Adapt remote MCP tools into the local registry, keeping local policy."""
for tool in await client.list_tools():
registry.register(
RegisteredTool(
name=f"mcp__{tool.name}",
args_model=model_from_json_schema(tool.name, tool.input_schema),
handler=make_mcp_handler(client, tool.name),
timeout_seconds=15.0,
requires_approval=is_write_tool(tool.name),
)
)
Three things this preserves. Names are prefixed, so a remote tool cannot shadow or impersonate a local one. Your timeout and approval policy still applies, since the server's opinion about whether its tool needs approval is not authoritative for your application. And every call still passes through your validation and your loop guard.
That last point is worth stating plainly: an MCP server is a third party in your agent's execution path. It sees the arguments you send, and its results enter your model's context. Lesson 5 covers why that matters.
Exposing your own tools as an MCP server
Turning the recipe index into a server makes it usable by any MCP client, including editors and desktop applications.
from mcp.server import MCPServer
server = MCPServer("recipe-index")
@server.tool()
def search_recipes(query: str, max_results: int = 5) -> list[dict]:
"""Search the recipe corpus by ingredient or dish name."""
return index.search(query, limit=max_results)
@server.tool()
def get_recipe(recipe_id: str) -> dict:
"""Fetch one recipe by its identifier."""
return index.get(recipe_id)
if __name__ == "__main__":
server.run(transport="stdio")
[VOLATILE: the server API changed significantly in SDK v2, including where transport and serving options are configured. Verify the decorator names, the server class name, and the run signature against the installed version.]
The schema is derived from the type hints, and the description comes from the docstring, which is the same single-definition principle from Lesson 3.
Three things to get right when writing a server.
Docstrings are prompts. They are what a model reads to decide whether to call your tool, so write them for that audience rather than for a developer browsing the code.
Validate inputs inside the tool. Your server may be called by clients you did not write, so it is a trust boundary in its own right.
Return errors as content rather than raising where the client can act on them, for the same reason Lesson 3 gave.
Decide what not to expose. A server is an interface for arbitrary clients, and any tool you expose can be called by any model connected to any client. Exposing a general purpose "run this SQL" tool means exposing your database to whatever is on the other end. Expose specific, bounded capabilities.