Release v0.3.0

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