From 9553fb9c8a48d7e03300535fd6b7a72998c02c2b Mon Sep 17 00:00:00 2001 From: cliffhall Date: Sat, 25 Apr 2026 18:33:52 -0400 Subject: [PATCH] Add InspectorView state machine driving screens from props InspectorView replaces the prior pair (ConnectedView/UnconnectedView wrapping children) with a single state-machine view. Holds activeServer, connectionStatus, initializeResult, latencyMs, activeTab, and availableTabs internally; renders the appropriate screen based on activeTab; switches the header between connected and unconnected modes. Screen data (servers, tools, prompts, resources, resourceTemplates, subscriptions, logs, tasks, progressByTaskId, history) flows in via props so stories can populate the entire view with fixtures. Demo data lives in stories, not in the view. Stories include one server per transport (stdio, sse, streamable-http) so the TransportBadge / descriptor variants are all visible. Reorders ViewHeader.stories tabs to match the new ALL_TABS sequence. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../groups/ViewHeader/ViewHeader.stories.tsx | 2 +- .../InspectorView/InspectorView.stories.tsx | 285 ++++++++++++++++++ .../views/InspectorView/InspectorView.tsx | 229 ++++++++++++++ 3 files changed, 515 insertions(+), 1 deletion(-) create mode 100644 clients/web/src/components/views/InspectorView/InspectorView.stories.tsx create mode 100644 clients/web/src/components/views/InspectorView/InspectorView.tsx 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" && ( + + )} + + + ); +}