From 3fdfb75d81bda2362843a453abf553e0f3379b5d Mon Sep 17 00:00:00 2001 From: Robert Dailey Date: Sat, 21 Mar 2026 12:59:41 -0500 Subject: [PATCH 1/3] 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. --- .changeset/add-sse-keepalive.md | 5 ++ packages/server/src/server/streamableHttp.ts | 36 ++++++++ .../server/test/server/streamableHttp.test.ts | 89 +++++++++++++++++++ 3 files changed, 130 insertions(+) create mode 100644 .changeset/add-sse-keepalive.md diff --git a/.changeset/add-sse-keepalive.md b/.changeset/add-sse-keepalive.md new file mode 100644 index 000000000..78654d9d9 --- /dev/null +++ b/.changeset/add-sse-keepalive.md @@ -0,0 +1,5 @@ +--- +'@modelcontextprotocol/server': minor +--- + +Add optional `keepAliveInterval` to `WebStandardStreamableHTTPServerTransportOptions` that sends periodic SSE comments on the standalone GET stream to prevent reverse proxy idle timeout disconnections. diff --git a/packages/server/src/server/streamableHttp.ts b/packages/server/src/server/streamableHttp.ts index edb07b004..34aa75ce0 100644 --- a/packages/server/src/server/streamableHttp.ts +++ b/packages/server/src/server/streamableHttp.ts @@ -141,6 +141,15 @@ export interface WebStandardStreamableHTTPServerTransportOptions { */ retryInterval?: number; + /** + * Interval in milliseconds for sending SSE keepalive comments on the standalone + * GET SSE stream. When set, the transport sends periodic SSE comments + * (`: keepalive`) to prevent reverse proxies from closing idle connections. + * + * Disabled by default (no keepalive comments are sent). + */ + keepAliveInterval?: number; + /** * List of protocol versions that this transport will accept. * Used to validate the `mcp-protocol-version` header in incoming requests. @@ -238,6 +247,8 @@ export class WebStandardStreamableHTTPServerTransport implements Transport { private _allowedOrigins?: string[]; private _enableDnsRebindingProtection: boolean; private _retryInterval?: number; + private _keepAliveInterval?: number; + private _keepAliveTimer?: ReturnType; private _supportedProtocolVersions: string[]; sessionId?: string; @@ -255,6 +266,7 @@ export class WebStandardStreamableHTTPServerTransport implements Transport { this._allowedOrigins = options.allowedOrigins; this._enableDnsRebindingProtection = options.enableDnsRebindingProtection ?? false; this._retryInterval = options.retryInterval; + this._keepAliveInterval = options.keepAliveInterval; this._supportedProtocolVersions = options.supportedProtocolVersions ?? SUPPORTED_PROTOCOL_VERSIONS; } @@ -445,6 +457,7 @@ export class WebStandardStreamableHTTPServerTransport implements Transport { }, cancel: () => { // Stream was cancelled by client + this._clearKeepAliveTimer(); this._streamMapping.delete(this._standaloneSseStreamId); } }); @@ -474,6 +487,19 @@ export class WebStandardStreamableHTTPServerTransport implements Transport { } }); + // Start keepalive timer to send periodic SSE comments that prevent + // reverse proxies from closing the connection due to idle timeouts + if (this._keepAliveInterval !== undefined) { + this._keepAliveTimer = setInterval(() => { + try { + streamController!.enqueue(encoder.encode(': keepalive\n\n')); + } catch { + // Controller is closed or errored, stop sending keepalives + this._clearKeepAliveTimer(); + } + }, this._keepAliveInterval); + } + return new Response(readable, { headers }); } @@ -896,7 +922,16 @@ export class WebStandardStreamableHTTPServerTransport implements Transport { return undefined; } + private _clearKeepAliveTimer(): void { + if (this._keepAliveTimer !== undefined) { + clearInterval(this._keepAliveTimer); + this._keepAliveTimer = undefined; + } + } + async close(): Promise { + this._clearKeepAliveTimer(); + // Close all SSE connections for (const { cleanup } of this._streamMapping.values()) { cleanup(); @@ -928,6 +963,7 @@ export class WebStandardStreamableHTTPServerTransport implements Transport { * Use this to implement polling behavior for server-initiated notifications. */ closeStandaloneSSEStream(): void { + this._clearKeepAliveTimer(); const stream = this._streamMapping.get(this._standaloneSseStreamId); if (stream) { stream.cleanup(); diff --git a/packages/server/test/server/streamableHttp.test.ts b/packages/server/test/server/streamableHttp.test.ts index aa8ede227..12c69e117 100644 --- a/packages/server/test/server/streamableHttp.test.ts +++ b/packages/server/test/server/streamableHttp.test.ts @@ -956,4 +956,93 @@ describe('Zod v4', () => { expect(error?.message).toContain('Unsupported protocol version'); }); }); + + describe('HTTPServerTransport - keepAliveInterval', () => { + let transport: WebStandardStreamableHTTPServerTransport; + let mcpServer: McpServer; + + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(async () => { + vi.useRealTimers(); + await transport.close(); + }); + + async function setupTransport(keepAliveInterval?: number): Promise { + mcpServer = new McpServer({ name: 'test-server', version: '1.0.0' }); + + transport = new WebStandardStreamableHTTPServerTransport({ + sessionIdGenerator: () => randomUUID(), + keepAliveInterval + }); + + await mcpServer.connect(transport); + + const initReq = createRequest('POST', TEST_MESSAGES.initialize); + const initRes = await transport.handleRequest(initReq); + return initRes.headers.get('mcp-session-id') as string; + } + + it('should send SSE keepalive comments periodically when keepAliveInterval is set', async () => { + const sessionId = await setupTransport(50); + + const getReq = createRequest('GET', undefined, { sessionId }); + const getRes = await transport.handleRequest(getReq); + + expect(getRes.status).toBe(200); + expect(getRes.body).not.toBeNull(); + + const reader = getRes.body!.getReader(); + + // Advance past two intervals to accumulate keepalive comments + vi.advanceTimersByTime(120); + + const { value } = await reader.read(); + const text = new TextDecoder().decode(value); + expect(text).toContain(': keepalive'); + }); + + it('should not send SSE comments when keepAliveInterval is not set', async () => { + const sessionId = await setupTransport(undefined); + + const getReq = createRequest('GET', undefined, { sessionId }); + const getRes = await transport.handleRequest(getReq); + + expect(getRes.status).toBe(200); + expect(getRes.body).not.toBeNull(); + + const reader = getRes.body!.getReader(); + + // Advance time; no keepalive should be enqueued + vi.advanceTimersByTime(200); + + // Close the transport to end the stream, then read whatever was buffered + await transport.close(); + + const chunks: string[] = []; + for (let result = await reader.read(); !result.done; result = await reader.read()) { + chunks.push(new TextDecoder().decode(result.value)); + } + + const allText = chunks.join(''); + expect(allText).not.toContain(': keepalive'); + }); + + it('should clear the keepalive interval when the transport is closed', async () => { + const sessionId = await setupTransport(50); + + const getReq = createRequest('GET', undefined, { sessionId }); + const getRes = await transport.handleRequest(getReq); + + expect(getRes.status).toBe(200); + + // Close the transport, which should clear the interval + await transport.close(); + + // Advancing timers after close should not throw + vi.advanceTimersByTime(200); + }); + }); }); From 3c3340cbce79e89eed8a173f4b64c7db98375a9d Mon Sep 17 00:00:00 2001 From: Robert Dailey Date: Tue, 31 Mar 2026 18:29:49 -0500 Subject: [PATCH 2/3] fix(server): add keepalive timer in replayEvents() and fix test The replayEvents() code path (client reconnects with Last-Event-ID) was missing keepalive timer setup, so reconnecting clients would lose keepalive protection and get dropped again at the next proxy idle timeout. Also fixes the cleanup test to actually prove that close() clears the timer by asserting vi.getTimerCount() drops to 0, instead of relying on the catch fallback which would self-clear anyway. Addresses PR review feedback from @felixweinberger on PR #1726. --- packages/server/src/server/streamableHttp.ts | 13 +++++++++++++ packages/server/test/server/streamableHttp.test.ts | 4 ++-- 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/packages/server/src/server/streamableHttp.ts b/packages/server/src/server/streamableHttp.ts index 34aa75ce0..92bba2f41 100644 --- a/packages/server/src/server/streamableHttp.ts +++ b/packages/server/src/server/streamableHttp.ts @@ -551,6 +551,7 @@ export class WebStandardStreamableHTTPServerTransport implements Transport { }, cancel: () => { // Stream was cancelled by client + this._clearKeepAliveTimer(); // Cleanup will be handled by the mapping } }); @@ -582,6 +583,18 @@ export class WebStandardStreamableHTTPServerTransport implements Transport { } }); + // Start keepalive timer for the replayed stream so reconnecting + // clients remain protected from proxy idle timeouts + if (this._keepAliveInterval !== undefined) { + this._keepAliveTimer = setInterval(() => { + try { + streamController!.enqueue(encoder.encode(': keepalive\n\n')); + } catch { + this._clearKeepAliveTimer(); + } + }, this._keepAliveInterval); + } + return new Response(readable, { headers }); } catch (error) { this.onerror?.(error as Error); diff --git a/packages/server/test/server/streamableHttp.test.ts b/packages/server/test/server/streamableHttp.test.ts index 12c69e117..f474e1cbf 100644 --- a/packages/server/test/server/streamableHttp.test.ts +++ b/packages/server/test/server/streamableHttp.test.ts @@ -1037,12 +1037,12 @@ describe('Zod v4', () => { const getRes = await transport.handleRequest(getReq); expect(getRes.status).toBe(200); + expect(vi.getTimerCount()).toBe(1); // Close the transport, which should clear the interval await transport.close(); - // Advancing timers after close should not throw - vi.advanceTimersByTime(200); + expect(vi.getTimerCount()).toBe(0); }); }); }); From 8af2d12f7f1361cddc544263e7e85eece99d2999 Mon Sep 17 00:00:00 2001 From: Robert Dailey Date: Wed, 1 Apr 2026 07:39:02 -0500 Subject: [PATCH 3/3] fix(server): clear keepalive timer before reassignment in replayEvents Prevent timer handle leaks when concurrent reconnect requests bypass the conflict check. This edge case is possible when EventStore omits the optional getStreamIdForEventId method, allowing duplicate replay attempts to start new timers without cleaning up existing ones. --- packages/server/src/server/streamableHttp.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/server/src/server/streamableHttp.ts b/packages/server/src/server/streamableHttp.ts index 92bba2f41..2864a736b 100644 --- a/packages/server/src/server/streamableHttp.ts +++ b/packages/server/src/server/streamableHttp.ts @@ -586,6 +586,7 @@ export class WebStandardStreamableHTTPServerTransport implements Transport { // Start keepalive timer for the replayed stream so reconnecting // clients remain protected from proxy idle timeouts if (this._keepAliveInterval !== undefined) { + this._clearKeepAliveTimer(); this._keepAliveTimer = setInterval(() => { try { streamController!.enqueue(encoder.encode(': keepalive\n\n'));