TL;DR
This FastMCP 4 tutorial builds a working MCP server in Python: a small notes-and-commits server that Claude Desktop, Claude Code or Cursor can call, with tools, a resource, a prompt, in-memory pytest tests, GitHub OAuth over HTTP, and the MCP Inspector. FastMCP is the framework its author says powers about 70% of MCP servers across all languages. Version 4 (current release 4.0.10, September 25, 2026) is built on the 2026-07-28 MCP spec, so requests are sessionless by default. The decorator API carried over from 3.x unchanged. Session state, sampling, ctx.elicit() and background tasks all changed, and the gotchas section lists what breaks when you upgrade.
FastMCP version details last verified October 4, 2026: current release v4.0.10. The repository moved from jlowin/fastmcp to PrefectHQ/fastmcp, and old GitHub links redirect.
What changed for FastMCP in 2026
The official mcp SDK shipped its own class called FastMCP until SDK 2.0 in July 2026 (the 1.x maintenance line still has it), and that class is a different library from the standalone FastMCP framework this tutorial uses. Most tutorials online were also written against FastMCP 2.x or 3.x, and FastMCP 4 removed several APIs they rely on.
The server in this post lets Claude Desktop search a folder of Markdown notes and pull recent git commits from a handful of repos: one search tool, one resource and one prompt locally, plus a whoami tool once OAuth is on.
FastMCP 4.0 went GA on August 31, 2026, after five betas, and it was built for the new protocol. Per the v4.0.0 release notes, requests are now self-contained, so any replica behind an ordinary load balancer can answer them, and one deployment negotiates the protocol version per connection so older clients keep working. The same notes say most FastMCP 3 applications upgrade without code changes. The code below targets the 4.0.x line.
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.
FastMCP vs the official MCP Python SDK
The Model Context Protocol spec defines messages, transports, and how client and server agree on capabilities. The official mcp Python SDK implements all of it and has its own decorator-based server class. FastMCP 1.0 was contributed to the SDK in 2024 and lived there as mcp.server.fastmcp.FastMCP; SDK 2.0 renamed that class MCPServer on July 28, 2026 (migration guide). A tutorial that imports FastMCP from mcp.server.fastmcp is using the SDK’s class; the standalone framework imports from fastmcp.
The standalone FastMCP (pip install fastmcp, led by Jeremiah Lowin at Prefect, who has said it’s downloaded a million times a day) kept going after that split and now runs on top of SDK v2. At the decorator level both libraries work the same way: you write a function with type-annotated parameters and a docstring, put @mcp.tool on it, and the library generates the JSON Schema, validates inputs, shows 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). Most of the difference is in auth, plus OpenAPI import:
| Feature | Official mcp SDK 2.x (MCPServer) | FastMCP 4.x |
|---|---|---|
| Tool definition | @mcp.tool() on a typed function | @mcp.tool on a typed function |
| Transports | stdio, Streamable HTTP, SSE | stdio, Streamable HTTP, legacy SSE |
| Auth | Resource server only: you implement a TokenVerifier | Token verifiers, OAuth proxy for GitHub/Google/Azure, remote OAuth, a full OAuth server |
| Testing | In-memory Client(server) | In-memory Client(mcp) plus the Inspector |
| OpenAPI | Not included | Generates tools from an OpenAPI spec |
For a single local server, the SDK’s MCPServer is enough. The auth row is why this post uses FastMCP: the SDK’s authorization docs leave token verification to you and ship no GitHub or Google integration.
Install FastMCP and run a minimal server
This tutorial uses uv as the Python project manager. If you haven’t switched, the 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 the latest 4.x release (4.0.10, current as of October 4, 2026). 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 file is a complete MCP server: mcp.run() starts in stdio mode by default, 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 developer tool from the MCP project that connects to a server and lets you call tools, browse resources, and inspect the protocol traffic. Launch it with npx:
npx @modelcontextprotocol/inspector uv run server.py
That command starts your server as a subprocess and serves the Inspector’s web UI locally; the terminal prints the address to open. Connect, find the add tool, and you’ll see two integer fields generated from the type hints, with the docstring as the description. Enter 21 and 21, run it, and 42 comes back. The Inspector also has CLI and terminal-UI modes (--cli, --tui), which help when the server runs on a box you reach over SSH. Keep it open while you build; every change below is easiest to check there.
A real tool: searching notes
An MCP server earns its place by exposing something the model can’t do on its own, which here means searching the Markdown notes in ~/notes/. 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 field, 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.
The limit default and the 200-character snippet cap decide how much text lands in the model’s context on every call. In long agent runs, stale tool output crowds the window first: the context engineering explainer covers a benchmark where keeping only the last five tool interactions beat keeping the full history on accuracy, at about a third of the tokens. Keep limit small and raise it only if the model keeps re-querying.
FastMCP turns the type hints into JSON Schema: query: str becomes {"type": "string"}, limit: int = 5 becomes {"type": "integer", "default": 5}, and the list[dict] return becomes structured output. For richer schemas (minimum/maximum, enum, regex), use Annotated with Pydantic’s Field and FastMCP picks them up (if Pydantic’s serialization overhead worries you, msgspec vs Pydantic measures it). The docstring becomes the tool description, and Sphinx-style or Google-style param sections describe individual arguments. Errors come back as MCP errors: raise ValueError for argument problems and FastMCP wraps it, or raise fastmcp.exceptions.ToolError(message) to control exactly 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 []. A typo in the path constant fails the same way, silently, which is worse than an error. I ran the loop above as plain Python with NOTES_DIR = Path.home() / "note" (missing the “s”):
$ python3 rglob_typo.py
3.12.3
False
[]
That’s the Python version, NOTES_DIR.exists(), and the tool’s result. Path.rglob() on a directory that doesn’t exist yields nothing instead of raising, so the tool reports “no matches” and the model tells the user their notes don’t mention Kubernetes. Add a guard at the top of the function so the mistake surfaces:
if not NOTES_DIR.is_dir():
raise ToolError(f"notes directory not found: {NOTES_DIR}")
with from fastmcp.exceptions import ToolError at the top of the file.
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.
Rule of thumb: if the model asks for it with an argument, make it a tool; if the user attaches it from the client UI, make it a resource.
search_notes is a tool because the query is dynamic and the model decides when to fire it. But “the last 20 commits in each repo” is a resource. Claude Desktop lets the user pick from each server’s list 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 pathlib import Path
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 {repo} placeholder makes this a resource template: clients show it as one entry, and the user fills in {repo} (FastMCP 4 also supports argument completion for template inputs, so a client that asks can be offered the valid names). If you’d rather have one ready-made entry per repo in the client’s list, register static resources in a loop instead. Either way, resources are meant for the user to attach, and whether the model can read one on its own depends on the client. Since 4.0, FastMCP also screens templated resource parameters for .. segments and absolute paths by default, so a crafted {repo} value can’t walk out of the directory you meant to expose. The lookup against REPOS above is still worth keeping as a second check.
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 (Claude Code, for example, turns MCP prompts into slash commands). Unlike tools, prompts are never invoked by the model. The user picks one, the server fills in the template, and the client sends the result as the user’s next message. They’re useful for canned workflows you’d otherwise retype.
@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 commits in {repo} since {since}. "
f"Highlight breaking changes, new features, and refactors. "
f"Skip dependency bumps."
)
In Claude Code’s / menu this shows up as /notes:commit_summary (MCP), and typing /mcp__notes__commit_summary runs it too, with repo and since as arguments; other clients list prompts in their attach or command menus. Claude Code splits prompt arguments on whitespace, so pass a one-token since such as 2026-09-01 rather than “2 weeks ago”, or leave it out and take the default. The filled-in prompt then lands in the conversation. The prompt names the git://recent-commits/{repo} resource, so attach that resource in the same message; clients differ in whether the model can read resources the user hasn’t attached.
The server renders the prompt when the client calls prompts/get, and the client sends the resulting text through the normal turn loop. A prompt can reference resources and tools by name and rely on the model to wire them together. Doing this on purpose keeps domain knowledge (“which resources to combine for a status update”) on the server, versioned with the code.
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 quickest way to open it is Settings → Developer → Edit Config, which creates the file if it doesn’t exist. 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"]
}
}
}
Quit Claude Desktop completely and start it again. Current builds list local servers under connectors: click the “Add files, connectors, and more” button at the bottom left of the message box, hover over Connectors, choose Manage connectors, and select notes to see the search_notes tool. Try a message like “search my notes for ‘PostgreSQL connection pool’” and watch Claude pick up the tool and call it.
If the server isn’t listed, it probably crashed at launch. Claude Desktop’s MCP logs live in ~/Library/Logs/Claude/ on macOS (%APPDATA%\Claude\logs on Windows): mcp.log has connection errors, and mcp-server-notes.log has your server’s stderr. Run tail -n 20 -f ~/Library/Logs/Claude/mcp*.log while restarting. A “module not found” error usually means uv run resolved against the wrong project directory, so double-check the --directory arg and use absolute paths. Invalid JSON is the other usual suspect: run the file through python3 -m json.tool before restarting.
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’s auth docs now describe five strategies:
- Token verification (
JWTVerifierand the otherTokenVerifierclasses) validates tokens issued by an identity layer you already run, such as your own IdP or any other JWT issuer. It has no OAuth endpoints of its own. - Remote OAuth (
RemoteAuthProvider) integrates with providers that support OAuth Dynamic Client Registration, like WorkOS AuthKit and Keycloak. - The OAuth Proxy (
OAuthProxy) bridges providers that don’t support DCR (GitHub, Google, Azure). FastMCP runs the OAuth dance on behalf of the MCP client. - Full OAuth (
OAuthProvider) makes you the authorization server. Don’t do this unless you really need to. MultiAuthcomposes several of the above into one auth provider.
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="Ov23li...", # from GitHub OAuth App
client_secret="your-github-client-secret", # from GitHub OAuth App
base_url="https://mcp.example.com",
)
mcp = FastMCP(name="notes-mcp", auth=auth)
In the GitHub OAuth App, register https://mcp.example.com/auth/callback as the callback URL (the default redirect_path). FastMCP then 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 tool, get_access_token() returns the token for the current request, or None if there isn’t one:
from fastmcp.server.dependencies import get_access_token
@mcp.tool
async def whoami() -> dict:
"""Report who is calling this server."""
token = get_access_token()
if token is None:
return {"authenticated": False}
return {"authenticated": True, "user": token.claims.get("sub")}
Token audience handling moved during the 3.2.x line: 3.2.1 and 3.2.2 fixed audience checks for the Azure and Cognito providers, and 3.2.4 made the AuthKit provider bind tokens to the resource URL per RFC 8707. If you use one of those providers and tokens start failing after a 3.x bump, check the audience settings first; the GitHub proxy used here was never part of those fixes.
The bigger 4.0 change hits deployments that set issuer_url separately from base_url. The token’s iss claim now carries the issuer, and since issuer_url defaults to base_url, the setup above keeps working. That invalidates existing refresh tokens, so every client re-authorizes once after the upgrade (upgrade guide).
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)
The MCP endpoint is then http://localhost:8000/mcp, served over Streamable HTTP. curl http://localhost:8000/.well-known/oauth-authorization-server returns the OAuth metadata, and an MCP client pointed at https://mcp.example.com/mcp will go through the GitHub login flow before getting access to your tools. FastMCP still accepts transport="sse", but its docs call SSE legacy and recommend HTTP for all new projects. Streamable HTTP replaced HTTP+SSE in the 2025-03-26 MCP spec, and the 2026-07-28 revision formally lists the HTTP+SSE transport as deprecated under its new lifecycle policy.
Testing without the Inspector
The Inspector is for manual checks. For CI, FastMCP ships a Client that talks to a server in the same process, with direct calls instead of a subprocess or a network hop, so it plugs straight into 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()
Client(mcp) (passing the server object directly) uses an in-memory transport, so the test needs no running server and no port. 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.)
Run these tests in CI with the pytest-asyncio plugin. 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 your workstation.
One 4.0 change affects older test suites: passing a bare string like Client("server.py") to spawn a script is deprecated, and FastMCP 5 will remove it. Pass Path("server.py") or an explicit StdioTransport instead. Passing the server object, as above, is unaffected.
Production gotchas
Pin the major version. FastMCP 4 deleted the APIs 3.x had deprecated, among them as_proxy() (now create_proxy()), import_server(), mount(prefix=...) (now namespace=), and the serializer= and exclude_args= tool arguments (return a ToolResult or inject hidden parameters with Depends() instead). A loose constraint like fastmcp>=3 resolves to 4.x on a fresh machine and can break an older server there while it keeps working on yours. Pin fastmcp>=4,<5, or >=3.2,<4 to stay on 3.x until you’ve read the upgrade guide. Both @mcp.tool and the older @mcp.tool() spelling still work.
Sessionless by default. FastMCP 4 speaks the sessionless 2026-07-28 protocol with any client that supports it. Code that assumed a session breaks in quiet ways: ctx.set_state() no longer persists between requests, Middleware.on_initialize never runs because there’s no initialize handshake, and ctx.elicit() raises instead of waiting for the user. The ctx.sample() and ctx.list_roots() methods are gone; the spec deprecates sampling and roots, and FastMCP only reaches them through the same guard pattern it uses for user input. If a tool needs to ask the user something mid-call, the 4.x way is a guard tool: it returns an InputRequiredResult and runs again once the client supplies the answer.
stdio vs HTTP transport defaults. A container that refuses connections is usually still running on stdio. mcp.run() uses stdio unless you pass transport="http" or set FASTMCP_TRANSPORT=http, and nothing about running in a container changes that, so the server sits on stdin and never binds a port.
httpx became httpx2. FastMCP 4 replaced httpx with the httpx2 fork across its HTTP stack. Any except httpx.HTTPError around client calls or in a custom auth handler is now dead code, because the exceptions come from httpx2. Grep for httpx. before you upgrade.
Background tasks moved to an extension. Tasks now run through the io.modelcontextprotocol/tasks extension and ship separately: install fastmcp[tasks]>=4, import TasksExtension from the separate fastmcp_tasks package, and call mcp.add_extension(TasksExtension()) at startup, or a server with an @mcp.tool(task=True) tool won’t start. The 3.2.4 change still applies too: per its release notes, a background task runs in the authorization context of the request that scheduled it.
Subprocess output in stdio mode. If a tool calls subprocess.run without capture_output=True, the child inherits the server’s stdout, which is the MCP transport, and its output corrupts the JSON-RPC stream. Pass capture_output=True (plus text=True if you want str back), or send the child’s stdout to subprocess.DEVNULL.
Where this slots into the broader agent stack
MCP servers are how you give Claude Code subagents access to your private data and APIs. Claude Code and Codex CLI both consume them, and the model behind those CLIs is a separate choice, which the GPT-5.4 vs Claude Opus 4.7 vs Gemini 3.1 Pro comparison covered as of May 2026.
The notes server is a small case of a common pattern: documents exposed as resources the user attaches on demand, which is also how ideas from agentic memory research tend to land in real deployments. If your notes start life as company PDFs, convert them to Markdown first; MarkItDown vs Docling vs Marker compares the converters. For a much larger server to learn from, Chrome DevTools MCP exposes more than 40 tools for inspecting network traffic, reading console logs and profiling pages. And if your agent runs on an orchestration framework instead of a coding CLI, MCP tools come in through that framework, which LangGraph vs CrewAI vs OpenAI Agents SDK compares.
MCP beat the previous wave of plugin formats because it’s transport-agnostic and client-agnostic by design: ChatGPT plugins were tied to OpenAI and LangChain tools to a Python runtime, while MCP is JSON-RPC over whatever transport you want, with a small spec. OpenAI, Google DeepMind and Microsoft all announced MCP support in the first half of 2025, and in December 2025 Anthropic handed it to the Linux Foundation’s Agentic AI Foundation. The local stdio server you build here works in Claude Desktop, Claude Code and Cursor without changes; ChatGPT connects to remote MCP servers, so it needs the HTTP version from the OAuth section. Gemini CLI wires up MCP servers too, though Google stopped serving it to individual Google-account sign-ins in June 2026 and moved those users to Antigravity CLI. For where MCP sits next to A2A, ACP and WebMCP, and everything the 2026-07-28 spec changed, see the AI agent protocols guide.
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 runs on the official mcp SDK and adds decorator-based tool/resource/prompt registration, automatic schema generation from type hints, multiple transports (stdio, Streamable HTTP, legacy SSE), built-in auth (token verification, OAuth, OAuth proxy), proxies, and OpenAPI import. Its author, Jeremiah Lowin, puts it at about 70% of MCP servers across all languages.
How does FastMCP differ from the standard MCP SDK?
The official mcp SDK has its own decorator-based server class, MCPServer, which was called FastMCP until SDK 2.0 renamed it in July 2026. It covers the protocol, transports and in-memory testing, but for auth it only verifies tokens you validate yourself. The standalone FastMCP framework (pip install fastmcp) adds ready-made OAuth providers for GitHub, Google and Azure, server composition and proxies, and OpenAPI-to-tools generation. For one local server either works; for a hosted server with logins, FastMCP saves the most code.
Is FastMCP 4 compatible with FastMCP 3 code?
Mostly. The release notes say most FastMCP 3 applications upgrade without code changes, and the decorators in this tutorial are unchanged. What breaks: APIs 3.x had deprecated are gone (including the serializer= and exclude_args= tool arguments), session state and ctx.elicit() behave differently on the sessionless protocol, the ctx.sample() and ctx.list_roots() methods were removed, background tasks need the fastmcp[tasks] extra plus an explicit TasksExtension, and httpx exceptions became httpx2 exceptions.
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, quit and restart Claude, and the server shows up under Connectors in the “Add files, connectors, and more” menu.
Does FastMCP support OAuth?
Yes. FastMCP’s docs describe five auth strategies: token verification for tokens from a known issuer, remote OAuth providers that support Dynamic Client Registration (e.g. WorkOS AuthKit and Keycloak), an OAuth Proxy for providers that don’t (GitHub, Google, Azure), a full OAuth server if you want to be the authorization server yourself, and MultiAuth to combine them. For a team that already signs in with GitHub or Google, the proxy is the simplest fit.
Sources
- FastMCP on GitHub (PrefectHQ/fastmcp): source repo, README, releases
- FastMCP documentation (gofastmcp.com): official docs and guides
- FastMCP v4.0.0 release notes: the 4.0 GA (Aug 31, 2026) and its breaking changes
- Upgrading from FastMCP 3: the full list of 4.0 removals and behavior changes
- FastMCP v3.2.4 release notes: AuthKit audience binding (RFC 8707), Keycloak provider, background tasks scoped to the request’s authorization
- Model Context Protocol specification and the 2026-07-28 changelog: the protocol FastMCP implements and its latest revision
- Official
mcpPython SDK and its v2 migration guide: the SDK FastMCP builds on, and theFastMCPtoMCPServerrename - Connect to local MCP servers (modelcontextprotocol.io): Claude Desktop config, connectors menu and log locations
- RFC 8707 (Resource Indicators for OAuth 2.0): the resource-indicator spec behind token audience binding
Bottom line
FastMCP 4 is the version to start a new Python MCP server on, and the one to pin: fastmcp>=4,<5. The decorator surface is the same small one it was in 3.x, and the sessionless protocol underneath makes a hosted server easier to run behind a load balancer. Upgrading a 3.x server takes more work: session state, sampling, elicitation and background tasks all changed, so read the gotchas list first. Start with the minimal example, add the notes tool with its directory guard, and reach for HTTP and OAuth only when someone else needs to connect. Before pointing NOTES_DIR or REPOS at anything, check what’s in it, because whatever the tool can read, the model can repeat back.