fix: prime SSE streams so Firefox resolves the events fetch - #1946
Open
cliffhall wants to merge 1 commit into
Open
fix: prime SSE streams so Firefox resolves the events fetch#1946cliffhall wants to merge 1 commit into
cliffhall wants to merge 1 commit into
Conversation
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
--cliand--tui.Root cause
A deadlock between two facts:
GET /api/mcp/eventssent response headers immediately but zero body bytes until there was an MCP message to report.fetch()response to JS until the first body byte arrives. Chromium resolves as soon as headers do.RemoteClientTransport.openEventStream()awaits that fetch before the MCPClientsendsinitialize. So: noinitialize→ no message to report → no bytes written → the fetch never resolves → noinitialize. 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:
+ X-Content-Type-Options: nosniff+ ":\n\n"priming commentSo the
nosniffsuggestion 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 anawaitand drops an edit made in the gap. This was caught bynpm run ci— the/api/servers/eventsexternal-editor test timed out under parallel load with the naive ordering./api/servers/eventsgets the same treatment — its fetch was equally stuck on Firefox until the first real change event, which for an uneditedmcp.jsonnever comes. Its client-side reader counted any\n\nas a change, so the inert frame would have fired a spurious background re-fetch on every connection; it now skips frames with noevent:/data:field. The transport's ownparseSSEalready 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:
initialize→notifications/initialized→tools/list.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 3000mson both).useServers.test.tsx— asserts the priming frame adds zero list GETs (pre-fix: 2 instead of 1).Gate status, stated precisely:
npm run ciexits 1 at thecoveragestep, 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 ininspectorClient.test.ts(SdkError: Connection closed), which I reproduced identically on a cleanv2/mainbaseline with these changes stashed. Filed as #1947.Because the chain aborts, the skipped stages were run explicitly here, and pass:
validatecoverageverify:build-gatesmokeci:storybook🤖 Generated with Claude Code
https://claude.ai/code/session_01YAt8rqxysNbhYWLhoRm3fU