Merge branch 'v2/main' into v2/feat/2068-refresh-token-toggle
This commit is contained in:
commit
6968738f24
5 changed files with 1427 additions and 1310 deletions
|
|
@ -2025,6 +2025,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();
|
||||
|
|
|
|||
|
|
@ -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 { createProxyFetch } from "./proxyFetch.js";
|
||||
|
||||
/**
|
||||
|
|
@ -49,6 +52,7 @@ export function createTransportNode(
|
|||
authProvider,
|
||||
settings,
|
||||
interceptAuthChallenges = false,
|
||||
onAuthChallengeObserved,
|
||||
} = options;
|
||||
|
||||
// `optionsFetchFn` is the caller's whole fetch stack and already sits on top of
|
||||
|
|
@ -57,9 +61,18 @@ export function createTransportNode(
|
|||
// that. The fallback is for the one caller that supplies nothing: the web
|
||||
// backend (`core/mcp/remote/node/server.ts`), which calls this directly.
|
||||
const baseFetch = optionsFetchFn ?? createProxyFetch() ?? globalThis.fetch;
|
||||
// Purely passive, so — unlike proxying and interception — it is safe to
|
||||
// apply to any fetch, including a caller's explicit one.
|
||||
const withChallengeObserver = (inner: typeof fetch): typeof fetch =>
|
||||
onAuthChallengeObserved
|
||||
? createAuthChallengeObserverFetch(inner, onAuthChallengeObserved)
|
||||
: 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.
|
||||
const fetchWithOptionalAuthIntercept = interceptAuthChallenges
|
||||
? createAuthChallengeInterceptFetch(baseFetch)
|
||||
: baseFetch;
|
||||
? createAuthChallengeInterceptFetch(withChallengeObserver(baseFetch))
|
||||
: withChallengeObserver(baseFetch);
|
||||
|
||||
if (serverType === "stdio") {
|
||||
const stdioConfig = config as StdioServerConfig;
|
||||
|
|
@ -92,9 +105,15 @@ export function createTransportNode(
|
|||
// A caller-supplied eventSourceInit.fetch wins as-is (explicit fetch is not
|
||||
// re-wrapped for proxying); the default path uses fetchWithOptionalAuthIntercept
|
||||
// (the proxy-aware baseFetch plus the optional auth-challenge intercept).
|
||||
const sseFetch =
|
||||
(sseConfig.eventSourceInit?.fetch as typeof fetch) ||
|
||||
fetchWithOptionalAuthIntercept;
|
||||
// The challenge observer is applied either way: it is passive, and without
|
||||
// it a first-time legacy SSE authorization through an explicit fetch would
|
||||
// lose the challenge's `resource_metadata` the same way (Copilot).
|
||||
const configuredSseFetch = sseConfig.eventSourceInit?.fetch as
|
||||
| typeof fetch
|
||||
| undefined;
|
||||
const sseFetch = configuredSseFetch
|
||||
? withChallengeObserver(configuredSseFetch)
|
||||
: fetchWithOptionalAuthIntercept;
|
||||
const trackedFetch = onFetchRequest
|
||||
? createFetchTracker(sseFetch, {
|
||||
trackRequest: onFetchRequest,
|
||||
|
|
|
|||
|
|
@ -39,6 +39,7 @@ import type {
|
|||
HandleAuthChallengeOptions,
|
||||
} from "../auth/challenge.js";
|
||||
import {
|
||||
challengeResourceMetadataUrl,
|
||||
parseScopeString,
|
||||
unionAuthorizationScopes,
|
||||
} from "../auth/challenge.js";
|
||||
|
|
@ -81,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) {
|
||||
|
|
@ -271,6 +278,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();
|
||||
|
|
@ -281,16 +302,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") {
|
||||
|
|
@ -742,10 +770,17 @@ export class OAuthManager {
|
|||
|
||||
provider.clearCapturedAuthUrl();
|
||||
|
||||
// RFC 9728: honor the metadata document the challenge advertised rather
|
||||
// than probing the default locations derived from the MCP server URL
|
||||
// (#2071). The SDK persists it in its discovery state, so the callback leg
|
||||
// recovers it without our passing it again.
|
||||
const resourceMetadataUrl = challengeResourceMetadataUrl(enriched);
|
||||
|
||||
await ensureCimdClientRegistration({
|
||||
serverUrl,
|
||||
provider,
|
||||
fetchFn: this.params.effectiveAuthFetch,
|
||||
resourceMetadataUrl,
|
||||
});
|
||||
|
||||
const scopeForAuth =
|
||||
|
|
@ -763,6 +798,7 @@ export class OAuthManager {
|
|||
serverUrl,
|
||||
scope: scopeForAuth,
|
||||
fetchFn: this.params.effectiveAuthFetch,
|
||||
resourceMetadataUrl,
|
||||
...(enriched.reason === "insufficient_scope" && {
|
||||
forceReauthorization: isStrictScopeSuperset(
|
||||
scopeForAuth,
|
||||
|
|
@ -892,6 +928,7 @@ export class OAuthManager {
|
|||
serverUrl,
|
||||
scope: scopeForAuth,
|
||||
fetchFn: this.params.effectiveAuthFetch,
|
||||
resourceMetadataUrl: challengeResourceMetadataUrl(enriched),
|
||||
forceReauthorization: true,
|
||||
});
|
||||
} finally {
|
||||
|
|
|
|||
|
|
@ -38,6 +38,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 {
|
||||
|
|
@ -940,6 +941,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 {
|
||||
|
|
|
|||
Loading…
Reference in a new issue