chore: 纳入项目全部源码进根仓库统一管理

删除 YouleNexus 内嵌 .git,统一由根仓库唯一管理。提交:
- projects/ 旧 H5 模板 Game_Surface_3 与各子游戏源码
- cocoscreator_projects/YouleNexus 工程 + cocos-creator-mcp 扩展源码
- CLAUDE.md、.mcp.json 等配置
library/temp/node_modules/dist 等缓存由 .gitignore 排除。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-28 11:55:13 +08:00
co-authored by Claude Opus 4.8
parent f5673c33c8
commit 7ab748919a
7317 changed files with 2810014 additions and 0 deletions
+13
View File
@@ -0,0 +1,13 @@
{
"mcpServers": {
"cocos-creator-mcp": {
"command": "node",
"args": [
"G:/Works/YouleGamesCocosCreator/cocoscreator_projects/YouleNexus/extensions/cocos-creator-mcp/client/stdio-bridge.js"
],
"env": {
"COCOS_MCP_URL": "http://127.0.0.1:3000/mcp"
}
}
}
}
+64
View File
@@ -0,0 +1,64 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## 第一准则:服务器零改动
**任何前端开发都必须完全遵循前后端数据包的协议与数据结构,做到服务器零改动。** 这是本仓库不可逾越的最高准则,优先于一切其它考量:
- 协议信封、`route`/`rpc` 命名、字段名与类型、`roomtype` 配置数组、`deskinfo` 快照结构等,**必须与现有协议逐字节对齐**(依据 `docs/protocol/`),不得为前端方便而改变格式或新增/重命名字段。
- 遇到协议层有疑问时,**先读 `docs/protocol/` 对应章节核对,不要臆测**;宁可前端多做适配,也绝不要求服务器配合改动。
- 新的 Cocos 前端(`YouleNexus`)的目标,就是在不改服务器的前提下复刻该协议契约、实现联调兼容。
## 仓库总览
这是友乐(Youle)棋牌游戏平台的前端工作区,核心是「**一套前端模板 + 多个子游戏**」的架构,并正在迁移到 Cocos Creator。三个顶层目录:
- **`projects/`** —— 旧版 HTML5 前端。`projects/Game_Surface_3` 是**前端模板(平台外壳)**,其余目录(`doudizhu` / `erqiwang` / `niuniu` / `majiang_jx` / `guanpai-jx` / `pdk_card_client-jinxian` / `sangelaok` / `zpy` / `gamehall3` …)都是**基于该模板扩展出来的子游戏**,平台层几乎不动,只改子游戏层 + 美术数据。
- **`docs/protocol/`** —— 前端模板框架与服务器收发包的**协议规范**(权威文档,8 篇)。改动任何网络层代码前**必须先读对应章节**,服务器不可改,前端必须严格对齐协议。
- **`cocoscreator_projects/YouleNexus/`** —— 新的 Cocos Creator 3.8+ 前端工程,目标是按 `docs/protocol` 复刻协议契约,做到服务器零改动即可联调。
## 前端模板 Game_Surface_3 与子游戏(`projects/`)
**四层结构(自底向上):**
1. **引擎层** —— `js/gameabc.min.js`(gameabc 精灵引擎)+ Spine(`spine-canvas.js` / `SpineMgr.js`)+ Canvas 渲染;提供触屏/定时器/资源/WebSocket 底层回调。
2. **桥接层** —— `js/gamemain.js`:统一事件总线,把引擎回调分发到平台 UI、子游戏 `Game_Modify`、对战逻辑。
3. **平台层** —— `js/00_Surface/*`(12 个文件):登录、大厅、房间、聊天、社交、网络、资源、排行、任务等通用能力。**子游戏间几乎不变**。
4. **子游戏层** —— `js/01_SubGame/*`(3 个文件):只实现本游戏的对局逻辑与房间 UI。
**平台层关键文件(`js/00_Surface/`):** `02_Const.js`(`ConstVal`/`AppList`/路由表)、`04_Data.js`(`GameData` 全局态)、`09_Net.js`(`Net._SendData()` 收发分发)、`12_Logic.js`(`Logic` 连接/重连/分发编排)、`07_Desk.js`(`Desk` 房间状态机)、`06_Player.js`(`C_Player`/`Player` 数据结构)、`11_GameUI.js`(平台 UI)、`00_minhttp.js`(WebSocket 底层封装)。
**子游戏层(`js/01_SubGame/`,开发子游戏 = 改这 3 个文件 + 美术数据):**
- `00_SubGame_Config.js` —— `Game_Config`(房间数、气泡位置、分享、调试开关)
- `01_SubGame_modify.js` —— `Game_Modify` / `gameCombat`(对局实现 + 房间/胜负 UI)
- `02_SubGame_Input.js` —— 平台回调钩子(模板里是桩,子游戏覆写为真实逻辑)
- 另需补充:`gamemain.js`(扩展对局初始化)、专用算法模块(牌型/出牌等)、美术数据 `output/gameabc_data.min.js`。
**状态归属:** 平台层维护 `Desk`(房间/座位)、`C_Player`(自己)、`GameData`(连接/资产);子游戏维护对局态(手牌、出牌历史、轮次、分数)于自身命名空间,并需可序列化为 `deskinfo` 以支持断线重连。
## 网络协议(`docs/protocol/`,新旧前端共同契约)
- **传输层:** 单条长连 **WebSocket,纯 JSON 文本**(无二进制/protobuf)。先 HTTP GET 配置服务拿 `urlserver`,再 `ws://<ip:port>` 连接;支持 `connect_agentserver`/`connect_roomserver` 切换服务器。
- **消息信封:**
- 客户端→服务器(单层):`{ "app":"youle", "route":"agent|room|platform|<game_route>", "rpc":"<name>", "data":{…} }`
- 服务器→客户端(**双层包裹**):外层 `{ "data": <inner> }`;inner 可能是字符串需 `JSON.parse`。**收包必做过滤**:`@toconcon`(前 9 字符,握手包,忽略)、`data.com === "@serverheartbeat"`(服务器心跳,~20s 一次,**不回复**)。
- **路由分发:** `route ∈ {agent, room, platform}` → 平台层处理(`Net.<rpc>` → `Desk.*` / `C_Player.*`);`route` 为其它值 → 交子游戏 `Game_Modify._ReceiveData(msg)`,由子游戏 `switch(msg.rpc)` 派发对局包。平台请求通用身份字段:`agentid`/`gameid`/`playerid`(房间操作再加 `roomcode`)。
- **心跳/超时/重连:** 客户端 30s 收包超时(`ConstVal.Max.heartbeat = 30000`),超时报“网络慢”并重连;断线时 `Logic` 轮询候选服务器,重连后重发 `player_login`。
- **核心数据结构(详见 `04-数据结构.md`):** `C_Player`(本地玩家)、`Player(seat)`(座位玩家)、`Desk`(房间/牌桌)、`player_login` 响应(账号资产 + 可选房间恢复段:`roomcode`/`isbattle`/`deskinfo`…)。
- **两个不可妥协的约束:** ① `roomtype` 房间配置数组结构必须与目标子游戏**逐字节一致**(服务器据此解析规则);② `deskinfo` 对局快照须可序列化/反序列化以支持 `isbattle==1` 时 `Game_Modify.Reconnect(deskinfo)` 断线重连。
**协议文档索引:** `00-框架架构设计` · `01-传输层与架构` · `02-协议-agent路由` · `03-协议-room路由` · `04-数据结构` · `05-游戏内协议与桥接` · `06-子游戏开发模式与Cocos方案`。改协议相关代码前先读对应篇。
## Cocos Creator 新前端:优先使用 cocos-creator-mcp
新工程 `cocoscreator_projects/YouleNexus`。**任何涉及该工程的编辑器操作**(场景、节点、组件、prefab、资源、预览/截图/录制等),都应**优先使用 `cocos-creator-mcp` 这组 MCP 工具**(命名空间 `mcp__cocos-creator-mcp__*`,共 164 个)直接驱动编辑器,而不是手改 `.scene` / `.prefab` / `.meta` 等序列化文件——手改极易破坏 UUID 引用与序列化结构。
**前置条件(缺一不可,否则工具调用必然失败):**
1. Cocos Creator 3.8+ 已打开 `YouleNexus` 工程;
2. 扩展 `cocos-creator-mcp` 已启用,并在其面板里点了 **Start Server**(监听 `http://127.0.0.1:3000/mcp`)。
**开工前先探活**:先调用 `mcp__cocos-creator-mcp__server_get_status`(或 `curl http://127.0.0.1:3000/health`,期望 `{"status":"ok","tools":164}`)。**连不上时,提示用户在编辑器里启动服务,不要盲目重试**——MCP 桥(`extensions/cocos-creator-mcp/client/stdio-bridge.js`)只转发,无法替你启动编辑器服务。
**工具分类(按前缀):** `scene_*`(场景生命周期/层级/查询/撤销)、`node_*`(增删改/变换/树)、`component_*`、`prefab_*`、`asset_*`、`project_*`、`builder_*`(预览/构建)、`debug_*`(截图、录制、game command、控制台日志)、`view_*`(gizmo/相机/网格)、`refimage_*`、`preferences_*`、`server_*`。
> MCP 的项目级安装方式与排障细节(扩展位置、`package.json` name 必须为 `cocos-creator-mcp`、镜像 404 等坑)记录在项目记忆 `cocos-creator-mcp-setup`。
@@ -0,0 +1,5 @@
{
"image": {
"type": "sprite-frame"
}
}
@@ -0,0 +1,24 @@
#///////////////////////////
# Cocos Creator 3D Project
#///////////////////////////
library/
temp/
local/
build/
profiles/
native
#//////////////////////////
# NPM
#//////////////////////////
node_modules/
#//////////////////////////
# VSCode
#//////////////////////////
.vscode/
#//////////////////////////
# WebStorm
#//////////////////////////
.idea/
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Yusuke Harada
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
@@ -0,0 +1,539 @@
# Cocos Creator MCP
[MCP (Model Context Protocol)](https://modelcontextprotocol.io/) server extension for Cocos Creator 3.8+.
AI assistants like Claude can control Cocos Creator editor through this extension — creating nodes, editing scenes, managing prefabs, building projects, and more.
## Features
- **164 Tools** across 13 categories — comprehensive editor automation
- **Streamable HTTP (SSE)** — Native support for MCP's Streamable HTTP transport
- **JSON-RPC 2.0** — Standard MCP protocol compliance
- **Prefab Property Persistence** — Component properties are correctly preserved when saving prefabs
- **Preview in Editor** — Start editor preview programmatically (no manual button click needed)
- **Screenshot Capture** — Capture editor window and game preview screenshots (WebP / PNG)
- **Video Recording** — Record game preview canvas to video (MP4 / WebM) via Preview Recorder panel
- **Game Command Control** — Send commands to running game preview (screenshot, click, navigate, state, inspect)
- **Client Scripts** — Drop-in TypeScript files for game preview integration (`client/`)
- **Auto Start** — Server starts automatically when the extension loads
- **Tool Call Logging** — All tool invocations logged with timing for debugging
- **UUID Validation** — Input validation helpers for better error messages
- **i18n** — English, Japanese, Chinese
- **Regression Tests** — 200+ assertions covering core tool flows
## Quick Start
### 1. Install
Copy or symlink this extension into your Cocos Creator project's `extensions/` directory:
```bash
# Windows (Junction — no admin required)
mklink /J "your-project\extensions\cocos-creator-mcp" "path\to\cocos-creator-mcp"
# macOS / Linux
ln -s /path/to/cocos-creator-mcp your-project/extensions/cocos-creator-mcp
```
### 2. Build
```bash
cd cocos-creator-mcp
npm install
npm run build
```
### 3. Enable in Cocos Creator
1. Open your project in Cocos Creator
2. Go to **Extension > Extension Manager**
3. Enable **Cocos Creator MCP**
4. Open the panel: **Extension > Cocos Creator MCP > Open Panel**
5. Click **Start Server** (or set `autoStart: true` in config)
### 4. Connect from Claude Code
Pick one of the two transports below.
#### Option A — stdio bridge (recommended for Claude Code VSCode extension)
The Claude Code VSCode extension currently has a bug where it unconditionally
tries OAuth Dynamic Client Registration for HTTP-type MCP servers and fails
with `SDK auth failed` (see upstream issues
[#26917](https://github.com/anthropics/claude-code/issues/26917),
[#38102](https://github.com/anthropics/claude-code/issues/38102),
[#29697](https://github.com/anthropics/claude-code/issues/29697)).
To avoid it entirely, use the bundled stdio bridge. It speaks JSON-RPC on
stdin/stdout and forwards to the HTTP server internally.
```json
{
"mcpServers": {
"cocos-creator-mcp": {
"command": "node",
"args": [
"<ABSOLUTE_PATH_TO>/cocos-creator-mcp/client/stdio-bridge.js"
]
}
}
}
```
Optional env var: `COCOS_MCP_URL` (default `http://127.0.0.1:3000/mcp`).
#### Option B — direct HTTP
Works with Claude Code CLI, Cursor, Cline, and other clients that don't force
OAuth on HTTP MCP. The server ships minimal dummy OAuth endpoints
(`/.well-known/oauth-*`, `/oauth/register|authorize|token`) so OAuth-requiring
clients can still complete a pro-forma flow on localhost.
```json
{
"mcpServers": {
"cocos-creator-mcp": {
"type": "http",
"url": "http://127.0.0.1:3000/mcp"
}
}
}
```
> The dummy OAuth endpoints will be removed once upstream issues
> ([#26917](https://github.com/anthropics/claude-code/issues/26917),
> [#38102](https://github.com/anthropics/claude-code/issues/38102))
> are resolved or real authentication is introduced.
### 5. Verify
```bash
curl http://127.0.0.1:3000/health
# {"status":"ok","tools":164}
```
## Available Tools (164)
<details>
<summary><strong>Scene (6)</strong> — Scene lifecycle and hierarchy</summary>
| Tool | Description |
|------|-------------|
| `scene_get_hierarchy` | Get the node tree (with optional component info) |
| `scene_open` | Open a scene by UUID or db:// path |
| `scene_save` | Save the current scene |
| `scene_get_list` | List all .scene files |
| `scene_close` | Close the current scene |
| `scene_get_current` | Get name and UUID of the current scene |
</details>
<details>
<summary><strong>Scene Advanced (30)</strong> — Undo, clipboard, queries, property manipulation</summary>
| Tool | Description |
|------|-------------|
| `scene_execute_script` | Execute a custom scene script method |
| `scene_snapshot` | Take a snapshot for undo |
| `scene_snapshot_abort` | Abort the current undo snapshot |
| `scene_begin_undo` | Begin recording undo operations |
| `scene_end_undo` | End undo recording |
| `scene_cancel_undo` | Cancel undo recording |
| `scene_query_dirty` | Check if scene has unsaved changes |
| `scene_query_ready` | Check if scene is fully loaded |
| `scene_query_classes` | List all available component classes |
| `scene_query_components` | Query components for a node |
| `scene_query_component_has_script` | Check if a component has a script file |
| `scene_query_node_tree` | Get raw node tree from editor |
| `scene_query_node` | Get full property dump of a node |
| `scene_query_component` | Get full property dump of a component |
| `scene_query_nodes_by_asset` | Find nodes referencing an asset |
| `scene_query_scene_bounds` | Get scene bounding rect |
| `scene_soft_reload` | Soft reload scene |
| `scene_reset_node_transform` | Reset transform to default |
| `scene_reset_property` | Reset a specific property to default |
| `scene_reset_component` | Reset a component to defaults |
| `scene_copy_node` | Copy node to clipboard |
| `scene_paste_node` | Paste node from clipboard |
| `scene_cut_node` | Cut node to clipboard |
| `scene_create` | Create a new empty scene |
| `scene_save_as` | Save scene to a new file |
| `scene_set_parent` | Reparent node(s) with official API |
| `scene_restore_prefab` | Restore prefab node to original state |
| `scene_execute_component_method` | Call a method on a component |
| `scene_move_array_element` | Reorder array property element |
| `scene_remove_array_element` | Remove array property element |
</details>
<details>
<summary><strong>Scene View (19)</strong> — Gizmo, camera, grid, viewport</summary>
| Tool | Description |
|------|-------------|
| `view_change_gizmo_tool` | Switch gizmo tool (move/rotate/scale/rect) |
| `view_query_gizmo_tool` | Get current gizmo tool |
| `view_change_gizmo_pivot` | Change pivot mode (center/pivot) |
| `view_query_gizmo_pivot` | Get current pivot mode |
| `view_change_gizmo_coordinate` | Change coordinate system (local/global) |
| `view_query_gizmo_coordinate` | Get current coordinate system |
| `view_change_mode_2d_3d` | Switch 2D/3D view |
| `view_query_mode_2d_3d` | Get current view mode |
| `view_set_grid_visible` | Show/hide grid |
| `view_query_grid_visible` | Check grid visibility |
| `view_set_icon_gizmo_3d` | Toggle 3D icon gizmos |
| `view_query_icon_gizmo_3d` | Check 3D icon gizmo state |
| `view_set_icon_gizmo_size` | Set icon gizmo size |
| `view_query_icon_gizmo_size` | Get icon gizmo size |
| `view_focus_on_node` | Focus camera on node(s) |
| `view_align_with_view` | Align node with camera view |
| `view_align_view_with_node` | Align camera with node |
| `view_get_status` | Get all view settings at once |
| `view_reset` | Reset scene view to default |
</details>
<details>
<summary><strong>Node (14)</strong> — Create, edit, move, delete nodes</summary>
| Tool | Description |
|------|-------------|
| `node_create` | Create node (with optional components) |
| `node_get_info` | Get node details (position, scale, components) |
| `node_find_by_name` | Find nodes by name |
| `node_set_property` | Set node property |
| `node_set_transform` | Set position/rotation/scale at once |
| `node_set_active` | Set node visibility |
| `node_set_layer` | Set node layer |
| `node_delete` | Delete node |
| `node_move` | Move node to new parent |
| `node_duplicate` | Duplicate node |
| `node_get_all` | List all nodes |
| `node_detect_type` | Detect node type (2D/3D/Node) |
| `node_create_tree` | Create a node hierarchy in one call (v1.6) |
| `node_set_layout` | Set UITransform + Widget + color/opacity at once (v1.13) |
</details>
<details>
<summary><strong>Component (8)</strong> — Add, remove, configure components</summary>
| Tool | Description |
|------|-------------|
| `component_add` | Add component (e.g. `cc.Label`, `cc.Sprite`) |
| `component_remove` | Remove component |
| `component_get_components` | List components on node |
| `component_set_property` | Set component property (Label.string, fontSize, etc.) |
| `component_get_info` | Get full component dump by UUID |
| `component_get_available` | List all available component classes |
| `component_auto_bind` | Auto-match `@property` fields to nodes by name (v1.12) |
| `component_query_enum` | Query enum values of a component property (v1.6) |
</details>
<details>
<summary><strong>Prefab (12)</strong> — Prefab lifecycle and validation</summary>
| Tool | Description |
|------|-------------|
| `prefab_list` | List all prefabs |
| `prefab_create` | Create prefab from node (properties preserved) |
| `prefab_instantiate` | Instantiate prefab into scene |
| `prefab_get_info` | Get prefab asset info |
| `prefab_update` | Apply prefab changes |
| `prefab_revert` | Revert prefab instance to original |
| `prefab_duplicate` | Copy prefab to new path |
| `prefab_validate` | Validate prefab for broken references |
| `prefab_open` | Open prefab for editing (v1.5) |
| `prefab_close` | Close prefab editing mode and return to scene (v1.5) |
| `prefab_create_and_replace` | Create prefab and replace instance in one call (v1.5) |
| `prefab_create_from_spec` | Create node tree + auto-bind + prefab_create in one call (v1.12) |
</details>
<details>
<summary><strong>Asset (18)</strong> — CRUD, queries, metadata, dependencies</summary>
| Tool | Description |
|------|-------------|
| `asset_create` | Create new asset |
| `asset_delete` | Delete asset |
| `asset_move` | Move/rename asset |
| `asset_copy` | Copy asset |
| `asset_save` | Save asset |
| `asset_reimport` | Re-import asset |
| `asset_import` | Import external file into project |
| `asset_query_path` | Get file path for UUID |
| `asset_query_uuid` | Get UUID for path |
| `asset_query_url` | Get URL for UUID |
| `asset_get_details` | Get asset metadata |
| `asset_get_dependencies` | Get asset dependencies |
| `asset_open_external` | Open in external editor |
| `asset_save_meta` | Save asset meta/importer settings |
| `asset_generate_available_url` | Generate non-conflicting asset path |
| `asset_query_ready` | Check if asset DB is ready |
| `asset_query_users` | Find assets that reference this asset |
| `asset_query_missing` | Check for missing references |
</details>
<details>
<summary><strong>Project (8)</strong> — Project info, settings, engine</summary>
| Tool | Description |
|------|-------------|
| `project_get_info` | Get project name and path |
| `project_refresh_assets` | Refresh asset database |
| `project_get_asset_info` | Get asset info by UUID |
| `project_find_asset` | Find assets by glob pattern |
| `project_get_settings` | Get project settings |
| `project_set_settings` | Set a project setting |
| `project_get_engine_info` | Get engine version and paths |
| `project_query_scripts` | Query all script plugins |
</details>
<details>
<summary><strong>Preferences (4)</strong> — Editor preferences</summary>
| Tool | Description |
|------|-------------|
| `preferences_get` | Get preference value |
| `preferences_set` | Set preference value |
| `preferences_get_all` | Get all preferences for a protocol |
| `preferences_reset` | Reset preference to default |
</details>
<details>
<summary><strong>Debug (22)</strong> — Editor info, logs, preview, screenshots, recording, game control</summary>
| Tool | Description |
|------|-------------|
| `debug_get_editor_info` | Get editor version and environment |
| `debug_list_messages` | List available Editor messages |
| `debug_execute_script` | Execute scene script method |
| `debug_get_console_logs` | Get console log entries (scene + game preview) |
| `debug_clear_console` | Clear editor console and log buffers |
| `debug_preview` | Start Preview in Editor (play button) |
| `debug_clear_code_cache` | Clear code cache (Developer > Cache) |
| `debug_screenshot` | Capture editor window screenshot |
| `debug_game_command` | Send command to game preview (screenshot/click/navigate/state/inspect) |
| `debug_batch_screenshot` | Navigate to multiple pages and screenshot each |
| `debug_record_start` | Start recording game preview canvas (webm/mp4) |
| `debug_record_stop` | Stop recording and save video file |
| `debug_reload_extension` | Reload this MCP extension (after build) |
| `debug_list_extensions` | List installed extensions |
| `debug_get_extension_info` | Get extension details |
| `debug_get_project_logs` | Read project log entries |
| `debug_search_project_logs` | Search patterns in project logs |
| `debug_get_log_file_info` | Get log file metadata |
| `debug_validate_scene` | Validate scene for common issues |
| `debug_query_devices` | List connected devices |
| `debug_open_url` | Open URL in system browser |
| `debug_wait_compile` | Wait for TypeScript compile to finish (v1.12) |
</details>
<details>
<summary><strong>Server (7)</strong> — Editor server and network</summary>
| Tool | Description |
|------|-------------|
| `server_query_ip_list` | Get editor server IPs |
| `server_query_port` | Get editor server port |
| `server_get_status` | Get full server status |
| `server_check_connectivity` | Check if editor server is reachable |
| `server_get_network_interfaces` | Get network interface details |
| `server_get_build_hash` | Get build hash of MCP dist files (v1.6) |
| `server_check_code_sync` | Check if runtime matches dist hash (v1.6) |
</details>
<details>
<summary><strong>Builder (5)</strong> — Build and preview</summary>
| Tool | Description |
|------|-------------|
| `builder_open_panel` | Open Build panel |
| `builder_get_settings` | Get build configuration |
| `builder_query_tasks` | Query active build tasks |
| `builder_run_preview` | Start preview server |
| `builder_stop_preview` | Stop preview server |
</details>
<details>
<summary><strong>Reference Image (11)</strong> — Scene overlay images</summary>
| Tool | Description |
|------|-------------|
| `refimage_add` | Add a reference image |
| `refimage_remove` | Remove a reference image |
| `refimage_list` | List all reference images |
| `refimage_clear_all` | Remove all reference images |
| `refimage_switch` | Switch active reference image |
| `refimage_set_position` | Set image position |
| `refimage_set_scale` | Set image scale |
| `refimage_set_opacity` | Set image opacity |
| `refimage_query_config` | Get reference image config |
| `refimage_query_current` | Get current active image info |
| `refimage_refresh` | Refresh image display |
</details>
## Client Scripts
The `client/` directory contains TypeScript files for runtime communication between the game preview and the MCP server. Since the extension is installed in `extensions/`, these files can be imported directly — no copying needed.
### McpConsoleCapture
Captures `console.log/warn/error` from the game preview and sends them to the MCP server.
```typescript
// Import from extensions/ (adjust relative path as needed)
import { initMcpConsoleCapture } from "../../extensions/cocos-creator-mcp/client/McpConsoleCapture";
initMcpConsoleCapture();
```
### McpDebugClient
Enables AI-driven game control: screenshots, node clicking, and custom commands.
```typescript
import { initMcpDebugClient } from "../../extensions/cocos-creator-mcp/client/McpDebugClient";
initMcpDebugClient({
customCommands: {
// Add project-specific commands
state: () => ({ success: true, data: { dump: MyDb.dump() } }),
navigate: async (args) => {
await MyRouter.goTo(args.page);
return { success: true };
},
},
});
```
**Built-in commands** (no setup needed):
- `screenshot` — Capture game screen via RenderTexture
- `click` — Click a node by name
**Custom commands** (project-specific):
- Register any handler via `customCommands` option
- Called via `debug_game_command` MCP tool
Both scripts silently ignore when the MCP server is not running, so they are safe to leave in development builds.
## Console Log Capture (Details)
`debug_get_console_logs` captures logs from two sources:
### Scene Process Logs (automatic)
Console output from scene scripts (`console.log/warn/error` in the scene renderer process) is automatically captured. No setup required.
### Game Preview Logs (opt-in)
Game code runs in a browser during preview, which is a separate process. To capture game preview logs, your game code needs to send logs to the MCP server's `/log` endpoint.
**Setup:**
Add a console capture script to your game project:
```typescript
const MCP_LOG_URL = "http://127.0.0.1:3000/log";
const FLUSH_INTERVAL = 500;
let buffer: Array<{ timestamp: string; level: string; message: string }> = [];
function hook(level: string, original: (...args: any[]) => void) {
return function (...args: any[]) {
original.apply(console, args);
buffer.push({
timestamp: new Date().toISOString(),
level,
message: args.map(a => typeof a === "string" ? a : JSON.stringify(a)).join(" "),
});
};
}
console.log = hook("log", console.log);
console.warn = hook("warn", console.warn);
console.error = hook("error", console.error);
setInterval(() => {
if (buffer.length === 0) return;
const entries = buffer.splice(0, 50);
fetch(MCP_LOG_URL, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(entries),
}).catch(() => {}); // silently ignore if MCP server is not running
}, FLUSH_INTERVAL);
```
**`POST /log` format:**
```json
[
{ "timestamp": "2026-03-26T12:00:00.000Z", "level": "log", "message": "Hello" },
{ "timestamp": "2026-03-26T12:00:01.000Z", "level": "error", "message": "Something failed" }
]
```
Both scene and game logs are merged chronologically when retrieved via `debug_get_console_logs`. Each log entry includes a `source` field (`"scene"` or `"game"`) to distinguish the origin.
## Configuration
Settings are stored in `{project}/settings/cocos-creator-mcp.json`:
```json
{
"port": 3000,
"autoStart": true
}
```
| Option | Default | Description |
|--------|---------|-------------|
| `port` | `3000` | HTTP server port |
| `autoStart` | `false` | Start server automatically when extension loads |
## Testing
```bash
node test/regression.mjs # default port 3000
node test/regression.mjs 3001 # custom port
```
## Version History
- **v0.1** — MCP server + scene/node tools (13 tools)
- **v0.5** — Component, prefab, project, debug tools (27 tools)
- **v1.0** — Full tool coverage (145 tools, 13 categories, 224 test assertions)
- **v1.1** — Console log capture (scene process auto-capture + game preview via `/log` endpoint)
- **v1.2** — AI autonomous development: Preview in Editor, screenshot capture, game command control, code cache clear, scene save fix. Client scripts for game preview integration (`client/`)
- **v1.3** — `scene:set-property` for prefab save support, prefab_create overwrite guard, param alias (`component` → `componentType`)
- **v1.5** — `prefab_create_and_replace`, batch `set_property`, `prefab_open`
- **v1.6** — `debug_batch_screenshot`, widget support in `create_tree`, `component_query_enum`, `server_check_code_sync`
- **v1.8.0** — Preview Recorder panel: `debug_record_start` / `debug_record_stop` (MediaRecorder via canvas.captureStream, MP4/WebM, quality presets)
- **v1.8.1** — Fix: `component_set_property` cc.Asset references (cc.Font etc.) falling back to cc.Node when type is unspecified
- **v1.8.2** — Preview Recorder: screenshot button (webp/png toggle, max width), section-based UI layout
- **v1.9.0** — Preview Recorder auto-archive of old recordings + preflight "preview not running" check
- **v1.10.0** — `scene_create` asset-db fallback, stringified args preventive validation, test coverage expansion
- **v1.11.0** — HTTP MCP OAuth workaround (stdio bridge + dummy OAuth endpoints for Claude Code VSCode upstream bug) + dialog prevention for scene switching tools (`force` param, `ensureSceneSafeToSwitch`, `safeSaveScene`) + regression tests for both
- **v1.12.0** — Prefab authoring efficiency: `component_auto_bind` (auto-match `@property` fields to node names), `debug_wait_compile` (wait for TS compile to finish), `prefab_create_from_spec` (create node tree + auto-bind + prefab_create in one call)
- **v1.13.0** — `nodeName` parameter on component/get_components/auto_bind (no UUID required), `screenshot` auto-return option on `component_set_property` / `node_set_layout`, `node_set_layout` unified tool (UITransform + Widget + color/opacity in one call), dialog auto-response for untitled+dirty scenes, shared screenshot / node-resolve utilities
- **v1.14.0** — Widget `_alignFlags` auto-recalc bug fix: `setProperty` / `setProperties` / `node_set_layout` now re-query `isAlign*` values from scene and rebuild `_alignFlags` bitmask after isAlign updates (Editor bug where bitmask was not updated automatically, causing prefabs to save with `_alignFlags: 45` stuck state). Also `node_create` component addition now waits for editor reflection (`waitForComponent`) to fix flaky tests
## Development
```bash
npm run watch # Watch mode
npm run build # One-time build
```
After building, reload the extension in Cocos Creator:
- **Extension Manager** — disable then re-enable
- **Developer > Reload** — reloads main process
- **Full restart** — required for scene script or new category changes
## Requirements
- Cocos Creator 3.8+
- Node.js 18+
## Known Limitations
- **`scene_create`**: Does not work on Cocos Creator 3.8.x because the underlying `scene:new-scene` Editor message is not exposed on that version. As a workaround, create the `.scene` JSON file directly under `db://assets/` and call `project_refresh_assets` so the editor picks it up. See [#13](https://github.com/harady/cocos-creator-mcp/issues/13) for details.
## License
MIT
File diff suppressed because one or more lines are too long
@@ -0,0 +1,91 @@
/**
* McpConsoleCapture — ゲームプレビュー時のconsole.log/warn/errorをMCPサーバーに送信する。
*
* MCPサーバーの POST /log エンドポイントにバッチ送信する。
* MCPサーバー未起動時は黙って無視(本番影響なし)。
*
* Usage:
* import { initMcpConsoleCapture } from "./McpConsoleCapture";
* initMcpConsoleCapture(); // or initMcpConsoleCapture({ mcpBaseUrl: "http://127.0.0.1:3001" });
*/
export interface McpConsoleCaptureConfig {
/** MCPサーバーのベースURL (default: "http://127.0.0.1:3000") */
mcpBaseUrl?: string;
/** バッチ送信間隔ms (default: 500) */
flushInterval?: number;
/** 1回の送信最大件数 (default: 50) */
maxBatchSize?: number;
}
interface LogEntry {
timestamp: string;
level: "log" | "warn" | "error";
message: string;
}
let _buffer: LogEntry[] = [];
let _timer: any = null;
let _initialized = false;
let _config: Required<McpConsoleCaptureConfig>;
function formatArgs(args: any[]): string {
return args.map(a => {
if (typeof a === "string") return a;
if (a instanceof Error) return `${a.message}\n${a.stack || ""}`;
try { return JSON.stringify(a); } catch { return String(a); }
}).join(" ");
}
function flush(): void {
if (_buffer.length === 0) return;
const entries = _buffer.splice(0, _config.maxBatchSize);
fetch(`${_config.mcpBaseUrl}/log`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(entries),
}).catch(() => {});
}
/** ゲーム起動時に1回呼ぶ。console.log/warn/errorをフックしてMCPに送信開始 */
export function initMcpConsoleCapture(config?: McpConsoleCaptureConfig): void {
if (_initialized) return;
_initialized = true;
_config = {
mcpBaseUrl: config?.mcpBaseUrl ?? "http://127.0.0.1:3000",
flushInterval: config?.flushInterval ?? 500,
maxBatchSize: config?.maxBatchSize ?? 50,
};
const originalLog = console.log;
const originalWarn = console.warn;
const originalError = console.error;
function hook(level: LogEntry["level"], original: (...args: any[]) => void) {
return function (...args: any[]) {
original.apply(console, args);
_buffer.push({
timestamp: new Date().toISOString(),
level,
message: formatArgs(args),
});
};
}
console.log = hook("log", originalLog);
console.warn = hook("warn", originalWarn);
console.error = hook("error", originalError);
_timer = setInterval(flush, _config.flushInterval);
console.log("[McpConsoleCapture] initialized");
}
/** 停止 */
export function stopMcpConsoleCapture(): void {
if (_timer) {
clearInterval(_timer);
_timer = null;
}
flush();
_initialized = false;
}
File diff suppressed because one or more lines are too long
@@ -0,0 +1,315 @@
/**
* McpDebugClient — MCPサーバーとCocosCreatorプレビューの双方向通信
*
* MCPサーバーのコマンドキューをポーリングし、コマンドを実行して結果を返す。
* MCPサーバー未起動時は黙って無視(本番影響なし)。
*
* ビルトインコマンド:
* - screenshot: RenderTexture経由でゲーム画面キャプチャ
* - click: ノード名指定でクリックイベント発火
*
* カスタムコマンドを追加可能(プロジェクト固有の機能):
* initMcpDebugClient({
* customCommands: {
* state: () => ({ success: true, data: myDb.dump() }),
* navigate: async (args) => { await myRouter.go(args.page); return { success: true }; },
* },
* });
*/
import { director, Node, Button, Camera, RenderTexture, gfx } from "cc";
export interface McpDebugClientConfig {
/** MCPサーバーのベースURL (default: "http://127.0.0.1:3000") */
mcpBaseUrl?: string;
/** ポーリング間隔ms (default: 500) */
pollInterval?: number;
/** プロジェクト固有のコマンドハンドラー */
customCommands?: Record<string, (args: any) => any | Promise<any>>;
}
interface McpCommand {
id: string;
type: string;
args?: any;
}
let _polling = false;
let _timer: any = null;
let _config: Required<Pick<McpDebugClientConfig, "mcpBaseUrl" | "pollInterval">> & { customCommands: Record<string, (args: any) => any | Promise<any>> };
async function pollCommand(): Promise<void> {
if (_polling) return;
_polling = true;
try {
const res = await fetch(`${_config.mcpBaseUrl}/game/command`);
if (!res.ok) return;
const cmd: McpCommand | null = await res.json();
if (!cmd) return;
console.log(`[McpDebugClient] command: ${cmd.type}`, cmd.args || "");
const result = await executeCommand(cmd);
await fetch(`${_config.mcpBaseUrl}/game/result`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(result),
}).catch(() => {});
} catch {
// MCP server not running
} finally {
_polling = false;
}
}
async function executeCommand(cmd: McpCommand): Promise<any> {
try {
// ビルトインコマンド
switch (cmd.type) {
case "screenshot":
return { id: cmd.id, ...takeScreenshot() };
case "click":
return { id: cmd.id, ...clickNode(cmd.args?.name) };
case "record_start":
return { id: cmd.id, ...startRecording(cmd.args) };
case "record_stop":
return { id: cmd.id, ...(await stopRecording()) };
}
// カスタムコマンド
const handler = _config.customCommands[cmd.type];
if (handler) {
const result = await handler(cmd.args);
return { id: cmd.id, ...result };
}
return { id: cmd.id, success: false, error: `Unknown command: ${cmd.type}` };
} catch (e: any) {
return { id: cmd.id, success: false, error: e.message || String(e) };
}
}
// ─── ビルトインコマンド ───
function takeScreenshot(): { success: boolean; data?: any; error?: string } {
try {
const scene = director.getScene();
if (!scene) return { success: false, error: "No active scene" };
let camera: Camera | null = null;
const findCamera = (node: Node) => {
const cam = node.getComponent(Camera);
if (cam && cam.enabled) { camera = cam; return; }
for (const child of node.children) {
if (camera) return;
findCamera(child);
}
};
findCamera(scene);
if (!camera) return { success: false, error: "No camera found" };
const cam = camera as Camera;
const width = Math.floor(cam.camera.width);
const height = Math.floor(cam.camera.height);
const rt = new RenderTexture();
rt.reset({ width, height });
const prevTarget = cam.targetTexture;
cam.targetTexture = rt;
director.root!.frameMove(0);
cam.targetTexture = prevTarget;
const region = new gfx.BufferTextureCopy();
region.texOffset.x = 0;
region.texOffset.y = 0;
region.texExtent.width = width;
region.texExtent.height = height;
const buffer = new Uint8Array(width * height * 4);
const gfxTex = rt.getGFXTexture()!;
director.root!.device.copyTextureToBuffers(gfxTex, [buffer], [region]);
const cvs = document.createElement("canvas");
cvs.width = width;
cvs.height = height;
const ctx = cvs.getContext("2d")!;
const imageData = ctx.createImageData(width, height);
for (let y = 0; y < height; y++) {
const srcRow = (height - 1 - y) * width * 4;
const dstRow = y * width * 4;
for (let x = 0; x < width * 4; x++) {
imageData.data[dstRow + x] = buffer[srcRow + x];
}
}
ctx.putImageData(imageData, 0, 0);
const dataUrl = cvs.toDataURL("image/png");
rt.destroy();
return { success: true, data: { dataUrl, width, height } };
} catch (e: any) {
return { success: false, error: e.message };
}
}
function clickNode(name?: string): { success: boolean; error?: string } {
if (!name) return { success: false, error: "name argument required" };
const scene = director.getScene();
if (!scene) return { success: false, error: "No active scene" };
const found = findNodeByName(scene, name);
if (!found) return { success: false, error: `Node '${name}' not found` };
found.emit(Button.EventType.CLICK, found);
return { success: true };
}
// ─── 録画(MediaRecorder経由) ───
let _mediaRecorder: MediaRecorder | null = null;
let _recordChunks: Blob[] = [];
let _recordStream: MediaStream | null = null;
let _recordId: string | null = null;
const QUALITY_PRESETS: Record<string, number> = {
low: 0.15,
medium: 0.25,
high: 0.40,
ultra: 0.60,
};
function startRecording(args?: { fps?: number; videoBitsPerSecond?: number; quality?: string; coefficient?: number; format?: "webm" | "mp4" }): { success: boolean; error?: string; data?: any } {
// 前回の録画状態が残っていたら強制クリア
if (_mediaRecorder) {
try { if (_mediaRecorder.state !== "inactive") _mediaRecorder.stop(); } catch {}
_recordStream?.getTracks().forEach(t => { try { t.stop(); } catch {} });
_mediaRecorder = null;
_recordStream = null;
_recordChunks = [];
_recordId = null;
}
try {
// GameView canvas取得
const canvas = document.getElementById("GameCanvas") as HTMLCanvasElement
|| document.querySelector("canvas") as HTMLCanvasElement;
if (!canvas) return { success: false, error: "canvas not found" };
const fps = args?.fps ?? 30;
const quality = args?.quality ?? "medium";
const coef = args?.coefficient ?? QUALITY_PRESETS[quality] ?? QUALITY_PRESETS.medium;
const autoBps = Math.round(canvas.width * canvas.height * fps * coef);
const bps = args?.videoBitsPerSecond ?? autoBps;
const format = args?.format ?? "mp4";
_recordStream = canvas.captureStream(fps);
_recordChunks = [];
_recordId = `rec_${new Date().toISOString().replace(/[:.]/g, "-").replace("T", "_").substring(0, 19)}`;
// 指定フォーマット優先、非対応ならwebmにfallback
const mp4Candidates = [
"video/mp4;codecs=h264",
"video/mp4;codecs=avc1.42E01E",
"video/mp4",
];
const webmCandidates = [
"video/webm;codecs=vp9",
"video/webm;codecs=vp8",
"video/webm",
];
const candidates = format === "mp4"
? [...mp4Candidates, ...webmCandidates]
: webmCandidates;
let mimeType = "";
for (const c of candidates) {
if (typeof MediaRecorder !== "undefined" && MediaRecorder.isTypeSupported(c)) {
mimeType = c;
break;
}
}
if (!mimeType) return { success: false, error: "No supported MediaRecorder mimeType" };
_mediaRecorder = new MediaRecorder(_recordStream, { mimeType, videoBitsPerSecond: bps });
_mediaRecorder.ondataavailable = (e: BlobEvent) => {
if (e.data.size > 0) _recordChunks.push(e.data);
};
_mediaRecorder.start();
return { success: true, data: { id: _recordId, mimeType, fps, videoBitsPerSecond: bps, quality, canvasWidth: canvas.width, canvasHeight: canvas.height } };
} catch (e: any) {
_mediaRecorder = null;
_recordStream = null;
return { success: false, error: e.message || String(e) };
}
}
async function stopRecording(): Promise<{ success: boolean; error?: string; data?: any }> {
if (!_mediaRecorder || !_recordId) return { success: false, error: "not recording" };
const id = _recordId;
const mimeType = _mediaRecorder.mimeType;
return new Promise((resolve) => {
_mediaRecorder!.onstop = async () => {
try {
const blob = new Blob(_recordChunks, { type: mimeType });
_recordStream?.getTracks().forEach(t => t.stop());
_mediaRecorder = null;
_recordStream = null;
_recordChunks = [];
_recordId = null;
// Blob → base64 → POST /game/recording
const base64 = await blobToBase64(blob);
const uploadRes = await fetch(`${_config.mcpBaseUrl}/game/recording`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ id, base64, mimeType }),
});
const uploadData = await uploadRes.json();
resolve({ success: true, data: { id, size: blob.size, ...uploadData } });
} catch (e: any) {
resolve({ success: false, error: e.message || String(e) });
}
};
_mediaRecorder!.stop();
});
}
function blobToBase64(blob: Blob): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onloadend = () => {
const result = reader.result as string;
// "data:video/webm;base64,XXX" → XXX だけ抽出
const commaIdx = result.indexOf(",");
resolve(commaIdx >= 0 ? result.substring(commaIdx + 1) : result);
};
reader.onerror = reject;
reader.readAsDataURL(blob);
});
}
function findNodeByName(root: Node, name: string): Node | null {
if (root.name === name) return root;
for (const child of root.children) {
const found = findNodeByName(child, name);
if (found) return found;
}
return null;
}
// ─── 初期化・停止 ───
export function initMcpDebugClient(config?: McpDebugClientConfig): void {
if (_timer) return;
_config = {
mcpBaseUrl: config?.mcpBaseUrl ?? "http://127.0.0.1:3000",
pollInterval: config?.pollInterval ?? 500,
customCommands: config?.customCommands ?? {},
};
_timer = setInterval(pollCommand, _config.pollInterval);
console.log("[McpDebugClient] initialized");
}
export function stopMcpDebugClient(): void {
if (_timer) {
clearInterval(_timer);
_timer = null;
}
}
@@ -0,0 +1,171 @@
#!/usr/bin/env node
/**
* cocos-creator-mcp stdio bridge
*
* stdin で line-delimited JSON-RPC を読み取り、HTTP MCP エンドポイントに転送する。
* レスポンスは stdout に 1 行ずつ書き出す。
*
* Claude Code VSCode 拡張の HTTP MCP OAuth 強制バグ (#26917 等) を回避するための
* 代替トランスポート。ユーザーは .mcp.json で stdio 型として設定する:
*
* {
* "mcpServers": {
* "cocos-creator-mcp": {
* "command": "node",
* "args": [
* "C:/path/to/cocos-creator-mcp/client/stdio-bridge.js"
* ]
* }
* }
* }
*
* Optional 環境変数:
* COCOS_MCP_URL - MCP サーバーの URL (default: http://127.0.0.1:3000/mcp)
*/
"use strict";
const http = require("http");
const { URL } = require("url");
const readline = require("readline");
const DEFAULT_URL = "http://127.0.0.1:3000/mcp";
const serverUrl = new URL(process.env.COCOS_MCP_URL || DEFAULT_URL);
let sessionId = null;
function log(...args) {
// デバッグログは stderr に出す(stdout は JSON-RPC 応答専用)
console.error("[stdio-bridge]", ...args);
}
function postJsonRpc(body) {
return new Promise((resolve, reject) => {
const headers = {
"Content-Type": "application/json",
"Accept": "application/json",
"Content-Length": Buffer.byteLength(body),
};
if (sessionId) headers["Mcp-Session-Id"] = sessionId;
const req = http.request(
{
hostname: serverUrl.hostname,
port: serverUrl.port || 80,
path: serverUrl.pathname,
method: "POST",
headers,
},
(res) => {
const chunks = [];
res.on("data", (c) => chunks.push(c));
res.on("end", () => {
const responseSession = res.headers["mcp-session-id"];
if (responseSession && !sessionId) {
sessionId = responseSession;
log("session established:", sessionId);
}
const text = Buffer.concat(chunks).toString("utf8");
resolve({ status: res.statusCode || 0, body: text });
});
}
);
req.on("error", (err) => reject(err));
req.write(body);
req.end();
});
}
function writeResponse(obj) {
process.stdout.write(JSON.stringify(obj) + "\n");
}
async function handleLine(line) {
const trimmed = line.trim();
if (!trimmed) return;
let request;
try {
request = JSON.parse(trimmed);
} catch (e) {
writeResponse({
jsonrpc: "2.0",
id: null,
error: { code: -32700, message: "Parse error", data: e.message },
});
return;
}
try {
const { status, body } = await postJsonRpc(trimmed);
// Notifications (no id) may yield empty body
if (!body) {
if (request.id !== undefined) {
writeResponse({
jsonrpc: "2.0",
id: request.id,
error: { code: -32603, message: "Empty response from server", data: { status } },
});
}
return;
}
// Pass through server response (single JSON object per line)
try {
const parsed = JSON.parse(body);
writeResponse(parsed);
} catch {
// サーバーが SSE 等で返した場合のフォールバック
process.stdout.write(body + (body.endsWith("\n") ? "" : "\n"));
}
} catch (err) {
writeResponse({
jsonrpc: "2.0",
id: request.id !== undefined ? request.id : null,
error: {
code: -32603,
message: "HTTP bridge error",
data: err && err.message ? err.message : String(err),
},
});
}
}
let inflight = 0;
let stdinClosed = false;
function maybeExit() {
if (stdinClosed && inflight === 0) {
log("stdin closed, no inflight, exiting");
process.exit(0);
}
}
function main() {
log(`starting, target=${serverUrl.href}`);
const rl = readline.createInterface({
input: process.stdin,
terminal: false,
});
rl.on("line", (line) => {
inflight++;
handleLine(line)
.catch((e) => log("handler error:", e))
.finally(() => {
inflight--;
maybeExit();
});
});
rl.on("close", () => {
stdinClosed = true;
maybeExit();
});
process.on("SIGINT", () => process.exit(0));
process.on("SIGTERM", () => process.exit(0));
}
main();
@@ -0,0 +1,9 @@
"use strict";
module.exports = {
description: "MCP (Model Context Protocol) server for Cocos Creator",
panel_title: "Cocos Creator MCP",
open_panel: "Open Panel",
recorder_title: "Preview Recorder",
open_recorder: "Open Recorder",
};
@@ -0,0 +1,9 @@
"use strict";
module.exports = {
description: "Cocos Creator用 MCP (Model Context Protocol) サーバー",
panel_title: "Cocos Creator MCP",
open_panel: "パネルを開く",
recorder_title: "Preview Recorder",
open_recorder: "Previewレコーダー",
};
@@ -0,0 +1,9 @@
"use strict";
module.exports = {
description: "Cocos Creator MCP (模型上下文协议) 服务器",
panel_title: "Cocos Creator MCP",
open_panel: "打开面板",
recorder_title: "Preview Recorder",
open_recorder: "打开录制器",
};
@@ -0,0 +1,310 @@
{
"name": "@iflow-mcp/harady-cocos-creator-mcp",
"version": "1.14.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@iflow-mcp/harady-cocos-creator-mcp",
"version": "1.14.0",
"dependencies": {
"vue": "^3.1.4",
"wasm-vips": "^0.0.17"
},
"bin": {
"iflow-mcp_harady-cocos-creator-mcp": "client/stdio-bridge.js"
},
"devDependencies": {
"@cocos/creator-types": "^3.8.6",
"@types/node": "^18.17.1",
"typescript": "^5.8.2"
}
},
"node_modules/@babel/helper-string-parser": {
"version": "7.29.7",
"resolved": "https://registry.npmmirror.com/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz",
"integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/helper-validator-identifier": {
"version": "7.29.7",
"resolved": "https://registry.npmmirror.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz",
"integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/parser": {
"version": "7.29.7",
"resolved": "https://registry.npmmirror.com/@babel/parser/-/parser-7.29.7.tgz",
"integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==",
"dependencies": {
"@babel/types": "^7.29.7"
},
"bin": {
"parser": "bin/babel-parser.js"
},
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/@babel/types": {
"version": "7.29.7",
"resolved": "https://registry.npmmirror.com/@babel/types/-/types-7.29.7.tgz",
"integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==",
"dependencies": {
"@babel/helper-string-parser": "^7.29.7",
"@babel/helper-validator-identifier": "^7.29.7"
},
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@cocos/creator-types": {
"version": "3.8.7",
"resolved": "https://registry.npmmirror.com/@cocos/creator-types/-/creator-types-3.8.7.tgz",
"integrity": "sha512-QTtuC+w1s1yQ8DPlgFSiIGS+Pko6v2QdrrQRN8/SttEBo53JjyXEWHXpRkg1lGiHq0u/aadm0cN/Y4jtEmis/w==",
"dev": true
},
"node_modules/@jridgewell/sourcemap-codec": {
"version": "1.5.5",
"resolved": "https://registry.npmmirror.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
"integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="
},
"node_modules/@types/node": {
"version": "18.19.130",
"resolved": "https://registry.npmmirror.com/@types/node/-/node-18.19.130.tgz",
"integrity": "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==",
"dev": true,
"dependencies": {
"undici-types": "~5.26.4"
}
},
"node_modules/@vue/compiler-core": {
"version": "3.5.39",
"resolved": "https://registry.npmmirror.com/@vue/compiler-core/-/compiler-core-3.5.39.tgz",
"integrity": "sha512-16KBTEXAJCpDr0mwlw+AZyhu8iyC7R3S2vBwsI7QnWJU6X3WKc9VKeNEZpiMdZ569qWhz9574L3vV55qRL0Vtw==",
"dependencies": {
"@babel/parser": "^7.29.7",
"@vue/shared": "3.5.39",
"entities": "^7.0.1",
"estree-walker": "^2.0.2",
"source-map-js": "^1.2.1"
}
},
"node_modules/@vue/compiler-dom": {
"version": "3.5.39",
"resolved": "https://registry.npmmirror.com/@vue/compiler-dom/-/compiler-dom-3.5.39.tgz",
"integrity": "sha512-oQPigALqYbNxTNPvNgSOe+czwVExfbVF02lz8jP0S3AXJiu3jxYDygNUiqSep4ezzW8XgnubqH63My2A7JR/vg==",
"dependencies": {
"@vue/compiler-core": "3.5.39",
"@vue/shared": "3.5.39"
}
},
"node_modules/@vue/compiler-sfc": {
"version": "3.5.39",
"resolved": "https://registry.npmmirror.com/@vue/compiler-sfc/-/compiler-sfc-3.5.39.tgz",
"integrity": "sha512-d0ki86iOyN8LoZPBmk5SJWNwHP19CnDDCfuo//+2WJa2g5Ke0Jay983PIBIcSSzldC68I8DrD5GrHV3OSDfodg==",
"dependencies": {
"@babel/parser": "^7.29.7",
"@vue/compiler-core": "3.5.39",
"@vue/compiler-dom": "3.5.39",
"@vue/compiler-ssr": "3.5.39",
"@vue/shared": "3.5.39",
"estree-walker": "^2.0.2",
"magic-string": "^0.30.21",
"postcss": "^8.5.15",
"source-map-js": "^1.2.1"
}
},
"node_modules/@vue/compiler-ssr": {
"version": "3.5.39",
"resolved": "https://registry.npmmirror.com/@vue/compiler-ssr/-/compiler-ssr-3.5.39.tgz",
"integrity": "sha512-Ce7/wvwMHai74bdszfXExdazFigYnlF9zgCmEQUcM1j0fOymlouZ7XilTYNo8oUjhlnjYOZbGrcYKuqjz89Ucw==",
"dependencies": {
"@vue/compiler-dom": "3.5.39",
"@vue/shared": "3.5.39"
}
},
"node_modules/@vue/reactivity": {
"version": "3.5.39",
"resolved": "https://registry.npmmirror.com/@vue/reactivity/-/reactivity-3.5.39.tgz",
"integrity": "sha512-TpsuBJ9gGlZa5d23XcM2y8EXanz9dZeVDQBXRwzy46ItgvM+rWpzs+UVM0wcRLxGvcav0HE5jz2gNL53xlRAog==",
"dependencies": {
"@vue/shared": "3.5.39"
}
},
"node_modules/@vue/runtime-core": {
"version": "3.5.39",
"resolved": "https://registry.npmmirror.com/@vue/runtime-core/-/runtime-core-3.5.39.tgz",
"integrity": "sha512-9GLtNyRvPAUMbX+7ono0RC2j0guo2LXVi8LvcmAooImACUKm0oFf0jjwbX8/H0AE/t1nxhAkn8RSl9PMCzzxZw==",
"dependencies": {
"@vue/reactivity": "3.5.39",
"@vue/shared": "3.5.39"
}
},
"node_modules/@vue/runtime-dom": {
"version": "3.5.39",
"resolved": "https://registry.npmmirror.com/@vue/runtime-dom/-/runtime-dom-3.5.39.tgz",
"integrity": "sha512-7Y6aAGboKcXAZ3ECuUy7RrS5yy2r47dhTp2SKaJmYxjopImaVFaNa5Ne66NwGovsrxVAl5S5rwc7m22UG7Lmww==",
"dependencies": {
"@vue/reactivity": "3.5.39",
"@vue/runtime-core": "3.5.39",
"@vue/shared": "3.5.39",
"csstype": "^3.2.3"
}
},
"node_modules/@vue/server-renderer": {
"version": "3.5.39",
"resolved": "https://registry.npmmirror.com/@vue/server-renderer/-/server-renderer-3.5.39.tgz",
"integrity": "sha512-yZSakiAGw85rZfG7UM8akMnIF+FmeiNk47uvHf2nVBBSe+dIKUhZuZq9+XgJhbV3nS5Z4ALH23/MpXofW+mbcw==",
"dependencies": {
"@vue/compiler-ssr": "3.5.39",
"@vue/shared": "3.5.39"
},
"peerDependencies": {
"vue": "3.5.39"
}
},
"node_modules/@vue/shared": {
"version": "3.5.39",
"resolved": "https://registry.npmmirror.com/@vue/shared/-/shared-3.5.39.tgz",
"integrity": "sha512-l1rrBtBfTnmxvtsvdQDXltUUy8S1Y+ZaqdfUzmAnJkTd8Z8rv5v/ytW+TKiqEOWyHPoqtPlNFSs0lhRmYVSHVA=="
},
"node_modules/csstype": {
"version": "3.2.3",
"resolved": "https://registry.npmmirror.com/csstype/-/csstype-3.2.3.tgz",
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="
},
"node_modules/entities": {
"version": "7.0.1",
"resolved": "https://registry.npmmirror.com/entities/-/entities-7.0.1.tgz",
"integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==",
"engines": {
"node": ">=0.12"
},
"funding": {
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/estree-walker": {
"version": "2.0.2",
"resolved": "https://registry.npmmirror.com/estree-walker/-/estree-walker-2.0.2.tgz",
"integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="
},
"node_modules/magic-string": {
"version": "0.30.21",
"resolved": "https://registry.npmmirror.com/magic-string/-/magic-string-0.30.21.tgz",
"integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
"dependencies": {
"@jridgewell/sourcemap-codec": "^1.5.5"
}
},
"node_modules/nanoid": {
"version": "3.3.15",
"resolved": "https://registry.npmmirror.com/nanoid/-/nanoid-3.3.15.tgz",
"integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"bin": {
"nanoid": "bin/nanoid.cjs"
},
"engines": {
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
}
},
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmmirror.com/picocolors/-/picocolors-1.1.1.tgz",
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="
},
"node_modules/postcss": {
"version": "8.5.15",
"resolved": "https://registry.npmmirror.com/postcss/-/postcss-8.5.15.tgz",
"integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==",
"funding": [
{
"type": "opencollective",
"url": "https://opencollective.com/postcss/"
},
{
"type": "tidelift",
"url": "https://tidelift.com/funding/github/npm/postcss"
},
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"dependencies": {
"nanoid": "^3.3.12",
"picocolors": "^1.1.1",
"source-map-js": "^1.2.1"
},
"engines": {
"node": "^10 || ^12 || >=14"
}
},
"node_modules/source-map-js": {
"version": "1.2.1",
"resolved": "https://registry.npmmirror.com/source-map-js/-/source-map-js-1.2.1.tgz",
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/typescript": {
"version": "5.9.3",
"resolved": "https://registry.npmmirror.com/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"devOptional": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": {
"node": ">=14.17"
}
},
"node_modules/undici-types": {
"version": "5.26.5",
"resolved": "https://registry.npmmirror.com/undici-types/-/undici-types-5.26.5.tgz",
"integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==",
"dev": true
},
"node_modules/vue": {
"version": "3.5.39",
"resolved": "https://registry.npmmirror.com/vue/-/vue-3.5.39.tgz",
"integrity": "sha512-xmZCYabFGcirU8r0fTuvl/LICc1OU620rnqepaJDL/a141ZigkG7AyaxQLdqJ02ZRYzWe6YPaDHeQx7MfknQfA==",
"dependencies": {
"@vue/compiler-dom": "3.5.39",
"@vue/compiler-sfc": "3.5.39",
"@vue/runtime-dom": "3.5.39",
"@vue/server-renderer": "3.5.39",
"@vue/shared": "3.5.39"
},
"peerDependencies": {
"typescript": "*"
},
"peerDependenciesMeta": {
"typescript": {
"optional": true
}
}
},
"node_modules/wasm-vips": {
"version": "0.0.17",
"resolved": "https://registry.npmmirror.com/wasm-vips/-/wasm-vips-0.0.17.tgz",
"integrity": "sha512-nhkqUNJDUymImoXGrVfImC4wzIFTb9KfBpAngb7dcEQNPP1gVTx4+WL3VVVDSXQpMsyeacsQDOx0+DM33Rpurg==",
"engines": {
"node": ">=16.4.0"
}
}
}
}
@@ -0,0 +1,109 @@
{
"package_version": 2,
"name": "cocos-creator-mcp",
"version": "1.14.0",
"author": "harady",
"editor": ">=3.8.0",
"scripts": {
"build": "tsc && node scripts/postbuild.js",
"watch": "tsc -w"
},
"description": "MCP Server for Cocos Creator [2ade3d2fa124]",
"main": "./dist/main.js",
"bin": {
"iflow-mcp_harady-cocos-creator-mcp": "client/stdio-bridge.js"
},
"dependencies": {
"vue": "^3.1.4",
"wasm-vips": "^0.0.17"
},
"devDependencies": {
"@cocos/creator-types": "^3.8.6",
"@types/node": "^18.17.1",
"typescript": "^5.8.2"
},
"panels": {
"default": {
"title": "i18n:cocos-creator-mcp.panel_title",
"type": "dockable",
"main": "dist/panels/default",
"size": {
"min-width": 350,
"min-height": 200,
"width": 450,
"height": 350
}
},
"recorder": {
"title": "i18n:cocos-creator-mcp.recorder_title",
"type": "dockable",
"main": "dist/panels/recorder",
"size": {
"min-width": 400,
"min-height": 400,
"width": 500,
"height": 600
}
}
},
"contributions": {
"menu": [
{
"path": "i18n:menu.extension/Cocos Creator MCP",
"label": "i18n:cocos-creator-mcp.open_panel",
"message": "open-panel"
},
{
"path": "i18n:menu.extension/Cocos Creator MCP",
"label": "i18n:cocos-creator-mcp.open_recorder",
"message": "open-recorder"
}
],
"messages": {
"open-panel": {
"methods": [
"openPanel"
]
},
"open-recorder": {
"methods": [
"openRecorder"
]
},
"start-server": {
"methods": [
"startServer"
]
},
"stop-server": {
"methods": [
"stopServer"
]
},
"get-server-status": {
"methods": [
"getServerStatus"
]
},
"update-port": {
"methods": [
"updatePort"
]
}
},
"scene": {
"script": "./dist/scene.js",
"methods": [
"getNodeInfo",
"getAllNodes",
"findNodesByName",
"setNodeProperty",
"setComponentProperty",
"getSceneHierarchy",
"moveNode",
"addComponentToNode",
"removeComponentFromNode"
]
}
}
}
@@ -0,0 +1 @@
@iflow-mcp/harady-cocos-creator-mcp
@@ -0,0 +1,5 @@
{
"push_platform": "github",
"fork_url": "https://github.com/iflow-mcp/harady-cocos-creator-mcp",
"fork_branch": "iflow"
}
@@ -0,0 +1,98 @@
const fs = require("fs");
const crypto = require("crypto");
const path = require("path");
// dist/ 配下の全 .js ファイルの内容からハッシュを生成
// コードが同じなら常に同じ値になる
const distDir = path.join(__dirname, "..", "dist");
const hash = crypto.createHash("sha256");
// dist/ 配下を再帰的に走査して全 .js ファイルを収集
function collectJsFiles(dir, prefix = "") {
let files = [];
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const relPath = prefix ? `${prefix}/${entry.name}` : entry.name;
if (entry.isDirectory()) {
files = files.concat(collectJsFiles(path.join(dir, entry.name), relPath));
} else if (entry.name.endsWith(".js")) {
files.push(relPath);
}
}
return files;
}
const jsFiles = collectJsFiles(distDir).sort();
for (const file of jsFiles) {
const content = fs.readFileSync(path.join(distDir, file), "utf8");
// __BUILD_HASH__ プレースホルダーは除外して計算(自己参照を避ける)
hash.update(content.replace(/__BUILD_HASH__/g, ""));
}
const buildHash = hash.digest("hex").substring(0, 12);
// mcp-server.js にハッシュを埋め込む
const serverFile = path.join(distDir, "mcp-server.js");
const serverContent = fs.readFileSync(serverFile, "utf8");
fs.writeFileSync(serverFile, serverContent.replace("__BUILD_HASH__", buildHash));
// package.json の description にハッシュを付与
const pkgFile = path.join(__dirname, "..", "package.json");
const pkg = JSON.parse(fs.readFileSync(pkgFile, "utf8"));
const baseDesc = pkg.description.replace(/\s*\[[a-f0-9]+\]$/, ""); // 既存ハッシュを除去
pkg.description = `${baseDesc} [${buildHash}]`;
fs.writeFileSync(pkgFile, JSON.stringify(pkg, null, 4) + "\n");
console.log("BUILD_HASH:", buildHash);
// ── dist自動同期: ゲームプロジェクトの extensions/ にコピー ──
// SYNC_TARGET 環境変数 or settings ファイルで同期先を指定
const settingsFile = path.join(__dirname, "..", "sync-targets.json");
let syncTargets = [];
if (process.env.SYNC_TARGET) {
syncTargets = process.env.SYNC_TARGET.split(",").map(s => s.trim());
} else if (fs.existsSync(settingsFile)) {
syncTargets = JSON.parse(fs.readFileSync(settingsFile, "utf8")).targets || [];
}
if (syncTargets.length > 0) {
const rootDir = path.join(__dirname, "..");
const filesToSync = ["dist", "client", "i18n", "static", "package.json", "package-lock.json", "node_modules"];
for (const target of syncTargets) {
const targetDir = path.resolve(target);
if (!fs.existsSync(path.dirname(targetDir))) {
console.warn(`SYNC SKIP: parent dir not found: ${targetDir}`);
continue;
}
console.log(`SYNC: ${targetDir}`);
for (const entry of filesToSync) {
const src = path.join(rootDir, entry);
const dst = path.join(targetDir, entry);
if (!fs.existsSync(src)) continue;
if (fs.statSync(src).isDirectory()) {
copyDirSync(src, dst);
} else {
fs.mkdirSync(path.dirname(dst), { recursive: true });
fs.copyFileSync(src, dst);
}
}
console.log(`SYNC DONE: ${targetDir}`);
}
} else {
console.log("SYNC: no targets configured (set SYNC_TARGET or create sync-targets.json)");
}
function copyDirSync(src, dst) {
fs.mkdirSync(dst, { recursive: true });
for (const entry of fs.readdirSync(src, { withFileTypes: true })) {
const srcPath = path.join(src, entry.name);
const dstPath = path.join(dst, entry.name);
if (entry.isDirectory()) {
copyDirSync(srcPath, dstPath);
} else {
fs.copyFileSync(srcPath, dstPath);
}
}
}
@@ -0,0 +1,44 @@
import fs from "fs";
import path from "path";
const HOURS_24 = 24 * 60 * 60 * 1000;
/**
* 24時間以上前のファイルを OLD_yyyyMM フォルダに移動する.
*/
export function archiveOldFiles(dir: string): void {
try {
let files: string[];
try {
files = fs.readdirSync(dir);
} catch {
return;
}
const now = Date.now();
let archived = 0;
for (const file of files) {
const filePath = path.join(dir, file);
const stat = fs.statSync(filePath);
if (stat.isDirectory()) continue;
if (now - stat.mtimeMs < HOURS_24) continue;
const fileDate = new Date(stat.mtimeMs);
const yyyy = fileDate.getFullYear();
const mm = String(fileDate.getMonth() + 1).padStart(2, "0");
const archiveDir = path.join(dir, `OLD_${yyyy}${mm}`);
fs.mkdirSync(archiveDir, { recursive: true });
fs.renameSync(filePath, path.join(archiveDir, file));
archived++;
}
if (archived > 0) {
console.log(`[cocos-creator-mcp] ${archived}件のファイルを月別フォルダにアーカイブ (${dir})`);
}
} catch (e) {
console.warn("[cocos-creator-mcp] アーカイブ失敗:", e);
}
}
@@ -0,0 +1,137 @@
import { McpServer } from "./mcp-server";
import { SceneTools } from "./tools/scene-tools";
import { NodeTools } from "./tools/node-tools";
import { ComponentTools } from "./tools/component-tools";
import { PrefabTools } from "./tools/prefab-tools";
import { ProjectTools } from "./tools/project-tools";
import { DebugTools } from "./tools/debug-tools";
import { SceneAdvancedTools } from "./tools/scene-advanced-tools";
import { SceneViewTools } from "./tools/scene-view-tools";
import { AssetTools } from "./tools/asset-tools";
import { PreferencesTools } from "./tools/preferences-tools";
import { ServerTools } from "./tools/server-tools";
import { BuilderTools } from "./tools/builder-tools";
import { ReferenceImageTools } from "./tools/reference-image-tools";
import { ServerConfig, DEFAULT_CONFIG } from "./types";
import path from "path";
import fs from "fs";
let server: McpServer | null = null;
function getSettingsPath(): string {
return path.join(Editor.Project.path, "settings", "cocos-creator-mcp.json");
}
function loadConfig(): ServerConfig {
try {
const p = getSettingsPath();
if (fs.existsSync(p)) {
const data = JSON.parse(fs.readFileSync(p, "utf-8"));
return { ...DEFAULT_CONFIG, ...data };
}
} catch (e) {
console.warn("[cocos-creator-mcp] Failed to load settings, using defaults");
}
return { ...DEFAULT_CONFIG };
}
function saveConfig(config: ServerConfig): void {
try {
const p = getSettingsPath();
const dir = path.dirname(p);
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(p, JSON.stringify(config, null, 2), "utf-8");
} catch (e) {
console.error("[cocos-creator-mcp] Failed to save settings:", e);
}
}
function createServer(config: ServerConfig): McpServer {
const s = new McpServer(config);
s.register(new SceneTools());
s.register(new NodeTools());
const componentTools = new ComponentTools();
s.register(componentTools);
s.register(new PrefabTools(componentTools));
s.register(new ProjectTools());
s.register(new DebugTools());
s.register(new SceneAdvancedTools());
s.register(new SceneViewTools());
s.register(new AssetTools());
s.register(new PreferencesTools());
s.register(new ServerTools());
s.register(new BuilderTools());
s.register(new ReferenceImageTools());
return s;
}
export const methods: Record<string, (...args: any[]) => any> = {
openPanel() {
Editor.Panel.open("cocos-creator-mcp");
},
openRecorder() {
Editor.Panel.open("cocos-creator-mcp.recorder");
},
async startServer() {
if (server?.isRunning) return { running: true, port: server.port };
const config = loadConfig();
server = createServer(config);
await server.start();
return { running: true, port: server.port };
},
async stopServer() {
if (server) {
await server.stop();
server = null;
}
return { running: false };
},
async updatePort(_port: number) {
const config = loadConfig();
config.port = _port;
saveConfig(config);
// Restart server if running
if (server?.isRunning) {
await server.stop();
server = createServer(config);
await server.start();
}
return { port: _port, running: server?.isRunning ?? false };
},
getServerStatus() {
return {
running: server?.isRunning ?? false,
port: server?.port ?? loadConfig().port,
toolCount: server?.getAllTools().length ?? 0,
toolNames: server?.getAllTools().map((t) => t.name).sort() ?? [],
};
},
};
export async function load() {
console.log("[cocos-creator-mcp] Extension loaded");
const config = loadConfig();
server = createServer(config);
if (config.autoStart) {
try {
await server.start();
console.log(`[cocos-creator-mcp] Auto-started on port ${config.port}`);
} catch (e) {
console.error("[cocos-creator-mcp] Auto-start failed:", e);
}
}
}
export async function unload() {
if (server) {
await server.stop();
server = null;
}
console.log("[cocos-creator-mcp] Extension unloaded");
}
@@ -0,0 +1,542 @@
import http from "http";
import { ToolCategory, ToolDefinition, JsonRpcRequest, JsonRpcResponse, ServerConfig, DEFAULT_CONFIG } from "./types";
import { archiveOldFiles } from "./archive";
const MCP_PROTOCOL_VERSION = "2024-11-05";
const SESSION_ID = `cocos-mcp-${Date.now()}-${Math.random().toString(36).substring(2, 10)}`;
/** ビルド時にコードベースのSHA256ハッシュが埋め込まれる */
export const BUILD_HASH = "__BUILD_HASH__";
// ─── Game Preview Log Buffer ───
interface GameLogEntry {
timestamp: string;
level: "log" | "warn" | "error";
message: string;
}
const MAX_GAME_LOG_BUFFER = 500;
const _gameLogs: GameLogEntry[] = [];
/** Access game preview log buffer from debug-tools */
export function getGameLogs(count: number, level?: string): { logs: GameLogEntry[]; total: number } {
let logs = _gameLogs;
if (level) {
logs = logs.filter(l => l.level === level);
}
return { logs: logs.slice(-count), total: _gameLogs.length };
}
export function clearGameLogs(): void {
_gameLogs.length = 0;
}
// ─── Game Debug Command Queue ───
interface GameCommand {
id: string;
type: string;
args?: any;
timestamp: string;
}
interface GameCommandResult {
id: string;
success: boolean;
data?: any;
error?: string;
timestamp: string;
}
let _pendingCommand: GameCommand | null = null;
let _commandResult: GameCommandResult | null = null;
let _commandIdCounter = 0;
/** Queue a command for the game to execute */
export function queueGameCommand(type: string, args?: any): string {
const id = `cmd_${++_commandIdCounter}_${Date.now()}`;
_pendingCommand = { id, type, args, timestamp: new Date().toISOString() };
_commandResult = null;
return id;
}
/** Get the result of the last command (poll until available) */
export function getCommandResult(): GameCommandResult | null {
return _commandResult;
}
/** Clear command state */
export function clearCommandState(): void {
_pendingCommand = null;
_commandResult = null;
}
// ─── Recording Storage ───
interface RecordingInfo {
path: string;
size: number;
createdAt: string;
}
const _recordings = new Map<string, RecordingInfo>();
/** Get completed recording info by id */
export function getRecording(id: string): RecordingInfo | undefined {
return _recordings.get(id);
}
export function setRecording(id: string, info: RecordingInfo): void {
_recordings.set(id, info);
}
export class McpServer {
private server: http.Server | null = null;
private tools: Map<string, ToolCategory> = new Map();
private toolIndex: Map<string, ToolCategory> = new Map(); // toolName -> category
private config: ServerConfig;
constructor(config?: Partial<ServerConfig>) {
this.config = { ...DEFAULT_CONFIG, ...config };
}
/** Register a tool category */
register(category: ToolCategory): void {
this.tools.set(category.categoryName, category);
for (const tool of category.getTools()) {
this.toolIndex.set(tool.name, category);
}
}
/** Get all tool definitions */
getAllTools(): ToolDefinition[] {
const all: ToolDefinition[] = [];
for (const cat of this.tools.values()) {
all.push(...cat.getTools());
}
return all;
}
/** Start HTTP server */
start(): Promise<void> {
return new Promise((resolve, reject) => {
if (this.server) {
resolve();
return;
}
this.server = http.createServer((req, res) => this.handleRequest(req, res));
this.server.listen(this.config.port, "127.0.0.1", () => {
console.log(`[cocos-creator-mcp] Server started on http://127.0.0.1:${this.config.port}/mcp`);
resolve();
});
this.server.on("error", (e) => {
console.error(`[cocos-creator-mcp] Server error:`, e);
reject(e);
});
});
}
/** Stop HTTP server */
stop(): Promise<void> {
return new Promise((resolve) => {
if (!this.server) {
resolve();
return;
}
this.server.close(() => {
this.server = null;
console.log("[cocos-creator-mcp] Server stopped");
resolve();
});
});
}
get isRunning(): boolean {
return this.server !== null;
}
get port(): number {
return this.config.port;
}
private async handleRequest(req: http.IncomingMessage, res: http.ServerResponse): Promise<void> {
// CORS
res.setHeader("Access-Control-Allow-Origin", "*");
res.setHeader("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS");
res.setHeader("Access-Control-Allow-Headers", "Content-Type, Accept");
if (req.method === "OPTIONS") {
res.writeHead(204);
res.end();
return;
}
const url = req.url || "/";
const origin = `http://127.0.0.1:${this.config.port}`;
// ─── OAuth endpoints (MCP spec 2025-06-18 / RFC 9728 / RFC 8414 / RFC 7591) ───
//
// Claude Code の VSCode 拡張は HTTP トランスポートの MCP サーバーに対して
// 無条件で OAuth discovery / DCR を試みる (#26917 等の既知バグ)。
// cocos-creator-mcp は localhost-only のローカル開発ツールで本物の認証は不要だが、
// クライアントを満足させるため OAuth エンドポイント群をダミー実装して常時許可する。
//
// TODO: 以下のいずれかが発生したら削除する
// 1. anthropics/claude-code #26917 / #38102 等の HTTP OAuth バグが修正される
// 2. 本物の認証機構を実装する必要が出る(偽 OAuth と衝突するため)
// 3. MCP spec が PKCE 検証・トークンローテーション必須等に更新される
// 4. stdio ブリッジが十分定着して HTTP transport 自体を deprecate する
// RFC 9728 Protected Resource Metadata
if (url === "/.well-known/oauth-protected-resource" && req.method === "GET") {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({
resource: `${origin}/mcp`,
authorization_servers: [origin],
bearer_methods_supported: ["header"],
scopes_supported: ["mcp"],
}));
return;
}
// RFC 8414 Authorization Server Metadata
if (url === "/.well-known/oauth-authorization-server" && req.method === "GET") {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({
issuer: origin,
authorization_endpoint: `${origin}/oauth/authorize`,
token_endpoint: `${origin}/oauth/token`,
registration_endpoint: `${origin}/oauth/register`,
response_types_supported: ["code"],
grant_types_supported: ["authorization_code"],
code_challenge_methods_supported: ["S256", "plain"],
token_endpoint_auth_methods_supported: ["none"],
scopes_supported: ["mcp"],
}));
return;
}
// RFC 7591 Dynamic Client Registration — accept anything, return dummy client
if (url === "/oauth/register" && req.method === "POST") {
const body = await readBody(req);
let reg: any = {};
try { reg = JSON.parse(body); } catch { /* ignore */ }
const clientId = `cocos-mcp-client-${Date.now()}`;
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({
client_id: clientId,
client_id_issued_at: Math.floor(Date.now() / 1000),
client_name: reg.client_name || "cocos-creator-mcp client",
redirect_uris: reg.redirect_uris || [],
token_endpoint_auth_method: "none",
grant_types: ["authorization_code"],
response_types: ["code"],
}));
return;
}
// OAuth authorization endpoint — auto-consent, redirect immediately with code
if (url.startsWith("/oauth/authorize") && req.method === "GET") {
const parsed = new URL(url, origin);
const redirectUri = parsed.searchParams.get("redirect_uri") || "";
const state = parsed.searchParams.get("state") || "";
if (!redirectUri) {
res.writeHead(400, { "Content-Type": "application/json" });
res.end(JSON.stringify({ error: "invalid_request", error_description: "redirect_uri required" }));
return;
}
const code = `cocos-mcp-code-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
const location = `${redirectUri}${redirectUri.includes("?") ? "&" : "?"}code=${encodeURIComponent(code)}&state=${encodeURIComponent(state)}`;
res.writeHead(302, { Location: location });
res.end();
return;
}
// OAuth token endpoint — always issue a dummy token
if (url === "/oauth/token" && req.method === "POST") {
await readBody(req); // drain
res.writeHead(200, {
"Content-Type": "application/json",
"Cache-Control": "no-store",
});
res.end(JSON.stringify({
access_token: "cocos-mcp-public-token",
token_type: "Bearer",
expires_in: 86400,
scope: "mcp",
}));
return;
}
// Health check
if (url === "/health" && req.method === "GET") {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ status: "ok", tools: this.getAllTools().length }));
return;
}
// Game debug command queue — game polls for commands
if (url === "/game/command" && req.method === "GET") {
const cmd = _pendingCommand;
_pendingCommand = null; // consume
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify(cmd));
return;
}
// Game debug command result — game posts result
if (url === "/game/result" && req.method === "POST") {
const body = await readBody(req);
try {
_commandResult = JSON.parse(body);
} catch { /* ignore */ }
res.writeHead(204);
res.end();
return;
}
// Game preview recording receiver
if (url === "/game/recording" && req.method === "POST") {
const body = await readBody(req);
try {
const { id, base64, mimeType, savePath } = JSON.parse(body);
if (!id || !base64) throw new Error("id/base64 required");
const fs = require("fs");
const path = require("path");
const buffer = Buffer.from(base64, "base64");
// savePath指定があればそこに保存(絶対パスまたはプロジェクト相対パス)
const projectPath = (global as any).Editor?.Project?.path
|| process.cwd();
let dir: string;
if (savePath) {
dir = path.isAbsolute(savePath) ? savePath : path.join(projectPath, savePath);
} else {
dir = path.join(projectPath, "temp", "recordings");
}
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
const mt = (mimeType || "").toLowerCase();
const ext = mt.includes("webm") ? "webm"
: mt.includes("mp4") ? "mp4"
: "bin";
const fileName = `${id}.${ext}`;
const filePath = path.join(dir, fileName);
fs.writeFileSync(filePath, buffer);
setRecording(id, {
path: filePath,
size: buffer.length,
createdAt: new Date().toISOString(),
});
if (this.config.autoArchiveRecordings) {
archiveOldFiles(dir);
}
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ success: true, path: filePath, size: buffer.length }));
} catch (e: any) {
res.writeHead(400, { "Content-Type": "application/json" });
res.end(JSON.stringify({ success: false, error: e.message }));
}
return;
}
// Game preview log receiver
if (url === "/log" && req.method === "POST") {
const body = await readBody(req);
try {
const entries: GameLogEntry[] = JSON.parse(body);
for (const entry of (Array.isArray(entries) ? entries : [entries])) {
_gameLogs.push({
timestamp: entry.timestamp || new Date().toISOString(),
level: entry.level || "log",
message: entry.message || "",
});
// __debug_state__ ログから userId を debug-menu.json に保存
try {
const msg = JSON.parse(entry.message || "");
if (msg.__debug_state__ && msg.userId) {
const _fs = require("fs");
const _path = require("path");
const projectPath = (global as any).Editor?.Project?.path || process.cwd();
const settingsPath = _path.join(projectPath, "settings", "debug-menu.json");
_fs.writeFileSync(settingsPath, JSON.stringify({ userId: msg.userId }, null, 2), "utf-8");
}
} catch { /* not debug_state */ }
}
if (_gameLogs.length > MAX_GAME_LOG_BUFFER) {
_gameLogs.splice(0, _gameLogs.length - MAX_GAME_LOG_BUFFER);
}
} catch { /* ignore malformed */ }
res.writeHead(204);
res.end();
return;
}
// MCP endpoint
if (url === "/mcp") {
if (req.method === "GET") {
// SSE keepalive stream
res.writeHead(200, {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
"Connection": "keep-alive",
});
// Send initial comment to keep connection alive
res.write(": connected\n\n");
return;
}
if (req.method === "POST") {
await this.handleMcpPost(req, res);
return;
}
if (req.method === "DELETE") {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ ok: true }));
return;
}
}
// 404
res.writeHead(404, { "Content-Type": "application/json" });
res.end(JSON.stringify({ error: "Not found" }));
}
private async handleMcpPost(req: http.IncomingMessage, res: http.ServerResponse): Promise<void> {
const body = await readBody(req);
let rpc: JsonRpcRequest;
try {
rpc = JSON.parse(body);
} catch {
this.sendJsonRpc(res, { jsonrpc: "2.0", id: null, error: { code: -32700, message: "Parse error" } });
return;
}
const accept = req.headers["accept"] || "";
const wantSse = accept.includes("text/event-stream");
let response: JsonRpcResponse;
switch (rpc.method) {
case "initialize":
response = {
jsonrpc: "2.0",
id: rpc.id,
result: {
protocolVersion: MCP_PROTOCOL_VERSION,
capabilities: { tools: {} },
serverInfo: {
name: "cocos-creator-mcp",
version: "1.0.0",
},
},
};
break;
case "notifications/initialized":
// No response needed for notification
res.writeHead(204, { "Mcp-Session-Id": SESSION_ID });
res.end();
return;
case "tools/list":
response = {
jsonrpc: "2.0",
id: rpc.id,
result: { tools: this.getAllTools() },
};
break;
case "tools/call": {
const toolName = rpc.params?.name;
const args = rpc.params?.arguments || {};
const category = this.toolIndex.get(toolName);
if (!category) {
response = {
jsonrpc: "2.0",
id: rpc.id,
error: { code: -32602, message: `Unknown tool: ${toolName}` },
};
} else {
try {
const start = Date.now();
console.log(`[cocos-creator-mcp] ▶ ${toolName}`, Object.keys(args).length > 0 ? JSON.stringify(args).substring(0, 200) : "");
const timeoutMs = (toolName.startsWith("prefab_") || toolName === "scene_open") ? 120000 : 30000;
const result = await withTimeout(category.execute(toolName, args), timeoutMs, `Tool ${toolName} timed out`);
console.log(`[cocos-creator-mcp] ✓ ${toolName} (${Date.now() - start}ms)`);
response = {
jsonrpc: "2.0",
id: rpc.id,
result,
};
} catch (e: any) {
console.error(`[cocos-creator-mcp] ✗ ${toolName}:`, e.message || String(e));
response = {
jsonrpc: "2.0",
id: rpc.id,
error: { code: -32603, message: e.message || String(e) },
};
}
}
break;
}
default:
response = {
jsonrpc: "2.0",
id: rpc.id,
error: { code: -32601, message: `Method not found: ${rpc.method}` },
};
}
if (wantSse) {
this.sendSse(res, [response]);
} else {
this.sendJsonRpc(res, response);
}
}
private sendJsonRpc(res: http.ServerResponse, data: JsonRpcResponse): void {
res.writeHead(200, {
"Content-Type": "application/json",
"Mcp-Session-Id": SESSION_ID,
});
res.end(JSON.stringify(data));
}
private sendSse(res: http.ServerResponse, messages: JsonRpcResponse[]): void {
res.writeHead(200, {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
"Mcp-Session-Id": SESSION_ID,
});
for (const msg of messages) {
res.write(`event: message\ndata: ${JSON.stringify(msg)}\n\n`);
}
res.end();
}
}
function withTimeout<T>(promise: Promise<T>, ms: number, message: string): Promise<T> {
return new Promise((resolve, reject) => {
const timer = setTimeout(() => reject(new Error(message)), ms);
promise.then(
(v) => { clearTimeout(timer); resolve(v); },
(e) => { clearTimeout(timer); reject(e); },
);
});
}
function readBody(req: http.IncomingMessage): Promise<string> {
return new Promise((resolve, reject) => {
const chunks: Buffer[] = [];
req.on("data", (c) => chunks.push(c));
req.on("end", () => resolve(Buffer.concat(chunks).toString("utf-8")));
req.on("error", reject);
});
}
@@ -0,0 +1,36 @@
/**
* Shared node UUID resolution — resolves nodeName to UUID via scene script.
*/
const EXT_NAME = "cocos-creator-mcp";
export interface ResolvedNode {
uuid: string;
name: string;
}
/**
* Resolve a node UUID from either `uuid` or `nodeName` parameter.
* If `uuid` is provided, it is returned as-is.
* If `nodeName` is provided, searches the scene for a matching node.
* Throws if neither is provided or no node is found.
*/
export async function resolveNodeUuid(args: { uuid?: string; nodeName?: string }): Promise<ResolvedNode> {
if (args.uuid) {
return { uuid: args.uuid, name: "" };
}
if (!args.nodeName) {
throw new Error("Either 'uuid' or 'nodeName' is required");
}
const result = await Editor.Message.request("scene", "execute-scene-script", {
name: EXT_NAME,
method: "findNodesByName",
args: [args.nodeName],
});
if (!result?.success || !result?.data?.length) {
throw new Error(`Node not found: "${args.nodeName}"`);
}
// 最初のマッチを使用
const node = result.data[0];
return { uuid: node.uuid, name: node.name || args.nodeName };
}
@@ -0,0 +1,117 @@
const { createApp } = require("vue");
const panelDataMap = new WeakMap<any, any>();
module.exports = Editor.Panel.define({
template: `
<div id="app">
<h2>Cocos Creator MCP</h2>
<div class="status">
<span>Status: <strong :class="running ? 'on' : 'off'">{{ running ? 'Running' : 'Stopped' }}</strong></span>
</div>
<div class="port-row">
<label>Port:</label>
<input type="number" v-model.number="editPort" :disabled="running" min="1024" max="65535" />
<ui-button v-if="!running && editPort !== port" @confirm="applyPort" class="small-btn">Apply</ui-button>
</div>
<div class="actions">
<ui-button v-if="!running" @confirm="start">Start Server</ui-button>
<ui-button v-if="running" @confirm="stop">Stop Server</ui-button>
</div>
<div v-if="running" class="info">
<p>Endpoint: <code>http://127.0.0.1:{{ port }}/mcp</code></p>
<p>Tools: <strong>{{ toolCount }}</strong></p>
</div>
<div v-if="error" class="error">{{ error }}</div>
</div>
`,
style: `
#app { padding: 12px; font-family: sans-serif; color: #ccc; }
h2 { margin: 0 0 8px 0; font-size: 16px; }
.status { margin: 8px 0; }
.on { color: #4f4; }
.off { color: #f66; }
.port-row { margin: 8px 0; display: flex; align-items: center; gap: 8px; }
.port-row label { font-size: 12px; }
.port-row input { width: 80px; padding: 3px 6px; background: #222; color: #ccc; border: 1px solid #444; border-radius: 3px; font-size: 12px; }
.port-row input:disabled { opacity: 0.5; }
.actions { margin: 8px 0; }
.info { margin: 8px 0; padding: 8px; background: var(--color-normal-fill-emphasis); border-radius: 4px; }
.info p { margin: 4px 0; font-size: 12px; }
.info code { background: #333; padding: 2px 6px; border-radius: 3px; font-size: 11px; }
.error { margin: 8px 0; color: #f66; font-size: 12px; }
`,
$: { app: "#app" },
ready() {
if (!this.$.app) return;
const app = createApp({
data() {
return {
running: false,
port: 3000,
editPort: 3000,
toolCount: 0,
error: "",
};
},
methods: {
async start(this: any) {
try {
this.error = "";
const result = await Editor.Message.request("cocos-creator-mcp", "start-server");
this.running = result.running;
this.port = result.port;
this.editPort = result.port;
await this.refresh();
} catch (e: any) {
this.error = e.message || String(e);
}
},
async stop(this: any) {
try {
await Editor.Message.request("cocos-creator-mcp", "stop-server");
this.running = false;
this.toolCount = 0;
} catch (e: any) {
this.error = e.message || String(e);
}
},
async applyPort(this: any) {
try {
this.error = "";
const result = await Editor.Message.request("cocos-creator-mcp", "update-port", this.editPort);
this.port = result.port;
this.running = result.running;
if (this.running) await this.refresh();
} catch (e: any) {
this.error = e.message || String(e);
}
},
async refresh(this: any) {
try {
const status = await Editor.Message.request("cocos-creator-mcp", "get-server-status");
this.running = status.running;
this.port = status.port;
this.editPort = status.port;
this.toolCount = status.toolCount;
} catch (e: any) {
console.warn("[cocos-creator-mcp] refresh failed:", e);
}
},
},
async mounted(this: any) {
try {
await this.refresh();
} catch (e) {
console.warn("[cocos-creator-mcp] mounted refresh failed:", e);
}
},
});
app.mount(this.$.app);
panelDataMap.set(this, app);
},
close() {
const app = panelDataMap.get(this);
if (app) app.unmount();
},
});
@@ -0,0 +1,540 @@
const { createApp: createAppRec } = require("vue");
const panelDataMapRec = new WeakMap<any, any>();
module.exports = Editor.Panel.define({
template: `
<div id="app">
<h2>Preview Recorder</h2>
<div class="controls">
<button v-if="!recording" @click="start" class="btn btn-start">● 録画開始</button>
<button v-else @click="stop" class="btn btn-stop" :disabled="stopping">■ 録画停止{{ stopping ? '中...' : '' }}</button>
<button @click="screenshot" class="btn btn-shot" :disabled="shooting">📸 スクショ{{ shooting ? '中...' : '' }}</button>
</div>
<div v-if="recording" class="status-row">
<span class="rec-dot">●</span> REC <strong>{{ elapsed }}s</strong>
<span class="info">{{ recordingInfo }}</span>
</div>
<div class="section-title">録画設定</div>
<div class="row">
<label>FPS:</label>
<input type="number" v-model.number="fps" :disabled="recording" min="10" max="60" />
<label>形式:</label>
<select v-model="format" :disabled="recording">
<option value="mp4">MP4</option>
<option value="webm">WebM</option>
</select>
</div>
<div class="row">
<label>品質:</label>
<select v-model="quality" @change="onQualityChange" :disabled="recording">
<option value="low">低</option>
<option value="medium">中</option>
<option value="high">高</option>
<option value="ultra">最高</option>
<option value="custom">カスタム</option>
</select>
<label title="ビットレート = 幅 × 高さ × FPS × 品質係数">品質係数:</label>
<input type="number" v-model.number="coefficient" @input="onCoefChange"
:disabled="recording" min="0.01" max="2" step="0.01" class="custom-bitrate"
title="ビットレート = 幅 × 高さ × FPS × 品質係数" />
<button @click="resetQuality" class="btn btn-small" :disabled="recording" title="録画設定を初期値に戻す">↺</button>
</div>
<div class="section-title">スクショ設定</div>
<div class="row">
<label>形式:</label>
<select v-model="shotFormat" :disabled="shooting">
<option value="png">PNG</option>
<option value="webp">WebP</option>
</select>
</div>
<div class="section-title">保存先</div>
<div class="row">
<input type="text" v-model="savePath" :disabled="recording" class="path-input" placeholder="temp/recordings" />
<button @click="selectSaveFolder" class="btn btn-small" :disabled="recording">📁 選択</button>
<button @click="resetSavePath" class="btn btn-small" :disabled="recording" title="保存先を初期値に戻す">↺</button>
</div>
<div class="row">
<button @click="openSaveFolder" class="btn btn-small">📂 保存フォルダを開く</button>
<label class="checkbox-label" title="ONにすると、録画/スクショ保存時に24時間以上前のファイルを OLD_yyyyMM フォルダに自動移動します"><input type="checkbox" v-model="autoArchive" @change="onAutoArchiveChange" /> 古いファイルを自動整理</label>
<button class="btn btn-help" @click="showArchiveHelp = !showArchiveHelp" title="自動整理の説明">?</button>
</div>
<div v-if="showArchiveHelp" class="help-box">
<strong>自動整理について</strong><br>
ONにすると、録画やスクショを保存するたびに、24時間以上前の古いファイルを<br>
<code>OLD_yyyyMM/</code> フォルダ(例: OLD_202604/)に自動で移動します。<br>
保存フォルダ直下には直近のファイルだけが残り、整理された状態を保てます。
</div>
<div v-if="lastResult" class="result" :class="lastError ? 'error' : 'success'">
<div v-if="!lastError">
<strong>✓ {{ lastResult.kind === 'shot' ? 'スクショ保存' : '録画完了' }}</strong><br>
<code>{{ lastResult.path }}</code><br>
{{ (lastResult.size / 1024).toFixed(1) }} KB
</div>
<div v-else>
<strong>✗ エラー:</strong> {{ lastResult.error || lastResult.message || 'unknown' }}
</div>
</div>
<div class="note">
※ ゲームプレビュー実行中に使ってください(GameDebugClientが必要)
</div>
</div>
`,
style: `
#app { padding: 16px; font-family: sans-serif; color: #ccc; font-size: 12px; }
h2 { margin: 0 0 12px 0; font-size: 18px; }
.controls { margin: 12px 0; }
.btn {
padding: 10px 20px;
color: #fff;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
font-weight: bold;
}
.btn:disabled { background: #555; color: #999; cursor: not-allowed; }
.btn-start { background: #d44; }
.btn-start:hover { background: #e55; }
.btn-stop { background: #888; }
.btn-stop:hover { background: #999; }
.btn-shot { background: #468; margin-left: 8px; }
.btn-shot:hover { background: #579; }
.section-title {
margin-top: 14px;
margin-bottom: 4px;
padding: 4px 0 3px 0;
font-size: 11px;
font-weight: bold;
color: #8af;
border-bottom: 1px solid #333;
}
.btn-small {
padding: 4px 10px;
background: #4a8;
font-size: 11px;
margin-left: 8px;
font-weight: normal;
}
.btn-small:hover { background: #5b9; }
.status-row { margin: 10px 0; font-size: 14px; }
.rec-dot { color: #f44; animation: blink 1s infinite; }
@keyframes blink { 50% { opacity: 0.3; } }
.info { color: #888; font-size: 11px; margin-left: 8px; }
.row { margin: 10px 0; display: flex; align-items: center; gap: 8px; flex-wrap: nowrap; }
.row label { font-size: 12px; }
.row input { width: 60px; padding: 4px 8px; background: #222; color: #ccc; border: 1px solid #444; border-radius: 3px; }
.row select { padding: 4px 8px; background: #222; color: #ccc; border: 1px solid #444; border-radius: 3px; }
.path-input { flex: 1; min-width: 200px; width: auto !important; font-family: monospace; }
.custom-bitrate { width: 70px !important; }
.unit { font-size: 11px; color: #888; }
.result { margin: 12px 0; padding: 10px; border-radius: 4px; font-size: 12px; line-height: 1.5; }
.result.success { background: #1a3a1a; color: #afa; }
.result.error { background: #3a1a1a; color: #faa; }
.result code { font-size: 10px; word-break: break-all; background: #000; padding: 2px 4px; border-radius: 2px; }
.checkbox-label { font-size: 12px; display: flex; align-items: center; gap: 4px; margin-left: auto; cursor: pointer; }
.checkbox-label input[type="checkbox"] { cursor: pointer; }
.btn-help { padding: 2px 7px; background: #555; font-size: 11px; font-weight: bold; border-radius: 50%; min-width: 20px; margin-left: 4px; }
.btn-help:hover { background: #777; }
.help-box { margin: 8px 0; padding: 10px; background: #1a2a3a; border: 1px solid #345; border-radius: 4px; font-size: 11px; line-height: 1.6; color: #bcd; }
.help-box code { background: #000; padding: 1px 4px; border-radius: 2px; font-size: 10px; }
.note { margin-top: 16px; padding-top: 8px; border-top: 1px solid #333; font-size: 10px; color: #888; }
`,
$: { app: "#app" },
ready() {
if (!this.$.app) return;
const MCP_BASE = "http://127.0.0.1:3000";
const STORAGE_KEY = "cocos-mcp-recorder-settings";
const PERSISTED_KEYS = ["fps", "quality", "coefficient", "format", "savePath", "shotFormat"];
// localStorage から設定を読み込み
const loadSettings = () => {
try {
const raw = localStorage.getItem(STORAGE_KEY);
return raw ? JSON.parse(raw) : {};
} catch { return {}; }
};
const saved = loadSettings();
// プロジェクト設定から autoArchiveRecordings を読み込み
const loadProjectConfig = () => {
try {
const fs = require("fs");
const path = require("path");
const p = path.join(Editor.Project.path, "settings", "cocos-creator-mcp.json");
if (fs.existsSync(p)) return JSON.parse(fs.readFileSync(p, "utf-8"));
} catch { /* ignore */ }
return {};
};
const projectCfg = loadProjectConfig();
const app = createAppRec({
data() {
return {
recording: false,
stopping: false,
shooting: false,
elapsed: "0.0",
recordingInfo: "",
fps: saved.fps ?? 30,
quality: saved.quality ?? "medium",
coefficient: saved.coefficient ?? 0.25,
format: saved.format ?? "mp4",
savePath: saved.savePath ?? "temp/recordings",
shotFormat: saved.shotFormat ?? "png",
autoArchive: projectCfg.autoArchiveRecordings ?? false,
showArchiveHelp: false,
lastResult: null as any,
lastError: false,
_startTime: 0,
_timer: null as any,
_aliveCheckTimer: null as any,
};
},
watch: {
fps(this: any) { this.saveSettings(); },
quality(this: any) { this.saveSettings(); },
coefficient(this: any) { this.saveSettings(); },
format(this: any) { this.saveSettings(); },
savePath(this: any) { this.saveSettings(); },
shotFormat(this: any) { this.saveSettings(); },
},
methods: {
saveSettings(this: any) {
const data: any = {};
for (const key of PERSISTED_KEYS) data[key] = this[key];
try { localStorage.setItem(STORAGE_KEY, JSON.stringify(data)); } catch { /* ignore */ }
},
async isPreviewRunning(this: any): Promise<boolean> {
try {
const res = await fetch(`${MCP_BASE}/mcp`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
jsonrpc: "2.0", id: 98, method: "tools/call",
params: {
name: "debug_game_command",
arguments: { type: "inspect", args: { name: "Canvas" }, timeout: 1500 },
},
}),
});
const json = await res.json();
const content = json.result?.content?.[0]?.text;
const parsed = content ? JSON.parse(content) : null;
return !!parsed?.success;
} catch {
return false;
}
},
async start(this: any) {
this.lastResult = null;
if (!await this.isPreviewRunning()) {
this.lastResult = { error: "ゲームプレビューが実行されていません。プレビューを開始してから録画してください。" };
this.lastError = true;
return;
}
try {
const res = await fetch(`${MCP_BASE}/mcp`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
jsonrpc: "2.0",
id: 1,
method: "tools/call",
params: {
name: "debug_record_start",
arguments: {
fps: this.fps,
coefficient: this.coefficient,
format: this.format,
savePath: this.savePath,
},
},
}),
});
const json = await res.json();
const content = json.result?.content?.[0]?.text;
const parsed = content ? JSON.parse(content) : null;
if (parsed?.success && parsed.data?.id) {
this.recording = true;
const d = parsed.data;
const mbps = d?.videoBitsPerSecond ? (d.videoBitsPerSecond / 1_000_000).toFixed(2) : "?";
this.recordingInfo = `${d?.canvasWidth || "?"}x${d?.canvasHeight || "?"} @ ${d?.fps || "?"}fps / ${mbps}Mbps / ${d?.mimeType || ""}`;
this._startTime = Date.now();
this._timer = setInterval(() => {
this.elapsed = ((Date.now() - this._startTime) / 1000).toFixed(1);
}, 100);
// プレビュー停止検知用ポーリング(2秒毎)
this._aliveCheckTimer = setInterval(() => {
this.checkPreviewAlive();
}, 2000);
} else {
// 可能な限り詳細なエラー情報を表示
const errDetail = parsed?.data?.error
|| parsed?.error
|| (parsed?.data ? JSON.stringify(parsed.data) : null)
|| (parsed ? JSON.stringify(parsed).substring(0, 200) : "no response")
|| "録画開始失敗";
this.lastResult = { error: errDetail };
this.lastError = true;
}
} catch (e: any) {
this.lastResult = { error: `通信エラー: ${e.message}` };
this.lastError = true;
}
},
async stop(this: any) {
this.stopping = true;
try {
const res = await fetch(`${MCP_BASE}/mcp`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
jsonrpc: "2.0",
id: 2,
method: "tools/call",
params: {
name: "debug_record_stop",
arguments: { timeout: 60000 },
},
}),
});
const json = await res.json();
const content = json.result?.content?.[0]?.text;
const parsed = content ? JSON.parse(content) : null;
if (parsed?.success && parsed.data?.path) {
this.lastResult = { path: parsed.data.path, size: parsed.data.size };
this.lastError = false;
} else {
const errDetail = parsed?.data?.error
|| parsed?.error
|| (parsed?.data ? JSON.stringify(parsed.data) : null)
|| "録画停止失敗";
this.lastResult = { error: errDetail };
this.lastError = true;
}
} catch (e: any) {
this.lastResult = { error: `通信エラー: ${e.message}` };
this.lastError = true;
} finally {
this.recording = false;
this.stopping = false;
if (this._timer) { clearInterval(this._timer); this._timer = null; }
if (this._aliveCheckTimer) { clearInterval(this._aliveCheckTimer); this._aliveCheckTimer = null; }
}
},
onQualityChange(this: any) {
const map: Record<string, number> = { low: 0.15, medium: 0.25, high: 0.40, ultra: 0.60 };
if (this.quality !== "custom") this.coefficient = map[this.quality];
},
onCoefChange(this: any) {
const map: Record<number, string> = { 0.15: "low", 0.25: "medium", 0.40: "high", 0.60: "ultra" };
this.quality = map[this.coefficient] || "custom";
},
resetQuality(this: any) {
this.fps = 30;
this.quality = "medium";
this.coefficient = 0.25;
this.format = "mp4";
},
resetSavePath(this: any) {
this.savePath = "temp/recordings";
},
onAutoArchiveChange(this: any) {
try {
const fs = require("fs");
const path = require("path");
const p = path.join(Editor.Project.path, "settings", "cocos-creator-mcp.json");
let cfg: any = {};
if (fs.existsSync(p)) cfg = JSON.parse(fs.readFileSync(p, "utf-8"));
cfg.autoArchiveRecordings = this.autoArchive;
const dir = path.dirname(p);
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(p, JSON.stringify(cfg, null, 2), "utf-8");
} catch (e) {
console.warn("[recorder] 設定保存失敗:", e);
}
},
async screenshot(this: any) {
this.shooting = true;
if (!await this.isPreviewRunning()) {
this.lastResult = { error: "ゲームプレビューが実行されていません。プレビューを開始してからスクショしてください。" };
this.lastError = true;
this.shooting = false;
return;
}
try {
const res = await fetch(`${MCP_BASE}/mcp`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
jsonrpc: "2.0",
id: 3,
method: "tools/call",
params: {
name: "debug_game_command",
arguments: { type: "screenshot", args: {}, timeout: 5000, maxWidth: 0, imageFormat: this.shotFormat },
},
}),
});
const json = await res.json();
const content = json.result?.content?.[0]?.text;
const parsed = content ? JSON.parse(content) : null;
if (parsed?.success && parsed.path) {
// savePath 配下にコピー
const fs = require("fs");
const path = require("path");
const projectPath = Editor.Project.path;
let destDir = this.savePath || "temp/recordings";
if (!path.isAbsolute(destDir)) destDir = path.join(projectPath, destDir);
if (!fs.existsSync(destDir)) fs.mkdirSync(destDir, { recursive: true });
const ts = new Date().toISOString().replace(/[:.]/g, "-");
const ext = path.extname(parsed.path) || ".png";
const destPath = path.join(destDir, `screenshot_${ts}${ext}`);
fs.copyFileSync(parsed.path, destPath);
// 設定に応じて古いファイルをアーカイブ
try {
const settingsPath = path.join(projectPath, "settings", "cocos-creator-mcp.json");
if (fs.existsSync(settingsPath)) {
const cfg = JSON.parse(fs.readFileSync(settingsPath, "utf-8"));
if (cfg.autoArchiveRecordings) {
const { archiveOldFiles } = require("../../archive");
archiveOldFiles(destDir);
}
}
} catch { /* ignore */ }
this.lastResult = { kind: "shot", path: destPath, size: parsed.size };
this.lastError = false;
} else {
const errDetail = parsed?.error || parsed?.message || "スクショ失敗";
this.lastResult = { error: errDetail };
this.lastError = true;
}
} catch (e: any) {
this.lastResult = { error: `通信エラー: ${e.message}` };
this.lastError = true;
} finally {
this.shooting = false;
}
},
async checkPreviewAlive(this: any) {
if (!this.recording) return;
try {
// MCP経由で軽量なinspectコマンドを送る
const res = await fetch(`${MCP_BASE}/mcp`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
jsonrpc: "2.0", id: 99, method: "tools/call",
params: {
name: "debug_game_command",
arguments: { type: "inspect", args: { name: "Canvas" }, timeout: 1500 },
},
}),
});
const json = await res.json();
const content = json.result?.content?.[0]?.text;
const parsed = content ? JSON.parse(content) : null;
if (parsed?.success) {
// プレビュー生存
return;
}
// 応答なし → プレビュー停止とみなして録画停止状態に
console.warn("[PreviewRecorder] preview not responding, stopping recording state");
this.recording = false;
if (this._timer) { clearInterval(this._timer); this._timer = null; }
if (this._aliveCheckTimer) { clearInterval(this._aliveCheckTimer); this._aliveCheckTimer = null; }
this.lastResult = { error: "プレビューが停止したため録画を中断しました(動画は保存されません)" };
this.lastError = true;
} catch (e) {
// ネットワークエラーは通信エラーとして無視(一時的かもしれない)
}
},
async selectSaveFolder(this: any) {
try {
const result = await (Editor.Dialog as any).select({
title: "保存先フォルダを選択",
type: "directory",
multi: false,
});
if (result?.filePaths?.length) {
const path = require("path");
const projectPath = Editor.Project.path;
const absPath = result.filePaths[0];
// プロジェクト配下なら相対パスで保持
const relPath = path.relative(projectPath, absPath);
if (!relPath.startsWith("..") && !path.isAbsolute(relPath)) {
this.savePath = relPath.replace(/\\/g, "/");
} else {
this.savePath = absPath;
}
}
} catch (e: any) {
console.error("[PreviewRecorder] selectSaveFolder failed:", e);
}
},
openSaveFolder(this: any) {
const path = require("path");
const fs = require("fs");
const projectPath = Editor.Project.path;
let dir = this.savePath || "temp/recordings";
if (!path.isAbsolute(dir)) dir = path.join(projectPath, dir);
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
try {
const { spawn } = require("child_process");
const platform = process.platform;
const [cmd, ...args] = platform === "win32"
? ["explorer.exe", dir.replace(/\//g, "\\")]
: platform === "darwin"
? ["open", dir]
: ["xdg-open", dir];
const p = spawn(cmd, args, { detached: true, stdio: "ignore" });
p.unref();
} catch (e: any) {
console.error("[PreviewRecorder] openSaveFolder failed:", e);
this.lastResult = { error: `フォルダを開けませんでした: ${e.message}` };
this.lastError = true;
}
},
openFolder(this: any) {
if (!this.lastResult?.path) return;
const filePath = this.lastResult.path;
try {
const { shell } = require("electron");
if (shell?.showItemInFolder) {
shell.showItemInFolder(filePath);
return;
}
} catch (e) { /* fallback */ }
// フォールバック: OS別コマンド
try {
const { exec } = require("child_process");
const platform = process.platform;
if (platform === "win32") {
exec(`explorer.exe /select,"${filePath.replace(/\//g, "\\")}"`);
} else if (platform === "darwin") {
exec(`open -R "${filePath}"`);
} else {
const dir = require("path").dirname(filePath);
exec(`xdg-open "${dir}"`);
}
} catch (e: any) {
console.error("[PreviewRecorder] openFolder failed:", e);
}
},
},
});
app.mount(this.$.app);
panelDataMapRec.set(this, app);
},
beforeClose() { },
close() {
const app = panelDataMapRec.get(this);
if (app) app.unmount();
panelDataMapRec.delete(this);
},
});
@@ -0,0 +1,509 @@
/**
* Scene script — runs inside CocosCreator's scene renderer process.
* These methods are called via Editor.Message.request('scene', 'execute-scene-script', ...)
* and have access to the `cc` module (engine runtime).
*/
import { join } from "path";
module.paths.push(join(Editor.App.path, "node_modules"));
// ─── Console Log Buffer ───
interface ConsoleLogEntry {
timestamp: string;
level: "log" | "warn" | "error";
message: string;
}
const MAX_LOG_BUFFER = 500;
const _consoleLogs: ConsoleLogEntry[] = [];
const _originalLog = console.log;
const _originalWarn = console.warn;
const _originalError = console.error;
function formatArgs(args: any[]): string {
return args.map(a => {
if (typeof a === "string") return a;
try { return JSON.stringify(a); } catch { return String(a); }
}).join(" ");
}
function pushLog(level: ConsoleLogEntry["level"], args: any[]): void {
_consoleLogs.push({
timestamp: new Date().toISOString(),
level,
message: formatArgs(args),
});
if (_consoleLogs.length > MAX_LOG_BUFFER) {
_consoleLogs.splice(0, _consoleLogs.length - MAX_LOG_BUFFER);
}
}
console.log = function (...args: any[]) {
_originalLog.apply(console, args);
pushLog("log", args);
};
console.warn = function (...args: any[]) {
_originalWarn.apply(console, args);
pushLog("warn", args);
};
console.error = function (...args: any[]) {
_originalError.apply(console, args);
pushLog("error", args);
};
function getScene() {
const { director } = require("cc");
return director.getScene();
}
function findNode(uuid: string) {
const scene = getScene();
if (!scene) return null;
// Recursive search — getChildByUuid is not recursive in cc
const queue = [...scene.children];
while (queue.length > 0) {
const node = queue.shift()!;
if (node.uuid === uuid) return node;
if (node.children) queue.push(...node.children);
}
return null;
}
/**
* Recursively build a node tree from a JSON spec.
* Returns { uuid, name, children: [...] }
*/
function buildNodeRecursive(parent: any, spec: any): any {
const { Node, js, Vec3 } = require("cc");
const node = new Node(spec.name || "Node");
parent.addChild(node);
// Add components
if (spec.components && Array.isArray(spec.components)) {
const { Sprite, Label } = require("cc");
for (const compName of spec.components) {
const CompClass = js.getClassByName(compName);
if (CompClass) {
if (!node.getComponent(CompClass)) {
const comp = node.addComponent(CompClass);
// Sprite: sizeMode=CUSTOM でUITransformサイズの上書きを防ぐ
if (comp instanceof Sprite) {
comp.sizeMode = 0; // SizeMode.CUSTOM
}
// Label: useSystemFont + 色を黒に(白パネル上で見えるように)
if (comp instanceof Label) {
comp.useSystemFont = true;
const { Color } = require("cc");
comp.color = new Color(51, 51, 51, 255);
}
}
}
}
}
// Set component properties
// Format: { "cc.UITransform.contentSize": {width:720, height:1280} }
if (spec.properties) {
for (const [key, value] of Object.entries(spec.properties)) {
const dotIdx = key.lastIndexOf(".");
if (dotIdx < 0) continue;
const compName = key.substring(0, dotIdx);
const propName = key.substring(dotIdx + 1);
const CompClass = js.getClassByName(compName);
if (!CompClass) continue;
const comp = node.getComponent(CompClass);
if (!comp) continue;
try {
// contentSize needs special handling (Size type)
if (propName === "contentSize" && value && typeof value === "object") {
comp.setContentSize((value as any).width ?? 0, (value as any).height ?? 0);
} else {
comp[propName] = value;
}
} catch { /* skip invalid property */ }
}
}
// Set UITransform anchorPoint if specified
if (spec.anchorPoint) {
const { UITransform } = require("cc");
const ut = node.getComponent(UITransform);
if (ut) ut.setAnchorPoint(spec.anchorPoint.x ?? 0.5, spec.anchorPoint.y ?? 0.5);
}
// Set node properties (position, scale, active)
if (spec.active === false) node.active = false;
if (spec.position) node.setPosition(spec.position.x || 0, spec.position.y || 0, spec.position.z || 0);
if (spec.scale) node.setScale(spec.scale.x ?? 1, spec.scale.y ?? 1, spec.scale.z ?? 1);
// Set Widget properties if specified
// Format: { top: 0, bottom: 0, left: 0, right: 0 } — each field enables the corresponding alignment
if (spec.widget) {
const { Widget } = require("cc");
let w = node.getComponent(Widget);
if (!w) w = node.addComponent(Widget);
const wSpec = spec.widget;
if (wSpec.top !== undefined) { w.isAlignTop = true; w.top = wSpec.top; }
if (wSpec.bottom !== undefined) { w.isAlignBottom = true; w.bottom = wSpec.bottom; }
if (wSpec.left !== undefined) { w.isAlignLeft = true; w.left = wSpec.left; }
if (wSpec.right !== undefined) { w.isAlignRight = true; w.right = wSpec.right; }
if (wSpec.horizontalCenter !== undefined) { w.isAlignHorizontalCenter = true; w.horizontalCenter = wSpec.horizontalCenter; }
if (wSpec.verticalCenter !== undefined) { w.isAlignVerticalCenter = true; w.verticalCenter = wSpec.verticalCenter; }
}
// Build children
const childResults: any[] = [];
if (spec.children && Array.isArray(spec.children)) {
for (const childSpec of spec.children) {
childResults.push(buildNodeRecursive(node, childSpec));
}
}
return { uuid: node.uuid, name: node.name, children: childResults };
}
function collectNodeInfo(node: any, includeComponents: boolean = false): any {
const info: any = {
uuid: node.uuid,
name: node.name,
active: node.active,
position: { x: node.position.x, y: node.position.y, z: node.position.z },
scale: { x: node.scale.x, y: node.scale.y, z: node.scale.z },
parent: node.parent?.uuid || null,
childCount: node.children?.length || 0,
};
if (includeComponents && node.components) {
info.components = node.components.map((c: any) => ({
type: c.constructor.name,
uuid: c.uuid,
enabled: c.enabled,
}));
}
return info;
}
export const methods: Record<string, (...args: any[]) => any> = {
getSceneHierarchy(includeComponents: boolean = false) {
try {
const scene = getScene();
if (!scene) return { success: false, error: "No active scene" };
const walk = (node: any): any => {
const item: any = {
uuid: node.uuid,
name: node.name,
active: node.active,
children: [],
};
if (includeComponents && node.components) {
item.components = node.components.map((c: any) => ({
type: c.constructor.name,
uuid: c.uuid,
enabled: c.enabled,
}));
}
if (node.children) {
item.children = node.children.map((ch: any) => walk(ch));
}
return item;
};
return {
success: true,
sceneName: scene.name,
sceneUuid: scene.uuid,
hierarchy: scene.children.map((ch: any) => walk(ch)),
};
} catch (e: any) {
return { success: false, error: e.message };
}
},
getNodeInfo(uuid: string) {
try {
const node = findNode(uuid);
if (!node) return { success: false, error: `Node ${uuid} not found` };
return { success: true, data: collectNodeInfo(node, true) };
} catch (e: any) {
return { success: false, error: e.message };
}
},
getAllNodes() {
try {
const scene = getScene();
if (!scene) return { success: false, error: "No active scene" };
const nodes: any[] = [];
const walk = (node: any) => {
nodes.push(collectNodeInfo(node));
if (node.children) node.children.forEach(walk);
};
scene.children.forEach(walk);
return { success: true, data: nodes };
} catch (e: any) {
return { success: false, error: e.message };
}
},
/**
* 指定ノードの子孫から名前で検索する。auto_bind 用。
* rootUuid が空の場合はシーン全体を検索(後方互換)。
*/
findDescendantsByName(rootUuid: string, name: string) {
try {
let root: any;
if (rootUuid) {
root = findNode(rootUuid);
if (!root) return { success: false, error: `Root node ${rootUuid} not found` };
} else {
root = getScene();
if (!root) return { success: false, error: "No active scene" };
}
const results: any[] = [];
const walk = (node: any) => {
if (node.name === name) results.push(collectNodeInfo(node));
if (node.children) node.children.forEach(walk);
};
if (root.children) root.children.forEach(walk);
return { success: true, data: results };
} catch (e: any) {
return { success: false, error: e.message };
}
},
/**
* 指定ノードの全子孫を depth 付きで返す。auto_bind の一括検索用。
*/
getAllDescendants(rootUuid: string) {
try {
const root = findNode(rootUuid);
if (!root) return { success: false, error: `Node ${rootUuid} not found` };
const results: Array<{uuid: string, name: string, depth: number}> = [];
const walk = (node: any, depth: number) => {
results.push({ uuid: node.uuid, name: node.name, depth });
if (node.children) node.children.forEach((c: any) => walk(c, depth + 1));
};
if (root.children) root.children.forEach((c: any) => walk(c, 1));
return { success: true, data: results };
} catch (e: any) {
return { success: false, error: e.message };
}
},
findNodesByName(name: string) {
try {
const scene = getScene();
if (!scene) return { success: false, error: "No active scene" };
const results: any[] = [];
const walk = (node: any) => {
if (node.name === name) results.push(collectNodeInfo(node));
if (node.children) node.children.forEach(walk);
};
scene.children.forEach(walk);
return { success: true, data: results };
} catch (e: any) {
return { success: false, error: e.message };
}
},
findNodeByPath(path: string) {
try {
const scene = getScene();
if (!scene) return { success: false, error: "No active scene" };
const parts = path.split("/");
let current: any = null;
const walk = (node: any): any => {
if (node.name === parts[0]) return node;
if (node.children) {
for (const child of node.children) {
const found = walk(child);
if (found) return found;
}
}
return null;
};
for (const child of scene.children) {
current = walk(child);
if (current) break;
}
if (!current) return { success: false, error: `Node "${parts[0]}" not found` };
for (let i = 1; i < parts.length; i++) {
const child = current.children?.find((c: any) => c.name === parts[i]);
if (!child) return { success: false, error: `Child "${parts[i]}" not found in "${current.name}"` };
current = child;
}
return { success: true, data: collectNodeInfo(current) };
} catch (e: any) {
return { success: false, error: e.message };
}
},
setNodeProperty(uuid: string, property: string, value: any) {
try {
const node = findNode(uuid);
if (!node) return { success: false, error: `Node ${uuid} not found` };
switch (property) {
case "position":
node.setPosition(value.x ?? 0, value.y ?? 0, value.z ?? 0);
break;
case "rotation":
node.setRotationFromEuler(value.x ?? 0, value.y ?? 0, value.z ?? 0);
break;
case "scale":
node.setScale(value.x ?? 1, value.y ?? 1, value.z ?? 1);
break;
case "active":
node.active = !!value;
break;
case "name":
node.name = String(value);
break;
default:
(node as any)[property] = value;
}
return { success: true };
} catch (e: any) {
return { success: false, error: e.message };
}
},
setComponentProperty(uuid: string, componentType: string, property: string, value: any) {
try {
const { js } = require("cc");
const node = findNode(uuid);
if (!node) return { success: false, error: `Node ${uuid} not found` };
const CompClass = js.getClassByName(componentType);
if (!CompClass) return { success: false, error: `Component class ${componentType} not found` };
const comp = node.getComponent(CompClass);
if (!comp) return { success: false, error: `Component ${componentType} not on node ${uuid}` };
comp[property] = value;
return { success: true };
} catch (e: any) {
return { success: false, error: e.message };
}
},
addComponentToNode(uuid: string, componentType: string) {
try {
const { js } = require("cc");
const node = findNode(uuid);
if (!node) return { success: false, error: `Node ${uuid} not found` };
const CompClass = js.getClassByName(componentType);
if (!CompClass) return { success: false, error: `Component class ${componentType} not found` };
const comp = node.addComponent(CompClass);
return { success: true, data: { uuid: comp.uuid, type: componentType } };
} catch (e: any) {
return { success: false, error: e.message };
}
},
moveNode(uuid: string, parentUuid: string) {
try {
const node = findNode(uuid);
if (!node) return { success: false, error: `Node ${uuid} not found` };
const parent = findNode(parentUuid);
if (!parent) return { success: false, error: `Parent ${parentUuid} not found` };
node.setParent(parent);
return { success: true };
} catch (e: any) {
return { success: false, error: e.message };
}
},
/**
* Build a node tree from a JSON spec in one call.
* Spec format:
* {
* name: string,
* components?: string[], // e.g. ["cc.UITransform", "cc.Layout"]
* properties?: Record<string, any>, // e.g. { "cc.UITransform.contentSize": {width:720,height:1280} }
* children?: NodeSpec[]
* }
*/
buildNodeTree(parentUuid: string, spec: any) {
try {
const { Node, js, UITransform } = require("cc");
const parent = findNode(parentUuid);
if (!parent) return { success: false, error: `Parent ${parentUuid} not found` };
const result = buildNodeRecursive(parent, spec);
return { success: true, data: result };
} catch (e: any) {
return { success: false, error: e.message };
}
},
testLog(message: string = "test message") {
console.log("[testLog]", message);
const methods = Object.keys(exports.methods || {});
return { success: true, bufferSize: _consoleLogs.length, methods };
},
getConsoleLogs(count: number = 50, level?: string) {
let logs = _consoleLogs;
if (level) {
logs = logs.filter(l => l.level === level);
}
return { success: true, logs: logs.slice(-count), total: _consoleLogs.length };
},
clearConsoleLogs() {
_consoleLogs.length = 0;
return { success: true };
},
async setPropertyViaEditor(nodeUuid: string, path: string, dump: any) {
try {
// scene:set-property API
// uuid: ノードUUID(コンポーネントUUIDではない)
// path: __comps__.{index}.{property} 形式
// dump: { value, type } 形式
const opts = { uuid: nodeUuid, path, dump };
const result = await (Editor as any).Message.request("scene", "set-property", opts);
return { success: true, result };
} catch (e: any) {
return { success: false, error: e.message || String(e) };
}
},
removeComponentFromNode(uuid: string, componentType: string) {
try {
const { js } = require("cc");
const node = findNode(uuid);
if (!node) return { success: false, error: `Node ${uuid} not found` };
const CompClass = js.getClassByName(componentType);
if (!CompClass) return { success: false, error: `Component class ${componentType} not found` };
const comp = node.getComponent(CompClass);
if (!comp) return { success: false, error: `Component ${componentType} not on node` };
node.removeComponent(comp);
return { success: true };
} catch (e: any) {
return { success: false, error: e.message };
}
},
};
@@ -0,0 +1,100 @@
/**
* Shared screenshot utility — used by debug-tools and other tools that need screenshots.
*/
export interface ProcessedImage {
buffer: Buffer;
width: number;
height: number;
format: string;
}
export interface ScreenshotResult {
success: boolean;
path: string;
size: number;
format: string;
originalSize: string;
savedSize: string;
}
export async function processImage(pngBuffer: Buffer, maxWidth: number, desiredFormat?: string): Promise<ProcessedImage> {
try {
const Vips = require("wasm-vips");
const vips = await Vips();
let image = vips.Image.newFromBuffer(pngBuffer);
if (maxWidth > 0 && image.width > maxWidth) {
image = image.thumbnailImage(maxWidth);
}
if (desiredFormat === "png") {
const pngOut = image.pngsaveBuffer();
const result = { buffer: Buffer.from(pngOut), width: image.width, height: image.height, format: "png" };
image.delete();
return result;
}
const outBuf = image.webpsaveBuffer({ Q: 85 });
const result = { buffer: Buffer.from(outBuf), width: image.width, height: image.height, format: "webp" };
image.delete();
return result;
} catch {
// Fallback: NativeImage resize + JPEG
const electron = require("electron");
let image = electron.nativeImage.createFromBuffer(pngBuffer);
if (maxWidth > 0) {
const size = image.getSize();
if (size.width > maxWidth) {
const ratio = maxWidth / size.width;
image = image.resize({ width: Math.round(size.width * ratio), height: Math.round(size.height * ratio) });
}
}
const size = image.getSize();
const buffer = image.toJPEG(85);
return { buffer, width: size.width, height: size.height, format: "jpeg" };
}
}
export async function takeEditorScreenshot(savePath?: string | undefined, maxWidth?: number): Promise<ScreenshotResult> {
const fs = require("fs");
const path = require("path");
const electron = require("electron");
const windows = electron.BrowserWindow.getAllWindows();
if (!windows || windows.length === 0) {
throw new Error("No editor window found");
}
// Find the main (largest) window
let win = windows[0];
let maxArea = 0;
for (const w of windows) {
const bounds = w.getBounds();
const area = bounds.width * bounds.height;
if (area > maxArea) {
maxArea = area;
win = w;
}
}
// Bring to front and wait for render
win.show();
await new Promise(r => setTimeout(r, 300));
const nativeImage = await win.webContents.capturePage();
const originalSize = nativeImage.getSize();
const pngBuffer = nativeImage.toPNG();
const effectiveMaxWidth = maxWidth !== undefined ? maxWidth : 960;
const { buffer, width, height, format } = await processImage(pngBuffer, effectiveMaxWidth);
const ext = format === "webp" ? "webp" : format === "jpeg" ? "jpg" : "png";
if (!savePath) {
const dir = path.join(Editor.Project.tmpDir, "screenshots");
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
savePath = path.join(dir, `screenshot_${timestamp}.${ext}`);
}
const finalPath = savePath!;
fs.writeFileSync(finalPath, buffer);
return {
success: true, path: finalPath, size: buffer.length, format,
originalSize: `${originalSize.width}x${originalSize.height}`,
savedSize: `${width}x${height}`,
};
}
@@ -0,0 +1,38 @@
import { ToolResult } from "./types";
/** Helper to create a successful text result */
export function ok(data: any): ToolResult {
return {
content: [{ type: "text", text: JSON.stringify(data, null, 2) }],
};
}
/** Helper to create an error result */
export function err(message: string): ToolResult {
return {
content: [{ type: "text", text: JSON.stringify({ error: message }) }],
isError: true,
};
}
/** Validate that a string looks like a CocosCreator UUID (not empty, reasonable format) */
export function validateUuid(uuid: string, label: string = "uuid"): string | null {
if (!uuid || typeof uuid !== "string") {
return `${label} is required`;
}
if (uuid.trim().length === 0) {
return `${label} cannot be empty`;
}
// CocosCreator UUIDs: either standard format (8-4-4-4-12) or compressed (22 chars with +/=)
// Be permissive — just reject obviously wrong values
if (uuid.length < 10) {
return `${label} "${uuid}" is too short to be a valid UUID`;
}
return null; // valid
}
/** Validate UUID and return err() if invalid, null if valid */
export function checkUuid(uuid: string, label: string = "uuid"): ToolResult | null {
const error = validateUuid(uuid, label);
return error ? err(error) : null;
}
@@ -0,0 +1,290 @@
import { ToolCategory, ToolDefinition, ToolResult } from "../types";
import { ok, err } from "../tool-base";
export class AssetTools implements ToolCategory {
readonly categoryName = "asset";
getTools(): ToolDefinition[] {
return [
{
name: "asset_create",
description: "Create a new asset file in the project.",
inputSchema: {
type: "object",
properties: {
path: { type: "string", description: "db:// path for the new asset" },
content: { type: "string", description: "File content (for text-based assets)" },
},
required: ["path"],
},
},
{
name: "asset_delete",
description: "Delete an asset from the project.",
inputSchema: {
type: "object",
properties: {
path: { type: "string", description: "db:// path of the asset to delete" },
},
required: ["path"],
},
},
{
name: "asset_move",
description: "Move/rename an asset.",
inputSchema: {
type: "object",
properties: {
source: { type: "string", description: "Current db:// path" },
destination: { type: "string", description: "New db:// path" },
},
required: ["source", "destination"],
},
},
{
name: "asset_copy",
description: "Copy an asset to a new location.",
inputSchema: {
type: "object",
properties: {
source: { type: "string", description: "Source db:// path" },
destination: { type: "string", description: "Destination db:// path" },
},
required: ["source", "destination"],
},
},
{
name: "asset_save",
description: "Save an asset by UUID.",
inputSchema: {
type: "object",
properties: {
uuid: { type: "string", description: "Asset UUID" },
},
required: ["uuid"],
},
},
{
name: "asset_reimport",
description: "Re-import an asset to refresh it.",
inputSchema: {
type: "object",
properties: {
path: { type: "string", description: "db:// path of the asset" },
},
required: ["path"],
},
},
{
name: "asset_query_path",
description: "Get the file path for an asset UUID.",
inputSchema: {
type: "object",
properties: {
uuid: { type: "string", description: "Asset UUID" },
},
required: ["uuid"],
},
},
{
name: "asset_query_uuid",
description: "Get the UUID for an asset path.",
inputSchema: {
type: "object",
properties: {
path: { type: "string", description: "db:// path of the asset" },
},
required: ["path"],
},
},
{
name: "asset_query_url",
description: "Get the URL for an asset UUID.",
inputSchema: {
type: "object",
properties: {
uuid: { type: "string", description: "Asset UUID" },
},
required: ["uuid"],
},
},
{
name: "asset_get_details",
description: "Get detailed metadata for an asset (type, size, importer, etc.).",
inputSchema: {
type: "object",
properties: {
uuid: { type: "string", description: "Asset UUID" },
},
required: ["uuid"],
},
},
{
name: "asset_get_dependencies",
description: "Get all assets that a given asset depends on.",
inputSchema: {
type: "object",
properties: {
uuid: { type: "string", description: "Asset UUID" },
},
required: ["uuid"],
},
},
{
name: "asset_open_external",
description: "Open an asset in the system's default external editor.",
inputSchema: {
type: "object",
properties: {
path: { type: "string", description: "db:// path of the asset" },
},
required: ["path"],
},
},
{
name: "asset_import",
description: "Import an external file into the project assets.",
inputSchema: {
type: "object",
properties: {
source: { type: "string", description: "Source file path on disk" },
target: { type: "string", description: "Target db:// path in project" },
},
required: ["source", "target"],
},
},
{
name: "asset_save_meta",
description: "Save asset meta information (importer settings, etc.).",
inputSchema: {
type: "object",
properties: {
uuid: { type: "string", description: "Asset UUID" },
meta: { type: "string", description: "Meta JSON string" },
},
required: ["uuid", "meta"],
},
},
{
name: "asset_generate_available_url",
description: "Generate a non-conflicting asset URL (avoids name collisions).",
inputSchema: {
type: "object",
properties: {
url: { type: "string", description: "Desired db:// path" },
},
required: ["url"],
},
},
{
name: "asset_query_ready",
description: "Check if the asset database is ready.",
inputSchema: { type: "object", properties: {} },
},
// ── 以下、既存MCP未対応のEditor API ──
{
name: "asset_query_users",
description: "Find all assets that use/reference a given asset.",
inputSchema: {
type: "object",
properties: { uuid: { type: "string", description: "Asset UUID" } },
required: ["uuid"],
},
},
{
name: "asset_query_missing",
description: "Check if an asset has missing references.",
inputSchema: {
type: "object",
properties: { uuid: { type: "string", description: "Asset UUID" } },
required: ["uuid"],
},
},
];
}
async execute(toolName: string, args: Record<string, any>): Promise<ToolResult> {
try {
switch (toolName) {
case "asset_create":
await (Editor.Message.request as any)("asset-db", "create-asset", args.path, args.content || null);
return ok({ success: true, path: args.path });
case "asset_delete":
await (Editor.Message.request as any)("asset-db", "delete-asset", args.path);
return ok({ success: true, path: args.path });
case "asset_move":
await (Editor.Message.request as any)("asset-db", "move-asset", args.source, args.destination);
return ok({ success: true, source: args.source, destination: args.destination });
case "asset_copy":
await (Editor.Message.request as any)("asset-db", "copy-asset", args.source, args.destination);
return ok({ success: true, source: args.source, destination: args.destination });
case "asset_save":
await (Editor.Message.request as any)("asset-db", "save-asset", args.uuid);
return ok({ success: true, uuid: args.uuid });
case "asset_reimport":
await (Editor.Message.request as any)("asset-db", "reimport-asset", args.path);
return ok({ success: true, path: args.path });
case "asset_query_path": {
const path = await (Editor.Message.request as any)("asset-db", "query-path", args.uuid);
return ok({ success: true, uuid: args.uuid, path });
}
case "asset_query_uuid": {
const uuid = await (Editor.Message.request as any)("asset-db", "query-uuid", args.path);
return ok({ success: true, path: args.path, uuid });
}
case "asset_query_url": {
const url = await (Editor.Message.request as any)("asset-db", "query-url", args.uuid);
return ok({ success: true, uuid: args.uuid, url });
}
case "asset_get_details": {
const info = await (Editor.Message.request as any)("asset-db", "query-asset-info", args.uuid);
const meta = await (Editor.Message.request as any)("asset-db", "query-asset-meta", args.uuid).catch(() => null);
return ok({ success: true, info, meta });
}
case "asset_get_dependencies": {
// Try multiple API names as they vary by CocosCreator version
let deps;
try {
deps = await (Editor.Message.request as any)("asset-db", "query-depends", args.uuid);
} catch {
try {
deps = await (Editor.Message.request as any)("asset-db", "query-asset-depends", args.uuid);
} catch {
deps = [];
}
}
return ok({ success: true, uuid: args.uuid, dependencies: deps });
}
case "asset_open_external":
await (Editor.Message.request as any)("asset-db", "open-asset", args.path);
return ok({ success: true, path: args.path });
case "asset_import":
await (Editor.Message.request as any)("asset-db", "import-asset", args.source, args.target);
return ok({ success: true, source: args.source, target: args.target });
case "asset_save_meta":
await (Editor.Message.request as any)("asset-db", "save-asset-meta", args.uuid, args.meta);
return ok({ success: true, uuid: args.uuid });
case "asset_generate_available_url": {
const url = await (Editor.Message.request as any)("asset-db", "generate-available-url", args.url);
return ok({ success: true, url });
}
case "asset_query_ready": {
const ready = await (Editor.Message.request as any)("asset-db", "query-ready");
return ok({ success: true, ready });
}
case "asset_query_users": {
const users = await (Editor.Message.request as any)("asset-db", "query-asset-users", args.uuid).catch(() => []);
return ok({ success: true, uuid: args.uuid, users });
}
case "asset_query_missing": {
const missing = await (Editor.Message.request as any)("asset-db", "query-missing-asset-info", args.uuid).catch(() => null);
return ok({ success: true, uuid: args.uuid, missing, hasMissing: !!missing });
}
default:
return err(`Unknown tool: ${toolName}`);
}
} catch (e: any) {
return err(e.message || String(e));
}
}
}
@@ -0,0 +1,64 @@
import { ToolCategory, ToolDefinition, ToolResult } from "../types";
import { ok, err } from "../tool-base";
export class BuilderTools implements ToolCategory {
readonly categoryName = "builder";
getTools(): ToolDefinition[] {
return [
{
name: "builder_open_panel",
description: "Open the Build panel in the editor.",
inputSchema: { type: "object", properties: {} },
},
{
name: "builder_get_settings",
description: "Get the current build settings/configuration.",
inputSchema: { type: "object", properties: {} },
},
{
name: "builder_query_tasks",
description: "Query active build tasks.",
inputSchema: { type: "object", properties: {} },
},
{
name: "builder_run_preview",
description: "Start the preview server for browser testing.",
inputSchema: { type: "object", properties: {} },
},
{
name: "builder_stop_preview",
description: "Stop the preview server.",
inputSchema: { type: "object", properties: {} },
},
];
}
async execute(toolName: string, _args: Record<string, any>): Promise<ToolResult> {
try {
switch (toolName) {
case "builder_open_panel":
Editor.Panel.open("builder");
return ok({ success: true });
case "builder_get_settings": {
const settings = await (Editor.Message.request as any)("builder", "query-build-options").catch(() => null);
return ok({ success: true, settings });
}
case "builder_query_tasks": {
const tasks = await (Editor.Message.request as any)("builder", "query-tasks").catch(() => []);
return ok({ success: true, tasks });
}
case "builder_run_preview":
await (Editor.Message.request as any)("preview", "start");
return ok({ success: true, message: "Preview started" });
case "builder_stop_preview":
await (Editor.Message.request as any)("preview", "stop");
return ok({ success: true, message: "Preview stopped" });
default:
return err(`Unknown tool: ${toolName}`);
}
} catch (e: any) {
return err(e.message || String(e));
}
}
}
@@ -0,0 +1,687 @@
import { ToolCategory, ToolDefinition, ToolResult } from "../types";
import { ok, err } from "../tool-base";
import { parseMaybeJson } from "../utils";
import { resolveNodeUuid } from "../node-resolve";
import { takeEditorScreenshot } from "../screenshot";
const EXT_NAME = "cocos-creator-mcp";
export class ComponentTools implements ToolCategory {
readonly categoryName = "component";
getTools(): ToolDefinition[] {
return [
{
name: "component_add",
description: "Add a component to a node. Use cc.XXX format (e.g. 'cc.Label', 'cc.Sprite', 'cc.Button').",
inputSchema: {
type: "object",
properties: {
uuid: { type: "string", description: "Node UUID" },
componentType: { type: "string", description: "Component class name (e.g. 'cc.Label')" },
},
required: ["uuid", "componentType"],
},
},
{
name: "component_remove",
description: "Remove a component from a node.",
inputSchema: {
type: "object",
properties: {
uuid: { type: "string", description: "Node UUID" },
componentType: { type: "string", description: "Component class name to remove" },
},
required: ["uuid", "componentType"],
},
},
{
name: "component_get_components",
description: "Get all components on a node with their properties.",
inputSchema: {
type: "object",
properties: {
uuid: { type: "string", description: "Node UUID (either uuid or nodeName required)" },
nodeName: { type: "string", description: "Node name to find (alternative to uuid)" },
},
},
},
{
name: "component_set_property",
description: "Set one or more properties on a component. For single: use property+value. For batch: use properties array. Use nodeName instead of uuid to find node by name. Set screenshot=true to capture editor screenshot after changes. Examples: Label.string, Label.fontSize, Sprite.color, UITransform.contentSize.",
inputSchema: {
type: "object",
properties: {
uuid: { type: "string", description: "Node UUID (either uuid or nodeName required)" },
nodeName: { type: "string", description: "Node name to find (alternative to uuid — avoids UUID lookup)" },
componentType: { type: "string", description: "Component class name (e.g. 'cc.Label')" },
property: { type: "string", description: "Property name (single mode)" },
value: { description: "Value to set (single mode)" },
properties: {
type: "array",
description: "Batch mode: array of {property, value} objects to set multiple properties at once",
items: {
type: "object",
properties: {
property: { type: "string", description: "Property name" },
value: { description: "Value to set" },
},
required: ["property", "value"],
},
},
screenshot: { type: "boolean", description: "If true, capture editor screenshot after setting properties and return the file path (default: false)" },
},
required: ["componentType"],
},
},
{
name: "component_get_info",
description: "Get detailed dump of a specific component by its UUID.",
inputSchema: {
type: "object",
properties: {
componentUuid: { type: "string", description: "Component UUID (not node UUID)" },
},
required: ["componentUuid"],
},
},
{
name: "component_get_available",
description: "List all available component classes that can be added to nodes.",
inputSchema: { type: "object", properties: {} },
},
{
name: "component_auto_bind",
description: "Automatically bind @property references by matching property names to descendant node names. Searches only descendants of the target node. Validates component type existence. Supports array properties (Slot_0, Slot_1...). Mode: 'fuzzy' (default) tries exact match first, then case-insensitive; 'strict' requires exact match only.",
inputSchema: {
type: "object",
properties: {
uuid: { type: "string", description: "Node UUID (either uuid or nodeName required)" },
nodeName: { type: "string", description: "Node name to find (alternative to uuid)" },
componentType: { type: "string", description: "Script component class name (e.g. 'QuestReadyPageView')" },
force: { type: "boolean", description: "If true, rebind even already-bound properties (default: false)" },
mode: { type: "string", enum: ["fuzzy", "strict"], description: "Matching mode: 'fuzzy' (default) or 'strict'" },
},
required: ["componentType"],
},
},
{
name: "component_query_enum",
description: "Get enum values for a component property. Useful for knowing what values Layout.type, Layout.resizeMode, etc. accept.",
inputSchema: {
type: "object",
properties: {
uuid: { type: "string", description: "Node UUID" },
componentType: { type: "string", description: "Component class (e.g. 'cc.Layout')" },
property: { type: "string", description: "Property name (e.g. 'type', 'resizeMode')" },
},
required: ["uuid", "componentType", "property"],
},
},
];
}
async execute(toolName: string, args: Record<string, any>): Promise<ToolResult> {
// パラメータエイリアス: component → componentType
const compType = args.componentType || args.component;
// nodeName → uuid 解決(対応ツールのみ)
const needsResolve = ["component_set_property", "component_get_components", "component_auto_bind"];
if (needsResolve.includes(toolName) && !args.uuid && args.nodeName) {
try {
const resolved = await resolveNodeUuid({ nodeName: args.nodeName });
args.uuid = resolved.uuid;
} catch (e: any) {
return err(e.message || String(e));
}
}
switch (toolName) {
case "component_add":
return this.addComponent(args.uuid, compType);
case "component_remove":
return this.removeComponent(args.uuid, compType);
case "component_get_components":
return this.getComponents(args.uuid);
case "component_set_property": {
const properties = parseMaybeJson(args.properties);
let result: ToolResult;
if (properties && Array.isArray(properties)) {
const parsed = properties.map((p: any) => ({ ...p, value: parseMaybeJson(p.value) }));
result = await this.setProperties(args.uuid, compType, parsed);
} else {
result = await this.setProperty(args.uuid, compType, args.property, parseMaybeJson(args.value));
}
// screenshot オプション
if (args.screenshot) {
try {
const ss = await takeEditorScreenshot();
const data = JSON.parse(result.content[0].text);
data.screenshot = { path: ss.path, size: ss.savedSize };
return ok(data);
} catch (ssErr: any) {
// スクショ失敗してもプロパティ設定結果は返す
const data = JSON.parse(result.content[0].text);
data.screenshotError = ssErr.message || String(ssErr);
return ok(data);
}
}
return result;
}
case "component_get_info": {
try {
const dump = await (Editor.Message.request as any)("scene", "query-component", args.componentUuid);
return ok({ success: true, component: dump });
} catch (e: any) { return err(e.message || String(e)); }
}
case "component_get_available": {
try {
const classes = await (Editor.Message.request as any)("scene", "query-classes");
return ok({ success: true, classes });
} catch (e: any) { return err(e.message || String(e)); }
}
case "component_auto_bind":
return this.autoBind(args.uuid, compType, args.force ?? false, args.mode ?? "fuzzy");
case "component_query_enum":
return this.queryEnum(args.uuid, compType, args.property);
default:
return err(`Unknown tool: ${toolName}`);
}
}
private async addComponent(uuid: string, componentType: string): Promise<ToolResult> {
try {
const result = await this.sceneScript("addComponentToNode", [uuid, componentType]);
return ok(result);
} catch (e: any) {
return err(e.message || String(e));
}
}
private async queryEnum(nodeUuid: string, componentType: string, property: string): Promise<ToolResult> {
try {
const nodeDump = await (Editor.Message.request as any)("scene", "query-node", nodeUuid);
if (!nodeDump) return err("Node not found");
const comps = nodeDump.__comps__ || [];
for (const comp of comps) {
const compType = comp.type;
if (!compType) continue;
// Match by cc.XXX format
const normalizedType = componentType.startsWith("cc.") ? componentType.substring(3) : componentType;
if (compType !== `cc.${normalizedType}` && compType !== componentType) continue;
const propDump = comp.value?.[property];
if (!propDump) return err(`Property '${property}' not found on ${componentType}`);
if (propDump.type !== "Enum") {
return ok({ success: true, property, type: propDump.type, note: "Not an enum property", currentValue: propDump.value });
}
return ok({
success: true,
property,
currentValue: propDump.value,
enumList: propDump.enumList,
});
}
return err(`Component ${componentType} not found on node`);
} catch (e: any) {
return err(e.message || String(e));
}
}
private async removeComponent(uuid: string, componentType: string): Promise<ToolResult> {
try {
const result = await this.sceneScript("removeComponentFromNode", [uuid, componentType]);
return ok(result);
} catch (e: any) {
return err(e.message || String(e));
}
}
private async getComponents(uuid: string): Promise<ToolResult> {
try {
const result = await this.sceneScript("getNodeInfo", [uuid]);
if (!result.success) return ok(result);
return ok({
success: true,
uuid,
name: result.data?.name,
components: result.data?.components || [],
});
} catch (e: any) {
return err(e.message || String(e));
}
}
/**
* @property 名とノード名を自動マッチングしてバインドする。
*
* - 検索スコープ: 対象ノードの子孫のみ
* - 複数ヒット時: 階層の浅いノード(直接の子)を優先
* - 型検証: Component 参照型の場合、該当コンポーネントの存在を確認
* - 配列対応: @property([Node]) → 連番ノード名 (Slots_0, Slots_1...)
* - mode:
* - "fuzzy" (default): 完全一致 → case-insensitive → not_found+候補
* - "strict": 完全一致のみ → not_found+候補
*/
private async autoBind(nodeUuid: string, componentType: string, force: boolean, mode: string): Promise<ToolResult> {
try {
const nodeDump = await (Editor.Message.request as any)("scene", "query-node", nodeUuid);
if (!nodeDump) return err("Node not found");
const comps = nodeDump.__comps__ || [];
const compName = componentType.replace("cc.", "");
const compIndex = comps.findIndex((c: any) => {
const t = c.type || "";
return t === compName || t === `cc.${compName}`;
});
if (compIndex < 0) return err(`Component ${componentType} not found on node`);
// 子孫ノード一覧を一括取得(検索効率化)
const allDescendants = await this.sceneScript("getAllDescendants", [nodeUuid]);
const descendantList: Array<{uuid: string, name: string, depth: number}> =
allDescendants?.success ? allDescendants.data : [];
const compDump = comps[compIndex];
const properties = compDump.value || {};
const skipKeys = new Set(["uuid", "name", "enabled", "node", "__scriptAsset", "__prefab", "_name", "_objFlags", "_enabled"]);
const results: any[] = [];
for (const [propName, propDumpRaw] of Object.entries(properties)) {
if (skipKeys.has(propName) || propName.startsWith("_")) continue;
const propDump = propDumpRaw as any;
const propType = propDump.type as string;
if (!propType) continue;
const extendsArr = (propDump.extends || []) as string[];
// 配列型の判定
const isArray = propType === "Array" || Array.isArray(propDump.value);
if (isArray) {
const arrayResult = await this.autoBindArray(nodeUuid, compIndex, propName, propDump, descendantList, mode);
results.push(arrayResult);
continue;
}
const isNodeRef = propType === "cc.Node";
const isComponentRef = extendsArr.includes("cc.Component");
if (!isNodeRef && !isComponentRef) continue;
// 既にバインド済みならスキップ
const currentValue = propDump.value;
if (!force && currentValue?.uuid) {
results.push({ property: propName, status: "already_bound" });
continue;
}
// 名前マッチ: 完全一致 → fuzzy時は case-insensitive
const matchResult = this.findMatchingNode(propName, descendantList, mode);
if (matchResult && isComponentRef) {
// 型検証: コンポーネントが存在するか
const hasComp = await this.nodeHasComponent(matchResult.uuid, propType);
if (!hasComp) {
results.push({ property: propName, type: propType, status: "type_mismatch",
nodeName: matchResult.name, message: `Node "${matchResult.name}" has no ${propType} component` });
continue;
}
}
if (!matchResult) {
// 候補サジェスト
const suggestions = this.getSuggestions(propName, descendantList);
results.push({ property: propName, type: propType, status: "not_found", suggestions });
continue;
}
const path = `__comps__.${compIndex}.${propName}`;
const dump = await this.buildDumpWithTypeInfo(nodeUuid, path, matchResult.uuid);
const setResult = await this.sceneScript("setPropertyViaEditor", [nodeUuid, path, dump]);
const status = matchResult.exact ? "bound" : "fuzzy_bound";
results.push({ property: propName, status, nodeName: matchResult.name, success: setResult?.success !== false });
}
const boundCount = results.filter(r => r.status === "bound" || r.status === "fuzzy_bound").length;
const fuzzyCount = results.filter(r => r.status === "fuzzy_bound").length;
const notFoundCount = results.filter(r => r.status === "not_found").length;
return ok({ success: true, boundCount, fuzzyCount, notFoundCount, results });
} catch (e: any) {
return err(e.message || String(e));
}
}
/**
* 子孫リストからプロパティ名にマッチするノードを検索。
* 完全一致を優先、fuzzy モードでは case-insensitive もフォールバック。
* 複数ヒット時は階層の浅い(depth が小さい)ものを優先。
*/
private findMatchingNode(
propName: string, descendants: Array<{uuid: string, name: string, depth: number}>, mode: string
): { uuid: string; name: string; exact: boolean } | null {
const candidates = this.propertyNameToNodeNames(propName);
// 1. 完全一致
for (const candidate of candidates) {
const matches = descendants
.filter(d => d.name === candidate)
.sort((a, b) => a.depth - b.depth);
if (matches.length > 0) {
return { uuid: matches[0].uuid, name: matches[0].name, exact: true };
}
}
// 2. fuzzy: case-insensitive
if (mode === "fuzzy") {
const lowerCandidates = candidates.map(c => c.toLowerCase());
const matches = descendants
.filter(d => lowerCandidates.includes(d.name.toLowerCase()))
.sort((a, b) => a.depth - b.depth);
if (matches.length > 0) {
return { uuid: matches[0].uuid, name: matches[0].name, exact: false };
}
}
return null;
}
/**
* not_found 時に似た名前のノードをサジェストする。
*/
private getSuggestions(propName: string, descendants: Array<{uuid: string, name: string, depth: number}>): string[] {
const lower = propName.toLowerCase();
return descendants
.filter(d => d.name.toLowerCase().includes(lower) || lower.includes(d.name.toLowerCase()))
.map(d => d.name)
.slice(0, 5);
}
/**
* ノードに指定型のコンポーネントが存在するか確認。
*/
private async nodeHasComponent(nodeUuid: string, propType: string): Promise<boolean> {
const typeName = propType.replace("cc.", "");
const info = await this.sceneScript("getNodeInfo", [nodeUuid]);
if (!info?.success || !info?.data?.components) return false;
return info.data.components.some((c: any) => c.type === typeName);
}
/**
* 配列 @property の自動バインド。
* プロパティ名 "slots" → "Slots_0", "Slots_1", ... の連番ノードを検索。
*/
private async autoBindArray(
nodeUuid: string, compIndex: number, propName: string, propDump: any,
descendants: Array<{uuid: string, name: string, depth: number}>, mode: string
): Promise<any> {
const elementType = propDump.value?.[0]?.type as string | undefined;
if (!elementType) {
return { property: propName, status: "skip", reason: "empty array or unknown element type" };
}
const pascal = propName.charAt(0).toUpperCase() + propName.slice(1);
const foundElements: any[] = [];
let index = 0;
while (true) {
const candidateName = `${pascal}_${index}`;
// 完全一致 or case-insensitive
let match = descendants.find(d => d.name === candidateName);
if (!match && mode === "fuzzy") {
const lower = candidateName.toLowerCase();
match = descendants.find(d => d.name.toLowerCase() === lower);
}
if (!match) break;
const elementPath = `__comps__.${compIndex}.${propName}.${index}`;
const dump = await this.buildDumpWithTypeInfo(nodeUuid, elementPath, match.uuid);
const setResult = await this.sceneScript("setPropertyViaEditor", [nodeUuid, elementPath, dump]);
const exact = match.name === candidateName;
foundElements.push({ index, nodeName: match.name, exact, success: setResult?.success !== false });
index++;
}
if (foundElements.length === 0) {
return { property: propName, status: "not_found", type: "Array", candidates: [`${pascal}_0`, `${pascal}_1`, "..."] };
}
const hasFuzzy = foundElements.some(e => !e.exact);
return { property: propName, status: hasFuzzy ? "fuzzy_bound" : "bound", type: "Array", count: foundElements.length, elements: foundElements };
}
/**
* camelCase プロパティ名からノード名の候補を生成。
* closeButton → ["CloseButton", "closeButton"]
*/
private propertyNameToNodeNames(propName: string): string[] {
const pascal = propName.charAt(0).toUpperCase() + propName.slice(1);
const names = [pascal];
if (pascal !== propName) names.push(propName);
return names;
}
private async setProperty(uuid: string, componentType: string, property: string, value: any): Promise<ToolResult> {
try {
// コンポーネントのインデックスを取得
const nodeInfo = await this.sceneScript("getNodeInfo", [uuid]);
if (!nodeInfo?.success || !nodeInfo?.data?.components) {
return err(`Node ${uuid} not found or has no components`);
}
const compName = componentType.replace("cc.", "");
const compIndex = nodeInfo.data.components.findIndex((c: any) => c.type === compName);
if (compIndex < 0) {
return err(`Component ${componentType} not found on node ${uuid}`);
}
// scene:set-property でプロパティ変更(Prefab保存時にも反映される)
// パス形式: __comps__.{index}.{property}
const path = `__comps__.${compIndex}.${property}`;
// プロパティの型情報をquery-nodeから取得して、適切なdump形式を構築
const dump = await this.buildDumpWithTypeInfo(uuid, path, value);
const result = await this.sceneScript("setPropertyViaEditor", [uuid, path, dump]);
// cc.Widget の isAlign* 設定後は _alignFlags を再計算する
// (Editor が isAlign* 変更時に _alignFlags を自動更新しないバグの対処)
if (componentType === "cc.Widget" && property.startsWith("isAlign")) {
await this.recalcWidgetAlignFlags(uuid, compIndex);
}
return ok({ success: true, path, dump, result });
} catch (e: any) {
return err(e.message || String(e));
}
}
private async setProperties(uuid: string, componentType: string, properties: Array<{property: string, value: any}>): Promise<ToolResult> {
try {
if (!componentType) return err("componentType is required");
if (!properties.length) return err("properties array is empty");
// コンポーネントのインデックスを取得(1回だけ)
const nodeInfo = await this.sceneScript("getNodeInfo", [uuid]);
if (!nodeInfo?.success || !nodeInfo?.data?.components) {
return err(`Node ${uuid} not found or has no components`);
}
const compName = componentType.replace("cc.", "");
const compIndex = nodeInfo.data.components.findIndex((c: any) => c.type === compName);
if (compIndex < 0) {
return err(`Component ${componentType} not found on node ${uuid}`);
}
const results: any[] = [];
for (const { property, value } of properties) {
const path = `__comps__.${compIndex}.${property}`;
const dump = await this.buildDumpWithTypeInfo(uuid, path, value);
const result = await this.sceneScript("setPropertyViaEditor", [uuid, path, dump]);
results.push({ property, success: result?.success !== false, path });
}
const allOk = results.every(r => r.success);
// cc.Widget の isAlign* 設定後は _alignFlags を再計算する
// (Editor が isAlign* 変更時に _alignFlags を自動更新しないバグの対処)
if (componentType === "cc.Widget" && properties.some(p => p.property.startsWith("isAlign"))) {
await this.recalcWidgetAlignFlags(uuid, compIndex);
}
return ok({ success: allOk, results });
} catch (e: any) {
return err(e.message || String(e));
}
}
/**
* cc.Widget の isAlign* プロパティ現在値から _alignFlags ビットマスクを再計算して設定する。
*
* CocosCreator Editor は isAlign* を setPropertyViaEditor で変更しても
* _alignFlags を自動更新しないバグがある。このヘルパーで明示的に同期する。
*
* _alignFlags ビット定義:
* isAlignLeft=1, isAlignRight=2, isAlignTop=4, isAlignBottom=8,
* isAlignHorizontalCenter=16, isAlignVerticalCenter=32
*/
private async recalcWidgetAlignFlags(uuid: string, wIdx: number): Promise<void> {
const ALIGN_BITS: Record<string, number> = {
isAlignLeft: 1, isAlignRight: 2, isAlignTop: 4, isAlignBottom: 8,
isAlignHorizontalCenter: 16, isAlignVerticalCenter: 32,
};
try {
const nodeDump = await (Editor.Message.request as any)("scene", "query-node", uuid);
if (!nodeDump) return;
const wCompDump = nodeDump.__comps__?.[wIdx];
if (!wCompDump) return;
let alignFlags = 0;
for (const [key, bit] of Object.entries(ALIGN_BITS)) {
if (wCompDump.value?.[key]?.value === true) alignFlags |= bit;
}
const path = `__comps__.${wIdx}._alignFlags`;
await this.sceneScript("setPropertyViaEditor", [uuid, path, { value: alignFlags, type: "Number" }]);
} catch (_e) {
// _alignFlags 再計算の失敗は致命的でないため無視
}
}
/**
* プロパティの型情報をEditor APIから取得し、適切なdump形式を構築する。
*
* UUID文字列が渡された場合、プロパティの型に応じて:
* - Node/Component参照型 → {type: propType, value: {uuid: nodeUuid}}
* - Asset参照型(cc.Prefab等) → {type: propType, value: {uuid: assetUuid}}
* - String型 → {value, type: "String"}
*/
private async buildDumpWithTypeInfo(nodeUuid: string, path: string, value: any): Promise<any> {
// プリミティブ型はそのまま
if (typeof value === "number") return { value, type: "Number" };
if (typeof value === "boolean") return { value, type: "Boolean" };
// オブジェクト形式 {uuid: "xxx", type: "cc.Node"} はそのまま
// type 指定なしの {uuid: "xxx"} はプロパティの実際の型を解決するため文字列扱いに変換する
if (value !== null && typeof value === "object" && typeof value.uuid === "string") {
if (typeof value.type === "string") {
return { type: value.type, value: { uuid: value.uuid } };
}
// type 未指定: 文字列として処理してプロパティ型から解決
value = value.uuid;
}
// @path: プレフィックスの場合: パスからノードUUIDを解決
if (typeof value === "string" && value.startsWith("@path:")) {
const nodePath = value.slice(6);
const result = await this.sceneScript("findNodeByPath", [nodePath]);
if (result?.success && result.data?.uuid) {
value = result.data.uuid;
} else {
throw new Error(`Node not found at path: ${nodePath}`);
}
}
// 文字列の場合: プロパティの型情報を取得して判定
if (typeof value === "string") {
try {
const nodeDump = await (Editor.Message.request as any)("scene", "query-node", nodeUuid);
if (nodeDump) {
const propDump = this.resolveDumpPath(nodeDump, path);
if (propDump?.type) {
const propType = propDump.type as string;
const extendsArr = (propDump.extends || []) as string[];
const isComponentRef = extendsArr.includes("cc.Component");
const isNodeRef = propType === "cc.Node";
const isAssetRef = extendsArr.includes("cc.Asset");
if (isComponentRef) {
// コンポーネント参照: ノードUUIDからコンポーネントUUIDを解決
const compUuid = await this.resolveComponentUuid(value, propType);
return { type: propType, value: { uuid: compUuid || value } };
}
if (isNodeRef) {
return { type: propType, value: { uuid: value } };
}
if (isAssetRef) {
return { type: propType, value: { uuid: value } };
}
}
}
} catch (_e) {
// query-node失敗時はフォールバック
}
return { value, type: "String" };
}
// その他のオブジェクト(contentSize, color等の構造体)
if (value !== null && typeof value === "object" && !Array.isArray(value)) {
const wrapped: any = {};
for (const [k, v] of Object.entries(value)) {
wrapped[k] = { value: v };
}
return { value: wrapped };
}
return { value };
}
/**
* query-nodeのdumpからドットパスでプロパティを解決する。
* 例: "__comps__.2.scrollView" → nodeDump.__comps__[2].value.scrollView
*/
private resolveDumpPath(nodeDump: any, path: string): any {
const parts = path.split(".");
let current = nodeDump;
for (const part of parts) {
if (!current) return null;
if (part === "__comps__") {
current = current.__comps__;
} else if (/^\d+$/.test(part)) {
current = current[parseInt(part)]?.value;
} else {
current = current?.[part];
}
}
return current;
}
/**
* ノードUUIDからコンポーネントUUIDを解決する。
* propType(例: "cc.ScrollView", "MissionListPanel")に一致するコンポーネントを探す。
*/
private async resolveComponentUuid(nodeUuid: string, propType: string): Promise<string | null> {
try {
const nodeInfo = await this.sceneScript("getNodeInfo", [nodeUuid]);
if (!nodeInfo?.success || !nodeInfo?.data?.components) return null;
const typeName = propType.replace("cc.", "");
const comp = nodeInfo.data.components.find((c: any) => c.type === typeName);
return comp?.uuid || null;
} catch (_e) {
return null;
}
}
private async sceneScript(method: string, args: any[]): Promise<any> {
return Editor.Message.request("scene", "execute-scene-script", {
name: EXT_NAME,
method,
args,
});
}
}
@@ -0,0 +1,804 @@
import { ToolCategory, ToolDefinition, ToolResult } from "../types";
import { ok, err } from "../tool-base";
import { getGameLogs, clearGameLogs, queueGameCommand, getCommandResult } from "../mcp-server";
import { parseMaybeJson } from "../utils";
import { ensureSceneSafeToSwitch } from "./scene-tools";
import { processImage, takeEditorScreenshot } from "../screenshot";
export class DebugTools implements ToolCategory {
readonly categoryName = "debug";
getTools(): ToolDefinition[] {
return [
{
name: "debug_get_editor_info",
description: "Get Cocos Creator editor information (version, platform, language).",
inputSchema: { type: "object", properties: {} },
},
{
name: "debug_list_messages",
description: "List available Editor messages for a given extension or built-in module.",
inputSchema: {
type: "object",
properties: {
target: { type: "string", description: "Message target (e.g. 'scene', 'asset-db', 'extension')" },
},
required: ["target"],
},
},
{
name: "debug_execute_script",
description: "Execute a custom scene script method. The method must be registered in scene.ts.",
inputSchema: {
type: "object",
properties: {
method: { type: "string", description: "Method name from scene.ts" },
args: { type: "array", description: "Arguments to pass", items: {} },
},
required: ["method"],
},
},
{
name: "debug_get_console_logs",
description: "Get recent console log entries. Automatically captures scene process logs (console.log/warn/error in scene scripts). Game preview logs can also be captured by sending POST requests to /log endpoint — see README for setup.",
inputSchema: {
type: "object",
properties: {
count: { type: "number", description: "Max number of entries (default 50)" },
level: { type: "string", description: "Filter by level: 'log', 'warn', or 'error'" },
source: { type: "string", description: "Filter by source: 'scene' or 'game'. Returns both if omitted." },
},
},
},
{
name: "debug_clear_console",
description: "Clear the editor console.",
inputSchema: { type: "object", properties: {} },
},
{
name: "debug_list_extensions",
description: "List all installed extensions.",
inputSchema: { type: "object", properties: {} },
},
{
name: "debug_get_project_logs",
description: "Read recent project log entries from the log file.",
inputSchema: {
type: "object",
properties: {
lines: { type: "number", description: "Number of lines to read (default 100)" },
},
},
},
{
name: "debug_search_project_logs",
description: "Search for a pattern in project logs.",
inputSchema: {
type: "object",
properties: {
pattern: { type: "string", description: "Search pattern (regex supported)" },
},
required: ["pattern"],
},
},
{
name: "debug_get_log_file_info",
description: "Get information about the project log file (size, path, last modified).",
inputSchema: { type: "object", properties: {} },
},
// ── 以下、既存MCP未対応のEditor API ──
{
name: "debug_query_devices",
description: "List connected devices (for native debugging).",
inputSchema: { type: "object", properties: {} },
},
{
name: "debug_open_url",
description: "Open a URL in the system browser from the editor.",
inputSchema: {
type: "object",
properties: { url: { type: "string", description: "URL to open" } },
required: ["url"],
},
},
{
name: "debug_validate_scene",
description: "Validate the current scene for common issues.",
inputSchema: { type: "object", properties: {} },
},
{
name: "debug_game_command",
description: "Send a command to the running game preview. Requires GameDebugClient in the game. Commands: 'screenshot' (capture game canvas), 'state' (dump GameDb), 'navigate' (go to a page), 'click' (click a node by name), 'inspect' (get runtime node info: UITransform sizes, Widget, Layout, position). Returns the result from the game.",
inputSchema: {
type: "object",
properties: {
type: { type: "string", description: "Command type: 'screenshot', 'state', 'navigate', 'click', 'inspect'" },
args: { type: "object", description: "Command arguments (e.g. {page: 'HomePageView'} for navigate, {name: 'ButtonName'} for click)" },
timeout: { type: "number", description: "Max wait time in ms (default 5000)" },
maxWidth: { type: "number", description: "Max width for screenshot resize (default: 960, 0 = no resize)" },
imageFormat: { type: "string", description: "Screenshot output format: 'webp' (default, Q=85) or 'png' (lossless)" },
},
required: ["type"],
},
},
{
name: "debug_screenshot",
description: "Take a screenshot of the editor window and save to a file. Returns the file path of the saved PNG.",
inputSchema: {
type: "object",
properties: {
savePath: { type: "string", description: "File path to save the PNG (default: temp/screenshots/screenshot_<timestamp>.png)" },
maxWidth: { type: "number", description: "Max width in pixels for resize (default: 960, 0 = no resize). Aspect ratio is preserved." },
},
},
},
{
name: "debug_preview",
description: "Start or stop the game preview. Uses Preview in Editor (auto-opens MainScene if needed). Falls back to browser preview if editor preview fails.",
inputSchema: {
type: "object",
properties: {
action: { type: "string", description: "'start' (default) or 'stop'" },
waitForReady: { type: "boolean", description: "If true, wait until GameDebugClient connects after start (default: false)" },
waitTimeout: { type: "number", description: "Max wait time in ms for waitForReady (default: 15000)" },
},
},
},
{
name: "debug_clear_code_cache",
description: "Clear the code cache (equivalent to Developer > Cache > Clear code cache) and soft-reload the scene.",
inputSchema: { type: "object", properties: {} },
},
{
name: "debug_reload_extension",
description: "Reload the MCP extension itself. Use after npm run build to apply code changes without restarting CocosCreator. Response is sent before reload starts.",
inputSchema: { type: "object", properties: {} },
},
{
name: "debug_get_extension_info",
description: "Get detailed information about a specific extension.",
inputSchema: {
type: "object",
properties: {
name: { type: "string", description: "Extension name" },
},
required: ["name"],
},
},
{
name: "debug_record_start",
description: "Start recording the game preview canvas to a video file. Uses MediaRecorder on the game side. Bitrate is auto-calculated from canvas resolution × fps × quality coefficient unless videoBitsPerSecond is set explicitly.",
inputSchema: {
type: "object",
properties: {
fps: { type: "number", description: "Frames per second (default: 30)" },
quality: { type: "string", description: "'low'/'medium'/'high'/'ultra' (default: medium). Coefficients: 0.15/0.25/0.40/0.60" },
coefficient: { type: "number", description: "Custom bitrate coefficient (width × height × fps × coefficient). Overrides quality." },
videoBitsPerSecond: { type: "number", description: "Explicit bitrate in bps. Overrides quality-based calculation." },
format: { type: "string", description: "'mp4' (default) or 'webm'. mp4 falls back to webm if not supported." },
savePath: { type: "string", description: "Save directory (project-relative or absolute). Default: temp/recordings" },
},
},
},
{
name: "debug_record_stop",
description: "Stop recording started by debug_record_start. Returns the saved WebM file path and size. Video is saved to project's temp/recordings/rec_<datetime>.webm.",
inputSchema: {
type: "object",
properties: {
timeout: { type: "number", description: "Max wait time in ms for file upload (default: 30000)" },
},
},
},
{
name: "debug_batch_screenshot",
description: "Navigate to multiple pages and take a screenshot of each. Requires game preview running with GameDebugClient. Returns an array of screenshot file paths.",
inputSchema: {
type: "object",
properties: {
pages: {
type: "array",
items: { type: "string" },
description: "List of page names to screenshot (e.g. ['HomePageView', 'ShopPageView'])",
},
delay: { type: "number", description: "Delay in ms between navigate and screenshot (default: 1000)" },
maxWidth: { type: "number", description: "Max width for screenshot resize (default: 960)" },
},
required: ["pages"],
},
},
{
name: "debug_wait_compile",
description: "Wait for TypeScript compilation to complete. Monitors the packer-driver debug log for 'Target(editor) ends' message. Use after modifying .ts files to ensure changes are compiled before operating on Prefabs. With clean=true, deletes compiled output first to force a fresh recompile (slower but guaranteed).",
inputSchema: {
type: "object",
properties: {
timeout: { type: "number", description: "Max wait time in ms (default: 15000)" },
clean: { type: "boolean", description: "If true, delete compiled output first to force fresh recompile (default: false)" },
},
},
},
];
}
async execute(toolName: string, args: Record<string, any>): Promise<ToolResult> {
try {
switch (toolName) {
case "debug_get_editor_info":
return this.getEditorInfo();
case "debug_list_messages":
return this.listMessages(args.target);
case "debug_execute_script":
return this.executeScript(args.method, args.args || []);
case "debug_get_console_logs":
return this.getConsoleLogs(args.count || 50, args.level, args.source);
case "debug_clear_console":
Editor.Message.send("console", "clear");
// Clear scene process log buffer
await Editor.Message.request("scene", "execute-scene-script", {
name: "cocos-creator-mcp",
method: "clearConsoleLogs",
args: [],
}).catch(() => {});
// Clear game preview log buffer
clearGameLogs();
return ok({ success: true });
case "debug_list_extensions":
return this.listExtensions();
case "debug_get_project_logs":
return this.getProjectLogs(args.lines || 100);
case "debug_search_project_logs":
return this.searchProjectLogs(args.pattern);
case "debug_get_log_file_info":
return this.getLogFileInfo();
case "debug_query_devices": {
const devices = await (Editor.Message.request as any)("device", "query").catch(() => []);
return ok({ success: true, devices });
}
case "debug_open_url":
await (Editor.Message.request as any)("program", "open-url", args.url);
return ok({ success: true, url: args.url });
case "debug_game_command":
return this.gameCommand(args.type || args.command, parseMaybeJson(args.args), args.timeout || 5000, args.maxWidth, args.imageFormat);
case "debug_screenshot":
return this.takeScreenshot(args.savePath, args.maxWidth);
case "debug_preview":
return this.handlePreview(args.action || "start", args.waitForReady, args.waitTimeout || 15000);
case "debug_clear_code_cache":
return this.clearCodeCache();
case "debug_reload_extension":
return this.reloadExtension();
case "debug_validate_scene":
return this.validateScene();
case "debug_get_extension_info":
return this.getExtensionInfo(args.name);
case "debug_batch_screenshot":
return this.batchScreenshot(args.pages, args.delay || 1000, args.maxWidth);
case "debug_record_start":
return this.gameCommand("record_start", { fps: args.fps, quality: args.quality, coefficient: args.coefficient, videoBitsPerSecond: args.videoBitsPerSecond, format: args.format, savePath: args.savePath }, 5000);
case "debug_record_stop":
return this.gameCommand("record_stop", undefined, args.timeout || 30000);
case "debug_wait_compile":
return this.waitCompile(args.timeout || 15000, args.clean ?? false);
default:
return err(`Unknown tool: ${toolName}`);
}
} catch (e: any) {
return err(e.message || String(e));
}
}
private async getEditorInfo(): Promise<ToolResult> {
return ok({
success: true,
version: Editor.App.version,
path: Editor.App.path,
home: Editor.App.home,
language: Editor.I18n?.getLanguage?.() || "unknown",
});
}
private async listMessages(target: string): Promise<ToolResult> {
try {
const info = await (Editor.Message.request as any)("extension", "query-info", target);
return ok({ success: true, target, info });
} catch (e: any) {
const knownMessages: Record<string, string[]> = {
"scene": [
"query-node-tree", "create-node", "remove-node", "duplicate-node",
"set-property", "create-prefab", "save-scene", "execute-scene-script",
"query-is-dirty", "query-classes", "soft-reload", "snapshot",
"change-gizmo-tool", "query-gizmo-tool-name", "focus-camera-on-nodes",
],
"asset-db": [
"query-assets", "query-asset-info", "query-asset-meta",
"refresh-asset", "save-asset", "create-asset", "delete-asset",
"move-asset", "copy-asset", "open-asset", "reimport-asset",
"query-path", "query-uuid", "query-url", "query-asset-depends",
],
};
const messages = knownMessages[target];
if (messages) {
return ok({ success: true, target, messages, note: "Static list (query failed)" });
}
return err(e.message || String(e));
}
}
private async executeScript(method: string, args: any[]): Promise<ToolResult> {
const result = await Editor.Message.request("scene", "execute-scene-script", {
name: "cocos-creator-mcp",
method,
args,
});
return ok(result);
}
private async getConsoleLogs(count: number, level?: string, source?: string): Promise<ToolResult> {
// 1. Try Editor's native console API first (may be supported in future CocosCreator versions)
if (!source) {
try {
const logs = await (Editor.Message.request as any)("console", "query-last-logs", count);
if (Array.isArray(logs) && logs.length > 0) {
return ok({ success: true, logs, source: "editor-api", note: "Using native Editor console API" });
}
} catch { /* Not supported in this version — use fallback */ }
}
// 2. Fallback: collect from scene process buffer + game preview buffer
let sceneLogs: any[] = [];
let gameLogs: any[] = [];
// 2a. Scene process logs (console wrapper in scene.ts)
if (!source || source === "scene") {
try {
const result = await Editor.Message.request("scene", "execute-scene-script", {
name: "cocos-creator-mcp",
method: "getConsoleLogs",
args: [count * 2, level], // request more, will trim after merge
});
if (result?.logs) {
sceneLogs = result.logs.map((l: any) => ({ ...l, source: "scene" }));
}
} catch { /* scene not available */ }
}
// 2b. Game preview logs (received via POST /log endpoint)
if (!source || source === "game") {
const gameResult = getGameLogs(count * 2, level);
gameLogs = gameResult.logs.map((l: any) => ({ ...l, source: "game" }));
}
// Merge and sort by timestamp, take last `count`
const merged = [...sceneLogs, ...gameLogs]
.sort((a, b) => a.timestamp.localeCompare(b.timestamp))
.slice(-count);
return ok({
success: true,
logs: merged,
total: { scene: sceneLogs.length, game: (source === "scene" ? 0 : gameLogs.length) },
});
}
private async listExtensions(): Promise<ToolResult> {
try {
const list = await (Editor.Message.request as any)("extension", "query-all");
return ok({ success: true, extensions: list });
} catch {
return ok({ success: true, extensions: [], note: "Extension query not supported" });
}
}
private async getExtensionInfo(name: string): Promise<ToolResult> {
try {
const info = await (Editor.Message.request as any)("extension", "query-info", name);
return ok({ success: true, name, info });
} catch (e: any) {
return err(e.message || String(e));
}
}
private async getProjectLogs(lines: number): Promise<ToolResult> {
try {
const fs = require("fs");
const path = require("path");
const logPath = path.join(Editor.Project.tmpDir, "logs", "project.log");
if (!fs.existsSync(logPath)) return ok({ success: true, logs: [], note: "Log file not found" });
const content = fs.readFileSync(logPath, "utf-8");
const allLines = content.split("\n");
const recent = allLines.slice(-lines);
return ok({ success: true, lines: recent.length, logs: recent });
} catch (e: any) {
return err(e.message || String(e));
}
}
private async searchProjectLogs(pattern: string): Promise<ToolResult> {
try {
const fs = require("fs");
const path = require("path");
const logPath = path.join(Editor.Project.tmpDir, "logs", "project.log");
if (!fs.existsSync(logPath)) return ok({ success: true, matches: [] });
const content = fs.readFileSync(logPath, "utf-8");
const regex = new RegExp(pattern, "gi");
const matches = content.split("\n").filter((line: string) => regex.test(line));
return ok({ success: true, pattern, count: matches.length, matches: matches.slice(0, 100) });
} catch (e: any) {
return err(e.message || String(e));
}
}
private async getLogFileInfo(): Promise<ToolResult> {
try {
const fs = require("fs");
const path = require("path");
const logPath = path.join(Editor.Project.tmpDir, "logs", "project.log");
if (!fs.existsSync(logPath)) return ok({ success: true, exists: false });
const stat = fs.statSync(logPath);
return ok({ success: true, exists: true, path: logPath, size: stat.size, modified: stat.mtime });
} catch (e: any) {
return err(e.message || String(e));
}
}
private async handlePreview(action: string, waitForReady?: boolean, waitTimeout?: number): Promise<ToolResult> {
if (action === "stop") {
return this.stopPreview();
}
const result = await this.startPreview();
if (waitForReady) {
const resultData = JSON.parse(result.content[0].text);
if (resultData.success) {
const ready = await this.waitForGameReady(waitTimeout || 15000);
resultData.gameReady = ready;
if (!ready) {
resultData.note = (resultData.note || "") + " GameDebugClient did not connect within timeout.";
}
return ok(resultData);
}
}
return result;
}
private async waitForGameReady(timeout: number): Promise<boolean> {
const start = Date.now();
while (Date.now() - start < timeout) {
// Check if game has sent any log or command result recently
const gameResult = getGameLogs(1);
if (gameResult.total > 0) return true;
await new Promise(r => setTimeout(r, 500));
}
return false;
}
private async startPreview(): Promise<ToolResult> {
try {
await this.ensureMainSceneOpen();
// ツールバーのVueインスタンス経由でplay()を呼ぶ(UI状態も同期される)
const played = await this.executeOnToolbar("start");
if (played) {
return ok({ success: true, action: "start", mode: "editor" });
}
// フォールバック: 直接API
const isPlaying = await (Editor.Message.request as any)("scene", "editor-preview-set-play", true);
return ok({ success: true, isPlaying, action: "start", mode: "editor", note: "direct API (toolbar UI may not sync)" });
} catch (e: any) {
try {
const electron = require("electron");
await electron.shell.openExternal("http://127.0.0.1:7456");
return ok({ success: true, action: "start", mode: "browser" });
} catch (e2: any) {
return err(e2.message || String(e2));
}
}
}
private async stopPreview(): Promise<ToolResult> {
try {
// ツールバー経由で停止(UI同期)
const stopped = await this.executeOnToolbar("stop");
if (!stopped) {
// フォールバック: 直接API
await (Editor.Message.request as any)("scene", "editor-preview-set-play", false);
}
// scene:preview-stop ブロードキャストでツールバーUI状態をリセット
Editor.Message.broadcast("scene:preview-stop");
// シーンビューに戻す
await new Promise(r => setTimeout(r, 500));
await this.ensureMainSceneOpen();
return ok({ success: true, action: "stop" });
} catch (e: any) {
return err(e.message || String(e));
}
}
private async executeOnToolbar(action: "start" | "stop"): Promise<boolean> {
try {
const electron = require("electron");
const allContents = electron.webContents.getAllWebContents();
for (const wc of allContents) {
try {
// play()をawaitしない — プレビュー完了を待つとタイムアウトするため
if (action === "start") {
const result = await wc.executeJavaScript(
`(function() { if (window.xxx && window.xxx.play && !window.xxx.gameView.isPlay) { window.xxx.play(); return true; } return false; })()`
);
if (result) return true;
} else {
const result = await wc.executeJavaScript(
`(function() { if (window.xxx && window.xxx.gameView.isPlay) { window.xxx.play(); return true; } return false; })()`
);
if (result) return true;
}
} catch { /* not the toolbar webContents */ }
}
} catch { /* electron API not available */ }
return false;
}
private async ensureMainSceneOpen(): Promise<void> {
const hierarchy = await Editor.Message.request("scene", "execute-scene-script", {
name: "cocos-creator-mcp",
method: "getSceneHierarchy",
args: [false],
}).catch(() => null);
if (!hierarchy?.sceneName || hierarchy.sceneName === "scene-2d") {
// プロジェクト設定のStart Sceneを参照
let sceneUuid: string | null = null;
try {
sceneUuid = await (Editor as any).Profile.getConfig("preview", "general.start_scene", "local");
} catch { /* ignore */ }
// Start Sceneが未設定 or "current_scene" の場合、最初のシーンを使う
if (!sceneUuid || sceneUuid === "current_scene") {
const scenes = await Editor.Message.request("asset-db", "query-assets", {
ccType: "cc.SceneAsset",
pattern: "db://assets/**/*",
});
if (Array.isArray(scenes) && scenes.length > 0) {
sceneUuid = scenes[0].uuid;
}
}
if (sceneUuid) {
// debug_preview 内部の自動遷移は preview を優先して force=true
// (dialog 出るより preview 開始を優先する運用)
await ensureSceneSafeToSwitch(true);
await (Editor.Message.request as any)("scene", "open-scene", sceneUuid);
await new Promise(r => setTimeout(r, 1500));
}
}
}
private async clearCodeCache(): Promise<ToolResult> {
try {
const electron = require("electron");
const menu = electron.Menu.getApplicationMenu();
if (!menu) return err("Application menu not found");
const findMenuItem = (items: any[], path: string[]): any => {
for (const item of items) {
if (item.label === path[0]) {
if (path.length === 1) return item;
if (item.submenu?.items) return findMenuItem(item.submenu.items, path.slice(1));
}
}
return null;
};
const cacheItem = findMenuItem(menu.items, ["Developer", "Cache", "Clear code cache"]);
if (!cacheItem) return err("Menu item 'Developer > Cache > Clear code cache' not found");
cacheItem.click();
await new Promise(r => setTimeout(r, 1000));
return ok({ success: true, note: "Code cache cleared via menu" });
} catch (e: any) {
return err(e.message || String(e));
}
}
private async gameCommand(type: string, args: any, timeout: number, maxWidth?: number, imageFormat?: string): Promise<ToolResult> {
const cmdId = queueGameCommand(type, args);
// Poll for result
const start = Date.now();
while (Date.now() - start < timeout) {
const result = getCommandResult();
if (result && result.id === cmdId) {
// If screenshot, save to file and return path
if (type === "screenshot" && result.success && result.data?.dataUrl) {
try {
const fs = require("fs");
const path = require("path");
const dir = path.join(Editor.Project.tmpDir, "screenshots");
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
const base64 = result.data.dataUrl.replace(/^data:image\/png;base64,/, "");
const pngBuffer = Buffer.from(base64, "base64");
const effectiveMaxWidth = maxWidth !== undefined ? maxWidth : 960;
const electron = require("electron");
const origImage = electron.nativeImage.createFromBuffer(pngBuffer);
const originalSize = origImage.getSize();
const { buffer, width, height, format } = await processImage(pngBuffer, effectiveMaxWidth, imageFormat);
const ext = format === "webp" ? "webp" : format === "jpeg" ? "jpg" : "png";
const filePath = path.join(dir, `game_${timestamp}.${ext}`);
fs.writeFileSync(filePath, buffer);
return ok({
success: true, path: filePath, size: buffer.length, format,
originalSize: `${originalSize.width}x${originalSize.height}`,
savedSize: `${width}x${height}`,
});
} catch (e: any) {
return ok({ success: true, note: "Screenshot captured but file save failed", error: e.message });
}
}
return ok(result);
}
await new Promise(r => setTimeout(r, 200));
}
return err(`Game did not respond within ${timeout}ms. Is GameDebugClient running in the preview?`);
}
private async takeScreenshot(savePath?: string, maxWidth?: number): Promise<ToolResult> {
try {
const result = await takeEditorScreenshot(savePath, maxWidth);
return ok(result);
} catch (e: any) {
return err(e.message || String(e));
}
}
private async reloadExtension(): Promise<ToolResult> {
// Schedule reload after response is sent
setTimeout(async () => {
try {
await (Editor.Message.request as any)("extension", "reload", "cocos-creator-mcp");
} catch (e: any) {
console.error("[MCP] Extension reload failed:", e.message);
}
}, 500);
return ok({ success: true, note: "Extension reload scheduled. MCP server will restart in ~1s. NOTE: Adding new tool definitions or modifying scene.ts requires a full CocosCreator restart (reload is not sufficient)." });
}
private async batchScreenshot(pages: string[], delay: number, maxWidth?: number): Promise<ToolResult> {
const results: any[] = [];
const timeout = 10000;
for (const page of pages) {
// Navigate
const navResult = await this.gameCommand("navigate", { page }, timeout, maxWidth);
const navData = JSON.parse(navResult.content[0].text);
if (!navData.success) {
results.push({ page, success: false, error: "navigate failed" });
continue;
}
// Wait for page to render
await new Promise(r => setTimeout(r, delay));
// Screenshot
const ssResult = await this.gameCommand("screenshot", {}, timeout, maxWidth);
const ssData = JSON.parse(ssResult.content[0].text);
results.push({
page,
success: ssData.success || false,
path: ssData.path,
error: ssData.success ? undefined : (ssData.error || ssData.message),
});
}
const succeeded = results.filter(r => r.success).length;
return ok({
success: true,
total: pages.length,
succeeded,
failed: pages.length - succeeded,
results,
});
}
private async validateScene(): Promise<ToolResult> {
try {
const tree = await Editor.Message.request("scene", "query-node-tree");
const issues: string[] = [];
const checkNodes = (nodes: any[]) => {
if (!nodes) return;
for (const node of nodes) {
if (!node.name) issues.push(`Node ${node.uuid} has no name`);
if (node.children) checkNodes(node.children);
}
};
if (Array.isArray(tree)) checkNodes(tree);
return ok({ success: true, issueCount: issues.length, issues });
} catch (e: any) {
return err(e.message || String(e));
}
}
/**
* TypeScript コンパイル完了を待つ。
* packer-driver の debug.log に "Target(editor) ends" が現れるのを監視する。
* 既にコンパイル済み(直近数秒以内に完了ログあり)なら即座に返す。
*/
private async waitCompile(timeout: number, clean: boolean): Promise<ToolResult> {
try {
const fs = require("fs");
const path = require("path");
const logPath = path.join(Editor.Project.path, "temp", "programming", "packer-driver", "logs", "debug.log");
const chunksDir = path.join(Editor.Project.path, "temp", "programming", "packer-driver", "targets", "editor", "chunks");
if (!fs.existsSync(logPath)) {
return err(`Compile log not found: ${logPath}`);
}
const MARKER = "Target(editor) ends";
// clean モード: コードキャッシュクリア + soft-reload で再コンパイルを強制
if (clean) {
// Developer > Cache > Clear code cache をクリック
try {
const electron = require("electron");
const menu = electron.Menu.getApplicationMenu();
const findMenuItem = (items: any[], labels: string[]): any => {
for (const item of items) {
if (item.label === labels[0]) {
if (labels.length === 1) return item;
if (item.submenu?.items) return findMenuItem(item.submenu.items, labels.slice(1));
}
}
return null;
};
const cacheItem = menu ? findMenuItem(menu.items, ["Developer", "Cache", "Clear code cache"]) : null;
if (cacheItem) cacheItem.click();
} catch (_e) { /* ignore */ }
await new Promise(r => setTimeout(r, 500));
// soft-reload でシーンを再読み込み → コンパイルトリガー
await (Editor.Message.request as any)("scene", "soft-reload").catch(() => {});
}
// refresh-asset でファイル変更を CC に通知してコンパイルをトリガー
await (Editor.Message.request as any)("asset-db", "refresh-asset", "db://assets").catch(() => {});
const initialSize = fs.statSync(logPath).size;
const startTime = Date.now();
const POLL_INTERVAL = 200;
const DETECT_GRACE_MS = 2000; // CC がファイル変更を検知するまでの猶予
while (Date.now() - startTime < timeout) {
await new Promise(r => setTimeout(r, POLL_INTERVAL));
const currentSize = fs.statSync(logPath).size;
// ログが成長していない
if (currentSize <= initialSize) {
// clean モードでは必ずコンパイルが走るので猶予判定しない
if (clean) continue;
// 猶予期間内はまだ待つ (CC の検知が遅い可能性)
if (Date.now() - startTime < DETECT_GRACE_MS) continue;
// 猶予期間を過ぎてもログが成長しない → コンパイル不要
return ok({ success: true, compiled: true, waitedMs: Date.now() - startTime, note: "No compilation triggered (no changes detected)" });
}
// ログが成長した → 新しい部分にマーカーがあるか確認
const fd = fs.openSync(logPath, "r");
const newBytes = currentSize - initialSize;
const buffer = Buffer.alloc(newBytes);
fs.readSync(fd, buffer, 0, newBytes, initialSize);
fs.closeSync(fd);
const newContent = buffer.toString("utf8");
if (newContent.includes(MARKER)) {
return ok({ success: true, compiled: true, waitedMs: Date.now() - startTime });
}
}
return ok({ success: true, compiled: false, timeout: true, waitedMs: timeout });
} catch (e: any) {
return err(e.message || String(e));
}
}
}
@@ -0,0 +1,624 @@
import { ToolCategory, ToolDefinition, ToolResult } from "../types";
import { ok, err } from "../tool-base";
import { parseMaybeJson } from "../utils";
import { resolveNodeUuid } from "../node-resolve";
import { takeEditorScreenshot } from "../screenshot";
const EXT_NAME = "cocos-creator-mcp";
export class NodeTools implements ToolCategory {
readonly categoryName = "node";
getTools(): ToolDefinition[] {
return [
{
name: "node_create",
description: "Create a new node in the scene.",
inputSchema: {
type: "object",
properties: {
name: { type: "string", description: "Node name" },
parent: { type: "string", description: "Parent node UUID (optional, defaults to scene root)" },
components: {
type: "array",
items: { type: "string" },
description: "Component class names to add (e.g. ['cc.Label', 'cc.Sprite'])",
},
},
required: ["name"],
},
},
{
name: "node_get_info",
description: "Get detailed information about a node by UUID, including components.",
inputSchema: {
type: "object",
properties: {
uuid: { type: "string", description: "Node UUID" },
},
required: ["uuid"],
},
},
{
name: "node_find_by_name",
description: "Find all nodes matching a given name.",
inputSchema: {
type: "object",
properties: {
name: { type: "string", description: "Node name to search" },
},
required: ["name"],
},
},
{
name: "node_set_property",
description: "Set a property on a node (name, active, position, rotation, scale, etc.).",
inputSchema: {
type: "object",
properties: {
uuid: { type: "string", description: "Node UUID" },
property: { type: "string", description: "Property name (e.g. 'name', 'active', 'position')" },
value: { description: "Value to set. For position/rotation/scale use {x,y,z}." },
},
required: ["uuid", "property", "value"],
},
},
{
name: "node_set_transform",
description: "Set position, rotation, and/or scale of a node at once.",
inputSchema: {
type: "object",
properties: {
uuid: { type: "string", description: "Node UUID" },
position: {
type: "object",
properties: { x: { type: "number" }, y: { type: "number" }, z: { type: "number" } },
description: "Position {x,y,z}",
},
rotation: {
type: "object",
properties: { x: { type: "number" }, y: { type: "number" }, z: { type: "number" } },
description: "Euler rotation {x,y,z}",
},
scale: {
type: "object",
properties: { x: { type: "number" }, y: { type: "number" }, z: { type: "number" } },
description: "Scale {x,y,z}",
},
},
required: ["uuid"],
},
},
{
name: "node_delete",
description: "Delete a node by UUID.",
inputSchema: {
type: "object",
properties: {
uuid: { type: "string", description: "Node UUID" },
},
required: ["uuid"],
},
},
{
name: "node_move",
description: "Move a node to a new parent.",
inputSchema: {
type: "object",
properties: {
uuid: { type: "string", description: "Node UUID" },
parentUuid: { type: "string", description: "New parent node UUID" },
},
required: ["uuid", "parentUuid"],
},
},
{
name: "node_duplicate",
description: "Duplicate a node.",
inputSchema: {
type: "object",
properties: {
uuid: { type: "string", description: "Node UUID to duplicate" },
},
required: ["uuid"],
},
},
{
name: "node_get_all",
description: "Get a flat list of all nodes in the current scene.",
inputSchema: {
type: "object",
properties: {},
},
},
{
name: "node_set_active",
description: "Set a node's active (visible) state.",
inputSchema: {
type: "object",
properties: {
uuid: { type: "string", description: "Node UUID" },
active: { type: "boolean", description: "Whether the node is active" },
},
required: ["uuid", "active"],
},
},
{
name: "node_set_layer",
description: "Set a node's layer.",
inputSchema: {
type: "object",
properties: {
uuid: { type: "string", description: "Node UUID" },
layer: { type: "number", description: "Layer value" },
},
required: ["uuid", "layer"],
},
},
{
name: "node_detect_type",
description: "Detect node type (2D, 3D, or regular Node) based on its components.",
inputSchema: {
type: "object",
properties: {
uuid: { type: "string", description: "Node UUID" },
},
required: ["uuid"],
},
},
{
name: "node_create_tree",
description: "Create a full node tree from a JSON spec in one call. Much faster than creating nodes one by one. Spec format: { name, components?: ['cc.UITransform'], properties?: {'cc.UITransform.contentSize': {width:720,height:1280}}, widget?: {top:0, bottom:0, left:0, right:0}, active?: bool, position?: {x,y,z}, children?: [...] }",
inputSchema: {
type: "object",
properties: {
parent: { type: "string", description: "Parent node UUID" },
spec: { description: "Node tree specification (JSON object with name, components, properties, children)" },
},
required: ["parent", "spec"],
},
},
{
name: "node_set_layout",
description: "Set UITransform (contentSize, anchorPoint) and Widget (margins) on a node in one call. Much faster than calling component_set_property multiple times for layout adjustments. Set screenshot=true to capture the editor after changes.",
inputSchema: {
type: "object",
properties: {
uuid: { type: "string", description: "Node UUID (either uuid or nodeName required)" },
nodeName: { type: "string", description: "Node name to find (alternative to uuid)" },
contentSize: {
type: "object",
properties: { width: { type: "number" }, height: { type: "number" } },
description: "UITransform contentSize {width, height}",
},
anchorPoint: {
type: "object",
properties: { x: { type: "number" }, y: { type: "number" } },
description: "UITransform anchorPoint {x, y} (0-1)",
},
widget: {
type: "object",
properties: {
top: { type: "number" }, bottom: { type: "number" },
left: { type: "number" }, right: { type: "number" },
horizontalCenter: { type: "number" }, verticalCenter: { type: "number" },
isAlignTop: { type: "boolean" }, isAlignBottom: { type: "boolean" },
isAlignLeft: { type: "boolean" }, isAlignRight: { type: "boolean" },
isAlignHorizontalCenter: { type: "boolean" }, isAlignVerticalCenter: { type: "boolean" },
},
description: "Widget alignment margins. Setting a value (e.g. top:0) automatically enables the corresponding alignment (isAlignTop:true).",
},
color: {
type: "object",
properties: { r: { type: "number" }, g: { type: "number" }, b: { type: "number" }, a: { type: "number" } },
description: "Node color {r,g,b,a} (0-255)",
},
opacity: { type: "number", description: "Node opacity (0-255)" },
screenshot: { type: "boolean", description: "If true, capture editor screenshot after changes (default: false)" },
},
},
},
];
}
async execute(toolName: string, args: Record<string, any>): Promise<ToolResult> {
const rejected = await this.rejectIfPreviewRunning(toolName);
if (rejected) return rejected;
switch (toolName) {
case "node_create":
return this.createNode(args.name, args.parent, args.components);
case "node_get_info":
return this.getNodeInfo(args.uuid);
case "node_find_by_name":
return this.findByName(args.name);
case "node_set_property":
return this.setProperty(args.uuid, args.property, parseMaybeJson(args.value));
case "node_set_transform":
return this.setTransform(args.uuid, args.position, args.rotation, args.scale);
case "node_delete":
return this.deleteNode(args.uuid);
case "node_move":
return this.moveNode(args.uuid, args.parentUuid);
case "node_duplicate":
return this.duplicateNode(args.uuid);
case "node_get_all":
return this.getAllNodes();
case "node_set_active":
return this.setProperty(args.uuid, "active", args.active);
case "node_set_layer":
return this.setProperty(args.uuid, "layer", args.layer);
case "node_create_tree":
return this.createNodeTree(args.parent, parseMaybeJson(args.spec));
case "node_set_layout":
return this.setLayout(args);
case "node_detect_type": {
try {
const info = await this.sceneScript("getNodeInfo", [args.uuid]);
if (!info.success) return ok(info);
const comps = info.data?.components || [];
const compTypes = comps.map((c: any) => c.type);
let nodeType = "Node";
if (compTypes.includes("UITransform")) nodeType = "2D";
else if (compTypes.includes("MeshRenderer") || compTypes.includes("Camera")) nodeType = "3D";
return ok({ success: true, uuid: args.uuid, nodeType, components: compTypes });
} catch (e: any) { return err(e.message || String(e)); }
}
default:
return err(`Unknown tool: ${toolName}`);
}
}
/** Scene editing tools that must not run during preview */
private static readonly SCENE_EDIT_TOOLS = new Set([
"node_create", "node_delete", "node_move", "node_duplicate",
"node_set_property", "node_set_transform", "node_set_active", "node_set_layer",
"node_create_tree", "node_set_layout",
]);
private async rejectIfPreviewRunning(toolName: string): Promise<ToolResult | null> {
if (!NodeTools.SCENE_EDIT_TOOLS.has(toolName)) return null;
try {
const state = await Editor.Message.request("preview", "query-info");
if (state && (state as any).running) {
return err(`"${toolName}" はプレビュー中に実行できません。先にプレビューを停止してください。`);
}
} catch { /* query failed — allow execution */ }
return null;
}
private async createNode(name: string, parent?: string, components?: string[]): Promise<ToolResult> {
try {
// Use Editor API to create node
const uuid = await Editor.Message.request("scene", "create-node", {
parent: parent || undefined,
name,
assetUuid: undefined,
});
// Wait until the node is queryable in the scene process
await this.waitForNode(uuid);
// Add components if specified
if (components && components.length > 0) {
for (const comp of components) {
await this.sceneScript("addComponentToNode", [uuid, comp]);
// Wait until the component is reflected in query-node
await this.waitForComponent(uuid, comp);
}
}
return ok({ success: true, uuid, name });
} catch (e: any) {
return err(e.message || String(e));
}
}
/**
* Wait until a node becomes queryable in the scene process.
* Editor.Message.request("scene", "create-node") returns before the node
* is fully registered in the scene hierarchy, so subsequent scene script
* calls (findNode) may fail without this wait.
*/
private async waitForNode(uuid: string, maxRetries = 10, intervalMs = 100): Promise<void> {
for (let i = 0; i < maxRetries; i++) {
try {
const result = await this.sceneScript("getNodeInfo", [uuid]);
if (result?.success) return;
} catch { /* not ready yet */ }
await new Promise(resolve => setTimeout(resolve, intervalMs));
}
// Don't throw — let the caller proceed and get a more specific error if needed
}
/**
* Wait until a component added via addComponentToNode is reflected in query-node.
* sceneScript returns before the Editor API (query-node) reflects the change,
* so polling is needed to avoid race conditions in subsequent tool calls.
*/
private async waitForComponent(nodeUuid: string, componentType: string, maxRetries = 10, intervalMs = 100): Promise<void> {
const normalizedType = componentType.startsWith("cc.") ? componentType.substring(3) : componentType;
for (let i = 0; i < maxRetries; i++) {
try {
const nodeDump = await (Editor.Message.request as any)("scene", "query-node", nodeUuid);
const comps: any[] = nodeDump?.__comps__ || [];
const found = comps.some((c) =>
c.type === componentType || c.type === `cc.${normalizedType}` || c.type === normalizedType
);
if (found) return;
} catch { /* not ready yet */ }
await new Promise(resolve => setTimeout(resolve, intervalMs));
}
// Don't throw — component may still work; let caller get a specific error if needed
}
private async createNodeTree(parentUuid: string, spec: any): Promise<ToolResult> {
try {
const result = await this.sceneScript("buildNodeTree", [parentUuid, spec]);
if (!result?.success) return err(result?.error || "buildNodeTree failed");
return ok(result);
} catch (e: any) {
return err(e.message || String(e));
}
}
private async getNodeInfo(uuid: string): Promise<ToolResult> {
try {
const result = await this.sceneScript("getNodeInfo", [uuid]);
return ok(result);
} catch (e: any) {
return err(e.message || String(e));
}
}
private async findByName(name: string): Promise<ToolResult> {
try {
const result = await this.sceneScript("findNodesByName", [name]);
return ok(result);
} catch (e: any) {
return err(e.message || String(e));
}
}
private async setProperty(uuid: string, property: string, value: any): Promise<ToolResult> {
try {
const result = await this.sceneScript("setNodeProperty", [uuid, property, value]);
return ok(result);
} catch (e: any) {
return err(e.message || String(e));
}
}
private async setTransform(uuid: string, position?: any, rotation?: any, scale?: any): Promise<ToolResult> {
try {
const results: any[] = [];
if (position) {
results.push(await this.sceneScript("setNodeProperty", [uuid, "position", position]));
}
if (rotation) {
results.push(await this.sceneScript("setNodeProperty", [uuid, "rotation", rotation]));
}
if (scale) {
results.push(await this.sceneScript("setNodeProperty", [uuid, "scale", scale]));
}
const anyFailed = results.find((r) => !r.success);
if (anyFailed) return ok(anyFailed);
return ok({ success: true, uuid });
} catch (e: any) {
return err(e.message || String(e));
}
}
private async deleteNode(uuid: string): Promise<ToolResult> {
try {
await Editor.Message.request("scene", "remove-node", { uuid });
return ok({ success: true, uuid });
} catch (e: any) {
return err(e.message || String(e));
}
}
private async moveNode(uuid: string, parentUuid: string): Promise<ToolResult> {
try {
await (Editor.Message.request as any)("scene", "set-property", {
uuid,
path: "parent",
dump: { type: "cc.Node", value: { uuid: parentUuid } },
});
return ok({ success: true, uuid, parentUuid });
} catch (e: any) {
// Fallback: try scene script
try {
const result = await this.sceneScript("moveNode", [uuid, parentUuid]);
return ok(result);
} catch (e2: any) {
return err(e.message || String(e));
}
}
}
private async duplicateNode(uuid: string): Promise<ToolResult> {
try {
const result = await Editor.Message.request("scene", "duplicate-node", uuid);
// duplicate-node returns an array of UUIDs
const newUuid = Array.isArray(result) ? result[0] : result;
return ok({ success: true, sourceUuid: uuid, newUuid });
} catch (e: any) {
return err(e.message || String(e));
}
}
private async getAllNodes(): Promise<ToolResult> {
try {
const result = await this.sceneScript("getAllNodes", []);
return ok(result);
} catch (e: any) {
return err(e.message || String(e));
}
}
/**
* UITransform + Widget + color/opacity をまとめて設定する。
* Widget の値を指定すると、対応する isAlign* フラグを自動で true にする。
*/
private async setLayout(args: Record<string, any>): Promise<ToolResult> {
try {
// nodeName → uuid 解決
let uuid = args.uuid;
if (!uuid && args.nodeName) {
const resolved = await resolveNodeUuid({ nodeName: args.nodeName });
uuid = resolved.uuid;
}
if (!uuid) return err("Either 'uuid' or 'nodeName' is required");
const results: any[] = [];
// UITransform の設定
const contentSize = parseMaybeJson(args.contentSize);
const anchorPoint = parseMaybeJson(args.anchorPoint);
if (contentSize || anchorPoint) {
const nodeInfo = await this.sceneScript("getNodeInfo", [uuid]);
if (!nodeInfo?.success) return err(`Node ${uuid} not found`);
const comps = nodeInfo.data?.components || [];
const utIdx = comps.findIndex((c: any) => c.type === "UITransform");
if (utIdx < 0) return err("Node has no UITransform component");
if (contentSize) {
const path = `__comps__.${utIdx}.contentSize`;
const dump = { value: { width: { value: contentSize.width }, height: { value: contentSize.height } } };
const r = await this.sceneScript("setPropertyViaEditor", [uuid, path, dump]);
results.push({ property: "contentSize", success: r?.success !== false });
}
if (anchorPoint) {
const path = `__comps__.${utIdx}.anchorPoint`;
const dump = { value: { x: { value: anchorPoint.x }, y: { value: anchorPoint.y } } };
const r = await this.sceneScript("setPropertyViaEditor", [uuid, path, dump]);
results.push({ property: "anchorPoint", success: r?.success !== false });
}
}
// Widget の設定
const widget = parseMaybeJson(args.widget);
if (widget) {
// Widget コンポーネントを探す(なければ追加)
let nodeInfo = await this.sceneScript("getNodeInfo", [uuid]);
if (!nodeInfo?.success) return err(`Node ${uuid} not found`);
let comps = nodeInfo.data?.components || [];
let wIdx = comps.findIndex((c: any) => c.type === "Widget");
if (wIdx < 0) {
await this.sceneScript("addComponentToNode", [uuid, "cc.Widget"]);
// 再取得
nodeInfo = await this.sceneScript("getNodeInfo", [uuid]);
comps = nodeInfo.data?.components || [];
wIdx = comps.findIndex((c: any) => c.type === "Widget");
if (wIdx < 0) return err("Failed to add Widget component");
results.push({ property: "Widget", action: "added" });
}
// isAlign* を自動設定(値があれば true にする)
const alignMap: Record<string, string> = {
top: "isAlignTop", bottom: "isAlignBottom",
left: "isAlignLeft", right: "isAlignRight",
horizontalCenter: "isAlignHorizontalCenter",
verticalCenter: "isAlignVerticalCenter",
};
for (const [key, value] of Object.entries(widget)) {
// isAlign* を明示指定した場合はそのまま設定
const path = `__comps__.${wIdx}.${key}`;
if (typeof value === "boolean") {
const dump = { value, type: "Boolean" };
await this.sceneScript("setPropertyViaEditor", [uuid, path, dump]);
results.push({ property: `Widget.${key}`, success: true });
} else if (typeof value === "number") {
// まず対応する isAlign* を true にする
const alignKey = alignMap[key];
if (alignKey && widget[alignKey] === undefined) {
const alignPath = `__comps__.${wIdx}.${alignKey}`;
await this.sceneScript("setPropertyViaEditor", [uuid, alignPath, { value: true, type: "Boolean" }]);
}
const dump = { value, type: "Number" };
await this.sceneScript("setPropertyViaEditor", [uuid, path, dump]);
results.push({ property: `Widget.${key}`, success: true });
}
}
// _alignFlags を isAlign* 現在値から再計算して設定
// (Editor が isAlign* 変更時に _alignFlags を自動更新しないバグの対処)
try {
const ALIGN_BITS: Record<string, number> = {
isAlignLeft: 1, isAlignRight: 2, isAlignTop: 4, isAlignBottom: 8,
isAlignHorizontalCenter: 16, isAlignVerticalCenter: 32,
};
const nodeDump = await (Editor.Message.request as any)("scene", "query-node", uuid);
if (nodeDump) {
const wCompDump = nodeDump.__comps__?.[wIdx];
if (wCompDump) {
let alignFlags = 0;
for (const [key, bit] of Object.entries(ALIGN_BITS)) {
if (wCompDump.value?.[key]?.value === true) alignFlags |= bit;
}
const flagPath = `__comps__.${wIdx}._alignFlags`;
await this.sceneScript("setPropertyViaEditor", [uuid, flagPath, { value: alignFlags, type: "Number" }]);
results.push({ property: "Widget._alignFlags", value: alignFlags });
}
}
} catch (_e) {
// _alignFlags 再計算の失敗は致命的でないため無視
}
}
// color
const color = parseMaybeJson(args.color);
if (color) {
const r = await this.sceneScript("setNodeProperty", [uuid, "color", color]);
results.push({ property: "color", success: r?.success !== false });
}
// opacity
if (args.opacity !== undefined) {
// cc.UIOpacity を使う(なければ color.a で設定)
const nodeInfo = await this.sceneScript("getNodeInfo", [uuid]);
const comps = nodeInfo?.data?.components || [];
const opIdx = comps.findIndex((c: any) => c.type === "UIOpacity");
if (opIdx >= 0) {
const path = `__comps__.${opIdx}.opacity`;
await this.sceneScript("setPropertyViaEditor", [uuid, path, { value: args.opacity, type: "Number" }]);
results.push({ property: "UIOpacity.opacity", success: true });
} else {
// UIOpacity がない場合は color.a を直接設定
const currentColor = nodeInfo?.data?.color || { r: 255, g: 255, b: 255, a: 255 };
currentColor.a = args.opacity;
const r = await this.sceneScript("setNodeProperty", [uuid, "color", currentColor]);
results.push({ property: "color.a", success: r?.success !== false });
}
}
const allOk = results.every(r => r.success !== false);
let response: any = { success: allOk, uuid, results };
// screenshot
if (args.screenshot) {
try {
const ss = await takeEditorScreenshot();
response.screenshot = { path: ss.path, size: ss.savedSize };
} catch (ssErr: any) {
response.screenshotError = ssErr.message || String(ssErr);
}
}
return ok(response);
} catch (e: any) {
return err(e.message || String(e));
}
}
/** Call a scene script method */
private async sceneScript(method: string, args: any[]): Promise<any> {
return Editor.Message.request("scene", "execute-scene-script", {
name: EXT_NAME,
method,
args,
});
}
}
@@ -0,0 +1,680 @@
import { ToolCategory, ToolDefinition, ToolResult } from "../types";
import { ok, err } from "../tool-base";
import { ensureSceneSafeToSwitch, safeSaveScene } from "./scene-tools";
import { parseMaybeJson } from "../utils";
import type { ComponentTools } from "./component-tools";
import fs from "fs";
import path from "path";
import crypto from "crypto";
const EXT_NAME = "cocos-creator-mcp";
/** prefab_instantiate で配置したネスト Prefab 情報を記憶 */
interface NestedPrefabEntry {
nodeUuid: string;
prefabAssetUuid: string;
parentUuid: string;
}
export class PrefabTools implements ToolCategory {
readonly categoryName = "prefab";
private _pendingNestedPrefabs: NestedPrefabEntry[] = [];
/** prefab_open で開いた Prefab アセット UUID */
private _currentPrefabUuid: string | null = null;
private _componentTools: ComponentTools | null;
constructor(componentTools?: ComponentTools) {
this._componentTools = componentTools ?? null;
}
getTools(): ToolDefinition[] {
return [
{
name: "prefab_list",
description: "List all prefab files in the project.",
inputSchema: {
type: "object",
properties: {},
},
},
{
name: "prefab_create",
description: "Create a prefab from an existing node in the scene. The node remains in the scene.",
inputSchema: {
type: "object",
properties: {
uuid: { type: "string", description: "Node UUID to create prefab from" },
path: { type: "string", description: "db:// path for the prefab (e.g. 'db://assets/prefabs/MyPrefab.prefab')" },
},
required: ["uuid", "path"],
},
},
{
name: "prefab_instantiate",
description: "Instantiate a prefab into the scene.",
inputSchema: {
type: "object",
properties: {
prefabUuid: { type: "string", description: "Prefab asset UUID" },
parent: { type: "string", description: "Parent node UUID (optional, defaults to scene root)" },
},
required: ["prefabUuid"],
},
},
{
name: "prefab_get_info",
description: "Get information about a prefab asset (name, path, UUID).",
inputSchema: {
type: "object",
properties: {
uuid: { type: "string", description: "Prefab asset UUID" },
},
required: ["uuid"],
},
},
{
name: "prefab_update",
description: "Update (re-save) a prefab from its instance node in the scene.",
inputSchema: {
type: "object",
properties: {
uuid: { type: "string", description: "Node UUID of the prefab instance in the scene" },
},
required: ["uuid"],
},
},
{
name: "prefab_duplicate",
description: "Duplicate a prefab asset to a new path.",
inputSchema: {
type: "object",
properties: {
source: { type: "string", description: "Source prefab db:// path" },
destination: { type: "string", description: "Destination db:// path" },
},
required: ["source", "destination"],
},
},
{
name: "prefab_validate",
description: "Validate a prefab for missing references or broken links.",
inputSchema: {
type: "object",
properties: {
uuid: { type: "string", description: "Prefab asset UUID" },
},
required: ["uuid"],
},
},
{
name: "prefab_revert",
description: "Revert a prefab instance node to its original prefab state.",
inputSchema: {
type: "object",
properties: {
uuid: { type: "string", description: "Node UUID of the prefab instance" },
},
required: ["uuid"],
},
},
{
name: "prefab_create_and_replace",
description: "Create a prefab from a node AND replace the original node with a prefab instance. This is the recommended way to extract a nested prefab — one command instead of create → delete → instantiate.",
inputSchema: {
type: "object",
properties: {
uuid: { type: "string", description: "Node UUID to create prefab from" },
path: { type: "string", description: "db:// path for the prefab (e.g. 'db://assets/prefabs/MyPrefab.prefab')" },
},
required: ["uuid", "path"],
},
},
{
name: "prefab_open",
description: "Open a prefab in editing mode. Equivalent to double-clicking the prefab in CocosCreator. Returns an error if the current scene is dirty and untitled (to avoid modal save dialog); pass force=true to bypass.",
inputSchema: {
type: "object",
properties: {
uuid: { type: "string", description: "Prefab asset UUID" },
path: { type: "string", description: "Prefab db:// path (alternative to uuid)" },
force: { type: "boolean", description: "Skip dirty-scene preflight check (may trigger modal save dialog)" },
},
},
},
{
name: "prefab_close",
description: "Save and close the current prefab editing mode, then return to the main scene. Returns an error if the current prefab is dirty and untitled (to avoid modal save dialog); pass force=true to bypass.",
inputSchema: {
type: "object",
properties: {
save: { type: "boolean", description: "Save prefab before closing (default true)" },
sceneUuid: { type: "string", description: "Scene UUID to return to (default: project's start scene or first scene)" },
force: { type: "boolean", description: "Skip dirty-scene preflight check (may trigger modal save dialog)" },
},
},
},
{
name: "prefab_create_from_spec",
description: "Create a prefab from a JSON spec in one call. Combines node_create_tree + component_auto_bind + prefab_create into a single operation. Spec extends node_create_tree format with optional autoBind field. Example: { name: 'MyPopup', components: ['cc.UITransform', 'MyPopupView'], autoBind: 'MyPopupView', children: [{ name: 'CloseButton', components: ['cc.Button'] }] }",
inputSchema: {
type: "object",
properties: {
path: { type: "string", description: "db:// path for the prefab (e.g. 'db://assets/prefabs/MyPrefab.prefab')" },
spec: { description: "Node tree specification with optional autoBind field (string for component type to auto-bind)" },
autoBindMode: { type: "string", enum: ["fuzzy", "strict"], description: "Auto-bind matching mode (default: fuzzy)" },
},
required: ["path", "spec"],
},
},
];
}
async execute(toolName: string, args: Record<string, any>): Promise<ToolResult> {
switch (toolName) {
case "prefab_list":
return this.listPrefabs();
case "prefab_create":
return this.createPrefab(args.uuid, args.path);
case "prefab_instantiate":
return this.instantiatePrefab(args.prefabUuid, args.parent);
case "prefab_get_info":
return this.getPrefabInfo(args.uuid);
case "prefab_update":
return this.updatePrefab(args.uuid);
case "prefab_duplicate": {
try {
await (Editor.Message.request as any)("asset-db", "copy-asset", args.source, args.destination);
return ok({ success: true, source: args.source, destination: args.destination });
} catch (e: any) { return err(e.message || String(e)); }
}
case "prefab_validate": {
try {
const info = await (Editor.Message.request as any)("asset-db", "query-asset-info", args.uuid);
const deps = await (Editor.Message.request as any)("asset-db", "query-depends", args.uuid).catch(() => []);
return ok({ success: true, uuid: args.uuid, info, dependencies: deps, valid: !!info });
} catch (e: any) { return err(e.message || String(e)); }
}
case "prefab_revert":
return this.revertPrefab(args.uuid);
case "prefab_create_and_replace":
return this.createAndReplace(args.uuid, args.path);
case "prefab_open":
return this.openPrefab(args.uuid, args.path, !!args.force);
case "prefab_close":
return this.closePrefab(args.save !== false, args.sceneUuid, !!args.force);
case "prefab_create_from_spec":
return this.createFromSpec(args.path, parseMaybeJson(args.spec), args.autoBindMode ?? "fuzzy");
default:
return err(`Unknown tool: ${toolName}`);
}
}
private async listPrefabs(): Promise<ToolResult> {
try {
const results = await Editor.Message.request("asset-db", "query-assets", {
pattern: "db://assets/**/*.prefab",
});
const prefabs = (results || []).map((a: any) => ({
uuid: a.uuid,
path: a.path || a.url,
name: a.name,
}));
return ok({ success: true, prefabs });
} catch (e: any) {
return err(e.message || String(e));
}
}
private async createPrefab(nodeUuid: string, path: string): Promise<ToolResult> {
try {
// 既存Prefabがある場合は警告を返す(上書きダイアログでタイムアウトするため)
const existing = await this.assetExists(path);
if (existing) {
return err(
`Prefab already exists at "${path}". Use prefab_update instead to update an existing prefab. ` +
`Workflow: 1) prefab_instantiate to place in scene, 2) modify properties, 3) prefab_update to save.`
);
}
const result = await (Editor.Message.request as any)("scene", "create-prefab", nodeUuid, path);
return ok({ success: true, nodeUuid, path, result });
} catch (e: any) {
return err(e.message || String(e));
}
}
private async assetExists(path: string): Promise<boolean> {
try {
const pattern = path.replace(/\.prefab$/, "") + ".*";
const results = await Editor.Message.request("asset-db", "query-assets", { pattern });
return (results || []).length > 0;
} catch {
try {
const info = await (Editor.Message.request as any)("asset-db", "query-asset-info", path);
return !!info;
} catch {
return false;
}
}
}
private async instantiatePrefab(prefabUuid: string, parent?: string): Promise<ToolResult> {
try {
const nodeUuid = await Editor.Message.request("scene", "create-node", {
parent: parent || undefined,
assetUuid: prefabUuid,
});
// Prefab 編集モード中の場合、ネスト Prefab 情報を記憶
// prefab_update 時に JSON 後処理で asset/instance/nestedPrefabInstanceRoots を設定
if (parent) {
this._pendingNestedPrefabs.push({
nodeUuid,
prefabAssetUuid: prefabUuid,
parentUuid: parent,
});
}
return ok({ success: true, nodeUuid, prefabUuid });
} catch (e: any) {
return err(e.message || String(e));
}
}
private async updatePrefab(nodeUuid: string): Promise<ToolResult> {
try {
const result = await (Editor.Message.request as any)("scene", "apply-prefab", nodeUuid);
// ネスト Prefab の JSON 後処理
if (this._pendingNestedPrefabs.length > 0) {
await this._fixNestedPrefabJson(nodeUuid);
}
return ok({ success: true, nodeUuid, result });
} catch (e: any) {
return err(e.message || String(e));
}
}
/**
* prefab_update 後に Prefab JSON を後処理して、ネスト Prefab 参照を正しく設定する.
*/
private async _fixNestedPrefabJson(_rootNodeUuid: string): Promise<void> {
if (this._pendingNestedPrefabs.length === 0) return;
try {
// シーンを保存して Prefab JSON を書き出す
// 現在シーンが untitled (scene-2d) の場合、save-scene はダイアログを出すので
// safeSaveScene でスキップする。untitled でシーンにインスタンスが居るケースは
// 本来 prefab_open モードでのみ発生するので save が効く想定だが、
// テスト等で直接呼ばれた場合の保護として skip する。
const saved = await safeSaveScene();
if (!saved) {
console.warn(
"[PrefabTools] _fixNestedPrefabJson: save-scene skipped (untitled scene). " +
"Nested prefab JSON post-processing may be incomplete."
);
return;
}
await new Promise(r => setTimeout(r, 1500));
// prefab_open で記憶した UUID からファイルパスを取得
if (!this._currentPrefabUuid) return;
const prefabPath = await (Editor.Message.request as any)(
"asset-db", "query-path", this._currentPrefabUuid
);
if (!prefabPath) return;
if (!fs.existsSync(prefabPath)) return;
const data = JSON.parse(fs.readFileSync(prefabPath, "utf-8"));
// 各ネスト Prefab エントリを処理
for (const entry of this._pendingNestedPrefabs) {
// fileId でノードを検索(nodeUuid はシーン内 UUID、Prefab JSON 内では fileId)
let flpNodeIdx = -1;
for (let i = 0; i < data.length; i++) {
if (data[i].__type__ === "cc.PrefabInfo" && data[i].fileId === entry.nodeUuid) {
// この PrefabInfo を持つノードを探す
for (let j = 0; j < data.length; j++) {
if (data[j]._prefab?.__id__ === i) {
flpNodeIdx = j;
break;
}
}
break;
}
}
// fileId で見つからない場合、ノード名で検索
if (flpNodeIdx < 0) {
// Prefab アセット名を取得
const assetInfo = await (Editor.Message.request as any)("asset-db", "query-asset-info", entry.prefabAssetUuid);
const assetName = assetInfo?.name?.replace(".prefab", "") || "";
for (let i = 0; i < data.length; i++) {
if (data[i].__type__ === "cc.Node" && (data[i]._name === assetName || data[i]._name === undefined)) {
const prefabIdx = data[i]._prefab?.__id__;
if (prefabIdx != null && data[prefabIdx]?.asset?.__id__ === 0 && !data[prefabIdx]?.instance) {
flpNodeIdx = i;
break;
}
}
}
}
if (flpNodeIdx < 0) continue;
const prefabInfoIdx = data[flpNodeIdx]._prefab?.__id__;
if (prefabInfoIdx == null) continue;
// PrefabInfo を修正
const prefabInfo = data[prefabInfoIdx];
prefabInfo.root = { __id__: flpNodeIdx };
prefabInfo.asset = {
__uuid__: entry.prefabAssetUuid,
__expectedType__: "cc.Prefab",
};
// PrefabInstance を追加
if (!prefabInfo.instance) {
const instanceIdx = data.length;
data.push({
__type__: "cc.PrefabInstance",
fileId: crypto.randomBytes(16).toString("base64").replace(/[+/=]/g, "").substring(0, 22),
prefabRootNode: { __id__: 1 }, // Prefab 編集モードのルート
mountedChildren: [],
mountedComponents: [],
propertyOverrides: [],
removedComponents: [],
});
prefabInfo.instance = { __id__: instanceIdx };
}
// 子ノード・コンポーネントをクリア(Prefab アセットから復元される)
data[flpNodeIdx]._children = [];
data[flpNodeIdx]._components = [];
// ルートの nestedPrefabInstanceRoots に追加
const rootPrefabIdx = data[1]._prefab?.__id__;
if (rootPrefabIdx != null) {
const rootPrefab = data[rootPrefabIdx];
if (!rootPrefab.nestedPrefabInstanceRoots) {
rootPrefab.nestedPrefabInstanceRoots = [];
}
const alreadyNested = rootPrefab.nestedPrefabInstanceRoots.some(
(r: any) => r?.__id__ === flpNodeIdx
);
if (!alreadyNested) {
rootPrefab.nestedPrefabInstanceRoots.push({ __id__: flpNodeIdx });
}
}
}
fs.writeFileSync(prefabPath, JSON.stringify(data, null, 2), "utf-8");
this._pendingNestedPrefabs = [];
} catch (e: any) {
console.warn("[PrefabTools] _fixNestedPrefabJson failed:", e.message);
}
}
private async revertPrefab(nodeUuid: string): Promise<ToolResult> {
try {
const result = await (Editor.Message.request as any)("scene", "revert-prefab", nodeUuid);
return ok({ success: true, nodeUuid, result });
} catch (e: any) {
return err(e.message || String(e));
}
}
private async getPrefabInfo(uuid: string): Promise<ToolResult> {
try {
const info = await (Editor.Message.request as any)("asset-db", "query-asset-info", uuid);
return ok({ success: true, info });
} catch (e: any) {
return err(e.message || String(e));
}
}
private async createAndReplace(nodeUuid: string, path: string): Promise<ToolResult> {
try {
// 1. Check if prefab already exists
const existing = await this.assetExists(path);
if (existing) {
return err(
`Prefab already exists at "${path}". Delete it first or use a different path.`
);
}
// 2. Get node info (parent, sibling index, transform) before creating prefab
const nodeInfo = await this.sceneScript("getNodeInfo", [nodeUuid]);
if (!nodeInfo?.success) {
return err(`Node ${nodeUuid} not found`);
}
const parentUuid = nodeInfo.data?.parent;
// 3. Create prefab from the node
const prefabAssetUuid = await (Editor.Message.request as any)("scene", "create-prefab", nodeUuid, path);
if (!prefabAssetUuid) {
return err("create-prefab returned no asset UUID");
}
// 4. Delete the original node
await (Editor.Message.request as any)("scene", "remove-node", { uuid: nodeUuid });
// 5. Instantiate the prefab at the same parent
const newNodeUuid = await Editor.Message.request("scene", "create-node", {
parent: parentUuid || undefined,
assetUuid: prefabAssetUuid,
});
return ok({
success: true,
prefabAssetUuid,
prefabPath: path,
originalNodeUuid: nodeUuid,
newInstanceUuid: newNodeUuid,
});
} catch (e: any) {
return err(e.message || String(e));
}
}
private async openPrefab(uuid?: string, path?: string, force: boolean = false): Promise<ToolResult> {
try {
// Prefab を開くときも内部的にシーン切替が発生するので dirty untitled チェック
await ensureSceneSafeToSwitch(force);
// Resolve UUID from path if needed
let assetUuid = uuid;
if (!assetUuid && path) {
const info = await (Editor.Message.request as any)("asset-db", "query-asset-info", path);
assetUuid = info?.uuid;
}
if (!assetUuid) {
return err("Either uuid or path is required");
}
// Open prefab in editing mode (equivalent to double-click)
await (Editor.Message.request as any)("asset-db", "open-asset", assetUuid);
// Wait for prefab editing mode to initialize
await new Promise(r => setTimeout(r, 1000));
this._currentPrefabUuid = assetUuid;
this._pendingNestedPrefabs = [];
return ok({ success: true, uuid: assetUuid, mode: "prefab-edit" });
} catch (e: any) {
return err(e.message || String(e));
}
}
private async closePrefab(save: boolean, sceneUuid?: string, force: boolean = false): Promise<ToolResult> {
try {
// 1. Save prefab if requested
// prefab edit モード中は "current scene" = 編集中 Prefab なので save-scene は Prefab を保存する。
// ただし untitled フォールバックに当たった場合はダイアログ回避のためスキップする。
if (save) {
await safeSaveScene();
await new Promise(r => setTimeout(r, 500));
}
// 2. Determine which scene to return to
let targetScene = sceneUuid;
if (!targetScene) {
// Try project's start scene
try {
targetScene = await (Editor as any).Profile.getConfig("preview", "general.start_scene", "local");
} catch { /* ignore */ }
// Fallback to first scene
if (!targetScene || targetScene === "current_scene") {
const scenes = await Editor.Message.request("asset-db", "query-assets", {
ccType: "cc.SceneAsset",
pattern: "db://assets/**/*",
});
if (Array.isArray(scenes) && scenes.length > 0) {
targetScene = scenes[0].uuid;
}
}
}
// 3. Open the scene
if (targetScene) {
// prefab edit モードから戻る遷移もダイアログが出うる
await ensureSceneSafeToSwitch(force);
await (Editor.Message.request as any)("scene", "open-scene", targetScene);
await new Promise(r => setTimeout(r, 1000));
}
return ok({ success: true, returnedToScene: targetScene });
} catch (e: any) {
return err(e.message || String(e));
}
}
private async createFromSpec(prefabPath: string, spec: any, autoBindMode: string): Promise<ToolResult> {
try {
// 1. 既存 Prefab チェック
const existing = await this.assetExists(prefabPath);
if (existing) {
return err(
`Prefab already exists at "${prefabPath}". Delete it first or use a different path.`
);
}
// 2. シーンの最初の cc.Node UUID を取得(Scene UUID ではなく Canvas 等)
const hier = await this.sceneScript("getSceneHierarchy", [false]);
const hierarchy = hier?.hierarchy || [];
const firstNode = hierarchy[0];
if (!firstNode?.uuid) return err("Could not find a node in the current scene to use as parent");
const parentUuid = firstNode.uuid;
// 3. ノードツリーを構築
const autoBind = spec.autoBind;
const cleanSpec = { ...spec };
delete cleanSpec.autoBind;
const treeResult = await this.sceneScript("buildNodeTree", [parentUuid, cleanSpec]);
if (!treeResult?.success) return err(treeResult?.error || "buildNodeTree failed");
const nodeUuid = treeResult.data?.uuid;
if (!nodeUuid) return err("buildNodeTree returned no root node UUID");
// 4. フォント・SpriteFrame を Editor API 経由で設定(アセット依存追跡のため)
await this._applyDefaultAssets(nodeUuid);
// 5. autoBind 実行 (旧4)
let autoBindResult: any = null;
if (autoBind) {
if (!this._componentTools) {
return err("autoBind requires ComponentTools dependency (internal configuration error)");
}
const bindToolResult = await this._componentTools.execute("component_auto_bind", {
uuid: nodeUuid,
componentType: autoBind,
force: false,
mode: autoBindMode,
});
try {
autoBindResult = JSON.parse(bindToolResult.content[0].text);
} catch { autoBindResult = bindToolResult; }
}
// 6. Prefab 作成
const prefabAssetUuid = await (Editor.Message.request as any)(
"scene", "create-prefab", nodeUuid, prefabPath
);
if (!prefabAssetUuid) {
await (Editor.Message.request as any)("scene", "remove-node", { uuid: nodeUuid });
return err("create-prefab returned no asset UUID");
}
// 7. 一時ノードを削除
await (Editor.Message.request as any)("scene", "remove-node", { uuid: nodeUuid });
return ok({
success: true,
prefabAssetUuid,
path: prefabPath,
nodeTree: treeResult.data,
autoBind: autoBindResult,
});
} catch (e: any) {
return err(e.message || String(e));
}
}
/**
* buildNodeTree で作成したノードツリーの Label にプロジェクトフォントを設定する。
* Editor API (scene:set-property) 経由で設定することでアセット依存が正しく追跡される。
*/
private async _applyDefaultAssets(rootUuid: string): Promise<void> {
// プロジェクトのデフォルトフォントを検索(resources/fonts/ 配下の TTFFont)
let fontUuid: string | null = null;
try {
const assets = await Editor.Message.request("asset-db", "query-assets", {
pattern: "db://assets/resources/fonts/**",
ccType: "cc.TTFFont",
});
if (Array.isArray(assets) && assets.length > 0) {
fontUuid = assets[0].uuid;
}
} catch { /* ignore */ }
if (!fontUuid) return;
// 全子孫ノードを取得
const descendants = await this.sceneScript("getAllDescendants", [rootUuid]);
if (!descendants?.success) return;
const allNodes = [{ uuid: rootUuid, name: "root" }, ...descendants.data];
for (const node of allNodes) {
try {
const nodeDump = await (Editor.Message.request as any)("scene", "query-node", node.uuid);
if (!nodeDump) continue;
const comps = nodeDump.__comps__ || [];
for (let i = 0; i < comps.length; i++) {
const compType = comps[i].type || "";
// Label にフォント設定
if (compType === "cc.Label") {
const fontDump = comps[i].value?.font;
if (!fontDump?.value?.uuid) {
await (Editor.Message.request as any)("scene", "set-property", {
uuid: node.uuid,
path: `__comps__.${i}.font`,
dump: { type: "cc.TTFFont", value: { uuid: fontUuid } },
});
}
}
}
} catch { /* skip nodes that can't be queried */ }
}
}
private async sceneScript(method: string, args: any[]): Promise<any> {
return Editor.Message.request("scene", "execute-scene-script", {
name: "cocos-creator-mcp",
method,
args,
});
}
}
@@ -0,0 +1,84 @@
import { ToolCategory, ToolDefinition, ToolResult } from "../types";
import { ok, err } from "../tool-base";
export class PreferencesTools implements ToolCategory {
readonly categoryName = "preferences";
getTools(): ToolDefinition[] {
return [
{
name: "preferences_get",
description: "Get a preference value by key.",
inputSchema: {
type: "object",
properties: {
protocol: { type: "string", description: "Protocol name (e.g. 'general', 'builder', 'engine')" },
key: { type: "string", description: "Preference key" },
},
required: ["protocol", "key"],
},
},
{
name: "preferences_set",
description: "Set a preference value.",
inputSchema: {
type: "object",
properties: {
protocol: { type: "string", description: "Protocol name" },
key: { type: "string", description: "Preference key" },
value: { description: "Value to set" },
},
required: ["protocol", "key", "value"],
},
},
{
name: "preferences_get_all",
description: "Get all preferences for a given protocol.",
inputSchema: {
type: "object",
properties: {
protocol: { type: "string", description: "Protocol name (e.g. 'general')" },
},
required: ["protocol"],
},
},
{
name: "preferences_reset",
description: "Reset a preference to its default value.",
inputSchema: {
type: "object",
properties: {
protocol: { type: "string", description: "Protocol name" },
key: { type: "string", description: "Preference key to reset" },
},
required: ["protocol", "key"],
},
},
];
}
async execute(toolName: string, args: Record<string, any>): Promise<ToolResult> {
try {
switch (toolName) {
case "preferences_get": {
const value = Editor.Profile.getConfig(args.protocol, args.key);
return ok({ success: true, protocol: args.protocol, key: args.key, value });
}
case "preferences_set":
Editor.Profile.setConfig(args.protocol, args.key, args.value);
return ok({ success: true, protocol: args.protocol, key: args.key });
case "preferences_get_all": {
const config = Editor.Profile.getConfig(args.protocol);
return ok({ success: true, protocol: args.protocol, config });
}
case "preferences_reset":
Editor.Profile.removeConfig(args.protocol, args.key);
return ok({ success: true, protocol: args.protocol, key: args.key });
default:
return err(`Unknown tool: ${toolName}`);
}
} catch (e: any) {
return err(e.message || String(e));
}
}
}
@@ -0,0 +1,167 @@
import { ToolCategory, ToolDefinition, ToolResult } from "../types";
import { ok, err } from "../tool-base";
export class ProjectTools implements ToolCategory {
readonly categoryName = "project";
getTools(): ToolDefinition[] {
return [
{
name: "project_get_info",
description: "Get project information (name, path, engine version).",
inputSchema: {
type: "object",
properties: {},
},
},
{
name: "project_refresh_assets",
description: "Refresh the asset database to detect file changes.",
inputSchema: {
type: "object",
properties: {},
},
},
{
name: "project_get_asset_info",
description: "Get information about an asset by UUID.",
inputSchema: {
type: "object",
properties: {
uuid: { type: "string", description: "Asset UUID" },
},
required: ["uuid"],
},
},
{
name: "project_find_asset",
description: "Find assets by name pattern (glob). Returns matching asset paths and UUIDs.",
inputSchema: {
type: "object",
properties: {
pattern: { type: "string", description: "Glob pattern (e.g. 'db://assets/**/*.ts', 'db://assets/**/Button*')" },
},
required: ["pattern"],
},
},
{
name: "project_get_settings",
description: "Get project settings for a given protocol.",
inputSchema: {
type: "object",
properties: {
protocol: { type: "string", description: "Settings protocol (e.g. 'general', 'engine')" },
},
},
},
{
name: "project_set_settings",
description: "Set a project setting.",
inputSchema: {
type: "object",
properties: {
protocol: { type: "string" },
key: { type: "string" },
value: {},
},
required: ["protocol", "key", "value"],
},
},
{
name: "project_get_engine_info",
description: "Get engine version and path information.",
inputSchema: { type: "object", properties: {} },
},
{
name: "project_query_scripts",
description: "Query all script plugins in the project.",
inputSchema: { type: "object", properties: {} },
},
];
}
async execute(toolName: string, args: Record<string, any>): Promise<ToolResult> {
switch (toolName) {
case "project_get_info":
return this.getInfo();
case "project_refresh_assets":
return this.refreshAssets();
case "project_get_asset_info":
return this.getAssetInfo(args.uuid);
case "project_find_asset":
return this.findAsset(args.pattern);
case "project_get_settings": {
try {
const config = await (Editor.Message.request as any)("project", "query-config", args.protocol || "general");
return ok({ success: true, config });
} catch (e: any) { return err(e.message || String(e)); }
}
case "project_set_settings": {
try {
await (Editor.Message.request as any)("project", "set-config", args.protocol, args.key, args.value);
return ok({ success: true });
} catch (e: any) { return err(e.message || String(e)); }
}
case "project_get_engine_info": {
try {
const info = await (Editor.Message.request as any)("engine", "query-info");
return ok({ success: true, info });
} catch (e: any) { return err(e.message || String(e)); }
}
case "project_query_scripts": {
try {
const scripts = await (Editor.Message.request as any)("programming", "query-sorted-plugins");
return ok({ success: true, scripts });
} catch (e: any) { return err(e.message || String(e)); }
}
default:
return err(`Unknown tool: ${toolName}`);
}
}
private async getInfo(): Promise<ToolResult> {
try {
return ok({
success: true,
name: Editor.Project.name,
path: Editor.Project.path,
tmpDir: Editor.Project.tmpDir,
});
} catch (e: any) {
return err(e.message || String(e));
}
}
private async refreshAssets(): Promise<ToolResult> {
try {
await Editor.Message.request("asset-db", "refresh-asset", "db://assets");
return ok({ success: true });
} catch (e: any) {
return err(e.message || String(e));
}
}
private async getAssetInfo(uuid: string): Promise<ToolResult> {
try {
const info = await (Editor.Message.request as any)("asset-db", "query-asset-info", uuid);
return ok({ success: true, info });
} catch (e: any) {
return err(e.message || String(e));
}
}
private async findAsset(pattern: string): Promise<ToolResult> {
try {
const results = await Editor.Message.request("asset-db", "query-assets", { pattern });
const assets = (results || []).map((a: any) => ({
uuid: a.uuid,
path: a.path || a.url,
name: a.name,
type: a.type,
}));
return ok({ success: true, assets });
} catch (e: any) {
return err(e.message || String(e));
}
}
}
@@ -0,0 +1,150 @@
import { ToolCategory, ToolDefinition, ToolResult } from "../types";
import { ok, err } from "../tool-base";
export class ReferenceImageTools implements ToolCategory {
readonly categoryName = "referenceImage";
getTools(): ToolDefinition[] {
return [
{
name: "refimage_add",
description: "Add a reference image to the scene view.",
inputSchema: {
type: "object",
properties: {
path: { type: "string", description: "Image file path or db:// path" },
},
required: ["path"],
},
},
{
name: "refimage_remove",
description: "Remove a reference image by index.",
inputSchema: {
type: "object",
properties: {
index: { type: "number", description: "Image index" },
},
required: ["index"],
},
},
{
name: "refimage_list",
description: "List all reference images in the scene view.",
inputSchema: { type: "object", properties: {} },
},
{
name: "refimage_clear_all",
description: "Remove all reference images.",
inputSchema: { type: "object", properties: {} },
},
{
name: "refimage_switch",
description: "Switch to a specific reference image by index.",
inputSchema: {
type: "object",
properties: {
index: { type: "number", description: "Image index to switch to" },
},
required: ["index"],
},
},
{
name: "refimage_set_position",
description: "Set the position of the current reference image.",
inputSchema: {
type: "object",
properties: {
x: { type: "number" },
y: { type: "number" },
},
required: ["x", "y"],
},
},
{
name: "refimage_set_scale",
description: "Set the scale of the current reference image.",
inputSchema: {
type: "object",
properties: {
scale: { type: "number", description: "Scale factor" },
},
required: ["scale"],
},
},
{
name: "refimage_set_opacity",
description: "Set the opacity of the current reference image.",
inputSchema: {
type: "object",
properties: {
opacity: { type: "number", description: "Opacity (0-255)" },
},
required: ["opacity"],
},
},
{
name: "refimage_query_config",
description: "Get the current reference image configuration.",
inputSchema: { type: "object", properties: {} },
},
{
name: "refimage_query_current",
description: "Get info about the currently active reference image.",
inputSchema: { type: "object", properties: {} },
},
{
name: "refimage_refresh",
description: "Refresh the reference image display.",
inputSchema: { type: "object", properties: {} },
},
];
}
async execute(toolName: string, args: Record<string, any>): Promise<ToolResult> {
try {
switch (toolName) {
case "refimage_add":
await (Editor.Message.request as any)("scene", "add-reference-image", args.path);
return ok({ success: true, path: args.path });
case "refimage_remove":
await (Editor.Message.request as any)("scene", "remove-reference-image", args.index);
return ok({ success: true, index: args.index });
case "refimage_list": {
const config = await (Editor.Message.request as any)("scene", "query-reference-image-config").catch(() => null);
return ok({ success: true, config });
}
case "refimage_clear_all":
await (Editor.Message.request as any)("scene", "clear-all-reference-images");
return ok({ success: true });
case "refimage_switch":
await (Editor.Message.request as any)("scene", "switch-reference-image", args.index);
return ok({ success: true, index: args.index });
case "refimage_set_position":
await (Editor.Message.request as any)("scene", "set-reference-image-position", args.x, args.y);
return ok({ success: true, x: args.x, y: args.y });
case "refimage_set_scale":
await (Editor.Message.request as any)("scene", "set-reference-image-scale", args.scale);
return ok({ success: true, scale: args.scale });
case "refimage_set_opacity":
await (Editor.Message.request as any)("scene", "set-reference-image-opacity", args.opacity);
return ok({ success: true, opacity: args.opacity });
case "refimage_query_config": {
const config = await (Editor.Message.request as any)("scene", "query-reference-image-config").catch(() => null);
return ok({ success: true, config });
}
case "refimage_query_current": {
const current = await (Editor.Message.request as any)("scene", "query-current-reference-image").catch(() => null);
return ok({ success: true, current });
}
case "refimage_refresh":
await (Editor.Message.request as any)("scene", "refresh-reference-image");
return ok({ success: true });
default:
return err(`Unknown tool: ${toolName}`);
}
} catch (e: any) {
return err(e.message || String(e));
}
}
}
@@ -0,0 +1,635 @@
import { ToolCategory, ToolDefinition, ToolResult } from "../types";
import { ok, err } from "../tool-base";
import { ensureSceneSafeToSwitch } from "./scene-tools";
const EXT_NAME = "cocos-creator-mcp";
export class SceneAdvancedTools implements ToolCategory {
readonly categoryName = "sceneAdvanced";
getTools(): ToolDefinition[] {
return [
{
name: "scene_execute_script",
description: "Execute a scene script method by name with arguments.",
inputSchema: {
type: "object",
properties: {
method: { type: "string", description: "Scene script method name" },
args: { type: "array", description: "Arguments to pass", items: {} },
},
required: ["method"],
},
},
{
name: "scene_snapshot",
description: "Take a snapshot of the current scene state (for undo).",
inputSchema: { type: "object", properties: {} },
},
{
name: "scene_query_dirty",
description: "Check if the current scene has unsaved changes.",
inputSchema: { type: "object", properties: {} },
},
{
name: "scene_query_classes",
description: "Query all available component classes in the scene.",
inputSchema: { type: "object", properties: {} },
},
{
name: "scene_query_components",
description: "Query available components for a given node.",
inputSchema: {
type: "object",
properties: {
uuid: { type: "string", description: "Node UUID" },
},
required: ["uuid"],
},
},
{
name: "scene_query_node_tree",
description: "Query the raw node tree from the editor (alternative to scene_get_hierarchy).",
inputSchema: { type: "object", properties: {} },
},
{
name: "scene_query_nodes_by_asset",
description: "Find all nodes that reference a given asset UUID.",
inputSchema: {
type: "object",
properties: {
assetUuid: { type: "string", description: "Asset UUID to search for" },
},
required: ["assetUuid"],
},
},
{
name: "scene_soft_reload",
description: "Soft reload the current scene without losing state.",
inputSchema: { type: "object", properties: {} },
},
{
name: "scene_reset_node_transform",
description: "Reset a node's transform to default (position 0,0,0 / rotation 0,0,0 / scale 1,1,1).",
inputSchema: {
type: "object",
properties: {
uuid: { type: "string", description: "Node UUID" },
},
required: ["uuid"],
},
},
{
name: "scene_copy_node",
description: "Copy a node to clipboard.",
inputSchema: {
type: "object",
properties: {
uuid: { type: "string", description: "Node UUID" },
},
required: ["uuid"],
},
},
{
name: "scene_paste_node",
description: "Paste node from clipboard under a parent.",
inputSchema: {
type: "object",
properties: {
parentUuid: { type: "string", description: "Parent node UUID" },
},
required: ["parentUuid"],
},
},
{
name: "scene_create",
description: "Create a new empty 2D scene. If path is omitted, uses the editor's built-in new-scene command (may not work on CC 3.8.x). If path is specified, creates a .scene file via asset-db as a fallback. Returns an error if the current scene is dirty and untitled (to avoid modal save dialog); pass force=true to bypass.",
inputSchema: {
type: "object",
properties: {
path: { type: "string", description: "Scene asset path (e.g. 'db://assets/scenes/NewScene.scene'). If omitted, uses editor's new-scene command." },
force: { type: "boolean", description: "Skip dirty-scene preflight check (may trigger modal save dialog)" },
},
},
},
{
name: "scene_cut_node",
description: "Cut a node to clipboard (removes from scene).",
inputSchema: {
type: "object",
properties: { uuid: { type: "string", description: "Node UUID" } },
required: ["uuid"],
},
},
{
name: "scene_reset_property",
description: "Reset a specific property on a node or component to its default value.",
inputSchema: {
type: "object",
properties: {
uuid: { type: "string", description: "Node or component UUID" },
path: { type: "string", description: "Property path (e.g. 'position', 'color')" },
},
required: ["uuid", "path"],
},
},
{
name: "scene_reset_component",
description: "Reset a component to its default state.",
inputSchema: {
type: "object",
properties: { uuid: { type: "string", description: "Component UUID" } },
required: ["uuid"],
},
},
{
name: "scene_execute_component_method",
description: "Call a method on a component at edit-time.",
inputSchema: {
type: "object",
properties: {
uuid: { type: "string", description: "Component UUID" },
method: { type: "string", description: "Method name" },
args: { type: "array", description: "Method arguments", items: {} },
},
required: ["uuid", "method"],
},
},
{
name: "scene_move_array_element",
description: "Move an array element to a new position in a property.",
inputSchema: {
type: "object",
properties: {
uuid: { type: "string", description: "Node or component UUID" },
path: { type: "string", description: "Array property path" },
target: { type: "number", description: "Current index" },
offset: { type: "number", description: "Move offset (+1 = down, -1 = up)" },
},
required: ["uuid", "path", "target", "offset"],
},
},
{
name: "scene_remove_array_element",
description: "Remove an element from an array property by index.",
inputSchema: {
type: "object",
properties: {
uuid: { type: "string", description: "Node or component UUID" },
path: { type: "string", description: "Array property path" },
index: { type: "number", description: "Index to remove" },
},
required: ["uuid", "path", "index"],
},
},
{
name: "scene_snapshot_abort",
description: "Abort the current undo snapshot.",
inputSchema: { type: "object", properties: {} },
},
{
name: "scene_query_ready",
description: "Check if the scene is fully loaded and ready.",
inputSchema: { type: "object", properties: {} },
},
{
name: "scene_query_component_has_script",
description: "Check if a component has an associated script file.",
inputSchema: {
type: "object",
properties: { name: { type: "string", description: "Component class name" } },
required: ["name"],
},
},
{
name: "scene_restore_prefab",
description: "Restore a prefab node to its original prefab state.",
inputSchema: {
type: "object",
properties: { uuid: { type: "string", description: "Node UUID" } },
required: ["uuid"],
},
},
{
name: "scene_begin_undo",
description: "Begin recording undo operations.",
inputSchema: { type: "object", properties: {} },
},
{
name: "scene_end_undo",
description: "End undo recording and save the undo step.",
inputSchema: { type: "object", properties: {} },
},
{
name: "scene_cancel_undo",
description: "Cancel the current undo recording.",
inputSchema: { type: "object", properties: {} },
},
{
name: "scene_save_as",
description: "Save the current scene to a new file (shows save dialog).",
inputSchema: { type: "object", properties: {} },
},
// ── 以下、既存MCP未対応のEditor API ──
{
name: "scene_set_parent",
description: "Reparent node(s) using the official Editor API (alternative to node_move).",
inputSchema: {
type: "object",
properties: {
uuids: { type: "array", items: { type: "string" }, description: "Node UUID(s) to move" },
parent: { type: "string", description: "New parent node UUID" },
keepWorldTransform: { type: "boolean", description: "Keep world position (default false)" },
},
required: ["uuids", "parent"],
},
},
{
name: "scene_query_node",
description: "Get a full property dump of a node (all serialized data).",
inputSchema: {
type: "object",
properties: { uuid: { type: "string", description: "Node UUID" } },
required: ["uuid"],
},
},
{
name: "scene_query_component",
description: "Get a full property dump of a component by its UUID.",
inputSchema: {
type: "object",
properties: { uuid: { type: "string", description: "Component UUID" } },
required: ["uuid"],
},
},
{
name: "scene_query_scene_bounds",
description: "Get the bounding rect of the current scene.",
inputSchema: { type: "object", properties: {} },
},
];
}
async execute(toolName: string, args: Record<string, any>): Promise<ToolResult> {
try {
switch (toolName) {
case "scene_execute_script":
return ok(await this.sceneScript(args.method, args.args || []));
case "scene_snapshot":
return ok(await (Editor.Message.request as any)("scene", "snapshot"));
case "scene_query_dirty": {
const dirty = await (Editor.Message.request as any)("scene", "query-dirty");
return ok({ success: true, dirty });
}
case "scene_query_classes": {
const classes = await (Editor.Message.request as any)("scene", "query-classes");
return ok({ success: true, classes });
}
case "scene_query_components": {
const comps = await (Editor.Message.request as any)("scene", "query-components", args.uuid);
return ok({ success: true, components: comps });
}
case "scene_query_node_tree": {
const tree = await Editor.Message.request("scene", "query-node-tree");
return ok({ success: true, tree });
}
case "scene_query_nodes_by_asset": {
const nodes = await (Editor.Message.request as any)("scene", "query-nodes-by-asset-uuid", args.assetUuid);
return ok({ success: true, nodes });
}
case "scene_soft_reload":
await (Editor.Message.request as any)("scene", "soft-reload");
return ok({ success: true });
case "scene_reset_node_transform":
return await this.resetTransform(args.uuid);
case "scene_copy_node":
await (Editor.Message.request as any)("scene", "copy-node", args.uuid);
return ok({ success: true, uuid: args.uuid });
case "scene_paste_node": {
const result = await (Editor.Message.request as any)("scene", "paste-node", args.parentUuid);
return ok({ success: true, result });
}
case "scene_create":
return this.createScene(args.path, !!args.force);
case "scene_cut_node":
await (Editor.Message.request as any)("scene", "cut-node", args.uuid);
return ok({ success: true, uuid: args.uuid });
case "scene_reset_property":
await (Editor.Message.request as any)("scene", "reset-property", { uuid: args.uuid, path: args.path });
return ok({ success: true });
case "scene_reset_component":
await (Editor.Message.request as any)("scene", "reset-component", { uuid: args.uuid });
return ok({ success: true });
case "scene_execute_component_method": {
const result = await (Editor.Message.request as any)("scene", "execute-component-method", { uuid: args.uuid, name: args.method, args: args.args || [] });
return ok({ success: true, result });
}
case "scene_move_array_element":
await (Editor.Message.request as any)("scene", "move-array-element", { uuid: args.uuid, path: args.path, target: args.target, offset: args.offset });
return ok({ success: true });
case "scene_remove_array_element":
await (Editor.Message.request as any)("scene", "remove-array-element", { uuid: args.uuid, path: args.path, index: args.index });
return ok({ success: true });
case "scene_snapshot_abort":
await (Editor.Message.request as any)("scene", "snapshot-abort");
return ok({ success: true });
case "scene_query_ready": {
const ready = await (Editor.Message.request as any)("scene", "query-is-ready");
return ok({ success: true, ready });
}
case "scene_query_component_has_script": {
const hasScript = await (Editor.Message.request as any)("scene", "query-component-has-script", args.name);
return ok({ success: true, name: args.name, hasScript });
}
case "scene_restore_prefab":
await (Editor.Message.request as any)("scene", "restore-prefab", { uuid: args.uuid });
return ok({ success: true, uuid: args.uuid });
case "scene_begin_undo":
await (Editor.Message.request as any)("scene", "begin-recording");
return ok({ success: true });
case "scene_end_undo":
await (Editor.Message.request as any)("scene", "end-recording");
return ok({ success: true });
case "scene_cancel_undo":
await (Editor.Message.request as any)("scene", "cancel-recording");
return ok({ success: true });
case "scene_save_as": {
const result = await (Editor.Message.request as any)("scene", "save-as-scene");
return ok({ success: true, result });
}
case "scene_set_parent":
await (Editor.Message.request as any)("scene", "set-parent", {
parent: args.parent,
uuids: args.uuids,
keepWorldTransform: args.keepWorldTransform || false,
});
return ok({ success: true });
case "scene_query_node": {
const dump = await (Editor.Message.request as any)("scene", "query-node", args.uuid);
return ok({ success: true, node: dump });
}
case "scene_query_component": {
const dump = await (Editor.Message.request as any)("scene", "query-component", args.uuid);
return ok({ success: true, component: dump });
}
case "scene_query_scene_bounds": {
const bounds = await (Editor.Message.request as any)("scene", "query-scene-bounds");
return ok({ success: true, bounds });
}
default:
return err(`Unknown tool: ${toolName}`);
}
} catch (e: any) {
return err(e.message || String(e));
}
}
private async resetTransform(uuid: string): Promise<ToolResult> {
const result = await this.sceneScript("setNodeProperty", [uuid, "position", { x: 0, y: 0, z: 0 }]);
await this.sceneScript("setNodeProperty", [uuid, "rotation", { x: 0, y: 0, z: 0 }]);
await this.sceneScript("setNodeProperty", [uuid, "scale", { x: 1, y: 1, z: 1 }]);
return ok({ success: true, uuid });
}
private async createScene(path?: string, force: boolean = false): Promise<ToolResult> {
// ダイアログ割り込み防止: 現在シーンが dirty かつ untitled の場合は事前エラー
try { await ensureSceneSafeToSwitch(force); }
catch (e: any) { return err(e.message || String(e)); }
// まず scene:new-scene を試行(path 未指定時のみ)
if (!path) {
try {
await (Editor.Message.request as any)("scene", "new-scene");
return ok({ success: true });
} catch (e: any) {
const msg = e?.message || String(e);
if (msg.includes("Message does not exist") || msg.includes("scene - new-scene")) {
// CC 3.8.x → asset-db fallback にフォール
const fallbackPath = await this.generateAvailableScenePath();
return this.createSceneViaAssetDb(fallbackPath);
}
return err(msg);
}
}
// path 指定 → asset-db fallback
return this.createSceneViaAssetDb(path);
}
private async generateAvailableScenePath(): Promise<string> {
const basePath = "db://assets/NewScene.scene";
try {
const result = await (Editor.Message.request as any)("asset-db", "generate-available-url", basePath);
if (result) return result;
} catch { /* fallback */ }
return `db://assets/NewScene_${Date.now()}.scene`;
}
private async createSceneViaAssetDb(path: string): Promise<ToolResult> {
try {
if (!path.endsWith(".scene")) path += ".scene";
const sceneName = path.split("/").pop()!.replace(".scene", "");
const uid = () => crypto.randomUUID?.() ?? `${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
const sid = () => {
const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
let s = "";
for (let i = 0; i < 21; i++) s += chars[Math.floor(Math.random() * chars.length)];
return s;
};
const sceneJson = this.buildMinimalSceneJson(sceneName, uid, sid);
const content = JSON.stringify(sceneJson, null, 2);
await (Editor.Message.request as any)("asset-db", "create-asset", path, content);
// シーンを開く
try {
// ensureSceneSafeToSwitch は createScene 入口で既に通過済みなのでここでは再チェックしない
const queryResult = await (Editor.Message.request as any)("asset-db", "query-uuid", path);
if (queryResult) {
await (Editor.Message.request as any)("scene", "open-scene", queryResult);
}
} catch { /* open failure is not critical */ }
return ok({ success: true, path, method: "asset-db-fallback" });
} catch (e: any) {
return err(e.message || String(e));
}
}
private buildMinimalSceneJson(name: string, uid: () => string, sid: () => string): any[] {
const sceneId = uid();
const canvasNodeId = sid();
const cameraNodeId = sid();
const vec3 = (x: number, y: number, z: number) => ({ __type__: "cc.Vec3", x, y, z });
const quat = () => ({ __type__: "cc.Quat", x: 0, y: 0, z: 0, w: 1 });
return [
// [0] SceneAsset
{
__type__: "cc.SceneAsset",
_name: name,
_objFlags: 0,
__editorExtras__: {},
_native: "",
scene: { __id__: 1 },
},
// [1] Scene
{
__type__: "cc.Scene",
_name: name,
_objFlags: 0,
__editorExtras__: {},
_parent: null,
_children: [{ __id__: 2 }],
_active: true,
_components: [],
_prefab: null,
_lpos: vec3(0, 0, 0),
_lrot: quat(),
_lscale: vec3(1, 1, 1),
_mobility: 0,
_layer: 1073741824,
_euler: vec3(0, 0, 0),
autoReleaseAssets: false,
_globals: { __id__: 10 },
_id: sceneId,
},
// [2] Canvas node
{
__type__: "cc.Node",
_name: "Canvas",
_objFlags: 0,
__editorExtras__: {},
_parent: { __id__: 1 },
_children: [{ __id__: 3 }],
_active: true,
_components: [{ __id__: 5 }, { __id__: 6 }, { __id__: 7 }],
_prefab: null,
_lpos: vec3(0, 0, 0),
_lrot: quat(),
_lscale: vec3(1, 1, 1),
_mobility: 0,
_layer: 33554432,
_euler: vec3(0, 0, 0),
_id: canvasNodeId,
},
// [3] Camera node
{
__type__: "cc.Node",
_name: "Camera",
_objFlags: 0,
__editorExtras__: {},
_parent: { __id__: 2 },
_children: [],
_active: true,
_components: [{ __id__: 4 }],
_prefab: null,
_lpos: vec3(0, 0, 1000),
_lrot: quat(),
_lscale: vec3(1, 1, 1),
_mobility: 0,
_layer: 1073741824,
_euler: vec3(0, 0, 0),
_id: cameraNodeId,
},
// [4] Camera component
{
__type__: "cc.Camera",
_name: "",
_objFlags: 0,
__editorExtras__: {},
node: { __id__: 3 },
_enabled: true,
_projection: 1,
_priority: 0,
_fov: 45,
_fovAxis: 0,
_orthoHeight: 10,
_near: 1,
_far: 2000,
_color: { __type__: "cc.Color", r: 0, g: 0, b: 0, a: 255 },
_depth: 1,
_stencil: 0,
_clearFlags: 6,
_rect: { __type__: "cc.Rect", x: 0, y: 0, width: 1, height: 1 },
_visibility: 1108344832,
_id: "",
},
// [5] UITransform on Canvas
{
__type__: "cc.UITransform",
_name: "",
_objFlags: 0,
__editorExtras__: {},
node: { __id__: 2 },
_enabled: true,
_contentSize: { __type__: "cc.Size", width: 720, height: 1280 },
_anchorPoint: { __type__: "cc.Vec2", x: 0.5, y: 0.5 },
_id: "",
},
// [6] Canvas component
{
__type__: "cc.Canvas",
_name: "",
_objFlags: 0,
__editorExtras__: {},
node: { __id__: 2 },
_enabled: true,
_cameraComponent: { __id__: 4 },
_alignCanvasWithScreen: true,
_id: "",
},
// [7] Widget on Canvas (fullscreen)
{
__type__: "cc.Widget",
_name: "",
_objFlags: 0,
__editorExtras__: {},
node: { __id__: 2 },
_enabled: true,
_alignFlags: 15,
_target: null,
_left: 0,
_right: 0,
_top: 0,
_bottom: 0,
_isAbsLeft: true,
_isAbsRight: true,
_isAbsTop: true,
_isAbsBottom: true,
_originalWidth: 0,
_originalHeight: 0,
_id: "",
},
// [8] cc.PrefabInfo for scene
// [9] (reserved)
// [10] SceneGlobals
{
__type__: "cc.SceneGlobals",
ambient: { __id__: 11 },
shadows: { __id__: 12 },
_skybox: { __id__: 13 },
fog: { __id__: 14 },
},
// [11] AmbientInfo
{ __type__: "cc.AmbientInfo", _skyLightingColor: { __type__: "cc.Vec4", x: 0.2, y: 0.2, z: 0.2, w: 1 } },
// [12] ShadowsInfo
{ __type__: "cc.ShadowsInfo" },
// [13] SkyboxInfo
{ __type__: "cc.SkyboxInfo" },
// [14] FogInfo
{ __type__: "cc.FogInfo" },
];
}
private async sceneScript(method: string, args: any[]): Promise<any> {
return Editor.Message.request("scene", "execute-scene-script", {
name: EXT_NAME,
method,
args,
});
}
}
@@ -0,0 +1,361 @@
import { ToolCategory, ToolDefinition, ToolResult } from "../types";
import { ok, err } from "../tool-base";
const EXT_NAME = "cocos-creator-mcp";
/** シーン名から「untitled (保存先なし)」かどうかを判定 */
const UNTITLED_SCENE_NAMES = new Set(["scene-2d", "scene-3d", "Untitled", "NewScene", ""]);
/**
* 現在のシーンの dirty 状態を取得。
* CC バージョンによって query-dirty / query-is-dirty のどちらか(または両方)が存在するので両方試行。
*/
export async function queryCurrentSceneDirty(): Promise<boolean> {
try {
const r = await (Editor.Message.request as any)("scene", "query-dirty");
return !!r;
} catch { /* try alternate */ }
try {
const r = await (Editor.Message.request as any)("scene", "query-is-dirty");
return !!r;
} catch { /* assume clean if both fail */ }
return false;
}
/**
* 現在のシーン情報(名前・UUID)を取得。
*/
export async function queryCurrentSceneInfo(): Promise<{ sceneName: string; sceneUuid: string }> {
try {
const result: any = await Editor.Message.request(
"scene",
"execute-scene-script",
{ name: EXT_NAME, method: "getSceneHierarchy", args: [false] }
);
return { sceneName: result?.sceneName || "", sceneUuid: result?.sceneUuid || "" };
} catch {
return { sceneName: "", sceneUuid: "" };
}
}
/**
* 現在のシーンが untitled (scene-2d 等) かを判定。
*/
export async function isCurrentSceneUntitled(): Promise<boolean> {
const { sceneName } = await queryCurrentSceneInfo();
return UNTITLED_SCENE_NAMES.has(sceneName);
}
/**
* save-scene をダイアログ安全に呼び出す。
*
* - 現在のシーンが untitled (scene-2d 等) → no-op(ダイアログ防止)
* - それ以外 → save-scene を実行
*
* 任意の MCP ツール内部で save-scene を呼ぶ場合は必ずこれ経由にする。
* 直接 `Editor.Message.request("scene", "save-scene")` を呼ぶと、
* 現在シーンが untitled のときにモーダルダイアログが出て MCP が固まる。
*
* @returns 実際に保存を実行したか
*/
export async function safeSaveScene(): Promise<boolean> {
if (await isCurrentSceneUntitled()) {
console.warn(
"[cocos-creator-mcp] safeSaveScene: current scene is untitled, " +
"skipping save-scene to avoid modal dialog."
);
return false;
}
await (Editor.Message.request as any)("scene", "save-scene");
return true;
}
/**
* シーン切替系ツール(scene_open/close/new など)の前処理。
*
* - clean → OK
* - dirty + 保存先ありのシーン → save-scene で自動保存
* - dirty + untitled シーン → ダイアログを自動応答("Don't Save")して変更を破棄
*
* 目的: "Save changes?" ダイアログで MCP がブロックされるのを防ぐ。
*/
export async function ensureSceneSafeToSwitch(force: boolean = false): Promise<void> {
const isDirty = await queryCurrentSceneDirty();
if (!isDirty) return;
const { sceneName } = await queryCurrentSceneInfo();
const isUntitled = UNTITLED_SCENE_NAMES.has(sceneName);
if (isUntitled) {
// untitled + dirty: ダイアログが出るとMCPがブロックされるので、
// Electron dialog をパッチして「保存しない」を自動応答する。
// パッチは次回のシーン切替(呼び出し元の scene_open 等)でダイアログが出る瞬間に効く。
patchDialogForDiscard();
console.warn(
`[cocos-creator-mcp] ensureSceneSafeToSwitch: untitled scene "${sceneName}" ` +
`is dirty — dialog will auto-respond "Don't Save" to avoid blocking MCP.`
);
return;
}
try {
// ここに来る時点で untitled ではない(上で判定済み)ので直接 save-scene OK
await (Editor.Message.request as any)("scene", "save-scene");
} catch (e: any) {
if (force) {
console.warn(
`[cocos-creator-mcp] ensureSceneSafeToSwitch: save-scene failed but force=true — proceeding. ` +
`Error: ${e.message || e}`
);
return;
}
throw new Error(
`Failed to auto-save dirty scene "${sceneName}" before switch: ${e.message || e}. ` +
`Save manually and retry, or pass force=true to bypass.`
);
}
}
/**
* Electron の dialog.showMessageBox(Sync) を一時的にパッチして、
* 次回のダイアログで「保存しない (Don't Save)」を自動選択する。
*
* CocosCreator の "Save changes?" ダイアログは通常:
* buttons: ["Save", "Cancel", "Don't Save"] → index 2 = Don't Save
* または:
* buttons: ["Save", "Don't Save", "Cancel"] → index 1 = Don't Save
*
* ボタンテキストから "Don't Save" / "not" / "discard" を探し、
* 見つからなければ最後のボタン(通常 Don't Save)を選択する。
*/
function patchDialogForDiscard(): void {
try {
const electron = require("electron");
const dialog = electron.dialog;
const origSync = dialog.showMessageBoxSync;
const origAsync = dialog.showMessageBox;
function findDiscardIndex(buttons: string[]): number {
if (!buttons || buttons.length === 0) return 0;
const idx = buttons.findIndex((b: string) => {
const lower = b.toLowerCase();
return lower.includes("don't save") || lower.includes("not")
|| lower.includes("discard") || lower.includes("保存しない");
});
return idx >= 0 ? idx : buttons.length - 1;
}
// Sync version
dialog.showMessageBoxSync = function (...args: any[]) {
dialog.showMessageBoxSync = origSync; // 1回で復元
const options = args.length > 1 ? args[1] : args[0];
const buttons = options?.buttons || [];
const result = findDiscardIndex(buttons);
console.warn(`[cocos-creator-mcp] dialog auto-responded: button[${result}]="${buttons[result] || "?"}" (buttons: ${JSON.stringify(buttons)})`);
return result;
};
// Async version
dialog.showMessageBox = function (...args: any[]) {
dialog.showMessageBox = origAsync; // 1回で復元
const options = args.length > 1 ? args[1] : args[0];
const buttons = options?.buttons || [];
const result = findDiscardIndex(buttons);
console.warn(`[cocos-creator-mcp] dialog auto-responded (async): button[${result}]="${buttons[result] || "?"}" (buttons: ${JSON.stringify(buttons)})`);
return Promise.resolve({ response: result, checkboxChecked: false });
};
// 安全策: 5秒後に未使用なら復元(次のダイアログが出なかったケース)
setTimeout(() => {
if (dialog.showMessageBoxSync !== origSync) {
dialog.showMessageBoxSync = origSync;
}
if (dialog.showMessageBox !== origAsync) {
dialog.showMessageBox = origAsync;
}
}, 5000);
} catch (e: any) {
console.error(`[cocos-creator-mcp] patchDialogForDiscard failed: ${e.message || e}`);
}
}
export class SceneTools implements ToolCategory {
readonly categoryName = "scene";
getTools(): ToolDefinition[] {
return [
{
name: "scene_get_hierarchy",
description: "Get the node hierarchy of the current scene. Returns tree structure with uuid, name, active, and optionally components.",
inputSchema: {
type: "object",
properties: {
includeComponents: {
type: "boolean",
description: "Include component info for each node",
},
},
},
},
{
name: "scene_open",
description: "Open a scene by its asset UUID or database path (e.g. 'db://assets/scenes/Main.scene'). If the current scene is dirty and untitled, returns an error instead of triggering a modal save dialog that would block MCP. Pass force=true to bypass this guard.",
inputSchema: {
type: "object",
properties: {
scene: {
type: "string",
description: "Scene UUID or db:// path",
},
force: {
type: "boolean",
description: "Skip dirty-scene preflight check (may trigger modal save dialog)",
},
},
required: ["scene"],
},
},
{
name: "scene_save",
description: "Save the currently open scene.",
inputSchema: {
type: "object",
properties: {},
},
},
{
name: "scene_get_list",
description: "List all scene files in the project.",
inputSchema: {
type: "object",
properties: {},
},
},
{
name: "scene_close",
description: "Close the current scene. If the current scene is dirty and untitled, returns an error instead of triggering a modal save dialog. Pass force=true to bypass.",
inputSchema: {
type: "object",
properties: {
force: {
type: "boolean",
description: "Skip dirty-scene preflight check (may trigger modal save dialog)",
},
},
},
},
{
name: "scene_get_current",
description: "Get the name and UUID of the currently open scene.",
inputSchema: { type: "object", properties: {} },
},
];
}
async execute(toolName: string, args: Record<string, any>): Promise<ToolResult> {
switch (toolName) {
case "scene_get_hierarchy":
return this.getHierarchy(args.includeComponents ?? false);
case "scene_open":
return this.openScene(args.scene, !!args.force);
case "scene_save":
return this.saveScene();
case "scene_get_list":
return this.getSceneList();
case "scene_close":
try {
await ensureSceneSafeToSwitch(!!args.force);
await (Editor.Message.request as any)("scene", "close-scene");
return ok({ success: true });
} catch (e: any) { return err(e.message || String(e)); }
case "scene_get_current":
return this.getHierarchy(false).then((r) => {
const parsed = JSON.parse(r.content[0].text);
return ok({ success: true, sceneName: parsed.sceneName, sceneUuid: parsed.sceneUuid });
}).catch((e) => err(String(e)));
default:
return err(`Unknown tool: ${toolName}`);
}
}
private async getHierarchy(includeComponents: boolean): Promise<ToolResult> {
try {
const result = await Editor.Message.request(
"scene",
"execute-scene-script",
{
name: EXT_NAME,
method: "getSceneHierarchy",
args: [includeComponents],
}
);
return ok(result);
} catch (e: any) {
// Fallback: use query-node-tree
try {
const tree = await Editor.Message.request("scene", "query-node-tree");
return ok({ success: true, hierarchy: tree });
} catch (e2: any) {
return err(e2.message || String(e2));
}
}
}
private async openScene(scene: string, force: boolean): Promise<ToolResult> {
try {
await ensureSceneSafeToSwitch(force);
await Editor.Message.request("asset-db", "open-asset", scene);
return ok({ success: true, scene });
} catch (e: any) {
return err(e.message || String(e));
}
}
private async saveScene(): Promise<ToolResult> {
try {
// 現在のシーンが保存済みファイルか確認(新規シーンの場合はダイアログが出るのでスキップ)
const scenes = await Editor.Message.request("asset-db", "query-assets", {
pattern: "db://assets/**/*.scene",
}).catch(() => []);
// シーン名を取得して既存シーンか判定
const hierarchy = await Editor.Message.request(
"scene", "execute-scene-script",
{ name: "cocos-creator-mcp", method: "getSceneHierarchy", args: [false] }
).catch(() => null);
const sceneName = hierarchy?.sceneName;
const isNewScene = !sceneName || sceneName === "scene-2d" || sceneName === "Untitled";
if (isNewScene) {
return ok({ success: true, note: "New/untitled scene, skip save to avoid dialog" });
}
// シーンがdirtyでない場合は保存不要
const isDirty = await (Editor.Message.request as any)("scene", "query-is-dirty").catch(() => true);
if (!isDirty) {
return ok({ success: true, note: "Scene not dirty, skip save" });
}
const result = await (Editor.Message.request as any)("scene", "save-scene", false);
return ok({ success: true, result });
} catch (e: any) {
return err(e.message || String(e));
}
}
private async getSceneList(): Promise<ToolResult> {
try {
const results = await Editor.Message.request("asset-db", "query-assets", {
pattern: "db://assets/**/*.scene",
});
const scenes = (results || []).map((a: any) => ({
uuid: a.uuid,
path: a.path || a.url,
name: a.name,
}));
return ok({ success: true, scenes });
} catch (e: any) {
return err(e.message || String(e));
}
}
}
@@ -0,0 +1,232 @@
import { ToolCategory, ToolDefinition, ToolResult } from "../types";
import { ok, err } from "../tool-base";
export class SceneViewTools implements ToolCategory {
readonly categoryName = "sceneView";
getTools(): ToolDefinition[] {
return [
{
name: "view_change_gizmo_tool",
description: "Change the gizmo tool (move, rotate, scale, rect).",
inputSchema: {
type: "object",
properties: {
tool: { type: "string", enum: ["move", "rotate", "scale", "rect"], description: "Gizmo tool name" },
},
required: ["tool"],
},
},
{
name: "view_query_gizmo_tool",
description: "Get the currently active gizmo tool.",
inputSchema: { type: "object", properties: {} },
},
{
name: "view_change_gizmo_pivot",
description: "Change gizmo pivot mode (center or pivot).",
inputSchema: {
type: "object",
properties: {
pivot: { type: "string", enum: ["center", "pivot"], description: "Pivot mode" },
},
required: ["pivot"],
},
},
{
name: "view_query_gizmo_pivot",
description: "Get the current gizmo pivot mode.",
inputSchema: { type: "object", properties: {} },
},
{
name: "view_change_gizmo_coordinate",
description: "Change gizmo coordinate system (local or global).",
inputSchema: {
type: "object",
properties: {
coordinate: { type: "string", enum: ["local", "global"], description: "Coordinate system" },
},
required: ["coordinate"],
},
},
{
name: "view_query_gizmo_coordinate",
description: "Get the current gizmo coordinate system.",
inputSchema: { type: "object", properties: {} },
},
{
name: "view_change_mode_2d_3d",
description: "Switch between 2D and 3D view mode.",
inputSchema: {
type: "object",
properties: {
mode: { type: "string", enum: ["2d", "3d"], description: "View mode" },
},
required: ["mode"],
},
},
{
name: "view_query_mode_2d_3d",
description: "Get the current 2D/3D view mode.",
inputSchema: { type: "object", properties: {} },
},
{
name: "view_set_grid_visible",
description: "Show or hide the scene grid.",
inputSchema: {
type: "object",
properties: {
visible: { type: "boolean", description: "Whether to show the grid" },
},
required: ["visible"],
},
},
{
name: "view_query_grid_visible",
description: "Check if the scene grid is visible.",
inputSchema: { type: "object", properties: {} },
},
{
name: "view_focus_on_node",
description: "Focus the scene camera on specific node(s).",
inputSchema: {
type: "object",
properties: {
uuids: { type: "array", items: { type: "string" }, description: "Node UUIDs to focus on" },
},
required: ["uuids"],
},
},
{
name: "view_get_status",
description: "Get the current scene view status (gizmo tool, pivot, coordinate, grid, etc.).",
inputSchema: { type: "object", properties: {} },
},
{
name: "view_set_icon_gizmo_3d",
description: "Toggle 3D icon gizmos on/off.",
inputSchema: {
type: "object",
properties: { enabled: { type: "boolean" } },
required: ["enabled"],
},
},
{
name: "view_query_icon_gizmo_3d",
description: "Check if 3D icon gizmos are enabled.",
inputSchema: { type: "object", properties: {} },
},
{
name: "view_set_icon_gizmo_size",
description: "Set the icon gizmo size.",
inputSchema: {
type: "object",
properties: { size: { type: "number" } },
required: ["size"],
},
},
{
name: "view_query_icon_gizmo_size",
description: "Get the current icon gizmo size.",
inputSchema: { type: "object", properties: {} },
},
{
name: "view_align_with_view",
description: "Align the selected node with the current camera view.",
inputSchema: { type: "object", properties: {} },
},
{
name: "view_align_view_with_node",
description: "Align the camera view with the selected node.",
inputSchema: { type: "object", properties: {} },
},
{
name: "view_reset",
description: "Reset the scene view to default state.",
inputSchema: { type: "object", properties: {} },
},
];
}
async execute(toolName: string, args: Record<string, any>): Promise<ToolResult> {
try {
switch (toolName) {
case "view_change_gizmo_tool":
await (Editor.Message.request as any)("scene", "change-gizmo-tool", args.tool);
return ok({ success: true, tool: args.tool });
case "view_query_gizmo_tool": {
const tool = await (Editor.Message.request as any)("scene", "query-gizmo-tool-name");
return ok({ success: true, tool });
}
case "view_change_gizmo_pivot":
await (Editor.Message.request as any)("scene", "change-gizmo-pivot", args.pivot);
return ok({ success: true, pivot: args.pivot });
case "view_query_gizmo_pivot": {
const pivot = await (Editor.Message.request as any)("scene", "query-gizmo-pivot");
return ok({ success: true, pivot });
}
case "view_change_gizmo_coordinate":
await (Editor.Message.request as any)("scene", "change-gizmo-coordinate", args.coordinate);
return ok({ success: true, coordinate: args.coordinate });
case "view_query_gizmo_coordinate": {
const coord = await (Editor.Message.request as any)("scene", "query-gizmo-coordinate");
return ok({ success: true, coordinate: coord });
}
case "view_change_mode_2d_3d":
await (Editor.Message.request as any)("scene", "change-view-mode-2d-3d", args.mode);
return ok({ success: true, mode: args.mode });
case "view_query_mode_2d_3d": {
const mode = await (Editor.Message.request as any)("scene", "query-view-mode-2d-3d");
return ok({ success: true, mode });
}
case "view_set_grid_visible":
await (Editor.Message.request as any)("scene", "set-grid-visible", args.visible);
return ok({ success: true, visible: args.visible });
case "view_query_grid_visible": {
const visible = await (Editor.Message.request as any)("scene", "query-grid-visible");
return ok({ success: true, visible });
}
case "view_focus_on_node":
await (Editor.Message.request as any)("scene", "focus-camera-on-nodes", args.uuids);
return ok({ success: true, uuids: args.uuids });
case "view_get_status": {
const [tool, pivot, coord, mode, grid] = await Promise.all([
(Editor.Message.request as any)("scene", "query-gizmo-tool-name").catch(() => null),
(Editor.Message.request as any)("scene", "query-gizmo-pivot").catch(() => null),
(Editor.Message.request as any)("scene", "query-gizmo-coordinate").catch(() => null),
(Editor.Message.request as any)("scene", "query-view-mode-2d-3d").catch(() => null),
(Editor.Message.request as any)("scene", "query-grid-visible").catch(() => null),
]);
return ok({ success: true, tool, pivot, coordinate: coord, mode, gridVisible: grid });
}
case "view_set_icon_gizmo_3d":
await (Editor.Message.request as any)("scene", "set-icon-gizmo-3d", args.enabled);
return ok({ success: true, enabled: args.enabled });
case "view_query_icon_gizmo_3d": {
const enabled = await (Editor.Message.request as any)("scene", "query-is-icon-gizmo-3d");
return ok({ success: true, enabled });
}
case "view_set_icon_gizmo_size":
await (Editor.Message.request as any)("scene", "set-icon-gizmo-size", args.size);
return ok({ success: true, size: args.size });
case "view_query_icon_gizmo_size": {
const size = await (Editor.Message.request as any)("scene", "query-icon-gizmo-size");
return ok({ success: true, size });
}
case "view_align_with_view":
await (Editor.Message.request as any)("scene", "align-with-view");
return ok({ success: true });
case "view_align_view_with_node":
await (Editor.Message.request as any)("scene", "align-view-with-node");
return ok({ success: true });
case "view_reset":
await (Editor.Message.request as any)("scene", "reset-scene-view");
return ok({ success: true });
default:
return err(`Unknown tool: ${toolName}`);
}
} catch (e: any) {
return err(e.message || String(e));
}
}
}
@@ -0,0 +1,130 @@
import { ToolCategory, ToolDefinition, ToolResult } from "../types";
import { ok, err } from "../tool-base";
import { BUILD_HASH } from "../mcp-server";
export class ServerTools implements ToolCategory {
readonly categoryName = "server";
getTools(): ToolDefinition[] {
return [
{
name: "server_query_ip_list",
description: "Get the list of IP addresses the editor server is listening on.",
inputSchema: { type: "object", properties: {} },
},
{
name: "server_query_port",
description: "Get the port number of the editor's built-in server.",
inputSchema: { type: "object", properties: {} },
},
{
name: "server_get_status",
description: "Get the editor server status (IP, port, connectivity).",
inputSchema: { type: "object", properties: {} },
},
{
name: "server_get_build_hash",
description: "Get the build hash of the MCP server. The hash is derived from the code content, so identical code always produces the same hash.",
inputSchema: { type: "object", properties: {} },
},
{
name: "server_check_connectivity",
description: "Check if the editor server is reachable.",
inputSchema: { type: "object", properties: {} },
},
{
name: "server_get_network_interfaces",
description: "Get detailed network interface information.",
inputSchema: { type: "object", properties: {} },
},
{
name: "server_check_code_sync",
description: "Check if the running MCP extension code matches the latest build. Compares the runtime BUILD_HASH with the hash of dist/ files. Returns whether extension reload or CC restart is needed.",
inputSchema: { type: "object", properties: {} },
},
];
}
async execute(toolName: string, _args: Record<string, any>): Promise<ToolResult> {
try {
switch (toolName) {
case "server_query_ip_list": {
const ips = await (Editor.Message.request as any)("server", "query-ip-list");
return ok({ success: true, ips });
}
case "server_query_port": {
const port = await (Editor.Message.request as any)("server", "query-port");
return ok({ success: true, port });
}
case "server_get_status": {
const [ips, port] = await Promise.all([
(Editor.Message.request as any)("server", "query-ip-list").catch(() => []),
(Editor.Message.request as any)("server", "query-port").catch(() => null),
]);
return ok({ success: true, ips, port, buildId: BUILD_HASH });
}
case "server_get_build_hash": {
return ok({ success: true, buildHash: BUILD_HASH });
}
case "server_check_connectivity": {
try {
const port = await (Editor.Message.request as any)("server", "query-port");
return ok({ success: true, reachable: true, port });
} catch {
return ok({ success: true, reachable: false });
}
}
case "server_get_network_interfaces": {
const os = require("os");
const interfaces = os.networkInterfaces();
return ok({ success: true, interfaces });
}
case "server_check_code_sync": {
try {
const fs = require("fs");
const path = require("path");
const crypto = require("crypto");
const extDir = path.join(Editor.Project.path, "extensions", "cocos-creator-mcp", "dist");
if (!fs.existsSync(extDir)) {
return ok({ success: true, synced: false, note: "Extension dist/ not found", runtimeHash: BUILD_HASH });
}
const hash = crypto.createHash("sha256");
const collectJs = (dir: string, prefix = ""): string[] => {
let files: string[] = [];
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const rel = prefix ? `${prefix}/${entry.name}` : entry.name;
if (entry.isDirectory()) files = files.concat(collectJs(path.join(dir, entry.name), rel));
else if (entry.name.endsWith(".js")) files.push(rel);
}
return files;
};
for (const file of collectJs(extDir).sort()) {
let content = fs.readFileSync(path.join(extDir, file), "utf8");
// mcp-server.js の baked hash を postbuild 前と同じプレースホルダに逆置換
// (postbuild は __BUILD_HASH__ が残った状態でハッシュ計算しているため、同じ入力にする)
if (file === "mcp-server.js") {
content = content.replace(/exports\.BUILD_HASH = "[a-f0-9]{12}"/, 'exports.BUILD_HASH = "__BUILD_HASH__"');
}
hash.update(content.replace(/__BUILD_HASH__/g, ""));
}
const diskHash = hash.digest("hex").substring(0, 12);
const synced = diskHash === BUILD_HASH;
return ok({
success: true,
synced,
runtimeHash: BUILD_HASH,
diskHash,
action: synced ? "none" : "Extension reload or CC restart needed",
});
} catch (e: any) {
return ok({ success: true, synced: false, error: e.message, runtimeHash: BUILD_HASH });
}
}
default:
return err(`Unknown tool: ${toolName}`);
}
} catch (e: any) {
return err(e.message || String(e));
}
}
}
@@ -0,0 +1,56 @@
/** MCP Tool definition (JSON Schema based) */
export interface ToolDefinition {
name: string;
description: string;
inputSchema: {
type: "object";
properties: Record<string, any>;
required?: string[];
};
}
/** Result returned from tool execution */
export interface ToolResult {
content: Array<{ type: "text"; text: string }>;
isError?: boolean;
}
/** Interface that all tool categories must implement */
export interface ToolCategory {
readonly categoryName: string;
getTools(): ToolDefinition[];
execute(toolName: string, args: Record<string, any>): Promise<ToolResult>;
}
/** JSON-RPC 2.0 request */
export interface JsonRpcRequest {
jsonrpc: "2.0";
id?: string | number;
method: string;
params?: any;
}
/** JSON-RPC 2.0 response */
export interface JsonRpcResponse {
jsonrpc: "2.0";
id?: string | number | null;
result?: any;
error?: {
code: number;
message: string;
data?: any;
};
}
/** Server configuration */
export interface ServerConfig {
port: number;
autoStart: boolean;
autoArchiveRecordings: boolean;
}
export const DEFAULT_CONFIG: ServerConfig = {
port: 3000,
autoStart: false,
autoArchiveRecordings: false,
};
@@ -0,0 +1,10 @@
/**
* MCP クライアントが JSON オブジェクトを文字列化して送信する問題への共通対策.
* スキーマに type が未宣言のオブジェクト引数は文字列で届く場合がある.
*/
export function parseMaybeJson<T = any>(value: any): T {
if (typeof value === "string") {
try { return JSON.parse(value); } catch { /* string のまま */ }
}
return value;
}
@@ -0,0 +1,128 @@
/**
* Prefab保存テスト — プロパティがPrefabに保持されるか検証
*
* 前提: CocosCreatorでcocos-creator-mcpサーバーが起動中、MainScene開いてる
*/
const PORT = process.argv[2] || 3001;
const BASE = `http://127.0.0.1:${PORT}`;
let rpcId = 0;
async function rpc(method, params = {}) {
const res = await fetch(`${BASE}/mcp`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ jsonrpc: "2.0", id: ++rpcId, method, params }),
});
return res.json();
}
async function callTool(name, args = {}) {
const res = await rpc("tools/call", { name, arguments: args });
if (res.error) return { _rpcError: res.error };
const text = res.result?.content?.[0]?.text;
return text ? JSON.parse(text) : res.result;
}
async function sceneScript(method, args) {
// Call execute-scene-script through a raw JSON-RPC (not via tool)
// Actually we need to go through the MCP server... let's use node_set_property hack
// Or better: add a scene_script tool call
// For now, use the existing setComponentProperty that IS registered in scene.ts
// We can't call execute-scene-script directly from outside.
// But we CAN call the node_set_property tool which internally calls setNodeProperty scene script.
// For component property, we need a dedicated tool.
// Workaround: Use curl to call the Editor's Messages API if available
return null;
}
async function main() {
console.log("\n🧪 Prefab Property Persistence Test\n");
// Step 1: Create test node with Label
console.log("1. Creating test node with Label...");
const created = await callTool("node_create", {
name: "PrefabPropertyTest",
parent: "83A39G7PtBqLLEoVjvUo4h", // Canvas
components: ["cc.Label"],
});
console.log(` Node UUID: ${created.uuid}`);
const nodeUuid = created.uuid;
// Step 2: Set position (this should persist)
console.log("2. Setting position to (100, 200, 0)...");
await callTool("node_set_transform", {
uuid: nodeUuid,
position: { x: 100, y: 200, z: 0 },
});
// Step 3: Verify position was set
const info1 = await callTool("node_get_info", { uuid: nodeUuid });
console.log(` Position: (${info1.data?.position?.x}, ${info1.data?.position?.y})`);
console.log(` Components: ${info1.data?.components?.map(c => c.type).join(", ")}`);
// Step 4: Try to create prefab via Editor API
console.log("3. Attempting to create prefab via Editor.Message...");
// We'll try the create-prefab scene message
// This requires calling: Editor.Message.request("scene", "create-prefab", nodeUuid, url)
// Since we don't have a tool for this, let's try different approaches:
// Approach A: Try "create-prefab" message (3 args: nodeUuid, url, ?)
const prefabUrl = "db://assets/test/PrefabPropertyTest.prefab";
// We need to add this capability. For now, let's check if we can use
// asset-db to create an asset
console.log(` Target: ${prefabUrl}`);
// Actually, let me just check the scene script createPrefabFromNode
// which IS registered in package.json scene methods
// But wait - our own MCP's scene.ts doesn't have createPrefabFromNode
console.log("\n⚠️ Prefab creation tool not yet implemented in our MCP.");
console.log(" This is what needs to be built for v0.5.\n");
// Step 5: Test what the Editor API returns for prefab-related messages
console.log("4. Probing available prefab-related Editor Messages...");
const messages = [
["scene", "create-prefab"],
["scene", "save-prefab"],
["asset-db", "create-asset"],
];
for (const [target, msg] of messages) {
try {
const res = await fetch(`${BASE}/mcp`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
jsonrpc: "2.0", id: ++rpcId, method: "tools/call",
params: { name: "node_get_info", arguments: { uuid: "test" } }
}),
});
console.log(` ${target}:${msg} — needs testing`);
} catch (e) {
console.log(` ${target}:${msg} — error: ${e.message}`);
}
}
// Cleanup
console.log("\n5. Cleaning up test node...");
await callTool("node_delete", { uuid: nodeUuid });
console.log(" Deleted.\n");
console.log("═".repeat(50));
console.log(" Summary:");
console.log(" - Node creation with components: ✅");
console.log(" - Position/transform setting: ✅");
console.log(" - Component property setting: ❓ (needs component tool)");
console.log(" - Prefab creation: ❓ (needs prefab tool)");
console.log(" - Property persistence in prefab: ❓ (blocked by above)");
console.log("═".repeat(50));
console.log("\n→ v0.5 で component + prefab ツール実装後に再検証");
}
main().catch(e => { console.error("Fatal:", e); process.exit(1); });
@@ -0,0 +1,128 @@
#!/usr/bin/env node
/**
* 独立测试服务器 - 模拟Cocos Creator MCP服务器的最小化实现
* 用于测试stdio-bridge.js的基本功能
*/
const http = require('http');
const PORT = 3000;
const MCP_PROTOCOL_VERSION = "2024-11-05";
// 模拟的工具列表(从README中提取的164个工具中的几个示例)
const MOCK_TOOLS = [
{
name: "scene_get_hierarchy",
description: "Get the node tree (with optional component info)",
inputSchema: {
type: "object",
properties: {
includeComponents: { type: "boolean", description: "Include component info in hierarchy" }
}
}
},
{
name: "node_create",
description: "Create node (with optional components)",
inputSchema: {
type: "object",
properties: {
name: { type: "string", description: "Node name" },
parentUuid: { type: "string", description: "Parent node UUID" }
},
required: ["name"]
}
},
{
name: "scene_save",
description: "Save the current scene",
inputSchema: {
type: "object",
properties: {}
}
}
];
const server = http.createServer((req, res) => {
if (req.method === 'POST' && req.url === '/mcp') {
let body = '';
req.on('data', chunk => body += chunk);
req.on('end', () => {
try {
const request = JSON.parse(body);
console.log('[TEST SERVER] Received:', request.method);
const response = {
jsonrpc: "2.0",
id: request.id
};
switch (request.method) {
case 'initialize':
response.result = {
protocolVersion: MCP_PROTOCOL_VERSION,
capabilities: {
tools: {}
},
serverInfo: {
name: "cocos-creator-mcp",
version: "1.14.0"
}
};
break;
case 'tools/list':
response.result = {
tools: MOCK_TOOLS
};
break;
case 'tools/call':
const toolName = request.params.name;
response.result = {
content: [{
type: "text",
text: JSON.stringify({
success: true,
tool: toolName,
message: `Called ${toolName} successfully`
})
}]
};
break;
default:
response.error = {
code: -32601,
message: "Method not found"
};
}
res.setHeader('Content-Type', 'application/json');
res.end(JSON.stringify(response));
} catch (e) {
res.statusCode = 400;
res.end(JSON.stringify({ error: e.message }));
}
});
} else if (req.method === 'GET' && req.url === '/health') {
res.setHeader('Content-Type', 'application/json');
res.end(JSON.stringify({ status: 'ok', tools: MOCK_TOOLS.length }));
} else {
res.statusCode = 404;
res.end('Not found');
}
});
server.listen(PORT, '127.0.0.1', () => {
console.log(`[TEST SERVER] Started on http://127.0.0.1:${PORT}/mcp`);
console.log(`[TEST SERVER] Health check: http://127.0.0.1:${PORT}/health`);
});
// 优雅关闭
process.on('SIGINT', () => {
console.log('[TEST SERVER] Shutting down...');
server.close(() => {
process.exit(0);
});
});
@@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ES2017",
"module": "CommonJS",
"moduleResolution": "node",
"inlineSourceMap": true,
"inlineSources": true,
"esModuleInterop": true,
"skipLibCheck": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"outDir": "./dist",
"rootDir": "./source",
"types": [
"node",
"@cocos/creator-types/editor"
]
},
"exclude": ["client", "test", "dist"]
}
@@ -0,0 +1,7 @@
{
"name": "YouleNexus",
"uuid": "15252ac4-d24e-4b2c-86b0-51caaf24f779",
"creator": {
"version": "3.8.8"
}
}
@@ -0,0 +1,3 @@
{
"__version__": "1.3.9"
}
@@ -0,0 +1,23 @@
{
"__version__": "3.0.9",
"game": {
"name": "未知游戏",
"app_id": "UNKNOW",
"c_id": "0"
},
"appConfigMaps": [
{
"app_id": "UNKNOW",
"config_id": "f9e835"
}
],
"configs": [
{
"app_id": "UNKNOW",
"config_id": "f9e835",
"config_name": "Default",
"config_remarks": "",
"services": []
}
]
}
@@ -0,0 +1,3 @@
{
"__version__": "1.0.1"
}
@@ -0,0 +1,226 @@
{
"__version__": "1.0.12",
"modules": {
"configs": {
"defaultConfig": {
"name": "Default Config",
"cache": {
"base": {
"_value": true
},
"gfx-webgl": {
"_value": true
},
"gfx-webgl2": {
"_value": true
},
"gfx-webgpu": {
"_value": false
},
"animation": {
"_value": true
},
"skeletal-animation": {
"_value": false
},
"3d": {
"_value": false
},
"meshopt": {
"_value": false
},
"2d": {
"_value": true
},
"sorting-2d": {
"_value": false
},
"rich-text": {
"_value": true
},
"mask": {
"_value": true
},
"graphics": {
"_value": true
},
"ui-skew": {
"_value": false
},
"affine-transform": {
"_value": true
},
"ui": {
"_value": true
},
"particle": {
"_value": false
},
"physics": {
"_value": false,
"_option": "physics-ammo"
},
"physics-ammo": {
"_value": false,
"_flags": {
"LOAD_BULLET_MANUALLY": false
}
},
"physics-cannon": {
"_value": false
},
"physics-physx": {
"_value": false,
"_flags": {
"LOAD_PHYSX_MANUALLY": false
}
},
"physics-builtin": {
"_value": false
},
"physics-2d": {
"_value": true,
"_option": "physics-2d-box2d"
},
"physics-2d-box2d": {
"_value": false
},
"physics-2d-box2d-wasm": {
"_value": false,
"_flags": {
"LOAD_BOX2D_MANUALLY": false
}
},
"physics-2d-builtin": {
"_value": false
},
"physics-2d-box2d-jsb": {
"_value": false
},
"intersection-2d": {
"_value": true
},
"primitive": {
"_value": false
},
"profiler": {
"_value": true
},
"occlusion-query": {
"_value": false
},
"geometry-renderer": {
"_value": false
},
"debug-renderer": {
"_value": false
},
"particle-2d": {
"_value": true
},
"audio": {
"_value": true
},
"video": {
"_value": true
},
"webview": {
"_value": true
},
"tween": {
"_value": true
},
"websocket": {
"_value": false
},
"websocket-server": {
"_value": false
},
"terrain": {
"_value": false
},
"light-probe": {
"_value": false
},
"tiled-map": {
"_value": true
},
"vendor-google": {
"_value": false
},
"spine": {
"_value": true,
"_option": "spine-3.8"
},
"spine-3.8": {
"_value": true,
"_flags": {
"LOAD_SPINE_MANUALLY": false
}
},
"spine-4.2": {
"_value": false,
"_flags": {
"LOAD_SPINE_MANUALLY": false
}
},
"dragon-bones": {
"_value": true
},
"marionette": {
"_value": false
},
"procedural-animation": {
"_value": false
},
"custom-pipeline-post-process": {
"_value": false
},
"render-pipeline": {
"_value": true,
"_option": "custom-pipeline"
},
"custom-pipeline": {
"_value": true
},
"legacy-pipeline": {
"_value": false
},
"xr": {
"_value": false
}
},
"includeModules": [
"2d",
"affine-transform",
"animation",
"audio",
"base",
"custom-pipeline",
"dragon-bones",
"gfx-webgl",
"gfx-webgl2",
"graphics",
"intersection-2d",
"mask",
"particle-2d",
"physics-2d-box2d",
"profiler",
"rich-text",
"spine-3.8",
"tiled-map",
"tween",
"ui",
"video",
"webview"
],
"noDeprecatedFeatures": {
"value": false,
"version": ""
},
"flags": {}
}
},
"globalConfigKey": "defaultConfig"
}
}
@@ -0,0 +1,23 @@
{
"__version__": "1.0.1",
"information": {
"customSplash": {
"id": "customSplash",
"label": "customSplash",
"enable": true,
"customSplash": {
"complete": false,
"form": "https://creator-api.cocos.com/api/form/show?sid=1316b2c5c5aea2b1143818ff1b28cd92"
}
},
"removeSplash": {
"id": "removeSplash",
"label": "removeSplash",
"enable": true,
"removeSplash": {
"complete": false,
"form": "https://creator-api.cocos.com/api/form/show?sid=1316b2c5c5aea2b1143818ff1b28cd92"
}
}
}
}
@@ -0,0 +1,3 @@
{
"__version__": "1.0.4"
}
@@ -0,0 +1,3 @@
{
"__version__": "1.0.6"
}
@@ -0,0 +1,9 @@
{
/* Base configuration. Do not edit this field. */
"extends": "./temp/tsconfig.cocos.json",
/* Add your custom configuration here. */
"compilerOptions": {
"strict": false
}
}
+23
View File
@@ -0,0 +1,23 @@
E:\YouLeGames\client\games2\Projects\Game_Surface_1\js\gameabc.min.js
E:\YouLeGames\client\games2\Projects\Game_Surface_1\js\gamemain.js
E:\YouLeGames\client\games2\Projects\Game_Surface_1\js\Game_Surface_1_Event.js
E:\YouLeGames\client\games2\Projects\Game_Surface_1\js\niuniu_surface_Event.js
E:\YouLeGames\client\games2\Projects\Game_Surface_1\js\Surface\Const.js
E:\YouLeGames\client\games2\Projects\Game_Surface_1\js\Surface\Data.js
E:\YouLeGames\client\games2\Projects\Game_Surface_1\js\Surface\Desk.js
E:\YouLeGames\client\games2\Projects\Game_Surface_1\js\Surface\Func.js
E:\YouLeGames\client\games2\Projects\Game_Surface_1\js\Surface\Game.js
E:\YouLeGames\client\games2\Projects\Game_Surface_1\js\Surface\GameUI.js
E:\YouLeGames\client\games2\Projects\Game_Surface_1\js\Surface\jquery.min.js
E:\YouLeGames\client\games2\Projects\Game_Surface_1\js\Surface\Logic.js
E:\YouLeGames\client\games2\Projects\Game_Surface_1\js\Surface\minhttp.js
E:\YouLeGames\client\games2\Projects\Game_Surface_1\js\Surface\Net.js
E:\YouLeGames\client\games2\Projects\Game_Surface_1\js\Surface\niuniu_surface_Event.js
E:\YouLeGames\client\games2\Projects\Game_Surface_1\js\Surface\Player.js
E:\YouLeGames\client\games2\Projects\Game_Surface_1\js\Surface\Utl_Input.js
E:\YouLeGames\client\games2\Projects\Game_Surface_1\js\Surface\Utl_Output.js
E:\YouLeGames\client\games2\Projects\Game_Surface_1\js\Surface\youle.develop.js
E:\YouLeGames\client\games2\Projects\Game_Surface_1\js\SubGame\banWords.js
E:\YouLeGames\client\games2\Projects\Game_Surface_1\js\SubGame\SubGame_Config.js
E:\YouLeGames\client\games2\Projects\Game_Surface_1\js\SubGame\SubGame_modify.js
E:\YouLeGames\client\games2\Projects\Game_Surface_1\js\SubGame\Banwords.js
+6
View File
@@ -0,0 +1,6 @@
LayerStartID=620
SpiritStartID=3001
ImgResStartID=1
TxtResStartID=200
VoiResStartID=1
GameStartFile=index.html
+1
View File
@@ -0,0 +1 @@
var app_getbattery=1;
+1
View File
@@ -0,0 +1 @@
var app_version=0;var app_gameconfig='1';var app_gamedir='1';var app_gamestart='1';var app_agent='1';var app_appversion='1';var app_market='2';var app_channel='1';var app_Launchtype=0; var app_getwifisignalLevel=1;var app_gamename=0;
+1
View File
@@ -0,0 +1 @@
var app_gamesname=new Array('1');
+1
View File
@@ -0,0 +1 @@
var app_getnetwork=1;
Binary file not shown.

After

Width:  |  Height:  |  Size: 223 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 809 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 180 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 358 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 598 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 172 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 261 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 160 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 689 B

Some files were not shown because too many files have changed in this diff Show More