registry/test/mcp.test.js
cheney 963046edd9
All checks were successful
build-and-deploy / build-and-deploy (push) Successful in 39s
feat: 新增 MCP 服务,支持 AI 自动操作项目与配置
- Streamable HTTP 传输,/mcp 端点,Bearer token 鉴权(data/mcp.json,已忽略)
- 项目/配置完整增删改查工具,删除为两步式确认(delete_* + confirm_delete_*)
- 写操作遵循系统模式,readonly 下拒绝
- 依赖 @modelcontextprotocol/sdk,新增单元测试
2026-09-08 10:03:09 +08:00

148 lines
5.4 KiB
JavaScript
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 工具单元测试
* 覆盖:工具注册完整性、删除两步式确认、只读模式下拒绝写操作
* 运行npm test
*/
const test = require("node:test");
const assert = require("node:assert");
const { createMcpServer } = require("../src/mcp/tools");
const { loadSystem, saveSystem } = require("../src/persistence/jsonAdapter");
// 恢复测试前的系统模式,避免影响其他用例
const originalMode = (loadSystem() || {}).mode || "edit";
test.after(() => {
const system = loadSystem() || {};
system.mode = originalMode;
saveSystem(system);
});
let server;
test.before(() => {
server = createMcpServer();
});
/**
* 直接调用已注册工具的处理函数
* @param {string} name - 工具名
* @param {Object} args - 工具参数
* @returns {Promise<Object>} 工具结果
*/
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("配置删除同样为两步式确认", 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");
});