Skip to content

Commit d221ae9

Browse files
committed
fix: keep HTTP SSE streams alive
Add stream-owned keep-alive timers to session and per-request HTTP transports, propagate createMcpHandler's existing interval across its HTTP legs, and prevent proxy buffering.
1 parent 1e1392e commit d221ae9

12 files changed

Lines changed: 363 additions & 25 deletions
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@modelcontextprotocol/server': minor
3+
---
4+
5+
Add configurable SSE keep-alive comment frames to Streamable HTTP transports and apply `createMcpHandler`'s existing `keepAliveMs` option to every HTTP SSE stream it serves.

docs/troubleshooting.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -154,6 +154,10 @@ Rewrite the imports:
154154

155155
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.
156156

157+
## `SSE stream disconnected: TypeError: terminated`
158+
159+
HTTP SSE streams emit a `: keepalive` comment every 15 seconds by default so client body-idle timeouts and intermediaries do not terminate an otherwise idle connection. Configure the interval with `keepAliveMs` on the transport or `createMcpHandler`; set it to `0` to disable heartbeats.
160+
157161
## Recap
158162

159163
- Every heading on this page is the exact message you searched for.

packages/server/src/server/createMcpHandler.ts

Lines changed: 19 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -194,8 +194,8 @@ export interface CreateMcpHandlerOptions {
194194
*/
195195
maxSubscriptions?: number;
196196
/**
197-
* SSE comment-frame keepalive interval for `subscriptions/listen` streams,
198-
* in milliseconds. Set to `0` to disable.
197+
* SSE comment-frame keepalive interval for every SSE stream this handler
198+
* serves. In modern `auto` mode it starts after SSE upgrade. Set to `0` to disable.
199199
* @default 15000
200200
*/
201201
keepAliveMs?: number;
@@ -306,7 +306,11 @@ function internalServerErrorResponse(id: RequestId | null = null): Response {
306306
* The entry passes its own `onerror` here when expanding the default, so
307307
* legacy-leg failures are never silently swallowed.
308308
*/
309-
export function legacyStatelessFallback(factory: McpServerFactory, onerror?: (error: Error) => void): LegacyHttpHandler {
309+
function legacyStatelessFallbackWithKeepAlive(
310+
factory: McpServerFactory,
311+
onerror?: (error: Error) => void,
312+
keepAliveMs?: number
313+
): LegacyHttpHandler {
310314
return async (request, options) => {
311315
if (request.method.toUpperCase() !== 'POST') {
312316
return jsonRpcErrorResponse(405, -32_000, 'Method not allowed.');
@@ -317,7 +321,10 @@ export function legacyStatelessFallback(factory: McpServerFactory, onerror?: (er
317321
...(options?.authInfo !== undefined && { authInfo: options.authInfo }),
318322
requestInfo: request
319323
});
320-
const transport = new WebStandardStreamableHTTPServerTransport({ sessionIdGenerator: undefined });
324+
const transport = new WebStandardStreamableHTTPServerTransport({
325+
sessionIdGenerator: undefined,
326+
...(keepAliveMs !== undefined && { keepAliveMs })
327+
});
321328
await product.connect(transport);
322329

323330
const teardown = () => {
@@ -390,6 +397,10 @@ export function legacyStatelessFallback(factory: McpServerFactory, onerror?: (er
390397
};
391398
}
392399

400+
export function legacyStatelessFallback(factory: McpServerFactory, onerror?: (error: Error) => void): LegacyHttpHandler {
401+
return legacyStatelessFallbackWithKeepAlive(factory, onerror);
402+
}
403+
393404
/* ------------------------------------------------------------------------ *
394405
* The entry's classification step (shared with isLegacyRequest)
395406
* ------------------------------------------------------------------------ */
@@ -632,7 +643,8 @@ export function createMcpHandler(factory: McpServerFactory, options: CreateMcpHa
632643

633644
// The default posture is the stateless fallback; 'reject' is the only way
634645
// to turn legacy serving off (modern-only strict).
635-
const legacyHandler: LegacyHttpHandler | undefined = legacy === 'reject' ? undefined : legacyStatelessFallback(factory, reportError);
646+
const legacyHandler: LegacyHttpHandler | undefined =
647+
legacy === 'reject' ? undefined : legacyStatelessFallbackWithKeepAlive(factory, reportError, options.keepAliveMs);
636648

637649
async function serveModern(route: InboundModernRoute, request: Request, authInfo: AuthInfo | undefined): Promise<Response> {
638650
const claimedRevision = route.classification.revision;
@@ -778,7 +790,8 @@ export function createMcpHandler(factory: McpServerFactory, options: CreateMcpHa
778790
classification: route.classification,
779791
request,
780792
...(authInfo !== undefined && { authInfo }),
781-
...(responseMode !== undefined && { responseMode })
793+
...(responseMode !== undefined && { responseMode }),
794+
...(options.keepAliveMs !== undefined && { keepAliveMs: options.keepAliveMs })
782795
});
783796
if (route.messageKind === 'notification') {
784797
// Notification exchanges have no terminal response to ride the

packages/server/src/server/invoke.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,8 @@ export interface InvokeContext {
3535
authInfo?: AuthInfo;
3636
/** Response shaping for the exchange; defaults to `auto` (lazy SSE upgrade). */
3737
responseMode?: PerRequestResponseMode;
38+
/** SSE keep-alive interval for the exchange. */
39+
keepAliveMs?: number;
3840
}
3941

4042
/**
@@ -58,7 +60,8 @@ export async function invoke(
5860
): Promise<Response> {
5961
const transport = new PerRequestHTTPServerTransport({
6062
classification: ctx.classification,
61-
...(ctx.responseMode !== undefined && { responseMode: ctx.responseMode })
63+
...(ctx.responseMode !== undefined && { responseMode: ctx.responseMode }),
64+
...(ctx.keepAliveMs !== undefined && { keepAliveMs: ctx.keepAliveMs })
6265
});
6366
await server.connect(transport);
6467
return transport.handleMessage(message, {

packages/server/src/server/listenRouter.ts

Lines changed: 3 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -34,9 +34,10 @@ import { codecForVersion, MODERN_WIRE_REVISION, SERVER_INFO_META_KEY, SUBSCRIPTI
3434

3535
import type { ServerEventBus } from './serverEventBus';
3636
import { honoredSubset, listenFilterAccepts, serverEventToNotification } from './serverEventBus';
37+
import { armSseKeepAlive, DEFAULT_SSE_KEEP_ALIVE_MS } from './sseKeepAlive';
3738

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

4142
/** Default capacity guard: refuse a new subscription when this many are already open. */
4243
export const DEFAULT_MAX_SUBSCRIPTIONS = 1024;
@@ -218,14 +219,7 @@ export function createListenRouter(options: ListenRouterOptions): ListenRouter {
218219
writeNotification(note.method, note.params);
219220
});
220221

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

230224
open.add(teardown);
231225
},

packages/server/src/server/perRequestTransport.ts

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,8 @@ import {
5858
SdkErrorCode
5959
} from '@modelcontextprotocol/core-internal';
6060

61+
import { armSseKeepAlive, DEFAULT_SSE_KEEP_ALIVE_MS } from './sseKeepAlive';
62+
6163
/**
6264
* How the transport shapes its HTTP response for a request:
6365
*
@@ -79,6 +81,8 @@ export interface PerRequestHTTPServerTransportOptions {
7981
classification: MessageClassification;
8082
/** Response shaping for the exchange; defaults to `auto`. */
8183
responseMode?: PerRequestResponseMode;
84+
/** SSE keep-alive interval in milliseconds; defaults to `15000`, `0` disables. */
85+
keepAliveMs?: number;
8286
}
8387

8488
/** Per-exchange context handed to {@linkcode PerRequestHTTPServerTransport.handleMessage}. */
@@ -140,10 +144,13 @@ export class PerRequestHTTPServerTransport implements Transport {
140144
private _deferredResponse?: DeferredResponse;
141145
private _sse?: SseSink;
142146
private _abortCleanup?: () => void;
147+
private readonly _keepAliveMs: number;
148+
private _keepAliveTimer?: ReturnType<typeof setInterval>;
143149

144150
constructor(options: PerRequestHTTPServerTransportOptions) {
145151
this._classification = options.classification;
146152
this._responseMode = options.responseMode ?? 'auto';
153+
this._keepAliveMs = options.keepAliveMs ?? DEFAULT_SSE_KEEP_ALIVE_MS;
147154
}
148155

149156
async start(): Promise<void> {
@@ -342,6 +349,7 @@ export class PerRequestHTTPServerTransport implements Transport {
342349

343350
this._abortCleanup?.();
344351
this._abortCleanup = undefined;
352+
this.stopKeepAlive();
345353

346354
if (this._sse !== undefined && !this._sse.closed) {
347355
this._sse.closed = true;
@@ -382,13 +390,14 @@ export class PerRequestHTTPServerTransport implements Transport {
382390
}
383391
});
384392
this._sse = { controller, encoder: new TextEncoder(), closed: false };
393+
this._keepAliveTimer = armSseKeepAlive(this._keepAliveMs, () => this.writeCommentFrame('keepalive'));
385394

386395
this.settleResponse(
387396
new Response(readable, {
388397
status: 200,
389398
headers: {
390399
'Content-Type': 'text/event-stream',
391-
'Cache-Control': 'no-cache',
400+
'Cache-Control': 'no-cache, no-transform',
392401
Connection: 'keep-alive',
393402
// Disable proxy buffering so streamed messages are
394403
// delivered as they are written.
@@ -398,7 +407,15 @@ export class PerRequestHTTPServerTransport implements Transport {
398407
);
399408
}
400409

410+
private stopKeepAlive(): void {
411+
if (this._keepAliveTimer !== undefined) {
412+
clearInterval(this._keepAliveTimer);
413+
this._keepAliveTimer = undefined;
414+
}
415+
}
416+
401417
private finalizeStream(): void {
418+
this.stopKeepAlive();
402419
if (this._sse !== undefined && !this._sse.closed) {
403420
this._sse.closed = true;
404421
try {
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
/** Default interval between SSE keep-alive comment frames. */
2+
export const DEFAULT_SSE_KEEP_ALIVE_MS = 15_000;
3+
4+
const MAX_TIMER_DELAY_MS = 2 ** 31 - 1;
5+
6+
/** Arms an unref'd timer, or disables keep-alive for invalid delays. */
7+
export function armSseKeepAlive(intervalMs: number, onTick: () => void): ReturnType<typeof setInterval> | undefined {
8+
if (!Number.isFinite(intervalMs) || intervalMs < 1) {
9+
return undefined;
10+
}
11+
12+
const timer = setInterval(onTick, Math.min(intervalMs, MAX_TIMER_DELAY_MS));
13+
(timer as { unref?: () => void }).unref?.();
14+
return timer;
15+
}

0 commit comments

Comments
 (0)