diff --git a/packages/cli/src/ui/components/mcp/MCPManagementDialog.tsx b/packages/cli/src/ui/components/mcp/MCPManagementDialog.tsx index 179d86e2d..6c2124dbd 100644 --- a/packages/cli/src/ui/components/mcp/MCPManagementDialog.tsx +++ b/packages/cli/src/ui/components/mcp/MCPManagementDialog.tsx @@ -17,8 +17,10 @@ import type { import { MCP_MANAGEMENT_STEPS } from './types.js'; import { ServerListStep } from './steps/ServerListStep.js'; import { ServerDetailStep } from './steps/ServerDetailStep.js'; +import { ServerLogsStep } from './steps/ServerLogsStep.js'; import { ToolListStep } from './steps/ToolListStep.js'; import { ToolDetailStep } from './steps/ToolDetailStep.js'; +import { DisableScopeSelectStep } from './steps/DisableScopeSelectStep.js'; import { useConfig } from '../../contexts/ConfigContext.js'; import { getMCPServerStatus, @@ -27,6 +29,7 @@ import { type AnyDeclarativeTool, type DiscoveredMCPPrompt, } from '@qwen-code/qwen-code-core'; +import { loadSettings, SettingScope } from '../../../config/settings.js'; export const MCPManagementDialog: React.FC = ({ onClose, @@ -54,6 +57,13 @@ export const MCPManagementDialog: React.FC = ({ const toolRegistry = config.getToolRegistry(); const promptRegistry = await config.getPromptRegistry(); + // 获取 settings 以确定每个服务器的 scope + const settings = loadSettings(); + const userSettings = settings.forScope(SettingScope.User).settings; + const workspaceSettings = settings.forScope( + SettingScope.Workspace, + ).settings; + const serverInfos: MCPServerDisplayInfo[] = []; for (const [name, serverConfig] of Object.entries(mcpServers) as Array< @@ -76,20 +86,34 @@ export const MCPManagementDialog: React.FC = ({ (p) => 'serverName' in p && p.serverName === name, ); - // 确定来源 + // 确定来源类型 let source: 'user' | 'project' | 'extension' = 'user'; if (serverConfig.extensionName) { source = 'extension'; } - // TODO: 区分user和project来源需要更详细的配置信息 + + // 确定配置所在的 scope + let scope: 'user' | 'workspace' | 'extension' = 'user'; + if (serverConfig.extensionName) { + scope = 'extension'; + } else if (workspaceSettings.mcpServers?.[name]) { + scope = 'workspace'; + } else if (userSettings.mcpServers?.[name]) { + scope = 'user'; + } + + // 使用 config.isMcpServerDisabled() 检查服务器是否被禁用 + const isDisabled = config.isMcpServerDisabled(name); serverInfos.push({ name, status, source, + scope, config: serverConfig, toolCount: serverTools.length, promptCount: serverPrompts.length, + isDisabled, }); } @@ -169,6 +193,11 @@ export const MCPManagementDialog: React.FC = ({ handleNavigateToStep(MCP_MANAGEMENT_STEPS.TOOL_LIST); }, [handleNavigateToStep]); + // 查看服务器日志 + const handleViewLogs = useCallback(() => { + handleNavigateToStep(MCP_MANAGEMENT_STEPS.SERVER_LOGS); + }, [handleNavigateToStep]); + // 选择工具 const handleSelectTool = useCallback( (tool: MCPToolDisplayInfo) => { @@ -178,77 +207,203 @@ export const MCPManagementDialog: React.FC = ({ [handleNavigateToStep], ); + // 重新加载服务器数据 + const reloadServers = useCallback(async () => { + if (!config) return; + + setIsLoading(true); + try { + const mcpServers = config.getMcpServers() || {}; + const toolRegistry = config.getToolRegistry(); + const promptRegistry = await config.getPromptRegistry(); + + // 获取 settings 以确定每个服务器的 scope + const settings = loadSettings(); + const userSettings = settings.forScope(SettingScope.User).settings; + const workspaceSettings = settings.forScope( + SettingScope.Workspace, + ).settings; + + const serverInfos: MCPServerDisplayInfo[] = []; + + for (const [name, serverConfig] of Object.entries(mcpServers) as Array< + [string, MCPServerConfig] + >) { + const status = getMCPServerStatus(name); + + const allTools: AnyDeclarativeTool[] = + toolRegistry?.getAllTools() || []; + const serverTools = allTools.filter( + (t): t is DiscoveredMCPTool => + t instanceof DiscoveredMCPTool && t.serverName === name, + ); + + const allPrompts: DiscoveredMCPPrompt[] = + promptRegistry?.getAllPrompts() || []; + const serverPrompts = allPrompts.filter( + (p) => 'serverName' in p && p.serverName === name, + ); + + // 确定来源类型 + let source: 'user' | 'project' | 'extension' = 'user'; + if (serverConfig.extensionName) { + source = 'extension'; + } + + // 确定配置所在的 scope + let scope: 'user' | 'workspace' | 'extension' = 'user'; + if (serverConfig.extensionName) { + scope = 'extension'; + } else if (workspaceSettings.mcpServers?.[name]) { + scope = 'workspace'; + } else if (userSettings.mcpServers?.[name]) { + scope = 'user'; + } + + // 使用 config.isMcpServerDisabled() 检查服务器是否被禁用 + const isDisabled = config.isMcpServerDisabled(name); + + serverInfos.push({ + name, + status, + source, + scope, + config: serverConfig, + toolCount: serverTools.length, + promptCount: serverPrompts.length, + isDisabled, + }); + } + + setServers(serverInfos); + } finally { + setIsLoading(false); + } + }, [config]); + // 重新连接服务器 const handleReconnect = useCallback(async () => { if (!config || !selectedServer) return; try { + setIsLoading(true); const toolRegistry = config.getToolRegistry(); if (toolRegistry) { await toolRegistry.discoverToolsForServer(selectedServer.name); } // 重新加载服务器数据以更新状态 - const loadServers = async () => { - setIsLoading(true); - try { - const mcpServers = config.getMcpServers() || {}; - const toolRegistry = config.getToolRegistry(); - const promptRegistry = await config.getPromptRegistry(); - - const serverInfos: MCPServerDisplayInfo[] = []; - - for (const [name, serverConfig] of Object.entries( - mcpServers, - ) as Array<[string, MCPServerConfig]>) { - const status = getMCPServerStatus(name); - - const allTools: AnyDeclarativeTool[] = - toolRegistry?.getAllTools() || []; - const serverTools = allTools.filter( - (t): t is DiscoveredMCPTool => - t instanceof DiscoveredMCPTool && t.serverName === name, - ); - - const allPrompts: DiscoveredMCPPrompt[] = - promptRegistry?.getAllPrompts() || []; - const serverPrompts = allPrompts.filter( - (p) => 'serverName' in p && p.serverName === name, - ); - - let source: 'user' | 'project' | 'extension' = 'user'; - if (serverConfig.extensionName) { - source = 'extension'; - } - - serverInfos.push({ - name, - status, - source, - config: serverConfig, - toolCount: serverTools.length, - promptCount: serverPrompts.length, - }); - } - - setServers(serverInfos); - } finally { - setIsLoading(false); - } - }; - await loadServers(); + await reloadServers(); } catch (_error) { // 错误处理 - 静默失败 + } finally { + setIsLoading(false); } - }, [config, selectedServer]); + }, [config, selectedServer, reloadServers]); - // 禁用服务器 - const handleDisable = useCallback(async () => { + // 启用服务器 + const handleEnableServer = useCallback(async () => { if (!config || !selectedServer) return; - // TODO: 实现禁用服务器的逻辑 - // 这需要修改配置文件,暂时返回到服务器列表 - handleNavigateBack(); - }, [config, selectedServer, handleNavigateBack]); + try { + setIsLoading(true); + + const server = selectedServer; + const settings = loadSettings(); + + // 从 user 和 workspace 的排除列表中移除 + for (const scope of [SettingScope.User, SettingScope.Workspace]) { + const scopeSettings = settings.forScope(scope).settings; + const currentExcluded = scopeSettings.mcp?.excluded || []; + + if (currentExcluded.includes(server.name)) { + const newExcluded = currentExcluded.filter( + (name: string) => name !== server.name, + ); + settings.setValue(scope, 'mcp.excluded', newExcluded); + } + } + + // 更新运行时配置的排除列表 + const currentExcluded = config.getExcludedMcpServers() || []; + const newExcluded = currentExcluded.filter( + (name: string) => name !== server.name, + ); + config.setExcludedMcpServers(newExcluded); + + // 重新发现该服务器的工具 + const toolRegistry = config.getToolRegistry(); + if (toolRegistry) { + await toolRegistry.discoverToolsForServer(server.name); + } + + // 重新加载服务器列表 + await reloadServers(); + } catch (_error) { + // 错误处理 - 静默失败 + } finally { + setIsLoading(false); + } + }, [config, selectedServer, reloadServers]); + + // 处理禁用/启用操作 + const handleDisable = useCallback(() => { + if (!selectedServer) return; + + // 如果服务器已被禁用,则直接启用 + if (selectedServer.isDisabled) { + void handleEnableServer(); + } else { + // 否则导航到禁用 scope 选择 + handleNavigateToStep(MCP_MANAGEMENT_STEPS.DISABLE_SCOPE_SELECT); + } + }, [selectedServer, handleEnableServer, handleNavigateToStep]); + + // 选择禁用 scope 后执行禁用 + const handleSelectDisableScope = useCallback( + async (scope: 'user' | 'workspace') => { + if (!config || !selectedServer) return; + + try { + setIsLoading(true); + + const server = selectedServer; + const settings = loadSettings(); + + // 获取当前的排除列表 + const scopeSettings = settings.forScope( + scope === 'user' ? SettingScope.User : SettingScope.Workspace, + ).settings; + const currentExcluded = scopeSettings.mcp?.excluded || []; + + // 如果服务器不在排除列表中,添加它 + if (!currentExcluded.includes(server.name)) { + const newExcluded = [...currentExcluded, server.name]; + settings.setValue( + scope === 'user' ? SettingScope.User : SettingScope.Workspace, + 'mcp.excluded', + newExcluded, + ); + } + + // 使用新的 disableMcpServer 方法禁用服务器 + const toolRegistry = config.getToolRegistry(); + if (toolRegistry) { + await toolRegistry.disableMcpServer(server.name); + } + + // 重新加载服务器列表 + await reloadServers(); + + // 返回到服务器详情页 + handleNavigateBack(); + } catch (_error) { + // 错误处理 - 静默失败 + } finally { + setIsLoading(false); + } + }, + [config, selectedServer, handleNavigateBack, reloadServers], + ); // 渲染步骤头部 const renderStepHeader = useCallback(() => { @@ -262,6 +417,12 @@ export const MCPManagementDialog: React.FC = ({ case MCP_MANAGEMENT_STEPS.SERVER_DETAIL: headerText = selectedServer?.name || t('Server Detail'); break; + case MCP_MANAGEMENT_STEPS.DISABLE_SCOPE_SELECT: + headerText = t('Disable Server'); + break; + case MCP_MANAGEMENT_STEPS.SERVER_LOGS: + headerText = t('Server Logs'); + break; case MCP_MANAGEMENT_STEPS.TOOL_LIST: headerText = t('Tools'); break; @@ -302,12 +463,27 @@ export const MCPManagementDialog: React.FC = ({ ); + case MCP_MANAGEMENT_STEPS.DISABLE_SCOPE_SELECT: + return ( + + ); + + case MCP_MANAGEMENT_STEPS.SERVER_LOGS: + return ( + + ); + case MCP_MANAGEMENT_STEPS.TOOL_LIST: return ( = ({ selectedTool, handleSelectServer, handleViewTools, + handleViewLogs, handleReconnect, handleDisable, handleNavigateBack, handleSelectTool, + handleSelectDisableScope, getServerTools, ]); @@ -361,6 +539,12 @@ export const MCPManagementDialog: React.FC = ({ case MCP_MANAGEMENT_STEPS.SERVER_DETAIL: footerText = t('↑↓ to navigate · Enter to select · Esc to back'); break; + case MCP_MANAGEMENT_STEPS.DISABLE_SCOPE_SELECT: + footerText = t('↑↓ to navigate · Enter to confirm · Esc to back'); + break; + case MCP_MANAGEMENT_STEPS.SERVER_LOGS: + footerText = t('↑↓ to navigate · M to pause/resume · Q/Esc to back'); + break; case MCP_MANAGEMENT_STEPS.TOOL_LIST: footerText = t('↑↓ to navigate · Enter to select · Esc to back'); break; diff --git a/packages/cli/src/ui/components/mcp/index.ts b/packages/cli/src/ui/components/mcp/index.ts index 01ebfee8f..435ef469d 100644 --- a/packages/cli/src/ui/components/mcp/index.ts +++ b/packages/cli/src/ui/components/mcp/index.ts @@ -10,6 +10,7 @@ export { MCPManagementDialog } from './MCPManagementDialog.js'; // Steps export { ServerListStep } from './steps/ServerListStep.js'; export { ServerDetailStep } from './steps/ServerDetailStep.js'; +export { ServerLogsStep } from './steps/ServerLogsStep.js'; export { ToolListStep } from './steps/ToolListStep.js'; export { ToolDetailStep } from './steps/ToolDetailStep.js'; @@ -21,6 +22,7 @@ export type { MCPPromptDisplayInfo, ServerListStepProps, ServerDetailStepProps, + ServerLogsStepProps, ToolListStepProps, ToolDetailStepProps, MCPManagementStep, diff --git a/packages/cli/src/ui/components/mcp/steps/DisableScopeSelectStep.tsx b/packages/cli/src/ui/components/mcp/steps/DisableScopeSelectStep.tsx new file mode 100644 index 000000000..3c97ccfd1 --- /dev/null +++ b/packages/cli/src/ui/components/mcp/steps/DisableScopeSelectStep.tsx @@ -0,0 +1,88 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +import { useState } from 'react'; +import { Box, Text } from 'ink'; +import { theme } from '../../../semantic-colors.js'; +import { useKeypress } from '../../../hooks/useKeypress.js'; +import { RadioButtonSelect } from '../../shared/RadioButtonSelect.js'; +import { t } from '../../../../i18n/index.js'; +import type { DisableScopeSelectStepProps } from '../types.js'; + +export const DisableScopeSelectStep: React.FC = ({ + server, + onSelectScope, + onBack, +}) => { + const [selectedScope, setSelectedScope] = useState<'user' | 'workspace'>( + 'user', + ); + + const scopes = [ + { + key: 'user', + get label() { + return t('User Settings (global)'); + }, + value: 'user' as const, + }, + { + key: 'workspace', + get label() { + return t('Workspace Settings (project-specific)'); + }, + value: 'workspace' as const, + }, + ]; + + useKeypress( + (key) => { + if (key.name === 'escape') { + onBack(); + } else if (key.name === 'return') { + onSelectScope(selectedScope); + } + }, + { isActive: true }, + ); + + if (!server) { + return ( + + {t('No server selected')} + + ); + } + + return ( + + + + {t('Disable server:')} {server.name} + + + + {t('Select where to add the server to the exclude list:')} + + + + + + + items={scopes} + onHighlight={(value: 'user' | 'workspace') => setSelectedScope(value)} + onSelect={(value: 'user' | 'workspace') => onSelectScope(value)} + /> + + + + + {t('Press Enter to confirm, Esc to cancel')} + + + + ); +}; diff --git a/packages/cli/src/ui/components/mcp/steps/ServerDetailStep.tsx b/packages/cli/src/ui/components/mcp/steps/ServerDetailStep.tsx index fc34d1f51..3645f92f3 100644 --- a/packages/cli/src/ui/components/mcp/steps/ServerDetailStep.tsx +++ b/packages/cli/src/ui/components/mcp/steps/ServerDetailStep.tsx @@ -17,11 +17,12 @@ import { formatServerCommand, } from '../utils.js'; -type ServerAction = 'view-tools' | 'reconnect' | 'disable'; +type ServerAction = 'view-tools' | 'view-logs' | 'reconnect' | 'toggle-disable'; export const ServerDetailStep: React.FC = ({ server, onViewTools, + onViewLogs, onReconnect, onDisable, onBack, @@ -39,6 +40,13 @@ export const ServerDetailStep: React.FC = ({ }, value: 'view-tools' as const, }, + { + key: 'view-logs', + get label() { + return t('View logs'); + }, + value: 'view-logs' as const, + }, { key: 'reconnect', get label() { @@ -47,11 +55,11 @@ export const ServerDetailStep: React.FC = ({ value: 'reconnect' as const, }, { - key: 'disable', + key: 'toggle-disable', get label() { - return t('Disable'); + return server?.isDisabled ? t('Enable') : t('Disable'); }, - value: 'disable' as const, + value: 'toggle-disable' as const, }, ]; @@ -64,10 +72,13 @@ export const ServerDetailStep: React.FC = ({ case 'view-tools': onViewTools(); break; + case 'view-logs': + onViewLogs?.(); + break; case 'reconnect': onReconnect?.(); break; - case 'disable': + case 'toggle-disable': onDisable?.(); break; default: @@ -103,6 +114,22 @@ export const ServerDetailStep: React.FC = ({ } > {getStatusIcon(server.status)} {t(server.status)} + {server.isDisabled && ( + (disabled) + )} + + + + + + {t('Source:')} + + + {server.scope === 'user' + ? t('User Settings') + : server.scope === 'workspace' + ? t('Workspace Settings') + : t('Extension')} @@ -166,10 +193,13 @@ export const ServerDetailStep: React.FC = ({ case 'view-tools': onViewTools(); break; + case 'view-logs': + onViewLogs?.(); + break; case 'reconnect': onReconnect?.(); break; - case 'disable': + case 'toggle-disable': onDisable?.(); break; default: diff --git a/packages/cli/src/ui/components/mcp/steps/ServerListStep.tsx b/packages/cli/src/ui/components/mcp/steps/ServerListStep.tsx index 53e03ac59..eec88e854 100644 --- a/packages/cli/src/ui/components/mcp/steps/ServerListStep.tsx +++ b/packages/cli/src/ui/components/mcp/steps/ServerListStep.tsx @@ -149,6 +149,11 @@ export const ServerListStep: React.FC = ({ {' '} {t(server.status)} + {/* 显示 Scope 和禁用状态 */} + [{server.scope}] + {server.isDisabled && ( + (disabled) + )} ); })} diff --git a/packages/cli/src/ui/components/mcp/steps/ServerLogsStep.tsx b/packages/cli/src/ui/components/mcp/steps/ServerLogsStep.tsx new file mode 100644 index 000000000..20f063a82 --- /dev/null +++ b/packages/cli/src/ui/components/mcp/steps/ServerLogsStep.tsx @@ -0,0 +1,243 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +import { useState, useEffect, useCallback } from 'react'; +import { Box, Text } from 'ink'; +import { theme } from '../../../semantic-colors.js'; +import { useKeypress } from '../../../hooks/useKeypress.js'; +import { t } from '../../../../i18n/index.js'; +import type { ServerLogsStepProps } from '../types.js'; +import { getStatusColor, getStatusIcon } from '../utils.js'; +import { MCPServerStatus, getMCPServerStatus } from '@qwen-code/qwen-code-core'; + +// 模拟日志条目类型 +interface LogEntry { + timestamp: string; + level: 'info' | 'warn' | 'error' | 'debug'; + message: string; +} + +export const ServerLogsStep: React.FC = ({ + server, + onBack, +}) => { + const [logs, setLogs] = useState([]); + const [isMonitoring, setIsMonitoring] = useState(true); + const [selectedIndex, setSelectedIndex] = useState(0); + + // 生成模拟日志数据 + const generateMockLogs = useCallback((): LogEntry[] => { + const now = new Date(); + const baseLogs: LogEntry[] = [ + { + timestamp: new Date(now.getTime() - 5000).toISOString(), + level: 'info', + message: `MCP server '${server?.name}' initializing...`, + }, + { + timestamp: new Date(now.getTime() - 4000).toISOString(), + level: 'info', + message: 'Connecting to transport...', + }, + { + timestamp: new Date(now.getTime() - 3000).toISOString(), + level: server?.status === MCPServerStatus.CONNECTED ? 'info' : 'error', + message: + server?.status === MCPServerStatus.CONNECTED + ? 'Connection established successfully' + : 'Connection failed: ' + (server?.errorMessage || 'Unknown error'), + }, + ]; + + if (server?.status === MCPServerStatus.CONNECTED) { + baseLogs.push( + { + timestamp: new Date(now.getTime() - 2000).toISOString(), + level: 'info', + message: `Discovered ${server.toolCount} tools`, + }, + { + timestamp: new Date(now.getTime() - 1000).toISOString(), + level: 'info', + message: `Discovered ${server.promptCount} prompts`, + }, + { + timestamp: now.toISOString(), + level: 'info', + message: 'Server ready for requests', + }, + ); + } + + return baseLogs; + }, [server]); + + // 初始化日志 + useEffect(() => { + setLogs(generateMockLogs()); + }, [generateMockLogs]); + + // 模拟实时日志更新 + useEffect(() => { + if (!isMonitoring) return; + + const interval = setInterval(() => { + const currentStatus = server?.name + ? getMCPServerStatus(server.name) + : null; + + // 如果状态变化,添加日志 + if (currentStatus && currentStatus !== server?.status) { + const newLog: LogEntry = { + timestamp: new Date().toISOString(), + level: currentStatus === MCPServerStatus.CONNECTED ? 'info' : 'warn', + message: `Server status changed to: ${currentStatus}`, + }; + setLogs((prev) => [...prev.slice(-49), newLog]); // 保留最近50条 + } + }, 5000); + + return () => clearInterval(interval); + }, [isMonitoring, server]); + + // 键盘处理 + useKeypress( + (key) => { + if (key.name === 'escape' || key.name === 'q') { + onBack(); + } else if (key.name === 'up') { + setSelectedIndex((prev) => Math.max(0, prev - 1)); + } else if (key.name === 'down') { + setSelectedIndex((prev) => Math.min(logs.length - 1, prev + 1)); + } else if (key.name === 'm') { + setIsMonitoring((prev) => !prev); + } + }, + { isActive: true }, + ); + + if (!server) { + return ( + + {t('No server selected')} + + ); + } + + const statusColor = getStatusColor(server.status); + + const getLevelColor = (level: LogEntry['level']) => { + switch (level) { + case 'error': + return theme.status.error; + case 'warn': + return theme.status.warning; + case 'debug': + return theme.text.secondary; + default: + return theme.text.primary; + } + }; + + // 可视区域最大显示日志数量 + const VISIBLE_LOGS_COUNT = 15; + + // 计算可视区域的起始索引(滚动窗口) + const scrollOffset = (() => { + if (logs.length <= VISIBLE_LOGS_COUNT) { + return 0; + } + if (selectedIndex < VISIBLE_LOGS_COUNT - 1) { + return 0; + } + return Math.min( + selectedIndex - VISIBLE_LOGS_COUNT + 1, + logs.length - VISIBLE_LOGS_COUNT, + ); + })(); + + // 当前可视的日志列表 + const displayLogs = logs.slice( + scrollOffset, + scrollOffset + VISIBLE_LOGS_COUNT, + ); + + return ( + + {/* 标题栏 */} + + {t('Logs for {{name}}', { name: server.name })} + + {' '} + ({getStatusIcon(server.status)}{' '} + + {t(server.status)} + + ) + + + + {/* 日志列表 */} + + {displayLogs.map((log, index) => { + const actualIndex = scrollOffset + index; + const isSelected = actualIndex === selectedIndex; + const timestamp = new Date(log.timestamp).toLocaleTimeString(); + + return ( + + + + {isSelected ? '❯' : ' '} + + + + {timestamp} + + + + [{log.level.toUpperCase()}] + + + + + {log.message} + + + + ); + })} + + + {/* 滚动指示器 */} + {logs.length > VISIBLE_LOGS_COUNT && ( + + + {scrollOffset > 0 ? '↑ ' : ' '} + {t('{{current}}/{{total}}', { + current: (scrollOffset + 1).toString(), + total: logs.length.toString(), + })} + {scrollOffset + VISIBLE_LOGS_COUNT < logs.length ? ' ↓' : ''} + + + )} + + ); +}; diff --git a/packages/cli/src/ui/components/mcp/steps/ToolDetailStep.tsx b/packages/cli/src/ui/components/mcp/steps/ToolDetailStep.tsx index a83185c53..2cd6fa5f0 100644 --- a/packages/cli/src/ui/components/mcp/steps/ToolDetailStep.tsx +++ b/packages/cli/src/ui/components/mcp/steps/ToolDetailStep.tsx @@ -10,6 +10,110 @@ import { useKeypress } from '../../../hooks/useKeypress.js'; import { t } from '../../../../i18n/index.js'; import type { ToolDetailStepProps } from '../types.js'; +/** + * 截断过长的字符串 + */ +const truncate = (str: string, maxLen: number = 50): string => { + if (str.length <= maxLen) return str; + return str.substring(0, maxLen - 3) + '...'; +}; + +/** + * 渲染单个参数 + */ +const renderParameter = ( + name: string, + param: Record, + isRequired: boolean, +): React.ReactNode => { + const type = (param['type'] as string) || 'any'; + const description = (param['description'] as string) || ''; + const defaultValue = param['default']; + const enumValues = param['enum'] as string[] | undefined; + + return ( + + + • {name} + {isRequired && ( + ({t('required')}) + )} + + + {t('Type')}: + {type} + + {description && ( + + + {truncate(description, 80)} + + + )} + {enumValues && enumValues.length > 0 && ( + + + {t('Enum')}: {enumValues.join(', ')} + + + )} + {defaultValue !== undefined && ( + + + {t('Default')}:{' '} + {typeof defaultValue === 'string' + ? `"${truncate(defaultValue, 30)}"` + : String(defaultValue)} + + + )} + + ); +}; + +/** + * 渲染参数列表 + */ +const ParametersList: React.FC<{ + properties: Record; + required: string[]; +}> = ({ properties, required }) => { + const requiredSet = new Set(required); + + return ( + + {t('Parameters')}: + + {Object.entries(properties).map(([name, param]) => + renderParameter( + name, + param as Record, + requiredSet.has(name), + ), + )} + + + ); +}; + +/** + * 提取并展示schema的关键信息,使用类似示例的格式 + */ +const SchemaSummary: React.FC<{ schema: object }> = ({ schema }) => { + const obj = schema as Record; + const properties = obj['properties'] as Record | undefined; + const required = (obj['required'] as string[]) || []; + + return ( + + {/* 参数列表 */} + {properties && Object.keys(properties).length > 0 && ( + + )} + + ); +}; + export const ToolDetailStep: React.FC = ({ tool, onBack, @@ -31,22 +135,11 @@ export const ToolDetailStep: React.FC = ({ ); } - // 格式化schema显示 - const formatSchema = (schema: object | undefined): string => { - if (!schema) return t('No schema available'); - return JSON.stringify(schema, null, 2); - }; - return ( - {/* 工具名称 */} - - {tool.name} - - {/* 工具描述 */} {tool.description && ( - + {tool.description} )} @@ -54,7 +147,7 @@ export const ToolDetailStep: React.FC = ({ {/* 工具注解 */} {tool.annotations && ( - {t('Annotations:')} + {t('Annotations')}: {tool.annotations.title && ( @@ -90,20 +183,11 @@ export const ToolDetailStep: React.FC = ({ )} {/* Schema */} - - {t('Schema:')} - - - {formatSchema(tool.schema)} - + {tool.schema && ( + + - + )} {/* 所属服务器 */} diff --git a/packages/cli/src/ui/components/mcp/steps/ToolListStep.tsx b/packages/cli/src/ui/components/mcp/steps/ToolListStep.tsx index cc9ad6919..72bc348cd 100644 --- a/packages/cli/src/ui/components/mcp/steps/ToolListStep.tsx +++ b/packages/cli/src/ui/components/mcp/steps/ToolListStep.tsx @@ -130,7 +130,7 @@ export const ToolListStep: React.FC = ({ {scrollOffset > 0 ? '↑ ' : ' '} {t('{{current}}/{{total}}', { - current: (scrollOffset + 1).toString(), + current: (selectedIndex + 1).toString(), total: tools.length.toString(), })} {scrollOffset + VISIBLE_TOOLS_COUNT < tools.length ? ' ↓' : ''} diff --git a/packages/cli/src/ui/components/mcp/types.ts b/packages/cli/src/ui/components/mcp/types.ts index a49367711..2ee40dd2d 100644 --- a/packages/cli/src/ui/components/mcp/types.ts +++ b/packages/cli/src/ui/components/mcp/types.ts @@ -15,10 +15,12 @@ import type { export const MCP_MANAGEMENT_STEPS = { SERVER_LIST: 'server-list', SERVER_DETAIL: 'server-detail', + DISABLE_SCOPE_SELECT: 'disable-scope-select', TOOL_LIST: 'tool-list', TOOL_DETAIL: 'tool-detail', PROMPT_LIST: 'prompt-list', PROMPT_DETAIL: 'prompt-detail', + SERVER_LOGS: 'server-logs', } as const; export type MCPManagementStep = @@ -32,8 +34,10 @@ export interface MCPServerDisplayInfo { name: string; /** 连接状态 */ status: MCPServerStatus; - /** 来源 */ + /** 来源类型 */ source: 'user' | 'project' | 'extension'; + /** 配置所在的 scope */ + scope: 'user' | 'workspace' | 'extension'; /** 配置文件路径 */ configPath?: string; /** 服务器配置 */ @@ -44,6 +48,8 @@ export interface MCPServerDisplayInfo { promptCount: number; /** 错误信息 */ errorMessage?: string; + /** 是否被禁用(在排除列表中) */ + isDisabled: boolean; } /** @@ -118,6 +124,8 @@ export interface ServerDetailStepProps { server: MCPServerDisplayInfo | null; /** 查看工具列表回调 */ onViewTools: () => void; + /** 查看日志回调 */ + onViewLogs?: () => void; /** 重新连接回调 */ onReconnect?: () => void; /** 禁用服务器回调 */ @@ -126,6 +134,18 @@ export interface ServerDetailStepProps { onBack: () => void; } +/** + * DisableScopeSelectStep组件属性 + */ +export interface DisableScopeSelectStepProps { + /** 选中的服务器 */ + server: MCPServerDisplayInfo | null; + /** 选择 scope 回调 */ + onSelectScope: (scope: 'user' | 'workspace') => void; + /** 返回回调 */ + onBack: () => void; +} + /** * ToolListStep组件属性 */ @@ -174,6 +194,16 @@ export interface PromptDetailStepProps { onBack: () => void; } +/** + * ServerLogsStep组件属性 + */ +export interface ServerLogsStepProps { + /** 服务器信息 */ + server: MCPServerDisplayInfo | null; + /** 返回回调 */ + onBack: () => void; +} + /** * MCP管理对话框属性 */ diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index e1598a641..02a2b3a0d 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -445,7 +445,7 @@ export class Config { private readonly lspEnabled: boolean; private lspClient?: LspClient; private readonly allowedMcpServers?: string[]; - private readonly excludedMcpServers?: string[]; + private excludedMcpServers?: string[]; private sessionSubagents: SubagentConfig[]; private userMemory: string; private sdkMode: boolean; @@ -1152,17 +1152,25 @@ export class Config { ); } - if (this.excludedMcpServers) { - mcpServers = Object.fromEntries( - Object.entries(mcpServers).filter( - ([key]) => !this.excludedMcpServers?.includes(key), - ), - ); - } + // Note: We no longer filter out excluded servers here. + // The UI layer should check isMcpServerDisabled() to determine + // whether to show a server as disabled. return mcpServers; } + getExcludedMcpServers(): string[] | undefined { + return this.excludedMcpServers; + } + + setExcludedMcpServers(excluded: string[]): void { + this.excludedMcpServers = excluded; + } + + isMcpServerDisabled(serverName: string): boolean { + return this.excludedMcpServers?.includes(serverName) ?? false; + } + addMcpServers(servers: Record): void { if (this.initialized) { throw new Error('Cannot modify mcpServers after initialization'); diff --git a/packages/core/src/tools/mcp-client-manager.test.ts b/packages/core/src/tools/mcp-client-manager.test.ts index 051c9d87a..140b78324 100644 --- a/packages/core/src/tools/mcp-client-manager.test.ts +++ b/packages/core/src/tools/mcp-client-manager.test.ts @@ -44,6 +44,7 @@ describe('McpClientManager', () => { getPromptRegistry: () => ({}), getWorkspaceContext: () => ({}), getDebugMode: () => false, + isMcpServerDisabled: () => false, } as unknown as Config; const manager = new McpClientManager(mockConfig, {} as ToolRegistry); await manager.discoverAllMcpTools(mockConfig); @@ -68,6 +69,7 @@ describe('McpClientManager', () => { getPromptRegistry: () => ({}), getWorkspaceContext: () => ({}), getDebugMode: () => false, + isMcpServerDisabled: () => false, } as unknown as Config; const manager = new McpClientManager(mockConfig, {} as ToolRegistry); await manager.discoverAllMcpTools(mockConfig); @@ -97,11 +99,13 @@ describe('McpClientManager', () => { getPromptRegistry: () => ({}) as PromptRegistry, getWorkspaceContext: () => ({}) as WorkspaceContext, getDebugMode: () => false, + isMcpServerDisabled: () => false, } as unknown as Config; const manager = new McpClientManager(mockConfig, {} as ToolRegistry); // First connect to create the clients await manager.discoverAllMcpTools({ isTrustedFolder: () => true, + isMcpServerDisabled: () => false, } as unknown as Config); // Clear the disconnect calls from initial stop() in discoverAllMcpTools @@ -131,10 +135,12 @@ describe('McpClientManager', () => { getPromptRegistry: () => ({}) as PromptRegistry, getWorkspaceContext: () => ({}) as WorkspaceContext, getDebugMode: () => false, + isMcpServerDisabled: () => false, } as unknown as Config; const manager = new McpClientManager(mockConfig, {} as ToolRegistry); await manager.discoverAllMcpTools({ isTrustedFolder: () => true, + isMcpServerDisabled: () => false, } as unknown as Config); // Call stop multiple times - should not throw diff --git a/packages/core/src/tools/mcp-client-manager.ts b/packages/core/src/tools/mcp-client-manager.ts index 050875a88..ecc700739 100644 --- a/packages/core/src/tools/mcp-client-manager.ts +++ b/packages/core/src/tools/mcp-client-manager.ts @@ -21,6 +21,27 @@ import type { ReadResourceResult } from '@modelcontextprotocol/sdk/types.js'; const debugLogger = createDebugLogger('MCP'); +/** + * Configuration for MCP health monitoring + */ +export interface MCPHealthMonitorConfig { + /** Health check interval in milliseconds (default: 30000ms) */ + checkIntervalMs: number; + /** Number of consecutive failures before marking as disconnected (default: 3) */ + maxConsecutiveFailures: number; + /** Enable automatic reconnection (default: true) */ + autoReconnect: boolean; + /** Delay before reconnection attempt in milliseconds (default: 5000ms) */ + reconnectDelayMs: number; +} + +const DEFAULT_HEALTH_CONFIG: MCPHealthMonitorConfig = { + checkIntervalMs: 30000, // 30 seconds + maxConsecutiveFailures: 3, + autoReconnect: true, + reconnectDelayMs: 5000, // 5 seconds +}; + /** * Manages the lifecycle of multiple MCP clients, including local child processes. * This class is responsible for starting, stopping, and discovering tools from @@ -33,18 +54,24 @@ export class McpClientManager { private discoveryState: MCPDiscoveryState = MCPDiscoveryState.NOT_STARTED; private readonly eventEmitter?: EventEmitter; private readonly sendSdkMcpMessage?: SendSdkMcpMessage; + private healthConfig: MCPHealthMonitorConfig; + private healthCheckTimers: Map = new Map(); + private consecutiveFailures: Map = new Map(); + private isReconnecting: Map = new Map(); constructor( config: Config, toolRegistry: ToolRegistry, eventEmitter?: EventEmitter, sendSdkMcpMessage?: SendSdkMcpMessage, + healthConfig?: Partial, ) { this.cliConfig = config; this.toolRegistry = toolRegistry; this.eventEmitter = eventEmitter; this.sendSdkMcpMessage = sendSdkMcpMessage; + this.healthConfig = { ...DEFAULT_HEALTH_CONFIG, ...healthConfig }; } /** @@ -68,6 +95,12 @@ export class McpClientManager { this.eventEmitter?.emit('mcp-client-update', this.clients); const discoveryPromises = Object.entries(servers).map( async ([name, config]) => { + // Skip disabled servers + if (cliConfig.isMcpServerDisabled(name)) { + debugLogger.debug(`Skipping disabled MCP server: ${name}`); + return; + } + // For SDK MCP servers, pass the sendSdkMcpMessage callback const sdkCallback = isSdkMcpServerConfig(config) ? this.sendSdkMcpMessage @@ -160,6 +193,8 @@ export class McpClientManager { try { await client.connect(); await client.discover(cliConfig); + // Start health check for this server after successful discovery + this.startHealthCheck(serverName); } catch (error) { // Log the error but don't throw: callers expect best-effort discovery. debugLogger.error( @@ -177,6 +212,9 @@ export class McpClientManager { * This is the cleanup method to be called on application exit. */ async stop(): Promise { + // Stop all health checks first + this.stopAllHealthChecks(); + const disconnectionPromises = Array.from(this.clients.entries()).map( async ([name, client]) => { try { @@ -191,12 +229,267 @@ export class McpClientManager { await Promise.all(disconnectionPromises); this.clients.clear(); + this.consecutiveFailures.clear(); + this.isReconnecting.clear(); + } + + /** + * Disconnects a specific MCP server. + * @param serverName The name of the server to disconnect. + */ + async disconnectServer(serverName: string): Promise { + // Stop health check for this server + this.stopHealthCheck(serverName); + + const client = this.clients.get(serverName); + if (client) { + try { + await client.disconnect(); + } catch (error) { + debugLogger.error( + `Error disconnecting client '${serverName}': ${getErrorMessage(error)}`, + ); + } finally { + this.clients.delete(serverName); + this.consecutiveFailures.delete(serverName); + this.isReconnecting.delete(serverName); + this.eventEmitter?.emit('mcp-client-update', this.clients); + } + } } getDiscoveryState(): MCPDiscoveryState { return this.discoveryState; } + /** + * Gets the health monitoring configuration + */ + getHealthConfig(): MCPHealthMonitorConfig { + return { ...this.healthConfig }; + } + + /** + * Updates the health monitoring configuration + */ + updateHealthConfig(config: Partial): void { + this.healthConfig = { ...this.healthConfig, ...config }; + // Restart health checks with new configuration + this.stopAllHealthChecks(); + if (this.healthConfig.autoReconnect) { + this.startAllHealthChecks(); + } + } + + /** + * Starts health monitoring for a specific server + */ + private startHealthCheck(serverName: string): void { + if (!this.healthConfig.autoReconnect) { + return; + } + + // Clear existing timer if any + this.stopHealthCheck(serverName); + + const timer = setInterval(async () => { + await this.performHealthCheck(serverName); + }, this.healthConfig.checkIntervalMs); + + this.healthCheckTimers.set(serverName, timer); + } + + /** + * Stops health monitoring for a specific server + */ + private stopHealthCheck(serverName: string): void { + const timer = this.healthCheckTimers.get(serverName); + if (timer) { + clearInterval(timer); + this.healthCheckTimers.delete(serverName); + } + } + + /** + * Stops all health checks + */ + private stopAllHealthChecks(): void { + for (const [, timer] of this.healthCheckTimers.entries()) { + clearInterval(timer); + } + this.healthCheckTimers.clear(); + } + + /** + * Starts health checks for all connected servers + */ + private startAllHealthChecks(): void { + for (const serverName of this.clients.keys()) { + this.startHealthCheck(serverName); + } + } + + /** + * Performs a health check on a specific server + */ + private async performHealthCheck(serverName: string): Promise { + const client = this.clients.get(serverName); + if (!client) { + return; + } + + // Skip if already reconnecting + if (this.isReconnecting.get(serverName)) { + return; + } + + try { + // Check if client is connected by getting its status + const status = client.getStatus(); + + if (status !== MCPServerStatus.CONNECTED) { + // Connection is not healthy + const failures = (this.consecutiveFailures.get(serverName) || 0) + 1; + this.consecutiveFailures.set(serverName, failures); + + debugLogger.warn( + `Health check failed for server '${serverName}' (${failures}/${this.healthConfig.maxConsecutiveFailures})`, + ); + + if (failures >= this.healthConfig.maxConsecutiveFailures) { + // Trigger reconnection + await this.reconnectServer(serverName); + } + } else { + // Connection is healthy, reset failure count + this.consecutiveFailures.set(serverName, 0); + } + } catch (error) { + debugLogger.error( + `Error during health check for server '${serverName}': ${getErrorMessage(error)}`, + ); + } + } + + /** + * Reconnects a specific server + */ + private async reconnectServer(serverName: string): Promise { + if (this.isReconnecting.get(serverName)) { + return; + } + + this.isReconnecting.set(serverName, true); + debugLogger.info(`Attempting to reconnect to server '${serverName}'...`); + + try { + // Wait before reconnecting + await new Promise((resolve) => + setTimeout(resolve, this.healthConfig.reconnectDelayMs), + ); + + await this.discoverMcpToolsForServer(serverName, this.cliConfig); + + // Reset failure count on successful reconnection + this.consecutiveFailures.set(serverName, 0); + debugLogger.info(`Successfully reconnected to server '${serverName}'`); + } catch (error) { + debugLogger.error( + `Failed to reconnect to server '${serverName}': ${getErrorMessage(error)}`, + ); + } finally { + this.isReconnecting.set(serverName, false); + } + } + + /** + * Discovers tools incrementally for all configured servers. + * Only updates servers that have changed or are new. + */ + async discoverAllMcpToolsIncremental(cliConfig: Config): Promise { + if (!cliConfig.isTrustedFolder()) { + return; + } + + const servers = populateMcpServerCommand( + this.cliConfig.getMcpServers() || {}, + this.cliConfig.getMcpServerCommand(), + ); + + this.discoveryState = MCPDiscoveryState.IN_PROGRESS; + + // Find servers that are new or have changed configuration + const serversToUpdate: string[] = []; + const currentServerNames = new Set(this.clients.keys()); + const newServerNames = new Set(Object.keys(servers)); + + // Check for new servers or configuration changes + for (const [name] of Object.entries(servers)) { + const existingClient = this.clients.get(name); + if (!existingClient) { + // New server + serversToUpdate.push(name); + } else if (existingClient.getStatus() === MCPServerStatus.DISCONNECTED) { + // Disconnected server, try to reconnect + serversToUpdate.push(name); + } + // Note: Configuration change detection would require comparing + // the old and new config, which is not implemented here + } + + // Find removed servers + for (const name of currentServerNames) { + if (!newServerNames.has(name)) { + // Server was removed from configuration + await this.removeServer(name); + } + } + + // Update only the servers that need it + const discoveryPromises = serversToUpdate.map(async (name) => { + try { + await this.discoverMcpToolsForServer(name, cliConfig); + } catch (error) { + debugLogger.error( + `Error during incremental discovery for server '${name}': ${getErrorMessage(error)}`, + ); + } + }); + + await Promise.all(discoveryPromises); + + // Start health checks for all connected servers + if (this.healthConfig.autoReconnect) { + this.startAllHealthChecks(); + } + + this.discoveryState = MCPDiscoveryState.COMPLETED; + } + + /** + * Removes a server and its tools + */ + private async removeServer(serverName: string): Promise { + const client = this.clients.get(serverName); + if (client) { + try { + await client.disconnect(); + } catch (error) { + debugLogger.error( + `Error disconnecting removed server '${serverName}': ${getErrorMessage(error)}`, + ); + } + this.clients.delete(serverName); + this.stopHealthCheck(serverName); + this.consecutiveFailures.delete(serverName); + } + + // Remove tools for this server from registry + this.toolRegistry.removeMcpToolsByServer(serverName); + + this.eventEmitter?.emit('mcp-client-update', this.clients); + } + async readResource( serverName: string, uri: string, diff --git a/packages/core/src/tools/tool-registry.ts b/packages/core/src/tools/tool-registry.ts index 1db7f7e59..dc14bef86 100644 --- a/packages/core/src/tools/tool-registry.ts +++ b/packages/core/src/tools/tool-registry.ts @@ -229,6 +229,28 @@ export class ToolRegistry { } } + /** + * Disables an MCP server by removing its tools, prompts, and disconnecting the client. + * Also updates the config's exclusion list. + * @param serverName The name of the server to disable. + */ + async disableMcpServer(serverName: string): Promise { + // Remove tools from registry + this.removeMcpToolsByServer(serverName); + + // Remove prompts + this.config.getPromptRegistry().removePromptsByServer(serverName); + + // Disconnect the MCP client + await this.mcpClientManager.disconnectServer(serverName); + + // Update config's exclusion list + const currentExcluded = this.config.getExcludedMcpServers() || []; + if (!currentExcluded.includes(serverName)) { + this.config.setExcludedMcpServers([...currentExcluded, serverName]); + } + } + /** * Discovers tools from project (if available and configured). * Can be called multiple times to update discovered tools.