The network tracker wrapped the auth-challenge interceptor, so a 401/403 never reached it as a response: the interceptor cancelled the body and threw, and the tracker logged only the thrown error. NetworkEntry renders its response section only when a status is present, so the body — and the WWW-Authenticate resource_metadata and scope — were never shown. Move the tracker below the interceptor so it records the real response and clones its body before the interceptor cancels it, and stop awaiting that cancel: with the tracker's clone as the other tee branch, the cancel settles only once the clone reads to the end, which would hang on an unending body. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SG6q6YEDJyJB5H2Hr8nBDS Signed-off-by: cliffhall <cliff@futurescale.com>
73 lines
2.9 KiB
TypeScript
73 lines
2.9 KiB
TypeScript
import {
|
|
AuthChallengeError,
|
|
parseAuthChallengeFromResponse,
|
|
} from "../../auth/challenge.js";
|
|
import type { AuthChallenge } from "../../auth/challenge.js";
|
|
|
|
/**
|
|
* Wrap fetch so MCP HTTP 401/403 responses become {@link AuthChallengeError}
|
|
* before the SDK invokes `auth()` on a frozen remote token provider.
|
|
*/
|
|
export function createAuthChallengeInterceptFetch(
|
|
baseFetch: typeof fetch,
|
|
): typeof fetch {
|
|
return async (input, init) => {
|
|
const response = await baseFetch(input, init);
|
|
if (response.status !== 401 && response.status !== 403) {
|
|
return response;
|
|
}
|
|
|
|
const challenge = parseAuthChallengeFromResponse(response);
|
|
/* v8 ignore next 3 -- parseAuthChallengeFromResponse only returns undefined for non-401/403, which the status guard above already excludes */
|
|
if (!challenge) {
|
|
return response;
|
|
}
|
|
|
|
// Release the connection before throwing so the SDK transport is not left
|
|
// 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 cancelling a branch settles only once the tracker's clone has
|
|
// read to the end — so a 401 whose body never ends would hang the throw.
|
|
void response.body?.cancel().catch(() => {});
|
|
|
|
throw new AuthChallengeError(
|
|
challenge,
|
|
response.status,
|
|
`MCP auth challenge (${response.status})`,
|
|
);
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Wrap fetch so every MCP HTTP 401/403 is *reported* — control flow untouched,
|
|
* response returned as-is.
|
|
*
|
|
* This is the passive sibling of {@link createAuthChallengeInterceptFetch},
|
|
* and it exists for the one case that one cannot cover: a first-time
|
|
* authorization in the default legacy era. There, `InspectorClient`
|
|
* deliberately builds the transport with no `authProvider` (so the SDK does
|
|
* not open a browser before the callback server is listening) and therefore
|
|
* leaves interception off, so the CLI/TUI runner catches a plain
|
|
* `UnauthorizedError` and calls `authenticate()` itself. That SDK error
|
|
* carries no headers, so the challenge's RFC 9728 `resource_metadata` would be
|
|
* lost before discovery ever starts (#2071) — the observer keeps it.
|
|
*
|
|
* Only the status line and headers are read; the body is left untouched for
|
|
* the transport (or the interceptor layered above this one) to consume.
|
|
*/
|
|
export function createAuthChallengeObserverFetch(
|
|
baseFetch: typeof fetch,
|
|
onChallenge: (challenge: AuthChallenge) => void,
|
|
): typeof fetch {
|
|
return async (input, init) => {
|
|
const response = await baseFetch(input, init);
|
|
if (response.status === 401 || response.status === 403) {
|
|
const challenge = parseAuthChallengeFromResponse(response);
|
|
/* v8 ignore next 3 -- parseAuthChallengeFromResponse only returns undefined for non-401/403, which the status guard above already excludes */
|
|
if (challenge) {
|
|
onChallenge(challenge);
|
|
}
|
|
}
|
|
return response;
|
|
};
|
|
}
|