05 · Model Context Protocol (MCP)¶
Every tool you've defined so far has been hand-written and wired directly
into your own TOOLS list and execute_tool dispatcher. That doesn't
scale once you want to reuse the same tool across multiple apps, or plug in
tools someone else maintains (a database connector, a Slack integration).
MCP (Model Context Protocol) standardizes how an LLM application
discovers and calls tools exposed by an independent server — write a tool
once as an MCP server, use it from any MCP-compatible client.
The shape of the protocol¶
An MCP server exposes three kinds of things over a client/server connection (typically stdio for local processes, or HTTP for remote ones):
- Tools — callable functions with a name, description, and JSON Schema, conceptually identical to Level 1's tool definitions.
- Resources — readable data (files, DB rows, API responses) addressed by URI, for the client to pull into context.
- Prompts — reusable, parameterized prompt templates the server provides.
The client (your application) connects, asks the server "what tools do you
have," and gets back JSON Schema definitions it can pass straight through
to the model's tools parameter — the server is the single source of
truth for what a tool does and how to call it.
Writing a minimal MCP server¶
Using the Python SDK, a server exposing one tool looks like this:
# weather_mcp_server.py
from mcp.server.fastmcp import FastMCP
mcp = FastMCP("weather")
@mcp.tool()
def get_weather(city: str, unit: str = "celsius") -> dict:
"""Get current weather for a city."""
# Real implementation would call a weather API.
fake_temp_c = sum(map(ord, city.lower())) % 35
temp = fake_temp_c if unit == "celsius" else round(fake_temp_c * 9 / 5 + 32)
return {"city": city, "temp": temp, "unit": unit}
if __name__ == "__main__":
mcp.run(transport="stdio")
The decorator inspects the function's type hints and docstring to build the JSON Schema automatically — you write a normal Python function, and the protocol-level wiring (schema generation, request parsing, response serialization) is handled by the SDK.
Connecting to an MCP server from your app¶
Your application acts as an MCP client, launching the server as a
subprocess and forwarding its tools into a normal messages.create call:
from dotenv import load_dotenv
from mcp import ClientSession, StdioServerParameters
from mcp.client.stdio import stdio_client
import anthropic, asyncio
load_dotenv()
client = anthropic.Anthropic()
MODEL = "claude-sonnet-5"
async def run():
server_params = StdioServerParameters(command="python", args=["weather_mcp_server.py"])
async with stdio_client(server_params) as (read, write):
async with ClientSession(read, write) as session:
await session.initialize()
tools_response = await session.list_tools()
tools = [{
"name": t.name,
"description": t.description,
"input_schema": t.inputSchema,
} for t in tools_response.tools]
messages = [{"role": "user", "content": "What's the weather in Lisbon?"}]
resp = client.messages.create(model=MODEL, max_tokens=300, tools=tools, messages=messages)
for block in resp.content:
if block.type == "tool_use":
result = await session.call_tool(block.name, block.input)
print(f"{block.name}({block.input}) -> {result.content}")
asyncio.run(run())
This is the same request→execute→respond loop from Level 1 — the only
difference is that execute_tool is replaced by session.call_tool,
which forwards the call over the protocol to the server process instead of
running a local Python function directly.
Writing your own MCP tools for reuse¶
The payoff for the extra layer is reuse: a github_mcp_server.py you write
once can back a CLI assistant, a Slack bot, and a web app — each app is a
thin MCP client that connects to the same server and never re-implements
the GitHub API calls, auth, or pagination logic. When designing a server
for reuse:
- Keep tool descriptions self-contained. The consuming application may not know the domain — write descriptions as if the model has never seen your system before, because from a fresh client's perspective, it hasn't.
- Return structured, not prose, results. Let the calling model decide
how to phrase things to its own user; a server returning
{"temp": 18, "unit": "celsius"}is more reusable across apps than one returning "It's a pleasant 18°C in Lisbon today!" - Version your server the same way you'd version an API — tool schema changes are breaking changes for every client depending on them.
How It Actually Works¶
MCP does not change how the model calls tools — the model still only ever
emits a tool_use block naming a tool and filling arguments against a JSON
Schema, exactly as in Level 1. What MCP standardizes is everything
around that: a wire format (JSON-RPC messages over stdio or HTTP) for a
client to ask a server "what tools exist" and "run this one," so that tool
discovery and invocation are decoupled from any single application's
codebase.
This decoupling is why an MCP tool is reusable in a way a hand-written
execute_tool branch isn't: the server owns both the schema definition and
the implementation, and any client that speaks the protocol can fetch the
current schema at connection time via list_tools() rather than a
developer hand-copying it into a TOOLS list that can drift out of sync
with the actual function signature. The @mcp.tool() decorator's job is
purely mechanical — introspecting Python type hints to generate the JSON
Schema your app would otherwise have written by hand, and marshalling
requests and responses across the process boundary.
Cheat sheet¶
| Concept | Key fact |
|---|---|
| MCP | Protocol standardizing tool discovery + invocation across apps |
| Tools / Resources / Prompts | The three things an MCP server can expose |
list_tools() |
Client fetches current schemas at connection time |
call_tool() |
Client-side equivalent of Level 1's execute_tool |
| Transport | stdio for local subprocess servers, HTTP for remote ones |
| Reuse payoff | One server backs many client applications |
Exercise¶
Turn Level 1's calculator and weather tools into a single MCP server using
FastMCP, then write a client script that connects to it, lists its
tools, and runs a conversation requiring both. Confirm the schemas the
client receives from list_tools() match what you'd have hand-written in
Level 1 — then add a third tool to the server without touching the client
code and confirm the client picks it up automatically on the next
list_tools() call.