/** * MCP 工具动态变更 × 引擎同步测试(v0.5.3) * * 背景:MCP 工具在运行中增删(添加/断开/启停 server)后,已存在引擎的 * 工具列表不会自动更新 —— setToolsAll 只在启动期与工具开关时被调用。 * 后果:已打开会话拿不到新 MCP 工具,或持有已断开 server 的失效工具定义。 * v0.5.3 修复:MCPManager.setOnToolsChanged 回调,连接/断开后同步全部引擎。 * * 测试策略:mock MCP SDK(Client/StdioClientTransport),用真实的 * MCPManager + ToolRegistry + AgentEngineManager 端到端验证 —— * 先创建会话引擎(复现"已打开会话"),再连接 MCP server,断言引擎请求 * 的 tools 集合动态更新。 */ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; vi.mock('electron-log', () => ({ default: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }, })); // ===== Mock MCP SDK ===== const mockConnect = vi.fn(async () => undefined); const mockListTools = vi.fn(async () => ({ tools: [ { name: 'search_docs', description: 'Search documentation', inputSchema: { type: 'object', properties: { query: { type: 'string' } } }, }, ], })); const mockClose = vi.fn(async () => undefined); vi.mock('@modelcontextprotocol/sdk/client/index.js', () => ({ Client: class { connect = mockConnect; listTools = mockListTools; close = mockClose; callTool = vi.fn(async () => ({ content: [] })); }, })); vi.mock('@modelcontextprotocol/sdk/client/stdio.js', () => ({ StdioClientTransport: class { // 测试不真实 spawn 子进程(真实 stdio 会拉起 npx 进程) }, })); import { MCPManager } from '../../../services/mcp-manager.service'; import { AgentEngineManager } from '../../../services/agent-engine-manager.service'; import { ToolRegistry } from '../registry'; import type { IMetonaProviderAdapter, MetonaRequest, MetonaResponse, MetonaStreamEvent, } from '../../types'; import { MetonaStreamEventType } from '../../types'; import { mkdtempSync, rmSync } from 'fs'; import { tmpdir } from 'os'; import { join } from 'path'; // connectServer 成功后会写 mcp_servers 表(last_connected)— 用真实 :memory: SQLite let dbAvailable = true; let Database: typeof import('better-sqlite3'); try { // eslint-disable-next-line @typescript-eslint/no-require-imports Database = require('better-sqlite3'); const probe = new Database(':memory:'); probe.close(); } catch { dbAvailable = false; } /** 记录请求的 mock adapter */ function createRecordingAdapter(requests: MetonaRequest[]): IMetonaProviderAdapter { return { providerId: 'mock', supportedModels: ['mock-model'], supportsToolCalling: true, supportsThinking: false, getContextWindow: () => 1_000_000, send: vi.fn( async (): Promise => ({ meta: { requestId: 'r', provider: 'mock', model: 'm', latencyMs: 1, timestamp: Date.now() }, content: 'ok', usage: { inputTokens: 1, outputTokens: 1, totalTokens: 2 }, finishReason: 'stop' as never, }), ), sendStream: vi.fn(async function* (req: MetonaRequest): AsyncIterable { requests.push(req); yield { type: MetonaStreamEventType.TEXT_DELTA, requestId: 'r', sessionId: req.meta.sessionId, iteration: req.meta.iteration, seq: 0, timestamp: Date.now(), delta: 'done', }; yield { type: MetonaStreamEventType.DONE, requestId: 'r', sessionId: req.meta.sessionId, iteration: req.meta.iteration, seq: 1, timestamp: Date.now(), }; }), setAbortSignal: vi.fn(), healthCheck: async () => true, }; } const userMessage = { role: 'user' as const, content: 'hello', timestamp: Date.now() }; const systemPrompt = { roleDefinition: '', outputConstraints: '', safetyGuidelines: '' }; describe.skipIf(!dbAvailable)('MCP 工具动态变更 × 引擎同步(v0.5.3)', () => { let db: any; let dir: string; beforeEach(() => { mockConnect.mockClear(); mockListTools.mockClear(); mockClose.mockClear(); dir = mkdtempSync(join(tmpdir(), 'metona-mcp-sync-')); db = new Database(':memory:'); db.exec(` CREATE TABLE mcp_servers ( id TEXT PRIMARY KEY, name TEXT NOT NULL UNIQUE, transport TEXT NOT NULL, command TEXT, args TEXT, url TEXT, headers TEXT, enabled INTEGER NOT NULL DEFAULT 1, last_connected INTEGER, error_message TEXT, created_at INTEGER NOT NULL DEFAULT 0, updated_at INTEGER NOT NULL DEFAULT 0 ); `); }); afterEach(() => { try { db?.close(); } catch { /* ignore */ } try { rmSync(dir, { recursive: true, force: true }); } catch { /* ignore */ } }); it('运行中连接 MCP server 后,已存在引擎的工具列表动态更新', async () => { const requests: MetonaRequest[] = []; const adapter = createRecordingAdapter(requests); const registry = new ToolRegistry(); const manager = new AgentEngineManager({ buildAdapter: () => adapter, baseConfig: {}, toolRegistry: registry, }); const mcpManager = new MCPManager(() => db, registry); // v0.5.3 的接线:MCP 工具变更 → 同步全部已存在引擎 mcpManager.setOnToolsChanged(() => { manager.setToolsAll(registry.listTools()); }); // 1. 用户先打开会话(引擎已存在,此时无 MCP 工具) const engine = manager.getEngine('sess-mcp'); await engine.runStream(userMessage, 'sess-mcp', [], systemPrompt); expect(requests[0].tools ?? []).toHaveLength(0); // 2. 运行中添加并连接 MCP server(复现 ipc/mcp.ts addServer 路径) await mcpManager.connectServer({ id: 'mcp_test', name: 'test-server', transport: 'stdio', command: 'npx', args: ['-y', 'some-mcp-server'], enabled: true, }); // 3. 同一会话再发消息 —— 引擎请求携带新 MCP 工具(修复点: // v0.5.3 之前此处仍为空,需新建会话才能拿到) requests.length = 0; await engine.runStream(userMessage, 'sess-mcp', [], systemPrompt); const toolNames = (requests[0].tools ?? []).map((t) => t.name); expect(toolNames).toContain('mcp_test-server_search_docs'); }); it('断开 MCP server 后,已存在引擎移除失效工具定义', async () => { const requests: MetonaRequest[] = []; const adapter = createRecordingAdapter(requests); const registry = new ToolRegistry(); const manager = new AgentEngineManager({ buildAdapter: () => adapter, baseConfig: {}, toolRegistry: registry, }); const mcpManager = new MCPManager(() => db, registry); mcpManager.setOnToolsChanged(() => { manager.setToolsAll(registry.listTools()); }); // 连接 → 引擎拿到工具 await mcpManager.connectServer({ id: 'mcp_test2', name: 'srv2', transport: 'stdio', command: 'npx', args: [], enabled: true, }); const engine = manager.getEngine('sess-mcp2'); await engine.runStream(userMessage, 'sess-mcp2', [], systemPrompt); expect((requests[0].tools ?? []).length).toBeGreaterThan(0); // 断开 → 引擎的后续请求不再携带失效工具(修复点:此前引擎持有 // 失效定义,模型发起调用时才报 Unknown tool) await mcpManager.disconnectServer('srv2'); requests.length = 0; await engine.runStream(userMessage, 'sess-mcp2', [], systemPrompt); expect(requests[0].tools ?? []).toHaveLength(0); }); it('回调抛异常不影响 MCP 连接主流程(防御性)', async () => { const registry = new ToolRegistry(); const mcpManager = new MCPManager(() => db, registry); mcpManager.setOnToolsChanged(() => { throw new Error('callback exploded'); }); // 连接仍成功(回调失败仅记录日志) await expect( mcpManager.connectServer({ id: 'mcp_test3', name: 'srv3', transport: 'stdio', command: 'npx', args: [], enabled: true, }), ).resolves.toBeUndefined(); expect(registry.listTools().length).toBeGreaterThan(0); }); });