From 66e89f83170dab0e94d65fb05b6051a12bc3e522 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Mon, 18 May 2026 20:53:30 -0400 Subject: [PATCH 1/5] feat(web): wire App.tsx to v2 core/ hook layer; remove demo stub from InspectorView MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- clients/web/src/App.tsx | 594 ++++++++++++++++-- .../InspectorView/InspectorView.stories.tsx | 95 ++- .../InspectorView/InspectorView.test.tsx | 327 ++++++---- .../views/InspectorView/InspectorView.tsx | 386 +++++++----- clients/web/src/lib/environmentFactory.ts | 71 +++ clients/web/vite.config.ts | 27 +- 6 files changed, 1167 insertions(+), 333 deletions(-) create mode 100644 clients/web/src/lib/environmentFactory.ts diff --git a/clients/web/src/App.tsx b/clients/web/src/App.tsx index 7d64073cd..88b33ec84 100644 --- a/clients/web/src/App.tsx +++ b/clients/web/src/App.tsx @@ -1,60 +1,558 @@ -import { - ActionIcon, - Container, - Group, - Stack, - Text, - Title, - useComputedColorScheme, - useMantineColorScheme, -} from "@mantine/core"; -import { MdDarkMode, MdLightMode } from "react-icons/md"; - -const PageContainer = Container.withProps({ - size: "sm", - py: "xl", -}); - -const PageStack = Stack.withProps({ - align: "center", - gap: "md", -}); - -const ThemeToggle = ActionIcon.withProps({ - variant: "default", - size: "lg", - "aria-label": "Toggle color scheme", -}); - -const SubtitleText = Text.withProps({ - c: "var(--inspector-text-secondary)", -}); +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useComputedColorScheme, useMantineColorScheme } from "@mantine/core"; +import type { + InitializeResult, + LoggingLevel, + LoggingMessageNotification, + Tool, +} from "@modelcontextprotocol/sdk/types.js"; +import type { AppBridge } from "@modelcontextprotocol/ext-apps/app-bridge"; +import { InspectorClient } from "@inspector/core/mcp/index.js"; +import type { MessageEntry, ServerEntry } from "@inspector/core/mcp/types.js"; +import { ManagedToolsState } from "@inspector/core/mcp/state/managedToolsState.js"; +import { ManagedPromptsState } from "@inspector/core/mcp/state/managedPromptsState.js"; +import { ManagedResourcesState } from "@inspector/core/mcp/state/managedResourcesState.js"; +import { ManagedResourceTemplatesState } from "@inspector/core/mcp/state/managedResourceTemplatesState.js"; +import { ManagedRequestorTasksState } from "@inspector/core/mcp/state/managedRequestorTasksState.js"; +import { MessageLogState } from "@inspector/core/mcp/state/messageLogState.js"; +import { FetchRequestLogState } from "@inspector/core/mcp/state/fetchRequestLogState.js"; +import { StderrLogState } from "@inspector/core/mcp/state/stderrLogState.js"; +import type { RedirectUrlProvider } from "@inspector/core/auth/index.js"; +import { useInspectorClient } from "@inspector/core/react/useInspectorClient.js"; +import { useManagedTools } from "@inspector/core/react/useManagedTools.js"; +import { useManagedPrompts } from "@inspector/core/react/useManagedPrompts.js"; +import { useManagedResources } from "@inspector/core/react/useManagedResources.js"; +import { useManagedResourceTemplates } from "@inspector/core/react/useManagedResourceTemplates.js"; +import { useManagedRequestorTasks } from "@inspector/core/react/useManagedRequestorTasks.js"; +import { useMessageLog } from "@inspector/core/react/useMessageLog.js"; +import { InspectorView } from "./components/views/InspectorView/InspectorView"; +import type { ToolCallState } from "./components/screens/ToolsScreen/ToolsScreen"; +import type { GetPromptState } from "./components/screens/PromptsScreen/PromptsScreen"; +import type { ReadResourceState } from "./components/screens/ResourcesScreen/ResourcesScreen"; +import type { BridgeFactory } from "./components/elements/AppRenderer/AppRenderer"; +import type { LogEntryData } from "./components/elements/LogEntry/LogEntry"; +import { createWebEnvironment } from "./lib/environmentFactory"; + +// One hardcoded seed server so the Servers screen has something to connect +// to. Persistence + an "Add server" UI are explicitly out of scope for +// #1244 (the useServers v2-only hook is a separate effort); follow-up work +// will replace this with a real `useServers` store. +const SEED_SERVERS: ServerEntry[] = [ + { + id: "filesystem-server-default", + name: "Local Filesystem (npx)", + config: { + type: "stdio", + command: "npx", + args: ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"], + }, + connection: { status: "disconnected" }, + }, +]; + +// OAuth redirect URL provider — points at the dev backend's `/oauth/callback` +// handler. The InspectorClient only consults this when the active server +// requires OAuth; for stdio MCP servers it's never used. Created once and +// reused so `BrowserOAuthClientProvider` doesn't re-instantiate per render. +const redirectUrlProvider: RedirectUrlProvider = { + getRedirectUrl: () => `${window.location.origin}/oauth/callback`, +}; + +// MCP Apps sandbox — the iframe URL the parent should embed, plus the +// per-tool bridge factory. The dev backend serves `sandbox_proxy.html` on +// the sandbox controller port; the factory will eventually wrap the SDK +// client. For now neither is wired (the Apps tab uses these props but does +// not yet round-trip tool input through a live bridge — that's a follow-up +// alongside the AppRenderer integration). Keep these as stable references +// so InspectorView's effect deps don't churn. +const STUB_SANDBOX_PATH = "about:blank"; +const stubBridgeFactory: BridgeFactory = () => + ({ + sendToolInput: async () => {}, + sendToolResult: async () => {}, + sendToolCancelled: async () => {}, + teardownResource: async () => ({}), + close: async () => {}, + }) as unknown as AppBridge; + +// Derive `LogEntryData[]` from the MessageLog by filtering for the +// `notifications/message` notifications the server emits in response to +// `logging/setLevel`. The Logs screen renders these; we transform here +// rather than in the screen so the view stays prop-driven. +function messagesToLogEntries(messages: MessageEntry[]): LogEntryData[] { + const out: LogEntryData[] = []; + for (const m of messages) { + if (m.direction !== "notification") continue; + // MessageEntry.message is a JSONRPC union; notifications have `method` + // but not `id`. Narrow with an `in` check before the cast. + if (!("method" in m.message)) continue; + if (m.message.method !== "notifications/message") continue; + const params = (m.message as unknown as LoggingMessageNotification).params; + out.push({ + receivedAt: m.timestamp, + params, + }); + } + return out; +} function App() { + // Theme toggle plumbing (preserved from the pre-wire placeholder). const { setColorScheme } = useMantineColorScheme(); const computedColorScheme = useComputedColorScheme("light"); const isDark = computedColorScheme === "dark"; - const ThemeIcon = isDark ? MdLightMode : MdDarkMode; - - function toggleColorScheme() { + const onToggleTheme = useCallback(() => { setColorScheme(isDark ? "light" : "dark"); - } + }, [isDark, setColorScheme]); + + // Server list — held locally; one seed entry per #1244's "hardcoded + // sample server" scope decision. Future work: useServers hook. + const [servers] = useState(SEED_SERVERS); + + // The active connection target. `null` between sessions; set as soon as + // the user toggles a server card on. Drives state-manager lifetime. + const [activeServerId, setActiveServerId] = useState( + undefined, + ); + + // InspectorClient + per-primitive state managers. All recreated together + // whenever the user switches active servers, then destroyed when the + // next switch happens (or when the component unmounts). + const [inspectorClient, setInspectorClient] = + useState(null); + const [managedToolsState, setManagedToolsState] = + useState(null); + const [managedPromptsState, setManagedPromptsState] = + useState(null); + const [managedResourcesState, setManagedResourcesState] = + useState(null); + const [managedResourceTemplatesState, setManagedResourceTemplatesState] = + useState(null); + const [managedRequestorTasksState, setManagedRequestorTasksState] = + useState(null); + const [messageLogState, setMessageLogState] = + useState(null); + const [fetchRequestLogState, setFetchRequestLogState] = + useState(null); + const [stderrLogState, setStderrLogState] = useState( + null, + ); + + // Optimistic log level — `logging/setLevel` has no echo notification, so + // the parent keeps the current value locally. + const [currentLogLevel, setCurrentLogLevel] = useState("info"); + + // In-flight call panel state. Tracked here (rather than inside the + // respective screens) so the panels can reflect pending → ok/error + // transitions and so `onClear*` handlers can reset the panel without + // remounting the screen. + const [toolCallState, setToolCallState] = useState( + undefined, + ); + const [getPromptState, setGetPromptState] = useState< + GetPromptState | undefined + >(undefined); + const [readResourceState, setReadResourceState] = useState< + ReadResourceState | undefined + >(undefined); + + // Handshake telemetry. `connectStartRef` is set at the "connecting" edge + // and consumed at the "connected" edge — a ref (not state) so the + // intervening rerenders don't reset it. + const connectStartRef = useRef(undefined); + const [latencyMs, setLatencyMs] = useState(undefined); + const [errorMessage, setErrorMessage] = useState( + undefined, + ); + + // Hook layer. Each hook subscribes to its respective event source and + // re-renders the App on change. When `inspectorClient` / state managers + // are null, the hooks degrade to empty results. + const { + status: connectionStatus, + capabilities, + serverInfo, + instructions, + } = useInspectorClient(inspectorClient); + const { tools, refresh: refreshTools } = useManagedTools( + inspectorClient, + managedToolsState, + ); + const { prompts, refresh: refreshPrompts } = useManagedPrompts( + inspectorClient, + managedPromptsState, + ); + const { resources, refresh: refreshResources } = useManagedResources( + inspectorClient, + managedResourcesState, + ); + const { resourceTemplates } = useManagedResourceTemplates( + inspectorClient, + managedResourceTemplatesState, + ); + const { tasks, refresh: refreshTasks } = useManagedRequestorTasks( + inspectorClient, + managedRequestorTasksState, + ); + const { messages } = useMessageLog(messageLogState); + + // Capture observed handshake latency at the connecting → connected edge. + // Reset when the status leaves "connected" so the next connect starts + // clean (otherwise a stale latency would render on the next session). + useEffect(() => { + if ( + connectionStatus === "connected" && + connectStartRef.current !== undefined + ) { + setLatencyMs(Date.now() - connectStartRef.current); + connectStartRef.current = undefined; + } else if (connectionStatus !== "connected") { + setLatencyMs(undefined); + } + }, [connectionStatus]); + + // Build the InitializeResult the connected ViewHeader expects from the + // hook's split fields. `protocolVersion` is hard-coded for now — the + // useInspectorClient hook doesn't expose it; revisit if/when InspectorView + // grows a need for the real negotiated value. + const initializeResult = useMemo(() => { + if (connectionStatus !== "connected" || !serverInfo) return undefined; + return { + protocolVersion: "2025-06-18", + capabilities: capabilities ?? {}, + serverInfo, + ...(instructions ? { instructions } : {}), + }; + }, [connectionStatus, capabilities, serverInfo, instructions]); + + // Derive log entries from the message log. Filters for + // `notifications/message` (the response to `logging/setLevel`). + const logs = useMemo( + () => messagesToLogEntries(messages), + [messages], + ); + + // Wire up + tear down per active server. Called by `onToggleConnection` + // when the user switches targets. Returns the new client so the toggle + // can call `connect()` against it before React re-renders. + const setupClientForServer = useCallback( + (server: ServerEntry): InspectorClient => { + // Tear down the previous session's managers — each destroy() + // unsubscribes from the old client's events. Skipped on the first + // call (initial values are null). + managedToolsState?.destroy(); + managedPromptsState?.destroy(); + managedResourcesState?.destroy(); + managedResourceTemplatesState?.destroy(); + managedRequestorTasksState?.destroy(); + messageLogState?.destroy(); + fetchRequestLogState?.destroy(); + stderrLogState?.destroy(); + + const { environment } = createWebEnvironment( + undefined, + redirectUrlProvider, + ); + const client = new InspectorClient(server.config, { + environment, + // The Tasks tab needs the receiver-task pipeline; the + // requestor-task list comes from the client's task store. + receiverTasks: true, + // Sampling / elicitation are on by default; keep the parameterized + // options off until the UI grows the surface to render them. + elicit: { form: true, url: true }, + }); + + setInspectorClient(client); + setManagedToolsState(new ManagedToolsState(client)); + setManagedPromptsState(new ManagedPromptsState(client)); + setManagedResourcesState(new ManagedResourcesState(client)); + setManagedResourceTemplatesState( + new ManagedResourceTemplatesState(client), + ); + setManagedRequestorTasksState(new ManagedRequestorTasksState(client)); + setMessageLogState(new MessageLogState(client)); + setFetchRequestLogState(new FetchRequestLogState(client)); + setStderrLogState(new StderrLogState(client)); + + return client; + }, + [ + managedToolsState, + managedPromptsState, + managedResourcesState, + managedResourceTemplatesState, + managedRequestorTasksState, + messageLogState, + fetchRequestLogState, + stderrLogState, + ], + ); + + const onToggleConnection = useCallback( + async (id: string) => { + // Same server, already connected → disconnect. + if ( + id === activeServerId && + connectionStatus === "connected" && + inspectorClient + ) { + await inspectorClient.disconnect(); + return; + } + + const target = servers.find((s) => s.id === id); + if (!target) return; + + // Different server (or first connect): rebuild the client + managers. + let client = inspectorClient; + if (id !== activeServerId || client === null) { + client = setupClientForServer(target); + setActiveServerId(id); + } + + setErrorMessage(undefined); + connectStartRef.current = Date.now(); + try { + await client.connect(); + } catch (err) { + connectStartRef.current = undefined; + const message = err instanceof Error ? err.message : String(err); + setErrorMessage(message); + } + }, + [ + activeServerId, + connectionStatus, + inspectorClient, + servers, + setupClientForServer, + ], + ); + + const onDisconnect = useCallback(async () => { + if (!inspectorClient) return; + await inspectorClient.disconnect(); + }, [inspectorClient]); + + // --- Action handlers that route directly to the InspectorClient. --- + + const onCallTool = useCallback( + async (name: string, args: Record) => { + if (!inspectorClient) return; + const tool = tools.find((t: Tool) => t.name === name); + if (!tool) return; + setToolCallState({ status: "pending" }); + try { + const invocation = await inspectorClient.callTool( + tool, + args as Record, + ); + setToolCallState({ + status: invocation.success ? "ok" : "error", + result: invocation.result ?? undefined, + error: invocation.error, + }); + } catch (err) { + setToolCallState({ + status: "error", + error: err instanceof Error ? err.message : String(err), + }); + } + }, + [inspectorClient, tools], + ); + + const onClearToolResult = useCallback(() => { + setToolCallState(undefined); + }, []); + + const onGetPrompt = useCallback( + async (name: string, args: Record) => { + if (!inspectorClient) return; + setGetPromptState({ status: "pending" }); + try { + const invocation = await inspectorClient.getPrompt(name, args); + setGetPromptState({ status: "ok", result: invocation.result }); + } catch (err) { + setGetPromptState({ + status: "error", + error: err instanceof Error ? err.message : String(err), + }); + } + }, + [inspectorClient], + ); + + const onReadResource = useCallback( + async (uri: string) => { + if (!inspectorClient) return; + setReadResourceState({ status: "pending", uri }); + try { + const invocation = await inspectorClient.readResource(uri); + setReadResourceState({ + status: "ok", + uri, + result: invocation.result, + lastUpdated: invocation.timestamp, + }); + } catch (err) { + setReadResourceState({ + status: "error", + uri, + error: err instanceof Error ? err.message : String(err), + }); + } + }, + [inspectorClient], + ); + + const onSubscribeResource = useCallback( + (uri: string) => { + if (!inspectorClient) return; + void inspectorClient.subscribeToResource(uri); + }, + [inspectorClient], + ); + + const onUnsubscribeResource = useCallback( + (uri: string) => { + if (!inspectorClient) return; + void inspectorClient.unsubscribeFromResource(uri); + }, + [inspectorClient], + ); + + const onCancelTask = useCallback( + (taskId: string) => { + if (!inspectorClient) return; + void inspectorClient.cancelRequestorTask(taskId); + }, + [inspectorClient], + ); + + const onSetLogLevel = useCallback( + (level: LoggingLevel) => { + setCurrentLogLevel(level); + if (!inspectorClient) return; + void inspectorClient.setLoggingLevel(level); + }, + [inspectorClient], + ); + + const onRefreshTools = useCallback(() => { + void refreshTools(); + }, [refreshTools]); + const onRefreshPrompts = useCallback(() => { + void refreshPrompts(); + }, [refreshPrompts]); + const onRefreshResources = useCallback(() => { + void refreshResources(); + }, [refreshResources]); + const onRefreshTasks = useCallback(() => { + void refreshTasks(); + }, [refreshTasks]); + + const onClearLogs = useCallback(() => { + if (!messageLogState) return; + // Clear only the log notifications, not the entire request/response + // history (which the History screen renders from the same source). + messageLogState.clearMessages( + (m) => + m.direction === "notification" && + "method" in m.message && + m.message.method === "notifications/message", + ); + }, [messageLogState]); + + const onClearHistory = useCallback(() => { + messageLogState?.clearMessages(); + }, [messageLogState]); + + // Action stubs — these UI affordances exist but require additional + // wiring (server CRUD, history pinning, app sandbox round-trip, log + // export). Tracked separately; the noop keeps the prop interface + // satisfied without lying about behavior. + const todoNoop = useCallback(() => { + /* TODO: not wired yet */ + }, []); return ( - - - - MCP Inspector - - - - - - Web client for the Model Context Protocol Inspector - - - + { + void onToggleConnection(id); + }} + onDisconnect={() => { + void onDisconnect(); + }} + onServerAdd={todoNoop} + onServerImportConfig={todoNoop} + onServerImportJson={todoNoop} + onServerInfo={todoNoop} + onServerSettings={todoNoop} + onServerEdit={todoNoop} + onServerClone={todoNoop} + onServerRemove={todoNoop} + onCallTool={(name, args) => { + void onCallTool(name, args); + }} + onClearToolResult={onClearToolResult} + onRefreshTools={onRefreshTools} + onGetPrompt={(name, args) => { + void onGetPrompt(name, args); + }} + onRefreshPrompts={onRefreshPrompts} + onReadResource={(uri) => { + void onReadResource(uri); + }} + onSubscribeResource={onSubscribeResource} + onUnsubscribeResource={onUnsubscribeResource} + onRefreshResources={onRefreshResources} + onCancelTask={onCancelTask} + onClearCompletedTasks={todoNoop} + onRefreshTasks={onRefreshTasks} + onSetLogLevel={onSetLogLevel} + onClearLogs={onClearLogs} + onExportLogs={todoNoop} + onCopyAllLogs={todoNoop} + onClearHistory={onClearHistory} + onExportHistory={todoNoop} + onReplayHistory={todoNoop} + onTogglePinHistory={todoNoop} + onSelectApp={todoNoop} + onOpenApp={todoNoop} + onCloseApp={todoNoop} + onRefreshApps={onRefreshTools} + /> ); } export default App; + +// Surface so the FetchRequestLog + Stderr state managers are kept alive +// (their effects subscribe to client events for #1262 / future panels). +// Marking them void here keeps the no-unused-vars lint quiet without +// hiding the intent. +void FetchRequestLogState; +void StderrLogState; diff --git a/clients/web/src/components/views/InspectorView/InspectorView.stories.tsx b/clients/web/src/components/views/InspectorView/InspectorView.stories.tsx index 8b78abc1b..0e1d3c582 100644 --- a/clients/web/src/components/views/InspectorView/InspectorView.stories.tsx +++ b/clients/web/src/components/views/InspectorView/InspectorView.stories.tsx @@ -1,10 +1,12 @@ import type { + InitializeResult, Prompt, Resource, ResourceTemplate, Task, Tool, } from "@modelcontextprotocol/sdk/types.js"; +import type { AppBridge } from "@modelcontextprotocol/ext-apps/app-bridge"; import type { InspectorResourceSubscription, MessageEntry, @@ -17,6 +19,19 @@ import { mixedEntries as demoLogs } from "../../screens/LoggingScreen/LoggingScr import { longToolList as demoRegularTools } from "../../screens/ToolsScreen/ToolsScreen.fixtures"; import { SUN_ICON_SVG } from "../../../test/fixtures/storyIcons"; import type { TaskProgress } from "../../groups/TaskCard/TaskCard"; +import type { BridgeFactory } from "../../elements/AppRenderer/AppRenderer"; + +// Stories never drive a real MCP App bridge — render the iframe stage with +// a no-op factory so the AppsScreen mounts without trying to postMessage to +// a real sandbox. +const noopBridgeFactory: BridgeFactory = () => + ({ + sendToolInput: async () => {}, + sendToolResult: async () => {}, + sendToolCancelled: async () => {}, + teardownResource: async () => ({}), + close: async () => {}, + }) as unknown as AppBridge; // MCP App tools — `isAppTool` detects these via `_meta.ui.resourceUri`, // so they get filtered into the Apps tab while still appearing on Tools. @@ -234,12 +249,18 @@ const demoHistory: MessageEntry[] = [ }, ]; +const demoInitializeResult: InitializeResult = { + protocolVersion: "2025-06-18", + capabilities: {}, + serverInfo: { name: "Local Dev Server", version: "1.2.0" }, +}; + const meta: Meta = { title: "Views/InspectorView", component: InspectorView, parameters: { layout: "fullscreen" }, args: { - onToggleTheme: fn(), + // Data servers: demoServers, tools: demoTools, prompts: demoPrompts, @@ -250,6 +271,57 @@ const meta: Meta = { tasks: demoTasks, progressByTaskId: demoProgressByTaskId, history: demoHistory, + + // Connection state — stories default to "disconnected"; per-story + // overrides drive the connected / error narratives. + activeServer: undefined, + connectionStatus: "disconnected", + initializeResult: undefined, + latencyMs: undefined, + errorMessage: undefined, + + // Misc state + currentLogLevel: "info", + sandboxPath: "about:blank", + bridgeFactory: noopBridgeFactory, + + // Callbacks — all wired to storybook spies so play functions can assert + // on dispatch. Real wiring routes these to InspectorClient methods (the + // app shell at clients/web/src/App.tsx). + onToggleTheme: fn(), + onToggleConnection: fn(), + onDisconnect: fn(), + onServerAdd: fn(), + onServerImportConfig: fn(), + onServerImportJson: fn(), + onServerInfo: fn(), + onServerSettings: fn(), + onServerEdit: fn(), + onServerClone: fn(), + onServerRemove: fn(), + onCallTool: fn(), + onRefreshTools: fn(), + onGetPrompt: fn(), + onRefreshPrompts: fn(), + onReadResource: fn(), + onSubscribeResource: fn(), + onUnsubscribeResource: fn(), + onRefreshResources: fn(), + onCancelTask: fn(), + onClearCompletedTasks: fn(), + onRefreshTasks: fn(), + onSetLogLevel: fn(), + onClearLogs: fn(), + onExportLogs: fn(), + onCopyAllLogs: fn(), + onClearHistory: fn(), + onExportHistory: fn(), + onReplayHistory: fn(), + onTogglePinHistory: fn(), + onSelectApp: fn(), + onOpenApp: fn(), + onCloseApp: fn(), + onRefreshApps: fn(), }, }; @@ -263,3 +335,24 @@ export const NoServers: Story = { servers: [], }, }; + +// Renders the connected-state shell (full tab list, ViewHeader in connected +// mode). The other tabs still render their disconnected fixtures because +// the lists are passed through as static data — that's fine for visual +// regression / storybook play function coverage. +export const Connected: Story = { + args: { + activeServer: demoServers[0]!.id, + connectionStatus: "connected", + initializeResult: demoInitializeResult, + latencyMs: 142, + }, +}; + +export const ConnectionError: Story = { + args: { + activeServer: demoServers[0]!.id, + connectionStatus: "error", + errorMessage: "Handshake timeout", + }, +}; diff --git a/clients/web/src/components/views/InspectorView/InspectorView.test.tsx b/clients/web/src/components/views/InspectorView/InspectorView.test.tsx index 65fcdf30a..d0a9faf34 100644 --- a/clients/web/src/components/views/InspectorView/InspectorView.test.tsx +++ b/clients/web/src/components/views/InspectorView/InspectorView.test.tsx @@ -1,26 +1,94 @@ -import { describe, it, expect, vi, afterEach } from "vitest"; +import { describe, it, expect, vi } from "vitest"; import userEvent from "@testing-library/user-event"; -import type { Tool } from "@modelcontextprotocol/sdk/types.js"; +import type { + InitializeResult, + Tool, +} from "@modelcontextprotocol/sdk/types.js"; +import type { AppBridge } from "@modelcontextprotocol/ext-apps/app-bridge"; import type { ServerEntry } from "@inspector/core/mcp/types.js"; import { renderWithMantine, screen, waitFor, } from "../../../test/renderWithMantine"; -import { InspectorView } from "./InspectorView"; - -const baseProps = { - servers: [], - tools: [], - prompts: [], - resources: [], - resourceTemplates: [], - subscriptions: [], - logs: [], - tasks: [], - history: [], - onToggleTheme: vi.fn(), -}; +import { InspectorView, type InspectorViewProps } from "./InspectorView"; +import type { BridgeFactory } from "../../elements/AppRenderer/AppRenderer"; + +// Stub bridge factory — AppsScreen mounts the inner iframe and invokes +// `bridgeFactory(...)` on selection. The stub keeps that path quiet by +// returning a no-op AppBridge so tests don't try to postMessage to a +// real sandbox. +const noopBridgeFactory: BridgeFactory = () => + ({ + sendToolInput: async () => {}, + sendToolResult: async () => {}, + sendToolCancelled: async () => {}, + teardownResource: async () => ({}), + close: async () => {}, + }) as unknown as AppBridge; + +// Returns a fresh fixture each call so per-test spies can be asserted on +// in isolation. The view is purely prop-driven; every callback is +// dispatched up to the parent — these spies stand in for App.tsx's +// hook-routed handlers in the real wiring. +function makeProps( + overrides: Partial = {}, +): InspectorViewProps { + return { + servers: [], + activeServer: undefined, + connectionStatus: "disconnected", + initializeResult: undefined, + latencyMs: undefined, + errorMessage: undefined, + tools: [], + prompts: [], + resources: [], + resourceTemplates: [], + subscriptions: [], + logs: [], + tasks: [], + history: [], + currentLogLevel: "info", + sandboxPath: "about:blank", + bridgeFactory: noopBridgeFactory, + onToggleTheme: vi.fn(), + onToggleConnection: vi.fn(), + onDisconnect: vi.fn(), + onServerAdd: vi.fn(), + onServerImportConfig: vi.fn(), + onServerImportJson: vi.fn(), + onServerInfo: vi.fn(), + onServerSettings: vi.fn(), + onServerEdit: vi.fn(), + onServerClone: vi.fn(), + onServerRemove: vi.fn(), + onCallTool: vi.fn(), + onRefreshTools: vi.fn(), + onGetPrompt: vi.fn(), + onRefreshPrompts: vi.fn(), + onReadResource: vi.fn(), + onSubscribeResource: vi.fn(), + onUnsubscribeResource: vi.fn(), + onRefreshResources: vi.fn(), + onCancelTask: vi.fn(), + onClearCompletedTasks: vi.fn(), + onRefreshTasks: vi.fn(), + onSetLogLevel: vi.fn(), + onClearLogs: vi.fn(), + onExportLogs: vi.fn(), + onCopyAllLogs: vi.fn(), + onClearHistory: vi.fn(), + onExportHistory: vi.fn(), + onReplayHistory: vi.fn(), + onTogglePinHistory: vi.fn(), + onSelectApp: vi.fn(), + onOpenApp: vi.fn(), + onCloseApp: vi.fn(), + onRefreshApps: vi.fn(), + ...overrides, + }; +} const sampleServer: ServerEntry = { id: "alpha", @@ -29,20 +97,15 @@ const sampleServer: ServerEntry = { connection: { status: "disconnected" }, }; -afterEach(() => { - vi.restoreAllMocks(); -}); +const connectedInit: InitializeResult = { + protocolVersion: "2025-06-18", + capabilities: {}, + serverInfo: { name: "Alpha", version: "1.0.0" }, +}; -// The InspectorView's demo handshake stub uses Math.random() twice on each -// connect: first to pick a delay (50–500 ms), then to decide success vs. -// error against `STUB_SUCCESS_RATE = 0.85`. Mocking Math.random to a low -// value (0.1) forces both a short delay and a "success" outcome (0.1 < 0.85); -// 0.99 forces a short-ish delay and a "failure" (0.99 < 0.85 is false). Both -// constants are defined in InspectorView.tsx — keep these values in sync if -// the success rate ever changes. describe("InspectorView", () => { - it("renders the disconnected header by default", () => { - renderWithMantine(); + it("renders the empty-server-list placeholder when no servers are configured", () => { + renderWithMantine(); expect( screen.getByText("No servers configured. Add a server to get started."), ).toBeInTheDocument(); @@ -50,86 +113,105 @@ describe("InspectorView", () => { it("renders the server card from the input list", () => { renderWithMantine( - , + , ); expect(screen.getByText("Alpha")).toBeInTheDocument(); }); - it("transitions to connected on a successful handshake", async () => { - vi.spyOn(Math, "random").mockReturnValue(0.1); + it("dispatches onToggleConnection with the server id when the card toggle is clicked", async () => { + const onToggleConnection = vi.fn(); const user = userEvent.setup(); renderWithMantine( - , + , ); await user.click(screen.getByRole("switch")); - await waitFor( - () => { - expect( - screen.queryByText( - "No servers configured. Add a server to get started.", - ), - ).not.toBeInTheDocument(); - }, - { timeout: 2000 }, - ); + expect(onToggleConnection).toHaveBeenCalledWith("alpha"); }); - it("transitions to error on a failed handshake", async () => { - vi.spyOn(Math, "random").mockReturnValue(0.99); - const user = userEvent.setup(); + it("renders the connected header when connectionStatus + initializeResult are set", () => { renderWithMantine( - , - ); - await user.click(screen.getByRole("switch")); - await waitFor( - () => { - expect( - screen.getByText("Server returned invalid response"), - ).toBeInTheDocument(); - }, - { timeout: 2000 }, + , ); + // ServerCard renders the server name AND ViewHeader does (in connected + // mode it shows the serverInfo.name); checking ≥1 occurrence accepts + // both. The connected toggle being on confirms the connected mode. + expect(screen.getAllByText("Alpha").length).toBeGreaterThan(0); + expect(screen.getByRole("switch")).toBeChecked(); }); - it("disconnects when the connected server is toggled off", async () => { - vi.spyOn(Math, "random").mockReturnValue(0.1); - const user = userEvent.setup(); + it("renders the error banner when connectionStatus is 'error' with an errorMessage", () => { renderWithMantine( - , - ); - const toggle = screen.getByRole("switch"); - await user.click(toggle); - await waitFor( - () => { - expect(toggle).toBeChecked(); - expect(toggle).not.toBeDisabled(); - }, - { timeout: 2000 }, + , ); - await user.click(toggle); - expect(toggle).not.toBeChecked(); + expect(screen.getByText("Handshake timeout")).toBeInTheDocument(); }); - it("renders ViewHeader connected when handshake succeeds", async () => { - vi.spyOn(Math, "random").mockReturnValue(0.1); + it("snaps activeTab back to Servers when connection drops", async () => { + const { rerender } = renderWithMantine( + , + ); const user = userEvent.setup(); - renderWithMantine( - , + const tabSelect = await screen.findByDisplayValue("Servers"); + await user.click(tabSelect); + await user.click(await screen.findByText("Tools")); + await waitFor(() => + expect(screen.queryByDisplayValue("Tools")).toBeInTheDocument(), ); - const toggle = screen.getByRole("switch"); - await user.click(toggle); - await waitFor( - () => { - expect(toggle).toBeChecked(); - expect(toggle).not.toBeDisabled(); - }, - { timeout: 2000 }, + + rerender( + , + ); + + // Disconnected ViewHeader has no tab Select. The previously-selected + // "Tools" display value should be gone after the snap-back. + await waitFor(() => + expect(screen.queryByDisplayValue("Tools")).not.toBeInTheDocument(), ); - expect(screen.getAllByText("Alpha").length).toBeGreaterThan(0); + }); + + it("disables non-Servers tabs while disconnected", () => { + renderWithMantine(); + // The disconnected ViewHeader doesn't render the tab Select at all — + // only the connected branch does. Asserting on the empty-state copy is + // enough; a follow-up could deepen this once the disconnected header + // grows additional affordances. + expect( + screen.getByText("No servers configured. Add a server to get started."), + ).toBeInTheDocument(); }); it("filters tools to apps and auto-launches a no-fields app on the Apps tab", async () => { - vi.spyOn(Math, "random").mockReturnValue(0.1); const user = userEvent.setup(); const opsApp: Tool = { name: "ops", @@ -153,49 +235,72 @@ describe("InspectorView", () => { }; renderWithMantine( , ); - await user.click(screen.getByRole("switch")); - await waitFor( - () => { - expect(screen.getByRole("switch")).toBeChecked(); - }, - { timeout: 2000 }, - ); const tabSelect = await screen.findByDisplayValue("Servers"); await user.click(tabSelect); await user.click(await screen.findByText("Apps")); expect(screen.getByText("MCP Apps (1)")).toBeInTheDocument(); - // Auto-launch on selection mounts the AppRenderer, which invokes the - // stub bridge factory wired in InspectorView. await user.click(screen.getByText("Ops Dashboard")); expect(screen.getByTitle("Ops Dashboard")).toBeInTheDocument(); }); - it("toggles autoScroll on the Logs screen after connecting", async () => { - vi.spyOn(Math, "random").mockReturnValue(0.1); + it("dispatches onSetLogLevel through to the Logs screen", async () => { + const onSetLogLevel = vi.fn(); const user = userEvent.setup(); renderWithMantine( - , + , ); - const toggle = screen.getByRole("switch"); - await user.click(toggle); - await waitFor( - () => { - expect(toggle).toBeChecked(); - expect(toggle).not.toBeDisabled(); - }, - { timeout: 2000 }, + const tabSelect = await screen.findByDisplayValue("Servers"); + await user.click(tabSelect); + await user.click(await screen.findByText("Logs")); + // LogControls renders Mantine's Select with the current level — picking + // a value in the dropdown dispatches onSetLevel directly. (Mantine + // renders the visible search input and a hidden combobox input, both + // with the same displayValue; pick the first.) + const levelInputs = screen.getAllByDisplayValue("info"); + await user.click(levelInputs[0]!); + const warningOption = await screen.findByRole("option", { + name: "warning", + hidden: true, + }); + await user.click(warningOption); + expect(onSetLogLevel).toHaveBeenCalledWith("warning"); + }); + + it("toggles autoScroll locally on the Logs screen after connecting", async () => { + const user = userEvent.setup(); + renderWithMantine( + , ); - // Switch to Logs via the header Select. Mantine renders options into a - // hidden portal in happy-dom — find via getAllByText with hidden traversal. const tabSelect = await screen.findByDisplayValue("Servers"); await user.click(tabSelect); - const logsOption = await screen.findByText("Logs"); - await user.click(logsOption); + await user.click(await screen.findByText("Logs")); const autoScroll = await screen.findByRole("checkbox", { name: "Auto-scroll", }); diff --git a/clients/web/src/components/views/InspectorView/InspectorView.tsx b/clients/web/src/components/views/InspectorView/InspectorView.tsx index 734a15f8e..c8e940512 100644 --- a/clients/web/src/components/views/InspectorView/InspectorView.tsx +++ b/clients/web/src/components/views/InspectorView/InspectorView.tsx @@ -9,7 +9,6 @@ import type { Task, Tool, } from "@modelcontextprotocol/sdk/types.js"; -import type { AppBridge } from "@modelcontextprotocol/ext-apps/app-bridge"; import type { ConnectionStatus, InspectorResourceSubscription, @@ -19,14 +18,23 @@ import type { import { isAppTool } from "@inspector/core/mcp/apps.js"; import { ViewHeader } from "../../groups/ViewHeader/ViewHeader"; import { ServerListScreen } from "../../screens/ServerListScreen/ServerListScreen"; -import { ToolsScreen } from "../../screens/ToolsScreen/ToolsScreen"; +import { + ToolsScreen, + type ToolCallState, +} from "../../screens/ToolsScreen/ToolsScreen"; import { AppsScreen } from "../../screens/AppsScreen/AppsScreen"; import type { AppRendererHandle, BridgeFactory, } from "../../elements/AppRenderer/AppRenderer"; -import { PromptsScreen } from "../../screens/PromptsScreen/PromptsScreen"; -import { ResourcesScreen } from "../../screens/ResourcesScreen/ResourcesScreen"; +import { + PromptsScreen, + type GetPromptState, +} from "../../screens/PromptsScreen/PromptsScreen"; +import { + ResourcesScreen, + type ReadResourceState, +} from "../../screens/ResourcesScreen/ResourcesScreen"; import { LoggingScreen } from "../../screens/LoggingScreen/LoggingScreen"; import type { LogEntryData } from "../../elements/LogEntry/LogEntry"; import { TasksScreen } from "../../screens/TasksScreen/TasksScreen"; @@ -46,20 +54,6 @@ const ALL_TABS: string[] = [ "History", ]; -// Demo stub: Phase 3 wiring will replace this with a factory derived from -// the active MCP `Client`. Apps are detected from the tools list, so the -// "Apps" tab is exposed whenever the server advertises tools capability — -// the screen itself receives only the already-filtered subset. -const STUB_SANDBOX_PATH = "about:blank"; -const stubBridgeFactory: BridgeFactory = () => - ({ - sendToolInput: async () => {}, - sendToolResult: async () => {}, - sendToolCancelled: async () => {}, - teardownResource: async () => ({}), - close: async () => {}, - }) as unknown as AppBridge; - const SCREEN_ENTER_MS = 350; const SCREEN_EXIT_MS = 250; @@ -67,8 +61,7 @@ const SCREEN_EXIT_MS = 250; // children. `mih: "100%"` requires `AppShell.Main` to provide a definite // height for the stack itself to fill — the absolute children render // regardless, but nested ScrollArea screens need a non-collapsing parent -// for their scroll containment to work. Revisit if Phase 3 reveals -// scroll issues in real screens. +// for their scroll containment to work. const ScreenStageContainer = Stack.withProps({ pos: "relative", gap: 0, @@ -79,10 +72,7 @@ const ScreenStageContainer = Stack.withProps({ // Wraps each screen in a Mantine Transition. With Transition's default // (`keepMounted={false}`), the outgoing screen unmounts after its exit // animation — any local screen state (search filters, scroll position, -// expanded sections) is reset on tab switch. This matches the previous -// `{activeTab === "X" && }` behavior. If Phase 3 needs state -// to persist across tab switches, add `keepMounted` to the Transition -// below. +// expanded sections) is reset on tab switch. function ScreenStage({ active, children, @@ -109,26 +99,20 @@ function ScreenStage({ ); } -// Demo stub: every screen-action callback below resolves to noop. Phase 3 -// wiring will replace each with its real `useManaged*` / `useConnection` -// hook call. Anything still pointing here in Phase 3 is unfinished. -const noop = () => undefined; - -// Demo stub: simulated handshake delays and a sample of plausible failure -// reasons. Replace with real handshake telemetry once `useConnection` is wired. -const STUB_MIN_DELAY_MS = 50; -const STUB_MAX_DELAY_MS = 500; -const STUB_SUCCESS_RATE = 0.85; -const STUB_ERROR_MESSAGES = [ - "Connection refused", - "Handshake timeout", - "Protocol version mismatch", - "Authentication required", - "Server returned invalid response", -]; - export interface InspectorViewProps { + // Server list (static config; runtime connection state comes from the + // separate fields below and is merged into each card by this component). servers: ServerEntry[]; + + // Connection state — driven by the parent via `useInspectorClient`. + activeServer?: string; + connectionStatus: ConnectionStatus; + initializeResult?: InitializeResult; + latencyMs?: number; + errorMessage?: string; + + // Primitive lists, log streams, task state — all sourced from the + // per-primitive `useManaged*` / `useMessageLog` hooks in the parent. tools: Tool[]; prompts: Prompt[]; resources: Resource[]; @@ -138,11 +122,86 @@ export interface InspectorViewProps { tasks: Task[]; progressByTaskId?: Record; history: MessageEntry[]; + + // Per-screen "operation in flight" states (panel-level; optional because + // the underlying screens accept them as optional). + toolCallState?: ToolCallState; + getPromptState?: GetPromptState; + readResourceState?: ReadResourceState; + + // Logging level. The MCP `logging/setLevel` request has no echo + // notification, so the parent keeps the optimistic current value. + currentLogLevel: LoggingLevel; + + // MCP Apps sandbox. The parent's web environment provides both the + // sandbox iframe URL and the per-app bridge factory. + sandboxPath: string; + bridgeFactory: BridgeFactory; + + // History pinning. Optional because pin state isn't persisted yet (#1244 + // is single-PR; persistence is a separate concern). + pinnedHistoryIds?: Set; + + // Theme toggle (lives in the parent so the color scheme can also flow + // into other top-level UI later). onToggleTheme: () => void; + + // Connection lifecycle (dispatched to `useInspectorClient.connect/disconnect`). + onToggleConnection: (id: string) => void; + onDisconnect: () => void; + + // Server list actions. + onServerAdd: () => void; + onServerImportConfig: () => void; + onServerImportJson: () => void; + onServerInfo: (id: string) => void; + onServerSettings: (id: string) => void; + onServerEdit: (id: string) => void; + onServerClone: (id: string) => void; + onServerRemove: (id: string) => void; + + // Per-primitive actions (route to `inspectorClient` methods / hook refresh). + onCallTool: (name: string, args: Record) => void; + onCancelToolCall?: () => void; + onClearToolResult?: () => void; + onRefreshTools: () => void; + + onGetPrompt: (name: string, args: Record) => void; + onCopyPromptMessages?: () => void; + onRefreshPrompts: () => void; + + onReadResource: (uri: string) => void; + onSubscribeResource: (uri: string) => void; + onUnsubscribeResource: (uri: string) => void; + onRefreshResources: () => void; + + onCancelTask: (taskId: string) => void; + onClearCompletedTasks: () => void; + onRefreshTasks: () => void; + + onSetLogLevel: (level: LoggingLevel) => void; + onClearLogs: () => void; + onExportLogs: () => void; + onCopyAllLogs: () => void; + + onClearHistory: () => void; + onExportHistory: () => void; + onReplayHistory: (id: string) => void; + onTogglePinHistory: (id: string) => void; + + onSelectApp: (name: string) => void; + onOpenApp: (name: string, args: Record) => void; + onCloseApp: () => void; + onRefreshApps: () => void; } export function InspectorView({ servers: serversInput, + activeServer, + connectionStatus, + initializeResult, + latencyMs, + errorMessage, tools, prompts, resources, @@ -152,29 +211,76 @@ export function InspectorView({ tasks, progressByTaskId, history, + toolCallState, + getPromptState, + readResourceState, + currentLogLevel, + sandboxPath, + bridgeFactory, + pinnedHistoryIds, onToggleTheme, + onToggleConnection, + onDisconnect, + onServerAdd, + onServerImportConfig, + onServerImportJson, + onServerInfo, + onServerSettings, + onServerEdit, + onServerClone, + onServerRemove, + onCallTool, + onCancelToolCall, + onClearToolResult, + onRefreshTools, + onGetPrompt, + onCopyPromptMessages, + onRefreshPrompts, + onReadResource, + onSubscribeResource, + onUnsubscribeResource, + onRefreshResources, + onCancelTask, + onClearCompletedTasks, + onRefreshTasks, + onSetLogLevel, + onClearLogs, + onExportLogs, + onCopyAllLogs, + onClearHistory, + onExportHistory, + onReplayHistory, + onTogglePinHistory, + onSelectApp, + onOpenApp, + onCloseApp, + onRefreshApps, }: InspectorViewProps) { - const [activeServer, setActiveServer] = useState( - undefined, - ); - const [initializeResult, setInitializeResult] = useState< - InitializeResult | undefined - >(undefined); - const [connectionStatus, setConnectionStatus] = - useState("disconnected"); - const [latencyMs, setLatencyMs] = useState(undefined); - const [errorMessage, setErrorMessage] = useState( - undefined, - ); - const [activeTab, setActiveTab] = useState(SERVERS_TAB); - const [availableTabs, setAvailableTabs] = useState([SERVERS_TAB]); - const [logLevel, setLogLevel] = useState("info"); + // UI-only state. Connection state, primitive lists, and all action + // dispatching live in the parent; this component only owns navigation + // (which tab is visible) and a couple of view-local toggles. + const [selectedTab, setSelectedTab] = useState(SERVERS_TAB); const [autoScroll, setAutoScroll] = useState(true); - // Tracks the in-flight stub handshake timer so rapid Connect→Connect - // toggles can cancel the previous attempt before it resolves and - // overwrites the new server's state. - const handshakeTimer = useRef(undefined); const appRendererRef = useRef(null); + + // Only show the non-Servers tabs when actually connected. Capability-aware + // tab gating (hide Tools when the server doesn't advertise `tools`, etc.) + // can layer in later once the parent passes capabilities through. + const availableTabs = useMemo( + () => (connectionStatus === "connected" ? ALL_TABS : [SERVERS_TAB]), + [connectionStatus], + ); + + // Clamp the rendered tab to whatever's currently available. If the user + // had "Tools" selected and the connection drops, `availableTabs` becomes + // `[Servers]` and the view renders Servers without us having to imperatively + // reset the state (and trip the `set-state-in-effect` lint). When the + // connection comes back, the previous selection pops in again because + // `selectedTab` is preserved. + const activeTab = availableTabs.includes(selectedTab) + ? selectedTab + : SERVERS_TAB; + const appTools = useMemo(() => { return tools.filter((tool) => { try { @@ -188,10 +294,9 @@ export function InspectorView({ }); }, [tools]); - // The view is the single source of truth for connection state. Any - // `connection` field on incoming `serversInput` items is intentionally - // ignored — cards mirror the global `connectionStatus` for the active - // server and render as `disconnected` otherwise. + // Merge the parent's `serversInput` (static config) with the runtime + // connection state owned by the parent — only the active server reflects + // the live status; the rest render as `disconnected`. const servers = useMemo( () => serversInput.map((s) => { @@ -212,75 +317,6 @@ export function InspectorView({ [serversInput, activeServer, connectionStatus, errorMessage], ); - function disconnect() { - if (handshakeTimer.current !== undefined) { - window.clearTimeout(handshakeTimer.current); - handshakeTimer.current = undefined; - } - setActiveServer(undefined); - setConnectionStatus("disconnected"); - setInitializeResult(undefined); - setLatencyMs(undefined); - setErrorMessage(undefined); - setAvailableTabs([SERVERS_TAB]); - setActiveTab(SERVERS_TAB); - } - - // Demo stub: simulates the full connect → connecting → connected/error - // transition with a randomized handshake delay. Real wiring will dispatch - // `useConnection.connect(id)` and let the hook drive these state changes. - // The `protocolVersion`, `capabilities`, and `serverInfo` populated below - // are placeholders until a real `InitializeResult` arrives from the server. - function handleToggleConnection(id: string) { - if (id === activeServer && connectionStatus === "connected") { - disconnect(); - return; - } - const target = serversInput.find((s) => s.id === id); - if (!target) return; - - // Cancel any in-flight handshake so a rapid switch to a different - // server doesn't get overwritten by the previous server's resolver. - if (handshakeTimer.current !== undefined) { - window.clearTimeout(handshakeTimer.current); - } - - // Capture `start` at the "connecting" edge and compute observed - // latency at the "connected" edge. Both edges are owned by this - // handler so the timing is deterministic — no useEffect chain needed - // (which would otherwise trip `react-hooks/set-state-in-effect`). - const start = Date.now(); - setActiveServer(id); - setLatencyMs(undefined); - setErrorMessage(undefined); - setConnectionStatus("connecting"); - - const delay = - STUB_MIN_DELAY_MS + - Math.floor(Math.random() * (STUB_MAX_DELAY_MS - STUB_MIN_DELAY_MS + 1)); - - handshakeTimer.current = window.setTimeout(() => { - handshakeTimer.current = undefined; - if (Math.random() < STUB_SUCCESS_RATE) { - setInitializeResult({ - protocolVersion: "2025-06-18", - capabilities: {}, - serverInfo: target.info ?? { name: target.name, version: "0.0.0" }, - }); - setAvailableTabs(ALL_TABS); - setLatencyMs(Date.now() - start); - setConnectionStatus("connected"); - } else { - setErrorMessage( - STUB_ERROR_MESSAGES[ - Math.floor(Math.random() * STUB_ERROR_MESSAGES.length) - ], - ); - setConnectionStatus("error"); - } - }, delay); - } - return ( @@ -292,8 +328,8 @@ export function InspectorView({ latencyMs={latencyMs} activeTab={activeTab} availableTabs={availableTabs} - onTabChange={setActiveTab} - onDisconnect={disconnect} + onTabChange={setSelectedTab} + onDisconnect={onDisconnect} onToggleTheme={onToggleTheme} /> ) : ( @@ -306,44 +342,49 @@ export function InspectorView({ @@ -351,42 +392,43 @@ export function InspectorView({ resources={resources} templates={resourceTemplates} subscriptions={subscriptions} + readState={readResourceState} listChanged={false} - onRefreshList={noop} - onReadResource={noop} - onSubscribeResource={noop} - onUnsubscribeResource={noop} + onRefreshList={onRefreshResources} + onReadResource={onReadResource} + onSubscribeResource={onSubscribeResource} + onUnsubscribeResource={onUnsubscribeResource} /> setAutoScroll((prev) => !prev)} - onCopyAll={noop} + onCopyAll={onCopyAllLogs} /> diff --git a/clients/web/src/lib/environmentFactory.ts b/clients/web/src/lib/environmentFactory.ts new file mode 100644 index 000000000..350a16f00 --- /dev/null +++ b/clients/web/src/lib/environmentFactory.ts @@ -0,0 +1,71 @@ +import type { InspectorClientEnvironment } from "@inspector/core/mcp/index.js"; +import { + createRemoteTransport, + createRemoteFetch, + createRemoteLogger, +} from "@inspector/core/mcp/remote/index.js"; +import { + BrowserOAuthStorage, + BrowserNavigation, +} from "@inspector/core/auth/browser/index.js"; +import type { RedirectUrlProvider } from "@inspector/core/auth/index.js"; + +export interface WebEnvironmentResult { + environment: InspectorClientEnvironment; + logger: InspectorClientEnvironment["logger"]; +} + +/** + * Assemble an `InspectorClientEnvironment` for the browser: + * - transport / fetch / logger all routed through the in-process Hono + * backend at `window.location.origin` (the `clients/web/server` + * dev-backend wires this in `/api/*`). + * - OAuth storage + navigation use the `BrowserOAuthStorage` (sessionStorage) + * and `BrowserNavigation` (full-page redirect) adapters. + * + * Returns both the assembled environment and the logger so callers can share + * the same pino instance for any direct logging they need to do, instead of + * reaching back through the client. + * + * `authToken` is read from a higher level (currently unused in this app since + * v2 has no auth-token UI yet, but kept in the signature so the wiring is + * ready when token plumbing lands). + */ +export function createWebEnvironment( + authToken: string | undefined, + redirectUrlProvider: RedirectUrlProvider, +): WebEnvironmentResult { + const baseUrl = `${window.location.protocol}//${window.location.host}`; + + // Passing `window.fetch` directly raises "Illegal invocation" because the + // function loses its `this` binding when extracted off `window`. Wrap so + // the call site preserves the global receiver. + const fetchFn: typeof fetch = (...args) => globalThis.fetch(...args); + + const logger = createRemoteLogger({ + baseUrl, + authToken, + fetchFn, + }); + + const environment: InspectorClientEnvironment = { + transport: createRemoteTransport({ + baseUrl, + authToken, + fetchFn, + }), + fetch: createRemoteFetch({ + baseUrl, + authToken, + fetchFn, + }), + logger, + oauth: { + storage: new BrowserOAuthStorage(), + navigation: new BrowserNavigation(), + redirectUrlProvider, + }, + }; + + return { environment, logger }; +} diff --git a/clients/web/vite.config.ts b/clients/web/vite.config.ts index dfcd4f9a3..de63d9323 100644 --- a/clients/web/vite.config.ts +++ b/clients/web/vite.config.ts @@ -119,14 +119,39 @@ export default defineConfig({ }, resolve: { // NOTE: the unit vitest project (below) overrides this — see comment there. - alias: sharedAliases, + // + // Once App.tsx started consuming the full hook + state-manager surface + // (#1244), the browser dep graph reached bare-module subpaths in core/ + // that Rolldown couldn't resolve against `core/`'s parent (it has no + // node_modules of its own). Promote the same bare-module aliases the + // vitest projects use so `vite dev` / `vite build` can resolve them + // from `clients/web/node_modules`. + alias: [ + ...Object.entries(sharedAliases).map(([find, replacement]) => ({ + find, + replacement, + })), + ...nodeModulesAliases, + ], // Source files in core/ import bare modules (react, @testing-library/react, // etc.) that only exist in clients/web/node_modules. Dedupe ensures Vite // resolves them from this package rather than walking up from core/'s // location (which has no node_modules of its own yet). dedupe: sharedDedupe, }, + // Pin the Vite dev server to the same port (and host) the Hono plugin + // configures from env, so `allowedOrigins` actually matches the browser + // origin. Without this, `vite dev` falls back to Vite's default 5173 + // while the dev backend's `buildWebServerConfigFromEnv()` defaults to + // CLIENT_PORT=6274 — origin check rejects every `/api/*` request from + // the browser. CLIENT_PORT / HOST overrides flow through here too. + // `strictPort: true` so a port collision fails loudly instead of + // silently picking a different port (which would leave `allowedOrigins` + // pointing at the wrong host and break browser fetches). server: { + port: parseInt(process.env.CLIENT_PORT ?? '6274', 10), + host: process.env.HOST ?? 'localhost', + strictPort: true, fs: { allow: [path.resolve(dirname, '../..')], }, From f7744b6d407085d6642d36f2f019310980f11047 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Mon, 18 May 2026 21:04:24 -0400 Subject: [PATCH 2/5] fix(web): wire dev-backend auth token into createWebEnvironment + show full stdio command in ServerCard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- clients/web/src/App.tsx | 31 ++++++++++++++++++- .../groups/ServerCard/ServerCard.tsx | 8 ++++- 2 files changed, 37 insertions(+), 2 deletions(-) diff --git a/clients/web/src/App.tsx b/clients/web/src/App.tsx index 88b33ec84..41d71145b 100644 --- a/clients/web/src/App.tsx +++ b/clients/web/src/App.tsx @@ -9,6 +9,7 @@ import type { import type { AppBridge } from "@modelcontextprotocol/ext-apps/app-bridge"; import { InspectorClient } from "@inspector/core/mcp/index.js"; import type { MessageEntry, ServerEntry } from "@inspector/core/mcp/types.js"; +import { API_SERVER_ENV_VARS } from "@inspector/core/mcp/remote/constants.js"; import { ManagedToolsState } from "@inspector/core/mcp/state/managedToolsState.js"; import { ManagedPromptsState } from "@inspector/core/mcp/state/managedPromptsState.js"; import { ManagedResourcesState } from "@inspector/core/mcp/state/managedResourcesState.js"; @@ -58,6 +59,34 @@ const redirectUrlProvider: RedirectUrlProvider = { getRedirectUrl: () => `${window.location.origin}/oauth/callback`, }; +// Pull the dev-backend's auth token off the URL the launcher banner prints. +// `npm run dev` opens `http://localhost:6274?MCP_INSPECTOR_API_TOKEN=…`; +// every browser request to /api/* needs the same token in the +// `x-mcp-remote-auth: Bearer …` header or the Hono backend returns 401. +// Persist to sessionStorage so SPA navigations / OAuth round-trips don't +// drop the token from the URL bar. +function getAuthToken(): string | undefined { + if (typeof window === "undefined") return undefined; + const STORAGE_KEY = API_SERVER_ENV_VARS.AUTH_TOKEN; + const params = new URLSearchParams(window.location.search); + const fromUrl = params.get(API_SERVER_ENV_VARS.AUTH_TOKEN); + if (fromUrl) { + try { + window.sessionStorage.setItem(STORAGE_KEY, fromUrl); + } catch { + // Best-effort persistence — sessionStorage may be unavailable + // (privacy mode, iframe sandboxing, etc.); the URL value still + // works for the current page load. + } + return fromUrl; + } + try { + return window.sessionStorage.getItem(STORAGE_KEY) ?? undefined; + } catch { + return undefined; + } +} + // MCP Apps sandbox — the iframe URL the parent should embed, plus the // per-tool bridge factory. The dev backend serves `sandbox_proxy.html` on // the sandbox controller port; the factory will eventually wrap the SDK @@ -250,7 +279,7 @@ function App() { stderrLogState?.destroy(); const { environment } = createWebEnvironment( - undefined, + getAuthToken(), redirectUrlProvider, ); const client = new InspectorClient(server.config, { diff --git a/clients/web/src/components/groups/ServerCard/ServerCard.tsx b/clients/web/src/components/groups/ServerCard/ServerCard.tsx index a4e42ef0b..2ec339089 100644 --- a/clients/web/src/components/groups/ServerCard/ServerCard.tsx +++ b/clients/web/src/components/groups/ServerCard/ServerCard.tsx @@ -75,7 +75,13 @@ function getCommandOrUrl(config: MCPServerConfig): string { if (config.type === "sse" || config.type === "streamable-http") { return config.url; } - return config.command; + // 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({ From 4a180a6966f088c43219becb6cd21856d25c9571 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Mon, 18 May 2026 21:07:47 -0400 Subject: [PATCH 3/5] 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) --- clients/web/src/main.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/clients/web/src/main.tsx b/clients/web/src/main.tsx index 63c99077d..a4efb39d2 100644 --- a/clients/web/src/main.tsx +++ b/clients/web/src/main.tsx @@ -12,7 +12,9 @@ const resolver: CSSVariablesResolver = () => ({ variables: {}, light: {}, dark: { - "--mantine-color-body": "var(--mantine-color-blue-9)", + // Matches `.storybook/preview.tsx` so the dev app and the story preview + // share the same dark-mode page background. + "--mantine-color-body": "var(--mantine-color-dark-9)", }, }); From 9b373a663c93d4f13b412274d85887f4423ccb98 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Mon, 18 May 2026 21:15:20 -0400 Subject: [PATCH 4/5] feat(web): seed the Servers screen with the "everything" reference server MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- clients/web/src/App.tsx | 21 +++++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/clients/web/src/App.tsx b/clients/web/src/App.tsx index 41d71145b..6e36a02bf 100644 --- a/clients/web/src/App.tsx +++ b/clients/web/src/App.tsx @@ -34,10 +34,13 @@ import type { BridgeFactory } from "./components/elements/AppRenderer/AppRendere import type { LogEntryData } from "./components/elements/LogEntry/LogEntry"; import { createWebEnvironment } from "./lib/environmentFactory"; -// One hardcoded seed server so the Servers screen has something to connect -// to. Persistence + an "Add server" UI are explicitly out of scope for -// #1244 (the useServers v2-only hook is a separate effort); follow-up work -// will replace this with a real `useServers` store. +// Hardcoded seed servers so the Servers screen has something to connect to. +// Persistence + an "Add server" UI are explicitly out of scope for #1244 (the +// useServers v2-only hook is a separate effort); follow-up work will replace +// this with a real `useServers` store. The two seeds here cover the common +// shapes a developer reaches for first: a real filesystem (scoped to /tmp so +// nothing destructive is possible by default) and the canonical "everything" +// reference server (tools / prompts / resources / sampling / completion). const SEED_SERVERS: ServerEntry[] = [ { id: "filesystem-server-default", @@ -49,6 +52,16 @@ const SEED_SERVERS: ServerEntry[] = [ }, connection: { status: "disconnected" }, }, + { + id: "everything-server-default", + name: "Everything (npx)", + config: { + type: "stdio", + command: "npx", + args: ["-y", "@modelcontextprotocol/server-everything"], + }, + connection: { status: "disconnected" }, + }, ]; // OAuth redirect URL provider — points at the dev backend's `/oauth/callback` From 67f28aa6516bd39503267ead93e1ab9bb09d027e Mon Sep 17 00:00:00 2001 From: cliffhall Date: Tue, 19 May 2026 12:13:18 -0400 Subject: [PATCH 5/5] fix(web): address #1244 PR review feedback (JsonValue cast, client lifecycle, dead code) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - **#1 — Misleading `Record` cast**: Imported `JsonValue` from `@inspector/core/mcp/index.js` and changed `args as Record` to `args as Record` in `onCallTool`. The new cast honestly narrows from the screen-level `Record` 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) --- clients/web/src/App.tsx | 42 +++++++++++++++++++++++++++++++---------- 1 file changed, 32 insertions(+), 10 deletions(-) diff --git a/clients/web/src/App.tsx b/clients/web/src/App.tsx index 6e36a02bf..0187f7582 100644 --- a/clients/web/src/App.tsx +++ b/clients/web/src/App.tsx @@ -8,6 +8,7 @@ import type { } from "@modelcontextprotocol/sdk/types.js"; import type { AppBridge } from "@modelcontextprotocol/ext-apps/app-bridge"; import { InspectorClient } from "@inspector/core/mcp/index.js"; +import type { JsonValue } from "@inspector/core/mcp/index.js"; import type { MessageEntry, ServerEntry } from "@inspector/core/mcp/types.js"; import { API_SERVER_ENV_VARS } from "@inspector/core/mcp/remote/constants.js"; import { ManagedToolsState } from "@inspector/core/mcp/state/managedToolsState.js"; @@ -253,10 +254,26 @@ function App() { } }, [connectionStatus]); + // Disconnect the previous InspectorClient when it's replaced (server + // switch) or when App unmounts (HMR, tests). Without this the prior + // session's transport — a spawned stdio subprocess, an SSE stream, or + // an HTTP session — stays open until GC eventually lets go. The + // state-manager destroys in `setupClientForServer` only handle the + // listener side; this effect handles the transport side. `disconnect()` + // is the canonical lifecycle hook (InspectorClient has no `destroy()`); + // it closes the transport, clears subscriptions, cancels receiver TTLs. + useEffect(() => { + return () => { + if (inspectorClient) { + void inspectorClient.disconnect(); + } + }; + }, [inspectorClient]); + // Build the InitializeResult the connected ViewHeader expects from the // hook's split fields. `protocolVersion` is hard-coded for now — the - // useInspectorClient hook doesn't expose it; revisit if/when InspectorView - // grows a need for the real negotiated value. + // useInspectorClient hook doesn't expose it. TODO(#1324): consume the + // negotiated value once the hook surfaces it. const initializeResult = useMemo(() => { if (connectionStatus !== "connected" || !serverInfo) return undefined; return { @@ -358,6 +375,10 @@ function App() { try { await client.connect(); } catch (err) { + // Handshake-only. A mid-session transport failure transitions the + // client status to "error" without rejecting any pending promise, + // and `errorMessage` stays stale. TODO(#1323): consume an `error` + // event from `InspectorClientEventMap` once it exists. connectStartRef.current = undefined; const message = err instanceof Error ? err.message : String(err); setErrorMessage(message); @@ -386,9 +407,14 @@ function App() { if (!tool) return; setToolCallState({ status: "pending" }); try { + // ToolsScreen types the args as `Record` (it accepts + // anything the user types into the schema form). `callTool` requires + // `Record` — narrow at the boundary instead of + // claiming the object is empty (which the previous `as Record` cast did, misleadingly). const invocation = await inspectorClient.callTool( tool, - args as Record, + args as Record, ); setToolCallState({ status: invocation.success ? "ok" : "error", @@ -531,6 +557,9 @@ function App() { prompts={prompts} resources={resources} resourceTemplates={resourceTemplates} + // TODO(#1325): drop the empty fallback once `useResourceSubscriptions` + // surfaces the live subscription list — subscribe/unsubscribe buttons + // currently fire but the screen never reflects the result. subscriptions={[]} logs={logs} tasks={tasks} @@ -591,10 +620,3 @@ function App() { } export default App; - -// Surface so the FetchRequestLog + Stderr state managers are kept alive -// (their effects subscribe to client events for #1262 / future panels). -// Marking them void here keeps the no-unused-vars lint quiet without -// hiding the intent. -void FetchRequestLogState; -void StderrLogState;