inspector/clients/web/src/App.tsx
Cliff Hall 420da2e415
feat(servers): export server list as mcp.json download (#1346) (#1351)
* feat(servers): export server list as mcp.json download (#1346)

Adds an Export button to the Servers screen, next to "Add Servers".
Clicking it downloads the current server list as a canonical mcp.json
file (2-space indent, same shape serializeStore writes), so the user
can hand it off, sync it to another machine, or symlink into Claude
Desktop / Cursor / Cline.

Wiring:
- ServerListControls: new Export Button (variant=default), disabled
  when serverCount === 0 (nothing to download).
- ServerListScreen / InspectorView: thread `onExport` / `onServerExport`
  through.
- App.tsx: onServerExport runs serverEntriesToMcpConfig on the in-memory
  servers list, JSON.stringify with 2-space indent, and triggers a Blob
  download via a temporary anchor (appended to body for Firefox
  compatibility, then removed; object URL revoked).

Tests:
- ServerListControls unit tests cover button-count (toggle hidden vs
  shown, Export always present), disabled-on-empty, click fires onExport.
- ServerListControls.stories grows play functions for both stories —
  WithServers asserts Export is enabled + click fires the spy in real
  Chromium, WithoutServers asserts Export is disabled.
- Existing screen + view tests / stories updated for the new prop.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(servers): address PR #1351 review (extract helpers + exception safety + guard)

Substantive items from the review:

1. Serialization format parity (review #1): added
   `serializeMcpConfig(entries)` to core/mcp/serverList.ts — a
   browser-safe helper that runs `serverEntriesToMcpConfig` then
   stringifies with 2-space indent. Single source of truth for export
   formatting; can't drift from serializeStore's on-disk shape.

2. Exception safety on the anchor cleanup (review #2) + a direct test
   for the App.tsx download wiring (review #6): extracted
   `downloadJsonFile(filename, json)` to clients/web/src/lib/
   downloadFile.ts. removeChild + revokeObjectURL run in a `finally`
   so a thrown click() doesn't leak the DOM node or the object URL.
   Unit-tested under happy-dom with URL.createObjectURL mocked
   (happy-dom doesn't ship it).

3. Empty-list guard (review #5): App.tsx onServerExport bails when
   `servers.length === 0`. The button's disabled prop already prevents
   the UI path, but the handler is now locally correct against any
   future programmatic caller (storybook, keyboard shortcut, etc.).

Acknowledged but not changed:
- Tooltip on the disabled Export button (review #3) — Mantine's
  disabled-button + Tooltip wart wants a span wrapper + custom events
  config. Optional per the reviewer; deferred.
- Repeated exports → `mcp (1).json` browser suffix (review #4) — a
  deliberate trade-off since `mcp.json` is the conventional name a user
  would expect to symlink into Claude Desktop / Cursor / etc.

Tests: 1647 unit (+6) + 410 integration + 306 storybook = 2363 green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 15:15:56 -04:00

818 lines
31 KiB
TypeScript

import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useComputedColorScheme, useMantineColorScheme } from "@mantine/core";
import type {
InitializeResult,
LoggingLevel,
LoggingMessageNotification,
Tool,
} from "@modelcontextprotocol/sdk/types.js";
import type { AppBridge } from "@modelcontextprotocol/ext-apps/app-bridge";
import { InspectorClient } from "@inspector/core/mcp/index.js";
import type { JsonValue } from "@inspector/core/mcp/index.js";
import type {
MCPServerConfig,
MessageEntry,
ServerEntry,
} from "@inspector/core/mcp/types.js";
import { API_SERVER_ENV_VARS } 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 { 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 { serializeMcpConfig } from "@inspector/core/mcp/serverList.js";
import { MessageLogState } from "@inspector/core/mcp/state/messageLogState.js";
import { FetchRequestLogState } 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 { useInspectorClient } from "@inspector/core/react/useInspectorClient.js";
import { useServers } from "@inspector/core/react/useServers.js";
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 { 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 { InspectorView } from "./components/views/InspectorView/InspectorView";
import type { ToolCallState } from "./components/screens/ToolsScreen/ToolsScreen";
import type { GetPromptState } from "./components/screens/PromptsScreen/PromptsScreen";
import type { ReadResourceState } from "./components/screens/ResourcesScreen/ResourcesScreen";
import type { BridgeFactory } from "./components/elements/AppRenderer/AppRenderer";
import type { LogEntryData } from "./components/elements/LogEntry/LogEntry";
import {
ServerConfigModal,
type ServerConfigModalMode,
} from "./components/groups/ServerConfigModal/ServerConfigModal";
import { ServerRemoveConfirmModal } from "./components/groups/ServerRemoveConfirmModal/ServerRemoveConfirmModal";
import { downloadJsonFile } from "./lib/downloadFile";
import { createWebEnvironment } from "./lib/environmentFactory";
// 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`,
};
// Pull the dev-backend's auth token off the URL the launcher banner prints.
// `npm run dev` opens `http://localhost:6274?MCP_INSPECTOR_API_TOKEN=…`;
// every browser request to /api/* needs the same token in the
// `x-mcp-remote-auth: Bearer …` header or the Hono backend returns 401.
// Persist to sessionStorage so SPA navigations / OAuth round-trips don't
// drop the token from the URL bar.
function getAuthToken(): string | undefined {
if (typeof window === "undefined") return undefined;
const STORAGE_KEY = API_SERVER_ENV_VARS.AUTH_TOKEN;
const params = new URLSearchParams(window.location.search);
const fromUrl = params.get(API_SERVER_ENV_VARS.AUTH_TOKEN);
if (fromUrl) {
try {
window.sessionStorage.setItem(STORAGE_KEY, fromUrl);
} catch {
// Best-effort persistence — sessionStorage may be unavailable
// (privacy mode, iframe sandboxing, etc.); the URL value still
// works for the current page load.
}
return fromUrl;
}
try {
return window.sessionStorage.getItem(STORAGE_KEY) ?? undefined;
} catch {
return undefined;
}
}
// MCP Apps sandbox — the iframe URL the parent should embed, plus the
// per-tool bridge factory. The dev backend serves `sandbox_proxy.html` on
// the sandbox controller port; the factory will eventually wrap the SDK
// client. For now neither is wired (the Apps tab uses these props but does
// not yet round-trip tool input through a live bridge — that's a follow-up
// alongside the AppRenderer integration). Keep these as stable references
// so InspectorView's effect deps don't churn.
const STUB_SANDBOX_PATH = "about:blank";
const stubBridgeFactory: BridgeFactory = () =>
({
sendToolInput: async () => {},
sendToolResult: async () => {},
sendToolCancelled: async () => {},
teardownResource: async () => ({}),
close: async () => {},
}) as unknown as AppBridge;
// 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 before the cast.
if (!("method" in m.message)) continue;
if (m.message.method !== "notifications/message") continue;
const params = (m.message as unknown as LoggingMessageNotification).params;
out.push({
receivedAt: m.timestamp,
params,
});
}
return out;
}
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, removeServer } = 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);
const [removeTarget, setRemoveTarget] = useState<ServerEntry | 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,
);
// InspectorClient + per-primitive state managers. All recreated together
// whenever the user switches active servers, then destroyed when the
// next switch happens (or when the component unmounts).
const [inspectorClient, setInspectorClient] =
useState<InspectorClient | null>(null);
const [managedToolsState, setManagedToolsState] =
useState<ManagedToolsState | null>(null);
const [managedPromptsState, setManagedPromptsState] =
useState<ManagedPromptsState | null>(null);
const [managedResourcesState, setManagedResourcesState] =
useState<ManagedResourcesState | 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");
// 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);
// 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);
const [errorMessage, setErrorMessage] = useState<string | undefined>(
undefined,
);
// 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,
serverInfo,
instructions,
} = useInspectorClient(inspectorClient);
const { tools, refresh: refreshTools } = useManagedTools(
inspectorClient,
managedToolsState,
);
const { prompts, refresh: refreshPrompts } = useManagedPrompts(
inspectorClient,
managedPromptsState,
);
const { resources, refresh: refreshResources } = useManagedResources(
inspectorClient,
managedResourcesState,
);
const { resourceTemplates } = useManagedResourceTemplates(
inspectorClient,
managedResourceTemplatesState,
);
const { tasks, refresh: refreshTasks } = useManagedRequestorTasks(
inspectorClient,
managedRequestorTasksState,
);
const { subscriptions } = useResourceSubscriptions(
resourceSubscriptionsState,
);
const { messages } = useMessageLog(messageLogState);
// 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]);
// 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 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).
useEffect(() => {
if (!inspectorClient) return;
const onDisconnect = () => {
setActiveServerId(undefined);
};
inspectorClient.addEventListener("disconnect", onDisconnect);
return () => {
inspectorClient.removeEventListener("disconnect", onDisconnect);
};
}, [inspectorClient]);
// Build the InitializeResult the connected ViewHeader expects from the
// hook's split fields. `protocolVersion` is hard-coded for now — the
// useInspectorClient hook doesn't expose it. TODO(#1324): consume the
// negotiated value once the hook surfaces it.
const initializeResult = useMemo<InitializeResult | undefined>(() => {
if (connectionStatus !== "connected" || !serverInfo) return undefined;
return {
protocolVersion: "2025-06-18",
capabilities: capabilities ?? {},
serverInfo,
...(instructions ? { instructions } : {}),
};
}, [connectionStatus, capabilities, serverInfo, instructions]);
// Derive log entries from the message log. Filters for
// `notifications/message` (the response to `logging/setLevel`).
const logs = useMemo<LogEntryData[]>(
() => messagesToLogEntries(messages),
[messages],
);
// 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): 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();
managedResourceTemplatesState?.destroy();
managedRequestorTasksState?.destroy();
resourceSubscriptionsState?.destroy();
messageLogState?.destroy();
fetchRequestLogState?.destroy();
stderrLogState?.destroy();
const { environment } = createWebEnvironment(
getAuthToken(),
redirectUrlProvider,
);
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 },
});
setInspectorClient(client);
setManagedToolsState(new ManagedToolsState(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));
setFetchRequestLogState(new FetchRequestLogState(client));
setStderrLogState(new StderrLogState(client));
return client;
},
[
managedToolsState,
managedPromptsState,
managedResourcesState,
managedResourceTemplatesState,
managedRequestorTasksState,
resourceSubscriptionsState,
messageLogState,
fetchRequestLogState,
stderrLogState,
],
);
const onToggleConnection = useCallback(
async (id: string) => {
// Same server, already connected → disconnect.
if (
id === activeServerId &&
connectionStatus === "connected" &&
inspectorClient
) {
await inspectorClient.disconnect();
return;
}
const target = servers.find((s) => s.id === id);
if (!target) return;
// Different server (or first connect): rebuild the client + managers.
let client = inspectorClient;
if (id !== activeServerId || client === null) {
client = setupClientForServer(target);
setActiveServerId(id);
}
setErrorMessage(undefined);
connectStartRef.current = Date.now();
try {
await client.connect();
} catch (err) {
// Handshake-only. A mid-session transport failure transitions the
// client status to "error" without rejecting any pending promise,
// and `errorMessage` stays stale. TODO(#1323): consume an `error`
// event from `InspectorClientEventMap` once it exists.
connectStartRef.current = undefined;
const message = err instanceof Error ? err.message : String(err);
setErrorMessage(message);
}
},
[
activeServerId,
connectionStatus,
inspectorClient,
servers,
setupClientForServer,
],
);
const onDisconnect = useCallback(async () => {
if (!inspectorClient) return;
await inspectorClient.disconnect();
}, [inspectorClient]);
// --- Action handlers that route directly to the InspectorClient. ---
const onCallTool = useCallback(
async (name: string, args: Record<string, unknown>) => {
if (!inspectorClient) return;
const tool = tools.find((t: Tool) => t.name === name);
if (!tool) return;
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 = await inspectorClient.callTool(
tool,
args as Record<string, JsonValue>,
);
setToolCallState({
status: invocation.success ? "ok" : "error",
result: invocation.result ?? undefined,
error: invocation.error,
});
} catch (err) {
setToolCallState({
status: "error",
error: err instanceof Error ? err.message : String(err),
});
}
},
[inspectorClient, tools],
);
const onClearToolResult = useCallback(() => {
setToolCallState(undefined);
}, []);
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) {
setGetPromptState({
status: "error",
promptName: name,
error: err instanceof Error ? err.message : String(err),
});
}
},
[inspectorClient],
);
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) {
setReadResourceState({
status: "error",
uri,
error: err instanceof Error ? err.message : String(err),
});
}
},
[inspectorClient],
);
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 inspectorClient.getCompletions(
ref,
argumentName,
argumentValue,
context,
);
return result.values;
},
[inspectorClient],
);
const onCancelTask = useCallback(
(taskId: string) => {
if (!inspectorClient) return;
void inspectorClient.cancelRequestorTask(taskId);
},
[inspectorClient],
);
const onSetLogLevel = useCallback(
(level: LoggingLevel) => {
setCurrentLogLevel(level);
if (!inspectorClient) return;
void inspectorClient.setLoggingLevel(level);
},
[inspectorClient],
);
const onRefreshTools = useCallback(() => {
void refreshTools();
}, [refreshTools]);
const onRefreshPrompts = useCallback(() => {
void refreshPrompts();
}, [refreshPrompts]);
const onRefreshResources = useCallback(() => {
void refreshResources();
}, [refreshResources]);
const onRefreshTasks = useCallback(() => {
void refreshTasks();
}, [refreshTasks]);
const onClearLogs = useCallback(() => {
if (!messageLogState) return;
// Clear only the log notifications, not the entire request/response
// history (which the History screen renders from the same source).
messageLogState.clearMessages(
(m) =>
m.direction === "notification" &&
"method" in m.message &&
m.message.method === "notifications/message",
);
}, [messageLogState]);
const onClearHistory = useCallback(() => {
messageLogState?.clearMessages();
}, [messageLogState]);
// Action stubs — these UI affordances exist but require additional
// wiring (server CRUD, history pinning, app sandbox round-trip, log
// export). Tracked separately; the noop keeps the prop interface
// satisfied without lying about behavior.
const todoNoop = useCallback(() => {
/* TODO: not wired yet */
}, []);
// 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();
managedResourceTemplatesState?.destroy();
managedRequestorTasksState?.destroy();
resourceSubscriptionsState?.destroy();
messageLogState?.destroy();
fetchRequestLogState?.destroy();
stderrLogState?.destroy();
setInspectorClient(null);
setManagedToolsState(null);
setManagedPromptsState(null);
setManagedResourcesState(null);
setManagedResourceTemplatesState(null);
setManagedRequestorTasksState(null);
setResourceSubscriptionsState(null);
setMessageLogState(null);
setFetchRequestLogState(null);
setStderrLogState(null);
setActiveServerId(undefined);
}
await removeServer(id);
setRemoveTarget(null);
}, [
removeTarget,
activeServerId,
inspectorClient,
managedToolsState,
managedPromptsState,
managedResourcesState,
managedResourceTemplatesState,
managedRequestorTasksState,
resourceSubscriptionsState,
messageLogState,
fetchRequestLogState,
stderrLogState,
removeServer,
]);
// Submit handler for the Add / Edit / Clone modal. Add and Clone both go
// through addServer; Edit uses updateServer (which supports id rename).
// On rename of the active server, keep activeServerId pointed at the new id.
const onConfigSubmit = useCallback(
async (id: string, config: MCPServerConfig) => {
if (configModal?.mode === "edit" && configModal.targetId) {
const originalId = configModal.targetId;
await updateServer(originalId, id, config);
if (originalId === activeServerId && id !== originalId) {
setActiveServerId(id);
}
return;
}
// add or clone
await addServer(id, config);
},
[configModal, addServer, 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]);
// 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]);
return (
<>
<InspectorView
servers={servers}
activeServer={activeServerId}
connectionStatus={connectionStatus}
initializeResult={initializeResult}
latencyMs={latencyMs}
errorMessage={errorMessage}
tools={tools}
prompts={prompts}
resources={resources}
resourceTemplates={resourceTemplates}
subscriptions={subscriptions}
logs={logs}
tasks={tasks}
history={messages}
toolCallState={toolCallState}
getPromptState={getPromptState}
readResourceState={effectiveReadResourceState}
currentLogLevel={currentLogLevel}
sandboxPath={STUB_SANDBOX_PATH}
bridgeFactory={stubBridgeFactory}
onToggleTheme={onToggleTheme}
onToggleConnection={(id) => {
void onToggleConnection(id);
}}
onDisconnect={() => {
void onDisconnect();
}}
onServerAdd={() => setConfigModal({ mode: "add" })}
onServerImportConfig={todoNoop}
onServerImportJson={todoNoop}
onServerExport={onServerExport}
onServerInfo={todoNoop}
onServerSettings={todoNoop}
onServerEdit={(id) => setConfigModal({ mode: "edit", targetId: id })}
onServerClone={(id) => setConfigModal({ mode: "clone", targetId: id })}
onServerRemove={(id) => {
const target = servers.find((s) => s.id === id);
if (target) setRemoveTarget(target);
}}
onCallTool={(name, args) => {
void onCallTool(name, args);
}}
onClearToolResult={onClearToolResult}
onRefreshTools={onRefreshTools}
onGetPrompt={(name, args) => {
void onGetPrompt(name, args);
}}
onRefreshPrompts={onRefreshPrompts}
onReadResource={(uri) => {
void onReadResource(uri);
}}
onSubscribeResource={onSubscribeResource}
onUnsubscribeResource={onUnsubscribeResource}
onRefreshResources={onRefreshResources}
onCompleteArgument={onCompleteArgument}
completionsSupported={capabilities?.completions !== undefined}
onCancelTask={onCancelTask}
onClearCompletedTasks={todoNoop}
onRefreshTasks={onRefreshTasks}
onSetLogLevel={onSetLogLevel}
onClearLogs={onClearLogs}
onExportLogs={todoNoop}
onCopyAllLogs={todoNoop}
onClearHistory={onClearHistory}
onExportHistory={todoNoop}
onReplayHistory={todoNoop}
onTogglePinHistory={todoNoop}
onSelectApp={todoNoop}
onOpenApp={todoNoop}
onCloseApp={todoNoop}
onRefreshApps={onRefreshTools}
/>
<ServerConfigModal
opened={configModal !== null}
mode={configModal?.mode ?? "add"}
initialId={configModalTarget?.id}
initialConfig={configModalTarget?.config}
existingIds={existingIds}
onClose={() => setConfigModal(null)}
onSubmit={onConfigSubmit}
/>
<ServerRemoveConfirmModal
opened={removeTarget !== null}
target={removeTarget}
onCancel={() => setRemoveTarget(null)}
onConfirm={onConfirmRemove}
/>
</>
);
}
export default App;