Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions .changeset/preserve-resumption-token-on-empty-reconnect.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
---
'@modelcontextprotocol/client': patch
---

Preserve the resumption token across a resumed SSE stream that drops before any
id-bearing event arrives. `StreamableHTTPClientTransport._handleSseStream()` tracked the
latest event id in a local that started as `undefined` and was never seeded from the
`resumptionToken` the stream was opened with, yet both reconnect paths forwarded that
local as the next attempt's token. A stream resumed with `Last-Event-ID: e1` that closed
again before replaying any event — a load-balancer idle timeout, a server restart — was
therefore reconnected with no `Last-Event-ID` header at all. The server treated that as a
brand-new stream, missed events were never replayed, and a long-running request hung
until its timeout.

The tracker is now seeded from the incoming `resumptionToken`, so a reconnect that saw no
new events re-sends the same cursor. Replay from an already-seen cursor is idempotent,
and a newer event id still overrides the seed as soon as one arrives. `onresumptiontoken`
is unchanged: it fires only for ids actually received on the wire, not for the seed.

Fixes #2499.
9 changes: 7 additions & 2 deletions packages/client/src/client/streamableHttp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -720,15 +720,20 @@ export class StreamableHTTPClientTransport implements Transport {
options.onRequestStreamEnd?.();
return;
}
const { onresumptiontoken, replayMessageId, requestSignal, onRequestStreamEnd } = options;
const { resumptionToken, onresumptiontoken, replayMessageId, requestSignal, onRequestStreamEnd } = options;
// An intentional abort — transport-wide close OR a per-request abort
// (McpSubscription.close() aborting its `requestSignal`) — must read as
// a clean shutdown: no misleading "SSE stream disconnected" onerror,
// and no GET+Last-Event-ID reconnect that would resurrect a stream the
// caller just tore down.
const isIntentionalAbort = (): boolean => this._abortController?.signal.aborted === true || requestSignal?.aborted === true;

let lastEventId: string | undefined;
// Seed from the token this stream was opened with: if the stream drops
// again before any id-bearing event arrives (LB idle timeout, server
// restart), the reconnect must re-send the same Last-Event-ID rather
// than silently downgrade to a fresh, non-resumed stream. Replay from
// the same cursor is idempotent, so re-sending it is always safe.
let lastEventId: string | undefined = resumptionToken;
// Track whether we've received a priming event (event with ID)
// Per spec, server SHOULD send a priming event with ID before closing
let hasPrimingEvent = false;
Expand Down
113 changes: 113 additions & 0 deletions packages/client/test/client/streamableHttp.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1246,6 +1246,119 @@ describe('StreamableHTTPClientTransport', () => {
expect(fetchMock.mock.calls[1]![1]?.method).toBe('GET');
});

describe('resumption token preserved across an empty resumed stream', () => {
// Regression for #2499: a stream opened with `resumptionToken` that
// drops again BEFORE any id-bearing event arrives (LB idle timeout,
// server restart) must re-send the same Last-Event-ID on reconnect
// rather than silently downgrading to a fresh, non-resumed stream.
const sseResponse = (body: ReadableStream | null) => ({
ok: true,
status: 200,
headers: new Headers({ 'content-type': 'text/event-stream' }),
body
});

const makeTransport = () =>
new StreamableHTTPClientTransport(new URL('http://localhost:1234/mcp'), {
reconnectionOptions: {
initialReconnectionDelay: 10,
maxRetries: 1,
maxReconnectionDelay: 1000,
reconnectionDelayGrowFactor: 1
}
});

const lastEventIdOf = (fetchMock: Mock, call: number): string | null =>
(fetchMock.mock.calls[call]![1]?.headers as Headers).get('last-event-id');

it('re-sends the same Last-Event-ID when the resumed stream closes gracefully before any id-bearing event', async () => {
// ARRANGE
transport = makeTransport();
const fetchMock = globalThis.fetch as Mock;
// Resumed GET: accepted, but closes cleanly with no events.
fetchMock.mockResolvedValueOnce(
sseResponse(
new ReadableStream({
start(controller) {
controller.close();
}
})
)
);
// The reconnect GET.
fetchMock.mockResolvedValueOnce(sseResponse(new ReadableStream()));

// ACT
await transport.start();
await transport['_startOrAuthSse']({ resumptionToken: 'event-1' });
await vi.advanceTimersByTimeAsync(20);

// ASSERT
expect(fetchMock).toHaveBeenCalledTimes(2);
expect(lastEventIdOf(fetchMock, 0)).toBe('event-1');
expect(lastEventIdOf(fetchMock, 1)).toBe('event-1');
});

it('re-sends the same Last-Event-ID when the resumed stream errors before any id-bearing event', async () => {
// ARRANGE
transport = makeTransport();
transport.onerror = vi.fn();
const fetchMock = globalThis.fetch as Mock;
// Resumed GET: accepted, then the socket dies with no events.
fetchMock.mockResolvedValueOnce(
sseResponse(
new ReadableStream({
start(controller) {
controller.error(new Error('Network failure'));
}
})
)
);
fetchMock.mockResolvedValueOnce(sseResponse(new ReadableStream()));

// ACT
await transport.start();
await transport['_startOrAuthSse']({ resumptionToken: 'event-1' });
await vi.advanceTimersByTimeAsync(20);

// ASSERT
expect(fetchMock).toHaveBeenCalledTimes(2);
expect(lastEventIdOf(fetchMock, 1)).toBe('event-1');
});

it('prefers a newer event id over the seeded token once one arrives', async () => {
// ARRANGE
transport = makeTransport();
const onresumptiontoken = vi.fn();
const fetchMock = globalThis.fetch as Mock;
const encoder = new TextEncoder();
// Resumed GET replays one id-bearing event, then closes.
fetchMock.mockResolvedValueOnce(
sseResponse(
new ReadableStream({
start(controller) {
controller.enqueue(encoder.encode('id: event-2\ndata: \n\n'));
controller.close();
}
})
)
);
fetchMock.mockResolvedValueOnce(sseResponse(new ReadableStream()));

// ACT
await transport.start();
await transport['_startOrAuthSse']({ resumptionToken: 'event-1', onresumptiontoken });
await vi.advanceTimersByTimeAsync(20);

// ASSERT
expect(fetchMock).toHaveBeenCalledTimes(2);
expect(lastEventIdOf(fetchMock, 1)).toBe('event-2');
// The seed is not re-announced; only genuinely new ids surface.
expect(onresumptiontoken).toHaveBeenCalledTimes(1);
expect(onresumptiontoken).toHaveBeenCalledWith('event-2');
});
});

it('should NOT reconnect a POST-initiated stream that fails', async () => {
// ARRANGE
transport = new StreamableHTTPClientTransport(new URL('http://localhost:1234/mcp'), {
Expand Down
Loading