Skip to content
Closed
Show file tree
Hide file tree
Changes from 2 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
5 changes: 5 additions & 0 deletions .changeset/keepalive-lifecycle-hardening.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@modelcontextprotocol/sdk': patch
---

Hardens the Streamable HTTP server transport's SSE keep-alive lifecycle: keep-alive timers can no longer be armed after the transport closes or leak when a priming event write fails, and a non-finite `keepAliveMs` disables keep-alive instead of arming a clamped ~1ms interval.
20 changes: 17 additions & 3 deletions src/server/webStandardStreamableHttp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -227,6 +227,7 @@
// when sessionId is not set (undefined), it means the transport is in stateless mode
private sessionIdGenerator: (() => string) | undefined;
private _started: boolean = false;
private _closed: boolean = false;
private _hasHandledRequest: boolean = false;
private _streamMapping: Map<string, StreamMapping> = new Map();
private _requestToStreamMapping: Map<RequestId, string> = new Map();
Expand Down Expand Up @@ -268,13 +269,18 @@
* 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) {
// A deferred arm (e.g. after an event-store await that straddled
// close()) must not outlive the transport: close()'s timer sweep has
// already run. The `> 0` polarity disables keep-alive for non-finite
// values (NaN fails every comparison) instead of arming a
// Node-clamped ~1ms interval.
if (!(this._keepAliveMs > 0) || this._closed) {
return;
}
this.stopKeepAlive(streamId);

Check notice on line 283 in src/server/webStandardStreamableHttp.ts

View check run for this annotation

Claude / Claude Code Review

Replayed SSE stream's no-op cancel handler strands a stale _streamMapping entry on client disconnect, permanently 409-blocking the standalone GET stream on a live session

Pre-existing (from #2538, not introduced by this PR): `replayEvents()`'s ReadableStream `cancel` handler is an explicit no-op, unlike the GET/POST paths which do `stopKeepAlive` + `_streamMapping.delete` — so when a client disconnects from a resumed `_GET_stream` on a live session, the stale mapping entry survives forever (the keep-alive write-failure catch only stops the timer) and every subsequent plain GET gets 409 'Only one SSE stream is allowed per session', locking the client out of server
Comment thread
claude[bot] marked this conversation as resolved.
Comment thread
claude[bot] marked this conversation as resolved.
Comment thread
mattzcarey marked this conversation as resolved.
const timer = setInterval(() => {
try {
controller.enqueue(encoder.encode(': keepalive\n\n'));
Expand Down Expand Up @@ -842,8 +848,6 @@
}
}

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 thread
mattzcarey marked this conversation as resolved.

Expand All @@ -869,6 +873,14 @@
// The server SHOULD NOT close the SSE stream before sending all JSON-RPC responses
// This will be handled by the send() method when responses are ready

// Arm keep-alive only after the fallible awaits above — an error
// path returning 400 discards the Response, so nothing could ever
// cancel the stream and clear an already-armed timer. Skip if the
// responses already completed and cleaned the stream up.
if (this._streamMapping.get(streamId)?.controller === streamController!) {
this.startKeepAlive(streamId, streamController!, encoder);
Comment thread
claude[bot] marked this conversation as resolved.
}

return new Response(readable, { status: 200, headers });
} catch (error) {
// return JSON-RPC formatted error
Expand Down Expand Up @@ -961,6 +973,8 @@
}

async close(): Promise<void> {
this._closed = true;

// Close all SSE connections
this._streamMapping.forEach(({ cleanup }) => {
cleanup();
Comment thread
mattzcarey marked this conversation as resolved.
Expand Down
96 changes: 96 additions & 0 deletions test/server/streamableHttp.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3290,6 +3290,10 @@ describe('WebStandardStreamableHTTPServerTransport SSE keep-alive', () => {
});
}

function withSession(sessionId: string, extra?: Record<string, string>): Record<string, string> {
return { 'mcp-session-id': sessionId, 'mcp-protocol-version': '2025-11-25', ...extra };
}

async function createTransport(options?: { keepAliveMs?: number }): Promise<{
transport: WebStandardStreamableHTTPServerTransport;
sessionId: string;
Expand Down Expand Up @@ -3447,4 +3451,96 @@ describe('WebStandardStreamableHTTPServerTransport SSE keep-alive', () => {

await transport.close();
});

it('should disable keep-alive for a non-finite keepAliveMs instead of arming a clamped interval', async () => {
const { transport, sessionId } = await createTransport({ keepAliveMs: Number.NaN });

const response = await transport.handleRequest(req('GET', { headers: withSession(sessionId) }));
expect(response.status).toBe(200);

// No timer may be armed: setInterval(fn, NaN) would be clamped by
// Node to ~1ms and flood the stream with keep-alive frames.
expect(vi.getTimerCount()).toBe(0);
const reader = response.body!.getReader();
await vi.advanceTimersByTimeAsync(60000);
const raced = await Promise.race([reader.read(), Promise.resolve('pending')]);
expect(raced).toBe('pending');

await transport.close();
});

it('should not arm keep-alive when the transport closes during an event-store replay await', async () => {
let releaseReplay: (() => void) | undefined;
const eventStore: EventStore = {
async storeEvent(): Promise<EventId> {
return 'evt-1';
},
async replayEventsAfter(): Promise<StreamId> {
await new Promise<void>(resolve => {
releaseReplay = resolve;
});
return '_GET_stream';
}
};
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;

// Enter replayEvents and park on the replayEventsAfter await
const pendingGet = transport.handleRequest(req('GET', { headers: { ...withSession(sessionId), 'Last-Event-ID': 'evt-1' } }));
await vi.advanceTimersByTimeAsync(0);
expect(releaseReplay).toBeDefined();

// Close the transport mid-await, then let the replay continuation run
await transport.close();
releaseReplay?.();
await pendingGet;

// The deferred continuation must not have armed a timer close() can never sweep
expect(vi.getTimerCount()).toBe(0);
});

it('should not leak a keep-alive timer when the priming event write fails on a POST SSE stream', async () => {
// Healthy during initialization, then the store starts failing — the
// tool call's priming event write must reject inside handlePostRequest
let storeFails = false;
const eventStore: EventStore = {
async storeEvent(): Promise<EventId> {
if (storeFails) {
throw new Error('event store unavailable');
}
return `evt-${randomUUID()}`;
},
async replayEventsAfter(): Promise<StreamId> {
return 'stream-1';
}
};
const transport = new WebStandardStreamableHTTPServerTransport({ sessionIdGenerator: () => randomUUID(), eventStore });
const mcpServer = new McpServer({ name: 'test-server', version: '1.0.0' });
mcpServer.registerTool('noop', { description: 'noop' }, async () => ({ content: [] }));
await mcpServer.connect(transport);

const initResponse = await transport.handleRequest(req('POST', { body: TEST_MESSAGES.initialize }));
expect(initResponse.status).toBe(200);
const sessionId = initResponse.headers.get('mcp-session-id') as string;
// Let the init response finish sending (its send() stores an event and
// then cleans up the init stream's keep-alive) before failing the store
await vi.advanceTimersByTimeAsync(0);
expect(vi.getTimerCount()).toBe(0);
storeFails = true;

const response = await transport.handleRequest(
req('POST', {
body: { jsonrpc: '2.0', method: 'tools/call', params: { name: 'noop', arguments: {} }, id: 'call-1' },
headers: withSession(sessionId)
})
);
expect(response.status).toBe(400);

// The discarded stream must not carry a permanently-firing timer
expect(vi.getTimerCount()).toBe(0);

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