Build a Remote MCP Server for Your Own API (Node.js + Docker, 2026)
Wrap your existing HTTP API in a remote MCP server with the official Node SDK, add bearer auth, ship it in Docker, and verify it with a real client over both stdio and Streamable HTTP.
Every API you own is one thin translation layer away from being usable by an AI agent. This guide builds that layer: two MCP tools over a normal HTTP API, a stdio entry for local clients, a Streamable HTTP entry with bearer auth for remote ones, and a Docker image you can put behind a reverse proxy on a $5 VPS. Every number and error message below comes from a real run on Node 26 + SDK 1.32.1. Verified 2026-10-19.
What we’re building
A Model Context Protocol server exposing two tools over an existing JSON API:
list_orders— optionalstatusfilter, returns the upstream payloadget_order— single order by id, with a real error path when the upstream returns 404
The upstream here is a 20-line Express mock standing in for “your API”. Nothing in the MCP layer knows about your domain: that is the point. You are adding a protocol adapter, not rewriting the service.
What you’ll learn
- Which packages to pin and how the SDK’s tool registration works in 1.32.x
- Why stdio and HTTP are two entries over one server factory, not two servers
- How Streamable HTTP sessions actually behave, measured
- The two container gotchas that make a working server unreachable
- How to test the whole thing with the official client instead of trusting
curl
Prerequisites
- Node.js 24 LTS or newer (verified on Node 26.10.0)
- Docker with Compose v2 (verified on Docker 28.2.2)
- An API you control, or the mock below
1. Scaffold and pin
mkdir orders-mcp && cd orders-mcp
npm init -y
npm pkg set type=module
npm install @modelcontextprotocol/[email protected] [email protected] [email protected]
Versions are pinned because the SDK’s transport surface moves fast. At the time of writing, @modelcontextprotocol/[email protected] declares LATEST_PROTOCOL_VERSION = '2025-11-25' and accepts zod ^3.25 || ^4.0 as a peer dependency — so zod 4 is fine, but check that line before copying a tutorial written for 1.0.
2. Wrap the API as tools
// server.js
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { z } from "zod";
const API = process.env.UPSTREAM_URL || "http://127.0.0.1:4010";
async function api(path) {
const res = await fetch(API + path, { signal: AbortSignal.timeout(5000) });
if (!res.ok) throw new Error(`upstream ${res.status} for ${path}`);
return res.json();
}
export function buildServer() {
const server = new McpServer({ name: "orders", version: "1.0.0" });
server.registerTool(
"list_orders",
{
title: "List orders",
description: "List orders, optionally filtered by status (paid, shipped, refunded).",
inputSchema: { status: z.enum(["paid", "shipped", "refunded"]).optional() },
},
async ({ status }) => {
const qs = status ? `?status=${encodeURIComponent(status)}` : "";
const data = await api("/orders" + qs);
return {
content: [{ type: "text", text: JSON.stringify(data) }],
structuredContent: data,
};
},
);
server.registerTool(
"get_order",
{
title: "Get order",
description: "Fetch a single order by id.",
inputSchema: { id: z.string().describe("Order id, e.g. ord_1001") },
},
async ({ id }) => {
try {
const order = await api(`/orders/${encodeURIComponent(id)}`);
return { content: [{ type: "text", text: JSON.stringify(order) }], structuredContent: order };
} catch (err) {
return { isError: true, content: [{ type: "text", text: String(err.message) }] };
}
},
);
return server;
}
Two details matter more than they look:
- The description is the API. The model picks a tool by reading
descriptionand the zod schema. “Fetch a single order by id” plusiddocumented asord_1001is what stops it passing a status string. - Return
isError: truefor upstream failures, don’t throw. A thrown error becomes a protocol-level crash the client has to interpret;isErrorbecomes a tool result the model can read and retry. Verified:get_orderwithid=nopereturnedisError: true -> upstream 404 for /orders/nope.
3. stdio entry
// stdio.js
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { buildServer } from "./server.js";
const server = buildServer();
await server.connect(new StdioServerTransport());
console.error("orders mcp server ready on stdio");
stdout is the JSON-RPC channel on stdio. A single console.log corrupts the stream and the client reports a parse error, not your bug. Log to stderr only.
4. Streamable HTTP entry
One endpoint (/mcp), POST for requests, GET for the server-initiated stream, DELETE to end the session, and a session map keyed by the mcp-session-id header.
// http.js
import express from "express";
import { randomUUID } from "node:crypto";
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js";
import { buildServer } from "./server.js";
const PORT = Number(process.env.PORT || 8787);
const HOST = process.env.HOST || "127.0.0.1"; // containers need 0.0.0.0
const TOKEN = process.env.MCP_TOKEN;
const app = express();
app.use(express.json({ limit: "1mb" }));
app.use((req, res, next) => {
if (req.path !== "/mcp") return next();
if (req.headers.authorization !== `Bearer ${TOKEN}`) {
res.status(401).set("WWW-Authenticate", 'Bearer error="invalid_token"')
.json({ error: "missing or invalid bearer token" });
return;
}
next();
});
const transports = new Map();
app.post("/mcp", async (req, res) => {
const sid = req.headers["mcp-session-id"];
let transport = sid ? transports.get(sid) : undefined;
if (!transport && isInitializeRequest(req.body)) {
transport = new StreamableHTTPServerTransport({
sessionIdGenerator: () => randomUUID(),
onsessioninitialized: (id) => transports.set(id, transport),
});
transport.onclose = () => { if (transport.sessionId) transports.delete(transport.sessionId); };
await buildServer().connect(transport);
}
if (!transport) {
res.status(400).json({ jsonrpc: "2.0", id: null,
error: { code: -32000, message: "Bad Request: no valid session" } });
return;
}
await transport.handleRequest(req, res, req.body);
});
app.get("/mcp", (req, res) =>
transports.get(req.headers["mcp-session-id"])?.handleRequest(req, res) ?? res.status(400).send("no session"));
app.delete("/mcp", (req, res) =>
transports.get(req.headers["mcp-session-id"])?.handleRequest(req, res) ?? res.status(400).send("no session"));
app.get("/healthz", (_req, res) => res.json({ ok: true, sessions: transports.size }));
app.listen(PORT, HOST, () => console.log(`mcp http on ${HOST}:${PORT}`));
The handshake is visible with nothing but curl:
curl -si -X POST http://127.0.0.1:8787/mcp \
-H "Authorization: Bearer $MCP_TOKEN" \
-H 'content-type: application/json' \
-H 'accept: application/json, text/event-stream' \
-d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{},"clientInfo":{"name":"curl","version":"0"}}}'
HTTP/1.1 200 OK
content-type: text/event-stream
mcp-session-id: 682caa27-07cf-4710-9435-7256a9441e7e
event: message
data: {"result":{"protocolVersion":"2025-11-25","capabilities":{"tools":{"listChanged":true}},
"serverInfo":{"name":"orders","version":"1.0.0"}},"jsonrpc":"2.0","id":1}
Note the accept header: this transport answers with text/event-stream even for a single response, and a client that only accepts application/json fails here.
5. Test it with the official client
// smoke.js
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
const transport = new StdioClientTransport({
command: process.execPath, args: ["stdio.js"], env: { ...process.env }, stderr: "inherit",
});
const t0 = performance.now();
const client = new Client({ name: "smoke", version: "1.0.0" });
await client.connect(transport);
const coldStart = performance.now() - t0;
const tools = await client.listTools();
const runs = [];
for (let i = 0; i < 20; i++) {
const s = performance.now();
await client.callTool({ name: "list_orders", arguments: { status: "paid" } });
runs.push(performance.now() - s);
}
runs.sort((a, b) => a - b);
const p = (q) => runs[Math.floor(q * (runs.length - 1))].toFixed(1);
console.log("cold start ms:", coldStart.toFixed(1), "tools:", tools.tools.map((t) => t.name).join(","));
console.log(`p50=${p(0.5)} p95=${p(0.95)} max=${runs.at(-1).toFixed(1)}`);
await client.close();
Three runs on a 12-vCPU shared host, upstream on loopback:
| Transport | Cold start (connect) | callTool p50 |
p95 | Payload |
|---|---|---|---|---|
| stdio | 192.8 – 223.9 ms | 1.1 – 2.4 ms | 3.1 – 3.9 ms | 274 B for one order |
| Streamable HTTP | 19.6 – 43.6 ms | 2.3 – 2.9 ms | 5.0 – 6.5 ms | same |
Read that carefully: the HTTP transport connects faster than stdio, because spawn + handshake dominates stdio’s cold start while an HTTP client reuses a warm server. Per-call latency is 1–3 ms either way; the tool layer is not your bottleneck, the upstream API is.
6. Containerise it
FROM node:24-alpine
WORKDIR /app
ENV NODE_ENV=production
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
COPY server.js http.js ./
USER node
EXPOSE 8787
CMD ["node", "http.js"]
services:
orders-mcp:
build: .
image: orders-mcp:1.0.0
restart: unless-stopped
ports:
- "127.0.0.1:8790:8787" # only the reverse proxy reaches it
environment:
PORT: "8787"
HOST: "0.0.0.0" # gotcha #1
MCP_TOKEN: ${MCP_TOKEN:?set MCP_TOKEN in .env}
UPSTREAM_URL: http://host.docker.internal:4010
extra_hosts:
- "host.docker.internal:host-gateway" # gotcha #2
healthcheck:
test: ["CMD", "wget", "-qO-", "http://127.0.0.1:8787/healthz"]
interval: 15s
timeout: 3s
retries: 3
Measured result of that build: image 194 MB (from node:24-alpine, 172 MB), container RSS 40.6 – 41.3 MiB at idle, and an end-to-end tool call through the published port returned the real upstream payload.
Gotcha #1: 127.0.0.1 inside a container is the container
The first build listened on 127.0.0.1:8787. The container was up, the healthcheck passed, docker ps showed 127.0.0.1:8790->8787/tcp, and every request from the host timed out. Docker’s port publishing forwards to the container’s eth0, not its loopback. Bind 0.0.0.0 in the container and keep 127.0.0.1:8790 on the host side so nothing but the proxy can reach it.
Gotcha #2: a loopback-bound upstream is invisible to the container
Same class of bug on the other side of the call. With the mock API listening on 127.0.0.1:4010, the container’s call to host.docker.internal:4010 failed with ECONNREFUSED — host.docker.internal resolves to the bridge gateway (172.17.0.1), and nothing listens there. Bind the upstream to 0.0.0.0 (or move it into the same Compose network) before blaming the MCP server.
7. Put it behind a VPS reverse proxy
Never expose the Express port to the internet. Terminate TLS at Caddy or nginx and keep the container on 127.0.0.1:
mcp.example.com {
reverse_proxy 127.0.0.1:8790
}
Two headers you must not strip: mcp-session-id (the whole session model) and Authorization. If your proxy buffers responses, disable buffering for /mcp — the transport streams.
Then point a remote client at it:
{
"mcpServers": {
"orders": {
"type": "http",
"url": "https://mcp.example.com/mcp",
"headers": { "Authorization": "Bearer ${MCP_TOKEN}" }
}
}
}
Sessions behave differently than you expect
Measured, with the /healthz counter reading transports.size:
connect client 1 -> sessions 1
client.close() -> sessions 1 # close did NOT terminate
connect client 2 -> sessions 2
terminateSession() on client 2 -> sessions 1
client.close() closes the client’s connection but does not send the DELETE that ends the server-side session. Only transport.terminateSession() does. If you rely on clients to clean up, the map grows for as long as the process lives — visible in the counter as 1, 2, 3, 4 across consecutive test runs. Add a TTL sweep or drop idle transports on onclose, or run stateless (sessionIdGenerator: undefined) when your tools do not need per-session state.
Production checklist
-
MCP_TOKENfrom the environment, never a default value in code - Proxy terminates TLS; container publishes on
127.0.0.1only -
healthcheckon/healthz,restart: unless-stopped - Session map has a TTL, or the server is stateless
- Every tool has a description and a zod schema with constraints, not free-form strings
- Upstream errors return
isError: true, not thrown exceptions - Rate limit
/mcpper token (one leaked token = full API access) - Log to stderr; stdout stays clean on the stdio entry
Cost
| Item | Monthly |
|---|---|
| VPS (1 vCPU, 1 GB — the container idles at ~41 MiB) | €4–6 |
| Domain + TLS (Caddy, Let’s Encrypt) | ~€1 |
| Egress: agent traffic is tiny JSON, well under 100 GB | €0 |
| Total | under €7/month |
Common problems
| Symptom | Cause | Fix |
|---|---|---|
| Port published, nothing answers | Server bound to 127.0.0.1 inside the container |
Bind 0.0.0.0 in the container |
fetch failed / ECONNREFUSED on upstream |
Upstream bound to loopback, container resolves host.docker.internal to the bridge |
Bind upstream 0.0.0.0 or use a Compose network |
| Client parses garbage on stdio | console.log in the server |
Log to stderr only |
| 400 “Bad Request: no valid session” | POST without mcp-session-id after init |
Send the header on every request |
| Client rejects the response | Only accepts application/json |
Accept text/event-stream too |
| Memory climbs over days | client.close() does not end sessions |
terminateSession() / TTL sweep |
| 401 on every call behind a proxy | Proxy stripped Authorization |
Explicitly forward the header |