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>
2151 lines
75 KiB
TypeScript
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;
|