Search "how to create an MCP server in Python" and most of the code you find opens with from mcp.server.fastmcp import FastMCP. On a fresh install today, that line fails. pip install mcp now installs version 2 of the official SDK, and v2 removed the fastmcp module rather than deprecating it. The error message is at least honest about it:
ModuleNotFoundError: No module named 'mcp.server.fastmcp'. This is mcp 2.x,
where FastMCP was renamed to MCPServer ...
This guide builds a small but real Python MCP server on the current SDK: a notes server with a SQLite database, typed inputs and outputs, errors the model can read, and a test suite that runs without a client app. Every snippet below was run against mcp 2.2.0 on Python 3.11 on 24 September 2026, and it serves both the 2026-07-28 protocol revision and older 2025-11-25 clients.
If you want the protocol concepts first, read how to build an MCP server. If you want to watch one file move from stdio to a URL, read the MCP server tutorial. This page is the Python engineering: project layout, schemas, state, errors, tests and packaging.
What changed in the Python SDK, in one table
Old tutorials are not wrong about MCP. They are wrong about the package. If you are adapting v1 code, these are the lines that break or quietly behave differently:
| v1 tutorial says | v2 (current) | Why it matters |
|---|---|---|
from mcp.server.fastmcp import FastMCP |
from mcp.server import MCPServer |
The old import raises ModuleNotFoundError |
FastMCP("x", port=9000) |
mcp.run(transport="streamable-http", port=9000) |
Transport options moved to run(); the old form is a TypeError |
mcp.get_context() |
declare a ctx: Context parameter |
get_context() was removed |
| raise any exception, the model sees the message | only ToolError text reaches the model |
Other exceptions read "Error executing tool" |
sync def tools block the event loop |
sync tools run on a worker thread | Thread-affine code needs care |
ctx.elicit() to ask the user |
Resolve(...) dependency |
ctx.elicit() fails on 2026-07-28 connections |
ctx.info() for logs |
Python logging to stderr |
MCP-level logging is deprecated |
If you maintain v1 code and cannot migrate yet, pin mcp>=1.28,<2. The SDK team still ships critical fixes to the v1.x branch. For anything new, start on v2.
Set up the project
You need Python 3.10 or newer and uv (pip works too). Create a package rather than a loose script, because you will want a console command and a test folder within the hour:
uv init --package notes-mcp
cd notes-mcp
uv add "mcp[cli]"
uv add --dev pytest anyio
The [cli] extra gives you the mcp command (mcp dev, mcp run, mcp install). mcp dev opens the MCP Inspector, which is a Node app, so it needs npx on your PATH.
Here is the pyproject.toml this guide ends with. The part that matters is [project.scripts], which turns the server into a command a host can launch:
[project]
name = "notes-mcp"
version = "0.1.0"
requires-python = ">=3.10"
dependencies = ["mcp[cli]>=2.2,<3"]
[project.scripts]
notes-mcp = "notes_mcp.server:main"
[build-system]
requires = ["uv_build>=0.8,<0.13"]
build-backend = "uv_build"
[dependency-groups]
dev = ["pytest", "anyio"]
The upper bound on mcp is deliberate. The v1 to v2 jump broke most servers that left it off, starting with every one that imported mcp.server.fastmcp.
Write the server
Put this in src/notes_mcp/server.py. Read it once, then we will take it apart:
import sqlite3
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from dataclasses import dataclass
from pathlib import Path
from pydantic import BaseModel, Field
from mcp.server import MCPServer
from mcp.server.mcpserver import Context
from mcp.server.mcpserver.exceptions import ToolError
from mcp.types import ToolAnnotations
DB_PATH = Path.home() / ".notes-mcp.sqlite3"
@dataclass
class AppState:
db: sqlite3.Connection
@asynccontextmanager
async def lifespan(server: MCPServer) -> AsyncIterator[AppState]:
db = sqlite3.connect(DB_PATH, check_same_thread=False)
db.execute("CREATE TABLE IF NOT EXISTS notes (id INTEGER PRIMARY KEY, title TEXT, body TEXT)")
try:
yield AppState(db=db)
finally:
db.close()
mcp = MCPServer(
"notes",
instructions="Personal notes. Search before creating to avoid duplicates.",
lifespan=lifespan,
)
class Note(BaseModel):
id: int
title: str
body: str
@mcp.tool(annotations=ToolAnnotations(readOnlyHint=True))
def search_notes(
ctx: Context,
query: str = Field(description="Words to look for in the title or body"),
limit: int = Field(default=5, ge=1, le=50),
) -> list[Note]:
"""Find notes whose title or body contains the query."""
db = ctx.request_context.lifespan_context.db
rows = db.execute(
"SELECT id, title, body FROM notes WHERE title LIKE ? OR body LIKE ? LIMIT ?",
(f"%{query}%", f"%{query}%", limit),
).fetchall()
return [Note(id=r[0], title=r[1], body=r[2]) for r in rows]
@mcp.tool()
def add_note(ctx: Context, title: str, body: str) -> Note:
"""Save a new note and return it."""
if not title.strip():
raise ToolError("title must not be empty; ask the user for a short title")
db = ctx.request_context.lifespan_context.db
cur = db.execute("INSERT INTO notes (title, body) VALUES (?, ?)", (title, body))
db.commit()
return Note(id=cur.lastrowid, title=title, body=body)
@mcp.resource("notes://{note_id}")
def read_note(note_id: int, ctx: Context) -> str:
"""One note as plain text."""
db = ctx.request_context.lifespan_context.db
row = db.execute("SELECT title, body FROM notes WHERE id = ?", (note_id,)).fetchone()
if row is None:
raise ValueError(f"no note {note_id}")
return f"# {row[0]}\n\n{row[1]}"
def main() -> None:
mcp.run()
if __name__ == "__main__":
main()
Run uv run mcp dev src/notes_mcp/server.py, open the URL it prints, and call add_note from the Tools tab. That is a working server. The interesting part is what each piece turns into on the wire.
Type hints are the schema
You never wrote JSON Schema. The SDK built this inputSchema for search_notes from the signature:
{
"type": "object",
"properties": {
"query": {"type": "string", "description": "Words to look for in the title or body"},
"limit": {"type": "integer", "default": 5, "minimum": 1, "maximum": 50}
},
"required": ["query"]
}
Two habits follow from that. First, write the docstring and the Field(description=...) text for the model, because the model is the one reading it. "Words to look for in the title or body" tells it what to pass. "The query" does not. Second, put real bounds on numbers. When our test client sent limit=500, pydantic rejected it before the function ran and returned the validation message as the tool result, which is exactly what a model needs to retry with a smaller number. The bound is also your cheapest protection against a tool that returns ten thousand rows into a context window.
The ctx: Context parameter does not appear in the schema. The SDK sees the annotation, injects the context, and hides it from the model.
Return types become structured output
Because add_note is annotated -> Note, the tool advertises an outputSchema and returns structured_content alongside the text. A client can read {"id": 1, "title": "Dentist", "body": "Call Tuesday"} directly instead of parsing a sentence. search_notes returns a list, which is not an object, so the SDK wraps it as {"result": [...]}. Plan for that key if you consume the output in code.
If you want a plain-text tool, annotate -> str. If you want to switch structured output off regardless of the annotation, pass structured_output=False to the decorator.
Annotations are hints, not permissions
readOnlyHint=True tells the host that search_notes changes nothing, and some hosts use that to skip a confirmation prompt. It does not stop anything. The MCP spec says clients must treat annotations as untrusted unless they come from a trusted server, so do not use them as your safety layer. Keep side effects in the tools that say they have them, and keep the destructive ones narrow.
Hold state in the lifespan, not in globals
The lifespan function opens the database once and hands an AppState to every tool through ctx.request_context.lifespan_context. That pattern is the right home for connection pools, HTTP clients, loaded models and config.
One v2 change matters here. Over Streamable HTTP, the lifespan now runs once at startup, and its state is shared by every session and request. In v1 it ran once per session (and once per request under stateless_http=True). That makes pools much cheaper, and it means anything you put in the lifespan is shared across users. A per-user resource belongs in the tool body.
The check_same_thread=False on the SQLite connection is there because of a second v2 change: plain def tools run on a worker thread so they do not block the event loop. SQLite connections refuse cross-thread use by default. For a real multi-user server, use a pool or an async driver. For a personal stdio server, this is fine.
Errors the model can read
v2 splits failures into two kinds, and the split decides what the model sees.
- Expected failures raise
ToolError. The call returnsis_error=Trueand your message reaches the model word for word. In our test, an empty title came back as "title must not be empty; ask the user for a short title", which gives the model something to act on. - Unexpected failures are anything else. The model sees only
Error executing tool add_noteand the traceback goes to your server log. We checked this by raising aRuntimeErrorwhose message contained a fake password. The model saw none of it.
That default is safer than v1, where any exception's text went to the model. It also means a v1 server that relied on raise ValueError("user not found") to steer the model now goes quiet. Convert those to ToolError.
Resources are different. The read_note resource raises ValueError for a missing ID, which the client receives as a protocol error rather than a tool result. There is also a ResourceError for the same purpose.
Do not print to stdout
On stdio, stdout is the protocol channel. Anything else written there corrupts the JSON-RPC stream. We tested a tool that calls print("debug: called hi") over stdio, and the client's call failed with Connection closed. No partial result came back, and nothing pointed at the print.
Use the logging module, which writes to stderr by default, or print(..., file=sys.stderr). Hosts like Claude Desktop capture a server's stderr into their log files, so that is where your debug output should go. Do not reach for ctx.info() as the replacement: MCP-level logging is deprecated in the 2026-07-28 revision and prints an MCPDeprecationWarning in v2.
Test it without a client app
This is the part the older tutorials skip. In v2, Client accepts the server object itself and runs it in memory, with no subprocess and no port. Your lifespan runs too. Put this in tests/test_server.py:
import pytest
from mcp import Client
from notes_mcp import server
@pytest.fixture
def anyio_backend():
return "asyncio"
@pytest.fixture(autouse=True)
def temp_db(tmp_path, monkeypatch):
monkeypatch.setattr(server, "DB_PATH", tmp_path / "notes.sqlite3")
@pytest.mark.anyio
async def test_add_then_search():
async with Client(server.mcp) as client:
await client.call_tool("add_note", {"title": "Dentist", "body": "Call Tuesday"})
result = await client.call_tool("search_notes", {"query": "Tuesday"})
assert result.structured_content["result"][0]["title"] == "Dentist"
@pytest.mark.anyio
async def test_empty_title_is_a_readable_error():
async with Client(server.mcp) as client:
result = await client.call_tool("add_note", {"title": " ", "body": "x"})
assert result.is_error
assert "title must not be empty" in result.content[0].text
uv run pytest runs both in under a second. Because the test goes through the real protocol layer, it catches schema mistakes, validation behavior and error text, not just your function's logic. Assert on the error text the model will see. That string is part of your interface now.
One trap when porting: Client(mcp) negotiates the 2026-07-28 revision by default. A tool that calls ctx.elicit() works against old clients and fails in this test. Either move the question into a Resolve(...) parameter, which works on both revisions, or pin the test with Client(server.mcp, mode="legacy") if you really want the older behavior.
Run it: stdio for local, HTTP for a URL
mcp.run() with no arguments is stdio. With the console script from pyproject.toml, a host can launch it as a command:
{
"mcpServers": {
"notes": {
"command": "uv",
"args": ["--directory", "/absolute/path/to/notes-mcp", "run", "notes-mcp"]
}
}
}
That block is the Claude Desktop shape. Claude Code, Cursor and VS Code each use a different file and slightly different keys; the configuration page has all of them side by side. uv run mcp install src/notes_mcp/server.py will also write the Claude Desktop entry for you.
To share it without publishing to PyPI, uvx --from /path/to/notes-mcp notes-mcp (or a git+https:// URL in place of the path) installs and runs it in a throwaway environment. That is the same pattern many published Python servers use in their install instructions.
For a URL, change one argument:
mcp.run(transport="streamable-http", port=8000)
The endpoint is http://127.0.0.1:8000/mcp. We connected to it with a default v2 Client (it negotiated 2026-07-28) and with mode="legacy" (it negotiated 2025-11-25), against the same running process. You do not need a flag to serve older clients.
Localhost is where this stops being a tutorial. A public MCP URL should use the spec's OAuth 2.1 authorization if its tools touch user data. It also needs Host header allowlisting (transport_security; on a real hostname, requests get a 421 until you add it to allowed_hosts) and a plan for which state survives a restart. The tutorial's last section and remote MCP servers explained cover that ground.
Where this server shape stops fitting
A notes database is a good first server because you own every part of it. The pattern holds for anything in the same class: your company's internal API, a data warehouse, a CLI you want an agent to drive. Those are worth building.
It stops fitting when the tool is really a service with its own infrastructure and legal surface. Placing a phone call is a clean example. The Python part would be twenty lines. The rest is a telephony carrier, caller ID verification, call recording and retention, calling-hours rules, AI disclosure and an opt-out list.
That is why DialMCP exists as a hosted remote server rather than a package: place_call, get_call, end_call and list_calls at https://mcp.dialmcp.com/mcp, OAuth 2.1, and calls placed from your own SMS-verified US or Canadian number. You connect it next to the server you just built. Build the tools that are yours. Connect the ones that are services.
Your agent needs to make a phone call, not another server? DialMCP is hosted at https://mcp.dialmcp.com/mcp. Connect it next to the server you just built.
FAQ
Should I use FastMCP or the official MCP SDK?
For a new server, start with the official mcp package and its MCPServer class, which is what this guide uses. FastMCP is a separate open-source framework, now at version 4 and maintained by Prefect. Its 1.0 release was folded into the official SDK in 2024 as the old FastMCP class, and the standalone project has kept adding its own features (clients, auth, deployment tooling) since. Choose it if you need one of those, and check which one a tutorial means before copying its imports.
Why does from mcp.server.fastmcp import FastMCP fail?
You have mcp 2.x installed. The class is now MCPServer, imported with from mcp.server import MCPServer. Either port the code or pin mcp<2.
What Python version do I need?
Python 3.10 or newer for the current SDK.
How do I debug a Python MCP server?
Use uv run mcp dev server.py for the Inspector, write logs to stderr, and write in-memory tests with Client(server.mcp). Check the host's MCP log file when a server connects but its tools do not appear.
Can the same server work over stdio and HTTP?
Yes. The tool code is the same. Only the argument to mcp.run() changes.
Do I need async functions?
No. Plain def tools run on a worker thread in v2, so they do not block other requests. Use async def when you are calling async libraries, like an async HTTP client.