diff --git a/clients/web/src/components/groups/ViewHeader/ViewHeader.stories.tsx b/clients/web/src/components/groups/ViewHeader/ViewHeader.stories.tsx index 3b8bb5a3..b606faf0 100644 --- a/clients/web/src/components/groups/ViewHeader/ViewHeader.stories.tsx +++ b/clients/web/src/components/groups/ViewHeader/ViewHeader.stories.tsx @@ -31,8 +31,8 @@ export const Connected: Story = { "Tools", "Resources", "Prompts", - "Logging", "Tasks", + "Logging", "History", ], onTabChange: fn(), diff --git a/clients/web/src/components/views/InspectorView/InspectorView.stories.tsx b/clients/web/src/components/views/InspectorView/InspectorView.stories.tsx new file mode 100644 index 00000000..2c7df425 --- /dev/null +++ b/clients/web/src/components/views/InspectorView/InspectorView.stories.tsx @@ -0,0 +1,285 @@ +import type { + Prompt, + Resource, + ResourceTemplate, + Task, + Tool, +} from "@modelcontextprotocol/sdk/types.js"; +import type { + InspectorResourceSubscription, + MessageEntry, +} from "@inspector/core/mcp/types.js"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { fn } from "storybook/test"; +import { InspectorView } from "./InspectorView"; +import type { ServerEntry } from "../../screens/ServerListScreen/ServerListScreen"; +import type { LogEntryData } from "../../elements/LogEntry/LogEntry"; +import type { TaskProgress } from "../../groups/TaskCard/TaskCard"; + +const demoServers: ServerEntry[] = [ + { + id: "5e8c3d1f-2a4b-4c6d-8e7f-1a2b3c4d5e6f", + name: "Local Dev Server", + config: { + command: + "npx -y @modelcontextprotocol/server-filesystem /home/user/projects", + }, + info: { name: "Local Dev Server", version: "1.2.0" }, + connection: { status: "disconnected" }, + }, + { + id: "b3a7c1d2-9f8e-4a5b-bc6d-7e8f9a0b1c2d", + name: "Legacy Events Server", + config: { type: "sse", url: "https://legacy-events.example.com/mcp" }, + info: { name: "Legacy Events Server", version: "0.9.1" }, + connection: { status: "disconnected" }, + }, + { + id: "c4d5e6f7-8a9b-4c0d-9e1f-2a3b4c5d6e7f", + name: "Remote API Server", + config: { type: "streamable-http", url: "https://api.example.com/mcp" }, + info: { name: "Remote API Server", version: "2.0.0" }, + connection: { status: "disconnected" }, + }, +]; + +const demoTools: Tool[] = [ + { + name: "send_message", + title: "Send Message", + inputSchema: { type: "object" }, + }, + { + name: "create_record", + title: "Create Record", + description: "Creates a new record with the given parameters", + inputSchema: { + type: "object", + properties: { + title: { type: "string", description: "Record title" }, + count: { type: "number", description: "Number of items" }, + enabled: { + type: "boolean", + description: "Whether the record is active", + }, + }, + required: ["title"], + }, + }, + { name: "delete_records", inputSchema: { type: "object" } }, + { name: "list_users", inputSchema: { type: "object" } }, +]; + +const demoPrompts: Prompt[] = [ + { + name: "summarize", + description: "Summarize the given text into key points", + }, + { + name: "translate", + description: "Translate text from one language to another", + arguments: [ + { name: "text", required: true, description: "The text to translate" }, + { + name: "targetLanguage", + required: true, + description: "Target language code", + }, + ], + }, + { name: "code-review", description: "Review code for issues" }, + { name: "refactor" }, +]; + +const demoResources: Resource[] = [ + { + name: "config.json", + uri: "file:///config.json", + annotations: { audience: ["user"], priority: 0.8 }, + }, + { name: "README.md", uri: "file:///README.md" }, + { + name: "schema.sql", + uri: "file:///schema.sql", + annotations: { priority: 0.5 }, + }, + { name: "package.json", uri: "file:///package.json" }, +]; + +const demoResourceTemplates: ResourceTemplate[] = [ + { name: "User Profile", uriTemplate: "file:///users/{userId}/profile" }, + { + name: "Table Row", + title: "Database Table Row", + uriTemplate: "db://tables/{tableName}/rows/{rowId}", + }, +]; + +const demoSubscriptions: InspectorResourceSubscription[] = [ + { + resource: { name: "config.json", uri: "file:///config.json" }, + lastUpdated: new Date("2026-03-17T10:30:00Z"), + }, +]; + +const demoLogs: LogEntryData[] = [ + { + receivedAt: new Date("2026-03-17T10:00:00Z"), + params: { level: "info", data: "Server started on port 3000" }, + }, + { + receivedAt: new Date("2026-03-17T10:00:01Z"), + params: { + level: "debug", + logger: "config", + data: "Loading configuration from /etc/mcp/config.json", + }, + }, + { + receivedAt: new Date("2026-03-17T10:00:02Z"), + params: { + level: "warning", + logger: "http", + data: "Deprecated API endpoint called: /v1/tools", + }, + }, + { + receivedAt: new Date("2026-03-17T10:00:03Z"), + params: { + level: "error", + logger: "resources", + data: "Failed to read resource: file not found at /data/missing.txt", + }, + }, + { + receivedAt: new Date("2026-03-17T10:00:04Z"), + params: { + level: "info", + logger: "tools", + data: "Tool execution completed: search_files (245ms)", + }, + }, +]; + +const demoTasks: Task[] = [ + { + taskId: "d0b22eba71fa36229ce5c4dfadeaa7de", + status: "working", + ttl: 300000, + createdAt: "2026-03-29T20:18:20Z", + lastUpdatedAt: "2026-03-29T20:18:22Z", + statusMessage: "Processing records 650 of 1000...", + }, + { + taskId: "d487b49aa39023d907b5a2a5b506cb3", + status: "completed", + ttl: 300000, + createdAt: "2026-03-29T20:16:47Z", + lastUpdatedAt: "2026-03-29T20:16:49Z", + }, + { + taskId: "e6ebffd9cca84ddd1646d3c579a4d453", + status: "failed", + ttl: 300000, + createdAt: "2026-03-29T20:17:27Z", + lastUpdatedAt: "2026-03-29T20:17:57Z", + statusMessage: "Timeout waiting for tool response after 30s", + }, +]; + +const demoProgressByTaskId: Record = { + d0b22eba71fa36229ce5c4dfadeaa7de: { + progress: 650, + total: 1000, + message: "Processing records 650 of 1000...", + }, +}; + +const demoHistory: MessageEntry[] = [ + { + id: "req-1", + timestamp: new Date("2026-03-17T10:00:00Z"), + direction: "request", + message: { + jsonrpc: "2.0", + id: 1, + method: "tools/call", + params: { + name: "send_message", + arguments: { message: "Hello, world!" }, + }, + }, + response: { + jsonrpc: "2.0", + id: 1, + result: { + content: [{ type: "text", text: "Message sent successfully" }], + }, + }, + duration: 120, + }, + { + id: "req-2", + timestamp: new Date("2026-03-17T10:01:00Z"), + direction: "request", + message: { + jsonrpc: "2.0", + id: 2, + method: "resources/read", + params: { uri: "file:///config.json" }, + }, + response: { + jsonrpc: "2.0", + id: 2, + result: { contents: [{ uri: "file:///config.json", text: "{}" }] }, + }, + duration: 45, + }, + { + id: "req-3", + timestamp: new Date("2026-03-17T10:02:00Z"), + direction: "request", + message: { + jsonrpc: "2.0", + id: 3, + method: "tools/call", + params: { name: "delete_records", arguments: { ids: [1, 2, 3] } }, + }, + response: { + jsonrpc: "2.0", + id: 3, + error: { code: -32000, message: "Permission denied" }, + }, + duration: 350, + }, +]; + +const meta: Meta = { + title: "Views/InspectorView", + component: InspectorView, + parameters: { layout: "fullscreen" }, + args: { + onToggleTheme: fn(), + servers: demoServers, + tools: demoTools, + prompts: demoPrompts, + resources: demoResources, + resourceTemplates: demoResourceTemplates, + subscriptions: demoSubscriptions, + logs: demoLogs, + tasks: demoTasks, + progressByTaskId: demoProgressByTaskId, + history: demoHistory, + }, +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; + +export const NoServers: Story = { + args: { + servers: [], + }, +}; diff --git a/clients/web/src/components/views/InspectorView/InspectorView.tsx b/clients/web/src/components/views/InspectorView/InspectorView.tsx new file mode 100644 index 00000000..6bfa3859 --- /dev/null +++ b/clients/web/src/components/views/InspectorView/InspectorView.tsx @@ -0,0 +1,229 @@ +import { useMemo, useState } from "react"; +import { AppShell } from "@mantine/core"; +import type { + InitializeResult, + LoggingLevel, + Prompt, + Resource, + ResourceTemplate, + Task, + Tool, +} from "@modelcontextprotocol/sdk/types.js"; +import type { + ConnectionStatus, + InspectorResourceSubscription, + MessageEntry, +} from "@inspector/core/mcp/types.js"; +import { ViewHeader } from "../../groups/ViewHeader/ViewHeader"; +import { + ServerListScreen, + type ServerEntry, +} from "../../screens/ServerListScreen/ServerListScreen"; +import { ToolsScreen } from "../../screens/ToolsScreen/ToolsScreen"; +import { PromptsScreen } from "../../screens/PromptsScreen/PromptsScreen"; +import { ResourcesScreen } from "../../screens/ResourcesScreen/ResourcesScreen"; +import { LoggingScreen } from "../../screens/LoggingScreen/LoggingScreen"; +import type { LogEntryData } from "../../elements/LogEntry/LogEntry"; +import { TasksScreen } from "../../screens/TasksScreen/TasksScreen"; +import type { TaskProgress } from "../../groups/TaskCard/TaskCard"; +import { HistoryScreen } from "../../screens/HistoryScreen/HistoryScreen"; + +const SERVERS_TAB = "Servers"; + +const ALL_TABS: string[] = [ + SERVERS_TAB, + "Tools", + "Prompts", + "Resources", + "Logs", + "Tasks", + "History", +]; + +const noop = () => undefined; + +export interface InspectorViewProps { + servers: ServerEntry[]; + tools: Tool[]; + prompts: Prompt[]; + resources: Resource[]; + resourceTemplates: ResourceTemplate[]; + subscriptions: InspectorResourceSubscription[]; + logs: LogEntryData[]; + tasks: Task[]; + progressByTaskId?: Record; + history: MessageEntry[]; + onToggleTheme: () => void; +} + +export function InspectorView({ + servers: serversInput, + tools, + prompts, + resources, + resourceTemplates, + subscriptions, + logs, + tasks, + progressByTaskId, + history, + onToggleTheme, +}: InspectorViewProps) { + const [activeServer, setActiveServer] = useState( + undefined, + ); + const [initializeResult, setInitializeResult] = useState< + InitializeResult | undefined + >(undefined); + const [connectionStatus, setConnectionStatus] = + useState("disconnected"); + const [latencyMs, setLatencyMs] = useState(undefined); + const [activeTab, setActiveTab] = useState(SERVERS_TAB); + const [availableTabs, setAvailableTabs] = useState([SERVERS_TAB]); + const [logLevel, setLogLevel] = useState("info"); + const [autoScroll, setAutoScroll] = useState(true); + + // Per-card connection state derives from "is this the active server, + // and what's our current global connection status?". Cards that aren't + // the active server always render as disconnected. + const servers = useMemo( + () => + serversInput.map((s) => ({ + ...s, + connection: + s.id === activeServer + ? { status: connectionStatus } + : { status: "disconnected" }, + })), + [serversInput, activeServer, connectionStatus], + ); + + function disconnect() { + setActiveServer(undefined); + setConnectionStatus("disconnected"); + setInitializeResult(undefined); + setLatencyMs(undefined); + setAvailableTabs([SERVERS_TAB]); + setActiveTab(SERVERS_TAB); + } + + function handleToggleConnection(id: string) { + if (id === activeServer && connectionStatus === "connected") { + disconnect(); + return; + } + const target = serversInput.find((s) => s.id === id); + if (!target) return; + setActiveServer(id); + setConnectionStatus("connected"); + setInitializeResult({ + protocolVersion: "2025-06-18", + capabilities: {}, + serverInfo: target.info ?? { name: target.name, version: "0.0.0" }, + }); + setLatencyMs(42); + setAvailableTabs(ALL_TABS); + } + + return ( + + + {connectionStatus === "connected" && initializeResult ? ( + + ) : ( + + )} + + + {activeTab === SERVERS_TAB && ( + + )} + {activeTab === "Tools" && ( + + )} + {activeTab === "Prompts" && ( + + )} + {activeTab === "Resources" && ( + + )} + {activeTab === "Logs" && ( + setAutoScroll((prev) => !prev)} + onCopyAll={noop} + /> + )} + {activeTab === "Tasks" && ( + + )} + {activeTab === "History" && ( + + )} + + + ); +}