Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
668898f0c6 | ||
|
|
87cfa48cda | ||
|
|
e3dc45468b | ||
|
|
63c18b0b5f |
@@ -75,3 +75,12 @@ jobs:
|
|||||||
|
|
||||||
- name: Run tests
|
- name: Run tests
|
||||||
run: npm test
|
run: npm test
|
||||||
|
|
||||||
|
- name: Check generated docs
|
||||||
|
run: npm run docs:check
|
||||||
|
|
||||||
|
- name: Run release metadata checks
|
||||||
|
run: npm run release:check
|
||||||
|
|
||||||
|
- name: Run npm package dry-run
|
||||||
|
run: npm run pack:dry-run
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
.DS_Store
|
.DS_Store
|
||||||
|
.mcpregistry_*
|
||||||
node_modules/
|
node_modules/
|
||||||
temp/
|
temp/
|
||||||
Temp/
|
Temp/
|
||||||
@@ -6,3 +7,5 @@ Library/
|
|||||||
library/
|
library/
|
||||||
dist/
|
dist/
|
||||||
build/
|
build/
|
||||||
|
.release-tmp/
|
||||||
|
releases/
|
||||||
|
|||||||
@@ -6,6 +6,39 @@ This project follows a simple changelog format inspired by [Keep a Changelog](ht
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [0.3.3] - 2026-05-20
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added generated tool reference documentation in `docs/TOOLS.md`.
|
||||||
|
- Added `docs:generate` and `docs:check` scripts to keep tool counts, profiles, categories, and descriptions synchronized with `lib/tool-registry.js`.
|
||||||
|
- Added CI and release validation for generated tool documentation.
|
||||||
|
- Added a read-only `GET /tools` debug endpoint with curl examples for quick local troubleshooting.
|
||||||
|
- Added panel activity previews for recent tool calls and runtime logs.
|
||||||
|
- Added panel curl copy actions for `/health` and `/tools`.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Refined tool category inference so `get_tool_catalog` is grouped with project/context tools.
|
||||||
|
- Updated the MCP client config panel so the preview follows the selected client target.
|
||||||
|
- Split file-system tools and asset refresh helpers into `lib/tools/files.js` as the first registry modularization step.
|
||||||
|
|
||||||
|
## [0.3.2] - 2026-05-20
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added an npm-installable `funplay-cocos-mcp` stdio wrapper that bridges MCP clients to the local Cocos HTTP endpoint.
|
||||||
|
- Added MCP Registry metadata in `server.json`, including npm package ownership metadata via `mcpName`.
|
||||||
|
- Added wrapper tests, npm pack dry-run verification, and registry validation scripts.
|
||||||
|
|
||||||
|
## [0.3.1] - 2026-05-20
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added a documented release workflow and release checklist for Cocos extension publishing.
|
||||||
|
- Added release packaging scripts that generate a Cocos extension zip, release manifest, checksum file, and per-release README.
|
||||||
|
- Added CI validation for release metadata.
|
||||||
|
|
||||||
## [0.3.0] - 2026-05-20
|
## [0.3.0] - 2026-05-20
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -51,6 +51,8 @@ git clone https://github.com/FunplayAI/funplay-cocos-mcp.git extensions/funplay-
|
|||||||
|
|
||||||
Then restart Cocos Creator or reload extensions from the editor.
|
Then restart Cocos Creator or reload extensions from the editor.
|
||||||
|
|
||||||
|
For a non-git install, download `Funplay.CocosMcp.v<version>.zip` from the GitHub Releases page, unzip it, and move the extracted `funplay-cocos-mcp` folder into your project `extensions/` directory.
|
||||||
|
|
||||||
You can also install it globally by copying the folder into your Cocos Creator user extensions directory.
|
You can also install it globally by copying the folder into your Cocos Creator user extensions directory.
|
||||||
|
|
||||||
### 2. Start the MCP Server
|
### 2. Start the MCP Server
|
||||||
@@ -71,8 +73,10 @@ The panel is intentionally small:
|
|||||||
- Change the server port
|
- Change the server port
|
||||||
- Switch tool exposure between `core`, `full`, and `custom`
|
- Switch tool exposure between `core`, `full`, and `custom`
|
||||||
- Check the installed version against the latest GitHub release
|
- Check the installed version against the latest GitHub release
|
||||||
|
- Inspect recent tool calls and runtime log previews
|
||||||
- Tune tool exposure by category or individual tool
|
- Tune tool exposure by category or individual tool
|
||||||
- Configure AI clients with one click
|
- Configure AI clients with one click and preview the selected client config
|
||||||
|
- Copy quick `curl` commands for `/health` and `/tools`
|
||||||
- Expand debug output only when needed
|
- Expand debug output only when needed
|
||||||
|
|
||||||
### 3. Configure Your AI Client
|
### 3. Configure Your AI Client
|
||||||
@@ -177,6 +181,31 @@ url = "http://127.0.0.1:8765/"
|
|||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
|
### Optional: npm stdio Wrapper
|
||||||
|
|
||||||
|
If your MCP client prefers a local `stdio` command, install the npm wrapper after starting the Cocos editor server:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install -g funplay-cocos-mcp
|
||||||
|
```
|
||||||
|
|
||||||
|
Example MCP client entry:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"funplay_cocos": {
|
||||||
|
"command": "funplay-cocos-mcp",
|
||||||
|
"env": {
|
||||||
|
"FUNPLAY_COCOS_MCP_URL": "http://127.0.0.1:8765/"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The wrapper bridges stdio MCP traffic to the embedded Cocos HTTP endpoint. You can also run it with `npx funplay-cocos-mcp --url http://127.0.0.1:8765/`.
|
||||||
|
|
||||||
### 4. Verify the Connection
|
### 4. Verify the Connection
|
||||||
|
|
||||||
Open your AI client and try a few safe requests first:
|
Open your AI client and try a few safe requests first:
|
||||||
@@ -188,6 +217,13 @@ Open your AI client and try a few safe requests first:
|
|||||||
|
|
||||||
If these work, the MCP server, resources, prompts, and primary execution tool are connected correctly.
|
If these work, the MCP server, resources, prompts, and primary execution tool are connected correctly.
|
||||||
|
|
||||||
|
For local transport debugging, the panel can copy these commands, or you can run them directly:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl http://127.0.0.1:8765/health
|
||||||
|
curl http://127.0.0.1:8765/tools
|
||||||
|
```
|
||||||
|
|
||||||
### 5. Start Building
|
### 5. Start Building
|
||||||
|
|
||||||
Try a higher-level prompt in your AI client:
|
Try a higher-level prompt in your AI client:
|
||||||
@@ -199,6 +235,7 @@ Try a higher-level prompt in your AI client:
|
|||||||
- This extension is **Editor-only**. It is meant to automate Cocos Creator, not to add runtime dependencies to your final game build.
|
- This extension is **Editor-only**. It is meant to automate Cocos Creator, not to add runtime dependencies to your final game build.
|
||||||
- The MCP server listens on `http://127.0.0.1:8765/` by default.
|
- The MCP server listens on `http://127.0.0.1:8765/` by default.
|
||||||
- If the configured port is busy, the server automatically falls back to the next available port and the panel/client config use the actual running port.
|
- If the configured port is busy, the server automatically falls back to the next available port and the panel/client config use the actual running port.
|
||||||
|
- `GET /health` and `GET /tools` are read-only debug endpoints for quick local checks outside an MCP client.
|
||||||
- The default `core` profile exposes 34 high-signal tools. Switch to `full` for all 89 tools, or use `custom` to include/exclude tool categories and individual tools.
|
- The default `core` profile exposes 34 high-signal tools. Switch to `full` for all 89 tools, or use `custom` to include/exclude tool categories and individual tools.
|
||||||
- The panel includes a manual update check against the latest GitHub release.
|
- The panel includes a manual update check against the latest GitHub release.
|
||||||
- Streamable HTTP responses follow the MCP transport requirements for `Accept`, `MCP-Protocol-Version`, JSON-RPC notifications/responses, and optional `Mcp-Session-Id` sessions.
|
- Streamable HTTP responses follow the MCP transport requirements for `Accept`, `MCP-Protocol-Version`, JSON-RPC notifications/responses, and optional `Mcp-Session-Id` sessions.
|
||||||
@@ -222,7 +259,7 @@ Try a higher-level prompt in your AI client:
|
|||||||
- **89 Built-in Tools** — Scene hierarchy, editor state, selection workflows, prefabs, assets, project instructions, UI creation, components, files, logs, script diagnostics, screenshots, runtime control, and input simulation
|
- **89 Built-in Tools** — Scene hierarchy, editor state, selection workflows, prefabs, assets, project instructions, UI creation, components, files, logs, script diagnostics, screenshots, runtime control, and input simulation
|
||||||
- **Primary Unified Tool** — `execute_javascript` supports both `scene` and `editor` contexts
|
- **Primary Unified Tool** — `execute_javascript` supports both `scene` and `editor` contexts
|
||||||
- **Resources & Prompts** — Live project/log resources plus reusable workflows like script fixing, scene validation, and playable prototype creation
|
- **Resources & Prompts** — Live project/log resources plus reusable workflows like script fixing, scene validation, and playable prototype creation
|
||||||
- **Cocos Panel UI** — A minimal `Funplay > MCP Server` panel for service management, update checks, tool exposure, and MCP client setup
|
- **Cocos Panel UI** — A compact `Funplay > MCP Server` panel for service management, update checks, tool exposure, recent activity, logs, curl diagnostics, and MCP client setup
|
||||||
- **Screenshot and Input Support** — Capture editor/scene/game/preview screenshots and send Electron-level mouse/keyboard events
|
- **Screenshot and Input Support** — Capture editor/scene/game/preview screenshots and send Electron-level mouse/keyboard events
|
||||||
- **Vendor Agnostic** — Works with any AI client that supports MCP over HTTP JSON-RPC
|
- **Vendor Agnostic** — Works with any AI client that supports MCP over HTTP JSON-RPC
|
||||||
|
|
||||||
@@ -249,6 +286,8 @@ The current package exposes four capability layers:
|
|||||||
- **Prompts** — `fix_script_errors`, `create_playable_prototype`, `scene_validation`, and `auto_wire_scene`
|
- **Prompts** — `fix_script_errors`, `create_playable_prototype`, `scene_validation`, and `auto_wire_scene`
|
||||||
- **Resources** — project context, scene summaries, current selection, script diagnostics, asset selection, logs, and MCP interaction history
|
- **Resources** — project context, scene summaries, current selection, script diagnostics, asset selection, logs, and MCP interaction history
|
||||||
|
|
||||||
|
For the generated tool reference, including categories, profiles, and read/mutation hints, see [docs/TOOLS.md](./docs/TOOLS.md).
|
||||||
|
|
||||||
The default `core` set is intentionally small: `execute_javascript`, `execute_scene_script`, `execute_editor_script`, `get_editor_state`, `get_tool_catalog`, `check_for_updates`, `get_selection`, `list_project_instructions`, `read_project_instruction`, `set_selection`, `get_project_info`, `get_scene_info`, `get_hierarchy`, `list_scenes`, `open_scene`, `inspect_prefab`, `validate_prefab_references`, `inspect_prefab_instance`, `list_assets`, `inspect_asset`, `open_asset`, `select_asset`, `run_script_diagnostics`, `get_recent_logs`, `search_project_logs`, `clear_logs`, `validate_scene`, `get_performance_snapshot`, `get_script_diagnostic_context`, `get_runtime_state`, `capture_editor_screenshot`, `capture_scene_screenshot`, `capture_preview_screenshot`, and `list_editor_windows`.
|
The default `core` set is intentionally small: `execute_javascript`, `execute_scene_script`, `execute_editor_script`, `get_editor_state`, `get_tool_catalog`, `check_for_updates`, `get_selection`, `list_project_instructions`, `read_project_instruction`, `set_selection`, `get_project_info`, `get_scene_info`, `get_hierarchy`, `list_scenes`, `open_scene`, `inspect_prefab`, `validate_prefab_references`, `inspect_prefab_instance`, `list_assets`, `inspect_asset`, `open_asset`, `select_asset`, `run_script_diagnostics`, `get_recent_logs`, `search_project_logs`, `clear_logs`, `validate_scene`, `get_performance_snapshot`, `get_script_diagnostic_context`, `get_runtime_state`, `capture_editor_screenshot`, `capture_scene_screenshot`, `capture_preview_screenshot`, and `list_editor_windows`.
|
||||||
|
|
||||||
## Built-in Resources
|
## Built-in Resources
|
||||||
@@ -358,14 +397,38 @@ Cocos Creator Extension
|
|||||||
└─ server, resources, prompts, tool registry
|
└─ server, resources, prompts, tool registry
|
||||||
```
|
```
|
||||||
|
|
||||||
The server speaks MCP-style HTTP JSON-RPC 2.0 and supports tools, resources, resource templates, prompts, and health checks.
|
The server speaks MCP-style HTTP JSON-RPC 2.0 and supports tools, resources, resource templates, prompts, health checks, and a read-only `/tools` debug endpoint.
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|
||||||
Run a syntax check before publishing changes:
|
Run checks before publishing changes:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm run check
|
npm run check
|
||||||
|
npm test
|
||||||
|
npm run docs:check
|
||||||
|
npm run release:check
|
||||||
|
npm run pack:dry-run
|
||||||
|
```
|
||||||
|
|
||||||
|
Regenerate the tool reference after changing `lib/tool-registry.js`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run docs:generate
|
||||||
|
```
|
||||||
|
|
||||||
|
To generate a GitHub Release-ready extension package:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run release:package
|
||||||
|
```
|
||||||
|
|
||||||
|
The package is written to `releases/<version>/` with a zip, manifest, checksum file, and release README. See [RELEASE_WORKFLOW.md](./RELEASE_WORKFLOW.md) and [RELEASE_CHECKLIST.md](./RELEASE_CHECKLIST.md) for the full process.
|
||||||
|
|
||||||
|
Validate MCP Registry metadata before publishing:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run registry:validate
|
||||||
```
|
```
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|||||||
+67
-4
@@ -51,6 +51,8 @@ git clone https://github.com/FunplayAI/funplay-cocos-mcp.git extensions/funplay-
|
|||||||
|
|
||||||
然后重启 Cocos Creator,或在编辑器里重新加载扩展。
|
然后重启 Cocos Creator,或在编辑器里重新加载扩展。
|
||||||
|
|
||||||
|
如果不想用 git 安装,可以从 GitHub Releases 下载 `Funplay.CocosMcp.v<version>.zip`,解压后把 `funplay-cocos-mcp` 目录移动到项目的 `extensions/` 目录。
|
||||||
|
|
||||||
你也可以把目录复制到 Cocos Creator 的全局用户扩展目录中。
|
你也可以把目录复制到 Cocos Creator 的全局用户扩展目录中。
|
||||||
|
|
||||||
### 2. 启动 MCP Server
|
### 2. 启动 MCP Server
|
||||||
@@ -71,8 +73,10 @@ Funplay > MCP Server
|
|||||||
- 修改服务端口
|
- 修改服务端口
|
||||||
- 在 `core` / `full` / `custom` 工具暴露模式之间切换
|
- 在 `core` / `full` / `custom` 工具暴露模式之间切换
|
||||||
- 检查当前安装版本是否落后于 GitHub 最新 Release
|
- 检查当前安装版本是否落后于 GitHub 最新 Release
|
||||||
|
- 查看最近工具调用和运行日志预览
|
||||||
- 按工具分类或单个工具调整暴露范围
|
- 按工具分类或单个工具调整暴露范围
|
||||||
- 一键配置 AI 客户端
|
- 一键配置 AI 客户端,并随目标客户端预览对应配置
|
||||||
|
- 复制 `/health` 和 `/tools` 的快速 `curl` 排障命令
|
||||||
- 需要时再展开 Debug Output
|
- 需要时再展开 Debug Output
|
||||||
|
|
||||||
### 3. 配置 AI 客户端
|
### 3. 配置 AI 客户端
|
||||||
@@ -177,6 +181,31 @@ url = "http://127.0.0.1:8765/"
|
|||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
|
### 可选:npm stdio Wrapper
|
||||||
|
|
||||||
|
如果 MCP 客户端更适合使用本地 `stdio` 命令,可以在启动 Cocos 编辑器内置服务后安装 npm wrapper:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install -g funplay-cocos-mcp
|
||||||
|
```
|
||||||
|
|
||||||
|
示例 MCP 客户端配置:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"funplay_cocos": {
|
||||||
|
"command": "funplay-cocos-mcp",
|
||||||
|
"env": {
|
||||||
|
"FUNPLAY_COCOS_MCP_URL": "http://127.0.0.1:8765/"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
这个 wrapper 会把 stdio MCP 流量桥接到 Cocos 内置 HTTP endpoint。也可以直接运行 `npx funplay-cocos-mcp --url http://127.0.0.1:8765/`。
|
||||||
|
|
||||||
### 4. 验证连接
|
### 4. 验证连接
|
||||||
|
|
||||||
先在 AI 客户端里试几个安全请求:
|
先在 AI 客户端里试几个安全请求:
|
||||||
@@ -188,6 +217,13 @@ url = "http://127.0.0.1:8765/"
|
|||||||
|
|
||||||
如果这些都正常返回,说明 MCP server、resources、prompts 和主执行工具已经连通。
|
如果这些都正常返回,说明 MCP server、resources、prompts 和主执行工具已经连通。
|
||||||
|
|
||||||
|
如果要排查本地传输链路,面板可以直接复制下面的命令,也可以手动执行:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl http://127.0.0.1:8765/health
|
||||||
|
curl http://127.0.0.1:8765/tools
|
||||||
|
```
|
||||||
|
|
||||||
### 5. 开始构建
|
### 5. 开始构建
|
||||||
|
|
||||||
可以在 AI 客户端里尝试:
|
可以在 AI 客户端里尝试:
|
||||||
@@ -199,6 +235,7 @@ url = "http://127.0.0.1:8765/"
|
|||||||
- 这是一个 **仅限 Editor** 的扩展,用于自动化 Cocos Creator,不会给最终游戏包添加运行时依赖。
|
- 这是一个 **仅限 Editor** 的扩展,用于自动化 Cocos Creator,不会给最终游戏包添加运行时依赖。
|
||||||
- MCP Server 默认监听 `http://127.0.0.1:8765/`。
|
- MCP Server 默认监听 `http://127.0.0.1:8765/`。
|
||||||
- 如果配置端口被占用,服务会自动回退到下一个可用端口,面板与一键客户端配置会使用实际运行端口。
|
- 如果配置端口被占用,服务会自动回退到下一个可用端口,面板与一键客户端配置会使用实际运行端口。
|
||||||
|
- `GET /health` 和 `GET /tools` 是只读调试端点,方便不用 MCP 客户端也能快速检查本地服务。
|
||||||
- 默认 `core` profile 暴露 34 个高频工具;如果需要完整工具集,可在面板切到 `full`,暴露全部 89 个工具;也可以用 `custom` 按分类或工具名增删。
|
- 默认 `core` profile 暴露 34 个高频工具;如果需要完整工具集,可在面板切到 `full`,暴露全部 89 个工具;也可以用 `custom` 按分类或工具名增删。
|
||||||
- 面板提供手动更新检查,会对比当前安装版本和 GitHub 最新 Release。
|
- 面板提供手动更新检查,会对比当前安装版本和 GitHub 最新 Release。
|
||||||
- Streamable HTTP 响应已补齐 MCP 传输层要求,包括 `Accept`、`MCP-Protocol-Version`、JSON-RPC notification/response,以及可选 `Mcp-Session-Id` session。
|
- Streamable HTTP 响应已补齐 MCP 传输层要求,包括 `Accept`、`MCP-Protocol-Version`、JSON-RPC notification/response,以及可选 `Mcp-Session-Id` session。
|
||||||
@@ -222,7 +259,7 @@ url = "http://127.0.0.1:8765/"
|
|||||||
- **89 个内置工具** — 覆盖场景层级、编辑器状态、选择工作流、Prefab、资产、项目指令、UI 创建、组件、文件、日志、脚本诊断、截图、运行态控制和输入模拟
|
- **89 个内置工具** — 覆盖场景层级、编辑器状态、选择工作流、Prefab、资产、项目指令、UI 创建、组件、文件、日志、脚本诊断、截图、运行态控制和输入模拟
|
||||||
- **统一主工具** — `execute_javascript` 同时支持 `scene` 和 `editor` 两种上下文
|
- **统一主工具** — `execute_javascript` 同时支持 `scene` 和 `editor` 两种上下文
|
||||||
- **Resources 与 Prompts** — 实时项目/日志资源,以及脚本修复、场景验证、可玩原型等可复用工作流
|
- **Resources 与 Prompts** — 实时项目/日志资源,以及脚本修复、场景验证、可玩原型等可复用工作流
|
||||||
- **Cocos 图形面板** — `Funplay > MCP Server` 提供服务管理、更新检查、工具暴露和 MCP 客户端配置
|
- **Cocos 图形面板** — `Funplay > MCP Server` 提供服务管理、更新检查、工具暴露、最近活动、日志、curl 排障和 MCP 客户端配置
|
||||||
- **截图与输入支持** — 支持编辑器/场景/Game/Preview 截图,以及 Electron 级鼠标键盘事件
|
- **截图与输入支持** — 支持编辑器/场景/Game/Preview 截图,以及 Electron 级鼠标键盘事件
|
||||||
- **厂商无关** — 兼容任意支持 HTTP JSON-RPC MCP 的 AI 客户端
|
- **厂商无关** — 兼容任意支持 HTTP JSON-RPC MCP 的 AI 客户端
|
||||||
|
|
||||||
@@ -249,6 +286,8 @@ Funplay MCP for Cocos 延续 Funplay MCP for Unity 的设计原则,并针对 C
|
|||||||
- **Prompts** — `fix_script_errors`、`create_playable_prototype`、`scene_validation`、`auto_wire_scene`
|
- **Prompts** — `fix_script_errors`、`create_playable_prototype`、`scene_validation`、`auto_wire_scene`
|
||||||
- **Resources** — 项目上下文、场景摘要、当前选择、脚本诊断、资产选择、日志和 MCP 交互历史
|
- **Resources** — 项目上下文、场景摘要、当前选择、脚本诊断、资产选择、日志和 MCP 交互历史
|
||||||
|
|
||||||
|
自动生成的工具参考文档见 [docs/TOOLS.md](./docs/TOOLS.md),里面包含工具分类、profile 和读写/变更提示。
|
||||||
|
|
||||||
当前默认 `core` 工具集刻意保持精简,只包含:`execute_javascript`、`execute_scene_script`、`execute_editor_script`、`get_editor_state`、`get_tool_catalog`、`check_for_updates`、`get_selection`、`list_project_instructions`、`read_project_instruction`、`set_selection`、`get_project_info`、`get_scene_info`、`get_hierarchy`、`list_scenes`、`open_scene`、`inspect_prefab`、`validate_prefab_references`、`inspect_prefab_instance`、`list_assets`、`inspect_asset`、`open_asset`、`select_asset`、`run_script_diagnostics`、`get_recent_logs`、`search_project_logs`、`clear_logs`、`validate_scene`、`get_performance_snapshot`、`get_script_diagnostic_context`、`get_runtime_state`、`capture_editor_screenshot`、`capture_scene_screenshot`、`capture_preview_screenshot`、`list_editor_windows`。
|
当前默认 `core` 工具集刻意保持精简,只包含:`execute_javascript`、`execute_scene_script`、`execute_editor_script`、`get_editor_state`、`get_tool_catalog`、`check_for_updates`、`get_selection`、`list_project_instructions`、`read_project_instruction`、`set_selection`、`get_project_info`、`get_scene_info`、`get_hierarchy`、`list_scenes`、`open_scene`、`inspect_prefab`、`validate_prefab_references`、`inspect_prefab_instance`、`list_assets`、`inspect_asset`、`open_asset`、`select_asset`、`run_script_diagnostics`、`get_recent_logs`、`search_project_logs`、`clear_logs`、`validate_scene`、`get_performance_snapshot`、`get_script_diagnostic_context`、`get_runtime_state`、`capture_editor_screenshot`、`capture_scene_screenshot`、`capture_preview_screenshot`、`list_editor_windows`。
|
||||||
|
|
||||||
## 内置 Resources
|
## 内置 Resources
|
||||||
@@ -358,14 +397,38 @@ Cocos Creator Extension
|
|||||||
└─ server, resources, prompts, tool registry
|
└─ server, resources, prompts, tool registry
|
||||||
```
|
```
|
||||||
|
|
||||||
服务使用 MCP 风格的 HTTP JSON-RPC 2.0,支持 tools、resources、resource templates、prompts 和 health check。
|
服务使用 MCP 风格的 HTTP JSON-RPC 2.0,支持 tools、resources、resource templates、prompts、health check 和只读 `/tools` 调试端点。
|
||||||
|
|
||||||
## 开发
|
## 开发
|
||||||
|
|
||||||
发布改动前可以跑语法检查:
|
发布改动前可以跑检查:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm run check
|
npm run check
|
||||||
|
npm test
|
||||||
|
npm run docs:check
|
||||||
|
npm run release:check
|
||||||
|
npm run pack:dry-run
|
||||||
|
```
|
||||||
|
|
||||||
|
修改 `lib/tool-registry.js` 后可以重新生成工具参考:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run docs:generate
|
||||||
|
```
|
||||||
|
|
||||||
|
生成可上传到 GitHub Release 的扩展包:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run release:package
|
||||||
|
```
|
||||||
|
|
||||||
|
产物会写入 `releases/<version>/`,包含 zip、manifest、checksum 和 release README。完整流程见 [RELEASE_WORKFLOW.md](./RELEASE_WORKFLOW.md) 和 [RELEASE_CHECKLIST.md](./RELEASE_CHECKLIST.md)。
|
||||||
|
|
||||||
|
发布 MCP Registry 前可以验证元数据:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run registry:validate
|
||||||
```
|
```
|
||||||
|
|
||||||
## 协议
|
## 协议
|
||||||
|
|||||||
@@ -0,0 +1,95 @@
|
|||||||
|
# Release Checklist
|
||||||
|
|
||||||
|
Use this checklist before publishing a new release of Funplay MCP for Cocos.
|
||||||
|
|
||||||
|
## 1. Repository Hygiene
|
||||||
|
|
||||||
|
- [ ] `git status` contains only intended release changes
|
||||||
|
- [ ] No tracked local junk is present (`.DS_Store`, `.idea/`, `node_modules/`, `Library/`, `Temp/`, `dist/`, `build/`)
|
||||||
|
- [ ] `package.json` version matches the intended release
|
||||||
|
- [ ] `CHANGELOG.md` includes the release notes for the target version
|
||||||
|
- [ ] `README.md` and `README_CN.md` match the current product behavior
|
||||||
|
|
||||||
|
## 2. Automated Verification
|
||||||
|
|
||||||
|
- [ ] `npm run check` passes
|
||||||
|
- [ ] `npm test` passes
|
||||||
|
- [ ] `npm run docs:check` passes
|
||||||
|
- [ ] `npm run release:check` passes
|
||||||
|
- [ ] `npm run pack:dry-run` passes
|
||||||
|
- [ ] `npm run registry:validate` passes when `mcp-publisher` is available
|
||||||
|
- [ ] `npm run release:package` creates `releases/<version>/`
|
||||||
|
- [ ] `shasum -a 256 -c releases/<version>/SHA256SUMS.txt` passes
|
||||||
|
|
||||||
|
## 3. Package Contents
|
||||||
|
|
||||||
|
- [ ] The zip is named `Funplay.CocosMcp.v<version>.zip`
|
||||||
|
- [ ] The zip contains a single top-level `funplay-cocos-mcp/` folder
|
||||||
|
- [ ] The zip contains runtime files: `package.json`, `browser.js`, `scene.js`, `panel/`, and `lib/`
|
||||||
|
- [ ] The zip contains stdio wrapper metadata: `bin/funplay-cocos-mcp.js` and `server.json`
|
||||||
|
- [ ] The zip includes docs: `README.md`, `README_CN.md`, `CHANGELOG.md`, `CONTRIBUTING.md`, and `LICENSE`
|
||||||
|
- [ ] The zip does not contain `.git/`, `.github/`, `.DS_Store`, `node_modules/`, `Library/`, `Temp/`, `dist/`, `build/`, `test/`, or `scripts/`
|
||||||
|
- [ ] `release-manifest.json` references the correct GitHub download URL
|
||||||
|
- [ ] `SHA256SUMS.txt` includes the zip, manifest, and release README
|
||||||
|
|
||||||
|
## 4. Cocos Smoke Test
|
||||||
|
|
||||||
|
- [ ] Test in a clean Cocos Creator `3.8+` project
|
||||||
|
- [ ] Install from the generated zip into `<project>/extensions/funplay-cocos-mcp`
|
||||||
|
- [ ] Restart Cocos Creator or reload extensions
|
||||||
|
- [ ] Open `Funplay > MCP Server`
|
||||||
|
- [ ] Start the MCP server successfully
|
||||||
|
- [ ] If the configured port is already in use, verify automatic fallback is reported clearly
|
||||||
|
- [ ] Run a read-only tool such as `get_project_info`
|
||||||
|
- [ ] Run a scene inspection tool such as `get_scene_info`
|
||||||
|
- [ ] Run a screenshot tool when the editor has a visible scene or preview
|
||||||
|
- [ ] Verify interaction logs appear in the MCP Server panel
|
||||||
|
|
||||||
|
## 5. MCP Client Verification
|
||||||
|
|
||||||
|
- [ ] Verify at least one primary client can connect (`Claude Code`, `Cursor`, `Codex`, etc.)
|
||||||
|
- [ ] Confirm `tools/list` returns the expected `core` profile tools
|
||||||
|
- [ ] Confirm a tool call succeeds end-to-end from the external client
|
||||||
|
- [ ] Verify one-click config output still matches the documented config snippets
|
||||||
|
- [ ] Verify the stdio wrapper can connect with `funplay-cocos-mcp --url http://127.0.0.1:8765/`
|
||||||
|
|
||||||
|
## 6. npm And MCP Registry Readiness
|
||||||
|
|
||||||
|
- [ ] `package.json` `name` is `funplay-cocos-mcp`
|
||||||
|
- [ ] `package.json` `version` matches `server.json` version
|
||||||
|
- [ ] `package.json` `mcpName` matches `server.json` name
|
||||||
|
- [ ] `package.json` `bin.funplay-cocos-mcp` points to `bin/funplay-cocos-mcp.js`
|
||||||
|
- [ ] `server.json` npm package identifier and version match `package.json`
|
||||||
|
- [ ] `server.json` npm transport type is `stdio`
|
||||||
|
- [ ] npm package dry-run includes `bin/`, `lib/`, `panel/`, `browser.js`, `scene.js`, and `server.json`
|
||||||
|
- [ ] npm credentials are available for `npm publish`
|
||||||
|
- [ ] MCP Registry credentials are available for `mcp-publisher publish`
|
||||||
|
|
||||||
|
## 7. GitHub Release Readiness
|
||||||
|
|
||||||
|
- [ ] CI passes on `main`
|
||||||
|
- [ ] Release commit message is `Release v<version>`
|
||||||
|
- [ ] Tag is `v<version>`
|
||||||
|
- [ ] GitHub Release title is `v<version>`
|
||||||
|
- [ ] GitHub Release includes the zip, manifest, checksum file, and release README
|
||||||
|
- [ ] Public GitHub Release page renders the release notes and assets correctly
|
||||||
|
|
||||||
|
## 8. Publish
|
||||||
|
|
||||||
|
- [ ] Commit the release changes
|
||||||
|
- [ ] Create and push the release tag
|
||||||
|
- [ ] Create or update the GitHub Release
|
||||||
|
- [ ] Upload generated release assets
|
||||||
|
- [ ] Verify the GitHub Release asset list
|
||||||
|
- [ ] Publish npm package with `npm publish`
|
||||||
|
- [ ] Verify npm package with `npm view funplay-cocos-mcp@<version>`
|
||||||
|
- [ ] Publish MCP Registry metadata with `mcp-publisher publish server.json`
|
||||||
|
- [ ] Verify MCP Registry latest and specific-version endpoints
|
||||||
|
|
||||||
|
## 9. Post-Release
|
||||||
|
|
||||||
|
- [ ] Re-test installation from the public GitHub Release zip
|
||||||
|
- [ ] Re-test stdio wrapper installation from npm
|
||||||
|
- [ ] Check the update checker reports the new latest version
|
||||||
|
- [ ] Check README install instructions and download links
|
||||||
|
- [ ] Announce the release where appropriate
|
||||||
@@ -0,0 +1,332 @@
|
|||||||
|
# Funplay Cocos MCP Release Workflow
|
||||||
|
|
||||||
|
This document records the release workflow for publishing Funplay MCP for Cocos to:
|
||||||
|
|
||||||
|
- Git tags
|
||||||
|
- GitHub Releases
|
||||||
|
- Downloadable Cocos Creator extension zip packages
|
||||||
|
- npm stdio wrapper package
|
||||||
|
- Official MCP Registry
|
||||||
|
|
||||||
|
## Published Identity
|
||||||
|
|
||||||
|
- GitHub repository: `https://github.com/FunplayAI/funplay-cocos-mcp`
|
||||||
|
- Git tag format: `v<version>`
|
||||||
|
- GitHub Release tag: `v<version>`
|
||||||
|
- Extension package asset: `Funplay.CocosMcp.v<version>.zip`
|
||||||
|
- Cocos extension folder name inside the zip: `funplay-cocos-mcp`
|
||||||
|
- npm package id: `funplay-cocos-mcp`
|
||||||
|
- npm command: `funplay-cocos-mcp`
|
||||||
|
- MCP Registry server name: `io.github.FunplayAI/funplay-cocos-mcp`
|
||||||
|
- Default local MCP endpoint: `http://127.0.0.1:8765/`
|
||||||
|
|
||||||
|
## Version Alignment Rule
|
||||||
|
|
||||||
|
Keep these versions aligned:
|
||||||
|
|
||||||
|
- `package.json` `version`
|
||||||
|
- `CHANGELOG.md` release section
|
||||||
|
- Git tag `v<version>`
|
||||||
|
- GitHub Release `v<version>`
|
||||||
|
- `releases/<version>/release-manifest.json` `version`
|
||||||
|
- Release zip filename `Funplay.CocosMcp.v<version>.zip`
|
||||||
|
- `server.json` top-level version
|
||||||
|
- `server.json` npm package version
|
||||||
|
|
||||||
|
Example:
|
||||||
|
|
||||||
|
- `package.json`: `0.3.1`
|
||||||
|
- Git tag: `v0.3.1`
|
||||||
|
- GitHub Release: `v0.3.1`
|
||||||
|
- Release asset: `Funplay.CocosMcp.v0.3.1.zip`
|
||||||
|
- npm package: `funplay-cocos-mcp@0.3.1`
|
||||||
|
- MCP Registry: `0.3.1`
|
||||||
|
|
||||||
|
## Files To Update For A New Release
|
||||||
|
|
||||||
|
Update:
|
||||||
|
|
||||||
|
1. `package.json`
|
||||||
|
- `"version": "<version>"`
|
||||||
|
2. `CHANGELOG.md`
|
||||||
|
- add a dated release notes block
|
||||||
|
3. `server.json`
|
||||||
|
- top-level `"version"`
|
||||||
|
- npm package `"version"`
|
||||||
|
4. `docs/TOOLS.md`
|
||||||
|
- regenerate with `npm run docs:generate` after tool registry changes
|
||||||
|
|
||||||
|
Optional but recommended:
|
||||||
|
|
||||||
|
5. `README.md`
|
||||||
|
6. `README_CN.md`
|
||||||
|
7. GitHub Release notes text
|
||||||
|
|
||||||
|
## Release Steps
|
||||||
|
|
||||||
|
### 1. Verify Working Tree
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git status --short --branch
|
||||||
|
```
|
||||||
|
|
||||||
|
The tree should contain only intentional release changes.
|
||||||
|
|
||||||
|
### 2. Update Versions And Notes
|
||||||
|
|
||||||
|
Update `package.json` and `CHANGELOG.md`.
|
||||||
|
|
||||||
|
Use semantic versions such as `0.3.1`, and keep release headings in this format:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
## [0.3.1] - 2026-05-20
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Run Release Verification
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run release:verify
|
||||||
|
```
|
||||||
|
|
||||||
|
This runs:
|
||||||
|
|
||||||
|
- JavaScript syntax checks
|
||||||
|
- Node.js tests
|
||||||
|
- generated tool documentation validation
|
||||||
|
- release metadata validation
|
||||||
|
- npm package dry-run validation
|
||||||
|
- release package generation
|
||||||
|
|
||||||
|
The generated local artifacts are written to:
|
||||||
|
|
||||||
|
```text
|
||||||
|
releases/<version>/
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected contents:
|
||||||
|
|
||||||
|
- `Funplay.CocosMcp.v<version>.zip`
|
||||||
|
- `release-manifest.json`
|
||||||
|
- `SHA256SUMS.txt`
|
||||||
|
- `README.md`
|
||||||
|
|
||||||
|
### 4. Inspect The Package
|
||||||
|
|
||||||
|
The release script validates that every archive path stays under:
|
||||||
|
|
||||||
|
```text
|
||||||
|
funplay-cocos-mcp/
|
||||||
|
```
|
||||||
|
|
||||||
|
The package must not contain local/build content such as:
|
||||||
|
|
||||||
|
- `.git/`
|
||||||
|
- `.github/`
|
||||||
|
- `.DS_Store`
|
||||||
|
- `node_modules/`
|
||||||
|
- `Library/`
|
||||||
|
- `Temp/`
|
||||||
|
- `dist/`
|
||||||
|
- `build/`
|
||||||
|
- `test/`
|
||||||
|
- `scripts/`
|
||||||
|
|
||||||
|
Verify checksums:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd releases/<version>
|
||||||
|
shasum -a 256 -c SHA256SUMS.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.5 Validate npm And MCP Registry Metadata
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run pack:dry-run
|
||||||
|
npm run registry:validate
|
||||||
|
```
|
||||||
|
|
||||||
|
The npm package must include the stdio wrapper command:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx --yes ./funplay-cocos-mcp-<version>.tgz --version
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. Commit, Tag, And Push
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add .
|
||||||
|
git commit -m "Release v<version>"
|
||||||
|
git tag v<version>
|
||||||
|
git push origin main
|
||||||
|
git push origin v<version>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6. Create GitHub Release
|
||||||
|
|
||||||
|
Regenerate the final release artifacts from the tagged clean commit:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run release:package -- --strict-tag
|
||||||
|
```
|
||||||
|
|
||||||
|
If creating a new release:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
gh release create v<version> \
|
||||||
|
-R FunplayAI/funplay-cocos-mcp \
|
||||||
|
--title "v<version>" \
|
||||||
|
--notes-file /path/to/release-notes.md \
|
||||||
|
releases/<version>/Funplay.CocosMcp.v<version>.zip \
|
||||||
|
releases/<version>/release-manifest.json \
|
||||||
|
releases/<version>/SHA256SUMS.txt \
|
||||||
|
releases/<version>/README.md
|
||||||
|
```
|
||||||
|
|
||||||
|
If the release already exists and only assets need to be replaced:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
gh release upload v<version> \
|
||||||
|
-R FunplayAI/funplay-cocos-mcp \
|
||||||
|
--clobber \
|
||||||
|
releases/<version>/Funplay.CocosMcp.v<version>.zip \
|
||||||
|
releases/<version>/release-manifest.json \
|
||||||
|
releases/<version>/SHA256SUMS.txt \
|
||||||
|
releases/<version>/README.md
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7. Verify GitHub Release
|
||||||
|
|
||||||
|
```bash
|
||||||
|
gh release view v<version> \
|
||||||
|
-R FunplayAI/funplay-cocos-mcp \
|
||||||
|
--json url,assets,isDraft,isPrerelease,publishedAt
|
||||||
|
```
|
||||||
|
|
||||||
|
Confirm the release has all four assets.
|
||||||
|
|
||||||
|
### 8. Publish To npm
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm publish
|
||||||
|
```
|
||||||
|
|
||||||
|
Verify the published package:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm view funplay-cocos-mcp@<version> version bin mcpName
|
||||||
|
```
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
|
||||||
|
- `package.json` `mcpName` must match `server.json` `name`.
|
||||||
|
- If `npm publish` returns `ENEEDAUTH`, run `npm adduser` with a publishing account and retry.
|
||||||
|
- If the package name already exists under another owner, choose a scoped package name and update both `package.json` and `server.json`.
|
||||||
|
|
||||||
|
### 9. Publish To MCP Registry
|
||||||
|
|
||||||
|
Log in if needed:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mcp-publisher login github
|
||||||
|
```
|
||||||
|
|
||||||
|
Publish:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mcp-publisher publish server.json
|
||||||
|
```
|
||||||
|
|
||||||
|
Verify latest:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl "https://registry.modelcontextprotocol.io/v0.1/servers?search=io.github.FunplayAI/funplay-cocos-mcp&version=latest"
|
||||||
|
```
|
||||||
|
|
||||||
|
Check a specific version:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl "https://registry.modelcontextprotocol.io/v0.1/servers/io.github.FunplayAI%2Ffunplay-cocos-mcp/versions/<version>"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 10. Post-Release Smoke Test
|
||||||
|
|
||||||
|
Test the package from the public GitHub Release:
|
||||||
|
|
||||||
|
1. Download `Funplay.CocosMcp.v<version>.zip`.
|
||||||
|
2. Unzip it.
|
||||||
|
3. Move `funplay-cocos-mcp` into a Cocos project `extensions/` directory.
|
||||||
|
4. Restart Cocos Creator or reload extensions.
|
||||||
|
5. Open `Funplay > MCP Server`.
|
||||||
|
6. Start the MCP server.
|
||||||
|
7. Connect an MCP client and call `get_project_info`.
|
||||||
|
8. Install the npm wrapper with `npm install -g funplay-cocos-mcp`.
|
||||||
|
9. Connect an MCP client through the `funplay-cocos-mcp` command and call `tools/list`.
|
||||||
|
|
||||||
|
## Current Verification Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run release:verify
|
||||||
|
npm run registry:validate
|
||||||
|
gh release view v<version> -R FunplayAI/funplay-cocos-mcp --json url,assets
|
||||||
|
npm view funplay-cocos-mcp@<version> version bin mcpName
|
||||||
|
```
|
||||||
|
|
||||||
|
## Common Failure Cases
|
||||||
|
|
||||||
|
### Release validation says the changelog section is missing
|
||||||
|
|
||||||
|
Cause:
|
||||||
|
|
||||||
|
- `CHANGELOG.md` does not contain `## [<version>] - YYYY-MM-DD`.
|
||||||
|
|
||||||
|
Fix:
|
||||||
|
|
||||||
|
- Add a dated release section before packaging.
|
||||||
|
|
||||||
|
### `zip` command is missing
|
||||||
|
|
||||||
|
Cause:
|
||||||
|
|
||||||
|
- The local environment does not have the `zip` CLI installed.
|
||||||
|
|
||||||
|
Fix:
|
||||||
|
|
||||||
|
- Install `zip`, then rerun `npm run release:package`.
|
||||||
|
|
||||||
|
### GitHub Release upload replaces the wrong assets
|
||||||
|
|
||||||
|
Cause:
|
||||||
|
|
||||||
|
- The version directory or release tag does not match `package.json` version.
|
||||||
|
|
||||||
|
Fix:
|
||||||
|
|
||||||
|
- Rerun `npm run release:check`.
|
||||||
|
- Confirm the command uses `releases/<version>/` and `v<version>`.
|
||||||
|
|
||||||
|
### npm `ENEEDAUTH`
|
||||||
|
|
||||||
|
Cause:
|
||||||
|
|
||||||
|
- The local machine is not logged in to npm.
|
||||||
|
|
||||||
|
Fix:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm adduser
|
||||||
|
npm publish
|
||||||
|
```
|
||||||
|
|
||||||
|
### MCP Registry `Package validation failed`
|
||||||
|
|
||||||
|
Cause:
|
||||||
|
|
||||||
|
- npm package has not been published yet.
|
||||||
|
- `package.json` `mcpName` does not match `server.json` `name`.
|
||||||
|
- `server.json` package version does not match the npm package version.
|
||||||
|
|
||||||
|
Fix:
|
||||||
|
|
||||||
|
- Publish the npm package first.
|
||||||
|
- Rerun `npm run release:check`.
|
||||||
|
- Rerun `npm run registry:validate`.
|
||||||
Executable
+356
@@ -0,0 +1,356 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const fs = require('fs');
|
||||||
|
const http = require('http');
|
||||||
|
const https = require('https');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
const DEFAULT_URL = 'http://127.0.0.1:8765/';
|
||||||
|
const DEFAULT_TIMEOUT_SECONDS = 120;
|
||||||
|
const ACCEPT_HEADER = 'application/json, text/event-stream';
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const args = process.argv.slice(2);
|
||||||
|
|
||||||
|
if (hasFlag(args, '--help') || hasFlag(args, '-h')) {
|
||||||
|
printHelp();
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasFlag(args, '--version')) {
|
||||||
|
console.error(`funplay-cocos-mcp ${readPackageVersion()}`);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
const urlText = getOption(args, '--url')
|
||||||
|
|| process.env.FUNPLAY_COCOS_MCP_URL
|
||||||
|
|| DEFAULT_URL;
|
||||||
|
const timeoutText = getOption(args, '--timeout-seconds')
|
||||||
|
|| process.env.FUNPLAY_COCOS_MCP_TIMEOUT_SECONDS
|
||||||
|
|| String(DEFAULT_TIMEOUT_SECONDS);
|
||||||
|
|
||||||
|
const endpoint = parseEndpoint(urlText);
|
||||||
|
if (!endpoint) {
|
||||||
|
console.error(`Invalid --url value: ${urlText}`);
|
||||||
|
return 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
const timeoutSeconds = Number.parseInt(timeoutText, 10);
|
||||||
|
if (!Number.isFinite(timeoutSeconds) || timeoutSeconds <= 0) {
|
||||||
|
console.error(`Invalid timeout value: ${timeoutText}`);
|
||||||
|
return 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.error(`[Funplay Cocos MCP] Bridging stdio to ${endpoint.href}`);
|
||||||
|
await bridgeStdioToHttp({
|
||||||
|
input: process.stdin,
|
||||||
|
output: process.stdout,
|
||||||
|
endpoint,
|
||||||
|
timeoutMs: timeoutSeconds * 1000
|
||||||
|
});
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function bridgeStdioToHttp({ input, output, endpoint, timeoutMs }) {
|
||||||
|
let sessionId = '';
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
const message = await readMessage(input);
|
||||||
|
if (message === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let parsed = null;
|
||||||
|
let requestId = null;
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(message);
|
||||||
|
requestId = getRequestId(parsed);
|
||||||
|
} catch (error) {
|
||||||
|
await writeJsonRpcError(output, null, -32700, 'Parse error');
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await postJsonRpc(endpoint, message, {
|
||||||
|
timeoutMs,
|
||||||
|
sessionId
|
||||||
|
});
|
||||||
|
|
||||||
|
const nextSessionId = response.headers['mcp-session-id'];
|
||||||
|
if (typeof nextSessionId === 'string' && nextSessionId) {
|
||||||
|
sessionId = nextSessionId;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.statusCode >= 200 && response.statusCode < 300 && response.body.trim()) {
|
||||||
|
await writeMessage(output, response.body);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.statusCode >= 200 && response.statusCode < 300 && isNotification(parsed)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (requestId !== null) {
|
||||||
|
const messageText = response.body.trim()
|
||||||
|
? `Cocos MCP server returned HTTP ${response.statusCode}: ${response.body}`
|
||||||
|
: `Cocos MCP server returned HTTP ${response.statusCode}.`;
|
||||||
|
await writeJsonRpcError(output, requestId, -32000, messageText);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`[Funplay Cocos MCP] ${error.message}`);
|
||||||
|
if (requestId !== null) {
|
||||||
|
await writeJsonRpcError(output, requestId, -32000, `Proxy transport error: ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function postJsonRpc(endpoint, body, { timeoutMs, sessionId }) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const client = endpoint.protocol === 'https:' ? https : http;
|
||||||
|
const request = client.request(
|
||||||
|
endpoint,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
Accept: ACCEPT_HEADER,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Content-Length': Buffer.byteLength(body),
|
||||||
|
...(sessionId ? { 'Mcp-Session-Id': sessionId } : {})
|
||||||
|
},
|
||||||
|
timeout: timeoutMs
|
||||||
|
},
|
||||||
|
(response) => {
|
||||||
|
const chunks = [];
|
||||||
|
response.on('data', (chunk) => chunks.push(chunk));
|
||||||
|
response.on('end', () => {
|
||||||
|
resolve({
|
||||||
|
statusCode: response.statusCode || 0,
|
||||||
|
headers: response.headers,
|
||||||
|
body: Buffer.concat(chunks).toString('utf8')
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
request.on('timeout', () => {
|
||||||
|
request.destroy(new Error(`HTTP request timed out after ${timeoutMs / 1000} seconds.`));
|
||||||
|
});
|
||||||
|
request.on('error', reject);
|
||||||
|
request.end(body);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseEndpoint(value) {
|
||||||
|
try {
|
||||||
|
const endpoint = new URL(value);
|
||||||
|
if (endpoint.protocol !== 'http:' && endpoint.protocol !== 'https:') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return endpoint;
|
||||||
|
} catch (error) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRequestId(value) {
|
||||||
|
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return Object.prototype.hasOwnProperty.call(value, 'id') ? value.id : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isNotification(value) {
|
||||||
|
return Boolean(value && typeof value === 'object' && !Array.isArray(value) && value.method && !Object.prototype.hasOwnProperty.call(value, 'id'));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function writeJsonRpcError(output, id, code, message) {
|
||||||
|
await writeMessage(output, JSON.stringify({
|
||||||
|
jsonrpc: '2.0',
|
||||||
|
id,
|
||||||
|
error: {
|
||||||
|
code,
|
||||||
|
message
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readMessage(input) {
|
||||||
|
const headers = new Map();
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
const line = await readHeaderLine(input);
|
||||||
|
if (line === null) {
|
||||||
|
return headers.size === 0 ? null : Promise.reject(new Error('Unexpected EOF while reading MCP headers.'));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (line === '') {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
const separator = line.indexOf(':');
|
||||||
|
if (separator <= 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
headers.set(line.slice(0, separator).trim().toLowerCase(), line.slice(separator + 1).trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
const contentLength = Number.parseInt(headers.get('content-length') || '', 10);
|
||||||
|
if (!Number.isFinite(contentLength) || contentLength < 0) {
|
||||||
|
throw new Error('Missing Content-Length header.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = await readExact(input, contentLength);
|
||||||
|
return payload.toString('utf8');
|
||||||
|
}
|
||||||
|
|
||||||
|
function readHeaderLine(input) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const chunks = [];
|
||||||
|
|
||||||
|
function cleanup() {
|
||||||
|
input.off('readable', onReadable);
|
||||||
|
input.off('end', onEnd);
|
||||||
|
input.off('error', onError);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onReadable() {
|
||||||
|
let byte;
|
||||||
|
while ((byte = input.read(1)) !== null) {
|
||||||
|
if (byte[0] === 0x0a) {
|
||||||
|
cleanup();
|
||||||
|
if (chunks.length > 0 && chunks[chunks.length - 1][0] === 0x0d) {
|
||||||
|
chunks.pop();
|
||||||
|
}
|
||||||
|
resolve(Buffer.concat(chunks).toString('ascii'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
chunks.push(byte);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onEnd() {
|
||||||
|
cleanup();
|
||||||
|
resolve(chunks.length === 0 ? null : Buffer.concat(chunks).toString('ascii'));
|
||||||
|
}
|
||||||
|
|
||||||
|
function onError(error) {
|
||||||
|
cleanup();
|
||||||
|
reject(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
input.on('readable', onReadable);
|
||||||
|
input.once('end', onEnd);
|
||||||
|
input.once('error', onError);
|
||||||
|
onReadable();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function readExact(input, length) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const chunks = [];
|
||||||
|
let remaining = length;
|
||||||
|
|
||||||
|
function cleanup() {
|
||||||
|
input.off('readable', onReadable);
|
||||||
|
input.off('end', onEnd);
|
||||||
|
input.off('error', onError);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onReadable() {
|
||||||
|
while (remaining > 0) {
|
||||||
|
const chunk = input.read(remaining);
|
||||||
|
if (chunk === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
chunks.push(chunk);
|
||||||
|
remaining -= chunk.length;
|
||||||
|
}
|
||||||
|
cleanup();
|
||||||
|
resolve(Buffer.concat(chunks, length));
|
||||||
|
}
|
||||||
|
|
||||||
|
function onEnd() {
|
||||||
|
cleanup();
|
||||||
|
reject(new Error('Unexpected EOF while reading MCP payload.'));
|
||||||
|
}
|
||||||
|
|
||||||
|
function onError(error) {
|
||||||
|
cleanup();
|
||||||
|
reject(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
input.on('readable', onReadable);
|
||||||
|
input.once('end', onEnd);
|
||||||
|
input.once('error', onError);
|
||||||
|
onReadable();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeMessage(output, json) {
|
||||||
|
const payload = Buffer.from(json, 'utf8');
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
output.write(`Content-Length: ${payload.length}\r\n\r\n`, 'ascii', (headerError) => {
|
||||||
|
if (headerError) {
|
||||||
|
reject(headerError);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
output.write(payload, (payloadError) => {
|
||||||
|
if (payloadError) {
|
||||||
|
reject(payloadError);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasFlag(args, name) {
|
||||||
|
return args.some((arg) => arg.toLowerCase() === name.toLowerCase());
|
||||||
|
}
|
||||||
|
|
||||||
|
function getOption(args, name) {
|
||||||
|
for (let i = 0; i < args.length - 1; i += 1) {
|
||||||
|
if (args[i].toLowerCase() === name.toLowerCase()) {
|
||||||
|
return args[i + 1];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function readPackageVersion() {
|
||||||
|
try {
|
||||||
|
const packagePath = path.resolve(__dirname, '..', 'package.json');
|
||||||
|
const packageJson = JSON.parse(fs.readFileSync(packagePath, 'utf8'));
|
||||||
|
return packageJson.version || '0.0.0';
|
||||||
|
} catch (error) {
|
||||||
|
return '0.0.0';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function printHelp() {
|
||||||
|
console.error('funplay-cocos-mcp');
|
||||||
|
console.error('Bridges stdio MCP traffic to a local Cocos Creator HTTP MCP server.');
|
||||||
|
console.error();
|
||||||
|
console.error('Options:');
|
||||||
|
console.error(' --url <http://127.0.0.1:8765/> Cocos MCP HTTP endpoint.');
|
||||||
|
console.error(' --timeout-seconds <120> HTTP timeout per request.');
|
||||||
|
console.error(' --version Print the proxy version.');
|
||||||
|
console.error(' --help Show this help.');
|
||||||
|
console.error();
|
||||||
|
console.error('Environment:');
|
||||||
|
console.error(' FUNPLAY_COCOS_MCP_URL');
|
||||||
|
console.error(' FUNPLAY_COCOS_MCP_TIMEOUT_SECONDS');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (require.main === module) {
|
||||||
|
main().then((code) => {
|
||||||
|
process.exitCode = code;
|
||||||
|
}).catch((error) => {
|
||||||
|
console.error(`[Funplay Cocos MCP] ${error.message}`);
|
||||||
|
process.exitCode = 1;
|
||||||
|
});
|
||||||
|
}
|
||||||
+35
-2
@@ -4,7 +4,7 @@ const path = require('path');
|
|||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
const os = require('os');
|
const os = require('os');
|
||||||
const manifest = require('./package.json');
|
const manifest = require('./package.json');
|
||||||
const { configureTarget, getTargetStatuses } = require('./lib/client-config');
|
const { SERVER_NAME, buildTargets, configureTarget, getTargetStatuses } = require('./lib/client-config');
|
||||||
const { loadConfig, getProjectPath, getProjectName, getCocosVersion } = require('./lib/config');
|
const { loadConfig, getProjectPath, getProjectName, getCocosVersion } = require('./lib/config');
|
||||||
const { McpServer } = require('./lib/server');
|
const { McpServer } = require('./lib/server');
|
||||||
const { createToolRegistry } = require('./lib/tool-registry');
|
const { createToolRegistry } = require('./lib/tool-registry');
|
||||||
@@ -304,7 +304,21 @@ class ExtensionService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
getClientConfig() {
|
getClientConfig() {
|
||||||
const { url } = this.getEffectiveServerConnection();
|
const effective = this.getEffectiveServerConnection();
|
||||||
|
const { url } = effective;
|
||||||
|
const targetConfig = {
|
||||||
|
...this.config,
|
||||||
|
host: effective.host,
|
||||||
|
port: effective.port,
|
||||||
|
};
|
||||||
|
const targets = buildTargets(targetConfig).map((target) => ({
|
||||||
|
id: target.id,
|
||||||
|
name: target.name,
|
||||||
|
configPath: target.configPath,
|
||||||
|
isToml: Boolean(target.isToml),
|
||||||
|
preview: this.formatClientTargetPreview(target),
|
||||||
|
}));
|
||||||
|
const baseUrl = url.replace(/\/$/, '');
|
||||||
return {
|
return {
|
||||||
url,
|
url,
|
||||||
codex: `[mcp_servers.funplay_cocos]\nurl = "${url}"\n`,
|
codex: `[mcp_servers.funplay_cocos]\nurl = "${url}"\n`,
|
||||||
@@ -315,9 +329,28 @@ class ExtensionService {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
}, null, 2),
|
}, null, 2),
|
||||||
|
targets,
|
||||||
|
curl: {
|
||||||
|
health: `curl ${baseUrl}/health`,
|
||||||
|
tools: `curl ${baseUrl}/tools`,
|
||||||
|
catalog: `curl ${baseUrl}/tools?catalog=1`,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
formatClientTargetPreview(target) {
|
||||||
|
if (target.isToml) {
|
||||||
|
return `[mcp_servers.${SERVER_NAME}]\nurl = "${target.url}"\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rootKey = target.rootKey || 'mcpServers';
|
||||||
|
return JSON.stringify({
|
||||||
|
[rootKey]: {
|
||||||
|
[SERVER_NAME]: target.entry,
|
||||||
|
},
|
||||||
|
}, null, 2);
|
||||||
|
}
|
||||||
|
|
||||||
configureClient(targetId) {
|
configureClient(targetId) {
|
||||||
this.ensureRuntime();
|
this.ensureRuntime();
|
||||||
this.log('info', `Configuring MCP client target: ${targetId}`);
|
this.log('info', `Configuring MCP client target: ${targetId}`);
|
||||||
|
|||||||
+203
@@ -0,0 +1,203 @@
|
|||||||
|
# Tool Reference
|
||||||
|
|
||||||
|
<!-- This file is generated by `npm run docs:generate`. Do not edit by hand. -->
|
||||||
|
|
||||||
|
Generated from `lib/tool-registry.js`. The default `core` profile exposes 34 tools; the `full` profile exposes 89 tools.
|
||||||
|
|
||||||
|
## Profile Summary
|
||||||
|
|
||||||
|
| Profile | Tool Count | Purpose |
|
||||||
|
|---|---:|---|
|
||||||
|
| `core` | 34 | Focused default surface for common editor automation. |
|
||||||
|
| `full` | 89 | All built-in tools, including destructive and low-level helpers. |
|
||||||
|
|
||||||
|
## Core Tools
|
||||||
|
|
||||||
|
`capture_editor_screenshot`, `capture_preview_screenshot`, `capture_scene_screenshot`, `check_for_updates`, `clear_logs`, `execute_editor_script`, `execute_javascript`, `execute_scene_script`, `get_editor_state`, `get_hierarchy`, `get_performance_snapshot`, `get_project_info`, `get_recent_logs`, `get_runtime_state`, `get_scene_info`, `get_script_diagnostic_context`, `get_selection`, `get_tool_catalog`, `inspect_asset`, `inspect_prefab`, `inspect_prefab_instance`, `list_assets`, `list_editor_windows`, `list_project_instructions`, `list_scenes`, `open_asset`, `open_scene`, `read_project_instruction`, `run_script_diagnostics`, `search_project_logs`, `select_asset`, `set_selection`, `validate_prefab_references`, `validate_scene`
|
||||||
|
|
||||||
|
## Tools By Category
|
||||||
|
|
||||||
|
### Animation
|
||||||
|
|
||||||
|
| Tool | Profiles | Access | Description |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `add_animation_clip` | `full` | stateful | Add an AnimationClip asset to a node Animation component. |
|
||||||
|
| `list_animations` | `full` | read-only | [core] List Animation components in the active scene or under one node. |
|
||||||
|
| `play_animation` | `full` | stateful | [core] Play an Animation component clip on a node. |
|
||||||
|
| `stop_animation` | `full` | stateful | [core] Stop an Animation component clip on a node. |
|
||||||
|
|
||||||
|
### Assets
|
||||||
|
|
||||||
|
| Tool | Profiles | Access | Description |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `delete_asset` | `full` | mutating | Delete an asset from asset-db by uuid, db url, or path. |
|
||||||
|
| `inspect_asset` | `core`, `full` | read-only | [specialist] Inspect asset-db info, metadata, and serialized asset data by uuid or path. Prefer this when you need a precise structured asset read. |
|
||||||
|
| `list_assets` | `core`, `full` | read-only | [specialist] Query project assets from asset-db by pattern or asset type. Prefer this when you need exact asset discovery; otherwise use execute_javascript for broader automation. |
|
||||||
|
| `list_scenes` | `core`, `full` | read-only | [specialist] List scene assets in the project. Prefer this when you need exact scene discovery before opening one; otherwise stay in execute_javascript for broader workflows. |
|
||||||
|
| `open_asset` | `core`, `full` | stateful | [specialist] Open an asset inside Cocos Creator by uuid, db url, or path. Use this only when opening the asset itself is the explicit next step. |
|
||||||
|
| `open_scene` | `core`, `full` | stateful | [specialist] Open a scene asset in Cocos Creator by uuid, db url, or path. Use this when scene switching is the explicit goal; otherwise keep execute_javascript as the main planning tool. |
|
||||||
|
| `run_scene_asset` | `full` | mutating | Load a scene asset by uuid directly into the current runtime scene context. |
|
||||||
|
| `select_asset` | `core`, `full` | stateful | [specialist] Select an asset in the Cocos editor. Use this when editor selection state matters; otherwise keep execute_javascript as the primary workflow. |
|
||||||
|
|
||||||
|
### Camera
|
||||||
|
|
||||||
|
| Tool | Profiles | Access | Description |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `create_camera` | `full` | stateful | Create a Camera node in the active scene. |
|
||||||
|
| `list_cameras` | `full` | read-only | [core] List Camera components in the active scene. |
|
||||||
|
| `set_camera_properties` | `full` | mutating | Set selected Camera component properties. |
|
||||||
|
|
||||||
|
### Components
|
||||||
|
|
||||||
|
| Tool | Profiles | Access | Description |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `add_component` | `full` | stateful | Add a component to a node by component class name. |
|
||||||
|
| `inspect_component` | `full` | read-only | [core] Inspect a component attached to a node. |
|
||||||
|
| `invoke_component_method` | `full` | mutating | [core] Invoke a method on a component for runtime validation and test hooks. |
|
||||||
|
| `list_components` | `full` | read-only | [core] List components attached to a scene node. |
|
||||||
|
| `remove_component` | `full` | mutating | Remove a component from a node by name or index. |
|
||||||
|
| `reset_component_property` | `full` | mutating | Reset or clear a component property by dot path. |
|
||||||
|
| `set_component_property` | `full` | mutating | Set a component property by dot path using a JSON value. |
|
||||||
|
|
||||||
|
### Diagnostics
|
||||||
|
|
||||||
|
| Tool | Profiles | Access | Description |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `get_script_diagnostic_context` | `core`, `full` | read-only | [specialist] Run TypeScript diagnostics and attach source snippets for each error. This is a preferred specialist tool for compile-error triage before repair. |
|
||||||
|
| `run_script_diagnostics` | `core`, `full` | stateful | [specialist] Run a TypeScript no-emit check for the current Cocos project and return parsed diagnostics. This is a preferred specialist tool for script errors when diagnostics are needed. |
|
||||||
|
| `validate_prefab_references` | `core`, `full` | read-only | [specialist] Validate prefab asset references by checking serialized UUID references against asset-db. |
|
||||||
|
| `validate_scene` | `core`, `full` | read-only | [specialist] Run a compact validation pass over the active scene, runtime state, TypeScript diagnostics, and recent project log errors. |
|
||||||
|
|
||||||
|
### Execution
|
||||||
|
|
||||||
|
| Tool | Profiles | Access | Description |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `execute_editor_script` | `core`, `full` | mutating | [compat] Execute JavaScript in the editor/browser context. Prefer execute_javascript with context="editor" as the main unified tool; use this when you specifically want the editor-only compatibility entrypoint. |
|
||||||
|
| `execute_javascript` | `core`, `full` | mutating | [primary] Execute JavaScript in either the scene or editor context. Use context="scene" for live scene/runtime inspection and mutation, or context="editor" for Editor APIs, asset-db workflows, MCP orchestration, local filesystem access, and higher-level automation. Prefer this as the main flexible tool when many narrow tools would be noisy. |
|
||||||
|
|
||||||
|
### Files
|
||||||
|
|
||||||
|
| Tool | Profiles | Access | Description |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `exists` | `full` | read-only | [core] Check whether a project file or directory exists. |
|
||||||
|
| `get_file_snippet` | `full` | read-only | [core] Read a focused snippet around a file line number. |
|
||||||
|
| `list_directory` | `full` | read-only | [core] List files and directories inside a project directory. |
|
||||||
|
| `read_file` | `full` | read-only | [core] Read a file from the Cocos project. |
|
||||||
|
| `refresh_assets` | `full` | stateful | [core] Best-effort asset database refresh for a file or the assets root. |
|
||||||
|
| `replace_in_file` | `full` | mutating | [core] Replace text in a file, useful for script auto-fix loops. |
|
||||||
|
| `search_files` | `full` | read-only | [core] Search project files by simple wildcard pattern. |
|
||||||
|
| `write_file` | `full` | mutating | [core] Write or overwrite a file in the Cocos project. |
|
||||||
|
|
||||||
|
### Input
|
||||||
|
|
||||||
|
| Tool | Profiles | Access | Description |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `simulate_button_click` | `full` | mutating | [core] Simulate a Cocos Button click by emitting click events on the target button node. |
|
||||||
|
| `simulate_key_combo` | `full` | mutating | [core] Send a low-level Electron modified key press such as Ctrl+S or Cmd+P. |
|
||||||
|
| `simulate_key_press` | `full` | mutating | [core] Send a low-level Electron key press to the editor, preview, or simulator window. |
|
||||||
|
| `simulate_mouse_click` | `full` | mutating | [core] Send a low-level Electron mouse click to the editor, preview, or simulator window. |
|
||||||
|
| `simulate_mouse_drag` | `full` | mutating | [core] Send a low-level Electron mouse drag to the editor, preview, or simulator window. |
|
||||||
|
| `simulate_preview_input` | `full` | mutating | [core] Convenience wrapper for low-level preview/simulator input. Uses mouse click by default or key press when keyCode is provided. |
|
||||||
|
|
||||||
|
### Instructions
|
||||||
|
|
||||||
|
| Tool | Profiles | Access | Description |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `create_project_skill` | `full` | stateful | [core] Create a local Codex project skill under .codex/skills/{skillName}/SKILL.md. |
|
||||||
|
| `list_project_instructions` | `core`, `full` | read-only | [specialist] List project AI instruction files and local Codex project skills. |
|
||||||
|
| `read_project_instruction` | `core`, `full` | read-only | [specialist] Read a project AI instruction file such as AGENTS.md, CLAUDE.md, or a .codex skill SKILL.md. |
|
||||||
|
| `write_project_instruction` | `full` | mutating | [core] Create or update a project AI instruction file inside the Cocos project. |
|
||||||
|
|
||||||
|
### Logs
|
||||||
|
|
||||||
|
| Tool | Profiles | Access | Description |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `clear_logs` | `core`, `full` | mutating | [specialist] Clear in-memory MCP logs and, only with explicit confirmation, truncate common project log files. |
|
||||||
|
| `get_recent_logs` | `core`, `full` | read-only | [specialist] Return recent MCP runtime logs, recent tool interactions, and tails of common project log files. |
|
||||||
|
| `search_project_logs` | `core`, `full` | read-only | [specialist] Search common Cocos project log files for a string or regular expression. |
|
||||||
|
|
||||||
|
### Other
|
||||||
|
|
||||||
|
| Tool | Profiles | Access | Description |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `get_performance_snapshot` | `core`, `full` | read-only | [specialist] Return scene scale and runtime performance-oriented counters such as node/component counts, UI counts, depth, memory, and warnings. |
|
||||||
|
| `list_editor_windows` | `core`, `full` | read-only | [specialist] List available Electron windows so screenshots or input-targeting can choose the correct window. Use this when window targeting is the explicit problem. |
|
||||||
|
|
||||||
|
### Prefabs
|
||||||
|
|
||||||
|
| Tool | Profiles | Access | Description |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `apply_prefab_instance` | `full` | stateful | [core] Apply a scene prefab instance back to its associated prefab asset using the Cocos editor scene apply-prefab message. |
|
||||||
|
| `create_prefab_instance` | `full` | stateful | [core] Create a linked prefab instance in the editor hierarchy using Cocos scene create-node when available. |
|
||||||
|
| `duplicate_prefab` | `full` | stateful | [core] Create a new prefab asset by duplicating an existing prefab file without copying its .meta UUID. |
|
||||||
|
| `edit_prefab_json` | `full` | stateful | [core] Edit a prefab JSON file by JSON path assignment or literal search/replace, then validate references. |
|
||||||
|
| `inspect_prefab` | `core`, `full` | read-only | [specialist] Inspect a prefab asset, its metadata, serialized file path, and UUID-like asset references. |
|
||||||
|
| `inspect_prefab_instance` | `core`, `full` | read-only | [specialist] Inspect whether a scene node is linked to a prefab instance and return prefab metadata when available. |
|
||||||
|
| `instantiate_prefab` | `full` | stateful | Instantiate a prefab into the active scene by prefab uuid. |
|
||||||
|
| `list_prefabs` | `full` | read-only | [core] List prefab assets in the project. |
|
||||||
|
| `revert_prefab_instance` | `full` | stateful | [core] Revert a scene prefab instance from its associated prefab asset using available Cocos editor prefab revert messages. |
|
||||||
|
|
||||||
|
### Project
|
||||||
|
|
||||||
|
| Tool | Profiles | Access | Description |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `get_editor_state` | `core`, `full` | read-only | [specialist] Return a structured editor-state snapshot including project info, runtime server status, current selection, and visible Electron windows. Prefer this when you want one compact editor summary. |
|
||||||
|
| `get_project_info` | `core`, `full` | read-only | [specialist] Return the active Cocos project path, version, and MCP server configuration. Prefer this for a fast structured project summary; use execute_javascript when you need to inspect and act in one step. |
|
||||||
|
| `get_tool_catalog` | `core`, `full` | read-only | [specialist] Return every built-in MCP tool with profile, category, and current exposure state. Use this before changing custom tool exposure. |
|
||||||
|
|
||||||
|
### Runtime
|
||||||
|
|
||||||
|
| Tool | Profiles | Access | Description |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `emit_node_event` | `full` | mutating | [core] Emit a custom event on a target scene node with an optional JSON payload. |
|
||||||
|
| `get_runtime_state` | `core`, `full` | read-only | [specialist] Return structured Cocos runtime state including pause state, frame count, and scheduler time scale. Prefer this when you want a compact validation snapshot. |
|
||||||
|
| `pause_runtime` | `full` | stateful | [core] Pause Cocos director game logic execution. |
|
||||||
|
| `resume_runtime` | `full` | stateful | [core] Resume Cocos director game logic execution. |
|
||||||
|
| `set_time_scale` | `full` | mutating | [core] Set Cocos scheduler time scale for runtime validation. |
|
||||||
|
|
||||||
|
### Scene
|
||||||
|
|
||||||
|
| Tool | Profiles | Access | Description |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `create_node` | `full` | stateful | Create a new node under the active scene or a specified parent path. |
|
||||||
|
| `delete_node` | `full` | mutating | Delete a node by path, uuid, or name. |
|
||||||
|
| `execute_scene_script` | `core`, `full` | mutating | [compat] Execute JavaScript in the active Cocos scene context. Prefer execute_javascript with context="scene" as the main unified tool; use this when you specifically want the scene-only compatibility entrypoint. |
|
||||||
|
| `find_nodes` | `full` | read-only | [core] Find scene nodes by exact name, partial path, or component type. |
|
||||||
|
| `get_hierarchy` | `core`, `full` | read-only | [specialist] Return a structured hierarchy tree from the active scene or a specific node path. Prefer execute_javascript for broader reasoning or repair; use this when you want a predictable hierarchy snapshot. |
|
||||||
|
| `get_scene_info` | `core`, `full` | read-only | [specialist] Return a structured summary of the active Cocos scene. Prefer execute_javascript for multi-step inspection or mutation; use this when you specifically want a compact scene snapshot. |
|
||||||
|
| `inspect_node` | `full` | read-only | [core] Inspect a specific node by path, uuid, or name. |
|
||||||
|
| `set_node_transform` | `full` | mutating | Update node position, rotation, scale, or active state. |
|
||||||
|
|
||||||
|
### Screenshots
|
||||||
|
|
||||||
|
| Tool | Profiles | Access | Description |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `capture_desktop_screenshot` | `full` | read-only | [core] Capture a screenshot from the local desktop and return it as an MCP image payload. |
|
||||||
|
| `capture_editor_screenshot` | `core`, `full` | read-only | [specialist] Capture the focused Cocos Creator editor window and return it as an MCP image payload. Prefer screenshot tools only when visual verification is explicitly needed. |
|
||||||
|
| `capture_game_screenshot` | `full` | read-only | [core] Capture the Game/Preview panel region from the editor window with panel-level cropping when available. |
|
||||||
|
| `capture_preview_screenshot` | `core`, `full` | read-only | [specialist] Capture the preview or simulator window as an MCP image payload. Prefer this only when you need visual proof of game or preview output. |
|
||||||
|
| `capture_scene_screenshot` | `core`, `full` | read-only | [specialist] Capture the Scene panel region from the editor window with panel-level cropping when available. Prefer this only for visual validation of scene-side results. |
|
||||||
|
|
||||||
|
### Selection
|
||||||
|
|
||||||
|
| Tool | Profiles | Access | Description |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `get_editor_selection` | `full` | read-only | [compat] Return the current node and asset selection in the Cocos editor. Prefer get_selection as the primary structured selection read tool. |
|
||||||
|
| `get_selection` | `core`, `full` | read-only | [specialist] Return the current editor selection in a compact structured form. Prefer this when selection state matters for the next action. |
|
||||||
|
| `set_selection` | `core`, `full` | mutating | [specialist] Set or clear the current editor selection for an asset or node. Use this when downstream editor workflows depend on selection state. |
|
||||||
|
|
||||||
|
### Ui
|
||||||
|
|
||||||
|
| Tool | Profiles | Access | Description |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `create_button` | `full` | stateful | Create a UI Button node with child Label. |
|
||||||
|
| `create_canvas` | `full` | stateful | Create a Cocos Canvas node with UITransform. |
|
||||||
|
| `create_label` | `full` | stateful | Create a UI Label node under a parent. |
|
||||||
|
| `create_sprite` | `full` | stateful | Create a UI Sprite node, optionally assigning a SpriteFrame asset uuid. |
|
||||||
|
|
||||||
|
### Updates
|
||||||
|
|
||||||
|
| Tool | Profiles | Access | Description |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `check_for_updates` | `core`, `full` | read-only | [specialist] Check the latest Funplay Cocos MCP GitHub release and compare it with the installed extension version. |
|
||||||
|
|
||||||
+23
-1
@@ -140,11 +140,33 @@ class McpServer {
|
|||||||
|
|
||||||
const requestHandler = async (request, response) => {
|
const requestHandler = async (request, response) => {
|
||||||
try {
|
try {
|
||||||
if (request.method === 'GET' && request.url === '/health') {
|
const requestUrl = new URL(request.url || '/', 'http://localhost');
|
||||||
|
if (request.method === 'GET' && requestUrl.pathname === '/health') {
|
||||||
this.log('info', 'GET /health');
|
this.log('info', 'GET /health');
|
||||||
return json(response, 200, { ok: true, name: this.serverName, version: this.serverVersion }, this.negotiatedProtocolVersion);
|
return json(response, 200, { ok: true, name: this.serverName, version: this.serverVersion }, this.negotiatedProtocolVersion);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (request.method === 'GET' && requestUrl.pathname === '/tools') {
|
||||||
|
this.log('info', 'GET /tools');
|
||||||
|
const includeCatalog = requestUrl.searchParams.get('catalog') === '1';
|
||||||
|
const tools = includeCatalog && typeof this.toolRegistry.listToolCatalog === 'function'
|
||||||
|
? this.toolRegistry.listToolCatalog()
|
||||||
|
: this.toolRegistry.listTools();
|
||||||
|
const baseUrl = `http://${request.headers.host || `${this.config.host}:${this.getPort()}`}`;
|
||||||
|
return json(response, 200, {
|
||||||
|
ok: true,
|
||||||
|
name: this.serverName,
|
||||||
|
version: this.serverVersion,
|
||||||
|
count: tools.length,
|
||||||
|
tools,
|
||||||
|
examples: {
|
||||||
|
health: `curl ${baseUrl}/health`,
|
||||||
|
tools: `curl ${baseUrl}/tools`,
|
||||||
|
catalog: `curl ${baseUrl}/tools?catalog=1`,
|
||||||
|
},
|
||||||
|
}, this.negotiatedProtocolVersion);
|
||||||
|
}
|
||||||
|
|
||||||
if (!this.isAllowedOrigin(request)) {
|
if (!this.isAllowedOrigin(request)) {
|
||||||
this.log('warn', `Rejected ${request.method} ${request.url}: invalid Origin header.`);
|
this.log('warn', `Rejected ${request.method} ${request.url}: invalid Origin header.`);
|
||||||
return json(response, 403, { error: 'Forbidden: invalid Origin header' }, this.negotiatedProtocolVersion);
|
return json(response, 403, { error: 'Forbidden: invalid Origin header' }, this.negotiatedProtocolVersion);
|
||||||
|
|||||||
+3
-276
@@ -2,7 +2,6 @@
|
|||||||
|
|
||||||
const crypto = require('crypto');
|
const crypto = require('crypto');
|
||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
const path = require('path');
|
|
||||||
const {
|
const {
|
||||||
clearSelection,
|
clearSelection,
|
||||||
deleteAsset,
|
deleteAsset,
|
||||||
@@ -37,12 +36,14 @@ const {
|
|||||||
revertPrefabInstance,
|
revertPrefabInstance,
|
||||||
validatePrefabReferences,
|
validatePrefabReferences,
|
||||||
} = require('./prefabs');
|
} = require('./prefabs');
|
||||||
|
const { buildSnippet, createFileTools, refreshAssets } = require('./tools/files');
|
||||||
const { captureDesktopScreenshot, captureEditorWindowScreenshot, capturePanelScreenshot } = require('./screenshots');
|
const { captureDesktopScreenshot, captureEditorWindowScreenshot, capturePanelScreenshot } = require('./screenshots');
|
||||||
const { checkForUpdate } = require('./update-checker');
|
const { checkForUpdate } = require('./update-checker');
|
||||||
const { safeStringify } = require('./utils');
|
const { safeStringify } = require('./utils');
|
||||||
const IMAGE_DATA_URI_PREFIX = 'data:image/png;base64,';
|
const IMAGE_DATA_URI_PREFIX = 'data:image/png;base64,';
|
||||||
|
|
||||||
const TOOL_CATEGORY_RULES = [
|
const TOOL_CATEGORY_RULES = [
|
||||||
|
['project', /^(get_project_info|get_editor_state|get_tool_catalog)$/],
|
||||||
['updates', /update/],
|
['updates', /update/],
|
||||||
['logs', /log/],
|
['logs', /log/],
|
||||||
['diagnostics', /diagnostic|validate/],
|
['diagnostics', /diagnostic|validate/],
|
||||||
@@ -60,7 +61,6 @@ const TOOL_CATEGORY_RULES = [
|
|||||||
['runtime', /runtime|time_scale|node_event|invoke_component/],
|
['runtime', /runtime|time_scale|node_event|invoke_component/],
|
||||||
['scene', /scene|hierarchy|node/],
|
['scene', /scene|hierarchy|node/],
|
||||||
['execution', /execute_/],
|
['execution', /execute_/],
|
||||||
['project', /project|editor_state|tool_catalog/],
|
|
||||||
];
|
];
|
||||||
|
|
||||||
function createSchema(properties, required) {
|
function createSchema(properties, required) {
|
||||||
@@ -306,91 +306,6 @@ function toOutput(value) {
|
|||||||
return safeStringify(value);
|
return safeStringify(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
function matchesPattern(fileName, pattern) {
|
|
||||||
const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*');
|
|
||||||
return new RegExp(`^${escaped}$`, 'i').test(fileName);
|
|
||||||
}
|
|
||||||
|
|
||||||
function searchFiles(rootDir, pattern, limit) {
|
|
||||||
const results = [];
|
|
||||||
if (!fs.existsSync(rootDir)) {
|
|
||||||
return results;
|
|
||||||
}
|
|
||||||
|
|
||||||
const stack = [rootDir];
|
|
||||||
while (stack.length && results.length < limit) {
|
|
||||||
const current = stack.pop();
|
|
||||||
const entries = fs.readdirSync(current, { withFileTypes: true });
|
|
||||||
for (const entry of entries) {
|
|
||||||
if (entry.name === '.git' || entry.name === 'node_modules' || entry.name === 'temp' || entry.name === 'library') {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const fullPath = path.join(current, entry.name);
|
|
||||||
if (entry.isDirectory()) {
|
|
||||||
stack.push(fullPath);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (matchesPattern(entry.name, pattern)) {
|
|
||||||
results.push(fullPath);
|
|
||||||
if (results.length >= limit) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return results;
|
|
||||||
}
|
|
||||||
|
|
||||||
function readLines(filePath) {
|
|
||||||
return fs.readFileSync(filePath, 'utf8').split(/\r?\n/);
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildSnippet(filePath, lineNumber, contextLines = 3) {
|
|
||||||
const lines = readLines(filePath);
|
|
||||||
const start = Math.max(1, Number(lineNumber || 1) - Math.max(0, contextLines));
|
|
||||||
const end = Math.min(lines.length, Number(lineNumber || 1) + Math.max(0, contextLines));
|
|
||||||
const snippet = [];
|
|
||||||
for (let line = start; line <= end; line += 1) {
|
|
||||||
const marker = line === Number(lineNumber || 1) ? '>' : ' ';
|
|
||||||
snippet.push(`${marker} ${String(line).padStart(4, ' ')} | ${lines[line - 1]}`);
|
|
||||||
}
|
|
||||||
return snippet.join('\n');
|
|
||||||
}
|
|
||||||
|
|
||||||
function replaceAllLiteral(content, search, replacement) {
|
|
||||||
if (!search) {
|
|
||||||
throw new Error('search text is required.');
|
|
||||||
}
|
|
||||||
return content.split(search).join(replacement);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function refreshAssets(projectPath, targetPath) {
|
|
||||||
if (!global.Editor || !Editor.Message || typeof Editor.Message.request !== 'function') {
|
|
||||||
return 'Asset refresh API is unavailable; Cocos Creator should pick up file changes automatically.';
|
|
||||||
}
|
|
||||||
|
|
||||||
const relative = path.relative(path.join(projectPath, 'assets'), targetPath).replace(/\\/g, '/');
|
|
||||||
if (!relative.startsWith('..')) {
|
|
||||||
const dbUrl = `db://assets/${relative}`;
|
|
||||||
try {
|
|
||||||
await Editor.Message.request('asset-db', 'refresh-asset', dbUrl);
|
|
||||||
return `Refreshed asset database for ${dbUrl}`;
|
|
||||||
} catch (error) {
|
|
||||||
try {
|
|
||||||
await Editor.Message.request('asset-db', 'refresh-asset', 'db://assets');
|
|
||||||
return `Refreshed asset database after writing ${dbUrl}`;
|
|
||||||
} catch (innerError) {
|
|
||||||
return `File written, but asset refresh failed: ${innerError.message}`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return 'File written outside assets directory; no asset-db refresh was needed.';
|
|
||||||
}
|
|
||||||
|
|
||||||
async function resolveNodeUuid(sceneBridge, args) {
|
async function resolveNodeUuid(sceneBridge, args) {
|
||||||
if (args && args.uuid) {
|
if (args && args.uuid) {
|
||||||
return String(args.uuid);
|
return String(args.uuid);
|
||||||
@@ -1337,195 +1252,7 @@ function createToolRegistry({ getRuntimeContext, getStatus, interactionLog, runt
|
|||||||
),
|
),
|
||||||
handler: async (args) => sceneBridge.call('stopAnimation', args),
|
handler: async (args) => sceneBridge.call('stopAnimation', args),
|
||||||
},
|
},
|
||||||
{
|
...createFileTools({ createSchema, getRuntimeContext }),
|
||||||
name: 'read_file',
|
|
||||||
profile: 'full',
|
|
||||||
description: '[core] Read a file from the Cocos project.',
|
|
||||||
inputSchema: createSchema(
|
|
||||||
{
|
|
||||||
path: { type: 'string', description: 'Project-relative or absolute file path.' },
|
|
||||||
},
|
|
||||||
['path']
|
|
||||||
),
|
|
||||||
handler: async (args) => {
|
|
||||||
const { projectPath } = getRuntimeContext();
|
|
||||||
const fullPath = resolveProjectPath(projectPath, args.path);
|
|
||||||
if (!fs.existsSync(fullPath)) {
|
|
||||||
throw new Error(`File not found: ${args.path}`);
|
|
||||||
}
|
|
||||||
const content = fs.readFileSync(fullPath, 'utf8');
|
|
||||||
return content.length > 12000 ? `${content.slice(0, 12000)}\n... (truncated)` : content;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'get_file_snippet',
|
|
||||||
profile: 'full',
|
|
||||||
description: '[core] Read a focused snippet around a file line number.',
|
|
||||||
inputSchema: createSchema(
|
|
||||||
{
|
|
||||||
path: { type: 'string', description: 'Project-relative or absolute file path.' },
|
|
||||||
line: { type: 'number', description: 'Target line number, starting at 1.' },
|
|
||||||
contextLines: { type: 'number', description: 'Number of surrounding context lines.' },
|
|
||||||
},
|
|
||||||
['path', 'line']
|
|
||||||
),
|
|
||||||
handler: async (args) => {
|
|
||||||
const { projectPath } = getRuntimeContext();
|
|
||||||
const fullPath = resolveProjectPath(projectPath, args.path);
|
|
||||||
if (!fs.existsSync(fullPath)) {
|
|
||||||
throw new Error(`File not found: ${args.path}`);
|
|
||||||
}
|
|
||||||
return buildSnippet(fullPath, args.line, Number.isFinite(args.contextLines) ? args.contextLines : 3);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'write_file',
|
|
||||||
profile: 'full',
|
|
||||||
description: '[core] Write or overwrite a file in the Cocos project.',
|
|
||||||
inputSchema: createSchema(
|
|
||||||
{
|
|
||||||
path: { type: 'string', description: 'Project-relative or absolute file path.' },
|
|
||||||
content: { type: 'string', description: 'File content to write.' },
|
|
||||||
},
|
|
||||||
['path', 'content']
|
|
||||||
),
|
|
||||||
handler: async (args) => {
|
|
||||||
const { projectPath } = getRuntimeContext();
|
|
||||||
const fullPath = resolveProjectPath(projectPath, args.path);
|
|
||||||
fs.mkdirSync(path.dirname(fullPath), { recursive: true });
|
|
||||||
fs.writeFileSync(fullPath, args.content, 'utf8');
|
|
||||||
return `Wrote ${args.content.length} chars to ${args.path}\n${await refreshAssets(projectPath, fullPath)}`;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'replace_in_file',
|
|
||||||
profile: 'full',
|
|
||||||
description: '[core] Replace text in a file, useful for script auto-fix loops.',
|
|
||||||
inputSchema: createSchema(
|
|
||||||
{
|
|
||||||
path: { type: 'string', description: 'Project-relative or absolute file path.' },
|
|
||||||
search: { type: 'string', description: 'Literal text to search for.' },
|
|
||||||
replace: { type: 'string', description: 'Replacement text.' },
|
|
||||||
replaceAll: { type: 'boolean', description: 'Replace every occurrence instead of only the first.' },
|
|
||||||
},
|
|
||||||
['path', 'search', 'replace']
|
|
||||||
),
|
|
||||||
handler: async (args) => {
|
|
||||||
const { projectPath } = getRuntimeContext();
|
|
||||||
const fullPath = resolveProjectPath(projectPath, args.path);
|
|
||||||
if (!fs.existsSync(fullPath)) {
|
|
||||||
throw new Error(`File not found: ${args.path}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const original = fs.readFileSync(fullPath, 'utf8');
|
|
||||||
if (!original.includes(args.search)) {
|
|
||||||
throw new Error(`Search text was not found in ${args.path}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const updated = args.replaceAll
|
|
||||||
? replaceAllLiteral(original, args.search, args.replace)
|
|
||||||
: original.replace(args.search, args.replace);
|
|
||||||
|
|
||||||
fs.writeFileSync(fullPath, updated, 'utf8');
|
|
||||||
return `Updated ${args.path} (${args.replaceAll ? 'all matches' : 'first match'})\n${await refreshAssets(projectPath, fullPath)}`;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'search_files',
|
|
||||||
profile: 'full',
|
|
||||||
description: '[core] Search project files by simple wildcard pattern.',
|
|
||||||
inputSchema: createSchema(
|
|
||||||
{
|
|
||||||
pattern: { type: 'string', description: "Wildcard file pattern such as '*.ts' or 'Player*'." },
|
|
||||||
directory: { type: 'string', description: 'Project-relative search root. Defaults to assets.' },
|
|
||||||
limit: { type: 'number', description: 'Maximum number of results to return.' },
|
|
||||||
},
|
|
||||||
['pattern']
|
|
||||||
),
|
|
||||||
handler: async (args) => {
|
|
||||||
const { projectPath } = getRuntimeContext();
|
|
||||||
const searchRoot = resolveProjectPath(projectPath, args.directory || 'assets');
|
|
||||||
if (!fs.existsSync(searchRoot)) {
|
|
||||||
throw new Error(`Directory not found: ${args.directory || 'assets'}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const limit = Number.isFinite(args.limit) ? Math.max(1, Math.min(500, args.limit)) : 100;
|
|
||||||
const results = searchFiles(searchRoot, args.pattern, limit).map((fullPath) =>
|
|
||||||
path.relative(projectPath, fullPath).replace(/\\/g, '/')
|
|
||||||
);
|
|
||||||
return {
|
|
||||||
count: results.length,
|
|
||||||
files: results,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'list_directory',
|
|
||||||
profile: 'full',
|
|
||||||
description: '[core] List files and directories inside a project directory.',
|
|
||||||
inputSchema: createSchema(
|
|
||||||
{
|
|
||||||
path: { type: 'string', description: 'Project-relative or absolute directory path.' },
|
|
||||||
},
|
|
||||||
['path']
|
|
||||||
),
|
|
||||||
handler: async (args) => {
|
|
||||||
const { projectPath } = getRuntimeContext();
|
|
||||||
const targetPath = resolveProjectPath(projectPath, args.path);
|
|
||||||
if (!fs.existsSync(targetPath) || !fs.statSync(targetPath).isDirectory()) {
|
|
||||||
throw new Error(`Directory not found: ${args.path}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const entries = fs
|
|
||||||
.readdirSync(targetPath, { withFileTypes: true })
|
|
||||||
.filter((entry) => !entry.name.startsWith('.'))
|
|
||||||
.map((entry) => ({
|
|
||||||
name: entry.name,
|
|
||||||
type: entry.isDirectory() ? 'directory' : 'file',
|
|
||||||
}));
|
|
||||||
return {
|
|
||||||
path: args.path,
|
|
||||||
entries,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'exists',
|
|
||||||
profile: 'full',
|
|
||||||
description: '[core] Check whether a project file or directory exists.',
|
|
||||||
inputSchema: createSchema(
|
|
||||||
{
|
|
||||||
path: { type: 'string', description: 'Project-relative or absolute path.' },
|
|
||||||
},
|
|
||||||
['path']
|
|
||||||
),
|
|
||||||
handler: async (args) => {
|
|
||||||
const { projectPath } = getRuntimeContext();
|
|
||||||
const targetPath = resolveProjectPath(projectPath, args.path);
|
|
||||||
return {
|
|
||||||
path: args.path,
|
|
||||||
exists: fs.existsSync(targetPath),
|
|
||||||
isFile: fs.existsSync(targetPath) ? fs.statSync(targetPath).isFile() : false,
|
|
||||||
isDirectory: fs.existsSync(targetPath) ? fs.statSync(targetPath).isDirectory() : false,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'refresh_assets',
|
|
||||||
profile: 'full',
|
|
||||||
description: '[core] Best-effort asset database refresh for a file or the assets root.',
|
|
||||||
inputSchema: createSchema(
|
|
||||||
{
|
|
||||||
path: { type: 'string', description: 'Optional project-relative file path to refresh.' },
|
|
||||||
},
|
|
||||||
[]
|
|
||||||
),
|
|
||||||
handler: async (args) => {
|
|
||||||
const { projectPath } = getRuntimeContext();
|
|
||||||
const targetPath = resolveProjectPath(projectPath, args.path || 'assets');
|
|
||||||
return await refreshAssets(projectPath, targetPath);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
name: 'run_script_diagnostics',
|
name: 'run_script_diagnostics',
|
||||||
profile: 'core',
|
profile: 'core',
|
||||||
|
|||||||
@@ -0,0 +1,303 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const { resolveProjectPath } = require('../path-safety');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a focused, line-numbered snippet around one file line.
|
||||||
|
* @param {string} filePath Absolute file path.
|
||||||
|
* @param {number} lineNumber One-based target line number.
|
||||||
|
* @param {number} contextLines Surrounding lines to include.
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
function buildSnippet(filePath, lineNumber, contextLines = 3) {
|
||||||
|
const lines = fs.readFileSync(filePath, 'utf8').split(/\r?\n/);
|
||||||
|
const start = Math.max(1, Number(lineNumber || 1) - Math.max(0, contextLines));
|
||||||
|
const end = Math.min(lines.length, Number(lineNumber || 1) + Math.max(0, contextLines));
|
||||||
|
const snippet = [];
|
||||||
|
for (let line = start; line <= end; line += 1) {
|
||||||
|
const marker = line === Number(lineNumber || 1) ? '>' : ' ';
|
||||||
|
snippet.push(`${marker} ${String(line).padStart(4, ' ')} | ${lines[line - 1]}`);
|
||||||
|
}
|
||||||
|
return snippet.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
function matchesPattern(fileName, pattern) {
|
||||||
|
const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*');
|
||||||
|
return new RegExp(`^${escaped}$`, 'i').test(fileName);
|
||||||
|
}
|
||||||
|
|
||||||
|
function searchFiles(rootDir, pattern, limit) {
|
||||||
|
const results = [];
|
||||||
|
if (!fs.existsSync(rootDir)) {
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
const stack = [rootDir];
|
||||||
|
while (stack.length && results.length < limit) {
|
||||||
|
const current = stack.pop();
|
||||||
|
const entries = fs.readdirSync(current, { withFileTypes: true });
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (entry.name === '.git' || entry.name === 'node_modules' || entry.name === 'temp' || entry.name === 'library') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fullPath = path.join(current, entry.name);
|
||||||
|
if (entry.isDirectory()) {
|
||||||
|
stack.push(fullPath);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (matchesPattern(entry.name, pattern)) {
|
||||||
|
results.push(fullPath);
|
||||||
|
if (results.length >= limit) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
function replaceAllLiteral(content, search, replacement) {
|
||||||
|
if (!search) {
|
||||||
|
throw new Error('search text is required.');
|
||||||
|
}
|
||||||
|
return content.split(search).join(replacement);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Best-effort refresh for Cocos asset database after external file edits.
|
||||||
|
* @param {string} projectPath Active Cocos project root.
|
||||||
|
* @param {string} targetPath Absolute file or directory path.
|
||||||
|
* @returns {Promise<string>}
|
||||||
|
*/
|
||||||
|
async function refreshAssets(projectPath, targetPath) {
|
||||||
|
if (!global.Editor || !Editor.Message || typeof Editor.Message.request !== 'function') {
|
||||||
|
return 'Asset refresh API is unavailable; Cocos Creator should pick up file changes automatically.';
|
||||||
|
}
|
||||||
|
|
||||||
|
const relative = path.relative(path.join(projectPath, 'assets'), targetPath).replace(/\\/g, '/');
|
||||||
|
if (!relative.startsWith('..')) {
|
||||||
|
const dbUrl = `db://assets/${relative}`;
|
||||||
|
try {
|
||||||
|
await Editor.Message.request('asset-db', 'refresh-asset', dbUrl);
|
||||||
|
return `Refreshed asset database for ${dbUrl}`;
|
||||||
|
} catch (error) {
|
||||||
|
try {
|
||||||
|
await Editor.Message.request('asset-db', 'refresh-asset', 'db://assets');
|
||||||
|
return `Refreshed asset database after writing ${dbUrl}`;
|
||||||
|
} catch (innerError) {
|
||||||
|
return `File written, but asset refresh failed: ${innerError.message}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'File written outside assets directory; no asset-db refresh was needed.';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* File-system tools. Kept separate from the registry so path safety and asset
|
||||||
|
* refresh behavior can be tested and evolved independently.
|
||||||
|
*/
|
||||||
|
function createFileTools({ createSchema, getRuntimeContext }) {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
name: 'read_file',
|
||||||
|
profile: 'full',
|
||||||
|
description: '[core] Read a file from the Cocos project.',
|
||||||
|
inputSchema: createSchema(
|
||||||
|
{
|
||||||
|
path: { type: 'string', description: 'Project-relative or absolute file path.' },
|
||||||
|
},
|
||||||
|
['path']
|
||||||
|
),
|
||||||
|
handler: async (args) => {
|
||||||
|
const { projectPath } = getRuntimeContext();
|
||||||
|
const fullPath = resolveProjectPath(projectPath, args.path);
|
||||||
|
if (!fs.existsSync(fullPath)) {
|
||||||
|
throw new Error(`File not found: ${args.path}`);
|
||||||
|
}
|
||||||
|
const content = fs.readFileSync(fullPath, 'utf8');
|
||||||
|
return content.length > 12000 ? `${content.slice(0, 12000)}\n... (truncated)` : content;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'get_file_snippet',
|
||||||
|
profile: 'full',
|
||||||
|
description: '[core] Read a focused snippet around a file line number.',
|
||||||
|
inputSchema: createSchema(
|
||||||
|
{
|
||||||
|
path: { type: 'string', description: 'Project-relative or absolute file path.' },
|
||||||
|
line: { type: 'number', description: 'Target line number, starting at 1.' },
|
||||||
|
contextLines: { type: 'number', description: 'Number of surrounding context lines.' },
|
||||||
|
},
|
||||||
|
['path', 'line']
|
||||||
|
),
|
||||||
|
handler: async (args) => {
|
||||||
|
const { projectPath } = getRuntimeContext();
|
||||||
|
const fullPath = resolveProjectPath(projectPath, args.path);
|
||||||
|
if (!fs.existsSync(fullPath)) {
|
||||||
|
throw new Error(`File not found: ${args.path}`);
|
||||||
|
}
|
||||||
|
return buildSnippet(fullPath, args.line, Number.isFinite(args.contextLines) ? args.contextLines : 3);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'write_file',
|
||||||
|
profile: 'full',
|
||||||
|
description: '[core] Write or overwrite a file in the Cocos project.',
|
||||||
|
inputSchema: createSchema(
|
||||||
|
{
|
||||||
|
path: { type: 'string', description: 'Project-relative or absolute file path.' },
|
||||||
|
content: { type: 'string', description: 'File content to write.' },
|
||||||
|
},
|
||||||
|
['path', 'content']
|
||||||
|
),
|
||||||
|
handler: async (args) => {
|
||||||
|
const { projectPath } = getRuntimeContext();
|
||||||
|
const fullPath = resolveProjectPath(projectPath, args.path);
|
||||||
|
fs.mkdirSync(path.dirname(fullPath), { recursive: true });
|
||||||
|
fs.writeFileSync(fullPath, args.content, 'utf8');
|
||||||
|
return `Wrote ${args.content.length} chars to ${args.path}\n${await refreshAssets(projectPath, fullPath)}`;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'replace_in_file',
|
||||||
|
profile: 'full',
|
||||||
|
description: '[core] Replace text in a file, useful for script auto-fix loops.',
|
||||||
|
inputSchema: createSchema(
|
||||||
|
{
|
||||||
|
path: { type: 'string', description: 'Project-relative or absolute file path.' },
|
||||||
|
search: { type: 'string', description: 'Literal text to search for.' },
|
||||||
|
replace: { type: 'string', description: 'Replacement text.' },
|
||||||
|
replaceAll: { type: 'boolean', description: 'Replace every occurrence instead of only the first.' },
|
||||||
|
},
|
||||||
|
['path', 'search', 'replace']
|
||||||
|
),
|
||||||
|
handler: async (args) => {
|
||||||
|
const { projectPath } = getRuntimeContext();
|
||||||
|
const fullPath = resolveProjectPath(projectPath, args.path);
|
||||||
|
if (!fs.existsSync(fullPath)) {
|
||||||
|
throw new Error(`File not found: ${args.path}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const original = fs.readFileSync(fullPath, 'utf8');
|
||||||
|
if (!original.includes(args.search)) {
|
||||||
|
throw new Error(`Search text was not found in ${args.path}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = args.replaceAll
|
||||||
|
? replaceAllLiteral(original, args.search, args.replace)
|
||||||
|
: original.replace(args.search, args.replace);
|
||||||
|
|
||||||
|
fs.writeFileSync(fullPath, updated, 'utf8');
|
||||||
|
return `Updated ${args.path} (${args.replaceAll ? 'all matches' : 'first match'})\n${await refreshAssets(projectPath, fullPath)}`;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'search_files',
|
||||||
|
profile: 'full',
|
||||||
|
description: '[core] Search project files by simple wildcard pattern.',
|
||||||
|
inputSchema: createSchema(
|
||||||
|
{
|
||||||
|
pattern: { type: 'string', description: "Wildcard file pattern such as '*.ts' or 'Player*'." },
|
||||||
|
directory: { type: 'string', description: 'Project-relative search root. Defaults to assets.' },
|
||||||
|
limit: { type: 'number', description: 'Maximum number of results to return.' },
|
||||||
|
},
|
||||||
|
['pattern']
|
||||||
|
),
|
||||||
|
handler: async (args) => {
|
||||||
|
const { projectPath } = getRuntimeContext();
|
||||||
|
const searchRoot = resolveProjectPath(projectPath, args.directory || 'assets');
|
||||||
|
if (!fs.existsSync(searchRoot)) {
|
||||||
|
throw new Error(`Directory not found: ${args.directory || 'assets'}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const limit = Number.isFinite(args.limit) ? Math.max(1, Math.min(500, args.limit)) : 100;
|
||||||
|
const results = searchFiles(searchRoot, args.pattern, limit).map((fullPath) =>
|
||||||
|
path.relative(projectPath, fullPath).replace(/\\/g, '/')
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
count: results.length,
|
||||||
|
files: results,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'list_directory',
|
||||||
|
profile: 'full',
|
||||||
|
description: '[core] List files and directories inside a project directory.',
|
||||||
|
inputSchema: createSchema(
|
||||||
|
{
|
||||||
|
path: { type: 'string', description: 'Project-relative or absolute directory path.' },
|
||||||
|
},
|
||||||
|
['path']
|
||||||
|
),
|
||||||
|
handler: async (args) => {
|
||||||
|
const { projectPath } = getRuntimeContext();
|
||||||
|
const targetPath = resolveProjectPath(projectPath, args.path);
|
||||||
|
if (!fs.existsSync(targetPath) || !fs.statSync(targetPath).isDirectory()) {
|
||||||
|
throw new Error(`Directory not found: ${args.path}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const entries = fs
|
||||||
|
.readdirSync(targetPath, { withFileTypes: true })
|
||||||
|
.filter((entry) => !entry.name.startsWith('.'))
|
||||||
|
.map((entry) => ({
|
||||||
|
name: entry.name,
|
||||||
|
type: entry.isDirectory() ? 'directory' : 'file',
|
||||||
|
}));
|
||||||
|
return {
|
||||||
|
path: args.path,
|
||||||
|
entries,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'exists',
|
||||||
|
profile: 'full',
|
||||||
|
description: '[core] Check whether a project file or directory exists.',
|
||||||
|
inputSchema: createSchema(
|
||||||
|
{
|
||||||
|
path: { type: 'string', description: 'Project-relative or absolute path.' },
|
||||||
|
},
|
||||||
|
['path']
|
||||||
|
),
|
||||||
|
handler: async (args) => {
|
||||||
|
const { projectPath } = getRuntimeContext();
|
||||||
|
const targetPath = resolveProjectPath(projectPath, args.path);
|
||||||
|
return {
|
||||||
|
path: args.path,
|
||||||
|
exists: fs.existsSync(targetPath),
|
||||||
|
isFile: fs.existsSync(targetPath) ? fs.statSync(targetPath).isFile() : false,
|
||||||
|
isDirectory: fs.existsSync(targetPath) ? fs.statSync(targetPath).isDirectory() : false,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'refresh_assets',
|
||||||
|
profile: 'full',
|
||||||
|
description: '[core] Best-effort asset database refresh for a file or the assets root.',
|
||||||
|
inputSchema: createSchema(
|
||||||
|
{
|
||||||
|
path: { type: 'string', description: 'Optional project-relative file path to refresh.' },
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
),
|
||||||
|
handler: async (args) => {
|
||||||
|
const { projectPath } = getRuntimeContext();
|
||||||
|
const targetPath = resolveProjectPath(projectPath, args.path || 'assets');
|
||||||
|
return await refreshAssets(projectPath, targetPath);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
buildSnippet,
|
||||||
|
createFileTools,
|
||||||
|
refreshAssets,
|
||||||
|
};
|
||||||
+52
-3
@@ -1,14 +1,63 @@
|
|||||||
{
|
{
|
||||||
"name": "funplay-cocos-mcp",
|
"name": "funplay-cocos-mcp",
|
||||||
"package_version": 2,
|
"package_version": 2,
|
||||||
"version": "0.3.0",
|
"version": "0.3.3",
|
||||||
"description": "Embedded MCP server for Cocos Creator with scene script execution, project resources, prompts, and file/scene tools.",
|
"description": "Embedded MCP server for Cocos Creator with scene script execution, project resources, prompts, and file/scene tools.",
|
||||||
"author": "Funplay",
|
"author": "Funplay",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"main": "browser.js",
|
"main": "browser.js",
|
||||||
|
"bin": {
|
||||||
|
"funplay-cocos-mcp": "bin/funplay-cocos-mcp.js"
|
||||||
|
},
|
||||||
|
"mcpName": "io.github.FunplayAI/funplay-cocos-mcp",
|
||||||
|
"repository": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "git+https://github.com/FunplayAI/funplay-cocos-mcp.git"
|
||||||
|
},
|
||||||
|
"homepage": "https://github.com/FunplayAI/funplay-cocos-mcp#readme",
|
||||||
|
"bugs": {
|
||||||
|
"url": "https://github.com/FunplayAI/funplay-cocos-mcp/issues"
|
||||||
|
},
|
||||||
|
"keywords": [
|
||||||
|
"mcp",
|
||||||
|
"cocos",
|
||||||
|
"cocos-creator",
|
||||||
|
"funplay",
|
||||||
|
"model-context-protocol",
|
||||||
|
"ai"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"files": [
|
||||||
|
"bin/",
|
||||||
|
"docs/",
|
||||||
|
"lib/",
|
||||||
|
"panel/",
|
||||||
|
"browser.js",
|
||||||
|
"scene.js",
|
||||||
|
"server.json",
|
||||||
|
"README.md",
|
||||||
|
"README_CN.md",
|
||||||
|
"CHANGELOG.md",
|
||||||
|
"CONTRIBUTING.md",
|
||||||
|
"LICENSE",
|
||||||
|
"RELEASE_WORKFLOW.md",
|
||||||
|
"RELEASE_CHECKLIST.md"
|
||||||
|
],
|
||||||
|
"publishConfig": {
|
||||||
|
"access": "public"
|
||||||
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"check": "node --check browser.js && node --check scene.js && node --check panel/index.js && node --check lib/assets.js && node --check lib/client-config.js && node --check lib/config.js && node --check lib/diagnostics.js && node --check lib/electron-tools.js && node --check lib/input.js && node --check lib/interaction-log.js && node --check lib/logs.js && node --check lib/path-safety.js && node --check lib/prefabs.js && node --check lib/project-instructions.js && node --check lib/prompts.js && node --check lib/resources.js && node --check lib/runtime-log.js && node --check lib/screenshots.js && node --check lib/server.js && node --check lib/tool-registry.js && node --check lib/update-checker.js && node --check lib/utils.js",
|
"check": "node --check browser.js && node --check scene.js && node --check panel/index.js && node --check bin/funplay-cocos-mcp.js && node --check lib/assets.js && node --check lib/client-config.js && node --check lib/config.js && node --check lib/diagnostics.js && node --check lib/electron-tools.js && node --check lib/input.js && node --check lib/interaction-log.js && node --check lib/logs.js && node --check lib/path-safety.js && node --check lib/prefabs.js && node --check lib/project-instructions.js && node --check lib/prompts.js && node --check lib/resources.js && node --check lib/runtime-log.js && node --check lib/screenshots.js && node --check lib/server.js && node --check lib/tool-registry.js && node --check lib/tools/files.js && node --check lib/update-checker.js && node --check lib/utils.js && node --check scripts/generate-tool-docs.js && node --check scripts/release.js",
|
||||||
"test": "node --test"
|
"test": "node --test",
|
||||||
|
"docs:generate": "node scripts/generate-tool-docs.js",
|
||||||
|
"docs:check": "node scripts/generate-tool-docs.js --check",
|
||||||
|
"pack:dry-run": "npm pack --dry-run",
|
||||||
|
"registry:validate": "mcp-publisher validate server.json",
|
||||||
|
"release:check": "node scripts/release.js check && npm run docs:check",
|
||||||
|
"release:package": "node scripts/release.js package",
|
||||||
|
"release:verify": "npm run check && npm test && npm run release:check && npm run pack:dry-run && npm run release:package"
|
||||||
},
|
},
|
||||||
"panels": {
|
"panels": {
|
||||||
"default": {
|
"default": {
|
||||||
|
|||||||
+168
-5
@@ -34,6 +34,8 @@ module.exports = Editor.Panel.define({
|
|||||||
</label>
|
</label>
|
||||||
<ui-button id="restartBtn">Restart</ui-button>
|
<ui-button id="restartBtn">Restart</ui-button>
|
||||||
<ui-button id="copyUrlBtn">Copy URL</ui-button>
|
<ui-button id="copyUrlBtn">Copy URL</ui-button>
|
||||||
|
<ui-button id="copyHealthCurlBtn">Copy Health Curl</ui-button>
|
||||||
|
<ui-button id="copyToolsCurlBtn">Copy Tools Curl</ui-button>
|
||||||
<ui-button id="checkUpdatesBtn">Check Updates</ui-button>
|
<ui-button id="checkUpdatesBtn">Check Updates</ui-button>
|
||||||
</div>
|
</div>
|
||||||
<div class="grid">
|
<div class="grid">
|
||||||
@@ -70,6 +72,20 @@ module.exports = Editor.Panel.define({
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section class="card">
|
||||||
|
<h2>Activity</h2>
|
||||||
|
<div class="activity-grid">
|
||||||
|
<div class="activity-column">
|
||||||
|
<h3>Recent Calls</h3>
|
||||||
|
<div id="recentCalls" class="mini-list muted"></div>
|
||||||
|
</div>
|
||||||
|
<div class="activity-column">
|
||||||
|
<h3>Log Preview</h3>
|
||||||
|
<div id="recentLogs" class="mini-list muted"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<section class="card">
|
<section class="card">
|
||||||
<h2>MCP Client Config</h2>
|
<h2>MCP Client Config</h2>
|
||||||
<div class="row">
|
<div class="row">
|
||||||
@@ -114,6 +130,12 @@ module.exports = Editor.Panel.define({
|
|||||||
margin: 0 0 10px 0;
|
margin: 0 0 10px 0;
|
||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
}
|
}
|
||||||
|
h3 {
|
||||||
|
margin: 0 0 6px 0;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-normal-contrast-weak);
|
||||||
|
}
|
||||||
p {
|
p {
|
||||||
margin: 4px 0 0 0;
|
margin: 4px 0 0 0;
|
||||||
color: var(--color-normal-contrast-weakest);
|
color: var(--color-normal-contrast-weakest);
|
||||||
@@ -199,6 +221,46 @@ module.exports = Editor.Panel.define({
|
|||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
word-break: break-all;
|
word-break: break-all;
|
||||||
}
|
}
|
||||||
|
.activity-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(180px, 1fr));
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
.mini-list {
|
||||||
|
min-height: 74px;
|
||||||
|
max-height: 158px;
|
||||||
|
overflow: auto;
|
||||||
|
box-sizing: border-box;
|
||||||
|
border: 1px solid var(--color-normal-border);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 8px;
|
||||||
|
background: rgba(0,0,0,0.12);
|
||||||
|
line-height: 1.35;
|
||||||
|
}
|
||||||
|
.mini-item {
|
||||||
|
padding: 0 0 8px 0;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
border-bottom: 1px solid rgba(255,255,255,0.08);
|
||||||
|
}
|
||||||
|
.mini-item:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
padding-bottom: 0;
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
.mini-title {
|
||||||
|
color: var(--color-normal-contrast);
|
||||||
|
font-weight: 600;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
.mini-meta {
|
||||||
|
margin-top: 2px;
|
||||||
|
color: var(--color-normal-contrast-weakest);
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
.mini-body {
|
||||||
|
margin-top: 2px;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
details {
|
details {
|
||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
@@ -232,6 +294,11 @@ module.exports = Editor.Panel.define({
|
|||||||
.primary {
|
.primary {
|
||||||
border-color: #4aa3ff;
|
border-color: #4aa3ff;
|
||||||
}
|
}
|
||||||
|
@media (max-width: 620px) {
|
||||||
|
.activity-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
`,
|
`,
|
||||||
$: {
|
$: {
|
||||||
root: '.mcp-root',
|
root: '.mcp-root',
|
||||||
@@ -244,6 +311,8 @@ module.exports = Editor.Panel.define({
|
|||||||
sessionsInput: '#sessionsInput',
|
sessionsInput: '#sessionsInput',
|
||||||
restartBtn: '#restartBtn',
|
restartBtn: '#restartBtn',
|
||||||
copyUrlBtn: '#copyUrlBtn',
|
copyUrlBtn: '#copyUrlBtn',
|
||||||
|
copyHealthCurlBtn: '#copyHealthCurlBtn',
|
||||||
|
copyToolsCurlBtn: '#copyToolsCurlBtn',
|
||||||
checkUpdatesBtn: '#checkUpdatesBtn',
|
checkUpdatesBtn: '#checkUpdatesBtn',
|
||||||
updateStatus: '#updateStatus',
|
updateStatus: '#updateStatus',
|
||||||
toolSummary: '#toolSummary',
|
toolSummary: '#toolSummary',
|
||||||
@@ -258,6 +327,8 @@ module.exports = Editor.Panel.define({
|
|||||||
configureClientBtn: '#configureClientBtn',
|
configureClientBtn: '#configureClientBtn',
|
||||||
clientTargetStatus: '#clientTargetStatus',
|
clientTargetStatus: '#clientTargetStatus',
|
||||||
clientConfigText: '#clientConfigText',
|
clientConfigText: '#clientConfigText',
|
||||||
|
recentCalls: '#recentCalls',
|
||||||
|
recentLogs: '#recentLogs',
|
||||||
output: '#output',
|
output: '#output',
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
@@ -295,9 +366,8 @@ module.exports = Editor.Panel.define({
|
|||||||
this.$.disabledToolsInput.value = this.formatList(config.disabledTools);
|
this.$.disabledToolsInput.value = this.formatList(config.disabledTools);
|
||||||
this.renderUpdateStatus();
|
this.renderUpdateStatus();
|
||||||
this.renderToolSummary();
|
this.renderToolSummary();
|
||||||
|
|
||||||
this.$.clientConfigText.value = state.clientConfig ? state.clientConfig.codex : '';
|
|
||||||
this.renderClientTargets();
|
this.renderClientTargets();
|
||||||
|
this.renderActivity();
|
||||||
},
|
},
|
||||||
formatList(value) {
|
formatList(value) {
|
||||||
return Array.isArray(value) ? value.join('\n') : '';
|
return Array.isArray(value) ? value.join('\n') : '';
|
||||||
@@ -329,6 +399,75 @@ module.exports = Editor.Panel.define({
|
|||||||
this.$.toolSummary.textContent =
|
this.$.toolSummary.textContent =
|
||||||
`Enabled ${enabled.length}/${catalog.length} tools | Categories: ${categories.join(', ')}`;
|
`Enabled ${enabled.length}/${catalog.length} tools | Categories: ${categories.join(', ')}`;
|
||||||
},
|
},
|
||||||
|
renderActivity() {
|
||||||
|
const state = this.state || {};
|
||||||
|
this.renderMiniList(
|
||||||
|
this.$.recentCalls,
|
||||||
|
state.recentInteractions || [],
|
||||||
|
(entry) => ({
|
||||||
|
title: `${String(entry.status || '').toUpperCase()} ${entry.toolName || 'tool'}`,
|
||||||
|
meta: this.formatTimestamp(entry.timestamp),
|
||||||
|
body: entry.summary || '',
|
||||||
|
}),
|
||||||
|
'No recent MCP calls.'
|
||||||
|
);
|
||||||
|
this.renderMiniList(
|
||||||
|
this.$.recentLogs,
|
||||||
|
state.recentRuntimeLogs || [],
|
||||||
|
(entry) => ({
|
||||||
|
title: `${String(entry.level || 'info').toUpperCase()} ${entry.message || ''}`,
|
||||||
|
meta: this.formatTimestamp(entry.timestamp),
|
||||||
|
body: entry.details ? stringify(entry.details) : '',
|
||||||
|
}),
|
||||||
|
'No runtime logs yet.'
|
||||||
|
);
|
||||||
|
},
|
||||||
|
renderMiniList(container, entries, formatEntry, emptyText) {
|
||||||
|
container.innerHTML = '';
|
||||||
|
if (!entries.length) {
|
||||||
|
container.textContent = emptyText;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fragment = document.createDocumentFragment();
|
||||||
|
entries.slice(0, 6).forEach((entry) => {
|
||||||
|
const formatted = formatEntry(entry);
|
||||||
|
const item = document.createElement('div');
|
||||||
|
item.className = 'mini-item';
|
||||||
|
|
||||||
|
const title = document.createElement('div');
|
||||||
|
title.className = 'mini-title';
|
||||||
|
title.textContent = formatted.title;
|
||||||
|
item.appendChild(title);
|
||||||
|
|
||||||
|
if (formatted.meta) {
|
||||||
|
const meta = document.createElement('div');
|
||||||
|
meta.className = 'mini-meta';
|
||||||
|
meta.textContent = formatted.meta;
|
||||||
|
item.appendChild(meta);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (formatted.body) {
|
||||||
|
const body = document.createElement('div');
|
||||||
|
body.className = 'mini-body';
|
||||||
|
body.textContent = formatted.body;
|
||||||
|
item.appendChild(body);
|
||||||
|
}
|
||||||
|
|
||||||
|
fragment.appendChild(item);
|
||||||
|
});
|
||||||
|
container.appendChild(fragment);
|
||||||
|
},
|
||||||
|
formatTimestamp(value) {
|
||||||
|
if (!value) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
const date = new Date(value);
|
||||||
|
if (Number.isNaN(date.getTime())) {
|
||||||
|
return String(value);
|
||||||
|
}
|
||||||
|
return date.toLocaleTimeString();
|
||||||
|
},
|
||||||
renderClientTargets() {
|
renderClientTargets() {
|
||||||
const targets = (this.state && this.state.clientTargets) || [];
|
const targets = (this.state && this.state.clientTargets) || [];
|
||||||
const preferred = this.state && this.state.config ? this.state.config.lastClientTargetId : '';
|
const preferred = this.state && this.state.config ? this.state.config.lastClientTargetId : '';
|
||||||
@@ -349,10 +488,30 @@ module.exports = Editor.Panel.define({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.$.clientTargetStatus.textContent = `${target.configured ? 'Configured' : 'Not configured'}: ${target.configPath}`;
|
this.$.clientTargetStatus.textContent = `${target.configured ? 'Configured' : 'Not configured'}: ${target.configPath}`;
|
||||||
|
const previews = this.state && this.state.clientConfig && Array.isArray(this.state.clientConfig.targets)
|
||||||
|
? this.state.clientConfig.targets
|
||||||
|
: [];
|
||||||
|
const preview = previews.find((item) => item.id === target.id);
|
||||||
|
this.$.clientConfigText.value = preview && preview.preview
|
||||||
|
? preview.preview
|
||||||
|
: (this.state && this.state.clientConfig ? this.state.clientConfig.codex : '');
|
||||||
},
|
},
|
||||||
showOutput(value) {
|
showOutput(value) {
|
||||||
this.$.output.textContent = stringify(value);
|
this.$.output.textContent = stringify(value);
|
||||||
},
|
},
|
||||||
|
copyText(text, successMessage) {
|
||||||
|
if (!text) {
|
||||||
|
this.showOutput('Nothing to copy.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
navigator.clipboard.writeText(text)
|
||||||
|
.then(() => this.showOutput(successMessage))
|
||||||
|
.catch(() => this.showOutput(text));
|
||||||
|
},
|
||||||
|
getCurlCommand(key) {
|
||||||
|
const curl = this.state && this.state.clientConfig && this.state.clientConfig.curl;
|
||||||
|
return curl && curl[key] ? curl[key] : '';
|
||||||
|
},
|
||||||
async persistConfig(options = {}) {
|
async persistConfig(options = {}) {
|
||||||
const { showOutput = false } = options;
|
const { showOutput = false } = options;
|
||||||
try {
|
try {
|
||||||
@@ -415,9 +574,13 @@ module.exports = Editor.Panel.define({
|
|||||||
this.$.copyUrlBtn.addEventListener('click', () => {
|
this.$.copyUrlBtn.addEventListener('click', () => {
|
||||||
const status = this.state && this.state.status;
|
const status = this.state && this.state.status;
|
||||||
const text = status && status.url ? status.url : '';
|
const text = status && status.url ? status.url : '';
|
||||||
navigator.clipboard.writeText(text)
|
this.copyText(text, 'Copied URL to clipboard.');
|
||||||
.then(() => this.showOutput('Copied URL to clipboard.'))
|
});
|
||||||
.catch(() => this.showOutput(text));
|
this.$.copyHealthCurlBtn.addEventListener('click', () => {
|
||||||
|
this.copyText(this.getCurlCommand('health'), 'Copied health curl command.');
|
||||||
|
});
|
||||||
|
this.$.copyToolsCurlBtn.addEventListener('click', () => {
|
||||||
|
this.copyText(this.getCurlCommand('tools'), 'Copied tools curl command.');
|
||||||
});
|
});
|
||||||
this.$.checkUpdatesBtn.addEventListener('click', () => this.runAction(() => request('check-updates')));
|
this.$.checkUpdatesBtn.addEventListener('click', () => this.runAction(() => request('check-updates')));
|
||||||
this.$.enabledInput.addEventListener('change', () => this.handleEnableToggle());
|
this.$.enabledInput.addEventListener('change', () => this.handleEnableToggle());
|
||||||
|
|||||||
@@ -0,0 +1,164 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const { createToolRegistry } = require('../lib/tool-registry');
|
||||||
|
|
||||||
|
const ROOT = path.resolve(__dirname, '..');
|
||||||
|
const OUTPUT_PATH = path.join(ROOT, 'docs', 'TOOLS.md');
|
||||||
|
|
||||||
|
function createRegistry(profile) {
|
||||||
|
return createToolRegistry({
|
||||||
|
getRuntimeContext: () => ({
|
||||||
|
config: { toolProfile: profile },
|
||||||
|
projectPath: '/tmp/funplay-cocos-docs-project',
|
||||||
|
version: '0.0.0-docs',
|
||||||
|
}),
|
||||||
|
interactionLog: { add() {} },
|
||||||
|
runtimeLog: { add() {}, list: () => [], clear: () => 0 },
|
||||||
|
sceneBridge: { call: async () => ({ ok: true }) },
|
||||||
|
editorExecutor: async () => ({ ok: true }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildToolModel() {
|
||||||
|
const fullRegistry = createRegistry('full');
|
||||||
|
const coreNames = new Set(createRegistry('core').listTools().map((tool) => tool.name));
|
||||||
|
const fullNames = new Set(fullRegistry.listTools().map((tool) => tool.name));
|
||||||
|
const catalog = fullRegistry.listToolCatalog()
|
||||||
|
.map((tool) => ({
|
||||||
|
name: tool.name,
|
||||||
|
category: tool.category || 'other',
|
||||||
|
profile: tool.profile || 'full',
|
||||||
|
enabledInCore: coreNames.has(tool.name),
|
||||||
|
enabledInFull: fullNames.has(tool.name),
|
||||||
|
readOnly: Boolean(tool.annotations && tool.annotations.readOnlyHint),
|
||||||
|
destructive: Boolean(tool.annotations && tool.annotations.destructiveHint),
|
||||||
|
description: normalizeDescription(tool.description),
|
||||||
|
}))
|
||||||
|
.sort((a, b) => a.category.localeCompare(b.category) || a.name.localeCompare(b.name));
|
||||||
|
|
||||||
|
return {
|
||||||
|
coreCount: coreNames.size,
|
||||||
|
fullCount: fullNames.size,
|
||||||
|
catalog,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeDescription(description) {
|
||||||
|
return String(description || '')
|
||||||
|
.replace(/\s+/g, ' ')
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildMarkdown(model) {
|
||||||
|
const categories = groupBy(model.catalog, (tool) => tool.category);
|
||||||
|
const lines = [
|
||||||
|
'# Tool Reference',
|
||||||
|
'',
|
||||||
|
'<!-- This file is generated by `npm run docs:generate`. Do not edit by hand. -->',
|
||||||
|
'',
|
||||||
|
`Generated from \`lib/tool-registry.js\`. The default \`core\` profile exposes ${model.coreCount} tools; the \`full\` profile exposes ${model.fullCount} tools.`,
|
||||||
|
'',
|
||||||
|
'## Profile Summary',
|
||||||
|
'',
|
||||||
|
'| Profile | Tool Count | Purpose |',
|
||||||
|
'|---|---:|---|',
|
||||||
|
`| \`core\` | ${model.coreCount} | Focused default surface for common editor automation. |`,
|
||||||
|
`| \`full\` | ${model.fullCount} | All built-in tools, including destructive and low-level helpers. |`,
|
||||||
|
'',
|
||||||
|
'## Core Tools',
|
||||||
|
'',
|
||||||
|
model.catalog
|
||||||
|
.filter((tool) => tool.enabledInCore)
|
||||||
|
.sort((a, b) => a.name.localeCompare(b.name))
|
||||||
|
.map((tool) => `\`${tool.name}\``)
|
||||||
|
.join(', '),
|
||||||
|
'',
|
||||||
|
'## Tools By Category',
|
||||||
|
'',
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const category of Object.keys(categories).sort()) {
|
||||||
|
const tools = categories[category];
|
||||||
|
lines.push(`### ${titleCase(category)}`);
|
||||||
|
lines.push('');
|
||||||
|
lines.push('| Tool | Profiles | Access | Description |');
|
||||||
|
lines.push('|---|---|---|---|');
|
||||||
|
for (const tool of tools) {
|
||||||
|
lines.push(`| \`${tool.name}\` | ${profileLabel(tool)} | ${accessLabel(tool)} | ${escapeTableCell(tool.description)} |`);
|
||||||
|
}
|
||||||
|
lines.push('');
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${lines.join('\n').replace(/\n{3,}/g, '\n\n')}\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function profileLabel(tool) {
|
||||||
|
return tool.enabledInCore ? '`core`, `full`' : '`full`';
|
||||||
|
}
|
||||||
|
|
||||||
|
function accessLabel(tool) {
|
||||||
|
if (tool.readOnly) {
|
||||||
|
return 'read-only';
|
||||||
|
}
|
||||||
|
if (tool.destructive) {
|
||||||
|
return 'mutating';
|
||||||
|
}
|
||||||
|
return 'stateful';
|
||||||
|
}
|
||||||
|
|
||||||
|
function titleCase(value) {
|
||||||
|
return String(value)
|
||||||
|
.split(/[-_\s]+/)
|
||||||
|
.filter(Boolean)
|
||||||
|
.map((part) => `${part.charAt(0).toUpperCase()}${part.slice(1)}`)
|
||||||
|
.join(' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeTableCell(value) {
|
||||||
|
return String(value || '')
|
||||||
|
.replace(/\|/g, '\\|')
|
||||||
|
.replace(/\n/g, '<br>');
|
||||||
|
}
|
||||||
|
|
||||||
|
function groupBy(values, getKey) {
|
||||||
|
return values.reduce((groups, value) => {
|
||||||
|
const key = getKey(value);
|
||||||
|
if (!groups[key]) {
|
||||||
|
groups[key] = [];
|
||||||
|
}
|
||||||
|
groups[key].push(value);
|
||||||
|
return groups;
|
||||||
|
}, {});
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeDocs(markdown) {
|
||||||
|
const directory = path.dirname(OUTPUT_PATH);
|
||||||
|
if (!fs.existsSync(directory)) {
|
||||||
|
fs.mkdirSync(directory, { recursive: true });
|
||||||
|
}
|
||||||
|
fs.writeFileSync(OUTPUT_PATH, markdown, 'utf8');
|
||||||
|
}
|
||||||
|
|
||||||
|
function checkDocs(markdown) {
|
||||||
|
const existing = fs.existsSync(OUTPUT_PATH) ? fs.readFileSync(OUTPUT_PATH, 'utf8') : '';
|
||||||
|
if (existing !== markdown) {
|
||||||
|
console.error('docs/TOOLS.md is out of date. Run `npm run docs:generate`.');
|
||||||
|
process.exitCode = 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function main() {
|
||||||
|
const args = process.argv.slice(2);
|
||||||
|
const markdown = buildMarkdown(buildToolModel());
|
||||||
|
if (args.includes('--check')) {
|
||||||
|
checkDocs(markdown);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
writeDocs(markdown);
|
||||||
|
console.log(`Wrote ${path.relative(ROOT, OUTPUT_PATH)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
main();
|
||||||
@@ -0,0 +1,541 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const childProcess = require('child_process');
|
||||||
|
const crypto = require('crypto');
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
const ROOT = path.resolve(__dirname, '..');
|
||||||
|
const PACKAGE_DIR_NAME = 'funplay-cocos-mcp';
|
||||||
|
const RELEASES_DIR = path.join(ROOT, 'releases');
|
||||||
|
const TEMP_DIR = path.join(ROOT, '.release-tmp');
|
||||||
|
const ZIP_PREFIX = 'Funplay.CocosMcp';
|
||||||
|
const REPOSITORY_URL = 'https://github.com/FunplayAI/funplay-cocos-mcp';
|
||||||
|
|
||||||
|
const REQUIRED_REPO_FILES = [
|
||||||
|
'package.json',
|
||||||
|
'README.md',
|
||||||
|
'README_CN.md',
|
||||||
|
'docs/TOOLS.md',
|
||||||
|
'RELEASE_WORKFLOW.md',
|
||||||
|
'RELEASE_CHECKLIST.md',
|
||||||
|
'CHANGELOG.md',
|
||||||
|
'CONTRIBUTING.md',
|
||||||
|
'LICENSE',
|
||||||
|
'server.json',
|
||||||
|
'bin/funplay-cocos-mcp.js',
|
||||||
|
'browser.js',
|
||||||
|
'scene.js',
|
||||||
|
'panel/index.js',
|
||||||
|
'lib/server.js',
|
||||||
|
'lib/tool-registry.js'
|
||||||
|
];
|
||||||
|
|
||||||
|
const PACKAGE_INCLUDES = [
|
||||||
|
'package.json',
|
||||||
|
'README.md',
|
||||||
|
'README_CN.md',
|
||||||
|
'docs',
|
||||||
|
'CHANGELOG.md',
|
||||||
|
'CONTRIBUTING.md',
|
||||||
|
'LICENSE',
|
||||||
|
'server.json',
|
||||||
|
'bin',
|
||||||
|
'browser.js',
|
||||||
|
'scene.js',
|
||||||
|
'panel',
|
||||||
|
'lib'
|
||||||
|
];
|
||||||
|
|
||||||
|
const FORBIDDEN_TRACKED_SEGMENTS = new Set([
|
||||||
|
'.idea',
|
||||||
|
'node_modules',
|
||||||
|
'Library',
|
||||||
|
'library',
|
||||||
|
'Temp',
|
||||||
|
'temp',
|
||||||
|
'dist',
|
||||||
|
'build',
|
||||||
|
'coverage',
|
||||||
|
'releases',
|
||||||
|
'.release-tmp'
|
||||||
|
]);
|
||||||
|
|
||||||
|
const FORBIDDEN_ARCHIVE_SEGMENTS = new Set([
|
||||||
|
'.git',
|
||||||
|
'.github',
|
||||||
|
'.idea',
|
||||||
|
'node_modules',
|
||||||
|
'Library',
|
||||||
|
'library',
|
||||||
|
'Temp',
|
||||||
|
'temp',
|
||||||
|
'dist',
|
||||||
|
'build',
|
||||||
|
'coverage',
|
||||||
|
'releases',
|
||||||
|
'.release-tmp',
|
||||||
|
'scripts',
|
||||||
|
'test'
|
||||||
|
]);
|
||||||
|
|
||||||
|
const FORBIDDEN_NAMES = new Set([
|
||||||
|
'.DS_Store'
|
||||||
|
]);
|
||||||
|
|
||||||
|
function main() {
|
||||||
|
const command = process.argv[2] || 'check';
|
||||||
|
const options = parseOptions(process.argv.slice(3));
|
||||||
|
|
||||||
|
if (command === 'check') {
|
||||||
|
const context = checkRelease(options);
|
||||||
|
console.log(`Release check passed for v${context.version}.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (command === 'package') {
|
||||||
|
const context = checkRelease(options);
|
||||||
|
const artifacts = packageRelease(context);
|
||||||
|
console.log(`Release package ready: ${path.relative(ROOT, artifacts.releaseDir)}`);
|
||||||
|
console.log(`- ${artifacts.zipName}`);
|
||||||
|
console.log('- release-manifest.json');
|
||||||
|
console.log('- SHA256SUMS.txt');
|
||||||
|
console.log('- README.md');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
printUsage();
|
||||||
|
process.exitCode = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseOptions(args) {
|
||||||
|
const options = {
|
||||||
|
version: '',
|
||||||
|
strictTag: false
|
||||||
|
};
|
||||||
|
|
||||||
|
for (let i = 0; i < args.length; i += 1) {
|
||||||
|
const arg = args[i];
|
||||||
|
if (arg === '--version' && args[i + 1]) {
|
||||||
|
options.version = args[i + 1];
|
||||||
|
i += 1;
|
||||||
|
} else if (arg === '--strict-tag') {
|
||||||
|
options.strictTag = true;
|
||||||
|
} else {
|
||||||
|
throw new Error(`Unknown release option: ${arg}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return options;
|
||||||
|
}
|
||||||
|
|
||||||
|
function checkRelease(options = {}) {
|
||||||
|
const errors = [];
|
||||||
|
const packageJson = readJson(path.join(ROOT, 'package.json'), errors);
|
||||||
|
const version = options.version || (packageJson && packageJson.version) || '';
|
||||||
|
const tag = `v${version}`;
|
||||||
|
|
||||||
|
if (!packageJson) {
|
||||||
|
throwErrors(errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.version && options.version !== packageJson.version) {
|
||||||
|
errors.push(`--version ${options.version} does not match package.json version ${packageJson.version}.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (packageJson.name !== 'funplay-cocos-mcp') {
|
||||||
|
errors.push('package.json name must be funplay-cocos-mcp.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Number.isInteger(packageJson.package_version) || packageJson.package_version <= 0) {
|
||||||
|
errors.push('package.json package_version must be a positive integer.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!packageJson.main || !fs.existsSync(path.join(ROOT, packageJson.main))) {
|
||||||
|
errors.push('package.json main must point to an existing file.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!packageJson.bin || packageJson.bin['funplay-cocos-mcp'] !== 'bin/funplay-cocos-mcp.js') {
|
||||||
|
errors.push('package.json bin.funplay-cocos-mcp must point to bin/funplay-cocos-mcp.js.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!/^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/.test(version)) {
|
||||||
|
errors.push(`package.json version must be semver-like, got: ${version}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const serverJson = readJson(path.join(ROOT, 'server.json'), errors);
|
||||||
|
if (serverJson) {
|
||||||
|
if (serverJson.name !== packageJson.mcpName) {
|
||||||
|
errors.push('server.json name must match package.json mcpName.');
|
||||||
|
}
|
||||||
|
if (serverJson.version !== version) {
|
||||||
|
errors.push(`server.json version ${serverJson.version} must match package.json version ${version}.`);
|
||||||
|
}
|
||||||
|
const npmPackage = Array.isArray(serverJson.packages)
|
||||||
|
? serverJson.packages.find((entry) => entry && entry.registryType === 'npm')
|
||||||
|
: null;
|
||||||
|
if (!npmPackage) {
|
||||||
|
errors.push('server.json must include an npm package entry.');
|
||||||
|
} else {
|
||||||
|
if (npmPackage.identifier !== packageJson.name) {
|
||||||
|
errors.push(`server.json npm identifier ${npmPackage.identifier} must match package.json name ${packageJson.name}.`);
|
||||||
|
}
|
||||||
|
if (npmPackage.version !== version) {
|
||||||
|
errors.push(`server.json npm package version ${npmPackage.version} must match package.json version ${version}.`);
|
||||||
|
}
|
||||||
|
if (!npmPackage.transport || npmPackage.transport.type !== 'stdio') {
|
||||||
|
errors.push('server.json npm package transport must be stdio.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const relative of REQUIRED_REPO_FILES) {
|
||||||
|
if (!fs.existsSync(path.join(ROOT, relative))) {
|
||||||
|
errors.push(`Missing required repository file: ${relative}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const changelogPath = path.join(ROOT, 'CHANGELOG.md');
|
||||||
|
const changelog = fs.existsSync(changelogPath) ? fs.readFileSync(changelogPath, 'utf8') : '';
|
||||||
|
if (version && !new RegExp(`^## \\[${escapeRegExp(version)}\\] - \\d{4}-\\d{2}-\\d{2}`, 'm').test(changelog)) {
|
||||||
|
errors.push(`CHANGELOG.md is missing a dated ## [${version}] release section.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const trackedFiles = gitLines(['ls-files']);
|
||||||
|
const forbiddenTracked = trackedFiles.filter(isForbiddenTrackedPath);
|
||||||
|
if (forbiddenTracked.length > 0) {
|
||||||
|
errors.push(`Tracked local/build junk must not be committed:\n- ${forbiddenTracked.join('\n- ')}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const relative of PACKAGE_INCLUDES) {
|
||||||
|
const fullPath = path.join(ROOT, relative);
|
||||||
|
if (!fs.existsSync(fullPath)) {
|
||||||
|
errors.push(`Package include path is missing: ${relative}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.strictTag && !gitTagExists(tag)) {
|
||||||
|
errors.push(`Git tag ${tag} does not exist. Create it before publishing.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
throwErrors(errors);
|
||||||
|
|
||||||
|
return {
|
||||||
|
packageJson,
|
||||||
|
version,
|
||||||
|
tag,
|
||||||
|
changelogNotes: extractChangelogNotes(changelog, version),
|
||||||
|
gitCommit: gitText(['rev-parse', 'HEAD']).trim(),
|
||||||
|
gitDirty: gitText(['status', '--porcelain']).trim() !== ''
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function packageRelease(context) {
|
||||||
|
ensureCommand('zip');
|
||||||
|
|
||||||
|
const releaseDir = path.join(RELEASES_DIR, context.version);
|
||||||
|
const stagingRoot = path.join(TEMP_DIR, PACKAGE_DIR_NAME);
|
||||||
|
const zipName = `${ZIP_PREFIX}.v${context.version}.zip`;
|
||||||
|
const zipPath = path.join(releaseDir, zipName);
|
||||||
|
|
||||||
|
fs.rmSync(releaseDir, { recursive: true, force: true });
|
||||||
|
fs.rmSync(TEMP_DIR, { recursive: true, force: true });
|
||||||
|
fs.mkdirSync(releaseDir, { recursive: true });
|
||||||
|
fs.mkdirSync(stagingRoot, { recursive: true });
|
||||||
|
|
||||||
|
for (const relative of PACKAGE_INCLUDES) {
|
||||||
|
copyIntoPackage(relative, stagingRoot);
|
||||||
|
}
|
||||||
|
|
||||||
|
const stagedFiles = collectFiles(stagingRoot)
|
||||||
|
.map((filePath) => path.relative(TEMP_DIR, filePath).split(path.sep).join('/'));
|
||||||
|
validateArchivePaths(stagedFiles);
|
||||||
|
|
||||||
|
run('zip', ['-qr', zipPath, PACKAGE_DIR_NAME], { cwd: TEMP_DIR });
|
||||||
|
validateZipListing(zipPath);
|
||||||
|
|
||||||
|
const zipSha256 = sha256File(zipPath);
|
||||||
|
const zipSize = fs.statSync(zipPath).size;
|
||||||
|
const manifest = buildManifest(context, {
|
||||||
|
zipName,
|
||||||
|
zipSha256,
|
||||||
|
zipSize,
|
||||||
|
fileCount: stagedFiles.length
|
||||||
|
});
|
||||||
|
|
||||||
|
const manifestPath = path.join(releaseDir, 'release-manifest.json');
|
||||||
|
fs.writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`);
|
||||||
|
|
||||||
|
const readmePath = path.join(releaseDir, 'README.md');
|
||||||
|
fs.writeFileSync(readmePath, buildReleaseReadme(context, manifest));
|
||||||
|
|
||||||
|
const checksums = [
|
||||||
|
checksumLine(zipPath, zipName),
|
||||||
|
checksumLine(manifestPath, 'release-manifest.json'),
|
||||||
|
checksumLine(readmePath, 'README.md')
|
||||||
|
].join('');
|
||||||
|
fs.writeFileSync(path.join(releaseDir, 'SHA256SUMS.txt'), checksums);
|
||||||
|
|
||||||
|
fs.rmSync(TEMP_DIR, { recursive: true, force: true });
|
||||||
|
|
||||||
|
return {
|
||||||
|
releaseDir,
|
||||||
|
zipName
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function copyIntoPackage(relative, stagingRoot) {
|
||||||
|
const source = path.join(ROOT, relative);
|
||||||
|
const destination = path.join(stagingRoot, relative);
|
||||||
|
fs.cpSync(source, destination, {
|
||||||
|
recursive: true,
|
||||||
|
force: true,
|
||||||
|
filter(sourcePath) {
|
||||||
|
const name = path.basename(sourcePath);
|
||||||
|
if (FORBIDDEN_NAMES.has(name)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const relativeSource = path.relative(ROOT, sourcePath).split(path.sep);
|
||||||
|
return !relativeSource.some((part) => FORBIDDEN_ARCHIVE_SEGMENTS.has(part));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildManifest(context, artifact) {
|
||||||
|
return {
|
||||||
|
version: context.version,
|
||||||
|
generatedAt: new Date().toISOString(),
|
||||||
|
repository: {
|
||||||
|
url: REPOSITORY_URL,
|
||||||
|
source: 'github'
|
||||||
|
},
|
||||||
|
git: {
|
||||||
|
tag: context.tag,
|
||||||
|
commit: context.gitCommit,
|
||||||
|
dirty: context.gitDirty
|
||||||
|
},
|
||||||
|
package: {
|
||||||
|
name: context.packageJson.name,
|
||||||
|
version: context.packageJson.version,
|
||||||
|
main: context.packageJson.main,
|
||||||
|
packageVersion: context.packageJson.package_version
|
||||||
|
},
|
||||||
|
artifacts: {
|
||||||
|
extensionZip: {
|
||||||
|
file: artifact.zipName,
|
||||||
|
sha256: artifact.zipSha256,
|
||||||
|
sizeBytes: artifact.zipSize,
|
||||||
|
fileCount: artifact.fileCount,
|
||||||
|
installDirectory: 'extensions/funplay-cocos-mcp',
|
||||||
|
githubDownloadUrl: `${REPOSITORY_URL}/releases/download/${context.tag}/${artifact.zipName}`
|
||||||
|
}
|
||||||
|
},
|
||||||
|
notes: firstMeaningfulLine(context.changelogNotes)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildReleaseReadme(context, manifest) {
|
||||||
|
const zip = manifest.artifacts.extensionZip;
|
||||||
|
return `# Funplay MCP for Cocos ${context.tag}
|
||||||
|
|
||||||
|
This folder contains the generated release artifacts for Funplay MCP for Cocos ${context.tag}.
|
||||||
|
|
||||||
|
## Artifacts
|
||||||
|
|
||||||
|
- \`${zip.file}\` - Cocos Creator extension package.
|
||||||
|
- \`release-manifest.json\` - Machine-readable release metadata.
|
||||||
|
- \`SHA256SUMS.txt\` - SHA-256 checksums for release artifacts.
|
||||||
|
|
||||||
|
## Install
|
||||||
|
|
||||||
|
1. Unzip \`${zip.file}\`.
|
||||||
|
2. Move the extracted \`${PACKAGE_DIR_NAME}\` folder into your Cocos project \`extensions/\` directory.
|
||||||
|
3. Restart Cocos Creator or reload extensions.
|
||||||
|
4. Open \`Funplay > MCP Server\`.
|
||||||
|
|
||||||
|
## Verify
|
||||||
|
|
||||||
|
\`\`\`bash
|
||||||
|
shasum -a 256 -c SHA256SUMS.txt
|
||||||
|
\`\`\`
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateArchivePaths(paths) {
|
||||||
|
const bad = [];
|
||||||
|
const prefix = `${PACKAGE_DIR_NAME}/`;
|
||||||
|
|
||||||
|
for (const archivePath of paths) {
|
||||||
|
const normalized = archivePath.replace(/\\/g, '/');
|
||||||
|
const parts = normalized.split('/').filter(Boolean);
|
||||||
|
if (!normalized.startsWith(prefix)) {
|
||||||
|
bad.push(`${archivePath} (must stay under ${PACKAGE_DIR_NAME}/)`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (parts.some((part) => part === '..' || part === '.')) {
|
||||||
|
bad.push(`${archivePath} (contains unsafe relative path segments)`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (parts.some((part) => FORBIDDEN_ARCHIVE_SEGMENTS.has(part) || FORBIDDEN_NAMES.has(part))) {
|
||||||
|
bad.push(`${archivePath} (contains forbidden release content)`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (bad.length > 0) {
|
||||||
|
throw new Error(`Release archive contains invalid paths:\n- ${bad.join('\n- ')}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateZipListing(zipPath) {
|
||||||
|
const result = childProcess.spawnSync('unzip', ['-Z1', zipPath], {
|
||||||
|
cwd: ROOT,
|
||||||
|
encoding: 'utf8'
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.error && result.error.code === 'ENOENT') {
|
||||||
|
console.warn('Warning: unzip is not available; skipped zip listing validation.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.status !== 0) {
|
||||||
|
throw new Error(`Failed to inspect ${zipPath}:\n${result.stderr || result.stdout}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const listing = result.stdout
|
||||||
|
.split(/\r?\n/)
|
||||||
|
.map((line) => line.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
validateArchivePaths(listing);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isForbiddenTrackedPath(relative) {
|
||||||
|
const parts = relative.split('/');
|
||||||
|
return parts.some((part) => FORBIDDEN_TRACKED_SEGMENTS.has(part) || FORBIDDEN_NAMES.has(part));
|
||||||
|
}
|
||||||
|
|
||||||
|
function readJson(filePath, errors) {
|
||||||
|
try {
|
||||||
|
return JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
||||||
|
} catch (error) {
|
||||||
|
errors.push(`${path.relative(ROOT, filePath)} is not valid JSON: ${error.message}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectFiles(directory) {
|
||||||
|
const entries = fs.readdirSync(directory, { withFileTypes: true });
|
||||||
|
const files = [];
|
||||||
|
for (const entry of entries) {
|
||||||
|
const fullPath = path.join(directory, entry.name);
|
||||||
|
if (entry.isDirectory()) {
|
||||||
|
files.push(...collectFiles(fullPath));
|
||||||
|
} else if (entry.isFile()) {
|
||||||
|
files.push(fullPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return files;
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractChangelogNotes(changelog, version) {
|
||||||
|
const heading = new RegExp(`^## \\[${escapeRegExp(version)}\\] - \\d{4}-\\d{2}-\\d{2}\\s*$`, 'm');
|
||||||
|
const match = heading.exec(changelog);
|
||||||
|
if (!match) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
const start = match.index + match[0].length;
|
||||||
|
const rest = changelog.slice(start);
|
||||||
|
const next = rest.search(/^## /m);
|
||||||
|
return (next >= 0 ? rest.slice(0, next) : rest).trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function firstMeaningfulLine(text) {
|
||||||
|
const line = text
|
||||||
|
.split(/\r?\n/)
|
||||||
|
.map((value) => value.trim())
|
||||||
|
.find((value) => value && !value.startsWith('###') && !value.startsWith('-'));
|
||||||
|
if (line) {
|
||||||
|
return line;
|
||||||
|
}
|
||||||
|
const bullet = text
|
||||||
|
.split(/\r?\n/)
|
||||||
|
.map((value) => value.trim())
|
||||||
|
.find((value) => value.startsWith('- '));
|
||||||
|
return bullet ? bullet.slice(2) : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function checksumLine(filePath, displayName) {
|
||||||
|
return `${sha256File(filePath)} ${displayName}\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sha256File(filePath) {
|
||||||
|
return crypto.createHash('sha256').update(fs.readFileSync(filePath)).digest('hex');
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureCommand(name) {
|
||||||
|
const result = childProcess.spawnSync(name, ['-v'], { encoding: 'utf8' });
|
||||||
|
if (result.error && result.error.code === 'ENOENT') {
|
||||||
|
throw new Error(`Required command not found: ${name}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function run(command, args, options = {}) {
|
||||||
|
const result = childProcess.spawnSync(command, args, {
|
||||||
|
cwd: options.cwd || ROOT,
|
||||||
|
encoding: 'utf8'
|
||||||
|
});
|
||||||
|
if (result.error) {
|
||||||
|
throw result.error;
|
||||||
|
}
|
||||||
|
if (result.status !== 0) {
|
||||||
|
throw new Error(`${command} ${args.join(' ')} failed:\n${result.stderr || result.stdout}`);
|
||||||
|
}
|
||||||
|
return result.stdout;
|
||||||
|
}
|
||||||
|
|
||||||
|
function gitLines(args) {
|
||||||
|
const text = gitText(args);
|
||||||
|
return text ? text.split(/\r?\n/).filter(Boolean) : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function gitText(args) {
|
||||||
|
const result = childProcess.spawnSync('git', args, {
|
||||||
|
cwd: ROOT,
|
||||||
|
encoding: 'utf8'
|
||||||
|
});
|
||||||
|
if (result.error || result.status !== 0) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
return result.stdout;
|
||||||
|
}
|
||||||
|
|
||||||
|
function gitTagExists(tag) {
|
||||||
|
const result = childProcess.spawnSync('git', ['rev-parse', '-q', '--verify', `refs/tags/${tag}`], {
|
||||||
|
cwd: ROOT,
|
||||||
|
encoding: 'utf8'
|
||||||
|
});
|
||||||
|
return result.status === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeRegExp(value) {
|
||||||
|
return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||||
|
}
|
||||||
|
|
||||||
|
function throwErrors(errors) {
|
||||||
|
if (errors.length > 0) {
|
||||||
|
throw new Error(`Release validation failed:\n- ${errors.join('\n- ')}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function printUsage() {
|
||||||
|
console.error(`Usage:
|
||||||
|
node scripts/release.js check [--version <version>] [--strict-tag]
|
||||||
|
node scripts/release.js package [--version <version>] [--strict-tag]`);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
main();
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error.message);
|
||||||
|
process.exitCode = 1;
|
||||||
|
}
|
||||||
+37
@@ -0,0 +1,37 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json",
|
||||||
|
"name": "io.github.FunplayAI/funplay-cocos-mcp",
|
||||||
|
"title": "Funplay Cocos MCP",
|
||||||
|
"description": "stdio bridge for the local Cocos Creator Editor MCP server from FunplayAI/funplay-cocos-mcp.",
|
||||||
|
"repository": {
|
||||||
|
"url": "https://github.com/FunplayAI/funplay-cocos-mcp",
|
||||||
|
"source": "github"
|
||||||
|
},
|
||||||
|
"version": "0.3.3",
|
||||||
|
"packages": [
|
||||||
|
{
|
||||||
|
"registryType": "npm",
|
||||||
|
"identifier": "funplay-cocos-mcp",
|
||||||
|
"version": "0.3.3",
|
||||||
|
"transport": {
|
||||||
|
"type": "stdio"
|
||||||
|
},
|
||||||
|
"environmentVariables": [
|
||||||
|
{
|
||||||
|
"name": "FUNPLAY_COCOS_MCP_URL",
|
||||||
|
"description": "Optional local Cocos MCP HTTP endpoint. Defaults to http://127.0.0.1:8765/.",
|
||||||
|
"format": "string",
|
||||||
|
"isRequired": false,
|
||||||
|
"isSecret": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "FUNPLAY_COCOS_MCP_TIMEOUT_SECONDS",
|
||||||
|
"description": "Optional HTTP timeout per request in seconds. Defaults to 120.",
|
||||||
|
"format": "string",
|
||||||
|
"isRequired": false,
|
||||||
|
"isSecret": false
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -66,6 +66,33 @@ function httpJson(port, payload, headers = {}) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function httpGet(port, path = '/', headers = {}) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const request = http.request(
|
||||||
|
{
|
||||||
|
host: '127.0.0.1',
|
||||||
|
port,
|
||||||
|
method: 'GET',
|
||||||
|
path,
|
||||||
|
headers,
|
||||||
|
},
|
||||||
|
(response) => {
|
||||||
|
const chunks = [];
|
||||||
|
response.on('data', (chunk) => chunks.push(chunk));
|
||||||
|
response.on('end', () => {
|
||||||
|
resolve({
|
||||||
|
statusCode: response.statusCode,
|
||||||
|
headers: response.headers,
|
||||||
|
body: Buffer.concat(chunks).toString('utf8'),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
);
|
||||||
|
request.on('error', reject);
|
||||||
|
request.end();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
test('initialize negotiates the current MCP protocol version by default', async () => {
|
test('initialize negotiates the current MCP protocol version by default', async () => {
|
||||||
const server = createServer();
|
const server = createServer();
|
||||||
const response = await server.handleRpcRequest({
|
const response = await server.handleRpcRequest({
|
||||||
@@ -222,6 +249,43 @@ test('HTTP notifications return 202 Accepted with no body', async () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('HTTP GET /tools returns debug tool metadata and curl examples', async () => {
|
||||||
|
const server = createServer({
|
||||||
|
listTools: () => [
|
||||||
|
{
|
||||||
|
name: 'get_project_info',
|
||||||
|
description: 'Return project info.',
|
||||||
|
inputSchema: { type: 'object', properties: {} },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
listToolCatalog: () => [
|
||||||
|
{
|
||||||
|
name: 'get_project_info',
|
||||||
|
description: 'Return project info.',
|
||||||
|
category: 'project',
|
||||||
|
profile: 'core',
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}, { port: 0 });
|
||||||
|
await server.start();
|
||||||
|
try {
|
||||||
|
const response = await httpGet(server.getPort(), '/tools?catalog=1');
|
||||||
|
const payload = JSON.parse(response.body);
|
||||||
|
|
||||||
|
assert.equal(response.statusCode, 200);
|
||||||
|
assert.equal(payload.ok, true);
|
||||||
|
assert.equal(payload.name, 'test-server');
|
||||||
|
assert.equal(payload.version, '0.0.0-test');
|
||||||
|
assert.equal(payload.count, 1);
|
||||||
|
assert.equal(payload.tools[0].category, 'project');
|
||||||
|
assert.match(payload.examples.health, /curl http:\/\/127\.0\.0\.1:\d+\/health/);
|
||||||
|
assert.match(payload.examples.tools, /curl http:\/\/127\.0\.0\.1:\d+\/tools/);
|
||||||
|
} finally {
|
||||||
|
await server.stop();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test('HTTP initialize can return an optional session id when sessions are enabled', async () => {
|
test('HTTP initialize can return an optional session id when sessions are enabled', async () => {
|
||||||
const server = createServer({}, { port: 0, enableSessions: true });
|
const server = createServer({}, { port: 0, enableSessions: true });
|
||||||
await server.start();
|
await server.start();
|
||||||
|
|||||||
@@ -0,0 +1,176 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const childProcess = require('node:child_process');
|
||||||
|
const http = require('node:http');
|
||||||
|
const path = require('node:path');
|
||||||
|
const test = require('node:test');
|
||||||
|
|
||||||
|
const ROOT = path.resolve(__dirname, '..');
|
||||||
|
const WRAPPER = path.join(ROOT, 'bin', 'funplay-cocos-mcp.js');
|
||||||
|
const PACKAGE = require('../package.json');
|
||||||
|
|
||||||
|
function writeFramed(stream, value) {
|
||||||
|
const payload = Buffer.from(JSON.stringify(value), 'utf8');
|
||||||
|
stream.write(`Content-Length: ${payload.length}\r\n\r\n`);
|
||||||
|
stream.write(payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
function createFramedReader(stream) {
|
||||||
|
let buffer = Buffer.alloc(0);
|
||||||
|
const waiters = [];
|
||||||
|
|
||||||
|
function readFromBuffer() {
|
||||||
|
const marker = buffer.indexOf('\r\n\r\n');
|
||||||
|
if (marker < 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const header = buffer.slice(0, marker).toString('ascii');
|
||||||
|
const match = /^content-length:\s*(\d+)$/im.exec(header);
|
||||||
|
if (!match) {
|
||||||
|
throw new Error(`Missing Content-Length header: ${header}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const length = Number.parseInt(match[1], 10);
|
||||||
|
const start = marker + 4;
|
||||||
|
const end = start + length;
|
||||||
|
if (buffer.length < end) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = buffer.slice(start, end).toString('utf8');
|
||||||
|
buffer = buffer.slice(end);
|
||||||
|
return JSON.parse(payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
function pump() {
|
||||||
|
while (waiters.length > 0) {
|
||||||
|
const value = readFromBuffer();
|
||||||
|
if (value === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
waiters.shift().resolve(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
stream.on('data', (chunk) => {
|
||||||
|
buffer = Buffer.concat([buffer, chunk]);
|
||||||
|
pump();
|
||||||
|
});
|
||||||
|
stream.on('error', (error) => {
|
||||||
|
while (waiters.length > 0) {
|
||||||
|
waiters.shift().reject(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return function readNext() {
|
||||||
|
const value = readFromBuffer();
|
||||||
|
if (value !== null) {
|
||||||
|
return Promise.resolve(value);
|
||||||
|
}
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
waiters.push({ resolve, reject });
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createHttpProxyTarget(handler) {
|
||||||
|
const server = http.createServer(handler);
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
server.once('error', reject);
|
||||||
|
server.listen(0, '127.0.0.1', () => {
|
||||||
|
resolve({
|
||||||
|
server,
|
||||||
|
url: `http://127.0.0.1:${server.address().port}/`
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function readBody(request) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const chunks = [];
|
||||||
|
request.on('data', (chunk) => chunks.push(chunk));
|
||||||
|
request.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
|
||||||
|
request.on('error', reject);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function waitForExit(child) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
child.once('error', reject);
|
||||||
|
child.once('exit', (code) => resolve(code));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test('stdio wrapper prints package version', () => {
|
||||||
|
const result = childProcess.spawnSync(process.execPath, [WRAPPER, '--version'], {
|
||||||
|
cwd: ROOT,
|
||||||
|
encoding: 'utf8'
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(result.status, 0);
|
||||||
|
assert.match(result.stderr, new RegExp(`funplay-cocos-mcp ${PACKAGE.version}`));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('stdio wrapper proxies framed JSON-RPC to the Cocos HTTP endpoint', async () => {
|
||||||
|
const requests = [];
|
||||||
|
const target = await createHttpProxyTarget(async (request, response) => {
|
||||||
|
const body = await readBody(request);
|
||||||
|
requests.push({
|
||||||
|
headers: request.headers,
|
||||||
|
body: JSON.parse(body)
|
||||||
|
});
|
||||||
|
|
||||||
|
response.setHeader('Content-Type', 'application/json');
|
||||||
|
if (requests.length === 1) {
|
||||||
|
response.setHeader('Mcp-Session-Id', 'session-1');
|
||||||
|
}
|
||||||
|
response.end(JSON.stringify({
|
||||||
|
jsonrpc: '2.0',
|
||||||
|
id: requests.length,
|
||||||
|
result: { ok: true, index: requests.length }
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
const child = childProcess.spawn(process.execPath, [WRAPPER, '--url', target.url], {
|
||||||
|
cwd: ROOT,
|
||||||
|
stdio: ['pipe', 'pipe', 'pipe']
|
||||||
|
});
|
||||||
|
const readNext = createFramedReader(child.stdout);
|
||||||
|
|
||||||
|
try {
|
||||||
|
writeFramed(child.stdin, {
|
||||||
|
jsonrpc: '2.0',
|
||||||
|
id: 1,
|
||||||
|
method: 'initialize',
|
||||||
|
params: {}
|
||||||
|
});
|
||||||
|
assert.deepEqual(await readNext(), {
|
||||||
|
jsonrpc: '2.0',
|
||||||
|
id: 1,
|
||||||
|
result: { ok: true, index: 1 }
|
||||||
|
});
|
||||||
|
|
||||||
|
writeFramed(child.stdin, {
|
||||||
|
jsonrpc: '2.0',
|
||||||
|
id: 2,
|
||||||
|
method: 'tools/list'
|
||||||
|
});
|
||||||
|
assert.deepEqual(await readNext(), {
|
||||||
|
jsonrpc: '2.0',
|
||||||
|
id: 2,
|
||||||
|
result: { ok: true, index: 2 }
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(requests.length, 2);
|
||||||
|
assert.equal(requests[0].headers.accept, 'application/json, text/event-stream');
|
||||||
|
assert.equal(requests[1].headers['mcp-session-id'], 'session-1');
|
||||||
|
assert.equal(requests[1].body.method, 'tools/list');
|
||||||
|
} finally {
|
||||||
|
child.stdin.end();
|
||||||
|
await waitForExit(child);
|
||||||
|
await new Promise((resolve) => target.server.close(resolve));
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const fs = require('node:fs');
|
||||||
|
const os = require('node:os');
|
||||||
|
const path = require('node:path');
|
||||||
|
const test = require('node:test');
|
||||||
|
const { buildSnippet, createFileTools } = require('../lib/tools/files');
|
||||||
|
|
||||||
|
function createSchema(properties, required) {
|
||||||
|
return { type: 'object', properties, required };
|
||||||
|
}
|
||||||
|
|
||||||
|
function createTools(projectPath) {
|
||||||
|
return createFileTools({
|
||||||
|
createSchema,
|
||||||
|
getRuntimeContext: () => ({ projectPath }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTool(tools, name) {
|
||||||
|
const tool = tools.find((item) => item.name === name);
|
||||||
|
assert.ok(tool, `Expected ${name} to exist`);
|
||||||
|
return tool;
|
||||||
|
}
|
||||||
|
|
||||||
|
test('buildSnippet returns focused line-numbered context', () => {
|
||||||
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'funplay-cocos-files-'));
|
||||||
|
try {
|
||||||
|
const filePath = path.join(root, 'sample.ts');
|
||||||
|
fs.writeFileSync(filePath, ['alpha', 'beta', 'gamma', 'delta'].join('\n'), 'utf8');
|
||||||
|
|
||||||
|
const snippet = buildSnippet(filePath, 2, 1);
|
||||||
|
|
||||||
|
assert.match(snippet, / 1 \| alpha/);
|
||||||
|
assert.match(snippet, />\s+2 \| beta/);
|
||||||
|
assert.match(snippet, / 3 \| gamma/);
|
||||||
|
assert.doesNotMatch(snippet, /delta/);
|
||||||
|
} finally {
|
||||||
|
fs.rmSync(root, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('file tools write, read, replace, search, list, and check project files', async () => {
|
||||||
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'funplay-cocos-files-'));
|
||||||
|
try {
|
||||||
|
fs.mkdirSync(path.join(root, 'assets'), { recursive: true });
|
||||||
|
const tools = createTools(root);
|
||||||
|
|
||||||
|
const writeResult = await getTool(tools, 'write_file').handler({
|
||||||
|
path: 'assets/player.ts',
|
||||||
|
content: 'const name = "Hero";\nconst clone = "Hero";\n',
|
||||||
|
});
|
||||||
|
assert.match(writeResult, /Wrote \d+ chars/);
|
||||||
|
|
||||||
|
const readResult = await getTool(tools, 'read_file').handler({ path: 'assets/player.ts' });
|
||||||
|
assert.match(readResult, /const name = "Hero"/);
|
||||||
|
|
||||||
|
await getTool(tools, 'replace_in_file').handler({
|
||||||
|
path: 'assets/player.ts',
|
||||||
|
search: 'Hero',
|
||||||
|
replace: 'Player',
|
||||||
|
replaceAll: true,
|
||||||
|
});
|
||||||
|
assert.equal(fs.readFileSync(path.join(root, 'assets', 'player.ts'), 'utf8').includes('Hero'), false);
|
||||||
|
|
||||||
|
const searchResult = await getTool(tools, 'search_files').handler({ pattern: '*.ts', directory: 'assets' });
|
||||||
|
assert.deepEqual(searchResult.files, ['assets/player.ts']);
|
||||||
|
|
||||||
|
const listResult = await getTool(tools, 'list_directory').handler({ path: 'assets' });
|
||||||
|
assert.deepEqual(listResult.entries, [{ name: 'player.ts', type: 'file' }]);
|
||||||
|
|
||||||
|
const existsResult = await getTool(tools, 'exists').handler({ path: 'assets/player.ts' });
|
||||||
|
assert.deepEqual(existsResult, {
|
||||||
|
path: 'assets/player.ts',
|
||||||
|
exists: true,
|
||||||
|
isFile: true,
|
||||||
|
isDirectory: false,
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
fs.rmSync(root, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user