Merge pull request #2037 from modelcontextprotocol/v2/feat/1906-auth-token-url-overrides

feat(auth): Auth URL and Token URL overrides in Server Settings
This commit is contained in:
Cliff Hall 2026-08-16 22:49:19 -04:00 • committed by GitHub
commit 4e4aa49db1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 1558 additions and 6 deletions

View file

@ -218,6 +218,7 @@ import {
type AuthChallengeOutcome,
type HandleAuthChallengeOptions,
} from "../auth/challenge.js";
import { withOAuthEndpointOverrides } from "../auth/endpointOverrides.js";
import type { OAuthTokens } from "@modelcontextprotocol/client";
import { silentLogger, type InspectorLogger } from "../logging/logger.js";
import { createFetchTracker } from "./fetchTracking.js";
@ -650,6 +651,16 @@ export class InspectorClient extends InspectorClientEventTarget {
};
// Effective auth fetch: base fetch + tracking with category 'auth'
// #1906: apply the per-server authorization/token endpoint overrides to the
// authorization-server metadata document in flight. This wraps the *base*
// fetch rather than `effectiveAuthFetch` so it also covers the discovery the
// SDK runs from inside the transport (the 401/refresh path), which is handed
// `this.fetchFn` directly. The overrides are read lazily — `oauthManager` is
// created a few lines below, and `setOAuthConfig` can change them later — so
// the wrapper is inert until a server actually configures one.
this.fetchFn = withOAuthEndpointOverrides(this.fetchFn ?? fetch, () =>
this.oauthManager?.getEndpointOverrides(),
);
this.effectiveAuthFetch = this.buildEffectiveAuthFetch();
this.sessionId = options.sessionId;
@ -5988,6 +5999,9 @@ export class InspectorClient extends InspectorClientEventTarget {
scope?: string;
/** Custom authorization-request parameters (#2018). Authorize URL only. */
authorizationParams?: Record<string, string>;
/** Endpoint overrides applied to the discovered AS metadata (#1906). */
authorizationUrl?: string;
tokenUrl?: string;
}): void {
if (!this.oauthManager) {
throw new Error(

View file

@ -5,6 +5,7 @@
*/
import { BaseOAuthClientProvider } from "../auth/providers.js";
import type { OAuthEndpointOverrides } from "../auth/endpointOverrides.js";
import type { OAuthFlowState, OAuthStep } from "../auth/types.js";
import { EMPTY_OAUTH_FLOW_STATE } from "../auth/types.js";
import type { OAuthTokens } from "@modelcontextprotocol/client";
@ -95,6 +96,8 @@ export class OAuthManager {
scope?: string;
enterpriseManaged?: boolean;
authorizationParams?: Record<string, string>;
authorizationUrl?: string;
tokenUrl?: string;
}): void {
this.oauthConfig = {
...this.oauthConfig,
@ -106,6 +109,31 @@ export class OAuthManager {
return this.params.getServerUrl();
}
/**
* The per-server authorization/token endpoint overrides, read live so a
* `setOAuthConfig` between requests takes effect without rebuilding the
* client's fetch. `InspectorClient` reads this from inside its fetch wrapper.
* (#1906)
*
* Returns nothing under enterprise-managed authorization, for the same reason
* `redirectToExternalAuthorization` skips the custom authorization parameters
* (#2018): the EMA leg authorizes against the enterprise IdP, a *different*
* authorization server, and its OIDC discovery runs through this same fetch
* (`emaFlow` → `idpOidc`). Rewriting that document would point the IdP login —
* or the IdP code/refresh-token exchange — at the resource server's
* authorization server, which is both wrong and a way to leak an IdP
* credential across an authorization-server boundary.
*/
getEndpointOverrides(): OAuthEndpointOverrides | undefined {
if (this.isEnterpriseManaged()) return undefined;
const { authorizationUrl, tokenUrl } = this.oauthConfig;
if (!authorizationUrl && !tokenUrl) return undefined;
return {
...(authorizationUrl && { authorizationUrl }),
...(tokenUrl && { tokenUrl }),
};
}
/**
* Return the configured OAuth storage, throwing a clear error if it is
* absent. Used on the EMA re-mint/persist paths, which are only reached for

View file

@ -1147,11 +1147,22 @@ export function createRemoteApp(
clientSecret?: string;
scopes?: string;
authorizationParams?: Record<string, string>;
authorizationUrl?: string;
tokenUrl?: string;
enterpriseManaged?: boolean;
} => {
if (v === null || typeof v !== "object" || Array.isArray(v)) return false;
const o = v as Record<string, unknown>;
for (const k of ["clientId", "clientSecret", "scopes"] as const) {
for (const k of [
"clientId",
"clientSecret",
"scopes",
// #1906 — shape only; the values are validated as absolute http(s) URLs
// where they're applied (core/auth/endpointOverrides.ts), so a typo drops
// one field with a warning instead of dropping the whole `oauth` block.
"authorizationUrl",
"tokenUrl",
] as const) {
if (o[k] !== undefined && typeof o[k] !== "string") return false;
}
if (
@ -1271,7 +1282,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?, enterpriseManaged?, onInsufficientScope? }`.",
"Dropping malformed `oauth` field — expected `{ clientId?, clientSecret?, scopes?, authorizationParams?, authorizationUrl?, tokenUrl?, enterpriseManaged?, onInsufficientScope? }`.",
);
delete valObj.oauth;
}
@ -1538,6 +1549,10 @@ export function createRemoteApp(
"oauthClientId",
"oauthClientSecret",
"oauthScopes",
// #1906 — string-shaped on the wire; URL validity is checked where the
// override is applied, not here.
"oauthAuthorizationUrl",
"oauthTokenUrl",
] as const) {
if (obj[optional] !== undefined && typeof obj[optional] !== "string") {
return { ok: false, error: `settings.${optional} must be a string` };
@ -1676,6 +1691,18 @@ export function createRemoteApp(
if (isKvArray(obj.oauthAuthorizationParams)) {
value.oauthAuthorizationParams = obj.oauthAuthorizationParams;
}
// Empty-string coerces to absent like the credential fields above: the form
// emits `""` when a user clears the input, and an empty override on disk
// would read back as a configured-but-blank endpoint. (#1906)
if (
typeof obj.oauthAuthorizationUrl === "string" &&
obj.oauthAuthorizationUrl !== ""
) {
value.oauthAuthorizationUrl = obj.oauthAuthorizationUrl;
}
if (typeof obj.oauthTokenUrl === "string" && obj.oauthTokenUrl !== "") {
value.oauthTokenUrl = obj.oauthTokenUrl;
}
if (obj.enterpriseManaged === true) {
value.enterpriseManaged = true;
}

View file

@ -248,6 +248,56 @@ export function oauthAuthorizationParamsFromSettings(
return Object.keys(record).length > 0 ? record : undefined;
}
/**
* Validate one of a server's `oauth` endpoint overrides as it comes off disk,
* or `undefined` when there is nothing usable. (#1906)
*
* Same reasoning as `cleanAuthorizationParams` above: `StoredMCPServer` types
* these as `string`, but that is a compile-time promise a hand-edited file does
* not keep, and the CLI/TUI read `mcp.json` directly. A non-string reaches
* `oauthEndpointOverridesFromSettings`, which calls `.trim()` on it — so without
* this the whole server list fails to load. URL *validity* is not checked here;
* that happens where the override is applied, so a typo drops one field with a
* warning instead of anything louder.
*/
export function cleanEndpointOverride(
value: string | undefined,
field: "authorizationUrl" | "tokenUrl",
): string | undefined {
if (value === undefined) return undefined;
// Keep: unreachable per the parameter type, reachable from disk.
if (typeof value !== "string") {
console.warn(
`Ignoring \`oauth.${field}\`: expected a string, got ${typeof value}.`,
);
return undefined;
}
return value.trim() || undefined;
}
/**
* Collapse a server's endpoint-override fields into the
* `InspectorClientOptions.oauth` sub-shape, or `undefined` when neither is set.
* Shared by the web (`App.tsx`) and Node (`buildRunnerClientAuthOptions`) paths
* so both derive the option identically. The values are passed through as
* typed; validation (absolute http(s) URL) happens once at the point of use, in
* `core/auth/endpointOverrides.ts`. (#1906)
*/
export function oauthEndpointOverridesFromSettings(
settings: Pick<
InspectorServerSettings,
"oauthAuthorizationUrl" | "oauthTokenUrl"
>,
): { authorizationUrl?: string; tokenUrl?: string } | undefined {
const authorizationUrl = settings.oauthAuthorizationUrl?.trim();
const tokenUrl = settings.oauthTokenUrl?.trim();
if (!authorizationUrl && !tokenUrl) return undefined;
return {
...(authorizationUrl && { authorizationUrl }),
...(tokenUrl && { tokenUrl }),
};
}
/**
* Lift the Inspector-extension fields off a freshly-read `StoredMCPServer`
* into the pair-array / flat-OAuth `InspectorServerSettings` shape the form
@ -357,6 +407,21 @@ export function storedFieldsToInspectorSettings(
if (authorizationParams) {
settings.oauthAuthorizationParams = envRecordToPairs(authorizationParams);
}
// Truthiness drops empty strings like the credential fields above, so a
// cleared field reads back as unset rather than as an empty override. The
// `string` type is a compile-time promise a hand-edited `mcp.json` does not
// keep, and only the web client's `/api/servers` route checks it — the CLI and
// TUI read the file directly and then call `.trim()` on these in
// `oauthEndpointOverridesFromSettings`, so a non-string would crash the server
// load. Sanitized like `authorizationParams` above rather than trusted.
// (#1906)
const authorizationUrl = cleanEndpointOverride(
stored.oauth?.authorizationUrl,
"authorizationUrl",
);
if (authorizationUrl) settings.oauthAuthorizationUrl = authorizationUrl;
const tokenUrl = cleanEndpointOverride(stored.oauth?.tokenUrl, "tokenUrl");
if (tokenUrl) settings.oauthTokenUrl = tokenUrl;
if (stored.oauth?.onInsufficientScope) {
settings.oauthOnInsufficientScope = stored.oauth.onInsufficientScope;
}
@ -474,6 +539,14 @@ export function inspectorSettingsToStoredFields(
oauthFields.authorizationParams = authParams;
}
}
// Empty/blank reads back as unset (above), so a cleared field writes nothing
// and the file diff stays minimal for servers that never set one. (#1906)
if (settings.oauthAuthorizationUrl?.trim()) {
oauthFields.authorizationUrl = settings.oauthAuthorizationUrl.trim();
}
if (settings.oauthTokenUrl?.trim()) {
oauthFields.tokenUrl = settings.oauthTokenUrl.trim();
}
if (settings.oauthOnInsufficientScope) {
oauthFields.onInsufficientScope = settings.oauthOnInsufficientScope;
}

View file

@ -176,6 +176,19 @@ export type StoredMCPServer = MCPServerConfig & {
* (#2018)
*/
authorizationParams?: Record<string, string>;
/**
* Overrides the `authorization_endpoint` the authorization server's
* metadata document advertises — for pointing a server at a development or
* staging authorization server without changing what it publishes.
* Inspector-specific. (#1906)
*/
authorizationUrl?: string;
/**
* Overrides the `token_endpoint` the authorization server's metadata
* document advertises. Independent of {@link authorizationUrl}.
* Inspector-specific. (#1906)
*/
tokenUrl?: string;
};
/**
* Filesystem/URI roots advertised to the server via the `roots` client
@ -534,6 +547,13 @@ export interface OAuthSettings {
* (#2018)
*/
authorizationParams?: { key: string; value: string }[];
/**
* Endpoint overrides applied to the discovered authorization-server metadata
* (#1906). Optional for the same reason as `authorizationParams` — callers
* building an `OAuthSettings` for the other fields need not supply them.
*/
authorizationUrl?: string;
tokenUrl?: string;
enterpriseManaged?: boolean;
onInsufficientScope?: OnInsufficientScopePolicy;
}
@ -699,6 +719,18 @@ export interface InspectorServerSettings {
* the way there. Applied to the authorization request only. (#2018)
*/
oauthAuthorizationParams?: { key: string; value: string }[];
/**
* Overrides the authorization endpoint discovery resolved, so a server can be
* pointed at a development or staging authorization server without changing
* what it advertises. Empty/absent means "use what discovery returned".
* Persisted as `oauth.authorizationUrl`. (#1906)
*/
oauthAuthorizationUrl?: string;
/**
* Overrides the token endpoint discovery resolved. Independent of
* {@link oauthAuthorizationUrl}; persisted as `oauth.tokenUrl`. (#1906)
*/
oauthTokenUrl?: string;
/**
* SEP-2350 step-up policy for a `403 insufficient_scope` challenge on this
* server's HTTP transport. Defaults to `reauthorize` when unset.
@ -1064,6 +1096,13 @@ export interface InspectorClientOptions {
* (#2018). Reserved, protocol-critical keys are dropped with a warning.
*/
authorizationParams?: Record<string, string>;
/**
* Overrides for the endpoints the authorization server's metadata document
* advertises (#1906). Applied to the discovered document, so they reach both
* the authorization request and the token request.
*/
authorizationUrl?: string;
tokenUrl?: string;
};
/**