A Streamable HTTP MCP server is, from an infrastructure standpoint, just an HTTP service, it containerizes and deploys the same way as any other backend API. The details that matter are keeping the image small and hardened, wiring health checks correctly, keeping the process stateless so it scales horizontally, and handling shutdown gracefully so rolling deploys do not silently drop in-flight work.
Dockerfile: Multi-Stage and Hardened
# Stage 1: build dependencies in a full image, discard build tools afterward
FROM python:3.12-slim AS builder
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir --target=/deps -r requirements.txt
# Stage 2: minimal runtime image, only the installed packages + app code
FROM python:3.12-slim AS runtime
WORKDIR /app
COPY --from=builder /deps /usr/local/lib/python3.12/site-packages
COPY . .
# Non-root user: MCP servers handle enterprise data, run unprivileged
RUN useradd -m mcpuser && chown -R mcpuser /app
USER mcpuser
EXPOSE 8080
HEALTHCHECK --interval=30s --timeout=3s CMD curl -f http://localhost:8080/health || exit 1
CMD ["uvicorn", "server:app", "--host", "0.0.0.0", "--port", "8080"]
The multi-stage split matters beyond image size: build tools (compilers, headers needed only to install certain Python packages) never end up in the runtime image at all, shrinking the attack surface, since a vulnerability in a build-only tool cannot be exploited in a container that never shipped it. Running as a non-root user is a similarly cheap, high-value hardening step, if a vulnerability in a dependency ever allows arbitrary code execution inside the container, a non-root process cannot escalate to modifying system files or escaping the container as easily as a root process could.
# server.py: expose /health separately from the MCP endpoint itself
from starlette.responses import JSONResponse
from starlette.routing import Route
async def health(request):
return JSONResponse({"status": "ok"})
app = mcp.streamable_http_app()
app.router.routes.append(Route("/health", health))
Kubernetes Deployment with Correctly-Scoped Probes
apiVersion: apps/v1
kind: Deployment
metadata:
name: enterprise-mcp-server
spec:
replicas: 3
strategy:
rollingUpdate: { maxUnavailable: 0, maxSurge: 1 } # Never drop below full capacity during deploys
selector:
matchLabels: { app: enterprise-mcp-server }
template:
metadata:
labels: { app: enterprise-mcp-server }
spec:
terminationGracePeriodSeconds: 45 # Give in-flight tool calls time to finish on SIGTERM
containers:
- name: mcp-server
image: registry.internal/enterprise-mcp-server:1.4.0
ports:
- containerPort: 8080
env:
- name: DATABASE_URL
valueFrom: { secretKeyRef: { name: mcp-db-secret, key: url } }
resources:
requests: { cpu: "250m", memory: "256Mi" }
limits: { cpu: "1", memory: "512Mi" }
readinessProbe:
httpGet: { path: /health/ready, port: 8080 } # Checks downstream deps
initialDelaySeconds: 5
periodSeconds: 10
livenessProbe:
httpGet: { path: /health/live, port: 8080 } # Process-only check
initialDelaySeconds: 15
periodSeconds: 20
---
apiVersion: v1
kind: Service
metadata:
name: enterprise-mcp-server
spec:
selector: { app: enterprise-mcp-server }
ports:
- port: 80
targetPort: 8080
# Two distinct health endpoints, not one, matching the two probes' different purposes
@app_router.get("/health/live")
async def liveness(request):
"""Process is running and the event loop is responsive. No downstream checks."""
return JSONResponse({"status": "alive"})
@app_router.get("/health/ready")
async def readiness(request):
"""Can this pod actually serve tool calls right now? Checks real dependencies."""
try:
async with asyncio.timeout(2):
await db_pool.execute("SELECT 1")
return JSONResponse({"status": "ready"})
except Exception:
return JSONResponse({"status": "not_ready"}, status_code=503)
flowchart TB
subgraph cluster["Kubernetes Cluster"]
Svc["Service: enterprise-mcp-server\n(ClusterIP, load balances)"]
Svc --> P1["Pod 1"]
Svc --> P2["Pod 2"]
Svc --> P3["Pod 3"]
P1 --> DB[("Postgres\n(shared state)")]
P2 --> DB
P3 --> DB
end
Agent1["Agent Pod A"] --> Svc
Agent2["Agent Pod B"] --> Svc
style Svc fill:#f0fdf9,stroke:#0D9488,color:#0F172A
style P1 fill:#EEF0F7,stroke:#6366F1,color:#0F172A
style P2 fill:#EEF0F7,stroke:#6366F1,color:#0F172A
style P3 fill:#EEF0F7,stroke:#6366F1,color:#0F172A
style DB fill:#fff7ed,stroke:#f59e0b,color:#0F172A
style Agent1 fill:#EEF0F7,stroke:#6366F1,color:#0F172A
style Agent2 fill:#EEF0F7,stroke:#6366F1,color:#0F172A
Graceful Shutdown: Not Dropping In-Flight Work
When Kubernetes rolls out a new version, it sends SIGTERM to the old pod, waits up to terminationGracePeriodSeconds, and then force-kills anything still running. A server that ignores SIGTERM and simply gets killed mid-request drops whatever was in flight, an aborted tool call, or worse, a partially-completed write that never gets confirmed to the caller either way.
import signal, asyncio
shutting_down = False
def handle_sigterm(*args):
global shutting_down
shutting_down = True
# Stop accepting new connections immediately; existing ASGI server
# (uvicorn) drains in-flight requests within the grace period on its own.
signal.signal(signal.SIGTERM, handle_sigterm)
@app_router.get("/health/ready")
async def readiness(request):
if shutting_down:
# Tell Kubernetes to stop routing new traffic here the instant
# SIGTERM arrives, even before the grace period elapses.
return JSONResponse({"status": "shutting_down"}, status_code=503)
...
For the async job pattern from Lesson 27 specifically, this is why job state must live in external storage (Redis/Postgres) rather than only in the server process’s memory, a rolling deploy’s forced kill after the grace period will still terminate a background worker task mid-job if it runs longer than the grace period allows, and only a job whose progress was persisted externally can be picked back up by whichever replica handles the next poll, rather than silently losing all progress.
Keep the readiness probe honest, if it always returns 200 regardless of downstream health or shutdown state, Kubernetes will keep routing traffic to a pod that cannot actually serve tool calls, or worse, to one that is actively terminating. A slightly deeper readiness check, a lightweight database ping with a short timeout, plus the shutdown-state check above, catches both classes of failure before they reach an agent as a mysterious tool error.
The next lesson covers remote MCP servers reached over the public internet or a partner network, and the registry pattern used to discover them.