inspector/clients/tui/src/App.tsx
cliffhall 531cf84e60 Merge branch 'v2/main' into v2/fix/2067-proxy-undici
One conflict, in `clients/tui/__tests__/App.test.tsx`: both sides added an
`opts` capture to the test harness's `FakeClient` for different fields —
`defaultMetadata` on v2/main (#1910), `environment.fetch` here (#2067) — so the
resolution is the union of the two, keeping both rationales and both tests.

Everything else auto-merged. `npm run ci` passes on the result.

Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-23 23:34:07 -04:00

2108 lines
72 KiB
TypeScript

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 {
createProxyFetch,
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<string, TuiServer>;
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<string | null>(null);
const [activeTab, setActiveTab] = useState<TabType>("info");
const [focus, setFocus] = useState<FocusArea>("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<string | null>(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<string | null>(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<string | null>(null);
const oauthInProgressRef = useRef(false);
const callbackServerRef = useRef<OAuthCallbackServer | null>(null);
const selectedServerRef = useRef<string | null>(null);
const mcpServersRef = useRef(mcpServers);
const inspectorClientsRef = useRef<Record<string, InspectorClient>>({});
// 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<string, InspectorClient>
>({});
// ManagedToolsState per server (tools list from manager, not client)
const [managedToolsStates, setManagedToolsStates] = useState<
Record<string, ManagedToolsState>
>({});
const [managedResourcesStates, setManagedResourcesStates] = useState<
Record<string, ManagedResourcesState>
>({});
const [managedResourceTemplatesStates, setManagedResourceTemplatesStates] =
useState<Record<string, ManagedResourceTemplatesState>>({});
const [managedPromptsStates, setManagedPromptsStates] = useState<
Record<string, ManagedPromptsState>
>({});
const [messageLogStates, setMessageLogStates] = useState<
Record<string, MessageLogState>
>({});
const [fetchRequestLogStates, setFetchRequestLogStates] = useState<
Record<string, FetchRequestLogState>
>({});
const [stderrLogStates, setStderrLogStates] = useState<
Record<string, StderrLogState>
>({});
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<string, MutableRedirectUrlProvider>
>({});
// Create InspectorClient and state managers for each server on mount
useEffect(() => {
const newClients: Record<string, InspectorClient> = {};
const newManagers: Record<string, ManagedToolsState> = {};
const newManagedResourcesStates: Record<string, ManagedResourcesState> = {};
const newManagedResourceTemplatesStates: Record<
string,
ManagedResourceTemplatesState
> = {};
const newManagedPromptsStates: Record<string, ManagedPromptsState> = {};
const newMessageLogStates: Record<string, MessageLogState> = {};
const newFetchRequestLogStates: Record<string, FetchRequestLogState> = {};
const newStderrLogStates: Record<string, StderrLogState> = {};
for (const serverName of serverNames) {
if (!(serverName in inspectorClients)) {
const { config: serverConfig, settings: savedSettings } =
mcpServers[serverName]!;
const environment: InspectorClientEnvironment = {
transport: createTransportNode,
logger: getTuiLogger(),
// Bottom of the fetch stack, so InspectorClient's wrappers compose
// over it and OAuth requests are proxied too (#2067). Undefined when
// no proxy env var is set.
fetch: createProxyFetch(),
};
// 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<TuiOAuthRunResult> => {
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) => (
<Box
key={`desc-${idx}`}
marginTop={idx === 0 ? 0 : 0}
flexShrink={0}
>
<Text dimColor>{line}</Text>
</Box>
))}
</>
)}
{"uri" in resource && resource.uri && (
<Box marginTop={1} flexShrink={0}>
<Text bold>URI:</Text>
<Box paddingLeft={2}>
<Text dimColor>{resource.uri}</Text>
</Box>
</Box>
)}
{"mimeType" in resource && resource.mimeType && (
<Box marginTop={1} flexShrink={0}>
<Text bold>MIME Type:</Text>
<Box paddingLeft={2}>
<Text dimColor>{resource.mimeType}</Text>
</Box>
</Box>
)}
<Box marginTop={1} flexShrink={0} flexDirection="column">
<Text bold>Full JSON:</Text>
<Box paddingLeft={2}>
<Text dimColor>{JSON.stringify(resource, null, 2)}</Text>
</Box>
</Box>
</>
);
const renderPromptDetails = (
prompt: Prompt & { result?: GetPromptResult },
) => (
<>
{prompt.description && (
<>
{prompt.description.split("\n").map((line: string, idx: number) => (
<Box
key={`desc-${idx}`}
marginTop={idx === 0 ? 0 : 0}
flexShrink={0}
>
<Text dimColor>{line}</Text>
</Box>
))}
</>
)}
{prompt.arguments && prompt.arguments.length > 0 && (
<>
<Box marginTop={1} flexShrink={0}>
<Text bold>Arguments:</Text>
</Box>
{prompt.arguments.map((arg: PromptArgument, idx: number) => (
<Box
key={`arg-${idx}`}
marginTop={1}
paddingLeft={2}
flexShrink={0}
>
<Text dimColor>
- {arg.name}:{" "}
{arg.description ?? (arg as { type?: string }).type ?? "string"}
</Text>
</Box>
))}
</>
)}
<Box marginTop={1} flexShrink={0} flexDirection="column">
<Text bold>Full JSON:</Text>
<Box paddingLeft={2}>
<Text dimColor>{JSON.stringify(prompt, null, 2)}</Text>
</Box>
</Box>
</>
);
const renderToolDetails = (tool: Tool) => (
<>
{tool.description && (
<>
{tool.description.split("\n").map((line: string, idx: number) => (
<Box
key={`desc-${idx}`}
marginTop={idx === 0 ? 0 : 0}
flexShrink={0}
>
<Text dimColor>{line}</Text>
</Box>
))}
</>
)}
{tool.inputSchema && (
<Box marginTop={1} flexShrink={0} flexDirection="column">
<Text bold>Input Schema:</Text>
<Box paddingLeft={2}>
<Text dimColor>{JSON.stringify(tool.inputSchema, null, 2)}</Text>
</Box>
</Box>
)}
<Box marginTop={1} flexShrink={0} flexDirection="column">
<Text bold>Full JSON:</Text>
<Box paddingLeft={2}>
<Text dimColor>{JSON.stringify(tool, null, 2)}</Text>
</Box>
</Box>
</>
);
const renderRequestDetails = (request: FetchRequestEntry) => (
<>
<Box flexShrink={0}>
<Text bold>
{request.method} {request.url}
</Text>
</Box>
<Box marginTop={1} flexShrink={0}>
<Text bold>
Category:{" "}
<Text>{request.category === "auth" ? "auth" : "transport"}</Text>
</Text>
</Box>
{request.responseStatus !== undefined ? (
<Box marginTop={1} flexShrink={0}>
<Text bold>
Status: {request.responseStatus} {request.responseStatusText || ""}
</Text>
</Box>
) : request.error ? (
<Box marginTop={1} flexShrink={0}>
<Text bold color="red">
Error: {request.error}
</Text>
</Box>
) : null}
{request.duration !== undefined && (
<Box marginTop={1} flexShrink={0}>
<Text dimColor>
{request.timestamp.toLocaleTimeString()} ({request.duration}ms)
</Text>
</Box>
)}
<Box marginTop={1} flexShrink={0}>
<Text bold>Request Headers:</Text>
{Object.entries(request.requestHeaders).map(([key, value]) => (
<Box key={key} marginTop={0} paddingLeft={2} flexShrink={0}>
<Text dimColor>
{key}: {value}
</Text>
</Box>
))}
</Box>
{request.requestBody && (
<>
<Box marginTop={1} flexShrink={0}>
<Text bold>Request Body:</Text>
</Box>
{(() => {
try {
const parsed = JSON.parse(request.requestBody);
return JSON.stringify(parsed, null, 2)
.split("\n")
.map((line: string, idx: number) => (
<Box
key={`req-body-${idx}`}
marginTop={idx === 0 ? 1 : 0}
paddingLeft={2}
flexShrink={0}
>
<Text dimColor>{line}</Text>
</Box>
));
} catch {
return (
<Box marginTop={1} paddingLeft={2} flexShrink={0}>
<Text dimColor>{request.requestBody}</Text>
</Box>
);
}
})()}
</>
)}
{request.responseHeaders &&
Object.keys(request.responseHeaders).length > 0 && (
<>
<Box marginTop={1} flexShrink={0}>
<Text bold>Response Headers:</Text>
</Box>
{Object.entries(request.responseHeaders).map(([key, value]) => (
<Box key={key} marginTop={0} paddingLeft={2} flexShrink={0}>
<Text dimColor>
{key}: {value}
</Text>
</Box>
))}
</>
)}
{request.responseBody && (
<>
<Box marginTop={1} flexShrink={0}>
<Text bold>Response Body:</Text>
</Box>
{(() => {
try {
const parsed = JSON.parse(request.responseBody);
return JSON.stringify(parsed, null, 2)
.split("\n")
.map((line: string, idx: number) => (
<Box
key={`resp-body-${idx}`}
marginTop={idx === 0 ? 1 : 0}
paddingLeft={2}
flexShrink={0}
>
<Text dimColor>{line}</Text>
</Box>
));
} catch {
return (
<Box marginTop={1} paddingLeft={2} flexShrink={0}>
<Text dimColor>{request.responseBody}</Text>
</Box>
);
}
})()}
</>
)}
</>
);
const renderMessageDetails = (message: MessageEntry) => (
<>
<Box flexShrink={0}>
<Text bold>Direction: {message.direction}</Text>
</Box>
<Box marginTop={1} flexShrink={0}>
<Text dimColor>
{message.timestamp.toLocaleTimeString()}
{message.duration !== undefined && ` (${message.duration}ms)`}
</Text>
</Box>
{message.direction === "request" ? (
<>
<Box marginTop={1} flexShrink={0} flexDirection="column">
<Text bold>Request:</Text>
<Box paddingLeft={2}>
<Text dimColor>{JSON.stringify(message.message, null, 2)}</Text>
</Box>
</Box>
{message.response && (
<Box marginTop={1} flexShrink={0} flexDirection="column">
<Text bold>Response:</Text>
<Box paddingLeft={2}>
<Text dimColor>
{JSON.stringify(message.response, null, 2)}
</Text>
</Box>
</Box>
)}
</>
) : (
<Box marginTop={1} flexShrink={0} flexDirection="column">
<Text bold>
{message.direction === "response" ? "Response:" : "Notification:"}
</Text>
<Box paddingLeft={2}>
<Text dimColor>{JSON.stringify(message.message, null, 2)}</Text>
</Box>
</Box>
)}
</>
);
// 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<string, TabType> = 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 (
<Box
flexDirection="column"
width={dimensions.width}
height={dimensions.height}
>
{/* Header row across the top */}
<Box
width={dimensions.width}
height={headerHeight}
borderStyle="single"
borderTop={false}
borderLeft={false}
borderRight={false}
paddingX={1}
justifyContent="space-between"
alignItems="center"
>
<Box>
<Text bold color="cyan">
{APP_NAME}
</Text>
<Text dimColor> - {APP_DESCRIPTION}</Text>
</Box>
<Text dimColor>v{APP_VERSION}</Text>
</Box>
{/* Main content area */}
<Box
flexDirection="row"
height={availableHeight + tabsHeight}
width={dimensions.width}
>
{/* Left column - Server list */}
<Box
width={serverListWidth}
height={availableHeight + tabsHeight}
borderStyle="single"
borderTop={false}
borderBottom={false}
borderLeft={false}
borderRight={true}
flexDirection="column"
paddingX={1}
>
<Box marginTop={1} marginBottom={1}>
<Text
bold
backgroundColor={focus === "serverList" ? "yellow" : undefined}
>
MCP Servers
</Text>
</Box>
<Box flexDirection="column" flexGrow={1}>
{serverNames.map((serverName) => {
const isSelected = selectedServer === serverName;
return (
<Box key={serverName} paddingY={0}>
<Text>
{isSelected ? "▶ " : " "}
{serverName}
</Text>
</Box>
);
})}
</Box>
{/* Fixed footer */}
<Box
flexShrink={0}
height={1}
justifyContent="center"
backgroundColor="gray"
>
<Text bold color="white">
ESC to exit
</Text>
</Box>
</Box>
{/* Right column - Server details, Tabs and content */}
<Box
flexGrow={1}
height={availableHeight + tabsHeight}
flexDirection="column"
>
{/* Server Details - Flexible height */}
<Box
width={contentWidth}
borderStyle="single"
borderTop={false}
borderLeft={false}
borderRight={false}
borderBottom={true}
paddingX={1}
paddingY={1}
flexDirection="column"
flexShrink={0}
>
<Box flexDirection="column">
<Box
flexDirection="row"
justifyContent="space-between"
alignItems="center"
>
<Text bold color="cyan">
{selectedServer}
</Text>
<Box flexDirection="row" alignItems="center" gap={1}>
{currentServerState && (
<>
<Text color={getStatusColor(currentServerState.status)}>
{getStatusSymbol(currentServerState.status)}{" "}
{currentServerState.status}
</Text>
<Text> </Text>
{(currentServerState?.status === "disconnected" ||
currentServerState?.status === "error") && (
<Text color="cyan" bold>
[<Text underline>C</Text>onnect]
</Text>
)}
{(currentServerState?.status === "connected" ||
currentServerState?.status === "connecting") && (
<Text color="red" bold>
[<Text underline>D</Text>isconnect]
</Text>
)}
</>
)}
</Box>
</Box>
{oauthStatus === "authenticating" && (
<Box marginTop={1}>
<Text dimColor>OAuth: authenticating…</Text>
</Box>
)}
{oauthStatus === "error" && oauthMessage && (
<Box marginTop={1}>
<Text color="red">OAuth: {oauthMessage}</Text>
</Box>
)}
{disconnectError && (
<Box marginTop={1}>
<Text color="red">Disconnect failed: {disconnectError}</Text>
</Box>
)}
</Box>
</Box>
{/* Tabs */}
<Tabs
activeTab={activeTab}
onTabChange={setActiveTab}
width={contentWidth}
counts={tabCounts}
focused={focus === "tabs"}
showAuth={
!!(
selectedServer &&
selectedServerConfig &&
isOAuthCapableServerConfig(selectedServerConfig)
)
}
showLogging={
selectedServer && inspectorClients[selectedServer]
? inspectorClients[selectedServer].getServerType() === "stdio"
: false
}
showRequests={
selectedServer && inspectorClients[selectedServer]
? (() => {
const serverType =
inspectorClients[selectedServer].getServerType();
return (
serverType === "sse" || serverType === "streamable-http"
);
})()
: false
}
/>
{/* Tab Content */}
<Box
flexGrow={1}
minHeight={6}
width={contentWidth}
borderTop={false}
borderLeft={false}
borderRight={false}
borderBottom={false}
>
{activeTab === "info" && (
<InfoTab
serverName={selectedServer}
serverConfig={selectedServerConfig}
serverSettings={selectedServerEntry?.settings ?? null}
serverState={currentServerState}
width={contentWidth}
height={contentHeight}
focused={
focus === "tabContentList" || focus === "tabContentDetails"
}
/>
)}
{activeTab === "auth" &&
selectedServer &&
selectedServerConfig &&
isOAuthCapableServerConfig(selectedServerConfig) ? (
<AuthTab
serverName={selectedServer}
serverConfig={selectedServerConfig}
inspectorClient={selectedInspectorClient}
oauthStatus={oauthStatus}
oauthMessage={oauthMessage}
oauthRevision={oauthRevision}
pendingStepUp={
pendingStepUp?.serverName === selectedServer
? {
challenge: pendingStepUp.challenge,
authorizationScopes:
pendingStepUp.challenge.authorizationScopes,
enterpriseManaged: pendingStepUp.enterpriseManaged,
}
: null
}
onAuthorizeStepUp={() => {
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 ? (
<ResourcesTab
key={`resources-${selectedServer}`}
resources={currentServerState.resources}
resourceTemplates={currentServerState.resourceTemplates}
inspectorClient={selectedInspectorClient}
width={contentWidth}
height={contentHeight}
onCountChange={(count) =>
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 ? (
<PromptsTab
key={`prompts-${selectedServer}`}
prompts={currentServerState.prompts}
inspectorClient={selectedInspectorClient}
width={contentWidth}
height={contentHeight}
onCountChange={(count) =>
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 ? (
<ToolsTab
key={`tools-${selectedServer}`}
tools={currentServerState.tools}
isConnected={inspectorStatus === "connected"}
width={contentWidth}
height={contentHeight}
onCountChange={(count) =>
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 ? (
<HistoryTab
serverName={selectedServer}
messages={inspectorMessages}
width={contentWidth}
height={contentHeight}
onCountChange={(count) =>
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) ? (
<RequestsTab
serverName={selectedServer}
requests={inspectorFetchRequests}
width={contentWidth}
height={contentHeight}
onCountChange={(count) =>
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 ? (
<NotificationsTab
stderrLogs={inspectorStderrLogs}
width={contentWidth}
height={contentHeight}
onCountChange={(count) =>
setTabCounts((prev) => ({ ...prev, logging: count }))
}
focused={
focus === "tabContentList" || focus === "tabContentDetails"
}
/>
) : null}
</Box>
</Box>
</Box>
{/* Tool Test Modal - rendered at App level for full screen overlay */}
{toolTestModal && (
<ToolTestModal
tool={toolTestModal.tool}
inspectorClient={toolTestModal.inspectorClient}
width={dimensions.width}
height={dimensions.height}
onClose={() => setToolTestModal(null)}
onAuthRecoveryRequired={(error) => {
onAuthRecoveryRequired(error);
setToolTestModal(null);
}}
/>
)}
{/* Resource Test Modal - rendered at App level for full screen overlay */}
{resourceTestModal && (
<ResourceTestModal
template={resourceTestModal.template}
inspectorClient={resourceTestModal.inspectorClient}
width={dimensions.width}
height={dimensions.height}
onClose={() => setResourceTestModal(null)}
onAuthRecoveryRequired={(error) => {
onAuthRecoveryRequired(error);
setResourceTestModal(null);
}}
/>
)}
{promptTestModal && (
<PromptTestModal
prompt={promptTestModal.prompt}
inspectorClient={promptTestModal.inspectorClient}
width={dimensions.width}
height={dimensions.height}
onClose={() => setPromptTestModal(null)}
onAuthRecoveryRequired={(error) => {
onAuthRecoveryRequired(error);
setPromptTestModal(null);
}}
/>
)}
{/* Details Modal - rendered at App level for full screen overlay */}
{detailsModal && (
<DetailsModal
title={detailsModal.title}
content={detailsModal.content}
width={dimensions.width}
height={dimensions.height}
onClose={() => setDetailsModal(null)}
/>
)}
</Box>
);
}
export default App;