/** * Top-level MCP Servers tab. * * Unified table view: shows both installed servers and registry catalog * results in a single table. Filter chips at the top let users toggle * between "All", "Installed", and "Registry" views. */ import debug from 'debug'; import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useT } from '../../../lib/i18n/I18nContext'; import { mcpClientsApi } from '../../../services/api/mcpClientsApi'; import { openUrl } from '../../../utils/openUrl'; import ChipTabs from '../../layout/ChipTabs'; import Button from '../../ui/Button'; import InstallDialog from './InstallDialog'; import InstalledServerDetail from './InstalledServerDetail'; import McpConnectionHealthToolbar from './McpConnectionHealthToolbar'; import McpInventoryPanel from './McpInventoryPanel'; import { deriveAuthor } from './McpServerCard'; import type { ConnStatus, InstalledServer, ServerStatus, SmitheryServer } from './types'; const log = debug('mcp-clients:tab'); const POLL_INTERVAL_MS = 5_000; const DEBOUNCE_MS = 300; const PAGE_SIZE = 30; type View = | { mode: 'home' } | { mode: 'detail'; serverId: string } | { mode: 'install'; qualifiedName: string; prefillEnv?: Record }; type FilterChip = 'all' | 'installed' | 'registry'; /** * Collapse catalog entries to a single row per `qualified_name`. The registry * can return the same server within a page or across paginated "load more" * fetches; without this the unified table renders duplicate rows and React * collides on the `catalog-` key. First occurrence wins so the * earliest (highest-ranked) result is the one kept. */ const dedupeByQualifiedName = (servers: SmitheryServer[]): SmitheryServer[] => { const seen = new Set(); const out: SmitheryServer[] = []; for (const server of servers) { if (seen.has(server.qualified_name)) continue; seen.add(server.qualified_name); out.push(server); } return out; }; /** * Collapse installed servers to one row per `qualified_name`. Install is * idempotent in the core now, but pre-existing double-installs can linger on * disk; the first occurrence (earliest install) is kept. */ const dedupeInstalledByQualifiedName = (servers: InstalledServer[]): InstalledServer[] => { const seen = new Set(); const out: InstalledServer[] = []; for (const server of servers) { if (seen.has(server.qualified_name)) continue; seen.add(server.qualified_name); out.push(server); } return out; }; const STATUS_DOT: Record = { connected: 'bg-sage-500', connecting: 'bg-amber-400', disconnected: 'bg-surface-strong', unauthorized: 'bg-amber-500', error: 'bg-coral-500', disabled: 'bg-surface-strong', }; /** Transport classification a catalog row can be filtered by. */ type Transport = 'hosted' | 'stdio'; /** * Classify a catalog row by how it runs: `hosted` = reachable over an HTTP * endpoint (cloud-run); `stdio` = installed and run on-device as a subprocess. * `is_deployed` is set by the registry adapter when the server exposes a remote. */ const transportOf = (server: SmitheryServer): Transport => server.is_deployed ? 'hosted' : 'stdio'; /** * Derive a browsable source-repository URL from the registry slug. The official * registry namespaces community servers as `io.github./` (and * `io.gitlab./…`), which maps 1:1 to a repo page. Returns `null` for * vendor reverse-DNS slugs that don't encode a code host. */ export const deriveRepoUrl = (qualifiedName: string): string | null => { const slash = qualifiedName.indexOf('/'); if (slash < 1) return null; const prefix = qualifiedName.slice(0, slash); const repo = qualifiedName.slice(slash + 1); if (!repo) return null; if (prefix.startsWith('io.github.')) { return `https://github.com/${prefix.slice('io.github.'.length)}/${repo}`; } if (prefix.startsWith('io.gitlab.')) { return `https://gitlab.com/${prefix.slice('io.gitlab.'.length)}/${repo}`; } return null; }; /** Transport pill (Stdio vs Hosted) — the catalog's primary classification. */ const TransportBadge = ({ transport }: { transport: Transport }) => { const { t } = useT(); const hosted = transport === 'hosted'; return ( {t(hosted ? 'mcp.tab.transport.hosted' : 'mcp.tab.transport.local')} ); }; /** * External link that opens in the system browser. Stops propagation so clicking * a server's website/repo never also triggers the row's install action. */ const ExternalLink = ({ href, label }: { href: string; label: string }) => ( ); /** * One catalog (registry) row. Memoized so the 5s status poll — which re-renders * the parent tab — doesn't re-render the (potentially large) catalog list: the * catalog data and the install handler are stable across polls, so memo skips * every row. This is the main lever against the "rendering is slow" report. */ const CatalogRow = memo( ({ server, onInstall, }: { server: SmitheryServer; onInstall: (qualifiedName: string) => void; }) => { const { t } = useT(); const repoUrl = deriveRepoUrl(server.qualified_name); const author = deriveAuthor(server.qualified_name); return ( onInstall(server.qualified_name)} onKeyDown={e => { // Only act on keys aimed at the row itself — Enter/Space bubble up // from the nested Website/Repository buttons, which must not open // the install flow. if (e.target !== e.currentTarget) return; if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onInstall(server.qualified_name); } }}>
{server.icon_url ? ( ) : ( 🔌 )}
{server.display_name} {server.official && ( ✓ {t('mcp.tab.officialBadge')} )} {/* The registry is full of look-alike names (a dozen "gmail" servers); the slug is the unique identifier that tells them apart. */} {server.qualified_name} {server.description && ( {server.description} )} {(server.website_url || repoUrl) && ( {server.website_url && ( )} {repoUrl && } )}
{author ?? '—'} {t('mcp.install.button')} ); } ); CatalogRow.displayName = 'CatalogRow'; const McpServersTab = () => { const { t } = useT(); const [servers, setServers] = useState([]); const [statuses, setStatuses] = useState([]); const [loading, setLoading] = useState(true); const [loadError, setLoadError] = useState(null); const [view, setView] = useState({ mode: 'home' }); const [inventoryOpen, setInventoryOpen] = useState(false); // Unified search + filter const [searchQuery, setSearchQuery] = useState(''); const [activeChip, setActiveChip] = useState('all'); // Secondary classification filter over catalog rows: by transport. const [transportFilter, setTransportFilter] = useState<'all' | Transport>('all'); // Registry catalog results const [catalogServers, setCatalogServers] = useState([]); const [catalogLoading, setCatalogLoading] = useState(false); const [catalogPage, setCatalogPage] = useState(1); const [catalogTotalPages, setCatalogTotalPages] = useState(1); // Set when a registry fetch fails so the Registry view shows an error state // (with retry) instead of silently falling back to an empty/stale catalog. const [catalogError, setCatalogError] = useState(false); const pollTimerRef = useRef | null>(null); const debounceRef = useRef | null>(null); const requestSeqRef = useRef(0); const loadInstalled = useCallback(async () => { log('loading installed servers'); try { const installed = await mcpClientsApi.installedList(); setServers(Array.isArray(installed) ? installed : []); setLoadError(null); } catch (err) { const msg = err instanceof Error ? err.message : 'Failed to load installed servers'; setLoadError(msg); } }, []); const fetchStatuses = useCallback(async () => { try { const sv = await mcpClientsApi.status(); setStatuses(Array.isArray(sv) ? sv : []); } catch (err) { log('status poll error: %o', err); } }, []); const fetchCatalog = useCallback( async (query: string, transport: 'all' | Transport, page: number, append: boolean) => { const seq = ++requestSeqRef.current; setCatalogLoading(true); try { const result = await mcpClientsApi.registrySearch({ query: query || undefined, transport: transport === 'all' ? undefined : transport, page, page_size: PAGE_SIZE, }); if (seq !== requestSeqRef.current) return; const incoming = result.servers ?? []; setCatalogServers(prev => dedupeByQualifiedName(append ? [...prev, ...incoming] : incoming) ); setCatalogPage(result.page); setCatalogTotalPages(result.total_pages); setCatalogError(false); } catch (err) { if (seq !== requestSeqRef.current) return; log('catalog fetch error: %o', err); // A fresh (non-append) fetch that fails leaves no usable rows — surface // the error. A failed "load more" keeps the rows already shown. if (!append) setCatalogError(true); } finally { if (seq === requestSeqRef.current) setCatalogLoading(false); } }, [] ); useEffect(() => { Promise.all([loadInstalled(), fetchStatuses()]).finally(() => setLoading(false)); }, [loadInstalled, fetchStatuses]); // Fetch catalog (page 1) on mount and whenever the query or transport filter // changes. Search + transport now run in the core over the cached full // catalog, so changing either re-queries from the top. useEffect(() => { if (debounceRef.current) clearTimeout(debounceRef.current); debounceRef.current = setTimeout(() => { void fetchCatalog(searchQuery, transportFilter, 1, false); }, DEBOUNCE_MS); return () => { if (debounceRef.current) clearTimeout(debounceRef.current); }; }, [searchQuery, transportFilter, fetchCatalog]); // Poll status useEffect(() => { // Poll while anything is in a non-terminal state — not just `connected`. // An `unauthorized`/`error`/`connecting` server can transition (the // background reconnect supervisor, a completed OAuth sign-in, an expiring // token) and the UI must reflect that without a manual refresh (#3719 RC5). const hasActive = statuses.some( s => s.status === 'connected' || s.status === 'connecting' || s.status === 'unauthorized' || s.status === 'error' ); if (!hasActive) { if (pollTimerRef.current) { clearTimeout(pollTimerRef.current); pollTimerRef.current = null; } return; } const schedule = () => { pollTimerRef.current = setTimeout(async () => { await fetchStatuses(); schedule(); }, POLL_INTERVAL_MS); }; schedule(); return () => { if (pollTimerRef.current) { clearTimeout(pollTimerRef.current); pollTimerRef.current = null; } }; }, [statuses, fetchStatuses]); const handleSelectServer = useCallback((serverId: string) => { setView({ mode: 'detail', serverId }); }, []); const handleSelectInstall = useCallback((qualifiedName: string) => { setView({ mode: 'install', qualifiedName }); }, []); const handleInstallSuccess = useCallback( async (server: InstalledServer) => { await loadInstalled(); await fetchStatuses(); setView({ mode: 'detail', serverId: server.server_id }); }, [loadInstalled, fetchStatuses] ); const handleUninstalled = useCallback( async (_serverId: string) => { await loadInstalled(); await fetchStatuses(); setView({ mode: 'home' }); }, [loadInstalled, fetchStatuses] ); const handleEnabledChange = useCallback( async (_serverId: string, _enabled: boolean) => { await loadInstalled(); await fetchStatuses(); }, [loadInstalled, fetchStatuses] ); const handleLoadMore = () => { void fetchCatalog(searchQuery, transportFilter, catalogPage + 1, true); }; // Bulk lifecycle actions for the health toolbar. One failure doesn't abort the // batch (allSettled), and we always refresh status so the dots reflect reality // — but if any call rejected we then throw so the toolbar can surface the // failure (otherwise a partial/total failure would look like success). const handleReconnectAll = useCallback( async (serverIds: string[]) => { log('reconnect all: %o', serverIds); const results = await Promise.allSettled(serverIds.map(id => mcpClientsApi.connect(id))); await fetchStatuses(); if (results.some(r => r.status === 'rejected')) { throw new Error(t('mcp.health.opErrorGeneric')); } }, [fetchStatuses, t] ); const handleDisconnectAll = useCallback( async (serverIds: string[]) => { log('disconnect all: %o', serverIds); const results = await Promise.allSettled(serverIds.map(id => mcpClientsApi.disconnect(id))); await fetchStatuses(); if (results.some(r => r.status === 'rejected')) { throw new Error(t('mcp.health.opErrorGeneric')); } }, [fetchStatuses, t] ); const selectedServer = view.mode === 'detail' ? (servers.find(s => s.server_id === view.serverId) ?? null) : null; const selectedConnStatus = view.mode === 'detail' ? statuses.find(s => s.server_id === view.serverId) : undefined; // One installed row per service. Install is idempotent server-side now, but // legacy double-installs can still exist on disk; collapse them by // qualified_name (servers arrive earliest-first) so the list stays "one per // thing". Raw `servers` is kept for server_id-keyed detail/status lookups. // Memoized so the 5s status poll doesn't rebuild + refilter the list. const filteredInstalled = useMemo(() => { const view = dedupeInstalledByQualifiedName(servers); const q = searchQuery.trim().toLowerCase(); if (!q) return view; return view.filter( s => s.display_name.toLowerCase().includes(q) || s.qualified_name.toLowerCase().includes(q) || (s.description ?? '').toLowerCase().includes(q) ); }, [servers, searchQuery]); // Catalog rows minus already-installed servers. Search + transport filtering // now happen in the core over the cached full catalog (so relevance and // pagination are accurate); the only thing left to do client-side is hide // servers the user already installed. Memoized so the status poll doesn't // recompute it. const availableCatalog = useMemo(() => { const installedNames = new Set(servers.map(s => s.qualified_name)); return catalogServers.filter(s => !installedNames.has(s.qualified_name)); }, [catalogServers, servers]); const showRegistry = activeChip === 'all' || activeChip === 'registry'; // Render catalog rows from memoized data so they survive parent re-renders // (the status poll) untouched — `CatalogRow` is itself memoized. const catalogRows = useMemo( () => availableCatalog.map(server => ( )), [availableCatalog, handleSelectInstall] ); const statusMap = new Map(statuses.map(s => [s.server_id, s])); if (loading) { return (
{t('mcp.tab.loading')}
); } // Detail view if (view.mode === 'detail' && selectedServer) { return (
void handleUninstalled(serverId)} onEnabledChange={(serverId, enabled) => void handleEnabledChange(serverId, enabled)} />
); } // Install view — InstallDialog renders its own "← Go back", so the tab does // NOT add a second one here (that was the duplicate back button). if (view.mode === 'install') { return (
void handleInstallSuccess(server)} onCancel={() => setView({ mode: 'home' })} />
); } // Home view — unified table return (
{/* Search + filter chips */}
setSearchQuery(e.target.value)} placeholder={t('mcp.catalog.searchPlaceholder')} aria-label={t('mcp.catalog.searchAria')} className="flex-1 rounded-lg border border-line bg-surface px-3 py-2 text-sm text-content placeholder:text-stone-400 dark:placeholder:text-neutral-500 focus:outline-none focus:ring-2 focus:ring-primary-500/40" />
{/* Filters on one bar: the scope (All / Installed / Registry) chips, then — when registry rows are visible — a labelled transport filter rendered as Stdio/Hosted TOGGLES (no second "All" chip; deselecting both means all). This avoids the duplicate-"All" confusion. */}
className="flex flex-wrap items-center gap-2" value={activeChip} onChange={setActiveChip} items={[ { id: 'all', label: t('mcp.tab.filter.all') }, { id: 'installed', label: t('mcp.tab.filter.installed').replace( '{count}', String(filteredInstalled.length) ), }, { id: 'registry', label: t('mcp.tab.filter.registry') }, ]} /> {showRegistry && ( <>
{loadError && (
{loadError}
)} {/* Connection health + bulk lifecycle actions. Only meaningful once servers are installed; surfaces "Retry all" for error-state servers (the failed-connection retry affordance #4272 asks for) and "Disconnect all". Reads the polled statuses — no extra fetches. */} {(activeChip === 'all' || activeChip === 'installed') && statuses.length > 0 && ( )} {/* Table — horizontally scrollable so the Source/Author/Action columns aren't clipped when the panel is narrower than the table's natural width (the wrapper was `overflow-hidden`, which cut them off with no way to scroll). `min-w` keeps the columns readable rather than crushing them. */}
{/* Installed servers */} {(activeChip === 'all' || activeChip === 'installed') && filteredInstalled.map(server => { const status: ServerStatus = statusMap.get(server.server_id)?.status ?? 'disconnected'; return ( handleSelectServer(server.server_id)} onKeyDown={e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); handleSelectServer(server.server_id); } }}> ); })} {/* Registry servers — official first, then the registry's relevance order. Each row shows its transport, website/repo links, and the real auth surfaces on install. */} {showRegistry && catalogRows}
{t('mcp.tab.column.name')} {t('mcp.tab.column.type')} {t('mcp.tab.column.author')} {t('mcp.tab.column.action')}
{server.display_name} {server.description && ( {server.description} )}
{deriveAuthor(server.qualified_name) ?? '—'} {t('mcp.tab.action.manage')}
{/* Registry fetch error — takes precedence over the empty state so a failed load reads as an error (with retry), not "no results". */} {showRegistry && catalogError && !catalogLoading && (

{t('mcp.catalog.loadFailed')}

)} {/* Empty states */} {activeChip === 'installed' && filteredInstalled.length === 0 && (
{t('mcp.installed.empty')}
)} {activeChip === 'registry' && availableCatalog.length === 0 && !catalogLoading && !catalogError && (
{searchQuery ? t('mcp.catalog.noResultsFor').replace('{query}', searchQuery) : t('mcp.catalog.noResults')}
)} {activeChip === 'all' && filteredInstalled.length === 0 && availableCatalog.length === 0 && !catalogLoading && !catalogError && (
{searchQuery ? t('mcp.catalog.noResultsFor').replace('{query}', searchQuery) : t('mcp.catalog.noResults')}
)} {/* Loading / load more */} {catalogLoading && (
{t('common.loading')}
)} {!catalogLoading && catalogPage < catalogTotalPages && (activeChip === 'all' || activeChip === 'registry') && (
)}
{inventoryOpen && ( { setInventoryOpen(false); setView({ mode: 'install', qualifiedName, prefillEnv }); }} onClose={() => setInventoryOpen(false)} /> )}
); }; export default McpServersTab;