inspector/clients/web/src/App.tsx
cliffhall ed2070f67c chore(web): extract App.tsx's connection lifecycle into useConnectionLifecycle
Phase 2 step 3 of the App.tsx decomposition (#2154, under #2129/#2126).

Moves the whole connection cluster out of App.tsx and into
clients/web/src/hooks/useConnectionLifecycle.ts: setupClientForServer
(the single place a connection is configured), onToggleConnection,
onDisconnect, onReauthenticateFromBanner, the deep-link auto-connect
effect and its three phase latches, the connected/teardown/disconnect
effects, connectErrorMessage, and the session-scoped reset a disconnect
triggers. Handshake telemetry rides along as useHandshakeTelemetry,
split out only because useOAuthRecovery runs first and stamps the same
ref.

No behavior change. App.tsx goes 3,039 -> 2,412 lines.

Per #2129, resetSessionScopedUiState takes a narrow SessionResetSurface
- one reset() per owner - rather than every phase-1 setter, so the hook
does not end up depending on everything phase 1 extracted. The two
log-level operations stay App-owned because the connect path seeds the
same state.

Closes #2154

Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-27 00:40:18 -04:00

2395 lines
98 KiB
TypeScript

import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Box, Text } from "@mantine/core";
import { notifications } from "@mantine/notifications";
import type {
CreateMessageResult,
ElicitResult,
InitializeResult,
LoggingLevel,
Tool,
} from "@modelcontextprotocol/client";
import { InspectorClient } from "@inspector/core/mcp/index.js";
import { getServerType } from "@inspector/core/mcp/config.js";
import type { JsonValue } from "@inspector/core/mcp/index.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,
ServerEntry,
ServerType,
} from "@inspector/core/mcp/types.js";
import {
DEFAULT_TASK_TTL_MS,
resolveModernLogLevel,
} from "@inspector/core/mcp/types.js";
import {
cleanRoots,
serializeMcpConfig,
} from "@inspector/core/mcp/serverList.js";
import type { ClientConfig } from "@inspector/core/client/types.js";
import {
loadClientConfigRemote,
saveClientConfigRemote,
} from "@inspector/core/client/remote.js";
import { formatClientConfigLoadError } from "@inspector/core/client/config-parse.js";
import type { FetchRequestLogStateEventMap } from "@inspector/core/mcp/state/fetchRequestLogState.js";
import { useInspectorClient } from "@inspector/core/react/useInspectorClient.js";
import {
ServerListReloadError,
useServers,
} from "@inspector/core/react/useServers.js";
import { useSettingsDraft } from "@inspector/core/react/useSettingsDraft.js";
import { useClientSettingsDraft } from "@inspector/core/react/useClientSettingsDraft.js";
import { useEmaIdpLoginState } from "@inspector/core/react/useEmaIdpLoginState.js";
import { useLastPersistedSettings } from "./hooks/useLastPersistedSettings";
import { usePaginatedListsOverride } from "./hooks/usePaginatedListsOverride";
import { useValueChange } from "./hooks/useValueChange";
import { useThemeToggle } from "./hooks/useThemeToggle";
import { useTabUiState } from "./hooks/useTabUiState";
import { useSessionRef } from "./hooks/useSessionRef";
import {
useOAuthRecovery,
type SetupClientForServer,
} from "./hooks/useOAuthRecovery";
import { useInspectorStores } from "./hooks/useInspectorStores";
import {
useConnectionLifecycle,
useHandshakeTelemetry,
} from "./hooks/useConnectionLifecycle";
import { useMcpApps } from "./hooks/useMcpApps";
import { useExportActions } from "./hooks/useExportActions";
import { useProgressToasts } from "./hooks/useProgressToasts";
import { useTaskToasts } from "./hooks/useTaskToasts";
import type { ListPaginationControlsProps } from "./components/elements/ListPaginationControls/ListPaginationControls";
import { useInitialConfig } from "@inspector/core/react/useInitialConfig.js";
import { refreshingPersist } from "./lib/refreshingPersist";
import { usePendingClientRequests } from "@inspector/core/react/usePendingClientRequests.js";
import { InspectorView } from "./components/views/InspectorView/InspectorView";
import type {
ToolCallState,
ToolsUiState,
} from "./components/screens/ToolsScreen/ToolsScreen";
import type { GetPromptState } from "./components/screens/PromptsScreen/PromptsScreen";
import type { ReadResourceState } from "./components/screens/ResourcesScreen/ResourcesScreen";
import { AppElicitationHost } from "./components/elements/AppElicitation/AppElicitationHost";
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 { OutputValidationModal } from "./components/groups/OutputValidationModal/OutputValidationModal";
import { UrlElicitationErrorModal } from "./components/groups/UrlElicitationErrorModal/UrlElicitationErrorModal";
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 { downloadJsonFile } from "./lib/downloadFile";
import { enrichProtocolEntries } from "./utils/correlateTransportErrors";
import { visibleMalformedListItems } from "./utils/malformedListReport";
import { parseDeepLink, deepLinkParseStatus } from "./utils/deepLink";
import type { DeepLink, DeepLinkParseStatus } from "./utils/deepLink";
import { AuthRecoveryRequiredError } from "@inspector/core/auth/challenge.js";
import { getAuthToken } from "./lib/authToken";
import { messagesToLogEntries } from "./lib/protocolReplay";
import {
errorCodeOf,
errorMessage,
formatErrorDetails,
} from "./utils/errorFormat";
import { EMPTY_SETTINGS } from "./utils/serverSettingsDefaults";
import type { StepUpSource } from "./utils/stepUp";
import {
bodyDroppedToastId,
CLIENT_CONFIG_LOAD_ERROR_NOTIFICATION_ID,
} from "./utils/toasts/toastIds";
import { FetchBodyDroppedToastMessage } from "./components/elements/Toasts/FetchBodyDroppedToastMessage";
import { OutputValidationToastMessage } from "./components/elements/Toasts/OutputValidationToastMessage";
import { UrlElicitationErrorToastMessage } from "./components/elements/Toasts/UrlElicitationErrorToastMessage";
import { ReAuthBannerBar } from "./components/groups/ReAuthBanner/ReAuthBannerBar";
function App() {
const { onToggleTheme } = useThemeToggle();
// 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<string[]>(
[],
);
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 closeConnectionInfoModal = useCallback(
() => setConnectionInfoModalOpen(false),
[],
);
const [removeTarget, setRemoveTarget] = useState<ServerEntry | null>(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<string | undefined>(
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<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<InspectorClient | null>(null);
const configBaseUrl =
typeof window !== "undefined" ? window.location.origin : "http://localhost";
// One `GET /api/config` fetch recovers every static payload field the app
// reads: the MCP Apps `sandboxUrl`, the session's `writable` flag (read-only
// `--config` / ad-hoc sessions hide catalog CRUD; the default catalog and
// `--catalog` stay writable), and the Inspector `version` shown in the
// lower-right corner (the browser can't read it off disk).
const {
sandboxUrl,
writable: serverListWritable,
version: inspectorVersion,
secretStorage,
refresh: refreshInitialConfig,
loading: initialConfigLoading,
} = useInitialConfig({
baseUrl: configBaseUrl,
authToken: getAuthToken(),
});
const [clientConfig, setClientConfig] = useState<ClientConfig>({});
useEffect(() => {
if (typeof window === "undefined") return;
void loadClientConfigRemote({
baseUrl: configBaseUrl,
authToken: getAuthToken(),
})
.then(setClientConfig)
.catch((err) => {
setClientConfig({});
notifications.show({
id: CLIENT_CONFIG_LOAD_ERROR_NOTIFICATION_ID,
title: "Could not load Client Settings",
message: `${formatClientConfigLoadError(err)}\n\nCheck ~/.mcp-inspector/storage/client.json or re-enter settings in Client Settings.`,
color: "red",
autoClose: false,
});
});
}, [configBaseUrl]);
// the answer is baked into the client at construction (it decides whether the
// nested MCP Apps `elicitation` capability is advertised). So a connect waits
// for it rather than guessing: guessing "available" over-claims a capability
// we may not have, and guessing "unavailable" strands the whole session on
// the native form despite having a sandbox. The wait is a local fetch already
// in flight since mount.
const initialConfigSettledRef = useRef<{
promise: Promise<void>;
resolve: () => void;
}>(null);
initialConfigSettledRef.current ??= (() => {
let resolve!: () => void;
const promise = new Promise<void>((r) => (resolve = r));
return { promise, resolve };
})();
useEffect(() => {
if (!initialConfigLoading) initialConfigSettledRef.current?.resolve();
}, [initialConfigLoading]);
// Optimistic log level — `logging/setLevel` has no echo notification, so
// the parent keeps the current value locally.
const [currentLogLevel, setCurrentLogLevel] = useState<LoggingLevel>("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<LoggingLevel | null>(
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<ToolCallState | undefined>(
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 {
ui,
setUi,
activeTab,
setActiveTab,
pinnedProtocolIds,
setPinnedProtocolIds,
togglePinProtocol,
resetTabUiState,
} = useTabUiState();
// One stable ref mirroring every session value a long-lived callback needs
// to read *currently* rather than as of the render that created it. Declared
// here — ahead of its first reader — because a hook return is not provably
// stable to `react-hooks/exhaustive-deps`, so consumers list it and would hit
// the temporal dead zone if it came later. The two pending-OAuth slots are
// written into it by `useOAuthRecovery`, which owns them (#2153).
const sessionRef = useSessionRef({
activeServerId,
servers,
inspectorClient,
});
// The narrow reset surface `useConnectionLifecycle` drops session-scoped UI
// state through on disconnect — one entry per owner, rather than that
// owner's individual setters (#2129). The log-level pair stays here because
// it is App-owned state the connect path also seeds.
const resetLogLevels = useCallback(() => {
setCurrentLogLevel("info");
// Re-seed rather than blank: the client restores its own opt-in from the
// server setting at connect (`resetSessionState`), so blanking here would
// leave the control reading Off while every modern request still carries
// the level — visible on the auth-recovery path, which reconnects the same
// client instance rather than rebuilding it (#1629, #1797).
// The session ref is synced in a passive effect, so it still holds the
// outgoing server's id when this runs from `onDisconnect` — which is what
// lets the re-seed find its settings. Clearing that ref eagerly would take
// the no-server branch below and silently drop this to Off.
// Branch on the *server*, not its settings: an entry with no settings node
// is the common case (`mcp.json` written by hand, never opened in Server
// Settings), and there the default is right — it is what the seed and the
// client both use. Only "no server at all" means Off.
const outgoing = sessionRef.current.servers.find(
(s) => s.id === sessionRef.current.activeServerId,
);
setModernLogLevel(
outgoing ? (resolveModernLogLevel(outgoing.settings) ?? null) : null,
);
}, [sessionRef]);
// #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 is seeded the same way in its constructor). "off" means not opted
// in (null). Only affects modern connections.
const seedModernLogLevel = useCallback(
(settings: InspectorServerSettings | undefined) => {
setModernLogLevel(resolveModernLogLevel(settings) ?? null);
},
[],
);
// Progress and task-status toasts, and the taskId → progress map the Tasks
// screen renders from. Both hooks subscribe to the live client's events and
// own their own toast-id bookkeeping.
useProgressToasts(inspectorClient);
const { progressByTaskId, resetTaskProgress, activeToolCallTaskIdRef } =
useTaskToasts(inspectorClient);
// Hook layer. Each hook subscribes to its respective event source and
// re-renders the App on change. When `inspectorClient` / state managers
// are null, the hooks degrade to empty results.
const {
status: connectionStatus,
capabilities,
clientCapabilities,
serverInfo,
instructions,
protocolVersion,
protocolEra,
discoverResult,
excludedTools,
malformedListItems,
lastError,
} = useInspectorClient(inspectorClient);
// What every settings write that landed on disk actually wrote, so a later
// failed write can be rolled back to that rather than to a `servers` entry
// frozen at the last successful list read (#2089).
const lastPersistedSettings = useLastPersistedSettings(servers);
// The active server's persisted paginated setting drives the display mode.
// The sidebar toggle edits it (optimistically, below) and persists it.
const activeServerEntry = servers.find((s) => s.id === activeServerId);
const persistedPaginatedLists =
activeServerEntry?.settings?.paginatedLists ?? false;
// The optimistic override, held per server and superseded by the first
// successful list read that rebuilds that server's entry. Per server rather
// than one app-wide slot cleared on every active-entry change: the previous
// shape dropped the override on a plain server switch, so an A → B → A round
// trip fell back to A's stale entry and showed a value neither disk nor the
// live client held (#2095).
const paginatedListsOverride = usePaginatedListsOverride(servers);
const paginatedLists =
paginatedListsOverride.valueFor(activeServerId) ?? persistedPaginatedLists;
const connected = connectionStatus === "connected";
// Handshake telemetry. `connectStartRef` is stamped at the "connecting" edge
// and consumed at the "connected" edge — a ref (not state) so the
// intervening rerenders don't reset it. Declared here rather than inside
// `useConnectionLifecycle` because `useOAuthRecovery`, which runs first,
// stamps it too (#2154).
const { connectStartRef, latencyMs } =
useHandshakeTelemetry(connectionStatus);
// The per-session state managers and the finished lists they feed. Owns the
// create / destroy lifecycle, so the connection code below sees two stable
// callbacks rather than twelve state slots.
const {
stores,
createStores,
destroyStores,
fetchLogRef,
refreshTools,
refreshPrompts,
refreshResources,
loadToolsPage,
loadPromptsPage,
loadResourcesPage,
toolsListChanged,
clearToolsListChanged,
promptsListChanged,
clearPromptsListChanged,
resourcesListChanged,
clearResourcesListChanged,
resourceTemplates,
resourceTemplatesLoadError,
refreshResourceTemplates,
toolsPagination,
promptsPagination,
resourcesPagination,
tasks,
refreshTasks,
clearCompletedTasks,
subscriptions,
subscriptionStreamState,
messages,
fetchRequests,
stderrLogs,
} = useInspectorStores({ inspectorClient, connected, paginatedLists });
/** Drops every in-flight result panel; see `clearResultPanels` below. */
const clearResultPanels = useCallback(() => {
setToolCallState(undefined);
setGetPromptState(undefined);
setReadResourceState(undefined);
}, []);
/**
* Routes a step-up failure or cancellation to the panel that issued the
* command. `app` and `ambient` have no panel of their own — the App bridge
* surfaces its own message and an ambient challenge was never user-initiated
* — so they fall through to the no-op arm.
*/
const setSourceScopedError = useCallback(
(source: StepUpSource, message: string) => {
switch (source) {
case "tool":
setToolCallState({ status: "error", error: message });
break;
case "prompt":
setGetPromptState((prev) =>
prev ? { ...prev, status: "error", error: message } : prev,
);
break;
case "resource":
setReadResourceState((prev) =>
prev ? { ...prev, status: "error", error: message } : prev,
);
break;
default:
break;
}
},
[],
);
/**
* Published below, once the connect path's `setupClientForServer` exists —
* it reads `onBeforeOAuthRedirect` and `sessionStorageAdapter` out of the
* hook we are about to call, so it cannot be declared before it.
*/
const setupClientForServerRef = useRef<SetupClientForServer | null>(null);
// Every path by which a lapsed authorization is noticed and recovered: the
// re-auth banner, step-up, deferred background-tab recovery, the
// `/oauth/callback` completion, and the redirect plumbing the connect path
// below consumes (#2153).
const {
webOAuthStorage,
sessionStorageAdapter,
onBeforeOAuthRedirect,
prepareOAuthRedirect,
reAuthBanner,
setReAuthBanner,
resetOAuthRecoveryState,
pendingStepUp,
handleStepUpAuthorize,
handleStepUpCancel,
handleCommandScopedAuthRecovery,
runWithCommandAuthRecovery,
runCommandInBackground,
connectionInfoOAuth,
clearServerOAuthAndDisconnect,
finalizeExplicitDisconnect,
} = useOAuthRecovery({
sessionRef,
servers,
activeServerId,
inspectorClient,
connectionStatus,
activeTab,
ui,
setUi,
setActiveTab,
fetchLogRef,
connectStartRef,
initialConfigSettledRef,
setupClientForServerRef,
setActiveServerId,
setFailedServerId,
clearResultPanels,
setSourceScopedError,
});
// The malformed-entry report is written by the aggregate walk's salvage. In
// paginated mode the tools/prompts/resources panels render the paged stores
// instead, which never write or clear it — so it would linger above a page it
// does not describe. See `visibleMalformedListItems` (#1909 + #1721).
const shownMalformedListItems = useMemo(
() => visibleMalformedListItems(malformedListItems, paginatedLists),
[malformedListItems, paginatedLists],
);
const tools = toolsPagination.items;
const prompts = promptsPagination.items;
const resources = resourcesPagination.items;
// The session-scoped state each owner drops on disconnect. One `reset()`
// per owner, handed to `useConnectionLifecycle` as a single object so the
// connection hook does not take a dependency on every phase-1 hook (#2129).
const sessionReset = useMemo(
() => ({
clearResultPanels,
resetTabUiState,
resetTaskProgress,
resetOAuthRecoveryState,
resetLogLevels,
closeConnectionInfoModal,
}),
[
clearResultPanels,
resetTabUiState,
resetTaskProgress,
resetOAuthRecoveryState,
resetLogLevels,
closeConnectionInfoModal,
],
);
// MCP Apps runtime wiring (#2156): the sandbox bridge factories, the renderer
// handle, and the app-rendered elicitation controller. Called here rather
// than beside the other client-scoped hooks above because it reads the
// resource listing, which is only derived at this point.
const {
appRendererRef,
sandboxBridgeFactory,
elicitationBridgeFactory,
appElicitations,
newAppElicitationSession,
handleAppElicitationSettle,
handleAppElicitationFail,
} = useMcpApps({ inspectorClient, configBaseUrl, resources });
// 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)];
}, []);
// Everything about bringing a session up and taking it down: building the
// client for a server, connecting, disconnecting, the effects that observe
// those transitions, and the session-scoped reset a disconnect triggers
// (#2154).
const {
connectedServerId,
connectErrorMessage,
onToggleConnection,
onDisconnect,
onReauthenticateFromBanner,
} = useConnectionLifecycle({
sessionRef,
servers,
activeServerId,
inspectorClient,
connectionStatus,
addServer,
updateServer,
setActiveServerId,
setFailedServerId,
setInspectorClient,
createStores,
destroyStores,
lastPersistedSettings,
clientConfig,
newAppElicitationSession,
sandboxUrl,
initialConfigSettledRef,
connectStartRef,
setupClientForServerRef,
deepLink,
webOAuthStorage,
sessionStorageAdapter,
onBeforeOAuthRedirect,
prepareOAuthRedirect,
finalizeExplicitDisconnect,
reAuthBanner,
setReAuthBanner,
sessionReset,
seedModernLogLevel,
});
// 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(() => {
const fetchRequestLogState = stores?.fetchRequestLogState;
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: (
<FetchBodyDroppedToastMessage
maxFetchRequests={event.detail.maxFetchRequests}
onAdjust={() => {
notifications.hide(bodyDroppedToastId(activeServerId));
setSettingsModalTargetId(activeServerId);
}}
/>
),
});
};
fetchRequestLogState.addEventListener(
"fetchRequestBodyDropped",
onBodyDropped,
);
return () => {
fetchRequestLogState.removeEventListener(
"fetchRequestBodyDropped",
onBodyDropped,
);
};
}, [stores, 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);
// 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<ServerEntry | undefined>(
() => servers.find((s) => s.id === activeServerId),
[servers, activeServerId],
);
// Whether the server actually reported `serverInfo`, vs. the catalog-name
// fallback synthesized below. Threaded to the Connection Info modal so it can
// show "not reported" instead of an inferred name that looks server-sent.
const serverInfoReported = serverInfo !== undefined;
// Build the InitializeResult the connected ViewHeader / Connection Info
// modal expect from the hook's split fields. `protocolVersion` is the value
// the InspectorClient negotiated during initialize (#1324); it's dispatched
// alongside serverInfo, so in practice it's present whenever we're connected.
// We gate only on `connectionStatus`, never on serverInfo or protocolVersion:
// this object also drives the connected header (and its whole tab bar) and the
// Connection Info modal, so a missing field must not hide those.
//
// A modern-era server's `server/discover` makes `serverInfo` OPTIONAL (SHOULD,
// not MUST — it's stamped in `_meta["io.modelcontextprotocol/serverInfo"]`), so
// a conforming modern server may omit it and `serverInfo` stays undefined even
// while connected. Falling back to the catalog name (rather than returning
// `undefined`) keeps the header + tabs rendered for those servers (#1772). It
// fires for such a modern server — and, harmlessly, in the batched instant on
// any connect (legacy included) between the `connected` status dispatch and the
// `serverInfo` dispatch, which land in a single React render. The modal uses
// `serverInfoReported` (above), not this name, to stay faithful.
//
// This `??` only covers an *absent* serverInfo. A server that *reports* a
// blank name (`{ name: "" }`) is degraded for display a layer below, in
// InspectorView's `resolveHeaderServerInfo` (#1774) — kept there so this
// faithful object (and thus the modal) never carries a borrowed name.
const initializeResult = useMemo<InitializeResult | undefined>(() => {
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,
]);
// 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 = sessionRef.current.activeServerName;
notifications.show({
title: name ? `Connection to "${name}" lost` : "Connection lost",
message: lastError,
color: "red",
});
}, [sessionRef, 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 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<LogEntryData[]>(
() => messagesToLogEntries(messages),
[messages],
);
// Make the live client reflect one settings value, in full. Every path that
// decides what the active server's settings *are* goes through this: the
// settings modal's close (#1444), and each write's settled / rolled-back
// reconciliation (#2089). It is one function because the live surface is more
// than `setServerSettings` — a partial re-application would leave the Network
// log sized for, and the server advertised roots from, a value that is not on
// disk. Reads the client through its ref so a write outliving a reconnect
// applies to the instance that exists now.
const applyLiveServerSettings = useCallback(
(settings: InspectorServerSettings) => {
const client = sessionRef.current.inspectorClient;
if (!client) return;
// Settings the managed state reads at notification time
// (auto-refresh-on-list-changed) take effect without a reconnect (#1444).
// Connection-time inputs (transport, OAuth, timeouts) still only apply on
// the next connect.
client.setServerSettings(settings);
// Resize the Network log buffer live so a maxFetchRequests edit takes
// effect without a reconnect (shrinking trims immediately).
fetchLogRef.current?.setMaxFetchRequests(settings.maxFetchRequests);
// Root edits are diffed against what the client currently advertises
// (both cleaned) and notified only when they differ: `setRoots` fires
// `notifications/roots/list_changed`, which makes the server re-request
// `roots/list`.
const nextRoots = cleanRoots(settings.roots);
const currentRoots = cleanRoots(client.getRoots());
if (JSON.stringify(nextRoots) !== JSON.stringify(currentRoots)) {
void client.setRoots(nextRoots).catch(() => {
// setRoots swallows notification failures internally; a throw here
// only means the client is mid-teardown — the persisted roots will
// re-advertise on the next connect, so nothing to surface.
});
}
},
[sessionRef, fetchLogRef],
);
// --- Action handlers that route directly to the InspectorClient. ---
const onCallTool = useCallback(
async (
name: string,
args: Record<string, unknown>,
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<string, unknown>` (it accepts
// anything the user types into the schema form). `callTool` requires
// `Record<string, JsonValue>` — narrow at the boundary instead of
// claiming the object is empty (which the previous `as Record<string,
// never>` cast did, misleadingly).
const invocation = asTask
? await inspectorClient.callToolStream(
tool,
args as Record<string, JsonValue>,
undefined,
undefined,
{ ttl: activeServer?.settings?.taskTtl || DEFAULT_TASK_TTL_MS },
)
: await inspectorClient.callTool(
tool,
args as Record<string, JsonValue>,
);
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: (
<Text size="sm">
The server requested the same URL again after you completed it (
{err.url}), so the call was cancelled to avoid an endless loop.
</Text>
),
});
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: (
<UrlElicitationErrorToastMessage
onViewDetails={() => setUrlElicitationErrorDetails(details)}
/>
),
});
return;
}
setToolCallState({
status: "error",
error: errorMessage(err),
errorCode: errorCodeOf(err),
});
}
},
[
inspectorClient,
tools,
activeServer,
capabilities,
activeServerId,
handleCommandScopedAuthRecovery,
activeToolCallTaskIdRef,
],
);
const onClearToolResult = useCallback(() => {
setToolCallState(undefined);
}, []);
// Tools UI changes flow through here so selecting a *different* tool also
// drops the previous tool's result — the result panel renders `toolCallState`
// regardless of selection, so without this a stale result would linger under
// the newly-selected tool (which has no result of its own yet). Search and
// form edits keep `selectedToolKey` unchanged, so they leave the result be.
// Depends on `selectedToolKey` only (not the whole `toolsUi`), so a search
// keystroke doesn't churn the callback identity.
const onToolsUiChange = useCallback(
(next: ToolsUiState) => {
if (next.selectedToolKey !== ui.toolsUi.selectedToolKey) {
setToolCallState(undefined);
}
setUi.setToolsUi(next);
},
[ui.toolsUi.selectedToolKey, setUi],
);
// --- 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<string, unknown>) => {
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<string, JsonValue>,
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: (
<OutputValidationToastMessage
onViewDetails={() => 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,
appRendererRef,
],
);
const onCloseApp = useCallback(() => {
void appRendererRef.current?.teardown();
}, [appRendererRef]);
const onGetPrompt = useCallback(
async (name: string, args: Record<string, string>) => {
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<string, string>,
): Promise<string[]> => {
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, activeToolCallTaskIdRef]);
const onClearCompletedTasks = useCallback(() => {
clearCompletedTasks();
}, [clearCompletedTasks]);
const onSetLogLevel = useCallback(
(level: LoggingLevel) => {
setCurrentLogLevel(level);
if (!inspectorClient) return;
// Nothing else records a `logging/setLevel` failure, and the optimistic
// level above already moved — so report it, or the selector would sit on
// a level the server never accepted with no explanation.
runCommandInBackground(
() => inspectorClient.setLoggingLevel(level),
"ambient",
"Failed to set log level",
);
},
[inspectorClient, runCommandInBackground],
);
// Modern era (#1629): no request is sent — the client stores the level and
// stamps it on every subsequent request's `_meta`. `null` opts back out.
const onSetModernLogLevel = useCallback(
(level: LoggingLevel | null) => {
setModernLogLevel(level);
inspectorClient?.setModernLogLevel(level ?? undefined);
},
[inspectorClient],
);
// Refresh acts per pagination mode: in paginated mode reload page 1 (the
// paged state); in all-pages mode re-fetch the whole aggregate with auth
// recovery (the pre-existing path). See usePaginatedList / #1721.
const onRefreshTools = useCallback(() => {
if (paginatedLists) {
// Paginated refresh reloads page 1 of the paged store, bypassing the
// managed hook's refresh — so acknowledge the list-changed indicator here
// (the managed state lit it on `list_changed`; nothing else clears it in
// paginated mode) (#1721).
clearToolsListChanged();
runCommandInBackground(() => toolsPagination.onRefresh(), "ambient");
} else {
runCommandInBackground(() => refreshTools(), "ambient");
}
}, [
paginatedLists,
toolsPagination,
refreshTools,
clearToolsListChanged,
runCommandInBackground,
]);
const onRefreshPrompts = useCallback(() => {
if (paginatedLists) {
clearPromptsListChanged();
runCommandInBackground(() => promptsPagination.onRefresh(), "ambient");
} else {
runCommandInBackground(() => refreshPrompts(), "ambient");
}
}, [
paginatedLists,
promptsPagination,
refreshPrompts,
clearPromptsListChanged,
runCommandInBackground,
]);
const onRefreshResources = useCallback(() => {
if (paginatedLists) {
clearResourcesListChanged();
runCommandInBackground(() => resourcesPagination.onRefresh(), "ambient");
// Resource templates always use the managed (aggregate) path.
runCommandInBackground(() => refreshResourceTemplates(), "ambient");
} else {
runCommandInBackground(async () => {
await refreshResources();
await refreshResourceTemplates();
}, "ambient");
}
}, [
paginatedLists,
resourcesPagination,
refreshResources,
refreshResourceTemplates,
clearResourcesListChanged,
runCommandInBackground,
]);
// The per-list sidebar toggle edits the server-wide `paginatedLists` setting:
// optimistic override for an instant flip, live push so the managed state's
// gating reads it now, and a persisted PUT so it survives reconnects (#1721).
const onTogglePaginatedLists = useCallback(
(value: boolean) => {
const current = servers.find((s) => s.id === activeServerId);
if (!current || activeServerId === undefined) return;
// Recorded against this server rather than app-wide, so it survives a
// switch away and back (#2095). Ordered after the id guard because the
// record is keyed by that id; with no active server there is nothing the
// toggle could have been flipped for.
paginatedListsOverride.record(activeServerId, value);
// Not `current.settings` directly: that entry only advances on a
// successful list read, so once one has failed it describes disk as it
// was *before* the writes made since. Build on the last write known to
// have landed while that is the fresher account (#2089).
const next: InspectorServerSettings = {
...(lastPersistedSettings.resolve(activeServerId) ?? EMPTY_SETTINGS),
paginatedLists: value,
};
inspectorClient?.setServerSettings(next);
// Drive the load that the mode change implies (data-loading stays out of
// React effects; the paged stores own only the connect-time load). To
// paginated: pull page 1 into each paged store. To all-pages: refetch
// each managed aggregate that was gated off. Only when connected.
if (connected) {
// Wrap in ambient auth recovery so a mid-session 401 triggers re-auth
// rather than surfacing raw, matching the all-pages refresh path.
if (value) {
runCommandInBackground(() => loadToolsPage(undefined), "ambient");
runCommandInBackground(() => loadPromptsPage(undefined), "ambient");
runCommandInBackground(() => loadResourcesPage(undefined), "ambient");
} else {
runCommandInBackground(() => refreshTools(), "ambient");
runCommandInBackground(() => refreshPrompts(), "ambient");
runCommandInBackground(() => refreshResources(), "ambient");
}
}
// Refreshed like every other secret-store mutation: this resends the
// server's rehydrated secrets, so it can trigger the pending
// plaintext-to-encrypted upgrade even though the user only toggled
// pagination (#1950 review r22).
// Announced before the request goes out, so two toggles in flight at once
// are ordered by when they were issued rather than by which one's list
// reload finished first (#2089).
const write = lastPersistedSettings.begin(activeServerId);
// This value is on disk now. Remember it as the rollback baseline for
// whatever is written next, since the `servers` entry it was derived
// from will keep describing the old value if the reload behind this
// write — or any later one — fails (#2089).
//
// Re-apply it when this write is the settled one: an overlapping toggle
// that failed *first* rolled the UI and the live client back to a
// baseline this write has since replaced, and if the list read behind
// this write failed too, nothing else would ever correct them. Through
// the *current* client, not this continuation's closure: a reconnect to
// the same server passes the id check while the captured instance is
// already destroyed.
const settlePaginationWrite = () => {
const settled = write.landed(next);
if (!settled) return;
// The override is keyed by server, so it is re-applied whatever is
// active now — it is this server's value and is only ever displayed
// while this server is the active one. The live client is not: it
// belongs to whichever server is connected (#2095).
paginatedListsOverride.record(
activeServerId,
next.paginatedLists ?? false,
);
if (sessionRef.current.activeServerId === activeServerId) {
applyLiveServerSettings(next);
}
};
void refreshingPersist(updateServerSettings, refreshInitialConfig)(
activeServerId,
next,
)
.then(settlePaginationWrite)
.catch((err: unknown) => {
// A `ServerListReloadError` means the PUT landed and only reading the
// list back failed, so the new setting IS on disk (#1914). That is a
// landed write, not a failed one: rolling back would put the UI and
// the live client on the *old* value and contradict disk. Settle it
// exactly as the success path does and report only the failed reload.
if (err instanceof ServerListReloadError) {
settlePaginationWrite();
notifications.show({
title:
"Pagination setting saved, but the server list did not reload",
message: err.message,
color: "red",
});
return;
}
// This write is over and never reached disk, so it stops counting as
// in flight: an earlier write still running is the settled state once
// it lands, and is what re-applies the UI this rollback is about to
// set (#2089).
write.failed();
// Persist failed: revert the optimistic override and roll the live
// client setting back, so the UI and client reflect the value that's
// actually on disk rather than the failed edit (#1721).
//
// The baseline is resolved *here*, not captured when this write was
// issued: another toggle can land in between, and its value is what
// disk holds by the time this one fails. The override is set to that
// baseline rather than cleared, because clearing it falls back to
// `persistedPaginatedLists` — read from a `servers` entry that may be
// stale, showing the same wrong value from the other side (#2089).
//
// The override is recorded whatever is active by the time this
// rejection arrives: it is keyed by this write's server and is only
// ever displayed while that server is the active one, so a switch in
// between costs nothing and dropping it would leave the stale entry
// to answer for A the next time it comes back (#2095).
//
// The live client is the half that stays gated — it belongs to
// whichever server is connected now, so pushing this server's value
// into it after a switch would apply it to another one. It is taken
// from the ref for the same reason the success path does: a reconnect
// to the same server passes the id check while this continuation's
// captured instance is already destroyed.
const baseline =
lastPersistedSettings.resolve(activeServerId) ?? EMPTY_SETTINGS;
paginatedListsOverride.record(
activeServerId,
baseline.paginatedLists ?? false,
);
if (sessionRef.current.activeServerId === activeServerId) {
applyLiveServerSettings(baseline);
}
notifications.show({
title: "Failed to save pagination setting",
message: err instanceof Error ? err.message : String(err),
color: "red",
});
});
},
[
sessionRef,
servers,
activeServerId,
lastPersistedSettings,
paginatedListsOverride,
applyLiveServerSettings,
inspectorClient,
updateServerSettings,
refreshInitialConfig,
connected,
loadToolsPage,
loadPromptsPage,
loadResourcesPage,
refreshTools,
refreshPrompts,
refreshResources,
runCommandInBackground,
],
);
// Wrap Load-next-page in ambient auth recovery too, so a paginated
// paginated fetch that hits a 401 recovers like the all-pages path (#1721).
const onLoadMoreTools = useCallback(
() => runCommandInBackground(() => toolsPagination.onLoadMore(), "ambient"),
[toolsPagination, runCommandInBackground],
);
const onLoadMorePrompts = useCallback(
() =>
runCommandInBackground(() => promptsPagination.onLoadMore(), "ambient"),
[promptsPagination, runCommandInBackground],
);
const onLoadMoreResources = useCallback(
() =>
runCommandInBackground(() => resourcesPagination.onLoadMore(), "ambient"),
[resourcesPagination, runCommandInBackground],
);
const toolsPaginationControls: ListPaginationControlsProps = {
paginated: toolsPagination.paginated,
onPaginatedChange: onTogglePaginatedLists,
canLoadMore: toolsPagination.canLoadMore,
loadedPages: toolsPagination.loadedPages,
onLoadMore: onLoadMoreTools,
};
const promptsPaginationControls: ListPaginationControlsProps = {
paginated: promptsPagination.paginated,
onPaginatedChange: onTogglePaginatedLists,
canLoadMore: promptsPagination.canLoadMore,
loadedPages: promptsPagination.loadedPages,
onLoadMore: onLoadMorePrompts,
};
const resourcesPaginationControls: ListPaginationControlsProps = {
paginated: resourcesPagination.paginated,
onPaginatedChange: onTogglePaginatedLists,
canLoadMore: resourcesPagination.canLoadMore,
loadedPages: resourcesPagination.loadedPages,
onLoadMore: onLoadMoreResources,
};
const onRefreshTasks = useCallback(() => {
runCommandInBackground(
() => refreshTasks(),
"ambient",
"Failed to refresh tasks",
);
}, [refreshTasks, runCommandInBackground]);
// Clear / Export / Replay for the four log-ish views (Logs, Protocol,
// Network, Console), plus the Protocol per-section variants.
const {
onClearLogs,
onClearProtocol,
onClearNetwork,
onClearConsole,
onExportLogs,
onExportProtocol,
onExportNetwork,
onExportConsole,
onClearProtocolSection,
onExportProtocolSection,
onReplayProtocol,
} = useExportActions({
activeServerId,
messageLogState: stores?.messageLogState ?? null,
fetchRequestLogState: stores?.fetchRequestLogState ?? null,
stderrLogState: stores?.stderrLogState ?? null,
messages,
fetchRequests,
logs,
stderrLogs,
pinnedProtocolIds,
setPinnedProtocolIds,
inspectorClient,
tools,
});
// 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();
}
destroyStores();
setInspectorClient(null);
setActiveServerId(undefined);
}
// Deleting sweeps the server's secrets from the store, which for a
// file-backed store is a write — and a write is what performs the pending
// plaintext-to-encrypted upgrade (#1950 review r22).
await refreshingPersist(removeServer, refreshInitialConfig)(id);
setRemoveTarget(null);
}, [
removeTarget,
activeServerId,
inspectorClient,
destroyStores,
removeServer,
refreshInitialConfig,
]);
// Submit handler for the Add / Edit / Clone modal. Add and Clone both go
// through addServer; Edit uses updateServer (which supports id rename).
// Add a server, then mark it as the freshly-added one so the list scrolls to
// it and highlights it. Used by manual add/clone and both import flows; edits
// and conflict-overwrites (updateServer) intentionally don't highlight.
// Accumulates into the current highlight batch (a multi-server import adds
// each id), deduped. The batch is reset to empty when an add/import modal
// opens (see the menu handlers).
const addServerHighlighted = useCallback(
async (id: string, config: MCPServerConfig) => {
const markAdded = () =>
setHighlightedServerIds((ids) =>
ids.includes(id) ? ids : [...ids, id],
);
try {
await addServer(id, config);
} catch (err) {
// A failed list reload still means the row is on disk, so it belongs
// in the highlight batch — the next successful refresh is what
// renders it, and it would otherwise arrive unmarked (#1914 r2).
if (err instanceof ServerListReloadError) markAdded();
throw err;
}
markAdded();
},
[addServer],
);
// On rename of the active server, keep activeServerId pointed at the new id.
const onConfigSubmit = useCallback(
async (id: string, config: MCPServerConfig) => {
if (configModal?.mode === "edit" && configModal.targetId) {
const originalId = configModal.targetId;
const followRename = () => {
if (originalId === activeServerId && id !== originalId) {
setActiveServerId(id);
}
};
try {
await updateServer(originalId, id, config);
} catch (err) {
// A `ServerListReloadError` means the PUT landed and only reading
// the list back failed — the rename IS on disk. The active
// selection has to follow it anyway, or the next successful
// refresh leaves `activeServerId` pointing at an id that no longer
// exists (#1914 r2). Still rethrow, so the modal shows the reload
// error rather than closing as if nothing happened.
if (err instanceof ServerListReloadError) followRename();
throw err;
}
followRename();
return;
}
// add or clone
await addServerHighlighted(id, config);
},
[configModal, addServerHighlighted, updateServer, activeServerId],
);
// Derive the existingIds list the modal uses for uniqueness validation.
// In edit mode the target's own id must be excluded so saving without
// renaming doesn't trip the "already exists" check.
const existingIds = useMemo(() => {
const ids = servers.map((s) => s.id);
if (configModal?.mode === "edit" && configModal.targetId) {
return ids.filter((id) => id !== configModal.targetId);
}
return ids;
}, [servers, configModal]);
const configModalTarget = useMemo(() => {
if (!configModal?.targetId) return undefined;
return servers.find((s) => s.id === configModal.targetId);
}, [configModal, servers]);
// An edit/clone modal whose target has left the list can no longer render a
// coherent form: `initialId`/`initialConfig` go undefined, and
// ServerConfigModal's own `useValueChange` reset then blanks the open form
// *and* clears the error it is showing.
//
// Only reachable since #1914 made the modal stay open on a failed post-write
// reload: rename A → B, the PUT lands, the reload fails, and the modal sits
// there targeting A. The write itself is what triggers the backend's change
// broadcast, so the SSE-driven background refresh that lands B (and drops A)
// is the *likely* next event, not a remote one. Closing is right — the edit
// is on disk and the list now agrees — and it covers the ordinary case too,
// where an external mcp.json edit removes the server being edited.
//
// Adjusted during render rather than in an effect (see `useValueChange`), so
// no frame ever paints the blanked form.
useValueChange(
configModal?.mode === "add" || configModal?.targetId === undefined
? true
: servers.some((s) => s.id === configModal.targetId),
(targetPresent) => {
if (!targetPresent) setConfigModal(null);
},
);
const settingsModalTarget = useMemo(() => {
if (!settingsModalTargetId) return undefined;
return servers.find((s) => s.id === settingsModalTargetId);
}, [settingsModalTargetId, servers]);
const settingsModalServerType = settingsModalTarget
? getServerType(settingsModalTarget.config)
: "stdio";
// The settings modal is fully controlled — every input change fires
// `onSettingsChange` back up here, and the input's `value` prop only
// updates when this component re-renders with a new `settings` prop.
// We hold the in-progress draft in `useSettingsDraft` so every change
// re-renders synchronously; the hook also debounces the PUT and
// exposes `flush` for the close handler to call. The draft is
// (re)initialized only when the modal opens to a *different* server,
// which is why a background refresh of `servers` can run without
// clobbering in-progress edits.
//
// `resolveInitial` reads `servers` from this render's closure — that
// works because the settings entry point is the "Settings" button on
// a rendered server card, so `servers` is always non-empty by the
// time this hook is called. A future caller that opens the modal
// from elsewhere (e.g. a keyboard shortcut on initial load) would
// need a different initialization path; the empty-shell fallback at
// least keeps the form renderable while `servers` hydrates.
const {
draft: settingsDraft,
onChange: onSettingsChange,
flush: flushSettingsDraft,
} = useSettingsDraft<InspectorServerSettings>({
targetId: settingsModalTargetId,
// Through the tracker rather than off `servers` directly: after a write
// lands whose list reload failed, that entry still describes the previous
// value, so seeding from it and saving an unrelated field would write the
// superseded value straight back to disk (#2089). `resolve` falls back to
// the entry when nothing has landed, so the ordinary case is unchanged.
resolveInitial: (id) => lastPersistedSettings.resolve(id) ?? EMPTY_SETTINGS,
// The saved settings may include an OAuth client secret or a stdio `env:`
// value, and writing one can change what the secrets file *is* — the first
// save under a newly-set passphrase re-encrypts a pre-existing plaintext
// file. `refreshingPersist` re-asks the backend afterwards; it is a named
// unit rather than an inline `await …; refresh()` because this file is
// outside the coverage gate, so wiring written here is tested by nothing
// (#1950 review r17).
onPersist: async (id: string, value: InspectorServerSettings) => {
// Announced before the request, like the toggle's own write: the debounced
// flush can put two saves for one server in flight, and the later-issued
// one describes disk however the two happen to finish (#2089).
const write = lastPersistedSettings.begin(id);
const settleSettingsWrite = () => {
const settled = write.landed(value);
if (!settled) return;
paginatedListsOverride.record(id, value.paginatedLists ?? false);
if (sessionRef.current.activeServerId === id) {
applyLiveServerSettings(value);
}
};
try {
await refreshingPersist(updateServerSettings, refreshInitialConfig)(
id,
value,
);
} catch (err) {
// A `ServerListReloadError` means the PUT landed and only reading the
// list back failed, so this write did reach disk (#1914): record it
// like the success path below instead of rolling anything back, and
// rethrow so `onError` can say the reload — not the save — failed.
if (err instanceof ServerListReloadError) {
settleSettingsWrite();
throw err;
}
// Stop counting as in flight before the rejection goes on to
// `onError` — an earlier write still running settles the state once it
// lands, and cannot know that while this one looks pending (#2089).
write.failed();
// A write that landed while this one was pending was told it was not
// settled and so applied nothing; this save never reached disk, so that
// write is the account of it and nothing else will re-apply it. Same
// reconciliation the toggle's own failure path does.
const baseline = lastPersistedSettings.resolve(id);
if (baseline) {
paginatedListsOverride.record(id, baseline.paginatedLists ?? false);
if (sessionRef.current.activeServerId === id) {
applyLiveServerSettings(baseline);
}
}
throw err;
}
// Recorded for the same reason the pagination toggle records its own
// write: this is what disk holds now, and the `servers` entry it was
// edited from will keep describing the previous value for as long as list
// reads keep failing. Both settings writers feed the same per-server
// record, so a rollback in either takes the most recent write for that
// server, not just the most recent write *of its own kind*.
//
// And the reconciliation is shared too, for the mixed-writer overlap: a
// toggle failing while this save is in flight rolls the UI and the live
// client back to a baseline this save then replaces on disk, so a settled
// save has to re-apply itself exactly as a settled toggle does (#2089).
settleSettingsWrite();
},
// Surface failures via toast — the modal usually closes
// immediately on user dismiss, so a silent fail-on-flush would
// leave the user thinking their last edits saved when they
// didn't (especially painful for the OAuth client secret).
onError: (id, err) => {
notifications.show({
// A `ServerListReloadError` means the PUT landed and only reading the
// list back failed, so "Failed to save" would be false — and this
// toast is the user's only signal here, since the modal has usually
// closed by the time a flush rejects (#1914 r3).
title:
err instanceof ServerListReloadError
? `Saved settings for "${id}", but the server list did not reload`
: `Failed to save settings for "${id}"`,
message: err instanceof Error ? err.message : String(err),
color: "red",
});
},
});
const settingsModalValue: InspectorServerSettings =
settingsDraft ?? EMPTY_SETTINGS;
const {
draft: clientSettingsDraft,
onChange: onClientSettingsChange,
flush: flushClientSettingsDraft,
} = useClientSettingsDraft({
opened: clientSettingsOpen,
resolveInitial: () => clientConfigToFormValues(clientConfig),
// Wrapped for the same reason as the server-settings persist above: this
// one can carry the enterprise IdP client secret, and that write is what
// flips a pending-encryption file to encrypted.
onPersist: refreshingPersist(async (values) => {
if (!canPersistClientSettingsDraft(values)) return;
const next = formValuesToClientConfig(values);
await saveClientConfigRemote(next, {
baseUrl: configBaseUrl,
authToken: getAuthToken(),
});
setClientConfig(next);
}, refreshInitialConfig),
onError: (err) => {
notifications.show({
title: "Failed to save client settings",
message: formatClientConfigLoadError(err),
color: "red",
});
},
});
const clientSettingsModalValue = clientSettingsDraft ?? EMPTY_CLIENT_SETTINGS;
const { loginState: emaIdpLoginState, logout: logoutEmaIdp } =
useEmaIdpLoginState(
webOAuthStorage,
clientSettingsModalValue.emaEnabled
? clientSettingsModalValue.issuer
: undefined,
clientSettingsOpen,
);
const onClientSettingsModalClose = useCallback(() => {
// Only fires when ClientSettingsModal allows the close (it blocks on an
// invalid issuer and reveals the error instead). The implicit "save" is the
// flush below; the persist gate drops anything still incomplete.
flushClientSettingsDraft();
setClientSettingsOpen(false);
}, [flushClientSettingsDraft]);
// Gate the stdio-only Working Directory / Environment Variables controls in
// the settings modal. Derived from the resolved target server's transport
// (see `settingsModalServerType` above), which defaults to "stdio" when the
// target isn't resolvable.
const settingsModalIsStdio = settingsModalServerType === "stdio";
const handleClearConnectionOAuth = useCallback(() => {
if (!activeServer) return;
void clearServerOAuthAndDisconnect(activeServer);
}, [activeServer, clearServerOAuthAndDisconnect]);
const handleClearStoredOAuthFromSettings = useCallback(() => {
if (!settingsModalTarget) return;
void clearServerOAuthAndDisconnect(settingsModalTarget);
}, [settingsModalTarget, clearServerOAuthAndDisconnect]);
const onSettingsModalClose = useCallback(() => {
flushSettingsDraft();
// Apply root edits to the live client once, on close — not on every
// keystroke. `setRoots` fires `notifications/roots/list_changed`, which
// makes the server re-request `roots/list`; doing that per character while
// the user types a URI would flood the wire. We diff the final draft roots
// against what the client currently advertises (both cleaned) and notify
// only when they actually differ, and only for the connected server.
if (
inspectorClient &&
settingsModalTargetId !== undefined &&
settingsModalTargetId === activeServerId &&
settingsDraft
) {
// Normally the draft is what the user just saved, so it is also what is
// on disk. When this server's last write *rejected* it is not: the draft
// still holds the failed edit, and applying it here would contradict disk
// and undo the rollback that reported the failure — so apply what landed
// instead. `EMPTY_SETTINGS`, not the draft, when nothing has ever landed
// for this server: falling back to the draft would re-apply the very
// values that failed (#2089).
const applied = lastPersistedSettings.lastWriteFailed(
settingsModalTargetId,
)
? (lastPersistedSettings.resolve(settingsModalTargetId) ??
EMPTY_SETTINGS)
: settingsDraft;
applyLiveServerSettings(applied);
}
setSettingsModalTargetId(undefined);
}, [
flushSettingsDraft,
inspectorClient,
settingsModalTargetId,
activeServerId,
settingsDraft,
lastPersistedSettings,
applyLiveServerSettings,
]);
// The Resources screen needs `isSubscribed` to flip the Subscribe button
// label to "Unsubscribe". Derive it from the live subscriptions list rather
// than threading it through every setReadResourceState site — that way the
// button reflects state changes from any source (preview panel, subscribed
// tile, or future server-initiated subscribe notifications).
const effectiveReadResourceState = useMemo<
ReadResourceState | undefined
>(() => {
if (!readResourceState) return undefined;
if (!readResourceState.uri) return readResourceState;
const isSubscribed = subscriptions.some(
(s) => s.resource.uri === readResourceState.uri,
);
return { ...readResourceState, isSubscribed };
}, [readResourceState, subscriptions]);
// Surface one pending server-initiated request at a time in the modal,
// sampling-first. Responding (below) removes it from the client's queue,
// which re-renders this with the next request or closes the modal.
const totalPendingRequests =
pendingSamples.length + pendingElicitations.length;
// Derive the head request inside the memo (depending on the source arrays)
// so the memo actually caches — an inline `activeElicitation` would have a
// fresh identity every render and defeat it.
const pendingRequestContent =
useMemo<PendingClientRequestContent | null>(() => {
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],
);
return (
<>
<Box>
{reAuthBanner ? (
<ReAuthBannerBar>
<ReAuthBanner
message={reAuthBanner.message}
title={reAuthBanner.title}
actionLabel={reAuthBanner.actionLabel}
onReauthenticate={onReauthenticateFromBanner}
onDismiss={() => setReAuthBanner(null)}
/>
</ReAuthBannerBar>
) : null}
<InspectorView
deepLink={deepLink}
deepLinkStatus={deepLinkStatus}
servers={servers}
serverListWritable={serverListWritable}
activeServer={activeServerId}
erroredServerId={failedServerId}
connectedServerId={connectedServerId}
version={inspectorVersion}
connectionStatus={connectionStatus}
connectErrorMessage={connectErrorMessage}
initializeResult={initializeResult}
latencyMs={latencyMs}
tools={tools}
excludedTools={excludedTools}
malformedListItems={shownMalformedListItems}
prompts={prompts}
resources={resources}
resourceTemplates={resourceTemplates}
toolsListChanged={toolsListChanged}
promptsListChanged={promptsListChanged}
resourcesListChanged={resourcesListChanged}
toolsLoadError={toolsPagination.error}
promptsLoadError={promptsPagination.error}
resourcesLoadError={
resourcesPagination.error ?? resourceTemplatesLoadError
}
subscriptions={subscriptions}
subscriptionStreamState={subscriptionStreamState}
logs={logs}
tasks={tasks}
progressByTaskId={progressByTaskId}
protocol={protocolEntries}
protocolEra={protocolEra}
network={fetchRequests}
stderrLogs={stderrLogs}
toolCallState={toolCallState}
getPromptState={getPromptState}
readResourceState={effectiveReadResourceState}
toolsUi={ui.toolsUi}
promptsUi={ui.promptsUi}
resourcesUi={ui.resourcesUi}
appsUi={ui.appsUi}
tasksUi={ui.tasksUi}
logsUi={ui.logsUi}
protocolUi={ui.protocolUi}
networkUi={ui.networkUi}
consoleUi={ui.consoleUi}
activeTab={activeTab}
onActiveTabChange={setActiveTab}
currentLogLevel={currentLogLevel}
sandboxPath={sandboxUrl}
bridgeFactory={sandboxBridgeFactory}
appRendererRef={appRendererRef}
onToggleTheme={onToggleTheme}
onOpenClientSettings={() => 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={setUi.setPromptsUi}
onGetPrompt={(name, args) => {
void onGetPrompt(name, args);
}}
onRefreshPrompts={onRefreshPrompts}
onResourcesUiChange={setUi.setResourcesUi}
onReadResource={(uri) => {
void onReadResource(uri);
}}
onSubscribeResource={onSubscribeResource}
onUnsubscribeResource={onUnsubscribeResource}
onRefreshResources={onRefreshResources}
onCompleteArgument={onCompleteArgument}
completionsSupported={capabilities?.completions !== undefined}
subscriptionsSupported={capabilities?.resources?.subscribe === true}
onTasksUiChange={setUi.setTasksUi}
onCancelTask={(taskId) => {
void onCancelTask(taskId);
}}
onClearCompletedTasks={onClearCompletedTasks}
onRefreshTasks={onRefreshTasks}
onSetLogLevel={onSetLogLevel}
modernLogLevel={modernLogLevel}
onSetModernLogLevel={onSetModernLogLevel}
onLogsUiChange={setUi.setLogsUi}
onClearLogs={onClearLogs}
onExportLogs={onExportLogs}
onProtocolUiChange={setUi.setProtocolUi}
onClearProtocol={onClearProtocol}
onExportProtocol={onExportProtocol}
onClearProtocolSection={onClearProtocolSection}
onExportProtocolSection={onExportProtocolSection}
onReplayProtocol={onReplayProtocol}
onTogglePinProtocol={togglePinProtocol}
pinnedProtocolIds={pinnedProtocolIds}
onNetworkUiChange={setUi.setNetworkUi}
onClearNetwork={onClearNetwork}
onExportNetwork={onExportNetwork}
onConsoleUiChange={setUi.setConsoleUi}
onClearConsole={onClearConsole}
onExportConsole={onExportConsole}
onAppsUiChange={setUi.setAppsUi}
onSelectApp={onSelectApp}
onOpenApp={(name, args) => {
void onOpenApp(name, args);
}}
onCloseApp={onCloseApp}
onAppError={onAppError}
onRefreshApps={onRefreshTools}
/>
</Box>
<AppElicitationHost
entries={appElicitations}
sandboxPath={sandboxUrl}
bridgeFactory={elicitationBridgeFactory}
onSettle={handleAppElicitationSettle}
onFail={handleAppElicitationFail}
/>
<ServerConfigModal
opened={configModal !== null}
mode={configModal?.mode ?? "add"}
initialId={configModalTarget?.id}
initialConfig={configModalTarget?.config}
existingIds={existingIds}
onClose={() => setConfigModal(null)}
// Wrapped like the settings persists: this submit carries stdio `env`
// values, so it can perform the pending plaintext-to-encrypted
// upgrade and change the descriptor the footer reports.
onSubmit={refreshingPersist(onConfigSubmit, refreshInitialConfig)}
secretStorage={secretStorage}
/>
<ServerImportConfigModal
opened={importConfigOpen}
existingIds={existingIds}
// Refreshed once per import batch, not per entry. `useImportClientConfig`
// applies every addition and conflict sequentially, and on an encrypted
// file store each `/api/config` authenticates the whole file with a
// scrypt derivation — so wrapping the per-entry callbacks made a
// 20-server import pay 20 serialized KDFs and round trips. Closing is
// the batch boundary: the modal must be dismissed before the settings
// footer that reads this descriptor can be reached, and closing also
// covers a partially-failed batch (#1950 review r26).
onClose={() => {
setImportConfigOpen(false);
refreshInitialConfig();
}}
onFetchSource={importSource}
onAddServer={addServerHighlighted}
onUpdateServer={updateServer}
/>
<ServerImportJsonModal
opened={importJsonOpen}
existingIds={existingIds}
onClose={() => setImportJsonOpen(false)}
onAddServer={refreshingPersist(
addServerHighlighted,
refreshInitialConfig,
)}
/>
<ServerSettingsModal
// Remount per open (and per target server) so the accordion resets to
// its initial "options" section — the body-dropped toast deep-links
// here expecting the Network Log Size control to be visible.
key={settingsModalTargetId ?? "server-settings-closed"}
opened={settingsModalTargetId !== undefined}
settings={settingsModalValue}
serverType={settingsModalServerType}
isStdio={settingsModalIsStdio}
// The negotiated era only applies when this settings modal targets the
// live-connected server; otherwise the server isn't connected and the
// era is unknown (#1629). Lets the form hide the modern log-level control
// once an `auto` server resolves to legacy.
negotiatedEra={
connectionStatus === "connected" &&
settingsModalTargetId === activeServerId
? protocolEra
: undefined
}
onClose={onSettingsModalClose}
onSettingsChange={onSettingsChange}
onClearStoredOAuth={
settingsModalIsStdio ? undefined : handleClearStoredOAuthFromSettings
}
secretStorage={secretStorage}
/>
<ClientSettingsModal
key={
clientSettingsOpen ? "client-settings-open" : "client-settings-closed"
}
opened={clientSettingsOpen}
settings={clientSettingsModalValue}
onClose={onClientSettingsModalClose}
onSettingsChange={onClientSettingsChange}
emaIdpLoginState={emaIdpLoginState}
onEmaIdpLogout={logoutEmaIdp}
secretStorage={secretStorage}
/>
{initializeResult && activeServer && (
<ConnectionInfoModal
opened={connectionInfoModalOpen}
onClose={() => setConnectionInfoModalOpen(false)}
initializeResult={initializeResult}
serverInfoReported={serverInfoReported}
clientCapabilities={clientCapabilities}
transport={connectionInfoTransport}
protocolEra={protocolEra}
discoverResult={discoverResult}
oauth={connectionInfoOAuth}
onClearOAuth={
connectionInfoCanClearOAuth ? handleClearConnectionOAuth : undefined
}
/>
)}
<ServerRemoveConfirmModal
opened={removeTarget !== null}
target={removeTarget}
onCancel={() => setRemoveTarget(null)}
onConfirm={onConfirmRemove}
/>
<OutputValidationModal
opened={outputValidationDetails !== null}
toolName={outputValidationDetails?.toolName}
message={outputValidationDetails?.message}
onClose={() => setOutputValidationDetails(null)}
/>
<UrlElicitationErrorModal
opened={urlElicitationErrorDetails !== null}
toolName={urlElicitationErrorDetails?.toolName}
details={urlElicitationErrorDetails?.details}
onClose={() => setUrlElicitationErrorDetails(null)}
/>
<PendingClientRequestModal
request={pendingRequestContent}
serverName={activeServer?.name ?? "this server"}
queuePosition={queueLabel}
onSamplingRespond={onSamplingRespond}
onSamplingReject={onSamplingReject}
onElicitationRespond={onElicitationRespond}
/>
<StepUpAuthModal
opened={
pendingStepUp !== null && pendingStepUp.serverId === activeServerId
}
challenge={pendingStepUp?.challenge ?? null}
authorizationScopes={pendingStepUp?.challenge?.authorizationScopes}
enterpriseManaged={pendingStepUp?.enterpriseManaged}
onAuthorize={handleStepUpAuthorize}
onCancel={handleStepUpCancel}
/>
</>
);
}
export default App;