diff --git a/CHANGELOG.md b/CHANGELOG.md index 9be8827..3ac8f9f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,9 +6,42 @@ 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 diff --git a/README.md b/README.md index 04d6c2f..c99408a 100644 --- a/README.md +++ b/README.md @@ -65,16 +65,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 +237,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 +253,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 +275,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 +309,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 +368,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 +382,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 +400,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 ``` diff --git a/README_CN.md b/README_CN.md index fa97fb0..d1537b8 100644 --- a/README_CN.md +++ b/README_CN.md @@ -65,16 +65,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 +235,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 +253,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 +275,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 +309,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 +368,11 @@ Editor 上下文脚本可以访问 `Editor`、`fs`、`path`、`os`、`require` "enabledTools": [], "disabledTools": [], "enableSessions": false, + "executeJavascriptSafetyChecks": true, "autostart": true, - "maxInteractionLogEntries": 50 + "maxInteractionLogEntries": 50, + "activeToolProfileName": "", + "savedToolProfiles": [] } ``` @@ -376,7 +382,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 +400,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 ``` diff --git a/RELEASE_CHECKLIST.md b/RELEASE_CHECKLIST.md index 3a77c64..05f6d2c 100644 --- a/RELEASE_CHECKLIST.md +++ b/RELEASE_CHECKLIST.md @@ -30,6 +30,7 @@ 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, generated release notes, and release README diff --git a/browser.js b/browser.js index 99ce598..65ae41e 100644 --- a/browser.js +++ b/browser.js @@ -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) || diff --git a/docs/TOOLS.md b/docs/TOOLS.md index b157330..b448fcf 100644 --- a/docs/TOOLS.md +++ b/docs/TOOLS.md @@ -2,18 +2,18 @@ -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. | diff --git a/lib/config.js b/lib/config.js index b61ea57..f6c3689 100644 --- a/lib/config.js +++ b/lib/config.js @@ -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, diff --git a/lib/javascript-safety.js b/lib/javascript-safety.js new file mode 100644 index 0000000..ad0fe83 --- /dev/null +++ b/lib/javascript-safety.js @@ -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, +}; diff --git a/lib/project-instructions.js b/lib/project-instructions.js index a20c282..e2a051d 100644 --- a/lib/project-instructions.js +++ b/lib/project-instructions.js @@ -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, diff --git a/lib/server.js b/lib/server.js index e11e627..4a01df4 100644 --- a/lib/server.js +++ b/lib/server.js @@ -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: {}, diff --git a/lib/tool-profiles.js b/lib/tool-profiles.js new file mode 100644 index 0000000..0ca4911 --- /dev/null +++ b/lib/tool-profiles.js @@ -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, +}; diff --git a/lib/tool-registry.js b/lib/tool-registry.js index 5aa84f1..91911dc 100644 --- a/lib/tool-registry.js +++ b/lib/tool-registry.js @@ -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', diff --git a/lib/tools/assets-advanced.js b/lib/tools/assets-advanced.js new file mode 100644 index 0000000..7a8a59d --- /dev/null +++ b/lib/tools/assets-advanced.js @@ -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, +}; diff --git a/lib/tools/cocos-project.js b/lib/tools/cocos-project.js new file mode 100644 index 0000000..7f7b9ac --- /dev/null +++ b/lib/tools/cocos-project.js @@ -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, +}; diff --git a/lib/tools/scene-events.js b/lib/tools/scene-events.js new file mode 100644 index 0000000..c902e15 --- /dev/null +++ b/lib/tools/scene-events.js @@ -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, +}; diff --git a/package.json b/package.json index cb5033c..71324d0 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/panel/index.js b/panel/index.js index bfb806f..0b36fc3 100644 --- a/panel/index.js +++ b/panel/index.js @@ -51,6 +51,10 @@ module.exports = Editor.Panel.define({ MCP Sessions +

Changes auto-save. Port/profile changes restart the server when needed.

@@ -64,6 +68,19 @@ module.exports = Editor.Panel.define({ Full Custom +
+
+ + + Save Profile + Apply + Delete + Export + Import +
+ +
+
@@ -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(); diff --git a/scene.js b/scene.js index 022d161..5c7eab3 100644 --- a/scene.js +++ b/scene.js @@ -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) { diff --git a/scripts/release.js b/scripts/release.js index b572e83..71c8a1b 100644 --- a/scripts/release.js +++ b/scripts/release.js @@ -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)); @@ -252,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); @@ -509,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)); diff --git a/server.json b/server.json index e5277ac..c92f1da 100644 --- a/server.json +++ b/server.json @@ -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" }, diff --git a/test/assets-advanced.test.js b/test/assets-advanced.test.js new file mode 100644 index 0000000..d9229ee --- /dev/null +++ b/test/assets-advanced.test.js @@ -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); +}); diff --git a/test/cocos-project-tools.test.js b/test/cocos-project-tools.test.js new file mode 100644 index 0000000..ca3c8f7 --- /dev/null +++ b/test/cocos-project-tools.test.js @@ -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; + } +}); diff --git a/test/javascript-safety.test.js b/test/javascript-safety.test.js new file mode 100644 index 0000000..394d8d2 --- /dev/null +++ b/test/javascript-safety.test.js @@ -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/ + ); +}); diff --git a/test/project-instructions.test.js b/test/project-instructions.test.js index bee2ba1..f2b4d63 100644 --- a/test/project-instructions.test.js +++ b/test/project-instructions.test.js @@ -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( diff --git a/test/server.test.js b/test/server.test.js index 7143181..bc1c9bb 100644 --- a/test/server.test.js +++ b/test/server.test.js @@ -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(); diff --git a/test/tool-profiles.test.js b/test/tool-profiles.test.js new file mode 100644 index 0000000..aaef935 --- /dev/null +++ b/test/tool-profiles.test.js @@ -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']); +}); diff --git a/test/tool-registry.test.js b/test/tool-registry.test.js index ab98f52..280b694 100644 --- a/test/tool-registry.test.js +++ b/test/tool-registry.test.js @@ -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); +});