feat: add MCP management TUI with dynamic enable/disable and runtime updates

This commit is contained in:
LaZzyMan 2026-02-13 17:54:04 +08:00
parent f64f08d8a1
commit fe0ec8049c
13 changed files with 1094 additions and 99 deletions

View file

@ -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;

View file

@ -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,

View file

@ -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>
);
};

View file

@ -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:

View file

@ -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>
);
})}

View 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>
);
};

View file

@ -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}>

View file

@ -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 ? ' ↓' : ''}

View file

@ -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管理对话框属性
*/