What happened?
StreamableHTTPClientTransport fails to update its resumption state when an SSE event contains an explicitly empty id field.
After receiving a non-empty ID followed by id:, the previous ID remains in the transport's resumption state and is reused on the next reconnect.
The current logic in [streamableHttp.ts](https://github.com/modelcontextprotocol/typescript-sdk/blob/5119ee7fd7790e335a3fb60ef36f85334e2a6326/packages/client/src/client/streamableHttp.ts#L761-L767) uses a truthiness check:
if (event.id) {
lastEventId = event.id;
hasPrimingEvent = true;
onresumptiontoken?.(event.id);
}
For a dispatched SSE block with an empty ID, eventsource-parser produces event.id === "", so this condition skips both the state update and the callback.
As a result, an explicitly cleared event ID is treated the same as if no new ID had been received.
What did you expect?
An empty id: should reset the last event ID to the empty string.
The onresumptiontoken callback should receive "", and a subsequent standalone GET reconnect should omit the Last-Event-ID header.
The [WHATWG SSE specification](https://html.spec.whatwg.org/dev/server-sent-events.html#the-event-stream-format) explicitly describes an empty id field as resetting the last event ID to the empty string, so the header is no longer sent on reconnection.
This is different from an event that omits the id field entirely, which should leave the previous event ID unchanged.
One detail worth handling separately is hasPrimingEvent: an empty ID clears the current resumption token, so it should not necessarily be treated as a usable priming/resumption token for a POST response stream. Simply changing the condition to event.id !== undefined while always setting hasPrimingEvent = true may preserve stale resumability state after the token itself has been cleared.
Code to reproduce
Return this SSE response from the initial standalone GET, then close its body:
id: stale-event-id
data:
id:
data:
The exact bytes are:
const body = 'id: stale-event-id\ndata: \n\nid:\ndata: \n\n';
Allow the transport to reconnect and record its onresumptiontoken calls and the next GET's headers.
Return an open SSE body for the second GET to prevent additional reconnects.
The local regression test exercises the real transport and SSE parser with mocked fetch responses and fake timers. Its callback assertion fails on the commit below:
Expected: [["stale-event-id"], [""]]
Received: [["stale-event-id"]]
Because lastEventId remains "stale-event-id", the reconnect path forwards that stale token instead of clearing it.
A regression test should verify:
- the callback receives both
"stale-event-id" and "";
- the reconnect after the empty ID does not send
Last-Event-ID;
- an event that omits
id entirely still preserves the previous ID.
This reproduction intentionally uses an empty data: field so the SSE block is dispatched by the currently pinned eventsource-parser version. An ID-only block such as id:\n\n may involve a separate parser-level behavior and is outside the narrow scope of this report.
SDK version
main at 5119ee7fd7790e335a3fb60ef36f85334e2a6326
Node.js 22.19.0
Windows
Area
Client / Transports
Related work
#2684 addresses retry accounting and preserving an existing resumption token when a resumed response contains no events.
This case is different: the server explicitly sends an empty id field, which according to SSE semantics resets the current event ID.
This concerns the v2 SDK's Streamable HTTP resumability path rather than a change specific to the 2026-07-28 MCP protocol version.
AI assistance was used to investigate this behavior, prepare the local regression test, and draft this report.
What happened?
StreamableHTTPClientTransportfails to update its resumption state when an SSE event contains an explicitly emptyidfield.After receiving a non-empty ID followed by
id:, the previous ID remains in the transport's resumption state and is reused on the next reconnect.The current logic in
[streamableHttp.ts](https://github.com/modelcontextprotocol/typescript-sdk/blob/5119ee7fd7790e335a3fb60ef36f85334e2a6326/packages/client/src/client/streamableHttp.ts#L761-L767)uses a truthiness check:For a dispatched SSE block with an empty ID,
eventsource-parserproducesevent.id === "", so this condition skips both the state update and the callback.As a result, an explicitly cleared event ID is treated the same as if no new ID had been received.
What did you expect?
An empty
id:should reset the last event ID to the empty string.The
onresumptiontokencallback should receive"", and a subsequent standalone GET reconnect should omit theLast-Event-IDheader.The [WHATWG SSE specification](https://html.spec.whatwg.org/dev/server-sent-events.html#the-event-stream-format) explicitly describes an empty
idfield as resetting the last event ID to the empty string, so the header is no longer sent on reconnection.This is different from an event that omits the
idfield entirely, which should leave the previous event ID unchanged.One detail worth handling separately is
hasPrimingEvent: an empty ID clears the current resumption token, so it should not necessarily be treated as a usable priming/resumption token for a POST response stream. Simply changing the condition toevent.id !== undefinedwhile always settinghasPrimingEvent = truemay preserve stale resumability state after the token itself has been cleared.Code to reproduce
Return this SSE response from the initial standalone GET, then close its body:
The exact bytes are:
Allow the transport to reconnect and record its
onresumptiontokencalls and the next GET's headers.Return an open SSE body for the second GET to prevent additional reconnects.
The local regression test exercises the real transport and SSE parser with mocked fetch responses and fake timers. Its callback assertion fails on the commit below:
Because
lastEventIdremains"stale-event-id", the reconnect path forwards that stale token instead of clearing it.A regression test should verify:
"stale-event-id"and"";Last-Event-ID;identirely still preserves the previous ID.This reproduction intentionally uses an empty
data:field so the SSE block is dispatched by the currently pinnedeventsource-parserversion. An ID-only block such asid:\n\nmay involve a separate parser-level behavior and is outside the narrow scope of this report.SDK version
mainat5119ee7fd7790e335a3fb60ef36f85334e2a6326Node.js 22.19.0
Windows
Area
Client / Transports
Related work
#2684 addresses retry accounting and preserving an existing resumption token when a resumed response contains no events.
This case is different: the server explicitly sends an empty
idfield, which according to SSE semantics resets the current event ID.This concerns the v2 SDK's Streamable HTTP resumability path rather than a change specific to the 2026-07-28 MCP protocol version.
AI assistance was used to investigate this behavior, prepare the local regression test, and draft this report.