Building a Model Context Protocol Server in Python: A Complete Guide


Last updated: May 16, 2025
Learn how to create a powerful MCP server in Python to extend AI capabilities with custom tools and resources for enhanced productivity
Building a Model Context Protocol Server in Python: A Complete Guide
The Model Context Protocol (MCP) represents a significant advancement in how we interact with AI models. By creating custom MCP servers, developers can extend AI capabilities with specialized tools and resources, enabling more powerful and context-aware applications. This comprehensive guide will walk you through building your own MCP server in Python, from basic concepts to advanced implementations.
What is the Model Context Protocol?
The Model Context Protocol is a standardized way for AI systems to communicate with external tools and resources. An MCP server acts as a bridge between AI models and various capabilities, such as:
- Accessing specialized databases or knowledge bases
- Performing complex calculations or simulations
- Interacting with external APIs and services
- Processing and analyzing data in specialized ways
- Generating content like images, code, or other media
MCP servers enable AI models to go beyond their built-in capabilities, allowing them to leverage external tools and data sources to solve more complex problems.
Why Build an MCP Server in Python?
Python offers several advantages for developing MCP servers:
- Rich Ecosystem: Access to thousands of libraries for data processing, API integration, and more
- Simplicity: Clean syntax and readability make development faster
- AI/ML Integration: Seamless compatibility with popular AI and machine learning frameworks
- Cross-Platform: Works across different operating systems
- Community Support: Vast community and resources for troubleshooting
Prerequisites for Building an MCP Server
Before diving into development, ensure you have:
- Python Environment: Python 3.8+ installed
- Development Tools: A code editor (VS Code, PyCharm, etc.)
- Basic Knowledge: Familiarity with Python, APIs, and web servers
- Understanding of MCP: Basic knowledge of how the Model Context Protocol works
- Required Libraries:
fastapi
,uvicorn
,pydantic
, and other dependencies
MCP Server Architecture Overview
An MCP server typically consists of these core components:
- Server Framework: Handles HTTP/WebSocket communication
- Tool Definitions: Specifies the capabilities your server provides
- Resource Providers: Manages access to data sources
- Execution Engine: Processes requests and executes tool functions
- Authentication & Security: Ensures secure access to your server
Step-by-Step Guide to Building an MCP Server
Step 1: Setting Up Your Project (15 minutes)
Start by creating a new project directory and setting up a virtual environment:
# Create project directory
mkdir mcp-server
cd mcp-server
# Create and activate virtual environment
python -m venv venv
source venv/bin/activate # On Windows: venv\Scripts\activate
# Install required packages
pip install fastapi uvicorn pydantic python-dotenv
Create a basic project structure:
mcp-server/
├── venv/
├── app/
│ ├── __init__.py
│ ├── main.py
│ ├── tools/
│ │ ├── __init__.py
│ │ └── base.py
│ ├── resources/
│ │ ├── __init__.py
│ │ └── base.py
│ └── config.py
├── .env
└── requirements.txt
Step 2: Defining the MCP Server Configuration (30 minutes)
Create a configuration file to manage your server settings:
# app/config.py
import os
from pydantic import BaseSettings
from dotenv import load_dotenv
load_dotenv()
class Settings(BaseSettings):
APP_NAME: str = "Python MCP Server"
APP_VERSION: str = "0.1.0"
APP_DESCRIPTION: str = "A Model Context Protocol server implemented in Python"
# Server configuration
HOST: str = os.getenv("HOST", "0.0.0.0")
PORT: int = int(os.getenv("PORT", "8000"))
# Security settings
API_KEY: str = os.getenv("API_KEY", "")
ENABLE_AUTH: bool = os.getenv("ENABLE_AUTH", "True").lower() == "true"
# Logging
LOG_LEVEL: str = os.getenv("LOG_LEVEL", "INFO")
settings = Settings()
Step 3: Creating the Base Tool and Resource Classes (45 minutes)
Define the base classes for tools and resources:
# app/tools/base.py
from typing import Dict, Any, List, Optional
from pydantic import BaseModel, Field
class ToolParameter(BaseModel):
name: str
type: str
description: str
required: bool = True
default: Optional[Any] = None
class ToolSchema(BaseModel):
name: str
description: str
parameters: List[ToolParameter]
class BaseTool:
"""Base class for all MCP tools"""
@property
def schema(self) -> ToolSchema:
"""Return the tool's schema definition"""
raise NotImplementedError("Tool must implement schema property")
async def execute(self, params: Dict[str, Any]) -> Any:
"""Execute the tool with the provided parameters"""
raise NotImplementedError("Tool must implement execute method")
# app/resources/base.py
from typing import Dict, Any, Optional
from pydantic import BaseModel
class ResourceSchema(BaseModel):
name: str
description: str
uri_format: str
class BaseResource:
"""Base class for all MCP resources"""
@property
def schema(self) -> ResourceSchema:
"""Return the resource's schema definition"""
raise NotImplementedError("Resource must implement schema property")
async def get(self, uri: str, params: Optional[Dict[str, Any]] = None) -> Any:
"""Retrieve the resource identified by the URI"""
raise NotImplementedError("Resource must implement get method")
Step 4: Implementing a Sample Tool (1 hour)
Let's create a simple weather forecast tool:
# app/tools/weather.py
from typing import Dict, Any, List
import aiohttp
from app.tools.base import BaseTool, ToolSchema, ToolParameter
class WeatherForecastTool(BaseTool):
"""Tool for fetching weather forecasts"""
@property
def schema(self) -> ToolSchema:
return ToolSchema(
name="get_forecast",
description="Get weather forecast for a specific location",
parameters=[
ToolParameter(
name="city",
type="string",
description="City name (e.g., 'San Francisco')",
required=True
),
ToolParameter(
name="days",
type="integer",
description="Number of days to forecast",
required=False,
default=5
)
]
)
async def execute(self, params: Dict[str, Any]) -> Any:
"""Execute the weather forecast tool"""
city = params.get("city")
days = params.get("days", 5)
# In a real implementation, you would call a weather API
# This is a simplified example
async with aiohttp.ClientSession() as session:
# Replace with actual weather API endpoint
api_url = f"https://api.weatherapi.com/v1/forecast.json?key=YOUR_API_KEY&q={city}&days={days}"
async with session.get(api_url) as response:
if response.status == 200:
data = await response.json()
return {
"location": data["location"]["name"],
"country": data["location"]["country"],
"current": {
"temp_c": data["current"]["temp_c"],
"condition": data["current"]["condition"]["text"]
},
"forecast": [
{
"date": day["date"],
"max_temp_c": day["day"]["maxtemp_c"],
"min_temp_c": day["day"]["mintemp_c"],
"condition": day["day"]["condition"]["text"]
}
for day in data["forecast"]["forecastday"]
]
}
else:
return {"error": f"Failed to fetch weather data: {response.status}"}
Step 5: Implementing a Sample Resource (1 hour)
Now, let's create a resource for accessing weather data:
# app/resources/weather.py
from typing import Dict, Any, Optional
import aiohttp
from app.resources.base import BaseResource, ResourceSchema
class WeatherResource(BaseResource):
"""Resource for accessing weather data"""
@property
def schema(self) -> ResourceSchema:
return ResourceSchema(
name="weather",
description="Access current and historical weather data",
uri_format="weather://{city}/{data_type}"
)
async def get(self, uri: str, params: Optional[Dict[str, Any]] = None) -> Any:
"""Retrieve weather data based on the URI"""
# Parse the URI (format: weather://city/data_type)
parts = uri.replace("weather://", "").split("/")
if len(parts) != 2:
return {"error": "Invalid URI format. Expected: weather://city/data_type"}
city, data_type = parts
# In a real implementation, you would call a weather API
# This is a simplified example
async with aiohttp.ClientSession() as session:
# Replace with actual weather API endpoint
api_url = f"https://api.weatherapi.com/v1/current.json?key=YOUR_API_KEY&q={city}"
async with session.get(api_url) as response:
if response.status == 200:
data = await response.json()
if data_type == "current":
return {
"location": data["location"]["name"],
"country": data["location"]["country"],
"temperature": data["current"]["temp_c"],
"condition": data["current"]["condition"]["text"],
"humidity": data["current"]["humidity"],
"wind_kph": data["current"]["wind_kph"]
}
else:
return {"error": f"Unknown data type: {data_type}"}
else:
return {"error": f"Failed to fetch weather data: {response.status}"}
Step 6: Creating the Main Server Application (1 hour)
Now, let's build the main FastAPI application:
# app/main.py
from fastapi import FastAPI, Depends, HTTPException, Header
from fastapi.middleware.cors import CORSMiddleware
from typing import Dict, Any, List, Optional
import logging
from app.config import settings
from app.tools.weather import WeatherForecastTool
from app.resources.weather import WeatherResource
# Configure logging
logging.basicConfig(
level=getattr(logging, settings.LOG_LEVEL),
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
)
logger = logging.getLogger("mcp_server")
# Initialize FastAPI app
app = FastAPI(
title=settings.APP_NAME,
description=settings.APP_DESCRIPTION,
version=settings.APP_VERSION,
)
# Add CORS middleware
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# Initialize tools and resources
tools = {
"get_forecast": WeatherForecastTool(),
}
resources = {
"weather": WeatherResource(),
}
# Authentication dependency
async def verify_api_key(x_api_key: str = Header(None)):
if not settings.ENABLE_AUTH:
return True
if x_api_key != settings.API_KEY:
raise HTTPException(status_code=401, detail="Invalid API key")
return True
# Server info endpoint
@app.get("/")
async def get_server_info():
return {
"name": settings.APP_NAME,
"version": settings.APP_VERSION,
"description": settings.APP_DESCRIPTION,
}
# List available tools
@app.get("/tools")
async def list_tools(_: bool = Depends(verify_api_key)):
return {
"tools": [tool.schema for tool in tools.values()]
}
# Execute a tool
@app.post("/tools/{tool_name}")
async def execute_tool(
tool_name: str,
params: Dict[str, Any],
_: bool = Depends(verify_api_key)
):
if tool_name not in tools:
raise HTTPException(status_code=404, detail=f"Tool '{tool_name}' not found")
try:
logger.info(f"Executing tool: {tool_name} with params: {params}")
result = await tools[tool_name].execute(params)
return {"result": result}
except Exception as e:
logger.error(f"Error executing tool {tool_name}: {str(e)}")
raise HTTPException(status_code=500, detail=f"Error executing tool: {str(e)}")
# List available resources
@app.get("/resources")
async def list_resources(_: bool = Depends(verify_api_key)):
return {
"resources": [resource.schema for resource in resources.values()]
}
# Access a resource
@app.get("/resources")
async def access_resource(
uri: str,
params: Optional[Dict[str, Any]] = None,
_: bool = Depends(verify_api_key)
):
# Parse the URI to determine which resource handler to use
resource_type = uri.split("://")[0] if "://" in uri else None
if not resource_type or resource_type not in resources:
raise HTTPException(status_code=404, detail=f"Resource type '{resource_type}' not found")
try:
logger.info(f"Accessing resource: {uri}")
result = await resources[resource_type].get(uri, params)
return {"result": result}
except Exception as e:
logger.error(f"Error accessing resource {uri}: {str(e)}")
raise HTTPException(status_code=500, detail=f"Error accessing resource: {str(e)}")
# Run the server
if __name__ == "__main__":
import uvicorn
uvicorn.run(
"app.main:app",
host=settings.HOST,
port=settings.PORT,
reload=True,
)
Step 7: Running and Testing Your MCP Server (30 minutes)
Create a script to run your server:
# Create run.py in the project root
echo 'from app.main import app
import uvicorn
from app.config import settings
if __name__ == "__main__":
uvicorn.run(
app,
host=settings.HOST,
port=settings.PORT,
reload=True,
)' > run.py
Run your server:
python run.py
Your MCP server should now be running at http://localhost:8000
. You can test it using curl or any API client:
# Get server info
curl http://localhost:8000/
# List available tools
curl http://localhost:8000/tools
# Execute the weather forecast tool
curl -X POST http://localhost:8000/tools/get_forecast \
-H "Content-Type: application/json" \
-d '{"city": "San Francisco", "days": 3}'
Advanced MCP Server Features
Adding Authentication and Security (45 minutes)
Enhance your server's security with proper authentication:
# app/auth.py
from fastapi import Depends, HTTPException, status
from fastapi.security import APIKeyHeader
from app.config import settings
api_key_header = APIKeyHeader(name="X-API-Key", auto_error=False)
async def get_api_key(api_key: str = Depends(api_key_header)):
if not settings.ENABLE_AUTH:
return True
if api_key != settings.API_KEY:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid API key",
)
return True
Update your main.py to use this authentication module.
Implementing Streaming Responses (1 hour)
For tools that generate content incrementally, implement streaming responses:
# app/tools/text_generator.py
from typing import Dict, Any, AsyncGenerator
from fastapi import WebSocket
import json
from app.tools.base import BaseTool, ToolSchema, ToolParameter
class TextGeneratorTool(BaseTool):
"""Tool for generating text incrementally"""
@property
def schema(self) -> ToolSchema:
return ToolSchema(
name="generate_text",
description="Generate text based on a prompt",
parameters=[
ToolParameter(
name="prompt",
type="string",
description="The prompt to generate text from",
required=True
),
ToolParameter(
name="max_tokens",
type="integer",
description="Maximum number of tokens to generate",
required=False,
default=100
)
]
)
async def execute_stream(self, params: Dict[str, Any]) -> AsyncGenerator[str, None]:
"""Execute the text generator with streaming output"""
prompt = params.get("prompt")
max_tokens = params.get("max_tokens", 100)
# In a real implementation, you would call a text generation API
# This is a simplified example that just returns the prompt word by word
words = prompt.split()
for i, word in enumerate(words):
if i >= max_tokens:
break
yield word
# Simulate processing time
await asyncio.sleep(0.1)
Add a WebSocket endpoint to your main.py:
@app.websocket("/ws/tools/{tool_name}")
async def websocket_tool(websocket: WebSocket, tool_name: str):
await websocket.accept()
try:
# Receive parameters
data = await websocket.receive_text()
params = json.loads(data)
if tool_name not in tools:
await websocket.send_json({"error": f"Tool '{tool_name}' not found"})
await websocket.close()
return
tool = tools[tool_name]
# Check if the tool supports streaming
if hasattr(tool, "execute_stream"):
async for chunk in tool.execute_stream(params):
await websocket.send_json({"chunk": chunk})
await websocket.send_json({"done": True})
else:
# Fall back to non-streaming execution
result = await tool.execute(params)
await websocket.send_json({"result": result, "done": True})
except Exception as e:
logger.error(f"WebSocket error: {str(e)}")
await websocket.send_json({"error": str(e)})
finally:
await websocket.close()
Streaming responses require WebSocket support and proper error handling to manage connection issues. Always implement timeouts and reconnection logic in your client applications.
Implementing Tool Composition (1 hour)
Create a system for composing multiple tools together:
# app/tools/composer.py
from typing import Dict, Any, List
from app.tools.base import BaseTool, ToolSchema, ToolParameter
class ToolComposer(BaseTool):
"""Tool for composing multiple tools together"""
def __init__(self, available_tools: Dict[str, BaseTool]):
self.available_tools = available_tools
@property
def schema(self) -> ToolSchema:
return ToolSchema(
name="compose_tools",
description="Execute multiple tools in sequence, passing outputs as inputs",
parameters=[
ToolParameter(
name="steps",
type="array",
description="Array of tool execution steps",
required=True
)
]
)
async def execute(self, params: Dict[str, Any]) -> Any:
"""Execute a sequence of tools"""
steps = params.get("steps", [])
context = {} # Stores intermediate results
for i, step in enumerate(steps):
if "tool" not in step or step["tool"] not in self.available_tools:
return {"error": f"Invalid tool in step {i}: {step.get('tool')}"}
tool = self.available_tools[step["tool"]]
tool_params = step.get("params", {})
# Process parameter references (e.g., $results.step1.temperature)
for param_name, param_value in tool_params.items():
if isinstance(param_value, str) and param_value.startswith("$results."):
# Extract the reference path
ref_path = param_value[9:].split(".")
ref_value = context
for path_part in ref_path:
if path_part in ref_value:
ref_value = ref_value[path_part]
else:
return {"error": f"Invalid reference in step {i}: {param_value}"}
tool_params[param_name] = ref_value
# Execute the tool
result = await tool.execute(tool_params)
# Store the result in context
step_id = step.get("id", f"step{i}")
context[step_id] = result
return {"results": context}
Best Practices for MCP Server Development
1. Modular Design
Structure your MCP server with modularity in mind:
- Separate tools and resources into individual modules
- Use dependency injection for configuration and shared services
- Create abstract base classes for common functionality
- Implement plugin systems for extensibility
2. Error Handling and Logging
Implement robust error handling:
try:
# Operation that might fail
result = await perform_operation()
return result
except ValidationError as e:
logger.warning(f"Validation error: {str(e)}")
return {"error": "Invalid input", "details": str(e)}
except ExternalAPIError as e:
logger.error(f"External API error: {str(e)}")
return {"error": "External service unavailable", "details": str(e)}
except Exception as e:
logger.exception(f"Unexpected error: {str(e)}")
return {"error": "Internal server error", "details": str(e)}
3. Performance Optimization
Optimize your MCP server for performance:
- Use asynchronous programming for I/O-bound operations
- Implement caching for frequently accessed data
- Use connection pooling for database and API connections
- Consider horizontal scaling for high-traffic scenarios
4. Documentation
Document your MCP server thoroughly:
- Generate OpenAPI documentation for your API endpoints
- Document tool and resource schemas
- Provide usage examples for each tool and resource
- Create a comprehensive README with setup and deployment instructions
Common Challenges and Solutions
Challenge: Handling Large Responses
Solution: Implement pagination and streaming responses:
async def get_large_dataset(page: int = 0, page_size: int = 100):
# Calculate offset
offset = page * page_size
# Fetch data with pagination
data = await database.fetch_many(
query="SELECT * FROM large_table LIMIT :limit OFFSET :offset",
values={"limit": page_size, "offset": offset}
)
# Get total count
total = await database.fetch_val("SELECT COUNT(*) FROM large_table")
return {
"data": data,
"pagination": {
"page": page,
"page_size": page_size,
"total": total,
"pages": (total + page_size - 1) // page_size
}
}
Challenge: Managing External API Rate Limits
Solution: Implement rate limiting and backoff strategies:
class RateLimitedAPI:
def __init__(self, requests_per_minute: int = 60):
self.requests_per_minute = requests_per_minute
self.request_times = []
async def make_request(self, url: str, method: str = "GET", **kwargs):
# Check if we're exceeding rate limits
now = time.time()
self.request_times = [t for t in self.request_times if now - t < 60]
if len(self.request_times) >= self.requests_per_minute:
# Calculate wait time
oldest_request = min(self.request_times)
wait_time = 60 - (now - oldest_request)
logger.info(f"Rate limit reached, waiting {wait_time:.2f} seconds")
await asyncio.sleep(wait_time)
# Make the request
async with aiohttp.ClientSession() as session:
async with session.request(method, url, **kwargs) as response:
self.request_times.append(time.time())
return await response.json()
Challenge: Securing Sensitive Data
Solution: Implement proper secrets management:
# Use environment variables for sensitive data
API_KEY = os.getenv("API_KEY")
# Use a secrets manager for more complex scenarios
from azure.keyvault.secrets import SecretClient
from azure.identity import DefaultAzureCredential
credential = DefaultAzureCredential()
secret_client = SecretClient(vault_url="https://your-vault.vault.azure.net/", credential=credential)
api_key = secret_client.get_secret("api-key").value
Real-World MCP Server Use Cases
Use Case 1: AI-Powered Data Analysis
Create an MCP server that provides data analysis tools:
- Time series forecasting
- Anomaly detection
- Sentiment analysis
- Data visualization generation
- Statistical analysis
Use Case 2: Content Generation
Build an MCP server for content generation:
- Image generation and manipulation
- Text-to-speech conversion
- Language translation
- Code generation and formatting
- Document summarization
Use Case 3: Knowledge Base Access
Develop an MCP server that provides access to specialized knowledge:
- Medical research database
- Legal document analysis
- Financial data and analysis
- Scientific literature search
- Technical documentation
Conclusion
Building a Model Context Protocol server in Python opens up a world of possibilities for extending AI capabilities. By following this guide, you've learned how to create a robust MCP server that can provide specialized tools and resources to AI models.
The key to success lies in thoughtful design, proper error handling, and a focus on security and performance. As you continue to develop your MCP server, consider adding more tools and resources that leverage your unique domain expertise or data sources.
The future of AI lies in its ability to seamlessly integrate with specialized tools and knowledge sources. By building MCP servers, you're contributing to this ecosystem and enabling more powerful, context-aware AI applications.
Start small, focus on solving specific problems well, and gradually expand your MCP server's capabilities as you gain experience. The modular architecture outlined in this guide will make it easy to add new features and scale your server as needed.