import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useComputedColorScheme, useMantineColorScheme } from "@mantine/core"; import { notifications } from "@mantine/notifications"; 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 { InspectorServerSettings, 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 { ServerSettingsModal } from "./components/groups/ServerSettingsModal/ServerSettingsModal"; 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, updateServerSettings, 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 [settingsModalTargetId, setSettingsModalTargetId] = useState< string | undefined >(undefined); const [removeTarget, setRemoveTarget] = useState(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( 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(null); const [managedToolsState, setManagedToolsState] = useState(null); const [managedPromptsState, setManagedPromptsState] = useState(null); const [managedResourcesState, setManagedResourcesState] = useState(null); const [managedResourceTemplatesState, setManagedResourceTemplatesState] = useState(null); const [managedRequestorTasksState, setManagedRequestorTasksState] = useState(null); const [resourceSubscriptionsState, setResourceSubscriptionsState] = useState(null); const [messageLogState, setMessageLogState] = useState(null); const [fetchRequestLogState, setFetchRequestLogState] = useState(null); const [stderrLogState, setStderrLogState] = useState( null, ); // Optimistic log level — `logging/setLevel` has no echo notification, so // the parent keeps the current value locally. const [currentLogLevel, setCurrentLogLevel] = useState("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( 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(undefined); const [latencyMs, setLatencyMs] = useState(undefined); const [errorMessage, setErrorMessage] = useState( 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(() => { 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( () => 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, ); // 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; // 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 oauth = savedSettings && (savedSettings.oauthClientId || savedSettings.oauthClientSecret || savedSettings.oauthScopes) ? { ...(savedSettings.oauthClientId && { clientId: savedSettings.oauthClientId, }), ...(savedSettings.oauthClientSecret && { clientSecret: savedSettings.oauthClientSecret, }), ...(savedSettings.oauthScopes && { scope: savedSettings.oauthScopes, }), } : 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 }, ...(savedSettings && savedSettings.requestTimeout > 0 && { timeout: savedSettings.requestTimeout, }), ...(defaultMetadata && Object.keys(defaultMetadata).length > 0 && { defaultMetadata, }), ...(oauth && { oauth }), ...(savedSettings && { serverSettings: savedSettings }), }); 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; // 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); } setErrorMessage(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 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) => { 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` (it accepts // anything the user types into the schema form). `callTool` requires // `Record` — narrow at the boundary instead of // claiming the object is empty (which the previous `as Record` cast did, misleadingly). const invocation = await inspectorClient.callTool( tool, args as Record, ); 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) => { 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, ): Promise => { 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]); const settingsModalTarget = useMemo(() => { if (!settingsModalTargetId) return undefined; return servers.find((s) => s.id === settingsModalTargetId); }, [settingsModalTargetId, servers]); // Stable starting shape for entries that have no `settings` node yet — the // form needs concrete arrays / numbers to render. const settingsModalValue = useMemo(() => { return ( settingsModalTarget?.settings ?? { headers: [], metadata: [], connectionTimeout: 0, requestTimeout: 0, } ); }, [settingsModalTarget]); // The settings modal fires onSettingsChange on every keystroke. Persisting // each character would run a tight PUT-then-full-refresh loop against the // backend (and the in-memory `servers` state could flicker mid-typing as // each refresh resets the modal's prop). Debounce so a burst of edits // coalesces into a single PUT, and flush on modal close so nothing is lost. const pendingSettingsRef = useRef<{ id: string; settings: InspectorServerSettings; } | null>(null); const pendingSettingsTimerRef = useRef< ReturnType | undefined >(undefined); const flushPendingSettings = useCallback(() => { if (pendingSettingsTimerRef.current) { clearTimeout(pendingSettingsTimerRef.current); pendingSettingsTimerRef.current = undefined; } const pending = pendingSettingsRef.current; if (!pending) return; pendingSettingsRef.current = null; // Fire-and-forget — but surface failures via toast. The modal 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). updateServerSettings(pending.id, pending.settings).catch((err) => { notifications.show({ title: `Failed to save settings for "${pending.id}"`, message: err instanceof Error ? err.message : String(err), color: "red", }); }); }, [updateServerSettings]); const onSettingsChange = useCallback( (next: InspectorServerSettings) => { if (!settingsModalTargetId) return; pendingSettingsRef.current = { id: settingsModalTargetId, settings: next, }; if (pendingSettingsTimerRef.current) { clearTimeout(pendingSettingsTimerRef.current); } pendingSettingsTimerRef.current = setTimeout(flushPendingSettings, 300); }, [settingsModalTargetId, flushPendingSettings], ); const onSettingsModalClose = useCallback(() => { flushPendingSettings(); setSettingsModalTargetId(undefined); }, [flushPendingSettings]); // Cancel any pending debounce on unmount (route change / HMR). Without // this a stale setTimeout could still fire one final `updateServerSettings` // against an unmounted component — harmless today (just an extra PUT of // the final payload) but the cleanest pattern is to clear the timer. useEffect(() => { return () => { if (pendingSettingsTimerRef.current) { clearTimeout(pendingSettingsTimerRef.current); pendingSettingsTimerRef.current = undefined; } }; }, []); // 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 ( <> { void onToggleConnection(id); }} onDisconnect={() => { void onDisconnect(); }} onServerAdd={() => setConfigModal({ mode: "add" })} onServerImportConfig={todoNoop} onServerImportJson={todoNoop} onServerExport={onServerExport} onServerInfo={todoNoop} onServerSettings={(id) => setSettingsModalTargetId(id)} 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} /> setConfigModal(null)} onSubmit={onConfigSubmit} /> setRemoveTarget(null)} onConfirm={onConfirmRemove} /> ); } export default App;