Skip to content

Commit 11a9ca1

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 b2242ba commit 11a9ca1

2 files changed

Lines changed: 20 additions & 6 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) sent while the per-request SSE stream is intentionally closed (e.g. after `closeSSE()` for SEP-1699 polling) are now persisted to the configured `eventStore` so they replay on reconnect. Previously they were silently dropped. Events sent after the per-request stream entry is fully torn down are not persisted.

packages/server/src/server/streamableHttp.ts

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1002,15 +1002,24 @@ export class WebStandardStreamableHTTPServerTransport implements Transport {
10021002

10031003
const stream = this._streamMapping.get(streamId);
10041004

1005-
if (!this._enableJsonResponse && stream?.controller && stream?.encoder) {
1006-
// For SSE responses, generate event ID if event store is provided
1005+
if (!this._enableJsonResponse) {
1006+
// Store FIRST so request-related events emitted while the per-request
1007+
// stream is disconnected (e.g. after `closeSSE()`) are replayed on
1008+
// reconnect — same store-first semantics as the standalone path above.
1009+
// Gated on `stream !== undefined`: when the per-request stream entry
1010+
// has been torn down entirely (the streamId is no longer in
1011+
// `_streamMapping`), the request was abandoned — do not persist for a
1012+
// replay that will never be requested. `closeSSE()` only nulls the
1013+
// controller/encoder, so the entry stays present and the store still
1014+
// runs in that intentional poll-and-replay case.
10071015
let eventId: string | undefined;
1008-
1009-
if (this._eventStore) {
1016+
if (this._eventStore && stream !== undefined) {
10101017
eventId = await this._eventStore.storeEvent(streamId, message);
10111018
}
1012-
// Write the event to the response stream
1013-
this.writeSSEEvent(stream.controller, stream.encoder, message, eventId);
1019+
if (stream?.controller && stream?.encoder) {
1020+
// Write the event to the response stream
1021+
this.writeSSEEvent(stream.controller, stream.encoder, message, eventId);
1022+
}
10141023
}
10151024

10161025
if (isJSONRPCResultResponse(message) || isJSONRPCErrorResponse(message)) {

0 commit comments

Comments
 (0)