From 4beaf088d0591d42caf191485a1378bccfdb2d3d Mon Sep 17 00:00:00 2001 From: kurihada Date: Thu, 11 Dec 2025 21:18:41 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=20MCP=20(Model=20Con?= =?UTF-8?q?text=20Protocol)=20=E9=9B=86=E6=88=90=E6=94=AF=E6=8C=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 实现 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 : 测试连接 配置支持: - 用户级 (~/.ai-assist/config.json) 和项目级配置 - JSON/YAML 格式 - 通配符模式工具启用/禁用 --- package-lock.json | 8 + package.json | 1 + src/index.ts | 268 +++++++++++++++++++++ src/mcp/client.ts | 265 +++++++++++++++++++++ src/mcp/config.ts | 342 +++++++++++++++++++++++++++ src/mcp/index.ts | 47 ++++ src/mcp/manager.ts | 319 +++++++++++++++++++++++++ src/mcp/tool-adapter.ts | 166 +++++++++++++ src/mcp/transports/index.ts | 27 +++ src/mcp/transports/stdio.ts | 278 ++++++++++++++++++++++ src/mcp/types.ts | 276 ++++++++++++++++++++++ tests/unit/mcp/config.test.ts | 351 ++++++++++++++++++++++++++++ tests/unit/mcp/manager.test.ts | 157 +++++++++++++ tests/unit/mcp/tool-adapter.test.ts | 289 +++++++++++++++++++++++ 14 files changed, 2794 insertions(+) create mode 100644 src/mcp/client.ts create mode 100644 src/mcp/config.ts create mode 100644 src/mcp/index.ts create mode 100644 src/mcp/manager.ts create mode 100644 src/mcp/tool-adapter.ts create mode 100644 src/mcp/transports/index.ts create mode 100644 src/mcp/transports/stdio.ts create mode 100644 src/mcp/types.ts create mode 100644 tests/unit/mcp/config.test.ts create mode 100644 tests/unit/mcp/manager.test.ts create mode 100644 tests/unit/mcp/tool-adapter.test.ts diff --git a/package-lock.json b/package-lock.json index 88c6543..139a009 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 25f185d..746d179 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/index.ts b/src/index.ts index 443ccaa..11fae24 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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 { + 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 +): void { + const adapter = createMCPToolAdapter(mcpManager); + const mcpTools = mcpManager.getTools(); + const adaptedTools = adapter.adaptTools(mcpTools); + + // 注册到工具注册表 + toolRegistry.registerAll(adaptedTools); +} + +/** + * 关闭 MCP 系统 + */ +async function shutdownMCP(): Promise { + 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 = { + 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(); + 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 ') + .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(); diff --git a/src/mcp/client.ts b/src/mcp/client.ts new file mode 100644 index 0000000..40c61f1 --- /dev/null +++ b/src/mcp/client.ts @@ -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 { + 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 { + if (this._status === 'disconnected') { + return; + } + + try { + await this.transport.close(); + } finally { + this._status = 'disconnected'; + this.tools = []; + } + } + + /** + * MCP 初始化握手 + */ + private async initialize(): Promise { + 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 { + 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 + ): Promise { + // 获取原始工具名 + 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 + ); + } + } +} diff --git a/src/mcp/config.ts b/src/mcp/config.ts new file mode 100644 index 0000000..8bc996e --- /dev/null +++ b/src/mcp/config.ts @@ -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(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 = {}; + 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 = {}; + 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 +): 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; // 默认启用 +} diff --git a/src/mcp/index.ts b/src/mcp/index.ts new file mode 100644 index 0000000..822a9f1 --- /dev/null +++ b/src/mcp/index.ts @@ -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'; diff --git a/src/mcp/manager.ts b/src/mcp/manager.ts new file mode 100644 index 0000000..5838010 --- /dev/null +++ b/src/mcp/manager.ts @@ -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 = new Map(); + private config: MCPConfig = {}; + private initialized = false; + + /** + * 初始化所有已启用的服务器 + */ + async initialize(config: MCPConfig): Promise { + 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 { + 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 { + 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 { + 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 + ): Promise { + // 从工具名中解析服务器名 + 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 { + 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( + event: K, + listener: MCPManagerEvents[K] + ): this { + return super.on(event, listener); + } + + override emit( + event: K, + ...args: Parameters + ): 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; + } +} diff --git a/src/mcp/tool-adapter.ts b/src/mcp/tool-adapter.ts new file mode 100644 index 0000000..92c0fc5 --- /dev/null +++ b/src/mcp/tool-adapter.ts @@ -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 { + const parameters: Record = {}; + + if (!schema || typeof schema !== 'object') { + return parameters; + } + + const properties = schema.properties as Record | 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): Promise => { + 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); +} diff --git a/src/mcp/transports/index.ts b/src/mcp/transports/index.ts new file mode 100644 index 0000000..8816755 --- /dev/null +++ b/src/mcp/transports/index.ts @@ -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'; diff --git a/src/mcp/transports/stdio.ts b/src/mcp/transports/stdio.ts new file mode 100644 index 0000000..2d052bf --- /dev/null +++ b/src/mcp/transports/stdio.ts @@ -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(); + 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 { + 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'], + }); + + // 处理 stdout(JSON-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 { + 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 { + 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 { + 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; + } +} diff --git a/src/mcp/types.ts b/src/mcp/types.ts new file mode 100644 index 0000000..6d5816c --- /dev/null +++ b/src/mcp/types.ts @@ -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; + /** 工作目录 */ + cwd?: string; + /** 是否启用,默认 true */ + enabled?: boolean; + /** 超时毫秒数,默认 30000 */ + timeout?: number; +} + +/** 远程 MCP 服务器配置 (HTTP/SSE 传输) */ +export interface RemoteMCPServer { + type: 'remote'; + /** MCP 端点 URL */ + url: string; + /** 自定义请求头,支持 {env:VAR} 语法 */ + headers?: Record; + /** OAuth 配置,空对象 {} 表示自动发现 (RFC 7591) */ + oauth?: OAuthConfig | Record; + /** 是否启用,默认 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; + /** 工具启用/禁用配置,支持通配符如 "server-*" */ + tools?: Record; +} + +// ============================================================================ +// 运行时类型 +// ============================================================================ + +/** 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; + /** 关闭传输 */ + close(): Promise; + /** 发送请求并等待响应 */ + request(method: string, params?: unknown): Promise; + /** 发送通知(无响应) */ + notify(method: string, params?: unknown): Promise; + /** 监听服务器通知 */ + 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; +} + +/** 初始化请求参数 */ +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; +} + +/** 工具调用响应 */ +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; +} diff --git a/tests/unit/mcp/config.test.ts b/tests/unit/mcp/config.test.ts new file mode 100644 index 0000000..da02f7a --- /dev/null +++ b/tests/unit/mcp/config.test.ts @@ -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(); + }); + }); +}); diff --git a/tests/unit/mcp/manager.test.ts b/tests/unit/mcp/manager.test.ts new file mode 100644 index 0000000..f22af3a --- /dev/null +++ b/tests/unit/mcp/manager.test.ts @@ -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); + }); +}); diff --git a/tests/unit/mcp/tool-adapter.test.ts b/tests/unit/mcp/tool-adapter.test.ts new file mode 100644 index 0000000..1932fd0 --- /dev/null +++ b/tests/unit/mcp/tool-adapter.test.ts @@ -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'); + }); + }); +});