4 Commits
Author SHA1 Message Date
winlifes a9b539ca9c docs: add Cocos Store install link to README 2026-06-14 21:23:14 -07:00
winlifes 6405dced7d Release v0.4.0 2026-06-10 20:26:09 -07:00
winlifes 03e5ab8dfe Use English GitHub release notes 2026-05-20 20:57:47 -07:00
winlifes be8c1d624f Fix GitHub release notes workflow 2026-05-20 20:51:33 -07:00
28 changed files with 2118 additions and 60 deletions
+47 -2
View File
@@ -6,23 +6,68 @@ This project follows a simple changelog format inspired by [Keep a Changelog](ht
## [Unreleased]
## [0.4.0] - 2026-06-11
### Added
- Added project identity metadata to `/health` and MCP `initialize` responses so clients and duplicate listeners can verify the active Cocos project safely.
- Added same-project listener attach behavior before port fallback, preventing accidental attachment to a different Cocos project on the same port.
- Added default-on JavaScript safety checks for `execute_javascript`, `execute_scene_script`, and `execute_editor_script`, with per-call `safety_checks` overrides.
- Added named tool profiles in the Cocos panel, including save, apply, delete, import, and export workflows.
- Added category-level tool exposure controls in the panel for quick enable, disable, and clear actions.
- Added asset dependency tools: `inspect_asset_dependencies` and `validate_asset_dependencies`.
- Added Cocos project/editor tools: `get_build_status`, `open_build_panel`, `run_project_preview`, `save_current_scene`, `get_editor_preference`, `set_editor_preference`, and `broadcast_editor_message`.
- Added Button event binding tools: `list_button_click_events` and `bind_button_click_event`.
- Added `create_cocos_mcp_project_skill` for generating a recommended local Codex workflow skill.
- Added release package sensitive-content scanning for npm/GitHub/MCP token-like values and private keys.
### Optimized
- Improved tool exposure UX for larger projects by making custom profiles reusable and shareable.
- Expanded generated tool documentation and README coverage for the new 37-tool `core` profile and 101-tool `full` profile.
- Improved release packaging confidence with checksum verification and content scanning.
### Changed
- Expanded the default `core` profile from 34 tools to 37 tools.
- Expanded the `full` profile from 89 tools to 101 tools.
- Split more tool implementations into focused modules under `lib/tools/`, including advanced assets, Cocos project/editor helpers, and scene event helpers.
- Persisted `executeJavascriptSafetyChecks`, active tool profile names, and saved tool profiles in project configuration.
### Fixed
- Fixed the GitHub Release workflow so release pages use generated English changelog-style `RELEASE_NOTES.md` instead of the artifact installation README.
- Fixed release package scanning false positives around normal MCP server config names while preserving credential detection.
### Security
- Added guardrails for JavaScript execution against obvious risky file-system and shell patterns, including delete/truncate calls, raw writable streams, path traversal, user/system absolute paths, and `child_process`.
## [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`.
### Optimized
- Added CI and release validation for generated tool documentation.
- Improved the MCP client config panel so the preview follows the selected client target.
- Improved the panel information architecture around troubleshooting and maintenance: version, update status, tool profile, client config, recent calls, and logs.
### 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.
### Fixed
- No runtime bug fixes in this release; this release focuses on product polish, debugging ergonomics, and maintainability.
## [0.3.2] - 2026-05-20
### Added
+31 -16
View File
@@ -8,6 +8,7 @@
<a href="#"><img src="https://img.shields.io/badge/License-MIT-blue.svg" alt="License: MIT"></a>
<a href="#"><img src="https://img.shields.io/badge/MCP-Compatible-green" alt="MCP Compatible"></a>
<a href="#"><img src="https://img.shields.io/badge/Platform-Editor%20Only-orange" alt="Editor Only"></a>
<a href="https://store.cocos.com/app/detail/8913"><img src="https://img.shields.io/badge/Cocos%20Store-Install-brightgreen" alt="Install from Cocos Store"></a>
</p>
<p align="center">
<a href="./README_CN.md">中文</a> | English
@@ -41,7 +42,9 @@ If you just want to connect quickly, do these three things:
### 1. Install as a Cocos Creator Extension
Clone or copy this repository into your Cocos project extension directory:
Recommended install: use the official [Cocos Store page](https://store.cocos.com/app/detail/8913) and install the extension into your target Cocos Creator project.
For local development or unreleased builds, clone or copy this repository into your Cocos project extension directory:
```bash
cd /path/to/your-cocos-project
@@ -65,16 +68,17 @@ Funplay > MCP Server
The server runs on `http://127.0.0.1:8765/` by default.
If the configured port is already occupied, the extension automatically falls back to the next available local port and uses the actual running port for one-click MCP client configuration.
If the configured port is already occupied, the extension first checks whether the existing listener belongs to the same Cocos project. Same-project listeners are reused safely; unrelated listeners trigger automatic fallback to the next available local port.
The panel is intentionally small:
- Enable or disable the MCP server
- Change the server port
- Switch tool exposure between `core`, `full`, and `custom`
- Save, apply, import, and export named tool profiles
- 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 controls or individual tool names
- 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
@@ -236,10 +240,11 @@ Try a higher-level prompt in your AI client:
- 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.
- `GET /health` and `GET /tools` are read-only debug endpoints for quick local checks outside an MCP client.
- The default `core` profile exposes 34 high-signal tools. Switch to `full` for all 89 tools, or use `custom` to include/exclude tool categories and individual tools.
- The default `core` profile exposes 37 high-signal tools. Switch to `full` for all 101 tools, or use `custom` to include/exclude tool categories and individual tools.
- 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.
- Tool listings include MCP `outputSchema` and `annotations`; structured tool results use a standard envelope with `ok`, `tool`, `callId`, `summary`, `data`, and follow-up `refs`.
- `execute_javascript` safety checks are enabled by default. They block obvious risky filesystem and shell patterns such as delete/truncate calls, raw writable streams, path traversal, user/system absolute paths, and `child_process`. This is a guardrail, not a full sandbox; a call can explicitly pass `safety_checks: false` when you have reviewed the risk.
- 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.
- The recommended workflow is `execute_javascript` first, then focused helper tools for screenshots, diagnostics, assets, and inspection.
@@ -251,12 +256,12 @@ Try a higher-level prompt in your AI client:
- **Embedded Cocos Extension** — No separate Python daemon or external bridge process is required for the Cocos-side plugin
- **One-Click Client Configuration** — Configure Claude Code, Cursor, VS Code, Trae, Kiro, and Codex directly from Cocos Creator
- **Project Context Built In** — Exposes live project, scene, selection, script diagnostics, logs, and interaction-history resources
- **Focused by Default, Full When Needed** — `core` reduces tool-list noise; `full` exposes every available tool; `custom` lets you tune by category or tool
- **Focused by Default, Full When Needed** — `core` reduces tool-list noise; `full` exposes every available tool; `custom` plus saved profiles lets you tune and restore tool exposure by category or tool
- **Visual Validation** — Scene/editor/preview screenshots and input simulation help AI verify UI and gameplay changes
## Highlights
- **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
- **101 Built-in Tools** — Scene hierarchy, editor state, selection workflows, prefabs, assets, asset dependencies, project instructions, UI creation, components, files, logs, script diagnostics, screenshots, runtime control, build/preview helpers, editor preferences, event binding, and input simulation
- **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
- **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
@@ -273,22 +278,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 |
| Primary execution tool | `execute_javascript` | `execute_code` |
| Primary language | JavaScript in scene/editor contexts | C# in Unity editor/runtime contexts |
| Default profile | `core` with 34 tools | `core` focused tool profile |
| Full profile | 89 tools plus `custom` exposure | 79 tools |
| Default profile | `core` with 37 tools | `core` focused tool profile |
| Full profile | 101 tools plus `custom` exposure | 79 tools |
| Client setup | One-click config panel | One-click config window |
## MCP Capabilities
The current package exposes four capability layers:
- **Tools** — 34 tools in `core`, 89 tools in `full`, plus `custom` include/exclude rules
- **Tools** — 37 tools in `core`, 101 tools in `full`, plus `custom` include/exclude rules and saved tool profiles
- **Primary execution** — `execute_javascript` for scene/runtime and editor/browser automation
- **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
For the generated tool reference, including categories, profiles, and read/mutation hints, see [docs/TOOLS.md](./docs/TOOLS.md).
The default `core` set is intentionally small: `execute_javascript`, `execute_scene_script`, `execute_editor_script`, `get_editor_state`, `get_tool_catalog`, `check_for_updates`, `get_selection`, `list_project_instructions`, `read_project_instruction`, `set_selection`, `get_project_info`, `get_scene_info`, `get_hierarchy`, `list_scenes`, `open_scene`, `inspect_prefab`, `validate_prefab_references`, `inspect_prefab_instance`, `list_assets`, `inspect_asset`, `open_asset`, `select_asset`, `run_script_diagnostics`, `get_recent_logs`, `search_project_logs`, `clear_logs`, `validate_scene`, `get_performance_snapshot`, `get_script_diagnostic_context`, `get_runtime_state`, `capture_editor_screenshot`, `capture_scene_screenshot`, `capture_preview_screenshot`, and `list_editor_windows`.
The default `core` set is intentionally small: `execute_javascript`, `execute_scene_script`, `execute_editor_script`, `get_editor_state`, `get_tool_catalog`, `check_for_updates`, `get_selection`, `list_project_instructions`, `read_project_instruction`, `set_selection`, `get_project_info`, `get_build_status`, `get_scene_info`, `get_hierarchy`, `list_scenes`, `open_scene`, `inspect_prefab`, `validate_prefab_references`, `inspect_prefab_instance`, `list_assets`, `inspect_asset`, `inspect_asset_dependencies`, `validate_asset_dependencies`, `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
@@ -307,24 +312,25 @@ The default `core` set is intentionally small: `execute_javascript`, `execute_sc
## Built-in Tools
Funplay MCP for Cocos currently ships with **89 tool functions** in the `full` profile:
Funplay MCP for Cocos currently ships with **101 tool functions** in the `full` profile:
| Category | Tools |
|----------|-------|
| **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` |
| **Project Instructions** | `list_project_instructions`, `read_project_instruction`, `write_project_instruction`, `create_project_skill` |
| **Project Instructions** | `list_project_instructions`, `read_project_instruction`, `write_project_instruction`, `create_project_skill`, `create_cocos_mcp_project_skill` |
| **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` |
| **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` |
| **Assets & Prefabs** | `list_assets`, `inspect_asset`, `inspect_asset_dependencies`, `validate_asset_dependencies`, `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` |
| **UI** | `create_canvas`, `create_label`, `create_button`, `create_sprite` |
| **Camera** | `list_cameras`, `create_camera`, `set_camera_properties` |
| **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` |
| **Diagnostics & Logs** | `run_script_diagnostics`, `get_script_diagnostic_context`, `get_recent_logs`, `search_project_logs`, `clear_logs`, `validate_scene`, `get_performance_snapshot` |
| **Build & Editor** | `get_build_status`, `open_build_panel`, `run_project_preview`, `save_current_scene`, `get_editor_preference`, `set_editor_preference`, `broadcast_editor_message` |
| **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 & Events** | `emit_node_event`, `simulate_button_click`, `list_button_click_events`, `bind_button_click_event`, `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` |
## Primary Tool Examples
@@ -365,8 +371,11 @@ Place `funplay-cocos-mcp.config.json` in the Cocos project root:
"enabledTools": [],
"disabledTools": [],
"enableSessions": false,
"executeJavascriptSafetyChecks": true,
"autostart": true,
"maxInteractionLogEntries": 50
"maxInteractionLogEntries": 50,
"activeToolProfileName": "",
"savedToolProfiles": []
}
```
@@ -376,7 +385,7 @@ Environment variables are also supported:
- `COCOS_MCP_PORT`
- `COCOS_MCP_PROFILE`
`toolProfile: "custom"` starts from the `core` set, then adds `enabledToolCategories` / `enabledTools` and removes `disabledToolCategories` / `disabledTools`. `enableSessions` is off by default because this server does not need cross-request client state for normal editor automation.
`toolProfile: "custom"` starts from the `core` set, then adds `enabledToolCategories` / `enabledTools` and removes `disabledToolCategories` / `disabledTools`. The panel can save these exposure settings as named `savedToolProfiles` for quick restore or sharing. `enableSessions` is off by default because this server does not need cross-request client state for normal editor automation.
## Architecture
@@ -394,6 +403,12 @@ Cocos Creator Extension
│ └─ Minimal MCP Server panel
└─ lib/
├─ assets, diagnostics, screenshots, input
├─ tool-profiles, javascript-safety
├─ tools/
│ ├─ files
│ ├─ assets-advanced
│ ├─ cocos-project
│ └─ scene-events
└─ server, resources, prompts, tool registry
```
+32 -17
View File
@@ -8,6 +8,7 @@
<a href="#"><img src="https://img.shields.io/badge/License-MIT-blue.svg" alt="License: MIT"></a>
<a href="#"><img src="https://img.shields.io/badge/MCP-Compatible-green" alt="MCP Compatible"></a>
<a href="#"><img src="https://img.shields.io/badge/Platform-Editor%20Only-orange" alt="Editor Only"></a>
<a href="https://store.cocos.com/app/detail/8913"><img src="https://img.shields.io/badge/Cocos%20Store-Install-brightgreen" alt="Install from Cocos Store"></a>
</p>
<p align="center">
中文 | <a href="./README.md">English</a>
@@ -41,7 +42,9 @@ Funplay MCP for Cocos 是一个采用 MIT 协议的 Cocos Creator 扩展,它
### 1. 安装为 Cocos Creator 扩展
把仓库 clone 或复制到 Cocos 项目的扩展目录:
推荐安装方式:通过官方 [Cocos Store 页面](https://store.cocos.com/app/detail/8913) 安装到目标 Cocos Creator 项目。
如果要本地开发或使用未发布版本,可以把仓库 clone 或复制到 Cocos 项目的扩展目录:
```bash
cd /path/to/your-cocos-project
@@ -65,16 +68,17 @@ Funplay > MCP Server
服务默认运行在 `http://127.0.0.1:8765/`
如果配置端口已被占用,扩展会自动回退到下一个可用本地端口,并在一键客户端配置时使用实际运行端口。
如果配置端口已被占用,扩展会先检查已有 listener 是否属于同一个 Cocos 项目;同项目 listener 会被安全复用,无关 listener 才会自动回退到下一个可用本地端口。
面板刻意保持精简:
- 启用或停用 MCP Server
- 修改服务端口
-`core` / `full` / `custom` 工具暴露模式之间切换
- 保存、套用、导入和导出命名工具 profile
- 检查当前安装版本是否落后于 GitHub 最新 Release
- 查看最近工具调用和运行日志预览
- 按工具分类或单个工具调整暴露范围
- 通过分类控制或单个工具调整暴露范围
- 一键配置 AI 客户端,并随目标客户端预览对应配置
- 复制 `/health``/tools` 的快速 `curl` 排障命令
- 需要时再展开 Debug Output
@@ -234,12 +238,13 @@ curl http://127.0.0.1:8765/tools
- 这是一个 **仅限 Editor** 的扩展,用于自动化 Cocos Creator,不会给最终游戏包添加运行时依赖。
- MCP Server 默认监听 `http://127.0.0.1:8765/`
- 如果配置端口被占用,服务会自动回退到下一个可用端口,面板与一键客户端配置会使用实际运行端口。
- 如果配置端口被占用,服务会先通过项目身份识别同项目已有 listener;无法确认同项目时才会自动回退到下一个可用端口,面板与一键客户端配置会使用实际运行端口。
- `GET /health``GET /tools` 是只读调试端点,方便不用 MCP 客户端也能快速检查本地服务。
- 默认 `core` profile 暴露 34 个高频工具;如果需要完整工具集,可在面板切到 `full`,暴露全部 89 个工具;也可以用 `custom` 按分类或工具名增删。
- 默认 `core` profile 暴露 37 个高频工具;如果需要完整工具集,可在面板切到 `full`,暴露全部 101 个工具;也可以用 `custom` 按分类或工具名增删。
- 面板提供手动更新检查,会对比当前安装版本和 GitHub 最新 Release。
- 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。
- `execute_javascript` 安全检查默认开启,会拦截明显高风险的文件系统和 shell 模式,例如删除/截断调用、原始写入流、路径穿越、用户/系统绝对路径和 `child_process`。这是防护栏,不是完整沙箱;确认风险后可在单次调用中显式传入 `safety_checks: false`
- 所有已暴露的 MCP 工具都会直接执行,Cocos 扩展里没有额外 approval 开关。
- 文件工具和 `cocos://asset/path/...` 资源默认只能访问当前 Cocos 项目根目录内的路径。
- 推荐工作流是优先使用 `execute_javascript`,再配合截图、诊断、资产、检查类工具。
@@ -251,12 +256,12 @@ curl http://127.0.0.1:8765/tools
- **嵌入式 Cocos 扩展** — Cocos 侧不需要单独 Python 守护进程或外部 bridge
- **一键客户端配置** — 在 Cocos Creator 内直接配置 Claude Code、Cursor、VS Code、Trae、Kiro、Codex
- **内建项目上下文** — 直接暴露项目、场景、选择、脚本诊断、日志和交互历史资源
- **默认聚焦,必要时全量** — `core` 降低工具列表噪音,需要时切到 `full` 暴露全部工具;`custom` 可按分类或工具名调整
- **默认聚焦,必要时全量** — `core` 降低工具列表噪音,需要时切到 `full` 暴露全部工具;`custom` 与保存的 profile 可按分类或工具名调整并恢复
- **可视化验证** — 截图和输入模拟让 AI 能验证 UI 与玩法改动
## 核心特性
- **89 个内置工具** — 覆盖场景层级、编辑器状态、选择工作流、Prefab、资产、项目指令、UI 创建、组件、文件、日志、脚本诊断、截图、运行态控制和输入模拟
- **101 个内置工具** — 覆盖场景层级、编辑器状态、选择工作流、Prefab、资产、资产依赖、项目指令、UI 创建、组件、文件、日志、脚本诊断、截图、运行态控制、构建/预览辅助、编辑器偏好、事件绑定和输入模拟
- **统一主工具** — `execute_javascript` 同时支持 `scene``editor` 两种上下文
- **Resources 与 Prompts** — 实时项目/日志资源,以及脚本修复、场景验证、可玩原型等可复用工作流
- **Cocos 图形面板** — `Funplay > MCP Server` 提供服务管理、更新检查、工具暴露、最近活动、日志、curl 排障和 MCP 客户端配置
@@ -273,22 +278,22 @@ Funplay MCP for Cocos 延续 Funplay MCP for Unity 的设计原则,并针对 C
| 内置服务 | 内嵌 HTTP MCP Server | 内嵌 HTTP MCP Server |
| 主执行工具 | `execute_javascript` | `execute_code` |
| 主语言 | 场景/编辑器上下文中的 JavaScript | Unity 编辑器/运行态中的 C# |
| 默认工具集 | `core`34 个工具 | 聚焦版 `core` 工具集 |
| 完整工具集 | 89 个工具,并支持 `custom` 暴露 | 79 个工具 |
| 默认工具集 | `core`37 个工具 | 聚焦版 `core` 工具集 |
| 完整工具集 | 101 个工具,并支持 `custom` 暴露 | 79 个工具 |
| 客户端配置 | 一键配置面板 | 一键配置窗口 |
## MCP 能力结构
当前包提供四层能力:
- **Tools** — `core` 下 34 个工具,`full`89 个工具,并支持 `custom` include/exclude 规则
- **Tools** — `core` 下 37 个工具,`full`101 个工具,并支持 `custom` include/exclude 规则和命名工具 profile
- **Primary execution** — `execute_javascript` 用于场景/运行态和编辑器/browser 自动化
- **Prompts** — `fix_script_errors``create_playable_prototype``scene_validation``auto_wire_scene`
- **Resources** — 项目上下文、场景摘要、当前选择、脚本诊断、资产选择、日志和 MCP 交互历史
自动生成的工具参考文档见 [docs/TOOLS.md](./docs/TOOLS.md),里面包含工具分类、profile 和读写/变更提示。
当前默认 `core` 工具集刻意保持精简,只包含:`execute_javascript``execute_scene_script``execute_editor_script``get_editor_state``get_tool_catalog``check_for_updates``get_selection``list_project_instructions``read_project_instruction``set_selection``get_project_info``get_scene_info``get_hierarchy``list_scenes``open_scene``inspect_prefab``validate_prefab_references``inspect_prefab_instance``list_assets``inspect_asset``open_asset``select_asset``run_script_diagnostics``get_recent_logs``search_project_logs``clear_logs``validate_scene``get_performance_snapshot``get_script_diagnostic_context``get_runtime_state``capture_editor_screenshot``capture_scene_screenshot``capture_preview_screenshot``list_editor_windows`
当前默认 `core` 工具集刻意保持精简,只包含:`execute_javascript``execute_scene_script``execute_editor_script``get_editor_state``get_tool_catalog``check_for_updates``get_selection``list_project_instructions``read_project_instruction``set_selection``get_project_info``get_build_status``get_scene_info``get_hierarchy``list_scenes``open_scene``inspect_prefab``validate_prefab_references``inspect_prefab_instance``list_assets``inspect_asset``inspect_asset_dependencies``validate_asset_dependencies``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
@@ -307,24 +312,25 @@ Funplay MCP for Cocos 延续 Funplay MCP for Unity 的设计原则,并针对 C
## 内置工具
Funplay MCP for Cocos 当前在 `full` profile 下提供 **89 个工具函数**
Funplay MCP for Cocos 当前在 `full` profile 下提供 **101 个工具函数**
| 分类 | 工具 |
|------|------|
| **脚本执行** | `execute_javascript`, `execute_scene_script`, `execute_editor_script` |
| **编辑器状态** | `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` |
| **项目指令** | `list_project_instructions`, `read_project_instruction`, `write_project_instruction`, `create_project_skill`, `create_cocos_mcp_project_skill` |
| **项目与场景** | `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` |
| **资产与 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` |
| **资产与 Prefab** | `list_assets`, `inspect_asset`, `inspect_asset_dependencies`, `validate_asset_dependencies`, `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` |
| **UI** | `create_canvas`, `create_label`, `create_button`, `create_sprite` |
| **相机** | `list_cameras`, `create_camera`, `set_camera_properties` |
| **动画** | `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` |
| **诊断与日志** | `run_script_diagnostics`, `get_script_diagnostic_context`, `get_recent_logs`, `search_project_logs`, `clear_logs`, `validate_scene`, `get_performance_snapshot` |
| **构建与编辑器** | `get_build_status`, `open_build_panel`, `run_project_preview`, `save_current_scene`, `get_editor_preference`, `set_editor_preference`, `broadcast_editor_message` |
| **运行态** | `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`, `list_button_click_events`, `bind_button_click_event`, `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` |
## 主工具示例
@@ -365,8 +371,11 @@ Editor 上下文脚本可以访问 `Editor`、`fs`、`path`、`os`、`require`
"enabledTools": [],
"disabledTools": [],
"enableSessions": false,
"executeJavascriptSafetyChecks": true,
"autostart": true,
"maxInteractionLogEntries": 50
"maxInteractionLogEntries": 50,
"activeToolProfileName": "",
"savedToolProfiles": []
}
```
@@ -376,7 +385,7 @@ Editor 上下文脚本可以访问 `Editor`、`fs`、`path`、`os`、`require`
- `COCOS_MCP_PORT`
- `COCOS_MCP_PROFILE`
`toolProfile: "custom"` 会从 `core` 集合开始,再加入 `enabledToolCategories` / `enabledTools`,并移除 `disabledToolCategories` / `disabledTools``enableSessions` 默认关闭,因为常规编辑器自动化不需要跨请求客户端状态。
`toolProfile: "custom"` 会从 `core` 集合开始,再加入 `enabledToolCategories` / `enabledTools`,并移除 `disabledToolCategories` / `disabledTools`面板可以把这些暴露设置保存为命名 `savedToolProfiles`,方便恢复或分享。`enableSessions` 默认关闭,因为常规编辑器自动化不需要跨请求客户端状态。
## 架构
@@ -394,6 +403,12 @@ Cocos Creator Extension
│ └─ Minimal MCP Server panel
└─ lib/
├─ assets, diagnostics, screenshots, input
├─ tool-profiles, javascript-safety
├─ tools/
│ ├─ files
│ ├─ assets-advanced
│ ├─ cocos-project
│ └─ scene-events
└─ server, resources, prompts, tool registry
```
+4 -2
View File
@@ -20,6 +20,7 @@ Use this checklist before publishing a new release of Funplay MCP for Cocos.
- [ ] `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
- [ ] `releases/<version>/RELEASE_NOTES.md` is organized by change type, such as Added/Optimized/Changed/Fixed
## 3. Package Contents
@@ -29,8 +30,9 @@ Use this checklist before publishing a new release of Funplay MCP for Cocos.
- [ ] 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 packaging sensitive-content scan does not find npm/GitHub/MCP tokens or private keys
- [ ] `release-manifest.json` references the correct GitHub download URL
- [ ] `SHA256SUMS.txt` includes the zip, manifest, and release README
- [ ] `SHA256SUMS.txt` includes the zip, manifest, generated release notes, and release README
## 4. Cocos Smoke Test
@@ -72,7 +74,7 @@ Use this checklist before publishing a new release of Funplay MCP for Cocos.
- [ ] 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
- [ ] Public GitHub Release page uses `RELEASE_NOTES.md` and renders the release notes/assets correctly
## 8. Publish
+10 -2
View File
@@ -175,7 +175,7 @@ If creating a new release:
gh release create v<version> \
-R FunplayAI/funplay-cocos-mcp \
--title "v<version>" \
--notes-file /path/to/release-notes.md \
--notes-file releases/<version>/RELEASE_NOTES.md \
releases/<version>/Funplay.CocosMcp.v<version>.zip \
releases/<version>/release-manifest.json \
releases/<version>/SHA256SUMS.txt \
@@ -194,6 +194,14 @@ gh release upload v<version> \
releases/<version>/README.md
```
If the release body needs to be refreshed without replacing assets:
```bash
gh release edit v<version> \
-R FunplayAI/funplay-cocos-mcp \
--notes-file releases/<version>/RELEASE_NOTES.md
```
### 7. Verify GitHub Release
```bash
@@ -202,7 +210,7 @@ gh release view v<version> \
--json url,assets,isDraft,isPrerelease,publishedAt
```
Confirm the release has all four assets.
Confirm the release has all four assets and the public release body is organized by change type.
### 8. Publish To npm
+24 -1
View File
@@ -5,7 +5,7 @@ const fs = require('fs');
const os = require('os');
const manifest = require('./package.json');
const { SERVER_NAME, buildTargets, configureTarget, getTargetStatuses } = require('./lib/client-config');
const { loadConfig, getProjectPath, getProjectName, getCocosVersion } = require('./lib/config');
const { loadConfig, getProjectPath, getProjectName, getProjectIdentity, getCocosVersion } = require('./lib/config');
const { McpServer } = require('./lib/server');
const { createToolRegistry } = require('./lib/tool-registry');
const { ResourceProvider } = require('./lib/resources');
@@ -13,6 +13,7 @@ const { PromptProvider } = require('./lib/prompts');
const { InteractionLog } = require('./lib/interaction-log');
const { RuntimeLog } = require('./lib/runtime-log');
const { checkForUpdate } = require('./lib/update-checker');
const { normalizeSavedToolProfiles } = require('./lib/tool-profiles');
const EXTENSION_NAME = manifest.name || 'funplay-cocos-mcp';
const LOG_PREFIX = '[Funplay Cocos MCP]';
@@ -101,6 +102,7 @@ class ExtensionService {
config: this.config,
projectPath: getProjectPath(),
projectName: getProjectName(),
projectIdentity: getProjectIdentity(),
cocosVersion: getCocosVersion(),
packagePath: path.dirname(__filename),
});
@@ -134,6 +136,8 @@ class ExtensionService {
promptProvider: this.promptProvider,
serverName: `Funplay Cocos MCP - ${getProjectName()}`,
serverVersion: manifest.version || '0.0.0',
projectName: getProjectName(),
projectIdentity: getProjectIdentity(),
});
await this.server.start();
@@ -178,8 +182,13 @@ class ExtensionService {
const fallbackInfo = this.server && this.server.isRunning() && typeof this.server.getPortFallbackInfo === 'function'
? this.server.getPortFallbackInfo()
: null;
const attachInfo = this.server && this.server.isRunning() && typeof this.server.getAttachInfo === 'function'
? this.server.getAttachInfo()
: null;
return {
running: Boolean(this.server && this.server.isRunning()),
attachedToExisting: Boolean(attachInfo),
attachInfo,
host: this.config.host,
port: effective.port,
requestedPort: this.config.port,
@@ -191,10 +200,14 @@ class ExtensionService {
enabledToolCategories: this.config.enabledToolCategories,
disabledToolCategories: this.config.disabledToolCategories,
enableSessions: this.config.enableSessions,
executeJavascriptSafetyChecks: this.config.executeJavascriptSafetyChecks,
autostart: this.config.autostart,
activeToolProfileName: this.config.activeToolProfileName,
savedToolProfiles: this.config.savedToolProfiles,
version: manifest.version || '0.0.0',
projectPath: getProjectPath(),
projectName: getProjectName(),
projectIdentity: getProjectIdentity(),
cocosVersion: getCocosVersion(),
url: effective.url,
};
@@ -416,6 +429,9 @@ class ExtensionService {
enableSessions: partialConfig && typeof partialConfig.enableSessions === 'boolean'
? partialConfig.enableSessions
: this.config.enableSessions,
executeJavascriptSafetyChecks: partialConfig && typeof partialConfig.executeJavascriptSafetyChecks === 'boolean'
? partialConfig.executeJavascriptSafetyChecks
: this.config.executeJavascriptSafetyChecks,
autostart: partialConfig && typeof partialConfig.autostart === 'boolean'
? partialConfig.autostart
: this.config.autostart,
@@ -425,6 +441,12 @@ class ExtensionService {
lastClientTargetId: partialConfig && partialConfig.lastClientTargetId
? String(partialConfig.lastClientTargetId)
: this.config.lastClientTargetId,
activeToolProfileName: partialConfig && typeof partialConfig.activeToolProfileName === 'string'
? String(partialConfig.activeToolProfileName)
: this.config.activeToolProfileName,
savedToolProfiles: partialConfig && Array.isArray(partialConfig.savedToolProfiles)
? normalizeSavedToolProfiles(partialConfig.savedToolProfiles)
: this.config.savedToolProfiles,
};
const configPath = this.config.configPath;
@@ -435,6 +457,7 @@ class ExtensionService {
nextConfig.port !== this.config.port ||
nextConfig.toolProfile !== this.config.toolProfile ||
nextConfig.enableSessions !== this.config.enableSessions ||
nextConfig.executeJavascriptSafetyChecks !== this.config.executeJavascriptSafetyChecks ||
JSON.stringify(nextConfig.enabledTools) !== JSON.stringify(this.config.enabledTools) ||
JSON.stringify(nextConfig.disabledTools) !== JSON.stringify(this.config.disabledTools) ||
JSON.stringify(nextConfig.enabledToolCategories) !== JSON.stringify(this.config.enabledToolCategories) ||
+38 -6
View File
@@ -2,18 +2,18 @@
<!-- 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.
Generated from `lib/tool-registry.js`. The default `core` profile exposes 37 tools; the `full` profile exposes 101 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` | 37 | Focused default surface for common editor automation. |
| `full` | 101 | 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`
`capture_editor_screenshot`, `capture_preview_screenshot`, `capture_scene_screenshot`, `check_for_updates`, `clear_logs`, `execute_editor_script`, `execute_javascript`, `execute_scene_script`, `get_build_status`, `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_asset_dependencies`, `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_asset_dependencies`, `validate_prefab_references`, `validate_scene`
## Tools By Category
@@ -32,6 +32,7 @@ Generated from `lib/tool-registry.js`. The default `core` profile exposes 34 too
|---|---|---|---|
| `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. |
| `inspect_asset_dependencies` | `core`, `full` | read-only | [specialist] Inspect UUID-style dependencies referenced by a serialized Cocos asset. |
| `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. |
@@ -39,6 +40,21 @@ Generated from `lib/tool-registry.js`. The default `core` profile exposes 34 too
| `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. |
### Broadcast
| Tool | Profiles | Access | Description |
|---|---|---|---|
| `broadcast_editor_message` | `full` | stateful | [core] Send or broadcast a Cocos editor message for advanced editor automation. |
### Build
| Tool | Profiles | Access | Description |
|---|---|---|---|
| `get_build_status` | `core`, `full` | read-only | [specialist] Query Cocos build/preview status using known builder message variants. |
| `open_build_panel` | `full` | stateful | [core] Open the Cocos build panel, defaulting to the builder panel id. |
| `run_project_preview` | `full` | stateful | [core] Start Cocos preview/run using known preview and builder message variants. |
| `save_current_scene` | `full` | stateful | [core] Save the currently open Cocos scene using available editor scene messages. |
### Camera
| Tool | Profiles | Access | Description |
@@ -65,9 +81,19 @@ Generated from `lib/tool-registry.js`. The default `core` profile exposes 34 too
|---|---|---|---|
| `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_asset_dependencies` | `core`, `full` | read-only | [specialist] Validate UUID-style dependencies for one asset or a project asset query. |
| `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. |
### Events
| Tool | Profiles | Access | Description |
|---|---|---|---|
| `bind_button_click_event` | `full` | stateful | [core] Bind a Cocos Button click event to a target node component method. |
| `emit_node_event` | `full` | mutating | [core] Emit a custom event on a target scene node with an optional JSON payload. |
| `list_button_click_events` | `full` | read-only | [core] List click event bindings on a Cocos Button component. |
| `simulate_button_click` | `full` | mutating | [core] Simulate a Cocos Button click by emitting click events on the target button node. |
### Execution
| Tool | Profiles | Access | Description |
@@ -92,7 +118,6 @@ Generated from `lib/tool-registry.js`. The default `core` profile exposes 34 too
| 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. |
@@ -103,6 +128,7 @@ Generated from `lib/tool-registry.js`. The default `core` profile exposes 34 too
| Tool | Profiles | Access | Description |
|---|---|---|---|
| `create_cocos_mcp_project_skill` | `full` | stateful | [core] Create a recommended local Codex project skill for Funplay Cocos MCP workflows. |
| `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. |
@@ -137,6 +163,13 @@ Generated from `lib/tool-registry.js`. The default `core` profile exposes 34 too
| `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. |
### Preferences
| Tool | Profiles | Access | Description |
|---|---|---|---|
| `get_editor_preference` | `full` | read-only | [core] Read a Cocos editor preference through Editor.Profile when available. |
| `set_editor_preference` | `full` | mutating | [core] Write a Cocos editor preference through Editor.Profile when available. |
### Project
| Tool | Profiles | Access | Description |
@@ -149,7 +182,6 @@ Generated from `lib/tool-registry.js`. The default `core` profile exposes 34 too
| 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. |
+24
View File
@@ -1,7 +1,9 @@
'use strict';
const crypto = require('crypto');
const fs = require('fs');
const path = require('path');
const { normalizeSavedToolProfiles } = require('./tool-profiles');
const DEFAULTS = {
host: '127.0.0.1',
@@ -12,9 +14,12 @@ const DEFAULTS = {
enabledToolCategories: [],
disabledToolCategories: [],
enableSessions: false,
executeJavascriptSafetyChecks: true,
autostart: true,
maxInteractionLogEntries: 50,
lastClientTargetId: 'claude_code',
activeToolProfileName: '',
savedToolProfiles: [],
};
function getProjectPath() {
@@ -28,6 +33,19 @@ function getProjectName() {
return path.basename(getProjectPath());
}
function normalizeProjectIdentityPath(projectPath) {
const normalized = path.resolve(String(projectPath || process.cwd())).replace(/\\/g, '/');
return process.platform === 'win32' ? normalized.toLowerCase() : normalized;
}
function getProjectIdentity(projectPath = getProjectPath()) {
return crypto
.createHash('sha256')
.update(`funplay-cocos-mcp:${normalizeProjectIdentityPath(projectPath)}`)
.digest('hex')
.slice(0, 24);
}
function getCocosVersion() {
if (global.Editor && Editor.App) {
if (typeof Editor.App.version === 'string' && Editor.App.version) {
@@ -106,11 +124,16 @@ function loadConfig() {
enabledToolCategories: normalizeStringList(fileConfig.enabledToolCategories).map((item) => item.toLowerCase()),
disabledToolCategories: normalizeStringList(fileConfig.disabledToolCategories).map((item) => item.toLowerCase()),
enableSessions: typeof fileConfig.enableSessions === 'boolean' ? fileConfig.enableSessions : DEFAULTS.enableSessions,
executeJavascriptSafetyChecks: typeof fileConfig.executeJavascriptSafetyChecks === 'boolean'
? fileConfig.executeJavascriptSafetyChecks
: DEFAULTS.executeJavascriptSafetyChecks,
autostart: typeof fileConfig.autostart === 'boolean' ? fileConfig.autostart : DEFAULTS.autostart,
maxInteractionLogEntries: Number.isInteger(fileConfig.maxInteractionLogEntries)
? Math.max(10, Math.min(500, fileConfig.maxInteractionLogEntries))
: DEFAULTS.maxInteractionLogEntries,
lastClientTargetId: normalizeClientTargetId(fileConfig.lastClientTargetId),
activeToolProfileName: typeof fileConfig.activeToolProfileName === 'string' ? fileConfig.activeToolProfileName : '',
savedToolProfiles: normalizeSavedToolProfiles(fileConfig.savedToolProfiles),
configPath,
configError: fileConfig.__error || '',
};
@@ -120,6 +143,7 @@ module.exports = {
DEFAULTS,
getProjectPath,
getProjectName,
getProjectIdentity,
getCocosVersion,
loadConfig,
normalizeProfile,
+114
View File
@@ -0,0 +1,114 @@
'use strict';
const path = require('path');
const { isPathInside } = require('./path-safety');
const DELETE_METHOD_PATTERN = /\bfs(?:\s*\.\s*promises)?\s*\.\s*(rm|rmdir|unlink|truncate|rmSync|rmdirSync|unlinkSync|truncateSync)\s*\(/;
const WRITE_STREAM_PATTERN = /\bfs\s*\.\s*(createWriteStream|openSync)\s*\(/;
const SHELL_PATTERN = /require\s*\(\s*['"]child_process['"]\s*\)|\bchild_process\s*\.|\b(exec|execFile|spawn|fork|execSync|execFileSync|spawnSync)\s*\(/;
const WRITE_METHOD_PATTERN = /\bfs(?:\s*\.\s*promises)?\s*\.\s*(writeFile|appendFile|copyFile|cp|rename|mkdir|writeFileSync|appendFileSync|copyFileSync|cpSync|renameSync|mkdirSync)\s*\(/;
const HOME_PATH_PATTERN = /(?:^~(?:\/|\\|$)|\$HOME|%USERPROFILE%|%HOMEPATH%)/i;
const TRAVERSAL_PATTERN = /(^|[\\/])\.\.([\\/]|$)/;
function extractStringLiterals(code) {
const literals = [];
const pattern = /(['"`])((?:\\[\s\S]|(?!\1)[\s\S])*?)\1/g;
let match;
while ((match = pattern.exec(String(code || '')))) {
literals.push(match[2]);
}
return literals;
}
function isAbsoluteLiteral(value) {
return path.isAbsolute(value)
|| path.win32.isAbsolute(value)
|| /^\\\\/.test(value);
}
function isAbsoluteLiteralInsideProject(projectPath, value) {
if (!projectPath) {
return false;
}
if (path.win32.isAbsolute(value)) {
const root = projectPath.replace(/\//g, '\\');
const relative = path.win32.relative(root, value);
return relative === '' || (relative && !relative.startsWith('..') && !path.win32.isAbsolute(relative));
}
if (path.isAbsolute(value)) {
return isPathInside(projectPath, path.resolve(value));
}
return false;
}
function inspectJavascriptSafety(code, options = {}) {
const source = String(code || '');
const projectPath = options.projectPath ? path.resolve(String(options.projectPath)) : '';
const violations = [];
if (DELETE_METHOD_PATTERN.test(source)) {
violations.push('direct fs delete/truncate calls are blocked by default');
}
if (WRITE_STREAM_PATTERN.test(source)) {
violations.push('raw writable file streams are blocked by default');
}
if (SHELL_PATTERN.test(source)) {
violations.push('child_process execution is blocked by default');
}
const hasFileMutation = DELETE_METHOD_PATTERN.test(source)
|| WRITE_METHOD_PATTERN.test(source)
|| WRITE_STREAM_PATTERN.test(source);
if (hasFileMutation && /\bos\s*\.\s*homedir\s*\(/.test(source)) {
violations.push('file mutations derived from os.homedir() are blocked by default');
}
if (hasFileMutation && /\bprocess\s*\.\s*env\s*\.\s*(HOME|USERPROFILE|HOMEPATH|APPDATA|LOCALAPPDATA|TMP|TEMP)\b/.test(source)) {
violations.push('file mutations derived from user/system environment paths are blocked by default');
}
for (const literal of extractStringLiterals(source)) {
if (HOME_PATH_PATTERN.test(literal)) {
violations.push(`user-home path literal is blocked: ${literal}`);
continue;
}
if (TRAVERSAL_PATTERN.test(literal)) {
violations.push(`path traversal literal is blocked: ${literal}`);
continue;
}
if (isAbsoluteLiteral(literal)) {
if (!isAbsoluteLiteralInsideProject(projectPath, literal)) {
violations.push(`absolute path outside the Cocos project is blocked: ${literal}`);
}
}
}
return {
ok: violations.length === 0,
violations: Array.from(new Set(violations)),
};
}
function assertJavascriptSafety(code, options = {}) {
const result = inspectJavascriptSafety(code, options);
if (result.ok) {
return result;
}
throw new Error(
'JavaScript safety checks blocked this code: ' +
`${result.violations.join('; ')}. ` +
'Use project-relative helper/file tools, or pass safety_checks=false only after reviewing the risk.'
);
}
module.exports = {
assertJavascriptSafety,
inspectJavascriptSafety,
};
+18
View File
@@ -146,8 +146,26 @@ function createProjectSkill(projectPath, options = {}) {
});
}
function createCocosMcpProjectSkill(projectPath, options = {}) {
return createProjectSkill(projectPath, {
skillName: options.skillName || 'funplay-cocos-mcp-workflow',
title: options.title || 'Funplay Cocos MCP Workflow',
description: options.description || 'Use this skill when editing, validating, or debugging this Cocos Creator project through Funplay Cocos MCP.',
overwrite: options.overwrite !== false,
instructions: String(options.instructions || '').trim() || [
'- Start by reading `cocos://project/context` or calling `get_editor_state` to confirm the active project, scene, server URL, and tool profile.',
'- Prefer `execute_javascript` for high-level scene/editor orchestration, but keep safety checks enabled unless the code was reviewed.',
'- Use focused tools when they are better primitives: `list_assets`, `inspect_asset_dependencies`, `validate_asset_dependencies`, `run_script_diagnostics`, `get_script_diagnostic_context`, and screenshot tools.',
'- For UI work, inspect the active Canvas/hierarchy first, mutate the smallest necessary node/component set, then verify with `validate_scene` and a screenshot.',
'- For prefab or asset edits, inspect dependencies/references before mutation and refresh assets afterward.',
'- When changing tool exposure, save a named tool profile so the same client setup can be restored later.',
].join('\n'),
});
}
module.exports = {
KNOWN_INSTRUCTION_PATHS,
createCocosMcpProjectSkill,
createProjectSkill,
listProjectInstructions,
readProjectInstruction,
+135 -3
View File
@@ -84,7 +84,11 @@ class McpServer {
this.runtimeLog = options.runtimeLog;
this.serverName = options.serverName;
this.serverVersion = options.serverVersion;
this.projectName = options.projectName || '';
this.projectIdentity = options.projectIdentity || '';
this.server = null;
this.attached = false;
this.attachedInfo = null;
this.actualPort = null;
this.portFallbackInfo = null;
this.negotiatedProtocolVersion = MCP_PROTOCOL_VERSION;
@@ -93,10 +97,13 @@ class McpServer {
}
isRunning() {
return Boolean(this.server && this.server.listening);
return Boolean(this.attached || (this.server && this.server.listening));
}
getPort() {
if (this.attached && this.actualPort) {
return this.actualPort;
}
if (this.server && typeof this.server.address === 'function') {
const address = this.server.address();
if (address && typeof address.port === 'number') {
@@ -114,6 +121,10 @@ class McpServer {
return this.portFallbackInfo;
}
getAttachInfo() {
return this.attachedInfo;
}
log(level, message) {
if (this.runtimeLog && typeof this.runtimeLog.add === 'function') {
this.runtimeLog.add(level, message);
@@ -137,13 +148,21 @@ class McpServer {
this.actualPort = null;
this.portFallbackInfo = null;
this.attached = false;
this.attachedInfo = null;
const requestHandler = async (request, response) => {
try {
const requestUrl = new URL(request.url || '/', 'http://localhost');
if (request.method === 'GET' && requestUrl.pathname === '/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,
projectName: this.projectName,
projectIdentity: this.projectIdentity,
}, this.negotiatedProtocolVersion);
}
if (request.method === 'GET' && requestUrl.pathname === '/tools') {
@@ -288,7 +307,12 @@ class McpServer {
return;
} catch (error) {
lastError = error;
candidate.removeAllListeners();
if (error && error.code === 'EADDRINUSE' && port < 65535 && attempt < MAX_PORT_FALLBACK_ATTEMPTS) {
if (await this.tryAttachToExisting(port)) {
return;
}
const nextPort = port + 1;
this.log(
'warn',
@@ -299,7 +323,6 @@ class McpServer {
continue;
}
candidate.removeAllListeners();
break;
}
}
@@ -311,6 +334,15 @@ class McpServer {
}
async stop() {
if (this.attached) {
this.log('info', `Detached from existing MCP listener on ${this.config.host}:${this.actualPort}.`);
this.attached = false;
this.attachedInfo = null;
this.actualPort = null;
this.portFallbackInfo = null;
return;
}
if (!this.server) {
this.log('info', 'Stop skipped: server object is empty.');
return;
@@ -321,6 +353,7 @@ class McpServer {
this.server = null;
this.actualPort = null;
this.portFallbackInfo = null;
this.attachedInfo = null;
await new Promise((resolve, reject) => {
active.close((error) => {
if (error) {
@@ -334,6 +367,100 @@ class McpServer {
});
}
async tryAttachToExisting(port) {
if (!this.projectIdentity || this.config.attachToExisting === false || port === 0) {
return false;
}
const probe = await this.probeExistingServer(port);
if (!probe || !probe.result) {
this.log('warn', `Port ${port} is occupied, but no compatible Funplay MCP initialize response was received.`);
return false;
}
const result = probe.result || {};
const serverInfo = result.serverInfo || {};
const funplay = result.funplay || {};
const remoteProjectIdentity = funplay.projectIdentity || serverInfo.projectIdentity || '';
const remoteName = serverInfo.name || '';
if (remoteName === this.serverName && remoteProjectIdentity === this.projectIdentity) {
this.attached = true;
this.attachedInfo = {
host: this.config.host,
port,
serverName: remoteName,
projectName: funplay.projectName || this.projectName,
projectIdentity: remoteProjectIdentity,
version: serverInfo.version || '',
};
this.actualPort = port;
this.portFallbackInfo = null;
this.log('info', `Attached to existing MCP listener for this project at http://${this.config.host}:${port}/.`);
return true;
}
this.log(
'warn',
`Port ${port} belongs to another listener; expected name=${this.serverName}, project=${this.projectIdentity}, ` +
`got name=${remoteName || 'unknown'}, project=${remoteProjectIdentity || 'unknown'}.`
);
return false;
}
probeExistingServer(port) {
const body = JSON.stringify({
jsonrpc: '2.0',
id: 'funplay-probe',
method: 'initialize',
params: {
protocolVersion: MCP_PROTOCOL_VERSION,
clientInfo: {
name: 'funplay-cocos-mcp-probe',
version: this.serverVersion,
},
},
});
return new Promise((resolve) => {
const request = http.request(
{
host: this.config.host,
port,
method: 'POST',
path: '/',
timeout: 600,
headers: {
Accept: 'application/json, text/event-stream',
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(body),
},
},
(response) => {
const chunks = [];
response.on('data', (chunk) => chunks.push(chunk));
response.on('end', () => {
if (response.statusCode !== 200) {
resolve(null);
return;
}
try {
resolve(JSON.parse(Buffer.concat(chunks).toString('utf8')));
} catch (error) {
resolve(null);
}
});
}
);
request.on('timeout', () => {
request.destroy();
resolve(null);
});
request.on('error', () => resolve(null));
request.end(body);
});
}
listen(server, port, host) {
return new Promise((resolve, reject) => {
const onError = (error) => {
@@ -559,6 +686,11 @@ class McpServer {
name: this.serverName,
version: this.serverVersion,
},
funplay: {
server: 'funplay-cocos-mcp',
projectName: this.projectName,
projectIdentity: this.projectIdentity,
},
capabilities: {
tools: {},
resources: {},
+155
View File
@@ -0,0 +1,155 @@
'use strict';
const PROFILE_FIELDS = [
'toolProfile',
'enabledToolCategories',
'disabledToolCategories',
'enabledTools',
'disabledTools',
];
function normalizeProfileName(value) {
const normalized = String(value || '').trim();
if (!normalized) {
throw new Error('profile name is required.');
}
return normalized.slice(0, 80);
}
function normalizeStringList(value) {
if (Array.isArray(value)) {
return value.map((item) => String(item || '').trim()).filter(Boolean);
}
if (typeof value === 'string') {
return value.split(/[\n,]/).map((item) => item.trim()).filter(Boolean);
}
return [];
}
function normalizeProfileMode(value) {
const normalized = String(value || 'core').trim().toLowerCase();
return normalized === 'full' || normalized === 'custom' ? normalized : 'core';
}
function normalizeToolProfile(value) {
const profile = value || {};
return {
name: normalizeProfileName(profile.name),
toolProfile: normalizeProfileMode(profile.toolProfile),
enabledToolCategories: normalizeStringList(profile.enabledToolCategories).map((item) => item.toLowerCase()),
disabledToolCategories: normalizeStringList(profile.disabledToolCategories).map((item) => item.toLowerCase()),
enabledTools: normalizeStringList(profile.enabledTools),
disabledTools: normalizeStringList(profile.disabledTools),
updatedAt: profile.updatedAt ? String(profile.updatedAt) : new Date().toISOString(),
};
}
function normalizeSavedToolProfiles(value) {
const profiles = [];
const seen = new Set();
for (const item of Array.isArray(value) ? value : []) {
try {
const profile = normalizeToolProfile(item);
const key = profile.name.toLowerCase();
if (seen.has(key)) {
const index = profiles.findIndex((existing) => existing.name.toLowerCase() === key);
profiles[index] = profile;
} else {
seen.add(key);
profiles.push(profile);
}
} catch (error) {
// Ignore malformed saved profile entries rather than breaking extension startup.
}
}
return profiles.sort((left, right) => left.name.localeCompare(right.name));
}
function createToolProfileSnapshot(config = {}, name) {
return normalizeToolProfile({
name,
toolProfile: config.toolProfile,
enabledToolCategories: config.enabledToolCategories,
disabledToolCategories: config.disabledToolCategories,
enabledTools: config.enabledTools,
disabledTools: config.disabledTools,
});
}
function upsertToolProfile(savedProfiles, profile) {
const normalized = normalizeToolProfile(profile);
const profiles = normalizeSavedToolProfiles(savedProfiles);
const key = normalized.name.toLowerCase();
const index = profiles.findIndex((item) => item.name.toLowerCase() === key);
if (index >= 0) {
profiles[index] = normalized;
} else {
profiles.push(normalized);
}
return normalizeSavedToolProfiles(profiles);
}
function deleteToolProfile(savedProfiles, name) {
const key = normalizeProfileName(name).toLowerCase();
return normalizeSavedToolProfiles(savedProfiles)
.filter((profile) => profile.name.toLowerCase() !== key);
}
function findToolProfile(savedProfiles, name) {
const key = normalizeProfileName(name).toLowerCase();
return normalizeSavedToolProfiles(savedProfiles)
.find((profile) => profile.name.toLowerCase() === key) || null;
}
function applyToolProfile(config = {}, profile) {
const normalized = normalizeToolProfile(profile);
const next = { ...config };
for (const field of PROFILE_FIELDS) {
next[field] = Array.isArray(normalized[field])
? normalized[field].slice()
: normalized[field];
}
next.activeToolProfileName = normalized.name;
return next;
}
function exportToolProfiles(savedProfiles) {
return {
version: 1,
profiles: normalizeSavedToolProfiles(savedProfiles),
};
}
function parseProfileImportPayload(payload) {
if (typeof payload === 'string') {
return JSON.parse(payload);
}
return payload || {};
}
function importToolProfiles(savedProfiles, payload, options = {}) {
const parsed = parseProfileImportPayload(payload);
const incoming = Array.isArray(parsed)
? parsed
: Array.isArray(parsed.profiles)
? parsed.profiles
: [];
if (!incoming.length) {
throw new Error('No tool profiles found in import payload.');
}
const base = options.replace ? [] : normalizeSavedToolProfiles(savedProfiles);
return incoming.reduce((profiles, profile) => upsertToolProfile(profiles, profile), base);
}
module.exports = {
applyToolProfile,
createToolProfileSnapshot,
deleteToolProfile,
exportToolProfiles,
findToolProfile,
importToolProfiles,
normalizeSavedToolProfiles,
normalizeToolProfile,
upsertToolProfile,
};
+62 -1
View File
@@ -23,6 +23,7 @@ const {
} = require('./logs');
const { resolveProjectPath } = require('./path-safety');
const {
createCocosMcpProjectSkill,
createProjectSkill,
listProjectInstructions,
readProjectInstruction,
@@ -36,14 +37,22 @@ const {
revertPrefabInstance,
validatePrefabReferences,
} = require('./prefabs');
const { createAssetsAdvancedTools } = require('./tools/assets-advanced');
const { createCocosProjectTools } = require('./tools/cocos-project');
const { buildSnippet, createFileTools, refreshAssets } = require('./tools/files');
const { createSceneEventTools } = require('./tools/scene-events');
const { captureDesktopScreenshot, captureEditorWindowScreenshot, capturePanelScreenshot } = require('./screenshots');
const { checkForUpdate } = require('./update-checker');
const { assertJavascriptSafety } = require('./javascript-safety');
const { safeStringify } = require('./utils');
const IMAGE_DATA_URI_PREFIX = 'data:image/png;base64,';
const TOOL_CATEGORY_RULES = [
['project', /^(get_project_info|get_editor_state|get_tool_catalog)$/],
['build', /^(get_build_status|open_build_panel|run_project_preview|save_current_scene)$/],
['preferences', /preference/],
['broadcast', /broadcast/],
['events', /event|bind_button_click|button_click/],
['updates', /update/],
['logs', /log/],
['diagnostics', /diagnostic|validate/],
@@ -306,6 +315,30 @@ function toOutput(value) {
return safeStringify(value);
}
function useJavascriptSafetyChecks(args, runtimeContext) {
if (args && typeof args.safety_checks === 'boolean') {
return args.safety_checks;
}
if (args && typeof args.safetyChecks === 'boolean') {
return args.safetyChecks;
}
const config = runtimeContext && runtimeContext.config;
if (config && typeof config.executeJavascriptSafetyChecks === 'boolean') {
return config.executeJavascriptSafetyChecks;
}
return true;
}
function assertToolJavascriptSafety(args, runtimeContext) {
if (!useJavascriptSafetyChecks(args, runtimeContext)) {
return;
}
assertJavascriptSafety(args && args.code, {
projectPath: runtimeContext && runtimeContext.projectPath,
});
}
async function resolveNodeUuid(sceneBridge, args) {
if (args && args.uuid) {
return String(args.uuid);
@@ -328,11 +361,14 @@ function createToolRegistry({ getRuntimeContext, getStatus, interactionLog, runt
context: { type: 'string', description: 'Execution context: scene or editor.' },
code: { type: 'string', description: 'JavaScript code to execute. May directly return a value, define run(env), or export a function.' },
args: { type: 'object', description: 'Optional JSON object passed into the script.' },
safety_checks: { type: 'boolean', description: 'Override the project default JavaScript safety checks for this call.' },
},
['context', 'code']
),
handler: async (args) => {
const context = String(args.context || '').toLowerCase();
const runtimeContext = getRuntimeContext();
assertToolJavascriptSafety(args, runtimeContext);
if (context === 'scene') {
return sceneBridge.call('executeCode', { code: args.code, args: args.args || {} });
}
@@ -353,10 +389,14 @@ function createToolRegistry({ getRuntimeContext, getStatus, interactionLog, runt
{
code: { type: 'string', description: 'JavaScript code to execute inside the scene script context.' },
args: { type: 'object', description: 'Optional JSON object passed to the scene script.' },
safety_checks: { type: 'boolean', description: 'Override the project default JavaScript safety checks for this call.' },
},
['code']
),
handler: async (args) => sceneBridge.call('executeCode', { code: args.code, args: args.args || {} }),
handler: async (args) => {
assertToolJavascriptSafety(args, getRuntimeContext());
return sceneBridge.call('executeCode', { code: args.code, args: args.args || {} });
},
},
{
name: 'execute_editor_script',
@@ -366,10 +406,12 @@ function createToolRegistry({ getRuntimeContext, getStatus, interactionLog, runt
{
code: { type: 'string', description: 'JavaScript code to execute inside the editor context.' },
args: { type: 'object', description: 'Optional JSON object passed to the editor script.' },
safety_checks: { type: 'boolean', description: 'Override the project default JavaScript safety checks for this call.' },
},
['code']
),
handler: async (args) => {
assertToolJavascriptSafety(args, getRuntimeContext());
if (typeof editorExecutor !== 'function') {
throw new Error('Editor JavaScript execution is unavailable.');
}
@@ -512,6 +554,22 @@ function createToolRegistry({ getRuntimeContext, getStatus, interactionLog, runt
return createProjectSkill(projectPath, args);
},
},
{
name: 'create_cocos_mcp_project_skill',
profile: 'full',
description: '[core] Create a recommended local Codex project skill for Funplay Cocos MCP workflows.',
inputSchema: createSchema(
{
skillName: { type: 'string', description: 'Optional filesystem-safe project skill name.' },
overwrite: { type: 'boolean', description: 'Allow overwriting an existing skill. Defaults to true.' },
},
[]
),
handler: async (args) => {
const { projectPath } = getRuntimeContext();
return createCocosMcpProjectSkill(projectPath, args);
},
},
{
name: 'set_selection',
profile: 'core',
@@ -656,6 +714,7 @@ function createToolRegistry({ getRuntimeContext, getStatus, interactionLog, runt
inputSchema: createSchema({}, []),
handler: async () => getRuntimeContext(),
},
...createCocosProjectTools({ createSchema }),
{
name: 'list_scenes',
profile: 'core',
@@ -961,6 +1020,7 @@ function createToolRegistry({ getRuntimeContext, getStatus, interactionLog, runt
return selectAsset(info.uuid || args.target);
},
},
...createAssetsAdvancedTools({ createSchema, getRuntimeContext }),
{
name: 'get_editor_selection',
profile: 'full',
@@ -1463,6 +1523,7 @@ function createToolRegistry({ getRuntimeContext, getStatus, interactionLog, runt
),
handler: async (args) => sceneBridge.call('simulateButtonClick', args),
},
...createSceneEventTools({ createSchema, sceneBridge }),
{
name: 'invoke_component_method',
profile: 'full',
+221
View File
@@ -0,0 +1,221 @@
'use strict';
const fs = require('fs');
const path = require('path');
const { listAssets, queryAssetInfo } = require('../assets');
const { resolveProjectPath } = require('../path-safety');
const UUID_KEY_PATTERN = /uuid|assetUuid|prefabUuid|sceneUuid|__uuid__/i;
const UUID_LITERAL_PATTERN = /[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}|[A-Za-z0-9+/=-]{20,32}/g;
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 = resolveProjectPath(projectPath, candidate);
if (fs.existsSync(fullPath) && fs.statSync(fullPath).isFile()) {
return fullPath;
}
}
return '';
}
function collectStructuredUuidReferences(value, refs = [], pointer = '') {
if (value == null) {
return refs;
}
if (Array.isArray(value)) {
value.forEach((item, index) => collectStructuredUuidReferences(item, refs, `${pointer}/${index}`));
return refs;
}
if (typeof value !== 'object') {
return refs;
}
for (const [key, child] of Object.entries(value)) {
const childPointer = `${pointer}/${key}`;
if (typeof child === 'string' && UUID_KEY_PATTERN.test(key)) {
refs.push({ uuid: child, path: childPointer, key, source: 'structured' });
continue;
}
collectStructuredUuidReferences(child, refs, childPointer);
}
return refs;
}
function collectTextUuidReferences(text) {
const refs = [];
const seen = new Set();
let match;
while ((match = UUID_LITERAL_PATTERN.exec(String(text || '')))) {
const uuid = match[0];
if (seen.has(uuid)) {
continue;
}
seen.add(uuid);
refs.push({ uuid, path: `@${match.index}`, key: '', source: 'text' });
}
return refs;
}
function dedupeReferences(refs) {
const seen = new Set();
const result = [];
for (const ref of refs) {
const key = `${ref.uuid}:${ref.path}`;
if (seen.has(key)) {
continue;
}
seen.add(key);
result.push(ref);
}
return result;
}
function collectUuidReferences(content) {
const refs = [];
try {
refs.push(...collectStructuredUuidReferences(JSON.parse(content)));
} catch (error) {
// Non-JSON assets still get a literal reference scan below.
}
refs.push(...collectTextUuidReferences(content));
return dedupeReferences(refs);
}
async function inspectAssetDependencies(projectPath, target, options = {}) {
const info = await queryAssetInfo(target);
const filePath = assetFilePath(projectPath, info);
if (!filePath) {
throw new Error(`Asset file was not found: ${target}`);
}
const content = fs.readFileSync(filePath, 'utf8');
const limit = Number.isFinite(options.limit) ? Math.max(1, Math.min(500, options.limit)) : 200;
const references = collectUuidReferences(content).slice(0, limit);
const dependencies = [];
const missing = [];
for (const ref of references) {
try {
const asset = await queryAssetInfo(ref.uuid);
dependencies.push({
...ref,
exists: true,
asset: {
uuid: asset.uuid,
url: asset.url,
type: asset.type,
importer: asset.importer,
},
});
} catch (error) {
missing.push({ ...ref, exists: false, error: error.message });
}
}
return {
ok: missing.length === 0,
target,
asset: {
uuid: info.uuid,
url: info.url,
type: info.type,
},
filePath: path.relative(projectPath, filePath).replace(/\\/g, '/'),
referenceCount: references.length,
dependencyCount: dependencies.length,
missingCount: missing.length,
dependencies,
missing,
};
}
async function validateAssetDependencies(projectPath, options = {}) {
const targets = options.target
? [options.target]
: (await listAssets({ pattern: options.pattern || 'db://assets/**', ccType: options.ccType }))
.slice(0, Number.isFinite(options.limit) ? Math.max(1, Math.min(200, options.limit)) : 50)
.map((asset) => asset.uuid || asset.url)
.filter(Boolean);
const assets = [];
for (const target of targets) {
try {
assets.push(await inspectAssetDependencies(projectPath, target, options));
} catch (error) {
assets.push({
ok: false,
target,
error: error.message,
missingCount: 1,
});
}
}
const missingCount = assets.reduce((sum, asset) => sum + (Number(asset.missingCount) || 0), 0);
return {
ok: missingCount === 0,
assetCount: assets.length,
missingCount,
assets,
};
}
function createAssetsAdvancedTools({ createSchema, getRuntimeContext }) {
return [
{
name: 'inspect_asset_dependencies',
profile: 'core',
description: '[specialist] Inspect UUID-style dependencies referenced by a serialized Cocos asset.',
inputSchema: createSchema(
{
target: { type: 'string', description: 'Asset uuid, db url, or project path.' },
limit: { type: 'number', description: 'Maximum dependency references to inspect.' },
},
['target']
),
handler: async (args) => {
const { projectPath } = getRuntimeContext();
return await inspectAssetDependencies(projectPath, args.target, args);
},
},
{
name: 'validate_asset_dependencies',
profile: 'core',
description: '[specialist] Validate UUID-style dependencies for one asset or a project asset query.',
inputSchema: createSchema(
{
target: { type: 'string', description: 'Optional asset uuid, db url, or project path.' },
pattern: { type: 'string', description: 'Asset-db pattern used when target is omitted.' },
ccType: { type: 'string', description: 'Optional Cocos asset type filter.' },
limit: { type: 'number', description: 'Maximum assets to scan when target is omitted.' },
},
[]
),
handler: async (args) => {
const { projectPath } = getRuntimeContext();
return await validateAssetDependencies(projectPath, args);
},
},
];
}
module.exports = {
collectUuidReferences,
createAssetsAdvancedTools,
inspectAssetDependencies,
validateAssetDependencies,
};
+245
View File
@@ -0,0 +1,245 @@
'use strict';
function hasEditorMessage() {
return Boolean(global.Editor && Editor.Message);
}
function ensureEditorMessage() {
if (!hasEditorMessage()) {
throw new Error('Editor.Message is unavailable in this Cocos extension host.');
}
}
async function requestEditorMessage(channel, method, ...args) {
ensureEditorMessage();
if (typeof Editor.Message.request !== 'function') {
throw new Error('Editor.Message.request is unavailable in this Cocos extension host.');
}
return await Editor.Message.request(channel, method, ...args);
}
async function tryEditorRequests(candidates) {
const attempts = [];
for (const candidate of candidates) {
const channel = candidate.channel;
const method = candidate.method;
const args = Array.isArray(candidate.args) ? candidate.args : [];
try {
const result = await requestEditorMessage(channel, method, ...args);
return {
ok: true,
channel,
method,
result,
attempts,
};
} catch (error) {
attempts.push({ channel, method, error: error.message });
}
}
const message = attempts.length
? attempts.map((attempt) => `${attempt.channel}.${attempt.method}: ${attempt.error}`).join('; ')
: 'no editor message candidates were provided';
const error = new Error(`No compatible Cocos editor message succeeded: ${message}`);
error.attempts = attempts;
throw error;
}
async function tryEditorRequestsStatus(candidates) {
try {
return await tryEditorRequests(candidates);
} catch (error) {
return {
ok: false,
available: false,
attempts: error.attempts || [],
error: error.message,
};
}
}
async function openPanel(panelName) {
const id = String(panelName || 'builder').trim();
if (!id) {
throw new Error('panelName is required.');
}
if (!global.Editor || !Editor.Panel || typeof Editor.Panel.open !== 'function') {
throw new Error('Editor.Panel.open is unavailable in this Cocos extension host.');
}
const result = await Editor.Panel.open(id);
return { opened: true, panelName: id, result };
}
function getEditorPreference(scope, key) {
if (!global.Editor || !Editor.Profile) {
throw new Error('Editor.Profile is unavailable in this Cocos extension host.');
}
const normalizedScope = String(scope || 'project').toLowerCase();
const target = normalizedScope === 'global' ? Editor.Profile : Editor.Profile;
const getters = normalizedScope === 'global'
? ['getConfig', 'getGlobal']
: ['getProject', 'getConfig'];
for (const getter of getters) {
if (typeof target[getter] === 'function') {
return target[getter](key);
}
}
throw new Error('No compatible Editor.Profile getter is available.');
}
function setEditorPreference(scope, key, value) {
if (!global.Editor || !Editor.Profile) {
throw new Error('Editor.Profile is unavailable in this Cocos extension host.');
}
const normalizedScope = String(scope || 'project').toLowerCase();
const target = Editor.Profile;
const setters = normalizedScope === 'global'
? ['setConfig', 'setGlobal']
: ['setProject', 'setConfig'];
for (const setter of setters) {
if (typeof target[setter] === 'function') {
const result = target[setter](key, value);
return { set: true, scope: normalizedScope, key, value, method: setter, result };
}
}
throw new Error('No compatible Editor.Profile setter is available.');
}
function broadcastEditorMessage(options = {}) {
ensureEditorMessage();
const channel = String(options.channel || '').trim();
const message = String(options.message || '').trim();
if (!message) {
throw new Error('message is required.');
}
const payload = options.payload === undefined ? {} : options.payload;
if (channel && typeof Editor.Message.send === 'function') {
const result = Editor.Message.send(channel, message, payload);
return { sent: true, mode: 'send', channel, message, payload, result };
}
if (typeof Editor.Message.broadcast === 'function') {
const result = Editor.Message.broadcast(message, payload);
return { sent: true, mode: 'broadcast', message, payload, result };
}
throw new Error('Neither Editor.Message.send nor Editor.Message.broadcast is available.');
}
function createCocosProjectTools({ createSchema }) {
return [
{
name: 'save_current_scene',
profile: 'full',
description: '[core] Save the currently open Cocos scene using available editor scene messages.',
inputSchema: createSchema({}, []),
handler: async () => {
const result = await tryEditorRequests([
{ channel: 'scene', method: 'save-scene' },
{ channel: 'scene', method: 'save' },
]);
return { saved: true, ...result };
},
},
{
name: 'open_build_panel',
profile: 'full',
description: '[core] Open the Cocos build panel, defaulting to the builder panel id.',
inputSchema: createSchema(
{
panelName: { type: 'string', description: 'Panel id to open. Defaults to builder.' },
},
[]
),
handler: async (args) => openPanel(args.panelName || 'builder'),
},
{
name: 'get_build_status',
profile: 'core',
description: '[specialist] Query Cocos build/preview status using known builder message variants.',
inputSchema: createSchema({}, []),
handler: async () => await tryEditorRequestsStatus([
{ channel: 'builder', method: 'query-build-status' },
{ channel: 'builder', method: 'get-build-status' },
{ channel: 'builder', method: 'query-build-tasks' },
]),
},
{
name: 'run_project_preview',
profile: 'full',
description: '[core] Start Cocos preview/run using known preview and builder message variants.',
inputSchema: createSchema(
{
platform: { type: 'string', description: 'Optional preview platform or build target.' },
},
[]
),
handler: async (args) => await tryEditorRequests([
{ channel: 'preview', method: 'start', args: [args || {}] },
{ channel: 'preview', method: 'open-preview', args: [args || {}] },
{ channel: 'builder', method: 'preview', args: [args || {}] },
]),
},
{
name: 'get_editor_preference',
profile: 'full',
description: '[core] Read a Cocos editor preference through Editor.Profile when available.',
inputSchema: createSchema(
{
scope: { type: 'string', description: 'Preference scope: project or global. Defaults to project.' },
key: { type: 'string', description: 'Preference key.' },
},
['key']
),
handler: async (args) => ({
scope: args.scope || 'project',
key: args.key,
value: getEditorPreference(args.scope, args.key),
}),
},
{
name: 'set_editor_preference',
profile: 'full',
description: '[core] Write a Cocos editor preference through Editor.Profile when available.',
inputSchema: createSchema(
{
scope: { type: 'string', description: 'Preference scope: project or global. Defaults to project.' },
key: { type: 'string', description: 'Preference key.' },
valueJson: { type: 'string', description: 'JSON encoded preference value.' },
},
['key', 'valueJson']
),
handler: async (args) => {
let value;
try {
value = JSON.parse(args.valueJson);
} catch (error) {
throw new Error(`valueJson must be valid JSON: ${error.message}`);
}
return setEditorPreference(args.scope, args.key, value);
},
},
{
name: 'broadcast_editor_message',
profile: 'full',
description: '[core] Send or broadcast a Cocos editor message for advanced editor automation.',
inputSchema: createSchema(
{
channel: { type: 'string', description: 'Optional Editor.Message channel for send().' },
message: { type: 'string', description: 'Message name to send or broadcast.' },
payload: { type: 'object', description: 'Optional JSON payload.' },
},
['message']
),
handler: async (args) => broadcastEditorMessage(args),
},
];
}
module.exports = {
broadcastEditorMessage,
createCocosProjectTools,
getEditorPreference,
setEditorPreference,
tryEditorRequests,
tryEditorRequestsStatus,
};
+45
View File
@@ -0,0 +1,45 @@
'use strict';
function createSceneEventTools({ createSchema, sceneBridge }) {
return [
{
name: 'list_button_click_events',
profile: 'full',
description: '[core] List click event bindings on a Cocos Button component.',
inputSchema: createSchema(
{
path: { type: 'string', description: 'Button node hierarchy path.' },
uuid: { type: 'string', description: 'Button node uuid.' },
name: { type: 'string', description: 'Fallback exact button node name.' },
},
[]
),
handler: async (args) => sceneBridge.call('listButtonClickEvents', args),
},
{
name: 'bind_button_click_event',
profile: 'full',
description: '[core] Bind a Cocos Button click event to a target node component method.',
inputSchema: createSchema(
{
path: { type: 'string', description: 'Button node hierarchy path.' },
uuid: { type: 'string', description: 'Button node uuid.' },
name: { type: 'string', description: 'Fallback exact button node name.' },
targetPath: { type: 'string', description: 'Target node path containing the handler component.' },
targetUuid: { type: 'string', description: 'Target node uuid containing the handler component.' },
targetName: { type: 'string', description: 'Fallback exact target node name.' },
componentName: { type: 'string', description: 'Target component class name.' },
handler: { type: 'string', description: 'Method name to invoke on the target component.' },
customEventData: { type: 'string', description: 'Optional custom event data string.' },
replace: { type: 'boolean', description: 'Replace an identical existing binding.' },
},
['componentName', 'handler']
),
handler: async (args) => sceneBridge.call('bindButtonClickEvent', args),
},
];
}
module.exports = {
createSceneEventTools,
};
+2 -2
View File
@@ -1,7 +1,7 @@
{
"name": "funplay-cocos-mcp",
"package_version": 2,
"version": "0.3.3",
"version": "0.4.0",
"description": "Embedded MCP server for Cocos Creator with scene script execution, project resources, prompts, and file/scene tools.",
"author": "Funplay",
"license": "MIT",
@@ -49,7 +49,7 @@
"access": "public"
},
"scripts": {
"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",
"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/javascript-safety.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-profiles.js && node --check lib/tool-registry.js && node --check lib/tools/assets-advanced.js && node --check lib/tools/cocos-project.js && node --check lib/tools/files.js && node --check lib/tools/scene-events.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",
"docs:generate": "node scripts/generate-tool-docs.js",
"docs:check": "node scripts/generate-tool-docs.js --check",
+318 -1
View File
@@ -51,6 +51,10 @@ module.exports = Editor.Panel.define({
<ui-checkbox id="sessionsInput"></ui-checkbox>
MCP Sessions
</label>
<label class="checkbox-line">
<ui-checkbox id="javascriptSafetyInput"></ui-checkbox>
JavaScript Safety Checks
</label>
</div>
<div id="updateStatus" class="client-status muted"></div>
<p>Changes auto-save. Port/profile changes restart the server when needed.</p>
@@ -64,6 +68,19 @@ module.exports = Editor.Panel.define({
<ui-button id="useFullBtn">Full</ui-button>
<ui-button id="useCustomBtn">Custom</ui-button>
</div>
<div class="profile-manager">
<div class="row">
<ui-input id="toolProfileNameInput" placeholder="Profile name"></ui-input>
<ui-select id="savedToolProfileSelect"></ui-select>
<ui-button id="saveToolProfileBtn">Save Profile</ui-button>
<ui-button id="applyToolProfileBtn">Apply</ui-button>
<ui-button id="deleteToolProfileBtn">Delete</ui-button>
<ui-button id="exportToolProfilesBtn">Export</ui-button>
<ui-button id="importToolProfilesBtn">Import</ui-button>
</div>
<ui-textarea id="toolProfileImportText"></ui-textarea>
</div>
<div id="categoryControls" class="category-controls"></div>
<div class="tool-config-grid">
<label>Enabled Categories <ui-textarea id="enabledCategoriesInput"></ui-textarea></label>
<label>Disabled Categories <ui-textarea id="disabledCategoriesInput"></ui-textarea></label>
@@ -180,6 +197,54 @@ module.exports = Editor.Panel.define({
gap: 8px;
margin-top: 8px;
}
.profile-manager {
margin-top: 8px;
}
#toolProfileNameInput {
min-width: 150px;
}
#savedToolProfileSelect {
min-width: 150px;
}
#toolProfileImportText {
min-height: 54px;
margin-top: 8px;
}
.category-controls {
display: grid;
grid-template-columns: repeat(2, minmax(220px, 1fr));
gap: 8px;
margin-top: 10px;
}
.category-row {
border: 1px solid var(--color-normal-border);
border-radius: 6px;
padding: 8px;
background: rgba(0,0,0,0.10);
display: grid;
gap: 6px;
}
.category-heading {
display: flex;
justify-content: space-between;
align-items: center;
gap: 8px;
}
.category-name {
color: var(--color-normal-contrast);
font-weight: 600;
word-break: break-word;
}
.category-count {
color: var(--color-normal-contrast-weakest);
font-size: 11px;
white-space: nowrap;
}
.category-actions {
display: flex;
gap: 6px;
flex-wrap: wrap;
}
label {
display: flex;
flex-direction: column;
@@ -298,6 +363,9 @@ module.exports = Editor.Panel.define({
.activity-grid {
grid-template-columns: 1fr;
}
.category-controls {
grid-template-columns: 1fr;
}
}
`,
$: {
@@ -309,6 +377,7 @@ module.exports = Editor.Panel.define({
portInput: '#portInput',
profileSelect: '#profileSelect',
sessionsInput: '#sessionsInput',
javascriptSafetyInput: '#javascriptSafetyInput',
restartBtn: '#restartBtn',
copyUrlBtn: '#copyUrlBtn',
copyHealthCurlBtn: '#copyHealthCurlBtn',
@@ -319,6 +388,15 @@ module.exports = Editor.Panel.define({
useCoreBtn: '#useCoreBtn',
useFullBtn: '#useFullBtn',
useCustomBtn: '#useCustomBtn',
toolProfileNameInput: '#toolProfileNameInput',
savedToolProfileSelect: '#savedToolProfileSelect',
saveToolProfileBtn: '#saveToolProfileBtn',
applyToolProfileBtn: '#applyToolProfileBtn',
deleteToolProfileBtn: '#deleteToolProfileBtn',
exportToolProfilesBtn: '#exportToolProfilesBtn',
importToolProfilesBtn: '#importToolProfilesBtn',
toolProfileImportText: '#toolProfileImportText',
categoryControls: '#categoryControls',
enabledCategoriesInput: '#enabledCategoriesInput',
disabledCategoriesInput: '#disabledCategoriesInput',
enabledToolsInput: '#enabledToolsInput',
@@ -353,19 +431,23 @@ module.exports = Editor.Panel.define({
const portText = status.portFallbackActive
? ` | Port fallback: ${status.requestedPort} -> ${status.port}`
: '';
const attachText = status.attachedToExisting ? ' | Attached listener' : '';
this.$.statusText.textContent =
`${status.url || ''} | Project: ${status.projectName || ''} | Cocos ${status.cocosVersion || ''}${portText}`;
`${status.url || ''} | Project: ${status.projectName || ''} | Cocos ${status.cocosVersion || ''}${portText}${attachText}`;
this.$.enabledInput.value = Boolean(isRunning || config.autostart);
this.$.portInput.value = Number(config.port || status.port || 8765);
this.$.profileSelect.value = config.toolProfile || status.toolProfile || 'core';
this.$.sessionsInput.value = Boolean(config.enableSessions || status.enableSessions);
this.$.javascriptSafetyInput.value = config.executeJavascriptSafetyChecks !== false;
this.$.enabledCategoriesInput.value = this.formatList(config.enabledToolCategories);
this.$.disabledCategoriesInput.value = this.formatList(config.disabledToolCategories);
this.$.enabledToolsInput.value = this.formatList(config.enabledTools);
this.$.disabledToolsInput.value = this.formatList(config.disabledTools);
this.renderToolProfiles();
this.renderUpdateStatus();
this.renderToolSummary();
this.renderCategoryControls();
this.renderClientTargets();
this.renderActivity();
},
@@ -373,11 +455,136 @@ module.exports = Editor.Panel.define({
return Array.isArray(value) ? value.join('\n') : '';
},
parseList(value) {
if (Array.isArray(value)) {
return value.map((item) => String(item || '').trim()).filter(Boolean);
}
return String(value || '')
.split(/[\n,]/)
.map((item) => item.trim())
.filter(Boolean);
},
normalizeToolProfile(profile) {
const name = String(profile && profile.name || '').trim();
if (!name) {
throw new Error('Profile name is required.');
}
const mode = String(profile.toolProfile || 'core').toLowerCase();
return {
name: name.slice(0, 80),
toolProfile: mode === 'full' || mode === 'custom' ? mode : 'core',
enabledToolCategories: this.parseList(profile.enabledToolCategories).map((item) => item.toLowerCase()),
disabledToolCategories: this.parseList(profile.disabledToolCategories).map((item) => item.toLowerCase()),
enabledTools: this.parseList(profile.enabledTools),
disabledTools: this.parseList(profile.disabledTools),
updatedAt: profile.updatedAt || new Date().toISOString(),
};
},
normalizeToolProfiles(value) {
const result = [];
const seen = new Set();
(Array.isArray(value) ? value : []).forEach((profile) => {
try {
const normalized = this.normalizeToolProfile(profile);
const key = normalized.name.toLowerCase();
const existing = result.findIndex((item) => item.name.toLowerCase() === key);
if (existing >= 0) {
result[existing] = normalized;
} else if (!seen.has(key)) {
seen.add(key);
result.push(normalized);
}
} catch (error) {
// Ignore malformed imported entries in the panel; backend normalization repeats this.
}
});
return result.sort((left, right) => left.name.localeCompare(right.name));
},
currentToolProfileSnapshot(name) {
return this.normalizeToolProfile({
name,
toolProfile: this.$.profileSelect.value || 'core',
enabledToolCategories: this.parseList(this.$.enabledCategoriesInput.value).map((item) => item.toLowerCase()),
disabledToolCategories: this.parseList(this.$.disabledCategoriesInput.value).map((item) => item.toLowerCase()),
enabledTools: this.parseList(this.$.enabledToolsInput.value),
disabledTools: this.parseList(this.$.disabledToolsInput.value),
});
},
getSavedToolProfiles() {
const config = this.state && this.state.config ? this.state.config : {};
return this.normalizeToolProfiles(config.savedToolProfiles || []);
},
renderToolProfiles() {
const config = this.state && this.state.config ? this.state.config : {};
const profiles = this.getSavedToolProfiles();
const selected = this.$.savedToolProfileSelect.value || config.activeToolProfileName || (profiles[0] && profiles[0].name) || '';
this.$.savedToolProfileSelect.innerHTML = '';
if (profiles.length) {
profiles.forEach((profile) => {
const option = document.createElement('option');
option.value = profile.name;
option.textContent = profile.name;
this.$.savedToolProfileSelect.appendChild(option);
});
} else {
const option = document.createElement('option');
option.value = '';
option.textContent = 'No saved profiles';
this.$.savedToolProfileSelect.appendChild(option);
}
this.$.savedToolProfileSelect.value = selected;
if (!this.$.toolProfileNameInput.value) {
this.$.toolProfileNameInput.value = selected || config.activeToolProfileName || '';
}
},
renderCategoryControls() {
const catalog = (this.state && this.state.toolCatalog) || [];
const groups = catalog.reduce((acc, tool) => {
const category = tool.category || 'other';
if (!acc[category]) {
acc[category] = { total: 0, enabled: 0 };
}
acc[category].total += 1;
if (tool.enabled) {
acc[category].enabled += 1;
}
return acc;
}, {});
this.$.categoryControls.innerHTML = '';
Object.keys(groups).sort().forEach((category) => {
const row = document.createElement('div');
row.className = 'category-row';
const heading = document.createElement('div');
heading.className = 'category-heading';
const name = document.createElement('div');
name.className = 'category-name';
name.textContent = category;
const count = document.createElement('div');
count.className = 'category-count';
count.textContent = `${groups[category].enabled}/${groups[category].total}`;
heading.appendChild(name);
heading.appendChild(count);
const actions = document.createElement('div');
actions.className = 'category-actions';
[
['enable', 'Enable'],
['disable', 'Disable'],
['clear', 'Clear'],
].forEach(([mode, label]) => {
const button = document.createElement('ui-button');
button.textContent = label;
button.dataset.category = category;
button.dataset.mode = mode;
actions.appendChild(button);
});
row.appendChild(heading);
row.appendChild(actions);
this.$.categoryControls.appendChild(row);
});
},
renderUpdateStatus() {
const update = this.state && this.state.updateInfo;
if (!update) {
@@ -546,11 +753,103 @@ module.exports = Editor.Panel.define({
enabledTools: this.parseList(this.$.enabledToolsInput.value),
disabledTools: this.parseList(this.$.disabledToolsInput.value),
enableSessions: Boolean(this.$.sessionsInput.value),
executeJavascriptSafetyChecks: Boolean(this.$.javascriptSafetyInput.value),
autostart: Boolean(this.$.enabledInput.value),
maxInteractionLogEntries: this.state && this.state.config ? this.state.config.maxInteractionLogEntries : 50,
lastClientTargetId: this.$.clientTargetSelect.value || 'claude_code',
activeToolProfileName: this.$.toolProfileNameInput.value || '',
savedToolProfiles: this.getSavedToolProfiles(),
};
},
async saveCurrentToolProfile() {
const name = this.$.toolProfileNameInput.value || this.$.savedToolProfileSelect.value;
const snapshot = this.currentToolProfileSnapshot(name);
const profiles = this.getSavedToolProfiles();
const key = snapshot.name.toLowerCase();
const existing = profiles.findIndex((profile) => profile.name.toLowerCase() === key);
if (existing >= 0) {
profiles[existing] = snapshot;
} else {
profiles.push(snapshot);
}
this.state.config.savedToolProfiles = this.normalizeToolProfiles(profiles);
this.state.config.activeToolProfileName = snapshot.name;
await this.persistConfig({ showOutput: true });
},
async applySavedToolProfile() {
const name = this.$.savedToolProfileSelect.value;
const profile = this.getSavedToolProfiles().find((item) => item.name === name);
if (!profile) {
this.showOutput('Select a saved profile first.');
return;
}
this.$.profileSelect.value = profile.toolProfile;
this.$.enabledCategoriesInput.value = this.formatList(profile.enabledToolCategories);
this.$.disabledCategoriesInput.value = this.formatList(profile.disabledToolCategories);
this.$.enabledToolsInput.value = this.formatList(profile.enabledTools);
this.$.disabledToolsInput.value = this.formatList(profile.disabledTools);
this.$.toolProfileNameInput.value = profile.name;
this.state.config.activeToolProfileName = profile.name;
await this.persistConfig({ showOutput: true });
},
async deleteSavedToolProfile() {
const name = this.$.savedToolProfileSelect.value;
if (!name) {
this.showOutput('Select a saved profile first.');
return;
}
this.state.config.savedToolProfiles = this.getSavedToolProfiles()
.filter((profile) => profile.name !== name);
if (this.state.config.activeToolProfileName === name) {
this.state.config.activeToolProfileName = '';
}
this.$.toolProfileNameInput.value = '';
await this.persistConfig({ showOutput: true });
},
exportSavedToolProfiles() {
const payload = JSON.stringify({ version: 1, profiles: this.getSavedToolProfiles() }, null, 2);
this.$.toolProfileImportText.value = payload;
this.copyText(payload, 'Copied tool profiles to clipboard.');
},
async importSavedToolProfiles() {
try {
const payload = JSON.parse(this.$.toolProfileImportText.value || '{}');
const incoming = Array.isArray(payload)
? payload
: Array.isArray(payload.profiles)
? payload.profiles
: [];
if (!incoming.length) {
throw new Error('No profiles found.');
}
this.state.config.savedToolProfiles = this.normalizeToolProfiles([
...this.getSavedToolProfiles(),
...incoming,
]);
await this.persistConfig({ showOutput: true });
} catch (error) {
this.showOutput(`Import profiles failed: ${error.message}`);
}
},
async setCategoryExposure(category, mode) {
const enabled = new Set(this.parseList(this.$.enabledCategoriesInput.value).map((item) => item.toLowerCase()));
const disabled = new Set(this.parseList(this.$.disabledCategoriesInput.value).map((item) => item.toLowerCase()));
const key = String(category || '').toLowerCase();
if (!key) {
return;
}
enabled.delete(key);
disabled.delete(key);
if (mode === 'enable') {
enabled.add(key);
} else if (mode === 'disable') {
disabled.add(key);
}
this.$.profileSelect.value = 'custom';
this.$.enabledCategoriesInput.value = Array.from(enabled).sort().join('\n');
this.$.disabledCategoriesInput.value = Array.from(disabled).sort().join('\n');
await this.persistConfig({ showOutput: true });
},
async handleEnableToggle() {
const shouldEnable = Boolean(this.$.enabledInput.value);
const wasRunning = Boolean(this.state && this.state.status && this.state.status.running);
@@ -587,6 +886,7 @@ module.exports = Editor.Panel.define({
this.$.portInput.addEventListener('change', () => this.persistConfig({ showOutput: true }));
this.$.profileSelect.addEventListener('change', () => this.persistConfig({ showOutput: true }));
this.$.sessionsInput.addEventListener('change', () => this.persistConfig({ showOutput: true }));
this.$.javascriptSafetyInput.addEventListener('change', () => this.persistConfig({ showOutput: true }));
this.$.enabledCategoriesInput.addEventListener('change', () => this.persistConfig({ showOutput: true }));
this.$.disabledCategoriesInput.addEventListener('change', () => this.persistConfig({ showOutput: true }));
this.$.enabledToolsInput.addEventListener('change', () => this.persistConfig({ showOutput: true }));
@@ -611,6 +911,23 @@ module.exports = Editor.Panel.define({
this.$.profileSelect.value = 'custom';
this.persistConfig({ showOutput: true });
});
this.$.saveToolProfileBtn.addEventListener('click', () => this.saveCurrentToolProfile());
this.$.applyToolProfileBtn.addEventListener('click', () => this.applySavedToolProfile());
this.$.deleteToolProfileBtn.addEventListener('click', () => this.deleteSavedToolProfile());
this.$.exportToolProfilesBtn.addEventListener('click', () => this.exportSavedToolProfiles());
this.$.importToolProfilesBtn.addEventListener('click', () => this.importSavedToolProfiles());
this.$.savedToolProfileSelect.addEventListener('change', () => {
this.$.toolProfileNameInput.value = this.$.savedToolProfileSelect.value || '';
});
this.$.categoryControls.addEventListener('click', (event) => {
const target = event.target && typeof event.target.closest === 'function'
? event.target.closest('ui-button')
: event.target;
if (!target || !target.dataset || !target.dataset.category) {
return;
}
this.setCategoryExposure(target.dataset.category, target.dataset.mode);
});
this.$.clientTargetSelect.addEventListener('confirm', () => this.renderClientTargetStatus());
this.$.clientTargetSelect.addEventListener('change', () => {
this.renderClientTargetStatus();
+113
View File
@@ -16,6 +16,7 @@ const {
SceneAsset,
js,
Component,
EventHandler,
Canvas,
UITransform,
Label,
@@ -241,6 +242,23 @@ function findComponent(node, options = {}) {
return null;
}
function getEventHandlerClass() {
return (Component && Component.EventHandler) || EventHandler || null;
}
function serializeEventHandler(handler) {
if (!handler) {
return null;
}
return {
target: handler.target && handler.target.name ? getNodePath(handler.target) : '',
targetUuid: handler.target && handler.target.uuid ? handler.target.uuid : '',
component: handler.component || '',
handler: handler.handler || '',
customEventData: handler.customEventData || '',
};
}
function getOrAddComponent(node, componentClass) {
return node.getComponent(componentClass) || node.addComponent(componentClass);
}
@@ -1345,6 +1363,101 @@ exports.methods = {
};
},
async listButtonClickEvents(options = {}) {
const node = findNode(options);
if (!node) {
throw new Error('Target node was not found.');
}
const button = node.getComponent(Button);
if (!button) {
throw new Error('Button component was not found on target node.');
}
return {
node: getNodePath(node),
uuid: node.uuid,
clickEventCount: Array.isArray(button.clickEvents) ? button.clickEvents.length : 0,
clickEvents: Array.isArray(button.clickEvents)
? button.clickEvents.map(serializeEventHandler).filter(Boolean)
: [],
};
},
async bindButtonClickEvent(options = {}) {
const node = findNode(options);
if (!node) {
throw new Error('Button node was not found.');
}
const button = node.getComponent(Button);
if (!button) {
throw new Error('Button component was not found on target node.');
}
const target = findNode({
path: options.targetPath,
uuid: options.targetUuid,
name: options.targetName,
});
if (!target) {
throw new Error('Event target node was not found.');
}
const componentName = String(options.componentName || '').trim();
const handlerName = String(options.handler || options.handlerName || '').trim();
if (!componentName || !handlerName) {
throw new Error('componentName and handler are required.');
}
const component = findComponent(target, { componentName });
if (!component) {
throw new Error(`Target component was not found: ${componentName}`);
}
if (typeof component[handlerName] !== 'function') {
throw new Error(`Target component method was not found: ${componentName}.${handlerName}`);
}
const HandlerClass = getEventHandlerClass();
if (!HandlerClass) {
throw new Error('Cocos EventHandler class is unavailable.');
}
const existing = Array.isArray(button.clickEvents) ? button.clickEvents : [];
const duplicate = existing.find((event) => (
event &&
event.target === target &&
event.component === componentName &&
event.handler === handlerName &&
String(event.customEventData || '') === String(options.customEventData || '')
));
if (duplicate && options.replace !== true) {
return {
bound: false,
duplicate: true,
node: getNodePath(node),
event: serializeEventHandler(duplicate),
clickEventCount: existing.length,
};
}
const event = new HandlerClass();
event.target = target;
event.component = componentName;
event.handler = handlerName;
event.customEventData = String(options.customEventData || '');
button.clickEvents = options.replace === true
? existing.filter((item) => item !== duplicate).concat(event)
: existing.concat(event);
return {
bound: true,
node: getNodePath(node),
uuid: node.uuid,
event: serializeEventHandler(event),
clickEventCount: button.clickEvents.length,
};
},
async invokeComponentMethod(options = {}) {
const node = findNode(options);
if (!node) {
+132
View File
@@ -84,6 +84,13 @@ const FORBIDDEN_NAMES = new Set([
'.DS_Store'
]);
const FORBIDDEN_CONTENT_PATTERNS = [
['npm token', /\bnpm_[A-Za-z0-9]{20,}\b/],
['GitHub token', /\bgh[pousr]_[A-Za-z0-9_]{30,}\b/],
['MCP token', /\bmcp_[A-Za-z0-9_-]{32,}\b/],
['private key', /-----BEGIN [A-Z ]*PRIVATE KEY-----/]
];
function main() {
const command = process.argv[2] || 'check';
const options = parseOptions(process.argv.slice(3));
@@ -101,6 +108,7 @@ function main() {
console.log(`- ${artifacts.zipName}`);
console.log('- release-manifest.json');
console.log('- SHA256SUMS.txt');
console.log('- RELEASE_NOTES.md');
console.log('- README.md');
return;
}
@@ -251,6 +259,7 @@ function packageRelease(context) {
const stagedFiles = collectFiles(stagingRoot)
.map((filePath) => path.relative(TEMP_DIR, filePath).split(path.sep).join('/'));
validateArchivePaths(stagedFiles);
validateArchiveContent(collectFiles(stagingRoot));
run('zip', ['-qr', zipPath, PACKAGE_DIR_NAME], { cwd: TEMP_DIR });
validateZipListing(zipPath);
@@ -270,9 +279,13 @@ function packageRelease(context) {
const readmePath = path.join(releaseDir, 'README.md');
fs.writeFileSync(readmePath, buildReleaseReadme(context, manifest));
const releaseNotesPath = path.join(releaseDir, 'RELEASE_NOTES.md');
fs.writeFileSync(releaseNotesPath, buildGitHubReleaseNotes(context, manifest));
const checksums = [
checksumLine(zipPath, zipName),
checksumLine(manifestPath, 'release-manifest.json'),
checksumLine(releaseNotesPath, 'RELEASE_NOTES.md'),
checksumLine(readmePath, 'README.md')
].join('');
fs.writeFileSync(path.join(releaseDir, 'SHA256SUMS.txt'), checksums);
@@ -362,6 +375,101 @@ shasum -a 256 -c SHA256SUMS.txt
`;
}
function buildGitHubReleaseNotes(context, manifest) {
const sections = parseChangelogSections(context.changelogNotes);
const rendered = [];
const used = new Set();
const order = [
['Added', 'Added'],
['Optimized', 'Optimized'],
['Changed', 'Changed'],
['Fixed', 'Fixed'],
['Security', 'Security'],
['Deprecated', 'Deprecated'],
['Removed', 'Removed']
];
for (const [sourceHeading, displayHeading] of order) {
const section = sections.find((item) => item.heading.toLowerCase() === sourceHeading.toLowerCase());
if (!section) {
continue;
}
used.add(section.heading);
rendered.push(`## ${displayHeading}`, '', ...section.lines, '');
}
for (const section of sections) {
if (used.has(section.heading)) {
continue;
}
rendered.push(`## ${section.heading}`, '', ...section.lines, '');
}
const zip = manifest.artifacts.extensionZip;
return [
`# Funplay MCP for Cocos ${context.tag}`,
'',
...rendered,
'## Release Assets',
'',
`- \`${zip.file}\` - Cocos Creator extension package.`,
'- `release-manifest.json` - Machine-readable release metadata.',
'- `SHA256SUMS.txt` - SHA-256 checksums for release artifacts.',
'',
'## Publish Channels',
'',
'- GitHub Release: Cocos Creator extension zip package.',
`- npm: \`${context.packageJson.name}@${context.version}\`.`,
`- MCP Registry: \`${context.packageJson.mcpName}\` version \`${context.version}\`.`,
'',
'## Verify',
'',
'```bash',
'shasum -a 256 -c SHA256SUMS.txt',
'```',
''
].join('\n');
}
function parseChangelogSections(notes) {
const sections = [];
let current = null;
for (const line of String(notes || '').split(/\r?\n/)) {
const heading = /^###\s+(.+?)\s*$/.exec(line);
if (heading) {
current = {
heading: heading[1].trim(),
lines: []
};
sections.push(current);
continue;
}
if (current) {
current.lines.push(line);
}
}
return sections
.map((section) => ({
heading: section.heading,
lines: trimBlankLines(section.lines)
}))
.filter((section) => section.lines.length > 0);
}
function trimBlankLines(lines) {
const trimmed = lines.slice();
while (trimmed.length && !trimmed[0].trim()) {
trimmed.shift();
}
while (trimmed.length && !trimmed[trimmed.length - 1].trim()) {
trimmed.pop();
}
return trimmed;
}
function validateArchivePaths(paths) {
const bad = [];
const prefix = `${PACKAGE_DIR_NAME}/`;
@@ -409,6 +517,30 @@ function validateZipListing(zipPath) {
validateArchivePaths(listing);
}
function validateArchiveContent(filePaths) {
const bad = [];
for (const filePath of filePaths) {
const stat = fs.statSync(filePath);
if (stat.size > 1024 * 1024) {
continue;
}
const buffer = fs.readFileSync(filePath);
if (buffer.includes(0)) {
continue;
}
const text = buffer.toString('utf8');
for (const [label, pattern] of FORBIDDEN_CONTENT_PATTERNS) {
if (pattern.test(text)) {
bad.push(`${path.relative(ROOT, filePath)} (${label})`);
}
}
}
if (bad.length > 0) {
throw new Error(`Release archive contains sensitive-looking content:\n- ${bad.join('\n- ')}`);
}
}
function isForbiddenTrackedPath(relative) {
const parts = relative.split('/');
return parts.some((part) => FORBIDDEN_TRACKED_SEGMENTS.has(part) || FORBIDDEN_NAMES.has(part));
+2 -2
View File
@@ -7,12 +7,12 @@
"url": "https://github.com/FunplayAI/funplay-cocos-mcp",
"source": "github"
},
"version": "0.3.3",
"version": "0.4.0",
"packages": [
{
"registryType": "npm",
"identifier": "funplay-cocos-mcp",
"version": "0.3.3",
"version": "0.4.0",
"transport": {
"type": "stdio"
},
+16
View File
@@ -0,0 +1,16 @@
'use strict';
const assert = require('node:assert/strict');
const test = require('node:test');
const { collectUuidReferences } = require('../lib/tools/assets-advanced');
test('collectUuidReferences finds structured and literal UUID references', () => {
const refs = collectUuidReferences(JSON.stringify({
__type__: 'cc.Prefab',
sprite: { __uuid__: '2d3KcYpS5HCKb6wU0v5c9x' },
nested: [{ assetUuid: '550e8400-e29b-41d4-a716-446655440000' }],
}));
assert.equal(refs.some((ref) => ref.uuid === '2d3KcYpS5HCKb6wU0v5c9x' && ref.source === 'structured'), true);
assert.equal(refs.some((ref) => ref.uuid === '550e8400-e29b-41d4-a716-446655440000'), true);
});
+89
View File
@@ -0,0 +1,89 @@
'use strict';
const assert = require('node:assert/strict');
const test = require('node:test');
const {
broadcastEditorMessage,
getEditorPreference,
setEditorPreference,
tryEditorRequests,
tryEditorRequestsStatus,
} = require('../lib/tools/cocos-project');
test('tryEditorRequests returns the first successful editor message candidate', async () => {
const calls = [];
global.Editor = {
Message: {
request: async (channel, method, payload) => {
calls.push({ channel, method, payload });
if (method === 'bad') {
throw new Error('nope');
}
return { ok: true };
},
},
};
try {
const result = await tryEditorRequests([
{ channel: 'scene', method: 'bad' },
{ channel: 'scene', method: 'save-scene', args: [{ force: true }] },
]);
assert.equal(result.ok, true);
assert.equal(result.method, 'save-scene');
assert.equal(calls.length, 2);
} finally {
delete global.Editor;
}
});
test('tryEditorRequestsStatus returns an unavailable payload instead of throwing', async () => {
global.Editor = {
Message: {
request: async () => {
throw new Error('missing');
},
},
};
try {
const result = await tryEditorRequestsStatus([{ channel: 'builder', method: 'query-build-status' }]);
assert.equal(result.ok, false);
assert.equal(result.available, false);
assert.equal(result.attempts.length, 1);
} finally {
delete global.Editor;
}
});
test('preference helpers and broadcast use available Editor APIs', () => {
const sent = [];
const store = new Map();
global.Editor = {
Message: {
send(channel, message, payload) {
sent.push({ channel, message, payload });
},
},
Profile: {
getProject(key) {
return store.get(key);
},
setProject(key, value) {
store.set(key, value);
},
},
};
try {
setEditorPreference('project', 'preview.port', 7456);
assert.equal(getEditorPreference('project', 'preview.port'), 7456);
const result = broadcastEditorMessage({ channel: 'scene', message: 'custom-event', payload: { ok: true } });
assert.equal(result.sent, true);
assert.deepEqual(sent[0], { channel: 'scene', message: 'custom-event', payload: { ok: true } });
} finally {
delete global.Editor;
}
});
+58
View File
@@ -0,0 +1,58 @@
'use strict';
const assert = require('node:assert/strict');
const path = require('node:path');
const test = require('node:test');
const {
assertJavascriptSafety,
inspectJavascriptSafety,
} = require('../lib/javascript-safety');
const PROJECT_PATH = path.resolve('/tmp/funplay-cocos-test-project');
test('JavaScript safety allows project-local write snippets by default', () => {
const result = inspectJavascriptSafety(
"fs.writeFileSync(path.join(context.projectPath, 'assets/generated.ts'), 'export {};');",
{ projectPath: PROJECT_PATH }
);
assert.equal(result.ok, true);
});
test('JavaScript safety blocks delete operations', () => {
assert.throws(
() => assertJavascriptSafety("fs.rmSync(path.join(context.projectPath, 'assets'), { recursive: true });", {
projectPath: PROJECT_PATH,
}),
/delete\/truncate/
);
});
test('JavaScript safety blocks traversal and home path literals', () => {
const result = inspectJavascriptSafety(
"fs.writeFileSync('../outside.txt', 'x'); fs.writeFileSync('~/secret.txt', 'x');",
{ projectPath: PROJECT_PATH }
);
assert.equal(result.ok, false);
assert.equal(result.violations.some((item) => item.includes('path traversal')), true);
assert.equal(result.violations.some((item) => item.includes('user-home')), true);
});
test('JavaScript safety blocks absolute paths outside the project', () => {
const result = inspectJavascriptSafety("fs.writeFileSync('/tmp/outside.txt', 'x');", {
projectPath: PROJECT_PATH,
});
assert.equal(result.ok, false);
assert.equal(result.violations.some((item) => item.includes('absolute path outside')), true);
});
test('JavaScript safety blocks child_process usage', () => {
assert.throws(
() => assertJavascriptSafety("const cp = require('child_process'); cp.execSync('rm -rf /tmp/x');", {
projectPath: PROJECT_PATH,
}),
/child_process/
);
});
+11
View File
@@ -6,6 +6,7 @@ const os = require('node:os');
const path = require('node:path');
const test = require('node:test');
const {
createCocosMcpProjectSkill,
createProjectSkill,
listProjectInstructions,
readProjectInstruction,
@@ -39,6 +40,16 @@ test('createProjectSkill writes a Codex project skill', () => {
assert.equal(listed.skills.some((skill) => skill.path === result.path), true);
});
test('createCocosMcpProjectSkill writes the recommended MCP workflow skill', () => {
const projectPath = fs.mkdtempSync(path.join(os.tmpdir(), 'funplay-cocos-default-skill-'));
const result = createCocosMcpProjectSkill(projectPath);
assert.equal(result.path, '.codex/skills/funplay-cocos-mcp-workflow/SKILL.md');
const content = readProjectInstruction(projectPath, result.path).content;
assert.match(content, /Funplay Cocos MCP Workflow/);
assert.match(content, /inspect_asset_dependencies/);
});
test('project instruction helpers reject traversal outside the project', () => {
const projectPath = fs.mkdtempSync(path.join(os.tmpdir(), 'funplay-cocos-instructions-safe-'));
assert.throws(
+67 -3
View File
@@ -9,7 +9,7 @@ const {
SUPPORTED_PROTOCOL_VERSIONS,
} = require('../lib/server');
function createServer(toolRegistry = {}, config = {}) {
function createServer(toolRegistry = {}, config = {}, options = {}) {
return new McpServer({
config: { host: '127.0.0.1', port: 8765, ...config },
toolRegistry: {
@@ -28,8 +28,10 @@ function createServer(toolRegistry = {}, config = {}) {
},
interactionLog: { add() {} },
runtimeLog: { add() {} },
serverName: 'test-server',
serverVersion: '0.0.0-test',
serverName: options.serverName || 'test-server',
serverVersion: options.serverVersion || '0.0.0-test',
projectName: options.projectName || 'test-project',
projectIdentity: options.projectIdentity || 'test-project-id',
});
}
@@ -104,6 +106,7 @@ test('initialize negotiates the current MCP protocol version by default', async
assert.equal(response.result.protocolVersion, MCP_PROTOCOL_VERSION);
assert.equal(response.result.serverInfo.name, 'test-server');
assert.equal(response.result.funplay.projectIdentity, 'test-project-id');
});
test('initialize can negotiate an older supported MCP protocol version', async () => {
@@ -286,6 +289,67 @@ test('HTTP GET /tools returns debug tool metadata and curl examples', async () =
}
});
test('HTTP GET /health returns project identity metadata', async () => {
const server = createServer({}, { port: 0 }, { projectIdentity: 'health-project-id' });
await server.start();
try {
const response = await httpGet(server.getPort(), '/health');
const payload = JSON.parse(response.body);
assert.equal(response.statusCode, 200);
assert.equal(payload.ok, true);
assert.equal(payload.projectName, 'test-project');
assert.equal(payload.projectIdentity, 'health-project-id');
} finally {
await server.stop();
}
});
test('start attaches to an existing same-project listener on the configured port', async () => {
const owner = createServer({}, { port: 0 }, { projectIdentity: 'same-project' });
await owner.start();
const attached = createServer({}, { port: owner.getPort() }, { projectIdentity: 'same-project' });
try {
await attached.start();
assert.equal(attached.isRunning(), true);
assert.equal(attached.getPort(), owner.getPort());
assert.equal(attached.getAttachInfo().projectIdentity, 'same-project');
await attached.stop();
assert.equal(attached.isRunning(), false);
const response = await httpGet(owner.getPort(), '/health');
assert.equal(response.statusCode, 200);
} finally {
if (attached.isRunning()) {
await attached.stop();
}
await owner.stop();
}
});
test('start falls back instead of attaching to a different project listener', async () => {
const owner = createServer({}, { port: 0 }, { projectIdentity: 'owner-project' });
await owner.start();
const contender = createServer({}, { port: owner.getPort() }, { projectIdentity: 'other-project' });
try {
await contender.start();
assert.equal(contender.isRunning(), true);
assert.notEqual(contender.getPort(), owner.getPort());
assert.equal(contender.getAttachInfo(), null);
assert.equal(contender.getPortFallbackInfo().requestedPort, owner.getPort());
} finally {
if (contender.isRunning()) {
await contender.stop();
}
await owner.stop();
}
});
test('HTTP initialize can return an optional session id when sessions are enabled', async () => {
const server = createServer({}, { port: 0, enableSessions: true });
await server.start();
+66
View File
@@ -0,0 +1,66 @@
'use strict';
const assert = require('node:assert/strict');
const test = require('node:test');
const {
applyToolProfile,
createToolProfileSnapshot,
deleteToolProfile,
exportToolProfiles,
importToolProfiles,
normalizeSavedToolProfiles,
upsertToolProfile,
} = require('../lib/tool-profiles');
test('tool profiles normalize, upsert, and dedupe by name', () => {
const profiles = normalizeSavedToolProfiles([
{ name: 'QA', toolProfile: 'custom', enabledToolCategories: 'assets\nlogs' },
{ name: 'qa', toolProfile: 'full', disabledTools: ['delete_asset'] },
{ name: '' },
]);
assert.equal(profiles.length, 1);
assert.equal(profiles[0].name, 'qa');
assert.equal(profiles[0].toolProfile, 'full');
const updated = upsertToolProfile(profiles, {
name: 'Prototype',
toolProfile: 'custom',
enabledToolCategories: ['ui'],
});
assert.equal(updated.length, 2);
assert.equal(updated.some((profile) => profile.name === 'Prototype'), true);
});
test('tool profiles snapshot and apply exposure config', () => {
const snapshot = createToolProfileSnapshot({
toolProfile: 'custom',
enabledToolCategories: ['assets'],
disabledToolCategories: ['input'],
enabledTools: ['write_file'],
disabledTools: ['delete_asset'],
}, 'Asset QA');
const applied = applyToolProfile({ port: 8765 }, snapshot);
assert.equal(applied.port, 8765);
assert.equal(applied.activeToolProfileName, 'Asset QA');
assert.deepEqual(applied.enabledToolCategories, ['assets']);
assert.deepEqual(applied.disabledTools, ['delete_asset']);
});
test('tool profiles import, export, and delete', () => {
const imported = importToolProfiles([], JSON.stringify({
version: 1,
profiles: [
{ name: 'Core QA', toolProfile: 'core' },
{ name: 'Debug', toolProfile: 'custom', enabledToolCategories: ['logs', 'diagnostics'] },
],
}));
assert.equal(imported.length, 2);
assert.deepEqual(exportToolProfiles(imported).profiles.map((profile) => profile.name), ['Core QA', 'Debug']);
const remaining = deleteToolProfile(imported, 'Debug');
assert.deepEqual(remaining.map((profile) => profile.name), ['Core QA']);
});
+39 -2
View File
@@ -21,11 +21,13 @@ function createRegistry(profile, projectPath = path.resolve('/tmp/funplay-cocos-
test('core profile exposes the documented focused tool set', () => {
const tools = createRegistry('core').listTools();
assert.equal(tools.length, 34);
assert.equal(tools.length, 37);
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_tool_catalog'), true);
assert.equal(tools.some((tool) => tool.name === 'validate_scene'), true);
assert.equal(tools.some((tool) => tool.name === 'inspect_asset_dependencies'), true);
assert.equal(tools.some((tool) => tool.name === 'get_build_status'), 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);
@@ -34,10 +36,14 @@ test('core profile exposes the documented focused tool set', () => {
test('full profile exposes all built-in tools', () => {
const tools = createRegistry('full').listTools();
assert.equal(tools.length, 89);
assert.equal(tools.length, 101);
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 === 'create_cocos_mcp_project_skill'), true);
assert.equal(tools.some((tool) => tool.name === 'bind_button_click_event'), true);
assert.equal(tools.some((tool) => tool.name === 'open_build_panel'), true);
assert.equal(tools.some((tool) => tool.name === 'broadcast_editor_message'), true);
assert.equal(tools.some((tool) => tool.name === 'get_editor_state'), true);
assert.equal(tools.some((tool) => tool.name === 'set_selection'), true);
});
@@ -98,3 +104,34 @@ test('callToolDetailed preserves screenshot image text while keeping structured
assert.equal(result.value.data.image, true);
assert.equal(result.value.data.mimeType, 'image/png');
});
test('execute_javascript safety checks block risky editor snippets by default', async () => {
const registry = createRegistry('core');
await assert.rejects(
() => registry.callToolDetailed('execute_javascript', {
context: 'editor',
code: "fs.rmSync(path.join(context.projectPath, 'assets'), { recursive: true });",
}),
/JavaScript safety checks blocked/
);
});
test('execute_javascript safety checks can be explicitly disabled per call', async () => {
let called = false;
const registry = createRegistry('core', path.resolve('/tmp/funplay-cocos-test-project'), {}, {
editorExecutor: async () => {
called = true;
return { ok: true };
},
});
const result = await registry.callToolDetailed('execute_javascript', {
context: 'editor',
code: "fs.rmSync(path.join(context.projectPath, 'assets'), { recursive: true });",
safety_checks: false,
});
assert.equal(called, true);
assert.equal(result.value.ok, true);
});