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