MCP in Python: Building Your First Server with the Python SDK
Every AI engineer eventually hits the same wall: your model is smart, but it can't do anything. It can't check your calendar, query your database, or read the file sitting three folders deep in your project. You end up writing bespoke glue code for every tool, every model, every project — and none of it is reusable. The Model Context Protocol, or MCP, was built to fix exactly this problem, and the Python SDK is the fastest way to feel the difference yourself. In the next few minutes, you'll go from a blank file to a working MCP server that exposes real tools to any MCP-compatible client, understand the moving parts well enough to debug them, and know exactly where this fits into the rest of your AI-engineering stack.
What MCP Actually Solves
Before touching code, it's worth being precise about the problem MCP addresses, because the hype around it tends to blur the details.
Large language models are stateless text predictors. They don't have hands. Every time you want a model to "do" something in the real world — search the web, run a query, write a file — you need a bridge between the model's text output and an actual function call. For years, every framework built its own version of this bridge: LangChain had its tool abstraction, OpenAI had function calling, and every vendor's agent SDK had a slightly different shape for describing tools. If you built a tool for one framework, it was locked to that framework.
MCP is a protocol, not a framework. It defines a standard way for a "host" application (like Claude Desktop, an IDE, or your own agent) to talk to a "server" that exposes capabilities. Those capabilities come in three flavors:
- Tools — functions the model can call, with typed inputs and outputs, usually to take an action or fetch dynamic data
- Resources — read-only data the host can attach to context, like a file, a database row, or an API response
- Prompts — reusable prompt templates that a server can hand to the host
Because the protocol is standardized (it's built on JSON-RPC 2.0), a server you write once works with any client that speaks MCP — Claude Desktop, Claude Code, your own custom agent, or a third-party IDE plugin. You write the integration once, and it composes everywhere. That's the entire value proposition: stop rewriting the same GitHub tool, the same Postgres tool, the same filesystem tool for every new agent framework that shows up.
Think about how this compares to the pre-MCP world. If you built a "search internal docs" tool for a LangChain agent, that tool's schema and calling convention lived inside LangChain's abstractions. Porting it to a different framework, or to a raw API integration, meant rewriting the adapter layer from scratch. Multiply that by every tool and every framework your team touches, and you get an N-times-M integration problem: N tools, M frameworks, and a rewrite for every new pair. MCP collapses that into N servers and M clients that all speak the same wire protocol, so adding a new framework or a new tool is additive instead of multiplicative. That's also why the ecosystem around MCP has grown so quickly — a server for Slack, GitHub, or Postgres written by one team is immediately usable by anyone running an MCP-compatible client, with zero framework-specific glue code.
Setting Up Your Environment
The official Python SDK is published as mcp on PyPI, and the recommended way to manage it is with uv, the fast Python package manager, though plain pip works fine too.
mkdir mcp-first-server
cd mcp-first-server
uv init
uv add "mcp[cli]"If you're using pip instead:
python -m venv venv
source venv/bin/activate
pip install "mcp[cli]"The [cli] extra matters — it pulls in the mcp dev command, which gives you an inspector UI to test your server without wiring up a full client first. You'll want this while you're learning.
Verify the install:
python -c "import mcp; print(mcp.__version__)"If that prints a version number without errors, you're ready to write a server.
Your First Server: FastMCP
The Python SDK ships with a high-level API called FastMCP that hides almost all of the protocol plumbing. You describe your tools as plain Python functions with type hints and docstrings, and the SDK handles schema generation, JSON-RPC message handling, and the transport layer.
Here's a complete, working server in under twenty lines:
# server.py
from mcp.server.fastmcp import FastMCP
mcp = FastMCP("teachyou-demo")
@mcp.tool()
def add(a: int, b: int) -> int:
"""Add two numbers together."""
return a + b
@mcp.tool()
def word_count(text: str) -> int:
"""Count the number of words in a piece of text."""
return len(text.split())
if __name__ == "__main__":
mcp.run()That's it. FastMCP("teachyou-demo") creates a server instance with a name that clients will see when they connect. The @mcp.tool() decorator registers a function as a callable tool — the SDK inspects the type hints (a: int, b: int) to build a JSON Schema for the tool's parameters, and it uses the docstring as the description the model sees when deciding whether to call the tool.
This last point matters more than it looks. The docstring isn't documentation for humans — it's the primary signal the model uses to decide when and how to invoke your tool. A vague docstring like "does stuff" will get your tool ignored or misused. Write it the way you'd explain the function to a new teammate.
Run the inspector to try it interactively:
uv run mcp dev server.pyThis opens a local web UI where you can see your registered tools, call them manually with test inputs, and watch the raw JSON-RPC messages flow back and forth. Before you connect a real LLM to anything, get in the habit of testing here first — it saves you from debugging a broken tool and a confused model at the same time.
Resources: Giving the Model Things to Read
Tools are for actions. Resources are for data the host application can pull in as context, typically without the model needing to "decide" to fetch it — think of them as addressable, read-only endpoints, similar in spirit to a GET request.
from mcp.server.fastmcp import FastMCP
mcp = FastMCP("teachyou-demo")
FAKE_DB = {
"python-101": "Introduction to Python: variables, loops, functions.",
"mcp-101": "Model Context Protocol: tools, resources, and prompts explained.",
}
@mcp.resource("course://{course_id}")
def get_course_summary(course_id: str) -> str:
"""Return the summary text for a given course id."""
if course_id not in FAKE_DB:
raise ValueError(f"No course found with id: {course_id}")
return FAKE_DB[course_id]The @mcp.resource("course://{course_id}") decorator registers a resource template. The {course_id} segment is a URI parameter — when a client requests course://mcp-101, the SDK extracts mcp-101 and calls your function with course_id="mcp-101". This is how you expose parameterized, dynamic data without writing a new endpoint for every possible value.
A common mistake newcomers make is putting business logic that has side effects into a resource. Resources are meant to be safe to read repeatedly — a host might fetch the same resource several times while building context. If reading it changes state, it should probably be a tool instead.
Structuring Tool Inputs with Pydantic
Simple type hints work for scalars, but real tools usually need structured input. The SDK integrates directly with Pydantic, so you can define a model for your tool's arguments and get validation for free.
from mcp.server.fastmcp import FastMCP
from pydantic import BaseModel, Field
mcp = FastMCP("teachyou-demo")
class SearchQuery(BaseModel):
keyword: str = Field(description="The term to search for")
max_results: int = Field(default=5, ge=1, le=20, description="Number of results to return")
@mcp.tool()
def search_courses(query: SearchQuery) -> list[str]:
"""Search the course catalog by keyword and return matching titles."""
catalog = [
"MCP in Python: Building Your First Server",
"Prompt Engineering for Production Agents",
"RAG Pipelines with LangChain",
"Fine-Tuning Small Language Models",
]
matches = [c for c in catalog if query.keyword.lower() in c.lower()]
return matches[: query.max_results]The SDK converts SearchQuery into a JSON Schema automatically, including the Field constraints. If a client calls search_courses with max_results=100, Pydantic rejects it before your function body ever runs, and the error is surfaced back to the model as a tool-call failure. This is one of the underrated benefits of building on a typed SDK instead of hand-rolling JSON parsing: validation errors become part of the model's feedback loop, and it can often self-correct on the next call.
Async Tools and Real I/O
Toy examples do arithmetic. Real MCP servers call databases, hit APIs, and read files — all of which should be async so your server can handle concurrent requests without blocking.
import httpx
from mcp.server.fastmcp import FastMCP
mcp = FastMCP("teachyou-demo")
@mcp.tool()
async def fetch_status_code(url: str) -> int:
"""Fetch a URL and return its HTTP status code."""
async with httpx.AsyncClient(timeout=10.0) as client:
response = await client.get(url)
return response.status_code
@mcp.tool()
async def get_weather(city: str) -> dict:
"""Get current weather conditions for a given city name."""
async with httpx.AsyncClient() as client:
resp = await client.get(
"https://api.open-meteo.com/v1/forecast",
params={"latitude": 0, "longitude": 0, "current_weather": True},
)
resp.raise_for_status()
data = resp.json()
return data.get("current_weather", {})FastMCP fully supports async def tools — just declare the function as async and await your I/O calls normally. Under the hood, the SDK runs on anyio, so it plays well with both asyncio and trio event loops.
A practical tip: always set explicit timeouts on outbound HTTP calls inside tools. If your tool hangs, the whole conversation the model is having with the user hangs with it, and there's no built-in timeout at the protocol level to save you.
Error Handling That the Model Can Actually Use
A tool that raises an unhandled exception isn't just a bug in your server — it's a bad experience for the model, because a raw stack trace tells it nothing about what went wrong or how to fix its next attempt. Treat error messages as part of your tool's interface.
from mcp.server.fastmcp import FastMCP
mcp = FastMCP("teachyou-demo")
INVENTORY = {"pen": 40, "notebook": 0, "sticker": 12}
@mcp.tool()
def reserve_item(item: str, quantity: int) -> str:
"""Reserve a quantity of an item from inventory. Raises an error if unavailable."""
if item not in INVENTORY:
raise ValueError(f"Unknown item '{item}'. Available items: {list(INVENTORY.keys())}")
if quantity <= 0:
raise ValueError("Quantity must be a positive integer.")
if INVENTORY[item] < quantity:
raise ValueError(
f"Cannot reserve {quantity} of '{item}'; only {INVENTORY[item]} in stock."
)
INVENTORY[item] -= quantity
return f"Reserved {quantity} of '{item}'. {INVENTORY[item]} remaining."Notice that every ValueError message includes enough context for the model to retry intelligently — it tells you what the valid items are, or exactly how much stock is left. The SDK catches exceptions raised inside tool functions and reports them back to the client as tool errors rather than crashing the server process, but the quality of that error message is entirely up to you. Vague messages like "Error: bad input" waste a full round-trip; specific ones let the model self-correct in one.
Choosing a Transport: stdio vs HTTP
MCP servers can run over different transports depending on how they're deployed, and this is where a lot of beginners get confused because the code looks nearly identical but the deployment story is completely different.
stdio is the default and simplest option. The client launches your server as a subprocess and communicates over standard input/output. This is what Claude Desktop and Claude Code use for locally installed servers — there's no networking involved, no ports to open, and no auth to configure. It's the right choice for a server that lives on the same machine as the client.
if __name__ == "__main__":
mcp.run() # defaults to stdio transportStreamable HTTP is for servers that need to run remotely — as a standalone web service that multiple clients can connect to over the network. You enable it explicitly:
if __name__ == "__main__":
mcp.run(transport="streamable-http")Under the hood this spins up an ASGI app (built on Starlette) that you can also mount into an existing FastAPI or Starlette application if you want your MCP server to live alongside a regular REST API. This is the transport to reach for once you move past local experimentation and want to expose a server that a team, or a hosted agent, can connect to over a URL.
A rule of thumb: start every project on stdio. It's zero-config and the inspector works with it out of the box. Only switch to HTTP once you have a concrete reason — multiple remote clients, a deployment target, or a need for the server to outlive any single client session.
Connecting a Client
A server is only useful once something talks to it. The SDK also ships a client library, and understanding the client side makes the whole protocol click into place.
import asyncio
from mcp import ClientSession, StdioServerParameters
from mcp.client.stdio import stdio_client
server_params = StdioServerParameters(
command="python",
args=["server.py"],
)
async def main():
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": 5, "b": 7})
print("Result:", result.content)
asyncio.run(main())Walk through what's happening here, because it maps directly onto what Claude Desktop or Claude Code does behind the scenes when you register a server in a config file. stdio_client launches your server.py as a subprocess and wires up two streams. ClientSession wraps those streams in the actual MCP protocol logic — handshakes, message framing, request/response correlation. session.initialize() performs the capability negotiation handshake, where client and server tell each other what protocol version and features they support. From there, list_tools() and call_tool() are just JSON-RPC calls dressed up as clean Python methods.
If you've ever wired an MCP server into Claude Desktop's claude_desktop_config.json, this is exactly what's happening under the hood — the app is running this same client logic against the command you specified.
Practical Patterns for Production Servers
A few things separate a demo server from one you'd actually deploy, worth internalizing early rather than retrofitting later.
- Keep tools narrow and composable. A single
do_everythingtool with fifteen optional parameters is harder for a model to call correctly than five small, well-named tools. Models reason better over clear, single-purpose functions. - Return structured data, not prose. If a tool result is going to be used programmatically or chained into another tool call, prefer returning a dict or list over a formatted string. Let the model or host format it for the user later.
- Log outside the stdio stream. If you're using stdio transport, anything you
print()corrupts the protocol stream, since stdout is the wire format. Use Python'sloggingmodule configured to write to stderr or a file instead. - Version your tool schemas deliberately. If you rename a parameter or change its type, existing clients with cached tool definitions may break mid-session. Treat tool signatures with the same care you'd give a public API.
- Add timeouts and rate limits around external calls. Anything hitting a third-party API from inside a tool should fail fast and predictably, not hang the whole session.
- Test with the inspector before testing with a model. It's much faster to catch a schema bug in
mcp devthan to watch a model quietly fail to call your tool three times in a row.
These aren't exotic concerns — they're the same discipline you'd apply to any API you expect other people (or other systems) to depend on. MCP just makes the "other system" an LLM instead of a frontend.
It's also worth planning for how a model actually behaves once your server is wired into a live session. Models don't call tools in isolation — they see the full list of available tools and their descriptions every time they decide whether to act, so a server with thirty overlapping tools makes every decision noisier, not just the buggy ones. If you find yourself adding a sixth variant of "get_user_data," stop and ask whether you actually need distinct tools or just a shared one with a well-designed parameter. The same applies to resources: exposing every table in a database as a separate resource template sounds thorough, but it usually just adds noise the host has to reason about before it even starts solving the user's problem. Curate your server's surface area the way you'd curate a public API's documentation — the goal is a small set of things that are each obvious to use correctly.
Where This Fits in the Bigger Picture
Once you can build a single server, the natural next questions are architectural: should this run locally or remotely, how do you handle auth for a server that touches real user data, how do you compose multiple servers into one agent, and how do you deploy one so a whole team can use it instead of just your laptop. Those are exactly the questions we work through, end to end, in Building & Integrating MCP Servers — taking you from the toy examples in this article to servers backed by real databases, protected by proper authentication, and wired into production agent workflows.
MCP is still young, but the pattern it encodes — a standard, typed, model-agnostic way to expose capabilities — is the same shape a lot of durable infrastructure takes early in its life. Learning to build servers now, while the ecosystem is still forming, puts you ahead of the curve rather than catching up to it later.
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.