import React, { useState, useEffect, useCallback, useRef } from 'react' import { format } from 'date-fns' import { FiSearch, FiDownload, FiExternalLink, FiStar, FiCalendar, FiUser, FiTag, FiRefreshCw, FiDatabase, FiClock, FiActivity, FiCheck, FiX } from 'react-icons/fi' import Typography from '../ui/Typography' import { Button } from '../ui/Button' import { Input } from '../ui/input' import { cn } from '../../lib/utils' /** * Utility to keep client-side ordering consistent when merging pages. */ function sortServersByStars(items: MCPRegistryServer[]): MCPRegistryServer[] { const list = [...items] list.sort((a, b) => { const aStars = Number(a.githubStars ?? 0) const bStars = Number(b.githubStars ?? 0) if (aStars !== bStars) return bStars - aStars const aInst = Number(a.installCount ?? 0) const bInst = Number(b.installCount ?? 0) if (aInst !== bInst) return bInst - aInst return a.name.localeCompare(b.name) }) return list } /** * Builds a stable unique key for React lists and dedupe. */ function uniqueKeyForServer(s: MCPRegistryServer): string { return ( s.packageName || s.repository?.url || s.id || s.name ) } /** * Removes duplicate servers based on the unique key. */ function dedupeServers(servers: MCPRegistryServer[]): MCPRegistryServer[] { const map = new Map() for (const s of servers) { const key = uniqueKeyForServer(s) if (!map.has(key)) map.set(key, s) } return Array.from(map.values()) } interface MCPRegistryServer { id: string name: string description: string author?: string version?: string url?: string packageRegistry?: string packageName?: string repository?: { type: string url: string } tags?: string[] license?: string createdAt?: string updatedAt?: string installCount?: number rating?: number githubStars?: number tools?: Array<{ name: string description?: string }> } interface MCPRegistryResponse { servers: MCPRegistryServer[] total?: number hasMore?: boolean page?: number limit?: number } interface MCPRegistryProps { onInstall?: (server: MCPRegistryServer) => void className?: string } interface CacheStats { totalServers: number serversByRegistry: Record cacheEntries: number averageResponseTime: number cacheHitRate: number oldestEntry: string | null newestEntry: string | null } /** * MCP Registry component for discovering and installing servers from public registries */ export const MCPRegistry: React.FC = ({ onInstall, className }) => { const [servers, setServers] = useState([]) const [isLoading, setIsLoading] = useState(false) const [error, setError] = useState(null) const [searchQuery, setSearchQuery] = useState('') const [selectedTags, setSelectedTags] = useState([]) const [selectedCategory, setSelectedCategory] = useState('') const [total, setTotal] = useState(0) const [installingIds, setInstallingIds] = useState>(new Set()) const [installedIds, setInstalledIds] = useState>(new Set()) const [installedServers, setInstalledServers] = useState([]) const [successMessage, setSuccessMessage] = useState(null) const [page, setPage] = useState(0) const [hasMore, setHasMore] = useState(false) const [isLoadingMore, setIsLoadingMore] = useState(false) const [cacheStats, setCacheStats] = useState(null) const [showCacheStats, setShowCacheStats] = useState(false) const [lastSearchTime, setLastSearchTime] = useState(null) const pageSize = 50 const loadMoreRef = useRef(null) const loadCacheStats = useCallback(async () => { if (!window.electron?.getMCPCacheStats) return try { const result = await window.electron.getMCPCacheStats() if (result.success && result.data) { setCacheStats(result.data) } } catch (error) { } }, []) const searchRegistries = useCallback(async (query: string = '', tags: string[] = [], pageNum: number = 0, append: boolean = false) => { if (!window.electron?.searchMCPRegistry) { setError('Registry search not available') return } if (pageNum === 0) { setIsLoading(true) } else { setIsLoadingMore(true) } setError(null) const searchStartTime = Date.now() try { const result = await window.electron.searchMCPRegistry({ query: query.trim() || undefined, tags: tags.length > 0 ? tags : undefined, limit: pageSize, offset: pageNum * pageSize }) const searchTime = Date.now() - searchStartTime setLastSearchTime(searchTime) if (result.success && result.data) { if (append) { setServers(prev => sortServersByStars(dedupeServers([...prev, ...(result.data.servers || [])]))) } else { setServers(sortServersByStars(dedupeServers(result.data.servers || []))) } setTotal(result.data.total || 0) const calculatedHasMore = result.data.hasMore || false setHasMore(calculatedHasMore) setPage(pageNum) loadCacheStats() } else { setError(result.error || 'Failed to search registries') if (!append) { setServers([]) setTotal(0) } } } catch (err) { setError(err instanceof Error ? err.message : 'Failed to search registries') if (!append) { setServers([]) setTotal(0) } } finally { setIsLoading(false) setIsLoadingMore(false) } }, [pageSize, loadCacheStats]) useEffect(() => { searchRegistries('', [], 0, false) loadCacheStats() loadInstalledServers() }, []) const loadInstalledServers = async () => { if (!window.electron?.loadMCPServers) return try { const result = await window.electron.loadMCPServers() if (result.success && result.data) { const serverNames = result.data.map((server: any) => server.name.toLowerCase()) setInstalledServers(serverNames) } } catch (error) { } } const categories = [ { value: '', label: 'All Categories' }, { value: 'ai', label: 'AI/ML' }, { value: 'database', label: 'Database' }, { value: 'development', label: 'Development' }, { value: 'devops', label: 'DevOps' }, { value: 'communication', label: 'Communication' }, { value: 'filesystem', label: 'File System' }, { value: 'api', label: 'API/Integration' }, { value: 'security', label: 'Security' }, { value: 'analytics', label: 'Analytics' }, { value: 'cloud', label: 'Cloud' } ] const getCategoryKeywords = (category: string): string[] => { const keywordMap: Record = { 'ai': ['ai', 'ml', 'llm', 'gpt', 'claude', 'model', 'neural', 'artificial', 'intelligence'], 'database': ['database', 'sql', 'postgres', 'mysql', 'mongodb', 'db', 'sqlite', 'redis'], 'development': ['code', 'developer', 'programming', 'ide', 'editor', 'debug', 'lint'], 'devops': ['docker', 'kubernetes', 'ci/cd', 'deployment', 'monitoring', 'jenkins', 'terraform'], 'communication': ['slack', 'discord', 'email', 'chat', 'messaging', 'notification'], 'filesystem': ['file', 'filesystem', 'directory', 'folder', 'storage'], 'api': ['api', 'rest', 'graphql', 'webhook', 'integration', 'http'], 'security': ['security', 'auth', 'encryption', 'password', 'ssl', 'oauth', 'jwt'], 'analytics': ['analytics', 'metrics', 'monitoring', 'logging', 'tracking', 'telemetry'], 'cloud': ['aws', 'azure', 'gcp', 'cloud', 's3', 'lambda', 'ec2'] } return keywordMap[category] || [] } const filterServersByCategory = (servers: MCPRegistryServer[], category: string) => { if (!category) return servers const keywords = getCategoryKeywords(category) return servers.filter(server => { const text = (server.name + ' ' + server.description).toLowerCase() return keywords.some(keyword => text.includes(keyword)) }) } const handleSearch = (query: string) => { setSearchQuery(query) setPage(0) searchRegistries(query, selectedTags, 0, false) } const handleTagToggle = (tag: string) => { const newTags = selectedTags.includes(tag) ? selectedTags.filter(t => t !== tag) : [...selectedTags, tag] setSelectedTags(newTags) setPage(0) searchRegistries(searchQuery, newTags, 0, false) } const handleCategoryChange = (category: string) => { setSelectedCategory(category) setPage(0) } const loadMore = useCallback(() => { const nextPage = page + 1 searchRegistries(searchQuery, selectedTags, nextPage, true) }, [page, searchQuery, selectedTags, searchRegistries]) useEffect(() => { const currentRef = loadMoreRef.current const computedHasMore = hasMore || (total > servers.length) if (!currentRef || !computedHasMore || isLoadingMore) return const observer = new IntersectionObserver( (entries) => { if (entries[0].isIntersecting && !isLoadingMore && computedHasMore) { loadMore() } }, { threshold: 0.1, rootMargin: '100px' } ) observer.observe(currentRef) return () => { if (currentRef) { observer.unobserve(currentRef) } } }, [hasMore, total, servers.length, isLoadingMore, loadMore]) const handleInstall = async (server: MCPRegistryServer) => { if (!window.electron?.installMCPFromRegistry) { setError('Installation not available') return } if (installedServers.includes(server.name.toLowerCase())) { setError(`${server.name} is already installed`) return } setInstallingIds(prev => new Set(prev).add(server.id)) setError(null) try { const result = await window.electron.installMCPFromRegistry( server.id, server.packageName ) if (result.success) { setInstalledIds(prev => new Set(prev).add(server.id)) setInstalledServers(prev => [...prev, server.name.toLowerCase()]) setSuccessMessage(`${server.name} installed successfully!`) setTimeout(() => { setSuccessMessage(null) }, 5000) setTimeout(() => { setInstalledIds(prev => { const newSet = new Set(prev) newSet.delete(server.id) return newSet }) }, 5000) onInstall?.(server) setTimeout(() => { loadInstalledServers() }, 1000) } else { setError(result.error || 'Failed to install server') } } catch (err) { setError(err instanceof Error ? err.message : 'Failed to install server') } finally { setInstallingIds(prev => { const newSet = new Set(prev) newSet.delete(server.id) return newSet }) } } const availableTags = React.useMemo(() => { const tags = new Set() servers.forEach(server => { server.tags?.forEach(tag => tags.add(tag)) }) return Array.from(tags).sort() }, [servers]) const filteredServers = React.useMemo(() => { return filterServersByCategory(servers, selectedCategory) }, [servers, selectedCategory]) const sortedServers = React.useMemo(() => sortServersByStars(filteredServers), [filteredServers]) return (
handleSearch(e.target.value)} className="pl-10" />
{availableTags.length > 0 && (
Filter by tags: {availableTags.slice(0, 10).map(tag => ( ))}
)} {showCacheStats && cacheStats && (
Cache Statistics
Total Servers {cacheStats.totalServers?.toLocaleString() || '0'}
Cache Entries {cacheStats.cacheEntries?.toLocaleString() || '0'}
Hit Rate {cacheStats.cacheHitRate?.toFixed(1) || '0.0'}%
Avg Response {cacheStats.averageResponseTime ? Math.round(cacheStats.averageResponseTime) : 0}ms
Servers by Registry
{cacheStats.serversByRegistry && Object.entries(cacheStats.serversByRegistry).map(([registry, count]) => ( {registry}: {count?.toLocaleString() || '0'} ))}
{lastSearchTime && (
Last search: {lastSearchTime}ms
)}
)}
{(() => { if (isLoading) return 'Searching...' if (selectedCategory && filteredServers.length < servers.length) { return `Showing ${filteredServers.length} of ${total} servers` } return `Found ${total} server${total !== 1 ? 's' : ''}` })()} {(selectedTags.length > 0 || selectedCategory) && ( )}
{error && (
{error}
)} {successMessage && (
{successMessage}
)} {isLoading ? (
{[...Array(6)].map((_, i) => (
))}
) : filteredServers.length === 0 ? (
{(() => { if (selectedCategory) { const categoryLabel = categories.find(c => c.value === selectedCategory)?.label return `No servers found in the ${categoryLabel} category` } if (searchQuery || selectedTags.length > 0) { return 'No servers found matching your search criteria' } return 'No servers available in the registry' })()}
) : ( <>
{sortedServers.map(server => ( handleInstall(server)} isInstalling={installingIds.has(server.id)} isInstalled={installedIds.has(server.id) || installedServers.includes(server.name.toLowerCase())} /> ))}
{(hasMore || total > servers.length) && (
)} {isLoadingMore && (
Loading more servers...
)} )}
) } interface ServerCardProps { server: MCPRegistryServer onInstall: () => void isInstalling: boolean isInstalled?: boolean } const ServerCard: React.FC = ({ server, onInstall, isInstalling, isInstalled }) => { const formatUpdatedAt = (dateString?: string) => { if (!dateString) return null try { return format(new Date(dateString), 'PP') } catch { return null } } const isInstallable = !!( server.packageName || (server.repository?.url && server.repository.url.includes('github.com')) || server.name ) return (
{isInstalled ? (
) : isInstalling ? (
) : !isInstallable ? (
) : ( )}
{server.name} {server.rating && (
{server.rating.toFixed(1)}
)}
{server.description}
{server.githubStars !== undefined && server.githubStars !== null && (
{server.githubStars.toLocaleString()} stars
)} {server.installCount !== undefined && server.installCount !== null && (
{server.installCount.toLocaleString()} installs
)} {server.author && (
{server.author}
)} {server.version && (
v{server.version}
)} {server.packageRegistry && (
{server.packageRegistry}
)} {formatUpdatedAt(server.updatedAt) && (
{formatUpdatedAt(server.updatedAt)}
)}
{server.tags && server.tags.length > 0 && (
{server.tags.slice(0, 3).map(tag => ( {tag} ))} {server.tags.length > 3 && ( +{server.tags.length - 3} more )}
)} {server.tools && server.tools.length > 0 && (
Available Tools ({server.tools.length})
{server.tools.slice(0, 4).map((tool, index) => ( {tool.name} ))} {server.tools.length > 4 && ( +{server.tools.length - 4} more )}
)} {(server.repository?.url || server.url) && (
)}
) }