⌂ Home MCP Track — Building with the Model Context Protocol ★ MODULE 08 of 8 · MCP Track — Final Module
~75 min Intermediate → Production Concept + Lab
MODULE 08 · Final Module

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.?

After this module you will:
  • 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
Everything you built in MCP01–MCP07 — tools, resources, prompts, auth tokens, multi-server orchestration, streaming — forms the payload. This module is the container. A great MCP server that crashes silently under load, leaks memory, or logs nothing useful is not production-ready; it is a time bomb.

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.

Analogy Think of a production MCP server like an air traffic control tower. Before a plane lands (tool call), the tower checks its radar (health probe), confirms runway capacity (rate limit), records everything on black-box tape (structured log), and has a backup tower ready if the primary fails (replica pod). A toy tower that only works on sunny days with zero traffic is not air traffic control — it is a model on a shelf.

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.

▶ Production Checklist — Live Preview
Comprehensive error handling
Circuit breaker pattern
Per-client rate limiting
Redis-backed rate store
/health endpoint
/ready endpoint
/metrics (Prometheus)
Kubernetes probes configured
SIGTERM handler
In-flight request drain
Structured JSON logs
trace_id per request
Multi-stage Docker build
Non-root user in container
Read-only filesystem
Resource limits (mem/CPU)
Lint → test → build CI
Automated deployment
Zero-downtime rollout
Rollback procedure
ItemCategorySeverityWhy It Matters
Map all exceptions to MCP error typesError HandlingCriticalUnhandled exceptions return opaque 500s; clients cannot retry correctly
Retry transient errors with back-offError HandlingCriticalNetwork blips become permanent failures without retry
Circuit breaker on downstream callsError HandlingHighPrevents cascade failure when a dependency degrades
Log all tool failures with contextObservabilityCriticalSilent failures are impossible to debug in production
Per-client rate limitingReliabilityCriticalOne misbehaving client exhausts the server for everyone
Redis-backed rate storeReliabilityHighIn-memory limits break under horizontal scaling
Retry-After header on 429sReliabilityMediumPolite clients need to know when to retry
/health liveness endpointObservabilityCriticalContainer orchestrators restart dead pods automatically
/ready readiness endpointObservabilityCriticalPrevents routing traffic to pods that are not yet warm
/metrics Prometheus endpointObservabilityHighEnables alerting on p99 latency, error rate, queue depth
Kubernetes liveness + readiness probesDeploymentHighWithout probes, K8s never restarts broken pods
SIGTERM handlerDeploymentCriticalAbrupt kills corrupt in-flight tool calls
30s drain windowDeploymentHighMatches Kubernetes terminationGracePeriodSeconds default
Structured JSON logsObservabilityHighUnstructured logs cannot be queried in Datadog/Loki
trace_id per requestObservabilityHighCorrelate logs across multi-server orchestration chains
Multi-stage Docker buildSecurityHighDev dependencies and build artifacts bloat attack surface
Non-root container userSecurityCriticalRoot containers can write to host filesystem on misconfigured nodes
Read-only filesystemSecurityHighPrevents runtime code injection
CPU + memory resource limitsReliabilityHighNo limits means one server can evict every other pod
Automated lint → test → deploy CIDeploymentMediumManual deploys introduce human error; CI enforces consistency
Why It Matters Teams that skip this checklist typically discover the gaps when: (1) a single high-traffic client brings down the entire server (no rate limiting), (2) a deployment drops 400 in-flight requests (no graceful shutdown), or (3) a silent bug goes undetected for 3 days because there are no structured logs to query. The median time-to-detect for a production MCP failure without structured logging is 6 hours.

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).

MCP Error Types
  • 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.

▶ Circuit Breaker State Machine
CLOSED
Requests pass through
OPEN
Fail immediately
HALF-OPEN
Probe call allowed
Failures: 0 / 5 Cooldown: — State: CLOSED
Circuit is closed. Requests pass through to the downstream dependency.
WHAT
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).
WHY
Centralizing error mapping means you never accidentally leak a KeyError or AttributeError stack trace to the client. Every error response has the same shape, which clients can parse reliably.
GOTCHA
Do not retry InvalidParamsError or ToolNotFoundError — they are deterministic. Retrying them wastes tokens and time. Only retry errors that are transient by nature.
Python — robust_executor.py
"""
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)
TypeScript — robustExecutor.ts
/**
 * 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);
What Just Happened?

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.

Tier Policy Standard clients: 60 requests/minute (1/sec). Premium clients: 600 requests/minute (10/sec). The client tier is determined from the JWT claims or API key metadata passed in the MCP request headers. Rate state is stored in Redis so that horizontal scaling does not fragment limits across instances.
WHAT
A Redis-backed token bucket. The bucket state (tokens remaining, last refill timestamp) lives in a Redis hash keyed by ratelimit:{client_id}. A Lua script does the check-and-decrement atomically to avoid race conditions across multiple server instances.
WHY
Redis Lua scripts execute atomically on the Redis server. No other client can interleave between the token check and decrement. Without atomicity, two instances could both see "1 token remaining" and both allow the request, effectively doubling the limit.
GOTCHA
Always set a TTL on the rate limit key (e.g., EXPIRE ratelimit:{id} 120). Without a TTL, keys for departed clients accumulate forever and Redis memory grows unbounded.
Python — rate_limiter.py
"""
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)
TypeScript — rateLimiter.ts
/**
 * 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();
}
What Just Happened?

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).

Why It Matters Without a readiness probe, Kubernetes routes traffic to pods the moment they start — before the database connection pool is warm. This causes a burst of connection errors on every rolling deploy. With the probe, pods only receive traffic after they report ready. In a 10-pod deployment, this prevents ~3 seconds of errors per deploy cycle.
WHAT
Three HTTP endpoints alongside the MCP server. /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.
WHY
Running health endpoints on a separate HTTP port (8090) means the MCP transport (stdio or SSE) does not compete with probe traffic for bandwidth or connection slots.
Python — health_server.py
"""
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}")
TypeScript — healthServer.ts
/**
 * 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

WHAT
The Kubernetes deployment manifest configures both probes against the health server port. The readiness probe has an initialDelaySeconds of 10 to give the server time to connect to Redis before it receives traffic.
YAML — k8s-deployment.yaml (probe section)
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.

▶ Graceful Shutdown Sequence
SIGTERM received
Kubernetes signals pod to stop
0ms
🚫
Stop accepting new requests
Close listener; new clients get connection refused
~50ms
Drain in-flight requests
Wait up to 30s for active tool calls to finish
~5s
🔗
Close downstream connections
Flush Redis, close DB pool, flush log buffers
~200ms
Exit 0
Clean exit; Kubernetes confirms pod terminated
Done
WHAT
Register a SIGTERM handler that: (1) sets a "shutting down" flag to reject new requests, (2) waits for an active-request counter to reach zero, (3) closes connections, (4) calls sys.exit(0). A 30-second hard timeout prevents the pod from hanging if a request never completes.
GOTCHA
Python's 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.
Python — graceful_shutdown.py
"""
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())
TypeScript — gracefulShutdown.ts
/**
 * 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
What Just Happened?

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.

Analogy Structured logs are like a flight data recorder, not a pilot's verbal commentary. The verbal commentary might say "something went wrong at 14:23." The flight recorder says which sensor, what reading, what altitude, what speed, what the co-pilot did three seconds earlier. When the plane is already on the ground and you are trying to figure out why it crashed, "something went wrong" is not actionable.
WHAT
Python uses 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.
WHY
A consistent schema lets you write a single Grafana dashboard that works for every MCP server in your fleet, regardless of which team wrote it or which language it uses.
Python — structured_logging.py (structlog)
"""
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
# }
TypeScript — logger.ts (pino)
/**
 * 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
// }
Log Level Policy DEBUG: tool arguments (only in non-production; may contain PII). INFO: tool call start/success with duration. WARN: retries, circuit breaker transitions, rate limit hits. ERROR: tool failures, internal exceptions. Never log at DEBUG in production by default — enable per-request via a 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).

WHAT
Stage 1 installs all dependencies and compiles code. Stage 2 copies only the runtime artifacts. The container runs as UID 1001 with no new privileges. Resource limits in docker-compose.yml prevent any single server from consuming all host resources.
WHY
Multi-stage builds reduce image size by 60–80% (Python example: 900MB build image → 180MB runtime image). Smaller images mean faster pulls on deploy, reduced attack surface, and lower registry storage costs.
GOTCHA
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.
Dockerfile — Python (multi-stage)
# ── 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"]
Dockerfile — TypeScript / Node.js (multi-stage)
# ── 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"]
YAML — docker-compose.yml (production)
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-Specific Test Strategy Use 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.
WHAT
A single GitHub Actions workflow. The 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.
WHY
Gating the deploy on test passage means a broken tool cannot reach production. Build caching (layer cache from the previous image) cuts CI time from 4 minutes to under 90 seconds on warm runs.
GOTCHA
Pin the MCP Inspector version in CI (e.g., @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.
YAML — .github/workflows/deploy.yml
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
What Just Happened?

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.

You now have every production pattern in place: errors map correctly, rate limits prevent abuse, probes enable zero-downtime deploys, graceful shutdown protects in-flight work, logs are queryable, images are hardened, and CI enforces quality before every deploy. The next section closes the MCP track.

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:

MCP01 — MCP02
Tools, resources, and prompts. The three primitives every MCP server is built from.
MCP03 — MCP04
SSE transport, HTTP deployment, and authentication with Bearer tokens and OAuth.
MCP05 — MCP06
Sampling, elicitation, and server-sent notifications for real-time progress.
MCP07 — MCP08
Multi-server orchestration, fan-out patterns, and full production hardening.

Three paths forward:

  1. 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.
  2. 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.
  3. 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.

1. A tool makes a database call that occasionally times out with a ConnectionError. After three retries the error persists. Which MCP error type should the executor return to the client?

A
InvalidParamsError — the connection string must be wrong
B
ToolNotFoundError — the tool cannot reach its dependency
C
ToolExecutionError — the tool ran but the execution failed due to a transient dependency error
D
InternalError — only use this for unexpected exceptions with no better mapping

2. Your MCP server runs 4 replicas behind a load balancer. A standard client is allowed 60 requests/minute. If each replica uses an in-memory token bucket, what happens?

A
Correct enforcement — each replica tracks 60/4 = 15 requests/minute
B
The effective limit becomes 240 requests/minute — each replica allows 60 independently
C
Requests fail because replicas cannot share state
D
The load balancer enforces the limit automatically

3. Kubernetes sends SIGTERM to your MCP server pod. The server has 3 in-flight tool calls that each take about 8 seconds. terminationGracePeriodSeconds is set to 10. What happens?

A
All 3 calls finish cleanly — SIGTERM waits indefinitely
B
The calls finish because 8 < 10, so no requests are dropped
C
After 10 seconds Kubernetes sends SIGKILL, killing the calls mid-execution regardless of the drain handler
D
The readiness probe blocks SIGKILL until all calls finish

4. Which of the following log events should be emitted at DEBUG level (not INFO or ERROR)?

A
A tool call completes successfully with duration 42ms
B
The raw arguments passed to a tool call
C
A circuit breaker transition from CLOSED to OPEN
D
A rate limit hit with Retry-After value

5. Your multi-stage Dockerfile has a USER 1001 instruction in the runtime stage. The base image has USER root. What is the security benefit, and what is the most common breakage it causes?

A
Benefit: smaller image. Breakage: slower builds
B
Benefit: encrypted filesystem. Breakage: missing env vars
C
Benefit: container cannot write to host filesystem on misconfigured nodes. Breakage: code that writes to paths owned by root (e.g., /var/log) fails at runtime
D
Benefit: read-only container. Breakage: health endpoints unreachable