Skip to content

fix(mcp): stream guarded transport via undici.request so SSE responses work under Bun#5897

Merged
waleedlatif1 merged 5 commits into
stagingfrom
fix/mcp-bun-undici-streaming
Jul 23, 2026
Merged

fix(mcp): stream guarded transport via undici.request so SSE responses work under Bun#5897
waleedlatif1 merged 5 commits into
stagingfrom
fix/mcp-bun-undici-streaming

Conversation

@waleedlatif1

Copy link
Copy Markdown
Collaborator

Summary

  • Root-caused the systemic MCP `tools/list` 30s timeouts (GitHub + every `text/event-stream` MCP server): the standalone server runs under Bun, and real undici's `fetch` never delivers a streaming `response.body` under Bun — headers arrive, the body hangs, so the SDK's SSE read times out at 30s. Buffered (`application/json`) servers were unaffected; only streaming ones stalled.
  • Fix: `createSsrfGuardedFetchWithDispatcher` (the MCP transport builder) now routes through a new `undiciRequestAsResponse` helper — `undici.request()` (returns a Node `Readable`, which Bun streams natively) instead of `undici.fetch()`, wrapped back into a real `Response`. Same guarded Agent + `connect.lookup` — SSRF is byte-for-byte unchanged.
  • The Node→Web body bridge is hand-rolled (`new ReadableStream({start,pull,cancel})`), not `Readable.toWeb`, which throws an unhandleable `ERR_INVALID_STATE` on the redirect `body.cancel()` (WebStream adapter throws unhandleable error nodejs/node#54205). Handles `data`/`end`/`error`/`close` (premature close on abort rejects the reader) with `desiredSize` backpressure.
  • Scoped to the MCP builder; the pinned-fetch path (providers/tools/a2a) is untouched.

Root cause precedent

Confirmed against community reports: Bun #5964 (fetch body reader behaves as closed on streaming HTTP), Bun #30381 (keep-alive body only ends on TCP FIN → hangs). The hand-rolled bridge is the pattern nodejs/node#54205 explicitly recommends over `toWeb`.

Type of Change

  • Bug fix

Testing

  • 6/6 e2e side-effect checks pass on BOTH Node and Bun: streaming SSE, 302 redirect→SSE via `followRedirectsGuarded`, buffered `.json()`, abort-rejects-read, `maxResponseSize` DoS cap fires, 20× redirect with no socket leak.
  • 87 existing SSRF + MCP unit tests green; 5 new deterministic tests (`guarded-request-fetch.server.test.ts`) drive the real builder with a mocked `undici.request`.
  • Typecheck + biome clean.
  • Pending: staging deploy to confirm GitHub/SSE MCP servers connect in the real container.

Checklist

  • Code follows project style guidelines
  • Self-reviewed my changes
  • Tests added/updated and passing
  • No new warnings introduced
  • I confirm that I have read and agree to the terms outlined in the Contributor License Agreement (CLA)

…s work under Bun

undici's fetch exposes its response body as a WHATWG ReadableStream whose
bridge is broken under the Bun runtime the standalone server runs on: headers
arrive but response.body never yields data, hanging every MCP streamable-HTTP
(text/event-stream) tools/list read to its 30s timeout. undici's lower-level
request() returns a Node Readable, which Bun streams natively.

createSsrfGuardedFetchWithDispatcher (the MCP transport builder) now routes
through undiciRequestAsResponse: same guarded Agent + connect.lookup (SSRF
unchanged), request() instead of fetch(), and a hand-rolled Node->Web body
bridge (not Readable.toWeb, which throws ERR_INVALID_STATE on the redirect
body.cancel()). Buffered reads, followRedirectsGuarded, maxResponseSize, and
abort all preserved and verified on both Node and Bun.
@vercel

vercel Bot commented Jul 23, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
docs Skipped Skipped Jul 23, 2026 9:11pm

Request Review

@cursor

cursor Bot commented Jul 23, 2026

Copy link
Copy Markdown

PR Summary

Medium Risk
Touches outbound HTTP for user-controlled MCP hosts (SSRF-guarded path only); behavior is heavily tested but the new stream/decompression layer is complex and security-sensitive at the edges.

Overview
Fixes MCP SSE timeouts on Bun by swapping createSsrfGuardedFetchWithDispatcher from undici.fetch to undici.request, then wrapping the Node Readable in a spec Response via new undiciRequestAsResponse. The same SSRF Agent and followRedirectsGuarded flow are unchanged; redirects still use manual hops with maxRedirections unset.

The new path adds fetch parity that request() lacks: header/body coercion (including URLSearchParams for OAuth), gzip/deflate/br decoding, a custom Node→Web stream bridge (avoids Readable.toWeb cancel bugs on redirect body.cancel()), buffer copies for pooled chunks, and clean teardown on abort or bad Content-Encoding.

createPinnedFetchWithDispatcher still uses undici.fetch. guarded-request-fetch.server.test.ts mocks undici.request and covers streaming, redirects, form bodies, gzip, and error paths.

Reviewed by Cursor Bugbot for commit 0c3cf59. Configure here.

@greptile-apps

greptile-apps Bot commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR replaces the MCP guarded transport’s undici.fetch path with a streaming-compatible undici.request adapter.

  • Bridges Node readable response bodies into WHATWG ReadableStream responses.
  • Preserves guarded DNS dispatch, manual redirect validation, response decompression, abort handling, and fetch-compatible request normalization.
  • Adds deterministic tests for streaming, redirects, body conversion, compression, buffer ownership, and premature stream closure.

Confidence Score: 5/5

The PR appears safe to merge because no blocking failure related to an eligible previous Greptile thread remains.

No blocking failure remains.

Important Files Changed

Filename Overview
apps/sim/lib/core/security/input-validation.server.ts Replaces the MCP guarded fetch implementation with an undici.request-backed response adapter while retaining guarded dispatch and redirect handling.
apps/sim/lib/core/security/guarded-request-fetch.server.test.ts Adds coverage for request normalization, streaming responses, redirects, decompression, buffer copying, and premature stream termination.

Sequence Diagram

sequenceDiagram
  participant SDK as MCP SDK
  participant Guard as SSRF-guarded fetch
  participant Redirect as Redirect validator
  participant Undici as undici.request
  participant Server as MCP server
  SDK->>Guard: Fetch MCP request
  Guard->>Redirect: Validate initial URL
  Redirect->>Undici: Request with guarded Agent
  Undici->>Server: Connect using guarded DNS lookup
  Server-->>Undici: Headers and Node Readable body
  Undici-->>Redirect: Response with Web ReadableStream bridge
  alt Redirect response
    Redirect->>Redirect: Cancel body and validate next target
    Redirect->>Undici: Request validated redirect target
  else Final response
    Redirect-->>SDK: Streaming-compatible Response
  end
Loading

Reviews (5): Last reviewed commit: "fix(mcp): guard decoder errors and null-..." | Re-trigger Greptile

@waleedlatif1

Copy link
Copy Markdown
Collaborator Author

@greptile

@waleedlatif1

Copy link
Copy Markdown
Collaborator Author

@cursor review

Comment thread apps/sim/lib/core/security/input-validation.server.ts
Comment thread apps/sim/lib/core/security/input-validation.server.ts Outdated
- toUndiciRequestBody serializes a URLSearchParams body (the MCP SDK's OAuth
  token/refresh exchange sends one); undici.request rejects it otherwise.
- Default content-type application/x-www-form-urlencoded when the caller didn't
  set one (fetch parity).
- nodeReadableToWebStream enqueues a copy (new Uint8Array(chunk)) not a view, so
  undici recycling the pooled source buffer can't corrupt queued chunks.
- Tests for both.
@waleedlatif1

Copy link
Copy Markdown
Collaborator Author

@greptile

@waleedlatif1

Copy link
Copy Markdown
Collaborator Author

@cursor review

Comment thread apps/sim/lib/core/security/input-validation.server.ts Outdated
…tch parity)

undici.request returns raw bytes; fetch auto-decompresses. Restore that: pipe a
gzip/deflate/br response body through the matching zlib decoder before the WHATWG
bridge and strip content-encoding/content-length. maxResponseSize still caps the
compressed wire bytes; errors forward into the decoder and the source is torn down
when the decoded stream ends or is cancelled. Adds a gzip decode test.
@waleedlatif1

Copy link
Copy Markdown
Collaborator Author

@greptile

@waleedlatif1

Copy link
Copy Markdown
Collaborator Author

@cursor review

Comment thread apps/sim/lib/core/security/input-validation.server.ts Outdated
Comment thread apps/sim/lib/core/security/input-validation.server.ts
…crashes

- Attach the stream bridge's error listener before piping into the zlib decoder, so
  a synchronous zlib error (server mislabeling a non-gzip body as gzip) rejects the
  reader instead of crashing the process.
- Attach an error listener before draining a null-body response, and wrap Response
  construction in try/catch that destroys the source (no socket leak) on an
  out-of-range status. Adds an invalid-gzip regression test.
@waleedlatif1

Copy link
Copy Markdown
Collaborator Author

@greptile

@waleedlatif1

Copy link
Copy Markdown
Collaborator Author

@cursor review

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ Bugbot reviewed your changes and found no new issues!

Comment @cursor review or bugbot run to trigger another review on this PR

Reviewed by Cursor Bugbot for commit 0c3cf59. Configure here.

@waleedlatif1
waleedlatif1 merged commit a5e1030 into staging Jul 23, 2026
14 checks passed
@waleedlatif1
waleedlatif1 deleted the fix/mcp-bun-undici-streaming branch July 23, 2026 21:18
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.

1 participant