Skip to content

Commit b2e0f85

Browse files
committed
fix(server): keep idle GET SSE streams alive
1 parent ab552c3 commit b2e0f85

3 files changed

Lines changed: 45 additions & 0 deletions

File tree

.changeset/lazy-mails-change.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@modelcontextprotocol/server": patch
3+
---
4+
5+
fix(server): keep idle GET SSE streams alive

packages/server/src/server/streamableHttp.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -438,6 +438,14 @@ export class WebStandardStreamableHTTPServerTransport implements Transport {
438438

439439
const encoder = new TextEncoder();
440440
let streamController: ReadableStreamDefaultController<Uint8Array>;
441+
let keepAliveInterval: ReturnType<typeof setInterval> | undefined;
442+
443+
const clearKeepAlive = () => {
444+
if (keepAliveInterval !== undefined) {
445+
clearInterval(keepAliveInterval);
446+
keepAliveInterval = undefined;
447+
}
448+
};
441449

442450
// Create a ReadableStream with a controller we can use to push SSE events
443451
const readable = new ReadableStream<Uint8Array>({
@@ -446,6 +454,7 @@ export class WebStandardStreamableHTTPServerTransport implements Transport {
446454
},
447455
cancel: () => {
448456
// Stream was cancelled by client
457+
clearKeepAlive();
449458
this._streamMapping.delete(this._standaloneSseStreamId);
450459
}
451460
});
@@ -466,6 +475,7 @@ export class WebStandardStreamableHTTPServerTransport implements Transport {
466475
controller: streamController!,
467476
encoder,
468477
cleanup: () => {
478+
clearKeepAlive();
469479
this._streamMapping.delete(this._standaloneSseStreamId);
470480
try {
471481
streamController!.close();
@@ -475,6 +485,14 @@ export class WebStandardStreamableHTTPServerTransport implements Transport {
475485
}
476486
});
477487

488+
keepAliveInterval = setInterval(() => {
489+
try {
490+
streamController!.enqueue(encoder.encode(': keep-alive\n\n'));
491+
} catch {
492+
clearKeepAlive();
493+
}
494+
}, 15_000);
495+
478496
return new Response(readable, { headers });
479497
}
480498

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

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -360,6 +360,28 @@ describe('Zod v4', () => {
360360
expect(response.headers.get('mcp-session-id')).toBe(sessionId);
361361
});
362362

363+
it('should send keep-alive comments on idle standalone SSE streams', async () => {
364+
vi.useFakeTimers();
365+
try {
366+
sessionId = await initializeServer();
367+
368+
const request = createRequest('GET', undefined, { sessionId });
369+
const response = await transport.handleRequest(request);
370+
const reader = response.body!.getReader();
371+
const read = reader.read();
372+
373+
await vi.advanceTimersByTimeAsync(15_000);
374+
375+
const { value, done } = await read;
376+
expect(done).toBe(false);
377+
expect(new TextDecoder().decode(value)).toBe(': keep-alive\n\n');
378+
379+
await reader.cancel();
380+
} finally {
381+
vi.useRealTimers();
382+
}
383+
});
384+
363385
it('should reject GET without Accept: text/event-stream', async () => {
364386
sessionId = await initializeServer();
365387

0 commit comments

Comments
 (0)