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
8 changed files with 1212 additions and 6 deletions

View file

@ -0,0 +1,246 @@
/**
* Per-server authorization/token endpoint overrides (#1906).
*
* The Inspector deliberately has no fields for the authorization and token URLs
* — they are resolved by authorization-server metadata discovery (RFC 8414 /
* OpenID Connect Discovery), exactly as a real MCP host resolves them. But a
* server under development often advertises its *production* authorization
* server while the person debugging it wants the staging one, and discovery
* gives them no way to say so.
*
* These overrides are that affordance: when set, they replace whatever the
* authorization server's metadata document returned.
*
* ## Why this is a fetch wrapper and not a provider hook
*
* Both endpoints reach the flow through exactly one path in SDK v2: `auth()`
* discovers the metadata document and then reads `metadata.authorization_endpoint`
* (in `startAuthorization`) and `metadata.token_endpoint` (in `fetchToken`).
* Neither is routed through `OAuthClientProvider`, and `AuthOptions` has no
* `metadata` field — so the provider seam that carries the custom authorization
* parameters (#2018) cannot reach the token endpoint at all. The one seam that
* sees both is the `fetchFn` the SDK uses for discovery, so the override is
* applied to the metadata document in flight.
*
* Two consequences worth knowing:
*
* - **A metadata document is required.** When discovery returns nothing, the SDK
* falls back to `/authorize` and `/token` on the authorization server's origin
* and there is no document to patch. That matches the feature as asked for —
* overriding "whatever urls the authorization server returns" — and a server
* publishing no metadata is already on a path where the endpoints are not
* being advertised in the first place.
* - **The Network tab shows the patched document.** The wrapper is applied to the
* base fetch, inside the request tracker, so what the tab renders is the
* metadata as the flow consumed it. That is the useful reading: it explains why
* the subsequent authorize/token requests went where they did.
*/
/**
* Per-server overrides for the two endpoints an authorization server publishes.
* Both are optional and independent — overriding only the token URL is a valid
* configuration.
*/
export interface OAuthEndpointOverrides {
/** Replaces `authorization_endpoint` in the discovered metadata. */
authorizationUrl?: string;
/** Replaces `token_endpoint` in the discovered metadata. */
tokenUrl?: string;
}
/** Field name on the metadata document each override replaces. */
const OVERRIDE_FIELDS = {
authorizationUrl: "authorization_endpoint",
tokenUrl: "token_endpoint",
} as const;
/**
* Validation message for one configured endpoint URL, or `undefined` when the
* value is acceptable. A blank value is not an error — it means "no override".
*
* Only absolute `http:`/`https:` URLs are accepted: the value is written
* straight into the metadata document, where the SDK passes it to `new URL(...)`
* with no base — so a relative path would throw deep inside the flow, far from
* the setting that caused it. `http:` is allowed because the whole point is
* reaching a local or staging authorization server.
*
* The settings form renders this message against the field, so the form and the
* runtime cannot disagree about which values are usable.
*/
export function oauthEndpointUrlError(value: string): string | undefined {
const trimmed = value.trim();
if (trimmed === "") return undefined;
let parsed: URL;
try {
parsed = new URL(trimmed);
} catch {
return `"${trimmed}" is not an absolute URL.`;
}
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
return `"${trimmed}" is not an http(s) URL.`;
}
return undefined;
}
/**
* The trimmed value, or `undefined` when it is blank or unusable. A rejected
* value warns and is dropped rather than throwing, so one bad field cannot make
* an otherwise-working server unconnectable.
*/
function normalizeEndpointUrl(
value: string | undefined,
field: keyof OAuthEndpointOverrides,
): string | undefined {
if (value === undefined) return undefined;
const trimmed = value.trim();
if (trimmed === "") return undefined;
const error = oauthEndpointUrlError(trimmed);
if (error) {
console.warn(`[oauth] Ignoring \`${field}\`: ${error}`);
return undefined;
}
return trimmed;
}
/**
* Normalize the configured overrides, dropping blank and malformed values.
* Returns `undefined` when nothing usable remains, which is the signal callers
* use to skip wrapping their fetch at all.
*/
export function normalizeOAuthEndpointOverrides(
overrides: OAuthEndpointOverrides | undefined,
): OAuthEndpointOverrides | undefined {
if (!overrides) return undefined;
const authorizationUrl = normalizeEndpointUrl(
overrides.authorizationUrl,
"authorizationUrl",
);
const tokenUrl = normalizeEndpointUrl(overrides.tokenUrl, "tokenUrl");
if (!authorizationUrl && !tokenUrl) return undefined;
return {
...(authorizationUrl && { authorizationUrl }),
...(tokenUrl && { tokenUrl }),
};
}
/**
* Whether a parsed JSON body is an authorization-server metadata document.
*
* The wrapper sees every JSON response on the connection, so this has to be
* narrow enough not to rewrite an unrelated body that happens to carry a
* similarly-named field. RFC 8414 §2 makes `issuer` REQUIRED and it appears in
* no other document the flow fetches (protected-resource metadata has
* `authorization_servers`, not `issuer`), so requiring `issuer` alongside at
* least one of the two endpoints is a precise test.
*/
export function isAuthorizationServerMetadata(
value: unknown,
): value is Record<string, unknown> {
if (value === null || typeof value !== "object" || Array.isArray(value)) {
return false;
}
const doc = value as Record<string, unknown>;
if (typeof doc.issuer !== "string") return false;
return (
typeof doc.authorization_endpoint === "string" ||
typeof doc.token_endpoint === "string"
);
}
/**
* Return a copy of an authorization-server metadata document with the
* configured endpoints replaced.
*
* An override is written even when the document does not advertise that
* endpoint: a metadata document missing `authorization_endpoint` already fails
* the flow (`new URL(undefined)` throws), so supplying the missing value is
* strictly better than leaving the hole.
*/
export function applyOAuthEndpointOverrides(
metadata: Record<string, unknown>,
overrides: OAuthEndpointOverrides,
): Record<string, unknown> {
const patched = { ...metadata };
for (const [key, field] of Object.entries(OVERRIDE_FIELDS) as [
keyof OAuthEndpointOverrides,
(typeof OVERRIDE_FIELDS)[keyof OAuthEndpointOverrides],
][]) {
const override = overrides[key];
if (override) patched[field] = override;
}
return patched;
}
/** Rebuild a response around a replacement body, preserving status/headers. */
function responseWithBody(response: Response, body: string): Response {
const headers = new Headers(response.headers);
// The body length changes when an override is applied, and a stale
// `content-length` on a synthesized `Response` is worse than none — consumers
// read the body we hand them, not the header.
headers.delete("content-length");
// `fetch` already decoded the body, so the replacement is plain text and an
// inherited `content-encoding` would describe an encoding it no longer has.
headers.delete("content-encoding");
return new Response(body, {
status: response.status,
statusText: response.statusText,
headers,
});
}
/**
* Wrap a `fetch` so authorization-server metadata responses carry the
* configured endpoint overrides.
*
* `getOverrides` is called per request rather than captured once: the OAuth
* config is mutable (a settings edit can change it without rebuilding the
* client), and reading it lazily means a wrapped fetch never serves a stale
* override. When it returns nothing the response is passed through untouched,
* so the wrapper is inert for the overwhelmingly common unconfigured case.
*/
export function withOAuthEndpointOverrides(
fetchFn: typeof fetch,
getOverrides: () => OAuthEndpointOverrides | undefined,
): typeof fetch {
// Normalization is memoized on the raw pair rather than run per request: it
// warns about a malformed value, and every request on the connection passes
// through here — so without this a single typo would log on every call.
let lastKey: string | undefined;
let lastNormalized: OAuthEndpointOverrides | undefined;
const resolveOverrides = (): OAuthEndpointOverrides | undefined => {
const raw = getOverrides();
const key = JSON.stringify([raw?.authorizationUrl, raw?.tokenUrl]);
if (key !== lastKey) {
lastKey = key;
lastNormalized = normalizeOAuthEndpointOverrides(raw);
}
return lastNormalized;
};
return async (input, init) => {
const response = await fetchFn(input, init);
const overrides = resolveOverrides();
if (!overrides) return response;
if (!response.ok) return response;
const contentType = response.headers.get("content-type") ?? "";
if (!contentType.toLowerCase().includes("json")) return response;
const body = await response.text();
let parsed: unknown;
try {
parsed = JSON.parse(body);
} catch {
// Not JSON despite the header. The body is already consumed, so hand back
// an equivalent response rather than the drained original.
return responseWithBody(response, body);
}
if (!isAuthorizationServerMetadata(parsed)) {
return responseWithBody(response, body);
}
return responseWithBody(
response,
JSON.stringify(applyOAuthEndpointOverrides(parsed, overrides)),
);
};
}

View file

@ -36,6 +36,16 @@ export {
} from "./authorizationParams.js";
export type { ReservedAuthorizationParam } from "./authorizationParams.js";
// Authorization/token endpoint overrides (#1906)
export {
oauthEndpointUrlError,
normalizeOAuthEndpointOverrides,
isAuthorizationServerMetadata,
applyOAuthEndpointOverrides,
withOAuthEndpointOverrides,
} from "./endpointOverrides.js";
export type { OAuthEndpointOverrides } from "./endpointOverrides.js";
// Storage
export type {
OAuthStorage,

View file

@ -11,7 +11,10 @@ import type {
InspectorClientOptions,
InspectorServerSettings,
} from "../mcp/types.js";
import { oauthAuthorizationParamsFromSettings } from "../mcp/serverList.js";
import {
oauthAuthorizationParamsFromSettings,
oauthEndpointOverridesFromSettings,
} from "../mcp/serverList.js";
import { loadClientConfig } from "./config.js";
import type { ClientConfig } from "./types.js";
import {
@ -74,6 +77,9 @@ export function buildRunnerClientAuthOptions(
const serverAuthorizationParams = savedSettings
? oauthAuthorizationParamsFromSettings(savedSettings)
: undefined;
const serverEndpointOverrides = savedSettings
? oauthEndpointOverridesFromSettings(savedSettings)
: undefined;
const oauthFromServer =
savedSettings &&
@ -81,6 +87,7 @@ export function buildRunnerClientAuthOptions(
savedSettings.oauthClientSecret ||
savedSettings.oauthScopes ||
serverAuthorizationParams ||
serverEndpointOverrides ||
savedSettings.enterpriseManaged)
? {
...(savedSettings.oauthClientId && {
@ -95,6 +102,7 @@ export function buildRunnerClientAuthOptions(
...(serverAuthorizationParams && {
authorizationParams: serverAuthorizationParams,
}),
...serverEndpointOverrides,
...(savedSettings.enterpriseManaged && {
enterpriseManaged: true,
}),

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;
};
/**