-
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
9505f93
6d20a1f
3ec1fd1
c9b0215
a606d7d
49f886c
8a6a55a
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': minor | ||
| --- | ||
|
|
||
| SSE streams served by the SDK now emit keep-alive comment frames (`: keepalive`) so idle connections are not killed by client body-idle timeouts or intermediaries. `WebStandardStreamableHTTPServerTransport` and `PerRequestHTTPServerTransport` gain a `keepAliveMs` option (default 15000; set 0 to disable), and `createMcpHandler`'s existing `keepAliveMs` now covers modern per-request exchange streams and the legacy stateless fallback in addition to `subscriptions/listen` streams. Streaming responses disable proxy transformations and nginx-style buffering, and deferred work cannot register streams after transport close. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -34,9 +34,10 @@ | |
|
|
||
| import type { ServerEventBus } from './serverEventBus'; | ||
| import { honoredSubset, listenFilterAccepts, serverEventToNotification } from './serverEventBus'; | ||
| import { armSseKeepAlive, DEFAULT_SSE_KEEP_ALIVE_MS } from './sseKeepAlive'; | ||
|
|
||
| /** Default SSE comment-frame keepalive interval for listen streams. */ | ||
| export const DEFAULT_LISTEN_KEEPALIVE_MS = 15_000; | ||
| export const DEFAULT_LISTEN_KEEPALIVE_MS = DEFAULT_SSE_KEEP_ALIVE_MS; | ||
|
|
||
| /** Default capacity guard: refuse a new subscription when this many are already open. */ | ||
| export const DEFAULT_MAX_SUBSCRIPTIONS = 1024; | ||
|
|
@@ -215,20 +216,13 @@ | |
| unsubscribe = bus.subscribe(event => { | ||
| if (closed || !listenFilterAccepts(honored, event)) return; | ||
| const note = stampSubscriptionId(serverEventToNotification(event), subscriptionId); | ||
| 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); | ||
| }, | ||
|
Check warning on line 225 in packages/server/src/server/listenRouter.ts
|
||
|
Comment on lines
219
to
225
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. 🟡 In Extended reasoning...What the bug is. Concrete consequences. (a) The keep-alive interval this PR arms at line 222 via Step-by-step proof. (1) Deploy Why existing code doesn't prevent it. Nothing on any call path catches the throw: the stream How to fix. One line: wrap the call — Severity rationale. The teardown ordering is pre-existing — Related pre-existing site (mention-only, not filed separately). |
||
| cancel() { | ||
| // The client closed the SSE stream — the spec's HTTP cancel | ||
| // signal. Not a server-side graceful close, so no listen | ||
|
|
@@ -251,7 +245,7 @@ | |
| status: 200, | ||
| headers: { | ||
| 'Content-Type': 'text/event-stream', | ||
| 'Cache-Control': 'no-cache', | ||
| 'Cache-Control': 'no-cache, no-transform', | ||
| Connection: 'keep-alive', | ||
| 'X-Accel-Buffering': 'no' | ||
| } | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,25 @@ | ||
| /** Default interval between SSE keep-alive comment frames. */ | ||
| export const DEFAULT_SSE_KEEP_ALIVE_MS = 15_000; | ||
|
|
||
| /** Largest delay accepted by JavaScript timers without overflow clamping. */ | ||
| const MAX_TIMER_DELAY_MS = 2_147_483_647; | ||
|
|
||
| /** | ||
| * Arms an SSE keep-alive timer when `intervalMs` is a valid timer delay. | ||
| * | ||
| * Invalid delays disable keep-alive. In Node.js, sub-millisecond, non-finite, | ||
| * and overflowing delays are clamped to roughly 1 ms, which would otherwise | ||
| * flood every open stream with comment frames. | ||
| * | ||
| * @returns The timer handle, or `undefined` when keep-alive is disabled. | ||
| */ | ||
| export function armSseKeepAlive(intervalMs: number, onTick: () => void): ReturnType<typeof setInterval> | undefined { | ||
| if (!Number.isFinite(intervalMs) || intervalMs < 1 || intervalMs > MAX_TIMER_DELAY_MS) { | ||
| return undefined; | ||
| } | ||
|
|
||
| const timer = setInterval(onTick, intervalMs); | ||
| // Node.js timers expose `unref`; browser and Workers timers are numbers. | ||
| (timer as { unref?: () => void }).unref?.(); | ||
| return timer; | ||
| } |
Uh oh!
There was an error while loading. Please reload this page.