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
121 changed files with 7585 additions and 595 deletions

View file

@ -1035,9 +1035,6 @@
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@ -1055,9 +1052,6 @@
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@ -1075,9 +1069,6 @@
"ppc64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@ -1095,9 +1086,6 @@
"s390x"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@ -1115,9 +1103,6 @@
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@ -1135,9 +1120,6 @@
"x64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@ -3677,9 +3659,6 @@
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MPL-2.0",
"optional": true,
"os": [
@ -3701,9 +3680,6 @@
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MPL-2.0",
"optional": true,
"os": [
@ -3725,9 +3701,6 @@
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MPL-2.0",
"optional": true,
"os": [
@ -3749,9 +3722,6 @@
"x64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MPL-2.0",
"optional": true,
"os": [

View file

@ -618,9 +618,6 @@
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@ -638,9 +635,6 @@
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@ -658,9 +652,6 @@
"ppc64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@ -678,9 +669,6 @@
"s390x"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@ -698,9 +686,6 @@
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@ -718,9 +703,6 @@
"x64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@ -2192,9 +2174,6 @@
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MPL-2.0",
"optional": true,
"os": [
@ -2216,9 +2195,6 @@
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MPL-2.0",
"optional": true,
"os": [
@ -2240,9 +2216,6 @@
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MPL-2.0",
"optional": true,
"os": [
@ -2264,9 +2237,6 @@
"x64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MPL-2.0",
"optional": true,
"os": [

View file

@ -561,7 +561,7 @@ function App({
// Shared ref for OAuth callback server; stop before starting new (avoids EADDRINUSE when prior auth failed without redirect)
const callbackServerRef = useRef<OAuthCallbackServer | null>(null);
// OAuth Quick Auth (normal mode; callback server + open URL)
// OAuth Quick Auth (quick execution; callback server + open URL)
const handleQuickAuth = useCallback(async () => {
if (
!selectedServer ||
@ -620,8 +620,10 @@ function App({
if (redirectUrlProvider) {
redirectUrlProvider.redirectUrl = redirectUrl;
}
await selectedInspectorClient.authenticate();
await flowDone;
const authUrl = await selectedInspectorClient.authenticate();
if (authUrl !== undefined) {
await flowDone;
}
setOauthStatus("success");
setOauthMessage("OAuth complete. Press C to connect.");
} catch (err) {
@ -719,7 +721,7 @@ function App({
);
try {
await selectedInspectorClient.proceedOAuthStep();
const state = selectedInspectorClient.getOAuthState();
const state = selectedInspectorClient.getOAuthFlowState();
if (state?.oauthStep === "authorization_code" && state.authorizationUrl) {
await openUrl(state.authorizationUrl);
}

View file

@ -6,7 +6,7 @@ import type {
MCPServerConfig,
InspectorClient,
} from "@inspector/core/mcp/index.js";
import type { AuthGuidedState, OAuthStep } from "@inspector/core/auth/index.js";
import type { OAuthFlowState, OAuthStep } from "@inspector/core/auth/index.js";
const STEP_LABELS: Record<OAuthStep, string> = {
metadata_discovery: "Metadata Discovery",
@ -68,21 +68,21 @@ export function AuthTab({
isOAuthCapable,
}: AuthTabProps) {
const scrollViewRef = useRef<ScrollViewRef>(null);
const [oauthState, setOauthState] = useState<AuthGuidedState | undefined>(
undefined,
);
const [oauthFlowState, setOauthFlowState] = useState<
OAuthFlowState | undefined
>(undefined);
const [guidedStarted, setGuidedStarted] = useState(false);
const [clearedConfirmation, setClearedConfirmation] = useState(false);
// Sync oauthState from InspectorClient
// Sync oauthFlowState from InspectorClient
useEffect(() => {
if (!inspectorClient) {
setOauthState(undefined);
setOauthFlowState(undefined);
setGuidedStarted(false);
return;
}
const update = () => setOauthState(inspectorClient.getOAuthState());
const update = () => setOauthFlowState(inspectorClient.getOAuthFlowState());
update();
const onStepChange = () => update();
@ -106,10 +106,10 @@ export function AuthTab({
}
}, [selectedAction]);
const guidedFlowStarted = !!oauthState?.oauthStep;
const currentStep = oauthState?.oauthStep ?? "metadata_discovery";
const guidedFlowStarted = !!oauthFlowState?.oauthStep;
const currentStep = oauthFlowState?.oauthStep ?? "metadata_discovery";
const needsAuthCode =
currentStep === "authorization_code" && oauthState?.authorizationUrl;
currentStep === "authorization_code" && oauthFlowState?.authorizationUrl;
const isComplete = currentStep === "complete";
const handleContinue = useCallback(async () => {
@ -268,8 +268,8 @@ export function AuthTab({
(step === currentStep && isComplete));
const inProgress =
guidedFlowStarted && step === currentStep && !isComplete;
const details = oauthState
? getStepDetails(oauthState, step)
const details = oauthFlowState
? getStepDetails(oauthFlowState, step)
: null;
const icon = completed ? "✓" : inProgress ? "→" : "○";
@ -305,22 +305,24 @@ export function AuthTab({
})}
{/* Waiting for auth - URL was opened when we reached this step */}
{oauthState && needsAuthCode && oauthState?.authorizationUrl && (
<Box marginTop={2} flexDirection="column">
<Text bold>Authorization URL opened in browser</Text>
<Box marginTop={1}>
<Text dimColor>
{oauthState.authorizationUrl.toString()}
</Text>
{oauthFlowState &&
needsAuthCode &&
oauthFlowState?.authorizationUrl && (
<Box marginTop={2} flexDirection="column">
<Text bold>Authorization URL opened in browser</Text>
<Box marginTop={1}>
<Text dimColor>
{oauthFlowState.authorizationUrl.toString()}
</Text>
</Box>
<Box marginTop={1}>
<Text dimColor>
Complete authorization in the browser. You will be
redirected and the flow will complete automatically.
</Text>
</Box>
</Box>
<Box marginTop={1}>
<Text dimColor>
Complete authorization in the browser. You will be
redirected and the flow will complete automatically.
</Text>
</Box>
</Box>
)}
)}
</Box>
)}
@ -333,24 +335,33 @@ export function AuthTab({
<Text color="red">{oauthMessage}</Text>
)}
{oauthStatus === "success" &&
oauthState &&
oauthState.authType === "normal" &&
(oauthState.oauthTokens || oauthState.oauthClientInfo) && (
oauthFlowState &&
oauthFlowState.execution === "quick" &&
(oauthFlowState.oauthTokens ||
oauthFlowState.oauthClientInfo) && (
<>
<Text bold>Quick Auth Results</Text>
{oauthState.oauthClientInfo && (
{oauthFlowState.oauthClientInfo && (
<Box marginTop={1} flexDirection="column" paddingLeft={2}>
<Text dimColor>
Client:{" "}
{JSON.stringify(oauthState.oauthClientInfo, null, 2)}
{JSON.stringify(
oauthFlowState.oauthClientInfo,
null,
2,
)}
</Text>
</Box>
)}
{oauthState.oauthTokens && (
{oauthFlowState.oauthTokens && (
<Box marginTop={1} flexDirection="column" paddingLeft={2}>
<Text dimColor>
Access Token:{" "}
{oauthState.oauthTokens.access_token?.slice(0, 20)}...
{oauthFlowState.oauthTokens.access_token?.slice(
0,
20,
)}
...
</Text>
</Box>
)}
@ -383,10 +394,7 @@ export function AuthTab({
);
}
function getStepDetails(
state: AuthGuidedState,
step: OAuthStep,
): string | null {
function getStepDetails(state: OAuthFlowState, step: OAuthStep): string | null {
switch (step) {
case "metadata_discovery":
if (state.resourceMetadata || state.oauthMetadata) {

View file

@ -41,7 +41,9 @@ const { messageLogClear } = vi.hoisted(() => ({ messageLogClear: vi.fn() }));
// Extends EventTarget so the App's `addEventListener("disconnect", …)` wiring
// is real; the test fires `dispatchEvent(new Event("disconnect"))` to simulate
// any of the three disconnect paths (toggle, header button, transport failure).
vi.mock("@inspector/core/mcp/index.js", () => {
vi.mock("@inspector/core/mcp/index.js", async (importOriginal) => {
const actual =
await importOriginal<typeof import("@inspector/core/mcp/index.js")>();
class FakeInspectorClient extends EventTarget {
connect = vi.fn().mockResolvedValue(undefined);
disconnect = vi.fn().mockResolvedValue(undefined);
@ -66,7 +68,8 @@ vi.mock("@inspector/core/mcp/index.js", () => {
.mockResolvedValue({ resourceTemplates: [] });
listRequestorTasks = vi.fn().mockResolvedValue({ tasks: [] });
ping = vi.fn().mockResolvedValue(undefined);
getOAuthState = vi.fn().mockReturnValue(undefined);
getOAuthFlowState = vi.fn().mockReturnValue(undefined);
getOAuthState = vi.fn().mockResolvedValue(undefined);
getPendingSamples = vi.fn().mockReturnValue([]);
getPendingElicitations = vi.fn().mockReturnValue([]);
getRoots = vi.fn().mockReturnValue([]);
@ -75,6 +78,7 @@ vi.mock("@inspector/core/mcp/index.js", () => {
}
const instances: FakeInspectorClient[] = [];
return {
...actual,
InspectorClient: vi.fn(function () {
const client = new FakeInspectorClient();
instances.push(client);

View file

@ -56,6 +56,14 @@ import {
cleanRoots,
serializeMcpConfig,
} from "@inspector/core/mcp/serverList.js";
import type { ClientConfig } from "@inspector/core/client/types.js";
import { getActiveEnterpriseManagedAuthIdp } from "@inspector/core/client/types.js";
import { isEmaClientNotConfiguredError } from "@inspector/core/auth/ema/clientConfigError.js";
import {
loadClientConfigRemote,
saveClientConfigRemote,
} from "@inspector/core/client/remote.js";
import { formatClientConfigLoadError } from "@inspector/core/client/config-parse.js";
import { MessageLogState } from "@inspector/core/mcp/state/messageLogState.js";
import { FetchRequestLogState } from "@inspector/core/mcp/state/fetchRequestLogState.js";
import type { FetchRequestLogStateEventMap } from "@inspector/core/mcp/state/fetchRequestLogState.js";
@ -70,6 +78,9 @@ import { RemoteInspectorClientStorage } from "@inspector/core/mcp/remote/index.j
import { useInspectorClient } from "@inspector/core/react/useInspectorClient.js";
import { useServers } from "@inspector/core/react/useServers.js";
import { useSettingsDraft } from "@inspector/core/react/useSettingsDraft.js";
import { useClientSettingsDraft } from "@inspector/core/react/useClientSettingsDraft.js";
import { useEmaIdpLoginState } from "@inspector/core/react/useEmaIdpLoginState.js";
import { getBrowserOAuthStorage } from "@inspector/core/auth/browser/index.js";
import { useManagedTools } from "@inspector/core/react/useManagedTools.js";
import { useManagedPrompts } from "@inspector/core/react/useManagedPrompts.js";
import { useManagedResources } from "@inspector/core/react/useManagedResources.js";
@ -108,9 +119,17 @@ import {
type ServerConfigModalMode,
} from "./components/groups/ServerConfigModal/ServerConfigModal";
import { ServerSettingsModal } from "./components/groups/ServerSettingsModal/ServerSettingsModal";
import { ClientSettingsModal } from "./components/groups/ClientSettingsModal/ClientSettingsModal";
import {
canPersistClientSettingsDraft,
clientConfigToFormValues,
EMPTY_CLIENT_SETTINGS,
formValuesToClientConfig,
} from "./components/groups/ClientSettingsForm/clientSettingsValues";
import { ServerImportConfigModal } from "./components/groups/ServerImportConfigModal/ServerImportConfigModal";
import { ServerImportJsonModal } from "./components/groups/ServerImportJsonModal/ServerImportJsonModal";
import { ConnectionInfoModal } from "./components/groups/ConnectionInfoModal/ConnectionInfoModal";
import { oauthDetailsFromConnectionState } from "./components/groups/ConnectionInfoContent/oauthDetailsFromConnectionState";
import { OutputValidationModal } from "./components/groups/OutputValidationModal/OutputValidationModal";
import { UrlElicitationErrorModal } from "./components/groups/UrlElicitationErrorModal/UrlElicitationErrorModal";
import { isReplayableHistoryMethod } from "./components/groups/historyUtils.js";
@ -300,6 +319,8 @@ function bodyDroppedToastId(serverId: string): string {
return `fetch-body-dropped-${serverId}`;
}
const CLIENT_CONFIG_LOAD_ERROR_NOTIFICATION_ID = "client-config-load-error";
// Body of the "response body dropped" warning toast: a one-line summary of what
// happened, the likely causes, and a link that opens this server's settings
// (on the Options section) so the user can raise the Network Log Size if it's
@ -528,6 +549,7 @@ function App() {
const [settingsModalTargetId, setSettingsModalTargetId] = useState<
string | undefined
>(undefined);
const [clientSettingsOpen, setClientSettingsOpen] = useState(false);
const [connectionInfoModalOpen, setConnectionInfoModalOpen] = useState(false);
const [removeTarget, setRemoveTarget] = useState<ServerEntry | null>(null);
// Details for the output-schema-mismatch modal opened from the warning toast.
@ -572,6 +594,27 @@ function App() {
baseUrl: configBaseUrl,
authToken: getAuthToken(),
});
const [clientConfig, setClientConfig] = useState<ClientConfig>({});
useEffect(() => {
if (typeof window === "undefined") return;
void loadClientConfigRemote({
baseUrl: configBaseUrl,
authToken: getAuthToken(),
})
.then(setClientConfig)
.catch((err) => {
setClientConfig({});
notifications.show({
id: CLIENT_CONFIG_LOAD_ERROR_NOTIFICATION_ID,
title: "Could not load Client Settings",
message: `${formatClientConfigLoadError(err)}\n\nCheck ~/.mcp-inspector/storage/client.json or re-enter settings in Client Settings.`,
color: "red",
autoClose: false,
});
});
}, [configBaseUrl]);
const sandboxBridgeFactory = useMemo(
() =>
createAppBridgeFactory({
@ -1147,55 +1190,39 @@ function App() {
const connectionInfoTransport: ServerType =
activeServer?.config.type ?? "stdio";
// OAuth details rendered in the Connection Info modal — read from the
// active InspectorClient's guided-OAuth state machine snapshot
// (synchronous), with configured scopes pulled from the server's
// persisted settings. All three fields are independently optional; if
// none are populated we return undefined so the modal hides the OAuth
// section entirely.
//
// Snapshot-at-last-derivation semantics: the memo deps
// (`connectionStatus`, `inspectorClient`, `activeServer`) don't
// include `oauthStepChange` / `oauthComplete`, so a token refresh
// that happens while none of those change won't update the rendered
// token. The memo will still re-run on the natural triggers — server
// switch, reconnect, or a settings edit that re-references
// `activeServer` — which matches the modal's overall framing
// ("info about the connection at this moment") and avoids
// subscribing to a third event source from a dialog whose primary
// job is read-only. If we ever surface live token refresh state,
// switch to subscribing on those events.
//
// For `authUrl` we prefer the authorization-server-advertised
// `authorization_endpoint` over the full `authorizationUrl`. The
// latter is the per-flight URL the user was redirected to (with
// `state`, `code_challenge`, etc.) — informative for a debugger,
// noisy for a connection summary; the endpoint is the stable
// identifier of "which AS is in use here."
//
// Scope splitter: OAuth 2.1 §3.3 specifies space-separated scopes;
// the persisted value is config-controlled (user-typed into the
// server settings form), so the literal `" "` split is sufficient
// and matches the spec.
const connectionInfoOAuth = useMemo<OAuthDetails | undefined>(() => {
if (connectionStatus !== "connected" || !inspectorClient) return undefined;
const oauthState = inspectorClient.getOAuthState();
const authUrl =
oauthState?.oauthMetadata?.authorization_endpoint ??
oauthState?.authorizationUrl?.toString();
const accessToken = oauthState?.oauthTokens?.access_token;
const scopes = activeServer?.settings?.oauthScopes
?.split(" ")
.filter(Boolean);
if (!authUrl && !accessToken && !(scopes && scopes.length > 0)) {
return undefined;
const [
connectionInfoOAuthWhenConnected,
setConnectionInfoOAuthWhenConnected,
] = useState<OAuthDetails | undefined>(undefined);
const connectionInfoOAuth =
connectionStatus === "connected" && inspectorClient
? connectionInfoOAuthWhenConnected
: undefined;
useEffect(() => {
if (connectionStatus !== "connected" || !inspectorClient) {
return;
}
return {
...(authUrl && { authUrl }),
...(scopes && scopes.length > 0 && { scopes }),
...(accessToken && { accessToken }),
let cancelled = false;
const refresh = (): void => {
void inspectorClient.getOAuthState().then((state) => {
if (cancelled) return;
setConnectionInfoOAuthWhenConnected(
state ? oauthDetailsFromConnectionState(state) : undefined,
);
});
};
}, [connectionStatus, inspectorClient, activeServer]);
refresh();
inspectorClient.addEventListener("oauthComplete", refresh);
return () => {
cancelled = true;
inspectorClient.removeEventListener("oauthComplete", refresh);
};
}, [connectionStatus, inspectorClient]);
// Derive log entries from the message log. Filters for
// `notifications/message` (the response to `logging/setLevel`).
@ -1287,6 +1314,7 @@ function App() {
// The settings node persisted in mcp.json for this server — distinct
// from the InspectorClient options we're about to derive from it.
const savedSettings = server.settings;
const activeIdp = getActiveEnterpriseManagedAuthIdp(clientConfig);
// Flatten the persisted settings into the InspectorClient options shape.
// Empty / zero values stay unset so the SDK defaults apply.
const defaultMetadata = savedSettings?.metadata
@ -1300,7 +1328,8 @@ function App() {
savedSettings &&
(savedSettings.oauthClientId ||
savedSettings.oauthClientSecret ||
savedSettings.oauthScopes)
savedSettings.oauthScopes ||
savedSettings.enterpriseManaged)
? {
...(savedSettings.oauthClientId && {
clientId: savedSettings.oauthClientId,
@ -1311,6 +1340,9 @@ function App() {
...(savedSettings.oauthScopes && {
scope: savedSettings.oauthScopes,
}),
...(savedSettings.enterpriseManaged && {
enterpriseManaged: true,
}),
}
: undefined;
const client = new InspectorClient(server.config, {
@ -1335,6 +1367,12 @@ function App() {
defaultMetadata,
}),
...(oauth && { oauth }),
...(activeIdp && {
enterpriseManagedAuth: { idp: activeIdp },
}),
...(clientConfig.enterpriseManagedAuth && {
installEnterpriseManagedAuth: clientConfig.enterpriseManagedAuth,
}),
...(savedSettings && { serverSettings: savedSettings }),
// Set on the `/oauth/callback` rebuild so the client's `saveSession`
// events (and any later persistence) key off the same OAuth authId
@ -1390,6 +1428,7 @@ function App() {
stderrLogState,
sessionStorageAdapter,
onBeforeOAuthRedirect,
clientConfig,
],
);
@ -1411,7 +1450,7 @@ function App() {
oauthCallbackHandledRef.current = true;
const params = parseOAuthCallbackParams(window.location.search);
// The OAuth `state` round-trips `{mode}:{authId}`; the authId is the
// The OAuth `state` round-trips `{execution}:{authId}`; the authId is the
// session key the pre-redirect page saved the fetch log under, so the
// rebuilt client can restore those `auth` entries. Read it before the
// URL is cleared below.
@ -1480,6 +1519,15 @@ function App() {
await client.connect();
} catch (err) {
connectStartRef.current = undefined;
if (isEmaClientNotConfiguredError(err)) {
notifications.show({
title: `Cannot connect to "${server.name}"`,
message: err.message,
color: "red",
autoClose: false,
});
return;
}
const message = err instanceof Error ? err.message : String(err);
notifications.show({
title: `Failed to connect to "${server.name}"`,
@ -1529,6 +1577,16 @@ function App() {
// (#1323).
connectStartRef.current = undefined;
if (isEmaClientNotConfiguredError(err)) {
notifications.show({
title: `Cannot connect to "${target.name}"`,
message: err.message,
color: "red",
autoClose: false,
});
return;
}
// A 401 from an OAuth-protected server means we have no (valid) token
// yet. Kick off the authorization-code flow: `authenticate()` runs
// discovery + DCR (proxied through the backend), then redirects the
@ -1539,10 +1597,23 @@ function App() {
if (isUnauthorizedError(err)) {
try {
window.sessionStorage.setItem(OAUTH_PENDING_SERVER_KEY, id);
await client.authenticate();
const authUrl = await client.authenticate();
if (authUrl === undefined) {
connectStartRef.current = Date.now();
await client.connect();
}
return;
} catch (authErr) {
window.sessionStorage.removeItem(OAUTH_PENDING_SERVER_KEY);
if (isEmaClientNotConfiguredError(authErr)) {
notifications.show({
title: `Cannot connect to "${target.name}"`,
message: authErr.message,
color: "red",
autoClose: false,
});
return;
}
const message =
authErr instanceof Error ? authErr.message : String(authErr);
notifications.show({
@ -2217,6 +2288,15 @@ function App() {
return servers.find((s) => s.id === configModal.targetId);
}, [configModal, servers]);
const settingsModalTarget = useMemo(() => {
if (!settingsModalTargetId) return undefined;
return servers.find((s) => s.id === settingsModalTargetId);
}, [settingsModalTargetId, servers]);
const settingsModalServerType = settingsModalTarget
? getServerType(settingsModalTarget.config)
: "stdio";
// The settings modal is fully controlled — every input change fires
// `onSettingsChange` back up here, and the input's `value` prop only
// updates when this component re-renders with a new `settings` prop.
@ -2259,15 +2339,53 @@ function App() {
const settingsModalValue: InspectorServerSettings =
settingsDraft ?? EMPTY_SETTINGS;
const {
draft: clientSettingsDraft,
onChange: onClientSettingsChange,
flush: flushClientSettingsDraft,
} = useClientSettingsDraft({
opened: clientSettingsOpen,
resolveInitial: () => clientConfigToFormValues(clientConfig),
onPersist: async (values) => {
if (!canPersistClientSettingsDraft(values)) return;
const next = formValuesToClientConfig(values);
await saveClientConfigRemote(next, {
baseUrl: configBaseUrl,
authToken: getAuthToken(),
});
setClientConfig(next);
},
onError: (err) => {
notifications.show({
title: "Failed to save client settings",
message: err instanceof Error ? err.message : String(err),
color: "red",
});
},
});
const clientSettingsModalValue = clientSettingsDraft ?? EMPTY_CLIENT_SETTINGS;
const emaOAuthStorage = useMemo(() => getBrowserOAuthStorage(), []);
const { loginState: emaIdpLoginState, logout: logoutEmaIdp } =
useEmaIdpLoginState(
emaOAuthStorage,
clientSettingsModalValue.emaEnabled
? clientSettingsModalValue.issuer
: undefined,
clientSettingsOpen,
);
const onClientSettingsModalClose = useCallback(() => {
flushClientSettingsDraft();
setClientSettingsOpen(false);
}, [flushClientSettingsDraft]);
// Gate the stdio-only Working Directory / Environment Variables controls in
// the settings modal. Resolve the target server's transport from its config;
// default to non-stdio when the target isn't resolvable (modal closed).
const settingsModalTarget = servers.find(
(s) => s.id === settingsModalTargetId,
);
const settingsModalIsStdio = settingsModalTarget
? getServerType(settingsModalTarget.config) === "stdio"
: false;
// the settings modal. Derived from the resolved target server's transport
// (see `settingsModalServerType` above), which defaults to "stdio" when the
// target isn't resolvable.
const settingsModalIsStdio = settingsModalServerType === "stdio";
const onSettingsModalClose = useCallback(() => {
flushSettingsDraft();
@ -2431,6 +2549,7 @@ function App() {
bridgeFactory={sandboxBridgeFactory}
appRendererRef={appRendererRef}
onToggleTheme={onToggleTheme}
onOpenClientSettings={() => setClientSettingsOpen(true)}
onToggleConnection={(id) => {
void onToggleConnection(id);
}}
@ -2560,13 +2679,25 @@ function App() {
// Remount per open (and per target server) so the accordion resets to
// its initial "options" section — the body-dropped toast deep-links
// here expecting the Network Log Size control to be visible.
key={settingsModalTargetId ?? "closed"}
key={settingsModalTargetId ?? "server-settings-closed"}
opened={settingsModalTargetId !== undefined}
settings={settingsModalValue}
serverType={settingsModalServerType}
isStdio={settingsModalIsStdio}
onClose={onSettingsModalClose}
onSettingsChange={onSettingsChange}
/>
<ClientSettingsModal
key={
clientSettingsOpen ? "client-settings-open" : "client-settings-closed"
}
opened={clientSettingsOpen}
settings={clientSettingsModalValue}
onClose={onClientSettingsModalClose}
onSettingsChange={onClientSettingsChange}
emaIdpLoginState={emaIdpLoginState}
onEmaIdpLogout={logoutEmaIdp}
/>
{initializeResult && activeServer && (
<ConnectionInfoModal
opened={connectionInfoModalOpen}

View file

@ -18,6 +18,7 @@ export function CopyButton({ value }: CopyButtonProps) {
color={copied ? "green" : "var(--inspector-text-primary)"}
onClick={copy}
fz={24}
aria-label={copied ? "Copied" : "Copy"}
>
{copied ? "\u2713" : "\u2398"}
</ActionIcon>

View file

@ -0,0 +1,74 @@
import { describe, it, expect, vi } from "vitest";
import userEvent from "@testing-library/user-event";
import { renderWithMantine, screen } from "../../../test/renderWithMantine";
import { ClientSettingsForm } from "./ClientSettingsForm";
import { EMPTY_CLIENT_SETTINGS } from "./clientSettingsValues";
describe("ClientSettingsForm EMA IdP session", () => {
it("shows signed-in state and sign out", async () => {
const user = userEvent.setup();
const onLogout = vi.fn();
renderWithMantine(
<ClientSettingsForm
settings={{
...EMPTY_CLIENT_SETTINGS,
emaEnabled: true,
issuer: "https://idp.test",
}}
expandedSections={["ema"]}
onExpandedSectionsChange={vi.fn()}
onSettingsChange={vi.fn()}
emaIdpLoginState="logged_in"
onEmaIdpLogout={onLogout}
/>,
);
expect(screen.getByText("Signed in")).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "Sign out" }));
expect(onLogout).toHaveBeenCalled();
});
it("shows not signed in without sign out button", () => {
renderWithMantine(
<ClientSettingsForm
settings={{
...EMPTY_CLIENT_SETTINGS,
emaEnabled: true,
issuer: "https://idp.test",
}}
expandedSections={["ema"]}
onExpandedSectionsChange={vi.fn()}
onSettingsChange={vi.fn()}
emaIdpLoginState="none"
onEmaIdpLogout={vi.fn()}
/>,
);
expect(screen.getByText(/Not signed in/i)).toBeInTheDocument();
expect(
screen.queryByRole("button", { name: "Sign out" }),
).not.toBeInTheDocument();
});
it("shows expired state with sign out", () => {
renderWithMantine(
<ClientSettingsForm
settings={{
...EMPTY_CLIENT_SETTINGS,
emaEnabled: true,
issuer: "https://idp.test",
}}
expandedSections={["ema"]}
onExpandedSectionsChange={vi.fn()}
onSettingsChange={vi.fn()}
emaIdpLoginState="expired"
onEmaIdpLogout={vi.fn()}
/>,
);
expect(screen.getByText("Session expired")).toBeInTheDocument();
expect(
screen.getByRole("button", { name: "Sign out" }),
).toBeInTheDocument();
});
});

View file

@ -0,0 +1,174 @@
import {
Accordion,
Badge,
Button,
Checkbox,
Group,
Stack,
Text,
TextInput,
} from "@mantine/core";
import { ClearButton } from "../../elements/ClearButton/ClearButton";
import type { EmaIdpLoginState } from "@inspector/core/auth/ema/idpSession.js";
import type { ClientSettingsFormValues } from "./clientSettingsValues.js";
export type ClientSettingsSection = "ema";
export interface ClientSettingsFormProps {
settings: ClientSettingsFormValues;
expandedSections: ClientSettingsSection[];
onExpandedSectionsChange: (sections: ClientSettingsSection[]) => void;
onSettingsChange: (settings: ClientSettingsFormValues) => void;
emaIdpLoginState?: EmaIdpLoginState;
onEmaIdpLogout?: () => void;
}
const HintText = Text.withProps({
size: "sm",
c: "dimmed",
});
export function ClientSettingsForm({
settings,
expandedSections,
onExpandedSectionsChange,
onSettingsChange,
emaIdpLoginState = "none",
onEmaIdpLogout,
}: ClientSettingsFormProps) {
function patch(partial: Partial<ClientSettingsFormValues>) {
onSettingsChange({ ...settings, ...partial });
}
const showIdpSession =
settings.emaEnabled &&
settings.issuer.trim() !== "" &&
emaIdpLoginState !== "none";
return (
<Accordion
multiple
value={expandedSections}
onChange={(value) =>
onExpandedSectionsChange(value as ClientSettingsSection[])
}
variant="separated"
>
<Accordion.Item value="ema">
<Accordion.Control>Enterprise-Managed Authorization</Accordion.Control>
<Accordion.Panel>
<Stack gap="md">
<Checkbox
label="Enable enterprise IdP configuration"
description="Enterprise-managed authorization signs you in through your organization's IdP instead of each MCP server's own OAuth login. Enable enterprise-managed authorization on participating MCP server's settings."
checked={settings.emaEnabled}
onChange={(e) => patch({ emaEnabled: e.currentTarget.checked })}
/>
{settings.emaEnabled && (
<>
<HintText>
Register this redirect URI with your IdP:{" "}
{typeof window !== "undefined"
? `${window.location.origin}/oauth/callback`
: "http://localhost:6274/oauth/callback"}
</HintText>
<TextInput
label="Issuer"
description="Your enterprise IdP issuer URL."
value={settings.issuer}
onChange={(e) => patch({ issuer: e.currentTarget.value })}
rightSectionPointerEvents="auto"
rightSection={
settings.issuer ? (
<ClearButton onClick={() => patch({ issuer: "" })} />
) : null
}
/>
<TextInput
label="Client ID"
description="Client id registered with your enterprise IdP."
value={settings.clientId}
onChange={(e) => patch({ clientId: e.currentTarget.value })}
rightSectionPointerEvents="auto"
rightSection={
settings.clientId ? (
<ClearButton onClick={() => patch({ clientId: "" })} />
) : null
}
/>
<TextInput
label="Client Secret"
description="Client secret for your enterprise IdP (if required)."
type="password"
value={settings.clientSecret}
onChange={(e) =>
patch({ clientSecret: e.currentTarget.value })
}
rightSectionPointerEvents="auto"
rightSection={
settings.clientSecret ? (
<ClearButton
onClick={() => patch({ clientSecret: "" })}
/>
) : null
}
/>
{settings.issuer.trim() !== "" && (
<Group
justify="space-between"
align="flex-start"
wrap="nowrap"
>
<Stack gap={4} flex={1}>
<Text size="sm" fw={500}>
IdP sign-in
</Text>
{emaIdpLoginState === "logged_in" ? (
<>
<Badge w="fit-content" color="green" variant="light">
Signed in
</Badge>
<HintText>
Your enterprise IdP session is active. Connecting to
EMA-enabled MCP servers will not prompt for IdP
login until you sign out or the session expires.
</HintText>
</>
) : emaIdpLoginState === "expired" ? (
<>
<Badge w="fit-content" color="yellow" variant="light">
Session expired
</Badge>
<HintText>
Your cached IdP session has expired. The next
connect to an EMA-enabled server will prompt for IdP
login.
</HintText>
</>
) : (
<HintText>
Not signed in to your enterprise IdP. Connecting to an
EMA-enabled MCP server will open IdP login.
</HintText>
)}
</Stack>
{showIdpSession && onEmaIdpLogout ? (
<Button
variant="light"
color="red"
size="compact-sm"
onClick={onEmaIdpLogout}
>
Sign out
</Button>
) : null}
</Group>
)}
</>
)}
</Stack>
</Accordion.Panel>
</Accordion.Item>
</Accordion>
);
}

View file

@ -0,0 +1,125 @@
import { describe, it, expect } from "vitest";
import {
canPersistClientSettingsDraft,
clientConfigToFormValues,
formValuesToClientConfig,
} from "./clientSettingsValues";
describe("clientSettingsValues", () => {
it("clientConfigToFormValues maps enterpriseManagedAuth.idp", () => {
expect(
clientConfigToFormValues({
enterpriseManagedAuth: {
idp: {
issuer: "https://idp.test",
clientId: "cid",
clientSecret: "sec",
},
},
}),
).toEqual({
emaEnabled: true,
issuer: "https://idp.test",
clientId: "cid",
clientSecret: "sec",
});
});
it("clientConfigToFormValues preserves IdP fields when EMA is disabled", () => {
expect(
clientConfigToFormValues({
enterpriseManagedAuth: {
enabled: false,
idp: {
issuer: "https://idp.test",
clientId: "cid",
clientSecret: "sec",
},
},
}),
).toEqual({
emaEnabled: false,
issuer: "https://idp.test",
clientId: "cid",
clientSecret: "sec",
});
});
it("formValuesToClientConfig keeps IdP credentials when disabled", () => {
expect(
formValuesToClientConfig({
emaEnabled: false,
issuer: "https://idp.test",
clientId: "cid",
clientSecret: "sec",
}),
).toEqual({
enterpriseManagedAuth: {
enabled: false,
idp: {
issuer: "https://idp.test",
clientId: "cid",
clientSecret: "sec",
},
},
});
});
it("formValuesToClientConfig omits block when disabled with no stored fields", () => {
expect(
formValuesToClientConfig({
emaEnabled: false,
issuer: "",
clientId: "",
clientSecret: "",
}),
).toEqual({});
});
it("formValuesToClientConfig trims issuer and clientId when enabled", () => {
expect(
formValuesToClientConfig({
emaEnabled: true,
issuer: " https://idp.test ",
clientId: " cid ",
clientSecret: "sec",
}),
).toEqual({
enterpriseManagedAuth: {
enabled: true,
idp: {
issuer: "https://idp.test",
clientId: "cid",
clientSecret: "sec",
},
},
});
});
it("canPersistClientSettingsDraft allows disabled or complete IdP fields", () => {
expect(
canPersistClientSettingsDraft({
emaEnabled: false,
issuer: "",
clientId: "",
clientSecret: "",
}),
).toBe(true);
expect(
canPersistClientSettingsDraft({
emaEnabled: true,
issuer: "https://idp.test",
clientId: "cid",
clientSecret: "",
}),
).toBe(true);
expect(
canPersistClientSettingsDraft({
emaEnabled: true,
issuer: "",
clientId: "cid",
clientSecret: "",
}),
).toBe(false);
});
});

View file

@ -0,0 +1,69 @@
import type { ClientConfig } from "@inspector/core/client/types.js";
/** Form shape for install-level client settings (client.json). */
export interface ClientSettingsFormValues {
emaEnabled: boolean;
issuer: string;
clientId: string;
clientSecret: string;
}
export const EMPTY_CLIENT_SETTINGS: ClientSettingsFormValues = {
emaEnabled: false,
issuer: "",
clientId: "",
clientSecret: "",
};
export function clientConfigToFormValues(
config: ClientConfig,
): ClientSettingsFormValues {
const ema = config.enterpriseManagedAuth;
const idp = ema?.idp;
if (!idp) {
return { ...EMPTY_CLIENT_SETTINGS };
}
return {
emaEnabled: ema.enabled !== false,
issuer: idp.issuer,
clientId: idp.clientId,
clientSecret: idp.clientSecret ?? "",
};
}
function hasStoredIdpFields(values: ClientSettingsFormValues): boolean {
return (
values.issuer.trim() !== "" ||
values.clientId.trim() !== "" ||
values.clientSecret !== ""
);
}
export function formValuesToClientConfig(
values: ClientSettingsFormValues,
): ClientConfig {
if (!hasStoredIdpFields(values)) {
return {};
}
const idp = {
issuer: values.issuer.trim(),
clientId: values.clientId.trim(),
clientSecret: values.clientSecret,
};
return {
enterpriseManagedAuth: {
enabled: values.emaEnabled,
idp,
},
};
}
/** Skip debounced persist while EMA is enabled but required IdP fields are blank. */
export function canPersistClientSettingsDraft(
values: ClientSettingsFormValues,
): boolean {
if (!values.emaEnabled) return true;
return values.issuer.trim() !== "" && values.clientId.trim() !== "";
}

View file

@ -0,0 +1,77 @@
import { AppShell } from "@mantine/core";
import type { Meta, StoryObj } from "@storybook/react-vite";
import { fn } from "storybook/test";
import { useArgs } from "storybook/preview-api";
import {
ClientSettingsModal,
type ClientSettingsModalProps,
} from "./ClientSettingsModal";
import type { ClientSettingsFormValues } from "../ClientSettingsForm/clientSettingsValues";
const configuredSettings: ClientSettingsFormValues = {
emaEnabled: true,
issuer: "https://idp.example.com",
clientId: "inspector-idp-client",
clientSecret: "super-secret-idp-value",
};
function InteractiveRender(args: ClientSettingsModalProps) {
const [, updateArgs] = useArgs<ClientSettingsModalProps>();
return (
<AppShell>
<AppShell.Main>
<ClientSettingsModal
{...args}
onSettingsChange={(settings) => {
args.onSettingsChange(settings);
updateArgs({ settings });
}}
/>
</AppShell.Main>
</AppShell>
);
}
const meta: Meta<typeof ClientSettingsModal> = {
title: "Groups/ClientSettingsModal",
component: ClientSettingsModal,
parameters: { layout: "fullscreen" },
render: InteractiveRender,
args: {
opened: true,
onClose: fn(),
onSettingsChange: fn(),
},
};
export default meta;
type Story = StoryObj<typeof ClientSettingsModal>;
export const Configured: Story = {
args: {
settings: configuredSettings,
},
};
export const Empty: Story = {
args: {
settings: {
emaEnabled: false,
issuer: "",
clientId: "",
clientSecret: "",
},
},
};
export const EnabledEmptyFields: Story = {
args: {
settings: {
emaEnabled: true,
issuer: "",
clientId: "",
clientSecret: "",
},
},
};

View file

@ -0,0 +1,90 @@
import { describe, it, expect, vi } from "vitest";
import userEvent from "@testing-library/user-event";
import { renderWithMantine, screen } from "../../../test/renderWithMantine";
import { ClientSettingsModal } from "./ClientSettingsModal";
import { EMPTY_CLIENT_SETTINGS } from "../ClientSettingsForm/clientSettingsValues";
describe("ClientSettingsModal", () => {
it("renders the title when opened", () => {
renderWithMantine(
<ClientSettingsModal
opened
settings={EMPTY_CLIENT_SETTINGS}
onClose={vi.fn()}
onSettingsChange={vi.fn()}
/>,
);
expect(screen.getByText("Client Settings")).toBeInTheDocument();
});
it("invokes onClose when the CloseButton is clicked", async () => {
const user = userEvent.setup();
const onClose = vi.fn();
renderWithMantine(
<ClientSettingsModal
opened
settings={EMPTY_CLIENT_SETTINGS}
onClose={onClose}
onSettingsChange={vi.fn()}
/>,
);
await user.click(
document.querySelector("button.mantine-CloseButton-root")!,
);
expect(onClose).toHaveBeenCalled();
});
it("calls onSettingsChange when toggling EMA enable", async () => {
const user = userEvent.setup();
const onSettingsChange = vi.fn();
renderWithMantine(
<ClientSettingsModal
opened
settings={EMPTY_CLIENT_SETTINGS}
onClose={vi.fn()}
onSettingsChange={onSettingsChange}
/>,
);
await user.click(
screen.getByRole("checkbox", {
name: "Enable enterprise IdP configuration",
}),
);
expect(onSettingsChange).toHaveBeenCalledWith({
...EMPTY_CLIENT_SETTINGS,
emaEnabled: true,
});
});
it("shows IdP fields when EMA is enabled", () => {
renderWithMantine(
<ClientSettingsModal
opened
settings={{ ...EMPTY_CLIENT_SETTINGS, emaEnabled: true }}
onClose={vi.fn()}
onSettingsChange={vi.fn()}
/>,
);
expect(screen.getByLabelText("Issuer")).toBeInTheDocument();
expect(screen.getByLabelText("Client ID")).toBeInTheDocument();
expect(screen.getByLabelText("Client Secret")).toBeInTheDocument();
});
it("calls onSettingsChange when typing issuer", async () => {
const user = userEvent.setup();
const onSettingsChange = vi.fn();
renderWithMantine(
<ClientSettingsModal
opened
settings={{ ...EMPTY_CLIENT_SETTINGS, emaEnabled: true }}
onClose={vi.fn()}
onSettingsChange={onSettingsChange}
/>,
);
await user.type(screen.getByLabelText("Issuer"), "h");
expect(onSettingsChange).toHaveBeenCalled();
const call =
onSettingsChange.mock.calls[onSettingsChange.mock.calls.length - 1][0];
expect(call.issuer).toBe("h");
});
});

View file

@ -0,0 +1,71 @@
import { useState } from "react";
import { CloseButton, Group, Modal, Stack, Title } from "@mantine/core";
import { ListToggle } from "../../elements/ListToggle/ListToggle";
import {
ClientSettingsForm,
type ClientSettingsSection,
} from "../ClientSettingsForm/ClientSettingsForm";
import type { EmaIdpLoginState } from "@inspector/core/auth/ema/idpSession.js";
import type { ClientSettingsFormValues } from "../ClientSettingsForm/clientSettingsValues.js";
const ALL_SECTIONS: ClientSettingsSection[] = ["ema"];
export interface ClientSettingsModalProps {
opened: boolean;
settings: ClientSettingsFormValues;
onClose: () => void;
onSettingsChange: (settings: ClientSettingsFormValues) => void;
emaIdpLoginState?: EmaIdpLoginState;
onEmaIdpLogout?: () => void;
}
export function ClientSettingsModal({
opened,
settings,
onClose,
onSettingsChange,
emaIdpLoginState,
onEmaIdpLogout,
}: ClientSettingsModalProps) {
const [expandedSections, setExpandedSections] = useState<
ClientSettingsSection[]
>(["ema"]);
const allExpanded = expandedSections.length === ALL_SECTIONS.length;
function handleToggleAll() {
setExpandedSections(allExpanded ? [] : ALL_SECTIONS);
}
return (
<Modal
opened={opened}
onClose={onClose}
withCloseButton={false}
size="lg"
centered
>
<Stack gap="md">
<Group justify="space-between" wrap="nowrap">
<ListToggle
compact={!allExpanded}
variant="subtle"
onToggle={handleToggleAll}
/>
<Title order={4} ta="center" flex={1}>
Client Settings
</Title>
<CloseButton onClick={onClose} />
</Group>
<ClientSettingsForm
settings={settings}
expandedSections={expandedSections}
onExpandedSectionsChange={setExpandedSections}
onSettingsChange={onSettingsChange}
emaIdpLoginState={emaIdpLoginState}
onEmaIdpLogout={onEmaIdpLogout}
/>
</Stack>
</Modal>
);
}

View file

@ -84,6 +84,8 @@ export const WithOAuth: Story = {
},
transport: "streamable-http",
oauth: {
protocol: "standard",
authorized: true,
authUrl: "https://auth.example.com/oauth2/authorize",
scopes: ["read", "write", "admin"],
accessToken: "eyJhbGciOiJSUzI1NiIs...truncated",

View file

@ -102,6 +102,9 @@ describe("ConnectionInfoContent", () => {
clientCapabilities={fullClientCaps}
transport="streamable-http"
oauth={{
protocol: "standard",
authorized: true,
clientId: "client-abc",
authUrl: "https://auth.example.com/authorize",
scopes: ["read", "write"],
accessToken: "token-123",
@ -109,6 +112,9 @@ describe("ConnectionInfoContent", () => {
/>,
);
expect(screen.getByText("OAuth Details")).toBeInTheDocument();
expect(screen.getByText("Standard")).toBeInTheDocument();
expect(screen.getByText("Authorized")).toBeInTheDocument();
expect(screen.getByText("client-abc")).toBeInTheDocument();
expect(screen.getByText("Auth URL")).toBeInTheDocument();
expect(
screen.getByText("https://auth.example.com/authorize"),
@ -117,16 +123,35 @@ describe("ConnectionInfoContent", () => {
expect(screen.getByText("token-123")).toBeInTheDocument();
});
it("hides OAuth fields that are not provided", () => {
it("renders EMA idp session when provided", () => {
renderWithMantine(
<ConnectionInfoContent
initializeResult={fullResult}
clientCapabilities={fullClientCaps}
transport="streamable-http"
oauth={{
protocol: "ema",
authorized: true,
idpSession: "logged_in",
}}
/>,
);
expect(screen.getByText("Enterprise-managed")).toBeInTheDocument();
expect(screen.getByText("IdP session")).toBeInTheDocument();
expect(screen.getByText("Signed in")).toBeInTheDocument();
});
it("hides optional OAuth fields that are not provided", () => {
renderWithMantine(
<ConnectionInfoContent
initializeResult={fullResult}
clientCapabilities={fullClientCaps}
transport="stdio"
oauth={{ scopes: [] }}
oauth={{ protocol: "standard", authorized: false }}
/>,
);
expect(screen.getByText("OAuth Details")).toBeInTheDocument();
expect(screen.getByText("Not authorized")).toBeInTheDocument();
expect(screen.queryByText("Auth URL")).not.toBeInTheDocument();
expect(screen.queryByText("Scopes")).not.toBeInTheDocument();
expect(screen.queryByText("Access Token")).not.toBeInTheDocument();

View file

@ -17,11 +17,17 @@ import {
type CapabilityKey,
} from "../../elements/CapabilityItem/CapabilityItem";
import { ContentViewer } from "../../elements/ContentViewer/ContentViewer";
import { OAuthAccessTokenField } from "./OAuthAccessTokenField";
export interface OAuthDetails {
protocol: "standard" | "ema";
authorized: boolean;
clientId?: string;
authUrl?: string;
scopes?: string[];
accessToken?: string;
/** EMA only — install-level IdP session for legs 1–2. */
idpSession?: "none" | "logged_in" | "expired";
}
export interface ConnectionInfoContentProps {
@ -45,6 +51,23 @@ function formatScopes(scopes: string[]): string {
return scopes.join(", ");
}
function formatProtocol(protocol: OAuthDetails["protocol"]): string {
return protocol === "ema" ? "Enterprise-managed" : "Standard";
}
function formatIdpSession(
session: NonNullable<OAuthDetails["idpSession"]>,
): string {
switch (session) {
case "logged_in":
return "Signed in";
case "expired":
return "Session expired";
default:
return "Not signed in";
}
}
const SERVER_CAPABILITY_KEYS: CapabilityKey[] = [
"tools",
"resources",
@ -148,6 +171,30 @@ export function ConnectionInfoContent({
<Stack gap="xs">
<SectionHeading>OAuth Details</SectionHeading>
<Stack gap="xs">
<SimpleGrid cols={2}>
<Text size="sm">Protocol</Text>
<ValueText>{formatProtocol(oauth.protocol)}</ValueText>
<Text size="sm">Status</Text>
<Badge
variant="outline"
color={oauth.authorized ? "green" : "gray"}
>
{oauth.authorized ? "Authorized" : "Not authorized"}
</Badge>
</SimpleGrid>
{oauth.clientId && (
<SimpleGrid cols={2}>
<Text size="sm">Client ID</Text>
<Code>{oauth.clientId}</Code>
</SimpleGrid>
)}
{oauth.protocol === "ema" && oauth.idpSession && (
<SimpleGrid cols={2}>
<Text size="sm">IdP session</Text>
<ValueText>{formatIdpSession(oauth.idpSession)}</ValueText>
</SimpleGrid>
)}
{oauth.authUrl && (
<SimpleGrid cols={2}>
<Text size="sm">Auth URL</Text>
@ -161,10 +208,7 @@ export function ConnectionInfoContent({
</SimpleGrid>
)}
{oauth.accessToken && (
<SimpleGrid cols={2}>
<Text size="sm">Access Token</Text>
<Code>{oauth.accessToken}</Code>
</SimpleGrid>
<OAuthAccessTokenField accessToken={oauth.accessToken} />
)}
</Stack>
</Stack>

View file

@ -0,0 +1,39 @@
import { describe, it, expect } from "vitest";
import userEvent from "@testing-library/user-event";
import { renderWithMantine, screen } from "../../../test/renderWithMantine";
import { OAuthAccessTokenField } from "./OAuthAccessTokenField";
const jwt = "eyJhbGciOiJub25lIn0.eyJzdWIiOiJ1c2VyIn0.";
describe("OAuthAccessTokenField", () => {
it("renders token with copy control beside the label", () => {
renderWithMantine(<OAuthAccessTokenField accessToken={jwt} />);
expect(screen.getByText("Access Token")).toBeInTheDocument();
expect(screen.getByText("eyJhbGciOiJub25lIn0")).toBeInTheDocument();
expect(screen.getByText("eyJzdWIiOiJ1c2VyIn0")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Copy" })).toBeInTheDocument();
});
it("replaces raw token with decoded JSON and restores on toggle", async () => {
const user = userEvent.setup();
renderWithMantine(<OAuthAccessTokenField accessToken={jwt} />);
expect(screen.getByText("eyJhbGciOiJub25lIn0")).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "Decode JWT" }));
expect(screen.getByText(/"sub": "user"/)).toBeInTheDocument();
expect(screen.queryByText("eyJhbGciOiJub25lIn0")).not.toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "Show token" }));
expect(screen.getByText("eyJhbGciOiJub25lIn0")).toBeInTheDocument();
expect(screen.queryByText(/"sub": "user"/)).not.toBeInTheDocument();
});
it("omits decode toggle for opaque tokens", () => {
renderWithMantine(
<OAuthAccessTokenField accessToken="opaque-access-token-value" />,
);
expect(
screen.queryByRole("button", { name: "Decode JWT" }),
).not.toBeInTheDocument();
});
});

View file

@ -0,0 +1,76 @@
import { Fragment, useMemo, useState } from "react";
import { Button, Code, Flex, Stack, Text } from "@mantine/core";
import { decodeJwtPayload, isJwtFormat } from "@inspector/core/auth/ema/jwt.js";
import { CopyButton } from "../../elements/CopyButton/CopyButton";
export interface OAuthAccessTokenFieldProps {
accessToken: string;
}
const DecodeButton = Button.withProps({
variant: "subtle",
size: "compact-xs",
});
/** Wrap JWT at segment boundaries; break long segments without orphaning `.`. */
function JwtTokenText({ token }: { token: string }) {
const parts = token.split(".");
return (
<>
{parts.map((part, index) => (
<Fragment key={index}>
{index > 0 && "."}
<span style={{ wordBreak: "break-all" }}>{part}</span>
</Fragment>
))}
</>
);
}
export function OAuthAccessTokenField({
accessToken,
}: OAuthAccessTokenFieldProps) {
const [showDecoded, setShowDecoded] = useState(false);
const isJwt = isJwtFormat(accessToken);
const jwtDecoded = useMemo(
() => (isJwt ? decodeJwtPayload(accessToken) : undefined),
[accessToken, isJwt],
);
const decodedText = useMemo(() => {
if (!jwtDecoded) return undefined;
return JSON.stringify(
{ header: jwtDecoded.header, payload: jwtDecoded.payload },
null,
2,
);
}, [jwtDecoded]);
return (
<Stack gap="xs">
<Flex justify="space-between" align="center" gap="sm" wrap="nowrap">
<Text size="sm">Access Token</Text>
<Flex gap={4} align="center" wrap="nowrap">
{jwtDecoded && (
<DecodeButton
onClick={() => setShowDecoded((open) => !open)}
aria-pressed={showDecoded}
>
{showDecoded ? "Show token" : "Decode JWT"}
</DecodeButton>
)}
<CopyButton value={accessToken} />
</Flex>
</Flex>
<Code block p="sm" variant="wrapping">
{showDecoded && decodedText ? (
decodedText
) : isJwt ? (
<JwtTokenText token={accessToken} />
) : (
accessToken
)}
</Code>
</Stack>
);
}

View file

@ -0,0 +1,54 @@
import { describe, it, expect } from "vitest";
import type { OAuthConnectionState } from "@inspector/core/auth/types.js";
import { oauthDetailsFromConnectionState } from "./oauthDetailsFromConnectionState";
describe("oauthDetailsFromConnectionState", () => {
it("maps authorized standard connection state", () => {
const state: OAuthConnectionState = {
authorized: true,
protocol: "standard",
serverUrl: "https://mcp.example.com/mcp",
grantedScope: "read write",
tokens: { access_token: "tok", token_type: "Bearer" },
client: {
source: "preregistered",
clientId: "client-1",
hasClientSecret: true,
},
authorizationServerMetadata: {
issuer: "https://auth.example.com",
authorization_endpoint: "https://auth.example.com/authorize",
token_endpoint: "https://auth.example.com/token",
response_types_supported: ["code"],
},
};
expect(oauthDetailsFromConnectionState(state)).toEqual({
protocol: "standard",
authorized: true,
clientId: "client-1",
authUrl: "https://auth.example.com/authorize",
scopes: ["read", "write"],
accessToken: "tok",
});
});
it("includes EMA idp session when present", () => {
const state: OAuthConnectionState = {
authorized: false,
protocol: "ema",
serverUrl: "https://mcp.example.com/mcp",
ema: {
idpIssuer: "https://idp.example.com",
idpClientId: "idp-client",
idpSession: "logged_in",
},
};
expect(oauthDetailsFromConnectionState(state)).toEqual({
protocol: "ema",
authorized: false,
idpSession: "logged_in",
});
});
});

Some files were not shown because too many files have changed in this diff Show more