Merge branch 'v2/main' into v2/fix/1909-salvage-malformed-list-items

This commit is contained in:
Cliff Hall 2026-08-15 23:05:31 -04:00 • committed by GitHub
commit 0f31de83af
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 1782 additions and 138 deletions

View file

@ -5986,6 +5986,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?, onInsufficientScope? }`.",
);
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,60 @@ export function envPairsToRecord(
return out;
}
/**
* Validate a server's `oauth.authorizationParams` as it comes off disk, or
* `undefined` when there is nothing usable. (#2018)
*
* `StoredMCPServer` types this as `Record<string, string>`, but that is a
* compile-time promise a hand-edited `mcp.json` does not keep — and only the web
* client's `/api/servers` route checks it, while the CLI and TUI read the file
* directly. Without this, `authorizationParams: "oops"` enumerates as the
* character-indexed pairs `0=o, 1=o, …` and `{ audience: 5 }` reaches
* `URLSearchParams.set`, which stringifies it — either way the Inspector sends
* query parameters the user never wrote. Each rejection warns, following
* `cleanRoots` above, which guards the same class for `roots`.
*/
export function cleanAuthorizationParams(
params: Record<string, string> | undefined,
): Record<string, string> | undefined {
if (params === undefined) return undefined;
// Keep: unreachable per the parameter type, reachable from disk.
if (typeof params !== "object" || params === null || Array.isArray(params)) {
console.warn(
"Ignoring `oauth.authorizationParams`: expected an object of string values, got",
Array.isArray(params) ? "array" : typeof params,
);
return undefined;
}
const out: Record<string, string> = {};
for (const [key, value] of Object.entries(params)) {
if (typeof value !== "string") {
console.warn(
`Dropping \`oauth.authorizationParams.${key}\`: expected a string value, got ${typeof value}.`,
);
continue;
}
if (key.trim() === "") continue;
out[key] = value;
}
return Object.keys(out).length > 0 ? out : undefined;
}
/**
* 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 +346,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. Sanitized
// rather than trusted: only the web client's `/api/servers` route validates
// this field, while the CLI/TUI read `mcp.json` straight off disk. (#2018)
const authorizationParams = cleanAuthorizationParams(
stored.oauth?.authorizationParams,
);
if (authorizationParams) {
settings.oauthAuthorizationParams = envRecordToPairs(authorizationParams);
}
if (stored.oauth?.onInsufficientScope) {
settings.oauthOnInsufficientScope = stored.oauth.onInsufficientScope;
}
@ -395,6 +466,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;
}
@ -602,18 +681,13 @@ export function extractSecretsFromStored(
if (stored.oauth?.clientSecret) {
secrets[SECRET_FIELD_OAUTH_CLIENT_SECRET] = stored.oauth.clientSecret;
const restOauth: {
clientId?: string;
scopes?: string;
enterpriseManaged?: boolean;
} = {};
if (stored.oauth.clientId !== undefined)
restOauth.clientId = stored.oauth.clientId;
if (stored.oauth.scopes !== undefined)
restOauth.scopes = stored.oauth.scopes;
if (stored.oauth.enterpriseManaged === true) {
restOauth.enterpriseManaged = true;
}
// Keep every OAuth field except the one secret, rather than enumerating the
// non-secret ones. An allow-list here is a standing trap: it silently drops
// any field added to `oauth` afterwards, and it had already done so —
// `onInsufficientScope` was lost whenever a server carried both a client
// secret and a non-default SEP-2350 policy. Subtracting the secret instead
// means a future field is preserved by construction. (#2018)
const { clientSecret: _clientSecret, ...restOauth } = stored.oauth;
if (Object.keys(restOauth).length > 0) {
stripped.oauth = restOauth;
} else {

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.
@ -1034,6 +1059,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>;
};
/**