/** * MCP 工具定义 * 将 Registry 的项目/配置 CRUD 能力以 MCP 工具形式暴露给 AI。 * 约定: * - 删除操作为“两步式”:delete_* 首次调用仅登记待删除项并返回确认令牌, * 确认令牌需再调用 confirm_* 工具才会真正执行删除。 * - 读写均遵循系统当前模式:readonly 模式下所有写操作(增删改)被拒绝。 */ const crypto = require("crypto"); const z = require("zod/v4"); const { McpServer } = require("@modelcontextprotocol/sdk/server/mcp.js"); const projectService = require("../services/projectService"); const configService = require("../services/configService"); const { loadSystem, loadMcpConfig } = require("../persistence/jsonAdapter"); // 待确认删除的登记表:token -> { type, projectId, configId, expiresAt } const pendingDeletions = new Map(); // 待确认删除令牌有效期(毫秒) const PENDING_TTL = 5 * 60 * 1000; /** * 获取当前系统模式 * @returns {string} plan/edit/readonly */ function currentMode() { try { return (loadSystem() || {}).mode || "edit"; } catch { return "edit"; } } /** * 只读模式下拒绝写操作 * @throws {Error} 只读模式 */ function assertWritable() { if (currentMode() === "readonly") { throw new Error("系统当前为只读模式,不允许写操作"); } } /** * 生成删除确认令牌 * @returns {string} 随机令牌 */ function generateToken() { return crypto.randomBytes(16).toString("hex"); } /** * 登记一个待删除操作,返回确认令牌 * @param {string} type - 类型:project/config * @param {string} projectId - 项目 ID * @param {string} [configId] - 配置 ID(type=config 时) */ function createPendingDeletion(type, projectId, configId) { const token = generateToken(); pendingDeletions.set(token, { type, projectId, configId, expiresAt: Date.now() + PENDING_TTL, }); return token; } /** * 校验并取出待删除登记,同时清理过期项 * @param {string} token - 确认令牌 * @returns {Object|null} 登记对象或 null */ function takePendingDeletion(token) { const now = Date.now(); for (const [key, value] of pendingDeletions) { if (value.expiresAt < now) { pendingDeletions.delete(key); } } const entry = pendingDeletions.get(token); if (!entry) { return null; } pendingDeletions.delete(token); return entry; } /** * 将服务层抛错转为可读文本 * @param {Error} err - 错误对象 * @returns {string} 错误信息 */ function errText(err) { return err && err.message ? err.message : String(err); } /** * 构建带删除确认提示的文本结果 * @param {string} summary - 结果概要 * @param {string} confirmTool - 待调用的确认工具名 * @param {string} token - 确认令牌 * @param {Object} extra - 附带的上下文数据 */ function confirmResult(summary, confirmTool, token, extra) { return { content: [ { type: "text", text: `${summary}\n\n该操作需要二次确认。请调用工具 ${confirmTool} 并提供 confirmation_token: ${token} 以完成删除(令牌 ${Math.round(PENDING_TTL / 60000)} 分钟内有效)。`, }, ], structuredContent: { status: "awaiting_confirmation", summary, confirmTool, confirmation_token: token, ...extra, }, }; } /** * 创建并返回 MCP 服务器实例(仅定义工具,不绑定传输层) * @returns {McpServer} 已注册全部工具的服务实例 */ function createMcpServer() { const server = new McpServer( { name: "registry-config-center", version: "1.0.0", }, { capabilities: { tools: {}, }, } ); // ==================== 系统 ==================== server.registerTool( "get_mode", { description: "获取系统当前模式(plan/edit/readonly)。", inputSchema: {}, }, async () => ({ content: [{ type: "text", text: `当前模式:${currentMode()}` }], structuredContent: { mode: currentMode() }, }) ); server.registerTool( "set_mode", { description: "切换系统模式。mode 取值为 plan(规划)、edit(编辑)、readonly(只读)。", inputSchema: { mode: z.enum(["plan", "edit", "readonly"]).describe("目标模式"), }, }, async ({ mode }) => { const { loadSystem, saveSystem } = require("../persistence/jsonAdapter"); const system = loadSystem() || {}; system.mode = mode; saveSystem(system); return { content: [{ type: "text", text: `模式已切换为:${mode}` }], structuredContent: { mode }, }; } ); // ==================== 项目 ==================== server.registerTool( "list_projects", { description: "获取所有项目列表。", inputSchema: {}, }, async () => ({ content: [ { type: "text", text: JSON.stringify(projectService.getAllProjects(), null, 2) }, ], structuredContent: { projects: projectService.getAllProjects() }, }) ); server.registerTool( "get_project", { description: "根据 ID 获取单个项目详情。", inputSchema: { projectId: z.string().describe("项目 ID"), }, }, async ({ projectId }) => { const project = projectService.getProjectById(projectId); if (!project) { throw new Error(`项目不存在:${projectId}`); } return { content: [{ type: "text", text: JSON.stringify(project, null, 2) }], structuredContent: { project }, }; } ); server.registerTool( "create_project", { description: "创建新项目。必填 name;维度开关 enableIdc/enableEnvironment/enableGroup 创建后不可修改;映射方式默认 path(也可填 label)。", inputSchema: { name: z.string().describe("项目名称(必填)"), description: z.string().optional().describe("项目描述"), enableIdc: z.boolean().optional().describe("是否启用机房维度"), enableEnvironment: z.boolean().optional().describe("是否启用环境维度"), enableGroup: z.boolean().optional().describe("是否启用分组维度"), idcMapping: z.enum(["path", "label"]).optional().describe("机房映射方式"), environmentMapping: z.enum(["path", "label"]).optional().describe("环境映射方式"), groupMapping: z.enum(["path", "label"]).optional().describe("分组映射方式"), }, }, async (args) => { assertWritable(); const project = projectService.createProject(args); return { content: [{ type: "text", text: JSON.stringify(project, null, 2) }], structuredContent: { project }, }; } ); server.registerTool( "update_project_mappings", { description: "更新项目的维度映射方式(path/label)。", inputSchema: { projectId: z.string().describe("项目 ID"), idcMapping: z.enum(["path", "label"]).optional().describe("机房映射方式"), environmentMapping: z.enum(["path", "label"]).optional().describe("环境映射方式"), groupMapping: z.enum(["path", "label"]).optional().describe("分组映射方式"), }, }, async ({ projectId, idcMapping, environmentMapping, groupMapping }) => { assertWritable(); const project = projectService.updateMappings(projectId, { idcMapping, environmentMapping, groupMapping, }); return { content: [{ type: "text", text: JSON.stringify(project, null, 2) }], structuredContent: { project }, }; } ); server.registerTool( "delete_project", { description: "删除项目及其所有配置(两步式)。首次调用只登记删除操作并返回 confirmation_token,需再调用 confirm_delete_project 才会真正删除。", inputSchema: { projectId: z.string().describe("项目 ID"), }, }, async ({ projectId }) => { assertWritable(); const project = projectService.getProjectById(projectId); if (!project) { throw new Error(`项目不存在:${projectId}`); } const token = createPendingDeletion("project", projectId); return confirmResult( `已登记删除项目「${project.name}」(${projectId}),等待确认。`, "confirm_delete_project", token, { projectId, projectName: project.name } ); } ); server.registerTool( "confirm_delete_project", { description: "确认并真正删除之前由 delete_project 登记的项目(需提供其返回的 confirmation_token)。", inputSchema: { confirmation_token: z.string().describe("delete_project 返回的确认令牌"), }, }, async ({ confirmation_token }) => { assertWritable(); const entry = takePendingDeletion(confirmation_token); if (!entry) { return { content: [ { type: "text", text: "确认令牌无效或已过期,删除操作未执行。请重新调用 delete_project。" }, ], structuredContent: { status: "invalid_or_expired" }, isError: true, }; } if (entry.type !== "project") { return { content: [{ type: "text", text: "该确认令牌对应的不是项目删除操作。" }], structuredContent: { status: "type_mismatch" }, isError: true, }; } const ok = projectService.deleteProject(entry.projectId); if (!ok) { return { content: [{ type: "text", text: `项目不存在或已删除:${entry.projectId}` }], structuredContent: { status: "not_found" }, isError: true, }; } return { content: [{ type: "text", text: `项目已删除:${entry.projectId}` }], structuredContent: { status: "deleted", projectId: entry.projectId }, }; } ); // ==================== 配置项 ==================== server.registerTool( "list_configs", { description: "获取指定项目下所有配置项。", inputSchema: { projectId: z.string().describe("项目 ID"), }, }, async ({ projectId }) => { const configs = configService.getConfigsByProject(projectId); return { content: [{ type: "text", text: JSON.stringify(configs, null, 2) }], structuredContent: { configs }, }; } ); server.registerTool( "get_config", { description: "根据配置 ID 获取单个配置项。", inputSchema: { projectId: z.string().describe("项目 ID"), configId: z.string().describe("配置项 ID"), }, }, async ({ projectId, configId }) => { const config = configService.getConfigById(projectId, configId); if (!config) { throw new Error(`配置项不存在:${configId}`); } return { content: [{ type: "text", text: JSON.stringify(config, null, 2) }], structuredContent: { config }, }; } ); server.registerTool( "create_config", { description: "新增配置项。必填 projectId、key(可用 / 嵌套)、description。维度字段在项目启用对应维度时为必填。type 取值 string/number/boolean/json。", inputSchema: { projectId: z.string().describe("项目 ID"), key: z.string().describe("配置键名,可用 / 嵌套"), value: z.any().optional().describe("配置值"), type: z.enum(["string", "number", "boolean", "json"]).optional().describe("值类型"), description: z.string().describe("配置说明(必填)"), defaultValue: z.any().optional().describe("默认值"), valueRange: z.string().optional().describe("值范围(编辑模式校验)"), required: z.boolean().optional().describe("是否必填"), exists: z.boolean().optional().describe("当前是否存在/生效"), purpose: z.string().optional().describe("用途说明"), idc: z.string().optional().describe("机房(启用该维度时必填)"), environment: z.string().optional().describe("环境(启用该维度时必填)"), group: z.string().optional().describe("分组(启用该维度时必填)"), }, }, async (args) => { assertWritable(); const { projectId, ...data } = args; const mode = currentMode(); const config = configService.createConfig(projectId, data, mode); return { content: [{ type: "text", text: JSON.stringify(config, null, 2) }], structuredContent: { config }, }; } ); server.registerTool( "update_config", { description: "修改配置项(只读模式下禁止)。plan 模式可修改 type/description/purpose/valueRange/required/key/维度;edit 模式可修改 value/exists/key/维度。可仅传需修改的字段。", inputSchema: { projectId: z.string().describe("项目 ID"), configId: z.string().describe("配置项 ID"), key: z.string().optional().describe("配置键名"), value: z.any().optional().describe("配置值"), type: z.enum(["string", "number", "boolean", "json"]).optional().describe("值类型"), description: z.string().optional().describe("配置说明"), defaultValue: z.any().optional().describe("默认值"), valueRange: z.string().optional().describe("值范围"), required: z.boolean().optional().describe("是否必填"), exists: z.boolean().optional().describe("当前是否存在/生效"), purpose: z.string().optional().describe("用途说明"), idc: z.string().optional().describe("机房"), environment: z.string().optional().describe("环境"), group: z.string().optional().describe("分组"), }, }, async (args) => { assertWritable(); const { projectId, configId, ...data } = args; const picks = {}; for (const field of [ "key", "value", "type", "description", "defaultValue", "valueRange", "required", "exists", "purpose", "idc", "environment", "group", ]) { if (data[field] !== undefined) { picks[field] = data[field]; } } const mode = currentMode(); const config = configService.updateConfig(projectId, configId, picks, mode); return { content: [{ type: "text", text: JSON.stringify(config, null, 2) }], structuredContent: { config }, }; } ); server.registerTool( "delete_config", { description: "删除配置项(两步式)。首次调用只登记删除操作并返回 confirmation_token,需再调用 confirm_delete_config 才会真正删除。", inputSchema: { projectId: z.string().describe("项目 ID"), configId: z.string().describe("配置项 ID"), }, }, async ({ projectId, configId }) => { assertWritable(); const config = configService.getConfigById(projectId, configId); if (!config) { throw new Error(`配置项不存在:${configId}`); } const token = createPendingDeletion("config", projectId, configId); return confirmResult( `已登记删除配置项「${config.key}」(${configId}),等待确认。`, "confirm_delete_config", token, { projectId, configId, configKey: config.key } ); } ); server.registerTool( "confirm_delete_config", { description: "确认并真正删除之前由 delete_config 登记的配置项(需提供其返回的 confirmation_token)。", inputSchema: { confirmation_token: z.string().describe("delete_config 返回的确认令牌"), }, }, async ({ confirmation_token }) => { assertWritable(); const entry = takePendingDeletion(confirmation_token); if (!entry) { return { content: [ { type: "text", text: "确认令牌无效或已过期,删除操作未执行。请重新调用 delete_config。" }, ], structuredContent: { status: "invalid_or_expired" }, isError: true, }; } if (entry.type !== "config") { return { content: [{ type: "text", text: "该确认令牌对应的不是配置项删除操作。" }], structuredContent: { status: "type_mismatch" }, isError: true, }; } const mode = currentMode(); const ok = configService.deleteConfig(entry.projectId, entry.configId, mode); if (!ok) { return { content: [{ type: "text", text: `配置项不存在或已删除:${entry.configId}` }], structuredContent: { status: "not_found" }, isError: true, }; } return { content: [{ type: "text", text: `配置项已删除:${entry.configId}` }], structuredContent: { status: "deleted", projectId: entry.projectId, configId: entry.configId }, }; } ); return server; } module.exports = { createMcpServer, currentMode };