Tutorials Foundations
How to build an MCP server in Python, step by step
Build an MCP server in Python with uv and FastMCP — two tools, a resource and a prompt — test it in the MCP Inspector, then connect Claude Desktop and Claude Code.
Most first-server tutorials build a weather toy you delete an hour later. We’ll build something you might actually keep: a dev-notes server that lets Claude save and search notes about your work — decisions, todos, snippets — in a JSON file on disk. Small enough to finish today, real enough to exercise all three server primitives: tools, a resource, and a prompt.
If those three words aren’t crisp yet, read What is MCP? first — this tutorial assumes the mental model from that page.
Step 1 — Create the project with uv
uv is the fastest way to run Python MCP servers, and — more importantly — the official docs and most client configs assume it. Install it if you haven’t:
# macOS / Linux
curl -LsSf https://astral.sh/uv/install.sh | sh
# Windows (PowerShell)
powershell -ExecutionPolicy ByPass -c "irm https://astral.sh/uv/install.ps1 | iex"
Create the project and add the SDK:
uv init dev-notes
cd dev-notes
uv add "mcp[cli]"
The mcp package is the official Python SDK (stable 1.28.1 as of writing; requires Python ≥3.10). The [cli] extra gives you the mcp command-line tool, including the dev/Inspector integration we’ll use in Step 4.
One naming clarification before we write code, because it confuses everyone: the class we’ll use is called FastMCP, and it lives inside the official mcp SDK — this is “FastMCP 1.0”. There is also a separate third-party framework called FastMCP 2.x (gofastmcp.com) with a much larger feature set. Same name, different packages. Everything below uses the official SDK only. (The SDK’s 2.0 beta renames the class to MCPServer; teach-yourself-later material — stable 1.x is what you want today.)
Step 2 — Write the server skeleton
Create server.py in the project root:
import json
from datetime import datetime, timezone
from pathlib import Path
from mcp.server.fastmcp import FastMCP
mcp = FastMCP("dev-notes")
NOTES_FILE = Path(__file__).parent / "notes.json"
def _load() -> list[dict]:
if NOTES_FILE.exists():
return json.loads(NOTES_FILE.read_text())
return []
def _save(notes: list[dict]) -> None:
NOTES_FILE.write_text(json.dumps(notes, indent=2))
if __name__ == "__main__":
mcp.run()
FastMCP("dev-notes") creates the server; the string is the name clients display. mcp.run() starts it on the stdio transport — remember from the architecture page that stdio means the client launches this script as a subprocess and speaks JSON-RPC over stdin/stdout.
That has one practical consequence worth tattooing somewhere: never print to stdout in a stdio server. A stray print() lands in the middle of the protocol stream and breaks the session. If you need to debug, write to stderr (print("...", file=sys.stderr)).
Step 3 — Add two tools
Tools are functions the model can call. With FastMCP, a tool is just a decorated Python function — the input schema is derived from your type hints, and the docstring becomes the description the model reads. Add these above the __main__ block:
@mcp.tool()
def add_note(text: str, tags: list[str] | None = None) -> str:
"""Save a development note. Use when the user wants to remember
a decision, a todo, or a code snippet for later."""
notes = _load()
note = {
"id": len(notes) + 1,
"text": text,
"tags": tags or [],
"created": datetime.now(timezone.utc).isoformat(),
}
notes.append(note)
_save(notes)
return f"Saved note #{note['id']}."
@mcp.tool()
def search_notes(query: str) -> str:
"""Search saved notes by keyword or tag. Returns matches, newest first."""
q = query.lower()
hits = [
n for n in _load()
if q in n["text"].lower() or any(q == t.lower() for t in n["tags"])
]
if not hits:
return f"No notes matching {query!r}."
hits.reverse()
return "\n".join(
f"#{n['id']} [{', '.join(n['tags'])}] {n['text']}" for n in hits
)
Two things are doing quiet, load-bearing work here. The type hints (text: str, tags: list[str] | None) become the JSON schema the client validates calls against. The docstrings are the only thing the model sees when deciding whether and how to call your tool — write them like API documentation, stating when to use the tool, not just what it does.
Step 4 — Add a resource and a prompt
Not everything should be a tool. Recent notes are just data — make them a resource, which the host can attach as context without a tool-call round trip. And “review my week” is a user-initiated action — make it a prompt the user invokes deliberately:
@mcp.resource("notes://recent")
def recent_notes() -> str:
"""The ten most recent dev notes, as JSON."""
return json.dumps(_load()[-10:], indent=2)
@mcp.prompt()
def weekly_review() -> str:
"""Summarise recent dev notes into wins, open questions, and next actions."""
return (
"Read my recent dev notes from the notes://recent resource. "
"Summarise them into three short sections: wins, open questions, "
"and next actions. Keep it under 200 words."
)
The notes://recent string is a URI — resources are addressed like documents, in whatever scheme you invent. That’s the whole server: about 70 lines, all three primitives.
Step 5 — Test it in the MCP Inspector
Don’t connect a client yet — test the server in isolation first. The SDK’s dev mode launches the MCP Inspector, a browser UI that acts as a bare client:
uv run mcp dev server.py
Your browser opens the Inspector (it needs Node.js, since it runs via npx). Click Connect, then work through:
- Tools tab →
add_note→ add a note like “Chose Streamable HTTP over SSE for the API server” with tagarchitecture→ you should seeSaved note #1. search_noteswith queryarchitecture→ your note comes back.- Resources tab →
notes://recent→ the JSON appears. - Prompts tab →
weekly_review→ the template renders.
If all four work, the server is correct and every problem from here on is configuration, not code. That distinction will save you hours.
Step 6 — Connect it to Claude Desktop
Claude Desktop reads a JSON config file:
- macOS:
~/Library/Application Support/Claude/claude_desktop_config.json - Windows:
%AppData%\Claude\claude_desktop_config.json
Create it if it doesn’t exist (or use Settings → Developer → Edit Config). macOS:
{
"mcpServers": {
"dev-notes": {
"command": "/Users/you/.local/bin/uv",
"args": [
"--directory",
"/Users/you/projects/dev-notes",
"run",
"server.py"
]
}
}
}
Windows (note the doubled backslashes — this is JSON escaping, and forgetting it is the #1 Windows failure):
{
"mcpServers": {
"dev-notes": {
"command": "C:\\Users\\you\\.local\\bin\\uv.exe",
"args": [
"--directory",
"C:\\Users\\you\\projects\\dev-notes",
"run",
"server.py"
]
}
}
}
Two rules, both non-negotiable:
- Absolute paths everywhere — for the command and the project directory. Claude Desktop does not inherit your shell’s PATH or working directory. Find your uv path with
which uv(macOS/Linux) orwhere.exe uv(Windows). - Fully restart Claude Desktop — quit it from the menu bar (macOS) or system tray (Windows). Closing the window only hides it, and the config is read at launch.
After restarting, the tools icon (a sliders symbol) in the chat input lists dev-notes. Try: “Save a note that I decided to use uv for all Python MCP servers, tag it tooling.” Claude will ask permission, call add_note, and confirm.
Step 7 — Connect it to Claude Code
Claude Code registers servers from the command line — no JSON editing:
claude mcp add --transport stdio dev-notes -- \
uv --directory /Users/you/projects/dev-notes run server.py
Everything after -- is the launch command, exactly as you’d type it in a shell. Then verify:
claude mcp list # should show dev-notes
claude mcp get dev-notes
Inside a session, /mcp shows connection status and lets you inspect tools. By default the registration is local scope (you, this project only); add --scope user to make it available in all your projects, or --scope project to write a .mcp.json your whole team can share — the cross-client connection guide covers scopes and every other client’s config format.
Troubleshooting
The client couldn’t find the executable, because GUI apps don’t inherit your shell PATH. Replace spawn uv ENOENT (or spawn npx ENOENT) in the logs"command": "uv" with the absolute path from which uv / where.exe uv. On Windows, if the command is a .cmd shim (like npx), launch it via "command": "cmd", "args": ["/c", "npx", ...] — and remember every backslash in JSON must be \\.
Server loads but tools don’t appear in Claude Desktop
First, restart properly: quit from the menu bar/tray, not the window close button. Then validate the config is legal JSON (a trailing comma silently breaks it — paste it into a JSON validator). Finally check the logs (next entry) — a server that crashes on startup also shows as “no tools”.
The server process died before completing the handshake. Run the exact command from your config manually in a terminal — MCP error -32000: Connection closeduv --directory /path/to/dev-notes run server.py — and read the traceback. Common causes: wrong --directory path, a syntax error in server.py, or a print() to stdout corrupting the protocol stream.
The server is running under the wrong Python environment. The ModuleNotFoundError: No module named 'mcp'uv --directory /abs/path run server.py form fixes this — uv resolves the project’s own virtual environment from that directory. If you used plain "command": "python", you got the system interpreter, which has no mcp installed.
Where are the logs?
Claude Desktop writes per-server logs: macOS ~/Library/Logs/Claude/mcp-server-dev-notes.log (plus mcp.log for general connection events), Windows %AppData%\Claude\logs\. Follow live with tail -f ~/Library/Logs/Claude/mcp-server-*.log. Everything your server writes to stderr lands here — which is exactly why stderr, never stdout, is where debug output belongs.
Next step
Same server, second language: next you’ll build the TypeScript version with the official @modelcontextprotocol/sdk — and meet the stdout trap in its natural habitat, where console.log breaks everything.
Was this guide useful?
Thanks — noted. It shapes what gets written next.
newsletter
One practical MCP guide in your inbox. No news, no hype.
Tutorials and decision frameworks as they ship. Unsubscribe anytime.