* 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>
818 lines
31 KiB
TypeScript
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;
|