Python MCP Server: Build One with the Official SDK v2 (2026)
Build a Python MCP server with the official mcp SDK v2: uv setup, tools, resources, prompts, typed params, async, Streamable HTTP, pytest and uvx packaging.
To build a Python MCP server, install the official SDK with uv add "mcp[cli]", create an MCPServer, and decorate plain Python functions with @mcp.tool(), @mcp.resource() and @mcp.prompt(). Type hints become the input schema. mcp.run() serves stdio by default, and mcp.run(transport="streamable-http") serves HTTP. You test it in memory with Client(mcp) and ship it as a package that users run with uvx.
Everything below was run against mcp 2.3.0, the current stable release (October 2026). It speaks the 2026-07-28 protocol revision and still serves older clients. If you're coming from older tutorials, the most visible change is that the FastMCP class from v1 is now MCPServer.
Official SDK vs FastMCP: which one?
Two Python libraries use similar decorators, and most posts online mix them up:
Official SDK (mcp) | Standalone FastMCP (fastmcp) | |
|---|---|---|
| Maintainer | Model Context Protocol project | Prefect |
| Server class | from mcp.server import MCPServer | from fastmcp import FastMCP |
| Tool decorator | @mcp.tool() | @mcp.tool |
| HTTP transport name | "streamable-http" | "http" (or "streamable-http") |
| Current version | 2.3.0 | 4.1.0 (built on mcp 2.x) |
| Extras | Core protocol, mcp CLI | Auth providers, server composition, apps, its own CLI |
History explains the confusion. FastMCP 1.0 was merged into the official SDK in 2024 as mcp.server.fastmcp.FastMCP. The standalone project kept going on its own, and in SDK v2 the built-in class was renamed MCPServer, so from mcp.server.fastmcp import FastMCP now raises ModuleNotFoundError. This guide uses the official SDK. If you choose standalone FastMCP, the ideas carry over, but follow its own docs.
Step 1: Set up the project with uv
You need Python 3.11+ (per the SDK docs) and uv.
uv init --package pypi-info
cd pypi-info
uv add "mcp[cli]" httpx
--package creates a src/pypi_info/ layout with a [project.scripts] entry, which you need later for uvx. The cli extra adds the mcp command (mcp dev, mcp run, mcp install). The example server calls the PyPI JSON API, so it needs no API key.
Step 2: Write the server
Create src/pypi_info/server.py:
from typing import Annotated
import httpx
from pydantic import BaseModel, Field
from mcp.server import MCPServer
from mcp.server.mcpserver.exceptions import ToolError
mcp = MCPServer("pypi-info")
class PackageInfo(BaseModel):
name: str
version: str
summary: str | None
requires_python: str | None
recent_versions: list[str] = []
@mcp.tool()
async def get_package(
name: Annotated[str, Field(description="Exact PyPI project name, e.g. 'httpx'")],
include_versions: Annotated[int, Field(ge=0, le=20, description="How many recent releases to list")] = 0,
) -> PackageInfo:
"""Get the latest version, summary and Python requirement of a PyPI package. Use before pinning a dependency."""
async with httpx.AsyncClient(timeout=10) as client:
r = await client.get(f"https://pypi.org/pypi/{name}/json")
if r.status_code == 404:
raise ToolError(f"No PyPI project named {name!r}. Check the spelling.")
r.raise_for_status()
data = r.json()
info = data["info"]
return PackageInfo(
name=info["name"],
version=info["version"],
summary=info["summary"],
requires_python=info["requires_python"],
recent_versions=list(data["releases"])[-include_versions:] if include_versions else [],
)
@mcp.resource("pypi://about")
def about() -> str:
"""What this server does and where its data comes from."""
return "Package metadata comes from the public PyPI JSON API."
@mcp.prompt()
def compare(first: str, second: str) -> str:
"""Compare two PyPI packages for a new project."""
return (
f"Use the get_package tool on {first} and {second}, then compare them on "
"maintenance, Python support and scope. Recommend one."
)
def main() -> None:
mcp.run()
if __name__ == "__main__":
main()
What the SDK reads from this file:
- Name and description come from the function name and docstring. Write the docstring for the model: say what the tool does and when to use it.
- Typed params:
Annotated[..., Field(...)]adds per-argument descriptions and limits.ge=0, le=20becomesminimum/maximumin the schema, and a call withinclude_versions=999is rejected before your function runs. The model sees that error and retries.Literal[...]gives you enums, and a Pydantic model parameter gives you a nested object. - Structured output: returning a Pydantic model gives the tool an output schema. The client gets both readable text (
content) and typedstructured_content. - Async: use
async deffor I/O. A plaindeftool also works, and the SDK runs it in a worker thread so it never blocks the server. - Errors: raise
ToolErrorwith a message the model can act on. It comes back as anis_errorresult, not a crash.
Tools are model-controlled, resources are application-controlled data, and prompts are templates the user picks. See MCP resources vs tools for when to use which.
If a tool needs to report progress or read one of the server's resources, declare a ctx: Context parameter (from mcp.server.mcpserver import Context) and call await ctx.report_progress(...) or await ctx.read_resource(...). The SDK fills it in, and the model never sees it.
Step 3: Run and inspect it
uv run mcp dev src/pypi_info/server.py
This launches your server under the MCP Inspector, which needs npx on your PATH. Open the printed URL, call get_package on the Tools tab, read pypi://about under Resources, and render compare under Prompts.
On stdio, stdout carries the protocol. Use the logging module, which writes to stderr, and don't use print().
Step 4: Serve it over Streamable HTTP
Local clients launch your file over stdio. To give the server to people who don't have your code, serve it on a URL:
uv run mcp run src/pypi_info/server.py --transport streamable-http
Or set it in code: mcp.run(transport="streamable-http", port=8000). Options such as host, port, streamable_http_path (default /mcp) and json_response go to run(), not to the MCPServer constructor. Clients connect to http://127.0.0.1:8000/mcp.
Before you deploy behind a real hostname, there's one thing to know. The app enables DNS-rebinding protection and accepts only localhost Host headers until you pass an allowlist. Without one, every request gets 421 Misdirected Request:
from mcp.server.transport_security import TransportSecuritySettings
security = TransportSecuritySettings(
allowed_hosts=["mcp.example.com", "mcp.example.com:*"],
allowed_origins=["https://app.example.com"],
)
app = mcp.streamable_http_app(transport_security=security)
# run with: uvicorn pypi_info.server:app --workers 4
Modern (2026-07-28) clients are sessionless, so any worker can serve any request without sticky sessions. Hosting, OAuth and scaling are covered in how to deploy an MCP server.
Step 5: Test it with pytest
Client connects to the server object in memory, with no subprocess and no port:
uv add --dev pytest anyio
# tests/test_server.py
import pytest
from mcp import Client
from pypi_info.server import mcp
@pytest.fixture
def anyio_backend():
return "asyncio"
@pytest.mark.anyio
async def test_get_package():
async with Client(mcp, raise_exceptions=True) as client:
result = await client.call_tool("get_package", {"name": "httpx"})
assert not result.is_error
assert result.structured_content["name"] == "httpx"
@pytest.mark.anyio
async def test_unknown_package_is_tool_error():
async with Client(mcp) as client:
result = await client.call_tool("get_package", {"name": "no-such-package-xyz-123"})
assert result.is_error
Run uv run pytest. raise_exceptions=True shows you real server-side errors in tests instead of the generic message remote callers get. Point the same Client at "http://localhost:8000/mcp" to smoke-test the HTTP server.
Step 6: Connect it to a client
All local clients take the same launch command, so use absolute paths:
claude mcp add pypi-info -- uv run --directory /absolute/path/to/pypi-info pypi-info
For Claude Desktop, uv run mcp install src/pypi_info/server.py writes the config entry for you. Add -v API_KEY=... or -f .env to pass environment variables. Cursor and VS Code use the same command and args in their JSON files. See the Claude Desktop, Cursor and VS Code guides.
Step 7: Package it for uvx
Because uv init --package created a console script, point it at main:
[project.scripts]
pypi-info = "pypi_info.server:main"
Build and try the wheel exactly as a user would:
uv build
npx @modelcontextprotocol/inspector --cli uvx --from ./dist/pypi_info-0.1.0-py3-none-any.whl pypi-info --method tools/list
Publish with uv publish. After that, anyone can run uvx pypi-info, and the client config becomes "command": "uvx", "args": ["pypi-info"]. To list the server in the official MCP Registry, add a line like mcp-name: io.github.your-username/pypi-info to your README (an HTML comment is fine). That is how the registry verifies PyPI ownership. Then run mcp-publisher init, login and publish, as described in build an MCP server.
Common mistakes
- Importing
mcp.server.fastmcp: that path was removed in v2. Usefrom mcp.server import MCPServer, or pinmcp<2if you aren't ready to migrate. - Passing
port=toMCPServer(...): this raisesTypeError. Transport options go torun(). - Relative paths in client configs: clients launch your server from their own working directory.
- New SSE servers:
transport="sse"still works for old clients, but it has been superseded since 2025-03-26. Build on Streamable HTTP (why).
Next steps
- See how popular Python servers are built in the MCP server directory, for example Serena or Postgres MCP Pro.
- Read the MCP security best practices before you expose tools that write data.
- Shipped it? Submit it to MCP.so.
Frequently asked questions
Is FastMCP the same as the official MCP Python SDK?
- No. FastMCP 1.0 was merged into the official SDK in 2024, but the standalone fastmcp package by Prefect is now a separate project. In the official SDK v2 the built-in class is called MCPServer and is imported with from mcp.server import MCPServer.
How do I run a Python MCP server?
- Call mcp.run() for stdio, which is what local clients like Claude Code and Cursor use, or mcp.run(transport='streamable-http') to serve HTTP on port 8000 at /mcp. During development, uv run mcp dev server.py opens the server in the MCP Inspector.
How do I share a Python MCP server so others can run it with uvx?
- Add a [project.scripts] entry that points to a function calling mcp.run(), build with uv build and publish with uv publish. Users can then start it with uvx your-package, and clients use command uvx with your package name as the argument.
Which Python version does the MCP Python SDK need?
- The official SDK v2 documentation targets Python 3.11 or newer. Install it with uv add 'mcp[cli]' or pip install 'mcp[cli]' to get the SDK and the mcp command-line tool.