Skip to content
5 changes: 5 additions & 0 deletions .changeset/add-sse-keepalive.md
Original file line number Diff line number Diff line change
@@ -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.
90 changes: 69 additions & 21 deletions packages/server/src/server/streamableHttp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,8 @@ interface StreamMapping {
replayedEventIds?: Set<string>;
/** Cleanup function to close stream and remove mapping */
cleanup: () => void;
/** Per-stream keepalive timer; cleared by this stream's cleanup/cancel */
keepAliveTimer?: ReturnType<typeof setInterval>;
}

/**
Expand Down Expand Up @@ -148,6 +150,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;
Comment on lines +153 to +160

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 The JSDoc for keepAliveInterval (line 147) says it sends keepalive comments on the standalone GET SSE stream, but the implementation also starts the timer for reconnecting clients via the Last-Event-ID / replayEvents() path. The description should be updated to reflect that the option applies to all GET-initiated SSE streams, not just the initial standalone one.

Extended reasoning...

What the inaccuracy is

The JSDoc for keepAliveInterval reads: "Interval in milliseconds for sending SSE keepalive comments on the standalone GET SSE stream." This is factually incomplete. The same PR also added keepalive timer support inside replayEvents() (the Last-Event-ID reconnect path), so keepAliveInterval governs keepalive behaviour on both GET stream types.

The specific code evidence

In handleGetRequest() (around line 500 of the diff), when this._keepAliveInterval !== undefined, a setInterval is stored in mapping.keepAliveTimer. In replayEvents() (around line 600), an identical block assigns a local keepAliveTimer and stores it in mapping.keepAliveTimer. Both paths read the same this._keepAliveInterval field. The changeset file .changeset/add-sse-keepalive.md repeats the same partial description: "sends periodic SSE comments on the standalone GET stream".

Why existing code does not prevent confusion

A developer reading only the JSDoc would conclude that reconnecting clients (those sending Last-Event-ID) do not benefit from the keepalive option, and might add their own keepalive logic elsewhere, leading to duplicate timers or misfiled bugs.

Impact

There is no runtime impact -- the code works correctly for both stream types and users actually get more coverage than advertised. The issue is purely documentation: a developer relying on the JSDoc would have an incomplete mental model of the feature scope.

Concrete proof

  1. Developer reads the JSDoc: "sends SSE keepalive comments on the standalone GET SSE stream."
  2. Developer sets keepAliveInterval: 15_000 intending to keep all GET-based connections alive.
  3. A client reconnects with Last-Event-ID -> enters replayEvents() -> timer is started there too.
  4. Developer is unaware replay streams also have keepalives, potentially adding redundant logic or assuming they are missing.

Addressing the refutation

One verifier argued this is not worth reporting because the behavior is more generous than advertised. While true that no user suffers broken behavior, a JSDoc that describes only half of a new feature's scope is a genuine documentation bug. The fix is a one-line word change and the cost of the inaccuracy (misleading developers) outweighs the cost of correcting it.

How to fix

Change "on the standalone GET SSE stream" to "on all GET-initiated SSE streams (both the initial standalone stream and reconnecting Last-Event-ID streams)". Update the changeset description similarly.


/**
* List of protocol versions that this transport will accept.
* Used to validate the `mcp-protocol-version` header in incoming requests.
Expand Down Expand Up @@ -246,6 +257,7 @@ export class WebStandardStreamableHTTPServerTransport implements Transport {
private _allowedOrigins?: string[];
private _enableDnsRebindingProtection: boolean;
private _retryInterval?: number;
private _keepAliveInterval?: number;
private _supportedProtocolVersions: string[];

sessionId?: string;
Expand All @@ -263,6 +275,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;
}

Expand Down Expand Up @@ -461,18 +474,33 @@ export class WebStandardStreamableHTTPServerTransport implements Transport {
}

const encoder = new TextEncoder();
let streamController: ReadableStreamDefaultController<Uint8Array>;
let streamController!: ReadableStreamDefaultController<Uint8Array>;

const mapping: StreamMapping = {
encoder,
cleanup: () => {
if (mapping.keepAliveTimer) clearInterval(mapping.keepAliveTimer);
this._streamMapping.delete(this._standaloneSseStreamId);
try {
streamController.close();
} catch {
// Controller might already be closed
}
}
};

// Create a ReadableStream with a controller we can use to push SSE events
const readable = new ReadableStream<Uint8Array>({
start: controller => {
streamController = controller;
mapping.controller = controller;
},
cancel: () => {
// Stream was cancelled by client. Only drop the mapping when
// it still points at THIS controller — a stale cancel must not
// delete a successor stream registered by a later GET/resume.
if (this._streamMapping.get(this._standaloneSseStreamId)?.controller === streamController) {
if (mapping.keepAliveTimer) clearInterval(mapping.keepAliveTimer);
this._streamMapping.delete(this._standaloneSseStreamId);
}
}
Comment thread
claude[bot] marked this conversation as resolved.
Expand All @@ -489,19 +517,20 @@ export class WebStandardStreamableHTTPServerTransport implements Transport {
headers['mcp-session-id'] = this.sessionId;
}

// Store the stream mapping with the controller for pushing data
this._streamMapping.set(this._standaloneSseStreamId, {
controller: streamController!,
encoder,
cleanup: () => {
this._streamMapping.delete(this._standaloneSseStreamId);
this._streamMapping.set(this._standaloneSseStreamId, mapping);

// Start keepalive timer to send periodic SSE comments that prevent
// reverse proxies from closing the connection due to idle timeouts
if (this._keepAliveInterval !== undefined) {
mapping.keepAliveTimer = setInterval(() => {
try {
streamController!.close();
streamController.enqueue(encoder.encode(': keepalive\n\n'));
} catch {
// Controller might already be closed
// Controller is closed or errored, stop sending keepalives
if (mapping.keepAliveTimer) clearInterval(mapping.keepAliveTimer);
}
Comment thread
rcdailey marked this conversation as resolved.
}
});
}, this._keepAliveInterval);
Comment thread
rcdailey marked this conversation as resolved.
}

return new Response(readable, { headers });
}
Expand Down Expand Up @@ -551,18 +580,21 @@ export class WebStandardStreamableHTTPServerTransport implements Transport {
// replayEventsAfter resolves) — must be `let`.
// eslint-disable-next-line prefer-const
let replayedStreamId: string | undefined;
let keepAliveTimer: ReturnType<typeof setInterval> | undefined;

const readable = new ReadableStream<Uint8Array>({
start: controller => {
streamController = controller;
},
cancel: () => {
// Stream was cancelled by client — drop the mapping so a
// subsequent reconnect with the same Last-Event-ID is not
// refused with 409 by the conflict check above. Only delete
// when the mapped entry is still THIS closure's controller:
// a stale cancel from an earlier resume must not delete a
// successor resumed stream a re-poll has since registered.
// Always clear the closure-local keepalive timer; the
// mapping may already have been removed by the early-close
// block for completed requests, but the timer is still ours.
if (keepAliveTimer) clearInterval(keepAliveTimer);
// Drop the mapping so a subsequent reconnect with the same
// Last-Event-ID is not refused with 409. Only delete when
// the mapped entry is still THIS closure's controller: a
// stale cancel must not delete a successor resumed stream.
if (replayedStreamId !== undefined && this._streamMapping.get(replayedStreamId)?.controller === streamController) {
this._streamMapping.delete(replayedStreamId);
}
Expand All @@ -577,27 +609,29 @@ export class WebStandardStreamableHTTPServerTransport implements Transport {
const success = this.writeSSEEvent(streamController!, encoder, message, eventId);
if (!success) {
try {
streamController!.close();
streamController.close();
} catch {
// Controller might already be closed
}
}
}
});

this._streamMapping.set(replayedStreamId, {
const mapping: StreamMapping = {
controller: streamController!,
encoder,
replayedEventIds,
cleanup: () => {
if (keepAliveTimer) clearInterval(keepAliveTimer);
this._streamMapping.delete(replayedStreamId!);
try {
streamController!.close();
} catch {
// Controller might already be closed
}
}
});
};
this._streamMapping.set(replayedStreamId!, mapping);

// If this is a per-request stream and no in-flight request still
// targets this streamId, the request was already retired by the
Expand All @@ -618,6 +652,20 @@ export class WebStandardStreamableHTTPServerTransport implements Transport {
}
}

// Start keepalive timer for the replayed stream so reconnecting
// clients remain protected from proxy idle timeouts.
// Skip if the early-close block above already removed the mapping.
if (this._keepAliveInterval !== undefined && this._streamMapping.has(replayedStreamId!)) {
keepAliveTimer = setInterval(() => {
try {
streamController!.enqueue(encoder.encode(': keepalive\n\n'));
} catch {
if (keepAliveTimer) clearInterval(keepAliveTimer);
}
}, this._keepAliveInterval);
mapping.keepAliveTimer = keepAliveTimer;
}

return new Response(readable, { headers });
} catch (error) {
this.onerror?.(error as Error);
Expand Down Expand Up @@ -980,7 +1028,7 @@ export class WebStandardStreamableHTTPServerTransport implements Transport {
}
this._closed = true;

// Close all SSE connections
// Close all SSE connections (each cleanup() also clears its own keepAliveTimer)
for (const { cleanup } of this._streamMapping.values()) {
cleanup();
}
Expand Down
144 changes: 144 additions & 0 deletions packages/server/test/server/streamableHttp.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1406,4 +1406,148 @@ describe('Zod v4', () => {
expect(cleanupCalls).toEqual(['stream-1']);
});
});

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<string> {
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);
expect(vi.getTimerCount()).toBe(1);

// Close the transport, which should clear the interval
await transport.close();

expect(vi.getTimerCount()).toBe(0);
});
Comment thread
rcdailey marked this conversation as resolved.

it('should maintain independent keepalive timers per concurrent stream', async () => {
// Minimal event store so a GET with last-event-id triggers replayEvents()
// and creates a second StreamMapping entry concurrently with the standalone GET.
const eventStore: EventStore = {
async storeEvent(streamId) {
return `${streamId}_evt`;
},
async getStreamIdForEventId() {
return 'replay-stream';
},
async replayEventsAfter() {
return 'replay-stream';
}
};

mcpServer = new McpServer({ name: 'test-server', version: '1.0.0' });
transport = new WebStandardStreamableHTTPServerTransport({
sessionIdGenerator: () => randomUUID(),
keepAliveInterval: 50,
eventStore
});
await mcpServer.connect(transport);
const initRes = await transport.handleRequest(createRequest('POST', TEST_MESSAGES.initialize));
const sessionId = initRes.headers.get('mcp-session-id') as string;

// Stream A: standalone GET
const resA = await transport.handleRequest(createRequest('GET', undefined, { sessionId }));
expect(resA.status).toBe(200);
const readerA = resA.body!.getReader();
expect(vi.getTimerCount()).toBe(1);

// Stream B: GET with last-event-id -> replayEvents path, separate mapping key.
// Simulate an in-flight request for the replay stream so the
// early-close-for-completed-requests block keeps the stream open.
(transport as any)._requestToStreamMapping.set('fake-req', 'replay-stream');
const resB = await transport.handleRequest(
createRequest('GET', undefined, { sessionId, extraHeaders: { 'last-event-id': 'evt-1' } })
);
expect(resB.status).toBe(200);
const readerB = resB.body!.getReader();
expect(vi.getTimerCount()).toBe(2);

// Cancel stream B; its keepalive timer must be cleared without affecting A's
await readerB.cancel();
expect(vi.getTimerCount()).toBe(1);

// Stream A still receives keepalives after B is cancelled
vi.advanceTimersByTime(60);
const { value } = await readerA.read();
expect(new TextDecoder().decode(value)).toContain(': keepalive');

await readerA.cancel();
expect(vi.getTimerCount()).toBe(0);
});
});
});
Loading