This episode takes an MCP server into production: making it an HTTP service with Express, Fastify, and Next.js, Docker containerization, discovery via the .well-known file, and stateless routing for horizontal scaling with load balancers and health checks.

Episode 8 introduced MRTR — the feature enabling mid-call interactions without holding streaming open. That combination (stateless core + MRTR) opens the door wide for deploying an MCP server as a real network service. In this episode we take the server out of the local terminal: turning it into an HTTP endpoint, packaging it in Docker, letting clients discover it via .well-known, and exploiting the stateless nature for horizontal scaling.
Roadmap: start with the reasons for moving to HTTP, implementations with Express and Fastify, a route handler in Next.js, Docker containerization, the mcp.json discovery file, then stateless routing with a load balancer and health checks.
A stdio server can only be used by hosts that can spawn a subprocess on the same machine — local editors, CLI agents, or your Node/Python processes. That doesn't apply to web applications, SaaS agents, or services used by many users over the internet. For those, the server needs to speak Streamable HTTP: JSON-RPC request/response via POST, plus SSE for notifications and streaming.
Info
The good news: choosing a transport doesn't change your tool logic. The same server from episode 6 can be moved from StdioServerTransport to StreamableHTTPServerTransport without changing a single tool.
The main HTTP advantages: reachable from anywhere, easy to authenticate (episode 10), and — thanks to the stateless core — replicable without sessions.
The basic pattern is the same for all frameworks: set up the HTTP transport once, then hand POST requests to the transport.
import express from "express";
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
const app = express();
app.use(express.json());
const server = new McpServer({ name: "remote-tools", version: "1.0.0" });
const transport = new StreamableHTTPServerTransport({ enableJsonResponse: true });
await server.connect(transport);
app.post("/mcp", async (req, res) => {
await transport.handleRequest(req.body, req, res);
});
app.get("/healthz", (_req, res) => {
res.json({ status: "ok" });
});
app.listen(3001, () => console.log("MCP server di port 3001"));The Fastify version is almost identical, only the way of writing the response differs:
import Fastify from "fastify";
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
const app = Fastify();
const server = new McpServer({ name: "remote-tools", version: "1.0.0" });
const transport = new StreamableHTTPServerTransport({ enableJsonResponse: true });
await server.connect(transport);
app.post("/mcp", async (request, reply) => {
await transport.handleRequest(request.body, request.raw, reply.raw);
});
app.get("/healthz", async () => ({ status: "ok" }));
await app.listen({ port: 3001 });Note three things: the /mcp endpoint route receives all JSON-RPC requests, the /healthz route is for health checks (used in the routing section), and express.json() (or similar middleware) is required so the body is parsed before reaching the transport.
In the Next.js App Router, an MCP endpoint is written as a route handler at app/api/mcp/route.ts. The HTTP transport is created lazily — once — then reused for each request:
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
let transport;
export async function POST(request) {
if (!transport) {
transport = new StreamableHTTPServerTransport();
await server.connect(transport);
}
return transport.handleRequest(await request.json(), request, Response);
}Important note: the App Router supports streaming, but not all SSE options apply in serverless functions with limited execution duration. For large scale, run the server in a container (not serverless) so streaming and MRTR work comfortably.
A multi-stage Dockerfile keeps the image small: a build stage to compile TypeScript, and a runtime stage containing only the artifacts and production dependencies.
FROM node:20-alpine 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
WORKDIR /app
ENV NODE_ENV=production
COPY --from=build /app/dist ./dist
COPY --from=build /app/node_modules ./node_modules
EXPOSE 3001
CMD ["node", "dist/server.js"]Run it with compose, complete with a health check:
services:
mcp-server:
build: .
ports:
- "3001:3001"
healthcheck:
test: ["CMD", "wget", "-qO-", "http://localhost:3001/healthz"]
interval: 30sThe port and other configuration variables are read via environment variables (e.g. the MCP_SERVER_PORT variable) mapped from secrets in the orchestration platform — not hardcoded in the image.
Clients can discover an MCP server automatically via a discovery URL. Publish a mcp.json file at the path /.well-known/mcp.json:
{
"mcpServers": {
"production": {
"url": "https://mcp.example.com/mcp"
}
}
}Clients only need to know the domain (e.g. https://mcp.example.com), read that file, and direct their connection to the listed url. This is the same pattern as OpenID Connect's /.well-known/ — familiar and easy for tooling to adopt.
This is the most interesting part. Because the 2026-07-28 core is stateless, the server doesn't store sessions in memory — except for the in-flight state deliberately opened via MRTR. The consequences: horizontal scaling without sticky sessions, a round-robin load balancer letting any request land on any replica, and the /healthz health check telling the load balancer which replicas are eligible for traffic.
upstream mcp_pool {
server 10.0.0.11:3001;
server 10.0.0.12:3001;
}
server {
listen 443 ssl;
location /mcp {
proxy_pass http://mcp_pool;
proxy_http_version 1.1;
}
location /healthz {
proxy_pass http://mcp_pool;
}
}For MRTR flows that need follow-ups directed to the same instance, use routingId (the Mcp-Routing-Id header) as the basis for load balancer routing — for example a hash based on that header. Everything else, let the load spread evenly.
Episode 9 turns your local server into a production service: a Streamable HTTP endpoint in Express, Fastify, and Next.js, a Docker image ready to deploy, discovery via mcp.json, and a stateless architecture that unlocks horizontal scaling without sticky sessions.
Key takeaways:
stdio to HTTP is just swapping the transport./mcp for JSON-RPC, GET /healthz for health./.well-known/mcp.json makes client onboarding easy.In the next episode 10 we lock the door that has been open this whole time: Authorization with OAuth 2.1 — the roles of resource server and authorization server, the Mcp-Authorization header, refresh tokens, PKCE, and SSRF and CRLF mitigations. See you there!