feat: port CLI, TUI, and launcher from v1.5 into v2 (#1452)

Ports the CLI, TUI, and launcher from v1.5 into v2 behind a single
`mcp-inspector` entrypoint (launcher → web/cli/tui). Includes the
`mcp-inspector --web --dev` launcher fix (Vite alias resolution + prod-server
double-start), the root `postinstall` install cascade with simplified CI, and
spec/AGENTS docs (no-workspaces rationale, Development setup).

Closes #1246.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Bob Dickinson 2026-06-14 17:27:49 -07:00 • committed by GitHub
commit 2488ca5e38
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
23 changed files with 25594 additions and 236 deletions

1878
clients/tui/src/App.tsx Normal file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,432 @@
import React, { useState, useEffect, useCallback, useRef } from "react";
import { Box, Text, useInput, type Key } from "ink";
import { ScrollView, type ScrollViewRef } from "ink-scroll-view";
import { SelectableItem } from "./SelectableItem.js";
import type {
MCPServerConfig,
InspectorClient,
} from "@inspector/core/mcp/index.js";
import type {
AuthGuidedState,
OAuthStep,
} from "@inspector/core/auth/index.js";
const STEP_LABELS: Record<OAuthStep, string> = {
metadata_discovery: "Metadata Discovery",
client_registration: "Client Registration",
authorization_redirect: "Preparing Authorization",
authorization_code: "Request Authorization Code",
token_request: "Token Request",
complete: "Authentication Complete",
};
const STEP_ORDER: OAuthStep[] = [
"metadata_discovery",
"client_registration",
"authorization_redirect",
"authorization_code",
"token_request",
"complete",
];
function stepIndex(step: OAuthStep): number {
const i = STEP_ORDER.indexOf(step);
return i >= 0 ? i : 0;
}
interface AuthTabProps {
serverName: string | null;
serverConfig: MCPServerConfig | null;
inspectorClient: InspectorClient | null;
oauthStatus: "idle" | "authenticating" | "success" | "error";
oauthMessage: string | null;
width: number;
height: number;
focused?: boolean;
selectedAction: "guided" | "quick" | "clear";
onSelectedActionChange: (action: "guided" | "quick" | "clear") => void;
onQuickAuth: () => Promise<void>;
onGuidedStart: () => Promise<void>;
onGuidedAdvance: () => Promise<void>;
onRunGuidedToCompletion: () => Promise<void>;
onClearOAuth: () => void;
isOAuthCapable: boolean;
}
export function AuthTab({
serverName,
inspectorClient,
oauthStatus,
oauthMessage,
width,
height,
focused = false,
selectedAction,
onSelectedActionChange,
onQuickAuth,
onGuidedStart,
onGuidedAdvance,
onRunGuidedToCompletion,
onClearOAuth,
isOAuthCapable,
}: AuthTabProps) {
const scrollViewRef = useRef<ScrollViewRef>(null);
const [oauthState, setOauthState] = useState<AuthGuidedState | undefined>(
undefined,
);
const [guidedStarted, setGuidedStarted] = useState(false);
const [clearedConfirmation, setClearedConfirmation] = useState(false);
// Sync oauthState from InspectorClient
useEffect(() => {
if (!inspectorClient) {
setOauthState(undefined);
setGuidedStarted(false);
return;
}
const update = () => setOauthState(inspectorClient.getOAuthState());
update();
const onStepChange = () => update();
inspectorClient.addEventListener("oauthStepChange", onStepChange);
inspectorClient.addEventListener("oauthComplete", onStepChange);
return () => {
inspectorClient.removeEventListener("oauthStepChange", onStepChange);
inspectorClient.removeEventListener("oauthComplete", onStepChange);
};
}, [inspectorClient]);
// Reset guided state when switching servers
useEffect(() => {
setGuidedStarted(false);
}, [serverName]);
// Clear confirmation when switching away from Clear menu item
useEffect(() => {
if (selectedAction !== "clear") {
setClearedConfirmation(false);
}
}, [selectedAction]);
const guidedFlowStarted = !!oauthState?.oauthStep;
const currentStep = oauthState?.oauthStep ?? "metadata_discovery";
const needsAuthCode =
currentStep === "authorization_code" && oauthState?.authorizationUrl;
const isComplete = currentStep === "complete";
const handleContinue = useCallback(async () => {
if (!guidedStarted) {
await onGuidedStart();
setGuidedStarted(true);
} else if (!needsAuthCode && !isComplete) {
await onGuidedAdvance();
}
}, [
guidedStarted,
needsAuthCode,
isComplete,
onGuidedStart,
onGuidedAdvance,
]);
// Keyboard: G/Q/S select menu item (handled by App when not focused),
// left/right select, Enter run, up/down scroll
useInput(
(input: string, key: Key) => {
if (!focused || !isOAuthCapable) return;
const lower = input.toLowerCase();
if (lower === "g") {
onSelectedActionChange("guided");
return;
}
if (lower === "q") {
onSelectedActionChange("quick");
return;
}
if (lower === "s") {
onSelectedActionChange("clear");
return;
}
if (key.leftArrow) {
onSelectedActionChange(
selectedAction === "guided"
? "clear"
: selectedAction === "quick"
? "guided"
: "quick",
);
} else if (key.rightArrow) {
onSelectedActionChange(
selectedAction === "guided"
? "quick"
: selectedAction === "quick"
? "clear"
: "guided",
);
} else if (key.upArrow && scrollViewRef.current) {
scrollViewRef.current.scrollBy(-1);
} else if (key.downArrow && scrollViewRef.current) {
scrollViewRef.current.scrollBy(1);
} else if (key.pageUp && scrollViewRef.current) {
const h = scrollViewRef.current.getViewportHeight() || 1;
scrollViewRef.current.scrollBy(-h);
} else if (key.pageDown && scrollViewRef.current) {
const h = scrollViewRef.current.getViewportHeight() || 1;
scrollViewRef.current.scrollBy(h);
} else if (key.return) {
if (selectedAction === "guided") onRunGuidedToCompletion();
else if (selectedAction === "quick") onQuickAuth();
else if (selectedAction === "clear") {
onClearOAuth();
setClearedConfirmation(true);
}
} else if (input === " " && selectedAction === "guided") {
handleContinue();
}
},
{
isActive: focused,
},
);
if (!serverName || !isOAuthCapable) {
return (
<Box width={width} height={height} paddingX={1} paddingY={1}>
<Text dimColor>
Select an OAuth-capable server (SSE or Streamable HTTP) to configure
authentication.
</Text>
</Box>
);
}
return (
<Box width={width} height={height} flexDirection="column" paddingX={1}>
<Box paddingY={1} flexShrink={0}>
<Text bold backgroundColor={focused ? "yellow" : undefined}>
Authentication
</Text>
</Box>
<Box
flexGrow={0}
overflow="hidden"
flexDirection="column"
gap={0}
paddingY={0}
>
{/* Action bar and hint - single container for tight spacing */}
<Box flexShrink={0} flexDirection="column" gap={0} paddingBottom={1}>
<Box flexDirection="row" gap={2}>
<SelectableItem
isSelected={selectedAction === "guided"}
bold={selectedAction === "guided"}
>
<Text underline>G</Text>uided Auth
</SelectableItem>
<SelectableItem
isSelected={selectedAction === "quick"}
bold={selectedAction === "quick"}
>
<Text underline>Q</Text>uick Auth
</SelectableItem>
<SelectableItem
isSelected={selectedAction === "clear"}
bold={selectedAction === "clear"}
>
Clear OAuth <Text underline>S</Text>tate
</SelectableItem>
</Box>
<Box flexDirection="column">
{selectedAction === "guided" && (
<>
<Text dimColor>
Press [Space] to advance one step through guided auth.
</Text>
<Text dimColor>
Press [Enter] to run guided auth to completion.
</Text>
</>
)}
{selectedAction === "quick" && (
<Text dimColor>Press [Enter] to run quick auth.</Text>
)}
{selectedAction === "clear" && (
<Text dimColor>Press [Enter] to clear OAuth state.</Text>
)}
</Box>
</Box>
<ScrollView ref={scrollViewRef} height={height - 10}>
{selectedAction === "guided" && (
<Box key="guided" flexShrink={0} flexDirection="column">
<Text bold>Guided OAuth Flow Progress</Text>
{STEP_ORDER.map((step) => {
const stepIdx = stepIndex(step);
const currentIdx = stepIndex(currentStep);
const completed =
guidedFlowStarted &&
(stepIdx < currentIdx ||
(step === currentStep && isComplete));
const inProgress =
guidedFlowStarted && step === currentStep && !isComplete;
const details = oauthState
? getStepDetails(oauthState, step)
: null;
const icon = completed ? "✓" : inProgress ? "→" : "○";
const color = completed
? "green"
: inProgress
? "cyan"
: "gray";
return (
<Box
key={step}
marginTop={1}
flexDirection="column"
paddingLeft={2}
>
<Text color={color}>
{icon} {STEP_LABELS[step]}
{inProgress && " (in progress)"}
</Text>
{completed && details && (
<Box marginTop={1} paddingLeft={2} flexDirection="column">
<Text dimColor>{details}</Text>
</Box>
)}
{inProgress && details && (
<Box marginTop={1} paddingLeft={2} flexDirection="column">
<Text dimColor>{details}</Text>
</Box>
)}
</Box>
);
})}
{/* Waiting for auth - URL was opened when we reached this step */}
{oauthState && needsAuthCode && oauthState?.authorizationUrl && (
<Box marginTop={2} flexDirection="column">
<Text bold>Authorization URL opened in browser</Text>
<Box marginTop={1}>
<Text dimColor>
{oauthState.authorizationUrl.toString()}
</Text>
</Box>
<Box marginTop={1}>
<Text dimColor>
Complete authorization in the browser. You will be
redirected and the flow will complete automatically.
</Text>
</Box>
</Box>
)}
</Box>
)}
{selectedAction === "quick" && (
<Box key="quick" flexShrink={0} flexDirection="column">
{oauthStatus === "authenticating" && (
<Text dimColor>Authenticating...</Text>
)}
{oauthStatus === "error" && oauthMessage && (
<Text color="red">{oauthMessage}</Text>
)}
{oauthStatus === "success" &&
oauthState &&
oauthState.authType === "normal" &&
(oauthState.oauthTokens || oauthState.oauthClientInfo) && (
<>
<Text bold>Quick Auth Results</Text>
{oauthState.oauthClientInfo && (
<Box marginTop={1} flexDirection="column" paddingLeft={2}>
<Text dimColor>
Client:{" "}
{JSON.stringify(oauthState.oauthClientInfo, null, 2)}
</Text>
</Box>
)}
{oauthState.oauthTokens && (
<Box marginTop={1} flexDirection="column" paddingLeft={2}>
<Text dimColor>
Access Token:{" "}
{oauthState.oauthTokens.access_token?.slice(0, 20)}...
</Text>
</Box>
)}
</>
)}
</Box>
)}
{selectedAction === "clear" && clearedConfirmation && (
<Box key="clear" flexShrink={0} flexDirection="column">
<Text color="green">OAuth state cleared.</Text>
</Box>
)}
</ScrollView>
</Box>
{focused && (
<Box
flexShrink={0}
height={1}
justifyContent="center"
backgroundColor="gray"
>
<Text bold color="white">
←/→ select, G/Q/S or Enter run, ↑/↓ scroll
</Text>
</Box>
)}
</Box>
);
}
function getStepDetails(
state: AuthGuidedState,
step: OAuthStep,
): string | null {
switch (step) {
case "metadata_discovery":
if (state.resourceMetadata || state.oauthMetadata) {
const parts: string[] = [];
if (state.resourceMetadata) {
parts.push(
`Resource: ${JSON.stringify(state.resourceMetadata, null, 2)}`,
);
}
if (state.oauthMetadata) {
parts.push(`OAuth: ${JSON.stringify(state.oauthMetadata, null, 2)}`);
}
return parts.join("\n");
}
return null;
case "client_registration":
if (state.oauthClientInfo) {
return JSON.stringify(state.oauthClientInfo, null, 2);
}
return null;
case "authorization_redirect":
if (state.authorizationUrl) {
return `URL: ${state.authorizationUrl.toString()}`;
}
return null;
case "authorization_code":
return state.authorizationCode
? `Code received: ${state.authorizationCode.slice(0, 10)}...`
: null;
case "token_request":
return "Exchanging code for tokens...";
case "complete":
if (state.oauthTokens) {
return `Tokens: access_token=${state.oauthTokens.access_token?.slice(0, 15)}...`;
}
return null;
default:
return null;
}
}

View file

@ -0,0 +1,102 @@
import React, { useRef } from "react";
import { Box, Text, useInput, type Key } from "ink";
import { ScrollView, type ScrollViewRef } from "ink-scroll-view";
interface DetailsModalProps {
title: string;
content: React.ReactNode;
width: number;
height: number;
onClose: () => void;
}
export function DetailsModal({
title,
content,
width,
height,
onClose,
}: DetailsModalProps) {
const scrollViewRef = useRef<ScrollViewRef>(null);
// Use full terminal dimensions
const [terminalDimensions, setTerminalDimensions] = React.useState({
width: process.stdout.columns || width,
height: process.stdout.rows || height,
});
React.useEffect(() => {
const updateDimensions = () => {
setTerminalDimensions({
width: process.stdout.columns || width,
height: process.stdout.rows || height,
});
};
process.stdout.on("resize", updateDimensions);
updateDimensions();
return () => {
process.stdout.off("resize", updateDimensions);
};
}, [width, height]);
// Handle escape to close and scrolling
useInput(
(input: string, key: Key) => {
if (key.escape) {
onClose();
} else if (key.downArrow) {
scrollViewRef.current?.scrollBy(1);
} else if (key.upArrow) {
scrollViewRef.current?.scrollBy(-1);
} else if (key.pageDown) {
const viewportHeight = scrollViewRef.current?.getViewportHeight() || 1;
scrollViewRef.current?.scrollBy(viewportHeight);
} else if (key.pageUp) {
const viewportHeight = scrollViewRef.current?.getViewportHeight() || 1;
scrollViewRef.current?.scrollBy(-viewportHeight);
}
},
{ isActive: true },
);
// Calculate modal dimensions - use almost full screen
const modalWidth = terminalDimensions.width - 2;
const modalHeight = terminalDimensions.height - 2;
return (
<Box
position="absolute"
width={terminalDimensions.width}
height={terminalDimensions.height}
flexDirection="column"
justifyContent="center"
alignItems="center"
>
{/* Modal Content */}
<Box
width={modalWidth}
height={modalHeight}
borderStyle="single"
borderColor="cyan"
flexDirection="column"
paddingX={1}
paddingY={1}
backgroundColor="black"
>
{/* Header */}
<Box flexShrink={0} marginBottom={1}>
<Text bold color="cyan">
{title}
</Text>
<Text> </Text>
<Text dimColor>(Press ESC to close)</Text>
</Box>
{/* Content Area */}
<Box flexGrow={1} flexDirection="column" overflow="hidden">
<ScrollView ref={scrollViewRef}>{content}</ScrollView>
</Box>
</Box>
</Box>
);
}

View file

@ -0,0 +1,326 @@
import React, { useEffect, useRef } from "react";
import { Box, Text, useInput, type Key } from "ink";
import { ScrollView, type ScrollViewRef } from "ink-scroll-view";
import type { MessageEntry } from "@inspector/core/mcp/index.js";
import { useSelectableList } from "../hooks/useSelectableList.js";
interface HistoryTabProps {
serverName: string | null;
messages: MessageEntry[];
width: number;
height: number;
onCountChange?: (count: number) => void;
focusedPane?: "messages" | "details" | null;
onViewDetails?: (message: MessageEntry) => void;
modalOpen?: boolean;
}
export function HistoryTab({
messages,
width,
height,
onCountChange,
focusedPane = null,
onViewDetails,
modalOpen = false,
}: HistoryTabProps) {
const visibleCount = Math.max(1, height - 7);
const { selectedIndex, firstVisible, setSelection } = useSelectableList(
messages.length,
visibleCount,
);
const scrollViewRef = useRef<ScrollViewRef>(null);
const selectedMessage = messages[selectedIndex] || null;
// Handle arrow key navigation and scrolling when focused
useInput(
(input: string, key: Key) => {
if (focusedPane === "messages") {
if (key.upArrow && selectedIndex > 0) {
setSelection(selectedIndex - 1);
} else if (key.downArrow && selectedIndex < messages.length - 1) {
setSelection(selectedIndex + 1);
} else if (key.pageUp) {
setSelection(Math.max(0, selectedIndex - visibleCount));
} else if (key.pageDown) {
setSelection(
Math.min(messages.length - 1, selectedIndex + visibleCount),
);
}
return;
}
// details scrolling (only when details pane is focused)
if (focusedPane === "details") {
// Handle '+' key to view in full screen modal
if (input === "+" && selectedMessage && onViewDetails) {
onViewDetails(selectedMessage);
return;
}
if (key.upArrow) {
scrollViewRef.current?.scrollBy(-1);
} else if (key.downArrow) {
scrollViewRef.current?.scrollBy(1);
} else if (key.pageUp) {
const viewportHeight =
scrollViewRef.current?.getViewportHeight() || 1;
scrollViewRef.current?.scrollBy(-viewportHeight);
} else if (key.pageDown) {
const viewportHeight =
scrollViewRef.current?.getViewportHeight() || 1;
scrollViewRef.current?.scrollBy(viewportHeight);
}
}
},
{ isActive: !modalOpen && focusedPane !== undefined },
);
// Update count when messages change
React.useEffect(() => {
onCountChange?.(messages.length);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [messages.length]);
// Reset details scroll when message selection changes
useEffect(() => {
scrollViewRef.current?.scrollTo(0);
}, [selectedIndex]);
const listWidth = Math.floor(width * 0.4);
const detailWidth = width - listWidth;
return (
<Box flexDirection="row" width={width} height={height}>
{/* Left column - Messages list */}
<Box
width={listWidth}
height={height}
borderStyle="single"
borderTop={false}
borderBottom={false}
borderLeft={false}
borderRight={true}
flexDirection="column"
paddingX={1}
>
<Box paddingY={1} flexShrink={0}>
<Text
bold
backgroundColor={focusedPane === "messages" ? "yellow" : undefined}
>
Messages ({messages.length})
</Text>
</Box>
{/* Messages list */}
{messages.length === 0 ? (
<Box paddingY={1}>
<Text dimColor>No messages</Text>
</Box>
) : (
<Box
flexDirection="column"
height={visibleCount}
overflow="hidden"
flexShrink={0}
>
{messages
.slice(firstVisible, firstVisible + visibleCount)
.map((msg, i) => {
const index = firstVisible + i;
const isSelected = index === selectedIndex;
let label: string;
if (msg.direction === "request" && "method" in msg.message) {
label = msg.message.method;
} else if (msg.direction === "response") {
if ("result" in msg.message) {
label = "Response (result)";
} else if ("error" in msg.message) {
label = `Response (error: ${msg.message.error.code})`;
} else {
label = "Response";
}
} else if (
msg.direction === "notification" &&
"method" in msg.message
) {
label = msg.message.method;
} else {
label = "Unknown";
}
const direction =
msg.direction === "request"
? "→"
: msg.direction === "response"
? "←"
: "•";
const hasResponse = msg.response !== undefined;
return (
<Box key={msg.id} paddingY={0} flexShrink={0}>
<Text color={isSelected ? "white" : "white"}>
{isSelected ? "▶ " : " "}
{direction} {label}
{hasResponse
? " ✓"
: msg.direction === "request"
? " ..."
: ""}
</Text>
</Box>
);
})}
</Box>
)}
</Box>
{/* Right column - Message details */}
<Box
width={detailWidth}
height={height}
paddingX={1}
flexDirection="column"
flexShrink={0}
borderStyle="single"
borderTop={false}
borderBottom={false}
borderLeft={false}
borderRight={false}
>
{selectedMessage ? (
<>
{/* Fixed method caption only */}
<Box flexShrink={0} paddingTop={1}>
<Text
bold
backgroundColor={
focusedPane === "details" ? "yellow" : undefined
}
{...(focusedPane === "details" ? {} : { color: "cyan" })}
>
{selectedMessage.direction === "request" &&
"method" in selectedMessage.message
? selectedMessage.message.method
: selectedMessage.direction === "response"
? "Response"
: selectedMessage.direction === "notification" &&
"method" in selectedMessage.message
? selectedMessage.message.method
: "Message"}
</Text>
</Box>
{/* Scrollable content area */}
<ScrollView ref={scrollViewRef} height={height - 5}>
{/* Metadata */}
<Box marginTop={1} flexDirection="column" flexShrink={0}>
<Text bold>Direction: {selectedMessage.direction}</Text>
<Box marginTop={1}>
<Text dimColor>
{selectedMessage.timestamp.toLocaleTimeString()}
{selectedMessage.duration !== undefined &&
` (${selectedMessage.duration}ms)`}
</Text>
</Box>
</Box>
{selectedMessage.direction === "request" ? (
<>
{/* Request label */}
<Box marginTop={1} flexShrink={0}>
<Text bold>Request:</Text>
</Box>
{/* Request content */}
{JSON.stringify(selectedMessage.message, null, 2)
.split("\n")
.map((line: string, idx: number) => (
<Box
key={`req-${idx}`}
marginTop={idx === 0 ? 1 : 0}
paddingLeft={2}
flexShrink={0}
>
<Text dimColor>{line}</Text>
</Box>
))}
{/* Response section */}
{selectedMessage.response ? (
<>
<Box marginTop={1} flexShrink={0}>
<Text bold>Response:</Text>
</Box>
{JSON.stringify(selectedMessage.response, null, 2)
.split("\n")
.map((line: string, idx: number) => (
<Box
key={`resp-${idx}`}
marginTop={idx === 0 ? 1 : 0}
paddingLeft={2}
flexShrink={0}
>
<Text dimColor>{line}</Text>
</Box>
))}
</>
) : (
<Box marginTop={1} flexShrink={0}>
<Text dimColor italic>
Waiting for response...
</Text>
</Box>
)}
</>
) : (
<>
{/* Response or notification label */}
<Box marginTop={1} flexShrink={0}>
<Text bold>
{selectedMessage.direction === "response"
? "Response:"
: "Notification:"}
</Text>
</Box>
{/* Message content */}
{JSON.stringify(selectedMessage.message, null, 2)
.split("\n")
.map((line: string, idx: number) => (
<Box
key={`msg-${idx}`}
marginTop={idx === 0 ? 1 : 0}
paddingLeft={2}
flexShrink={0}
>
<Text dimColor>{line}</Text>
</Box>
))}
</>
)}
</ScrollView>
{/* Fixed footer - only show when details pane is focused */}
{focusedPane === "details" && (
<Box
flexShrink={0}
height={1}
justifyContent="center"
backgroundColor="gray"
>
<Text bold color="white">
↑/↓ to scroll, + to zoom
</Text>
</Box>
)}
</>
) : (
<Box paddingY={1} flexShrink={0}>
<Text dimColor>Select a message to view details</Text>
</Box>
)}
</Box>
</Box>
);
}

View file

@ -0,0 +1,226 @@
import React, { useRef } from "react";
import { Box, Text, useInput, type Key } from "ink";
import { ScrollView, type ScrollViewRef } from "ink-scroll-view";
import type {
MCPServerConfig,
ServerState,
} from "@inspector/core/mcp/index.js";
interface InfoTabProps {
serverName: string | null;
serverConfig: MCPServerConfig | null;
serverState: ServerState | null;
width: number;
height: number;
focused?: boolean;
}
export function InfoTab({
serverName,
serverConfig,
serverState,
width,
height,
focused = false,
}: InfoTabProps) {
const scrollViewRef = useRef<ScrollViewRef>(null);
// Handle keyboard input for scrolling
useInput(
(input: string, key: Key) => {
if (focused) {
if (key.upArrow) {
scrollViewRef.current?.scrollBy(-1);
} else if (key.downArrow) {
scrollViewRef.current?.scrollBy(1);
} else if (key.pageUp) {
const viewportHeight =
scrollViewRef.current?.getViewportHeight() || 1;
scrollViewRef.current?.scrollBy(-viewportHeight);
} else if (key.pageDown) {
const viewportHeight =
scrollViewRef.current?.getViewportHeight() || 1;
scrollViewRef.current?.scrollBy(viewportHeight);
}
}
},
{ isActive: focused },
);
return (
<Box width={width} height={height} flexDirection="column" paddingX={1}>
<Box paddingY={1} flexShrink={0}>
<Text bold backgroundColor={focused ? "yellow" : undefined}>
Info
</Text>
</Box>
{serverName ? (
<>
{/* Scrollable content area - takes remaining space */}
<Box height={height - 4} overflow="hidden" paddingTop={1}>
<ScrollView ref={scrollViewRef} height={height - 4}>
{/* Server Configuration */}
<Box flexShrink={0} marginTop={1}>
<Text bold>Server Configuration</Text>
</Box>
{serverConfig ? (
<Box
flexShrink={0}
marginTop={1}
paddingLeft={2}
flexDirection="column"
>
{serverConfig.type === undefined ||
serverConfig.type === "stdio" ? (
<>
<Text dimColor>Type: stdio</Text>
<Text dimColor>Command: {serverConfig.command}</Text>
{serverConfig.args && serverConfig.args.length > 0 && (
<Box marginTop={1} flexDirection="column">
<Text dimColor>Args:</Text>
{serverConfig.args.map((arg: string, idx: number) => (
<Box
key={`arg-${idx}`}
paddingLeft={2}
marginTop={idx === 0 ? 0 : 0}
>
<Text dimColor>{arg}</Text>
</Box>
))}
</Box>
)}
{serverConfig.env &&
Object.keys(serverConfig.env).length > 0 && (
<Box marginTop={1}>
<Text dimColor>
Env:{" "}
{Object.entries(serverConfig.env)
.map(([k, v]) => `${k}=${v}`)
.join(", ")}
</Text>
</Box>
)}
{serverConfig.cwd && (
<Box marginTop={1}>
<Text dimColor>CWD: {serverConfig.cwd}</Text>
</Box>
)}
</>
) : serverConfig.type === "sse" ? (
<>
<Text dimColor>Type: sse</Text>
<Text dimColor>URL: {serverConfig.url}</Text>
{serverConfig.headers &&
Object.keys(serverConfig.headers).length > 0 && (
<Box marginTop={1}>
<Text dimColor>
Headers:{" "}
{Object.entries(serverConfig.headers)
.map(([k, v]) => `${k}=${v}`)
.join(", ")}
</Text>
</Box>
)}
</>
) : serverConfig.type === "streamable-http" ? (
<>
<Text dimColor>Type: streamable-http</Text>
<Text dimColor>URL: {serverConfig.url}</Text>
{serverConfig.headers &&
Object.keys(serverConfig.headers).length > 0 && (
<Box marginTop={1}>
<Text dimColor>
Headers:{" "}
{Object.entries(serverConfig.headers)
.map(([k, v]) => `${k}=${v}`)
.join(", ")}
</Text>
</Box>
)}
</>
) : null}
</Box>
) : (
<Box marginTop={1} paddingLeft={2}>
<Text dimColor>No configuration available</Text>
</Box>
)}
{/* Server Info */}
{serverState &&
serverState.status === "connected" &&
serverState.serverInfo && (
<>
<Box flexShrink={0} marginTop={2}>
<Text bold>Server Information</Text>
</Box>
<Box
flexShrink={0}
marginTop={1}
paddingLeft={2}
flexDirection="column"
>
{serverState.serverInfo.name && (
<Text dimColor>
Name: {serverState.serverInfo.name}
</Text>
)}
{serverState.serverInfo.version && (
<Box marginTop={1}>
<Text dimColor>
Version: {serverState.serverInfo.version}
</Text>
</Box>
)}
{serverState.instructions && (
<Box marginTop={1} flexDirection="column">
<Text dimColor>Instructions:</Text>
<Box paddingLeft={2} marginTop={1}>
<Text dimColor>{serverState.instructions}</Text>
</Box>
</Box>
)}
</Box>
</>
)}
{serverState && serverState.status === "error" && (
<Box flexShrink={0} marginTop={2}>
<Text bold color="red">
Error
</Text>
{serverState.error && (
<Box marginTop={1} paddingLeft={2}>
<Text color="red">{serverState.error}</Text>
</Box>
)}
</Box>
)}
{serverState && serverState.status === "disconnected" && (
<Box flexShrink={0} marginTop={2}>
<Text dimColor>Server not connected</Text>
</Box>
)}
</ScrollView>
</Box>
{/* Fixed keyboard help footer at bottom - only show when focused */}
{focused && (
<Box
flexShrink={0}
height={1}
justifyContent="center"
backgroundColor="gray"
>
<Text bold color="white">
↑/↓ to scroll, + to zoom
</Text>
</Box>
)}
</>
) : null}
</Box>
);
}

View file

@ -0,0 +1,83 @@
import React, { useEffect, useRef } from "react";
import { Box, Text, useInput, type Key } from "ink";
import { ScrollView, type ScrollViewRef } from "ink-scroll-view";
import type { StderrLogEntry } from "@inspector/core/mcp/index.js";
interface NotificationsTabProps {
stderrLogs: StderrLogEntry[];
width: number;
height: number;
onCountChange?: (count: number) => void;
focused?: boolean;
}
export function NotificationsTab({
stderrLogs,
width,
height,
onCountChange,
focused = false,
}: NotificationsTabProps) {
const scrollViewRef = useRef<ScrollViewRef>(null);
const onCountChangeRef = useRef(onCountChange);
// Update ref when callback changes
useEffect(() => {
onCountChangeRef.current = onCountChange;
}, [onCountChange]);
useEffect(() => {
onCountChangeRef.current?.(stderrLogs.length);
}, [stderrLogs.length]);
// Handle keyboard input for scrolling
useInput(
(input: string, key: Key) => {
if (focused) {
if (key.upArrow) {
scrollViewRef.current?.scrollBy(-1);
} else if (key.downArrow) {
scrollViewRef.current?.scrollBy(1);
} else if (key.pageUp) {
const viewportHeight =
scrollViewRef.current?.getViewportHeight() || 1;
scrollViewRef.current?.scrollBy(-viewportHeight);
} else if (key.pageDown) {
const viewportHeight =
scrollViewRef.current?.getViewportHeight() || 1;
scrollViewRef.current?.scrollBy(viewportHeight);
}
}
},
{ isActive: focused },
);
return (
<Box width={width} height={height} flexDirection="column" paddingX={1}>
<Box paddingY={1} flexShrink={0}>
<Text bold backgroundColor={focused ? "yellow" : undefined}>
Logging ({stderrLogs.length})
</Text>
</Box>
{stderrLogs.length === 0 ? (
<Box paddingY={1}>
<Text dimColor>No stderr output yet</Text>
</Box>
) : (
<ScrollView ref={scrollViewRef} height={height - 3}>
{stderrLogs.map((log, index) => (
<Box
key={`log-${log.timestamp.getTime()}-${index}`}
paddingY={0}
flexDirection="row"
flexShrink={0}
>
<Text dimColor>[{log.timestamp.toLocaleTimeString()}] </Text>
<Text color="red">{log.message}</Text>
</Box>
))}
</ScrollView>
)}
</Box>
);
}

View file

@ -0,0 +1,301 @@
import React, { useState } from "react";
import { Box, Text, useInput, type Key } from "ink";
import { Form } from "ink-form";
import { InspectorClient } from "@inspector/core/mcp/index.js";
import type {
Prompt,
GetPromptResult,
} from "@modelcontextprotocol/sdk/types.js";
import { promptArgsToForm } from "../utils/promptArgsToForm.js";
import { ScrollView, type ScrollViewRef } from "ink-scroll-view";
// Helper to extract error message from various error types
function getErrorMessage(error: unknown): string {
if (error instanceof Error) {
return error.message;
}
if (typeof error === "string") {
return error;
}
if (error && typeof error === "object" && "message" in error) {
return String(error.message);
}
return "Unknown error";
}
interface PromptTestModalProps {
prompt: Prompt;
inspectorClient: InspectorClient | null;
width: number;
height: number;
onClose: () => void;
}
type ModalState = "form" | "loading" | "results";
interface PromptResult {
input: Record<string, string>;
output: GetPromptResult | null;
error?: string;
errorDetails?: unknown;
duration: number;
}
export function PromptTestModal({
prompt,
inspectorClient,
width,
height,
onClose,
}: PromptTestModalProps) {
const [state, setState] = useState<ModalState>("form");
const [result, setResult] = useState<PromptResult | null>(null);
const scrollViewRef = React.useRef<ScrollViewRef>(null);
// Use full terminal dimensions instead of passed dimensions
const [terminalDimensions, setTerminalDimensions] = React.useState({
width: process.stdout.columns || width,
height: process.stdout.rows || height,
});
React.useEffect(() => {
const updateDimensions = () => {
setTerminalDimensions({
width: process.stdout.columns || width,
height: process.stdout.rows || height,
});
};
process.stdout.on("resize", updateDimensions);
updateDimensions();
return () => {
process.stdout.off("resize", updateDimensions);
};
}, [width, height]);
const formStructure = promptArgsToForm(
prompt.arguments || [],
prompt.name || "Unknown Prompt",
);
// Reset state when modal closes
React.useEffect(() => {
return () => {
// Cleanup: reset state when component unmounts
setState("form");
setResult(null);
};
}, []);
// Handle all input when modal is open - prevents input from reaching underlying components
// When in form mode, only handle escape (form handles its own input)
// When in results mode, handle scrolling keys
useInput(
(input: string, key: Key) => {
// Always handle escape to close modal
if (key.escape) {
setState("form");
setResult(null);
onClose();
return;
}
if (state === "form") {
// In form mode, let the form handle all other input
// Don't process anything else - this prevents input from reaching underlying components
return;
}
if (state === "results") {
// Allow scrolling in results view
if (key.downArrow) {
scrollViewRef.current?.scrollBy(1);
} else if (key.upArrow) {
scrollViewRef.current?.scrollBy(-1);
} else if (key.pageDown) {
const viewportHeight =
scrollViewRef.current?.getViewportHeight() || 1;
scrollViewRef.current?.scrollBy(viewportHeight);
} else if (key.pageUp) {
const viewportHeight =
scrollViewRef.current?.getViewportHeight() || 1;
scrollViewRef.current?.scrollBy(-viewportHeight);
}
}
},
{ isActive: true },
);
const handleFormSubmit = async (values: Record<string, string>) => {
if (!inspectorClient || !prompt) return;
setState("loading");
const startTime = Date.now();
try {
// Get the prompt using the provided arguments
const invocation = await inspectorClient.getPrompt(prompt.name, values);
const duration = Date.now() - startTime;
setResult({
input: values,
output: invocation.result,
duration,
});
setState("results");
} catch (error) {
const duration = Date.now() - startTime;
const errorMessage = getErrorMessage(error);
// Extract detailed error information
const errorObj: Record<string, unknown> = {
message: errorMessage,
};
if (error instanceof Error) {
errorObj.name = error.name;
errorObj.stack = error.stack;
} else if (error && typeof error === "object") {
// Try to extract more details from error object
Object.assign(errorObj, error);
} else {
errorObj.error = String(error);
}
setResult({
input: values,
output: null,
error: errorMessage,
errorDetails: errorObj,
duration,
});
setState("results");
}
};
// Calculate modal dimensions - use almost full screen
const modalWidth = terminalDimensions.width - 2;
const modalHeight = terminalDimensions.height - 2;
return (
<Box
position="absolute"
width={terminalDimensions.width}
height={terminalDimensions.height}
flexDirection="column"
justifyContent="center"
alignItems="center"
>
{/* Modal Content */}
<Box
width={modalWidth}
height={modalHeight}
borderStyle="single"
borderColor="cyan"
flexDirection="column"
paddingX={1}
paddingY={1}
backgroundColor="black"
>
{/* Header */}
<Box flexShrink={0} marginBottom={1}>
<Text bold color="cyan">
{formStructure.title}
</Text>
<Text> </Text>
<Text dimColor>(Press ESC to close)</Text>
</Box>
{/* Content Area */}
<Box flexGrow={1} flexDirection="column" overflow="hidden">
{state === "form" && (
<Box flexGrow={1} flexDirection="column">
{prompt.description && (
<Box marginBottom={1} flexShrink={0}>
<Text dimColor>{prompt.description}</Text>
</Box>
)}
<Form
form={formStructure}
onSubmit={(values: object) =>
handleFormSubmit(values as Record<string, string>)
}
/>
</Box>
)}
{state === "loading" && (
<Box flexGrow={1} justifyContent="center" alignItems="center">
<Text color="yellow">Getting prompt...</Text>
</Box>
)}
{state === "results" && result && (
<Box flexGrow={1} flexDirection="column" overflow="hidden">
<ScrollView ref={scrollViewRef}>
{/* Timing */}
<Box marginBottom={1} flexShrink={0}>
<Text bold color="green">
Duration: {result.duration}ms
</Text>
</Box>
{/* Input */}
{Object.keys(result.input).length > 0 && (
<Box marginBottom={1} flexShrink={0} flexDirection="column">
<Text bold color="cyan">
Arguments:
</Text>
<Box paddingLeft={2}>
<Text dimColor>
{JSON.stringify(result.input, null, 2)}
</Text>
</Box>
</Box>
)}
{/* Output or Error */}
{result.error ? (
<Box flexShrink={0} flexDirection="column">
<Box marginTop={1} flexShrink={0}>
<Text bold color="red">
Error:
</Text>
</Box>
<Box marginTop={1} paddingLeft={2} flexShrink={0}>
<Text color="red">{String(result.error)}</Text>
</Box>
{result.errorDetails != null ? (
<>
<Box marginTop={1} flexShrink={0}>
<Text bold color="red" dimColor>
Error Details:
</Text>
</Box>
<Box marginTop={1} paddingLeft={2} flexShrink={0}>
<Text dimColor>
{JSON.stringify(result.errorDetails, null, 2)}
</Text>
</Box>
</>
) : null}
</Box>
) : (
<Box flexShrink={0} flexDirection="column">
<Text bold color="green">
Prompt Messages:
</Text>
<Box paddingLeft={2}>
<Text dimColor>
{JSON.stringify(result.output, null, 2)}
</Text>
</Box>
</Box>
)}
</ScrollView>
</Box>
)}
</Box>
</Box>
</Box>
);
}

View file

@ -0,0 +1,276 @@
import React, { useState, useEffect, useRef } from "react";
import { Box, Text, useInput, type Key } from "ink";
import { ScrollView, type ScrollViewRef } from "ink-scroll-view";
import type { InspectorClient } from "@inspector/core/mcp/index.js";
import type {
Prompt,
PromptArgument,
GetPromptResult,
} from "@modelcontextprotocol/sdk/types.js";
import { useSelectableList } from "../hooks/useSelectableList.js";
interface PromptsTabProps {
prompts: Prompt[];
inspectorClient: InspectorClient | null;
width: number;
height: number;
onCountChange?: (count: number) => void;
focusedPane?: "list" | "details" | null;
onViewDetails?: (prompt: Prompt & { result?: GetPromptResult }) => void;
onFetchPrompt?: (prompt: Prompt) => void;
modalOpen?: boolean;
}
export function PromptsTab({
prompts,
inspectorClient,
width,
height,
focusedPane = null,
onViewDetails,
onFetchPrompt,
modalOpen = false,
}: PromptsTabProps) {
const visibleCount = Math.max(1, height - 7);
const { selectedIndex, firstVisible, setSelection } = useSelectableList(
prompts.length,
visibleCount,
{ resetWhen: prompts },
);
const [error, setError] = useState<string | null>(null);
const scrollViewRef = useRef<ScrollViewRef>(null);
// Handle arrow key navigation when focused
useInput(
(input: string, key: Key) => {
// Handle Enter key to fetch prompt (works from both list and details)
if (key.return && selectedPrompt && inspectorClient && onFetchPrompt) {
// If prompt has arguments, open modal to collect them
// Otherwise, fetch directly
if (selectedPrompt.arguments && selectedPrompt.arguments.length > 0) {
onFetchPrompt(selectedPrompt);
} else {
// No arguments, fetch directly
(async () => {
try {
const invocation = await inspectorClient.getPrompt(
selectedPrompt.name,
);
// Show result in details modal
if (onViewDetails) {
onViewDetails({
...selectedPrompt,
result: invocation.result,
});
}
} catch (error) {
setError(
error instanceof Error ? error.message : "Failed to get prompt",
);
}
})();
}
return;
}
if (focusedPane === "list") {
if (key.upArrow && selectedIndex > 0) {
setSelection(selectedIndex - 1);
} else if (key.downArrow && selectedIndex < prompts.length - 1) {
setSelection(selectedIndex + 1);
}
return;
}
if (focusedPane === "details") {
// Handle '+' key to view in full screen modal
if (input === "+" && selectedPrompt && onViewDetails) {
onViewDetails(selectedPrompt);
return;
}
// Scroll the details pane using ink-scroll-view
if (key.upArrow) {
scrollViewRef.current?.scrollBy(-1);
} else if (key.downArrow) {
scrollViewRef.current?.scrollBy(1);
} else if (key.pageUp) {
const viewportHeight =
scrollViewRef.current?.getViewportHeight() || 1;
scrollViewRef.current?.scrollBy(-viewportHeight);
} else if (key.pageDown) {
const viewportHeight =
scrollViewRef.current?.getViewportHeight() || 1;
scrollViewRef.current?.scrollBy(viewportHeight);
}
}
},
{
isActive:
!modalOpen && (focusedPane === "list" || focusedPane === "details"),
},
);
// Reset scroll when selection changes
useEffect(() => {
scrollViewRef.current?.scrollTo(0);
}, [selectedIndex]);
const selectedPrompt = prompts[selectedIndex] || null;
const listWidth = Math.floor(width * 0.4);
const detailWidth = width - listWidth;
return (
<Box flexDirection="row" width={width} height={height}>
{/* Prompts List */}
<Box
width={listWidth}
height={height}
borderStyle="single"
borderTop={false}
borderBottom={false}
borderLeft={false}
borderRight={true}
flexDirection="column"
paddingX={1}
>
<Box paddingY={1}>
<Text
bold
backgroundColor={focusedPane === "list" ? "yellow" : undefined}
>
Prompts ({prompts.length})
</Text>
</Box>
{error ? (
<Box paddingY={1}>
<Text color="red">{error}</Text>
</Box>
) : prompts.length === 0 ? (
<Box paddingY={1}>
<Text dimColor>No prompts available</Text>
</Box>
) : (
<Box
flexDirection="column"
height={visibleCount}
overflow="hidden"
flexShrink={0}
>
{prompts
.slice(firstVisible, firstVisible + visibleCount)
.map((prompt, i) => {
const index = firstVisible + i;
const isSelected = index === selectedIndex;
return (
<Box key={prompt.name || index} paddingY={0} flexShrink={0}>
<Text>
{isSelected ? "▶ " : " "}
{prompt.name || `Prompt ${index + 1}`}
</Text>
</Box>
);
})}
</Box>
)}
</Box>
{/* Prompt Details */}
<Box
width={detailWidth}
height={height}
paddingX={1}
flexDirection="column"
overflow="hidden"
>
{selectedPrompt ? (
<>
{/* Fixed header */}
<Box flexShrink={0} paddingTop={1}>
<Text
bold
backgroundColor={
focusedPane === "details" ? "yellow" : undefined
}
{...(focusedPane === "details" ? {} : { color: "cyan" })}
>
{selectedPrompt.name}
</Text>
</Box>
{/* Scrollable content area - direct ScrollView with height prop like NotificationsTab */}
<ScrollView ref={scrollViewRef} height={height - 5}>
{/* Description */}
{selectedPrompt.description && (
<>
{selectedPrompt.description
.split("\n")
.map((line: string, idx: number) => (
<Box
key={`desc-${idx}`}
marginTop={idx === 0 ? 1 : 0}
flexShrink={0}
>
<Text dimColor>{line}</Text>
</Box>
))}
</>
)}
{/* Arguments */}
{selectedPrompt.arguments &&
selectedPrompt.arguments.length > 0 && (
<>
<Box marginTop={1} flexShrink={0}>
<Text bold>Arguments:</Text>
</Box>
{selectedPrompt.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>
),
)}
</>
)}
{/* Enter to Get Prompt message */}
<Box marginTop={1} flexShrink={0}>
<Text dimColor>[Enter to Get Prompt]</Text>
</Box>
</ScrollView>
{/* Fixed footer - only show when details pane is focused */}
{focusedPane === "details" && (
<Box
flexShrink={0}
height={1}
justifyContent="center"
backgroundColor="gray"
>
<Text bold color="white">
↑/↓ to scroll, + to zoom
</Text>
</Box>
)}
</>
) : (
<Box paddingY={1} flexShrink={0}>
<Text dimColor>Select a prompt to view details</Text>
</Box>
)}
</Box>
</Box>
);
}

View file

@ -0,0 +1,370 @@
import React, { useEffect, useRef } from "react";
import { Box, Text, useInput, type Key } from "ink";
import { ScrollView, type ScrollViewRef } from "ink-scroll-view";
import type { FetchRequestEntry } from "@inspector/core/mcp/index.js";
import { useSelectableList } from "../hooks/useSelectableList.js";
interface RequestsTabProps {
serverName: string | null;
requests: FetchRequestEntry[];
width: number;
height: number;
onCountChange?: (count: number) => void;
focusedPane?: "requests" | "details" | null;
onViewDetails?: (request: FetchRequestEntry) => void;
modalOpen?: boolean;
}
export function RequestsTab({
requests,
width,
height,
onCountChange,
focusedPane = null,
onViewDetails,
modalOpen = false,
}: RequestsTabProps) {
const visibleCount = Math.max(1, height - 7);
const { selectedIndex, firstVisible, setSelection } = useSelectableList(
requests.length,
visibleCount,
);
const scrollViewRef = useRef<ScrollViewRef>(null);
const selectedRequest = requests[selectedIndex] || null;
// Handle arrow key navigation and scrolling when focused
useInput(
(input: string, key: Key) => {
if (focusedPane === "requests") {
if (key.upArrow && selectedIndex > 0) {
setSelection(selectedIndex - 1);
} else if (key.downArrow && selectedIndex < requests.length - 1) {
setSelection(selectedIndex + 1);
} else if (key.pageUp) {
setSelection(Math.max(0, selectedIndex - visibleCount));
} else if (key.pageDown) {
setSelection(
Math.min(requests.length - 1, selectedIndex + visibleCount),
);
}
return;
}
// details scrolling (only when details pane is focused)
if (focusedPane === "details") {
// Handle '+' key to view in full screen modal
if (input === "+" && selectedRequest && onViewDetails) {
onViewDetails(selectedRequest);
return;
}
if (key.upArrow) {
scrollViewRef.current?.scrollBy(-1);
} else if (key.downArrow) {
scrollViewRef.current?.scrollBy(1);
} else if (key.pageUp) {
const viewportHeight =
scrollViewRef.current?.getViewportHeight() || 1;
scrollViewRef.current?.scrollBy(-viewportHeight);
} else if (key.pageDown) {
const viewportHeight =
scrollViewRef.current?.getViewportHeight() || 1;
scrollViewRef.current?.scrollBy(viewportHeight);
}
}
},
{ isActive: !modalOpen && focusedPane !== undefined },
);
// Update count when requests change
React.useEffect(() => {
onCountChange?.(requests.length);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [requests.length]);
// Reset details scroll when request selection changes
useEffect(() => {
scrollViewRef.current?.scrollTo(0);
}, [selectedIndex]);
const listWidth = Math.floor(width * 0.4);
const detailWidth = width - listWidth;
const getStatusColor = (status?: number): string => {
if (!status) return "gray";
if (status >= 200 && status < 300) return "green";
if (status >= 300 && status < 400) return "yellow";
if (status >= 400) return "red";
return "gray";
};
return (
<Box flexDirection="row" width={width} height={height}>
{/* Left column - Requests list */}
<Box
width={listWidth}
height={height}
borderStyle="single"
borderTop={false}
borderBottom={false}
borderLeft={false}
borderRight={true}
flexDirection="column"
paddingX={1}
>
<Box paddingY={1} flexShrink={0}>
<Text
bold
backgroundColor={focusedPane === "requests" ? "yellow" : undefined}
>
Requests ({requests.length})
</Text>
</Box>
{/* Requests list */}
{requests.length === 0 ? (
<Box paddingY={1}>
<Text dimColor>No requests</Text>
</Box>
) : (
<Box
flexDirection="column"
height={visibleCount}
overflow="hidden"
flexShrink={0}
>
{requests
.slice(firstVisible, firstVisible + visibleCount)
.map((req, i) => {
const index = firstVisible + i;
const isSelected = index === selectedIndex;
const statusColor = getStatusColor(req.responseStatus);
const statusText = req.responseStatus
? `${req.responseStatus}`
: req.error
? "ERROR"
: "...";
const categoryLabel = req.category === "auth" ? "AUTH" : "MCP ";
const methodPadded = req.method === "GET" ? "GET " : req.method;
return (
<Box key={req.id} paddingY={0} flexShrink={0}>
<Text color={isSelected ? "white" : "white"}>
{isSelected ? "▶ " : " "}
<Text>{categoryLabel}</Text>{" "}
<Text color={statusColor}>{methodPadded}</Text>{" "}
<Text dimColor>{statusText}</Text>
{req.duration !== undefined && (
<Text dimColor> {req.duration}ms</Text>
)}
</Text>
</Box>
);
})}
</Box>
)}
</Box>
{/* Right column - Request details */}
<Box
width={detailWidth}
height={height}
paddingX={1}
flexDirection="column"
flexShrink={0}
borderStyle="single"
borderTop={false}
borderBottom={false}
borderLeft={false}
borderRight={false}
>
{selectedRequest ? (
<>
{/* Fixed header */}
<Box flexShrink={0} paddingTop={1}>
<Text
bold
backgroundColor={
focusedPane === "details" ? "yellow" : undefined
}
{...(focusedPane === "details" ? {} : { color: "cyan" })}
>
{selectedRequest.method} {selectedRequest.url}
</Text>
</Box>
{/* Scrollable content area */}
<ScrollView ref={scrollViewRef} height={height - 5}>
{/* Category */}
<Box marginTop={1} flexShrink={0}>
<Text bold>
Category:{" "}
<Text>
{selectedRequest.category === "auth" ? "auth" : "transport"}
</Text>
</Text>
</Box>
{/* Status */}
{selectedRequest.responseStatus !== undefined ? (
<Box marginTop={1} flexShrink={0}>
<Text bold>
Status:{" "}
<Text
color={getStatusColor(selectedRequest.responseStatus)}
>
{selectedRequest.responseStatus}{" "}
{selectedRequest.responseStatusText || ""}
</Text>
</Text>
</Box>
) : selectedRequest.error ? (
<Box marginTop={1} flexShrink={0}>
<Text bold color="red">
Error: {selectedRequest.error}
</Text>
</Box>
) : (
<Box marginTop={1} flexShrink={0}>
<Text dimColor italic>
Request in progress...
</Text>
</Box>
)}
{/* Duration */}
{selectedRequest.duration !== undefined && (
<Box marginTop={1} flexShrink={0}>
<Text dimColor>
{selectedRequest.timestamp.toLocaleTimeString()} (
{selectedRequest.duration}ms)
</Text>
</Box>
)}
{/* Request Headers */}
<Box marginTop={1} flexShrink={0}>
<Text bold>Request Headers:</Text>
</Box>
{Object.entries(selectedRequest.requestHeaders).map(
([key, value]) => (
<Box key={key} marginTop={0} paddingLeft={2} flexShrink={0}>
<Text dimColor>
{key}: {value}
</Text>
</Box>
),
)}
{/* Request Body */}
{selectedRequest.requestBody && (
<>
<Box marginTop={1} flexShrink={0}>
<Text bold>Request Body:</Text>
</Box>
{(() => {
try {
const parsed = JSON.parse(selectedRequest.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>{selectedRequest.requestBody}</Text>
</Box>
);
}
})()}
</>
)}
{/* Response Headers */}
{selectedRequest.responseHeaders &&
Object.keys(selectedRequest.responseHeaders).length > 0 && (
<>
<Box marginTop={1} flexShrink={0}>
<Text bold>Response Headers:</Text>
</Box>
{Object.entries(selectedRequest.responseHeaders).map(
([key, value]) => (
<Box
key={key}
marginTop={0}
paddingLeft={2}
flexShrink={0}
>
<Text dimColor>
{key}: {value}
</Text>
</Box>
),
)}
</>
)}
{/* Response Body */}
{selectedRequest.responseBody && (
<>
<Box marginTop={1} flexShrink={0}>
<Text bold>Response Body:</Text>
</Box>
{(() => {
try {
const parsed = JSON.parse(selectedRequest.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>{selectedRequest.responseBody}</Text>
</Box>
);
}
})()}
</>
)}
</ScrollView>
{/* Fixed footer - only show when details pane is focused */}
{focusedPane === "details" && (
<Box
flexShrink={0}
height={1}
justifyContent="center"
backgroundColor="gray"
>
<Text bold color="white">
↑/↓ to scroll, + to zoom
</Text>
</Box>
)}
</>
) : (
<Box paddingY={1} flexShrink={0}>
<Text dimColor>Select a request to view details</Text>
</Box>
)}
</Box>
</Box>
);
}

View file

@ -0,0 +1,321 @@
import React, { useState } from "react";
import { Box, Text, useInput, type Key } from "ink";
import { Form } from "ink-form";
import { InspectorClient } from "@inspector/core/mcp/index.js";
import type { ReadResourceResult } from "@modelcontextprotocol/sdk/types.js";
import { uriTemplateToForm } from "../utils/uriTemplateToForm.js";
import { ScrollView, type ScrollViewRef } from "ink-scroll-view";
// Helper to extract error message from various error types
function getErrorMessage(error: unknown): string {
if (error instanceof Error) {
return error.message;
}
if (typeof error === "string") {
return error;
}
if (error && typeof error === "object" && "message" in error) {
return String(error.message);
}
return "Unknown error";
}
interface ResourceTestModalProps {
template: {
name: string;
uriTemplate: string;
description?: string;
};
inspectorClient: InspectorClient | null;
width: number;
height: number;
onClose: () => void;
}
type ModalState = "form" | "loading" | "results";
interface ResourceResult {
input: Record<string, string>;
output: ReadResourceResult | null;
error?: string;
errorDetails?: unknown;
duration: number;
uri: string;
}
export function ResourceTestModal({
template,
inspectorClient,
width,
height,
onClose,
}: ResourceTestModalProps) {
const [state, setState] = useState<ModalState>("form");
const [result, setResult] = useState<ResourceResult | null>(null);
const scrollViewRef = React.useRef<ScrollViewRef>(null);
// Use full terminal dimensions instead of passed dimensions
const [terminalDimensions, setTerminalDimensions] = React.useState({
width: process.stdout.columns || width,
height: process.stdout.rows || height,
});
React.useEffect(() => {
const updateDimensions = () => {
setTerminalDimensions({
width: process.stdout.columns || width,
height: process.stdout.rows || height,
});
};
process.stdout.on("resize", updateDimensions);
updateDimensions();
return () => {
process.stdout.off("resize", updateDimensions);
};
}, [width, height]);
const formStructure = uriTemplateToForm(
template.uriTemplate,
template.name || "Unknown Template",
);
// Reset state when modal closes
React.useEffect(() => {
return () => {
// Cleanup: reset state when component unmounts
setState("form");
setResult(null);
};
}, []);
// Handle all input when modal is open - prevents input from reaching underlying components
// When in form mode, only handle escape (form handles its own input)
// When in results mode, handle scrolling keys
useInput(
(input: string, key: Key) => {
// Always handle escape to close modal
if (key.escape) {
setState("form");
setResult(null);
onClose();
return;
}
if (state === "form") {
// In form mode, let the form handle all other input
// Don't process anything else - this prevents input from reaching underlying components
return;
}
if (state === "results") {
// Allow scrolling in results view
if (key.downArrow) {
scrollViewRef.current?.scrollBy(1);
} else if (key.upArrow) {
scrollViewRef.current?.scrollBy(-1);
} else if (key.pageDown) {
const viewportHeight =
scrollViewRef.current?.getViewportHeight() || 1;
scrollViewRef.current?.scrollBy(viewportHeight);
} else if (key.pageUp) {
const viewportHeight =
scrollViewRef.current?.getViewportHeight() || 1;
scrollViewRef.current?.scrollBy(-viewportHeight);
}
}
},
{ isActive: true },
);
const handleFormSubmit = async (values: Record<string, string>) => {
if (!inspectorClient || !template) return;
setState("loading");
const startTime = Date.now();
try {
// Use InspectorClient's readResourceFromTemplate method which encapsulates template expansion and resource reading
const invocation = await inspectorClient.readResourceFromTemplate(
template.uriTemplate,
values,
);
const duration = Date.now() - startTime;
setResult({
input: values,
output: invocation.result, // Extract the SDK result from the invocation
duration,
uri: invocation.expandedUri, // Use expandedUri instead of uri
});
setState("results");
} catch (error) {
const duration = Date.now() - startTime;
const errorMessage = getErrorMessage(error);
// Try to get expanded URI from error if available, otherwise use template
let uri = template.uriTemplate;
// If the error response contains uri, use it
if (error && typeof error === "object" && "uri" in error) {
uri = (error as { uri: string }).uri;
}
// Extract detailed error information
const errorObj: Record<string, unknown> = {
message: errorMessage,
};
if (error instanceof Error) {
errorObj.name = error.name;
errorObj.stack = error.stack;
} else if (error && typeof error === "object") {
// Try to extract more details from error object
Object.assign(errorObj, error);
} else {
errorObj.error = String(error);
}
setResult({
input: values,
output: null,
error: errorMessage,
errorDetails: errorObj,
duration,
uri,
});
setState("results");
}
};
// Calculate modal dimensions - use almost full screen
const modalWidth = terminalDimensions.width - 2;
const modalHeight = terminalDimensions.height - 2;
return (
<Box
position="absolute"
width={terminalDimensions.width}
height={terminalDimensions.height}
flexDirection="column"
justifyContent="center"
alignItems="center"
>
{/* Modal Content */}
<Box
width={modalWidth}
height={modalHeight}
borderStyle="single"
borderColor="cyan"
flexDirection="column"
paddingX={1}
paddingY={1}
backgroundColor="black"
>
{/* Header */}
<Box flexShrink={0} marginBottom={1}>
<Text bold color="cyan">
{formStructure.title}
</Text>
<Text> </Text>
<Text dimColor>(Press ESC to close)</Text>
</Box>
{/* Content Area */}
<Box flexGrow={1} flexDirection="column" overflow="hidden">
{state === "form" && (
<Box flexGrow={1} flexDirection="column">
{template.description && (
<Box marginBottom={1} flexShrink={0}>
<Text dimColor>{template.description}</Text>
</Box>
)}
<Form
form={formStructure}
onSubmit={(values: object) =>
handleFormSubmit(values as Record<string, string>)
}
/>
</Box>
)}
{state === "loading" && (
<Box flexGrow={1} justifyContent="center" alignItems="center">
<Text color="yellow">Reading resource...</Text>
</Box>
)}
{state === "results" && result && (
<Box flexGrow={1} flexDirection="column" overflow="hidden">
<ScrollView ref={scrollViewRef}>
{/* Timing */}
<Box marginBottom={1} flexShrink={0}>
<Text bold color="green">
Duration: {result.duration}ms
</Text>
</Box>
{/* URI */}
<Box marginBottom={1} flexShrink={0}>
<Text bold color="cyan">
URI:{" "}
</Text>
<Text dimColor>{result.uri}</Text>
</Box>
{/* Input */}
<Box marginBottom={1} flexShrink={0} flexDirection="column">
<Text bold color="cyan">
Template Values:
</Text>
<Box paddingLeft={2}>
<Text dimColor>
{JSON.stringify(result.input, null, 2)}
</Text>
</Box>
</Box>
{/* Output or Error */}
{result.error ? (
<Box flexShrink={0} flexDirection="column">
<Box marginTop={1} flexShrink={0}>
<Text bold color="red">
Error:
</Text>
</Box>
<Box marginTop={1} paddingLeft={2} flexShrink={0}>
<Text color="red">{String(result.error)}</Text>
</Box>
{result.errorDetails != null ? (
<>
<Box marginTop={1} flexShrink={0}>
<Text bold color="red" dimColor>
Error Details:
</Text>
</Box>
<Box marginTop={1} paddingLeft={2} flexShrink={0}>
<Text dimColor>
{JSON.stringify(result.errorDetails, null, 2)}
</Text>
</Box>
</>
) : null}
</Box>
) : (
<Box flexShrink={0} flexDirection="column">
<Text bold color="green">
Resource Content:
</Text>
<Box paddingLeft={2}>
<Text dimColor>
{JSON.stringify(result.output, null, 2)}
</Text>
</Box>
</Box>
)}
</ScrollView>
</Box>
)}
</Box>
</Box>
</Box>
);
}

View file

@ -0,0 +1,430 @@
import React, { useState, useEffect, useRef, useMemo } from "react";
import { Box, Text, useInput, type Key } from "ink";
import { ScrollView, type ScrollViewRef } from "ink-scroll-view";
import type { InspectorClient } from "@inspector/core/mcp/index.js";
import type {
Resource,
ReadResourceResult,
} from "@modelcontextprotocol/sdk/types.js";
import { useSelectableList } from "../hooks/useSelectableList.js";
interface ResourceTemplate {
name: string;
uriTemplate: string;
description?: string;
}
interface ResourcesTabProps {
resources: Resource[];
resourceTemplates?: ResourceTemplate[];
inspectorClient: InspectorClient | null;
width: number;
height: number;
onCountChange?: (count: number) => void;
focusedPane?: "list" | "details" | null;
onViewDetails?: (
resource: Resource | { content: ReadResourceResult },
) => void;
onFetchResource?: (resource: Resource) => void;
onFetchTemplate?: (template: ResourceTemplate) => void;
modalOpen?: boolean;
}
export function ResourcesTab({
resources,
resourceTemplates = [],
inspectorClient,
width,
height,
onCountChange,
focusedPane = null,
onViewDetails,
onFetchResource,
onFetchTemplate,
modalOpen = false,
}: ResourcesTabProps) {
const [error, setError] = useState<string | null>(null);
const [resourceContent, setResourceContent] =
useState<ReadResourceResult | null>(null);
const [loading, setLoading] = useState<boolean>(false);
const [shouldFetchResource, setShouldFetchResource] = useState<string | null>(
null,
);
const scrollViewRef = useRef<ScrollViewRef>(null);
// Combined list: resources first, then templates - memoized to prevent unnecessary recalculations
const allItems = useMemo(
() => [
...resources.map((r) => ({ type: "resource" as const, data: r })),
...resourceTemplates.map((t) => ({ type: "template" as const, data: t })),
],
[resources, resourceTemplates],
);
const totalCount = useMemo(
() => resources.length + resourceTemplates.length,
[resources.length, resourceTemplates.length],
);
const visibleCount = Math.max(1, height - 7);
const { selectedIndex, firstVisible, setSelection } = useSelectableList(
totalCount,
visibleCount,
{ resetWhen: resources },
);
const selectedItem = useMemo(
() => allItems[selectedIndex] || null,
[allItems, selectedIndex],
);
// Handle arrow key navigation when focused
useInput(
(input: string, key: Key) => {
// Handle Enter key to fetch resource (works from both list and details)
if (
key.return &&
selectedItem &&
inspectorClient &&
(onFetchResource || onFetchTemplate)
) {
if (selectedItem.type === "resource" && selectedItem.data.uri) {
// Trigger fetch for regular resource
setShouldFetchResource(selectedItem.data.uri);
if (onFetchResource) {
onFetchResource(selectedItem.data);
}
} else if (selectedItem.type === "template" && onFetchTemplate) {
// Open modal for template
onFetchTemplate(selectedItem.data);
}
return;
}
if (focusedPane === "list") {
if (key.upArrow && selectedIndex > 0) {
setSelection(selectedIndex - 1);
} else if (key.downArrow && selectedIndex < totalCount - 1) {
setSelection(selectedIndex + 1);
}
return;
}
if (focusedPane === "details") {
// Handle '+' key to view in full screen modal
if (input === "+" && resourceContent && onViewDetails) {
onViewDetails({ content: resourceContent });
return;
}
// Scroll the details pane using ink-scroll-view
if (key.upArrow) {
scrollViewRef.current?.scrollBy(-1);
} else if (key.downArrow) {
scrollViewRef.current?.scrollBy(1);
} else if (key.pageUp) {
const viewportHeight =
scrollViewRef.current?.getViewportHeight() || 1;
scrollViewRef.current?.scrollBy(-viewportHeight);
} else if (key.pageDown) {
const viewportHeight =
scrollViewRef.current?.getViewportHeight() || 1;
scrollViewRef.current?.scrollBy(viewportHeight);
}
}
},
{
isActive:
!modalOpen && (focusedPane === "list" || focusedPane === "details"),
},
);
// Reset scroll when selection changes
useEffect(() => {
scrollViewRef.current?.scrollTo(0);
}, [selectedIndex]);
// Clear fetched content when resources change
const prevResourcesRef = useRef<Resource[]>(resources);
useEffect(() => {
if (prevResourcesRef.current !== resources) {
setResourceContent(null);
setShouldFetchResource(null);
prevResourcesRef.current = resources;
}
}, [resources]);
const isResource = selectedItem?.type === "resource";
const isTemplate = selectedItem?.type === "template";
const selectedResource = isResource ? selectedItem.data : null;
const selectedTemplate = isTemplate ? selectedItem.data : null;
// Fetch resource content when shouldFetchResource is set
useEffect(() => {
if (!shouldFetchResource || !inspectorClient) return;
const fetchContent = async () => {
setLoading(true);
setError(null);
try {
const invocation =
await inspectorClient.readResource(shouldFetchResource);
setResourceContent(invocation.result);
} catch (err) {
setError(
err instanceof Error ? err.message : "Failed to read resource",
);
setResourceContent(null);
} finally {
setLoading(false);
setShouldFetchResource(null);
}
};
fetchContent();
}, [shouldFetchResource, inspectorClient]);
const listWidth = Math.floor(width * 0.4);
const detailWidth = width - listWidth;
// Update count when items change - use ref to track previous count and only call when it actually changes
const prevCountRef = useRef<number>(totalCount);
useEffect(() => {
if (prevCountRef.current !== totalCount) {
prevCountRef.current = totalCount;
onCountChange?.(totalCount);
}
}, [totalCount, onCountChange]);
return (
<Box flexDirection="row" width={width} height={height}>
{/* Resources and Templates List */}
<Box
width={listWidth}
height={height}
borderStyle="single"
borderTop={false}
borderBottom={false}
borderLeft={false}
borderRight={true}
flexDirection="column"
paddingX={1}
>
<Box paddingY={1}>
<Text
bold
backgroundColor={focusedPane === "list" ? "yellow" : undefined}
>
Resources ({totalCount})
</Text>
</Box>
{error ? (
<Box paddingY={1}>
<Text color="red">{error}</Text>
</Box>
) : totalCount === 0 ? (
<Box paddingY={1}>
<Text dimColor>No resources available</Text>
</Box>
) : (
<Box
flexDirection="column"
height={visibleCount}
overflow="hidden"
flexShrink={0}
>
{allItems
.slice(firstVisible, firstVisible + visibleCount)
.map((item, i) => {
const index = firstVisible + i;
const isSelected = index === selectedIndex;
const label =
item.type === "resource"
? item.data.name || item.data.uri || `Resource ${index + 1}`
: item.data.name ||
`Template ${index - resources.length + 1}`;
const key =
item.type === "resource"
? item.data.uri || index
: item.data.uriTemplate || index;
return (
<Box key={key} paddingY={0} flexShrink={0}>
<Text>
{isSelected ? "▶ " : " "}
{label}
</Text>
</Box>
);
})}
</Box>
)}
</Box>
{/* Resource Details */}
<Box
width={detailWidth}
height={height}
paddingX={1}
flexDirection="column"
overflow="hidden"
>
{selectedResource ? (
<>
{/* Fixed header */}
<Box flexShrink={0} paddingTop={1}>
<Text
bold
backgroundColor={
focusedPane === "details" ? "yellow" : undefined
}
{...(focusedPane === "details" ? {} : { color: "cyan" })}
>
{selectedResource.name || selectedResource.uri}
</Text>
</Box>
{/* Scrollable content area */}
<ScrollView ref={scrollViewRef} height={height - 3}>
{/* Description */}
{selectedResource.description && (
<>
{selectedResource.description
.split("\n")
.map((line: string, idx: number) => (
<Box
key={`desc-${idx}`}
marginTop={idx === 0 ? 1 : 0}
flexShrink={0}
>
<Text dimColor>{line}</Text>
</Box>
))}
</>
)}
{/* URI */}
{selectedResource.uri && (
<Box marginTop={1} flexShrink={0}>
<Text dimColor>URI: {selectedResource.uri}</Text>
</Box>
)}
{/* MIME Type */}
{selectedResource.mimeType && (
<Box marginTop={1} flexShrink={0}>
<Text dimColor>MIME Type: {selectedResource.mimeType}</Text>
</Box>
)}
{/* Resource Content */}
{loading && (
<Box marginTop={1} flexShrink={0}>
<Text color="yellow">Loading resource content...</Text>
</Box>
)}
{!loading && resourceContent && (
<>
<Box marginTop={1} flexShrink={0}>
<Text bold>Content:</Text>
</Box>
<Box marginTop={1} paddingLeft={2} flexShrink={0}>
<Text dimColor>
{JSON.stringify(resourceContent, null, 2)}
</Text>
</Box>
</>
)}
{!loading && !resourceContent && selectedResource.uri && (
<Box marginTop={1} flexShrink={0}>
<Text dimColor>[Enter to Fetch Resource]</Text>
</Box>
)}
</ScrollView>
{/* Fixed footer - only show when details pane is focused */}
{focusedPane === "details" && (
<Box
flexShrink={0}
height={1}
justifyContent="center"
backgroundColor="gray"
>
<Text bold color="white">
{resourceContent
? "↑/↓ to scroll, + to zoom"
: "Enter to fetch, ↑/↓ to scroll"}
</Text>
</Box>
)}
</>
) : selectedTemplate ? (
<>
{/* Fixed header */}
<Box flexShrink={0} paddingTop={1}>
<Text
bold
backgroundColor={
focusedPane === "details" ? "yellow" : undefined
}
{...(focusedPane === "details" ? {} : { color: "cyan" })}
>
{selectedTemplate.name}
</Text>
</Box>
{/* Scrollable content area */}
<ScrollView ref={scrollViewRef} height={height - 3}>
{/* Description */}
{selectedTemplate.description && (
<>
{selectedTemplate.description
.split("\n")
.map((line: string, idx: number) => (
<Box
key={`desc-${idx}`}
marginTop={idx === 0 ? 1 : 0}
flexShrink={0}
>
<Text dimColor>{line}</Text>
</Box>
))}
</>
)}
{/* URI Template */}
{selectedTemplate.uriTemplate && (
<Box marginTop={1} flexShrink={0}>
<Text dimColor>
URI Template: {selectedTemplate.uriTemplate}
</Text>
</Box>
)}
<Box marginTop={1} flexShrink={0}>
<Text dimColor>[Enter to Fetch Resource]</Text>
</Box>
</ScrollView>
{/* Fixed footer - only show when details pane is focused */}
{focusedPane === "details" && (
<Box
flexShrink={0}
height={1}
justifyContent="center"
backgroundColor="gray"
>
<Text bold color="white">
Enter to fetch
</Text>
</Box>
)}
</>
) : (
<Box paddingY={1} flexShrink={0}>
<Text dimColor>Select a resource or template to view details</Text>
</Box>
)}
</Box>
</Box>
);
}

View file

@ -0,0 +1,22 @@
import React from "react";
import { Box, Text } from "ink";
/** Renders a selectable item: ▶ + space when selected, space + space when not. Fixed width to prevent layout shift. */
export function SelectableItem({
isSelected,
bold,
children,
}: {
isSelected: boolean;
bold?: boolean;
children: React.ReactNode;
}) {
return (
<Box flexShrink={0} flexDirection="row">
<Box width={2}>
<Text bold={bold}>{isSelected ? "▶ " : " "}</Text>
</Box>
<Text bold={bold}>{children}</Text>
</Box>
);
}

View file

@ -0,0 +1,85 @@
import React from "react";
import { Box, Text } from "ink";
import { type TabType, tabs } from "./tabsConfig.js";
interface TabsProps {
activeTab: TabType;
onTabChange: (tab: TabType) => void;
width: number;
counts?: {
info?: number;
auth?: number;
resources?: number;
prompts?: number;
tools?: number;
messages?: number;
requests?: number;
logging?: number;
};
focused?: boolean;
showAuth?: boolean;
showLogging?: boolean;
showRequests?: boolean;
}
export function Tabs({
activeTab,
width,
counts = {},
focused = false,
showAuth = true,
showLogging = true,
showRequests = false,
}: TabsProps) {
let visibleTabs = tabs;
if (!showAuth) {
visibleTabs = visibleTabs.filter((tab) => tab.id !== "auth");
}
if (!showLogging) {
visibleTabs = visibleTabs.filter((tab) => tab.id !== "logging");
}
if (!showRequests) {
visibleTabs = visibleTabs.filter((tab) => tab.id !== "requests");
}
return (
<Box
width={width}
flexShrink={0}
borderStyle="single"
borderTop={false}
borderLeft={false}
borderRight={false}
borderBottom={true}
flexDirection="row"
justifyContent="space-between"
flexWrap="wrap"
paddingX={1}
>
{visibleTabs.map((tab) => {
const isActive = activeTab === tab.id;
const count = counts[tab.id];
const countText = count !== undefined ? ` (${count})` : "";
const firstChar = tab.label[0];
const restOfLabel = tab.label.slice(1);
return (
<Box key={tab.id} flexShrink={0}>
<Text
bold={isActive}
{...(isActive && focused
? {}
: { color: isActive ? "cyan" : "gray" })}
backgroundColor={isActive && focused ? "yellow" : undefined}
>
{isActive ? "▶ " : " "}
<Text underline>{firstChar}</Text>
{restOfLabel}
{countText}
</Text>
</Box>
);
})}
</Box>
);
}

View file

@ -0,0 +1,287 @@
import React, { useState } from "react";
import { Box, Text, useInput, type Key } from "ink";
import { Form } from "ink-form";
import { InspectorClient } from "@inspector/core/mcp/index.js";
import type { Tool, CallToolResult } from "@modelcontextprotocol/sdk/types.js";
import type { JsonValue } from "@inspector/core/mcp/index.js";
import { schemaToForm } from "../utils/schemaToForm.js";
import { ScrollView, type ScrollViewRef } from "ink-scroll-view";
interface ToolTestModalProps {
tool: Tool;
inspectorClient: InspectorClient | null;
width: number;
height: number;
onClose: () => void;
}
type ModalState = "form" | "loading" | "results";
interface ToolResult {
input: Record<string, JsonValue>;
output: CallToolResult | null;
error?: string;
errorDetails?: unknown;
duration: number;
}
export function ToolTestModal({
tool,
inspectorClient,
width,
height,
onClose,
}: ToolTestModalProps) {
const [state, setState] = useState<ModalState>("form");
const [result, setResult] = useState<ToolResult | null>(null);
const scrollViewRef = React.useRef<ScrollViewRef>(null);
// Use full terminal dimensions instead of passed dimensions
const [terminalDimensions, setTerminalDimensions] = React.useState({
width: process.stdout.columns || width,
height: process.stdout.rows || height,
});
React.useEffect(() => {
const updateDimensions = () => {
setTerminalDimensions({
width: process.stdout.columns || width,
height: process.stdout.rows || height,
});
};
process.stdout.on("resize", updateDimensions);
updateDimensions();
return () => {
process.stdout.off("resize", updateDimensions);
};
}, [width, height]);
const formStructure = tool?.inputSchema
? schemaToForm(tool.inputSchema, tool.name || "Unknown Tool")
: {
title: `Test Tool: ${tool?.name || "Unknown"}`,
sections: [{ title: "Parameters", fields: [] }],
};
// Reset state when modal closes
React.useEffect(() => {
return () => {
// Cleanup: reset state when component unmounts
setState("form");
setResult(null);
};
}, []);
// Handle all input when modal is open - prevents input from reaching underlying components
// When in form mode, only handle escape (form handles its own input)
// When in results mode, handle scrolling keys
useInput(
(input: string, key: Key) => {
// Always handle escape to close modal
if (key.escape) {
setState("form");
setResult(null);
onClose();
return;
}
if (state === "form") {
// In form mode, let the form handle all other input
// Don't process anything else - this prevents input from reaching underlying components
return;
}
if (state === "results") {
// Allow scrolling in results view
if (key.downArrow) {
scrollViewRef.current?.scrollBy(1);
} else if (key.upArrow) {
scrollViewRef.current?.scrollBy(-1);
} else if (key.pageDown) {
const viewportHeight =
scrollViewRef.current?.getViewportHeight() || 1;
scrollViewRef.current?.scrollBy(viewportHeight);
} else if (key.pageUp) {
const viewportHeight =
scrollViewRef.current?.getViewportHeight() || 1;
scrollViewRef.current?.scrollBy(-viewportHeight);
}
}
},
{ isActive: true },
);
const handleFormSubmit = async (values: Record<string, JsonValue>) => {
if (!inspectorClient || !tool) return;
setState("loading");
const startTime = Date.now();
try {
// Use InspectorClient.callTool() which handles parameter conversion and metadata
const invocation = await inspectorClient.callTool(tool, values);
const duration = Date.now() - startTime;
// InspectorClient.callTool() returns ToolCallInvocation
// Check if the call succeeded and extract the result
if (!invocation.success || invocation.result === null) {
// Error case: tool call failed
setResult({
input: values,
output: null,
error: invocation.error || "Tool call failed",
errorDetails: invocation,
duration,
});
} else {
// Success case: extract the result
const result = invocation.result;
// Check for error indicators in the result (SDK may return error in result)
const isError = "isError" in result && result.isError === true;
setResult({
input: values,
output: isError ? null : result,
error: isError ? "Tool returned an error" : undefined,
errorDetails: isError ? result : undefined,
duration,
});
}
setState("results");
} catch (error) {
const duration = Date.now() - startTime;
const errorObj =
error instanceof Error
? { message: error.message, name: error.name, stack: error.stack }
: { error: String(error) };
setResult({
input: values,
output: null,
error: error instanceof Error ? error.message : "Unknown error",
errorDetails: errorObj,
duration,
});
setState("results");
}
};
// Calculate modal dimensions - use almost full screen
const modalWidth = terminalDimensions.width - 2;
const modalHeight = terminalDimensions.height - 2;
return (
<Box
position="absolute"
width={terminalDimensions.width}
height={terminalDimensions.height}
flexDirection="column"
justifyContent="center"
alignItems="center"
>
{/* Modal Content */}
<Box
width={modalWidth}
height={modalHeight}
borderStyle="single"
borderColor="cyan"
flexDirection="column"
paddingX={1}
paddingY={1}
backgroundColor="black"
>
{/* Header */}
<Box flexShrink={0} marginBottom={1}>
<Text bold color="cyan">
{formStructure.title}
</Text>
<Text> </Text>
<Text dimColor>(Press ESC to close)</Text>
</Box>
{/* Content Area */}
<Box flexGrow={1} flexDirection="column" overflow="hidden">
{state === "form" && (
<Box flexGrow={1} width="100%">
<Form
form={formStructure}
onSubmit={(value: object) =>
void handleFormSubmit(value as Record<string, JsonValue>)
}
/>
</Box>
)}
{state === "loading" && (
<Box flexGrow={1} justifyContent="center" alignItems="center">
<Text color="yellow">Calling tool...</Text>
</Box>
)}
{state === "results" && result && (
<Box flexGrow={1} flexDirection="column" overflow="hidden">
<ScrollView ref={scrollViewRef}>
{/* Timing */}
<Box marginBottom={1} flexShrink={0}>
<Text bold color="green">
Duration: {result.duration}ms
</Text>
</Box>
{/* Input */}
<Box marginBottom={1} flexShrink={0} flexDirection="column">
<Text bold color="cyan">
Input:
</Text>
<Box paddingLeft={2}>
<Text dimColor>
{JSON.stringify(result.input, null, 2)}
</Text>
</Box>
</Box>
{/* Output or Error */}
{result.error ? (
<Box flexShrink={0} flexDirection="column">
<Text bold color="red">
Error:
</Text>
<Box paddingLeft={2}>
<Text color="red">{String(result.error)}</Text>
</Box>
{result.errorDetails != null ? (
<>
<Box marginTop={1}>
<Text bold color="red" dimColor>
Error Details:
</Text>
</Box>
<Box paddingLeft={2}>
<Text dimColor>
{JSON.stringify(result.errorDetails, null, 2)}
</Text>
</Box>
</>
) : null}
</Box>
) : (
<Box flexShrink={0} flexDirection="column">
<Text bold color="green">
Output:
</Text>
<Box paddingLeft={2}>
<Text dimColor>
{JSON.stringify(result.output, null, 2)}
</Text>
</Box>
</Box>
)}
</ScrollView>
</Box>
)}
</Box>
</Box>
</Box>
);
}

View file

@ -0,0 +1,247 @@
import React, { useState, useEffect, useRef } from "react";
import { Box, Text, useInput, type Key } from "ink";
import { ScrollView, type ScrollViewRef } from "ink-scroll-view";
import type { Tool } from "@modelcontextprotocol/sdk/types.js";
import { useSelectableList } from "../hooks/useSelectableList.js";
interface ToolsTabProps {
tools: Tool[];
isConnected: boolean;
width: number;
height: number;
onCountChange?: (count: number) => void;
focusedPane?: "list" | "details" | null;
onTestTool?: (tool: Tool) => void;
onViewDetails?: (tool: Tool) => void;
modalOpen?: boolean;
}
export function ToolsTab({
tools,
isConnected,
width,
height,
focusedPane = null,
onTestTool,
onViewDetails,
modalOpen = false,
}: ToolsTabProps) {
const visibleCount = Math.max(1, height - 7);
const { selectedIndex, firstVisible, setSelection } = useSelectableList(
tools.length,
visibleCount,
{ resetWhen: tools },
);
const [error] = useState<string | null>(null);
const scrollViewRef = useRef<ScrollViewRef>(null);
const listWidth = Math.floor(width * 0.4);
const detailWidth = width - listWidth;
// Handle arrow key navigation when focused
useInput(
(input: string, key: Key) => {
// Handle Enter key to test tool (works from both list and details)
if (key.return && selectedTool && isConnected && onTestTool) {
onTestTool(selectedTool);
return;
}
if (focusedPane === "list") {
if (key.upArrow && selectedIndex > 0) {
setSelection(selectedIndex - 1);
} else if (key.downArrow && selectedIndex < tools.length - 1) {
setSelection(selectedIndex + 1);
}
return;
}
if (focusedPane === "details") {
// Handle '+' key to view in full screen modal
if (input === "+" && selectedTool && onViewDetails) {
onViewDetails(selectedTool);
return;
}
// Scroll the details pane using ink-scroll-view
if (key.upArrow) {
scrollViewRef.current?.scrollBy(-1);
} else if (key.downArrow) {
scrollViewRef.current?.scrollBy(1);
} else if (key.pageUp) {
const viewportHeight =
scrollViewRef.current?.getViewportHeight() || 1;
scrollViewRef.current?.scrollBy(-viewportHeight);
} else if (key.pageDown) {
const viewportHeight =
scrollViewRef.current?.getViewportHeight() || 1;
scrollViewRef.current?.scrollBy(viewportHeight);
}
}
},
{
isActive:
!modalOpen && (focusedPane === "list" || focusedPane === "details"),
},
);
// Reset scroll when selection changes
useEffect(() => {
scrollViewRef.current?.scrollTo(0);
}, [selectedIndex]);
const selectedTool = tools[selectedIndex] || null;
return (
<Box flexDirection="row" width={width} height={height}>
{/* Tools List */}
<Box
width={listWidth}
height={height}
borderStyle="single"
borderTop={false}
borderBottom={false}
borderLeft={false}
borderRight={true}
flexDirection="column"
paddingX={1}
>
<Box paddingY={1}>
<Text
bold
backgroundColor={focusedPane === "list" ? "yellow" : undefined}
>
Tools ({tools.length})
</Text>
</Box>
{error ? (
<Box paddingY={1}>
<Text color="red">{error}</Text>
</Box>
) : tools.length === 0 ? (
<Box paddingY={1}>
<Text dimColor>No tools available</Text>
</Box>
) : (
<Box
flexDirection="column"
height={visibleCount}
overflow="hidden"
flexShrink={0}
>
{tools
.slice(firstVisible, firstVisible + visibleCount)
.map((tool, i) => {
const index = firstVisible + i;
const isSelected = index === selectedIndex;
return (
<Box key={tool.name || index} paddingY={0} flexShrink={0}>
<Text>
{isSelected ? "▶ " : " "}
{tool.name || `Tool ${index + 1}`}
</Text>
</Box>
);
})}
</Box>
)}
</Box>
{/* Tool Details */}
<Box
width={detailWidth}
height={height}
paddingX={1}
flexDirection="column"
overflow="hidden"
>
{selectedTool ? (
<>
{/* Fixed header */}
<Box
flexShrink={0}
flexDirection="row"
justifyContent="space-between"
paddingTop={1}
>
<Text
bold
backgroundColor={
focusedPane === "details" ? "yellow" : undefined
}
{...(focusedPane === "details" ? {} : { color: "cyan" })}
>
{selectedTool.name}
</Text>
{isConnected && (
<Text>
<Text color="cyan" bold>
[Enter to Test]
</Text>
</Text>
)}
</Box>
{/* Scrollable content area - direct ScrollView with height prop like NotificationsTab */}
<ScrollView ref={scrollViewRef} height={height - 5}>
{/* Description */}
{selectedTool.description && (
<>
{selectedTool.description
.split("\n")
.map((line: string, idx: number) => (
<Box
key={`desc-${idx}`}
marginTop={idx === 0 ? 1 : 0}
flexShrink={0}
>
<Text dimColor>{line}</Text>
</Box>
))}
</>
)}
{/* Input Schema */}
{selectedTool.inputSchema && (
<>
<Box marginTop={1} flexShrink={0}>
<Text bold>Input Schema:</Text>
</Box>
{JSON.stringify(selectedTool.inputSchema, null, 2)
.split("\n")
.map((line: string, idx: number) => (
<Box
key={`schema-${idx}`}
marginTop={idx === 0 ? 1 : 0}
paddingLeft={2}
flexShrink={0}
>
<Text dimColor>{line}</Text>
</Box>
))}
</>
)}
</ScrollView>
{/* Fixed footer - only show when details pane is focused */}
{focusedPane === "details" && (
<Box
flexShrink={0}
height={1}
justifyContent="center"
backgroundColor="gray"
>
<Text bold color="white">
↑/↓ to scroll, + to zoom
</Text>
</Box>
)}
</>
) : (
<Box paddingY={1} flexShrink={0}>
<Text dimColor>Select a tool to view details</Text>
</Box>
)}
</Box>
</Box>
);
}

View file

@ -0,0 +1,20 @@
export type TabType =
| "info"
| "auth"
| "resources"
| "prompts"
| "tools"
| "messages"
| "requests"
| "logging";
export const tabs: { id: TabType; label: string; accelerator: string }[] = [
{ id: "info", label: "Info", accelerator: "i" },
{ id: "auth", label: "Auth", accelerator: "a" },
{ id: "resources", label: "Resources", accelerator: "r" },
{ id: "prompts", label: "Prompts", accelerator: "p" },
{ id: "tools", label: "Tools", accelerator: "t" },
{ id: "messages", label: "Messages", accelerator: "m" },
{ id: "requests", label: "HTTP Requests", accelerator: "h" },
{ id: "logging", label: "Logging", accelerator: "l" },
];

View file

@ -0,0 +1,61 @@
import { useState, useEffect, useCallback } from "react";
function clampFirstVisible(
first: number,
selected: number,
visibleCount: number,
): number {
if (selected < first) return selected;
if (selected >= first + visibleCount) return selected - visibleCount + 1;
return first;
}
export interface UseSelectableListOptions {
/** When this reference changes, reset selection to 0 (e.g. tools list when switching servers) */
resetWhen?: unknown;
}
/**
* Manages selection and scroll position for a virtualized list.
* Returns selection state and a setSelection that updates both
* selectedIndex and firstVisible so the selected item stays in view.
*/
export function useSelectableList(
itemCount: number,
visibleCount: number,
options?: UseSelectableListOptions,
) {
const [selectedIndex, setSelectedIndex] = useState(0);
const [firstVisible, setFirstVisible] = useState(0);
const setSelection = useCallback(
(newIndex: number) => {
setSelectedIndex(newIndex);
setFirstVisible((prev) =>
clampFirstVisible(prev, newIndex, visibleCount),
);
},
[visibleCount],
);
// Reset when the list reference changes (e.g. different server).
useEffect(() => {
if (options?.resetWhen !== undefined) {
setSelectedIndex(0);
setFirstVisible(0);
}
}, [options?.resetWhen]);
// Clamp when list shrinks
useEffect(() => {
if (itemCount > 0 && selectedIndex >= itemCount) {
const newIndex = itemCount - 1;
setSelectedIndex(newIndex);
setFirstVisible((prev) =>
clampFirstVisible(prev, newIndex, visibleCount),
);
}
}, [itemCount, selectedIndex, visibleCount]);
return { selectedIndex, firstVisible, setSelection };
}

30
clients/tui/src/logger.ts Normal file
View file

@ -0,0 +1,30 @@
import path from "node:path";
import pino from "pino";
import type { Logger } from "pino";
let tuiLoggerInstance: Logger | undefined;
/**
* TUI file logger for auth and InspectorClient events.
* Writes to ~/.mcp-inspector/auth.log so TUI console output is not corrupted.
* Lazy-initialized so --help and other early exits never open the log stream.
*/
export function getTuiLogger(): Logger {
if (!tuiLoggerInstance) {
const logDir =
process.env.MCP_INSPECTOR_LOG_DIR ??
path.join(
process.env.HOME || process.env.USERPROFILE || ".",
".mcp-inspector",
);
const logPath = path.join(logDir, "auth.log");
tuiLoggerInstance = pino(
{
name: "mcp-inspector-tui",
level: process.env.LOG_LEVEL ?? "info",
},
pino.destination({ dest: logPath, append: true, mkdir: true }),
);
}
return tuiLoggerInstance;
}

View file

@ -0,0 +1,100 @@
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import type {
InspectorServerSettings,
MCPServerConfig,
MCPConfig,
StdioServerConfig,
} from "@inspector/core/mcp/types.js";
import { DEFAULT_TASK_TTL_MS } from "@inspector/core/mcp/types.js";
import { mcpConfigToServerEntries } from "@inspector/core/mcp/serverList.js";
import {
resolveServerConfigs,
withDefaultConfigPath,
type ServerConfigOptions,
} from "@inspector/core/mcp/node/config.js";
export type TuiServer = {
config: MCPServerConfig;
settings?: InspectorServerSettings;
};
export function headersToServerSettings(
headers?: Record<string, string>,
): InspectorServerSettings | undefined {
if (!headers || Object.keys(headers).length === 0) {
return undefined;
}
return {
headers: Object.entries(headers).map(([key, value]) => ({ key, value })),
metadata: [],
connectionTimeout: 0,
requestTimeout: 0,
taskTtl: DEFAULT_TASK_TTL_MS,
autoRefreshOnListChanged: false,
roots: [],
};
}
function applyStdioOverrides(
config: MCPServerConfig,
overrides: { env?: Record<string, string>; cwd?: string },
): MCPServerConfig {
if (config.type !== "stdio") return config;
const c = { ...config } as StdioServerConfig;
if (overrides.env && Object.keys(overrides.env).length > 0) {
c.env = { ...(c.env ?? {}), ...overrides.env };
}
if (overrides.cwd?.trim()) {
c.cwd = overrides.cwd.trim();
}
return c;
}
function mergeSettings(
base: InspectorServerSettings | undefined,
headers?: Record<string, string>,
): InspectorServerSettings | undefined {
const fromHeaders = headersToServerSettings(headers);
if (!fromHeaders) return base;
if (!base) return fromHeaders;
return { ...base, headers: fromHeaders.headers };
}
export function loadTuiServers(
serverOptions: ServerConfigOptions & { headers?: Record<string, string> },
): Record<string, TuiServer> {
serverOptions = withDefaultConfigPath(serverOptions);
const hasConfigPath = Boolean(serverOptions.configPath?.trim());
if (hasConfigPath) {
const configPath = serverOptions.configPath!;
const resolvedPath = resolve(process.cwd(), configPath);
const config = JSON.parse(readFileSync(resolvedPath, "utf-8")) as MCPConfig;
const entries = mcpConfigToServerEntries(config);
const result: Record<string, TuiServer> = {};
for (const entry of entries) {
result[entry.name] = {
config: applyStdioOverrides(entry.config, {
env: serverOptions.env,
cwd: serverOptions.cwd,
}),
settings: mergeSettings(entry.settings, serverOptions.headers),
};
}
return result;
}
const configs = resolveServerConfigs(serverOptions, "multi");
if (configs.length === 0) {
throw new Error(
"At least one server is required. Use --config <path> or ad-hoc target (command/URL).",
);
}
return {
default: {
config: configs[0]!,
settings: headersToServerSettings(serverOptions.headers),
},
};
}

View file

@ -0,0 +1,12 @@
import open from "open";
/**
* Opens a URL in the user's default browser.
* Used when handling oauthAuthorizationRequired to launch the OAuth authorization page.
*
* @param url - URL to open (string or URL)
* @returns Promise that resolves when the opener completes (or rejects on error)
*/
export async function openUrl(url: string | URL): Promise<void> {
await open(typeof url === "string" ? url : url.href);
}

Some files were not shown because too many files have changed in this diff Show more