Containerize PKB-TS, swap stdio for HTTP+SSE, put Caddy in front with automatic TLS.
MCP Servers Module 5
One local client per process; configured via Claude Desktop config; lives on your laptop.
Many clients over the network; one process per container; TLS terminated at the edge.
"Same SDK. Same handlers. Different transport class."
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.
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.
express + SSEServerTransport glueconst 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.
DockerfileFROM 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.
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.
Caddyfilepkb.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.
tls internal for local devLocal 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
}
USER node in the Dockerfile drops root.npm ci --omit=dev excludes devDependencies from the runtime image.node:20-alpine is a known-small, known-maintained base..dockerignore blocks node_modules, .git, log samples from leaking into the build context.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.
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.
phase=ok happened, but not who triggered it. Fixed in L14 / L15.