Step-by-Step: Build an MCP Server in Python (MCPServer)
Each step has a Goal, what to Do, and how to Verify it. Track progress with the MCP checklist. The finished code is in templates/python-server/.
The official mcp package (v2) provides MCPServer (from mcp.server.mcpserver import MCPServer). It turns type hints and docstrings into tool schemas automatically.
Renamed in v2: in
mcp1.x this class was calledFastMCP(from mcp.server.fastmcp import FastMCP). The decorators (@mcp.tool(),@mcp.resource(),@mcp.prompt()) andmcp.run()work the same way. The separate third-partyfastmcppackage is a different project. If you must keep v1 code running, pinmcp<2. Migration guide: https://py.sdk.modelcontextprotocol.io/v2/migration/.
Step 1: Define the capabilities
Section titled “Step 1: Define the capabilities”- Goal: A short list of task-shaped tools.
- Do: The same as in the TypeScript guide, Step 1. One tool per user job, fewer than about 10 tools, and each one marked as read-only or not.
- Verify: Every tool maps to a real request.
Step 2: Scaffold with uv
Section titled “Step 2: Scaffold with uv”-
Goal: An isolated, reproducible environment.
-
Do:
Terminal window # install uv once: https://docs.astral.sh/uv/ (Windows: winget install astral-sh.uv)uv init notes-servercd notes-serveruv add "mcp[cli]"If you use pip instead:
python -m venv .venv, activate it, thenpip install "mcp[cli]". -
Verify:
uv run python -c "import mcp; print('ok')"printsok.
Step 3: Create the server
Section titled “Step 3: Create the server”-
Goal: A server that completes the handshake.
-
Do: In
server.py:import sysfrom mcp.server.mcpserver import MCPServermcp = MCPServer("notes", version="0.1.0")# tools, resources, prompts go hereif __name__ == "__main__":print("notes MCP server starting on stdio", file=sys.stderr) # stderr, never stdoutmcp.run() # stdio by default -
Verify:
uv run mcp dev server.pyopens the Inspector and connects.
Step 4: Add tools
Section titled “Step 4: Add tools”-
Goal: Type-safe tools with good descriptions.
-
Do: The docstring becomes the description and the type hints become the schema:
from typing import Annotatedfrom mcp.types import ToolAnnotationsfrom pydantic import Field@mcp.tool(annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=False))def search_notes(query: Annotated[str, Field(min_length=1, description="Keyword or phrase, case-insensitive")],limit: Annotated[int, Field(ge=1, le=20, description="Max results")] = 5,) -> str:"""Search saved notes by keyword in title or body. Returns up to `limit`matches, newest first. Use before add_note to avoid duplicates."""q = query.lower()hits = [n for n in load_notes() if q in f"{n['title']} {n['body']}".lower()][:limit]if not hits:return f'No notes match "{query}". Try a broader keyword.'return "\n".join(f"- {n['title']} ({n['created']}): {n['body'][:200]}" for n in hits) -
Verify: In the Inspector, the tool’s schema shows
query(required) andlimit(default 5), and running it works.
Step 5: Handle errors well
Section titled “Step 5: Handle errors well”-
Goal: The model can recover from failures.
-
Do: For expected failures, raise an exception with an actionable message.
MCPServerreturns it to the model as a tool error (isError: true):@mcp.tool(annotations=ToolAnnotations(readOnlyHint=False, destructiveHint=False))def add_note(title: Annotated[str, Field(min_length=1, max_length=120)],body: Annotated[str, Field(min_length=1, max_length=10_000)],) -> str:"""Save a new note. Fails if a note with the same title exists."""notes = load_notes()if any(n["title"].lower() == title.lower() for n in notes):raise ValueError(f'A note titled "{title}" already exists. Choose another title or search first.')notes.append({"title": title, "body": body, "created": now_iso()})save_notes(notes)return f'Saved note "{title}".' -
Verify: A duplicate title returns a readable error in the Inspector, and the server stays up.
Step 6: Resources, prompts, and structured output (optional)
Section titled “Step 6: Resources, prompts, and structured output (optional)”-
Do:
@mcp.resource("notes://all", mime_type="application/json")def all_notes() -> str:"""Every saved note as JSON."""return json.dumps(load_notes(), indent=2)@mcp.prompt()def weekly_review(focus: str = "") -> str:"""Summarise this week's notes."""return "Search my notes from this week and write a weekly review" + (f" focused on {focus}." if focus else ".")Return a Pydantic model or a
TypedDictfrom a tool to get structured output:MCPServergenerates theoutputSchemaand fillsstructuredContentfor you. -
Verify: They appear on the Inspector’s Resources and Prompts tabs.
Step 7: Configuration, async, and context
Section titled “Step 7: Configuration, async, and context”- Do:
- Read configuration from environment variables:
NOTES_FILE = os.environ.get("NOTES_FILE", str(Path.home() / ".notes-mcp.json")). - Use
async deffor tools that do I/O (HTTP calls, database queries). - Add a
ctx: Contextparameter (from mcp.server.mcpserver import Context) to get logging (await ctx.info(...)), progress reporting (await ctx.report_progress(...)), and elicitation.
- Read configuration from environment variables:
- Verify: It runs with the variable unset and set, and long operations report progress.
Step 8: Test
Section titled “Step 8: Test”- Do:
- Run it in the Inspector:
uv run mcp dev server.py. - Run the automated smoke test:
node qa/mcp-smoke.mjs python 03-mcp/templates/python-server/server.pyfrom the guide root, ornpm run qa:templates. - Unit-test the plain functions (
load_notes, the filtering logic) with pytest. For in-memory protocol tests, pass the server object straight to the client:async with mcp.Client(server) as client: await client.call_tool("search_notes", {"query": "x"}).
- Run it in the Inspector:
- Verify: The smoke test prints
PASS, and pytest is green.
Step 9: Connect to your clients
Section titled “Step 9: Connect to your clients”-
Do: Follow Connect to clients. With uv, the config is:
{"mcpServers": {"notes": {"command": "uv","args": ["--directory", "C:/path/to/notes-server", "run", "server.py"]}}}Shortcuts:
uv run mcp install server.pyregisters it with Claude Desktop, andclaude mcp add notes -- uv --directory C:/path/to/notes-server run server.pyregisters it with Claude Code. -
Verify: Cursor shows the server as green, and a natural-language request calls the tool.
Step 10: Remote deployment (optional)
Section titled “Step 10: Remote deployment (optional)”- Do: Switch the transport with
mcp.run(transport="streamable-http")(it serves/mcpon port 8000 by default), put it behind HTTPS and authentication, and deploy it. See Security & production. - Verify:
npx @modelcontextprotocol/inspectorconnects tohttps://your-host/mcpwith authentication.