Guide · Transport
MCP server SSE transport
The SSE transport was the original HTTP-based MCP transport before the Streamable HTTP spec revision. It uses two endpoints: a long-lived GET /sse connection for server-to-client messages and a POST /messages endpoint for client-to-server requests. Many production MCP servers still use it, and many clients still expect it. This guide covers the full setup: Express integration, session management, CORS, connection lifecycle, and when it still makes sense versus switching to Streamable HTTP.
TL;DR
Create one SSEServerTransport instance per client connection, not one shared instance. Store active transports in a Map keyed by session ID. The GET /sse handler creates the transport and calls server.connect(transport); the POST /messages handler looks up the transport by session ID and calls transport.handlePostMessage(req, res). Set Access-Control-Allow-Origin headers if browser clients will connect. Prefer Streamable HTTP for new deployments — it is the current spec standard and handles load balancers more cleanly.
SSE transport architecture
Server-Sent Events (SSE) is a browser standard for one-directional streaming from server to client over HTTP. MCP's SSE transport repurposes it as a bidirectional channel by pairing the SSE stream with a separate POST endpoint:
- GET /sse — The client opens a long-lived HTTP connection. The server keeps it open and pushes JSON-RPC responses and notifications as SSE events (
data: {...}\n\n). The first event sent is anendpointevent containing the URL for the POST endpoint including the session ID. - POST /messages?sessionId=… — The client sends JSON-RPC requests as the HTTP body. The server routes the message to the matching transport instance using the session ID, processes the request, and sends the response back via the SSE stream (not the POST response body).
This bidirectional split is the key thing to internalize: the POST response is always 202 Accepted with no body. The actual result comes back as an SSE event. If you expect the POST to return the result directly, you have the wrong mental model.
Express + SSEServerTransport setup
The critical architectural point: one SSEServerTransport instance per active client connection. Each SSE connection gets its own transport, its own server instance (or a shared server that handles multiple transports), and its own session ID.
import express from 'express';
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js';
const app = express();
app.use(express.json());
// Active transports keyed by session ID
const sessions = new Map<string, SSEServerTransport>();
function createServer(): McpServer {
const server = new McpServer({ name: 'my-server', version: '1.0.0' });
// Register tools here...
return server;
}
// Client opens SSE connection
app.get('/sse', async (req, res) => {
const transport = new SSEServerTransport('/messages', res);
const sessionId = transport.sessionId; // SDK assigns a UUID
sessions.set(sessionId, transport);
transport.onclose = () => {
sessions.delete(sessionId);
};
const server = createServer();
await server.connect(transport);
// connect() resolves when the SSE connection closes
});
// Client sends requests
app.post('/messages', async (req, res) => {
const sessionId = req.query.sessionId as string;
const transport = sessions.get(sessionId);
if (!transport) {
res.status(404).json({ error: 'Session not found' });
return;
}
await transport.handlePostMessage(req, res);
});
app.listen(3000, () => {
process.stderr.write('MCP server listening on :3000\n');
});
The SSEServerTransport constructor takes two arguments: the path for the POST endpoint (used to construct the endpoint event URL) and the Express response object for the SSE stream. The transport sets the necessary response headers (Content-Type: text/event-stream, Cache-Control: no-cache, Connection: keep-alive) automatically.
Session management
The SSEServerTransport generates a UUID session ID internally. When the SSE connection opens, the transport sends an endpoint event:
event: endpoint
data: /messages?sessionId=a3f7c1e2-9b4d-4c82-8e5a-1f2d3e4f5a6b
The client reads this event and uses the provided URL for all subsequent POST requests. This is the pairing mechanism — the POST path includes the session ID so the server can route the request to the right transport instance.
Session cleanup on disconnect: always register an onclose handler to remove the session from the map. If you don't, the map grows unboundedly as clients connect and disconnect.
transport.onclose = () => {
sessions.delete(sessionId);
console.error(`Session ${sessionId} closed. Active sessions: ${sessions.size}`);
};
Consider adding a session expiry mechanism if clients can drop without closing cleanly (network drops, browser tab closes without graceful shutdown). A setInterval that checks transport.lastActivityAt and calls transport.close() on idle sessions handles this.
CORS configuration
If browser-based MCP clients will connect — or if your server and client run on different origins during development — you need CORS headers on both the SSE and POST endpoints. The SSE connection uses EventSource, which does not send preflight OPTIONS requests but does require the correct Access-Control-Allow-Origin header.
import cors from 'cors';
const corsOptions = {
origin: process.env.ALLOWED_ORIGINS?.split(',') ?? '*',
methods: ['GET', 'POST', 'OPTIONS'],
allowedHeaders: ['Content-Type', 'Authorization'],
};
// Apply CORS before the SSE and POST handlers
app.use('/sse', cors(corsOptions));
app.use('/messages', cors(corsOptions));
// Handle preflight for the POST endpoint
app.options('/messages', cors(corsOptions));
In production, replace '*' with the specific origin of your client. Wildcard origins prevent credentials (cookies, Authorization headers) from being included in requests, which breaks OAuth-authenticated flows. See MCP server authentication for the full CORS + auth pattern.
Connection lifecycle and health
An SSE connection is a persistent HTTP connection. Proxies, load balancers, and firewalls may close idle connections with no data after their own timeout (typically 60–120 seconds). To keep the connection alive, send a keep-alive comment every 15–30 seconds:
// After creating the transport but before connecting
const keepAlive = setInterval(() => {
res.write(': keep-alive\n\n'); // SSE comment — no event, no data
}, 15_000);
transport.onclose = () => {
clearInterval(keepAlive);
sessions.delete(sessionId);
};
SSE comment lines (starting with :) are ignored by the EventSource API but reset the proxy's idle timeout. Tune the interval to be shorter than whatever timeout your reverse proxy enforces.
Monitor connection counts and session map size via metrics. A growing session map that never shrinks indicates a cleanup bug. An active_sessions gauge exposed to Prometheus makes this visible without log-scraping.
Error handling
Two failure modes to handle explicitly:
- POST with unknown session ID — return
404with a JSON body the client can inspect. Do not return202silently, or the client will wait forever for a response that never comes. - SSE connection error — wrap
server.connect(transport)in try/catch. If connect throws (malformed handshake, tool handler crash that propagates to the transport layer), log the error and ensure the session is removed from the map.
app.get('/sse', async (req, res) => {
const transport = new SSEServerTransport('/messages', res);
const sessionId = transport.sessionId;
sessions.set(sessionId, transport);
transport.onclose = () => sessions.delete(sessionId);
try {
const server = createServer();
await server.connect(transport);
} catch (err) {
console.error(`Session ${sessionId} error:`, err);
sessions.delete(sessionId);
}
});
Deployment considerations
| Concern | SSE transport behavior | Mitigation |
|---|---|---|
| Load balancers | Session affinity required: GET /sse and POST /messages for the same session must reach the same server instance | Sticky sessions (cookie or IP hash) — or use Streamable HTTP with shared session store |
| Serverless (Lambda, Cloudflare Workers) | Long-lived SSE connections are incompatible with request-lifetime execution models | Use Streamable HTTP in stateless mode instead |
| Horizontal scaling | Sessions are in-memory — a restart or scale-in loses all active sessions | Store session state in Redis; move to Streamable HTTP |
| Proxy timeouts | Idle SSE connections get closed by proxies | Keep-alive comments (see above); configure proxy timeout to match |
| TLS termination | Works normally — HTTPS at the load balancer, plain HTTP to the app | Standard TLS setup |
When to still use SSE transport
The SSE transport is the older of the two HTTP transport options — the MCP spec added Streamable HTTP in March 2025 as the preferred model going forward. But SSE is still the right choice in specific situations:
- Client compatibility — older MCP client libraries and some IDE plugins support SSE but not Streamable HTTP yet. If your clients have pinned SDK versions, SSE may be the only option until they upgrade.
- Browser clients — the EventSource API for SSE is natively available in every browser without polyfills. If your client is a browser extension or web app, SSE works out of the box.
- Existing infrastructure — if your deployment already has SSE working and the team is familiar with it, the migration cost to Streamable HTTP may not be justified unless you hit a specific limitation.
Support both transports simultaneously during a migration period by mounting both sets of endpoints in the same Express app and routing clients based on their capabilities.
AliveMCP monitoring for SSE servers
AliveMCP monitors SSE transport servers by probing the full connection lifecycle: opening a GET /sse connection, reading the endpoint event, sending an initialize JSON-RPC request via POST, and validating the response. This end-to-end probe catches more failure modes than a simple HTTP health check:
- Proxy routing errors (GET /sse reaches the server; POST /messages does not)
- Session map corruption (server returns 404 on a freshly opened session)
- SSE event framing errors (malformed
data:lines) - Initialize handshake failures (server rejects the client's protocol version)
Add your SSE server to AliveMCP to get 60-second monitoring with history, response-time percentiles, and downtime alerts — without running your own probe infrastructure.
Related questions
Can I share one McpServer instance across all SSE sessions?
Yes, if your server is stateless (no per-session mutable state). Create one McpServer at startup, call server.connect(transport) for each new SSE connection. If your server has per-session state (user identity, session-specific cache), use the factory pattern: const server = createServer() per connection. Per-session server instances are more isolated but use more memory.
How do I know which session a POST request belongs to?
The session ID is in the POST URL as a query parameter: /messages?sessionId=.... The SSEServerTransport includes this ID in the endpoint event it sends when the SSE connection opens, so the client always uses the correct URL. You read it server-side from req.query.sessionId.
Does SSEServerTransport work with Fastify, Hono, or other frameworks?
It works with any framework that exposes the raw Node.js http.ServerResponse object as the response argument. The SSEServerTransport needs the raw response to call res.write() directly. In Fastify, use reply.raw; in Hono (Node.js adapter), access the raw response via the context object. Express is the most straightforward because res is already the raw response.
What happens when the client reconnects after a dropped connection?
EventSource clients automatically reconnect after a dropped connection (this is part of the SSE spec). When they reconnect, they get a new GET /sse connection, which creates a new transport instance and new session ID. Any in-flight requests from the previous session are lost — the client will need to re-send them. For request idempotency, see MCP server retry logic.
Further reading
- MCP server stdio transport — local process communication guide
- MCP server Streamable HTTP transport — modern remote deployment
- MCP server transport comparison — stdio vs SSE vs Streamable HTTP
- MCP server JSON-RPC 2.0 — protocol messages and lifecycle
- MCP server authentication — OAuth 2.1 and API key patterns
- MCP server load balancing — sticky sessions and horizontal scaling
- MCP server metrics — session counts and connection health
- MCP server SSL certificate — HTTPS setup and renewal
- AliveMCP — uptime monitoring for SSE and HTTP-deployed MCP servers