MCP Reconnect Sends a Stale Last-Event-ID? Fix Empty SSE IDs

MCP Reconnect Sends a Stale Last-Event-ID? Fix Empty SSE IDs

Todd Mercer

A coral ribbon passes through a luminous reset plane and continues as a clean amber curve across cobalt forms.

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:

1. The client receives an SSE event with id: cursor-17
2. The client receives a later SSE event with id:
3. The connection ends
4. A standalone GET reconnect sends Last-Event-ID: cursor-17

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

id: cursor-17

cursor-17

Last-Event-ID: cursor-17

no id field

unchanged

preserve the previous behavior

explicitly empty id:

empty string

omit Last-Event-ID

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:

node --version
pnpm list @modelcontextprotocol/client @modelcontextprotocol/sdk

Then capture only the protocol facts needed for the diagnosis:

  • whether the affected transport is Streamable HTTP;

  • the ordered SSE id fields, including the difference between absent and empty;

  • whether the reconnect is a standalone GET or a POST-initiated response stream;

  • the outbound Last-Event-ID header;

  • 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:

id: cursor-17
data: first

id:
data: reset

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:

  1. onresumptiontoken receives cursor-17 after the first event.

  2. onresumptiontoken receives the empty string after the reset event.

  3. 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-ID header, 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:

aident account auth status
aident vault vault --action status
aident capabilities search \
  --query 'get one GitHub issue by owner repository and issue number' \
  --targetEnv staging \
  --limit 5
aident capabilities get \
  --name composio:github_tools:github_get_an_issue
aident capabilities preflight \
  --name composio:github_tools:github_get_an_issue \
  --input '{"owner":"modelcontextprotocol","repo":"typescript-sdk","issue_number":2769}'

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:

  1. Did you record the exact client package and Node.js version?

  2. Did the captured stream contain an explicitly empty id: rather than no ID field?

  3. Did the callback receive the empty string?

  4. Did the next standalone GET omit Last-Event-ID?

  5. Did the absent-ID control preserve the previous token?

  6. Did the POST-stream test clear unusable resumability state?

  7. Did you avoid exposing production tokens and message bodies?

  8. Is the deployed package tied to a merged release rather than an open pull request?

Sources and refresh trigger

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

Illustrated editorial avatar of Todd Mercer

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.

Home

Home

Home

Integrations

Integrations

Integrations

Vault

Vault

Vault

Audit

Audit

Audit

Arana Grande

Arana Grande

Arana Grande

Free

Free

Free

30-day audit summary

30-day audit summary

30-day audit summary

Daily action-call volume and the latest receipts from the Loadout audit trail.

Daily action-call volume and the latest receipts from the Loadout audit trail.

Daily action-call volume and the latest receipts from the Loadout audit trail.

View Audit

View Audit

View Audit

Loadout usage

Loadout usage

Loadout usage

617 action calls in the last 30 days

617 action calls in the last 30 days

617 action calls in the last 30 days

May 19 - Jun 17

May 19 - Jun 17

May 19 - Jun 17

10 active days

10 active days

10 active days

Less

Less

Less

More

More

More

Recent activity

Recent activity

Recent activity

Latest action-call receipts from connected agents

Latest action-call receipts from connected agents

Latest action-call receipts from connected agents

Apr 23, 09:23 AM

Apr 23, 09:23 AM

Apr 23, 09:23 AM

Shopify

Shopify

Shopify

Creates Or Updates An Asset For A Theme

Creates Or Updates An Asset For A Theme

Creates Or Updates An Asset For A Theme

Success

Success

Success

Apr 23, 09:21 AM

Apr 23, 09:21 AM

Apr 23, 09:21 AM

Shopify

Shopify

Shopify

Update Products Param Product Id

Update Products Param Product Id

Update Products Param Product Id

Success

Success

Success

Apr 23, 08:53 AM

Apr 23, 08:53 AM

Apr 23, 08:53 AM

Shopify

Shopify

Shopify

Update Products Param Product Id

Update Products Param Product Id

Update Products Param Product Id

Failed

Failed

Failed

Apr 22, 22:13 PM

Apr 22, 22:13 PM

Apr 22, 22:13 PM

Shopify

Shopify

Shopify

Create Product Image

Create Product Image

Create Product Image

Success

Success

Success

Apr 22, 22:12 PM

Apr 22, 22:12 PM

Apr 22, 22:12 PM

Shopify

Shopify

Shopify

Create Product Image

Create Product Image

Create Product Image

Success

Success

Success

Connected integration coverage

Connected integration coverage

Connected integration coverage

162

162

162

of 753 accessible connected

of 753 accessible connected

of 753 accessible connected

Callable actions

Callable actions

Callable actions

1,126

1,126

1,126

Vault credentials

Vault credentials

Vault credentials

8

8

8

Explore what's possible

Explore what's possible

Explore what's possible

See all Integrations

See all Integrations

See all Integrations

Google Ads

Google Ads

Google Ads

All available Goolge Ads tools via...

All available Goolge Ads tools via...

All available Goolge Ads tools via...

X (twitter)

X (twitter)

X (twitter)

All available X tools via...

All available X tools via...

All available X tools via...

Github

Github

Github

All available Github tools via...

All available Github tools via...

All available Github tools via...

Notion

Notion

Notion

All available Notion tools via...

All available Notion tools via...

All available Notion tools via...

Slack

Slack

Slack

All available Slack tools via...

All available Slack tools via...

All available Slack tools via...

Firecrawl

Firecrawl

Firecrawl

All available Firecrawl tools via...

All available Firecrawl tools via...

All available Firecrawl tools via...

753 integrations are available for loadouts.

753 integrations are available for loadouts.

753 integrations are available for loadouts.

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.

Try Aident Loadout

Empower your Codex or OpenClaws to get real jobs done. Connect 27,000+ tools in one prompt, and let your agents deliver real results.

Try Aident Loadout

Empower your Codex or OpenClaws to get real jobs done. Connect 27,000+ tools in one prompt, and let your agents deliver real results.

Try Aident Loadout

Empower your Codex or OpenClaws to get real jobs done. Connect 27,000+ tools in one prompt, and let your agents deliver real results.