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>
This commit is contained in:
cliffhall 2026-08-24 21:04:34 -04:00
commit 597e1bd25f
5 changed files with 242 additions and 5 deletions

View file

@ -1933,6 +1933,16 @@ export class InspectorClient extends InspectorClientEventTarget {
...(this.serverSettings && { settings: this.serverSettings }),
};
if (this.isHttpOAuthConfig() && oauthManager) {
// Record every 401/403 the transport sees, whatever else happens to
// it. The legacy first-authorization path deliberately runs with no
// authProvider and no challenge interception (see below), so the SDK
// raises a headerless `UnauthorizedError` and the client calls
// `authenticate()` with nothing in hand — this is the only place the
// challenge's RFC 9728 `resource_metadata` still exists (#2071).
const manager = oauthManager;
transportOptions.onAuthChallengeObserved = (challenge) => {
manager.noteObservedAuthChallenge(challenge);
};
if (oauthManager.isEnterpriseManaged()) {
await oauthManager.trySilentEnterpriseManagedAuth();
const provider = await oauthManager.createOAuthProviderForTransport();

View file

@ -2,6 +2,7 @@ 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}
@ -33,3 +34,37 @@ export function createAuthChallengeInterceptFetch(
);
};
}
/**
* 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;
};
}

View file

@ -12,7 +12,10 @@ import { StdioClientTransport } from "@modelcontextprotocol/client/stdio";
import { SSEClientTransport } from "@modelcontextprotocol/client";
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/client";
import { createFetchTracker } from "../fetchTracking.js";
import { createAuthChallengeInterceptFetch } from "./authChallengeFetch.js";
import {
createAuthChallengeInterceptFetch,
createAuthChallengeObserverFetch,
} from "./authChallengeFetch.js";
import type { Dispatcher } from "undici";
/** Node's `RequestInit` plus the undici-specific `dispatcher` option. */
@ -110,14 +113,21 @@ export function createTransportNode(
authProvider,
settings,
interceptAuthChallenges = false,
onAuthChallengeObserved,
} = options;
// Proxy-wrap first so the auth-challenge interceptor observes responses from
// the (optionally proxied) network call.
const baseFetch = withProxyDispatcher(optionsFetchFn ?? globalThis.fetch);
const fetchWithOptionalAuthIntercept = interceptAuthChallenges
? createAuthChallengeInterceptFetch(baseFetch)
// 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.
const observedFetch = onAuthChallengeObserved
? createAuthChallengeObserverFetch(baseFetch, onAuthChallengeObserved)
: baseFetch;
const fetchWithOptionalAuthIntercept = interceptAuthChallenges
? createAuthChallengeInterceptFetch(observedFetch)
: observedFetch;
if (serverType === "stdio") {
const stdioConfig = config as StdioServerConfig;

View file

@ -82,6 +82,12 @@ export class OAuthManager {
private oauthFlowState: OAuthFlowState | null = null;
/** SEP-2350 union scope pending until interactive step-up completes. */
private pendingAuthorizationScope: string | undefined;
/**
* The RFC 9728 metadata URL from the last 401/403 the transport observed.
* See {@link noteObservedAuthChallenge}.
*/
private observedResourceMetadataUrl: URL | undefined;
private authChallengeMutex: Promise<void> = Promise.resolve();
constructor(params: OAuthManagerParams) {
@ -263,6 +269,20 @@ export class OAuthManager {
return authorizationUrl;
}
/**
* Record a challenge the transport saw but did not raise as one.
*
* The legacy first-authorization path connects with no `authProvider` and no
* challenge interception, so the client calls {@link authenticate} off a
* headerless SDK `UnauthorizedError` — this is what carries the advertised
* RFC 9728 metadata URL across that gap (#2071). Latest-wins: every
* challenge comes from the same server, so the most recent one is what that
* server currently says, including when it says nothing.
*/
noteObservedAuthChallenge(challenge: AuthChallenge): void {
this.observedResourceMetadataUrl = challengeResourceMetadataUrl(challenge);
}
async authenticate(): Promise<URL | undefined> {
if (this.isEnterpriseManaged()) {
return this.authenticateEnterpriseManaged();
@ -273,16 +293,23 @@ export class OAuthManager {
provider.clearCapturedAuthUrl();
// No challenge is passed to this entry point — it is reached from a plain
// unauthorized error — so the last one the transport observed is the only
// source for the advertised metadata URL.
const resourceMetadataUrl = this.observedResourceMetadataUrl;
await ensureCimdClientRegistration({
serverUrl,
provider,
fetchFn: this.params.effectiveAuthFetch,
resourceMetadataUrl,
});
const result = await mcpAuth(provider, {
serverUrl,
scope: provider.scope,
fetchFn: this.params.effectiveAuthFetch,
resourceMetadataUrl,
});
if (result === "AUTHORIZED") {

View file

@ -34,6 +34,7 @@ import type {
RedirectUrlProvider,
} from "../auth/providers.js";
import type { OAuthStorage } from "../auth/storage.js";
import type { AuthChallenge } from "../auth/challenge.js";
// Stdio transport config
export interface StdioServerConfig {
@ -867,6 +868,15 @@ export interface CreateTransportOptions {
* become {@link AuthChallengeError} before the SDK calls `auth()` on a frozen provider.
*/
interceptAuthChallenges?: boolean;
/**
* Called for every HTTP 401/403 the transport sees, without altering control
* flow. Unlike {@link interceptAuthChallenges} this never throws, so it also
* covers the legacy first-time-authorization path where interception is
* deliberately off and the SDK's headerless `UnauthorizedError` would
* otherwise lose the challenge's `resource_metadata` (#2071).
*/
onAuthChallengeObserved?: (challenge: AuthChallenge) => void;
}
export interface CreateTransportResult {