inspector/clients/web/src/components/groups/ServerCard/ServerCard.tsx
Cliff Hall 4d74b94bed
Wire App.tsx to v2 core/ hook layer; remove demo stub from InspectorView (#1322)
* feat(web): wire App.tsx to v2 core/ hook layer; remove demo stub from InspectorView

App.tsx mounts InspectorView with live data from the v2 hook layer instead
of the title-page placeholder. InspectorView becomes pure prop-driven; the
demo handshake stub and connection state machinery are removed.

Connection lifecycle:
- App.tsx lazily instantiates `InspectorClient` on the connect edge,
  rebuilding it (and its state managers) when the user picks a different
  server. State managers (Managed{Tools,Prompts,Resources,ResourceTemplates,
  RequestorTasks}State + MessageLog/FetchRequestLog/StderrLog) are
  destroyed on switch.
- `useInspectorClient` + the per-primitive `useManaged*` hooks drive the
  view's data. `latencyMs` is captured at the connecting → connected edge
  via a ref so the intermediate rerenders don't lose the start timestamp.
- `errorMessage` is captured from `client.connect()` rejection; status-
  machine-driven errors that don't surface through the promise are a known
  follow-up (no `error` event in the v2 InspectorClientEventMap yet).

Action handlers:
- `onCallTool` / `onGetPrompt` / `onReadResource` await the corresponding
  InspectorClient method and reflect pending → ok/error in `ToolCallState`
  / `GetPromptState` / `ReadResourceState` panel props. Manually verified
  end-to-end against `npx @modelcontextprotocol/server-filesystem` —
  the Tools result panel renders the real "Allowed directories: /private/tmp"
  output rather than a stub.
- `onSetLogLevel` calls `client.setLoggingLevel(level)` and optimistically
  bumps `currentLogLevel` locally (the request has no echo notification).
- `onSubscribeResource` / `onUnsubscribeResource` / `onCancelTask` route to
  their respective client methods. Server CRUD (`onServerAdd`/`onServerEdit`
  /…) and history pinning are intentionally `todoNoop` for now; the seed
  server list is hardcoded per the agreed scope (a `useServers` v2-only
  hook is a separate effort).
- `notifications/message` notifications flowing through `MessageLogState`
  are filtered into `LogEntryData[]` and passed to the Logs screen, so
  `logging/setLevel` and the resulting log stream round-trip.

InspectorView (`InspectorView.tsx`):
- Removed: `STUB_*` constants, `handshakeTimer` ref, `handleToggleConnection`
  / `disconnect` functions, stub `bridgeFactory`/`sandboxPath`, internal
  connection state, `logLevel` state.
- Added 38 props covering connection state, panel states, log level, and
  action callbacks. The view is pure presentational.
- `availableTabs` is derived from `connectionStatus`; `activeTab` is
  clamped to whatever's currently available (avoiding the `set-state-in-effect`
  lint and keeping the previously-selected tab on reconnect).

Stories + tests:
- `InspectorView.stories.tsx` adds spy callbacks for every action prop and
  new `Connected` / `ConnectionError` stories for the connected/error
  narratives.
- `InspectorView.test.tsx` is rewritten around the prop-driven contract
  (10 tests covering the empty list, server cards, toggle dispatch,
  connected header, error banner, tab snap-back on disconnect, app
  filtering, log-level dispatch, autoScroll local toggle).

Dev backend port wiring (`vite.config.ts`):
- Vite's dev server is now pinned to `CLIENT_PORT` (default 6274) with
  `strictPort: true`, matching the dev backend's `allowedOrigins`. Without
  this, Vite would fall back to 5173 while the Hono origin check still
  required 6274 — breaking every browser `/api/*` fetch out of the box.
- Top-level `resolve.alias` now spreads the same bare-module aliases the
  vitest projects use. Rolldown can't reach `pino/browser.js` /
  `zustand/middleware` from `core/`'s parent (no node_modules there); the
  wired App pulls those modules into the browser dep graph for the first
  time, so the aliases need to be visible at build time too.

createWebEnvironment helper (`src/lib/environmentFactory.ts`):
- Mirrors v1.5's `lib/adapters/environmentFactory.ts`: builds
  `InspectorClientEnvironment` from `createRemoteTransport` +
  `createRemoteFetch` + `createRemoteLogger` + `BrowserOAuthStorage` +
  `BrowserNavigation`. Returns the assembled environment plus the logger
  for direct app-level use.

Closes #1244.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(web): wire dev-backend auth token into createWebEnvironment + show full stdio command in ServerCard

Two issues surfaced after the #1244 wiring landed locally:

1. **401 on `/api/mcp/connect`** when running `npm run dev` with the
   default auth (no `DANGEROUSLY_OMIT_AUTH`). The dev backend generates a
   random token and prints `http://localhost:6274?MCP_INSPECTOR_API_TOKEN=…`
   in the banner, but App.tsx was calling `createWebEnvironment(undefined, …)`
   so the browser never sent `x-mcp-remote-auth`. The Hono middleware
   correctly rejected with "Authentication required. Use the
   x-mcp-remote-auth header with Bearer token."

   Adds a small `getAuthToken()` helper in App.tsx that reads
   `MCP_INSPECTOR_API_TOKEN` off `window.location.search`, falls back to
   `sessionStorage`, and persists fresh values into sessionStorage so
   client-side navigation / OAuth round-trips don't drop the token. The
   resolved value is passed to `createWebEnvironment` whenever a new
   InspectorClient is built.

2. **Misleading ServerCard display** — the seed server's `config` is
   `{ command: "npx", args: ["-y", "@modelcontextprotocol/server-filesystem",
   "/tmp"] }` but the card only rendered `"npx"`, so it looked like the
   command was incomplete. `getCommandOrUrl` now joins `command + args`
   for stdio configs, so the card shows the same string that gets spawned.
   SSE / streamable-http paths unchanged.

Manual end-to-end re-verified against `npm run dev` (no DANGEROUSLY_OMIT_AUTH):
- Banner prints the auth-token URL.
- Browser navigates to that URL → `getAuthToken()` picks the token off the
  query string, persists to sessionStorage, threads it through to every
  `/api/*` request.
- Server card shows `npx -y @modelcontextprotocol/server-filesystem /tmp`.
- Toggle connect → connection succeeds; ViewHeader shows
  `Connected (~1.3s)`; Tools tab populates with the 14 filesystem tools.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(web): use dark-9 instead of blue-9 for dark-mode page background

`main.tsx` overrode `--mantine-color-body` to `--mantine-color-blue-9` in
dark mode, while `.storybook/preview.tsx` used `--mantine-color-dark-9`.
The dev app then rendered with a blue background while every story (and
the design intent visible in storybook) used dark grey.

Switch main.tsx to `--mantine-color-dark-9` so the dev app matches the
storybook reference.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(web): seed the Servers screen with the "everything" reference server

Adds `@modelcontextprotocol/server-everything` to the hardcoded seed list
alongside the filesystem server. The everything server exposes tools,
prompts, resources, sampling, and completion — covering most of the
surface a developer might want to exercise against the wired-up
InspectorView without configuring anything.

Comment updated to explain the two-seed shape.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(web): address #1244 PR review feedback (JsonValue cast, client lifecycle, dead code)

- **#1 — Misleading `Record<string, never>` cast**: Imported `JsonValue`
  from `@inspector/core/mcp/index.js` and changed `args as Record<string,
  never>` to `args as Record<string, JsonValue>` in `onCallTool`. The new
  cast honestly narrows from the screen-level `Record<string, unknown>`
  to what `InspectorClient.callTool` actually requires. Added a comment
  explaining the boundary.

- **#2 + #3 — Previous InspectorClient not disconnected on server switch
  or unmount**: Added a `useEffect` keyed on `inspectorClient` whose
  cleanup calls `inspectorClient.disconnect()`. One effect covers both
  the swap-on-switch leak (prior session's transport stayed open until
  GC) and the unmount-during-connected leak (HMR / tests).
  `setupClientForServer`'s existing state-manager `destroy()` calls
  handle the listener side; the new effect handles the transport side.

- **#4 — Dead `void` block at bottom of file**: Deleted the trailing
  `void FetchRequestLogState; void StderrLogState;` block. The state
  managers are already constructed via `new FetchRequestLogState(client)`
  / `new StderrLogState(client)`, so the imports are referenced by the
  linter's reckoning. The `void` pair was dead code with a comment that
  no longer matched reality.

- **#5 / #7 / #8 — Follow-up TODOs**: Added TODO refs to the new
  follow-up issues:
  - TODO(#1323): `error` event in InspectorClientEventMap so mid-session
    transport failures surface (connect-only catch is incomplete).
  - TODO(#1324): negotiated `protocolVersion` through `useInspectorClient`
    (currently hard-coded `"2025-06-18"`).
  - TODO(#1325): `useResourceSubscriptions` hook so subscribe/unsubscribe
    buttons reflect their server-side effect.

Items #6 (useCallback dep churn), #9 (helper unit tests), and #10
(shared no-op bridge factory fixture) are acknowledged in the PR reply
as deferred — no functional impact and out of scope for a "wire the
hook layer" PR.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 15:41:55 -04:00

175 lines
4.7 KiB
TypeScript

import { Badge, Button, Card, Group, Stack, Text } from "@mantine/core";
import type {
MCPServerConfig,
ServerEntry,
ServerType,
} from "@inspector/core/mcp/types.js";
import { ServerStatusIndicator } from "../../elements/ServerStatusIndicator/ServerStatusIndicator";
import { TransportBadge } from "../../elements/TransportBadge/TransportBadge";
import { ConnectionToggle } from "../../elements/ConnectionToggle/ConnectionToggle";
import { ContentViewer } from "../../elements/ContentViewer/ContentViewer";
import { InlineError } from "../../elements/InlineError/InlineError";
export interface ServerCardProps extends ServerEntry {
activeServer?: string;
onToggleConnection: (id: string) => void;
onServerInfo: (id: string) => void;
onSettings: (id: string) => void;
onEdit: (id: string) => void;
onClone: (id: string) => void;
onRemove: (id: string) => void;
compact?: boolean;
}
const HeaderLeft = Group.withProps({
gap: "sm",
wrap: "nowrap",
miw: 0,
flex: 1,
});
const HeaderRight = Group.withProps({
gap: "sm",
wrap: "nowrap",
});
const ActionsRow = Group.withProps({
gap: "xs",
});
const ServerName = Text.withProps({
fw: 600,
size: "lg",
truncate: "end",
miw: 0,
flex: 1,
});
const ModeText = Text.withProps({
size: "sm",
c: "dimmed",
});
const SubtleButton = Button.withProps({
variant: "subtle",
size: "xs",
});
const RemoveButton = Button.withProps({
variant: "subtle",
size: "xs",
color: "red.6",
});
function getTransport(config: MCPServerConfig): ServerType {
return config.type ?? "stdio";
}
const TRANSPORT_DESCRIPTION: Record<ServerType, string> = {
stdio: "Standard I/O",
sse: "SSE (Server Sent Events) [deprecated]",
"streamable-http": "Streamable HTTP",
};
function getCommandOrUrl(config: MCPServerConfig): string {
if (config.type === "sse" || config.type === "streamable-http") {
return config.url;
}
// Show the full argv for stdio so the card displays the same thing
// that gets spawned. Otherwise a `command: "npx", args: ["-y", "pkg"]`
// config renders as just "npx", which is misleading.
const args = config.args ?? [];
return args.length > 0
? `${config.command} ${args.join(" ")}`
: config.command;
}
export function ServerCard({
id,
name,
config,
info,
connection,
activeServer,
onToggleConnection,
onServerInfo,
onSettings,
onEdit,
onClone,
onRemove,
compact = false,
}: ServerCardProps) {
const isDimmed = activeServer !== undefined && activeServer !== id;
const transport = getTransport(config);
const commandOrUrl = getCommandOrUrl(config);
const version = info?.version;
return (
<Card
withBorder
padding="lg"
variant={isDimmed ? "disabled" : undefined}
{...(isDimmed ? { "aria-disabled": true, inert: true } : {})}
>
<Stack gap="sm">
<Group justify="space-between" wrap="nowrap">
<HeaderLeft>
<ServerName>{name}</ServerName>
</HeaderLeft>
<HeaderRight>
<ServerStatusIndicator
status={connection.status}
retryCount={connection.retryCount}
/>
<ConnectionToggle
status={connection.status}
disabled={isDimmed}
onToggle={() => onToggleConnection(id)}
/>
</HeaderRight>
</Group>
{!compact && (
<>
<Group gap="sm" mih={30}>
{version && <Badge variant="outline">{version}</Badge>}
<TransportBadge transport={transport} />
<ModeText>{TRANSPORT_DESCRIPTION[transport]}</ModeText>
</Group>
<ContentViewer
block={{ type: "text", text: commandOrUrl }}
copyable
/>
{connection.error && (
<InlineError
error={{
message: connection.error.message,
data: connection.error.details,
}}
retryCount={connection.retryCount}
/>
)}
<Group justify="space-between">
<ActionsRow>
<SubtleButton onClick={() => onClone(id)}>Clone</SubtleButton>
<SubtleButton onClick={() => onEdit(id)}>Edit</SubtleButton>
<RemoveButton onClick={() => onRemove(id)}>Remove</RemoveButton>
</ActionsRow>
<ActionsRow>
<SubtleButton onClick={() => onServerInfo(id)}>
Server Info
</SubtleButton>
<SubtleButton onClick={() => onSettings(id)}>
Settings
</SubtleButton>
</ActionsRow>
</Group>
</>
)}
</Stack>
</Card>
);
}