From 963046edd9fc955b0d0c74a0f66c64b8f4cdd266 Mon Sep 17 00:00:00 2001 From: cheney Date: Tue, 8 Sep 2026 10:03:09 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=20MCP=20=E6=9C=8D?= =?UTF-8?q?=E5=8A=A1=EF=BC=8C=E6=94=AF=E6=8C=81=20AI=20=E8=87=AA=E5=8A=A8?= =?UTF-8?q?=E6=93=8D=E4=BD=9C=E9=A1=B9=E7=9B=AE=E4=B8=8E=E9=85=8D=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Streamable HTTP 传输,/mcp 端点,Bearer token 鉴权(data/mcp.json,已忽略) - 项目/配置完整增删改查工具,删除为两步式确认(delete_* + confirm_delete_*) - 写操作遵循系统模式,readonly 下拒绝 - 依赖 @modelcontextprotocol/sdk,新增单元测试 --- .gitignore | 1 + README.md | 52 ++- package-lock.json | 761 +++++++++++++++++++++++++++++++++ package.json | 9 +- src/mcp/index.js | 113 +++++ src/mcp/tools.js | 515 ++++++++++++++++++++++ src/persistence/jsonAdapter.js | 23 +- src/server.js | 8 +- test/mcp.test.js | 148 +++++++ 9 files changed, 1623 insertions(+), 7 deletions(-) create mode 100644 src/mcp/index.js create mode 100644 src/mcp/tools.js create mode 100644 test/mcp.test.js diff --git a/.gitignore b/.gitignore index dea7c50..af645f5 100644 --- a/.gitignore +++ b/.gitignore @@ -2,5 +2,6 @@ node_modules/ data/projects.json data/system.json data/configs/*.json +data/mcp.json *.log .reasonix/* \ No newline at end of file diff --git a/README.md b/README.md index 4778615..ca82f6c 100644 --- a/README.md +++ b/README.md @@ -19,10 +19,56 @@ npm start - **三种模式**: - 规划模式:定义配置结构(类型、说明、用途、值范围)。 - 编辑模式:填写/修改配置值,含类型与范围校验。 - - 只读模式:仅查看,禁止修改。 +- 只读模式:仅查看,禁止修改。 - **持久化**:当前实现 JSON 文件方案,支持导出下载与导入合并。持久化层已抽象,后续可扩展 XML / YAML / SQLite / MySQL / Nacos。 +- **MCP 服务**:通过 Model Context Protocol 暴露同一套能力,AI 可自动完成项目/配置的增删改查;删除为两步式确认,写操作受系统模式(只读)约束。 -## 文档 +## MCP 服务 + +MCP 服务以 Streamable HTTP 传输运行在 `POST /mcp` 端点,供 AI(例如 Claude、Cursor 等支持 MCP 的客户端)自动操作。 + +### 配置 + +授权 token 写在 `data/mcp.json`(已被 .gitignore 忽略,不会提交): + +```json +{ + "enabled": true, + "token": "CHANGE_ME_REGISTRY_MCP_TOKEN" +} +``` + +客户端连接时需携带请求头 `Authorization: Bearer `。若 token 为空字符串,则跳过鉴权(不推荐)。 + +### 配置示例(客户端) + +```json +{ + "mcpServers": { + "registry": { + "url": "http://localhost:3000/mcp", + "headers": { "Authorization": "Bearer CHANGE_ME_REGISTRY_MCP_TOKEN" } + } + } +} +``` + +### 可用工具 + +| 类别 | 工具 | 说明 | +| --- | --- | --- | +| 系统 | `get_mode` / `set_mode` | 读取/切换系统模式(plan/edit/readonly) | +| 项目 | `list_projects` / `get_project` | 项目查询 | +| 项目 | `create_project` | 创建项目(维度开关创建后不可改) | +| 项目 | `update_project_mappings` | 调整维度映射方式(path/label) | +| 项目 | `delete_project` / `confirm_delete_project` | 两步式删除:先 `delete_project` 获取令牌,再 `confirm_delete_project` 真正删除 | +| 配置 | `list_configs` / `get_config` | 配置项查询 | +| 配置 | `create_config` / `update_config` | 配置项新增/修改 | +| 配置 | `delete_config` / `confirm_delete_config` | 两步式删除,机制同上 | + +两步式删除返回 `confirmation_token`(5 分钟内有效、一次性),需调用对应的 `confirm_*` 工具并回传该令牌才会真正删除。 + +### 文档 - 需求文档:`docs/requirements.md` - 概要设计文档:`docs/design.md` @@ -46,4 +92,4 @@ registry/ - 对接 SSO 单点登录(当前版本无认证)。 - 扩展更多持久化方案(数据库、Nacos、SSH 远程配置文件)。 -- 项目维度转换机制。 +- 项目维度转换机制。 diff --git a/package-lock.json b/package-lock.json index 7e13aa3..8cfc2b1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,9 +9,416 @@ "version": "1.0.0", "license": "MIT", "dependencies": { + "@modelcontextprotocol/sdk": "^1.30.0", "express": "^4.18.2" } }, + "node_modules/@hono/node-server": { + "version": "2.1.1", + "resolved": "https://registry.npmmirror.com/@hono/node-server/-/node-server-2.1.1.tgz", + "integrity": "sha512-ELuehkj5VCBdgEw9zs+ivkKwyzzUCSQuE96YmiPvn1ECBoZCczbFXJLeEGMTYjphP6gydh4pHMqEYPVMYUVgQg==", + "license": "MIT", + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "hono": "^4" + } + }, + "node_modules/@modelcontextprotocol/sdk": { + "version": "1.30.0", + "resolved": "https://registry.npmmirror.com/@modelcontextprotocol/sdk/-/sdk-1.30.0.tgz", + "integrity": "sha512-xKd8OIzlqNzcqcNumGAa6g+PW2kjD5vrpcKOnfldAUPP3j7lnqMPwlTXQm8gF+UwH72z0lqaRbjr9hqGz0eITA==", + "license": "MIT", + "dependencies": { + "@hono/node-server": "^1.19.9 || ^2.0.5", + "ajv": "^8.17.1", + "ajv-formats": "^3.0.1", + "content-type": "^1.0.5", + "cors": "^2.8.5", + "cross-spawn": "^7.0.5", + "eventsource": "^3.0.2", + "eventsource-parser": "^3.0.0", + "express": "^5.2.1", + "express-rate-limit": "^8.2.1", + "hono": "^4.11.4", + "jose": "^6.1.3", + "json-schema-typed": "^8.0.2", + "pkce-challenge": "^5.0.0", + "raw-body": "^3.0.0", + "zod": "^3.25 || ^4.0", + "zod-to-json-schema": "^3.25.1" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@cfworker/json-schema": "^4.1.1", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "@cfworker/json-schema": { + "optional": true + }, + "zod": { + "optional": false + } + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/body-parser": { + "version": "2.3.0", + "resolved": "https://registry.npmmirror.com/body-parser/-/body-parser-2.3.0.tgz", + "integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==", + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^2.0.0", + "debug": "^4.4.3", + "http-errors": "^2.0.1", + "iconv-lite": "^0.7.2", + "on-finished": "^2.4.1", + "qs": "^6.15.2", + "raw-body": "^3.0.2", + "type-is": "^2.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/body-parser/node_modules/content-type": { + "version": "2.1.0", + "resolved": "https://registry.npmmirror.com/content-type/-/content-type-2.1.0.tgz", + "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/content-disposition": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/content-disposition/-/content-disposition-1.1.0.tgz", + "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmmirror.com/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmmirror.com/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/express": { + "version": "5.2.1", + "resolved": "https://registry.npmmirror.com/express/-/express-5.2.1.tgz", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", + "license": "MIT", + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.1", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/finalhandler": { + "version": "2.1.1", + "resolved": "https://registry.npmmirror.com/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/iconv-lite": { + "version": "0.7.3", + "resolved": "https://registry.npmmirror.com/iconv-lite/-/iconv-lite-0.7.3.tgz", + "integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/media-typer": { + "version": "1.1.1", + "resolved": "https://registry.npmmirror.com/media-typer/-/media-typer-1.1.1.tgz", + "integrity": "sha512-yz3xRaG20c6/BOzvYoDaGtPmGscs7YivItZEEqe6GbwNfHuxu9YNmvnEkMzKldAGY4/80pRcQRZSEnhquk9XuQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmmirror.com/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmmirror.com/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmmirror.com/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/negotiator": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/negotiator/-/negotiator-1.1.0.tgz", + "integrity": "sha512-NMPBRMJgiQHjbd8phG3Vebdx4kZ1H121rbl5IkMqeOsahptB9BKo/d7oJ3zTXqTgagn2bWlNSXkh0QUGM31RYg==", + "license": "MIT", + "dependencies": { + "content-type": "^2.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/negotiator/node_modules/content-type": { + "version": "2.1.0", + "resolved": "https://registry.npmmirror.com/content-type/-/content-type-2.1.0.tgz", + "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/raw-body": { + "version": "3.0.2", + "resolved": "https://registry.npmmirror.com/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/send": { + "version": "1.2.1", + "resolved": "https://registry.npmmirror.com/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/serve-static": { + "version": "2.2.1", + "resolved": "https://registry.npmmirror.com/serve-static/-/serve-static-2.2.1.tgz", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", + "license": "MIT", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/type-is": { + "version": "2.1.0", + "resolved": "https://registry.npmmirror.com/type-is/-/type-is-2.1.0.tgz", + "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==", + "license": "MIT", + "dependencies": { + "content-type": "^2.0.0", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/type-is/node_modules/content-type": { + "version": "2.1.0", + "resolved": "https://registry.npmmirror.com/content-type/-/content-type-2.1.0.tgz", + "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/accepts": { "version": "1.3.8", "resolved": "https://registry.npmmirror.com/accepts/-/accepts-1.3.8.tgz", @@ -25,6 +432,39 @@ "node": ">= 0.6" } }, + "node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmmirror.com/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmmirror.com/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, "node_modules/array-flatten": { "version": "1.1.1", "resolved": "https://registry.npmmirror.com/array-flatten/-/array-flatten-1.1.1.tgz", @@ -129,6 +569,37 @@ "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", "license": "MIT" }, + "node_modules/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmmirror.com/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmmirror.com/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, "node_modules/debug": { "version": "2.6.9", "resolved": "https://registry.npmmirror.com/debug/-/debug-2.6.9.tgz", @@ -231,6 +702,27 @@ "node": ">= 0.6" } }, + "node_modules/eventsource": { + "version": "3.0.7", + "resolved": "https://registry.npmmirror.com/eventsource/-/eventsource-3.0.7.tgz", + "integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==", + "license": "MIT", + "dependencies": { + "eventsource-parser": "^3.0.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/eventsource-parser": { + "version": "3.1.1", + "resolved": "https://registry.npmmirror.com/eventsource-parser/-/eventsource-parser-3.1.1.tgz", + "integrity": "sha512-EKN1vKAMcZ8MlYMpaNuxN6R9yakzH6uajHcHVTqWJzvu5pWw9DyhbP35HH8MVBQ+dZjAfDxk+A8NiR9KWaXiyQ==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/express": { "version": "4.22.2", "resolved": "https://registry.npmmirror.com/express/-/express-4.22.2.tgz", @@ -277,6 +769,70 @@ "url": "https://opencollective.com/express" } }, + "node_modules/express-rate-limit": { + "version": "8.7.0", + "resolved": "https://registry.npmmirror.com/express-rate-limit/-/express-rate-limit-8.7.0.tgz", + "integrity": "sha512-hOwV7WOxXfjRpAM1DSJWZDXx3GhplwD8IfwuwvogD8i1Qnkgosw/H45s4ZnFAUHDAhPjlY9hLBvJhKmGMyY26g==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "ip-address": "^10.2.0" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, + "node_modules/express-rate-limit/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmmirror.com/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/express-rate-limit/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmmirror.com/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmmirror.com/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/fast-uri": { + "version": "3.1.7", + "resolved": "https://registry.npmmirror.com/fast-uri/-/fast-uri-3.1.7.tgz", + "integrity": "sha512-dOvZVzjdZdz7phd9v6jCbwxrBW3fK6n8Rc0CtdmM4bumzMnxywBYhuph6J819RRw/ku+rLbelwfMunktuzVVHg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, "node_modules/finalhandler": { "version": "1.3.2", "resolved": "https://registry.npmmirror.com/finalhandler/-/finalhandler-1.3.2.tgz", @@ -395,6 +951,15 @@ "node": ">= 0.4" } }, + "node_modules/hono": { + "version": "4.13.7", + "resolved": "https://registry.npmmirror.com/hono/-/hono-4.13.7.tgz", + "integrity": "sha512-c8/gF9ac8Y78/agExVocyLevgR+JlpNB444Py0FSX8pJoPdYUfUzRcXtYEYGwt6l19qIlVZPN5Mfsw9jFShmQQ==", + "license": "MIT", + "engines": { + "node": ">=16.9.0" + } + }, "node_modules/http-errors": { "version": "2.0.1", "resolved": "https://registry.npmmirror.com/http-errors/-/http-errors-2.0.1.tgz", @@ -433,6 +998,15 @@ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "license": "ISC" }, + "node_modules/ip-address": { + "version": "10.7.0", + "resolved": "https://registry.npmmirror.com/ip-address/-/ip-address-10.7.0.tgz", + "integrity": "sha512-BGFsyJd5mpXp3rK6jIdADLNgpJUK1jnjzvYF8lK+VyDab9JAmqN0YOKDdP17HlgKb2+ehPgDc8EtnRLbGCAMhA==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, "node_modules/ipaddr.js": { "version": "1.9.1", "resolved": "https://registry.npmmirror.com/ipaddr.js/-/ipaddr.js-1.9.1.tgz", @@ -442,6 +1016,39 @@ "node": ">= 0.10" } }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmmirror.com/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/jose": { + "version": "6.2.12", + "resolved": "https://registry.npmmirror.com/jose/-/jose-6.2.12.tgz", + "integrity": "sha512-9NiFmJEex0sy2Dk58j2UGBSHgUs2ypF9eZSu4L6vjOX3Dp96Sw1F3uL+H+D1sx02jZZdzUT0HgvCy59CuvXcWw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmmirror.com/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, + "node_modules/json-schema-typed": { + "version": "8.0.2", + "resolved": "https://registry.npmmirror.com/json-schema-typed/-/json-schema-typed-8.0.2.tgz", + "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", + "license": "BSD-2-Clause" + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmmirror.com/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -526,6 +1133,15 @@ "node": ">= 0.6" } }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmmirror.com/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/object-inspect": { "version": "1.13.4", "resolved": "https://registry.npmmirror.com/object-inspect/-/object-inspect-1.13.4.tgz", @@ -550,6 +1166,15 @@ "node": ">= 0.8" } }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmmirror.com/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, "node_modules/parseurl": { "version": "1.3.3", "resolved": "https://registry.npmmirror.com/parseurl/-/parseurl-1.3.3.tgz", @@ -559,12 +1184,30 @@ "node": ">= 0.8" } }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmmirror.com/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/path-to-regexp": { "version": "0.1.13", "resolved": "https://registry.npmmirror.com/path-to-regexp/-/path-to-regexp-0.1.13.tgz", "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==", "license": "MIT" }, + "node_modules/pkce-challenge": { + "version": "5.0.1", + "resolved": "https://registry.npmmirror.com/pkce-challenge/-/pkce-challenge-5.0.1.tgz", + "integrity": "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==", + "license": "MIT", + "engines": { + "node": ">=16.20.0" + } + }, "node_modules/proxy-addr": { "version": "2.0.7", "resolved": "https://registry.npmmirror.com/proxy-addr/-/proxy-addr-2.0.7.tgz", @@ -618,6 +1261,64 @@ "node": ">= 0.8" } }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmmirror.com/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmmirror.com/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/router/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmmirror.com/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/router/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmmirror.com/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/router/node_modules/path-to-regexp": { + "version": "8.4.2", + "resolved": "https://registry.npmmirror.com/path-to-regexp/-/path-to-regexp-8.4.2.tgz", + "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/safe-buffer": { "version": "5.2.1", "resolved": "https://registry.npmmirror.com/safe-buffer/-/safe-buffer-5.2.1.tgz", @@ -695,6 +1396,27 @@ "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", "license": "ISC" }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmmirror.com/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/side-channel": { "version": "1.1.1", "resolved": "https://registry.npmmirror.com/side-channel/-/side-channel-1.1.1.tgz", @@ -824,6 +1546,45 @@ "engines": { "node": ">= 0.8" } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmmirror.com/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, + "node_modules/zod": { + "version": "4.5.4", + "resolved": "https://registry.npmmirror.com/zod/-/zod-4.5.4.tgz", + "integrity": "sha512-sC95tT5iHHH9gtpj6A81kh+NEaRAUFN+qlUPDUbRfOMvNf5QCBqsb3WgvnpVtK5Y+4UfA6KqufotuTvMGiTlsA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-to-json-schema": { + "version": "3.25.2", + "resolved": "https://registry.npmmirror.com/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz", + "integrity": "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==", + "license": "ISC", + "peerDependencies": { + "zod": "^3.25.28 || ^4" + } } } } diff --git a/package.json b/package.json index 51f884b..9f436c2 100644 --- a/package.json +++ b/package.json @@ -8,9 +8,14 @@ "dev": "node src/server.js", "test": "node --test" }, - "keywords": ["config", "registry", "microservice"], + "keywords": [ + "config", + "registry", + "microservice" + ], "license": "MIT", "dependencies": { + "@modelcontextprotocol/sdk": "^1.30.0", "express": "^4.18.2" } -} +} diff --git a/src/mcp/index.js b/src/mcp/index.js new file mode 100644 index 0000000..943810c --- /dev/null +++ b/src/mcp/index.js @@ -0,0 +1,113 @@ +/** + * MCP HTTP 传输层 + * 以 Streamable HTTP 协议在 /mcp 端点暴露 MCP 服务。 + * 鉴权:基于 data/mcp.json 中配置的 token,要求请求头 Authorization: Bearer 。 + */ +const crypto = require("crypto"); +const express = require("express"); +const { randomUUID } = require("node:crypto"); +const { + StreamableHTTPServerTransport, +} = require("@modelcontextprotocol/sdk/server/streamableHttp.js"); +const { isInitializeRequest } = require("@modelcontextprotocol/sdk/types.js"); +const { createMcpServer } = require("./tools"); +const { loadMcpConfig } = require("../persistence/jsonAdapter"); + +const router = express.Router(); + +// sessionId -> transport,用于会话复用 +const transports = new Map(); + +/** + * 校验请求携带的 Bearer token 是否与配置中的 token 匹配 + * @param {string} [header] - Authorization 请求头 + * @returns {boolean} 是否鉴权通过 + */ +function isAuthorized(header) { + const mcp = loadMcpConfig(); + // 未配置 token 时视为未启用鉴权 + if (!mcp.token) { + return true; + } + const expected = mcp.token; + const provided = header && header.startsWith("Bearer ") ? header.slice(7).trim() : ""; + if (!provided || provided.length !== expected.length) { + return false; + } + // 使用常量时间比较,避免时序侧信道 + const a = Buffer.from(provided); + const b = Buffer.from(expected); + return crypto.timingSafeEqual(a, b); +} + +/** + * 鉴权中间件:token 校验失败返回 401 + */ +router.use((req, res, next) => { + if (!isAuthorized(req.headers.authorization)) { + return res + .status(401) + .json({ jsonrpc: "2.0", error: { code: -32001, message: "Unauthorized: invalid or missing token" }, id: null }); + } + next(); +}); + +/** + * 根据会话 ID 或其下的 transport 处理请求 + * 若带 mcp-session-id 且存在则复用;否则仅初始化请求可新建会话 + */ +async function handleRequest(req, res) { + const sessionId = req.headers["mcp-session-id"]; + const jsonBody = req.body; + + try { + let transport; + if (sessionId && transports.get(sessionId)) { + transport = transports.get(sessionId); + } else if (!sessionId && isInitializeRequest(jsonBody)) { + transport = new StreamableHTTPServerTransport({ + sessionIdGenerator: () => randomUUID(), + onsessioninitialized: (sid) => { + transports.set(sid, transport); + }, + }); + transport.onclose = () => { + if (transport.sessionId) { + transports.delete(transport.sessionId); + } + }; + const server = createMcpServer(); + await server.connect(transport); + await transport.handleRequest(req, res, req.body); + return; + } else { + res.status(400).json({ + jsonrpc: "2.0", + error: { code: -32000, message: "Bad Request: missing or invalid mcp-session-id" }, + id: null, + }); + return; + } + await transport.handleRequest(req, res, req.body); + } catch (err) { + console.error("MCP 请求处理失败:", err); + if (!res.headersSent) { + res.status(500).json({ + jsonrpc: "2.0", + error: { code: -32603, message: "Internal server error" }, + id: null, + }); + } + } +} + +// POST /mcp:初始化与工具调用 +router.post("/", (req, res) => handleRequest(req, res)); + +// GET /mcp:建立 SSE 流(会话恢复/服务器通知) +router.get("/", (req, res) => handleRequest(req, res)); + +// DELETE /mcp:会话终止 +router.delete("/", (req, res) => handleRequest(req, res)); + +module.exports = router; diff --git a/src/mcp/tools.js b/src/mcp/tools.js new file mode 100644 index 0000000..3c0616b --- /dev/null +++ b/src/mcp/tools.js @@ -0,0 +1,515 @@ +/** + * 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 }; diff --git a/src/persistence/jsonAdapter.js b/src/persistence/jsonAdapter.js index ffa0f9f..102d873 100644 --- a/src/persistence/jsonAdapter.js +++ b/src/persistence/jsonAdapter.js @@ -10,6 +10,7 @@ const path = require("path"); const DATA_DIR = path.join(__dirname, "..", "..", "data"); const PROJECTS_FILE = path.join(DATA_DIR, "projects.json"); const SYSTEM_FILE = path.join(DATA_DIR, "system.json"); +const MCP_FILE = path.join(DATA_DIR, "mcp.json"); const CONFIGS_DIR = path.join(DATA_DIR, "configs"); /** @@ -121,6 +122,24 @@ function saveSystem(system) { writeJsonFile(SYSTEM_FILE, system); } +/** + * 加载 MCP 服务配置 + * @returns {Object} MCP 配置对象(默认启用、空 token) + */ +function loadMcpConfig() { + ensureDataDir(); + return readJsonFile(MCP_FILE) || { enabled: true, token: "" }; +} + +/** + * 保存 MCP 服务配置 + * @param {Object} mcp - MCP 配置对象 + */ +function saveMcpConfig(mcp) { + ensureDataDir(); + writeJsonFile(MCP_FILE, mcp); +} + module.exports = { loadProjects, saveProjects, @@ -129,4 +148,6 @@ module.exports = { deleteConfigs, loadSystem, saveSystem, -}; + loadMcpConfig, + saveMcpConfig, +}; diff --git a/src/server.js b/src/server.js index 0352ff9..efde8ef 100644 --- a/src/server.js +++ b/src/server.js @@ -8,6 +8,9 @@ const projectRoutes = require("./routes/projects"); const configRoutes = require("./routes/configs"); const { loadSystem, saveSystem } = require("./persistence/jsonAdapter"); +// MCP 服务(Model Context Protocol,供 AI 自动操作) +const mcpRoutes = require("./mcp"); + const app = express(); const PORT = process.env.PORT || 3000; @@ -21,6 +24,9 @@ app.use(express.static(path.join(__dirname, "..", "public"), { etag: false, last app.use("/api/projects", projectRoutes); app.use("/api/projects/:projectId/configs", configRoutes); +// MCP 服务端点(Streamable HTTP 传输) +app.use("/mcp", mcpRoutes); + /** * GET /api/mode * 获取当前系统模式 @@ -58,4 +64,4 @@ app.put("/api/mode", (req, res) => { // 启动服务 app.listen(PORT, () => { console.log(`Registry 服务已启动: http://localhost:${PORT}`); -}); +}); diff --git a/test/mcp.test.js b/test/mcp.test.js new file mode 100644 index 0000000..fbaf85a --- /dev/null +++ b/test/mcp.test.js @@ -0,0 +1,148 @@ +/** + * 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} 工具结果 + */ +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"); +}); \ No newline at end of file