Lesson 13 Docker & HTTP Transport
Theme:

Docker & HTTP Transport

Lesson 13 · MCP Servers · ~10 min read

Why HTTP transport changes the model

A stdio MCP server is a child process of its client. The client owns its lifecycle, sees its stdout, and is the only entity that can call it. There is no network surface, no authentication question, and no “who is this caller?” — the parent process is the caller, end of story. An HTTP MCP server is a different animal: a long-running process that accepts connections from anyone who can reach it. That single change drives the rest of Module 5. You now need to identify who is calling (L14 auth), limit what each caller can do (L14 rate limit), isolate their state (L14 multi-tenant), and audit every call (L15). Lesson 13 is the foundation: get HTTP transport working end-to-end with TLS, and defer the security model to L14. We will not put a bare HTTP MCP server on the public internet in this lesson — that comes after the security work.

SSE: the two-route protocol

MCP-over-HTTP, in its SSE flavour, uses two routes:

The server-side glue is SSEServerTransport(messagesPath, response). You pass it the path you will mount the POST route at (/messages) and the Express res object from the GET /sse handler — it owns the stream from there. The single-session pattern (let transport: SSEServerTransport | null = null) is fine for the L13 lab; the multi-session split (Map<sessionId, SSEServerTransport>) lands in L14 alongside auth, because session id and tenant id are the same key in practice.

The newer Streamable HTTP transport collapses the two routes into a single POST / that uses chunked transfer-encoding for the server-to-client direction. Request and response share one connection — simpler to reason about and friendlier to edge runtimes (Cloudflare Workers, Vercel Functions, Deno Deploy) that enforce short connection lifetimes. We mention it as homework but stay on SSE for the main path because SSE is still the most widely deployed pattern in 2026.

The transport switch

PKB picks transport at startup from PKB_TRANSPORT:

const mode = process.env.PKB_TRANSPORT ?? "stdio";
if (mode === "stdio") { /* L12 path: StdioServerTransport */ }
if (mode === "http")  { /* express + SSEServerTransport */ }

Both branches share the same Server instance and the same setRequestHandler registrations. Tool code never sees the transport. This is the whole point of the SDK’s transport abstraction: you write tools once; you ship them anywhere. The same pattern works for Claude Desktop (stdio), an internal HTTP service (SSE), and a public edge deployment (Streamable HTTP once you migrate). Keep the switch at the very top of server.ts so the rest of the file stays transport-agnostic.

The multi-stage Dockerfile

The two-stage build is the standard Node Docker pattern, and the one PKB-TS adopts in this lesson:

FROM node:20 AS build
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm ci
COPY tsconfig.json ./
COPY src ./src
RUN npm run build

FROM node:20-alpine AS runtime
WORKDIR /app
ENV NODE_ENV=production
ENV PKB_TRANSPORT=http
ENV PORT=8080
COPY package.json package-lock.json* ./
RUN npm ci --omit=dev
COPY --from=build /app/dist ./dist
USER node
EXPOSE 8080
CMD ["node", "dist/server.js"]

Stage 1 (node:20, Debian-based) has the full toolchain so tsc and any native compile step works. Stage 2 (node:20-alpine, ~30 MB base) has only the runtime, production deps, and compiled JS — typical final size ~80 MB. USER node drops root in the runtime stage; the node image bundles a non-root node user with UID 1000. .dockerignore keeps node_modules, .git, log samples, and RUNBOOK.md out of the build context so layer caching stays fast and nothing sensitive accidentally lands in the image. Every line in the Dockerfile has a security or perf reason — treat it as the most-read code in the repo.

docker compose up and the Caddy frontend

Compose wires two services:

Caddy does the heavy lifting in six lines:

pkb.example.com {
    encode gzip
    reverse_proxy pkb:8080
    log {
        output stdout
        format json
    }
}

Caddy automatically requests and renews a Let’s Encrypt certificate for pkb.example.com on first start. The hostname must resolve to your server’s public IP via a DNS A/AAAA record. For local dev — no public domain — swap the host for localhost and add tls internal { } so Caddy generates a self-signed cert and registers a local CA you can trust once via caddy trust. The healthcheck route /healthz is also reverse-proxied; both Caddy and the docker-compose healthcheck (if you enable it) probe through HTTP.

Inspector against the deployed server

The MCP Inspector accepts a URL directly — the same tool you ran against npx tsx src/server.ts in L12 now works against the live HTTPS endpoint:

npx @modelcontextprotocol/inspector https://pkb.example.com/sse

It opens the SSE stream, lists echo (and now if you did the L12 homework), and round-trips the same calls — same UI, same workflow. The protocol is end-to-end identical; the only thing that changed is transport. This is the strongest argument for the SDK’s transport abstraction: your tests, your client code, and your tool implementations don’t need an if stdio else http branch anywhere. The dispatcher built in L12 ships to HTTPS unchanged.

Container-security baseline

Even before L14/L15’s security work, the L13 image follows a few non-negotiable defaults:

Common pitfalls

Five pitfalls before you put HTTP MCP in front of a real client

  1. Opening /sse from multiple clients with the single-session pattern — the second client overwrites the first’s transport reference and the first session silently dies. L14’s tenant-keyed Map fixes this.
  2. Forgetting express.json() middleware on POST /messages — the handler receives an undefined body and the SSE stream hangs without an error. Add the middleware before mounting routes.
  3. Mapping host port 8080:8080 and fronting with Caddy on 443 — the server is now reachable two ways, one of which has no TLS. Use expose: 8080 in compose so only Caddy faces the internet.
  4. ACME failures because DNS doesn’t resolve to the server’s IP — Caddy logs the error clearly. Run dig pkb.example.com before debugging compose; nine times out of ten it’s a propagation delay or a stale A record.
  5. Treating /healthz as a “full system check” that exercises every tool — this makes the load-balancer health flap during partial outages. Keep /healthz cheap and binary: process is up, transport is wired, return 200.

References