Todd Mercer

MCP Reconnect Sends a Stale Last-Event-ID? Fix Empty SSE IDs
If an MCP TypeScript client reconnects after the server sends an empty SSE id: field, it must clear the previous resumption token. A reconnect that still sends the old Last-Event-ID can replay an invalid cursor, duplicate events, or make a server reject the resume attempt. Do not treat an empty ID as if the ID field were absent.
This guide is for developers operating an MCP client or Streamable HTTP integration. The job is complete when a controlled sequence of one non-empty ID, one explicitly empty ID, and one reconnect produces no Last-Event-ID header. The evidence below applies to the open TypeScript SDK v2 issue and proposed fix reviewed on September 9, 2026. It does not establish that every duplicate event or reconnect failure has this cause.
Recognize the exact failure
The relevant sequence is small:
Step 4 is the bug. The empty id: in step 2 resets the last event ID to the empty string under the WHATWG Server-Sent Events rules. When the stored value is empty, the reconnect should omit Last-Event-ID.
An event that has no id field is different. It leaves the previous value alone. Collapsing both cases into a truthy check loses that distinction:
Incoming event | Correct stored state | Next reconnect header |
|---|---|---|
|
|
|
no | unchanged | preserve the previous behavior |
explicitly empty | empty string | omit |
The TypeScript SDK issue traced the failure to a truthiness guard in StreamableHTTPClientTransport. Because an empty string is false in JavaScript, the client skipped both its state update and the onresumptiontoken callback.
Check whether your symptom matches
Record the client package and runtime before changing code:
Then capture only the protocol facts needed for the diagnosis:
whether the affected transport is Streamable HTTP;
the ordered SSE
idfields, including the difference between absent and empty;whether the reconnect is a standalone GET or a POST-initiated response stream;
the outbound
Last-Event-IDheader;whether the application observed a duplicate, a rejected cursor, or only a suspicious header.
Redact bearer tokens, cookies, message bodies, customer data, and full production URLs. A resumption token may itself be sensitive or linkable, so use synthetic IDs in a local reproduction.
If the server never sent an explicitly empty id:, stop here. Investigate a different reconnect boundary, such as an expired cursor, server retention, proxy buffering, or duplicate application processing. If the client is not the TypeScript SDK v2 transport named in the issue, use the SSE rule as a behavioral test, not as proof of the same implementation defect.
Reproduce the reset with three assertions
Build a local test server or transport harness that emits dispatched SSE events in this order:
The blank line after each data field dispatches the event. After the second event, end the stream and allow the client to make its normal standalone GET reconnect.
Assert all three behaviors separately:
onresumptiontokenreceivescursor-17after the first event.onresumptiontokenreceives the empty string after the reset event.The reconnect omits
Last-Event-ID.
Add a control where the second event has data: no-reset but no id field. In that control, the previous token should remain available. This control prevents a broad repair from clearing valid resumption state on every event.
Also test a POST-initiated response stream. Clearing the token should not leave a separate boolean saying that a usable priming token still exists. The open issue explicitly calls out this second state boundary, which is why changing only if (event.id) to if (event.id !== undefined) can be incomplete.
Expected result on the affected implementation: the empty-string callback assertion fails or the reconnect still carries cursor-17. Expected result after a complete fix: the empty string is observable, the standalone GET reconnect has no stale header, the absent-ID control preserves the earlier token, and POST-stream resumability is cleared when no usable token remains.
Use the upstream fix as the review boundary
The focused TypeScript SDK pull request 2771 proposes four related changes:
recognize an explicitly empty parsed ID as a real update;
call
onresumptiontoken("");reconnect a standalone GET without
Last-Event-ID;clear POST-stream resumability when the token is no longer usable.
Its author reported 881 passing client tests, including 73 Streamable HTTP tests, plus typecheck, lint, and build checks on Node.js 22.19.0. An independent commenter reported a focused reproduction and a red-before-green regression test on a separate branch. Those are useful implementation signals, but the pull request was still open when this article was prepared. There was no released fixed package version to recommend.
Do not silently copy an unmerged patch into production or claim that upgrading fixes the problem until the change lands in a release. Choose one explicit temporary boundary:
keep the last package version your own reconnect suite has validated;
create a fresh client and transport session after the server intentionally clears its ID, if your application can do so without losing required state;
disable resumable delivery for the affected route and accept a documented non-resumable fallback; or
hold the rollout until an upstream release passes your four-case regression suite.
The right fallback depends on whether your system can safely replay events. If writes or other non-idempotent effects can occur, fail closed instead of reconnecting with an ambiguous cursor.
Why clearing the header matters
Last-Event-ID tells the server where the client believes it should resume. An empty SSE ID is the server's way to revoke that position. Reusing the old value after the reset makes the client state more authoritative than the protocol message that changed it.
The practical harm varies by server:
an event may be delivered twice;
the server may reject an unknown or retired cursor;
the client may reconnect repeatedly with the same stale state;
a POST response stream may be treated as resumable even though no valid token remains.
The transport fix should therefore preserve three states, not two: a usable non-empty token, an explicitly cleared token, and no new ID information. That model also makes logs and regression tests easier to interpret.
Avoid common false fixes
Do not:
strip every
Last-Event-IDheader, which disables valid resumption;treat every event without an ID as a reset;
retry indefinitely against the same rejected cursor;
change the server to emit a fake non-empty ID merely to satisfy a buggy client;
log real resumption tokens or authorization headers;
declare the issue fixed from one successful reconnect without the absent-ID control;
publish an upstream version number before a release containing the fix exists.
If a reverse proxy rewrites or buffers the SSE stream, capture the bytes on both sides of that boundary. The client cannot apply empty-ID semantics to an event it never receives.
Choose where the transport should live
If you are building an MCP client, keep the regression suite close to the transport and pin upgrades until the empty-ID, absent-ID, GET, and POST cases pass. If your actual job is to give an agent access to GitHub, Linear, or another SaaS API, operating a custom MCP transport may be unnecessary.
The local versus remote MCP guide explains the ownership tradeoff. The GitHub MCP Server versus Aident Loadout comparison shows how a managed catalog changes setup, credential, approval, and maintenance boundaries.
With Aident Loadout, the measurable next step is not a generic homepage visit. Follow the Aident setup instructions, search for one integration Action that matches your job, inspect its current schema, and run a free preflight without executing the provider Action. Success means your agent can evaluate a managed path without copying a provider token or depending on the broken client transport you are diagnosing.
The public CLI sequence used for this article was:
Expected result: authentication and Vault are ready, discovery returns the exact current Action name, schema inspection confirms the required inputs, and preflight reports valid input at zero estimated credits. Stop before execute if your goal is only to evaluate the managed path.
Verification checklist
Before closing the incident, confirm:
Did you record the exact client package and Node.js version?
Did the captured stream contain an explicitly empty
id:rather than no ID field?Did the callback receive the empty string?
Did the next standalone GET omit
Last-Event-ID?Did the absent-ID control preserve the previous token?
Did the POST-stream test clear unusable resumability state?
Did you avoid exposing production tokens and message bodies?
Is the deployed package tied to a merged release rather than an open pull request?
Sources and refresh trigger
TypeScript SDK issue 2769: stale resumption token after an empty SSE ID, opened September 8, 2026 and reviewed September 9, 2026.
TypeScript SDK pull request 2771: clear resumption state on empty SSE event IDs, open and reviewed September 9, 2026.
WHATWG Server-Sent Events event-stream format, including the empty-ID reset example.
MCP 2026-07-28 Streamable HTTP transport specification, reviewed September 9, 2026.
Refresh this guide when pull request 2771 merges or closes, a fixed TypeScript client package ships, MCP changes its Streamable HTTP resumption rules, or a maintainer documents a different supported workaround.
About the author

Todd Mercer
Todd Mercer is an editorial pen name used by Aident's developer-tools team. This column covers agent CLI configuration, MCP connectivity, authentication, permissions, and troubleshooting. Guides focus on observable symptoms, documented fixes, reproducible checks where available, and the limits of each workaround.



The one tool
for every tool
your agent needs.
Give any AI agent real capabilities in seconds. Connect 27,000+ tools once, skip the setup headache, and let your agents execute.


