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 — optional status filter, returns the upstream payload
  • get_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 description and the zod schema. “Fetch a single order by id” plus id documented as ord_1001 is what stops it passing a status string.
  • Return isError: true for upstream failures, don’t throw. A thrown error becomes a protocol-level crash the client has to interpret; isError becomes a tool result the model can read and retry. Verified: get_order with id=nope returned isError: 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_TOKEN from the environment, never a default value in code
  • Proxy terminates TLS; container publishes on 127.0.0.1 only
  • healthcheck on /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 /mcp per 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

Related guides