import React, { useState, useMemo, useEffect, useCallback, useRef, } from "react"; import { Box, Text, useInput, useApp, type Key } from "ink"; import type { MessageEntry, FetchRequestEntry, InspectorClientOptions, InspectorClientEnvironment, } from "@inspector/core/mcp/index.js"; import type { Tool, Resource, Prompt, PromptArgument, GetPromptResult, } from "@modelcontextprotocol/client"; import { InspectorClient } from "@inspector/core/mcp/index.js"; import { cleanRoots } from "@inspector/core/mcp/serverList.js"; import { eraToVersionNegotiation } from "@inspector/core/mcp/types.js"; import { ManagedToolsState, ManagedResourcesState, ManagedResourceTemplatesState, ManagedPromptsState, MessageLogState, FetchRequestLogState, StderrLogState, } from "@inspector/core/mcp/state/index.js"; import { createTransportNode } from "@inspector/core/mcp/node/index.js"; import { useInspectorClient } from "@inspector/core/react/useInspectorClient.js"; import { useManagedTools } from "@inspector/core/react/useManagedTools.js"; import { useManagedResources } from "@inspector/core/react/useManagedResources.js"; import { useManagedResourceTemplates } from "@inspector/core/react/useManagedResourceTemplates.js"; import { useManagedPrompts } from "@inspector/core/react/useManagedPrompts.js"; import { useMessageLog } from "@inspector/core/react/useMessageLog.js"; import { useFetchRequestLog } from "@inspector/core/react/useFetchRequestLog.js"; import { useStderrLog } from "@inspector/core/react/useStderrLog.js"; import { CallbackNavigation, MutableRedirectUrlProvider, isUnauthorizedError, AuthRecoveryRequiredError, } from "@inspector/core/auth/index.js"; import type { AuthChallenge } from "@inspector/core/auth/challenge.js"; import type { TypedEvent } from "@inspector/core/mcp/inspectorClientEventTarget.js"; import { isEmaClientNotConfiguredError } from "@inspector/core/auth/ema/clientConfigError.js"; import type { ClientConfig } from "@inspector/core/client/types.js"; import { buildRunnerClientAuthOptions, isOAuthCapableServerConfig, } from "@inspector/core/client/runner.js"; import { formatRunnerOAuthRedirectUrl } from "@inspector/core/auth/node/runner-oauth-callback.js"; import { readInspectorVersion } from "@inspector/core/node/version.js"; import { createOAuthCallbackServer, type OAuthCallbackServer, NodeOAuthStorage, runRunnerInteractiveOAuth, } from "@inspector/core/auth/node/index.js"; import { getTuiLogger } from "./logger.js"; import { openUrl } from "./utils/openUrl.js"; import { isStepUpConfirmation, stepUpInsufficientScopeMessage, } from "./utils/tuiOAuth.js"; import { emaStepUpFailureMessage } from "@inspector/core/auth/oauthUx.js"; import { Tabs } from "./components/Tabs.js"; import { type TabType, tabs as tabList } from "./components/tabsConfig.js"; import { InfoTab } from "./components/InfoTab.js"; import { AuthTab } from "./components/AuthTab.js"; import { ResourcesTab } from "./components/ResourcesTab.js"; import { PromptsTab } from "./components/PromptsTab.js"; import { ToolsTab } from "./components/ToolsTab.js"; import { NotificationsTab } from "./components/NotificationsTab.js"; import { HistoryTab } from "./components/HistoryTab.js"; import { RequestsTab } from "./components/RequestsTab.js"; import { ToolTestModal } from "./components/ToolTestModal.js"; import { ResourceTestModal } from "./components/ResourceTestModal.js"; import { PromptTestModal } from "./components/PromptTestModal.js"; import { DetailsModal } from "./components/DetailsModal.js"; import type { TuiServer } from "./tui-servers.js"; // Header branding. The version is the single source of truth — the root // package.json — read via the shared core reader; the name/description are the // TUI's own display strings (they live in code, not the npm manifest, which no // longer carries them per-client). const APP_NAME = "MCP Inspector TUI"; const APP_DESCRIPTION = "Terminal User Interface for the Model Context Protocol Inspector"; const APP_VERSION = readInspectorVersion(import.meta.url); /** Client identity name the TUI reports to servers. */ const TUI_CLIENT_NAME = "inspector-tui"; // Focus management types type FocusArea = | "serverList" | "tabs" // Used by Resources/Prompts/Tools - list pane | "tabContentList" // Used by Resources/Prompts/Tools - details pane | "tabContentDetails" // Used only when activeTab === 'messages' | "messagesList" | "messagesDetail" // Used only when activeTab === 'requests' | "requestsList" | "requestsDetail"; interface AppProps { mcpServers: Record; clientConfig: ClientConfig; clientId?: string; clientSecret?: string; clientMetadataUrl?: string; callbackUrlConfig: { hostname: string; port: number; pathname: string; }; } function App({ mcpServers, clientConfig, clientId, clientSecret, clientMetadataUrl, callbackUrlConfig, }: AppProps) { const { exit } = useApp(); useEffect(() => { getTuiLogger().info( { serverNames: Object.keys(mcpServers) }, "TUI started", ); }, [mcpServers]); const [selectedServer, setSelectedServer] = useState(null); const [activeTab, setActiveTab] = useState("info"); const [focus, setFocus] = useState("serverList"); const [tabCounts, setTabCounts] = useState<{ info?: number; resources?: number; prompts?: number; tools?: number; messages?: number; requests?: number; logging?: number; }>({}); const [oauthStatus, setOauthStatus] = useState< "idle" | "authenticating" | "error" >("idle"); const [oauthMessage, setOauthMessage] = useState(null); const [oauthRevision, setOauthRevision] = useState(0); const [pendingStepUp, setPendingStepUp] = useState<{ serverName: string; challenge: AuthChallenge; authorizationUrl: URL; enterpriseManaged?: boolean; } | null>(null); const pendingStepUpRef = useRef(pendingStepUp); useEffect(() => { pendingStepUpRef.current = pendingStepUp; }, [pendingStepUp]); const [connectError, setConnectError] = useState(null); // Monotonic token for in-flight disconnects — see handleDisconnect. const disconnectAttemptRef = useRef(0); // A failed disconnect is deliberately NOT folded into `connectError`. That // one is only rendered by InfoTab when the status is "error", which a // rejected disconnect leaves untouched (the status stays "connected"), so // the message would never be seen — and it feeds // `connectError ?? inspectorLastError`, where a stale value would go on // masking a later, real connection error. This renders in the header // regardless of status and is cleared on the next connect/disconnect // attempt and on a server switch. const [disconnectError, setDisconnectError] = useState(null); const oauthInProgressRef = useRef(false); const callbackServerRef = useRef(null); const selectedServerRef = useRef(null); const mcpServersRef = useRef(mcpServers); const inspectorClientsRef = useRef>({}); // Tool test modal state const [toolTestModal, setToolTestModal] = useState<{ tool: Tool; inspectorClient: InspectorClient | null; } | null>(null); // Resource test modal state const [resourceTestModal, setResourceTestModal] = useState<{ template: { name: string; uriTemplate: string; description?: string; }; inspectorClient: InspectorClient | null; } | null>(null); // Prompt test modal state const [promptTestModal, setPromptTestModal] = useState<{ prompt: Prompt; inspectorClient: InspectorClient | null; } | null>(null); // Details modal state const [detailsModal, setDetailsModal] = useState<{ title: string; content: React.ReactNode; } | null>(null); // InspectorClient instances for each server const [inspectorClients, setInspectorClients] = useState< Record >({}); // ManagedToolsState per server (tools list from manager, not client) const [managedToolsStates, setManagedToolsStates] = useState< Record >({}); const [managedResourcesStates, setManagedResourcesStates] = useState< Record >({}); const [managedResourceTemplatesStates, setManagedResourceTemplatesStates] = useState>({}); const [managedPromptsStates, setManagedPromptsStates] = useState< Record >({}); const [messageLogStates, setMessageLogStates] = useState< Record >({}); const [fetchRequestLogStates, setFetchRequestLogStates] = useState< Record >({}); const [stderrLogStates, setStderrLogStates] = useState< Record >({}); const [dimensions, setDimensions] = useState({ width: process.stdout.columns || 80, height: process.stdout.rows || 24, }); useEffect(() => { const updateDimensions = () => { setDimensions({ width: process.stdout.columns || 80, height: process.stdout.rows || 24, }); }; process.stdout.on("resize", updateDimensions); return () => { process.stdout.off("resize", updateDimensions); }; }, []); const serverNames = Object.keys(mcpServers); const selectedServerEntry = selectedServer ? mcpServers[selectedServer] : null; const selectedServerConfig = selectedServerEntry?.config ?? null; // Mutable redirect URL providers, keyed by server name (populated before authenticate) const redirectUrlProvidersRef = useRef< Record >({}); // Create InspectorClient and state managers for each server on mount useEffect(() => { const newClients: Record = {}; const newManagers: Record = {}; const newManagedResourcesStates: Record = {}; const newManagedResourceTemplatesStates: Record< string, ManagedResourceTemplatesState > = {}; const newManagedPromptsStates: Record = {}; const newMessageLogStates: Record = {}; const newFetchRequestLogStates: Record = {}; const newStderrLogStates: Record = {}; for (const serverName of serverNames) { if (!(serverName in inspectorClients)) { const { config: serverConfig, settings: savedSettings } = mcpServers[serverName]!; const environment: InspectorClientEnvironment = { transport: createTransportNode, logger: getTuiLogger(), }; // Per-server default `_meta` is already a JSON object (#1910) — no // pair-array flattening left to do; `{}` means "no defaults". const defaultMetadata = savedSettings?.metadata; const clientAuthOptions = buildRunnerClientAuthOptions( clientConfig, savedSettings, { clientId, clientSecret, clientMetadataUrl }, ); const opts: InspectorClientOptions = { environment, clientIdentity: { name: TUI_CLIENT_NAME, version: APP_VERSION }, pipeStderr: true, ...(savedSettings && savedSettings.requestTimeout > 0 && { timeout: savedSettings.requestTimeout, }), ...(defaultMetadata && Object.keys(defaultMetadata).length > 0 && { defaultMetadata, }), ...(savedSettings && { serverSettings: savedSettings }), // Advertise the roots configured for this server in mcp.json, as web // and the CLI do. The TUI has no roots editor, but a user who set // roots in the web UI expects them to apply to the same server here // (#1797). roots: cleanRoots(savedSettings?.roots ?? []), // Per-server protocol era (SEP §7.8) from mcp.json → SDK // versionNegotiation; absent era defaults to legacy (#1626). ...(savedSettings?.protocolEra && { versionNegotiation: eraToVersionNegotiation( savedSettings.protocolEra, ), }), ...clientAuthOptions, }; if (isOAuthCapableServerConfig(serverConfig)) { const redirectUrlProvider = redirectUrlProvidersRef.current[serverName] ?? (redirectUrlProvidersRef.current[serverName] = new MutableRedirectUrlProvider()); redirectUrlProvider.redirectUrl = formatRunnerOAuthRedirectUrl(callbackUrlConfig); environment.oauth = { storage: new NodeOAuthStorage(), navigation: new CallbackNavigation( async (url) => await openUrl(url), ), redirectUrlProvider, }; } const client = new InspectorClient(serverConfig, opts); newClients[serverName] = client; newManagers[serverName] = new ManagedToolsState(client); newManagedResourcesStates[serverName] = new ManagedResourcesState( client, ); newManagedResourceTemplatesStates[serverName] = new ManagedResourceTemplatesState(client); newManagedPromptsStates[serverName] = new ManagedPromptsState(client); newMessageLogStates[serverName] = new MessageLogState(client); newFetchRequestLogStates[serverName] = new FetchRequestLogState(client); newStderrLogStates[serverName] = new StderrLogState(client); } } if (Object.keys(newClients).length > 0) { setInspectorClients((prev) => ({ ...prev, ...newClients })); setManagedToolsStates((prev) => ({ ...prev, ...newManagers })); setManagedResourcesStates((prev) => ({ ...prev, ...newManagedResourcesStates, })); setManagedResourceTemplatesStates((prev) => ({ ...prev, ...newManagedResourceTemplatesStates, })); setManagedPromptsStates((prev) => ({ ...prev, ...newManagedPromptsStates, })); setMessageLogStates((prev) => ({ ...prev, ...newMessageLogStates })); setFetchRequestLogStates((prev) => ({ ...prev, ...newFetchRequestLogStates, })); setStderrLogStates((prev) => ({ ...prev, ...newStderrLogStates })); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [ clientConfig, clientId, clientSecret, clientMetadataUrl, callbackUrlConfig, ]); // Cleanup: destroy managers and disconnect all clients on unmount useEffect(() => { return () => { void callbackServerRef.current?.stop(); callbackServerRef.current = null; Object.values(managedToolsStates).forEach((manager) => { manager.destroy(); }); Object.values(managedResourcesStates).forEach((manager) => { manager.destroy(); }); Object.values(managedResourceTemplatesStates).forEach((manager) => { manager.destroy(); }); Object.values(managedPromptsStates).forEach((manager) => { manager.destroy(); }); Object.values(messageLogStates).forEach((manager) => { manager.destroy(); }); Object.values(fetchRequestLogStates).forEach((manager) => { manager.destroy(); }); Object.values(stderrLogStates).forEach((manager) => { manager.destroy(); }); Object.values(inspectorClients).forEach((client) => { client.disconnect().catch(() => { // Ignore errors during cleanup }); }); }; }, [ inspectorClients, managedToolsStates, managedResourcesStates, managedResourceTemplatesStates, managedPromptsStates, messageLogStates, fetchRequestLogStates, stderrLogStates, ]); // Preselect the first server on mount useEffect(() => { if (serverNames.length > 0 && selectedServer === null) { setSelectedServer(serverNames[0]); } // eslint-disable-next-line react-hooks/exhaustive-deps }, []); // Clear OAuth status when switching servers; drop step-up for other servers. useEffect(() => { setOauthStatus("idle"); setOauthMessage(null); // The header banner is server-scoped, so a failure from the server we just // left must not be read as this one's. Clearing is not enough: a switch // away and back (A → B → A) would leave both the attempt token and the // server name matching again, so the stale rejection would land on the // re-selected A. Retiring the token on every switch is what closes that. disconnectAttemptRef.current++; setDisconnectError(null); const stepUp = pendingStepUpRef.current; if (stepUp && selectedServer && stepUp.serverName !== selectedServer) { setPendingStepUp(null); } }, [selectedServer]); useEffect(() => { selectedServerRef.current = selectedServer; mcpServersRef.current = mcpServers; inspectorClientsRef.current = inspectorClients; }, [selectedServer, mcpServers, inspectorClients]); // Switch away from Auth tab when server is not OAuth-capable useEffect(() => { if ( activeTab === "auth" && selectedServerConfig && !isOAuthCapableServerConfig(selectedServerConfig) ) { setActiveTab("info"); } }, [activeTab, selectedServerConfig]); // Get InspectorClient for selected server const selectedInspectorClient = useMemo( () => (selectedServer ? inspectorClients[selectedServer] : null), [selectedServer, inspectorClients], ); // Use the hook to get reactive state from InspectorClient const { status: inspectorStatus, capabilities: inspectorCapabilities, serverInfo: inspectorServerInfo, instructions: inspectorInstructions, connect: connectInspector, disconnect: disconnectInspector, lastError: inspectorLastError, } = useInspectorClient(selectedInspectorClient); // Log state from managers (per-server) const selectedMessageLogState = useMemo( () => selectedServer && messageLogStates[selectedServer] ? messageLogStates[selectedServer] : null, [selectedServer, messageLogStates], ); const selectedFetchRequestLogState = useMemo( () => selectedServer && fetchRequestLogStates[selectedServer] ? fetchRequestLogStates[selectedServer] : null, [selectedServer, fetchRequestLogStates], ); const selectedStderrLogState = useMemo( () => selectedServer && stderrLogStates[selectedServer] ? stderrLogStates[selectedServer] : null, [selectedServer, stderrLogStates], ); const { messages: inspectorMessages } = useMessageLog( selectedMessageLogState, ); const { fetchRequests: inspectorFetchRequests } = useFetchRequestLog( selectedFetchRequestLogState, ); const { stderrLogs: inspectorStderrLogs } = useStderrLog( selectedStderrLogState, ); // Tools from ManagedToolsState (full list, auto-load on connect) const selectedManagedToolsState = useMemo( () => selectedServer && managedToolsStates[selectedServer] ? managedToolsStates[selectedServer] : null, [selectedServer, managedToolsStates], ); const { tools: managedTools } = useManagedTools( selectedInspectorClient, selectedManagedToolsState, ); // Resources, resource templates, prompts from managed state managers const selectedManagedResourcesState = useMemo( () => selectedServer && managedResourcesStates[selectedServer] ? managedResourcesStates[selectedServer] : null, [selectedServer, managedResourcesStates], ); const selectedManagedResourceTemplatesState = useMemo( () => selectedServer && managedResourceTemplatesStates[selectedServer] ? managedResourceTemplatesStates[selectedServer] : null, [selectedServer, managedResourceTemplatesStates], ); const selectedManagedPromptsState = useMemo( () => selectedServer && managedPromptsStates[selectedServer] ? managedPromptsStates[selectedServer] : null, [selectedServer, managedPromptsStates], ); const { resources: managedResources } = useManagedResources( selectedInspectorClient, selectedManagedResourcesState, ); const { resourceTemplates: managedResourceTemplates } = useManagedResourceTemplates( selectedInspectorClient, selectedManagedResourceTemplatesState, ); const { prompts: managedPrompts } = useManagedPrompts( selectedInspectorClient, selectedManagedPromptsState, ); // Connect — on 401 or mid-session auth recovery, run OAuth then retry. type TuiOAuthRunResult = | "success" | "already_authorized" | "insufficient_scope" | "skipped" | "unsupported"; const runOAuthAuthentication = useCallback( async (options?: { challenge?: AuthChallenge; authorizationUrl?: URL; /** When set, run OAuth for this server (may differ from the selected server). */ serverName?: string; }): Promise => { const serverName = options?.serverName ?? selectedServer; if (!serverName) { return "unsupported"; } const client = inspectorClientsRef.current[serverName]; const serverEntry = mcpServersRef.current[serverName]; const serverConfig = serverEntry?.config; if ( !client || !serverConfig || !isOAuthCapableServerConfig(serverConfig) ) { return "unsupported"; } if (oauthInProgressRef.current) { return "skipped"; } oauthInProgressRef.current = true; getTuiLogger().info( { server: serverName }, "OAuth authentication started", ); const existing = callbackServerRef.current; if (existing) { await existing.stop(); callbackServerRef.current = null; } const redirectUrlProvider = redirectUrlProvidersRef.current[serverName]; if (!redirectUrlProvider) { oauthInProgressRef.current = false; return "unsupported"; } try { const result = await runRunnerInteractiveOAuth({ client, redirectUrlProvider, callbackListen: callbackUrlConfig, createCallbackServer: createOAuthCallbackServer, onCallbackServer: (server) => { callbackServerRef.current = server; }, authorizationUrl: options?.authorizationUrl, authChallenge: options?.challenge, }); if (result.kind === "insufficient_scope") { setOauthStatus("error"); setOauthMessage(stepUpInsufficientScopeMessage(result.challenge)); return "insufficient_scope"; } if (result.kind === "success" || result.kind === "already_authorized") { setOauthRevision((n) => n + 1); return result.kind; } return "unsupported"; } finally { oauthInProgressRef.current = false; callbackServerRef.current = null; } }, [selectedServer, callbackUrlConfig], ); const presentStepUpForServer = useCallback( ( serverName: string, challenge: AuthChallenge, authorizationUrl: URL, enterpriseManaged?: boolean, ) => { const pending = pendingStepUpRef.current; if (pending && pending.serverName !== serverName) { setOauthMessage( "A step-up prompt is already open. Complete or decline it before continuing.", ); return; } if (pending?.serverName === serverName) { setOauthMessage( "Updated step-up request — review the scopes on the Auth tab.", ); } else { setOauthMessage(null); } setSelectedServer(serverName); setPendingStepUp({ serverName, challenge, authorizationUrl, enterpriseManaged, }); setActiveTab("auth"); setOauthStatus("idle"); setFocus("tabContentList"); }, [], ); const handleAuthRecoveryRequired = useCallback( (serverName: string, error: AuthRecoveryRequiredError) => { const serverEntry = mcpServersRef.current[serverName]; const settings = serverEntry?.settings; const client = inspectorClientsRef.current[serverName]; const needsStepUpConfirm = error.emaStepUpConfirm || isStepUpConfirmation(error.authChallenge, settings); if (needsStepUpConfirm) { void (async () => { if ( client && (await client.checkAuthChallengeSatisfied(error.authChallenge)) ) { setOauthStatus("idle"); setOauthMessage("Authorization updated. Retry your action."); setOauthRevision((n) => n + 1); return; } presentStepUpForServer( serverName, error.authChallenge, error.authorizationUrl, settings?.enterpriseManaged, ); })(); return; } void (async () => { if ( client && (await client.checkAuthChallengeSatisfied(error.authChallenge)) ) { setOauthStatus("idle"); setOauthMessage("Authorization updated. Retry your action."); setOauthRevision((n) => n + 1); return; } const needsSwitch = selectedServerRef.current !== serverName; if (needsSwitch) { setSelectedServer(serverName); setActiveTab("auth"); setOauthMessage( `Authentication required for "${serverName}". Re-authenticating…`, ); } else { setOauthMessage(null); } setOauthStatus("authenticating"); try { const oauthResult = await runOAuthAuthentication({ challenge: error.authChallenge, authorizationUrl: error.authorizationUrl, serverName, }); if ( oauthResult === "success" || oauthResult === "already_authorized" ) { setOauthStatus("idle"); setOauthMessage("Authorization updated. Retry your action."); } else if (oauthResult === "skipped") { setOauthStatus("idle"); setOauthMessage("OAuth already in progress."); } } catch (authErr) { const authMsg = authErr instanceof Error ? authErr.message : String(authErr); setOauthStatus("error"); setOauthMessage(authMsg); } })(); }, [presentStepUpForServer, runOAuthAuthentication], ); const onAuthRecoveryRequired = useCallback( (error: AuthRecoveryRequiredError) => { if (selectedServer) { handleAuthRecoveryRequired(selectedServer, error); } }, [selectedServer, handleAuthRecoveryRequired], ); const handleConnect = useCallback(async () => { if (!selectedServer || !selectedInspectorClient || !selectedServerConfig) { return; } // A connect attempt supersedes whatever the last disconnect reported — // including one still in flight, which is what the counter bump retires. disconnectAttemptRef.current++; setDisconnectError(null); const finishConnect = async () => { await connectInspector(); setConnectError(null); setOauthStatus("idle"); setOauthMessage(null); setOauthRevision((n) => n + 1); }; try { await finishConnect(); } catch (err) { const msg = err instanceof Error ? err.message : String(err); setConnectError(msg); if (isEmaClientNotConfiguredError(err)) { setOauthStatus("error"); setOauthMessage(err.message); return; } if ( isOAuthCapableServerConfig(selectedServerConfig) && isUnauthorizedError(err) ) { try { setOauthStatus("authenticating"); setOauthMessage(null); await disconnectInspector(); const oauthResult = await runOAuthAuthentication(); if ( oauthResult === "success" || oauthResult === "already_authorized" ) { await finishConnect(); } else if (oauthResult === "skipped") { setOauthStatus("idle"); setOauthMessage("OAuth already in progress."); } } catch (authErr) { if (authErr instanceof AuthRecoveryRequiredError) { handleAuthRecoveryRequired(selectedServer, authErr); return; } const authMsg = authErr instanceof Error ? authErr.message : String(authErr); setConnectError(authMsg); if (isEmaClientNotConfiguredError(authErr)) { setOauthStatus("error"); setOauthMessage(authErr.message); return; } setOauthStatus("error"); setOauthMessage(authMsg); } return; } if (err instanceof AuthRecoveryRequiredError && selectedServer) { handleAuthRecoveryRequired(selectedServer, err); return; } } }, [ selectedServer, selectedInspectorClient, selectedServerConfig, connectInspector, disconnectInspector, runOAuthAuthentication, handleAuthRecoveryRequired, ]); useEffect(() => { const cleanups: Array<() => void> = []; for (const [serverName, client] of Object.entries(inspectorClients)) { const onAmbient = (): void => { if (selectedServerRef.current !== serverName) return; setOauthStatus("idle"); setOauthMessage("Refreshing authorization…"); }; const onRecovered = (): void => { if (selectedServerRef.current !== serverName) return; setOauthMessage(null); setOauthRevision((n) => n + 1); }; const onInteractive = ( event: TypedEvent<"authChallengeInteractive">, ): void => { handleAuthRecoveryRequired( serverName, new AuthRecoveryRequiredError( event.detail.authorizationUrl, event.detail.challenge, ), ); }; const onOAuthError = (event: TypedEvent<"oauthError">): void => { if (selectedServerRef.current !== serverName) return; const message = event.detail.error instanceof Error ? event.detail.error.message : String(event.detail.error); setOauthStatus("error"); setOauthMessage(message); }; client.addEventListener("authChallengeAmbient", onAmbient); client.addEventListener("authChallengeRecovered", onRecovered); client.addEventListener("authChallengeInteractive", onInteractive); client.addEventListener("oauthError", onOAuthError); cleanups.push(() => { client.removeEventListener("authChallengeAmbient", onAmbient); client.removeEventListener("authChallengeRecovered", onRecovered); client.removeEventListener("authChallengeInteractive", onInteractive); client.removeEventListener("oauthError", onOAuthError); }); } return () => { for (const cleanup of cleanups) { cleanup(); } }; }, [inspectorClients, handleAuthRecoveryRequired]); // Disconnect handler const handleDisconnect = useCallback(async () => { if (!selectedServer) return; // Claim this attempt before awaiting. A disconnect can still be in flight // when the user switches servers or presses 'd' again, and it is the // *stale* one that usually rejects — so the catch below must be able to // tell "my failure" from "one that has since been superseded", or server // A's error lands in server B's header. `handleConnect` bumps the same // counter for the same reason. const attempt = ++disconnectAttemptRef.current; const attemptServer = selectedServer; // Clear first, so a retry that succeeds leaves no stale message behind. setDisconnectError(null); try { await disconnectInspector(); // InspectorClient will update status automatically, and data is preserved } catch (err) { // Nothing above this catches: the only caller is the key handler, which // cannot await, so without this the rejection escapes unhandled. A // superseded one is still owned here — just not published. if ( disconnectAttemptRef.current !== attempt || selectedServerRef.current !== attemptServer ) { return; } setDisconnectError(err instanceof Error ? err.message : String(err)); } }, [selectedServer, disconnectInspector]); const handleClearOAuth = useCallback(async () => { if (!selectedInspectorClient) return; await selectedInspectorClient.clearOAuthTokens(); setOauthStatus("idle"); setOauthMessage(null); setConnectError(null); if (inspectorStatus === "connected" || inspectorStatus === "connecting") { await disconnectInspector(); } setOauthRevision((n) => n + 1); }, [selectedInspectorClient, inspectorStatus, disconnectInspector]); // Build current server state from InspectorClient data (tools from ManagedToolsState) const currentServerState = useMemo(() => { if (!selectedServer) return null; return { status: inspectorStatus, error: connectError ?? inspectorLastError ?? null, capabilities: inspectorCapabilities, serverInfo: inspectorServerInfo, instructions: inspectorInstructions, resources: managedResources, resourceTemplates: managedResourceTemplates, prompts: managedPrompts, tools: managedTools, stderrLogs: inspectorStderrLogs, // InspectorClient manages this }; }, [ selectedServer, inspectorStatus, connectError, inspectorLastError, inspectorCapabilities, inspectorServerInfo, inspectorInstructions, managedResources, managedResourceTemplates, managedPrompts, managedTools, inspectorStderrLogs, ]); const renderResourceDetails = ( resource: | Resource | { content: import("@modelcontextprotocol/client").ReadResourceResult; }, ) => ( <> {"uri" in resource && resource.description && ( <> {resource.description.split("\n").map((line: string, idx: number) => ( {line} ))} )} {"uri" in resource && resource.uri && ( URI: {resource.uri} )} {"mimeType" in resource && resource.mimeType && ( MIME Type: {resource.mimeType} )} Full JSON: {JSON.stringify(resource, null, 2)} ); const renderPromptDetails = ( prompt: Prompt & { result?: GetPromptResult }, ) => ( <> {prompt.description && ( <> {prompt.description.split("\n").map((line: string, idx: number) => ( {line} ))} )} {prompt.arguments && prompt.arguments.length > 0 && ( <> Arguments: {prompt.arguments.map((arg: PromptArgument, idx: number) => ( - {arg.name}:{" "} {arg.description ?? (arg as { type?: string }).type ?? "string"} ))} )} Full JSON: {JSON.stringify(prompt, null, 2)} ); const renderToolDetails = (tool: Tool) => ( <> {tool.description && ( <> {tool.description.split("\n").map((line: string, idx: number) => ( {line} ))} )} {tool.inputSchema && ( Input Schema: {JSON.stringify(tool.inputSchema, null, 2)} )} Full JSON: {JSON.stringify(tool, null, 2)} ); const renderRequestDetails = (request: FetchRequestEntry) => ( <> {request.method} {request.url} Category:{" "} {request.category === "auth" ? "auth" : "transport"} {request.responseStatus !== undefined ? ( Status: {request.responseStatus} {request.responseStatusText || ""} ) : request.error ? ( Error: {request.error} ) : null} {request.duration !== undefined && ( {request.timestamp.toLocaleTimeString()} ({request.duration}ms) )} Request Headers: {Object.entries(request.requestHeaders).map(([key, value]) => ( {key}: {value} ))} {request.requestBody && ( <> Request Body: {(() => { try { const parsed = JSON.parse(request.requestBody); return JSON.stringify(parsed, null, 2) .split("\n") .map((line: string, idx: number) => ( {line} )); } catch { return ( {request.requestBody} ); } })()} )} {request.responseHeaders && Object.keys(request.responseHeaders).length > 0 && ( <> Response Headers: {Object.entries(request.responseHeaders).map(([key, value]) => ( {key}: {value} ))} )} {request.responseBody && ( <> Response Body: {(() => { try { const parsed = JSON.parse(request.responseBody); return JSON.stringify(parsed, null, 2) .split("\n") .map((line: string, idx: number) => ( {line} )); } catch { return ( {request.responseBody} ); } })()} )} ); const renderMessageDetails = (message: MessageEntry) => ( <> Direction: {message.direction} {message.timestamp.toLocaleTimeString()} {message.duration !== undefined && ` (${message.duration}ms)`} {message.direction === "request" ? ( <> Request: {JSON.stringify(message.message, null, 2)} {message.response && ( Response: {JSON.stringify(message.response, null, 2)} )} ) : ( {message.direction === "response" ? "Response:" : "Notification:"} {JSON.stringify(message.message, null, 2)} )} ); // Update tab counts when selected server changes or InspectorClient state changes // Just reflect InspectorClient state - don't try to be clever useEffect(() => { if (!selectedServer) { return; } setTabCounts({ resources: managedResources.length || 0, prompts: managedPrompts.length || 0, tools: managedTools.length || 0, messages: inspectorMessages.length || 0, requests: inspectorFetchRequests.length || 0, logging: inspectorStderrLogs.length || 0, }); }, [ selectedServer, managedResources, managedPrompts, managedTools, inspectorMessages, inspectorFetchRequests, inspectorStderrLogs, ]); // Keep focus state consistent when switching tabs (only adjust if focus is already in tab content) useEffect(() => { if (activeTab === "messages") { if (focus === "tabContentList" || focus === "tabContentDetails") { setFocus("messagesList"); } } else if (activeTab === "requests") { if (focus === "tabContentList" || focus === "tabContentDetails") { setFocus("requestsList"); } } else { if ( focus === "messagesList" || focus === "messagesDetail" || focus === "requestsList" || focus === "requestsDetail" ) { setFocus("tabContentList"); } } // Intentionally not depending on focus to avoid loops // eslint-disable-next-line react-hooks/exhaustive-deps }, [activeTab]); // Switch away from logging tab if server is not stdio useEffect(() => { if (activeTab === "logging" && selectedServer) { const client = inspectorClients[selectedServer]; if (client && client.getServerType() !== "stdio") { setActiveTab("info"); } } }, [selectedServer, activeTab, inspectorClients]); useInput((input: string, key: Key) => { // Don't process input when modal is open if (toolTestModal || resourceTestModal || promptTestModal || detailsModal) { return; } if (key.ctrl && input === "c") { exit(); } // Exit accelerators if (key.escape) { exit(); } // Tab switching with accelerator keys (letter from the tab label) const showAuthTab = !!selectedServer && !!selectedServerConfig && isOAuthCapableServerConfig(selectedServerConfig); const showLoggingTab = !!selectedServer && inspectorClients[selectedServer]?.getServerType() === "stdio"; const showRequestsTab = !!selectedServer && (inspectorClients[selectedServer]?.getServerType() === "sse" || inspectorClients[selectedServer]?.getServerType() === "streamable-http"); const tabAccelerators: Record = Object.fromEntries( tabList .filter((tab: { id: TabType }) => { if (tab.id === "auth" && !showAuthTab) return false; if (tab.id === "logging" && !showLoggingTab) return false; if (tab.id === "requests" && !showRequestsTab) return false; return true; }) .map((tab: { id: TabType; label: string; accelerator: string }) => [ tab.accelerator, tab.id, ]), ); if (tabAccelerators[input.toLowerCase()]) { const nextTab = tabAccelerators[input.toLowerCase()]!; const authStepUpAccelerator = input.toLowerCase() === "a" && nextTab === "auth" && activeTab === "auth" && pendingStepUp?.serverName === selectedServer; if (!authStepUpAccelerator) { setActiveTab(nextTab); setFocus(nextTab === "auth" ? "tabContentList" : "tabs"); } } else if (key.tab && !key.shift) { // Flat focus order: servers -> tabs -> list -> details -> wrap to servers const focusOrder: FocusArea[] = activeTab === "messages" ? ["serverList", "tabs", "messagesList", "messagesDetail"] : activeTab === "requests" ? ["serverList", "tabs", "requestsList", "requestsDetail"] : ["serverList", "tabs", "tabContentList", "tabContentDetails"]; const currentIndex = focusOrder.indexOf(focus); const nextIndex = (currentIndex + 1) % focusOrder.length; setFocus(focusOrder[nextIndex]); } else if (key.tab && key.shift) { // Reverse order: servers <- tabs <- list <- details <- wrap to servers const focusOrder: FocusArea[] = activeTab === "messages" ? ["serverList", "tabs", "messagesList", "messagesDetail"] : activeTab === "requests" ? ["serverList", "tabs", "requestsList", "requestsDetail"] : ["serverList", "tabs", "tabContentList", "tabContentDetails"]; const currentIndex = focusOrder.indexOf(focus); const prevIndex = currentIndex > 0 ? currentIndex - 1 : focusOrder.length - 1; setFocus(focusOrder[prevIndex]); } else if (key.upArrow || key.downArrow) { // Arrow keys only work in the focused pane if (focus === "serverList") { // Arrow key navigation for server list if (key.upArrow) { if (selectedServer === null) { setSelectedServer(serverNames[serverNames.length - 1] || null); } else { const currentIndex = serverNames.indexOf(selectedServer); const newIndex = currentIndex > 0 ? currentIndex - 1 : serverNames.length - 1; setSelectedServer(serverNames[newIndex] || null); } } else if (key.downArrow) { if (selectedServer === null) { setSelectedServer(serverNames[0] || null); } else { const currentIndex = serverNames.indexOf(selectedServer); const newIndex = currentIndex < serverNames.length - 1 ? currentIndex + 1 : 0; setSelectedServer(serverNames[newIndex] || null); } } return; // Handled, don't let other handlers process } // If focus is on tabs, tabContentList, tabContentDetails, messagesList, or messagesDetail, // arrow keys will be handled by those components - don't do anything here } else if (focus === "tabs" && (key.leftArrow || key.rightArrow)) { // Left/Right arrows switch tabs when tabs are focused const showAuthTab = !!selectedServer && !!selectedServerConfig && isOAuthCapableServerConfig(selectedServerConfig); const showLoggingTab = !!selectedServer && inspectorClients[selectedServer]?.getServerType() === "stdio"; const showRequestsTab = !!selectedServer && (inspectorClients[selectedServer]?.getServerType() === "sse" || inspectorClients[selectedServer]?.getServerType() === "streamable-http"); const allTabs: TabType[] = [ "info", "auth", "resources", "prompts", "tools", "messages", "requests", "logging", ]; const tabs = allTabs.filter((t) => { if (t === "auth" && !showAuthTab) return false; if (t === "logging" && !showLoggingTab) return false; if (t === "requests" && !showRequestsTab) return false; return true; }); const currentIndex = tabs.indexOf(activeTab); if (key.leftArrow) { const newIndex = currentIndex > 0 ? currentIndex - 1 : tabs.length - 1; setActiveTab(tabs[newIndex]); } else if (key.rightArrow) { const newIndex = currentIndex < tabs.length - 1 ? currentIndex + 1 : 0; setActiveTab(tabs[newIndex]); } } // Accelerator keys for connect/disconnect (work from anywhere) if (selectedServer) { if ( input.toLowerCase() === "c" && (inspectorStatus === "disconnected" || inspectorStatus === "error") ) { // Both handlers own their failures internally (each ends in a catch // that surfaces the message), so there is nothing left for a key // handler — which cannot await — to do with the promise. void handleConnect(); } else if ( input.toLowerCase() === "d" && (inspectorStatus === "connected" || inspectorStatus === "connecting") ) { void handleDisconnect(); } } }); // Calculate layout dimensions const headerHeight = 1; const tabsHeight = 1; // Server details will be flexible - calculate remaining space for content const availableHeight = dimensions.height - headerHeight - tabsHeight; // Reserve space for server details (will grow as needed, but we'll use flexGrow) const serverDetailsMinHeight = 3; const contentHeight = availableHeight - serverDetailsMinHeight; const serverListWidth = Math.floor(dimensions.width * 0.3); const contentWidth = dimensions.width - serverListWidth; const getStatusColor = (status: string) => { switch (status) { case "connected": return "green"; case "connecting": return "yellow"; case "error": return "red"; default: return "gray"; } }; const getStatusSymbol = (status: string) => { switch (status) { case "connected": return "●"; case "connecting": return "◐"; case "error": return "✗"; default: return "○"; } }; return ( {/* Header row across the top */} {APP_NAME} - {APP_DESCRIPTION} v{APP_VERSION} {/* Main content area */} {/* Left column - Server list */} MCP Servers {serverNames.map((serverName) => { const isSelected = selectedServer === serverName; return ( {isSelected ? "▶ " : " "} {serverName} ); })} {/* Fixed footer */} ESC to exit {/* Right column - Server details, Tabs and content */} {/* Server Details - Flexible height */} {selectedServer} {currentServerState && ( <> {getStatusSymbol(currentServerState.status)}{" "} {currentServerState.status} {(currentServerState?.status === "disconnected" || currentServerState?.status === "error") && ( [Connect] )} {(currentServerState?.status === "connected" || currentServerState?.status === "connecting") && ( [Disconnect] )} )} {oauthStatus === "authenticating" && ( OAuth: authenticating… )} {oauthStatus === "error" && oauthMessage && ( OAuth: {oauthMessage} )} {disconnectError && ( Disconnect failed: {disconnectError} )} {/* Tabs */} { const serverType = inspectorClients[selectedServer].getServerType(); return ( serverType === "sse" || serverType === "streamable-http" ); })() : false } /> {/* Tab Content */} {activeTab === "info" && ( )} {activeTab === "auth" && selectedServer && selectedServerConfig && isOAuthCapableServerConfig(selectedServerConfig) ? ( { if (!pendingStepUp || !selectedInspectorClient) return; const { challenge, authorizationUrl, enterpriseManaged } = pendingStepUp; setPendingStepUp(null); setOauthStatus("authenticating"); void (async () => { try { if (enterpriseManaged) { const outcome = await selectedInspectorClient.handleAuthChallenge( challenge, { confirmedStepUp: true }, ); if (outcome.kind === "satisfied") { await disconnectInspector().catch(() => {}); await connectInspector(); setOauthStatus("idle"); setOauthMessage( "Step-up authorization succeeded. Retry your action.", ); setOauthRevision((n) => n + 1); return; } if (outcome.kind === "interactive") { const oauthResult = await runOAuthAuthentication({ challenge: outcome.challenge, authorizationUrl: outcome.authorizationUrl, }); if ( oauthResult === "success" || oauthResult === "already_authorized" ) { setOauthStatus("idle"); setOauthMessage( "Step-up authorization succeeded. Retry your action.", ); } else if (oauthResult === "skipped") { setOauthStatus("idle"); setOauthMessage("OAuth already in progress."); } return; } if (outcome.kind === "failed") { setOauthStatus("error"); setOauthMessage( emaStepUpFailureMessage(outcome.error.message), ); return; } setOauthStatus("idle"); setOauthMessage( "Step-up authorization succeeded. Retry your action.", ); return; } const oauthResult = await runOAuthAuthentication({ challenge, authorizationUrl, }); if ( oauthResult === "success" || oauthResult === "already_authorized" ) { setOauthStatus("idle"); setOauthMessage( "Step-up authorization succeeded. Retry your action.", ); } else if (oauthResult === "skipped") { setOauthStatus("idle"); setOauthMessage("OAuth already in progress."); } } catch (authErr) { const authMsg = authErr instanceof Error ? authErr.message : String(authErr); setOauthStatus("error"); setOauthMessage(authMsg); } })(); }} onCancelStepUp={() => { setPendingStepUp(null); setOauthMessage("Authorization cancelled."); }} width={contentWidth} height={contentHeight} focused={ focus === "tabContentList" || focus === "tabContentDetails" } onClearOAuth={() => { void handleClearOAuth(); }} connectionStatus={inspectorStatus} /> ) : null} {activeTab === "resources" && currentServerState?.status === "connected" && selectedInspectorClient ? ( setTabCounts((prev) => ({ ...prev, resources: count })) } focusedPane={ focus === "tabContentDetails" ? "details" : focus === "tabContentList" ? "list" : null } onViewDetails={(resource) => setDetailsModal({ title: `Resource: ${"uri" in resource ? resource.name || resource.uri || "Unknown" : "Resource content"}`, content: renderResourceDetails(resource), }) } onFetchResource={() => { // Resource fetching is handled internally by ResourcesTab // This callback is just for triggering the fetch }} onFetchTemplate={(template) => { setResourceTestModal({ template, inspectorClient: selectedInspectorClient, }); }} onAuthRecoveryRequired={onAuthRecoveryRequired} modalOpen={ !!( toolTestModal || resourceTestModal || promptTestModal || detailsModal ) } /> ) : activeTab === "prompts" && currentServerState?.status === "connected" && selectedInspectorClient ? ( setTabCounts((prev) => ({ ...prev, prompts: count })) } focusedPane={ focus === "tabContentDetails" ? "details" : focus === "tabContentList" ? "list" : null } onViewDetails={(prompt) => setDetailsModal({ title: `Prompt: ${prompt.name || "Unknown"}`, content: renderPromptDetails(prompt), }) } onFetchPrompt={(prompt) => { setPromptTestModal({ prompt, inspectorClient: selectedInspectorClient, }); }} onAuthRecoveryRequired={onAuthRecoveryRequired} modalOpen={ !!( toolTestModal || resourceTestModal || promptTestModal || detailsModal ) } /> ) : activeTab === "tools" && currentServerState?.status === "connected" && selectedInspectorClient ? ( setTabCounts((prev) => ({ ...prev, tools: count })) } focusedPane={ focus === "tabContentDetails" ? "details" : focus === "tabContentList" ? "list" : null } onTestTool={(tool) => setToolTestModal({ tool, inspectorClient: selectedInspectorClient, }) } onViewDetails={(tool) => setDetailsModal({ title: `Tool: ${tool.name || "Unknown"}`, content: renderToolDetails(tool), }) } modalOpen={!!(toolTestModal || detailsModal)} /> ) : activeTab === "messages" && selectedInspectorClient ? ( setTabCounts((prev) => ({ ...prev, messages: count })) } focusedPane={ focus === "messagesDetail" ? "details" : focus === "messagesList" ? "messages" : null } modalOpen={!!(toolTestModal || detailsModal)} onViewDetails={(message) => { const label = message.direction === "request" && "method" in message.message ? message.message.method : message.direction === "response" ? "Response" : message.direction === "notification" && "method" in message.message ? message.message.method : "Message"; setDetailsModal({ title: `Message: ${label}`, content: renderMessageDetails(message), }); }} /> ) : activeTab === "requests" && selectedInspectorClient && (inspectorStatus === "connected" || inspectorFetchRequests.length > 0) ? ( setTabCounts((prev) => ({ ...prev, requests: count })) } focusedPane={ focus === "requestsDetail" ? "details" : focus === "requestsList" ? "requests" : null } modalOpen={!!(toolTestModal || detailsModal)} onViewDetails={(request) => { setDetailsModal({ title: `Request: ${request.method} ${request.url}`, content: renderRequestDetails(request), }); }} /> ) : activeTab === "logging" && selectedInspectorClient ? ( setTabCounts((prev) => ({ ...prev, logging: count })) } focused={ focus === "tabContentList" || focus === "tabContentDetails" } /> ) : null} {/* Tool Test Modal - rendered at App level for full screen overlay */} {toolTestModal && ( setToolTestModal(null)} onAuthRecoveryRequired={(error) => { onAuthRecoveryRequired(error); setToolTestModal(null); }} /> )} {/* Resource Test Modal - rendered at App level for full screen overlay */} {resourceTestModal && ( setResourceTestModal(null)} onAuthRecoveryRequired={(error) => { onAuthRecoveryRequired(error); setResourceTestModal(null); }} /> )} {promptTestModal && ( setPromptTestModal(null)} onAuthRecoveryRequired={(error) => { onAuthRecoveryRequired(error); setPromptTestModal(null); }} /> )} {/* Details Modal - rendered at App level for full screen overlay */} {detailsModal && ( setDetailsModal(null)} /> )} ); } export default App;