diff --git a/packages/inspector/src/dashboard/react/InspectorDashboard.tsx b/packages/inspector/src/dashboard/react/InspectorDashboard.tsx index 78c97ad5..6f7f46b7 100644 --- a/packages/inspector/src/dashboard/react/InspectorDashboard.tsx +++ b/packages/inspector/src/dashboard/react/InspectorDashboard.tsx @@ -17,17 +17,83 @@ import { useGlobals, type GlobalsState } from "./hooks/useGlobals"; import { useConnections } from "./hooks/useConnections"; import { useOAuth } from "./hooks/useOAuth"; import { useMcpPrimitives, type McpPrimitives } from "./hooks/useMcpPrimitives"; +import type { ConnectionParams } from "@mcp-apps-kit/testing"; +import { z } from "zod"; import { Toolbar } from "./components/Toolbar"; -import { ConnectionBar } from "./components/ConnectionBar"; -import { TabBar } from "./components/TabBar"; import { GlobalsPanel } from "./components/GlobalsPanel"; -import { McpPrimitivesPanel } from "./components/McpPrimitivesPanel"; +import { + McpPrimitivesPanel, + type ServerData, + type StoppedConnection, + type SelectedPrimitive, +} from "./components/McpPrimitivesPanel"; +import type { Primitive } from "./components/PrimitiveDetail"; import { RightPanel } from "./components/RightPanel"; import { NoWidgetPlaceholder, type ConnectionState } from "./components/NoWidgetPlaceholder"; import { OAuthDiscoveryPanel } from "./components/OAuthDiscoveryPanel"; import { styles } from "./styles"; import logoUrl from "../assets/logo.png"; +// ============================================================================= +// Stopped Connections Storage +// ============================================================================= + +const STOPPED_CONNECTIONS_KEY = "mcp-dashboard-stopped-connections"; + +/** Zod schema for ConnectionParams validation */ +const ConnectionParamsSchema = z.union([ + z.object({ + transport: z.literal("http"), + url: z.string().min(1), + }), + z.object({ + transport: z.literal("stdio"), + command: z.string().min(1), + args: z.array(z.string()).optional(), + env: z.record(z.string(), z.string()).optional(), + inheritEnv: z.boolean().optional(), + cwd: z.string().optional(), + }), +]); + +/** Zod schema for StoppedConnection validation */ +const StoppedConnectionSchema = z.object({ + id: z.string().min(1), + name: z.string().min(1), + url: z.string().min(1), + params: ConnectionParamsSchema, +}); + +/** Load stopped connections from localStorage */ +function loadStoppedConnections(): StoppedConnection[] { + if (typeof window === "undefined") return []; + try { + const stored = localStorage.getItem(STOPPED_CONNECTIONS_KEY); + if (!stored) return []; + const parsed = JSON.parse(stored) as unknown; + // Validate shape + if (!Array.isArray(parsed)) return []; + return parsed + .map((item) => { + const result = StoppedConnectionSchema.safeParse(item); + return result.success ? result.data : null; + }) + .filter((item): item is StoppedConnection => item !== null); + } catch { + return []; + } +} + +/** Save stopped connections to localStorage */ +function saveStoppedConnections(connections: StoppedConnection[]): void { + if (typeof window === "undefined") return; + try { + localStorage.setItem(STOPPED_CONNECTIONS_KEY, JSON.stringify(connections)); + } catch { + // Ignore storage errors + } +} + export interface InspectorDashboardProps { /** Base URL for the inspector API (default: current origin) */ baseUrl?: string; @@ -48,13 +114,11 @@ export function InspectorDashboard({ baseUrl = "" }: InspectorDashboardProps): R const { connections, activeConnectionId, - setActiveConnectionId, isCreating, error: connectionError, createConnection, reconnectConnection, closeConnection, - getMatchingEntries, authDiscovery, clearAuthDiscovery, } = useConnections(baseUrl); @@ -63,11 +127,21 @@ export function InspectorDashboard({ baseUrl = "" }: InspectorDashboardProps): R const connectionCacheRef = useRef>(new Map()); const prevConnectionIdRef = useRef(null); + // Stopped connections state (persisted to localStorage) + const [stoppedConnections, setStoppedConnections] = useState(() => + loadStoppedConnections() + ); + + // Track which server is currently reconnecting (shows loading state) + const [reconnectingServerId, setReconnectingServerId] = useState(null); + + // Per-connection primitives cache (for building ServerData for all connections) + const primitivesPerConnectionRef = useRef>(new Map()); + // Session state const [selectedSessionByConnection, setSelectedSessionByConnection] = useState< Record >({}); - const [isConnectionFormOpen, setIsConnectionFormOpen] = useState(false); const { sessions, isLoading: sessionsLoading } = useSessions(baseUrl, activeConnectionId); const activeConnection = useMemo( @@ -187,7 +261,6 @@ export function InspectorDashboard({ baseUrl = "" }: InspectorDashboardProps): R void reconnectConnection(activeConnectionId).then((connected) => { if (connected) { clearAuthDiscovery(); - setIsConnectionFormOpen(false); } }); } @@ -212,6 +285,50 @@ export function InspectorDashboard({ baseUrl = "" }: InspectorDashboardProps): R resources.length > 0 ? resources : (cachedState?.primitives?.resources ?? []); const displayPrompts = prompts.length > 0 ? prompts : (cachedState?.primitives?.prompts ?? []); + // Update primitives cache for the active connection + useEffect(() => { + if (activeConnectionId && (tools.length > 0 || resources.length > 0 || prompts.length > 0)) { + primitivesPerConnectionRef.current.set(activeConnectionId, { tools, resources, prompts }); + } + }, [activeConnectionId, tools, resources, prompts]); + + // Persist stopped connections to localStorage + useEffect(() => { + saveStoppedConnections(stoppedConnections); + }, [stoppedConnections]); + + // Build ServerData array from active connections + primitives cache + const serverDataList: ServerData[] = useMemo(() => { + return connections + .filter((conn) => conn.status === "connected") + .map((conn) => { + const cached = primitivesPerConnectionRef.current.get(conn.id); + // For active connection, use live data; for others, use cache + const prims = + conn.id === activeConnectionId + ? { tools: displayTools, resources: displayResources, prompts: displayPrompts } + : (cached ?? { tools: [], resources: [], prompts: [] }); + return { + id: conn.id, + name: conn.serverInfo?.name ?? conn.url, + url: conn.url, + isConnected: true, + tools: prims.tools, + resources: prims.resources, + prompts: prims.prompts, + // Pass connection params for server info display + params: { transport: "http" }, // Dashboard only supports HTTP transport + serverInfo: conn.serverInfo ?? undefined, + // Capabilities determined from available primitives + capabilities: { + tools: prims.tools.length > 0, + resources: prims.resources.length > 0, + prompts: prims.prompts.length > 0, + }, + }; + }); + }, [connections, activeConnectionId, displayTools, displayResources, displayPrompts]); + // Compute connection state for NoWidgetPlaceholder const connectionState: ConnectionState = useMemo(() => { // No server connected @@ -233,13 +350,17 @@ export function InspectorDashboard({ baseUrl = "" }: InspectorDashboardProps): R return (initEvent?.payload as { clientName?: string } | undefined)?.clientName; }, [displayAgentEvents]); - // Handler to open connection form - const handleConnect = useCallback(() => { - setIsConnectionFormOpen(true); - }, []); - - // Left panel state (MCP primitives) - const [isLeftPanelCollapsed, setIsLeftPanelCollapsed] = useState(false); + // Left panel state (MCP primitives) - persisted to localStorage + const [isLeftPanelCollapsed, setIsLeftPanelCollapsed] = useState(() => { + if (typeof window !== "undefined") { + try { + return localStorage.getItem("mcp-dashboard-left-collapsed") === "true"; + } catch { + return false; + } + } + return false; + }); // Right panel state (persisted) const [isRightPanelCollapsed, setIsRightPanelCollapsed] = useState(() => { @@ -253,6 +374,34 @@ export function InspectorDashboard({ baseUrl = "" }: InspectorDashboardProps): R return false; }); + // Selected primitive state (for detail view) + const [selectedPrimitive, setSelectedPrimitive] = useState(null); + + // Resolve selected primitive to its full data for PrimitiveDetail + const resolvedPrimitive: Primitive | null = useMemo(() => { + if (!selectedPrimitive) return null; + + const server = serverDataList.find((s) => s.id === selectedPrimitive.serverId); + if (!server) return null; + + switch (selectedPrimitive.kind) { + case "tool": { + const tool = server.tools.find((t) => t.name === selectedPrimitive.name); + return tool ? { ...tool, kind: "tool" as const } : null; + } + case "resource": { + const resource = server.resources.find((r) => r.name === selectedPrimitive.name); + return resource ? { ...resource, kind: "resource" as const } : null; + } + case "prompt": { + const prompt = server.prompts.find((p) => p.name === selectedPrimitive.name); + return prompt ? { ...prompt, kind: "prompt" as const } : null; + } + default: + return null; + } + }, [selectedPrimitive, serverDataList]); + // Globals bar state (persisted) const [isGlobalsBarCollapsed, setIsGlobalsBarCollapsed] = useState(() => { if (typeof window !== "undefined") { @@ -338,6 +487,17 @@ export function InspectorDashboard({ baseUrl = "" }: InspectorDashboardProps): R } }, [isRightPanelCollapsed]); + // Save left panel collapsed state + useEffect(() => { + if (typeof window !== "undefined") { + try { + localStorage.setItem("mcp-dashboard-left-collapsed", String(isLeftPanelCollapsed)); + } catch { + // ignore storage access errors + } + } + }, [isLeftPanelCollapsed]); + // Save globals bar collapsed state useEffect(() => { if (typeof window !== "undefined") { @@ -401,14 +561,15 @@ export function InspectorDashboard({ baseUrl = "" }: InspectorDashboardProps): R setIsGlobalsBarCollapsed((prev) => !prev); }, []); + // Handle primitive selection - shows detail in left panel (sidebar) + const handleSelectPrimitive = useCallback((primitive: SelectedPrimitive | null) => { + setSelectedPrimitive(primitive); + }, []); + const handleCreateConnection = useCallback( async (params: import("@mcp-apps-kit/testing").ConnectionParams): Promise => { const conn = await createConnection(params); - if (conn) { - setIsConnectionFormOpen(false); - return true; - } - return false; + return !!conn; }, [createConnection] ); @@ -421,6 +582,8 @@ export function InspectorDashboard({ baseUrl = "" }: InspectorDashboardProps): R } // Clear cached state for this connection connectionCacheRef.current.delete(id); + // Clear primitives cache to prevent unbounded growth + primitivesPerConnectionRef.current.delete(id); setSelectedSessionByConnection((prev) => { if (!(id in prev)) { return prev; @@ -433,16 +596,67 @@ export function InspectorDashboard({ baseUrl = "" }: InspectorDashboardProps): R [closeConnection] ); - const tabs = useMemo( - () => - connections.map((connection) => ({ - id: connection.id, - url: connection.url, - serverInfo: connection.serverInfo, - status: connection.status, - isOAuth: connection.isOAuth, - })), - [connections] + // Handler to stop a server (disconnect but keep in stopped list) + const handleStopServer = useCallback( + async (serverId: string) => { + const conn = connections.find((c) => c.id === serverId); + if (!conn) return; + + // Build connection params from what we know + // For HTTP connections, we can reconstruct params from the URL + const params: ConnectionParams = { transport: "http", url: conn.url }; + + // Add to stopped connections + const stoppedConn: StoppedConnection = { + id: serverId, + name: conn.serverInfo?.name ?? conn.url, + url: conn.url, + params, + }; + + setStoppedConnections((prev) => { + // Don't duplicate + if (prev.some((s) => s.id === serverId)) return prev; + return [...prev, stoppedConn]; + }); + + // Close the connection + await handleCloseConnection(serverId); + }, + [connections, handleCloseConnection] + ); + + // Handler to start a stopped server (reconnect using stored params) + const handleStartServer = useCallback( + async (stoppedConn: StoppedConnection) => { + // Show loading state + setReconnectingServerId(stoppedConn.id); + + try { + // Reconnect using stored params + const success = await handleCreateConnection(stoppedConn.params); + if (success) { + // Only remove from stopped list after successful connection + setStoppedConnections((prev) => prev.filter((s) => s.id !== stoppedConn.id)); + } + } finally { + setReconnectingServerId(null); + } + }, + [handleCreateConnection] + ); + + // Handler to delete a server (connected or stopped) + const handleDeleteServer = useCallback( + async (serverId: string, isConnected: boolean) => { + if (isConnected) { + // Close the connection and don't add to stopped list + await closeConnection(serverId); + } + // Remove from stopped list if present + setStoppedConnections((prev) => prev.filter((s) => s.id !== serverId)); + }, + [closeConnection] ); // Compute screencast container aspect ratio from globals viewport @@ -508,19 +722,6 @@ export function InspectorDashboard({ baseUrl = "" }: InspectorDashboardProps): R

sirius-mcp inspector

- {/* Connection Bar */} - setIsConnectionFormOpen(false)} - getMatchingEntries={getMatchingEntries} - oauth={activeConnectionId ? oauth : undefined} - authDiscovery={authDiscovery} - onDismissDiscovery={clearAuthDiscovery} - /> -
{displaySessions.length > 0 && ( @@ -580,32 +781,33 @@ export function InspectorDashboard({ baseUrl = "" }: InspectorDashboardProps): R
- setActiveConnectionId(id)} - onClose={(id) => void handleCloseConnection(id)} - onAdd={() => setIsConnectionFormOpen(true)} - /> - {/* Error Banner */} {error &&
{error}
} {/* Content Wrapper - horizontal layout */}
- {/* Left Panel - MCP Primitives (always present) */} + {/* Left Panel - MCP Primitives as Server Blocks */} setIsLeftPanelCollapsed(!isLeftPanelCollapsed)} - position="left" panelWidth={leftPanelWidth} resizeHandleProps={leftResizeHandleProps} isResizing={isLeftResizing} + onStopServer={handleStopServer} + onStartServer={handleStartServer} + onDeleteServer={handleDeleteServer} + onConnect={handleCreateConnection} + isCreating={isCreating} + connectionError={connectionError} + selectedPrimitive={selectedPrimitive} + onSelectPrimitive={handleSelectPrimitive} + resolvedPrimitive={resolvedPrimitive} + onClosePrimitive={() => setSelectedPrimitive(null)} /> {/* Center Column - screencast + globals bar */} @@ -638,11 +840,7 @@ export function InspectorDashboard({ baseUrl = "" }: InspectorDashboardProps): R
) : ( /* Tamagotchi placeholder when no widget */ - + )} @@ -690,7 +888,6 @@ export function InspectorDashboard({ baseUrl = "" }: InspectorDashboardProps): R // Close on backdrop click if (e.target === e.currentTarget) { clearAuthDiscovery(); - setIsConnectionFormOpen(false); } }} > @@ -707,7 +904,6 @@ export function InspectorDashboard({ baseUrl = "" }: InspectorDashboardProps): R void reconnectConnection(activeConnectionId).then((connected) => { if (connected) { clearAuthDiscovery(); - setIsConnectionFormOpen(false); } }); } @@ -715,7 +911,6 @@ export function InspectorDashboard({ baseUrl = "" }: InspectorDashboardProps): R }} onDismiss={() => { clearAuthDiscovery(); - setIsConnectionFormOpen(false); }} />
diff --git a/packages/inspector/src/dashboard/react/components/McpPrimitivesPanel.tsx b/packages/inspector/src/dashboard/react/components/McpPrimitivesPanel.tsx index 9481c4b3..3237d799 100644 --- a/packages/inspector/src/dashboard/react/components/McpPrimitivesPanel.tsx +++ b/packages/inspector/src/dashboard/react/components/McpPrimitivesPanel.tsx @@ -1,11 +1,12 @@ /** * McpPrimitivesPanel Component * - * Displays MCP Tools, Resources, and Prompts in a tabbed interface. - * Each primitive type is shown as a list of cards with schema details. + * Displays MCP servers as collapsible blocks with their primitives grouped by kind. + * Each server shows: header with name + Start/Stop button, and nested TOOLS/RESOURCES/PROMPTS sections. + * Stopped servers appear greyed out with a "Start" button to reconnect. */ -import React, { useState, useCallback, useRef, useEffect } from "react"; +import React, { useState, useCallback, useRef, useEffect, useMemo } from "react"; import type { McpTool, McpResource, @@ -13,19 +14,118 @@ import type { JsonSchemaProperty, McpPromptArgument, } from "../types/mcp-primitives"; +import type { ConnectionParams } from "@mcp-apps-kit/testing"; +import { SidebarConnectionForm } from "./SidebarConnectionForm"; +import { PrimitiveDetail, type Primitive } from "./PrimitiveDetail"; // ============================================================================= // Types // ============================================================================= -type TabType = "tools" | "resources" | "prompts"; +/** Server data for display in the sidebar */ +export interface ServerData { + id: string; + name: string; + url: string; + isConnected: boolean; + tools: McpTool[]; + resources: McpResource[]; + prompts: McpPrompt[]; + /** Connection parameters including transport type */ + params?: { + transport?: "stdio" | "http" | "sse" | string; + [key: string]: unknown; + }; + /** Server info from initialization */ + serverInfo?: { + version?: string; + name?: string; + [key: string]: unknown; + }; + /** Server capabilities */ + capabilities?: { + tools?: boolean | object; + resources?: boolean | object; + prompts?: boolean | object; + logging?: boolean | object; + sampling?: boolean | object; + roots?: boolean | object; + [key: string]: unknown; + }; +} + +/** Stopped connection stored for reconnection */ +export interface StoppedConnection { + id: string; + name: string; + url: string; + params: ConnectionParams; +} + +// ============================================================================= +// Props Types - Support both OLD API (tests) and NEW API (server blocks) +// ============================================================================= + +/** Selected primitive identifier */ +export interface SelectedPrimitive { + serverId: string; + kind: "tool" | "resource" | "prompt"; + name: string; +} + +/** New API props for server blocks mode */ +export interface McpPrimitivesPanelNewProps { + /** Active server connections with their primitives */ + servers: ServerData[]; + /** Stopped connections that can be restarted */ + stoppedConnections: StoppedConnection[]; + /** ID of server currently reconnecting (shows loading state) */ + reconnectingServerId?: string | null; + /** Whether primitives are still loading */ + isLoading: boolean; + /** Whether the panel is visible */ + isVisible: boolean; + /** Whether the panel is collapsed */ + isCollapsed?: boolean; + /** Callback to toggle collapsed state */ + onToggleCollapse?: () => void; + /** Panel width (for resizable panel) */ + panelWidth?: number; + /** Resize handle props (for resizable panel) */ + resizeHandleProps?: React.HTMLAttributes; + /** Whether resize is active */ + isResizing?: boolean; + /** Callback when Stop button is clicked */ + onStopServer?: (serverId: string) => void; + /** Callback when Start button is clicked for a stopped server */ + onStartServer?: (stoppedConnection: StoppedConnection) => void; + /** Callback when Delete button is clicked for a server */ + onDeleteServer?: (serverId: string, isConnected: boolean) => void; + /** Callback to open connection form for new server (legacy - opens header form) */ + onAddServer?: () => void; + /** Callback to connect to a new server with params (inline form) */ + onConnect?: (params: ConnectionParams) => Promise; + /** Whether a connection is currently being created */ + isCreating?: boolean; + /** Connection error message */ + connectionError?: string | null; + /** Currently selected primitive */ + selectedPrimitive?: SelectedPrimitive | null; + /** Callback when a primitive is selected */ + onSelectPrimitive?: (primitive: SelectedPrimitive | null) => void; + /** Resolved primitive data for detail view */ + resolvedPrimitive?: Primitive | null; + /** Callback to close the primitive detail */ + onClosePrimitive?: () => void; +} -export interface McpPrimitivesPanelProps { - /** MCP Tools from the server */ +/** Legacy API props for backward compatibility with tests */ +export interface McpPrimitivesPanelLegacyProps { + /** MCP Tools from the server (legacy) */ tools: McpTool[]; - /** MCP Resources from the server */ + /** MCP Resources from the server (legacy) */ resources: McpResource[]; - /** MCP Prompts from the server */ + /** MCP Prompts from the server (legacy) */ prompts: McpPrompt[]; /** Whether primitives are still loading */ isLoading: boolean; @@ -35,7 +135,7 @@ export interface McpPrimitivesPanelProps { isCollapsed?: boolean; /** Callback to toggle collapsed state */ onToggleCollapse?: () => void; - /** Panel position affects styling */ + /** Panel position affects styling (legacy) */ position: "center" | "left"; /** Panel width (for resizable left panel) */ panelWidth?: number; @@ -45,6 +145,14 @@ export interface McpPrimitivesPanelProps { isResizing?: boolean; } +/** Combined props - supports both APIs */ +export type McpPrimitivesPanelProps = McpPrimitivesPanelNewProps | McpPrimitivesPanelLegacyProps; + +/** Type guard to check if using legacy API */ +function isLegacyProps(props: McpPrimitivesPanelProps): props is McpPrimitivesPanelLegacyProps { + return "position" in props && "tools" in props; +} + // Font stack (matches styles.ts FONT_SANS) const FONT_SANS = "'Inter', 'SF Pro Display', 'Segoe UI', 'Roboto', -apple-system, BlinkMacSystemFont, sans-serif"; @@ -61,109 +169,98 @@ const localStyles: Record = { overflow: "hidden", transition: "width 0.25s ease, opacity 0.3s ease, transform 0.3s ease", height: "100%", - }, - panelLeft: { width: "320px", flexShrink: 0, borderRight: "1px solid #2d2f2f", }, - panelCenter: { - width: "100%", - height: "100%", - border: "1px solid #2d2f2f", - borderRadius: "8px", - }, - panelCenterAppear: { - animation: "panelAppear 0.4s ease-out forwards", - }, panelCollapsed: { width: 0, borderRight: "none", opacity: 0, }, - header: { + // Header row 1: collapse toggle + title + headerTitle: { display: "flex", alignItems: "center", - justifyContent: "space-between", - padding: "0.75rem 1rem", + gap: "0.5rem", + padding: "0.75rem", backgroundColor: "#0a0a0a", borderBottom: "1px solid #1a1a1a", flexShrink: 0, }, - title: { + headerTitleText: { + fontSize: "0.9375rem", + fontWeight: 600, + color: "#e8e8e8", + letterSpacing: "0.01em", + }, + // Header row 2: search + add button + headerSearch: { + display: "flex", + alignItems: "center", + gap: "0.5rem", + padding: "0.5rem 0.75rem", + backgroundColor: "#0a0a0a", + borderBottom: "1px solid #1a1a1a", + flexShrink: 0, + }, + searchInput: { + flex: 1, + backgroundColor: "#111111", + border: "1px solid #2d2f2f", + borderRadius: "4px", + color: "#e8e8e8", + padding: "0.375rem 0.5rem", fontSize: "0.75rem", - fontWeight: 500, - color: "#9ca3af", - textTransform: "uppercase" as const, - letterSpacing: "0.05em", + fontFamily: "inherit", + outline: "none", }, - collapseBtn: { - background: "transparent", + addButton: { + backgroundColor: "transparent", border: "1px solid #3d4040", borderRadius: "4px", - padding: "0.25rem 0.375rem", + padding: "0.375rem 0.625rem", cursor: "pointer", color: "#9ca3af", - fontSize: "0.625rem", + fontSize: "0.875rem", + fontWeight: 500, display: "flex", alignItems: "center", justifyContent: "center", transition: "all 0.15s ease", - }, - tabs: { - display: "flex", - alignItems: "center", - gap: "0.25rem", - padding: "0.5rem 0.75rem", - backgroundColor: "#0a0a0a", - borderBottom: "1px solid #1a1a1a", flexShrink: 0, }, - tab: { - fontFamily: "inherit", - backgroundColor: "transparent", + collapseBtn: { + background: "transparent", border: "1px solid #3d4040", - color: "#9ca3af", - padding: "0.375rem 0.75rem", borderRadius: "4px", - fontSize: "0.6875rem", + padding: "0.25rem 0.5rem", cursor: "pointer", - transition: "all 0.15s ease", + color: "#9ca3af", + fontSize: "0.75rem", display: "flex", alignItems: "center", - gap: "0.375rem", - }, - tabActive: { - backgroundColor: "rgba(255, 255, 255, 0.15)", - borderColor: "#ffffff", - color: "#ffffff", - }, - tabCount: { - backgroundColor: "rgba(255, 255, 255, 0.1)", - padding: "0.125rem 0.375rem", - borderRadius: "3px", - fontSize: "0.5625rem", - fontWeight: 500, - }, - tabCountActive: { - backgroundColor: "rgba(255, 255, 255, 0.25)", + justifyContent: "center", + transition: "all 0.15s ease", + flexShrink: 0, }, content: { flex: 1, overflowY: "auto", - padding: "0.75rem", - fontSize: "0.75rem", minHeight: 0, + position: "relative" as const, }, emptyState: { display: "flex", + flexDirection: "column", alignItems: "center", justifyContent: "center", height: "100%", - color: "#4b5563", + color: "#9ca3af", fontSize: "0.75rem", padding: "2rem", textAlign: "center" as const, + gap: "0.75rem", }, loadingState: { display: "flex", @@ -182,6 +279,171 @@ const localStyles: Record = { borderRadius: "50%", animation: "spin 0.8s linear infinite", }, + // Server block styles + serverBlock: { + borderBottom: "1px solid #1a1a1a", + }, + serverBlockStopped: { + opacity: 0.5, + }, + serverHeader: { + display: "flex", + alignItems: "center", + padding: "0.5rem 0.75rem", + gap: "0.5rem", + cursor: "pointer", + userSelect: "none" as const, + }, + serverName: { + flex: 1, + fontSize: "0.9375rem", + fontWeight: 600, + color: "#ffffff", + overflow: "hidden", + textOverflow: "ellipsis", + whiteSpace: "nowrap" as const, + }, + serverNameStopped: { + color: "#6b7280", + }, + serverButton: { + backgroundColor: "#ffffff", + border: "none", + borderRadius: "4px", + padding: "0.25rem", + cursor: "pointer", + color: "#000000", + fontSize: "0.875rem", + display: "flex", + alignItems: "center", + justifyContent: "center", + transition: "opacity 0.15s ease", + flexShrink: 0, + opacity: 0.9, + }, + serverButtonHover: { + opacity: 1, + }, + deleteButton: { + backgroundColor: "transparent", + border: "none", + borderRadius: "4px", + padding: "0.25rem", + cursor: "pointer", + color: "#6b7280", + fontSize: "0.875rem", + display: "flex", + alignItems: "center", + justifyContent: "center", + transition: "color 0.15s ease", + flexShrink: 0, + }, + expandIndicator: { + fontSize: "0.5rem", + color: "#6b7280", + flexShrink: 0, + width: "0.75rem", + textAlign: "center" as const, + }, + serverContent: { + paddingBottom: "0.5rem", + }, + stoppedMessage: { + padding: "0.5rem 0.75rem 0.5rem 1.5rem", + fontSize: "0.6875rem", + color: "#9ca3af", + fontStyle: "italic" as const, + }, + // Server info section - horizontal chips + serverInfoContent: { + padding: "0.375rem 0.75rem 0.5rem", + display: "flex", + flexWrap: "wrap" as const, + gap: "0.375rem", + }, + serverInfoChip: { + display: "inline-flex", + alignItems: "center", + gap: "0.25rem", + backgroundColor: "#1f1f1f", + border: "1px solid #2d2f2f", + borderRadius: "12px", + padding: "0.125rem 0.5rem", + fontSize: "0.625rem", + }, + serverInfoLabel: { + color: "#6b7280", + }, + serverInfoValue: { + color: "#e8e8e8", + }, + // Primitive kind section + kindSection: { + paddingLeft: "0.75rem", + }, + kindHeader: { + padding: "0.375rem 0.5rem", + fontSize: "0.6875rem", + fontWeight: 600, + color: "#6b7280", + textTransform: "uppercase" as const, + letterSpacing: "0.05em", + }, + // Primitive item styles - clickable items that open detail view + primitiveItem: { + margin: "0.25rem 0.5rem", + padding: "0.5rem 0.75rem", + cursor: "pointer", + fontSize: "0.875rem", + fontWeight: 500, + color: "#e8e8e8", + display: "flex", + alignItems: "center", + gap: "0.5rem", + transition: "all 0.15s ease", + backgroundColor: "#1a1a1a", + border: "1px solid #2d2f2f", + borderRadius: "6px", + outline: "none", + }, + primitiveItemHover: { + backgroundColor: "#252525", + }, + primitiveItemActive: { + backgroundColor: "#252525", + }, + primitiveName: { + flex: 1, + overflow: "hidden", + textOverflow: "ellipsis", + whiteSpace: "nowrap" as const, + fontFamily: FONT_SANS, + }, + widgetBadge: { + fontSize: "0.5rem", + fontWeight: 600, + color: "#b39ddb", + backgroundColor: "rgba(179, 157, 219, 0.15)", + padding: "0.0625rem 0.25rem", + borderRadius: "2px", + textTransform: "uppercase" as const, + letterSpacing: "0.03em", + flexShrink: 0, + }, + // Resize handle + resizeHandle: { + width: "6px", + background: + "linear-gradient(to right, transparent 2px, #2d2f2f 2px, #2d2f2f 4px, transparent 4px)", + cursor: "ew-resize", + flexShrink: 0, + transition: "background 0.15s ease", + }, + resizeHandleActive: { + background: + "linear-gradient(to right, transparent 2px, #ffffff 2px, #ffffff 4px, transparent 4px)", + }, + // Card styles (for expanded primitive details - kept for compatibility) card: { backgroundColor: "#111111", border: "1px solid #2d2f2f", @@ -203,14 +465,6 @@ const localStyles: Record = { userSelect: "none" as const, gap: "0.5rem", }, - expandIndicator: { - fontSize: "0.5rem", - color: "#6b7280", - flexShrink: 0, - width: "0.75rem", - textAlign: "center" as const, - transition: "color 0.15s ease", - }, cardName: { fontSize: "0.9375rem", fontWeight: 600, @@ -290,30 +544,6 @@ const localStyles: Record = { lineHeight: 1.4, paddingLeft: "0.25rem", }, - // Widget badge for tools with UI - widgetBadge: { - fontSize: "0.5rem", - fontWeight: 600, - color: "#b39ddb", - backgroundColor: "rgba(179, 157, 219, 0.15)", - padding: "0.125rem 0.375rem", - borderRadius: "3px", - textTransform: "uppercase" as const, - letterSpacing: "0.03em", - }, - // Resize handle for left panel - resizeHandle: { - width: "6px", - background: - "linear-gradient(to right, transparent 2px, #2d2f2f 2px, #2d2f2f 4px, transparent 4px)", - cursor: "ew-resize", - flexShrink: 0, - transition: "background 0.15s ease", - }, - resizeHandleActive: { - background: - "linear-gradient(to right, transparent 2px, #ffffff 2px, #ffffff 4px, transparent 4px)", - }, resourceUri: { fontFamily: "'JetBrains Mono', 'Fira Code', 'SF Mono', 'Monaco', 'Inconsolata', 'Roboto Mono', monospace", @@ -334,7 +564,6 @@ const localStyles: Record = { borderRadius: "3px", marginTop: "0.25rem", }, - // Metadata section (annotations, _meta) metaSection: { marginTop: "0.5rem", padding: "0.5rem", @@ -411,15 +640,90 @@ function KeyframeStyles(): React.ReactElement { transform: scale(1) translateY(0); } } + @keyframes slideInFromRight { + from { + opacity: 0; + transform: translateX(8px); + } + to { + opacity: 1; + transform: translateX(0); + } + } + @keyframes slideOutToLeft { + from { + opacity: 1; + transform: translateX(0); + } + to { + opacity: 0; + transform: translateX(-8px); + } + } `} ); } -/** - * Spinner component for loading state - */ -function Spinner(): React.ReactElement { - return
; +/** + * Spinner component for loading state + */ +function Spinner(): React.ReactElement { + return
; +} + +/** + * Slide-over panel for detail view - slides on top of content + */ +function SlideOverDetail({ + children, + isVisible, +}: { + children: React.ReactNode; + isVisible: boolean; +}): React.ReactElement | null { + const [show, setShow] = useState(false); + const [render, setRender] = useState(isVisible); + + useEffect(() => { + if (isVisible) { + setRender(true); + // Trigger slide-in after mount + requestAnimationFrame(() => { + requestAnimationFrame(() => { + setShow(true); + }); + }); + return; // No cleanup needed for enter animation + } else { + setShow(false); + // Wait for slide-out animation + const timer = setTimeout(() => setRender(false), 250); + return () => clearTimeout(timer); + } + }, [isVisible]); + + if (!render) return null; + + return ( +
+ {children} +
+ ); } /** @@ -434,7 +738,6 @@ function CopyButton({ data }: { data: unknown }): React.ReactElement { setCopied(true); setTimeout(() => setCopied(false), 1500); } catch { - // Fallback for browsers that don't support clipboard API const textarea = document.createElement("textarea"); textarea.value = JSON.stringify(data, null, 2); document.body.appendChild(textarea); @@ -530,17 +833,10 @@ function PromptArguments({ args }: { args: McpPromptArgument[] }): React.ReactEl function hasToolUI(tool: McpTool): boolean { const meta = tool._meta; if (!meta) return false; - - // MCP Apps format: _meta.ui.resourceUri const uiMeta = meta.ui as Record | undefined; if (uiMeta?.resourceUri) return true; - - // Alternative MCP format: _meta["ui/resourceUri"] if (meta["ui/resourceUri"]) return true; - - // OpenAI format: _meta["openai/outputTemplate"] if (meta["openai/outputTemplate"]) return true; - return false; } @@ -605,7 +901,6 @@ function MetadataSection({ // Animated Collapse // ============================================================================= -/** Smooth expand/collapse wrapper using max-height transition */ const COLLAPSE_TRANSITION_MS = 200; const COLLAPSE_TRANSITION_CSS = `${COLLAPSE_TRANSITION_MS / 1000}s`; @@ -629,25 +924,20 @@ function AnimatedCollapse({ let timer: ReturnType | undefined; if (isOpen) { - // Keep overflow hidden during the expand transition setOverflow("hidden"); - // Measure content and animate from 0 to its height rafId1 = requestAnimationFrame(() => { const height = el.scrollHeight; setMaxHeight(height > 0 ? `${height}px` : "none"); }); - // After transition completes, switch to none/visible so content can grow timer = setTimeout(() => { setMaxHeight("none"); setOverflow("visible"); }, COLLAPSE_TRANSITION_MS); } else { - // Snap to current measured height, keep overflow hidden setOverflow("hidden"); const height = el.scrollHeight; if (height > 0) { setMaxHeight(`${height}px`); - // Double rAF ensures the browser has painted the explicit height before transitioning to 0 rafId1 = requestAnimationFrame(() => { rafId2 = requestAnimationFrame(() => setMaxHeight("0px")); }); @@ -679,7 +969,384 @@ function AnimatedCollapse({ } // ============================================================================= -// Card Components +// Server Info Helpers +// ============================================================================= + +/** + * Get list of enabled capabilities from capabilities object + */ +function getEnabledCapabilities(capabilities: ServerData["capabilities"] | undefined): string[] { + if (!capabilities) return []; + const enabled: string[] = []; + for (const [key, value] of Object.entries(capabilities)) { + if (value) { + enabled.push(key); + } + } + return enabled; +} + +// ============================================================================= +// Server Block Component +// ============================================================================= + +interface ServerBlockProps { + server: ServerData | StoppedConnection; + isConnected: boolean; + isReconnecting?: boolean; + searchFilter: string; + onStop?: () => void; + onStart?: () => void; + onDelete?: () => void; + selectedPrimitive?: SelectedPrimitive | null; + onSelectPrimitive?: (primitive: SelectedPrimitive | null) => void; +} + +function ServerBlock({ + server, + isConnected, + isReconnecting = false, + searchFilter, + onStop, + onStart, + onDelete, + selectedPrimitive, + onSelectPrimitive, +}: ServerBlockProps): React.ReactElement | null { + const [isExpanded, setIsExpanded] = useState(true); + const [hoveredItem, setHoveredItem] = useState(null); + + // Helper to check if a primitive is selected + const isPrimitiveSelected = useCallback( + (kind: "tool" | "resource" | "prompt", name: string): boolean => { + return ( + selectedPrimitive?.serverId === server.id && + selectedPrimitive?.kind === kind && + selectedPrimitive?.name === name + ); + }, + [selectedPrimitive, server.id] + ); + + // Handler for primitive clicks + const handlePrimitiveClick = useCallback( + (kind: "tool" | "resource" | "prompt", name: string) => { + if (onSelectPrimitive) { + // Toggle selection - if already selected, deselect + if (isPrimitiveSelected(kind, name)) { + onSelectPrimitive(null); + } else { + onSelectPrimitive({ serverId: server.id, kind, name }); + } + } + }, + [onSelectPrimitive, server.id, isPrimitiveSelected] + ); + + // Get primitives (only for connected servers) + const tools = "tools" in server ? server.tools : []; + const resources = "resources" in server ? server.resources : []; + const prompts = "prompts" in server ? server.prompts : []; + + // Filter primitives by search + const q = searchFilter.toLowerCase(); + const filteredTools = q ? tools.filter((t) => t.name.toLowerCase().includes(q)) : tools; + const filteredResources = q + ? resources.filter((r) => r.name.toLowerCase().includes(q)) + : resources; + const filteredPrompts = q ? prompts.filter((p) => p.name.toLowerCase().includes(q)) : prompts; + + // If searching and no matches, hide the server block + if ( + q && + filteredTools.length === 0 && + filteredResources.length === 0 && + filteredPrompts.length === 0 + ) { + return null; + } + + const handleButtonClick = (e: React.MouseEvent) => { + e.stopPropagation(); + if (isConnected && onStop) { + onStop(); + } else if (!isConnected && onStart) { + onStart(); + } + }; + + return ( +
+ {/* Server header */} +
setIsExpanded(!isExpanded)} + role="button" + tabIndex={0} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + setIsExpanded(!isExpanded); + } + }} + aria-expanded={isExpanded} + data-testid={`server-block-header-${server.id}`} + > + + {server.name || server.url} + + + {onDelete && ( + + )} +
+ + {/* Server content */} + + {/* Server info - horizontal chips */} +
+ + status + + {isConnected ? "running" : isReconnecting ? "connecting" : "stopped"} + + + + transport + + {"params" in server && server.params?.transport ? server.params.transport : "—"} + + + {"serverInfo" in server && server.serverInfo?.version && ( + + v + {server.serverInfo.version} + + )} + {"capabilities" in server && getEnabledCapabilities(server.capabilities).length > 0 && ( + + caps + + {getEnabledCapabilities(server.capabilities).join(", ")} + + + )} +
+ + {isConnected ? ( +
+ {/* Tools section */} + {filteredTools.length > 0 && ( +
+
Tools
+ {filteredTools.map((tool) => { + const isSelected = isPrimitiveSelected("tool", tool.name); + return ( +
handlePrimitiveClick("tool", tool.name)} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + handlePrimitiveClick("tool", tool.name); + } + }} + onMouseEnter={() => setHoveredItem(`tool-${tool.name}`)} + onMouseLeave={() => setHoveredItem(null)} + role="button" + tabIndex={0} + aria-selected={isSelected} + data-testid={`tool-item-${tool.name}`} + > + {tool.name} + {hasToolUI(tool) && Widget} +
+ ); + })} +
+ )} + + {/* Resources section */} + {filteredResources.length > 0 && ( +
+
Resources
+ {filteredResources.map((resource) => { + const isSelected = isPrimitiveSelected("resource", resource.name); + return ( +
handlePrimitiveClick("resource", resource.name)} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + handlePrimitiveClick("resource", resource.name); + } + }} + onMouseEnter={() => setHoveredItem(`resource-${resource.uri}`)} + onMouseLeave={() => setHoveredItem(null)} + role="button" + tabIndex={0} + aria-selected={isSelected} + data-testid={`resource-item-${resource.name}`} + > + {resource.name} +
+ ); + })} +
+ )} + + {/* Prompts section */} + {filteredPrompts.length > 0 && ( +
+
Prompts
+ {filteredPrompts.map((prompt) => { + const isSelected = isPrimitiveSelected("prompt", prompt.name); + return ( +
handlePrimitiveClick("prompt", prompt.name)} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + handlePrimitiveClick("prompt", prompt.name); + } + }} + onMouseEnter={() => setHoveredItem(`prompt-${prompt.name}`)} + onMouseLeave={() => setHoveredItem(null)} + role="button" + tabIndex={0} + aria-selected={isSelected} + data-testid={`prompt-item-${prompt.name}`} + > + {prompt.name} +
+ ); + })} +
+ )} + + {/* Empty state when server is connected but has no primitives */} + {filteredTools.length === 0 && + filteredResources.length === 0 && + filteredPrompts.length === 0 && ( +
No primitives available
+ )} +
+ ) : ( +
Server stopped
+ )} +
+
+ ); +} + +// ============================================================================= +// Legacy Tab Type (for backward compatibility) +// ============================================================================= + +type TabType = "tools" | "resources" | "prompts"; + +// ============================================================================= +// Legacy Card Components (for backward compatibility with tests) // ============================================================================= function ToolCard({ tool }: { tool: McpTool }): React.ReactElement { @@ -687,7 +1354,7 @@ function ToolCard({ tool }: { tool: McpTool }): React.ReactElement { const hasUI = hasToolUI(tool); return ( -
+
setIsExpanded((prev) => !prev)} @@ -704,7 +1371,7 @@ function ToolCard({ tool }: { tool: McpTool }): React.ReactElement { >
{isExpanded ? "▼" : "▶"} - {tool.name} + {tool.name} {hasUI && Widget}
@@ -753,7 +1420,7 @@ function ResourceCard({ resource }: { resource: McpResource }): React.ReactEleme const [isExpanded, setIsExpanded] = useState(false); return ( -
+
setIsExpanded((prev) => !prev)} @@ -770,7 +1437,7 @@ function ResourceCard({ resource }: { resource: McpResource }): React.ReactEleme >
{isExpanded ? "▼" : "▶"} - {resource.name} + {resource.name}
@@ -796,7 +1463,7 @@ function PromptCard({ prompt }: { prompt: McpPrompt }): React.ReactElement { const [isExpanded, setIsExpanded] = useState(false); return ( -
+
setIsExpanded((prev) => !prev)} @@ -813,7 +1480,7 @@ function PromptCard({ prompt }: { prompt: McpPrompt }): React.ReactElement { >
{isExpanded ? "▼" : "▶"} - {prompt.name} + {prompt.name}
@@ -831,30 +1498,100 @@ function PromptCard({ prompt }: { prompt: McpPrompt }): React.ReactElement { } // ============================================================================= -// Main Component +// Legacy Panel Styles (for backward compatibility) +// ============================================================================= + +const legacyStyles: Record = { + panelCenter: { + width: "100%", + height: "100%", + border: "1px solid #2d2f2f", + borderRadius: "8px", + }, + panelCenterAppear: { + animation: "panelAppear 0.4s ease-out forwards", + }, + legacyHeader: { + display: "flex", + alignItems: "center", + justifyContent: "space-between", + padding: "0.75rem 1rem", + backgroundColor: "#0a0a0a", + borderBottom: "1px solid #1a1a1a", + flexShrink: 0, + }, + title: { + fontSize: "0.75rem", + fontWeight: 500, + color: "#9ca3af", + textTransform: "uppercase" as const, + letterSpacing: "0.05em", + }, + tabs: { + display: "flex", + alignItems: "center", + gap: "0.25rem", + padding: "0.5rem 0.75rem", + backgroundColor: "#0a0a0a", + borderBottom: "1px solid #1a1a1a", + flexShrink: 0, + }, + tab: { + fontFamily: "inherit", + backgroundColor: "transparent", + border: "1px solid #3d4040", + color: "#9ca3af", + padding: "0.375rem 0.75rem", + borderRadius: "4px", + fontSize: "0.6875rem", + cursor: "pointer", + transition: "all 0.15s ease", + display: "flex", + alignItems: "center", + gap: "0.375rem", + }, + tabActive: { + backgroundColor: "rgba(255, 255, 255, 0.15)", + borderColor: "#ffffff", + color: "#ffffff", + }, + tabCount: { + backgroundColor: "rgba(255, 255, 255, 0.1)", + padding: "0.125rem 0.375rem", + borderRadius: "3px", + fontSize: "0.5625rem", + fontWeight: 500, + }, + tabCountActive: { + backgroundColor: "rgba(255, 255, 255, 0.25)", + }, +}; + +// ============================================================================= +// Legacy Panel Content (for backward compatibility with tests) // ============================================================================= -export function McpPrimitivesPanel({ +function LegacyPanelContent({ tools, resources, prompts, isLoading, isVisible, - isCollapsed = false, + isCollapsed, onToggleCollapse, position, panelWidth, resizeHandleProps, isResizing, -}: McpPrimitivesPanelProps): React.ReactElement { +}: McpPrimitivesPanelLegacyProps): React.ReactElement { const [activeTab, setActiveTab] = useState("tools"); // Build panel styles based on position and visibility const panelStyle: React.CSSProperties = { ...localStyles.panel, - ...(position === "left" ? localStyles.panelLeft : localStyles.panelCenter), + ...(position === "left" ? {} : legacyStyles.panelCenter), ...(position === "left" && !isVisible ? localStyles.panelCollapsed : {}), - ...(position === "center" ? localStyles.panelCenterAppear : {}), + ...(position === "center" ? legacyStyles.panelCenterAppear : {}), ...(position === "left" && panelWidth ? { width: panelWidth } : {}), }; @@ -928,21 +1665,21 @@ export function McpPrimitivesPanel({ <>
-
+
{tabs.map((tab) => ( + )} +
+
+ ); + } + + const hasAnyServer = servers.length > 0 || stoppedConnections.length > 0; + + return ( + <> +
+ + {/* Header row 1: collapse toggle + title */} +
+ {onToggleCollapse && ( + + )} + MCP Explorer +
+ + {/* Header row 2: search + add button */} +
+ setSearchFilter(e.target.value)} + style={localStyles.searchInput} + data-testid="sidebar-search-input" + /> + +
+ + {/* Inline connection form (shown when "+" is clicked) */} + + + {/* Content */} +
+ {/* Show PrimitiveDetail when a primitive is selected */} + {/* Server list - always rendered, detail slides over it */} + {isLoading && servers.length === 0 && stoppedConnections.length === 0 ? ( +
+ + Loading... +
+ ) : !hasAnyServer && !isFormOpen ? ( +
+ No servers connected + +
+ ) : ( + <> + {/* Active servers */} + {servers.map((server) => ( + onStopServer?.(server.id)} + onDelete={() => onDeleteServer?.(server.id, true)} + selectedPrimitive={selectedPrimitive} + onSelectPrimitive={onSelectPrimitive} + /> + ))} + + {/* Stopped servers */} + {stoppedConnections.map((stopped) => ( + onStartServer?.(stopped)} + onDelete={() => onDeleteServer?.(stopped.id, false)} + /> + ))} + + )} + + {/* Slide-over detail panel */} + + {resolvedPrimitive && ( + + )} + +
+
+ + {/* Resize handle */} + {resizeHandleProps && ( +
+ )} + + ); +} + +// ============================================================================= +// Main Component - Supports both Legacy and New APIs +// ============================================================================= + +export function McpPrimitivesPanel(props: McpPrimitivesPanelProps): React.ReactElement { + // Use type guard to determine which API is being used + if (isLegacyProps(props)) { + return ; + } + return ; +} + export default McpPrimitivesPanel; + +// Re-export types for backward compatibility +export type { McpTool, McpResource, McpPrompt }; diff --git a/packages/inspector/src/dashboard/react/components/NoWidgetPlaceholder.tsx b/packages/inspector/src/dashboard/react/components/NoWidgetPlaceholder.tsx index 7697ae4f..535e518a 100644 --- a/packages/inspector/src/dashboard/react/components/NoWidgetPlaceholder.tsx +++ b/packages/inspector/src/dashboard/react/components/NoWidgetPlaceholder.tsx @@ -20,24 +20,8 @@ export interface NoWidgetPlaceholderProps { connectionState: ConnectionState; /** Name of connected agent client (only set when agent-connected) */ clientName?: string; - /** Callback when user clicks Connect Server button */ - onConnect: () => void; } -/** - * Module-level button hover handlers. - * Extracted from inline handlers for memoization — these don't depend on component state. - */ -const handleButtonHover = (e: React.MouseEvent) => { - e.currentTarget.style.opacity = "0.9"; - e.currentTarget.style.transform = "scale(1.02)"; -}; - -const handleButtonLeave = (e: React.MouseEvent) => { - e.currentTarget.style.opacity = "1"; - e.currentTarget.style.transform = "scale(1)"; -}; - /** Local styles for the stepped tutorial layout */ const localStyles: Record = { tagline: { @@ -66,18 +50,6 @@ const localStyles: Record = { color: "#e8e8e8", fontWeight: 600, }, - connectButton: { - backgroundColor: "#ffffff", - color: "#000000", - border: "none", - borderRadius: "8px", - padding: "0.625rem 1.25rem", - fontSize: "0.875rem", - fontWeight: 500, - fontFamily: "inherit", - cursor: "pointer", - transition: "opacity 0.15s ease, transform 0.15s ease", - }, stateContent: { display: "flex", flexDirection: "column" as const, @@ -90,7 +62,6 @@ const localStyles: Record = { export function NoWidgetPlaceholder({ connectionState, clientName, - onConnect, }: NoWidgetPlaceholderProps): React.ReactElement { return (
@@ -105,16 +76,10 @@ export function NoWidgetPlaceholder({ {connectionState === "no-server" && ( <>

Connect the server you want to inspect

- +

+ Use the + button in the sidebar to add a + connection +

)} diff --git a/packages/inspector/src/dashboard/react/components/PrimitiveDetail.tsx b/packages/inspector/src/dashboard/react/components/PrimitiveDetail.tsx new file mode 100644 index 00000000..343a1902 --- /dev/null +++ b/packages/inspector/src/dashboard/react/components/PrimitiveDetail.tsx @@ -0,0 +1,1412 @@ +/** + * PrimitiveDetail Component + * + * Displays detailed information about a selected MCP primitive (tool, resource, or prompt). + * Browse mode shows read-only details; action mode allows execution. + */ + +import React, { useState, useCallback, useEffect } from "react"; +import type { + McpTool, + McpResource, + McpPrompt, + JsonSchemaProperty, + McpPromptArgument, +} from "../types/mcp-primitives"; + +// ============================================================================= +// Types +// ============================================================================= + +/** Union type for all primitive kinds */ +export type Primitive = + | (McpTool & { kind: "tool" }) + | (McpResource & { kind: "resource" }) + | (McpPrompt & { kind: "prompt" }); + +/** Content block in tool/resource results */ +export interface ContentBlock { + type: string; + text?: string; + data?: string; + mimeType?: string; +} + +/** Resource content in read results */ +export interface ResourceContent { + uri: string; + mimeType?: string; + text?: string; + blob?: string; +} + +/** Message in prompt results */ +export interface PromptMessage { + role: "user" | "assistant"; + content: string | ContentBlock[]; +} + +/** Execution result metadata */ +export interface ExecutionMeta { + requestId?: string; + serverName?: string; + duration_ms?: number; + cached?: boolean; + promptName?: string; + [key: string]: unknown; +} + +/** Execution result type */ +export interface ExecutionResult { + ok: boolean; + error?: string; + content?: ContentBlock[]; + contents?: ResourceContent[]; + messages?: PromptMessage[]; + structuredContent?: unknown; + _meta?: ExecutionMeta; +} + +/** Execute function signature */ +export type ExecuteFn = ( + primitive: Primitive, + params: Record +) => Promise; + +/** Props for the PrimitiveDetail component */ +export interface PrimitiveDetailProps { + /** The primitive to display */ + primitive: Primitive; + /** Callback when action button is clicked (browse mode) - for external mode management */ + onAction?: (primitive: Primitive) => void; + /** Callback to execute the primitive - if not provided, mock execution is used */ + onExecute?: ExecuteFn; + /** Callback when back/close is requested */ + onClose?: () => void; +} + +// ============================================================================= +// Mock Data +// ============================================================================= + +const MOCK_TOOL_RESULT: ExecutionResult = { + ok: true, + content: [ + { + type: "text", + text: "# Weekly Sync Notes\n\nAttendees: @alice, @bob\n\n## Updates\n- Project Alpha: on track\n- Project Beta: blocked on API review", + }, + ], + structuredContent: { + page: { + id: "a1b2c3d4", + title: "Weekly Sync Notes", + type: "page", + properties: { Status: "Active", Tags: ["meetings", "weekly"] }, + }, + }, + _meta: { requestId: "req_8f3a2b1c", serverName: "mock-mcp", duration_ms: 243, cached: false }, +}; + +const MOCK_RESOURCE_RESULT: ExecutionResult = { + ok: true, + contents: [ + { + uri: "mock://docs/spec", + mimeType: "text/markdown", + text: "# Enhanced Markdown Spec v2.1\n\n## Callouts\nUse > [!type] syntax\n\n## Toggle Blocks\nWrapped in
tags", + }, + ], + _meta: { requestId: "req_4d7e9f2a", serverName: "mock-mcp", duration_ms: 87, cached: true }, +}; + +const MOCK_PROMPT_RESULT: ExecutionResult = { + ok: true, + messages: [ + { + role: "user", + content: + "Please summarize the following page.\n\nPage URL: https://example.com/page\nStyle: brief", + }, + ], + _meta: { requestId: "req_1c5b8e3d", serverName: "mock-mcp", promptName: "summarize-page" }, +}; + +// ============================================================================= +// Styles +// ============================================================================= + +const FONT_MONO = + "'JetBrains Mono', 'Fira Code', 'SF Mono', 'Monaco', 'Inconsolata', 'Roboto Mono', monospace"; + +const styles: Record = { + container: { + backgroundColor: "#111111", + border: "1px solid #2d2f2f", + borderRadius: "6px", + display: "flex", + flexDirection: "column", + overflow: "hidden", + }, + // Header + header: { + padding: "12px 16px", + borderBottom: "1px solid #2d2f2f", + display: "flex", + alignItems: "center", + gap: "8px", + flexWrap: "wrap", + backgroundColor: "#0d0e0e", + }, + name: { + fontSize: "1rem", + fontWeight: 600, + color: "#e8e8e8", + margin: 0, + }, + tag: { + display: "inline-flex", + alignItems: "center", + border: "1px solid #3d4040", + borderRadius: "3px", + padding: "2px 8px", + fontSize: "0.6875rem", + color: "#9ca3af", + whiteSpace: "nowrap", + }, + tagKind: { + backgroundColor: "rgba(32, 178, 170, 0.15)", + borderColor: "#20b2aa", + color: "#20b2aa", + }, + tagReadOnly: { + backgroundColor: "rgba(96, 165, 250, 0.15)", + borderColor: "#60a5fa", + color: "#60a5fa", + }, + tagIdempotent: { + backgroundColor: "rgba(167, 139, 250, 0.15)", + borderColor: "#a78bfa", + color: "#a78bfa", + }, + tagDestructive: { + backgroundColor: "rgba(239, 68, 68, 0.15)", + borderColor: "#ef4444", + color: "#ef4444", + }, + tagMimeType: { + backgroundColor: "rgba(251, 191, 36, 0.15)", + borderColor: "#fbbf24", + color: "#fbbf24", + }, + tagActionMode: { + backgroundColor: "rgba(34, 197, 94, 0.15)", + borderColor: "#22c55e", + color: "#22c55e", + }, + annotations: { + display: "flex", + gap: "4px", + marginLeft: "auto", + flexWrap: "wrap", + }, + // Body + body: { + padding: "16px", + display: "flex", + flexDirection: "column", + gap: "16px", + flex: 1, + overflowY: "auto", + }, + summary: { + fontSize: "0.8125rem", + lineHeight: 1.6, + color: "#d1d5db", + margin: 0, + }, + // Collapsible description + collapsibleToggle: { + display: "flex", + alignItems: "center", + gap: "6px", + cursor: "pointer", + userSelect: "none", + padding: "4px 0", + fontSize: "0.75rem", + color: "#9ca3af", + border: "none", + background: "none", + textAlign: "left", + }, + collapsibleIcon: { + fontSize: "0.625rem", + width: "12px", + }, + descriptionContent: { + paddingLeft: "18px", + paddingTop: "8px", + }, + descriptionText: { + fontSize: "0.75rem", + lineHeight: 1.6, + color: "#9ca3af", + margin: 0, + }, + descriptionParagraph: { + marginTop: "6px", + }, + // URI section (resources) + section: { + display: "flex", + flexDirection: "column", + gap: "6px", + }, + sectionTitle: { + fontSize: "0.6875rem", + fontWeight: 600, + color: "#6b7280", + textTransform: "uppercase", + letterSpacing: "0.05em", + }, + uriBox: { + fontFamily: FONT_MONO, + fontSize: "0.75rem", + color: "#ce9178", + backgroundColor: "rgba(206, 145, 120, 0.1)", + padding: "8px 12px", + borderRadius: "4px", + wordBreak: "break-all", + border: "1px solid #2d2f2f", + }, + // Parameters section + paramList: { + display: "flex", + flexDirection: "column", + gap: "2px", + }, + paramItem: { + padding: "8px 0", + borderBottom: "1px solid #1a1a1a", + }, + paramItemLast: { + borderBottom: "none", + }, + paramHeader: { + display: "flex", + alignItems: "center", + gap: "8px", + flexWrap: "wrap", + }, + paramName: { + fontFamily: FONT_MONO, + fontSize: "0.8125rem", + color: "#ffffff", + fontWeight: 500, + }, + paramType: { + fontFamily: FONT_MONO, + fontSize: "0.6875rem", + color: "#c4b5fd", + backgroundColor: "rgba(196, 181, 253, 0.1)", + padding: "1px 6px", + borderRadius: "3px", + }, + paramRequired: { + fontSize: "0.6875rem", + color: "#ef9a9a", + backgroundColor: "rgba(239, 154, 154, 0.1)", + padding: "1px 6px", + borderRadius: "3px", + }, + paramOptional: { + fontSize: "0.6875rem", + color: "#9ca3af", + backgroundColor: "rgba(156, 163, 175, 0.1)", + padding: "1px 6px", + borderRadius: "3px", + }, + paramDesc: { + fontSize: "0.6875rem", + color: "#6b7280", + lineHeight: 1.5, + marginTop: "4px", + paddingLeft: "2px", + }, + // Footer + footer: { + padding: "12px 16px", + borderTop: "1px solid #2d2f2f", + display: "flex", + gap: "8px", + backgroundColor: "#0d0e0e", + }, + button: { + fontFamily: "inherit", + fontSize: "0.75rem", + padding: "8px 14px", + borderRadius: "4px", + cursor: "pointer", + transition: "all 0.15s ease", + display: "flex", + alignItems: "center", + gap: "6px", + }, + buttonSecondary: { + backgroundColor: "transparent", + border: "1px solid #3d4040", + color: "#9ca3af", + }, + buttonPrimary: { + backgroundColor: "#ffffff", + border: "1px solid #ffffff", + color: "#000000", + fontWeight: 500, + }, + buttonDisabled: { + backgroundColor: "transparent", + border: "1px solid #3d4040", + color: "#6b7280", + cursor: "not-allowed", + }, + copySuccess: { + borderColor: "#22c55e", + color: "#22c55e", + backgroundColor: "rgba(34, 197, 94, 0.1)", + }, + // Form styles + formField: { + display: "flex", + flexDirection: "column", + gap: "6px", + }, + input: { + fontFamily: FONT_MONO, + fontSize: "0.75rem", + padding: "8px 12px", + backgroundColor: "#1a1a1a", + border: "1px solid #3d4040", + borderRadius: "4px", + color: "#e8e8e8", + outline: "none", + width: "100%", + boxSizing: "border-box", + }, + textarea: { + fontFamily: FONT_MONO, + fontSize: "0.75rem", + padding: "8px 12px", + backgroundColor: "#1a1a1a", + border: "1px solid #3d4040", + borderRadius: "4px", + color: "#e8e8e8", + outline: "none", + width: "100%", + boxSizing: "border-box", + resize: "vertical", + minHeight: "80px", + }, + toggleButton: { + fontFamily: "inherit", + fontSize: "0.75rem", + padding: "8px 16px", + borderRadius: "4px", + cursor: "pointer", + transition: "all 0.15s ease", + border: "1px solid #3d4040", + backgroundColor: "transparent", + color: "#9ca3af", + }, + toggleButtonActive: { + backgroundColor: "rgba(34, 197, 94, 0.15)", + borderColor: "#22c55e", + color: "#22c55e", + }, + // Response panel styles + responsePanel: { + marginTop: "8px", + }, + responsePanelTitle: { + fontSize: "0.6875rem", + fontWeight: 600, + color: "#6b7280", + textTransform: "uppercase", + letterSpacing: "0.05em", + marginBottom: "8px", + }, + responseBox: { + border: "1px solid #2d2f2f", + borderRadius: "4px", + overflow: "hidden", + }, + responseStatus: { + padding: "8px 12px", + borderBottom: "1px solid #2d2f2f", + display: "flex", + alignItems: "center", + gap: "8px", + fontSize: "0.75rem", + }, + statusDot: { + width: "8px", + height: "8px", + borderRadius: "50%", + border: "1px solid #9ca3af", + }, + statusDotSuccess: { + backgroundColor: "transparent", + borderColor: "#22c55e", + }, + statusDotError: { + backgroundColor: "#ef4444", + borderColor: "#ef4444", + }, + statusText: { + color: "#d1d5db", + }, + durationText: { + marginLeft: "auto", + fontSize: "0.6875rem", + color: "#6b7280", + }, + responseSection: { + borderTop: "1px solid #2d2f2f", + }, + responseSectionHeader: { + padding: "8px 12px", + cursor: "pointer", + fontSize: "0.75rem", + display: "flex", + alignItems: "center", + gap: "6px", + backgroundColor: "transparent", + border: "none", + width: "100%", + textAlign: "left", + color: "#d1d5db", + }, + responseSectionTitle: { + fontWeight: 600, + flex: 1, + }, + responseSectionHint: { + fontSize: "0.625rem", + color: "#6b7280", + }, + responseSectionContent: { + padding: "0 12px 12px", + }, + contentBlock: { + marginBottom: "8px", + }, + contentBlockFirst: { + marginBottom: "8px", + }, + preText: { + fontFamily: FONT_MONO, + fontSize: "0.6875rem", + color: "#d1d5db", + whiteSpace: "pre-wrap", + wordBreak: "break-word", + lineHeight: 1.6, + margin: "4px 0 0", + padding: "8px", + backgroundColor: "#0d0e0e", + borderRadius: "4px", + border: "1px solid #1a1a1a", + }, + metaRow: { + display: "flex", + gap: "12px", + fontSize: "0.6875rem", + padding: "2px 0", + color: "#d1d5db", + }, + metaKey: { + minWidth: "100px", + color: "#6b7280", + }, + loadingSpinner: { + display: "inline-flex", + alignItems: "center", + gap: "8px", + color: "#9ca3af", + fontSize: "0.75rem", + }, + // Form footer buttons + formFooter: { + display: "flex", + gap: "8px", + paddingTop: "8px", + }, +}; + +// ============================================================================= +// Helper Functions +// ============================================================================= + +/** + * Format a JSON Schema property type for display + */ +function formatType(prop: JsonSchemaProperty): string { + if (prop.enum) { + const preview = prop.enum.slice(0, 3).join(" | "); + return prop.enum.length > 3 ? `${preview} | ...` : preview; + } + if (prop.type === "array" && prop.items) { + return `${formatType(prop.items)}[]`; + } + return prop.type || "unknown"; +} + +/** + * Get the summary text for a primitive (description or fallback) + */ +function getSummary(primitive: Primitive): string | undefined { + return primitive.description; +} + +/** + * Get the action button label based on primitive kind + */ +function getActionLabel(kind: Primitive["kind"]): { icon: string; label: string } { + switch (kind) { + case "tool": + return { icon: "▶", label: "Run" }; + case "resource": + return { icon: "↓", label: "Read" }; + case "prompt": + return { icon: "→", label: "Use" }; + } +} + +/** + * Get mock result based on primitive kind + */ +function getMockResult(kind: Primitive["kind"]): ExecutionResult { + switch (kind) { + case "tool": + return MOCK_TOOL_RESULT; + case "resource": + return MOCK_RESOURCE_RESULT; + case "prompt": + return MOCK_PROMPT_RESULT; + } +} + +/** + * Determine input type for a parameter based on its JSON Schema type + */ +function getInputType(schemaType: string): "text" | "number" | "boolean" | "object" { + switch (schemaType) { + case "number": + case "integer": + return "number"; + case "boolean": + return "boolean"; + case "object": + case "array": + return "object"; + default: + return "text"; + } +} + +// ============================================================================= +// Sub-Components +// ============================================================================= + +/** Tag component for consistent styling */ +function Tag({ + children, + variant, +}: { + children: React.ReactNode; + variant?: "kind" | "readOnly" | "idempotent" | "destructive" | "mimeType" | "actionMode"; +}): React.ReactElement { + const variantStyles: Record = { + kind: styles.tagKind!, + readOnly: styles.tagReadOnly!, + idempotent: styles.tagIdempotent!, + destructive: styles.tagDestructive!, + mimeType: styles.tagMimeType!, + actionMode: styles.tagActionMode!, + }; + + return ( + {children} + ); +} + +/** Parameters section for tools (browse mode) */ +function ParametersSection({ + properties, + required = [], +}: { + properties: Record; + required?: string[]; +}): React.ReactElement | null { + const entries = Object.entries(properties); + if (entries.length === 0) return null; + + return ( +
+
Parameters
+
+ {entries.map(([name, prop], index) => ( +
+
+ {name} + {formatType(prop)} + + {required.includes(name) ? "required" : "optional"} + +
+ {prop.description &&
{prop.description}
} +
+ ))} +
+
+ ); +} + +/** Arguments section for prompts (browse mode) */ +function ArgumentsSection({ args }: { args: McpPromptArgument[] }): React.ReactElement | null { + if (args.length === 0) return null; + + return ( +
+
Arguments
+
+ {args.map((arg, index) => ( +
+
+ {arg.name} + + {arg.required ? "required" : "optional"} + +
+ {arg.description &&
{arg.description}
} +
+ ))} +
+
+ ); +} + +/** Annotations tags for tools */ +function AnnotationTags({ + annotations, +}: { + annotations: McpTool["annotations"]; +}): React.ReactElement | null { + if (!annotations) return null; + + const tags: React.ReactElement[] = []; + + if (annotations.readOnlyHint) { + tags.push( + + read-only + + ); + } + + if (annotations.idempotentHint) { + tags.push( + + idempotent + + ); + } + + if (annotations.destructiveHint) { + tags.push( + + ⚠ destructive + + ); + } + + if (tags.length === 0) return null; + + return
{tags}
; +} + +/** Copy JSON button with feedback */ +function CopyJsonButton({ data }: { data: unknown }): React.ReactElement { + const [copied, setCopied] = useState(false); + + const handleCopy = useCallback(async () => { + try { + await navigator.clipboard.writeText(JSON.stringify(data, null, 2)); + setCopied(true); + setTimeout(() => setCopied(false), 1500); + } catch { + const textarea = document.createElement("textarea"); + textarea.value = JSON.stringify(data, null, 2); + document.body.appendChild(textarea); + textarea.select(); + document.execCommand("copy"); + document.body.removeChild(textarea); + setCopied(true); + setTimeout(() => setCopied(false), 1500); + } + }, [data]); + + return ( + + ); +} + +/** Loading spinner component */ +function LoadingSpinner({ text }: { text: string }): React.ReactElement { + return ( + + + {text} + + + ); +} + +// ============================================================================= +// Response Panel +// ============================================================================= + +/** Collapsible section within the response panel */ +function ResponseSection({ + label, + defaultOpen, + children, +}: { + label: string; + defaultOpen: boolean; + children: React.ReactNode; +}): React.ReactElement { + const [isOpen, setIsOpen] = useState(defaultOpen); + + return ( +
+ + {isOpen &&
{children}
} +
+ ); +} + +/** Response panel showing execution results */ +function ResponsePanel({ result }: { result: ExecutionResult }): React.ReactElement { + const hasContent = !!(result.content || result.contents || result.messages); + const hasStructured = result.structuredContent !== undefined; + const hasMeta = result._meta !== undefined; + + return ( +
+
Response
+
+ {/* Status row */} +
+ + {result.ok ? "Success" : "Error"} + {result._meta?.duration_ms !== undefined && ( + {result._meta.duration_ms as number}ms + )} +
+ + {/* Error message */} + {result.error !== undefined && !result.ok && ( +
+ {String(result.error)} +
+ )} + + {/* Content section */} + {hasContent && ( + + {/* Tool content blocks */} + {result.content?.map((block, i) => ( +
0 ? styles.contentBlock : styles.contentBlockFirst}> + {block.type} +
{block.text || block.data || ""}
+
+ ))} + + {/* Resource contents */} + {result.contents?.map((item, i) => ( +
0 ? styles.contentBlock : styles.contentBlockFirst}> + {item.mimeType || "unknown"} +
+ {item.uri} +
+
{item.text || ""}
+
+ ))} + + {/* Prompt messages */} + {result.messages?.map((msg, i) => ( +
0 ? styles.contentBlock : styles.contentBlockFirst}> + {msg.role} +
+                  {typeof msg.content === "string"
+                    ? msg.content
+                    : JSON.stringify(msg.content, null, 2)}
+                
+
+ ))} +
+ )} + + {/* Structured content section */} + {hasStructured && ( + +
{JSON.stringify(result.structuredContent, null, 2)}
+
+ )} + + {/* Meta section */} + {hasMeta && result._meta && ( + + {Object.entries(result._meta).map(([key, value]) => ( +
+ {key} + + {typeof value === "object" ? JSON.stringify(value) : String(value as string)} + +
+ ))} +
+ )} +
+
+ ); +} + +// ============================================================================= +// Action Mode Forms +// ============================================================================= + +/** Tool Run Form */ +function ToolRunForm({ + tool, + onExecute, + onClose, +}: { + tool: McpTool & { kind: "tool" }; + onExecute?: ExecuteFn; + onClose: () => void; +}): React.ReactElement { + const properties = tool.inputSchema?.properties || {}; + const required = tool.inputSchema?.required || []; + + // Initialize form values based on parameter types + const [values, setValues] = useState>(() => { + const initial: Record = {}; + for (const [name, prop] of Object.entries(properties)) { + const inputType = getInputType(prop.type); + if (inputType === "boolean") { + initial[name] = false; + } else if (inputType === "number") { + initial[name] = ""; + } else { + initial[name] = ""; + } + } + return initial; + }); + + const [isRunning, setIsRunning] = useState(false); + const [result, setResult] = useState(null); + + const setValue = (name: string, value: unknown) => { + setValues((prev) => ({ ...prev, [name]: value })); + }; + + // Check if all required fields are filled + const isReady = required.every((name) => { + const val = values[name]; + if (val === undefined || val === null || val === "") return false; + return true; + }); + + const handleRun = async () => { + setIsRunning(true); + setResult(null); + + // Convert string values to proper types + const params: Record = {}; + for (const [name, prop] of Object.entries(properties)) { + const val = values[name]; + const inputType = getInputType(prop.type); + + if (val === "" || val === undefined) continue; + + if (inputType === "number" && typeof val === "string") { + params[name] = parseFloat(val); + } else if (inputType === "object" && typeof val === "string") { + try { + params[name] = JSON.parse(val); + } catch { + params[name] = val; // Keep as string if invalid JSON + } + } else { + params[name] = val; + } + } + + if (onExecute) { + try { + const res = await onExecute(tool, params); + setResult(res); + } catch (err) { + setResult({ ok: false, error: String(err) }); + } + } else { + // Mock execution + await new Promise((resolve) => setTimeout(resolve, 500)); + setResult(getMockResult("tool")); + } + + setIsRunning(false); + }; + + return ( +
+ {Object.entries(properties).map(([name, prop]) => { + const inputType = getInputType(prop.type); + const isRequired = required.includes(name); + + return ( +
+
+ {name} + {formatType(prop)} + + {isRequired ? "required" : "optional"} + +
+ {prop.description &&
{prop.description}
} + + {inputType === "boolean" ? ( + + ) : inputType === "object" ? ( +