inspector/core/mcp/node/authChallengeFetch.ts
cliffhall 597e1bd25f fix(auth): carry resource_metadata into first-time legacy authorization
The challenge-driven plumbing did not reach the default CLI/TUI flow. With
no stored token in the legacy era, InspectorClient deliberately builds the
transport with no authProvider (so the SDK cannot open a browser before the
callback server is listening), which also leaves challenge interception off;
the 401 then surfaces as the SDK's headerless UnauthorizedError and the
runner calls authenticate() with no challenge in hand. So a server serving
metadata only at the advertised path still failed there — including for the
legacy showcase the README instructs.

Add a passive counterpart to the challenge interceptor: an observer fetch
that reports every 401/403 without touching control flow or the body. The
OAuthManager records the advertised URL from it and uses it in
authenticate() and its CIMD pre-registration. Latest-wins, including
clearing when a later challenge advertises none.

Also: the fixture helper dropped an explicitly supplied empty
resourceMetadataPath by truthiness, bypassing validation and silently
reverting to the well-known route.

Covered by a direct-client integration test that drives connect ->
authenticate() against the real server and asserts the advertised path was
requested and the well-known one never was; verified failing without the
fix.

Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-24 21:04:34 -04:00

70 lines
2.7 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.
await 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;
};
}