Skip to content
Closed
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
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 lifecycle: deferred work cannot register streams after transport close, error cleanup preserves successor request mappings, invalid timer delays safely disable keep-alive, and SSE responses disable proxy buffering.
72 changes: 65 additions & 7 deletions src/server/webStandardStreamableHttp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -156,7 +156,8 @@
*
* 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.
* 15 seconds). Set to 0 to disable keep-alive frames; values below 1, above
* 2147483647, or non-finite values also disable the timer.
*/
keepAliveMs?: number;
}
Expand Down Expand Up @@ -227,6 +228,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 @@ -271,7 +273,11 @@
* 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. Invalid timer delays disable keep-alive rather than
// letting setInterval clamp them to ~1ms and flood every stream.
if (!Number.isFinite(this._keepAliveMs) || this._keepAliveMs < 1 || this._keepAliveMs > 2_147_483_647 || this._closed) {
return;
}
this.stopKeepAlive(streamId);
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.
Expand Down Expand Up @@ -376,6 +382,10 @@
* Returns a Response object (Web Standard)
*/
async handleRequest(req: Request, options?: HandleRequestOptions): Promise<Response> {
if (this._closed) {
return this.createJsonErrorResponse(404, -32001, 'Session not found');
}

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

View check run for this annotation

Claude / Claude Code Review

DELETE retry during slow onsessionclosed re-runs the whole teardown: _closed is committed only after the await

Pre-existing (not introduced by this PR): the new `_closed` front-door gate is inert for the entire duration of the `onsessionclosed` await in `handleDeleteRequest`, because `_closed` is only set at the top of `close()` — which runs *after* that await. A retried DELETE (idempotent per RFC 9110, so HTTP clients/proxies retry freely) arriving during a slow async `onsessionclosed` callback passes the gate and `validateSession`, and re-runs the whole teardown: `onsessionclosed`, `close()`, and `oncl
Comment on lines +385 to +387

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟣 Pre-existing (not introduced by this PR): the new _closed front-door gate is inert for the entire duration of the onsessionclosed await in handleDeleteRequest, because _closed is only set at the top of close() — which runs after that await. A retried DELETE (idempotent per RFC 9110, so HTTP clients/proxies retry freely) arriving during a slow async onsessionclosed callback passes the gate and validateSession, and re-runs the whole teardown: onsessionclosed, close(), and onclose all fire twice. One-line completion of this PR's own idiom: set _closed (or a _closing flag checked by the gate) in handleDeleteRequest before awaiting _onsessionclosed.

Extended reasoning...

The bug. handleDeleteRequest runs await Promise.resolve(this._onsessionclosed?.(this.sessionId!)) before await this.close(), and this._closed = true is committed only at the top of close(). So for the entire wall-clock duration of the user callback — which the docs pitch as the place to "clean up resources associated with the session", i.e. real apps do async DB/Redis/billing work there — the transport is mid-teardown but _closed is still false. The PR's new front-door gate (handleRequest, lines 385-387) therefore protects only the post-close() portion of teardown, not the (potentially much longer) callback portion.

The triggering code path. DELETE is defined as idempotent by RFC 9110, so HTTP clients and proxies retry it freely on timeout. A retried DELETE arriving while the first is parked on the onsessionclosed await: (1) passes the _closed gate (false), (2) passes validateSessionsessionId and _initialized are never cleared anywhere, (3) passes validateProtocolVersion, and (4) re-enters handleDeleteRequest, invoking this._onsessionclosed a second time for the same session id. Both continuations then call close(), which has no reentrancy guard, so this.onclose?.() fires twice — double-invoking Protocol._onclose and the application's onclose handler. Both DELETEs return 200.

Step-by-step proof. (1) App configures onsessionclosed that deletes session rows in a slow database. (2) Client sends DELETE; server enters handleDeleteRequest, passes all validation, parks on the callback await. _closed is still false. (3) The client's HTTP layer times out and retries the DELETE (standard retry policy for an idempotent method). (4) The retry passes the front-door gate, validateSession, and validateProtocolVersion — nothing distinguishes it from a first DELETE — and invokes onsessionclosed again for the same session. (5) Both requests proceed to close(); the second sweep is empty, but onclose?.() runs unconditionally both times. Every per-session cleanup hook the SDK exposes (onsessionclosed, transport onclose, Protocol onclose) double-runs — apps that decrement connection counters, emit per-session audit/billing events, or release external resources double-run that logic.

Why this is pre-existing rather than introduced. handleDeleteRequest is untouched by this PR, and close()'s non-idempotency predates it. Before this PR there was no _closed flag at all, so two concurrent/retried DELETEs double-ran onsessionclosed/close()/onclose identically — and post-close requests weren't rejected either. The PR strictly narrows the vulnerable window (sequential post-close DELETEs now correctly 404). This is also not a duplicate of the other findings on this PR: bug_001/bug_002 concern POST-path awaits racing close(), and the earlier front-door-gate comment concerned requests arriving strictly after close() completes; here the second request arrives before close() runs, so none of those fixes prevent the double teardown.

Impact if left as-is. No SDK-internal corruption — the second sweep over the empty maps is harmless. The harm is at the application layer: double-invocation of session-close callbacks (counters, audit, billing, external resource release), plus Protocol._onclose's response-handler rejection sweep running twice. Merging this PR as-is is strictly no worse than pre-PR behavior.

How to fix. A one-line completion of the PR's own idiom: set this._closed = true (or a dedicated _closing flag that the front-door gate also checks) at the top of handleDeleteRequest before awaiting _onsessionclosed. A retried DELETE then gets the same 404 the gate already produces after close, and the session-close callbacks are guaranteed to run exactly once. Since the maintainer has scoped broader lifecycle changes out of this targeted backport, this works equally well as an immediate follow-up.


// In stateless mode (no sessionIdGenerator), each request must use a fresh transport.
// Reusing a stateless transport causes message ID collisions between clients.
if (!this.sessionIdGenerator && this._hasHandledRequest) {
Expand Down Expand Up @@ -488,7 +498,8 @@
const headers: Record<string, string> = {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache, no-transform',
Connection: 'keep-alive'
Connection: 'keep-alive',
'X-Accel-Buffering': 'no'
};

// After initialization, always include the session ID if we have one
Expand Down Expand Up @@ -547,7 +558,8 @@
const headers: Record<string, string> = {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache, no-transform',
Connection: 'keep-alive'
Connection: 'keep-alive',
'X-Accel-Buffering': 'no'
};

if (this.sessionId !== undefined) {
Expand Down Expand Up @@ -583,6 +595,21 @@
}
});

// The transport may have closed while the replay await was parked:
// its cleanup sweep ran before this stream was registered, so
// registering now would strand a mapping (and an open controller)
// on a dead transport, hang the client on a stream that never
// ends, and 409-block a later resume of this stream id. End the
// stream instead so the client observes termination.
if (this._closed) {
try {
streamController!.close();
} catch {
// Controller might already be closed
}
return new Response(readable, { headers });
}

this._streamMapping.set(replayedStreamId, {
controller: streamController!,
encoder,
Comment thread
mattzcarey marked this conversation as resolved.
Expand Down Expand Up @@ -658,6 +685,12 @@
* Handles POST requests containing JSON-RPC messages
*/
private async handlePostRequest(req: Request, options?: HandleRequestOptions): Promise<Response> {
// Set once the SSE stream bookkeeping has been registered, so the
// catch below can reclaim it: an error after registration (a failed
// priming event write, a throwing message handler) returns an error
// response, leaving nothing that could ever cancel the discarded
// stream or retire the request mappings.
let reclaimSseBookkeeping: (() => void) | undefined;
try {
// Validate the Accept header
const acceptHeader = req.headers.get('accept');
Expand Down Expand Up @@ -750,6 +783,12 @@
}
}

// Request parsing and session initialization may await user/runtime
// work. Do not register or dispatch after close() has swept state.
if (this._closed) {
return this.createJsonErrorResponse(404, -32001, 'Session not found');
}

Check warning on line 790 in src/server/webStandardStreamableHttp.ts

View check run for this annotation

Claude / Claude Code Review

New _closed rejection paths silently 404 without invoking onerror, unlike every other rejection in the transport

The two new `_closed` rejection sites (the `handleRequest` front-door check and this post-parse re-check) return their 404 without invoking `this.onerror`, unlike every other rejection path in this file — including the semantically identical 404 `-32001` 'Session not found' in `validateSession`. This makes exactly the post-close race traffic this PR hardens against invisible to operators while indistinguishable stale-session-id 404s are still reported; adding `this.onerror?.(new Error('Session n
Comment on lines +786 to +790

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 two new _closed rejection sites (the handleRequest front-door check and this post-parse re-check) return their 404 without invoking this.onerror, unlike every other rejection path in this file — including the semantically identical 404 -32001 'Session not found' in validateSession. This makes exactly the post-close race traffic this PR hardens against invisible to operators while indistinguishable stale-session-id 404s are still reported; adding this.onerror?.(new Error('Session not found')) (or a more specific message) before each return restores the convention.

Extended reasoning...

What the bug is. The transport has a uniform convention: every request rejection reports through this.onerror?.() before returning the error Response. All ~20 rejection paths in webStandardStreamableHttp.ts follow it — the DNS-rebinding 403s, the 406/415 header checks, the 400 parse errors, the 405, the 409 conflicts, all four validateSession/validateProtocolVersion errors (including the semantically identical 404 -32001 'Session not found': this.onerror?.(new Error('Session not found'))), both replayEvents error paths, and the final POST catch. The two _closed rejection sites added by this PR are the only exceptions: the front-door check at the top of handleRequest and the post-parse re-check in handlePostRequest both return createJsonErrorResponse(404, -32001, 'Session not found') silently.

The code path that triggers it. These guards fire on precisely the races this PR's lifecycle work targets: a client GET or POST racing a DELETE in the standard session-map pattern (the app looks up the transport before onclose evicts it), or a POST whose req.json() / onsessioninitialized await straddles close() — the PR's own new test ('should not register a POST stream after close races session initialization') exercises the second site.

Why existing code doesn't hide the gap. The silence is observable, not academic. Protocol.connect() wraps transport.onerror to forward into Protocol._onerror → the application's Server.onerror (src/shared/protocol.ts:621-625), and Protocol._onclose clears _transport but never detaches the transport object's onerror property — so post-close onerror calls still reach the application handler (and any transport-level onerror installed directly, as the SDK's own tests do). Nothing in the PR suggests the suppression is intentional: the new comments explain the 404 but not the missing report, and the new tests assert only the 404 status.

Impact. An operator debugging 'clients intermittently get Session not found' gets asymmetric telemetry: the rejection appears in onerror when it comes from validateSession (stale session id presented) but not when it comes from the new _closed guards (request hit a just-closed transport) — even though the two cases are byte-for-byte indistinguishable to the client, and the second is exactly the post-close traffic this PR is about.

Step-by-step proof. (1) App uses the standard session-map pattern; client holds session S. (2) Client issues DELETE for S while its reconnect logic concurrently re-opens the standalone GET stream. (3) DELETE wins: handleDeleteRequestclose() sets _closed = true; the app's map still holds the transport until onclose runs. (4) The racing GET reaches handleRequest, hits the new _closed guard, and returns 404 — onerror never fires; the app's error handler (still wired per protocol.ts:621-625) sees nothing. (5) Compare: the same client retrying a moment later against a new transport with a stale session id gets the same 404 via validateSession — and that one is reported. Same client-visible symptom, half the telemetry.

How to fix. One line at each site, mirroring validateSession:

if (this._closed) {
    this.onerror?.(new Error('Session not found')); // or 'Request received after transport close'
    return this.createJsonErrorResponse(404, -32001, 'Session not found');
}

Severity. Nit: the client receives the correct, spec-appropriate 404 either way, cleanup is correct, and nothing functionally breaks — this is purely a diagnostics/consistency gap in the PR's new code with a one-line fix per site. It should not block merge.


// check if it contains requests
const hasRequests = messages.some(isJSONRPCRequest);

Expand Down Expand Up @@ -812,8 +851,9 @@

const headers: Record<string, string> = {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
Connection: 'keep-alive'
'Cache-Control': 'no-cache, no-transform',
Connection: 'keep-alive',
'X-Accel-Buffering': 'no'
};
Comment thread
claude[bot] marked this conversation as resolved.

// After initialization, always include the session ID if we have one
Expand Down Expand Up @@ -842,7 +882,14 @@
}
}

this.startKeepAlive(streamId, streamController!, encoder);
reclaimSseBookkeeping = () => {
this._streamMapping.get(streamId)?.cleanup();
for (const message of messages) {
if (isJSONRPCRequest(message) && this._requestToStreamMapping.get(message.id) === streamId) {
this._requestToStreamMapping.delete(message.id);
}
}
Comment thread
claude[bot] marked this conversation as resolved.
};

// 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,10 +916,19 @@
// 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
this.onerror?.(error as Error);
reclaimSseBookkeeping?.();
return this.createJsonErrorResponse(400, -32700, 'Parse error', { data: String(error) });
}
}
Comment thread
mattzcarey marked this conversation as resolved.
Expand Down Expand Up @@ -961,6 +1017,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
Loading
Loading