import React, { useState, useEffect, useMemo, useCallback } from 'react'; import { Button } from './ui/button'; import { X, ListChecks, RefreshCw, ChevronDown, Trash2, RotateCw, Plus, Search, } from 'lucide-react'; import { cn } from '@/lib/utils'; import type { ServerRegistryEntry } from '@dexto/registry'; import type { McpServerConfig } from '@dexto/core'; import { serverRegistry } from '@/lib/serverRegistry'; import { buildConfigFromRegistryEntry, hasEmptyOrPlaceholderValue } from '@/lib/serverConfig'; import ServerRegistryModal from './ServerRegistryModal'; import { Tooltip, TooltipTrigger, TooltipContent } from './ui/tooltip'; import { useAnalytics } from '@/lib/analytics/index.js'; import { useServers, useAddServer, useDeleteServer, useRestartServer } from './hooks/useServers'; import { useAllTools, type ToolInfo } from './hooks/useTools'; import { useAgentPath } from './hooks/useAgents'; interface ServersPanelProps { isOpen: boolean; onClose: () => void; onOpenConnectModal: () => void; onOpenConnectWithPrefill?: (opts: { name: string; config: Partial & { type?: 'stdio' | 'sse' | 'http' }; lockName?: boolean; registryEntryId?: string; onCloseRegistryModal?: () => void; }) => void; onServerConnected?: (serverName: string) => void; variant?: 'overlay' | 'inline'; refreshTrigger?: number; // Add a trigger to force refresh } // Utility function to strip tool name prefixes (internal--, custom--, mcp--serverName--) function stripToolPrefix(toolName: string, source: 'internal' | 'custom' | 'mcp'): string { if (source === 'internal' && toolName.startsWith('internal--')) { return toolName.replace('internal--', ''); } if (source === 'custom' && toolName.startsWith('custom--')) { return toolName.replace('custom--', ''); } if (source === 'mcp' && toolName.startsWith('mcp--')) { // Format: mcp--serverName--toolName -> extract toolName const parts = toolName.split('--'); if (parts.length >= 3) { return parts.slice(2).join('--'); // Join remaining parts in case tool name has '--' } // Fallback: if format is different, just remove 'mcp--' return toolName.replace('mcp--', ''); } return toolName; } export default function ServersPanel({ isOpen, onClose, onOpenConnectModal, onOpenConnectWithPrefill, onServerConnected, variant: variantProp, refreshTrigger, }: ServersPanelProps) { const variant: 'overlay' | 'inline' = variantProp ?? 'overlay'; const analytics = useAnalytics(); const [isRegistryModalOpen, setIsRegistryModalOpen] = useState(false); const [isRegistryBusy, setIsRegistryBusy] = useState(false); const [searchQuery, setSearchQuery] = useState(''); const [expandedToolId, setExpandedToolId] = useState(null); const [collapsedSections, setCollapsedSections] = useState>(new Set()); // Use TanStack Query hooks const { data: servers = [], isLoading: isLoadingServers, refetch: refetchServers, } = useServers(isOpen); const addServerMutation = useAddServer(); const deleteServerMutation = useDeleteServer(); const restartServerMutation = useRestartServer(); // Fetch all tools from all sources (internal, custom, MCP) const { data: allToolsData, isLoading: isLoadingAllTools, refetch: refetchTools, } = useAllTools(isOpen); // Track agent path for auto-refresh on agent switch const { data: agentPath } = useAgentPath(); // Unified refresh function const handleRefresh = useCallback(() => { refetchServers(); refetchTools(); }, [refetchServers, refetchTools]); // Toggle section collapse const toggleSection = useCallback((sectionTitle: string) => { setCollapsedSections((prev) => { const next = new Set(prev); if (next.has(sectionTitle)) { next.delete(sectionTitle); } else { next.add(sectionTitle); } return next; }); }, []); // Group tools by source with server info const toolsBySource = useMemo(() => { if (!allToolsData) return { internal: [], custom: [], mcp: new Map< string, { tools: ToolInfo[]; server: { id: string; name: string; status: string } | null; } >(), }; const internal: ToolInfo[] = []; const custom: ToolInfo[] = []; const mcp = new Map< string, { tools: ToolInfo[]; server: { id: string; name: string; status: string } | null } >(); allToolsData.tools.forEach((tool: ToolInfo) => { if (tool.source === 'internal') { internal.push(tool); } else if (tool.source === 'custom') { custom.push(tool); } else if (tool.source === 'mcp' && tool.serverName) { const existing = mcp.get(tool.serverName) || { tools: [], server: null }; existing.tools.push(tool); // Try to find the actual server if (!existing.server) { const server = servers.find( (s: { id: string; name: string }) => s.name === tool.serverName ); existing.server = server || null; } mcp.set(tool.serverName, existing); } }); return { internal, custom, mcp }; }, [allToolsData, servers]); // Filter tools based on search query and create sections const filteredSections = useMemo(() => { const sections: Array<{ title: string; tools: ToolInfo[]; type: 'internal' | 'custom' | 'mcp'; server?: { id: string; name: string; status: string } | null; }> = []; const query = searchQuery.toLowerCase(); // Internal tools section if (toolsBySource.internal.length > 0) { const filtered = searchQuery ? toolsBySource.internal.filter( (tool) => tool.name.toLowerCase().includes(query) || tool.description?.toLowerCase().includes(query) ) : toolsBySource.internal; if (filtered.length > 0) { sections.push({ title: 'Internal', tools: filtered, type: 'internal' }); } } // Custom tools section if (toolsBySource.custom.length > 0) { const filtered = searchQuery ? toolsBySource.custom.filter( (tool) => tool.name.toLowerCase().includes(query) || tool.description?.toLowerCase().includes(query) ) : toolsBySource.custom; if (filtered.length > 0) { sections.push({ title: 'Custom', tools: filtered, type: 'custom' }); } } // MCP server sections toolsBySource.mcp.forEach(({ tools, server }, serverName) => { const serverMatches = serverName.toLowerCase().includes(query); const filtered = searchQuery ? serverMatches ? tools : tools.filter( (tool) => tool.name.toLowerCase().includes(query) || tool.description?.toLowerCase().includes(query) ) : tools; if (filtered.length > 0) { sections.push({ title: serverName, tools: filtered, type: 'mcp', server, }); } }); return sections; }, [toolsBySource, searchQuery]); // Calculate total tool count const totalToolCount = allToolsData?.totalCount || 0; const handleInstallServer = async ( entry: ServerRegistryEntry ): Promise<'connected' | 'requires-input'> => { const config = buildConfigFromRegistryEntry(entry); const needsEnvInput = config.type === 'stdio' && Object.keys(config.env || {}).length > 0 && hasEmptyOrPlaceholderValue(config.env || {}); const needsHeaderInput = (config.type === 'sse' || config.type === 'http') && Object.keys(config.headers || {}).length > 0 && hasEmptyOrPlaceholderValue(config.headers || {}); // If additional input is needed, show the modal if (needsEnvInput || needsHeaderInput) { if (typeof onOpenConnectWithPrefill === 'function') { onOpenConnectWithPrefill({ name: entry.name, config, lockName: true, registryEntryId: entry.id, onCloseRegistryModal: () => setIsRegistryModalOpen(false), }); } return 'requires-input'; } // Otherwise, connect directly try { setIsRegistryBusy(true); await addServerMutation.mutateAsync({ name: entry.name, config, persistToAgent: false, }); // Sync registry after installation try { await serverRegistry.syncWithServerStatus(); } catch (e) { console.warn('Failed to sync registry after server install:', e); } // Track MCP server connection analytics.trackMCPServerConnected({ serverName: entry.name, transportType: config.type as 'stdio' | 'http' | 'sse', }); setIsRegistryModalOpen(false); onServerConnected?.(entry.name); return 'connected'; } catch (error: any) { throw new Error(error.message || 'Failed to install server'); } finally { setIsRegistryBusy(false); } }; const handleDeleteServer = async (serverId: string) => { const server = servers.find((s: { id: string; name: string }) => s.id === serverId); if (!server) return; if (!window.confirm(`Are you sure you want to remove server "${server.name}"?`)) { return; } try { await deleteServerMutation.mutateAsync(serverId); // Mark corresponding registry entry as uninstalled try { const normalize = (s: string) => s.toLowerCase().replace(/[^a-z0-9]+/g, '-'); const currentId = normalize(serverId); const entries = await serverRegistry.getEntries(); const match = entries.find((e) => { const aliases = [e.id, e.name, ...(e.matchIds || [])] .filter(Boolean) .map((x) => normalize(String(x))); return aliases.includes(currentId); }); if (match) { await serverRegistry.setInstalled(match.id, false); } } catch (e) { console.warn('Failed to update registry installed state on delete:', e); } // Sync registry with updated server status try { await serverRegistry.syncWithServerStatus(); } catch (e) { console.warn('Failed to sync registry status after server deletion:', e); } } catch (err: any) { console.error('Delete server error:', err); } }; const handleRestartServer = async (serverId: string) => { const server = servers.find((s: { id: string; name: string }) => s.id === serverId); if (!server) return; if (!window.confirm(`Restart server "${server.name}"?`)) { return; } try { await restartServerMutation.mutateAsync(serverId); // Sync registry with updated server status try { await serverRegistry.syncWithServerStatus(); } catch (e) { console.warn('Failed to sync registry status after server restart:', e); } } catch (err: any) { console.error('Restart server error:', err); } }; // Auto-refresh on panel open, agent switch, or external trigger // Consolidated from three separate useEffect hooks to prevent redundant fetches useEffect(() => { if (isOpen) { handleRefresh(); } }, [isOpen, agentPath, refreshTrigger, handleRefresh]); // For inline variant, just return the content wrapped if (variant === 'inline') { return ( ); } // Overlay variant with slide animation return ( <> {/* Backdrop */}
{/* Panel - slides from right */} ); }