Skip to content

Commit 48a026b

Browse files
committed
fix(server): keep idle GET SSE streams alive
1 parent f2a3320 commit 48a026b

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
@@ -455,6 +455,14 @@ export class WebStandardStreamableHTTPServerTransport implements Transport {
455455

456456
const encoder = new TextEncoder();
457457
let streamController: ReadableStreamDefaultController<Uint8Array>;
458+
let keepAliveInterval: ReturnType<typeof setInterval> | undefined;
459+
460+
const clearKeepAlive = () => {
461+
if (keepAliveInterval !== undefined) {
462+
clearInterval(keepAliveInterval);
463+
keepAliveInterval = undefined;
464+
}
465+
};
458466

459467
// Create a ReadableStream with a controller we can use to push SSE events
460468
const readable = new ReadableStream<Uint8Array>({
@@ -463,6 +471,7 @@ export class WebStandardStreamableHTTPServerTransport implements Transport {
463471
},
464472
cancel: () => {
465473
// Stream was cancelled by client
474+
clearKeepAlive();
466475
this._streamMapping.delete(this._standaloneSseStreamId);
467476
}
468477
});
@@ -483,6 +492,7 @@ export class WebStandardStreamableHTTPServerTransport implements Transport {
483492
controller: streamController!,
484493
encoder,
485494
cleanup: () => {
495+
clearKeepAlive();
486496
this._streamMapping.delete(this._standaloneSseStreamId);
487497
try {
488498
streamController!.close();
@@ -492,6 +502,14 @@ export class WebStandardStreamableHTTPServerTransport implements Transport {
492502
}
493503
});
494504

505+
keepAliveInterval = setInterval(() => {
506+
try {
507+
streamController!.enqueue(encoder.encode(': keep-alive\n\n'));
508+
} catch {
509+
clearKeepAlive();
510+
}
511+
}, 15_000);
512+
495513
return new Response(readable, { headers });
496514
}
497515

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)