Skip to content

Commit 470678d

Browse files
authored
fix: send SSE keep-alive comment frames from WebStandardStreamableHTTPServerTransport (#2541)
1 parent 1e1392e commit 470678d

13 files changed

Lines changed: 480 additions & 36 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: 22 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -59,13 +59,14 @@ import {
5959
} from '@modelcontextprotocol/core-internal';
6060

6161
import { invoke } from './invoke';
62-
import { createListenRouter, DEFAULT_LISTEN_KEEPALIVE_MS, DEFAULT_MAX_SUBSCRIPTIONS } from './listenRouter';
62+
import { createListenRouter, DEFAULT_MAX_SUBSCRIPTIONS } from './listenRouter';
6363
import { McpServer } from './mcp';
6464
import type { PerRequestResponseMode } from './perRequestTransport';
6565
import type { Server } from './server';
6666
import { installModernOnlyHandlers, seedClientIdentityFromEnvelope, serverIdentityOf } from './server';
6767
import type { ServerEventBus, ServerNotifier } from './serverEventBus';
6868
import { createServerNotifier, InMemoryServerEventBus } from './serverEventBus';
69+
import { DEFAULT_SSE_KEEP_ALIVE_MS } from './sseKeepAlive';
6970
import { WebStandardStreamableHTTPServerTransport } from './streamableHttp';
7071

7172
/* ------------------------------------------------------------------------ *
@@ -194,8 +195,8 @@ export interface CreateMcpHandlerOptions {
194195
*/
195196
maxSubscriptions?: number;
196197
/**
197-
* SSE comment-frame keepalive interval for `subscriptions/listen` streams,
198-
* in milliseconds. Set to `0` to disable.
198+
* SSE comment-frame keepalive interval for every SSE stream this handler
199+
* serves. In modern `auto` mode it starts after SSE upgrade. Set to `0` to disable.
199200
* @default 15000
200201
*/
201202
keepAliveMs?: number;
@@ -306,7 +307,11 @@ function internalServerErrorResponse(id: RequestId | null = null): Response {
306307
* The entry passes its own `onerror` here when expanding the default, so
307308
* legacy-leg failures are never silently swallowed.
308309
*/
309-
export function legacyStatelessFallback(factory: McpServerFactory, onerror?: (error: Error) => void): LegacyHttpHandler {
310+
function createLegacyStatelessFallback(
311+
factory: McpServerFactory,
312+
onerror?: (error: Error) => void,
313+
keepAliveMs?: number
314+
): LegacyHttpHandler {
310315
return async (request, options) => {
311316
if (request.method.toUpperCase() !== 'POST') {
312317
return jsonRpcErrorResponse(405, -32_000, 'Method not allowed.');
@@ -317,7 +322,10 @@ export function legacyStatelessFallback(factory: McpServerFactory, onerror?: (er
317322
...(options?.authInfo !== undefined && { authInfo: options.authInfo }),
318323
requestInfo: request
319324
});
320-
const transport = new WebStandardStreamableHTTPServerTransport({ sessionIdGenerator: undefined });
325+
const transport = new WebStandardStreamableHTTPServerTransport({
326+
sessionIdGenerator: undefined,
327+
...(keepAliveMs !== undefined && { keepAliveMs })
328+
});
321329
await product.connect(transport);
322330

323331
const teardown = () => {
@@ -390,6 +398,10 @@ export function legacyStatelessFallback(factory: McpServerFactory, onerror?: (er
390398
};
391399
}
392400

401+
export function legacyStatelessFallback(factory: McpServerFactory, onerror?: (error: Error) => void): LegacyHttpHandler {
402+
return createLegacyStatelessFallback(factory, onerror);
403+
}
404+
393405
/* ------------------------------------------------------------------------ *
394406
* The entry's classification step (shared with isLegacyRequest)
395407
* ------------------------------------------------------------------------ */
@@ -619,7 +631,7 @@ export function createMcpHandler(factory: McpServerFactory, options: CreateMcpHa
619631
const listenRouter = createListenRouter({
620632
bus,
621633
maxSubscriptions: options.maxSubscriptions ?? DEFAULT_MAX_SUBSCRIPTIONS,
622-
keepAliveMs: options.keepAliveMs ?? DEFAULT_LISTEN_KEEPALIVE_MS,
634+
keepAliveMs: options.keepAliveMs ?? DEFAULT_SSE_KEEP_ALIVE_MS,
623635
onerror: reportError
624636
});
625637
if (responseMode === 'json') {
@@ -632,7 +644,8 @@ export function createMcpHandler(factory: McpServerFactory, options: CreateMcpHa
632644

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

637650
async function serveModern(route: InboundModernRoute, request: Request, authInfo: AuthInfo | undefined): Promise<Response> {
638651
const claimedRevision = route.classification.revision;
@@ -778,7 +791,8 @@ export function createMcpHandler(factory: McpServerFactory, options: CreateMcpHa
778791
classification: route.classification,
779792
request,
780793
...(authInfo !== undefined && { authInfo }),
781-
...(responseMode !== undefined && { responseMode })
794+
...(responseMode !== undefined && { responseMode }),
795+
...(options.keepAliveMs !== undefined && { keepAliveMs: options.keepAliveMs })
782796
});
783797
if (route.messageKind === 'notification') {
784798
// 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: 9 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -34,9 +34,7 @@ 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-
38-
/** Default SSE comment-frame keepalive interval for listen streams. */
39-
export const DEFAULT_LISTEN_KEEPALIVE_MS = 15_000;
37+
import { armSseKeepAlive, DEFAULT_SSE_KEEP_ALIVE_MS } from './sseKeepAlive';
4038

4139
/** Default capacity guard: refuse a new subscription when this many are already open. */
4240
export const DEFAULT_MAX_SUBSCRIPTIONS = 1024;
@@ -124,7 +122,7 @@ export interface ListenRouter {
124122
export function createListenRouter(options: ListenRouterOptions): ListenRouter {
125123
const { bus, onerror } = options;
126124
const maxSubscriptions = options.maxSubscriptions ?? DEFAULT_MAX_SUBSCRIPTIONS;
127-
const keepAliveMs = options.keepAliveMs ?? DEFAULT_LISTEN_KEEPALIVE_MS;
125+
const keepAliveMs = options.keepAliveMs ?? DEFAULT_SSE_KEEP_ALIVE_MS;
128126

129127
const open = new Set<(graceful: boolean) => void>();
130128

@@ -188,7 +186,11 @@ export function createListenRouter(options: ListenRouterOptions): ListenRouter {
188186
);
189187
}
190188
closed = true;
191-
unsubscribe?.();
189+
try {
190+
unsubscribe?.();
191+
} catch (error) {
192+
onerror?.(error instanceof Error ? error : new Error(String(error)));
193+
}
192194
if (keepAliveTimer !== undefined) clearInterval(keepAliveTimer);
193195
abortCleanup?.();
194196
open.delete(teardown);
@@ -218,14 +220,7 @@ export function createListenRouter(options: ListenRouterOptions): ListenRouter {
218220
writeNotification(note.method, note.params);
219221
});
220222

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-
}
223+
keepAliveTimer = armSseKeepAlive(keepAliveMs, () => writeFrame(': keepalive\n\n'));
229224

230225
open.add(teardown);
231226
},
@@ -251,7 +246,7 @@ export function createListenRouter(options: ListenRouterOptions): ListenRouter {
251246
status: 200,
252247
headers: {
253248
'Content-Type': 'text/event-stream',
254-
'Cache-Control': 'no-cache',
249+
'Cache-Control': 'no-cache, no-transform',
255250
Connection: 'keep-alive',
256251
'X-Accel-Buffering': 'no'
257252
}

packages/server/src/server/perRequestTransport.ts

Lines changed: 15 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}. */
@@ -107,6 +111,7 @@ interface SseSink {
107111
controller: ReadableStreamDefaultController<Uint8Array>;
108112
encoder: InstanceType<typeof TextEncoder>;
109113
closed: boolean;
114+
keepAliveTimer?: ReturnType<typeof setInterval>;
110115
}
111116

112117
/**
@@ -140,10 +145,12 @@ export class PerRequestHTTPServerTransport implements Transport {
140145
private _deferredResponse?: DeferredResponse;
141146
private _sse?: SseSink;
142147
private _abortCleanup?: () => void;
148+
private readonly _keepAliveMs: number;
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> {
@@ -343,6 +350,9 @@ export class PerRequestHTTPServerTransport implements Transport {
343350
this._abortCleanup?.();
344351
this._abortCleanup = undefined;
345352

353+
if (this._sse?.keepAliveTimer !== undefined) {
354+
clearInterval(this._sse.keepAliveTimer);
355+
}
346356
if (this._sse !== undefined && !this._sse.closed) {
347357
this._sse.closed = true;
348358
try {
@@ -382,13 +392,14 @@ export class PerRequestHTTPServerTransport implements Transport {
382392
}
383393
});
384394
this._sse = { controller, encoder: new TextEncoder(), closed: false };
395+
this._sse.keepAliveTimer = armSseKeepAlive(this._keepAliveMs, () => this.writeCommentFrame('keepalive'));
385396

386397
this.settleResponse(
387398
new Response(readable, {
388399
status: 200,
389400
headers: {
390401
'Content-Type': 'text/event-stream',
391-
'Cache-Control': 'no-cache',
402+
'Cache-Control': 'no-cache, no-transform',
392403
Connection: 'keep-alive',
393404
// Disable proxy buffering so streamed messages are
394405
// delivered as they are written.
@@ -399,6 +410,9 @@ export class PerRequestHTTPServerTransport implements Transport {
399410
}
400411

401412
private finalizeStream(): void {
413+
if (this._sse?.keepAliveTimer !== undefined) {
414+
clearInterval(this._sse.keepAliveTimer);
415+
}
402416
if (this._sse !== undefined && !this._sse.closed) {
403417
this._sse.closed = true;
404418
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)