This commit is contained in:
LaZzyMan 2026-02-25 20:51:41 +08:00
parent 25f923e89e
commit a608fdd243
14 changed files with 194 additions and 471 deletions

View file

@ -17,7 +17,6 @@ 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';
@ -28,9 +27,12 @@ import {
type MCPServerConfig,
type AnyDeclarativeTool,
type DiscoveredMCPPrompt,
createDebugLogger,
} from '@qwen-code/qwen-code-core';
import { loadSettings, SettingScope } from '../../../config/settings.js';
const debugLogger = createDebugLogger('MCP_DIALOG');
export const MCPManagementDialog: React.FC<MCPManagementDialogProps> = ({
onClose,
}) => {
@ -46,85 +48,94 @@ export const MCPManagementDialog: React.FC<MCPManagementDialogProps> = ({
]);
const [isLoading, setIsLoading] = useState(true);
// Load MCP server data
// Load MCP server data - extracted to a separate function for reuse
const fetchServerData = useCallback(async (): Promise<
MCPServerDisplayInfo[]
> => {
if (!config) return [];
const mcpServers = config.getMcpServers() || {};
const toolRegistry = config.getToolRegistry();
const promptRegistry = config.getPromptRegistry();
// Get settings to determine the scope of each server
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);
// Get tools for this server
const allTools: AnyDeclarativeTool[] = toolRegistry?.getAllTools() || [];
const serverTools = allTools.filter(
(t): t is DiscoveredMCPTool =>
t instanceof DiscoveredMCPTool && t.serverName === name,
);
// Get prompts for this server
const allPrompts: DiscoveredMCPPrompt[] =
promptRegistry?.getAllPrompts() || [];
const serverPrompts = allPrompts.filter(
(p) => 'serverName' in p && p.serverName === name,
);
// Determine source type
let source: 'user' | 'project' | 'extension' = 'user';
if (serverConfig.extensionName) {
source = 'extension';
}
// Determine the scope of the configuration
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';
}
// Use config.isMcpServerDisabled() to check if server is disabled
const isDisabled = config.isMcpServerDisabled(name);
serverInfos.push({
name,
status,
source,
scope,
config: serverConfig,
toolCount: serverTools.length,
promptCount: serverPrompts.length,
isDisabled,
});
}
return serverInfos;
}, [config]);
// Load MCP server data on initial render
useEffect(() => {
const loadServers = async () => {
if (!config) return;
setIsLoading(true);
try {
const mcpServers = config.getMcpServers() || {};
const toolRegistry = config.getToolRegistry();
const promptRegistry = await config.getPromptRegistry();
// Get settings to determine the scope of each server
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);
// Get tools for this server
const allTools: AnyDeclarativeTool[] =
toolRegistry?.getAllTools() || [];
const serverTools = allTools.filter(
(t): t is DiscoveredMCPTool =>
t instanceof DiscoveredMCPTool && t.serverName === name,
);
// Get prompts for this server
const allPrompts: DiscoveredMCPPrompt[] =
promptRegistry?.getAllPrompts() || [];
const serverPrompts = allPrompts.filter(
(p) => 'serverName' in p && p.serverName === name,
);
// Determine source type
let source: 'user' | 'project' | 'extension' = 'user';
if (serverConfig.extensionName) {
source = 'extension';
}
// Determine the scope of the configuration
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';
}
// Use config.isMcpServerDisabled() to check if server is disabled
const isDisabled = config.isMcpServerDisabled(name);
serverInfos.push({
name,
status,
source,
scope,
config: serverConfig,
toolCount: serverTools.length,
promptCount: serverPrompts.length,
isDisabled,
});
}
const serverInfos = await fetchServerData();
setServers(serverInfos);
} catch (error) {
debugLogger.error('Error loading MCP servers:', error);
} finally {
setIsLoading(false);
}
};
loadServers();
}, [config]);
}, [fetchServerData]);
// Selected server
const selectedServer = useMemo(() => {
@ -194,11 +205,6 @@ export const MCPManagementDialog: React.FC<MCPManagementDialogProps> = ({
handleNavigateToStep(MCP_MANAGEMENT_STEPS.TOOL_LIST);
}, [handleNavigateToStep]);
// View server logs
const handleViewLogs = useCallback(() => {
handleNavigateToStep(MCP_MANAGEMENT_STEPS.SERVER_LOGS);
}, [handleNavigateToStep]);
// Select tool
const handleSelectTool = useCallback(
(tool: MCPToolDisplayInfo) => {
@ -208,79 +214,18 @@ export const MCPManagementDialog: React.FC<MCPManagementDialogProps> = ({
[handleNavigateToStep],
);
// Reload server data
// Reload server data - uses the extracted fetchServerData function
const reloadServers = useCallback(async () => {
if (!config) return;
setIsLoading(true);
try {
const mcpServers = config.getMcpServers() || {};
const toolRegistry = config.getToolRegistry();
const promptRegistry = await config.getPromptRegistry();
// Get settings to determine the scope of each server
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,
);
// Determine source type
let source: 'user' | 'project' | 'extension' = 'user';
if (serverConfig.extensionName) {
source = 'extension';
}
// Determine the scope of the configuration
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';
}
// Use config.isMcpServerDisabled() to check if server is disabled
const isDisabled = config.isMcpServerDisabled(name);
serverInfos.push({
name,
status,
source,
scope,
config: serverConfig,
toolCount: serverTools.length,
promptCount: serverPrompts.length,
isDisabled,
});
}
const serverInfos = await fetchServerData();
setServers(serverInfos);
} catch (error) {
debugLogger.error('Error reloading MCP servers:', error);
} finally {
setIsLoading(false);
}
}, [config]);
}, [fetchServerData]);
// Reconnect server
const handleReconnect = useCallback(async () => {
@ -294,8 +239,11 @@ export const MCPManagementDialog: React.FC<MCPManagementDialogProps> = ({
}
// Reload server data to update status
await reloadServers();
} catch (_error) {
// Error handling - fail silently
} catch (error) {
debugLogger.error(
`Error reconnecting to server '${selectedServer.name}':`,
error,
);
} finally {
setIsLoading(false);
}
@ -339,8 +287,11 @@ export const MCPManagementDialog: React.FC<MCPManagementDialogProps> = ({
// Reload server data
await reloadServers();
} catch (_error) {
// Error handling - fail silently
} catch (error) {
debugLogger.error(
`Error enabling server '${selectedServer.name}':`,
error,
);
} finally {
setIsLoading(false);
}
@ -397,8 +348,11 @@ export const MCPManagementDialog: React.FC<MCPManagementDialogProps> = ({
// Return to server detail page
handleNavigateBack();
} catch (_error) {
// Error handling - fail silently
} catch (error) {
debugLogger.error(
`Error disabling server '${selectedServer.name}':`,
error,
);
} finally {
setIsLoading(false);
}
@ -421,9 +375,6 @@ export const MCPManagementDialog: React.FC<MCPManagementDialogProps> = ({
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;
@ -464,7 +415,6 @@ export const MCPManagementDialog: React.FC<MCPManagementDialogProps> = ({
<ServerDetailStep
server={selectedServer}
onViewTools={handleViewTools}
onViewLogs={handleViewLogs}
onReconnect={handleReconnect}
onDisable={handleDisable}
onBack={handleNavigateBack}
@ -480,11 +430,6 @@ export const MCPManagementDialog: React.FC<MCPManagementDialogProps> = ({
/>
);
case MCP_MANAGEMENT_STEPS.SERVER_LOGS:
return (
<ServerLogsStep server={selectedServer} onBack={handleNavigateBack} />
);
case MCP_MANAGEMENT_STEPS.TOOL_LIST:
return (
<ToolListStep
@ -515,7 +460,6 @@ export const MCPManagementDialog: React.FC<MCPManagementDialogProps> = ({
selectedTool,
handleSelectServer,
handleViewTools,
handleViewLogs,
handleReconnect,
handleDisable,
handleNavigateBack,
@ -543,9 +487,6 @@ export const MCPManagementDialog: React.FC<MCPManagementDialogProps> = ({
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;

View file

@ -18,6 +18,16 @@ export const MAX_DISPLAY_TOOLS = 10;
*/
export const MAX_DISPLAY_PROMPTS = 10;
/**
*
*/
export const VISIBLE_LOGS_COUNT = 15;
/**
*
*/
export const VISIBLE_TOOLS_COUNT = 10;
/**
*
*/

View file

@ -10,7 +10,6 @@ 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';
@ -22,7 +21,6 @@ export type {
MCPPromptDisplayInfo,
ServerListStepProps,
ServerDetailStepProps,
ServerLogsStepProps,
ToolListStepProps,
ToolDetailStepProps,
MCPManagementStep,

View file

@ -17,12 +17,11 @@ import {
formatServerCommand,
} from '../utils.js';
type ServerAction = 'view-tools' | 'view-logs' | 'reconnect' | 'toggle-disable';
type ServerAction = 'view-tools' | 'reconnect' | 'toggle-disable';
export const ServerDetailStep: React.FC<ServerDetailStepProps> = ({
server,
onViewTools,
onViewLogs,
onReconnect,
onDisable,
onBack,
@ -40,13 +39,6 @@ export const ServerDetailStep: React.FC<ServerDetailStepProps> = ({
},
value: 'view-tools' as const,
},
{
key: 'view-logs',
get label() {
return t('View logs');
},
value: 'view-logs' as const,
},
{
key: 'reconnect',
get label() {
@ -72,9 +64,6 @@ export const ServerDetailStep: React.FC<ServerDetailStepProps> = ({
case 'view-tools':
onViewTools();
break;
case 'view-logs':
onViewLogs?.();
break;
case 'reconnect':
onReconnect?.();
break;
@ -115,7 +104,7 @@ export const ServerDetailStep: React.FC<ServerDetailStepProps> = ({
>
{getStatusIcon(server.status)} {t(server.status)}
{server.isDisabled && (
<Text color={theme.status.warning}> (disabled)</Text>
<Text color={theme.status.warning}> {t('(disabled)')}</Text>
)}
</Text>
</Box>
@ -193,9 +182,6 @@ export const ServerDetailStep: React.FC<ServerDetailStepProps> = ({
case 'view-tools':
onViewTools();
break;
case 'view-logs':
onViewLogs?.();
break;
case 'reconnect':
onReconnect?.();
break;

View file

@ -152,7 +152,7 @@ export const ServerListStep: React.FC<ServerListStepProps> = ({
{/* 显示 Scope 和禁用状态 */}
<Text color={theme.text.secondary}> [{server.scope}]</Text>
{server.isDisabled && (
<Text color={theme.status.warning}> (disabled)</Text>
<Text color={theme.status.warning}> {t('(disabled)')}</Text>
)}
</Box>
);

View file

@ -1,243 +0,0 @@
/**
* @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<ServerLogsStepProps> = ({
server,
onBack,
}) => {
const [logs, setLogs] = useState<LogEntry[]>([]);
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 (
<Box>
<Text color={theme.status.error}>{t('No server selected')}</Text>
</Box>
);
}
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 (
<Box flexDirection="column">
{/* 标题栏 */}
<Box marginBottom={1}>
<Text bold>{t('Logs for {{name}}', { name: server.name })}</Text>
<Text color={theme.text.secondary}>
{' '}
({getStatusIcon(server.status)}{' '}
<Text
color={
statusColor === 'green'
? theme.status.success
: statusColor === 'yellow'
? theme.status.warning
: theme.status.error
}
>
{t(server.status)}
</Text>
)
</Text>
</Box>
{/* 日志列表 */}
<Box flexDirection="column" minHeight={VISIBLE_LOGS_COUNT}>
{displayLogs.map((log, index) => {
const actualIndex = scrollOffset + index;
const isSelected = actualIndex === selectedIndex;
const timestamp = new Date(log.timestamp).toLocaleTimeString();
return (
<Box key={actualIndex}>
<Box minWidth={3}>
<Text
color={isSelected ? theme.text.accent : theme.text.primary}
>
{isSelected ? '' : ' '}
</Text>
</Box>
<Box minWidth={10}>
<Text color={theme.text.secondary}>{timestamp}</Text>
</Box>
<Box minWidth={8}>
<Text color={getLevelColor(log.level)}>
[{log.level.toUpperCase()}]
</Text>
</Box>
<Box flexGrow={1}>
<Text
color={isSelected ? theme.text.accent : theme.text.primary}
wrap="truncate"
>
{log.message}
</Text>
</Box>
</Box>
);
})}
</Box>
{/* 滚动指示器 */}
{logs.length > VISIBLE_LOGS_COUNT && (
<Box marginTop={1}>
<Text color={theme.text.secondary}>
{scrollOffset > 0 ? '↑ ' : ' '}
{t('{{current}}/{{total}}', {
current: (scrollOffset + 1).toString(),
total: logs.length.toString(),
})}
{scrollOffset + VISIBLE_LOGS_COUNT < logs.length ? ' ↓' : ''}
</Text>
</Box>
)}
</Box>
);
};

View file

@ -10,9 +10,7 @@ import { theme } from '../../../semantic-colors.js';
import { useKeypress } from '../../../hooks/useKeypress.js';
import { t } from '../../../../i18n/index.js';
import type { ToolListStepProps, MCPToolDisplayInfo } from '../types.js';
// 可视区域最大显示工具数量
const VISIBLE_TOOLS_COUNT = 10;
import { VISIBLE_TOOLS_COUNT } from '../constants.js';
export const ToolListStep: React.FC<ToolListStepProps> = ({
tools,

View file

@ -18,9 +18,6 @@ export const MCP_MANAGEMENT_STEPS = {
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 =
@ -124,8 +121,6 @@ export interface ServerDetailStepProps {
server: MCPServerDisplayInfo | null;
/** 查看工具列表回调 */
onViewTools: () => void;
/** 查看日志回调 */
onViewLogs?: () => void;
/** 重新连接回调 */
onReconnect?: () => void;
/** 禁用服务器回调 */
@ -170,40 +165,6 @@ export interface ToolDetailStepProps {
onBack: () => void;
}
/**
* PromptListStep组件属性
*/
export interface PromptListStepProps {
/** Prompt列表 */
prompts: MCPPromptDisplayInfo[];
/** 服务器名称 */
serverName: string;
/** 选择回调 */
onSelect: (prompt: MCPPromptDisplayInfo) => void;
/** 返回回调 */
onBack: () => void;
}
/**
* PromptDetailStep组件属性
*/
export interface PromptDetailStepProps {
/** Prompt信息 */
prompt: MCPPromptDisplayInfo | null;
/** 返回回调 */
onBack: () => void;
}
/**
* ServerLogsStep组件属性
*/
export interface ServerLogsStepProps {
/** 服务器信息 */
server: MCPServerDisplayInfo | null;
/** 返回回调 */
onBack: () => void;
}
/**
* MCP管理对话框属性
*/