Skip to content

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 mcp 1.x this class was called FastMCP (from mcp.server.fastmcp import FastMCP). The decorators (@mcp.tool(), @mcp.resource(), @mcp.prompt()) and mcp.run() work the same way. The separate third-party fastmcp package is a different project. If you must keep v1 code running, pin mcp<2. Migration guide: https://py.sdk.modelcontextprotocol.io/v2/migration/.


  • 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.
  • 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-server
    cd notes-server
    uv add "mcp[cli]"

    If you use pip instead: python -m venv .venv, activate it, then pip install "mcp[cli]".

  • Verify: uv run python -c "import mcp; print('ok')" prints ok.

  • Goal: A server that completes the handshake.

  • Do: In server.py:

    import sys
    from mcp.server.mcpserver import MCPServer
    mcp = MCPServer("notes", version="0.1.0")
    # tools, resources, prompts go here
    if __name__ == "__main__":
    print("notes MCP server starting on stdio", file=sys.stderr) # stderr, never stdout
    mcp.run() # stdio by default
  • Verify: uv run mcp dev server.py opens the Inspector and connects.

  • Goal: Type-safe tools with good descriptions.

  • Do: The docstring becomes the description and the type hints become the schema:

    from typing import Annotated
    from mcp.types import ToolAnnotations
    from 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) and limit (default 5), and running it works.

  • Goal: The model can recover from failures.

  • Do: For expected failures, raise an exception with an actionable message. MCPServer returns 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 TypedDict from a tool to get structured output: MCPServer generates the outputSchema and fills structuredContent for you.

  • Verify: They appear on the Inspector’s Resources and Prompts tabs.

  • Do:
    • Read configuration from environment variables: NOTES_FILE = os.environ.get("NOTES_FILE", str(Path.home() / ".notes-mcp.json")).
    • Use async def for tools that do I/O (HTTP calls, database queries).
    • Add a ctx: Context parameter (from mcp.server.mcpserver import Context) to get logging (await ctx.info(...)), progress reporting (await ctx.report_progress(...)), and elicitation.
  • Verify: It runs with the variable unset and set, and long operations report progress.
  • Do:
    1. Run it in the Inspector: uv run mcp dev server.py.
    2. Run the automated smoke test: node qa/mcp-smoke.mjs python 03-mcp/templates/python-server/server.py from the guide root, or npm run qa:templates.
    3. 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"}).
  • Verify: The smoke test prints PASS, and pytest is green.
  • 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.py registers it with Claude Desktop, and claude mcp add notes -- uv --directory C:/path/to/notes-server run server.py registers it with Claude Code.

  • Verify: Cursor shows the server as green, and a natural-language request calls the tool.

  • Do: Switch the transport with mcp.run(transport="streamable-http") (it serves /mcp on port 8000 by default), put it behind HTTPS and authentication, and deploy it. See Security & production.
  • Verify: npx @modelcontextprotocol/inspector connects to https://your-host/mcp with authentication.