Skip to content

fix(mcp): use HTTP/1.1 for the streamable-HTTP transport (drop allowH2)#5797

Merged
waleedlatif1 merged 3 commits into
stagingfrom
fix/mcp-transport-http1
Jul 21, 2026
Merged

fix(mcp): use HTTP/1.1 for the streamable-HTTP transport (drop allowH2)#5797
waleedlatif1 merged 3 commits into
stagingfrom
fix/mcp-transport-http1

Conversation

@waleedlatif1

Copy link
Copy Markdown
Collaborator

Summary

  • The MCP transport was the only place in the codebase opting undici into HTTP/2 (allowH2: true in createPinnedMcpFetch). Everything else — including the OAuth legs that now work — uses h1.1.
  • undici's HTTP/2 support is experimental and has a documented cluster of stalls where response headers arrive but the body DATA frames never do, on POST bodies over reused/coalesced sessions (undici #2311, #3433, #4143). Behind a shared egress IP fronted by a CDN, that is exactly what hung the streamable-HTTP initialize after OAuth succeeded: 200 + Mcp-Session-Id, then an empty body until the SDK's 30s timeout — reproducible only from the deployed egress, never from a fresh IP or in isolation (the same authenticated initialize returns the full result in ~400-600ms over h2 or h1.1 from a dev machine).
  • Fix: drop allowH2 → the transport runs HTTP/1.1.

Why this is the correct, best-practice config (verified against the reference clients)

  • Official TypeScript SDK StreamableHTTPClientTransport: issues requests via (this._fetch ?? fetch)(...) — global fetch = undici on HTTP/1.1 — and never sets allowH2 / imports node:http2.
  • Official Python SDK: builds its httpx.AsyncClient with no http2=True → HTTP/1.1.
  • mcp-remote and SDK-based clients (Continue, LibreChat, …) consume that transport as-is (h1.1).
  • h2's only real win is multiplexing concurrent requests; the MCP transport is one POST per message plus a single long-lived SSE stream, so it gains nothing. CDN fronts serve h1.1 regardless.

What does NOT change

  • SSRF IP-pinning (connect.lookup) is orthogonal to ALPN and is untouched.
  • Agent teardown on disconnect (close()) still tears down pooled sockets (incl. the SSE connection).

Type of Change

  • Bug fix

Testing

  • Full lib/mcp suite green (372); tsc clean; biome clean.
  • Added a regression test asserting the transport is built without allowH2 (stays on h1.1) and still tears down on close().
  • Honest caveat: the stall reproduces only from the deployed shared egress, so the definitive confirmation is re-testing the Gauge connect on staging once this deploys.

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)

The MCP transport was the only place in the codebase opting undici into HTTP/2
(`allowH2: true`). undici's h2 support is experimental and has a documented
cluster of stalls where response headers arrive but the body DATA frames never
do, on POST bodies over reused/coalesced sessions (nodejs/undici #2311, #3433,
#4143). Behind a shared egress IP fronted by a CDN, that is exactly what hung the
streamable-HTTP `initialize` after OAuth: 200 + Mcp-Session-Id, then an empty
body until the SDK's 30s timeout — reproducible only from the deployed egress,
never from a fresh IP or in isolation.

h2 buys the MCP transport nothing (one POST per JSON-RPC message plus a single
long-lived SSE stream — no concurrency to multiplex), and both official MCP SDKs
run the transport on HTTP/1.1: the TypeScript SDK's StreamableHTTPClientTransport
calls global fetch (undici h1.1, no allowH2), and the Python SDK builds its httpx
client with no http2=True. Dropping allowH2 aligns with them and steps off the h2
stall surface. CDN fronts serve h1.1 anyway; SSRF IP-pinning and Agent teardown
are unchanged.
@vercel

vercel Bot commented Jul 21, 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 21, 2026 4:07am

Request Review

@cursor

cursor Bot commented Jul 21, 2026

Copy link
Copy Markdown

PR Summary

Medium Risk
Changes how all long-lived MCP streamable-HTTP connections negotiate HTTP (h2 → h1.1); behavior is intentional and aligned with official MCP clients, but it affects production MCP connectivity paths.

Overview
Fixes streamable-HTTP MCP connections that returned 200 and session headers but never delivered the initialize body (until the SDK timeout), especially from shared egress behind CDNs.

createPinnedMcpFetch no longer passes allowH2: true to the pinned undici Agent, so the long-lived transport stays on HTTP/1.1 instead of undici’s experimental HTTP/2 path (known POST/reuse stalls with headers but no body). SSRF IP pinning and close() Agent teardown are unchanged. Docs/comments in pinned-fetch.ts and probe.ts are tightened to match; a createPinnedMcpFetch regression test asserts no allowH2 and that close() still destroys the dispatcher.

Removes optional helpText under the Claude Platform API key field in token service-account descriptors (copy-only).

Reviewed by Cursor Bugbot for commit 71a7259. Configure here.

@greptile-apps

greptile-apps Bot commented Jul 21, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR moves the streamable HTTP transport back to HTTP/1.1. The main changes are:

  • Removes the MCP transport's explicit undici HTTP/2 opt-in.
  • Preserves SSRF IP pinning and dispatcher teardown.
  • Adds a test for HTTP/1.1 configuration and socket cleanup.
  • Removes the Claude Platform credential hint.
  • Shortens comments in the MCP fetch and OAuth probe code.

Confidence Score: 5/5

This looks safe to merge.

  • The pinned agent defaults to HTTP/1.1 when allowH2 is omitted.
  • IP pinning and dispatcher destruction remain unchanged.
  • The new test covers the protocol option and cleanup behavior.
  • No blocking issue remains in the updated code.

Important Files Changed

Filename Overview
apps/sim/lib/mcp/pinned-fetch.ts Uses the pinned agent's HTTP/1.1 default while preserving IP pinning and socket teardown.
apps/sim/lib/mcp/pinned-fetch.test.ts Adds coverage for omitting allowH2 and destroying the dispatcher on close.
apps/sim/lib/mcp/oauth/probe.ts Shortens lifecycle comments without changing probe behavior.
apps/sim/lib/credentials/token-service-accounts/descriptors.ts Removes the optional help text from the Claude Platform credential descriptor.

Reviews (3): Last reviewed commit: "chore(credentials): drop help text on th..." | Re-trigger Greptile

@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 c1f8195. Configure here.

@waleedlatif1

Copy link
Copy Markdown
Collaborator Author

@greptile

@waleedlatif1

Copy link
Copy Markdown
Collaborator Author

@cursor review

@waleedlatif1
waleedlatif1 merged commit 48103e9 into staging Jul 21, 2026
15 checks passed
@waleedlatif1
waleedlatif1 deleted the fix/mcp-transport-http1 branch July 21, 2026 04:07

@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 71a7259. Configure here.

waleedlatif1 added a commit that referenced this pull request Jul 21, 2026
…hangs (#5798)

* fix(mcp): pin outbound connections to IPv4 to avoid unreachable-IPv6 hangs

Root cause of the MCP 'connecting forever' / tool-discovery timeouts in
production: SSRF pinning forces each outbound connection to a single resolved
IP, which strips Happy Eyeballs' IPv4 fallback. dns.lookup(verbatim) returns the
IPv6 address first for Cloudflare-fronted dual-stack hosts (Gauge, api.exa.ai),
so the connection was pinned to IPv6 — but the production app subnets have no
IPv6 egress (AWS NAT gateways are IPv4-only), so the pinned IPv6 connection
connects into a void and hangs until the 30s timeout. Intermittent because the
resolver rotates A/AAAA order; works on retry when it happens to pick IPv4.

This is why h1.1 (#5797) did not fix it (protocol-independent) and why it never
reproduced locally (dev machines have IPv6 egress). Empirically verified: pinning
only the IPv6 address times out; preferring the IPv4 address connects in ~600ms.

Fix: at both pinned-resolution sites (validateMcpServerSsrf and validateUrlWithDNS)
resolve all addresses and prefer an IPv4 one; IPv6-only hosts still pin their sole
address. No signature/threading changes; SSRF validation of the pinned IP is
unchanged.

* chore(mcp): trim inline comments on the IPv4-preference fix
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