Two findings, both valid. `onCallTool`, `onGetPrompt`, `onReadResource` and `onOpenApp` await `handleCommandScopedAuthRecovery` from inside their catch blocks, and a rejection thrown from a catch is not caught by that same catch — so it escapes the handler. That helper awaits `checkAuthChallengeSatisfied` and `pushRemoteAuthState`, both of which reach the backend and can reject, so the bare `void` on those four could still leak. Rather than patch the four and keep a comment enumerating which handlers are safe, all seven wrappers now terminate the same way: a reporting `.catch` via `reportDispatchFailure`. Whether a handler "owns its failures" turned out not to be reliably knowable by reading — the previous two attempts at that judgement were both wrong — so the code no longer depends on getting it right. A handler that does surface its own failure resolves and never reaches the catch, so nothing double-toasts. The reorder callback lifted out of the JSX had no App-level coverage. The `InspectorView` double grows a reorder control, with tests for both forwarding the ordered ids and reporting a rejected reorder. Adds the auth-recovery escape test the review asked for. All four new rejection tests were confirmed to fail against the bare-`void` code, so they pin the fixes rather than merely passing beside them. Signed-off-by: cliffhall <cliff@futurescale.com>
1970 lines
77 KiB
TypeScript
1970 lines
77 KiB
TypeScript
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
import { Box } 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 type { TypedEventGeneric } from "@inspector/core/mcp/typedEventTarget.js";
|
|
import type {
|
|
InspectorServerSettings,
|
|
MCPServerConfig,
|
|
ServerEntry,
|
|
ServerType,
|
|
} from "@inspector/core/mcp/types.js";
|
|
import { 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 { useResultPanels, useServerCommands } from "./hooks/useServerCommands";
|
|
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 {
|
|
AppsPanelProps,
|
|
ConnectionProps,
|
|
ConsolePanelProps,
|
|
LogsPanelProps,
|
|
NetworkPanelProps,
|
|
PromptsPanelProps,
|
|
ProtocolPanelProps,
|
|
ResourcesPanelProps,
|
|
ServerListProps,
|
|
ShellProps,
|
|
TasksPanelProps,
|
|
ToolsPanelProps,
|
|
} from "./components/views/InspectorView/types";
|
|
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 { EMPTY_SETTINGS } from "./utils/serverSettingsDefaults";
|
|
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 { ReAuthBannerBar } from "./components/groups/ReAuthBanner/ReAuthBannerBar";
|
|
|
|
/**
|
|
* Terminates a dispatched handler's promise by reporting the failure, for the
|
|
* `InspectorView` action wrappers below. Module scope because it closes over
|
|
* nothing — the title is the only thing that varies per action.
|
|
*/
|
|
function reportDispatchFailure(title: string) {
|
|
return (err: unknown) => {
|
|
notifications.show({
|
|
title,
|
|
message: err instanceof Error ? err.message : String(err),
|
|
color: "red",
|
|
});
|
|
};
|
|
}
|
|
|
|
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,
|
|
);
|
|
|
|
// The three in-flight result panels, plus the `clearResultPanels` /
|
|
// `setSourceScopedError` writers `useOAuthRecovery` needs. Owned by the
|
|
// commands hook's file but called here, ahead of the OAuth hook that reads
|
|
// those two (#2155).
|
|
const panels = useResultPanels();
|
|
const {
|
|
toolCallState,
|
|
getPromptState,
|
|
readResourceState,
|
|
clearResultPanels,
|
|
setSourceScopedError,
|
|
} = panels;
|
|
|
|
// 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 });
|
|
|
|
/**
|
|
* 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. Every one
|
|
// of them routes through the command-scoped auth recovery above, which is
|
|
// why they sit after it (#2155). ---
|
|
const {
|
|
onCallTool,
|
|
onClearToolResult,
|
|
onToolsUiChange,
|
|
onGetPrompt,
|
|
onReadResource,
|
|
onReadResourceContents,
|
|
onSubscribeResource,
|
|
onUnsubscribeResource,
|
|
onCompleteArgument,
|
|
onCancelTask,
|
|
onCancelToolCall,
|
|
onClearCompletedTasks,
|
|
onSetLogLevel,
|
|
onSetModernLogLevel,
|
|
onRefreshTools,
|
|
onRefreshPrompts,
|
|
onRefreshResources,
|
|
onRefreshTasks,
|
|
onTogglePaginatedLists,
|
|
onLoadMoreTools,
|
|
onLoadMorePrompts,
|
|
onLoadMoreResources,
|
|
} = useServerCommands({
|
|
sessionRef,
|
|
servers,
|
|
activeServerId,
|
|
activeServer,
|
|
inspectorClient,
|
|
connected,
|
|
capabilities,
|
|
tools,
|
|
panels,
|
|
selectedToolKey: ui.toolsUi.selectedToolKey,
|
|
setToolsUi: setUi.setToolsUi,
|
|
setUrlElicitationErrorDetails,
|
|
setCurrentLogLevel,
|
|
setModernLogLevel,
|
|
activeToolCallTaskIdRef,
|
|
clearCompletedTasks,
|
|
refreshTasks,
|
|
paginatedLists,
|
|
paginatedListsOverride,
|
|
toolsPagination,
|
|
promptsPagination,
|
|
resourcesPagination,
|
|
refreshTools,
|
|
refreshPrompts,
|
|
refreshResources,
|
|
refreshResourceTemplates,
|
|
clearToolsListChanged,
|
|
clearPromptsListChanged,
|
|
clearResourcesListChanged,
|
|
loadToolsPage,
|
|
loadPromptsPage,
|
|
loadResourcesPage,
|
|
lastPersistedSettings,
|
|
applyLiveServerSettings,
|
|
updateServerSettings,
|
|
refreshInitialConfig,
|
|
handleCommandScopedAuthRecovery,
|
|
runWithCommandAuthRecovery,
|
|
runCommandInBackground,
|
|
});
|
|
|
|
// --- 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 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,
|
|
};
|
|
|
|
// 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],
|
|
);
|
|
|
|
// --- InspectorView prop bundles (#2130) -----------------------------------
|
|
//
|
|
// The view takes one prop per domain, so the JSX below reads as a component
|
|
// tree rather than a wall of ~130 props. Everything the bundles need is
|
|
// assembled here: the handlers that used to be multi-line closures declared
|
|
// inline in the JSX are named callbacks, and the `void`-discarding wrappers
|
|
// carry their justification once rather than at a call site.
|
|
|
|
const onServerAdd = useCallback(() => {
|
|
setHighlightedServerIds([]);
|
|
setConfigModal({ mode: "add" });
|
|
}, []);
|
|
|
|
const onServerImportConfig = useCallback(() => {
|
|
setHighlightedServerIds([]);
|
|
setImportConfigOpen(true);
|
|
}, []);
|
|
|
|
const onServerImportJson = useCallback(() => {
|
|
setHighlightedServerIds([]);
|
|
setImportJsonOpen(true);
|
|
}, []);
|
|
|
|
const onServerEdit = useCallback((id: string) => {
|
|
setConfigModal({ mode: "edit", targetId: id });
|
|
}, []);
|
|
|
|
const onServerClone = useCallback((id: string) => {
|
|
setHighlightedServerIds([]);
|
|
setConfigModal({ mode: "clone", targetId: id });
|
|
}, []);
|
|
|
|
const onServerRemove = useCallback(
|
|
(id: string) => {
|
|
const target = servers.find((s) => s.id === id);
|
|
if (target) setRemoveTarget(target);
|
|
},
|
|
[servers],
|
|
);
|
|
|
|
const onServerReorderFromList = useCallback(
|
|
(orderedIds: string[]) => {
|
|
// 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",
|
|
});
|
|
});
|
|
},
|
|
[reorderServers],
|
|
);
|
|
|
|
const openClientSettings = useCallback(() => setClientSettingsOpen(true), []);
|
|
const openConnectionInfo = useCallback(
|
|
() => setConnectionInfoModalOpen(true),
|
|
[],
|
|
);
|
|
const openServerSettings = useCallback(
|
|
(id: string) => setSettingsModalTargetId(id),
|
|
[],
|
|
);
|
|
|
|
// Every `InspectorView` action prop is synchronous while the handler behind
|
|
// it is async, so each wrapper below has to terminate its promise somehow.
|
|
// They all do it the same way: a reporting `.catch`, never a bare `void`.
|
|
//
|
|
// The uniformity is the point. Deciding per handler whether it "owns its
|
|
// failures" is a judgement this PR got wrong twice (#2130 review) — the
|
|
// handlers really do catch their ordinary errors, but a rejection can still
|
|
// escape by a route the reading misses:
|
|
//
|
|
// - `onToggleConnection` awaits `initialConfigSettledRef` and constructs
|
|
// the client via `setupClientForServer` outside its own try/catch, and
|
|
// its already-connected branch is a `try/finally` that lets a failed
|
|
// transport close propagate. `onDisconnect` is that `try/finally` alone.
|
|
// - `onCallTool` / `onGetPrompt` / `onReadResource` / `onOpenApp` await
|
|
// `handleCommandScopedAuthRecovery` from *inside* their catch blocks, and
|
|
// a rejection thrown from a catch is not caught by that same catch. That
|
|
// helper awaits `checkAuthChallengeSatisfied` and `pushRemoteAuthState`,
|
|
// both of which reach the backend and can reject.
|
|
//
|
|
// A handler that does surface its own failure resolves, so it never reaches
|
|
// this `.catch` and there is no double-toast; what lands here is only the
|
|
// escape, which would otherwise be a global unhandled rejection with nothing
|
|
// shown to the user.
|
|
const dispatchToggleConnection = useCallback(
|
|
(id: string) => {
|
|
onToggleConnection(id).catch(
|
|
reportDispatchFailure("Failed to change the connection"),
|
|
);
|
|
},
|
|
[onToggleConnection],
|
|
);
|
|
const dispatchDisconnect = useCallback(() => {
|
|
onDisconnect().catch(reportDispatchFailure("Failed to disconnect"));
|
|
}, [onDisconnect]);
|
|
const dispatchCallTool = useCallback(
|
|
(name: string, args: Record<string, unknown>, runAsTask?: boolean) => {
|
|
onCallTool(name, args, runAsTask).catch(
|
|
reportDispatchFailure("Tool call failed"),
|
|
);
|
|
},
|
|
[onCallTool],
|
|
);
|
|
const dispatchGetPrompt = useCallback(
|
|
(name: string, args: Record<string, string>) => {
|
|
onGetPrompt(name, args).catch(
|
|
reportDispatchFailure("Failed to get prompt"),
|
|
);
|
|
},
|
|
[onGetPrompt],
|
|
);
|
|
const dispatchReadResource = useCallback(
|
|
(uri: string) => {
|
|
onReadResource(uri).catch(
|
|
reportDispatchFailure("Failed to read resource"),
|
|
);
|
|
},
|
|
[onReadResource],
|
|
);
|
|
const dispatchCancelTask = useCallback(
|
|
(taskId: string) => {
|
|
onCancelTask(taskId).catch(
|
|
reportDispatchFailure("Failed to cancel task"),
|
|
);
|
|
},
|
|
[onCancelTask],
|
|
);
|
|
const dispatchOpenApp = useCallback(
|
|
(name: string, args: Record<string, unknown>) => {
|
|
onOpenApp(name, args).catch(reportDispatchFailure("Failed to open app"));
|
|
},
|
|
[onOpenApp],
|
|
);
|
|
|
|
const shellProps: ShellProps = {
|
|
deepLink,
|
|
deepLinkStatus,
|
|
version: inspectorVersion,
|
|
malformedListItems: shownMalformedListItems,
|
|
activeTab,
|
|
onActiveTabChange: setActiveTab,
|
|
onToggleTheme,
|
|
onOpenClientSettings: openClientSettings,
|
|
};
|
|
|
|
const connectionProps: ConnectionProps = {
|
|
activeServer: activeServerId,
|
|
erroredServerId: failedServerId,
|
|
connectedServerId,
|
|
connectionStatus,
|
|
connectErrorMessage,
|
|
initializeResult,
|
|
latencyMs,
|
|
protocolEra,
|
|
onCompleteArgument,
|
|
completionsSupported: capabilities?.completions !== undefined,
|
|
onToggleConnection: dispatchToggleConnection,
|
|
onDisconnect: dispatchDisconnect,
|
|
};
|
|
|
|
const serverListProps: ServerListProps = {
|
|
servers,
|
|
serverListWritable,
|
|
highlightedServerIds,
|
|
onClearHighlight: clearHighlight,
|
|
onServerAdd,
|
|
onServerImportConfig,
|
|
onServerImportJson,
|
|
onServerExport,
|
|
onConnectionInfo: openConnectionInfo,
|
|
onServerSettings: openServerSettings,
|
|
onServerEdit,
|
|
onServerClone,
|
|
onServerRemove,
|
|
onServerReorder: onServerReorderFromList,
|
|
};
|
|
|
|
const toolsPanelProps: ToolsPanelProps = {
|
|
tools,
|
|
excludedTools,
|
|
toolsListChanged,
|
|
toolsLoadError: toolsPagination.error,
|
|
toolsUi: ui.toolsUi,
|
|
toolCallState,
|
|
toolsPagination: toolsPaginationControls,
|
|
serverSupportsTaskToolCalls:
|
|
!!capabilities?.tasks?.requests?.tools?.call ||
|
|
(inspectorClient?.isTasksExtensionNegotiated() ?? false),
|
|
onToolsUiChange,
|
|
onCallTool: dispatchCallTool,
|
|
onCancelToolCall,
|
|
onClearToolResult,
|
|
onRefreshTools,
|
|
onReadResourceContents,
|
|
};
|
|
|
|
const promptsPanelProps: PromptsPanelProps = {
|
|
prompts,
|
|
promptsListChanged,
|
|
promptsLoadError: promptsPagination.error,
|
|
promptsUi: ui.promptsUi,
|
|
getPromptState,
|
|
promptsPagination: promptsPaginationControls,
|
|
onPromptsUiChange: setUi.setPromptsUi,
|
|
onGetPrompt: dispatchGetPrompt,
|
|
onRefreshPrompts,
|
|
};
|
|
|
|
const resourcesPanelProps: ResourcesPanelProps = {
|
|
resources,
|
|
resourceTemplates,
|
|
subscriptions,
|
|
subscriptionStreamState,
|
|
subscriptionsSupported: capabilities?.resources?.subscribe === true,
|
|
resourcesListChanged,
|
|
resourcesLoadError: resourcesPagination.error ?? resourceTemplatesLoadError,
|
|
resourcesUi: ui.resourcesUi,
|
|
readResourceState: effectiveReadResourceState,
|
|
resourcesPagination: resourcesPaginationControls,
|
|
onResourcesUiChange: setUi.setResourcesUi,
|
|
onReadResource: dispatchReadResource,
|
|
onSubscribeResource,
|
|
onUnsubscribeResource,
|
|
onRefreshResources,
|
|
};
|
|
|
|
const appsPanelProps: AppsPanelProps = {
|
|
appsUi: ui.appsUi,
|
|
sandboxPath: sandboxUrl,
|
|
bridgeFactory: sandboxBridgeFactory,
|
|
appRendererRef,
|
|
onAppsUiChange: setUi.setAppsUi,
|
|
onSelectApp,
|
|
onOpenApp: dispatchOpenApp,
|
|
onCloseApp,
|
|
onAppError,
|
|
// The Apps tab is a filtered view of the tools list, so refreshing it is
|
|
// refreshing tools.
|
|
onRefreshApps: onRefreshTools,
|
|
};
|
|
|
|
const tasksPanelProps: TasksPanelProps = {
|
|
tasks,
|
|
progressByTaskId,
|
|
tasksUi: ui.tasksUi,
|
|
onTasksUiChange: setUi.setTasksUi,
|
|
onCancelTask: dispatchCancelTask,
|
|
onClearCompletedTasks,
|
|
onRefreshTasks,
|
|
};
|
|
|
|
const logsPanelProps: LogsPanelProps = {
|
|
logs,
|
|
logsUi: ui.logsUi,
|
|
currentLogLevel,
|
|
modernLogLevel,
|
|
onSetLogLevel,
|
|
onSetModernLogLevel,
|
|
onLogsUiChange: setUi.setLogsUi,
|
|
onClearLogs,
|
|
onExportLogs,
|
|
};
|
|
|
|
const protocolPanelProps: ProtocolPanelProps = {
|
|
protocol: protocolEntries,
|
|
protocolUi: ui.protocolUi,
|
|
pinnedProtocolIds,
|
|
onProtocolUiChange: setUi.setProtocolUi,
|
|
onClearProtocol,
|
|
onExportProtocol,
|
|
onClearProtocolSection,
|
|
onExportProtocolSection,
|
|
onReplayProtocol,
|
|
onTogglePinProtocol: togglePinProtocol,
|
|
};
|
|
|
|
const networkPanelProps: NetworkPanelProps = {
|
|
network: fetchRequests,
|
|
networkUi: ui.networkUi,
|
|
onNetworkUiChange: setUi.setNetworkUi,
|
|
onClearNetwork,
|
|
onExportNetwork,
|
|
};
|
|
|
|
const consolePanelProps: ConsolePanelProps = {
|
|
stderrLogs,
|
|
consoleUi: ui.consoleUi,
|
|
onConsoleUiChange: setUi.setConsoleUi,
|
|
onClearConsole,
|
|
onExportConsole,
|
|
};
|
|
|
|
return (
|
|
<>
|
|
<Box>
|
|
{reAuthBanner ? (
|
|
<ReAuthBannerBar>
|
|
<ReAuthBanner
|
|
message={reAuthBanner.message}
|
|
title={reAuthBanner.title}
|
|
actionLabel={reAuthBanner.actionLabel}
|
|
onReauthenticate={onReauthenticateFromBanner}
|
|
onDismiss={() => setReAuthBanner(null)}
|
|
/>
|
|
</ReAuthBannerBar>
|
|
) : null}
|
|
<InspectorView
|
|
shell={shellProps}
|
|
connection={connectionProps}
|
|
servers={serverListProps}
|
|
tools={toolsPanelProps}
|
|
prompts={promptsPanelProps}
|
|
resources={resourcesPanelProps}
|
|
apps={appsPanelProps}
|
|
tasks={tasksPanelProps}
|
|
logs={logsPanelProps}
|
|
protocol={protocolPanelProps}
|
|
network={networkPanelProps}
|
|
console={consolePanelProps}
|
|
/>
|
|
</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;
|