-
Notifications
You must be signed in to change notification settings - Fork 2k
fix: send SSE keep-alive comment frames from WebStandardStreamableHTTPServerTransport #2541
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
| --- | ||
| '@modelcontextprotocol/server': patch | ||
| --- | ||
|
|
||
| `WebStandardStreamableHTTPServerTransport` now writes 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). | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -154,6 +154,14 @@ | |
|
|
||
| The Resource Server helpers did not move there: `requireBearerAuth`, `mcpAuthMetadataRouter` and `OAuthTokenVerifier` are first-class in `@modelcontextprotocol/express` — see [Authorization](./serving/authorization.md). `@modelcontextprotocol/server-legacy` is frozen and receives no new features; serve new code over [Streamable HTTP](./serving/http.md), which still reaches 2025-era clients through [legacy client support](./serving/legacy-clients.md). A client limited to the HTTP+SSE transport is the one case that still needs the frozen `@modelcontextprotocol/server-legacy/sse` import above. | ||
|
|
||
| ## `SSE stream disconnected: TypeError: terminated` | ||
|
|
||
| An idle SSE stream was killed by an intermediary or an idle-connection timeout — Node's `server.requestTimeout` defaults to 300 seconds, and reverse proxies and cloud load balancers have similar watchdogs. The client observes the dropped socket as this error (typically every ~5 minutes) and reconnects in a loop. | ||
|
|
||
| `WebStandardStreamableHTTPServerTransport` prevents this by writing an SSE comment frame (`: keepalive`) to every open SSE stream every 15 seconds by default. Comment frames are dropped by SSE parsers before event dispatch, so they never surface as protocol messages. Tune or disable the interval with the transport's `keepAliveMs` option (`0` disables); `createMcpHandler`'s `keepAliveMs` option covers both its `subscriptions/listen` streams and the legacy fallback's per-request transport. | ||
|
|
||
| If you still see this error, either keep-alive is disabled (`keepAliveMs: 0`) or an intermediary between client and server buffers or strips SSE data — check for proxies that buffer streaming responses (e.g. nginx without `proxy_buffering off`). | ||
|
Check failure on line 163 in docs/troubleshooting.md
|
||
|
Comment on lines
+157
to
+163
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔴 createMcpHandler's modern (2026-07-28) leg serves request exchanges via PerRequestHTTPServerTransport, which has no keep-alive at all — writeCommentFrame() (perRequestTransport.ts:326) has zero production callers — so a long-running modern tools/call with responseMode 'sse' (or 'auto' upgraded by a mid-call notification) still idles past Node's 300s requestTimeout and dies with 'SSE stream disconnected: TypeError: terminated'. The new troubleshooting entry then affirmatively rules out this cause ('either keep-alive is disabled (keepAliveMs: 0) or an intermediary... buffers or strips SSE data'), sending modern-leg users to chase proxy buffering that isn't the problem. Either drive writeCommentFrame() from a keepAliveMs interval in PerRequestHTTPServerTransport, or scope the troubleshooting prose to the streams that actually have keep-alive. Extended reasoning...The remaining gap. This PR (with follow-up 6d20a1f) adds keep-alive to |
||
|
|
||
| ## Recap | ||
|
|
||
| - Every heading on this page is the exact message you searched for. | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -194,8 +194,9 @@ | |
| */ | ||
| maxSubscriptions?: number; | ||
| /** | ||
| * SSE comment-frame keepalive interval for `subscriptions/listen` streams, | ||
| * in milliseconds. Set to `0` to disable. | ||
| * SSE comment-frame keepalive interval, in milliseconds, applied to | ||
| * `subscriptions/listen` streams and to the SSE streams of the legacy | ||
| * stateless fallback's per-request transport. Set to `0` to disable. | ||
| * @default 15000 | ||
| */ | ||
| keepAliveMs?: number; | ||
|
|
@@ -303,13 +304,17 @@ | |
| * | ||
| * The optional `onerror` callback receives factory and serving failures on | ||
| * this leg (reporting only — the response stays the 500 internal-error body). | ||
| * The entry passes its own `onerror` here when expanding the default, so | ||
| * legacy-leg failures are never silently swallowed. | ||
| */ | ||
| export function legacyStatelessFallback(factory: McpServerFactory, onerror?: (error: Error) => void): LegacyHttpHandler { | ||
| export function legacyStatelessFallback( | ||
| factory: McpServerFactory, | ||
| onerror?: (error: Error) => void, | ||
| transportOptions?: { keepAliveMs?: number } | ||
| ): LegacyHttpHandler { | ||
| return async (request, options) => { | ||
| if (request.method.toUpperCase() !== 'POST') { | ||
| return jsonRpcErrorResponse(405, -32_000, 'Method not allowed.'); | ||
|
Check warning on line 317 in packages/server/src/server/createMcpHandler.ts
|
||
|
Comment on lines
307
to
317
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 Two doc blocks in this file still claim the legacy fallback transport is constructed "with only Extended reasoning...What's stale. Commit 6d20a1f (addressing the earlier review round) threads
Why it's wrong now. The Concrete walk-through. (1) A consumer deploys Why nothing catches it. JSDoc is prose; nothing at compile or test time checks it against the construction site, and the repo's This matches the repo's Documentation recurring catch (REVIEW.md #1718, #1838: prose in the same diff promising behavior the code no longer ships), making it a repo-requested check rather than a style preference — but that rule flags the finding; it doesn't make it blocking, and there is no runtime effect. Fix. Reword both sites, e.g. "constructed with |
||
| } | ||
| try { | ||
| const product = await factory({ | ||
|
|
@@ -317,7 +322,10 @@ | |
| ...(options?.authInfo !== undefined && { authInfo: options.authInfo }), | ||
| requestInfo: request | ||
| }); | ||
| const transport = new WebStandardStreamableHTTPServerTransport({ sessionIdGenerator: undefined }); | ||
| const transport = new WebStandardStreamableHTTPServerTransport({ | ||
| sessionIdGenerator: undefined, | ||
| ...(transportOptions?.keepAliveMs !== undefined && { keepAliveMs: transportOptions.keepAliveMs }) | ||
| }); | ||
| await product.connect(transport); | ||
|
|
||
| const teardown = () => { | ||
|
|
@@ -632,7 +640,12 @@ | |
|
|
||
| // The default posture is the stateless fallback; 'reject' is the only way | ||
| // to turn legacy serving off (modern-only strict). | ||
| const legacyHandler: LegacyHttpHandler | undefined = legacy === 'reject' ? undefined : legacyStatelessFallback(factory, reportError); | ||
| const legacyHandler: LegacyHttpHandler | undefined = | ||
| legacy === 'reject' | ||
| ? undefined | ||
| : legacyStatelessFallback(factory, reportError, { | ||
| ...(options.keepAliveMs !== undefined && { keepAliveMs: options.keepAliveMs }) | ||
| }); | ||
|
|
||
| async function serveModern(route: InboundModernRoute, request: Request, authInfo: AuthInfo | undefined): Promise<Response> { | ||
| const claimedRevision = route.classification.revision; | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -148,6 +148,19 @@ | |
| */ | ||
| 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; | ||
|
|
||
| /** | ||
| * List of protocol versions that this transport will accept. | ||
| * Used to validate the `mcp-protocol-version` header in incoming requests. | ||
|
|
@@ -161,6 +174,9 @@ | |
| supportedProtocolVersions?: string[]; | ||
| } | ||
|
|
||
| /** Default interval between SSE keep-alive comment frames. */ | ||
| const DEFAULT_KEEP_ALIVE_MS = 15_000; | ||
|
|
||
| /** | ||
| * Options for handling a request | ||
| */ | ||
|
|
@@ -247,6 +263,8 @@ | |
| private _enableDnsRebindingProtection: boolean; | ||
| private _retryInterval?: number; | ||
| private _supportedProtocolVersions: string[]; | ||
| private _keepAliveMs: number; | ||
| private _keepAliveTimers: Map<string, ReturnType<typeof setInterval>> = new Map(); | ||
|
|
||
| sessionId?: string; | ||
| onclose?: () => void; | ||
|
|
@@ -264,6 +282,49 @@ | |
| this._enableDnsRebindingProtection = options.enableDnsRebindingProtection ?? false; | ||
| this._retryInterval = options.retryInterval; | ||
| this._supportedProtocolVersions = options.supportedProtocolVersions ?? SUPPORTED_PROTOCOL_VERSIONS; | ||
| this._keepAliveMs = options.keepAliveMs ?? DEFAULT_KEEP_ALIVE_MS; | ||
|
claude[bot] marked this conversation as resolved.
|
||
| } | ||
|
|
||
| /** | ||
| * 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 {@linkcode 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: InstanceType<typeof TextEncoder> | ||
| ): void { | ||
| // A deferred arm (e.g. after an event-store await) must not outlive the | ||
| // transport: close()'s timer sweep has already run and never runs again. | ||
| if (this._keepAliveMs <= 0 || this._closed) { | ||
| 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?.(); | ||
|
Check warning on line 315 in packages/server/src/server/streamableHttp.ts
|
||
|
Comment on lines
+300
to
+315
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 The guard 'if (this._keepAliveMs <= 0 || this._closed) return;' passes NaN (NaN <= 0 is false), and Node clamps setInterval delays of NaN/Infinity/>2^31-1 to ~1ms — so a non-finite keepAliveMs (e.g. parseInt of an unset env var, or Infinity as a "never" sentinel) floods every SSE stream with ~1000 ': keepalive' frames/sec, while listenRouter.ts:221 uses the opposite polarity ('if (keepAliveMs > 0)') and safely disables for the same value — one createMcpHandler option, opposite behaviors on the two legs it now covers. One-line fix: match listenRouter's polarity ('if (!(this._keepAliveMs > 0) || this._closed)') and/or normalize with Number.isFinite in the constructor. Extended reasoning...What the bug is. |
||
| this._keepAliveTimers.set(streamId, timer); | ||
| } | ||
|
|
||
| /** | ||
| * 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); | ||
| } | ||
| } | ||
|
|
||
| /** | ||
|
|
@@ -473,6 +534,7 @@ | |
| // 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) { | ||
| this.stopKeepAlive(this._standaloneSseStreamId); | ||
| this._streamMapping.delete(this._standaloneSseStreamId); | ||
| } | ||
| } | ||
|
|
@@ -494,6 +556,7 @@ | |
| controller: streamController!, | ||
| encoder, | ||
| cleanup: () => { | ||
| this.stopKeepAlive(this._standaloneSseStreamId); | ||
| this._streamMapping.delete(this._standaloneSseStreamId); | ||
| try { | ||
| streamController!.close(); | ||
|
|
@@ -503,6 +566,8 @@ | |
| } | ||
| }); | ||
|
|
||
| this.startKeepAlive(this._standaloneSseStreamId, streamController!, encoder); | ||
|
|
||
| return new Response(readable, { headers }); | ||
| } | ||
|
|
||
|
|
@@ -564,6 +629,7 @@ | |
| // a stale cancel from an earlier resume must not delete a | ||
| // successor resumed stream a re-poll has since registered. | ||
| if (replayedStreamId !== undefined && this._streamMapping.get(replayedStreamId)?.controller === streamController) { | ||
| this.stopKeepAlive(replayedStreamId); | ||
| this._streamMapping.delete(replayedStreamId); | ||
| } | ||
| } | ||
|
|
@@ -590,6 +656,7 @@ | |
| encoder, | ||
| replayedEventIds, | ||
| cleanup: () => { | ||
| this.stopKeepAlive(replayedStreamId!); | ||
| this._streamMapping.delete(replayedStreamId!); | ||
| try { | ||
| streamController!.close(); | ||
|
|
@@ -618,6 +685,12 @@ | |
| } | ||
| } | ||
|
|
||
| // Only arm keep-alive if the stream is still registered — the | ||
| // no-in-flight-request path above may have already closed it. | ||
| if (this._streamMapping.get(replayedStreamId)?.controller === streamController!) { | ||
| this.startKeepAlive(replayedStreamId, streamController!, encoder); | ||
| } | ||
|
claude[bot] marked this conversation as resolved.
|
||
|
|
||
| return new Response(readable, { headers }); | ||
| } catch (error) { | ||
| this.onerror?.(error as Error); | ||
|
|
@@ -830,6 +903,7 @@ | |
| // resumed stream under the same streamId) must not delete | ||
| // the successor. | ||
| if (this._streamMapping.get(streamId)?.controller === streamController) { | ||
| this.stopKeepAlive(streamId); | ||
| this._streamMapping.delete(streamId); | ||
| } | ||
| } | ||
|
|
@@ -854,6 +928,7 @@ | |
| controller: streamController!, | ||
| encoder, | ||
| cleanup: () => { | ||
| this.stopKeepAlive(streamId); | ||
| this._streamMapping.delete(streamId); | ||
| try { | ||
| streamController!.close(); | ||
|
|
@@ -891,6 +966,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); | ||
| } | ||
|
|
||
| return new Response(readable, { status: 200, headers }); | ||
| } catch (error) { | ||
| // return JSON-RPC formatted error | ||
|
|
@@ -986,6 +1069,12 @@ | |
| } | ||
| 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(); | ||
|
|
||
| // Clear any pending responses | ||
| this._requestResponseMap.clear(); | ||
| this.onclose?.(); | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.