Merge branch 'v2/main' into v2/fix/2117-persist-requested-scope

#2114 landed on v2/main, which is the PR this one follows up. Two
conflicts, one of them silent.

The marked conflict was cosmetic: keep v2/main's comment on the
`provider.scope` read, which now notes the getter filters (#2068).

The real one git merged cleanly and would have left dead: #2114's

  if (this.oauthConfig.requestRefreshToken === false && requestedScope) {
    this.pendingAuthorizationScope = requestedScope;
  }

sat directly above this branch's unconditional
`recordAuthorizationRequestScope`, which overwrites the same field a few
lines later. Dropping that gate is what #2117 is for, so it is removed
rather than kept as a no-op.

The two changes compose: the SDK adds `offline_access` only when
`clientMetadata.grant_types` includes `refresh_token`, which the provider
drops while the opt-out is on -- so reading the authorize URL yields the
filtered scope there, exactly what #2114's gated branch persisted.

#2114's third case pinned the gate and inverts here, as its issue
predicted. It now asserts the request is persisted even with the grant
on; that records no new claim, since the request equals what storage
already held.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013hzwzS8UvBsr4yZm7o7sev
Signed-off-by: cliffhall <cliff@futurescale.com>
This commit is contained in:
cliffhall 2026-08-25 08:51:45 -04:00
commit 71b960e078
2 changed files with 1424 additions and 8 deletions

View file

@ -12,6 +12,7 @@ import type {
import type { OAuthStorage, SaveClientInformationOptions } from "./storage.js";
import { generateOAuthState } from "./utils.js";
import { applyAuthorizationParams } from "./authorizationParams.js";
import { scopeForDeclinedRefreshGrant } from "./scopes.js";
/**
* Redirect URL provider. Returns the redirect URL for OAuth flows.
@ -105,6 +106,36 @@ export type OAuthProviderConfig = {
* unaffected.
*/
authorizationParams?: Record<string, string>;
/**
* Whether to declare the `refresh_token` grant in the registered client
* metadata (#2068). Defaults to `true`; set `false` to register (and
* authorize) as an authorization-code-only client.
*
* This is not merely cosmetic. The SDK's `determineScope()` appends
* `offline_access` to the effective scope whenever the authorization server
* advertises it **and** the client metadata declares `refresh_token`, and
* `startAuthorization()` then appends `prompt=consent` whenever
* `offline_access` is in scope. Against Microsoft Entra ID, that forced
* consent prompt routes a non-admin user into the admin-consent workflow and
* fails with `AADSTS90094` even after a tenant admin has consented.
*
* It removes that *automatic* augmentation only. `startAuthorization()` reads
* the scope, never `grant_types`, so an `offline_access` the caller passed in
* `scope` — or one the resource advertises when `scope` is unset, which
* `determineScope` falls back to — still produces the prompt. This is not on
* its own a guarantee that `prompt=consent` is absent.
*/
requestRefreshToken?: boolean;
/**
* The scope configured for this server (mcp.json / Server Settings), as
* distinct from the scope carried in OAuth storage after a previous grant.
*
* Used only to decide whether an `offline_access` in the effective scope was
* *asked for* or merely inherited — see {@link scopeForDeclinedRefreshGrant}.
* An explicitly configured one is honored; an inherited one is dropped from
* the request when {@link requestRefreshToken} is false. (#2068)
*/
configuredScope?: string;
};
/**
@ -126,6 +157,10 @@ export class BaseOAuthClientProvider implements OAuthClientProvider {
public clientMetadataUrl?: string;
/** Custom authorization-request parameters (#2018). Authorize URL only. */
protected authorizationParams?: Record<string, string>;
/** Declare the `refresh_token` grant in {@link clientMetadata} (#2068). */
protected requestRefreshToken: boolean;
/** Server-configured scope, for the #2068 `offline_access` distinction. */
protected configuredScope?: string;
constructor(serverUrl: string, oauthConfig: OAuthProviderConfig) {
this.serverUrl = serverUrl;
@ -134,6 +169,8 @@ export class BaseOAuthClientProvider implements OAuthClientProvider {
this.navigation = oauthConfig.navigation;
this.clientMetadataUrl = oauthConfig.clientMetadataUrl;
this.authorizationParams = oauthConfig.authorizationParams;
this.requestRefreshToken = oauthConfig.requestRefreshToken ?? true;
this.configuredScope = oauthConfig.configuredScope;
}
/**
@ -171,6 +208,21 @@ export class BaseOAuthClientProvider implements OAuthClientProvider {
}
get scope(): string | undefined {
// #2068: filter at the point of *request*, not in storage. Covers the two
// readers that go through this getter — `clientMetadata.scope` below, and
// the `scope:` argument `OAuthManager.authenticate` hands to the SDK.
//
// It is NOT the only request path: mid-session `insufficient_scope` step-up
// builds its union from raw storage and passes it to `mcpAuth` directly,
// never reading this getter. `OAuthManager.handleAuthChallenge` applies the
// same filter there. Both call `scopeForDeclinedRefreshGrant`, so the rule
// lives in one place even though it has to be applied twice.
if (!this.requestRefreshToken) {
return scopeForDeclinedRefreshGrant(
this.cachedScope,
this.configuredScope,
);
}
return this.cachedScope;
}
@ -186,7 +238,14 @@ export class BaseOAuthClientProvider implements OAuthClientProvider {
const metadata: OAuthClientMetadata = {
redirect_uris: this.redirect_uris,
token_endpoint_auth_method: "none",
grant_types: ["authorization_code", "refresh_token"],
// #2068: `refresh_token` is declared by default, and dropped when the
// server's "Request refresh token" setting is off — which stops the SDK
// *adding* `offline_access` (and so the `prompt=consent` it forces). It
// does not remove an `offline_access` that reaches the scope another way.
// See `OAuthProviderConfig.requestRefreshToken`.
grant_types: this.requestRefreshToken
? ["authorization_code", "refresh_token"]
: ["authorization_code"],
response_types: ["code"],
client_name: "MCP Inspector",
client_uri: "https://github.com/modelcontextprotocol/inspector",

View file

@ -18,6 +18,96 @@ export const computeScopeUnion = sdkComputeScopeUnion;
*/
export const isStrictScopeSuperset = sdkIsStrictScopeSuperset;
/** The scope token whose presence makes the SDK force `prompt=consent`. */
export const OFFLINE_ACCESS_SCOPE = "offline_access";
/**
* The scope to *request* when the `refresh_token` grant has been declined
* (#2068).
*
* Declining the grant stops the SDK adding `offline_access`, but it cannot
* remove one that is already in the scope — and the Inspector's own storage is
* the way it gets there. A successful authorization persists the scope the AS
* granted (`resolvePersistedScopeAfterGrant`), which for a default-on client
* includes `offline_access`; the provider reloads that on the next connect and
* hands it back to the SDK, which sees the token in scope and appends
* `prompt=consent` again. So a server that authorized once before the opt-out
* would keep hitting AADSTS90094 with the box unchecked — the exact failure the
* setting exists to prevent.
*
* Dropping it from the *request* (not from storage) is what breaks that loop:
* the next grant comes back without `offline_access` and the persisted scope
* self-heals, while re-checking the box restores the old behavior from the
* user's own configuration rather than from a value we destroyed.
*
* An `offline_access` the user explicitly configured is honored — they asked
* for it, and the web form warns that it keeps the consent prompt alive. That
* means *ensuring* it is requested, not merely declining to strip it: the
* persisted scope can predate the configuration change (`createOAuthProvider`
* preserves a stored scope rather than reseeding from current settings), and
* silently omitting a scope the user typed would make both this promise and the
* form's warning false.
*
* Only `offline_access` is added, never the rest of the configured scope.
* Widening the request with unrelated configured tokens is outside what this
* setting governs and could push an otherwise-satisfied connection into a
* step-up authorization.
*
* `requiredScopes` carries scopes a `403 insufficient_scope` challenge demands
* (SEP-2350). They are honored exactly like a configured one: the server is
* asking for them, and stripping a scope the challenge requires would loop —
* re-authorize, get the same challenge back, strip it again. The step-up path
* needs this because it does not read the scope through the provider at all:
* it unions the *raw* stored scope with the challenge and hands that to the
* SDK directly, so an inherited `offline_access` would otherwise reappear
* there with the setting off.
*/
export function scopeForDeclinedRefreshGrant(
effectiveScope: string | undefined,
configuredScope: string | undefined,
requiredScopes?: readonly string[],
): string | undefined {
if (!effectiveScope) return effectiveScope;
const configured = configuredScope?.trim().split(/\s+/) ?? [];
const required = requiredScopes ?? [];
if (
configured.includes(OFFLINE_ACCESS_SCOPE) ||
required.includes(OFFLINE_ACCESS_SCOPE)
) {
const tokens = effectiveScope
.trim()
.split(/\s+/)
.filter((token) => token !== "");
if (!tokens.includes(OFFLINE_ACCESS_SCOPE)) {
tokens.push(OFFLINE_ACCESS_SCOPE);
}
return tokens.join(" ");
}
const kept = effectiveScope
.trim()
.split(/\s+/)
.filter((token) => token !== "" && token !== OFFLINE_ACCESS_SCOPE);
if (kept.length > 0) return kept.join(" ");
// Filtering removed everything (the persisted scope was `offline_access`
// alone). Returning `undefined` here would be read as "nothing stored" by
// `OAuthManager.createOAuthProvider`, whose seeding branch then *writes* the
// configured scope to storage — turning this request-only filter into a
// silent overwrite of the persisted value, so re-enabling the grant could no
// longer restore it. Hand back the configured scope instead: it is the right
// thing to request, and it leaves storage alone.
// Cannot itself contain `offline_access`: that case returned above.
const fallback = configuredScope?.trim();
if (fallback) return fallback;
// Nothing configured either, so there is no value to preserve and the seeding
// branch is inert (it requires a configured scope). `undefined` rather than
// `""` so the SDK falls back to its own resolution instead of requesting an
// empty scope.
return undefined;
}
/**
* Scope to persist after a successful token grant (RFC 6749 §5.1).
* When the AS returns `scope`, it is the authoritative full grant.