fix: send SSE keep-alive comment frames from WebStandardStreamableHTTPServerTransport#2541
fix: send SSE keep-alive comment frames from WebStandardStreamableHTTPServerTransport#2541mattzcarey wants to merge 7 commits into
Conversation
…PServerTransport Idle SSE streams (the standalone GET stream in particular, but also POST response streams during long-running tool calls) are killed by intermediaries and server idle timeouts, which clients observe as "SSE stream disconnected: TypeError: terminated" followed by a reconnect loop. The transport now writes an SSE comment frame (`: keepalive`) to every open SSE stream every keepAliveMs milliseconds (default 15000, per the WHATWG SSE spec recommendation; set 0 to disable). Comment frames are dropped by SSE parsers and never surface as protocol messages. The timer is unref'd so it never holds the process open, and is cleared on stream cleanup/cancel and transport close. Naming matches the existing keepAliveMs on createMcpHandler's subscriptions/listen streams. v2 port of #2538 (v1.x); refs #1211
🦋 Changeset detectedLatest commit: 8a6a55a The changes in this PR will be included in the next version bump. This PR includes changesets to release 10 packages
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
@modelcontextprotocol/client
@modelcontextprotocol/codemod
@modelcontextprotocol/core
@modelcontextprotocol/server
@modelcontextprotocol/server-legacy
@modelcontextprotocol/express
@modelcontextprotocol/fastify
@modelcontextprotocol/hono
@modelcontextprotocol/node
commit: |
There was a problem hiding this comment.
Beyond the inline findings, I also checked the candidate concern that the keepAliveMs: 0 disable test is vacuous (Promise.race against an already-resolved promise) — it is not: race subscribes to reader.read() first, so when a frame is buffered its already-resolved promise's reaction is queued ahead of Promise.resolve('pending') and the test would correctly fail if frames were written.
Extended reasoning...
Bugs were found and posted as inline comments (keep-alive timer armed before the fallible writePrimingEvent await, and the close-during-replayEventsAfter race), so human review is already signalled. This note only records the one additional candidate examined and ruled out this run — the supposed vacuity of the keepAliveMs: 0 test's Promise.race — which was verified to be a sound test via microtask reaction ordering. Nothing here should be read as a guarantee of correctness of the rest of the diff.
- startKeepAlive is a no-op after transport close: a deferred arm (e.g. resuming after an event-store replay await that straddled close()) would otherwise create a timer that close()'s sweep can never clear. - The POST SSE path arms keep-alive after the fallible awaits (priming event write, message dispatch) instead of before them, so an error path that discards the Response cannot leak a permanently-firing timer against a stream nothing can cancel. - createMcpHandler's keepAliveMs now also reaches the legacy stateless fallback's per-request transport, instead of governing only subscriptions/listen streams while the legacy leg silently used the transport default. - Documents the keep-alive behavior and the 'SSE stream disconnected: TypeError: terminated' symptom in docs/troubleshooting.md.
…iew round 2 - PerRequestHTTPServerTransport gains keepAliveMs (default 15000, 0 disables): while an exchange's SSE stream is open, an interval drives writeCommentFrame so a long-running handler with no mid-call output doesn't idle past intermediary/server timeouts — the same failure the session transport fix targets, previously unaddressed on the modern serving path. Threaded from createMcpHandler through invoke(), so the handler's keepAliveMs now uniformly covers listen streams, modern per-request exchanges, and the legacy stateless fallback. - Keep-alive guards use the > 0 polarity (matching listenRouter) so a non-finite keepAliveMs disables keep-alive instead of arming a Node- clamped ~1ms interval. - The close-during-replay regression test resumes the standalone GET stream so the continuation genuinely reaches the keep-alive arm (mutation-verified: removing the _closed guard now fails the test). - Reworded the two stale legacy-fallback doc blocks that still claimed the transport is constructed 'with only sessionIdGenerator: undefined', documented legacyStatelessFallback's transportOptions parameter, and scoped the troubleshooting entry to match actual coverage.
…sport too The round-2 polarity fix was reverted from WebStandardStreamableHTTP- ServerTransport by an overzealous checkout during mutation testing and shipped only in PerRequestHTTPServerTransport. The guard now uses !(keepAliveMs > 0) at both sites, so a non-finite value disables keep-alive on every leg instead of arming a Node-clamped ~1ms interval on the session transport while the sibling guards disable. Mirrors the non-finite regression test into the streamableHttp suite.
There was a problem hiding this comment.
Beyond the inline findings, I verified at head c9b0215 that the round-4 fixes genuinely landed this time (given the earlier accidental revert): the NaN-safe !(keepAliveMs > 0) guard is present in both streamableHttp.ts:306 and perRequestTransport.ts:427 (matching listenRouter.ts:221), and the replay-await regression test's mock now returns _GET_stream, so it genuinely reaches the keep-alive arm and exercises the closed-transport guard. I also checked the timer lifecycle for leak paths beyond the inline findings: stopKeepAlive is wired into every stream cleanup/cancel path (standalone GET, replay, POST), and close() sweeps the timer map after stream cleanup — no unswept path found for finite in-domain intervals.
Extended reasoning...
This run's five findings are all nit-severity (test-coverage gaps, an Infinity edge on an operator-config input, a pre-existing error-code mapping a new test cements, a missing X-Accel-Buffering header on pre-existing response sites, and a docs misattribution) and are already posted inline. This note records the concrete items examined and ruled out on top of them, chiefly re-verifying that the previously-claimed-then-reverted guard fix is actually present at the current head — informational only, not a correctness guarantee. The PR touches transport lifecycle code across four review rounds, so it stays with the inline findings and human judgment rather than an approval.
- reclaim stream bookkeeping and end replay streams after close races - reject invalid timer delays across all handler serving legs - disable proxy buffering and correct timeout troubleshooting guidance - pin createMcpHandler option forwarding and lifecycle behavior with tests
There was a problem hiding this comment.
Beyond the inline findings, this run also examined whether the round-4 troubleshooting correction (client body-idle timeout, not server.requestTimeout) needed to propagate into the keepAliveMs JSDoc and the changeset, which still say "intermediaries and server idle timeouts" — ruled out: that prose names the client-observed symptom (TypeError: terminated) and the intermediary/watchdog class generically, without repeating the specific incorrect server.requestTimeout attribution the docs fix removed.
Extended reasoning...
This run's findings (the pre-existing handlePostRequest close-race gap and the two nits) are already posted inline, so no summary of them belongs here. The one candidate examined and refuted this run was a claimed inconsistency between the corrected troubleshooting entry and the keepAliveMs JSDoc/changeset wording; verifiers concluded the surviving prose is symptom-level and accurate (it does not re-assert the refuted server.requestTimeout mechanism), so it is not a defect. Recording it so a later pass does not re-litigate the wording after the round-4 doc correction.
Share the default, safe timer range, and Node unref behavior across session, per-request, and listen streams. Reject sub-millisecond delays that JavaScript timers clamp to 1ms, and clarify when auto-mode exchanges can emit heartbeats.
|
Quality review follow-up in
The refactor removes duplicated policy while preserving each transport's distinct teardown ownership. Local server suite is 472/472 and full workspace pre-push build/typecheck/lint passed. |
Prevent post-await work from registering streams after close, preserve successor request mappings during failed priming cleanup, align anti-buffering headers, and classify the new public API as a minor change.
There was a problem hiding this comment.
Beyond the inline findings, this run also examined a close() racing the writePrimingEvent await on the POST SSE leg and ruled it out: the stream mapping is registered before that await, so close()'s sweep runs its cleanup (controller closed, mapping deleted, timer stopped); the continuation's keep-alive arm is skipped by the registration identity check at streamableHttp.ts:1016, and the client observes stream end rather than a hang.
Extended reasoning...
Bugs were found this round (two nits, one pre-existing), so the inline comments carry the substantive feedback and no approval/deferral body is warranted. This note records one concrete close-race candidate that was examined and refuted — the writePrimingEvent-await window in handlePostRequest — since prior rounds repeatedly surfaced close-race variants in this function and this record should keep a later pass from re-exploring it. Verified against the current code: registration precedes the await, close()'s sweep closes the controller and deletes the mapping, and the post-await keep-alive arm identity-checks the mapping (startKeepAlive is additionally _closed-guarded). This is informational only, not a correctness guarantee, and is distinct from the filed nit about the earlier req.json() window (session mint + onsessioninitialized firing before the 404).
| } | ||
| } | ||
|
|
||
| // 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, -32_001, 'Session not found'); | ||
| } |
There was a problem hiding this comment.
🟡 The round-5 _closed re-check at streamableHttp.ts:873-877 runs after the initialization side effects, so a transport.close() that completes while await req.json() (line ~806) is parked still lets the continuation mint this.sessionId, set _initialized = true, and invoke the user's onsessioninitialized callback on a dead transport before returning 404 — leaving a permanent phantom entry in the callback's documented transports[sessionId] = transport registry pattern (onclose already fired while sessionId was still undefined, and DELETE is unreachable post-close). Fix: hoist a _closed re-check before the initialization branch (before line ~849), keeping the existing check for the onsessioninitialized-await window.
Extended reasoning...
What the bug is. The round-5 fix (8a6a55a) added a closed-transport re-check to handlePostRequest with the comment "Request parsing and session initialization may await user/runtime work. Do not register or dispatch after close() has swept state", and the PR thread states the function now "re-checks _closed after request/session-initialization awaits and before any stream/request registration or dispatch". But the check sits at streamableHttp.ts:870-877 — after the initialization branch. The suspension points and side effects run in this order: await req.json() (:806) → re-init guard (:841) → this.sessionId = this.sessionIdGenerator?.() (:849) → :850) → this._initialized = true (await Promise.resolve(this._onsessioninitialized(this.sessionId)) (:854-855) → the new :875). So the check covers only the second await window (close landing while _closed check (onsessioninitialized itself is parked — the scenario the new regression test pins). The first window is still open: a close completing while the body read is parked lets the continuation mutate the closed transport and invoke the user's callback for a session that can never serve, and only then return 404.\n\nStep-by-step proof.\n1. A client POSTs initialize on a fresh sessionful transport with onsessioninitialized configured; handlePostRequest parks on await req.json() — a real network body read.\n2. transport.close() completes concurrently (programmatic shutdown, handler teardown). Its once-only sweep runs and fires onclose. Crucially, close() (:1105+) clears neither _initialized nor sessionId, and at this instant sessionId is still undefined — so a consumer's keyed cleanup in onclose (delete transports[transport.sessionId]) is a no-op.\n3. The POST continuation resumes: messages parse, isInitializationRequest is true, the re-init guard passes (_initialized is still false), and the continuation mints this.sessionId (:849), sets _initialized = true (:850), and invokes the user's onsessioninitialized callback (:854-855).\n4. Only now does the :875 check run and return 404.\n\nWhy existing code doesn't prevent it. The handleRequest entry guard (:416) only rejects requests arriving after close; this POST entered before. The :875 check runs after the side effects. Nothing between await req.json() and :849 consults _closed. And no later cleanup path can reach the consumer: close() is re-entrancy-guarded so onclose never re-fires, and onsessionclosed only fires from handleDeleteRequest, which the :416 guard makes unreachable on a closed transport (404 first).\n\nImpact. onsessioninitialized's own JSDoc documents its purpose as registering and tracking multiple MCP sessions — the canonical pattern stores transports[sessionId] = transport and cleans up keyed on the session id in onclose/onsessionclosed. In this race the registry gains a live session id mapped to a dead transport, with no callback left that could ever remove it: a permanent server-side leak per occurrence, and any client request routed via that entry hits a closed transport and 404s. This matches the repo's Async/Lifecycle recurring catch (deferred continuations must check closed state before mutating this._* or invoking callbacks).\n\nWhy nit rather than blocking. The trigger requires a programmatic close() racing an in-flight initialize body read on a sessionful transport with onsessioninitialized set — a narrow window (the legacy-fallback abort-teardown path does hit close-during-req.json(), but it is stateless with no callback, so it is harmless there). The client observes a clean 404 and simply re-initializes; the consequence is a bounded registry-entry leak plus a misleading callback invocation, not a hang or protocol failure. Note the mint-before-check ordering predates this PR — what the PR added is the guard whose placement leaves this half of the window open while the PR discussion describes it as covering the initialization awaits.\n\nHow to fix. Hoist a _closed re-check to just before the initialization branch (right after message parsing at ~:836, or at minimum before :849), mirroring the guard the PR already added, so a closed transport can neither mint a session id nor invoke the registration callback. Keep the existing :875 check for the onsessioninitialized-await window. A regression test can mirror the existing 'should not register a POST stream after close races session initialization' test but park the body read (e.g. a ReadableStream request body released after close()) and assert onsessioninitialized was never called and transport.sessionId stays undefined.
| writeNotification(note.method, note.params); | ||
| }); | ||
|
|
||
| if (keepAliveMs > 0) { | ||
| keepAliveTimer = setInterval(() => writeFrame(': keepalive\n\n'), keepAliveMs); | ||
| // Do not hold the event loop open on idle subscriptions. Node's | ||
| // setInterval returns a Timeout with .unref(); browsers/Workers | ||
| // return a number — the cast is an environment shim, not a | ||
| // workaround for SDK typing. | ||
| (keepAliveTimer as { unref?: () => void }).unref?.(); | ||
| } | ||
| keepAliveTimer = armSseKeepAlive(keepAliveMs, () => writeFrame(': keepalive\n\n')); | ||
|
|
||
| open.add(teardown); | ||
| }, |
There was a problem hiding this comment.
🟡 In listenRouter's teardown, closed = true is flipped and then the user-supplied bus's unsubscribe?.() is called unguarded before clearInterval(keepAliveTimer), abortCleanup?.(), open.delete(teardown), and controller.close() — so a throwing unsubscribe from a custom ServerEventBus (e.g. a disconnected pub/sub backend) permanently leaks the keep-alive timer this PR arms via armSseKeepAlive and a maxSubscriptions slot, and one such throw propagates out of closeAll(), aborting graceful close of every other subscription. The teardown ordering itself is pre-existing (this PR only rewrote the adjacent timer-arm line), but a one-line try/finally around unsubscribe?.() (or reordering it last) closes the gap; the same pattern applies to the pre-existing handleDeleteRequest site in streamableHttp.ts, where a throwing onsessionclosed skips close() entirely.
Extended reasoning...
What the bug is. teardown in createListenRouter (listenRouter.ts:172-201) runs, in order: closed = true → unsubscribe?.() → clearInterval(keepAliveTimer) → abortCleanup?.() → open.delete(teardown) → controller.close(). unsubscribe is whatever bus.subscribe(...) returned, and bus is the user-supplied CreateMcpHandlerOptions.bus extension point, explicitly documented for multi-process deployments to implement over their own pub/sub backend. If that user code throws (a disconnected Redis client is the canonical case), the throw skips all four remaining steps. Because closed was flipped before the fallible call, the re-entry guard (if (closed) return, line 173) makes teardown permanently uncompletable: no retry — not a later stream cancel, not the abort listener, not closeAll() — can ever run the skipped steps.
Concrete consequences. (a) The keep-alive interval this PR arms at line 222 via armSseKeepAlive fires forever — writeFrame no-ops since closed is true, but the timer is never cleared, directly contradicting the changeset's claim that timers are "stopped on cancel/finalization/close". (b) The open-set entry leaks, permanently consuming one maxSubscriptions slot (default 1024); enough throwing teardowns and every new subscriptions/listen is refused with -32603 'Subscription limit reached'. (c) The abort listener and stream controller leak. (d) Worst of the compounding effects: closeAll() iterates for (const teardown of open) teardown(true) with no try/catch, and createMcpHandler's close() calls it synchronously before closing in-flight modern servers — so ONE throwing unsubscribe aborts graceful close of every other subscription and rejects handler.close(), skipping the in-flight cleanup too.
Step-by-step proof. (1) Deploy createMcpHandler(factory, { bus: myRedisBus }) where myRedisBus.subscribe() returns an unsubscribe that calls into the Redis client. (2) Two clients open subscriptions/listen streams A and B; each arms a 15 s keep-alive timer and adds its teardown to open. (3) The Redis connection drops; the client library now throws from unsubscribe. (4) Client A disconnects → the stream's cancel() calls teardown(false) → closed = true → unsubscribe() throws → clearInterval/open.delete/controller.close() never run, and the exception propagates into the ReadableStream cancel path. (5) A's timer keeps firing every 15 s forever; A's slot in open is never reclaimed. (6) The operator calls handler.close() → closeAll() iterates open, hits A's teardown first, re-enters, if (closed) return — fine — but when it reaches any other still-open entry whose unsubscribe also throws, the loop aborts and B never gets its graceful-close result frame; the throw escapes handler.close() before inflight servers are closed.
Why existing code doesn't prevent it. Nothing on any call path catches the throw: the stream cancel() hook, the abort-signal listener, and closeAll() all invoke teardown bare. armSseKeepAlive unref's the timer, so the process can still exit, but within a running server the interval churns indefinitely and the capacity leak is unbounded. This is exactly the repo's Async/Lifecycle recurring catch (#1735/#1763): user-supplied or chained callbacks in close/teardown paths must be wrapped in try/finally so a throw cannot skip the remaining teardown — an explicitly requested check for this codebase, and the PR hardened this precise lifecycle class at every sibling SSE site (per-request transport, session transport) while this one call survives unguarded.
How to fix. One line: wrap the call — try { unsubscribe?.(); } catch (e) { onerror?.(e instanceof Error ? e : new Error(String(e))); } — or reorder so unsubscribe?.() runs last, after clearInterval, abortCleanup, open.delete, and controller.close(). Optionally also wrap the teardown(true) call inside closeAll() so one bad subscription can never abort the others.
Severity rationale. The teardown ordering is pre-existing — git diff shows this PR's listenRouter changes cover only the timer-arm line (now armSseKeepAlive) and the response headers, and the identical leak existed pre-PR with the raw setInterval. The default InMemoryServerEventBus never throws, so no stock configuration is affected. Filed as a nit rather than purely pre-existing because this PR arms the leaked timer inside this very closure and its changeset asserts a timer-lifecycle guarantee ("stopped on cancel/finalization/close") that this path falsifies — and the fix is a one-liner squarely within the PR's stated hardening scope.
Related pre-existing site (mention-only, not filed separately). streamableHttp.ts handleDeleteRequest has the same shape: await Promise.resolve(this._onsessionclosed?.(this.sessionId!)); await this.close(); — a throwing/rejecting user onsessionclosed skips close() entirely, leaving the session, its SSE streams, and (post-PR) all keep-alive timers alive while the client believes DELETE terminated the session. Same fix shape: try/finally so close() always runs. That code is untouched by this PR.
| } | ||
| this._streamMapping.clear(); | ||
|
|
||
| // Clear any keep-alive timers not already cleared by stream cleanup | ||
| for (const timer of this._keepAliveTimers.values()) { | ||
| clearInterval(timer); | ||
| } | ||
| this._keepAliveTimers.clear(); | ||
|
|
There was a problem hiding this comment.
🟣 Pre-existing issue (the JSON-mode cleanup shape and the close() sweep both predate this PR): in enableJsonResponse mode, the stream entry's cleanup only deletes the _streamMapping entry and never settles the 'new Promise(resolve => ...)' returned by handlePostRequest, so close()'s sweep landing while a JSON-mode request handler is in flight (client-drivable: DELETE on the session during a slow tools/call POST) silently drops the resolveJson sink and the POST's handleRequest promise pends forever — a byte-less hung HTTP exchange plus a leaked promise and captured request context. Since this PR already fixed the equivalent close-race hang on every SSE leg, settling the deferred in the JSON entry's cleanup (e.g. resolve with the 404 'Session not found' body the transport now answers for closed transports) would complete the invariant the new lifecycle tests pin.
Extended reasoning...
What the bug is. In enableJsonResponse mode, handlePostRequest returns new Promise<Response>(resolve => ...) and registers the JSON stream entry (streamableHttp.ts:904-910) as { resolveJson: resolve, cleanup: () => { this._streamMapping.delete(streamId); } }. The cleanup deletes the mapping but never settles the deferred. close()'s sweep (streamableHttp.ts:1108-1121, extended by this PR to also clear keep-alive timers) runs for (const { cleanup } of this._streamMapping.values()) cleanup() — so a close that lands while a JSON-mode request handler is still in flight silently drops the resolveJson sink, and nothing can ever settle the returned Promise<Response> afterwards.
Why nothing downstream saves it. When transport.onclose fires, Protocol._onclose sets its transport reference to undefined and aborts in-flight handlers, so the handler's eventual response typically never reaches transport.send() at all. Even if a late send() did run, the all-responses-ready path would find stream === undefined and — in the JSON-mode branch — throw 'No connection established for request ID' into onerror without resolving anything (close() clears _requestResponseMap but not _requestToStreamMapping, so the lookup succeeds and reaches the throw). The Promise<Response> returned by handleRequest stays pending forever.
Step-by-step proof.
- Sessionful transport with
enableJsonResponse: true; client POSTs a slowtools/call. The handler is dispatched and the JSON entry is registered — so the pre-registration_closedre-check this PR added at streamableHttp.ts:875 (commit 8a6a55a) has already passed. - The same or another client sends
DELETEwith the session id →handleDeleteRequestawaitsthis.close()→ the sweep invokes the JSON entry's cleanup, deleting the mapping without resolving the deferred. - The tool handler is aborted (or completes into a nulled transport); no code path holds the
resolveanymore. - The POST's
handleRequestpromise never settles: in a Node adapter (await handler(req, res)inNodeStreamableHTTPServerTransport.handleRequest), the HTTP exchange stays open with zero bytes written until the client's own socket/body timeout kills it — and server-side the pending promise plus its captured request context leak for the life of the process.
Contrast with SSE mode — why this survives this PR. In SSE mode the same sweep's cleanup closes the stream controller, so the client observes stream end — the exact invariant this PR's new close-race lifecycle tests pin ('the client observes stream end instead of hanging on a dead session'). JSON mode is the one response shape where close() still produces an eternal hang instead of a terminated exchange, i.e. a surviving sibling of exactly the hang class this PR eliminated on every SSE leg (the repo's Completeness recurring catch: partial fixes leaving sibling code paths with the bug the PR addresses).
Not a duplicate of the round-3 finding. The earlier report's root cause was registering mappings after close()'s once-only sweep — fixed in 8a6a55a by the pre-registration _closed re-check and the handleRequest 404 gate. This is the post-registration window: the entry exists when the sweep runs, the guards pass, and the fix is different (settle the promise, not re-check before registering).
How to fix. Make the JSON entry's cleanup settle the deferred before deleting the mapping — e.g. resolve(this.createJsonErrorResponse(404, -32_001, 'Session not found')), matching what the transport now answers for requests on a closed transport — or, equivalently, resolve all resolveJson sinks in close()'s sweep. Either way the exchange terminates with a well-formed body instead of hanging, restoring parity with the SSE legs.
Why pre_existing. The resolveJson/non-settling-cleanup shape and the sweep loop both predate this PR; the diff only adds the keep-alive timer clears to close() and did not add or alter the JSON-mode cleanup. That said, since this PR reworks this exact function's close-race lifecycle throughout, it is the natural place to close out the last surviving hang shape.
v2 port of #2538 (v1.x). Refs #1211.
Problem
Long-idle SSE responses can go too long without delivering body bytes. A client body-idle timeout (for example undici's
bodyTimeout) or an intermediary such as a reverse proxy or cloud load balancer may then terminate the stream. Clients observeSSE stream disconnected: TypeError: terminatedand reconnect in a loop.v2 already keep-alived
subscriptions/listenstreams vialistenRouter, but the transport's own SSE streams had no equivalent. Downstream users (for example Cloudflare'sagentsSDK v2 integration) currently rewrap transport response bodies just to inject keep-alive frames.Fix
keepAliveMstoWebStandardStreamableHTTPServerTransportandPerRequestHTTPServerTransport, defaulting to 15,000 ms (the WHATWG SSE guidance recommends a comment line roughly every 15 seconds). Set0to disable. Invalid timer delays (sub-millisecond, non-finite, or above JavaScript's maximum timer delay) safely disable keep-alive rather than being clamped to a ~1 ms flood.subscriptions/listen; andcreateMcpHandler's legacy fallback. In modernautomode the timer starts after SSE upgrade; useresponseMode: 'sse'when a silent long-running handler needs heartbeat bytes.NodeStreamableHTTPServerTransportinherits the option through its aliased options type.: keepaliveSSE comment frames. Compliant SSE parsers discard comments before event dispatch, so they never surface as MCP messages.Cache-Control: no-cache, no-transformandX-Accel-Buffering: noon every HTTP SSE producer.unrefpolicy in one internal helper shared by the session, per-request, and listen implementations.Verification
@modelcontextprotocol/server: 474 tests passedcreateMcpHandlerforwarding leg, replay/POST close races, ownership-safe POST error cleanup, and timer teardownOperational note
Keep-alive is default-on to fix existing deployments without requiring a configuration change. Runtimes that intentionally depend on an idle stream for hibernation can set
keepAliveMs: 0.