Merge pull request #1946 from modelcontextprotocol/v2/fix/firefox-sse-stream-hang

fix: prime SSE streams so Firefox resolves the events fetch
This commit is contained in:
Cliff Hall 2026-08-11 11:22:34 -04:00 • committed by GitHub
commit 9e074aa6f2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194

View file

@ -78,6 +78,27 @@ import { formatClientConfigLoadError } from "../../../client/config-parse.js";
import { envSecretField } from "../../../auth/secret-fields.js";
import { ZodError } from "zod";
/**
* Written to every SSE stream the instant it opens, before anything else.
*
* 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 here flush headers immediately and then stay silent
* until there is something to report, which deadlocks Firefox on
* `/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
* hangs on "Connecting…" forever with no error anywhere (#1858).
*
* A `:` comment line is inert per the SSE spec — conforming parsers ignore it
* — so priming with one unblocks the read without inventing a wire event.
*
* `X-Content-Type-Options: nosniff` does **not** fix this. Verified against
* Firefox 153: with the header and no body byte, the fetch still never
* resolves. The delay is not MIME sniffing.
*/
const SSE_PRIMING_COMMENT = ":\n\n";
/**
* Shape of the initial config returned by GET /api/config (defaults for client).
*/
@ -791,6 +812,7 @@ export function createRemoteApp(
}
});
// Prime every SSE stream the instant it opens — see SSE_PRIMING_COMMENT.
app.get("/api/mcp/events", async (c) => {
const sessionId = c.req.query("sessionId");
if (!sessionId) {
@ -824,6 +846,11 @@ export function createRemoteApp(
return;
}
// Prime only after the consumer is registered, so nothing observable
// to the client happens before this stream can actually report
// events. See SSE_PRIMING_COMMENT.
await stream.write(SSE_PRIMING_COMMENT);
stream.onAbort(() => {
// Client disconnected - clear event consumer
const shouldCleanup = session.clearEventConsumer();
@ -2395,6 +2422,16 @@ export function createRemoteApp(
ensureWatcher();
}
// Prime the stream so the client's fetch() actually resolves — on
// Firefox it otherwise stays pending until the first real change
// event, which for an unedited `mcp.json` is never. See
// SSE_PRIMING_COMMENT. Deliberately *after* the subscriber is
// registered and the watcher started: callers treat the arrival of
// this stream's first bytes as proof they are subscribed, so priming
// first would hand out that proof across an `await`, before the
// watcher exists, and drop an edit made in the gap.
await stream.write(SSE_PRIMING_COMMENT);
stream.onAbort(() => {
serverEventSubscribers.delete(send);
void maybeStopWatcher();