feat: 添加 MCP (Model Context Protocol) 集成支持

实现 MCP 协议集成,允许通过外部服务器扩展工具能力:

核心模块:
- types.ts: MCP 类型定义 (LocalMCPServer, RemoteMCPServer, MCPTool 等)
- config.ts: 配置加载、验证,支持 {env:VAR} 环境变量语法
- transports/stdio.ts: stdio 传输层实现 (JSON-RPC 2.0)
- client.ts: MCP 客户端,处理协议握手和工具调用
- manager.ts: 多服务器生命周期管理
- tool-adapter.ts: 将 MCP 工具转换为内部格式

CLI 命令:
- ai-assist mcp list: 列出服务器状态
- ai-assist mcp tools: 列出可用工具
- ai-assist mcp test <server>: 测试连接

配置支持:
- 用户级 (~/.ai-assist/config.json) 和项目级配置
- JSON/YAML 格式
- 通配符模式工具启用/禁用
This commit is contained in:
2025-12-11 21:18:41 +08:00
parent bca19b7741
commit 4beaf088d0
14 changed files with 2794 additions and 0 deletions
+8
View File
@@ -33,6 +33,7 @@
},
"devDependencies": {
"@types/js-yaml": "^4.0.9",
"@types/json-schema": "^7.0.15",
"@types/node": "^22.0.0",
"@types/uuid": "^10.0.0",
"@vitest/coverage-v8": "^4.0.15",
@@ -1365,6 +1366,13 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/json-schema": {
"version": "7.0.15",
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
"integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/node": {
"version": "22.19.2",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.2.tgz",
+1
View File
@@ -47,6 +47,7 @@
},
"devDependencies": {
"@types/js-yaml": "^4.0.9",
"@types/json-schema": "^7.0.15",
"@types/node": "^22.0.0",
"@types/uuid": "^10.0.0",
"@vitest/coverage-v8": "^4.0.15",
+268
View File
@@ -17,9 +17,100 @@ import {
installAllServers,
showServerInfo,
} from './lsp/cli.js';
import {
getMCPManager,
loadMCPConfig,
createMCPToolAdapter,
} from './mcp/index.js';
const program = new Command();
// MCP 管理器实例
let mcpInitialized = false;
/**
* 初始化 MCP 系统
* 加载配置、连接服务器、注册工具
*/
async function initMCP(workdir: string): Promise<void> {
if (mcpInitialized) {
return;
}
const mcpConfig = loadMCPConfig(workdir);
// 如果没有 MCP 配置,跳过初始化
if (!mcpConfig.mcp || Object.keys(mcpConfig.mcp).length === 0) {
return;
}
const mcpManager = getMCPManager();
// 监听工具变化事件
mcpManager.on('tools:changed', () => {
registerMCPTools(mcpManager);
});
// 监听服务器事件(用于日志)
mcpManager.on('server:connected', (name) => {
console.log(`🔌 MCP 服务器已连接: ${name}`);
});
mcpManager.on('server:disconnected', (name) => {
console.log(`🔌 MCP 服务器已断开: ${name}`);
});
mcpManager.on('server:error', (name, error) => {
console.error(`❌ MCP 服务器 ${name} 错误:`, error);
});
try {
await mcpManager.initialize(mcpConfig);
registerMCPTools(mcpManager);
mcpInitialized = true;
// 显示 MCP 状态
const statuses = mcpManager.getServerStatuses();
const connected = statuses.filter((s) => s.status === 'connected');
if (connected.length > 0) {
const totalTools = connected.reduce((sum, s) => sum + s.toolCount, 0);
console.log(
`🔌 MCP: ${connected.length} 个服务器已连接,${totalTools} 个工具可用`
);
}
} catch (error) {
console.error(
'❌ MCP 初始化失败:',
error instanceof Error ? error.message : String(error)
);
}
}
/**
* 将 MCP 工具注册到工具注册表
*/
function registerMCPTools(
mcpManager: ReturnType<typeof getMCPManager>
): void {
const adapter = createMCPToolAdapter(mcpManager);
const mcpTools = mcpManager.getTools();
const adaptedTools = adapter.adaptTools(mcpTools);
// 注册到工具注册表
toolRegistry.registerAll(adaptedTools);
}
/**
* 关闭 MCP 系统
*/
async function shutdownMCP(): Promise<void> {
if (mcpInitialized) {
const mcpManager = getMCPManager();
await mcpManager.shutdown();
mcpInitialized = false;
}
}
program
.name('ai-assist')
.description('AI Terminal Assistant - 终端中的 AI 编程助手')
@@ -70,6 +161,179 @@ lspCommand
showServerInfo(server);
});
// MCP 命令组
const mcpCommand = program.command('mcp').description('MCP 服务器管理');
mcpCommand
.command('list')
.description('列出所有 MCP 服务器及其状态')
.action(async () => {
const mcpConfig = loadMCPConfig(process.cwd());
if (!mcpConfig.mcp || Object.keys(mcpConfig.mcp).length === 0) {
console.log('没有配置 MCP 服务器');
console.log('\n配置方法:');
console.log(' 在 ~/.ai-assist/config.json 或 .ai-assist/config.json 中添加 mcp 配置');
console.log('\n示例:');
console.log(' {');
console.log(' "mcp": {');
console.log(' "filesystem": {');
console.log(' "type": "local",');
console.log(' "command": ["npx", "-y", "@anthropic-ai/mcp-server-filesystem", "/path/to/dir"]');
console.log(' }');
console.log(' }');
console.log(' }');
return;
}
const mcpManager = getMCPManager();
// 尝试连接以获取工具数量
try {
if (!mcpManager.isInitialized()) {
await mcpManager.initialize(mcpConfig);
}
} catch {
// 忽略连接错误,仍然显示配置的服务器
}
const statuses = mcpManager.getServerStatuses();
console.log('\nMCP 服务器列表:\n');
const statusIcons: Record<string, string> = {
connected: '✅',
connecting: '🔄',
disconnected: '⭕',
disabled: '🚫',
error: '❌',
};
for (const status of statuses) {
const icon = statusIcons[status.status] || '❓';
const toolInfo = status.toolCount > 0 ? ` (${status.toolCount} 个工具)` : '';
const errorInfo = status.error ? ` - ${status.error}` : '';
console.log(
` ${icon} ${status.name} [${status.type}] - ${status.status}${toolInfo}${errorInfo}`
);
}
console.log('');
// 关闭连接
await mcpManager.shutdown();
});
mcpCommand
.command('tools [server]')
.description('列出 MCP 服务器提供的工具')
.action(async (server?: string) => {
const mcpConfig = loadMCPConfig(process.cwd());
if (!mcpConfig.mcp || Object.keys(mcpConfig.mcp).length === 0) {
console.log('没有配置 MCP 服务器');
return;
}
const mcpManager = getMCPManager();
try {
if (!mcpManager.isInitialized()) {
await mcpManager.initialize(mcpConfig);
}
const tools = mcpManager.getTools();
if (tools.length === 0) {
console.log('没有可用的 MCP 工具');
return;
}
// 按服务器分组
const toolsByServer = new Map<string, typeof tools>();
for (const tool of tools) {
if (server && tool.server !== server) {
continue;
}
const serverTools = toolsByServer.get(tool.server) || [];
serverTools.push(tool);
toolsByServer.set(tool.server, serverTools);
}
if (toolsByServer.size === 0) {
console.log(server ? `服务器 "${server}" 没有提供工具` : '没有可用的工具');
return;
}
console.log('\nMCP 工具列表:\n');
for (const [serverName, serverTools] of toolsByServer) {
console.log(`📦 ${serverName}:`);
for (const tool of serverTools) {
console.log(` ${tool.name}`);
if (tool.description) {
console.log(` ${tool.description.substring(0, 80)}${tool.description.length > 80 ? '...' : ''}`);
}
}
console.log('');
}
} catch (error) {
console.error(
'获取工具列表失败:',
error instanceof Error ? error.message : String(error)
);
} finally {
await mcpManager.shutdown();
}
});
mcpCommand
.command('test <server>')
.description('测试 MCP 服务器连接')
.action(async (server: string) => {
const mcpConfig = loadMCPConfig(process.cwd());
if (!mcpConfig.mcp?.[server]) {
console.log(`❌ 未找到服务器配置: ${server}`);
return;
}
console.log(`🔄 正在连接 ${server}...`);
const mcpManager = getMCPManager();
try {
await mcpManager.initialize({
mcp: { [server]: mcpConfig.mcp[server] },
tools: mcpConfig.tools,
});
const status = mcpManager.getServerStatus(server);
if (status?.status === 'connected') {
console.log(`✅ 连接成功!`);
console.log(` 工具数量: ${status.toolCount}`);
const tools = mcpManager.getTools();
if (tools.length > 0) {
console.log(' 可用工具:');
for (const tool of tools) {
console.log(` - ${tool.originalName}`);
}
}
} else {
console.log(`❌ 连接失败: ${status?.error || '未知错误'}`);
}
} catch (error) {
console.error(
`❌ 连接失败:`,
error instanceof Error ? error.message : String(error)
);
} finally {
await mcpManager.shutdown();
}
});
// 初始化权限系统
function setupPermissions(): void {
const permissionManager = getPermissionManager();
@@ -111,6 +375,9 @@ program.action(async () => {
// 初始化 LSP 系统
initLSP(process.cwd());
// 初始化 MCP 系统(加载外部工具服务器)
await initMCP(process.cwd());
// 设置工具注册表(支持动态工具发现)
agent.setRegistry(toolRegistry);
@@ -150,6 +417,7 @@ program.action(async () => {
// 优雅退出
process.on('SIGINT', async () => {
console.log('\n\n👋 再见!');
await shutdownMCP();
await shutdownLSP();
await sessionManager.close();
ui.close();
+265
View File
@@ -0,0 +1,265 @@
/**
* MCP 客户端
* 管理与单个 MCP 服务器的连接
*/
import type {
Transport,
MCPServerConfig,
MCPTool,
MCPServerStatus,
MCPServerStatusType,
MCPToolCallResult,
InitializeParams,
InitializeResult,
ListToolsResult,
CallToolParams,
CallToolResult,
ServerCapabilities,
MCPContent,
} from './types.js';
import { createTransport } from './transports/index.js';
/** MCP 协议版本 */
const PROTOCOL_VERSION = '2024-11-05';
/** 客户端信息 */
const CLIENT_INFO = {
name: 'ai-terminal-assistant',
version: '1.0.0',
};
/**
* MCP 客户端类
*/
export class MCPClient {
private transport: Transport;
private serverCapabilities?: ServerCapabilities;
private serverInfo?: { name: string; version: string };
private tools: MCPTool[] = [];
private _status: MCPServerStatusType = 'disconnected';
private _error?: string;
private lastConnected?: Date;
constructor(
private name: string,
private config: MCPServerConfig
) {
this.transport = createTransport(name, config);
}
/**
* 连接到 MCP 服务器
*/
async connect(): Promise<void> {
if (this._status === 'connected') {
return;
}
this._status = 'connecting';
this._error = undefined;
try {
// 启动传输层
await this.transport.start();
// 监听连接关闭
this.transport.onClose((error) => {
this._status = 'disconnected';
if (error) {
this._error = error.message;
}
});
// 监听服务器通知
this.transport.onNotification((method, params) => {
this.handleNotification(method, params);
});
// 初始化握手
await this.initialize();
// 获取工具列表
await this.refreshTools();
this._status = 'connected';
this.lastConnected = new Date();
} catch (error) {
this._status = 'error';
this._error = error instanceof Error ? error.message : String(error);
throw error;
}
}
/**
* 断开连接
*/
async disconnect(): Promise<void> {
if (this._status === 'disconnected') {
return;
}
try {
await this.transport.close();
} finally {
this._status = 'disconnected';
this.tools = [];
}
}
/**
* MCP 初始化握手
*/
private async initialize(): Promise<void> {
const params: InitializeParams = {
protocolVersion: PROTOCOL_VERSION,
capabilities: {
roots: { listChanged: true },
},
clientInfo: CLIENT_INFO,
};
const result = (await this.transport.request(
'initialize',
params
)) as InitializeResult;
this.serverCapabilities = result.capabilities;
this.serverInfo = result.serverInfo;
// 发送 initialized 通知
await this.transport.notify('notifications/initialized');
}
/**
* 刷新工具列表
*/
async refreshTools(): Promise<void> {
const result = (await this.transport.request(
'tools/list',
{}
)) as ListToolsResult;
this.tools = result.tools.map((tool) => ({
server: this.name,
name: `${this.name}-${tool.name}`,
originalName: tool.name,
description: tool.description || '',
inputSchema: tool.inputSchema,
outputSchema: tool.outputSchema,
}));
}
/**
* 调用工具
*/
async callTool(
toolName: string,
args: Record<string, unknown>
): Promise<MCPToolCallResult> {
// 获取原始工具名
const tool = this.tools.find((t) => t.name === toolName);
if (!tool) {
return {
success: false,
content: [{ type: 'text', text: `Tool not found: ${toolName}` }],
isError: true,
};
}
try {
const params: CallToolParams = {
name: tool.originalName,
arguments: args,
};
const result = (await this.transport.request(
'tools/call',
params
)) as CallToolResult;
return {
success: !result.isError,
content: result.content as MCPContent[],
isError: result.isError,
};
} catch (error) {
return {
success: false,
content: [
{
type: 'text',
text: error instanceof Error ? error.message : String(error),
},
],
isError: true,
};
}
}
/**
* 获取工具列表
*/
getTools(): MCPTool[] {
return [...this.tools];
}
/**
* 获取状态
*/
getStatus(): MCPServerStatus {
return {
name: this.name,
type: this.config.type,
status: this._status,
toolCount: this.tools.length,
error: this._error,
lastConnected: this.lastConnected,
};
}
/**
* 获取服务器信息
*/
getServerInfo(): { name: string; version: string } | undefined {
return this.serverInfo;
}
/**
* 获取服务器能力
*/
getServerCapabilities(): ServerCapabilities | undefined {
return this.serverCapabilities;
}
/**
* 处理服务器通知
*/
private handleNotification(method: string, params: unknown): void {
switch (method) {
case 'notifications/tools/list_changed':
// 工具列表变化,刷新
this.refreshTools().catch((error) => {
console.error(
`[MCP:${this.name}] Failed to refresh tools:`,
error
);
});
break;
case 'notifications/resources/list_changed':
// 资源列表变化(暂不处理)
break;
case 'notifications/prompts/list_changed':
// 提示列表变化(暂不处理)
break;
default:
console.debug(
`[MCP:${this.name}] Unknown notification: ${method}`,
params
);
}
}
}
+342
View File
@@ -0,0 +1,342 @@
/**
* MCP 配置加载和验证
*/
import * as fs from 'fs';
import * as path from 'path';
import * as yaml from 'yaml';
import type {
MCPConfig,
MCPServerConfig,
LocalMCPServer,
RemoteMCPServer,
} from './types.js';
/** 默认配置值 */
const DEFAULTS = {
timeout: 30000,
enabled: true,
};
/**
* 解析环境变量引用
* 支持 {env:VAR_NAME} 语法
*/
export function resolveEnvVariables(value: string): string {
return value.replace(/\{env:([^}]+)\}/g, (_, varName) => {
const envValue = process.env[varName];
if (envValue === undefined) {
console.warn(`警告: 环境变量 ${varName} 未设置`);
return '';
}
return envValue;
});
}
/**
* 递归解析对象中的环境变量
*/
export function resolveEnvInObject<T>(obj: T): T {
if (typeof obj === 'string') {
return resolveEnvVariables(obj) as T;
}
if (Array.isArray(obj)) {
return obj.map((item) => resolveEnvInObject(item)) as T;
}
if (obj !== null && typeof obj === 'object') {
const result: Record<string, unknown> = {};
for (const [key, value] of Object.entries(obj)) {
result[key] = resolveEnvInObject(value);
}
return result as T;
}
return obj;
}
/**
* 验证本地服务器配置
*/
function validateLocalServer(
name: string,
config: LocalMCPServer
): string | null {
if (!config.command || !Array.isArray(config.command)) {
return `服务器 "${name}": command 必须是字符串数组`;
}
if (config.command.length === 0) {
return `服务器 "${name}": command 不能为空`;
}
if (config.timeout !== undefined && typeof config.timeout !== 'number') {
return `服务器 "${name}": timeout 必须是数字`;
}
return null;
}
/**
* 验证远程服务器配置
*/
function validateRemoteServer(
name: string,
config: RemoteMCPServer
): string | null {
if (!config.url || typeof config.url !== 'string') {
return `服务器 "${name}": url 是必需的`;
}
try {
new URL(config.url);
} catch {
return `服务器 "${name}": url 格式无效`;
}
if (config.timeout !== undefined && typeof config.timeout !== 'number') {
return `服务器 "${name}": timeout 必须是数字`;
}
return null;
}
/**
* 验证服务器配置
*/
function validateServerConfig(
name: string,
config: MCPServerConfig
): string | null {
if (!config.type) {
return `服务器 "${name}": type 是必需的 (local 或 remote)`;
}
if (config.type === 'local') {
return validateLocalServer(name, config as LocalMCPServer);
}
if (config.type === 'remote') {
return validateRemoteServer(name, config as RemoteMCPServer);
}
return `服务器 "${name}": type 必须是 "local" 或 "remote"`;
}
/**
* 验证 MCP 配置
*/
export function validateMCPConfig(config: MCPConfig): string[] {
const errors: string[] = [];
if (!config.mcp) {
return errors; // 没有 MCP 配置是合法的
}
if (typeof config.mcp !== 'object' || Array.isArray(config.mcp)) {
errors.push('mcp 配置必须是对象格式');
return errors;
}
for (const [name, serverConfig] of Object.entries(config.mcp)) {
// 验证服务器名称
if (!/^[a-zA-Z][a-zA-Z0-9_-]*$/.test(name)) {
errors.push(
`服务器名称 "${name}" 无效: 必须以字母开头,只能包含字母、数字、下划线和连字符`
);
continue;
}
const error = validateServerConfig(name, serverConfig);
if (error) {
errors.push(error);
}
}
// 验证 tools 配置
if (config.tools) {
if (typeof config.tools !== 'object' || Array.isArray(config.tools)) {
errors.push('tools 配置必须是对象格式');
} else {
for (const [pattern, enabled] of Object.entries(config.tools)) {
if (typeof enabled !== 'boolean') {
errors.push(`tools["${pattern}"] 的值必须是布尔值`);
}
}
}
}
return errors;
}
/**
* 规范化服务器配置(添加默认值)
*/
function normalizeServerConfig(config: MCPServerConfig): MCPServerConfig {
return {
...config,
enabled: config.enabled ?? DEFAULTS.enabled,
timeout: config.timeout ?? DEFAULTS.timeout,
};
}
/**
* 规范化 MCP 配置
*/
export function normalizeMCPConfig(config: MCPConfig): MCPConfig {
if (!config.mcp) {
return config;
}
const normalizedMcp: Record<string, MCPServerConfig> = {};
for (const [name, serverConfig] of Object.entries(config.mcp)) {
normalizedMcp[name] = normalizeServerConfig(serverConfig);
}
return {
...config,
mcp: normalizedMcp,
};
}
/**
* 从文件加载配置
*/
function loadConfigFile(filePath: string): MCPConfig | null {
if (!fs.existsSync(filePath)) {
return null;
}
try {
const content = fs.readFileSync(filePath, 'utf-8');
const ext = path.extname(filePath).toLowerCase();
if (ext === '.json' || ext === '.jsonc') {
// 移除 JSONC 注释
const jsonContent = content.replace(
/\/\/.*$|\/\*[\s\S]*?\*\//gm,
''
);
return JSON.parse(jsonContent);
}
if (ext === '.yaml' || ext === '.yml') {
return yaml.parse(content);
}
console.warn(`不支持的配置文件格式: ${ext}`);
return null;
} catch (error) {
console.error(`加载配置文件失败 ${filePath}:`, error);
return null;
}
}
/**
* 合并配置(后者覆盖前者)
*/
function mergeConfigs(base: MCPConfig, override: MCPConfig): MCPConfig {
return {
mcp: {
...base.mcp,
...override.mcp,
},
tools: {
...base.tools,
...override.tools,
},
};
}
/**
* 加载 MCP 配置
* 按优先级从低到高加载:
* 1. 用户级: ~/.ai-assist/config.{json,yaml}
* 2. 项目级: .ai-assist/config.{json,yaml}
*/
export function loadMCPConfig(workdir?: string): MCPConfig {
const cwd = workdir || process.cwd();
const homeDir = process.env.HOME || process.env.USERPROFILE || '';
// 配置文件搜索路径
const searchPaths = [
// 用户级配置
path.join(homeDir, '.ai-assist', 'config.json'),
path.join(homeDir, '.ai-assist', 'config.jsonc'),
path.join(homeDir, '.ai-assist', 'config.yaml'),
path.join(homeDir, '.ai-assist', 'config.yml'),
// 项目级配置
path.join(cwd, '.ai-assist', 'config.json'),
path.join(cwd, '.ai-assist', 'config.jsonc'),
path.join(cwd, '.ai-assist', 'config.yaml'),
path.join(cwd, '.ai-assist', 'config.yml'),
];
let mergedConfig: MCPConfig = {};
for (const configPath of searchPaths) {
const config = loadConfigFile(configPath);
if (config) {
mergedConfig = mergeConfigs(mergedConfig, config);
}
}
// 验证配置
const errors = validateMCPConfig(mergedConfig);
if (errors.length > 0) {
console.error('MCP 配置验证失败:');
for (const error of errors) {
console.error(` - ${error}`);
}
// 返回空配置而不是无效配置
return {};
}
// 规范化配置
const normalizedConfig = normalizeMCPConfig(mergedConfig);
// 解析环境变量
return resolveEnvInObject(normalizedConfig);
}
/**
* 获取已启用的服务器列表
*/
export function getEnabledServers(
config: MCPConfig
): Array<{ name: string; config: MCPServerConfig }> {
if (!config.mcp) {
return [];
}
return Object.entries(config.mcp)
.filter(([, serverConfig]) => serverConfig.enabled !== false)
.map(([name, serverConfig]) => ({ name, config: serverConfig }));
}
/**
* 检查工具是否被配置启用
* 支持通配符匹配,如 "server-*"
*/
export function isToolEnabled(
toolName: string,
toolsConfig?: Record<string, boolean>
): boolean {
if (!toolsConfig) {
return true; // 默认启用
}
// 精确匹配优先
if (toolName in toolsConfig) {
return toolsConfig[toolName];
}
// 通配符匹配(从最具体到最通用)
const patterns = Object.keys(toolsConfig)
.filter((pattern) => pattern.includes('*'))
.sort((a, b) => {
// 更具体的模式(* 出现位置更靠后)优先
const aPos = a.indexOf('*');
const bPos = b.indexOf('*');
return bPos - aPos;
});
for (const pattern of patterns) {
const regex = new RegExp(
'^' + pattern.replace(/\*/g, '.*').replace(/\?/g, '.') + '$'
);
if (regex.test(toolName)) {
return toolsConfig[pattern];
}
}
return true; // 默认启用
}
+47
View File
@@ -0,0 +1,47 @@
/**
* MCP 模块导出入口
*/
// 类型导出
export type {
MCPConfig,
MCPServerConfig,
LocalMCPServer,
RemoteMCPServer,
OAuthConfig,
MCPTool,
MCPServerStatus,
MCPServerStatusType,
MCPToolCallResult,
MCPContent,
MCPTextContent,
MCPImageContent,
MCPResourceContent,
Transport,
ServerCapabilities,
ClientCapabilities,
MCPManagerEvents,
} from './types.js';
// 配置相关
export {
loadMCPConfig,
validateMCPConfig,
normalizeMCPConfig,
getEnabledServers,
isToolEnabled,
resolveEnvVariables,
resolveEnvInObject,
} from './config.js';
// 客户端
export { MCPClient } from './client.js';
// 管理器
export { MCPManager, getMCPManager, resetMCPManager } from './manager.js';
// 工具适配器
export { MCPToolAdapter, createMCPToolAdapter } from './tool-adapter.js';
// 传输层
export { createTransport, StdioTransport } from './transports/index.js';
+319
View File
@@ -0,0 +1,319 @@
/**
* MCP Manager
* 管理多个 MCP 服务器的生命周期
*/
import { EventEmitter } from 'events';
import type {
MCPConfig,
MCPServerConfig,
MCPTool,
MCPServerStatus,
MCPToolCallResult,
MCPManagerEvents,
} from './types.js';
import { MCPClient } from './client.js';
import { getEnabledServers, isToolEnabled } from './config.js';
/**
* MCP Manager 类
* 负责管理所有 MCP 服务器连接
*/
export class MCPManager extends EventEmitter {
private clients: Map<string, MCPClient> = new Map();
private config: MCPConfig = {};
private initialized = false;
/**
* 初始化所有已启用的服务器
*/
async initialize(config: MCPConfig): Promise<void> {
if (this.initialized) {
await this.shutdown();
}
this.config = config;
const servers = getEnabledServers(config);
// 并行连接所有服务器
const connectPromises = servers.map(async ({ name, config: serverConfig }) => {
try {
await this.connectServer(name, serverConfig);
} catch (error) {
console.error(
`[MCP] Failed to connect to ${name}:`,
error instanceof Error ? error.message : String(error)
);
}
});
await Promise.all(connectPromises);
this.initialized = true;
}
/**
* 连接单个服务器
*/
private async connectServer(
name: string,
config: MCPServerConfig
): Promise<void> {
const client = new MCPClient(name, config);
try {
await client.connect();
this.clients.set(name, client);
this.emit('server:connected', name);
} catch (error) {
this.emit('server:error', name, error instanceof Error ? error : new Error(String(error)));
throw error;
}
}
/**
* 关闭所有连接
*/
async shutdown(): Promise<void> {
const disconnectPromises = Array.from(this.clients.entries()).map(
async ([name, client]) => {
try {
await client.disconnect();
this.emit('server:disconnected', name);
} catch (error) {
console.error(`[MCP] Error disconnecting ${name}:`, error);
}
}
);
await Promise.all(disconnectPromises);
this.clients.clear();
this.initialized = false;
}
/**
* 重连指定服务器
*/
async reconnect(serverName: string): Promise<void> {
const existingClient = this.clients.get(serverName);
if (existingClient) {
await existingClient.disconnect();
this.clients.delete(serverName);
this.emit('server:disconnected', serverName);
}
const serverConfig = this.config.mcp?.[serverName];
if (!serverConfig) {
throw new Error(`Server not found: ${serverName}`);
}
await this.connectServer(serverName, serverConfig);
this.emit('tools:changed');
}
/**
* 获取所有可用工具
*/
getTools(): MCPTool[] {
const allTools: MCPTool[] = [];
for (const client of this.clients.values()) {
const status = client.getStatus();
if (status.status === 'connected') {
const tools = client.getTools();
// 根据配置过滤工具
for (const tool of tools) {
if (isToolEnabled(tool.name, this.config.tools)) {
allTools.push(tool);
}
}
}
}
return allTools;
}
/**
* 获取指定工具
*/
getTool(name: string): MCPTool | undefined {
for (const client of this.clients.values()) {
const tools = client.getTools();
const tool = tools.find((t) => t.name === name);
if (tool && isToolEnabled(tool.name, this.config.tools)) {
return tool;
}
}
return undefined;
}
/**
* 调用工具
*/
async callTool(
name: string,
args: Record<string, unknown>
): Promise<MCPToolCallResult> {
// 从工具名中解析服务器名
const dashIndex = name.indexOf('-');
if (dashIndex === -1) {
return {
success: false,
content: [{ type: 'text', text: `Invalid tool name format: ${name}` }],
isError: true,
};
}
const serverName = name.substring(0, dashIndex);
const client = this.clients.get(serverName);
if (!client) {
return {
success: false,
content: [{ type: 'text', text: `Server not connected: ${serverName}` }],
isError: true,
};
}
// 检查工具是否启用
if (!isToolEnabled(name, this.config.tools)) {
return {
success: false,
content: [{ type: 'text', text: `Tool is disabled: ${name}` }],
isError: true,
};
}
return await client.callTool(name, args);
}
/**
* 获取所有服务器状态
*/
getServerStatuses(): MCPServerStatus[] {
const statuses: MCPServerStatus[] = [];
// 已连接的服务器
for (const client of this.clients.values()) {
statuses.push(client.getStatus());
}
// 未连接但已配置的服务器
if (this.config.mcp) {
for (const [name, serverConfig] of Object.entries(this.config.mcp)) {
if (!this.clients.has(name)) {
statuses.push({
name,
type: serverConfig.type,
status: serverConfig.enabled === false ? 'disabled' : 'disconnected',
toolCount: 0,
});
}
}
}
return statuses;
}
/**
* 获取指定服务器状态
*/
getServerStatus(name: string): MCPServerStatus | undefined {
const client = this.clients.get(name);
if (client) {
return client.getStatus();
}
const serverConfig = this.config.mcp?.[name];
if (serverConfig) {
return {
name,
type: serverConfig.type,
status: serverConfig.enabled === false ? 'disabled' : 'disconnected',
toolCount: 0,
};
}
return undefined;
}
/**
* 启用/禁用服务器
*/
async setServerEnabled(serverName: string, enabled: boolean): Promise<void> {
const serverConfig = this.config.mcp?.[serverName];
if (!serverConfig) {
throw new Error(`Server not found: ${serverName}`);
}
if (enabled) {
// 启用并连接
serverConfig.enabled = true;
if (!this.clients.has(serverName)) {
await this.connectServer(serverName, serverConfig);
this.emit('tools:changed');
}
} else {
// 禁用并断开
serverConfig.enabled = false;
const client = this.clients.get(serverName);
if (client) {
await client.disconnect();
this.clients.delete(serverName);
this.emit('server:disconnected', serverName);
this.emit('tools:changed');
}
}
}
/**
* 获取客户端实例(用于测试)
*/
getClient(name: string): MCPClient | undefined {
return this.clients.get(name);
}
/**
* 是否已初始化
*/
isInitialized(): boolean {
return this.initialized;
}
// 类型安全的事件方法
override on<K extends keyof MCPManagerEvents>(
event: K,
listener: MCPManagerEvents[K]
): this {
return super.on(event, listener);
}
override emit<K extends keyof MCPManagerEvents>(
event: K,
...args: Parameters<MCPManagerEvents[K]>
): boolean {
return super.emit(event, ...args);
}
}
// 单例实例
let mcpManager: MCPManager | null = null;
/**
* 获取 MCP Manager 单例
*/
export function getMCPManager(): MCPManager {
if (!mcpManager) {
mcpManager = new MCPManager();
}
return mcpManager;
}
/**
* 重置 MCP Manager(用于测试)
*/
export function resetMCPManager(): void {
if (mcpManager) {
mcpManager.shutdown().catch(console.error);
mcpManager = null;
}
}
+166
View File
@@ -0,0 +1,166 @@
/**
* MCP 工具适配器
* 将 MCP 工具转换为内部工具格式
*/
import type { ToolParameter, ToolResult } from '../types/index.js';
import type { ToolWithMetadata, ToolCategory } from '../tools/types.js';
import type { MCPTool, MCPToolCallResult, MCPContent } from './types.js';
import type { MCPManager } from './manager.js';
/**
* 将 JSON Schema 类型转换为内部参数类型
*/
function convertJsonSchemaType(
schemaType: string | string[] | undefined
): 'string' | 'number' | 'boolean' | 'object' | 'array' {
if (Array.isArray(schemaType)) {
// 取第一个非 null 类型
const type = schemaType.find((t) => t !== 'null');
return convertJsonSchemaType(type);
}
switch (schemaType) {
case 'string':
return 'string';
case 'number':
case 'integer':
return 'number';
case 'boolean':
return 'boolean';
case 'array':
return 'array';
case 'object':
default:
return 'object';
}
}
/**
* 将 JSON Schema 转换为内部参数定义
*/
function convertInputSchema(
schema: MCPTool['inputSchema']
): Record<string, ToolParameter> {
const parameters: Record<string, ToolParameter> = {};
if (!schema || typeof schema !== 'object') {
return parameters;
}
const properties = schema.properties as Record<string, {
type?: string | string[];
description?: string;
}> | undefined;
const required = (schema.required as string[]) || [];
if (properties) {
for (const [name, prop] of Object.entries(properties)) {
parameters[name] = {
type: convertJsonSchemaType(prop.type),
description: prop.description || '',
required: required.includes(name),
};
}
}
return parameters;
}
/**
* 将 MCP 内容转换为字符串输出
*/
function contentToString(content: MCPContent[]): string {
const parts: string[] = [];
for (const item of content) {
switch (item.type) {
case 'text':
parts.push(item.text);
break;
case 'image':
parts.push(`[Image: ${item.mimeType}]`);
break;
case 'resource':
if (item.resource.text) {
parts.push(item.resource.text);
} else {
parts.push(`[Resource: ${item.resource.uri}]`);
}
break;
}
}
return parts.join('\n');
}
/**
* 将 MCP 调用结果转换为内部 ToolResult
*/
function convertToolResult(result: MCPToolCallResult): ToolResult {
const output = contentToString(result.content);
if (result.isError || !result.success) {
return {
success: false,
output: '',
error: output || 'Tool execution failed',
};
}
return {
success: true,
output,
};
}
/**
* MCP 工具适配器类
*/
export class MCPToolAdapter {
constructor(private manager: MCPManager) {}
/**
* 将 MCP 工具适配为内部工具格式
*/
adaptToInternalTool(mcpTool: MCPTool): ToolWithMetadata {
const parameters = convertInputSchema(mcpTool.inputSchema);
return {
name: mcpTool.name,
description: mcpTool.description || `MCP tool: ${mcpTool.originalName}`,
parameters,
execute: async (params: Record<string, unknown>): Promise<ToolResult> => {
const result = await this.manager.callTool(mcpTool.name, params);
return convertToolResult(result);
},
metadata: {
name: mcpTool.name,
category: 'agent' as ToolCategory, // MCP 工具归类为 agent
description: mcpTool.description || `MCP tool from ${mcpTool.server}`,
keywords: [
'mcp',
mcpTool.server,
mcpTool.originalName,
...mcpTool.originalName.split(/[_-]/),
],
deferLoading: false, // MCP 工具不延迟加载
},
};
}
/**
* 批量适配工具
*/
adaptTools(mcpTools: MCPTool[]): ToolWithMetadata[] {
return mcpTools.map((tool) => this.adaptToInternalTool(tool));
}
}
/**
* 创建 MCP 工具适配器
*/
export function createMCPToolAdapter(manager: MCPManager): MCPToolAdapter {
return new MCPToolAdapter(manager);
}
+27
View File
@@ -0,0 +1,27 @@
/**
* 传输层工厂
*/
import type { Transport, MCPServerConfig, LocalMCPServer } from '../types.js';
import { StdioTransport } from './stdio.js';
/**
* 创建传输层实例
*/
export function createTransport(
serverName: string,
config: MCPServerConfig
): Transport {
if (config.type === 'local') {
return new StdioTransport(config as LocalMCPServer, serverName);
}
if (config.type === 'remote') {
// TODO: 实现 HTTP 传输
throw new Error('Remote transport not yet implemented');
}
throw new Error(`Unknown transport type: ${(config as MCPServerConfig).type}`);
}
export { StdioTransport } from './stdio.js';
+278
View File
@@ -0,0 +1,278 @@
/**
* stdio 传输实现
* 通过子进程的 stdin/stdout 与 MCP 服务器通信
*/
import { spawn, type ChildProcess } from 'child_process';
import { EventEmitter } from 'events';
import type {
Transport,
JSONRPCRequest,
JSONRPCResponse,
JSONRPCNotification,
LocalMCPServer,
} from '../types.js';
/** 默认请求超时 */
const DEFAULT_TIMEOUT = 30000;
/** 待处理请求 */
interface PendingRequest {
resolve: (result: unknown) => void;
reject: (error: Error) => void;
timer: NodeJS.Timeout;
}
/**
* stdio 传输层实现
*/
export class StdioTransport extends EventEmitter implements Transport {
private process: ChildProcess | null = null;
private nextId = 1;
private pendingRequests = new Map<string | number, PendingRequest>();
private buffer = '';
private notificationHandler?: (method: string, params: unknown) => void;
private closeHandler?: (error?: Error) => void;
private isClosing = false;
constructor(
private config: LocalMCPServer,
private serverName: string
) {
super();
}
/**
* 启动传输(创建子进程)
*/
async start(): Promise<void> {
if (this.process) {
throw new Error('Transport already started');
}
const [command, ...args] = this.config.command;
return new Promise((resolve, reject) => {
try {
this.process = spawn(command, args, {
cwd: this.config.cwd,
env: {
...process.env,
...this.config.env,
},
stdio: ['pipe', 'pipe', 'pipe'],
});
// 处理 stdoutJSON-RPC 消息)
this.process.stdout?.on('data', (data: Buffer) => {
this.handleData(data);
});
// 处理 stderr(日志输出)
this.process.stderr?.on('data', (data: Buffer) => {
const message = data.toString().trim();
if (message) {
console.debug(`[MCP:${this.serverName}] ${message}`);
}
});
// 处理进程错误
this.process.on('error', (error) => {
if (!this.isClosing) {
this.handleClose(error);
}
reject(error);
});
// 处理进程退出
this.process.on('exit', (code, signal) => {
if (!this.isClosing) {
const error =
code !== 0
? new Error(
`Process exited with code ${code}${signal ? ` (signal: ${signal})` : ''}`
)
: undefined;
this.handleClose(error);
}
});
// 给进程一点时间启动
setTimeout(() => {
if (this.process && !this.process.killed) {
resolve();
}
}, 100);
} catch (error) {
reject(error);
}
});
}
/**
* 关闭传输
*/
async close(): Promise<void> {
this.isClosing = true;
// 拒绝所有待处理请求
for (const [id, pending] of this.pendingRequests) {
clearTimeout(pending.timer);
pending.reject(new Error('Transport closed'));
this.pendingRequests.delete(id);
}
// 终止子进程
if (this.process && !this.process.killed) {
return new Promise((resolve) => {
const timeout = setTimeout(() => {
this.process?.kill('SIGKILL');
resolve();
}, 5000);
this.process!.on('exit', () => {
clearTimeout(timeout);
resolve();
});
this.process!.kill('SIGTERM');
});
}
}
/**
* 发送请求并等待响应
*/
async request(method: string, params?: unknown): Promise<unknown> {
if (!this.process || this.process.killed) {
throw new Error('Transport not connected');
}
const id = this.nextId++;
const request: JSONRPCRequest = {
jsonrpc: '2.0',
id,
method,
params,
};
return new Promise((resolve, reject) => {
const timeout = this.config.timeout ?? DEFAULT_TIMEOUT;
const timer = setTimeout(() => {
this.pendingRequests.delete(id);
reject(new Error(`Request timeout after ${timeout}ms: ${method}`));
}, timeout);
this.pendingRequests.set(id, { resolve, reject, timer });
this.send(request);
});
}
/**
* 发送通知(无响应)
*/
async notify(method: string, params?: unknown): Promise<void> {
if (!this.process || this.process.killed) {
throw new Error('Transport not connected');
}
const notification: JSONRPCNotification = {
jsonrpc: '2.0',
method,
params,
};
this.send(notification);
}
/**
* 监听服务器通知
*/
onNotification(handler: (method: string, params: unknown) => void): void {
this.notificationHandler = handler;
}
/**
* 监听连接关闭
*/
onClose(handler: (error?: Error) => void): void {
this.closeHandler = handler;
}
/**
* 发送 JSON-RPC 消息
*/
private send(message: JSONRPCRequest | JSONRPCNotification): void {
const data = JSON.stringify(message) + '\n';
this.process?.stdin?.write(data);
}
/**
* 处理接收到的数据
*/
private handleData(data: Buffer): void {
this.buffer += data.toString();
// 按行分割处理
const lines = this.buffer.split('\n');
this.buffer = lines.pop() || ''; // 保留不完整的行
for (const line of lines) {
if (line.trim()) {
try {
this.handleMessage(JSON.parse(line));
} catch (error) {
console.error(
`[MCP:${this.serverName}] Failed to parse message:`,
line
);
}
}
}
}
/**
* 处理 JSON-RPC 消息
*/
private handleMessage(message: JSONRPCResponse | JSONRPCNotification): void {
// 响应消息
if ('id' in message && message.id !== undefined) {
const pending = this.pendingRequests.get(message.id);
if (pending) {
clearTimeout(pending.timer);
this.pendingRequests.delete(message.id);
if ('error' in message && message.error) {
pending.reject(
new Error(
`${message.error.message} (code: ${message.error.code})`
)
);
} else {
pending.resolve(message.result);
}
}
return;
}
// 通知消息
if ('method' in message) {
this.notificationHandler?.(message.method, message.params);
}
}
/**
* 处理连接关闭
*/
private handleClose(error?: Error): void {
// 拒绝所有待处理请求
for (const [id, pending] of this.pendingRequests) {
clearTimeout(pending.timer);
pending.reject(error || new Error('Connection closed'));
this.pendingRequests.delete(id);
}
this.closeHandler?.(error);
this.process = null;
}
}
+276
View File
@@ -0,0 +1,276 @@
/**
* MCP (Model Context Protocol) 类型定义
*/
import type { JSONSchema7 } from 'json-schema';
// ============================================================================
// 配置类型
// ============================================================================
/** 本地 MCP 服务器配置 (stdio 传输) */
export interface LocalMCPServer {
type: 'local';
/** 启动命令和参数,如 ["npx", "-y", "@anthropic/mcp-server-filesystem", "/path"] */
command: string[];
/** 环境变量,支持 {env:VAR} 语法引用系统环境变量 */
env?: Record<string, string>;
/** 工作目录 */
cwd?: string;
/** 是否启用,默认 true */
enabled?: boolean;
/** 超时毫秒数,默认 30000 */
timeout?: number;
}
/** 远程 MCP 服务器配置 (HTTP/SSE 传输) */
export interface RemoteMCPServer {
type: 'remote';
/** MCP 端点 URL */
url: string;
/** 自定义请求头,支持 {env:VAR} 语法 */
headers?: Record<string, string>;
/** OAuth 配置,空对象 {} 表示自动发现 (RFC 7591) */
oauth?: OAuthConfig | Record<string, never>;
/** 是否启用,默认 true */
enabled?: boolean;
/** 超时毫秒数,默认 30000 */
timeout?: number;
}
/** OAuth 配置 */
export interface OAuthConfig {
clientId: string;
clientSecret: string;
scope?: string;
/** Token 端点,可选,自动发现时不需要 */
tokenEndpoint?: string;
}
/** MCP 服务器配置(本地或远程) */
export type MCPServerConfig = LocalMCPServer | RemoteMCPServer;
/** MCP 配置根节点 */
export interface MCPConfig {
/** MCP 服务器配置,key 为服务器名称 */
mcp?: Record<string, MCPServerConfig>;
/** 工具启用/禁用配置,支持通配符如 "server-*" */
tools?: Record<string, boolean>;
}
// ============================================================================
// 运行时类型
// ============================================================================
/** MCP 工具定义 */
export interface MCPTool {
/** 来源服务器名称 */
server: string;
/** 完整工具名: {server}-{originalName} */
name: string;
/** MCP 服务器中的原始名称 */
originalName: string;
/** 工具描述 */
description: string;
/** 输入参数 JSON Schema */
inputSchema: JSONSchema7;
/** 输出结果 JSON Schema(可选) */
outputSchema?: JSONSchema7;
}
/** 服务器运行状态 */
export type MCPServerStatusType =
| 'connected'
| 'connecting'
| 'disconnected'
| 'auth_required'
| 'disabled'
| 'error';
/** 服务器状态信息 */
export interface MCPServerStatus {
/** 服务器名称 */
name: string;
/** 服务器类型 */
type: 'local' | 'remote';
/** 当前状态 */
status: MCPServerStatusType;
/** 工具数量 */
toolCount: number;
/** 错误信息(当 status 为 error 时) */
error?: string;
/** 最后连接时间 */
lastConnected?: Date;
}
/** 工具调用结果 */
export interface MCPToolCallResult {
/** 是否成功 */
success: boolean;
/** 结果内容 */
content: MCPContent[];
/** 是否为错误结果 */
isError?: boolean;
}
/** MCP 内容类型 */
export type MCPContent =
| MCPTextContent
| MCPImageContent
| MCPResourceContent;
/** 文本内容 */
export interface MCPTextContent {
type: 'text';
text: string;
}
/** 图片内容 */
export interface MCPImageContent {
type: 'image';
data: string;
mimeType: string;
}
/** 资源内容 */
export interface MCPResourceContent {
type: 'resource';
resource: {
uri: string;
mimeType?: string;
text?: string;
blob?: string;
};
}
// ============================================================================
// 传输层类型
// ============================================================================
/** JSON-RPC 请求 */
export interface JSONRPCRequest {
jsonrpc: '2.0';
id: string | number;
method: string;
params?: unknown;
}
/** JSON-RPC 响应 */
export interface JSONRPCResponse {
jsonrpc: '2.0';
id: string | number;
result?: unknown;
error?: JSONRPCError;
}
/** JSON-RPC 通知 */
export interface JSONRPCNotification {
jsonrpc: '2.0';
method: string;
params?: unknown;
}
/** JSON-RPC 错误 */
export interface JSONRPCError {
code: number;
message: string;
data?: unknown;
}
/** 传输层接口 */
export interface Transport {
/** 启动传输 */
start(): Promise<void>;
/** 关闭传输 */
close(): Promise<void>;
/** 发送请求并等待响应 */
request(method: string, params?: unknown): Promise<unknown>;
/** 发送通知(无响应) */
notify(method: string, params?: unknown): Promise<void>;
/** 监听服务器通知 */
onNotification(handler: (method: string, params: unknown) => void): void;
/** 监听连接关闭 */
onClose(handler: (error?: Error) => void): void;
}
// ============================================================================
// MCP 协议消息类型
// ============================================================================
/** 服务器能力 */
export interface ServerCapabilities {
tools?: {
listChanged?: boolean;
};
resources?: {
subscribe?: boolean;
listChanged?: boolean;
};
prompts?: {
listChanged?: boolean;
};
}
/** 客户端能力 */
export interface ClientCapabilities {
roots?: {
listChanged?: boolean;
};
sampling?: Record<string, never>;
}
/** 初始化请求参数 */
export interface InitializeParams {
protocolVersion: string;
capabilities: ClientCapabilities;
clientInfo: {
name: string;
version: string;
};
}
/** 初始化响应结果 */
export interface InitializeResult {
protocolVersion: string;
capabilities: ServerCapabilities;
serverInfo: {
name: string;
version: string;
};
instructions?: string;
}
/** 工具列表响应 */
export interface ListToolsResult {
tools: Array<{
name: string;
description?: string;
inputSchema: JSONSchema7;
outputSchema?: JSONSchema7;
}>;
nextCursor?: string;
}
/** 工具调用参数 */
export interface CallToolParams {
name: string;
arguments?: Record<string, unknown>;
}
/** 工具调用响应 */
export interface CallToolResult {
content: MCPContent[];
isError?: boolean;
}
// ============================================================================
// 事件类型
// ============================================================================
/** MCP Manager 事件 */
export interface MCPManagerEvents {
'server:connected': (serverName: string) => void;
'server:disconnected': (serverName: string, error?: Error) => void;
'server:error': (serverName: string, error: Error) => void;
'tools:changed': () => void;
}
+351
View File
@@ -0,0 +1,351 @@
/**
* MCP 配置加载和验证测试
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import * as fs from 'fs';
import * as path from 'path';
import {
loadMCPConfig,
validateMCPConfig,
normalizeMCPConfig,
getEnabledServers,
isToolEnabled,
resolveEnvVariables,
resolveEnvInObject,
} from '../../../src/mcp/config.js';
import type { MCPConfig } from '../../../src/mcp/types.js';
// Mock fs 模块
vi.mock('fs');
vi.mock('path', async () => {
const actual = await vi.importActual('path');
return {
...actual,
extname: (actual as typeof import('path')).extname,
};
});
describe('MCP Config', () => {
beforeEach(() => {
vi.resetAllMocks();
// 默认不存在配置文件
vi.mocked(fs.existsSync).mockReturnValue(false);
});
afterEach(() => {
vi.restoreAllMocks();
});
describe('resolveEnvVariables', () => {
it('应该解析环境变量引用', () => {
process.env.TEST_VAR = 'test_value';
const result = resolveEnvVariables('{env:TEST_VAR}');
expect(result).toBe('test_value');
delete process.env.TEST_VAR;
});
it('应该处理多个环境变量', () => {
process.env.VAR1 = 'value1';
process.env.VAR2 = 'value2';
const result = resolveEnvVariables('{env:VAR1}/{env:VAR2}');
expect(result).toBe('value1/value2');
delete process.env.VAR1;
delete process.env.VAR2;
});
it('应该对未设置的变量返回空字符串', () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
const result = resolveEnvVariables('{env:NONEXISTENT_VAR}');
expect(result).toBe('');
expect(warnSpy).toHaveBeenCalled();
warnSpy.mockRestore();
});
it('应该保留普通字符串不变', () => {
const result = resolveEnvVariables('plain string');
expect(result).toBe('plain string');
});
});
describe('resolveEnvInObject', () => {
it('应该递归解析对象中的环境变量', () => {
process.env.TEST_KEY = 'secret';
const obj = {
key: '{env:TEST_KEY}',
nested: {
value: '{env:TEST_KEY}',
},
array: ['{env:TEST_KEY}'],
};
const result = resolveEnvInObject(obj);
expect(result).toEqual({
key: 'secret',
nested: {
value: 'secret',
},
array: ['secret'],
});
delete process.env.TEST_KEY;
});
it('应该保留非字符串值不变', () => {
const obj = {
number: 123,
boolean: true,
null: null,
};
const result = resolveEnvInObject(obj);
expect(result).toEqual(obj);
});
});
describe('validateMCPConfig', () => {
it('应该接受空配置', () => {
const errors = validateMCPConfig({});
expect(errors).toHaveLength(0);
});
it('应该接受有效的本地服务器配置', () => {
const config: MCPConfig = {
mcp: {
filesystem: {
type: 'local',
command: ['npx', '-y', '@anthropic-ai/mcp-server-filesystem'],
},
},
};
const errors = validateMCPConfig(config);
expect(errors).toHaveLength(0);
});
it('应该接受有效的远程服务器配置', () => {
const config: MCPConfig = {
mcp: {
remote: {
type: 'remote',
url: 'https://mcp.example.com',
},
},
};
const errors = validateMCPConfig(config);
expect(errors).toHaveLength(0);
});
it('应该拒绝无效的服务器名称', () => {
const config: MCPConfig = {
mcp: {
'123invalid': {
type: 'local',
command: ['echo'],
},
},
};
const errors = validateMCPConfig(config);
expect(errors.length).toBeGreaterThan(0);
expect(errors[0]).toContain('服务器名称');
});
it('应该拒绝缺少 command 的本地服务器', () => {
const config: MCPConfig = {
mcp: {
server: {
type: 'local',
command: [],
},
},
};
const errors = validateMCPConfig(config);
expect(errors.length).toBeGreaterThan(0);
expect(errors[0]).toContain('command');
});
it('应该拒绝无效的 URL', () => {
const config: MCPConfig = {
mcp: {
remote: {
type: 'remote',
url: 'invalid-url',
},
},
};
const errors = validateMCPConfig(config);
expect(errors.length).toBeGreaterThan(0);
expect(errors[0]).toContain('url');
});
it('应该验证 tools 配置', () => {
const config: MCPConfig = {
mcp: {},
tools: {
'valid-tool': true,
'invalid-tool': 'not-boolean' as unknown as boolean,
},
};
const errors = validateMCPConfig(config);
expect(errors.length).toBeGreaterThan(0);
expect(errors[0]).toContain('布尔值');
});
});
describe('normalizeMCPConfig', () => {
it('应该添加默认值', () => {
const config: MCPConfig = {
mcp: {
server: {
type: 'local',
command: ['echo'],
},
},
};
const normalized = normalizeMCPConfig(config);
expect(normalized.mcp?.server.enabled).toBe(true);
expect(normalized.mcp?.server.timeout).toBe(30000);
});
it('应该保留显式设置的值', () => {
const config: MCPConfig = {
mcp: {
server: {
type: 'local',
command: ['echo'],
enabled: false,
timeout: 5000,
},
},
};
const normalized = normalizeMCPConfig(config);
expect(normalized.mcp?.server.enabled).toBe(false);
expect(normalized.mcp?.server.timeout).toBe(5000);
});
});
describe('getEnabledServers', () => {
it('应该返回启用的服务器', () => {
const config: MCPConfig = {
mcp: {
enabled: {
type: 'local',
command: ['echo'],
enabled: true,
},
disabled: {
type: 'local',
command: ['echo'],
enabled: false,
},
default: {
type: 'local',
command: ['echo'],
},
},
};
const servers = getEnabledServers(config);
expect(servers).toHaveLength(2);
expect(servers.map((s) => s.name)).toContain('enabled');
expect(servers.map((s) => s.name)).toContain('default');
expect(servers.map((s) => s.name)).not.toContain('disabled');
});
it('应该处理空配置', () => {
const servers = getEnabledServers({});
expect(servers).toHaveLength(0);
});
});
describe('isToolEnabled', () => {
it('没有配置时默认启用', () => {
expect(isToolEnabled('any-tool')).toBe(true);
expect(isToolEnabled('any-tool', undefined)).toBe(true);
});
it('应该精确匹配工具名', () => {
const config = {
'server-tool': false,
'server-other': true,
};
expect(isToolEnabled('server-tool', config)).toBe(false);
expect(isToolEnabled('server-other', config)).toBe(true);
});
it('应该支持通配符匹配', () => {
const config = {
'server-*': false,
};
expect(isToolEnabled('server-tool1', config)).toBe(false);
expect(isToolEnabled('server-tool2', config)).toBe(false);
expect(isToolEnabled('other-tool', config)).toBe(true);
});
it('精确匹配应优先于通配符', () => {
const config = {
'server-*': false,
'server-special': true,
};
expect(isToolEnabled('server-special', config)).toBe(true);
expect(isToolEnabled('server-other', config)).toBe(false);
});
});
describe('loadMCPConfig', () => {
it('没有配置文件时返回空配置', () => {
vi.mocked(fs.existsSync).mockReturnValue(false);
const config = loadMCPConfig('/test/dir');
expect(config).toEqual({});
});
it('应该加载 JSON 配置文件', () => {
const testConfig = {
mcp: {
server: {
type: 'local',
command: ['echo'],
},
},
};
vi.mocked(fs.existsSync).mockImplementation((filePath) => {
return String(filePath).endsWith('config.json');
});
vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify(testConfig));
const config = loadMCPConfig('/test/dir');
expect(config.mcp).toBeDefined();
});
it('应该合并用户级和项目级配置', () => {
const userConfig = {
mcp: {
userServer: {
type: 'local',
command: ['user-cmd'],
},
},
};
const projectConfig = {
mcp: {
projectServer: {
type: 'local',
command: ['project-cmd'],
},
},
};
let callCount = 0;
vi.mocked(fs.existsSync).mockImplementation((filePath) => {
return (
String(filePath).includes('.ai-assist') &&
String(filePath).endsWith('config.json')
);
});
vi.mocked(fs.readFileSync).mockImplementation(() => {
callCount++;
// 第一次调用返回用户配置,第二次返回项目配置
return JSON.stringify(callCount === 1 ? userConfig : projectConfig);
});
const config = loadMCPConfig('/test/dir');
expect(config.mcp?.userServer).toBeDefined();
expect(config.mcp?.projectServer).toBeDefined();
});
});
});
+157
View File
@@ -0,0 +1,157 @@
/**
* MCP Manager 测试
* 测试不需要实际连接的功能
*/
import { describe, it, expect, vi, afterEach } from 'vitest';
import { MCPManager, getMCPManager, resetMCPManager } from '../../../src/mcp/manager.js';
import type { MCPConfig } from '../../../src/mcp/types.js';
describe('MCPManager', () => {
afterEach(async () => {
resetMCPManager();
});
describe('基础状态', () => {
it('初始状态应该是未初始化', () => {
const manager = new MCPManager();
expect(manager.isInitialized()).toBe(false);
});
it('初始时没有工具', () => {
const manager = new MCPManager();
expect(manager.getTools()).toEqual([]);
});
it('初始时没有服务器状态', () => {
const manager = new MCPManager();
expect(manager.getServerStatuses()).toEqual([]);
});
});
describe('配置处理', () => {
it('空配置不应该连接任何服务器', async () => {
const manager = new MCPManager();
await manager.initialize({});
expect(manager.isInitialized()).toBe(true);
expect(manager.getTools()).toEqual([]);
});
it('禁用的服务器应该显示在状态中', async () => {
const manager = new MCPManager();
const config: MCPConfig = {
mcp: {
disabled: {
type: 'local',
command: ['echo'],
enabled: false,
},
},
};
await manager.initialize(config);
const statuses = manager.getServerStatuses();
expect(statuses).toHaveLength(1);
expect(statuses[0].name).toBe('disabled');
expect(statuses[0].status).toBe('disabled');
});
});
describe('工具名解析', () => {
it('无效的工具名格式应返回错误', async () => {
const manager = new MCPManager();
await manager.initialize({});
const result = await manager.callTool('invalidname', {});
expect(result.success).toBe(false);
expect(result.isError).toBe(true);
expect((result.content[0] as { text: string }).text).toContain('Invalid tool name');
});
it('服务器未连接时应返回错误', async () => {
const manager = new MCPManager();
await manager.initialize({});
const result = await manager.callTool('server-tool', {});
expect(result.success).toBe(false);
expect(result.isError).toBe(true);
expect((result.content[0] as { text: string }).text).toContain('not connected');
});
});
describe('reconnect', () => {
it('服务器不存在时应抛出错误', async () => {
const manager = new MCPManager();
await manager.initialize({ mcp: {} });
await expect(manager.reconnect('nonexistent')).rejects.toThrow('Server not found');
});
});
describe('setServerEnabled', () => {
it('服务器不存在时应抛出错误', async () => {
const manager = new MCPManager();
await manager.initialize({ mcp: {} });
await expect(manager.setServerEnabled('nonexistent', true)).rejects.toThrow(
'Server not found'
);
});
});
describe('shutdown', () => {
it('空初始化后可以安全关闭', async () => {
const manager = new MCPManager();
await manager.initialize({});
await manager.shutdown();
expect(manager.isInitialized()).toBe(false);
});
it('未初始化时可以安全关闭', async () => {
const manager = new MCPManager();
await manager.shutdown();
expect(manager.isInitialized()).toBe(false);
});
});
describe('事件发射', () => {
it('应该是 EventEmitter 实例', () => {
const manager = new MCPManager();
expect(typeof manager.on).toBe('function');
expect(typeof manager.emit).toBe('function');
});
it('应该支持事件监听', () => {
const manager = new MCPManager();
const callback = vi.fn();
manager.on('tools:changed', callback);
manager.emit('tools:changed');
expect(callback).toHaveBeenCalled();
});
});
});
describe('getMCPManager / resetMCPManager', () => {
afterEach(() => {
resetMCPManager();
});
it('应该返回单例实例', () => {
const manager1 = getMCPManager();
const manager2 = getMCPManager();
expect(manager1).toBe(manager2);
});
it('重置后应该返回新实例', () => {
const manager1 = getMCPManager();
resetMCPManager();
const manager2 = getMCPManager();
expect(manager1).not.toBe(manager2);
});
it('重置的实例应该是未初始化状态', () => {
const manager1 = getMCPManager();
resetMCPManager();
const manager2 = getMCPManager();
expect(manager2.isInitialized()).toBe(false);
});
});
+289
View File
@@ -0,0 +1,289 @@
/**
* MCP 工具适配器测试
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { MCPToolAdapter, createMCPToolAdapter } from '../../../src/mcp/tool-adapter.js';
import type { MCPTool, MCPManager, MCPToolCallResult } from '../../../src/mcp/types.js';
// 创建 mock MCPManager
function createMockManager(): MCPManager {
return {
callTool: vi.fn(),
getTools: vi.fn().mockReturnValue([]),
getServerStatuses: vi.fn().mockReturnValue([]),
initialize: vi.fn(),
shutdown: vi.fn(),
on: vi.fn(),
emit: vi.fn(),
} as unknown as MCPManager;
}
describe('MCPToolAdapter', () => {
let mockManager: MCPManager;
let adapter: MCPToolAdapter;
beforeEach(() => {
mockManager = createMockManager();
adapter = new MCPToolAdapter(mockManager);
});
describe('adaptToInternalTool', () => {
it('应该将 MCP 工具转换为内部格式', () => {
const mcpTool: MCPTool = {
server: 'filesystem',
name: 'filesystem-read_file',
originalName: 'read_file',
description: 'Read a file from the filesystem',
inputSchema: {
type: 'object',
properties: {
path: {
type: 'string',
description: 'Path to the file',
},
},
required: ['path'],
},
};
const internalTool = adapter.adaptToInternalTool(mcpTool);
expect(internalTool.name).toBe('filesystem-read_file');
expect(internalTool.description).toBe('Read a file from the filesystem');
expect(internalTool.parameters).toHaveProperty('path');
expect(internalTool.parameters.path.type).toBe('string');
expect(internalTool.parameters.path.required).toBe(true);
expect(internalTool.metadata.category).toBe('agent');
expect(internalTool.metadata.keywords).toContain('mcp');
expect(internalTool.metadata.keywords).toContain('filesystem');
});
it('应该正确转换参数类型', () => {
const mcpTool: MCPTool = {
server: 'test',
name: 'test-tool',
originalName: 'tool',
description: 'Test tool',
inputSchema: {
type: 'object',
properties: {
stringParam: { type: 'string' },
numberParam: { type: 'number' },
integerParam: { type: 'integer' },
booleanParam: { type: 'boolean' },
arrayParam: { type: 'array' },
objectParam: { type: 'object' },
},
},
};
const internalTool = adapter.adaptToInternalTool(mcpTool);
expect(internalTool.parameters.stringParam.type).toBe('string');
expect(internalTool.parameters.numberParam.type).toBe('number');
expect(internalTool.parameters.integerParam.type).toBe('number');
expect(internalTool.parameters.booleanParam.type).toBe('boolean');
expect(internalTool.parameters.arrayParam.type).toBe('array');
expect(internalTool.parameters.objectParam.type).toBe('object');
});
it('应该处理联合类型', () => {
const mcpTool: MCPTool = {
server: 'test',
name: 'test-tool',
originalName: 'tool',
description: 'Test tool',
inputSchema: {
type: 'object',
properties: {
nullable: { type: ['string', 'null'] },
},
},
};
const internalTool = adapter.adaptToInternalTool(mcpTool);
expect(internalTool.parameters.nullable.type).toBe('string');
});
it('应该处理空 inputSchema', () => {
const mcpTool: MCPTool = {
server: 'test',
name: 'test-tool',
originalName: 'tool',
description: 'Test tool',
inputSchema: {},
};
const internalTool = adapter.adaptToInternalTool(mcpTool);
expect(internalTool.parameters).toEqual({});
});
it('execute 应该调用 manager.callTool', async () => {
const mcpTool: MCPTool = {
server: 'test',
name: 'test-tool',
originalName: 'tool',
description: 'Test tool',
inputSchema: {
type: 'object',
properties: {
arg: { type: 'string' },
},
},
};
const mockResult: MCPToolCallResult = {
success: true,
content: [{ type: 'text', text: 'result' }],
isError: false,
};
vi.mocked(mockManager.callTool).mockResolvedValue(mockResult);
const internalTool = adapter.adaptToInternalTool(mcpTool);
const result = await internalTool.execute({ arg: 'value' });
expect(mockManager.callTool).toHaveBeenCalledWith('test-tool', {
arg: 'value',
});
expect(result.success).toBe(true);
expect(result.output).toBe('result');
});
it('execute 应该处理错误结果', async () => {
const mcpTool: MCPTool = {
server: 'test',
name: 'test-tool',
originalName: 'tool',
description: 'Test tool',
inputSchema: {},
};
const mockResult: MCPToolCallResult = {
success: false,
content: [{ type: 'text', text: 'error message' }],
isError: true,
};
vi.mocked(mockManager.callTool).mockResolvedValue(mockResult);
const internalTool = adapter.adaptToInternalTool(mcpTool);
const result = await internalTool.execute({});
expect(result.success).toBe(false);
expect(result.error).toBe('error message');
});
});
describe('adaptTools', () => {
it('应该批量转换工具', () => {
const mcpTools: MCPTool[] = [
{
server: 'server1',
name: 'server1-tool1',
originalName: 'tool1',
description: 'Tool 1',
inputSchema: {},
},
{
server: 'server2',
name: 'server2-tool2',
originalName: 'tool2',
description: 'Tool 2',
inputSchema: {},
},
];
const internalTools = adapter.adaptTools(mcpTools);
expect(internalTools).toHaveLength(2);
expect(internalTools[0].name).toBe('server1-tool1');
expect(internalTools[1].name).toBe('server2-tool2');
});
});
describe('createMCPToolAdapter', () => {
it('应该创建适配器实例', () => {
const adapter = createMCPToolAdapter(mockManager);
expect(adapter).toBeInstanceOf(MCPToolAdapter);
});
});
describe('内容转换', () => {
it('应该处理图片内容', async () => {
const mcpTool: MCPTool = {
server: 'test',
name: 'test-tool',
originalName: 'tool',
description: 'Test tool',
inputSchema: {},
};
const mockResult: MCPToolCallResult = {
success: true,
content: [{ type: 'image', data: 'base64data', mimeType: 'image/png' }],
isError: false,
};
vi.mocked(mockManager.callTool).mockResolvedValue(mockResult);
const internalTool = adapter.adaptToInternalTool(mcpTool);
const result = await internalTool.execute({});
expect(result.output).toBe('[Image: image/png]');
});
it('应该处理资源内容', async () => {
const mcpTool: MCPTool = {
server: 'test',
name: 'test-tool',
originalName: 'tool',
description: 'Test tool',
inputSchema: {},
};
const mockResult: MCPToolCallResult = {
success: true,
content: [
{
type: 'resource',
resource: {
uri: 'file:///test.txt',
text: 'file content',
},
},
],
isError: false,
};
vi.mocked(mockManager.callTool).mockResolvedValue(mockResult);
const internalTool = adapter.adaptToInternalTool(mcpTool);
const result = await internalTool.execute({});
expect(result.output).toBe('file content');
});
it('应该处理混合内容', async () => {
const mcpTool: MCPTool = {
server: 'test',
name: 'test-tool',
originalName: 'tool',
description: 'Test tool',
inputSchema: {},
};
const mockResult: MCPToolCallResult = {
success: true,
content: [
{ type: 'text', text: 'line 1' },
{ type: 'text', text: 'line 2' },
],
isError: false,
};
vi.mocked(mockManager.callTool).mockResolvedValue(mockResult);
const internalTool = adapter.adaptToInternalTool(mcpTool);
const result = await internalTool.execute({});
expect(result.output).toBe('line 1\nline 2');
});
});
});