feat: Add Mantine theme, 12 atom components, and 25 molecule components with Storybook stories

- Mantine theme with component extensions (Badge, Card, Paper, Button, etc.)
- CSS custom properties for design tokens (--inspector-* vars)
- Dark/light mode support with auto detection
- 12 atoms: StatusIndicator, AnnotationBadge, CapabilityItem, LogEntry,
  ProgressDisplay, ContentViewer, ListChangedIndicator, CopyButton,
  ConnectionToggle, TransportBadge, InlineError, MessageBubble
- 25 molecules: ServerCard, AddServerMenu, SchemaForm, ToolListItem,
  ToolDetailPanel, ResultPanel, ResourceListItem, ResourcePreviewPanel,
  ResourceTemplateInput, PromptArgumentsForm, PromptMessagesDisplay,
  LogControls, TaskCard, HistoryEntry, ServerInfoContent, ServerSettingsForm,
  RootsTable, SamplingRequestPanel, ElicitationFormPanel, ElicitationUrlPanel,
  InlineSamplingRequest, InlineElicitationRequest, PendingClientRequests,
  ImportServerJsonPanel, ExperimentalFeaturesPanel
- Exclude storybook-static from version control
- Exclude .stories.tsx from app tsconfig (Storybook-only modules)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
cliffhall 2026-03-17 10:23:56 -04:00
commit 39ef184b53
149 changed files with 5895 additions and 102158 deletions

View file

@ -10,6 +10,7 @@ lerna-debug.log*
node_modules
dist
dist-ssr
storybook-static
*.local
# Editor directories and files

View file

@ -1,12 +1,16 @@
import type { Preview } from '@storybook/react-vite'
import { MantineProvider } from '@mantine/core'
import { Notifications } from '@mantine/notifications'
import '@mantine/core/styles.css'
import '@mantine/notifications/styles.css'
import '../src/App.css'
import { theme } from '../src/theme/theme'
const preview: Preview = {
decorators: [
(Story) => (
<MantineProvider>
<MantineProvider theme={theme} defaultColorScheme="light">
<Notifications position="top-right" />
<Story />
</MantineProvider>
),
@ -21,6 +25,7 @@ const preview: Preview = {
a11y: {
test: 'todo',
},
layout: 'centered',
},
}

View file

@ -8,7 +8,7 @@ import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([globalIgnores(['dist']), {
export default defineConfig([globalIgnores(['dist', 'storybook-static']), {
files: ['**/*.{ts,tsx}'],
extends: [
js.configs.recommended,

View file

@ -4,7 +4,7 @@
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>web</title>
<title>MCP Inspector</title>
</head>
<body>
<div id="root"></div>

105
clients/web/src/App.css Normal file
View file

@ -0,0 +1,105 @@
/*
* App.css — Global styles that CANNOT be expressed in the Mantine theme.
*
* Only the following belong here:
* - :root CSS custom properties (--inspector-* design tokens)
* - @keyframes animations
* - Pseudo-selectors (:hover, :focus) that cross component boundaries
* - Nested child-element selectors for third-party HTML output
* - Styles for native HTML elements (img, iframe, etc.)
*
* Everything else (margin, padding, background, border, color, font-size)
* belongs in the Mantine theme via Component.extend() or .withProps().
*/
:root {
/* ── Brand ─────────────────────────────────────────────── */
--inspector-brand-primary: var(--mantine-primary-color-filled);
/* ── Text ──────────────────────────────────────────────── */
--inspector-text-primary: var(--mantine-color-text);
--inspector-text-secondary: var(--mantine-color-dimmed);
--inspector-text-inverse: var(--mantine-color-white);
/* ── Surfaces ──────────────────────────────────────────── */
--inspector-surface-body: var(--mantine-color-body);
--inspector-surface-card: var(--mantine-color-default);
--inspector-surface-code: var(--mantine-color-gray-0);
/* ── Borders ───────────────────────────────────────────── */
--inspector-border-default: var(--mantine-color-default-border);
--inspector-border-subtle: var(--mantine-color-gray-3);
/* ── Connection status ─────────────────────────────────── */
--inspector-status-connected: var(--mantine-color-green-6);
--inspector-status-connecting: var(--mantine-color-yellow-6);
--inspector-status-disconnected: var(--mantine-color-gray-5);
--inspector-status-failed: var(--mantine-color-red-6);
/* ── Log levels (RFC 5424) ─────────────────────────────── */
--inspector-log-debug: var(--mantine-color-gray-6);
--inspector-log-info: var(--mantine-color-blue-6);
--inspector-log-notice: var(--mantine-color-teal-6);
--inspector-log-warning: var(--mantine-color-yellow-6);
--inspector-log-error: var(--mantine-color-red-6);
--inspector-log-critical: var(--mantine-color-red-8);
--inspector-log-alert: var(--mantine-color-red-9);
--inspector-log-emergency: var(--mantine-color-red-9);
/* ── Annotations ───────────────────────────────────────── */
--inspector-annotation-destructive: var(--mantine-color-red-6);
--inspector-annotation-long-run: var(--mantine-color-yellow-6);
--inspector-annotation-read-only: var(--mantine-color-dark-6);
--inspector-annotation-audience: var(--mantine-color-gray-6);
--inspector-annotation-priority-high: var(--mantine-color-orange-6);
--inspector-annotation-priority-medium: var(--mantine-color-yellow-6);
--inspector-annotation-priority-low: var(--mantine-color-green-6);
/* ── Task status ───────────────────────────────────────── */
--inspector-task-waiting: var(--mantine-color-gray-5);
--inspector-task-running: var(--mantine-color-blue-6);
--inspector-task-completed: var(--mantine-color-green-6);
--inspector-task-failed: var(--mantine-color-red-6);
--inspector-task-cancelled: var(--mantine-color-yellow-6);
/* ── History ───────────────────────────────────────────── */
--inspector-history-success: var(--mantine-color-green-6);
--inspector-history-error: var(--mantine-color-red-6);
}
/* Dark mode overrides — tokens that need adjustment in dark scheme */
[data-mantine-color-scheme='dark'] {
--inspector-surface-code: var(--mantine-color-dark-6);
--inspector-border-subtle: var(--mantine-color-dark-4);
}
/* ── Animations ──────────────────────────────────────────── */
@keyframes inspector-pulse {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.4;
}
}
@keyframes inspector-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
/* ── Status dot animations ───────────────────────────────── */
.inspector-pulse {
animation: inspector-pulse 1.5s ease-in-out infinite;
}
.inspector-spin {
animation: inspector-spin 1s linear infinite;
}

View file

@ -1,11 +1,39 @@
import { Title, Text, Container, Stack } from '@mantine/core'
import {
Title,
Text,
Container,
Stack,
useMantineColorScheme,
useComputedColorScheme,
ActionIcon,
Group,
} from '@mantine/core'
function App() {
const { setColorScheme } = useMantineColorScheme()
const computedColorScheme = useComputedColorScheme('light')
const toggleColorScheme = () => {
setColorScheme(computedColorScheme === 'dark' ? 'light' : 'dark')
}
return (
<Container size="sm" py="xl">
<Stack align="center" gap="md">
<Title order={1}>MCP Inspector</Title>
<Text c="dimmed">Web client for the Model Context Protocol Inspector</Text>
<Group>
<Title order={1}>MCP Inspector</Title>
<ActionIcon
onClick={toggleColorScheme}
variant="default"
size="lg"
aria-label="Toggle color scheme"
>
{computedColorScheme === 'dark' ? '\u2600' : '\u263E'}
</ActionIcon>
</Group>
<Text c="var(--inspector-text-secondary)">
Web client for the Model Context Protocol Inspector
</Text>
</Stack>
</Container>
)

View file

@ -0,0 +1,52 @@
import type { Meta, StoryObj } from '@storybook/react-vite'
import { AnnotationBadge } from './AnnotationBadge'
const meta: Meta<typeof AnnotationBadge> = {
title: 'Atoms/AnnotationBadge',
component: AnnotationBadge,
}
export default meta
type Story = StoryObj<typeof AnnotationBadge>
export const Audience: Story = {
args: {
label: 'user',
variant: 'audience',
},
}
export const ReadOnly: Story = {
args: {
label: 'read-only',
variant: 'readOnly',
},
}
export const Destructive: Story = {
args: {
label: 'destructive',
variant: 'destructive',
},
}
export const LongRun: Story = {
args: {
label: 'long-run',
variant: 'longRun',
},
}
export const PriorityHigh: Story = {
args: {
label: 'priority: high',
variant: 'priority',
},
}
export const Default: Story = {
args: {
label: 'custom',
variant: 'default',
},
}

View file

@ -0,0 +1,27 @@
import { Badge } from '@mantine/core'
export type AnnotationVariant = 'audience' | 'readOnly' | 'destructive' | 'longRun' | 'priority' | 'default'
export interface AnnotationBadgeProps {
label: string
variant?: AnnotationVariant
}
const variantMap: Record<AnnotationVariant, { color: string; variant: string }> = {
audience: { color: 'gray', variant: 'outline' },
readOnly: { color: 'dark', variant: 'filled' },
destructive: { color: 'red', variant: 'outline' },
longRun: { color: 'yellow', variant: 'outline' },
priority: { color: 'orange', variant: 'light' },
default: { color: 'gray', variant: 'light' },
}
export function AnnotationBadge({ label, variant = 'default' }: AnnotationBadgeProps) {
const { color, variant: badgeVariant } = variantMap[variant]
return (
<Badge color={color} variant={badgeVariant}>
{label}
</Badge>
)
}

View file

@ -0,0 +1,32 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { CapabilityItem } from './CapabilityItem';
const meta: Meta<typeof CapabilityItem> = {
title: 'Atoms/CapabilityItem',
component: CapabilityItem,
};
export default meta;
type Story = StoryObj<typeof CapabilityItem>;
export const Supported: Story = {
args: {
name: 'Tools',
supported: true,
},
};
export const SupportedWithCount: Story = {
args: {
name: 'Tools',
supported: true,
count: 4,
},
};
export const NotSupported: Story = {
args: {
name: 'Completions',
supported: false,
},
};

View file

@ -0,0 +1,18 @@
import { Group, Text } from '@mantine/core';
export interface CapabilityItemProps {
name: string;
supported: boolean;
count?: number;
}
export function CapabilityItem({ name, supported, count }: CapabilityItemProps) {
const label = count != null ? `${name} (${count})` : name;
return (
<Group gap="xs">
<Text c={supported ? 'green' : 'red'}>{supported ? '\u2713' : '\u2717'}</Text>
<Text>{label}</Text>
</Group>
);
}

View file

@ -0,0 +1,46 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { fn } from 'storybook/test';
import { ConnectionToggle } from './ConnectionToggle';
const meta: Meta<typeof ConnectionToggle> = {
title: 'Atoms/ConnectionToggle',
component: ConnectionToggle,
args: {
onChange: fn(),
},
};
export default meta;
type Story = StoryObj<typeof ConnectionToggle>;
export const Connected: Story = {
args: {
checked: true,
loading: false,
disabled: false,
},
};
export const Disconnected: Story = {
args: {
checked: false,
loading: false,
disabled: false,
},
};
export const Loading: Story = {
args: {
checked: false,
loading: true,
disabled: false,
},
};
export const Disabled: Story = {
args: {
checked: false,
loading: false,
disabled: true,
},
};

View file

@ -0,0 +1,19 @@
import { Switch } from '@mantine/core';
export interface ConnectionToggleProps {
checked: boolean;
loading: boolean;
disabled: boolean;
onChange: (checked: boolean) => void;
}
export function ConnectionToggle({ checked, loading, disabled, onChange }: ConnectionToggleProps) {
return (
<Switch
size="lg"
checked={checked}
disabled={disabled || loading}
onChange={(event) => onChange(event.currentTarget.checked)}
/>
);
}

View file

@ -0,0 +1,78 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { fn } from 'storybook/test';
import { ContentViewer } from './ContentViewer';
const meta: Meta<typeof ContentViewer> = {
title: 'Atoms/ContentViewer',
component: ContentViewer,
};
export default meta;
type Story = StoryObj<typeof ContentViewer>;
export const PlainText: Story = {
args: {
type: 'text',
content: 'Hello, world!\nThis is plain text.',
},
};
export const JsonContent: Story = {
args: {
type: 'json',
content: '{"name":"my-app","version":"1.0.0","description":"Sample configuration file"}',
},
};
export const ImagePreview: Story = {
args: {
type: 'image',
content:
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==',
mimeType: 'image/png',
},
};
export const LongContent: Story = {
args: {
type: 'json',
content: JSON.stringify({
name: 'my-app',
version: '2.5.0',
description: 'A comprehensive application configuration',
author: 'Inspector Team',
license: 'MIT',
repository: 'https://github.com/example/my-app',
dependencies: {
react: '^18.0.0',
typescript: '^5.0.0',
mantine: '^7.0.0',
},
devDependencies: {
vitest: '^1.0.0',
storybook: '^8.0.0',
eslint: '^9.0.0',
},
scripts: {
dev: 'vite',
build: 'tsc && vite build',
test: 'vitest',
lint: 'eslint .',
},
settings: {
theme: 'dark',
language: 'en',
notifications: true,
maxRetries: 3,
},
}),
},
};
export const WithCopyButton: Story = {
args: {
type: 'text',
content: 'Hello, world!\nThis text can be copied.',
onCopy: fn(),
},
};

View file

@ -0,0 +1,43 @@
import { Button, Code, Image, Stack } from '@mantine/core';
export interface ContentViewerProps {
type: 'text' | 'json' | 'image' | 'audio';
content: string;
mimeType?: string;
onCopy?: () => void;
}
function formatJson(content: string): string {
try {
return JSON.stringify(JSON.parse(content), null, 2);
} catch {
return content;
}
}
export function ContentViewer({ type, content, mimeType, onCopy }: ContentViewerProps) {
return (
<Stack gap="xs">
{type === 'json' && <Code block>{formatJson(content)}</Code>}
{type === 'text' && <Code block>{content}</Code>}
{type === 'image' && (
<Image
src={`data:${mimeType || 'image/png'};base64,${content}`}
alt="Content preview"
maw={400}
radius="md"
/>
)}
{type === 'audio' && (
<audio controls>
<source src={`data:${mimeType || 'audio/wav'};base64,${content}`} />
</audio>
)}
{onCopy && (
<Button variant="light" size="xs" onClick={onCopy}>
Copy
</Button>
)}
</Stack>
);
}

View file

@ -0,0 +1,23 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { CopyButton } from './CopyButton';
const meta: Meta<typeof CopyButton> = {
title: 'Atoms/CopyButton',
component: CopyButton,
};
export default meta;
type Story = StoryObj<typeof CopyButton>;
export const Default: Story = {
args: {
value: 'Hello, world!',
},
};
export const LongValue: Story = {
args: {
value:
'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur.',
},
};

View file

@ -0,0 +1,19 @@
import { ActionIcon, CopyButton as MantineCopyButton, Tooltip } from '@mantine/core';
export interface CopyButtonProps {
value: string;
}
export function CopyButton({ value }: CopyButtonProps) {
return (
<MantineCopyButton value={value}>
{({ copied, copy }) => (
<Tooltip label={copied ? 'Copied' : 'Copy'}>
<ActionIcon variant="subtle" color={copied ? 'green' : 'gray'} onClick={copy}>
{copied ? '\u2713' : '\u2398'}
</ActionIcon>
</Tooltip>
)}
</MantineCopyButton>
);
}

View file

@ -0,0 +1,50 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { InlineError } from './InlineError';
const meta: Meta<typeof InlineError> = {
title: 'Atoms/InlineError',
component: InlineError,
};
export default meta;
type Story = StoryObj<typeof InlineError>;
export const ShortError: Story = {
args: {
message: 'Connection timeout after 20s',
},
};
export const WithRetryCount: Story = {
args: {
message: 'Connection failed',
retryCount: 3,
maxRetries: 5,
},
};
export const WithDetails: Story = {
args: {
message: 'Connection refused',
details:
'Error: ECONNREFUSED 127.0.0.1:3000\n at TCPConnectWrap.afterConnect [as oncomplete] (node:net:1494:16)',
},
};
export const WithDocLink: Story = {
args: {
message: 'Connection failed',
docLink: 'https://example.com/troubleshooting',
},
};
export const FullError: Story = {
args: {
message: 'Connection refused',
details:
'Error: ECONNREFUSED 127.0.0.1:3000\n at TCPConnectWrap.afterConnect [as oncomplete] (node:net:1494:16)',
retryCount: 3,
maxRetries: 5,
docLink: 'https://example.com/troubleshooting',
},
};

View file

@ -0,0 +1,43 @@
import { Alert, Anchor, Group, Spoiler, Stack, Text } from '@mantine/core';
export interface InlineErrorProps {
message: string;
details?: string;
retryCount?: number;
maxRetries?: number;
onShowMore?: () => void;
docLink?: string;
}
export function InlineError({
message,
details,
retryCount,
maxRetries,
docLink,
}: InlineErrorProps) {
return (
<Alert color="red" variant="light" title={message}>
<Stack gap="xs">
{retryCount !== undefined && (
<Text size="sm">
Retry attempt {retryCount}
{maxRetries !== undefined && ` of ${maxRetries}`}
</Text>
)}
{details && (
<Spoiler maxHeight={0} showLabel="Show more" hideLabel="Show less">
<Text size="sm">{details}</Text>
</Spoiler>
)}
{docLink && (
<Group>
<Anchor href={docLink} target="_blank" size="sm">
View Troubleshooting Guide &rarr;
</Anchor>
</Group>
)}
</Stack>
</Alert>
);
}

View file

@ -0,0 +1,25 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { fn } from 'storybook/test';
import { ListChangedIndicator } from './ListChangedIndicator';
const meta: Meta<typeof ListChangedIndicator> = {
title: 'Atoms/ListChangedIndicator',
component: ListChangedIndicator,
};
export default meta;
type Story = StoryObj<typeof ListChangedIndicator>;
export const Visible: Story = {
args: {
visible: true,
onRefresh: fn(),
},
};
export const Hidden: Story = {
args: {
visible: false,
onRefresh: fn(),
},
};

View file

@ -0,0 +1,27 @@
import { Button, Flex, Group, Text } from '@mantine/core';
export interface ListChangedIndicatorProps {
visible: boolean;
onRefresh: () => void;
}
const Dot = Flex.withProps({
w: 8,
h: 8,
style: { borderRadius: '50%' },
bg: 'var(--inspector-status-connecting)',
});
export function ListChangedIndicator({ visible, onRefresh }: ListChangedIndicatorProps) {
if (!visible) return null;
return (
<Group gap="xs">
<Dot />
<Text size="sm" c="dimmed">List updated</Text>
<Button size="xs" variant="light" onClick={onRefresh}>
↻ Refresh
</Button>
</Group>
);
}

Some files were not shown because too many files have changed in this diff Show more