inspector/clients/web/src/App.tsx
cliffhall 8a6bec02a7 docs: correct the toggle rollback comment for the per-server override (#2095)
The rollback's explanation still described the removed app-wide boolean and
claimed the override was applied only for the active server. That is now the
live client's rule alone: the override is keyed by this write's server and is
only ever displayed while that server is active, so it is recorded whatever
is active by the time the rejection arrives.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0177mPHAdECD18nLLCTwR5rh
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-25 20:26:53 -04:00

5303 lines
211 KiB
TypeScript

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