Create a Model Context Protocol (MCP) server in Python with the official MCP Python SDK v2. SDK v2 supports the MCP specification revision 2026-07-28. The same server also serves clients that use earlier revisions.
Copy this checklist and track your progress:
Run these commands:
uv init --app --no-package project-name
cd project-name
uv add "mcp[cli]>=2,<3"
uv add --dev pytest
Then delete main.py. The server goes in server.py.
--no-package, uv makes a src/ package layout, and the tests cannot import server.py.uv init makes the .gitignore file. Do not write a different one.Start from this template:
from mcp.server import MCPServer
from mcp.server.mcpserver.exceptions import ToolError
mcp = MCPServer("Demo", version="0.1.0")
@mcp.tool()
def divide(a: float, b: float) -> float:
"""Divide a by b."""
if b == 0:
raise ToolError("b must not be zero.")
return a / b
if __name__ == "__main__":
mcp.run() # stdio is the default transport
Then change the template for the request of the user:
divide with the tools that the user needs.TypedDict when the client needs machine-readable data. The SDK makes the output schema from the return type.async def for I/O. The SDK runs a sync tool on a worker thread.@mcp.tool(annotations=ToolAnnotations(read_only_hint=True)) (from mcp.types). Use destructive_hint=True for a destructive tool.@mcp.resource("users://{user_id}")) and prompts (@mcp.prompt()) only when the user asks for them.Use stdio by default. Read references/streamable-http.md only when the user asks for a remote server or an HTTP server.
Put test_server.py next to server.py. Write a test for each tool and for each ToolError:
import pytest
from mcp import Client
from server import mcp
@pytest.mark.anyio
async def test_divide():
async with Client(mcp) as client:
result = await client.call_tool("divide", {"a": 6, "b": 3})
assert result.structured_content == {"result": 2.0}
@pytest.mark.anyio
async def test_divide_by_zero():
async with Client(mcp) as client:
result = await client.call_tool("divide", {"a": 1, "b": 0})
assert result.is_error
assert "must not be zero" in result.content[0].text
Client needs no subprocess and no port. It connects with the 2026-07-28 revision.mcp package installs anyio, which supplies the anyio marker. Do not add pytest-asyncio.uv run pytest.uv run pytest again.For VS Code, write .vscode/mcp.json:
{
"servers": {
"demo": {
"type": "stdio",
"command": "uv",
"args": ["--directory", "/absolute/path/to/project", "run", "server.py"]
}
}
}
For Claude Desktop, run uv run mcp install server.py.
Tell the user these commands:
uv run server.py starts the stdio server. The server waits for a host on stdin and prints nothing.uv run mcp dev server.py opens the MCP Inspector. The Inspector needs Node.js.SDK v2 and the 2026-07-28 revision changed many SDK v1 patterns. Do not copy SDK v1 examples.
FastMCP and mcp.server.fastmcp. Import the server class with from mcp.server import MCPServer.Context, Image, Audio, Resolve, Elicit, ElicitationResult, AcceptedElicitation, Message, UserMessage, and AssistantMessage from mcp.server.mcpserver.MCPServer argument. Give all other constructor arguments as keyword arguments. Their positional order changed in SDK v2.version. If you do not set it, the server reports an empty version.transport, host, port, json_response, stateless_http, transport_security) to run(), not to MCPServer(...).read_only_hint, structured_content, and is_error.ToolError for an error that the model must read. For other exceptions, the model gets only a generic error message.MCPError for a tool failure. The client gets a protocol error, not a tool result with is_error. Many hosts do not show this error to the model.print(). Log to stderr with the logging module.ctx.elicit(). It fails on a 2026-07-28 connection. For user input during a tool call, annotate a parameter with Resolve(fn). Return Elicit(message, Model) from fn.await ctx.notify_tools_changed(). A 2026-07-28 connection drops ctx.session.send_tool_list_changed().Client(mcp) in a test runs the lifespan again. Read its object with ctx.request_context.lifespan_context.ctx.session.create_message()): Call the LLM provider API directly.ctx.log(), ctx.info(), and similar methods): Use the standard logging module.ctx.session.list_roots()): Get paths from tool parameters, resource URIs, or the server configuration.