Merge branch 'v2/main' into v2/fix/2096-stdio-env-cwd-revert

This commit is contained in:
Cliff Hall 2026-08-25 08:55:35 -04:00 • committed by GitHub
commit 6af849e217
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 1418 additions and 9 deletions

View file

@ -6340,6 +6340,8 @@ export class InspectorClient extends InspectorClientEventTarget {
/** Endpoint overrides applied to the discovered AS metadata (#1906). */
authorizationUrl?: string;
tokenUrl?: string;
/** Declare the `refresh_token` grant in client metadata (#2068). */
requestRefreshToken?: boolean;
}): void {
if (!this.oauthManager) {
throw new Error(

View file

@ -48,6 +48,7 @@ import {
isStrictScopeSuperset,
resolveEffectiveGrantedScope,
resolvePersistedScopeAfterGrant,
scopeForDeclinedRefreshGrant,
} from "../auth/scopes.js";
import { stepUpInsufficientScopeMessage } from "../auth/oauthUx.js";
import type {
@ -105,6 +106,8 @@ export class OAuthManager {
authorizationParams?: Record<string, string>;
authorizationUrl?: string;
tokenUrl?: string;
/** Declare the `refresh_token` grant in client metadata (#2068). */
requestRefreshToken?: boolean;
}): void {
this.oauthConfig = {
...this.oauthConfig,
@ -176,6 +179,13 @@ export class OAuthManager {
// read from mcp.json, and nothing in the flow needs to persist or union
// them.
authorizationParams: this.oauthConfig.authorizationParams,
// #2068: per-server opt-out of the `refresh_token` grant. Like
// `authorizationParams` this is pure config off mcp.json, so it lives on
// the provider rather than in OAuth storage.
requestRefreshToken: this.oauthConfig.requestRefreshToken,
// #2068: lets the provider tell an `offline_access` the user asked for
// from one inherited from a previous grant's persisted scope.
configuredScope: this.oauthConfig.scope,
});
provider.setEventTarget(this.params.getEventTarget());
@ -305,9 +315,12 @@ export class OAuthManager {
resourceMetadataUrl,
});
// Read once: the getter filters (#2068), so this is what was actually
// requested and what the callback leg has to persist.
const requestedScope = provider.scope;
const result = await mcpAuth(provider, {
serverUrl,
scope: provider.scope,
scope: requestedScope,
fetchFn: this.params.effectiveAuthFetch,
resourceMetadataUrl,
});
@ -318,6 +331,21 @@ export class OAuthManager {
);
}
// #2068 — record the filtered request so `completeOAuthFlow` can persist
// it. `resolvePersistedScopeAfterGrant` falls back to the requested scope
// when the token response omits `scope` (RFC 6749 §5.1: omitted means the
// grant matched the request), and without this that fallback is `undefined`
// on the ordinary leg — only step-up ever set it. The old
// `offline_access` would then stay in storage, so the scope never
// self-heals and later satisfaction checks still read it as granted.
//
// Only when the filter is in effect. With the grant declared there is
// nothing to heal, and persisting a request the AS did not confirm would be
// a behavior change beyond this setting.
if (this.oauthConfig.requestRefreshToken === false && requestedScope) {
this.pendingAuthorizationScope = requestedScope;
}
const capturedUrl = provider.getCapturedAuthUrl();
if (!capturedUrl) {
throw new Error("Failed to capture authorization URL");
@ -774,13 +802,37 @@ export class OAuthManager {
resourceMetadataUrl,
});
const scopeForAuth =
// #2068 — the step-up union is built from *raw* storage
// (`enrichChallengeWithScopes`) and handed to the SDK directly, so it never
// passes through the provider's filtering `scope` getter. Without this an
// `offline_access` inherited from an earlier grant reappears here and the
// SDK re-adds `prompt=consent` even with the setting off — the same failure
// the option exists to prevent, on the one path that bypasses it. A scope
// the challenge itself requires is preserved, since stripping that would
// just re-earn the same challenge.
const requestedScopeForAuth =
enriched.reason === "insufficient_scope"
? enriched.authorizationScopes?.join(" ")
: this.oauthConfig.scope?.trim() ||
(enriched.requiredScopes?.length
? enriched.requiredScopes.join(" ")
: undefined);
const scopeForAuth =
this.oauthConfig.requestRefreshToken === false
? scopeForDeclinedRefreshGrant(
requestedScopeForAuth,
this.oauthConfig.scope,
// The RAW challenge scopes, not `enriched.requiredScopes`:
// `enrichChallengeWithScopes` narrows that to the *missing* subset,
// so a challenge requiring `offline_access tools:write` against a
// stored scope that already has `offline_access` arrives here as
// just `["tools:write"]`. Passing the narrowed list would read the
// server's explicit requirement as inherited and strip it — then
// re-authorization earns the same challenge forever, which is the
// loop this argument exists to prevent.
challenge.requiredScopes,
)
: requestedScopeForAuth;
provider.setSuppressAuthorizationNavigation(true);
let result: Awaited<ReturnType<typeof mcpAuth>>;

View file

@ -1391,6 +1391,7 @@ export function createRemoteApp(
authorizationUrl?: string;
tokenUrl?: string;
enterpriseManaged?: boolean;
requestRefreshToken?: boolean;
} => {
if (v === null || typeof v !== "object" || Array.isArray(v)) return false;
const o = v as Record<string, unknown>;
@ -1418,6 +1419,14 @@ export function createRemoteApp(
) {
return false;
}
// #2068 — shape only; the read side keeps just an explicit `false`, so a
// stray `true` from a hand-edited file reads back as the default anyway.
if (
o.requestRefreshToken !== undefined &&
typeof o.requestRefreshToken !== "boolean"
) {
return false;
}
return true;
};
// A roots array: each entry must have a string `uri` and, when present, a
@ -1531,7 +1540,7 @@ export function createRemoteApp(
if ("oauth" in valObj && !isOauthObject(valObj.oauth)) {
logWarn(
{ route: "/api/servers", id, droppedKey: "oauth" },
"Dropping malformed `oauth` field — expected `{ clientId?, clientSecret?, scopes?, authorizationParams?, authorizationUrl?, tokenUrl?, enterpriseManaged?, onInsufficientScope? }`.",
"Dropping malformed `oauth` field — expected `{ clientId?, clientSecret?, scopes?, authorizationParams?, authorizationUrl?, tokenUrl?, enterpriseManaged?, onInsufficientScope?, requestRefreshToken? }`.",
);
delete valObj.oauth;
}
@ -1834,6 +1843,15 @@ export function createRemoteApp(
error: "settings.enterpriseManaged must be a boolean",
};
}
if (
obj.oauthRequestRefreshToken !== undefined &&
typeof obj.oauthRequestRefreshToken !== "boolean"
) {
return {
ok: false,
error: "settings.oauthRequestRefreshToken must be a boolean",
};
}
if (
obj.oauthOnInsufficientScope !== undefined &&
obj.oauthOnInsufficientScope !== "reauthorize" &&
@ -1961,6 +1979,11 @@ export function createRemoteApp(
if (obj.enterpriseManaged === true) {
value.enterpriseManaged = true;
}
// #2068: the default is on, so only the explicit opt-out is carried; a
// `true` on the wire reads back as unset, which means the same thing.
if (obj.oauthRequestRefreshToken === false) {
value.oauthRequestRefreshToken = false;
}
if (
obj.oauthOnInsufficientScope === "reauthorize" ||
obj.oauthOnInsufficientScope === "throw"

View file

@ -611,6 +611,12 @@ export function storedFieldsToInspectorSettings(
if (stored.oauth?.enterpriseManaged === true) {
settings.enterpriseManaged = true;
}
// Inverted from the flags above: the default is *on*, so only an explicit
// `false` is carried through. Anything else on disk (absent, or a
// hand-edited non-boolean) reads back as unset, i.e. the default. (#2068)
if (stored.oauth?.requestRefreshToken === false) {
settings.oauthRequestRefreshToken = false;
}
// Mirror the stdio working directory for the form. Like the OAuth fields, an
// empty string coerces to absent so the form's "(inherit)" placeholder shows.
if (stored.cwd) settings.cwd = stored.cwd;
@ -738,6 +744,12 @@ export function inspectorSettingsToStoredFields(
if (settings.enterpriseManaged === true) {
oauthFields.enterpriseManaged = true;
}
// Only the non-default (off) is written; an absent field reads back as the
// default (on), so writing `true` would inject the key into files that never
// set it and break byte-stable round-trips. (#2068)
if (settings.oauthRequestRefreshToken === false) {
oauthFields.requestRefreshToken = false;
}
if (Object.keys(oauthFields).length > 0) {
out.oauth = oauthFields;
}

View file

@ -181,6 +181,18 @@ export type StoredMCPServer = MCPServerConfig & {
enterpriseManaged?: boolean;
/** SEP-2350 step-up policy for `403 insufficient_scope` (default `reauthorize`). */
onInsufficientScope?: OnInsufficientScopePolicy;
/**
* Whether the Inspector declares the `refresh_token` grant when it
* registers. Defaults to `true`; only `false` is written to disk, so an
* entry that never touched the setting keeps a minimal diff. Turning it off
* stops the SDK's *automatic* `offline_access` augmentation, and so the
* `prompt=consent` that scope forces — but only that one: an
* `offline_access` from `scopes` here, or from the resource's advertised
* scopes when `scopes` is unset, still reaches the authorization request.
* See {@link InspectorServerSettings.oauthRequestRefreshToken}.
* Inspector-specific. (#2068)
*/
requestRefreshToken?: boolean;
/**
* Custom query parameters appended to the OAuth **authorization request**
* (never the token request) — e.g. Keycloak's `kc_idp_hint`, OIDC's
@ -589,6 +601,11 @@ export interface OAuthSettings {
tokenUrl?: string;
enterpriseManaged?: boolean;
onInsufficientScope?: OnInsufficientScopePolicy;
/**
* Whether to declare the `refresh_token` grant (#2068). Optional for the same
* reason as `authorizationParams`; `undefined` means the default, on.
*/
requestRefreshToken?: boolean;
}
/**
@ -774,6 +791,26 @@ export interface InspectorServerSettings {
* server's HTTP transport. Defaults to `reauthorize` when unset.
*/
oauthOnInsufficientScope?: OnInsufficientScopePolicy;
/**
* Whether the Inspector declares the `refresh_token` grant in its OAuth
* client metadata for this server. `undefined` (the default) means on;
* persisted as `oauth.requestRefreshToken` only when explicitly off. Turning
* it off drops the SDK's *automatic* `offline_access` scope and so the
* `prompt=consent` it forces — the Entra admin-consent failure in #2068.
*
* Only the automatic one: `startAuthorization` adds `prompt=consent` for any
* `offline_access` in the effective scope without consulting `grant_types`,
* so a scope the user configured (or one the resource advertises when the
* scope field is blank) still forces the prompt.
*
* Read at connect time like the rest of the OAuth block, so a live client
* keeps the metadata it was built with. It changes what the Inspector
* declares, not state the authorization server already holds: an existing
* registration still lists the grant, and a refresh token issued earlier
* stays usable (the SDK's refresh path never consults `grant_types`) until
* the stored OAuth state is cleared.
*/
oauthRequestRefreshToken?: boolean;
/**
* When true, connect via the configured enterprise IdP (EMA) instead of
* interactive OAuth to the MCP authorization server. Per-server OAuth
@ -1170,6 +1207,11 @@ export interface InspectorClientOptions {
*/
authorizationUrl?: string;
tokenUrl?: string;
/**
* Declare the `refresh_token` grant in the registered client metadata
* (#2068). Defaults to `true` when omitted.
*/
requestRefreshToken?: boolean;
};
/**