Build an MCP Server in Python: Add Custom Tools to Claude (2026)
The Model Context Protocol lets you extend Claude with capabilities that go far beyond conversation. File access, database queries, live API calls, internal tooling — if you can write it in Python, you can give Claude access to it. This guide walks you through building a working MCP server from scratch, connecting it to Claude Desktop, and understanding how the pieces fit together.
What is MCP?
Model Context Protocol (MCP) is Anthropic's open standard that lets you extend AI assistants like Claude with custom tools — file access, database queries, API calls, anything you can write in Python.
Think of MCP servers as plugins: Claude calls your server, your server executes code and returns results, Claude uses those results to answer the user.
MCP is transport-agnostic. Your server can run as a local process that Claude Desktop spawns on startup (using stdio), or as a remote HTTP service that multiple clients connect to. Both use the same protocol and the same Python SDK.
How MCP Works
The protocol follows a simple request-response loop:
- Claude receives a user request
- Claude decides it needs a tool (e.g., "read this file")
- Claude sends a tool call to your MCP server
- Your server executes the function and returns the result
- Claude uses the result to formulate its response
Claude does the reasoning — your server does the execution. Claude sees what tools are available (names, descriptions, input schemas) and decides when and how to call them. The descriptions you write on your tools directly influence when Claude reaches for them, so writing clear, accurate descriptions is important.
Installation
pip install mcp
For HTTP transport you will also want:
pip install starlette uvicorn httpx
And for timezone-aware datetime in the examples:
pip install pytz
Your First MCP Server
The minimal server structure: create a Server instance, register a list_tools handler so Claude knows what is available, register a call_tool handler that executes tool calls, then run the server over stdio.
# server.py
from mcp.server import Server
from mcp.server.stdio import stdio_server
from mcp import types
import asyncio
# Create server
app = Server("my-tools")
@app.list_tools()
async def list_tools() -> list[types.Tool]:
"""Tell Claude what tools are available."""
return [
types.Tool(
name="get_current_time",
description="Get the current date and time",
inputSchema={
"type": "object",
"properties": {
"timezone": {
"type": "string",
"description": "Timezone name (e.g. 'UTC', 'US/Eastern')",
"default": "UTC"
}
}
}
)
]
@app.call_tool()
async def call_tool(name: str, arguments: dict) -> list[types.TextContent]:
"""Execute a tool call from Claude."""
if name == "get_current_time":
from datetime import datetime
import pytz
tz = pytz.timezone(arguments.get("timezone", "UTC"))
now = datetime.now(tz)
return [types.TextContent(type="text", text=f"Current time: {now.strftime('%Y-%m-%d %H:%M:%S %Z')}")]
raise ValueError(f"Unknown tool: {name}")
async def main():
async with stdio_server() as (read_stream, write_stream):
await app.run(read_stream, write_stream, app.create_initialization_options())
if __name__ == "__main__":
asyncio.run(main())
Run it directly to verify it starts without errors:
python server.py
It will sit waiting for input — that is expected. Claude Desktop will handle the communication when you wire it up.
A More Useful Tool: File System Access
Most real MCP servers give Claude access to data it cannot reach on its own. File system access is the most common starting point. This example registers two tools — read_file and list_directory — and handles both in the same call_tool handler.
import os
from pathlib import Path
@app.list_tools()
async def list_tools() -> list[types.Tool]:
return [
types.Tool(
name="read_file",
description="Read the contents of a file",
inputSchema={
"type": "object",
"properties": {
"path": {"type": "string", "description": "File path to read"}
},
"required": ["path"]
}
),
types.Tool(
name="list_directory",
description="List files in a directory",
inputSchema={
"type": "object",
"properties": {
"path": {"type": "string", "description": "Directory path"}
},
"required": ["path"]
}
)
]
@app.call_tool()
async def call_tool(name: str, arguments: dict) -> list[types.TextContent]:
if name == "read_file":
path = Path(arguments["path"])
if not path.exists():
return [types.TextContent(type="text", text=f"Error: File {path} not found")]
content = path.read_text(encoding="utf-8")
return [types.TextContent(type="text", text=content)]
elif name == "list_directory":
path = Path(arguments["path"])
if not path.is_dir():
return [types.TextContent(type="text", text=f"Error: {path} is not a directory")]
files = [f.name for f in path.iterdir()]
return [types.TextContent(type="text", text="\n".join(files))]
Notice that errors are returned as TextContent rather than raised as exceptions. Claude can read the error message and decide how to proceed — that is better behavior than crashing the server.
Tool: Query a REST API
MCP tools can call external APIs and return the results to Claude. This makes it straightforward to give Claude real-time data access without any model fine-tuning.
import httpx
@app.list_tools()
async def list_tools():
return [
types.Tool(
name="get_github_repo",
description="Get information about a GitHub repository",
inputSchema={
"type": "object",
"properties": {
"owner": {"type": "string"},
"repo": {"type": "string"}
},
"required": ["owner", "repo"]
}
)
]
@app.call_tool()
async def call_tool(name: str, arguments: dict):
if name == "get_github_repo":
async with httpx.AsyncClient() as client:
r = await client.get(
f"https://api.github.com/repos/{arguments['owner']}/{arguments['repo']}"
)
data = r.json()
return [types.TextContent(type="text", text=(
f"Repo: {data['full_name']}\n"
f"Stars: {data['stargazers_count']}\n"
f"Description: {data['description']}\n"
f"Language: {data['language']}"
))]
httpx.AsyncClient is the right choice here because your MCP server is an async application. Avoid using requests (synchronous) inside async handlers — it will block the event loop.
Connect to Claude Desktop
Claude Desktop reads a configuration file on startup and spawns each configured MCP server as a subprocess. Add your server to ~/.config/claude/claude_desktop_config.json on Linux or Mac:
{
"mcpServers": {
"my-tools": {
"command": "python",
"args": ["/path/to/your/server.py"]
}
}
}
Or with uv (recommended if you manage dependencies with uv):
{
"mcpServers": {
"my-tools": {
"command": "uv",
"args": ["run", "/path/to/server.py"]
}
}
}
On Windows the config lives at %APPDATA%\Claude\claude_desktop_config.json.
Restart Claude Desktop after editing the file. Your tools will appear in the tool picker and Claude will use them automatically when relevant.
To debug connection issues, check the Claude Desktop logs at ~/.config/claude/logs/ — MCP server stderr is captured there.
Resources: Expose Data to Claude
Beyond tools, MCP also supports resources — structured data that Claude can read as context, similar to how you might attach a file to a conversation. Resources have URIs and MIME types.
@app.list_resources()
async def list_resources() -> list[types.Resource]:
return [
types.Resource(
uri="file:///etc/hostname",
name="System hostname",
mimeType="text/plain"
)
]
@app.read_resource()
async def read_resource(uri: str) -> str:
if uri == "file:///etc/hostname":
return Path("/etc/hostname").read_text()
Resources are a good fit for data that does not need parameters — configuration files, reference documents, current system state. Tools are better when you need dynamic inputs.
Prompts: Reusable Prompt Templates
MCP servers can also expose prompts — reusable templates that appear in Claude Desktop's prompt library. Users can invoke them directly without typing the full prompt each time.
@app.list_prompts()
async def list_prompts() -> list[types.Prompt]:
return [
types.Prompt(
name="code_review",
description="Review Python code for issues",
arguments=[
types.PromptArgument(name="code", description="Python code to review", required=True)
]
)
]
@app.get_prompt()
async def get_prompt(name: str, arguments: dict) -> types.GetPromptResult:
if name == "code_review":
return types.GetPromptResult(
description="Code review prompt",
messages=[
types.PromptMessage(
role="user",
content=types.TextContent(
type="text",
text=f"Review this Python code for bugs, style issues, and improvements:\n\n```python\n{arguments['code']}\n```"
)
)
]
)
HTTP Transport (for Remote Servers)
When you want a shared MCP server that multiple users or machines can connect to, run it over HTTP with Server-Sent Events transport instead of stdio.
from mcp.server.sse import SseServerTransport
from starlette.applications import Starlette
from starlette.routing import Route
transport = SseServerTransport("/messages/")
async def handle_sse(request):
async with transport.connect_sse(request.scope, request.receive, request._send) as streams:
await app.run(streams[0], streams[1], app.create_initialization_options())
starlette_app = Starlette(routes=[Route("/sse", endpoint=handle_sse)])
Run with uvicorn:
uvicorn server:starlette_app --host 0.0.0.0 --port 8080
Clients connect to http://yourserver:8080/sse. Clients that support remote MCP servers (including some Claude API integrations) can then use your server without installing anything locally.
Testing Your Server with mcp dev
The MCP SDK ships a development tool that lets you inspect your server interactively without Claude Desktop:
pip install "mcp[cli]"
mcp dev server.py
This opens a browser-based inspector where you can call tools manually, see the exact JSON being sent and received, and verify your server behaves correctly before wiring it into Claude.
Best Practices
Validate inputs: always check arguments before executing. Never assume Claude will send the right types — validate and return a clear error message if something is wrong.
Handle errors gracefully: return error messages as TextContent, do not crash. A crashed server disconnects from Claude and requires a restart.
Keep tools focused: one tool = one clear purpose. Claude uses the tool name and description to decide when to call it, so overlapping purposes lead to unpredictable behavior.
Write descriptive descriptions: Claude reads your description to decide when to call the tool. "Get information" is too vague. "Get the current star count, description, and primary language for a GitHub repository by owner and name" tells Claude exactly when this tool is useful.
Limit filesystem scope: if your tool reads files, consider accepting only relative paths within a configured base directory. This prevents Claude from inadvertently accessing sensitive system files.
Log to stderr: the MCP protocol uses stdout for communication. Any print statements or log output must go to stderr to avoid corrupting the protocol stream.
import sys
print("debug message", file=sys.stderr)
Or configure the standard logging module to write to stderr, which it does by default.