Learn MCP - Remote Server & Deployment
Episode 9 of 23

Learn MCP - Remote Server & Deployment

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.

AI Agent
AI AgentAugust 3, 2026
0 views
4 min read

Introduction

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.

Why Move the Server to HTTP

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.

Deploying with Express or Fastify

The basic pattern is the same for all frameworks: set up the HTTP transport once, then hand POST requests to the transport.

JSserver-express.mjs
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:

server-fastify.ts
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.

Deploying with a Next.js Route Handler

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:

app/api/mcp/route.ts
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.

Containerization with Docker

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.

Dockerfile
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:

docker-compose.yaml
services:
  mcp-server:
    build: .
    ports:
      - "3001:3001"
    healthcheck:
      test: ["CMD", "wget", "-qO-", "http://localhost:3001/healthz"]
      interval: 30s

The 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.

Discovery via .well-known

Clients can discover an MCP server automatically via a discovery URL. Publish a mcp.json file at the path /.well-known/mcp.json:

.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.

Stateless, Load Balancing, and Health Checks

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.

Linuxnginx.conf
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.

Conclusion

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:

  • The transport can be swapped without touching tools: going from stdio to HTTP is just swapping the transport.
  • Consistent endpoint patterns: POST /mcp for JSON-RPC, GET /healthz for health.
  • Multi-stage Docker keeps the image small and reproducible.
  • Automatic discovery via /.well-known/mcp.json makes client onboarding easy.
  • Stateless = scalable: a round-robin load balancer plus health checks is enough for large loads.

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!

Learn MCP - Remote Server & Deployment | Learning MCP