mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-04-30 12:40:44 +00:00
feat: add MCP management TUI with dynamic enable/disable and runtime updates
This commit is contained in:
parent
f64f08d8a1
commit
fe0ec8049c
13 changed files with 1094 additions and 99 deletions
|
|
@ -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<MCPManagementDialogProps> = ({
|
||||
onClose,
|
||||
|
|
@ -54,6 +57,13 @@ export const MCPManagementDialog: React.FC<MCPManagementDialogProps> = ({
|
|||
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<MCPManagementDialogProps> = ({
|
|||
(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<MCPManagementDialogProps> = ({
|
|||
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<MCPManagementDialogProps> = ({
|
|||
[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<MCPManagementDialogProps> = ({
|
|||
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<MCPManagementDialogProps> = ({
|
|||
<ServerDetailStep
|
||||
server={selectedServer}
|
||||
onViewTools={handleViewTools}
|
||||
onViewLogs={handleViewLogs}
|
||||
onReconnect={handleReconnect}
|
||||
onDisable={handleDisable}
|
||||
onBack={handleNavigateBack}
|
||||
/>
|
||||
);
|
||||
|
||||
case MCP_MANAGEMENT_STEPS.DISABLE_SCOPE_SELECT:
|
||||
return (
|
||||
<DisableScopeSelectStep
|
||||
server={selectedServer}
|
||||
onSelectScope={handleSelectDisableScope}
|
||||
onBack={handleNavigateBack}
|
||||
/>
|
||||
);
|
||||
|
||||
case MCP_MANAGEMENT_STEPS.SERVER_LOGS:
|
||||
return (
|
||||
<ServerLogsStep server={selectedServer} onBack={handleNavigateBack} />
|
||||
);
|
||||
|
||||
case MCP_MANAGEMENT_STEPS.TOOL_LIST:
|
||||
return (
|
||||
<ToolListStep
|
||||
|
|
@ -338,10 +514,12 @@ export const MCPManagementDialog: React.FC<MCPManagementDialogProps> = ({
|
|||
selectedTool,
|
||||
handleSelectServer,
|
||||
handleViewTools,
|
||||
handleViewLogs,
|
||||
handleReconnect,
|
||||
handleDisable,
|
||||
handleNavigateBack,
|
||||
handleSelectTool,
|
||||
handleSelectDisableScope,
|
||||
getServerTools,
|
||||
]);
|
||||
|
||||
|
|
@ -361,6 +539,12 @@ export const MCPManagementDialog: React.FC<MCPManagementDialogProps> = ({
|
|||
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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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<DisableScopeSelectStepProps> = ({
|
||||
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 (
|
||||
<Box>
|
||||
<Text color={theme.status.error}>{t('No server selected')}</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" gap={1}>
|
||||
<Box flexDirection="column">
|
||||
<Text color={theme.text.primary}>
|
||||
{t('Disable server:')} {server.name}
|
||||
</Text>
|
||||
<Box marginTop={1}>
|
||||
<Text color={theme.text.secondary}>
|
||||
{t('Select where to add the server to the exclude list:')}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Box marginTop={1}>
|
||||
<RadioButtonSelect<'user' | 'workspace'>
|
||||
items={scopes}
|
||||
onHighlight={(value: 'user' | 'workspace') => setSelectedScope(value)}
|
||||
onSelect={(value: 'user' | 'workspace') => onSelectScope(value)}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Box marginTop={1}>
|
||||
<Text color={theme.text.secondary}>
|
||||
{t('Press Enter to confirm, Esc to cancel')}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
|
@ -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<ServerDetailStepProps> = ({
|
||||
server,
|
||||
onViewTools,
|
||||
onViewLogs,
|
||||
onReconnect,
|
||||
onDisable,
|
||||
onBack,
|
||||
|
|
@ -39,6 +40,13 @@ 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() {
|
||||
|
|
@ -47,11 +55,11 @@ export const ServerDetailStep: React.FC<ServerDetailStepProps> = ({
|
|||
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<ServerDetailStepProps> = ({
|
|||
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<ServerDetailStepProps> = ({
|
|||
}
|
||||
>
|
||||
{getStatusIcon(server.status)} {t(server.status)}
|
||||
{server.isDisabled && (
|
||||
<Text color={theme.status.warning}> (disabled)</Text>
|
||||
)}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Box marginTop={1}>
|
||||
<Text color={theme.text.primary}>{t('Source:')}</Text>
|
||||
<Box marginLeft={2}>
|
||||
<Text color={theme.text.secondary}>
|
||||
{server.scope === 'user'
|
||||
? t('User Settings')
|
||||
: server.scope === 'workspace'
|
||||
? t('Workspace Settings')
|
||||
: t('Extension')}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
|
|
@ -166,10 +193,13 @@ export const ServerDetailStep: React.FC<ServerDetailStepProps> = ({
|
|||
case 'view-tools':
|
||||
onViewTools();
|
||||
break;
|
||||
case 'view-logs':
|
||||
onViewLogs?.();
|
||||
break;
|
||||
case 'reconnect':
|
||||
onReconnect?.();
|
||||
break;
|
||||
case 'disable':
|
||||
case 'toggle-disable':
|
||||
onDisable?.();
|
||||
break;
|
||||
default:
|
||||
|
|
|
|||
|
|
@ -149,6 +149,11 @@ export const ServerListStep: React.FC<ServerListStepProps> = ({
|
|||
{' '}
|
||||
{t(server.status)}
|
||||
</Text>
|
||||
{/* 显示 Scope 和禁用状态 */}
|
||||
<Text color={theme.text.secondary}> [{server.scope}]</Text>
|
||||
{server.isDisabled && (
|
||||
<Text color={theme.status.warning}> (disabled)</Text>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
|
|
|
|||
243
packages/cli/src/ui/components/mcp/steps/ServerLogsStep.tsx
Normal file
243
packages/cli/src/ui/components/mcp/steps/ServerLogsStep.tsx
Normal file
|
|
@ -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<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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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<string, unknown>,
|
||||
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 (
|
||||
<Box key={name} flexDirection="column" marginTop={1}>
|
||||
<Box>
|
||||
<Text color={theme.text.primary}>• {name}</Text>
|
||||
{isRequired && (
|
||||
<Text color={theme.status.error}> ({t('required')})</Text>
|
||||
)}
|
||||
</Box>
|
||||
<Box marginLeft={2}>
|
||||
<Text color={theme.text.secondary}>{t('Type')}: </Text>
|
||||
<Text color={theme.status.success}>{type}</Text>
|
||||
</Box>
|
||||
{description && (
|
||||
<Box marginLeft={2}>
|
||||
<Text color={theme.text.secondary} wrap="wrap">
|
||||
{truncate(description, 80)}
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
{enumValues && enumValues.length > 0 && (
|
||||
<Box marginLeft={2}>
|
||||
<Text color={theme.text.secondary}>
|
||||
{t('Enum')}: {enumValues.join(', ')}
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
{defaultValue !== undefined && (
|
||||
<Box marginLeft={2}>
|
||||
<Text color={theme.text.secondary}>
|
||||
{t('Default')}:{' '}
|
||||
{typeof defaultValue === 'string'
|
||||
? `"${truncate(defaultValue, 30)}"`
|
||||
: String(defaultValue)}
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* 渲染参数列表
|
||||
*/
|
||||
const ParametersList: React.FC<{
|
||||
properties: Record<string, unknown>;
|
||||
required: string[];
|
||||
}> = ({ properties, required }) => {
|
||||
const requiredSet = new Set(required);
|
||||
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Text color={theme.text.secondary}>{t('Parameters')}:</Text>
|
||||
<Box marginLeft={2} flexDirection="column">
|
||||
{Object.entries(properties).map(([name, param]) =>
|
||||
renderParameter(
|
||||
name,
|
||||
param as Record<string, unknown>,
|
||||
requiredSet.has(name),
|
||||
),
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* 提取并展示schema的关键信息,使用类似示例的格式
|
||||
*/
|
||||
const SchemaSummary: React.FC<{ schema: object }> = ({ schema }) => {
|
||||
const obj = schema as Record<string, unknown>;
|
||||
const properties = obj['properties'] as Record<string, unknown> | undefined;
|
||||
const required = (obj['required'] as string[]) || [];
|
||||
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
{/* 参数列表 */}
|
||||
{properties && Object.keys(properties).length > 0 && (
|
||||
<ParametersList properties={properties} required={required} />
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export const ToolDetailStep: React.FC<ToolDetailStepProps> = ({
|
||||
tool,
|
||||
onBack,
|
||||
|
|
@ -31,22 +135,11 @@ export const ToolDetailStep: React.FC<ToolDetailStepProps> = ({
|
|||
);
|
||||
}
|
||||
|
||||
// 格式化schema显示
|
||||
const formatSchema = (schema: object | undefined): string => {
|
||||
if (!schema) return t('No schema available');
|
||||
return JSON.stringify(schema, null, 2);
|
||||
};
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" gap={1}>
|
||||
{/* 工具名称 */}
|
||||
<Box>
|
||||
<Text bold>{tool.name}</Text>
|
||||
</Box>
|
||||
|
||||
{/* 工具描述 */}
|
||||
{tool.description && (
|
||||
<Box marginTop={1}>
|
||||
<Box>
|
||||
<Text wrap="wrap">{tool.description}</Text>
|
||||
</Box>
|
||||
)}
|
||||
|
|
@ -54,7 +147,7 @@ export const ToolDetailStep: React.FC<ToolDetailStepProps> = ({
|
|||
{/* 工具注解 */}
|
||||
{tool.annotations && (
|
||||
<Box flexDirection="column" marginTop={1}>
|
||||
<Text color={theme.text.primary}>{t('Annotations:')}</Text>
|
||||
<Text color={theme.text.secondary}>{t('Annotations')}:</Text>
|
||||
<Box marginLeft={2} flexDirection="column">
|
||||
{tool.annotations.title && (
|
||||
<Text color={theme.text.secondary}>
|
||||
|
|
@ -90,20 +183,11 @@ export const ToolDetailStep: React.FC<ToolDetailStepProps> = ({
|
|||
)}
|
||||
|
||||
{/* Schema */}
|
||||
<Box flexDirection="column" marginTop={1}>
|
||||
<Text color={theme.text.primary}>{t('Schema:')}</Text>
|
||||
<Box
|
||||
marginLeft={2}
|
||||
marginTop={1}
|
||||
padding={1}
|
||||
borderStyle="single"
|
||||
borderColor={theme.border.default}
|
||||
>
|
||||
<Text color={theme.text.secondary} wrap="wrap">
|
||||
{formatSchema(tool.schema)}
|
||||
</Text>
|
||||
{tool.schema && (
|
||||
<Box flexDirection="column" marginTop={1}>
|
||||
<SchemaSummary schema={tool.schema} />
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* 所属服务器 */}
|
||||
<Box marginTop={1}>
|
||||
|
|
|
|||
|
|
@ -130,7 +130,7 @@ export const ToolListStep: React.FC<ToolListStepProps> = ({
|
|||
<Text color={theme.text.secondary}>
|
||||
{scrollOffset > 0 ? '↑ ' : ' '}
|
||||
{t('{{current}}/{{total}}', {
|
||||
current: (scrollOffset + 1).toString(),
|
||||
current: (selectedIndex + 1).toString(),
|
||||
total: tools.length.toString(),
|
||||
})}
|
||||
{scrollOffset + VISIBLE_TOOLS_COUNT < tools.length ? ' ↓' : ''}
|
||||
|
|
|
|||
|
|
@ -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管理对话框属性
|
||||
*/
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue