feat(web,core): enterprise-managed authorization (EMA) (#1509) (#1512)

Adds enterprise-managed authorization (EMA): browser-side IdP login + RFC 8693 token-exchange and JWT-bearer resource-token mint, per-server EMA opt-in in Server Settings, IdP config in Client Settings, and IdP clientSecret keychain storage. Includes the v2/main merge and review follow-ups (400 on malformed client config; normalizeIdpIssuer import consolidation).
This commit is contained in:
Bob Dickinson 2026-06-24 19:15:12 -07:00 • committed by GitHub
commit 5de4e911e3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 7585 additions and 595 deletions

View file

@ -22,3 +22,8 @@ export function getServerType(config: MCPServerConfig): ServerType {
`Invalid server type: ${type}. Valid types are: stdio, sse, streamable-http`,
);
}
/** OAuth and enterprise-managed auth apply only to remote HTTP-based transports. */
export function isOAuthCapableServerType(type: ServerType): boolean {
return type === "sse" || type === "streamable-http";
}

View file

@ -11,7 +11,7 @@ export type {
// Re-export type-safe event target types for consumers
export type { InspectorClientEventMap } from "./inspectorClientEventTarget.js";
export { getServerType } from "./config.js";
export { getServerType, isOAuthCapableServerType } from "./config.js";
// Re-export types used by consumers
export type {

View file

@ -118,7 +118,7 @@ import {
UrlElicitationLoopError,
} from "./urlElicitation.js";
import { ToolCallCancelledError } from "./toolCallCancelledError.js";
import type { AuthGuidedState, OAuthStep } from "../auth/types.js";
import type { OAuthConnectionState, OAuthFlowState, OAuthStep } from "../auth/types.js";
import type { OAuthTokens } from "@modelcontextprotocol/sdk/shared/auth.js";
import { silentLogger, type InspectorLogger } from "../logging/logger.js";
import { createFetchTracker } from "./fetchTracking.js";
@ -306,6 +306,8 @@ export class InspectorClient extends InspectorClientEventTarget {
return Promise.resolve();
},
initialConfig: oauthConfig,
enterpriseManagedAuth: options.enterpriseManagedAuth,
installEnterpriseManagedAuth: options.installEnterpriseManagedAuth,
dispatchOAuthStepChange: (detail) =>
this.dispatchTypedEvent("oauthStepChange", detail),
dispatchOAuthComplete: (detail) =>
@ -362,6 +364,11 @@ export class InspectorClient extends InspectorClientEventTarget {
},
};
}
if (options.oauth?.enterpriseManaged) {
capabilities.extensions = {
"io.modelcontextprotocol/enterprise-managed-authorization": {},
};
}
if (Object.keys(capabilities).length > 0) {
clientOptions.capabilities = capabilities;
}
@ -702,7 +709,21 @@ export class InspectorClient extends InspectorClientEventTarget {
};
const oauthManager = this.oauthManager;
if (this.isHttpOAuthConfig() && oauthManager) {
if (oauthManager.isEnterpriseManaged()) {
await oauthManager.trySilentEnterpriseManagedAuth();
}
const provider = await oauthManager.createOAuthProviderForTransport();
if (oauthManager.isEnterpriseManaged()) {
const tokens = await provider.tokens();
if (!tokens?.access_token) {
const err = new Error(
"Unauthorized: EMA resource access token unavailable",
) as Error & { status?: number; code?: number };
err.status = 401;
err.code = 401;
throw err;
}
}
transportOptions.authProvider = provider;
}
const { transport: baseTransport } = this.transportClientFactory(
@ -2655,7 +2676,7 @@ export class InspectorClient extends InspectorClientEventTarget {
* Initiates OAuth flow using SDK's auth() function (normal mode)
* Can be called directly by user or automatically triggered by 401 errors
*/
async authenticate(): Promise<URL> {
async authenticate(): Promise<URL | undefined> {
return this.ensureOAuthManager().authenticate();
}
@ -2733,17 +2754,30 @@ export class InspectorClient extends InspectorClientEventTarget {
}
/**
* Get current OAuth state machine state (for guided mode)
* In-memory OAuth flow snapshot (quick or guided). Undefined when no flow
* has run on this client instance; use {@link getOAuthState} for persisted
* authorization state.
*/
getOAuthState(): AuthGuidedState | undefined {
return this.oauthManager?.getOAuthState();
getOAuthFlowState(): OAuthFlowState | undefined {
return this.oauthManager?.getOAuthFlowState();
}
/**
* Get current OAuth step (for guided mode)
* Current step when an OAuth flow is active (quick or guided).
*/
getOAuthStep(): OAuthStep | undefined {
return this.oauthManager?.getOAuthStep();
getOAuthFlowStep(): OAuthStep | undefined {
return this.oauthManager?.getOAuthFlowStep();
}
/**
* Persisted OAuth authorization snapshot for this HTTP server (storage +
* config). Undefined for stdio transports or when OAuth is not configured.
*/
async getOAuthState(): Promise<OAuthConnectionState | undefined> {
if (!this.isHttpOAuthConfig() || !this.oauthManager) {
return undefined;
}
return this.oauthManager.getOAuthState();
}
/**

View file

@ -39,7 +39,7 @@ import type { SamplingCreateMessage } from "./samplingCreateMessage.js";
import type { ElicitationCreateMessage } from "./elicitationCreateMessage.js";
import type { JsonValue } from "../json/jsonUtils.js";
import type { OAuthTokens } from "@modelcontextprotocol/sdk/shared/auth.js";
import type { OAuthStep, AuthGuidedState } from "../auth/types.js";
import type { OAuthStep, OAuthFlowState } from "../auth/types.js";
/** Task with createdAt optional so we can emit synthetic tasks (e.g. on result/error) that omit it. */
export type TaskWithOptionalCreatedAt = Omit<Task, "createdAt"> & {
@ -149,7 +149,7 @@ export interface InspectorClientEventMap {
oauthStepChange: {
step: OAuthStep;
previousStep: OAuthStep;
state: Partial<AuthGuidedState>;
state: Partial<OAuthFlowState>;
};
oauthComplete: { tokens: OAuthTokens };
oauthAuthorizationRequired: { url: URL };

View file

@ -1,20 +1,37 @@
/**
* Internal OAuth sub-manager for InspectorClient.
* Holds OAuth config, state machine, and guided state; orchestrates normal and guided flows.
* Holds OAuth config, state machine, and guided state; orchestrates quick and guided flows.
* Not part of the public API; InspectorClient delegates to this module.
*/
import { BaseOAuthClientProvider } from "../auth/providers.js";
import type { AuthGuidedState, OAuthStep } from "../auth/types.js";
import { EMPTY_GUIDED_STATE } from "../auth/types.js";
import type { AuthExecution, OAuthFlowState, OAuthStep } from "../auth/types.js";
import { EMPTY_OAUTH_FLOW_STATE } from "../auth/types.js";
import { OAuthStateMachine } from "../auth/state-machine.js";
import type { OAuthTokens } from "@modelcontextprotocol/sdk/shared/auth.js";
import { auth } from "@modelcontextprotocol/sdk/client/auth.js";
import type { OAuthClientInformation } from "@modelcontextprotocol/sdk/shared/auth.js";
import { parseOAuthState } from "../auth/utils.js";
import type { EnterpriseManagedAuthIdpConfig } from "../client/types.js";
import type { ClientConfig } from "../client/types.js";
import { EmaClientNotConfiguredError } from "../auth/ema/clientConfigError.js";
import {
completeEmaIdpAuthorizationAndMint,
refreshEmaResourceTokens,
startEmaIdpAuthorization,
trySilentEmaAuth,
type EmaFlowConfig,
} from "../auth/ema/emaFlow.js";
import {
buildOAuthConnectionState,
isServerOAuthConfigured,
protocolFromOAuthConfig,
} from "../auth/connection-state.js";
import type { OAuthConnectionState } from "../auth/types.js";
import { EmaTransportOAuthProvider } from "../auth/ema/transportProvider.js";
import type {
InspectorClientOptions,
InspectorClientEnvironment,
InspectorClientOptions,
} from "./types.js";
export type OAuthManagerConfig = NonNullable<InspectorClientOptions["oauth"]> &
@ -26,10 +43,13 @@ export interface OAuthManagerParams {
getEventTarget: () => EventTarget;
onBeforeOAuthRedirect?: (sessionId: string) => Promise<void>;
initialConfig: OAuthManagerConfig;
enterpriseManagedAuth?: { idp: EnterpriseManagedAuthIdpConfig };
/** Install-level EMA block (including when disabled) for user-facing errors. */
installEnterpriseManagedAuth?: ClientConfig["enterpriseManagedAuth"];
dispatchOAuthStepChange: (detail: {
step: OAuthStep;
previousStep: OAuthStep;
state: Partial<AuthGuidedState>;
state: Partial<OAuthFlowState>;
}) => void;
dispatchOAuthComplete: (detail: { tokens: OAuthTokens }) => void;
dispatchOAuthAuthorizationRequired: (detail: { url: URL }) => void;
@ -44,7 +64,7 @@ export class OAuthManager {
private params: OAuthManagerParams;
private oauthConfig: OAuthManagerConfig;
private oauthStateMachine: OAuthStateMachine | null = null;
private oauthState: AuthGuidedState | null = null;
private oauthFlowState: OAuthFlowState | null = null;
constructor(params: OAuthManagerParams) {
this.params = params;
@ -56,6 +76,7 @@ export class OAuthManager {
clientSecret?: string;
clientMetadataUrl?: string;
scope?: string;
enterpriseManaged?: boolean;
}): void {
this.oauthConfig = {
...this.oauthConfig,
@ -68,7 +89,7 @@ export class OAuthManager {
}
private async createOAuthProvider(
mode: "normal" | "guided",
execution: AuthExecution,
): Promise<BaseOAuthClientProvider> {
if (
!this.oauthConfig.storage ||
@ -89,7 +110,7 @@ export class OAuthManager {
navigation: this.oauthConfig.navigation,
clientMetadataUrl: this.oauthConfig.clientMetadataUrl,
},
mode,
execution,
);
provider.setEventTarget(this.params.getEventTarget());
@ -111,8 +132,82 @@ export class OAuthManager {
return provider;
}
async authenticate(): Promise<URL> {
const provider = await this.createOAuthProvider("normal");
/** Whether this connection uses enterprise-managed authorization (EMA). */
isEnterpriseManaged(): boolean {
return this.oauthConfig.enterpriseManaged === true;
}
private getEmaFlowConfig(): EmaFlowConfig {
if (!this.oauthConfig.storage || !this.oauthConfig.redirectUrlProvider) {
throw new Error(
"OAuth environment components (storage, redirectUrlProvider) are required.",
);
}
const idp = this.params.enterpriseManagedAuth?.idp;
if (!idp) {
const install = this.params.installEnterpriseManagedAuth;
throw new EmaClientNotConfiguredError(
install?.enabled === false && install.idp
? "disabled"
: "not_configured",
);
}
return {
serverUrl: this.getServerUrl(),
idp,
resourceClientId: this.oauthConfig.clientId,
resourceClientSecret: this.oauthConfig.clientSecret,
scope: this.oauthConfig.scope,
redirectUrl: this.oauthConfig.redirectUrlProvider.getRedirectUrl("quick"),
storage: this.oauthConfig.storage,
fetchFn: this.params.effectiveAuthFetch,
};
}
/** Attempt silent EMA (cached IdP session + legs 2–3). */
async trySilentEnterpriseManagedAuth(): Promise<boolean> {
if (!this.isEnterpriseManaged()) return false;
const result = await trySilentEmaAuth(this.getEmaFlowConfig());
if (result.status === "success") return true;
if (result.status === "mint_failed") throw result.error;
return false;
}
private async authenticateEnterpriseManaged(): Promise<URL | undefined> {
const config = this.getEmaFlowConfig();
const silent = await trySilentEmaAuth(config);
if (silent.status === "success") {
return undefined;
}
if (silent.status === "mint_failed") {
throw silent.error;
}
const authorizationUrl = await startEmaIdpAuthorization(config);
const stateParam = authorizationUrl.searchParams.get("state");
if (stateParam && this.params.onBeforeOAuthRedirect) {
const parsedState = parseOAuthState(stateParam);
if (parsedState?.authId) {
await this.params.onBeforeOAuthRedirect(parsedState.authId);
}
}
this.oauthConfig.navigation!.navigateToAuthorization(authorizationUrl);
this.oauthFlowState = {
...EMPTY_OAUTH_FLOW_STATE,
execution: "quick",
oauthStep: "authorization_code",
authorizationUrl,
};
return authorizationUrl;
}
async authenticate(): Promise<URL | undefined> {
if (this.isEnterpriseManaged()) {
return this.authenticateEnterpriseManaged();
}
const provider = await this.createOAuthProvider("quick");
const serverUrl = this.getServerUrl();
provider.clearCapturedAuthUrl();
@ -143,9 +238,9 @@ export class OAuthManager {
}
const clientInfo = await provider.clientInformation();
this.oauthState = {
...EMPTY_GUIDED_STATE,
authType: "normal",
this.oauthFlowState = {
...EMPTY_OAUTH_FLOW_STATE,
execution: "quick",
oauthStep: "authorization_code",
authorizationUrl: capturedUrl,
oauthClientInfo: clientInfo ?? null,
@ -157,9 +252,9 @@ export class OAuthManager {
const provider = await this.createOAuthProvider("guided");
const serverUrl = this.getServerUrl();
this.oauthState = { ...EMPTY_GUIDED_STATE };
this.oauthFlowState = { ...EMPTY_OAUTH_FLOW_STATE };
if (this.oauthConfig.clientId) {
this.oauthState.oauthClientInfo = {
this.oauthFlowState.oauthClientInfo = {
client_id: this.oauthConfig.clientId,
...(this.oauthConfig.clientSecret && {
client_secret: this.oauthConfig.clientSecret,
@ -170,12 +265,12 @@ export class OAuthManager {
serverUrl,
provider,
(updates) => {
const state = this.oauthState;
const state = this.oauthFlowState;
if (!state) throw new Error("OAuth state not initialized");
const previousStep = state.oauthStep;
this.oauthState = { ...state, ...updates };
this.oauthFlowState = { ...state, ...updates };
if (updates.oauthStep === "complete") {
this.oauthState!.completedAt = Date.now();
this.oauthFlowState!.completedAt = Date.now();
}
const step = updates.oauthStep ?? previousStep;
this.params.dispatchOAuthStepChange({
@ -187,11 +282,11 @@ export class OAuthManager {
this.params.effectiveAuthFetch,
);
await this.oauthStateMachine.executeStep(this.oauthState);
await this.oauthStateMachine.executeStep(this.oauthFlowState);
}
async runGuidedAuth(): Promise<URL | undefined> {
if (!this.oauthStateMachine || !this.oauthState) {
if (!this.oauthStateMachine || !this.oauthFlowState) {
await this.beginGuidedAuth();
}
@ -201,7 +296,7 @@ export class OAuthManager {
}
while (true) {
const state = this.oauthState;
const state = this.oauthFlowState;
if (!state) {
throw new Error("Guided auth failed to initialize state");
}
@ -214,7 +309,7 @@ export class OAuthManager {
await machine.executeStep(state);
}
const state = this.oauthState;
const state = this.oauthFlowState;
if (state?.oauthStep === "complete") {
return undefined;
}
@ -241,39 +336,39 @@ export class OAuthManager {
authorizationCode: string,
completeFlow: boolean = false,
): Promise<void> {
if (!this.oauthStateMachine || !this.oauthState) {
if (!this.oauthStateMachine || !this.oauthFlowState) {
throw new Error(
"Not in guided OAuth flow. Call beginGuidedAuth() first.",
);
}
const currentStep = this.oauthState.oauthStep;
const currentStep = this.oauthFlowState.oauthStep;
if (currentStep !== "authorization_code") {
throw new Error(
`Cannot set authorization code at step ${currentStep}. Expected step: authorization_code`,
);
}
this.oauthState.authorizationCode = authorizationCode;
this.oauthFlowState.authorizationCode = authorizationCode;
if (completeFlow) {
await this.oauthStateMachine.executeStep(this.oauthState);
let step: OAuthStep = this.oauthState.oauthStep;
await this.oauthStateMachine.executeStep(this.oauthFlowState);
let step: OAuthStep = this.oauthFlowState.oauthStep;
while (step !== "complete") {
await this.oauthStateMachine.executeStep(this.oauthState);
step = this.oauthState.oauthStep;
await this.oauthStateMachine.executeStep(this.oauthFlowState);
step = this.oauthFlowState.oauthStep;
}
if (!this.oauthState.oauthTokens) {
if (!this.oauthFlowState.oauthTokens) {
throw new Error("Failed to exchange authorization code for tokens");
}
this.params.dispatchOAuthComplete({
tokens: this.oauthState.oauthTokens,
tokens: this.oauthFlowState.oauthTokens,
});
} else {
this.params.dispatchOAuthStepChange({
step: this.oauthState.oauthStep,
previousStep: this.oauthState.oauthStep,
step: this.oauthFlowState.oauthStep,
previousStep: this.oauthFlowState.oauthStep,
state: { authorizationCode },
});
}
@ -281,10 +376,28 @@ export class OAuthManager {
async completeOAuthFlow(authorizationCode: string): Promise<void> {
try {
if (this.oauthStateMachine && this.oauthState) {
if (this.isEnterpriseManaged()) {
const config = this.getEmaFlowConfig();
const tokens = await completeEmaIdpAuthorizationAndMint(
config,
authorizationCode,
);
const completedAt = Date.now();
this.oauthFlowState = {
...EMPTY_OAUTH_FLOW_STATE,
execution: "quick",
oauthStep: "complete",
oauthTokens: tokens,
completedAt,
};
this.params.dispatchOAuthComplete({ tokens });
return;
}
if (this.oauthStateMachine && this.oauthFlowState) {
await this.setGuidedAuthorizationCode(authorizationCode, true);
} else {
const provider = await this.createOAuthProvider("normal");
const provider = await this.createOAuthProvider("quick");
const serverUrl = this.getServerUrl();
const result = await auth(provider, {
@ -306,17 +419,17 @@ export class OAuthManager {
const clientInfo = await provider.clientInformation();
const completedAt = Date.now();
this.oauthState = this.oauthState
this.oauthFlowState = this.oauthFlowState
? {
...this.oauthState,
...this.oauthFlowState,
oauthStep: "complete",
oauthTokens: tokens,
oauthClientInfo: clientInfo ?? null,
completedAt,
}
: {
...EMPTY_GUIDED_STATE,
authType: "normal",
...EMPTY_OAUTH_FLOW_STATE,
execution: "quick",
oauthStep: "complete",
oauthTokens: tokens,
oauthClientInfo: clientInfo ?? null,
@ -334,11 +447,11 @@ export class OAuthManager {
}
async getOAuthTokens(): Promise<OAuthTokens | undefined> {
if (this.oauthState?.oauthTokens) {
return this.oauthState.oauthTokens;
if (this.oauthFlowState?.oauthTokens) {
return this.oauthFlowState.oauthTokens;
}
const provider = await this.createOAuthProvider("normal");
const provider = await this.createOAuthProvider("quick");
try {
return await provider.tokens();
} catch {
@ -354,7 +467,7 @@ export class OAuthManager {
const serverUrl = this.getServerUrl();
this.oauthConfig.storage.clear(serverUrl);
this.oauthState = null;
this.oauthFlowState = null;
this.oauthStateMachine = null;
}
@ -363,29 +476,63 @@ export class OAuthManager {
return tokens !== undefined;
}
getOAuthState(): AuthGuidedState | undefined {
return this.oauthState ? { ...this.oauthState } : undefined;
getOAuthFlowState(): OAuthFlowState | undefined {
return this.oauthFlowState ? { ...this.oauthFlowState } : undefined;
}
getOAuthStep(): OAuthStep | undefined {
return this.oauthState?.oauthStep;
getOAuthFlowStep(): OAuthStep | undefined {
return this.oauthFlowState?.oauthStep;
}
/**
* Persisted OAuth authorization snapshot for this server (storage + config).
* Returns undefined when OAuth is not configured for the server.
*/
async getOAuthState(): Promise<OAuthConnectionState | undefined> {
if (!isServerOAuthConfigured(this.oauthConfig)) {
return undefined;
}
return buildOAuthConnectionState({
serverUrl: this.getServerUrl(),
protocol: protocolFromOAuthConfig(this.oauthConfig),
configuredScope: this.oauthConfig.scope,
enterpriseManagedAuth: this.params.enterpriseManagedAuth,
storage: this.oauthConfig.storage!,
flowState: this.oauthFlowState ?? undefined,
});
}
async proceedOAuthStep(): Promise<void> {
if (!this.oauthStateMachine || !this.oauthState) {
if (!this.oauthStateMachine || !this.oauthFlowState) {
throw new Error(
"Not in guided OAuth flow. Call authenticateGuided() first.",
);
}
await this.oauthStateMachine.executeStep(this.oauthState);
await this.oauthStateMachine.executeStep(this.oauthFlowState);
}
/**
* Re-run EMA legs 2–3 when resource tokens expire but IdP session remains valid.
*/
async refreshEnterpriseManagedTokens(): Promise<boolean> {
if (!this.isEnterpriseManaged()) return false;
const tokens = await refreshEmaResourceTokens(this.getEmaFlowConfig());
return tokens !== undefined;
}
/**
* Create an OAuth provider for transport auth (connect()).
* Used only when isHttpOAuthConfig() is true.
*/
async createOAuthProviderForTransport(): Promise<BaseOAuthClientProvider> {
return this.createOAuthProvider("normal");
async createOAuthProviderForTransport(): Promise<
BaseOAuthClientProvider | EmaTransportOAuthProvider
> {
const provider = await this.createOAuthProvider("quick");
if (this.isEnterpriseManaged()) {
return new EmaTransportOAuthProvider(provider, this.getEmaFlowConfig());
}
return provider;
}
}

View file

@ -59,7 +59,14 @@ import {
KeyringSecretStore,
type SecretStore,
} from "../../../auth/node/secret-store.js";
import {
deleteClientConfigStore,
readClientConfigStore,
writeClientConfigStore,
} from "../../../client/node-persistence.js";
import { formatClientConfigLoadError } from "../../../client/config-parse.js";
import { envSecretField } from "../../../auth/secret-fields.js";
import { ZodError } from "zod";
/**
* Shape of the initial config returned by GET /api/config (defaults for client).
@ -808,6 +815,11 @@ export function createRemoteApp(
const filePath = getStoreFilePath(storageDir, storeId);
try {
if (storeId === "client") {
const config = await readClientConfigStore(filePath, secretStore);
return c.json(config);
}
const raw = await readStoreFile(filePath);
if (raw === null) {
return c.json({}, 200);
@ -836,10 +848,25 @@ export function createRemoteApp(
const filePath = getStoreFilePath(storageDir, storeId);
try {
if (storeId === "client") {
await writeClientConfigStore(filePath, body, secretStore);
return c.json({ ok: true });
}
const jsonData = serializeStore(body);
await writeStoreFile(filePath, jsonData);
return c.json({ ok: true });
} catch (error) {
// A malformed client.json body fails `parseClientConfig` with a ZodError
// — that's a client error (bad request), not a server failure. Return 400
// with the same human-readable formatting the load path uses, rather than
// letting it fall through to the generic 500 below.
if (error instanceof ZodError) {
return c.json({ error: formatClientConfigLoadError(error) }, 400);
}
const keychainResp =
storeId === "client" ? keychainErrorResponse(c, error) : undefined;
if (keychainResp) return keychainResp;
const msg = error instanceof Error ? error.message : String(error);
return c.json({ error: `Failed to write store: ${msg}` }, 500);
}
@ -854,6 +881,11 @@ export function createRemoteApp(
const filePath = getStoreFilePath(storageDir, storeId);
try {
if (storeId === "client") {
await deleteClientConfigStore(filePath, secretStore);
return c.json({ ok: true });
}
await deleteStoreFile(filePath);
return c.json({ ok: true });
} catch (error) {
@ -910,12 +942,23 @@ export function createRemoteApp(
};
const isOauthObject = (
v: unknown,
): v is { clientId?: string; clientSecret?: string; scopes?: string } => {
): v is {
clientId?: string;
clientSecret?: string;
scopes?: 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) {
if (o[k] !== undefined && typeof o[k] !== "string") return false;
}
if (
o.enterpriseManaged !== undefined &&
typeof o.enterpriseManaged !== "boolean"
) {
return false;
}
return true;
};
// A roots array: each entry must have a string `uri` and, when present, a
@ -1021,7 +1064,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? }`.",
"Dropping malformed `oauth` field — expected `{ clientId?, clientSecret?, scopes?, enterpriseManaged? }`.",
);
delete valObj.oauth;
}
@ -1260,6 +1303,15 @@ export function createRemoteApp(
return { ok: false, error: `settings.${optional} must be a string` };
}
}
if (
obj.enterpriseManaged !== undefined &&
typeof obj.enterpriseManaged !== "boolean"
) {
return {
ok: false,
error: "settings.enterpriseManaged must be a boolean",
};
}
// roots is optional on the wire (older clients won't send it); when
// present it must be an array of `{ uri, name? }`. Reuses the same
// module-scope `isRootArray` guard the read path applies in
@ -1319,6 +1371,9 @@ export function createRemoteApp(
if (typeof obj.oauthScopes === "string" && obj.oauthScopes !== "") {
value.oauthScopes = obj.oauthScopes;
}
if (obj.enterpriseManaged === true) {
value.enterpriseManaged = true;
}
// Empty cwd coerces to absent on the value (matching the read side); the
// write-through distinguishes "sent cwd: '' " (clear) from "cwd omitted"
// (preserve) via `cwdProvided` below rather than the value alone.
@ -1524,6 +1579,27 @@ export function createRemoteApp(
return obsolete;
};
/**
* Merge keychain secrets for a server-id rename. PUT payload values win
* over the old id's keychain entries; keychain fills fields missing from
* the payload (e.g. config-modal rename that does not re-send stripped
* secrets). Filtered to the new on-disk entry's expected fields so env
* keys removed during the rename are not carried over.
*/
const mergeRenameKeychainSecrets = (
stripped: StoredMCPServer,
keychainSecrets: Record<string, string>,
nextSecrets: Record<string, string>,
): Record<string, string> => {
const allowedFields = new Set(expectedSecretFields(stripped));
const merged = { ...keychainSecrets, ...nextSecrets };
const out: Record<string, string> = {};
for (const [field, value] of Object.entries(merged)) {
if (allowedFields.has(field)) out[field] = value;
}
return out;
};
// Parallel for symmetry with `readKeychainEntriesFor` /
// `writeKeychainEntriesFor`: distinct (id, field) deletes have no
// ordering requirement, and `secretStore.delete` is already a silent
@ -1977,7 +2053,17 @@ export function createRemoteApp(
// write leaves orphan keychain entries — recoverable on the
// next reconcile or `deleteAllForServer` sweep.
if (newId !== originalId) {
await writeKeychainEntriesFor(newId, secrets);
const previousFields = expectedSecretFields(existing);
const keychainSecrets = await readKeychainEntriesFor(
originalId,
previousFields,
);
const secretsToWrite = mergeRenameKeychainSecrets(
stripped,
keychainSecrets,
secrets,
);
await writeKeychainEntriesFor(newId, secretsToWrite);
await writeMcpAndTrackMtime(serializeStore(next));
await secretStore.deleteAllForServer(originalId);
} else {

View file

@ -1,13 +1,22 @@
import type { OAuthClientProvider } from "@modelcontextprotocol/sdk/client/auth.js";
import type { OAuthTokens } from "@modelcontextprotocol/sdk/shared/auth.js";
import type {
OAuthClientMetadata,
OAuthTokens,
} from "@modelcontextprotocol/sdk/shared/auth.js";
import type { RemoteConnectRequest } from "../types.js";
const REMOTE_OAUTH_STUB_METADATA: OAuthClientMetadata = {
redirect_uris: [],
scope: "",
};
/**
* Simple OAuth client provider that just returns tokens.
* Used by the remote server to inject Bearer tokens into transport requests.
* The other OAuthClientProvider methods are required by the interface but never
* exercised in the remote-server path (the SDK only calls `tokens()` for Bearer
* injection); they are kept as no-op stubs.
*
* The SDK may invoke {@link auth} on 401; stubs must satisfy the full provider
* surface so that path fails with a clear error instead of throwing on
* undefined `clientMetadata.scope`.
*/
export function createTokenAuthProvider(
tokens: RemoteConnectRequest["oauthTokens"],
@ -15,6 +24,12 @@ export function createTokenAuthProvider(
if (!tokens) return undefined;
return {
get clientMetadata(): OAuthClientMetadata {
return REMOTE_OAUTH_STUB_METADATA;
},
get redirectUrl(): string {
return "";
},
async tokens(): Promise<OAuthTokens | undefined> {
return tokens as OAuthTokens;
},
@ -33,8 +48,10 @@ export function createTokenAuthProvider(
clear() {
// No-op
},
redirectToAuthorization() {
// No-op
async redirectToAuthorization() {
throw new Error(
"OAuth re-authorization must be performed in the Inspector web client (remote server cannot complete OAuth flows)",
);
},
state() {
return "";

View file

@ -199,6 +199,9 @@ export function storedFieldsToInspectorSettings(
if (stored.oauth?.clientSecret)
settings.oauthClientSecret = stored.oauth.clientSecret;
if (stored.oauth?.scopes) settings.oauthScopes = stored.oauth.scopes;
if (stored.oauth?.enterpriseManaged === true) {
settings.enterpriseManaged = true;
}
// Mirror the stdio working directory for the form. Like the OAuth fields, an
// empty string coerces to absent so the form's "(inherit)" placeholder shows.
if (stored.cwd) settings.cwd = stored.cwd;
@ -268,11 +271,15 @@ export function inspectorSettingsToStoredFields(
clientId?: string;
clientSecret?: string;
scopes?: string;
enterpriseManaged?: boolean;
} = {};
if (settings.oauthClientId) oauthFields.clientId = settings.oauthClientId;
if (settings.oauthClientSecret)
oauthFields.clientSecret = settings.oauthClientSecret;
if (settings.oauthScopes) oauthFields.scopes = settings.oauthScopes;
if (settings.enterpriseManaged === true) {
oauthFields.enterpriseManaged = true;
}
if (Object.keys(oauthFields).length > 0) {
out.oauth = oauthFields;
}
@ -467,11 +474,18 @@ export function extractSecretsFromStored(
if (stored.oauth?.clientSecret) {
secrets[SECRET_FIELD_OAUTH_CLIENT_SECRET] = stored.oauth.clientSecret;
const restOauth: { clientId?: string; scopes?: string } = {};
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;
}
if (Object.keys(restOauth).length > 0) {
stripped.oauth = restOauth;
} else {

View file

@ -23,6 +23,10 @@ import type { OAuthClientProvider } from "@modelcontextprotocol/sdk/client/auth.
import type { Transport } from "@modelcontextprotocol/sdk/shared/transport.js";
import type { InspectorLogger } from "../logging/logger.js";
import type { JsonValue } from "../json/jsonUtils.js";
import type {
ClientConfig,
EnterpriseManagedAuthIdpConfig,
} from "../client/types.js";
import type {
OAuthNavigation,
RedirectUrlProvider,
@ -128,6 +132,8 @@ export type StoredMCPServer = MCPServerConfig & {
clientId?: string;
clientSecret?: string;
scopes?: string;
/** When true, connect via enterprise IdP (EMA) instead of standard resource OAuth. */
enterpriseManaged?: boolean;
};
/**
* Filesystem/URI roots advertised to the server via the `roots` client
@ -397,6 +403,7 @@ export interface OAuthSettings {
clientId: string;
clientSecret: string;
scopes: string;
enterpriseManaged?: boolean;
}
/**
@ -445,6 +452,12 @@ export interface InspectorServerSettings {
oauthClientId?: string;
oauthClientSecret?: string;
oauthScopes?: string;
/**
* When true, connect via the configured enterprise IdP (EMA) instead of
* interactive OAuth to the MCP authorization server. Per-server OAuth
* fields below are resource AS credentials. (#1509)
*/
enterpriseManaged?: boolean;
/**
* When true, lists auto-refresh on `list_changed` notifications; when
* false (default), the notification only lights the list-changed indicator
@ -715,8 +728,24 @@ export interface InspectorClientOptions {
clientSecret?: string;
clientMetadataUrl?: string;
scope?: string;
/** Route to EMA flow when true (resource AS creds in clientId/clientSecret). */
enterpriseManaged?: boolean;
};
/**
* Global enterprise IdP credentials (from client.json). Used for EMA legs 1–2
* when {@link oauth.enterpriseManaged} is true on the server.
*/
enterpriseManagedAuth?: {
idp: EnterpriseManagedAuthIdpConfig;
};
/**
* Full install-level EMA config from client.json (including when disabled).
* Used to produce friendly errors when a server expects EMA but IdP is inactive.
*/
installEnterpriseManagedAuth?: ClientConfig["enterpriseManagedAuth"];
/**
* Optional session ID. If not provided, will be extracted from OAuth state
* when OAuth flow starts. Passed in saveSession event for FetchRequestLogState.