Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/streamable-http-sse-keepalive.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@modelcontextprotocol/sdk': patch
---

`StreamableHTTPServerTransport` and `WebStandardStreamableHTTPServerTransport` now write SSE keep-alive comment frames (`: keepalive`) to open SSE streams so idle connections (e.g. the standalone GET stream, or a POST stream during a long-running tool call) are not killed by
intermediaries or server idle timeouts. Configurable via the new `keepAliveMs` option (default 15000; set 0 to disable).
70 changes: 70 additions & 0 deletions src/server/webStandardStreamableHttp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -146,8 +146,24 @@ export interface WebStandardStreamableHTTPServerTransportOptions {
* client reconnection timing for polling behavior.
*/
retryInterval?: number;

/**
* Interval in milliseconds between SSE keep-alive comment frames (`: keepalive`)
* written to open SSE streams. Keep-alive frames prevent idle streams (e.g. the
* standalone GET stream, or a POST stream during a long-running tool call) from
* being killed by intermediaries and server idle timeouts, which clients observe
* as `SSE stream disconnected: TypeError: terminated`.
*
* Comment frames are ignored by SSE parsers and never surface as messages.
* Defaults to 15000 (per the WHATWG SSE spec recommendation of roughly every
* 15 seconds). Set to 0 to disable keep-alive frames.
*/
keepAliveMs?: number;
}

/** Default interval between SSE keep-alive comment frames. */
const DEFAULT_KEEP_ALIVE_MS = 15_000;

/**
* Options for handling a request
*/
Expand Down Expand Up @@ -225,6 +241,8 @@ export class WebStandardStreamableHTTPServerTransport implements Transport {
private _allowedOrigins?: string[];
private _enableDnsRebindingProtection: boolean;
private _retryInterval?: number;
private _keepAliveMs: number;
private _keepAliveTimers: Map<string, ReturnType<typeof setInterval>> = new Map();

sessionId?: string;
onclose?: () => void;
Expand All @@ -241,6 +259,43 @@ export class WebStandardStreamableHTTPServerTransport implements Transport {
this._allowedOrigins = options.allowedOrigins;
this._enableDnsRebindingProtection = options.enableDnsRebindingProtection ?? false;
this._retryInterval = options.retryInterval;
this._keepAliveMs = options.keepAliveMs ?? DEFAULT_KEEP_ALIVE_MS;
}

/**
* Arms a keep-alive interval for an SSE stream that periodically writes an SSE
* comment frame so intermediaries and idle timeouts don't kill the connection.
* Replaces any timer already armed for the same stream id (a resumed stream
* re-registered under the same id supersedes its predecessor's timer). The
* timer is cleared via stopKeepAlive when the stream is cleaned up, and
* clears itself if a write fails (stream already closed/cancelled).
*/
private startKeepAlive(streamId: string, controller: ReadableStreamDefaultController<Uint8Array>, encoder: TextEncoder): void {
if (this._keepAliveMs <= 0) {
return;
}
this.stopKeepAlive(streamId);
const timer = setInterval(() => {
try {
controller.enqueue(encoder.encode(': keepalive\n\n'));
} catch {
this.stopKeepAlive(streamId);
}
}, this._keepAliveMs);
// Don't let the keep-alive timer hold the process open (Node.js only)
(timer as { unref?: () => void }).unref?.();
this._keepAliveTimers.set(streamId, timer);
}
Comment on lines +273 to +288

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔴 startKeepAlive() registers a new interval with this._keepAliveTimers.set(streamId, timer) without clearing any existing timer for that streamId, so a second call for the same stream orphans the first interval — reachable via replayEvents(), where the 409 conflict check is skipped whenever the EventStore doesn't implement the optional getStreamIdForEventId (including the SDK's own InMemoryEventStore). On a reconnect the orphaned timer's write eventually throws and its catch calls stopKeepAlive(streamId), clearing the live resumed stream's timer — silently disabling keep-alive on exactly the resumability path this PR targets, while leaking one interval per reconnect. Fix: call this.stopKeepAlive(streamId) at the top of startKeepAlive().

Extended reasoning...

The bug

startKeepAlive() (src/server/webStandardStreamableHttp.ts:271-285) unconditionally does this._keepAliveTimers.set(streamId, timer). If a timer is already registered under that streamId, the old setInterval handle is overwritten in the map but never cleared. Since both stopKeepAlive() and close() only clear timers currently in the map, the old interval becomes permanently unreachable and runs for the life of the process.

The code path that triggers it

The trigger is the replay path, and it's realistic:

  1. replayEvents() only performs its 409 "Stream already has an active connection" check when the EventStore implements the optional getStreamIdForEventId() (lines 529-542). The SDK's own InMemoryEventStore (src/examples/shared/inMemoryEventStore.ts) does not implement it, so servers built on it skip the conflict check entirely.
  2. The replay stream's ReadableStream.cancel() handler is a no-op (// Cleanup will be handled by the mapping, lines 562-565) — so when the client disconnects, the mapping stays and the keep-alive timer stays armed.
  3. Reconnecting with a Last-Event-ID from the same original stream re-enters replayEvents() with the same replayedStreamId. The second pass overwrites the _streamMapping entry (pre-existing behavior) and startKeepAlive(replayedStreamId, …) overwrites the timer entry (new in this PR), orphaning timer A.

Why nothing prevents it

The only defenses are the 409 check (skipped without getStreamIdForEventId) and cleanup on cancel (a no-op on the replay stream). The other call sites happen to be safe — the standalone GET has its own 409 guard and a cancel handler that calls stopKeepAlive, and POST streams use a fresh crypto.randomUUID() — so the flaw is confined to, but reliably triggered on, the replay path.

Impact

Two concrete failures per reconnect cycle:

  • Leaked interval: orphaned timer A keeps firing every 15s forever, enqueueing : keepalive into an abandoned controller (or throwing, see below). Leaks accumulate unboundedly across reconnects in a long-lived session.
  • Keep-alive silently disabled on the live stream: once the abandoned stream's controller is cancelled, timer A's enqueue throws and its catch calls this.stopKeepAlive(streamId) — which clears timer B, the live resumed stream's keep-alive (they share the same streamId key). The resumed stream then goes idle and gets killed by the very proxy/idle timeouts this PR exists to defeat — reintroducing SSE stream disconnected: TypeError: terminated #1211 precisely on the resumability path.

Step-by-step proof

  1. Server uses eventStore: new InMemoryEventStore() (no getStreamIdForEventId). Client holds an event ID from stream S.
  2. Client sends GET with Last-Event-IDreplayEvents() runs, no 409 check, maps S, arms timer A for S.
  3. Client's connection drops (proxy timeout, network blip). The replay stream's cancel() is a no-op → mapping for S and timer A both survive; the controller is now cancelled.
  4. Client reconnects with a Last-Event-ID still resolving to SreplayEvents() runs again, maps S (overwrite), calls startKeepAlive(S, controllerB, …)_keepAliveTimers.set(S, timerB) overwrites timer A without clearing it.
  5. ≤15s later, timer A fires: controllerA.enqueue() throws (cancelled stream) → catchstopKeepAlive(S)clearInterval(timerB) and deletes the map entry. The live resumed stream now has no keep-alive; timer A keeps firing and throwing forever (subsequent stopKeepAlive(S) calls are no-ops).
  6. Each further reconnect repeats steps 2-5, orphaning one more permanent interval.

Fix

One line — make startKeepAlive idempotent per stream:

private startKeepAlive(streamId: string, controller: ..., encoder: TextEncoder): void {
    if (this._keepAliveMs <= 0) {
        return;
    }
    this.stopKeepAlive(streamId);   // clear any existing timer for this stream
    const timer = setInterval(() => { ... });
    ...
}

This is severity-normal rather than a nit because the failure isn't just a resource leak: the PR's own fix silently stops working in its target scenario (long-lived sessions behind idle-killing intermediaries, using resumability), and nothing surfaces the breakage — the client just resumes seeing SSE stream disconnected: TypeError: terminated.


/**
* Clears the keep-alive interval for a stream, if one is armed.
*/
private stopKeepAlive(streamId: string): void {
const timer = this._keepAliveTimers.get(streamId);
if (timer !== undefined) {
clearInterval(timer);
this._keepAliveTimers.delete(streamId);
}
}

/**
Expand Down Expand Up @@ -425,6 +480,7 @@ export class WebStandardStreamableHTTPServerTransport implements Transport {
},
cancel: () => {
// Stream was cancelled by client
this.stopKeepAlive(this._standaloneSseStreamId);
this._streamMapping.delete(this._standaloneSseStreamId);
}
});
Expand All @@ -445,6 +501,7 @@ export class WebStandardStreamableHTTPServerTransport implements Transport {
controller: streamController!,
encoder,
cleanup: () => {
this.stopKeepAlive(this._standaloneSseStreamId);
this._streamMapping.delete(this._standaloneSseStreamId);
try {
streamController!.close();
Expand All @@ -454,6 +511,8 @@ export class WebStandardStreamableHTTPServerTransport implements Transport {
}
});

this.startKeepAlive(this._standaloneSseStreamId, streamController!, encoder);

return new Response(readable, { headers });
}

Expand Down Expand Up @@ -528,6 +587,7 @@ export class WebStandardStreamableHTTPServerTransport implements Transport {
controller: streamController!,
encoder,
cleanup: () => {
this.stopKeepAlive(replayedStreamId);
this._streamMapping.delete(replayedStreamId);
try {
streamController!.close();
Expand All @@ -537,6 +597,8 @@ export class WebStandardStreamableHTTPServerTransport implements Transport {
}
});

this.startKeepAlive(replayedStreamId, streamController!, encoder);

return new Response(readable, { headers });
} catch (error) {
this.onerror?.(error as Error);
Expand Down Expand Up @@ -743,6 +805,7 @@ export class WebStandardStreamableHTTPServerTransport implements Transport {
},
cancel: () => {
// Stream was cancelled by client
this.stopKeepAlive(streamId);
this._streamMapping.delete(streamId);
}
});
Expand All @@ -766,6 +829,7 @@ export class WebStandardStreamableHTTPServerTransport implements Transport {
controller: streamController!,
encoder,
cleanup: () => {
this.stopKeepAlive(streamId);
this._streamMapping.delete(streamId);
try {
streamController!.close();
Expand All @@ -778,6 +842,8 @@ export class WebStandardStreamableHTTPServerTransport implements Transport {
}
}

this.startKeepAlive(streamId, streamController!, encoder);

// Write priming event if event store is configured (after mapping is set up)
await this.writePrimingEvent(streamController!, encoder, streamId, clientProtocolVersion);
Comment on lines +842 to 845

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔴 In the POST SSE path, startKeepAlive(streamId, ...) is armed immediately before await this.writePrimingEvent(...), which can reject when eventStore.storeEvent() fails; the rejection lands in the outer catch, which returns a 400 without calling stopKeepAlive(streamId) — and since the Response(readable) is never returned, the stream has no consumer, so the timer's enqueue never throws and its self-cleanup catch never fires. Each failed POST leaks a permanently-firing interval (fresh UUID streamId per request) that enqueues : keepalive into an unconsumed queue every 15s until transport close. Fix: arm the keep-alive only after writePrimingEvent succeeds, or call stopKeepAlive(streamId)/the mapping cleanup in the catch before returning the error response.

Extended reasoning...

The bug

In handlePostRequest's SSE branch, this.startKeepAlive(streamId, streamController!, encoder) (line 842) is called immediately before await this.writePrimingEvent(streamController!, encoder, streamId, clientProtocolVersion) (line 845). writePrimingEvent awaits this._eventStore.storeEvent(streamId, {}) — an async call into user-provided storage (Redis, a database, etc.) that can reject under transient failure. When it does, the rejection propagates to the outer catch (lines 870–874), which returns a 400 JSON error response without calling stopKeepAlive(streamId) or running the stream mapping's cleanup(). The interval armed three lines earlier keeps firing forever.

Why none of the PR's safety nets catch this

The PR has three cleanup mechanisms for keep-alive timers, and this path defeats all of them:

  1. The timer's self-cleanup catch requires controller.enqueue() to throw. But the Response(readable) is never returned to the caller on this path — the 400 error response is returned instead — so the ReadableStream has no consumer and is never closed, errored, or cancelled. Per the Streams spec, enqueue() on a live controller always succeeds (the internal queue is unbounded; desiredSize just goes negative). The : keepalive chunk is appended to an unconsumed queue every 15s, forever, and the catch never fires.
  2. cancel() / mapping cleanup() never run: nobody holds the response, so cancel() can't be invoked; and cleanup() is only reached via send()'s all-responses-ready branch or the closeSSEStream callbacks built in the onmessage dispatch loop (line 848+) — which is after the throwing await, so no handler ever receives these messages and no response is ever produced for these request ids.
  3. transport.close() does clear _keepAliveTimers, but only at session end — a stateful session can live for hours or days.

Note that unref() only prevents the timer from holding the Node process open; it does not stop the interval from firing or free anything.

Impact

Each failed POST-with-request arms a new interval under a fresh crypto.randomUUID() streamId, so leaks accumulate one per failed request. A client retry loop against a flapping event store leaks an interval per retry, each retaining its controller, encoder, and an ever-growing chunk queue — an unbounded timer + memory leak on a long-lived transport. This is new in this PR: pre-PR, a storeEvent rejection on this path leaked only inert _streamMapping/_requestToStreamMapping entries; the actively-firing interval and unbounded enqueue are introduced here.

Step-by-step proof

  1. Server: stateful transport with eventStore backed by Redis; Redis has a transient outage.
  2. Client (protocol version >= 2025-11-25, so writePrimingEvent actually calls storeEvent) POSTs a tools/call request.
  3. handlePostRequest reaches the SSE branch: creates readable, sets the stream mapping, calls startKeepAlive(streamId, ...) — interval armed.
  4. await this.writePrimingEvent(...)await this._eventStore.storeEvent(...) rejects.
  5. Outer catch returns createJsonErrorResponse(400, -32700, 'Parse error', ...). The readable is abandoned with zero consumers; no stopKeepAlive, no cleanup().
  6. Every 15s the interval enqueues : keepalive into the unconsumed queue; enqueue succeeds, so the self-cleanup catch never runs.
  7. Client retries → steps 3–6 repeat with a fresh UUID → a second leaked interval. Repeat for the duration of the outage.
  8. All leaked intervals fire until transport.close(), potentially hours/days later.

Why this is distinct from the earlier claude[bot] finding

The timeline comment describes the replay-path same-streamId timer overwrite; its suggested fix — stopKeepAlive(streamId) at the top of startKeepAlive() — does not fix this bug, because here every leaked timer has a fresh UUID streamId that is never passed to startKeepAlive again.

Fix

Either arm the keep-alive only after writePrimingEvent succeeds (moving line 842 below line 845 — the other two call sites already follow this arm-last pattern), or wrap the post-arm section so the catch calls stopKeepAlive(streamId) (or the mapping's cleanup()) before returning the error response.


Expand Down Expand Up @@ -901,6 +967,10 @@ export class WebStandardStreamableHTTPServerTransport implements Transport {
});
this._streamMapping.clear();

// Clear any keep-alive timers not already cleared by stream cleanup
this._keepAliveTimers.forEach(timer => clearInterval(timer));
this._keepAliveTimers.clear();

// Clear any pending responses
this._requestResponseMap.clear();
this.onclose?.();
Expand Down
176 changes: 176 additions & 0 deletions test/server/streamableHttp.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3272,3 +3272,179 @@ describe('WebStandardStreamableHTTPServerTransport - onerror callback', () => {
await storeTransport.close();
});
});

describe('WebStandardStreamableHTTPServerTransport SSE keep-alive', () => {
/** Shorthand to build a Web Standard Request for direct transport testing. */
function req(method: string, opts?: { body?: unknown; headers?: Record<string, string> }): Request {
const headers: Record<string, string> = { ...opts?.headers };
if (method === 'POST') {
headers['Accept'] ??= 'application/json, text/event-stream';
headers['Content-Type'] ??= 'application/json';
} else if (method === 'GET') {
headers['Accept'] ??= 'text/event-stream';
}
return new Request('http://localhost/mcp', {
method,
headers,
body: opts?.body !== undefined ? JSON.stringify(opts.body) : undefined
});
}

async function createTransport(options?: { keepAliveMs?: number }): Promise<{
transport: WebStandardStreamableHTTPServerTransport;
sessionId: string;
}> {
const transport = new WebStandardStreamableHTTPServerTransport({ sessionIdGenerator: () => randomUUID(), ...options });
await new McpServer({ name: 'test-server', version: '1.0.0' }).connect(transport);
const initResponse = await transport.handleRequest(req('POST', { body: TEST_MESSAGES.initialize }));
expect(initResponse.status).toBe(200);
return { transport, sessionId: initResponse.headers.get('mcp-session-id') as string };
}

beforeEach(() => {
vi.useFakeTimers();
});

afterEach(() => {
vi.useRealTimers();
});

it('should write keep-alive comment frames to an idle standalone GET stream', async () => {
const { transport, sessionId } = await createTransport();

const response = await transport.handleRequest(
req('GET', { headers: { 'mcp-session-id': sessionId, 'mcp-protocol-version': '2025-11-25' } })
);
expect(response.status).toBe(200);

const reader = response.body!.getReader();
await vi.advanceTimersByTimeAsync(15000);
const { value } = await reader.read();
expect(new TextDecoder().decode(value)).toBe(': keepalive\n\n');

await transport.close();
});

it('should honor a custom keepAliveMs interval', async () => {
const { transport, sessionId } = await createTransport({ keepAliveMs: 1000 });

const response = await transport.handleRequest(
req('GET', { headers: { 'mcp-session-id': sessionId, 'mcp-protocol-version': '2025-11-25' } })
);
const reader = response.body!.getReader();

await vi.advanceTimersByTimeAsync(3000);
let received = '';
for (let i = 0; i < 3; i++) {
const { value } = await reader.read();
received += new TextDecoder().decode(value);
}
expect(received).toBe(': keepalive\n\n'.repeat(3));

await transport.close();
});

it('should not write keep-alive frames when keepAliveMs is 0', async () => {
const { transport, sessionId } = await createTransport({ keepAliveMs: 0 });

const response = await transport.handleRequest(
req('GET', { headers: { 'mcp-session-id': sessionId, 'mcp-protocol-version': '2025-11-25' } })
);
const reader = response.body!.getReader();

await vi.advanceTimersByTimeAsync(60000);
const read = reader.read();
const raced = await Promise.race([read, Promise.resolve('pending')]);
expect(raced).toBe('pending');

await transport.close();
});

it('should stop keep-alive frames after the stream is closed', async () => {
const { transport, sessionId } = await createTransport();

const response = await transport.handleRequest(
req('GET', { headers: { 'mcp-session-id': sessionId, 'mcp-protocol-version': '2025-11-25' } })
);
const reader = response.body!.getReader();

await transport.close();
const { done } = await reader.read();
expect(done).toBe(true);

// Advancing time after close must not throw or fire further writes
expect(vi.getTimerCount()).toBe(0);
await vi.advanceTimersByTimeAsync(60000);
});

it('should write keep-alive frames on a POST SSE stream while a request is pending', async () => {
const transport = new WebStandardStreamableHTTPServerTransport({ sessionIdGenerator: () => randomUUID() });
const mcpServer = new McpServer({ name: 'test-server', version: '1.0.0' });
let resolveTool: (() => void) | undefined;
mcpServer.tool('slow', async () => {
await new Promise<void>(resolve => {
resolveTool = resolve;
});
return { content: [{ type: 'text', text: 'done' }] };
});
await mcpServer.connect(transport);

const initResponse = await transport.handleRequest(req('POST', { body: TEST_MESSAGES.initialize }));
const sessionId = initResponse.headers.get('mcp-session-id') as string;

const response = await transport.handleRequest(
req('POST', {
body: { jsonrpc: '2.0', method: 'tools/call', params: { name: 'slow', arguments: {} }, id: 'call-1' },
headers: { 'mcp-session-id': sessionId, 'mcp-protocol-version': '2025-11-25' }
})
);
expect(response.status).toBe(200);
const reader = response.body!.getReader();

await vi.advanceTimersByTimeAsync(15000);
const { value } = await reader.read();
expect(new TextDecoder().decode(value)).toBe(': keepalive\n\n');

resolveTool?.();
await transport.close();
});

it('should supersede the previous keep-alive timer when a replayed stream re-registers under the same stream id', async () => {
// Event store WITHOUT the optional getStreamIdForEventId — the replay
// path then skips its 409 conflict check, so a reconnect re-registers
// the same stream id. The predecessor's timer must be replaced, not
// orphaned (an orphaned timer's failing write would clear the live
// stream's keep-alive via stopKeepAlive on the shared stream id).
const eventStore: EventStore = {
async storeEvent(): Promise<EventId> {
return 'evt-1';
},
async replayEventsAfter(): Promise<StreamId> {
return 'stream-1';
}
};
const transport = new WebStandardStreamableHTTPServerTransport({ sessionIdGenerator: () => randomUUID(), eventStore });
await new McpServer({ name: 'test-server', version: '1.0.0' }).connect(transport);
const initResponse = await transport.handleRequest(req('POST', { body: TEST_MESSAGES.initialize }));
const sessionId = initResponse.headers.get('mcp-session-id') as string;

const replayHeaders = { 'mcp-session-id': sessionId, 'mcp-protocol-version': '2025-11-25', 'Last-Event-ID': 'evt-1' };
const first = await transport.handleRequest(req('GET', { headers: replayHeaders }));
expect(first.status).toBe(200);

// Reconnect with the same Last-Event-ID — re-registers 'stream-1'
const second = await transport.handleRequest(req('GET', { headers: replayHeaders }));
expect(second.status).toBe(200);

// Exactly one keep-alive timer must remain armed (plus none orphaned)
expect(vi.getTimerCount()).toBe(1);

// The live (second) stream still receives keep-alive frames
const reader = second.body!.getReader();
await vi.advanceTimersByTimeAsync(15000);
const { value } = await reader.read();
expect(new TextDecoder().decode(value)).toBe(': keepalive\n\n');

await transport.close();
});
});
Loading