(innerFetch: FetchLike)
| 370 | * 15-min needs-auth cache. |
| 371 | */ |
| 372 | export function createClaudeAiProxyFetch(innerFetch: FetchLike): FetchLike { |
| 373 | return async (url, init) => { |
| 374 | const doRequest = async () => { |
| 375 | await checkAndRefreshOAuthTokenIfNeeded() |
| 376 | const currentTokens = getClaudeAIOAuthTokens() |
| 377 | if (!currentTokens) { |
| 378 | throw new Error('No claude.ai OAuth token available') |
| 379 | } |
| 380 | // eslint-disable-next-line eslint-plugin-n/no-unsupported-features/node-builtins |
| 381 | const headers = new Headers(init?.headers) |
| 382 | headers.set('Authorization', `Bearer ${currentTokens.accessToken}`) |
| 383 | const response = await innerFetch(url, { ...init, headers }) |
| 384 | // Return the exact token that was sent. Reading getClaudeAIOAuthTokens() |
| 385 | // again after the request is wrong under concurrent 401s: another |
| 386 | // connector's handleOAuth401Error clears the memoize cache, so we'd read |
| 387 | // the NEW token from keychain, pass it to handleOAuth401Error, which |
| 388 | // finds same-as-keychain → returns false → skips retry. Same pattern as |
| 389 | // bridgeApi.ts withOAuthRetry (token passed as fn param). |
| 390 | return { response, sentToken: currentTokens.accessToken } |
| 391 | } |
| 392 | |
| 393 | const { response, sentToken } = await doRequest() |
| 394 | if (response.status !== 401) { |
| 395 | return response |
| 396 | } |
| 397 | // handleOAuth401Error returns true only if the token actually changed |
| 398 | // (keychain had a newer one, or force-refresh succeeded). Gate retry on |
| 399 | // that — otherwise we double round-trip time for every connector whose |
| 400 | // downstream service genuinely needs auth (the common case: 30+ servers |
| 401 | // with "MCP server requires authentication but no OAuth token configured"). |
| 402 | const tokenChanged = await handleOAuth401Error(sentToken).catch(() => false) |
| 403 | logEvent('tengu_mcp_claudeai_proxy_401', { |
| 404 | tokenChanged: |
| 405 | tokenChanged as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, |
| 406 | }) |
| 407 | if (!tokenChanged) { |
| 408 | // ELOCKED contention: another connector may have won the lockfile and refreshed — check if token changed underneath us |
| 409 | const now = getClaudeAIOAuthTokens()?.accessToken |
| 410 | if (!now || now === sentToken) { |
| 411 | return response |
| 412 | } |
| 413 | } |
| 414 | try { |
| 415 | return (await doRequest()).response |
| 416 | } catch { |
| 417 | // Retry itself failed (network error). Return the original 401 so the |
| 418 | // outer handler can classify it. |
| 419 | return response |
| 420 | } |
| 421 | } |
| 422 | } |
| 423 | |
| 424 | // Minimal interface for WebSocket instances passed to mcpWebSocketTransport |
| 425 | type WsClientLike = { |
no test coverage detected