Skip to content

Commit dd652fd

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 3f3fe0d commit dd652fd

3 files changed

Lines changed: 135 additions & 8 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 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, and the final response no longer surfaces a spurious "No connection established" send error. Previously they were silently dropped. Events sent after a hard client disconnect (the per-request stream cancelled by the client) are not persisted.

packages/server/src/server/streamableHttp.ts

Lines changed: 32 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -941,9 +941,24 @@ export class WebStandardStreamableHTTPServerTransport implements Transport {
941941
if (!streamId) return;
942942

943943
const stream = this._streamMapping.get(streamId);
944-
if (stream) {
945-
stream.cleanup();
944+
if (stream?.controller) {
945+
try {
946+
stream.controller.close();
947+
} catch {
948+
// Controller might already be closed
949+
}
946950
}
951+
// Keep a placeholder _streamMapping entry (controller/encoder cleared)
952+
// so send() can distinguish an intentional poll-and-replay close from a
953+
// hard client disconnect (the ReadableStream cancel callback deletes
954+
// the entry entirely). The placeholder is removed when the final
955+
// response is sent (or overwritten when the client reconnects via
956+
// Last-Event-ID and replayEvents() registers the resumed stream).
957+
this._streamMapping.set(streamId, {
958+
cleanup: () => {
959+
this._streamMapping.delete(streamId);
960+
}
961+
});
947962
}
948963

949964
/**
@@ -1002,15 +1017,24 @@ export class WebStandardStreamableHTTPServerTransport implements Transport {
10021017

10031018
const stream = this._streamMapping.get(streamId);
10041019

1005-
if (!this._enableJsonResponse && stream?.controller && stream?.encoder) {
1006-
// For SSE responses, generate event ID if event store is provided
1020+
if (!this._enableJsonResponse) {
1021+
// Store FIRST so request-related events emitted while the per-request
1022+
// stream is disconnected (e.g. after `closeSSE()`) are replayed on
1023+
// reconnect — same store-first semantics as the standalone path above.
1024+
// Gated on `stream !== undefined`: `closeSSEStream()` keeps a
1025+
// placeholder entry (controller/encoder cleared), so the intentional
1026+
// poll-and-replay case stores; a hard client disconnect (the
1027+
// ReadableStream cancel callback) deletes the entry entirely, so an
1028+
// abandoned request does not persist for a replay that will never be
1029+
// requested.
10071030
let eventId: string | undefined;
1008-
1009-
if (this._eventStore) {
1031+
if (this._eventStore && stream !== undefined) {
10101032
eventId = await this._eventStore.storeEvent(streamId, message);
10111033
}
1012-
// Write the event to the response stream
1013-
this.writeSSEEvent(stream.controller, stream.encoder, message, eventId);
1034+
if (stream?.controller && stream?.encoder) {
1035+
// Write the event to the response stream
1036+
this.writeSSEEvent(stream.controller, stream.encoder, message, eventId);
1037+
}
10141038
}
10151039

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

packages/server/test/server/streamableHttp.test.ts

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -705,6 +705,104 @@ describe('Zod v4', () => {
705705
// Should have id: field in the SSE event
706706
expect(text).toContain('id:');
707707
});
708+
709+
it('should store request-related events emitted after closeSSEStream() and not throw on the final response', async () => {
710+
// The SEP-1699 poll-and-replay flow: handler closes the per-request
711+
// SSE stream, then emits a notification and its final result while
712+
// the client has not yet reconnected. Both must be persisted to the
713+
// eventStore (so they replay on Last-Event-ID reconnect) and the
714+
// final-response send must not surface a spurious error.
715+
mcpServer.registerTool(
716+
'poll',
717+
{ description: 'closeSSE then emit', inputSchema: z.object({}) },
718+
async (_args, ctx): Promise<CallToolResult> => {
719+
ctx.http?.closeSSE?.();
720+
await ctx.mcpReq.notify({
721+
method: 'notifications/progress',
722+
params: { progressToken: 'poll-1', progress: 75 }
723+
});
724+
return { content: [{ type: 'text', text: 'done' }] };
725+
}
726+
);
727+
728+
const sendErrors: unknown[] = [];
729+
mcpServer.server.onerror = e => sendErrors.push(e);
730+
731+
sessionId = await initializeServer();
732+
storedEvents.clear();
733+
734+
const callMessage: JSONRPCMessage = {
735+
jsonrpc: '2.0',
736+
method: 'tools/call',
737+
params: { name: 'poll', arguments: {} },
738+
id: 'poll-1'
739+
};
740+
const response = await transport.handleRequest(createRequest('POST', callMessage, { sessionId }));
741+
// closeSSE() in the handler closes the controller; drain the (now
742+
// closed) body so the Response is fully consumed.
743+
await response.text().catch(() => {});
744+
// Let the async handler chain (notify + final response send) settle.
745+
await new Promise(resolve => setTimeout(resolve, 50));
746+
747+
const stored = [...storedEvents.values()].map(e => e.message);
748+
expect(
749+
stored.some(m => 'method' in m && m.method === 'notifications/progress'),
750+
'progress notification should be stored for replay after closeSSE()'
751+
).toBe(true);
752+
expect(
753+
stored.some(m => 'id' in m && m.id === 'poll-1' && 'result' in m),
754+
'final response should be stored for replay after closeSSE()'
755+
).toBe(true);
756+
expect(sendErrors).toEqual([]);
757+
});
758+
759+
it('should not store request-related events after a hard client disconnect (stream cancel)', async () => {
760+
// A hard client disconnect (the per-request ReadableStream's cancel
761+
// callback) deletes the _streamMapping entry entirely; events emitted
762+
// after that are NOT persisted (the request was abandoned).
763+
let release!: () => void;
764+
const gate = new Promise<void>(resolve => {
765+
release = resolve;
766+
});
767+
mcpServer.registerTool(
768+
'abandon',
769+
{ description: 'emit after client disconnect', inputSchema: z.object({}) },
770+
async (_args, ctx): Promise<CallToolResult> => {
771+
await gate;
772+
await ctx.mcpReq.notify({
773+
method: 'notifications/progress',
774+
params: { progressToken: 'abandon-1', progress: 50 }
775+
});
776+
return { content: [{ type: 'text', text: 'done' }] };
777+
}
778+
);
779+
// The final-response send still throws on a hard disconnect (pre-existing);
780+
// swallow it so the test only pins the storage behavior.
781+
mcpServer.server.onerror = () => {};
782+
783+
sessionId = await initializeServer();
784+
785+
const callMessage: JSONRPCMessage = {
786+
jsonrpc: '2.0',
787+
method: 'tools/call',
788+
params: { name: 'abandon', arguments: {} },
789+
id: 'abandon-1'
790+
};
791+
const response = await transport.handleRequest(createRequest('POST', callMessage, { sessionId }));
792+
storedEvents.clear();
793+
// Hard-disconnect: cancel the per-request stream (fires the
794+
// ReadableStream cancel callback, deleting the _streamMapping entry).
795+
await response.body?.cancel();
796+
await new Promise(resolve => setTimeout(resolve, 10));
797+
release();
798+
await new Promise(resolve => setTimeout(resolve, 50));
799+
800+
const stored = [...storedEvents.values()].map(e => e.message);
801+
expect(
802+
stored.some(m => 'method' in m && m.method === 'notifications/progress'),
803+
'progress notification should NOT be stored after hard client disconnect'
804+
).toBe(false);
805+
});
708806
});
709807

710808
describe('HTTPServerTransport - Protocol Version Validation', () => {

0 commit comments

Comments
 (0)