registry/src/mcp/tools.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

516 lines
17 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 工具定义
* 将 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] - 配置 IDtype=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 };