/** * MCP 工具单元测试 * 覆盖:工具注册完整性、删除两步式确认、只读模式下拒绝写操作 * 运行:npm test */ const test = require("node:test"); const assert = require("node:assert"); const fs = require("node:fs"); const os = require("node:os"); const path = require("node:path"); const { createMcpServer } = require("../src/mcp/tools"); const { init, loadSystem, saveSystem } = require("../src/persistence/duckdbAdapter"); // 使用临时数据目录,避免污染真实 data/ const tmpDataDir = fs.mkdtempSync(path.join(os.tmpdir(), "registry-mcp-test-")); process.env.REGISTRY_DATA_DIR = tmpDataDir; let server; let originalMode = "edit"; // 初始化 DuckDB,记录测试前模式;测试结束后恢复原模式 test.before(async () => { await init(); originalMode = (await loadSystem() || {}).mode || "edit"; server = createMcpServer(); }); test.after(async () => { const system = await loadSystem() || {}; system.mode = originalMode; await saveSystem(system); }); /** * 直接调用已注册工具的处理函数 * @param {string} name - 工具名 * @param {Object} args - 工具参数 * @returns {Promise} 工具结果 */ async function callTool(name, args) { const tool = server._registeredTools[name]; assert.ok(tool, `工具不存在: ${name}`); try { const result = await tool.handler(args, {}); return { text: (result.content || []).map((c) => c.text).join("\n"), structuredContent: result.structuredContent, isError: result.isError === true, }; } catch (err) { // 直接调用底层 handler 时,抛错不会自动转换为 isError 结果,这里统一转换 return { text: err && err.message ? err.message : String(err), structuredContent: { error: true }, isError: true, }; } } test("注册了全部项目/配置 CRUD 及确认删除工具", () => { const names = new Set(Object.keys(server._registeredTools)); for (const name of [ "get_mode", "set_mode", "list_projects", "get_project", "create_project", "update_project_mappings", "delete_project", "confirm_delete_project", "list_configs", "get_config", "create_config", "update_config", "delete_config", "confirm_delete_config", ]) { assert.ok(names.has(name), `缺少工具: ${name}`); } }); test("删除项目为两步式:先登记待确认,确认后才真正删除", async () => { const { text: createText } = await callTool("create_project", { name: "MCP-Unittest-Proj", }); const project = JSON.parse(createText.match(/\{[\s\S]*\}/)[0]); const pid = project.id; const del = await callTool("delete_project", { projectId: pid }); assert.strictEqual(del.structuredContent.status, "awaiting_confirmation"); assert.ok(del.structuredContent.confirmation_token); // 未确认前项目仍然存在 const stillThere = await callTool("get_project", { projectId: pid }); assert.ok(!stillThere.isError); // 确认后项目被删除 const confirm = await callTool("confirm_delete_project", { confirmation_token: del.structuredContent.confirmation_token, }); assert.strictEqual(confirm.structuredContent.status, "deleted"); const gone = await callTool("get_project", { projectId: pid }); assert.ok(gone.isError, "确认删除后项目应不存在"); }); test("重复使用确认令牌会失败(一次性令牌)", async () => { const { text: createText } = await callTool("create_project", { name: "MCP-Unittest-Token" }); const project = JSON.parse(createText.match(/\{[\s\S]*\}/)[0]); const del = await callTool("delete_project", { projectId: project.id }); const tok = del.structuredContent.confirmation_token; await callTool("confirm_delete_project", { confirmation_token: tok }); const second = await callTool("confirm_delete_project", { confirmation_token: tok }); assert.ok(second.isError); assert.strictEqual(second.structuredContent.status, "invalid_or_expired"); }); test("readonly 模式下拒绝项目/配置写操作,切换回 edit 后恢复", async () => { await callTool("set_mode", { mode: "readonly" }); const createProject = await callTool("create_project", { name: "不应创建" }); assert.ok(createProject.isError); assert.match(createProject.text, /只读模式/); const delProject = await callTool("delete_project", { projectId: "proj-mrlip49o-qsr2yx" }); assert.ok(delProject.isError); assert.match(delProject.text, /只读模式/); const createConfig = await callTool("create_config", { projectId: "proj-mrlip49o-qsr2yx", key: "x", description: "x", }); assert.ok(createConfig.isError); assert.match(createConfig.text, /只读模式/); await callTool("set_mode", { mode: "edit" }); }); test("edit 模式下可以修改配置说明", async () => { await callTool("set_mode", { mode: "edit" }); const { text: createText } = await callTool("create_project", { name: "MCP-Unittest-Desc" }); const project = JSON.parse(createText.match(/\{[\s\S]*\}/)[0]); const cfg = await callTool("create_config", { projectId: project.id, key: "db/host", description: "原始说明", value: "localhost", }); const cfgObj = JSON.parse(cfg.text.match(/\{[\s\S]*\}/)[0]); const updated = await callTool("update_config", { projectId: project.id, configId: cfgObj.id, description: "新的说明", }); assert.ok(!updated.isError, "edit 模式下应允许修改说明"); const updatedObj = JSON.parse(updated.text.match(/\{[\s\S]*\}/)[0]); assert.strictEqual(updatedObj.description, "新的说明"); assert.strictEqual(updatedObj.value, "localhost", "仅改说明,值不应变化"); // 清理测试项目 const delProject = await callTool("delete_project", { projectId: project.id }); const confirmProject = await callTool("confirm_delete_project", { confirmation_token: delProject.structuredContent.confirmation_token, }); assert.strictEqual(confirmProject.structuredContent.status, "deleted"); }); test("配置删除同样为两步式确认", async () => { const { text: createText } = await callTool("create_project", { name: "MCP-Unittest-Cfg" }); const project = JSON.parse(createText.match(/\{[\s\S]*\}/)[0]); const cfg = await callTool("create_config", { projectId: project.id, key: "a/b", description: "unit", value: "1", type: "number", }); const cfgObj = JSON.parse(cfg.text.match(/\{[\s\S]*\}/)[0]); const del = await callTool("delete_config", { projectId: project.id, configId: cfgObj.id }); assert.strictEqual(del.structuredContent.status, "awaiting_confirmation"); const confirm = await callTool("confirm_delete_config", { confirmation_token: del.structuredContent.confirmation_token, }); assert.strictEqual(confirm.structuredContent.status, "deleted"); // 清理测试项目 const delProject = await callTool("delete_project", { projectId: project.id }); const confirmProject = await callTool("confirm_delete_project", { confirmation_token: delProject.structuredContent.confirmation_token, }); assert.strictEqual(confirmProject.structuredContent.status, "deleted"); });