mirror of
https://github.com/HappyLifeOk/cc-3-8-x-mcp.git
synced 2026-08-20 13:37:13 +00:00
[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:
@@ -1,3 +0,0 @@
|
|||||||
[submodule "mcp-sdk"]
|
|
||||||
path = mcp-sdk
|
|
||||||
url = https://gitee.com/Fu_Rao/universal-mcp-sdk.git
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
# AGENTS.md
|
# 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` 域操作 |
|
| 全局 `cocos-mcp-gateway` 注入的项目 tool | `scene` / `asset-db` / `preview` / `local` 域操作 |
|
||||||
| 当前项目 HTTP MCP endpoint | 当前 agent 没注入 MCP tool 时的等价入口 |
|
|
||||||
| `cocos-mcp-cli` offline 命令 | `.prefab` / `.anim` 文件查询和修改 |
|
| `cocos-mcp-cli` offline 命令 | `.prefab` / `.anim` 文件查询和修改 |
|
||||||
| Playwright / Chrome | 浏览器里真实游戏页面的交互验证 |
|
| Playwright / Chrome | 浏览器里真实游戏页面的交互验证 |
|
||||||
| `.dev/refresh` 的 `restart-package` | 重启本扩展代码 |
|
| `.dev/refresh` 的 `restart-package` | 重启本扩展代码 |
|
||||||
|
|
||||||
其它入口不作为本插件使用路径。项目文档可以补充项目自己的只读兜底信息,但不能覆盖本文件的入口规则。
|
项目扩展的 `/bridge` 是 Gateway 私有协议,agent 和其他 MCP 客户端不得直接调用、不得直接读取或复制注册记录中的 token。其它入口不作为本插件使用路径。
|
||||||
|
|
||||||
## 绑定当前项目 MCP
|
## 绑定当前项目
|
||||||
|
|
||||||
同机可能同时打开多个 Cocos 项目。agent 必须先按项目根目录绑定 MCP 实例,再执行后续操作。
|
同机可能同时打开多个 Cocos 项目。agent 必须先按项目根目录绑定 MCP 实例,再执行后续操作。
|
||||||
|
|
||||||
@@ -32,7 +31,9 @@
|
|||||||
2. 只保留 `projectPath` 与当前项目根目录一致的记录。
|
2. 只保留 `projectPath` 与当前项目根目录一致的记录。
|
||||||
3. 校验 `pid` 仍存活。
|
3. 校验 `pid` 仍存活。
|
||||||
4. 多个匹配时取 `updatedAt` 最新的记录。
|
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)
|
data = json.load(f)
|
||||||
if os.path.realpath(data.get('projectPath', '')) != os.path.realpath(PROJECT):
|
if os.path.realpath(data.get('projectPath', '')) != os.path.realpath(PROJECT):
|
||||||
continue
|
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)
|
os.kill(int(data['pid']), 0)
|
||||||
items.append((data.get('updatedAt', ''), path, data))
|
items.append((data.get('updatedAt', ''), path, data))
|
||||||
except Exception:
|
except Exception:
|
||||||
@@ -69,9 +74,9 @@ for _, path, data in sorted(items)[-3:]:
|
|||||||
PY
|
PY
|
||||||
```
|
```
|
||||||
|
|
||||||
## Tool 与 HTTP MCP
|
## Gateway Tool
|
||||||
|
|
||||||
如果当前 agent 已注入 router 暴露的 MCP tool,使用带项目名前缀的 tool:
|
使用 Gateway 暴露的带项目名前缀 tool:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
forest__preview_query_url
|
forest__preview_query_url
|
||||||
@@ -79,16 +84,7 @@ forest__asset_reimport
|
|||||||
forest__preview_refresh_and_reload
|
forest__preview_refresh_and_reload
|
||||||
```
|
```
|
||||||
|
|
||||||
如果当前 agent 没注入这些 tool,按上节解析当前项目 MCP,再直接调用 HTTP endpoint。
|
如果当前 agent 没注入这些 tool,先修复或注册全局 `cocos-mcp-gateway`。禁止把项目 `/bridge` 当成 HTTP MCP fallback。
|
||||||
|
|
||||||
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":{}}}'
|
|
||||||
```
|
|
||||||
|
|
||||||
## 预览 URL
|
## 预览 URL
|
||||||
|
|
||||||
@@ -97,12 +93,12 @@ curl -sS -X POST http://127.0.0.1:<mcp-port>/mcp \
|
|||||||
| 来源 | 说明 |
|
| 来源 | 说明 |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `preview_query_url` | 首选 |
|
| `preview_query_url` | 首选 |
|
||||||
| `local_get_status` | 查预览端口、MCP endpoint、编辑器状态 |
|
| `local_get_status` | 查预览端口、Bridge 状态、编辑器状态 |
|
||||||
| 项目文档明确声明的只读兜底文件 | 仅在 MCP 不通时使用 |
|
| 项目文档明确声明的只读兜底文件 | 仅在 Gateway 不通时使用 |
|
||||||
|
|
||||||
没有项目文档声明时,不读取旧 `.dev/preview-url`,不猜 `localhost:7456`。
|
没有项目文档声明时,不读取旧 `.dev/preview-url`,不猜 `localhost:7456`。
|
||||||
|
|
||||||
浏览器验证必须在 MCP 返回的 URL 上追加唯一 `tid`:
|
浏览器验证必须在 Gateway tool 返回的 URL 上追加唯一 `tid`:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
<preview_url>/?tid=<unique-id>
|
<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 编辑器已打开当前项目,并且扩展已启用。
|
1. 没有匹配 `projectPath` 的注册文件:确认 Cocos 编辑器已打开当前项目,并且扩展已启用。
|
||||||
2. `pid` 不存活:忽略该注册文件,等编辑器重新注册。
|
2. `pid` 不存活:忽略该注册文件,等编辑器重新注册。
|
||||||
3. HTTP MCP endpoint 不通:用 `.dev/refresh` 的 `restart-package` 重启扩展;仍不通就重启编辑器。
|
3. Editor Bridge 不通:用 `.dev/refresh` 的 `restart-package` 重启扩展;仍不通就重启编辑器。
|
||||||
4. MCP 返回的预览 URL 为空:确认编辑器预览已启动。
|
4. Gateway tool 返回的预览 URL 为空:确认编辑器预览已启动。
|
||||||
5. 工具行为异常或文档与实际不一致:反馈插件问题,由用户决定是否修插件本身。
|
5. 工具行为异常或文档与实际不一致:反馈插件问题,由用户决定是否修插件本身。
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
# cc-3-8-x-mcp
|
# 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 |
|
| [`QUICK-REF.md`](./QUICK-REF.md) | **一页速查表**:节点定位三式、场景 → op 对照、ops.json 速记、踩坑表 | agent 起手第一份,挑不到再翻 cli.md |
|
||||||
| [`doc/cli.md`](./doc/cli.md) | **CLI 完整手册**:命令、26 个 op 全表、配方、已知坑、源码导航 | 改 `.prefab` / `.anim` 文件前必读 |
|
| [`doc/cli.md`](./doc/cli.md) | **CLI 完整手册**:命令、26 个 op 全表、配方、已知坑、源码导航 | 改 `.prefab` / `.anim` 文件前必读 |
|
||||||
| [`doc/prefab-schema.md`](./doc/prefab-schema.md) | CC3 prefab JSON 结构速查(节点 / 组件 / 引用字段格式) | 看不懂 prefab 字段时查 |
|
| [`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)
|
│ stdio (JSON-RPC)
|
||||||
▼
|
▼
|
||||||
┌─────────────────────────────┐
|
┌─────────────────────────────┐
|
||||||
│ router/bin.js │ ← 统一入口,聚合多个编辑器
|
│ 全局 cocos-mcp-gateway │ ← 唯一 MCP Server / Codex 注册入口
|
||||||
|
│ runtime/router/bin.js │ ← 聚合多个编辑器
|
||||||
│ - 扫 ~/.cocos-mcp/editors/ │
|
│ - 扫 ~/.cocos-mcp/editors/ │
|
||||||
│ - offline prefab tools │
|
│ - offline prefab tools │
|
||||||
└───────────┬─────────────────┘
|
└───────────┬─────────────────┘
|
||||||
│ HTTP MCP (JSON-RPC)
|
│ 私有 loopback JSON-RPC /bridge + Bearer token
|
||||||
┌───────┴────────┐
|
┌───────┴────────┐
|
||||||
│ │
|
│ │
|
||||||
▼ ▼
|
▼ ▼
|
||||||
编辑器实例 A 编辑器实例 B ← 每个项目一个编辑器进程
|
编辑器实例 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 域**:
|
**暴露的 tool 域**:
|
||||||
|
|
||||||
@@ -58,23 +83,26 @@ offline prefab tools(`prefab_query` / `prefab_edit` / `prefab_batch`)在 rou
|
|||||||
| preview | 预览地址查询、浏览器控制、截图、JS 注入 |
|
| preview | 预览地址查询、浏览器控制、截图、JS 注入 |
|
||||||
| local | 本地状态、worktree 列表、.dev 目录管理 |
|
| local | 本地状态、worktree 列表、.dev 目录管理 |
|
||||||
|
|
||||||
### 2. stdio router(`router/`)
|
### 2. 全局 MCP Gateway
|
||||||
|
|
||||||
入口:`router/bin.js`
|
源码与入口位于独立仓库 `cocos-mcp-gateway/runtime/router/bin.js`。
|
||||||
|
|
||||||
**职责**:
|
**职责**:
|
||||||
|
|
||||||
- 扫描 `~/.cocos-mcp/editors/` 发现活跃编辑器(心跳超 120s 视为已死)
|
- 扫描 `~/.cocos-mcp/editors/` 发现活跃编辑器(心跳超 120s 视为已死)
|
||||||
|
- 只接受 `transport=editor-bridge`、Gateway API v2、Bridge API v1 的注册记录
|
||||||
|
- 校验 PID、loopback `/bridge` endpoint 和随机 token;拒绝非本机地址
|
||||||
|
- 从权限为 `0600` 的注册记录读取每个实例的随机 bearer token,不把 token 暴露在 tool 列表中
|
||||||
- 每隔 15s 自动发现新实例
|
- 每隔 15s 自动发现新实例
|
||||||
- 给每个编辑器的 tool 加 `<shortName>__` 前缀,合并后暴露给 Claude Code
|
- 给每个编辑器的 tool 加 `<shortName>__` 前缀,合并后暴露给 MCP 客户端
|
||||||
- 内置 offline prefab tools(`prefab_query` / `prefab_edit` / `prefab_batch`),不带前缀,全局可用
|
- 内置 offline prefab tools(`prefab_query` / `prefab_edit` / `prefab_batch`),不带前缀,全局可用
|
||||||
|
|
||||||
**tool 路由示例**:
|
**tool 路由示例**:
|
||||||
|
|
||||||
```
|
```
|
||||||
forest__scene_query_node_tree → forest 编辑器的 HTTP MCP server
|
forest__scene_query_node_tree → forest 编辑器的 Editor Bridge
|
||||||
another__asset_query_assets → another 编辑器的 HTTP MCP server
|
another__asset_query_assets → another 编辑器的 Editor Bridge
|
||||||
prefab_query → router 本地执行(cli),无需编辑器
|
prefab_query → Gateway 本地执行(cli),无需编辑器
|
||||||
```
|
```
|
||||||
|
|
||||||
### 3. cocos-mcp-cli(`cli/`)
|
### 3. cocos-mcp-cli(`cli/`)
|
||||||
@@ -89,7 +117,7 @@ prefab_query → router 本地执行(cli),无需编辑
|
|||||||
|
|
||||||
| 区块 | 内容 |
|
| 区块 | 内容 |
|
||||||
|---|---|
|
|---|---|
|
||||||
| MCP Server | 运行状态指示灯 / 端点地址 / tool 数量 / 请求计数;复制端点、复制 CLI 命令、重启 |
|
| Editor Bridge | 运行状态指示灯 / 私有端点 / tool 数量 / 请求计数;复制不含令牌的诊断信息、查看客户端入口、重启 |
|
||||||
| 编辑器状态 | 当前分支 / HEAD / 预览地址和端口 / 编辑器 PID / Watcher 状态 / 最后更新时间 |
|
| 编辑器状态 | 当前分支 / HEAD / 预览地址和端口 / 编辑器 PID / Watcher 状态 / 最后更新时间 |
|
||||||
| 快捷动作 | 一键刷新(资源+场景+预览)/ 软重载场景 / 打开预览浏览器 / 截图 / 打开 .dev 目录 / 清理临时文件 / 手动输入路径重新导入 |
|
| 快捷动作 | 一键刷新(资源+场景+预览)/ 软重载场景 / 打开预览浏览器 / 截图 / 打开 .dev 目录 / 清理临时文件 / 手动输入路径重新导入 |
|
||||||
| Debug 注入 | 在预览页面执行任意 JS(`eval_js`),结果直接展示;自定义快捷按钮(配置见下方) |
|
| Debug 注入 | 在预览页面执行任意 JS(`eval_js`),结果直接展示;自定义快捷按钮(配置见下方) |
|
||||||
@@ -155,7 +183,7 @@ prefab_query → router 本地执行(cli),无需编辑
|
|||||||
| `local_open_dev_dir` | 在 Finder 中打开 .dev 目录 |
|
| `local_open_dev_dir` | 在 Finder 中打开 .dev 目录 |
|
||||||
| `local_clean_dev_dir` | 清理 .dev 临时文件 |
|
| `local_clean_dev_dir` | 清理 .dev 临时文件 |
|
||||||
|
|
||||||
### offline 域(router 级,无需编辑器运行)
|
### offline 域(Gateway 级,无需编辑器运行)
|
||||||
|
|
||||||
| Tool | 说明 |
|
| Tool | 说明 |
|
||||||
|---|---|
|
|---|---|
|
||||||
@@ -165,7 +193,7 @@ prefab_query → router 本地执行(cli),无需编辑
|
|||||||
|
|
||||||
完整 op 列表与配方见 [`doc/cli.md`](./doc/cli.md)。
|
完整 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
|
```json
|
||||||
{
|
{
|
||||||
"pid": 12345,
|
"pid": 12345,
|
||||||
"url": "http://127.0.0.1:7788/mcp",
|
"url": "http://127.0.0.1:7788/bridge",
|
||||||
"shortName": "forest",
|
"transport": "editor-bridge",
|
||||||
"projectPath": "/path/to/project"
|
"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
|
## 接入 MCP 客户端
|
||||||
|
|
||||||
> ⚠️ 本仓库通过 git submodule 依赖 [universal-mcp-sdk](https://github.com/HappyLifeOk/universal-mcp-sdk),clone 后**必须先拉 submodule**,否则 MCP server 起不来:
|
|
||||||
>
|
|
||||||
> ```bash
|
|
||||||
> git submodule update --init --recursive
|
|
||||||
> ```
|
|
||||||
|
|
||||||
```bash
|
```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
|
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),随后自动重新发现。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -3,13 +3,14 @@
|
|||||||
var fs = require('fs');
|
var fs = require('fs');
|
||||||
var path = require('path');
|
var path = require('path');
|
||||||
var { exec } = require('child_process');
|
var { exec } = require('child_process');
|
||||||
|
var localStatus = require('./server/local-status');
|
||||||
|
|
||||||
var DEV_DIR = '.dev';
|
var DEV_DIR = '.dev';
|
||||||
|
|
||||||
// 缓存预览地址
|
// 缓存预览地址
|
||||||
var _previewUrl = '';
|
var _previewUrl = '';
|
||||||
// 定时刷新 dev-reload-info.json 的 interval handle
|
// Gateway 注册心跳 interval handle;不再周期性重写 dev-reload-info.json
|
||||||
var _infoInterval = null;
|
var _registryHeartbeatInterval = null;
|
||||||
// `.dev/refresh` 命令文件 watcher(唯一保留的 watcher)
|
// `.dev/refresh` 命令文件 watcher(唯一保留的 watcher)
|
||||||
var _refreshWatcher = null;
|
var _refreshWatcher = null;
|
||||||
|
|
||||||
@@ -35,7 +36,7 @@ function pushCommandLog(source, cmd) {
|
|||||||
* @param {string} previewUrl 已知的预览 URL(非空)
|
* @param {string} previewUrl 已知的预览 URL(非空)
|
||||||
*/
|
*/
|
||||||
function writeDevReloadInfo(previewUrl) {
|
function writeDevReloadInfo(previewUrl) {
|
||||||
if (!previewUrl) return;
|
if (!previewUrl) return false;
|
||||||
var portMatch = previewUrl.match(/:(\d+)/);
|
var portMatch = previewUrl.match(/:(\d+)/);
|
||||||
var previewPort = portMatch ? parseInt(portMatch[1], 10) : null;
|
var previewPort = portMatch ? parseInt(portMatch[1], 10) : null;
|
||||||
// 读取项目名(package.json name 字段)
|
// 读取项目名(package.json name 字段)
|
||||||
@@ -54,37 +55,47 @@ function writeDevReloadInfo(previewUrl) {
|
|||||||
editorVersion: (Editor.App && Editor.App.version) ? Editor.App.version : '',
|
editorVersion: (Editor.App && Editor.App.version) ? Editor.App.version : '',
|
||||||
previewUrl: previewUrl,
|
previewUrl: previewUrl,
|
||||||
previewPort: previewPort,
|
previewPort: previewPort,
|
||||||
updatedAt: new Date().toISOString(),
|
|
||||||
};
|
};
|
||||||
try {
|
try {
|
||||||
var devDir = path.join(Editor.Project.path, DEV_DIR);
|
var devDir = path.join(Editor.Project.path, DEV_DIR);
|
||||||
if (!fs.existsSync(devDir)) {
|
if (!fs.existsSync(devDir)) {
|
||||||
fs.mkdirSync(devDir, { recursive: true });
|
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(
|
fs.writeFileSync(
|
||||||
path.join(Editor.Project.path, INFO_FILE),
|
infoPath,
|
||||||
JSON.stringify(info, null, 2),
|
JSON.stringify(info, null, 2),
|
||||||
'utf-8'
|
'utf-8'
|
||||||
);
|
);
|
||||||
log('dev-reload-info.json updated — port:' + (previewPort || 'null'));
|
log('dev-reload-info.json updated — port:' + (previewPort || 'null'));
|
||||||
|
return true;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn('[dev-reload] writeDevReloadInfo failed:', e.message || e);
|
console.warn('[dev-reload] writeDevReloadInfo failed:', e.message || e);
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 启动 30s 定时刷新,保持 updatedAt 活跃供外部 stale 检测 */
|
/** 启动 30s Gateway 注册心跳;预览信息由启动/主动查询触发检查,仅实际变化时写入 */
|
||||||
function startInfoInterval() {
|
function startRegistryHeartbeat() {
|
||||||
if (_infoInterval) clearInterval(_infoInterval);
|
if (_registryHeartbeatInterval) clearInterval(_registryHeartbeatInterval);
|
||||||
_infoInterval = setInterval(function () {
|
_registryHeartbeatInterval = setInterval(function () {
|
||||||
if (_previewUrl) writeDevReloadInfo(_previewUrl);
|
|
||||||
// 顺便刷 registry,让 router 判活
|
|
||||||
if (typeof writeRegistry === 'function') writeRegistry();
|
if (typeof writeRegistry === 'function') writeRegistry();
|
||||||
}, 30000);
|
}, 30000);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 停止定时刷新 */
|
/** 停止 Gateway 注册心跳 */
|
||||||
function stopInfoInterval() {
|
function stopRegistryHeartbeat() {
|
||||||
if (_infoInterval) { clearInterval(_infoInterval); _infoInterval = null; }
|
if (_registryHeartbeatInterval) {
|
||||||
|
clearInterval(_registryHeartbeatInterval);
|
||||||
|
_registryHeartbeatInterval = null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function log(msg) {
|
function log(msg) {
|
||||||
@@ -166,31 +177,13 @@ exports.methods = {
|
|||||||
async openPanel() {
|
async openPanel() {
|
||||||
await Editor.Panel.open('cc-3-8-x-mcp');
|
await Editor.Panel.open('cc-3-8-x-mcp');
|
||||||
},
|
},
|
||||||
async restartServer() {
|
async restartBridge() {
|
||||||
await stopMcpServer();
|
await stopEditorBridge();
|
||||||
await startMcpServer();
|
await startEditorBridge();
|
||||||
return { port: _mcpServer ? _mcpServer.port : null };
|
return { port: _editorBridge ? _editorBridge.port : null };
|
||||||
},
|
},
|
||||||
async getMcpConfig() {
|
async getBridgeConfig() {
|
||||||
if (!_mcpServer) return { running: false };
|
return buildBridgeConfig();
|
||||||
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 },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
},
|
},
|
||||||
/** Panel 使用:刷新资源 + 重载场景 */
|
/** Panel 使用:刷新资源 + 重载场景 */
|
||||||
async triggerRefresh() {
|
async triggerRefresh() {
|
||||||
@@ -272,7 +265,6 @@ exports.methods = {
|
|||||||
if (!fs.existsSync(infoPath)) return;
|
if (!fs.existsSync(infoPath)) return;
|
||||||
try {
|
try {
|
||||||
var info = JSON.parse(fs.readFileSync(infoPath, 'utf-8'));
|
var info = JSON.parse(fs.readFileSync(infoPath, 'utf-8'));
|
||||||
var ageMs = Date.now() - new Date(info.updatedAt).getTime();
|
|
||||||
results.push({
|
results.push({
|
||||||
projectPath: info.projectPath,
|
projectPath: info.projectPath,
|
||||||
projectName: info.projectName,
|
projectName: info.projectName,
|
||||||
@@ -280,7 +272,7 @@ exports.methods = {
|
|||||||
previewUrl: info.previewUrl,
|
previewUrl: info.previewUrl,
|
||||||
editorPid: info.editorPid,
|
editorPid: info.editorPid,
|
||||||
updatedAt: info.updatedAt,
|
updatedAt: info.updatedAt,
|
||||||
staleSec: Math.floor(ageMs / 1000),
|
alive: localStatus.isProcessAlive(info.editorPid),
|
||||||
self: info.projectPath === Editor.Project.path,
|
self: info.projectPath === Editor.Project.path,
|
||||||
});
|
});
|
||||||
} catch (e) { /* ignore */ }
|
} catch (e) { /* ignore */ }
|
||||||
@@ -313,34 +305,54 @@ exports.methods = {
|
|||||||
previewPort: portMatch ? parseInt(portMatch[1], 10) : null,
|
previewPort: portMatch ? parseInt(portMatch[1], 10) : null,
|
||||||
editorPid: process.pid,
|
editorPid: process.pid,
|
||||||
editorVersion: (Editor.App && Editor.App.version) ? Editor.App.version : '',
|
editorVersion: (Editor.App && Editor.App.version) ? Editor.App.version : '',
|
||||||
|
editorVersionRange: '>=3.8.0 <3.9.0',
|
||||||
projectPath: Editor.Project.path,
|
projectPath: Editor.Project.path,
|
||||||
updatedAt: updatedAt,
|
updatedAt: updatedAt,
|
||||||
infoFile: INFO_FILE,
|
infoFile: INFO_FILE,
|
||||||
watchers: {
|
watchers: {
|
||||||
refresh: !!_refreshWatcher,
|
refresh: !!_refreshWatcher,
|
||||||
infoInterval: !!_infoInterval,
|
registryHeartbeat: !!_registryHeartbeatInterval,
|
||||||
},
|
},
|
||||||
gitBranch: gitBranch,
|
gitBranch: gitBranch,
|
||||||
gitHead: gitHead,
|
gitHead: gitHead,
|
||||||
commandLog: _commandLog.slice().reverse(),
|
commandLog: _commandLog.slice().reverse(),
|
||||||
mcpServer: _mcpServer ? {
|
editorBridge: _editorBridge ? {
|
||||||
running: _mcpServer.started,
|
running: _editorBridge.started,
|
||||||
url: 'http://' + _mcpServer.host + ':' + _mcpServer.port + '/mcp',
|
url: 'http://' + _editorBridge.host + ':' + _editorBridge.port + '/bridge',
|
||||||
port: _mcpServer.port,
|
port: _editorBridge.port,
|
||||||
toolCount: _mcpServer.toolCount,
|
bridgeApiVersion: _editorBridge.bridgeApiVersion,
|
||||||
resourceCount: _mcpServer.resourceCount,
|
toolCount: _editorBridge.toolCount,
|
||||||
stats: _mcpServer.stats,
|
resourceCount: _editorBridge.resourceCount,
|
||||||
|
stats: _editorBridge.stats,
|
||||||
} : { running: false },
|
} : { running: false },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// ── MCP Server ──
|
// ── Editor Bridge(仅供全局 cocos-mcp-gateway 调用,不是 MCP Server)──
|
||||||
|
|
||||||
var _mcpServer = null;
|
var _editorBridge = null;
|
||||||
var MCP_DEFAULT_PORT = 7523;
|
var BRIDGE_DEFAULT_PORT = 7523;
|
||||||
|
var GATEWAY_API_VERSION = 2;
|
||||||
var REGISTRY_DIR = path.join(require('os').homedir(), '.cocos-mcp', 'editors');
|
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 工具名前缀,需能区分不同项目)。
|
* 计算项目短名(MCP 工具名前缀,需能区分不同项目)。
|
||||||
@@ -358,7 +370,7 @@ function getProjectShortName() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 解析 Cocos 编辑器主进程可执行路径,写进注册文件供 router 的 editor_restart 拉起用。
|
* 解析 Cocos 编辑器主进程可执行路径,写进注册文件供 Gateway 的 editor_restart 拉起用。
|
||||||
* 取 process.argv[0] / process.execPath(编辑器主进程可执行,跨平台)。
|
* 取 process.argv[0] / process.execPath(编辑器主进程可执行,跨平台)。
|
||||||
* 排除 Helper(渲染/GPU 子进程),解析不到返回空串。
|
* 排除 Helper(渲染/GPU 子进程),解析不到返回空串。
|
||||||
*/
|
*/
|
||||||
@@ -376,22 +388,32 @@ function getEditorExecPath() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function writeRegistry() {
|
function writeRegistry() {
|
||||||
if (!_mcpServer || !_mcpServer.started) return;
|
if (!_editorBridge || !_editorBridge.started) return;
|
||||||
try {
|
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 = {
|
var entry = {
|
||||||
pid: process.pid,
|
pid: process.pid,
|
||||||
projectPath: Editor.Project.path,
|
projectPath: Editor.Project.path,
|
||||||
projectShortName: getProjectShortName(),
|
projectShortName: getProjectShortName(),
|
||||||
host: _mcpServer.host,
|
host: _editorBridge.host,
|
||||||
port: _mcpServer.port,
|
port: _editorBridge.port,
|
||||||
url: 'http://' + _mcpServer.host + ':' + _mcpServer.port + '/mcp',
|
url: 'http://' + _editorBridge.host + ':' + _editorBridge.port + '/bridge',
|
||||||
|
transport: 'editor-bridge',
|
||||||
editorVersion: (Editor.App && Editor.App.version) ? Editor.App.version : '',
|
editorVersion: (Editor.App && Editor.App.version) ? Editor.App.version : '',
|
||||||
execPath: getEditorExecPath(),
|
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(),
|
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) {
|
} catch (e) {
|
||||||
console.warn('[cc-mcp] writeRegistry failed:', e.message || e);
|
console.warn('[cc-mcp] writeRegistry failed:', e.message || e);
|
||||||
}
|
}
|
||||||
@@ -420,18 +442,17 @@ function findFreePort(startPort) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function startMcpServer() {
|
async function startEditorBridge() {
|
||||||
if (_mcpServer && _mcpServer.started) return;
|
if (_editorBridge && _editorBridge.started) return;
|
||||||
var port = await findFreePort(MCP_DEFAULT_PORT);
|
var port = await findFreePort(BRIDGE_DEFAULT_PORT);
|
||||||
var sdk = require(SDK_PATH);
|
var authToken = require('crypto').randomBytes(32).toString('hex');
|
||||||
|
|
||||||
// ── 使用 mcp-sdk ──────────────────────────────────────────────
|
|
||||||
var tdef = require('./server/tools');
|
var tdef = require('./server/tools');
|
||||||
var ctx = buildToolCtx();
|
var ctx = buildToolCtx();
|
||||||
var toolDefs = tdef.defineTools(ctx);
|
var toolDefs = tdef.defineTools(ctx);
|
||||||
var resourceDefs = tdef.defineResources(ctx);
|
var resourceDefs = tdef.defineResources(ctx);
|
||||||
|
|
||||||
// 只计真实的工具调用 / 资源读取,不计 router 每 15s 的 initialize/tools-list 探活——
|
// 只计真实的工具调用 / 资源读取,不计 Gateway 每 15s 的 bridge/describe 探活——
|
||||||
// 否则计数永远在涨,无法用于判断「业务指令到没到编辑器」。
|
// 否则计数永远在涨,无法用于判断「业务指令到没到编辑器」。
|
||||||
// lastRequestAt/lastTool 是判活主信号(曾因 requestCount 初始化后无人自增、恒 0,被当成转发断裂误诊)。
|
// lastRequestAt/lastTool 是判活主信号(曾因 requestCount 初始化后无人自增、恒 0,被当成转发断裂误诊)。
|
||||||
var stats = { startedAt: new Date().toISOString(), requestCount: 0, lastRequestAt: null, lastTool: null };
|
var stats = { startedAt: new Date().toISOString(), requestCount: 0, lastRequestAt: null, lastTool: null };
|
||||||
@@ -444,10 +465,13 @@ async function startMcpServer() {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
var server = sdk.createServer({
|
var bridgeModule = require('./server/editor-bridge');
|
||||||
name: 'cc-3-8-x-mcp',
|
var bridge = bridgeModule.createEditorBridge({
|
||||||
version: '2.0.0',
|
name: 'cocos-creator-3-8-x-editor-bridge',
|
||||||
|
version: require('./package.json').version,
|
||||||
|
host: '127.0.0.1',
|
||||||
port: port,
|
port: port,
|
||||||
|
authToken: authToken,
|
||||||
tools: toolDefs.map(function (t) {
|
tools: toolDefs.map(function (t) {
|
||||||
return {
|
return {
|
||||||
name: t.name,
|
name: t.name,
|
||||||
@@ -467,23 +491,24 @@ async function startMcpServer() {
|
|||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
// 启动 HTTP(cc-3-8-x-mcp 只跑 HTTP,不跑 stdio)
|
await bridge.start();
|
||||||
await server.start('http');
|
_editorBridge = {
|
||||||
_mcpServer = {
|
|
||||||
started: true,
|
started: true,
|
||||||
host: '127.0.0.1',
|
host: bridge.host,
|
||||||
port: port,
|
port: bridge.port,
|
||||||
|
bridgeApiVersion: bridge.bridgeApiVersion,
|
||||||
|
authToken: authToken,
|
||||||
toolCount: toolDefs.length,
|
toolCount: toolDefs.length,
|
||||||
resourceCount: resourceDefs.length,
|
resourceCount: resourceDefs.length,
|
||||||
stats: stats,
|
stats: stats,
|
||||||
stop: function () { server.stop(); },
|
stop: function () { return bridge.stop(); },
|
||||||
};
|
};
|
||||||
writeRegistry();
|
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() {
|
function buildToolCtx() {
|
||||||
return {
|
return {
|
||||||
@@ -509,15 +534,13 @@ function buildToolCtx() {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async function stopMcpServer() {
|
async function stopEditorBridge() {
|
||||||
if (!_mcpServer) return;
|
if (!_editorBridge) return;
|
||||||
removeRegistry();
|
removeRegistry();
|
||||||
if (_mcpServer.stop) {
|
try {
|
||||||
_mcpServer.stop();
|
if (_editorBridge.stop) await _editorBridge.stop();
|
||||||
} else {
|
} catch (e) { /* ignore */ }
|
||||||
try { await _mcpServer.stop(); } catch (e) { /* ignore */ }
|
_editorBridge = null;
|
||||||
}
|
|
||||||
_mcpServer = null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── .dev/refresh 文件命令协议 ──
|
// ── .dev/refresh 文件命令协议 ──
|
||||||
@@ -565,7 +588,7 @@ function doRestartSelf(name) {
|
|||||||
})
|
})
|
||||||
.then(function (pkgPath) {
|
.then(function (pkgPath) {
|
||||||
return Promise.resolve(Editor.Package.disable(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 new Promise(function (r) { setTimeout(r, 200); }); })
|
||||||
.then(function () { return Editor.Package.enable(pkgPath); });
|
.then(function () { return Editor.Package.enable(pkgPath); });
|
||||||
})
|
})
|
||||||
@@ -642,23 +665,23 @@ exports.load = async function () {
|
|||||||
log('loaded');
|
log('loaded');
|
||||||
// 启动 .dev/refresh 文件 watcher
|
// 启动 .dev/refresh 文件 watcher
|
||||||
startRefreshWatcher();
|
startRefreshWatcher();
|
||||||
// 异步拿预览地址,写 dev-reload-info.json,启动定时刷新
|
// 异步拿预览地址;仅实际变化时写 dev-reload-info.json,并启动 Gateway 注册心跳
|
||||||
getPreviewUrl().then(function(url) {
|
getPreviewUrl().then(function(url) {
|
||||||
if (url) writeDevReloadInfo(url);
|
if (url) writeDevReloadInfo(url);
|
||||||
startInfoInterval();
|
startRegistryHeartbeat();
|
||||||
}).catch(function(e) {
|
}).catch(function(e) {
|
||||||
console.error('[dev-reload] load: getPreviewUrl failed —', e && (e.stack || e.message) || e);
|
console.error('[dev-reload] load: getPreviewUrl failed —', e && (e.stack || e.message) || e);
|
||||||
startInfoInterval();
|
startRegistryHeartbeat();
|
||||||
});
|
});
|
||||||
// 启动 MCP server(失败打完整栈,不阻断扩展 load)
|
// 启动 Editor Bridge(失败打完整栈,不阻断扩展 load)
|
||||||
startMcpServer().catch(function(e) {
|
startEditorBridge().catch(function(e) {
|
||||||
console.error('[cc-mcp] MCP server failed to start:', e && (e.stack || e.message) || e);
|
console.error('[cc-mcp] Editor Bridge failed to start:', e && (e.stack || e.message) || e);
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
exports.unload = async function () {
|
exports.unload = async function () {
|
||||||
stopRefreshWatcher();
|
stopRefreshWatcher();
|
||||||
stopInfoInterval();
|
stopRegistryHeartbeat();
|
||||||
await stopMcpServer();
|
await stopEditorBridge();
|
||||||
log('unloaded');
|
log('unloaded');
|
||||||
};
|
};
|
||||||
|
|||||||
-1
Submodule mcp-sdk deleted from 78332af023
+8
-8
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"package_version": 2,
|
"package_version": 2,
|
||||||
"version": "2.0.0",
|
"version": "3.0.0",
|
||||||
"name": "cc-3-8-x-mcp",
|
"name": "cc-3-8-x-mcp",
|
||||||
"title": "Cocos Creator 3.8.x MCP Server",
|
"title": "CC 3.8.x MCP Editor Bridge",
|
||||||
"description": "把 Cocos Creator 3.8.x 编辑器能力(scene/asset-db/preview/local)以 MCP 协议暴露给外部 AI 客户端;兼容原 dev-reload 信号文件通道",
|
"description": "把 Cocos Creator 3.8.x 编辑器能力通过私有本机 Bridge 接入全局 cocos-mcp-gateway;兼容原 dev-reload 信号文件通道",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"author": "付饶",
|
"author": "付饶",
|
||||||
"editor": ">=3.8.0",
|
"editor": ">=3.8.0 <3.9.0",
|
||||||
"main": "./main.js",
|
"main": "./main.js",
|
||||||
"panels": {
|
"panels": {
|
||||||
"default": {
|
"default": {
|
||||||
@@ -30,15 +30,15 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "i18n:menu.extension/Cocos MCP",
|
"path": "i18n:menu.extension/Cocos MCP",
|
||||||
"label": "重启 MCP Server",
|
"label": "重启 Editor Bridge",
|
||||||
"message": "restart-server"
|
"message": "restart-bridge"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"messages": {
|
"messages": {
|
||||||
"open-panel": { "methods": ["openPanel"] },
|
"open-panel": { "methods": ["openPanel"] },
|
||||||
"restart-server": { "methods": ["restartServer"] },
|
"restart-bridge": { "methods": ["restartBridge"] },
|
||||||
|
"get-bridge-config": { "methods": ["getBridgeConfig"] },
|
||||||
"get-status": { "methods": ["getStatus"] },
|
"get-status": { "methods": ["getStatus"] },
|
||||||
"get-mcp-config": { "methods": ["getMcpConfig"] },
|
|
||||||
"refresh-assets": { "methods": ["refreshAssets"] },
|
"refresh-assets": { "methods": ["refreshAssets"] },
|
||||||
"query-preview-url": { "methods": ["queryPreviewUrl"] },
|
"query-preview-url": { "methods": ["queryPreviewUrl"] },
|
||||||
"trigger-refresh": { "methods": ["triggerRefresh"] },
|
"trigger-refresh": { "methods": ["triggerRefresh"] },
|
||||||
|
|||||||
+20
-20
@@ -1,19 +1,19 @@
|
|||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
// Cocos MCP 功能面板
|
// Cocos MCP 功能面板
|
||||||
// MCP 状态 / 编辑器状态 / 快捷动作 / Debug 注入 / 命令日志 / 同机 worktree
|
// Editor Bridge 状态 / 编辑器状态 / 快捷动作 / 命令日志 / 同机 worktree
|
||||||
|
|
||||||
exports.template = /* html */ `
|
exports.template = /* html */ `
|
||||||
<div class="wrap">
|
<div class="wrap">
|
||||||
<section class="mcp">
|
<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="mcpRunning">-</span></div>
|
||||||
<div class="row"><label>端点</label><span id="mcpUrl">-</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>Tools</label><span id="mcpTools">-</span></div>
|
||||||
<div class="row"><label>请求数</label><span id="mcpReqCount">0</span></div>
|
<div class="row"><label>请求数</label><span id="mcpReqCount">0</span></div>
|
||||||
<div class="mcp-actions">
|
<div class="mcp-actions">
|
||||||
<ui-button id="btnCopyMcpUrl">复制端点</ui-button>
|
<ui-button id="btnCopyMcpUrl">复制诊断信息</ui-button>
|
||||||
<ui-button id="btnCopyCli">复制 CLI 命令</ui-button>
|
<ui-button id="btnCopyCli">复制客户端入口</ui-button>
|
||||||
<ui-button id="btnRestartMcp" class="secondary">重启</ui-button>
|
<ui-button id="btnRestartMcp" class="secondary">重启</ui-button>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -154,11 +154,11 @@ exports.methods = {
|
|||||||
const w = s.watchers || {};
|
const w = s.watchers || {};
|
||||||
this.$.watchers.textContent =
|
this.$.watchers.textContent =
|
||||||
(w.refresh ? '●refresh ' : '○refresh ') +
|
(w.refresh ? '●refresh ' : '○refresh ') +
|
||||||
(w.infoInterval ? '●info' : '○info');
|
(w.registryHeartbeat ? '●registry' : '○registry');
|
||||||
this.$.updatedAt.textContent = s.updatedAt ? s.updatedAt.replace('T', ' ').replace(/\..+$/, '') : '-';
|
this.$.updatedAt.textContent = s.updatedAt ? s.updatedAt.replace('T', ' ').replace(/\..+$/, '') : '-';
|
||||||
|
|
||||||
// MCP 区
|
// Editor Bridge 区
|
||||||
const mcp = s.mcpServer || {};
|
const mcp = s.editorBridge || {};
|
||||||
if (mcp.running) {
|
if (mcp.running) {
|
||||||
this.$.mcpDot.className = 'dot green';
|
this.$.mcpDot.className = 'dot green';
|
||||||
this.$.mcpRunning.textContent = 'running';
|
this.$.mcpRunning.textContent = 'running';
|
||||||
@@ -210,9 +210,9 @@ exports.methods = {
|
|||||||
}
|
}
|
||||||
this.$.worktreeList.innerHTML = list.map(w => {
|
this.$.worktreeList.innerHTML = list.map(w => {
|
||||||
const name = (w.projectName || w.projectPath || '').split('/').slice(-2).join('/');
|
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';
|
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('');
|
}).join('');
|
||||||
} catch (e) { /* ignore */ }
|
} catch (e) { /* ignore */ }
|
||||||
},
|
},
|
||||||
@@ -256,25 +256,25 @@ exports.methods = {
|
|||||||
},
|
},
|
||||||
async onCopyMcpUrl() {
|
async onCopyMcpUrl() {
|
||||||
try {
|
try {
|
||||||
const cfg = await Editor.Message.request('cc-3-8-x-mcp', 'get-mcp-config');
|
const cfg = await Editor.Message.request('cc-3-8-x-mcp', 'get-bridge-config');
|
||||||
if (!cfg || !cfg.url) { this.showToast('MCP 未运行'); return; }
|
if (!cfg || !cfg.running) { this.showToast('Editor Bridge 未运行'); return; }
|
||||||
await navigator.clipboard.writeText(cfg.url);
|
await navigator.clipboard.writeText(JSON.stringify(cfg, null, 2));
|
||||||
this.showToast('已复制: ' + cfg.url);
|
this.showToast('已复制 Bridge 诊断信息(不含令牌)');
|
||||||
} catch (e) { this.showToast('失败: ' + (e.message || e)); }
|
} catch (e) { this.showToast('失败: ' + (e.message || e)); }
|
||||||
},
|
},
|
||||||
async onCopyCli() {
|
async onCopyCli() {
|
||||||
try {
|
try {
|
||||||
const cfg = await Editor.Message.request('cc-3-8-x-mcp', 'get-mcp-config');
|
const cfg = await Editor.Message.request('cc-3-8-x-mcp', 'get-bridge-config');
|
||||||
if (!cfg || !cfg.cliAddCommand) { this.showToast('MCP 未运行'); return; }
|
if (!cfg || !cfg.clientEntry) { this.showToast('Editor Bridge 未运行'); return; }
|
||||||
await navigator.clipboard.writeText(cfg.cliAddCommand);
|
await navigator.clipboard.writeText(cfg.clientEntry);
|
||||||
this.showToast('已复制 CLI 命令');
|
this.showToast('客户端只需连接 cocos-mcp-gateway');
|
||||||
} catch (e) { this.showToast('失败: ' + (e.message || e)); }
|
} catch (e) { this.showToast('失败: ' + (e.message || e)); }
|
||||||
},
|
},
|
||||||
async onRestartMcp() {
|
async onRestartMcp() {
|
||||||
this.showToast('重启 MCP…');
|
this.showToast('重启 Bridge…');
|
||||||
try {
|
try {
|
||||||
await Editor.Message.request('cc-3-8-x-mcp', 'restart-server');
|
await Editor.Message.request('cc-3-8-x-mcp', 'restart-bridge');
|
||||||
this.showToast('MCP 已重启');
|
this.showToast('Editor Bridge 已重启');
|
||||||
this.refreshStatus();
|
this.refreshStatus();
|
||||||
} catch (e) { this.showToast('失败: ' + (e.message || e)); }
|
} catch (e) { this.showToast('失败: ' + (e.message || e)); }
|
||||||
},
|
},
|
||||||
|
|||||||
+72
-73
@@ -2,14 +2,14 @@
|
|||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* cocos-mcp-router
|
* cocos-mcp-gateway
|
||||||
*
|
*
|
||||||
* stdio MCP server,聚合所有活跃的 Cocos 编辑器扩展,给客户端暴露统一的 tool 列表。
|
* 全局 stdio MCP Gateway,聚合所有活跃的 Cocos 编辑器扩展,给客户端暴露统一的 tool 列表。
|
||||||
*
|
*
|
||||||
* 发现机制:
|
* 发现机制:
|
||||||
* 扫 ~/.cocos-mcp/editors/*.json,每个文件代表一个活跃扩展实例
|
* 扫 ~/.cocos-mcp/editors/*.json,每个文件代表一个活跃扩展实例
|
||||||
* 过滤 mtime > 120s 的(视为已死)
|
* 过滤 mtime > 120s 的(视为已死)
|
||||||
* 对每个活跃编辑器调 HTTP POST /mcp initialize + tools/list,拿到其 tool 清单
|
* 对每个活跃编辑器调用私有 /bridge,读取 tool/resource 清单。
|
||||||
*
|
*
|
||||||
* 命名:
|
* 命名:
|
||||||
* tool 名前缀化:<projectShortName>__<originalName>
|
* tool 名前缀化:<projectShortName>__<originalName>
|
||||||
@@ -19,27 +19,34 @@
|
|||||||
* tools/call 收到前缀名 → 拆出 projectShortName → 查 editor URL → HTTP 转发
|
* 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 fs = require('fs');
|
||||||
var path = require('path');
|
var path = require('path');
|
||||||
var os = require('os');
|
var os = require('os');
|
||||||
var http = require('http');
|
|
||||||
|
|
||||||
var offlineTools = require('./src/offline-tools.js');
|
var offlineTools = require('./src/offline-tools.js');
|
||||||
var editorControl = require('./src/editor-control.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 REGISTRY_DIR = path.join(os.homedir(), '.cocos-mcp', 'editors');
|
||||||
var STALE_MS = 120 * 1000; // 2 分钟没心跳视为死
|
var STALE_MS = 120 * 1000; // 2 分钟没心跳视为死
|
||||||
var DISCOVERY_INTERVAL_MS = 15 * 1000;
|
var DISCOVERY_INTERVAL_MS = 15 * 1000;
|
||||||
var PROTOCOL_VERSION = '2024-11-05';
|
var PROTOCOL_VERSION = '2025-06-18';
|
||||||
var ROUTER_INFO = { name: 'cocos-mcp-router', version: '0.1.0' };
|
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() {
|
function logErr() {
|
||||||
// router 走 stdio,不能往 stdout 写非 JSON-RPC 内容,日志只能走 stderr
|
// router 走 stdio,不能往 stdout 写非 JSON-RPC 内容,日志只能走 stderr
|
||||||
var args = Array.prototype.slice.call(arguments);
|
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;
|
return;
|
||||||
}
|
}
|
||||||
var info = JSON.parse(fs.readFileSync(full, 'utf-8'));
|
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);
|
entries.push(info);
|
||||||
} catch (e) { /* ignore */ }
|
} catch (e) { /* ignore */ }
|
||||||
});
|
});
|
||||||
@@ -73,64 +84,30 @@ function scanRegistry() {
|
|||||||
return entries;
|
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) {
|
async function probeEditor(info) {
|
||||||
try {
|
try {
|
||||||
var initRes = await httpJsonRpc(info.url, {
|
var describeRes = await rpc.bridgeJsonRpc(info.url, {
|
||||||
jsonrpc: '2.0', id: 1, method: 'initialize', params: {
|
jsonrpc: '2.0', id: 1, method: 'bridge/describe', params: {},
|
||||||
protocolVersion: PROTOCOL_VERSION,
|
}, {
|
||||||
clientInfo: { name: 'cocos-mcp-router', version: ROUTER_INFO.version },
|
authToken: info.authToken,
|
||||||
capabilities: {},
|
timeoutMs: rpc.PROBE_TIMEOUT_MS,
|
||||||
},
|
|
||||||
});
|
});
|
||||||
if (initRes.error) throw new Error(initRes.error.message);
|
if (describeRes.error) throw new Error(describeRes.error.message);
|
||||||
var listRes = await httpJsonRpc(info.url, { jsonrpc: '2.0', id: 2, method: 'tools/list' });
|
var description = describeRes.result || {};
|
||||||
if (listRes.error) throw new Error(listRes.error.message);
|
if (description.bridgeApiVersion !== 1) {
|
||||||
return listRes.result.tools || [];
|
throw new Error('unsupported bridge api version: ' + description.bridgeApiVersion);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
tools: description.tools || [],
|
||||||
|
resources: description.resources || [],
|
||||||
|
bridgeApiVersion: description.bridgeApiVersion,
|
||||||
|
};
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logErr('probe failed', info.projectShortName, info.url, e.message);
|
logErr('probe failed', info.projectShortName, info.url, e.message);
|
||||||
return null;
|
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_-] */
|
/** 去掉 shortName 里的非法字符,MCP tool 名只允许 [a-zA-Z0-9_-] */
|
||||||
function sanitizeShortName(name) {
|
function sanitizeShortName(name) {
|
||||||
return String(name || 'unknown').replace(/[^a-zA-Z0-9_-]/g, '_');
|
return String(name || 'unknown').replace(/[^a-zA-Z0-9_-]/g, '_');
|
||||||
@@ -148,9 +125,10 @@ async function discover() {
|
|||||||
// 首次 probe 只拿到部分/空工具;之后 asset-db 就绪、业务工具全激活,必须重新 probe 刷新,
|
// 首次 probe 只拿到部分/空工具;之后 asset-db 就绪、业务工具全激活,必须重新 probe 刷新,
|
||||||
// 否则 claude 永远看不到后激活的工具(如 forest__preview_refresh_and_reload)。
|
// 否则 claude 永远看不到后激活的工具(如 forest__preview_refresh_and_reload)。
|
||||||
var existing = editors.get(key);
|
var existing = editors.get(key);
|
||||||
var tools = await probeEditor(info);
|
var description = await probeEditor(info);
|
||||||
if (tools == null) continue; // probe 失败:保留旧缓存(editor 可能临时忙/重启中),不覆盖
|
if (description == null) continue; // probe 失败:保留旧缓存(editor 可能临时忙/重启中),不覆盖
|
||||||
var resources = await probeEditorResources(info);
|
var tools = description.tools;
|
||||||
|
var resources = description.resources;
|
||||||
editors.set(key, {
|
editors.set(key, {
|
||||||
baseShortName: sanitizeShortName(info.projectShortName),
|
baseShortName: sanitizeShortName(info.projectShortName),
|
||||||
shortName: sanitizeShortName(info.projectShortName), // dedupeShortNames 会按冲突重设
|
shortName: sanitizeShortName(info.projectShortName), // dedupeShortNames 会按冲突重设
|
||||||
@@ -158,6 +136,11 @@ async function discover() {
|
|||||||
pid: info.pid,
|
pid: info.pid,
|
||||||
startedAt: Date.parse(info.startedAt) || 0, // 同项目双实例时 dedupe 按它选代表
|
startedAt: Date.parse(info.startedAt) || 0, // 同项目双实例时 dedupe 按它选代表
|
||||||
url: info.url,
|
url: info.url,
|
||||||
|
authToken: info.authToken || '',
|
||||||
|
extensionVersion: info.extensionVersion || '',
|
||||||
|
gatewayApiVersion: info.gatewayApiVersion || 0,
|
||||||
|
transport: info.transport,
|
||||||
|
bridgeApiVersion: description.bridgeApiVersion || info.bridgeApiVersion || 0,
|
||||||
tools: tools,
|
tools: tools,
|
||||||
resources: resources,
|
resources: resources,
|
||||||
lastProbed: Date.now(),
|
lastProbed: Date.now(),
|
||||||
@@ -264,8 +247,8 @@ function buildAggregatedToolList() {
|
|||||||
}
|
}
|
||||||
// router 自身的 meta tool
|
// router 自身的 meta tool
|
||||||
out.push({
|
out.push({
|
||||||
name: 'router_list_editors',
|
name: 'gateway_list_editors',
|
||||||
description: '列出当前 router 发现的所有活跃 Cocos 编辑器(shortName / pid / url / tool 数)',
|
description: '列出当前 Gateway 发现的所有活跃 Cocos 编辑器(实例、Bridge 版本、tool 数;不暴露 token)',
|
||||||
inputSchema: { type: 'object', properties: {} },
|
inputSchema: { type: 'object', properties: {} },
|
||||||
});
|
});
|
||||||
// offline prefab tools(不需要编辑器运行)
|
// offline prefab tools(不需要编辑器运行)
|
||||||
@@ -276,11 +259,11 @@ function buildAggregatedToolList() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function encodeRouterResourceUri(ed, uri) {
|
function encodeRouterResourceUri(ed, uri) {
|
||||||
return 'cocos-router://' + ed.shortName + '/' + encodeURIComponent(uri);
|
return 'cocos-gateway://' + ed.shortName + '/' + encodeURIComponent(uri);
|
||||||
}
|
}
|
||||||
|
|
||||||
function decodeRouterResourceUri(uri) {
|
function decodeRouterResourceUri(uri) {
|
||||||
var m = String(uri || '').match(/^cocos-router:\/\/([^\/]+)\/(.+)$/);
|
var m = String(uri || '').match(/^cocos-gateway:\/\/([^\/]+)\/(.+)$/);
|
||||||
if (!m) return null;
|
if (!m) return null;
|
||||||
return { shortName: m[1], uri: decodeURIComponent(m[2]) };
|
return { shortName: m[1], uri: decodeURIComponent(m[2]) };
|
||||||
}
|
}
|
||||||
@@ -354,7 +337,7 @@ async function handleMessage(msg) {
|
|||||||
await discover();
|
await discover();
|
||||||
result = {
|
result = {
|
||||||
protocolVersion: PROTOCOL_VERSION,
|
protocolVersion: PROTOCOL_VERSION,
|
||||||
serverInfo: ROUTER_INFO,
|
serverInfo: GATEWAY_INFO,
|
||||||
capabilities: {
|
capabilities: {
|
||||||
tools: { listChanged: true },
|
tools: { listChanged: true },
|
||||||
resources: {},
|
resources: {},
|
||||||
@@ -398,10 +381,20 @@ async function handleMessage(msg) {
|
|||||||
|
|
||||||
async function handleToolCall(name, args) {
|
async function handleToolCall(name, args) {
|
||||||
// Router 自身的 meta tool
|
// Router 自身的 meta tool
|
||||||
if (name === 'router_list_editors') {
|
if (name === 'gateway_list_editors') {
|
||||||
await discover();
|
await discover();
|
||||||
var list = Array.from(editors.values()).map(function (ed) {
|
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) }] };
|
return { content: [{ type: 'text', text: JSON.stringify(list, null, 2) }] };
|
||||||
}
|
}
|
||||||
@@ -427,9 +420,12 @@ async function handleToolCall(name, args) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
var forward = await httpJsonRpc(hit.editor.url, {
|
var forward = await rpc.bridgeJsonRpc(hit.editor.url, {
|
||||||
jsonrpc: '2.0', id: Date.now(), method: 'tools/call',
|
jsonrpc: '2.0', id: Date.now(), method: 'bridge/invoke',
|
||||||
params: { name: hit.originalName, arguments: args },
|
params: { name: hit.originalName, arguments: args },
|
||||||
|
}, {
|
||||||
|
authToken: hit.editor.authToken,
|
||||||
|
timeoutMs: rpc.toolTimeoutMs(hit.originalName),
|
||||||
});
|
});
|
||||||
if (forward.error) {
|
if (forward.error) {
|
||||||
return { content: [{ type: 'text', text: 'editor error: ' + forward.error.message }], isError: true };
|
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' }] };
|
return { contents: [{ type: 'text', text: 'editor not found for resource: ' + decoded.shortName, mimeType: 'text/plain' }] };
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
var forward = await httpJsonRpc(ed.url, {
|
var forward = await rpc.bridgeJsonRpc(ed.url, {
|
||||||
jsonrpc: '2.0', id: Date.now(), method: 'resources/read',
|
jsonrpc: '2.0', id: Date.now(), method: 'bridge/read-resource',
|
||||||
params: { uri: decoded.uri },
|
params: { uri: decoded.uri },
|
||||||
|
}, {
|
||||||
|
authToken: ed.authToken,
|
||||||
|
timeoutMs: rpc.TOOL_TIMEOUT_MS,
|
||||||
});
|
});
|
||||||
if (forward.error) {
|
if (forward.error) {
|
||||||
return { contents: [{ type: 'text', text: 'editor error: ' + forward.error.message, mimeType: 'text/plain' }] };
|
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); });
|
discover().catch(function (e) { logErr('initial discover failed', e.message); });
|
||||||
logErr('cocos-mcp-router started (stdio)');
|
logErr('cocos-mcp-gateway started (stdio)');
|
||||||
|
|||||||
@@ -5,8 +5,8 @@
|
|||||||
*
|
*
|
||||||
* Router 级编辑器进程管理 tool。spawn / kill / wait_ready / restart Cocos 编辑器进程。
|
* Router 级编辑器进程管理 tool。spawn / kill / wait_ready / restart Cocos 编辑器进程。
|
||||||
*
|
*
|
||||||
* 为什么挂在 router(而不是编辑器内 server):
|
* 为什么挂在 Gateway(而不是编辑器内 Bridge):
|
||||||
* 编辑器内的 MCP server 寄生在编辑器进程里,kill 编辑器 = kill server 自己,自杀后没法
|
* 编辑器内的 Bridge 寄生在编辑器进程里,kill 编辑器 = kill Bridge 自己,自杀后没法
|
||||||
* 再把自己拉起来。router 是进程外的常驻 stdio 进程,编辑器死了它还活着,所以「关 / 重启 /
|
* 再把自己拉起来。router 是进程外的常驻 stdio 进程,编辑器死了它还活着,所以「关 / 重启 /
|
||||||
* 等就绪」这类要跨越编辑器进程生死的能力只能放这里,跟 offline prefab tools 同类,不走转发。
|
* 等就绪」这类要跨越编辑器进程生死的能力只能放这里,跟 offline prefab tools 同类,不走转发。
|
||||||
*
|
*
|
||||||
@@ -21,13 +21,12 @@
|
|||||||
var fs = require('fs');
|
var fs = require('fs');
|
||||||
var path = require('path');
|
var path = require('path');
|
||||||
var os = require('os');
|
var os = require('os');
|
||||||
var http = require('http');
|
|
||||||
var cp = require('child_process');
|
var cp = require('child_process');
|
||||||
var crypto = require('crypto');
|
var crypto = require('crypto');
|
||||||
|
var bridgeRpc = require('./http-json-rpc.js');
|
||||||
|
|
||||||
var REGISTRY_DIR = path.join(os.homedir(), '.cocos-mcp', 'editors');
|
var REGISTRY_DIR = path.join(os.homedir(), '.cocos-mcp', 'editors');
|
||||||
var STALE_MS = 120 * 1000; // 与 bin.js 对齐:2 分钟没心跳视为死
|
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 共用。 */
|
/** Bridge ping 探活:能响应只代表扩展已启动,不代表 AssetDB 已就绪。 */
|
||||||
function httpMcp(url, method, params, timeoutMs) {
|
function probeReady(entry) {
|
||||||
return new Promise(function (resolve) {
|
return bridgeRpc.bridgeJsonRpc(entry.url, {
|
||||||
try {
|
jsonrpc: '2.0', id: 1, method: 'bridge/ping', params: {},
|
||||||
var u = new URL(url);
|
}, {
|
||||||
var body = JSON.stringify({ jsonrpc: '2.0', id: 1, method: method, params: params || {} });
|
authToken: entry.authToken,
|
||||||
var req = http.request({
|
timeoutMs: 4000,
|
||||||
hostname: u.hostname, port: u.port, path: u.pathname, method: 'POST',
|
}).then(function (r) {
|
||||||
headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) },
|
return !!(r && !r.error && r.result && r.result.bridgeApiVersion === 1);
|
||||||
timeout: timeoutMs || 4000,
|
}).catch(function () { return false; });
|
||||||
}, 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); });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 项目就绪探测:MCP initialize 成功 ≠ 进了项目 —— 实测激进清登录态后 initialize 仍 ready,
|
* 项目就绪探测:Bridge ping 成功 ≠ 进了项目 —— 扩展已启动时项目仍可能在加载,
|
||||||
* 但编辑器 UI 卡在登录页。用 asset_query_assets 查 db://assets/* 探 asset-db 是否就绪:
|
* 但编辑器 UI 卡在登录页。用 asset_query_assets 查 db://assets/* 探 asset-db 是否就绪:
|
||||||
* 项目真打开才加载 asset-db、返回非空顶层资源;登录页 / 项目加载中则空或失败。
|
* 项目真打开才加载 asset-db、返回非空顶层资源;登录页 / 项目加载中则空或失败。
|
||||||
* 正向(进项目非空)已实测;负向(登录页态返回啥)按逻辑推断,未在登录页态实测。
|
* 正向(进项目非空)已实测;负向(登录页态返回啥)按逻辑推断,未在登录页态实测。
|
||||||
*/
|
*/
|
||||||
function probeProjectReady(url) {
|
function probeProjectReady(entry) {
|
||||||
return httpMcp(url, 'tools/call', {
|
return bridgeRpc.bridgeJsonRpc(entry.url, {
|
||||||
name: 'asset_query_assets', arguments: { pattern: 'db://assets/*' },
|
jsonrpc: '2.0', id: 2, method: 'bridge/invoke',
|
||||||
}, 6000).then(function (r) {
|
params: { name: 'asset_query_assets', arguments: { pattern: 'db://assets/*' } },
|
||||||
|
}, {
|
||||||
|
authToken: entry.authToken,
|
||||||
|
timeoutMs: 6000,
|
||||||
|
}).then(function (r) {
|
||||||
return hasReadyAssetResult(r);
|
return hasReadyAssetResult(r);
|
||||||
});
|
}).catch(function () { return false; });
|
||||||
}
|
}
|
||||||
|
|
||||||
function hasReadyAssetResult(r) {
|
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,避免匹配到尚未删净的旧注册。
|
* excludePid:restart 时传被 kill 的旧 pid,避免匹配到尚未删净的旧注册。
|
||||||
* 返回 { ready, entry?, reason?, waitedMs }。
|
* 返回 { ready, entry?, reason?, waitedMs }。
|
||||||
*/
|
*/
|
||||||
@@ -545,10 +525,10 @@ async function waitReady(projectPath, opts) {
|
|||||||
opts = opts || {};
|
opts = opts || {};
|
||||||
var timeoutMs = opts.timeoutMs || 90000; // 大项目冷启动慢,默认 90s
|
var timeoutMs = opts.timeoutMs || 90000; // 大项目冷启动慢,默认 90s
|
||||||
var excludePid = opts.excludePid || 0;
|
var excludePid = opts.excludePid || 0;
|
||||||
var requireProject = opts.requireProject !== false; // 默认要求项目就绪(区分登录页/加载中),传 false 退回只看 MCP
|
var requireProject = opts.requireProject !== false; // 默认要求项目就绪(区分登录页/加载中),传 false 只看 Bridge
|
||||||
var start = Date.now();
|
var start = Date.now();
|
||||||
var lastReason = 'still waiting';
|
var lastReason = 'still waiting';
|
||||||
var sawMcp = false;
|
var sawBridge = false;
|
||||||
|
|
||||||
while (Date.now() - start < timeoutMs) {
|
while (Date.now() - start < timeoutMs) {
|
||||||
var hit = activeEditors().filter(function (e) {
|
var hit = activeEditors().filter(function (e) {
|
||||||
@@ -556,13 +536,13 @@ async function waitReady(projectPath, opts) {
|
|||||||
})[0];
|
})[0];
|
||||||
|
|
||||||
if (hit) {
|
if (hit) {
|
||||||
var mcpOk = await probeReady(hit.url);
|
var bridgeOk = await probeReady(hit);
|
||||||
if (mcpOk) {
|
if (bridgeOk) {
|
||||||
sawMcp = true;
|
sawBridge = true;
|
||||||
var projOk = requireProject ? await probeProjectReady(hit.url) : true;
|
var projOk = requireProject ? await probeProjectReady(hit) : true;
|
||||||
if (projOk) {
|
if (projOk) {
|
||||||
return {
|
return {
|
||||||
ready: true, mcpReady: true, projectReady: projOk,
|
ready: true, bridgeReady: true, projectReady: projOk,
|
||||||
entry: {
|
entry: {
|
||||||
shortName: sanitize(hit.projectShortName),
|
shortName: sanitize(hit.projectShortName),
|
||||||
pid: hit.pid, url: hit.url, port: hit.port,
|
pid: hit.pid, url: hit.url, port: hit.port,
|
||||||
@@ -571,18 +551,18 @@ async function waitReady(projectPath, opts) {
|
|||||||
waitedMs: Date.now() - start,
|
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 {
|
} else {
|
||||||
lastReason = 'registered (pid=' + hit.pid + ') but MCP server not responding yet';
|
lastReason = 'registered (pid=' + hit.pid + ') but Editor Bridge not responding yet';
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
lastReason = 'no fresh registry entry for project yet (editor still booting)';
|
lastReason = 'no fresh registry entry for project yet (editor still booting)';
|
||||||
}
|
}
|
||||||
await sleep(1000);
|
await sleep(1000);
|
||||||
}
|
}
|
||||||
var res = { ready: false, mcpReady: sawMcp, projectReady: false, reason: lastReason, waitedMs: Date.now() - start };
|
var res = { ready: false, bridgeReady: sawBridge, projectReady: false, reason: lastReason, waitedMs: Date.now() - start };
|
||||||
if (sawMcp) {
|
if (sawBridge) {
|
||||||
res.hint = '⚠️ MCP server 起来了但项目没就绪。若是 router 拉起/重启编辑器,请确认 spawnArgs 包含 --nologin;若是手动拉起,可能卡在 Cocos Developer Login 或仍在加载项目。';
|
res.hint = '⚠️ Editor Bridge 起来了但项目没就绪。若是 Gateway 拉起/重启编辑器,请确认 spawnArgs 包含 --nologin;若是手动拉起,可能卡在 Cocos Developer Login 或仍在加载项目。';
|
||||||
}
|
}
|
||||||
return res;
|
return res;
|
||||||
}
|
}
|
||||||
@@ -619,7 +599,7 @@ var EDITOR_TOOLS = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'editor_wait_ready',
|
name: 'editor_wait_ready',
|
||||||
description: '[editor] 等指定项目的 Cocos 编辑器就绪(注册文件出现且 MCP server 能 initialize)。' +
|
description: '[editor] 等指定项目的 Cocos 编辑器就绪(注册文件出现且 Editor Bridge 可探活)。' +
|
||||||
'用于「拉起编辑器后等它起来再操作」。已就绪则立即返回。' +
|
'用于「拉起编辑器后等它起来再操作」。已就绪则立即返回。' +
|
||||||
'编辑器尚未运行时必须传 projectPath(空注册表无法从 shortName 反推路径)。',
|
'编辑器尚未运行时必须传 projectPath(空注册表无法从 shortName 反推路径)。',
|
||||||
inputSchema: {
|
inputSchema: {
|
||||||
|
|||||||
@@ -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/);
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user