Skip to content

Commit 3ec1fd1

Browse files
committed
fix: extend SSE keep-alive to the modern per-request leg; address review round 2
- PerRequestHTTPServerTransport gains keepAliveMs (default 15000, 0 disables): while an exchange's SSE stream is open, an interval drives writeCommentFrame so a long-running handler with no mid-call output doesn't idle past intermediary/server timeouts — the same failure the session transport fix targets, previously unaddressed on the modern serving path. Threaded from createMcpHandler through invoke(), so the handler's keepAliveMs now uniformly covers listen streams, modern per-request exchanges, and the legacy stateless fallback. - Keep-alive guards use the > 0 polarity (matching listenRouter) so a non-finite keepAliveMs disables keep-alive instead of arming a Node- clamped ~1ms interval. - The close-during-replay regression test resumes the standalone GET stream so the continuation genuinely reaches the keep-alive arm (mutation-verified: removing the _closed guard now fails the test). - Reworded the two stale legacy-fallback doc blocks that still claimed the transport is constructed 'with only sessionIdGenerator: undefined', documented legacyStatelessFallback's transportOptions parameter, and scoped the troubleshooting entry to match actual coverage.
1 parent 6d20a1f commit 3ec1fd1

7 files changed

Lines changed: 180 additions & 18 deletions

File tree

.changeset/streamable-http-sse-keepalive.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,4 @@
22
'@modelcontextprotocol/server': patch
33
---
44

5-
`WebStandardStreamableHTTPServerTransport` now writes SSE keep-alive comment frames (`: keepalive`) to open SSE streams so idle connections (e.g. the standalone GET stream, or a POST stream during a long-running tool call) are not killed by intermediaries or server idle timeouts. Configurable via the new `keepAliveMs` option (default 15000; set 0 to disable).
5+
SSE streams served by the SDK now emit keep-alive comment frames (`: keepalive`) so idle connections (e.g. the standalone GET stream, or a stream during a long-running tool call) are not killed by intermediaries or server idle timeouts. `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.

docs/troubleshooting.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -158,7 +158,7 @@ The Resource Server helpers did not move there: `requireBearerAuth`, `mcpAuthMet
158158

159159
An idle SSE stream was killed by an intermediary or an idle-connection timeout — Node's `server.requestTimeout` defaults to 300 seconds, and reverse proxies and cloud load balancers have similar watchdogs. The client observes the dropped socket as this error (typically every ~5 minutes) and reconnects in a loop.
160160

161-
`WebStandardStreamableHTTPServerTransport` prevents this by writing an SSE comment frame (`: keepalive`) to every open SSE stream every 15 seconds by default. Comment frames are dropped by SSE parsers before event dispatch, so they never surface as protocol messages. Tune or disable the interval with the transport's `keepAliveMs` option (`0` disables); `createMcpHandler`'s `keepAliveMs` option covers both its `subscriptions/listen` streams and the legacy fallback's per-request transport.
161+
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).
162162

163163
If you still see this error, either keep-alive is disabled (`keepAliveMs: 0`) or an intermediary between client and server buffers or strips SSE data — check for proxies that buffer streaming responses (e.g. nginx without `proxy_buffering off`).
164164

packages/server/src/server/createMcpHandler.ts

Lines changed: 18 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -145,8 +145,9 @@ export interface CreateMcpHandlerOptions {
145145
* - `'stateless'` (the default, also when the option is omitted) —
146146
* old-school stateless serving: each legacy request is answered by a
147147
* fresh instance from the same factory over a streamable HTTP transport
148-
* constructed with only `sessionIdGenerator: undefined` (the established
149-
* stateless idiom). Because serving is per-request and stateless, GET and
148+
* constructed with `sessionIdGenerator: undefined` (the established
149+
* stateless idiom), plus the handler's `keepAliveMs` when provided.
150+
* Because serving is per-request and stateless, GET and
150151
* DELETE (2025 session operations) are answered with `405` /
151152
* `Method not allowed.`.
152153
* - `'reject'` — modern-only strict: legacy-classified requests are
@@ -194,9 +195,10 @@ export interface CreateMcpHandlerOptions {
194195
*/
195196
maxSubscriptions?: number;
196197
/**
197-
* SSE comment-frame keepalive interval, in milliseconds, applied to
198-
* `subscriptions/listen` streams and to the SSE streams of the legacy
199-
* stateless fallback's per-request transport. Set to `0` to disable.
198+
* SSE comment-frame keepalive interval, in milliseconds, applied to every
199+
* SSE stream this handler serves: `subscriptions/listen` streams, modern
200+
* per-request exchange streams, and the legacy stateless fallback's
201+
* per-request transport. Set to `0` to disable.
200202
* @default 15000
201203
*/
202204
keepAliveMs?: number;
@@ -296,16 +298,20 @@ function internalServerErrorResponse(id: RequestId | null = null): Response {
296298
* strict modern endpoint).
297299
*
298300
* Each POST is served by a fresh instance from the factory connected to a
299-
* fresh streamable HTTP transport constructed with only
300-
* `sessionIdGenerator: undefined` the established stateless idiom, unchanged.
301-
* Because serving is per-request and stateless, GET and DELETE (2025 session
302-
* operations) are answered with `405` / `Method not allowed.`, exactly like the
303-
* canonical stateless example.
301+
* fresh streamable HTTP transport constructed with
302+
* `sessionIdGenerator: undefined` (the established stateless idiom) plus any
303+
* `transportOptions`. Because serving is per-request and stateless, GET and
304+
* DELETE (2025 session operations) are answered with `405` /
305+
* `Method not allowed.`, exactly like the canonical stateless example.
304306
*
305307
* The optional `onerror` callback receives factory and serving failures on
306308
* this leg (reporting only — the response stays the 500 internal-error body).
307309
* The entry passes its own `onerror` here when expanding the default, so
308310
* legacy-leg failures are never silently swallowed.
311+
*
312+
* The optional `transportOptions` are threaded into each per-request
313+
* transport; currently just `keepAliveMs`, the SSE keep-alive comment-frame
314+
* interval (the entry forwards its own `keepAliveMs` option here).
309315
*/
310316
export function legacyStatelessFallback(
311317
factory: McpServerFactory,
@@ -791,7 +797,8 @@ export function createMcpHandler(factory: McpServerFactory, options: CreateMcpHa
791797
classification: route.classification,
792798
request,
793799
...(authInfo !== undefined && { authInfo }),
794-
...(responseMode !== undefined && { responseMode })
800+
...(responseMode !== undefined && { responseMode }),
801+
...(options.keepAliveMs !== undefined && { keepAliveMs: options.keepAliveMs })
795802
});
796803
if (route.messageKind === 'notification') {
797804
// Notification exchanges have no terminal response to ride the

packages/server/src/server/invoke.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,12 @@ export interface InvokeContext {
3535
authInfo?: AuthInfo;
3636
/** Response shaping for the exchange; defaults to `auto` (lazy SSE upgrade). */
3737
responseMode?: PerRequestResponseMode;
38+
/**
39+
* SSE keep-alive comment-frame interval for the exchange's stream, in
40+
* milliseconds; passed through to the per-request transport. `0` disables.
41+
* @default 15000
42+
*/
43+
keepAliveMs?: number;
3844
}
3945

4046
/**
@@ -58,7 +64,8 @@ export async function invoke(
5864
): Promise<Response> {
5965
const transport = new PerRequestHTTPServerTransport({
6066
classification: ctx.classification,
61-
...(ctx.responseMode !== undefined && { responseMode: ctx.responseMode })
67+
...(ctx.responseMode !== undefined && { responseMode: ctx.responseMode }),
68+
...(ctx.keepAliveMs !== undefined && { keepAliveMs: ctx.keepAliveMs })
6269
});
6370
await server.connect(transport);
6471
return transport.handleMessage(message, {

packages/server/src/server/perRequestTransport.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,8 +79,19 @@ export interface PerRequestHTTPServerTransportOptions {
7979
classification: MessageClassification;
8080
/** Response shaping for the exchange; defaults to `auto`. */
8181
responseMode?: PerRequestResponseMode;
82+
/**
83+
* Interval in milliseconds between SSE keep-alive comment frames
84+
* (`: keepalive`) written while the exchange's SSE stream is open, so a
85+
* long-running handler with no mid-call output doesn't idle past
86+
* intermediary and server idle timeouts. Set to `0` to disable.
87+
* @default 15000
88+
*/
89+
keepAliveMs?: number;
8290
}
8391

92+
/** Default interval between SSE keep-alive comment frames. */
93+
const DEFAULT_KEEP_ALIVE_MS = 15_000;
94+
8495
/** Per-exchange context handed to {@linkcode PerRequestHTTPServerTransport.handleMessage}. */
8596
export interface PerRequestMessageExtra {
8697
/**
@@ -140,10 +151,13 @@ export class PerRequestHTTPServerTransport implements Transport {
140151
private _deferredResponse?: DeferredResponse;
141152
private _sse?: SseSink;
142153
private _abortCleanup?: () => void;
154+
private readonly _keepAliveMs: number;
155+
private _keepAliveTimer?: ReturnType<typeof setInterval>;
143156

144157
constructor(options: PerRequestHTTPServerTransportOptions) {
145158
this._classification = options.classification;
146159
this._responseMode = options.responseMode ?? 'auto';
160+
this._keepAliveMs = options.keepAliveMs ?? DEFAULT_KEEP_ALIVE_MS;
147161
}
148162

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

343357
this._abortCleanup?.();
344358
this._abortCleanup = undefined;
359+
this.stopKeepAlive();
345360

346361
if (this._sse !== undefined && !this._sse.closed) {
347362
this._sse.closed = true;
@@ -382,6 +397,7 @@ export class PerRequestHTTPServerTransport implements Transport {
382397
}
383398
});
384399
this._sse = { controller, encoder: new TextEncoder(), closed: false };
400+
this.startKeepAlive();
385401

386402
this.settleResponse(
387403
new Response(readable, {
@@ -398,7 +414,34 @@ export class PerRequestHTTPServerTransport implements Transport {
398414
);
399415
}
400416

417+
/**
418+
* Arms the exchange's keep-alive interval, writing an SSE comment frame
419+
* every `keepAliveMs` while the stream is open. `writeCommentFrame`
420+
* already drops frames once the exchange is closed or the stream is
421+
* finalized, so the interval body needs no extra guards; the timer itself
422+
* is cleared on stream finalization and transport close.
423+
* Uses the `> 0` polarity so a non-finite value disables keep-alive
424+
* instead of arming a clamped ~1ms interval.
425+
*/
426+
private startKeepAlive(): void {
427+
if (!(this._keepAliveMs > 0) || this._closed) {
428+
return;
429+
}
430+
const timer = setInterval(() => this.writeCommentFrame('keepalive'), this._keepAliveMs);
431+
// Don't let the keep-alive timer hold the process open (Node.js only)
432+
(timer as { unref?: () => void }).unref?.();
433+
this._keepAliveTimer = timer;
434+
}
435+
436+
private stopKeepAlive(): void {
437+
if (this._keepAliveTimer !== undefined) {
438+
clearInterval(this._keepAliveTimer);
439+
this._keepAliveTimer = undefined;
440+
}
441+
}
442+
401443
private finalizeStream(): void {
444+
this.stopKeepAlive();
402445
if (this._sse !== undefined && !this._sse.closed) {
403446
this._sse.closed = true;
404447
try {

packages/server/test/server/perRequestStreaming.test.ts

Lines changed: 104 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import {
1111
PROTOCOL_VERSION_META_KEY,
1212
setNegotiatedProtocolVersion
1313
} from '@modelcontextprotocol/core-internal';
14-
import { describe, expect, it } from 'vitest';
14+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
1515

1616
import type { PerRequestResponseMode } from '../../src/server/perRequestTransport';
1717
import { PerRequestHTTPServerTransport } from '../../src/server/perRequestTransport';
@@ -46,14 +46,16 @@ interface StreamingSetup {
4646

4747
async function setup(
4848
handler: (ctx: ServerContext) => Promise<CallToolResult>,
49-
responseMode?: PerRequestResponseMode
49+
responseMode?: PerRequestResponseMode,
50+
keepAliveMs?: number
5051
): Promise<StreamingSetup> {
5152
const server = new Server({ name: 'streaming-test', version: '1.0.0' }, { capabilities: { tools: {} } });
5253
server.setRequestHandler('tools/call', async (_request, ctx) => handler(ctx));
5354
setNegotiatedProtocolVersion(server, MODERN_REVISION);
5455
const transport = new PerRequestHTTPServerTransport({
5556
classification: MODERN,
56-
...(responseMode !== undefined && { responseMode })
57+
...(responseMode !== undefined && { responseMode }),
58+
...(keepAliveMs !== undefined && { keepAliveMs })
5759
});
5860
await server.connect(transport);
5961
return { server, transport };
@@ -249,3 +251,102 @@ describe('disconnect is cancellation', () => {
249251
expect(observedSignal?.aborted).toBe(true);
250252
});
251253
});
254+
255+
describe('keep-alive', () => {
256+
beforeEach(() => {
257+
vi.useFakeTimers();
258+
});
259+
260+
afterEach(() => {
261+
vi.useRealTimers();
262+
});
263+
264+
it('writes keep-alive comment frames while a forced-sse exchange is streaming', async () => {
265+
let release!: () => void;
266+
const gate = new Promise<void>(resolve => {
267+
release = resolve;
268+
});
269+
const { transport } = await setup(async () => {
270+
await gate;
271+
return { content: [] };
272+
}, 'sse');
273+
274+
const responsePromise = transport.handleMessage(toolsCall());
275+
// The stream opened at dispatch end; the handler now idles past the
276+
// default interval with no mid-call output.
277+
await vi.advanceTimersByTimeAsync(15_000);
278+
release();
279+
const response = await responsePromise;
280+
const frames = await sseFrames(response);
281+
expect(frames[0]).toBe(': keepalive');
282+
283+
// The exchange completed and closed the transport: no timer survives.
284+
expect(vi.getTimerCount()).toBe(0);
285+
});
286+
287+
it('writes keep-alive frames after an auto exchange upgrades to SSE', async () => {
288+
let release!: () => void;
289+
const gate = new Promise<void>(resolve => {
290+
release = resolve;
291+
});
292+
const { transport } = await setup(async ctx => {
293+
await ctx.mcpReq.notify(progressNotification(1));
294+
await gate;
295+
return { content: [] };
296+
});
297+
298+
const responsePromise = transport.handleMessage(toolsCall());
299+
// Let the handler run, emit the upgrading notification, then idle.
300+
await vi.advanceTimersByTimeAsync(15_000);
301+
release();
302+
const response = await responsePromise;
303+
const frames = await sseFrames(response);
304+
expect(frames).toContain(': keepalive');
305+
expect(vi.getTimerCount()).toBe(0);
306+
});
307+
308+
it('does not write keep-alive frames when keepAliveMs is 0', async () => {
309+
let release!: () => void;
310+
const gate = new Promise<void>(resolve => {
311+
release = resolve;
312+
});
313+
const { transport } = await setup(
314+
async () => {
315+
await gate;
316+
return { content: [] };
317+
},
318+
'sse',
319+
0
320+
);
321+
322+
const responsePromise = transport.handleMessage(toolsCall());
323+
await vi.advanceTimersByTimeAsync(60_000);
324+
release();
325+
const response = await responsePromise;
326+
const frames = await sseFrames(response);
327+
expect(frames.some(frame => frame.startsWith(': keepalive'))).toBe(false);
328+
});
329+
330+
it('disables keep-alive for a non-finite keepAliveMs instead of arming a clamped interval', async () => {
331+
let release!: () => void;
332+
const gate = new Promise<void>(resolve => {
333+
release = resolve;
334+
});
335+
const { transport } = await setup(
336+
async () => {
337+
await gate;
338+
return { content: [] };
339+
},
340+
'sse',
341+
Number.NaN
342+
);
343+
344+
const responsePromise = transport.handleMessage(toolsCall());
345+
expect(vi.getTimerCount()).toBe(0);
346+
await vi.advanceTimersByTimeAsync(1_000);
347+
release();
348+
const response = await responsePromise;
349+
const frames = await sseFrames(response);
350+
expect(frames.some(frame => frame.startsWith(': keepalive'))).toBe(false);
351+
});
352+
});

packages/server/test/server/streamableHttp.test.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1542,7 +1542,11 @@ describe('WebStandardStreamableHTTPServerTransport SSE keep-alive lifecycle', ()
15421542
await new Promise<void>(resolve => {
15431543
releaseReplay = resolve;
15441544
});
1545-
return 'stream-1';
1545+
// Resume the standalone GET stream: it skips the
1546+
// no-in-flight-request early close unconditionally, so the
1547+
// continuation genuinely reaches the keep-alive arm and only
1548+
// the closed-transport guard keeps the timer count at zero.
1549+
return '_GET_stream';
15461550
}
15471551
};
15481552
const transport = new WebStandardStreamableHTTPServerTransport({ sessionIdGenerator: () => randomUUID(), eventStore });

0 commit comments

Comments
 (0)