diff --git a/clients/web/src/test/core/mcp/fetchTracking.test.ts b/clients/web/src/test/core/mcp/fetchTracking.test.ts index 0419b840..af1e58b9 100644 --- a/clients/web/src/test/core/mcp/fetchTracking.test.ts +++ b/clients/web/src/test/core/mcp/fetchTracking.test.ts @@ -1,5 +1,9 @@ import { describe, it, expect, vi } from "vitest"; import { + CHALLENGE_BODY_MAX_BYTES, + CHALLENGE_BODY_READ_MS, + TRUNCATED_BODY_SUFFIX, + WITHHELD_TRUNCATED_JSON_BODY, createFetchTracker, findHeader, redactSensitiveHeaders, @@ -543,6 +547,281 @@ describe("createFetchTracker", () => { }); }); +describe("createFetchTracker — 401/403 challenge bodies (#2297)", () => { + function track(response: Response) { + const tracked: FetchRequestEntryBase[] = []; + const updateResponseBody = vi.fn(); + const fetcher = createFetchTracker((async () => response) as typeof fetch, { + trackRequest: (entry) => tracked.push(entry), + updateResponseBody, + }); + return { fetcher, tracked, updateResponseBody }; + } + + it("records the body on the entry itself, before returning", async () => { + const body = JSON.stringify({ error: "invalid_token" }); + const { fetcher, tracked, updateResponseBody } = track( + new Response(body, { + status: 401, + headers: { "content-type": "application/json" }, + }), + ); + + const res = await fetcher("https://mcp.example/mcp", { method: "POST" }); + + expect(tracked).toHaveLength(1); + expect(tracked[0]?.responseStatus).toBe(401); + expect(tracked[0]?.responseBody).toBe(body); + expect(updateResponseBody).not.toHaveBeenCalled(); + // The caller still gets an unread body. + expect(await res.text()).toBe(body); + }); + + it("does the same for a 403", async () => { + const { fetcher, tracked } = track( + new Response("insufficient_scope", { status: 403 }), + ); + await fetcher("https://mcp.example/mcp", { method: "POST" }); + expect(tracked[0]?.responseBody).toBe("insufficient_scope"); + }); + + it("stops at the byte cap and marks the body truncated", async () => { + const chunk = new Uint8Array(CHALLENGE_BODY_MAX_BYTES / 2).fill(97); + let pulls = 0; + const cancel = vi.fn(); + const stream = new ReadableStream({ + pull(controller) { + pulls += 1; + controller.enqueue(chunk); + }, + cancel, + }); + const { fetcher, tracked } = track(new Response(stream, { status: 401 })); + + const res = await fetcher("https://mcp.example/mcp", { method: "POST" }); + + const recorded = tracked[0]?.responseBody ?? ""; + expect(recorded.endsWith(TRUNCATED_BODY_SUFFIX)).toBe(true); + expect(recorded.length - TRUNCATED_BODY_SUFFIX.length).toBe( + CHALLENGE_BODY_MAX_BYTES, + ); + // With the clone released, cancelling the caller's branch reaches the source. + await res.body?.cancel(); + expect(cancel).toHaveBeenCalled(); + expect(pulls).toBeLessThan(10); + }); + + it("gives up on a stalled body at the deadline and releases the source", async () => { + vi.useFakeTimers(); + try { + const cancel = vi.fn(); + const stream = new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode("partial")); + }, + cancel, + }); + const { fetcher, tracked } = track(new Response(stream, { status: 401 })); + + const pending = fetcher("https://mcp.example/mcp", { method: "POST" }); + await vi.advanceTimersByTimeAsync(CHALLENGE_BODY_READ_MS); + const res = await pending; + + expect(tracked[0]?.responseBody).toBe(`partial${TRUNCATED_BODY_SUFFIX}`); + await res.body?.cancel(); + expect(cancel).toHaveBeenCalled(); + } finally { + vi.useRealTimers(); + } + }); + + it("records the entry with no body when the clone cannot be made", async () => { + const response = new Response("nope", { status: 401 }); + vi.spyOn(response, "clone").mockImplementation(() => { + throw new Error("clone failed"); + }); + const { fetcher, tracked } = track(response); + + await fetcher("https://mcp.example/mcp", { method: "POST" }); + + expect(tracked).toHaveLength(1); + expect(tracked[0]?.responseStatus).toBe(401); + expect(tracked[0]?.responseBody).toBeUndefined(); + }); + + it("records the entry with no body when the read errors", async () => { + const stream = new ReadableStream({ + pull(controller) { + controller.error(new Error("socket reset")); + }, + }); + const { fetcher, tracked } = track(new Response(stream, { status: 401 })); + + await fetcher("https://mcp.example/mcp", { method: "POST" }); + + expect(tracked[0]?.responseStatus).toBe(401); + expect(tracked[0]?.responseBody).toBeUndefined(); + }); +}); + +describe("createFetchTracker — challenge body bounds and redaction (#2297)", () => { + function trackOne(response: Response) { + const tracked: FetchRequestEntryBase[] = []; + const fetcher = createFetchTracker((async () => response) as typeof fetch, { + trackRequest: (entry) => tracked.push(entry), + }); + return { fetcher, tracked }; + } + + /** A body that delivers `prefix` and then never ends. */ + function stalledBody(prefix: string): ReadableStream { + return new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode(prefix)); + }, + }); + } + + async function withDeadlineElapsed(run: () => Promise): Promise { + vi.useFakeTimers(); + try { + const pending = run(); + await vi.advanceTimersByTimeAsync(CHALLENGE_BODY_READ_MS); + return await pending; + } finally { + vi.useRealTimers(); + } + } + + it("caps a single chunk larger than the budget at the byte limit", async () => { + const oversized = new Uint8Array(1024 * 1024).fill(97); + const { fetcher, tracked } = trackOne( + new Response( + new ReadableStream({ + start(controller) { + controller.enqueue(oversized); + controller.close(); + }, + }), + { status: 401 }, + ), + ); + + await fetcher("https://mcp.example/mcp", { method: "POST" }); + + const recorded = tracked[0]?.responseBody ?? ""; + expect(recorded.endsWith(TRUNCATED_BODY_SUFFIX)).toBe(true); + expect(recorded.length - TRUNCATED_BODY_SUFFIX.length).toBe( + CHALLENGE_BODY_MAX_BYTES, + ); + }); + + it("records the body of a 401 to a long-lived-stream GET", async () => { + const { fetcher, tracked } = trackOne( + new Response("unauthorized", { + status: 401, + headers: { "content-type": "text/event-stream" }, + }), + ); + + await fetcher("https://mcp.example/mcp", { method: "GET" }); + + expect(tracked[0]?.responseBody).toBe("unauthorized"); + }); + + it("withholds a cut-off JSON body rather than logging an unredacted prefix", async () => { + const { fetcher, tracked } = trackOne( + new Response(stalledBody('{"access_token":"live-access","token_type'), { + status: 401, + headers: { "content-type": "application/json" }, + }), + ); + + await withDeadlineElapsed(() => + fetcher("https://mcp.example/token", { method: "POST" }), + ); + + expect(tracked[0]?.responseBody).toBe(WITHHELD_TRUNCATED_JSON_BODY); + expect(serializedWithoutId(tracked[0]!)).not.toContain("live-access"); + }); + + it("withholds a cut-off body that sniffs as JSON with no content-type", async () => { + const { fetcher, tracked } = trackOne( + new Response(stalledBody(' [{"refresh_token":"live-refresh"'), { + status: 403, + }), + ); + + await withDeadlineElapsed(() => + fetcher("https://mcp.example/token", { method: "POST" }), + ); + + expect(tracked[0]?.responseBody).toBe(WITHHELD_TRUNCATED_JSON_BODY); + }); + + it("still redacts a complete JSON challenge body", async () => { + const { fetcher, tracked } = trackOne( + new Response(JSON.stringify({ error: "x", access_token: "live" }), { + status: 401, + headers: { "content-type": "application/json" }, + }), + ); + + await fetcher("https://mcp.example/token", { method: "POST" }); + + expect(tracked[0]?.responseBody).toBe( + JSON.stringify({ error: "x", access_token: REDACTED_VALUE }), + ); + }); + + it("redacts a cut-off form body field by field", async () => { + const { fetcher, tracked } = trackOne( + new Response(stalledBody("error=invalid&access_token=live&scope=re"), { + status: 401, + headers: { "content-type": "application/x-www-form-urlencoded" }, + }), + ); + + await withDeadlineElapsed(() => + fetcher("https://mcp.example/token", { method: "POST" }), + ); + + const recorded = tracked[0]?.responseBody ?? ""; + expect(recorded).not.toContain("live"); + expect(recorded.endsWith(TRUNCATED_BODY_SUFFIX)).toBe(true); + }); +}); + +describe("createFetchTracker — challenge body cancel rejection (#2297)", () => { + it("swallows the source's cancel() rejection after stopping early", async () => { + const chunk = new Uint8Array(CHALLENGE_BODY_MAX_BYTES).fill(97); + const stream = new ReadableStream({ + pull(controller) { + controller.enqueue(chunk); + }, + cancel() { + throw new Error("cancel failed"); + }, + }); + const tracked: FetchRequestEntryBase[] = []; + const fetcher = createFetchTracker( + (async () => new Response(stream, { status: 401 })) as typeof fetch, + { trackRequest: (entry) => tracked.push(entry) }, + ); + + const res = await fetcher("https://mcp.example/mcp", { method: "POST" }); + // Cancelling the caller's branch cancels the source, whose rejection then + // reaches the tracker's own cancel of its clone — which must swallow it. + await res.body?.cancel().catch(() => undefined); + await flush(); + + expect(tracked[0]?.responseStatus).toBe(401); + expect(tracked[0]?.responseBody?.endsWith(TRUNCATED_BODY_SUFFIX)).toBe( + true, + ); + }); +}); + describe("redactUrlQuery", () => { it("redacts sensitive params and keeps the path + other params", () => { expect( diff --git a/clients/web/src/test/integration/mcp/node/transport.test.ts b/clients/web/src/test/integration/mcp/node/transport.test.ts index e5326b52..83b8e48a 100644 --- a/clients/web/src/test/integration/mcp/node/transport.test.ts +++ b/clients/web/src/test/integration/mcp/node/transport.test.ts @@ -271,6 +271,90 @@ describe("Transport", () => { } }); + it("records an intercepted 401's status, WWW-Authenticate and body (#2297)", async () => { + const challengeBody = JSON.stringify({ + error: "invalid_token", + error_description: "token expired", + }); + const wwwAuthenticate = + 'Bearer resource_metadata="https://mcp.example/.well-known/oauth-protected-resource", scope="files:read"'; + const fetchFn: typeof fetch = async () => + new Response(challengeBody, { + status: 401, + statusText: "Unauthorized", + headers: { + "content-type": "application/json", + "www-authenticate": wwwAuthenticate, + }, + }); + + const fetchRequests: FetchRequestEntryBase[] = []; + const onFetchResponseBody = vi.fn(); + const result = createTransportNode( + { type: "streamable-http", url: "https://mcp.example/mcp" }, + { + fetchFn, + interceptAuthChallenges: true, + onFetchRequest: (entry) => fetchRequests.push(entry), + onFetchResponseBody, + }, + ); + + const client = new Client( + { name: "test-client", version: "1.0.0" }, + { capabilities: {} }, + ); + await expect(client.connect(result.transport)).rejects.toThrow( + "MCP auth challenge (401)", + ); + + // The body is on the entry by the time the challenge is thrown — not a + // later update — so a caller that stops listening at the throw has it. + expect(fetchRequests).toHaveLength(1); + const [entry] = fetchRequests; + expect(entry.error).toBeUndefined(); + expect(entry.responseStatus).toBe(401); + expect(entry.responseHeaders?.["www-authenticate"]).toBe(wwwAuthenticate); + expect(entry.responseBody).toBe(challengeBody); + expect(onFetchResponseBody).not.toHaveBeenCalled(); + }); + + it("still throws an intercepted 401 whose body never ends (#2297)", async () => { + const fetchFn: typeof fetch = async () => + new Response( + new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode("partial")); + }, + }), + { + status: 401, + headers: { "www-authenticate": 'Bearer error="invalid_token"' }, + }, + ); + + const fetchRequests: FetchRequestEntryBase[] = []; + const result = createTransportNode( + { type: "streamable-http", url: "https://mcp.example/mcp" }, + { + fetchFn, + interceptAuthChallenges: true, + onFetchRequest: (entry) => fetchRequests.push(entry), + }, + ); + + const client = new Client( + { name: "test-client", version: "1.0.0" }, + { capabilities: {} }, + ); + await expect(client.connect(result.transport)).rejects.toThrow( + "MCP auth challenge (401)", + ); + expect(fetchRequests[0]?.responseStatus).toBe(401); + // Cut off at the tracker's read deadline rather than buffered forever. + expect(fetchRequests[0]?.responseBody).toBe("partial\n[truncated]"); + }); + it("applies settings.headers to the outgoing streamable-http request", async () => { const server = createTestServerHttp({ serverInfo: createTestServerInfo(), diff --git a/clients/web/src/test/integration/mcp/remote/remote-auth-branches.test.ts b/clients/web/src/test/integration/mcp/remote/remote-auth-branches.test.ts index 355f5bb8..ef1a7967 100644 --- a/clients/web/src/test/integration/mcp/remote/remote-auth-branches.test.ts +++ b/clients/web/src/test/integration/mcp/remote/remote-auth-branches.test.ts @@ -193,6 +193,69 @@ describe("server.ts /api/mcp/* branch coverage", () => { await new Promise((r) => upstream.server.close(() => r())); } }); + + it("relays a streamable-http 401's status and body over the event stream (#2297)", async () => { + const upstream = await startUnauthorizedUpstream(); + const events = new AbortController(); + try { + const connected = (await ( + await connect(h, { type: "streamable-http", url: upstream.url }) + ).json()) as { sessionId?: string }; + expect(connected.sessionId).toBeDefined(); + + const stream = await fetch( + `${h.baseUrl}/api/mcp/events?sessionId=${connected.sessionId}`, + { signal: events.signal }, + ); + const sent = await fetch(`${h.baseUrl}/api/mcp/send`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + sessionId: connected.sessionId, + message: { + jsonrpc: "2.0", + id: 1, + method: "initialize", + params: { + protocolVersion: "2025-06-18", + capabilities: {}, + clientInfo: { name: "test", version: "1.0.0" }, + }, + }, + }), + }); + expect(await sent.json()).toMatchObject({ + ok: false, + kind: "auth_challenge", + }); + + const reader = stream.body!.getReader(); + const decoder = new TextDecoder(); + const pattern = /data: (\{"type":"fetch_request",.*?\})\n/; + let text = ""; + let match: RegExpMatchArray | null = null; + while (!(match = text.match(pattern))) { + const { done, value } = await reader.read(); + if (done) break; + text += decoder.decode(value, { stream: true }); + } + const event = JSON.parse(match![1]!) as { + data: { + responseStatus?: number; + responseBody?: string; + error?: string; + }; + }; + expect(event.data.error).toBeUndefined(); + expect(event.data.responseStatus).toBe(401); + expect(event.data.responseBody).toBe( + JSON.stringify({ error: "unauthorized" }), + ); + } finally { + events.abort(); + await new Promise((r) => upstream.server.close(() => r())); + } + }); }); describe("POST /api/mcp/send", () => { diff --git a/core/mcp/fetchTracking.ts b/core/mcp/fetchTracking.ts index 10eb1d00..d4cce790 100644 --- a/core/mcp/fetchTracking.ts +++ b/core/mcp/fetchTracking.ts @@ -150,6 +150,14 @@ function redactJsonValue(value: unknown): unknown { * (and other binary/opaque bodies) are passed through verbatim — OAuth never uses * multipart, so the risk is low; revisit if a multipart secret path appears. */ +export function redactBody( + body: string, + contentType: string | null | undefined, +): string; +export function redactBody( + body: string | undefined, + contentType: string | null | undefined, +): string | undefined; export function redactBody( body: string | undefined, contentType: string | null | undefined, @@ -232,6 +240,98 @@ export function isLongLivedStreamResponse( ); } +/** Most of a 401/403 body the tracker will buffer before it stops reading. */ +export const CHALLENGE_BODY_MAX_BYTES = 64 * 1024; +/** How long the tracker waits for a 401/403 body before giving up on the rest. */ +export const CHALLENGE_BODY_READ_MS = 2_000; +/** Appended to a 401/403 body the tracker stopped reading early. */ +export const TRUNCATED_BODY_SUFFIX = "\n[truncated]"; +/** + * Recorded in place of a 401/403 body that was cut off and looks like JSON. + * {@link redactBody} can only mask fields in JSON that parses, so a cut-off + * prefix such as `{"access_token":"…",` would otherwise reach the log verbatim. + */ +export const WITHHELD_TRUNCATED_JSON_BODY = + "[truncated JSON body withheld: an incomplete body cannot be redacted]"; + +interface ChallengeBodyRead { + text: string; + truncated: boolean; +} + +/** + * The recorded form of a 401/403 body: redacted, marked when cut off, and never + * a cut-off JSON prefix. Form-encoded bodies still redact when cut, since each + * surviving `key=value` pair is parsed on its own; plain text has nothing to + * redact either way. + */ +function recordableChallengeBody( + read: ChallengeBodyRead, + contentType: string | null, +): string { + if (!read.truncated) return redactBody(read.text, contentType); + const looksLikeJson = + (contentType ?? "").toLowerCase().includes("json") || + /^\s*[{[]/.test(read.text); + if (looksLikeJson) return WITHHELD_TRUNCATED_JSON_BODY; + return redactBody(read.text, contentType) + TRUNCATED_BODY_SUFFIX; +} + +/** + * Read a 401/403 response body from a clone, bounded by + * {@link CHALLENGE_BODY_MAX_BYTES} and {@link CHALLENGE_BODY_READ_MS}. + * + * Unlike every other status, a challenge body is read *before* the entry is + * recorded (#2297). An auth-challenge interceptor above the tracker cancels the + * response and throws as soon as the tracker returns, and on the web backend + * that ends the connect request — so a body delivered later, as a separate + * update, arrives after anything could still carry it to the log. The bound is + * what makes waiting safe: a body that never ends is cut off, and cancelling the + * clone's reader lets the interceptor's cancel of the original release the + * connection instead of leaving the clone draining it. + */ +async function readChallengeBody( + response: Response, +): Promise { + let reader: ReadableStreamDefaultReader; + try { + const body = response.clone().body; + /* v8 ignore next -- the caller checked response.body, and a clone of a response with a body has one */ + if (!body) return undefined; + reader = body.getReader(); + } catch { + return undefined; + } + + const decoder = new TextDecoder(); + let text = ""; + let bytes = 0; + let timer: ReturnType | undefined; + const deadline = new Promise<"timeout">((resolve) => { + timer = setTimeout(() => resolve("timeout"), CHALLENGE_BODY_READ_MS); + }); + try { + while (bytes < CHALLENGE_BODY_MAX_BYTES) { + const next = await Promise.race([reader.read(), deadline]); + if (next === "timeout") break; + if (next.done) return { text: text + decoder.decode(), truncated: false }; + // A stream may hand back one chunk far larger than the budget, so the cap + // is applied to the bytes, not to the chunk count. + const chunk = next.value.subarray(0, CHALLENGE_BODY_MAX_BYTES - bytes); + bytes += chunk.byteLength; + text += decoder.decode(chunk, { stream: true }); + } + // Stopped early. Not awaited: cancel() adopts the source's promise, which a + // stalled body may never settle. + reader.cancel().catch(() => {}); + return { text: text + decoder.decode(), truncated: true }; + } catch { + return undefined; + } finally { + clearTimeout(timer); + } +} + export interface FetchTrackingCallbacks { trackRequest?: (entry: FetchRequestEntryBase) => void; /** @@ -578,6 +678,22 @@ export function createFetchTracker( duration, }; + // Not gated on isLongLivedStream: that exclusion guards an unbounded read, + // and this one is bounded — so a challenge to an SSE GET is recorded too. + if ( + (responseStatus === 401 || responseStatus === 403) && + response.body && + !response.bodyUsed + ) { + const read = await readChallengeBody(response); + entry.responseBody = + read === undefined + ? undefined + : recordableChallengeBody(read, response.headers.get("content-type")); + callbacks.trackRequest?.(entry); + return response; + } + callbacks.trackRequest?.(entry); // A long-lived stream is watched instead of read: its body never ends, diff --git a/core/mcp/node/authChallengeFetch.ts b/core/mcp/node/authChallengeFetch.ts index 0ce757ba..1ed8e34a 100644 --- a/core/mcp/node/authChallengeFetch.ts +++ b/core/mcp/node/authChallengeFetch.ts @@ -24,8 +24,11 @@ export function createAuthChallengeInterceptFetch( } // Release the connection before throwing so the SDK transport is not left - // with a half-read 401/403 body on streamable HTTP. - await response.body?.cancel().catch(() => {}); + // with a half-read 401/403 body on streamable HTTP. Not awaited: when the + // network tracker sits below this wrapper (#2297) the body is one branch of + // a tee, and cancel() adopts the source's promise, which a stalled body may + // never settle — so awaiting it could hang the throw. + void response.body?.cancel().catch(() => {}); throw new AuthChallengeError( challenge, diff --git a/core/mcp/node/transport.ts b/core/mcp/node/transport.ts index c9a8ee54..08792f0c 100644 --- a/core/mcp/node/transport.ts +++ b/core/mcp/node/transport.ts @@ -74,12 +74,29 @@ export function createTransportNode( onAuthChallengeObserved ? createAuthChallengeObserverFetch(inner, onAuthChallengeObserved) : inner; + const withTracking = (inner: typeof fetch): typeof fetch => + wantsFetchTracking + ? createFetchTracker(inner, { + trackRequest: onFetchRequest, + updateResponseBody: onFetchResponseBody, + updateStream: onFetchStreamUpdate, + }) + : inner; // The observer sits *under* the interceptor so it still reports the // challenge on the path where interception throws — and, more to the point, // on the legacy first-auth path where interception is off entirely. + // + // The network tracker sits under the interceptor too (#2297). Above it, the + // tracker never saw the 401/403 at all: the interceptor cancels the body and + // throws, so the entry was recorded from the thrown error alone — no status, + // no `WWW-Authenticate`, and no response body, which is exactly the detail + // someone debugging an OAuth challenge needs. Below it, the tracker records + // the real response and reads its body (bounded) before the interceptor + // cancels it. + const trackedBase = withTracking(withChallengeObserver(baseFetch)); const fetchWithOptionalAuthIntercept = interceptAuthChallenges - ? createAuthChallengeInterceptFetch(withChallengeObserver(baseFetch)) - : withChallengeObserver(baseFetch); + ? createAuthChallengeInterceptFetch(trackedBase) + : trackedBase; if (serverType === "stdio") { const stdioConfig = config as StdioServerConfig; @@ -118,16 +135,9 @@ export function createTransportNode( const configuredSseFetch = sseConfig.eventSourceInit?.fetch as | typeof fetch | undefined; - const sseFetch = configuredSseFetch - ? withChallengeObserver(configuredSseFetch) + const trackedFetch = configuredSseFetch + ? withTracking(withChallengeObserver(configuredSseFetch)) : fetchWithOptionalAuthIntercept; - const trackedFetch = wantsFetchTracking - ? createFetchTracker(sseFetch, { - trackRequest: onFetchRequest, - updateResponseBody: onFetchResponseBody, - updateStream: onFetchStreamUpdate, - }) - : sseFetch; const headers = headersFromSettings(settings); @@ -142,19 +152,11 @@ export function createTransportNode( ...(headers && { headers }), }; - const postFetch = wantsFetchTracking - ? createFetchTracker(fetchWithOptionalAuthIntercept, { - trackRequest: onFetchRequest, - updateResponseBody: onFetchResponseBody, - updateStream: onFetchStreamUpdate, - }) - : fetchWithOptionalAuthIntercept; - const transport = new SSEClientTransport(url, { authProvider, eventSourceInit, requestInit, - fetch: postFetch, + fetch: fetchWithOptionalAuthIntercept, }); return { transport }; @@ -170,18 +172,10 @@ export function createTransportNode( ...(headers && { headers }), }; - const transportFetch = wantsFetchTracking - ? createFetchTracker(fetchWithOptionalAuthIntercept, { - trackRequest: onFetchRequest, - updateResponseBody: onFetchResponseBody, - updateStream: onFetchStreamUpdate, - }) - : fetchWithOptionalAuthIntercept; - const transport = new StreamableHTTPClientTransport(url, { authProvider, requestInit, - fetch: transportFetch, + fetch: fetchWithOptionalAuthIntercept, // SEP-2350: how the transport reacts to a `403 insufficient_scope` // challenge. Defaults to the SDK's `reauthorize` when unset. ...(settings?.oauthOnInsufficientScope && {