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:
GET /sse— the client opens this once per session. The server keeps the connection open and uses it as a server-to-client push channel (the “events” stream).POST /messages— every client-initiated request goes here. The server processes the JSON-RPC body and responds on the SSE stream above, not in the POST response.
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:
pkb— built from the local Dockerfile, expose-only (no host port), env vars set the transport mode and port. The container is not reachable from the host directly; only Caddy can talk to it inside the compose network.caddy—caddy:2-alpineimage, publishes 80 and 443, mounts theCaddyfile, and persistscaddy_data(Let’s Encrypt account + certs) andcaddy_configin named volumes.
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:
USER node— the runtime stage runs as non-root.node:20-alpinebundles UID 1000 asnode; confirm withdocker exec pkb id.npm ci --omit=dev— notsx,pino-pretty,@types/*, or test deps in the runtime image. Smaller attack surface, smaller layer.node:20-alpine— small (~30 MB base), actively maintained, well-known. Avoid arbitrary:latesttags — they make image audits non-reproducible.- No secrets in the image. The
Caddyfileis public-domain config; PKB itself readsLOG_LEVEL/PKB_TRANSPORT/PORTfrom env at start time. Bearer tokens, OAuth secrets, and per-tenant config come in at compose-time (L14) or via a secret store, never baked into the layers. .dockerignorelistsnode_modules,.git,*.log— every entry has a security or perf reason. Treat the file as a deny-list audit, not a perf nicety.
Common pitfalls
Five pitfalls before you put HTTP MCP in front of a real client
- Opening
/ssefrom 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-keyedMapfixes this. - Forgetting
express.json()middleware onPOST /messages— the handler receives anundefinedbody and the SSE stream hangs without an error. Add the middleware before mounting routes. - Mapping host port
8080:8080and fronting with Caddy on 443 — the server is now reachable two ways, one of which has no TLS. Useexpose: 8080in compose so only Caddy faces the internet. - ACME failures because DNS doesn’t resolve to the server’s IP —
Caddy logs the error clearly. Run
dig pkb.example.combefore debugging compose; nine times out of ten it’s a propagation delay or a stale A record. - Treating
/healthzas a “full system check” that exercises every tool — this makes the load-balancer health flap during partial outages. Keep/healthzcheap and binary: process is up, transport is wired, return200.
References
-
@modelcontextprotocol/sdkon GitHub — SSE transport source lives atsrc/server/sse.ts; Streamable HTTP atsrc/server/streamableHttp.ts. - Caddy 2 documentation —
reverse_proxy, automatic HTTPS,tls internal, and thelogdirective. - Docker multi-stage
build documentation — the canonical reference for the
FROM … AS build/FROM … AS runtimepattern. - Node.js official Docker image
— tag listing, security advisories, and the rationale for the bundled
nodeuser. - Let’s Encrypt ACME documentation — HTTP-01 and DNS-01 challenge mechanics; useful when Caddy’s ACME logs point at a propagation or firewall issue.
- PKB TypeScript sample-server tree
— the working
Dockerfile,docker-compose.yml, andCaddyfileyou walk through in the lab.