[cc-3-8-x-mcp] 重构: 改为 Gateway 私有 Editor Bridge

改了什么:移除项目级 MCP Server 与 universal-mcp-sdk,新增带随机 token 的本机 Editor Bridge、Gateway v2 注册和分级超时转发。

为什么:MCP 协议与多项目路由统一交给全局 Gateway,项目扩展只保留 Cocos Editor.Message 和离线 CLI 职责。

影响范围:扩展启动与面板、编辑器注册、router 转发、安全边界、使用文档及自动化测试。
This commit is contained in:
furao
2026-08-14 12:10:09 +08:00
parent a3b016ea8e
commit ea364d3470
16 changed files with 1167 additions and 314 deletions
-3
View File
@@ -1,3 +0,0 @@
[submodule "mcp-sdk"]
path = mcp-sdk
url = https://gitee.com/Fu_Rao/universal-mcp-sdk.git
+21 -25
View File
@@ -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 暴露的 `<project>__<tool>`,不直接请求记录中的 `/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-port>/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-port>/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
<preview_url>/?tid=<unique-id>
@@ -112,7 +108,7 @@ curl -sS -X POST http://127.0.0.1:<mcp-port>/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. 工具行为异常或文档与实际不一致:反馈插件问题,由用户决定是否修插件本身。
+70 -33
View File
@@ -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/<pid>.json`(心跳注册)。
在 Cocos Creator 编辑器进程内运行。启动时拉起轻量 Editor Bridge,并把自身信息写入 `~/.cocos-mcp/editors/<pid>.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 加 `<shortName>__` 前缀,合并后暴露给 Claude Code
- 给每个编辑器的 tool 加 `<shortName>__` 前缀,合并后暴露给 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 名以 `<shortName>__` 为前缀隔离,同机多开不冲突。
注册目录权限为 `0700`,记录以原子写入方式更新且权限为 `0600`。Gateway 定期扫此目录,心跳超过 120s 的记录视为死亡自动剔除。tool 名以 `<shortName>__` 为前缀隔离,同机多开不冲突。
---
## 接入 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),随后自动重新发现
---
+117 -94
View File
@@ -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() {
}),
});
// 启动 HTTPcc-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');
};
Submodule mcp-sdk deleted from 78332af023
+8 -8
View File
@@ -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"] },
+20 -20
View File
@@ -1,19 +1,19 @@
'use strict';
// Cocos MCP 功能面板
// MCP 状态 / 编辑器状态 / 快捷动作 / Debug 注入 / 命令日志 / 同机 worktree
// Editor Bridge 状态 / 编辑器状态 / 快捷动作 / 命令日志 / 同机 worktree
exports.template = /* html */ `
<div class="wrap">
<section class="mcp">
<header>MCP Server <span id="mcpDot" class="dot gray"></span></header>
<header>Editor Bridge <span id="mcpDot" class="dot gray"></span></header>
<div class="row"><label>状态</label><span id="mcpRunning">-</span></div>
<div class="row"><label>端点</label><span id="mcpUrl">-</span></div>
<div class="row"><label>Tools</label><span id="mcpTools">-</span></div>
<div class="row"><label>请求数</label><span id="mcpReqCount">0</span></div>
<div class="mcp-actions">
<ui-button id="btnCopyMcpUrl">复制端点</ui-button>
<ui-button id="btnCopyCli">复制 CLI 命令</ui-button>
<ui-button id="btnCopyMcpUrl">复制诊断信息</ui-button>
<ui-button id="btnCopyCli">复制客户端入口</ui-button>
<ui-button id="btnRestartMcp" class="secondary">重启</ui-button>
</div>
</section>
@@ -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 `<div class="${selfCls}"><span class="name">${escapeHtml(name)}${w.self ? ' (本)' : ''}</span><span class="port">:${w.previewPort || '?'} pid${w.editorPid}${stale}</span></div>`;
return `<div class="${selfCls}"><span class="name">${escapeHtml(name)}${w.self ? ' (本)' : ''}</span><span class="port">:${w.previewPort || '?'} pid${w.editorPid}${offline}</span></div>`;
}).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)); }
},
+72 -73
View File
@@ -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 名前缀化:<projectShortName>__<originalName>
@@ -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)');
+37 -57
View File
@@ -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 探活成功。
* excludePidrestart 时传被 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: {
+128
View File
@@ -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:<port>/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,
};
@@ -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/);
});
+104
View File
@@ -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);
});
+283
View File
@@ -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,
};
+33
View File
@@ -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,
};
+119
View File
@@ -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);
});
+32
View File
@@ -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);
});