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) <noreply@anthropic.com>
This commit is contained in:
cliffhall 2026-04-25 18:33:52 -04:00
commit 9553fb9c8a
3 changed files with 515 additions and 1 deletions

View file

@ -31,8 +31,8 @@ export const Connected: Story = {
"Tools",
"Resources",
"Prompts",
"Logging",
"Tasks",
"Logging",
"History",
],
onTabChange: fn(),

View file

@ -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<string, TaskProgress> = {
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<typeof InspectorView> = {
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<typeof InspectorView>;
export const Default: Story = {};
export const NoServers: Story = {
args: {
servers: [],
},
};

View file

@ -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<string, TaskProgress>;
history: MessageEntry[];
onToggleTheme: () => void;
}
export function InspectorView({
servers: serversInput,
tools,
prompts,
resources,
resourceTemplates,
subscriptions,
logs,
tasks,
progressByTaskId,
history,
onToggleTheme,
}: InspectorViewProps) {
const [activeServer, setActiveServer] = useState<string | undefined>(
undefined,
);
const [initializeResult, setInitializeResult] = useState<
InitializeResult | undefined
>(undefined);
const [connectionStatus, setConnectionStatus] =
useState<ConnectionStatus>("disconnected");
const [latencyMs, setLatencyMs] = useState<number | undefined>(undefined);
const [activeTab, setActiveTab] = useState<string>(SERVERS_TAB);
const [availableTabs, setAvailableTabs] = useState<string[]>([SERVERS_TAB]);
const [logLevel, setLogLevel] = useState<LoggingLevel>("info");
const [autoScroll, setAutoScroll] = useState<boolean>(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<ServerEntry[]>(
() =>
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 (
<AppShell header={{ height: 60 }} padding="md">
<AppShell.Header>
{connectionStatus === "connected" && initializeResult ? (
<ViewHeader
connected
serverInfo={initializeResult.serverInfo}
status={connectionStatus}
latencyMs={latencyMs}
activeTab={activeTab}
availableTabs={availableTabs}
onTabChange={setActiveTab}
onDisconnect={disconnect}
onToggleTheme={onToggleTheme}
/>
) : (
<ViewHeader connected={false} onToggleTheme={onToggleTheme} />
)}
</AppShell.Header>
<AppShell.Main>
{activeTab === SERVERS_TAB && (
<ServerListScreen
servers={servers}
activeServer={activeServer}
onAddManually={noop}
onImportConfig={noop}
onImportServerJson={noop}
onToggleConnection={handleToggleConnection}
onServerInfo={noop}
onSettings={noop}
onEdit={noop}
onClone={noop}
onRemove={noop}
/>
)}
{activeTab === "Tools" && (
<ToolsScreen
tools={tools}
listChanged={false}
onRefreshList={noop}
onSelectTool={noop}
onCallTool={noop}
/>
)}
{activeTab === "Prompts" && (
<PromptsScreen
prompts={prompts}
listChanged={false}
onRefreshList={noop}
onSelectPrompt={noop}
onGetPrompt={noop}
/>
)}
{activeTab === "Resources" && (
<ResourcesScreen
resources={resources}
templates={resourceTemplates}
subscriptions={subscriptions}
listChanged={false}
onRefreshList={noop}
onSelectUri={noop}
onSelectTemplate={noop}
onReadResource={noop}
onSubscribeResource={noop}
onUnsubscribeResource={noop}
/>
)}
{activeTab === "Logs" && (
<LoggingScreen
entries={logs}
currentLevel={logLevel}
onSetLevel={setLogLevel}
onClear={noop}
onExport={noop}
autoScroll={autoScroll}
onToggleAutoScroll={() => setAutoScroll((prev) => !prev)}
onCopyAll={noop}
/>
)}
{activeTab === "Tasks" && (
<TasksScreen
tasks={tasks}
progressByTaskId={progressByTaskId}
onRefresh={noop}
onClearCompleted={noop}
onCancel={noop}
/>
)}
{activeTab === "History" && (
<HistoryScreen
entries={history}
pinnedIds={new Set()}
onClearAll={noop}
onExport={noop}
onReplay={noop}
onTogglePin={noop}
/>
)}
</AppShell.Main>
</AppShell>
);
}