From ea364d3470b79acea3b5cb13622894c0277d094d Mon Sep 17 00:00:00 2001 From: furao Date: Fri, 14 Aug 2026 12:10:09 +0800 Subject: [PATCH] =?UTF-8?q?[cc-3-8-x-mcp]=20=E9=87=8D=E6=9E=84:=20?= =?UTF-8?q?=E6=94=B9=E4=B8=BA=20Gateway=20=E7=A7=81=E6=9C=89=20Editor=20Br?= =?UTF-8?q?idge?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 改了什么:移除项目级 MCP Server 与 universal-mcp-sdk,新增带随机 token 的本机 Editor Bridge、Gateway v2 注册和分级超时转发。 为什么:MCP 协议与多项目路由统一交给全局 Gateway,项目扩展只保留 Cocos Editor.Message 和离线 CLI 职责。 影响范围:扩展启动与面板、编辑器注册、router 转发、安全边界、使用文档及自动化测试。 --- .gitmodules | 3 - AGENTS.md | 46 ++- README.md | 103 +++++-- main.js | 211 +++++++------ mcp-sdk | 1 - package.json | 16 +- panel/index.js | 40 +-- router/bin.js | 145 +++++---- router/src/editor-control.js | 94 +++--- router/src/http-json-rpc.js | 128 ++++++++ .../test/gateway-bridge.integration.test.js | 123 ++++++++ router/test/http-json-rpc.test.js | 104 +++++++ server/editor-bridge.js | 283 ++++++++++++++++++ server/local-status.js | 33 ++ server/test/editor-bridge.test.js | 119 ++++++++ server/test/local-status.test.js | 32 ++ 16 files changed, 1167 insertions(+), 314 deletions(-) delete mode 100644 .gitmodules delete mode 160000 mcp-sdk create mode 100644 router/src/http-json-rpc.js create mode 100644 router/test/gateway-bridge.integration.test.js create mode 100644 router/test/http-json-rpc.test.js create mode 100644 server/editor-bridge.js create mode 100644 server/local-status.js create mode 100644 server/test/editor-bridge.test.js create mode 100644 server/test/local-status.test.js diff --git a/.gitmodules b/.gitmodules deleted file mode 100644 index c0f8686..0000000 --- a/.gitmodules +++ /dev/null @@ -1,3 +0,0 @@ -[submodule "mcp-sdk"] - path = mcp-sdk - url = https://gitee.com/Fu_Rao/universal-mcp-sdk.git diff --git a/AGENTS.md b/AGENTS.md index 7bd86cd..8bb0455 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,6 +1,6 @@ # AGENTS.md -本文是 `cc-3-8-x-mcp` 的 agent 使用规则。只写插件级规则;项目自己的预览参数、业务本地服地址、测试账号和验证步骤写在项目文档里。 +本文是 `cc-3-8-x-mcp` 的 agent 使用规则。它只支持 Cocos Creator 3.8.x;项目自己的预览参数、业务本地服地址、测试账号和验证步骤写在项目文档里。 ## 允许入口 @@ -8,15 +8,14 @@ | 入口 | 用途 | |---|---| -| 当前项目 MCP 实例 | `scene` / `asset-db` / `preview` / `local` 域操作 | -| 当前项目 HTTP MCP endpoint | 当前 agent 没注入 MCP tool 时的等价入口 | +| 全局 `cocos-mcp-gateway` 注入的项目 tool | `scene` / `asset-db` / `preview` / `local` 域操作 | | `cocos-mcp-cli` offline 命令 | `.prefab` / `.anim` 文件查询和修改 | | Playwright / Chrome | 浏览器里真实游戏页面的交互验证 | | `.dev/refresh` 的 `restart-package` | 重启本扩展代码 | -其它入口不作为本插件使用路径。项目文档可以补充项目自己的只读兜底信息,但不能覆盖本文件的入口规则。 +项目扩展的 `/bridge` 是 Gateway 私有协议,agent 和其他 MCP 客户端不得直接调用、不得直接读取或复制注册记录中的 token。其它入口不作为本插件使用路径。 -## 绑定当前项目 MCP +## 绑定当前项目 同机可能同时打开多个 Cocos 项目。agent 必须先按项目根目录绑定 MCP 实例,再执行后续操作。 @@ -32,7 +31,9 @@ 2. 只保留 `projectPath` 与当前项目根目录一致的记录。 3. 校验 `pid` 仍存活。 4. 多个匹配时取 `updatedAt` 最新的记录。 -5. 后续所有 HTTP MCP 请求都使用该记录的 `url`。 +5. 只接受 `transport=editor-bridge`、`gatewayApiVersion>=2`、`bridgeApiVersion=1` 的记录。 +6. 后续操作只调用 Gateway 暴露的 `__`,不直接请求记录中的 `/bridge` URL。 +7. 缺失或格式不正确的 `authToken` 视为无效注册记录;token 不写日志、不贴到回复里。 快速确认脚本: @@ -59,6 +60,10 @@ for path in glob.glob(os.path.expanduser('~/.cocos-mcp/editors/*.json')): data = json.load(f) if os.path.realpath(data.get('projectPath', '')) != os.path.realpath(PROJECT): continue + if data.get('transport') != 'editor-bridge': + continue + if data.get('gatewayApiVersion', 0) < 2 or data.get('bridgeApiVersion') != 1: + continue os.kill(int(data['pid']), 0) items.append((data.get('updatedAt', ''), path, data)) except Exception: @@ -69,9 +74,9 @@ for _, path, data in sorted(items)[-3:]: PY ``` -## Tool 与 HTTP MCP +## Gateway Tool -如果当前 agent 已注入 router 暴露的 MCP tool,使用带项目名前缀的 tool: +使用 Gateway 暴露的带项目名前缀 tool: ```text forest__preview_query_url @@ -79,16 +84,7 @@ forest__asset_reimport forest__preview_refresh_and_reload ``` -如果当前 agent 没注入这些 tool,按上节解析当前项目 MCP,再直接调用 HTTP endpoint。 - -HTTP 调用格式: - -```bash -curl -sS -X POST http://127.0.0.1:/mcp \ - -H 'Content-Type: application/json' \ - -H 'Accept: application/json, text/event-stream' \ - -d '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"preview_query_url","arguments":{}}}' -``` +如果当前 agent 没注入这些 tool,先修复或注册全局 `cocos-mcp-gateway`。禁止把项目 `/bridge` 当成 HTTP MCP fallback。 ## 预览 URL @@ -97,12 +93,12 @@ curl -sS -X POST http://127.0.0.1:/mcp \ | 来源 | 说明 | |---|---| | `preview_query_url` | 首选 | -| `local_get_status` | 查预览端口、MCP endpoint、编辑器状态 | -| 项目文档明确声明的只读兜底文件 | 仅在 MCP 不通时使用 | +| `local_get_status` | 查预览端口、Bridge 状态、编辑器状态 | +| 项目文档明确声明的只读兜底文件 | 仅在 Gateway 不通时使用 | 没有项目文档声明时,不读取旧 `.dev/preview-url`,不猜 `localhost:7456`。 -浏览器验证必须在 MCP 返回的 URL 上追加唯一 `tid`: +浏览器验证必须在 Gateway tool 返回的 URL 上追加唯一 `tid`: ```text /?tid= @@ -112,7 +108,7 @@ curl -sS -X POST http://127.0.0.1:/mcp \ ## 资源修改后刷新 -offline CLI 直接写磁盘,Cocos 编辑器不会自动感知。改完 `.prefab` / `.anim` / `.json` 等资源后,使用当前项目 MCP 刷新: +offline CLI 直接写磁盘,Cocos 编辑器不会自动感知。改完 `.prefab` / `.anim` / `.json` 等资源后,使用当前项目 Gateway tool 刷新: | 修改范围 | 后续动作 | |---|---| @@ -140,7 +136,7 @@ offline CLI 直接写磁盘,Cocos 编辑器不会自动感知。改完 `.prefa ## 浏览器验证 -MCP 负责拿 URL、重导资源、刷新预览;浏览器里真实业务页面的交互验证交给 Playwright / Chrome。 +Gateway tool 负责拿 URL、重导资源、刷新预览;浏览器里真实业务页面的交互验证交给 Playwright / Chrome。 推荐流程: @@ -157,6 +153,6 @@ browser_take_screenshot 1. 没有匹配 `projectPath` 的注册文件:确认 Cocos 编辑器已打开当前项目,并且扩展已启用。 2. `pid` 不存活:忽略该注册文件,等编辑器重新注册。 -3. HTTP MCP endpoint 不通:用 `.dev/refresh` 的 `restart-package` 重启扩展;仍不通就重启编辑器。 -4. MCP 返回的预览 URL 为空:确认编辑器预览已启动。 +3. Editor Bridge 不通:用 `.dev/refresh` 的 `restart-package` 重启扩展;仍不通就重启编辑器。 +4. Gateway tool 返回的预览 URL 为空:确认编辑器预览已启动。 5. 工具行为异常或文档与实际不一致:反馈插件问题,由用户决定是否修插件本身。 diff --git a/README.md b/README.md index 9f8a940..42ec1c2 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,10 @@ # cc-3-8-x-mcp -Cocos Creator 3.8.x 的 MCP 桥接扩展 + 离线 prefab 读写 CLI。 +Cocos Creator 3.8.x 专用的 Editor Bridge 扩展 + 离线 prefab 读写 CLI。 -把编辑器的 scene/asset/preview/local 能力以 MCP 协议暴露给 Claude Code;同时提供无需编辑器运行的 offline prefab 编辑能力,支持节点增删克隆与 stub 节点属性覆写。 +把编辑器的 scene/asset/preview/local 能力通过本机私有 Bridge 交给全局 +`cocos-mcp-gateway`;同时提供无需编辑器运行的 offline prefab 编辑能力。 +本扩展不是 MCP Server,不能被 Codex、Claude Code 或其他 MCP 客户端直接注册。 --- @@ -10,7 +12,7 @@ Cocos Creator 3.8.x 的 MCP 桥接扩展 + 离线 prefab 读写 CLI。 | 文档 | 内容 | 阅读时机 | |---|---|---| -| [`AGENTS.md`](./AGENTS.md) | **Agent 使用规则**:多项目 MCP 绑定、HTTP fallback、预览 URL、CLI/MCP 分工 | agent 使用本插件前 | +| [`AGENTS.md`](./AGENTS.md) | **Agent 使用规则**:Gateway 绑定、Editor Bridge、预览 URL、CLI/Gateway 分工 | agent 使用本插件前 | | [`QUICK-REF.md`](./QUICK-REF.md) | **一页速查表**:节点定位三式、场景 → op 对照、ops.json 速记、踩坑表 | agent 起手第一份,挑不到再翻 cli.md | | [`doc/cli.md`](./doc/cli.md) | **CLI 完整手册**:命令、26 个 op 全表、配方、已知坑、源码导航 | 改 `.prefab` / `.anim` 文件前必读 | | [`doc/prefab-schema.md`](./doc/prefab-schema.md) | CC3 prefab JSON 结构速查(节点 / 组件 / 引用字段格式) | 看不懂 prefab 字段时查 | @@ -23,31 +25,54 @@ Cocos Creator 3.8.x 的 MCP 桥接扩展 + 离线 prefab 读写 CLI。 ## 架构 ``` -Claude Code (MCP client) +Codex / Claude Code / 其他 stdio MCP client │ stdio (JSON-RPC) ▼ ┌─────────────────────────────┐ -│ router/bin.js │ ← 统一入口,聚合多个编辑器 +│ 全局 cocos-mcp-gateway │ ← 唯一 MCP Server / Codex 注册入口 +│ runtime/router/bin.js │ ← 聚合多个编辑器 │ - 扫 ~/.cocos-mcp/editors/ │ │ - offline prefab tools │ └───────────┬─────────────────┘ - │ HTTP MCP (JSON-RPC) + │ 私有 loopback JSON-RPC /bridge + Bearer token ┌───────┴────────┐ │ │ ▼ ▼ 编辑器实例 A 编辑器实例 B ← 每个项目一个编辑器进程 -server/mcp-server.js ← 在 Cocos 编辑器进程内跑 HTTP MCP server +项目内 cc-3-8-x-mcp ← 在 Cocos Creator 3.8.x 进程内跑轻量 Editor Bridge ``` -offline prefab tools(`prefab_query` / `prefab_edit` / `prefab_batch`)在 router 进程内直接执行,调用 `cli/src/index.js`,不需要编辑器运行。 +职责边界:项目扩展只负责 `Editor.Message` 能力、Bridge 和 CLI;全局 +`cocos-mcp-gateway` 独占 MCP 协议、客户端注册、实例发现与多项目路由。 +`universal-mcp-sdk` 已从项目扩展运行链路移除。 + +offline prefab tools(`prefab_query` / `prefab_edit` / `prefab_batch`)在 Gateway 进程内直接执行,调用 `cli/src/index.js`,不需要编辑器运行。 + +## 3.0 架构升级 + +`cc-3-8-x-mcp` 的名字保持不变,因为它仍然是 Cocos Creator 3.8.x 专用扩展; +变化的是它在整套系统中的职责。 + +| 对比项 | 旧版 | 3.0 | +|---|---|---| +| 客户端连接 | 客户端或全局 router 连接项目 `/mcp` | 客户端只连接全局 `cocos-mcp-gateway` | +| 扩展职责 | 项目级 MCP Server + Editor.Message + CLI | 私有 Editor Bridge + Editor.Message + CLI | +| MCP 协议 | 每个项目扩展都解析一遍 | 只由 Gateway 处理 | +| 共享 SDK | 项目内带 `universal-mcp-sdk` 子库 | 已移除 | +| Bridge 访问 | 项目 HTTP MCP endpoint 可被直接配置 | 仅允许 Gateway 通过 loopback 和随机 token 调用 | + +这意味着项目仓库更轻、客户端配置更稳定,MCP 兼容问题与 Cocos 编辑器问题也有了明确的排查边界。 + +这是一次不兼容升级:旧 `/mcp` endpoint 和旧协议 fallback 均已移除。使用 3.0 +扩展时必须同时使用新版 `cocos-mcp-gateway`,并删除客户端中的项目级 MCP 配置。 --- ## 三个组件 -### 1. 编辑器扩展(`main.js` + panel + server) +### 1. 编辑器扩展(`main.js` + panel + `server/editor-bridge.js`) -在 Cocos Creator 编辑器进程内运行。启动时拉起 `server/mcp-server.js`(HTTP MCP server),并把自身信息写入 `~/.cocos-mcp/editors/.json`(心跳注册)。 +在 Cocos Creator 编辑器进程内运行。启动时拉起轻量 Editor Bridge,并把自身信息写入 `~/.cocos-mcp/editors/.json`(心跳注册)。Bridge 只接受 Gateway 的本机鉴权请求。 **暴露的 tool 域**: @@ -58,23 +83,26 @@ offline prefab tools(`prefab_query` / `prefab_edit` / `prefab_batch`)在 rou | preview | 预览地址查询、浏览器控制、截图、JS 注入 | | local | 本地状态、worktree 列表、.dev 目录管理 | -### 2. stdio router(`router/`) +### 2. 全局 MCP Gateway -入口:`router/bin.js` +源码与入口位于独立仓库 `cocos-mcp-gateway/runtime/router/bin.js`。 **职责**: - 扫描 `~/.cocos-mcp/editors/` 发现活跃编辑器(心跳超 120s 视为已死) +- 只接受 `transport=editor-bridge`、Gateway API v2、Bridge API v1 的注册记录 +- 校验 PID、loopback `/bridge` endpoint 和随机 token;拒绝非本机地址 +- 从权限为 `0600` 的注册记录读取每个实例的随机 bearer token,不把 token 暴露在 tool 列表中 - 每隔 15s 自动发现新实例 -- 给每个编辑器的 tool 加 `__` 前缀,合并后暴露给 Claude Code +- 给每个编辑器的 tool 加 `__` 前缀,合并后暴露给 MCP 客户端 - 内置 offline prefab tools(`prefab_query` / `prefab_edit` / `prefab_batch`),不带前缀,全局可用 **tool 路由示例**: ``` -forest__scene_query_node_tree → forest 编辑器的 HTTP MCP server -another__asset_query_assets → another 编辑器的 HTTP MCP server -prefab_query → router 本地执行(cli),无需编辑器 +forest__scene_query_node_tree → forest 编辑器的 Editor Bridge +another__asset_query_assets → another 编辑器的 Editor Bridge +prefab_query → Gateway 本地执行(cli),无需编辑器 ``` ### 3. cocos-mcp-cli(`cli/`) @@ -89,7 +117,7 @@ prefab_query → router 本地执行(cli),无需编辑 | 区块 | 内容 | |---|---| -| MCP Server | 运行状态指示灯 / 端点地址 / tool 数量 / 请求计数;复制端点、复制 CLI 命令、重启 | +| Editor Bridge | 运行状态指示灯 / 私有端点 / tool 数量 / 请求计数;复制不含令牌的诊断信息、查看客户端入口、重启 | | 编辑器状态 | 当前分支 / HEAD / 预览地址和端口 / 编辑器 PID / Watcher 状态 / 最后更新时间 | | 快捷动作 | 一键刷新(资源+场景+预览)/ 软重载场景 / 打开预览浏览器 / 截图 / 打开 .dev 目录 / 清理临时文件 / 手动输入路径重新导入 | | Debug 注入 | 在预览页面执行任意 JS(`eval_js`),结果直接展示;自定义快捷按钮(配置见下方) | @@ -155,7 +183,7 @@ prefab_query → router 本地执行(cli),无需编辑 | `local_open_dev_dir` | 在 Finder 中打开 .dev 目录 | | `local_clean_dev_dir` | 清理 .dev 临时文件 | -### offline 域(router 级,无需编辑器运行) +### offline 域(Gateway 级,无需编辑器运行) | Tool | 说明 | |---|---| @@ -165,7 +193,7 @@ prefab_query → router 本地执行(cli),无需编辑 完整 op 列表与配方见 [`doc/cli.md`](./doc/cli.md)。 -> offline tools 的 `filePath` 和 `opsJsonPath` 必须为绝对路径;router 以 stdio 模式运行,cwd 不确定,相对路径有歧义。 +> offline tools 的 `filePath` 和 `opsJsonPath` 必须为绝对路径;Gateway 以 stdio 模式运行,cwd 不确定,相对路径有歧义。 --- @@ -176,29 +204,38 @@ prefab_query → router 本地执行(cli),无需编辑 ```json { "pid": 12345, - "url": "http://127.0.0.1:7788/mcp", - "shortName": "forest", - "projectPath": "/path/to/project" + "url": "http://127.0.0.1:7788/bridge", + "transport": "editor-bridge", + "projectShortName": "forest", + "projectPath": "/path/to/project", + "extensionVersion": "3.0.0", + "gatewayApiVersion": 2, + "bridgeApiVersion": 1, + "authToken": "<64 hex chars>" } ``` -router 定期扫此目录,心跳超过 120s 的记录视为死亡自动剔除。tool 名以 `__` 为前缀隔离,同机多开不冲突。 +注册目录权限为 `0700`,记录以原子写入方式更新且权限为 `0600`。Gateway 定期扫此目录,心跳超过 120s 的记录视为死亡自动剔除。tool 名以 `__` 为前缀隔离,同机多开不冲突。 --- -## 接入 Claude Code - -> ⚠️ 本仓库通过 git submodule 依赖 [universal-mcp-sdk](https://github.com/HappyLifeOk/universal-mcp-sdk),clone 后**必须先拉 submodule**,否则 MCP server 起不来: -> -> ```bash -> git submodule update --init --recursive -> ``` +## 接入 MCP 客户端 ```bash -claude mcp add cocos -- node /path/to/cc-3-8-x-mcp/router/bin.js +claude mcp add cocos -- node /path/to/cocos-mcp-gateway/runtime/router/bin.js ``` -接入后 Claude Code 即可调用所有活跃编辑器的 tool,以及全局 offline prefab tools。 +客户端只注册全局 `cocos-mcp-gateway`。不要注册项目扩展的 `/bridge`;它不是 MCP endpoint。 + +Codex 使用全局 `cocos-mcp` plugin 的 `.mcp.json` 启动同一个 stdio Gateway。不要把某个项目的私有 Bridge endpoint 直接注册成全局 Codex MCP;它不是 MCP endpoint,而且会随编辑器进程和端口变化。 + +## Bridge 与 Gateway 安全边界 + +- MCP `initialize`、tools/resources 聚合和客户端兼容只存在于 Gateway。 +- 项目 Bridge 只监听 loopback,所有 `/bridge` POST 都要求进程级随机 bearer token。 +- Bridge 拒绝浏览器 `Origin`,只接受 `application/json`,默认 body 上限 4 MiB。 +- Gateway 只接受 API v2/v1 的 Editor Bridge,不兼容旧项目 `/mcp` Server;所有项目必须统一升级。 +- Gateway 为探活、普通 tool 和资源/刷新/场景类长操作使用分级超时。 --- @@ -212,7 +249,7 @@ claude mcp add cocos -- node /path/to/cc-3-8-x-mcp/router/bin.js echo "restart-package" > .dev/refresh ``` -面板上的「重启 MCP Server」按钮只重启 HTTP server 实例,Node require 缓存不动,**改不到 main.js 的代码改动**。`restart-package` 走 `Editor.Package.disable + enable`,整个扩展沙箱重建,所有 JS 重新 require。注意命令是 fire-and-forget,不返回结果;扩展重启过程中 MCP 连接会短暂中断(≈1-2s),重连由 router 自动发现完成。 +面板上的「重启 Editor Bridge」按钮只重启 Bridge 实例,Node require 缓存不动,**改不到 main.js 的代码改动**。`restart-package` 走 `Editor.Package.disable + enable`,整个扩展沙箱重建,所有 JS 重新 require。注意命令是 fire-and-forget,不返回结果;扩展重启过程中 Gateway 路由会短暂中断(≈1-2s),随后自动重新发现。 --- diff --git a/main.js b/main.js index e40962a..453784a 100644 --- a/main.js +++ b/main.js @@ -3,13 +3,14 @@ var fs = require('fs'); var path = require('path'); var { exec } = require('child_process'); +var localStatus = require('./server/local-status'); var DEV_DIR = '.dev'; // 缓存预览地址 var _previewUrl = ''; -// 定时刷新 dev-reload-info.json 的 interval handle -var _infoInterval = null; +// Gateway 注册心跳 interval handle;不再周期性重写 dev-reload-info.json +var _registryHeartbeatInterval = null; // `.dev/refresh` 命令文件 watcher(唯一保留的 watcher) var _refreshWatcher = null; @@ -35,7 +36,7 @@ function pushCommandLog(source, cmd) { * @param {string} previewUrl 已知的预览 URL(非空) */ function writeDevReloadInfo(previewUrl) { - if (!previewUrl) return; + if (!previewUrl) return false; var portMatch = previewUrl.match(/:(\d+)/); var previewPort = portMatch ? parseInt(portMatch[1], 10) : null; // 读取项目名(package.json name 字段) @@ -54,37 +55,47 @@ function writeDevReloadInfo(previewUrl) { editorVersion: (Editor.App && Editor.App.version) ? Editor.App.version : '', previewUrl: previewUrl, previewPort: previewPort, - updatedAt: new Date().toISOString(), }; try { var devDir = path.join(Editor.Project.path, DEV_DIR); if (!fs.existsSync(devDir)) { fs.mkdirSync(devDir, { recursive: true }); } + var infoPath = path.join(Editor.Project.path, INFO_FILE); + if (fs.existsSync(infoPath)) { + try { + var previous = JSON.parse(fs.readFileSync(infoPath, 'utf-8')); + if (localStatus.isSameDevReloadInfo(previous, info)) return false; + } catch (e) { /* 文件损坏时覆盖重写 */ } + } + info.updatedAt = new Date().toISOString(); fs.writeFileSync( - path.join(Editor.Project.path, INFO_FILE), + infoPath, JSON.stringify(info, null, 2), 'utf-8' ); log('dev-reload-info.json updated — port:' + (previewPort || 'null')); + return true; } catch (e) { console.warn('[dev-reload] writeDevReloadInfo failed:', e.message || e); + return false; } } -/** 启动 30s 定时刷新,保持 updatedAt 活跃供外部 stale 检测 */ -function startInfoInterval() { - if (_infoInterval) clearInterval(_infoInterval); - _infoInterval = setInterval(function () { - if (_previewUrl) writeDevReloadInfo(_previewUrl); - // 顺便刷 registry,让 router 判活 +/** 启动 30s Gateway 注册心跳;预览信息由启动/主动查询触发检查,仅实际变化时写入 */ +function startRegistryHeartbeat() { + if (_registryHeartbeatInterval) clearInterval(_registryHeartbeatInterval); + _registryHeartbeatInterval = setInterval(function () { if (typeof writeRegistry === 'function') writeRegistry(); }, 30000); } -/** 停止定时刷新 */ -function stopInfoInterval() { - if (_infoInterval) { clearInterval(_infoInterval); _infoInterval = null; } +/** 停止 Gateway 注册心跳 */ +function stopRegistryHeartbeat() { + if (_registryHeartbeatInterval) { + clearInterval(_registryHeartbeatInterval); + _registryHeartbeatInterval = null; + } } function log(msg) { @@ -166,31 +177,13 @@ exports.methods = { async openPanel() { await Editor.Panel.open('cc-3-8-x-mcp'); }, - async restartServer() { - await stopMcpServer(); - await startMcpServer(); - return { port: _mcpServer ? _mcpServer.port : null }; + async restartBridge() { + await stopEditorBridge(); + await startEditorBridge(); + return { port: _editorBridge ? _editorBridge.port : null }; }, - async getMcpConfig() { - if (!_mcpServer) return { running: false }; - var url = 'http://' + _mcpServer.host + ':' + _mcpServer.port + '/mcp'; - return { - running: _mcpServer.started, - url: url, - port: _mcpServer.port, - host: _mcpServer.host, - toolCount: _mcpServer.toolCount, - resourceCount: _mcpServer.resourceCount, - stats: _mcpServer.stats, - // Claude Code 的 mcp add 命令 - cliAddCommand: 'claude mcp add cocos --transport http ' + url, - // JSON 配置片段 - jsonConfig: { - mcpServers: { - cocos: { transport: 'http', url: url }, - }, - }, - }; + async getBridgeConfig() { + return buildBridgeConfig(); }, /** Panel 使用:刷新资源 + 重载场景 */ async triggerRefresh() { @@ -272,7 +265,6 @@ exports.methods = { if (!fs.existsSync(infoPath)) return; try { var info = JSON.parse(fs.readFileSync(infoPath, 'utf-8')); - var ageMs = Date.now() - new Date(info.updatedAt).getTime(); results.push({ projectPath: info.projectPath, projectName: info.projectName, @@ -280,7 +272,7 @@ exports.methods = { previewUrl: info.previewUrl, editorPid: info.editorPid, updatedAt: info.updatedAt, - staleSec: Math.floor(ageMs / 1000), + alive: localStatus.isProcessAlive(info.editorPid), self: info.projectPath === Editor.Project.path, }); } catch (e) { /* ignore */ } @@ -313,34 +305,54 @@ exports.methods = { previewPort: portMatch ? parseInt(portMatch[1], 10) : null, editorPid: process.pid, editorVersion: (Editor.App && Editor.App.version) ? Editor.App.version : '', + editorVersionRange: '>=3.8.0 <3.9.0', projectPath: Editor.Project.path, updatedAt: updatedAt, infoFile: INFO_FILE, watchers: { refresh: !!_refreshWatcher, - infoInterval: !!_infoInterval, + registryHeartbeat: !!_registryHeartbeatInterval, }, gitBranch: gitBranch, gitHead: gitHead, commandLog: _commandLog.slice().reverse(), - mcpServer: _mcpServer ? { - running: _mcpServer.started, - url: 'http://' + _mcpServer.host + ':' + _mcpServer.port + '/mcp', - port: _mcpServer.port, - toolCount: _mcpServer.toolCount, - resourceCount: _mcpServer.resourceCount, - stats: _mcpServer.stats, + editorBridge: _editorBridge ? { + running: _editorBridge.started, + url: 'http://' + _editorBridge.host + ':' + _editorBridge.port + '/bridge', + port: _editorBridge.port, + bridgeApiVersion: _editorBridge.bridgeApiVersion, + toolCount: _editorBridge.toolCount, + resourceCount: _editorBridge.resourceCount, + stats: _editorBridge.stats, } : { running: false }, }; } }; -// ── MCP Server ── +// ── Editor Bridge(仅供全局 cocos-mcp-gateway 调用,不是 MCP Server)── -var _mcpServer = null; -var MCP_DEFAULT_PORT = 7523; +var _editorBridge = null; +var BRIDGE_DEFAULT_PORT = 7523; +var GATEWAY_API_VERSION = 2; var REGISTRY_DIR = path.join(require('os').homedir(), '.cocos-mcp', 'editors'); -var SDK_PATH = path.join(__dirname, 'mcp-sdk', 'index.js'); + +function buildBridgeConfig() { + if (!_editorBridge) return { running: false, gatewayManaged: true }; + return { + running: _editorBridge.started, + gatewayManaged: true, + transport: 'editor-bridge', + bridgeApiVersion: _editorBridge.bridgeApiVersion, + url: 'http://' + _editorBridge.host + ':' + _editorBridge.port + '/bridge', + port: _editorBridge.port, + host: _editorBridge.host, + toolCount: _editorBridge.toolCount, + resourceCount: _editorBridge.resourceCount, + stats: _editorBridge.stats, + clientEntry: 'cocos-mcp-gateway', + note: '这是 Gateway 私有端点,不要直接注册为 MCP Server。', + }; +} /** * 计算项目短名(MCP 工具名前缀,需能区分不同项目)。 @@ -358,7 +370,7 @@ function getProjectShortName() { } /** - * 解析 Cocos 编辑器主进程可执行路径,写进注册文件供 router 的 editor_restart 拉起用。 + * 解析 Cocos 编辑器主进程可执行路径,写进注册文件供 Gateway 的 editor_restart 拉起用。 * 取 process.argv[0] / process.execPath(编辑器主进程可执行,跨平台)。 * 排除 Helper(渲染/GPU 子进程),解析不到返回空串。 */ @@ -376,22 +388,32 @@ function getEditorExecPath() { } function writeRegistry() { - if (!_mcpServer || !_mcpServer.started) return; + if (!_editorBridge || !_editorBridge.started) return; try { - if (!fs.existsSync(REGISTRY_DIR)) fs.mkdirSync(REGISTRY_DIR, { recursive: true }); + if (!fs.existsSync(REGISTRY_DIR)) fs.mkdirSync(REGISTRY_DIR, { recursive: true, mode: 0o700 }); + try { fs.chmodSync(REGISTRY_DIR, 0o700); } catch (e) { /* Windows / restricted FS */ } var entry = { pid: process.pid, projectPath: Editor.Project.path, projectShortName: getProjectShortName(), - host: _mcpServer.host, - port: _mcpServer.port, - url: 'http://' + _mcpServer.host + ':' + _mcpServer.port + '/mcp', + host: _editorBridge.host, + port: _editorBridge.port, + url: 'http://' + _editorBridge.host + ':' + _editorBridge.port + '/bridge', + transport: 'editor-bridge', editorVersion: (Editor.App && Editor.App.version) ? Editor.App.version : '', execPath: getEditorExecPath(), - startedAt: _mcpServer.stats.startedAt, + extensionVersion: require('./package.json').version, + gatewayApiVersion: GATEWAY_API_VERSION, + bridgeApiVersion: _editorBridge.bridgeApiVersion, + authToken: _editorBridge.authToken, + startedAt: _editorBridge.stats.startedAt, updatedAt: new Date().toISOString(), }; - fs.writeFileSync(path.join(REGISTRY_DIR, process.pid + '.json'), JSON.stringify(entry, null, 2), 'utf-8'); + var registryFile = path.join(REGISTRY_DIR, process.pid + '.json'); + var tempFile = registryFile + '.' + Date.now() + '.tmp'; + fs.writeFileSync(tempFile, JSON.stringify(entry, null, 2), { encoding: 'utf-8', mode: 0o600 }); + fs.renameSync(tempFile, registryFile); + try { fs.chmodSync(registryFile, 0o600); } catch (e) { /* Windows / restricted FS */ } } catch (e) { console.warn('[cc-mcp] writeRegistry failed:', e.message || e); } @@ -420,18 +442,17 @@ function findFreePort(startPort) { }); } -async function startMcpServer() { - if (_mcpServer && _mcpServer.started) return; - var port = await findFreePort(MCP_DEFAULT_PORT); - var sdk = require(SDK_PATH); +async function startEditorBridge() { + if (_editorBridge && _editorBridge.started) return; + var port = await findFreePort(BRIDGE_DEFAULT_PORT); + var authToken = require('crypto').randomBytes(32).toString('hex'); - // ── 使用 mcp-sdk ────────────────────────────────────────────── var tdef = require('./server/tools'); var ctx = buildToolCtx(); var toolDefs = tdef.defineTools(ctx); var resourceDefs = tdef.defineResources(ctx); - // 只计真实的工具调用 / 资源读取,不计 router 每 15s 的 initialize/tools-list 探活—— + // 只计真实的工具调用 / 资源读取,不计 Gateway 每 15s 的 bridge/describe 探活—— // 否则计数永远在涨,无法用于判断「业务指令到没到编辑器」。 // lastRequestAt/lastTool 是判活主信号(曾因 requestCount 初始化后无人自增、恒 0,被当成转发断裂误诊)。 var stats = { startedAt: new Date().toISOString(), requestCount: 0, lastRequestAt: null, lastTool: null }; @@ -444,10 +465,13 @@ async function startMcpServer() { }; } - var server = sdk.createServer({ - name: 'cc-3-8-x-mcp', - version: '2.0.0', + var bridgeModule = require('./server/editor-bridge'); + var bridge = bridgeModule.createEditorBridge({ + name: 'cocos-creator-3-8-x-editor-bridge', + version: require('./package.json').version, + host: '127.0.0.1', port: port, + authToken: authToken, tools: toolDefs.map(function (t) { return { name: t.name, @@ -467,23 +491,24 @@ async function startMcpServer() { }), }); - // 启动 HTTP(cc-3-8-x-mcp 只跑 HTTP,不跑 stdio) - await server.start('http'); - _mcpServer = { + await bridge.start(); + _editorBridge = { started: true, - host: '127.0.0.1', - port: port, + host: bridge.host, + port: bridge.port, + bridgeApiVersion: bridge.bridgeApiVersion, + authToken: authToken, toolCount: toolDefs.length, resourceCount: resourceDefs.length, stats: stats, - stop: function () { server.stop(); }, + stop: function () { return bridge.stop(); }, }; writeRegistry(); - log('MCP server up (SDK) — http://127.0.0.1:' + port + '/mcp (tools:' + toolDefs.length + ') shortName=' + getProjectShortName()); + log('Editor Bridge up — http://127.0.0.1:' + bridge.port + '/bridge (tools:' + toolDefs.length + ') shortName=' + getProjectShortName()); } /** - * 构建 tool/resource 的 ctx(共享给 SDK 和 fallback) + * 构建 tool/resource 的 ctx。 */ function buildToolCtx() { return { @@ -509,15 +534,13 @@ function buildToolCtx() { }; } -async function stopMcpServer() { - if (!_mcpServer) return; +async function stopEditorBridge() { + if (!_editorBridge) return; removeRegistry(); - if (_mcpServer.stop) { - _mcpServer.stop(); - } else { - try { await _mcpServer.stop(); } catch (e) { /* ignore */ } - } - _mcpServer = null; + try { + if (_editorBridge.stop) await _editorBridge.stop(); + } catch (e) { /* ignore */ } + _editorBridge = null; } // ── .dev/refresh 文件命令协议 ── @@ -565,7 +588,7 @@ function doRestartSelf(name) { }) .then(function (pkgPath) { return Promise.resolve(Editor.Package.disable(pkgPath)) - // 200ms 给 unload 钩子(stopRefreshWatcher / stopMcpServer)跑完 + // 200ms 给 unload 钩子(stopRefreshWatcher / stopEditorBridge)跑完 .then(function () { return new Promise(function (r) { setTimeout(r, 200); }); }) .then(function () { return Editor.Package.enable(pkgPath); }); }) @@ -642,23 +665,23 @@ exports.load = async function () { log('loaded'); // 启动 .dev/refresh 文件 watcher startRefreshWatcher(); - // 异步拿预览地址,写 dev-reload-info.json,启动定时刷新 + // 异步拿预览地址;仅实际变化时写 dev-reload-info.json,并启动 Gateway 注册心跳 getPreviewUrl().then(function(url) { if (url) writeDevReloadInfo(url); - startInfoInterval(); + startRegistryHeartbeat(); }).catch(function(e) { console.error('[dev-reload] load: getPreviewUrl failed —', e && (e.stack || e.message) || e); - startInfoInterval(); + startRegistryHeartbeat(); }); - // 启动 MCP server(失败打完整栈,不阻断扩展 load) - startMcpServer().catch(function(e) { - console.error('[cc-mcp] MCP server failed to start:', e && (e.stack || e.message) || e); + // 启动 Editor Bridge(失败打完整栈,不阻断扩展 load) + startEditorBridge().catch(function(e) { + console.error('[cc-mcp] Editor Bridge failed to start:', e && (e.stack || e.message) || e); }); }; exports.unload = async function () { stopRefreshWatcher(); - stopInfoInterval(); - await stopMcpServer(); + stopRegistryHeartbeat(); + await stopEditorBridge(); log('unloaded'); }; diff --git a/mcp-sdk b/mcp-sdk deleted file mode 160000 index 78332af..0000000 --- a/mcp-sdk +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 78332af0230cca26b79a75d554822086ce7e8e92 diff --git a/package.json b/package.json index 6c574bc..3d0818c 100644 --- a/package.json +++ b/package.json @@ -1,12 +1,12 @@ { "package_version": 2, - "version": "2.0.0", + "version": "3.0.0", "name": "cc-3-8-x-mcp", - "title": "Cocos Creator 3.8.x MCP Server", - "description": "把 Cocos Creator 3.8.x 编辑器能力(scene/asset-db/preview/local)以 MCP 协议暴露给外部 AI 客户端;兼容原 dev-reload 信号文件通道", + "title": "CC 3.8.x MCP Editor Bridge", + "description": "把 Cocos Creator 3.8.x 编辑器能力通过私有本机 Bridge 接入全局 cocos-mcp-gateway;兼容原 dev-reload 信号文件通道", "license": "Apache-2.0", "author": "付饶", - "editor": ">=3.8.0", + "editor": ">=3.8.0 <3.9.0", "main": "./main.js", "panels": { "default": { @@ -30,15 +30,15 @@ }, { "path": "i18n:menu.extension/Cocos MCP", - "label": "重启 MCP Server", - "message": "restart-server" + "label": "重启 Editor Bridge", + "message": "restart-bridge" } ], "messages": { "open-panel": { "methods": ["openPanel"] }, - "restart-server": { "methods": ["restartServer"] }, + "restart-bridge": { "methods": ["restartBridge"] }, + "get-bridge-config": { "methods": ["getBridgeConfig"] }, "get-status": { "methods": ["getStatus"] }, - "get-mcp-config": { "methods": ["getMcpConfig"] }, "refresh-assets": { "methods": ["refreshAssets"] }, "query-preview-url": { "methods": ["queryPreviewUrl"] }, "trigger-refresh": { "methods": ["triggerRefresh"] }, diff --git a/panel/index.js b/panel/index.js index 18a9d20..49274cd 100644 --- a/panel/index.js +++ b/panel/index.js @@ -1,19 +1,19 @@ 'use strict'; // Cocos MCP 功能面板 -// MCP 状态 / 编辑器状态 / 快捷动作 / Debug 注入 / 命令日志 / 同机 worktree +// Editor Bridge 状态 / 编辑器状态 / 快捷动作 / 命令日志 / 同机 worktree exports.template = /* html */ `
-
MCP Server
+
Editor Bridge
-
-
-
0
- 复制端点 - 复制 CLI 命令 + 复制诊断信息 + 复制客户端入口 重启
@@ -154,11 +154,11 @@ exports.methods = { const w = s.watchers || {}; this.$.watchers.textContent = (w.refresh ? '●refresh ' : '○refresh ') + - (w.infoInterval ? '●info' : '○info'); + (w.registryHeartbeat ? '●registry' : '○registry'); this.$.updatedAt.textContent = s.updatedAt ? s.updatedAt.replace('T', ' ').replace(/\..+$/, '') : '-'; - // MCP 区 - const mcp = s.mcpServer || {}; + // Editor Bridge 区 + const mcp = s.editorBridge || {}; if (mcp.running) { this.$.mcpDot.className = 'dot green'; this.$.mcpRunning.textContent = 'running'; @@ -210,9 +210,9 @@ exports.methods = { } this.$.worktreeList.innerHTML = list.map(w => { const name = (w.projectName || w.projectPath || '').split('/').slice(-2).join('/'); - const stale = w.staleSec > 90 ? ` ⚠${w.staleSec}s` : ''; + const offline = w.alive === false ? ' ⚠offline' : ''; const selfCls = w.self ? 'wt-row self' : 'wt-row'; - return `
${escapeHtml(name)}${w.self ? ' (本)' : ''}:${w.previewPort || '?'} pid${w.editorPid}${stale}
`; + return `
${escapeHtml(name)}${w.self ? ' (本)' : ''}:${w.previewPort || '?'} pid${w.editorPid}${offline}
`; }).join(''); } catch (e) { /* ignore */ } }, @@ -256,25 +256,25 @@ exports.methods = { }, async onCopyMcpUrl() { try { - const cfg = await Editor.Message.request('cc-3-8-x-mcp', 'get-mcp-config'); - if (!cfg || !cfg.url) { this.showToast('MCP 未运行'); return; } - await navigator.clipboard.writeText(cfg.url); - this.showToast('已复制: ' + cfg.url); + const cfg = await Editor.Message.request('cc-3-8-x-mcp', 'get-bridge-config'); + if (!cfg || !cfg.running) { this.showToast('Editor Bridge 未运行'); return; } + await navigator.clipboard.writeText(JSON.stringify(cfg, null, 2)); + this.showToast('已复制 Bridge 诊断信息(不含令牌)'); } catch (e) { this.showToast('失败: ' + (e.message || e)); } }, async onCopyCli() { try { - const cfg = await Editor.Message.request('cc-3-8-x-mcp', 'get-mcp-config'); - if (!cfg || !cfg.cliAddCommand) { this.showToast('MCP 未运行'); return; } - await navigator.clipboard.writeText(cfg.cliAddCommand); - this.showToast('已复制 CLI 命令'); + const cfg = await Editor.Message.request('cc-3-8-x-mcp', 'get-bridge-config'); + if (!cfg || !cfg.clientEntry) { this.showToast('Editor Bridge 未运行'); return; } + await navigator.clipboard.writeText(cfg.clientEntry); + this.showToast('客户端只需连接 cocos-mcp-gateway'); } catch (e) { this.showToast('失败: ' + (e.message || e)); } }, async onRestartMcp() { - this.showToast('重启 MCP…'); + this.showToast('重启 Bridge…'); try { - await Editor.Message.request('cc-3-8-x-mcp', 'restart-server'); - this.showToast('MCP 已重启'); + await Editor.Message.request('cc-3-8-x-mcp', 'restart-bridge'); + this.showToast('Editor Bridge 已重启'); this.refreshStatus(); } catch (e) { this.showToast('失败: ' + (e.message || e)); } }, diff --git a/router/bin.js b/router/bin.js index 183b1e3..ff6f67e 100755 --- a/router/bin.js +++ b/router/bin.js @@ -2,14 +2,14 @@ 'use strict'; /** - * cocos-mcp-router + * cocos-mcp-gateway * - * stdio MCP server,聚合所有活跃的 Cocos 编辑器扩展,给客户端暴露统一的 tool 列表。 + * 全局 stdio MCP Gateway,聚合所有活跃的 Cocos 编辑器扩展,给客户端暴露统一的 tool 列表。 * * 发现机制: * 扫 ~/.cocos-mcp/editors/*.json,每个文件代表一个活跃扩展实例 * 过滤 mtime > 120s 的(视为已死) - * 对每个活跃编辑器调 HTTP POST /mcp initialize + tools/list,拿到其 tool 清单 + * 对每个活跃编辑器调用私有 /bridge,读取 tool/resource 清单。 * * 命名: * tool 名前缀化:__ @@ -19,27 +19,34 @@ * tools/call 收到前缀名 → 拆出 projectShortName → 查 editor URL → HTTP 转发 * * 客户端接入: - * claude mcp add cocos -- node /path/to/forest/extensions/cc-3-8-x-mcp/router/bin.js + * claude mcp add cocos -- node /path/to/cocos-mcp-gateway/runtime/router/bin.js */ var fs = require('fs'); var path = require('path'); var os = require('os'); -var http = require('http'); var offlineTools = require('./src/offline-tools.js'); var editorControl = require('./src/editor-control.js'); +var rpc = require('./src/http-json-rpc.js'); var REGISTRY_DIR = path.join(os.homedir(), '.cocos-mcp', 'editors'); var STALE_MS = 120 * 1000; // 2 分钟没心跳视为死 var DISCOVERY_INTERVAL_MS = 15 * 1000; -var PROTOCOL_VERSION = '2024-11-05'; -var ROUTER_INFO = { name: 'cocos-mcp-router', version: '0.1.0' }; +var PROTOCOL_VERSION = '2025-06-18'; +var GATEWAY_INFO = { name: 'cocos-mcp-gateway', version: '0.3.0' }; + +try { + if (!fs.existsSync(REGISTRY_DIR)) fs.mkdirSync(REGISTRY_DIR, { recursive: true, mode: 0o700 }); + fs.chmodSync(REGISTRY_DIR, 0o700); +} catch (e) { + logErr('registry directory hardening failed:', e.message); +} function logErr() { // router 走 stdio,不能往 stdout 写非 JSON-RPC 内容,日志只能走 stderr var args = Array.prototype.slice.call(arguments); - process.stderr.write('[cocos-mcp-router] ' + args.join(' ') + '\n'); + process.stderr.write('[cocos-mcp-gateway] ' + args.join(' ') + '\n'); } // ── 发现活跃编辑器 ── @@ -65,7 +72,11 @@ function scanRegistry() { return; } var info = JSON.parse(fs.readFileSync(full, 'utf-8')); - if (!info || !info.url) return; + var invalidReason = rpc.validateRegistryEntry(info); + if (invalidReason) { + logErr('ignored registry entry', name, invalidReason); + return; + } entries.push(info); } catch (e) { /* ignore */ } }); @@ -73,64 +84,30 @@ function scanRegistry() { return entries; } -function httpJsonRpc(targetUrl, body) { - return new Promise(function (resolve, reject) { - try { - var u = new URL(targetUrl); - var data = JSON.stringify(body); - var req = http.request({ - hostname: u.hostname, - port: u.port, - path: u.pathname, - method: 'POST', - headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(data) }, - timeout: 8000, - }, function (res) { - var chunks = []; - res.on('data', function (c) { chunks.push(c); }); - res.on('end', function () { - var raw = Buffer.concat(chunks).toString('utf-8'); - try { resolve(JSON.parse(raw)); } - catch (e) { reject(new Error('invalid json from ' + targetUrl + ': ' + raw.slice(0, 120))); } - }); - }); - req.on('error', reject); - req.on('timeout', function () { req.destroy(new Error('timeout')); }); - req.write(data); - req.end(); - } catch (e) { reject(e); } - }); -} - async function probeEditor(info) { try { - var initRes = await httpJsonRpc(info.url, { - jsonrpc: '2.0', id: 1, method: 'initialize', params: { - protocolVersion: PROTOCOL_VERSION, - clientInfo: { name: 'cocos-mcp-router', version: ROUTER_INFO.version }, - capabilities: {}, - }, + var describeRes = await rpc.bridgeJsonRpc(info.url, { + jsonrpc: '2.0', id: 1, method: 'bridge/describe', params: {}, + }, { + authToken: info.authToken, + timeoutMs: rpc.PROBE_TIMEOUT_MS, }); - if (initRes.error) throw new Error(initRes.error.message); - var listRes = await httpJsonRpc(info.url, { jsonrpc: '2.0', id: 2, method: 'tools/list' }); - if (listRes.error) throw new Error(listRes.error.message); - return listRes.result.tools || []; + if (describeRes.error) throw new Error(describeRes.error.message); + var description = describeRes.result || {}; + if (description.bridgeApiVersion !== 1) { + throw new Error('unsupported bridge api version: ' + description.bridgeApiVersion); + } + return { + tools: description.tools || [], + resources: description.resources || [], + bridgeApiVersion: description.bridgeApiVersion, + }; } catch (e) { logErr('probe failed', info.projectShortName, info.url, e.message); return null; } } -async function probeEditorResources(info) { - try { - var listRes = await httpJsonRpc(info.url, { jsonrpc: '2.0', id: 3, method: 'resources/list' }); - if (listRes.error) throw new Error(listRes.error.message); - return listRes.result.resources || []; - } catch (e) { - return []; - } -} - /** 去掉 shortName 里的非法字符,MCP tool 名只允许 [a-zA-Z0-9_-] */ function sanitizeShortName(name) { return String(name || 'unknown').replace(/[^a-zA-Z0-9_-]/g, '_'); @@ -148,9 +125,10 @@ async function discover() { // 首次 probe 只拿到部分/空工具;之后 asset-db 就绪、业务工具全激活,必须重新 probe 刷新, // 否则 claude 永远看不到后激活的工具(如 forest__preview_refresh_and_reload)。 var existing = editors.get(key); - var tools = await probeEditor(info); - if (tools == null) continue; // probe 失败:保留旧缓存(editor 可能临时忙/重启中),不覆盖 - var resources = await probeEditorResources(info); + var description = await probeEditor(info); + if (description == null) continue; // probe 失败:保留旧缓存(editor 可能临时忙/重启中),不覆盖 + var tools = description.tools; + var resources = description.resources; editors.set(key, { baseShortName: sanitizeShortName(info.projectShortName), shortName: sanitizeShortName(info.projectShortName), // dedupeShortNames 会按冲突重设 @@ -158,6 +136,11 @@ async function discover() { pid: info.pid, startedAt: Date.parse(info.startedAt) || 0, // 同项目双实例时 dedupe 按它选代表 url: info.url, + authToken: info.authToken || '', + extensionVersion: info.extensionVersion || '', + gatewayApiVersion: info.gatewayApiVersion || 0, + transport: info.transport, + bridgeApiVersion: description.bridgeApiVersion || info.bridgeApiVersion || 0, tools: tools, resources: resources, lastProbed: Date.now(), @@ -264,8 +247,8 @@ function buildAggregatedToolList() { } // router 自身的 meta tool out.push({ - name: 'router_list_editors', - description: '列出当前 router 发现的所有活跃 Cocos 编辑器(shortName / pid / url / tool 数)', + name: 'gateway_list_editors', + description: '列出当前 Gateway 发现的所有活跃 Cocos 编辑器(实例、Bridge 版本、tool 数;不暴露 token)', inputSchema: { type: 'object', properties: {} }, }); // offline prefab tools(不需要编辑器运行) @@ -276,11 +259,11 @@ function buildAggregatedToolList() { } function encodeRouterResourceUri(ed, uri) { - return 'cocos-router://' + ed.shortName + '/' + encodeURIComponent(uri); + return 'cocos-gateway://' + ed.shortName + '/' + encodeURIComponent(uri); } function decodeRouterResourceUri(uri) { - var m = String(uri || '').match(/^cocos-router:\/\/([^\/]+)\/(.+)$/); + var m = String(uri || '').match(/^cocos-gateway:\/\/([^\/]+)\/(.+)$/); if (!m) return null; return { shortName: m[1], uri: decodeURIComponent(m[2]) }; } @@ -354,7 +337,7 @@ async function handleMessage(msg) { await discover(); result = { protocolVersion: PROTOCOL_VERSION, - serverInfo: ROUTER_INFO, + serverInfo: GATEWAY_INFO, capabilities: { tools: { listChanged: true }, resources: {}, @@ -398,10 +381,20 @@ async function handleMessage(msg) { async function handleToolCall(name, args) { // Router 自身的 meta tool - if (name === 'router_list_editors') { + if (name === 'gateway_list_editors') { await discover(); var list = Array.from(editors.values()).map(function (ed) { - return { shortName: ed.shortName, pid: ed.pid, url: ed.url, projectPath: ed.projectPath, toolCount: ed.tools.length }; + return { + shortName: ed.shortName, + pid: ed.pid, + url: ed.url, + projectPath: ed.projectPath, + extensionVersion: ed.extensionVersion, + gatewayApiVersion: ed.gatewayApiVersion, + transport: ed.transport, + bridgeApiVersion: ed.bridgeApiVersion, + toolCount: ed.tools.length, + }; }); return { content: [{ type: 'text', text: JSON.stringify(list, null, 2) }] }; } @@ -427,9 +420,12 @@ async function handleToolCall(name, args) { } try { - var forward = await httpJsonRpc(hit.editor.url, { - jsonrpc: '2.0', id: Date.now(), method: 'tools/call', + var forward = await rpc.bridgeJsonRpc(hit.editor.url, { + jsonrpc: '2.0', id: Date.now(), method: 'bridge/invoke', params: { name: hit.originalName, arguments: args }, + }, { + authToken: hit.editor.authToken, + timeoutMs: rpc.toolTimeoutMs(hit.originalName), }); if (forward.error) { return { content: [{ type: 'text', text: 'editor error: ' + forward.error.message }], isError: true }; @@ -454,9 +450,12 @@ async function handleResourceRead(uri) { return { contents: [{ type: 'text', text: 'editor not found for resource: ' + decoded.shortName, mimeType: 'text/plain' }] }; } try { - var forward = await httpJsonRpc(ed.url, { - jsonrpc: '2.0', id: Date.now(), method: 'resources/read', + var forward = await rpc.bridgeJsonRpc(ed.url, { + jsonrpc: '2.0', id: Date.now(), method: 'bridge/read-resource', params: { uri: decoded.uri }, + }, { + authToken: ed.authToken, + timeoutMs: rpc.TOOL_TIMEOUT_MS, }); if (forward.error) { return { contents: [{ type: 'text', text: 'editor error: ' + forward.error.message, mimeType: 'text/plain' }] }; @@ -472,4 +471,4 @@ setInterval(function () { discover().catch(function () {}); }, DISCOVERY_INTERVA // 启动首次发现 discover().catch(function (e) { logErr('initial discover failed', e.message); }); -logErr('cocos-mcp-router started (stdio)'); +logErr('cocos-mcp-gateway started (stdio)'); diff --git a/router/src/editor-control.js b/router/src/editor-control.js index 41f94b1..52f955e 100644 --- a/router/src/editor-control.js +++ b/router/src/editor-control.js @@ -5,8 +5,8 @@ * * Router 级编辑器进程管理 tool。spawn / kill / wait_ready / restart Cocos 编辑器进程。 * - * 为什么挂在 router(而不是编辑器内 server): - * 编辑器内的 MCP server 寄生在编辑器进程里,kill 编辑器 = kill server 自己,自杀后没法 + * 为什么挂在 Gateway(而不是编辑器内 Bridge): + * 编辑器内的 Bridge 寄生在编辑器进程里,kill 编辑器 = kill Bridge 自己,自杀后没法 * 再把自己拉起来。router 是进程外的常驻 stdio 进程,编辑器死了它还活着,所以「关 / 重启 / * 等就绪」这类要跨越编辑器进程生死的能力只能放这里,跟 offline prefab tools 同类,不走转发。 * @@ -21,13 +21,12 @@ var fs = require('fs'); var path = require('path'); var os = require('os'); -var http = require('http'); var cp = require('child_process'); var crypto = require('crypto'); +var bridgeRpc = require('./http-json-rpc.js'); var REGISTRY_DIR = path.join(os.homedir(), '.cocos-mcp', 'editors'); var STALE_MS = 120 * 1000; // 与 bin.js 对齐:2 分钟没心跳视为死 -var PROTOCOL_VERSION = '2024-11-05'; // ── 通用小工具 ────────────────────────────────────────────────── @@ -458,53 +457,34 @@ function spawnEditor(execPath, projectPath, opts) { // ── 就绪探测 ──────────────────────────────────────────────────── -/** 通用 HTTP MCP 调用,返回完整 JSON-RPC 响应(失败返回 null)。probeReady/probeProjectReady 共用。 */ -function httpMcp(url, method, params, timeoutMs) { - return new Promise(function (resolve) { - try { - var u = new URL(url); - var body = JSON.stringify({ jsonrpc: '2.0', id: 1, method: method, params: params || {} }); - var req = http.request({ - hostname: u.hostname, port: u.port, path: u.pathname, method: 'POST', - headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) }, - timeout: timeoutMs || 4000, - }, function (res) { - var chunks = []; - res.on('data', function (c) { chunks.push(c); }); - res.on('end', function () { - try { resolve(JSON.parse(Buffer.concat(chunks).toString('utf-8'))); } - catch (e) { resolve(null); } - }); - }); - req.on('error', function () { resolve(null); }); - req.on('timeout', function () { req.destroy(); resolve(null); }); - req.write(body); - req.end(); - } catch (e) { resolve(null); } - }); -} - -/** MCP initialize 探活:能 initialize = MCP server 起来了(但不代表进了项目,登录页态也能起)。 */ -function probeReady(url) { - return httpMcp(url, 'initialize', { - protocolVersion: PROTOCOL_VERSION, - clientInfo: { name: 'editor-control', version: '0' }, - capabilities: {}, - }).then(function (r) { return !!(r && !r.error); }); +/** Bridge ping 探活:能响应只代表扩展已启动,不代表 AssetDB 已就绪。 */ +function probeReady(entry) { + return bridgeRpc.bridgeJsonRpc(entry.url, { + jsonrpc: '2.0', id: 1, method: 'bridge/ping', params: {}, + }, { + authToken: entry.authToken, + timeoutMs: 4000, + }).then(function (r) { + return !!(r && !r.error && r.result && r.result.bridgeApiVersion === 1); + }).catch(function () { return false; }); } /** - * 项目就绪探测:MCP initialize 成功 ≠ 进了项目 —— 实测激进清登录态后 initialize 仍 ready, + * 项目就绪探测:Bridge ping 成功 ≠ 进了项目 —— 扩展已启动时项目仍可能在加载, * 但编辑器 UI 卡在登录页。用 asset_query_assets 查 db://assets/* 探 asset-db 是否就绪: * 项目真打开才加载 asset-db、返回非空顶层资源;登录页 / 项目加载中则空或失败。 * 正向(进项目非空)已实测;负向(登录页态返回啥)按逻辑推断,未在登录页态实测。 */ -function probeProjectReady(url) { - return httpMcp(url, 'tools/call', { - name: 'asset_query_assets', arguments: { pattern: 'db://assets/*' }, - }, 6000).then(function (r) { +function probeProjectReady(entry) { + return bridgeRpc.bridgeJsonRpc(entry.url, { + jsonrpc: '2.0', id: 2, method: 'bridge/invoke', + params: { name: 'asset_query_assets', arguments: { pattern: 'db://assets/*' } }, + }, { + authToken: entry.authToken, + timeoutMs: 6000, + }).then(function (r) { return hasReadyAssetResult(r); - }); + }).catch(function () { return false; }); } function hasReadyAssetResult(r) { @@ -537,7 +517,7 @@ function hasReadyAssetText(txt) { /** * 轮询等指定项目的编辑器就绪。 - * 就绪判定:注册表有 projectPath 匹配、非 stale、pid≠excludePid 的 entry,且 probeReady 成功。 + * 就绪判定:注册表有 projectPath 匹配、非 stale、pid≠excludePid 的 entry,且 Bridge 探活成功。 * excludePid:restart 时传被 kill 的旧 pid,避免匹配到尚未删净的旧注册。 * 返回 { ready, entry?, reason?, waitedMs }。 */ @@ -545,10 +525,10 @@ async function waitReady(projectPath, opts) { opts = opts || {}; var timeoutMs = opts.timeoutMs || 90000; // 大项目冷启动慢,默认 90s var excludePid = opts.excludePid || 0; - var requireProject = opts.requireProject !== false; // 默认要求项目就绪(区分登录页/加载中),传 false 退回只看 MCP + var requireProject = opts.requireProject !== false; // 默认要求项目就绪(区分登录页/加载中),传 false 只看 Bridge var start = Date.now(); var lastReason = 'still waiting'; - var sawMcp = false; + var sawBridge = false; while (Date.now() - start < timeoutMs) { var hit = activeEditors().filter(function (e) { @@ -556,13 +536,13 @@ async function waitReady(projectPath, opts) { })[0]; if (hit) { - var mcpOk = await probeReady(hit.url); - if (mcpOk) { - sawMcp = true; - var projOk = requireProject ? await probeProjectReady(hit.url) : true; + var bridgeOk = await probeReady(hit); + if (bridgeOk) { + sawBridge = true; + var projOk = requireProject ? await probeProjectReady(hit) : true; if (projOk) { return { - ready: true, mcpReady: true, projectReady: projOk, + ready: true, bridgeReady: true, projectReady: projOk, entry: { shortName: sanitize(hit.projectShortName), pid: hit.pid, url: hit.url, port: hit.port, @@ -571,18 +551,18 @@ async function waitReady(projectPath, opts) { waitedMs: Date.now() - start, }; } - lastReason = 'MCP up (pid=' + hit.pid + ') 但 asset-db 未就绪,可能卡在 Cocos Developer Login 或项目加载中'; + lastReason = 'Bridge up (pid=' + hit.pid + ') 但 asset-db 未就绪,可能卡在 Cocos Developer Login 或项目加载中'; } else { - lastReason = 'registered (pid=' + hit.pid + ') but MCP server not responding yet'; + lastReason = 'registered (pid=' + hit.pid + ') but Editor Bridge not responding yet'; } } else { lastReason = 'no fresh registry entry for project yet (editor still booting)'; } await sleep(1000); } - var res = { ready: false, mcpReady: sawMcp, projectReady: false, reason: lastReason, waitedMs: Date.now() - start }; - if (sawMcp) { - res.hint = '⚠️ MCP server 起来了但项目没就绪。若是 router 拉起/重启编辑器,请确认 spawnArgs 包含 --nologin;若是手动拉起,可能卡在 Cocos Developer Login 或仍在加载项目。'; + var res = { ready: false, bridgeReady: sawBridge, projectReady: false, reason: lastReason, waitedMs: Date.now() - start }; + if (sawBridge) { + res.hint = '⚠️ Editor Bridge 起来了但项目没就绪。若是 Gateway 拉起/重启编辑器,请确认 spawnArgs 包含 --nologin;若是手动拉起,可能卡在 Cocos Developer Login 或仍在加载项目。'; } return res; } @@ -619,7 +599,7 @@ var EDITOR_TOOLS = [ }, { name: 'editor_wait_ready', - description: '[editor] 等指定项目的 Cocos 编辑器就绪(注册文件出现且 MCP server 能 initialize)。' + + description: '[editor] 等指定项目的 Cocos 编辑器就绪(注册文件出现且 Editor Bridge 可探活)。' + '用于「拉起编辑器后等它起来再操作」。已就绪则立即返回。' + '编辑器尚未运行时必须传 projectPath(空注册表无法从 shortName 反推路径)。', inputSchema: { diff --git a/router/src/http-json-rpc.js b/router/src/http-json-rpc.js new file mode 100644 index 0000000..7cc5c4f --- /dev/null +++ b/router/src/http-json-rpc.js @@ -0,0 +1,128 @@ +'use strict'; + +const http = require('http'); + +const DEFAULT_PROBE_TIMEOUT_MS = 8000; +const DEFAULT_TOOL_TIMEOUT_MS = 60000; +const DEFAULT_LONG_TOOL_TIMEOUT_MS = 180000; + +function positiveEnvInt(name, fallback) { + const raw = process.env[name]; + if (!raw) return fallback; + const value = Number(raw); + return Number.isFinite(value) && value > 0 ? Math.floor(value) : fallback; +} + +const PROBE_TIMEOUT_MS = positiveEnvInt('COCOS_MCP_PROBE_TIMEOUT_MS', DEFAULT_PROBE_TIMEOUT_MS); +const TOOL_TIMEOUT_MS = positiveEnvInt('COCOS_MCP_TOOL_TIMEOUT_MS', DEFAULT_TOOL_TIMEOUT_MS); +const LONG_TOOL_TIMEOUT_MS = positiveEnvInt('COCOS_MCP_LONG_TOOL_TIMEOUT_MS', DEFAULT_LONG_TOOL_TIMEOUT_MS); + +function isLoopbackUrl(rawUrl, expectedPath) { + try { + const parsed = new URL(rawUrl); + const host = parsed.hostname.toLowerCase(); + const isLoopback = host === '127.0.0.1' || host === 'localhost' || host === '[::1]' || host === '::1'; + const port = Number(parsed.port); + return parsed.protocol === 'http:' + && isLoopback + && Number.isInteger(port) && port > 0 && port <= 65535 + && parsed.pathname === expectedPath + && !parsed.username && !parsed.password + && !parsed.search && !parsed.hash; + } catch (e) { + return false; + } +} + +function isLoopbackBridgeUrl(rawUrl) { + return isLoopbackUrl(rawUrl, '/bridge'); +} + +function isPidAlive(pid) { + if (!Number.isInteger(Number(pid)) || Number(pid) <= 0) return false; + try { + process.kill(Number(pid), 0); + return true; + } catch (e) { + return e && e.code === 'EPERM'; + } +} + +function validateRegistryEntry(info) { + if (!info || typeof info !== 'object') return 'registry entry must be an object'; + if (!isPidAlive(info.pid)) return 'editor pid is not alive'; + if (typeof info.projectPath !== 'string' || !info.projectPath) return 'projectPath is required'; + if (typeof info.projectShortName !== 'string' || !info.projectShortName) return 'projectShortName is required'; + if (info.transport !== 'editor-bridge') return 'transport must be editor-bridge'; + if (!isLoopbackBridgeUrl(info.url)) return 'editor-bridge endpoint must be http://loopback:/bridge'; + if (Number(info.gatewayApiVersion || 0) < 2) return 'editor-bridge requires gatewayApiVersion >= 2'; + if (Number(info.bridgeApiVersion || 0) !== 1) return 'unsupported bridgeApiVersion'; + if (!/^[0-9a-f]{64}$/i.test(info.authToken || '')) return 'editor-bridge authToken must be a 32-byte hex token'; + return null; +} + +function bridgeJsonRpc(targetUrl, body, options) { + options = options || {}; + const timeoutMs = options.timeoutMs || PROBE_TIMEOUT_MS; + + return new Promise(function (resolve, reject) { + try { + if (!isLoopbackBridgeUrl(targetUrl)) { + reject(new Error('refusing invalid loopback Editor Bridge endpoint')); + return; + } + const u = new URL(targetUrl); + const data = JSON.stringify(body); + const headers = { + 'Content-Type': 'application/json', + 'Accept': 'application/json', + 'Content-Length': Buffer.byteLength(data), + 'X-Cocos-Bridge-Version': '1', + }; + if (options.authToken) headers.Authorization = 'Bearer ' + options.authToken; + + const req = http.request({ + hostname: u.hostname, + port: u.port, + path: u.pathname, + method: 'POST', + headers, + timeout: timeoutMs, + }, function (res) { + const chunks = []; + res.on('data', function (c) { chunks.push(c); }); + res.on('end', function () { + const raw = Buffer.concat(chunks).toString('utf-8'); + if (res.statusCode < 200 || res.statusCode >= 300) { + reject(new Error('HTTP ' + res.statusCode + ' from Editor Bridge endpoint: ' + raw.slice(0, 160))); + return; + } + try { resolve(JSON.parse(raw)); } + catch (e) { reject(new Error('invalid json from ' + targetUrl + ': ' + raw.slice(0, 120))); } + }); + }); + req.on('error', reject); + req.on('timeout', function () { req.destroy(new Error('timeout after ' + timeoutMs + 'ms')); }); + req.write(data); + req.end(); + } catch (e) { reject(e); } + }); +} + +function toolTimeoutMs(name) { + return /(?:asset_refresh|asset_reimport|preview_refresh_and_reload|scene_open_scene|scene_save_scene)$/.test(name || '') + ? LONG_TOOL_TIMEOUT_MS + : TOOL_TIMEOUT_MS; +} + +module.exports = { + PROBE_TIMEOUT_MS, + TOOL_TIMEOUT_MS, + LONG_TOOL_TIMEOUT_MS, + isLoopbackUrl, + isLoopbackBridgeUrl, + isPidAlive, + validateRegistryEntry, + bridgeJsonRpc, + toolTimeoutMs, +}; diff --git a/router/test/gateway-bridge.integration.test.js b/router/test/gateway-bridge.integration.test.js new file mode 100644 index 0000000..d32d08d --- /dev/null +++ b/router/test/gateway-bridge.integration.test.js @@ -0,0 +1,123 @@ +'use strict'; + +const { test, before, after } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const readline = require('node:readline'); +const { spawn } = require('node:child_process'); + +const bridgeModule = require('../../server/editor-bridge.js'); + +let tempHome; +let bridge; +let gateway; +let nextId = 1; +const pending = new Map(); + +function callGateway(method, params) { + const id = nextId++; + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + pending.delete(id); + reject(new Error(`gateway response timeout: ${method}`)); + }, 5000); + pending.set(id, { resolve, reject, timer }); + gateway.stdin.write(JSON.stringify({ jsonrpc: '2.0', id, method, params: params || {} }) + '\n'); + }); +} + +before(async () => { + tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'cocos-mcp-gateway-test-')); + const token = 'f'.repeat(64); + bridge = bridgeModule.createEditorBridge({ + name: 'integration-bridge', + version: '3.0.0', + port: 0, + authToken: token, + tools: [{ + name: 'echo', + description: 'echo integration args', + inputSchema: { type: 'object' }, + handler: async (args) => ({ echoed: args }), + }], + resources: [{ + uri: 'cocos://integration', + name: 'integration resource', + mimeType: 'application/json', + read: async () => ({ integrated: true }), + }], + }); + await bridge.start(); + + const registryDir = path.join(tempHome, '.cocos-mcp', 'editors'); + fs.mkdirSync(registryDir, { recursive: true, mode: 0o700 }); + fs.writeFileSync(path.join(registryDir, `${process.pid}.json`), JSON.stringify({ + pid: process.pid, + projectPath: '/tmp/integration-project', + projectShortName: 'integration', + url: `http://127.0.0.1:${bridge.port}/bridge`, + transport: 'editor-bridge', + extensionVersion: '3.0.0', + gatewayApiVersion: 2, + bridgeApiVersion: 1, + authToken: token, + startedAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }, null, 2), { mode: 0o600 }); + + gateway = spawn(process.execPath, [path.join(__dirname, '..', 'bin.js')], { + env: Object.assign({}, process.env, { HOME: tempHome }), + stdio: ['pipe', 'pipe', 'pipe'], + }); + gateway.on('exit', (code) => { + for (const item of pending.values()) { + clearTimeout(item.timer); + item.reject(new Error(`gateway exited early: ${code}`)); + } + pending.clear(); + }); + const lines = readline.createInterface({ input: gateway.stdout }); + lines.on('line', (line) => { + let message; + try { message = JSON.parse(line); } catch (error) { return; } + const item = pending.get(message.id); + if (!item) return; + pending.delete(message.id); + clearTimeout(item.timer); + if (message.error) item.reject(new Error(message.error.message)); + else item.resolve(message.result); + }); +}); + +after(async () => { + if (gateway && gateway.exitCode == null) gateway.kill('SIGTERM'); + if (bridge) await bridge.stop(); + if (tempHome) fs.rmSync(tempHome, { recursive: true, force: true }); +}); + +test('Gateway 通过 Editor Bridge 聚合并转发 tool/resource', async () => { + const initialized = await callGateway('initialize', { + protocolVersion: '2025-06-18', + clientInfo: { name: 'integration-test', version: '1' }, + capabilities: {}, + }); + assert.equal(initialized.serverInfo.name, 'cocos-mcp-gateway'); + + const listed = await callGateway('tools/list'); + assert.equal(listed.tools.some((tool) => tool.name === 'integration__echo'), true); + + const called = await callGateway('tools/call', { + name: 'integration__echo', + arguments: { value: 9 }, + }); + assert.match(called.content[0].text, /"value": 9/); + + const resources = await callGateway('resources/list'); + const resource = resources.resources.find((item) => item.name.includes('integration resource')); + assert.ok(resource); + + const read = await callGateway('resources/read', { uri: resource.uri }); + assert.match(read.contents[0].text, /"integrated": true/); +}); diff --git a/router/test/http-json-rpc.test.js b/router/test/http-json-rpc.test.js new file mode 100644 index 0000000..bb0ed89 --- /dev/null +++ b/router/test/http-json-rpc.test.js @@ -0,0 +1,104 @@ +'use strict'; + +const { test, before, after } = require('node:test'); +const assert = require('node:assert/strict'); +const http = require('node:http'); +const { once } = require('node:events'); + +const rpc = require('../src/http-json-rpc.js'); + +let server; +let bridgeUrl; +let lastRequest; + +before(async () => { + server = http.createServer((req, res) => { + const chunks = []; + req.on('data', (chunk) => chunks.push(chunk)); + req.on('end', () => { + const body = JSON.parse(Buffer.concat(chunks).toString('utf8')); + lastRequest = { headers: req.headers, body }; + if (body.method === 'slow') { + setTimeout(() => { + if (!res.writableEnded) { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ jsonrpc: '2.0', id: body.id, result: {} })); + } + }, 100); + return; + } + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ jsonrpc: '2.0', id: body.id, result: { ok: true } })); + }); + }); + server.listen(0, '127.0.0.1'); + await once(server, 'listening'); + bridgeUrl = `http://127.0.0.1:${server.address().port}/bridge`; +}); + +after(async () => { + if (!server) return; + await new Promise((resolve) => server.close(resolve)); +}); + +test('Editor Bridge 只接受带显式端口的 loopback /bridge URL', () => { + assert.equal(rpc.isLoopbackBridgeUrl('http://127.0.0.1:7523/bridge'), true); + assert.equal(rpc.isLoopbackBridgeUrl('http://localhost:7523/bridge'), true); + assert.equal(rpc.isLoopbackBridgeUrl('http://127.0.0.1:7523/mcp'), false); + assert.equal(rpc.isLoopbackBridgeUrl('https://127.0.0.1:7523/bridge'), false); + assert.equal(rpc.isLoopbackBridgeUrl('http://192.168.1.2:7523/bridge'), false); +}); + +test('gateway v2 editor-bridge registry 必须声明 bridge 版本、loopback 地址和 token', () => { + const base = { + url: bridgeUrl, + transport: 'editor-bridge', + pid: process.pid, + projectPath: '/tmp/project', + projectShortName: 'project', + gatewayApiVersion: 2, + bridgeApiVersion: 1, + authToken: 'd'.repeat(64), + }; + assert.equal(rpc.validateRegistryEntry(base), null); + assert.match(rpc.validateRegistryEntry(Object.assign({}, base, { url: 'http://127.0.0.1:7523/mcp' })), /bridge/); + assert.match(rpc.validateRegistryEntry(Object.assign({}, base, { transport: 'mcp' })), /transport/); + assert.match(rpc.validateRegistryEntry(Object.assign({}, base, { bridgeApiVersion: 2 })), /bridgeApiVersion/); + assert.match(rpc.validateRegistryEntry(Object.assign({}, base, { gatewayApiVersion: 1 })), /gatewayApiVersion/); + assert.match(rpc.validateRegistryEntry(Object.assign({}, base, { authToken: '' })), /authToken/); + assert.match(rpc.validateRegistryEntry(Object.assign({}, base, { pid: 99999999 })), /not alive/); +}); + +test('Bridge 转发只携带私有 Bridge 版本和 Authorization,不伪装 MCP 下游', async () => { + const result = await rpc.bridgeJsonRpc(bridgeUrl, { + jsonrpc: '2.0', id: 11, method: 'bridge/invoke', + params: { name: 'scene_query_node', arguments: {} }, + }, { + authToken: 'e'.repeat(64), + timeoutMs: 1000, + }); + + assert.deepEqual(result.result, { ok: true }); + assert.equal(lastRequest.headers['x-cocos-bridge-version'], '1'); + assert.equal(lastRequest.headers['mcp-protocol-version'], undefined); + assert.equal(lastRequest.headers['mcp-method'], undefined); + assert.equal(lastRequest.headers.authorization, `Bearer ${'e'.repeat(64)}`); +}); + +test('拒绝非 loopback Bridge 目标,并对下游超时给出明确错误', async () => { + await assert.rejects( + rpc.bridgeJsonRpc('http://example.com:80/bridge', { jsonrpc: '2.0', id: 2, method: 'bridge/ping' }), + /invalid loopback/ + ); + await assert.rejects( + rpc.bridgeJsonRpc(bridgeUrl, { jsonrpc: '2.0', id: 3, method: 'slow' }, { timeoutMs: 20 }), + /timeout after 20ms/ + ); +}); + +test('资源刷新和场景保存类操作使用长超时', () => { + assert.equal(rpc.toolTimeoutMs('asset_refresh'), rpc.LONG_TOOL_TIMEOUT_MS); + assert.equal(rpc.toolTimeoutMs('preview_refresh_and_reload'), rpc.LONG_TOOL_TIMEOUT_MS); + assert.equal(rpc.toolTimeoutMs('scene_save_scene'), rpc.LONG_TOOL_TIMEOUT_MS); + assert.equal(rpc.toolTimeoutMs('scene_query_node'), rpc.TOOL_TIMEOUT_MS); +}); diff --git a/server/editor-bridge.js b/server/editor-bridge.js new file mode 100644 index 0000000..459c87e --- /dev/null +++ b/server/editor-bridge.js @@ -0,0 +1,283 @@ +'use strict'; + +/** + * Cocos Editor Bridge + * + * 这是 Gateway 与 Cocos 编辑器扩展之间的本机私有 JSON-RPC 协议,不是 MCP Server。 + * MCP 握手、工具聚合和客户端兼容全部由全局 cocos-mcp-gateway 负责。 + */ + +var http = require('http'); +var crypto = require('crypto'); + +var BRIDGE_API_VERSION = 1; +var DEFAULT_MAX_BODY_BYTES = 4 * 1024 * 1024; + +function textContent(text) { + return { type: 'text', text: String(text) }; +} + +function wrapContent(result) { + if (result === undefined || result === null) return [textContent('(ok)')]; + if (typeof result === 'string') return [textContent(result)]; + if (Array.isArray(result) && result[0] && result[0].type) return result; + if (result && result.type && result.text !== undefined) return [result]; + return [textContent(JSON.stringify(result, null, 2))]; +} + +function normalizeToolResult(result) { + if (result && typeof result === 'object' && !Array.isArray(result)) { + var out = {}; + if (result.content !== undefined) out.content = result.content; + if (result.structuredContent !== undefined) out.structuredContent = result.structuredContent; + if (result._meta !== undefined) out._meta = result._meta; + if (result.isError !== undefined) out.isError = result.isError; + if (Object.keys(out).length > 0) { + if (!out.content) out.content = wrapContent(out.structuredContent || '(ok)'); + return out; + } + } + return { content: wrapContent(result) }; +} + +function toolSchema(tool) { + var schema = { + name: tool.name, + description: tool.description || '', + inputSchema: tool.inputSchema || { type: 'object', properties: {} }, + }; + if (tool.outputSchema) schema.outputSchema = tool.outputSchema; + if (tool.annotations) schema.annotations = tool.annotations; + if (tool._meta) schema._meta = tool._meta; + return schema; +} + +function resourceSchema(resource) { + return { + uri: resource.uri, + name: resource.name || resource.uri, + description: resource.description || '', + mimeType: resource.mimeType || 'text/plain', + }; +} + +function safeTokenEquals(actual, token) { + var actualBuffer = Buffer.from(String(actual || '')); + var expectedBuffer = Buffer.from('Bearer ' + token); + return actualBuffer.length === expectedBuffer.length + && crypto.timingSafeEqual(actualBuffer, expectedBuffer); +} + +function createEditorBridge(options) { + options = options || {}; + var name = options.name || 'cocos-mcp-editor-bridge'; + var version = options.version || '1.0.0'; + var host = options.host || '127.0.0.1'; + var port = options.port === undefined ? 7523 : options.port; + var bridgePath = options.path || '/bridge'; + var authToken = options.authToken || ''; + var tools = options.tools || []; + var resources = options.resources || []; + var maxBodyBytes = Number.isFinite(options.maxBodyBytes) && options.maxBodyBytes > 0 + ? Math.floor(options.maxBodyBytes) + : DEFAULT_MAX_BODY_BYTES; + var server = null; + + function response(id, result) { + return { jsonrpc: '2.0', id: id, result: result }; + } + + function errorResponse(id, code, message) { + return { jsonrpc: '2.0', id: id === undefined ? null : id, error: { code: code, message: message } }; + } + + async function dispatch(message) { + if (!message || message.jsonrpc !== '2.0' || typeof message.method !== 'string') { + return errorResponse(message && message.id, -32600, 'Invalid Request'); + } + + var params = message.params || {}; + switch (message.method) { + case 'bridge/ping': + return response(message.id, { bridgeApiVersion: BRIDGE_API_VERSION }); + case 'bridge/describe': + return response(message.id, { + bridgeApiVersion: BRIDGE_API_VERSION, + serverInfo: { name: name, version: version }, + tools: tools.map(toolSchema), + resources: resources.map(resourceSchema), + }); + case 'bridge/invoke': { + if (!params.name) return errorResponse(message.id, -32602, 'Missing tool name'); + var tool = tools.find(function (item) { return item.name === params.name; }); + if (!tool) { + return response(message.id, { + content: [textContent('Tool not found: ' + params.name)], + isError: true, + }); + } + try { + var result = await tool.handler(params.arguments || {}); + return response(message.id, normalizeToolResult(result)); + } catch (e) { + return response(message.id, { + content: [textContent('[' + params.name + '] Error: ' + (e.message || e))], + isError: true, + }); + } + } + case 'bridge/read-resource': { + if (!params.uri) return errorResponse(message.id, -32602, 'Missing resource uri'); + var resource = resources.find(function (item) { return item.uri === params.uri; }); + if (!resource) { + return response(message.id, { + contents: [textContent('Resource not found: ' + params.uri)], + }); + } + try { + var data = await resource.read(); + var text = typeof data === 'string' ? data : JSON.stringify(data, null, 2); + return response(message.id, { + contents: [{ + type: 'text', + text: text, + mimeType: resource.mimeType || 'text/plain', + }], + }); + } catch (e) { + return response(message.id, { + contents: [textContent('[' + params.uri + '] Error: ' + (e.message || e))], + }); + } + } + default: + return errorResponse(message.id, -32601, 'Method not found: ' + message.method); + } + } + + function sendJson(res, statusCode, body) { + var data = JSON.stringify(body); + res.writeHead(statusCode, { + 'Content-Type': 'application/json', + 'Content-Length': Buffer.byteLength(data), + 'Cache-Control': 'no-store', + }); + res.end(data); + } + + function handle(req, res) { + var pathname; + try { pathname = new URL(req.url, 'http://127.0.0.1').pathname; } + catch (e) { pathname = ''; } + + if (pathname === '/' && req.method === 'GET') { + sendJson(res, 200, { + status: 'ok', + name: name, + version: version, + bridgeApiVersion: BRIDGE_API_VERSION, + }); + return; + } + if (pathname !== bridgePath) { + sendJson(res, 404, errorResponse(null, -32600, 'Not found')); + return; + } + if (req.headers.origin) { + sendJson(res, 403, errorResponse(null, -32600, 'Browser Origin is not allowed')); + return; + } + if (!authToken || !safeTokenEquals(req.headers.authorization, authToken)) { + sendJson(res, 401, errorResponse(null, -32001, 'Unauthorized')); + return; + } + if (req.method !== 'POST') { + sendJson(res, 405, errorResponse(null, -32600, 'Method not allowed, use POST')); + return; + } + var contentType = String(req.headers['content-type'] || '').toLowerCase(); + if (!contentType.startsWith('application/json')) { + sendJson(res, 415, errorResponse(null, -32600, 'Content-Type must be application/json')); + return; + } + + var chunks = []; + var bodyBytes = 0; + var bodyTooLarge = false; + req.on('data', function (chunk) { + bodyBytes += chunk.length; + if (bodyBytes > maxBodyBytes) { + bodyTooLarge = true; + return; + } + chunks.push(chunk); + }); + req.on('end', function () { + if (bodyTooLarge) { + sendJson(res, 413, errorResponse(null, -32600, 'Request body exceeds ' + maxBodyBytes + ' bytes')); + return; + } + var message; + try { message = JSON.parse(Buffer.concat(chunks).toString('utf-8')); } + catch (e) { + sendJson(res, 400, errorResponse(null, -32700, 'Parse error')); + return; + } + dispatch(message).then(function (result) { + sendJson(res, 200, result); + }).catch(function (e) { + sendJson(res, 500, errorResponse(message.id, -32603, 'Internal error: ' + (e.message || e))); + }); + }); + } + + function start() { + if (server) return Promise.resolve(api); + return new Promise(function (resolve, reject) { + var candidate = http.createServer(handle); + function onError(error) { + candidate.removeListener('listening', onListening); + reject(error); + } + function onListening() { + candidate.removeListener('error', onError); + candidate.on('error', function (error) { + console.error('[cocos-mcp-editor-bridge] HTTP server error:', error.message || error); + }); + server = candidate; + var address = server.address(); + if (address && typeof address === 'object') port = address.port; + resolve(api); + } + candidate.once('error', onError); + candidate.once('listening', onListening); + candidate.listen(port, host); + }); + } + + function stop() { + if (!server) return Promise.resolve(); + var closing = server; + server = null; + return new Promise(function (resolve) { + closing.close(function () { resolve(); }); + }); + } + + var api = { + start: start, + stop: stop, + dispatch: dispatch, + get host() { return host; }, + get port() { return port; }, + path: bridgePath, + bridgeApiVersion: BRIDGE_API_VERSION, + }; + return api; +} + +module.exports = { + BRIDGE_API_VERSION: BRIDGE_API_VERSION, + createEditorBridge: createEditorBridge, + normalizeToolResult: normalizeToolResult, +}; diff --git a/server/local-status.js b/server/local-status.js new file mode 100644 index 0000000..6281f44 --- /dev/null +++ b/server/local-status.js @@ -0,0 +1,33 @@ +'use strict'; + +var DEV_RELOAD_INFO_KEYS = [ + 'projectPath', + 'projectName', + 'editorPid', + 'editorVersion', + 'previewUrl', + 'previewPort', +]; + +function isSameDevReloadInfo(previous, next) { + if (!previous || !next) return false; + return DEV_RELOAD_INFO_KEYS.every(function (key) { + return previous[key] === next[key]; + }); +} + +function isProcessAlive(pid) { + var value = Number(pid); + if (!Number.isInteger(value) || value <= 0) return false; + try { + process.kill(value, 0); + return true; + } catch (e) { + return !!(e && e.code === 'EPERM'); + } +} + +module.exports = { + isSameDevReloadInfo: isSameDevReloadInfo, + isProcessAlive: isProcessAlive, +}; diff --git a/server/test/editor-bridge.test.js b/server/test/editor-bridge.test.js new file mode 100644 index 0000000..40f0d3b --- /dev/null +++ b/server/test/editor-bridge.test.js @@ -0,0 +1,119 @@ +'use strict'; + +const { test, before, after } = require('node:test'); +const assert = require('node:assert/strict'); +const http = require('node:http'); + +const bridgeModule = require('../editor-bridge.js'); + +let bridge; +let bridgeUrl; +const token = 'a'.repeat(64); + +function post(body, options) { + options = options || {}; + return new Promise((resolve, reject) => { + const url = new URL(bridgeUrl); + const data = options.rawBody === undefined ? JSON.stringify(body) : options.rawBody; + const headers = { + 'Content-Type': options.contentType || 'application/json', + 'Content-Length': Buffer.byteLength(data), + }; + if (options.authorized !== false) headers.Authorization = `Bearer ${token}`; + if (options.origin) headers.Origin = options.origin; + const req = http.request({ + hostname: url.hostname, + port: url.port, + path: url.pathname, + method: 'POST', + headers, + }, (res) => { + const chunks = []; + res.on('data', (chunk) => chunks.push(chunk)); + res.on('end', () => { + const raw = Buffer.concat(chunks).toString('utf8'); + resolve({ statusCode: res.statusCode, body: JSON.parse(raw) }); + }); + }); + req.on('error', reject); + req.end(data); + }); +} + +before(async () => { + bridge = bridgeModule.createEditorBridge({ + name: 'bridge-test', + version: '3.0.0', + port: 0, + authToken: token, + maxBodyBytes: 256, + tools: [ + { + name: 'echo', + description: 'echo args', + inputSchema: { type: 'object' }, + handler: async (args) => args, + }, + { + name: 'fail', + handler: async () => { throw new Error('expected failure'); }, + }, + ], + resources: [ + { + uri: 'cocos://test', + name: 'test resource', + mimeType: 'application/json', + read: async () => ({ ok: true }), + }, + ], + }); + await bridge.start(); + bridgeUrl = `http://127.0.0.1:${bridge.port}/bridge`; +}); + +after(async () => { + if (bridge) await bridge.stop(); +}); + +test('describe 一次返回 Bridge 版本、tools 和 resources', async () => { + const res = await post({ jsonrpc: '2.0', id: 1, method: 'bridge/describe', params: {} }); + assert.equal(res.statusCode, 200); + assert.equal(res.body.result.bridgeApiVersion, 1); + assert.equal(res.body.result.serverInfo.name, 'bridge-test'); + assert.deepEqual(res.body.result.tools.map((tool) => tool.name), ['echo', 'fail']); + assert.deepEqual(res.body.result.resources.map((resource) => resource.uri), ['cocos://test']); +}); + +test('invoke 保持原 MCP tool result 契约,并把异常变成 isError', async () => { + const ok = await post({ + jsonrpc: '2.0', id: 2, method: 'bridge/invoke', + params: { name: 'echo', arguments: { value: 7 } }, + }); + assert.equal(ok.body.result.isError, undefined); + assert.match(ok.body.result.content[0].text, /"value": 7/); + + const failed = await post({ + jsonrpc: '2.0', id: 3, method: 'bridge/invoke', + params: { name: 'fail', arguments: {} }, + }); + assert.equal(failed.body.result.isError, true); + assert.match(failed.body.result.content[0].text, /expected failure/); +}); + +test('read-resource 返回 Gateway 可直接转成 MCP 的 contents', async () => { + const res = await post({ + jsonrpc: '2.0', id: 4, method: 'bridge/read-resource', + params: { uri: 'cocos://test' }, + }); + assert.equal(res.body.result.contents[0].mimeType, 'application/json'); + assert.match(res.body.result.contents[0].text, /"ok": true/); +}); + +test('拒绝无 token、浏览器 Origin、错误 Content-Type 和超限 body', async () => { + const request = { jsonrpc: '2.0', id: 5, method: 'bridge/ping', params: {} }; + assert.equal((await post(request, { authorized: false })).statusCode, 401); + assert.equal((await post(request, { origin: 'http://example.com' })).statusCode, 403); + assert.equal((await post(request, { contentType: 'text/plain' })).statusCode, 415); + assert.equal((await post(null, { rawBody: JSON.stringify({ data: 'x'.repeat(300) }) })).statusCode, 413); +}); diff --git a/server/test/local-status.test.js b/server/test/local-status.test.js new file mode 100644 index 0000000..fcd1aa3 --- /dev/null +++ b/server/test/local-status.test.js @@ -0,0 +1,32 @@ +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert/strict'); +const localStatus = require('../local-status'); + +function makeInfo(overrides) { + return Object.assign({ + projectPath: '/project/forest', + projectName: 'forest', + editorPid: 123, + editorVersion: '3.8.8', + previewUrl: 'http://127.0.0.1:7456', + previewPort: 7456, + }, overrides || {}); +} + +test('dev-reload info 仅 updatedAt 变化时不重复写入', () => { + const previous = Object.assign(makeInfo(), { updatedAt: '2026-08-14T00:00:00.000Z' }); + assert.equal(localStatus.isSameDevReloadInfo(previous, makeInfo()), true); +}); + +test('预览端口或编辑器进程变化时需要更新 dev-reload info', () => { + assert.equal(localStatus.isSameDevReloadInfo(makeInfo(), makeInfo({ previewPort: 7457 })), false); + assert.equal(localStatus.isSameDevReloadInfo(makeInfo(), makeInfo({ editorPid: 456 })), false); +}); + +test('进程存活检测接受当前进程并拒绝非法 PID', () => { + assert.equal(localStatus.isProcessAlive(process.pid), true); + assert.equal(localStatus.isProcessAlive(0), false); + assert.equal(localStatus.isProcessAlive('invalid'), false); +});