MCP Python SDK Tutorial
The MCP Python SDK is the official library for building Model Context Protocol servers and clients in Python, and it is the fastest way to give an AI agent real, callable tools instead of a wall of prompt instructions. If you have ever wired a large language model to a database, a file system, or an internal API by hand-rolling JSON schemas and function-calling glue code, MCP replaces all of that with a small, typed, protocol-first layer that any MCP-aware client (Claude Desktop, Claude Code, or your own agent loop) can talk to without custom integration work. This tutorial builds a working MCP server from scratch, adds tools and resources, tests it with the inspector, writes a client that calls it programmatically, and connects it to Claude Desktop.
What the MCP Python SDK actually gives you
Model Context Protocol is a standard for exposing capabilities to language models: tools (functions the model can call), resources (data the model can read), and prompts (reusable prompt templates). The MCP Python SDK implements both sides of that protocol: a server framework for exposing your own tools, and a client library for talking to any MCP server from Python.
The SDK ships a high-level API called FastMCP that removes almost all of the protocol boilerplate. You write plain Python functions, decorate them, and the SDK handles JSON-RPC message framing, schema generation from type hints, and transport negotiation. There is also a low-level API if you need fine-grained control over request handling, but for the vast majority of use cases FastMCP is what you want.
Three ideas matter before you write code:
- Tools are functions the model can invoke, with arguments and a return value. Think "search the database," "send an email," "run this calculation."
- Resources are read-only data the model can pull into context, addressed by a URI. Think "read this config file" or "fetch this document."
- Prompts are parameterized templates the client can surface to the user, like a slash command that expands into a full instruction.
Installing the MCP Python SDK
You need Python 3.10 or newer. The SDK is distributed on PyPI as mcp, and the recommended install path uses uv because the SDK's own tooling (like the dev inspector) assumes it, but pip works fine too.
uv add "mcp[cli]"or with pip:
pip install "mcp[cli]"The [cli] extra pulls in the developer tooling, including the mcp dev command you will use to test servers interactively. Verify the install:
python -c "import mcp; print(mcp.__version__)"If that prints a version number without an ImportError, you are set up correctly.
Building your first MCP server
Create a file named server.py. This server exposes two tools: one that does arithmetic and one that fetches a fake weather reading, plus one resource that serves a static note.
from mcp.server.fastmcp import FastMCP
mcp = FastMCP("demo-server")
@mcp.tool()
def add(a: int, b: int) -> int:
"""Add two integers and return the sum."""
return a + b
@mcp.tool()
def get_weather(city: str) -> str:
"""Return a mock weather reading for the given city."""
readings = {
"delhi": "38C, hazy",
"bangalore": "24C, light rain",
"mumbai": "31C, humid",
}
return readings.get(city.lower(), "no data for that city")
@mcp.resource("note://today")
def todays_note() -> str:
"""A static note resource the client can read."""
return "Ship the MCP server, then write the docs."
if __name__ == "__main__":
mcp.run()A few things to notice. The docstring on each tool becomes the description the model sees, so write it like you are explaining the function to someone who cannot read the code. The type hints (a: int, b: int) are not decoration, the SDK reads them at import time to generate the JSON schema the client uses for validation. If you skip type hints, arguments fall back to loosely typed and you lose the guardrails that stop a model from passing a string where you expect a number.
Run the server directly to sanity-check it starts:
python server.pyIt will sit there waiting on stdio, which is expected. Stdio transport does not print a banner, it just waits for a client to speak the protocol at it. Kill it with Ctrl+C and move to testing it properly.
Testing with the MCP inspector
The SDK's CLI extra includes an inspector, a web UI that connects to your server, lists its tools and resources, and lets you call them by hand before wiring up a real client.
mcp dev server.pyThis opens a local web page. In it you can:
- See the tool list with the generated schemas for
addandget_weather. - Call
addwith sample arguments and see the JSON-RPC response. - Read the
note://todayresource and confirm it returns the string you expect.
This step matters more than it looks. Most MCP bugs are schema bugs: an argument typed as str when the caller sends an integer, or a tool description vague enough that the model picks the wrong tool. Catching those in the inspector is much faster than catching them inside a live agent run where the failure shows up as a confusing model response three turns later.
Adding structured input and error handling
Real tools rarely take two scalar arguments. Use Pydantic models or dataclasses for structured input, and raise exceptions for invalid states rather than returning error strings silently.
from pydantic import BaseModel, Field
from mcp.server.fastmcp import FastMCP
mcp = FastMCP("orders-server")
class RefundRequest(BaseModel):
order_id: str = Field(description="The order ID to refund")
amount_cents: int = Field(description="Refund amount in cents", gt=0)
reason: str = Field(description="Why this refund is being issued")
@mcp.tool()
def issue_refund(request: RefundRequest) -> str:
"""Issue a refund for an order. Fails if the order is not found."""
order = lookup_order(request.order_id)
if order is None:
raise ValueError(f"no order found with id {request.order_id}")
if request.amount_cents > order["total_cents"]:
raise ValueError("refund amount exceeds order total")
process_refund(order, request.amount_cents, request.reason)
return f"refunded {request.amount_cents} cents on order {request.order_id}"
def lookup_order(order_id: str):
# replace with a real database call
return {"id": order_id, "total_cents": 5000}
def process_refund(order, amount_cents, reason):
# replace with a real payment provider call
passRaising ValueError (or any exception) inside a tool gets caught by FastMCP and surfaced to the model as a tool error, which the model can then react to, retry differently, or explain to the user. That is much better than swallowing the exception and returning a string like "error: order not found", because the client's error-handling path (retries, logging, surfacing to the user) only fires on actual protocol-level errors.
The gt=0 constraint on amount_cents is enforced automatically. If the model tries to call issue_refund with a negative amount, validation fails before your function body even runs.
Resources with dynamic URIs
Static resources are fine for constants, but most real resources need a parameter, like reading a specific file or record by ID. Use a URI template:
@mcp.resource("orders://{order_id}")
def get_order_resource(order_id: str) -> str:
"""Fetch order details as a resource, addressed by order ID."""
order = lookup_order(order_id)
if order is None:
return "order not found"
return f"order {order_id}: total {order['total_cents']} cents"The client can now read orders://12345 and get that specific order back, without needing a separate tool call. Resources are pull-based (the client decides when to read them) while tools are push-based (the model decides when to call them), so use resources for data you expect to be read often or cached, and tools for anything that performs an action or requires arguments the model needs to reason about first.
Adding a prompt template
Prompts are reusable, parameterized instructions the client can present to the user, often as a slash command.
@mcp.prompt()
def summarize_order(order_id: str) -> str:
"""Generate a prompt asking the model to summarize an order."""
return (
f"Look up order {order_id} using the get_order_resource resource, "
f"then write a two-sentence summary a support agent could paste into a ticket."
)A client like Claude Desktop can surface summarize_order as a slash command, fill in order_id from user input, and send the resulting text as the first message of a conversation. This is useful for standardizing how your team asks the model to do repetitive tasks, instead of everyone typing a slightly different version of the same instruction.
Writing an MCP client in Python
You do not need Claude Desktop to use a server, the SDK's client library lets you drive any MCP server programmatically, which is useful for testing and for building your own agent loop.
import asyncio
from mcp import ClientSession, StdioServerParameters
from mcp.client.stdio import stdio_client
async def main():
server_params = StdioServerParameters(
command="python",
args=["server.py"],
)
async with stdio_client(server_params) as (read, write):
async with ClientSession(read, write) as session:
await session.initialize()
tools = await session.list_tools()
print("available tools:", [t.name for t in tools.tools])
result = await session.call_tool("add", arguments={"a": 4, "b": 9})
print("add result:", result.content)
resource = await session.read_resource("note://today")
print("resource:", resource.contents)
asyncio.run(main())This spawns server.py as a subprocess, speaks MCP over its stdin and stdout, lists the tools, calls add, and reads the note resource. This pattern, an MCP client embedded inside your own Python agent, is exactly how you would wire MCP tools into a custom agent loop that is not Claude Desktop or Claude Code, for example a backend service that calls Claude's API directly and needs tool access without hand-writing the tool-calling glue.
Choosing a transport: stdio versus streamable HTTP
Stdio transport, shown above, is the right default for local tools: a database client on your machine, a file system helper, a script that shells out to a CLI. The client spawns the server as a subprocess and both sides exit together.
For anything that needs to run as a standalone service, reachable over the network, use streamable HTTP transport instead:
from mcp.server.fastmcp import FastMCP
mcp = FastMCP("remote-demo")
@mcp.tool()
def ping() -> str:
"""Health check tool."""
return "pong"
if __name__ == "__main__":
mcp.run(transport="streamable-http")Run it and it listens on an HTTP port, ready to be reached from a remote client, put behind a reverse proxy, or deployed as its own service. Pick stdio when the server and client always live on the same machine and share a lifecycle. Pick streamable HTTP when the server is a shared resource, multiple clients need to reach it, or it needs to run independently of any one client process.
Connecting your server to Claude Desktop
Once a server works locally, add it to Claude Desktop's configuration so Claude can call it directly in conversation. Open Claude Desktop's settings and edit the MCP server config (on macOS this lives at ~/Library/Application Support/Claude/claude_desktop_config.json, on Windows under %APPDATA%\Claude\):
{
"mcpServers": {
"demo-server": {
"command": "python",
"args": ["/absolute/path/to/server.py"]
}
}
}Use an absolute path, Claude Desktop launches the server from its own working directory, not the folder your script lives in, so a relative path will fail silently. Restart Claude Desktop after editing the config. If the server started correctly, you will see demo-server listed under the tools icon in a new conversation, with add, get_weather, and the resources you defined all available for Claude to call.
For Claude Code, the equivalent setup uses the claude mcp add command, pointed at the same script, and the server shows up the same way inside a coding session, letting the agent call your custom tools while it works on a repository.
Debugging common failures
A few failure modes come up constantly when people first wire up an MCP Python SDK server:
- Server exits immediately. Usually means an exception is thrown at import time, before
mcp.run()is even reached. Runpython server.pydirectly in a terminal, not through a client, and read the traceback. - Tool shows up but the model never calls it. The docstring is probably too vague. "Get data" tells the model nothing about when to use it. "Fetch the current inventory count for a SKU by its product ID" tells it exactly when to reach for the tool.
- Client hangs on `initialize()`. Almost always a transport mismatch, the client is trying stdio against a server started with
transport="streamable-http", or vice versa. Match the transport on both ends. - Validation errors on every call. Check your type hints against what the model is actually sending; log the raw arguments inside the tool function temporarily to see the mismatch.
Working through the inspector before wiring up a real client catches most of these before they turn into a debugging session inside a live agent conversation.
FAQ
What Python version does the MCP Python SDK require? Python 3.10 or newer. Older versions are not supported because the SDK relies on typing features introduced in 3.10.
Do I need `uv` to use the MCP Python SDK? No, pip install "mcp[cli]" works fine. uv is recommended because the SDK's own examples and the mcp dev inspector tooling are built and tested against it, but it is not a hard requirement.
What is the difference between a tool and a resource in MCP? A tool is an action the model decides to invoke, with arguments, and it can have side effects. A resource is read-only data addressed by a URI that the client pulls in, either automatically or when the user asks for it. Use tools for anything that does something; use resources for anything that is just data to read.
Can one MCP server expose both stdio and HTTP transports? Not at the same time in a single running process. You choose the transport when you call mcp.run(transport=...). If you need both, run two instances of the same server code, one configured for stdio and one for streamable HTTP.
Does the MCP Python SDK work with models other than Claude? Yes. MCP is a protocol, not a Claude-specific feature, so any MCP-aware client can talk to a server built with this SDK. Claude Desktop and Claude Code are the most common clients today, but the protocol itself is model-agnostic.
How do I secure an MCP server exposed over HTTP? Put it behind your normal HTTP security stack: TLS termination, authentication middleware, and network-level access control (a private network or an allowlist) in front of the streamable HTTP endpoint. The SDK does not include built-in authentication, so treat a network-reachable MCP server the same way you would treat any other internal API.
Can a single server register many tools? Yes, there is no hard limit on tool count. In practice, keep a server focused on one domain (orders, files, search) rather than bundling unrelated tools into one process, it keeps tool descriptions clearer for the model and keeps the codebase easier to reason about.
BootcampA 30-day guided bootcamp: build, harden and ship a production autonomous agent from scratch.
AI AgentsUnderstand how AI agents really work: the loop, the tools, the memory, and why most agent projects fail.