Build a minimal MCP server with the Python SDK's MCPServer, @mcp.tool and stdio
DrFritzi · Reviewed · Updated 28 Sept 2026 · Markdown
Answer
Install the mcp package, create MCPServer("Demo") from mcp.server, decorate a typed function with @mcp.tool(), and call mcp.run(), which serves over stdio by default. Test it with uv run mcp dev server.py, which opens the MCP Inspector. This page describes mcp 2.2.0 on Python 3.11, the version we ran. The SDK README says v2 supports MCP specification 2026-07-28, and that mcp>=1.28,<2 keeps you on the older v1 line.
Details
1. Install
uv add "mcp[cli]" # or: pip install "mcp[cli]"
The cli extra provides the mcp command used for mcp dev. Plain mcp is enough for a server that you run with python. PyPI lists Python 3.10 or later as required.
2. Write the server (server.py)
from mcp.server import MCPServer
mcp = MCPServer("Demo")
@mcp.tool()
def add(a: int, b: int) -> int:
"""Add two numbers."""
return a + b
if __name__ == "__main__":
mcp.run()
The README and first-steps guide show the class and decorator. MCPServer.run(transport='stdio', ...) also accepts 'sse' and 'streamable-http', so a bare mcp.run() means stdio. The docs run a stdio server with python server.py.
3. Test it
- Inspector:
uv run mcp dev server.pystarts the server and the MCP Inspector together. - Any client: write JSON-RPC lines to the process's stdin and read stdout.
Verification
We ran the file above in a fresh virtualenv with mcp 2.2.0 and sent initialize, notifications/initialized, tools/list and tools/call as newline-delimited JSON. Real replies, trimmed to the interesting part:
initialize -> "protocolVersion":"2025-11-25","serverInfo":{"name":"Demo","version":""}
tools/list -> {"description":"Add two numbers.","inputSchema":{"properties":{"a":{"title":"A","type":"integer"},"b":{"title":"B","type":"integer"}},"required":["a","b"],"type":"object","title":"addArguments"},"name":"add","outputSchema":{"properties":{"result":{"title":"Result","type":"integer"}},"required":["result"],"type":"object","title":"addOutput"}}
tools/call {"a":2,"b":3} -> {"content":[{"text":"5","type":"text"}],"isError":false,"structuredContent":{"result":5}}
tools/call {"a":"x","b":3} -> {"content":[{"text":"Error executing tool add: 1 validation error for addArguments\na\n Input should be a valid integer, ...","type":"text"}],"isError":true}
Our initialize asked for 2025-11-25 and the server answered with it. We did not test a 2026-07-28 client.
How the schema is built
| You write | Client sees |
|---|---|
Function name add |
Tool name |
Docstring """Add two numbers.""" |
Tool description |
Type hints a: int, b: int |
inputSchema (both required) |
Return type -> int |
outputSchema and structuredContent, plus a text block |
Pitfall: writing to stdout
On stdio, stdout carries the protocol. The SDK docs say that while serving it diverts output that is flushed to stdout, such as a flushed print(), to stderr. They also say buffered output or prints made before serving starts can still reach the wire, and that logging, which writes each record to stderr, is the right tool. Log with logging or print(..., file=sys.stderr), and do not rely on the diversion.