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

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