Build a Custom MCP Server in Python in 2026
Building tools that Claude Code can call directly from your terminal changes how you work with AI. Instead of copy-pasting data into prompts, you give Claude direct access to your systems through MCP servers.
I’ve built several MCP servers in Python — for WordPress publishing, Skool community management, and YouTube analytics. Every one follows the same pattern. Here’s exactly how to build a custom MCP server in Python from scratch using the official SDK.
What Is an MCP Server?
MCP (Model Context Protocol) is an open standard from Anthropic that lets AI assistants call external tools. You define tools with typed inputs and outputs. Claude Code calls them mid-conversation like native functions.
The protocol runs over JSON-RPC through stdio. Your server starts as a subprocess that Claude Code spawns and talks to via stdin/stdout. No HTTP. No webhooks. Just a Python process that reads and writes JSON.
If you’ve built a CLI tool before, building an MCP server is simpler.
Set Up the Project
Start with a clean directory and install the MCP Python SDK:
mkdir my-mcp-server && cd my-mcp-server
python3 -m venv .venv && source .venv/bin/activate
pip install mcp[cli]
The mcp[cli] package includes the server framework and a dev inspector for testing. That’s the only dependency you need.
Build Your First Tool
Create server.py. Every MCP server follows the same structure: create a server instance, define tools with decorators, and run the stdio transport.
from mcp.server.fastmcp import FastMCP
mcp = FastMCP("my-tools")
@mcp.tool()
def get_system_info() -> str:
"""Return basic system information."""
import platform
return (
f"OS: {platform.system()} {platform.release()}\n"
f"Python: {platform.python_version()}\n"
f"Machine: {platform.machine()}"
)
@mcp.tool()
def count_files(directory: str, extension: str = "") -> str:
"""Count files in a directory, optionally filtered by extension.
Args:
directory: Path to the directory to scan
extension: File extension to filter by (e.g. '.py', '.json')
"""
from pathlib import Path
path = Path(directory).expanduser()
if not path.is_dir():
return f"Error: {directory} is not a valid directory"
if extension:
files = list(path.rglob(f"*{extension}"))
else:
files = [f for f in path.rglob("*") if f.is_file()]
return f"Found {len(files)} files in {directory}" + (
f" matching {extension}" if extension else ""
)
if __name__ == "__main__":
mcp.run(transport="stdio")
Three things to notice:
- The
@mcp.tool()decorator registers a function as a callable tool. The function name becomes the tool name. - Type hints on parameters are required. MCP uses them to generate the JSON schema that Claude reads.
- The docstring becomes the tool description. Claude uses this to decide when to call it. Write it like you’re explaining to a colleague.
Add a Tool With External API Access
Most useful MCP servers talk to external services. Here’s a tool that checks the status of a website:
import httpx
@mcp.tool()
async def check_website(url: str) -> str:
"""Check if a website is reachable and return its status code and response time.
Args:
url: Full URL to check (e.g. https://example.com)
"""
async with httpx.AsyncClient(timeout=10) as client:
try:
response = await client.get(url)
return (
f"Status: {response.status_code}\n"
f"Response time: {response.elapsed.total_seconds():.2f}s\n"
f"Content-Type: {response.headers.get('content-type', 'unknown')}"
)
except httpx.RequestError as e:
return f"Error reaching {url}: {str(e)}"
Async tools work out of the box. The MCP SDK handles the event loop. Use httpx for HTTP calls — it’s async-native and comes with timeout handling.
Install it alongside the SDK:
pip install httpx
Test Before Connecting to Claude
The MCP CLI includes a dev inspector that lets you test tools without Claude:
mcp dev server.py
This opens a web UI where you can call each tool, inspect the JSON schema, and verify outputs. Fix issues here before connecting to Claude Code — debugging inside a conversation is slower.
Connect to Claude Code
Add your server to Claude Code’s MCP configuration. Open .claude/settings.json in your project root:
{
"mcpServers": {
"my-tools": {
"command": "/path/to/my-mcp-server/.venv/bin/python",
"args": ["server.py"],
"cwd": "/path/to/my-mcp-server"
}
}
}
Use the full path to the Python binary inside your venv. Claude Code spawns this as a subprocess. If you use a relative path or the system Python, it won’t find your installed packages.
Restart Claude Code. Your tools appear as mcp__my-tools__get_system_info, mcp__my-tools__count_files, and mcp__my-tools__check_website.
Add Resources for Context
Tools handle actions. Resources handle data that Claude reads as context. Use them for configuration, reference data, or live state:
@mcp.resource("config://settings")
def get_settings() -> str:
"""Current server configuration."""
import json
settings = {
"version": "1.0.0",
"tools_registered": 3,
"max_timeout": 10,
}
return json.dumps(settings, indent=2)
Claude can read this resource to understand your server’s capabilities before calling tools.
Error Handling That Works
MCP tools should never raise unhandled exceptions. A crash kills the server process and disconnects all tools mid-conversation. Wrap external calls and return error strings:
@mcp.tool()
def read_config(path: str) -> str:
"""Read a configuration file and return its contents.
Args:
path: Path to the config file
"""
from pathlib import Path
try:
config_path = Path(path).expanduser()
if not config_path.exists():
return f"Error: {path} does not exist"
if config_path.stat().st_size > 100_000:
return f"Error: {path} is too large (>100KB)"
return config_path.read_text()
except PermissionError:
return f"Error: no permission to read {path}"
except Exception as e:
return f"Error reading {path}: {str(e)}"
Return errors as strings, not exceptions. Claude reads the error message and adjusts — retries with a different path, asks the user for help, or moves on. An unhandled exception just kills the connection.
Transport: Stdio vs HTTP — Which to Pick
MCP supports two transports: stdio (local child process — Claude spawns your script and pipes JSON-RPC over stdin/stdout) and HTTP (your server runs as a long-lived web service that Claude connects to via Streamable HTTP). The choice matters more than most tutorials make it sound.
Use stdio when:
- The server is for your local machine only (Claude Desktop, Claude Code CLI on your laptop)
- Tools need access to your local filesystem, local network, or local processes
- You don’t want to manage a hosted service
- Auth is implicitly your OS-level access (the script runs as you)
Use HTTP when:
- Multiple clients (Claude Desktop, Cursor, custom agents, teammates) need the same tools
- Tools rely on a centralized resource (production database, CRM, internal API)
- You want auditability — one server, one log, one place to revoke access
- The server holds long-lived state (cached embeddings, connection pools) that’s wasteful to recreate per stdio invocation
Concrete switch from stdio to HTTP looks like this — same FastMCP server, different transport:
from mcp.server.fastmcp import FastMCP
mcp = FastMCP("my-server")
@mcp.tool()
def search_docs(query: str) -> str:
"""Search internal documentation."""
# ... same tool implementation as stdio version ...
return results
if __name__ == "__main__":
# Stdio (default): Claude spawns this script as a child process
# mcp.run()
# HTTP / Streamable HTTP: serve on a port, multiple clients connect
mcp.run(transport="streamable-http", host="0.0.0.0", port=8000)
The tool definitions stay identical. Only the transport line changes. This is intentional in the spec — write the tools once, choose the deployment shape based on the use case.
Deploy MCP Server for Team / Multi-Client Use
Once you switch to HTTP transport, your MCP server is just a normal Python web service. The deployment patterns are the same ones you’d use for any FastAPI/Flask app. Three options ordered by setup effort:
1. Single Docker container behind a reverse proxy
Wrap the server in a Dockerfile, run it on a small VPS, put Caddy or nginx in front for HTTPS:
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
EXPOSE 8000
CMD ["python", "server.py"]
Clients connect with {"type": "streamable-http", "url": "https://mcp.example.com/", "headers": {"x-api-key": "..."}}. A /mo Hetzner CX11 handles dozens of tools comfortably.
2. Serverless (Cloud Run, Fly.io, Lambda + API Gateway)
If tool calls are infrequent and you don’t want to manage a VPS, serverless works — but Streamable HTTP needs the platform to support long-lived connections. Cloud Run and Fly.io handle this fine; Lambda’s 15-min execution cap means avoid for streaming sessions.
3. Behind your existing API gateway
If you already run an internal API (FastAPI app, etc.), mount the MCP server as a sub-route. Reuses auth middleware, logging, rate-limiting. The MCP Python SDK exposes an ASGI app you can mount with app.mount("/mcp", mcp.streamable_http_app()).
Auth: don’t ship without it
Stdio servers inherit your user’s privileges automatically — an HTTP server is reachable from the public internet. At minimum: require an x-api-key header on every tool call, generate a separate key per client/teammate (so revocation is one-key-scope), log which key called which tool, and never bake secrets into tool responses (they end up in conversation history Claude may serialize).
For tools that read or modify shared state (production DB, CRM), add scope checks inside each tool function. The MCP layer authenticates the request; the tool authorizes the specific action.
Project Structure for Production
Once your server grows past 3-4 tools, split into modules:
my-mcp-server/
server.py # Entry point — imports and registers tools
tools/
system.py # System info tools
web.py # HTTP/API tools
files.py # File operation tools
requirements.txt
Keep server.py minimal. Import tools from modules so each file stays focused:
from mcp.server.fastmcp import FastMCP
mcp = FastMCP("my-tools")
# Import tool modules — decorators register on import
import tools.system # noqa: F401
import tools.web # noqa: F401
import tools.files # noqa: F401
if __name__ == "__main__":
mcp.run(transport="stdio")
Each tool module imports the shared mcp instance and uses @mcp.tool() as usual. This pattern scales to 20+ tools without the main file becoming unreadable.
Common Mistakes
Printing to stdout. MCP uses stdout for JSON-RPC. Any print() statement corrupts the protocol and crashes the connection. Use logging to stderr instead:
import logging
import sys
logging.basicConfig(stream=sys.stderr, level=logging.INFO)
logger = logging.getLogger(__name__)
Missing type hints. Every parameter needs a type annotation. Without one, the MCP SDK can’t generate the JSON schema, and Claude won’t know what arguments to pass.
Blocking the event loop. If you have a CPU-heavy tool, run it in an executor. The MCP server is single-threaded — a blocking call freezes all tools:
import asyncio
@mcp.tool()
async def heavy_computation(data: str) -> str:
"""Run a CPU-intensive task without blocking other tools."""
loop = asyncio.get_event_loop()
result = await loop.run_in_executor(None, process_data, data)
return result
FAQ
Do I need to restart Claude Code after changing my MCP server?
Yes. Claude Code loads the MCP configuration at startup and spawns servers as subprocesses. After editing server.py, restart Claude Code to pick up changes. The mcp dev inspector is faster for iterating.
Can one MCP server connect to multiple AI clients?
Yes. The MCP protocol is client-agnostic. The same server works with Claude Code, Claude Desktop, and any other MCP-compatible client. No code changes needed — just point each client to your server’s entry point.
What’s the difference between tools and resources?
Tools perform actions — they take inputs, do something, and return results. Resources provide read-only context — configuration, reference data, live state. Claude reads resources for background knowledge and calls tools to take action.
Is there a limit on how many tools one server can expose?
No hard limit in the protocol. I run servers with 10-15 tools without issues. If you go past 20, consider splitting into multiple servers by domain (one for database ops, one for API calls, etc.) to keep tool descriptions focused.
I’m documenting the full build process — MCP servers, Claude Code agent patterns, and autonomous workflows — in my Build & Automate community. If you want step-by-step modules with real production code, that’s where it’s happening.
This post was published using Notipo — my Notion-to-WordPress sync tool. Write in Notion, publish to WordPress automatically.
Related Reading
- Give Your AI Agent Persistent Memory — Memory architectures for agents — file-based, vector, hybrid — with real-world trade-offs.
- Set Up OpenClaw as Your Personal AI Agent — End-to-end OpenClaw setup — install, configure, connect tools, run your first automation.
- Write Claude Code Agent Skills That Actually Work — Custom skills that consistently fire and produce useful output.