@@ -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. */
88export 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 */
2623export 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 */
4534const 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 */
5441const 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 . */
5744function 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 */
7557function 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 */
10584async 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 */
130104function 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