fix(mcp): resumed interrupted HTTP response streams

Implemented 2025-11-25 Streamable HTTP polling semantics for POST SSE responses: retain event IDs and retry intervals, wait as instructed, and reconnect with GET plus Last-Event-ID until the originating JSON-RPC response arrives.

Extended the shared SSE parser to expose valid id/retry fields and control-only events so reconnecting consumers do not need to reparse raw lines.

Fixes #8264
This commit is contained in:
roboomp
2026-08-11 18:21:05 +00:00
parent b65ac59653
commit eaa5f46d32
6 changed files with 164 additions and 32 deletions
+4
View File
@@ -2,6 +2,10 @@
## [Unreleased]
### Changed
- Extended parsed Server-Sent Events with optional `id` and `retry` fields, including control-only events, so reconnecting transports can retain stream cursors and server-requested retry intervals.
## [17.2.13] - 2026-08-11
### Changed
+27 -3
View File
@@ -281,11 +281,16 @@ export async function* readSseJson<T>(
* - `raw` is the list of decoded non-empty lines that made up the event,
* preserved for diagnostic context (error reporting, debugging). The
* dispatching blank line is not included.
* - `id` and `retry` are present only when the event carried valid fields with
* those names. Control-only events are yielded so reconnecting transports can
* retain the cursor and server-requested retry interval.
*/
export interface ServerSentEvent {
event: string | null;
data: string;
raw: string[];
id?: string;
retry?: number;
}
interface SseEventState {
@@ -296,6 +301,8 @@ interface SseEventState {
// seen yet" (distinct from a `data:` field with an empty value).
data: string | null;
raw: string[];
id?: string;
retry?: number;
}
// Complete lines are decoded in one batch per source chunk. Each batch ends on
@@ -303,7 +310,7 @@ interface SseEventState {
const SSE_DECODER = new TextDecoder("utf-8");
function flushSseEvent(state: SseEventState): ServerSentEvent | null {
if (state.event === null && state.data === null) {
if (state.event === null && state.data === null && state.id === undefined && state.retry === undefined) {
state.raw = [];
return null;
}
@@ -312,9 +319,13 @@ function flushSseEvent(state: SseEventState): ServerSentEvent | null {
data: state.data ?? "",
raw: state.raw,
};
if (state.id !== undefined) event.id = state.id;
if (state.retry !== undefined) event.retry = state.retry;
state.event = null;
state.data = null;
state.raw = [];
state.id = undefined;
state.retry = undefined;
return event;
}
@@ -348,9 +359,22 @@ function pushSseLine(line: string, state: SseEventState): ServerSentEvent | null
state.data += "\n";
state.data += value;
}
} else if (fieldName === "id") {
if (!value.includes("\0")) state.id = value;
} else if (fieldName === "retry" && value.length > 0) {
let valid = true;
for (let index = 0; index < value.length; index++) {
const code = value.charCodeAt(index);
if (code < 0x30 || code > 0x39) {
valid = false;
break;
}
}
if (valid) {
const retry = Number(value);
if (Number.isSafeInteger(retry)) state.retry = retry;
}
}
// `id` and `retry` are intentionally ignored — the providers we consume
// don't use them, and the underlying transport handles reconnects itself.
return null;
}
+15
View File
@@ -396,6 +396,21 @@ describe("readSseEvents", () => {
expect(evt.raw).toEqual(["event: ping", "data: ok"]);
});
it("yields control-only id/retry events for reconnecting transports", async () => {
const stream = bytesStreamFromChunks([encoder.encode("id: stream-1\nretry: 25\n\n")]);
const events = await collectAsync(readSseEvents(stream));
expect(events).toEqual([
{
event: null,
data: "",
raw: ["id: stream-1", "retry: 25"],
id: "stream-1",
retry: 25,
},
] satisfies ServerSentEvent[]);
});
it("strips a single optional space after the field colon (and only one)", async () => {
const stream = bytesStreamFromChunks([encoder.encode("event: spaced\ndata: body\n\n")]);
const [evt] = await collectAsync(readSseEvents(stream));