feat: per-server custom OAuth authorization-request parameters (#2018)

Real deployments gate IdP selection, prompt behavior, or audience on a query
parameter the OAuth/OIDC core specs never standardized (Keycloak's
`kc_idp_hint`, OIDC's `login_hint` / `prompt` / `acr_values`, Auth0's
`audience`), and none of them could be driven from the Inspector.

The Inspector never builds the authorize URL — the SDK's `auth()` does, and it
exposes no hook for extra parameters — so the merge happens at the one seam
that sees the finished URL: `BaseOAuthClientProvider.redirectToAuthorization`.
It lives in `core/`, so web, CLI, and TUI all inherit it.

- `core/auth/authorizationParams.ts`: the reserved-key list (`client_id`,
  `code_challenge`, `code_challenge_method`, `redirect_uri`, `resource`,
  `response_type`, `scope`, `state`), the key validator the form renders
  inline, and the merge itself. Reserved keys are dropped with a warning
  rather than silently overriding PKCE / the state binding / RFC 8707;
  blank-key rows are skipped; the caller's URL is never mutated.
- Applied to the authorization request only — nothing wires it into the token
  request.
- Persisted as `oauth.authorizationParams` (a string record) on the per-server
  config beside `scopes`, edited as key/value rows in Server Settings ->
  Authorization ("Additional authorization parameters"), with an inline error
  per offending row plus a section-level warning.

- The enterprise-managed (EMA) leg redirects to a *different* authorization
  server (the enterprise IdP), so it goes through the explicit
  `redirectToExternalAuthorization` entry point, which skips the merge — a
  `kc_idp_hint` or `audience` meant for the MCP server's AS must never be
  appended to the IdP's authorize URL.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RDXCSrnSBV7vZfxycG5Z6E
Signed-off-by: cliffhall <cliff@futurescale.com>
This commit is contained in:
cliffhall 2026-08-15 18:58:46 -04:00
commit b25ece8ded
10 changed files with 1064 additions and 6 deletions

View file

@ -0,0 +1,105 @@
/**
* Per-server custom authorization-request parameters (#2018).
*
* Real deployments gate IdP selection, prompt behavior, or audience on a query
* parameter the OAuth/OIDC core specs never standardized — Keycloak's
* `kc_idp_hint`, OIDC's `login_hint` / `prompt` / `acr_values`, Auth0's
* `audience`. The Inspector never builds the authorize URL itself (the SDK's
* `auth()` does, and exposes no hook for extra parameters), so the merge
* happens at the one seam that sees the finished URL:
* `BaseOAuthClientProvider.redirectToAuthorization`.
*
* The parameters apply to the **authorization request only** — never the token
* request, which the SDK builds separately and which this module is not wired
* into. They also apply only to the server's **own** authorization server:
* the enterprise-managed (EMA) leg redirects to a different one (the enterprise
* IdP) and goes through `BaseOAuthClientProvider.redirectToExternalAuthorization`,
* which deliberately skips the merge.
*/
/**
* Parameters the OAuth flow owns and a user may not override.
*
* Overriding any of these does not produce a useful debugging scenario — it
* breaks PKCE (`code_challenge`, `code_challenge_method`), CSRF binding
* (`state`), the callback leg (`redirect_uri`, `response_type`, `client_id`),
* or RFC 8707 resource indicators (`resource`), and the failure surfaces as an
* opaque authorization-server error rather than as an Inspector problem.
* `scope` is excluded too: it has its own field, and the SEP-2350 step-up union
* is computed from that field rather than from these parameters.
*/
export const RESERVED_AUTHORIZATION_PARAMS = [
"client_id",
"code_challenge",
"code_challenge_method",
"redirect_uri",
"resource",
"response_type",
"scope",
"state",
] as const;
export type ReservedAuthorizationParam =
(typeof RESERVED_AUTHORIZATION_PARAMS)[number];
const RESERVED: ReadonlySet<string> = new Set(RESERVED_AUTHORIZATION_PARAMS);
/**
* Whether `key` names a parameter the OAuth flow owns. Compared case- and
* whitespace-insensitively: query parameter names are case-sensitive on the
* wire, but a `Client_Id` typed into the form is plainly an attempt to set the
* reserved one, and letting it through would be the silent override this rule
* exists to prevent.
*/
export function isReservedAuthorizationParam(key: string): boolean {
return RESERVED.has(key.trim().toLowerCase());
}
/**
* Validation message for one authorization-parameter key, or `undefined` when
* the key is acceptable. A blank key is not an error — the form lets a user
* leave a freshly-added row empty mid-edit, and blank rows are dropped rather
* than rejected.
*/
export function authorizationParamKeyError(key: string): string | undefined {
const trimmed = key.trim();
if (trimmed === "") return undefined;
if (isReservedAuthorizationParam(trimmed)) {
return `"${trimmed}" is set by the authorization flow and cannot be overridden.`;
}
return undefined;
}
/**
* Merge custom parameters into a finished authorization URL.
*
* Blank keys are skipped (a half-edited form row) and reserved keys are dropped
* with a warning — never silently, and never overriding what the SDK put there.
* Returns the original URL instance when there is nothing to apply, and
* otherwise a copy, so the caller's URL is never mutated.
*/
export function applyAuthorizationParams(
authorizationUrl: URL,
params: Record<string, string> | undefined,
): URL {
if (!params) return authorizationUrl;
const applicable = Object.entries(params).filter(([key]) => {
if (key.trim() === "") return false;
if (isReservedAuthorizationParam(key)) {
console.warn(
`[oauth] Ignoring reserved authorization parameter "${key.trim()}" — it is set by the authorization flow.`,
);
return false;
}
return true;
});
if (applicable.length === 0) return authorizationUrl;
const merged = new URL(authorizationUrl.href);
for (const [key, value] of applicable) {
merged.searchParams.set(key.trim(), value);
}
return merged;
}

View file

@ -84,7 +84,11 @@ export class EmaTransportOAuthProvider implements OAuthClientProvider {
async redirectToAuthorization(_authorizationUrl: URL): Promise<void> {
const idpAuthorizationUrl = await startEmaIdpAuthorization(this.emaConfig);
this.inner.clearCapturedAuthUrl();
this.inner.redirectToAuthorization(idpAuthorizationUrl);
// The IdP is a different authorization server than the one the inner
// provider was configured for, so the per-server custom authorization
// parameters (#2018) must not be appended here — hence the "external"
// entry point rather than `redirectToAuthorization`.
this.inner.redirectToExternalAuthorization(idpAuthorizationUrl);
}
saveCodeVerifier(codeVerifier: string): void | Promise<void> {

View file

@ -27,6 +27,15 @@ export { ensureCimdClientRegistration } from "./cimd.js";
export { mcpAuth, type McpAuthOptions, type McpAuthResult } from "./mcpAuth.js";
export { computeScopeUnion, isStrictScopeSuperset } from "./scopes.js";
// Custom authorization-request parameters (#2018)
export {
RESERVED_AUTHORIZATION_PARAMS,
isReservedAuthorizationParam,
authorizationParamKeyError,
applyAuthorizationParams,
} from "./authorizationParams.js";
export type { ReservedAuthorizationParam } from "./authorizationParams.js";
// Storage
export type {
OAuthStorage,

View file

@ -11,6 +11,7 @@ import type {
} from "@modelcontextprotocol/client";
import type { OAuthStorage, SaveClientInformationOptions } from "./storage.js";
import { generateOAuthState } from "./utils.js";
import { applyAuthorizationParams } from "./authorizationParams.js";
/**
* Redirect URL provider. Returns the redirect URL for OAuth flows.
@ -96,6 +97,14 @@ export type OAuthProviderConfig = {
redirectUrlProvider: RedirectUrlProvider;
navigation: OAuthNavigation;
clientMetadataUrl?: string;
/**
* Per-server custom authorization-request parameters (#2018), merged into the
* finished authorize URL in {@link BaseOAuthClientProvider.redirectToAuthorization}.
* Reserved (protocol-critical) keys are dropped with a warning — see
* `authorizationParams.ts`. Authorization request only; the token request is
* unaffected.
*/
authorizationParams?: Record<string, string>;
};
/**
@ -115,6 +124,8 @@ export class BaseOAuthClientProvider implements OAuthClientProvider {
protected redirectUrlProvider: RedirectUrlProvider;
protected navigation: OAuthNavigation;
public clientMetadataUrl?: string;
/** Custom authorization-request parameters (#2018). Authorize URL only. */
protected authorizationParams?: Record<string, string>;
constructor(serverUrl: string, oauthConfig: OAuthProviderConfig) {
this.serverUrl = serverUrl;
@ -122,6 +133,7 @@ export class BaseOAuthClientProvider implements OAuthClientProvider {
this.redirectUrlProvider = oauthConfig.redirectUrlProvider;
this.navigation = oauthConfig.navigation;
this.clientMetadataUrl = oauthConfig.clientMetadataUrl;
this.authorizationParams = oauthConfig.authorizationParams;
}
/**
@ -272,6 +284,34 @@ export class BaseOAuthClientProvider implements OAuthClientProvider {
}
redirectToAuthorization(authorizationUrl: URL): void {
// #2018: the SDK builds the authorize URL and offers no hook for extra
// parameters, so the per-server ones are merged here — the one seam that
// sees the finished URL before navigation. Everything downstream (the
// captured URL used by the step-up modal, the event, the navigation) uses
// the merged URL so all three agree on what was actually requested.
this.performAuthorizationRedirect(
applyAuthorizationParams(authorizationUrl, this.authorizationParams),
);
}
/**
* Redirect to an authorize URL belonging to a **different** authorization
* server than the one this provider was configured for, so the per-server
* custom parameters (#2018) MUST NOT be applied.
*
* The only caller is the enterprise-managed (EMA) transport provider, which
* discards the resource AS URL and sends the user to the enterprise IdP
* instead. Parameters a user configured for their MCP server's authorization
* server — a `kc_idp_hint`, an `audience` — are meaningless and potentially
* flow-breaking on the IdP's, and appending them there would leak per-server
* config across an authorization-server boundary.
*/
redirectToExternalAuthorization(authorizationUrl: URL): void {
this.performAuthorizationRedirect(authorizationUrl);
}
/** Capture, announce, and navigate to a finished authorize URL. */
private performAuthorizationRedirect(authorizationUrl: URL): void {
// Capture URL for return value
this.capturedAuthUrl = authorizationUrl;

View file

@ -11,6 +11,7 @@ import type {
InspectorClientOptions,
InspectorServerSettings,
} from "../mcp/types.js";
import { oauthAuthorizationParamsFromSettings } from "../mcp/serverList.js";
import { loadClientConfig } from "./config.js";
import type { ClientConfig } from "./types.js";
import {
@ -70,11 +71,16 @@ export function buildRunnerClientAuthOptions(
const activeIdp = getActiveEnterpriseManagedAuthIdp(clientConfig);
const activeCimdUrl = getActiveCimdClientMetadataUrl(clientConfig);
const serverAuthorizationParams = savedSettings
? oauthAuthorizationParamsFromSettings(savedSettings)
: undefined;
const oauthFromServer =
savedSettings &&
(savedSettings.oauthClientId ||
savedSettings.oauthClientSecret ||
savedSettings.oauthScopes ||
serverAuthorizationParams ||
savedSettings.enterpriseManaged)
? {
...(savedSettings.oauthClientId && {
@ -86,6 +92,9 @@ export function buildRunnerClientAuthOptions(
...(savedSettings.oauthScopes && {
scope: savedSettings.oauthScopes,
}),
...(serverAuthorizationParams && {
authorizationParams: serverAuthorizationParams,
}),
...(savedSettings.enterpriseManaged && {
enterpriseManaged: true,
}),

View file

@ -5421,6 +5421,8 @@ export class InspectorClient extends InspectorClientEventTarget {
clientSecret?: string;
clientMetadataUrl?: string;
scope?: string;
/** Custom authorization-request parameters (#2018). Authorize URL only. */
authorizationParams?: Record<string, string>;
}): void {
if (!this.oauthManager) {
throw new Error(

View file

@ -94,6 +94,7 @@ export class OAuthManager {
clientMetadataUrl?: string;
scope?: string;
enterpriseManaged?: boolean;
authorizationParams?: Record<string, string>;
}): void {
this.oauthConfig = {
...this.oauthConfig,
@ -135,6 +136,11 @@ export class OAuthManager {
redirectUrlProvider: this.oauthConfig.redirectUrlProvider,
navigation: this.oauthConfig.navigation,
clientMetadataUrl: this.oauthConfig.clientMetadataUrl,
// #2018: per-server custom authorization-request parameters. Carried on
// the provider (not in OAuth storage like `scope`) — they are pure config
// read from mcp.json, and nothing in the flow needs to persist or union
// them.
authorizationParams: this.oauthConfig.authorizationParams,
});
provider.setEventTarget(this.params.getEventTarget());

View file

@ -1130,6 +1130,7 @@ export function createRemoteApp(
clientId?: string;
clientSecret?: string;
scopes?: string;
authorizationParams?: Record<string, string>;
enterpriseManaged?: boolean;
} => {
if (v === null || typeof v !== "object" || Array.isArray(v)) return false;
@ -1137,6 +1138,12 @@ export function createRemoteApp(
for (const k of ["clientId", "clientSecret", "scopes"] as const) {
if (o[k] !== undefined && typeof o[k] !== "string") return false;
}
if (
o.authorizationParams !== undefined &&
!isStringRecord(o.authorizationParams)
) {
return false;
}
if (
o.enterpriseManaged !== undefined &&
typeof o.enterpriseManaged !== "boolean"
@ -1248,7 +1255,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?, enterpriseManaged? }`.",
"Dropping malformed `oauth` field — expected `{ clientId?, clientSecret?, scopes?, authorizationParams?, enterpriseManaged? }`.",
);
delete valObj.oauth;
}
@ -1520,6 +1527,18 @@ export function createRemoteApp(
return { ok: false, error: `settings.${optional} must be a string` };
}
}
// Optional on the wire (older clients won't send it); when present it must
// be the same `{ key, value }` row shape the headers/metadata lists use.
if (
obj.oauthAuthorizationParams !== undefined &&
!isKvArray(obj.oauthAuthorizationParams)
) {
return {
ok: false,
error:
"settings.oauthAuthorizationParams must be an array of { key, value }",
};
}
if (
obj.enterpriseManaged !== undefined &&
typeof obj.enterpriseManaged !== "boolean"
@ -1635,6 +1654,12 @@ export function createRemoteApp(
if (typeof obj.oauthScopes === "string" && obj.oauthScopes !== "") {
value.oauthScopes = obj.oauthScopes;
}
// Carried through with its blank rows intact — the omit-on-empty filtering
// happens on the way to disk (inspectorSettingsToStoredFields), matching
// how the headers/metadata rows are handled. (#2018)
if (isKvArray(obj.oauthAuthorizationParams)) {
value.oauthAuthorizationParams = obj.oauthAuthorizationParams;
}
if (obj.enterpriseManaged === true) {
value.enterpriseManaged = true;
}

View file

@ -164,6 +164,9 @@ type StoredInspectorFields = Pick<
* Convert a stored stdio `env` record into the controlled key/value rows the
* settings form edits, preserving the object's key insertion order. Empty when
* absent. Inverse of `envPairsToRecord`.
*
* The transform is generic key/value, so the OAuth authorization-parameters
* record (#2018) reuses it rather than growing a second copy.
*/
export function envRecordToPairs(
env: Record<string, string> | undefined,
@ -176,6 +179,9 @@ export function envRecordToPairs(
* with an empty/whitespace key (the form lets users leave a new row blank
* mid-edit). Inverse of `envRecordToPairs`. Used by the `/api/servers` PUT
* write-through that maps `settings.env` back onto `config.env`.
*
* Generic key/value like its inverse, so the OAuth authorization-parameters
* rows (#2018) collapse through it too.
*/
export function envPairsToRecord(
pairs: { key: string; value: string }[],
@ -188,6 +194,21 @@ export function envPairsToRecord(
return out;
}
/**
* Collapse a server's custom authorization-parameter rows into the record the
* `InspectorClientOptions.oauth.authorizationParams` option takes, or
* `undefined` when nothing survives (no rows, or every row blank-keyed). Shared
* by the web (`App.tsx`) and Node (`buildRunnerClientAuthOptions`) paths so both
* derive the option identically. (#2018)
*/
export function oauthAuthorizationParamsFromSettings(
settings: Pick<InspectorServerSettings, "oauthAuthorizationParams">,
): Record<string, string> | undefined {
if (!settings.oauthAuthorizationParams) return undefined;
const record = envPairsToRecord(settings.oauthAuthorizationParams);
return Object.keys(record).length > 0 ? record : undefined;
}
/**
* Lift the Inspector-extension fields off a freshly-read `StoredMCPServer`
* into the pair-array / flat-OAuth `InspectorServerSettings` shape the form
@ -286,6 +307,17 @@ export function storedFieldsToInspectorSettings(
if (stored.oauth?.clientSecret)
settings.oauthClientSecret = stored.oauth.clientSecret;
if (stored.oauth?.scopes) settings.oauthScopes = stored.oauth.scopes;
// Optional record with no default; carried through only when the file has a
// non-empty object, so an absent/empty field reads back as unset and the
// write side then omits it — keeping a byte-stable round-trip. (#2018)
if (
stored.oauth?.authorizationParams &&
Object.keys(stored.oauth.authorizationParams).length > 0
) {
settings.oauthAuthorizationParams = envRecordToPairs(
stored.oauth.authorizationParams,
);
}
if (stored.oauth?.onInsufficientScope) {
settings.oauthOnInsufficientScope = stored.oauth.onInsufficientScope;
}
@ -395,6 +427,14 @@ export function inspectorSettingsToStoredFields(
if (settings.oauthClientSecret)
oauthFields.clientSecret = settings.oauthClientSecret;
if (settings.oauthScopes) oauthFields.scopes = settings.oauthScopes;
// Blank-key rows (a row the user added but never filled in) are dropped, so
// an all-blank list writes nothing — matching the read side's omit-on-empty.
if (settings.oauthAuthorizationParams) {
const authParams = envPairsToRecord(settings.oauthAuthorizationParams);
if (Object.keys(authParams).length > 0) {
oauthFields.authorizationParams = authParams;
}
}
if (settings.oauthOnInsufficientScope) {
oauthFields.onInsufficientScope = settings.oauthOnInsufficientScope;
}
@ -605,12 +645,17 @@ export function extractSecretsFromStored(
const restOauth: {
clientId?: string;
scopes?: string;
authorizationParams?: Record<string, string>;
enterpriseManaged?: boolean;
} = {};
if (stored.oauth.clientId !== undefined)
restOauth.clientId = stored.oauth.clientId;
if (stored.oauth.scopes !== undefined)
restOauth.scopes = stored.oauth.scopes;
// Not a secret — the authorization parameters stay on the stripped entry so
// stripping the client secret doesn't silently discard them. (#2018)
if (stored.oauth.authorizationParams !== undefined)
restOauth.authorizationParams = stored.oauth.authorizationParams;
if (stored.oauth.enterpriseManaged === true) {
restOauth.enterpriseManaged = true;
}

View file

@ -166,6 +166,16 @@ export type StoredMCPServer = MCPServerConfig & {
enterpriseManaged?: boolean;
/** SEP-2350 step-up policy for `403 insufficient_scope` (default `reauthorize`). */
onInsufficientScope?: OnInsufficientScopePolicy;
/**
* Custom query parameters appended to the OAuth **authorization request**
* (never the token request) — e.g. Keycloak's `kc_idp_hint`, OIDC's
* `login_hint` / `prompt` / `acr_values`, Auth0's `audience`. Reserved,
* protocol-critical keys (`client_id`, `state`, `code_challenge`, …) are
* rejected by the form and dropped with a warning at merge time. Omitted on
* disk when empty, keeping the file diff minimal. Inspector-specific.
* (#2018)
*/
authorizationParams?: Record<string, string>;
};
/**
* Filesystem/URI roots advertised to the server via the `roots` client
@ -516,6 +526,14 @@ export interface OAuthSettings {
clientId: string;
clientSecret: string;
scopes: string;
/**
* Custom authorization-request parameters as controlled key/value rows.
* Optional so callers constructing an `OAuthSettings` for the other fields
* don't have to supply an empty array; the form always passes the current
* rows (blank ones included, so a half-typed row survives a re-render).
* (#2018)
*/
authorizationParams?: { key: string; value: string }[];
enterpriseManaged?: boolean;
onInsufficientScope?: OnInsufficientScopePolicy;
}
@ -674,6 +692,13 @@ export interface InspectorServerSettings {
oauthClientId?: string;
oauthClientSecret?: string;
oauthScopes?: string;
/**
* Custom authorization-request parameters, edited as controlled key/value
* rows (mirrors `headers` / `metadata`). Persisted as the
* `oauth.authorizationParams` record on disk; blank-key rows are dropped on
* the way there. Applied to the authorization request only. (#2018)
*/
oauthAuthorizationParams?: { key: string; value: string }[];
/**
* SEP-2350 step-up policy for a `403 insufficient_scope` challenge on this
* server's HTTP transport. Defaults to `reauthorize` when unset.
@ -1026,6 +1051,11 @@ export interface InspectorClientOptions {
scope?: string;
/** Route to EMA flow when true (resource AS creds in clientId/clientSecret). */
enterpriseManaged?: boolean;
/**
* Custom query parameters merged into the authorization request URL only
* (#2018). Reserved, protocol-critical keys are dropped with a warning.
*/
authorizationParams?: Record<string, string>;
};
/**