feat(auth): per-server authorization and token URL overrides

Adds two optional per-server fields — `oauth.authorizationUrl` and
`oauth.tokenUrl` — that override the endpoints authorization-server
metadata discovery resolved, so a server can be pointed at a development
or staging authorization server without changing what it advertises.

SDK v2 routes both endpoints through the discovered metadata document and
neither through OAuthClientProvider, so the override is applied by
wrapping the client's base fetch and patching the metadata document in
flight (core/auth/endpointOverrides.ts). Wrapping the base fetch — rather
than only the auth fetch — also covers the discovery the SDK runs from
inside the transport on the 401/refresh path.

The fields round-trip through mcp.json, the /api/servers routes, the
runner (CLI/TUI), and the Server Settings → Authorization panel, where a
value that is not an absolute http(s) URL is flagged inline; at runtime
such a value is dropped with a single warning rather than failing the
connection.

Signed-off-by: cliffhall <cliff@futurescale.com>
This commit is contained in:
cliffhall 2026-08-16 20:23:25 -04:00
commit 364d8c9dbc
5 changed files with 1212 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,21 @@ 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)
*/
getEndpointOverrides(): OAuthEndpointOverrides | 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

@ -1131,11 +1131,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 (
@ -1255,7 +1266,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;
}
@ -1522,6 +1533,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` };
@ -1660,6 +1675,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,29 @@ export function oauthAuthorizationParamsFromSettings(
return Object.keys(record).length > 0 ? record : 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 +380,11 @@ 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. (#1906)
if (stored.oauth?.authorizationUrl)
settings.oauthAuthorizationUrl = stored.oauth.authorizationUrl;
if (stored.oauth?.tokenUrl) settings.oauthTokenUrl = stored.oauth.tokenUrl;
if (stored.oauth?.onInsufficientScope) {
settings.oauthOnInsufficientScope = stored.oauth.onInsufficientScope;
}
@ -474,6 +502,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;
};
/**