Lesson 03 The Python SDK Landscape
Theme:

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:

your_server.py @mcp.tool() FastMCP decorator layer mcp.server.Server low-level API stdio or HTTP

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:

  1. Mixing low-level and FastMCP decorators in the same file. @server.list_tools() belongs on a Server instance, @mcp.tool() on a FastMCP instance — 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.
  2. Forgetting requires-python = ">=3.10". The SDK relies on asyncio.TaskGroup and 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 in pyproject.toml.
  3. Shipping without a [project.scripts] entry. Without a console script, users have to invoke your server as python -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