8 Commits
Author SHA1 Message Date
winlifes 6405dced7d Release v0.4.0 2026-06-10 20:26:09 -07:00
winlifes 03e5ab8dfe Use English GitHub release notes 2026-05-20 20:57:47 -07:00
winlifes be8c1d624f Fix GitHub release notes workflow 2026-05-20 20:51:33 -07:00
winlifes 668898f0c6 Release v0.3.3 2026-05-20 20:13:18 -07:00
winlifes 87cfa48cda Ignore MCP registry token files 2026-05-20 19:29:34 -07:00
winlifes e3dc45468b Release v0.3.2 2026-05-20 19:24:33 -07:00
winlifes 63c18b0b5f Release v0.3.1 2026-05-20 19:15:04 -07:00
winlifes c57cf140a8 Release v0.3.0 2026-05-20 18:52:25 -07:00
36 changed files with 5989 additions and 338 deletions
+9
View File
@@ -75,3 +75,12 @@ jobs:
- name: Run tests - name: Run tests
run: npm test run: npm test
- name: Check generated docs
run: npm run docs:check
- name: Run release metadata checks
run: npm run release:check
- name: Run npm package dry-run
run: npm run pack:dry-run
+3
View File
@@ -1,4 +1,5 @@
.DS_Store .DS_Store
.mcpregistry_*
node_modules/ node_modules/
temp/ temp/
Temp/ Temp/
@@ -6,3 +7,5 @@ Library/
library/ library/
dist/ dist/
build/ build/
.release-tmp/
releases/
+95
View File
@@ -6,6 +6,101 @@ This project follows a simple changelog format inspired by [Keep a Changelog](ht
## [Unreleased] ## [Unreleased]
## [0.4.0] - 2026-06-11
### Added
- Added project identity metadata to `/health` and MCP `initialize` responses so clients and duplicate listeners can verify the active Cocos project safely.
- Added same-project listener attach behavior before port fallback, preventing accidental attachment to a different Cocos project on the same port.
- Added default-on JavaScript safety checks for `execute_javascript`, `execute_scene_script`, and `execute_editor_script`, with per-call `safety_checks` overrides.
- Added named tool profiles in the Cocos panel, including save, apply, delete, import, and export workflows.
- Added category-level tool exposure controls in the panel for quick enable, disable, and clear actions.
- Added asset dependency tools: `inspect_asset_dependencies` and `validate_asset_dependencies`.
- Added Cocos project/editor tools: `get_build_status`, `open_build_panel`, `run_project_preview`, `save_current_scene`, `get_editor_preference`, `set_editor_preference`, and `broadcast_editor_message`.
- Added Button event binding tools: `list_button_click_events` and `bind_button_click_event`.
- Added `create_cocos_mcp_project_skill` for generating a recommended local Codex workflow skill.
- Added release package sensitive-content scanning for npm/GitHub/MCP token-like values and private keys.
### Optimized
- Improved tool exposure UX for larger projects by making custom profiles reusable and shareable.
- Expanded generated tool documentation and README coverage for the new 37-tool `core` profile and 101-tool `full` profile.
- Improved release packaging confidence with checksum verification and content scanning.
### Changed
- Expanded the default `core` profile from 34 tools to 37 tools.
- Expanded the `full` profile from 89 tools to 101 tools.
- Split more tool implementations into focused modules under `lib/tools/`, including advanced assets, Cocos project/editor helpers, and scene event helpers.
- Persisted `executeJavascriptSafetyChecks`, active tool profile names, and saved tool profiles in project configuration.
### Fixed
- Fixed the GitHub Release workflow so release pages use generated English changelog-style `RELEASE_NOTES.md` instead of the artifact installation README.
- Fixed release package scanning false positives around normal MCP server config names while preserving credential detection.
### Security
- Added guardrails for JavaScript execution against obvious risky file-system and shell patterns, including delete/truncate calls, raw writable streams, path traversal, user/system absolute paths, and `child_process`.
## [0.3.3] - 2026-05-20
### Added
- Added generated tool reference documentation in `docs/TOOLS.md`.
- Added `docs:generate` and `docs:check` scripts to keep tool counts, profiles, categories, and descriptions synchronized with `lib/tool-registry.js`.
- Added a read-only `GET /tools` debug endpoint with curl examples for quick local troubleshooting.
- Added panel activity previews for recent tool calls and runtime logs.
- Added panel curl copy actions for `/health` and `/tools`.
### Optimized
- Added CI and release validation for generated tool documentation.
- Improved the MCP client config panel so the preview follows the selected client target.
- Improved the panel information architecture around troubleshooting and maintenance: version, update status, tool profile, client config, recent calls, and logs.
### Changed
- Refined tool category inference so `get_tool_catalog` is grouped with project/context tools.
- Split file-system tools and asset refresh helpers into `lib/tools/files.js` as the first registry modularization step.
### Fixed
- No runtime bug fixes in this release; this release focuses on product polish, debugging ergonomics, and maintainability.
## [0.3.2] - 2026-05-20
### Added
- Added an npm-installable `funplay-cocos-mcp` stdio wrapper that bridges MCP clients to the local Cocos HTTP endpoint.
- Added MCP Registry metadata in `server.json`, including npm package ownership metadata via `mcpName`.
- Added wrapper tests, npm pack dry-run verification, and registry validation scripts.
## [0.3.1] - 2026-05-20
### Added
- Added a documented release workflow and release checklist for Cocos extension publishing.
- Added release packaging scripts that generate a Cocos extension zip, release manifest, checksum file, and per-release README.
- Added CI validation for release metadata.
## [0.3.0] - 2026-05-20
### Added
- Added MCP `outputSchema` and `annotations` to listed tools.
- Added a standard structured tool result envelope with `ok`, `tool`, `callId`, `summary`, `data`, and follow-up `refs`.
- Added prefab workflow tools: `inspect_prefab`, `validate_prefab_references`, `duplicate_prefab`, `edit_prefab_json`, `create_prefab_instance`, `inspect_prefab_instance`, `apply_prefab_instance`, and `revert_prefab_instance`.
- Added `get_performance_snapshot` and expanded `validate_scene` with scene scale/performance-oriented counters.
- Added project AI instruction tools: `list_project_instructions`, `read_project_instruction`, `write_project_instruction`, and `create_project_skill`.
- Added tests for tool metadata, result envelopes, and project instruction helpers.
### Changed
- Expanded the default `core` profile from 28 tools to 34 tools.
- Expanded the `full` profile from 76 tools to 89 tools.
- Updated tool interaction logs to store concise result summaries from the standard envelope.
## [0.2.0] - 2026-05-20 ## [0.2.0] - 2026-05-20
### Added ### Added
+96 -19
View File
@@ -51,6 +51,8 @@ git clone https://github.com/FunplayAI/funplay-cocos-mcp.git extensions/funplay-
Then restart Cocos Creator or reload extensions from the editor. Then restart Cocos Creator or reload extensions from the editor.
For a non-git install, download `Funplay.CocosMcp.v<version>.zip` from the GitHub Releases page, unzip it, and move the extracted `funplay-cocos-mcp` folder into your project `extensions/` directory.
You can also install it globally by copying the folder into your Cocos Creator user extensions directory. You can also install it globally by copying the folder into your Cocos Creator user extensions directory.
### 2. Start the MCP Server ### 2. Start the MCP Server
@@ -63,16 +65,19 @@ Funplay > MCP Server
The server runs on `http://127.0.0.1:8765/` by default. The server runs on `http://127.0.0.1:8765/` by default.
If the configured port is already occupied, the extension automatically falls back to the next available local port and uses the actual running port for one-click MCP client configuration. If the configured port is already occupied, the extension first checks whether the existing listener belongs to the same Cocos project. Same-project listeners are reused safely; unrelated listeners trigger automatic fallback to the next available local port.
The panel is intentionally small: The panel is intentionally small:
- Enable or disable the MCP server - Enable or disable the MCP server
- Change the server port - Change the server port
- Switch tool exposure between `core`, `full`, and `custom` - Switch tool exposure between `core`, `full`, and `custom`
- Save, apply, import, and export named tool profiles
- Check the installed version against the latest GitHub release - Check the installed version against the latest GitHub release
- Tune tool exposure by category or individual tool - Inspect recent tool calls and runtime log previews
- Configure AI clients with one click - Tune tool exposure by category controls or individual tool names
- Configure AI clients with one click and preview the selected client config
- Copy quick `curl` commands for `/health` and `/tools`
- Expand debug output only when needed - Expand debug output only when needed
### 3. Configure Your AI Client ### 3. Configure Your AI Client
@@ -177,6 +182,31 @@ url = "http://127.0.0.1:8765/"
</details> </details>
### Optional: npm stdio Wrapper
If your MCP client prefers a local `stdio` command, install the npm wrapper after starting the Cocos editor server:
```bash
npm install -g funplay-cocos-mcp
```
Example MCP client entry:
```json
{
"mcpServers": {
"funplay_cocos": {
"command": "funplay-cocos-mcp",
"env": {
"FUNPLAY_COCOS_MCP_URL": "http://127.0.0.1:8765/"
}
}
}
}
```
The wrapper bridges stdio MCP traffic to the embedded Cocos HTTP endpoint. You can also run it with `npx funplay-cocos-mcp --url http://127.0.0.1:8765/`.
### 4. Verify the Connection ### 4. Verify the Connection
Open your AI client and try a few safe requests first: Open your AI client and try a few safe requests first:
@@ -188,6 +218,13 @@ Open your AI client and try a few safe requests first:
If these work, the MCP server, resources, prompts, and primary execution tool are connected correctly. If these work, the MCP server, resources, prompts, and primary execution tool are connected correctly.
For local transport debugging, the panel can copy these commands, or you can run them directly:
```bash
curl http://127.0.0.1:8765/health
curl http://127.0.0.1:8765/tools
```
### 5. Start Building ### 5. Start Building
Try a higher-level prompt in your AI client: Try a higher-level prompt in your AI client:
@@ -199,9 +236,12 @@ Try a higher-level prompt in your AI client:
- This extension is **Editor-only**. It is meant to automate Cocos Creator, not to add runtime dependencies to your final game build. - This extension is **Editor-only**. It is meant to automate Cocos Creator, not to add runtime dependencies to your final game build.
- The MCP server listens on `http://127.0.0.1:8765/` by default. - The MCP server listens on `http://127.0.0.1:8765/` by default.
- If the configured port is busy, the server automatically falls back to the next available port and the panel/client config use the actual running port. - If the configured port is busy, the server automatically falls back to the next available port and the panel/client config use the actual running port.
- The default `core` profile exposes 28 high-signal tools. Switch to `full` for all 76 tools, or use `custom` to include/exclude tool categories and individual tools. - `GET /health` and `GET /tools` are read-only debug endpoints for quick local checks outside an MCP client.
- The default `core` profile exposes 37 high-signal tools. Switch to `full` for all 101 tools, or use `custom` to include/exclude tool categories and individual tools.
- The panel includes a manual update check against the latest GitHub release. - The panel includes a manual update check against the latest GitHub release.
- Streamable HTTP responses follow the MCP transport requirements for `Accept`, `MCP-Protocol-Version`, JSON-RPC notifications/responses, and optional `Mcp-Session-Id` sessions. - Streamable HTTP responses follow the MCP transport requirements for `Accept`, `MCP-Protocol-Version`, JSON-RPC notifications/responses, and optional `Mcp-Session-Id` sessions.
- Tool listings include MCP `outputSchema` and `annotations`; structured tool results use a standard envelope with `ok`, `tool`, `callId`, `summary`, `data`, and follow-up `refs`.
- `execute_javascript` safety checks are enabled by default. They block obvious risky filesystem and shell patterns such as delete/truncate calls, raw writable streams, path traversal, user/system absolute paths, and `child_process`. This is a guardrail, not a full sandbox; a call can explicitly pass `safety_checks: false` when you have reviewed the risk.
- All exposed MCP tools execute directly. There is no extra approval toggle inside the Cocos extension. - All exposed MCP tools execute directly. There is no extra approval toggle inside the Cocos extension.
- File tools and `cocos://asset/path/...` resources are restricted to the active Cocos project root. - File tools and `cocos://asset/path/...` resources are restricted to the active Cocos project root.
- The recommended workflow is `execute_javascript` first, then focused helper tools for screenshots, diagnostics, assets, and inspection. - The recommended workflow is `execute_javascript` first, then focused helper tools for screenshots, diagnostics, assets, and inspection.
@@ -213,15 +253,15 @@ Try a higher-level prompt in your AI client:
- **Embedded Cocos Extension** — No separate Python daemon or external bridge process is required for the Cocos-side plugin - **Embedded Cocos Extension** — No separate Python daemon or external bridge process is required for the Cocos-side plugin
- **One-Click Client Configuration** — Configure Claude Code, Cursor, VS Code, Trae, Kiro, and Codex directly from Cocos Creator - **One-Click Client Configuration** — Configure Claude Code, Cursor, VS Code, Trae, Kiro, and Codex directly from Cocos Creator
- **Project Context Built In** — Exposes live project, scene, selection, script diagnostics, logs, and interaction-history resources - **Project Context Built In** — Exposes live project, scene, selection, script diagnostics, logs, and interaction-history resources
- **Focused by Default, Full When Needed** — `core` reduces tool-list noise; `full` exposes every available tool; `custom` lets you tune by category or tool - **Focused by Default, Full When Needed** — `core` reduces tool-list noise; `full` exposes every available tool; `custom` plus saved profiles lets you tune and restore tool exposure by category or tool
- **Visual Validation** — Scene/editor/preview screenshots and input simulation help AI verify UI and gameplay changes - **Visual Validation** — Scene/editor/preview screenshots and input simulation help AI verify UI and gameplay changes
## Highlights ## Highlights
- **76 Built-in Tools** — Scene hierarchy, editor state, selection workflows, assets, UI creation, components, files, logs, script diagnostics, screenshots, runtime control, and input simulation - **101 Built-in Tools** — Scene hierarchy, editor state, selection workflows, prefabs, assets, asset dependencies, project instructions, UI creation, components, files, logs, script diagnostics, screenshots, runtime control, build/preview helpers, editor preferences, event binding, and input simulation
- **Primary Unified Tool** — `execute_javascript` supports both `scene` and `editor` contexts - **Primary Unified Tool** — `execute_javascript` supports both `scene` and `editor` contexts
- **Resources & Prompts** — Live project/log resources plus reusable workflows like script fixing, scene validation, and playable prototype creation - **Resources & Prompts** — Live project/log resources plus reusable workflows like script fixing, scene validation, and playable prototype creation
- **Cocos Panel UI** — A minimal `Funplay > MCP Server` panel for service management, update checks, tool exposure, and MCP client setup - **Cocos Panel UI** — A compact `Funplay > MCP Server` panel for service management, update checks, tool exposure, recent activity, logs, curl diagnostics, and MCP client setup
- **Screenshot and Input Support** — Capture editor/scene/game/preview screenshots and send Electron-level mouse/keyboard events - **Screenshot and Input Support** — Capture editor/scene/game/preview screenshots and send Electron-level mouse/keyboard events
- **Vendor Agnostic** — Works with any AI client that supports MCP over HTTP JSON-RPC - **Vendor Agnostic** — Works with any AI client that supports MCP over HTTP JSON-RPC
@@ -235,20 +275,22 @@ Funplay MCP for Cocos follows the same design principles as Funplay MCP for Unit
| Embedded server | Built-in HTTP MCP server | Built-in HTTP MCP server | | Embedded server | Built-in HTTP MCP server | Built-in HTTP MCP server |
| Primary execution tool | `execute_javascript` | `execute_code` | | Primary execution tool | `execute_javascript` | `execute_code` |
| Primary language | JavaScript in scene/editor contexts | C# in Unity editor/runtime contexts | | Primary language | JavaScript in scene/editor contexts | C# in Unity editor/runtime contexts |
| Default profile | `core` with 28 tools | `core` focused tool profile | | Default profile | `core` with 37 tools | `core` focused tool profile |
| Full profile | 76 tools plus `custom` exposure | 79 tools | | Full profile | 101 tools plus `custom` exposure | 79 tools |
| Client setup | One-click config panel | One-click config window | | Client setup | One-click config panel | One-click config window |
## MCP Capabilities ## MCP Capabilities
The current package exposes four capability layers: The current package exposes four capability layers:
- **Tools** — 28 tools in `core`, 76 tools in `full`, plus `custom` include/exclude rules - **Tools** — 37 tools in `core`, 101 tools in `full`, plus `custom` include/exclude rules and saved tool profiles
- **Primary execution** — `execute_javascript` for scene/runtime and editor/browser automation - **Primary execution** — `execute_javascript` for scene/runtime and editor/browser automation
- **Prompts** — `fix_script_errors`, `create_playable_prototype`, `scene_validation`, and `auto_wire_scene` - **Prompts** — `fix_script_errors`, `create_playable_prototype`, `scene_validation`, and `auto_wire_scene`
- **Resources** — project context, scene summaries, current selection, script diagnostics, asset selection, logs, and MCP interaction history - **Resources** — project context, scene summaries, current selection, script diagnostics, asset selection, logs, and MCP interaction history
The default `core` set is intentionally small: `execute_javascript`, `execute_scene_script`, `execute_editor_script`, `get_editor_state`, `get_tool_catalog`, `check_for_updates`, `get_selection`, `set_selection`, `get_project_info`, `get_scene_info`, `get_hierarchy`, `list_scenes`, `open_scene`, `list_assets`, `inspect_asset`, `open_asset`, `select_asset`, `run_script_diagnostics`, `get_recent_logs`, `search_project_logs`, `clear_logs`, `validate_scene`, `get_script_diagnostic_context`, `get_runtime_state`, `capture_editor_screenshot`, `capture_scene_screenshot`, `capture_preview_screenshot`, and `list_editor_windows`. For the generated tool reference, including categories, profiles, and read/mutation hints, see [docs/TOOLS.md](./docs/TOOLS.md).
The default `core` set is intentionally small: `execute_javascript`, `execute_scene_script`, `execute_editor_script`, `get_editor_state`, `get_tool_catalog`, `check_for_updates`, `get_selection`, `list_project_instructions`, `read_project_instruction`, `set_selection`, `get_project_info`, `get_build_status`, `get_scene_info`, `get_hierarchy`, `list_scenes`, `open_scene`, `inspect_prefab`, `validate_prefab_references`, `inspect_prefab_instance`, `list_assets`, `inspect_asset`, `inspect_asset_dependencies`, `validate_asset_dependencies`, `open_asset`, `select_asset`, `run_script_diagnostics`, `get_recent_logs`, `search_project_logs`, `clear_logs`, `validate_scene`, `get_performance_snapshot`, `get_script_diagnostic_context`, `get_runtime_state`, `capture_editor_screenshot`, `capture_scene_screenshot`, `capture_preview_screenshot`, and `list_editor_windows`.
## Built-in Resources ## Built-in Resources
@@ -267,23 +309,25 @@ The default `core` set is intentionally small: `execute_javascript`, `execute_sc
## Built-in Tools ## Built-in Tools
Funplay MCP for Cocos currently ships with **76 tool functions** in the `full` profile: Funplay MCP for Cocos currently ships with **101 tool functions** in the `full` profile:
| Category | Tools | | Category | Tools |
|----------|-------| |----------|-------|
| **Script Execution** | `execute_javascript`, `execute_scene_script`, `execute_editor_script` | | **Script Execution** | `execute_javascript`, `execute_scene_script`, `execute_editor_script` |
| **Editor State** | `get_editor_state`, `get_tool_catalog`, `check_for_updates`, `get_selection`, `set_selection`, `get_editor_selection` | | **Editor State** | `get_editor_state`, `get_tool_catalog`, `check_for_updates`, `get_selection`, `set_selection`, `get_editor_selection` |
| **Project Instructions** | `list_project_instructions`, `read_project_instruction`, `write_project_instruction`, `create_project_skill`, `create_cocos_mcp_project_skill` |
| **Project & Scene** | `get_project_info`, `get_scene_info`, `get_hierarchy`, `find_nodes`, `inspect_node`, `list_scenes`, `open_scene`, `run_scene_asset` | | **Project & Scene** | `get_project_info`, `get_scene_info`, `get_hierarchy`, `find_nodes`, `inspect_node`, `list_scenes`, `open_scene`, `run_scene_asset` |
| **Node Editing** | `create_node`, `delete_node`, `set_node_transform` | | **Node Editing** | `create_node`, `delete_node`, `set_node_transform` |
| **Assets & Prefabs** | `list_assets`, `inspect_asset`, `open_asset`, `select_asset`, `delete_asset`, `list_prefabs`, `instantiate_prefab`, `get_editor_selection` | | **Assets & Prefabs** | `list_assets`, `inspect_asset`, `inspect_asset_dependencies`, `validate_asset_dependencies`, `open_asset`, `select_asset`, `delete_asset`, `list_prefabs`, `inspect_prefab`, `validate_prefab_references`, `duplicate_prefab`, `edit_prefab_json`, `create_prefab_instance`, `inspect_prefab_instance`, `apply_prefab_instance`, `revert_prefab_instance`, `instantiate_prefab` |
| **Components** | `list_components`, `inspect_component`, `add_component`, `remove_component`, `set_component_property`, `reset_component_property` | | **Components** | `list_components`, `inspect_component`, `add_component`, `remove_component`, `set_component_property`, `reset_component_property` |
| **UI** | `create_canvas`, `create_label`, `create_button`, `create_sprite` | | **UI** | `create_canvas`, `create_label`, `create_button`, `create_sprite` |
| **Camera** | `list_cameras`, `create_camera`, `set_camera_properties` | | **Camera** | `list_cameras`, `create_camera`, `set_camera_properties` |
| **Animation** | `list_animations`, `add_animation_clip`, `play_animation`, `stop_animation` | | **Animation** | `list_animations`, `add_animation_clip`, `play_animation`, `stop_animation` |
| **Files** | `read_file`, `get_file_snippet`, `write_file`, `replace_in_file`, `search_files`, `list_directory`, `exists`, `refresh_assets` | | **Files** | `read_file`, `get_file_snippet`, `write_file`, `replace_in_file`, `search_files`, `list_directory`, `exists`, `refresh_assets` |
| **Diagnostics & Logs** | `run_script_diagnostics`, `get_script_diagnostic_context`, `get_recent_logs`, `search_project_logs`, `clear_logs`, `validate_scene` | | **Diagnostics & Logs** | `run_script_diagnostics`, `get_script_diagnostic_context`, `get_recent_logs`, `search_project_logs`, `clear_logs`, `validate_scene`, `get_performance_snapshot` |
| **Build & Editor** | `get_build_status`, `open_build_panel`, `run_project_preview`, `save_current_scene`, `get_editor_preference`, `set_editor_preference`, `broadcast_editor_message` |
| **Runtime** | `get_runtime_state`, `pause_runtime`, `resume_runtime`, `set_time_scale` | | **Runtime** | `get_runtime_state`, `pause_runtime`, `resume_runtime`, `set_time_scale` |
| **Interaction** | `emit_node_event`, `simulate_button_click`, `invoke_component_method`, `simulate_mouse_click`, `simulate_mouse_drag`, `simulate_key_press`, `simulate_key_combo`, `simulate_preview_input` | | **Interaction & Events** | `emit_node_event`, `simulate_button_click`, `list_button_click_events`, `bind_button_click_event`, `invoke_component_method`, `simulate_mouse_click`, `simulate_mouse_drag`, `simulate_key_press`, `simulate_key_combo`, `simulate_preview_input` |
| **Screenshots & Windows** | `capture_desktop_screenshot`, `capture_editor_screenshot`, `capture_scene_screenshot`, `capture_game_screenshot`, `capture_preview_screenshot`, `list_editor_windows` | | **Screenshots & Windows** | `capture_desktop_screenshot`, `capture_editor_screenshot`, `capture_scene_screenshot`, `capture_game_screenshot`, `capture_preview_screenshot`, `list_editor_windows` |
## Primary Tool Examples ## Primary Tool Examples
@@ -324,8 +368,11 @@ Place `funplay-cocos-mcp.config.json` in the Cocos project root:
"enabledTools": [], "enabledTools": [],
"disabledTools": [], "disabledTools": [],
"enableSessions": false, "enableSessions": false,
"executeJavascriptSafetyChecks": true,
"autostart": true, "autostart": true,
"maxInteractionLogEntries": 50 "maxInteractionLogEntries": 50,
"activeToolProfileName": "",
"savedToolProfiles": []
} }
``` ```
@@ -335,7 +382,7 @@ Environment variables are also supported:
- `COCOS_MCP_PORT` - `COCOS_MCP_PORT`
- `COCOS_MCP_PROFILE` - `COCOS_MCP_PROFILE`
`toolProfile: "custom"` starts from the `core` set, then adds `enabledToolCategories` / `enabledTools` and removes `disabledToolCategories` / `disabledTools`. `enableSessions` is off by default because this server does not need cross-request client state for normal editor automation. `toolProfile: "custom"` starts from the `core` set, then adds `enabledToolCategories` / `enabledTools` and removes `disabledToolCategories` / `disabledTools`. The panel can save these exposure settings as named `savedToolProfiles` for quick restore or sharing. `enableSessions` is off by default because this server does not need cross-request client state for normal editor automation.
## Architecture ## Architecture
@@ -353,17 +400,47 @@ Cocos Creator Extension
│ └─ Minimal MCP Server panel │ └─ Minimal MCP Server panel
└─ lib/ └─ lib/
├─ assets, diagnostics, screenshots, input ├─ assets, diagnostics, screenshots, input
├─ tool-profiles, javascript-safety
├─ tools/
│ ├─ files
│ ├─ assets-advanced
│ ├─ cocos-project
│ └─ scene-events
└─ server, resources, prompts, tool registry └─ server, resources, prompts, tool registry
``` ```
The server speaks MCP-style HTTP JSON-RPC 2.0 and supports tools, resources, resource templates, prompts, and health checks. The server speaks MCP-style HTTP JSON-RPC 2.0 and supports tools, resources, resource templates, prompts, health checks, and a read-only `/tools` debug endpoint.
## Development ## Development
Run a syntax check before publishing changes: Run checks before publishing changes:
```bash ```bash
npm run check npm run check
npm test
npm run docs:check
npm run release:check
npm run pack:dry-run
```
Regenerate the tool reference after changing `lib/tool-registry.js`:
```bash
npm run docs:generate
```
To generate a GitHub Release-ready extension package:
```bash
npm run release:package
```
The package is written to `releases/<version>/` with a zip, manifest, checksum file, and release README. See [RELEASE_WORKFLOW.md](./RELEASE_WORKFLOW.md) and [RELEASE_CHECKLIST.md](./RELEASE_CHECKLIST.md) for the full process.
Validate MCP Registry metadata before publishing:
```bash
npm run registry:validate
``` ```
## License ## License
+97 -20
View File
@@ -51,6 +51,8 @@ git clone https://github.com/FunplayAI/funplay-cocos-mcp.git extensions/funplay-
然后重启 Cocos Creator,或在编辑器里重新加载扩展。 然后重启 Cocos Creator,或在编辑器里重新加载扩展。
如果不想用 git 安装,可以从 GitHub Releases 下载 `Funplay.CocosMcp.v<version>.zip`,解压后把 `funplay-cocos-mcp` 目录移动到项目的 `extensions/` 目录。
你也可以把目录复制到 Cocos Creator 的全局用户扩展目录中。 你也可以把目录复制到 Cocos Creator 的全局用户扩展目录中。
### 2. 启动 MCP Server ### 2. 启动 MCP Server
@@ -63,16 +65,19 @@ Funplay > MCP Server
服务默认运行在 `http://127.0.0.1:8765/` 服务默认运行在 `http://127.0.0.1:8765/`
如果配置端口已被占用,扩展会自动回退到下一个可用本地端口,并在一键客户端配置时使用实际运行端口。 如果配置端口已被占用,扩展会先检查已有 listener 是否属于同一个 Cocos 项目;同项目 listener 会被安全复用,无关 listener 才会自动回退到下一个可用本地端口。
面板刻意保持精简: 面板刻意保持精简:
- 启用或停用 MCP Server - 启用或停用 MCP Server
- 修改服务端口 - 修改服务端口
-`core` / `full` / `custom` 工具暴露模式之间切换 -`core` / `full` / `custom` 工具暴露模式之间切换
- 保存、套用、导入和导出命名工具 profile
- 检查当前安装版本是否落后于 GitHub 最新 Release - 检查当前安装版本是否落后于 GitHub 最新 Release
- 按工具分类或单个工具调整暴露范围 - 查看最近工具调用和运行日志预览
- 一键配置 AI 客户端 - 通过分类控制或单个工具名调整暴露范围
- 一键配置 AI 客户端,并随目标客户端预览对应配置
- 复制 `/health``/tools` 的快速 `curl` 排障命令
- 需要时再展开 Debug Output - 需要时再展开 Debug Output
### 3. 配置 AI 客户端 ### 3. 配置 AI 客户端
@@ -177,6 +182,31 @@ url = "http://127.0.0.1:8765/"
</details> </details>
### 可选:npm stdio Wrapper
如果 MCP 客户端更适合使用本地 `stdio` 命令,可以在启动 Cocos 编辑器内置服务后安装 npm wrapper
```bash
npm install -g funplay-cocos-mcp
```
示例 MCP 客户端配置:
```json
{
"mcpServers": {
"funplay_cocos": {
"command": "funplay-cocos-mcp",
"env": {
"FUNPLAY_COCOS_MCP_URL": "http://127.0.0.1:8765/"
}
}
}
}
```
这个 wrapper 会把 stdio MCP 流量桥接到 Cocos 内置 HTTP endpoint。也可以直接运行 `npx funplay-cocos-mcp --url http://127.0.0.1:8765/`
### 4. 验证连接 ### 4. 验证连接
先在 AI 客户端里试几个安全请求: 先在 AI 客户端里试几个安全请求:
@@ -188,6 +218,13 @@ url = "http://127.0.0.1:8765/"
如果这些都正常返回,说明 MCP server、resources、prompts 和主执行工具已经连通。 如果这些都正常返回,说明 MCP server、resources、prompts 和主执行工具已经连通。
如果要排查本地传输链路,面板可以直接复制下面的命令,也可以手动执行:
```bash
curl http://127.0.0.1:8765/health
curl http://127.0.0.1:8765/tools
```
### 5. 开始构建 ### 5. 开始构建
可以在 AI 客户端里尝试: 可以在 AI 客户端里尝试:
@@ -198,10 +235,13 @@ url = "http://127.0.0.1:8765/"
- 这是一个 **仅限 Editor** 的扩展,用于自动化 Cocos Creator,不会给最终游戏包添加运行时依赖。 - 这是一个 **仅限 Editor** 的扩展,用于自动化 Cocos Creator,不会给最终游戏包添加运行时依赖。
- MCP Server 默认监听 `http://127.0.0.1:8765/` - MCP Server 默认监听 `http://127.0.0.1:8765/`
- 如果配置端口被占用,服务会自动回退到下一个可用端口,面板与一键客户端配置会使用实际运行端口。 - 如果配置端口被占用,服务会先通过项目身份识别同项目已有 listener;无法确认同项目时才会自动回退到下一个可用端口,面板与一键客户端配置会使用实际运行端口。
- 默认 `core` profile 暴露 28 个高频工具;如果需要完整工具集,可在面板切到 `full`,暴露全部 76 个工具;也可以用 `custom` 按分类或工具名增删 - `GET /health``GET /tools` 是只读调试端点,方便不用 MCP 客户端也能快速检查本地服务
- 默认 `core` profile 暴露 37 个高频工具;如果需要完整工具集,可在面板切到 `full`,暴露全部 101 个工具;也可以用 `custom` 按分类或工具名增删。
- 面板提供手动更新检查,会对比当前安装版本和 GitHub 最新 Release。 - 面板提供手动更新检查,会对比当前安装版本和 GitHub 最新 Release。
- Streamable HTTP 响应已补齐 MCP 传输层要求,包括 `Accept``MCP-Protocol-Version`、JSON-RPC notification/response,以及可选 `Mcp-Session-Id` session。 - Streamable HTTP 响应已补齐 MCP 传输层要求,包括 `Accept``MCP-Protocol-Version`、JSON-RPC notification/response,以及可选 `Mcp-Session-Id` session。
- 工具列表会包含 MCP `outputSchema``annotations`;结构化工具结果统一使用包含 `ok``tool``callId``summary``data``refs` 的标准 envelope。
- `execute_javascript` 安全检查默认开启,会拦截明显高风险的文件系统和 shell 模式,例如删除/截断调用、原始写入流、路径穿越、用户/系统绝对路径和 `child_process`。这是防护栏,不是完整沙箱;确认风险后可在单次调用中显式传入 `safety_checks: false`
- 所有已暴露的 MCP 工具都会直接执行,Cocos 扩展里没有额外 approval 开关。 - 所有已暴露的 MCP 工具都会直接执行,Cocos 扩展里没有额外 approval 开关。
- 文件工具和 `cocos://asset/path/...` 资源默认只能访问当前 Cocos 项目根目录内的路径。 - 文件工具和 `cocos://asset/path/...` 资源默认只能访问当前 Cocos 项目根目录内的路径。
- 推荐工作流是优先使用 `execute_javascript`,再配合截图、诊断、资产、检查类工具。 - 推荐工作流是优先使用 `execute_javascript`,再配合截图、诊断、资产、检查类工具。
@@ -213,15 +253,15 @@ url = "http://127.0.0.1:8765/"
- **嵌入式 Cocos 扩展** — Cocos 侧不需要单独 Python 守护进程或外部 bridge - **嵌入式 Cocos 扩展** — Cocos 侧不需要单独 Python 守护进程或外部 bridge
- **一键客户端配置** — 在 Cocos Creator 内直接配置 Claude Code、Cursor、VS Code、Trae、Kiro、Codex - **一键客户端配置** — 在 Cocos Creator 内直接配置 Claude Code、Cursor、VS Code、Trae、Kiro、Codex
- **内建项目上下文** — 直接暴露项目、场景、选择、脚本诊断、日志和交互历史资源 - **内建项目上下文** — 直接暴露项目、场景、选择、脚本诊断、日志和交互历史资源
- **默认聚焦,必要时全量** — `core` 降低工具列表噪音,需要时切到 `full` 暴露全部工具;`custom` 可按分类或工具名调整 - **默认聚焦,必要时全量** — `core` 降低工具列表噪音,需要时切到 `full` 暴露全部工具;`custom` 与保存的 profile 可按分类或工具名调整并恢复
- **可视化验证** — 截图和输入模拟让 AI 能验证 UI 与玩法改动 - **可视化验证** — 截图和输入模拟让 AI 能验证 UI 与玩法改动
## 核心特性 ## 核心特性
- **76 个内置工具** — 覆盖场景层级、编辑器状态、选择工作流、资产、UI 创建、组件、文件、日志、脚本诊断、截图、运行态控制和输入模拟 - **101 个内置工具** — 覆盖场景层级、编辑器状态、选择工作流、Prefab、资产、资产依赖、项目指令、UI 创建、组件、文件、日志、脚本诊断、截图、运行态控制、构建/预览辅助、编辑器偏好、事件绑定和输入模拟
- **统一主工具** — `execute_javascript` 同时支持 `scene``editor` 两种上下文 - **统一主工具** — `execute_javascript` 同时支持 `scene``editor` 两种上下文
- **Resources 与 Prompts** — 实时项目/日志资源,以及脚本修复、场景验证、可玩原型等可复用工作流 - **Resources 与 Prompts** — 实时项目/日志资源,以及脚本修复、场景验证、可玩原型等可复用工作流
- **Cocos 图形面板** — `Funplay > MCP Server` 提供服务管理、更新检查、工具暴露和 MCP 客户端配置 - **Cocos 图形面板** — `Funplay > MCP Server` 提供服务管理、更新检查、工具暴露、最近活动、日志、curl 排障和 MCP 客户端配置
- **截图与输入支持** — 支持编辑器/场景/Game/Preview 截图,以及 Electron 级鼠标键盘事件 - **截图与输入支持** — 支持编辑器/场景/Game/Preview 截图,以及 Electron 级鼠标键盘事件
- **厂商无关** — 兼容任意支持 HTTP JSON-RPC MCP 的 AI 客户端 - **厂商无关** — 兼容任意支持 HTTP JSON-RPC MCP 的 AI 客户端
@@ -235,20 +275,22 @@ Funplay MCP for Cocos 延续 Funplay MCP for Unity 的设计原则,并针对 C
| 内置服务 | 内嵌 HTTP MCP Server | 内嵌 HTTP MCP Server | | 内置服务 | 内嵌 HTTP MCP Server | 内嵌 HTTP MCP Server |
| 主执行工具 | `execute_javascript` | `execute_code` | | 主执行工具 | `execute_javascript` | `execute_code` |
| 主语言 | 场景/编辑器上下文中的 JavaScript | Unity 编辑器/运行态中的 C# | | 主语言 | 场景/编辑器上下文中的 JavaScript | Unity 编辑器/运行态中的 C# |
| 默认工具集 | `core`28 个工具 | 聚焦版 `core` 工具集 | | 默认工具集 | `core`37 个工具 | 聚焦版 `core` 工具集 |
| 完整工具集 | 76 个工具,并支持 `custom` 暴露 | 79 个工具 | | 完整工具集 | 101 个工具,并支持 `custom` 暴露 | 79 个工具 |
| 客户端配置 | 一键配置面板 | 一键配置窗口 | | 客户端配置 | 一键配置面板 | 一键配置窗口 |
## MCP 能力结构 ## MCP 能力结构
当前包提供四层能力: 当前包提供四层能力:
- **Tools** — `core`28 个工具,`full`76 个工具,并支持 `custom` include/exclude 规则 - **Tools** — `core`37 个工具,`full`101 个工具,并支持 `custom` include/exclude 规则和命名工具 profile
- **Primary execution** — `execute_javascript` 用于场景/运行态和编辑器/browser 自动化 - **Primary execution** — `execute_javascript` 用于场景/运行态和编辑器/browser 自动化
- **Prompts** — `fix_script_errors``create_playable_prototype``scene_validation``auto_wire_scene` - **Prompts** — `fix_script_errors``create_playable_prototype``scene_validation``auto_wire_scene`
- **Resources** — 项目上下文、场景摘要、当前选择、脚本诊断、资产选择、日志和 MCP 交互历史 - **Resources** — 项目上下文、场景摘要、当前选择、脚本诊断、资产选择、日志和 MCP 交互历史
当前默认 `core` 工具集刻意保持精简,只包含:`execute_javascript``execute_scene_script``execute_editor_script``get_editor_state``get_tool_catalog``check_for_updates``get_selection``set_selection``get_project_info``get_scene_info``get_hierarchy``list_scenes``open_scene``list_assets``inspect_asset``open_asset``select_asset``run_script_diagnostics``get_recent_logs``search_project_logs``clear_logs``validate_scene``get_script_diagnostic_context``get_runtime_state``capture_editor_screenshot``capture_scene_screenshot``capture_preview_screenshot``list_editor_windows` 自动生成的工具参考文档见 [docs/TOOLS.md](./docs/TOOLS.md),里面包含工具分类、profile 和读写/变更提示
当前默认 `core` 工具集刻意保持精简,只包含:`execute_javascript``execute_scene_script``execute_editor_script``get_editor_state``get_tool_catalog``check_for_updates``get_selection``list_project_instructions``read_project_instruction``set_selection``get_project_info``get_build_status``get_scene_info``get_hierarchy``list_scenes``open_scene``inspect_prefab``validate_prefab_references``inspect_prefab_instance``list_assets``inspect_asset``inspect_asset_dependencies``validate_asset_dependencies``open_asset``select_asset``run_script_diagnostics``get_recent_logs``search_project_logs``clear_logs``validate_scene``get_performance_snapshot``get_script_diagnostic_context``get_runtime_state``capture_editor_screenshot``capture_scene_screenshot``capture_preview_screenshot``list_editor_windows`
## 内置 Resources ## 内置 Resources
@@ -267,23 +309,25 @@ Funplay MCP for Cocos 延续 Funplay MCP for Unity 的设计原则,并针对 C
## 内置工具 ## 内置工具
Funplay MCP for Cocos 当前在 `full` profile 下提供 **76 个工具函数** Funplay MCP for Cocos 当前在 `full` profile 下提供 **101 个工具函数**
| 分类 | 工具 | | 分类 | 工具 |
|------|------| |------|------|
| **脚本执行** | `execute_javascript`, `execute_scene_script`, `execute_editor_script` | | **脚本执行** | `execute_javascript`, `execute_scene_script`, `execute_editor_script` |
| **编辑器状态** | `get_editor_state`, `get_tool_catalog`, `check_for_updates`, `get_selection`, `set_selection`, `get_editor_selection` | | **编辑器状态** | `get_editor_state`, `get_tool_catalog`, `check_for_updates`, `get_selection`, `set_selection`, `get_editor_selection` |
| **项目指令** | `list_project_instructions`, `read_project_instruction`, `write_project_instruction`, `create_project_skill`, `create_cocos_mcp_project_skill` |
| **项目与场景** | `get_project_info`, `get_scene_info`, `get_hierarchy`, `find_nodes`, `inspect_node`, `list_scenes`, `open_scene`, `run_scene_asset` | | **项目与场景** | `get_project_info`, `get_scene_info`, `get_hierarchy`, `find_nodes`, `inspect_node`, `list_scenes`, `open_scene`, `run_scene_asset` |
| **节点编辑** | `create_node`, `delete_node`, `set_node_transform` | | **节点编辑** | `create_node`, `delete_node`, `set_node_transform` |
| **资产与 Prefab** | `list_assets`, `inspect_asset`, `open_asset`, `select_asset`, `delete_asset`, `list_prefabs`, `instantiate_prefab`, `get_editor_selection` | | **资产与 Prefab** | `list_assets`, `inspect_asset`, `inspect_asset_dependencies`, `validate_asset_dependencies`, `open_asset`, `select_asset`, `delete_asset`, `list_prefabs`, `inspect_prefab`, `validate_prefab_references`, `duplicate_prefab`, `edit_prefab_json`, `create_prefab_instance`, `inspect_prefab_instance`, `apply_prefab_instance`, `revert_prefab_instance`, `instantiate_prefab` |
| **组件** | `list_components`, `inspect_component`, `add_component`, `remove_component`, `set_component_property`, `reset_component_property` | | **组件** | `list_components`, `inspect_component`, `add_component`, `remove_component`, `set_component_property`, `reset_component_property` |
| **UI** | `create_canvas`, `create_label`, `create_button`, `create_sprite` | | **UI** | `create_canvas`, `create_label`, `create_button`, `create_sprite` |
| **相机** | `list_cameras`, `create_camera`, `set_camera_properties` | | **相机** | `list_cameras`, `create_camera`, `set_camera_properties` |
| **动画** | `list_animations`, `add_animation_clip`, `play_animation`, `stop_animation` | | **动画** | `list_animations`, `add_animation_clip`, `play_animation`, `stop_animation` |
| **文件** | `read_file`, `get_file_snippet`, `write_file`, `replace_in_file`, `search_files`, `list_directory`, `exists`, `refresh_assets` | | **文件** | `read_file`, `get_file_snippet`, `write_file`, `replace_in_file`, `search_files`, `list_directory`, `exists`, `refresh_assets` |
| **诊断与日志** | `run_script_diagnostics`, `get_script_diagnostic_context`, `get_recent_logs`, `search_project_logs`, `clear_logs`, `validate_scene` | | **诊断与日志** | `run_script_diagnostics`, `get_script_diagnostic_context`, `get_recent_logs`, `search_project_logs`, `clear_logs`, `validate_scene`, `get_performance_snapshot` |
| **构建与编辑器** | `get_build_status`, `open_build_panel`, `run_project_preview`, `save_current_scene`, `get_editor_preference`, `set_editor_preference`, `broadcast_editor_message` |
| **运行态** | `get_runtime_state`, `pause_runtime`, `resume_runtime`, `set_time_scale` | | **运行态** | `get_runtime_state`, `pause_runtime`, `resume_runtime`, `set_time_scale` |
| **交互** | `emit_node_event`, `simulate_button_click`, `invoke_component_method`, `simulate_mouse_click`, `simulate_mouse_drag`, `simulate_key_press`, `simulate_key_combo`, `simulate_preview_input` | | **交互与事件** | `emit_node_event`, `simulate_button_click`, `list_button_click_events`, `bind_button_click_event`, `invoke_component_method`, `simulate_mouse_click`, `simulate_mouse_drag`, `simulate_key_press`, `simulate_key_combo`, `simulate_preview_input` |
| **截图与窗口** | `capture_desktop_screenshot`, `capture_editor_screenshot`, `capture_scene_screenshot`, `capture_game_screenshot`, `capture_preview_screenshot`, `list_editor_windows` | | **截图与窗口** | `capture_desktop_screenshot`, `capture_editor_screenshot`, `capture_scene_screenshot`, `capture_game_screenshot`, `capture_preview_screenshot`, `list_editor_windows` |
## 主工具示例 ## 主工具示例
@@ -324,8 +368,11 @@ Editor 上下文脚本可以访问 `Editor`、`fs`、`path`、`os`、`require`
"enabledTools": [], "enabledTools": [],
"disabledTools": [], "disabledTools": [],
"enableSessions": false, "enableSessions": false,
"executeJavascriptSafetyChecks": true,
"autostart": true, "autostart": true,
"maxInteractionLogEntries": 50 "maxInteractionLogEntries": 50,
"activeToolProfileName": "",
"savedToolProfiles": []
} }
``` ```
@@ -335,7 +382,7 @@ Editor 上下文脚本可以访问 `Editor`、`fs`、`path`、`os`、`require`
- `COCOS_MCP_PORT` - `COCOS_MCP_PORT`
- `COCOS_MCP_PROFILE` - `COCOS_MCP_PROFILE`
`toolProfile: "custom"` 会从 `core` 集合开始,再加入 `enabledToolCategories` / `enabledTools`,并移除 `disabledToolCategories` / `disabledTools``enableSessions` 默认关闭,因为常规编辑器自动化不需要跨请求客户端状态。 `toolProfile: "custom"` 会从 `core` 集合开始,再加入 `enabledToolCategories` / `enabledTools`,并移除 `disabledToolCategories` / `disabledTools`面板可以把这些暴露设置保存为命名 `savedToolProfiles`,方便恢复或分享。`enableSessions` 默认关闭,因为常规编辑器自动化不需要跨请求客户端状态。
## 架构 ## 架构
@@ -353,17 +400,47 @@ Cocos Creator Extension
│ └─ Minimal MCP Server panel │ └─ Minimal MCP Server panel
└─ lib/ └─ lib/
├─ assets, diagnostics, screenshots, input ├─ assets, diagnostics, screenshots, input
├─ tool-profiles, javascript-safety
├─ tools/
│ ├─ files
│ ├─ assets-advanced
│ ├─ cocos-project
│ └─ scene-events
└─ server, resources, prompts, tool registry └─ server, resources, prompts, tool registry
``` ```
服务使用 MCP 风格的 HTTP JSON-RPC 2.0,支持 tools、resources、resource templates、promptshealth check。 服务使用 MCP 风格的 HTTP JSON-RPC 2.0,支持 tools、resources、resource templates、promptshealth check 和只读 `/tools` 调试端点
## 开发 ## 开发
发布改动前可以跑语法检查: 发布改动前可以跑检查:
```bash ```bash
npm run check npm run check
npm test
npm run docs:check
npm run release:check
npm run pack:dry-run
```
修改 `lib/tool-registry.js` 后可以重新生成工具参考:
```bash
npm run docs:generate
```
生成可上传到 GitHub Release 的扩展包:
```bash
npm run release:package
```
产物会写入 `releases/<version>/`,包含 zip、manifest、checksum 和 release README。完整流程见 [RELEASE_WORKFLOW.md](./RELEASE_WORKFLOW.md) 和 [RELEASE_CHECKLIST.md](./RELEASE_CHECKLIST.md)。
发布 MCP Registry 前可以验证元数据:
```bash
npm run registry:validate
``` ```
## 协议 ## 协议
+97
View File
@@ -0,0 +1,97 @@
# Release Checklist
Use this checklist before publishing a new release of Funplay MCP for Cocos.
## 1. Repository Hygiene
- [ ] `git status` contains only intended release changes
- [ ] No tracked local junk is present (`.DS_Store`, `.idea/`, `node_modules/`, `Library/`, `Temp/`, `dist/`, `build/`)
- [ ] `package.json` version matches the intended release
- [ ] `CHANGELOG.md` includes the release notes for the target version
- [ ] `README.md` and `README_CN.md` match the current product behavior
## 2. Automated Verification
- [ ] `npm run check` passes
- [ ] `npm test` passes
- [ ] `npm run docs:check` passes
- [ ] `npm run release:check` passes
- [ ] `npm run pack:dry-run` passes
- [ ] `npm run registry:validate` passes when `mcp-publisher` is available
- [ ] `npm run release:package` creates `releases/<version>/`
- [ ] `shasum -a 256 -c releases/<version>/SHA256SUMS.txt` passes
- [ ] `releases/<version>/RELEASE_NOTES.md` is organized by change type, such as Added/Optimized/Changed/Fixed
## 3. Package Contents
- [ ] The zip is named `Funplay.CocosMcp.v<version>.zip`
- [ ] The zip contains a single top-level `funplay-cocos-mcp/` folder
- [ ] The zip contains runtime files: `package.json`, `browser.js`, `scene.js`, `panel/`, and `lib/`
- [ ] The zip contains stdio wrapper metadata: `bin/funplay-cocos-mcp.js` and `server.json`
- [ ] The zip includes docs: `README.md`, `README_CN.md`, `CHANGELOG.md`, `CONTRIBUTING.md`, and `LICENSE`
- [ ] The zip does not contain `.git/`, `.github/`, `.DS_Store`, `node_modules/`, `Library/`, `Temp/`, `dist/`, `build/`, `test/`, or `scripts/`
- [ ] Release packaging sensitive-content scan does not find npm/GitHub/MCP tokens or private keys
- [ ] `release-manifest.json` references the correct GitHub download URL
- [ ] `SHA256SUMS.txt` includes the zip, manifest, generated release notes, and release README
## 4. Cocos Smoke Test
- [ ] Test in a clean Cocos Creator `3.8+` project
- [ ] Install from the generated zip into `<project>/extensions/funplay-cocos-mcp`
- [ ] Restart Cocos Creator or reload extensions
- [ ] Open `Funplay > MCP Server`
- [ ] Start the MCP server successfully
- [ ] If the configured port is already in use, verify automatic fallback is reported clearly
- [ ] Run a read-only tool such as `get_project_info`
- [ ] Run a scene inspection tool such as `get_scene_info`
- [ ] Run a screenshot tool when the editor has a visible scene or preview
- [ ] Verify interaction logs appear in the MCP Server panel
## 5. MCP Client Verification
- [ ] Verify at least one primary client can connect (`Claude Code`, `Cursor`, `Codex`, etc.)
- [ ] Confirm `tools/list` returns the expected `core` profile tools
- [ ] Confirm a tool call succeeds end-to-end from the external client
- [ ] Verify one-click config output still matches the documented config snippets
- [ ] Verify the stdio wrapper can connect with `funplay-cocos-mcp --url http://127.0.0.1:8765/`
## 6. npm And MCP Registry Readiness
- [ ] `package.json` `name` is `funplay-cocos-mcp`
- [ ] `package.json` `version` matches `server.json` version
- [ ] `package.json` `mcpName` matches `server.json` name
- [ ] `package.json` `bin.funplay-cocos-mcp` points to `bin/funplay-cocos-mcp.js`
- [ ] `server.json` npm package identifier and version match `package.json`
- [ ] `server.json` npm transport type is `stdio`
- [ ] npm package dry-run includes `bin/`, `lib/`, `panel/`, `browser.js`, `scene.js`, and `server.json`
- [ ] npm credentials are available for `npm publish`
- [ ] MCP Registry credentials are available for `mcp-publisher publish`
## 7. GitHub Release Readiness
- [ ] CI passes on `main`
- [ ] Release commit message is `Release v<version>`
- [ ] Tag is `v<version>`
- [ ] GitHub Release title is `v<version>`
- [ ] GitHub Release includes the zip, manifest, checksum file, and release README
- [ ] Public GitHub Release page uses `RELEASE_NOTES.md` and renders the release notes/assets correctly
## 8. Publish
- [ ] Commit the release changes
- [ ] Create and push the release tag
- [ ] Create or update the GitHub Release
- [ ] Upload generated release assets
- [ ] Verify the GitHub Release asset list
- [ ] Publish npm package with `npm publish`
- [ ] Verify npm package with `npm view funplay-cocos-mcp@<version>`
- [ ] Publish MCP Registry metadata with `mcp-publisher publish server.json`
- [ ] Verify MCP Registry latest and specific-version endpoints
## 9. Post-Release
- [ ] Re-test installation from the public GitHub Release zip
- [ ] Re-test stdio wrapper installation from npm
- [ ] Check the update checker reports the new latest version
- [ ] Check README install instructions and download links
- [ ] Announce the release where appropriate
+340
View File
@@ -0,0 +1,340 @@
# Funplay Cocos MCP Release Workflow
This document records the release workflow for publishing Funplay MCP for Cocos to:
- Git tags
- GitHub Releases
- Downloadable Cocos Creator extension zip packages
- npm stdio wrapper package
- Official MCP Registry
## Published Identity
- GitHub repository: `https://github.com/FunplayAI/funplay-cocos-mcp`
- Git tag format: `v<version>`
- GitHub Release tag: `v<version>`
- Extension package asset: `Funplay.CocosMcp.v<version>.zip`
- Cocos extension folder name inside the zip: `funplay-cocos-mcp`
- npm package id: `funplay-cocos-mcp`
- npm command: `funplay-cocos-mcp`
- MCP Registry server name: `io.github.FunplayAI/funplay-cocos-mcp`
- Default local MCP endpoint: `http://127.0.0.1:8765/`
## Version Alignment Rule
Keep these versions aligned:
- `package.json` `version`
- `CHANGELOG.md` release section
- Git tag `v<version>`
- GitHub Release `v<version>`
- `releases/<version>/release-manifest.json` `version`
- Release zip filename `Funplay.CocosMcp.v<version>.zip`
- `server.json` top-level version
- `server.json` npm package version
Example:
- `package.json`: `0.3.1`
- Git tag: `v0.3.1`
- GitHub Release: `v0.3.1`
- Release asset: `Funplay.CocosMcp.v0.3.1.zip`
- npm package: `funplay-cocos-mcp@0.3.1`
- MCP Registry: `0.3.1`
## Files To Update For A New Release
Update:
1. `package.json`
- `"version": "<version>"`
2. `CHANGELOG.md`
- add a dated release notes block
3. `server.json`
- top-level `"version"`
- npm package `"version"`
4. `docs/TOOLS.md`
- regenerate with `npm run docs:generate` after tool registry changes
Optional but recommended:
5. `README.md`
6. `README_CN.md`
7. GitHub Release notes text
## Release Steps
### 1. Verify Working Tree
```bash
git status --short --branch
```
The tree should contain only intentional release changes.
### 2. Update Versions And Notes
Update `package.json` and `CHANGELOG.md`.
Use semantic versions such as `0.3.1`, and keep release headings in this format:
```markdown
## [0.3.1] - 2026-05-20
```
### 3. Run Release Verification
```bash
npm run release:verify
```
This runs:
- JavaScript syntax checks
- Node.js tests
- generated tool documentation validation
- release metadata validation
- npm package dry-run validation
- release package generation
The generated local artifacts are written to:
```text
releases/<version>/
```
Expected contents:
- `Funplay.CocosMcp.v<version>.zip`
- `release-manifest.json`
- `SHA256SUMS.txt`
- `README.md`
### 4. Inspect The Package
The release script validates that every archive path stays under:
```text
funplay-cocos-mcp/
```
The package must not contain local/build content such as:
- `.git/`
- `.github/`
- `.DS_Store`
- `node_modules/`
- `Library/`
- `Temp/`
- `dist/`
- `build/`
- `test/`
- `scripts/`
Verify checksums:
```bash
cd releases/<version>
shasum -a 256 -c SHA256SUMS.txt
```
### 4.5 Validate npm And MCP Registry Metadata
```bash
npm run pack:dry-run
npm run registry:validate
```
The npm package must include the stdio wrapper command:
```bash
npx --yes ./funplay-cocos-mcp-<version>.tgz --version
```
### 5. Commit, Tag, And Push
```bash
git add .
git commit -m "Release v<version>"
git tag v<version>
git push origin main
git push origin v<version>
```
### 6. Create GitHub Release
Regenerate the final release artifacts from the tagged clean commit:
```bash
npm run release:package -- --strict-tag
```
If creating a new release:
```bash
gh release create v<version> \
-R FunplayAI/funplay-cocos-mcp \
--title "v<version>" \
--notes-file releases/<version>/RELEASE_NOTES.md \
releases/<version>/Funplay.CocosMcp.v<version>.zip \
releases/<version>/release-manifest.json \
releases/<version>/SHA256SUMS.txt \
releases/<version>/README.md
```
If the release already exists and only assets need to be replaced:
```bash
gh release upload v<version> \
-R FunplayAI/funplay-cocos-mcp \
--clobber \
releases/<version>/Funplay.CocosMcp.v<version>.zip \
releases/<version>/release-manifest.json \
releases/<version>/SHA256SUMS.txt \
releases/<version>/README.md
```
If the release body needs to be refreshed without replacing assets:
```bash
gh release edit v<version> \
-R FunplayAI/funplay-cocos-mcp \
--notes-file releases/<version>/RELEASE_NOTES.md
```
### 7. Verify GitHub Release
```bash
gh release view v<version> \
-R FunplayAI/funplay-cocos-mcp \
--json url,assets,isDraft,isPrerelease,publishedAt
```
Confirm the release has all four assets and the public release body is organized by change type.
### 8. Publish To npm
```bash
npm publish
```
Verify the published package:
```bash
npm view funplay-cocos-mcp@<version> version bin mcpName
```
Notes:
- `package.json` `mcpName` must match `server.json` `name`.
- If `npm publish` returns `ENEEDAUTH`, run `npm adduser` with a publishing account and retry.
- If the package name already exists under another owner, choose a scoped package name and update both `package.json` and `server.json`.
### 9. Publish To MCP Registry
Log in if needed:
```bash
mcp-publisher login github
```
Publish:
```bash
mcp-publisher publish server.json
```
Verify latest:
```bash
curl "https://registry.modelcontextprotocol.io/v0.1/servers?search=io.github.FunplayAI/funplay-cocos-mcp&version=latest"
```
Check a specific version:
```bash
curl "https://registry.modelcontextprotocol.io/v0.1/servers/io.github.FunplayAI%2Ffunplay-cocos-mcp/versions/<version>"
```
### 10. Post-Release Smoke Test
Test the package from the public GitHub Release:
1. Download `Funplay.CocosMcp.v<version>.zip`.
2. Unzip it.
3. Move `funplay-cocos-mcp` into a Cocos project `extensions/` directory.
4. Restart Cocos Creator or reload extensions.
5. Open `Funplay > MCP Server`.
6. Start the MCP server.
7. Connect an MCP client and call `get_project_info`.
8. Install the npm wrapper with `npm install -g funplay-cocos-mcp`.
9. Connect an MCP client through the `funplay-cocos-mcp` command and call `tools/list`.
## Current Verification Commands
```bash
npm run release:verify
npm run registry:validate
gh release view v<version> -R FunplayAI/funplay-cocos-mcp --json url,assets
npm view funplay-cocos-mcp@<version> version bin mcpName
```
## Common Failure Cases
### Release validation says the changelog section is missing
Cause:
- `CHANGELOG.md` does not contain `## [<version>] - YYYY-MM-DD`.
Fix:
- Add a dated release section before packaging.
### `zip` command is missing
Cause:
- The local environment does not have the `zip` CLI installed.
Fix:
- Install `zip`, then rerun `npm run release:package`.
### GitHub Release upload replaces the wrong assets
Cause:
- The version directory or release tag does not match `package.json` version.
Fix:
- Rerun `npm run release:check`.
- Confirm the command uses `releases/<version>/` and `v<version>`.
### npm `ENEEDAUTH`
Cause:
- The local machine is not logged in to npm.
Fix:
```bash
npm adduser
npm publish
```
### MCP Registry `Package validation failed`
Cause:
- npm package has not been published yet.
- `package.json` `mcpName` does not match `server.json` `name`.
- `server.json` package version does not match the npm package version.
Fix:
- Publish the npm package first.
- Rerun `npm run release:check`.
- Rerun `npm run registry:validate`.
+356
View File
@@ -0,0 +1,356 @@
#!/usr/bin/env node
'use strict';
const fs = require('fs');
const http = require('http');
const https = require('https');
const path = require('path');
const DEFAULT_URL = 'http://127.0.0.1:8765/';
const DEFAULT_TIMEOUT_SECONDS = 120;
const ACCEPT_HEADER = 'application/json, text/event-stream';
async function main() {
const args = process.argv.slice(2);
if (hasFlag(args, '--help') || hasFlag(args, '-h')) {
printHelp();
return 0;
}
if (hasFlag(args, '--version')) {
console.error(`funplay-cocos-mcp ${readPackageVersion()}`);
return 0;
}
const urlText = getOption(args, '--url')
|| process.env.FUNPLAY_COCOS_MCP_URL
|| DEFAULT_URL;
const timeoutText = getOption(args, '--timeout-seconds')
|| process.env.FUNPLAY_COCOS_MCP_TIMEOUT_SECONDS
|| String(DEFAULT_TIMEOUT_SECONDS);
const endpoint = parseEndpoint(urlText);
if (!endpoint) {
console.error(`Invalid --url value: ${urlText}`);
return 2;
}
const timeoutSeconds = Number.parseInt(timeoutText, 10);
if (!Number.isFinite(timeoutSeconds) || timeoutSeconds <= 0) {
console.error(`Invalid timeout value: ${timeoutText}`);
return 2;
}
console.error(`[Funplay Cocos MCP] Bridging stdio to ${endpoint.href}`);
await bridgeStdioToHttp({
input: process.stdin,
output: process.stdout,
endpoint,
timeoutMs: timeoutSeconds * 1000
});
return 0;
}
async function bridgeStdioToHttp({ input, output, endpoint, timeoutMs }) {
let sessionId = '';
while (true) {
const message = await readMessage(input);
if (message === null) {
return;
}
let parsed = null;
let requestId = null;
try {
parsed = JSON.parse(message);
requestId = getRequestId(parsed);
} catch (error) {
await writeJsonRpcError(output, null, -32700, 'Parse error');
continue;
}
try {
const response = await postJsonRpc(endpoint, message, {
timeoutMs,
sessionId
});
const nextSessionId = response.headers['mcp-session-id'];
if (typeof nextSessionId === 'string' && nextSessionId) {
sessionId = nextSessionId;
}
if (response.statusCode >= 200 && response.statusCode < 300 && response.body.trim()) {
await writeMessage(output, response.body);
continue;
}
if (response.statusCode >= 200 && response.statusCode < 300 && isNotification(parsed)) {
continue;
}
if (requestId !== null) {
const messageText = response.body.trim()
? `Cocos MCP server returned HTTP ${response.statusCode}: ${response.body}`
: `Cocos MCP server returned HTTP ${response.statusCode}.`;
await writeJsonRpcError(output, requestId, -32000, messageText);
}
} catch (error) {
console.error(`[Funplay Cocos MCP] ${error.message}`);
if (requestId !== null) {
await writeJsonRpcError(output, requestId, -32000, `Proxy transport error: ${error.message}`);
}
}
}
}
function postJsonRpc(endpoint, body, { timeoutMs, sessionId }) {
return new Promise((resolve, reject) => {
const client = endpoint.protocol === 'https:' ? https : http;
const request = client.request(
endpoint,
{
method: 'POST',
headers: {
Accept: ACCEPT_HEADER,
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(body),
...(sessionId ? { 'Mcp-Session-Id': sessionId } : {})
},
timeout: timeoutMs
},
(response) => {
const chunks = [];
response.on('data', (chunk) => chunks.push(chunk));
response.on('end', () => {
resolve({
statusCode: response.statusCode || 0,
headers: response.headers,
body: Buffer.concat(chunks).toString('utf8')
});
});
}
);
request.on('timeout', () => {
request.destroy(new Error(`HTTP request timed out after ${timeoutMs / 1000} seconds.`));
});
request.on('error', reject);
request.end(body);
});
}
function parseEndpoint(value) {
try {
const endpoint = new URL(value);
if (endpoint.protocol !== 'http:' && endpoint.protocol !== 'https:') {
return null;
}
return endpoint;
} catch (error) {
return null;
}
}
function getRequestId(value) {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
return null;
}
return Object.prototype.hasOwnProperty.call(value, 'id') ? value.id : null;
}
function isNotification(value) {
return Boolean(value && typeof value === 'object' && !Array.isArray(value) && value.method && !Object.prototype.hasOwnProperty.call(value, 'id'));
}
async function writeJsonRpcError(output, id, code, message) {
await writeMessage(output, JSON.stringify({
jsonrpc: '2.0',
id,
error: {
code,
message
}
}));
}
async function readMessage(input) {
const headers = new Map();
while (true) {
const line = await readHeaderLine(input);
if (line === null) {
return headers.size === 0 ? null : Promise.reject(new Error('Unexpected EOF while reading MCP headers.'));
}
if (line === '') {
break;
}
const separator = line.indexOf(':');
if (separator <= 0) {
continue;
}
headers.set(line.slice(0, separator).trim().toLowerCase(), line.slice(separator + 1).trim());
}
const contentLength = Number.parseInt(headers.get('content-length') || '', 10);
if (!Number.isFinite(contentLength) || contentLength < 0) {
throw new Error('Missing Content-Length header.');
}
const payload = await readExact(input, contentLength);
return payload.toString('utf8');
}
function readHeaderLine(input) {
return new Promise((resolve, reject) => {
const chunks = [];
function cleanup() {
input.off('readable', onReadable);
input.off('end', onEnd);
input.off('error', onError);
}
function onReadable() {
let byte;
while ((byte = input.read(1)) !== null) {
if (byte[0] === 0x0a) {
cleanup();
if (chunks.length > 0 && chunks[chunks.length - 1][0] === 0x0d) {
chunks.pop();
}
resolve(Buffer.concat(chunks).toString('ascii'));
return;
}
chunks.push(byte);
}
}
function onEnd() {
cleanup();
resolve(chunks.length === 0 ? null : Buffer.concat(chunks).toString('ascii'));
}
function onError(error) {
cleanup();
reject(error);
}
input.on('readable', onReadable);
input.once('end', onEnd);
input.once('error', onError);
onReadable();
});
}
function readExact(input, length) {
return new Promise((resolve, reject) => {
const chunks = [];
let remaining = length;
function cleanup() {
input.off('readable', onReadable);
input.off('end', onEnd);
input.off('error', onError);
}
function onReadable() {
while (remaining > 0) {
const chunk = input.read(remaining);
if (chunk === null) {
return;
}
chunks.push(chunk);
remaining -= chunk.length;
}
cleanup();
resolve(Buffer.concat(chunks, length));
}
function onEnd() {
cleanup();
reject(new Error('Unexpected EOF while reading MCP payload.'));
}
function onError(error) {
cleanup();
reject(error);
}
input.on('readable', onReadable);
input.once('end', onEnd);
input.once('error', onError);
onReadable();
});
}
function writeMessage(output, json) {
const payload = Buffer.from(json, 'utf8');
return new Promise((resolve, reject) => {
output.write(`Content-Length: ${payload.length}\r\n\r\n`, 'ascii', (headerError) => {
if (headerError) {
reject(headerError);
return;
}
output.write(payload, (payloadError) => {
if (payloadError) {
reject(payloadError);
return;
}
resolve();
});
});
});
}
function hasFlag(args, name) {
return args.some((arg) => arg.toLowerCase() === name.toLowerCase());
}
function getOption(args, name) {
for (let i = 0; i < args.length - 1; i += 1) {
if (args[i].toLowerCase() === name.toLowerCase()) {
return args[i + 1];
}
}
return '';
}
function readPackageVersion() {
try {
const packagePath = path.resolve(__dirname, '..', 'package.json');
const packageJson = JSON.parse(fs.readFileSync(packagePath, 'utf8'));
return packageJson.version || '0.0.0';
} catch (error) {
return '0.0.0';
}
}
function printHelp() {
console.error('funplay-cocos-mcp');
console.error('Bridges stdio MCP traffic to a local Cocos Creator HTTP MCP server.');
console.error();
console.error('Options:');
console.error(' --url <http://127.0.0.1:8765/> Cocos MCP HTTP endpoint.');
console.error(' --timeout-seconds <120> HTTP timeout per request.');
console.error(' --version Print the proxy version.');
console.error(' --help Show this help.');
console.error();
console.error('Environment:');
console.error(' FUNPLAY_COCOS_MCP_URL');
console.error(' FUNPLAY_COCOS_MCP_TIMEOUT_SECONDS');
}
if (require.main === module) {
main().then((code) => {
process.exitCode = code;
}).catch((error) => {
console.error(`[Funplay Cocos MCP] ${error.message}`);
process.exitCode = 1;
});
}
+59 -3
View File
@@ -4,8 +4,8 @@ const path = require('path');
const fs = require('fs'); const fs = require('fs');
const os = require('os'); const os = require('os');
const manifest = require('./package.json'); const manifest = require('./package.json');
const { configureTarget, getTargetStatuses } = require('./lib/client-config'); const { SERVER_NAME, buildTargets, configureTarget, getTargetStatuses } = require('./lib/client-config');
const { loadConfig, getProjectPath, getProjectName, getCocosVersion } = require('./lib/config'); const { loadConfig, getProjectPath, getProjectName, getProjectIdentity, getCocosVersion } = require('./lib/config');
const { McpServer } = require('./lib/server'); const { McpServer } = require('./lib/server');
const { createToolRegistry } = require('./lib/tool-registry'); const { createToolRegistry } = require('./lib/tool-registry');
const { ResourceProvider } = require('./lib/resources'); const { ResourceProvider } = require('./lib/resources');
@@ -13,6 +13,7 @@ const { PromptProvider } = require('./lib/prompts');
const { InteractionLog } = require('./lib/interaction-log'); const { InteractionLog } = require('./lib/interaction-log');
const { RuntimeLog } = require('./lib/runtime-log'); const { RuntimeLog } = require('./lib/runtime-log');
const { checkForUpdate } = require('./lib/update-checker'); const { checkForUpdate } = require('./lib/update-checker');
const { normalizeSavedToolProfiles } = require('./lib/tool-profiles');
const EXTENSION_NAME = manifest.name || 'funplay-cocos-mcp'; const EXTENSION_NAME = manifest.name || 'funplay-cocos-mcp';
const LOG_PREFIX = '[Funplay Cocos MCP]'; const LOG_PREFIX = '[Funplay Cocos MCP]';
@@ -101,6 +102,7 @@ class ExtensionService {
config: this.config, config: this.config,
projectPath: getProjectPath(), projectPath: getProjectPath(),
projectName: getProjectName(), projectName: getProjectName(),
projectIdentity: getProjectIdentity(),
cocosVersion: getCocosVersion(), cocosVersion: getCocosVersion(),
packagePath: path.dirname(__filename), packagePath: path.dirname(__filename),
}); });
@@ -134,6 +136,8 @@ class ExtensionService {
promptProvider: this.promptProvider, promptProvider: this.promptProvider,
serverName: `Funplay Cocos MCP - ${getProjectName()}`, serverName: `Funplay Cocos MCP - ${getProjectName()}`,
serverVersion: manifest.version || '0.0.0', serverVersion: manifest.version || '0.0.0',
projectName: getProjectName(),
projectIdentity: getProjectIdentity(),
}); });
await this.server.start(); await this.server.start();
@@ -178,8 +182,13 @@ class ExtensionService {
const fallbackInfo = this.server && this.server.isRunning() && typeof this.server.getPortFallbackInfo === 'function' const fallbackInfo = this.server && this.server.isRunning() && typeof this.server.getPortFallbackInfo === 'function'
? this.server.getPortFallbackInfo() ? this.server.getPortFallbackInfo()
: null; : null;
const attachInfo = this.server && this.server.isRunning() && typeof this.server.getAttachInfo === 'function'
? this.server.getAttachInfo()
: null;
return { return {
running: Boolean(this.server && this.server.isRunning()), running: Boolean(this.server && this.server.isRunning()),
attachedToExisting: Boolean(attachInfo),
attachInfo,
host: this.config.host, host: this.config.host,
port: effective.port, port: effective.port,
requestedPort: this.config.port, requestedPort: this.config.port,
@@ -191,10 +200,14 @@ class ExtensionService {
enabledToolCategories: this.config.enabledToolCategories, enabledToolCategories: this.config.enabledToolCategories,
disabledToolCategories: this.config.disabledToolCategories, disabledToolCategories: this.config.disabledToolCategories,
enableSessions: this.config.enableSessions, enableSessions: this.config.enableSessions,
executeJavascriptSafetyChecks: this.config.executeJavascriptSafetyChecks,
autostart: this.config.autostart, autostart: this.config.autostart,
activeToolProfileName: this.config.activeToolProfileName,
savedToolProfiles: this.config.savedToolProfiles,
version: manifest.version || '0.0.0', version: manifest.version || '0.0.0',
projectPath: getProjectPath(), projectPath: getProjectPath(),
projectName: getProjectName(), projectName: getProjectName(),
projectIdentity: getProjectIdentity(),
cocosVersion: getCocosVersion(), cocosVersion: getCocosVersion(),
url: effective.url, url: effective.url,
}; };
@@ -304,7 +317,21 @@ class ExtensionService {
} }
getClientConfig() { getClientConfig() {
const { url } = this.getEffectiveServerConnection(); const effective = this.getEffectiveServerConnection();
const { url } = effective;
const targetConfig = {
...this.config,
host: effective.host,
port: effective.port,
};
const targets = buildTargets(targetConfig).map((target) => ({
id: target.id,
name: target.name,
configPath: target.configPath,
isToml: Boolean(target.isToml),
preview: this.formatClientTargetPreview(target),
}));
const baseUrl = url.replace(/\/$/, '');
return { return {
url, url,
codex: `[mcp_servers.funplay_cocos]\nurl = "${url}"\n`, codex: `[mcp_servers.funplay_cocos]\nurl = "${url}"\n`,
@@ -315,9 +342,28 @@ class ExtensionService {
}, },
}, },
}, null, 2), }, null, 2),
targets,
curl: {
health: `curl ${baseUrl}/health`,
tools: `curl ${baseUrl}/tools`,
catalog: `curl ${baseUrl}/tools?catalog=1`,
},
}; };
} }
formatClientTargetPreview(target) {
if (target.isToml) {
return `[mcp_servers.${SERVER_NAME}]\nurl = "${target.url}"\n`;
}
const rootKey = target.rootKey || 'mcpServers';
return JSON.stringify({
[rootKey]: {
[SERVER_NAME]: target.entry,
},
}, null, 2);
}
configureClient(targetId) { configureClient(targetId) {
this.ensureRuntime(); this.ensureRuntime();
this.log('info', `Configuring MCP client target: ${targetId}`); this.log('info', `Configuring MCP client target: ${targetId}`);
@@ -383,6 +429,9 @@ class ExtensionService {
enableSessions: partialConfig && typeof partialConfig.enableSessions === 'boolean' enableSessions: partialConfig && typeof partialConfig.enableSessions === 'boolean'
? partialConfig.enableSessions ? partialConfig.enableSessions
: this.config.enableSessions, : this.config.enableSessions,
executeJavascriptSafetyChecks: partialConfig && typeof partialConfig.executeJavascriptSafetyChecks === 'boolean'
? partialConfig.executeJavascriptSafetyChecks
: this.config.executeJavascriptSafetyChecks,
autostart: partialConfig && typeof partialConfig.autostart === 'boolean' autostart: partialConfig && typeof partialConfig.autostart === 'boolean'
? partialConfig.autostart ? partialConfig.autostart
: this.config.autostart, : this.config.autostart,
@@ -392,6 +441,12 @@ class ExtensionService {
lastClientTargetId: partialConfig && partialConfig.lastClientTargetId lastClientTargetId: partialConfig && partialConfig.lastClientTargetId
? String(partialConfig.lastClientTargetId) ? String(partialConfig.lastClientTargetId)
: this.config.lastClientTargetId, : this.config.lastClientTargetId,
activeToolProfileName: partialConfig && typeof partialConfig.activeToolProfileName === 'string'
? String(partialConfig.activeToolProfileName)
: this.config.activeToolProfileName,
savedToolProfiles: partialConfig && Array.isArray(partialConfig.savedToolProfiles)
? normalizeSavedToolProfiles(partialConfig.savedToolProfiles)
: this.config.savedToolProfiles,
}; };
const configPath = this.config.configPath; const configPath = this.config.configPath;
@@ -402,6 +457,7 @@ class ExtensionService {
nextConfig.port !== this.config.port || nextConfig.port !== this.config.port ||
nextConfig.toolProfile !== this.config.toolProfile || nextConfig.toolProfile !== this.config.toolProfile ||
nextConfig.enableSessions !== this.config.enableSessions || nextConfig.enableSessions !== this.config.enableSessions ||
nextConfig.executeJavascriptSafetyChecks !== this.config.executeJavascriptSafetyChecks ||
JSON.stringify(nextConfig.enabledTools) !== JSON.stringify(this.config.enabledTools) || JSON.stringify(nextConfig.enabledTools) !== JSON.stringify(this.config.enabledTools) ||
JSON.stringify(nextConfig.disabledTools) !== JSON.stringify(this.config.disabledTools) || JSON.stringify(nextConfig.disabledTools) !== JSON.stringify(this.config.disabledTools) ||
JSON.stringify(nextConfig.enabledToolCategories) !== JSON.stringify(this.config.enabledToolCategories) || JSON.stringify(nextConfig.enabledToolCategories) !== JSON.stringify(this.config.enabledToolCategories) ||
+235
View File
@@ -0,0 +1,235 @@
# Tool Reference
<!-- This file is generated by `npm run docs:generate`. Do not edit by hand. -->
Generated from `lib/tool-registry.js`. The default `core` profile exposes 37 tools; the `full` profile exposes 101 tools.
## Profile Summary
| Profile | Tool Count | Purpose |
|---|---:|---|
| `core` | 37 | Focused default surface for common editor automation. |
| `full` | 101 | All built-in tools, including destructive and low-level helpers. |
## Core Tools
`capture_editor_screenshot`, `capture_preview_screenshot`, `capture_scene_screenshot`, `check_for_updates`, `clear_logs`, `execute_editor_script`, `execute_javascript`, `execute_scene_script`, `get_build_status`, `get_editor_state`, `get_hierarchy`, `get_performance_snapshot`, `get_project_info`, `get_recent_logs`, `get_runtime_state`, `get_scene_info`, `get_script_diagnostic_context`, `get_selection`, `get_tool_catalog`, `inspect_asset`, `inspect_asset_dependencies`, `inspect_prefab`, `inspect_prefab_instance`, `list_assets`, `list_editor_windows`, `list_project_instructions`, `list_scenes`, `open_asset`, `open_scene`, `read_project_instruction`, `run_script_diagnostics`, `search_project_logs`, `select_asset`, `set_selection`, `validate_asset_dependencies`, `validate_prefab_references`, `validate_scene`
## Tools By Category
### Animation
| Tool | Profiles | Access | Description |
|---|---|---|---|
| `add_animation_clip` | `full` | stateful | Add an AnimationClip asset to a node Animation component. |
| `list_animations` | `full` | read-only | [core] List Animation components in the active scene or under one node. |
| `play_animation` | `full` | stateful | [core] Play an Animation component clip on a node. |
| `stop_animation` | `full` | stateful | [core] Stop an Animation component clip on a node. |
### Assets
| Tool | Profiles | Access | Description |
|---|---|---|---|
| `delete_asset` | `full` | mutating | Delete an asset from asset-db by uuid, db url, or path. |
| `inspect_asset` | `core`, `full` | read-only | [specialist] Inspect asset-db info, metadata, and serialized asset data by uuid or path. Prefer this when you need a precise structured asset read. |
| `inspect_asset_dependencies` | `core`, `full` | read-only | [specialist] Inspect UUID-style dependencies referenced by a serialized Cocos asset. |
| `list_assets` | `core`, `full` | read-only | [specialist] Query project assets from asset-db by pattern or asset type. Prefer this when you need exact asset discovery; otherwise use execute_javascript for broader automation. |
| `list_scenes` | `core`, `full` | read-only | [specialist] List scene assets in the project. Prefer this when you need exact scene discovery before opening one; otherwise stay in execute_javascript for broader workflows. |
| `open_asset` | `core`, `full` | stateful | [specialist] Open an asset inside Cocos Creator by uuid, db url, or path. Use this only when opening the asset itself is the explicit next step. |
| `open_scene` | `core`, `full` | stateful | [specialist] Open a scene asset in Cocos Creator by uuid, db url, or path. Use this when scene switching is the explicit goal; otherwise keep execute_javascript as the main planning tool. |
| `run_scene_asset` | `full` | mutating | Load a scene asset by uuid directly into the current runtime scene context. |
| `select_asset` | `core`, `full` | stateful | [specialist] Select an asset in the Cocos editor. Use this when editor selection state matters; otherwise keep execute_javascript as the primary workflow. |
### Broadcast
| Tool | Profiles | Access | Description |
|---|---|---|---|
| `broadcast_editor_message` | `full` | stateful | [core] Send or broadcast a Cocos editor message for advanced editor automation. |
### Build
| Tool | Profiles | Access | Description |
|---|---|---|---|
| `get_build_status` | `core`, `full` | read-only | [specialist] Query Cocos build/preview status using known builder message variants. |
| `open_build_panel` | `full` | stateful | [core] Open the Cocos build panel, defaulting to the builder panel id. |
| `run_project_preview` | `full` | stateful | [core] Start Cocos preview/run using known preview and builder message variants. |
| `save_current_scene` | `full` | stateful | [core] Save the currently open Cocos scene using available editor scene messages. |
### Camera
| Tool | Profiles | Access | Description |
|---|---|---|---|
| `create_camera` | `full` | stateful | Create a Camera node in the active scene. |
| `list_cameras` | `full` | read-only | [core] List Camera components in the active scene. |
| `set_camera_properties` | `full` | mutating | Set selected Camera component properties. |
### Components
| Tool | Profiles | Access | Description |
|---|---|---|---|
| `add_component` | `full` | stateful | Add a component to a node by component class name. |
| `inspect_component` | `full` | read-only | [core] Inspect a component attached to a node. |
| `invoke_component_method` | `full` | mutating | [core] Invoke a method on a component for runtime validation and test hooks. |
| `list_components` | `full` | read-only | [core] List components attached to a scene node. |
| `remove_component` | `full` | mutating | Remove a component from a node by name or index. |
| `reset_component_property` | `full` | mutating | Reset or clear a component property by dot path. |
| `set_component_property` | `full` | mutating | Set a component property by dot path using a JSON value. |
### Diagnostics
| Tool | Profiles | Access | Description |
|---|---|---|---|
| `get_script_diagnostic_context` | `core`, `full` | read-only | [specialist] Run TypeScript diagnostics and attach source snippets for each error. This is a preferred specialist tool for compile-error triage before repair. |
| `run_script_diagnostics` | `core`, `full` | stateful | [specialist] Run a TypeScript no-emit check for the current Cocos project and return parsed diagnostics. This is a preferred specialist tool for script errors when diagnostics are needed. |
| `validate_asset_dependencies` | `core`, `full` | read-only | [specialist] Validate UUID-style dependencies for one asset or a project asset query. |
| `validate_prefab_references` | `core`, `full` | read-only | [specialist] Validate prefab asset references by checking serialized UUID references against asset-db. |
| `validate_scene` | `core`, `full` | read-only | [specialist] Run a compact validation pass over the active scene, runtime state, TypeScript diagnostics, and recent project log errors. |
### Events
| Tool | Profiles | Access | Description |
|---|---|---|---|
| `bind_button_click_event` | `full` | stateful | [core] Bind a Cocos Button click event to a target node component method. |
| `emit_node_event` | `full` | mutating | [core] Emit a custom event on a target scene node with an optional JSON payload. |
| `list_button_click_events` | `full` | read-only | [core] List click event bindings on a Cocos Button component. |
| `simulate_button_click` | `full` | mutating | [core] Simulate a Cocos Button click by emitting click events on the target button node. |
### Execution
| Tool | Profiles | Access | Description |
|---|---|---|---|
| `execute_editor_script` | `core`, `full` | mutating | [compat] Execute JavaScript in the editor/browser context. Prefer execute_javascript with context="editor" as the main unified tool; use this when you specifically want the editor-only compatibility entrypoint. |
| `execute_javascript` | `core`, `full` | mutating | [primary] Execute JavaScript in either the scene or editor context. Use context="scene" for live scene/runtime inspection and mutation, or context="editor" for Editor APIs, asset-db workflows, MCP orchestration, local filesystem access, and higher-level automation. Prefer this as the main flexible tool when many narrow tools would be noisy. |
### Files
| Tool | Profiles | Access | Description |
|---|---|---|---|
| `exists` | `full` | read-only | [core] Check whether a project file or directory exists. |
| `get_file_snippet` | `full` | read-only | [core] Read a focused snippet around a file line number. |
| `list_directory` | `full` | read-only | [core] List files and directories inside a project directory. |
| `read_file` | `full` | read-only | [core] Read a file from the Cocos project. |
| `refresh_assets` | `full` | stateful | [core] Best-effort asset database refresh for a file or the assets root. |
| `replace_in_file` | `full` | mutating | [core] Replace text in a file, useful for script auto-fix loops. |
| `search_files` | `full` | read-only | [core] Search project files by simple wildcard pattern. |
| `write_file` | `full` | mutating | [core] Write or overwrite a file in the Cocos project. |
### Input
| Tool | Profiles | Access | Description |
|---|---|---|---|
| `simulate_key_combo` | `full` | mutating | [core] Send a low-level Electron modified key press such as Ctrl+S or Cmd+P. |
| `simulate_key_press` | `full` | mutating | [core] Send a low-level Electron key press to the editor, preview, or simulator window. |
| `simulate_mouse_click` | `full` | mutating | [core] Send a low-level Electron mouse click to the editor, preview, or simulator window. |
| `simulate_mouse_drag` | `full` | mutating | [core] Send a low-level Electron mouse drag to the editor, preview, or simulator window. |
| `simulate_preview_input` | `full` | mutating | [core] Convenience wrapper for low-level preview/simulator input. Uses mouse click by default or key press when keyCode is provided. |
### Instructions
| Tool | Profiles | Access | Description |
|---|---|---|---|
| `create_cocos_mcp_project_skill` | `full` | stateful | [core] Create a recommended local Codex project skill for Funplay Cocos MCP workflows. |
| `create_project_skill` | `full` | stateful | [core] Create a local Codex project skill under .codex/skills/{skillName}/SKILL.md. |
| `list_project_instructions` | `core`, `full` | read-only | [specialist] List project AI instruction files and local Codex project skills. |
| `read_project_instruction` | `core`, `full` | read-only | [specialist] Read a project AI instruction file such as AGENTS.md, CLAUDE.md, or a .codex skill SKILL.md. |
| `write_project_instruction` | `full` | mutating | [core] Create or update a project AI instruction file inside the Cocos project. |
### Logs
| Tool | Profiles | Access | Description |
|---|---|---|---|
| `clear_logs` | `core`, `full` | mutating | [specialist] Clear in-memory MCP logs and, only with explicit confirmation, truncate common project log files. |
| `get_recent_logs` | `core`, `full` | read-only | [specialist] Return recent MCP runtime logs, recent tool interactions, and tails of common project log files. |
| `search_project_logs` | `core`, `full` | read-only | [specialist] Search common Cocos project log files for a string or regular expression. |
### Other
| Tool | Profiles | Access | Description |
|---|---|---|---|
| `get_performance_snapshot` | `core`, `full` | read-only | [specialist] Return scene scale and runtime performance-oriented counters such as node/component counts, UI counts, depth, memory, and warnings. |
| `list_editor_windows` | `core`, `full` | read-only | [specialist] List available Electron windows so screenshots or input-targeting can choose the correct window. Use this when window targeting is the explicit problem. |
### Prefabs
| Tool | Profiles | Access | Description |
|---|---|---|---|
| `apply_prefab_instance` | `full` | stateful | [core] Apply a scene prefab instance back to its associated prefab asset using the Cocos editor scene apply-prefab message. |
| `create_prefab_instance` | `full` | stateful | [core] Create a linked prefab instance in the editor hierarchy using Cocos scene create-node when available. |
| `duplicate_prefab` | `full` | stateful | [core] Create a new prefab asset by duplicating an existing prefab file without copying its .meta UUID. |
| `edit_prefab_json` | `full` | stateful | [core] Edit a prefab JSON file by JSON path assignment or literal search/replace, then validate references. |
| `inspect_prefab` | `core`, `full` | read-only | [specialist] Inspect a prefab asset, its metadata, serialized file path, and UUID-like asset references. |
| `inspect_prefab_instance` | `core`, `full` | read-only | [specialist] Inspect whether a scene node is linked to a prefab instance and return prefab metadata when available. |
| `instantiate_prefab` | `full` | stateful | Instantiate a prefab into the active scene by prefab uuid. |
| `list_prefabs` | `full` | read-only | [core] List prefab assets in the project. |
| `revert_prefab_instance` | `full` | stateful | [core] Revert a scene prefab instance from its associated prefab asset using available Cocos editor prefab revert messages. |
### Preferences
| Tool | Profiles | Access | Description |
|---|---|---|---|
| `get_editor_preference` | `full` | read-only | [core] Read a Cocos editor preference through Editor.Profile when available. |
| `set_editor_preference` | `full` | mutating | [core] Write a Cocos editor preference through Editor.Profile when available. |
### Project
| Tool | Profiles | Access | Description |
|---|---|---|---|
| `get_editor_state` | `core`, `full` | read-only | [specialist] Return a structured editor-state snapshot including project info, runtime server status, current selection, and visible Electron windows. Prefer this when you want one compact editor summary. |
| `get_project_info` | `core`, `full` | read-only | [specialist] Return the active Cocos project path, version, and MCP server configuration. Prefer this for a fast structured project summary; use execute_javascript when you need to inspect and act in one step. |
| `get_tool_catalog` | `core`, `full` | read-only | [specialist] Return every built-in MCP tool with profile, category, and current exposure state. Use this before changing custom tool exposure. |
### Runtime
| Tool | Profiles | Access | Description |
|---|---|---|---|
| `get_runtime_state` | `core`, `full` | read-only | [specialist] Return structured Cocos runtime state including pause state, frame count, and scheduler time scale. Prefer this when you want a compact validation snapshot. |
| `pause_runtime` | `full` | stateful | [core] Pause Cocos director game logic execution. |
| `resume_runtime` | `full` | stateful | [core] Resume Cocos director game logic execution. |
| `set_time_scale` | `full` | mutating | [core] Set Cocos scheduler time scale for runtime validation. |
### Scene
| Tool | Profiles | Access | Description |
|---|---|---|---|
| `create_node` | `full` | stateful | Create a new node under the active scene or a specified parent path. |
| `delete_node` | `full` | mutating | Delete a node by path, uuid, or name. |
| `execute_scene_script` | `core`, `full` | mutating | [compat] Execute JavaScript in the active Cocos scene context. Prefer execute_javascript with context="scene" as the main unified tool; use this when you specifically want the scene-only compatibility entrypoint. |
| `find_nodes` | `full` | read-only | [core] Find scene nodes by exact name, partial path, or component type. |
| `get_hierarchy` | `core`, `full` | read-only | [specialist] Return a structured hierarchy tree from the active scene or a specific node path. Prefer execute_javascript for broader reasoning or repair; use this when you want a predictable hierarchy snapshot. |
| `get_scene_info` | `core`, `full` | read-only | [specialist] Return a structured summary of the active Cocos scene. Prefer execute_javascript for multi-step inspection or mutation; use this when you specifically want a compact scene snapshot. |
| `inspect_node` | `full` | read-only | [core] Inspect a specific node by path, uuid, or name. |
| `set_node_transform` | `full` | mutating | Update node position, rotation, scale, or active state. |
### Screenshots
| Tool | Profiles | Access | Description |
|---|---|---|---|
| `capture_desktop_screenshot` | `full` | read-only | [core] Capture a screenshot from the local desktop and return it as an MCP image payload. |
| `capture_editor_screenshot` | `core`, `full` | read-only | [specialist] Capture the focused Cocos Creator editor window and return it as an MCP image payload. Prefer screenshot tools only when visual verification is explicitly needed. |
| `capture_game_screenshot` | `full` | read-only | [core] Capture the Game/Preview panel region from the editor window with panel-level cropping when available. |
| `capture_preview_screenshot` | `core`, `full` | read-only | [specialist] Capture the preview or simulator window as an MCP image payload. Prefer this only when you need visual proof of game or preview output. |
| `capture_scene_screenshot` | `core`, `full` | read-only | [specialist] Capture the Scene panel region from the editor window with panel-level cropping when available. Prefer this only for visual validation of scene-side results. |
### Selection
| Tool | Profiles | Access | Description |
|---|---|---|---|
| `get_editor_selection` | `full` | read-only | [compat] Return the current node and asset selection in the Cocos editor. Prefer get_selection as the primary structured selection read tool. |
| `get_selection` | `core`, `full` | read-only | [specialist] Return the current editor selection in a compact structured form. Prefer this when selection state matters for the next action. |
| `set_selection` | `core`, `full` | mutating | [specialist] Set or clear the current editor selection for an asset or node. Use this when downstream editor workflows depend on selection state. |
### Ui
| Tool | Profiles | Access | Description |
|---|---|---|---|
| `create_button` | `full` | stateful | Create a UI Button node with child Label. |
| `create_canvas` | `full` | stateful | Create a Cocos Canvas node with UITransform. |
| `create_label` | `full` | stateful | Create a UI Label node under a parent. |
| `create_sprite` | `full` | stateful | Create a UI Sprite node, optionally assigning a SpriteFrame asset uuid. |
### Updates
| Tool | Profiles | Access | Description |
|---|---|---|---|
| `check_for_updates` | `core`, `full` | read-only | [specialist] Check the latest Funplay Cocos MCP GitHub release and compare it with the installed extension version. |
+24
View File
@@ -1,7 +1,9 @@
'use strict'; 'use strict';
const crypto = require('crypto');
const fs = require('fs'); const fs = require('fs');
const path = require('path'); const path = require('path');
const { normalizeSavedToolProfiles } = require('./tool-profiles');
const DEFAULTS = { const DEFAULTS = {
host: '127.0.0.1', host: '127.0.0.1',
@@ -12,9 +14,12 @@ const DEFAULTS = {
enabledToolCategories: [], enabledToolCategories: [],
disabledToolCategories: [], disabledToolCategories: [],
enableSessions: false, enableSessions: false,
executeJavascriptSafetyChecks: true,
autostart: true, autostart: true,
maxInteractionLogEntries: 50, maxInteractionLogEntries: 50,
lastClientTargetId: 'claude_code', lastClientTargetId: 'claude_code',
activeToolProfileName: '',
savedToolProfiles: [],
}; };
function getProjectPath() { function getProjectPath() {
@@ -28,6 +33,19 @@ function getProjectName() {
return path.basename(getProjectPath()); return path.basename(getProjectPath());
} }
function normalizeProjectIdentityPath(projectPath) {
const normalized = path.resolve(String(projectPath || process.cwd())).replace(/\\/g, '/');
return process.platform === 'win32' ? normalized.toLowerCase() : normalized;
}
function getProjectIdentity(projectPath = getProjectPath()) {
return crypto
.createHash('sha256')
.update(`funplay-cocos-mcp:${normalizeProjectIdentityPath(projectPath)}`)
.digest('hex')
.slice(0, 24);
}
function getCocosVersion() { function getCocosVersion() {
if (global.Editor && Editor.App) { if (global.Editor && Editor.App) {
if (typeof Editor.App.version === 'string' && Editor.App.version) { if (typeof Editor.App.version === 'string' && Editor.App.version) {
@@ -106,11 +124,16 @@ function loadConfig() {
enabledToolCategories: normalizeStringList(fileConfig.enabledToolCategories).map((item) => item.toLowerCase()), enabledToolCategories: normalizeStringList(fileConfig.enabledToolCategories).map((item) => item.toLowerCase()),
disabledToolCategories: normalizeStringList(fileConfig.disabledToolCategories).map((item) => item.toLowerCase()), disabledToolCategories: normalizeStringList(fileConfig.disabledToolCategories).map((item) => item.toLowerCase()),
enableSessions: typeof fileConfig.enableSessions === 'boolean' ? fileConfig.enableSessions : DEFAULTS.enableSessions, enableSessions: typeof fileConfig.enableSessions === 'boolean' ? fileConfig.enableSessions : DEFAULTS.enableSessions,
executeJavascriptSafetyChecks: typeof fileConfig.executeJavascriptSafetyChecks === 'boolean'
? fileConfig.executeJavascriptSafetyChecks
: DEFAULTS.executeJavascriptSafetyChecks,
autostart: typeof fileConfig.autostart === 'boolean' ? fileConfig.autostart : DEFAULTS.autostart, autostart: typeof fileConfig.autostart === 'boolean' ? fileConfig.autostart : DEFAULTS.autostart,
maxInteractionLogEntries: Number.isInteger(fileConfig.maxInteractionLogEntries) maxInteractionLogEntries: Number.isInteger(fileConfig.maxInteractionLogEntries)
? Math.max(10, Math.min(500, fileConfig.maxInteractionLogEntries)) ? Math.max(10, Math.min(500, fileConfig.maxInteractionLogEntries))
: DEFAULTS.maxInteractionLogEntries, : DEFAULTS.maxInteractionLogEntries,
lastClientTargetId: normalizeClientTargetId(fileConfig.lastClientTargetId), lastClientTargetId: normalizeClientTargetId(fileConfig.lastClientTargetId),
activeToolProfileName: typeof fileConfig.activeToolProfileName === 'string' ? fileConfig.activeToolProfileName : '',
savedToolProfiles: normalizeSavedToolProfiles(fileConfig.savedToolProfiles),
configPath, configPath,
configError: fileConfig.__error || '', configError: fileConfig.__error || '',
}; };
@@ -120,6 +143,7 @@ module.exports = {
DEFAULTS, DEFAULTS,
getProjectPath, getProjectPath,
getProjectName, getProjectName,
getProjectIdentity,
getCocosVersion, getCocosVersion,
loadConfig, loadConfig,
normalizeProfile, normalizeProfile,
+114
View File
@@ -0,0 +1,114 @@
'use strict';
const path = require('path');
const { isPathInside } = require('./path-safety');
const DELETE_METHOD_PATTERN = /\bfs(?:\s*\.\s*promises)?\s*\.\s*(rm|rmdir|unlink|truncate|rmSync|rmdirSync|unlinkSync|truncateSync)\s*\(/;
const WRITE_STREAM_PATTERN = /\bfs\s*\.\s*(createWriteStream|openSync)\s*\(/;
const SHELL_PATTERN = /require\s*\(\s*['"]child_process['"]\s*\)|\bchild_process\s*\.|\b(exec|execFile|spawn|fork|execSync|execFileSync|spawnSync)\s*\(/;
const WRITE_METHOD_PATTERN = /\bfs(?:\s*\.\s*promises)?\s*\.\s*(writeFile|appendFile|copyFile|cp|rename|mkdir|writeFileSync|appendFileSync|copyFileSync|cpSync|renameSync|mkdirSync)\s*\(/;
const HOME_PATH_PATTERN = /(?:^~(?:\/|\\|$)|\$HOME|%USERPROFILE%|%HOMEPATH%)/i;
const TRAVERSAL_PATTERN = /(^|[\\/])\.\.([\\/]|$)/;
function extractStringLiterals(code) {
const literals = [];
const pattern = /(['"`])((?:\\[\s\S]|(?!\1)[\s\S])*?)\1/g;
let match;
while ((match = pattern.exec(String(code || '')))) {
literals.push(match[2]);
}
return literals;
}
function isAbsoluteLiteral(value) {
return path.isAbsolute(value)
|| path.win32.isAbsolute(value)
|| /^\\\\/.test(value);
}
function isAbsoluteLiteralInsideProject(projectPath, value) {
if (!projectPath) {
return false;
}
if (path.win32.isAbsolute(value)) {
const root = projectPath.replace(/\//g, '\\');
const relative = path.win32.relative(root, value);
return relative === '' || (relative && !relative.startsWith('..') && !path.win32.isAbsolute(relative));
}
if (path.isAbsolute(value)) {
return isPathInside(projectPath, path.resolve(value));
}
return false;
}
function inspectJavascriptSafety(code, options = {}) {
const source = String(code || '');
const projectPath = options.projectPath ? path.resolve(String(options.projectPath)) : '';
const violations = [];
if (DELETE_METHOD_PATTERN.test(source)) {
violations.push('direct fs delete/truncate calls are blocked by default');
}
if (WRITE_STREAM_PATTERN.test(source)) {
violations.push('raw writable file streams are blocked by default');
}
if (SHELL_PATTERN.test(source)) {
violations.push('child_process execution is blocked by default');
}
const hasFileMutation = DELETE_METHOD_PATTERN.test(source)
|| WRITE_METHOD_PATTERN.test(source)
|| WRITE_STREAM_PATTERN.test(source);
if (hasFileMutation && /\bos\s*\.\s*homedir\s*\(/.test(source)) {
violations.push('file mutations derived from os.homedir() are blocked by default');
}
if (hasFileMutation && /\bprocess\s*\.\s*env\s*\.\s*(HOME|USERPROFILE|HOMEPATH|APPDATA|LOCALAPPDATA|TMP|TEMP)\b/.test(source)) {
violations.push('file mutations derived from user/system environment paths are blocked by default');
}
for (const literal of extractStringLiterals(source)) {
if (HOME_PATH_PATTERN.test(literal)) {
violations.push(`user-home path literal is blocked: ${literal}`);
continue;
}
if (TRAVERSAL_PATTERN.test(literal)) {
violations.push(`path traversal literal is blocked: ${literal}`);
continue;
}
if (isAbsoluteLiteral(literal)) {
if (!isAbsoluteLiteralInsideProject(projectPath, literal)) {
violations.push(`absolute path outside the Cocos project is blocked: ${literal}`);
}
}
}
return {
ok: violations.length === 0,
violations: Array.from(new Set(violations)),
};
}
function assertJavascriptSafety(code, options = {}) {
const result = inspectJavascriptSafety(code, options);
if (result.ok) {
return result;
}
throw new Error(
'JavaScript safety checks blocked this code: ' +
`${result.violations.join('; ')}. ` +
'Use project-relative helper/file tools, or pass safety_checks=false only after reviewing the risk.'
);
}
module.exports = {
assertJavascriptSafety,
inspectJavascriptSafety,
};
+269
View File
@@ -0,0 +1,269 @@
'use strict';
const fs = require('fs');
const path = require('path');
const { listAssets, queryAssetData, queryAssetInfo, queryAssetMeta } = require('./assets');
const { resolveProjectPath } = require('./path-safety');
function requestEditorMessage(channel, method, ...args) {
if (!global.Editor || !Editor.Message || typeof Editor.Message.request !== 'function') {
throw new Error('Editor.Message.request is unavailable in the Cocos extension host.');
}
return Editor.Message.request(channel, method, ...args);
}
function assetUrlToPath(projectPath, url) {
if (!url || !String(url).startsWith('db://assets/')) {
return '';
}
return path.join(projectPath, String(url).slice('db://'.length));
}
function assetFilePath(projectPath, info) {
const candidates = [
info && info.file,
info && info.path,
info && info.source,
info && info.url ? assetUrlToPath(projectPath, info.url) : '',
].filter(Boolean);
for (const candidate of candidates) {
const fullPath = path.isAbsolute(candidate)
? resolveProjectPath(projectPath, candidate)
: resolveProjectPath(projectPath, candidate);
if (fs.existsSync(fullPath) && fs.statSync(fullPath).isFile()) {
return fullPath;
}
}
return '';
}
function collectUuidReferences(value, refs = [], pointer = '') {
if (!value || typeof value !== 'object') {
return refs;
}
if (Array.isArray(value)) {
value.forEach((item, index) => collectUuidReferences(item, refs, `${pointer}/${index}`));
return refs;
}
for (const [key, child] of Object.entries(value)) {
const childPointer = `${pointer}/${key}`;
if (
typeof child === 'string' &&
(key.toLowerCase().includes('uuid') || key === '__uuid__' || key === 'assetUuid' || key === 'prefabUuid')
) {
refs.push({ uuid: child, path: childPointer, key });
} else {
collectUuidReferences(child, refs, childPointer);
}
}
return refs;
}
function getByJsonPath(target, jsonPath) {
const segments = String(jsonPath || '')
.replace(/^\//, '')
.split(/[/.]/)
.map((segment) => segment.trim())
.filter(Boolean);
let current = target;
for (const segment of segments) {
if (current == null) {
return undefined;
}
current = current[segment];
}
return current;
}
function setByJsonPath(target, jsonPath, value) {
const segments = String(jsonPath || '')
.replace(/^\//, '')
.split(/[/.]/)
.map((segment) => segment.trim())
.filter(Boolean);
if (!segments.length) {
throw new Error('jsonPath is required.');
}
let current = target;
for (let index = 0; index < segments.length - 1; index += 1) {
const segment = segments[index];
if (current[segment] == null || typeof current[segment] !== 'object') {
current[segment] = {};
}
current = current[segment];
}
current[segments[segments.length - 1]] = value;
}
async function inspectPrefab(projectPath, target) {
const info = await queryAssetInfo(target);
const meta = await queryAssetMeta(target).catch(() => null);
const data = await queryAssetData(target).catch(() => null);
const filePath = assetFilePath(projectPath, info);
const content = filePath ? fs.readFileSync(filePath, 'utf8') : '';
const parsed = content ? JSON.parse(content) : data;
const references = collectUuidReferences(parsed).slice(0, 500);
return {
info,
meta,
filePath: filePath ? path.relative(projectPath, filePath).replace(/\\/g, '/') : '',
referenceCount: references.length,
references,
};
}
async function validatePrefabReferences(projectPath, options = {}) {
const targets = options.target
? [options.target]
: (await listAssets({ pattern: options.pattern || 'db://assets/**', ccType: 'cc.Prefab' }))
.slice(0, Number.isFinite(options.limit) ? Math.max(1, Math.min(200, options.limit)) : 50)
.map((asset) => asset.uuid || asset.url)
.filter(Boolean);
const prefabs = [];
for (const target of targets) {
const prefab = await inspectPrefab(projectPath, target);
const checked = [];
const missing = [];
for (const ref of prefab.references) {
try {
const info = await queryAssetInfo(ref.uuid);
checked.push({ ...ref, exists: true, asset: { uuid: info.uuid, url: info.url, type: info.type } });
} catch (error) {
missing.push({ ...ref, exists: false, error: error.message });
}
}
prefabs.push({
target,
filePath: prefab.filePath,
referenceCount: prefab.referenceCount,
checkedCount: checked.length + missing.length,
missingCount: missing.length,
missing,
});
}
const missingCount = prefabs.reduce((sum, prefab) => sum + prefab.missingCount, 0);
return {
ok: missingCount === 0,
prefabCount: prefabs.length,
missingCount,
prefabs,
};
}
async function duplicatePrefab(projectPath, options = {}) {
const source = String(options.source || '').trim();
const target = String(options.target || '').trim();
if (!source || !target) {
throw new Error('source and target are required.');
}
const info = await queryAssetInfo(source);
const sourcePath = assetFilePath(projectPath, info);
if (!sourcePath) {
throw new Error(`Prefab source file was not found: ${source}`);
}
const targetPath = resolveProjectPath(projectPath, target.endsWith('.prefab') ? target : `${target}.prefab`);
const assetsRoot = path.join(projectPath, 'assets');
const relativeToAssets = path.relative(assetsRoot, targetPath);
if (relativeToAssets.startsWith('..') || path.isAbsolute(relativeToAssets)) {
throw new Error('target must be inside the Cocos assets directory.');
}
if (fs.existsSync(targetPath) && options.overwrite !== true) {
throw new Error(`Target prefab already exists: ${target}`);
}
fs.mkdirSync(path.dirname(targetPath), { recursive: true });
fs.copyFileSync(sourcePath, targetPath);
return {
duplicated: true,
source: path.relative(projectPath, sourcePath).replace(/\\/g, '/'),
target: path.relative(projectPath, targetPath).replace(/\\/g, '/'),
};
}
async function editPrefabJson(projectPath, options = {}) {
const target = String(options.target || '').trim();
if (!target) {
throw new Error('target is required.');
}
const info = await queryAssetInfo(target);
const filePath = assetFilePath(projectPath, info);
if (!filePath) {
throw new Error(`Prefab file was not found: ${target}`);
}
const original = fs.readFileSync(filePath, 'utf8');
let updated = original;
if (options.search !== undefined) {
const search = String(options.search);
if (!search) {
throw new Error('search must not be empty.');
}
if (!original.includes(search)) {
throw new Error('search text was not found in prefab file.');
}
updated = options.replaceAll
? original.split(search).join(String(options.replace || ''))
: original.replace(search, String(options.replace || ''));
} else {
const json = JSON.parse(original);
const value = JSON.parse(String(options.valueJson || 'null'));
setByJsonPath(json, options.jsonPath, value);
updated = JSON.stringify(json, null, 2) + '\n';
}
JSON.parse(updated);
if (options.createBackup) {
fs.writeFileSync(`${filePath}.bak`, original, 'utf8');
}
fs.writeFileSync(filePath, updated, 'utf8');
return {
edited: true,
path: path.relative(projectPath, filePath).replace(/\\/g, '/'),
oldValue: options.jsonPath ? getByJsonPath(JSON.parse(original), options.jsonPath) : undefined,
validation: await validatePrefabReferences(projectPath, { target }),
};
}
async function applyPrefabInstance(nodeUuid) {
const uuid = String(nodeUuid || '').trim();
if (!uuid) {
throw new Error('node uuid is required.');
}
const result = await requestEditorMessage('scene', 'apply-prefab', uuid);
return { applied: true, uuid, result };
}
async function revertPrefabInstance(nodeUuid) {
const uuid = String(nodeUuid || '').trim();
if (!uuid) {
throw new Error('node uuid is required.');
}
const candidates = ['revert-prefab', 'restore-prefab'];
let lastError = null;
for (const method of candidates) {
try {
const result = await requestEditorMessage('scene', method, uuid);
return { reverted: true, uuid, method, result };
} catch (error) {
lastError = error;
}
}
throw lastError || new Error('No prefab revert editor message was available.');
}
module.exports = {
applyPrefabInstance,
duplicatePrefab,
editPrefabJson,
inspectPrefab,
revertPrefabInstance,
validatePrefabReferences,
};
+173
View File
@@ -0,0 +1,173 @@
'use strict';
const fs = require('fs');
const path = require('path');
const { resolveProjectPath } = require('./path-safety');
const KNOWN_INSTRUCTION_PATHS = [
'AGENTS.md',
'CLAUDE.md',
'GEMINI.md',
'.cursorrules',
'.windsurfrules',
'.github/copilot-instructions.md',
];
function normalizeSkillName(value) {
const normalized = String(value || '')
.trim()
.toLowerCase()
.replace(/[^a-z0-9_-]+/g, '-')
.replace(/^-+|-+$/g, '');
if (!normalized) {
throw new Error('skillName is required.');
}
return normalized;
}
function statFile(filePath) {
try {
return fs.statSync(filePath);
} catch (error) {
return null;
}
}
function listSkillFiles(projectPath) {
const skillRoot = resolveProjectPath(projectPath, '.codex/skills');
if (!fs.existsSync(skillRoot)) {
return [];
}
const skills = [];
const stack = [skillRoot];
while (stack.length) {
const current = stack.pop();
for (const entry of fs.readdirSync(current, { withFileTypes: true })) {
const fullPath = path.join(current, entry.name);
if (entry.isDirectory()) {
stack.push(fullPath);
continue;
}
if (entry.name === 'SKILL.md') {
const stat = statFile(fullPath);
skills.push({
path: path.relative(projectPath, fullPath).replace(/\\/g, '/'),
size: stat ? stat.size : 0,
mtime: stat ? stat.mtime.toISOString() : '',
});
}
}
}
return skills.sort((left, right) => left.path.localeCompare(right.path));
}
function listProjectInstructions(projectPath) {
const files = [];
for (const relativePath of KNOWN_INSTRUCTION_PATHS) {
const fullPath = resolveProjectPath(projectPath, relativePath);
const stat = statFile(fullPath);
if (stat && stat.isFile()) {
files.push({
path: relativePath,
size: stat.size,
mtime: stat.mtime.toISOString(),
});
}
}
return {
files,
skills: listSkillFiles(projectPath),
};
}
function readProjectInstruction(projectPath, target) {
const relativePath = String(target || '').trim();
if (!relativePath) {
throw new Error('target is required.');
}
const fullPath = resolveProjectPath(projectPath, relativePath);
if (!fs.existsSync(fullPath) || !fs.statSync(fullPath).isFile()) {
throw new Error(`Instruction file not found: ${relativePath}`);
}
return {
path: relativePath,
content: fs.readFileSync(fullPath, 'utf8'),
};
}
function writeProjectInstruction(projectPath, options = {}) {
const relativePath = String(options.target || '').trim();
if (!relativePath) {
throw new Error('target is required.');
}
const content = String(options.content || '');
const fullPath = resolveProjectPath(projectPath, relativePath);
if (fs.existsSync(fullPath) && options.overwrite === false) {
throw new Error(`Instruction file already exists: ${relativePath}`);
}
fs.mkdirSync(path.dirname(fullPath), { recursive: true });
fs.writeFileSync(fullPath, content, 'utf8');
const stat = fs.statSync(fullPath);
return {
written: true,
path: relativePath,
size: stat.size,
mtime: stat.mtime.toISOString(),
};
}
function createProjectSkill(projectPath, options = {}) {
const skillName = normalizeSkillName(options.skillName);
const title = String(options.title || skillName).trim();
const description = String(options.description || `Project-specific workflow for ${title}.`).trim();
const body = String(options.instructions || '').trim() || [
`Use this skill for ${title} work in this Cocos project.`,
'',
'- Inspect the active scene and project context before editing.',
'- Prefer focused MCP tools before broad manual file edits.',
'- Run relevant validation tools after changes.',
].join('\n');
const relativePath = `.codex/skills/${skillName}/SKILL.md`;
const content = [
`# ${title}`,
'',
`Description: ${description}`,
'',
'## Instructions',
body,
'',
].join('\n');
return writeProjectInstruction(projectPath, {
target: relativePath,
content,
overwrite: options.overwrite !== false,
});
}
function createCocosMcpProjectSkill(projectPath, options = {}) {
return createProjectSkill(projectPath, {
skillName: options.skillName || 'funplay-cocos-mcp-workflow',
title: options.title || 'Funplay Cocos MCP Workflow',
description: options.description || 'Use this skill when editing, validating, or debugging this Cocos Creator project through Funplay Cocos MCP.',
overwrite: options.overwrite !== false,
instructions: String(options.instructions || '').trim() || [
'- Start by reading `cocos://project/context` or calling `get_editor_state` to confirm the active project, scene, server URL, and tool profile.',
'- Prefer `execute_javascript` for high-level scene/editor orchestration, but keep safety checks enabled unless the code was reviewed.',
'- Use focused tools when they are better primitives: `list_assets`, `inspect_asset_dependencies`, `validate_asset_dependencies`, `run_script_diagnostics`, `get_script_diagnostic_context`, and screenshot tools.',
'- For UI work, inspect the active Canvas/hierarchy first, mutate the smallest necessary node/component set, then verify with `validate_scene` and a screenshot.',
'- For prefab or asset edits, inspect dependencies/references before mutation and refresh assets afterward.',
'- When changing tool exposure, save a named tool profile so the same client setup can be restored later.',
].join('\n'),
});
}
module.exports = {
KNOWN_INSTRUCTION_PATHS,
createCocosMcpProjectSkill,
createProjectSkill,
listProjectInstructions,
readProjectInstruction,
writeProjectInstruction,
};
+165 -6
View File
@@ -84,7 +84,11 @@ class McpServer {
this.runtimeLog = options.runtimeLog; this.runtimeLog = options.runtimeLog;
this.serverName = options.serverName; this.serverName = options.serverName;
this.serverVersion = options.serverVersion; this.serverVersion = options.serverVersion;
this.projectName = options.projectName || '';
this.projectIdentity = options.projectIdentity || '';
this.server = null; this.server = null;
this.attached = false;
this.attachedInfo = null;
this.actualPort = null; this.actualPort = null;
this.portFallbackInfo = null; this.portFallbackInfo = null;
this.negotiatedProtocolVersion = MCP_PROTOCOL_VERSION; this.negotiatedProtocolVersion = MCP_PROTOCOL_VERSION;
@@ -93,10 +97,13 @@ class McpServer {
} }
isRunning() { isRunning() {
return Boolean(this.server && this.server.listening); return Boolean(this.attached || (this.server && this.server.listening));
} }
getPort() { getPort() {
if (this.attached && this.actualPort) {
return this.actualPort;
}
if (this.server && typeof this.server.address === 'function') { if (this.server && typeof this.server.address === 'function') {
const address = this.server.address(); const address = this.server.address();
if (address && typeof address.port === 'number') { if (address && typeof address.port === 'number') {
@@ -114,6 +121,10 @@ class McpServer {
return this.portFallbackInfo; return this.portFallbackInfo;
} }
getAttachInfo() {
return this.attachedInfo;
}
log(level, message) { log(level, message) {
if (this.runtimeLog && typeof this.runtimeLog.add === 'function') { if (this.runtimeLog && typeof this.runtimeLog.add === 'function') {
this.runtimeLog.add(level, message); this.runtimeLog.add(level, message);
@@ -137,12 +148,42 @@ class McpServer {
this.actualPort = null; this.actualPort = null;
this.portFallbackInfo = null; this.portFallbackInfo = null;
this.attached = false;
this.attachedInfo = null;
const requestHandler = async (request, response) => { const requestHandler = async (request, response) => {
try { try {
if (request.method === 'GET' && request.url === '/health') { const requestUrl = new URL(request.url || '/', 'http://localhost');
if (request.method === 'GET' && requestUrl.pathname === '/health') {
this.log('info', 'GET /health'); this.log('info', 'GET /health');
return json(response, 200, { ok: true, name: this.serverName, version: this.serverVersion }, this.negotiatedProtocolVersion); return json(response, 200, {
ok: true,
name: this.serverName,
version: this.serverVersion,
projectName: this.projectName,
projectIdentity: this.projectIdentity,
}, this.negotiatedProtocolVersion);
}
if (request.method === 'GET' && requestUrl.pathname === '/tools') {
this.log('info', 'GET /tools');
const includeCatalog = requestUrl.searchParams.get('catalog') === '1';
const tools = includeCatalog && typeof this.toolRegistry.listToolCatalog === 'function'
? this.toolRegistry.listToolCatalog()
: this.toolRegistry.listTools();
const baseUrl = `http://${request.headers.host || `${this.config.host}:${this.getPort()}`}`;
return json(response, 200, {
ok: true,
name: this.serverName,
version: this.serverVersion,
count: tools.length,
tools,
examples: {
health: `curl ${baseUrl}/health`,
tools: `curl ${baseUrl}/tools`,
catalog: `curl ${baseUrl}/tools?catalog=1`,
},
}, this.negotiatedProtocolVersion);
} }
if (!this.isAllowedOrigin(request)) { if (!this.isAllowedOrigin(request)) {
@@ -266,7 +307,12 @@ class McpServer {
return; return;
} catch (error) { } catch (error) {
lastError = error; lastError = error;
candidate.removeAllListeners();
if (error && error.code === 'EADDRINUSE' && port < 65535 && attempt < MAX_PORT_FALLBACK_ATTEMPTS) { if (error && error.code === 'EADDRINUSE' && port < 65535 && attempt < MAX_PORT_FALLBACK_ATTEMPTS) {
if (await this.tryAttachToExisting(port)) {
return;
}
const nextPort = port + 1; const nextPort = port + 1;
this.log( this.log(
'warn', 'warn',
@@ -277,7 +323,6 @@ class McpServer {
continue; continue;
} }
candidate.removeAllListeners();
break; break;
} }
} }
@@ -289,6 +334,15 @@ class McpServer {
} }
async stop() { async stop() {
if (this.attached) {
this.log('info', `Detached from existing MCP listener on ${this.config.host}:${this.actualPort}.`);
this.attached = false;
this.attachedInfo = null;
this.actualPort = null;
this.portFallbackInfo = null;
return;
}
if (!this.server) { if (!this.server) {
this.log('info', 'Stop skipped: server object is empty.'); this.log('info', 'Stop skipped: server object is empty.');
return; return;
@@ -299,6 +353,7 @@ class McpServer {
this.server = null; this.server = null;
this.actualPort = null; this.actualPort = null;
this.portFallbackInfo = null; this.portFallbackInfo = null;
this.attachedInfo = null;
await new Promise((resolve, reject) => { await new Promise((resolve, reject) => {
active.close((error) => { active.close((error) => {
if (error) { if (error) {
@@ -312,6 +367,100 @@ class McpServer {
}); });
} }
async tryAttachToExisting(port) {
if (!this.projectIdentity || this.config.attachToExisting === false || port === 0) {
return false;
}
const probe = await this.probeExistingServer(port);
if (!probe || !probe.result) {
this.log('warn', `Port ${port} is occupied, but no compatible Funplay MCP initialize response was received.`);
return false;
}
const result = probe.result || {};
const serverInfo = result.serverInfo || {};
const funplay = result.funplay || {};
const remoteProjectIdentity = funplay.projectIdentity || serverInfo.projectIdentity || '';
const remoteName = serverInfo.name || '';
if (remoteName === this.serverName && remoteProjectIdentity === this.projectIdentity) {
this.attached = true;
this.attachedInfo = {
host: this.config.host,
port,
serverName: remoteName,
projectName: funplay.projectName || this.projectName,
projectIdentity: remoteProjectIdentity,
version: serverInfo.version || '',
};
this.actualPort = port;
this.portFallbackInfo = null;
this.log('info', `Attached to existing MCP listener for this project at http://${this.config.host}:${port}/.`);
return true;
}
this.log(
'warn',
`Port ${port} belongs to another listener; expected name=${this.serverName}, project=${this.projectIdentity}, ` +
`got name=${remoteName || 'unknown'}, project=${remoteProjectIdentity || 'unknown'}.`
);
return false;
}
probeExistingServer(port) {
const body = JSON.stringify({
jsonrpc: '2.0',
id: 'funplay-probe',
method: 'initialize',
params: {
protocolVersion: MCP_PROTOCOL_VERSION,
clientInfo: {
name: 'funplay-cocos-mcp-probe',
version: this.serverVersion,
},
},
});
return new Promise((resolve) => {
const request = http.request(
{
host: this.config.host,
port,
method: 'POST',
path: '/',
timeout: 600,
headers: {
Accept: 'application/json, text/event-stream',
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(body),
},
},
(response) => {
const chunks = [];
response.on('data', (chunk) => chunks.push(chunk));
response.on('end', () => {
if (response.statusCode !== 200) {
resolve(null);
return;
}
try {
resolve(JSON.parse(Buffer.concat(chunks).toString('utf8')));
} catch (error) {
resolve(null);
}
});
}
);
request.on('timeout', () => {
request.destroy();
resolve(null);
});
request.on('error', () => resolve(null));
request.end(body);
});
}
listen(server, port, host) { listen(server, port, host) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const onError = (error) => { const onError = (error) => {
@@ -537,6 +686,11 @@ class McpServer {
name: this.serverName, name: this.serverName,
version: this.serverVersion, version: this.serverVersion,
}, },
funplay: {
server: 'funplay-cocos-mcp',
projectName: this.projectName,
projectIdentity: this.projectIdentity,
},
capabilities: { capabilities: {
tools: {}, tools: {},
resources: {}, resources: {},
@@ -570,10 +724,15 @@ class McpServer {
} }
return this.createResult(request.id, result); return this.createResult(request.id, result);
} catch (error) { } catch (error) {
return this.createResult(request.id, { const result = {
content: textContent(error.message), content: textContent(error.message),
isError: true, isError: true,
}); };
const structured = structuredContent(error.toolEnvelope);
if (structured) {
result.structuredContent = structured;
}
return this.createResult(request.id, result);
} }
} }
+155
View File
@@ -0,0 +1,155 @@
'use strict';
const PROFILE_FIELDS = [
'toolProfile',
'enabledToolCategories',
'disabledToolCategories',
'enabledTools',
'disabledTools',
];
function normalizeProfileName(value) {
const normalized = String(value || '').trim();
if (!normalized) {
throw new Error('profile name is required.');
}
return normalized.slice(0, 80);
}
function normalizeStringList(value) {
if (Array.isArray(value)) {
return value.map((item) => String(item || '').trim()).filter(Boolean);
}
if (typeof value === 'string') {
return value.split(/[\n,]/).map((item) => item.trim()).filter(Boolean);
}
return [];
}
function normalizeProfileMode(value) {
const normalized = String(value || 'core').trim().toLowerCase();
return normalized === 'full' || normalized === 'custom' ? normalized : 'core';
}
function normalizeToolProfile(value) {
const profile = value || {};
return {
name: normalizeProfileName(profile.name),
toolProfile: normalizeProfileMode(profile.toolProfile),
enabledToolCategories: normalizeStringList(profile.enabledToolCategories).map((item) => item.toLowerCase()),
disabledToolCategories: normalizeStringList(profile.disabledToolCategories).map((item) => item.toLowerCase()),
enabledTools: normalizeStringList(profile.enabledTools),
disabledTools: normalizeStringList(profile.disabledTools),
updatedAt: profile.updatedAt ? String(profile.updatedAt) : new Date().toISOString(),
};
}
function normalizeSavedToolProfiles(value) {
const profiles = [];
const seen = new Set();
for (const item of Array.isArray(value) ? value : []) {
try {
const profile = normalizeToolProfile(item);
const key = profile.name.toLowerCase();
if (seen.has(key)) {
const index = profiles.findIndex((existing) => existing.name.toLowerCase() === key);
profiles[index] = profile;
} else {
seen.add(key);
profiles.push(profile);
}
} catch (error) {
// Ignore malformed saved profile entries rather than breaking extension startup.
}
}
return profiles.sort((left, right) => left.name.localeCompare(right.name));
}
function createToolProfileSnapshot(config = {}, name) {
return normalizeToolProfile({
name,
toolProfile: config.toolProfile,
enabledToolCategories: config.enabledToolCategories,
disabledToolCategories: config.disabledToolCategories,
enabledTools: config.enabledTools,
disabledTools: config.disabledTools,
});
}
function upsertToolProfile(savedProfiles, profile) {
const normalized = normalizeToolProfile(profile);
const profiles = normalizeSavedToolProfiles(savedProfiles);
const key = normalized.name.toLowerCase();
const index = profiles.findIndex((item) => item.name.toLowerCase() === key);
if (index >= 0) {
profiles[index] = normalized;
} else {
profiles.push(normalized);
}
return normalizeSavedToolProfiles(profiles);
}
function deleteToolProfile(savedProfiles, name) {
const key = normalizeProfileName(name).toLowerCase();
return normalizeSavedToolProfiles(savedProfiles)
.filter((profile) => profile.name.toLowerCase() !== key);
}
function findToolProfile(savedProfiles, name) {
const key = normalizeProfileName(name).toLowerCase();
return normalizeSavedToolProfiles(savedProfiles)
.find((profile) => profile.name.toLowerCase() === key) || null;
}
function applyToolProfile(config = {}, profile) {
const normalized = normalizeToolProfile(profile);
const next = { ...config };
for (const field of PROFILE_FIELDS) {
next[field] = Array.isArray(normalized[field])
? normalized[field].slice()
: normalized[field];
}
next.activeToolProfileName = normalized.name;
return next;
}
function exportToolProfiles(savedProfiles) {
return {
version: 1,
profiles: normalizeSavedToolProfiles(savedProfiles),
};
}
function parseProfileImportPayload(payload) {
if (typeof payload === 'string') {
return JSON.parse(payload);
}
return payload || {};
}
function importToolProfiles(savedProfiles, payload, options = {}) {
const parsed = parseProfileImportPayload(payload);
const incoming = Array.isArray(parsed)
? parsed
: Array.isArray(parsed.profiles)
? parsed.profiles
: [];
if (!incoming.length) {
throw new Error('No tool profiles found in import payload.');
}
const base = options.replace ? [] : normalizeSavedToolProfiles(savedProfiles);
return incoming.reduce((profiles, profile) => upsertToolProfile(profiles, profile), base);
}
module.exports = {
applyToolProfile,
createToolProfileSnapshot,
deleteToolProfile,
exportToolProfiles,
findToolProfile,
importToolProfiles,
normalizeSavedToolProfiles,
normalizeToolProfile,
upsertToolProfile,
};
+485 -271
View File
@@ -1,7 +1,7 @@
'use strict'; 'use strict';
const crypto = require('crypto');
const fs = require('fs'); const fs = require('fs');
const path = require('path');
const { const {
clearSelection, clearSelection,
deleteAsset, deleteAsset,
@@ -22,11 +22,37 @@ const {
searchProjectLogs, searchProjectLogs,
} = require('./logs'); } = require('./logs');
const { resolveProjectPath } = require('./path-safety'); const { resolveProjectPath } = require('./path-safety');
const {
createCocosMcpProjectSkill,
createProjectSkill,
listProjectInstructions,
readProjectInstruction,
writeProjectInstruction,
} = require('./project-instructions');
const {
applyPrefabInstance,
duplicatePrefab,
editPrefabJson,
inspectPrefab,
revertPrefabInstance,
validatePrefabReferences,
} = require('./prefabs');
const { createAssetsAdvancedTools } = require('./tools/assets-advanced');
const { createCocosProjectTools } = require('./tools/cocos-project');
const { buildSnippet, createFileTools, refreshAssets } = require('./tools/files');
const { createSceneEventTools } = require('./tools/scene-events');
const { captureDesktopScreenshot, captureEditorWindowScreenshot, capturePanelScreenshot } = require('./screenshots'); const { captureDesktopScreenshot, captureEditorWindowScreenshot, capturePanelScreenshot } = require('./screenshots');
const { checkForUpdate } = require('./update-checker'); const { checkForUpdate } = require('./update-checker');
const { assertJavascriptSafety } = require('./javascript-safety');
const { safeStringify } = require('./utils'); const { safeStringify } = require('./utils');
const IMAGE_DATA_URI_PREFIX = 'data:image/png;base64,';
const TOOL_CATEGORY_RULES = [ const TOOL_CATEGORY_RULES = [
['project', /^(get_project_info|get_editor_state|get_tool_catalog)$/],
['build', /^(get_build_status|open_build_panel|run_project_preview|save_current_scene)$/],
['preferences', /preference/],
['broadcast', /broadcast/],
['events', /event|bind_button_click|button_click/],
['updates', /update/], ['updates', /update/],
['logs', /log/], ['logs', /log/],
['diagnostics', /diagnostic|validate/], ['diagnostics', /diagnostic|validate/],
@@ -35,6 +61,7 @@ const TOOL_CATEGORY_RULES = [
['files', /file|directory|exists|refresh_assets/], ['files', /file|directory|exists|refresh_assets/],
['assets', /asset|scene$|scenes|open_scene|run_scene_asset/], ['assets', /asset|scene$|scenes|open_scene|run_scene_asset/],
['prefabs', /prefab/], ['prefabs', /prefab/],
['instructions', /instruction|skill/],
['selection', /selection|select_/], ['selection', /selection|select_/],
['components', /component/], ['components', /component/],
['ui', /canvas|label|button|sprite/], ['ui', /canvas|label|button|sprite/],
@@ -43,7 +70,6 @@ const TOOL_CATEGORY_RULES = [
['runtime', /runtime|time_scale|node_event|invoke_component/], ['runtime', /runtime|time_scale|node_event|invoke_component/],
['scene', /scene|hierarchy|node/], ['scene', /scene|hierarchy|node/],
['execution', /execute_/], ['execution', /execute_/],
['project', /project|editor_state|tool_catalog/],
]; ];
function createSchema(properties, required) { function createSchema(properties, required) {
@@ -57,6 +83,34 @@ function createSchema(properties, required) {
return schema; return schema;
} }
function createOutputSchema(dataSchema = {}) {
return {
type: 'object',
properties: {
ok: { type: 'boolean', description: 'Whether the tool call completed successfully.' },
tool: { type: 'string', description: 'Tool name that produced this result.' },
callId: { type: 'string', description: 'Stable identifier for this tool call result.' },
timestamp: { type: 'string', description: 'ISO timestamp when the result envelope was produced.' },
summary: { type: 'string', description: 'Short human-readable result summary.' },
data: dataSchema,
refs: {
type: 'array',
description: 'Stable references discovered in the result for follow-up tool calls.',
items: {
type: 'object',
properties: {
type: { type: 'string' },
id: { type: 'string' },
path: { type: 'string' },
name: { type: 'string' },
},
},
},
},
required: ['ok', 'tool', 'callId', 'timestamp', 'data'],
};
}
function inferToolCategory(toolName) { function inferToolCategory(toolName) {
for (const [category, pattern] of TOOL_CATEGORY_RULES) { for (const [category, pattern] of TOOL_CATEGORY_RULES) {
if (pattern.test(toolName)) { if (pattern.test(toolName)) {
@@ -86,6 +140,26 @@ function toolCategory(tool) {
return tool.category || inferToolCategory(tool.name); return tool.category || inferToolCategory(tool.name);
} }
function inferToolAnnotations(tool) {
const name = tool.name;
const category = toolCategory(tool);
const readOnly = /^(get|list|inspect|find|read|search|check|validate|exists|capture)/.test(name);
const destructive = /(delete|remove|clear|replace|write|reset|set_|execute|run_scene|invoke|emit|simulate)/.test(name);
const idempotent = readOnly || /^(set|select|open|pause|resume|stop|refresh)/.test(name);
return {
title: name
.split('_')
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
.join(' '),
readOnlyHint: readOnly,
destructiveHint: readOnly ? false : destructive,
idempotentHint: idempotent,
openWorldHint: category === 'updates',
...(tool.annotations || {}),
};
}
function isToolExposed(config, tool) { function isToolExposed(config, tool) {
const profile = config && config.toolProfile === 'full' const profile = config && config.toolProfile === 'full'
? 'full' ? 'full'
@@ -112,6 +186,115 @@ function isToolExposed(config, tool) {
return exposed; return exposed;
} }
function hashObject(value) {
return crypto
.createHash('sha256')
.update(safeStringify(value))
.digest('hex')
.slice(0, 16);
}
function summarizeResult(result) {
if (typeof result === 'string') {
if (result.startsWith(IMAGE_DATA_URI_PREFIX)) {
return 'Image payload returned.';
}
return result.length > 160 ? `${result.slice(0, 160)}...` : result;
}
if (!result || typeof result !== 'object') {
return String(result);
}
if (typeof result.summary === 'string') {
return result.summary;
}
for (const key of ['message', 'path', 'url', 'sceneName', 'projectName']) {
if (typeof result[key] === 'string' && result[key]) {
return `${key}: ${result[key]}`;
}
}
if (Number.isFinite(result.count)) {
return `count: ${result.count}`;
}
return 'Structured result returned.';
}
function normalizeEnvelopeData(result) {
if (typeof result === 'string' && result.startsWith(IMAGE_DATA_URI_PREFIX)) {
return {
image: true,
mimeType: 'image/png',
byteLength: Buffer.byteLength(result.slice(IMAGE_DATA_URI_PREFIX.length), 'base64'),
};
}
return result;
}
function addRef(refs, type, id, extra = {}) {
if (!id) {
return;
}
const key = `${type}:${id}`;
if (refs.some((ref) => ref.key === key)) {
return;
}
refs.push({ key, type, id: String(id), ...extra });
}
function collectRefs(value, refs = [], depth = 0, seen = new WeakSet()) {
if (!value || depth > 5) {
return refs;
}
if (Array.isArray(value)) {
for (const item of value) {
collectRefs(item, refs, depth + 1, seen);
}
return refs;
}
if (typeof value !== 'object') {
return refs;
}
if (seen.has(value)) {
return refs;
}
seen.add(value);
const uuid = value.uuid || value.prefabUuid || value.sceneUuid || value.assetUuid;
const pathValue = value.path || value.node || value.url;
if (uuid) {
addRef(refs, pathValue && String(pathValue).startsWith('db://') ? 'asset' : 'uuid', uuid, {
path: pathValue ? String(pathValue) : undefined,
name: value.name ? String(value.name) : undefined,
});
}
if (typeof pathValue === 'string' && pathValue) {
addRef(refs, pathValue.startsWith('db://') ? 'asset' : 'path', pathValue, {
name: value.name ? String(value.name) : undefined,
});
}
for (const item of Object.values(value)) {
collectRefs(item, refs, depth + 1, seen);
}
return refs;
}
function createResultEnvelope(tool, args, result, options = {}) {
const data = normalizeEnvelopeData(result);
const refs = collectRefs(data).map(({ key, ...ref }) => ref);
const timestamp = new Date().toISOString();
const summary = options.summary || summarizeResult(result);
const callId = `fp_${hashObject({ tool: tool.name, args: args || {}, result: data })}`;
return {
ok: options.ok !== false,
tool: tool.name,
callId,
timestamp,
summary,
data,
refs,
};
}
function summarizeDiagnostics(result) { function summarizeDiagnostics(result) {
if (!result) { if (!result) {
return null; return null;
@@ -132,89 +315,39 @@ function toOutput(value) {
return safeStringify(value); return safeStringify(value);
} }
function matchesPattern(fileName, pattern) { function useJavascriptSafetyChecks(args, runtimeContext) {
const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*'); if (args && typeof args.safety_checks === 'boolean') {
return new RegExp(`^${escaped}$`, 'i').test(fileName); return args.safety_checks;
}
if (args && typeof args.safetyChecks === 'boolean') {
return args.safetyChecks;
}
const config = runtimeContext && runtimeContext.config;
if (config && typeof config.executeJavascriptSafetyChecks === 'boolean') {
return config.executeJavascriptSafetyChecks;
}
return true;
} }
function searchFiles(rootDir, pattern, limit) { function assertToolJavascriptSafety(args, runtimeContext) {
const results = []; if (!useJavascriptSafetyChecks(args, runtimeContext)) {
if (!fs.existsSync(rootDir)) { return;
return results;
} }
const stack = [rootDir]; assertJavascriptSafety(args && args.code, {
while (stack.length && results.length < limit) { projectPath: runtimeContext && runtimeContext.projectPath,
const current = stack.pop(); });
const entries = fs.readdirSync(current, { withFileTypes: true });
for (const entry of entries) {
if (entry.name === '.git' || entry.name === 'node_modules' || entry.name === 'temp' || entry.name === 'library') {
continue;
} }
const fullPath = path.join(current, entry.name); async function resolveNodeUuid(sceneBridge, args) {
if (entry.isDirectory()) { if (args && args.uuid) {
stack.push(fullPath); return String(args.uuid);
continue;
} }
const inspected = await sceneBridge.call('inspectNode', args || {});
if (matchesPattern(entry.name, pattern)) { if (!inspected || !inspected.uuid) {
results.push(fullPath); throw new Error('Target node uuid could not be resolved.');
if (results.length >= limit) {
break;
} }
} return inspected.uuid;
}
}
return results;
}
function readLines(filePath) {
return fs.readFileSync(filePath, 'utf8').split(/\r?\n/);
}
function buildSnippet(filePath, lineNumber, contextLines = 3) {
const lines = readLines(filePath);
const start = Math.max(1, Number(lineNumber || 1) - Math.max(0, contextLines));
const end = Math.min(lines.length, Number(lineNumber || 1) + Math.max(0, contextLines));
const snippet = [];
for (let line = start; line <= end; line += 1) {
const marker = line === Number(lineNumber || 1) ? '>' : ' ';
snippet.push(`${marker} ${String(line).padStart(4, ' ')} | ${lines[line - 1]}`);
}
return snippet.join('\n');
}
function replaceAllLiteral(content, search, replacement) {
if (!search) {
throw new Error('search text is required.');
}
return content.split(search).join(replacement);
}
async function refreshAssets(projectPath, targetPath) {
if (!global.Editor || !Editor.Message || typeof Editor.Message.request !== 'function') {
return 'Asset refresh API is unavailable; Cocos Creator should pick up file changes automatically.';
}
const relative = path.relative(path.join(projectPath, 'assets'), targetPath).replace(/\\/g, '/');
if (!relative.startsWith('..')) {
const dbUrl = `db://assets/${relative}`;
try {
await Editor.Message.request('asset-db', 'refresh-asset', dbUrl);
return `Refreshed asset database for ${dbUrl}`;
} catch (error) {
try {
await Editor.Message.request('asset-db', 'refresh-asset', 'db://assets');
return `Refreshed asset database after writing ${dbUrl}`;
} catch (innerError) {
return `File written, but asset refresh failed: ${innerError.message}`;
}
}
}
return 'File written outside assets directory; no asset-db refresh was needed.';
} }
function createToolRegistry({ getRuntimeContext, getStatus, interactionLog, runtimeLog, sceneBridge, editorExecutor }) { function createToolRegistry({ getRuntimeContext, getStatus, interactionLog, runtimeLog, sceneBridge, editorExecutor }) {
@@ -228,11 +361,14 @@ function createToolRegistry({ getRuntimeContext, getStatus, interactionLog, runt
context: { type: 'string', description: 'Execution context: scene or editor.' }, context: { type: 'string', description: 'Execution context: scene or editor.' },
code: { type: 'string', description: 'JavaScript code to execute. May directly return a value, define run(env), or export a function.' }, code: { type: 'string', description: 'JavaScript code to execute. May directly return a value, define run(env), or export a function.' },
args: { type: 'object', description: 'Optional JSON object passed into the script.' }, args: { type: 'object', description: 'Optional JSON object passed into the script.' },
safety_checks: { type: 'boolean', description: 'Override the project default JavaScript safety checks for this call.' },
}, },
['context', 'code'] ['context', 'code']
), ),
handler: async (args) => { handler: async (args) => {
const context = String(args.context || '').toLowerCase(); const context = String(args.context || '').toLowerCase();
const runtimeContext = getRuntimeContext();
assertToolJavascriptSafety(args, runtimeContext);
if (context === 'scene') { if (context === 'scene') {
return sceneBridge.call('executeCode', { code: args.code, args: args.args || {} }); return sceneBridge.call('executeCode', { code: args.code, args: args.args || {} });
} }
@@ -253,10 +389,14 @@ function createToolRegistry({ getRuntimeContext, getStatus, interactionLog, runt
{ {
code: { type: 'string', description: 'JavaScript code to execute inside the scene script context.' }, code: { type: 'string', description: 'JavaScript code to execute inside the scene script context.' },
args: { type: 'object', description: 'Optional JSON object passed to the scene script.' }, args: { type: 'object', description: 'Optional JSON object passed to the scene script.' },
safety_checks: { type: 'boolean', description: 'Override the project default JavaScript safety checks for this call.' },
}, },
['code'] ['code']
), ),
handler: async (args) => sceneBridge.call('executeCode', { code: args.code, args: args.args || {} }), handler: async (args) => {
assertToolJavascriptSafety(args, getRuntimeContext());
return sceneBridge.call('executeCode', { code: args.code, args: args.args || {} });
},
}, },
{ {
name: 'execute_editor_script', name: 'execute_editor_script',
@@ -266,10 +406,12 @@ function createToolRegistry({ getRuntimeContext, getStatus, interactionLog, runt
{ {
code: { type: 'string', description: 'JavaScript code to execute inside the editor context.' }, code: { type: 'string', description: 'JavaScript code to execute inside the editor context.' },
args: { type: 'object', description: 'Optional JSON object passed to the editor script.' }, args: { type: 'object', description: 'Optional JSON object passed to the editor script.' },
safety_checks: { type: 'boolean', description: 'Override the project default JavaScript safety checks for this call.' },
}, },
['code'] ['code']
), ),
handler: async (args) => { handler: async (args) => {
assertToolJavascriptSafety(args, getRuntimeContext());
if (typeof editorExecutor !== 'function') { if (typeof editorExecutor !== 'function') {
throw new Error('Editor JavaScript execution is unavailable.'); throw new Error('Editor JavaScript execution is unavailable.');
} }
@@ -351,6 +493,83 @@ function createToolRegistry({ getRuntimeContext, getStatus, interactionLog, runt
inputSchema: createSchema({}, []), inputSchema: createSchema({}, []),
handler: async () => getCurrentSelection(), handler: async () => getCurrentSelection(),
}, },
{
name: 'list_project_instructions',
profile: 'core',
description: '[specialist] List project AI instruction files and local Codex project skills.',
inputSchema: createSchema({}, []),
handler: async () => {
const { projectPath } = getRuntimeContext();
return listProjectInstructions(projectPath);
},
},
{
name: 'read_project_instruction',
profile: 'core',
description: '[specialist] Read a project AI instruction file such as AGENTS.md, CLAUDE.md, or a .codex skill SKILL.md.',
inputSchema: createSchema(
{
target: { type: 'string', description: 'Project-relative instruction path.' },
},
['target']
),
handler: async (args) => {
const { projectPath } = getRuntimeContext();
return readProjectInstruction(projectPath, args.target);
},
},
{
name: 'write_project_instruction',
profile: 'full',
description: '[core] Create or update a project AI instruction file inside the Cocos project.',
inputSchema: createSchema(
{
target: { type: 'string', description: 'Project-relative instruction path.' },
content: { type: 'string', description: 'Instruction file content.' },
overwrite: { type: 'boolean', description: 'Allow overwriting an existing file. Defaults to true.' },
},
['target', 'content']
),
handler: async (args) => {
const { projectPath } = getRuntimeContext();
return writeProjectInstruction(projectPath, args);
},
},
{
name: 'create_project_skill',
profile: 'full',
description: '[core] Create a local Codex project skill under .codex/skills/{skillName}/SKILL.md.',
inputSchema: createSchema(
{
skillName: { type: 'string', description: 'Filesystem-safe project skill name.' },
title: { type: 'string', description: 'Human-readable skill title.' },
description: { type: 'string', description: 'Skill trigger description.' },
instructions: { type: 'string', description: 'Skill instructions body.' },
overwrite: { type: 'boolean', description: 'Allow overwriting an existing skill. Defaults to true.' },
},
['skillName']
),
handler: async (args) => {
const { projectPath } = getRuntimeContext();
return createProjectSkill(projectPath, args);
},
},
{
name: 'create_cocos_mcp_project_skill',
profile: 'full',
description: '[core] Create a recommended local Codex project skill for Funplay Cocos MCP workflows.',
inputSchema: createSchema(
{
skillName: { type: 'string', description: 'Optional filesystem-safe project skill name.' },
overwrite: { type: 'boolean', description: 'Allow overwriting an existing skill. Defaults to true.' },
},
[]
),
handler: async (args) => {
const { projectPath } = getRuntimeContext();
return createCocosMcpProjectSkill(projectPath, args);
},
},
{ {
name: 'set_selection', name: 'set_selection',
profile: 'core', profile: 'core',
@@ -495,6 +714,7 @@ function createToolRegistry({ getRuntimeContext, getStatus, interactionLog, runt
inputSchema: createSchema({}, []), inputSchema: createSchema({}, []),
handler: async () => getRuntimeContext(), handler: async () => getRuntimeContext(),
}, },
...createCocosProjectTools({ createSchema }),
{ {
name: 'list_scenes', name: 'list_scenes',
profile: 'core', profile: 'core',
@@ -537,6 +757,166 @@ function createToolRegistry({ getRuntimeContext, getStatus, interactionLog, runt
return { count: assets.length, prefabs: assets.slice(0, 200) }; return { count: assets.length, prefabs: assets.slice(0, 200) };
}, },
}, },
{
name: 'inspect_prefab',
profile: 'core',
description: '[specialist] Inspect a prefab asset, its metadata, serialized file path, and UUID-like asset references.',
inputSchema: createSchema(
{
target: { type: 'string', description: 'Prefab uuid, db url, or project path.' },
},
['target']
),
handler: async (args) => {
const { projectPath } = getRuntimeContext();
return await inspectPrefab(projectPath, args.target);
},
},
{
name: 'validate_prefab_references',
profile: 'core',
description: '[specialist] Validate prefab asset references by checking serialized UUID references against asset-db.',
inputSchema: createSchema(
{
target: { type: 'string', description: 'Optional prefab uuid, db url, or path. When omitted, scans prefab assets.' },
pattern: { type: 'string', description: 'Optional asset-db pattern used when scanning prefabs.' },
limit: { type: 'number', description: 'Maximum prefab assets to scan when target is omitted.' },
},
[]
),
handler: async (args) => {
const { projectPath } = getRuntimeContext();
return await validatePrefabReferences(projectPath, args);
},
},
{
name: 'duplicate_prefab',
profile: 'full',
description: '[core] Create a new prefab asset by duplicating an existing prefab file without copying its .meta UUID.',
inputSchema: createSchema(
{
source: { type: 'string', description: 'Source prefab uuid, db url, or project path.' },
target: { type: 'string', description: 'Project-relative target path under assets, with or without .prefab.' },
overwrite: { type: 'boolean', description: 'Overwrite target prefab if it already exists.' },
},
['source', 'target']
),
handler: async (args) => {
const { projectPath } = getRuntimeContext();
const result = await duplicatePrefab(projectPath, args);
return { ...result, refresh: await refreshAssets(projectPath, resolveProjectPath(projectPath, result.target)) };
},
},
{
name: 'edit_prefab_json',
profile: 'full',
description: '[core] Edit a prefab JSON file by JSON path assignment or literal search/replace, then validate references.',
inputSchema: createSchema(
{
target: { type: 'string', description: 'Prefab uuid, db url, or project path.' },
jsonPath: { type: 'string', description: 'JSON path such as /0/_name or 0._name when assigning valueJson.' },
valueJson: { type: 'string', description: 'JSON encoded value to assign at jsonPath.' },
search: { type: 'string', description: 'Literal text to search for instead of jsonPath assignment.' },
replace: { type: 'string', description: 'Replacement text for literal search.' },
replaceAll: { type: 'boolean', description: 'Replace all literal matches.' },
createBackup: { type: 'boolean', description: 'Create a .bak file before writing.' },
},
['target']
),
handler: async (args) => {
const { projectPath } = getRuntimeContext();
const result = await editPrefabJson(projectPath, args);
return { ...result, refresh: await refreshAssets(projectPath, resolveProjectPath(projectPath, result.path)) };
},
},
{
name: 'create_prefab_instance',
profile: 'full',
description: '[core] Create a linked prefab instance in the editor hierarchy using Cocos scene create-node when available.',
inputSchema: createSchema(
{
prefabUuid: { type: 'string', description: 'Prefab asset uuid, db url, or path.' },
parentPath: { type: 'string', description: 'Optional parent node path.' },
name: { type: 'string', description: 'Optional override node name.' },
position: { type: 'object', description: 'Optional position {x,y,z}; fallback runtime path only.' },
},
['prefabUuid']
),
handler: async (args) => {
const info = await queryAssetInfo(args.prefabUuid);
const payload = {
assetUuid: info.uuid || args.prefabUuid,
unlinkPrefab: false,
};
if (args.parentPath) {
payload.parent = await resolveNodeUuid(sceneBridge, { path: args.parentPath });
}
if (args.name) {
payload.name = args.name;
}
if (global.Editor && Editor.Message && typeof Editor.Message.request === 'function') {
try {
const createdUuid = await Editor.Message.request('scene', 'create-node', payload);
return {
created: true,
linkedPrefab: true,
prefabUuid: payload.assetUuid,
uuid: createdUuid,
};
} catch (error) {
runtimeLog && runtimeLog.add('warn', `Linked prefab create-node failed: ${error.message}`);
}
}
return await sceneBridge.call('instantiatePrefab', {
...args,
prefabUuid: info.uuid || args.prefabUuid,
});
},
},
{
name: 'inspect_prefab_instance',
profile: 'core',
description: '[specialist] Inspect whether a scene node is linked to a prefab instance and return prefab metadata when available.',
inputSchema: createSchema(
{
path: { type: 'string', description: 'Node hierarchy path.' },
uuid: { type: 'string', description: 'Node uuid.' },
name: { type: 'string', description: 'Fallback exact node name.' },
},
[]
),
handler: async (args) => sceneBridge.call('getPrefabInstanceInfo', args),
},
{
name: 'apply_prefab_instance',
profile: 'full',
description: '[core] Apply a scene prefab instance back to its associated prefab asset using the Cocos editor scene apply-prefab message.',
inputSchema: createSchema(
{
path: { type: 'string', description: 'Node hierarchy path.' },
uuid: { type: 'string', description: 'Node uuid.' },
name: { type: 'string', description: 'Fallback exact node name.' },
},
[]
),
handler: async (args) => await applyPrefabInstance(await resolveNodeUuid(sceneBridge, args)),
},
{
name: 'revert_prefab_instance',
profile: 'full',
description: '[core] Revert a scene prefab instance from its associated prefab asset using available Cocos editor prefab revert messages.',
inputSchema: createSchema(
{
path: { type: 'string', description: 'Node hierarchy path.' },
uuid: { type: 'string', description: 'Node uuid.' },
name: { type: 'string', description: 'Fallback exact node name.' },
},
[]
),
handler: async (args) => await revertPrefabInstance(await resolveNodeUuid(sceneBridge, args)),
},
{ {
name: 'instantiate_prefab', name: 'instantiate_prefab',
profile: 'full', profile: 'full',
@@ -640,6 +1020,7 @@ function createToolRegistry({ getRuntimeContext, getStatus, interactionLog, runt
return selectAsset(info.uuid || args.target); return selectAsset(info.uuid || args.target);
}, },
}, },
...createAssetsAdvancedTools({ createSchema, getRuntimeContext }),
{ {
name: 'get_editor_selection', name: 'get_editor_selection',
profile: 'full', profile: 'full',
@@ -931,195 +1312,7 @@ function createToolRegistry({ getRuntimeContext, getStatus, interactionLog, runt
), ),
handler: async (args) => sceneBridge.call('stopAnimation', args), handler: async (args) => sceneBridge.call('stopAnimation', args),
}, },
{ ...createFileTools({ createSchema, getRuntimeContext }),
name: 'read_file',
profile: 'full',
description: '[core] Read a file from the Cocos project.',
inputSchema: createSchema(
{
path: { type: 'string', description: 'Project-relative or absolute file path.' },
},
['path']
),
handler: async (args) => {
const { projectPath } = getRuntimeContext();
const fullPath = resolveProjectPath(projectPath, args.path);
if (!fs.existsSync(fullPath)) {
throw new Error(`File not found: ${args.path}`);
}
const content = fs.readFileSync(fullPath, 'utf8');
return content.length > 12000 ? `${content.slice(0, 12000)}\n... (truncated)` : content;
},
},
{
name: 'get_file_snippet',
profile: 'full',
description: '[core] Read a focused snippet around a file line number.',
inputSchema: createSchema(
{
path: { type: 'string', description: 'Project-relative or absolute file path.' },
line: { type: 'number', description: 'Target line number, starting at 1.' },
contextLines: { type: 'number', description: 'Number of surrounding context lines.' },
},
['path', 'line']
),
handler: async (args) => {
const { projectPath } = getRuntimeContext();
const fullPath = resolveProjectPath(projectPath, args.path);
if (!fs.existsSync(fullPath)) {
throw new Error(`File not found: ${args.path}`);
}
return buildSnippet(fullPath, args.line, Number.isFinite(args.contextLines) ? args.contextLines : 3);
},
},
{
name: 'write_file',
profile: 'full',
description: '[core] Write or overwrite a file in the Cocos project.',
inputSchema: createSchema(
{
path: { type: 'string', description: 'Project-relative or absolute file path.' },
content: { type: 'string', description: 'File content to write.' },
},
['path', 'content']
),
handler: async (args) => {
const { projectPath } = getRuntimeContext();
const fullPath = resolveProjectPath(projectPath, args.path);
fs.mkdirSync(path.dirname(fullPath), { recursive: true });
fs.writeFileSync(fullPath, args.content, 'utf8');
return `Wrote ${args.content.length} chars to ${args.path}\n${await refreshAssets(projectPath, fullPath)}`;
},
},
{
name: 'replace_in_file',
profile: 'full',
description: '[core] Replace text in a file, useful for script auto-fix loops.',
inputSchema: createSchema(
{
path: { type: 'string', description: 'Project-relative or absolute file path.' },
search: { type: 'string', description: 'Literal text to search for.' },
replace: { type: 'string', description: 'Replacement text.' },
replaceAll: { type: 'boolean', description: 'Replace every occurrence instead of only the first.' },
},
['path', 'search', 'replace']
),
handler: async (args) => {
const { projectPath } = getRuntimeContext();
const fullPath = resolveProjectPath(projectPath, args.path);
if (!fs.existsSync(fullPath)) {
throw new Error(`File not found: ${args.path}`);
}
const original = fs.readFileSync(fullPath, 'utf8');
if (!original.includes(args.search)) {
throw new Error(`Search text was not found in ${args.path}`);
}
const updated = args.replaceAll
? replaceAllLiteral(original, args.search, args.replace)
: original.replace(args.search, args.replace);
fs.writeFileSync(fullPath, updated, 'utf8');
return `Updated ${args.path} (${args.replaceAll ? 'all matches' : 'first match'})\n${await refreshAssets(projectPath, fullPath)}`;
},
},
{
name: 'search_files',
profile: 'full',
description: '[core] Search project files by simple wildcard pattern.',
inputSchema: createSchema(
{
pattern: { type: 'string', description: "Wildcard file pattern such as '*.ts' or 'Player*'." },
directory: { type: 'string', description: 'Project-relative search root. Defaults to assets.' },
limit: { type: 'number', description: 'Maximum number of results to return.' },
},
['pattern']
),
handler: async (args) => {
const { projectPath } = getRuntimeContext();
const searchRoot = resolveProjectPath(projectPath, args.directory || 'assets');
if (!fs.existsSync(searchRoot)) {
throw new Error(`Directory not found: ${args.directory || 'assets'}`);
}
const limit = Number.isFinite(args.limit) ? Math.max(1, Math.min(500, args.limit)) : 100;
const results = searchFiles(searchRoot, args.pattern, limit).map((fullPath) =>
path.relative(projectPath, fullPath).replace(/\\/g, '/')
);
return {
count: results.length,
files: results,
};
},
},
{
name: 'list_directory',
profile: 'full',
description: '[core] List files and directories inside a project directory.',
inputSchema: createSchema(
{
path: { type: 'string', description: 'Project-relative or absolute directory path.' },
},
['path']
),
handler: async (args) => {
const { projectPath } = getRuntimeContext();
const targetPath = resolveProjectPath(projectPath, args.path);
if (!fs.existsSync(targetPath) || !fs.statSync(targetPath).isDirectory()) {
throw new Error(`Directory not found: ${args.path}`);
}
const entries = fs
.readdirSync(targetPath, { withFileTypes: true })
.filter((entry) => !entry.name.startsWith('.'))
.map((entry) => ({
name: entry.name,
type: entry.isDirectory() ? 'directory' : 'file',
}));
return {
path: args.path,
entries,
};
},
},
{
name: 'exists',
profile: 'full',
description: '[core] Check whether a project file or directory exists.',
inputSchema: createSchema(
{
path: { type: 'string', description: 'Project-relative or absolute path.' },
},
['path']
),
handler: async (args) => {
const { projectPath } = getRuntimeContext();
const targetPath = resolveProjectPath(projectPath, args.path);
return {
path: args.path,
exists: fs.existsSync(targetPath),
isFile: fs.existsSync(targetPath) ? fs.statSync(targetPath).isFile() : false,
isDirectory: fs.existsSync(targetPath) ? fs.statSync(targetPath).isDirectory() : false,
};
},
},
{
name: 'refresh_assets',
profile: 'full',
description: '[core] Best-effort asset database refresh for a file or the assets root.',
inputSchema: createSchema(
{
path: { type: 'string', description: 'Optional project-relative file path to refresh.' },
},
[]
),
handler: async (args) => {
const { projectPath } = getRuntimeContext();
const targetPath = resolveProjectPath(projectPath, args.path || 'assets');
return await refreshAssets(projectPath, targetPath);
},
},
{ {
name: 'run_script_diagnostics', name: 'run_script_diagnostics',
profile: 'core', profile: 'core',
@@ -1242,6 +1435,7 @@ function createToolRegistry({ getRuntimeContext, getStatus, interactionLog, runt
includeComponents: true, includeComponents: true,
}).catch((error) => ({ ok: false, error: error.message })); }).catch((error) => ({ ok: false, error: error.message }));
const runtime = await sceneBridge.call('getRuntimeState', {}).catch((error) => ({ ok: false, error: error.message })); const runtime = await sceneBridge.call('getRuntimeState', {}).catch((error) => ({ ok: false, error: error.message }));
const performance = await sceneBridge.call('getPerformanceSnapshot', {}).catch((error) => ({ ok: false, error: error.message }));
const diagnostics = args.includeScriptDiagnostics === false const diagnostics = args.includeScriptDiagnostics === false
? null ? null
: summarizeDiagnostics(await runScriptDiagnostics(projectPath, args).catch((error) => ({ ok: false, summary: error.message, diagnostics: [] }))); : summarizeDiagnostics(await runScriptDiagnostics(projectPath, args).catch((error) => ({ ok: false, summary: error.message, diagnostics: [] })));
@@ -1250,14 +1444,22 @@ function createToolRegistry({ getRuntimeContext, getStatus, interactionLog, runt
: searchProjectLogs(projectPath, { query: 'error', limit: 20 }).matches; : searchProjectLogs(projectPath, { query: 'error', limit: 20 }).matches;
return { return {
ok: !scene.error && !runtime.error && (!diagnostics || diagnostics.ok) && (!logErrors || logErrors.length === 0), ok: !scene.error && !runtime.error && !performance.error && (!diagnostics || diagnostics.ok) && (!logErrors || logErrors.length === 0),
scene, scene,
runtime, runtime,
performance,
diagnostics, diagnostics,
logErrors, logErrors,
}; };
}, },
}, },
{
name: 'get_performance_snapshot',
profile: 'core',
description: '[specialist] Return scene scale and runtime performance-oriented counters such as node/component counts, UI counts, depth, memory, and warnings.',
inputSchema: createSchema({}, []),
handler: async (args) => sceneBridge.call('getPerformanceSnapshot', args),
},
{ {
name: 'get_runtime_state', name: 'get_runtime_state',
profile: 'core', profile: 'core',
@@ -1321,6 +1523,7 @@ function createToolRegistry({ getRuntimeContext, getStatus, interactionLog, runt
), ),
handler: async (args) => sceneBridge.call('simulateButtonClick', args), handler: async (args) => sceneBridge.call('simulateButtonClick', args),
}, },
...createSceneEventTools({ createSchema, sceneBridge }),
{ {
name: 'invoke_component_method', name: 'invoke_component_method',
profile: 'full', profile: 'full',
@@ -1580,6 +1783,8 @@ function createToolRegistry({ getRuntimeContext, getStatus, interactionLog, runt
name: tool.name, name: tool.name,
description: tool.description, description: tool.description,
inputSchema: tool.inputSchema, inputSchema: tool.inputSchema,
outputSchema: tool.outputSchema || createOutputSchema(tool.dataSchema),
annotations: inferToolAnnotations(tool),
})); }));
}, },
listToolCatalog() { listToolCatalog() {
@@ -1589,6 +1794,8 @@ function createToolRegistry({ getRuntimeContext, getStatus, interactionLog, runt
description: tool.description, description: tool.description,
profile: tool.profile, profile: tool.profile,
category: toolCategory(tool), category: toolCategory(tool),
annotations: inferToolAnnotations(tool),
outputSchema: tool.outputSchema || createOutputSchema(tool.dataSchema),
enabled: isToolExposed(config || {}, tool), enabled: isToolExposed(config || {}, tool),
})); }));
}, },
@@ -1604,14 +1811,21 @@ function createToolRegistry({ getRuntimeContext, getStatus, interactionLog, runt
try { try {
const result = await tool.handler(args || {}); const result = await tool.handler(args || {});
const output = toOutput(result); const envelope = createResultEnvelope(tool, args || {}, result);
interactionLog.add(name, 'success', output.slice(0, 500)); const output = typeof result === 'string' && result.startsWith(IMAGE_DATA_URI_PREFIX)
? result
: toOutput(envelope);
interactionLog.add(name, 'success', envelope.summary.slice(0, 500));
return { return {
value: result, value: envelope,
text: output, text: output,
}; };
} catch (error) { } catch (error) {
interactionLog.add(name, 'error', error.message); interactionLog.add(name, 'error', error.message);
error.toolEnvelope = createResultEnvelope(tool, args || {}, { message: error.message }, {
ok: false,
summary: error.message,
});
throw error; throw error;
} }
}, },
+221
View File
@@ -0,0 +1,221 @@
'use strict';
const fs = require('fs');
const path = require('path');
const { listAssets, queryAssetInfo } = require('../assets');
const { resolveProjectPath } = require('../path-safety');
const UUID_KEY_PATTERN = /uuid|assetUuid|prefabUuid|sceneUuid|__uuid__/i;
const UUID_LITERAL_PATTERN = /[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}|[A-Za-z0-9+/=-]{20,32}/g;
function assetUrlToPath(projectPath, url) {
if (!url || !String(url).startsWith('db://assets/')) {
return '';
}
return path.join(projectPath, String(url).slice('db://'.length));
}
function assetFilePath(projectPath, info) {
const candidates = [
info && info.file,
info && info.path,
info && info.source,
info && info.url ? assetUrlToPath(projectPath, info.url) : '',
].filter(Boolean);
for (const candidate of candidates) {
const fullPath = resolveProjectPath(projectPath, candidate);
if (fs.existsSync(fullPath) && fs.statSync(fullPath).isFile()) {
return fullPath;
}
}
return '';
}
function collectStructuredUuidReferences(value, refs = [], pointer = '') {
if (value == null) {
return refs;
}
if (Array.isArray(value)) {
value.forEach((item, index) => collectStructuredUuidReferences(item, refs, `${pointer}/${index}`));
return refs;
}
if (typeof value !== 'object') {
return refs;
}
for (const [key, child] of Object.entries(value)) {
const childPointer = `${pointer}/${key}`;
if (typeof child === 'string' && UUID_KEY_PATTERN.test(key)) {
refs.push({ uuid: child, path: childPointer, key, source: 'structured' });
continue;
}
collectStructuredUuidReferences(child, refs, childPointer);
}
return refs;
}
function collectTextUuidReferences(text) {
const refs = [];
const seen = new Set();
let match;
while ((match = UUID_LITERAL_PATTERN.exec(String(text || '')))) {
const uuid = match[0];
if (seen.has(uuid)) {
continue;
}
seen.add(uuid);
refs.push({ uuid, path: `@${match.index}`, key: '', source: 'text' });
}
return refs;
}
function dedupeReferences(refs) {
const seen = new Set();
const result = [];
for (const ref of refs) {
const key = `${ref.uuid}:${ref.path}`;
if (seen.has(key)) {
continue;
}
seen.add(key);
result.push(ref);
}
return result;
}
function collectUuidReferences(content) {
const refs = [];
try {
refs.push(...collectStructuredUuidReferences(JSON.parse(content)));
} catch (error) {
// Non-JSON assets still get a literal reference scan below.
}
refs.push(...collectTextUuidReferences(content));
return dedupeReferences(refs);
}
async function inspectAssetDependencies(projectPath, target, options = {}) {
const info = await queryAssetInfo(target);
const filePath = assetFilePath(projectPath, info);
if (!filePath) {
throw new Error(`Asset file was not found: ${target}`);
}
const content = fs.readFileSync(filePath, 'utf8');
const limit = Number.isFinite(options.limit) ? Math.max(1, Math.min(500, options.limit)) : 200;
const references = collectUuidReferences(content).slice(0, limit);
const dependencies = [];
const missing = [];
for (const ref of references) {
try {
const asset = await queryAssetInfo(ref.uuid);
dependencies.push({
...ref,
exists: true,
asset: {
uuid: asset.uuid,
url: asset.url,
type: asset.type,
importer: asset.importer,
},
});
} catch (error) {
missing.push({ ...ref, exists: false, error: error.message });
}
}
return {
ok: missing.length === 0,
target,
asset: {
uuid: info.uuid,
url: info.url,
type: info.type,
},
filePath: path.relative(projectPath, filePath).replace(/\\/g, '/'),
referenceCount: references.length,
dependencyCount: dependencies.length,
missingCount: missing.length,
dependencies,
missing,
};
}
async function validateAssetDependencies(projectPath, options = {}) {
const targets = options.target
? [options.target]
: (await listAssets({ pattern: options.pattern || 'db://assets/**', ccType: options.ccType }))
.slice(0, Number.isFinite(options.limit) ? Math.max(1, Math.min(200, options.limit)) : 50)
.map((asset) => asset.uuid || asset.url)
.filter(Boolean);
const assets = [];
for (const target of targets) {
try {
assets.push(await inspectAssetDependencies(projectPath, target, options));
} catch (error) {
assets.push({
ok: false,
target,
error: error.message,
missingCount: 1,
});
}
}
const missingCount = assets.reduce((sum, asset) => sum + (Number(asset.missingCount) || 0), 0);
return {
ok: missingCount === 0,
assetCount: assets.length,
missingCount,
assets,
};
}
function createAssetsAdvancedTools({ createSchema, getRuntimeContext }) {
return [
{
name: 'inspect_asset_dependencies',
profile: 'core',
description: '[specialist] Inspect UUID-style dependencies referenced by a serialized Cocos asset.',
inputSchema: createSchema(
{
target: { type: 'string', description: 'Asset uuid, db url, or project path.' },
limit: { type: 'number', description: 'Maximum dependency references to inspect.' },
},
['target']
),
handler: async (args) => {
const { projectPath } = getRuntimeContext();
return await inspectAssetDependencies(projectPath, args.target, args);
},
},
{
name: 'validate_asset_dependencies',
profile: 'core',
description: '[specialist] Validate UUID-style dependencies for one asset or a project asset query.',
inputSchema: createSchema(
{
target: { type: 'string', description: 'Optional asset uuid, db url, or project path.' },
pattern: { type: 'string', description: 'Asset-db pattern used when target is omitted.' },
ccType: { type: 'string', description: 'Optional Cocos asset type filter.' },
limit: { type: 'number', description: 'Maximum assets to scan when target is omitted.' },
},
[]
),
handler: async (args) => {
const { projectPath } = getRuntimeContext();
return await validateAssetDependencies(projectPath, args);
},
},
];
}
module.exports = {
collectUuidReferences,
createAssetsAdvancedTools,
inspectAssetDependencies,
validateAssetDependencies,
};
+245
View File
@@ -0,0 +1,245 @@
'use strict';
function hasEditorMessage() {
return Boolean(global.Editor && Editor.Message);
}
function ensureEditorMessage() {
if (!hasEditorMessage()) {
throw new Error('Editor.Message is unavailable in this Cocos extension host.');
}
}
async function requestEditorMessage(channel, method, ...args) {
ensureEditorMessage();
if (typeof Editor.Message.request !== 'function') {
throw new Error('Editor.Message.request is unavailable in this Cocos extension host.');
}
return await Editor.Message.request(channel, method, ...args);
}
async function tryEditorRequests(candidates) {
const attempts = [];
for (const candidate of candidates) {
const channel = candidate.channel;
const method = candidate.method;
const args = Array.isArray(candidate.args) ? candidate.args : [];
try {
const result = await requestEditorMessage(channel, method, ...args);
return {
ok: true,
channel,
method,
result,
attempts,
};
} catch (error) {
attempts.push({ channel, method, error: error.message });
}
}
const message = attempts.length
? attempts.map((attempt) => `${attempt.channel}.${attempt.method}: ${attempt.error}`).join('; ')
: 'no editor message candidates were provided';
const error = new Error(`No compatible Cocos editor message succeeded: ${message}`);
error.attempts = attempts;
throw error;
}
async function tryEditorRequestsStatus(candidates) {
try {
return await tryEditorRequests(candidates);
} catch (error) {
return {
ok: false,
available: false,
attempts: error.attempts || [],
error: error.message,
};
}
}
async function openPanel(panelName) {
const id = String(panelName || 'builder').trim();
if (!id) {
throw new Error('panelName is required.');
}
if (!global.Editor || !Editor.Panel || typeof Editor.Panel.open !== 'function') {
throw new Error('Editor.Panel.open is unavailable in this Cocos extension host.');
}
const result = await Editor.Panel.open(id);
return { opened: true, panelName: id, result };
}
function getEditorPreference(scope, key) {
if (!global.Editor || !Editor.Profile) {
throw new Error('Editor.Profile is unavailable in this Cocos extension host.');
}
const normalizedScope = String(scope || 'project').toLowerCase();
const target = normalizedScope === 'global' ? Editor.Profile : Editor.Profile;
const getters = normalizedScope === 'global'
? ['getConfig', 'getGlobal']
: ['getProject', 'getConfig'];
for (const getter of getters) {
if (typeof target[getter] === 'function') {
return target[getter](key);
}
}
throw new Error('No compatible Editor.Profile getter is available.');
}
function setEditorPreference(scope, key, value) {
if (!global.Editor || !Editor.Profile) {
throw new Error('Editor.Profile is unavailable in this Cocos extension host.');
}
const normalizedScope = String(scope || 'project').toLowerCase();
const target = Editor.Profile;
const setters = normalizedScope === 'global'
? ['setConfig', 'setGlobal']
: ['setProject', 'setConfig'];
for (const setter of setters) {
if (typeof target[setter] === 'function') {
const result = target[setter](key, value);
return { set: true, scope: normalizedScope, key, value, method: setter, result };
}
}
throw new Error('No compatible Editor.Profile setter is available.');
}
function broadcastEditorMessage(options = {}) {
ensureEditorMessage();
const channel = String(options.channel || '').trim();
const message = String(options.message || '').trim();
if (!message) {
throw new Error('message is required.');
}
const payload = options.payload === undefined ? {} : options.payload;
if (channel && typeof Editor.Message.send === 'function') {
const result = Editor.Message.send(channel, message, payload);
return { sent: true, mode: 'send', channel, message, payload, result };
}
if (typeof Editor.Message.broadcast === 'function') {
const result = Editor.Message.broadcast(message, payload);
return { sent: true, mode: 'broadcast', message, payload, result };
}
throw new Error('Neither Editor.Message.send nor Editor.Message.broadcast is available.');
}
function createCocosProjectTools({ createSchema }) {
return [
{
name: 'save_current_scene',
profile: 'full',
description: '[core] Save the currently open Cocos scene using available editor scene messages.',
inputSchema: createSchema({}, []),
handler: async () => {
const result = await tryEditorRequests([
{ channel: 'scene', method: 'save-scene' },
{ channel: 'scene', method: 'save' },
]);
return { saved: true, ...result };
},
},
{
name: 'open_build_panel',
profile: 'full',
description: '[core] Open the Cocos build panel, defaulting to the builder panel id.',
inputSchema: createSchema(
{
panelName: { type: 'string', description: 'Panel id to open. Defaults to builder.' },
},
[]
),
handler: async (args) => openPanel(args.panelName || 'builder'),
},
{
name: 'get_build_status',
profile: 'core',
description: '[specialist] Query Cocos build/preview status using known builder message variants.',
inputSchema: createSchema({}, []),
handler: async () => await tryEditorRequestsStatus([
{ channel: 'builder', method: 'query-build-status' },
{ channel: 'builder', method: 'get-build-status' },
{ channel: 'builder', method: 'query-build-tasks' },
]),
},
{
name: 'run_project_preview',
profile: 'full',
description: '[core] Start Cocos preview/run using known preview and builder message variants.',
inputSchema: createSchema(
{
platform: { type: 'string', description: 'Optional preview platform or build target.' },
},
[]
),
handler: async (args) => await tryEditorRequests([
{ channel: 'preview', method: 'start', args: [args || {}] },
{ channel: 'preview', method: 'open-preview', args: [args || {}] },
{ channel: 'builder', method: 'preview', args: [args || {}] },
]),
},
{
name: 'get_editor_preference',
profile: 'full',
description: '[core] Read a Cocos editor preference through Editor.Profile when available.',
inputSchema: createSchema(
{
scope: { type: 'string', description: 'Preference scope: project or global. Defaults to project.' },
key: { type: 'string', description: 'Preference key.' },
},
['key']
),
handler: async (args) => ({
scope: args.scope || 'project',
key: args.key,
value: getEditorPreference(args.scope, args.key),
}),
},
{
name: 'set_editor_preference',
profile: 'full',
description: '[core] Write a Cocos editor preference through Editor.Profile when available.',
inputSchema: createSchema(
{
scope: { type: 'string', description: 'Preference scope: project or global. Defaults to project.' },
key: { type: 'string', description: 'Preference key.' },
valueJson: { type: 'string', description: 'JSON encoded preference value.' },
},
['key', 'valueJson']
),
handler: async (args) => {
let value;
try {
value = JSON.parse(args.valueJson);
} catch (error) {
throw new Error(`valueJson must be valid JSON: ${error.message}`);
}
return setEditorPreference(args.scope, args.key, value);
},
},
{
name: 'broadcast_editor_message',
profile: 'full',
description: '[core] Send or broadcast a Cocos editor message for advanced editor automation.',
inputSchema: createSchema(
{
channel: { type: 'string', description: 'Optional Editor.Message channel for send().' },
message: { type: 'string', description: 'Message name to send or broadcast.' },
payload: { type: 'object', description: 'Optional JSON payload.' },
},
['message']
),
handler: async (args) => broadcastEditorMessage(args),
},
];
}
module.exports = {
broadcastEditorMessage,
createCocosProjectTools,
getEditorPreference,
setEditorPreference,
tryEditorRequests,
tryEditorRequestsStatus,
};
+303
View File
@@ -0,0 +1,303 @@
'use strict';
const fs = require('fs');
const path = require('path');
const { resolveProjectPath } = require('../path-safety');
/**
* Build a focused, line-numbered snippet around one file line.
* @param {string} filePath Absolute file path.
* @param {number} lineNumber One-based target line number.
* @param {number} contextLines Surrounding lines to include.
* @returns {string}
*/
function buildSnippet(filePath, lineNumber, contextLines = 3) {
const lines = fs.readFileSync(filePath, 'utf8').split(/\r?\n/);
const start = Math.max(1, Number(lineNumber || 1) - Math.max(0, contextLines));
const end = Math.min(lines.length, Number(lineNumber || 1) + Math.max(0, contextLines));
const snippet = [];
for (let line = start; line <= end; line += 1) {
const marker = line === Number(lineNumber || 1) ? '>' : ' ';
snippet.push(`${marker} ${String(line).padStart(4, ' ')} | ${lines[line - 1]}`);
}
return snippet.join('\n');
}
function matchesPattern(fileName, pattern) {
const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*');
return new RegExp(`^${escaped}$`, 'i').test(fileName);
}
function searchFiles(rootDir, pattern, limit) {
const results = [];
if (!fs.existsSync(rootDir)) {
return results;
}
const stack = [rootDir];
while (stack.length && results.length < limit) {
const current = stack.pop();
const entries = fs.readdirSync(current, { withFileTypes: true });
for (const entry of entries) {
if (entry.name === '.git' || entry.name === 'node_modules' || entry.name === 'temp' || entry.name === 'library') {
continue;
}
const fullPath = path.join(current, entry.name);
if (entry.isDirectory()) {
stack.push(fullPath);
continue;
}
if (matchesPattern(entry.name, pattern)) {
results.push(fullPath);
if (results.length >= limit) {
break;
}
}
}
}
return results;
}
function replaceAllLiteral(content, search, replacement) {
if (!search) {
throw new Error('search text is required.');
}
return content.split(search).join(replacement);
}
/**
* Best-effort refresh for Cocos asset database after external file edits.
* @param {string} projectPath Active Cocos project root.
* @param {string} targetPath Absolute file or directory path.
* @returns {Promise<string>}
*/
async function refreshAssets(projectPath, targetPath) {
if (!global.Editor || !Editor.Message || typeof Editor.Message.request !== 'function') {
return 'Asset refresh API is unavailable; Cocos Creator should pick up file changes automatically.';
}
const relative = path.relative(path.join(projectPath, 'assets'), targetPath).replace(/\\/g, '/');
if (!relative.startsWith('..')) {
const dbUrl = `db://assets/${relative}`;
try {
await Editor.Message.request('asset-db', 'refresh-asset', dbUrl);
return `Refreshed asset database for ${dbUrl}`;
} catch (error) {
try {
await Editor.Message.request('asset-db', 'refresh-asset', 'db://assets');
return `Refreshed asset database after writing ${dbUrl}`;
} catch (innerError) {
return `File written, but asset refresh failed: ${innerError.message}`;
}
}
}
return 'File written outside assets directory; no asset-db refresh was needed.';
}
/**
* File-system tools. Kept separate from the registry so path safety and asset
* refresh behavior can be tested and evolved independently.
*/
function createFileTools({ createSchema, getRuntimeContext }) {
return [
{
name: 'read_file',
profile: 'full',
description: '[core] Read a file from the Cocos project.',
inputSchema: createSchema(
{
path: { type: 'string', description: 'Project-relative or absolute file path.' },
},
['path']
),
handler: async (args) => {
const { projectPath } = getRuntimeContext();
const fullPath = resolveProjectPath(projectPath, args.path);
if (!fs.existsSync(fullPath)) {
throw new Error(`File not found: ${args.path}`);
}
const content = fs.readFileSync(fullPath, 'utf8');
return content.length > 12000 ? `${content.slice(0, 12000)}\n... (truncated)` : content;
},
},
{
name: 'get_file_snippet',
profile: 'full',
description: '[core] Read a focused snippet around a file line number.',
inputSchema: createSchema(
{
path: { type: 'string', description: 'Project-relative or absolute file path.' },
line: { type: 'number', description: 'Target line number, starting at 1.' },
contextLines: { type: 'number', description: 'Number of surrounding context lines.' },
},
['path', 'line']
),
handler: async (args) => {
const { projectPath } = getRuntimeContext();
const fullPath = resolveProjectPath(projectPath, args.path);
if (!fs.existsSync(fullPath)) {
throw new Error(`File not found: ${args.path}`);
}
return buildSnippet(fullPath, args.line, Number.isFinite(args.contextLines) ? args.contextLines : 3);
},
},
{
name: 'write_file',
profile: 'full',
description: '[core] Write or overwrite a file in the Cocos project.',
inputSchema: createSchema(
{
path: { type: 'string', description: 'Project-relative or absolute file path.' },
content: { type: 'string', description: 'File content to write.' },
},
['path', 'content']
),
handler: async (args) => {
const { projectPath } = getRuntimeContext();
const fullPath = resolveProjectPath(projectPath, args.path);
fs.mkdirSync(path.dirname(fullPath), { recursive: true });
fs.writeFileSync(fullPath, args.content, 'utf8');
return `Wrote ${args.content.length} chars to ${args.path}\n${await refreshAssets(projectPath, fullPath)}`;
},
},
{
name: 'replace_in_file',
profile: 'full',
description: '[core] Replace text in a file, useful for script auto-fix loops.',
inputSchema: createSchema(
{
path: { type: 'string', description: 'Project-relative or absolute file path.' },
search: { type: 'string', description: 'Literal text to search for.' },
replace: { type: 'string', description: 'Replacement text.' },
replaceAll: { type: 'boolean', description: 'Replace every occurrence instead of only the first.' },
},
['path', 'search', 'replace']
),
handler: async (args) => {
const { projectPath } = getRuntimeContext();
const fullPath = resolveProjectPath(projectPath, args.path);
if (!fs.existsSync(fullPath)) {
throw new Error(`File not found: ${args.path}`);
}
const original = fs.readFileSync(fullPath, 'utf8');
if (!original.includes(args.search)) {
throw new Error(`Search text was not found in ${args.path}`);
}
const updated = args.replaceAll
? replaceAllLiteral(original, args.search, args.replace)
: original.replace(args.search, args.replace);
fs.writeFileSync(fullPath, updated, 'utf8');
return `Updated ${args.path} (${args.replaceAll ? 'all matches' : 'first match'})\n${await refreshAssets(projectPath, fullPath)}`;
},
},
{
name: 'search_files',
profile: 'full',
description: '[core] Search project files by simple wildcard pattern.',
inputSchema: createSchema(
{
pattern: { type: 'string', description: "Wildcard file pattern such as '*.ts' or 'Player*'." },
directory: { type: 'string', description: 'Project-relative search root. Defaults to assets.' },
limit: { type: 'number', description: 'Maximum number of results to return.' },
},
['pattern']
),
handler: async (args) => {
const { projectPath } = getRuntimeContext();
const searchRoot = resolveProjectPath(projectPath, args.directory || 'assets');
if (!fs.existsSync(searchRoot)) {
throw new Error(`Directory not found: ${args.directory || 'assets'}`);
}
const limit = Number.isFinite(args.limit) ? Math.max(1, Math.min(500, args.limit)) : 100;
const results = searchFiles(searchRoot, args.pattern, limit).map((fullPath) =>
path.relative(projectPath, fullPath).replace(/\\/g, '/')
);
return {
count: results.length,
files: results,
};
},
},
{
name: 'list_directory',
profile: 'full',
description: '[core] List files and directories inside a project directory.',
inputSchema: createSchema(
{
path: { type: 'string', description: 'Project-relative or absolute directory path.' },
},
['path']
),
handler: async (args) => {
const { projectPath } = getRuntimeContext();
const targetPath = resolveProjectPath(projectPath, args.path);
if (!fs.existsSync(targetPath) || !fs.statSync(targetPath).isDirectory()) {
throw new Error(`Directory not found: ${args.path}`);
}
const entries = fs
.readdirSync(targetPath, { withFileTypes: true })
.filter((entry) => !entry.name.startsWith('.'))
.map((entry) => ({
name: entry.name,
type: entry.isDirectory() ? 'directory' : 'file',
}));
return {
path: args.path,
entries,
};
},
},
{
name: 'exists',
profile: 'full',
description: '[core] Check whether a project file or directory exists.',
inputSchema: createSchema(
{
path: { type: 'string', description: 'Project-relative or absolute path.' },
},
['path']
),
handler: async (args) => {
const { projectPath } = getRuntimeContext();
const targetPath = resolveProjectPath(projectPath, args.path);
return {
path: args.path,
exists: fs.existsSync(targetPath),
isFile: fs.existsSync(targetPath) ? fs.statSync(targetPath).isFile() : false,
isDirectory: fs.existsSync(targetPath) ? fs.statSync(targetPath).isDirectory() : false,
};
},
},
{
name: 'refresh_assets',
profile: 'full',
description: '[core] Best-effort asset database refresh for a file or the assets root.',
inputSchema: createSchema(
{
path: { type: 'string', description: 'Optional project-relative file path to refresh.' },
},
[]
),
handler: async (args) => {
const { projectPath } = getRuntimeContext();
const targetPath = resolveProjectPath(projectPath, args.path || 'assets');
return await refreshAssets(projectPath, targetPath);
},
},
];
}
module.exports = {
buildSnippet,
createFileTools,
refreshAssets,
};
+45
View File
@@ -0,0 +1,45 @@
'use strict';
function createSceneEventTools({ createSchema, sceneBridge }) {
return [
{
name: 'list_button_click_events',
profile: 'full',
description: '[core] List click event bindings on a Cocos Button component.',
inputSchema: createSchema(
{
path: { type: 'string', description: 'Button node hierarchy path.' },
uuid: { type: 'string', description: 'Button node uuid.' },
name: { type: 'string', description: 'Fallback exact button node name.' },
},
[]
),
handler: async (args) => sceneBridge.call('listButtonClickEvents', args),
},
{
name: 'bind_button_click_event',
profile: 'full',
description: '[core] Bind a Cocos Button click event to a target node component method.',
inputSchema: createSchema(
{
path: { type: 'string', description: 'Button node hierarchy path.' },
uuid: { type: 'string', description: 'Button node uuid.' },
name: { type: 'string', description: 'Fallback exact button node name.' },
targetPath: { type: 'string', description: 'Target node path containing the handler component.' },
targetUuid: { type: 'string', description: 'Target node uuid containing the handler component.' },
targetName: { type: 'string', description: 'Fallback exact target node name.' },
componentName: { type: 'string', description: 'Target component class name.' },
handler: { type: 'string', description: 'Method name to invoke on the target component.' },
customEventData: { type: 'string', description: 'Optional custom event data string.' },
replace: { type: 'boolean', description: 'Replace an identical existing binding.' },
},
['componentName', 'handler']
),
handler: async (args) => sceneBridge.call('bindButtonClickEvent', args),
},
];
}
module.exports = {
createSceneEventTools,
};
+52 -3
View File
@@ -1,14 +1,63 @@
{ {
"name": "funplay-cocos-mcp", "name": "funplay-cocos-mcp",
"package_version": 2, "package_version": 2,
"version": "0.2.0", "version": "0.4.0",
"description": "Embedded MCP server for Cocos Creator with scene script execution, project resources, prompts, and file/scene tools.", "description": "Embedded MCP server for Cocos Creator with scene script execution, project resources, prompts, and file/scene tools.",
"author": "Funplay", "author": "Funplay",
"license": "MIT", "license": "MIT",
"main": "browser.js", "main": "browser.js",
"bin": {
"funplay-cocos-mcp": "bin/funplay-cocos-mcp.js"
},
"mcpName": "io.github.FunplayAI/funplay-cocos-mcp",
"repository": {
"type": "git",
"url": "git+https://github.com/FunplayAI/funplay-cocos-mcp.git"
},
"homepage": "https://github.com/FunplayAI/funplay-cocos-mcp#readme",
"bugs": {
"url": "https://github.com/FunplayAI/funplay-cocos-mcp/issues"
},
"keywords": [
"mcp",
"cocos",
"cocos-creator",
"funplay",
"model-context-protocol",
"ai"
],
"engines": {
"node": ">=18"
},
"files": [
"bin/",
"docs/",
"lib/",
"panel/",
"browser.js",
"scene.js",
"server.json",
"README.md",
"README_CN.md",
"CHANGELOG.md",
"CONTRIBUTING.md",
"LICENSE",
"RELEASE_WORKFLOW.md",
"RELEASE_CHECKLIST.md"
],
"publishConfig": {
"access": "public"
},
"scripts": { "scripts": {
"check": "node --check browser.js && node --check scene.js && node --check panel/index.js && node --check lib/assets.js && node --check lib/client-config.js && node --check lib/config.js && node --check lib/diagnostics.js && node --check lib/electron-tools.js && node --check lib/input.js && node --check lib/interaction-log.js && node --check lib/logs.js && node --check lib/path-safety.js && node --check lib/prompts.js && node --check lib/resources.js && node --check lib/runtime-log.js && node --check lib/screenshots.js && node --check lib/server.js && node --check lib/tool-registry.js && node --check lib/update-checker.js && node --check lib/utils.js", "check": "node --check browser.js && node --check scene.js && node --check panel/index.js && node --check bin/funplay-cocos-mcp.js && node --check lib/assets.js && node --check lib/client-config.js && node --check lib/config.js && node --check lib/diagnostics.js && node --check lib/electron-tools.js && node --check lib/input.js && node --check lib/interaction-log.js && node --check lib/javascript-safety.js && node --check lib/logs.js && node --check lib/path-safety.js && node --check lib/prefabs.js && node --check lib/project-instructions.js && node --check lib/prompts.js && node --check lib/resources.js && node --check lib/runtime-log.js && node --check lib/screenshots.js && node --check lib/server.js && node --check lib/tool-profiles.js && node --check lib/tool-registry.js && node --check lib/tools/assets-advanced.js && node --check lib/tools/cocos-project.js && node --check lib/tools/files.js && node --check lib/tools/scene-events.js && node --check lib/update-checker.js && node --check lib/utils.js && node --check scripts/generate-tool-docs.js && node --check scripts/release.js",
"test": "node --test" "test": "node --test",
"docs:generate": "node scripts/generate-tool-docs.js",
"docs:check": "node scripts/generate-tool-docs.js --check",
"pack:dry-run": "npm pack --dry-run",
"registry:validate": "mcp-publisher validate server.json",
"release:check": "node scripts/release.js check && npm run docs:check",
"release:package": "node scripts/release.js package",
"release:verify": "npm run check && npm test && npm run release:check && npm run pack:dry-run && npm run release:package"
}, },
"panels": { "panels": {
"default": { "default": {
+486 -6
View File
@@ -34,6 +34,8 @@ module.exports = Editor.Panel.define({
</label> </label>
<ui-button id="restartBtn">Restart</ui-button> <ui-button id="restartBtn">Restart</ui-button>
<ui-button id="copyUrlBtn">Copy URL</ui-button> <ui-button id="copyUrlBtn">Copy URL</ui-button>
<ui-button id="copyHealthCurlBtn">Copy Health Curl</ui-button>
<ui-button id="copyToolsCurlBtn">Copy Tools Curl</ui-button>
<ui-button id="checkUpdatesBtn">Check Updates</ui-button> <ui-button id="checkUpdatesBtn">Check Updates</ui-button>
</div> </div>
<div class="grid"> <div class="grid">
@@ -49,6 +51,10 @@ module.exports = Editor.Panel.define({
<ui-checkbox id="sessionsInput"></ui-checkbox> <ui-checkbox id="sessionsInput"></ui-checkbox>
MCP Sessions MCP Sessions
</label> </label>
<label class="checkbox-line">
<ui-checkbox id="javascriptSafetyInput"></ui-checkbox>
JavaScript Safety Checks
</label>
</div> </div>
<div id="updateStatus" class="client-status muted"></div> <div id="updateStatus" class="client-status muted"></div>
<p>Changes auto-save. Port/profile changes restart the server when needed.</p> <p>Changes auto-save. Port/profile changes restart the server when needed.</p>
@@ -62,6 +68,19 @@ module.exports = Editor.Panel.define({
<ui-button id="useFullBtn">Full</ui-button> <ui-button id="useFullBtn">Full</ui-button>
<ui-button id="useCustomBtn">Custom</ui-button> <ui-button id="useCustomBtn">Custom</ui-button>
</div> </div>
<div class="profile-manager">
<div class="row">
<ui-input id="toolProfileNameInput" placeholder="Profile name"></ui-input>
<ui-select id="savedToolProfileSelect"></ui-select>
<ui-button id="saveToolProfileBtn">Save Profile</ui-button>
<ui-button id="applyToolProfileBtn">Apply</ui-button>
<ui-button id="deleteToolProfileBtn">Delete</ui-button>
<ui-button id="exportToolProfilesBtn">Export</ui-button>
<ui-button id="importToolProfilesBtn">Import</ui-button>
</div>
<ui-textarea id="toolProfileImportText"></ui-textarea>
</div>
<div id="categoryControls" class="category-controls"></div>
<div class="tool-config-grid"> <div class="tool-config-grid">
<label>Enabled Categories <ui-textarea id="enabledCategoriesInput"></ui-textarea></label> <label>Enabled Categories <ui-textarea id="enabledCategoriesInput"></ui-textarea></label>
<label>Disabled Categories <ui-textarea id="disabledCategoriesInput"></ui-textarea></label> <label>Disabled Categories <ui-textarea id="disabledCategoriesInput"></ui-textarea></label>
@@ -70,6 +89,20 @@ module.exports = Editor.Panel.define({
</div> </div>
</section> </section>
<section class="card">
<h2>Activity</h2>
<div class="activity-grid">
<div class="activity-column">
<h3>Recent Calls</h3>
<div id="recentCalls" class="mini-list muted"></div>
</div>
<div class="activity-column">
<h3>Log Preview</h3>
<div id="recentLogs" class="mini-list muted"></div>
</div>
</div>
</section>
<section class="card"> <section class="card">
<h2>MCP Client Config</h2> <h2>MCP Client Config</h2>
<div class="row"> <div class="row">
@@ -114,6 +147,12 @@ module.exports = Editor.Panel.define({
margin: 0 0 10px 0; margin: 0 0 10px 0;
font-size: 15px; font-size: 15px;
} }
h3 {
margin: 0 0 6px 0;
font-size: 12px;
font-weight: 600;
color: var(--color-normal-contrast-weak);
}
p { p {
margin: 4px 0 0 0; margin: 4px 0 0 0;
color: var(--color-normal-contrast-weakest); color: var(--color-normal-contrast-weakest);
@@ -158,6 +197,54 @@ module.exports = Editor.Panel.define({
gap: 8px; gap: 8px;
margin-top: 8px; margin-top: 8px;
} }
.profile-manager {
margin-top: 8px;
}
#toolProfileNameInput {
min-width: 150px;
}
#savedToolProfileSelect {
min-width: 150px;
}
#toolProfileImportText {
min-height: 54px;
margin-top: 8px;
}
.category-controls {
display: grid;
grid-template-columns: repeat(2, minmax(220px, 1fr));
gap: 8px;
margin-top: 10px;
}
.category-row {
border: 1px solid var(--color-normal-border);
border-radius: 6px;
padding: 8px;
background: rgba(0,0,0,0.10);
display: grid;
gap: 6px;
}
.category-heading {
display: flex;
justify-content: space-between;
align-items: center;
gap: 8px;
}
.category-name {
color: var(--color-normal-contrast);
font-weight: 600;
word-break: break-word;
}
.category-count {
color: var(--color-normal-contrast-weakest);
font-size: 11px;
white-space: nowrap;
}
.category-actions {
display: flex;
gap: 6px;
flex-wrap: wrap;
}
label { label {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -199,6 +286,46 @@ module.exports = Editor.Panel.define({
white-space: pre-wrap; white-space: pre-wrap;
word-break: break-all; word-break: break-all;
} }
.activity-grid {
display: grid;
grid-template-columns: repeat(2, minmax(180px, 1fr));
gap: 10px;
}
.mini-list {
min-height: 74px;
max-height: 158px;
overflow: auto;
box-sizing: border-box;
border: 1px solid var(--color-normal-border);
border-radius: 6px;
padding: 8px;
background: rgba(0,0,0,0.12);
line-height: 1.35;
}
.mini-item {
padding: 0 0 8px 0;
margin-bottom: 8px;
border-bottom: 1px solid rgba(255,255,255,0.08);
}
.mini-item:last-child {
margin-bottom: 0;
padding-bottom: 0;
border-bottom: none;
}
.mini-title {
color: var(--color-normal-contrast);
font-weight: 600;
word-break: break-word;
}
.mini-meta {
margin-top: 2px;
color: var(--color-normal-contrast-weakest);
font-size: 11px;
}
.mini-body {
margin-top: 2px;
word-break: break-word;
}
details { details {
display: block; display: block;
} }
@@ -232,6 +359,14 @@ module.exports = Editor.Panel.define({
.primary { .primary {
border-color: #4aa3ff; border-color: #4aa3ff;
} }
@media (max-width: 620px) {
.activity-grid {
grid-template-columns: 1fr;
}
.category-controls {
grid-template-columns: 1fr;
}
}
`, `,
$: { $: {
root: '.mcp-root', root: '.mcp-root',
@@ -242,14 +377,26 @@ module.exports = Editor.Panel.define({
portInput: '#portInput', portInput: '#portInput',
profileSelect: '#profileSelect', profileSelect: '#profileSelect',
sessionsInput: '#sessionsInput', sessionsInput: '#sessionsInput',
javascriptSafetyInput: '#javascriptSafetyInput',
restartBtn: '#restartBtn', restartBtn: '#restartBtn',
copyUrlBtn: '#copyUrlBtn', copyUrlBtn: '#copyUrlBtn',
copyHealthCurlBtn: '#copyHealthCurlBtn',
copyToolsCurlBtn: '#copyToolsCurlBtn',
checkUpdatesBtn: '#checkUpdatesBtn', checkUpdatesBtn: '#checkUpdatesBtn',
updateStatus: '#updateStatus', updateStatus: '#updateStatus',
toolSummary: '#toolSummary', toolSummary: '#toolSummary',
useCoreBtn: '#useCoreBtn', useCoreBtn: '#useCoreBtn',
useFullBtn: '#useFullBtn', useFullBtn: '#useFullBtn',
useCustomBtn: '#useCustomBtn', useCustomBtn: '#useCustomBtn',
toolProfileNameInput: '#toolProfileNameInput',
savedToolProfileSelect: '#savedToolProfileSelect',
saveToolProfileBtn: '#saveToolProfileBtn',
applyToolProfileBtn: '#applyToolProfileBtn',
deleteToolProfileBtn: '#deleteToolProfileBtn',
exportToolProfilesBtn: '#exportToolProfilesBtn',
importToolProfilesBtn: '#importToolProfilesBtn',
toolProfileImportText: '#toolProfileImportText',
categoryControls: '#categoryControls',
enabledCategoriesInput: '#enabledCategoriesInput', enabledCategoriesInput: '#enabledCategoriesInput',
disabledCategoriesInput: '#disabledCategoriesInput', disabledCategoriesInput: '#disabledCategoriesInput',
enabledToolsInput: '#enabledToolsInput', enabledToolsInput: '#enabledToolsInput',
@@ -258,6 +405,8 @@ module.exports = Editor.Panel.define({
configureClientBtn: '#configureClientBtn', configureClientBtn: '#configureClientBtn',
clientTargetStatus: '#clientTargetStatus', clientTargetStatus: '#clientTargetStatus',
clientConfigText: '#clientConfigText', clientConfigText: '#clientConfigText',
recentCalls: '#recentCalls',
recentLogs: '#recentLogs',
output: '#output', output: '#output',
}, },
methods: { methods: {
@@ -282,32 +431,160 @@ module.exports = Editor.Panel.define({
const portText = status.portFallbackActive const portText = status.portFallbackActive
? ` | Port fallback: ${status.requestedPort} -> ${status.port}` ? ` | Port fallback: ${status.requestedPort} -> ${status.port}`
: ''; : '';
const attachText = status.attachedToExisting ? ' | Attached listener' : '';
this.$.statusText.textContent = this.$.statusText.textContent =
`${status.url || ''} | Project: ${status.projectName || ''} | Cocos ${status.cocosVersion || ''}${portText}`; `${status.url || ''} | Project: ${status.projectName || ''} | Cocos ${status.cocosVersion || ''}${portText}${attachText}`;
this.$.enabledInput.value = Boolean(isRunning || config.autostart); this.$.enabledInput.value = Boolean(isRunning || config.autostart);
this.$.portInput.value = Number(config.port || status.port || 8765); this.$.portInput.value = Number(config.port || status.port || 8765);
this.$.profileSelect.value = config.toolProfile || status.toolProfile || 'core'; this.$.profileSelect.value = config.toolProfile || status.toolProfile || 'core';
this.$.sessionsInput.value = Boolean(config.enableSessions || status.enableSessions); this.$.sessionsInput.value = Boolean(config.enableSessions || status.enableSessions);
this.$.javascriptSafetyInput.value = config.executeJavascriptSafetyChecks !== false;
this.$.enabledCategoriesInput.value = this.formatList(config.enabledToolCategories); this.$.enabledCategoriesInput.value = this.formatList(config.enabledToolCategories);
this.$.disabledCategoriesInput.value = this.formatList(config.disabledToolCategories); this.$.disabledCategoriesInput.value = this.formatList(config.disabledToolCategories);
this.$.enabledToolsInput.value = this.formatList(config.enabledTools); this.$.enabledToolsInput.value = this.formatList(config.enabledTools);
this.$.disabledToolsInput.value = this.formatList(config.disabledTools); this.$.disabledToolsInput.value = this.formatList(config.disabledTools);
this.renderToolProfiles();
this.renderUpdateStatus(); this.renderUpdateStatus();
this.renderToolSummary(); this.renderToolSummary();
this.renderCategoryControls();
this.$.clientConfigText.value = state.clientConfig ? state.clientConfig.codex : '';
this.renderClientTargets(); this.renderClientTargets();
this.renderActivity();
}, },
formatList(value) { formatList(value) {
return Array.isArray(value) ? value.join('\n') : ''; return Array.isArray(value) ? value.join('\n') : '';
}, },
parseList(value) { parseList(value) {
if (Array.isArray(value)) {
return value.map((item) => String(item || '').trim()).filter(Boolean);
}
return String(value || '') return String(value || '')
.split(/[\n,]/) .split(/[\n,]/)
.map((item) => item.trim()) .map((item) => item.trim())
.filter(Boolean); .filter(Boolean);
}, },
normalizeToolProfile(profile) {
const name = String(profile && profile.name || '').trim();
if (!name) {
throw new Error('Profile name is required.');
}
const mode = String(profile.toolProfile || 'core').toLowerCase();
return {
name: name.slice(0, 80),
toolProfile: mode === 'full' || mode === 'custom' ? mode : 'core',
enabledToolCategories: this.parseList(profile.enabledToolCategories).map((item) => item.toLowerCase()),
disabledToolCategories: this.parseList(profile.disabledToolCategories).map((item) => item.toLowerCase()),
enabledTools: this.parseList(profile.enabledTools),
disabledTools: this.parseList(profile.disabledTools),
updatedAt: profile.updatedAt || new Date().toISOString(),
};
},
normalizeToolProfiles(value) {
const result = [];
const seen = new Set();
(Array.isArray(value) ? value : []).forEach((profile) => {
try {
const normalized = this.normalizeToolProfile(profile);
const key = normalized.name.toLowerCase();
const existing = result.findIndex((item) => item.name.toLowerCase() === key);
if (existing >= 0) {
result[existing] = normalized;
} else if (!seen.has(key)) {
seen.add(key);
result.push(normalized);
}
} catch (error) {
// Ignore malformed imported entries in the panel; backend normalization repeats this.
}
});
return result.sort((left, right) => left.name.localeCompare(right.name));
},
currentToolProfileSnapshot(name) {
return this.normalizeToolProfile({
name,
toolProfile: this.$.profileSelect.value || 'core',
enabledToolCategories: this.parseList(this.$.enabledCategoriesInput.value).map((item) => item.toLowerCase()),
disabledToolCategories: this.parseList(this.$.disabledCategoriesInput.value).map((item) => item.toLowerCase()),
enabledTools: this.parseList(this.$.enabledToolsInput.value),
disabledTools: this.parseList(this.$.disabledToolsInput.value),
});
},
getSavedToolProfiles() {
const config = this.state && this.state.config ? this.state.config : {};
return this.normalizeToolProfiles(config.savedToolProfiles || []);
},
renderToolProfiles() {
const config = this.state && this.state.config ? this.state.config : {};
const profiles = this.getSavedToolProfiles();
const selected = this.$.savedToolProfileSelect.value || config.activeToolProfileName || (profiles[0] && profiles[0].name) || '';
this.$.savedToolProfileSelect.innerHTML = '';
if (profiles.length) {
profiles.forEach((profile) => {
const option = document.createElement('option');
option.value = profile.name;
option.textContent = profile.name;
this.$.savedToolProfileSelect.appendChild(option);
});
} else {
const option = document.createElement('option');
option.value = '';
option.textContent = 'No saved profiles';
this.$.savedToolProfileSelect.appendChild(option);
}
this.$.savedToolProfileSelect.value = selected;
if (!this.$.toolProfileNameInput.value) {
this.$.toolProfileNameInput.value = selected || config.activeToolProfileName || '';
}
},
renderCategoryControls() {
const catalog = (this.state && this.state.toolCatalog) || [];
const groups = catalog.reduce((acc, tool) => {
const category = tool.category || 'other';
if (!acc[category]) {
acc[category] = { total: 0, enabled: 0 };
}
acc[category].total += 1;
if (tool.enabled) {
acc[category].enabled += 1;
}
return acc;
}, {});
this.$.categoryControls.innerHTML = '';
Object.keys(groups).sort().forEach((category) => {
const row = document.createElement('div');
row.className = 'category-row';
const heading = document.createElement('div');
heading.className = 'category-heading';
const name = document.createElement('div');
name.className = 'category-name';
name.textContent = category;
const count = document.createElement('div');
count.className = 'category-count';
count.textContent = `${groups[category].enabled}/${groups[category].total}`;
heading.appendChild(name);
heading.appendChild(count);
const actions = document.createElement('div');
actions.className = 'category-actions';
[
['enable', 'Enable'],
['disable', 'Disable'],
['clear', 'Clear'],
].forEach(([mode, label]) => {
const button = document.createElement('ui-button');
button.textContent = label;
button.dataset.category = category;
button.dataset.mode = mode;
actions.appendChild(button);
});
row.appendChild(heading);
row.appendChild(actions);
this.$.categoryControls.appendChild(row);
});
},
renderUpdateStatus() { renderUpdateStatus() {
const update = this.state && this.state.updateInfo; const update = this.state && this.state.updateInfo;
if (!update) { if (!update) {
@@ -329,6 +606,75 @@ module.exports = Editor.Panel.define({
this.$.toolSummary.textContent = this.$.toolSummary.textContent =
`Enabled ${enabled.length}/${catalog.length} tools | Categories: ${categories.join(', ')}`; `Enabled ${enabled.length}/${catalog.length} tools | Categories: ${categories.join(', ')}`;
}, },
renderActivity() {
const state = this.state || {};
this.renderMiniList(
this.$.recentCalls,
state.recentInteractions || [],
(entry) => ({
title: `${String(entry.status || '').toUpperCase()} ${entry.toolName || 'tool'}`,
meta: this.formatTimestamp(entry.timestamp),
body: entry.summary || '',
}),
'No recent MCP calls.'
);
this.renderMiniList(
this.$.recentLogs,
state.recentRuntimeLogs || [],
(entry) => ({
title: `${String(entry.level || 'info').toUpperCase()} ${entry.message || ''}`,
meta: this.formatTimestamp(entry.timestamp),
body: entry.details ? stringify(entry.details) : '',
}),
'No runtime logs yet.'
);
},
renderMiniList(container, entries, formatEntry, emptyText) {
container.innerHTML = '';
if (!entries.length) {
container.textContent = emptyText;
return;
}
const fragment = document.createDocumentFragment();
entries.slice(0, 6).forEach((entry) => {
const formatted = formatEntry(entry);
const item = document.createElement('div');
item.className = 'mini-item';
const title = document.createElement('div');
title.className = 'mini-title';
title.textContent = formatted.title;
item.appendChild(title);
if (formatted.meta) {
const meta = document.createElement('div');
meta.className = 'mini-meta';
meta.textContent = formatted.meta;
item.appendChild(meta);
}
if (formatted.body) {
const body = document.createElement('div');
body.className = 'mini-body';
body.textContent = formatted.body;
item.appendChild(body);
}
fragment.appendChild(item);
});
container.appendChild(fragment);
},
formatTimestamp(value) {
if (!value) {
return '';
}
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
return String(value);
}
return date.toLocaleTimeString();
},
renderClientTargets() { renderClientTargets() {
const targets = (this.state && this.state.clientTargets) || []; const targets = (this.state && this.state.clientTargets) || [];
const preferred = this.state && this.state.config ? this.state.config.lastClientTargetId : ''; const preferred = this.state && this.state.config ? this.state.config.lastClientTargetId : '';
@@ -349,10 +695,30 @@ module.exports = Editor.Panel.define({
return; return;
} }
this.$.clientTargetStatus.textContent = `${target.configured ? 'Configured' : 'Not configured'}: ${target.configPath}`; this.$.clientTargetStatus.textContent = `${target.configured ? 'Configured' : 'Not configured'}: ${target.configPath}`;
const previews = this.state && this.state.clientConfig && Array.isArray(this.state.clientConfig.targets)
? this.state.clientConfig.targets
: [];
const preview = previews.find((item) => item.id === target.id);
this.$.clientConfigText.value = preview && preview.preview
? preview.preview
: (this.state && this.state.clientConfig ? this.state.clientConfig.codex : '');
}, },
showOutput(value) { showOutput(value) {
this.$.output.textContent = stringify(value); this.$.output.textContent = stringify(value);
}, },
copyText(text, successMessage) {
if (!text) {
this.showOutput('Nothing to copy.');
return;
}
navigator.clipboard.writeText(text)
.then(() => this.showOutput(successMessage))
.catch(() => this.showOutput(text));
},
getCurlCommand(key) {
const curl = this.state && this.state.clientConfig && this.state.clientConfig.curl;
return curl && curl[key] ? curl[key] : '';
},
async persistConfig(options = {}) { async persistConfig(options = {}) {
const { showOutput = false } = options; const { showOutput = false } = options;
try { try {
@@ -387,11 +753,103 @@ module.exports = Editor.Panel.define({
enabledTools: this.parseList(this.$.enabledToolsInput.value), enabledTools: this.parseList(this.$.enabledToolsInput.value),
disabledTools: this.parseList(this.$.disabledToolsInput.value), disabledTools: this.parseList(this.$.disabledToolsInput.value),
enableSessions: Boolean(this.$.sessionsInput.value), enableSessions: Boolean(this.$.sessionsInput.value),
executeJavascriptSafetyChecks: Boolean(this.$.javascriptSafetyInput.value),
autostart: Boolean(this.$.enabledInput.value), autostart: Boolean(this.$.enabledInput.value),
maxInteractionLogEntries: this.state && this.state.config ? this.state.config.maxInteractionLogEntries : 50, maxInteractionLogEntries: this.state && this.state.config ? this.state.config.maxInteractionLogEntries : 50,
lastClientTargetId: this.$.clientTargetSelect.value || 'claude_code', lastClientTargetId: this.$.clientTargetSelect.value || 'claude_code',
activeToolProfileName: this.$.toolProfileNameInput.value || '',
savedToolProfiles: this.getSavedToolProfiles(),
}; };
}, },
async saveCurrentToolProfile() {
const name = this.$.toolProfileNameInput.value || this.$.savedToolProfileSelect.value;
const snapshot = this.currentToolProfileSnapshot(name);
const profiles = this.getSavedToolProfiles();
const key = snapshot.name.toLowerCase();
const existing = profiles.findIndex((profile) => profile.name.toLowerCase() === key);
if (existing >= 0) {
profiles[existing] = snapshot;
} else {
profiles.push(snapshot);
}
this.state.config.savedToolProfiles = this.normalizeToolProfiles(profiles);
this.state.config.activeToolProfileName = snapshot.name;
await this.persistConfig({ showOutput: true });
},
async applySavedToolProfile() {
const name = this.$.savedToolProfileSelect.value;
const profile = this.getSavedToolProfiles().find((item) => item.name === name);
if (!profile) {
this.showOutput('Select a saved profile first.');
return;
}
this.$.profileSelect.value = profile.toolProfile;
this.$.enabledCategoriesInput.value = this.formatList(profile.enabledToolCategories);
this.$.disabledCategoriesInput.value = this.formatList(profile.disabledToolCategories);
this.$.enabledToolsInput.value = this.formatList(profile.enabledTools);
this.$.disabledToolsInput.value = this.formatList(profile.disabledTools);
this.$.toolProfileNameInput.value = profile.name;
this.state.config.activeToolProfileName = profile.name;
await this.persistConfig({ showOutput: true });
},
async deleteSavedToolProfile() {
const name = this.$.savedToolProfileSelect.value;
if (!name) {
this.showOutput('Select a saved profile first.');
return;
}
this.state.config.savedToolProfiles = this.getSavedToolProfiles()
.filter((profile) => profile.name !== name);
if (this.state.config.activeToolProfileName === name) {
this.state.config.activeToolProfileName = '';
}
this.$.toolProfileNameInput.value = '';
await this.persistConfig({ showOutput: true });
},
exportSavedToolProfiles() {
const payload = JSON.stringify({ version: 1, profiles: this.getSavedToolProfiles() }, null, 2);
this.$.toolProfileImportText.value = payload;
this.copyText(payload, 'Copied tool profiles to clipboard.');
},
async importSavedToolProfiles() {
try {
const payload = JSON.parse(this.$.toolProfileImportText.value || '{}');
const incoming = Array.isArray(payload)
? payload
: Array.isArray(payload.profiles)
? payload.profiles
: [];
if (!incoming.length) {
throw new Error('No profiles found.');
}
this.state.config.savedToolProfiles = this.normalizeToolProfiles([
...this.getSavedToolProfiles(),
...incoming,
]);
await this.persistConfig({ showOutput: true });
} catch (error) {
this.showOutput(`Import profiles failed: ${error.message}`);
}
},
async setCategoryExposure(category, mode) {
const enabled = new Set(this.parseList(this.$.enabledCategoriesInput.value).map((item) => item.toLowerCase()));
const disabled = new Set(this.parseList(this.$.disabledCategoriesInput.value).map((item) => item.toLowerCase()));
const key = String(category || '').toLowerCase();
if (!key) {
return;
}
enabled.delete(key);
disabled.delete(key);
if (mode === 'enable') {
enabled.add(key);
} else if (mode === 'disable') {
disabled.add(key);
}
this.$.profileSelect.value = 'custom';
this.$.enabledCategoriesInput.value = Array.from(enabled).sort().join('\n');
this.$.disabledCategoriesInput.value = Array.from(disabled).sort().join('\n');
await this.persistConfig({ showOutput: true });
},
async handleEnableToggle() { async handleEnableToggle() {
const shouldEnable = Boolean(this.$.enabledInput.value); const shouldEnable = Boolean(this.$.enabledInput.value);
const wasRunning = Boolean(this.state && this.state.status && this.state.status.running); const wasRunning = Boolean(this.state && this.state.status && this.state.status.running);
@@ -415,15 +873,20 @@ module.exports = Editor.Panel.define({
this.$.copyUrlBtn.addEventListener('click', () => { this.$.copyUrlBtn.addEventListener('click', () => {
const status = this.state && this.state.status; const status = this.state && this.state.status;
const text = status && status.url ? status.url : ''; const text = status && status.url ? status.url : '';
navigator.clipboard.writeText(text) this.copyText(text, 'Copied URL to clipboard.');
.then(() => this.showOutput('Copied URL to clipboard.')) });
.catch(() => this.showOutput(text)); this.$.copyHealthCurlBtn.addEventListener('click', () => {
this.copyText(this.getCurlCommand('health'), 'Copied health curl command.');
});
this.$.copyToolsCurlBtn.addEventListener('click', () => {
this.copyText(this.getCurlCommand('tools'), 'Copied tools curl command.');
}); });
this.$.checkUpdatesBtn.addEventListener('click', () => this.runAction(() => request('check-updates'))); this.$.checkUpdatesBtn.addEventListener('click', () => this.runAction(() => request('check-updates')));
this.$.enabledInput.addEventListener('change', () => this.handleEnableToggle()); this.$.enabledInput.addEventListener('change', () => this.handleEnableToggle());
this.$.portInput.addEventListener('change', () => this.persistConfig({ showOutput: true })); this.$.portInput.addEventListener('change', () => this.persistConfig({ showOutput: true }));
this.$.profileSelect.addEventListener('change', () => this.persistConfig({ showOutput: true })); this.$.profileSelect.addEventListener('change', () => this.persistConfig({ showOutput: true }));
this.$.sessionsInput.addEventListener('change', () => this.persistConfig({ showOutput: true })); this.$.sessionsInput.addEventListener('change', () => this.persistConfig({ showOutput: true }));
this.$.javascriptSafetyInput.addEventListener('change', () => this.persistConfig({ showOutput: true }));
this.$.enabledCategoriesInput.addEventListener('change', () => this.persistConfig({ showOutput: true })); this.$.enabledCategoriesInput.addEventListener('change', () => this.persistConfig({ showOutput: true }));
this.$.disabledCategoriesInput.addEventListener('change', () => this.persistConfig({ showOutput: true })); this.$.disabledCategoriesInput.addEventListener('change', () => this.persistConfig({ showOutput: true }));
this.$.enabledToolsInput.addEventListener('change', () => this.persistConfig({ showOutput: true })); this.$.enabledToolsInput.addEventListener('change', () => this.persistConfig({ showOutput: true }));
@@ -448,6 +911,23 @@ module.exports = Editor.Panel.define({
this.$.profileSelect.value = 'custom'; this.$.profileSelect.value = 'custom';
this.persistConfig({ showOutput: true }); this.persistConfig({ showOutput: true });
}); });
this.$.saveToolProfileBtn.addEventListener('click', () => this.saveCurrentToolProfile());
this.$.applyToolProfileBtn.addEventListener('click', () => this.applySavedToolProfile());
this.$.deleteToolProfileBtn.addEventListener('click', () => this.deleteSavedToolProfile());
this.$.exportToolProfilesBtn.addEventListener('click', () => this.exportSavedToolProfiles());
this.$.importToolProfilesBtn.addEventListener('click', () => this.importSavedToolProfiles());
this.$.savedToolProfileSelect.addEventListener('change', () => {
this.$.toolProfileNameInput.value = this.$.savedToolProfileSelect.value || '';
});
this.$.categoryControls.addEventListener('click', (event) => {
const target = event.target && typeof event.target.closest === 'function'
? event.target.closest('ui-button')
: event.target;
if (!target || !target.dataset || !target.dataset.category) {
return;
}
this.setCategoryExposure(target.dataset.category, target.dataset.mode);
});
this.$.clientTargetSelect.addEventListener('confirm', () => this.renderClientTargetStatus()); this.$.clientTargetSelect.addEventListener('confirm', () => this.renderClientTargetStatus());
this.$.clientTargetSelect.addEventListener('change', () => { this.$.clientTargetSelect.addEventListener('change', () => {
this.renderClientTargetStatus(); this.renderClientTargetStatus();
+243
View File
@@ -16,6 +16,7 @@ const {
SceneAsset, SceneAsset,
js, js,
Component, Component,
EventHandler,
Canvas, Canvas,
UITransform, UITransform,
Label, Label,
@@ -241,6 +242,23 @@ function findComponent(node, options = {}) {
return null; return null;
} }
function getEventHandlerClass() {
return (Component && Component.EventHandler) || EventHandler || null;
}
function serializeEventHandler(handler) {
if (!handler) {
return null;
}
return {
target: handler.target && handler.target.name ? getNodePath(handler.target) : '',
targetUuid: handler.target && handler.target.uuid ? handler.target.uuid : '',
component: handler.component || '',
handler: handler.handler || '',
customEventData: handler.customEventData || '',
};
}
function getOrAddComponent(node, componentClass) { function getOrAddComponent(node, componentClass) {
return node.getComponent(componentClass) || node.addComponent(componentClass); return node.getComponent(componentClass) || node.addComponent(componentClass);
} }
@@ -308,6 +326,97 @@ function findComponentsByClass(componentClass) {
return results; return results;
} }
function getPrefabInfo(node) {
const prefab = node && node._prefab;
if (!prefab) {
return {
linked: false,
};
}
const asset = prefab.asset || prefab._asset || null;
return {
linked: Boolean(asset || prefab.fileId || prefab.root),
fileId: prefab.fileId || '',
asset: asset
? {
name: asset.name || '',
uuid: asset.uuid || asset._uuid || '',
}
: null,
instance: prefab.instance ? plain(prefab.instance) : null,
sync: prefab.sync,
rawKeys: Object.keys(prefab).slice(0, 50),
};
}
function collectSceneStats() {
const stats = {
nodeCount: 0,
activeNodeCount: 0,
inactiveNodeCount: 0,
maxDepth: 0,
componentCount: 0,
componentsByType: {},
prefabInstanceCount: 0,
uiTransformCount: 0,
canvasCount: 0,
cameraCount: 0,
labelCount: 0,
spriteCount: 0,
buttonCount: 0,
};
function visit(node, depth) {
if (node !== getScene()) {
stats.nodeCount += 1;
stats.maxDepth = Math.max(stats.maxDepth, depth);
if (node.active) stats.activeNodeCount += 1;
else stats.inactiveNodeCount += 1;
if (node._prefab) stats.prefabInstanceCount += 1;
}
for (const component of node.components || []) {
const name = component && component.constructor ? component.constructor.name : 'UnknownComponent';
stats.componentCount += 1;
stats.componentsByType[name] = (stats.componentsByType[name] || 0) + 1;
if (component instanceof UITransform) stats.uiTransformCount += 1;
if (component instanceof Canvas) stats.canvasCount += 1;
if (component instanceof Camera) stats.cameraCount += 1;
if (component instanceof Label) stats.labelCount += 1;
if (component instanceof Sprite) stats.spriteCount += 1;
if (component instanceof Button) stats.buttonCount += 1;
}
for (const child of node.children) {
visit(child, depth + 1);
}
}
visit(getScene(), 0);
return stats;
}
function buildSceneWarnings(stats) {
const warnings = [];
if (stats.nodeCount === 0) {
warnings.push({ severity: 'warn', code: 'empty_scene', message: 'The active scene has no child nodes.' });
}
if (stats.cameraCount === 0) {
warnings.push({ severity: 'warn', code: 'missing_camera', message: 'No Camera component was found in the active scene.' });
}
if (stats.nodeCount > 500) {
warnings.push({ severity: 'info', code: 'large_node_count', message: `Scene has ${stats.nodeCount} nodes.` });
}
if (stats.maxDepth > 12) {
warnings.push({ severity: 'info', code: 'deep_hierarchy', message: `Scene hierarchy depth is ${stats.maxDepth}.` });
}
if (stats.labelCount > 80) {
warnings.push({ severity: 'info', code: 'many_labels', message: `Scene has ${stats.labelCount} Label components.` });
}
return warnings;
}
function getScheduler() { function getScheduler() {
return typeof director.getScheduler === 'function' ? director.getScheduler() : null; return typeof director.getScheduler === 'function' ? director.getScheduler() : null;
} }
@@ -1149,6 +1258,45 @@ exports.methods = {
}; };
}, },
async getPerformanceSnapshot() {
const scheduler = getScheduler();
const stats = collectSceneStats();
const memory = typeof performance !== 'undefined' && performance.memory
? {
jsHeapSizeLimit: performance.memory.jsHeapSizeLimit,
totalJSHeapSize: performance.memory.totalJSHeapSize,
usedJSHeapSize: performance.memory.usedJSHeapSize,
}
: null;
return {
sceneName: getScene().name,
runtime: {
paused: typeof director.isPaused === 'function' ? director.isPaused() : false,
timeScale: scheduler && typeof scheduler.getTimeScale === 'function' ? scheduler.getTimeScale() : 1,
totalFrames: typeof director.getTotalFrames === 'function' ? director.getTotalFrames() : undefined,
},
stats,
memory,
warnings: buildSceneWarnings(stats),
};
},
async getPrefabInstanceInfo(options = {}) {
const node = findNode(options);
if (!node) {
throw new Error('Target node was not found.');
}
return {
node: {
name: node.name,
path: getNodePath(node),
uuid: node.uuid,
},
prefab: getPrefabInfo(node),
};
},
async pauseRuntime() { async pauseRuntime() {
if (typeof director.pause === 'function') { if (typeof director.pause === 'function') {
director.pause(); director.pause();
@@ -1215,6 +1363,101 @@ exports.methods = {
}; };
}, },
async listButtonClickEvents(options = {}) {
const node = findNode(options);
if (!node) {
throw new Error('Target node was not found.');
}
const button = node.getComponent(Button);
if (!button) {
throw new Error('Button component was not found on target node.');
}
return {
node: getNodePath(node),
uuid: node.uuid,
clickEventCount: Array.isArray(button.clickEvents) ? button.clickEvents.length : 0,
clickEvents: Array.isArray(button.clickEvents)
? button.clickEvents.map(serializeEventHandler).filter(Boolean)
: [],
};
},
async bindButtonClickEvent(options = {}) {
const node = findNode(options);
if (!node) {
throw new Error('Button node was not found.');
}
const button = node.getComponent(Button);
if (!button) {
throw new Error('Button component was not found on target node.');
}
const target = findNode({
path: options.targetPath,
uuid: options.targetUuid,
name: options.targetName,
});
if (!target) {
throw new Error('Event target node was not found.');
}
const componentName = String(options.componentName || '').trim();
const handlerName = String(options.handler || options.handlerName || '').trim();
if (!componentName || !handlerName) {
throw new Error('componentName and handler are required.');
}
const component = findComponent(target, { componentName });
if (!component) {
throw new Error(`Target component was not found: ${componentName}`);
}
if (typeof component[handlerName] !== 'function') {
throw new Error(`Target component method was not found: ${componentName}.${handlerName}`);
}
const HandlerClass = getEventHandlerClass();
if (!HandlerClass) {
throw new Error('Cocos EventHandler class is unavailable.');
}
const existing = Array.isArray(button.clickEvents) ? button.clickEvents : [];
const duplicate = existing.find((event) => (
event &&
event.target === target &&
event.component === componentName &&
event.handler === handlerName &&
String(event.customEventData || '') === String(options.customEventData || '')
));
if (duplicate && options.replace !== true) {
return {
bound: false,
duplicate: true,
node: getNodePath(node),
event: serializeEventHandler(duplicate),
clickEventCount: existing.length,
};
}
const event = new HandlerClass();
event.target = target;
event.component = componentName;
event.handler = handlerName;
event.customEventData = String(options.customEventData || '');
button.clickEvents = options.replace === true
? existing.filter((item) => item !== duplicate).concat(event)
: existing.concat(event);
return {
bound: true,
node: getNodePath(node),
uuid: node.uuid,
event: serializeEventHandler(event),
clickEventCount: button.clickEvents.length,
};
},
async invokeComponentMethod(options = {}) { async invokeComponentMethod(options = {}) {
const node = findNode(options); const node = findNode(options);
if (!node) { if (!node) {
+164
View File
@@ -0,0 +1,164 @@
#!/usr/bin/env node
'use strict';
const fs = require('fs');
const path = require('path');
const { createToolRegistry } = require('../lib/tool-registry');
const ROOT = path.resolve(__dirname, '..');
const OUTPUT_PATH = path.join(ROOT, 'docs', 'TOOLS.md');
function createRegistry(profile) {
return createToolRegistry({
getRuntimeContext: () => ({
config: { toolProfile: profile },
projectPath: '/tmp/funplay-cocos-docs-project',
version: '0.0.0-docs',
}),
interactionLog: { add() {} },
runtimeLog: { add() {}, list: () => [], clear: () => 0 },
sceneBridge: { call: async () => ({ ok: true }) },
editorExecutor: async () => ({ ok: true }),
});
}
function buildToolModel() {
const fullRegistry = createRegistry('full');
const coreNames = new Set(createRegistry('core').listTools().map((tool) => tool.name));
const fullNames = new Set(fullRegistry.listTools().map((tool) => tool.name));
const catalog = fullRegistry.listToolCatalog()
.map((tool) => ({
name: tool.name,
category: tool.category || 'other',
profile: tool.profile || 'full',
enabledInCore: coreNames.has(tool.name),
enabledInFull: fullNames.has(tool.name),
readOnly: Boolean(tool.annotations && tool.annotations.readOnlyHint),
destructive: Boolean(tool.annotations && tool.annotations.destructiveHint),
description: normalizeDescription(tool.description),
}))
.sort((a, b) => a.category.localeCompare(b.category) || a.name.localeCompare(b.name));
return {
coreCount: coreNames.size,
fullCount: fullNames.size,
catalog,
};
}
function normalizeDescription(description) {
return String(description || '')
.replace(/\s+/g, ' ')
.trim();
}
function buildMarkdown(model) {
const categories = groupBy(model.catalog, (tool) => tool.category);
const lines = [
'# Tool Reference',
'',
'<!-- This file is generated by `npm run docs:generate`. Do not edit by hand. -->',
'',
`Generated from \`lib/tool-registry.js\`. The default \`core\` profile exposes ${model.coreCount} tools; the \`full\` profile exposes ${model.fullCount} tools.`,
'',
'## Profile Summary',
'',
'| Profile | Tool Count | Purpose |',
'|---|---:|---|',
`| \`core\` | ${model.coreCount} | Focused default surface for common editor automation. |`,
`| \`full\` | ${model.fullCount} | All built-in tools, including destructive and low-level helpers. |`,
'',
'## Core Tools',
'',
model.catalog
.filter((tool) => tool.enabledInCore)
.sort((a, b) => a.name.localeCompare(b.name))
.map((tool) => `\`${tool.name}\``)
.join(', '),
'',
'## Tools By Category',
'',
];
for (const category of Object.keys(categories).sort()) {
const tools = categories[category];
lines.push(`### ${titleCase(category)}`);
lines.push('');
lines.push('| Tool | Profiles | Access | Description |');
lines.push('|---|---|---|---|');
for (const tool of tools) {
lines.push(`| \`${tool.name}\` | ${profileLabel(tool)} | ${accessLabel(tool)} | ${escapeTableCell(tool.description)} |`);
}
lines.push('');
}
return `${lines.join('\n').replace(/\n{3,}/g, '\n\n')}\n`;
}
function profileLabel(tool) {
return tool.enabledInCore ? '`core`, `full`' : '`full`';
}
function accessLabel(tool) {
if (tool.readOnly) {
return 'read-only';
}
if (tool.destructive) {
return 'mutating';
}
return 'stateful';
}
function titleCase(value) {
return String(value)
.split(/[-_\s]+/)
.filter(Boolean)
.map((part) => `${part.charAt(0).toUpperCase()}${part.slice(1)}`)
.join(' ');
}
function escapeTableCell(value) {
return String(value || '')
.replace(/\|/g, '\\|')
.replace(/\n/g, '<br>');
}
function groupBy(values, getKey) {
return values.reduce((groups, value) => {
const key = getKey(value);
if (!groups[key]) {
groups[key] = [];
}
groups[key].push(value);
return groups;
}, {});
}
function writeDocs(markdown) {
const directory = path.dirname(OUTPUT_PATH);
if (!fs.existsSync(directory)) {
fs.mkdirSync(directory, { recursive: true });
}
fs.writeFileSync(OUTPUT_PATH, markdown, 'utf8');
}
function checkDocs(markdown) {
const existing = fs.existsSync(OUTPUT_PATH) ? fs.readFileSync(OUTPUT_PATH, 'utf8') : '';
if (existing !== markdown) {
console.error('docs/TOOLS.md is out of date. Run `npm run docs:generate`.');
process.exitCode = 1;
}
}
function main() {
const args = process.argv.slice(2);
const markdown = buildMarkdown(buildToolModel());
if (args.includes('--check')) {
checkDocs(markdown);
return;
}
writeDocs(markdown);
console.log(`Wrote ${path.relative(ROOT, OUTPUT_PATH)}`);
}
main();
+673
View File
@@ -0,0 +1,673 @@
#!/usr/bin/env node
'use strict';
const childProcess = require('child_process');
const crypto = require('crypto');
const fs = require('fs');
const path = require('path');
const ROOT = path.resolve(__dirname, '..');
const PACKAGE_DIR_NAME = 'funplay-cocos-mcp';
const RELEASES_DIR = path.join(ROOT, 'releases');
const TEMP_DIR = path.join(ROOT, '.release-tmp');
const ZIP_PREFIX = 'Funplay.CocosMcp';
const REPOSITORY_URL = 'https://github.com/FunplayAI/funplay-cocos-mcp';
const REQUIRED_REPO_FILES = [
'package.json',
'README.md',
'README_CN.md',
'docs/TOOLS.md',
'RELEASE_WORKFLOW.md',
'RELEASE_CHECKLIST.md',
'CHANGELOG.md',
'CONTRIBUTING.md',
'LICENSE',
'server.json',
'bin/funplay-cocos-mcp.js',
'browser.js',
'scene.js',
'panel/index.js',
'lib/server.js',
'lib/tool-registry.js'
];
const PACKAGE_INCLUDES = [
'package.json',
'README.md',
'README_CN.md',
'docs',
'CHANGELOG.md',
'CONTRIBUTING.md',
'LICENSE',
'server.json',
'bin',
'browser.js',
'scene.js',
'panel',
'lib'
];
const FORBIDDEN_TRACKED_SEGMENTS = new Set([
'.idea',
'node_modules',
'Library',
'library',
'Temp',
'temp',
'dist',
'build',
'coverage',
'releases',
'.release-tmp'
]);
const FORBIDDEN_ARCHIVE_SEGMENTS = new Set([
'.git',
'.github',
'.idea',
'node_modules',
'Library',
'library',
'Temp',
'temp',
'dist',
'build',
'coverage',
'releases',
'.release-tmp',
'scripts',
'test'
]);
const FORBIDDEN_NAMES = new Set([
'.DS_Store'
]);
const FORBIDDEN_CONTENT_PATTERNS = [
['npm token', /\bnpm_[A-Za-z0-9]{20,}\b/],
['GitHub token', /\bgh[pousr]_[A-Za-z0-9_]{30,}\b/],
['MCP token', /\bmcp_[A-Za-z0-9_-]{32,}\b/],
['private key', /-----BEGIN [A-Z ]*PRIVATE KEY-----/]
];
function main() {
const command = process.argv[2] || 'check';
const options = parseOptions(process.argv.slice(3));
if (command === 'check') {
const context = checkRelease(options);
console.log(`Release check passed for v${context.version}.`);
return;
}
if (command === 'package') {
const context = checkRelease(options);
const artifacts = packageRelease(context);
console.log(`Release package ready: ${path.relative(ROOT, artifacts.releaseDir)}`);
console.log(`- ${artifacts.zipName}`);
console.log('- release-manifest.json');
console.log('- SHA256SUMS.txt');
console.log('- RELEASE_NOTES.md');
console.log('- README.md');
return;
}
printUsage();
process.exitCode = 2;
}
function parseOptions(args) {
const options = {
version: '',
strictTag: false
};
for (let i = 0; i < args.length; i += 1) {
const arg = args[i];
if (arg === '--version' && args[i + 1]) {
options.version = args[i + 1];
i += 1;
} else if (arg === '--strict-tag') {
options.strictTag = true;
} else {
throw new Error(`Unknown release option: ${arg}`);
}
}
return options;
}
function checkRelease(options = {}) {
const errors = [];
const packageJson = readJson(path.join(ROOT, 'package.json'), errors);
const version = options.version || (packageJson && packageJson.version) || '';
const tag = `v${version}`;
if (!packageJson) {
throwErrors(errors);
}
if (options.version && options.version !== packageJson.version) {
errors.push(`--version ${options.version} does not match package.json version ${packageJson.version}.`);
}
if (packageJson.name !== 'funplay-cocos-mcp') {
errors.push('package.json name must be funplay-cocos-mcp.');
}
if (!Number.isInteger(packageJson.package_version) || packageJson.package_version <= 0) {
errors.push('package.json package_version must be a positive integer.');
}
if (!packageJson.main || !fs.existsSync(path.join(ROOT, packageJson.main))) {
errors.push('package.json main must point to an existing file.');
}
if (!packageJson.bin || packageJson.bin['funplay-cocos-mcp'] !== 'bin/funplay-cocos-mcp.js') {
errors.push('package.json bin.funplay-cocos-mcp must point to bin/funplay-cocos-mcp.js.');
}
if (!/^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/.test(version)) {
errors.push(`package.json version must be semver-like, got: ${version}`);
}
const serverJson = readJson(path.join(ROOT, 'server.json'), errors);
if (serverJson) {
if (serverJson.name !== packageJson.mcpName) {
errors.push('server.json name must match package.json mcpName.');
}
if (serverJson.version !== version) {
errors.push(`server.json version ${serverJson.version} must match package.json version ${version}.`);
}
const npmPackage = Array.isArray(serverJson.packages)
? serverJson.packages.find((entry) => entry && entry.registryType === 'npm')
: null;
if (!npmPackage) {
errors.push('server.json must include an npm package entry.');
} else {
if (npmPackage.identifier !== packageJson.name) {
errors.push(`server.json npm identifier ${npmPackage.identifier} must match package.json name ${packageJson.name}.`);
}
if (npmPackage.version !== version) {
errors.push(`server.json npm package version ${npmPackage.version} must match package.json version ${version}.`);
}
if (!npmPackage.transport || npmPackage.transport.type !== 'stdio') {
errors.push('server.json npm package transport must be stdio.');
}
}
}
for (const relative of REQUIRED_REPO_FILES) {
if (!fs.existsSync(path.join(ROOT, relative))) {
errors.push(`Missing required repository file: ${relative}`);
}
}
const changelogPath = path.join(ROOT, 'CHANGELOG.md');
const changelog = fs.existsSync(changelogPath) ? fs.readFileSync(changelogPath, 'utf8') : '';
if (version && !new RegExp(`^## \\[${escapeRegExp(version)}\\] - \\d{4}-\\d{2}-\\d{2}`, 'm').test(changelog)) {
errors.push(`CHANGELOG.md is missing a dated ## [${version}] release section.`);
}
const trackedFiles = gitLines(['ls-files']);
const forbiddenTracked = trackedFiles.filter(isForbiddenTrackedPath);
if (forbiddenTracked.length > 0) {
errors.push(`Tracked local/build junk must not be committed:\n- ${forbiddenTracked.join('\n- ')}`);
}
for (const relative of PACKAGE_INCLUDES) {
const fullPath = path.join(ROOT, relative);
if (!fs.existsSync(fullPath)) {
errors.push(`Package include path is missing: ${relative}`);
}
}
if (options.strictTag && !gitTagExists(tag)) {
errors.push(`Git tag ${tag} does not exist. Create it before publishing.`);
}
throwErrors(errors);
return {
packageJson,
version,
tag,
changelogNotes: extractChangelogNotes(changelog, version),
gitCommit: gitText(['rev-parse', 'HEAD']).trim(),
gitDirty: gitText(['status', '--porcelain']).trim() !== ''
};
}
function packageRelease(context) {
ensureCommand('zip');
const releaseDir = path.join(RELEASES_DIR, context.version);
const stagingRoot = path.join(TEMP_DIR, PACKAGE_DIR_NAME);
const zipName = `${ZIP_PREFIX}.v${context.version}.zip`;
const zipPath = path.join(releaseDir, zipName);
fs.rmSync(releaseDir, { recursive: true, force: true });
fs.rmSync(TEMP_DIR, { recursive: true, force: true });
fs.mkdirSync(releaseDir, { recursive: true });
fs.mkdirSync(stagingRoot, { recursive: true });
for (const relative of PACKAGE_INCLUDES) {
copyIntoPackage(relative, stagingRoot);
}
const stagedFiles = collectFiles(stagingRoot)
.map((filePath) => path.relative(TEMP_DIR, filePath).split(path.sep).join('/'));
validateArchivePaths(stagedFiles);
validateArchiveContent(collectFiles(stagingRoot));
run('zip', ['-qr', zipPath, PACKAGE_DIR_NAME], { cwd: TEMP_DIR });
validateZipListing(zipPath);
const zipSha256 = sha256File(zipPath);
const zipSize = fs.statSync(zipPath).size;
const manifest = buildManifest(context, {
zipName,
zipSha256,
zipSize,
fileCount: stagedFiles.length
});
const manifestPath = path.join(releaseDir, 'release-manifest.json');
fs.writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`);
const readmePath = path.join(releaseDir, 'README.md');
fs.writeFileSync(readmePath, buildReleaseReadme(context, manifest));
const releaseNotesPath = path.join(releaseDir, 'RELEASE_NOTES.md');
fs.writeFileSync(releaseNotesPath, buildGitHubReleaseNotes(context, manifest));
const checksums = [
checksumLine(zipPath, zipName),
checksumLine(manifestPath, 'release-manifest.json'),
checksumLine(releaseNotesPath, 'RELEASE_NOTES.md'),
checksumLine(readmePath, 'README.md')
].join('');
fs.writeFileSync(path.join(releaseDir, 'SHA256SUMS.txt'), checksums);
fs.rmSync(TEMP_DIR, { recursive: true, force: true });
return {
releaseDir,
zipName
};
}
function copyIntoPackage(relative, stagingRoot) {
const source = path.join(ROOT, relative);
const destination = path.join(stagingRoot, relative);
fs.cpSync(source, destination, {
recursive: true,
force: true,
filter(sourcePath) {
const name = path.basename(sourcePath);
if (FORBIDDEN_NAMES.has(name)) {
return false;
}
const relativeSource = path.relative(ROOT, sourcePath).split(path.sep);
return !relativeSource.some((part) => FORBIDDEN_ARCHIVE_SEGMENTS.has(part));
}
});
}
function buildManifest(context, artifact) {
return {
version: context.version,
generatedAt: new Date().toISOString(),
repository: {
url: REPOSITORY_URL,
source: 'github'
},
git: {
tag: context.tag,
commit: context.gitCommit,
dirty: context.gitDirty
},
package: {
name: context.packageJson.name,
version: context.packageJson.version,
main: context.packageJson.main,
packageVersion: context.packageJson.package_version
},
artifacts: {
extensionZip: {
file: artifact.zipName,
sha256: artifact.zipSha256,
sizeBytes: artifact.zipSize,
fileCount: artifact.fileCount,
installDirectory: 'extensions/funplay-cocos-mcp',
githubDownloadUrl: `${REPOSITORY_URL}/releases/download/${context.tag}/${artifact.zipName}`
}
},
notes: firstMeaningfulLine(context.changelogNotes)
};
}
function buildReleaseReadme(context, manifest) {
const zip = manifest.artifacts.extensionZip;
return `# Funplay MCP for Cocos ${context.tag}
This folder contains the generated release artifacts for Funplay MCP for Cocos ${context.tag}.
## Artifacts
- \`${zip.file}\` - Cocos Creator extension package.
- \`release-manifest.json\` - Machine-readable release metadata.
- \`SHA256SUMS.txt\` - SHA-256 checksums for release artifacts.
## Install
1. Unzip \`${zip.file}\`.
2. Move the extracted \`${PACKAGE_DIR_NAME}\` folder into your Cocos project \`extensions/\` directory.
3. Restart Cocos Creator or reload extensions.
4. Open \`Funplay > MCP Server\`.
## Verify
\`\`\`bash
shasum -a 256 -c SHA256SUMS.txt
\`\`\`
`;
}
function buildGitHubReleaseNotes(context, manifest) {
const sections = parseChangelogSections(context.changelogNotes);
const rendered = [];
const used = new Set();
const order = [
['Added', 'Added'],
['Optimized', 'Optimized'],
['Changed', 'Changed'],
['Fixed', 'Fixed'],
['Security', 'Security'],
['Deprecated', 'Deprecated'],
['Removed', 'Removed']
];
for (const [sourceHeading, displayHeading] of order) {
const section = sections.find((item) => item.heading.toLowerCase() === sourceHeading.toLowerCase());
if (!section) {
continue;
}
used.add(section.heading);
rendered.push(`## ${displayHeading}`, '', ...section.lines, '');
}
for (const section of sections) {
if (used.has(section.heading)) {
continue;
}
rendered.push(`## ${section.heading}`, '', ...section.lines, '');
}
const zip = manifest.artifacts.extensionZip;
return [
`# Funplay MCP for Cocos ${context.tag}`,
'',
...rendered,
'## Release Assets',
'',
`- \`${zip.file}\` - Cocos Creator extension package.`,
'- `release-manifest.json` - Machine-readable release metadata.',
'- `SHA256SUMS.txt` - SHA-256 checksums for release artifacts.',
'',
'## Publish Channels',
'',
'- GitHub Release: Cocos Creator extension zip package.',
`- npm: \`${context.packageJson.name}@${context.version}\`.`,
`- MCP Registry: \`${context.packageJson.mcpName}\` version \`${context.version}\`.`,
'',
'## Verify',
'',
'```bash',
'shasum -a 256 -c SHA256SUMS.txt',
'```',
''
].join('\n');
}
function parseChangelogSections(notes) {
const sections = [];
let current = null;
for (const line of String(notes || '').split(/\r?\n/)) {
const heading = /^###\s+(.+?)\s*$/.exec(line);
if (heading) {
current = {
heading: heading[1].trim(),
lines: []
};
sections.push(current);
continue;
}
if (current) {
current.lines.push(line);
}
}
return sections
.map((section) => ({
heading: section.heading,
lines: trimBlankLines(section.lines)
}))
.filter((section) => section.lines.length > 0);
}
function trimBlankLines(lines) {
const trimmed = lines.slice();
while (trimmed.length && !trimmed[0].trim()) {
trimmed.shift();
}
while (trimmed.length && !trimmed[trimmed.length - 1].trim()) {
trimmed.pop();
}
return trimmed;
}
function validateArchivePaths(paths) {
const bad = [];
const prefix = `${PACKAGE_DIR_NAME}/`;
for (const archivePath of paths) {
const normalized = archivePath.replace(/\\/g, '/');
const parts = normalized.split('/').filter(Boolean);
if (!normalized.startsWith(prefix)) {
bad.push(`${archivePath} (must stay under ${PACKAGE_DIR_NAME}/)`);
continue;
}
if (parts.some((part) => part === '..' || part === '.')) {
bad.push(`${archivePath} (contains unsafe relative path segments)`);
continue;
}
if (parts.some((part) => FORBIDDEN_ARCHIVE_SEGMENTS.has(part) || FORBIDDEN_NAMES.has(part))) {
bad.push(`${archivePath} (contains forbidden release content)`);
}
}
if (bad.length > 0) {
throw new Error(`Release archive contains invalid paths:\n- ${bad.join('\n- ')}`);
}
}
function validateZipListing(zipPath) {
const result = childProcess.spawnSync('unzip', ['-Z1', zipPath], {
cwd: ROOT,
encoding: 'utf8'
});
if (result.error && result.error.code === 'ENOENT') {
console.warn('Warning: unzip is not available; skipped zip listing validation.');
return;
}
if (result.status !== 0) {
throw new Error(`Failed to inspect ${zipPath}:\n${result.stderr || result.stdout}`);
}
const listing = result.stdout
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean);
validateArchivePaths(listing);
}
function validateArchiveContent(filePaths) {
const bad = [];
for (const filePath of filePaths) {
const stat = fs.statSync(filePath);
if (stat.size > 1024 * 1024) {
continue;
}
const buffer = fs.readFileSync(filePath);
if (buffer.includes(0)) {
continue;
}
const text = buffer.toString('utf8');
for (const [label, pattern] of FORBIDDEN_CONTENT_PATTERNS) {
if (pattern.test(text)) {
bad.push(`${path.relative(ROOT, filePath)} (${label})`);
}
}
}
if (bad.length > 0) {
throw new Error(`Release archive contains sensitive-looking content:\n- ${bad.join('\n- ')}`);
}
}
function isForbiddenTrackedPath(relative) {
const parts = relative.split('/');
return parts.some((part) => FORBIDDEN_TRACKED_SEGMENTS.has(part) || FORBIDDEN_NAMES.has(part));
}
function readJson(filePath, errors) {
try {
return JSON.parse(fs.readFileSync(filePath, 'utf8'));
} catch (error) {
errors.push(`${path.relative(ROOT, filePath)} is not valid JSON: ${error.message}`);
return null;
}
}
function collectFiles(directory) {
const entries = fs.readdirSync(directory, { withFileTypes: true });
const files = [];
for (const entry of entries) {
const fullPath = path.join(directory, entry.name);
if (entry.isDirectory()) {
files.push(...collectFiles(fullPath));
} else if (entry.isFile()) {
files.push(fullPath);
}
}
return files;
}
function extractChangelogNotes(changelog, version) {
const heading = new RegExp(`^## \\[${escapeRegExp(version)}\\] - \\d{4}-\\d{2}-\\d{2}\\s*$`, 'm');
const match = heading.exec(changelog);
if (!match) {
return '';
}
const start = match.index + match[0].length;
const rest = changelog.slice(start);
const next = rest.search(/^## /m);
return (next >= 0 ? rest.slice(0, next) : rest).trim();
}
function firstMeaningfulLine(text) {
const line = text
.split(/\r?\n/)
.map((value) => value.trim())
.find((value) => value && !value.startsWith('###') && !value.startsWith('-'));
if (line) {
return line;
}
const bullet = text
.split(/\r?\n/)
.map((value) => value.trim())
.find((value) => value.startsWith('- '));
return bullet ? bullet.slice(2) : '';
}
function checksumLine(filePath, displayName) {
return `${sha256File(filePath)} ${displayName}\n`;
}
function sha256File(filePath) {
return crypto.createHash('sha256').update(fs.readFileSync(filePath)).digest('hex');
}
function ensureCommand(name) {
const result = childProcess.spawnSync(name, ['-v'], { encoding: 'utf8' });
if (result.error && result.error.code === 'ENOENT') {
throw new Error(`Required command not found: ${name}`);
}
}
function run(command, args, options = {}) {
const result = childProcess.spawnSync(command, args, {
cwd: options.cwd || ROOT,
encoding: 'utf8'
});
if (result.error) {
throw result.error;
}
if (result.status !== 0) {
throw new Error(`${command} ${args.join(' ')} failed:\n${result.stderr || result.stdout}`);
}
return result.stdout;
}
function gitLines(args) {
const text = gitText(args);
return text ? text.split(/\r?\n/).filter(Boolean) : [];
}
function gitText(args) {
const result = childProcess.spawnSync('git', args, {
cwd: ROOT,
encoding: 'utf8'
});
if (result.error || result.status !== 0) {
return '';
}
return result.stdout;
}
function gitTagExists(tag) {
const result = childProcess.spawnSync('git', ['rev-parse', '-q', '--verify', `refs/tags/${tag}`], {
cwd: ROOT,
encoding: 'utf8'
});
return result.status === 0;
}
function escapeRegExp(value) {
return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
function throwErrors(errors) {
if (errors.length > 0) {
throw new Error(`Release validation failed:\n- ${errors.join('\n- ')}`);
}
}
function printUsage() {
console.error(`Usage:
node scripts/release.js check [--version <version>] [--strict-tag]
node scripts/release.js package [--version <version>] [--strict-tag]`);
}
try {
main();
} catch (error) {
console.error(error.message);
process.exitCode = 1;
}
+37
View File
@@ -0,0 +1,37 @@
{
"$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json",
"name": "io.github.FunplayAI/funplay-cocos-mcp",
"title": "Funplay Cocos MCP",
"description": "stdio bridge for the local Cocos Creator Editor MCP server from FunplayAI/funplay-cocos-mcp.",
"repository": {
"url": "https://github.com/FunplayAI/funplay-cocos-mcp",
"source": "github"
},
"version": "0.4.0",
"packages": [
{
"registryType": "npm",
"identifier": "funplay-cocos-mcp",
"version": "0.4.0",
"transport": {
"type": "stdio"
},
"environmentVariables": [
{
"name": "FUNPLAY_COCOS_MCP_URL",
"description": "Optional local Cocos MCP HTTP endpoint. Defaults to http://127.0.0.1:8765/.",
"format": "string",
"isRequired": false,
"isSecret": false
},
{
"name": "FUNPLAY_COCOS_MCP_TIMEOUT_SECONDS",
"description": "Optional HTTP timeout per request in seconds. Defaults to 120.",
"format": "string",
"isRequired": false,
"isSecret": false
}
]
}
]
}
+16
View File
@@ -0,0 +1,16 @@
'use strict';
const assert = require('node:assert/strict');
const test = require('node:test');
const { collectUuidReferences } = require('../lib/tools/assets-advanced');
test('collectUuidReferences finds structured and literal UUID references', () => {
const refs = collectUuidReferences(JSON.stringify({
__type__: 'cc.Prefab',
sprite: { __uuid__: '2d3KcYpS5HCKb6wU0v5c9x' },
nested: [{ assetUuid: '550e8400-e29b-41d4-a716-446655440000' }],
}));
assert.equal(refs.some((ref) => ref.uuid === '2d3KcYpS5HCKb6wU0v5c9x' && ref.source === 'structured'), true);
assert.equal(refs.some((ref) => ref.uuid === '550e8400-e29b-41d4-a716-446655440000'), true);
});
+89
View File
@@ -0,0 +1,89 @@
'use strict';
const assert = require('node:assert/strict');
const test = require('node:test');
const {
broadcastEditorMessage,
getEditorPreference,
setEditorPreference,
tryEditorRequests,
tryEditorRequestsStatus,
} = require('../lib/tools/cocos-project');
test('tryEditorRequests returns the first successful editor message candidate', async () => {
const calls = [];
global.Editor = {
Message: {
request: async (channel, method, payload) => {
calls.push({ channel, method, payload });
if (method === 'bad') {
throw new Error('nope');
}
return { ok: true };
},
},
};
try {
const result = await tryEditorRequests([
{ channel: 'scene', method: 'bad' },
{ channel: 'scene', method: 'save-scene', args: [{ force: true }] },
]);
assert.equal(result.ok, true);
assert.equal(result.method, 'save-scene');
assert.equal(calls.length, 2);
} finally {
delete global.Editor;
}
});
test('tryEditorRequestsStatus returns an unavailable payload instead of throwing', async () => {
global.Editor = {
Message: {
request: async () => {
throw new Error('missing');
},
},
};
try {
const result = await tryEditorRequestsStatus([{ channel: 'builder', method: 'query-build-status' }]);
assert.equal(result.ok, false);
assert.equal(result.available, false);
assert.equal(result.attempts.length, 1);
} finally {
delete global.Editor;
}
});
test('preference helpers and broadcast use available Editor APIs', () => {
const sent = [];
const store = new Map();
global.Editor = {
Message: {
send(channel, message, payload) {
sent.push({ channel, message, payload });
},
},
Profile: {
getProject(key) {
return store.get(key);
},
setProject(key, value) {
store.set(key, value);
},
},
};
try {
setEditorPreference('project', 'preview.port', 7456);
assert.equal(getEditorPreference('project', 'preview.port'), 7456);
const result = broadcastEditorMessage({ channel: 'scene', message: 'custom-event', payload: { ok: true } });
assert.equal(result.sent, true);
assert.deepEqual(sent[0], { channel: 'scene', message: 'custom-event', payload: { ok: true } });
} finally {
delete global.Editor;
}
});
+58
View File
@@ -0,0 +1,58 @@
'use strict';
const assert = require('node:assert/strict');
const path = require('node:path');
const test = require('node:test');
const {
assertJavascriptSafety,
inspectJavascriptSafety,
} = require('../lib/javascript-safety');
const PROJECT_PATH = path.resolve('/tmp/funplay-cocos-test-project');
test('JavaScript safety allows project-local write snippets by default', () => {
const result = inspectJavascriptSafety(
"fs.writeFileSync(path.join(context.projectPath, 'assets/generated.ts'), 'export {};');",
{ projectPath: PROJECT_PATH }
);
assert.equal(result.ok, true);
});
test('JavaScript safety blocks delete operations', () => {
assert.throws(
() => assertJavascriptSafety("fs.rmSync(path.join(context.projectPath, 'assets'), { recursive: true });", {
projectPath: PROJECT_PATH,
}),
/delete\/truncate/
);
});
test('JavaScript safety blocks traversal and home path literals', () => {
const result = inspectJavascriptSafety(
"fs.writeFileSync('../outside.txt', 'x'); fs.writeFileSync('~/secret.txt', 'x');",
{ projectPath: PROJECT_PATH }
);
assert.equal(result.ok, false);
assert.equal(result.violations.some((item) => item.includes('path traversal')), true);
assert.equal(result.violations.some((item) => item.includes('user-home')), true);
});
test('JavaScript safety blocks absolute paths outside the project', () => {
const result = inspectJavascriptSafety("fs.writeFileSync('/tmp/outside.txt', 'x');", {
projectPath: PROJECT_PATH,
});
assert.equal(result.ok, false);
assert.equal(result.violations.some((item) => item.includes('absolute path outside')), true);
});
test('JavaScript safety blocks child_process usage', () => {
assert.throws(
() => assertJavascriptSafety("const cp = require('child_process'); cp.execSync('rm -rf /tmp/x');", {
projectPath: PROJECT_PATH,
}),
/child_process/
);
});
+59
View File
@@ -0,0 +1,59 @@
'use strict';
const assert = require('node:assert/strict');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const test = require('node:test');
const {
createCocosMcpProjectSkill,
createProjectSkill,
listProjectInstructions,
readProjectInstruction,
writeProjectInstruction,
} = require('../lib/project-instructions');
test('project instruction helpers list, read, and write safe project files', () => {
const projectPath = fs.mkdtempSync(path.join(os.tmpdir(), 'funplay-cocos-instructions-'));
const write = writeProjectInstruction(projectPath, {
target: 'AGENTS.md',
content: '# Agent Notes\n',
});
assert.equal(write.written, true);
assert.equal(readProjectInstruction(projectPath, 'AGENTS.md').content, '# Agent Notes\n');
const listed = listProjectInstructions(projectPath);
assert.equal(listed.files.some((file) => file.path === 'AGENTS.md'), true);
});
test('createProjectSkill writes a Codex project skill', () => {
const projectPath = fs.mkdtempSync(path.join(os.tmpdir(), 'funplay-cocos-skill-'));
const result = createProjectSkill(projectPath, {
skillName: 'scene qa',
title: 'Scene QA',
description: 'Validate Cocos scenes.',
});
assert.equal(result.path, '.codex/skills/scene-qa/SKILL.md');
const listed = listProjectInstructions(projectPath);
assert.equal(listed.skills.some((skill) => skill.path === result.path), true);
});
test('createCocosMcpProjectSkill writes the recommended MCP workflow skill', () => {
const projectPath = fs.mkdtempSync(path.join(os.tmpdir(), 'funplay-cocos-default-skill-'));
const result = createCocosMcpProjectSkill(projectPath);
assert.equal(result.path, '.codex/skills/funplay-cocos-mcp-workflow/SKILL.md');
const content = readProjectInstruction(projectPath, result.path).content;
assert.match(content, /Funplay Cocos MCP Workflow/);
assert.match(content, /inspect_asset_dependencies/);
});
test('project instruction helpers reject traversal outside the project', () => {
const projectPath = fs.mkdtempSync(path.join(os.tmpdir(), 'funplay-cocos-instructions-safe-'));
assert.throws(
() => writeProjectInstruction(projectPath, { target: '../AGENTS.md', content: 'x' }),
/outside the Cocos project/
);
});
+131 -3
View File
@@ -9,7 +9,7 @@ const {
SUPPORTED_PROTOCOL_VERSIONS, SUPPORTED_PROTOCOL_VERSIONS,
} = require('../lib/server'); } = require('../lib/server');
function createServer(toolRegistry = {}, config = {}) { function createServer(toolRegistry = {}, config = {}, options = {}) {
return new McpServer({ return new McpServer({
config: { host: '127.0.0.1', port: 8765, ...config }, config: { host: '127.0.0.1', port: 8765, ...config },
toolRegistry: { toolRegistry: {
@@ -28,8 +28,10 @@ function createServer(toolRegistry = {}, config = {}) {
}, },
interactionLog: { add() {} }, interactionLog: { add() {} },
runtimeLog: { add() {} }, runtimeLog: { add() {} },
serverName: 'test-server', serverName: options.serverName || 'test-server',
serverVersion: '0.0.0-test', serverVersion: options.serverVersion || '0.0.0-test',
projectName: options.projectName || 'test-project',
projectIdentity: options.projectIdentity || 'test-project-id',
}); });
} }
@@ -66,6 +68,33 @@ function httpJson(port, payload, headers = {}) {
}); });
} }
function httpGet(port, path = '/', headers = {}) {
return new Promise((resolve, reject) => {
const request = http.request(
{
host: '127.0.0.1',
port,
method: 'GET',
path,
headers,
},
(response) => {
const chunks = [];
response.on('data', (chunk) => chunks.push(chunk));
response.on('end', () => {
resolve({
statusCode: response.statusCode,
headers: response.headers,
body: Buffer.concat(chunks).toString('utf8'),
});
});
}
);
request.on('error', reject);
request.end();
});
}
test('initialize negotiates the current MCP protocol version by default', async () => { test('initialize negotiates the current MCP protocol version by default', async () => {
const server = createServer(); const server = createServer();
const response = await server.handleRpcRequest({ const response = await server.handleRpcRequest({
@@ -77,6 +106,7 @@ test('initialize negotiates the current MCP protocol version by default', async
assert.equal(response.result.protocolVersion, MCP_PROTOCOL_VERSION); assert.equal(response.result.protocolVersion, MCP_PROTOCOL_VERSION);
assert.equal(response.result.serverInfo.name, 'test-server'); assert.equal(response.result.serverInfo.name, 'test-server');
assert.equal(response.result.funplay.projectIdentity, 'test-project-id');
}); });
test('initialize can negotiate an older supported MCP protocol version', async () => { test('initialize can negotiate an older supported MCP protocol version', async () => {
@@ -222,6 +252,104 @@ test('HTTP notifications return 202 Accepted with no body', async () => {
} }
}); });
test('HTTP GET /tools returns debug tool metadata and curl examples', async () => {
const server = createServer({
listTools: () => [
{
name: 'get_project_info',
description: 'Return project info.',
inputSchema: { type: 'object', properties: {} },
},
],
listToolCatalog: () => [
{
name: 'get_project_info',
description: 'Return project info.',
category: 'project',
profile: 'core',
enabled: true,
},
],
}, { port: 0 });
await server.start();
try {
const response = await httpGet(server.getPort(), '/tools?catalog=1');
const payload = JSON.parse(response.body);
assert.equal(response.statusCode, 200);
assert.equal(payload.ok, true);
assert.equal(payload.name, 'test-server');
assert.equal(payload.version, '0.0.0-test');
assert.equal(payload.count, 1);
assert.equal(payload.tools[0].category, 'project');
assert.match(payload.examples.health, /curl http:\/\/127\.0\.0\.1:\d+\/health/);
assert.match(payload.examples.tools, /curl http:\/\/127\.0\.0\.1:\d+\/tools/);
} finally {
await server.stop();
}
});
test('HTTP GET /health returns project identity metadata', async () => {
const server = createServer({}, { port: 0 }, { projectIdentity: 'health-project-id' });
await server.start();
try {
const response = await httpGet(server.getPort(), '/health');
const payload = JSON.parse(response.body);
assert.equal(response.statusCode, 200);
assert.equal(payload.ok, true);
assert.equal(payload.projectName, 'test-project');
assert.equal(payload.projectIdentity, 'health-project-id');
} finally {
await server.stop();
}
});
test('start attaches to an existing same-project listener on the configured port', async () => {
const owner = createServer({}, { port: 0 }, { projectIdentity: 'same-project' });
await owner.start();
const attached = createServer({}, { port: owner.getPort() }, { projectIdentity: 'same-project' });
try {
await attached.start();
assert.equal(attached.isRunning(), true);
assert.equal(attached.getPort(), owner.getPort());
assert.equal(attached.getAttachInfo().projectIdentity, 'same-project');
await attached.stop();
assert.equal(attached.isRunning(), false);
const response = await httpGet(owner.getPort(), '/health');
assert.equal(response.statusCode, 200);
} finally {
if (attached.isRunning()) {
await attached.stop();
}
await owner.stop();
}
});
test('start falls back instead of attaching to a different project listener', async () => {
const owner = createServer({}, { port: 0 }, { projectIdentity: 'owner-project' });
await owner.start();
const contender = createServer({}, { port: owner.getPort() }, { projectIdentity: 'other-project' });
try {
await contender.start();
assert.equal(contender.isRunning(), true);
assert.notEqual(contender.getPort(), owner.getPort());
assert.equal(contender.getAttachInfo(), null);
assert.equal(contender.getPortFallbackInfo().requestedPort, owner.getPort());
} finally {
if (contender.isRunning()) {
await contender.stop();
}
await owner.stop();
}
});
test('HTTP initialize can return an optional session id when sessions are enabled', async () => { test('HTTP initialize can return an optional session id when sessions are enabled', async () => {
const server = createServer({}, { port: 0, enableSessions: true }); const server = createServer({}, { port: 0, enableSessions: true });
await server.start(); await server.start();
+176
View File
@@ -0,0 +1,176 @@
'use strict';
const assert = require('node:assert/strict');
const childProcess = require('node:child_process');
const http = require('node:http');
const path = require('node:path');
const test = require('node:test');
const ROOT = path.resolve(__dirname, '..');
const WRAPPER = path.join(ROOT, 'bin', 'funplay-cocos-mcp.js');
const PACKAGE = require('../package.json');
function writeFramed(stream, value) {
const payload = Buffer.from(JSON.stringify(value), 'utf8');
stream.write(`Content-Length: ${payload.length}\r\n\r\n`);
stream.write(payload);
}
function createFramedReader(stream) {
let buffer = Buffer.alloc(0);
const waiters = [];
function readFromBuffer() {
const marker = buffer.indexOf('\r\n\r\n');
if (marker < 0) {
return null;
}
const header = buffer.slice(0, marker).toString('ascii');
const match = /^content-length:\s*(\d+)$/im.exec(header);
if (!match) {
throw new Error(`Missing Content-Length header: ${header}`);
}
const length = Number.parseInt(match[1], 10);
const start = marker + 4;
const end = start + length;
if (buffer.length < end) {
return null;
}
const payload = buffer.slice(start, end).toString('utf8');
buffer = buffer.slice(end);
return JSON.parse(payload);
}
function pump() {
while (waiters.length > 0) {
const value = readFromBuffer();
if (value === null) {
return;
}
waiters.shift().resolve(value);
}
}
stream.on('data', (chunk) => {
buffer = Buffer.concat([buffer, chunk]);
pump();
});
stream.on('error', (error) => {
while (waiters.length > 0) {
waiters.shift().reject(error);
}
});
return function readNext() {
const value = readFromBuffer();
if (value !== null) {
return Promise.resolve(value);
}
return new Promise((resolve, reject) => {
waiters.push({ resolve, reject });
});
};
}
function createHttpProxyTarget(handler) {
const server = http.createServer(handler);
return new Promise((resolve, reject) => {
server.once('error', reject);
server.listen(0, '127.0.0.1', () => {
resolve({
server,
url: `http://127.0.0.1:${server.address().port}/`
});
});
});
}
function readBody(request) {
return new Promise((resolve, reject) => {
const chunks = [];
request.on('data', (chunk) => chunks.push(chunk));
request.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
request.on('error', reject);
});
}
function waitForExit(child) {
return new Promise((resolve, reject) => {
child.once('error', reject);
child.once('exit', (code) => resolve(code));
});
}
test('stdio wrapper prints package version', () => {
const result = childProcess.spawnSync(process.execPath, [WRAPPER, '--version'], {
cwd: ROOT,
encoding: 'utf8'
});
assert.equal(result.status, 0);
assert.match(result.stderr, new RegExp(`funplay-cocos-mcp ${PACKAGE.version}`));
});
test('stdio wrapper proxies framed JSON-RPC to the Cocos HTTP endpoint', async () => {
const requests = [];
const target = await createHttpProxyTarget(async (request, response) => {
const body = await readBody(request);
requests.push({
headers: request.headers,
body: JSON.parse(body)
});
response.setHeader('Content-Type', 'application/json');
if (requests.length === 1) {
response.setHeader('Mcp-Session-Id', 'session-1');
}
response.end(JSON.stringify({
jsonrpc: '2.0',
id: requests.length,
result: { ok: true, index: requests.length }
}));
});
const child = childProcess.spawn(process.execPath, [WRAPPER, '--url', target.url], {
cwd: ROOT,
stdio: ['pipe', 'pipe', 'pipe']
});
const readNext = createFramedReader(child.stdout);
try {
writeFramed(child.stdin, {
jsonrpc: '2.0',
id: 1,
method: 'initialize',
params: {}
});
assert.deepEqual(await readNext(), {
jsonrpc: '2.0',
id: 1,
result: { ok: true, index: 1 }
});
writeFramed(child.stdin, {
jsonrpc: '2.0',
id: 2,
method: 'tools/list'
});
assert.deepEqual(await readNext(), {
jsonrpc: '2.0',
id: 2,
result: { ok: true, index: 2 }
});
assert.equal(requests.length, 2);
assert.equal(requests[0].headers.accept, 'application/json, text/event-stream');
assert.equal(requests[1].headers['mcp-session-id'], 'session-1');
assert.equal(requests[1].body.method, 'tools/list');
} finally {
child.stdin.end();
await waitForExit(child);
await new Promise((resolve) => target.server.close(resolve));
}
});
+66
View File
@@ -0,0 +1,66 @@
'use strict';
const assert = require('node:assert/strict');
const test = require('node:test');
const {
applyToolProfile,
createToolProfileSnapshot,
deleteToolProfile,
exportToolProfiles,
importToolProfiles,
normalizeSavedToolProfiles,
upsertToolProfile,
} = require('../lib/tool-profiles');
test('tool profiles normalize, upsert, and dedupe by name', () => {
const profiles = normalizeSavedToolProfiles([
{ name: 'QA', toolProfile: 'custom', enabledToolCategories: 'assets\nlogs' },
{ name: 'qa', toolProfile: 'full', disabledTools: ['delete_asset'] },
{ name: '' },
]);
assert.equal(profiles.length, 1);
assert.equal(profiles[0].name, 'qa');
assert.equal(profiles[0].toolProfile, 'full');
const updated = upsertToolProfile(profiles, {
name: 'Prototype',
toolProfile: 'custom',
enabledToolCategories: ['ui'],
});
assert.equal(updated.length, 2);
assert.equal(updated.some((profile) => profile.name === 'Prototype'), true);
});
test('tool profiles snapshot and apply exposure config', () => {
const snapshot = createToolProfileSnapshot({
toolProfile: 'custom',
enabledToolCategories: ['assets'],
disabledToolCategories: ['input'],
enabledTools: ['write_file'],
disabledTools: ['delete_asset'],
}, 'Asset QA');
const applied = applyToolProfile({ port: 8765 }, snapshot);
assert.equal(applied.port, 8765);
assert.equal(applied.activeToolProfileName, 'Asset QA');
assert.deepEqual(applied.enabledToolCategories, ['assets']);
assert.deepEqual(applied.disabledTools, ['delete_asset']);
});
test('tool profiles import, export, and delete', () => {
const imported = importToolProfiles([], JSON.stringify({
version: 1,
profiles: [
{ name: 'Core QA', toolProfile: 'core' },
{ name: 'Debug', toolProfile: 'custom', enabledToolCategories: ['logs', 'diagnostics'] },
],
}));
assert.equal(imported.length, 2);
assert.deepEqual(exportToolProfiles(imported).profiles.map((profile) => profile.name), ['Core QA', 'Debug']);
const remaining = deleteToolProfile(imported, 'Debug');
assert.deepEqual(remaining.map((profile) => profile.name), ['Core QA']);
});
+70 -7
View File
@@ -5,7 +5,7 @@ const path = require('node:path');
const test = require('node:test'); const test = require('node:test');
const { createToolRegistry } = require('../lib/tool-registry'); const { createToolRegistry } = require('../lib/tool-registry');
function createRegistry(profile, projectPath = path.resolve('/tmp/funplay-cocos-test-project'), configExtras = {}) { function createRegistry(profile, projectPath = path.resolve('/tmp/funplay-cocos-test-project'), configExtras = {}, overrides = {}) {
return createToolRegistry({ return createToolRegistry({
getRuntimeContext: () => ({ getRuntimeContext: () => ({
config: { toolProfile: profile, ...configExtras }, config: { toolProfile: profile, ...configExtras },
@@ -13,31 +13,48 @@ function createRegistry(profile, projectPath = path.resolve('/tmp/funplay-cocos-
version: '0.0.0-test', version: '0.0.0-test',
}), }),
interactionLog: { add() {} }, interactionLog: { add() {} },
runtimeLog: { list: () => [], clear: () => 0 }, runtimeLog: { add() {}, list: () => [], clear: () => 0 },
sceneBridge: { call: async () => ({ ok: true }) }, sceneBridge: overrides.sceneBridge || { call: async () => ({ ok: true }) },
editorExecutor: async () => ({ ok: true }), editorExecutor: overrides.editorExecutor || (async () => ({ ok: true })),
}); });
} }
test('core profile exposes the documented focused tool set', () => { test('core profile exposes the documented focused tool set', () => {
const tools = createRegistry('core').listTools(); const tools = createRegistry('core').listTools();
assert.equal(tools.length, 28); assert.equal(tools.length, 37);
assert.equal(tools.some((tool) => tool.name === 'execute_javascript'), true); assert.equal(tools.some((tool) => tool.name === 'execute_javascript'), true);
assert.equal(tools.some((tool) => tool.name === 'get_editor_state'), true); assert.equal(tools.some((tool) => tool.name === 'get_editor_state'), true);
assert.equal(tools.some((tool) => tool.name === 'get_tool_catalog'), true); assert.equal(tools.some((tool) => tool.name === 'get_tool_catalog'), true);
assert.equal(tools.some((tool) => tool.name === 'validate_scene'), true); assert.equal(tools.some((tool) => tool.name === 'validate_scene'), true);
assert.equal(tools.some((tool) => tool.name === 'inspect_asset_dependencies'), true);
assert.equal(tools.some((tool) => tool.name === 'get_build_status'), true);
assert.equal(tools.some((tool) => tool.name === 'get_performance_snapshot'), true);
assert.equal(tools.some((tool) => tool.name === 'list_project_instructions'), true);
assert.equal(tools.some((tool) => tool.name === 'set_selection'), true); assert.equal(tools.some((tool) => tool.name === 'set_selection'), true);
assert.equal(tools.some((tool) => tool.name === 'write_file'), false); assert.equal(tools.some((tool) => tool.name === 'write_file'), false);
}); });
test('full profile exposes all built-in tools', () => { test('full profile exposes all built-in tools', () => {
const tools = createRegistry('full').listTools(); const tools = createRegistry('full').listTools();
assert.equal(tools.length, 76); assert.equal(tools.length, 101);
assert.equal(tools.some((tool) => tool.name === 'write_file'), true); assert.equal(tools.some((tool) => tool.name === 'write_file'), true);
assert.equal(tools.some((tool) => tool.name === 'edit_prefab_json'), true);
assert.equal(tools.some((tool) => tool.name === 'create_project_skill'), true);
assert.equal(tools.some((tool) => tool.name === 'create_cocos_mcp_project_skill'), true);
assert.equal(tools.some((tool) => tool.name === 'bind_button_click_event'), true);
assert.equal(tools.some((tool) => tool.name === 'open_build_panel'), true);
assert.equal(tools.some((tool) => tool.name === 'broadcast_editor_message'), true);
assert.equal(tools.some((tool) => tool.name === 'get_editor_state'), true); assert.equal(tools.some((tool) => tool.name === 'get_editor_state'), true);
assert.equal(tools.some((tool) => tool.name === 'set_selection'), true); assert.equal(tools.some((tool) => tool.name === 'set_selection'), true);
}); });
test('tool definitions include MCP outputSchema and annotations', () => {
const tool = createRegistry('core').listTools().find((item) => item.name === 'get_project_info');
assert.equal(tool.outputSchema.type, 'object');
assert.equal(tool.outputSchema.properties.ok.type, 'boolean');
assert.equal(tool.annotations.readOnlyHint, true);
});
test('custom profile can expose a category and disable a specific tool', () => { test('custom profile can expose a category and disable a specific tool', () => {
const tools = createRegistry('custom', path.resolve('/tmp/funplay-cocos-test-project'), { const tools = createRegistry('custom', path.resolve('/tmp/funplay-cocos-test-project'), {
enabledToolCategories: ['files'], enabledToolCategories: ['files'],
@@ -69,6 +86,52 @@ test('file tools reject writes outside the project root', async () => {
test('callToolDetailed preserves structured values and text output', async () => { test('callToolDetailed preserves structured values and text output', async () => {
const registry = createRegistry('core'); const registry = createRegistry('core');
const result = await registry.callToolDetailed('get_project_info', {}); const result = await registry.callToolDetailed('get_project_info', {});
assert.equal(result.value.projectPath, path.resolve('/tmp/funplay-cocos-test-project')); assert.equal(result.value.ok, true);
assert.equal(result.value.tool, 'get_project_info');
assert.equal(result.value.data.projectPath, path.resolve('/tmp/funplay-cocos-test-project'));
assert.match(result.value.callId, /^fp_/);
assert.match(result.text, /projectPath/); assert.match(result.text, /projectPath/);
}); });
test('callToolDetailed preserves screenshot image text while keeping structured envelope small', async () => {
const dataUri = 'data:image/png;base64,AAAA';
const registry = createRegistry('core', path.resolve('/tmp/funplay-cocos-test-project'), {}, {
editorExecutor: async () => dataUri,
});
const result = await registry.callToolDetailed('execute_javascript', { context: 'editor', code: 'return image;' });
assert.equal(result.text, dataUri);
assert.equal(result.value.data.image, true);
assert.equal(result.value.data.mimeType, 'image/png');
});
test('execute_javascript safety checks block risky editor snippets by default', async () => {
const registry = createRegistry('core');
await assert.rejects(
() => registry.callToolDetailed('execute_javascript', {
context: 'editor',
code: "fs.rmSync(path.join(context.projectPath, 'assets'), { recursive: true });",
}),
/JavaScript safety checks blocked/
);
});
test('execute_javascript safety checks can be explicitly disabled per call', async () => {
let called = false;
const registry = createRegistry('core', path.resolve('/tmp/funplay-cocos-test-project'), {}, {
editorExecutor: async () => {
called = true;
return { ok: true };
},
});
const result = await registry.callToolDetailed('execute_javascript', {
context: 'editor',
code: "fs.rmSync(path.join(context.projectPath, 'assets'), { recursive: true });",
safety_checks: false,
});
assert.equal(called, true);
assert.equal(result.value.ok, true);
});
+83
View File
@@ -0,0 +1,83 @@
'use strict';
const assert = require('node:assert/strict');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const test = require('node:test');
const { buildSnippet, createFileTools } = require('../lib/tools/files');
function createSchema(properties, required) {
return { type: 'object', properties, required };
}
function createTools(projectPath) {
return createFileTools({
createSchema,
getRuntimeContext: () => ({ projectPath }),
});
}
function getTool(tools, name) {
const tool = tools.find((item) => item.name === name);
assert.ok(tool, `Expected ${name} to exist`);
return tool;
}
test('buildSnippet returns focused line-numbered context', () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'funplay-cocos-files-'));
try {
const filePath = path.join(root, 'sample.ts');
fs.writeFileSync(filePath, ['alpha', 'beta', 'gamma', 'delta'].join('\n'), 'utf8');
const snippet = buildSnippet(filePath, 2, 1);
assert.match(snippet, / 1 \| alpha/);
assert.match(snippet, />\s+2 \| beta/);
assert.match(snippet, / 3 \| gamma/);
assert.doesNotMatch(snippet, /delta/);
} finally {
fs.rmSync(root, { recursive: true, force: true });
}
});
test('file tools write, read, replace, search, list, and check project files', async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'funplay-cocos-files-'));
try {
fs.mkdirSync(path.join(root, 'assets'), { recursive: true });
const tools = createTools(root);
const writeResult = await getTool(tools, 'write_file').handler({
path: 'assets/player.ts',
content: 'const name = "Hero";\nconst clone = "Hero";\n',
});
assert.match(writeResult, /Wrote \d+ chars/);
const readResult = await getTool(tools, 'read_file').handler({ path: 'assets/player.ts' });
assert.match(readResult, /const name = "Hero"/);
await getTool(tools, 'replace_in_file').handler({
path: 'assets/player.ts',
search: 'Hero',
replace: 'Player',
replaceAll: true,
});
assert.equal(fs.readFileSync(path.join(root, 'assets', 'player.ts'), 'utf8').includes('Hero'), false);
const searchResult = await getTool(tools, 'search_files').handler({ pattern: '*.ts', directory: 'assets' });
assert.deepEqual(searchResult.files, ['assets/player.ts']);
const listResult = await getTool(tools, 'list_directory').handler({ path: 'assets' });
assert.deepEqual(listResult.entries, [{ name: 'player.ts', type: 'file' }]);
const existsResult = await getTool(tools, 'exists').handler({ path: 'assets/player.ts' });
assert.deepEqual(existsResult, {
path: 'assets/player.ts',
exists: true,
isFile: true,
isDirectory: false,
});
} finally {
fs.rmSync(root, { recursive: true, force: true });
}
});