Skip to content

Commit 941226e

Browse files
committed
feat(server): add keepAliveInterval to standalone GET SSE stream
Adds an opt-in keepAliveInterval option to WebStandardStreamableHTTPServerTransportOptions that sends periodic SSE comments (`: keepalive`) on the standalone GET SSE stream. Reverse proxies commonly close connections that are idle for 30-60s. With no server-initiated messages, the GET SSE stream has no traffic during quiet periods, causing silent disconnections. This option lets operators send harmless SSE comments at a configurable cadence to keep the connection alive. The timer is cleared on close(), closeStandaloneSSEStream(), and on stream cancellation. Disabled by default; no behavior change for existing deployments. Addresses upstream #28, #876.
1 parent ccb78f2 commit 941226e

File tree

3 files changed

+130
-0
lines changed

3 files changed

+130
-0
lines changed

.changeset/add-sse-keepalive.md

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 optional `keepAliveInterval` to `WebStandardStreamableHTTPServerTransportOptions` that sends periodic SSE comments on the standalone GET stream to prevent reverse proxy idle timeout disconnections.

packages/server/src/server/streamableHttp.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,15 @@ export interface WebStandardStreamableHTTPServerTransportOptions {
141141
*/
142142
retryInterval?: number;
143143

144+
/**
145+
* Interval in milliseconds for sending SSE keepalive comments on the standalone
146+
* GET SSE stream. When set, the transport sends periodic SSE comments
147+
* (`: keepalive`) to prevent reverse proxies from closing idle connections.
148+
*
149+
* Disabled by default (no keepalive comments are sent).
150+
*/
151+
keepAliveInterval?: number;
152+
144153
/**
145154
* List of protocol versions that this transport will accept.
146155
* Used to validate the `mcp-protocol-version` header in incoming requests.
@@ -238,6 +247,8 @@ export class WebStandardStreamableHTTPServerTransport implements Transport {
238247
private _allowedOrigins?: string[];
239248
private _enableDnsRebindingProtection: boolean;
240249
private _retryInterval?: number;
250+
private _keepAliveInterval?: number;
251+
private _keepAliveTimer?: ReturnType<typeof setInterval>;
241252
private _supportedProtocolVersions: string[];
242253

243254
sessionId?: string;
@@ -255,6 +266,7 @@ export class WebStandardStreamableHTTPServerTransport implements Transport {
255266
this._allowedOrigins = options.allowedOrigins;
256267
this._enableDnsRebindingProtection = options.enableDnsRebindingProtection ?? false;
257268
this._retryInterval = options.retryInterval;
269+
this._keepAliveInterval = options.keepAliveInterval;
258270
this._supportedProtocolVersions = options.supportedProtocolVersions ?? SUPPORTED_PROTOCOL_VERSIONS;
259271
}
260272

@@ -443,6 +455,7 @@ export class WebStandardStreamableHTTPServerTransport implements Transport {
443455
},
444456
cancel: () => {
445457
// Stream was cancelled by client
458+
this._clearKeepAliveTimer();
446459
this._streamMapping.delete(this._standaloneSseStreamId);
447460
}
448461
});
@@ -472,6 +485,19 @@ export class WebStandardStreamableHTTPServerTransport implements Transport {
472485
}
473486
});
474487

488+
// Start keepalive timer to send periodic SSE comments that prevent
489+
// reverse proxies from closing the connection due to idle timeouts
490+
if (this._keepAliveInterval !== undefined) {
491+
this._keepAliveTimer = setInterval(() => {
492+
try {
493+
streamController!.enqueue(encoder.encode(': keepalive\n\n'));
494+
} catch {
495+
// Controller is closed or errored, stop sending keepalives
496+
this._clearKeepAliveTimer();
497+
}
498+
}, this._keepAliveInterval);
499+
}
500+
475501
return new Response(readable, { headers });
476502
}
477503

@@ -886,7 +912,16 @@ export class WebStandardStreamableHTTPServerTransport implements Transport {
886912
return undefined;
887913
}
888914

915+
private _clearKeepAliveTimer(): void {
916+
if (this._keepAliveTimer !== undefined) {
917+
clearInterval(this._keepAliveTimer);
918+
this._keepAliveTimer = undefined;
919+
}
920+
}
921+
889922
async close(): Promise<void> {
923+
this._clearKeepAliveTimer();
924+
890925
// Close all SSE connections
891926
for (const { cleanup } of this._streamMapping.values()) {
892927
cleanup();
@@ -918,6 +953,7 @@ export class WebStandardStreamableHTTPServerTransport implements Transport {
918953
* Use this to implement polling behavior for server-initiated notifications.
919954
*/
920955
closeStandaloneSSEStream(): void {
956+
this._clearKeepAliveTimer();
921957
const stream = this._streamMapping.get(this._standaloneSseStreamId);
922958
if (stream) {
923959
stream.cleanup();

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

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -765,4 +765,93 @@ describe('Zod v4', () => {
765765
await expect(transport.start()).rejects.toThrow('Transport already started');
766766
});
767767
});
768+
769+
describe('HTTPServerTransport - keepAliveInterval', () => {
770+
let transport: WebStandardStreamableHTTPServerTransport;
771+
let mcpServer: McpServer;
772+
773+
beforeEach(() => {
774+
vi.useFakeTimers();
775+
});
776+
777+
afterEach(async () => {
778+
vi.useRealTimers();
779+
await transport.close();
780+
});
781+
782+
async function setupTransport(keepAliveInterval?: number): Promise<string> {
783+
mcpServer = new McpServer({ name: 'test-server', version: '1.0.0' });
784+
785+
transport = new WebStandardStreamableHTTPServerTransport({
786+
sessionIdGenerator: () => randomUUID(),
787+
keepAliveInterval
788+
});
789+
790+
await mcpServer.connect(transport);
791+
792+
const initReq = createRequest('POST', TEST_MESSAGES.initialize);
793+
const initRes = await transport.handleRequest(initReq);
794+
return initRes.headers.get('mcp-session-id') as string;
795+
}
796+
797+
it('should send SSE keepalive comments periodically when keepAliveInterval is set', async () => {
798+
const sessionId = await setupTransport(50);
799+
800+
const getReq = createRequest('GET', undefined, { sessionId });
801+
const getRes = await transport.handleRequest(getReq);
802+
803+
expect(getRes.status).toBe(200);
804+
expect(getRes.body).not.toBeNull();
805+
806+
const reader = getRes.body!.getReader();
807+
808+
// Advance past two intervals to accumulate keepalive comments
809+
vi.advanceTimersByTime(120);
810+
811+
const { value } = await reader.read();
812+
const text = new TextDecoder().decode(value);
813+
expect(text).toContain(': keepalive');
814+
});
815+
816+
it('should not send SSE comments when keepAliveInterval is not set', async () => {
817+
const sessionId = await setupTransport(undefined);
818+
819+
const getReq = createRequest('GET', undefined, { sessionId });
820+
const getRes = await transport.handleRequest(getReq);
821+
822+
expect(getRes.status).toBe(200);
823+
expect(getRes.body).not.toBeNull();
824+
825+
const reader = getRes.body!.getReader();
826+
827+
// Advance time; no keepalive should be enqueued
828+
vi.advanceTimersByTime(200);
829+
830+
// Close the transport to end the stream, then read whatever was buffered
831+
await transport.close();
832+
833+
const chunks: string[] = [];
834+
for (let result = await reader.read(); !result.done; result = await reader.read()) {
835+
chunks.push(new TextDecoder().decode(result.value));
836+
}
837+
838+
const allText = chunks.join('');
839+
expect(allText).not.toContain(': keepalive');
840+
});
841+
842+
it('should clear the keepalive interval when the transport is closed', async () => {
843+
const sessionId = await setupTransport(50);
844+
845+
const getReq = createRequest('GET', undefined, { sessionId });
846+
const getRes = await transport.handleRequest(getReq);
847+
848+
expect(getRes.status).toBe(200);
849+
850+
// Close the transport, which should clear the interval
851+
await transport.close();
852+
853+
// Advancing timers after close should not throw
854+
vi.advanceTimersByTime(200);
855+
});
856+
});
768857
});

0 commit comments

Comments
 (0)