The Python SDK Landscape
Lesson 03 · MCP Servers · ~10 min read
Two layers, one SDK
The official Python SDK lives in a single package — mcp — but it actually ships
two server-building APIs stacked on top of one another. The lower layer is
mcp.server.Server: a thin, hand-driven object where you register a callback for every
MCP capability the server supports. You decide what list_tools returns, you decide how
call_tool dispatches, you wire each handler yourself. There is nothing magical happening,
and you can see every byte that hits the wire.
The higher layer is mcp.server.fastmcp.FastMCP: a decorator-driven facade that wraps the
low-level Server and infers everything it can from your Python type hints. Mark a function with
@mcp.tool() and FastMCP introspects its signature, builds a JSON Schema from the
Pydantic-compatible annotations, registers it under list_tools, and dispatches calls
automatically. Same for @mcp.resource() and @mcp.prompt().
Both layers produce the exact same wire protocol — an MCP client cannot tell which one you used. So the choice is purely an ergonomics decision. FastMCP is right roughly 95% of the time: it removes boilerplate, keeps the schema and the implementation in one place, and is what every reference server in the official repo uses today. Reach for the low-level Server only when you need fine-grained control — for example, dynamically generated tool lists, custom error envelopes, or serving capabilities the high-level layer hasn't wrapped yet. In this course we use FastMCP by default and only drop down when there is a concrete reason to.
A side-by-side hello tool
The clearest way to feel the difference is to write the same trivial tool — greet(name)
— in both styles. The low-level version is explicit about every step:
from mcp.server import Server
from mcp.types import Tool, TextContent
server = Server("hello-low")
@server.list_tools()
async def list_tools() -> list[Tool]:
return [
Tool(
name="greet",
description="Return a greeting for the given name.",
inputSchema={
"type": "object",
"properties": {"name": {"type": "string"}},
"required": ["name"],
},
)
]
@server.call_tool()
async def call_tool(name: str, arguments: dict) -> list[TextContent]:
if name == "greet":
return [TextContent(type="text", text=f"Hello, {arguments['name']}!")]
raise ValueError(f"Unknown tool: {name}")
You hand-write the JSON Schema. You dispatch on the tool name. You construct content blocks. Nothing
is wrong with this — it is the protocol, made visible. But for 20 tools it becomes 20 schemas plus
one giant if/elif ladder.
The FastMCP version of the same tool, in its entirety, is:
from mcp.server.fastmcp import FastMCP
mcp = FastMCP("hello-fast")
@mcp.tool()
def greet(name: str) -> str:
"""Return a greeting for the given name."""
return f"Hello, {name}!"
Three lines of meaningful code. The schema is derived from the type hint, the description from the docstring, the dispatcher from the function name, the content-block wrapping from the return type. This is the shape of the SDK in one picture:
Project layout that scales
Even toy servers benefit from a layout that splits responsibilities by file. A typical project looks like this:
my-mcp-server/
pyproject.toml
src/
my_mcp_server/
__init__.py
server.py # FastMCP instance, transport entry point
tools.py # @mcp.tool() functions
resources.py # @mcp.resource() functions
prompts.py # @mcp.prompt() functions
tests/
test_tools.py
Why split files when you only have three tools? Two reasons. First, every category — tools, resources, prompts — eventually grows. By the time you have ten tools and five resources you will want them separated, and splitting later means renaming imports across consumers. Second, the split forces you to think about your server as a library of capabilities rather than a single script: each module exports clean functions you can unit-test in isolation, without spinning up the protocol.
The pyproject.toml for an MCP server has three essentials: a package name, the
mcp dependency pinned to a recent minor version, and a [project.scripts]
entry that exposes your server as a console script. That last one matters more than it looks. Claude
Desktop's claude_desktop_config.json launches servers by command line; if your package
installs a my-mcp-server entry point, users can register it as just
"command": "my-mcp-server" rather than "python -m my_mcp_server.server"
with a fragile path. Pin requires-python = ">=3.10" while you are at it — the SDK uses
modern async features that 3.9 does not support.
The mcp dev loop
The SDK ships a developer command, mcp dev, that is the single best feedback loop you
will get while building a server. Run mcp dev pkb/server.py and two things happen at
once: your server is launched under stdio with hot-reload watching the source tree, and the MCP
Inspector — a small web UI — opens in your browser pre-connected to it. You can list tools, call
them with sample arguments, watch the raw JSON-RPC traffic, and edit your code; the Inspector
reconnects on every save. The iteration cycle becomes: change code → save → click the
tool button in Inspector → read the result. Three seconds, no manual restarts, no client
config. Keep Inspector open in a second window while you work.
Pydantic for tool schemas
FastMCP leans on Pydantic v2 to turn Python type hints into JSON Schema. You almost
never write Pydantic models directly — built-in types and simple dataclasses are enough — but when
a tool has a structured input, declaring a BaseModel for the argument gives you three
wins at once. Type errors are caught at startup rather than at the first malformed call. The schema
exposed to the client is generated from the same definition the implementation uses, so they can
never drift. And the Inspector picks up Pydantic's field descriptions and constraints, giving you
a usable form UI for free. Hand-written JSON Schema gets you none of these.
Testing patterns
The most useful testing tool the SDK ships is the in-memory transport, exposed as
mcp.shared.memory.create_connected_server_and_client_session. It returns a wired pair
— your server instance on one side, an MCP client session on the other — connected through
in-process queues rather than pipes or sockets. The full protocol runs: initialize, list-tools,
call-tool, notifications, all of it. But there is no subprocess, no stdin/stdout, no port to bind.
Tests start in milliseconds.
The pattern looks like this:
import pytest
from mcp.shared.memory import create_connected_server_and_client_session
from my_mcp_server.server import mcp
@pytest.mark.asyncio
async def test_greet():
async with create_connected_server_and_client_session(mcp._mcp_server) as (
client, _server,
):
result = await client.call_tool("greet", {"name": "Ada"})
assert "Ada" in result.content[0].text
Three properties make this the right pattern for CI. It is fast — no subprocess startup tax, so a thousand tool calls run in seconds. It is honest — the protocol semantics are real, including the initialize handshake, content types, and error envelopes. And it is portable — there are no platform-specific socket or pipe behaviours to flake on Windows runners. Reach for subprocess-based integration tests only when you genuinely need to verify the stdio layer itself.
Reading reference servers
The single highest-leverage hour you can spend after this lesson is reading source code in the
modelcontextprotocol/servers repo. It is the official collection of reference servers,
most of them in Python, and they show you idiomatic patterns the docs do not always spell out: how
to factor large tool sets, how to expose resources alongside tools, how to handle authentication and
rate limits, how to package for distribution. Start with fetch — it is small,
fully featured, and demonstrates a single useful capability end to end. Then read
sqlite for a more substantial example that mixes tools, resources, and sampling
(the server-calls-the-LLM pattern from Lesson 02). Skim two or three more from the list to see the
range. You will lift a project layout idea from each one.
Common pitfalls:
- Mixing low-level and FastMCP decorators in the same file.
@server.list_tools()belongs on aServerinstance,@mcp.tool()on aFastMCPinstance — the two APIs are distinct objects with distinct decorator namespaces, and mixing them produces tools that register but never dispatch (or vice versa). Pick one API per server file. - Forgetting
requires-python = ">=3.10". The SDK relies onasyncio.TaskGroupand PEP 604 union syntax that 3.9 does not support; the failures on 3.9 are cryptic import errors and stalled handshakes rather than a clean version error, so pin the floor explicitly inpyproject.toml. - Shipping without a
[project.scripts]entry. Without a console script, users have to invoke your server aspython -m your_module.server, which breaks Claude Desktop configs the moment paths change or virtualenvs differ between machines. Add the entry point at the first release, not the fifth.
References
- Python SDK README (modelcontextprotocol/python-sdk) — install instructions, full quickstart, and the canonical example for both Server and FastMCP.
- FastMCP docs — source-tree documentation for the high-level decorator API, including resources, prompts, and lifecycle hooks.
- Reference servers repo (modelcontextprotocol/servers) — the official collection; start with
fetch, thensqlite. - Pydantic v2 docs — model and field reference, especially the JSON Schema generation chapter.