inspector/clients/tui/src/App.tsx
cliffhall 56ac83051b fix(auth): RFC-encode the Basic credential; make the TUI clear await its work (#2144)
Copilot review round 6.

- The Basic credential is now form-urlencoded per RFC 6749 §2.3.1. Round 1
  matched the SDK's raw `id:secret` on the theory that a credential should be
  presented the same way here as at the token endpoint; that does not hold — a
  client authenticated with `client_secret_post` never exercised the SDK's
  Basic path, so there is no precedent to match, and the raw form is ambiguous
  for an id containing `:` and makes `btoa` throw on a non-Latin-1 secret. The
  fixture decodes each half.
- The ctx-less grant read is suppressed on the store's issuer *stamp* rather
  than on the token's value. The active slot can hold client information
  without a token, in which case the read falls back to the legacy grant — and
  a value collision with any other issuer would have dropped that grant
  unrevoked and unreported.
- The TUI announced "OAuth state cleared" on the keypress, so with a bounded
  revocation request it said so while the work was still in flight and then
  removed it when the work finished. `onClearOAuth` returns a promise;
  `AuthTab` shows "Clearing OAuth state…" until it settles, ignores repeats
  while one is in flight, and skips exactly the revision bump its own clear
  caused so the confirmation is not immediately reset by it.
- `handleClearOAuth` now claims an attempt and retires a superseded one, the
  way `handleDisconnect` does: the await can keep it suspended for seconds,
  long enough for the user to switch servers and have server A's outcome —
  including its disconnect — land on server B.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0177mPHAdECD18nLLCTwR5rh
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-28 03:40:40 -04:00

2151 lines
75 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 { oauthMessageToneFor } from "./oauthMessageTone.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);
// The tone is derived from the message rather than stored beside it, so it
// cannot go stale — see `oauthMessageToneFor` for why that matters here.
// Both stay plain `useState` setters on purpose: wrapping `setOauthMessage`
// in a `useCallback` would make it a value `react-hooks/exhaustive-deps`
// demands in seven dependency arrays, for an identity that never changes.
const [oauthWarningText, setOauthWarningText] = useState<string | null>(null);
const oauthMessageTone = oauthMessageToneFor(oauthMessage, oauthWarningText);
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);
/** Retires a superseded "clear OAuth state" the way disconnects are (#2144). */
const clearOAuthAttemptRef = 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;
// Claim this attempt before awaiting, exactly as `handleDisconnect` does.
// The RFC 7009 leg is a bounded network request, so this callback can now
// stay suspended for seconds — long enough for the user to switch servers,
// at which point publishing this result would put server A's outcome on
// server B (and would race a second clear over the same state). (#2144)
const attempt = ++clearOAuthAttemptRef.current;
const attemptServer = selectedServer;
// RFC 7009. Best-effort: the outcome is reported in the status line, never
// thrown, so clearing always completes. The per-server `oauthRevokeOnClear`
// opt-out is honored here the same way the web client honors it.
const revocation = await selectedInspectorClient.clearOAuthTokens({
revoke: selectedServerEntry?.settings?.oauthRevokeOnClear !== false,
});
// The local clear has happened either way — only the *reporting* is
// retired, so a superseded attempt leaves no trace on the new selection.
if (
clearOAuthAttemptRef.current !== attempt ||
selectedServerRef.current !== attemptServer
) {
return;
}
setOauthStatus("idle");
if (revocation.status === "failed") {
const warning = `Cleared locally, but revoking the grant at the authorization server failed: ${revocation.detail}. It may still be valid there.`;
setOauthWarningText(warning);
setOauthMessage(warning);
} else {
setOauthMessage(null);
}
setConnectError(null);
if (inspectorStatus === "connected" || inspectorStatus === "connecting") {
await disconnectInspector();
}
setOauthRevision((n) => n + 1);
}, [
selectedInspectorClient,
selectedServer,
selectedServerEntry,
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}
oauthMessageTone={oauthMessageTone}
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;