@@ -6,61 +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 Agent's pooled sockets; 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 a
17- * connection's requests.
15+ * Pinned fetch for the long-lived MCP transport (one Agent reused per connection).
1816 *
19- * The transport speaks **HTTP/1.1** (undici's default — we do not opt into `allowH2`).
20- * Every stock MCP client does the same: the official SDK's `StreamableHTTPClientTransport`
21- * calls global `fetch` (undici on h1.1) and never touches HTTP/2. h2's only real win is
22- * multiplexing many concurrent requests over one socket, which the MCP transport — one
23- * POST per JSON-RPC message plus a single long-lived SSE stream — never does, so it buys
24- * nothing here. undici's h2 support is still marked experimental and has a documented
25- * cluster of "response headers arrive, body DATA frames never do" stalls on POST bodies
26- * over reused/coalesced sessions (nodejs/undici #2311, #3433, #4143). Behind a shared
27- * egress IP fronted by a CDN, that stall is exactly what hung the streamable-HTTP
28- * `initialize` (200 + `Mcp-Session-Id`, then an empty body until the SDK's 30s timeout).
29- * h1.1 sidesteps that whole surface, and CDN fronts serve h1.1 anyway.
30- *
31- * Pinning is unaffected: the pinned lookup forces the socket to `resolvedIP` regardless of
32- * protocol. The returned `close` binds Agent teardown to the transport lifecycle so pooled
33- * keep-alive sockets (including the SSE connection) don't linger past disconnect.
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.
3422 */
3523export function createPinnedMcpFetch ( resolvedIP : string ) : PinnedMcpFetch {
3624 const { fetch : pinnedFetch , dispatcher } = createPinnedFetchWithDispatcher ( resolvedIP )
3725 return { fetch : pinnedFetch , close : ( ) => dispatcher . destroy ( ) }
3826}
3927
4028/**
41- * Per-request deadline for guarded MCP OAuth / RFC 7009 revocation HTTP calls.
42- *
43- * The MCP SDK issues OAuth discovery, dynamic client registration, and token
44- * exchange with a bare `fetch` and no `AbortSignal` — only the JSON-RPC message
45- * layer gets the SDK's request timeout. Combined with undici's 5-minute default
46- * headers/body timeouts, a slow or unresponsive authorization server leaves the
47- * request (and the browser the user is waiting on during `/oauth/start`) pending
48- * for minutes. Bounding each leg turns that into a fast, actionable failure. 30s
49- * mirrors `MCP_CLIENT_CONSTANTS.DEFAULT_CONNECTION_TIMEOUT` and leaves wide
50- * 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`.
5133 */
5234const OAUTH_FETCH_TIMEOUT_MS = 30_000
5335
5436/**
55- * Cap on a guarded OAuth response body. Discovery/registration/token/refresh/revocation
56- * replies are always well under 1 KB; this ceiling is purely a DoS backstop so a
57- * malicious authorization server (reached via attacker-controllable metadata URLs)
58- * can't stream a multi-GB body within the deadline and exhaust memory. undici rejects
59- * 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.
6040 */
6141const MAX_OAUTH_RESPONSE_BYTES = 1_048_576
6242
63- /** 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 . */
6444function isResponseTooLarge ( error : unknown ) : boolean {
6545 const e = error as { code ?: string ; cause ?: { code ?: string } } | null
6646 return (
@@ -70,14 +50,9 @@ function isResponseTooLarge(error: unknown): boolean {
7050}
7151
7252/**
73- * Bounds `promise` by the composed deadline/caller `signal`, rejecting with the signal's
74- * reason if it aborts first. Holds every guarded phase inside the deadline — the
75- * `dns.lookup`-based SSRF validation (which takes no signal of its own), the HTTP
76- * request, and the response body read. Either path attaches a rejection handler to
77- * `promise` (the pre-aborted `.catch`, or `.then(resolve, reject)`), so a settlement
78- * arriving after the deadline has fired — once we've stopped awaiting it and are tearing
79- * the request down — can't surface as an unhandled rejection. The abort listener is
80- * 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.
8156 */
8257function withDeadline < T > ( promise : Promise < T > , signal : AbortSignal ) : Promise < T > {
8358 if ( signal . aborted ) {
@@ -101,19 +76,15 @@ function withDeadline<T>(promise: Promise<T>, signal: AbortSignal): Promise<T> {
10176}
10277
10378/**
104- * Reads a guarded OAuth response fully under the deadline, then returns a detached,
105- * in-memory copy. The MCP SDK reads discovery/registration/token/refresh bodies
106- * lazily — AFTER `auth()`'s injected fetch resolves — and applies no timeout of its
107- * own, so returning the live response would let the body read escape every deadline
108- * (the "Connecting… forever" hang). Buffering here brings the body read inside the
109- * wall-clock `signal`: undici's `bodyTimeout` only measures idle gaps between chunks
110- * 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.
11183 */
11284async function bufferUnderDeadline ( response : Response , signal : AbortSignal ) : Promise < Response > {
11385 const body = await withDeadline ( response . arrayBuffer ( ) , signal )
11486 const headers = new Headers ( response . headers )
115- // The buffered copy is decoded and detached from the socket; drop framing headers
116- // that would misdescribe it.
87+ // Detached + decoded: drop framing headers that would misdescribe the in-memory copy.
11788 headers . delete ( 'content-encoding' )
11889 headers . delete ( 'content-length' )
11990 const nullBody = response . status === 204 || response . status === 205 || response . status === 304
@@ -125,14 +96,10 @@ async function bufferUnderDeadline(response: Response, signal: AbortSignal): Pro
12596}
12697
12798/**
128- * Hands a streaming guarded response back live instead of buffering it. The only
129- * streaming reply a guarded call can see is the auth-type probe's `initialize`
130- * (`text/event-stream`), and the probe classifies from headers alone — buffering it
131- * would drain the stream or stall on a server that holds it open (misclassifying auth).
132- * The per-request pinned Agent (if any) is torn down once the stream ends, the caller
133- * cancels it, or the deadline aborts, so the socket is never stranded; the `tee` keeps
134- * the returned body fully readable meanwhile. Teardown ownership moves here, out of the
135- * 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.
136103 */
137104function releaseStreamOnSettle (
138105 response : Response ,
@@ -150,11 +117,10 @@ function releaseStreamOnSettle(
150117 if ( signal . aborted ) onAbort ( )
151118 else signal . addEventListener ( 'abort' , onAbort , { once : true } )
152119 try {
153- while ( ! ( await reader . read ( ) ) . done ) {
154- // Drain to end-of-stream so the Agent can be torn down once the reply completes.
155- }
120+ // Drain to end-of-stream so the Agent can be torn down once the reply completes.
121+ while ( ! ( await reader . read ( ) ) . done ) { }
156122 } catch {
157- // Aborted or errored — the teardown below still runs.
123+ // Aborted or errored — teardown still runs in `finally` .
158124 } finally {
159125 signal . removeEventListener ( 'abort' , onAbort )
160126 void dispatcher . destroy ( ) . catch ( ( ) => { } )
@@ -169,29 +135,14 @@ function releaseStreamOnSettle(
169135
170136/**
171137 * Builds a `FetchLike` for one-shot MCP OAuth calls (discovery, dynamic client
172- * registration, token exchange/refresh, RFC 7009 revocation). It validates every
173- * outbound URL against the MCP SSRF policy before issuing it, then pins the
174- * connection to the resolved IP. Unlike the live transport — where the server URL is
175- * validated once up front — these hops follow URLs taken verbatim from
176- * attacker-controllable authorization-server metadata (`authorization_servers`,
177- * `token_endpoint`, `revocation_endpoint`, …), so each is re-validated and
178- * private/reserved/loopback targets are rejected (honoring `ALLOWED_MCP_DOMAINS` and
179- * self-hosted localhost rules).
180- *
181- * Three correctness guarantees, each of which the MCP SDK does NOT provide itself (it
182- * sets no timeout on any OAuth leg and reads bodies lazily):
183- * - **Bounded end-to-end.** The `timeoutMs` deadline (`AbortSignal.timeout`) composed
184- * with any caller signal covers SSRF validation, the request, AND the response body
185- * read — the body is buffered here so the SDK's later read can't outlive the
186- * deadline. Only our own deadline is relabeled to an `McpError`; a caller abort or
187- * any other failure propagates unchanged.
188- * - **No leaked sockets.** The per-request pinned Agent is `destroy()`ed on every path,
189- * releasing the keep-alive socket a one-shot flow would otherwise strand.
190- * - **Detached response.** The returned `Response` is an in-memory copy, safe to read
191- * 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.
192141 *
193- * A stalled DNS resolution still runs to completion in the background, but its result
194- * 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`.
195146 *
196147 * @param timeoutMs Per-request deadline in ms (defaults to 30s; override for tests).
197148 * @throws McpSsrfError if a request URL resolves to a blocked IP address
@@ -201,11 +152,9 @@ export function createSsrfGuardedMcpFetch(timeoutMs: number = OAUTH_FETCH_TIMEOU
201152 return ( async ( url , init ) => {
202153 const target = typeof url === 'string' ? url : url . href
203154 const timeoutSignal = AbortSignal . timeout ( timeoutMs )
204- // Compose deadline + caller signal up front so every phase — SSRF validation, the
205- // 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.
206156 const signal = init ?. signal ? AbortSignal . any ( [ init . signal , timeoutSignal ] ) : timeoutSignal
207- // The per-request pinned Agent MUST be torn down: it holds a keep-alive socket the
208- // 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.
209158 let dispatcher : Agent | undefined
210159 try {
211160 const resolvedIP = await withDeadline ( validateMcpServerSsrf ( target ) , signal )
@@ -220,22 +169,19 @@ export function createSsrfGuardedMcpFetch(timeoutMs: number = OAUTH_FETCH_TIMEOU
220169 // No pin (self-hosted allowlist) — global fetch over the shared dispatcher.
221170 response = await withDeadline ( globalThis . fetch ( url , { ...init , signal } ) , signal )
222171 }
223- // A `text/event-stream` reply is the auth-type probe's `initialize` (the only
224- // streaming case a guarded call sees); hand it back live with its own teardown so
225- // the caller reads headers without the buffer draining/stalling the stream. Every
226- // 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.
227174 const contentType = response . headers . get ( 'content-type' ) ?? ''
228175 if ( contentType . includes ( 'text/event-stream' ) ) {
229176 const streamed = releaseStreamOnSettle ( response , dispatcher , signal )
230- dispatcher = undefined // teardown ownership handed to releaseStreamOnSettle
177+ dispatcher = undefined // teardown ownership moved to releaseStreamOnSettle
231178 return streamed
232179 }
233180 return await bufferUnderDeadline ( response , signal )
234181 } catch ( error ) {
235182 const host = URL . canParse ( target ) ? new URL ( target ) . host : target
236- // Relabel only when our own deadline is what fired — identified by the
237- // rejection reason's identity, not init.signal's state (which may abort
238- // 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).
239185 if ( timeoutSignal . aborted && error === timeoutSignal . reason ) {
240186 throw new McpError ( `MCP authorization request to ${ host } timed out after ${ timeoutMs } ms` )
241187 }
@@ -246,8 +192,7 @@ export function createSsrfGuardedMcpFetch(timeoutMs: number = OAUTH_FETCH_TIMEOU
246192 }
247193 throw error
248194 } finally {
249- // Destroy (not close) so a hung leg can't make teardown itself hang; this releases
250- // 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.
251196 await dispatcher ?. destroy ( ) . catch ( ( ) => { } )
252197 }
253198 } ) satisfies FetchLike
0 commit comments