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>
This commit is contained in:
parent
37a4d99575
commit
4d74b94bed
8 changed files with 1241 additions and 335 deletions
|
|
@ -1,59 +1,621 @@
|
||||||
import {
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
ActionIcon,
|
import { useComputedColorScheme, useMantineColorScheme } from "@mantine/core";
|
||||||
Container,
|
import type {
|
||||||
Group,
|
InitializeResult,
|
||||||
Stack,
|
LoggingLevel,
|
||||||
Text,
|
LoggingMessageNotification,
|
||||||
Title,
|
Tool,
|
||||||
useComputedColorScheme,
|
} from "@modelcontextprotocol/sdk/types.js";
|
||||||
useMantineColorScheme,
|
import type { AppBridge } from "@modelcontextprotocol/ext-apps/app-bridge";
|
||||||
} from "@mantine/core";
|
import { InspectorClient } from "@inspector/core/mcp/index.js";
|
||||||
import { MdDarkMode, MdLightMode } from "react-icons/md";
|
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";
|
||||||
|
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";
|
||||||
|
|
||||||
const PageContainer = Container.withProps({
|
// Hardcoded seed servers so the Servers screen has something to connect to.
|
||||||
size: "sm",
|
// Persistence + an "Add server" UI are explicitly out of scope for #1244 (the
|
||||||
py: "xl",
|
// 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",
|
||||||
|
name: "Local Filesystem (npx)",
|
||||||
|
config: {
|
||||||
|
type: "stdio",
|
||||||
|
command: "npx",
|
||||||
|
args: ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"],
|
||||||
|
},
|
||||||
|
connection: { status: "disconnected" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "everything-server-default",
|
||||||
|
name: "Everything (npx)",
|
||||||
|
config: {
|
||||||
|
type: "stdio",
|
||||||
|
command: "npx",
|
||||||
|
args: ["-y", "@modelcontextprotocol/server-everything"],
|
||||||
|
},
|
||||||
|
connection: { status: "disconnected" },
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
const PageStack = Stack.withProps({
|
// OAuth redirect URL provider — points at the dev backend's `/oauth/callback`
|
||||||
align: "center",
|
// handler. The InspectorClient only consults this when the active server
|
||||||
gap: "md",
|
// 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`,
|
||||||
|
};
|
||||||
|
|
||||||
const ThemeToggle = ActionIcon.withProps({
|
// Pull the dev-backend's auth token off the URL the launcher banner prints.
|
||||||
variant: "default",
|
// `npm run dev` opens `http://localhost:6274?MCP_INSPECTOR_API_TOKEN=…`;
|
||||||
size: "lg",
|
// every browser request to /api/* needs the same token in the
|
||||||
"aria-label": "Toggle color scheme",
|
// `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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const SubtitleText = Text.withProps({
|
// MCP Apps sandbox — the iframe URL the parent should embed, plus the
|
||||||
c: "var(--inspector-text-secondary)",
|
// 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() {
|
function App() {
|
||||||
|
// Theme toggle plumbing (preserved from the pre-wire placeholder).
|
||||||
const { setColorScheme } = useMantineColorScheme();
|
const { setColorScheme } = useMantineColorScheme();
|
||||||
const computedColorScheme = useComputedColorScheme("light");
|
const computedColorScheme = useComputedColorScheme("light");
|
||||||
const isDark = computedColorScheme === "dark";
|
const isDark = computedColorScheme === "dark";
|
||||||
const ThemeIcon = isDark ? MdLightMode : MdDarkMode;
|
const onToggleTheme = useCallback(() => {
|
||||||
|
|
||||||
function toggleColorScheme() {
|
|
||||||
setColorScheme(isDark ? "light" : "dark");
|
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<ServerEntry[]>(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<string | undefined>(
|
||||||
|
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<InspectorClient | null>(null);
|
||||||
|
const [managedToolsState, setManagedToolsState] =
|
||||||
|
useState<ManagedToolsState | null>(null);
|
||||||
|
const [managedPromptsState, setManagedPromptsState] =
|
||||||
|
useState<ManagedPromptsState | null>(null);
|
||||||
|
const [managedResourcesState, setManagedResourcesState] =
|
||||||
|
useState<ManagedResourcesState | null>(null);
|
||||||
|
const [managedResourceTemplatesState, setManagedResourceTemplatesState] =
|
||||||
|
useState<ManagedResourceTemplatesState | null>(null);
|
||||||
|
const [managedRequestorTasksState, setManagedRequestorTasksState] =
|
||||||
|
useState<ManagedRequestorTasksState | null>(null);
|
||||||
|
const [messageLogState, setMessageLogState] =
|
||||||
|
useState<MessageLogState | null>(null);
|
||||||
|
const [fetchRequestLogState, setFetchRequestLogState] =
|
||||||
|
useState<FetchRequestLogState | null>(null);
|
||||||
|
const [stderrLogState, setStderrLogState] = useState<StderrLogState | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Optimistic log level — `logging/setLevel` has no echo notification, so
|
||||||
|
// the parent keeps the current value locally.
|
||||||
|
const [currentLogLevel, setCurrentLogLevel] = useState<LoggingLevel>("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<ToolCallState | undefined>(
|
||||||
|
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<number | undefined>(undefined);
|
||||||
|
const [latencyMs, setLatencyMs] = useState<number | undefined>(undefined);
|
||||||
|
const [errorMessage, setErrorMessage] = useState<string | undefined>(
|
||||||
|
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]);
|
||||||
|
|
||||||
|
// 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. TODO(#1324): consume the
|
||||||
|
// negotiated value once the hook surfaces it.
|
||||||
|
const initializeResult = useMemo<InitializeResult | undefined>(() => {
|
||||||
|
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<LogEntryData[]>(
|
||||||
|
() => 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(
|
||||||
|
getAuthToken(),
|
||||||
|
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) {
|
||||||
|
// 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);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[
|
||||||
|
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<string, unknown>) => {
|
||||||
|
if (!inspectorClient) return;
|
||||||
|
const tool = tools.find((t: Tool) => t.name === name);
|
||||||
|
if (!tool) return;
|
||||||
|
setToolCallState({ status: "pending" });
|
||||||
|
try {
|
||||||
|
// ToolsScreen types the args as `Record<string, unknown>` (it accepts
|
||||||
|
// anything the user types into the schema form). `callTool` requires
|
||||||
|
// `Record<string, JsonValue>` — narrow at the boundary instead of
|
||||||
|
// claiming the object is empty (which the previous `as Record<string,
|
||||||
|
// never>` cast did, misleadingly).
|
||||||
|
const invocation = await inspectorClient.callTool(
|
||||||
|
tool,
|
||||||
|
args as Record<string, JsonValue>,
|
||||||
|
);
|
||||||
|
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<string, string>) => {
|
||||||
|
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 (
|
return (
|
||||||
<PageContainer>
|
<InspectorView
|
||||||
<PageStack>
|
servers={servers}
|
||||||
<Group>
|
activeServer={activeServerId}
|
||||||
<Title order={1}>MCP Inspector</Title>
|
connectionStatus={connectionStatus}
|
||||||
<ThemeToggle onClick={toggleColorScheme}>
|
initializeResult={initializeResult}
|
||||||
<ThemeIcon size={20} />
|
latencyMs={latencyMs}
|
||||||
</ThemeToggle>
|
errorMessage={errorMessage}
|
||||||
</Group>
|
tools={tools}
|
||||||
<SubtitleText>
|
prompts={prompts}
|
||||||
Web client for the Model Context Protocol Inspector
|
resources={resources}
|
||||||
</SubtitleText>
|
resourceTemplates={resourceTemplates}
|
||||||
</PageStack>
|
// TODO(#1325): drop the empty fallback once `useResourceSubscriptions`
|
||||||
</PageContainer>
|
// surfaces the live subscription list — subscribe/unsubscribe buttons
|
||||||
|
// currently fire but the screen never reflects the result.
|
||||||
|
subscriptions={[]}
|
||||||
|
logs={logs}
|
||||||
|
tasks={tasks}
|
||||||
|
history={messages}
|
||||||
|
toolCallState={toolCallState}
|
||||||
|
getPromptState={getPromptState}
|
||||||
|
readResourceState={readResourceState}
|
||||||
|
currentLogLevel={currentLogLevel}
|
||||||
|
sandboxPath={STUB_SANDBOX_PATH}
|
||||||
|
bridgeFactory={stubBridgeFactory}
|
||||||
|
onToggleTheme={onToggleTheme}
|
||||||
|
onToggleConnection={(id) => {
|
||||||
|
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}
|
||||||
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -75,7 +75,13 @@ function getCommandOrUrl(config: MCPServerConfig): string {
|
||||||
if (config.type === "sse" || config.type === "streamable-http") {
|
if (config.type === "sse" || config.type === "streamable-http") {
|
||||||
return config.url;
|
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({
|
export function ServerCard({
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,12 @@
|
||||||
import type {
|
import type {
|
||||||
|
InitializeResult,
|
||||||
Prompt,
|
Prompt,
|
||||||
Resource,
|
Resource,
|
||||||
ResourceTemplate,
|
ResourceTemplate,
|
||||||
Task,
|
Task,
|
||||||
Tool,
|
Tool,
|
||||||
} from "@modelcontextprotocol/sdk/types.js";
|
} from "@modelcontextprotocol/sdk/types.js";
|
||||||
|
import type { AppBridge } from "@modelcontextprotocol/ext-apps/app-bridge";
|
||||||
import type {
|
import type {
|
||||||
InspectorResourceSubscription,
|
InspectorResourceSubscription,
|
||||||
MessageEntry,
|
MessageEntry,
|
||||||
|
|
@ -17,6 +19,19 @@ import { mixedEntries as demoLogs } from "../../screens/LoggingScreen/LoggingScr
|
||||||
import { longToolList as demoRegularTools } from "../../screens/ToolsScreen/ToolsScreen.fixtures";
|
import { longToolList as demoRegularTools } from "../../screens/ToolsScreen/ToolsScreen.fixtures";
|
||||||
import { SUN_ICON_SVG } from "../../../test/fixtures/storyIcons";
|
import { SUN_ICON_SVG } from "../../../test/fixtures/storyIcons";
|
||||||
import type { TaskProgress } from "../../groups/TaskCard/TaskCard";
|
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`,
|
// MCP App tools — `isAppTool` detects these via `_meta.ui.resourceUri`,
|
||||||
// so they get filtered into the Apps tab while still appearing on Tools.
|
// 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<typeof InspectorView> = {
|
const meta: Meta<typeof InspectorView> = {
|
||||||
title: "Views/InspectorView",
|
title: "Views/InspectorView",
|
||||||
component: InspectorView,
|
component: InspectorView,
|
||||||
parameters: { layout: "fullscreen" },
|
parameters: { layout: "fullscreen" },
|
||||||
args: {
|
args: {
|
||||||
onToggleTheme: fn(),
|
// Data
|
||||||
servers: demoServers,
|
servers: demoServers,
|
||||||
tools: demoTools,
|
tools: demoTools,
|
||||||
prompts: demoPrompts,
|
prompts: demoPrompts,
|
||||||
|
|
@ -250,6 +271,57 @@ const meta: Meta<typeof InspectorView> = {
|
||||||
tasks: demoTasks,
|
tasks: demoTasks,
|
||||||
progressByTaskId: demoProgressByTaskId,
|
progressByTaskId: demoProgressByTaskId,
|
||||||
history: demoHistory,
|
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: [],
|
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",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
|
||||||
|
|
@ -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 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 type { ServerEntry } from "@inspector/core/mcp/types.js";
|
||||||
import {
|
import {
|
||||||
renderWithMantine,
|
renderWithMantine,
|
||||||
screen,
|
screen,
|
||||||
waitFor,
|
waitFor,
|
||||||
} from "../../../test/renderWithMantine";
|
} from "../../../test/renderWithMantine";
|
||||||
import { InspectorView } from "./InspectorView";
|
import { InspectorView, type InspectorViewProps } from "./InspectorView";
|
||||||
|
import type { BridgeFactory } from "../../elements/AppRenderer/AppRenderer";
|
||||||
|
|
||||||
const baseProps = {
|
// Stub bridge factory — AppsScreen mounts the inner iframe and invokes
|
||||||
servers: [],
|
// `bridgeFactory(...)` on selection. The stub keeps that path quiet by
|
||||||
tools: [],
|
// returning a no-op AppBridge so tests don't try to postMessage to a
|
||||||
prompts: [],
|
// real sandbox.
|
||||||
resources: [],
|
const noopBridgeFactory: BridgeFactory = () =>
|
||||||
resourceTemplates: [],
|
({
|
||||||
subscriptions: [],
|
sendToolInput: async () => {},
|
||||||
logs: [],
|
sendToolResult: async () => {},
|
||||||
tasks: [],
|
sendToolCancelled: async () => {},
|
||||||
history: [],
|
teardownResource: async () => ({}),
|
||||||
onToggleTheme: vi.fn(),
|
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> = {},
|
||||||
|
): 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 = {
|
const sampleServer: ServerEntry = {
|
||||||
id: "alpha",
|
id: "alpha",
|
||||||
|
|
@ -29,20 +97,15 @@ const sampleServer: ServerEntry = {
|
||||||
connection: { status: "disconnected" },
|
connection: { status: "disconnected" },
|
||||||
};
|
};
|
||||||
|
|
||||||
afterEach(() => {
|
const connectedInit: InitializeResult = {
|
||||||
vi.restoreAllMocks();
|
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", () => {
|
describe("InspectorView", () => {
|
||||||
it("renders the disconnected header by default", () => {
|
it("renders the empty-server-list placeholder when no servers are configured", () => {
|
||||||
renderWithMantine(<InspectorView {...baseProps} />);
|
renderWithMantine(<InspectorView {...makeProps()} />);
|
||||||
expect(
|
expect(
|
||||||
screen.getByText("No servers configured. Add a server to get started."),
|
screen.getByText("No servers configured. Add a server to get started."),
|
||||||
).toBeInTheDocument();
|
).toBeInTheDocument();
|
||||||
|
|
@ -50,86 +113,105 @@ describe("InspectorView", () => {
|
||||||
|
|
||||||
it("renders the server card from the input list", () => {
|
it("renders the server card from the input list", () => {
|
||||||
renderWithMantine(
|
renderWithMantine(
|
||||||
<InspectorView {...baseProps} servers={[sampleServer]} />,
|
<InspectorView {...makeProps({ servers: [sampleServer] })} />,
|
||||||
);
|
);
|
||||||
expect(screen.getByText("Alpha")).toBeInTheDocument();
|
expect(screen.getByText("Alpha")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("transitions to connected on a successful handshake", async () => {
|
it("dispatches onToggleConnection with the server id when the card toggle is clicked", async () => {
|
||||||
vi.spyOn(Math, "random").mockReturnValue(0.1);
|
const onToggleConnection = vi.fn();
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
renderWithMantine(
|
renderWithMantine(
|
||||||
<InspectorView {...baseProps} servers={[sampleServer]} />,
|
<InspectorView
|
||||||
|
{...makeProps({ servers: [sampleServer], onToggleConnection })}
|
||||||
|
/>,
|
||||||
);
|
);
|
||||||
await user.click(screen.getByRole("switch"));
|
await user.click(screen.getByRole("switch"));
|
||||||
await waitFor(
|
expect(onToggleConnection).toHaveBeenCalledWith("alpha");
|
||||||
() => {
|
|
||||||
expect(
|
|
||||||
screen.queryByText(
|
|
||||||
"No servers configured. Add a server to get started.",
|
|
||||||
),
|
|
||||||
).not.toBeInTheDocument();
|
|
||||||
},
|
|
||||||
{ timeout: 2000 },
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("transitions to error on a failed handshake", async () => {
|
it("renders the connected header when connectionStatus + initializeResult are set", () => {
|
||||||
vi.spyOn(Math, "random").mockReturnValue(0.99);
|
|
||||||
const user = userEvent.setup();
|
|
||||||
renderWithMantine(
|
renderWithMantine(
|
||||||
<InspectorView {...baseProps} servers={[sampleServer]} />,
|
<InspectorView
|
||||||
);
|
{...makeProps({
|
||||||
await user.click(screen.getByRole("switch"));
|
servers: [sampleServer],
|
||||||
await waitFor(
|
activeServer: "alpha",
|
||||||
() => {
|
connectionStatus: "connected",
|
||||||
expect(
|
initializeResult: connectedInit,
|
||||||
screen.getByText("Server returned invalid response"),
|
latencyMs: 50,
|
||||||
).toBeInTheDocument();
|
})}
|
||||||
},
|
/>,
|
||||||
{ timeout: 2000 },
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("disconnects when the connected server is toggled off", async () => {
|
|
||||||
vi.spyOn(Math, "random").mockReturnValue(0.1);
|
|
||||||
const user = userEvent.setup();
|
|
||||||
renderWithMantine(
|
|
||||||
<InspectorView {...baseProps} servers={[sampleServer]} />,
|
|
||||||
);
|
|
||||||
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();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("renders ViewHeader connected when handshake succeeds", async () => {
|
|
||||||
vi.spyOn(Math, "random").mockReturnValue(0.1);
|
|
||||||
const user = userEvent.setup();
|
|
||||||
renderWithMantine(
|
|
||||||
<InspectorView {...baseProps} servers={[sampleServer]} />,
|
|
||||||
);
|
|
||||||
const toggle = screen.getByRole("switch");
|
|
||||||
await user.click(toggle);
|
|
||||||
await waitFor(
|
|
||||||
() => {
|
|
||||||
expect(toggle).toBeChecked();
|
|
||||||
expect(toggle).not.toBeDisabled();
|
|
||||||
},
|
|
||||||
{ 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.getAllByText("Alpha").length).toBeGreaterThan(0);
|
||||||
|
expect(screen.getByRole("switch")).toBeChecked();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders the error banner when connectionStatus is 'error' with an errorMessage", () => {
|
||||||
|
renderWithMantine(
|
||||||
|
<InspectorView
|
||||||
|
{...makeProps({
|
||||||
|
servers: [sampleServer],
|
||||||
|
activeServer: "alpha",
|
||||||
|
connectionStatus: "error",
|
||||||
|
errorMessage: "Handshake timeout",
|
||||||
|
})}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(screen.getByText("Handshake timeout")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("snaps activeTab back to Servers when connection drops", async () => {
|
||||||
|
const { rerender } = renderWithMantine(
|
||||||
|
<InspectorView
|
||||||
|
{...makeProps({
|
||||||
|
servers: [sampleServer],
|
||||||
|
activeServer: "alpha",
|
||||||
|
connectionStatus: "connected",
|
||||||
|
initializeResult: connectedInit,
|
||||||
|
latencyMs: 50,
|
||||||
|
})}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const tabSelect = await screen.findByDisplayValue("Servers");
|
||||||
|
await user.click(tabSelect);
|
||||||
|
await user.click(await screen.findByText("Tools"));
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(screen.queryByDisplayValue("Tools")).toBeInTheDocument(),
|
||||||
|
);
|
||||||
|
|
||||||
|
rerender(
|
||||||
|
<InspectorView
|
||||||
|
{...makeProps({
|
||||||
|
servers: [sampleServer],
|
||||||
|
activeServer: undefined,
|
||||||
|
connectionStatus: "disconnected",
|
||||||
|
})}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 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(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("disables non-Servers tabs while disconnected", () => {
|
||||||
|
renderWithMantine(<InspectorView {...makeProps()} />);
|
||||||
|
// 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 () => {
|
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 user = userEvent.setup();
|
||||||
const opsApp: Tool = {
|
const opsApp: Tool = {
|
||||||
name: "ops",
|
name: "ops",
|
||||||
|
|
@ -153,49 +235,72 @@ describe("InspectorView", () => {
|
||||||
};
|
};
|
||||||
renderWithMantine(
|
renderWithMantine(
|
||||||
<InspectorView
|
<InspectorView
|
||||||
{...baseProps}
|
{...makeProps({
|
||||||
servers={[sampleServer]}
|
servers: [sampleServer],
|
||||||
tools={[opsApp, plainTool, malformedAppTool]}
|
activeServer: "alpha",
|
||||||
|
connectionStatus: "connected",
|
||||||
|
initializeResult: connectedInit,
|
||||||
|
latencyMs: 50,
|
||||||
|
tools: [opsApp, plainTool, malformedAppTool],
|
||||||
|
})}
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
await user.click(screen.getByRole("switch"));
|
|
||||||
await waitFor(
|
|
||||||
() => {
|
|
||||||
expect(screen.getByRole("switch")).toBeChecked();
|
|
||||||
},
|
|
||||||
{ timeout: 2000 },
|
|
||||||
);
|
|
||||||
const tabSelect = await screen.findByDisplayValue("Servers");
|
const tabSelect = await screen.findByDisplayValue("Servers");
|
||||||
await user.click(tabSelect);
|
await user.click(tabSelect);
|
||||||
await user.click(await screen.findByText("Apps"));
|
await user.click(await screen.findByText("Apps"));
|
||||||
expect(screen.getByText("MCP Apps (1)")).toBeInTheDocument();
|
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"));
|
await user.click(screen.getByText("Ops Dashboard"));
|
||||||
expect(screen.getByTitle("Ops Dashboard")).toBeInTheDocument();
|
expect(screen.getByTitle("Ops Dashboard")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("toggles autoScroll on the Logs screen after connecting", async () => {
|
it("dispatches onSetLogLevel through to the Logs screen", async () => {
|
||||||
vi.spyOn(Math, "random").mockReturnValue(0.1);
|
const onSetLogLevel = vi.fn();
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
renderWithMantine(
|
renderWithMantine(
|
||||||
<InspectorView {...baseProps} servers={[sampleServer]} />,
|
<InspectorView
|
||||||
|
{...makeProps({
|
||||||
|
servers: [sampleServer],
|
||||||
|
activeServer: "alpha",
|
||||||
|
connectionStatus: "connected",
|
||||||
|
initializeResult: connectedInit,
|
||||||
|
latencyMs: 50,
|
||||||
|
onSetLogLevel,
|
||||||
|
})}
|
||||||
|
/>,
|
||||||
);
|
);
|
||||||
const toggle = screen.getByRole("switch");
|
|
||||||
await user.click(toggle);
|
|
||||||
await waitFor(
|
|
||||||
() => {
|
|
||||||
expect(toggle).toBeChecked();
|
|
||||||
expect(toggle).not.toBeDisabled();
|
|
||||||
},
|
|
||||||
{ timeout: 2000 },
|
|
||||||
);
|
|
||||||
// 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");
|
const tabSelect = await screen.findByDisplayValue("Servers");
|
||||||
await user.click(tabSelect);
|
await user.click(tabSelect);
|
||||||
const logsOption = await screen.findByText("Logs");
|
await user.click(await screen.findByText("Logs"));
|
||||||
await user.click(logsOption);
|
// 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(
|
||||||
|
<InspectorView
|
||||||
|
{...makeProps({
|
||||||
|
servers: [sampleServer],
|
||||||
|
activeServer: "alpha",
|
||||||
|
connectionStatus: "connected",
|
||||||
|
initializeResult: connectedInit,
|
||||||
|
latencyMs: 50,
|
||||||
|
})}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
const tabSelect = await screen.findByDisplayValue("Servers");
|
||||||
|
await user.click(tabSelect);
|
||||||
|
await user.click(await screen.findByText("Logs"));
|
||||||
const autoScroll = await screen.findByRole("checkbox", {
|
const autoScroll = await screen.findByRole("checkbox", {
|
||||||
name: "Auto-scroll",
|
name: "Auto-scroll",
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,6 @@ import type {
|
||||||
Task,
|
Task,
|
||||||
Tool,
|
Tool,
|
||||||
} from "@modelcontextprotocol/sdk/types.js";
|
} from "@modelcontextprotocol/sdk/types.js";
|
||||||
import type { AppBridge } from "@modelcontextprotocol/ext-apps/app-bridge";
|
|
||||||
import type {
|
import type {
|
||||||
ConnectionStatus,
|
ConnectionStatus,
|
||||||
InspectorResourceSubscription,
|
InspectorResourceSubscription,
|
||||||
|
|
@ -19,14 +18,23 @@ import type {
|
||||||
import { isAppTool } from "@inspector/core/mcp/apps.js";
|
import { isAppTool } from "@inspector/core/mcp/apps.js";
|
||||||
import { ViewHeader } from "../../groups/ViewHeader/ViewHeader";
|
import { ViewHeader } from "../../groups/ViewHeader/ViewHeader";
|
||||||
import { ServerListScreen } from "../../screens/ServerListScreen/ServerListScreen";
|
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 { AppsScreen } from "../../screens/AppsScreen/AppsScreen";
|
||||||
import type {
|
import type {
|
||||||
AppRendererHandle,
|
AppRendererHandle,
|
||||||
BridgeFactory,
|
BridgeFactory,
|
||||||
} from "../../elements/AppRenderer/AppRenderer";
|
} from "../../elements/AppRenderer/AppRenderer";
|
||||||
import { PromptsScreen } from "../../screens/PromptsScreen/PromptsScreen";
|
import {
|
||||||
import { ResourcesScreen } from "../../screens/ResourcesScreen/ResourcesScreen";
|
PromptsScreen,
|
||||||
|
type GetPromptState,
|
||||||
|
} from "../../screens/PromptsScreen/PromptsScreen";
|
||||||
|
import {
|
||||||
|
ResourcesScreen,
|
||||||
|
type ReadResourceState,
|
||||||
|
} from "../../screens/ResourcesScreen/ResourcesScreen";
|
||||||
import { LoggingScreen } from "../../screens/LoggingScreen/LoggingScreen";
|
import { LoggingScreen } from "../../screens/LoggingScreen/LoggingScreen";
|
||||||
import type { LogEntryData } from "../../elements/LogEntry/LogEntry";
|
import type { LogEntryData } from "../../elements/LogEntry/LogEntry";
|
||||||
import { TasksScreen } from "../../screens/TasksScreen/TasksScreen";
|
import { TasksScreen } from "../../screens/TasksScreen/TasksScreen";
|
||||||
|
|
@ -46,20 +54,6 @@ const ALL_TABS: string[] = [
|
||||||
"History",
|
"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_ENTER_MS = 350;
|
||||||
const SCREEN_EXIT_MS = 250;
|
const SCREEN_EXIT_MS = 250;
|
||||||
|
|
||||||
|
|
@ -67,8 +61,7 @@ const SCREEN_EXIT_MS = 250;
|
||||||
// children. `mih: "100%"` requires `AppShell.Main` to provide a definite
|
// children. `mih: "100%"` requires `AppShell.Main` to provide a definite
|
||||||
// height for the stack itself to fill — the absolute children render
|
// height for the stack itself to fill — the absolute children render
|
||||||
// regardless, but nested ScrollArea screens need a non-collapsing parent
|
// regardless, but nested ScrollArea screens need a non-collapsing parent
|
||||||
// for their scroll containment to work. Revisit if Phase 3 reveals
|
// for their scroll containment to work.
|
||||||
// scroll issues in real screens.
|
|
||||||
const ScreenStageContainer = Stack.withProps({
|
const ScreenStageContainer = Stack.withProps({
|
||||||
pos: "relative",
|
pos: "relative",
|
||||||
gap: 0,
|
gap: 0,
|
||||||
|
|
@ -79,10 +72,7 @@ const ScreenStageContainer = Stack.withProps({
|
||||||
// Wraps each screen in a Mantine Transition. With Transition's default
|
// Wraps each screen in a Mantine Transition. With Transition's default
|
||||||
// (`keepMounted={false}`), the outgoing screen unmounts after its exit
|
// (`keepMounted={false}`), the outgoing screen unmounts after its exit
|
||||||
// animation — any local screen state (search filters, scroll position,
|
// animation — any local screen state (search filters, scroll position,
|
||||||
// expanded sections) is reset on tab switch. This matches the previous
|
// expanded sections) is reset on tab switch.
|
||||||
// `{activeTab === "X" && <Screen />}` behavior. If Phase 3 needs state
|
|
||||||
// to persist across tab switches, add `keepMounted` to the Transition
|
|
||||||
// below.
|
|
||||||
function ScreenStage({
|
function ScreenStage({
|
||||||
active,
|
active,
|
||||||
children,
|
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 {
|
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[];
|
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[];
|
tools: Tool[];
|
||||||
prompts: Prompt[];
|
prompts: Prompt[];
|
||||||
resources: Resource[];
|
resources: Resource[];
|
||||||
|
|
@ -138,11 +122,86 @@ export interface InspectorViewProps {
|
||||||
tasks: Task[];
|
tasks: Task[];
|
||||||
progressByTaskId?: Record<string, TaskProgress>;
|
progressByTaskId?: Record<string, TaskProgress>;
|
||||||
history: MessageEntry[];
|
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<string>;
|
||||||
|
|
||||||
|
// Theme toggle (lives in the parent so the color scheme can also flow
|
||||||
|
// into other top-level UI later).
|
||||||
onToggleTheme: () => void;
|
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<string, unknown>) => void;
|
||||||
|
onCancelToolCall?: () => void;
|
||||||
|
onClearToolResult?: () => void;
|
||||||
|
onRefreshTools: () => void;
|
||||||
|
|
||||||
|
onGetPrompt: (name: string, args: Record<string, string>) => 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<string, unknown>) => void;
|
||||||
|
onCloseApp: () => void;
|
||||||
|
onRefreshApps: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function InspectorView({
|
export function InspectorView({
|
||||||
servers: serversInput,
|
servers: serversInput,
|
||||||
|
activeServer,
|
||||||
|
connectionStatus,
|
||||||
|
initializeResult,
|
||||||
|
latencyMs,
|
||||||
|
errorMessage,
|
||||||
tools,
|
tools,
|
||||||
prompts,
|
prompts,
|
||||||
resources,
|
resources,
|
||||||
|
|
@ -152,29 +211,76 @@ export function InspectorView({
|
||||||
tasks,
|
tasks,
|
||||||
progressByTaskId,
|
progressByTaskId,
|
||||||
history,
|
history,
|
||||||
|
toolCallState,
|
||||||
|
getPromptState,
|
||||||
|
readResourceState,
|
||||||
|
currentLogLevel,
|
||||||
|
sandboxPath,
|
||||||
|
bridgeFactory,
|
||||||
|
pinnedHistoryIds,
|
||||||
onToggleTheme,
|
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) {
|
}: InspectorViewProps) {
|
||||||
const [activeServer, setActiveServer] = useState<string | undefined>(
|
// UI-only state. Connection state, primitive lists, and all action
|
||||||
undefined,
|
// dispatching live in the parent; this component only owns navigation
|
||||||
);
|
// (which tab is visible) and a couple of view-local toggles.
|
||||||
const [initializeResult, setInitializeResult] = useState<
|
const [selectedTab, setSelectedTab] = useState<string>(SERVERS_TAB);
|
||||||
InitializeResult | undefined
|
|
||||||
>(undefined);
|
|
||||||
const [connectionStatus, setConnectionStatus] =
|
|
||||||
useState<ConnectionStatus>("disconnected");
|
|
||||||
const [latencyMs, setLatencyMs] = useState<number | undefined>(undefined);
|
|
||||||
const [errorMessage, setErrorMessage] = useState<string | undefined>(
|
|
||||||
undefined,
|
|
||||||
);
|
|
||||||
const [activeTab, setActiveTab] = useState<string>(SERVERS_TAB);
|
|
||||||
const [availableTabs, setAvailableTabs] = useState<string[]>([SERVERS_TAB]);
|
|
||||||
const [logLevel, setLogLevel] = useState<LoggingLevel>("info");
|
|
||||||
const [autoScroll, setAutoScroll] = useState<boolean>(true);
|
const [autoScroll, setAutoScroll] = useState<boolean>(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<number | undefined>(undefined);
|
|
||||||
const appRendererRef = useRef<AppRendererHandle>(null);
|
const appRendererRef = useRef<AppRendererHandle>(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<string[]>(
|
||||||
|
() => (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<Tool[]>(() => {
|
const appTools = useMemo<Tool[]>(() => {
|
||||||
return tools.filter((tool) => {
|
return tools.filter((tool) => {
|
||||||
try {
|
try {
|
||||||
|
|
@ -188,10 +294,9 @@ export function InspectorView({
|
||||||
});
|
});
|
||||||
}, [tools]);
|
}, [tools]);
|
||||||
|
|
||||||
// The view is the single source of truth for connection state. Any
|
// Merge the parent's `serversInput` (static config) with the runtime
|
||||||
// `connection` field on incoming `serversInput` items is intentionally
|
// connection state owned by the parent — only the active server reflects
|
||||||
// ignored — cards mirror the global `connectionStatus` for the active
|
// the live status; the rest render as `disconnected`.
|
||||||
// server and render as `disconnected` otherwise.
|
|
||||||
const servers = useMemo<ServerEntry[]>(
|
const servers = useMemo<ServerEntry[]>(
|
||||||
() =>
|
() =>
|
||||||
serversInput.map((s) => {
|
serversInput.map((s) => {
|
||||||
|
|
@ -212,75 +317,6 @@ export function InspectorView({
|
||||||
[serversInput, activeServer, connectionStatus, errorMessage],
|
[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 (
|
return (
|
||||||
<AppShell header={{ height: 60 }} padding="md">
|
<AppShell header={{ height: 60 }} padding="md">
|
||||||
<AppShell.Header>
|
<AppShell.Header>
|
||||||
|
|
@ -292,8 +328,8 @@ export function InspectorView({
|
||||||
latencyMs={latencyMs}
|
latencyMs={latencyMs}
|
||||||
activeTab={activeTab}
|
activeTab={activeTab}
|
||||||
availableTabs={availableTabs}
|
availableTabs={availableTabs}
|
||||||
onTabChange={setActiveTab}
|
onTabChange={setSelectedTab}
|
||||||
onDisconnect={disconnect}
|
onDisconnect={onDisconnect}
|
||||||
onToggleTheme={onToggleTheme}
|
onToggleTheme={onToggleTheme}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
|
|
@ -306,44 +342,49 @@ export function InspectorView({
|
||||||
<ServerListScreen
|
<ServerListScreen
|
||||||
servers={servers}
|
servers={servers}
|
||||||
activeServer={activeServer}
|
activeServer={activeServer}
|
||||||
onAddManually={noop}
|
onAddManually={onServerAdd}
|
||||||
onImportConfig={noop}
|
onImportConfig={onServerImportConfig}
|
||||||
onImportServerJson={noop}
|
onImportServerJson={onServerImportJson}
|
||||||
onToggleConnection={handleToggleConnection}
|
onToggleConnection={onToggleConnection}
|
||||||
onServerInfo={noop}
|
onServerInfo={onServerInfo}
|
||||||
onSettings={noop}
|
onSettings={onServerSettings}
|
||||||
onEdit={noop}
|
onEdit={onServerEdit}
|
||||||
onClone={noop}
|
onClone={onServerClone}
|
||||||
onRemove={noop}
|
onRemove={onServerRemove}
|
||||||
/>
|
/>
|
||||||
</ScreenStage>
|
</ScreenStage>
|
||||||
<ScreenStage active={activeTab === "Tools"}>
|
<ScreenStage active={activeTab === "Tools"}>
|
||||||
<ToolsScreen
|
<ToolsScreen
|
||||||
tools={tools}
|
tools={tools}
|
||||||
|
callState={toolCallState}
|
||||||
listChanged={false}
|
listChanged={false}
|
||||||
onRefreshList={noop}
|
onRefreshList={onRefreshTools}
|
||||||
onCallTool={noop}
|
onCallTool={onCallTool}
|
||||||
|
onCancelCall={onCancelToolCall}
|
||||||
|
onClearResult={onClearToolResult}
|
||||||
/>
|
/>
|
||||||
</ScreenStage>
|
</ScreenStage>
|
||||||
<ScreenStage active={activeTab === "Apps"}>
|
<ScreenStage active={activeTab === "Apps"}>
|
||||||
<AppsScreen
|
<AppsScreen
|
||||||
tools={appTools}
|
tools={appTools}
|
||||||
listChanged={false}
|
listChanged={false}
|
||||||
sandboxPath={STUB_SANDBOX_PATH}
|
sandboxPath={sandboxPath}
|
||||||
bridgeFactory={stubBridgeFactory}
|
bridgeFactory={bridgeFactory}
|
||||||
rendererRef={appRendererRef}
|
rendererRef={appRendererRef}
|
||||||
onRefreshList={noop}
|
onRefreshList={onRefreshApps}
|
||||||
onSelectApp={noop}
|
onSelectApp={onSelectApp}
|
||||||
onOpenApp={noop}
|
onOpenApp={onOpenApp}
|
||||||
onCloseApp={noop}
|
onCloseApp={onCloseApp}
|
||||||
/>
|
/>
|
||||||
</ScreenStage>
|
</ScreenStage>
|
||||||
<ScreenStage active={activeTab === "Prompts"}>
|
<ScreenStage active={activeTab === "Prompts"}>
|
||||||
<PromptsScreen
|
<PromptsScreen
|
||||||
prompts={prompts}
|
prompts={prompts}
|
||||||
|
getPromptState={getPromptState}
|
||||||
listChanged={false}
|
listChanged={false}
|
||||||
onRefreshList={noop}
|
onRefreshList={onRefreshPrompts}
|
||||||
onGetPrompt={noop}
|
onGetPrompt={onGetPrompt}
|
||||||
|
onCopyMessages={onCopyPromptMessages}
|
||||||
/>
|
/>
|
||||||
</ScreenStage>
|
</ScreenStage>
|
||||||
<ScreenStage active={activeTab === "Resources"}>
|
<ScreenStage active={activeTab === "Resources"}>
|
||||||
|
|
@ -351,42 +392,43 @@ export function InspectorView({
|
||||||
resources={resources}
|
resources={resources}
|
||||||
templates={resourceTemplates}
|
templates={resourceTemplates}
|
||||||
subscriptions={subscriptions}
|
subscriptions={subscriptions}
|
||||||
|
readState={readResourceState}
|
||||||
listChanged={false}
|
listChanged={false}
|
||||||
onRefreshList={noop}
|
onRefreshList={onRefreshResources}
|
||||||
onReadResource={noop}
|
onReadResource={onReadResource}
|
||||||
onSubscribeResource={noop}
|
onSubscribeResource={onSubscribeResource}
|
||||||
onUnsubscribeResource={noop}
|
onUnsubscribeResource={onUnsubscribeResource}
|
||||||
/>
|
/>
|
||||||
</ScreenStage>
|
</ScreenStage>
|
||||||
<ScreenStage active={activeTab === "Tasks"}>
|
<ScreenStage active={activeTab === "Tasks"}>
|
||||||
<TasksScreen
|
<TasksScreen
|
||||||
tasks={tasks}
|
tasks={tasks}
|
||||||
progressByTaskId={progressByTaskId}
|
progressByTaskId={progressByTaskId}
|
||||||
onRefresh={noop}
|
onRefresh={onRefreshTasks}
|
||||||
onClearCompleted={noop}
|
onClearCompleted={onClearCompletedTasks}
|
||||||
onCancel={noop}
|
onCancel={onCancelTask}
|
||||||
/>
|
/>
|
||||||
</ScreenStage>
|
</ScreenStage>
|
||||||
<ScreenStage active={activeTab === "Logs"}>
|
<ScreenStage active={activeTab === "Logs"}>
|
||||||
<LoggingScreen
|
<LoggingScreen
|
||||||
entries={logs}
|
entries={logs}
|
||||||
currentLevel={logLevel}
|
currentLevel={currentLogLevel}
|
||||||
onSetLevel={setLogLevel}
|
onSetLevel={onSetLogLevel}
|
||||||
onClear={noop}
|
onClear={onClearLogs}
|
||||||
onExport={noop}
|
onExport={onExportLogs}
|
||||||
autoScroll={autoScroll}
|
autoScroll={autoScroll}
|
||||||
onToggleAutoScroll={() => setAutoScroll((prev) => !prev)}
|
onToggleAutoScroll={() => setAutoScroll((prev) => !prev)}
|
||||||
onCopyAll={noop}
|
onCopyAll={onCopyAllLogs}
|
||||||
/>
|
/>
|
||||||
</ScreenStage>
|
</ScreenStage>
|
||||||
<ScreenStage active={activeTab === "History"}>
|
<ScreenStage active={activeTab === "History"}>
|
||||||
<HistoryScreen
|
<HistoryScreen
|
||||||
entries={history}
|
entries={history}
|
||||||
pinnedIds={new Set()}
|
pinnedIds={pinnedHistoryIds ?? new Set()}
|
||||||
onClearAll={noop}
|
onClearAll={onClearHistory}
|
||||||
onExport={noop}
|
onExport={onExportHistory}
|
||||||
onReplay={noop}
|
onReplay={onReplayHistory}
|
||||||
onTogglePin={noop}
|
onTogglePin={onTogglePinHistory}
|
||||||
/>
|
/>
|
||||||
</ScreenStage>
|
</ScreenStage>
|
||||||
</ScreenStageContainer>
|
</ScreenStageContainer>
|
||||||
|
|
|
||||||
71
clients/web/src/lib/environmentFactory.ts
Normal file
71
clients/web/src/lib/environmentFactory.ts
Normal file
|
|
@ -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 };
|
||||||
|
}
|
||||||
|
|
@ -12,7 +12,9 @@ const resolver: CSSVariablesResolver = () => ({
|
||||||
variables: {},
|
variables: {},
|
||||||
light: {},
|
light: {},
|
||||||
dark: {
|
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)",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -119,14 +119,39 @@ export default defineConfig({
|
||||||
},
|
},
|
||||||
resolve: {
|
resolve: {
|
||||||
// NOTE: the unit vitest project (below) overrides this — see comment there.
|
// 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,
|
// Source files in core/ import bare modules (react, @testing-library/react,
|
||||||
// etc.) that only exist in clients/web/node_modules. Dedupe ensures Vite
|
// etc.) that only exist in clients/web/node_modules. Dedupe ensures Vite
|
||||||
// resolves them from this package rather than walking up from core/'s
|
// resolves them from this package rather than walking up from core/'s
|
||||||
// location (which has no node_modules of its own yet).
|
// location (which has no node_modules of its own yet).
|
||||||
dedupe: sharedDedupe,
|
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: {
|
server: {
|
||||||
|
port: parseInt(process.env.CLIENT_PORT ?? '6274', 10),
|
||||||
|
host: process.env.HOST ?? 'localhost',
|
||||||
|
strictPort: true,
|
||||||
fs: {
|
fs: {
|
||||||
allow: [path.resolve(dirname, '../..')],
|
allow: [path.resolve(dirname, '../..')],
|
||||||
},
|
},
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue