Skip to content

[v2] Streamable HTTP client does not clear stale resumption token after an empty SSE id #2769

Description

@znnnnnnn-wil

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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions