@@ -63,6 +63,12 @@ interface StreamMapping {
6363 encoder ?: InstanceType < typeof TextEncoder > ;
6464 /** Promise resolver for JSON response mode */
6565 resolveJson ?: ( response : Response ) => void ;
66+ /**
67+ * Event ids already written to this stream by `replayEventsAfter` — lets
68+ * `send()` skip a duplicate write when the resumed stream registered
69+ * during the `storeEvent()` await and replay already delivered the event.
70+ */
71+ replayedEventIds ?: Set < string > ;
6672 /** Cleanup function to close stream and remove mapping */
6773 cleanup : ( ) => void ;
6874}
@@ -462,8 +468,12 @@ export class WebStandardStreamableHTTPServerTransport implements Transport {
462468 streamController = controller ;
463469 } ,
464470 cancel : ( ) => {
465- // Stream was cancelled by client
466- this . _streamMapping . delete ( this . _standaloneSseStreamId ) ;
471+ // Stream was cancelled by client. Only drop the mapping when
472+ // it still points at THIS controller — a stale cancel must not
473+ // delete a successor stream registered by a later GET/resume.
474+ if ( this . _streamMapping . get ( this . _standaloneSseStreamId ) ?. controller === streamController ) {
475+ this . _streamMapping . delete ( this . _standaloneSseStreamId ) ;
476+ }
467477 }
468478 } ) ;
469479
@@ -536,20 +546,33 @@ export class WebStandardStreamableHTTPServerTransport implements Transport {
536546 // Create a ReadableStream with controller for SSE
537547 const encoder = new TextEncoder ( ) ;
538548 let streamController : ReadableStreamDefaultController < Uint8Array > ;
549+ // Captured by the cancel closure below before it's assigned (after
550+ // replayEventsAfter resolves) — must be `let`.
551+ // eslint-disable-next-line prefer-const
552+ let replayedStreamId : string | undefined ;
539553
540554 const readable = new ReadableStream < Uint8Array > ( {
541555 start : controller => {
542556 streamController = controller ;
543557 } ,
544558 cancel : ( ) => {
545- // Stream was cancelled by client
546- // Cleanup will be handled by the mapping
559+ // Stream was cancelled by client — drop the mapping so a
560+ // subsequent reconnect with the same Last-Event-ID is not
561+ // refused with 409 by the conflict check above. Only delete
562+ // when the mapped entry is still THIS closure's controller:
563+ // a stale cancel from an earlier resume must not delete a
564+ // successor resumed stream a re-poll has since registered.
565+ if ( replayedStreamId !== undefined && this . _streamMapping . get ( replayedStreamId ) ?. controller === streamController ) {
566+ this . _streamMapping . delete ( replayedStreamId ) ;
567+ }
547568 }
548569 } ) ;
549570
550571 // Replay events - returns the streamId for backwards compatibility
551- const replayedStreamId = await this . _eventStore . replayEventsAfter ( lastEventId , {
572+ const replayedEventIds = new Set < string > ( ) ;
573+ replayedStreamId = await this . _eventStore . replayEventsAfter ( lastEventId , {
552574 send : async ( eventId : string , message : JSONRPCMessage ) => {
575+ replayedEventIds . add ( eventId ) ;
553576 const success = this . writeSSEEvent ( streamController ! , encoder , message , eventId ) ;
554577 if ( ! success ) {
555578 try {
@@ -564,8 +587,9 @@ export class WebStandardStreamableHTTPServerTransport implements Transport {
564587 this . _streamMapping . set ( replayedStreamId , {
565588 controller : streamController ! ,
566589 encoder,
590+ replayedEventIds,
567591 cleanup : ( ) => {
568- this . _streamMapping . delete ( replayedStreamId ) ;
592+ this . _streamMapping . delete ( replayedStreamId ! ) ;
569593 try {
570594 streamController ! . close ( ) ;
571595 } catch {
@@ -574,6 +598,25 @@ export class WebStandardStreamableHTTPServerTransport implements Transport {
574598 }
575599 } ) ;
576600
601+ // If this is a per-request stream and no in-flight request still
602+ // targets this streamId, the request was already retired by the
603+ // clean-return path while disconnected and the replay above just
604+ // delivered the final response. Per the spec the server SHOULD
605+ // close the SSE stream after the JSON-RPC response — close and
606+ // unregister so a later reconnect isn't refused with 409. The
607+ // standalone GET stream is never request-scoped and stays open.
608+ if ( replayedStreamId !== this . _standaloneSseStreamId ) {
609+ const hasInFlightRequest = [ ...this . _requestToStreamMapping . values ( ) ] . includes ( replayedStreamId ) ;
610+ if ( ! hasInFlightRequest ) {
611+ this . _streamMapping . delete ( replayedStreamId ) ;
612+ try {
613+ streamController ! . close ( ) ;
614+ } catch {
615+ // Controller might already be closed
616+ }
617+ }
618+ }
619+
577620 return new Response ( readable , { headers } ) ;
578621 } catch ( error ) {
579622 this . onerror ?.( error as Error ) ;
@@ -770,8 +813,14 @@ export class WebStandardStreamableHTTPServerTransport implements Transport {
770813 streamController = controller ;
771814 } ,
772815 cancel : ( ) => {
773- // Stream was cancelled by client
774- this . _streamMapping . delete ( streamId ) ;
816+ // Stream was cancelled by client. Only drop the mapping
817+ // when it still points at THIS controller — a stale cancel
818+ // (firing after a Last-Event-ID reconnect registered a
819+ // resumed stream under the same streamId) must not delete
820+ // the successor.
821+ if ( this . _streamMapping . get ( streamId ) ?. controller === streamController ) {
822+ this . _streamMapping . delete ( streamId ) ;
823+ }
775824 }
776825 } ) ;
777826
@@ -987,8 +1036,14 @@ export class WebStandardStreamableHTTPServerTransport implements Transport {
9871036 return ;
9881037 }
9891038
990- // Send the message to the standalone SSE stream
991- if ( standaloneSse . controller && standaloneSse . encoder ) {
1039+ // Send the message to the standalone SSE stream — unless the
1040+ // resumed stream's replay already delivered this exact eventId
1041+ // (identity dedup; mirrors the per-request path below).
1042+ if (
1043+ standaloneSse . controller &&
1044+ standaloneSse . encoder &&
1045+ ( eventId === undefined || ! standaloneSse . replayedEventIds ?. has ( eventId ) )
1046+ ) {
9921047 this . writeSSEEvent ( standaloneSse . controller , standaloneSse . encoder , message , eventId ) ;
9931048 }
9941049 return ;
@@ -1000,17 +1055,33 @@ export class WebStandardStreamableHTTPServerTransport implements Transport {
10001055 throw new Error ( `No connection established for request ID: ${ String ( requestId ) } ` ) ;
10011056 }
10021057
1003- const stream = this . _streamMapping . get ( streamId ) ;
1004-
1005- if ( ! this . _enableJsonResponse && stream ?. controller && stream ?. encoder ) {
1006- // For SSE responses, generate event ID if event store is provided
1058+ let stream = this . _streamMapping . get ( streamId ) ;
1059+
1060+ if ( ! this . _enableJsonResponse ) {
1061+ // Store FIRST so request-related events emitted while the per-request
1062+ // stream is disconnected (e.g. after `closeSSE()` or a transient
1063+ // client drop) are replayed on Last-Event-ID reconnect — same
1064+ // store-first semantics as the standalone path above. Storage is
1065+ // keyed on request-in-flight (`_requestToStreamMapping` resolved
1066+ // `streamId` above), not on whether a live SSE writer currently
1067+ // exists: `_streamMapping` tracks the delivery target only. Per
1068+ // 2025-11-25 transports.mdx, disconnection SHOULD NOT be
1069+ // interpreted as the client cancelling its request.
10071070 let eventId : string | undefined ;
1008-
10091071 if ( this . _eventStore ) {
10101072 eventId = await this . _eventStore . storeEvent ( streamId , message ) ;
1073+ // Re-read after the await: a Last-Event-ID reconnect during
1074+ // storeEvent() may have registered a resumed stream under this
1075+ // streamId (mirrors the standalone path's post-await read).
1076+ stream = this . _streamMapping . get ( streamId ) ;
1077+ }
1078+ // Write the event to the response stream — unless the resumed
1079+ // stream's replay already delivered this exact eventId (the store
1080+ // committed before replay scanned, so replay wrote it; identity
1081+ // dedup only, no ordering assumption).
1082+ if ( stream ?. controller && stream ?. encoder && ( eventId === undefined || ! stream . replayedEventIds ?. has ( eventId ) ) ) {
1083+ this . writeSSEEvent ( stream . controller , stream . encoder , message , eventId ) ;
10111084 }
1012- // Write the event to the response stream
1013- this . writeSSEEvent ( stream . controller , stream . encoder , message , eventId ) ;
10141085 }
10151086
10161087 if ( isJSONRPCResultResponse ( message ) || isJSONRPCErrorResponse ( message ) ) {
@@ -1022,7 +1093,37 @@ export class WebStandardStreamableHTTPServerTransport implements Transport {
10221093
10231094 if ( allResponsesReady ) {
10241095 if ( ! stream ) {
1025- throw new Error ( `No connection established for request ID: ${ String ( requestId ) } ` ) ;
1096+ if ( this . _enableJsonResponse ) {
1097+ // JSON-mode requires a resolveJson sink; with no stream entry the
1098+ // response is undeliverable.
1099+ throw new Error ( `No connection established for request ID: ${ String ( requestId ) } ` ) ;
1100+ }
1101+ if ( ! this . _eventStore ) {
1102+ // SSE-mode with no live writer and no event store: the
1103+ // response is undeliverable AND not stored. Surface via
1104+ // onerror so the drop is observable (matching pre-PR
1105+ // behaviour), then run the bookkeeping cleanup so the
1106+ // request id is retired.
1107+ this . onerror ?.(
1108+ new Error (
1109+ `Response for request ID ${ String ( requestId ) } is undeliverable: per-request stream is disconnected and no eventStore is configured`
1110+ )
1111+ ) ;
1112+ for ( const id of relatedIds ) {
1113+ this . _requestResponseMap . delete ( id ) ;
1114+ this . _requestToStreamMapping . delete ( id ) ;
1115+ }
1116+ return ;
1117+ }
1118+ // SSE-mode with no live writer and an event store configured:
1119+ // the response was stored above for replay on Last-Event-ID
1120+ // reconnect. Return cleanly after running the bookkeeping
1121+ // cleanup so the request id is retired.
1122+ for ( const id of relatedIds ) {
1123+ this . _requestResponseMap . delete ( id ) ;
1124+ this . _requestToStreamMapping . delete ( id ) ;
1125+ }
1126+ return ;
10261127 }
10271128 if ( this . _enableJsonResponse && stream . resolveJson ) {
10281129 // All responses ready, send as JSON
@@ -1040,6 +1141,7 @@ export class WebStandardStreamableHTTPServerTransport implements Transport {
10401141 } else {
10411142 stream . resolveJson ( Response . json ( responses , { status : 200 , headers } ) ) ;
10421143 }
1144+ stream . cleanup ( ) ;
10431145 } else {
10441146 // End the SSE stream
10451147 stream . cleanup ( ) ;
0 commit comments