Skip to content

fix: keep quiet MCP SSE streams alive - #2054

Open
utpalsinghdev wants to merge 2 commits into
UsefulSoftwareCo:mainfrom
utpalsinghdev:utpalsinghdev/issue-1983-mcp-sse-heartbeat
Open

utpalsinghdev wants to merge 2 commits into
UsefulSoftwareCo:mainfrom
utpalsinghdev:utpalsinghdev/issue-1983-mcp-sse-heartbeat

Conversation

@utpalsinghdev

@utpalsinghdev utpalsinghdev commented Sep 18, 2026

Copy link
Copy Markdown
Contributor

Summary

After initialize + notifications/initialized, a valid Streamable HTTP GET /mcp returns 200 text/event-stream and then writes nothing until a server-initiated message exists. Bun fetch waits on the first body byte and times out. Bun.serve({ idleTimeout: 0 }) keeps the socket open; it does not emit those bytes.

This adds withMcpSseHeartbeat in @executor-js/host-mcp/sse-heartbeat. It wraps a successful GET SSE response, writes : keepalive\n\n immediately, then repeats that comment every 25s (same frame and interval as the Cloudflare agents bridge). Original SSE events pass through in order. Status, status text, and headers are copied. Canceling the client body cancels the SDK stream and clears the timer, so a later GET is not 409.

Call sites: createMcpRequestHandler in apps/local/src/mcp.ts (CLI, local daemon, desktop sidecar) and makeInMemoryMcpSessionStore (self-host). The serving envelope does not wrap, so a Cloudflare path that already keepalives is not wrapped again. packages/hosts/cloudflare and the agents patch are unchanged.

Issue #1983 also lists returning 405 for standalone GET. The SDK treats 405 as "this server does not offer SSE." Executor still uses that GET for unsolicited server requests and native elicitation, so this keeps GET and heartbeats the quiet body.

Linked issue

Closes #1983

Reproduction

  1. createMcpRequestHandler on Bun.serve({ port: 0, idleTimeout: 0 }).
  2. POST initialize (200, capture mcp-session-id).
  3. POST notifications/initialized (202).
  4. GET /mcp with Accept: text/event-stream and a 1s abort.

Before the wrap, Bun fetch does not settle: TimeoutError at ~1023ms. Curl can see HTTP 200 headers with no body bytes. After the wrap, GET is 200 text/event-stream, first chunk is : keepalive\n\n, cancel, reconnect GET is 200 again.

Verification

  • bun run --cwd packages/hosts/mcp test -- src/sse-heartbeat.test.ts src/in-memory-session-store.test.ts: exit 0, 2 files, 25 tests.
  • bun run --cwd apps/local test -- src/mcp-sse-heartbeat.test.ts: exit 0, 2 tests. Sabotage on unmodified handler: both cases TimeoutError at ~1s. After wrap: ~30ms.
  • cd e2e && bunx --bun vitest run --project local local/mcp-standalone-get.test.ts: exit 0, 1 test. Run dir: e2e/runs/local/local-a-quiet-mcp-get-stream-stays-alive-with-an-sse-keepalive-comment/ (result.json ok, terminal.cast).
  • bun run format:check: exit 0.
  • bun run lint: exit 0.
  • bun run typecheck: exit 0, 45/45 packages.
  • bun run test under full turbo: exit 1. @executor-js/sdk hit 5s timeouts (oauth-ema-lifecycle, oauth-first-party, oauth-flow, oauth-register-dynamic, plugin-after-commit, schema-types). Isolated bun run --cwd packages/core/sdk test: exit 0, 64 files, 921 tests. Unrelated to this diff; same class of parallel-load flake under full turbo.

Envelope test forwards a live GET SSE body without injecting keepalive comments pins that cloud/agents streams are not double-wrapped here.

Docs

None. Behavior is covered by the unit, local Bun, and e2e tests above.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[bug] Quiet Streamable HTTP GET /mcp stream times out for Bun clients

1 participant