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:
parent
8beba25b58
commit
597e1bd25f
2 changed files with 242 additions and 5 deletions
|
|
@ -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;
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in a new issue