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

mcp 2.x (official SDK)

from mcp.server import MCPServer

The reference Python SDK maintained in the Model Context Protocol organisation. MCPServer is the high-level, decorator-based server API. In the v1 SDK the same class was called FastMCP and lived in mcp.server.fastmcp.

fastmcp 4.x (PrefectHQ)

from fastmcp import FastMCP

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.

fastapi-mcp 0.4

from fastapi_mcp import FastApiMCP

A small library that generates MCP tools from a FastAPI application’s OpenAPI document. It is built on the v1 SDK (mcp below 2), so it cannot share a virtual environment with mcp 2.x; see MCP inside FastAPI.

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.

server.py — the complete docs-assistant server (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

python server.py

stdio: the host launches the process and speaks over its standard streams. This is what claude mcp add --transport stdio and VS Code’s command entries do; see Connecting Hosts.

python server.py streamable-http

Streamable HTTP on http://127.0.0.1:8000/mcp, served by uvicorn.

mcp.run("streamable-http", host="0.0.0.0", port=9000)

Bind address and port are run() arguments in v2, no longer constructor arguments. The endpoint path is streamable_http_path, the request-body limit max_request_body_size (4 MiB by default), and stateless_http=True removes the server-to-client channel; each is also accepted by streamable_http_app().

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

from mcp.server.fastmcp import FastMCP

from mcp.server import MCPServer (also importable from mcp.server.mcpserver)

FastMCP("name", host=…​, port=…​, streamable_http_path=…​)

Transport parameters moved off the constructor: host and port to run(), and the path and other HTTP options to run() and streamable_http_app(). Passing port= to the constructor raises TypeError.

Unnamed server reports FastMCP; unversioned server reports a version

The default name is mcp-server and an unversioned server reports an empty version, so pass version= as the listing does.

MCP_* environment variables and .env

Never took effect in practice and are no longer read. Read the environment yourself and pass the values in.

def handlers run inline on the event loop

def handlers run on a worker thread. Thread-affine state and asyncio.get_running_loop() inside a def handler need attention.

Lifespan entered per session or per request

Entered once when the session manager starts and shared by all requests.

mcp.types fields in camelCase, McpError

Fields in snake_case (tool.input_schema), McpError renamed MCPError. mcp.types still works as an alias of the new mcp-types package.

Resource not found -32002

Resource not found is -32602, matching the 2026-07-28 specification.

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:

fastmcp_full.py — docs-assistant with FastMCP 4.0.10
"""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

MCPServer (official mcp 2.x)

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 Resolve / Elicit elicitation and RequestStateSecurity, which the primitives page builds on. It is the default for the docs-assistant.

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_verifier for MCPServer.

  • Testing & Debugging holds the contract script that all three builds pass.

  • Exposing RAG through MCP replaces the stubbed store with a retrieval pipeline.