Skip to content

fix: prime SSE streams so Firefox resolves the events fetch - #1946

Open
cliffhall wants to merge 1 commit into
v2/mainfrom
v2/fix/firefox-sse-stream-hang
Open

fix: prime SSE streams so Firefox resolves the events fetch#1946
cliffhall wants to merge 1 commit into
v2/mainfrom
v2/fix/firefox-sse-stream-hang

Conversation

@cliffhall

@cliffhall cliffhall commented Aug 6, 2026

Copy link
Copy Markdown
Member

Closes #1858

The bug

In Firefox the web UI never finishes connecting to any HTTP-transport MCP server — it sits on "Connecting…" indefinitely with nothing in the UI, the browser console, or the node logs. The same server connects instantly in Chrome, and works via --cli and --tui.

Root cause

A deadlock between two facts:

  1. GET /api/mcp/events sent response headers immediately but zero body bytes until there was an MCP message to report.
  2. Firefox does not hand a streaming fetch() response to JS until the first body byte arrives. Chromium resolves as soon as headers do.

RemoteClientTransport.openEventStream() awaits that fetch before the MCP Client sends initialize. So: no initialize → no message to report → no bytes written → the fetch never resolves → no initialize. Chromium escapes because it resolves on headers.

Credit to @kegi for the diagnosis on the issue — the analysis was exactly right.

Verifying the two proposed fixes

Both suggestions from the issue were tested against real Firefox 153 and Chromium with a 3-variant SSE probe, before writing any code:

Variant Firefox Chromium
Headers only, no body bytes (today) fetch never resolves resolves 4ms
+ X-Content-Type-Options: nosniff fetch never resolves resolves 1ms
+ ":\n\n" priming comment resolves 4ms resolves 1ms

So the nosniff suggestion does not fix this — the delay is not MIME sniffing. The priming write does.

The fix

Write an inert : SSE comment the instant each stream opens. A comment line is ignorable per the SSE spec, so this unblocks the read without inventing a wire event or changing the protocol.

Ordering matters: the priming write goes after each handler registers its consumer (the session event consumer; the file-watch subscriber plus ensureWatcher()). Callers treat the arrival of the stream's first bytes as proof they are subscribed, so priming first hands out that proof across an await and drops an edit made in the gap. This was caught by npm run ci — the /api/servers/events external-editor test timed out under parallel load with the naive ordering.

/api/servers/events gets the same treatment — its fetch was equally stuck on Firefox until the first real change event, which for an unedited mcp.json never comes. Its client-side reader counted any \n\n as a change, so the inert frame would have fired a spurious background re-fetch on every connection; it now skips frames with no event:/data: field. The transport's own parseSSE already drops comment frames correctly and needed no change.

Verification

End to end in real Firefox against a live streamable-HTTP test server, driven through the actual UI:

  • Before — the card sits on "Connecting…" past 25s, no error anywhere.
firefox-connect-before
  • After — the card reads "Connected", and the Protocol panel shows initializenotifications/initializedtools/list.
firefox-connect-after

Tests

Each new test was confirmed to fail with the fix reverted:

  • sse-priming.test.ts — asserts both SSE endpoints emit body bytes on an otherwise-idle stream (pre-fix: no body bytes within 3000ms on both).
  • useServers.test.tsx — asserts the priming frame adds zero list GETs (pre-fix: 2 instead of 1).

Gate status, stated precisely: npm run ci exits 1 at the coverage step, which aborts the chain before its last three stages. That is not caused by this PR — vitest fails the run on two pre-existing unhandled rejections in inspectorClient.test.ts (SdkError: Connection closed), which I reproduced identically on a clean v2/main baseline with these changes stashed. Filed as #1947.

Because the chain aborts, the skipped stages were run explicitly here, and pass:

Stage Result
validate pass
coverage 4812 passed; exits 1 on the #1947 rejections
verify:build-gate pass
smoke pass (all five)
ci:storybook 462 passed

🤖 Generated with Claude Code

https://claude.ai/code/session_01YAt8rqxysNbhYWLhoRm3fU

Firefox does not hand a streaming `fetch()` response to JS until the
first *body* byte arrives; Chromium resolves the promise as soon as the
headers do. Both SSE endpoints flushed headers immediately and then
stayed silent until there was something to report, which deadlocked
`/api/mcp/events`: `RemoteClientTransport.openEventStream()` awaits that
fetch *before* the MCP client sends `initialize`, so no `initialize` ->
no event to report -> no body byte -> the fetch never resolves. The web
UI hung on "Connecting..." forever with nothing in the UI, the console,
or the node logs.

Write an inert `:` SSE comment the instant each stream opens. Conforming
parsers ignore a comment line, so this unblocks the read without
inventing a wire event and needs no client-side protocol change.

`X-Content-Type-Options: nosniff` was also proposed on the issue but does
not fix it -- verified against Firefox 153, the fetch still never
resolves with the header set and no body byte. The delay is not MIME
sniffing.

The priming write is deliberately ordered *after* each handler registers
its consumer (the session event consumer; the file-watch subscriber plus
`ensureWatcher()`). Callers treat the arrival of the stream's first bytes
as proof they are subscribed, so priming first would hand out that proof
across an `await` and drop an edit made in the gap.

`/api/servers/events` gets the same treatment: its fetch was equally
stuck on Firefox until the first real change event, which for an unedited
`mcp.json` never comes. Its client-side reader counted any `\n\n` as a
change, so the inert frame would have fired a spurious background
re-fetch on every connection -- it now skips frames carrying no
`event:`/`data:` field. The transport's own `parseSSE` already drops
comment frames correctly and needed no change.

Verified end to end in real Firefox against a live streamable-HTTP test
server: before, stuck on "Connecting..." past 25s; after, initialize ->
notifications/initialized -> tools/list, connected.

Closes #1858

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YAt8rqxysNbhYWLhoRm3fU
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

v2 Issues and PRs for v2

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant