diff --git a/.claude/settings.json b/.claude/settings.json
new file mode 100644
index 00000000..1bcd18fa
--- /dev/null
+++ b/.claude/settings.json
@@ -0,0 +1,5 @@
+{
+ "enabledPlugins": {
+ "playwright@claude-plugins-official": true
+ }
+}
diff --git a/.gitignore b/.gitignore
index 6828e66b..e77e9dec 100644
--- a/.gitignore
+++ b/.gitignore
@@ -4,3 +4,4 @@
node_modules
dist
.env
+/.playwright-mcp/
diff --git a/clients/web/.storybook/main.ts b/clients/web/.storybook/main.ts
index 78cf7b39..3f89d8e6 100644
--- a/clients/web/.storybook/main.ts
+++ b/clients/web/.storybook/main.ts
@@ -12,6 +12,12 @@ const config: StorybookConfig = {
"@storybook/addon-docs",
"@storybook/addon-onboarding"
],
- "framework": "@storybook/react-vite"
+ "framework": "@storybook/react-vite",
+ previewHead: (head) => `
+ ${head}
+
+
+
+ `,
};
export default config;
\ No newline at end of file
diff --git a/clients/web/.storybook/preview.tsx b/clients/web/.storybook/preview.tsx
index 3aed15c1..7c856228 100644
--- a/clients/web/.storybook/preview.tsx
+++ b/clients/web/.storybook/preview.tsx
@@ -43,14 +43,23 @@ const preview: Preview = {
colorScheme: 'light',
},
decorators: [
- (Story, context) => (
-
-
-
-
-
-
- ),
+ (Story, context) => {
+ const isFullscreen = context.parameters?.layout === 'fullscreen'
+ return (
+
+
+
+ {isFullscreen ? (
+
+
+
+ ) : (
+
+ )}
+
+
+ )
+ },
],
parameters: {
controls: {
diff --git a/clients/web/index.html b/clients/web/index.html
index ae1890ac..cfd6d043 100644
--- a/clients/web/index.html
+++ b/clients/web/index.html
@@ -4,6 +4,9 @@
+
+
+
MCP Inspector
diff --git a/clients/web/package-lock.json b/clients/web/package-lock.json
index 087803b8..799c53df 100644
--- a/clients/web/package-lock.json
+++ b/clients/web/package-lock.json
@@ -14,7 +14,8 @@
"@mantine/hooks": "^8.3.17",
"@mantine/notifications": "^8.3.17",
"react": "^19.2.4",
- "react-dom": "^19.2.4"
+ "react-dom": "^19.2.4",
+ "react-icons": "^5.6.0"
},
"devDependencies": {
"@chromatic-com/storybook": "^5.0.1",
@@ -5138,6 +5139,15 @@
"react": "^19.2.4"
}
},
+ "node_modules/react-icons": {
+ "version": "5.6.0",
+ "resolved": "https://registry.npmjs.org/react-icons/-/react-icons-5.6.0.tgz",
+ "integrity": "sha512-RH93p5ki6LfOiIt0UtDyNg/cee+HLVR6cHHtW3wALfo+eOHTp8RnU2kRkI6E+H19zMIs03DyxUG/GfZMOGvmiA==",
+ "license": "MIT",
+ "peerDependencies": {
+ "react": "*"
+ }
+ },
"node_modules/react-is": {
"version": "16.13.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
diff --git a/clients/web/package.json b/clients/web/package.json
index 74a6a58b..db3a7f63 100644
--- a/clients/web/package.json
+++ b/clients/web/package.json
@@ -20,7 +20,8 @@
"@mantine/hooks": "^8.3.17",
"@mantine/notifications": "^8.3.17",
"react": "^19.2.4",
- "react-dom": "^19.2.4"
+ "react-dom": "^19.2.4",
+ "react-icons": "^5.6.0"
},
"devDependencies": {
"@chromatic-com/storybook": "^5.0.1",
diff --git a/clients/web/src/components/elements/AnnotationBadge/AnnotationBadge.tsx b/clients/web/src/components/elements/AnnotationBadge/AnnotationBadge.tsx
index c4aa06ca..14b8ee29 100644
--- a/clients/web/src/components/elements/AnnotationBadge/AnnotationBadge.tsx
+++ b/clients/web/src/components/elements/AnnotationBadge/AnnotationBadge.tsx
@@ -13,16 +13,13 @@ export interface AnnotationBadgeProps {
variant?: AnnotationVariant;
}
-const variantMap: Record<
- AnnotationVariant,
- { color: string; variant: string }
-> = {
- audience: { color: "blue", variant: "outline" },
- readOnly: { color: "green", variant: "filled" },
- destructive: { color: "red", variant: "filled" },
- longRun: { color: "yellow", variant: "filled" },
- priority: { color: "orange", variant: "filled" },
- default: { color: "gray", variant: "light" },
+const colorMap: Record = {
+ audience: "blue",
+ readOnly: "green",
+ destructive: "red",
+ longRun: "yellow",
+ priority: "orange",
+ default: "gray",
};
export function AnnotationBadge({
@@ -30,15 +27,10 @@ export function AnnotationBadge({
variant = "default",
}: AnnotationBadgeProps) {
const colorScheme = useComputedColorScheme();
- const { color, variant: badgeVariant } = variantMap[variant];
- const textColor =
- (variant === "priority" || variant === "destructive") &&
- colorScheme === "dark"
- ? "black"
- : undefined;
-
+ const color = colorMap[variant];
+ const textColor = colorScheme === "dark" ? "black" : "white";
return (
-
+
{label}
);
diff --git a/clients/web/src/components/elements/ListToggle/ListToggle.stories.tsx b/clients/web/src/components/elements/ListToggle/ListToggle.stories.tsx
new file mode 100644
index 00000000..fca3a320
--- /dev/null
+++ b/clients/web/src/components/elements/ListToggle/ListToggle.stories.tsx
@@ -0,0 +1,25 @@
+import type { Meta, StoryObj } from "@storybook/react-vite";
+import { fn } from "storybook/test";
+import { ListToggle } from "./ListToggle";
+
+const meta: Meta = {
+ title: "Elements/ListToggle",
+ component: ListToggle,
+};
+
+export default meta;
+type Story = StoryObj;
+
+export const Expanded: Story = {
+ args: {
+ compact: false,
+ onToggle: fn(),
+ },
+};
+
+export const Collapsed: Story = {
+ args: {
+ compact: true,
+ onToggle: fn(),
+ },
+};
diff --git a/clients/web/src/components/elements/ListToggle/ListToggle.tsx b/clients/web/src/components/elements/ListToggle/ListToggle.tsx
new file mode 100644
index 00000000..0d12ca1c
--- /dev/null
+++ b/clients/web/src/components/elements/ListToggle/ListToggle.tsx
@@ -0,0 +1,17 @@
+import { Button } from "@mantine/core";
+import { RiExpandVerticalLine, RiCollapseVerticalLine } from "react-icons/ri";
+
+export interface ListToggleProps {
+ compact: boolean;
+ onToggle: () => void;
+}
+
+export function ListToggle({ compact, onToggle }: ListToggleProps) {
+ const Icon = compact ? RiExpandVerticalLine : RiCollapseVerticalLine;
+
+ return (
+
+ );
+}
diff --git a/clients/web/src/components/elements/LogEntry/LogEntry.tsx b/clients/web/src/components/elements/LogEntry/LogEntry.tsx
index 49b035fb..73d66915 100644
--- a/clients/web/src/components/elements/LogEntry/LogEntry.tsx
+++ b/clients/web/src/components/elements/LogEntry/LogEntry.tsx
@@ -1,4 +1,5 @@
-import { Badge, Group, Text } from "@mantine/core";
+import { Group, Text } from "@mantine/core";
+import { LogLevelBadge } from "../LogLevelBadge/LogLevelBadge";
export type LogLevel =
| "debug"
@@ -17,28 +18,6 @@ export interface LogEntryProps {
logger?: string;
}
-const levelBadgeColor: Record = {
- debug: "gray",
- info: "blue",
- notice: "teal",
- warning: "yellow",
- error: "red",
- critical: "red",
- alert: "red",
- emergency: "red",
-};
-
-const levelBadgeVariant: Record = {
- debug: "light",
- info: "light",
- notice: "light",
- warning: "light",
- error: "light",
- critical: "filled",
- alert: "filled",
- emergency: "filled",
-};
-
const levelMessageColor: Record = {
debug: "dimmed",
info: "blue",
@@ -50,14 +29,6 @@ const levelMessageColor: Record = {
emergency: "red",
};
-function isBoldLevel(level: LogLevel): boolean {
- return level === "alert" || level === "emergency";
-}
-
-function getBoldWeight(level: LogLevel): number | undefined {
- return isBoldLevel(level) ? 700 : undefined;
-}
-
function formatLogger(logger: string): string {
return `[${logger}]`;
}
@@ -70,6 +41,7 @@ const TimestampText = Text.withProps({
const LoggerText = Text.withProps({
size: "xs",
+ ff: "monospace",
c: "dimmed",
});
@@ -77,15 +49,9 @@ export function LogEntry({ timestamp, level, message, logger }: LogEntryProps) {
return (
{timestamp}
-
- {level}
-
+
{logger && {formatLogger(logger)}}
-
+
{message}
diff --git a/clients/web/src/components/elements/LogLevelBadge/LogLevelBadge.stories.tsx b/clients/web/src/components/elements/LogLevelBadge/LogLevelBadge.stories.tsx
new file mode 100644
index 00000000..fda0585f
--- /dev/null
+++ b/clients/web/src/components/elements/LogLevelBadge/LogLevelBadge.stories.tsx
@@ -0,0 +1,19 @@
+import type { Meta, StoryObj } from "@storybook/react-vite";
+import { LogLevelBadge } from "./LogLevelBadge";
+
+const meta: Meta = {
+ title: "Elements/LogLevelBadge",
+ component: LogLevelBadge,
+};
+
+export default meta;
+type Story = StoryObj;
+
+export const Debug: Story = { args: { level: "debug" } };
+export const Info: Story = { args: { level: "info" } };
+export const Notice: Story = { args: { level: "notice" } };
+export const Warning: Story = { args: { level: "warning" } };
+export const Error: Story = { args: { level: "error" } };
+export const Critical: Story = { args: { level: "critical" } };
+export const Alert: Story = { args: { level: "alert" } };
+export const Emergency: Story = { args: { level: "emergency" } };
diff --git a/clients/web/src/components/elements/LogLevelBadge/LogLevelBadge.tsx b/clients/web/src/components/elements/LogLevelBadge/LogLevelBadge.tsx
new file mode 100644
index 00000000..3839b7f2
--- /dev/null
+++ b/clients/web/src/components/elements/LogLevelBadge/LogLevelBadge.tsx
@@ -0,0 +1,31 @@
+import { Badge, useComputedColorScheme } from "@mantine/core";
+import type { LogLevel } from "../LogEntry/LogEntry";
+
+export interface LogLevelBadgeProps {
+ level: LogLevel;
+}
+
+const levelColor: Record = {
+ debug: "gray",
+ info: "blue",
+ notice: "teal",
+ warning: "yellow",
+ error: "red",
+ critical: "red",
+ alert: "red",
+ emergency: "red",
+};
+
+const boldLevels: Set = new Set(["alert", "emergency"]);
+
+export function LogLevelBadge({ level }: LogLevelBadgeProps) {
+ const colorScheme = useComputedColorScheme();
+ const textColor = colorScheme === "dark" ? "black" : "white";
+ const fw = boldLevels.has(level) ? 500 : undefined;
+
+ return (
+
+ {level}
+
+ );
+}
diff --git a/clients/web/src/components/groups/HistoryControls/HistoryControls.stories.tsx b/clients/web/src/components/groups/HistoryControls/HistoryControls.stories.tsx
new file mode 100644
index 00000000..14fa3df4
--- /dev/null
+++ b/clients/web/src/components/groups/HistoryControls/HistoryControls.stories.tsx
@@ -0,0 +1,32 @@
+import type { Meta, StoryObj } from "@storybook/react-vite";
+import { fn } from "storybook/test";
+import { HistoryControls } from "./HistoryControls";
+
+const meta: Meta = {
+ title: "Groups/HistoryControls",
+ component: HistoryControls,
+};
+
+export default meta;
+type Story = StoryObj;
+
+export const Default: Story = {
+ args: {
+ searchText: "",
+ onSearchChange: fn(),
+ onMethodFilterChange: fn(),
+ onClearAll: fn(),
+ onExport: fn(),
+ },
+};
+
+export const WithFilters: Story = {
+ args: {
+ searchText: "tools",
+ methodFilter: "tools/call",
+ onSearchChange: fn(),
+ onMethodFilterChange: fn(),
+ onClearAll: fn(),
+ onExport: fn(),
+ },
+};
diff --git a/clients/web/src/components/groups/HistoryControls/HistoryControls.tsx b/clients/web/src/components/groups/HistoryControls/HistoryControls.tsx
new file mode 100644
index 00000000..361a3215
--- /dev/null
+++ b/clients/web/src/components/groups/HistoryControls/HistoryControls.tsx
@@ -0,0 +1,62 @@
+import { Button, Group, Select, Stack, TextInput, Title } from "@mantine/core";
+
+const METHOD_OPTIONS = [
+ "tools/call",
+ "tools/list",
+ "resources/read",
+ "resources/list",
+ "prompts/get",
+ "prompts/list",
+ "sampling/createMessage",
+ "elicitation/create",
+];
+
+const ToolbarButton = Button.withProps({
+ variant: "light",
+ size: "sm",
+});
+
+export interface HistoryControlsProps {
+ searchText: string;
+ methodFilter?: string;
+ onSearchChange: (text: string) => void;
+ onMethodFilterChange: (method: string) => void;
+ onClearAll: () => void;
+ onExport: () => void;
+}
+
+export function HistoryControls({
+ searchText,
+ methodFilter,
+ onSearchChange,
+ onMethodFilterChange,
+ onClearAll,
+ onExport,
+}: HistoryControlsProps) {
+ return (
+
+ History
+
+ Search
+ onSearchChange(event.currentTarget.value)}
+ />
+
+ Filter by Method
+
+ );
+}
diff --git a/clients/web/src/components/groups/HistoryEntry/HistoryEntry.stories.tsx b/clients/web/src/components/groups/HistoryEntry/HistoryEntry.stories.tsx
index 92fe5138..54dd7631 100644
--- a/clients/web/src/components/groups/HistoryEntry/HistoryEntry.stories.tsx
+++ b/clients/web/src/components/groups/HistoryEntry/HistoryEntry.stories.tsx
@@ -6,7 +6,6 @@ const meta: Meta = {
title: "Groups/HistoryEntry",
component: HistoryEntry,
args: {
- onToggleExpand: fn(),
onReplay: fn(),
onTogglePin: fn(),
},
@@ -23,7 +22,7 @@ export const SuccessCollapsed: Story = {
status: "success",
durationMs: 142,
isPinned: false,
- isExpanded: false,
+ isListExpanded: false,
},
};
@@ -35,7 +34,7 @@ export const SuccessExpanded: Story = {
status: "success",
durationMs: 142,
isPinned: false,
- isExpanded: true,
+ isListExpanded: true,
parameters: {
city: "San Francisco",
units: "celsius",
@@ -56,7 +55,7 @@ export const Error: Story = {
status: "error",
durationMs: 3200,
isPinned: false,
- isExpanded: false,
+ isListExpanded: false,
},
};
@@ -68,7 +67,7 @@ export const WithChildren: Story = {
status: "success",
durationMs: 1250,
isPinned: false,
- isExpanded: true,
+ isListExpanded: true,
parameters: {
action: "process",
},
@@ -102,7 +101,6 @@ export const Pinned: Story = {
status: "success",
durationMs: 45,
isPinned: true,
- pinLabel: "Baseline",
- isExpanded: false,
+ isListExpanded: false,
},
};
diff --git a/clients/web/src/components/groups/HistoryEntry/HistoryEntry.tsx b/clients/web/src/components/groups/HistoryEntry/HistoryEntry.tsx
index 7bb471a4..4d38d82b 100644
--- a/clients/web/src/components/groups/HistoryEntry/HistoryEntry.tsx
+++ b/clients/web/src/components/groups/HistoryEntry/HistoryEntry.tsx
@@ -1,8 +1,10 @@
+import { useEffect, useState } from "react";
import {
Badge,
Button,
Card,
Collapse,
+ Divider,
Group,
Stack,
Text,
@@ -27,9 +29,7 @@ export interface HistoryEntryProps {
response?: Record;
childEntries?: HistoryChildEntry[];
isPinned: boolean;
- pinLabel?: string;
- isExpanded: boolean;
- onToggleExpand: () => void;
+ isListExpanded: boolean;
onReplay: () => void;
onTogglePin: () => void;
}
@@ -82,9 +82,8 @@ function statusColor(status: "success" | "error"): string {
return status === "success" ? "green" : "red";
}
-function formatPinLabel(isPinned: boolean, pinLabel?: string): string {
- const base = isPinned ? "Unpin" : "Pin";
- return isPinned && pinLabel ? `${base} (${pinLabel})` : base;
+function formatPinLabel(isPinned: boolean): string {
+ return isPinned ? "Unpin" : "Pin";
}
function serializeJson(value: Record): string {
@@ -101,12 +100,15 @@ export function HistoryEntry({
response,
childEntries,
isPinned,
- pinLabel,
- isExpanded,
- onToggleExpand,
+ isListExpanded,
onReplay,
onTogglePin,
}: HistoryEntryProps) {
+ const [isExpanded, setIsExpanded] = useState(isListExpanded);
+
+ useEffect(() => {
+ setIsExpanded(isListExpanded);
+ }, [isListExpanded]);
return (
@@ -127,55 +129,60 @@ export function HistoryEntry({
Replay
- {formatPinLabel(isPinned, pinLabel)}
+ {formatPinLabel(isPinned)}
-
+ setIsExpanded((v) => !v)} ml="auto">
{isExpanded ? "Collapse" : "Expand"}
-
-
- {parameters && (
-
- Parameters:
-
-
- )}
- {response && (
-
- Response:
-
-
- )}
- {childEntries && childEntries.length > 0 && (
-
- {childEntries.map((child, index) => (
-
- +--
- {child.timestamp}
- {child.method}
- {child.target && {child.target}}
-
- {formatStatusLabel(child.status)}
-
-
- {formatDuration(child.durationMs)}
-
-
- ))}
-
- )}
-
-
+ {isExpanded && (
+
+
+ {parameters && (
+ <>
+
+
+ Parameters:
+
+
+ >
+ )}
+ {response && (
+
+ Response:
+
+
+ )}
+ {childEntries && childEntries.length > 0 && (
+
+ {childEntries.map((child, index) => (
+
+ +--
+ {child.timestamp}
+ {child.method}
+ {child.target && {child.target}}
+
+ {formatStatusLabel(child.status)}
+
+
+ {formatDuration(child.durationMs)}
+
+
+ ))}
+
+ )}
+
+
+ )}
);
diff --git a/clients/web/src/components/groups/HistoryListPanel/HistoryListPanel.stories.tsx b/clients/web/src/components/groups/HistoryListPanel/HistoryListPanel.stories.tsx
new file mode 100644
index 00000000..3812150c
--- /dev/null
+++ b/clients/web/src/components/groups/HistoryListPanel/HistoryListPanel.stories.tsx
@@ -0,0 +1,70 @@
+import type { Meta, StoryObj } from "@storybook/react-vite";
+import { fn } from "storybook/test";
+import { HistoryListPanel } from "./HistoryListPanel.js";
+import type { HistoryEntryProps } from "../HistoryEntry/HistoryEntry";
+
+const meta: Meta = {
+ title: "Groups/HistoryListPanel",
+ component: HistoryListPanel,
+ args: {
+ searchText: "",
+ },
+};
+
+export default meta;
+type Story = StoryObj;
+
+function makeEntry(overrides: Partial): HistoryEntryProps {
+ return {
+ timestamp: "2026-03-17T10:00:00Z",
+ method: "tools/call",
+ target: "send_message",
+ status: "success",
+ durationMs: 120,
+ isPinned: false,
+ isListExpanded: false,
+ onReplay: fn(),
+ onTogglePin: fn(),
+ ...overrides,
+ };
+}
+
+export const WithEntries: Story = {
+ args: {
+ entries: [
+ makeEntry({
+ timestamp: "2026-03-17T10:00:00Z",
+ method: "tools/call",
+ target: "send_message",
+ }),
+ makeEntry({
+ timestamp: "2026-03-17T10:01:00Z",
+ method: "resources/read",
+ target: "config.json",
+ durationMs: 45,
+ }),
+ makeEntry({
+ timestamp: "2026-03-17T10:02:00Z",
+ method: "tools/call",
+ target: "delete_records",
+ status: "error",
+ durationMs: 350,
+ }),
+ ],
+ pinnedEntries: [
+ makeEntry({
+ timestamp: "2026-03-17T09:30:00Z",
+ method: "tools/list",
+ isPinned: true,
+ durationMs: 80,
+ }),
+ ],
+ },
+};
+
+export const Empty: Story = {
+ args: {
+ entries: [],
+ pinnedEntries: [],
+ },
+};
diff --git a/clients/web/src/components/groups/HistoryListPanel/HistoryListPanel.tsx b/clients/web/src/components/groups/HistoryListPanel/HistoryListPanel.tsx
new file mode 100644
index 00000000..39ddafab
--- /dev/null
+++ b/clients/web/src/components/groups/HistoryListPanel/HistoryListPanel.tsx
@@ -0,0 +1,133 @@
+import { useMemo, useState } from "react";
+import {
+ Group,
+ Paper,
+ ScrollArea,
+ Stack,
+ Text,
+ Title,
+} from "@mantine/core";
+import { HistoryEntry } from "../HistoryEntry/HistoryEntry";
+import { ListToggle } from "../../elements/ListToggle/ListToggle";
+import type { HistoryEntryProps } from "../HistoryEntry/HistoryEntry";
+
+export interface HistoryRequestsPanelProps {
+ entries: HistoryEntryProps[];
+ pinnedEntries: HistoryEntryProps[];
+ searchText: string;
+ methodFilter?: string;
+}
+
+const PanelContainer = Paper.withProps({
+ withBorder: true,
+ p: "lg",
+ flex: 1,
+ variant: "panel",
+});
+
+const EmptyState = Text.withProps({
+ c: "dimmed",
+ ta: "center",
+ py: "xl",
+});
+
+function entryKey(entry: HistoryEntryProps): string {
+ return `${entry.timestamp}-${entry.method}`;
+}
+
+function formatPinnedTitle(count: number): string {
+ return `Pinned Requests (${count})`;
+}
+
+function formatHistoryTitle(count: number): string {
+ return `History (${count})`;
+}
+
+function matchesFilters(
+ entry: HistoryEntryProps,
+ searchText: string,
+ methodFilter?: string,
+): boolean {
+ if (methodFilter && entry.method !== methodFilter) return false;
+ if (searchText) {
+ const term = searchText.toLowerCase();
+ const searchable =
+ `${entry.method} ${entry.target ?? ""} ${entry.timestamp}`.toLowerCase();
+ if (!searchable.includes(term)) return false;
+ }
+ return true;
+}
+
+export function HistoryListPanel({
+ entries,
+ pinnedEntries,
+ searchText,
+ methodFilter,
+}: HistoryRequestsPanelProps) {
+ const [compact, setCompact] = useState(false);
+
+ const filteredEntries = useMemo(
+ () => entries.filter((e) => matchesFilters(e, searchText, methodFilter)),
+ [entries, searchText, methodFilter],
+ );
+
+ const filteredPinned = useMemo(
+ () =>
+ pinnedEntries.filter((e) => matchesFilters(e, searchText, methodFilter)),
+ [pinnedEntries, searchText, methodFilter],
+ );
+
+ const hasResults = filteredEntries.length > 0 || filteredPinned.length > 0;
+
+ return (
+
+
+ Requests
+ {hasResults && (
+ setCompact((c) => !c)}
+ />
+ )}
+
+
+ {!hasResults ? (
+ No request history
+ ) : (
+
+
+ {filteredPinned.length > 0 && (
+ <>
+
+ {formatPinnedTitle(filteredPinned.length)}
+
+ {filteredPinned.map((entry) => (
+
+ ))}
+ >
+ )}
+
+ {filteredEntries.length > 0 && (
+ <>
+
+ {formatHistoryTitle(filteredEntries.length)}
+
+ {filteredEntries.map((entry) => (
+
+ ))}
+ >
+ )}
+
+
+ )}
+
+ );
+}
diff --git a/clients/web/src/components/groups/LogControls/LogControls.tsx b/clients/web/src/components/groups/LogControls/LogControls.tsx
index 859ab7e6..ab218334 100644
--- a/clients/web/src/components/groups/LogControls/LogControls.tsx
+++ b/clients/web/src/components/groups/LogControls/LogControls.tsx
@@ -36,6 +36,11 @@ const ToolbarButton = Button.withProps({
size: "sm",
});
+const SubtleButton = Button.withProps({
+ variant: "subtle",
+ size: "xs",
+});
+
export interface LogControlsProps {
currentLevel: string;
filterText: string;
@@ -43,6 +48,7 @@ export interface LogControlsProps {
onSetLevel: (level: string) => void;
onFilterChange: (text: string) => void;
onToggleLevel: (level: string, visible: boolean) => void;
+ onToggleAllLevels: () => void;
onClear: () => void;
onExport: () => void;
}
@@ -54,13 +60,16 @@ export function LogControls({
onSetLevel,
onFilterChange,
onToggleLevel,
+ onToggleAllLevels,
onClear,
onExport,
}: LogControlsProps) {
return (
- Log Level
-
+ Logging
+
+ Active Level
+
- Filter
+ Search
onFilterChange(e.currentTarget.value)}
/>
- Show Levels
+
+ Filter by Level
+
+ {Object.values(visibleLevels).every(Boolean)
+ ? "Deselect All"
+ : "Select All"}
+
+
{LOG_LEVELS.map((level) => {
const style = LEVEL_COLORS[level];
return (
diff --git a/clients/web/src/components/groups/LogStreamPanel/LogStreamPanel.stories.tsx b/clients/web/src/components/groups/LogStreamPanel/LogStreamPanel.stories.tsx
new file mode 100644
index 00000000..f78349ff
--- /dev/null
+++ b/clients/web/src/components/groups/LogStreamPanel/LogStreamPanel.stories.tsx
@@ -0,0 +1,77 @@
+import type { Meta, StoryObj } from "@storybook/react-vite";
+import { fn } from "storybook/test";
+import { LogStreamPanel } from "./LogStreamPanel";
+import type { LogEntryProps, LogLevel } from "../../elements/LogEntry/LogEntry";
+
+const meta: Meta = {
+ title: "Groups/LogStreamPanel",
+ component: LogStreamPanel,
+ args: {
+ filterText: "",
+ visibleLevels: {
+ debug: true,
+ info: true,
+ notice: true,
+ warning: true,
+ error: true,
+ critical: true,
+ alert: true,
+ emergency: true,
+ },
+ autoScroll: true,
+ onToggleAutoScroll: fn(),
+ onCopyAll: fn(),
+ },
+};
+
+export default meta;
+type Story = StoryObj;
+
+export const Empty: Story = {
+ args: {
+ entries: [],
+ },
+};
+
+const logMessages: { level: LogLevel; message: string; logger?: string }[] = [
+ { level: "info", message: "Server started on port 3000" },
+ { level: "debug", message: "Loading configuration", logger: "config" },
+ {
+ level: "warning",
+ message: "Deprecated API endpoint called",
+ logger: "http",
+ },
+ { level: "error", message: "Failed to read resource", logger: "resources" },
+ {
+ level: "info",
+ message: "Tool execution completed (245ms)",
+ logger: "tools",
+ },
+ {
+ level: "critical",
+ message: "Database connection pool exhausted",
+ logger: "db",
+ },
+ { level: "alert", message: "Memory usage exceeds 95%", logger: "system" },
+ { level: "emergency", message: "System unresponsive", logger: "system" },
+];
+
+function pad(n: number): string {
+ return n.toString().padStart(2, "0");
+}
+
+const entries: LogEntryProps[] = Array.from({ length: 30 }, (_, i) => {
+ const src = logMessages[i % logMessages.length];
+ return {
+ timestamp: `2026-03-17T10:00:${pad(i + 1)}Z`,
+ level: src.level,
+ message: src.message,
+ logger: src.logger,
+ };
+});
+
+export const WithEntries: Story = {
+ args: {
+ entries,
+ },
+};
diff --git a/clients/web/src/components/groups/LogStreamPanel/LogStreamPanel.tsx b/clients/web/src/components/groups/LogStreamPanel/LogStreamPanel.tsx
new file mode 100644
index 00000000..ae3d358c
--- /dev/null
+++ b/clients/web/src/components/groups/LogStreamPanel/LogStreamPanel.tsx
@@ -0,0 +1,98 @@
+import { useMemo } from "react";
+import {
+ Button,
+ Checkbox,
+ Group,
+ Paper,
+ ScrollArea,
+ Stack,
+ Text,
+ Title,
+} from "@mantine/core";
+import { LogEntry } from "../../elements/LogEntry/LogEntry";
+import type { LogEntryProps } from "../../elements/LogEntry/LogEntry";
+
+export interface LogStreamPanelProps {
+ entries: LogEntryProps[];
+ filterText: string;
+ visibleLevels: Record;
+ autoScroll: boolean;
+ onToggleAutoScroll: () => void;
+ onCopyAll: () => void;
+}
+
+const PanelContainer = Paper.withProps({
+ withBorder: true,
+ p: "lg",
+ flex: 1,
+ variant: "panel",
+});
+
+const ToolbarButton = Button.withProps({
+ variant: "light",
+ size: "sm",
+});
+
+const EmptyCenter = Stack.withProps({
+ flex: 1,
+ align: "center",
+ justify: "center",
+});
+
+function matchesFilters(
+ entry: LogEntryProps,
+ filterText: string,
+ visibleLevels: Record,
+): boolean {
+ if (!visibleLevels[entry.level]) return false;
+ if (filterText) {
+ const term = filterText.toLowerCase();
+ const searchable =
+ `${entry.message} ${entry.logger ?? ""} ${entry.level}`.toLowerCase();
+ if (!searchable.includes(term)) return false;
+ }
+ return true;
+}
+
+export function LogStreamPanel({
+ entries,
+ filterText,
+ visibleLevels,
+ autoScroll,
+ onToggleAutoScroll,
+ onCopyAll,
+}: LogStreamPanelProps) {
+ const filteredEntries = useMemo(
+ () => entries.filter((e) => matchesFilters(e, filterText, visibleLevels)),
+ [entries, filterText, visibleLevels],
+ );
+
+ return (
+
+
+ Log Stream
+
+
+ Copy All
+
+
+ {filteredEntries.length > 0 ? (
+
+
+ {filteredEntries.map((entry, index) => (
+
+ ))}
+
+
+ ) : (
+
+ No log entries
+
+ )}
+
+ );
+}
diff --git a/clients/web/src/components/groups/AddServerMenu/AddServerMenu.stories.tsx b/clients/web/src/components/groups/ServerAddMenu/ServerAddMenu.stories.tsx
similarity index 55%
rename from clients/web/src/components/groups/AddServerMenu/AddServerMenu.stories.tsx
rename to clients/web/src/components/groups/ServerAddMenu/ServerAddMenu.stories.tsx
index 1374bda3..538fc1b7 100644
--- a/clients/web/src/components/groups/AddServerMenu/AddServerMenu.stories.tsx
+++ b/clients/web/src/components/groups/ServerAddMenu/ServerAddMenu.stories.tsx
@@ -1,10 +1,10 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { fn } from "storybook/test";
-import { AddServerMenu } from "./AddServerMenu";
+import { ServerAddMenu } from "./ServerAddMenu.js";
-const meta: Meta = {
- title: "Groups/AddServerMenu",
- component: AddServerMenu,
+const meta: Meta = {
+ title: "Groups/ServerAddMenu",
+ component: ServerAddMenu,
args: {
onAddManually: fn(),
onImportConfig: fn(),
@@ -13,6 +13,6 @@ const meta: Meta = {
};
export default meta;
-type Story = StoryObj;
+type Story = StoryObj;
export const Default: Story = {};
diff --git a/clients/web/src/components/groups/AddServerMenu/AddServerMenu.tsx b/clients/web/src/components/groups/ServerAddMenu/ServerAddMenu.tsx
similarity index 76%
rename from clients/web/src/components/groups/AddServerMenu/AddServerMenu.tsx
rename to clients/web/src/components/groups/ServerAddMenu/ServerAddMenu.tsx
index 83794413..862a676f 100644
--- a/clients/web/src/components/groups/AddServerMenu/AddServerMenu.tsx
+++ b/clients/web/src/components/groups/ServerAddMenu/ServerAddMenu.tsx
@@ -1,4 +1,5 @@
import { Button, Menu } from "@mantine/core";
+import { RiArrowDownSLine } from "react-icons/ri";
export interface AddServerMenuProps {
onAddManually: () => void;
@@ -6,15 +7,16 @@ export interface AddServerMenuProps {
onImportServerJson: () => void;
}
-export function AddServerMenu({
+export function ServerAddMenu({
onAddManually,
onImportConfig,
onImportServerJson,
}: AddServerMenuProps) {
+ const Icon = RiArrowDownSLine;
return (
-
-
- {connectionMode}
-
+ {!compact && (
+ <>
+
+
+ {connectionMode}
+
-
+
- {error && (
-
- )}
-
-
-
- Clone
- Edit
- Remove
-
-
- Server Info
- Settings
- {canTestClientFeatures && (
-
+ {error && (
+
)}
-
-
+
+
+
+ Clone
+ Edit
+ Remove
+
+
+ Server Info
+ Settings
+ {canTestClientFeatures && (
+
+ )}
+
+
+ >
+ )}
);
diff --git a/clients/web/src/components/groups/ServerListControls/ServerListControls.stories.tsx b/clients/web/src/components/groups/ServerListControls/ServerListControls.stories.tsx
new file mode 100644
index 00000000..e847bb5f
--- /dev/null
+++ b/clients/web/src/components/groups/ServerListControls/ServerListControls.stories.tsx
@@ -0,0 +1,33 @@
+import type { Meta, StoryObj } from "@storybook/react-vite";
+import { fn } from "storybook/test";
+import { ServerListControls } from "./ServerListControls";
+
+const meta: Meta = {
+ title: "Groups/ServerListControls",
+ component: ServerListControls,
+};
+
+export default meta;
+type Story = StoryObj;
+
+export const WithServers: Story = {
+ args: {
+ serverCount: 5,
+ compact: false,
+ onToggleList: fn(),
+ onAddManually: fn(),
+ onImportConfig: fn(),
+ onImportServerJson: fn(),
+ },
+};
+
+export const WithoutServers: Story = {
+ args: {
+ serverCount: 0,
+ compact: true,
+ onToggleList: fn(),
+ onAddManually: fn(),
+ onImportConfig: fn(),
+ onImportServerJson: fn(),
+ },
+};
diff --git a/clients/web/src/components/groups/ServerListControls/ServerListControls.tsx b/clients/web/src/components/groups/ServerListControls/ServerListControls.tsx
new file mode 100644
index 00000000..6dc41815
--- /dev/null
+++ b/clients/web/src/components/groups/ServerListControls/ServerListControls.tsx
@@ -0,0 +1,34 @@
+import { Group } from "@mantine/core";
+import { ListToggle } from "../../elements/ListToggle/ListToggle";
+import {
+ ServerAddMenu,
+ type AddServerMenuProps,
+} from "../ServerAddMenu/ServerAddMenu.js";
+
+export interface ServerListControlsProps extends AddServerMenuProps {
+ compact: boolean;
+ serverCount: number;
+ onToggleList: () => void;
+}
+
+export function ServerListControls({
+ compact,
+ serverCount,
+ onToggleList,
+ onAddManually,
+ onImportConfig,
+ onImportServerJson,
+}: ServerListControlsProps) {
+ return (
+
+ {serverCount > 0 && (
+
+ )}
+
+
+ );
+}
diff --git a/clients/web/src/components/groups/ViewHeader/ViewHeader.stories.tsx b/clients/web/src/components/groups/ViewHeader/ViewHeader.stories.tsx
new file mode 100644
index 00000000..d5860c13
--- /dev/null
+++ b/clients/web/src/components/groups/ViewHeader/ViewHeader.stories.tsx
@@ -0,0 +1,42 @@
+import type { Meta, StoryObj } from "@storybook/react-vite";
+import { fn } from "storybook/test";
+import { AppShell } from "@mantine/core";
+import { ViewHeader } from "./ViewHeader";
+
+const meta: Meta = {
+ title: "Groups/ViewHeader",
+ component: ViewHeader,
+ decorators: [
+ (Story) => (
+
+
+
+
+
+ ),
+ ],
+};
+
+export default meta;
+type Story = StoryObj;
+
+export const Connected: Story = {
+ args: {
+ connected: true,
+ serverName: "my-mcp-server",
+ status: "connected",
+ latencyMs: 23,
+ activeTab: "Tools",
+ availableTabs: ["Tools", "Resources", "Prompts", "Logging", "Tasks", "History"],
+ onTabChange: fn(),
+ onDisconnect: fn(),
+ onToggleTheme: fn(),
+ },
+};
+
+export const Unconnected: Story = {
+ args: {
+ connected: false,
+ onToggleTheme: fn(),
+ },
+};
diff --git a/clients/web/src/components/groups/ViewHeader/ViewHeader.tsx b/clients/web/src/components/groups/ViewHeader/ViewHeader.tsx
new file mode 100644
index 00000000..b2e9f85a
--- /dev/null
+++ b/clients/web/src/components/groups/ViewHeader/ViewHeader.tsx
@@ -0,0 +1,146 @@
+import {
+ ActionIcon,
+ Anchor,
+ Button,
+ Group,
+ Image,
+ SegmentedControl,
+ Text,
+ Title,
+} from "@mantine/core";
+import { useComputedColorScheme } from "@mantine/core";
+import { MdLightMode, MdDarkMode } from "react-icons/md";
+import { StatusIndicator } from "../../elements/StatusIndicator/StatusIndicator";
+import mcpLogo from "../../../theme/assets/MCP.svg";
+import mcpLogoDark from "../../../theme/assets/MCP-dark.svg";
+
+interface ConnectedProps {
+ connected: true;
+ serverName: string;
+ status: "connected" | "connecting" | "failed";
+ latencyMs?: number;
+ activeTab: string;
+ availableTabs: string[];
+ onTabChange: (tab: string) => void;
+ onDisconnect: () => void;
+ onToggleTheme: () => void;
+}
+
+interface UnconnectedProps {
+ connected: false;
+ onToggleTheme: () => void;
+}
+
+export type ViewHeaderProps = ConnectedProps | UnconnectedProps;
+
+const HeaderBar = Group.withProps({
+ h: "100%",
+ px: "md",
+ wrap: "nowrap",
+ gap: 0,
+});
+
+const LeftSection = Group.withProps({
+ gap: "md",
+ wrap: "nowrap",
+ w: "33.33%",
+ miw: 0,
+});
+
+const LogoLink = Anchor.withProps({
+ href: "https://modelcontextprotocol.io",
+ target: "_blank",
+ rel: "nofollow noopener noreferrer",
+});
+
+const LogoImage = Image.withProps({
+ alt: "MCP",
+ w: 28,
+ h: 28,
+ fit: "contain",
+});
+
+const ServerName = Text.withProps({
+ fw: 600,
+ size: "lg",
+ truncate: "end",
+ maw: "calc(100% - 40px)",
+});
+
+const CenterSection = Group.withProps({
+ w: "33.33%",
+ justify: "center",
+});
+
+const RightSection = Group.withProps({
+ gap: "sm",
+ w: "33.33%",
+ justify: "flex-end",
+});
+
+const DisconnectButton = Button.withProps({
+ variant: "outline",
+ c: "red",
+ size: "sm",
+});
+
+const ThemeToggle = ActionIcon.withProps({
+ variant: "outline",
+ size: 36,
+ "aria-label": "Toggle color scheme",
+});
+
+const UnconnectedBar = Group.withProps({
+ h: "100%",
+ px: "md",
+ justify: "space-between",
+});
+
+export function ViewHeader(props: ViewHeaderProps) {
+ const colorScheme = useComputedColorScheme();
+ const ThemeIcon = colorScheme === "dark" ? MdLightMode : MdDarkMode;
+
+ if (!props.connected) {
+ return (
+
+
+
+
+ MCP Inspector
+
+
+
+
+ );
+ }
+
+ return (
+
+
+
+
+
+ {props.serverName}
+
+
+
+
+
+
+
+
+
+ Disconnect
+
+
+
+
+
+
+ );
+}
diff --git a/clients/web/src/components/screens/HistoryScreen/HistoryScreen.stories.tsx b/clients/web/src/components/screens/HistoryScreen/HistoryScreen.stories.tsx
index 955c55b1..0d66d218 100644
--- a/clients/web/src/components/screens/HistoryScreen/HistoryScreen.stories.tsx
+++ b/clients/web/src/components/screens/HistoryScreen/HistoryScreen.stories.tsx
@@ -8,10 +8,6 @@ const meta: Meta = {
component: HistoryScreen,
parameters: { layout: "fullscreen" },
args: {
- searchText: "",
- onSearchChange: fn(),
- onMethodFilterChange: fn(),
- onLoadMore: fn(),
onClearAll: fn(),
onExport: fn(),
},
@@ -29,8 +25,7 @@ const entry1: HistoryEntryProps = {
parameters: { message: "Hello, world!" },
response: { result: "Message sent successfully" },
isPinned: false,
- isExpanded: false,
- onToggleExpand: fn(),
+ isListExpanded: false,
onReplay: fn(),
onTogglePin: fn(),
};
@@ -44,8 +39,7 @@ const entry2: HistoryEntryProps = {
parameters: { uri: "file:///config.json" },
response: { contents: [{ uri: "file:///config.json", text: "{}" }] },
isPinned: false,
- isExpanded: false,
- onToggleExpand: fn(),
+ isListExpanded: false,
onReplay: fn(),
onTogglePin: fn(),
};
@@ -59,8 +53,7 @@ const entry3: HistoryEntryProps = {
parameters: { ids: [1, 2, 3] },
response: { error: "Permission denied" },
isPinned: false,
- isExpanded: true,
- onToggleExpand: fn(),
+ isListExpanded: true,
onReplay: fn(),
onTogglePin: fn(),
};
@@ -72,9 +65,7 @@ const pinnedEntry1: HistoryEntryProps = {
durationMs: 80,
response: { tools: ["send_message", "list_users"] },
isPinned: true,
- pinLabel: "Tool discovery",
- isExpanded: false,
- onToggleExpand: fn(),
+ isListExpanded: false,
onReplay: fn(),
onTogglePin: fn(),
};
@@ -90,9 +81,7 @@ const pinnedEntry2: HistoryEntryProps = {
messages: [{ role: "user", content: { type: "text", text: "Hello!" } }],
},
isPinned: true,
- pinLabel: "Greeting prompt",
- isExpanded: false,
- onToggleExpand: fn(),
+ isListExpanded: false,
onReplay: fn(),
onTogglePin: fn(),
};
@@ -101,8 +90,6 @@ export const WithEntries: Story = {
args: {
entries: [entry1, entry2, entry3],
pinnedEntries: [pinnedEntry1, pinnedEntry2],
- totalCount: 25,
- displayedCount: 3,
},
};
@@ -110,17 +97,5 @@ export const Empty: Story = {
args: {
entries: [],
pinnedEntries: [],
- totalCount: 0,
- displayedCount: 0,
- },
-};
-
-export const WithFilter: Story = {
- args: {
- entries: [entry1, entry3],
- pinnedEntries: [],
- methodFilter: "tools/call",
- totalCount: 10,
- displayedCount: 2,
},
};
diff --git a/clients/web/src/components/screens/HistoryScreen/HistoryScreen.tsx b/clients/web/src/components/screens/HistoryScreen/HistoryScreen.tsx
index 3fce13d6..ca7436fd 100644
--- a/clients/web/src/components/screens/HistoryScreen/HistoryScreen.tsx
+++ b/clients/web/src/components/screens/HistoryScreen/HistoryScreen.tsx
@@ -1,155 +1,64 @@
-import {
- Button,
- Container,
- Divider,
- Group,
- Paper,
- Select,
- Stack,
- Text,
- TextInput,
- Title,
-} from "@mantine/core";
-import { HistoryEntry } from "../../groups/HistoryEntry/HistoryEntry";
+import { useState } from "react";
+import { Card, Flex, Stack } from "@mantine/core";
+import { HistoryControls } from "../../groups/HistoryControls/HistoryControls";
+import { HistoryListPanel } from "../../groups/HistoryListPanel/HistoryListPanel.js";
import type { HistoryEntryProps } from "../../groups/HistoryEntry/HistoryEntry";
export interface HistoryScreenProps {
entries: HistoryEntryProps[];
pinnedEntries: HistoryEntryProps[];
- searchText: string;
- methodFilter?: string;
- totalCount: number;
- displayedCount: number;
- onSearchChange: (text: string) => void;
- onMethodFilterChange: (method: string) => void;
- onLoadMore: () => void;
onClearAll: () => void;
onExport: () => void;
}
-const METHOD_OPTIONS = [
- "tools/call",
- "tools/list",
- "resources/read",
- "resources/list",
- "prompts/get",
- "prompts/list",
- "sampling/createMessage",
- "elicitation/create",
-];
-
-const PageContainer = Container.withProps({
- size: "xl",
- py: "xl",
+const ScreenLayout = Flex.withProps({
+ variant: "screen",
+ h: "calc(100vh - var(--app-shell-header-height, 0px))",
+ gap: "xl",
+ p: "xl",
});
-const ContentPanel = Paper.withProps({
+const Sidebar = Stack.withProps({
+ w: 280,
+ flex: "0 0 auto",
+});
+
+const SidebarCard = Card.withProps({
withBorder: true,
- p: "md",
+ padding: "lg",
});
-const HeaderRow = Group.withProps({
- justify: "space-between",
- wrap: "wrap",
-});
-
-const ToolbarButton = Button.withProps({
- variant: "light",
- size: "sm",
-});
-
-const EmptyState = Text.withProps({
- c: "dimmed",
- ta: "center",
- py: "xl",
-});
-
-const CountText = Text.withProps({
- size: "sm",
- c: "dimmed",
-});
-
-function entryKey(entry: HistoryEntryProps): string {
- return `${entry.timestamp}-${entry.method}`;
-}
-
-function formatPinnedTitle(count: number): string {
- return `Pinned Requests (${count})`;
-}
-
-function formatPagination(displayed: number, total: number): string {
- return `Showing ${displayed} of ${total} entries`;
-}
-
export function HistoryScreen({
entries,
pinnedEntries,
- searchText,
- methodFilter,
- totalCount,
- displayedCount,
- onSearchChange,
- onMethodFilterChange,
- onLoadMore,
onClearAll,
onExport,
}: HistoryScreenProps) {
+ const [searchText, setSearchText] = useState("");
+ const [methodFilter, setMethodFilter] = useState();
+
return (
-
-
-
-
- Request History
-
- onSearchChange(event.currentTarget.value)}
- />
-
-
-
- {entries.length === 0 ? (
- No request history
- ) : (
-
- {entries.map((entry) => (
-
- ))}
-
- )}
-
- {pinnedEntries.length > 0 && (
- <>
-
- {formatPinnedTitle(pinnedEntries.length)}
-
- {pinnedEntries.map((entry) => (
-
- ))}
-
- >
- )}
-
-
-
- {formatPagination(displayedCount, totalCount)}
-
- {displayedCount < totalCount && (
- Load More
- )}
-
-
-
-
+
+
+
+
+ setMethodFilter(value || undefined)
+ }
+ onClearAll={onClearAll}
+ onExport={onExport}
+ />
+
+
+
+
);
}
diff --git a/clients/web/src/components/screens/LoggingScreen/LoggingScreen.stories.tsx b/clients/web/src/components/screens/LoggingScreen/LoggingScreen.stories.tsx
index 15df86ca..bd129949 100644
--- a/clients/web/src/components/screens/LoggingScreen/LoggingScreen.stories.tsx
+++ b/clients/web/src/components/screens/LoggingScreen/LoggingScreen.stories.tsx
@@ -1,39 +1,20 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { fn } from "storybook/test";
import { LoggingScreen } from "./LoggingScreen";
-import type { LogControlsProps } from "../../groups/LogControls/LogControls";
import type { LogEntryProps, LogLevel } from "../../elements/LogEntry/LogEntry";
-function makeControls(): LogControlsProps {
- return {
- currentLevel: "info",
- filterText: "",
- visibleLevels: {
- debug: true,
- info: true,
- notice: true,
- warning: true,
- error: true,
- critical: true,
- alert: true,
- emergency: true,
- },
- onSetLevel: fn(),
- onFilterChange: fn(),
- onToggleLevel: fn(),
- onClear: fn(),
- onExport: fn(),
- };
-}
-
const meta: Meta = {
title: "Screens/LoggingScreen",
component: LoggingScreen,
parameters: { layout: "fullscreen" },
args: {
+ currentLevel: "info",
+ onSetLevel: fn(),
+ onClear: fn(),
+ onExport: fn(),
+ autoScroll: true,
onToggleAutoScroll: fn(),
onCopyAll: fn(),
- autoScroll: true,
},
};
@@ -42,7 +23,6 @@ type Story = StoryObj;
export const Empty: Story = {
args: {
- controls: makeControls(),
entries: [],
},
};
@@ -137,7 +117,8 @@ const logMessages: { level: LogLevel; message: string; logger?: string }[] = [
},
{
level: "info",
- message: "Session initialized with capabilities: tools, resources, prompts",
+ message:
+ "Session initialized with capabilities: tools, resources, prompts",
},
{
level: "debug",
@@ -183,7 +164,6 @@ const mixedEntries: LogEntryProps[] = Array.from({ length: 50 }, (_, i) => {
export const WithEntries: Story = {
args: {
- controls: makeControls(),
entries: mixedEntries,
},
};
@@ -240,7 +220,6 @@ const allLevelEntries: LogEntryProps[] = [
export const MixedLevels: Story = {
args: {
- controls: makeControls(),
entries: allLevelEntries,
},
};
diff --git a/clients/web/src/components/screens/LoggingScreen/LoggingScreen.tsx b/clients/web/src/components/screens/LoggingScreen/LoggingScreen.tsx
index d9c4a281..74e35f97 100644
--- a/clients/web/src/components/screens/LoggingScreen/LoggingScreen.tsx
+++ b/clients/web/src/components/screens/LoggingScreen/LoggingScreen.tsx
@@ -1,31 +1,41 @@
-import {
- Button,
- Card,
- Checkbox,
- Container,
- Grid,
- Group,
- ScrollArea,
- Stack,
- Text,
- Title,
-} from "@mantine/core";
+import { useState } from "react";
+import { Card, Flex, Stack } from "@mantine/core";
import { LogControls } from "../../groups/LogControls/LogControls";
-import { LogEntry } from "../../elements/LogEntry/LogEntry";
-import type { LogControlsProps } from "../../groups/LogControls/LogControls";
+import { LogStreamPanel } from "../../groups/LogStreamPanel/LogStreamPanel";
import type { LogEntryProps } from "../../elements/LogEntry/LogEntry";
export interface LoggingScreenProps {
- controls: LogControlsProps;
entries: LogEntryProps[];
+ currentLevel: string;
+ onSetLevel: (level: string) => void;
+ onClear: () => void;
+ onExport: () => void;
autoScroll: boolean;
onToggleAutoScroll: () => void;
onCopyAll: () => void;
}
-const PageContainer = Container.withProps({
- size: "xl",
- py: "xl",
+const ALL_LEVELS_VISIBLE: Record = {
+ debug: true,
+ info: true,
+ notice: true,
+ warning: true,
+ error: true,
+ critical: true,
+ alert: true,
+ emergency: true,
+};
+
+const ScreenLayout = Flex.withProps({
+ variant: "screen",
+ h: "calc(100vh - var(--app-shell-header-height, 0px))",
+ gap: "xl",
+ p: "xl",
+});
+
+const Sidebar = Stack.withProps({
+ w: 280,
+ flex: "0 0 auto",
});
const SidebarCard = Card.withProps({
@@ -33,74 +43,58 @@ const SidebarCard = Card.withProps({
padding: "lg",
});
-const FullHeightCard = Card.withProps({
- withBorder: true,
- padding: "lg",
- h: "100%",
-});
-
-const FullHeightStack = Stack.withProps({
- gap: "sm",
- h: "100%",
-});
-
-const ToolbarButton = Button.withProps({
- variant: "light",
- size: "sm",
-});
-
-const EmptyCenter = Stack.withProps({
- flex: 1,
- align: "center",
- justify: "center",
-});
-
export function LoggingScreen({
- controls,
entries,
+ currentLevel,
+ onSetLevel,
+ onClear,
+ onExport,
autoScroll,
onToggleAutoScroll,
onCopyAll,
}: LoggingScreenProps) {
+ const [filterText, setFilterText] = useState("");
+ const [visibleLevels, setVisibleLevels] =
+ useState>(ALL_LEVELS_VISIBLE);
+
+ function handleToggleLevel(level: string, visible: boolean) {
+ setVisibleLevels((prev) => ({ ...prev, [level]: visible }));
+ }
+
+ const NO_LEVELS_VISIBLE: Record = Object.fromEntries(
+ Object.keys(ALL_LEVELS_VISIBLE).map((k) => [k, false]),
+ );
+
+ function handleToggleAllLevels() {
+ const allSelected = Object.values(visibleLevels).every(Boolean);
+ setVisibleLevels(allSelected ? NO_LEVELS_VISIBLE : ALL_LEVELS_VISIBLE);
+ }
+
return (
-
-
-
-
-
-
-
-
-
-
-
- Log Stream
-
-
- Copy All
-
-
- {entries.length > 0 ? (
-
-
- {entries.map((entry, index) => (
-
- ))}
-
-
- ) : (
-
- No log entries
-
- )}
-
-
-
-
-
+
+
+
+
+
+
+
+
);
}
diff --git a/clients/web/src/components/screens/ServerListScreen/ServerListScreen.tsx b/clients/web/src/components/screens/ServerListScreen/ServerListScreen.tsx
index 96fb3b52..be0eedc9 100644
--- a/clients/web/src/components/screens/ServerListScreen/ServerListScreen.tsx
+++ b/clients/web/src/components/screens/ServerListScreen/ServerListScreen.tsx
@@ -1,6 +1,7 @@
-import { Container, Group, SimpleGrid, Stack, Text } from "@mantine/core";
+import { useState } from "react";
+import { Container, SimpleGrid, Stack, Text } from "@mantine/core";
import { ServerCard } from "../../groups/ServerCard/ServerCard";
-import { AddServerMenu } from "../../groups/AddServerMenu/AddServerMenu";
+import { ServerListControls } from "../../groups/ServerListControls/ServerListControls";
import type { ServerCardProps } from "../../groups/ServerCard/ServerCard";
export interface ServerListScreenProps {
@@ -27,16 +28,23 @@ export function ServerListScreen({
onImportConfig,
onImportServerJson,
}: ServerListScreenProps) {
+ const [compact, setCompact] = useState(false);
+
+ function handleToggleList() {
+ setCompact((prev) => (prev === true ? false : true));
+ }
+
return (
-
-
-
+
{servers.length === 0 ? (
@@ -49,7 +57,7 @@ export function ServerListScreen({
className="grid-align-start"
>
{servers.map((server) => (
-
+
))}
)}
diff --git a/clients/web/src/components/views/ConnectedView/ConnectedLayout.tsx b/clients/web/src/components/views/ConnectedView/ConnectedLayout.tsx
deleted file mode 100644
index 898520a6..00000000
--- a/clients/web/src/components/views/ConnectedView/ConnectedLayout.tsx
+++ /dev/null
@@ -1,97 +0,0 @@
-import type { ReactNode } from "react";
-import {
- ActionIcon,
- AppShell,
- Button,
- Group,
- Image,
- SegmentedControl,
- Text,
-} from "@mantine/core";
-import { useComputedColorScheme } from "@mantine/core";
-import { StatusIndicator } from "../../elements/StatusIndicator/StatusIndicator";
-import mcpLogo from "../../../theme/assets/MCP.svg";
-import mcpLogoDark from "../../../theme/assets/MCP-dark.svg";
-
-export interface ConnectedLayoutProps {
- serverName: string;
- status: "connected" | "connecting" | "failed";
- latencyMs?: number;
- activeTab: string;
- availableTabs: string[];
- onTabChange: (tab: string) => void;
- onDisconnect: () => void;
- onToggleTheme: () => void;
- children: ReactNode;
-}
-
-const ServerName = Text.withProps({
- fw: 600,
- size: "lg",
- truncate: "end",
- maw: "calc(100% - 40px)",
-});
-
-export function ConnectedLayout({
- serverName,
- status,
- latencyMs,
- activeTab,
- availableTabs,
- onTabChange,
- onDisconnect,
- onToggleTheme,
- children,
-}: ConnectedLayoutProps) {
- const colorScheme = useComputedColorScheme();
-
- return (
-
-
-
-
-
- {serverName}
-
-
-
-
-
-
-
-
-
-
- {colorScheme === "dark" ? "\u2600" : "\u263E"}
-
-
-
-
-
- {children}
-
- );
-}
diff --git a/clients/web/src/components/views/ConnectedView/ConnectedLayout.stories.tsx b/clients/web/src/components/views/ConnectedView/ConnectedView.stories.tsx
similarity index 86%
rename from clients/web/src/components/views/ConnectedView/ConnectedLayout.stories.tsx
rename to clients/web/src/components/views/ConnectedView/ConnectedView.stories.tsx
index 24c163a9..db043bf5 100644
--- a/clients/web/src/components/views/ConnectedView/ConnectedLayout.stories.tsx
+++ b/clients/web/src/components/views/ConnectedView/ConnectedView.stories.tsx
@@ -1,7 +1,7 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { fn } from "storybook/test";
import { Text } from "@mantine/core";
-import { ConnectedLayout } from "./ConnectedLayout";
+import { ConnectedView } from "./ConnectedView.js";
import { ToolsScreen } from "../../screens/ToolsScreen/ToolsScreen";
import { ResourcesScreen } from "../../screens/ResourcesScreen/ResourcesScreen";
import { PromptsScreen } from "../../screens/PromptsScreen/PromptsScreen";
@@ -147,9 +147,9 @@ const sampleLogEntries: LogEntryProps[] = Array.from({ length: 50 }, (_, i) => {
};
});
-const meta: Meta = {
+const meta: Meta = {
title: "Views/ConnectedView",
- component: ConnectedLayout,
+ component: ConnectedView,
parameters: { layout: "fullscreen" },
args: {
serverName: "my-mcp-server",
@@ -164,7 +164,7 @@ const meta: Meta = {
};
export default meta;
-type Story = StoryObj;
+type Story = StoryObj;
export const ToolsActive: Story = {
args: {
@@ -363,25 +363,10 @@ export const LoggingActive: Story = {
activeTab: "Logs",
children: (
diff --git a/clients/web/src/components/views/ConnectedView/ConnectedView.tsx b/clients/web/src/components/views/ConnectedView/ConnectedView.tsx
new file mode 100644
index 00000000..6e8b7238
--- /dev/null
+++ b/clients/web/src/components/views/ConnectedView/ConnectedView.tsx
@@ -0,0 +1,46 @@
+import type { ReactNode } from "react";
+import { AppShell } from "@mantine/core";
+import { ViewHeader } from "../../groups/ViewHeader/ViewHeader";
+
+export interface ConnectedLayoutProps {
+ serverName: string;
+ status: "connected" | "connecting" | "failed";
+ latencyMs?: number;
+ activeTab: string;
+ availableTabs: string[];
+ onTabChange: (tab: string) => void;
+ onDisconnect: () => void;
+ onToggleTheme: () => void;
+ children: ReactNode;
+}
+
+export function ConnectedView({
+ serverName,
+ status,
+ latencyMs,
+ activeTab,
+ availableTabs,
+ onTabChange,
+ onDisconnect,
+ onToggleTheme,
+ children,
+}: ConnectedLayoutProps) {
+ return (
+
+
+
+
+ {children}
+
+ );
+}
diff --git a/clients/web/src/components/views/UnconnectedView/HomeLayout.stories.tsx b/clients/web/src/components/views/UnconnectedView/HomeLayout.stories.tsx
deleted file mode 100644
index 28e4ea59..00000000
--- a/clients/web/src/components/views/UnconnectedView/HomeLayout.stories.tsx
+++ /dev/null
@@ -1,107 +0,0 @@
-import type { Meta, StoryObj } from "@storybook/react-vite";
-import { fn } from "storybook/test";
-import { Text } from "@mantine/core";
-import { HomeLayout } from "./HomeLayout";
-import { ServerListScreen } from "../../screens/ServerListScreen/ServerListScreen";
-import type { ServerCardProps } from "../../groups/ServerCard/ServerCard";
-
-const meta: Meta = {
- title: "Views/UnconnectedView",
- component: HomeLayout,
- parameters: { layout: "fullscreen" },
- args: {
- onToggleTheme: fn(),
- },
-};
-
-export default meta;
-type Story = StoryObj;
-
-function makeServerCallbacks(): Pick<
- ServerCardProps,
- | "onToggleConnection"
- | "onServerInfo"
- | "onSettings"
- | "onEdit"
- | "onClone"
- | "onRemove"
-> {
- return {
- onToggleConnection: fn(),
- onServerInfo: fn(),
- onSettings: fn(),
- onEdit: fn(),
- onClone: fn(),
- onRemove: fn(),
- };
-}
-
-export const Empty: Story = {
- args: {
- children: (
-
- ),
- },
-};
-
-export const WithServers: Story = {
- args: {
- children: (
-
- ),
- },
-};
-
-export const MinimalContent: Story = {
- args: {
- children: (
-
- Page content goes here
-
- ),
- },
-};
diff --git a/clients/web/src/components/views/UnconnectedView/HomeLayout.tsx b/clients/web/src/components/views/UnconnectedView/HomeLayout.tsx
deleted file mode 100644
index c5b68595..00000000
--- a/clients/web/src/components/views/UnconnectedView/HomeLayout.tsx
+++ /dev/null
@@ -1,36 +0,0 @@
-import type { ReactNode } from "react";
-import {
- ActionIcon,
- AppShell,
- Group,
- Title,
- useComputedColorScheme,
-} from "@mantine/core";
-
-export interface HomeLayoutProps {
- children: ReactNode;
- onToggleTheme: () => void;
-}
-
-export function HomeLayout({ children, onToggleTheme }: HomeLayoutProps) {
- const colorScheme = useComputedColorScheme();
-
- return (
-
-
-
- MCP Inspector
-
- {colorScheme === "dark" ? "\u2600" : "\u263E"}
-
-
-
- {children}
-
- );
-}
diff --git a/clients/web/src/components/views/UnconnectedView/UnconnectedView.stories.tsx b/clients/web/src/components/views/UnconnectedView/UnconnectedView.stories.tsx
new file mode 100644
index 00000000..d32b5010
--- /dev/null
+++ b/clients/web/src/components/views/UnconnectedView/UnconnectedView.stories.tsx
@@ -0,0 +1,259 @@
+import type { Meta, StoryObj } from "@storybook/react-vite";
+import { fn } from "storybook/test";
+import { UnconnectedView } from "./UnconnectedView.js";
+import { ServerListScreen } from "../../screens/ServerListScreen/ServerListScreen";
+import type { ServerCardProps } from "../../groups/ServerCard/ServerCard";
+
+const meta: Meta = {
+ title: "Views/UnconnectedView",
+ component: UnconnectedView,
+ parameters: { layout: "fullscreen" },
+ args: {
+ onToggleTheme: fn(),
+ },
+};
+
+export default meta;
+type Story = StoryObj;
+
+function makeServerCallbacks(): Pick<
+ ServerCardProps,
+ | "onToggleConnection"
+ | "onServerInfo"
+ | "onSettings"
+ | "onEdit"
+ | "onClone"
+ | "onRemove"
+> {
+ return {
+ onToggleConnection: fn(),
+ onServerInfo: fn(),
+ onSettings: fn(),
+ onEdit: fn(),
+ onClone: fn(),
+ onRemove: fn(),
+ };
+}
+
+export const Empty: Story = {
+ args: {
+ children: (
+
+ ),
+ },
+};
+
+export const WithServers: Story = {
+ args: {
+ children: (
+
+ ),
+ },
+};
+
+export const ManyServers: Story = {
+ args: {
+ children: (
+
+ ),
+ },
+};
+
+export const Connecting: Story = {
+ args: {
+ children: (
+
+ ),
+ },
+};
diff --git a/clients/web/src/components/views/UnconnectedView/UnconnectedView.tsx b/clients/web/src/components/views/UnconnectedView/UnconnectedView.tsx
new file mode 100644
index 00000000..b95dfaea
--- /dev/null
+++ b/clients/web/src/components/views/UnconnectedView/UnconnectedView.tsx
@@ -0,0 +1,19 @@
+import type { ReactNode } from "react";
+import { AppShell } from "@mantine/core";
+import { ViewHeader } from "../../groups/ViewHeader/ViewHeader";
+
+export interface HomeLayoutProps {
+ children: ReactNode;
+ onToggleTheme: () => void;
+}
+
+export function UnconnectedView({ children, onToggleTheme }: HomeLayoutProps) {
+ return (
+
+
+
+
+ {children}
+
+ );
+}
diff --git a/clients/web/src/theme/Badge.ts b/clients/web/src/theme/Badge.ts
index 6b35b5d1..f55d525c 100644
--- a/clients/web/src/theme/Badge.ts
+++ b/clients/web/src/theme/Badge.ts
@@ -4,5 +4,6 @@ export const ThemeBadge = Badge.extend({
defaultProps: {
size: "sm",
radius: "sm",
+ fw: 600,
},
});
diff --git a/clients/web/src/theme/Flex.ts b/clients/web/src/theme/Flex.ts
new file mode 100644
index 00000000..f27bb2aa
--- /dev/null
+++ b/clients/web/src/theme/Flex.ts
@@ -0,0 +1,14 @@
+import { Flex } from "@mantine/core";
+
+export const ThemeFlex = Flex.extend({
+ styles: (_theme, props) => {
+ if (props.variant === "screen") {
+ return {
+ root: {
+ overflow: "hidden",
+ },
+ };
+ }
+ return { root: {} };
+ },
+});
diff --git a/clients/web/src/theme/Paper.ts b/clients/web/src/theme/Paper.ts
index 041bb892..8170703e 100644
--- a/clients/web/src/theme/Paper.ts
+++ b/clients/web/src/theme/Paper.ts
@@ -25,6 +25,16 @@ export const ThemePaper = Paper.extend({
},
};
}
+ if (props.variant === "panel") {
+ return {
+ root: {
+ display: "flex",
+ flexDirection: "column" as const,
+ overflow: "hidden",
+ minHeight: 0,
+ },
+ };
+ }
return { root: {} };
},
});
diff --git a/clients/web/src/theme/index.ts b/clients/web/src/theme/index.ts
index 1736ef0a..91835be9 100644
--- a/clients/web/src/theme/index.ts
+++ b/clients/web/src/theme/index.ts
@@ -5,6 +5,7 @@ export { ThemeBadge } from "./Badge";
export { ThemeButton } from "./Button";
export { ThemeCard } from "./Card";
export { ThemeCode } from "./Code";
+export { ThemeFlex } from "./Flex";
export { ThemePaper } from "./Paper";
export { ThemeSelect } from "./Select";
export { ThemeSwitch } from "./Switch";
diff --git a/clients/web/src/theme/theme.ts b/clients/web/src/theme/theme.ts
index 42ca6e95..d79a8856 100644
--- a/clients/web/src/theme/theme.ts
+++ b/clients/web/src/theme/theme.ts
@@ -7,6 +7,7 @@ import {
ThemeButton,
ThemeCard,
ThemeCode,
+ ThemeFlex,
ThemePaper,
ThemeSelect,
ThemeSwitch,
@@ -25,11 +26,10 @@ export const theme = createTheme({
cursorType: "pointer",
/* ── Typography ─────────────────────────────────────────── */
- fontFamily:
- '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif',
- fontFamilyMonospace:
- 'ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace',
+ fontFamily: '"Fredoka", sans-serif',
+ fontFamilyMonospace: '"Roboto Mono", monospace',
headings: {
+ fontFamily: '"Fredoka", sans-serif',
fontWeight: "600",
},
@@ -42,6 +42,7 @@ export const theme = createTheme({
Button: ThemeButton,
Card: ThemeCard,
Code: ThemeCode,
+ Flex: ThemeFlex,
Paper: ThemePaper,
Select: ThemeSelect,
Switch: ThemeSwitch,
diff --git a/specification/v2_ux.md b/specification/v2_ux.md
index 67efd697..d695604b 100644
--- a/specification/v2_ux.md
+++ b/specification/v2_ux.md
@@ -661,8 +661,6 @@ A unified history of all MCP requests with **hierarchical request trace** capabi
│ │ * "Get config" resources/read config.json 14:34:15 [Replay] [Unpin] │ │
│ └─────────────────────────────────────────────────────────────────────────────────┘ │
│ │
-│ Showing 50 of 127 entries │
-│ [Load More] │
└─────────────────────────────────────────────────────────────────────────────────────┘
```
@@ -708,7 +706,6 @@ A unified history of all MCP requests with **hierarchical request trace** capabi
- Show/hide child requests (flatten view)
- **Search** across method names, parameters, and responses
-- **Pagination** - Load more for large histories
- **Clear** - Clear history (with confirmation)
- **Export** - Export history as JSON for sharing/debugging
diff --git a/specification/v2_ux_features.md b/specification/v2_ux_features.md
index a91ee794..e919f571 100644
--- a/specification/v2_ux_features.md
+++ b/specification/v2_ux_features.md
@@ -431,8 +431,6 @@ A unified history of all MCP requests with **hierarchical request trace** capabi
| | * "Get config" resources/read config.json 14:34:15 [Replay] [Unpin] | |
| +---------------------------------------------------------------------------------+ |
| |
-| Showing 50 of 127 entries |
-| [Load More] |
+-------------------------------------------------------------------------------------+
```
@@ -478,7 +476,6 @@ A unified history of all MCP requests with **hierarchical request trace** capabi
- Show/hide child requests (flatten view)
- **Search** across method names, parameters, and responses
-- **Pagination** - Load more for large histories
- **Clear** - Clear history (with confirmation)
- **Export** - Export history as JSON for sharing/debugging
diff --git a/specification/v2_ux_storybook.md b/specification/v2_ux_storybook.md
index 41d296da..2b077446 100644
--- a/specification/v2_ux_storybook.md
+++ b/specification/v2_ux_storybook.md
@@ -551,7 +551,6 @@ Single request history entry with expand/collapse, replay, and pin.
| `response` | `object?` | Response data |
| `children` | `HistoryEntry[]?` | Nested child requests (sampling/elicitation) |
| `isPinned` | `boolean` | Pin state |
-| `pinLabel` | `string?` | Custom pin label |
| `isExpanded` | `boolean` | Expanded state |
| `onToggleExpand` | `() => void` | Expand/collapse |
| `onReplay` | `() => void` | Replay request |
@@ -952,12 +951,11 @@ Request history list with search, filter, pinned section.
| `displayedCount` | `number` | Currently shown |
| `onSearchChange` | `(text: string) => void` | Search change |
| `onMethodFilterChange` | `(method: string) => void` | Filter change |
-| `onLoadMore` | `() => void` | Load more |
| `onClearAll` | `() => void` | Clear history |
| `onExport` | `() => void` | Export JSON |
| (entry callbacks forwarded) | | |
-**Mantine:** `Paper` with header `Group` (Title + `TextInput` search + `Select` filter + Export `Button` + Clear `Button`), `Stack` of HistoryEntry, "Pinned Requests" `Divider` + pinned entries, pagination text + Load More `Button`.
+**Mantine:** `Paper` with header `Group` (Title + `TextInput` search + `Select` filter + Export `Button` + Clear `Button`), `Stack` of HistoryEntry, "Pinned Requests" `Divider` + pinned entries.
**Stories:** WithEntries, WithPinned, WithNestedChildren, Filtered, Empty, ManyEntries