| 490 | * @param baseFetch - The fetch function to wrap |
| 491 | */ |
| 492 | export function wrapFetchWithTimeout(baseFetch: FetchLike): FetchLike { |
| 493 | return async (url: string | URL, init?: RequestInit) => { |
| 494 | const method = (init?.method ?? 'GET').toUpperCase() |
| 495 | |
| 496 | // Skip timeout for GET requests - in MCP transports, these are long-lived SSE streams. |
| 497 | // (OAuth discovery GETs in auth.ts use a separate createAuthFetch() with its own timeout.) |
| 498 | if (method === 'GET') { |
| 499 | return baseFetch(url, init) |
| 500 | } |
| 501 | |
| 502 | // Normalize headers and guarantee the Streamable-HTTP Accept value. new Headers() |
| 503 | // accepts HeadersInit | undefined and copies from plain objects, tuple arrays, |
| 504 | // and existing Headers instances — so whatever shape the SDK handed us, the |
| 505 | // Accept value survives the spread below as an own property of a concrete object. |
| 506 | // eslint-disable-next-line eslint-plugin-n/no-unsupported-features/node-builtins |
| 507 | const headers = new Headers(init?.headers) |
| 508 | if (!headers.has('accept')) { |
| 509 | headers.set('accept', MCP_STREAMABLE_HTTP_ACCEPT) |
| 510 | } |
| 511 | |
| 512 | // Use setTimeout instead of AbortSignal.timeout() so we can clearTimeout on |
| 513 | // completion. AbortSignal.timeout's internal timer is only released when the |
| 514 | // signal is GC'd, which in Bun is lazy — ~2.4KB of native memory per request |
| 515 | // lingers for the full 60s even when the request completes in milliseconds. |
| 516 | const controller = new AbortController() |
| 517 | const timer = setTimeout( |
| 518 | c => |
| 519 | c.abort(new DOMException('The operation timed out.', 'TimeoutError')), |
| 520 | MCP_REQUEST_TIMEOUT_MS, |
| 521 | controller, |
| 522 | ) |
| 523 | timer.unref?.() |
| 524 | |
| 525 | const parentSignal = init?.signal |
| 526 | const abort = () => controller.abort(parentSignal?.reason) |
| 527 | parentSignal?.addEventListener('abort', abort) |
| 528 | if (parentSignal?.aborted) { |
| 529 | controller.abort(parentSignal.reason) |
| 530 | } |
| 531 | |
| 532 | const cleanup = () => { |
| 533 | clearTimeout(timer) |
| 534 | parentSignal?.removeEventListener('abort', abort) |
| 535 | } |
| 536 | |
| 537 | try { |
| 538 | const response = await baseFetch(url, { |
| 539 | ...init, |
| 540 | headers, |
| 541 | signal: controller.signal, |
| 542 | }) |
| 543 | cleanup() |
| 544 | return response |
| 545 | } catch (error) { |
| 546 | cleanup() |
| 547 | throw error |
| 548 | } |
| 549 | } |