All checks were successful
build-and-deploy / build-and-deploy (push) Successful in 39s
- Streamable HTTP 传输,/mcp 端点,Bearer token 鉴权(data/mcp.json,已忽略) - 项目/配置完整增删改查工具,删除为两步式确认(delete_* + confirm_delete_*) - 写操作遵循系统模式,readonly 下拒绝 - 依赖 @modelcontextprotocol/sdk,新增单元测试
516 lines
17 KiB
JavaScript
516 lines
17 KiB
JavaScript
/**
|
||
* 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 };
|