Lesson 13 Docker & HTTP Transport
Theme:
Lesson 13 · Slide 1

Lesson 13 — Docker & HTTP Transport

Containerize PKB-TS, swap stdio for HTTP+SSE, put Caddy in front with automatic TLS.

MCP Servers Module 5

Lesson 13 · Slide 2

From local-only to internet-reachable

stdio transport

One local client per process; configured via Claude Desktop config; lives on your laptop.

HTTP+SSE transport

Many clients over the network; one process per container; TLS terminated at the edge.

"Same SDK. Same handlers. Different transport class."

Lesson 13 · Slide 3

SSE in 30 seconds

MCP-over-HTTP uses Server-Sent Events as the duplex channel: the client opens a long-lived GET /sse (server-to-client stream) and posts inbound requests to POST /messages. The server-side SSEServerTransport("/messages", res) ties the two endpoints together.

Trade-off vs Streamable HTTP: SSE needs two routes; Streamable HTTP is a single POST. SSE wins on browser-client compatibility; Streamable HTTP wins on edge-runtime ergonomics. We use SSE because it's the most widely deployed pattern in 2026.

Lesson 13 · Slide 4

The transport switch

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

One env var picks transport at startup; both modes share the same handlers — no fork in tool code.

Lesson 13 · Slide 5

express + SSEServerTransport glue

const app = express();
app.use(express.json());

let transport: SSEServerTransport | null = null;

app.get("/sse", async (_req, res) => {
  transport = new SSEServerTransport("/messages", res);
  await server.connect(transport);
});

app.post("/messages", async (req, res) => {
  if (!transport) return res.status(503).end();
  await transport.handlePostMessage(req, res, req.body);
});

app.get("/healthz", (_req, res) => res.json({ ok: true, service: "pkb" }));

/healthz returns {ok: true} for the load balancer / Caddy / Docker healthcheck. Single-session SSE shown here; the multi-session split lands in L14.

Lesson 13 · Slide 6

The multi-stage Dockerfile

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
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"]

Two-stage so the runtime image ships only compiled JS + production deps; final size ~80 MB.

Lesson 13 · Slide 7

docker compose up topology

[ caddy:443 ] --(reverse proxy)--> [ pkb:8080 ]
       |
   automatic TLS via Let's Encrypt

The compose file defines two services (pkb, caddy), one external port (443), and two named volumes (caddy_data, caddy_config) so the TLS cert survives container restarts.

Lesson 13 · Slide 8

The Caddyfile

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

6 lines of Caddy config replace 60 lines of nginx + 30 lines of certbot. The hostname must resolve to your server's public IP for ACME to succeed.

Lesson 13 · Slide 9

tls internal for local dev

Local dev: swap pkb.example.com for localhost and add tls internal { } so Caddy generates a self-signed cert. Inspector and browser-based clients trust the cert after a one-time caddy trust invocation. Use this for everything except the final "real domain" production smoke.

localhost {
    tls internal
    encode gzip
    reverse_proxy pkb:8080
}
Lesson 13 · Slide 10

Container security baseline

Lesson 13 · Slide 11

Healthcheck wiring

The GET /healthz route is the contract every layer reads — docker-compose.yml's healthcheck (optional, recommended), Caddy's load-balancer probe (auto), Kubernetes liveness/readiness if you graduate later.

Keep it sub-1 ms and dependency-free; don't make /healthz a "full system check" route — that defeats its purpose.

Lesson 13 · Slide 12

Smoke-testing the container

docker compose up --build -d
curl -s https://pkb.example.com/healthz
# {"ok":true,"service":"pkb"}
npx @modelcontextprotocol/inspector https://pkb.example.com/sse

Inspector accepts an SSE URL directly; the protocol is end-to-end the same, just wrapped in HTTPS.

Lesson 13 · Slide 13

What we're not doing (yet)

Lesson 13 · Slide 14

Recap & what's next

Coming up: Lesson 14 — Add bearer-token auth, per-tenant note isolation, and a token-bucket rate limiter. Make PKB-TS safe to expose to more than yourself.
1 / ?