feat: add mcp dialog

This commit is contained in:
LaZzyMan 2026-02-13 14:38:54 +08:00
parent 51fdf3c16a
commit f64f08d8a1
17 changed files with 1453 additions and 9 deletions

View file

@ -0,0 +1,412 @@
/**
* @license
* Copyright 2025 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
import { useState, useCallback, useEffect, useMemo } 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 {
MCPManagementDialogProps,
MCPServerDisplayInfo,
MCPToolDisplayInfo,
} from './types.js';
import { MCP_MANAGEMENT_STEPS } from './types.js';
import { ServerListStep } from './steps/ServerListStep.js';
import { ServerDetailStep } from './steps/ServerDetailStep.js';
import { ToolListStep } from './steps/ToolListStep.js';
import { ToolDetailStep } from './steps/ToolDetailStep.js';
import { useConfig } from '../../contexts/ConfigContext.js';
import {
getMCPServerStatus,
DiscoveredMCPTool,
type MCPServerConfig,
type AnyDeclarativeTool,
type DiscoveredMCPPrompt,
} from '@qwen-code/qwen-code-core';
export const MCPManagementDialog: React.FC<MCPManagementDialogProps> = ({
onClose,
}) => {
const config = useConfig();
const [servers, setServers] = useState<MCPServerDisplayInfo[]>([]);
const [selectedServerIndex, setSelectedServerIndex] = useState<number>(-1);
const [selectedTool, setSelectedTool] = useState<MCPToolDisplayInfo | null>(
null,
);
const [navigationStack, setNavigationStack] = useState<string[]>([
MCP_MANAGEMENT_STEPS.SERVER_LIST,
]);
const [isLoading, setIsLoading] = useState(true);
// 加载MCP服务器数据
useEffect(() => {
const loadServers = async () => {
if (!config) return;
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,
);
// 获取该服务器的prompts
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';
}
// TODO: 区分user和project来源需要更详细的配置信息
serverInfos.push({
name,
status,
source,
config: serverConfig,
toolCount: serverTools.length,
promptCount: serverPrompts.length,
});
}
setServers(serverInfos);
} finally {
setIsLoading(false);
}
};
loadServers();
}, [config]);
// 选中的服务器
const selectedServer = useMemo(() => {
if (selectedServerIndex >= 0 && selectedServerIndex < servers.length) {
return servers[selectedServerIndex];
}
return null;
}, [servers, selectedServerIndex]);
// 当前步骤
const getCurrentStep = useCallback(
() =>
navigationStack[navigationStack.length - 1] ||
MCP_MANAGEMENT_STEPS.SERVER_LIST,
[navigationStack],
);
// 导航处理
const handleNavigateToStep = useCallback((step: string) => {
setNavigationStack((prev) => [...prev, step]);
}, []);
const handleNavigateBack = useCallback(() => {
setNavigationStack((prev) => {
if (prev.length <= 1) return prev;
return prev.slice(0, -1);
});
}, []);
// 选择服务器
const handleSelectServer = useCallback(
(index: number) => {
setSelectedServerIndex(index);
handleNavigateToStep(MCP_MANAGEMENT_STEPS.SERVER_DETAIL);
},
[handleNavigateToStep],
);
// 获取服务器工具列表
const getServerTools = useCallback((): MCPToolDisplayInfo[] => {
if (!config || !selectedServer) return [];
const toolRegistry = config.getToolRegistry();
if (!toolRegistry) return [];
const allTools: AnyDeclarativeTool[] = toolRegistry.getAllTools();
const mcpTools: DiscoveredMCPTool[] = [];
for (const tool of allTools) {
if (
tool instanceof DiscoveredMCPTool &&
tool.serverName === selectedServer.name
) {
mcpTools.push(tool);
}
}
return mcpTools.map((tool) => ({
name: tool.name,
description: tool.description,
serverName: tool.serverName,
schema: tool.parameterSchema as object | undefined,
}));
}, [config, selectedServer]);
// 查看工具列表
const handleViewTools = useCallback(() => {
handleNavigateToStep(MCP_MANAGEMENT_STEPS.TOOL_LIST);
}, [handleNavigateToStep]);
// 选择工具
const handleSelectTool = useCallback(
(tool: MCPToolDisplayInfo) => {
setSelectedTool(tool);
handleNavigateToStep(MCP_MANAGEMENT_STEPS.TOOL_DETAIL);
},
[handleNavigateToStep],
);
// 重新连接服务器
const handleReconnect = useCallback(async () => {
if (!config || !selectedServer) return;
try {
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();
} catch (_error) {
// 错误处理 - 静默失败
}
}, [config, selectedServer]);
// 禁用服务器
const handleDisable = useCallback(async () => {
if (!config || !selectedServer) return;
// TODO: 实现禁用服务器的逻辑
// 这需要修改配置文件,暂时返回到服务器列表
handleNavigateBack();
}, [config, selectedServer, handleNavigateBack]);
// 渲染步骤头部
const renderStepHeader = useCallback(() => {
const currentStep = getCurrentStep();
let headerText = '';
switch (currentStep) {
case MCP_MANAGEMENT_STEPS.SERVER_LIST:
headerText = t('Manage MCP servers');
break;
case MCP_MANAGEMENT_STEPS.SERVER_DETAIL:
headerText = selectedServer?.name || t('Server Detail');
break;
case MCP_MANAGEMENT_STEPS.TOOL_LIST:
headerText = t('Tools');
break;
case MCP_MANAGEMENT_STEPS.TOOL_DETAIL:
headerText = selectedTool?.name || t('Tool Detail');
break;
default:
headerText = t('MCP Management');
}
return (
<Box>
<Text bold>{headerText}</Text>
</Box>
);
}, [getCurrentStep, selectedServer, selectedTool]);
// 渲染步骤内容
const renderStepContent = useCallback(() => {
if (isLoading) {
return (
<Box>
<Text color={theme.text.secondary}>{t('Loading...')}</Text>
</Box>
);
}
const currentStep = getCurrentStep();
switch (currentStep) {
case MCP_MANAGEMENT_STEPS.SERVER_LIST:
return (
<ServerListStep servers={servers} onSelect={handleSelectServer} />
);
case MCP_MANAGEMENT_STEPS.SERVER_DETAIL:
return (
<ServerDetailStep
server={selectedServer}
onViewTools={handleViewTools}
onReconnect={handleReconnect}
onDisable={handleDisable}
onBack={handleNavigateBack}
/>
);
case MCP_MANAGEMENT_STEPS.TOOL_LIST:
return (
<ToolListStep
tools={getServerTools()}
serverName={selectedServer?.name || ''}
onSelect={handleSelectTool}
onBack={handleNavigateBack}
/>
);
case MCP_MANAGEMENT_STEPS.TOOL_DETAIL:
return (
<ToolDetailStep tool={selectedTool} onBack={handleNavigateBack} />
);
default:
return (
<Box>
<Text color={theme.status.error}>{t('Unknown step')}</Text>
</Box>
);
}
}, [
isLoading,
getCurrentStep,
servers,
selectedServer,
selectedTool,
handleSelectServer,
handleViewTools,
handleReconnect,
handleDisable,
handleNavigateBack,
handleSelectTool,
getServerTools,
]);
// 渲染步骤底部
const renderStepFooter = useCallback(() => {
const currentStep = getCurrentStep();
let footerText = '';
switch (currentStep) {
case MCP_MANAGEMENT_STEPS.SERVER_LIST:
if (servers.length === 0) {
footerText = t('Esc to close');
} else {
footerText = t('↑↓ to navigate · Enter to select · Esc to close');
}
break;
case MCP_MANAGEMENT_STEPS.SERVER_DETAIL:
footerText = t('↑↓ to navigate · Enter to select · Esc to back');
break;
case MCP_MANAGEMENT_STEPS.TOOL_LIST:
footerText = t('↑↓ to navigate · Enter to select · Esc to back');
break;
case MCP_MANAGEMENT_STEPS.TOOL_DETAIL:
footerText = t('Esc to back');
break;
default:
footerText = t('Esc to close');
}
return (
<Box>
<Text color={theme.text.secondary}>{footerText}</Text>
</Box>
);
}, [getCurrentStep, servers.length]);
// ESC键处理
useKeypress(
(key) => {
if (key.name === 'escape') {
const currentStep = getCurrentStep();
if (currentStep === MCP_MANAGEMENT_STEPS.SERVER_LIST) {
onClose();
} else {
handleNavigateBack();
}
}
},
{ isActive: true },
);
return (
<Box flexDirection="column">
<Box
borderStyle="single"
borderColor={theme.border.default}
flexDirection="column"
padding={1}
width="100%"
gap={1}
>
{renderStepHeader()}
{renderStepContent()}
{renderStepFooter()}
</Box>
</Box>
);
};

View file

@ -0,0 +1,37 @@
/**
* @license
* Copyright 2025 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
/**
* MCP管理相关常量
*/
/**
*
*/
export const MAX_DISPLAY_TOOLS = 10;
/**
* prompt数量
*/
export const MAX_DISPLAY_PROMPTS = 10;
/**
*
*/
export const SOURCE_DISPLAY_NAMES: Record<string, string> = {
user: 'User MCPs',
project: 'Project MCPs',
extension: 'Extension MCPs',
};
/**
*
*/
export const STATUS_TEXT: Record<string, string> = {
connected: 'connected',
connecting: 'connecting',
disconnected: 'failed',
};

View file

@ -0,0 +1,30 @@
/**
* @license
* Copyright 2025 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
// Main Dialog
export { MCPManagementDialog } from './MCPManagementDialog.js';
// Steps
export { ServerListStep } from './steps/ServerListStep.js';
export { ServerDetailStep } from './steps/ServerDetailStep.js';
export { ToolListStep } from './steps/ToolListStep.js';
export { ToolDetailStep } from './steps/ToolDetailStep.js';
// Types
export type {
MCPManagementDialogProps,
MCPServerDisplayInfo,
MCPToolDisplayInfo,
MCPPromptDisplayInfo,
ServerListStepProps,
ServerDetailStepProps,
ToolListStepProps,
ToolDetailStepProps,
MCPManagementStep,
} from './types.js';
// Constants
export { MCP_MANAGEMENT_STEPS } from './types.js';

View file

@ -0,0 +1,183 @@
/**
* @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 { ServerDetailStepProps } from '../types.js';
import {
getStatusColor,
getStatusIcon,
formatServerCommand,
} from '../utils.js';
type ServerAction = 'view-tools' | 'reconnect' | 'disable';
export const ServerDetailStep: React.FC<ServerDetailStepProps> = ({
server,
onViewTools,
onReconnect,
onDisable,
onBack,
}) => {
const [selectedAction, setSelectedAction] =
useState<ServerAction>('view-tools');
const statusColor = server ? getStatusColor(server.status) : 'gray';
const actions = [
{
key: 'view-tools',
get label() {
return t('View tools');
},
value: 'view-tools' as const,
},
{
key: 'reconnect',
get label() {
return t('Reconnect');
},
value: 'reconnect' as const,
},
{
key: 'disable',
get label() {
return t('Disable');
},
value: 'disable' as const,
},
];
useKeypress(
(key) => {
if (key.name === 'escape') {
onBack();
} else if (key.name === 'return') {
switch (selectedAction) {
case 'view-tools':
onViewTools();
break;
case 'reconnect':
onReconnect?.();
break;
case 'disable':
onDisable?.();
break;
default:
break;
}
}
},
{ 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">
<Box>
<Text color={theme.text.primary}>{t('Status:')}</Text>
<Box marginLeft={2}>
<Text
color={
statusColor === 'green'
? theme.status.success
: statusColor === 'yellow'
? theme.status.warning
: theme.status.error
}
>
{getStatusIcon(server.status)} {t(server.status)}
</Text>
</Box>
</Box>
<Box marginTop={1}>
<Text color={theme.text.primary}>{t('Command:')}</Text>
<Box marginLeft={2}>
<Text wrap="truncate">{formatServerCommand(server)}</Text>
</Box>
</Box>
{server.config.cwd && (
<Box marginTop={1}>
<Text color={theme.text.primary}>{t('Working Directory:')}</Text>
<Box marginLeft={2}>
<Text wrap="truncate">{server.config.cwd}</Text>
</Box>
</Box>
)}
<Box marginTop={1}>
<Text color={theme.text.primary}>{t('Capabilities:')}</Text>
<Box marginLeft={2}>
<Text>
{server.toolCount > 0 ? t('tools') : ''}
{server.toolCount > 0 && server.promptCount > 0 ? ', ' : ''}
{server.promptCount > 0 ? t('prompts') : ''}
</Text>
</Box>
</Box>
<Box marginTop={1}>
<Text color={theme.text.primary}>{t('Tools:')}</Text>
<Box marginLeft={2}>
<Text>
{server.toolCount}{' '}
{server.toolCount === 1 ? t('tool') : t('tools')}
</Text>
</Box>
</Box>
{server.errorMessage && (
<Box marginTop={1}>
<Text color={theme.status.error}>{t('Error:')}</Text>
<Box marginLeft={2}>
<Text color={theme.status.error} wrap="wrap">
{server.errorMessage}
</Text>
</Box>
</Box>
)}
</Box>
{/* 操作列表 */}
<Box marginTop={1}>
<RadioButtonSelect<ServerAction>
items={actions}
onHighlight={(value: ServerAction) => setSelectedAction(value)}
onSelect={(value: ServerAction) => {
switch (value) {
case 'view-tools':
onViewTools();
break;
case 'reconnect':
onReconnect?.();
break;
case 'disable':
onDisable?.();
break;
default:
break;
}
}}
/>
</Box>
</Box>
);
};

View file

@ -0,0 +1,169 @@
/**
* @license
* Copyright 2025 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
import { useState, useMemo } 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 { ServerListStepProps, MCPServerDisplayInfo } from '../types.js';
import {
groupServersBySource,
getStatusIcon,
getStatusColor,
} from '../utils.js';
export const ServerListStep: React.FC<ServerListStepProps> = ({
servers,
onSelect,
}) => {
const [selectedIndex, setSelectedIndex] = useState(0);
const groupedServers = useMemo(
() => groupServersBySource(servers),
[servers],
);
// 计算扁平化的服务器列表用于导航
const flatServers = useMemo(() => {
const result: MCPServerDisplayInfo[] = [];
for (const group of groupedServers) {
result.push(...group.servers);
}
return result;
}, [groupedServers]);
// 键盘导航
useKeypress(
(key) => {
if (key.name === 'up') {
setSelectedIndex((prev) => Math.max(0, prev - 1));
} else if (key.name === 'down') {
setSelectedIndex((prev) => Math.min(flatServers.length - 1, prev + 1));
} else if (key.name === 'return') {
onSelect(selectedIndex);
}
},
{ isActive: true },
);
if (servers.length === 0) {
return (
<Box flexDirection="column">
<Text color={theme.text.secondary}>
{t('No MCP servers configured.')}
</Text>
<Text color={theme.text.secondary}>
{t('Add MCP servers to your settings to get started.')}
</Text>
</Box>
);
}
// 计算当前选中项在分组中的位置
const getSelectionPosition = (globalIndex: number) => {
let currentIndex = 0;
for (const group of groupedServers) {
if (globalIndex < currentIndex + group.servers.length) {
return {
groupIndex: groupedServers.indexOf(group),
itemIndex: globalIndex - currentIndex,
};
}
currentIndex += group.servers.length;
}
return { groupIndex: 0, itemIndex: 0 };
};
const currentPosition = getSelectionPosition(selectedIndex);
return (
<Box flexDirection="column">
{/* 服务器统计 */}
<Box marginBottom={1}>
<Text color={theme.text.secondary}>
{servers.length} {servers.length === 1 ? t('server') : t('servers')}
</Text>
</Box>
{/* 分组服务器列表 */}
{groupedServers.map((group, groupIndex) => (
<Box key={group.source} flexDirection="column" marginBottom={1}>
<Text bold color={theme.text.primary}>
{group.displayName}
{group.servers[0]?.configPath && (
<Text color={theme.text.secondary}>
{' '}
({group.servers[0].configPath})
</Text>
)}
</Text>
<Box flexDirection="column" marginTop={1}>
{group.servers.map((server, itemIndex) => {
const isSelected =
groupIndex === currentPosition.groupIndex &&
itemIndex === currentPosition.itemIndex;
const statusColor = getStatusColor(server.status);
return (
<Box key={server.name}>
<Box minWidth={2}>
<Text
color={
isSelected ? theme.text.accent : theme.text.primary
}
>
{isSelected ? '' : ' '}
</Text>
</Box>
<Text
color={isSelected ? theme.text.accent : theme.text.primary}
wrap="truncate"
>
{server.name}
</Text>
<Text color={theme.text.secondary}> · </Text>
<Text
color={
statusColor === 'green'
? theme.status.success
: statusColor === 'yellow'
? theme.status.warning
: theme.status.error
}
>
{getStatusIcon(server.status)}
</Text>
<Text
color={
statusColor === 'green'
? theme.status.success
: statusColor === 'yellow'
? theme.status.warning
: theme.status.error
}
>
{' '}
{t(server.status)}
</Text>
</Box>
);
})}
</Box>
</Box>
))}
{/* 提示信息 */}
{servers.some((s) => s.status === 'disconnected') && (
<Box marginTop={1}>
<Text color={theme.status.warning}>
{t('Run qwen --debug to see error logs')}
</Text>
</Box>
)}
</Box>
);
};

View file

@ -0,0 +1,116 @@
/**
* @license
* Copyright 2025 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
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 { ToolDetailStepProps } from '../types.js';
export const ToolDetailStep: React.FC<ToolDetailStepProps> = ({
tool,
onBack,
}) => {
useKeypress(
(key) => {
if (key.name === 'escape') {
onBack();
}
},
{ isActive: true },
);
if (!tool) {
return (
<Box>
<Text color={theme.status.error}>{t('No tool selected')}</Text>
</Box>
);
}
// 格式化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}>
<Text wrap="wrap">{tool.description}</Text>
</Box>
)}
{/* 工具注解 */}
{tool.annotations && (
<Box flexDirection="column" marginTop={1}>
<Text color={theme.text.primary}>{t('Annotations:')}</Text>
<Box marginLeft={2} flexDirection="column">
{tool.annotations.title && (
<Text color={theme.text.secondary}>
{t('Title')}: {tool.annotations.title}
</Text>
)}
{tool.annotations.readOnlyHint !== undefined && (
<Text color={theme.text.secondary}>
{t('Read Only')}:{' '}
{tool.annotations.readOnlyHint ? t('Yes') : t('No')}
</Text>
)}
{tool.annotations.destructiveHint !== undefined && (
<Text color={theme.text.secondary}>
{t('Destructive')}:{' '}
{tool.annotations.destructiveHint ? t('Yes') : t('No')}
</Text>
)}
{tool.annotations.idempotentHint !== undefined && (
<Text color={theme.text.secondary}>
{t('Idempotent')}:{' '}
{tool.annotations.idempotentHint ? t('Yes') : t('No')}
</Text>
)}
{tool.annotations.openWorldHint !== undefined && (
<Text color={theme.text.secondary}>
{t('Open World')}:{' '}
{tool.annotations.openWorldHint ? t('Yes') : t('No')}
</Text>
)}
</Box>
</Box>
)}
{/* 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>
</Box>
</Box>
{/* 所属服务器 */}
<Box marginTop={1}>
<Text color={theme.text.secondary}>
{t('Server')}: {tool.serverName}
</Text>
</Box>
</Box>
);
};

View file

@ -0,0 +1,142 @@
/**
* @license
* Copyright 2025 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
import { useState, useMemo } 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 { ToolListStepProps, MCPToolDisplayInfo } from '../types.js';
// 可视区域最大显示工具数量
const VISIBLE_TOOLS_COUNT = 10;
export const ToolListStep: React.FC<ToolListStepProps> = ({
tools,
serverName,
onSelect,
onBack,
}) => {
const [selectedIndex, setSelectedIndex] = useState(0);
// 计算可视区域的起始索引(滚动窗口)
const scrollOffset = useMemo(() => {
if (tools.length <= VISIBLE_TOOLS_COUNT) {
return 0;
}
// 确保选中项在可视区域内
if (selectedIndex < VISIBLE_TOOLS_COUNT - 1) {
return 0;
}
return Math.min(
selectedIndex - VISIBLE_TOOLS_COUNT + 1,
tools.length - VISIBLE_TOOLS_COUNT,
);
}, [selectedIndex, tools.length]);
// 当前可视的工具列表
const displayTools = useMemo(
() => tools.slice(scrollOffset, scrollOffset + VISIBLE_TOOLS_COUNT),
[tools, scrollOffset],
);
useKeypress(
(key) => {
if (key.name === 'escape') {
onBack();
} else if (key.name === 'up') {
setSelectedIndex((prev) => Math.max(0, prev - 1));
} else if (key.name === 'down') {
setSelectedIndex((prev) => Math.min(tools.length - 1, prev + 1));
} else if (key.name === 'return') {
if (tools[selectedIndex]) {
onSelect(tools[selectedIndex]);
}
}
},
{ isActive: true },
);
if (tools.length === 0) {
return (
<Box flexDirection="column">
<Text color={theme.text.secondary}>
{t('No tools available for this server.')}
</Text>
</Box>
);
}
const getToolAnnotations = (tool: MCPToolDisplayInfo): string => {
const hints: string[] = [];
if (tool.annotations?.destructiveHint) hints.push(t('destructive'));
if (tool.annotations?.readOnlyHint) hints.push(t('read-only'));
if (tool.annotations?.openWorldHint) hints.push(t('open-world'));
if (tool.annotations?.idempotentHint) hints.push(t('idempotent'));
return hints.join(', ');
};
return (
<Box flexDirection="column">
{/* 标题 */}
<Box marginBottom={1}>
<Text bold>{t('Tools for {{name}}', { name: serverName })}</Text>
<Text color={theme.text.secondary}>
{' '}
({tools.length} {tools.length === 1 ? t('tool') : t('tools')})
</Text>
</Box>
{/* 工具列表 */}
<Box flexDirection="column">
{displayTools.map((tool, index) => {
const actualIndex = scrollOffset + index;
const isSelected = actualIndex === selectedIndex;
const annotations = getToolAnnotations(tool);
return (
<Box key={tool.name}>
<Box minWidth={4}>
<Text
color={isSelected ? theme.text.accent : theme.text.primary}
>
{isSelected ? '' : ' '}
</Text>
<Text color={theme.text.secondary}>{actualIndex + 1}.</Text>
</Box>
<Text
color={isSelected ? theme.text.accent : theme.text.primary}
wrap="truncate"
>
{tool.name}
</Text>
{annotations && (
<>
<Text color={theme.text.secondary}> </Text>
<Text color={theme.text.secondary}>{annotations}</Text>
</>
)}
</Box>
);
})}
</Box>
{/* 滚动提示 */}
{tools.length > VISIBLE_TOOLS_COUNT && (
<Box marginTop={1}>
<Text color={theme.text.secondary}>
{scrollOffset > 0 ? '↑ ' : ' '}
{t('{{current}}/{{total}}', {
current: (scrollOffset + 1).toString(),
total: tools.length.toString(),
})}
{scrollOffset + VISIBLE_TOOLS_COUNT < tools.length ? ' ↓' : ''}
</Text>
</Box>
)}
</Box>
);
};

View file

@ -0,0 +1,183 @@
/**
* @license
* Copyright 2025 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
import type {
MCPServerConfig,
MCPServerStatus,
} from '@qwen-code/qwen-code-core';
/**
* MCP管理步骤定义
*/
export const MCP_MANAGEMENT_STEPS = {
SERVER_LIST: 'server-list',
SERVER_DETAIL: 'server-detail',
TOOL_LIST: 'tool-list',
TOOL_DETAIL: 'tool-detail',
PROMPT_LIST: 'prompt-list',
PROMPT_DETAIL: 'prompt-detail',
} as const;
export type MCPManagementStep =
(typeof MCP_MANAGEMENT_STEPS)[keyof typeof MCP_MANAGEMENT_STEPS];
/**
* MCP服务器显示信息
*/
export interface MCPServerDisplayInfo {
/** 服务器名称 */
name: string;
/** 连接状态 */
status: MCPServerStatus;
/** 来源 */
source: 'user' | 'project' | 'extension';
/** 配置文件路径 */
configPath?: string;
/** 服务器配置 */
config: MCPServerConfig;
/** 工具数量 */
toolCount: number;
/** Prompt数量 */
promptCount: number;
/** 错误信息 */
errorMessage?: string;
}
/**
* MCP工具显示信息
*/
export interface MCPToolDisplayInfo {
/** 工具名称 */
name: string;
/** 工具描述 */
description?: string;
/** 所属服务器 */
serverName: string;
/** 工具schema */
schema?: object;
/** 工具注解 */
annotations?: {
title?: string;
readOnlyHint?: boolean;
destructiveHint?: boolean;
idempotentHint?: boolean;
openWorldHint?: boolean;
};
}
/**
* MCP Prompt显示信息
*/
export interface MCPPromptDisplayInfo {
/** Prompt名称 */
name: string;
/** Prompt描述 */
description?: string;
/** 所属服务器 */
serverName: string;
/** 参数定义 */
arguments?: Array<{
name: string;
description?: string;
required?: boolean;
}>;
}
/**
*
*/
export interface GroupedServers {
/** 来源标识 */
source: string;
/** 来源显示名称 */
displayName: string;
/** 配置文件路径 */
configPath?: string;
/** 服务器列表 */
servers: MCPServerDisplayInfo[];
}
/**
* ServerListStep组件属性
*/
export interface ServerListStepProps {
/** 服务器列表 */
servers: MCPServerDisplayInfo[];
/** 选择回调 */
onSelect: (index: number) => void;
}
/**
* ServerDetailStep组件属性
*/
export interface ServerDetailStepProps {
/** 选中的服务器 */
server: MCPServerDisplayInfo | null;
/** 查看工具列表回调 */
onViewTools: () => void;
/** 重新连接回调 */
onReconnect?: () => void;
/** 禁用服务器回调 */
onDisable?: () => void;
/** 返回回调 */
onBack: () => void;
}
/**
* ToolListStep组件属性
*/
export interface ToolListStepProps {
/** 工具列表 */
tools: MCPToolDisplayInfo[];
/** 服务器名称 */
serverName: string;
/** 选择回调 */
onSelect: (tool: MCPToolDisplayInfo) => void;
/** 返回回调 */
onBack: () => void;
}
/**
* ToolDetailStep组件属性
*/
export interface ToolDetailStepProps {
/** 工具信息 */
tool: MCPToolDisplayInfo | null;
/** 返回回调 */
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;
}
/**
* MCP管理对话框属性
*/
export interface MCPManagementDialogProps {
/** 关闭回调 */
onClose: () => void;
}

View file

@ -0,0 +1,103 @@
/**
* @license
* Copyright 2025 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
import type { MCPServerDisplayInfo, GroupedServers } from './types.js';
import { SOURCE_DISPLAY_NAMES } from './constants.js';
/**
*
*/
export function groupServersBySource(
servers: MCPServerDisplayInfo[],
): GroupedServers[] {
const groups = new Map<string, MCPServerDisplayInfo[]>();
for (const server of servers) {
const existing = groups.get(server.source);
if (existing) {
existing.push(server);
} else {
groups.set(server.source, [server]);
}
}
// 按优先级排序: user > project > extension
const sourceOrder = ['user', 'project', 'extension'];
const result: GroupedServers[] = [];
for (const source of sourceOrder) {
const servers = groups.get(source);
if (servers && servers.length > 0) {
result.push({
source,
displayName: SOURCE_DISPLAY_NAMES[source] || source,
servers,
});
}
}
return result;
}
/**
*
*/
export function getStatusColor(
status: string,
): 'green' | 'yellow' | 'red' | 'gray' {
switch (status) {
case 'connected':
return 'green';
case 'connecting':
return 'yellow';
case 'disconnected':
return 'red';
default:
return 'gray';
}
}
/**
*
*/
export function getStatusIcon(status: string): string {
switch (status) {
case 'connected':
return '✓';
case 'connecting':
return '…';
case 'disconnected':
return '✗';
default:
return '?';
}
}
/**
*
*/
export function truncateText(text: string, maxLength: number): string {
if (text.length <= maxLength) return text;
return text.substring(0, maxLength - 3) + '...';
}
/**
*
*/
export function formatServerCommand(server: MCPServerDisplayInfo): string {
const config = server.config;
if (config.httpUrl) {
return `${config.httpUrl} (http)`;
}
if (config.url) {
return `${config.url} (sse)`;
}
if (config.command) {
const args = config.args?.join(' ') || '';
return `${config.command} ${args} (stdio)`.trim();
}
return 'Unknown';
}