Skip to content

Commit c1f8195

Browse files
committed
chore(mcp): trim verbose TSDoc and inline comments in pinned fetch + probe
1 parent 1313fc2 commit c1f8195

2 files changed

Lines changed: 50 additions & 107 deletions

File tree

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.ts

Lines changed: 45 additions & 100 deletions
Original file line numberDiff line numberDiff line change
@@ -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. */
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 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
*/
3523
export 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
*/
5234
const 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
*/
6141
const 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. */
6444
function 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
*/
8257
function 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
*/
11284
async 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
*/
137104
function 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

Comments
 (0)