5 Commits
Author SHA1 Message Date
winlifes 668898f0c6 Release v0.3.3 2026-05-20 20:13:18 -07:00
winlifes 87cfa48cda Ignore MCP registry token files 2026-05-20 19:29:34 -07:00
winlifes e3dc45468b Release v0.3.2 2026-05-20 19:24:33 -07:00
winlifes 63c18b0b5f Release v0.3.1 2026-05-20 19:15:04 -07:00
winlifes c57cf140a8 Release v0.3.0 2026-05-20 18:52:25 -07:00
26 changed files with 3921 additions and 322 deletions
+9
View File
@@ -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
+3
View File
@@ -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/
+50
View File
@@ -6,6 +6,56 @@ 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
### Added
- Added MCP `outputSchema` and `annotations` to listed tools.
- Added a standard structured tool result envelope with `ok`, `tool`, `callId`, `summary`, `data`, and follow-up `refs`.
- Added prefab workflow tools: `inspect_prefab`, `validate_prefab_references`, `duplicate_prefab`, `edit_prefab_json`, `create_prefab_instance`, `inspect_prefab_instance`, `apply_prefab_instance`, and `revert_prefab_instance`.
- Added `get_performance_snapshot` and expanded `validate_scene` with scene scale/performance-oriented counters.
- Added project AI instruction tools: `list_project_instructions`, `read_project_instruction`, `write_project_instruction`, and `create_project_skill`.
- Added tests for tool metadata, result envelopes, and project instruction helpers.
### Changed
- Expanded the default `core` profile from 28 tools to 34 tools.
- Expanded the `full` profile from 76 tools to 89 tools.
- Updated tool interaction logs to store concise result summaries from the standard envelope.
## [0.2.0] - 2026-05-20 ## [0.2.0] - 2026-05-20
### Added ### Added
+78 -13
View File
@@ -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,9 +235,11 @@ 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.
- The default `core` profile exposes 28 high-signal tools. Switch to `full` for all 76 tools, or use `custom` to include/exclude tool categories and individual tools. - `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 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.
- Tool listings include MCP `outputSchema` and `annotations`; structured tool results use a standard envelope with `ok`, `tool`, `callId`, `summary`, `data`, and follow-up `refs`.
- All exposed MCP tools execute directly. There is no extra approval toggle inside the Cocos extension. - All exposed MCP tools execute directly. There is no extra approval toggle inside the Cocos extension.
- File tools and `cocos://asset/path/...` resources are restricted to the active Cocos project root. - File tools and `cocos://asset/path/...` resources are restricted to the active Cocos project root.
- The recommended workflow is `execute_javascript` first, then focused helper tools for screenshots, diagnostics, assets, and inspection. - The recommended workflow is `execute_javascript` first, then focused helper tools for screenshots, diagnostics, assets, and inspection.
@@ -218,10 +256,10 @@ Try a higher-level prompt in your AI client:
## Highlights ## Highlights
- **76 Built-in Tools** — Scene hierarchy, editor state, selection workflows, assets, 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
@@ -235,20 +273,22 @@ Funplay MCP for Cocos follows the same design principles as Funplay MCP for Unit
| Embedded server | Built-in HTTP MCP server | Built-in HTTP MCP server | | Embedded server | Built-in HTTP MCP server | Built-in HTTP MCP server |
| Primary execution tool | `execute_javascript` | `execute_code` | | Primary execution tool | `execute_javascript` | `execute_code` |
| Primary language | JavaScript in scene/editor contexts | C# in Unity editor/runtime contexts | | Primary language | JavaScript in scene/editor contexts | C# in Unity editor/runtime contexts |
| Default profile | `core` with 28 tools | `core` focused tool profile | | Default profile | `core` with 34 tools | `core` focused tool profile |
| Full profile | 76 tools plus `custom` exposure | 79 tools | | Full profile | 89 tools plus `custom` exposure | 79 tools |
| Client setup | One-click config panel | One-click config window | | Client setup | One-click config panel | One-click config window |
## MCP Capabilities ## MCP Capabilities
The current package exposes four capability layers: The current package exposes four capability layers:
- **Tools** — 28 tools in `core`, 76 tools in `full`, plus `custom` include/exclude rules - **Tools** — 34 tools in `core`, 89 tools in `full`, plus `custom` include/exclude rules
- **Primary execution** — `execute_javascript` for scene/runtime and editor/browser automation - **Primary execution** — `execute_javascript` for scene/runtime and editor/browser automation
- **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
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`, `set_selection`, `get_project_info`, `get_scene_info`, `get_hierarchy`, `list_scenes`, `open_scene`, `list_assets`, `inspect_asset`, `open_asset`, `select_asset`, `run_script_diagnostics`, `get_recent_logs`, `search_project_logs`, `clear_logs`, `validate_scene`, `get_script_diagnostic_context`, `get_runtime_state`, `capture_editor_screenshot`, `capture_scene_screenshot`, `capture_preview_screenshot`, and `list_editor_windows`. 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`.
## Built-in Resources ## Built-in Resources
@@ -267,21 +307,22 @@ The default `core` set is intentionally small: `execute_javascript`, `execute_sc
## Built-in Tools ## Built-in Tools
Funplay MCP for Cocos currently ships with **76 tool functions** in the `full` profile: Funplay MCP for Cocos currently ships with **89 tool functions** in the `full` profile:
| Category | Tools | | Category | Tools |
|----------|-------| |----------|-------|
| **Script Execution** | `execute_javascript`, `execute_scene_script`, `execute_editor_script` | | **Script Execution** | `execute_javascript`, `execute_scene_script`, `execute_editor_script` |
| **Editor State** | `get_editor_state`, `get_tool_catalog`, `check_for_updates`, `get_selection`, `set_selection`, `get_editor_selection` | | **Editor State** | `get_editor_state`, `get_tool_catalog`, `check_for_updates`, `get_selection`, `set_selection`, `get_editor_selection` |
| **Project Instructions** | `list_project_instructions`, `read_project_instruction`, `write_project_instruction`, `create_project_skill` |
| **Project & Scene** | `get_project_info`, `get_scene_info`, `get_hierarchy`, `find_nodes`, `inspect_node`, `list_scenes`, `open_scene`, `run_scene_asset` | | **Project & Scene** | `get_project_info`, `get_scene_info`, `get_hierarchy`, `find_nodes`, `inspect_node`, `list_scenes`, `open_scene`, `run_scene_asset` |
| **Node Editing** | `create_node`, `delete_node`, `set_node_transform` | | **Node Editing** | `create_node`, `delete_node`, `set_node_transform` |
| **Assets & Prefabs** | `list_assets`, `inspect_asset`, `open_asset`, `select_asset`, `delete_asset`, `list_prefabs`, `instantiate_prefab`, `get_editor_selection` | | **Assets & Prefabs** | `list_assets`, `inspect_asset`, `open_asset`, `select_asset`, `delete_asset`, `list_prefabs`, `inspect_prefab`, `validate_prefab_references`, `duplicate_prefab`, `edit_prefab_json`, `create_prefab_instance`, `inspect_prefab_instance`, `apply_prefab_instance`, `revert_prefab_instance`, `instantiate_prefab` |
| **Components** | `list_components`, `inspect_component`, `add_component`, `remove_component`, `set_component_property`, `reset_component_property` | | **Components** | `list_components`, `inspect_component`, `add_component`, `remove_component`, `set_component_property`, `reset_component_property` |
| **UI** | `create_canvas`, `create_label`, `create_button`, `create_sprite` | | **UI** | `create_canvas`, `create_label`, `create_button`, `create_sprite` |
| **Camera** | `list_cameras`, `create_camera`, `set_camera_properties` | | **Camera** | `list_cameras`, `create_camera`, `set_camera_properties` |
| **Animation** | `list_animations`, `add_animation_clip`, `play_animation`, `stop_animation` | | **Animation** | `list_animations`, `add_animation_clip`, `play_animation`, `stop_animation` |
| **Files** | `read_file`, `get_file_snippet`, `write_file`, `replace_in_file`, `search_files`, `list_directory`, `exists`, `refresh_assets` | | **Files** | `read_file`, `get_file_snippet`, `write_file`, `replace_in_file`, `search_files`, `list_directory`, `exists`, `refresh_assets` |
| **Diagnostics & Logs** | `run_script_diagnostics`, `get_script_diagnostic_context`, `get_recent_logs`, `search_project_logs`, `clear_logs`, `validate_scene` | | **Diagnostics & Logs** | `run_script_diagnostics`, `get_script_diagnostic_context`, `get_recent_logs`, `search_project_logs`, `clear_logs`, `validate_scene`, `get_performance_snapshot` |
| **Runtime** | `get_runtime_state`, `pause_runtime`, `resume_runtime`, `set_time_scale` | | **Runtime** | `get_runtime_state`, `pause_runtime`, `resume_runtime`, `set_time_scale` |
| **Interaction** | `emit_node_event`, `simulate_button_click`, `invoke_component_method`, `simulate_mouse_click`, `simulate_mouse_drag`, `simulate_key_press`, `simulate_key_combo`, `simulate_preview_input` | | **Interaction** | `emit_node_event`, `simulate_button_click`, `invoke_component_method`, `simulate_mouse_click`, `simulate_mouse_drag`, `simulate_key_press`, `simulate_key_combo`, `simulate_preview_input` |
| **Screenshots & Windows** | `capture_desktop_screenshot`, `capture_editor_screenshot`, `capture_scene_screenshot`, `capture_game_screenshot`, `capture_preview_screenshot`, `list_editor_windows` | | **Screenshots & Windows** | `capture_desktop_screenshot`, `capture_editor_screenshot`, `capture_scene_screenshot`, `capture_game_screenshot`, `capture_preview_screenshot`, `list_editor_windows` |
@@ -356,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
+78 -13
View File
@@ -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,9 +235,11 @@ 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/`
- 如果配置端口被占用,服务会自动回退到下一个可用端口,面板与一键客户端配置会使用实际运行端口。 - 如果配置端口被占用,服务会自动回退到下一个可用端口,面板与一键客户端配置会使用实际运行端口。
- 默认 `core` profile 暴露 28 个高频工具;如果需要完整工具集,可在面板切到 `full`,暴露全部 76 个工具;也可以用 `custom` 按分类或工具名增删 - `GET /health``GET /tools` 是只读调试端点,方便不用 MCP 客户端也能快速检查本地服务
- 默认 `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。
- 工具列表会包含 MCP `outputSchema``annotations`;结构化工具结果统一使用包含 `ok``tool``callId``summary``data``refs` 的标准 envelope。
- 所有已暴露的 MCP 工具都会直接执行,Cocos 扩展里没有额外 approval 开关。 - 所有已暴露的 MCP 工具都会直接执行,Cocos 扩展里没有额外 approval 开关。
- 文件工具和 `cocos://asset/path/...` 资源默认只能访问当前 Cocos 项目根目录内的路径。 - 文件工具和 `cocos://asset/path/...` 资源默认只能访问当前 Cocos 项目根目录内的路径。
- 推荐工作流是优先使用 `execute_javascript`,再配合截图、诊断、资产、检查类工具。 - 推荐工作流是优先使用 `execute_javascript`,再配合截图、诊断、资产、检查类工具。
@@ -218,10 +256,10 @@ url = "http://127.0.0.1:8765/"
## 核心特性 ## 核心特性
- **76 个内置工具** — 覆盖场景层级、编辑器状态、选择工作流、资产、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 客户端
@@ -235,20 +273,22 @@ Funplay MCP for Cocos 延续 Funplay MCP for Unity 的设计原则,并针对 C
| 内置服务 | 内嵌 HTTP MCP Server | 内嵌 HTTP MCP Server | | 内置服务 | 内嵌 HTTP MCP Server | 内嵌 HTTP MCP Server |
| 主执行工具 | `execute_javascript` | `execute_code` | | 主执行工具 | `execute_javascript` | `execute_code` |
| 主语言 | 场景/编辑器上下文中的 JavaScript | Unity 编辑器/运行态中的 C# | | 主语言 | 场景/编辑器上下文中的 JavaScript | Unity 编辑器/运行态中的 C# |
| 默认工具集 | `core`28 个工具 | 聚焦版 `core` 工具集 | | 默认工具集 | `core`34 个工具 | 聚焦版 `core` 工具集 |
| 完整工具集 | 76 个工具,并支持 `custom` 暴露 | 79 个工具 | | 完整工具集 | 89 个工具,并支持 `custom` 暴露 | 79 个工具 |
| 客户端配置 | 一键配置面板 | 一键配置窗口 | | 客户端配置 | 一键配置面板 | 一键配置窗口 |
## MCP 能力结构 ## MCP 能力结构
当前包提供四层能力: 当前包提供四层能力:
- **Tools** — `core`28 个工具,`full`76 个工具,并支持 `custom` include/exclude 规则 - **Tools** — `core`34 个工具,`full`89 个工具,并支持 `custom` include/exclude 规则
- **Primary execution** — `execute_javascript` 用于场景/运行态和编辑器/browser 自动化 - **Primary execution** — `execute_javascript` 用于场景/运行态和编辑器/browser 自动化
- **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 交互历史
当前默认 `core` 工具集刻意保持精简,只包含:`execute_javascript``execute_scene_script``execute_editor_script``get_editor_state``get_tool_catalog``check_for_updates``get_selection``set_selection``get_project_info``get_scene_info``get_hierarchy``list_scenes``open_scene``list_assets``inspect_asset``open_asset``select_asset``run_script_diagnostics``get_recent_logs``search_project_logs``clear_logs``validate_scene``get_script_diagnostic_context``get_runtime_state``capture_editor_screenshot``capture_scene_screenshot``capture_preview_screenshot``list_editor_windows` 自动生成的工具参考文档见 [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`
## 内置 Resources ## 内置 Resources
@@ -267,21 +307,22 @@ Funplay MCP for Cocos 延续 Funplay MCP for Unity 的设计原则,并针对 C
## 内置工具 ## 内置工具
Funplay MCP for Cocos 当前在 `full` profile 下提供 **76 个工具函数** Funplay MCP for Cocos 当前在 `full` profile 下提供 **89 个工具函数**
| 分类 | 工具 | | 分类 | 工具 |
|------|------| |------|------|
| **脚本执行** | `execute_javascript`, `execute_scene_script`, `execute_editor_script` | | **脚本执行** | `execute_javascript`, `execute_scene_script`, `execute_editor_script` |
| **编辑器状态** | `get_editor_state`, `get_tool_catalog`, `check_for_updates`, `get_selection`, `set_selection`, `get_editor_selection` | | **编辑器状态** | `get_editor_state`, `get_tool_catalog`, `check_for_updates`, `get_selection`, `set_selection`, `get_editor_selection` |
| **项目指令** | `list_project_instructions`, `read_project_instruction`, `write_project_instruction`, `create_project_skill` |
| **项目与场景** | `get_project_info`, `get_scene_info`, `get_hierarchy`, `find_nodes`, `inspect_node`, `list_scenes`, `open_scene`, `run_scene_asset` | | **项目与场景** | `get_project_info`, `get_scene_info`, `get_hierarchy`, `find_nodes`, `inspect_node`, `list_scenes`, `open_scene`, `run_scene_asset` |
| **节点编辑** | `create_node`, `delete_node`, `set_node_transform` | | **节点编辑** | `create_node`, `delete_node`, `set_node_transform` |
| **资产与 Prefab** | `list_assets`, `inspect_asset`, `open_asset`, `select_asset`, `delete_asset`, `list_prefabs`, `instantiate_prefab`, `get_editor_selection` | | **资产与 Prefab** | `list_assets`, `inspect_asset`, `open_asset`, `select_asset`, `delete_asset`, `list_prefabs`, `inspect_prefab`, `validate_prefab_references`, `duplicate_prefab`, `edit_prefab_json`, `create_prefab_instance`, `inspect_prefab_instance`, `apply_prefab_instance`, `revert_prefab_instance`, `instantiate_prefab` |
| **组件** | `list_components`, `inspect_component`, `add_component`, `remove_component`, `set_component_property`, `reset_component_property` | | **组件** | `list_components`, `inspect_component`, `add_component`, `remove_component`, `set_component_property`, `reset_component_property` |
| **UI** | `create_canvas`, `create_label`, `create_button`, `create_sprite` | | **UI** | `create_canvas`, `create_label`, `create_button`, `create_sprite` |
| **相机** | `list_cameras`, `create_camera`, `set_camera_properties` | | **相机** | `list_cameras`, `create_camera`, `set_camera_properties` |
| **动画** | `list_animations`, `add_animation_clip`, `play_animation`, `stop_animation` | | **动画** | `list_animations`, `add_animation_clip`, `play_animation`, `stop_animation` |
| **文件** | `read_file`, `get_file_snippet`, `write_file`, `replace_in_file`, `search_files`, `list_directory`, `exists`, `refresh_assets` | | **文件** | `read_file`, `get_file_snippet`, `write_file`, `replace_in_file`, `search_files`, `list_directory`, `exists`, `refresh_assets` |
| **诊断与日志** | `run_script_diagnostics`, `get_script_diagnostic_context`, `get_recent_logs`, `search_project_logs`, `clear_logs`, `validate_scene` | | **诊断与日志** | `run_script_diagnostics`, `get_script_diagnostic_context`, `get_recent_logs`, `search_project_logs`, `clear_logs`, `validate_scene`, `get_performance_snapshot` |
| **运行态** | `get_runtime_state`, `pause_runtime`, `resume_runtime`, `set_time_scale` | | **运行态** | `get_runtime_state`, `pause_runtime`, `resume_runtime`, `set_time_scale` |
| **交互** | `emit_node_event`, `simulate_button_click`, `invoke_component_method`, `simulate_mouse_click`, `simulate_mouse_drag`, `simulate_key_press`, `simulate_key_combo`, `simulate_preview_input` | | **交互** | `emit_node_event`, `simulate_button_click`, `invoke_component_method`, `simulate_mouse_click`, `simulate_mouse_drag`, `simulate_key_press`, `simulate_key_combo`, `simulate_preview_input` |
| **截图与窗口** | `capture_desktop_screenshot`, `capture_editor_screenshot`, `capture_scene_screenshot`, `capture_game_screenshot`, `capture_preview_screenshot`, `list_editor_windows` | | **截图与窗口** | `capture_desktop_screenshot`, `capture_editor_screenshot`, `capture_scene_screenshot`, `capture_game_screenshot`, `capture_preview_screenshot`, `list_editor_windows` |
@@ -356,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、promptshealth check。 服务使用 MCP 风格的 HTTP JSON-RPC 2.0,支持 tools、resources、resource templates、promptshealth 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
``` ```
## 协议 ## 协议
+95
View File
@@ -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
+332
View File
@@ -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`.
+356
View File
@@ -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
View File
@@ -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
View File
@@ -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. |
+269
View File
@@ -0,0 +1,269 @@
'use strict';
const fs = require('fs');
const path = require('path');
const { listAssets, queryAssetData, queryAssetInfo, queryAssetMeta } = require('./assets');
const { resolveProjectPath } = require('./path-safety');
function requestEditorMessage(channel, method, ...args) {
if (!global.Editor || !Editor.Message || typeof Editor.Message.request !== 'function') {
throw new Error('Editor.Message.request is unavailable in the Cocos extension host.');
}
return Editor.Message.request(channel, method, ...args);
}
function assetUrlToPath(projectPath, url) {
if (!url || !String(url).startsWith('db://assets/')) {
return '';
}
return path.join(projectPath, String(url).slice('db://'.length));
}
function assetFilePath(projectPath, info) {
const candidates = [
info && info.file,
info && info.path,
info && info.source,
info && info.url ? assetUrlToPath(projectPath, info.url) : '',
].filter(Boolean);
for (const candidate of candidates) {
const fullPath = path.isAbsolute(candidate)
? resolveProjectPath(projectPath, candidate)
: resolveProjectPath(projectPath, candidate);
if (fs.existsSync(fullPath) && fs.statSync(fullPath).isFile()) {
return fullPath;
}
}
return '';
}
function collectUuidReferences(value, refs = [], pointer = '') {
if (!value || typeof value !== 'object') {
return refs;
}
if (Array.isArray(value)) {
value.forEach((item, index) => collectUuidReferences(item, refs, `${pointer}/${index}`));
return refs;
}
for (const [key, child] of Object.entries(value)) {
const childPointer = `${pointer}/${key}`;
if (
typeof child === 'string' &&
(key.toLowerCase().includes('uuid') || key === '__uuid__' || key === 'assetUuid' || key === 'prefabUuid')
) {
refs.push({ uuid: child, path: childPointer, key });
} else {
collectUuidReferences(child, refs, childPointer);
}
}
return refs;
}
function getByJsonPath(target, jsonPath) {
const segments = String(jsonPath || '')
.replace(/^\//, '')
.split(/[/.]/)
.map((segment) => segment.trim())
.filter(Boolean);
let current = target;
for (const segment of segments) {
if (current == null) {
return undefined;
}
current = current[segment];
}
return current;
}
function setByJsonPath(target, jsonPath, value) {
const segments = String(jsonPath || '')
.replace(/^\//, '')
.split(/[/.]/)
.map((segment) => segment.trim())
.filter(Boolean);
if (!segments.length) {
throw new Error('jsonPath is required.');
}
let current = target;
for (let index = 0; index < segments.length - 1; index += 1) {
const segment = segments[index];
if (current[segment] == null || typeof current[segment] !== 'object') {
current[segment] = {};
}
current = current[segment];
}
current[segments[segments.length - 1]] = value;
}
async function inspectPrefab(projectPath, target) {
const info = await queryAssetInfo(target);
const meta = await queryAssetMeta(target).catch(() => null);
const data = await queryAssetData(target).catch(() => null);
const filePath = assetFilePath(projectPath, info);
const content = filePath ? fs.readFileSync(filePath, 'utf8') : '';
const parsed = content ? JSON.parse(content) : data;
const references = collectUuidReferences(parsed).slice(0, 500);
return {
info,
meta,
filePath: filePath ? path.relative(projectPath, filePath).replace(/\\/g, '/') : '',
referenceCount: references.length,
references,
};
}
async function validatePrefabReferences(projectPath, options = {}) {
const targets = options.target
? [options.target]
: (await listAssets({ pattern: options.pattern || 'db://assets/**', ccType: 'cc.Prefab' }))
.slice(0, Number.isFinite(options.limit) ? Math.max(1, Math.min(200, options.limit)) : 50)
.map((asset) => asset.uuid || asset.url)
.filter(Boolean);
const prefabs = [];
for (const target of targets) {
const prefab = await inspectPrefab(projectPath, target);
const checked = [];
const missing = [];
for (const ref of prefab.references) {
try {
const info = await queryAssetInfo(ref.uuid);
checked.push({ ...ref, exists: true, asset: { uuid: info.uuid, url: info.url, type: info.type } });
} catch (error) {
missing.push({ ...ref, exists: false, error: error.message });
}
}
prefabs.push({
target,
filePath: prefab.filePath,
referenceCount: prefab.referenceCount,
checkedCount: checked.length + missing.length,
missingCount: missing.length,
missing,
});
}
const missingCount = prefabs.reduce((sum, prefab) => sum + prefab.missingCount, 0);
return {
ok: missingCount === 0,
prefabCount: prefabs.length,
missingCount,
prefabs,
};
}
async function duplicatePrefab(projectPath, options = {}) {
const source = String(options.source || '').trim();
const target = String(options.target || '').trim();
if (!source || !target) {
throw new Error('source and target are required.');
}
const info = await queryAssetInfo(source);
const sourcePath = assetFilePath(projectPath, info);
if (!sourcePath) {
throw new Error(`Prefab source file was not found: ${source}`);
}
const targetPath = resolveProjectPath(projectPath, target.endsWith('.prefab') ? target : `${target}.prefab`);
const assetsRoot = path.join(projectPath, 'assets');
const relativeToAssets = path.relative(assetsRoot, targetPath);
if (relativeToAssets.startsWith('..') || path.isAbsolute(relativeToAssets)) {
throw new Error('target must be inside the Cocos assets directory.');
}
if (fs.existsSync(targetPath) && options.overwrite !== true) {
throw new Error(`Target prefab already exists: ${target}`);
}
fs.mkdirSync(path.dirname(targetPath), { recursive: true });
fs.copyFileSync(sourcePath, targetPath);
return {
duplicated: true,
source: path.relative(projectPath, sourcePath).replace(/\\/g, '/'),
target: path.relative(projectPath, targetPath).replace(/\\/g, '/'),
};
}
async function editPrefabJson(projectPath, options = {}) {
const target = String(options.target || '').trim();
if (!target) {
throw new Error('target is required.');
}
const info = await queryAssetInfo(target);
const filePath = assetFilePath(projectPath, info);
if (!filePath) {
throw new Error(`Prefab file was not found: ${target}`);
}
const original = fs.readFileSync(filePath, 'utf8');
let updated = original;
if (options.search !== undefined) {
const search = String(options.search);
if (!search) {
throw new Error('search must not be empty.');
}
if (!original.includes(search)) {
throw new Error('search text was not found in prefab file.');
}
updated = options.replaceAll
? original.split(search).join(String(options.replace || ''))
: original.replace(search, String(options.replace || ''));
} else {
const json = JSON.parse(original);
const value = JSON.parse(String(options.valueJson || 'null'));
setByJsonPath(json, options.jsonPath, value);
updated = JSON.stringify(json, null, 2) + '\n';
}
JSON.parse(updated);
if (options.createBackup) {
fs.writeFileSync(`${filePath}.bak`, original, 'utf8');
}
fs.writeFileSync(filePath, updated, 'utf8');
return {
edited: true,
path: path.relative(projectPath, filePath).replace(/\\/g, '/'),
oldValue: options.jsonPath ? getByJsonPath(JSON.parse(original), options.jsonPath) : undefined,
validation: await validatePrefabReferences(projectPath, { target }),
};
}
async function applyPrefabInstance(nodeUuid) {
const uuid = String(nodeUuid || '').trim();
if (!uuid) {
throw new Error('node uuid is required.');
}
const result = await requestEditorMessage('scene', 'apply-prefab', uuid);
return { applied: true, uuid, result };
}
async function revertPrefabInstance(nodeUuid) {
const uuid = String(nodeUuid || '').trim();
if (!uuid) {
throw new Error('node uuid is required.');
}
const candidates = ['revert-prefab', 'restore-prefab'];
let lastError = null;
for (const method of candidates) {
try {
const result = await requestEditorMessage('scene', method, uuid);
return { reverted: true, uuid, method, result };
} catch (error) {
lastError = error;
}
}
throw lastError || new Error('No prefab revert editor message was available.');
}
module.exports = {
applyPrefabInstance,
duplicatePrefab,
editPrefabJson,
inspectPrefab,
revertPrefabInstance,
validatePrefabReferences,
};
+155
View File
@@ -0,0 +1,155 @@
'use strict';
const fs = require('fs');
const path = require('path');
const { resolveProjectPath } = require('./path-safety');
const KNOWN_INSTRUCTION_PATHS = [
'AGENTS.md',
'CLAUDE.md',
'GEMINI.md',
'.cursorrules',
'.windsurfrules',
'.github/copilot-instructions.md',
];
function normalizeSkillName(value) {
const normalized = String(value || '')
.trim()
.toLowerCase()
.replace(/[^a-z0-9_-]+/g, '-')
.replace(/^-+|-+$/g, '');
if (!normalized) {
throw new Error('skillName is required.');
}
return normalized;
}
function statFile(filePath) {
try {
return fs.statSync(filePath);
} catch (error) {
return null;
}
}
function listSkillFiles(projectPath) {
const skillRoot = resolveProjectPath(projectPath, '.codex/skills');
if (!fs.existsSync(skillRoot)) {
return [];
}
const skills = [];
const stack = [skillRoot];
while (stack.length) {
const current = stack.pop();
for (const entry of fs.readdirSync(current, { withFileTypes: true })) {
const fullPath = path.join(current, entry.name);
if (entry.isDirectory()) {
stack.push(fullPath);
continue;
}
if (entry.name === 'SKILL.md') {
const stat = statFile(fullPath);
skills.push({
path: path.relative(projectPath, fullPath).replace(/\\/g, '/'),
size: stat ? stat.size : 0,
mtime: stat ? stat.mtime.toISOString() : '',
});
}
}
}
return skills.sort((left, right) => left.path.localeCompare(right.path));
}
function listProjectInstructions(projectPath) {
const files = [];
for (const relativePath of KNOWN_INSTRUCTION_PATHS) {
const fullPath = resolveProjectPath(projectPath, relativePath);
const stat = statFile(fullPath);
if (stat && stat.isFile()) {
files.push({
path: relativePath,
size: stat.size,
mtime: stat.mtime.toISOString(),
});
}
}
return {
files,
skills: listSkillFiles(projectPath),
};
}
function readProjectInstruction(projectPath, target) {
const relativePath = String(target || '').trim();
if (!relativePath) {
throw new Error('target is required.');
}
const fullPath = resolveProjectPath(projectPath, relativePath);
if (!fs.existsSync(fullPath) || !fs.statSync(fullPath).isFile()) {
throw new Error(`Instruction file not found: ${relativePath}`);
}
return {
path: relativePath,
content: fs.readFileSync(fullPath, 'utf8'),
};
}
function writeProjectInstruction(projectPath, options = {}) {
const relativePath = String(options.target || '').trim();
if (!relativePath) {
throw new Error('target is required.');
}
const content = String(options.content || '');
const fullPath = resolveProjectPath(projectPath, relativePath);
if (fs.existsSync(fullPath) && options.overwrite === false) {
throw new Error(`Instruction file already exists: ${relativePath}`);
}
fs.mkdirSync(path.dirname(fullPath), { recursive: true });
fs.writeFileSync(fullPath, content, 'utf8');
const stat = fs.statSync(fullPath);
return {
written: true,
path: relativePath,
size: stat.size,
mtime: stat.mtime.toISOString(),
};
}
function createProjectSkill(projectPath, options = {}) {
const skillName = normalizeSkillName(options.skillName);
const title = String(options.title || skillName).trim();
const description = String(options.description || `Project-specific workflow for ${title}.`).trim();
const body = String(options.instructions || '').trim() || [
`Use this skill for ${title} work in this Cocos project.`,
'',
'- Inspect the active scene and project context before editing.',
'- Prefer focused MCP tools before broad manual file edits.',
'- Run relevant validation tools after changes.',
].join('\n');
const relativePath = `.codex/skills/${skillName}/SKILL.md`;
const content = [
`# ${title}`,
'',
`Description: ${description}`,
'',
'## Instructions',
body,
'',
].join('\n');
return writeProjectInstruction(projectPath, {
target: relativePath,
content,
overwrite: options.overwrite !== false,
});
}
module.exports = {
KNOWN_INSTRUCTION_PATHS,
createProjectSkill,
listProjectInstructions,
readProjectInstruction,
writeProjectInstruction,
};
+30 -3
View File
@@ -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);
@@ -570,10 +592,15 @@ class McpServer {
} }
return this.createResult(request.id, result); return this.createResult(request.id, result);
} catch (error) { } catch (error) {
return this.createResult(request.id, { const result = {
content: textContent(error.message), content: textContent(error.message),
isError: true, isError: true,
}); };
const structured = structuredContent(error.toolEnvelope);
if (structured) {
result.structuredContent = structured;
}
return this.createResult(request.id, result);
} }
} }
+429 -276
View File
@@ -1,7 +1,7 @@
'use strict'; 'use strict';
const crypto = require('crypto');
const fs = require('fs'); const fs = require('fs');
const path = require('path');
const { const {
clearSelection, clearSelection,
deleteAsset, deleteAsset,
@@ -22,11 +22,28 @@ const {
searchProjectLogs, searchProjectLogs,
} = require('./logs'); } = require('./logs');
const { resolveProjectPath } = require('./path-safety'); const { resolveProjectPath } = require('./path-safety');
const {
createProjectSkill,
listProjectInstructions,
readProjectInstruction,
writeProjectInstruction,
} = require('./project-instructions');
const {
applyPrefabInstance,
duplicatePrefab,
editPrefabJson,
inspectPrefab,
revertPrefabInstance,
validatePrefabReferences,
} = 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 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/],
@@ -35,6 +52,7 @@ const TOOL_CATEGORY_RULES = [
['files', /file|directory|exists|refresh_assets/], ['files', /file|directory|exists|refresh_assets/],
['assets', /asset|scene$|scenes|open_scene|run_scene_asset/], ['assets', /asset|scene$|scenes|open_scene|run_scene_asset/],
['prefabs', /prefab/], ['prefabs', /prefab/],
['instructions', /instruction|skill/],
['selection', /selection|select_/], ['selection', /selection|select_/],
['components', /component/], ['components', /component/],
['ui', /canvas|label|button|sprite/], ['ui', /canvas|label|button|sprite/],
@@ -43,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) {
@@ -57,6 +74,34 @@ function createSchema(properties, required) {
return schema; return schema;
} }
function createOutputSchema(dataSchema = {}) {
return {
type: 'object',
properties: {
ok: { type: 'boolean', description: 'Whether the tool call completed successfully.' },
tool: { type: 'string', description: 'Tool name that produced this result.' },
callId: { type: 'string', description: 'Stable identifier for this tool call result.' },
timestamp: { type: 'string', description: 'ISO timestamp when the result envelope was produced.' },
summary: { type: 'string', description: 'Short human-readable result summary.' },
data: dataSchema,
refs: {
type: 'array',
description: 'Stable references discovered in the result for follow-up tool calls.',
items: {
type: 'object',
properties: {
type: { type: 'string' },
id: { type: 'string' },
path: { type: 'string' },
name: { type: 'string' },
},
},
},
},
required: ['ok', 'tool', 'callId', 'timestamp', 'data'],
};
}
function inferToolCategory(toolName) { function inferToolCategory(toolName) {
for (const [category, pattern] of TOOL_CATEGORY_RULES) { for (const [category, pattern] of TOOL_CATEGORY_RULES) {
if (pattern.test(toolName)) { if (pattern.test(toolName)) {
@@ -86,6 +131,26 @@ function toolCategory(tool) {
return tool.category || inferToolCategory(tool.name); return tool.category || inferToolCategory(tool.name);
} }
function inferToolAnnotations(tool) {
const name = tool.name;
const category = toolCategory(tool);
const readOnly = /^(get|list|inspect|find|read|search|check|validate|exists|capture)/.test(name);
const destructive = /(delete|remove|clear|replace|write|reset|set_|execute|run_scene|invoke|emit|simulate)/.test(name);
const idempotent = readOnly || /^(set|select|open|pause|resume|stop|refresh)/.test(name);
return {
title: name
.split('_')
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
.join(' '),
readOnlyHint: readOnly,
destructiveHint: readOnly ? false : destructive,
idempotentHint: idempotent,
openWorldHint: category === 'updates',
...(tool.annotations || {}),
};
}
function isToolExposed(config, tool) { function isToolExposed(config, tool) {
const profile = config && config.toolProfile === 'full' const profile = config && config.toolProfile === 'full'
? 'full' ? 'full'
@@ -112,6 +177,115 @@ function isToolExposed(config, tool) {
return exposed; return exposed;
} }
function hashObject(value) {
return crypto
.createHash('sha256')
.update(safeStringify(value))
.digest('hex')
.slice(0, 16);
}
function summarizeResult(result) {
if (typeof result === 'string') {
if (result.startsWith(IMAGE_DATA_URI_PREFIX)) {
return 'Image payload returned.';
}
return result.length > 160 ? `${result.slice(0, 160)}...` : result;
}
if (!result || typeof result !== 'object') {
return String(result);
}
if (typeof result.summary === 'string') {
return result.summary;
}
for (const key of ['message', 'path', 'url', 'sceneName', 'projectName']) {
if (typeof result[key] === 'string' && result[key]) {
return `${key}: ${result[key]}`;
}
}
if (Number.isFinite(result.count)) {
return `count: ${result.count}`;
}
return 'Structured result returned.';
}
function normalizeEnvelopeData(result) {
if (typeof result === 'string' && result.startsWith(IMAGE_DATA_URI_PREFIX)) {
return {
image: true,
mimeType: 'image/png',
byteLength: Buffer.byteLength(result.slice(IMAGE_DATA_URI_PREFIX.length), 'base64'),
};
}
return result;
}
function addRef(refs, type, id, extra = {}) {
if (!id) {
return;
}
const key = `${type}:${id}`;
if (refs.some((ref) => ref.key === key)) {
return;
}
refs.push({ key, type, id: String(id), ...extra });
}
function collectRefs(value, refs = [], depth = 0, seen = new WeakSet()) {
if (!value || depth > 5) {
return refs;
}
if (Array.isArray(value)) {
for (const item of value) {
collectRefs(item, refs, depth + 1, seen);
}
return refs;
}
if (typeof value !== 'object') {
return refs;
}
if (seen.has(value)) {
return refs;
}
seen.add(value);
const uuid = value.uuid || value.prefabUuid || value.sceneUuid || value.assetUuid;
const pathValue = value.path || value.node || value.url;
if (uuid) {
addRef(refs, pathValue && String(pathValue).startsWith('db://') ? 'asset' : 'uuid', uuid, {
path: pathValue ? String(pathValue) : undefined,
name: value.name ? String(value.name) : undefined,
});
}
if (typeof pathValue === 'string' && pathValue) {
addRef(refs, pathValue.startsWith('db://') ? 'asset' : 'path', pathValue, {
name: value.name ? String(value.name) : undefined,
});
}
for (const item of Object.values(value)) {
collectRefs(item, refs, depth + 1, seen);
}
return refs;
}
function createResultEnvelope(tool, args, result, options = {}) {
const data = normalizeEnvelopeData(result);
const refs = collectRefs(data).map(({ key, ...ref }) => ref);
const timestamp = new Date().toISOString();
const summary = options.summary || summarizeResult(result);
const callId = `fp_${hashObject({ tool: tool.name, args: args || {}, result: data })}`;
return {
ok: options.ok !== false,
tool: tool.name,
callId,
timestamp,
summary,
data,
refs,
};
}
function summarizeDiagnostics(result) { function summarizeDiagnostics(result) {
if (!result) { if (!result) {
return null; return null;
@@ -132,89 +306,15 @@ function toOutput(value) {
return safeStringify(value); return safeStringify(value);
} }
function matchesPattern(fileName, pattern) { async function resolveNodeUuid(sceneBridge, args) {
const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*'); if (args && args.uuid) {
return new RegExp(`^${escaped}$`, 'i').test(fileName); return String(args.uuid);
} }
const inspected = await sceneBridge.call('inspectNode', args || {});
function searchFiles(rootDir, pattern, limit) { if (!inspected || !inspected.uuid) {
const results = []; throw new Error('Target node uuid could not be resolved.');
if (!fs.existsSync(rootDir)) {
return results;
} }
return inspected.uuid;
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.';
} }
function createToolRegistry({ getRuntimeContext, getStatus, interactionLog, runtimeLog, sceneBridge, editorExecutor }) { function createToolRegistry({ getRuntimeContext, getStatus, interactionLog, runtimeLog, sceneBridge, editorExecutor }) {
@@ -351,6 +451,67 @@ function createToolRegistry({ getRuntimeContext, getStatus, interactionLog, runt
inputSchema: createSchema({}, []), inputSchema: createSchema({}, []),
handler: async () => getCurrentSelection(), handler: async () => getCurrentSelection(),
}, },
{
name: 'list_project_instructions',
profile: 'core',
description: '[specialist] List project AI instruction files and local Codex project skills.',
inputSchema: createSchema({}, []),
handler: async () => {
const { projectPath } = getRuntimeContext();
return listProjectInstructions(projectPath);
},
},
{
name: 'read_project_instruction',
profile: 'core',
description: '[specialist] Read a project AI instruction file such as AGENTS.md, CLAUDE.md, or a .codex skill SKILL.md.',
inputSchema: createSchema(
{
target: { type: 'string', description: 'Project-relative instruction path.' },
},
['target']
),
handler: async (args) => {
const { projectPath } = getRuntimeContext();
return readProjectInstruction(projectPath, args.target);
},
},
{
name: 'write_project_instruction',
profile: 'full',
description: '[core] Create or update a project AI instruction file inside the Cocos project.',
inputSchema: createSchema(
{
target: { type: 'string', description: 'Project-relative instruction path.' },
content: { type: 'string', description: 'Instruction file content.' },
overwrite: { type: 'boolean', description: 'Allow overwriting an existing file. Defaults to true.' },
},
['target', 'content']
),
handler: async (args) => {
const { projectPath } = getRuntimeContext();
return writeProjectInstruction(projectPath, args);
},
},
{
name: 'create_project_skill',
profile: 'full',
description: '[core] Create a local Codex project skill under .codex/skills/{skillName}/SKILL.md.',
inputSchema: createSchema(
{
skillName: { type: 'string', description: 'Filesystem-safe project skill name.' },
title: { type: 'string', description: 'Human-readable skill title.' },
description: { type: 'string', description: 'Skill trigger description.' },
instructions: { type: 'string', description: 'Skill instructions body.' },
overwrite: { type: 'boolean', description: 'Allow overwriting an existing skill. Defaults to true.' },
},
['skillName']
),
handler: async (args) => {
const { projectPath } = getRuntimeContext();
return createProjectSkill(projectPath, args);
},
},
{ {
name: 'set_selection', name: 'set_selection',
profile: 'core', profile: 'core',
@@ -537,6 +698,166 @@ function createToolRegistry({ getRuntimeContext, getStatus, interactionLog, runt
return { count: assets.length, prefabs: assets.slice(0, 200) }; return { count: assets.length, prefabs: assets.slice(0, 200) };
}, },
}, },
{
name: 'inspect_prefab',
profile: 'core',
description: '[specialist] Inspect a prefab asset, its metadata, serialized file path, and UUID-like asset references.',
inputSchema: createSchema(
{
target: { type: 'string', description: 'Prefab uuid, db url, or project path.' },
},
['target']
),
handler: async (args) => {
const { projectPath } = getRuntimeContext();
return await inspectPrefab(projectPath, args.target);
},
},
{
name: 'validate_prefab_references',
profile: 'core',
description: '[specialist] Validate prefab asset references by checking serialized UUID references against asset-db.',
inputSchema: createSchema(
{
target: { type: 'string', description: 'Optional prefab uuid, db url, or path. When omitted, scans prefab assets.' },
pattern: { type: 'string', description: 'Optional asset-db pattern used when scanning prefabs.' },
limit: { type: 'number', description: 'Maximum prefab assets to scan when target is omitted.' },
},
[]
),
handler: async (args) => {
const { projectPath } = getRuntimeContext();
return await validatePrefabReferences(projectPath, args);
},
},
{
name: 'duplicate_prefab',
profile: 'full',
description: '[core] Create a new prefab asset by duplicating an existing prefab file without copying its .meta UUID.',
inputSchema: createSchema(
{
source: { type: 'string', description: 'Source prefab uuid, db url, or project path.' },
target: { type: 'string', description: 'Project-relative target path under assets, with or without .prefab.' },
overwrite: { type: 'boolean', description: 'Overwrite target prefab if it already exists.' },
},
['source', 'target']
),
handler: async (args) => {
const { projectPath } = getRuntimeContext();
const result = await duplicatePrefab(projectPath, args);
return { ...result, refresh: await refreshAssets(projectPath, resolveProjectPath(projectPath, result.target)) };
},
},
{
name: 'edit_prefab_json',
profile: 'full',
description: '[core] Edit a prefab JSON file by JSON path assignment or literal search/replace, then validate references.',
inputSchema: createSchema(
{
target: { type: 'string', description: 'Prefab uuid, db url, or project path.' },
jsonPath: { type: 'string', description: 'JSON path such as /0/_name or 0._name when assigning valueJson.' },
valueJson: { type: 'string', description: 'JSON encoded value to assign at jsonPath.' },
search: { type: 'string', description: 'Literal text to search for instead of jsonPath assignment.' },
replace: { type: 'string', description: 'Replacement text for literal search.' },
replaceAll: { type: 'boolean', description: 'Replace all literal matches.' },
createBackup: { type: 'boolean', description: 'Create a .bak file before writing.' },
},
['target']
),
handler: async (args) => {
const { projectPath } = getRuntimeContext();
const result = await editPrefabJson(projectPath, args);
return { ...result, refresh: await refreshAssets(projectPath, resolveProjectPath(projectPath, result.path)) };
},
},
{
name: 'create_prefab_instance',
profile: 'full',
description: '[core] Create a linked prefab instance in the editor hierarchy using Cocos scene create-node when available.',
inputSchema: createSchema(
{
prefabUuid: { type: 'string', description: 'Prefab asset uuid, db url, or path.' },
parentPath: { type: 'string', description: 'Optional parent node path.' },
name: { type: 'string', description: 'Optional override node name.' },
position: { type: 'object', description: 'Optional position {x,y,z}; fallback runtime path only.' },
},
['prefabUuid']
),
handler: async (args) => {
const info = await queryAssetInfo(args.prefabUuid);
const payload = {
assetUuid: info.uuid || args.prefabUuid,
unlinkPrefab: false,
};
if (args.parentPath) {
payload.parent = await resolveNodeUuid(sceneBridge, { path: args.parentPath });
}
if (args.name) {
payload.name = args.name;
}
if (global.Editor && Editor.Message && typeof Editor.Message.request === 'function') {
try {
const createdUuid = await Editor.Message.request('scene', 'create-node', payload);
return {
created: true,
linkedPrefab: true,
prefabUuid: payload.assetUuid,
uuid: createdUuid,
};
} catch (error) {
runtimeLog && runtimeLog.add('warn', `Linked prefab create-node failed: ${error.message}`);
}
}
return await sceneBridge.call('instantiatePrefab', {
...args,
prefabUuid: info.uuid || args.prefabUuid,
});
},
},
{
name: 'inspect_prefab_instance',
profile: 'core',
description: '[specialist] Inspect whether a scene node is linked to a prefab instance and return prefab metadata when available.',
inputSchema: createSchema(
{
path: { type: 'string', description: 'Node hierarchy path.' },
uuid: { type: 'string', description: 'Node uuid.' },
name: { type: 'string', description: 'Fallback exact node name.' },
},
[]
),
handler: async (args) => sceneBridge.call('getPrefabInstanceInfo', args),
},
{
name: 'apply_prefab_instance',
profile: 'full',
description: '[core] Apply a scene prefab instance back to its associated prefab asset using the Cocos editor scene apply-prefab message.',
inputSchema: createSchema(
{
path: { type: 'string', description: 'Node hierarchy path.' },
uuid: { type: 'string', description: 'Node uuid.' },
name: { type: 'string', description: 'Fallback exact node name.' },
},
[]
),
handler: async (args) => await applyPrefabInstance(await resolveNodeUuid(sceneBridge, args)),
},
{
name: 'revert_prefab_instance',
profile: 'full',
description: '[core] Revert a scene prefab instance from its associated prefab asset using available Cocos editor prefab revert messages.',
inputSchema: createSchema(
{
path: { type: 'string', description: 'Node hierarchy path.' },
uuid: { type: 'string', description: 'Node uuid.' },
name: { type: 'string', description: 'Fallback exact node name.' },
},
[]
),
handler: async (args) => await revertPrefabInstance(await resolveNodeUuid(sceneBridge, args)),
},
{ {
name: 'instantiate_prefab', name: 'instantiate_prefab',
profile: 'full', profile: 'full',
@@ -931,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',
@@ -1242,6 +1375,7 @@ function createToolRegistry({ getRuntimeContext, getStatus, interactionLog, runt
includeComponents: true, includeComponents: true,
}).catch((error) => ({ ok: false, error: error.message })); }).catch((error) => ({ ok: false, error: error.message }));
const runtime = await sceneBridge.call('getRuntimeState', {}).catch((error) => ({ ok: false, error: error.message })); const runtime = await sceneBridge.call('getRuntimeState', {}).catch((error) => ({ ok: false, error: error.message }));
const performance = await sceneBridge.call('getPerformanceSnapshot', {}).catch((error) => ({ ok: false, error: error.message }));
const diagnostics = args.includeScriptDiagnostics === false const diagnostics = args.includeScriptDiagnostics === false
? null ? null
: summarizeDiagnostics(await runScriptDiagnostics(projectPath, args).catch((error) => ({ ok: false, summary: error.message, diagnostics: [] }))); : summarizeDiagnostics(await runScriptDiagnostics(projectPath, args).catch((error) => ({ ok: false, summary: error.message, diagnostics: [] })));
@@ -1250,14 +1384,22 @@ function createToolRegistry({ getRuntimeContext, getStatus, interactionLog, runt
: searchProjectLogs(projectPath, { query: 'error', limit: 20 }).matches; : searchProjectLogs(projectPath, { query: 'error', limit: 20 }).matches;
return { return {
ok: !scene.error && !runtime.error && (!diagnostics || diagnostics.ok) && (!logErrors || logErrors.length === 0), ok: !scene.error && !runtime.error && !performance.error && (!diagnostics || diagnostics.ok) && (!logErrors || logErrors.length === 0),
scene, scene,
runtime, runtime,
performance,
diagnostics, diagnostics,
logErrors, logErrors,
}; };
}, },
}, },
{
name: 'get_performance_snapshot',
profile: 'core',
description: '[specialist] Return scene scale and runtime performance-oriented counters such as node/component counts, UI counts, depth, memory, and warnings.',
inputSchema: createSchema({}, []),
handler: async (args) => sceneBridge.call('getPerformanceSnapshot', args),
},
{ {
name: 'get_runtime_state', name: 'get_runtime_state',
profile: 'core', profile: 'core',
@@ -1580,6 +1722,8 @@ function createToolRegistry({ getRuntimeContext, getStatus, interactionLog, runt
name: tool.name, name: tool.name,
description: tool.description, description: tool.description,
inputSchema: tool.inputSchema, inputSchema: tool.inputSchema,
outputSchema: tool.outputSchema || createOutputSchema(tool.dataSchema),
annotations: inferToolAnnotations(tool),
})); }));
}, },
listToolCatalog() { listToolCatalog() {
@@ -1589,6 +1733,8 @@ function createToolRegistry({ getRuntimeContext, getStatus, interactionLog, runt
description: tool.description, description: tool.description,
profile: tool.profile, profile: tool.profile,
category: toolCategory(tool), category: toolCategory(tool),
annotations: inferToolAnnotations(tool),
outputSchema: tool.outputSchema || createOutputSchema(tool.dataSchema),
enabled: isToolExposed(config || {}, tool), enabled: isToolExposed(config || {}, tool),
})); }));
}, },
@@ -1604,14 +1750,21 @@ function createToolRegistry({ getRuntimeContext, getStatus, interactionLog, runt
try { try {
const result = await tool.handler(args || {}); const result = await tool.handler(args || {});
const output = toOutput(result); const envelope = createResultEnvelope(tool, args || {}, result);
interactionLog.add(name, 'success', output.slice(0, 500)); const output = typeof result === 'string' && result.startsWith(IMAGE_DATA_URI_PREFIX)
? result
: toOutput(envelope);
interactionLog.add(name, 'success', envelope.summary.slice(0, 500));
return { return {
value: result, value: envelope,
text: output, text: output,
}; };
} catch (error) { } catch (error) {
interactionLog.add(name, 'error', error.message); interactionLog.add(name, 'error', error.message);
error.toolEnvelope = createResultEnvelope(tool, args || {}, { message: error.message }, {
ok: false,
summary: error.message,
});
throw error; throw error;
} }
}, },
+303
View File
@@ -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
View File
@@ -1,14 +1,63 @@
{ {
"name": "funplay-cocos-mcp", "name": "funplay-cocos-mcp",
"package_version": 2, "package_version": 2,
"version": "0.2.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/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
View File
@@ -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());
+130
View File
@@ -308,6 +308,97 @@ function findComponentsByClass(componentClass) {
return results; return results;
} }
function getPrefabInfo(node) {
const prefab = node && node._prefab;
if (!prefab) {
return {
linked: false,
};
}
const asset = prefab.asset || prefab._asset || null;
return {
linked: Boolean(asset || prefab.fileId || prefab.root),
fileId: prefab.fileId || '',
asset: asset
? {
name: asset.name || '',
uuid: asset.uuid || asset._uuid || '',
}
: null,
instance: prefab.instance ? plain(prefab.instance) : null,
sync: prefab.sync,
rawKeys: Object.keys(prefab).slice(0, 50),
};
}
function collectSceneStats() {
const stats = {
nodeCount: 0,
activeNodeCount: 0,
inactiveNodeCount: 0,
maxDepth: 0,
componentCount: 0,
componentsByType: {},
prefabInstanceCount: 0,
uiTransformCount: 0,
canvasCount: 0,
cameraCount: 0,
labelCount: 0,
spriteCount: 0,
buttonCount: 0,
};
function visit(node, depth) {
if (node !== getScene()) {
stats.nodeCount += 1;
stats.maxDepth = Math.max(stats.maxDepth, depth);
if (node.active) stats.activeNodeCount += 1;
else stats.inactiveNodeCount += 1;
if (node._prefab) stats.prefabInstanceCount += 1;
}
for (const component of node.components || []) {
const name = component && component.constructor ? component.constructor.name : 'UnknownComponent';
stats.componentCount += 1;
stats.componentsByType[name] = (stats.componentsByType[name] || 0) + 1;
if (component instanceof UITransform) stats.uiTransformCount += 1;
if (component instanceof Canvas) stats.canvasCount += 1;
if (component instanceof Camera) stats.cameraCount += 1;
if (component instanceof Label) stats.labelCount += 1;
if (component instanceof Sprite) stats.spriteCount += 1;
if (component instanceof Button) stats.buttonCount += 1;
}
for (const child of node.children) {
visit(child, depth + 1);
}
}
visit(getScene(), 0);
return stats;
}
function buildSceneWarnings(stats) {
const warnings = [];
if (stats.nodeCount === 0) {
warnings.push({ severity: 'warn', code: 'empty_scene', message: 'The active scene has no child nodes.' });
}
if (stats.cameraCount === 0) {
warnings.push({ severity: 'warn', code: 'missing_camera', message: 'No Camera component was found in the active scene.' });
}
if (stats.nodeCount > 500) {
warnings.push({ severity: 'info', code: 'large_node_count', message: `Scene has ${stats.nodeCount} nodes.` });
}
if (stats.maxDepth > 12) {
warnings.push({ severity: 'info', code: 'deep_hierarchy', message: `Scene hierarchy depth is ${stats.maxDepth}.` });
}
if (stats.labelCount > 80) {
warnings.push({ severity: 'info', code: 'many_labels', message: `Scene has ${stats.labelCount} Label components.` });
}
return warnings;
}
function getScheduler() { function getScheduler() {
return typeof director.getScheduler === 'function' ? director.getScheduler() : null; return typeof director.getScheduler === 'function' ? director.getScheduler() : null;
} }
@@ -1149,6 +1240,45 @@ exports.methods = {
}; };
}, },
async getPerformanceSnapshot() {
const scheduler = getScheduler();
const stats = collectSceneStats();
const memory = typeof performance !== 'undefined' && performance.memory
? {
jsHeapSizeLimit: performance.memory.jsHeapSizeLimit,
totalJSHeapSize: performance.memory.totalJSHeapSize,
usedJSHeapSize: performance.memory.usedJSHeapSize,
}
: null;
return {
sceneName: getScene().name,
runtime: {
paused: typeof director.isPaused === 'function' ? director.isPaused() : false,
timeScale: scheduler && typeof scheduler.getTimeScale === 'function' ? scheduler.getTimeScale() : 1,
totalFrames: typeof director.getTotalFrames === 'function' ? director.getTotalFrames() : undefined,
},
stats,
memory,
warnings: buildSceneWarnings(stats),
};
},
async getPrefabInstanceInfo(options = {}) {
const node = findNode(options);
if (!node) {
throw new Error('Target node was not found.');
}
return {
node: {
name: node.name,
path: getNodePath(node),
uuid: node.uuid,
},
prefab: getPrefabInfo(node),
};
},
async pauseRuntime() { async pauseRuntime() {
if (typeof director.pause === 'function') { if (typeof director.pause === 'function') {
director.pause(); director.pause();
+164
View File
@@ -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();
+541
View File
@@ -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
View File
@@ -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
}
]
}
]
}
+48
View File
@@ -0,0 +1,48 @@
'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 {
createProjectSkill,
listProjectInstructions,
readProjectInstruction,
writeProjectInstruction,
} = require('../lib/project-instructions');
test('project instruction helpers list, read, and write safe project files', () => {
const projectPath = fs.mkdtempSync(path.join(os.tmpdir(), 'funplay-cocos-instructions-'));
const write = writeProjectInstruction(projectPath, {
target: 'AGENTS.md',
content: '# Agent Notes\n',
});
assert.equal(write.written, true);
assert.equal(readProjectInstruction(projectPath, 'AGENTS.md').content, '# Agent Notes\n');
const listed = listProjectInstructions(projectPath);
assert.equal(listed.files.some((file) => file.path === 'AGENTS.md'), true);
});
test('createProjectSkill writes a Codex project skill', () => {
const projectPath = fs.mkdtempSync(path.join(os.tmpdir(), 'funplay-cocos-skill-'));
const result = createProjectSkill(projectPath, {
skillName: 'scene qa',
title: 'Scene QA',
description: 'Validate Cocos scenes.',
});
assert.equal(result.path, '.codex/skills/scene-qa/SKILL.md');
const listed = listProjectInstructions(projectPath);
assert.equal(listed.skills.some((skill) => skill.path === result.path), true);
});
test('project instruction helpers reject traversal outside the project', () => {
const projectPath = fs.mkdtempSync(path.join(os.tmpdir(), 'funplay-cocos-instructions-safe-'));
assert.throws(
() => writeProjectInstruction(projectPath, { target: '../AGENTS.md', content: 'x' }),
/outside the Cocos project/
);
});
+64
View File
@@ -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();
+176
View File
@@ -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));
}
});
+33 -7
View File
@@ -5,7 +5,7 @@ const path = require('node:path');
const test = require('node:test'); const test = require('node:test');
const { createToolRegistry } = require('../lib/tool-registry'); const { createToolRegistry } = require('../lib/tool-registry');
function createRegistry(profile, projectPath = path.resolve('/tmp/funplay-cocos-test-project'), configExtras = {}) { function createRegistry(profile, projectPath = path.resolve('/tmp/funplay-cocos-test-project'), configExtras = {}, overrides = {}) {
return createToolRegistry({ return createToolRegistry({
getRuntimeContext: () => ({ getRuntimeContext: () => ({
config: { toolProfile: profile, ...configExtras }, config: { toolProfile: profile, ...configExtras },
@@ -13,31 +13,42 @@ function createRegistry(profile, projectPath = path.resolve('/tmp/funplay-cocos-
version: '0.0.0-test', version: '0.0.0-test',
}), }),
interactionLog: { add() {} }, interactionLog: { add() {} },
runtimeLog: { list: () => [], clear: () => 0 }, runtimeLog: { add() {}, list: () => [], clear: () => 0 },
sceneBridge: { call: async () => ({ ok: true }) }, sceneBridge: overrides.sceneBridge || { call: async () => ({ ok: true }) },
editorExecutor: async () => ({ ok: true }), editorExecutor: overrides.editorExecutor || (async () => ({ ok: true })),
}); });
} }
test('core profile exposes the documented focused tool set', () => { test('core profile exposes the documented focused tool set', () => {
const tools = createRegistry('core').listTools(); const tools = createRegistry('core').listTools();
assert.equal(tools.length, 28); assert.equal(tools.length, 34);
assert.equal(tools.some((tool) => tool.name === 'execute_javascript'), true); assert.equal(tools.some((tool) => tool.name === 'execute_javascript'), true);
assert.equal(tools.some((tool) => tool.name === 'get_editor_state'), true); assert.equal(tools.some((tool) => tool.name === 'get_editor_state'), true);
assert.equal(tools.some((tool) => tool.name === 'get_tool_catalog'), true); assert.equal(tools.some((tool) => tool.name === 'get_tool_catalog'), true);
assert.equal(tools.some((tool) => tool.name === 'validate_scene'), true); assert.equal(tools.some((tool) => tool.name === 'validate_scene'), true);
assert.equal(tools.some((tool) => tool.name === 'get_performance_snapshot'), true);
assert.equal(tools.some((tool) => tool.name === 'list_project_instructions'), true);
assert.equal(tools.some((tool) => tool.name === 'set_selection'), true); assert.equal(tools.some((tool) => tool.name === 'set_selection'), true);
assert.equal(tools.some((tool) => tool.name === 'write_file'), false); assert.equal(tools.some((tool) => tool.name === 'write_file'), false);
}); });
test('full profile exposes all built-in tools', () => { test('full profile exposes all built-in tools', () => {
const tools = createRegistry('full').listTools(); const tools = createRegistry('full').listTools();
assert.equal(tools.length, 76); assert.equal(tools.length, 89);
assert.equal(tools.some((tool) => tool.name === 'write_file'), true); assert.equal(tools.some((tool) => tool.name === 'write_file'), true);
assert.equal(tools.some((tool) => tool.name === 'edit_prefab_json'), true);
assert.equal(tools.some((tool) => tool.name === 'create_project_skill'), true);
assert.equal(tools.some((tool) => tool.name === 'get_editor_state'), true); assert.equal(tools.some((tool) => tool.name === 'get_editor_state'), true);
assert.equal(tools.some((tool) => tool.name === 'set_selection'), true); assert.equal(tools.some((tool) => tool.name === 'set_selection'), true);
}); });
test('tool definitions include MCP outputSchema and annotations', () => {
const tool = createRegistry('core').listTools().find((item) => item.name === 'get_project_info');
assert.equal(tool.outputSchema.type, 'object');
assert.equal(tool.outputSchema.properties.ok.type, 'boolean');
assert.equal(tool.annotations.readOnlyHint, true);
});
test('custom profile can expose a category and disable a specific tool', () => { test('custom profile can expose a category and disable a specific tool', () => {
const tools = createRegistry('custom', path.resolve('/tmp/funplay-cocos-test-project'), { const tools = createRegistry('custom', path.resolve('/tmp/funplay-cocos-test-project'), {
enabledToolCategories: ['files'], enabledToolCategories: ['files'],
@@ -69,6 +80,21 @@ test('file tools reject writes outside the project root', async () => {
test('callToolDetailed preserves structured values and text output', async () => { test('callToolDetailed preserves structured values and text output', async () => {
const registry = createRegistry('core'); const registry = createRegistry('core');
const result = await registry.callToolDetailed('get_project_info', {}); const result = await registry.callToolDetailed('get_project_info', {});
assert.equal(result.value.projectPath, path.resolve('/tmp/funplay-cocos-test-project')); assert.equal(result.value.ok, true);
assert.equal(result.value.tool, 'get_project_info');
assert.equal(result.value.data.projectPath, path.resolve('/tmp/funplay-cocos-test-project'));
assert.match(result.value.callId, /^fp_/);
assert.match(result.text, /projectPath/); assert.match(result.text, /projectPath/);
}); });
test('callToolDetailed preserves screenshot image text while keeping structured envelope small', async () => {
const dataUri = 'data:image/png;base64,AAAA';
const registry = createRegistry('core', path.resolve('/tmp/funplay-cocos-test-project'), {}, {
editorExecutor: async () => dataUri,
});
const result = await registry.callToolDetailed('execute_javascript', { context: 'editor', code: 'return image;' });
assert.equal(result.text, dataUri);
assert.equal(result.value.data.image, true);
assert.equal(result.value.data.mimeType, 'image/png');
});
+83
View File
@@ -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 });
}
});