Files
metona-ai-desktop/electron/harness/tools/__tests__/mcp-tools-sync.test.ts
T
thzxx 3a30e8f5b4
CI / 类型检查 + Lint + 单元测试 (push) Failing after 5m43s
CI / 全量测试 (Electron ABI) (push) Failing after 5m21s
CI / 产物编译验证 (push) Successful in 9m59s
fix: v0.5.3 模型能力完整性修复 — MCP 工具动态同步 + maxTokens 模型上限钳制
背景:v0.5.2 工具调用失效修复后,对模型全能力矩阵(工具/思考/多模态/流式/
压缩/摘要/记忆/故障转移/余额)做契约级核查,发现并修复两处同类时序/边界缺陷。

P1 MCP 工具运行中增删不同步引擎:
- 症状:运行中添加/启用 MCP server 后,已打开会话拿不到新工具;断开
  server 后已有引擎仍持有失效工具定义(模型发起调用才报 Unknown tool)
- 根因:setToolsAll 仅在启动期与工具开关时调用,MCP 连接/断开路径缺失
  (与 v0.5.2 修复的懒创建陷阱同类——"变更点 × 同步路径"未全覆盖)
- 修复:MCPManager 新增 setOnToolsChanged 回调,connectServer 注册完成 /
  disconnectServer 注销完成后触发;main.ts 注入回调同步全部已存在引擎。
  懒创建引擎由 createEngine 实时拉取(v0.5.2),三条路径(启动/懒创建/
  运行中变更)全覆盖。README"无需重启动态发现"的宣称至此真实成立。

P1 maxTokens 超模型上限直接 400:
- 症状:引擎默认 maxTokens=63488,OpenAI gpt-4o(16384)/gpt-4.1(32768)、
  Anthropic opus/haiku(32000)、MiMo standard(32768) 每次请求 400,等于不可用
- 修复:五个 adapter(DeepSeek/Agnes/MiMo/OpenAI/Anthropic)统一按
  MODEL_INFO.maxOutputTokens 钳制;MiMo 保留 thinking 兜底 32768 语义;
  Anthropic thinking budget 在钳制后的 max_tokens 内二分,自动跟随

测试(224 → 236 用例):
- 新增 maxTokens 钳制契约测试 ×9(max-tokens-clamp.test.ts):mock fetch
  记录真实请求体断言——超限钳制(MiMo standard/OpenAI gpt-4o/Anthropic
  opus)/ 未超限原样传递(DeepSeek/Agnes/MiMo pro/o3-mini/sonnet)/
  推理模型字段名 / 未配置默认值安全性
- 新增 MCP 动态同步端到端测试 ×3(mcp-tools-sync.test.ts):mock MCP SDK
  + 真实 MCPManager/ToolRegistry/AgentEngineManager——先建引擎再连
  server,断言同一会话请求的 tools 动态更新 / 断开后移除失效定义 /
  回调异常不阻断 MCP 主流程

能力矩阵核查结论(无回归确认): 工具调用主链路 ✓(v0.5.2)/ SubAgent
工具 ✓(delegate 实时 resolveTools)/ thinking 热更新 ✓(baseConfig 合并
路径无懒创建陷阱)/ 多模态当轮 ✓ / 压缩与孤立 tool 消息配对 ✓ / 摘要分层 ✓ /
记忆注入 ✓ / 故障转移 ✓ / 余额 ✓(v0.5.2)。已知设计限制:历史轮图片不
回传(attachments 仅存缩略图,图片只在发送当轮注入上下文)。

验证: lint 0 / typecheck 双工程 0 / test:electron 236 全过 / build 成功
2026-08-21 22:36:52 +08:00

247 lines
8.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* MCP 工具动态变更 × 引擎同步测试(v0.5.3)
*
* 背景:MCP 工具在运行中增删(添加/断开/启停 server)后,已存在引擎的
* 工具列表不会自动更新 —— setToolsAll 只在启动期与工具开关时被调用。
* 后果:已打开会话拿不到新 MCP 工具,或持有已断开 server 的失效工具定义。
* v0.5.3 修复:MCPManager.setOnToolsChanged 回调,连接/断开后同步全部引擎。
*
* 测试策略:mock MCP SDKClient/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<MetonaResponse> => ({
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<MetonaStreamEvent> {
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);
});
});