Production Patterns for MCP Servers
You have built MCP servers with tools, resources, prompts, authentication, multi-server orchestration, and streaming. This module closes the loop: what does it take to run all of that reliably in production, at scale, without surprises at 2 a.m.?
- ✓Implement the four MCP error types and a circuit breaker with automatic recovery
- ✓Enforce per-client rate limits with token buckets, backed by Redis for multi-instance deployments
- ✓Expose /health, /ready, and /metrics endpoints and configure Kubernetes probes
- ✓Handle SIGTERM gracefully so in-flight requests finish before the container exits
- ✓Write structured JSON logs and ship a multi-stage hardened Docker image with CI/CD
1. What "Production-Ready" Means for MCP
The phrase "production-ready" is overloaded. For an MCP server specifically, it means: every tool call that can fail, does fail safely; the server exposes enough information for operators to detect and diagnose problems; and the deployment process can update the server without dropping a single in-flight request.
The checklist below covers 20 requirements across five categories. Review it before deploying any MCP server. Items marked Critical will cause production incidents; items marked High will cause reliability or security problems within weeks.
| Item | Category | Severity | Why It Matters |
|---|---|---|---|
| Map all exceptions to MCP error types | Error Handling | Critical | Unhandled exceptions return opaque 500s; clients cannot retry correctly |
| Retry transient errors with back-off | Error Handling | Critical | Network blips become permanent failures without retry |
| Circuit breaker on downstream calls | Error Handling | High | Prevents cascade failure when a dependency degrades |
| Log all tool failures with context | Observability | Critical | Silent failures are impossible to debug in production |
| Per-client rate limiting | Reliability | Critical | One misbehaving client exhausts the server for everyone |
| Redis-backed rate store | Reliability | High | In-memory limits break under horizontal scaling |
| Retry-After header on 429s | Reliability | Medium | Polite clients need to know when to retry |
| /health liveness endpoint | Observability | Critical | Container orchestrators restart dead pods automatically |
| /ready readiness endpoint | Observability | Critical | Prevents routing traffic to pods that are not yet warm |
| /metrics Prometheus endpoint | Observability | High | Enables alerting on p99 latency, error rate, queue depth |
| Kubernetes liveness + readiness probes | Deployment | High | Without probes, K8s never restarts broken pods |
| SIGTERM handler | Deployment | Critical | Abrupt kills corrupt in-flight tool calls |
| 30s drain window | Deployment | High | Matches Kubernetes terminationGracePeriodSeconds default |
| Structured JSON logs | Observability | High | Unstructured logs cannot be queried in Datadog/Loki |
| trace_id per request | Observability | High | Correlate logs across multi-server orchestration chains |
| Multi-stage Docker build | Security | High | Dev dependencies and build artifacts bloat attack surface |
| Non-root container user | Security | Critical | Root containers can write to host filesystem on misconfigured nodes |
| Read-only filesystem | Security | High | Prevents runtime code injection |
| CPU + memory resource limits | Reliability | High | No limits means one server can evict every other pod |
| Automated lint → test → deploy CI | Deployment | Medium | Manual deploys introduce human error; CI enforces consistency |
2. Comprehensive Error Handling
Every tool can fail in four distinct ways, and the MCP protocol defines a distinct error type for each. Using the wrong error type causes clients to retry non-retryable errors (wasting tokens) or give up on retryable ones (losing work).
- ToolExecutionError — The tool ran but the result was an error (e.g., the database returned a constraint violation). The tool exists; the arguments were valid; the execution itself failed.
- ToolNotFoundError — The client called a tool name that does not exist on this server. Usually a client-side bug or version mismatch.
- InvalidParamsError — The tool exists but the arguments failed schema validation (missing required field, wrong type, out-of-range value).
- InternalError — An unexpected server-side exception that does not map to any of the above. Should be rare; always log the full stack trace.
A circuit breakerCircuit Breaker — a pattern where a wrapper tracks consecutive failures. After a threshold is hit, the circuit "opens" and all calls fail immediately (without attempting the real operation) for a timeout period. After the timeout, one probe call is allowed through (half-open). If it succeeds, the circuit closes again. wraps each downstream dependency (database, external API, LLM endpoint). After five failures in 60 seconds the circuit opens: all calls to that dependency return an error immediately, preventing the slow failure from consuming all request slots.
RobustToolExecutor wraps any tool function. It catches all exceptions, maps them to the correct MCP error type, and applies retry logic with exponential back-off for transient errors (network timeouts, HTTP 429s, HTTP 503s).
KeyError or AttributeError stack trace to the client. Every error response has the same shape, which clients can parse reliably.
InvalidParamsError or ToolNotFoundError — they are deterministic. Retrying them wastes tokens and time. Only retry errors that are transient by nature.
"""
RobustToolExecutor: maps exceptions to MCP error types,
retries transient errors, and implements a circuit breaker.
"""
import asyncio
import time
import logging
from enum import Enum
from dataclasses import dataclass, field
from typing import Callable, Any
from mcp.server.fastmcp import FastMCP
from mcp.types import (
ToolExecutionError,
InvalidParamsError,
InternalError,
)
logger = logging.getLogger(__name__)
# ── Circuit Breaker ──────────────────────────────────────────
class CBState(Enum):
CLOSED = "closed"
OPEN = "open"
HALF_OPEN = "half_open"
@dataclass
class CircuitBreaker:
name: str
failure_threshold: int = 5
reset_timeout: float = 60.0 # seconds before trying half-open
_state: CBState = field(default=CBState.CLOSED, init=False)
_failures: int = field(default=0, init=False)
_opened_at: float = field(default=0.0, init=False)
def record_success(self) -> None:
self._failures = 0
self._state = CBState.CLOSED
def record_failure(self) -> None:
self._failures += 1
if self._failures >= self.failure_threshold:
self._state = CBState.OPEN
self._opened_at = time.monotonic()
logger.warning(
"circuit_breaker_opened",
extra={"cb_name": self.name, "failures": self._failures}
)
def allow_request(self) -> bool:
if self._state == CBState.CLOSED:
return True
if self._state == CBState.OPEN:
elapsed = time.monotonic() - self._opened_at
if elapsed >= self.reset_timeout:
self._state = CBState.HALF_OPEN
logger.info("circuit_breaker_half_open", extra={"cb_name": self.name})
return True
return False
# HALF_OPEN: allow exactly one probe
return True
# ── Retry helper ─────────────────────────────────────────────
TRANSIENT_EXCEPTIONS = (ConnectionError, TimeoutError, asyncio.TimeoutError)
async def retry_with_backoff(
fn: Callable,
args: tuple,
kwargs: dict,
max_retries: int = 3,
base_delay: float = 0.5,
) -> Any:
last_exc: Exception | None = None
for attempt in range(max_retries):
try:
return await fn(*args, **kwargs)
except TRANSIENT_EXCEPTIONS as exc:
last_exc = exc
delay = base_delay * (2 ** attempt)
logger.warning(
"tool_retry",
extra={"attempt": attempt + 1, "delay": delay, "error": str(exc)}
)
await asyncio.sleep(delay)
raise last_exc # all retries exhausted
# ── Executor ─────────────────────────────────────────────────
class RobustToolExecutor:
def __init__(self, cb: CircuitBreaker | None = None):
self.cb = cb or CircuitBreaker(name="default")
async def execute(
self,
tool_name: str,
fn: Callable,
args: tuple = (),
kwargs: dict | None = None,
) -> Any:
kwargs = kwargs or {}
if not self.cb.allow_request():
raise ToolExecutionError(
message=f"Circuit breaker OPEN for '{self.cb.name}'. "
f"Retry after {self.cb.reset_timeout:.0f}s.",
tool_name=tool_name,
)
try:
result = await retry_with_backoff(fn, args, kwargs)
self.cb.record_success()
return result
except InvalidParamsError:
raise # deterministic, do not retry or count as failure
except TRANSIENT_EXCEPTIONS as exc:
self.cb.record_failure()
raise ToolExecutionError(
message=f"Transient error after retries: {exc}",
tool_name=tool_name,
) from exc
except Exception as exc:
self.cb.record_failure()
logger.exception("tool_internal_error", extra={"tool": tool_name})
raise InternalError(message=f"Unexpected error in {tool_name}: {exc}") from exc
# ── Usage with FastMCP ────────────────────────────────────────
mcp = FastMCP("robust-server")
executor = RobustToolExecutor(CircuitBreaker("database", failure_threshold=5, reset_timeout=60))
@mcp.tool()
async def query_database(sql: str) -> str:
"""Run a read-only SQL query."""
if not sql.strip().upper().startswith("SELECT"):
raise InvalidParamsError("Only SELECT statements are allowed.")
async def _run():
# Replace with real DB call
await asyncio.sleep(0.01)
return f"Results for: {sql}"
return await executor.execute("query_database", _run)
/**
* RobustToolExecutor: maps exceptions to MCP error types,
* retries transient errors, and implements a circuit breaker.
*/
import { McpError, ErrorCode } from "@modelcontextprotocol/sdk/types.js";
// ── Circuit Breaker ──────────────────────────────────────────
enum CBState { CLOSED, OPEN, HALF_OPEN }
class CircuitBreaker {
private state = CBState.CLOSED;
private failures = 0;
private openedAt = 0;
constructor(
readonly name: string,
private readonly threshold = 5,
private readonly resetTimeout = 60_000, // ms
) {}
recordSuccess(): void {
this.failures = 0;
this.state = CBState.CLOSED;
}
recordFailure(): void {
this.failures++;
if (this.failures >= this.threshold) {
this.state = CBState.OPEN;
this.openedAt = Date.now();
console.warn({ event: "circuit_breaker_opened", name: this.name });
}
}
allowRequest(): boolean {
if (this.state === CBState.CLOSED) return true;
if (this.state === CBState.OPEN) {
if (Date.now() - this.openedAt >= this.resetTimeout) {
this.state = CBState.HALF_OPEN;
console.info({ event: "circuit_breaker_half_open", name: this.name });
return true;
}
return false;
}
return true; // HALF_OPEN: allow one probe
}
}
// ── Retry helper ─────────────────────────────────────────────
const TRANSIENT_CODES = new Set([
"ECONNREFUSED", "ETIMEDOUT", "ECONNRESET",
]);
async function retryWithBackoff(
fn: () => Promise,
maxRetries = 3,
baseDelay = 500,
): Promise {
let lastError: unknown;
for (let attempt = 0; attempt < maxRetries; attempt++) {
try {
return await fn();
} catch (err: unknown) {
const code = (err as NodeJS.ErrnoException).code ?? "";
if (!TRANSIENT_CODES.has(code)) throw err;
lastError = err;
const delay = baseDelay * Math.pow(2, attempt);
console.warn({ event: "tool_retry", attempt: attempt + 1, delay });
await new Promise(r => setTimeout(r, delay));
}
}
throw lastError;
}
// ── Executor ─────────────────────────────────────────────────
export class RobustToolExecutor {
private cb: CircuitBreaker;
constructor(cb?: CircuitBreaker) {
this.cb = cb ?? new CircuitBreaker("default");
}
async execute(toolName: string, fn: () => Promise): Promise {
if (!this.cb.allowRequest()) {
throw new McpError(
ErrorCode.InternalError,
`Circuit breaker OPEN for '${this.cb.name}'. ` +
`Retry after ${this.cb["resetTimeout"] / 1000}s.`,
);
}
try {
const result = await retryWithBackoff(fn);
this.cb.recordSuccess();
return result;
} catch (err) {
if (err instanceof McpError &&
err.code === ErrorCode.InvalidParams) {
throw err; // deterministic — do not count as failure
}
this.cb.recordFailure();
if (err instanceof McpError) throw err;
const msg = err instanceof Error ? err.message : String(err);
console.error({ event: "tool_internal_error", tool: toolName, error: msg });
throw new McpError(ErrorCode.InternalError, `Unexpected error in ${toolName}: ${msg}`);
}
}
}
// ── Usage with MCP Server ─────────────────────────────────────
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { CallToolRequestSchema } from "@modelcontextprotocol/sdk/types.js";
const server = new Server({ name: "robust-server", version: "1.0.0" }, {
capabilities: { tools: {} },
});
const executor = new RobustToolExecutor(
new CircuitBreaker("database", 5, 60_000),
);
server.setRequestHandler(CallToolRequestSchema, async (req) => {
const { name, arguments: args } = req.params;
if (name === "query_database") {
const sql = String(args?.sql ?? "");
if (!sql.trim().toUpperCase().startsWith("SELECT")) {
throw new McpError(ErrorCode.InvalidParams, "Only SELECT statements are allowed.");
}
const result = await executor.execute("query_database", async () => {
// Replace with real DB call
return `Results for: ${sql}`;
});
return { content: [{ type: "text", text: result }] };
}
throw new McpError(ErrorCode.MethodNotFound, `Unknown tool: ${name}`);
});
const transport = new StdioServerTransport();
await server.connect(transport);
The executor checks the circuit breaker before every call. If the circuit is open, it returns an error immediately — no waiting for the downstream to time out. If the circuit is closed but the call fails with a transient error, it retries up to 3 times with exponential back-off (500ms → 1s → 2s). After 5 failures, the circuit opens and cools down for 60 seconds, then allows one probe call through to test recovery.
3. Rate Limiting Per Client
Without rate limiting, a single misbehaving client — a buggy agent in a retry loop, a load test that escaped into production — can consume all your server's capacity, degrading every other client. A token bucketToken Bucket — a rate limiting algorithm where each client has a "bucket" that holds a maximum number of tokens. Each request consumes one token. Tokens are refilled at a fixed rate (e.g., 1 per second for a 60/min limit). When the bucket is empty, requests are rejected until tokens refill. gives each client a smooth budget: they can burst up to the bucket capacity, then are throttled to the refill rate.
ratelimit:{client_id}. A Lua script does the check-and-decrement atomically to avoid race conditions across multiple server instances.
EXPIRE ratelimit:{id} 120). Without a TTL, keys for departed clients accumulate forever and Redis memory grows unbounded.
"""
Redis-backed token bucket rate limiter for MCP servers.
Requires: pip install redis[hiredis]
"""
import time
import redis.asyncio as aioredis
from dataclasses import dataclass
from mcp.types import ToolExecutionError
# ── Tier configuration ────────────────────────────────────────
TIERS = {
"standard": {"capacity": 60, "refill_rate": 1.0}, # 60/min
"premium": {"capacity": 600, "refill_rate": 10.0}, # 600/min
}
# ── Atomic Lua script ─────────────────────────────────────────
# Returns [allowed (0/1), tokens_remaining, retry_after_ms]
_LUA = """
local key = KEYS[1]
local capacity = tonumber(ARGV[1])
local rate = tonumber(ARGV[2]) -- tokens per second
local now = tonumber(ARGV[3]) -- current time (ms)
local ttl = tonumber(ARGV[4]) -- key TTL (seconds)
local bucket = redis.call("HMGET", key, "tokens", "last_refill")
local tokens = tonumber(bucket[1]) or capacity
local last_refill = tonumber(bucket[2]) or now
-- Refill tokens based on elapsed time
local elapsed = math.max(0, (now - last_refill) / 1000.0)
tokens = math.min(capacity, tokens + elapsed * rate)
if tokens >= 1 then
tokens = tokens - 1
redis.call("HMSET", key, "tokens", tokens, "last_refill", now)
redis.call("EXPIRE", key, ttl)
return {1, math.floor(tokens), 0}
else
-- Calculate when the next token will be available
local wait_ms = math.ceil((1 - tokens) / rate * 1000)
redis.call("HMSET", key, "tokens", tokens, "last_refill", now)
redis.call("EXPIRE", key, ttl)
return {0, 0, wait_ms}
end
"""
@dataclass
class RateLimitResult:
allowed: bool
tokens_remaining: int
retry_after_ms: int
class RedisRateLimiter:
def __init__(self, redis_url: str = "redis://localhost:6379"):
self._redis = aioredis.from_url(redis_url, decode_responses=True)
self._script = self._redis.register_script(_LUA)
async def check(self, client_id: str, tier: str = "standard") -> RateLimitResult:
cfg = TIERS.get(tier, TIERS["standard"])
now_ms = int(time.time() * 1000)
key = f"ratelimit:{client_id}"
result = await self._script(
keys=[key],
args=[cfg["capacity"], cfg["refill_rate"], now_ms, 120],
)
return RateLimitResult(
allowed=bool(result[0]),
tokens_remaining=int(result[1]),
retry_after_ms=int(result[2]),
)
# ── Middleware wrapper for FastMCP ────────────────────────────
class RateLimitedServer:
def __init__(self, mcp_app, limiter: RedisRateLimiter):
self.app = mcp_app
self.limiter = limiter
async def handle_tool_call(self, client_id: str, tier: str, tool_fn, *args, **kwargs):
result = await self.limiter.check(client_id, tier)
if not result.allowed:
retry_after_s = result.retry_after_ms / 1000
raise ToolExecutionError(
message=(
f"Rate limit exceeded. "
f"X-RateLimit-Remaining: 0. "
f"Retry-After: {retry_after_s:.1f}s"
),
tool_name=tool_fn.__name__,
)
return await tool_fn(*args, **kwargs)
/**
* Redis-backed token bucket rate limiter for MCP servers.
* Requires: npm install ioredis
*/
import Redis from "ioredis";
import { McpError, ErrorCode } from "@modelcontextprotocol/sdk/types.js";
// ── Tier configuration ────────────────────────────────────────
const TIERS: Record = {
standard: { capacity: 60, refillRate: 1.0 }, // 60/min
premium: { capacity: 600, refillRate: 10.0 }, // 600/min
};
// ── Atomic Lua script ─────────────────────────────────────────
const LUA = `
local key = KEYS[1]
local capacity = tonumber(ARGV[1])
local rate = tonumber(ARGV[2])
local now = tonumber(ARGV[3])
local ttl = tonumber(ARGV[4])
local bucket = redis.call("HMGET", key, "tokens", "last_refill")
local tokens = tonumber(bucket[1]) or capacity
local last_refill = tonumber(bucket[2]) or now
local elapsed = math.max(0, (now - last_refill) / 1000.0)
tokens = math.min(capacity, tokens + elapsed * rate)
if tokens >= 1 then
tokens = tokens - 1
redis.call("HMSET", key, "tokens", tokens, "last_refill", now)
redis.call("EXPIRE", key, ttl)
return {1, math.floor(tokens), 0}
else
local wait_ms = math.ceil((1 - tokens) / rate * 1000)
redis.call("HMSET", key, "tokens", tokens, "last_refill", now)
redis.call("EXPIRE", key, ttl)
return {0, 0, wait_ms}
end
`;
interface RateLimitResult {
allowed: boolean;
tokensRemaining: number;
retryAfterMs: number;
}
export class RedisRateLimiter {
private redis: Redis;
constructor(redisUrl = "redis://localhost:6379") {
this.redis = new Redis(redisUrl);
}
async check(clientId: string, tier = "standard"): Promise {
const cfg = TIERS[tier] ?? TIERS.standard;
const nowMs = Date.now();
const key = `ratelimit:${clientId}`;
const [allowed, remaining, retryAfterMs] = await this.redis.eval(
LUA, 1, key,
cfg.capacity, cfg.refillRate, nowMs, 120,
) as [number, number, number];
return { allowed: allowed === 1, tokensRemaining: remaining, retryAfterMs };
}
}
// ── Middleware wrapper ────────────────────────────────────────
export async function withRateLimit(
limiter: RedisRateLimiter,
clientId: string,
tier: string,
toolName: string,
fn: () => Promise,
): Promise {
const result = await limiter.check(clientId, tier);
if (!result.allowed) {
const retryAfterS = (result.retryAfterMs / 1000).toFixed(1);
throw new McpError(
ErrorCode.InternalError,
`Rate limit exceeded. X-RateLimit-Remaining: 0. Retry-After: ${retryAfterS}s`,
);
}
return fn();
}
The Lua script runs atomically on Redis — one round-trip replaces what would otherwise be a check → decrement → set sequence that races under concurrency. Each server instance reads the same Redis key, so limits are enforced globally even with 10 replicas. The error response includes a human-readable Retry-After value so well-behaved clients can back off intelligently.
4. Health Checks & Readiness Probes
Container orchestrators like Kubernetes need two distinct signals: "is the process alive?" (liveness probeLiveness Probe — a Kubernetes check that determines whether a container should be restarted. If the liveness probe fails repeatedly, Kubernetes kills and restarts the pod. Use for detecting deadlocks or unrecoverable states.) and "is it able to serve requests?" (readiness probeReadiness Probe — a Kubernetes check that determines whether a container should receive traffic. If readiness fails, the pod is removed from the service load balancer but not restarted. Use for slow startup, connection warm-up, or temporary overload.). They are not the same: a pod can be alive but not ready (still connecting to the database), or ready but degraded (slow).
/health returns 200 as long as the process is running. /ready checks active dependencies (Redis, database). /metrics returns a Prometheus text format with request counts, error rates, and latency histograms.
"""
Health check HTTP server for MCP servers.
Runs on port 8090 alongside the MCP transport.
Requires: pip install aiohttp
"""
import asyncio
import time
from aiohttp import web
import redis.asyncio as aioredis
# ── Metrics store (replace with prometheus_client in production) ─
_metrics = {
"tool_calls_total": 0,
"tool_errors_total": 0,
"tool_latency_sum_ms": 0.0,
}
def record_call(duration_ms: float, error: bool = False):
_metrics["tool_calls_total"] += 1
_metrics["tool_latency_sum_ms"] += duration_ms
if error:
_metrics["tool_errors_total"] += 1
# ── Dependency checkers ──────────────────────────────────────
async def check_redis(redis_url: str) -> tuple[bool, str]:
try:
r = aioredis.from_url(redis_url)
await r.ping()
await r.aclose()
return True, "ok"
except Exception as exc:
return False, str(exc)
# ── Handlers ─────────────────────────────────────────────────
async def handle_health(request: web.Request) -> web.Response:
"""Liveness: is the process alive?"""
return web.json_response({"status": "ok", "ts": time.time()})
async def handle_ready(request: web.Request) -> web.Response:
"""Readiness: can we serve requests right now?"""
redis_ok, redis_msg = await check_redis(
request.app["redis_url"]
)
checks = {
"redis": {"ok": redis_ok, "detail": redis_msg},
}
all_ok = all(c["ok"] for c in checks.values())
status = 200 if all_ok else 503
return web.json_response(
{"status": "ready" if all_ok else "degraded", "checks": checks},
status=status,
)
async def handle_metrics(request: web.Request) -> web.Response:
"""Prometheus text format."""
m = _metrics
body = "\n".join([
"# HELP mcp_tool_calls_total Total tool calls",
"# TYPE mcp_tool_calls_total counter",
f'mcp_tool_calls_total {m["tool_calls_total"]}',
"# HELP mcp_tool_errors_total Total tool errors",
"# TYPE mcp_tool_errors_total counter",
f'mcp_tool_errors_total {m["tool_errors_total"]}',
"# HELP mcp_tool_latency_ms_sum Cumulative latency ms",
"# TYPE mcp_tool_latency_ms_sum counter",
f'mcp_tool_latency_ms_sum {m["tool_latency_sum_ms"]}',
])
return web.Response(text=body, content_type="text/plain")
# ── App factory ───────────────────────────────────────────────
def create_health_app(redis_url: str = "redis://localhost:6379") -> web.Application:
app = web.Application()
app["redis_url"] = redis_url
app.router.add_get("/health", handle_health)
app.router.add_get("/ready", handle_ready)
app.router.add_get("/metrics", handle_metrics)
return app
async def run_health_server(port: int = 8090):
app = create_health_app()
runner = web.AppRunner(app)
await runner.setup()
site = web.TCPSite(runner, "0.0.0.0", port)
await site.start()
print(f"Health server running on :{port}")
/**
* Health check HTTP server for MCP servers.
* Runs on port 8090 alongside the MCP transport.
* Requires: npm install fastify ioredis prom-client
*/
import Fastify from "fastify";
import Redis from "ioredis";
import { Registry, Counter, Histogram, collectDefaultMetrics } from "prom-client";
// ── Metrics ──────────────────────────────────────────────────
const registry = new Registry();
collectDefaultMetrics({ register: registry });
export const toolCallsTotal = new Counter({
name: "mcp_tool_calls_total",
help: "Total MCP tool calls",
labelNames: ["tool", "status"],
registers: [registry],
});
export const toolLatency = new Histogram({
name: "mcp_tool_duration_ms",
help: "MCP tool call duration in ms",
labelNames: ["tool"],
buckets: [5, 10, 25, 50, 100, 250, 500, 1000],
registers: [registry],
});
// ── Redis check ───────────────────────────────────────────────
async function checkRedis(url: string): Promise<{ ok: boolean; detail: string }> {
const client = new Redis(url, { lazyConnect: true, connectTimeout: 2000 });
try {
await client.connect();
await client.ping();
await client.quit();
return { ok: true, detail: "ok" };
} catch (err) {
return { ok: false, detail: String(err) };
}
}
// ── Server ────────────────────────────────────────────────────
export function createHealthServer(redisUrl = "redis://localhost:6379") {
const app = Fastify({ logger: false });
// Liveness
app.get("/health", async (_req, reply) => {
return reply.send({ status: "ok", ts: Date.now() });
});
// Readiness
app.get("/ready", async (_req, reply) => {
const redis = await checkRedis(redisUrl);
const checks = { redis };
const allOk = Object.values(checks).every(c => c.ok);
return reply
.status(allOk ? 200 : 503)
.send({ status: allOk ? "ready" : "degraded", checks });
});
// Prometheus metrics
app.get("/metrics", async (_req, reply) => {
reply.header("Content-Type", registry.contentType);
return reply.send(await registry.metrics());
});
return app;
}
export async function runHealthServer(port = 8090) {
const app = createHealthServer();
await app.listen({ port, host: "0.0.0.0" });
console.log(`Health server listening on :${port}`);
}
Kubernetes Probe Configuration
initialDelaySeconds of 10 to give the server time to connect to Redis before it receives traffic.
containers:
- name: mcp-server
image: ghcr.io/your-org/mcp-server:latest
ports:
- containerPort: 8090
name: health
livenessProbe:
httpGet:
path: /health
port: health
initialDelaySeconds: 5
periodSeconds: 10
failureThreshold: 3
readinessProbe:
httpGet:
path: /ready
port: health
initialDelaySeconds: 10
periodSeconds: 5
failureThreshold: 2
# Docker HEALTHCHECK equivalent for non-K8s environments:
# HEALTHCHECK --interval=10s --timeout=3s --retries=3 \
# CMD wget -qO- http://localhost:8090/health || exit 1
5. Graceful Shutdown
When Kubernetes decides to stop a pod — during a rolling update, a node drain, or a scale-down — it sends SIGTERMSIGTERM — a POSIX signal asking a process to terminate cleanly. Unlike SIGKILL, the process can catch SIGTERM and perform cleanup (flush buffers, finish in-flight requests, close DB connections) before exiting. to the container's PID 1. By default, Python and Node.js exit immediately. Without a handler, in-flight tool calls are cut mid-execution, which can corrupt state and causes errors in the calling agent.
The correct sequence is: stop accepting new MCP connections → wait for active requests to drain → close downstream connections → exit 0. Kubernetes provides terminationGracePeriodSecondsterminationGracePeriodSeconds — a Kubernetes pod spec field (default: 30) specifying how long K8s waits between sending SIGTERM and force-killing the pod with SIGKILL. Set this to match or exceed your drain window. (default 30s) as the maximum time for this sequence.
sys.exit(0). A 30-second hard timeout prevents the pod from hanging if a request never completes.
signal.signal only works on the main thread. If your server uses asyncio, use loop.add_signal_handler(signal.SIGTERM, ...) instead — it integrates with the event loop correctly.
"""
Graceful shutdown for asyncio-based MCP servers.
Handles SIGTERM from Kubernetes with a 30s drain window.
"""
import asyncio
import signal
import logging
logger = logging.getLogger(__name__)
class ShutdownManager:
def __init__(self, drain_timeout: float = 30.0):
self.drain_timeout = drain_timeout
self._shutting_down = False
self._active_requests = 0
self._drain_event = asyncio.Event()
@property
def is_shutting_down(self) -> bool:
return self._shutting_down
def enter_request(self) -> bool:
"""Call at the start of each tool handler. Returns False if shutting down."""
if self._shutting_down:
return False
self._active_requests += 1
return True
def exit_request(self) -> None:
"""Call at the end of each tool handler (use try/finally)."""
self._active_requests -= 1
if self._shutting_down and self._active_requests == 0:
self._drain_event.set()
async def _drain(self) -> None:
"""Wait for active requests to finish."""
if self._active_requests == 0:
return
logger.info("graceful_shutdown_draining", extra={
"active_requests": self._active_requests,
"timeout": self.drain_timeout,
})
try:
await asyncio.wait_for(self._drain_event.wait(), timeout=self.drain_timeout)
logger.info("graceful_shutdown_drained")
except asyncio.TimeoutError:
logger.warning("graceful_shutdown_timeout", extra={
"remaining_requests": self._active_requests,
})
async def shutdown(self, cleanup_fn=None) -> None:
"""Perform the full shutdown sequence."""
logger.info("graceful_shutdown_started")
self._shutting_down = True
await self._drain()
if cleanup_fn:
try:
await cleanup_fn()
except Exception:
logger.exception("graceful_shutdown_cleanup_error")
logger.info("graceful_shutdown_complete")
# ── Wire into the event loop ──────────────────────────────────
def install_sigterm_handler(
loop: asyncio.AbstractEventLoop,
shutdown_mgr: ShutdownManager,
cleanup_fn=None,
) -> None:
async def _handle():
await shutdown_mgr.shutdown(cleanup_fn)
loop.stop()
loop.add_signal_handler(
signal.SIGTERM,
lambda: asyncio.ensure_future(_handle()),
)
# Also handle SIGINT (Ctrl+C in dev)
loop.add_signal_handler(
signal.SIGINT,
lambda: asyncio.ensure_future(_handle()),
)
# ── Usage in a FastMCP server ────────────────────────────────
async def main():
from mcp.server.fastmcp import FastMCP
mcp = FastMCP("my-server")
shutdown = ShutdownManager(drain_timeout=30)
@mcp.tool()
async def slow_tool(n: int) -> str:
if not shutdown.enter_request():
raise RuntimeError("Server is shutting down")
try:
await asyncio.sleep(n) # simulate work
return f"done after {n}s"
finally:
shutdown.exit_request()
loop = asyncio.get_event_loop()
install_sigterm_handler(loop, shutdown)
await mcp.run_async()
if __name__ == "__main__":
asyncio.run(main())
/**
* Graceful shutdown for Node.js MCP servers.
* Handles SIGTERM from Kubernetes with a 30s drain window.
*/
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
export class ShutdownManager {
private shuttingDown = false;
private activeRequests = 0;
private drainResolve?: () => void;
private drainPromise?: Promise;
constructor(private readonly drainTimeoutMs = 30_000) {}
get isShuttingDown(): boolean { return this.shuttingDown; }
enterRequest(): boolean {
if (this.shuttingDown) return false;
this.activeRequests++;
return true;
}
exitRequest(): void {
this.activeRequests--;
if (this.shuttingDown && this.activeRequests === 0) {
this.drainResolve?.();
}
}
private async drain(): Promise {
if (this.activeRequests === 0) return;
console.log({
event: "graceful_shutdown_draining",
activeRequests: this.activeRequests,
});
this.drainPromise = new Promise(resolve => {
this.drainResolve = resolve;
});
const timeout = new Promise(resolve =>
setTimeout(() => { resolve(); }, this.drainTimeoutMs)
);
await Promise.race([this.drainPromise, timeout]);
}
async shutdown(cleanup?: () => Promise): Promise {
console.log({ event: "graceful_shutdown_started" });
this.shuttingDown = true;
await this.drain();
if (cleanup) {
try { await cleanup(); }
catch (err) { console.error({ event: "cleanup_error", err }); }
}
console.log({ event: "graceful_shutdown_complete" });
}
}
// ── Wire into the process ─────────────────────────────────────
export function installShutdownHandlers(
mgr: ShutdownManager,
server: Server,
cleanup?: () => Promise,
): void {
const handle = async (signal: string) => {
console.log({ event: "signal_received", signal });
await mgr.shutdown(cleanup);
await server.close();
process.exit(0);
};
process.on("SIGTERM", () => handle("SIGTERM"));
process.on("SIGINT", () => handle("SIGINT"));
}
// ── Kubernetes manifest snippet ───────────────────────────────
// spec:
// terminationGracePeriodSeconds: 35 # > drainTimeoutMs (30s) + buffer
The ShutdownManager acts as a gate: every tool handler calls enter_request() at the start and exit_request() in a finally block. When SIGTERM fires, the flag flips and the drain function waits for the counter to reach zero. No tool call is cut mid-execution. The Kubernetes terminationGracePeriodSeconds is set to 35 (drain timeout 30s + 5s buffer) so SIGKILL only fires as a last resort.
6. Structured Logging for MCP Servers
Every log line from a production MCP server must be parseable as JSON. A log that says Error in tool is useless at 3 a.m. A log that says {"level":"error","tool_name":"query_database","client_id":"cl_abc","trace_id":"t_xyz","duration_ms":341,"error":"connection refused"} lets you write a Datadog query in 30 seconds.
structlog (configured with JSON renderer). TypeScript uses pino (JSON-first by design). Both emit the same schema: timestamp, level, server_name, tool_name, client_id, trace_id, duration_ms, error.
"""
Structured JSON logging for MCP servers using structlog.
Requires: pip install structlog
"""
import time
import uuid
import structlog
import logging
def configure_logging(server_name: str, level: str = "INFO") -> None:
"""Call once at server startup."""
structlog.configure(
processors=[
structlog.contextvars.merge_contextvars,
structlog.processors.add_log_level,
structlog.processors.TimeStamper(fmt="iso"),
structlog.processors.JSONRenderer(),
],
context_class=dict,
logger_factory=structlog.PrintLoggerFactory(),
cache_logger_on_first_use=True,
)
# Bind server-level context (present in every log line)
structlog.contextvars.bind_contextvars(server_name=server_name)
logging.basicConfig(level=getattr(logging, level.upper()))
log = structlog.get_logger()
# ── Tool call logging decorator ───────────────────────────────
def logged_tool(tool_name: str):
"""Decorator that adds structured logging to any async tool function."""
def decorator(fn):
async def wrapper(*args, client_id: str = "unknown", **kwargs):
trace_id = str(uuid.uuid4())[:8]
start = time.monotonic()
# Bind per-request context
structlog.contextvars.bind_contextvars(
tool_name=tool_name,
client_id=client_id,
trace_id=trace_id,
)
log.debug("tool_call_start", args_count=len(args))
try:
result = await fn(*args, **kwargs)
duration = (time.monotonic() - start) * 1000
log.info("tool_call_success", duration_ms=round(duration, 1))
return result
except Exception as exc:
duration = (time.monotonic() - start) * 1000
log.error(
"tool_call_error",
duration_ms=round(duration, 1),
error=str(exc),
error_type=type(exc).__name__,
)
raise
finally:
structlog.contextvars.clear_contextvars()
return wrapper
return decorator
# ── Usage ─────────────────────────────────────────────────────
configure_logging("my-mcp-server")
@logged_tool("search_documents")
async def search_documents(query: str) -> list[str]:
# ... real implementation
return [f"result for {query}"]
# Sample output (one line, formatted here for readability):
# {
# "server_name": "my-mcp-server",
# "tool_name": "search_documents",
# "client_id": "cl_abc",
# "trace_id": "a1b2c3d4",
# "event": "tool_call_success",
# "level": "info",
# "timestamp": "2024-01-15T14:23:01.442Z",
# "duration_ms": 18.3
# }
/**
* Structured JSON logging for MCP servers using pino.
* Requires: npm install pino
*/
import pino from "pino";
import { randomBytes } from "crypto";
// ── Base logger (configure once at startup) ───────────────────
export function createLogger(serverName: string, level = "info") {
return pino({
level,
base: { server_name: serverName },
timestamp: pino.stdTimeFunctions.isoTime,
formatters: {
level: (label: string) => ({ level: label }),
},
});
}
export const log = createLogger("my-mcp-server");
// ── Tool call logging wrapper ─────────────────────────────────
export async function loggedToolCall(
toolName: string,
clientId: string,
fn: (child: ReturnType) => Promise,
): Promise {
const traceId = randomBytes(4).toString("hex");
const child = log.child({ tool_name: toolName, client_id: clientId, trace_id: traceId });
const start = Date.now();
child.debug({ event: "tool_call_start" });
try {
const result = await fn(child);
child.info({ event: "tool_call_success", duration_ms: Date.now() - start });
return result;
} catch (err) {
child.error({
event: "tool_call_error",
duration_ms: Date.now() - start,
error: err instanceof Error ? err.message : String(err),
error_type: err instanceof Error ? err.constructor.name : "unknown",
});
throw err;
}
}
// ── Usage ─────────────────────────────────────────────────────
// In your tool handler:
// const result = await loggedToolCall("search_documents", clientId, async (child) => {
// child.debug({ event: "fetching", query });
// return searchImpl(query);
// });
// Sample output (pino JSON, one line):
// {
// "level": "info", "time": "2024-01-15T14:23:01.442Z",
// "server_name": "my-mcp-server", "tool_name": "search_documents",
// "client_id": "cl_abc", "trace_id": "a1b2c3d4",
// "event": "tool_call_success", "duration_ms": 18
// }
X-Debug-Trace header.
7. Production Docker Configuration
A production Dockerfile is not the same as a development Dockerfile. Three non-negotiable requirements: multi-stage build (dev dependencies and build artifacts must not appear in the final image), non-root user (default is root, which is dangerous), and read-only root filesystem (only /tmp is writable at runtime).
docker-compose.yml prevent any single server from consuming all host resources.
read_only: true in compose breaks any code that writes to the filesystem at runtime (temp files, log files, PID files). Audit your code for open(path, "w") calls and redirect them to /tmp or an external store before enabling this.
# ── Stage 1: Build ──────────────────────────────────────────
FROM python:3.12-slim AS builder
WORKDIR /build
# Install build dependencies
RUN apt-get update && apt-get install -y --no-install-recommends \
gcc libpq-dev \
&& rm -rf /var/lib/apt/lists/*
COPY requirements.txt .
RUN pip install --prefix=/install --no-cache-dir -r requirements.txt
# ── Stage 2: Runtime ─────────────────────────────────────────
FROM python:3.12-slim AS runtime
WORKDIR /app
# Non-root user
RUN groupadd -r mcp && useradd -r -g mcp -u 1001 mcp
# Copy only runtime dependencies from builder
COPY --from=builder /install /usr/local
COPY --chown=mcp:mcp . .
USER 1001
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1
EXPOSE 8090
HEALTHCHECK --interval=10s --timeout=3s --retries=3 \
CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8090/health')"
CMD ["python", "-m", "mcp_server"]
# ── Stage 1: Build ──────────────────────────────────────────
FROM node:20-slim AS builder
WORKDIR /build
COPY package*.json ./
RUN npm ci
COPY tsconfig.json .
COPY src/ ./src/
RUN npm run build
# Prune dev dependencies
RUN npm prune --omit=dev
# ── Stage 2: Runtime ─────────────────────────────────────────
FROM node:20-slim AS runtime
WORKDIR /app
# Non-root user (node image already has uid 1000)
RUN chown node:node /app
USER node
COPY --from=builder --chown=node:node /build/dist ./dist
COPY --from=builder --chown=node:node /build/node_modules ./node_modules
COPY --from=builder --chown=node:node /build/package.json .
ENV NODE_ENV=production
EXPOSE 8090
HEALTHCHECK --interval=10s --timeout=3s --retries=3 \
CMD node -e "require('http').get('http://localhost:8090/health', r => process.exit(r.statusCode===200?0:1))"
CMD ["node", "dist/server.js"]
version: "3.9"
services:
mcp-server:
image: ghcr.io/your-org/mcp-server:${TAG:-latest}
build: .
restart: unless-stopped
read_only: true
tmpfs:
- /tmp:size=64m,noexec,nosuid
environment:
- REDIS_URL=redis://redis:6379
- LOG_LEVEL=info
ports:
- "8090:8090"
deploy:
resources:
limits:
memory: 512m
cpus: "1.0"
reservations:
memory: 128m
cpus: "0.25"
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "3"
depends_on:
redis:
condition: service_healthy
redis:
image: redis:7-alpine
restart: unless-stopped
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 3s
retries: 3
8. CI/CD Pipeline
Every push to main should result in a tested, built, and deployed MCP server — automatically, with no manual steps that can be skipped under pressure. The pipeline runs four stages: lint (catch style and type errors before they become runtime bugs), test (including MCP-specific tool tests), build (produce a tagged Docker image), and deploy (rolling update to the target environment).
mcp dev to start the server in a subprocess, then call each tool via the @modelcontextprotocol/inspector CLI and assert on the JSON output. This tests the full protocol stack — JSON-RPC framing, tool schema validation, error handling — not just the Python/TypeScript function in isolation.
test job uses a service container for Redis. The build job builds and pushes to GitHub Container Registry only if tests pass. The deploy job triggers a rolling update via kubectl using a kubeconfig stored in repository secrets.
@modelcontextprotocol/inspector@0.8.0). An unexpected Inspector upgrade can change output format and break assertions. Use npm ci, not npm install, in CI to get reproducible installs.
name: MCP Server CI/CD
on:
push:
branches: [main]
pull_request:
branches: [main]
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}/mcp-server
jobs:
# ── 1. Lint ──────────────────────────────────────────────────
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with: { python-version: "3.12" }
- run: pip install ruff mypy
- run: ruff check . && mypy src/
# TypeScript:
# - run: npm ci && npm run lint && npm run typecheck
# ── 2. Test (with Redis service) ─────────────────────────────
test:
runs-on: ubuntu-latest
needs: lint
services:
redis:
image: redis:7-alpine
ports: ["6379:6379"]
options: --health-cmd "redis-cli ping" --health-interval 5s
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with: { python-version: "3.12" }
- uses: actions/setup-node@v4
with: { node-version: "20" }
- name: Install Python deps
run: pip install -r requirements.txt -r requirements-dev.txt
- name: Install MCP Inspector
run: npm install -g @modelcontextprotocol/inspector@0.8.0
- name: Unit tests
env:
REDIS_URL: redis://localhost:6379
run: pytest tests/ -v --tb=short
- name: MCP protocol tests
run: |
# Start server in background
python -m mcp_server &
SERVER_PID=$!
sleep 2 # wait for startup
# Test each tool via MCP Inspector CLI
mcp-inspector call search_documents \
--server-command "python -m mcp_server" \
--tool-args '{"query":"test"}' \
| python -c "import sys,json; d=json.load(sys.stdin); assert 'content' in d"
kill $SERVER_PID
# ── 3. Build & Push Docker image ─────────────────────────────
build:
runs-on: ubuntu-latest
needs: test
if: github.event_name == 'push'
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@v4
- uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- uses: docker/build-push-action@v5
with:
context: .
push: true
tags: |
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.sha }}
cache-from: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:buildcache
cache-to: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:buildcache,mode=max
# ── 4. Deploy (rolling update) ───────────────────────────────
deploy:
runs-on: ubuntu-latest
needs: build
if: github.event_name == 'push'
environment: production
steps:
- uses: azure/setup-kubectl@v3
- run: echo "${{ secrets.KUBECONFIG }}" | base64 -d > ~/.kube/config
- name: Rolling update
run: |
kubectl set image deployment/mcp-server \
mcp-server=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.sha }}
kubectl rollout status deployment/mcp-server --timeout=120s
The pipeline enforces a strict gate: code cannot be deployed unless it passes linting, type checking, unit tests, and a live MCP protocol test. The Docker image is built once and tagged with the commit SHA, so you can always identify exactly which commit is running in production. The kubectl rollout status call blocks until the rolling update succeeds — if any pod fails to start, the CI job fails and the old version continues serving traffic.
9. Track Complete — What's Next
You have completed the MCP Track
Eight modules covering the full MCP protocol stack — from a three-line tool to a production-hardened, horizontally-scalable, observable server. Here is what you built:
Three paths forward:
- Build the MCP Capstone — a production MCP server integrating all 8 modules: multi-tool, authenticated, rate-limited, observable, with CI/CD. The spec drives the entire build.
- Contribute to the Community Registry — publish your server at github.com/modelcontextprotocol/servers. A well-documented server with a clean README gets adopted within days.
- Integrate MCP into your main agent — go back to the main course (M09 RAG, M12 ReAct Loop, M14 Multi-Agent) and replace hardcoded tool implementations with MCP servers. Your agents become tool-agnostic.
10. Knowledge Check
Five questions covering production MCP patterns. Each question has exactly one best answer.