# Implementing SSE Transports for MCP: A Step-by-Step Guide for Node.js

If you have shipped a Model Context Protocol (MCP) server to production, it was probably over **stdio** — a child process spawned by Claude Desktop, a VS Code extension, or some local agent harness. That works beautifully for a single user on a single machine, and it is where most of us started. But stdio is a dead end the moment you need what every other backend service takes for granted: centralized auth, audit logs, horizontal scaling, and serving many concurrent clients from one deployment.
This guide is the bridge from *local process* to *network service*. We will walk through why Server-Sent Events (SSE) is the right transport for MCP’s asymmetric message flow, and then build a production-minded SSE MCP server in Node.js with Express — covering connection lifecycle, heartbeats, session state, and the security posture you need before this thing touches the public internet.
—
## 1. Moving Beyond stdio: Network-Distributed MCP
MCP is a JSON-RPC 2.0 protocol layered over a pluggable transport. The spec defines messages like `initialize`, `tools/call`, and `notifications/progress`, but deliberately says nothing about *how* those bytes travel — that is the transport’s job. The reference implementations give you three options:
– **stdio** — parent process spawns the server and speaks over stdin/stdout.
– **HTTP + SSE** — the client opens a server-sent event stream for server→client messages and POSTs client→server messages to a dedicated endpoint.
– **Streamable HTTP** (newer spec revisions) — collapses both directions onto a single POST endpoint.
stdio has real virtues: zero network surface, trivial auth (the OS owns the pipe), and automatic process lifecycle. But it couples your MCP server’s lifetime to a single client process. You cannot put stdio behind a load balancer. You cannot share one deployment across a fleet of agents. You cannot audit who called `tools/call` because there is no *who* — only a pipe.
Once your MCP server becomes a shared internal service — wrapping your CRM, your Oracle or MS SQL estate, your ticketing system — you need a transport that speaks HTTP. SSE is the pragmatic sweet spot: it rides plain HTTP (so every piece of middleware and infrastructure you already run understands it), it handles the server→client push channel that HTTP request/response alone cannot, and it is dramatically simpler to operate than a WebSocket fleet.
—
## 2. What Is SSE, and Why It Fits MCP’s Asymmetric Flow
Server-Sent Events is a one-way push protocol built directly on HTTP. The client opens a normal GET request; the server keeps the response open and streams `text/event-stream` frames to it. Each frame looks like:
“`
event: message
data: {“jsonrpc”:”2.0″,”id”:1,”result”:{}}
“`
The `data:` field carries the payload, `event:` names it, and comment lines starting with a colon (`: ping`) are ignored by the client — a detail we will exploit for heartbeats shortly.
Here is the key insight about MCP: **the traffic pattern is asymmetric, and it matches SSE’s one-way design perfectly.**
– **Client → Server** messages (`initialize`, `tools/call`, `resources/read`) are discrete request/response operations. They map naturally onto plain HTTP POST with a JSON body — easy to log, authenticate, rate-limit, and load-balance.
– **Server → Client** messages are the hard part. Responses can arrive seconds or minutes after a request (think long-running tool calls). And critically, the *server* must sometimes initiate traffic: `notifications/progress`, `notifications/logging`, `notifications/resources/updated`, and — in some configurations — server→client *requests* like `sampling/createMessage` where the model asks the host for context.
Plain request/response HTTP cannot represent a server-initiated push. WebSocket can, but it is a heavier contract: a protocol upgrade, a different connection lifecycle, frame parsing, and notoriously finicky behavior through corporate proxies and naive load balancers. SSE needs none of that — it is just a long-lived HTTP response. Every reverse proxy, CDN, and observability stack that understands HTTP understands SSE (give or take a buffering directive, which we will cover).
The result is a clean split that senior backend engineers will recognize and love:
| Direction | Transport | Semantics |
|—|—|—|
| Client → Server | `POST /messages` | Standard HTTP request/response, JSON-RPC body |
| Server → Client | `GET /sse` (held open) | Event stream for results, notifications, and server-initiated requests |
| Session binding | `sessionId` query param | Both legs address one logical MCP session |
One honest caveat before we commit: the MCP spec’s newer **Streamable HTTP** transport is where the protocol is headed, and it lets a single POST endpoint handle both directions (when the server has nothing to push, it can respond with an SSE stream or a plain JSON body). For existing SDKs and a large installed base of clients, the classic HTTP+SSE split remains the most interoperable choice today — and everything you learn about session lifecycle and connection management here transfers directly to Streamable HTTP later.
—
## 3. Setting Up Express.js for SSE-Based MCP Transport
Let’s scaffold. We will use the official `@modelcontextprotocol/sdk` for the protocol machinery (tool registration, JSON-RPC framing, capability negotiation) and Express purely as the HTTP layer. Do not reimplement JSON-RPC validation yourself — the SDK is battle-tested and versioned against the spec.
“`json
{
“name”: “mcp-sse-server”,
“type”: “module”,
“dependencies”: {
“@modelcontextprotocol/sdk”: “^1.x”,
“express”: “^4.19.0”,
“cors”: “^2.8.5”,
“helmet”: “^7.1.0”,
“zod”: “^3.23.0”
}
}
“`
Our HTTP surface is intentionally tiny — two routes, which is the whole point of SSE’s simplicity:
– `GET /mcp/sse` — the client connects here and receives the event stream.
– `POST /mcp/messages` — the client sends JSON-RPC messages here, tagged with `?sessionId=`.
The SSE handshake deserves attention because it is where beginners get confused. When a client connects to `/mcp/sse`, the server does not start processing tools yet. It first sends a special `endpoint` event telling the client *where* to POST:
“`
event: endpoint
data: /mcp/messages?sessionId=a1b2c3d4-e5f6-…
“`
Only after the client receives that event does it know the message URL. This indirection exists because the server may be behind a reverse proxy and needs to advertise its *public* route — so build the endpoint URI from the request’s host header, not from a hardcoded string.
The SDK’s `SSEServerTransport` handles this handshake and the JSON-RPC session plumbing for you. Here is the Express wiring:
“`js
import express from “express”;
import cors from “cors”;
import helmet from “helmet”;
import { McpServer } from “@modelcontextprotocol/sdk/server/mcp.js”;
import { SSEServerTransport } from “@modelcontextprotocol/sdk/server/sse.js”;
import { z } from “zod”;
const app = express();
// JSON body parsing only where it belongs: the POST message endpoint.
// Never apply express.json() globally before your SSE route.
app.use(helmet());
app.use(cors({ origin: process.env.CORS_ORIGIN?.split(“,”) ?? “*” }));
app.post(“/mcp/messages”, express.json({ limit: “1mb” }));
// The MCP server itself: register tools, resources, prompts.
const server = new McpServer(
{ name: “orders-mcp”, version: “1.0.0” },
{ capabilities: { tools: {} } }
);
server.registerTool(
“lookup_order”,
{ orderId: z.string().describe(“Order identifier”) },
async ({ orderId }) => ({ content: [{ type: “text”, text: `order:${orderId}` }] })
);
// One live SSE transport per connected session.
const sessions = new Map();
app.get(“/mcp/sse”, async (req, res) => {
// … auth check (Section 5) …
const transport = new SSEServerTransport(“/mcp/messages”, res);
sessions.set(transport.sessionId, transport);
res.on(“close”, () => {
sessions.delete(transport.sessionId);
// … cancel heartbeat timers …
});
await server.connect(transport);
});
app.post(“/mcp/messages”, async (req, res) => {
const transport = sessions.get(String(req.query.sessionId ?? “”));
if (!transport) {
res.status(404).json({ error: “Unknown session” });
return;
}
await transport.handlePostMessage(req, res);
});
app.listen(process.env.PORT ?? 3001, () =>
console.log(`MCP SSE server listening on :${process.env.PORT ?? 3001}`)
);
“`
A few things worth internalizing about this code:
1. **Body-parser placement matters.** `express.json()` on the GET stream route would try to read the never-ending body of an SSE connection. Apply it only to the POST route, as shown.
2. **Session registry is the glue.** `SSEServerTransport` generates a `sessionId` and embeds it in the `endpoint` event. Your POST handler must look up the matching transport by that ID. Every MCP deployment is a session store plus two routes.
3. **`helmet()` before everything.** It sets sensible security headers (`X-Content-Type-Options`, `X-Frame-Options`, etc.) on every response, including your stream. Cheap defense, always on.
If you would rather not use the SDK’s transport class, the manual recipe is: set `Content-Type: text/event-stream`, `Cache-Control: no-cache`, `Connection: keep-alive`, and `X-Accel-Buffering: no`; write `event: endpoint\ndata:
—
## 4. Handling Connections: Heartbeats, Timeouts, and Stateful Sessions
An SSE connection is an HTTP response held open indefinitely, and that is exactly what infrastructure tries to kill. Load balancers (nginx `proxy_read_timeout` defaults to 60s), cloud LBs, and corporate proxies all reap idle connections. Worse, some buffering proxies won’t flush anything to the client until they see enough bytes, which stalls your stream and breaks the MCP handshake entirely.
### Heartbeats
The fix is comment frames. An SSE comment — a line starting with `:` — is ignored by conformant clients but counts as traffic for intermediaries:
“`js
function startHeartbeat(res, sessionId, intervalMs = 15_000) {
const timer = setInterval(() => {
if (res.writableEnded) {
clearInterval(timer);
return;
}
res.write(“: heartbeat\n\n”);
}, intervalMs);
// Unref so the timer never keeps the process alive on its own.
timer.unref?.();
return timer;
}
“`
Fifteen seconds is a safe default: comfortably under the typical 30–60s proxy idle timeout, cheap at scale (a few bytes per connection per 15s), and invisible to clients. Also send the `X-Accel-Buffering: no` header on the stream response so nginx does not buffer your events.
### Timeouts and session TTL
Heartbeats keep *proxy* connections alive; they do not tell you whether the *client* is still there. A client that vanished (laptop asleep, network drop, process killed) leaves your session registry holding a zombie. Two mechanisms handle this:
– **Detect dead sockets.** The `close` event on `res` fires when the underlying socket dies — that is your primary cleanup hook, shown above. Test it: kill an `EventSource` client and confirm your registry drains within seconds.
– **Sweep stale sessions.** For defense in depth, track `lastSeen` per session (touch it in the POST handler) and run a periodic sweeper that terminates sessions idle beyond a TTL — 5–10 minutes of zero traffic is a good default for MCP, where clients may hold long-running tool calls. Combine the socket `close` event with the sweeper, not either one alone.
### Statefulness: the part everyone underestimates
MCP over SSE is **stateful by design**. The registry maps `sessionId` → transport because both legs of the conversation must reach the same server instance that owns the open stream — there is no way to POST a response to an SSE connection living on another node. Concretely:
– **Sticky sessions are mandatory** if you scale to multiple instances behind a load balancer, or accept that sessions pin to one node.
– **Reconnects create new sessions.** When a client’s `EventSource` retries, it issues a fresh `GET /sse`, gets a *new* `sessionId`, and the old session must be torn down. Design your tools to be idempotent and your resources to be re-fetchable, because MCP session state (initialization context, subscription sets) does not survive a transport reconnect.
– **A server restart orphans every client.** Plan for graceful shutdown: on `SIGTERM`, stop accepting new sessions, notify connected clients with an error event, and drain in-flight tool calls within a bounded window.
Treat the session registry as a first-class piece of application state. Put metrics on it — active sessions, reconnects per minute, zombie reaps — because connection churn is the earliest signal that something between your clients and server is misbehaving.
—
## 5. Security: CORS, Auth Headers, and Secure Transport
Putting MCP on the network means exposing your tool surface — potentially your *data* — to whoever can reach the endpoint. Treat an MCP server like any other API that gates sensitive operations.
### Authentication
Authenticate **both** legs, before any work happens:
– On `POST /mcp/messages`, validate a bearer token in the `Authorization` header against your auth service before dispatching to the transport.
– On `GET /mcp/sse`, do the same *before* opening the stream — the cheapest request you will ever deny is one you never let connect.
The subtlety that bites people: **the browser-native `EventSource` API cannot set custom headers.** You literally cannot send an `Authorization: Bearer …` header from it. Your options:
1. **Use a fetch-based SSE client** (e.g., `@microsoft/fetch-event-source`) that can send headers on the GET.
2. **Accept a short-lived, signed token in the query string** for the GET leg only, since you cannot avoid it with `EventSource`. Sign it with an HMAC, scope it to one session, and expire it in minutes. Never put a long-lived credential in a URL — it ends up in proxy logs, browser history, and referrer headers.
3. **Cookies + CSRF protection**, if the consumers are all browser contexts and you control the origin policy. More moving parts; usually not worth it for backend-to-backend MCP.
When MCP clients are server-side agents (the common case), header-based bearer tokens on both routes plus a fetch-based SSE client is the clean answer.
### CORS
MCP is not a browser API today, but the day an agent runs in a browser extension, CORS becomes your first line of defense. Do not use `*`. Enumerate origins, and remember CORS is enforced by the *client’s* browser — it stops malicious web pages from POSTing to your server, it does nothing against a compromised backend that has your token. Restrict origins, then rely on auth.
“`js
const allowed = new Set((process.env.CORS_ORIGIN ?? “”).split(“,”));
app.use(cors({
origin: (origin, cb) =>
cb(null, !origin || allowed.has(origin)), // block no-origin & unknown
methods: [“GET”, “POST”],
allowedHeaders: [“Content-Type”, “Authorization”],
maxAge: 600
}));
“`
### The transport layer
– **TLS everywhere**, terminated at your edge. Add HSTS via helmet and force HTTPS redirects.
– **Never** put an SSE or messages endpoint in front of a naive cache — the `Cache-Control: no-cache` on the stream response is not a suggestion.
– **Rate-limit per session and per token** at the POST endpoint; a runaway agent loop should trip your limits, not your database.
– **Validate and bound** JSON-RPC bodies (`express.json({ limit: “1mb” })`), and redact request/response payloads in logs — tool arguments routinely contain PII.
– **Authenticate outbound too.** If your tools call third-party APIs, those credentials belong in a secret manager, injected per-session, and never echoed in tool results.
—
## 6. Full Code Implementation
Here is the complete, runnable server combining everything above. Save it as `server.js`:
“`js
import express from “express”;
import cors from “cors”;
import helmet from “helmet”;
import crypto from “node:crypto”;
import { McpServer } from “@modelcontextprotocol/sdk/server/mcp.js”;
import { SSEServerTransport } from “@modelcontextprotocol/sdk/server/sse.js”;
import { z } from “zod”;
const PORT = Number(process.env.PORT ?? 3001);
const SESSION_TTL_MS = 10 * 60 * 1000;
const HEARTBEAT_MS = 15_000;
const SWEEP_MS = 60_000;
const app = express();
app.use(helmet());
app.use(cors({
origin: (origin, cb) => {
const allowed = (process.env.CORS_ORIGIN ?? “”).split(“,”).filter(Boolean);
cb(null, !origin || allowed.includes(origin));
}
}));
// —- Auth: shared by both legs (see Section 5) —-
function authenticate(req, res, next) {
const expected = process.env.MCP_API_TOKEN;
const token = req.headers.authorization?.replace(/^Bearer\s+/i, “”);
if (!expected || token === expected) return next();
res.status(401).json({ error: “Unauthorized” });
}
// —- MCP server (tools, resources, prompts) —-
const mcp = new McpServer(
{ name: “orders-mcp”, version: “1.0.0” },
{ capabilities: { tools: {}, logging: {} } }
);
mcp.registerTool(“lookup_order”, { orderId: z.string() }, async ({ orderId }) => {
// Real implementation: query your database / CRM / ERP here.
return { content: [{ type: “text”, text: `Fetched order ${orderId}` }] };
});
// —- Session registry with heartbeats & TTL sweeper —-
const sessions = new Map(); // sessionId -> { transport, res, lastSeen, heartbeat }
function startHeartbeat(res) {
const timer = setInterval(() => {
if (!res.writableEnded) res.write(“: hb\n\n”);
else clearInterval(timer);
}, HEARTBEAT_MS);
timer.unref?.();
return timer;
}
const sweeper = setInterval(() => {
const now = Date.now();
for (const [id, s] of sessions) {
if (now – s.lastSeen > SESSION_TTL_MS) {
s.res.end();
sessions.delete(id);
clearInterval(s.heartbeat);
console.warn(`[sweeper] reaped idle session ${id}`);
}
}
}, SWEEP_MS);
sweeper.unref?.();
// —- Leg 1: the SSE event stream (server -> client) —-
app.get(“/mcp/sse”, authenticate, async (req, res) => {
res.setHeader(“Cache-Control”, “no-cache”);
res.setHeader(“X-Accel-Buffering”, “no”);
const transport = new SSEServerTransport(“/mcp/messages”, res);
const sessionId = transport.sessionId;
sessions.set(sessionId, {
transport,
res,
lastSeen: Date.now(),
heartbeat: startHeartbeat(res),
});
res.on(“close”, () => {
const s = sessions.get(sessionId);
if (s) {
clearInterval(s.heartbeat);
sessions.delete(sessionId);
}
console.log(`[sse] client disconnected: ${sessionId}`);
});
await mcp.connect(transport);
console.log(`[sse] session established: ${sessionId}`);
});
// —- Leg 2: client -> server JSON-RPC over plain POST —-
app.post(“/mcp/messages”, authenticate, express.json({ limit: “1mb” }), async (req, res) => {
const s = sessions.get(String(req.query.sessionId ?? “”));
if (!s) return res.status(404).json({ error: “Unknown or expired session” });
s.lastSeen = Date.now();
await s.transport.handlePostMessage(req, res);
});
// —- Graceful shutdown —-
for (const sig of [“SIGINT”, “SIGTERM”]) {
process.on(sig, () => {
console.log(`[shutdown] ${sig} received, closing ${sessions.size} session(s)`);
for (const s of sessions.values()) s.res.end();
clearInterval(sweeper);
process.exit(0);
});
}
app.listen(PORT, () => console.log(`MCP SSE server on :${PORT}`));
“`
A minimal client to verify the flow — note the fetch-based SSE client so we can send the auth header:
“`js
import { fetchEventSource } from “@microsoft/fetch-event-source”;
await fetchEventSource(“http://localhost:3001/mcp/sse”, {
headers: { Authorization: “Bearer ” + process.env.MCP_API_TOKEN },
async onopen(res) { /* 200 -> start MCP initialization */ },
onmessage(ev) { console.log(ev.data); }, // JSON-RPC frames
onerror(err) { /* backoff & reconnect -> NEW session */ }
});
“`
**Run it:** `npm install && MCP_API_TOKEN=dev-token PORT=3001 node server.js`. Wire in any MCP client (Claude Desktop with a URL, the SDK’s client, or a custom agent) pointed at `http://localhost:3001/mcp/sse`.
**Before production**, add the things this skeleton intentionally leaves to you: real auth integration, structured logs with request IDs, per-token rate limits, an observability endpoint exposing `sessions.size`, and sticky sessions or a shared session registry (Redis pub/sub or similar) if you scale past one node.
—
## Conclusion
SSE is not the newest transport in the MCP spec — Streamable HTTP is — but it remains the most instructive and, for many deployments, the most operationally boring (in the best way): two HTTP routes, one held-open response per client, and every standard piece of HTTP infrastructure working in your favor. Master the session lifecycle — handshake, heartbeat, TTL sweep, graceful shutdown — and the security posture around both legs, and you have a template that will carry you through Streamable HTTP and whatever comes after.
Putting MCP servers on the network is exactly the kind of architecture work that looks simple on a whiteboard and gets subtle in production: real database schemas, real auth boundaries, real session semantics. That is what we do all day at OnlineCloud — MCP-native architecture, audited end-to-end, shipped by senior US-based engineers in weeks, not quarters. If you are moving your tool surface from stdio to the network, or designing a multi-agent system that needs Streamable HTTP, SSE, and the operational discipline around them, **talk to the OnlineCloud architects** — we would rather discuss your transport strategy than sell you a deck.
Leave a Reply