import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore, } from "react"; import { Anchor, Box, List, Paper, Stack, Text, useComputedColorScheme, useMantineColorScheme, } from "@mantine/core"; import { notifications } from "@mantine/notifications"; import type { CreateMessageResult, ElicitResult, InitializeResult, LoggingLevel, LoggingMessageNotification, Progress, ProgressToken, Resource, Task, Tool, } from "@modelcontextprotocol/client"; import { InspectorClient } from "@inspector/core/mcp/index.js"; import { toRecord } from "@inspector/core/json/jsonUtils.js"; import { getServerType } from "@inspector/core/mcp/config.js"; import type { InspectorClientEventMap, JsonValue, } from "@inspector/core/mcp/index.js"; import type { TypedEvent } from "@inspector/core/mcp/inspectorClientEventTarget.js"; import { getUrlElicitationsFromError, UrlElicitationLoopError, } from "@inspector/core/mcp/urlElicitation.js"; import { ToolCallCancelledError } from "@inspector/core/mcp/toolCallCancelledError.js"; import type { TypedEventGeneric } from "@inspector/core/mcp/typedEventTarget.js"; import type { InspectorServerSettings, MCPServerConfig, MessageEntry, ServerEntry, ServerType, } from "@inspector/core/mcp/types.js"; import { DEFAULT_MAX_FETCH_REQUESTS, DEFAULT_TASK_TTL_MS, eraToVersionNegotiation, resolveModernLogLevel, } from "@inspector/core/mcp/types.js"; import { API_SERVER_ENV_VARS, INSPECTOR_API_TOKEN_GLOBAL, } from "@inspector/core/mcp/remote/constants.js"; import { ManagedToolsState } from "@inspector/core/mcp/state/managedToolsState.js"; import { ManagedPromptsState } from "@inspector/core/mcp/state/managedPromptsState.js"; import { ManagedResourcesState } from "@inspector/core/mcp/state/managedResourcesState.js"; import { PagedToolsState } from "@inspector/core/mcp/state/pagedToolsState.js"; import { PagedPromptsState } from "@inspector/core/mcp/state/pagedPromptsState.js"; import { PagedResourcesState } from "@inspector/core/mcp/state/pagedResourcesState.js"; import { ManagedResourceTemplatesState } from "@inspector/core/mcp/state/managedResourceTemplatesState.js"; import { ManagedRequestorTasksState } from "@inspector/core/mcp/state/managedRequestorTasksState.js"; import { ResourceSubscriptionsState } from "@inspector/core/mcp/state/resourceSubscriptionsState.js"; import { applyStdioSettingsToConfig, cleanRoots, oauthAuthorizationParamsFromSettings, oauthEndpointOverridesFromSettings, serializeMcpConfig, } from "@inspector/core/mcp/serverList.js"; import type { ClientConfig } from "@inspector/core/client/types.js"; import { getActiveCimdClientMetadataUrl, 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"; import { StderrLogState } from "@inspector/core/mcp/state/stderrLogState.js"; import type { RedirectUrlProvider } from "@inspector/core/auth/index.js"; import { parseOAuthCallbackParams, parseOAuthState, generateOAuthErrorDescription, formatOAuthFailureDetail, } from "@inspector/core/auth/index.js"; import { RemoteInspectorClientStorage } from "@inspector/core/mcp/remote/index.js"; import { useInspectorClient } from "@inspector/core/react/useInspectorClient.js"; import { ServerListReloadError, 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 { getWebRemoteOAuthStorage } from "./lib/remoteOAuthStorage"; import { useManagedTools } from "@inspector/core/react/useManagedTools.js"; import { useManagedPrompts } from "@inspector/core/react/useManagedPrompts.js"; import { useManagedResources } from "@inspector/core/react/useManagedResources.js"; import { usePagedTools } from "@inspector/core/react/usePagedTools.js"; import { usePagedPrompts } from "@inspector/core/react/usePagedPrompts.js"; import { usePagedResources } from "@inspector/core/react/usePagedResources.js"; import { usePaginatedList } from "./hooks/usePaginatedList"; import { useLastPersistedSettings } from "./hooks/useLastPersistedSettings"; import { usePaginatedListsOverride } from "./hooks/usePaginatedListsOverride"; import { useValueChange } from "./hooks/useValueChange"; import type { ListPaginationControlsProps } from "./components/elements/ListPaginationControls/ListPaginationControls"; import { useManagedResourceTemplates } from "@inspector/core/react/useManagedResourceTemplates.js"; import { useManagedRequestorTasks } from "@inspector/core/react/useManagedRequestorTasks.js"; import { useResourceSubscriptions } from "@inspector/core/react/useResourceSubscriptions.js"; import { useMessageLog } from "@inspector/core/react/useMessageLog.js"; import { useFetchRequestLog } from "@inspector/core/react/useFetchRequestLog.js"; import { useStderrLog } from "@inspector/core/react/useStderrLog.js"; import { useInitialConfig } from "@inspector/core/react/useInitialConfig.js"; import { refreshingPersist } from "./lib/refreshingPersist"; import { usePendingClientRequests } from "@inspector/core/react/usePendingClientRequests.js"; import { InspectorView } from "./components/views/InspectorView/InspectorView"; import type { ToolCallState, ToolsUiState, } from "./components/screens/ToolsScreen/ToolsScreen"; import type { GetPromptState } from "./components/screens/PromptsScreen/PromptsScreen"; import type { ReadResourceState } from "./components/screens/ResourcesScreen/ResourcesScreen"; import type { TaskProgress } from "./components/groups/TaskCard/TaskCard"; import { EMPTY_TOOLS_UI, EMPTY_PROMPTS_UI, EMPTY_RESOURCES_UI, EMPTY_APPS_UI, EMPTY_TASKS_UI, EMPTY_LOGS_UI, EMPTY_PROTOCOL_UI, EMPTY_NETWORK_UI, EMPTY_CONSOLE_UI, } from "./components/screens/screenUiState"; import { clearScrollMemory } from "./hooks/useScrollMemory"; import type { AppRendererHandle } from "./components/elements/AppRenderer/AppRenderer"; import { createAppBridgeFactory } from "./components/elements/AppRenderer/createAppBridgeFactory"; import { publishAppDocument } from "./lib/publishAppDocument"; import { AppElicitationHost } from "./components/elements/AppElicitation/AppElicitationHost"; import { AppElicitationController, type AppElicitationSession, } from "./lib/appElicitationController"; import type { LogEntryData } from "./components/elements/LogEntry/LogEntry"; import { ServerConfigModal, 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 { isReplayableProtocolMethod } from "./components/groups/protocolUtils.js"; import type { OAuthDetails } from "./components/groups/ConnectionInfoContent/ConnectionInfoContent"; import { ServerRemoveConfirmModal } from "./components/groups/ServerRemoveConfirmModal/ServerRemoveConfirmModal"; import { StepUpAuthModal } from "./components/groups/StepUpAuthModal/StepUpAuthModal"; import { ReAuthBanner } from "./components/groups/ReAuthBanner/ReAuthBanner"; import { PendingClientRequestModal, type PendingClientRequestContent, } from "./components/groups/PendingClientRequestModal/PendingClientRequestModal"; import { buildExportFilename, downloadJsonFile } from "./lib/downloadFile"; import { INSPECTOR_SERVERS_TAB } from "./utils/inspectorTabs"; import { enrichProtocolEntries } from "./utils/correlateTransportErrors"; import { visibleMalformedListItems } from "./utils/malformedListReport"; import { parseDeepLink, deepLinkConfigEquals, deepLinkParseStatus, } from "./utils/deepLink"; import type { DeepLink, DeepLinkParseStatus } from "./utils/deepLink"; import { applyOAuthResumeUi, buildTabUiSnapshot, clearOAuthResumeSnapshot, consumeOAuthResumeSnapshot, oauthResumeInsufficientScopeMessage, oauthResumeToastMessage, writeOAuthResumeSnapshot, type OAuthResumeAuthKind, } from "./lib/oauthResume"; import { createWebEnvironment } from "./lib/environmentFactory"; import { OAUTH_CALLBACK_PATH, isUnauthorizedError } from "./utils/oauthFlow"; import { AuthRecoveryRequiredError } from "@inspector/core/auth/challenge.js"; import type { AuthChallenge, AuthChallengeReason, } from "@inspector/core/auth/challenge.js"; import { emaStepUpFailureMessage, emaStepUpInProgressMessage, emaStepUpSuccessMessage, isEmaStepUp as isCoreEmaStepUp, isStepUpConfirmation as isCoreStepUpConfirmation, } from "@inspector/core/auth/oauthUx.js"; import { clearServerOAuthState } from "./lib/clearServerOAuthState"; import { authRecoveryRestoredMessage, isReAuthBannerReason, issuerBindingFailureCopy, lostAuthorizationStateActionLabel, oauthPreRedirectToastCopy, oauthResumeAbandonedMessage, reAuthBannerMessage, type OAuthPreRedirectContext, type OAuthRecoverySource, } from "./utils/oauthUx"; import { findIssuerBindingFailure } from "@inspector/core/auth/issuerBinding.js"; import { isBrowserTabVisible, onBrowserTabVisible, } from "./lib/browserTabVisibility"; import type { PendingReauth } from "./utils/pendingReauth"; // OAuth redirect URL provider — points at the dev backend's `/oauth/callback` // handler. The InspectorClient only consults this when the active server // requires OAuth; for stdio MCP servers it's never used. Created once and // reused so `BrowserOAuthClientProvider` doesn't re-instantiate per render. const redirectUrlProvider: RedirectUrlProvider = { getRedirectUrl: () => `${window.location.origin}${OAUTH_CALLBACK_PATH}`, }; // Recover the backend's auth token. Every browser request to /api/* needs it // in the `x-mcp-remote-auth: Bearer …` header or the Hono backend returns 401. // Three sources, in priority order: // 1. `window.__INSPECTOR_API_TOKEN__` — injected into index.html by the // backend on every page load (dev Vite plugin + prod Hono server). This // is the robust path: it survives a bare-URL reload, a bookmark, or a // cleared sessionStorage, none of which carry the query string. // 2. `?MCP_INSPECTOR_API_TOKEN=…` — the URL the launcher banner prints. Kept // as a fallback for pasted full URLs and older integrations. // 3. sessionStorage — backstop for SPA navigations / OAuth round-trips that // land without either of the above. // Both the injected global and the URL value are persisted to sessionStorage // so a later navigation that drops them (e.g. a deep-link load that wasn't // injected, or an iframe) still authenticates from the backstop. function getAuthToken(): string | undefined { if (typeof window === "undefined") return undefined; const STORAGE_KEY = API_SERVER_ENV_VARS.AUTH_TOKEN; // Best-effort persistence — sessionStorage may be unavailable (privacy // mode, iframe sandboxing, etc.); the resolved value still works for the // current page load regardless. const persist = (token: string): void => { try { window.sessionStorage.setItem(STORAGE_KEY, token); } catch { // ignore — see note above } }; const fromGlobal = toRecord(window)[INSPECTOR_API_TOKEN_GLOBAL]; if (typeof fromGlobal === "string" && fromGlobal) { persist(fromGlobal); return fromGlobal; } const params = new URLSearchParams(window.location.search); const fromUrl = params.get(API_SERVER_ENV_VARS.AUTH_TOKEN); if (fromUrl) { persist(fromUrl); return fromUrl; } try { return window.sessionStorage.getItem(STORAGE_KEY) ?? undefined; } catch { return undefined; } } // Derive `LogEntryData[]` from the MessageLog by filtering for the // `notifications/message` notifications the server emits in response to // `logging/setLevel`. The Logs screen renders these; we transform here // rather than in the screen so the view stays prop-driven. function messagesToLogEntries(messages: MessageEntry[]): LogEntryData[] { const out: LogEntryData[] = []; for (const m of messages) { if (m.direction !== "notification") continue; // MessageEntry.message is a JSONRPC union; notifications have `method` // but not `id`. Narrow with an `in` check, then confirm the method. if (!("method" in m.message)) continue; if (m.message.method !== "notifications/message") continue; // The method check pins this to a logging notification; its `params` are // only generically typed on the JSONRPC union, so cast just that value. const params = m.message.params as LoggingMessageNotification["params"]; out.push({ receivedAt: m.timestamp, params, }); } return out; } // Re-issue the original request behind a Protocol entry. The call goes through // InspectorClient → tracked transport → message log, so the replayed // request+response surface as a fresh Protocol entry (protocol-local) — it // intentionally does NOT touch the Tools/Prompts/Resources panels. Returns a // human-readable reason when the entry can't be replayed (unsupported method, // or a tool that's no longer present), or null on a dispatched replay. async function replayProtocolRequest( client: InspectorClient, method: string, params: Record | undefined, tools: Tool[], ): Promise { // Gate on the shared replayable-method set (the same one ProtocolEntry uses to // show/hide the Replay button) so the two can't drift. if (!isReplayableProtocolMethod(method)) { return `Replay isn't supported for "${method}".`; } // Pagination cursor carried by the */list requests; replaying the same page // reproduces the original call. const cursor = typeof params?.cursor === "string" ? params.cursor : undefined; switch (method) { case "tools/call": { const name = typeof params?.name === "string" ? params.name : undefined; const tool = tools.find((t) => t.name === name); if (!tool) { return `Tool "${name ?? "?"}" is no longer available to replay.`; } await client.callTool( tool, (params?.arguments ?? {}) as Record, ); return null; } case "prompts/get": { const name = typeof params?.name === "string" ? params.name : undefined; if (!name) return "Prompt name is missing; cannot replay."; await client.getPrompt( name, (params?.arguments ?? {}) as Record, ); return null; } case "resources/read": { const uri = typeof params?.uri === "string" ? params.uri : undefined; if (!uri) return "Resource URI is missing; cannot replay."; await client.readResource(uri); return null; } case "tools/list": await client.listTools(cursor); return null; case "prompts/list": await client.listPrompts(cursor); return null; case "resources/list": await client.listResources(cursor); return null; case "resources/templates/list": await client.listResourceTemplates(cursor); return null; case "tasks/list": await client.listRequestorTasks(cursor); return null; case "ping": await client.ping(); return null; default: return `Replay isn't supported for "${method}".`; } } // Stable empty-shell for `InspectorServerSettings`. Used both as the // initial draft for a server entry that hasn't been touched yet, and as // the fallback the settings modal renders against when it's closed // (Mantine renders the dialog shell regardless of `opened`). Hoisted to // module scope so both call sites share the same object identity and so // React doesn't re-allocate on every render. const EMPTY_SETTINGS: InspectorServerSettings = { headers: [], env: [], metadata: {}, connectionTimeout: 0, requestTimeout: 0, taskTtl: DEFAULT_TASK_TTL_MS, autoRefreshOnListChanged: false, paginatedLists: false, maxFetchRequests: DEFAULT_MAX_FETCH_REQUESTS, roots: [], }; // Stable toast id for the "response body dropped" warning, keyed per server so // a request storm updates one persistent toast rather than stacking thousands // (the drop event can fire rapidly). Mirrors the progress-toast dedupe pattern. function bodyDroppedToastId(serverId: string): string { return `fetch-body-dropped-${serverId}`; } const CLIENT_CONFIG_LOAD_ERROR_NOTIFICATION_ID = "client-config-load-error"; // Shared "list of likely causes" styling for the warning-toast bodies below. const ToastCauseList = List.withProps({ size: "sm", spacing: 2 }); // The "open the relevant settings/details" link rendered at the bottom of each // warning-toast body. Same static shape across all three toasts; each passes // its own `onClick`. const ToastLinkButton = Anchor.withProps({ component: "button", type: "button", size: "sm", }); // The re-auth popup. A `Paper` so every static style is a prop; the stacking // order goes through `styles.root` since Mantine has no `z` prop. // // Floats top-right rather than spanning the top as a sticky full-bleed bar. // The bar cost the whole view a band of vertical space for what is a // notification about one server, and it sat directly above the monitoring // sidebar that an OAuth failure now opens (#2108) — the two things a user needs // at once here are this affordance and those requests, so it must not push them // around. `fixed`, not `sticky`, so scrolling the server list leaves it put. // // Centered, and deliberately WITHOUT an overlay. Every corner is spoken for — // the toast stack owns bottom-right (see `main.tsx`), the right edge is the // monitoring sidebar whose toolbar this would cover, and top-left is the // Servers header — so anchoring it anywhere hides something. Centering is the // one placement that reads as addressed to the whole window rather than // attached to the wrong panel. // // The missing overlay is the point, not an omission: this is a notification, // not a decision that must be made now. An OAuth failure opens the monitoring // sidebar (#2108), and blocking the page would force a choice between reading // those requests and keeping the affordance — dismissing is not free, since // "Authorize again" also clears the stale OAuth state, which a plain reconnect // does not do. So it floats above the page and leaves it usable. // // `transform` goes through `styles.root` for the same reason `zIndex` does: // Mantine exposes neither as a style prop. const ReAuthBannerBar = Paper.withProps({ pos: "fixed", top: "50%", left: "50%", w: 420, bg: "var(--mantine-color-body)", shadow: "xl", radius: "md", styles: { root: { transform: "translate(-50%, -50%)", zIndex: 200 } }, }); // 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 // just a high-traffic server. Surfaces the otherwise-invisible rotation drop // described in #1390. const FetchBodyDroppedToastMessage = ({ maxFetchRequests, onAdjust, }: { maxFetchRequests: number; onAdjust: () => void; }) => ( A response body arrived after its Network log entry had already rotated out (the log hit its {maxFetchRequests}-request limit), so the body couldn't be shown. This usually indicates: a chatty or misbehaving server (notification storms, rapid polling) an SSE/transport reconnect or retry storm a slow streaming call racing against high request volume the Network Log Size set too low for this server's traffic Adjust Network Log Size for this server ); // Body of the output-schema-mismatch warning toast: a one-line summary plus a // link that opens the full validation details in a modal (the raw error is far // too long for a toast). const OutputValidationToastMessage = ({ onViewDetails, }: { onViewDetails: () => void; }) => ( The tool result's structuredContent doesn't match the tool's outputSchema. The inspector renders it anyway, but strict MCP clients may not. View validation details ); // Body of the non-spec URLElicitationRequired toast: the server returned a // -32042 error with no `elicitations` list, so there's no URL to open. We keep // the toast short and link to a modal with the raw error body. const UrlElicitationErrorToastMessage = ({ onViewDetails, }: { onViewDetails: () => void; }) => ( The server reported a URLElicitationRequired error but listed no required elicitations, so there's nothing to open. View error details ); function errorMessage(err: unknown): string { return err instanceof Error ? err.message : String(err); } // The numeric JSON-RPC code of a thrown protocol error (e.g. a `ProtocolError` // carrying `-32602`), or undefined for a plain Error. Duck-typed like // `formatErrorDetails` so we don't couple to the SDK's error class here — the // only consumer is the Tools error panel's unknown-tool (`-32602`) hint (#1632). function errorCodeOf(err: unknown): number | undefined { if (err && typeof err === "object") { const code = (err as { code?: unknown }).code; if (typeof code === "number") return code; } return undefined; } // Pretty-print a thrown error for the URL-elicitation details modal: a ProtocolError // carries a `code`/`data` worth showing alongside the message, so include them // when present; otherwise fall back to the plain message. function formatErrorDetails(err: unknown): string { if (err && typeof err === "object") { const e = err as { code?: unknown; message?: unknown; data?: unknown }; if (e.code !== undefined || e.data !== undefined) { return JSON.stringify( { code: e.code, message: e.message, data: e.data }, null, 2, ); } } return errorMessage(err); } // How long a progress toast lingers after its last tick. Each new tick on the // same progress stream resets this window (via `notifications.update`), so a // steady stream keeps one toast alive; the toast clears a few seconds after // progress stops (i.e. the call finished or went quiet). const PROGRESS_TOAST_AUTOCLOSE_MS = 5000; // A task cancellation is a one-shot confirmation (unlike the live status toast, // which stays open while the task runs), so it auto-dismisses after a moment. const TASK_CANCELLED_TOAST_AUTOCLOSE_MS = 5000; // Stable toast id for a progress stream. Notifications keyed by this id are // replaced (not stacked) so a chatty server updates one toast per stream // rather than flooding the corner. The injected `progressToken` correlates a // stream with the request that triggered it; when absent (the common case — // the inspector doesn't expose a caller token), all ticks share one toast. function progressToastId(token: ProgressToken | undefined): string { return `progress-${String(token ?? "default")}`; } // One-line toast body: " — / (NN%)". The fraction // and percentage are omitted when the server sends no `total`. function formatProgressToastMessage( detail: Progress & { progressToken?: ProgressToken }, ): string { const { progress, total, message } = detail; const ratio = total !== undefined && total > 0 ? `${progress} / ${total} (${Math.round((progress / total) * 100)}%)` : `${progress}`; return message ? `${message} — ${ratio}` : ratio; } // Terminal task states — once a task reaches one of these it can't change, so // its toast is dismissed and its per-task progress entry is pruned. const TERMINAL_TASK_STATUSES: ReadonlySet = new Set([ "completed", "failed", "cancelled", ]); function isTerminalTaskStatus(status: Task["status"]): boolean { return TERMINAL_TASK_STATUSES.has(status); } // Stable toast id per task so live status updates replace one toast rather than // stacking a fresh one per `notifications/tasks/status` tick. function taskToastId(taskId: string): string { return `task-${taskId}`; } // Toast color per task status — mirrors TaskStatusBadge's mapping so the toast // and the Tasks-screen badge read consistently. function taskToastColor(status: Task["status"]): string { switch (status) { case "completed": return "green"; case "failed": return "red"; case "cancelled": return "gray"; case "input_required": return "yellow"; default: return "blue"; } } // The subset of Task fields the toast layer reads. Both task-event payloads — // the server `taskStatusChange` (full Task) and the client-origin // `requestorTaskUpdated` (Task with optional createdAt) — satisfy this. type TaskToastInput = Pick & { statusMessage?: string }; // One-line toast body: the task's `statusMessage` when present, else a short // fallback naming the status. The title carries the status itself. function formatTaskToastMessage(task: TaskToastInput): string { return task.statusMessage ?? `Task ${task.status}`; } function isEmaStepUp( challenge: AuthChallenge, server: ServerEntry | undefined, ): boolean { return isCoreEmaStepUp(challenge, { enterpriseManaged: server?.settings?.enterpriseManaged, }); } function isStepUpConfirmation( challenge: AuthChallenge, server: ServerEntry | undefined, ): boolean { return isCoreStepUpConfirmation(challenge, { enterpriseManaged: server?.settings?.enterpriseManaged, }); } /** Which in-flight action opened the step-up modal (for scoped cancel UX). */ type StepUpSource = "tool" | "prompt" | "resource" | "ambient" | "app"; function App() { // Theme toggle plumbing (preserved from the pre-wire placeholder). const { setColorScheme } = useMantineColorScheme(); const computedColorScheme = useComputedColorScheme("light"); const isDark = computedColorScheme === "dark"; const onToggleTheme = useCallback(() => { setColorScheme(isDark ? "light" : "dark"); }, [isDark, setColorScheme]); // Server list — sourced from ~/.mcp-inspector/mcp.json via the backend's // `/api/servers` routes. First-launch seeds are written by the backend when // the file is absent, so this hook returns a non-empty list on first load. const { servers, addServer, updateServer, updateServerSettings, removeServer, reorderServers, importSource, } = useServers({ baseUrl: typeof window !== "undefined" ? window.location.origin : "http://localhost", authToken: getAuthToken(), }); // CRUD-modal state. `configModal` drives Add / Edit / Clone via a single // shared form modal; `removeTarget` drives the remove-confirmation modal. const [configModal, setConfigModal] = useState<{ mode: ServerConfigModalMode; targetId?: string; } | null>(null); // Import-flow modals (#1348): "Import from client config" (other-client // config merge) and "Import from registry config" (registry single-server // import). const [importConfigOpen, setImportConfigOpen] = useState(false); const [importJsonOpen, setImportJsonOpen] = useState(false); // Ids of freshly-added servers (manual or import) — their cards draw an // animated border (and the first scrolls into view) until clicked. A batch // import accumulates all of its ids here; opening an add/import modal starts a // fresh batch. (#1348) const [highlightedServerIds, setHighlightedServerIds] = useState( [], ); const clearHighlight = useCallback( (id: string) => setHighlightedServerIds((ids) => ids.filter((x) => x !== id)), [], ); const [settingsModalTargetId, setSettingsModalTargetId] = useState< string | undefined >(undefined); const [clientSettingsOpen, setClientSettingsOpen] = useState(false); const [connectionInfoModalOpen, setConnectionInfoModalOpen] = useState(false); const [removeTarget, setRemoveTarget] = useState(null); // Details for the output-schema-mismatch modal opened from the warning toast. const [outputValidationDetails, setOutputValidationDetails] = useState<{ toolName: string; message: string; } | null>(null); // Raw body for the non-spec URLElicitationRequired (-32042, no elicitations) // modal opened from its warning toast. const [urlElicitationErrorDetails, setUrlElicitationErrorDetails] = useState<{ toolName: string; details: string; } | null>(null); // The active connection target. `null` between sessions; set as soon as // the user toggles a server card on. Drives state-manager lifetime. const [activeServerId, setActiveServerId] = useState( undefined, ); // Id of the server whose last connection attempt failed (#1621). Drives the // red border on that ServerCard. It is deliberately NOT reset by // `resetSessionScopedUiState` (a failed connect fires the `disconnect` event, // which would otherwise clear the flag the same tick we set it); instead it is // cleared when a new connection attempt starts, and re-set if that attempt // also fails. const [failedServerId, setFailedServerId] = useState( undefined, ); // Id of the server that just connected successfully (#1682) — the success // mirror of `failedServerId`. Drives the green highlight + scroll-into-view on // that ServerCard. Set on the →connected transition, cleared when a new // connection attempt starts or the session disconnects. const [connectedServerId, setConnectedServerId] = useState< string | undefined >(undefined); // InspectorClient + per-primitive state managers. All recreated together // whenever the user switches active servers, then destroyed when the // next switch happens (or when the component unmounts). const [inspectorClient, setInspectorClient] = useState(null); // MCP Apps runtime wiring. `sandboxUrl` is the inspector's sandbox-proxy page // (the trusted outer iframe); `appRendererRef` lets the app handlers push tool // input/result into the running app and tear it down. The bridge factory wraps // the active client's underlying SDK client so the running view can call the // server, and reads the tool's UI resource into the sandbox on handshake. const appRendererRef = useRef(null); const configBaseUrl = typeof window !== "undefined" ? window.location.origin : "http://localhost"; // One `GET /api/config` fetch recovers every static payload field the app // reads: the MCP Apps `sandboxUrl`, the session's `writable` flag (read-only // `--config` / ad-hoc sessions hide catalog CRUD; the default catalog and // `--catalog` stay writable), and the Inspector `version` shown in the // lower-right corner (the browser can't read it off disk). const { sandboxUrl, writable: serverListWritable, version: inspectorVersion, secretStorage, refresh: refreshInitialConfig, loading: initialConfigLoading, } = useInitialConfig({ baseUrl: configBaseUrl, authToken: getAuthToken(), }); const [clientConfig, setClientConfig] = useState({}); 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]); // The `resources/list` entries, read lazily by the App bridge factories below. // ext-apps treats a listing entry's `_meta.ui` as the static default for its // UI resource (a read content item's own `_meta.ui` wins), so the sandbox CSP // has to be able to see it. A ref rather than a dependency: `resources` is // derived further down this component, and the factories only read it inside // an async sandboxready handler, long after any render that produced it. const listedResourcesRef = useRef([]); // Best-effort by construction: this reads the list as it stands when the app // opens, and does not distinguish "no entry for this URI" from "the list // hasn't arrived yet". Both yield no hints, which is the same outcome as // having no listing carrier at all. Blocking the render on list readiness // instead would mean waiting on a request that, for a server advertising no // `resources` capability or whose list errored, never resolves — trading a // missing default for an app that never renders. const getListedResourceMeta = useCallback( (uri: string) => listedResourcesRef.current.find((r) => r.uri === uri)?._meta, [], ); // `_meta.ui.domain` support (#2056): hand a wrapped app document to the // backend so it can serve it from a dedicated origin, giving the app's // requests a real `Origin`. Resolves `null` on any backend that can't, and // the factory then renders the app the default (opaque-origin) way. const publishDocument = useCallback( (doc: { html: string; csp?: string }) => publishAppDocument(doc, { baseUrl: configBaseUrl, authToken: getAuthToken(), }), [configBaseUrl], ); const sandboxBridgeFactory = useMemo( () => createAppBridgeFactory({ publishAppDocument: publishDocument, getClient: () => inspectorClient?.getAppRendererClient() ?? null, getListedResourceMeta, readResource: async (uri) => { if (!inspectorClient) throw new Error("No MCP client connected."); const invocation = await inspectorClient.readResource(uri); return invocation.result; }, // The bridge's sandboxready handler reads + posts the UI resource // inside a detached async block; without this hook a 404 / malformed // resource is console.error-only and the user stares at a blank // frame. Surface it as a toast. The renderer separately drives // `data-app-status` so an automated driver can time out on // never-reaching-"ready" and read the toast. onResourceError: (err) => { notifications.show({ title: "App resource failed to load", message: err.message, color: "red", }); }, }), [inspectorClient, getListedResourceMeta, publishDocument], ); // App-rendered form elicitations (#1854). The controller is created once and // handed to every InspectorClient at construction — its `render` is what opts // this client into advertising the nested MCP Apps `elicitation` capability, // which is why only the web client (the one with a sandbox) claims it. const appElicitationControllerRef = useRef(null); appElicitationControllerRef.current ??= new AppElicitationController(); const appElicitationController = appElicitationControllerRef.current; // The window onto the controller for the CURRENT client. Closing it when the // client is replaced both rejects that connection's queued requests and // refuses any it enqueues during its own (asynchronous) teardown — a late // entry would otherwise be rendered by a factory bound to the replacement // client, i.e. read and answered through a different server. const appElicitationSessionRef = useRef(null); // `setupClientForServer` is synchronous and memoized, so a caller that // awaited the config would still resume with the `sandboxUrl` captured by the // render it STARTED in — undefined, on the very load this matters for. The // ref is written every render, so client construction reads the current value // whichever entry point (connect, deep link, OAuth callback) reached it. const sandboxUrlRef = useRef(undefined); sandboxUrlRef.current = sandboxUrl; // Whether the sandbox exists is only known once `/api/config` resolves, and // the answer is baked into the client at construction (it decides whether the // nested MCP Apps `elicitation` capability is advertised). So a connect waits // for it rather than guessing: guessing "available" over-claims a capability // we may not have, and guessing "unavailable" strands the whole session on // the native form despite having a sandbox. The wait is a local fetch already // in flight since mount. const initialConfigSettledRef = useRef<{ promise: Promise; resolve: () => void; }>(null); initialConfigSettledRef.current ??= (() => { let resolve!: () => void; const promise = new Promise((r) => (resolve = r)); return { promise, resolve }; })(); useEffect(() => { if (!initialConfigLoading) initialConfigSettledRef.current?.resolve(); }, [initialConfigLoading]); const appElicitations = useSyncExternalStore( appElicitationController.subscribe, appElicitationController.getEntries, ); // A SECOND factory, differing from `sandboxBridgeFactory` only in that it // advertises `hostCapabilities.elicitation`. An App-tool frame is never handed // an elicitation, so telling those apps otherwise would be a false claim. const elicitationBridgeFactory = useMemo( () => createAppBridgeFactory({ advertiseElicitation: true, publishAppDocument: publishDocument, getClient: () => inspectorClient?.getAppRendererClient() ?? null, getListedResourceMeta, readResource: async (uri) => { if (!inspectorClient) throw new Error("No MCP client connected."); const invocation = await inspectorClient.readResource(uri); return invocation.result; }, // Unlike the Apps tab there is no persistent surface to show the // failure on — the modal is about to be replaced by the native form — // so the toast is the only place the user learns why. onResourceError: (err) => { notifications.show({ title: "Elicitation app failed to load", message: err.message, color: "red", }); }, }), [inspectorClient, getListedResourceMeta, publishDocument], ); /** * Close the previous client's session and open one for the client being * constructed. Synchronous, and called at construction, so the swap itself is * the moment ownership changes hands. */ const newAppElicitationSession = useCallback(() => { appElicitationSessionRef.current?.close( new Error("Connection replaced before the app answered"), ); const session = appElicitationController.openSession(); appElicitationSessionRef.current = session; return session; }, [appElicitationController]); const handleAppElicitationSettle = useCallback( (requestId: string, result: ElicitResult) => { appElicitationController.settle(requestId, result); }, [appElicitationController], ); const handleAppElicitationFail = useCallback( (requestId: string, error: Error) => { appElicitationController.fail(requestId, error); }, [appElicitationController], ); const [managedToolsState, setManagedToolsState] = useState(null); const [managedPromptsState, setManagedPromptsState] = useState(null); const [managedResourcesState, setManagedResourcesState] = useState(null); // Paged (paginated) counterparts, used when the `paginatedLists` setting // is on (#1721). Created/destroyed alongside the managed states. const [pagedToolsState, setPagedToolsState] = useState(null); const [pagedPromptsState, setPagedPromptsState] = useState(null); const [pagedResourcesState, setPagedResourcesState] = useState(null); const [managedResourceTemplatesState, setManagedResourceTemplatesState] = useState(null); const [managedRequestorTasksState, setManagedRequestorTasksState] = useState(null); const [resourceSubscriptionsState, setResourceSubscriptionsState] = useState(null); const [messageLogState, setMessageLogState] = useState(null); const [fetchRequestLogState, setFetchRequestLogState] = useState(null); const [stderrLogState, setStderrLogState] = useState( null, ); // Optimistic log level — `logging/setLevel` has no echo notification, so // the parent keeps the current value locally. const [currentLogLevel, setCurrentLogLevel] = useState("info"); // Modern-era per-request log level (#1629). `null` = not opted in (the modern // default: logs stay absent until the user picks a level, which the client // then stamps on every request's `_meta`). Separate from `currentLogLevel` // because the modern control has an "off" state legacy doesn't. const [modernLogLevel, setModernLogLevel] = useState( null, ); // In-flight call panel state. Tracked here (rather than inside the // respective screens) so the panels can reflect pending → ok/error // transitions and so `onClear*` handlers can reset the panel without // remounting the screen. const [toolCallState, setToolCallState] = useState( undefined, ); const [getPromptState, setGetPromptState] = useState< GetPromptState | undefined >(undefined); const [readResourceState, setReadResourceState] = useState< ReadResourceState | undefined >(undefined); // Per-screen selection / search / filter state, one object per screen. Lifted // here (out of the individual screens) so it persists across tab navigation // within a live session — the screens unmount on tab switch, so screen-local // state would be lost. Cleared only on disconnect (via // `resetSessionScopedUiState`) or an explicit user action, never on plain // navigation (#1414/#1417). The in-flight result panels (`toolCallState` / // `getPromptState` / `readResourceState`) stay separate — they're written by // the async action handlers below, not by the screens. const [toolsUi, setToolsUi] = useState(EMPTY_TOOLS_UI); const [promptsUi, setPromptsUi] = useState(EMPTY_PROMPTS_UI); const [resourcesUi, setResourcesUi] = useState(EMPTY_RESOURCES_UI); const [appsUi, setAppsUi] = useState(EMPTY_APPS_UI); const [tasksUi, setTasksUi] = useState(EMPTY_TASKS_UI); const [logsUi, setLogsUi] = useState(EMPTY_LOGS_UI); const [protocolUi, setProtocolUi] = useState(EMPTY_PROTOCOL_UI); // Protocol entries the user pinned (by entry id). Session-scoped — the ids // reference message-log entries, which clear on disconnect, so this resets // with the rest of the per-screen state. const [pinnedProtocolIds, setPinnedProtocolIds] = useState>( () => new Set(), ); const [networkUi, setNetworkUi] = useState(EMPTY_NETWORK_UI); const [consoleUi, setConsoleUi] = useState(EMPTY_CONSOLE_UI); const [activeTab, setActiveTab] = useState(INSPECTOR_SERVERS_TAB); const [pendingStepUp, setPendingStepUp] = useState<{ challenge: AuthChallenge; authorizationUrl: URL; serverId: string; source: StepUpSource; enterpriseManaged?: boolean; } | null>(null); const pendingStepUpRef = useRef(pendingStepUp); const pendingStepUpRetryRef = useRef<(() => Promise) | null>(null); useEffect(() => { pendingStepUpRef.current = pendingStepUp; }, [pendingStepUp]); const [reAuthBanner, setReAuthBanner] = useState<{ serverId: string; message: string; /** * `lost_authorization_state` marks the SEP-2352 recovery case (#1808): the * callback arrived with no recorded discovery state, so the banner offers * "Authorize again", which drops the stale/partial OAuth state before * starting a fresh authorization. */ kind?: "lost_authorization_state"; /** * Resolved at the point the banner is raised so `issuerBindingFailureCopy` * stays the single source of the `kind → copy` mapping; both fall back to * `ReAuthBanner`'s own defaults when absent. */ title?: string; actionLabel?: string; } | null>(null); const [pendingReauth, setPendingReauth] = useState( null, ); const pendingReauthRef = useRef(pendingReauth); useEffect(() => { pendingReauthRef.current = pendingReauth; }, [pendingReauth]); const reauthResumeInProgressRef = useRef(false); const stepUpAuthorizeInProgressRef = useRef(false); useEffect(() => { const pending = pendingReauthRef.current; if (pending && pending.serverId !== activeServerId) { setPendingReauth(null); } const stepUp = pendingStepUpRef.current; if (stepUp && stepUp.serverId !== activeServerId) { setPendingStepUp(null); } }, [activeServerId]); const trySetPendingStepUp = useCallback( (next: NonNullable): boolean => { if (pendingStepUpRef.current !== null) { notifications.show({ title: "Step-up authorization in progress", message: "Complete or cancel the current step-up prompt before starting another.", color: "yellow", autoClose: 5000, }); return false; } setPendingStepUp(next); return true; }, [], ); // Handshake telemetry. `connectStartRef` is set at the "connecting" edge // and consumed at the "connected" edge — a ref (not state) so the // intervening rerenders don't reset it. const connectStartRef = useRef(undefined); const [latencyMs, setLatencyMs] = useState(undefined); // One-shot guard for the `/oauth/callback` handler below. The effect waits // for the async `servers` list to hydrate, so it can run on more than one // render; this ref ensures the token exchange fires exactly once per load. const oauthCallbackHandledRef = useRef(false); const staleOAuthCheckedRef = useRef(false); /** Guards against applying the same OAuth resume snapshot more than once per load. */ const oauthResumeUiAppliedRef = useRef(false); // Tracks which progress streams currently have a live toast, so each new tick // updates the existing toast instead of stacking a fresh one. Entries are // removed when their toast closes (auto-dismiss or user). A ref (not state) // because it's incidental bookkeeping that must not trigger re-renders. const progressToastIdsRef = useRef>(new Set()); // Same bookkeeping for live task-status toasts (one per taskId), so each // `notifications/tasks/status` tick replaces the existing toast. const taskToastIdsRef = useRef>(new Set()); // The taskId of the in-flight task-augmented tool call, captured from the // `toolCallTaskUpdated` event `callToolStream` dispatches. Lets the Tool // detail panel's Cancel button cancel the underlying task on the server // (#1455) instead of no-op'ing. Reset at the start of every call, so an // ordinary (non-task) call leaves it undefined and its Cancel doesn't fire a // stray task cancellation. A ref (not state) because it's only read at the // moment Cancel is clicked and must not trigger re-renders. const activeToolCallTaskIdRef = useRef(undefined); // Per-task progress, keyed by taskId. Sourced from the core `requestorTaskProgress` // event (emitted by callToolStream, which owns the taskId), fed to the Tasks // screen so `TaskCard`'s progress bar renders for active tasks. Pruned on // terminal status (below) and reset on disconnect (`resetSessionScopedUiState`). const [progressByTaskId, setProgressByTaskId] = useState< Record >({}); // Hook layer. Each hook subscribes to its respective event source and // re-renders the App on change. When `inspectorClient` / state managers // are null, the hooks degrade to empty results. const { status: connectionStatus, capabilities, clientCapabilities, serverInfo, instructions, protocolVersion, protocolEra, discoverResult, excludedTools, malformedListItems, lastError, } = useInspectorClient(inspectorClient); const { tools: managedTools, error: toolsLoadError, listChanged: toolsListChanged, refresh: refreshTools, clearListChanged: clearToolsListChanged, } = useManagedTools(inspectorClient, managedToolsState); const { prompts: managedPrompts, error: promptsLoadError, listChanged: promptsListChanged, refresh: refreshPrompts, clearListChanged: clearPromptsListChanged, } = useManagedPrompts(inspectorClient, managedPromptsState); const { resources: managedResources, error: resourcesLoadError, listChanged: resourcesListChanged, refresh: refreshResources, clearListChanged: clearResourcesListChanged, } = useManagedResources(inspectorClient, managedResourcesState); const { resourceTemplates, error: resourceTemplatesLoadError, refresh: refreshResourceTemplates, } = useManagedResourceTemplates( inspectorClient, managedResourceTemplatesState, ); // Paged (paginated) list sources. When `paginatedLists` is on the managed // states skip their all-page walk and these drive the sidebar instead (#1721). const { tools: pagedTools, nextCursor: pagedToolsCursor, pageCount: pagedToolsPageCount, error: pagedToolsLoadError, loadPage: loadToolsPage, } = usePagedTools(inspectorClient, pagedToolsState); const { prompts: pagedPrompts, nextCursor: pagedPromptsCursor, pageCount: pagedPromptsPageCount, error: pagedPromptsLoadError, loadPage: loadPromptsPage, } = usePagedPrompts(inspectorClient, pagedPromptsState); const { resources: pagedResources, nextCursor: pagedResourcesCursor, pageCount: pagedResourcesPageCount, error: pagedResourcesLoadError, loadPage: loadResourcesPage, } = usePagedResources(inspectorClient, pagedResourcesState); // What every settings write that landed on disk actually wrote, so a later // failed write can be rolled back to that rather than to a `servers` entry // frozen at the last successful list read (#2089). const lastPersistedSettings = useLastPersistedSettings(servers); // The active server's persisted paginated setting drives the display mode. // The sidebar toggle edits it (optimistically, below) and persists it. const activeServerEntry = servers.find((s) => s.id === activeServerId); const persistedPaginatedLists = activeServerEntry?.settings?.paginatedLists ?? false; // The optimistic override, held per server and superseded by the first // successful list read that rebuilds that server's entry. Per server rather // than one app-wide slot cleared on every active-entry change: the previous // shape dropped the override on a plain server switch, so an A → B → A round // trip fell back to A's stale entry and showed a value neither disk nor the // live client held (#2095). const paginatedListsOverride = usePaginatedListsOverride(servers); const paginatedLists = paginatedListsOverride.valueFor(activeServerId) ?? persistedPaginatedLists; // The malformed-entry report is written by the aggregate walk's salvage. In // paginated mode the tools/prompts/resources panels render the paged stores // instead, which never write or clear it — so it would linger above a page it // does not describe. See `visibleMalformedListItems` (#1909 + #1721). const shownMalformedListItems = useMemo( () => visibleMalformedListItems(malformedListItems, paginatedLists), [malformedListItems, paginatedLists], ); const connected = connectionStatus === "connected"; const toolsPagination = usePaginatedList({ connected, paginated: paginatedLists, managedItems: managedTools, managedRefresh: refreshTools, managedError: toolsLoadError, pagedItems: pagedTools, pagedNextCursor: pagedToolsCursor, pagedPageCount: pagedToolsPageCount, pagedError: pagedToolsLoadError, loadPage: loadToolsPage, }); const promptsPagination = usePaginatedList({ connected, paginated: paginatedLists, managedItems: managedPrompts, managedRefresh: refreshPrompts, managedError: promptsLoadError, pagedItems: pagedPrompts, pagedNextCursor: pagedPromptsCursor, pagedPageCount: pagedPromptsPageCount, pagedError: pagedPromptsLoadError, loadPage: loadPromptsPage, }); const resourcesPagination = usePaginatedList({ connected, paginated: paginatedLists, managedItems: managedResources, managedRefresh: refreshResources, managedError: resourcesLoadError, pagedItems: pagedResources, pagedNextCursor: pagedResourcesCursor, pagedPageCount: pagedResourcesPageCount, pagedError: pagedResourcesLoadError, loadPage: loadResourcesPage, }); const tools = toolsPagination.items; const prompts = promptsPagination.items; const resources = resourcesPagination.items; // Whatever the resource list currently holds — every page in the default // aggregate mode, only the pages fetched so far under `paginatedLists`. The // listing is a documented *default* that a read content item overrides, and // an app whose entry sits on an unfetched page simply falls back to no hints // (`connect-src 'none'`), exactly as before this wiring existed. Walking the // whole list to close that would issue the very requests the user opted out // of by turning pagination on, so the setting wins. listedResourcesRef.current = resources; const { tasks, refresh: refreshTasks, clearCompleted: clearCompletedTasks, } = useManagedRequestorTasks(inspectorClient, managedRequestorTasksState); const { subscriptions, streamState: subscriptionStreamState } = useResourceSubscriptions(resourceSubscriptionsState); const { messages } = useMessageLog(messageLogState); const { fetchRequests } = useFetchRequestLog(fetchRequestLogState); const { stderrLogs } = useStderrLog(stderrLogState); // Fold the transport errors the SDK throws rather than delivers (e.g. -32601 // on HTTP 404) onto their still-pending Protocol requests, by correlating with // the Network log via JSON-RPC id. Returns `messages` unchanged when nothing // matched, so the Protocol view only re-renders when an enrichment applies. const protocolEntries = useMemo( () => enrichProtocolEntries(messages, fetchRequests), [messages, fetchRequests], ); // Surface the otherwise-invisible "response body dropped after rotation" case // (#1390) as a deduped toast that links to this server's Network Log Size // setting. The state manager only emits this when the drop is genuinely due // to rotation (log at capacity), not for benign post-clear stragglers. useEffect(() => { if (!fetchRequestLogState || activeServerId === undefined) return; const onBodyDropped = ( event: TypedEventGeneric< FetchRequestLogStateEventMap, "fetchRequestBodyDropped" >, ) => { notifications.show({ id: bodyDroppedToastId(activeServerId), title: "Network log: response body dropped", color: "yellow", // Stays until dismissed (or the user opens settings via the link) so a // single toast represents an ongoing condition rather than flashing per // drop; the stable id dedupes a storm into this one toast. autoClose: false, message: ( { notifications.hide(bodyDroppedToastId(activeServerId)); setSettingsModalTargetId(activeServerId); }} /> ), }); }; fetchRequestLogState.addEventListener( "fetchRequestBodyDropped", onBodyDropped, ); return () => { fetchRequestLogState.removeEventListener( "fetchRequestBodyDropped", onBodyDropped, ); }; }, [fetchRequestLogState, activeServerId]); // Server-initiated sampling / elicitation requests. These arrive while a call // (e.g. a tool execution) is in flight and block it until the user responds. const { pendingSamples, pendingElicitations } = usePendingClientRequests(inspectorClient); // Capture observed handshake latency at the connecting → connected edge. // Reset when the status leaves "connected" so the next connect starts // clean (otherwise a stale latency would render on the next session). useEffect(() => { if ( connectionStatus === "connected" && connectStartRef.current !== undefined ) { setLatencyMs(Date.now() - connectStartRef.current); connectStartRef.current = undefined; } else if (connectionStatus !== "connected") { setLatencyMs(undefined); } }, [connectionStatus]); // Track the just-connected server so its card gets the green highlight + // scroll-into-view (#1682). Unlike `failedServerId` (which must survive the // `disconnect` event a failed connect fires), "connected" is a stable status, // so a status-driven effect can both set and clear it: set on connect, clear // whenever the session isn't connected (disconnect, a new attempt's // "connecting", or an error). useEffect(() => { setConnectedServerId( connectionStatus === "connected" ? activeServerId : undefined, ); }, [connectionStatus, activeServerId]); // Disconnect the previous InspectorClient when it's replaced (server // switch) or when App unmounts (HMR, tests). Without this the prior // session's transport — a spawned stdio subprocess, an SSE stream, or // an HTTP session — stays open until GC eventually lets go. The // state-manager destroys in `setupClientForServer` only handle the // listener side; this effect handles the transport side. `disconnect()` // is the canonical lifecycle hook (InspectorClient has no `destroy()`); // it closes the transport, clears subscriptions, cancels receiver TTLs. useEffect(() => { return () => { if (inspectorClient) { void inspectorClient.disconnect(); } }; }, [inspectorClient]); // Reset the session-scoped UI state that lives in App.tsx (rather than // inside the per-server state managers), so the next server's screens don't // show server A's last result. The per-call panels (`toolCallState` / // `getPromptState` / `readResourceState`) and the optimistic // `currentLogLevel` all survive a disconnect/reconnect cycle otherwise — // see #1368. `latencyMs` is intentionally excluded: it resets via the // `connectionStatus` effect above, which has its own connecting-edge ref to // coordinate with. Colocated with the setters it touches so this is the // single place to extend as App.tsx accrues more per-session state (#1394). /** Clears pending OAuth resume state — explicit user disconnect only. */ const clearOAuthResumeOnExplicitDisconnect = useCallback(() => { clearOAuthResumeSnapshot(); oauthResumeUiAppliedRef.current = false; }, []); /** Snapshot cleanup plus shell reset when the user explicitly ends a session. */ const finalizeExplicitDisconnect = useCallback(() => { clearOAuthResumeOnExplicitDisconnect(); setActiveTab(INSPECTOR_SERVERS_TAB); }, [clearOAuthResumeOnExplicitDisconnect]); // Does not clear the OAuth resume snapshot — that is tied to an in-flight // full-page redirect and is cleared on explicit disconnect or consumed on callback. const resetSessionScopedUiState = useCallback(() => { setToolCallState(undefined); setGetPromptState(undefined); setReadResourceState(undefined); setToolsUi(EMPTY_TOOLS_UI); setPromptsUi(EMPTY_PROMPTS_UI); setResourcesUi(EMPTY_RESOURCES_UI); setAppsUi(EMPTY_APPS_UI); setTasksUi(EMPTY_TASKS_UI); setLogsUi(EMPTY_LOGS_UI); setProtocolUi(EMPTY_PROTOCOL_UI); setPinnedProtocolIds(new Set()); setNetworkUi(EMPTY_NETWORK_UI); // Only the search filter resets here; the stderr entries themselves live in // StderrLogState, which deliberately survives connect/disconnect so a failed // launch's output stays visible for diagnosis (#1621). setConsoleUi(EMPTY_CONSOLE_UI); setProgressByTaskId({}); setCurrentLogLevel("info"); // Re-seed rather than blank: the client restores its own opt-in from the // server setting at connect (`resetSessionState`), so blanking here would // leave the control reading Off while every modern request still carries // the level — visible on the auth-recovery path, which reconnects the same // client instance rather than rebuilding it (#1629, #1797). // `activeServerIdRef` is synced in a passive effect, so it still holds the // outgoing server's id when this runs from `onDisconnect` — which is what // lets the re-seed find its settings. Clearing that ref eagerly would take // the no-server branch below and silently drop this to Off. // Branch on the *server*, not its settings: an entry with no settings node // is the common case (`mcp.json` written by hand, never opened in Server // Settings), and there the default is right — it is what the seed and the // client both use. Only "no server at all" means Off. const activeServer = serversRef.current.find( (s) => s.id === activeServerIdRef.current, ); setModernLogLevel( activeServer ? (resolveModernLogLevel(activeServer.settings) ?? null) : null, ); setPendingStepUp(null); setPendingReauth(null); setReAuthBanner(null); // Remembered scroll offsets are session-scoped too — drop them so the next // session's screens start at the top (#1417). clearScrollMemory(); }, []); // Reset activeServerId whenever the live session ends. Without this the // other ServerCards stay `inert` after disconnect — ServerCard dims any // card whose id differs from `activeServer`. Subscribing to // InspectorClient's own `disconnect` event covers all three paths // (explicit toggle, header Disconnect button, mid-session transport // failure / process exit) and avoids the first-render-clobbers-new-id // trap that watching connectionStatus has (status starts as // "disconnected" for the new client before connect() runs). The // session-scoped panel/level reset rides along here too via // `resetSessionScopedUiState`. useEffect(() => { if (!inspectorClient) return; const onDisconnect = () => { setActiveServerId(undefined); // Drop the open flag too — without this the modal would pop back the // next time `initializeResult` re-becomes truthy (e.g. reconnect). setConnectionInfoModalOpen(false); resetSessionScopedUiState(); }; inspectorClient.addEventListener("disconnect", onDisconnect); return () => { inspectorClient.removeEventListener("disconnect", onDisconnect); }; }, [inspectorClient, resetSessionScopedUiState]); // Surface incoming `notifications/progress` as toasts so the user can watch a // long-running tool's progress while staying on the tool view — the v2 // replacement for v1's always-visible "Server Notifications" shelf (#1414). // The full notification history still lives in the Protocol tab; these toasts // are the at-a-glance, in-context signal. Toasts are keyed by progress stream // (see `progressToastId`) and replaced per tick so a chatty server updates one // toast rather than stacking one per tick. useEffect(() => { if (!inspectorClient) return; const liveToastIds = progressToastIdsRef.current; const onProgress = ( event: TypedEventGeneric, ) => { const detail = event.detail; const id = progressToastId(detail.progressToken); const message = formatProgressToastMessage(detail); if (liveToastIds.has(id)) { notifications.update({ id, title: "Tool progress", message, color: "blue", autoClose: PROGRESS_TOAST_AUTOCLOSE_MS, }); return; } liveToastIds.add(id); notifications.show({ id, title: "Tool progress", message, color: "blue", autoClose: PROGRESS_TOAST_AUTOCLOSE_MS, onClose: () => liveToastIds.delete(id), }); }; inspectorClient.addEventListener("progressNotification", onProgress); return () => { inspectorClient.removeEventListener("progressNotification", onProgress); // Dismiss any still-visible progress toasts when the client is swapped // out, then drop the stream bookkeeping. Hiding them (rather than letting // them auto-close up to PROGRESS_TOAST_AUTOCLOSE_MS later) keeps a stale // "Tool progress" toast from lingering into the next session, and avoids // a race where the lingering toast's `onClose` would later delete an id // from the *new* session's set and trigger a duplicate-id re-show. liveToastIds.forEach((id) => notifications.hide(id)); liveToastIds.clear(); }; }, [inspectorClient]); // Correlate task-call progress to the task it belongs to. `callToolStream` // emits `requestorTaskProgress` tagged with the taskId it owns (the generic // `progressNotification` above carries only the caller's progressToken), so we // build a taskId → progress map the Tasks screen reads to render each active // task's progress bar. Entries are pruned on terminal status (in the task- // status effect below) and the whole map resets on disconnect. useEffect(() => { if (!inspectorClient) return; const onTaskProgress = ( event: TypedEventGeneric< InspectorClientEventMap, "requestorTaskProgress" >, ) => { const { taskId, progress } = event.detail; setProgressByTaskId((prev) => ({ ...prev, [taskId]: { progress: progress.progress, total: progress.total, message: progress.message, }, })); }; inspectorClient.addEventListener("requestorTaskProgress", onTaskProgress); return () => { inspectorClient.removeEventListener( "requestorTaskProgress", onTaskProgress, ); }; }, [inspectorClient]); // Capture the in-flight task-augmented tool call's taskId so the detail // panel's Cancel button can cancel the task on the server (#1455). The id // only becomes known mid-call, when `callToolStream` dispatches // `toolCallTaskUpdated`, so we stash the latest into the ref the cancel // handler reads. `onCallTool` clears it at the start of each call. useEffect(() => { if (!inspectorClient) return; const onToolCallTaskUpdated = ( event: TypedEventGeneric, ) => { activeToolCallTaskIdRef.current = event.detail.taskId; }; inspectorClient.addEventListener( "toolCallTaskUpdated", onToolCallTaskUpdated, ); return () => { inspectorClient.removeEventListener( "toolCallTaskUpdated", onToolCallTaskUpdated, ); }; }, [inspectorClient]); // Surface live task status as per-task toasts — the v2 replacement for v1/v1.5's // inline "Task status: … Polling…" line under the Tool Result (#1422, consistent // with #1414). Subscribes to `taskStatusChange` (server `notifications/tasks/status`) // and `requestorTaskUpdated` (client-origin updates from `callToolStream`) — the // same sources the managed task store consumes; `toolCallTaskUpdated` is redundant // with `requestorTaskUpdated` so we skip it to avoid double-firing. One toast per // taskId, replaced per tick, dismissed on terminal status (which also prunes the // task's progress entry) and on client teardown. The full status history still // lives in the Protocol view. useEffect(() => { if (!inspectorClient) return; const liveToastIds = taskToastIdsRef.current; const handleTaskUpdate = (taskId: string, task: TaskToastInput) => { const id = taskToastId(taskId); const terminal = isTerminalTaskStatus(task.status); const title = `Task ${task.status}`; const message = formatTaskToastMessage(task); const color = taskToastColor(task.status); if (terminal) { // Drop the task's progress entry now that it can't change. setProgressByTaskId((prev) => { if (!(taskId in prev)) return prev; const next = { ...prev }; delete next[taskId]; return next; }); } if (liveToastIds.has(id)) { notifications.update({ id, title, message, color }); if (terminal) { notifications.hide(id); liveToastIds.delete(id); } return; } // A first sighting that's already terminal needs no toast at all. if (terminal) return; liveToastIds.add(id); notifications.show({ id, title, message, color, autoClose: false, onClose: () => liveToastIds.delete(id), }); }; const onTaskStatusChange = ( event: TypedEventGeneric, ) => { handleTaskUpdate(event.detail.taskId, event.detail.task); }; const onRequestorTaskUpdated = ( event: TypedEventGeneric, ) => { handleTaskUpdate(event.detail.taskId, event.detail.task); }; // A cancel goes out as `taskCancelled` (dispatched by cancelRequestorTask), // not as a status notification, so it would otherwise leave the running // task's live "Task " toast hanging with no confirmation. Replace // that toast (or show a fresh one) with a short "Task cancelled" toast, and // prune the now-dead progress entry. Covers both cancel paths — the Tasks // screen and the Tool detail panel — since both route through // cancelRequestorTask (#1455). const onTaskCancelled = ( event: TypedEventGeneric, ) => { const { taskId } = event.detail; setProgressByTaskId((prev) => { if (!(taskId in prev)) return prev; const next = { ...prev }; delete next[taskId]; return next; }); const id = taskToastId(taskId); const toast = { id, title: "Task cancelled", message: "The task was cancelled.", color: taskToastColor("cancelled"), autoClose: TASK_CANCELLED_TOAST_AUTOCLOSE_MS, }; if (liveToastIds.has(id)) { // Convert the open status toast into the auto-closing confirmation; drop // it from the live set so a trailing "cancelled" status tick (if the // server sends one) doesn't re-show it. notifications.update(toast); liveToastIds.delete(id); } else { notifications.show(toast); } }; inspectorClient.addEventListener("taskStatusChange", onTaskStatusChange); inspectorClient.addEventListener( "requestorTaskUpdated", onRequestorTaskUpdated, ); inspectorClient.addEventListener("taskCancelled", onTaskCancelled); return () => { inspectorClient.removeEventListener( "taskStatusChange", onTaskStatusChange, ); inspectorClient.removeEventListener( "requestorTaskUpdated", onRequestorTaskUpdated, ); inspectorClient.removeEventListener("taskCancelled", onTaskCancelled); // Hide any still-visible task toasts on client swap so they don't linger // into the next session, then drop the bookkeeping (mirrors the progress- // toast teardown above). liveToastIds.forEach((id) => notifications.hide(id)); liveToastIds.clear(); }; }, [inspectorClient]); // The Server Info modal needs the active server's transport and (optional) // OAuth details — both are co-located here so the modal opens against the // same connection snapshot the header is reading. Also feeds the // `initializeResult` serverInfo fallback below. const activeServer = useMemo( () => servers.find((s) => s.id === activeServerId), [servers, activeServerId], ); // Whether the server actually reported `serverInfo`, vs. the catalog-name // fallback synthesized below. Threaded to the Connection Info modal so it can // show "not reported" instead of an inferred name that looks server-sent. const serverInfoReported = serverInfo !== undefined; // Build the InitializeResult the connected ViewHeader / Connection Info // modal expect from the hook's split fields. `protocolVersion` is the value // the InspectorClient negotiated during initialize (#1324); it's dispatched // alongside serverInfo, so in practice it's present whenever we're connected. // We gate only on `connectionStatus`, never on serverInfo or protocolVersion: // this object also drives the connected header (and its whole tab bar) and the // Connection Info modal, so a missing field must not hide those. // // A modern-era server's `server/discover` makes `serverInfo` OPTIONAL (SHOULD, // not MUST — it's stamped in `_meta["io.modelcontextprotocol/serverInfo"]`), so // a conforming modern server may omit it and `serverInfo` stays undefined even // while connected. Falling back to the catalog name (rather than returning // `undefined`) keeps the header + tabs rendered for those servers (#1772). It // fires for such a modern server — and, harmlessly, in the batched instant on // any connect (legacy included) between the `connected` status dispatch and the // `serverInfo` dispatch, which land in a single React render. The modal uses // `serverInfoReported` (above), not this name, to stay faithful. // // This `??` only covers an *absent* serverInfo. A server that *reports* a // blank name (`{ name: "" }`) is degraded for display a layer below, in // InspectorView's `resolveHeaderServerInfo` (#1774) — kept there so this // faithful object (and thus the modal) never carries a borrowed name. const initializeResult = useMemo(() => { if (connectionStatus !== "connected") return undefined; const resolvedServerInfo = serverInfo ?? { name: activeServer?.name ?? "", version: "", }; return { protocolVersion: protocolVersion ?? "", capabilities: capabilities ?? {}, serverInfo: resolvedServerInfo, ...(instructions ? { instructions } : {}), }; }, [ connectionStatus, capabilities, serverInfo, instructions, protocolVersion, activeServer, ]); // Mirror the active server's name into a ref so a mid-session failure toast // can still name the server: a transport crash dispatches `disconnect`, // which clears `activeServerId` (and thus `activeServer`) before the // `lastError` effect below runs, so the ref is the only surviving handle. const activeServerNameRef = useRef(undefined); const activeServerIdRef = useRef(activeServerId); const serversRef = useRef(servers); // The live client, mirrored for the same reason: a settings write that // outlives a disconnect/reconnect to the *same* server would otherwise push // its value into the instance captured when it was issued — which has since // been destroyed — while the replacement client, built from the stale list // entry, keeps the value the write replaced (#2089). const inspectorClientRef = useRef(inspectorClient); useEffect(() => { if (activeServer) activeServerNameRef.current = activeServer.name; activeServerIdRef.current = activeServerId; serversRef.current = servers; inspectorClientRef.current = inspectorClient; }, [activeServer, activeServerId, servers, inspectorClient]); // Last connection-level error message, surfaced as `data-error-message` on // the InspectorView header so an automated driver can read *why* a connect // failed without scraping a transient toast. Cleared on the next connect // attempt and on successful connection. const [connectErrorMessage, setConnectErrorMessage] = useState< string | undefined >(undefined); // Named writer so a single call site can be extended later (e.g. telemetry) // and the intent (record a connection-level failure) stays explicit. const recordConnectError = useCallback((message: string) => { setConnectErrorMessage(message); }, []); // Deep-link parameters parsed once from the initial URL. Security gating // (auth-token match, http(s)-only serverUrl) happens inside `parseDeepLink`, // so a `DeepLink` value here is already validated. The parse status is // surfaced as `data-deeplink` so an automated driver can tell "no deep link" // from "deep link present but rejected" — both leave `data-status` idle. const [deepLink, deepLinkStatus] = useMemo< [DeepLink | undefined, DeepLinkParseStatus] >(() => { /* v8 ignore next -- SSR guard: happy-dom always defines window in tests */ if (typeof window === "undefined") return [undefined, "none"]; const search = window.location.search; const parsed = parseDeepLink(search, getAuthToken()); return [parsed, deepLinkParseStatus(search, parsed)]; }, []); const deepLinkEnsureRef = useRef(false); const deepLinkUpdateRef = useRef(false); const deepLinkConnectRef = useRef(false); const showReAuthBanner = useCallback( ( serverId: string, detail?: unknown, options?: { reason?: AuthChallengeReason }, ) => { const server = serversRef.current.find((s) => s.id === serverId); const message = reAuthBannerMessage({ serverName: server?.name, detail: detail !== undefined ? formatOAuthFailureDetail(detail) : undefined, }); const reason = options?.reason; if (reason !== undefined && !isReAuthBannerReason(reason)) { notifications.show({ title: "Authorization required", message, color: "yellow", autoClose: false, }); return; } setReAuthBanner({ serverId, message, }); }, [], ); // Surface a mid-session transport failure (stdio crash, SSE drop, HTTP 5xx) // as a toast. The handshake case is handled in `onToggleConnection`'s catch; // this covers the `status: connected → error` transition that fires the // client's `error` event without rejecting any awaited promise (#1323). // `lastError` clears at the next connecting edge, so each failure toasts once. useEffect(() => { if (!lastError) return; const name = activeServerNameRef.current; notifications.show({ title: name ? `Connection to "${name}" lost` : "Connection lost", message: lastError, color: "red", }); }, [lastError]); // `config.type` is optional in the schema (a bare `command: ...` // entry implies stdio), so we materialize the default here rather // than at the render site — the modal's `transport` prop is a // required `ServerType`, and we only render the modal once we know // there's an active server (see the `{initializeResult && activeServer && …}` // guard below). const connectionInfoTransport: ServerType = activeServer?.config.type ?? "stdio"; const [ connectionInfoOAuthWhenConnected, setConnectionInfoOAuthWhenConnected, ] = useState(undefined); const connectionInfoOAuth = connectionStatus === "connected" && inspectorClient ? connectionInfoOAuthWhenConnected : undefined; useEffect(() => { if (connectionStatus !== "connected" || !inspectorClient) { return; } let cancelled = false; const refresh = (): void => { void inspectorClient.getOAuthState().then((state) => { if (cancelled) return; setConnectionInfoOAuthWhenConnected( state ? oauthDetailsFromConnectionState(state) : undefined, ); }); }; const onAmbientAuthChallenge = (): void => { const name = activeServerNameRef.current; notifications.show({ title: name ? `Refreshing authorization for "${name}"` : "Refreshing authorization", message: "Refreshing authorization…", color: "blue", autoClose: 4000, }); }; refresh(); inspectorClient.addEventListener("oauthComplete", refresh); inspectorClient.addEventListener( "authChallengeAmbient", onAmbientAuthChallenge, ); return () => { cancelled = true; inspectorClient.removeEventListener("oauthComplete", refresh); inspectorClient.removeEventListener( "authChallengeAmbient", onAmbientAuthChallenge, ); }; }, [connectionStatus, inspectorClient]); const connectionInfoCanClearOAuth = connectionStatus === "connected" && !!inspectorClient && (connectionInfoTransport === "streamable-http" || connectionInfoTransport === "sse"); // Derive log entries from the message log. Filters for // `notifications/message` (the response to `logging/setLevel`). const logs = useMemo( () => messagesToLogEntries(messages), [messages], ); // Shared OAuth runtime store (oauth.json via /api/storage/oauth). Memoized so // connect, EMA IdP session, and per-server clear share one in-memory view. const webOAuthStorage = useMemo( () => getWebRemoteOAuthStorage(getAuthToken()), [], ); // Backend-backed session storage used to carry the fetch (Network) log // across the OAuth full-page redirect. The auth handshake's first half — // protected-resource + auth-server discovery and Dynamic Client // Registration — happens on the pre-redirect page; without persisting it // those `auth` entries would vanish when the browser navigates to the // authorization server. `FetchRequestLogState` saves to this on the // client's `saveSession` event (fired in `onBeforeOAuthRedirect`) keyed by // the OAuth authId, and restores from it when rebuilt on `/oauth/callback`. // Created once; `getAuthToken()` is stable for the page's lifetime. const sessionStorageAdapter = useMemo( () => new RemoteInspectorClientStorage({ baseUrl: typeof window !== "undefined" ? window.location.origin : "http://localhost", authToken: getAuthToken(), }), [], ); // Always points at the live `FetchRequestLogState` so the synchronous // pre-redirect hook below can read the current Network log without being // rebound every time the active server (and its log state) changes. const fetchLogRef = useRef(null); // Make the live client reflect one settings value, in full. Every path that // decides what the active server's settings *are* goes through this: the // settings modal's close (#1444), and each write's settled / rolled-back // reconciliation (#2089). It is one function because the live surface is more // than `setServerSettings` — a partial re-application would leave the Network // log sized for, and the server advertised roots from, a value that is not on // disk. Reads the client through its ref so a write outliving a reconnect // applies to the instance that exists now. const applyLiveServerSettings = useCallback( (settings: InspectorServerSettings) => { const client = inspectorClientRef.current; if (!client) return; // Settings the managed state reads at notification time // (auto-refresh-on-list-changed) take effect without a reconnect (#1444). // Connection-time inputs (transport, OAuth, timeouts) still only apply on // the next connect. client.setServerSettings(settings); // Resize the Network log buffer live so a maxFetchRequests edit takes // effect without a reconnect (shrinking trims immediately). fetchLogRef.current?.setMaxFetchRequests(settings.maxFetchRequests); // Root edits are diffed against what the client currently advertises // (both cleaned) and notified only when they differ: `setRoots` fires // `notifications/roots/list_changed`, which makes the server re-request // `roots/list`. const nextRoots = cleanRoots(settings.roots); const currentRoots = cleanRoots(client.getRoots()); if (JSON.stringify(nextRoots) !== JSON.stringify(currentRoots)) { void client.setRoots(nextRoots).catch(() => { // setRoots swallows notification failures internally; a throw here // only means the client is mid-teardown — the persisted roots will // re-advertise on the next connect, so nothing to surface. }); } }, [], ); // Flush the pre-redirect Network log to backend storage, keyed by the OAuth // authId carried in the authorization URL's `state`. Runs synchronously from // `BrowserNavigation` right before `window.location.href`, so the keepalive // POST it kicks off outlives the unloading page. The `/oauth/callback` // rebuild restores these entries via `FetchRequestLogState`'s `sessionId`. // Stable identity: it reads mutable refs, so it never needs to be rebuilt. const onBeforeOAuthRedirect = useCallback( (authorizationUrl: URL) => { const stateParam = authorizationUrl.searchParams.get("state"); const authId = stateParam ? (parseOAuthState(stateParam)?.authId ?? undefined) : undefined; if (!authId) return; const fetchRequests = fetchLogRef.current?.getFetchRequests() ?? []; if (fetchRequests.length === 0) return; const now = Date.now(); // Fire-and-forget: the keepalive request inside `saveSession` is // dispatched synchronously here, before navigation commits. void sessionStorageAdapter .saveSession(authId, { fetchRequests, createdAt: now, updatedAt: now, }) .catch(() => { // Best-effort; losing the pre-redirect log is non-fatal. }); }, [sessionStorageAdapter], ); const prepareOAuthRedirect = useCallback( ({ serverId, authKind, authorizationUrl, authChallenge, recoverySource, preRedirectContext, client, }: { serverId: string; authKind: OAuthResumeAuthKind; authorizationUrl: URL; authChallenge?: AuthChallenge; recoverySource?: OAuthRecoverySource; preRedirectContext?: OAuthPreRedirectContext; client?: InspectorClient; }) => { setReAuthBanner(null); const server = serversRef.current.find((s) => s.id === serverId); const preRedirectToast = oauthPreRedirectToastCopy(authKind, { serverName: server?.name, enterpriseManaged: server?.settings?.enterpriseManaged, context: preRedirectContext, }); if (preRedirectToast) { notifications.show({ title: preRedirectToast.title, message: preRedirectToast.message, color: "blue", autoClose: 4000, }); } const oauthClient = client ?? inspectorClient; const remoteSessionId = oauthClient?.getRemoteBackendSessionId(); onBeforeOAuthRedirect(authorizationUrl); // Write immediately before navigation so implicit client teardown (during // connect setup) cannot clear the snapshot after we persist it. writeOAuthResumeSnapshot({ version: 1, serverId, activeTab, authKind, tabUi: buildTabUiSnapshot({ toolsUi, promptsUi, resourcesUi, appsUi, tasksUi, logsUi, protocolUi, networkUi, }), ...(remoteSessionId && { remoteSessionId }), ...(authKind === "step_up" && authChallenge && { authChallenge }), ...(recoverySource && { recoverySource }), }); void oauthClient?.beginInteractiveAuthorization(authorizationUrl); }, [ inspectorClient, activeTab, toolsUi, promptsUi, resourcesUi, appsUi, tasksUi, logsUi, protocolUi, networkUi, onBeforeOAuthRedirect, ], ); const tryApplyStoredAuthRecovery = useCallback( async ( client: InspectorClient, challenge: AuthChallenge, recoverySource?: OAuthRecoverySource, ): Promise => { if (!(await client.checkAuthChallengeSatisfied(challenge))) { return false; } await client.pushRemoteAuthState(); notifications.show({ title: "Authorization restored", message: authRecoveryRestoredMessage({ recoverySource }), color: "green", autoClose: 4000, }); return true; }, [], ); const runVisibleInteractiveAuth = useCallback( ({ serverId, challenge, authorizationUrl, source = "ambient", }: { serverId: string; challenge: AuthChallenge; authorizationUrl: URL; source?: StepUpSource; }) => { const server = serversRef.current.find((s) => s.id === serverId); if (isStepUpConfirmation(challenge, server)) { trySetPendingStepUp({ challenge, authorizationUrl, serverId, source, enterpriseManaged: isEmaStepUp(challenge, server), }); return; } prepareOAuthRedirect({ serverId, authKind: "reauth", authorizationUrl, recoverySource: source, }); }, [prepareOAuthRedirect, trySetPendingStepUp], ); const deferAmbientReauth = useCallback((pending: PendingReauth) => { if (pendingReauthRef.current) { notifications.show({ title: "Authorization update pending", message: "A new authorization request replaced the previous deferred recovery.", color: "yellow", autoClose: 5000, }); } else { notifications.show({ title: "Authorization pending", message: "Return to this tab to continue authorization.", color: "blue", autoClose: 5000, }); } setPendingReauth(pending); }, []); const handleCommandScopedAuthRecovery = useCallback( async ( error: AuthRecoveryRequiredError, options: { serverId: string; source: StepUpSource; retryOperation?: () => Promise; }, ): Promise => { if (!inspectorClient) { return false; } const server = serversRef.current.find((s) => s.id === options.serverId); const stepUp = error.emaStepUpConfirm || isStepUpConfirmation(error.authChallenge, server); if (stepUp) { if ( await tryApplyStoredAuthRecovery( inspectorClient, error.authChallenge, options.source, ) ) { return true; } pendingStepUpRetryRef.current = options.retryOperation ?? null; trySetPendingStepUp({ challenge: error.authChallenge, authorizationUrl: error.authorizationUrl, serverId: options.serverId, source: options.source, enterpriseManaged: isEmaStepUp(error.authChallenge, server), }); return false; } if ( await tryApplyStoredAuthRecovery( inspectorClient, error.authChallenge, options.source, ) ) { return true; } prepareOAuthRedirect({ serverId: options.serverId, authKind: "reauth", authorizationUrl: error.authorizationUrl, recoverySource: options.source, }); return false; }, [ inspectorClient, prepareOAuthRedirect, tryApplyStoredAuthRecovery, trySetPendingStepUp, ], ); const runWithCommandAuthRecovery = useCallback( async ( operation: () => Promise, source: StepUpSource, ): Promise => { if (!inspectorClient || !activeServerId) { return operation(); } try { return await operation(); } catch (err) { if (err instanceof AuthRecoveryRequiredError) { const satisfied = await handleCommandScopedAuthRecovery(err, { serverId: activeServerId, source, retryOperation: operation, }); if (satisfied) { return operation(); } return undefined; } throw err; } }, [inspectorClient, activeServerId, handleCommandScopedAuthRecovery], ); /** * Fire-and-forget form of {@link runWithCommandAuthRecovery}, for a command * whose caller has nothing to await (a click handler, a mode toggle). * * The wrapper rethrows anything that is not an `AuthRecoveryRequiredError`, * so a bare `void runWithCommandAuthRecovery(...)` turns every non-auth * failure — a transport error, a rejected `tools/list` — into an unhandled * rejection in the browser (#2049). Routing every background command through * here is what keeps a new call site from reintroducing that gap by * omission: there is no `void` to forget. * * `errorTitle` picks the reporting, and the choice is per call site: * * - **Omit it** when the operation's own state already renders the failure — * the list loads, whose managed/paged stores record the error and whose * panel shows it with a Retry. A toast there would only duplicate what the * user is already looking at, so the rejection is swallowed deliberately. * - **Pass one** when nothing else records the failure, and the user would * otherwise see the command silently do nothing. */ const runCommandInBackground = useCallback( ( operation: () => Promise, source: StepUpSource, errorTitle?: string, ): void => { void runWithCommandAuthRecovery(operation, source).catch( (err: unknown) => { if (!errorTitle) return; notifications.show({ title: errorTitle, message: err instanceof Error ? err.message : String(err), color: "red", }); }, ); }, [runWithCommandAuthRecovery], ); const resumePendingReauth = useCallback( async (pending: PendingReauth) => { if (reauthResumeInProgressRef.current) { return; } const client = inspectorClient; if (!client) { return; } if (connectionStatus !== "connected") { return; } if (pending.serverId !== activeServerIdRef.current) { return; } reauthResumeInProgressRef.current = true; setPendingReauth(null); try { if ( await tryApplyStoredAuthRecovery( client, pending.challenge, pending.source, ) ) { return; } if (pending.authKind === "step_up") { runVisibleInteractiveAuth({ serverId: pending.serverId, challenge: pending.challenge, authorizationUrl: pending.authorizationUrl, source: pending.source, }); return; } const outcome = await client.handleAuthChallenge(pending.challenge); if (outcome.kind === "satisfied") { await client.pushRemoteAuthState(); notifications.show({ title: "Authorization restored", message: authRecoveryRestoredMessage({ recoverySource: pending.source, }), color: "green", autoClose: 4000, }); return; } if (outcome.kind === "interactive") { runVisibleInteractiveAuth({ serverId: pending.serverId, challenge: outcome.challenge, authorizationUrl: outcome.authorizationUrl, source: pending.source, }); return; } if (outcome.kind === "failed") { showReAuthBanner(pending.serverId, outcome.error, { reason: pending.challenge.reason, }); } } finally { reauthResumeInProgressRef.current = false; } }, [ inspectorClient, connectionStatus, tryApplyStoredAuthRecovery, runVisibleInteractiveAuth, showReAuthBanner, ], ); useEffect(() => { if (connectionStatus !== "connected" || !inspectorClient) { return; } const onAuthChallengeInteractive = ( event: TypedEvent<"authChallengeInteractive">, ): void => { void (async () => { const { challenge, authorizationUrl } = event.detail; const serverId = activeServerIdRef.current; if (!serverId) { return; } const server = serversRef.current.find((s) => s.id === serverId); const authKind: OAuthResumeAuthKind = isStepUpConfirmation( challenge, server, ) ? "step_up" : "reauth"; if (!isBrowserTabVisible()) { deferAmbientReauth({ serverId, challenge, authorizationUrl, authKind, source: "ambient", }); return; } if (await tryApplyStoredAuthRecovery(inspectorClient, challenge)) { return; } runVisibleInteractiveAuth({ serverId, challenge, authorizationUrl, source: "ambient", }); })(); }; inspectorClient.addEventListener( "authChallengeInteractive", onAuthChallengeInteractive, ); return () => { inspectorClient.removeEventListener( "authChallengeInteractive", onAuthChallengeInteractive, ); }; }, [ connectionStatus, inspectorClient, tryApplyStoredAuthRecovery, runVisibleInteractiveAuth, deferAmbientReauth, ]); useEffect(() => { return onBrowserTabVisible(() => { const pending = pendingReauthRef.current; if (pending) { void resumePendingReauth(pending); } }); }, [resumePendingReauth]); // Resume deferred background-tab recovery once the session reconnects. useEffect(() => { if (connectionStatus !== "connected" || !inspectorClient) { return; } if (!isBrowserTabVisible()) { return; } const pending = pendingReauthRef.current; if (pending) { void resumePendingReauth(pending); } }, [connectionStatus, inspectorClient, resumePendingReauth]); useEffect(() => { if (connectionStatus !== "connected" || !inspectorClient) { return; } const onOAuthError = (event: TypedEvent<"oauthError">): void => { const serverId = activeServerIdRef.current; if (!serverId) { return; } showReAuthBanner(serverId, event.detail.error); }; inspectorClient.addEventListener("oauthError", onOAuthError); return () => { inspectorClient.removeEventListener("oauthError", onOAuthError); }; }, [connectionStatus, inspectorClient, showReAuthBanner]); // Detect an abandoned full-page OAuth redirect (snapshot left, no callback). useEffect(() => { if (staleOAuthCheckedRef.current) return; if (typeof window === "undefined") return; if (window.location.pathname === OAUTH_CALLBACK_PATH) return; if (servers.length === 0) return; staleOAuthCheckedRef.current = true; const snapshot = consumeOAuthResumeSnapshot(); if (!snapshot) { return; } queueMicrotask(() => { if (snapshot.authKind === "reauth") { showReAuthBanner( snapshot.serverId, oauthResumeAbandonedMessage(snapshot.authKind), ); return; } if (snapshot.authKind === "step_up") { showReAuthBanner( snapshot.serverId, oauthResumeAbandonedMessage(snapshot.authKind, { recoverySource: snapshot.recoverySource, }), ); } }); }, [servers, showReAuthBanner]); // Wire up + tear down per active server. Called by `onToggleConnection` // when the user switches targets. Returns the new client so the toggle // can call `connect()` against it before React re-renders. const setupClientForServer = useCallback( (server: ServerEntry, sessionId?: string): InspectorClient => { // Tear down the previous session's managers — each destroy() // unsubscribes from the old client's events. Skipped on the first // call (initial values are null). managedToolsState?.destroy(); managedPromptsState?.destroy(); managedResourcesState?.destroy(); pagedToolsState?.destroy(); pagedPromptsState?.destroy(); pagedResourcesState?.destroy(); managedResourceTemplatesState?.destroy(); managedRequestorTasksState?.destroy(); resourceSubscriptionsState?.destroy(); messageLogState?.destroy(); fetchRequestLogState?.destroy(); stderrLogState?.destroy(); const { environment, logger } = createWebEnvironment( getAuthToken(), redirectUrlProvider, onBeforeOAuthRedirect, ); // The settings node persisted in mcp.json for this server — distinct // from the InspectorClient options we're about to derive from it. // // Through the tracker, not `server.settings` directly: the entry only // advances on a successful list read, so a write that landed while reads // were failing — including one made from the settings modal for a server // that was not connected at the time — would otherwise be undone at the // next connect, which builds the client from that frozen entry. This is // the one place the whole connection is configured, so every construction // path (connect, reconnect, OAuth resume) goes through it (#2089). const savedSettings = lastPersistedSettings.resolve(server.id) ?? server.settings; const activeIdp = getActiveEnterpriseManagedAuthIdp(clientConfig); const activeCimdUrl = getActiveCimdClientMetadataUrl(clientConfig); // Flatten the persisted settings into the InspectorClient options shape. // Empty / zero values stay unset so the SDK defaults apply. // Per-server default `_meta` is already a JSON object (#1910) — no // pair-array flattening left to do; `{}` means "no defaults". const defaultMetadata = savedSettings?.metadata; 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 oauth = oauthFromServer || activeCimdUrl ? { ...(oauthFromServer ?? {}), ...(activeCimdUrl && { clientMetadataUrl: activeCimdUrl }), } : undefined; // The stdio `env` / `cwd` are edited as *settings* but stored on — and // read by the transport from — *config*, so the tracker's account of the // former has to be carried onto the latter. `server.config` comes off the // same frozen `servers` entry `server.settings` does, so without this a // save that landed while list reads were failing spawns the child process // with the pre-save environment while the modal, re-seeded from the // tracker, shows the new one (#2096). Same mapping the PUT route applies // when persisting it, from the same helper. const effectiveConfig = applyStdioSettingsToConfig( server.config, savedSettings, ); const client = new InspectorClient(effectiveConfig, { environment, // The Tasks tab needs the receiver-task pipeline; the // requestor-task list comes from the client's task store. receiverTasks: true, // Sampling / elicitation are on by default; keep the parameterized // options off until the UI grows the surface to render them. elicit: { form: true, url: true }, // Web only, and only when the sandbox renderer is actually available: // supplying this advertises the nested MCP Apps `elicitation` // capability, and a client that cannot host an app must not claim it // (#1854). Callers await `initialConfigSettled` first, so `sandboxUrl` // here means "confirmed absent" rather than "not known yet" — a // connection that reaches this with no sandbox behaves like the // CLI/TUI: native elicitation queue, no claim made to the server. ...(sandboxUrlRef.current && { appElicitation: newAppElicitationSession().render, }), // Always advertise the roots capability (even with no configured // roots) so the server can issue roots/list and receive // roots/list_changed; the configured roots are the answer to // roots/list. Empty-uri rows are dropped before they reach the wire. roots: cleanRoots(savedSettings?.roots ?? []), ...(savedSettings && savedSettings.requestTimeout > 0 && { timeout: savedSettings.requestTimeout, }), ...(defaultMetadata && Object.keys(defaultMetadata).length > 0 && { defaultMetadata, }), ...(oauth && { oauth }), ...(activeIdp && { enterpriseManagedAuth: { idp: activeIdp }, }), ...(clientConfig.enterpriseManagedAuth && { installEnterpriseManagedAuth: clientConfig.enterpriseManagedAuth, }), ...(savedSettings && { serverSettings: savedSettings }), // Per-server protocol era (SEP §7.8) → SDK versionNegotiation. Absent // settings or an unset era default to legacy inside // eraToVersionNegotiation / the InspectorClient constructor (#1626). ...(savedSettings?.protocolEra && { versionNegotiation: eraToVersionNegotiation( savedSettings.protocolEra, ), }), // Per-server advertised-extension overrides (#1739). Absent/empty falls // back to the registry defaults in the InspectorClient constructor. ...(savedSettings?.advertisedExtensions && Object.keys(savedSettings.advertisedExtensions).length > 0 && { advertisedExtensions: savedSettings.advertisedExtensions, }), // Set on the `/oauth/callback` rebuild so the client's `saveSession` // events (and any later persistence) key off the same OAuth authId // the pre-redirect page saved under. ...(sessionId && { sessionId }), }); setInspectorClient(client); // #1629: seed the live modern per-request log level from the server // setting so the Logs-tab control reflects what the client stamps by // default (the client was seeded the same way in its constructor). "off" // means not opted in (null). Only affects modern connections. setModernLogLevel(resolveModernLogLevel(savedSettings) ?? null); setManagedToolsState(new ManagedToolsState(client)); setPagedToolsState(new PagedToolsState(client)); setPagedPromptsState(new PagedPromptsState(client)); setPagedResourcesState(new PagedResourcesState(client)); setManagedPromptsState(new ManagedPromptsState(client)); const nextResourcesState = new ManagedResourcesState(client); setManagedResourcesState(nextResourcesState); setManagedResourceTemplatesState( new ManagedResourceTemplatesState(client), ); setManagedRequestorTasksState(new ManagedRequestorTasksState(client)); // ResourceSubscriptionsState consults the managed resources list to // resolve subscribed URIs to full Resource objects (so the subscription // tile shows the server-supplied name/title). Pass the freshly created // state to avoid the React update lag from setManagedResourcesState. setResourceSubscriptionsState( new ResourceSubscriptionsState(client, nextResourcesState), ); setMessageLogState(new MessageLogState(client)); // Wire session storage so the fetch log survives the OAuth redirect. // When `sessionId` is supplied (the `/oauth/callback` rebuild) the prior // page's `auth` entries are restored on construction; the actual save is // driven synchronously from `onBeforeOAuthRedirect` above (keyed by the // same authId). Keep `fetchLogRef` pointed at this instance so that hook // reads the current log. const nextFetchLog = new FetchRequestLogState(client, { sessionStorage: sessionStorageAdapter, logger, maxFetchRequests: savedSettings?.maxFetchRequests ?? DEFAULT_MAX_FETCH_REQUESTS, ...(sessionId && { sessionId }), }); fetchLogRef.current = nextFetchLog; setFetchRequestLogState(nextFetchLog); setStderrLogState(new StderrLogState(client)); return client; }, [ managedToolsState, managedPromptsState, managedResourcesState, pagedToolsState, pagedPromptsState, pagedResourcesState, managedResourceTemplatesState, managedRequestorTasksState, resourceSubscriptionsState, messageLogState, fetchRequestLogState, stderrLogState, sessionStorageAdapter, onBeforeOAuthRedirect, clientConfig, newAppElicitationSession, lastPersistedSettings, ], ); // Finish the OAuth authorization-code flow when the auth server redirects // back to `/oauth/callback`. This runs on a fresh page load (the redirect in // `onToggleConnection` unloaded the previous one), so all React state is // reset and we recover the initiating server from sessionStorage. We wait for // `servers` to hydrate before acting; the ref guard keeps the exchange to a // single run. The persisted PKCE verifier + DCR client info live in shared // `RemoteOAuthStorage` (`oauth.json`) and survive the redirect, so // `completeOAuthFlow` exchanges the code without needing the original // in-memory state machine. useEffect(() => { if (typeof window === "undefined") return; if (window.location.pathname !== OAUTH_CALLBACK_PATH) return; if (oauthCallbackHandledRef.current) return; // `useServers` returns [] until the first fetch resolves; defer until the // list is populated so `find` can resolve the pending server. if (servers.length === 0) return; oauthCallbackHandledRef.current = true; const params = parseOAuthCallbackParams(window.location.search); // The OAuth `state` round-trips the auth session id; 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. // // Defense-in-depth: a `state` that is present but does not parse to our // expected 64-char-hex authId shape did not originate from // `generateOAuthState`, so reject the callback instead of silently // proceeding with an undefined sessionId. This is a shape check, not full // state-matching — the primary CSRF protection remains PKCE // (`code_verifier`); this layer catches malformed/forged `state` early. // // Intentional asymmetry: a present-but-malformed `state` (including the // empty string `?state=`) is rejected, but a wholly absent `state` // (`stateParam === null`) is *not* — it falls through with // `sessionId = undefined` and is matched via the OAuth resume snapshot // (`resumeSnapshot.serverId`) instead. Rejecting the null case would turn any provider error redirect // that omits `state` into a misleading "rejected" toast, hiding the real // OAuth error surfaced by the `!params.successful` branch below. const stateParam = new URLSearchParams(window.location.search).get("state"); const parsedState = stateParam ? parseOAuthState(stateParam) : null; const stateRejected = stateParam !== null && parsedState === null; const sessionId = parsedState?.authId ?? undefined; const resumeSnapshot = consumeOAuthResumeSnapshot(); // Strip the code/state off the URL immediately so a reload can't replay // the (now single-use) authorization code through the exchange again. window.history.replaceState({}, "", "/"); if (stateRejected) { notifications.show({ title: "OAuth callback rejected", message: "OAuth callback carried an unrecognized state parameter that did not originate from this session. Please try connecting again.", color: "red", }); return; } const applyResumeUiOnce = ( snapshot: NonNullable, ) => { if (oauthResumeUiAppliedRef.current) { return; } applyOAuthResumeUi(snapshot, { setToolsUi, setPromptsUi, setResourcesUi, setAppsUi, setTasksUi, setLogsUi, setProtocolUi, setNetworkUi, setActiveTab, clearToolCallState: () => setToolCallState(undefined), clearGetPromptState: () => setGetPromptState(undefined), clearReadResourceState: () => setReadResourceState(undefined), }); oauthResumeUiAppliedRef.current = true; }; if (resumeSnapshot && params.successful) { applyResumeUiOnce(resumeSnapshot); } if (!params.successful) { const pendingId = resumeSnapshot?.serverId; if (pendingId) { // Red border only (#1621), not a sidebar. This arm returns before a // client is rebuilt, so the persisted `auth` entries are never // restored and the content-gated column stays shut — correctly: the // provider's own `error` param is the whole diagnostic, and the // re-auth banner below is already showing it. The flag is still right, // because the attempt did fail. setFailedServerId(pendingId); queueMicrotask(() => { showReAuthBanner(pendingId, generateOAuthErrorDescription(params)); }); } else { notifications.show({ title: "OAuth authorization failed", message: generateOAuthErrorDescription(params), color: "red", }); } return; } const pendingId = resumeSnapshot?.serverId; const server = pendingId ? servers.find((s) => s.id === pendingId) : undefined; if (!server) { notifications.show({ title: "OAuth callback could not be matched", message: "Could not determine which server started the OAuth flow. Please try connecting again.", color: "red", }); return; } void (async () => { // Same reason as the connect path: whether this client may advertise // app-rendered elicitation is fixed at construction. await initialConfigSettledRef.current?.promise; try { await webOAuthStorage.load(); } catch (err) { connectStartRef.current = undefined; // Red border only, for the same reason as the provider-error arm above // — and here the network log would not help anyway: this is a failure // to read local OAuth storage, not a request that went out. setFailedServerId(server.id); queueMicrotask(() => { showReAuthBanner(server.id, err instanceof Error ? err : String(err)); }); return; } const client = setupClientForServer(server, sessionId); setActiveServerId(server.id); try { connectStartRef.current = Date.now(); await client.resumeAfterOAuth(params.code, { remoteSessionId: resumeSnapshot?.remoteSessionId, iss: params.iss, }); } catch (err) { connectStartRef.current = undefined; // `resumeAfterOAuth` carries the reconnect, and that reconnect can // reject with an auth-recovery error — which holds the status at // `"connecting"` instead of moving it to `"error"`. Nothing downstream // of here ends the attempt, so without this the toggle is stuck and the // active-server lock is never released. Before the EMA guard, since a // stuck session is worth clearing whichever way the error classifies. await client.disconnect().catch(() => {}); // `activeServerId` is deliberately NOT cleared here, even though the // disconnect above often cannot announce itself: it emits only on a // status *change*, and the commonest failure — a rejected token // exchange — throws inside `completeOAuthFlow` before the reconnect // runs, so this freshly built client is still at its initial // `"disconnected"` and the listener that would clear it never fires. // // That looks like a leak and is not. The next step after a callback // failure is the re-auth banner below, and its "Authorize again" hands // `clearServerOAuthState` the live client only when the banner's server // *is* the active one. Releasing it here would pass `null` instead, and // the stale tokens would never be cleared from the client that holds // them — the one thing that recovery exists to do. if (isEmaClientNotConfiguredError(err)) { notifications.show({ title: `Cannot connect to "${server.name}"`, message: err.message, color: "red", autoClose: false, }); return; } // The token exchange (or the re-handshake behind it) failed. Flag the // server (#1621) so the monitoring sidebar opens onto the OAuth // requests that explain it (#2108) — the rebuilt client restored the // pre-redirect `auth` fetch entries from the session, so discovery, // DCR and the token exchange are all there. // // Below the EMA guard, not above it: an unconfigured enterprise client // is a *configuration* error rather than a failed attempt, and both // connect-path arms already return on it without flagging. Flagging it // only here would make the three disagree about what the red border // means. Above every other arm, so the classification fan-out that // follows carries it whichever way it goes. setFailedServerId(server.id); // SEP-2352 issuer binding (#1808). Two very different failures share // one SDK error class, so classify before falling through to the // generic re-auth banner (whose detail line would otherwise be the raw // "AuthorizationServerMismatchError" text): // - no discovery state was recorded → recoverable bookkeeping loss, // surface the "Authorize again" affordance; // - a *different* issuer answered the callback → security signal, so // no one-click recovery is offered. const issuerBindingFailure = findIssuerBindingFailure(err); if (issuerBindingFailure) { const copy = issuerBindingFailureCopy(issuerBindingFailure, { serverName: server.name, }); if (issuerBindingFailure.kind === "lost_authorization_state") { setReAuthBanner({ serverId: server.id, message: copy.message, kind: "lost_authorization_state", title: copy.title, actionLabel: lostAuthorizationStateActionLabel(), }); } else { notifications.show({ title: copy.title, message: copy.message, color: "red", autoClose: false, }); } return; } queueMicrotask(() => { showReAuthBanner(server.id, err instanceof Error ? err : String(err)); }); return; } setReAuthBanner(null); if (resumeSnapshot) { const stepUpChallenge = resumeSnapshot.authKind === "step_up" ? resumeSnapshot.authChallenge : undefined; const stepUpScopesGranted = !stepUpChallenge || (await client.checkAuthChallengeSatisfied(stepUpChallenge)); if (stepUpChallenge && !stepUpScopesGranted) { notifications.show({ title: "Additional permissions not granted", message: oauthResumeInsufficientScopeMessage(stepUpChallenge), color: "yellow", autoClose: false, }); return; } notifications.show({ title: "Authorization complete", message: oauthResumeToastMessage(resumeSnapshot.authKind, { recoverySource: resumeSnapshot.recoverySource, }), color: "green", autoClose: 6000, }); } })(); }, [servers, setupClientForServer, showReAuthBanner, webOAuthStorage]); const onToggleConnection = useCallback( async (id: string) => { // Whether this client may advertise app-rendered elicitation is decided // at construction and cannot be revised afterwards, so wait for the fact // rather than guess it (see `initialConfigSettledRef`). Already resolved // by the time any human clicks; this only orders a deep-link auto-connect // that races the same page load. await initialConfigSettledRef.current?.promise; // Same server, already connected → disconnect. if ( id === activeServerId && connectionStatus === "connected" && inspectorClient ) { try { await inspectorClient.disconnect(); } finally { finalizeExplicitDisconnect(); } return; } // Read from the ref so a caller that already awaited an // addServer/updateServer in the same async tick (e.g. the deep-link // auto-connect IIFE) sees the freshly-mutated list, not the stale array // captured by this callback's closure. const target = serversRef.current.find((s) => s.id === id); if (!target) return; // Always rebuild the InspectorClient on a (re)connect so the latest // `target.settings` (headers, metadata, timeouts, OAuth credentials) // are picked up. Reusing the previous client object would freeze the // settings at the moment it was first constructed, which would be // surprising right after the user edited them in the settings modal. const client = setupClientForServer(target); if (id !== activeServerId) { setActiveServerId(id); } // A new connection attempt has begun: clear any previous failure flag so // the red border on the last-failed card is removed (#1621). If this // attempt also fails, the catch below re-sets it for this server. setFailedServerId(undefined); // Clear the machine-readable connect error for the same reason; a fresh // attempt starts from a clean `data-error-message`. setConnectErrorMessage(undefined); connectStartRef.current = Date.now(); try { // `settings.connectionTimeout` is consumed inside InspectorClient.connect // (Promise.race + transport teardown live there now), so this branch // stays unaware of the per-server timeout. TUI/CLI consumers get the // same behavior by reading from `serverSettings` on the client. await client.connect(); } catch (err) { // Handshake-only. A mid-session transport failure does not throw; the // client's `error` event surfaces those, consumed via // `useInspectorClient`'s `lastError` and toasted in the effect above // (#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 // whole page to the auth server via `BrowserNavigation`. Persist the // initiating server id first so the `/oauth/callback` load can resume // against the right client. The redirect unloads this page, so there's // nothing to do after the await on the success path. if (err instanceof AuthRecoveryRequiredError) { try { if (await client.checkAuthChallengeSatisfied(err.authChallenge)) { connectStartRef.current = Date.now(); await client.connect(); return; } } catch (recoveryErr) { // Both awaits above are unguarded connect work sitting inside a // `catch`, so a rejection escapes `onToggleConnection` altogether: // no toast, no red border, no sidebar — the #2108 failure mode in // its most invisible form. Surface it as the failed connect attempt // it is. A throw from `checkAuthChallengeSatisfied` lands here too // rather than falling through to `prepareOAuthRedirect`: it is not // the same as the challenge being *unsatisfied*, and navigating the // whole page away on the strength of an error would bury it. connectStartRef.current = undefined; // Tear the session down before reporting, as the sibling OAuth // catch below does. The outer `connect()` rejected with an // auth-recovery error, which deliberately holds the status at // `"connecting"` rather than moving it to `"error"` — so if the // challenge check is what rejected, nothing else ever ends the // attempt and the toggle spins while the active-server lock is // held. The fetch log survives a disconnect, so the Network // diagnostics this issue is about are unaffected. await client.disconnect().catch(() => {}); setFailedServerId(id); const message = recoveryErr instanceof Error ? recoveryErr.message : String(recoveryErr); setConnectErrorMessage(message); notifications.show({ title: `Failed to connect to "${target.name}"`, message, color: "red", }); return; } prepareOAuthRedirect({ serverId: id, authKind: "reauth", authorizationUrl: err.authorizationUrl, preRedirectContext: "connect", client, }); return; } if (isUnauthorizedError(err)) { try { const authUrl = await client.authenticate(); if (authUrl === undefined) { connectStartRef.current = Date.now(); await client.connect(); } else { prepareOAuthRedirect({ serverId: id, authKind: "reauth", authorizationUrl: authUrl, preRedirectContext: "connect", client, }); } return; } catch (authErr) { clearOAuthResumeSnapshot(); await client.disconnect().catch(() => {}); if (isEmaClientNotConfiguredError(authErr)) { notifications.show({ title: `Cannot connect to "${target.name}"`, message: authErr.message, color: "red", autoClose: false, }); return; } // The connect attempt failed, same as any other handshake error — // flag the card (#1621) and, with it, open the monitoring sidebar // onto the OAuth requests that explain the failure (#2108). This // leg never reaches the `"error"` connection status (the // `disconnect()` above settles it at `"disconnected"`), so this // flag is the only signal the view has that a connect attempt died. setFailedServerId(id); const message = authErr instanceof Error ? authErr.message : String(authErr); setConnectErrorMessage(message); notifications.show({ title: `OAuth authorization failed for "${target.name}"`, message, color: "red", }); return; } } // Non-auth handshake error: toast so the user sees what went wrong // instead of the ConnectionToggle silently reverting to // "disconnected", and flag the card with a red border (#1621). setFailedServerId(id); const message = err instanceof Error ? err.message : String(err); setConnectErrorMessage(message); notifications.show({ title: `Failed to connect to "${target.name}"`, message, color: "red", }); } }, [ activeServerId, connectionStatus, inspectorClient, setupClientForServer, prepareOAuthRedirect, finalizeExplicitDisconnect, ], ); const onDisconnect = useCallback(async () => { if (!inspectorClient) return; try { await inspectorClient.disconnect(); } finally { finalizeExplicitDisconnect(); } }, [inspectorClient, finalizeExplicitDisconnect]); // Deep-link auto-connect (the URL-driven case of #1183). `useServers` // hydrates asynchronously (initial `servers` is `[]`), so this effect runs in // discrete phases keyed on what `servers` currently reflects, one per render: // 1. ensure — no row yet: one-shot `addServer`. // 2. update — row present but its persisted config differs from the deep // link (a stale transport/url from an earlier load under the stable // `deep-link` id): `updateServer`, then return so the effect re-runs. // 3. connect — row present AND its config already matches: connect. // Splitting update and connect across renders (rather than awaiting both in // one closure) is what makes the connect correct: `onToggleConnection` reads // the target from `serversRef`, which an earlier passive effect syncs from // `servers` — so connecting only once `servers` reflects the updated config // guarantees the client is built from the fresh transport, not the stale one. // The OAuth callback path takes precedence; a deep link on `/oauth/callback` // would be a misconfiguration, and the callback handler clears the URL. useEffect(() => { if (!deepLink) return; if (window.location.pathname === OAUTH_CALLBACK_PATH) return; const existing = servers.find((s) => s.id === deepLink.serverId); if (!existing) { if (deepLinkEnsureRef.current) return; deepLinkEnsureRef.current = true; void addServer(deepLink.serverId, deepLink.serverConfig).catch((err) => { const message = err instanceof Error ? err.message : String(err); // A 409 ("already exists") means the row is on disk and hydration will // surface it on a later render, so the connect phase still proceeds — // swallow it. Any other failure (read-only catalog, backend 5xx) would // otherwise leave the deep link permanently stuck at this guard with no // signal, so record it on the machine-readable error surface. if (!message.includes("already exists")) recordConnectError(message); }); return; } if (!deepLinkConfigEquals(existing.config, deepLink.serverConfig)) { if (deepLinkUpdateRef.current) return; deepLinkUpdateRef.current = true; void updateServer( deepLink.serverId, deepLink.serverId, deepLink.serverConfig, ).catch((err) => { const message = err instanceof Error ? err.message : String(err); recordConnectError(message); }); return; } if (deepLinkConnectRef.current) return; deepLinkConnectRef.current = true; // Connect unless we're already *connected* to the deep-link server. Gating // on `activeServerId` identity alone would skip the connect when a prior // session restored `activeServerId` to the `deep-link` id while the socket // is disconnected — a reload of the same deep-link URL would then silently // never connect. `onToggleConnection` only disconnects when the id is the // active one AND the status is connected, so this condition also avoids // toggling a live connection off. const alreadyConnected = activeServerId === deepLink.serverId && connectionStatus === "connected"; if (!alreadyConnected) { void onToggleConnection(deepLink.serverId).catch((err) => { // The toast fires from inside `onToggleConnection` for the common // cases; this catch covers the rest (surfaced on `data-error-message`). const message = err instanceof Error ? err.message : String(err); recordConnectError(message); }); } }, [ deepLink, servers, activeServerId, connectionStatus, addServer, updateServer, onToggleConnection, recordConnectError, ]); const onReauthenticateFromBanner = useCallback(() => { if (!reAuthBanner) return; const serverId = reAuthBanner.serverId; const bannerKind = reAuthBanner.kind; setReAuthBanner(null); // Lost-authorization-state recovery (#1808). The stale half of the flow // (code verifier without discovery state, possibly a stale registration) // would make a plain retry fail the same way, so drop the persisted OAuth // state for this server first, then start a fresh authorization. This // banner is only raised from the `/oauth/callback` failure path, where the // session never reached "connected", so connecting is always the right // toggle direction here. if (bannerKind === "lost_authorization_state") { void (async () => { const server = serversRef.current.find((s) => s.id === serverId); if (server) { try { await clearServerOAuthState({ config: server.config, inspectorClient: serverId === activeServerId ? inspectorClient : null, isActiveConnection: serverId === activeServerId, oauthStorage: webOAuthStorage, }); } catch (err) { notifications.show({ title: "Could not clear the stored authorization state", message: err instanceof Error ? err.message : String(err), color: "red", // The banner is already dismissed and the flow is dead, so this // is the only remaining explanation — don't time it out. autoClose: false, }); return; } } await onToggleConnection(serverId); })(); return; } if ( serverId === activeServerId && connectionStatus === "connected" && inspectorClient ) { void (async () => { const server = servers.find((s) => s.id === serverId); try { const authUrl = await inspectorClient.authenticate(); if (authUrl === undefined) { await inspectorClient.pushRemoteAuthState(); notifications.show({ title: "Authorization restored", message: authRecoveryRestoredMessage(), color: "green", autoClose: 4000, }); return; } prepareOAuthRedirect({ serverId, authKind: "reauth", authorizationUrl: authUrl, }); } catch (err) { const message = err instanceof Error ? err.message : String(err); notifications.show({ title: server ? `OAuth authorization failed for "${server.name}"` : "OAuth authorization failed", message, color: "red", }); } })(); return; } void onToggleConnection(serverId); }, [ reAuthBanner, activeServerId, connectionStatus, inspectorClient, servers, prepareOAuthRedirect, onToggleConnection, webOAuthStorage, ]); // --- Action handlers that route directly to the InspectorClient. --- const onCallTool = useCallback( async ( name: string, args: Record, runAsTask?: boolean, ) => { if (!inspectorClient) return; const tool = tools.find((t: Tool) => t.name === name); if (!tool) return; // Route through the task pipeline when the caller asked to (or the tool // requires it) — but only if the server advertises task tool calls. Per // spec a tool's `taskSupport` is considered only when the server declares // `tasks.requests.tools.call`, so without it we never task-augment (even a // "required" tool, which then surfaces callTool's "requires task support" // error). The created task shows up on the Tasks screen via the // `requestorTaskUpdated` events callToolStream dispatches, and its live // status/progress surface as toasts + progress bar. // Legacy servers advertise task tool calls via // `tasks.requests.tools.call`. Modern servers (SEP-2663) instead negotiate // the `io.modelcontextprotocol/tasks` extension and are server-directed: // task creation is decided per-request by the server, so declaring the // extension on the call (which the task path does) is what makes a returned // task handle legal ("unsolicited handles"). Either era routes the flagged // call through the streaming task pipeline. const serverSupportsTaskToolCalls = !!capabilities?.tasks?.requests?.tools?.call || inspectorClient.isTasksExtensionNegotiated(); const asTask = serverSupportsTaskToolCalls && (runAsTask || tool.execution?.taskSupport === "required"); // Drop any prior call's task id before starting; a task-augmented call // repopulates it via the `toolCallTaskUpdated` listener below, an ordinary // call leaves it cleared (#1455). activeToolCallTaskIdRef.current = undefined; setToolCallState({ status: "pending" }); try { // ToolsScreen types the args as `Record` (it accepts // anything the user types into the schema form). `callTool` requires // `Record` — narrow at the boundary instead of // claiming the object is empty (which the previous `as Record` cast did, misleadingly). const invocation = asTask ? await inspectorClient.callToolStream( tool, args as Record, undefined, undefined, { ttl: activeServer?.settings?.taskTtl || DEFAULT_TASK_TTL_MS }, ) : await inspectorClient.callTool( tool, args as Record, ); setToolCallState({ status: invocation.success ? "ok" : "error", result: invocation.result ?? undefined, error: invocation.error, }); } catch (err) { if (err instanceof AuthRecoveryRequiredError) { setToolCallState(undefined); if (activeServerId) { await handleCommandScopedAuthRecovery(err, { serverId: activeServerId, source: "tool", }); } return; } // The user cancelled the in-flight call (Cancel button → cancelToolCall). // The cancellation notification was already sent to the server, so just // clear the executing state — surfacing it as an error would read as a // failure rather than the deliberate cancel it was (#1458). if (err instanceof ToolCallCancelledError) { setToolCallState(undefined); notifications.show({ title: "Tool call cancelled", message: "A cancellation request was sent to the server.", color: "gray", autoClose: 3000, }); return; } // The server kept asking for a URL the user already completed this call, // so callTool aborted to avoid an endless re-prompt loop. Surface that // explicitly rather than as a generic failure. if (err instanceof UrlElicitationLoopError) { setToolCallState({ status: "error", error: err.message }); notifications.show({ autoClose: false, title: "URL elicitation loop", color: "yellow", message: ( The server requested the same URL again after you completed it ( {err.url}), so the call was cancelled to avoid an endless loop. ), }); return; } // A URLElicitationRequired (-32042) error that reaches here carried no // elicitations (a non-spec response — the with-list case is handled and // retried inside callTool). There's no URL to open, so surface a short // toast that links to the raw error rather than a bare error panel. const urlElicitations = getUrlElicitationsFromError(err); if (urlElicitations !== null && urlElicitations.length === 0) { const details = { toolName: name, details: formatErrorDetails(err), }; setToolCallState({ status: "error", error: errorMessage(err) }); notifications.show({ autoClose: false, title: "URL elicitation required", color: "yellow", message: ( setUrlElicitationErrorDetails(details)} /> ), }); return; } setToolCallState({ status: "error", error: errorMessage(err), errorCode: errorCodeOf(err), }); } }, [ inspectorClient, tools, activeServer, capabilities, activeServerId, handleCommandScopedAuthRecovery, ], ); const onClearToolResult = useCallback(() => { setToolCallState(undefined); }, []); // Tools UI changes flow through here so selecting a *different* tool also // drops the previous tool's result — the result panel renders `toolCallState` // regardless of selection, so without this a stale result would linger under // the newly-selected tool (which has no result of its own yet). Search and // form edits keep `selectedToolKey` unchanged, so they leave the result be. // Depends on `selectedToolKey` only (not the whole `toolsUi`), so a search // keystroke doesn't churn the callback identity. const onToolsUiChange = useCallback( (next: ToolsUiState) => { if (next.selectedToolKey !== toolsUi.selectedToolKey) { setToolCallState(undefined); } setToolsUi(next); }, [toolsUi.selectedToolKey], ); // --- MCP Apps handlers. Unlike onCallTool (which feeds the Tools panel), // these route the tool input/result into the running app via the renderer's // imperative handle. --- // Surfaces bridge/runtime failures (factory throw — e.g. no client after a // disconnect — late bridge rejection, or a failed tools/call) that would // otherwise leave a silently blank app iframe. const onAppError = useCallback((err: Error) => { notifications.show({ title: "MCP App error", message: err.message, color: "red", }); }, []); // Selection is owned by AppsScreen's local state; App.tsx has nothing to do // on select, but the prop is required so the screen stays prop-driven. const onSelectApp = useCallback(() => {}, []); const onOpenApp = useCallback( async (name: string, args: Record) => { if (!inspectorClient) return; const tool = tools.find((t: Tool) => t.name === name); if (!tool) return; // AppsScreen flips `running` -> mounts AppRenderer in the same tick it // calls this, so the renderer handle isn't wired yet. Yield one microtask // (after React commits the mount) before pushing input; the renderer then // buffers it until the view's `initialized` event, releasing input before // result. await Promise.resolve(); void appRendererRef.current?.sendToolInput(args); try { // skipOutputValidation: the result is forwarded verbatim to the running // app (the real consumer), so the host must not reject it on its own // outputSchema validation — that would deny the app a result the server // actually returned and legacy hosts render fine. const invocation = await inspectorClient.callTool( tool, args as Record, undefined, undefined, undefined, { skipOutputValidation: true }, ); if (invocation.success && invocation.result) { void appRendererRef.current?.sendToolResult(invocation.result); } // Leniency above keeps the app rendering, but surface the schema // mismatch so a server developer knows strict MCP clients may refuse // to render this app. The full validation error is too long for a // toast, so summarize and link to a modal with the details. if (invocation.outputValidationError) { const details = { toolName: tool.name, message: invocation.outputValidationError, }; notifications.show({ // Don't auto-dismiss: the message is advisory and the details modal // is one click away — let the user close it when they've read it. autoClose: false, title: "App output doesn't match its schema", color: "yellow", message: ( setOutputValidationDetails(details)} /> ), }); } } catch (err) { if (err instanceof AuthRecoveryRequiredError) { if (activeServerId) { const satisfied = await handleCommandScopedAuthRecovery(err, { serverId: activeServerId, source: "app", }); if (!satisfied) { onAppError( new Error( "Authorization required. Complete sign-in, then reopen the app.", ), ); } } return; } // Transport-level failure (the call never returned a result). Surface it // so the user isn't left staring at a blank/partial app frame. onAppError(err instanceof Error ? err : new Error(String(err))); } }, [ inspectorClient, tools, onAppError, activeServerId, handleCommandScopedAuthRecovery, ], ); const onCloseApp = useCallback(() => { void appRendererRef.current?.teardown(); }, []); const onGetPrompt = useCallback( async (name: string, args: Record) => { if (!inspectorClient) return; // Tag the in-flight + final state with the prompt name so the // PromptsScreen can guard against showing a stale result for a // prompt the user has already navigated away from. setGetPromptState({ status: "pending", promptName: name }); try { const invocation = await inspectorClient.getPrompt(name, args); setGetPromptState({ status: "ok", promptName: name, result: invocation.result, }); } catch (err) { if (err instanceof AuthRecoveryRequiredError) { setGetPromptState(undefined); if (activeServerId) { await handleCommandScopedAuthRecovery(err, { serverId: activeServerId, source: "prompt", }); } return; } setGetPromptState({ status: "error", promptName: name, error: err instanceof Error ? err.message : String(err), }); } }, [inspectorClient, activeServerId, handleCommandScopedAuthRecovery], ); const onReadResource = useCallback( async (uri: string) => { if (!inspectorClient) return; setReadResourceState({ status: "pending", uri }); try { const invocation = await inspectorClient.readResource(uri); setReadResourceState({ status: "ok", uri, result: invocation.result, lastUpdated: invocation.timestamp, }); } catch (err) { if (err instanceof AuthRecoveryRequiredError) { setReadResourceState(undefined); if (activeServerId) { await handleCommandScopedAuthRecovery(err, { serverId: activeServerId, source: "resource", }); } return; } setReadResourceState({ status: "error", uri, error: err instanceof Error ? err.message : String(err), }); } }, [inspectorClient, activeServerId, handleCommandScopedAuthRecovery], ); // Read-on-demand handler for `resource_link` blocks in a tool result. Unlike // `onReadResource` (which drives the Resources screen's preview panel via // shared state), this returns the contents directly so each ResourceLink can // own and inline its own fetched content. const onReadResourceContents = useCallback( async (uri: string) => { if (!inspectorClient) throw new Error("Client is not connected"); const read = () => inspectorClient.readResource(uri); try { const invocation = await read(); return invocation.result; } catch (err) { if (err instanceof AuthRecoveryRequiredError && activeServerId) { const satisfied = await handleCommandScopedAuthRecovery(err, { serverId: activeServerId, source: "resource", }); if (satisfied) { const retry = await read(); return retry.result; } } throw err; } }, [inspectorClient, activeServerId, handleCommandScopedAuthRecovery], ); const onSubscribeResource = useCallback( (uri: string) => { if (!inspectorClient) return; void inspectorClient.subscribeToResource(uri); }, [inspectorClient], ); const onUnsubscribeResource = useCallback( (uri: string) => { if (!inspectorClient) return; void inspectorClient.unsubscribeFromResource(uri); }, [inspectorClient], ); const onCompleteArgument = useCallback( async ( ref: | { type: "ref/resource"; uri: string } | { type: "ref/prompt"; name: string }, argumentName: string, argumentValue: string, context: Record, ): Promise => { if (!inspectorClient) return []; const result = await runWithCommandAuthRecovery( () => inspectorClient.getCompletions( ref, argumentName, argumentValue, context, ), "tool", ); return result?.values ?? []; }, [inspectorClient, runWithCommandAuthRecovery], ); const onCancelTask = useCallback( async (taskId: string) => { if (!inspectorClient) return; try { await runWithCommandAuthRecovery( () => inspectorClient.cancelRequestorTask(taskId), "tool", ); } catch (err) { if (err instanceof AuthRecoveryRequiredError) { return; } notifications.show({ title: "Failed to cancel task", message: err instanceof Error ? err.message : String(err), color: "red", }); } }, [inspectorClient, runWithCommandAuthRecovery], ); // Cancel the in-flight tool call. A task-augmented call (run-as-task) has a // server-side task, so cancel that via the tasks API (#1455) — the cancelled // status then flows back through the managed task store and toasts, the same // as cancelling from the Tasks screen. An ordinary call has no task, so abort // its request: the SDK sends a `notifications/cancelled` to the server (the // MCP cancellation flow) and the pending call rejects with a // ToolCallCancelledError that `onCallTool` clears as a cancellation (#1458). const onCancelToolCall = useCallback(() => { if (!inspectorClient) return; const taskId = activeToolCallTaskIdRef.current; if (taskId) { // Clear the ref before the call resolves so a rapid second Cancel click // doesn't re-cancel the now-terminating task (which would surface a // spurious "Failed to cancel task" toast). activeToolCallTaskIdRef.current = undefined; void onCancelTask(taskId); return; } inspectorClient.cancelToolCall(); }, [inspectorClient, onCancelTask]); const onClearCompletedTasks = useCallback(() => { clearCompletedTasks(); }, [clearCompletedTasks]); const onSetLogLevel = useCallback( (level: LoggingLevel) => { setCurrentLogLevel(level); if (!inspectorClient) return; // Nothing else records a `logging/setLevel` failure, and the optimistic // level above already moved — so report it, or the selector would sit on // a level the server never accepted with no explanation. runCommandInBackground( () => inspectorClient.setLoggingLevel(level), "ambient", "Failed to set log level", ); }, [inspectorClient, runCommandInBackground], ); // Modern era (#1629): no request is sent — the client stores the level and // stamps it on every subsequent request's `_meta`. `null` opts back out. const onSetModernLogLevel = useCallback( (level: LoggingLevel | null) => { setModernLogLevel(level); inspectorClient?.setModernLogLevel(level ?? undefined); }, [inspectorClient], ); // Refresh acts per pagination mode: in paginated mode reload page 1 (the // paged state); in all-pages mode re-fetch the whole aggregate with auth // recovery (the pre-existing path). See usePaginatedList / #1721. const onRefreshTools = useCallback(() => { if (paginatedLists) { // Paginated refresh reloads page 1 of the paged store, bypassing the // managed hook's refresh — so acknowledge the list-changed indicator here // (the managed state lit it on `list_changed`; nothing else clears it in // paginated mode) (#1721). clearToolsListChanged(); runCommandInBackground(() => toolsPagination.onRefresh(), "ambient"); } else { runCommandInBackground(() => refreshTools(), "ambient"); } }, [ paginatedLists, toolsPagination, refreshTools, clearToolsListChanged, runCommandInBackground, ]); const onRefreshPrompts = useCallback(() => { if (paginatedLists) { clearPromptsListChanged(); runCommandInBackground(() => promptsPagination.onRefresh(), "ambient"); } else { runCommandInBackground(() => refreshPrompts(), "ambient"); } }, [ paginatedLists, promptsPagination, refreshPrompts, clearPromptsListChanged, runCommandInBackground, ]); const onRefreshResources = useCallback(() => { if (paginatedLists) { clearResourcesListChanged(); runCommandInBackground(() => resourcesPagination.onRefresh(), "ambient"); // Resource templates always use the managed (aggregate) path. runCommandInBackground(() => refreshResourceTemplates(), "ambient"); } else { runCommandInBackground(async () => { await refreshResources(); await refreshResourceTemplates(); }, "ambient"); } }, [ paginatedLists, resourcesPagination, refreshResources, refreshResourceTemplates, clearResourcesListChanged, runCommandInBackground, ]); // The per-list sidebar toggle edits the server-wide `paginatedLists` setting: // optimistic override for an instant flip, live push so the managed state's // gating reads it now, and a persisted PUT so it survives reconnects (#1721). const onTogglePaginatedLists = useCallback( (value: boolean) => { const current = servers.find((s) => s.id === activeServerId); if (!current || activeServerId === undefined) return; // Recorded against this server rather than app-wide, so it survives a // switch away and back (#2095). Ordered after the id guard because the // record is keyed by that id; with no active server there is nothing the // toggle could have been flipped for. paginatedListsOverride.record(activeServerId, value); // Not `current.settings` directly: that entry only advances on a // successful list read, so once one has failed it describes disk as it // was *before* the writes made since. Build on the last write known to // have landed while that is the fresher account (#2089). const next: InspectorServerSettings = { ...(lastPersistedSettings.resolve(activeServerId) ?? EMPTY_SETTINGS), paginatedLists: value, }; inspectorClient?.setServerSettings(next); // Drive the load that the mode change implies (data-loading stays out of // React effects; the paged stores own only the connect-time load). To // paginated: pull page 1 into each paged store. To all-pages: refetch // each managed aggregate that was gated off. Only when connected. if (connected) { // Wrap in ambient auth recovery so a mid-session 401 triggers re-auth // rather than surfacing raw, matching the all-pages refresh path. if (value) { runCommandInBackground(() => loadToolsPage(undefined), "ambient"); runCommandInBackground(() => loadPromptsPage(undefined), "ambient"); runCommandInBackground(() => loadResourcesPage(undefined), "ambient"); } else { runCommandInBackground(() => refreshTools(), "ambient"); runCommandInBackground(() => refreshPrompts(), "ambient"); runCommandInBackground(() => refreshResources(), "ambient"); } } // Refreshed like every other secret-store mutation: this resends the // server's rehydrated secrets, so it can trigger the pending // plaintext-to-encrypted upgrade even though the user only toggled // pagination (#1950 review r22). // Announced before the request goes out, so two toggles in flight at once // are ordered by when they were issued rather than by which one's list // reload finished first (#2089). const write = lastPersistedSettings.begin(activeServerId); // This value is on disk now. Remember it as the rollback baseline for // whatever is written next, since the `servers` entry it was derived // from will keep describing the old value if the reload behind this // write — or any later one — fails (#2089). // // Re-apply it when this write is the settled one: an overlapping toggle // that failed *first* rolled the UI and the live client back to a // baseline this write has since replaced, and if the list read behind // this write failed too, nothing else would ever correct them. Through // the *current* client, not this continuation's closure: a reconnect to // the same server passes the id check while the captured instance is // already destroyed. const settlePaginationWrite = () => { const settled = write.landed(next); if (!settled) return; // The override is keyed by server, so it is re-applied whatever is // active now — it is this server's value and is only ever displayed // while this server is the active one. The live client is not: it // belongs to whichever server is connected (#2095). paginatedListsOverride.record( activeServerId, next.paginatedLists ?? false, ); if (activeServerIdRef.current === activeServerId) { applyLiveServerSettings(next); } }; void refreshingPersist(updateServerSettings, refreshInitialConfig)( activeServerId, next, ) .then(settlePaginationWrite) .catch((err: unknown) => { // A `ServerListReloadError` means the PUT landed and only reading the // list back failed, so the new setting IS on disk (#1914). That is a // landed write, not a failed one: rolling back would put the UI and // the live client on the *old* value and contradict disk. Settle it // exactly as the success path does and report only the failed reload. if (err instanceof ServerListReloadError) { settlePaginationWrite(); notifications.show({ title: "Pagination setting saved, but the server list did not reload", message: err.message, color: "red", }); return; } // This write is over and never reached disk, so it stops counting as // in flight: an earlier write still running is the settled state once // it lands, and is what re-applies the UI this rollback is about to // set (#2089). write.failed(); // Persist failed: revert the optimistic override and roll the live // client setting back, so the UI and client reflect the value that's // actually on disk rather than the failed edit (#1721). // // The baseline is resolved *here*, not captured when this write was // issued: another toggle can land in between, and its value is what // disk holds by the time this one fails. The override is set to that // baseline rather than cleared, because clearing it falls back to // `persistedPaginatedLists` — read from a `servers` entry that may be // stale, showing the same wrong value from the other side (#2089). // // The override is recorded whatever is active by the time this // rejection arrives: it is keyed by this write's server and is only // ever displayed while that server is the active one, so a switch in // between costs nothing and dropping it would leave the stale entry // to answer for A the next time it comes back (#2095). // // The live client is the half that stays gated — it belongs to // whichever server is connected now, so pushing this server's value // into it after a switch would apply it to another one. It is taken // from the ref for the same reason the success path does: a reconnect // to the same server passes the id check while this continuation's // captured instance is already destroyed. const baseline = lastPersistedSettings.resolve(activeServerId) ?? EMPTY_SETTINGS; paginatedListsOverride.record( activeServerId, baseline.paginatedLists ?? false, ); if (activeServerIdRef.current === activeServerId) { applyLiveServerSettings(baseline); } notifications.show({ title: "Failed to save pagination setting", message: err instanceof Error ? err.message : String(err), color: "red", }); }); }, [ servers, activeServerId, lastPersistedSettings, paginatedListsOverride, applyLiveServerSettings, inspectorClient, updateServerSettings, refreshInitialConfig, connected, loadToolsPage, loadPromptsPage, loadResourcesPage, refreshTools, refreshPrompts, refreshResources, runCommandInBackground, ], ); // Wrap Load-next-page in ambient auth recovery too, so a paginated // paginated fetch that hits a 401 recovers like the all-pages path (#1721). const onLoadMoreTools = useCallback( () => runCommandInBackground(() => toolsPagination.onLoadMore(), "ambient"), [toolsPagination, runCommandInBackground], ); const onLoadMorePrompts = useCallback( () => runCommandInBackground(() => promptsPagination.onLoadMore(), "ambient"), [promptsPagination, runCommandInBackground], ); const onLoadMoreResources = useCallback( () => runCommandInBackground(() => resourcesPagination.onLoadMore(), "ambient"), [resourcesPagination, runCommandInBackground], ); const toolsPaginationControls: ListPaginationControlsProps = { paginated: toolsPagination.paginated, onPaginatedChange: onTogglePaginatedLists, canLoadMore: toolsPagination.canLoadMore, loadedPages: toolsPagination.loadedPages, onLoadMore: onLoadMoreTools, }; const promptsPaginationControls: ListPaginationControlsProps = { paginated: promptsPagination.paginated, onPaginatedChange: onTogglePaginatedLists, canLoadMore: promptsPagination.canLoadMore, loadedPages: promptsPagination.loadedPages, onLoadMore: onLoadMorePrompts, }; const resourcesPaginationControls: ListPaginationControlsProps = { paginated: resourcesPagination.paginated, onPaginatedChange: onTogglePaginatedLists, canLoadMore: resourcesPagination.canLoadMore, loadedPages: resourcesPagination.loadedPages, onLoadMore: onLoadMoreResources, }; const onRefreshTasks = useCallback(() => { runCommandInBackground( () => refreshTasks(), "ambient", "Failed to refresh tasks", ); }, [refreshTasks, runCommandInBackground]); const onClearLogs = useCallback(() => { if (!messageLogState) return; // Clear only the log notifications, not the entire request/response // history (which the Protocol screen renders from the same source). messageLogState.clearMessages( (m) => m.direction === "notification" && "method" in m.message && m.message.method === "notifications/message", ); }, [messageLogState]); // Panel-level Clear clears the (unpinned) history and keeps pinned entries — // pinning is the way to protect an entry from Clear. This matches the button's // `disabled={unpinnedEntries.length === 0}` gating and the per-section model, // and leaves pinnedProtocolIds valid (the pins it references still exist). const onClearProtocol = useCallback(() => { messageLogState?.clearMessages((m) => !pinnedProtocolIds.has(m.id)); }, [messageLogState, pinnedProtocolIds]); const onClearNetwork = useCallback(() => { fetchRequestLogState?.clearFetchRequests(); }, [fetchRequestLogState]); const onExportNetwork = useCallback(() => { if (fetchRequests.length === 0) return; downloadJsonFile( buildExportFilename("network", activeServerId), JSON.stringify(fetchRequests, null, 2), ); }, [fetchRequests, activeServerId]); const onExportProtocol = useCallback(() => { if (messages.length === 0) return; downloadJsonFile( buildExportFilename("protocol", activeServerId), JSON.stringify(messages, null, 2), ); }, [messages, activeServerId]); // Clear just one section: remove its entries from the log by pin membership. // Clearing the pinned section also drops the (now-stale) pinned id set. const onClearProtocolSection = useCallback( (section: "pinned" | "history") => { const isPinned = section === "pinned"; messageLogState?.clearMessages((m) => isPinned ? pinnedProtocolIds.has(m.id) : !pinnedProtocolIds.has(m.id), ); if (isPinned) setPinnedProtocolIds(new Set()); }, [messageLogState, pinnedProtocolIds], ); // Export just one section's entries (by pin membership) to a JSON file. const onExportProtocolSection = useCallback( (section: "pinned" | "history") => { const isPinned = section === "pinned"; const subset = messages.filter((m) => isPinned ? pinnedProtocolIds.has(m.id) : !pinnedProtocolIds.has(m.id), ); if (subset.length === 0) return; downloadJsonFile( buildExportFilename( isPinned ? "protocol-pinned" : "protocol-unpinned", activeServerId, ), JSON.stringify(subset, null, 2), ); }, [messages, pinnedProtocolIds, activeServerId], ); // Pin/unpin a Protocol entry by id. ProtocolListPanel sorts pinned entries to // the top; the set is session-scoped (see resetSessionScopedUiState). const onTogglePinProtocol = useCallback((id: string) => { setPinnedProtocolIds((prev) => { const next = new Set(prev); if (next.has(id)) { next.delete(id); } else { next.add(id); } return next; }); }, []); // Replay a Protocol entry: re-issue its original request so the fresh // request+response appear as a new Protocol entry (protocol-local). A reason // string (unsupported method / missing tool) surfaces as a toast; a genuine // call error already shows up as the replayed entry's Error status, so only a // pre-flight failure (nothing logged) needs the fallback toast. const onReplayProtocol = useCallback( (id: string) => { if (!inspectorClient) return; const entry = messages.find((m) => m.id === id); if (!entry || !("method" in entry.message)) return; const { method } = entry.message; const params = "params" in entry.message ? (entry.message.params as Record | undefined) : undefined; void replayProtocolRequest(inspectorClient, method, params, tools) .then((reason) => { if (reason) { notifications.show({ title: "Can't replay", message: reason, color: "yellow", }); } }) .catch((err: unknown) => { notifications.show({ title: "Replay failed", message: err instanceof Error ? err.message : String(err), color: "red", }); }); }, [inspectorClient, messages, tools], ); const onExportLogs = useCallback(() => { if (logs.length === 0) return; downloadJsonFile( buildExportFilename("logs", activeServerId), JSON.stringify(logs, null, 2), ); }, [logs, activeServerId]); const onClearConsole = useCallback(() => { stderrLogState?.clearStderrLogs(); }, [stderrLogState]); const onExportConsole = useCallback(() => { if (stderrLogs.length === 0) return; downloadJsonFile( buildExportFilename("console", activeServerId), JSON.stringify(stderrLogs, null, 2), ); }, [stderrLogs, activeServerId]); // Download the current server list as a canonical mcp.json file. Uses the // in-memory `servers` list (kept in sync with disk by useServers' refresh- // after-mutate flow) so there's no extra HTTP roundtrip. Serialization // format (2-space indent) lives in serializeMcpConfig so the export // matches what serializeStore writes on the backend. The button is // disabled when the list is empty, but the guard here keeps the handler // locally correct against any future programmatic caller. const onServerExport = useCallback(() => { if (servers.length === 0) return; downloadJsonFile("mcp.json", serializeMcpConfig(servers)); }, [servers]); // Remove handler — runs after the user confirms in the modal. When removing // the active server, also tear down the session in-place so the client and // its 9 state managers can be GC'd now instead of lingering until the next // server switch. Mirrors the destroy sequence at the top of // `setupClientForServer` (lines ~304-312) but additionally nulls every ref. const onConfirmRemove = useCallback(async () => { if (!removeTarget) return; const id = removeTarget.id; if (id === activeServerId) { if (inspectorClient) { await inspectorClient.disconnect(); } managedToolsState?.destroy(); managedPromptsState?.destroy(); managedResourcesState?.destroy(); pagedToolsState?.destroy(); pagedPromptsState?.destroy(); pagedResourcesState?.destroy(); managedResourceTemplatesState?.destroy(); managedRequestorTasksState?.destroy(); resourceSubscriptionsState?.destroy(); messageLogState?.destroy(); fetchRequestLogState?.destroy(); stderrLogState?.destroy(); setInspectorClient(null); setManagedToolsState(null); setManagedPromptsState(null); setManagedResourcesState(null); setPagedToolsState(null); setPagedPromptsState(null); setPagedResourcesState(null); setManagedResourceTemplatesState(null); setManagedRequestorTasksState(null); setResourceSubscriptionsState(null); setMessageLogState(null); setFetchRequestLogState(null); setStderrLogState(null); setActiveServerId(undefined); } // Deleting sweeps the server's secrets from the store, which for a // file-backed store is a write — and a write is what performs the pending // plaintext-to-encrypted upgrade (#1950 review r22). await refreshingPersist(removeServer, refreshInitialConfig)(id); setRemoveTarget(null); }, [ removeTarget, activeServerId, inspectorClient, managedToolsState, managedPromptsState, managedResourcesState, pagedToolsState, pagedPromptsState, pagedResourcesState, managedResourceTemplatesState, managedRequestorTasksState, resourceSubscriptionsState, messageLogState, fetchRequestLogState, stderrLogState, removeServer, refreshInitialConfig, ]); // Submit handler for the Add / Edit / Clone modal. Add and Clone both go // through addServer; Edit uses updateServer (which supports id rename). // Add a server, then mark it as the freshly-added one so the list scrolls to // it and highlights it. Used by manual add/clone and both import flows; edits // and conflict-overwrites (updateServer) intentionally don't highlight. // Accumulates into the current highlight batch (a multi-server import adds // each id), deduped. The batch is reset to empty when an add/import modal // opens (see the menu handlers). const addServerHighlighted = useCallback( async (id: string, config: MCPServerConfig) => { const markAdded = () => setHighlightedServerIds((ids) => ids.includes(id) ? ids : [...ids, id], ); try { await addServer(id, config); } catch (err) { // A failed list reload still means the row is on disk, so it belongs // in the highlight batch — the next successful refresh is what // renders it, and it would otherwise arrive unmarked (#1914 r2). if (err instanceof ServerListReloadError) markAdded(); throw err; } markAdded(); }, [addServer], ); // On rename of the active server, keep activeServerId pointed at the new id. const onConfigSubmit = useCallback( async (id: string, config: MCPServerConfig) => { if (configModal?.mode === "edit" && configModal.targetId) { const originalId = configModal.targetId; const followRename = () => { if (originalId === activeServerId && id !== originalId) { setActiveServerId(id); } }; try { await updateServer(originalId, id, config); } catch (err) { // A `ServerListReloadError` means the PUT landed and only reading // the list back failed — the rename IS on disk. The active // selection has to follow it anyway, or the next successful // refresh leaves `activeServerId` pointing at an id that no longer // exists (#1914 r2). Still rethrow, so the modal shows the reload // error rather than closing as if nothing happened. if (err instanceof ServerListReloadError) followRename(); throw err; } followRename(); return; } // add or clone await addServerHighlighted(id, config); }, [configModal, addServerHighlighted, updateServer, activeServerId], ); // Derive the existingIds list the modal uses for uniqueness validation. // In edit mode the target's own id must be excluded so saving without // renaming doesn't trip the "already exists" check. const existingIds = useMemo(() => { const ids = servers.map((s) => s.id); if (configModal?.mode === "edit" && configModal.targetId) { return ids.filter((id) => id !== configModal.targetId); } return ids; }, [servers, configModal]); const configModalTarget = useMemo(() => { if (!configModal?.targetId) return undefined; return servers.find((s) => s.id === configModal.targetId); }, [configModal, servers]); // An edit/clone modal whose target has left the list can no longer render a // coherent form: `initialId`/`initialConfig` go undefined, and // ServerConfigModal's own `useValueChange` reset then blanks the open form // *and* clears the error it is showing. // // Only reachable since #1914 made the modal stay open on a failed post-write // reload: rename A → B, the PUT lands, the reload fails, and the modal sits // there targeting A. The write itself is what triggers the backend's change // broadcast, so the SSE-driven background refresh that lands B (and drops A) // is the *likely* next event, not a remote one. Closing is right — the edit // is on disk and the list now agrees — and it covers the ordinary case too, // where an external mcp.json edit removes the server being edited. // // Adjusted during render rather than in an effect (see `useValueChange`), so // no frame ever paints the blanked form. useValueChange( configModal?.mode === "add" || configModal?.targetId === undefined ? true : servers.some((s) => s.id === configModal.targetId), (targetPresent) => { if (!targetPresent) setConfigModal(null); }, ); 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. // We hold the in-progress draft in `useSettingsDraft` so every change // re-renders synchronously; the hook also debounces the PUT and // exposes `flush` for the close handler to call. The draft is // (re)initialized only when the modal opens to a *different* server, // which is why a background refresh of `servers` can run without // clobbering in-progress edits. // // `resolveInitial` reads `servers` from this render's closure — that // works because the settings entry point is the "Settings" button on // a rendered server card, so `servers` is always non-empty by the // time this hook is called. A future caller that opens the modal // from elsewhere (e.g. a keyboard shortcut on initial load) would // need a different initialization path; the empty-shell fallback at // least keeps the form renderable while `servers` hydrates. const { draft: settingsDraft, onChange: onSettingsChange, flush: flushSettingsDraft, } = useSettingsDraft({ targetId: settingsModalTargetId, // Through the tracker rather than off `servers` directly: after a write // lands whose list reload failed, that entry still describes the previous // value, so seeding from it and saving an unrelated field would write the // superseded value straight back to disk (#2089). `resolve` falls back to // the entry when nothing has landed, so the ordinary case is unchanged. resolveInitial: (id) => lastPersistedSettings.resolve(id) ?? EMPTY_SETTINGS, // The saved settings may include an OAuth client secret or a stdio `env:` // value, and writing one can change what the secrets file *is* — the first // save under a newly-set passphrase re-encrypts a pre-existing plaintext // file. `refreshingPersist` re-asks the backend afterwards; it is a named // unit rather than an inline `await …; refresh()` because this file is // outside the coverage gate, so wiring written here is tested by nothing // (#1950 review r17). onPersist: async (id: string, value: InspectorServerSettings) => { // Announced before the request, like the toggle's own write: the debounced // flush can put two saves for one server in flight, and the later-issued // one describes disk however the two happen to finish (#2089). const write = lastPersistedSettings.begin(id); const settleSettingsWrite = () => { const settled = write.landed(value); if (!settled) return; paginatedListsOverride.record(id, value.paginatedLists ?? false); if (activeServerIdRef.current === id) { applyLiveServerSettings(value); } }; try { await refreshingPersist(updateServerSettings, refreshInitialConfig)( id, value, ); } catch (err) { // A `ServerListReloadError` means the PUT landed and only reading the // list back failed, so this write did reach disk (#1914): record it // like the success path below instead of rolling anything back, and // rethrow so `onError` can say the reload — not the save — failed. if (err instanceof ServerListReloadError) { settleSettingsWrite(); throw err; } // Stop counting as in flight before the rejection goes on to // `onError` — an earlier write still running settles the state once it // lands, and cannot know that while this one looks pending (#2089). write.failed(); // A write that landed while this one was pending was told it was not // settled and so applied nothing; this save never reached disk, so that // write is the account of it and nothing else will re-apply it. Same // reconciliation the toggle's own failure path does. const baseline = lastPersistedSettings.resolve(id); if (baseline) { paginatedListsOverride.record(id, baseline.paginatedLists ?? false); if (activeServerIdRef.current === id) { applyLiveServerSettings(baseline); } } throw err; } // Recorded for the same reason the pagination toggle records its own // write: this is what disk holds now, and the `servers` entry it was // edited from will keep describing the previous value for as long as list // reads keep failing. Both settings writers feed the same per-server // record, so a rollback in either takes the most recent write for that // server, not just the most recent write *of its own kind*. // // And the reconciliation is shared too, for the mixed-writer overlap: a // toggle failing while this save is in flight rolls the UI and the live // client back to a baseline this save then replaces on disk, so a settled // save has to re-apply itself exactly as a settled toggle does (#2089). settleSettingsWrite(); }, // Surface failures via toast — the modal usually closes // immediately on user dismiss, so a silent fail-on-flush would // leave the user thinking their last edits saved when they // didn't (especially painful for the OAuth client secret). onError: (id, err) => { notifications.show({ // A `ServerListReloadError` means the PUT landed and only reading the // list back failed, so "Failed to save" would be false — and this // toast is the user's only signal here, since the modal has usually // closed by the time a flush rejects (#1914 r3). title: err instanceof ServerListReloadError ? `Saved settings for "${id}", but the server list did not reload` : `Failed to save settings for "${id}"`, message: err instanceof Error ? err.message : String(err), color: "red", }); }, }); const settingsModalValue: InspectorServerSettings = settingsDraft ?? EMPTY_SETTINGS; const { draft: clientSettingsDraft, onChange: onClientSettingsChange, flush: flushClientSettingsDraft, } = useClientSettingsDraft({ opened: clientSettingsOpen, resolveInitial: () => clientConfigToFormValues(clientConfig), // Wrapped for the same reason as the server-settings persist above: this // one can carry the enterprise IdP client secret, and that write is what // flips a pending-encryption file to encrypted. onPersist: refreshingPersist(async (values) => { if (!canPersistClientSettingsDraft(values)) return; const next = formValuesToClientConfig(values); await saveClientConfigRemote(next, { baseUrl: configBaseUrl, authToken: getAuthToken(), }); setClientConfig(next); }, refreshInitialConfig), onError: (err) => { notifications.show({ title: "Failed to save client settings", message: formatClientConfigLoadError(err), color: "red", }); }, }); const clientSettingsModalValue = clientSettingsDraft ?? EMPTY_CLIENT_SETTINGS; const { loginState: emaIdpLoginState, logout: logoutEmaIdp } = useEmaIdpLoginState( webOAuthStorage, clientSettingsModalValue.emaEnabled ? clientSettingsModalValue.issuer : undefined, clientSettingsOpen, ); const onClientSettingsModalClose = useCallback(() => { // Only fires when ClientSettingsModal allows the close (it blocks on an // invalid issuer and reveals the error instead). The implicit "save" is the // flush below; the persist gate drops anything still incomplete. flushClientSettingsDraft(); setClientSettingsOpen(false); }, [flushClientSettingsDraft]); // Gate the stdio-only Working Directory / Environment Variables controls in // 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 clearServerOAuthAndDisconnect = useCallback( async (server: { id: string; name: string; config: MCPServerConfig }) => { const isActive = server.id === activeServerId; const cleared = await clearServerOAuthState({ config: server.config, inspectorClient: isActive ? inspectorClient : null, isActiveConnection: isActive, oauthStorage: webOAuthStorage, }); if (!cleared) return; if (isActive && inspectorClient) { try { await inspectorClient.disconnect(); } finally { setConnectionInfoOAuthWhenConnected(undefined); finalizeExplicitDisconnect(); } } else { clearOAuthResumeOnExplicitDisconnect(); } notifications.show({ title: "OAuth state cleared", message: isActive ? "Stored tokens and client registration were removed. Reconnect to run a fresh authorization flow." : `Stored OAuth state was removed for "${server.name}". Connect to authorize again.`, color: "blue", }); }, [ activeServerId, inspectorClient, webOAuthStorage, finalizeExplicitDisconnect, clearOAuthResumeOnExplicitDisconnect, ], ); const handleClearConnectionOAuth = useCallback(() => { if (!activeServer) return; void clearServerOAuthAndDisconnect(activeServer); }, [activeServer, clearServerOAuthAndDisconnect]); const handleClearStoredOAuthFromSettings = useCallback(() => { if (!settingsModalTarget) return; void clearServerOAuthAndDisconnect(settingsModalTarget); }, [settingsModalTarget, clearServerOAuthAndDisconnect]); const onSettingsModalClose = useCallback(() => { flushSettingsDraft(); // Apply root edits to the live client once, on close — not on every // keystroke. `setRoots` fires `notifications/roots/list_changed`, which // makes the server re-request `roots/list`; doing that per character while // the user types a URI would flood the wire. We diff the final draft roots // against what the client currently advertises (both cleaned) and notify // only when they actually differ, and only for the connected server. if ( inspectorClient && settingsModalTargetId !== undefined && settingsModalTargetId === activeServerId && settingsDraft ) { // Normally the draft is what the user just saved, so it is also what is // on disk. When this server's last write *rejected* it is not: the draft // still holds the failed edit, and applying it here would contradict disk // and undo the rollback that reported the failure — so apply what landed // instead. `EMPTY_SETTINGS`, not the draft, when nothing has ever landed // for this server: falling back to the draft would re-apply the very // values that failed (#2089). const applied = lastPersistedSettings.lastWriteFailed( settingsModalTargetId, ) ? (lastPersistedSettings.resolve(settingsModalTargetId) ?? EMPTY_SETTINGS) : settingsDraft; applyLiveServerSettings(applied); } setSettingsModalTargetId(undefined); }, [ flushSettingsDraft, inspectorClient, settingsModalTargetId, activeServerId, settingsDraft, lastPersistedSettings, applyLiveServerSettings, ]); // The Resources screen needs `isSubscribed` to flip the Subscribe button // label to "Unsubscribe". Derive it from the live subscriptions list rather // than threading it through every setReadResourceState site — that way the // button reflects state changes from any source (preview panel, subscribed // tile, or future server-initiated subscribe notifications). const effectiveReadResourceState = useMemo< ReadResourceState | undefined >(() => { if (!readResourceState) return undefined; if (!readResourceState.uri) return readResourceState; const isSubscribed = subscriptions.some( (s) => s.resource.uri === readResourceState.uri, ); return { ...readResourceState, isSubscribed }; }, [readResourceState, subscriptions]); // Surface one pending server-initiated request at a time in the modal, // sampling-first. Responding (below) removes it from the client's queue, // which re-renders this with the next request or closes the modal. const totalPendingRequests = pendingSamples.length + pendingElicitations.length; // Derive the head request inside the memo (depending on the source arrays) // so the memo actually caches — an inline `activeElicitation` would have a // fresh identity every render and defeat it. const pendingRequestContent = useMemo(() => { const activeSample = pendingSamples[0]; if (activeSample) { return { kind: "sampling", id: activeSample.id, request: activeSample.request.params, origin: activeSample.origin, }; } const activeElicitation = pendingElicitations[0]; if (activeElicitation) { const params = activeElicitation.request.params; if ("url" in params) { return { kind: "elicitation-url", id: activeElicitation.id, message: params.message, url: params.url, origin: activeElicitation.origin, }; } return { kind: "elicitation-form", id: activeElicitation.id, request: params, origin: activeElicitation.origin, }; } return null; }, [pendingSamples, pendingElicitations]); // A remaining-count hint shown only when more than the displayed head is // queued. The modal always shows the head, so a "1 of N" position would be // misleading — the leading "1" never changes. const queueLabel = totalPendingRequests > 1 ? `${totalPendingRequests} pending` : ""; const onSamplingRespond = useCallback( (result: CreateMessageResult) => { void pendingSamples[0]?.respond(result); }, [pendingSamples], ); const onSamplingReject = useCallback(() => { void pendingSamples[0]?.reject( new Error("Sampling request rejected by user."), ); }, [pendingSamples]); const onElicitationRespond = useCallback( (result: ElicitResult) => { const pending = pendingElicitations[0]; if (!pending) return; // "Cancel" on a MODERN task's input_required request means "give up on the // task" — not "send a cancel answer" (which a non-advancing server would // just re-prompt on). Cancel the underlying task instead; that aborts the // pending request, closes this modal, and lets the poll settle as // cancelled (#1631). Submit/Decline still answer the task normally. if ( result.action === "cancel" && pending.origin === "task-input-required" && pending.taskId && inspectorClient ) { void inspectorClient.cancelRequestorTask(pending.taskId); return; } void pending.respond(result); }, [pendingElicitations, inspectorClient], ); const handleStepUpAuthorize = async () => { if (!pendingStepUp || stepUpAuthorizeInProgressRef.current) { return; } const stepUp = pendingStepUp; const client = inspectorClient; if (!client) { return; } if (stepUp.enterpriseManaged) { stepUpAuthorizeInProgressRef.current = true; setPendingStepUp(null); notifications.show({ title: "Organization permissions", message: emaStepUpInProgressMessage(), color: "blue", autoClose: 4000, }); try { const outcome = await client.handleAuthChallenge(stepUp.challenge, { confirmedStepUp: true, }); if (outcome.kind === "satisfied") { await client.pushRemoteAuthState(); notifications.show({ title: "Permissions updated", message: emaStepUpSuccessMessage({ recoverySource: stepUp.source, }), color: "green", autoClose: 5000, }); const retry = pendingStepUpRetryRef.current; pendingStepUpRetryRef.current = null; if (retry) { await retry(); } return; } if (outcome.kind === "interactive") { prepareOAuthRedirect({ serverId: stepUp.serverId, authKind: "step_up", authorizationUrl: outcome.authorizationUrl, authChallenge: outcome.challenge, recoverySource: stepUp.source, }); return; } if (outcome.kind === "failed") { const failureMessage = emaStepUpFailureMessage(outcome.error.message); notifications.show({ title: "Organization permissions", message: failureMessage, color: "red", autoClose: 6000, }); switch (stepUp.source) { case "tool": setToolCallState({ status: "error", error: failureMessage, }); break; case "prompt": setGetPromptState((prev) => prev ? { ...prev, status: "error", error: failureMessage } : prev, ); break; case "resource": setReadResourceState((prev) => prev ? { ...prev, status: "error", error: failureMessage } : prev, ); break; default: break; } } } finally { stepUpAuthorizeInProgressRef.current = false; } return; } stepUpAuthorizeInProgressRef.current = true; prepareOAuthRedirect({ serverId: stepUp.serverId, authKind: "step_up", authorizationUrl: stepUp.authorizationUrl, authChallenge: stepUp.challenge, recoverySource: stepUp.source, }); setPendingStepUp(null); pendingStepUpRetryRef.current = null; stepUpAuthorizeInProgressRef.current = false; }; const handleStepUpCancel = () => { const stepUp = pendingStepUpRef.current; setPendingStepUp(null); pendingStepUpRetryRef.current = null; if (!stepUp) { return; } const cancelled = "Authorization cancelled."; switch (stepUp.source) { case "tool": setToolCallState({ status: "error", error: cancelled }); break; case "prompt": setGetPromptState((prev) => prev ? { ...prev, status: "error", error: cancelled } : prev, ); break; case "resource": setReadResourceState((prev) => prev ? { ...prev, status: "error", error: cancelled } : prev, ); break; case "app": notifications.show({ title: "Authorization cancelled", message: cancelled, color: "gray", autoClose: 4000, }); break; case "ambient": break; } }; return ( <> {reAuthBanner ? ( setReAuthBanner(null)} /> ) : null} setClientSettingsOpen(true)} onToggleConnection={(id) => { void onToggleConnection(id); }} onDisconnect={() => { void onDisconnect(); }} onServerAdd={() => { setHighlightedServerIds([]); setConfigModal({ mode: "add" }); }} onServerImportConfig={() => { setHighlightedServerIds([]); setImportConfigOpen(true); }} onServerImportJson={() => { setHighlightedServerIds([]); setImportJsonOpen(true); }} onServerExport={onServerExport} onConnectionInfo={() => setConnectionInfoModalOpen(true)} onServerSettings={(id) => setSettingsModalTargetId(id)} onServerEdit={(id) => setConfigModal({ mode: "edit", targetId: id })} onServerClone={(id) => { setHighlightedServerIds([]); setConfigModal({ mode: "clone", targetId: id }); }} onServerRemove={(id) => { const target = servers.find((s) => s.id === id); if (target) setRemoveTarget(target); }} onServerReorder={(orderedIds) => { // reorderServers reverts the optimistic order via an internal // refresh() and re-throws on failure (409 from a racing external // edit, or a network error). Surface that to the user so the drag // doesn't silently bounce back — matching the toast pattern every // other mutation here uses. reorderServers(orderedIds).catch((err: unknown) => { notifications.show({ title: "Failed to reorder servers", message: err instanceof Error ? err.message : String(err), color: "red", }); }); }} highlightedServerIds={highlightedServerIds} onClearHighlight={clearHighlight} serverSupportsTaskToolCalls={ !!capabilities?.tasks?.requests?.tools?.call || (inspectorClient?.isTasksExtensionNegotiated() ?? false) } onToolsUiChange={onToolsUiChange} onCallTool={(name, args, runAsTask) => { void onCallTool(name, args, runAsTask); }} onCancelToolCall={onCancelToolCall} onClearToolResult={onClearToolResult} onReadResourceContents={onReadResourceContents} onRefreshTools={onRefreshTools} toolsPagination={toolsPaginationControls} promptsPagination={promptsPaginationControls} resourcesPagination={resourcesPaginationControls} onPromptsUiChange={setPromptsUi} onGetPrompt={(name, args) => { void onGetPrompt(name, args); }} onRefreshPrompts={onRefreshPrompts} onResourcesUiChange={setResourcesUi} onReadResource={(uri) => { void onReadResource(uri); }} onSubscribeResource={onSubscribeResource} onUnsubscribeResource={onUnsubscribeResource} onRefreshResources={onRefreshResources} onCompleteArgument={onCompleteArgument} completionsSupported={capabilities?.completions !== undefined} subscriptionsSupported={capabilities?.resources?.subscribe === true} onTasksUiChange={setTasksUi} onCancelTask={(taskId) => { void onCancelTask(taskId); }} onClearCompletedTasks={onClearCompletedTasks} onRefreshTasks={onRefreshTasks} onSetLogLevel={onSetLogLevel} modernLogLevel={modernLogLevel} onSetModernLogLevel={onSetModernLogLevel} onLogsUiChange={setLogsUi} onClearLogs={onClearLogs} onExportLogs={onExportLogs} onProtocolUiChange={setProtocolUi} onClearProtocol={onClearProtocol} onExportProtocol={onExportProtocol} onClearProtocolSection={onClearProtocolSection} onExportProtocolSection={onExportProtocolSection} onReplayProtocol={onReplayProtocol} onTogglePinProtocol={onTogglePinProtocol} pinnedProtocolIds={pinnedProtocolIds} onNetworkUiChange={setNetworkUi} onClearNetwork={onClearNetwork} onExportNetwork={onExportNetwork} onConsoleUiChange={setConsoleUi} onClearConsole={onClearConsole} onExportConsole={onExportConsole} onAppsUiChange={setAppsUi} onSelectApp={onSelectApp} onOpenApp={(name, args) => { void onOpenApp(name, args); }} onCloseApp={onCloseApp} onAppError={onAppError} onRefreshApps={onRefreshTools} /> setConfigModal(null)} // Wrapped like the settings persists: this submit carries stdio `env` // values, so it can perform the pending plaintext-to-encrypted // upgrade and change the descriptor the footer reports. onSubmit={refreshingPersist(onConfigSubmit, refreshInitialConfig)} secretStorage={secretStorage} /> { setImportConfigOpen(false); refreshInitialConfig(); }} onFetchSource={importSource} onAddServer={addServerHighlighted} onUpdateServer={updateServer} /> setImportJsonOpen(false)} onAddServer={refreshingPersist( addServerHighlighted, refreshInitialConfig, )} /> {initializeResult && activeServer && ( setConnectionInfoModalOpen(false)} initializeResult={initializeResult} serverInfoReported={serverInfoReported} clientCapabilities={clientCapabilities} transport={connectionInfoTransport} protocolEra={protocolEra} discoverResult={discoverResult} oauth={connectionInfoOAuth} onClearOAuth={ connectionInfoCanClearOAuth ? handleClearConnectionOAuth : undefined } /> )} setRemoveTarget(null)} onConfirm={onConfirmRemove} /> setOutputValidationDetails(null)} /> setUrlElicitationErrorDetails(null)} /> ); } export default App;