diff --git a/CHANGELOG.md b/CHANGELOG.md index 1596a22..7d7ae80 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,23 @@ This project follows a simple changelog format inspired by [Keep a Changelog](ht ## [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 ### Added diff --git a/README.md b/README.md index 89ba032..07b88a2 100644 --- a/README.md +++ b/README.md @@ -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. - 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. -- 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. - 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. - 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. @@ -218,7 +219,7 @@ Try a higher-level prompt in your AI client: ## 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 - **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 @@ -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 | | Primary execution tool | `execute_javascript` | `execute_code` | | Primary language | JavaScript in scene/editor contexts | C# in Unity editor/runtime contexts | -| Default profile | `core` with 28 tools | `core` focused tool profile | -| Full profile | 76 tools plus `custom` exposure | 79 tools | +| Default profile | `core` with 34 tools | `core` focused tool profile | +| Full profile | 89 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** — 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 - **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 -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 @@ -267,21 +268,22 @@ The default `core` set is intentionally small: `execute_javascript`, `execute_sc ## 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 | |----------|-------| | **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 & 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`, `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` | | **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` | +| **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` | | **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` | diff --git a/README_CN.md b/README_CN.md index ecbea9b..9724272 100644 --- a/README_CN.md +++ b/README_CN.md @@ -199,9 +199,10 @@ url = "http://127.0.0.1:8765/" - 这是一个 **仅限 Editor** 的扩展,用于自动化 Cocos Creator,不会给最终游戏包添加运行时依赖。 - MCP Server 默认监听 `http://127.0.0.1:8765/`。 - 如果配置端口被占用,服务会自动回退到下一个可用端口,面板与一键客户端配置会使用实际运行端口。 -- 默认 `core` profile 暴露 28 个高频工具;如果需要完整工具集,可在面板切到 `full`,暴露全部 76 个工具;也可以用 `custom` 按分类或工具名增删。 +- 默认 `core` profile 暴露 34 个高频工具;如果需要完整工具集,可在面板切到 `full`,暴露全部 89 个工具;也可以用 `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。 - 所有已暴露的 MCP 工具都会直接执行,Cocos 扩展里没有额外 approval 开关。 - 文件工具和 `cocos://asset/path/...` 资源默认只能访问当前 Cocos 项目根目录内的路径。 - 推荐工作流是优先使用 `execute_javascript`,再配合截图、诊断、资产、检查类工具。 @@ -218,7 +219,7 @@ url = "http://127.0.0.1:8765/" ## 核心特性 -- **76 个内置工具** — 覆盖场景层级、编辑器状态、选择工作流、资产、UI 创建、组件、文件、日志、脚本诊断、截图、运行态控制和输入模拟 +- **89 个内置工具** — 覆盖场景层级、编辑器状态、选择工作流、Prefab、资产、项目指令、UI 创建、组件、文件、日志、脚本诊断、截图、运行态控制和输入模拟 - **统一主工具** — `execute_javascript` 同时支持 `scene` 和 `editor` 两种上下文 - **Resources 与 Prompts** — 实时项目/日志资源,以及脚本修复、场景验证、可玩原型等可复用工作流 - **Cocos 图形面板** — `Funplay > MCP Server` 提供服务管理、更新检查、工具暴露和 MCP 客户端配置 @@ -235,20 +236,20 @@ Funplay MCP for Cocos 延续 Funplay MCP for Unity 的设计原则,并针对 C | 内置服务 | 内嵌 HTTP MCP Server | 内嵌 HTTP MCP Server | | 主执行工具 | `execute_javascript` | `execute_code` | | 主语言 | 场景/编辑器上下文中的 JavaScript | Unity 编辑器/运行态中的 C# | -| 默认工具集 | `core`,28 个工具 | 聚焦版 `core` 工具集 | -| 完整工具集 | 76 个工具,并支持 `custom` 暴露 | 79 个工具 | +| 默认工具集 | `core`,34 个工具 | 聚焦版 `core` 工具集 | +| 完整工具集 | 89 个工具,并支持 `custom` 暴露 | 79 个工具 | | 客户端配置 | 一键配置面板 | 一键配置窗口 | ## MCP 能力结构 当前包提供四层能力: -- **Tools** — `core` 下 28 个工具,`full` 下 76 个工具,并支持 `custom` include/exclude 规则 +- **Tools** — `core` 下 34 个工具,`full` 下 89 个工具,并支持 `custom` include/exclude 规则 - **Primary execution** — `execute_javascript` 用于场景/运行态和编辑器/browser 自动化 - **Prompts** — `fix_script_errors`、`create_playable_prototype`、`scene_validation`、`auto_wire_scene` - **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 @@ -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` | | **编辑器状态** | `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` | | **节点编辑** | `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` | | **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` | +| **诊断与日志** | `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` | | **交互** | `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` | diff --git a/lib/prefabs.js b/lib/prefabs.js new file mode 100644 index 0000000..a2dbc86 --- /dev/null +++ b/lib/prefabs.js @@ -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, +}; diff --git a/lib/project-instructions.js b/lib/project-instructions.js new file mode 100644 index 0000000..a20c282 --- /dev/null +++ b/lib/project-instructions.js @@ -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, +}; diff --git a/lib/server.js b/lib/server.js index fb8bddf..a95612a 100644 --- a/lib/server.js +++ b/lib/server.js @@ -570,10 +570,15 @@ class McpServer { } return this.createResult(request.id, result); } catch (error) { - return this.createResult(request.id, { + const result = { content: textContent(error.message), isError: true, - }); + }; + const structured = structuredContent(error.toolEnvelope); + if (structured) { + result.structuredContent = structured; + } + return this.createResult(request.id, result); } } diff --git a/lib/tool-registry.js b/lib/tool-registry.js index 83cdd1e..4aecb74 100644 --- a/lib/tool-registry.js +++ b/lib/tool-registry.js @@ -1,5 +1,6 @@ 'use strict'; +const crypto = require('crypto'); const fs = require('fs'); const path = require('path'); const { @@ -22,9 +23,24 @@ const { searchProjectLogs, } = require('./logs'); 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 { checkForUpdate } = require('./update-checker'); const { safeStringify } = require('./utils'); +const IMAGE_DATA_URI_PREFIX = 'data:image/png;base64,'; const TOOL_CATEGORY_RULES = [ ['updates', /update/], @@ -35,6 +51,7 @@ const TOOL_CATEGORY_RULES = [ ['files', /file|directory|exists|refresh_assets/], ['assets', /asset|scene$|scenes|open_scene|run_scene_asset/], ['prefabs', /prefab/], + ['instructions', /instruction|skill/], ['selection', /selection|select_/], ['components', /component/], ['ui', /canvas|label|button|sprite/], @@ -57,6 +74,34 @@ function createSchema(properties, required) { 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) { for (const [category, pattern] of TOOL_CATEGORY_RULES) { if (pattern.test(toolName)) { @@ -86,6 +131,26 @@ function toolCategory(tool) { 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) { const profile = config && config.toolProfile === 'full' ? 'full' @@ -112,6 +177,115 @@ function isToolExposed(config, tool) { 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) { if (!result) { return null; @@ -217,6 +391,17 @@ async function refreshAssets(projectPath, targetPath) { 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 }) { const tools = [ { @@ -351,6 +536,67 @@ function createToolRegistry({ getRuntimeContext, getStatus, interactionLog, runt inputSchema: createSchema({}, []), 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', profile: 'core', @@ -537,6 +783,166 @@ function createToolRegistry({ getRuntimeContext, getStatus, interactionLog, runt 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', profile: 'full', @@ -1242,6 +1648,7 @@ function createToolRegistry({ getRuntimeContext, getStatus, interactionLog, runt includeComponents: true, }).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 ? null : 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; 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, runtime, + performance, diagnostics, 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', profile: 'core', @@ -1580,6 +1995,8 @@ function createToolRegistry({ getRuntimeContext, getStatus, interactionLog, runt name: tool.name, description: tool.description, inputSchema: tool.inputSchema, + outputSchema: tool.outputSchema || createOutputSchema(tool.dataSchema), + annotations: inferToolAnnotations(tool), })); }, listToolCatalog() { @@ -1589,6 +2006,8 @@ function createToolRegistry({ getRuntimeContext, getStatus, interactionLog, runt description: tool.description, profile: tool.profile, category: toolCategory(tool), + annotations: inferToolAnnotations(tool), + outputSchema: tool.outputSchema || createOutputSchema(tool.dataSchema), enabled: isToolExposed(config || {}, tool), })); }, @@ -1604,14 +2023,21 @@ function createToolRegistry({ getRuntimeContext, getStatus, interactionLog, runt try { const result = await tool.handler(args || {}); - const output = toOutput(result); - interactionLog.add(name, 'success', output.slice(0, 500)); + const envelope = createResultEnvelope(tool, args || {}, result); + const output = typeof result === 'string' && result.startsWith(IMAGE_DATA_URI_PREFIX) + ? result + : toOutput(envelope); + interactionLog.add(name, 'success', envelope.summary.slice(0, 500)); return { - value: result, + value: envelope, text: output, }; } catch (error) { interactionLog.add(name, 'error', error.message); + error.toolEnvelope = createResultEnvelope(tool, args || {}, { message: error.message }, { + ok: false, + summary: error.message, + }); throw error; } }, diff --git a/package.json b/package.json index 5bda709..750b5d2 100644 --- a/package.json +++ b/package.json @@ -1,13 +1,13 @@ { "name": "funplay-cocos-mcp", "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.", "author": "Funplay", "license": "MIT", "main": "browser.js", "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" }, "panels": { diff --git a/scene.js b/scene.js index efaf521..022d161 100644 --- a/scene.js +++ b/scene.js @@ -308,6 +308,97 @@ function findComponentsByClass(componentClass) { 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() { 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() { if (typeof director.pause === 'function') { director.pause(); diff --git a/test/project-instructions.test.js b/test/project-instructions.test.js new file mode 100644 index 0000000..bee2ba1 --- /dev/null +++ b/test/project-instructions.test.js @@ -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/ + ); +}); diff --git a/test/tool-registry.test.js b/test/tool-registry.test.js index e82fa6d..ab98f52 100644 --- a/test/tool-registry.test.js +++ b/test/tool-registry.test.js @@ -5,7 +5,7 @@ const path = require('node:path'); const test = require('node:test'); 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({ getRuntimeContext: () => ({ config: { toolProfile: profile, ...configExtras }, @@ -13,31 +13,42 @@ function createRegistry(profile, projectPath = path.resolve('/tmp/funplay-cocos- version: '0.0.0-test', }), interactionLog: { add() {} }, - runtimeLog: { list: () => [], clear: () => 0 }, - sceneBridge: { call: async () => ({ ok: true }) }, - editorExecutor: async () => ({ ok: true }), + runtimeLog: { add() {}, list: () => [], clear: () => 0 }, + sceneBridge: overrides.sceneBridge || { call: async () => ({ ok: true }) }, + editorExecutor: overrides.editorExecutor || (async () => ({ ok: true })), }); } test('core profile exposes the documented focused tool set', () => { 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 === '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 === '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 === 'write_file'), false); }); test('full profile exposes all built-in tools', () => { 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 === '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 === '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', () => { const tools = createRegistry('custom', path.resolve('/tmp/funplay-cocos-test-project'), { 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 () => { const registry = createRegistry('core'); 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/); }); + +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'); +});