TL;DR
FastMCP is the Python library that powers around 70% of MCP servers in the wild, and v3.2.4 (April 14, 2026) makes it the easiest way to expose your own data and code to Claude, Cursor, ChatGPT, and any other MCP client. This tutorial walks through a real server I shipped last week, a notes-and-commits server I plug into Claude Desktop, covering tools, resources, prompts, GitHub OAuth, and the MCP Inspector. By the end you’ll have a Python file you can run with uv run server.py and connect from any MCP client, plus the patterns for moving it from a localhost prototype to a deployable HTTP service with auth.
Why this tutorial exists
I have written, broken, and rewritten three different MCP servers over the past two months. The first one used the official mcp Python SDK directly: too much boilerplate, too many JSON-RPC quirks. The second used FastMCP v2, which was much better, but the v3 GA in February 2026 changed enough of the auth model that the docs I had bookmarked were already stale. The third is the one this post is about: a small server I run on my workstation that lets Claude Desktop search my Markdown notes and pull recent git commits from a handful of repos. Two tools, one resource, one prompt, ~120 lines.
The reason I’m writing this now is that the FastMCP 3.x line is finally settled enough to be worth learning end to end. Versioning, auth, OAuth proxies, OpenAPI passthrough — all the things that were experimental six months ago are now in the stable surface. If you’ve been waiting for “the right time” to build an MCP server in Python, this is it.
I’ll assume you know Python, have used decorators, and have a vague sense of what MCP is (a JSON-RPC protocol Anthropic open-sourced in late 2024 that lets LLMs talk to your tools). I won’t assume you’ve read the spec.
What FastMCP gives you on top of the raw SDK
The Model Context Protocol spec defines messages, transports, and a capability negotiation handshake. The official mcp Python SDK gives you bindings for those messages, and that’s about it. Building a useful server on top of the bare SDK means hand-writing tool schemas, validating arguments, wiring up the lifecycle, and implementing transports yourself.
FastMCP collapses all of that into Python decorators and type hints. You write a function with type-annotated parameters and a docstring, slap @mcp.tool on it, and FastMCP generates the JSON Schema for you, validates inputs, surfaces the docstring to the model, and routes the response. The same pattern works for @mcp.resource (read-only data sources) and @mcp.prompt (reusable prompt templates).
| Feature | Raw mcp SDK | FastMCP 3.x |
|---|---|---|
| Tool definition | Manual schema + handler | @mcp.tool on a typed function |
| Argument validation | You write it | Pydantic, automatic |
| Transports | stdio only out of the box | stdio, HTTP/SSE, streaming HTTP |
| Auth | Not opinionated | JWT verifier, OAuth proxy, full OAuth |
| Testing | Bring your own | Built-in Client + Inspector |
| OpenAPI passthrough | Not included | Generates tools from an OpenAPI spec |
For the rest of this post we’ll use FastMCP. The author Jeremiah Lowin has stated that FastMCP is downloaded over a million times daily and powers roughly 70% of MCP servers across all languages, which tracks with what I see when I read other people’s MCP code on GitHub.
Install and run a 6-line server
I use uv as my Python project manager. If you haven’t switched, my uv vs pip vs Poetry comparison has the why. Skip to pip install fastmcp if you’d rather.
uv init notes-mcp
cd notes-mcp
uv add fastmcp
That pulls in fastmcp==3.2.4 (latest as of writing). Now create server.py:
from fastmcp import FastMCP
mcp = FastMCP("notes-mcp")
@mcp.tool
def add(a: int, b: int) -> int:
"""Add two numbers."""
return a + b
if __name__ == "__main__":
mcp.run()
That’s a complete, runnable MCP server. The default mcp.run() starts in stdio mode, which is what Claude Desktop and most local clients expect. Run it with:
uv run server.py
The process will sit there waiting for JSON-RPC messages on stdin. To poke at it interactively, you want the MCP Inspector, a small web app from Anthropic that connects to a server and lets you call tools, browse resources, and inspect the protocol traffic. Install and launch it with npx:
npx @modelcontextprotocol/inspector uv run server.py
That command starts your server as a subprocess, opens a browser at http://127.0.0.1:6274, and gives you a UI like this:
[Inspector — http://127.0.0.1:6274]
├── Server: notes-mcp (connected via stdio)
├── Tools (1)
│ └── add(a: int, b: int) -> int
│ Add two numbers.
│ ┌──────────────────────────────────┐
│ │ a: 21 │
│ │ b: 21 │
│ │ [Run] │
│ └──────────────────────────────────┘
│ → 42
├── Resources (0)
└── Prompts (0)
If you see your add tool listed and it returns 42 when you click Run, you have a working MCP server. The inspector is the single most useful tool in the FastMCP development loop. Keep a tab open while you’re building.
A real tool: searching notes
A six-line server is fine for a smoke test, but the point of an MCP server is to expose something the model can’t do on its own. For me that’s “search the Markdown notes I’ve been keeping in ~/notes/ since 2019”. Here’s the full implementation, dropped into server.py:
from pathlib import Path
from fastmcp import FastMCP
mcp = FastMCP("notes-mcp")
NOTES_DIR = Path.home() / "notes"
@mcp.tool
def search_notes(query: str, limit: int = 5) -> list[dict]:
"""Search Markdown notes by case-insensitive substring.
Returns up to `limit` matches, each with the file path,
a snippet around the first hit, and the line number.
"""
q = query.lower()
hits: list[dict] = []
for path in NOTES_DIR.rglob("*.md"):
try:
text = path.read_text(encoding="utf-8")
except UnicodeDecodeError:
continue
for i, line in enumerate(text.splitlines(), start=1):
if q in line.lower():
hits.append({
"path": str(path.relative_to(NOTES_DIR)),
"line": i,
"snippet": line.strip()[:200],
})
break
if len(hits) >= limit:
break
return hits
Reload the inspector. The tool now appears with a string query field and an integer limit slider, both inferred from the type hints. The docstring is the description the model sees when it decides whether to call the tool, so write it like you would a function docstring you’d want a junior to read.
A few details about how FastMCP handles this:
- Type hints become JSON Schema.
query: strbecomes{"type": "string"}.limit: int = 5becomes{"type": "integer", "default": 5}.list[dict]returns become structured output. If you want richer schemas (minimum/maximum, enum, regex), useAnnotatedwithFieldfrom Pydantic; FastMCP picks them up. - The docstring is the description. Both for the tool itself and (via Sphinx-style or Google-style param sections) for individual arguments.
- Errors are returned as MCP errors. Raise
ValueErrorfor argument problems and FastMCP wraps it. For arbitrary failures, raisefastmcp.exceptions.ToolError(message)to control what the model sees.
Run it from the inspector with query="kubernetes", limit=3. If you have notes about Kubernetes, they come back. If you don’t, you get []. If you typo the path constant, you get a Python FileNotFoundError propagated as an MCP error, and you’ll spend ten minutes wondering why before remembering you typed ~/note/.
Resources vs tools: when to use which
Tools are functions the model invokes. Resources are read-only chunks of data the client can browse and inject into context without a tool call. The distinction confused me for a while; here’s the one-line version that finally stuck:
If the model needs to ask for it by argument, it’s a tool. If the user (via the client UI) wants to attach it to context, it’s a resource.
Concretely: my search_notes is a tool because the query is dynamic and the model decides when to fire it. But “the contents of README.md from each of my repos” is a resource. Claude Desktop lets the user pick from a tree of resources to attach, and there’s no decision the model needs to make. Here’s a resource that exposes recent commits:
import subprocess
from fastmcp import FastMCP
mcp = FastMCP("notes-mcp")
REPOS = [Path.home() / "code" / r for r in ("blog", "agents", "scratch")]
@mcp.resource("git://recent-commits/{repo}")
def recent_commits(repo: str) -> str:
"""Last 20 commits from `repo` as `hash subject` lines."""
target = next((p for p in REPOS if p.name == repo), None)
if not target:
raise ValueError(f"unknown repo: {repo}")
out = subprocess.run(
["git", "-C", str(target), "log", "--oneline", "-n", "20"],
check=True, capture_output=True, text=True,
)
return out.stdout
The URI template git://recent-commits/{repo} lets the client enumerate variants. In Claude Desktop, this shows up as a list of attachable resources, one per repo, and the model only sees the contents when the user explicitly attaches one. That’s the right shape for “stuff the user might want to bring in”, and the wrong shape for “data the model should query”.
A subtle one: resources can also be static. @mcp.resource("config://server-info") with no template params returns one fixed resource. Use that for things like the server’s own description, version, and capabilities; clients show it in their UI without you doing anything else.
Prompt templates
The third primitive is @mcp.prompt. These are user-invoked templates the client surfaces in its UI (e.g. as slash commands in Claude Desktop). Unlike tools, the model never decides to invoke them. The user does, the client expands the template, and the result is sent as the next message. They’re useful for canned workflows you want to type-trigger.
@mcp.prompt
def commit_summary(repo: str, since: str = "1 week ago") -> str:
"""Summarize what shipped in `repo` since `since`.
Use this when you want a status update on a project.
"""
return (
f"Read the `git://recent-commits/{repo}` resource and write a "
f"3-bullet summary of the last week of commits in {repo}. "
f"Highlight breaking changes, new features, and refactors. "
f"Skip dependency bumps."
)
In Claude Desktop this appears as /commit_summary with two argument fields. Pick a repo, optionally tweak since, and the expanded prompt becomes your next message. The model then calls the resource I defined above (which it has access to) and writes the summary.
The piece that took me a while to internalize: prompts are templates, not tools. They expand to text on the client side, then the result goes through the normal turn loop. A prompt can reference resources and tools by name and rely on the model to wire them together. That’s a pattern worth using deliberately, because it keeps domain knowledge (“which resources to combine for a status update”) on the server, where you can version it, instead of in client-side prompt files.
Connect it to Claude Desktop
For local use, Claude Desktop reads from ~/Library/Application Support/Claude/claude_desktop_config.json on macOS (or %APPDATA%\Claude\claude_desktop_config.json on Windows). The official MCP local-server quickstart has the canonical version. Add an entry under mcpServers:
{
"mcpServers": {
"notes": {
"command": "uv",
"args": ["--directory", "/Users/me/code/notes-mcp", "run", "server.py"]
}
}
}
Restart Claude Desktop. You should see a small hammer icon in the message box; clicking it shows your server with the search_notes tool and git://recent-commits/{repo} resources. Try a message like “search my notes for ‘PostgreSQL connection pool’” and watch Claude pick up the tool and call it.
If nothing appears, the server probably crashed at launch. Claude Desktop’s MCP logs live at ~/Library/Logs/Claude/mcp*.log. tail -f them while restarting. Most failures are “module not found” because uv run is resolving against the wrong project directory; double-check the --directory arg.
Adding GitHub OAuth (for HTTP servers)
stdio is fine for a local server you start from your own machine. Once you want to host the server somewhere and let other people connect, you need HTTP plus auth. FastMCP 3.x has four auth strategies:
JWTVerifier: accept JWTs signed by a known issuer (Auth0, your own IdP). Simplest if you already have an identity layer.- Remote OAuth providers: integrate with providers that support OAuth Dynamic Client Registration, like WorkOS AuthKit and Keycloak (the new Keycloak provider in v3.2.4 lives in this bucket).
- OAuth Proxy: bridge providers that don’t support DCR (GitHub, Google, Azure). FastMCP runs the OAuth dance on behalf of the MCP client.
- Full OAuth implementation: you become the authorization server. Don’t do this unless you really need to.
For most “I want to share this with my team” use cases, the OAuth Proxy is the right pick. Here’s GitHub:
from fastmcp import FastMCP
from fastmcp.server.auth.providers.github import GitHubProvider
auth = GitHubProvider(
client_id="Iv1.abc123...", # from GitHub OAuth App
client_secret="ghp_...", # from GitHub OAuth App
base_url="https://mcp.example.com",
)
mcp = FastMCP(name="notes-mcp", auth=auth)
That’s the full setup. Behind the scenes, FastMCP exposes the OAuth metadata endpoints MCP clients expect (/.well-known/oauth-authorization-server, /authorize, /token), proxies them to GitHub, and validates the resulting access tokens on every tool call. Inside a @mcp.tool, you can read the authenticated user with ctx.auth (where ctx is a Context parameter you opt into by name).
The thing to know about v3.2.4 specifically is that token audience validation got stricter. It now binds tokens to the audience per RFC 8707, and the v3.2.1, v3.2.2, and v3.2.3 release notes show a cluster of fixes for Azure and Cognito audience handling. If you’re upgrading from an older 3.x version and OAuth suddenly stops working, that’s almost certainly what changed.
To run over HTTP instead of stdio, change the bottom of server.py:
if __name__ == "__main__":
mcp.run(transport="http", host="0.0.0.0", port=8000)
Now curl http://localhost:8000/.well-known/oauth-authorization-server returns the OAuth metadata, and an MCP client pointed at https://mcp.example.com will go through the GitHub login flow before getting access to your tools.
Testing without the inspector
The inspector is great for hand-rolled exploration. For tests in CI you want code. FastMCP ships a Client that talks to a server in-process: no subprocess, no transport, just direct calls. That makes it perfect for pytest:
import pytest
from fastmcp import Client
from server import mcp # your server module
@pytest.mark.asyncio
async def test_search_notes_returns_hits(tmp_path, monkeypatch):
notes = tmp_path / "notes"
notes.mkdir()
(notes / "k8s.md").write_text("Setting up a kubernetes cluster on Hetzner.")
monkeypatch.setattr("server.NOTES_DIR", notes)
async with Client(mcp) as client:
result = await client.call_tool("search_notes", {"query": "kubernetes"})
assert len(result.data) == 1
assert result.data[0]["path"] == "k8s.md"
assert "kubernetes" in result.data[0]["snippet"].lower()
Two things to flag. First, Client(mcp) (passing the server object directly) uses an in-memory transport that skips serialization entirely. That’s faster and gives you better stack traces when assertions fail. Second, result.data is the parsed Python object FastMCP reconstructs from the MCP wire format. For tools returning list[dict], you get the list of dicts back; for tools returning a single string, result.data is the string. (result.content gets you the raw MCP Content blocks if you need them.)
I run these tests in CI with the pytest-asyncio plugin and they catch the regressions you’d expect: argument schema drift, returns that no longer parse, accidentally requiring an env var that’s only set on my workstation.
Production gotchas I tripped over
Versioning. The decorator family changed across major versions. v2 used @mcp.tool() (parens). v3 supports both @mcp.tool and @mcp.tool(), but a chunk of the v2 packages still use parens. I had a pyproject.toml constraint of fastmcp>=2,<4 for a while and got stuck on a lockfile that pulled v2.x on one machine and v3.x on another. Pin to >=3.2.
stdio vs HTTP transport defaults. mcp.run() defaults to stdio. If you’re deploying to Fly.io and getting connection refused, you forgot the transport="http" argument. Fastmcp doesn’t auto-detect from environment; it’ll happily sit on stdin in a container and never bind a port.
Schema versioning for tools. v3.2 added version parameters on tools, resources, and prompts. If a client caches your tool schema and you change the signature, bumping version="2" tells well-behaved clients to refetch. Most clients don’t care yet, but Claude Desktop does.
Background tasks and auth scope. v3.2.4’s release notes mention that background tasks now run in the authorization context of the request that scheduled them, not the MCP session. If you have a fastmcp[tasks] setup that worked on 3.2.0 and broke on 3.2.4, this is the change.
Subprocess hangs in stdio mode. If a tool calls subprocess.run without capture_output=True, the child’s stdout collides with the MCP transport’s stdin/stdout, and the server hangs. Always capture, always set text=True. Ask me how I know.
Where this slots into the broader agent stack
If you’re already using Claude Code subagents for parallel coding tasks, MCP servers are how you give those agents access to your private data and APIs. If you’ve been comparing Claude Code vs Codex CLI for daily use, both consume MCP servers; the same server I built for Claude Desktop works in Cursor and ChatGPT desktop with zero changes. And if you’re tracking the broader agentic memory research, MCP resources are the practical layer where “memory” tends to live in real deployments, exposed as readable chunks the model can attach to context on demand.
The reason MCP wins over the previous wave of plugin formats is that it’s transport-agnostic and client-agnostic by design. ChatGPT plugins were OpenAI-only. LangChain tools were Python-runtime-only. MCP is JSON-RPC over whatever transport you want, with a tiny spec, and every major lab adopted it within roughly a year of Anthropic open-sourcing it. That’s why FastMCP went from a side project to claiming a majority of the world’s MCP servers (per its own usage stats) in the same window.
FAQ
How to build an MCP server in Python?
Install FastMCP (uv add fastmcp or pip install fastmcp), create a FastMCP("name") instance, decorate Python functions with @mcp.tool for actions, @mcp.resource("uri-template") for browseable data, and @mcp.prompt for user-invoked templates, then call mcp.run() at the bottom of the file. Run it with uv run server.py or wire it into a client config.
What is FastMCP?
FastMCP is a Python framework for building servers and clients that speak the Model Context Protocol. It wraps the official mcp SDK with decorator-based tool/resource/prompt registration, automatic schema generation from type hints, multiple transports (stdio, HTTP, SSE), and built-in auth (JWT, OAuth, OAuth proxy). The project’s own stats put it at around 70% of MCP servers in the wild.
How does FastMCP differ from the standard MCP SDK?
The official mcp Python SDK is a low-level binding for the protocol. FastMCP is a higher-level framework that uses Python decorators and Pydantic to remove the boilerplate. You write a typed function with a docstring, and FastMCP generates the schema, validates inputs, handles the protocol lifecycle, and supports multiple transports without you wiring them up.
Can I use FastMCP with Claude Desktop?
Yes. Add an entry under mcpServers in ~/Library/Application Support/Claude/claude_desktop_config.json (macOS) or %APPDATA%\Claude\claude_desktop_config.json (Windows) pointing to your uv run server.py command, restart Claude, and your tools, resources, and prompts appear in the message box.
Does FastMCP support OAuth?
Yes. FastMCP 3.x ships four auth strategies: a JWTVerifier for accepting tokens from a known issuer, integration with remote OAuth providers that support Dynamic Client Registration (e.g. WorkOS AuthKit), an OAuth Proxy for providers that don’t (GitHub, Google, Azure, Keycloak), and a full OAuth implementation if you want to be the authorization server yourself. The proxy pattern is what most teams will reach for.
Sources
- FastMCP on GitHub (jlowin/fastmcp): source repo, README, releases
- FastMCP documentation (gofastmcp.com): official docs and guides
- Model Context Protocol specification: the protocol FastMCP implements
- Official
mcpPython SDK: the low-level binding FastMCP wraps - FastMCP v3.2.4 release notes: RFC 8707 audience binding, Keycloak provider, security hardening
- RFC 8707 (Resource Indicators for OAuth 2.0): the spec FastMCP now enforces for token audience validation
Bottom line
If you’ve been waiting for “the right time” to learn MCP server development in Python, it’s now and the tool is FastMCP 3.2. The decorator API is small, the auth story is sane, the inspector makes the dev loop tight, and the same server you write for Claude Desktop runs in Cursor and ChatGPT without changes. Start with the six-line example, get a real tool returning data from your filesystem inside ten minutes, and only reach for HTTP transport and OAuth when you want to share it with a team. The hard part is deciding what data is worth exposing to a model in the first place; the framework gets out of your way.