Copilot review r2.
`onConfigSubmit` moved `activeServerId` to the renamed id only after
`updateServer` resolved. Once a failed list reload rejects, the rename is
still on disk — so the active id stayed on the old one and the next
successful refresh left it pointing at a row that no longer exists. Apply
the rename from the catch when the rejection is a `ServerListReloadError`,
then rethrow so the modal still shows the error. `addServerHighlighted`
gets the same treatment: the row is on disk, so it belongs in the
highlight batch the next successful refresh renders.
`activeServerId` is not rendered anywhere, so the test reads it back
through the one handler that hands it to a spy — the pagination toggle's
`updateServerSettings(activeServerId, …)` — after letting the renamed row
land as a later refresh would. It fails without the catch-side call. The
new describe installs its own `useServers` return value and restores the
previous implementation afterwards, since earlier describes' overrides
outlive them (which is why the first draft's spy was never called).
Also replaced the `as unknown as` in the new test with a single
`EventTarget & {…}` intersection, per the TypeScript rules. The other 19
in this file are the same pattern and are left for a separate sweep.
The third comment — that the pagination toggle's rollback baseline goes
stale after a failed list read — is a real defect but pre-existing and
unchanged by this PR; the end state is identical before and after it.
Filed as #2089 rather than widening this fix.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UL9retfvAXRgvi6EWk4SY4
Signed-off-by: cliffhall <cliff@futurescale.com>
4907 lines
189 KiB
TypeScript
4907 lines
189 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,
|
|
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 {
|
|
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 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 { 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",
|
|
});
|
|
|
|
// Sticky re-auth banner bar. A `Paper` so every static style is a prop: `shadow`
|
|
// emits `var(--mantine-shadow-sm)` (identical to the old CSS), and the stacking
|
|
// order goes through `styles.root` since Mantine has no `z` prop.
|
|
const ReAuthBannerBar = Paper.withProps({
|
|
px: "md",
|
|
pt: "xs",
|
|
pos: "sticky",
|
|
top: 60,
|
|
bg: "var(--mantine-color-body)",
|
|
shadow: "sm",
|
|
styles: { root: { zIndex: 200 } },
|
|
// Paper's default `radius: "md"` would round this full-bleed sticky bar's
|
|
// corners; the bar it replaced (a Box) had none.
|
|
radius: 0,
|
|
});
|
|
|
|
// Body of the "response body dropped" warning toast: a one-line summary of what
|
|
// happened, the likely causes, and a link that opens this server's settings
|
|
// (on the Options section) so the user can raise the Network Log Size if it's
|
|
// just a high-traffic server. Surfaces the otherwise-invisible rotation drop
|
|
// described in #1390.
|
|
const FetchBodyDroppedToastMessage = ({
|
|
maxFetchRequests,
|
|
onAdjust,
|
|
}: {
|
|
maxFetchRequests: number;
|
|
onAdjust: () => void;
|
|
}) => (
|
|
<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'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'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's structuredContent doesn't match the
|
|
tool'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'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]);
|
|
|
|
const sandboxBridgeFactory = useMemo(
|
|
() =>
|
|
createAppBridgeFactory({
|
|
getClient: () => inspectorClient?.getAppRendererClient() ?? null,
|
|
readResource: async (uri) => {
|
|
if (!inspectorClient) throw new Error("No MCP client connected.");
|
|
const invocation = await inspectorClient.readResource(uri);
|
|
return invocation.result;
|
|
},
|
|
// The bridge's sandboxready handler reads + posts the UI resource
|
|
// inside a detached async block; without this hook a 404 / malformed
|
|
// resource is console.error-only and the user stares at a blank
|
|
// frame. Surface it as a toast. The renderer separately drives
|
|
// `data-app-status` so an automated driver can time out on
|
|
// never-reaching-"ready" and read the toast.
|
|
onResourceError: (err) => {
|
|
notifications.show({
|
|
title: "App resource failed to load",
|
|
message: err.message,
|
|
color: "red",
|
|
});
|
|
},
|
|
}),
|
|
[inspectorClient],
|
|
);
|
|
|
|
// 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,
|
|
getClient: () => inspectorClient?.getAppRendererClient() ?? null,
|
|
readResource: async (uri) => {
|
|
if (!inspectorClient) throw new Error("No MCP client connected.");
|
|
const invocation = await inspectorClient.readResource(uri);
|
|
return invocation.result;
|
|
},
|
|
// 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],
|
|
);
|
|
/**
|
|
* 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);
|
|
// The active server's persisted paginated setting drives the display mode.
|
|
// The sidebar toggle edits it (optimistically, below) and persists it.
|
|
const persistedPaginatedLists =
|
|
servers.find((s) => s.id === activeServerId)?.settings?.paginatedLists ??
|
|
false;
|
|
const [paginatedListsOverride, setPaginatedListsOverride] = useState<
|
|
boolean | null
|
|
>(null);
|
|
// Drop the optimistic override once the persisted value catches up (or the
|
|
// active server changes), so the persisted setting is the resting source.
|
|
useEffect(() => {
|
|
setPaginatedListsOverride(null);
|
|
}, [persistedPaginatedLists, activeServerId]);
|
|
const paginatedLists = paginatedListsOverride ?? persistedPaginatedLists;
|
|
// 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;
|
|
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);
|
|
useEffect(() => {
|
|
if (activeServer) activeServerNameRef.current = activeServer.name;
|
|
activeServerIdRef.current = activeServerId;
|
|
serversRef.current = servers;
|
|
}, [activeServer, activeServerId, servers]);
|
|
|
|
// Last connection-level error message, surfaced as `data-error-message` on
|
|
// the InspectorView header so an automated driver can read *why* a connect
|
|
// failed without scraping a transient toast. Cleared on the next connect
|
|
// attempt and on successful connection.
|
|
const [connectErrorMessage, setConnectErrorMessage] = useState<
|
|
string | undefined
|
|
>(undefined);
|
|
// Named writer so a single call site can be extended later (e.g. telemetry)
|
|
// and the intent (record a connection-level failure) stays explicit.
|
|
const recordConnectError = useCallback((message: string) => {
|
|
setConnectErrorMessage(message);
|
|
}, []);
|
|
|
|
// Deep-link parameters parsed once from the initial URL. Security gating
|
|
// (auth-token match, http(s)-only serverUrl) happens inside `parseDeepLink`,
|
|
// so a `DeepLink` value here is already validated. The parse status is
|
|
// surfaced as `data-deeplink` so an automated driver can tell "no deep link"
|
|
// from "deep link present but rejected" — both leave `data-status` idle.
|
|
const [deepLink, deepLinkStatus] = useMemo<
|
|
[DeepLink | undefined, DeepLinkParseStatus]
|
|
>(() => {
|
|
/* v8 ignore next -- SSR guard: happy-dom always defines window in tests */
|
|
if (typeof window === "undefined") return [undefined, "none"];
|
|
const search = window.location.search;
|
|
const parsed = parseDeepLink(search, getAuthToken());
|
|
return [parsed, deepLinkParseStatus(search, parsed)];
|
|
}, []);
|
|
const deepLinkEnsureRef = useRef(false);
|
|
const deepLinkUpdateRef = useRef(false);
|
|
const deepLinkConnectRef = useRef(false);
|
|
|
|
const showReAuthBanner = useCallback(
|
|
(
|
|
serverId: string,
|
|
detail?: unknown,
|
|
options?: { reason?: AuthChallengeReason },
|
|
) => {
|
|
const server = serversRef.current.find((s) => s.id === serverId);
|
|
const message = reAuthBannerMessage({
|
|
serverName: server?.name,
|
|
detail:
|
|
detail !== undefined ? formatOAuthFailureDetail(detail) : undefined,
|
|
});
|
|
const reason = options?.reason;
|
|
if (reason !== undefined && !isReAuthBannerReason(reason)) {
|
|
notifications.show({
|
|
title: "Authorization required",
|
|
message,
|
|
color: "yellow",
|
|
autoClose: false,
|
|
});
|
|
return;
|
|
}
|
|
setReAuthBanner({
|
|
serverId,
|
|
message,
|
|
});
|
|
},
|
|
[],
|
|
);
|
|
|
|
// Surface a mid-session transport failure (stdio crash, SSE drop, HTTP 5xx)
|
|
// as a toast. The handshake case is handled in `onToggleConnection`'s catch;
|
|
// this covers the `status: connected → error` transition that fires the
|
|
// client's `error` event without rejecting any awaited promise (#1323).
|
|
// `lastError` clears at the next connecting edge, so each failure toasts once.
|
|
useEffect(() => {
|
|
if (!lastError) return;
|
|
const name = activeServerNameRef.current;
|
|
notifications.show({
|
|
title: name ? `Connection to "${name}" lost` : "Connection lost",
|
|
message: lastError,
|
|
color: "red",
|
|
});
|
|
}, [lastError]);
|
|
|
|
// `config.type` is optional in the schema (a bare `command: ...`
|
|
// entry implies stdio), so we materialize the default here rather
|
|
// than at the render site — the modal's `transport` prop is a
|
|
// required `ServerType`, and we only render the modal once we know
|
|
// there's an active server (see the `{initializeResult && activeServer && …}`
|
|
// guard below).
|
|
const connectionInfoTransport: ServerType =
|
|
activeServer?.config.type ?? "stdio";
|
|
|
|
const [
|
|
connectionInfoOAuthWhenConnected,
|
|
setConnectionInfoOAuthWhenConnected,
|
|
] = useState<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);
|
|
|
|
// 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.
|
|
const savedSettings = server.settings;
|
|
const activeIdp = getActiveEnterpriseManagedAuthIdp(clientConfig);
|
|
const activeCimdUrl = getActiveCimdClientMetadataUrl(clientConfig);
|
|
// Flatten the persisted settings into the InspectorClient options shape.
|
|
// Empty / zero values stay unset so the SDK defaults apply.
|
|
const defaultMetadata = savedSettings?.metadata
|
|
? Object.fromEntries(
|
|
savedSettings.metadata
|
|
.filter((m) => m.key.trim() !== "")
|
|
.map((m) => [m.key, m.value]),
|
|
)
|
|
: undefined;
|
|
const 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.oauthClientId && {
|
|
clientId: savedSettings.oauthClientId,
|
|
}),
|
|
...(savedSettings.oauthClientSecret && {
|
|
clientSecret: savedSettings.oauthClientSecret,
|
|
}),
|
|
...(savedSettings.oauthScopes && {
|
|
scope: savedSettings.oauthScopes,
|
|
}),
|
|
...(serverAuthorizationParams && {
|
|
authorizationParams: serverAuthorizationParams,
|
|
}),
|
|
...serverEndpointOverrides,
|
|
...(savedSettings.enterpriseManaged && {
|
|
enterpriseManaged: true,
|
|
}),
|
|
}
|
|
: undefined;
|
|
const oauth =
|
|
oauthFromServer || activeCimdUrl
|
|
? {
|
|
...(oauthFromServer ?? {}),
|
|
...(activeCimdUrl && { clientMetadataUrl: activeCimdUrl }),
|
|
}
|
|
: undefined;
|
|
const client = new InspectorClient(server.config, {
|
|
environment,
|
|
// The Tasks tab needs the receiver-task pipeline; the
|
|
// requestor-task list comes from the client's task store.
|
|
receiverTasks: true,
|
|
// Sampling / elicitation are on by default; keep the parameterized
|
|
// options off until the UI grows the surface to render them.
|
|
elicit: { form: true, url: true },
|
|
// 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,
|
|
],
|
|
);
|
|
|
|
// 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) {
|
|
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;
|
|
queueMicrotask(() => {
|
|
showReAuthBanner(server.id, err instanceof Error ? err : String(err));
|
|
});
|
|
return;
|
|
}
|
|
const client = setupClientForServer(server, sessionId);
|
|
setActiveServerId(server.id);
|
|
try {
|
|
connectStartRef.current = Date.now();
|
|
await client.resumeAfterOAuth(params.code, {
|
|
remoteSessionId: resumeSnapshot?.remoteSessionId,
|
|
iss: params.iss,
|
|
});
|
|
} catch (err) {
|
|
connectStartRef.current = undefined;
|
|
if (isEmaClientNotConfiguredError(err)) {
|
|
notifications.show({
|
|
title: `Cannot connect to "${server.name}"`,
|
|
message: err.message,
|
|
color: "red",
|
|
autoClose: false,
|
|
});
|
|
return;
|
|
}
|
|
// 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) {
|
|
if (await client.checkAuthChallengeSatisfied(err.authChallenge)) {
|
|
connectStartRef.current = Date.now();
|
|
await client.connect();
|
|
return;
|
|
}
|
|
prepareOAuthRedirect({
|
|
serverId: id,
|
|
authKind: "reauth",
|
|
authorizationUrl: err.authorizationUrl,
|
|
preRedirectContext: "connect",
|
|
client,
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (isUnauthorizedError(err)) {
|
|
try {
|
|
const authUrl = await client.authenticate();
|
|
if (authUrl === undefined) {
|
|
connectStartRef.current = Date.now();
|
|
await client.connect();
|
|
} else {
|
|
prepareOAuthRedirect({
|
|
serverId: id,
|
|
authKind: "reauth",
|
|
authorizationUrl: authUrl,
|
|
preRedirectContext: "connect",
|
|
client,
|
|
});
|
|
}
|
|
return;
|
|
} catch (authErr) {
|
|
clearOAuthResumeSnapshot();
|
|
await client.disconnect().catch(() => {});
|
|
if (isEmaClientNotConfiguredError(authErr)) {
|
|
notifications.show({
|
|
title: `Cannot connect to "${target.name}"`,
|
|
message: authErr.message,
|
|
color: "red",
|
|
autoClose: false,
|
|
});
|
|
return;
|
|
}
|
|
const message =
|
|
authErr instanceof Error ? authErr.message : String(authErr);
|
|
setConnectErrorMessage(message);
|
|
notifications.show({
|
|
title: `OAuth authorization failed for "${target.name}"`,
|
|
message,
|
|
color: "red",
|
|
});
|
|
return;
|
|
}
|
|
}
|
|
|
|
// Non-auth handshake error: toast so the user sees what went wrong
|
|
// instead of the ConnectionToggle silently reverting to
|
|
// "disconnected", and flag the card with a red border (#1621).
|
|
setFailedServerId(id);
|
|
const message = err instanceof Error ? err.message : String(err);
|
|
setConnectErrorMessage(message);
|
|
notifications.show({
|
|
title: `Failed to connect to "${target.name}"`,
|
|
message,
|
|
color: "red",
|
|
});
|
|
}
|
|
},
|
|
[
|
|
activeServerId,
|
|
connectionStatus,
|
|
inspectorClient,
|
|
setupClientForServer,
|
|
prepareOAuthRedirect,
|
|
finalizeExplicitDisconnect,
|
|
],
|
|
);
|
|
|
|
const onDisconnect = useCallback(async () => {
|
|
if (!inspectorClient) return;
|
|
try {
|
|
await inspectorClient.disconnect();
|
|
} finally {
|
|
finalizeExplicitDisconnect();
|
|
}
|
|
}, [inspectorClient, finalizeExplicitDisconnect]);
|
|
|
|
// Deep-link auto-connect (the URL-driven case of #1183). `useServers`
|
|
// hydrates asynchronously (initial `servers` is `[]`), so this effect runs in
|
|
// discrete phases keyed on what `servers` currently reflects, one per render:
|
|
// 1. ensure — no row yet: one-shot `addServer`.
|
|
// 2. update — row present but its persisted config differs from the deep
|
|
// link (a stale transport/url from an earlier load under the stable
|
|
// `deep-link` id): `updateServer`, then return so the effect re-runs.
|
|
// 3. connect — row present AND its config already matches: connect.
|
|
// Splitting update and connect across renders (rather than awaiting both in
|
|
// one closure) is what makes the connect correct: `onToggleConnection` reads
|
|
// the target from `serversRef`, which an earlier passive effect syncs from
|
|
// `servers` — so connecting only once `servers` reflects the updated config
|
|
// guarantees the client is built from the fresh transport, not the stale one.
|
|
// The OAuth callback path takes precedence; a deep link on `/oauth/callback`
|
|
// would be a misconfiguration, and the callback handler clears the URL.
|
|
useEffect(() => {
|
|
if (!deepLink) return;
|
|
if (window.location.pathname === OAUTH_CALLBACK_PATH) return;
|
|
|
|
const existing = servers.find((s) => s.id === deepLink.serverId);
|
|
if (!existing) {
|
|
if (deepLinkEnsureRef.current) return;
|
|
deepLinkEnsureRef.current = true;
|
|
void addServer(deepLink.serverId, deepLink.serverConfig).catch((err) => {
|
|
const message = err instanceof Error ? err.message : String(err);
|
|
// A 409 ("already exists") means the row is on disk and hydration will
|
|
// surface it on a later render, so the connect phase still proceeds —
|
|
// swallow it. Any other failure (read-only catalog, backend 5xx) would
|
|
// otherwise leave the deep link permanently stuck at this guard with no
|
|
// signal, so record it on the machine-readable error surface.
|
|
if (!message.includes("already exists")) recordConnectError(message);
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (!deepLinkConfigEquals(existing.config, deepLink.serverConfig)) {
|
|
if (deepLinkUpdateRef.current) return;
|
|
deepLinkUpdateRef.current = true;
|
|
void updateServer(
|
|
deepLink.serverId,
|
|
deepLink.serverId,
|
|
deepLink.serverConfig,
|
|
).catch((err) => {
|
|
const message = err instanceof Error ? err.message : String(err);
|
|
recordConnectError(message);
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (deepLinkConnectRef.current) return;
|
|
deepLinkConnectRef.current = true;
|
|
// Connect unless we're already *connected* to the deep-link server. Gating
|
|
// on `activeServerId` identity alone would skip the connect when a prior
|
|
// session restored `activeServerId` to the `deep-link` id while the socket
|
|
// is disconnected — a reload of the same deep-link URL would then silently
|
|
// never connect. `onToggleConnection` only disconnects when the id is the
|
|
// active one AND the status is connected, so this condition also avoids
|
|
// toggling a live connection off.
|
|
const alreadyConnected =
|
|
activeServerId === deepLink.serverId && connectionStatus === "connected";
|
|
if (!alreadyConnected) {
|
|
void onToggleConnection(deepLink.serverId).catch((err) => {
|
|
// The toast fires from inside `onToggleConnection` for the common
|
|
// cases; this catch covers the rest (surfaced on `data-error-message`).
|
|
const message = err instanceof Error ? err.message : String(err);
|
|
recordConnectError(message);
|
|
});
|
|
}
|
|
}, [
|
|
deepLink,
|
|
servers,
|
|
activeServerId,
|
|
connectionStatus,
|
|
addServer,
|
|
updateServer,
|
|
onToggleConnection,
|
|
recordConnectError,
|
|
]);
|
|
|
|
const onReauthenticateFromBanner = useCallback(() => {
|
|
if (!reAuthBanner) return;
|
|
const serverId = reAuthBanner.serverId;
|
|
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) => {
|
|
setPaginatedListsOverride(value);
|
|
const current = servers.find((s) => s.id === activeServerId);
|
|
if (!current || activeServerId === undefined) return;
|
|
const prevSettings = current.settings ?? EMPTY_SETTINGS;
|
|
const next: InspectorServerSettings = {
|
|
...prevSettings,
|
|
paginatedLists: value,
|
|
};
|
|
inspectorClient?.setServerSettings(next);
|
|
// Drive the load that the mode change implies (data-loading stays out of
|
|
// React effects; the paged stores own only the connect-time load). To
|
|
// paginated: pull page 1 into each paged store. To all-pages: refetch
|
|
// each managed aggregate that was gated off. Only when connected.
|
|
if (connected) {
|
|
// Wrap in ambient auth recovery so a mid-session 401 triggers re-auth
|
|
// rather than surfacing raw, matching the all-pages refresh path.
|
|
if (value) {
|
|
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).
|
|
void refreshingPersist(updateServerSettings, refreshInitialConfig)(
|
|
activeServerId,
|
|
next,
|
|
).catch((err: unknown) => {
|
|
// A ServerListReloadError means the PUT landed and only reading the
|
|
// list back failed, so the new setting IS on disk (#1914). Rolling
|
|
// back here would put the UI and the live client on the *old* value
|
|
// and contradict it — report it and leave the optimistic state alone.
|
|
const reloadFailed = err instanceof ServerListReloadError;
|
|
if (!reloadFailed) {
|
|
// Persist failed: revert the optimistic override (the effect only
|
|
// clears it when the persisted value changes, which won't happen here)
|
|
// and roll the live client setting back, so the UI and client reflect
|
|
// the value that's actually on disk rather than the failed edit (#1721).
|
|
setPaginatedListsOverride(null);
|
|
inspectorClient?.setServerSettings(prevSettings);
|
|
}
|
|
notifications.show({
|
|
title: reloadFailed
|
|
? "Pagination setting saved, but the server list did not reload"
|
|
: "Failed to save pagination setting",
|
|
message: err instanceof Error ? err.message : String(err),
|
|
color: "red",
|
|
});
|
|
});
|
|
},
|
|
[
|
|
servers,
|
|
activeServerId,
|
|
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]);
|
|
|
|
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,
|
|
resolveInitial: (id) =>
|
|
servers.find((s) => s.id === id)?.settings ?? 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: refreshingPersist(updateServerSettings, refreshInitialConfig),
|
|
// Surface failures via toast — the modal usually closes
|
|
// immediately on user dismiss, so a silent fail-on-flush would
|
|
// leave the user thinking their last edits saved when they
|
|
// didn't (especially painful for the OAuth client secret).
|
|
onError: (id, err) => {
|
|
notifications.show({
|
|
title: `Failed to save settings for "${id}"`,
|
|
message: err instanceof Error ? err.message : String(err),
|
|
color: "red",
|
|
});
|
|
},
|
|
});
|
|
|
|
const settingsModalValue: InspectorServerSettings =
|
|
settingsDraft ?? EMPTY_SETTINGS;
|
|
|
|
const {
|
|
draft: clientSettingsDraft,
|
|
onChange: onClientSettingsChange,
|
|
flush: flushClientSettingsDraft,
|
|
} = useClientSettingsDraft({
|
|
opened: clientSettingsOpen,
|
|
resolveInitial: () => clientConfigToFormValues(clientConfig),
|
|
// 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
|
|
) {
|
|
// Push the edited settings onto the live client so settings the managed
|
|
// state reads at notification time (auto-refresh-on-list-changed) take
|
|
// effect without a reconnect (#1444). Connection-time inputs (transport,
|
|
// OAuth, timeouts) still only apply on the next connect.
|
|
inspectorClient.setServerSettings(settingsDraft);
|
|
// Resize the Network log buffer live so a maxFetchRequests edit takes
|
|
// effect without a reconnect (shrinking trims immediately). Connect-time
|
|
// construction also reads this, so a reconnect would apply it anyway —
|
|
// this just makes the toast→adjust flow responsive.
|
|
fetchLogRef.current?.setMaxFetchRequests(settingsDraft.maxFetchRequests);
|
|
const nextRoots = cleanRoots(settingsDraft.roots);
|
|
const currentRoots = cleanRoots(inspectorClient.getRoots());
|
|
if (JSON.stringify(nextRoots) !== JSON.stringify(currentRoots)) {
|
|
void inspectorClient.setRoots(nextRoots).catch(() => {
|
|
// setRoots swallows notification failures internally; a throw here
|
|
// only means the client is mid-teardown — the persisted roots will
|
|
// re-advertise on the next connect, so nothing to surface.
|
|
});
|
|
}
|
|
}
|
|
setSettingsModalTargetId(undefined);
|
|
}, [
|
|
flushSettingsDraft,
|
|
inspectorClient,
|
|
settingsModalTargetId,
|
|
activeServerId,
|
|
settingsDraft,
|
|
]);
|
|
|
|
// The Resources screen needs `isSubscribed` to flip the Subscribe button
|
|
// label to "Unsubscribe". Derive it from the live subscriptions list rather
|
|
// than threading it through every setReadResourceState site — that way the
|
|
// button reflects state changes from any source (preview panel, subscribed
|
|
// tile, or future server-initiated subscribe notifications).
|
|
const effectiveReadResourceState = useMemo<
|
|
ReadResourceState | undefined
|
|
>(() => {
|
|
if (!readResourceState) return undefined;
|
|
if (!readResourceState.uri) return readResourceState;
|
|
const isSubscribed = subscriptions.some(
|
|
(s) => s.resource.uri === readResourceState.uri,
|
|
);
|
|
return { ...readResourceState, isSubscribed };
|
|
}, [readResourceState, subscriptions]);
|
|
|
|
// Surface one pending server-initiated request at a time in the modal,
|
|
// sampling-first. Responding (below) removes it from the client's queue,
|
|
// which re-renders this with the next request or closes the modal.
|
|
const totalPendingRequests =
|
|
pendingSamples.length + pendingElicitations.length;
|
|
|
|
// Derive the head request inside the memo (depending on the source arrays)
|
|
// so the memo actually caches — an inline `activeElicitation` would have a
|
|
// fresh identity every render and defeat it.
|
|
const pendingRequestContent =
|
|
useMemo<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;
|