@@ -148,6 +148,19 @@ export interface WebStandardStreamableHTTPServerTransportOptions {
148148 */
149149 retryInterval ?: number ;
150150
151+ /**
152+ * Interval in milliseconds between SSE keep-alive comment frames (`: keepalive`)
153+ * written to open SSE streams. Keep-alive frames prevent idle streams (e.g. the
154+ * standalone `GET` stream, or a `POST` stream during a long-running tool call)
155+ * from being killed by intermediaries and server idle timeouts, which clients
156+ * observe as `SSE stream disconnected: TypeError: terminated`.
157+ *
158+ * Comment frames are ignored by SSE parsers and never surface as messages.
159+ * Defaults to `15000` (per the WHATWG SSE spec recommendation of roughly every
160+ * 15 seconds). Set to `0` to disable keep-alive frames.
161+ */
162+ keepAliveMs ?: number ;
163+
151164 /**
152165 * List of protocol versions that this transport will accept.
153166 * Used to validate the `mcp-protocol-version` header in incoming requests.
@@ -161,6 +174,9 @@ export interface WebStandardStreamableHTTPServerTransportOptions {
161174 supportedProtocolVersions ?: string [ ] ;
162175}
163176
177+ /** Default interval between SSE keep-alive comment frames. */
178+ const DEFAULT_KEEP_ALIVE_MS = 15_000 ;
179+
164180/**
165181 * Options for handling a request
166182 */
@@ -247,6 +263,8 @@ export class WebStandardStreamableHTTPServerTransport implements Transport {
247263 private _enableDnsRebindingProtection : boolean ;
248264 private _retryInterval ?: number ;
249265 private _supportedProtocolVersions : string [ ] ;
266+ private _keepAliveMs : number ;
267+ private _keepAliveTimers : Map < string , ReturnType < typeof setInterval > > = new Map ( ) ;
250268
251269 sessionId ?: string ;
252270 onclose ?: ( ) => void ;
@@ -264,6 +282,47 @@ export class WebStandardStreamableHTTPServerTransport implements Transport {
264282 this . _enableDnsRebindingProtection = options . enableDnsRebindingProtection ?? false ;
265283 this . _retryInterval = options . retryInterval ;
266284 this . _supportedProtocolVersions = options . supportedProtocolVersions ?? SUPPORTED_PROTOCOL_VERSIONS ;
285+ this . _keepAliveMs = options . keepAliveMs ?? DEFAULT_KEEP_ALIVE_MS ;
286+ }
287+
288+ /**
289+ * Arms a keep-alive interval for an SSE stream that periodically writes an SSE
290+ * comment frame so intermediaries and idle timeouts don't kill the connection.
291+ * Replaces any timer already armed for the same stream id (a resumed stream
292+ * re-registered under the same id supersedes its predecessor's timer). The
293+ * timer is cleared via {@linkcode stopKeepAlive} when the stream is cleaned up,
294+ * and clears itself if a write fails (stream already closed/cancelled).
295+ */
296+ private startKeepAlive (
297+ streamId : string ,
298+ controller : ReadableStreamDefaultController < Uint8Array > ,
299+ encoder : InstanceType < typeof TextEncoder >
300+ ) : void {
301+ if ( this . _keepAliveMs <= 0 ) {
302+ return ;
303+ }
304+ this . stopKeepAlive ( streamId ) ;
305+ const timer = setInterval ( ( ) => {
306+ try {
307+ controller . enqueue ( encoder . encode ( ': keepalive\n\n' ) ) ;
308+ } catch {
309+ this . stopKeepAlive ( streamId ) ;
310+ }
311+ } , this . _keepAliveMs ) ;
312+ // Don't let the keep-alive timer hold the process open (Node.js only)
313+ ( timer as { unref ?: ( ) => void } ) . unref ?.( ) ;
314+ this . _keepAliveTimers . set ( streamId , timer ) ;
315+ }
316+
317+ /**
318+ * Clears the keep-alive interval for a stream, if one is armed.
319+ */
320+ private stopKeepAlive ( streamId : string ) : void {
321+ const timer = this . _keepAliveTimers . get ( streamId ) ;
322+ if ( timer !== undefined ) {
323+ clearInterval ( timer ) ;
324+ this . _keepAliveTimers . delete ( streamId ) ;
325+ }
267326 }
268327
269328 /**
@@ -473,6 +532,7 @@ export class WebStandardStreamableHTTPServerTransport implements Transport {
473532 // it still points at THIS controller — a stale cancel must not
474533 // delete a successor stream registered by a later GET/resume.
475534 if ( this . _streamMapping . get ( this . _standaloneSseStreamId ) ?. controller === streamController ) {
535+ this . stopKeepAlive ( this . _standaloneSseStreamId ) ;
476536 this . _streamMapping . delete ( this . _standaloneSseStreamId ) ;
477537 }
478538 }
@@ -494,6 +554,7 @@ export class WebStandardStreamableHTTPServerTransport implements Transport {
494554 controller : streamController ! ,
495555 encoder,
496556 cleanup : ( ) => {
557+ this . stopKeepAlive ( this . _standaloneSseStreamId ) ;
497558 this . _streamMapping . delete ( this . _standaloneSseStreamId ) ;
498559 try {
499560 streamController ! . close ( ) ;
@@ -503,6 +564,8 @@ export class WebStandardStreamableHTTPServerTransport implements Transport {
503564 }
504565 } ) ;
505566
567+ this . startKeepAlive ( this . _standaloneSseStreamId , streamController ! , encoder ) ;
568+
506569 return new Response ( readable , { headers } ) ;
507570 }
508571
@@ -564,6 +627,7 @@ export class WebStandardStreamableHTTPServerTransport implements Transport {
564627 // a stale cancel from an earlier resume must not delete a
565628 // successor resumed stream a re-poll has since registered.
566629 if ( replayedStreamId !== undefined && this . _streamMapping . get ( replayedStreamId ) ?. controller === streamController ) {
630+ this . stopKeepAlive ( replayedStreamId ) ;
567631 this . _streamMapping . delete ( replayedStreamId ) ;
568632 }
569633 }
@@ -590,6 +654,7 @@ export class WebStandardStreamableHTTPServerTransport implements Transport {
590654 encoder,
591655 replayedEventIds,
592656 cleanup : ( ) => {
657+ this . stopKeepAlive ( replayedStreamId ! ) ;
593658 this . _streamMapping . delete ( replayedStreamId ! ) ;
594659 try {
595660 streamController ! . close ( ) ;
@@ -618,6 +683,12 @@ export class WebStandardStreamableHTTPServerTransport implements Transport {
618683 }
619684 }
620685
686+ // Only arm keep-alive if the stream is still registered — the
687+ // no-in-flight-request path above may have already closed it.
688+ if ( this . _streamMapping . get ( replayedStreamId ) ?. controller === streamController ! ) {
689+ this . startKeepAlive ( replayedStreamId , streamController ! , encoder ) ;
690+ }
691+
621692 return new Response ( readable , { headers } ) ;
622693 } catch ( error ) {
623694 this . onerror ?.( error as Error ) ;
@@ -830,6 +901,7 @@ export class WebStandardStreamableHTTPServerTransport implements Transport {
830901 // resumed stream under the same streamId) must not delete
831902 // the successor.
832903 if ( this . _streamMapping . get ( streamId ) ?. controller === streamController ) {
904+ this . stopKeepAlive ( streamId ) ;
833905 this . _streamMapping . delete ( streamId ) ;
834906 }
835907 }
@@ -854,6 +926,7 @@ export class WebStandardStreamableHTTPServerTransport implements Transport {
854926 controller : streamController ! ,
855927 encoder,
856928 cleanup : ( ) => {
929+ this . stopKeepAlive ( streamId ) ;
857930 this . _streamMapping . delete ( streamId ) ;
858931 try {
859932 streamController ! . close ( ) ;
@@ -866,6 +939,8 @@ export class WebStandardStreamableHTTPServerTransport implements Transport {
866939 }
867940 }
868941
942+ this . startKeepAlive ( streamId , streamController ! , encoder ) ;
943+
869944 // Write priming event if event store is configured (after mapping is set up)
870945 await this . writePrimingEvent ( streamController ! , encoder , streamId , clientProtocolVersion ) ;
871946
@@ -986,6 +1061,12 @@ export class WebStandardStreamableHTTPServerTransport implements Transport {
9861061 }
9871062 this . _streamMapping . clear ( ) ;
9881063
1064+ // Clear any keep-alive timers not already cleared by stream cleanup
1065+ for ( const timer of this . _keepAliveTimers . values ( ) ) {
1066+ clearInterval ( timer ) ;
1067+ }
1068+ this . _keepAliveTimers . clear ( ) ;
1069+
9891070 // Clear any pending responses
9901071 this . _requestResponseMap . clear ( ) ;
9911072 this . onclose ?.( ) ;
0 commit comments