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.
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.