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:

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:

  1. POST with unknown session ID — return 404 with a JSON body the client can inspect. Do not return 202 silently, or the client will wait forever for a response that never comes.
  2. 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

ConcernSSE transport behaviorMitigation
Load balancersSession affinity required: GET /sse and POST /messages for the same session must reach the same server instanceSticky 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 modelsUse Streamable HTTP in stateless mode instead
Horizontal scalingSessions are in-memory — a restart or scale-in loses all active sessionsStore session state in Redis; move to Streamable HTTP
Proxy timeoutsIdle SSE connections get closed by proxiesKeep-alive comments (see above); configure proxy timeout to match
TLS terminationWorks normally — HTTPS at the load balancer, plain HTTP to the appStandard 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:

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:

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