
Ever wished you could extend Claude's capabilities with your own Python code without wrestling with REST APIs or complex middleware? That's exactly what the Model Context Protocol (MCP) makes possible. In this guide, we'll build production-ready MCP servers in Python—from simple tool definitions to advanced resource handling and deployment strategies.
Unlike TypeScript implementations that rely on the npm ecosystem, Python MCP development offers tight integration with scientific computing, data processing, and enterprise systems. We'll explore both the official MCP SDK and the newer FastMCP framework, showing you how to choose the right approach for your use case and understanding when each shines.
Table of Contents
- What Is MCP and Why Python?
- Understanding the MCP Architecture
- Why This Matters: Real-World Impact
- Setting Up Your First MCP Server
- Installation and Project Structure
- Your First Tool: The Official SDK Approach
- FastMCP: The Modern Alternative
- Async Handlers for I/O-Heavy Operations
- Real-World Integration Scenarios
- Packaging and Deployment
- Using uv for Fast Dependency Management
- Docker Deployment
- Production Considerations
- Common Pitfalls to Avoid
- Testing Your MCP Server
- Why Python MCP Matters in Real Organizations
- Advanced Patterns and Considerations
- The Learning Curve for Python Developers
- Building Resources: Beyond Tools
- Data Validation with Pydantic: Building Robust Tools
- Handling Streaming Responses: Real-Time Data
- Monitoring and Observability: Production Readiness
- Lifecycle Management: Startup and Shutdown
- Testing at Scale: Ensuring Reliability
- Summary: Building MCP Servers That Last
What Is MCP and Why Python?
The Model Context Protocol is a standardized interface that lets AI models access external tools, data sources, and services. An MCP server exposes these capabilities in a structured way that Claude (and other AI clients) can call seamlessly.
Think of it this way: instead of Claude having all possible capabilities built-in (which is impossible), MCP lets you attach custom functionality dynamically. Need Claude to query your internal database? Write an MCP server. Want it to control smart home devices? Another MCP server. This separation of concerns makes systems more maintainable and secure. Each server is independently deployable, versionable, and updatable. You can update your database query server without touching your smart home server.
Python is particularly powerful for MCP because it has native integration with pandas, NumPy, SQLAlchemy, and thousands of data/ML libraries. Development is faster with less boilerplate than TypeScript. It runs everywhere—Linux servers, Windows, macOS, Docker containers. Modern async/await support means I/O-heavy operations don't block your server. And if you need to integrate machine learning models, statistical analysis, or numerical computing, Python has unmatched libraries.
The key trade-off? TypeScript MCP servers typically have smaller memory footprints and start faster. Python wins on development speed and library ecosystem. For most use cases—especially those involving data processing, database queries, or machine learning—Python is the better choice. Your time investment in development pays dividends in deployment flexibility and library richness.
MCP follows a client-server architecture where Claude is the client and your Python code runs as the server. Communication happens via JSON-RPC protocol over stdin/stdout, which means you can even run Python MCP servers in resource-constrained environments like AWS Lambda or cloud functions (with some adaptation). This is profoundly different from HTTP-based APIs; there's no port management, no CORS configuration, no network stack overhead. It's as simple as starting a process and reading/writing JSON on standard streams.
Understanding the MCP Architecture
Before diving into code, let's understand how MCP actually works. When Claude needs to use an MCP tool, here's the choreography that happens behind the scenes:
Claude sends a request via JSON-RPC (a lightweight protocol for making remote calls). Your MCP server receives this request on stdin, processes it, and sends back a response on stdout. The beauty of this design is its simplicity—no HTTP servers, no complex authentication, no network configuration. Just stdin/stdout pipes. This architecture has profound implications for how you build servers. Because everything is JSON-RPC, there's no browser compatibility to worry about, no CORS headaches, no port management. You focus on logic, not infrastructure. The protocol is stateless—each request is independent, making servers simple to scale and reason about.
The MCP specification defines several message types: requests for tool calls, notifications about events, and responses with results. Your server needs to understand these messages, validate inputs, run business logic, and send back properly formatted responses. The official SDK and FastMCP both handle the message plumbing for you—you just write the tool logic.
This means you can focus on what matters: the business logic that makes your tool valuable. You don't spend time debugging protocol details; you spend time writing code that actually helps people.
Why This Matters: Real-World Impact
Understanding why MCP is valuable helps you design better servers. Consider a data scientist who has written Python code to analyze customer data. That code is valuable—it produces insights, finds patterns, helps the business. But it's trapped in a Jupyter notebook. Only Python developers can use it. It requires manual execution. There's no way to integrate it into business workflows.
With MCP, that Python code becomes instantly accessible to Claude. The data scientist can ask Claude questions about their data in natural language. Claude calls the MCP server, which runs the Python code, and returns results. The data scientist gets answers instantly. The code scales from "one person's notebook" to "organization-wide analytical capability."
This is where MCP's power emerges: not in exposing simple tools, but in exposing the valuable code you've already written and making it accessible through AI. Most organizations have significant codebases—data pipelines, analytical models, business logic—that only experts can access. A business analyst can't query a data warehouse because SQL isn't in their skill set. A product manager can't forecast revenue because the forecasting models are locked in Python scripts. A customer support agent can't access customer history because it requires writing database queries. MCP changes this by creating natural language interfaces to your code. The data warehouse query becomes "analyze our customer churn" instead of writing SQL. The forecasting model becomes "what's our revenue projection for Q3" instead of running Python scripts. Customer history becomes "show me this customer's interaction history" instead of database queries.
The real insight is that organizations are sitting on vast amounts of valuable, underutilized code. Build your MCP server around that code, and you've multiplied its impact by orders of magnitude. Suddenly that analytical model that was used by three people is being used by hundreds. Suddenly that business logic that was trapped in a backend service becomes accessible to non-technical users.
Another dimension of impact is skill utilization. Your Python engineers spend time writing code. But they also spend time running that code for other teams, explaining results, answering questions. It's valuable work but it's context-switching that pulls them away from building new features. An MCP server automates this knowledge transfer. Engineers build the server once, and then it answers questions for the entire organization 24/7. The engineer gets freed up for higher-leverage work, and the organization gets instant access to expertise they previously had to wait for.
Setting Up Your First MCP Server
Let's start with the basics. You'll need Python 3.10+ and pip (or uv for faster dependency management). The setup is straightforward, and the tooling has matured significantly, making it easier than ever to build servers that actually work.
Installation and Project Structure
# Create a project directory
mkdir my-mcp-server
cd my-mcp-server
# Create a virtual environment
python -m venv venv
source venv/bin/activate # On Windows: venv\Scripts\activate
# Install the official MCP SDK
pip install mcp
# Install FastMCP (optional, but recommended for rapid development)
pip install fastmcpHere's a minimal project structure that scales:
my-mcp-server/
├── venv/ # Virtual environment
├── src/
│ ├── __init__.py
│ └── server.py # Your MCP server
├── tests/
│ ├── __init__.py
│ └── test_server.py # Test file
├── pyproject.toml # Project metadata
├── requirements.txt # Dependencies
└── README.md # Documentation
This structure separates source code, tests, and configuration. It's clean, maintainable, and scales as your project grows. When you eventually containerize your server or distribute it as a package, this structure will serve you well.
Your First Tool: The Official SDK Approach
Let's build a simple server that exposes a weather lookup tool. We'll use the official MCP SDK first because understanding it shows you what's happening behind the convenience abstractions.
The official MCP SDK gives you fine-grained control over server lifecycle, resource management, and message handling. It's more verbose than FastMCP but gives you the full picture of how MCP works under the hood. Understanding the official SDK is valuable even if you eventually use FastMCP, because it shows you what's happening behind the convenience abstractions. You understand the foundation, not just the facade.
# src/server.py
import asyncio
from mcp.server import Server
from mcp.server.stdio import stdio_server
from mcp.types import Tool, TextContent, ToolResponse
# Initialize the server
server = Server(name="weather-server", version="1.0.0")
# Define a tool using the decorator pattern
@server.tool()
async def get_weather(location: str) -> str:
"""Fetch current weather for a location.
Args:
location: City name or coordinates (e.g., "San Francisco" or "37.7749,-122.4194")
Returns:
Weather description and temperature
"""
# In production, you'd call a real weather API
# For now, we'll mock the response
weather_data = {
"San Francisco": "Partly cloudy, 62°F",
"New York": "Clear, 45°F",
"London": "Rainy, 48°F"
}
result = weather_data.get(
location,
f"Weather data for {location} unavailable"
)
return result
# Server lifecycle
async def main():
# Configure the server to use stdio (Claude communicates via stdin/stdout)
async with stdio_server(server) as (read_stream, write_stream):
await server.run(read_stream, write_stream, initializationOptions={})
if __name__ == "__main__":
asyncio.run(main())What's happening here? The @server.tool() decorator registers get_weather with the MCP server. Claude can now call this tool directly. The function signature matters—MCP introspects your function to understand parameter names and types, docstring for the tool description, and return type. The docstring becomes Claude's documentation for the tool.
The stdio_server context manager handles all the message passing. Claude communicates with your server via stdin/stdout, and this abstraction handles the details gracefully. You don't need to think about JSON-RPC protocol details; the SDK manages that.
FastMCP: The Modern Alternative
FastMCP simplifies MCP server development with a lighter-weight API. Think of it as Flask for MCP—minimal boilerplate, maximum productivity. For most projects, FastMCP is the better starting point. You can always migrate to the official SDK later if you need advanced features like custom resource handling.
# src/server_fastmcp.py
from fastmcp import FastMCP
from pydantic import BaseModel, Field
from typing import Optional
import asyncio
# Initialize FastMCP server
mcp = FastMCP(name="weather-server")
class WeatherInput(BaseModel):
location: str = Field(..., description="City name")
units: str = Field(default="fahrenheit", description="Units")
# Register a tool with @mcp.tool decorator
@mcp.tool(
name="get_weather",
description="Get current weather for a location"
)
async def get_weather(location: str, units: str = "fahrenheit") -> dict:
"""Fetch weather data."""
# FastMCP automatically handles Pydantic validation
# and generates the tool schema
temps = {"San Francisco": 62, "New York": 45, "London": 48}
return {
"location": location,
"temperature": temps.get(location, 70),
"condition": "Partly cloudy",
"units": units
}
# Another tool: temperature conversion
@mcp.tool(description="Convert temperature between units")
async def convert_temperature(
temperature: float = Field(..., description="Temperature value"),
from_unit: str = Field(..., description="Source unit: celsius or fahrenheit"),
to_unit: str = Field(..., description="Target unit: celsius or fahrenheit")
) -> dict:
"""Convert between Celsius and Fahrenheit."""
if from_unit == to_unit:
return {"original": temperature, "converted": temperature, "unit": to_unit}
if from_unit == "celsius" and to_unit == "fahrenheit":
converted = (temperature * 9/5) + 32
else:
converted = (temperature - 32) * 5/9
return {
"original": temperature,
"from_unit": from_unit,
"converted": round(converted, 2),
"to_unit": to_unit
}
# Run the server
if __name__ == "__main__":
mcp.run()FastMCP advantages: Less boilerplate (no async context managers or server initialization), automatic tool registration and schema generation, built-in Pydantic support, cleaner for simple-to-moderate complexity servers. When to use FastMCP: simple tools, rapid prototyping, teaching MCP concepts, getting to production quickly. When to use official SDK: complex resource handling, fine-grained control, larger applications, custom transports.
Async Handlers for I/O-Heavy Operations
One of Python's superpowers is async/await. If your tools call APIs, query databases, or process files, async is crucial for server responsiveness and throughput. This is one of those technical decisions that seems like implementation detail but actually defines whether your MCP server is usable or not.
Understanding async/await requires thinking about what happens when your code waits for something external. An external API call, a database query, a file read—these all involve I/O that doesn't happen instantly. The operating system sends the request and waits for a response. Synchronous code (regular Python without async) blocks during that wait. The entire thread stops. If you have a function making an HTTP request that takes 100ms, and Claude calls that function three times in parallel, synchronous code will take 300ms (sequential). Async code will take 100ms (concurrent). The difference compounds: 100 concurrent requests take 10 seconds synchronously but 100ms with async.
But there's more to it than performance. It's about architecture. When you use sync code, you need thread pools. Each thread costs memory. You have limited threads. Eventually you run out and requests queue. When you use async, you have cooperative multitasking. You can handle thousands of concurrent operations with a single thread because they're not actually concurrent—they're coordinated by the event loop. While one request waits for a response, the event loop runs another request. While that one waits, it runs a third. Everyone makes progress.
For MCP servers especially, this matters because Claude might call your tools multiple times in parallel. If you have synchronous tools, the server becomes a bottleneck. Claude fires off five tool calls, and your server processes them sequentially. With async tools, the server processes them concurrently. Claude gets results faster. The user gets answers faster. Your server handles more load.
The tricky part is that async is infectious. If you use one async library, you need to await it. That makes your function async. Everything that calls that function needs to await too. This propagates up the call stack. Eventually everything is async. This is why understanding async/await architecture is crucial before you build. You can't just make one function async and leave the rest sync. You need to commit to the pattern throughout.
Here's the problem: if your tool makes a blocking HTTP request using the requests library, the entire MCP server halts until that request completes. If Claude calls two different tools simultaneously, the second one waits for the first to finish—defeating parallelism entirely. This is especially problematic when Claude chains multiple tool calls in a workflow. One slow tool blocks the entire system.
Async/await solves this. Your server can handle multiple tool calls concurrently, with each one pausing when it waits for I/O (network, disk, database) and resuming when the result arrives. This is especially important if Claude chains multiple tool calls in a workflow. The performance difference is dramatic: a tool that would take 10 seconds to complete five sequential API calls can complete in just 2 seconds if those calls happen in parallel.
# src/server_async.py
import asyncio
import aiohttp
from fastmcp import FastMCP
from typing import Optional
mcp = FastMCP(name="data-fetcher")
# Global session (reused across requests for efficiency)
session = None
async def get_session():
global session
if session is None:
session = aiohttp.ClientSession()
return session
@mcp.tool(description="Fetch JSON data from URL")
async def fetch_json(
url: str,
timeout: int = 10
) -> dict:
"""Fetch and parse JSON from a URL.
This is async so multiple requests don't block each other.
"""
sess = await get_session()
try:
async with sess.get(url, timeout=timeout) as response:
if response.status == 200:
return {
"success": True,
"data": await response.json(),
"status": response.status
}
else:
return {
"success": False,
"error": f"HTTP {response.status}",
"status": response.status
}
except asyncio.TimeoutError:
return {
"success": False,
"error": f"Request timed out after {timeout}s"
}
except Exception as e:
return {
"success": False,
"error": str(e)
}
@mcp.tool(description="Execute multiple async tasks in parallel")
async def fetch_multiple(urls: list[str]) -> dict:
"""Fetch multiple URLs concurrently.
This is much faster than sequential fetching.
"""
sess = await get_session()
tasks = [
sess.get(url, timeout=5)
for url in urls
]
results = []
try:
responses = await asyncio.gather(*tasks, return_exceptions=True)
for url, response in zip(urls, responses):
if isinstance(response, Exception):
results.append({
"url": url,
"success": False,
"error": str(response)
})
else:
try:
data = await response.json()
results.append({
"url": url,
"success": True,
"data": data
})
except:
results.append({
"url": url,
"success": False,
"error": "Invalid JSON"
})
finally:
# Clean up responses
for response in responses:
if hasattr(response, 'close'):
await response.close()
return {"results": results, "total": len(urls)}
if __name__ == "__main__":
mcp.run()Key async patterns:
- aiohttp for HTTP: Non-blocking HTTP requests. Regular
requestslibrary blocks the entire server. - asyncio.gather(): Run multiple async operations concurrently, not sequentially.
- Session reuse: Create one
aiohttp.ClientSessionand reuse it. Don't create new sessions per request.
The session reuse is important for performance. Creating a new HTTP session per request has overhead; reusing one session across many requests is dramatically faster. It's like the difference between opening a new restaurant kitchen for every meal versus using the same kitchen all day.
Real-World Integration Scenarios
Understanding how MCP fits into real applications is crucial. MCP doesn't exist in isolation—it connects your Python backend to Claude's intelligence. Your business logic, data access layer, and external API integrations stay where they are—in your Python code. MCP becomes the translation layer. Claude asks questions in natural language. Your MCP server translates those questions into function calls. The results come back as structured data Claude can reason about.
This means you're not limited to simple tool calls. You can expose complex business logic: decision engines, forecasting models, reporting systems, data transformation pipelines. Claude becomes the intelligent user interface to your entire backend.
Consider a customer support scenario. Your MCP server exposes tools to query customer history, look up orders, check inventory, process refunds. Claude can now help support agents instantly—not by replacing them, but by giving them perfect information about the customer and options for resolution. This is where MCP's power really shines.
Packaging and Deployment
Now that you've built a server, how do you deploy it? There are several strategies, each with different trade-offs around complexity, portability, and resource usage.
Using uv for Fast Dependency Management
uv is a Rust-based Python package manager that's dramatically faster than pip. For MCP servers, faster dependency resolution matters—it means faster deployment and startup times.
# Install uv
curl -LsSf https://astral.sh/uv/install.sh | sh
# Create pyproject.toml with your dependencies
cat > pyproject.toml << 'EOF'
[project]
name = "weather-mcp-server"
version = "1.0.0"
description = "Weather MCP server"
requires-python = ">=3.10"
dependencies = [
"mcp>=0.1.0",
"fastmcp>=0.1.0",
"aiohttp>=3.8.0",
"pydantic>=2.0.0"
]
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
EOF
# Create virtual environment and install
uv venv
source .venv/bin/activate
uv pip install -e .Docker Deployment
Container deployment ensures consistent environments across development, staging, and production. Docker isolates your MCP server from system Python variations, conflicting package versions, and environment-specific quirks. When you deploy a Docker image, you know exactly what environment your code runs in.
# Dockerfile
FROM python:3.11-slim
WORKDIR /app
# Copy project files
COPY pyproject.toml requirements.txt ./
COPY src ./src
# Install dependencies
RUN pip install --no-cache-dir -r requirements.txt
# Run the server
CMD ["python", "-m", "src.server"]Build and run:
docker build -t weather-mcp-server .
docker run --rm weather-mcp-serverProduction Considerations
Server Registration: In production, Claude needs to know how to reach your server. Register it in your claude_desktop_config.json:
{
"mcpServers": {
"weather": {
"command": "python",
"args": ["/path/to/server.py"]
}
}
}Error Handling: Always wrap tool logic in try/except. Graceful error handling is crucial because tool errors directly impact Claude's ability to complete tasks.
@mcp.tool()
async def safe_operation(input_data: str) -> dict:
try:
result = await do_something(input_data)
return {"success": True, "result": result}
except ValueError as e:
return {"success": False, "error": f"Invalid input: {e}"}
except Exception as e:
return {"success": False, "error": f"Server error: {e}"}Logging: Use Python's standard logging for debugging. When deployed, your MCP server runs in the background. Proper logging is critical for troubleshooting production issues.
import logging
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
@mcp.tool()
async def monitored_tool(x: int) -> int:
logger.info(f"Tool called with x={x}")
result = x * 2
logger.info(f"Returning {result}")
return resultCommon Pitfalls to Avoid
- Blocking calls in async functions: Never use
requestslibrary inside async handlers. Useaiohttp. Mixing blocking code in async functions defeats the entire purpose. - Forgetting to await: Every
asyncfunction call needsawait. Missing it returns a coroutine object, not the result. It's a common error that creates confusing bugs. - Session leaks: Reuse HTTP sessions rather than creating new ones per request. Each new session has initialization overhead.
- Over-documenting tools: Pydantic docstrings are auto-generated. Keep them concise. Let the field descriptions do the talking.
- Synchronous dependencies: Some libraries don't support async. Use
asyncio.to_thread()to run them without blocking:
import asyncio
import time
@mcp.tool()
async def slow_sync_operation() -> str:
# Run blocking function in thread pool
result = await asyncio.to_thread(time.sleep, 2)
return "Done"Testing Your MCP Server
Before deploying, write tests to ensure tools work correctly. This is essential because tool errors directly affect Claude's ability to complete tasks.
# tests/test_server.py
import asyncio
import pytest
from src.server_fastmcp import mcp, get_weather, convert_temperature
@pytest.mark.asyncio
async def test_get_weather_known_location():
"""Test weather tool with known city."""
result = await get_weather(location="San Francisco", units="fahrenheit")
assert result["success"] is True
assert result["temperature"] == 62
@pytest.mark.asyncio
async def test_temperature_conversion_c_to_f():
"""Test Celsius to Fahrenheit conversion."""
result = await convert_temperature(
temperature=0,
from_unit="celsius",
to_unit="fahrenheit"
)
assert result["converted"] == 32.0Run with:
pip install pytest pytest-asyncio
pytest tests/test_server.py -vWhy Python MCP Matters in Real Organizations
The real power of Python MCP servers becomes apparent when you think about how they solve actual problems in organizations. Most companies have Python code that does important work: data pipelines that process terabytes of information, machine learning models that make critical decisions, analytical tools that drive business strategy. That code is valuable, but it's often trapped in Jupyter notebooks, command-line scripts, or backend services that only engineers can access.
MCP changes this entirely. With an MCP server, you can expose that valuable Python code to Claude, making it accessible through natural language conversation. A data analyst can ask Claude to analyze customer churn patterns without writing a single Python command. A business strategist can ask Claude to forecast quarterly revenue based on historical data. A product manager can ask Claude to identify the highest-value feature requests from customer feedback. The Python code does the heavy lifting; Claude provides the natural language interface.
This is especially powerful when combined with Claude's reasoning capabilities. Claude doesn't just call your Python functions—it understands the results, interprets them in context, and explains what they mean to humans. Your Python code becomes part of Claude's reasoning process, not just a tool in a toolbox.
Advanced Patterns and Considerations
As your MCP server grows more complex, you'll encounter patterns that aren't obvious in simple examples. One critical pattern is connection pooling for database access. If your MCP server is going to make dozens of database queries per minute (because Claude is calling your database tool repeatedly), you need to reuse database connections rather than creating new ones. Creating a new connection to PostgreSQL takes hundreds of milliseconds; reusing one takes microseconds. At scale, connection pooling is the difference between responsive and glacial.
Another advanced pattern is caching. If Claude is repeatedly asking "What are the top 10 customers by revenue?", you don't want to query the database ten times. Implement caching at the application level: keep frequently-accessed data in memory, and update it periodically or on-demand. This is especially important for expensive computations or external API calls. Redis is perfect for this: it's fast, reliable, and designed for this exact use case.
Error recovery is another subtle but important pattern. Sometimes external services fail temporarily. Your MCP server should distinguish between transient errors (retry) and permanent errors (report failure). If your database is temporarily unavailable, retry after a few seconds. If your query is syntactically malformed, report the error immediately. Claude can learn from errors; help it learn the right lessons.
The Learning Curve for Python Developers
If you're a Python developer new to async/await, the learning curve is real but manageable. The key insight is that async functions are cooperative—they voluntarily pause when waiting for I/O, allowing other code to run. This is fundamentally different from threading, where the operating system forcibly pauses threads. With async, you have explicit control: await says "I'm waiting for something external, someone else can work while I wait."
The mental model is: synchronous code is like sequential instructions in a recipe. Async code is like managing multiple dinner preparations in parallel—while one sauce simmers, you're preparing the next course. When you await, you're saying "I'm waiting for the simmering to finish, go prepare something else."
Beginners often make mistakes with async. The most common: forgetting to await async function calls. If you call an async function without await, you get a coroutine object, not the result. This creates confusing bugs where your function seems to succeed but returns garbage data. The TypeScript compiler would catch this; Python won't. Be vigilant.
Building Resources: Beyond Tools
Tools are just the beginning. MCP also supports resources—static content that Claude can reference without computation. This might be documentation, configuration files, or reference data that informs Claude's decisions. Resources complement tools beautifully: tools are actions Claude can take, resources are information Claude can consult.
# src/resources.py
from fastmcp import FastMCP
from typing import Optional
mcp = FastMCP(name="docs-server")
# Define a resource that Claude can read
@mcp.resource(
name="api-documentation",
description="API endpoint documentation and examples"
)
async def get_api_documentation() -> str:
"""Return full API documentation."""
return """
# Company API Documentation
## Authentication
All requests require an API key in the Authorization header:
```
Authorization: Bearer YOUR_API_KEY
```
## Endpoints
### GET /customers/{id}
Retrieve a customer by ID.
Returns:
```json
{
"id": "customer_123",
"name": "ACME Corp",
"email": "contact@acme.com",
"created_at": "2024-01-15T10:30:00Z"
}
```
### POST /customers
Create a new customer.
Request body:
```json
{
"name": "string",
"email": "string"
}
```
"""
@mcp.resource(
name="database-schema",
description="Database schema and table definitions"
)
async def get_database_schema() -> str:
"""Return the database schema."""
return """
# Database Schema
## customers table
- id: UUID primary key
- name: VARCHAR(255) not null
- email: VARCHAR(255) unique
- created_at: TIMESTAMP default now()
- updated_at: TIMESTAMP
## orders table
- id: UUID primary key
- customer_id: UUID foreign key references customers(id)
- amount: DECIMAL(10,2)
- status: ENUM('pending', 'processing', 'completed', 'cancelled')
- created_at: TIMESTAMP default now()
"""Resources are loaded once and cached by Claude. Use them for documentation, schema information, or any static reference material. They're especially useful for maintaining consistency—when Claude has access to your actual API documentation, it's less likely to make incorrect assumptions about how your system works.
Data Validation with Pydantic: Building Robust Tools
The real backbone of production MCP servers isn't the protocol—it's rigorous input validation. Pydantic models do this automatically, turning string input from Claude into strongly-typed Python objects. But understanding how to design these models matters profoundly:
# src/models.py
from pydantic import BaseModel, Field, validator
from typing import Optional
from datetime import datetime
class CustomerFilter(BaseModel):
"""Filter criteria for searching customers."""
name: Optional[str] = Field(None, min_length=1, description="Customer name to search for")
email: Optional[str] = Field(None, description="Email address")
created_after: Optional[datetime] = Field(None, description="Only return customers created after this date")
limit: int = Field(default=10, ge=1, le=100, description="Number of results (1-100)")
@validator('email')
def validate_email(cls, v):
"""Ensure email format is valid."""
if v and '@' not in v:
raise ValueError('Invalid email format')
return v.lower()
class UpdateCustomerRequest(BaseModel):
"""Request body for updating a customer."""
customer_id: str = Field(..., description="The customer ID to update")
name: Optional[str] = Field(None, min_length=1, description="New customer name")
email: Optional[str] = Field(None, description="New email address")
@validator('customer_id')
def validate_customer_id(cls, v):
"""Ensure customer ID matches expected format."""
if not v.startswith('cust_'):
raise ValueError('Customer ID must start with cust_')
return v
# Now register tools with these models
@mcp.tool(description="Search for customers by name or email")
async def search_customers(name: str = None, email: str = None, limit: int = 10) -> dict:
"""
Pydantic models are used for validation.
This tool validates that limit is between 1-100.
Invalid input is rejected before your code runs.
"""
# By the time we reach here, validated is guaranteed
assert 1 <= limit <= 100
filters = CustomerFilter(name=name, email=email, limit=limit)
# Your actual implementation
return {"customers": [], "total": 0}This layered validation approach—Pydantic models for input, validators for business logic, assertions for sanity checks—creates defensive code that's still readable. Claude learns from validation errors; clear error messages help it retry correctly.
Handling Streaming Responses: Real-Time Data
For tools that produce large amounts of data or need to show progress, streaming responses give a better user experience. Instead of waiting for the entire result, Claude gets incremental updates:
# src/streaming_tools.py
from fastmcp import FastMCP
from typing import AsyncGenerator
mcp = FastMCP(name="streaming-server")
@mcp.tool(description="Analyze large dataset with progress updates")
async def analyze_dataset(dataset_id: str) -> AsyncGenerator[str, None]:
"""
Yield progress updates as analysis proceeds.
This prevents timeout on long operations.
"""
yield f"Starting analysis of dataset {dataset_id}...\n"
# Simulate multi-step processing
for i in range(10):
# Do some work
await asyncio.sleep(1) # Simulating computation
if i % 2 == 0:
yield f"Progress: processed {i*10}% of records\n"
yield "Analysis complete. Results ready.\n"Streaming is crucial for operations that take more than a few seconds. Without it, Claude times out waiting for results. With streaming, Claude sees progress and knows the tool is still working. This matters especially for data science tools, report generation, or any batch processing.
Monitoring and Observability: Production Readiness
Production MCP servers need visibility. You need to know which tools are slow, which ones error frequently, and how much computational load they're consuming. Implement structured logging and metrics:
# src/observability.py
import logging
import time
from functools import wraps
from typing import Callable, Any
import json
# Structured logging
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
)
logger = logging.getLogger(__name__)
class ToolMetrics:
"""Collect metrics about tool usage."""
def __init__(self):
self.invocations = {}
self.errors = {}
self.durations = {}
def record_invocation(self, tool_name: str, duration_ms: int, success: bool):
"""Record tool invocation metrics."""
if tool_name not in self.invocations:
self.invocations[tool_name] = 0
self.errors[tool_name] = 0
self.durations[tool_name] = []
self.invocations[tool_name] += 1
if not success:
self.errors[tool_name] += 1
self.durations[tool_name].append(duration_ms)
def get_summary(self) -> dict:
"""Get metrics summary for monitoring."""
summary = {}
for tool, count in self.invocations.items():
durations = self.durations[tool]
summary[tool] = {
"invocations": count,
"errors": self.errors[tool],
"error_rate": f"{(self.errors[tool]/count*100):.1f}%",
"avg_duration_ms": sum(durations) / len(durations) if durations else 0,
"max_duration_ms": max(durations) if durations else 0,
"min_duration_ms": min(durations) if durations else 0,
}
return summary
metrics = ToolMetrics()
def monitored_tool(func: Callable) -> Callable:
"""Decorator to monitor tool execution."""
async def wrapper(*args, **kwargs) -> Any:
start_time = time.time()
tool_name = func.__name__
try:
logger.info(f"Tool invoked: {tool_name}", extra={
"tool": tool_name,
"args": str(args)[:100], # Truncate large args
})
result = await func(*args, **kwargs)
duration_ms = (time.time() - start_time) * 1000
metrics.record_invocation(tool_name, duration_ms, success=True)
logger.info(f"Tool completed: {tool_name}", extra={
"tool": tool_name,
"duration_ms": duration_ms,
"status": "success"
})
return result
except Exception as e:
duration_ms = (time.time() - start_time) * 1000
metrics.record_invocation(tool_name, duration_ms, success=False)
logger.error(f"Tool failed: {tool_name}", extra={
"tool": tool_name,
"duration_ms": duration_ms,
"error": str(e),
"status": "error"
})
raise
return wrapper
# Use it on your tools
@mcp.tool()
@monitored_tool
async def expensive_operation(param: str) -> dict:
"""A tool we're monitoring."""
return {"result": "data"}With structured logging, you can aggregate logs and see patterns: which tools are slow, which ones fail frequently, when errors spike. This data is gold for optimization.
Lifecycle Management: Startup and Shutdown
Production servers need proper initialization and cleanup. Databases need connections, external services need authentication, resources need allocation:
# src/lifecycle.py
from fastmcp import FastMCP
import asyncio
mcp = FastMCP(name="lifecycle-server")
class DatabaseConnection:
def __init__(self):
self.connection = None
async def connect(self):
"""Initialize database connection on startup."""
logger.info("Initializing database connection")
# In production: actual database connection
await asyncio.sleep(0.1) # Simulate connection setup
self.connection = "connected"
logger.info("Database connected")
async def disconnect(self):
"""Clean up connection on shutdown."""
if self.connection:
logger.info("Closing database connection")
self.connection = None
db = DatabaseConnection()
@mcp.startup
async def startup():
"""Called when server starts."""
logger.info("Server starting up")
await db.connect()
@mcp.shutdown
async def shutdown():
"""Called when server shuts down."""
logger.info("Server shutting down")
await db.disconnect()
# Now your tools can safely use db.connection
@mcp.tool()
async def query_database(sql: str) -> dict:
"""Query the database."""
if not db.connection:
raise RuntimeError("Database not connected")
return {"result": "data"}Proper lifecycle management prevents resource leaks. When your server shuts down, connections close cleanly. When it starts, dependencies initialize in the right order. This matters especially when running in containers or serverless environments.
Testing at Scale: Ensuring Reliability
As your server grows, testing becomes critical. You need unit tests for individual tools, integration tests for the full server, and load tests to verify it handles concurrent requests:
# tests/test_integration.py
import pytest
import asyncio
from src.server_fastmcp import mcp, get_weather, fetch_json
@pytest.mark.asyncio
async def test_get_weather_integration():
"""Integration test: tool works end-to-end."""
result = await get_weather("San Francisco", units="celsius")
assert result["location"] == "San Francisco"
assert "temperature" in result
assert result["units"] == "celsius"
@pytest.mark.asyncio
async def test_multiple_concurrent_calls():
"""Load test: server handles concurrent requests."""
tasks = [
get_weather("New York"),
get_weather("London"),
get_weather("Tokyo"),
fetch_json("https://api.example.com/data"),
]
results = await asyncio.gather(*tasks)
assert len(results) == 4
assert all(r is not None for r in results)
@pytest.mark.asyncio
async def test_error_handling():
"""Error test: server gracefully handles failures."""
result = await fetch_json("https://invalid-url-that-will-fail.example.com")
assert result["success"] is False
assert "error" in resultRun tests before deployment. Especially load tests—they'll reveal bottlenecks and scaling issues you wouldn't discover otherwise.
Summary: Building MCP Servers That Last
Building MCP servers in Python is straightforward, but building them well requires thoughtfulness:
- Choose your framework: FastMCP for simplicity, official SDK for control
- Define tools with type hints: Let Pydantic validate inputs automatically
- Use async/await: Non-blocking I/O keeps your server responsive even under load
- Add resources: Provide documentation and reference data that Claude can consult
- Implement observability: Structured logging and metrics tell you what's happening
- Handle lifecycle properly: Initialize on startup, clean up on shutdown
- Test thoroughly: Unit, integration, and load tests catch problems before production
- Deploy with containers: Docker ensures your server runs identically everywhere
Python's ecosystem makes it natural to expose database queries, machine learning pipelines, and data processing workflows as Claude-accessible tools. This is where Python shines—not just as a language, but as a bridge between Claude's intelligence and your organization's data and algorithms.
Start simple. Build a server that exposes one or two tools. Get it running. Understand the patterns. Then expand. Add more tools. Handle edge cases. Monitor production behavior. The best MCP servers evolve gradually, learning from real usage rather than speculating about future needs.
The servers that provide the most value are those that expose meaningful business logic. Not "fetch this from an API" (though that's useful), but "analyze this customer data and predict churn risk," or "summarize this month's customer feedback and extract themes," or "recommend the next feature to build based on usage patterns." These are the tools that make Claude valuable to your organization, not just technically competent.
Your MCP server is now part of Claude's extended mind—use that power wisely. Build servers that amplify human intelligence, not replace it. Expose your organization's knowledge and capabilities in ways that let Claude help humans make better decisions. That's where the real value lives.
When you deploy your first production MCP server, you'll realize this architecture's elegance. Claude gets immediate access to your code. You maintain version control over your tools. The separation of concerns creates systems that are easier to understand, monitor, and improve. Most importantly, you've created infrastructure that multiplies your team's capabilities—Claude can now tap into your organization's knowledge and help your team work more effectively.
The best outcomes come from servers that solve real problems for real users. A tool that exists only because you could build it is a tool nobody uses. A tool that exists because you struggled without it is a tool that transforms workflows. Focus on the latter.
-iNet
Building intelligent connections between Claude and your code.