feat: 新增 MCP 服务,支持 AI 自动操作项目与配置
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,新增单元测试
This commit is contained in:
cheney 2026-09-08 10:03:09 +08:00
parent f9f3184f98
commit 963046edd9
9 changed files with 1623 additions and 7 deletions

1
.gitignore vendored
View File

@ -2,5 +2,6 @@ node_modules/
data/projects.json
data/system.json
data/configs/*.json
data/mcp.json
*.log
.reasonix/*

View File

@ -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>`。若 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 远程配置文件)。
- 项目维度转换机制。
- 项目维度转换机制。

761
package-lock.json generated
View File

@ -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"
}
}
}
}

View File

@ -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"
}
}
}

113
src/mcp/index.js Normal file
View File

@ -0,0 +1,113 @@
/**
* MCP HTTP 传输层
* Streamable HTTP 协议在 /mcp 端点暴露 MCP 服务
* 鉴权基于 data/mcp.json 中配置的 token要求请求头 Authorization: Bearer <token>
*/
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;

515
src/mcp/tools.js Normal file
View File

@ -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] - 配置 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 };

View File

@ -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,
};

View File

@ -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}`);
});
});

148
test/mcp.test.js Normal file
View File

@ -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<Object>} 工具结果
*/
async function callTool(name, args) {
const tool = server._registeredTools[name];
assert.ok(tool, `工具不存在: ${name}`);
try {
const result = await tool.handler(args, {});
return {
text: (result.content || []).map((c) => c.text).join("\n"),
structuredContent: result.structuredContent,
isError: result.isError === true,
};
} catch (err) {
// 直接调用底层 handler 时,抛错不会自动转换为 isError 结果,这里统一转换
return {
text: err && err.message ? err.message : String(err),
structuredContent: { error: true },
isError: true,
};
}
}
test("注册了全部项目/配置 CRUD 及确认删除工具", () => {
const names = new Set(Object.keys(server._registeredTools));
for (const name of [
"get_mode", "set_mode",
"list_projects", "get_project", "create_project", "update_project_mappings",
"delete_project", "confirm_delete_project",
"list_configs", "get_config", "create_config", "update_config",
"delete_config", "confirm_delete_config",
]) {
assert.ok(names.has(name), `缺少工具: ${name}`);
}
});
test("删除项目为两步式:先登记待确认,确认后才真正删除", async () => {
const { text: createText } = await callTool("create_project", {
name: "MCP-Unittest-Proj",
});
const project = JSON.parse(createText.match(/\{[\s\S]*\}/)[0]);
const pid = project.id;
const del = await callTool("delete_project", { projectId: pid });
assert.strictEqual(del.structuredContent.status, "awaiting_confirmation");
assert.ok(del.structuredContent.confirmation_token);
// 未确认前项目仍然存在
const stillThere = await callTool("get_project", { projectId: pid });
assert.ok(!stillThere.isError);
// 确认后项目被删除
const confirm = await callTool("confirm_delete_project", {
confirmation_token: del.structuredContent.confirmation_token,
});
assert.strictEqual(confirm.structuredContent.status, "deleted");
const gone = await callTool("get_project", { projectId: pid });
assert.ok(gone.isError, "确认删除后项目应不存在");
});
test("重复使用确认令牌会失败(一次性令牌)", async () => {
const { text: createText } = await callTool("create_project", { name: "MCP-Unittest-Token" });
const project = JSON.parse(createText.match(/\{[\s\S]*\}/)[0]);
const del = await callTool("delete_project", { projectId: project.id });
const tok = del.structuredContent.confirmation_token;
await callTool("confirm_delete_project", { confirmation_token: tok });
const second = await callTool("confirm_delete_project", { confirmation_token: tok });
assert.ok(second.isError);
assert.strictEqual(second.structuredContent.status, "invalid_or_expired");
});
test("readonly 模式下拒绝项目/配置写操作,切换回 edit 后恢复", async () => {
await callTool("set_mode", { mode: "readonly" });
const createProject = await callTool("create_project", { name: "不应创建" });
assert.ok(createProject.isError);
assert.match(createProject.text, /只读模式/);
const delProject = await callTool("delete_project", { projectId: "proj-mrlip49o-qsr2yx" });
assert.ok(delProject.isError);
assert.match(delProject.text, /只读模式/);
const createConfig = await callTool("create_config", {
projectId: "proj-mrlip49o-qsr2yx",
key: "x",
description: "x",
});
assert.ok(createConfig.isError);
assert.match(createConfig.text, /只读模式/);
await callTool("set_mode", { mode: "edit" });
});
test("配置删除同样为两步式确认", async () => {
const { text: createText } = await callTool("create_project", { name: "MCP-Unittest-Cfg" });
const project = JSON.parse(createText.match(/\{[\s\S]*\}/)[0]);
const cfg = await callTool("create_config", {
projectId: project.id,
key: "a/b",
description: "unit",
value: "1",
type: "number",
});
const cfgObj = JSON.parse(cfg.text.match(/\{[\s\S]*\}/)[0]);
const del = await callTool("delete_config", { projectId: project.id, configId: cfgObj.id });
assert.strictEqual(del.structuredContent.status, "awaiting_confirmation");
const confirm = await callTool("confirm_delete_config", {
confirmation_token: del.structuredContent.confirmation_token,
});
assert.strictEqual(confirm.structuredContent.status, "deleted");
// 清理测试项目
const delProject = await callTool("delete_project", { projectId: project.id });
const confirmProject = await callTool("confirm_delete_project", {
confirmation_token: delProject.structuredContent.confirmation_token,
});
assert.strictEqual(confirmProject.structuredContent.status, "deleted");
});