How to Build an MCP Server in Python and Test It Locally
Build an MCP server in Python with the official SDK: tools, resources, stdio vs Streamable HTTP, Inspector testing, a Python client, and the usual errors.

An MCP server in Python is a script that imports MCPServer from the official mcp package, decorates a few typed functions as tools or resources, and calls run(). The SDK turns the type hints and docstrings into the JSON Schema that clients see, handles the JSON-RPC 2.0 framing, and speaks all three standard transports: stdio, Streamable HTTP, and SSE.
The short version: install with uv add "mcp[cli]" or pip install "mcp[cli]", write the 15-line server below, open it in the MCP Inspector with uv run mcp dev server.py, then point Claude for Desktop, Claude Code, or your own Python client at it. The rest of this page walks through each step and the mistakes that cost people the most time.
The smallest MCP server in Python#
You need Python 3.11 or newer, according to the MCP Python SDK README on GitHub. The package on PyPI is simply mcp. Install it inside a project or virtual environment:
uv add "mcp[cli]"
# or, with pip:
pip install "mcp[cli]"
The cli extra adds the mcp command-line tool with mcp dev, mcp run, and mcp install. Plain mcp without the extra is fine for a server you'll launch yourself, but you'll want the CLI while developing. If you're on a system Python that refuses to install packages, start with a Python virtual environment rather than fighting pip.
Create 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
@mcp.resource("greeting://{name}")
def greeting(name: str) -> str:
"""Greet someone by name."""
return f"Hello, {name}!"
That's a complete server with one tool and one templated resource. Nothing here parses requests, validates arguments, or writes JSON Schema. The parameter annotations a: int, b: int are the schema, and the docstring is the description the model reads.
To make the file runnable as a stdio server on its own, add the standard entry point at the bottom:
if __name__ == "__main__":
mcp.run(transport="stdio")
Now uv run server.py starts the server and it waits for an MCP host to talk to it over stdin and stdout. It will look like it's hanging. That's correct behavior for a stdio server with nobody connected.
What the SDK builds from your type hints#
It helps to see what the decorator is doing, because it explains most of the advice later about docstrings and annotations. The SDK reads each parameter's type hint and the function's docstring and produces a tool definition that clients list and the model chooses from. Here's a sketch of that idea using only the standard library. It's not the SDK's exact output, but it shows why a missing hint or an empty docstring produces a worse tool:
import inspect
import json
from typing import get_type_hints
def add(a: int, b: int) -> int:
"""Add two numbers."""
return a + b
TYPE_MAP = {int: "integer", float: "number", str: "string", bool: "boolean"}
def describe_tool(func):
hints = get_type_hints(func)
params = inspect.signature(func).parameters
properties = {name: {"type": TYPE_MAP[hints[name]]} for name in params}
return {
"name": func.__name__,
"description": inspect.getdoc(func),
"input": {
"type": "object",
"properties": properties,
"required": list(params),
},
}
print(json.dumps(describe_tool(add), indent=2))
Two practical consequences. First, write docstrings as instructions for a model, not for a colleague: say what the tool returns and when to use it. Second, keep parameter types simple and annotated. A parameter without a hint gives the SDK nothing to work with.
Tools, resources, and a real HTTP call#
MCP servers can expose three kinds of capabilities. Tools are functions the model calls, with user approval. Resources are file-like data a client reads, such as an API response or file contents. Prompts are pre-written templates for users. Most servers people search for are tool servers, so that's the focus here.
Resource URIs like greeting://{name} are templates. The client requests a concrete URI, the server matches it against registered templates, and the captured segments become function arguments. This standard-library sketch shows the matching behavior you get for free:
import re
def compile_template(template: str) -> re.Pattern:
parts = re.split(r"(\{\w+\})", template)
regex = ""
for part in parts:
if part.startswith("{") and part.endswith("}"):
regex += f"(?P<{part[1:-1]}>[^/]+)"
else:
regex += re.escape(part)
return re.compile(f"^{regex}$")
def greeting(name: str) -> str:
return f"Hello, {name}!"
template = compile_template("greeting://{name}")
for uri in ["greeting://Ada", "greeting://Indra", "notes://today"]:
match = template.match(uri)
if match:
print(uri, "->", greeting(**match.groupdict()))
else:
print(uri, "-> no resource matches this URI")
Real tools usually call something over the network, and that's where async matters. The official weather quickstart uses httpx2, which the SDK already depends on, so installing mcp brought it in. Here is a tool built on the quickstart's helper that fetches active alerts from the US National Weather Service:
from typing import Any
import httpx2
from mcp.server import MCPServer
mcp = MCPServer("weather")
NWS_API_BASE = "https://api.weather.gov"
USER_AGENT = "weather-app/1.0"
async def make_nws_request(url: str) -> dict[str, Any] | None:
"""Make a request to the NWS API with proper error handling."""
headers = {"User-Agent": USER_AGENT, "Accept": "application/geo+json"}
async with httpx2.AsyncClient() as client:
try:
response = await client.get(url, headers=headers, timeout=30.0)
response.raise_for_status()
return response.json()
except Exception:
return None
def format_alert(feature: dict) -> str:
"""Format an alert feature into a readable string."""
props = feature["properties"]
return f"""
Event: {props.get("event", "Unknown")}
Area: {props.get("areaDesc", "Unknown")}
Severity: {props.get("severity", "Unknown")}
Description: {props.get("description", "No description available")}
Instructions: {props.get("instruction", "No specific instructions provided")}
"""
@mcp.tool()
async def get_alerts(state: str) -> str:
"""Get active weather alerts for a US state. Pass the two-letter state code, e.g. CA."""
url = f"{NWS_API_BASE}/alerts/active/area/{state.upper()}"
data = await make_nws_request(url)
if not data or "features" not in data:
return "Unable to fetch alerts or no alerts found."
if not data["features"]:
return "No active alerts for this state."
return "\n---\n".join(format_alert(f) for f in data["features"])
if __name__ == "__main__":
mcp.run(transport="stdio")
The tool is async def, so a slow upstream request doesn't block the server's event loop. If async and await are new territory, the primer on Python async and await covers the mental model you need before writing more than one tool.
stdio vs Streamable HTTP transport#
The transport decides how a client reaches your server, and the choice is mostly about where the server runs.
| Transport | How it runs | When to pick it |
|---|---|---|
| stdio | Host launches your script as a subprocess and talks over stdin/stdout | Local tools for Claude for Desktop, Claude Code, editors |
| Streamable HTTP | Your process listens on a URL, clients connect over HTTP | Anything deployed, shared, or reached from another machine |
| SSE | Older HTTP transport, still supported by the SDK | Only when a client requires it |
For stdio, the entry point above is all you need. For Streamable HTTP during development, the CLI can serve the same file without changing any code:
uv run mcp run server.py --transport streamable-http
The server then answers at http://localhost:8000/mcp. For deployment, use Streamable HTTP: stdio while you iterate locally, Streamable HTTP once anyone other than you needs to connect.
One rule for stdio that bites everyone at least once: stdout belongs to the protocol. Anything your code prints there gets mixed into the JSON-RPC stream and the client sees garbage. The official quickstart logs to stderr for exactly this reason. In Python, that means print(..., file=sys.stderr) or the logging module configured for stderr, never a bare print().
How to test an MCP server in Python#
The fastest feedback loop is the MCP Inspector, a browser UI that lists your tools and resources and lets you call them with arguments. The mcp dev command launches your server and opens the Inspector against it:
uv run mcp dev server.py
Call add with a=1 and b=2 and you should get 3 back. Try the greeting:// resource with a name and confirm the string comes through. If a tool errors, the Inspector shows the response, which is far easier to read than a host's error dialog.
The Inspector on GitHub also has a CLI mode that runs without a browser, which is what you want in a script or CI. It launches any server command you give it. For the stdio server above:
npx @modelcontextprotocol/inspector --cli uv run server.py --protocol-era modern --method tools/list
The --protocol-era flag matters. The Inspector defaults to the legacy era, which only exercises the 2025-era initialize handshake. Run the listing once with --protocol-era modern and once with --protocol-era legacy so you know both kinds of clients can talk to your server. Both runs should list the same tools.
Connecting your server to a client#
There are three clients most readers care about: Claude for Desktop, Claude Code, and their own Python code.
Claude for Desktop reads a JSON config file. The location depends on the OS:
| OS | Config file |
|---|---|
| macOS | ~/Library/Application Support/Claude/claude_desktop_config.json |
| Linux | ~/.config/Claude/claude_desktop_config.json |
| Windows | $env:AppData\Claude\claude_desktop_config.json |
Create the file if it doesn't exist and add your server under the mcpServers key. The command below tells the app to run uv --directory /ABSOLUTE/PATH/TO/PROJECT run server.py:
{
"mcpServers": {
"demo": {
"command": "uv",
"args": [
"--directory",
"/ABSOLUTE/PATH/TO/PROJECT",
"run",
"server.py"
]
}
}
}
Use an absolute path. On Windows, use double backslashes or forward slashes in the JSON. You may also need the full path to the uv executable in the command field, which which uv on macOS and Linux or where uv on Windows will tell you. Save, then fully restart Claude for Desktop. The MCP UI elements only appear once at least one server is configured correctly.
Claude Code uses the same stdio and HTTP servers but registers them differently. The walkthrough on adding a Claude Code MCP server and fixing common errors covers that setup, including what to check when the server shows as failed.
For your own Python code, the same mcp package is a full client. Start the server over Streamable HTTP as shown earlier, then:
import asyncio
from mcp import Client
async def main() -> None:
async with Client("http://localhost:8000/mcp") as client:
result = await client.call_tool("add", {"a": 1, "b": 2})
print(result.structured_content) # {'result': 3}
asyncio.run(main())
Passing a URL means Streamable HTTP. The SDK README notes that Client can also launch a local server as a stdio subprocess or wrap a custom transport, so you can test a stdio server from Python without going through a desktop app.
MCP server in Python vs TypeScript#
Both SDKs are official and the quickstart offers the same weather server in Python, TypeScript, Java, Kotlin, C#, Ruby, Rust, and Go. Pick the language your tool logic already lives in. If you're wrapping a Python library, a database, or a data pipeline, Python is the obvious choice. If your tool wraps a Node service or ships inside an npm package, TypeScript saves you a second runtime.
The day-to-day difference is the build step. A Python server is one file you run directly. The TypeScript server needs npm run build before each test, and the quickstart calls that out as the step people forget when their server won't connect. The TypeScript quickstart also shows that wiring the server by hand with server.connect(new StdioServerTransport()) only serves 2025-era clients, while the newer serveStdio helper negotiates the protocol revision. In Python, mcp.run(transport="stdio") is the only path shown in the current docs, so there's less to get wrong.
Common errors and gotchas#
- Server hangs when you run it. A stdio server with no client attached sits silently waiting on stdin. That's not a bug. Use
mcp devto actually exercise it. - Client gets JSON parse errors or disconnects immediately. Something wrote to stdout. Remove
print()calls or route them to stderr. mcp: command not found. You installed plainmcpwithout thecliextra, or you're in a different environment than the one you installed into. Reinstall with"mcp[cli]", or useuv run --with "mcp[cli]" mcp dev server.pyfor a one-off command outside a project.- Claude for Desktop doesn't show the server. The usual causes are a relative path in the config,
uvnot on the app's PATH, Windows backslashes that weren't doubled, or not restarting the app. Fix the config, put the fulluvpath incommandif needed, and restart. - Python client can't connect. Check that the server was started with
--transport streamable-httpand that the URL ends in/mcpon port 8000. A stdio server has no URL to connect to. - Tools list correctly but the model misuses them. Docstrings and type hints are the entire interface the model sees. Describe arguments, units, and expected formats in the docstring.
- Works in the Inspector, fails with one client. Run the Inspector CLI with both
--protocol-era modernand--protocol-era legacy. If one fails, the client that fails is on that era. - Old tutorials import a different class. The current README's example imports
MCPServerfrommcp.serverand requires SDK 2.0.0 or higher per the quickstart. The SDK docs include a v2 migration guide if you're updating older code. - Slow or stuck tools. A synchronous network call inside a tool blocks the event loop for every other request. Make network-bound tools
async defand use an async HTTP client.
Keep the security model in mind as well. The MCP specification treats tools as arbitrary code execution and requires hosts to obtain explicit user consent before invoking any tool. Your server shouldn't rely on that prompt as its only safeguard: validate inputs and limit what a tool can touch.
Quick reference#
| Task | Command or code |
|---|---|
| Install SDK with CLI | uv add "mcp[cli]" or pip install "mcp[cli]" |
| Create a server | from mcp.server import MCPServer then mcp = MCPServer("Demo") |
| Register a tool | @mcp.tool() on a typed function with a docstring |
| Register a resource | @mcp.resource("greeting://{name}") |
| Run over stdio | mcp.run(transport="stdio") under if __name__ == "__main__" |
| Run over Streamable HTTP | uv run mcp run server.py --transport streamable-http |
| Default HTTP URL | http://localhost:8000/mcp |
| Open in the Inspector | uv run mcp dev server.py |
| List tools from a script | npx @modelcontextprotocol/inspector --cli uv run server.py --protocol-era modern --method tools/list |
| Call a tool from Python | async with Client("http://localhost:8000/mcp") as client: await client.call_tool("add", {"a": 1, "b": 2}) |
| Claude for Desktop config | claude_desktop_config.json, mcpServers key, absolute paths, restart the app |
Before you call a server done, check this list:
- Python 3.11 or newer, SDK 2.0.0 or higher,
mcp[cli]installed in the active environment. - Every tool has type hints on every parameter and a docstring written for the model.
- Nothing in the server prints to stdout when running over stdio.
uv run mcp dev server.pylists the tools and a test call returns the right value.- The Inspector CLI lists tools in both the modern and legacy protocol eras.
- Host config uses absolute paths, and the host was restarted after the change.
- For deployment, the server runs over Streamable HTTP and a
Clientcan call it at the/mcpURL.
Quick answers
How do I run an MCP server in Python?
Add mcp.run(transport="stdio") under an if __name__ == "__main__" block and start it with uv run server.py, or serve it over HTTP with uv run mcp run server.py --transport streamable-http. For an interactive test, uv run mcp dev server.py opens it in the MCP Inspector.
How do I test an MCP server in Python?
Use the MCP Inspector. uv run mcp dev server.py opens a browser UI where you can list and call tools, and the Inspector CLI with --method tools/list works in scripts. Run the CLI with both --protocol-era modern and --protocol-era legacy because it defaults to legacy.
How do I call an MCP server from Python?
The same mcp package includes a client. Serve the server over Streamable HTTP, then use async with Client("http://localhost:8000/mcp") as client and await client.call_tool("add", {"a": 1, "b": 2}). The result's structured_content holds the returned value.
Does the Python SDK support SSE?
Yes. The SDK README lists stdio, Streamable HTTP, and SSE as supported transports. For new servers, the README positions Streamable HTTP as the transport you deploy, so reach for SSE only when a specific client requires it.
Should I write an MCP server in Python or TypeScript?
Both have official SDKs and the quickstart shows the same server in each. Choose the language your tool logic already uses. Python skips the build step, while TypeScript servers need npm run build before each test.
Did this solve it?
One tap. No account.
Sorry about that. What was missing?
from 0 readers
Saved you an hour? Send a tip.
USDC or ETH on Base, Ethereum, Arbitrum and more. Keeps the notes free and the examples running.
Network
0xc783bc5c3fa14cEC3f09A22284bC91C5AF0d59e1
On a computer? Scan the code with your phone wallet, or copy the address.
Pick the network your wallet or exchange uses. USDC works on all six; ETH on Base, Ethereum, Arbitrum and Optimism. Don't send on Tron or Solana: this address doesn't exist there. Tips are optional and unlock nothing; every guide stays free.
Discussion
Start the discussion
Next note