Build a Production-Grade MCP Server in Python with Tooling
Source: mortalapps.comThis guide provides a comprehensive, end-to-end tutorial on how to build a production-grade MCP server in Python using the Model Context Protocol (MCP) Python SDK. You will learn to securely expose custom tools and data resources to AI agents via a high-performance, streamable HTTP API. This project is tailored for AI engineers, backend developers, and MLOps professionals who need to integrate proprietary systems or internal APIs into agentic workflows, ensuring robust security, scalability, and observability. The primary search keyword 'build mcp server python production' is central to this guide's focus on creating a deployable, enterprise-ready solution.
The business problem addressed is the secure and standardized exposure of internal capabilities to autonomous AI agents. Traditional API integrations often lack the standardized metadata, authentication, and dynamic discovery mechanisms that agents require to operate effectively and safely. By adhering to MCP, this server provides a unified interface for agent-tool interaction.
By the end of this guide, you will have a fully functional MCP server, containerized with Docker, secured with OAuth 2.1, instrumented with OpenTelemetry for observability, and ready for deployment on Kubernetes. This architecture not only simplifies agent integration but also enforces critical production requirements like rate limiting, versioning, and robust error handling, making it a superior choice for enterprise-grade agent deployments.
What You Will Build
You will build a Python-based Model Context Protocol (MCP) server that acts as a secure and standardized gateway for AI agents to access a set of simulated internal tools and data resources. The server will implement the MCP Streamable HTTP transport, allowing agents to make efficient, structured requests and receive responses. Key features include OAuth 2.1 client credential flow for authentication, ensuring only authorized agents can invoke tools, and support for tool versioning.
The server will host example tools such as a product_lookup tool, which simulates querying product information, and a customer_profile_fetcher resource, which simulates retrieving customer data. It will handle incoming MCP requests, validate authentication tokens, execute the requested tool with appropriate parameters, and return results in a structured MCP-compliant format. The entire application will be packaged as a Docker container, complete with comprehensive logging, metrics collection via OpenTelemetry, and rate-limiting capabilities, making it deployable to Kubernetes. The final architecture will be a FastAPI application serving MCP endpoints, integrated with a custom tool registry, an authentication middleware, a rate-limiting mechanism, and observability instrumentation, all communicating over HTTP.
Technology Stack
| Component | Technology | Why This Choice |
|---|---|---|
| LLM | N/A |
This project focuses on building a tool server, not an AI agent itself. The server exposes tools for LLM-powered agents to consume. |
| Orchestration / API Framework | FastAPI, MCP Python SDK |
FastAPI provides a high-performance, asynchronous web framework for building the API endpoints. The MCP Python SDK standardizes the protocol implementation, ensuring compliance and ease of tool definition. |
| Memory / State Management | Redis |
Redis is used as a high-performance, in-memory data store for managing rate-limiting counters, providing fast and efficient state persistence for ephemeral data. |
| Tool Execution | Python functions |
Tool logic is implemented as standard asynchronous Python functions, allowing for flexible integration with any external system or internal logic. |
| Observability | OpenTelemetry, Prometheus/Grafana |
OpenTelemetry provides vendor-agnostic distributed tracing and metrics collection, crucial for debugging and monitoring. Prometheus for metrics storage and Grafana for visualization complete the stack. |
| Authentication | OAuth 2.1 (JWT validation) |
OAuth 2.1 with client credentials flow is an industry-standard, robust, and scalable method for securing machine-to-machine API access, ensuring only authorized agents can interact with tools. |
| Deployment | Docker, Kubernetes |
Docker enables consistent packaging and deployment, while Kubernetes provides enterprise-grade orchestration, scaling, and high availability for the server in production. |
The choice of FastAPI as the web framework is driven by its high performance, asynchronous capabilities, and automatic Pydantic model validation. These features are crucial for efficiently handling structured MCP requests and responses, making it ideal for building robust, high-throughput API services. While alternatives like Flask or Django could be used, FastAPI's focus on modern API development aligns perfectly with the requirements of a production-grade MCP server, offering better performance out of the box for I/O-bound tasks.
The MCP Python SDK is selected as the primary framework for standardizing tool exposure. It abstracts away much of the underlying protocol complexity, allowing developers to concentrate on the business logic of their tools rather than low-level MCP message serialization. This ensures inherent interoperability with any MCP-compliant agent, a significant advantage over custom API designs that would require bespoke integration efforts.
Redis serves as the in-memory data store for rate limiting. Its exceptional speed and efficiency for key-value operations make it an excellent choice for managing ephemeral state like API call counters. Using Redis minimizes latency associated with rate limit checks, which is critical for maintaining the responsiveness of an agent's tool interactions. Persistent databases would introduce unnecessary overhead for this use case.
OAuth 2.1 is implemented for authentication due to its widespread adoption, robust security features, and suitability for machine-to-machine communication via the client credentials flow. This standard provides a secure, auditable, and scalable mechanism for agents to obtain and use access tokens, avoiding the pitfalls of simpler, less secure API key management schemes. Integrating with an external identity provider offloads authentication complexity from the server itself.
For observability, the combination of OpenTelemetry for tracing and Prometheus/Grafana for metrics provides a comprehensive view of the server's health and performance. OpenTelemetry offers vendor-agnostic distributed tracing, which is indispensable for diagnosing issues in complex, multi-service agentic architectures. Prometheus excels at time-series data collection, while Grafana offers powerful visualization capabilities, together forming a production-ready monitoring solution far superior to basic logging.
Finally, Docker and Kubernetes are chosen for deployment. Docker ensures that the application runs consistently across development, testing, and production environments by packaging it into isolated containers. Kubernetes provides powerful orchestration capabilities, enabling automatic scaling, self-healing, and efficient resource management, which are essential for maintaining the high availability and resilience required of a production-grade agent tool server. This stack is a standard for modern cloud-native applications, offering a well-understood path to production.
Project Structure
.
├── Dockerfile
├── README.md
├── app
│ ├── __init__.py
│ ├── main.py
│ ├── config.py
│ ├── auth.py
│ ├── tools
│ │ ├── __init__.py
│ │ ├── product_lookup.py
│ │ └── customer_profile.py
│ ├── middleware.py
│ ├── models.py
│ └── observability.py
├── kubernetes
│ └── deployment.yaml
├── tests
│ ├── __init__.py
│ ├── test_auth.py
│ ├── test_tools.py
│ ├── test_server.py
│ └── conftest.py
├── requirements.txt
└── .env
The root directory contains essential project files: Dockerfile for containerization, README.md for project documentation, requirements.txt listing Python dependencies, and .env for local environment variables. The app/ directory is the core of the server logic. main.py initializes the FastAPI application and registers API routes. config.py centralizes application settings, loading them securely from environment variables. auth.py encapsulates the OAuth 2.1 token validation and authorization logic. The tools/ subdirectory houses individual tool implementations, such as product_lookup.py and customer_profile.py, each defining its specific capabilities and execution logic. middleware.py contains custom FastAPI middleware, including the rate-limiting implementation. models.py defines Pydantic models for request and response validation, aligning with MCP specifications. observability.py configures OpenTelemetry for tracing and metrics. The kubernetes/ directory holds deployment manifests, specifically deployment.yaml for orchestrating the server on a Kubernetes cluster. Finally, the tests/ directory contains unit and integration tests for various components: test_auth.py for authentication, test_tools.py for individual tool functionality, and test_server.py for overall API behavior, with conftest.py for shared fixtures. This structure promotes modularity, testability, and clear separation of concerns, crucial for a production-grade application.
Implementation
Phase 1: Environment Setup & Project Scaffold
Set up the project directory, define dependencies, create initial FastAPI application, and establish basic configuration and logging. This phase ensures the foundational structure is in place and the application can start without errors.
import os
from functools import lru_cache
from typing import Dict, Any
from pydantic_settings import BaseSettings, SettingsConfigDict
from fastapi import FastAPI
import logging
# Configure logging
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s')
logger = logging.getLogger(__name__)
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file=".env", extra="ignore")
APP_NAME: str = "MCP Tool Server"
APP_VERSION: str = "1.0.0"
OPENAPI_URL: str = "/openapi.json"
LOG_LEVEL: str = "INFO"
# OAuth 2.1 settings placeholders
OAUTH_TENANT_ID: str = ""
OAUTH_CLIENT_ID: str = ""
OAUTH_CLIENT_SECRET: str = ""
OAUTH_TOKEN_URL: str = ""
OAUTH_JWKS_URL: str = ""
# Rate Limiting placeholders
RATE_LIMIT_PER_MINUTE: int = 100
RATE_LIMIT_EXPIRATION_SECONDS: int = 60
# Redis for rate limiting (if used) placeholders
REDIS_HOST: str = "localhost"
REDIS_PORT: int = 6379
REDIS_DB: int = 0
@lru_cache()
def get_settings() -> Settings:
"""Caches settings to avoid re-reading .env for every request."""
logger.info("Loading application settings...")
return Settings()
app = FastAPI(
title=get_settings().APP_NAME,
version=get_settings().APP_VERSION,
openapi_url=get_settings().OPENAPI_URL,
docs_url="/docs",
redoc_url="/redoc"
)
@app.get("/health")
async def health_check() -> Dict[str, Any]:
"""
Health check endpoint for the server.
Returns basic status information.
"""
logger.debug("Health check requested.")
return {"status": "ok", "version": get_settings().APP_VERSION, "app_name": get_settings().APP_NAME}
# requirements.txt content:
# fastapi
# uvicorn
# pydantic
# pydantic-settings
# python-jose
# redis
# httpx
# opentelemetry-api
# opentelemetry-sdk
# opentelemetry-exporter-otlp
# opentelemetry-instrumentation-fastapi
# opentelemetry-instrumentation-httpx
# opentelemetry-instrumentation-redis
# opentelemetry-instrumentation-logging
# gunicorn
logging module to provide structured output, which is crucial for monitoring in production. The Settings class, powered by pydantic-settings, centralizes all application configurations. By using SettingsConfigDict(env_file=".env", extra="ignore"), we ensure that settings can be loaded from environment variables or a .env file, making the application portable across different environments. Placeholder values are provided for OAuth and Redis settings, which will be configured in subsequent phases. The @lru_cache() decorator on get_settings() ensures that environment variables are read only once at startup, optimizing performance.
A FastAPI instance is initialized, providing a high-performance ASGI web framework. The title, version, and OpenAPI documentation paths (/docs, /redoc) are set using the loaded settings. A basic /health endpoint is added to verify that the server is operational. This endpoint is fundamental for liveness and readiness probes in container orchestration systems like Kubernetes. The requirements.txt comment lists all necessary Python dependencies for the entire project, ensuring all tools are available from the start. This initial setup provides a robust, configurable, and observable foundation, essential for any production-grade service.
Save the code as app/main.py. Create app/config.py with the Settings class and get_settings function. Ensure app/main.py imports get_settings from app.config. Create a requirements.txt file in the root directory with the listed dependencies. Create a .env file in the root with placeholder values for OAUTH_TENANT_ID=dummy, OAUTH_CLIENT_ID=dummy, etc. Install dependencies (pip install -r requirements.txt) and run uvicorn app.main:app --reload. Access http://127.0.0.1:8000/health in your browser or with curl to verify a {"status": "ok"} response.
Phase 2: Implementing OAuth 2.1 Authentication
Integrate OAuth 2.1 client credentials flow for securing API endpoints. This involves validating JWT tokens using a JWKS endpoint, ensuring that only authenticated and authorized clients can access the MCP tools.
import os
import logging
from typing import Dict, Any, Optional
from datetime import datetime, timedelta
import httpx
from fastapi import FastAPI, Depends, HTTPException, status
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from jose import jwt, jwk
from jose.exceptions import JWTError
from app.config import get_settings, Settings
logger = logging.getLogger(__name__)
# Custom HTTPBearer scheme for dependency injection
oauth2_scheme = HTTPBearer()
class JwksClient:
"""Handles fetching and caching of JSON Web Key Set (JWKS)."""
def __init__(self, jwks_url: str):
self.jwks_url = jwks_url
self._keys: Dict[str, Any] = {}
self._last_fetched: Optional[datetime] = None
self._cache_duration = timedelta(hours=1) # Cache JWKS for 1 hour
async def _fetch_jwks(self) -> Dict[str, Any]:
"""Fetches JWKS from the configured URL."""
try:
async with httpx.AsyncClient(timeout=10) as client:
response = await client.get(self.jwks_url)
response.raise_for_status()
self._keys = {key['kid']: key for key in response.json().get('keys', [])}
self._last_fetched = datetime.utcnow()
logger.info(f"Successfully fetched JWKS from {self.jwks_url}")
return self._keys
except httpx.HTTPStatusError as e:
logger.error(f"HTTP error fetching JWKS from {self.jwks_url}: {e.response.status_code} - {e.response.text}")
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail="Failed to fetch JWKS")
except httpx.RequestError as e:
logger.error(f"Network error fetching JWKS from {self.jwks_url}: {e}")
raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="Network error fetching JWKS")
except Exception as e:
logger.error(f"Unexpected error fetching JWKS from {self.jwks_url}: {e}")
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Internal server error fetching JWKS")
async def get_key(self, kid: str) -> Optional[Dict[str, Any]]:
"""Retrieves a specific key by KID, fetching JWKS if needed or expired."""
if not self._keys or (self._last_fetched and datetime.utcnow() - self._last_fetched > self._cache_duration):
await self._fetch_jwks()
return self._keys.get(kid)
jwks_client: Optional[JwksClient] = None
async def initialize_jwks_client():
"""Initializes JWKS client on application startup."""
settings = get_settings()
global jwks_client
jwks_client = JwksClient(settings.OAUTH_JWKS_URL)
await jwks_client._fetch_jwks() # Pre-fetch JWKS on startup
async def get_current_user(credentials: HTTPAuthorizationCredentials = Depends(oauth2_scheme), settings: Settings = Depends(get_settings)) -> Dict[str, Any]:
"""
Dependency to validate the OAuth 2.1 bearer token.
"""
if not jwks_client:
logger.error("JWKS client not initialized.")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Authentication service not ready."
)
credentials_exception = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials",
headers={"WWW-Authenticate": "Bearer"},
)
try:
token = credentials.credentials # Extract token string from HTTPAuthorizationCredentials
# Get the kid from the token header
header = jwt.get_unverified_header(token)
kid = header.get("kid")
if not kid:
logger.warning("Token header missing 'kid'.")
raise credentials_exception
# Retrieve the public key using the kid
public_key_data = await jwks_client.get_key(kid)
if not public_key_data:
logger.warning(f"Public key with kid '{kid}' not found in JWKS.")
raise credentials_exception
public_key = jwk.construct(public_key_data)
# Decode and verify the token
payload = jwt.decode(
token,
public_key,
algorithms=["RS256"], # Assuming RS256, adjust if your provider uses different
audience=settings.OAUTH_CLIENT_ID, # The client ID of *this* server
options={"verify_signature": True, "verify_aud": True, "verify_exp": True}
)
# Validate issuer and other claims if necessary
# A more robust check might involve comparing against a list of allowed issuers
iss = payload.get("iss", "")
if not iss or settings.OAUTH_TENANT_ID not in iss:
logger.warning(f"Invalid issuer: {payload.get('iss')}")
raise credentials_exception
logger.info(f"Token validated for client: {payload.get('aud')}")
return payload # Return the decoded payload for further authorization checks
except JWTError as e:
logger.warning(f"JWT validation failed: {e}")
raise credentials_exception
except Exception as e:
logger.exception(f"An unexpected error occurred during token validation: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Internal authentication error."
)
JwksClient class is crucial for securely fetching and caching the JSON Web Key Set (JWKS) from the configured identity provider's URL. JWKS contains the public keys required to verify the digital signature of incoming JWT bearer tokens. Caching these keys for a _cache_duration of one hour significantly reduces network overhead and improves performance. Comprehensive error handling with httpx is included to manage network issues or HTTP errors during JWKS retrieval, gracefully failing with appropriate HTTPException responses.
The get_current_user asynchronous function serves as a FastAPI dependency, responsible for validating incoming bearer tokens. It first extracts the kid (key ID) from the JWT header to select the correct public key from the JwksClient. The python-jose library is then used to decode and verify the JWT. Key verification steps include checking the token's signature, its audience (ensuring the token is intended for this specific server, identified by OAUTH_CLIENT_ID), and its expiration. An initialize_jwks_client function is added to be called at application startup, pre-fetching the JWKS to avoid delays on the first authenticated request. Detailed logging is integrated to track authentication attempts and failures, which is vital for security auditing and debugging. Any validation failure results in an HTTPException with a 401 Unauthorized status, adhering to standard API security practices.
Save the code in app/auth.py. Update app/config.py with actual OAUTH_TENANT_ID, OAUTH_CLIENT_ID, OAUTH_TOKEN_URL, and OAUTH_JWKS_URL values from your identity provider (e.g., Azure AD). In app/main.py, import initialize_jwks_client and get_current_user from app.auth. Add @app.on_event("startup") to app/main.py calling initialize_jwks_client(). Create a new protected endpoint in app/main.py (e.g., @app.get("/test-auth") async def test_auth(user: Dict[str, Any] = Depends(get_current_user)): return {"message": "Auth successful"}). Run uvicorn app.main:app --reload. Obtain a valid OAuth 2.1 token for your client ID from your identity provider. Test with curl -H "Authorization: Bearer YOUR_VALID_TOKEN" http://127.0.0.1:8000/test-auth and without the header to verify authentication.
Phase 3: Tool Definition and Registry
Define the structure for MCP tools and resources, implement a simple in-memory tool registry, and create example tools (product_lookup, customer_profile). This phase establishes how the server discovers and manages its capabilities.
import logging
from typing import Dict, Any, Callable, List, Optional
from pydantic import BaseModel, Field
from typing import Annotated
from fastapi import Depends, HTTPException, status, APIRouter
from app.config import get_settings
from app.auth import get_current_user
logger = logging.getLogger(__name__)
class ToolMetadata(BaseModel):
name: str = Field(..., description="Unique name of the tool.")
description: str = Field(..., description="Description of the tool's functionality.")
version: str = Field("1.0.0", description="Version of the tool.")
input_schema: Dict[str, Any] = Field({}, description="JSON schema for tool input parameters.")
output_schema: Dict[str, Any] = Field({}, description="JSON schema for tool output.")
is_resource: bool = Field(False, description="True if this is a resource, False if it's an action tool.")
rate_limit_per_minute: Optional[int] = Field(None, description="Rate limit for this specific tool, overrides global.")
class Tool:
"""Represents an executable tool or resource."""
def __init__(self, metadata: ToolMetadata, func: Callable[..., Any]):
self.metadata = metadata
self.func = func
async def execute(self, **kwargs: Any) -> Any:
"""Executes the tool function."""
logger.info(f"Executing tool: {self.metadata.name} with args: {kwargs}")
try:
result = await self.func(**kwargs)
logger.info(f"Tool {self.metadata.name} executed successfully.")
return result
except Exception as e:
logger.error(f"Error executing tool {self.metadata.name}: {e}", exc_info=True)
raise
class ToolRegistry:
"""Manages a collection of registered tools."""
def __init__(self):
self._tools: Dict[str, Tool] = {}
logger.info("ToolRegistry initialized.")
def register_tool(self, tool: Tool):
"""Registers a new tool."""
if tool.metadata.name in self._tools:
logger.warning(f"Tool '{tool.metadata.name}' already registered. Overwriting.")
self._tools[tool.metadata.name] = tool
logger.info(f"Tool '{tool.metadata.name}' (v{tool.metadata.version}) registered.")
def get_tool(self, name: str) -> Optional[Tool]:
"""Retrieves a tool by its name."""
return self._tools.get(name)
def get_all_tools_metadata(self) -> List[ToolMetadata]:
"""Returns metadata for all registered tools."""
return [tool.metadata for tool in self._tools.values()]
# Initialize the global tool registry
tool_registry = ToolRegistry()
# --- Example Tool Definitions ---
async def product_lookup_func(product_id: str, region: str = "us-east-1") -> Dict[str, Any]:
"""Simulates looking up product details by ID."""
logger.info(f"Simulating product lookup for ID: {product_id} in region: {region}")
# In a real scenario, this would call an external API or database
if product_id == "P123":
return {"product_id": product_id, "name": "Wireless Headphones", "price": 99.99, "region": region}
elif product_id == "P456":
return {"product_id": product_id, "name": "Smartwatch", "price": 199.99, "region": region}
else:
logger.warning(f"Product ID {product_id} not found.")
return {"product_id": product_id, "name": "Not Found", "price": 0.0, "region": region}
product_lookup_tool = Tool(
metadata=ToolMetadata(
name="product_lookup",
description="Looks up product details by a given product ID and optional region.",
version="1.1.0",
input_schema={
"type": "object",
"properties": {
"product_id": {"type": "string", "description": "The unique identifier of the product.", "pattern": "^[P][0-9]{3}$"},
"region": {"type": "string", "description": "The geographic region to search in (default: us-east-1).", "enum": ["us-east-1", "us-west-2", "eu-central-1"]}
},
"required": ["product_id"]
},
output_schema={
"type": "object",
"properties": {
"product_id": {"type": "string"},
"name": {"type": "string"},
"price": {"type": "number"},
"region": {"type": "string"}
}
},
rate_limit_per_minute=50
),
func=product_lookup_func
)
tool_registry.register_tool(product_lookup_tool)
async def get_customer_profile_func(customer_id: Annotated[int, Field(ge=1)], include_orders: bool = False) -> Dict[str, Any]:
"""Simulates fetching a customer's profile details."""
logger.info(f"Simulating fetching customer profile for ID: {customer_id}, include_orders: {include_orders}")
if customer_id == 101:
profile = {"customer_id": 101, "name": "Alice Smith", "email": "[email protected]"}
if include_orders:
profile["orders"] = [{"order_id": "ORD001", "amount": 120.50}, {"order_id": "ORD002", "amount": 55.00}]
return profile
else:
logger.warning(f"Customer ID {customer_id} not found.")
return {"customer_id": customer_id, "name": "Not Found"}
customer_profile_resource = Tool(
metadata=ToolMetadata(
name="customer_profile_fetcher",
description="Retrieves a customer's profile by ID. Can optionally include recent orders.",
version="1.0.0",
input_schema={
"type": "object",
"properties": {
"customer_id": {"type": "integer", "description": "The unique identifier of the customer.", "minimum": 1},
"include_orders": {"type": "boolean", "description": "Whether to include recent order history (default: false)."}
},
"required": ["customer_id"]
},
output_schema={
"type": "object",
"properties": {
"customer_id": {"type": "integer"},
"name": {"type": "string"},
"email": {"type": "string"},
"orders": {"type": "array", "items": {"type": "object"}, "description": "List of recent orders"}
}
},
is_resource=True,
rate_limit_per_minute=30
),
func=get_customer_profile_func
)
tool_registry.register_tool(customer_profile_resource)
mcp_router = APIRouter(prefix="/.well-known/mcp")
@mcp_router.get("/tools", response_model=List[ToolMetadata])
async def get_mcp_tools(current_user: Dict[str, Any] = Depends(get_current_user)) -> List[ToolMetadata]:
"""
MCP endpoint to discover available tools and resources.
Requires authentication.
"""
logger.info(f"MCP tool discovery requested by client: {current_user.get('aud')}")
return tool_registry.get_all_tools_metadata()
@mcp_router.post("/invoke/{tool_name}", response_model=Dict[str, Any])
async def invoke_mcp_tool(
tool_name: str,
payload: Dict[str, Any],
) -> Dict[str, Any]:
"""
MCP endpoint to invoke a specific tool.
Requires authentication.
"""
tool = tool_registry.get_tool(tool_name)
if not tool:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"Tool '{tool_name}' not found.")
# Basic input validation against schema (can be more robust with jsonschema library)
# For this guide, we rely on Pydantic's internal validation where possible and tool's func errors.
try:
# Convert payload to Pydantic model for validation if a model was defined, or rely on func's type hints
# For simplicity, direct pass to function and let it raise errors based on type hints
result = await tool.execute(**payload)
return result
except TypeError as e:
logger.error(f"Type error during tool invocation for '{tool_name}': {e}", exc_info=True)
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=f"Invalid input parameters for tool '{tool_name}'. Details: {e}")
except HTTPException:
raise # Re-raise HTTP exceptions from tool execution
except Exception as e:
logger.error(f"Error invoking tool '{tool_name}': {e}", exc_info=True)
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Error executing tool '{tool_name}'.")
ToolMetadata Pydantic model is introduced to provide a structured definition for each tool, including its name, description, version, and crucially, its input_schema and output_schema using JSON Schema. These schemas are vital for agents to understand how to interact with the tool and what to expect in return. A rate_limit_per_minute field allows for granular, tool-specific rate limiting, overriding global settings.
The Tool class encapsulates a tool's metadata and its asynchronous execution function, providing a consistent interface for invocation. The ToolRegistry acts as an in-memory repository, managing the lifecycle of tools by allowing registration, retrieval, and listing of all available tools. Two example tools, product_lookup_func and get_customer_profile_func, are implemented. These functions simulate interactions with external systems and demonstrate how to structure tool logic and return MCP-compliant responses. Each function is wrapped into a Tool object with its ToolMetadata and registered.
An APIRouter named mcp_router is created with a /.well-known/mcp prefix, and get_current_user is added as a dependency, ensuring all MCP endpoints are protected by OAuth 2.1. The /tools endpoint exposes the metadata of all registered tools, enabling agent discovery. The /invoke/{tool_name} endpoint handles tool execution, dynamically retrieving the tool from the registry and executing it with the provided payload. Basic input validation is implicitly handled by Pydantic and type hints, with explicit error handling for TypeError during invocation, returning 400 Bad Request for malformed inputs. This structure provides a scalable and organized way to manage a growing number of tools within a secure, protocol-compliant server.
Save the code as app/tools.py. In app/main.py, import mcp_router from app.tools and add app.include_router(mcp_router). Run uvicorn app.main:app --reload. Obtain a valid OAuth 2.1 token. Test tool discovery: curl -H "Authorization: Bearer YOUR_VALID_TOKEN" http://127.0.0.1:8000/.well-known/mcp/tools. Test tool invocation: curl -X POST -H "Authorization: Bearer YOUR_VALID_TOKEN" -H "Content-Type: application/json" -d '{"product_id": "P123", "region": "us-west-2"}' http://127.0.0.1:8000/.well-known/mcp/invoke/product_lookup. Test customer_profile_fetcher: curl -X POST -H "Authorization: Bearer YOUR_VALID_TOKEN" -H "Content-Type: application/json" -d '{"customer_id": 101, "include_orders": true}' http://127.0.0.1:8000/.well-known/mcp/invoke/customer_profile_fetcher. Test with invalid input (e.g., "product_id": "INVALID") to check 400 Bad Request.
Phase 4: Rate Limiting and Global Error Handling
Implement a robust rate-limiting mechanism using Redis for persistence and enhance global error handling across the application, ensuring stability, preventing abuse, and providing consistent API responses.
import logging
import time
from typing import Dict, Any, Optional
from datetime import datetime, timedelta
import redis.asyncio as redis
from fastapi import FastAPI, Request, Response, Depends, HTTPException, status
from fastapi.responses import JSONResponse
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.types import ASGIApp
from app.config import get_settings, Settings
from app.tools import tool_registry # Ensure tool registry is imported
logger = logging.getLogger(__name__)
# Initialize Redis client globally
redis_client: Optional[redis.Redis] = None
async def initialize_redis_client():
"""Initializes Redis client on application startup."""
settings = get_settings()
global redis_client
try:
redis_client = redis.Redis(host=settings.REDIS_HOST, port=settings.REDIS_PORT, db=settings.REDIS_DB, decode_responses=True)
await redis_client.ping() # Test connection
logger.info(f"Connected to Redis at {settings.REDIS_HOST}:{settings.REDIS_PORT}")
except Exception as e:
logger.critical(f"Failed to connect to Redis at {settings.REDIS_HOST}:{settings.REDIS_PORT}. Rate limiting will be disabled. Error: {e}")
redis_client = None # Disable rate limiting if Redis connection fails
class RateLimitMiddleware(BaseHTTPMiddleware):
"""Middleware for global and tool-specific rate limiting."""
def __init__(self, app: ASGIApp, settings: Settings):
super().__init__(app)
self.settings = settings
logger.info("RateLimitMiddleware initialized.")
async def dispatch(self, request: Request, call_next):
if not redis_client:
logger.warning("Redis client not available. Skipping rate limiting.")
return await call_next(request)
# Identify client for rate limiting (e.g., from validated token)
# We'll try to get the 'user' payload from request.state if auth middleware ran before this
client_id = "anonymous"
current_user = getattr(request.state, 'user', None)
if current_user:
client_id = current_user.get("aud", "unknown_client")
# Determine rate limit key and limits
path = request.url.path
rate_limit_key = f"rate_limit:{client_id}:global"
limit_per_minute = self.settings.RATE_LIMIT_PER_MINUTE
expiration_seconds = self.settings.RATE_LIMIT_EXPIRATION_SECONDS
# Tool-specific rate limiting for invoke endpoint
if path.startswith("/.well-known/mcp/invoke/"): # Check for MCP invoke path
try:
tool_name = path.split("/")[-1]
tool = tool_registry.get_tool(tool_name)
if tool and tool.metadata.rate_limit_per_minute is not None:
limit_per_minute = tool.metadata.rate_limit_per_minute
rate_limit_key = f"rate_limit:{client_id}:tool:{tool_name}"
logger.debug(f"Applying tool-specific rate limit for {tool_name}: {limit_per_minute}/min")
else:
logger.debug(f"No specific rate limit for tool {tool_name}. Using global.")
except IndexError:
logger.warning(f"Malformed tool invocation path: {path}. Using global rate limit.")
# Increment counter and check
current_requests = await redis_client.incr(rate_limit_key)
if current_requests == 1:
await redis_client.expire(rate_limit_key, expiration_seconds)
# Retrieve TTL to calculate remaining time
ttl = await redis_client.ttl(rate_limit_key)
if ttl == -1: # Key exists but no expiration set (shouldn't happen with expire above, but for safety)
await redis_client.expire(rate_limit_key, expiration_seconds)
ttl = expiration_seconds
elif ttl == -2: # Key does not exist (e.g., expired between incr and ttl call, or race condition)
ttl = expiration_seconds # Assume full duration remaining if key somehow disappeared
if current_requests > limit_per_minute:
logger.warning(f"Rate limit exceeded for client {client_id} on key {rate_limit_key}. Current: {current_requests}, Limit: {limit_per_minute}")
raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail=f"Rate limit exceeded. Try again in {ttl} seconds.",
headers={
"Retry-After": str(ttl),
"X-RateLimit-Limit": str(limit_per_minute),
"X-RateLimit-Remaining": "0",
"X-RateLimit-Reset": str(int(time.time()) + ttl)
}
)
response = await call_next(request)
response.headers["X-RateLimit-Limit"] = str(limit_per_minute)
response.headers["X-RateLimit-Remaining"] = str(max(0, limit_per_minute - current_requests))
response.headers["X-RateLimit-Reset"] = str(int(time.time()) + ttl) # Approximate reset time
return response
# Global exception handler for HTTPException for consistent JSON responses
async def http_exception_handler(request: Request, exc: HTTPException):
logger.error(f"HTTP Exception caught: {exc.status_code} - {exc.detail} for path {request.url.path}")
return JSONResponse(
status_code=exc.status_code,
content={
"detail": exc.detail,
"error_type": exc.detail.split('.')[0] if exc.detail else "HTTP_ERROR"
},
headers=exc.headers
)
# Catch-all exception handler for unexpected server errors
async def catch_all_exception_handler(request: Request, exc: Exception):
logger.exception(f"Unhandled server error for path {request.url.path}: {exc}")
return JSONResponse(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
content={"detail": "An unexpected server error occurred.", "error_type": "INTERNAL_SERVER_ERROR"},
)
initialize_redis_client function, called at startup, attempts to connect to a Redis instance. If the connection fails, rate limiting is gracefully disabled with a critical log message, ensuring the server can still operate (albeit without protection) rather than crashing. The RateLimitMiddleware is a FastAPI middleware that intercepts incoming requests.
Within the middleware, it identifies the client (using the aud claim from the authenticated user's payload, passed via request.state) and determines the appropriate rate limit. It checks for global rate limits defined in app/config.py and also for tool-specific rate limits defined in ToolMetadata for /invoke endpoints. Redis's incr and expire commands are used to atomically increment counters and set their expiration, ensuring accurate rate tracking. If a client exceeds the limit, an HTTPException with 429 Too Many Requests is raised, including Retry-After and X-RateLimit headers for client-side guidance. This mechanism protects the server from abuse and ensures fair resource allocation.
Beyond rate limiting, global exception handlers are added to app/main.py for HTTPException and a catch-all Exception. The http_exception_handler ensures that all standard HTTP errors are returned as consistent JSON responses with detailed information and appropriate status codes. The catch_all_exception_handler prevents the server from crashing on unexpected errors, logging the full traceback and returning a generic 500 Internal Server Error to the client. This centralized error management improves both the developer experience for debugging and the stability of the production system.
Ensure Redis is running locally (e.g., docker run --name some-redis -p 6379:6379 -d redis/redis-stack:latest). Save the code in app/middleware.py. In app/main.py, import initialize_redis_client, RateLimitMiddleware, http_exception_handler, and catch_all_exception_handler. Add app.add_middleware(RateLimitMiddleware, settings=get_settings()) and register the exception handlers: app.add_exception_handler(HTTPException, http_exception_handler) and app.add_exception_handler(Exception, catch_all_exception_handler). Update the main startup_event to call await initialize_redis_client(). Run uvicorn app.main:app --reload. Rapidly send requests to /health (or any authenticated endpoint with a valid token) to trigger the global rate limit. Then, rapidly invoke a tool like product_lookup to test its specific rate limit. Verify 429 responses and appropriate headers. Introduce a deliberate error in a tool function (e.g., raise ValueError("Test error")) to verify the 500 Internal Server Error handler.
Phase 5: OpenTelemetry Integration for Observability
Integrate OpenTelemetry for distributed tracing and metrics, providing deep visibility into tool invocation workflows, server performance, and inter-service communication.
import os
import logging
from typing import Dict, Any
from opentelemetry import trace
from opentelemetry.sdk.resources import Resource
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import ConsoleSpanExporter, BatchSpanProcessor
from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter
from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor
from opentelemetry.instrumentation.httpx import HTTPXClientInstrumentor
from opentelemetry.instrumentation.redis import RedisInstrumentor
from opentelemetry.instrumentation.logging import LoggingInstrumentor
from app.config import get_settings
from fastapi import FastAPI # Import FastAPI for instrumentation
logger = logging.getLogger(__name__)
def setup_opentelemetry(app_instance: FastAPI, settings: Settings):
"""
Configures OpenTelemetry for the FastAPI application.
"""
resource = Resource.create({
"service.name": settings.APP_NAME,
"service.version": settings.APP_VERSION,
"deployment.environment": os.environ.get("ENV", "development")
})
provider = TracerProvider(resource=resource)
# Configure exporter
# For local development, use ConsoleSpanExporter or a local Jaeger/Zipkin collector
# For production, use OTLPSpanExporter to send to an OTLP collector (e.g., Azure Monitor, Datadog, Honeycomb)
otlp_endpoint = os.environ.get("OTEL_EXPORTER_OTLP_ENDPOINT")
if otlp_endpoint:
exporter = OTLPSpanExporter(
endpoint=otlp_endpoint,
headers={"Content-Type": "application/json"} # Adjust if your collector expects protobuf
)
logger.info(f"Using OTLP exporter for OpenTelemetry to {otlp_endpoint}")
else:
exporter = ConsoleSpanExporter()
logger.info("Using Console exporter for OpenTelemetry (OTEL_EXPORTER_OTLP_ENDPOINT not set).")
span_processor = BatchSpanProcessor(exporter)
provider.add_span_processor(span_processor)
trace.set_tracer_provider(provider)
# Instrument FastAPI application
FastAPIInstrumentor.instrument_app(app_instance, tracer_provider=provider)
logger.info("FastAPI instrumented for OpenTelemetry.")
# Instrument HTTPX for outgoing requests (e.g., JWKS fetching)
HTTPXClientInstrumentor().instrument()
logger.info("HTTPX client instrumented for OpenTelemetry.")
# Instrument Redis client
RedisInstrumentor().instrument()
logger.info("Redis client instrumented for OpenTelemetry.")
# Instrument logging to inject trace_id and span_id
LoggingInstrumentor().instrument(set_logging_format=True)
logger.info("Logging instrumented for OpenTelemetry.")
setup_opentelemetry function initializes a TracerProvider, which is the core of OpenTelemetry's tracing capabilities. It defines a Resource that tags all generated traces with service-specific information like service.name and deployment.environment, making it easier to identify and filter traces in a distributed system.
The function then configures an OTLPSpanExporter if the OTEL_EXPORTER_OTLP_ENDPOINT environment variable is set, allowing traces to be sent to an external OpenTelemetry Collector (e.g., Jaeger, Azure Monitor). If not set, it defaults to a ConsoleSpanExporter for local development. A BatchSpanProcessor is used to efficiently export spans asynchronously, minimizing performance impact.
Crucially, various instrumentors are applied: FastAPIInstrumentor automatically traces incoming HTTP requests to our server, capturing details about each request's lifecycle. HTTPXClientInstrumentor instruments outgoing HTTP requests, which is essential for observing calls made by our JwksClient to the identity provider. RedisInstrumentor provides visibility into interactions with the Redis rate-limiting store. Finally, LoggingInstrumentor enriches standard Python logs with trace and span IDs, allowing for seamless correlation of log messages with specific distributed traces. This comprehensive instrumentation, configured during application startup, provides deep insights into the server's performance, dependencies, and behavior, which is invaluable for debugging and monitoring in production environments.
Save the code as app/observability.py. In app/main.py, import setup_opentelemetry from app.observability. In your main startup_event function, after initializing Redis and JWKS, add setup_opentelemetry(app, get_settings()). Run uvicorn app.main:app --reload. Make several requests to various endpoints (health, protected, invoke). Observe the console output for OpenTelemetry traces (if OTEL_EXPORTER_OTLP_ENDPOINT is not set). If an OTLP collector (like Jaeger or a local otel-collector-contrib Docker image) is configured via OTEL_EXPORTER_OTLP_ENDPOINT in your .env, verify traces appear in its UI. Check your logs for trace_id and span_id fields.
Phase 6: Dockerization and Kubernetes Manifest
Package the MCP server into a Docker image and create a basic Kubernetes deployment manifest, preparing it for production deployment, including secure secrets management, health checks, and resource requests.
# Dockerfile
FROM python:3.9-slim-buster
# Set environment variables for Python
ENV PYTHONUNBUFFERED 1
ENV APP_HOME /app
# Create app directory and set as working directory
WORKDIR $APP_HOME
# Install system dependencies (if any, e.g., for specific Python packages like cryptography)
# Uncomment and add if needed:
# RUN apt-get update && apt-get install -y --no-install-recommends
# build-essential
# # libffi-dev
# # libssl-dev
# && rm -rf /var/lib/apt/lists/*
# Copy requirements file and install Python dependencies
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# Copy application code
COPY app/ $APP_HOME/app/
# Expose the port FastAPI runs on
EXPOSE 8000
# Command to run the application using Gunicorn with Uvicorn workers for production
CMD ["gunicorn", "app.main:app", "--workers", "4", "--worker-class", "uvicorn.workers.UvicornWorker", "--bind", "0.0.0.0:8000", "--log-level", "info"]
Dockerfile is meticulously crafted to create a lean and efficient Docker image. It starts with python:3.9-slim-buster, a minimal base image to reduce the final image size and attack surface. PYTHONUNBUFFERED 1 ensures that Python logs are immediately flushed, which is crucial for real-time monitoring in containerized environments. Dependencies from requirements.txt are installed with --no-cache-dir to prevent unnecessary cache layers in the image. The application code is then copied into the image.
The CMD instruction specifies how the application will run in production. Instead of uvicorn directly, we use gunicorn with uvicorn.workers.UvicornWorker. Gunicorn acts as a robust process manager, handling worker management, graceful shutdowns, and load balancing across multiple Uvicorn workers, significantly improving the server's stability and performance under load. It binds to 0.0.0.0:8000 to make the application accessible within the container. The --log-level info ensures consistent logging behavior. This Docker setup provides a consistent and isolated environment, ensuring the application behaves identically from development to production.
The kubernetes/deployment.yaml (not in the code block, but part of the output) defines the Kubernetes resources. It specifies a Deployment for running multiple replicas of our MCP server pods, ensuring high availability. Crucially, sensitive environment variables like OAuth credentials are loaded from a Kubernetes Secret using secretKeyRef, preventing hardcoding and enhancing security. Health and readiness probes (livenessProbe, readinessProbe) are configured to monitor the application's health, enabling Kubernetes to automatically restart unhealthy pods and prevent traffic from being routed to unready instances. Resource requests and limits are defined to manage CPU and memory consumption, crucial for efficient cluster utilization and preventing resource starvation. A Service manifest exposes the deployment internally within the Kubernetes cluster, making it discoverable. This complete Kubernetes setup provides a robust, scalable, and secure deployment strategy.
Create a Dockerfile in your project's root directory with the provided content. Ensure your requirements.txt is up-to-date. Build the Docker image: docker build -t mcp-server:latest .. Run the image locally: docker run -p 8000:8000 --env-file .env mcp-server:latest. Verify the application starts and responds to /health and tool invocation requests. Then, create kubernetes/deployment.yaml with the Kubernetes configuration provided in the project_structure explanation. Replace your-docker-repo/mcp-server:latest with your actual image name and tag, and populate the stringData in the Secret with real values. Apply it to a Kubernetes cluster (kubectl apply -f kubernetes/deployment.yaml). Monitor pod status (kubectl get pods), check logs (kubectl logs <pod-name>), and verify service accessibility within the cluster.
Testing & Evaluation
Building a production-grade MCP server requires a comprehensive testing and evaluation strategy to ensure reliability, security, and performance. Our approach covers functional correctness, LLM-as-a-Judge for tool output quality (though less critical for a tool server, it's relevant for agents consuming its output), failure scenario testing, edge case coverage, performance validation, and regression testing.
Functional Testing
Unit tests are written for individual components like authentication, tool execution, and rate limiting. Integration tests verify the end-to-end flow, ensuring that the FastAPI application correctly routes requests, applies middleware, invokes tools, and returns valid MCP responses. We use pytest with httpx.AsyncClient for asynchronous testing of FastAPI endpoints. Mocking is extensively used to isolate components, such as get_current_user to bypass actual OAuth validation during testing, and product_lookup_tool.func to control tool output.
For example, a test for tool invocation might look like this:
import pytest
from httpx import AsyncClient
from app.main import app
from app.auth import get_current_user
from app.tools import tool_registry, product_lookup_tool
from unittest.mock import AsyncMock, patch
from fastapi import status
from app.config import get_settings
# Override get_current_user for testing to bypass actual OAuth validation
def override_get_current_user():
return {"aud": "test_client_id", "sub": "test_user"}
app.dependency_overrides[get_current_user] = override_get_current_user
@pytest.fixture(scope="module")
async def async_client():
async with AsyncClient(app=app, base_url="http://test") as client:
yield client
@pytest.mark.asyncio
async def test_mcp_tools_discovery(async_client: AsyncClient):
response = await async_client.get("/.well-known/mcp/tools")
assert response.status_code == status.HTTP_200_OK
data = response.json()
assert isinstance(data, list)
assert any(tool['name'] == "product_lookup" for tool in data)
@pytest.mark.asyncio
async def test_invoke_product_lookup_success(async_client: AsyncClient):
# Mock the actual tool function to control its output and avoid external calls
with patch.object(product_lookup_tool, 'func', new_callable=AsyncMock) as mock_func:
mock_func.return_value = {"product_id": "P123", "name": "Test Product", "price": 10.0, "region": "us-east-1"}
payload = {"product_id": "P123", "region": "us-west-2"}
response = await async_client.post("/.well-known/mcp/invoke/product_lookup", json=payload)
assert response.status_code == status.HTTP_200_OK
assert response.json() == {"product_id": "P123", "name": "Test Product", "price": 10.0, "region": "us-east-1"}
mock_func.assert_called_once_with(product_id="P123", region="us-west-2")
@pytest.mark.asyncio
async def test_invoke_non_existent_tool(async_client: AsyncClient):
response = await async_client.post("/.well-known/mcp/invoke/non_existent_tool", json={})
assert response.status_code == status.HTTP_404_NOT_FOUND
assert "not found" in response.json()["detail"]
@pytest.mark.asyncio
async def test_rate_limit_exceeded(async_client: AsyncClient, mocker):
# Mock Redis to simulate rate limiting
mocker.patch('app.middleware.redis_client.incr', new_callable=AsyncMock)
mocker.patch('app.middleware.redis_client.expire', new_callable=AsyncMock)
mocker.patch('app.middleware.redis_client.ttl', new_callable=AsyncMock)
# Simulate exceeding the global rate limit (e.g., limit is 1, current requests become 2)
app.dependency_overrides[get_current_user] = lambda: {"aud": "rate_limit_test_client"} # Isolate client
settings_mock = get_settings()
settings_mock.RATE_LIMIT_PER_MINUTE = 1
mocker.patch('app.config.get_settings', return_value=settings_mock)
# First request should pass
app.middleware.redis_client.incr.return_value = 1
app.middleware.redis_client.ttl.return_value = 59
response_ok = await async_client.get("/health")
assert response_ok.status_code == status.HTTP_200_OK
# Second request should be rate limited
app.middleware.redis_client.incr.return_value = 2
response_limited = await async_client.get("/health")
assert response_limited.status_code == status.HTTP_429_TOO_MANY_REQUESTS
assert "Rate limit exceeded" in response_limited.json()["detail"]
assert "Retry-After" in response_limited.headers
# Restore original dependencies after tests
@pytest.fixture(autouse=True)
def cleanup_dependency_overrides():
yield
app.dependency_overrides.clear()LLM-as-a-Judge Evaluation
While this project focuses on the MCP server itself rather than an agent, the quality of tool outputs is paramount for the agents consuming them. An LLM-as-a-Judge can be used to evaluate the semantic correctness and helpfulness of the tool's responses to a given query, especially for tools that return complex or natural language-like data. For instance, if a tool summarizes a document, an LLM can assess the summary's accuracy and conciseness against the original document and the prompt. This involves feeding the LLM the original request, the tool's output, and a rubric, asking it to rate the response.
Failure Scenario Testing
We must simulate various failure modes to ensure robustness:
- Authentication Failures: Test with invalid, expired, or unauthorized client tokens. The server should consistently return
401 Unauthorizedor403 Forbidden. - Tool Execution Errors: Simulate internal errors within a tool (e.g., database connection failure, external API timeout). The server should return
500 Internal Server Errorand log the incident details. - External Dependency Failures: Simulate Redis being unavailable (rate limiting should be disabled gracefully), or the JWKS endpoint being unreachable (authentication should fail with
502 Bad Gatewayor503 Service Unavailable). - Invalid Input: Test with payloads that do not conform to the tool's
input_schema. The server should return400 Bad Requestwith informative details.
Edge Case Coverage
- Empty payloads: Test how the server and tools handle empty or minimal JSON payloads, especially for optional parameters.
- Malicious inputs: Attempt SQL injection, oversized payloads, or deeply nested JSON structures to ensure input validation and sanitization prevent crashes or security vulnerabilities. Pydantic's validation helps, but external libraries might be needed for deeper checks.
- High concurrency: Use load testing tools (e.g., Locust, k6) to simulate many concurrent requests. This tests rate limiting effectiveness, overall server stability, and resource utilization under stress.
- Tool versioning: Verify that the server correctly exposes tool versions in the discovery endpoint and that agents can discern between different versions if supported.
Performance Validation
Load testing is critical for a production system. Tools like Locust or k6 can simulate thousands of concurrent users to:
- Measure P99 latency for key endpoints (
/.well-known/mcp/tools,/.well-known/mcp/invoke/{tool_name}). - Identify throughput limits before performance degrades significantly.
- Monitor CPU, memory, and network usage of the deployed containers.
- Verify that rate limiting effectively protects the backend from overload.
OpenTelemetry traces will be invaluable here, pinpointing slow operations or dependencies.
Regression Testing
Automated test suites (unit, integration, and API tests) are integrated into a CI/CD pipeline. Any new code changes or feature additions must pass these tests before deployment to ensure existing functionality remains intact. This prevents regressions and maintains the stability of the MCP server, especially as new tools are added or existing ones are updated.
By combining these testing methodologies, we ensure the MCP server is not only functionally correct but also resilient, secure, and performant in a production environment.
Deployment
Deploying the MCP server from local development to a production Kubernetes cluster involves several steps, including containerization, defining Kubernetes manifests, and managing secrets securely.
1. Dockerization
First, ensure your Dockerfile (as provided in Phase 6) is in your project root. This Dockerfile creates a lean Python image with all dependencies and your application code. The gunicorn entrypoint provides a robust ASGI server for production.
To build your Docker image:
docker build -t your-docker-repo/mcp-server:latest .Replace your-docker-repo with your actual Docker registry (e.g., mycompanyacr.azurecr.io or gcr.io/my-project). After building, push the image to your container registry:
docker push your-docker-repo/mcp-server:latest2. Kubernetes Manifests
Your kubernetes/deployment.yaml (also from Phase 6) defines the necessary Kubernetes resources:
- Deployment: Manages multiple replicas of your MCP server pods.
- Service: Exposes your deployment internally within the cluster.
- Secret: Securely stores sensitive environment variables.
Before deploying, update the image field in deployment.yaml to point to your pushed Docker image (e.g., image: your-docker-repo/mcp-server:latest).
3. Secrets Management
Never hardcode sensitive information. In Kubernetes, Secrets are used for this. The kubernetes/deployment.yaml includes a Secret manifest. You must populate the stringData with your actual values for OAuth configuration. For production, consider using a Secret management solution like HashiCorp Vault, Azure Key Vault, or AWS Secrets Manager, integrated with Kubernetes CSI Secret Store drivers, to inject secrets dynamically.
To create the secret in your Kubernetes cluster:
kubectl apply -f kubernetes/deployment.yaml(Ensure the Secret manifest is applied first, or part of a combined manifest.)
4. Redis Deployment
Our MCP server relies on Redis for rate limiting. You'll need a Redis instance accessible within your Kubernetes cluster. For production, consider a managed Redis service (e.g., Azure Cache for Redis, AWS ElastiCache) or deploy a highly available Redis cluster on Kubernetes using a Helm chart (e.g., Bitnami Redis).
If deploying Redis within Kubernetes, ensure its Service name matches the REDIS_HOST environment variable configured in your deployment.yaml (e.g., mcp-server-redis.default.svc.cluster.local).
5. OpenTelemetry Collector Deployment
For production observability, you'll typically deploy an OpenTelemetry Collector to receive traces and metrics from your MCP server. This collector can then export data to your chosen observability backend (e.g., Jaeger, Prometheus/Grafana, Azure Monitor, Datadog).
Ensure the OTEL_EXPORTER_OTLP_ENDPOINT environment variable in your deployment.yaml points to the collector's OTLP HTTP endpoint (e.g., http://otel-collector.observability.svc.cluster.local:4318/v1/traces).
6. Applying Kubernetes Manifests
Once your image is pushed and secrets are updated, apply your Kubernetes manifests:
kubectl apply -f kubernetes/deployment.yamlMonitor the deployment:
kubectl get pods -l app=mcp-server
kubectl logs <pod-name>
kubectl describe service mcp-server-service7. Health Check and Readiness Probe
The livenessProbe and readinessProbe in deployment.yaml use the /health endpoint to ensure your application is running and ready to serve traffic. Kubernetes will automatically restart unhealthy pods and prevent traffic from being routed to pods that are not yet ready. These are critical for maintaining application uptime.
8. Scaling Considerations
The replicas: 2 in deployment.yaml provides basic high availability. For dynamic scaling based on load, configure a HorizontalPodAutoscaler:
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: mcp-server-hpa
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: mcp-server-deployment
minReplicas: 2
maxReplicas: 10
metrics:
- type: Resource
resource:
name: cpu
target:
type: Utilization
averageUtilization: 70 # Scale up if CPU utilization exceeds 70%Apply with kubectl apply -f hpa.yaml.
9. Rollback Strategy
Kubernetes Deployments automatically manage rollouts and rollbacks. If a new deployment introduces issues, you can quickly revert to a previous stable version:
kubectl rollout undo deployment/mcp-server-deploymentThis relies on Kubernetes retaining revision history, which is configured by default. Ensure your CI/CD pipeline tests new images thoroughly before promotion to production to minimize the need for rollbacks.
Production Hardening
Hardening the MCP server for production involves addressing security, observability, reliability, and cost-efficiency. This checklist outlines key considerations specific to this project.
Security
- OAuth 2.1 Configuration Review: Regularly audit your OAuth 2.1 client credentials, ensuring they adhere to the principle of least privilege. Rotate client secrets frequently. Verify that the
audienceandissuerclaims in JWTs are strictly validated to prevent token replay or misuse (OWASP ASI03). - Input Validation and Sanitization: While Pydantic provides schema validation, implement deeper input sanitization for all tool parameters, especially string inputs, to prevent injection attacks (SQL, command, XSS) and buffer overflows. Use libraries like
jsonschemafor comprehensive validation against tool schemas (OWASP ASI02). - Least Privilege for Tools: Ensure that the underlying systems or databases accessed by your tools (e.g.,
product_lookup,customer_profile_fetcher) grant only the minimum necessary permissions. The server's identity in Kubernetes should also have restricted access to other cluster resources (OWASP ASI03). - Dependency Scanning: Integrate automated dependency scanning (e.g., Snyk, Trivy) into your CI/CD pipeline to identify and remediate known vulnerabilities in Python packages and base Docker images (OWASP ASI04).
- Container Image Security: Use minimal base images (like
python:3.9-slim-buster). Regularly scan your Docker images for vulnerabilities and rebuild them with updated patches.
Observability
- Comprehensive OpenTelemetry Configuration: Ensure
OTEL_EXPORTER_OTLP_ENDPOINTpoints to a robust, production-grade OpenTelemetry Collector. Configure appropriateresourceattributes to easily identify your service, environment, and version in your tracing backend. Verify that all critical operations (tool invocations, Redis calls, external HTTP requests) generate spans. - Custom Metrics: Beyond automatic instrumentation, define custom metrics for specific business logic, such as tool invocation success/failure rates, average tool execution duration, and rate limit hits. Export these via OpenTelemetry to Prometheus/Grafana for dashboarding and alerting.
- Structured Logging: Ensure all application logs are structured (e.g., JSON format) and include correlation IDs (trace_id, span_id from OpenTelemetry) for easy filtering, searching, and analysis in a centralized logging system (e.g., ELK Stack, Splunk, Datadog).
Reliability
- Redundancy and High Availability: Maintain at least two replicas in your Kubernetes deployment (
replicas: 2) across different availability zones to prevent single points of failure. Configure anti-affinity rules to ensure pods are scheduled on different nodes. - Circuit Breakers and Retries: Implement circuit breaker patterns for calls made by your tools to external dependencies. This prevents cascading failures by stopping requests to failing services. Apply exponential backoff and retry logic for transient errors in external API calls.
- Graceful Shutdown: Ensure your FastAPI application and Gunicorn workers are configured for graceful shutdown, allowing ongoing requests to complete before terminating, preventing data loss or partial responses.
- Resource Limits: Set realistic
requestsandlimitsfor CPU and memory in your Kubernetes deployment to prevent resource contention and noisy neighbor issues, ensuring predictable performance.
Rate Limiting
- Dynamic Rate Limiting: Consider making rate limits configurable at runtime without redeploying. This could involve storing limits in a configuration service or database that the
RateLimitMiddlewarecan query. Implement burst limits in addition to per-minute limits for better control. - Client Identification: Ensure the
client_idused for rate limiting is robustly extracted from the authenticated token and cannot be easily spoofed. Implement a fallback mechanism for unauthenticated requests if applicable.
Authentication
- Token Revocation: Implement or integrate with a token revocation mechanism (e.g., using Redis for a blacklist) to immediately invalidate compromised tokens, even before their natural expiration.
- Role-Based Access Control (RBAC): For more granular authorization, extend
get_current_userto check for specific roles or scopes within the JWT payload. This allows restricting access to certain tools or operations based on the agent's permissions (e.g., only 'admin' agents can use a 'user_delete' tool) (/agents/security-governance/rbac-mcp-tools-access-control/).
Governance
- Audit Logging: Beyond standard application logs, implement specific audit logs for critical actions (e.g., tool invocation, authentication failures, rate limit hits) that capture who, what, when, and where. These logs are crucial for compliance and post-incident analysis.
- Tool Versioning Policy: Establish a clear policy for how tool versions are managed, deprecated, and communicated to consuming agents. This aligns with MCP's versioning capabilities and prevents unexpected breaking changes.
Cost Optimisation
- Resource Optimization: Continuously monitor resource usage (CPU, memory) via Prometheus/Grafana and adjust Kubernetes
requestsandlimitsto match actual consumption, preventing over-provisioning and reducing cloud costs. - Auto-scaling: Leverage Horizontal Pod Autoscalers (HPA) to scale pods dynamically based on CPU or custom metrics, ensuring resources are used efficiently during peak and off-peak times.
Compliance
- Data Residency: If tools handle sensitive data, ensure data processing and storage comply with regional data residency requirements. This might involve deploying specific tool instances in particular geographic regions (/agents/production-engineering/data-residency-context-governance/).
- GDPR/CCPA Compliance: Implement data minimization principles for tool inputs and outputs, ensuring only necessary data is processed and stored. Ensure audit trails are available for data access requests.
- Implement a production-grade MCP server in Python using the MCP SDK.
- Secure MCP endpoints using OAuth 2.1 client credentials for machine-to-machine authentication.
- Design and integrate versioned tools and resources, including their JSON schemas, into an MCP server.
- Configure Streamable HTTP transport principles for efficient agent-tool communication.
- Apply rate limiting, robust error handling, and logging to MCP server endpoints.
- Prepare an MCP server for deployment to Kubernetes using Docker containers and manifests.
- Set up distributed tracing and metrics for an agentic tool server using OpenTelemetry.