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:
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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));
|
||||
|
||||
Reference in New Issue
Block a user