import figures from 'figures'; import React, { useCallback, useState } from 'react'; import type { CommandResultDisplay } from '../../commands.js'; import { Box, color, Link, Text, useTheme } from '@anthropic/ink'; import { useKeybindings } from '../../keybindings/useKeybinding.js'; import type { ConfigScope } from '../../services/mcp/types.js'; import { describeMcpConfigFilePath } from '../../services/mcp/utils.js'; import { isDebugMode } from '../../utils/debug.js'; import { plural } from '../../utils/stringUtils.js'; import { ConfigurableShortcutHint } from '../ConfigurableShortcutHint.js'; import { Byline, Dialog, KeyboardShortcutHint } from '@anthropic/ink'; import { McpParsingWarnings } from './McpParsingWarnings.js'; import type { AgentMcpServerInfo, ServerInfo } from './types.js'; type Props = { servers: ServerInfo[]; agentServers?: AgentMcpServerInfo[]; onSelectServer: (server: ServerInfo) => void; onSelectAgentServer?: (agentServer: AgentMcpServerInfo) => void; onComplete: (result?: string, options?: { display?: CommandResultDisplay }) => void; defaultTab?: string; }; type SelectableItem = | { type: 'server'; server: ServerInfo } | { type: 'agent-server'; agentServer: AgentMcpServerInfo }; // Define scope order for display (constant, outside component) // 'dynamic' (built-in) is rendered separately at the end const SCOPE_ORDER: ConfigScope[] = ['project', 'local', 'user', 'enterprise']; // Get scope heading parts (label is bold, path is grey) function getScopeHeading(scope: ConfigScope): { label: string; path?: string } { switch (scope) { case 'project': return { label: 'Project MCPs', path: describeMcpConfigFilePath(scope) }; case 'user': return { label: 'User MCPs', path: describeMcpConfigFilePath(scope) }; case 'local': return { label: 'Local MCPs', path: describeMcpConfigFilePath(scope) }; case 'enterprise': return { label: 'Enterprise MCPs' }; case 'dynamic': return { label: 'Built-in MCPs', path: 'always available' }; default: return { label: scope }; } } // Group servers by scope function groupServersByScope(serverList: ServerInfo[]): Map { const groups = new Map(); for (const server of serverList) { const scope = server.scope; if (!groups.has(scope)) { groups.set(scope, []); } groups.get(scope)!.push(server); } // Sort servers within each group alphabetically for (const [, groupServers] of groups) { groupServers.sort((a, b) => a.name.localeCompare(b.name)); } return groups; } export function MCPListPanel({ servers, agentServers = [], onSelectServer, onSelectAgentServer, onComplete, }: Props): React.ReactNode { const [theme] = useTheme(); const [selectedIndex, setSelectedIndex] = useState(0); // Non-claudeai servers grouped by scope const serversByScope = React.useMemo(() => { const regularServers = servers.filter(s => s.client.config.type !== 'claudeai-proxy'); return groupServersByScope(regularServers); }, [servers]); const claudeAiServers = React.useMemo( () => servers.filter(s => s.client.config.type === 'claudeai-proxy').sort((a, b) => a.name.localeCompare(b.name)), [servers], ); // Built-in (dynamic) servers - rendered last const dynamicServers = React.useMemo( () => (serversByScope.get('dynamic') ?? []).sort((a, b) => a.name.localeCompare(b.name)), [serversByScope], ); // Pre-compute dynamic heading for render const dynamicHeading = getScopeHeading('dynamic'); // Build flat list of selectable items in display order const selectableItems = React.useMemo(() => { const items: SelectableItem[] = []; for (const scope of SCOPE_ORDER) { const scopeServers = serversByScope.get(scope) ?? []; for (const server of scopeServers) { items.push({ type: 'server', server }); } } for (const server of claudeAiServers) { items.push({ type: 'server', server }); } for (const agentServer of agentServers) { items.push({ type: 'agent-server', agentServer }); } // Dynamic (built-in) servers come last for (const server of dynamicServers) { items.push({ type: 'server', server }); } return items; }, [serversByScope, claudeAiServers, agentServers, dynamicServers]); const handleCancel = useCallback((): void => { onComplete('MCP dialog dismissed', { display: 'system', }); }, [onComplete]); const handleSelect = useCallback((): void => { const item = selectableItems[selectedIndex]; if (!item) return; if (item.type === 'server') { onSelectServer(item.server); } else if (item.type === 'agent-server' && onSelectAgentServer) { onSelectAgentServer(item.agentServer); } }, [selectableItems, selectedIndex, onSelectServer, onSelectAgentServer]); // Use configurable keybindings for navigation and selection useKeybindings( { 'confirm:previous': () => setSelectedIndex(prev => (prev === 0 ? selectableItems.length - 1 : prev - 1)), 'confirm:next': () => setSelectedIndex(prev => (prev === selectableItems.length - 1 ? 0 : prev + 1)), 'confirm:yes': handleSelect, 'confirm:no': handleCancel, }, { context: 'Confirmation' }, ); // Build index lookup for each server const getServerIndex = (server: ServerInfo): number => { return selectableItems.findIndex(item => item.type === 'server' && item.server === server); }; const getAgentServerIndex = (agentServer: AgentMcpServerInfo): number => { return selectableItems.findIndex(item => item.type === 'agent-server' && item.agentServer === agentServer); }; const debugMode = isDebugMode(); const hasFailedClients = servers.some(s => s.client.type === 'failed'); if (servers.length === 0 && agentServers.length === 0) { return null; } const renderServerItem = (server: ServerInfo): React.ReactNode => { const index = getServerIndex(server); const isSelected = selectedIndex === index; let statusIcon = ''; let statusText = ''; if (server.client.type === 'disabled') { statusIcon = color('inactive', theme)(figures.radioOff); statusText = 'disabled'; } else if (server.client.type === 'connected') { statusIcon = color('success', theme)(figures.tick); statusText = 'connected'; } else if (server.client.type === 'pending') { statusIcon = color('inactive', theme)(figures.radioOff); const { reconnectAttempt, maxReconnectAttempts } = server.client; if (reconnectAttempt && maxReconnectAttempts) { statusText = `reconnecting (${reconnectAttempt}/${maxReconnectAttempts})…`; } else { statusText = 'connecting…'; } } else if (server.client.type === 'needs-auth') { statusIcon = color('warning', theme)(figures.triangleUpOutline); statusText = 'needs authentication'; } else { statusIcon = color('error', theme)(figures.cross); statusText = 'failed (may be unavailable)'; } return ( {isSelected ? `${figures.pointer} ` : ' '} {server.name} · {statusIcon} {statusText} ); }; const renderAgentServerItem = (agentServer: AgentMcpServerInfo): React.ReactNode => { const index = getAgentServerIndex(agentServer); const isSelected = selectedIndex === index; const statusIcon = agentServer.needsAuth ? color('warning', theme)(figures.triangleUpOutline) : color('inactive', theme)(figures.radioOff); const statusText = agentServer.needsAuth ? 'may need auth' : 'agent-only'; return ( {isSelected ? `${figures.pointer} ` : ' '} {agentServer.name} · {statusIcon} {statusText} ); }; const totalServers = servers.length + agentServers.length; return ( {/* Regular servers grouped by scope */} {SCOPE_ORDER.map(scope => { const scopeServers = serversByScope.get(scope); if (!scopeServers || scopeServers.length === 0) return null; const heading = getScopeHeading(scope); return ( {heading.label} {heading.path && ({heading.path})} {scopeServers.map(server => renderServerItem(server))} ); })} {/* Claude.ai servers section */} {claudeAiServers.length > 0 && ( claude.ai {claudeAiServers.map(server => renderServerItem(server))} )} {/* Agent servers section - grouped by source agent */} {agentServers.length > 0 && ( Agent MCPs {/* Group servers by source agent */} {[...new Set(agentServers.flatMap(s => s.sourceAgents))].map(agentName => ( @{agentName} {agentServers .filter(s => s.sourceAgents.includes(agentName)) .map(agentServer => renderAgentServerItem(agentServer))} ))} )} {/* Built-in (dynamic) servers section - always last */} {dynamicServers.length > 0 && ( {dynamicHeading.label} {dynamicHeading.path && ({dynamicHeading.path})} {dynamicServers.map(server => renderServerItem(server))} )} {/* Footer info */} {hasFailedClients && ( {'※ Unavailable servers cannot be used until reconnected'} {debugMode ? '※ Error logs shown inline with --debug' : '※ Run claude --debug to see error logs'} )} https://costrict.ai/docs/en/mcp for help {/* Custom footer with navigation hint */} ); }