import { useCallback, useEffect, useMemo, useRef, useState } 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, 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_MODERN_LOG_LEVEL, DEFAULT_TASK_TTL_MS, eraToVersionNegotiation, } 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 { cleanRoots, 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 { 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 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 { 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 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 { parseDeepLink, deepLinkConfigEquals, deepLinkParseStatus, } from "./utils/deepLink"; import type { DeepLink, DeepLinkParseStatus } from "./utils/deepLink"; import { applyOAuthResumeUi, buildTabUiSnapshot, clearOAuthResumeSnapshot, consumeOAuthResumeSnapshot, oauthResumeInsufficientScopeMessage, oauthResumeToastMessage, writeOAuthResumeSnapshot, type OAuthResumeAuthKind, } from "./utils/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 "./utils/clearServerOAuthState"; import { authRecoveryRestoredMessage, isReAuthBannerReason, oauthPreRedirectToastCopy, oauthResumeAbandonedMessage, reAuthBannerMessage, type OAuthPreRedirectContext, type OAuthRecoverySource, } from "./utils/oauthUx"; import { isBrowserTabVisible, onBrowserTabVisible, } from "./utils/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", }); // Sticky re-auth banner bar. A `Paper` so every static style is a prop: `shadow` // emits `var(--mantine-shadow-sm)` (identical to the old CSS), and the stacking // order goes through `styles.root` since Mantine has no `z` prop. const ReAuthBannerBar = Paper.withProps({ px: "md", pt: "xs", pos: "sticky", top: 60, bg: "var(--mantine-color-body)", shadow: "sm", styles: { root: { zIndex: 200 } }, // Paper's default `radius: "md"` would round this full-bleed sticky bar's // corners; the bar it replaced (a Box) had none. radius: 0, }); // 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, } = 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]); const sandboxBridgeFactory = useMemo( () => createAppBridgeFactory({ getClient: () => inspectorClient?.getAppRendererClient() ?? null, 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], ); 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; } | 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, lastError, } = useInspectorClient(inspectorClient); const { tools: managedTools, listChanged: toolsListChanged, refresh: refreshTools, clearListChanged: clearToolsListChanged, } = useManagedTools(inspectorClient, managedToolsState); const { prompts: managedPrompts, listChanged: promptsListChanged, refresh: refreshPrompts, clearListChanged: clearPromptsListChanged, } = useManagedPrompts(inspectorClient, managedPromptsState); const { resources: managedResources, listChanged: resourcesListChanged, refresh: refreshResources, clearListChanged: clearResourcesListChanged, } = useManagedResources(inspectorClient, managedResourcesState); const { resourceTemplates, 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, loadPage: loadToolsPage, } = usePagedTools(inspectorClient, pagedToolsState); const { prompts: pagedPrompts, nextCursor: pagedPromptsCursor, pageCount: pagedPromptsPageCount, loadPage: loadPromptsPage, } = usePagedPrompts(inspectorClient, pagedPromptsState); const { resources: pagedResources, nextCursor: pagedResourcesCursor, pageCount: pagedResourcesPageCount, loadPage: loadResourcesPage, } = usePagedResources(inspectorClient, pagedResourcesState); // The active server's persisted paginated setting drives the display mode. // The sidebar toggle edits it (optimistically, below) and persists it. const persistedPaginatedLists = servers.find((s) => s.id === activeServerId)?.settings?.paginatedLists ?? false; const [paginatedListsOverride, setPaginatedListsOverride] = useState< boolean | null >(null); // Drop the optimistic override once the persisted value catches up (or the // active server changes), so the persisted setting is the resting source. useEffect(() => { setPaginatedListsOverride(null); }, [persistedPaginatedLists, activeServerId]); const paginatedLists = paginatedListsOverride ?? persistedPaginatedLists; const connected = connectionStatus === "connected"; const toolsPagination = usePaginatedList({ connected, paginated: paginatedLists, managedItems: managedTools, managedRefresh: refreshTools, pagedItems: pagedTools, pagedNextCursor: pagedToolsCursor, pagedPageCount: pagedToolsPageCount, loadPage: loadToolsPage, }); const promptsPagination = usePaginatedList({ connected, paginated: paginatedLists, managedItems: managedPrompts, managedRefresh: refreshPrompts, pagedItems: pagedPrompts, pagedNextCursor: pagedPromptsCursor, pagedPageCount: pagedPromptsPageCount, loadPage: loadPromptsPage, }); const resourcesPagination = usePaginatedList({ connected, paginated: paginatedLists, managedItems: managedResources, managedRefresh: refreshResources, pagedItems: pagedResources, pagedNextCursor: pagedResourcesCursor, pagedPageCount: pagedResourcesPageCount, loadPage: loadResourcesPage, }); const tools = toolsPagination.items; const prompts = promptsPagination.items; const resources = resourcesPagination.items; 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"); setModernLogLevel(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. 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); useEffect(() => { if (activeServer) activeServerNameRef.current = activeServer.name; activeServerIdRef.current = activeServerId; serversRef.current = servers; }, [activeServer, activeServerId, servers]); // 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); // 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], ); 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. const savedSettings = 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. const defaultMetadata = savedSettings?.metadata ? Object.fromEntries( savedSettings.metadata .filter((m) => m.key.trim() !== "") .map((m) => [m.key, m.value]), ) : undefined; const oauthFromServer = savedSettings && (savedSettings.oauthClientId || savedSettings.oauthClientSecret || savedSettings.oauthScopes || savedSettings.enterpriseManaged) ? { ...(savedSettings.oauthClientId && { clientId: savedSettings.oauthClientId, }), ...(savedSettings.oauthClientSecret && { clientSecret: savedSettings.oauthClientSecret, }), ...(savedSettings.oauthScopes && { scope: savedSettings.oauthScopes, }), ...(savedSettings.enterpriseManaged && { enterpriseManaged: true, }), } : undefined; const oauth = oauthFromServer || activeCimdUrl ? { ...(oauthFromServer ?? {}), ...(activeCimdUrl && { clientMetadataUrl: activeCimdUrl }), } : undefined; const client = new InspectorClient(server.config, { 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 }, // 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. const seededModernLevel = savedSettings?.modernLogLevel ?? DEFAULT_MODERN_LOG_LEVEL; setModernLogLevel(seededModernLevel === "off" ? null : seededModernLevel); 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, ], ); // 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) { 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 () => { try { await webOAuthStorage.load(); } catch (err) { connectStartRef.current = undefined; 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; if (isEmaClientNotConfiguredError(err)) { notifications.show({ title: `Cannot connect to "${server.name}"`, message: err.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) => { // 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) { if (await client.checkAuthChallengeSatisfied(err.authChallenge)) { connectStartRef.current = Date.now(); await client.connect(); 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; } 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; setReAuthBanner(null); 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, ]); // --- 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 `selectedToolName` unchanged, so they leave the result be. // Depends on `selectedToolName` only (not the whole `toolsUi`), so a search // keystroke doesn't churn the callback identity. const onToolsUiChange = useCallback( (next: ToolsUiState) => { if (next.selectedToolName !== toolsUi.selectedToolName) { setToolCallState(undefined); } setToolsUi(next); }, [toolsUi.selectedToolName], ); // --- 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; void runWithCommandAuthRecovery( () => inspectorClient.setLoggingLevel(level), "ambient", ); }, [inspectorClient, runWithCommandAuthRecovery], ); // 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(); void runWithCommandAuthRecovery( () => toolsPagination.onRefresh(), "ambient", ); } else { void runWithCommandAuthRecovery(() => refreshTools(), "ambient"); } }, [ paginatedLists, toolsPagination, refreshTools, clearToolsListChanged, runWithCommandAuthRecovery, ]); const onRefreshPrompts = useCallback(() => { if (paginatedLists) { clearPromptsListChanged(); void runWithCommandAuthRecovery( () => promptsPagination.onRefresh(), "ambient", ); } else { void runWithCommandAuthRecovery(() => refreshPrompts(), "ambient"); } }, [ paginatedLists, promptsPagination, refreshPrompts, clearPromptsListChanged, runWithCommandAuthRecovery, ]); const onRefreshResources = useCallback(() => { if (paginatedLists) { clearResourcesListChanged(); void runWithCommandAuthRecovery( () => resourcesPagination.onRefresh(), "ambient", ); // Resource templates always use the managed (aggregate) path. void runWithCommandAuthRecovery( () => refreshResourceTemplates(), "ambient", ); } else { void runWithCommandAuthRecovery(async () => { await refreshResources(); await refreshResourceTemplates(); }, "ambient"); } }, [ paginatedLists, resourcesPagination, refreshResources, refreshResourceTemplates, clearResourcesListChanged, runWithCommandAuthRecovery, ]); // 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) => { setPaginatedListsOverride(value); const current = servers.find((s) => s.id === activeServerId); if (!current || activeServerId === undefined) return; const prevSettings = current.settings ?? EMPTY_SETTINGS; const next: InspectorServerSettings = { ...prevSettings, 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) { void runWithCommandAuthRecovery( () => loadToolsPage(undefined), "ambient", ); void runWithCommandAuthRecovery( () => loadPromptsPage(undefined), "ambient", ); void runWithCommandAuthRecovery( () => loadResourcesPage(undefined), "ambient", ); } else { void runWithCommandAuthRecovery(() => refreshTools(), "ambient"); void runWithCommandAuthRecovery(() => refreshPrompts(), "ambient"); void runWithCommandAuthRecovery(() => refreshResources(), "ambient"); } } void updateServerSettings(activeServerId, next).catch((err: unknown) => { // Persist failed: revert the optimistic override (the effect only // clears it when the persisted value changes, which won't happen here) // 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). setPaginatedListsOverride(null); inspectorClient?.setServerSettings(prevSettings); notifications.show({ title: "Failed to save pagination setting", message: err instanceof Error ? err.message : String(err), color: "red", }); }); }, [ servers, activeServerId, inspectorClient, updateServerSettings, connected, loadToolsPage, loadPromptsPage, loadResourcesPage, refreshTools, refreshPrompts, refreshResources, runWithCommandAuthRecovery, ], ); // 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( () => void runWithCommandAuthRecovery( () => toolsPagination.onLoadMore(), "ambient", ), [toolsPagination, runWithCommandAuthRecovery], ); const onLoadMorePrompts = useCallback( () => void runWithCommandAuthRecovery( () => promptsPagination.onLoadMore(), "ambient", ), [promptsPagination, runWithCommandAuthRecovery], ); const onLoadMoreResources = useCallback( () => void runWithCommandAuthRecovery( () => resourcesPagination.onLoadMore(), "ambient", ), [resourcesPagination, runWithCommandAuthRecovery], ); 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(() => { void runWithCommandAuthRecovery(() => refreshTasks(), "ambient")?.catch( (err: unknown) => { notifications.show({ title: "Failed to refresh tasks", message: err instanceof Error ? err.message : String(err), color: "red", }); }, ); }, [refreshTasks, runWithCommandAuthRecovery]); 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); } await removeServer(id); setRemoveTarget(null); }, [ removeTarget, activeServerId, inspectorClient, managedToolsState, managedPromptsState, managedResourcesState, pagedToolsState, pagedPromptsState, pagedResourcesState, managedResourceTemplatesState, managedRequestorTasksState, resourceSubscriptionsState, messageLogState, fetchRequestLogState, stderrLogState, removeServer, ]); // 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) => { await addServer(id, config); setHighlightedServerIds((ids) => (ids.includes(id) ? ids : [...ids, id])); }, [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; await updateServer(originalId, id, config); if (originalId === activeServerId && id !== originalId) { setActiveServerId(id); } 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]); 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, resolveInitial: (id) => servers.find((s) => s.id === id)?.settings ?? EMPTY_SETTINGS, onPersist: updateServerSettings, // 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({ title: `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), onPersist: async (values) => { if (!canPersistClientSettingsDraft(values)) return; const next = formValuesToClientConfig(values); await saveClientConfigRemote(next, { baseUrl: configBaseUrl, authToken: getAuthToken(), }); setClientConfig(next); }, onError: (err) => { notifications.show({ title: "Failed to save client settings", message: 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 ) { // Push the edited settings onto the live client so 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. inspectorClient.setServerSettings(settingsDraft); // Resize the Network log buffer live so a maxFetchRequests edit takes // effect without a reconnect (shrinking trims immediately). Connect-time // construction also reads this, so a reconnect would apply it anyway — // this just makes the toast→adjust flow responsive. fetchLogRef.current?.setMaxFetchRequests(settingsDraft.maxFetchRequests); const nextRoots = cleanRoots(settingsDraft.roots); const currentRoots = cleanRoots(inspectorClient.getRoots()); if (JSON.stringify(nextRoots) !== JSON.stringify(currentRoots)) { void inspectorClient.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. }); } } setSettingsModalTargetId(undefined); }, [ flushSettingsDraft, inspectorClient, settingsModalTargetId, activeServerId, settingsDraft, ]); // 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)} onSubmit={onConfigSubmit} /> setImportConfigOpen(false)} onFetchSource={importSource} onAddServer={addServerHighlighted} onUpdateServer={updateServer} /> setImportJsonOpen(false)} onAddServer={addServerHighlighted} /> {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;