Python MCP Servers
|
This content was generated with the assistance of AI and should be verified against the official documentation of each tool and framework before being relied on in production. This section’s bibliography lists the reference material consulted while preparing these pages. |
This page was written against the 2026-07-28 revision of
the specification, the Python mcp SDK 2.2.0 and the standalone fastmcp 4.0.10, as checked on 2026-09-29 (see the
Model Context Protocol baseline). Everything runs on Python 3.12; mcp itself needs Python 3.10
or newer, so the Python 3.9 that older macOS releases ship is too old. Every listing on this page was run: the MCPServer
build passed the shared Inspector contract script over stdio and over Streamable HTTP, and the FastMCP 4 build passed
it over stdio (the script is on Testing & Debugging).
This is the first of three builds of the same docs-assistant server; the Java one is on
Spring Boot MCP Servers and the TypeScript one on
Node.js MCP Servers. Putting the Python server inside an existing web application is the
subject of MCP inside FastAPI. The primitives themselves — what a tool result
or an elicitation looks like on the wire — are on Tools, Resources, Prompts & Elicitation.
Three Python packages, one name
"FastMCP" names more than one thing in Python, which is the source of most confusion when a tutorial and a pip install
disagree. There are three distinct packages:
| Package | Entry point | What it is |
|---|---|---|
|
|
The reference Python SDK maintained in the Model Context Protocol organisation. |
|
|
A separate, actively developed framework that originated the high-level API the official SDK adopted in 2024, then kept growing: providers, composition and proxying, more authentication integrations, a client library and apps. It has its own release cycle and its own documentation at gofastmcp.com. |
|
|
A small library that generates MCP tools from a FastAPI application’s OpenAPI document. It is built on the v1
SDK ( |
The rename matters when you read older material. Anything written before the v2 SDK that says
from mcp.server.fastmcp import FastMCP refers to the class that is now MCPServer; that import path no longer exists in
mcp 2.x and fails with ModuleNotFoundError: No module named 'mcp.server.fastmcp'. The standalone fastmcp package
did not change name, so from fastmcp import FastMCP is still correct — and is a different class with a different
feature set. The rest of this page builds the server with MCPServer first, because it is the smallest dependency and
is what the official documentation teaches, and then shows the same server in FastMCP 4.
Setting up the project
Create a project with uv and add the SDK. The cli extra brings in the mcp command used for
the development loop below:
uv init docs-assistant --python 3.12
cd docs-assistant
uv add "mcp[cli]==2.2.*"
The server needs no other dependency. The docs-assistant data lives behind a small module, store.py, so the same
file serves every example on these pages. This is the stubbed in-memory version that the listings ran against; a
production build replaces search() and read() with queries against the pgvector store described on
PostgreSQL & pgvector and keeps every signature:
"""Stubbed in-memory documentation index shared by the docs-assistant examples."""
from dataclasses import dataclass
@dataclass(frozen=True)
class Page:
component: str
page: str
title: str
text: str
PAGES = [
Page("algebra", "matrices", "Matrices", "Matrix decompositions such as LU, QR and SVD solve linear systems."),
Page("algebra", "wrappers", "Wrappers", "Wrapper classes hide the decomposition details behind a simple solver."),
Page("units", "conversion", "Unit conversion", "Convert lengths, masses and times between unit systems."),
Page("statistics", "gaussian", "Gaussian distributions", "Normal distributions, means and covariance matrices."),
]
COMPONENTS = sorted({p.component for p in PAGES})
def search(query: str, component: str, top_k: int = 3) -> list[dict]:
words = {w.lower() for w in query.split()}
hits = []
for p in PAGES:
if p.component != component:
continue
haystack = f"{p.title} {p.text}".lower()
score = sum(1 for w in words if w in haystack)
if score:
hits.append({"component": p.component, "page": p.page, "title": p.title,
"score": score, "snippet": p.text})
hits.sort(key=lambda h: (-h["score"], h["page"]))
return hits[:top_k]
def read(component: str, page: str) -> str:
for p in PAGES:
if p.component == component and p.page == page:
return f"= {p.title}\n\n{p.text}\n"
raise KeyError(f"unknown page {component}/{page}")
The docs-assistant server with MCPServer
Here is the complete server. It exposes the three primitives the running example needs — one tool, one resource template, one prompt — and asks the user which component to search when the caller did not say. The sections after the listing take it apart.
mcp 2.2.0)"""docs-assistant MCP server, Python (mcp v2 MCPServer). Stdio or Streamable HTTP."""
import sys
from typing import Annotated, Literal
from pydantic import BaseModel, Field
from mcp.server import MCPServer
from mcp.server.mcpserver import Elicit, Resolve
from mcp.types import ToolAnnotations
import store
mcp = MCPServer("docs-assistant", version="1.0.0")
class Hit(BaseModel):
component: str
page: str
title: str
score: int
snippet: str
class SearchResult(BaseModel):
query: str
component: str
hits: list[Hit]
class ComponentChoice(BaseModel):
component: Literal["algebra", "units", "statistics"] = Field(description="Which component to search")
async def choose_component(component: str | None) -> ComponentChoice | Elicit[ComponentChoice]:
if component:
return ComponentChoice(component=component) # type: ignore[arg-type]
return Elicit("Which component should I search?", ComponentChoice)
@mcp.tool(annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=False))
async def search_docs(
query: str,
choice: Annotated[ComponentChoice, Resolve(choose_component)],
component: str | None = None,
top_k: int = 3,
) -> SearchResult:
"""Search the documentation of one component and return the best matching pages."""
hits = store.search(query, choice.component, top_k)
return SearchResult(query=query, component=choice.component, hits=[Hit(**h) for h in hits])
@mcp.resource("docs://{component}/{page}", mime_type="text/asciidoc")
def read_page(component: str, page: str) -> str:
"""The AsciiDoc source of one documentation page."""
return store.read(component, page)
@mcp.prompt()
def answer_with_citations(question: str, component: str = "algebra") -> str:
"""Instruct the model to answer from the docs and cite page URIs."""
return (f"Answer the question using only search_docs results for component '{component}'. "
f"Cite every claim as docs://{component}/<page>.\n\nQuestion: {question}")
if __name__ == "__main__":
transport = sys.argv[1] if len(sys.argv) > 1 else "stdio"
mcp.run(transport) # "stdio" or "streamable-http"
The whole server is one MCPServer instance and three decorated functions. Names, descriptions and schemas all come
from ordinary Python: the function name becomes the tool, resource or prompt name, the docstring becomes its
description, and the type hints become the schemas. Nothing else needs to be declared. See the
Python SDK documentation for the full guide the sections below summarise.
Tools and typed returns
@mcp.tool() registers a function as a tool. Its parameters become the inputSchema (JSON Schema 2020-12), with
defaults and optionality taken from the signature. The interesting part is the return annotation: a function that
returns a Pydantic model, as search_docs does with SearchResult, gets an outputSchema derived from that model and
returns its value as structuredContent. The SDK also adds the same JSON as a text block, which is what a client that
only understands unstructured content reads. This is the tool as tools/list reports it, captured from the running
server (abridged to the fields the annotations produced):
{
"name": "search_docs",
"description": "Search the documentation of one component and return the best matching pages.",
"annotations": { "readOnlyHint": true, "openWorldHint": false },
"inputSchema": {
"type": "object",
"properties": {
"query": { "title": "Query", "type": "string" },
"component": { "anyOf": [{ "type": "string" }, { "type": "null" }], "default": null, "title": "Component" },
"top_k": { "default": 3, "title": "Top K", "type": "integer" }
},
"required": ["query"],
"title": "search_docsArguments"
},
"outputSchema": { "type": "object", "required": ["query", "component", "hits"], "...": "..." }
}
Note that choice, the resolver-provided parameter, does not appear in the input schema: it is not something the
caller supplies. A call with {"query": "matrix decomposition", "component": "algebra", "top_k": 2} returns the typed
result, so a client can rely on the shape without parsing text:
{
"structuredContent": {
"query": "matrix decomposition",
"component": "algebra",
"hits": [
{ "component": "algebra", "page": "matrices", "title": "Matrices", "score": 2,
"snippet": "Matrix decompositions such as LU, QR and SVD solve linear systems." },
{ "component": "algebra", "page": "wrappers", "title": "Wrappers", "score": 1,
"snippet": "Wrapper classes hide the decomposition details behind a simple solver." }
]
},
"isError": false
}
Three details are worth knowing. First, the ToolAnnotations on the decorator are the readOnlyHint and
openWorldHint hints of the specification; they tell a host that this tool only reads and stays inside a closed set
of data, and they are untrusted hints, never a security control (see
Tools, Resources, Prompts & Elicitation). Second, a def handler (as opposed to
async def) runs on a worker thread in v2, so a blocking database driver does not stall other requests; an
async def handler runs on the event loop and must not block. Third, an unknown component is a tool execution error — an isError: true result — not a crash and not a protocol error; the model reads the message and can retry. The contract
script’s "unknown component fails" step exercises exactly that.
Resources
@mcp.resource("docs://{component}/{page}") registers a resource template: a URI with variables that the SDK maps to
the function’s parameters by name. A client calls resources/read with a concrete URI such as
docs://algebra/matrices and the function returns the page’s AsciiDoc source. mime_type="text/asciidoc" is
advertised on the template. A URI without variables registers a fixed resource instead. The v2 SDK checks the extracted
values against a default ResourceSecurity policy, so a variable that receives .. path segments, an absolute
path or a NUL byte is rejected before your function runs. The policy is set for the whole server with the
resource_security= constructor argument, and a template that legitimately needs such values opts out per parameter
with security=ResourceSecurity(exempt_params={…}) on the decorator. See the
SDK documentation for the details.
Prompts
@mcp.prompt() registers a prompt template. The parameters become the prompt’s arguments (question is required
because it has no default, component is optional) and a returned string becomes a single user message. A client lists
prompts with prompts/list and expands one with prompts/get; in a host such as Claude Code or VS Code a prompt shows
up as a slash command the user chooses, which is why prompts are the user-controlled primitive.
Elicitation with a resolver
search_docs declares two component-related parameters, and the pair is the SDK’s answer to elicitation in the stateless
protocol. component is the tool argument the caller may omit. choice is a dependency the SDK resolves before the
tool runs, using Resolve(choose_component). The resolver either returns a value or returns Elicit(…), which makes
the SDK answer the request with an input_required result asking the user to pick a component; when the client retries
with the answer, the SDK re-runs the tool with choice filled in. Calling ctx.elicit() inside the tool body is the
older, connection-bound way and fails on a 2026-07-28 connection. The wire exchange and the reason the resolved
parameter needs a different name from the tool argument are explained on
Tools, Resources, Prompts & Elicitation; the retry state the SDK seals, and the
RequestStateSecurity keys that a multi-instance deployment must share, are covered there and on
Deploying Remote Servers.
Running the server
The last lines of server.py choose a transport. The same code serves both, and a server should be written so that the
choice is a launch argument rather than a code change.
| Command | Effect |
|---|---|
|
stdio: the host launches the process and speaks over its standard streams. This is what
|
|
Streamable HTTP on |
|
Bind address and port are |
Two rules apply to stdio. The protocol owns stdout, so a stray print() corrupts it; log with the logging module,
which writes to stderr, or print(…, file=sys.stderr). And when you run over HTTP the default transport
security allows only localhost Host headers: behind a real hostname every request is rejected with 421 Misdirected
Request until you pass transport_security= an allowlist. Both are explained on Transports;
the Host allowlist is also why a server behind a reverse proxy needs explicit configuration.
The SDK serves the 2026-07-28 protocol and, from the same server object, clients that still speak the earlier
initialize handshake — the Python langchain-mcp-adapters client, which is built on the v1 SDK, called this server
without changes. You do not maintain two servers to support both eras.
The mcp dev loop
mcp dev server.py starts your server inside a uv run --with … environment and opens the MCP Inspector against it,
so you can call tools, read resources and expand prompts in a browser without configuring a host:
uv run mcp dev server.py
It requires the cli extra installed above and Node.js, because it launches the Inspector with npx. In v2 the spawned
environment is pinned to the mcp version you are running, so a v2 server is no longer run against a newer or older SDK
by accident. On the machine used for this page it started the Inspector 2.8.0 web UI on http://127.0.0.1:6274 and
printed an Inspector session token in its output; treat that token like a password and do not paste the URL into a
ticket or chat. The command-line alternative, used in the shared contract script, is
mcp-inspector --cli with --protocol-era modern, described on
Testing & Debugging. mcp run server.py runs the server with the same
environment handling but without the Inspector.
Coming from FastMCP in the v1 SDK
If you wrote a server with the v1 SDK’s FastMCP, most of the code carries over because the decorators are unchanged.
What changes is a set of names and defaults; the official
migration guide lists all of them. These are the ones a
docs-assistant-sized server meets:
| v1 SDK | v2 SDK |
|---|---|
|
|
|
Transport parameters moved off the constructor: |
Unnamed server reports |
The default name is |
|
Never took effect in practice and are no longer read. Read the environment yourself and pass the values in. |
|
|
Lifespan entered per session or per request |
Entered once when the session manager starts and shared by all requests. |
|
Fields in |
Resource not found |
Resource not found is |
The mechanical part is a one-line import change and the version= and run() edits; the behavioural part, worker
threads and the once-only lifespan, is worth a test run against a real client before the deploy.
The standalone FastMCP 4
The fastmcp package is the framework the official SDK’s high-level API came from, and it is still developed
separately. It is a legitimate choice, not a legacy one. The docs-assistant server in FastMCP 4 is nearly the same
program: the decorators have the same names, annotations is a plain dictionary, and a Pydantic return type gives the
same outputSchema and structuredContent. Only the elicitation resolver is missing, so component is a required
argument here:
"""docs-assistant with the standalone FastMCP 4 (PrefectHQ): same tool, resource template and prompt."""
from fastmcp import FastMCP
from pydantic import BaseModel
import store
mcp = FastMCP("docs-assistant", version="1.0.0")
class Hit(BaseModel):
component: str
page: str
title: str
score: int
snippet: str
class SearchResult(BaseModel):
query: str
component: str
hits: list[Hit]
@mcp.tool(annotations={"readOnlyHint": True, "openWorldHint": False})
def search_docs(query: str, component: str, top_k: int = 3) -> SearchResult:
"""Search the documentation of one component and return the best matching pages."""
if component not in store.COMPONENTS:
raise ValueError(f"Unknown component '{component}'")
return SearchResult(query=query, component=component,
hits=[Hit(**h) for h in store.search(query, component, top_k)])
@mcp.resource("docs://{component}/{page}", mime_type="text/asciidoc")
def read_page(component: str, page: str) -> str:
"""The AsciiDoc source of one documentation page."""
return store.read(component, page)
@mcp.prompt
def answer_with_citations(question: str, component: str = "algebra") -> str:
"""Instruct the model to answer from the docs and cite page URIs."""
return (f"Answer the question using only search_docs results for component '{component}'. "
f"Cite every claim as docs://{component}/<page>.\n\nQuestion: {question}")
if __name__ == "__main__":
mcp.run() # stdio; mcp.run(transport="http", port=8000) serves /mcp
This build passed the same five stdio steps of the contract script as the MCPServer build. Its HTTP transport is
mcp.run(transport="http", …) and, for mounting, mcp.http_app(path="/mcp"); the log banner it prints goes to
stderr, so stdio stays clean. Installing it is uv add "fastmcp==4.0.*", and it depends on the official mcp 2.x
package, so both APIs are available in one environment. Its documentation is at gofastmcp.com.
Which one to use depends on what the server needs beyond the basics:
| Choose | When |
|---|---|
|
The server is small or medium, you want the fewest dependencies, you want the SDK that is maintained alongside the
specification, or you need its |
FastMCP 4 |
You want framework features on top of the protocol: composing several servers into one, proxying a remote server, sourcing tools from an OpenAPI document or a FastAPI app, richer built-in authentication providers, a test client that talks to a server in memory, or its interactive-apps support. Also when your team already runs FastMCP servers and wants consistency. |
Either, behind a REST application |
Both mount into FastAPI. The two mounting recipes and their lifespan wiring, which differ, are on MCP inside FastAPI. |
Two cautions. The projects have separate release cycles and separate documentation; a snippet from one does not
necessarily run on the other, even though the decorators look alike, so pin both mcp and fastmcp and read the docs
of the package you import. And do not treat either as a way around the protocol’s rules: whichever library builds the
server, the tool-design and security guidance on Designing Good MCP Servers
and MCP Security applies unchanged.
Where to go next
-
MCP inside FastAPI mounts this server in a web application next to a REST API, including the lifespan pitfall that silently breaks it.
-
Authorization protects the Streamable HTTP endpoint with OAuth 2.1 and shows the
token_verifierforMCPServer. -
Testing & Debugging holds the contract script that all three builds pass.
-
Exposing RAG through MCP replaces the stubbed store with a retrieval pipeline.