The Inspector always declared `refresh_token` in its OAuth client metadata. The SDK's `determineScope()` then appends `offline_access` whenever the authorization server advertises it and the client declares that grant, and `startAuthorization()` appends `prompt=consent` whenever `offline_access` is in scope. Against Microsoft Entra ID that forced consent prompt routes a non-admin user into the admin-consent workflow and fails with AADSTS90094 — even after a tenant admin has consented. Adds a per-server "Request refresh token" setting (default on) that drops the grant from the client metadata, breaking that chain at its source. Persisted as `oauth.requestRefreshToken` and written only when off, so entries that never touch it keep a byte-stable round-trip. Web edits it in Server Settings → Authorization; the CLI and TUI pick it up from the same per-server settings via `buildRunnerClientAuthOptions`. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013hzwzS8UvBsr4yZm7o7sev Signed-off-by: cliffhall <cliff@futurescale.com>
149 lines
4.8 KiB
TypeScript
149 lines
4.8 KiB
TypeScript
/**
|
|
* Install-level client config loading and InspectorClient auth option wiring
|
|
* for Node runners (TUI, CLI).
|
|
*/
|
|
|
|
import type { SecretStore } from "../auth/node/secret-store.js";
|
|
import { defaultSecretStore } from "../auth/node/secret-store-selection.js";
|
|
import type {
|
|
InspectorClientOptions,
|
|
InspectorServerSettings,
|
|
} from "../mcp/types.js";
|
|
import {
|
|
oauthAuthorizationParamsFromSettings,
|
|
oauthEndpointOverridesFromSettings,
|
|
} from "../mcp/serverList.js";
|
|
import { loadClientConfig } from "./config.js";
|
|
import type { ClientConfig } from "./types.js";
|
|
import {
|
|
getActiveCimdClientMetadataUrl,
|
|
getActiveEnterpriseManagedAuthIdp,
|
|
} from "./types.js";
|
|
|
|
export interface LoadRunnerClientConfigOptions {
|
|
/** Explicit path from `--client-config` (or MCP_CLIENT_CONFIG_PATH when unset). */
|
|
clientConfigPath?: string;
|
|
/** Secret store for the IdP clientSecret; defaults to the store selected for
|
|
* this host (OS keychain, or its file/memory fallback). Tests inject an
|
|
* in-memory store for determinism. */
|
|
secretStore?: SecretStore;
|
|
}
|
|
|
|
/** Load install-level client.json with keychain-backed IdP secrets. */
|
|
export async function loadRunnerClientConfig(
|
|
options?: LoadRunnerClientConfigOptions,
|
|
): Promise<ClientConfig> {
|
|
const customPath =
|
|
options?.clientConfigPath?.trim() ||
|
|
process.env.MCP_CLIENT_CONFIG_PATH?.trim() ||
|
|
undefined;
|
|
const secretStore = options?.secretStore ?? defaultSecretStore();
|
|
return loadClientConfig({ filePath: customPath, secretStore });
|
|
}
|
|
|
|
export interface RunnerClientConfigOverrides {
|
|
clientId?: string;
|
|
clientSecret?: string;
|
|
clientMetadataUrl?: string;
|
|
}
|
|
|
|
/** HTTP transports (SSE, streamable-http) can use OAuth. */
|
|
export function isOAuthCapableServerConfig(
|
|
config: { type?: string } | null | undefined,
|
|
): boolean {
|
|
if (!config) return false;
|
|
return config.type === "sse" || config.type === "streamable-http";
|
|
}
|
|
|
|
/**
|
|
* Derive OAuth / EMA / CIMD InspectorClient options from install client.json,
|
|
* per-server settings, and CLI flag overrides (flags win over client.json).
|
|
*/
|
|
export function buildRunnerClientAuthOptions(
|
|
clientConfig: ClientConfig,
|
|
savedSettings?: InspectorServerSettings,
|
|
cliOverrides?: RunnerClientConfigOverrides,
|
|
): Pick<
|
|
InspectorClientOptions,
|
|
| "oauth"
|
|
| "enterpriseManagedAuth"
|
|
| "installEnterpriseManagedAuth"
|
|
| "directAuthRecovery"
|
|
> {
|
|
const activeIdp = getActiveEnterpriseManagedAuthIdp(clientConfig);
|
|
const activeCimdUrl = getActiveCimdClientMetadataUrl(clientConfig);
|
|
|
|
const serverAuthorizationParams = savedSettings
|
|
? oauthAuthorizationParamsFromSettings(savedSettings)
|
|
: undefined;
|
|
const serverEndpointOverrides = savedSettings
|
|
? oauthEndpointOverridesFromSettings(savedSettings)
|
|
: undefined;
|
|
|
|
const oauthFromServer =
|
|
savedSettings &&
|
|
(savedSettings.oauthClientId ||
|
|
savedSettings.oauthClientSecret ||
|
|
savedSettings.oauthScopes ||
|
|
serverAuthorizationParams ||
|
|
serverEndpointOverrides ||
|
|
savedSettings.enterpriseManaged ||
|
|
savedSettings.oauthRequestRefreshToken === false)
|
|
? {
|
|
...(savedSettings.oauthClientId && {
|
|
clientId: savedSettings.oauthClientId,
|
|
}),
|
|
...(savedSettings.oauthClientSecret && {
|
|
clientSecret: savedSettings.oauthClientSecret,
|
|
}),
|
|
...(savedSettings.oauthScopes && {
|
|
scope: savedSettings.oauthScopes,
|
|
}),
|
|
...(serverAuthorizationParams && {
|
|
authorizationParams: serverAuthorizationParams,
|
|
}),
|
|
...serverEndpointOverrides,
|
|
...(savedSettings.enterpriseManaged && {
|
|
enterpriseManaged: true,
|
|
}),
|
|
// #2068: only the explicit opt-out is forwarded; omitting the key
|
|
// leaves the provider's default (declare `refresh_token`) in place.
|
|
...(savedSettings.oauthRequestRefreshToken === false && {
|
|
requestRefreshToken: false,
|
|
}),
|
|
}
|
|
: undefined;
|
|
|
|
const clientMetadataUrl =
|
|
cliOverrides?.clientMetadataUrl?.trim() || activeCimdUrl;
|
|
|
|
const oauthFromCli =
|
|
cliOverrides?.clientId || cliOverrides?.clientSecret || clientMetadataUrl
|
|
? {
|
|
...(cliOverrides?.clientId && { clientId: cliOverrides.clientId }),
|
|
...(cliOverrides?.clientSecret && {
|
|
clientSecret: cliOverrides.clientSecret,
|
|
}),
|
|
...(clientMetadataUrl && { clientMetadataUrl }),
|
|
}
|
|
: undefined;
|
|
|
|
const oauth =
|
|
oauthFromServer || oauthFromCli
|
|
? {
|
|
...(oauthFromServer ?? {}),
|
|
...(oauthFromCli ?? {}),
|
|
}
|
|
: undefined;
|
|
|
|
return {
|
|
...(oauth && { oauth }),
|
|
...(activeIdp && {
|
|
enterpriseManagedAuth: { idp: activeIdp },
|
|
}),
|
|
...(clientConfig.enterpriseManagedAuth && {
|
|
installEnterpriseManagedAuth: clientConfig.enterpriseManagedAuth,
|
|
}),
|
|
...(oauth && { directAuthRecovery: true }),
|
|
};
|
|
}
|