Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/streamable-http-sse-keepalive.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@modelcontextprotocol/server': minor
---

SSE streams served by the SDK now emit keep-alive comment frames (`: keepalive`) so idle connections are not killed by client body-idle timeouts or intermediaries. `WebStandardStreamableHTTPServerTransport` and `PerRequestHTTPServerTransport` gain a `keepAliveMs` option (default 15000; set 0 to disable), and `createMcpHandler`'s existing `keepAliveMs` now covers modern per-request exchange streams and the legacy stateless fallback in addition to `subscriptions/listen` streams. Streaming responses disable proxy transformations and nginx-style buffering, and deferred work cannot register streams after transport close.
8 changes: 8 additions & 0 deletions docs/troubleshooting.md
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,14 @@ Rewrite the imports:

The Resource Server helpers did not move there: `requireBearerAuth`, `mcpAuthMetadataRouter` and `OAuthTokenVerifier` are first-class in `@modelcontextprotocol/express` — see [Authorization](./serving/authorization.md). `@modelcontextprotocol/server-legacy` is frozen and receives no new features; serve new code over [Streamable HTTP](./serving/http.md), which still reaches 2025-era clients through [legacy client support](./serving/legacy-clients.md). A client limited to the HTTP+SSE transport is the one case that still needs the frozen `@modelcontextprotocol/server-legacy/sse` import above.

## `SSE stream disconnected: TypeError: terminated`

An idle SSE response went too long without delivering body bytes. A client-side body-idle timeout (for example undici's `bodyTimeout`) or an intermediary such as a reverse proxy or cloud load balancer may then terminate the stream. The client observes this as `TypeError: terminated` and reconnects in a loop.

The SDK's HTTP serving prevents this by writing an SSE comment frame (`: keepalive`) to every open SSE stream every 15 seconds by default — `WebStandardStreamableHTTPServerTransport` on all of its streams, and `createMcpHandler` on `subscriptions/listen` streams, modern per-request exchange streams, and the legacy fallback's per-request transport. Comment frames are dropped by SSE parsers before event dispatch, so they never surface as protocol messages. Tune or disable the interval with the `keepAliveMs` option on the transport or handler (`0` disables).
Comment thread
mattzcarey marked this conversation as resolved.

If you still see this error, keep-alive may be disabled (`keepAliveMs: 0`), the client timeout may be shorter than the configured interval, or an intermediary may buffer or strip SSE data. Check for proxies that buffer streaming responses (for example nginx without `proxy_buffering off`).

## Recap

- Every heading on this page is the exact message you searched for.
Expand Down
48 changes: 35 additions & 13 deletions packages/server/src/server/createMcpHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -145,8 +145,9 @@ export interface CreateMcpHandlerOptions {
* - `'stateless'` (the default, also when the option is omitted) —
* old-school stateless serving: each legacy request is answered by a
* fresh instance from the same factory over a streamable HTTP transport
* constructed with only `sessionIdGenerator: undefined` (the established
* stateless idiom). Because serving is per-request and stateless, GET and
* constructed with `sessionIdGenerator: undefined` (the established
* stateless idiom), plus the handler's `keepAliveMs` when provided.
* Because serving is per-request and stateless, GET and
* DELETE (2025 session operations) are answered with `405` /
* `Method not allowed.`.
* - `'reject'` — modern-only strict: legacy-classified requests are
Expand Down Expand Up @@ -194,8 +195,12 @@ export interface CreateMcpHandlerOptions {
*/
maxSubscriptions?: number;
/**
* SSE comment-frame keepalive interval for `subscriptions/listen` streams,
* in milliseconds. Set to `0` to disable.
* SSE comment-frame keepalive interval, in milliseconds, applied to every
* SSE stream this handler serves: `subscriptions/listen` streams, modern
* per-request exchange streams, and the legacy stateless fallback's
* per-request transport. In modern `auto` mode, the timer starts only after
* the exchange upgrades to SSE; use `responseMode: 'sse'` when a silent
* long-running handler needs heartbeat bytes. Set to `0` to disable.
* @default 15000
*/
keepAliveMs?: number;
Expand Down Expand Up @@ -295,18 +300,26 @@ function internalServerErrorResponse(id: RequestId | null = null): Response {
* strict modern endpoint).
*
* Each POST is served by a fresh instance from the factory connected to a
* fresh streamable HTTP transport constructed with only
* `sessionIdGenerator: undefined` the established stateless idiom, unchanged.
* Because serving is per-request and stateless, GET and DELETE (2025 session
* operations) are answered with `405` / `Method not allowed.`, exactly like the
* canonical stateless example.
* fresh streamable HTTP transport constructed with
* `sessionIdGenerator: undefined` (the established stateless idiom) plus any
* `transportOptions`. Because serving is per-request and stateless, GET and
* DELETE (2025 session operations) are answered with `405` /
* `Method not allowed.`, exactly like the canonical stateless example.
*
* The optional `onerror` callback receives factory and serving failures on
* this leg (reporting only — the response stays the 500 internal-error body).
* The entry passes its own `onerror` here when expanding the default, so
* legacy-leg failures are never silently swallowed.
*
* The optional `transportOptions` are threaded into each per-request
* transport; currently just `keepAliveMs`, the SSE keep-alive comment-frame
* interval (the entry forwards its own `keepAliveMs` option here).
*/
export function legacyStatelessFallback(factory: McpServerFactory, onerror?: (error: Error) => void): LegacyHttpHandler {
export function legacyStatelessFallback(
factory: McpServerFactory,
onerror?: (error: Error) => void,
transportOptions?: { keepAliveMs?: number }
): LegacyHttpHandler {
return async (request, options) => {
if (request.method.toUpperCase() !== 'POST') {
return jsonRpcErrorResponse(405, -32_000, 'Method not allowed.');
Comment thread
claude[bot] marked this conversation as resolved.
Expand All @@ -317,7 +330,10 @@ export function legacyStatelessFallback(factory: McpServerFactory, onerror?: (er
...(options?.authInfo !== undefined && { authInfo: options.authInfo }),
requestInfo: request
});
const transport = new WebStandardStreamableHTTPServerTransport({ sessionIdGenerator: undefined });
const transport = new WebStandardStreamableHTTPServerTransport({
sessionIdGenerator: undefined,
...(transportOptions?.keepAliveMs !== undefined && { keepAliveMs: transportOptions.keepAliveMs })
});
await product.connect(transport);

const teardown = () => {
Expand Down Expand Up @@ -632,7 +648,12 @@ export function createMcpHandler(factory: McpServerFactory, options: CreateMcpHa

// The default posture is the stateless fallback; 'reject' is the only way
// to turn legacy serving off (modern-only strict).
const legacyHandler: LegacyHttpHandler | undefined = legacy === 'reject' ? undefined : legacyStatelessFallback(factory, reportError);
const legacyHandler: LegacyHttpHandler | undefined =
legacy === 'reject'
? undefined
: legacyStatelessFallback(factory, reportError, {
...(options.keepAliveMs !== undefined && { keepAliveMs: options.keepAliveMs })
});
Comment thread
mattzcarey marked this conversation as resolved.

async function serveModern(route: InboundModernRoute, request: Request, authInfo: AuthInfo | undefined): Promise<Response> {
const claimedRevision = route.classification.revision;
Expand Down Expand Up @@ -778,7 +799,8 @@ export function createMcpHandler(factory: McpServerFactory, options: CreateMcpHa
classification: route.classification,
request,
...(authInfo !== undefined && { authInfo }),
...(responseMode !== undefined && { responseMode })
...(responseMode !== undefined && { responseMode }),
...(options.keepAliveMs !== undefined && { keepAliveMs: options.keepAliveMs })
});
if (route.messageKind === 'notification') {
// Notification exchanges have no terminal response to ride the
Expand Down
9 changes: 8 additions & 1 deletion packages/server/src/server/invoke.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,12 @@ export interface InvokeContext {
authInfo?: AuthInfo;
/** Response shaping for the exchange; defaults to `auto` (lazy SSE upgrade). */
responseMode?: PerRequestResponseMode;
/**
* SSE keep-alive comment-frame interval for the exchange's stream, in
* milliseconds; passed through to the per-request transport. `0` disables.
* @default 15000
*/
keepAliveMs?: number;
}

/**
Expand All @@ -58,7 +64,8 @@ export async function invoke(
): Promise<Response> {
const transport = new PerRequestHTTPServerTransport({
classification: ctx.classification,
...(ctx.responseMode !== undefined && { responseMode: ctx.responseMode })
...(ctx.responseMode !== undefined && { responseMode: ctx.responseMode }),
...(ctx.keepAliveMs !== undefined && { keepAliveMs: ctx.keepAliveMs })
});
await server.connect(transport);
return transport.handleMessage(message, {
Expand Down
14 changes: 4 additions & 10 deletions packages/server/src/server/listenRouter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,9 +34,10 @@

import type { ServerEventBus } from './serverEventBus';
import { honoredSubset, listenFilterAccepts, serverEventToNotification } from './serverEventBus';
import { armSseKeepAlive, DEFAULT_SSE_KEEP_ALIVE_MS } from './sseKeepAlive';

/** Default SSE comment-frame keepalive interval for listen streams. */
export const DEFAULT_LISTEN_KEEPALIVE_MS = 15_000;
export const DEFAULT_LISTEN_KEEPALIVE_MS = DEFAULT_SSE_KEEP_ALIVE_MS;

/** Default capacity guard: refuse a new subscription when this many are already open. */
export const DEFAULT_MAX_SUBSCRIPTIONS = 1024;
Expand Down Expand Up @@ -215,20 +216,13 @@
unsubscribe = bus.subscribe(event => {
if (closed || !listenFilterAccepts(honored, event)) return;
const note = stampSubscriptionId(serverEventToNotification(event), subscriptionId);
writeNotification(note.method, note.params);
});

if (keepAliveMs > 0) {
keepAliveTimer = setInterval(() => writeFrame(': keepalive\n\n'), keepAliveMs);
// Do not hold the event loop open on idle subscriptions. Node's
// setInterval returns a Timeout with .unref(); browsers/Workers
// return a number — the cast is an environment shim, not a
// workaround for SDK typing.
(keepAliveTimer as { unref?: () => void }).unref?.();
}
keepAliveTimer = armSseKeepAlive(keepAliveMs, () => writeFrame(': keepalive\n\n'));

open.add(teardown);
},

Check warning on line 225 in packages/server/src/server/listenRouter.ts

View check run for this annotation

Claude / Claude Code Review

listenRouter teardown: throwing user bus unsubscribe() permanently leaks the keep-alive timer and a subscription slot

In `listenRouter`'s teardown, `closed = true` is flipped and then the user-supplied bus's `unsubscribe?.()` is called unguarded before `clearInterval(keepAliveTimer)`, `abortCleanup?.()`, `open.delete(teardown)`, and `controller.close()` — so a throwing `unsubscribe` from a custom `ServerEventBus` (e.g. a disconnected pub/sub backend) permanently leaks the keep-alive timer this PR arms via `armSseKeepAlive` and a `maxSubscriptions` slot, and one such throw propagates out of `closeAll()`, abortin
Comment on lines 219 to 225

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 In listenRouter's teardown, closed = true is flipped and then the user-supplied bus's unsubscribe?.() is called unguarded before clearInterval(keepAliveTimer), abortCleanup?.(), open.delete(teardown), and controller.close() — so a throwing unsubscribe from a custom ServerEventBus (e.g. a disconnected pub/sub backend) permanently leaks the keep-alive timer this PR arms via armSseKeepAlive and a maxSubscriptions slot, and one such throw propagates out of closeAll(), aborting graceful close of every other subscription. The teardown ordering itself is pre-existing (this PR only rewrote the adjacent timer-arm line), but a one-line try/finally around unsubscribe?.() (or reordering it last) closes the gap; the same pattern applies to the pre-existing handleDeleteRequest site in streamableHttp.ts, where a throwing onsessionclosed skips close() entirely.

Extended reasoning...

What the bug is. teardown in createListenRouter (listenRouter.ts:172-201) runs, in order: closed = trueunsubscribe?.()clearInterval(keepAliveTimer)abortCleanup?.()open.delete(teardown)controller.close(). unsubscribe is whatever bus.subscribe(...) returned, and bus is the user-supplied CreateMcpHandlerOptions.bus extension point, explicitly documented for multi-process deployments to implement over their own pub/sub backend. If that user code throws (a disconnected Redis client is the canonical case), the throw skips all four remaining steps. Because closed was flipped before the fallible call, the re-entry guard (if (closed) return, line 173) makes teardown permanently uncompletable: no retry — not a later stream cancel, not the abort listener, not closeAll() — can ever run the skipped steps.

Concrete consequences. (a) The keep-alive interval this PR arms at line 222 via armSseKeepAlive fires forever — writeFrame no-ops since closed is true, but the timer is never cleared, directly contradicting the changeset's claim that timers are "stopped on cancel/finalization/close". (b) The open-set entry leaks, permanently consuming one maxSubscriptions slot (default 1024); enough throwing teardowns and every new subscriptions/listen is refused with -32603 'Subscription limit reached'. (c) The abort listener and stream controller leak. (d) Worst of the compounding effects: closeAll() iterates for (const teardown of open) teardown(true) with no try/catch, and createMcpHandler's close() calls it synchronously before closing in-flight modern servers — so ONE throwing unsubscribe aborts graceful close of every other subscription and rejects handler.close(), skipping the in-flight cleanup too.

Step-by-step proof. (1) Deploy createMcpHandler(factory, { bus: myRedisBus }) where myRedisBus.subscribe() returns an unsubscribe that calls into the Redis client. (2) Two clients open subscriptions/listen streams A and B; each arms a 15 s keep-alive timer and adds its teardown to open. (3) The Redis connection drops; the client library now throws from unsubscribe. (4) Client A disconnects → the stream's cancel() calls teardown(false)closed = trueunsubscribe() throws → clearInterval/open.delete/controller.close() never run, and the exception propagates into the ReadableStream cancel path. (5) A's timer keeps firing every 15 s forever; A's slot in open is never reclaimed. (6) The operator calls handler.close()closeAll() iterates open, hits A's teardown first, re-enters, if (closed) return — fine — but when it reaches any other still-open entry whose unsubscribe also throws, the loop aborts and B never gets its graceful-close result frame; the throw escapes handler.close() before inflight servers are closed.

Why existing code doesn't prevent it. Nothing on any call path catches the throw: the stream cancel() hook, the abort-signal listener, and closeAll() all invoke teardown bare. armSseKeepAlive unref's the timer, so the process can still exit, but within a running server the interval churns indefinitely and the capacity leak is unbounded. This is exactly the repo's Async/Lifecycle recurring catch (#1735/#1763): user-supplied or chained callbacks in close/teardown paths must be wrapped in try/finally so a throw cannot skip the remaining teardown — an explicitly requested check for this codebase, and the PR hardened this precise lifecycle class at every sibling SSE site (per-request transport, session transport) while this one call survives unguarded.

How to fix. One line: wrap the call — try { unsubscribe?.(); } catch (e) { onerror?.(e instanceof Error ? e : new Error(String(e))); } — or reorder so unsubscribe?.() runs last, after clearInterval, abortCleanup, open.delete, and controller.close(). Optionally also wrap the teardown(true) call inside closeAll() so one bad subscription can never abort the others.

Severity rationale. The teardown ordering is pre-existing — git diff shows this PR's listenRouter changes cover only the timer-arm line (now armSseKeepAlive) and the response headers, and the identical leak existed pre-PR with the raw setInterval. The default InMemoryServerEventBus never throws, so no stock configuration is affected. Filed as a nit rather than purely pre-existing because this PR arms the leaked timer inside this very closure and its changeset asserts a timer-lifecycle guarantee ("stopped on cancel/finalization/close") that this path falsifies — and the fix is a one-liner squarely within the PR's stated hardening scope.

Related pre-existing site (mention-only, not filed separately). streamableHttp.ts handleDeleteRequest has the same shape: await Promise.resolve(this._onsessionclosed?.(this.sessionId!)); await this.close(); — a throwing/rejecting user onsessionclosed skips close() entirely, leaving the session, its SSE streams, and (post-PR) all keep-alive timers alive while the client believes DELETE terminated the session. Same fix shape: try/finally so close() always runs. That code is untouched by this PR.

cancel() {
// The client closed the SSE stream — the spec's HTTP cancel
// signal. Not a server-side graceful close, so no listen
Expand All @@ -251,7 +245,7 @@
status: 200,
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Cache-Control': 'no-cache, no-transform',
Connection: 'keep-alive',
'X-Accel-Buffering': 'no'
}
Expand Down
40 changes: 39 additions & 1 deletion packages/server/src/server/perRequestTransport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,8 @@ import {
SdkErrorCode
} from '@modelcontextprotocol/core-internal';

import { armSseKeepAlive, DEFAULT_SSE_KEEP_ALIVE_MS } from './sseKeepAlive';

/**
* How the transport shapes its HTTP response for a request:
*
Expand All @@ -79,6 +81,15 @@ export interface PerRequestHTTPServerTransportOptions {
classification: MessageClassification;
/** Response shaping for the exchange; defaults to `auto`. */
responseMode?: PerRequestResponseMode;
/**
* Interval in milliseconds between SSE keep-alive comment frames
* (`: keepalive`) written while the exchange's SSE stream is open. With
* `responseMode: 'sse'`, this also protects long-running handlers that emit
* no mid-call output. Set to `0` to disable; values below `1`, above
* `2147483647`, or non-finite values also disable the timer.
* @default 15000
*/
keepAliveMs?: number;
}

/** Per-exchange context handed to {@linkcode PerRequestHTTPServerTransport.handleMessage}. */
Expand Down Expand Up @@ -140,10 +151,13 @@ export class PerRequestHTTPServerTransport implements Transport {
private _deferredResponse?: DeferredResponse;
private _sse?: SseSink;
private _abortCleanup?: () => void;
private readonly _keepAliveMs: number;
private _keepAliveTimer?: ReturnType<typeof setInterval>;

constructor(options: PerRequestHTTPServerTransportOptions) {
this._classification = options.classification;
this._responseMode = options.responseMode ?? 'auto';
this._keepAliveMs = options.keepAliveMs ?? DEFAULT_SSE_KEEP_ALIVE_MS;
}

async start(): Promise<void> {
Expand Down Expand Up @@ -342,6 +356,7 @@ export class PerRequestHTTPServerTransport implements Transport {

this._abortCleanup?.();
this._abortCleanup = undefined;
this.stopKeepAlive();

if (this._sse !== undefined && !this._sse.closed) {
this._sse.closed = true;
Expand Down Expand Up @@ -382,13 +397,14 @@ export class PerRequestHTTPServerTransport implements Transport {
}
});
this._sse = { controller, encoder: new TextEncoder(), closed: false };
this.startKeepAlive();

this.settleResponse(
new Response(readable, {
status: 200,
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Cache-Control': 'no-cache, no-transform',
Connection: 'keep-alive',
// Disable proxy buffering so streamed messages are
// delivered as they are written.
Expand All @@ -398,7 +414,29 @@ export class PerRequestHTTPServerTransport implements Transport {
);
}

/**
* Arms the exchange's keep-alive interval, writing an SSE comment frame
* every `keepAliveMs` while the stream is open. `writeCommentFrame`
* already drops frames once the exchange is closed or the stream is
* finalized, so the interval body needs no extra guards; the timer itself
* is cleared on stream finalization and transport close.
*/
private startKeepAlive(): void {
if (this._closed) {
return;
}
this._keepAliveTimer = armSseKeepAlive(this._keepAliveMs, () => this.writeCommentFrame('keepalive'));
}

private stopKeepAlive(): void {
if (this._keepAliveTimer !== undefined) {
clearInterval(this._keepAliveTimer);
this._keepAliveTimer = undefined;
}
}

private finalizeStream(): void {
this.stopKeepAlive();
if (this._sse !== undefined && !this._sse.closed) {
this._sse.closed = true;
try {
Expand Down
25 changes: 25 additions & 0 deletions packages/server/src/server/sseKeepAlive.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
/** Default interval between SSE keep-alive comment frames. */
export const DEFAULT_SSE_KEEP_ALIVE_MS = 15_000;

/** Largest delay accepted by JavaScript timers without overflow clamping. */
const MAX_TIMER_DELAY_MS = 2_147_483_647;

/**
* Arms an SSE keep-alive timer when `intervalMs` is a valid timer delay.
*
* Invalid delays disable keep-alive. In Node.js, sub-millisecond, non-finite,
* and overflowing delays are clamped to roughly 1 ms, which would otherwise
* flood every open stream with comment frames.
*
* @returns The timer handle, or `undefined` when keep-alive is disabled.
*/
export function armSseKeepAlive(intervalMs: number, onTick: () => void): ReturnType<typeof setInterval> | undefined {
if (!Number.isFinite(intervalMs) || intervalMs < 1 || intervalMs > MAX_TIMER_DELAY_MS) {
return undefined;
}

const timer = setInterval(onTick, intervalMs);
// Node.js timers expose `unref`; browser and Workers timers are numbers.
(timer as { unref?: () => void }).unref?.();
return timer;
}
Loading
Loading