Skip to content

Commit 48103e9

Browse files
authored
fix(mcp): use HTTP/1.1 for the streamable-HTTP transport (drop allowH2) (#5797)
* fix(mcp): use HTTP/1.1 for the streamable-HTTP transport (drop allowH2) 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. * chore(mcp): trim verbose TSDoc and inline comments in pinned fetch + probe * chore(credentials): drop help text on the Claude Platform API key modal
1 parent e02dbce commit 48103e9

4 files changed

Lines changed: 79 additions & 105 deletions

File tree

apps/sim/lib/credentials/token-service-accounts/descriptors.ts

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -369,8 +369,6 @@ export const TOKEN_SERVICE_ACCOUNT_DESCRIPTORS: Record<
369369
},
370370
],
371371
docsUrl: 'https://docs.sim.ai/integrations/managed-agent',
372-
helpText:
373-
'Use an Anthropic workspace API key with Managed Agents access. The key is scoped to one Anthropic workspace — its agents, environments, vaults, and memory stores.',
374372
},
375373
}
376374

apps/sim/lib/mcp/oauth/probe.ts

Lines changed: 5 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -33,16 +33,15 @@ export async function detectMcpAuthType(
3333
return 'headers'
3434
}
3535

36-
// When the caller pre-validated the IP we pin to it directly and own the Agent's
37-
// lifetime; the SSRF-guarded fetch (used when we must validate ourselves) manages its
38-
// own per-request Agent teardown internally.
36+
// Pre-validated IP → pin directly (we own the Agent); otherwise the SSRF-guarded fetch
37+
// self-manages its per-request Agent teardown.
3938
const pinned = resolvedIP ? createPinnedFetchWithDispatcher(resolvedIP) : undefined
4039
const probeFetch: FetchLike = pinned?.fetch ?? createSsrfGuardedMcpFetch()
4140

4241
const controller = new AbortController()
4342
const timer = setTimeout(() => controller.abort(), PROBE_TIMEOUT_MS)
44-
// Best-effort session cleanup runs after we return the classification; the pinned
45-
// Agent must outlive it, so we tear it down once cleanup settles.
43+
// Session cleanup runs after we return; the pinned Agent must outlive it, so tear it
44+
// down once cleanup settles.
4645
let sessionClose: Promise<void> = Promise.resolve()
4746

4847
try {
@@ -87,8 +86,7 @@ export async function detectMcpAuthType(
8786
return 'headers'
8887
} finally {
8988
clearTimeout(timer)
90-
// Destroy (not close) so a hung request can't make teardown itself hang; wait for
91-
// the best-effort session-close hop first so it isn't aborted mid-flight.
89+
// destroy() after the session-close hop settles, so that cleanup isn't aborted mid-flight.
9290
if (pinned) {
9391
void sessionClose.finally(() => pinned.dispatcher.destroy().catch(() => {}))
9492
}

apps/sim/lib/mcp/pinned-fetch.test.ts

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,11 +23,37 @@ vi.mock('@/lib/mcp/domain-check', () => ({
2323
validateMcpServerSsrf: mockValidateMcpServerSsrf,
2424
}))
2525

26-
import { createSsrfGuardedMcpFetch } from '@/lib/mcp/pinned-fetch'
26+
import { createPinnedMcpFetch, createSsrfGuardedMcpFetch } from '@/lib/mcp/pinned-fetch'
2727

2828
/** The per-request pinned Agent is always built with a DoS-backstop response cap. */
2929
const withResponseCap = expect.objectContaining({ maxResponseSize: expect.any(Number) })
3030

31+
describe('createPinnedMcpFetch', () => {
32+
beforeEach(() => {
33+
vi.clearAllMocks()
34+
mockDestroy.mockResolvedValue(undefined)
35+
mockCreatePinnedFetchWithDispatcher.mockReturnValue({
36+
fetch: sentinelFetch,
37+
dispatcher: { destroy: mockDestroy },
38+
})
39+
})
40+
41+
it('builds the transport on HTTP/1.1 — never opts into allowH2 (undici h2 stalls)', () => {
42+
const { close } = createPinnedMcpFetch('203.0.113.10')
43+
44+
// Called with the IP only: no `allowH2`, so the Agent stays on undici's h1.1 default.
45+
expect(mockCreatePinnedFetchWithDispatcher).toHaveBeenCalledWith('203.0.113.10')
46+
const options = mockCreatePinnedFetchWithDispatcher.mock.calls[0][1] as
47+
| { allowH2?: boolean }
48+
| undefined
49+
expect(options?.allowH2).toBeUndefined()
50+
51+
// close() tears down the pooled sockets (incl. the long-lived SSE) on disconnect.
52+
void close()
53+
expect(mockDestroy).toHaveBeenCalledTimes(1)
54+
})
55+
})
56+
3157
describe('createSsrfGuardedMcpFetch', () => {
3258
beforeEach(() => {
3359
vi.clearAllMocks()

apps/sim/lib/mcp/pinned-fetch.ts

Lines changed: 47 additions & 95 deletions
Original file line numberDiff line numberDiff line change
@@ -6,54 +6,41 @@ import { McpError } from '@/lib/mcp/types'
66

77
/** Pinned fetch for the live MCP transport, plus a handle to release its sockets. */
88
export interface PinnedMcpFetch {
9-
/** Pinned fetch to hand to the MCP transport's `fetch` option. */
109
fetch: typeof fetch
11-
/** Tears down the underlying HTTP/2 Agent; call when the MCP client disconnects. */
10+
/** Tears down the Agent's pooled sockets; call when the MCP client disconnects. */
1211
close: () => Promise<void>
1312
}
1413

1514
/**
16-
* Pinned fetch for the long-lived MCP transport, which reuses one Agent across
17-
* a connection's requests. MCP servers are commonly behind HTTP/2 fronts (CDNs,
18-
* cloud LBs), and undici's Agent is h1.1-only unless opted into h2 via ALPN, so
19-
* the transport enables it. h2 is *not* used for one-shot flows (OAuth discovery,
20-
* auth-type probe), where a per-request Agent would leave idle h2 sessions with
21-
* no reuse benefit. Pinning is unaffected: the pinned lookup forces the socket to
22-
* `resolvedIP` regardless of negotiated protocol. The returned `close` binds the
23-
* Agent's teardown to the transport lifecycle so h2 sessions don't linger past
24-
* disconnect.
15+
* Pinned fetch for the long-lived MCP transport (one Agent reused per connection).
16+
*
17+
* Runs HTTP/1.1: we do not opt into undici's experimental `allowH2`, whose h2 path stalls
18+
* with headers-but-no-body on reused POST sessions (nodejs/undici #2311, #3433, #4143) —
19+
* the streamable-HTTP `initialize` hang behind a CDN. Both official MCP SDKs use h1.1, and
20+
* the transport has no concurrency to gain from h2. `close` tears down pooled sockets
21+
* (incl. the SSE connection) on disconnect; IP-pinning is protocol-independent.
2522
*/
2623
export function createPinnedMcpFetch(resolvedIP: string): PinnedMcpFetch {
27-
const { fetch: pinnedFetch, dispatcher } = createPinnedFetchWithDispatcher(resolvedIP, {
28-
allowH2: true,
29-
})
24+
const { fetch: pinnedFetch, dispatcher } = createPinnedFetchWithDispatcher(resolvedIP)
3025
return { fetch: pinnedFetch, close: () => dispatcher.destroy() }
3126
}
3227

3328
/**
34-
* Per-request deadline for guarded MCP OAuth / RFC 7009 revocation HTTP calls.
35-
*
36-
* The MCP SDK issues OAuth discovery, dynamic client registration, and token
37-
* exchange with a bare `fetch` and no `AbortSignal` — only the JSON-RPC message
38-
* layer gets the SDK's request timeout. Combined with undici's 5-minute default
39-
* headers/body timeouts, a slow or unresponsive authorization server leaves the
40-
* request (and the browser the user is waiting on during `/oauth/start`) pending
41-
* for minutes. Bounding each leg turns that into a fast, actionable failure. 30s
42-
* mirrors `MCP_CLIENT_CONSTANTS.DEFAULT_CONNECTION_TIMEOUT` and leaves wide
43-
* headroom over a healthy server, which completes each leg in well under a second.
29+
* Per-request deadline for guarded OAuth / RFC 7009 legs. The MCP SDK sets no timeout on
30+
* these (only its JSON-RPC layer gets one) and undici's default is 5 minutes, so an
31+
* unresponsive auth server would hang the flow — and the browser waiting on `/oauth/start`.
32+
* 30s mirrors `MCP_CLIENT_CONSTANTS.DEFAULT_CONNECTION_TIMEOUT`.
4433
*/
4534
const OAUTH_FETCH_TIMEOUT_MS = 30_000
4635

4736
/**
48-
* Cap on a guarded OAuth response body. Discovery/registration/token/refresh/revocation
49-
* replies are always well under 1 KB; this ceiling is purely a DoS backstop so a
50-
* malicious authorization server (reached via attacker-controllable metadata URLs)
51-
* can't stream a multi-GB body within the deadline and exhaust memory. undici rejects
52-
* with `UND_ERR_RES_EXCEEDED_MAX_SIZE` once the decoded body exceeds it.
37+
* DoS backstop for a guarded OAuth response body (real ones are <1 KB): stops a malicious
38+
* auth server — reached via attacker-controllable metadata URLs — from streaming a huge
39+
* body within the deadline. undici rejects with `UND_ERR_RES_EXCEEDED_MAX_SIZE` past it.
5340
*/
5441
const MAX_OAUTH_RESPONSE_BYTES = 1_048_576
5542

56-
/** True for undici's response-too-large rejection, however it's wrapped by `fetch`. */
43+
/** True for undici's response-too-large rejection, however `fetch` wraps it. */
5744
function isResponseTooLarge(error: unknown): boolean {
5845
const e = error as { code?: string; cause?: { code?: string } } | null
5946
return (
@@ -63,14 +50,9 @@ function isResponseTooLarge(error: unknown): boolean {
6350
}
6451

6552
/**
66-
* Bounds `promise` by the composed deadline/caller `signal`, rejecting with the signal's
67-
* reason if it aborts first. Holds every guarded phase inside the deadline — the
68-
* `dns.lookup`-based SSRF validation (which takes no signal of its own), the HTTP
69-
* request, and the response body read. Either path attaches a rejection handler to
70-
* `promise` (the pre-aborted `.catch`, or `.then(resolve, reject)`), so a settlement
71-
* arriving after the deadline has fired — once we've stopped awaiting it and are tearing
72-
* the request down — can't surface as an unhandled rejection. The abort listener is
73-
* removed once `promise` settles.
53+
* Bounds `promise` by `signal`, rejecting with its reason on abort — used to hold SSRF
54+
* validation, the request, and the body read inside the deadline. Attaches a rejection
55+
* handler on both paths so a settlement arriving after the deadline can't leak as unhandled.
7456
*/
7557
function withDeadline<T>(promise: Promise<T>, signal: AbortSignal): Promise<T> {
7658
if (signal.aborted) {
@@ -94,19 +76,15 @@ function withDeadline<T>(promise: Promise<T>, signal: AbortSignal): Promise<T> {
9476
}
9577

9678
/**
97-
* Reads a guarded OAuth response fully under the deadline, then returns a detached,
98-
* in-memory copy. The MCP SDK reads discovery/registration/token/refresh bodies
99-
* lazily — AFTER `auth()`'s injected fetch resolves — and applies no timeout of its
100-
* own, so returning the live response would let the body read escape every deadline
101-
* (the "Connecting… forever" hang). Buffering here brings the body read inside the
102-
* wall-clock `signal`: undici's `bodyTimeout` only measures idle gaps between chunks
103-
* and cannot bound a slow-drip or stalled body. These bodies are always small JSON.
79+
* Reads a guarded OAuth response under the deadline and returns a detached in-memory copy.
80+
* The SDK reads these bodies lazily — after our fetch resolves, with no timeout of its own
81+
* — so buffering here is what keeps the body read inside `signal`; undici's `bodyTimeout`
82+
* measures only idle gaps between chunks, not a stalled body. Bodies are always small JSON.
10483
*/
10584
async function bufferUnderDeadline(response: Response, signal: AbortSignal): Promise<Response> {
10685
const body = await withDeadline(response.arrayBuffer(), signal)
10786
const headers = new Headers(response.headers)
108-
// The buffered copy is decoded and detached from the socket; drop framing headers
109-
// that would misdescribe it.
87+
// Detached + decoded: drop framing headers that would misdescribe the in-memory copy.
11088
headers.delete('content-encoding')
11189
headers.delete('content-length')
11290
const nullBody = response.status === 204 || response.status === 205 || response.status === 304
@@ -118,14 +96,10 @@ async function bufferUnderDeadline(response: Response, signal: AbortSignal): Pro
11896
}
11997

12098
/**
121-
* Hands a streaming guarded response back live instead of buffering it. The only
122-
* streaming reply a guarded call can see is the auth-type probe's `initialize`
123-
* (`text/event-stream`), and the probe classifies from headers alone — buffering it
124-
* would drain the stream or stall on a server that holds it open (misclassifying auth).
125-
* The per-request pinned Agent (if any) is torn down once the stream ends, the caller
126-
* cancels it, or the deadline aborts, so the socket is never stranded; the `tee` keeps
127-
* the returned body fully readable meanwhile. Teardown ownership moves here, out of the
128-
* caller's `finally`.
99+
* Hands a streaming guarded response (the auth-type probe's `initialize`) back live rather
100+
* than buffering it — buffering would stall on a server that holds the stream open. Tears
101+
* the per-request Agent down once the stream ends, the caller cancels, or the deadline
102+
* aborts; the `tee` keeps the returned body readable meanwhile.
129103
*/
130104
function releaseStreamOnSettle(
131105
response: Response,
@@ -143,11 +117,10 @@ function releaseStreamOnSettle(
143117
if (signal.aborted) onAbort()
144118
else signal.addEventListener('abort', onAbort, { once: true })
145119
try {
146-
while (!(await reader.read()).done) {
147-
// Drain to end-of-stream so the Agent can be torn down once the reply completes.
148-
}
120+
// Drain to end-of-stream so the Agent can be torn down once the reply completes.
121+
while (!(await reader.read()).done) {}
149122
} catch {
150-
// Aborted or errored — the teardown below still runs.
123+
// Aborted or errored — teardown still runs in `finally`.
151124
} finally {
152125
signal.removeEventListener('abort', onAbort)
153126
void dispatcher.destroy().catch(() => {})
@@ -162,29 +135,14 @@ function releaseStreamOnSettle(
162135

163136
/**
164137
* Builds a `FetchLike` for one-shot MCP OAuth calls (discovery, dynamic client
165-
* registration, token exchange/refresh, RFC 7009 revocation). It validates every
166-
* outbound URL against the MCP SSRF policy before issuing it, then pins the
167-
* connection to the resolved IP. Unlike the live transport — where the server URL is
168-
* validated once up front — these hops follow URLs taken verbatim from
169-
* attacker-controllable authorization-server metadata (`authorization_servers`,
170-
* `token_endpoint`, `revocation_endpoint`, …), so each is re-validated and
171-
* private/reserved/loopback targets are rejected (honoring `ALLOWED_MCP_DOMAINS` and
172-
* self-hosted localhost rules).
173-
*
174-
* Three correctness guarantees, each of which the MCP SDK does NOT provide itself (it
175-
* sets no timeout on any OAuth leg and reads bodies lazily):
176-
* - **Bounded end-to-end.** The `timeoutMs` deadline (`AbortSignal.timeout`) composed
177-
* with any caller signal covers SSRF validation, the request, AND the response body
178-
* read — the body is buffered here so the SDK's later read can't outlive the
179-
* deadline. Only our own deadline is relabeled to an `McpError`; a caller abort or
180-
* any other failure propagates unchanged.
181-
* - **No leaked sockets.** The per-request pinned Agent is `destroy()`ed on every path,
182-
* releasing the keep-alive socket a one-shot flow would otherwise strand.
183-
* - **Detached response.** The returned `Response` is an in-memory copy, safe to read
184-
* after the underlying socket is gone.
138+
* registration, token exchange/refresh, RFC 7009 revocation). Each hop's URL comes from
139+
* attacker-controllable authorization-server metadata, so every request is re-validated
140+
* against the SSRF policy and pinned to the resolved IP.
185141
*
186-
* A stalled DNS resolution still runs to completion in the background, but its result
187-
* is discarded.
142+
* The SDK provides none of the safety itself (no timeout on OAuth legs, lazy body reads),
143+
* so the guard owns it: the `timeoutMs` deadline covers validation + request + the buffered
144+
* body read; the per-request pinned Agent is `destroy()`ed on every path; and the returned
145+
* `Response` is a detached in-memory copy. Only our own deadline is relabeled to `McpError`.
188146
*
189147
* @param timeoutMs Per-request deadline in ms (defaults to 30s; override for tests).
190148
* @throws McpSsrfError if a request URL resolves to a blocked IP address
@@ -194,11 +152,9 @@ export function createSsrfGuardedMcpFetch(timeoutMs: number = OAUTH_FETCH_TIMEOU
194152
return (async (url, init) => {
195153
const target = typeof url === 'string' ? url : url.href
196154
const timeoutSignal = AbortSignal.timeout(timeoutMs)
197-
// Compose deadline + caller signal up front so every phase — SSRF validation, the
198-
// HTTP request, and the body read — is bounded by the deadline and caller cancellation.
155+
// Bound every phase — validation, request, body read — by the deadline + caller signal.
199156
const signal = init?.signal ? AbortSignal.any([init.signal, timeoutSignal]) : timeoutSignal
200-
// The per-request pinned Agent MUST be torn down: it holds a keep-alive socket the
201-
// one-shot OAuth flow never reuses, so leaving it open leaks a socket per leg.
157+
// Per-request Agent must be torn down (finally): a one-shot leg never reuses its socket.
202158
let dispatcher: Agent | undefined
203159
try {
204160
const resolvedIP = await withDeadline(validateMcpServerSsrf(target), signal)
@@ -213,22 +169,19 @@ export function createSsrfGuardedMcpFetch(timeoutMs: number = OAUTH_FETCH_TIMEOU
213169
// No pin (self-hosted allowlist) — global fetch over the shared dispatcher.
214170
response = await withDeadline(globalThis.fetch(url, { ...init, signal }), signal)
215171
}
216-
// A `text/event-stream` reply is the auth-type probe's `initialize` (the only
217-
// streaming case a guarded call sees); hand it back live with its own teardown so
218-
// the caller reads headers without the buffer draining/stalling the stream. Every
219-
// OAuth leg is single-shot JSON and falls through to be buffered and torn down.
172+
// The probe's `initialize` can stream (text/event-stream); hand it back live so the
173+
// buffer doesn't drain/stall it. Every OAuth leg is single-shot JSON and is buffered.
220174
const contentType = response.headers.get('content-type') ?? ''
221175
if (contentType.includes('text/event-stream')) {
222176
const streamed = releaseStreamOnSettle(response, dispatcher, signal)
223-
dispatcher = undefined // teardown ownership handed to releaseStreamOnSettle
177+
dispatcher = undefined // teardown ownership moved to releaseStreamOnSettle
224178
return streamed
225179
}
226180
return await bufferUnderDeadline(response, signal)
227181
} catch (error) {
228182
const host = URL.canParse(target) ? new URL(target).host : target
229-
// Relabel only when our own deadline is what fired — identified by the
230-
// rejection reason's identity, not init.signal's state (which may abort
231-
// independently just after the deadline).
183+
// Relabel only our own deadline — by reason identity, not signal state (which may
184+
// abort independently just after the deadline).
232185
if (timeoutSignal.aborted && error === timeoutSignal.reason) {
233186
throw new McpError(`MCP authorization request to ${host} timed out after ${timeoutMs}ms`)
234187
}
@@ -239,8 +192,7 @@ export function createSsrfGuardedMcpFetch(timeoutMs: number = OAUTH_FETCH_TIMEOU
239192
}
240193
throw error
241194
} finally {
242-
// Destroy (not close) so a hung leg can't make teardown itself hang; this releases
243-
// the pooled keep-alive socket the per-request Agent would otherwise strand.
195+
// destroy() not close() so a hung leg can't stall teardown; frees the pooled socket.
244196
await dispatcher?.destroy().catch(() => {})
245197
}
246198
}) satisfies FetchLike

0 commit comments

Comments
 (0)