Skip to content

Commit 3b4f61a

Browse files
fix(server): streamableHttp stores request-related events when stream is disconnected
The standalone-SSE path stores to eventStore first, then writes if connected. The request-related path only stored when the stream was live, so a notification sent after closeSSE() (SEP-1699 polling) was silently dropped instead of being persisted for replay on reconnect. Exposed by the ctx.mcpReq.log request-related change against the new sse-polling example story; the gap pre-exists on main for any request-related notification (progress, ctx.mcpReq.notify) emitted after closeSSE().
1 parent 9a74619 commit 3b4f61a

3 files changed

Lines changed: 538 additions & 18 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@modelcontextprotocol/server': patch
3+
---
4+
5+
`WebStandardStreamableHTTPServerTransport`: request-related events (progress, `ctx.mcpReq.notify`, handler-emitted log) and the final response are now persisted to the configured `eventStore` whenever the request is in flight, regardless of whether a live SSE writer currently exists — mirroring the standalone-SSE path's store-first semantics. This fixes the `closeSSE()` poll-and-replay drop (events emitted after `closeSSE()` were previously silently lost) and aligns with the 2025-11-25 specification ("disconnection SHOULD NOT be interpreted as the client cancelling its request"). When an `eventStore` is configured, a final response sent while no per-request stream is connected is stored for replay and returns cleanly instead of throwing "No connection established"; a `Last-Event-ID` reconnect after the request has been retired replays the stored response and then closes the resumed stream. When no `eventStore` is configured, the same condition is surfaced via `onerror` (the response is undeliverable) and the request id is retired.

packages/server/src/server/streamableHttp.ts

Lines changed: 120 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)