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

View file

@ -0,0 +1,342 @@
/**
* 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.
*
* Four 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.
* - **They do not apply to the enterprise-managed (EMA) leg.** That flow
* authorizes against the enterprise IdP — a different authorization server —
* and its OIDC discovery runs through this same fetch, so `OAuthManager`
* suppresses the overrides when the server is enterprise-managed. This mirrors
* `redirectToExternalAuthorization` skipping the custom authorization
* parameters (#2018).
* - **They redirect endpoints, they do not re-point the issuer.** `issuer` is
* left exactly as discovery returned it, deliberately: it is the authorization
* server's *identity*, and it is what RFC 9207 / SEP-2352 check the callback's
* `iss` against to defend against an authorization-server mix-up. So these
* overrides fit alternate endpoints of the same logical issuer (a staging
* deployment fronting the same issuer, a local proxy). Aim one at an
* authorization server that advertises a *different* issuer and the callback
* is rejected as an issuer mismatch before the code is redeemed — which is the
* mix-up defense working. Suppressing that check to make the override "work"
* would trade a real security property for a debugging convenience, so the
* limit is documented rather than removed.
* - **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.`;
}
// `new URL` accepts embedded credentials but Fetch rejects an HTTP(S) request
// URL that carries them, so without this the value would pass both the form
// and the runtime and then fail deep in the OAuth exchange — the failure mode
// this validation exists to prevent. (Sending credentials to an endpoint the
// flow discovered is not something to support quietly, either.)
if (parsed.username || parsed.password) {
return "URL must not contain a username or password.";
}
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;
}
/**
* The well-known paths authorization-server metadata is discovered at: RFC 8414
* (§3.1, including the path-suffixed and path-prefixed variants the SDK's
* `buildDiscoveryUrls` emits) and OpenID Connect Discovery. Matched as a
* substring of the pathname so every variant is covered by two literals.
*/
const AS_METADATA_WELL_KNOWN_PATHS = [
"/.well-known/oauth-authorization-server",
"/.well-known/openid-configuration",
] as const;
/** The request URL a `fetch` call was made with, in any of its three forms. */
function requestUrlOf(input: RequestInfo | URL): string {
if (typeof input === "string") return input;
if (input instanceof URL) return input.href;
return input.url;
}
/**
* Whether a request is an authorization-server metadata discovery request.
*
* This is what keeps the wrapper off the hot path. Without it, every successful
* `application/json` response on the connection — every `tools/call` result, and
* every resource payload on the direct CLI/TUI transports — would be buffered
* and parsed a second time before the SDK could consume it, purely to discover
* that it is not a metadata document. Gating on the discovery URL first means
* ordinary traffic is untouched: one string test and the response is returned.
*
* A URL that will not parse falls back to testing the raw string, which is the
* conservative direction — worst case a request is *considered*, and the
* metadata predicate then rejects it.
*/
function isAuthorizationServerMetadataRequest(
input: RequestInfo | URL,
): boolean {
const raw = requestUrlOf(input);
let pathname: string;
try {
pathname = new URL(raw).pathname;
} catch {
pathname = raw;
}
return AS_METADATA_WELL_KNOWN_PATHS.some((path) => pathname.includes(path));
}
/**
* Whether a `content-type` names a **whole JSON document** — something that can
* be read to completion.
*
* This must be an exact media-type match, not a substring test for "json". The
* streamable-HTTP transport's long-lived server-push channel can be served as
* `application/x-ndjson` (see `isLongLivedStreamResponse` in
* `core/mcp/fetchTracking.ts`), which is an unbounded stream: awaiting `.json()`
* on a clone of it would never resolve, and since this wrapper awaits before
* returning, the MCP connection would hang for as long as an override is
* configured. Metadata documents are plain `application/json`, so the narrow
* test loses nothing. The `+json` structured suffix is accepted for the same
* reason it exists — it names a concrete document, not a stream.
*/
function isJsonDocumentResponse(contentType: string | null): boolean {
if (!contentType) return false;
// Strip parameters (`; charset=utf-8`) and compare the media type itself.
const mediaType = contentType.split(";")[0].trim().toLowerCase();
return mediaType === "application/json" || mediaType.endsWith("+json");
}
/** 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;
// Cheapest tests first: only a discovery request can carry the document
// this wrapper rewrites, so ordinary traffic never reaches the clone below.
if (!isAuthorizationServerMetadataRequest(input)) return response;
if (!response.ok) return response;
if (!isJsonDocumentResponse(response.headers.get("content-type")))
return response;
// Read through a clone so every path that does NOT patch can return the
// caller's own `Response`, untouched. Rebuilding unconditionally would drop
// native properties a synthesized `Response` cannot carry (`url`,
// `redirected`, `type`) from responses this wrapper has no business
// altering — and would throw outright on a body-less 204/205, whose status
// Fetch forbids a body on. A clone also makes a non-JSON body a plain
// parse failure with nothing consumed.
let parsed: unknown;
try {
parsed = await response.clone().json();
} catch {
return response;
}
if (!isAuthorizationServerMetadata(parsed)) return response;
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,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;
};
/**