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:
parent
53dc4549be
commit
2488ca5e38
1 changed files with 25594 additions and 236 deletions
301
clients/tui/src/components/PromptTestModal.tsx
Normal file
301
clients/tui/src/components/PromptTestModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in a new issue