17 Commits
Author SHA1 Message Date
winlifes a9b539ca9c docs: add Cocos Store install link to README 2026-06-14 21:23:14 -07:00
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
winlifes 3ab48e2457 Release v0.2.0 2026-05-20 06:06:36 -07:00
winlifes e01f058e3e Release v0.1.4 2026-05-11 06:17:19 -07:00
winlifes 6fc15e5ebe Tests: update tool profile expectations 2026-05-11 05:56:12 -07:00
winlifes 69ab5710f6 Release v0.1.3 2026-05-11 05:30:48 -07:00
winlifes a903f7f0af Release v0.1.2 2026-04-30 18:12:29 +08:00
winlifes 8c46313ac6 Release v0.1.1 2026-04-16 17:47:57 +08:00
winlifes be53f3c56c Improve MCP startup flow and repo automation 2026-04-16 17:45:35 +08:00
winlifes 140d6295a8 Client config: fix VS Code path by platform 2026-04-16 17:06:26 +08:00
48 changed files with 8230 additions and 419 deletions
+14
View File
@@ -0,0 +1,14 @@
## What changed
- Describe the change briefly
- Explain the user impact or motivation
## Checklist
- [ ] I tested the extension in a clean Cocos Creator 3.8+ project
- [ ] I verified `Funplay > MCP Server` opens and the MCP server can start correctly
- [ ] If I changed setup, one-click config, or port/config behavior, I verified the affected flow end-to-end
- [ ] I ran `npm run check`
- [ ] I updated docs for any user-facing behavior changes
- [ ] I did not commit local junk such as `.DS_Store`, `temp/`, or `library/`
- [ ] I updated `CHANGELOG.md` when the change affects users
+86
View File
@@ -0,0 +1,86 @@
name: CI
on:
pull_request:
push:
branches:
- main
workflow_dispatch:
jobs:
validate:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
- name: Validate repository metadata
run: |
python - <<'PY'
import json
import pathlib
import sys
root = pathlib.Path('.')
errors = []
package_path = root / 'package.json'
try:
package = json.loads(package_path.read_text(encoding='utf-8'))
except Exception as exc:
errors.append(f"package.json is not valid JSON: {exc}")
else:
for key in ('name', 'version', 'main', 'package_version'):
if key not in package or package[key] in (None, ''):
errors.append(f"package.json is missing required key: {key}")
required_docs = [
'README.md',
'README_CN.md',
'LICENSE',
'CHANGELOG.md',
'CONTRIBUTING.md',
]
for relative in required_docs:
if not (root / relative).exists():
errors.append(f"Missing required repository file: {relative}")
forbidden_paths = []
for path in root.rglob('*'):
if '.git' in path.parts:
continue
if path.name == '.DS_Store' or '.idea' in path.parts or 'library' in path.parts or 'Library' in path.parts:
forbidden_paths.append(str(path))
if forbidden_paths:
errors.append('Repository contains local junk files:\n- ' + '\n- '.join(sorted(forbidden_paths)))
if errors:
print('Validation failed:\n')
for error in errors:
print(f'- {error}')
sys.exit(1)
print('Repository validation passed.')
PY
- name: Run syntax checks
run: npm run check
- name: Run tests
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
.mcpregistry_*
node_modules/
temp/
Temp/
@@ -6,3 +7,5 @@ Library/
library/
dist/
build/
.release-tmp/
releases/
+179
View File
@@ -4,6 +4,185 @@ All notable changes to Funplay MCP for Cocos will be documented in this file.
This project follows a simple changelog format inspired by [Keep a Changelog](https://keepachangelog.com/), and uses semantic versioning when releases are tagged.
## [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
### Added
- Added a panel and tool-level update checker for comparing the installed extension version with the latest GitHub release.
- Added `custom` tool exposure with per-category and per-tool include/exclude configuration.
- Added `get_tool_catalog`, `check_for_updates`, `get_recent_logs`, `search_project_logs`, `clear_logs`, and `validate_scene`.
- Added MCP log resources: `cocos://logs/editor` and `cocos://logs/project`.
- Added in-memory runtime logs alongside the existing MCP interaction history.
- Added optional Streamable HTTP session support via `enableSessions` and `Mcp-Session-Id`.
### Changed
- Expanded the default `core` profile from 22 tools to 28 tools.
- Expanded the `full` profile from 70 tools to 76 tools.
- Updated the Cocos panel to show installed version, update status, session toggle, and tool exposure controls.
- Tightened Streamable HTTP behavior for `Accept` headers, JSON-RPC notifications/responses, `202 Accepted`, unsupported GET/SSE requests, and DELETE session termination.
### Fixed
- Improved project log tailing so trailing blank lines do not hide the last useful log entries.
## [0.1.4] - 2026-05-11
### Fixed
- Fixed CI test expectations after expanding the documented `core` and `full` tool profiles.
- Aligned the latest release line with the current tested `main` branch state.
## [0.1.3] - 2026-05-11
### Added
- Added `get_editor_state` as a compact structured editor-state summary tool.
- Added `get_selection` and `set_selection` as explicit selection workflow tools for editor-side automation.
- Added persistence for the selected one-click MCP client target in the Cocos panel configuration.
### Changed
- Expanded the default `core` profile from 19 tools to 22 tools by promoting editor-state and selection workflows.
- Expanded the `full` profile from 67 tools to 70 tools.
- Updated panel config persistence so changing the selected MCP client target no longer restarts the server unnecessarily.
## [0.1.2] - 2026-04-30
### Added
- Added Node.js unit tests for MCP protocol negotiation, tool profile exports, tool execution errors, and project file path safety.
### Changed
- Updated the MCP initialize response to negotiate protocol version `2025-11-25` by default while retaining compatibility with older supported protocol versions.
- Added `structuredContent` to tool call results when a tool returns structured JSON data.
- Changed tool execution failures to return MCP tool errors instead of JSON-RPC internal errors, improving client-side self-correction.
- Updated CI to run the new Node.js test suite.
### Security
- Restricted project file and asset-path resources to paths inside the active Cocos project root.
- Added HTTP request body size limits and invalid `Origin` header rejection for the embedded MCP server.
## [0.1.1] - 2026-04-16
### Added
- Added automatic port fallback when the configured MCP port is already occupied.
- Added actual-running-port reporting in MCP server status and panel state.
- Added `.github/pull_request_template.md` for repository contribution guidance.
- Added `.github/workflows/ci.yml` for lightweight GitHub validation.
- Added a lightweight GitHub Star promotion log after successful MCP server startup.
### Changed
- Updated one-click MCP client configuration to write the actual running server port instead of the requested port when port fallback is active.
- Updated the MCP panel status line to show configured-port to actual-port fallback information.
- Updated the English and Chinese README files to document automatic port fallback behavior.
### Fixed
- Fixed VS Code one-click configuration to use platform-specific config paths with macOS fallback behavior.
- Fixed Windows one-click MCP configuration path resolution by using a more reliable home/appdata lookup strategy.
## [0.1.0] - 2026-04-15
### Added
+119 -21
View File
@@ -8,6 +8,7 @@
<a href="#"><img src="https://img.shields.io/badge/License-MIT-blue.svg" alt="License: MIT"></a>
<a href="#"><img src="https://img.shields.io/badge/MCP-Compatible-green" alt="MCP Compatible"></a>
<a href="#"><img src="https://img.shields.io/badge/Platform-Editor%20Only-orange" alt="Editor Only"></a>
<a href="https://store.cocos.com/app/detail/8913"><img src="https://img.shields.io/badge/Cocos%20Store-Install-brightgreen" alt="Install from Cocos Store"></a>
</p>
<p align="center">
<a href="./README_CN.md">中文</a> | English
@@ -41,7 +42,9 @@ If you just want to connect quickly, do these three things:
### 1. Install as a Cocos Creator Extension
Clone or copy this repository into your Cocos project extension directory:
Recommended install: use the official [Cocos Store page](https://store.cocos.com/app/detail/8913) and install the extension into your target Cocos Creator project.
For local development or unreleased builds, clone or copy this repository into your Cocos project extension directory:
```bash
cd /path/to/your-cocos-project
@@ -51,6 +54,8 @@ git clone https://github.com/FunplayAI/funplay-cocos-mcp.git extensions/funplay-
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.
### 2. Start the MCP Server
@@ -63,12 +68,19 @@ Funplay > MCP Server
The server runs on `http://127.0.0.1:8765/` by default.
If the configured port is already occupied, the extension first checks whether the existing listener belongs to the same Cocos project. Same-project listeners are reused safely; unrelated listeners trigger automatic fallback to the next available local port.
The panel is intentionally small:
- Enable or disable the MCP server
- Change the server port
- Switch tool exposure between `core` and `full`
- Configure AI clients with one click
- Switch tool exposure between `core`, `full`, and `custom`
- Save, apply, import, and export named tool profiles
- Check the installed version against the latest GitHub release
- Inspect recent tool calls and runtime log previews
- Tune tool exposure by category 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
### 3. Configure Your AI Client
@@ -173,6 +185,31 @@ url = "http://127.0.0.1:8765/"
</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
Open your AI client and try a few safe requests first:
@@ -184,6 +221,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.
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
Try a higher-level prompt in your AI client:
@@ -194,8 +238,15 @@ Try a higher-level prompt in your AI client:
- This extension is **Editor-only**. It is meant to automate Cocos Creator, not to add runtime dependencies to your final game build.
- The MCP server listens on `http://127.0.0.1:8765/` by default.
- The default `core` profile exposes 19 high-signal tools. Switch to `full` in the panel if you want all 67 tools exposed.
- If the configured port is busy, the server automatically falls back to the next available port and the panel/client config use the actual running port.
- `GET /health` and `GET /tools` are read-only debug endpoints for quick local checks outside an MCP client.
- The default `core` profile exposes 37 high-signal tools. Switch to `full` for all 101 tools, or use `custom` to include/exclude tool categories and individual tools.
- The panel includes a manual update check against the latest GitHub release.
- Streamable HTTP responses follow the MCP transport requirements for `Accept`, `MCP-Protocol-Version`, JSON-RPC notifications/responses, and optional `Mcp-Session-Id` sessions.
- Tool listings include MCP `outputSchema` and `annotations`; structured tool results use a standard envelope with `ok`, `tool`, `callId`, `summary`, `data`, and follow-up `refs`.
- `execute_javascript` safety checks are enabled by default. They block obvious risky filesystem and shell patterns such as delete/truncate calls, raw writable streams, path traversal, user/system absolute paths, and `child_process`. This is a guardrail, not a full sandbox; a call can explicitly pass `safety_checks: false` when you have reviewed the risk.
- All exposed MCP tools execute directly. There is no extra approval toggle inside the Cocos extension.
- File tools and `cocos://asset/path/...` resources are restricted to the active Cocos project root.
- The recommended workflow is `execute_javascript` first, then focused helper tools for screenshots, diagnostics, assets, and inspection.
- If you change the server port or tool exposure in the panel, the extension saves the config and restarts the server when needed.
@@ -204,16 +255,16 @@ Try a higher-level prompt in your AI client:
- **`execute_javascript` First** — One high-flexibility JavaScript tool can orchestrate scene/runtime work and editor-side automation without flooding AI clients with too many narrow tool calls
- **Embedded Cocos Extension** — No separate Python daemon or external bridge process is required for the Cocos-side plugin
- **One-Click Client Configuration** — Configure Claude Code, Cursor, VS Code, Trae, Kiro, and Codex directly from Cocos Creator
- **Project Context Built In** — Exposes live project, scene, selection, script diagnostics, and interaction-history resources
- **Focused by Default, Full When Needed** — `core` reduces tool-list noise; `full` exposes every available tool
- **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` plus saved profiles lets you tune and restore tool exposure by category or tool
- **Visual Validation** — Scene/editor/preview screenshots and input simulation help AI verify UI and gameplay changes
## Highlights
- **67 Built-in Tools** — Scene hierarchy, assets, UI creation, components, files, script diagnostics, screenshots, runtime control, and input simulation
- **101 Built-in Tools** — Scene hierarchy, editor state, selection workflows, prefabs, assets, asset dependencies, project instructions, UI creation, components, files, logs, script diagnostics, screenshots, runtime control, build/preview helpers, editor preferences, event binding, and input simulation
- **Primary Unified Tool** — `execute_javascript` supports both `scene` and `editor` contexts
- **Resources & Prompts** — Live project 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 and MCP client setup
- **Resources & Prompts** — Live project/log resources plus reusable workflows like script fixing, scene validation, and playable prototype creation
- **Cocos Panel UI** — A compact `Funplay > MCP Server` panel for service management, update checks, tool exposure, recent activity, logs, curl diagnostics, and MCP client setup
- **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
@@ -227,20 +278,22 @@ Funplay MCP for Cocos follows the same design principles as Funplay MCP for Unit
| Embedded server | Built-in HTTP MCP server | Built-in HTTP MCP server |
| Primary execution tool | `execute_javascript` | `execute_code` |
| Primary language | JavaScript in scene/editor contexts | C# in Unity editor/runtime contexts |
| Default profile | `core` with 19 tools | `core` focused tool profile |
| Full profile | 67 tools | 79 tools |
| Default profile | `core` with 37 tools | `core` focused tool profile |
| Full profile | 101 tools plus `custom` exposure | 79 tools |
| Client setup | One-click config panel | One-click config window |
## MCP Capabilities
The current package exposes four capability layers:
- **Tools** — 19 tools in `core`, 67 tools in `full`
- **Tools** — 37 tools in `core`, 101 tools in `full`, plus `custom` include/exclude rules and saved tool profiles
- **Primary execution** — `execute_javascript` for scene/runtime and editor/browser automation
- **Prompts** — `fix_script_errors`, `create_playable_prototype`, `scene_validation`, and `auto_wire_scene`
- **Resources** — project context, scene summaries, current selection, script diagnostics, asset selection, 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_project_info`, `get_scene_info`, `get_hierarchy`, `list_scenes`, `open_scene`, `list_assets`, `inspect_asset`, `open_asset`, `select_asset`, `run_script_diagnostics`, `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
@@ -253,26 +306,31 @@ The default `core` set is intentionally small: `execute_javascript`, `execute_sc
| `cocos://selection/current` | Current editor selection |
| `cocos://selection/asset` | Current selected asset |
| `cocos://errors/scripts` | Script diagnostics |
| `cocos://logs/editor` | Recent MCP runtime logs and tool interactions |
| `cocos://logs/project` | Recent tails from common project log files |
| `cocos://mcp/interactions` | Recent MCP interaction history |
## Built-in Tools
Funplay MCP for Cocos currently ships with **67 tool functions** in the `full` profile:
Funplay MCP for Cocos currently ships with **101 tool functions** in the `full` profile:
| Category | Tools |
|----------|-------|
| **Script Execution** | `execute_javascript`, `execute_scene_script`, `execute_editor_script` |
| **Editor State** | `get_editor_state`, `get_tool_catalog`, `check_for_updates`, `get_selection`, `set_selection`, `get_editor_selection` |
| **Project Instructions** | `list_project_instructions`, `read_project_instruction`, `write_project_instruction`, `create_project_skill`, `create_cocos_mcp_project_skill` |
| **Project & Scene** | `get_project_info`, `get_scene_info`, `get_hierarchy`, `find_nodes`, `inspect_node`, `list_scenes`, `open_scene`, `run_scene_asset` |
| **Node Editing** | `create_node`, `delete_node`, `set_node_transform` |
| **Assets & Prefabs** | `list_assets`, `inspect_asset`, `open_asset`, `select_asset`, `delete_asset`, `list_prefabs`, `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` |
| **UI** | `create_canvas`, `create_label`, `create_button`, `create_sprite` |
| **Camera** | `list_cameras`, `create_camera`, `set_camera_properties` |
| **Animation** | `list_animations`, `add_animation_clip`, `play_animation`, `stop_animation` |
| **Files** | `read_file`, `get_file_snippet`, `write_file`, `replace_in_file`, `search_files`, `list_directory`, `exists`, `refresh_assets` |
| **Diagnostics** | `run_script_diagnostics`, `get_script_diagnostic_context` |
| **Diagnostics & Logs** | `run_script_diagnostics`, `get_script_diagnostic_context`, `get_recent_logs`, `search_project_logs`, `clear_logs`, `validate_scene`, `get_performance_snapshot` |
| **Build & Editor** | `get_build_status`, `open_build_panel`, `run_project_preview`, `save_current_scene`, `get_editor_preference`, `set_editor_preference`, `broadcast_editor_message` |
| **Runtime** | `get_runtime_state`, `pause_runtime`, `resume_runtime`, `set_time_scale` |
| **Interaction** | `emit_node_event`, `simulate_button_click`, `invoke_component_method`, `simulate_mouse_click`, `simulate_mouse_drag`, `simulate_key_press`, `simulate_key_combo`, `simulate_preview_input` |
| **Interaction & Events** | `emit_node_event`, `simulate_button_click`, `list_button_click_events`, `bind_button_click_event`, `invoke_component_method`, `simulate_mouse_click`, `simulate_mouse_drag`, `simulate_key_press`, `simulate_key_combo`, `simulate_preview_input` |
| **Screenshots & Windows** | `capture_desktop_screenshot`, `capture_editor_screenshot`, `capture_scene_screenshot`, `capture_game_screenshot`, `capture_preview_screenshot`, `list_editor_windows` |
## Primary Tool Examples
@@ -308,8 +366,16 @@ Place `funplay-cocos-mcp.config.json` in the Cocos project root:
"host": "127.0.0.1",
"port": 8765,
"toolProfile": "core",
"enabledToolCategories": [],
"disabledToolCategories": [],
"enabledTools": [],
"disabledTools": [],
"enableSessions": false,
"executeJavascriptSafetyChecks": true,
"autostart": true,
"maxInteractionLogEntries": 50
"maxInteractionLogEntries": 50,
"activeToolProfileName": "",
"savedToolProfiles": []
}
```
@@ -319,6 +385,8 @@ Environment variables are also supported:
- `COCOS_MCP_PORT`
- `COCOS_MCP_PROFILE`
`toolProfile: "custom"` starts from the `core` set, then adds `enabledToolCategories` / `enabledTools` and removes `disabledToolCategories` / `disabledTools`. 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
```text
@@ -335,17 +403,47 @@ Cocos Creator Extension
│ └─ Minimal MCP Server panel
└─ lib/
├─ assets, diagnostics, screenshots, input
├─ tool-profiles, javascript-safety
├─ tools/
│ ├─ files
│ ├─ assets-advanced
│ ├─ cocos-project
│ └─ scene-events
└─ server, resources, prompts, tool registry
```
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
Run a syntax check before publishing changes:
Run checks before publishing changes:
```bash
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
+119 -21
View File
@@ -8,6 +8,7 @@
<a href="#"><img src="https://img.shields.io/badge/License-MIT-blue.svg" alt="License: MIT"></a>
<a href="#"><img src="https://img.shields.io/badge/MCP-Compatible-green" alt="MCP Compatible"></a>
<a href="#"><img src="https://img.shields.io/badge/Platform-Editor%20Only-orange" alt="Editor Only"></a>
<a href="https://store.cocos.com/app/detail/8913"><img src="https://img.shields.io/badge/Cocos%20Store-Install-brightgreen" alt="Install from Cocos Store"></a>
</p>
<p align="center">
中文 | <a href="./README.md">English</a>
@@ -41,7 +42,9 @@ Funplay MCP for Cocos 是一个采用 MIT 协议的 Cocos Creator 扩展,它
### 1. 安装为 Cocos Creator 扩展
把仓库 clone 或复制到 Cocos 项目的扩展目录:
推荐安装方式:通过官方 [Cocos Store 页面](https://store.cocos.com/app/detail/8913) 安装到目标 Cocos Creator 项目。
如果要本地开发或使用未发布版本,可以把仓库 clone 或复制到 Cocos 项目的扩展目录:
```bash
cd /path/to/your-cocos-project
@@ -51,6 +54,8 @@ git clone https://github.com/FunplayAI/funplay-cocos-mcp.git extensions/funplay-
然后重启 Cocos Creator,或在编辑器里重新加载扩展。
如果不想用 git 安装,可以从 GitHub Releases 下载 `Funplay.CocosMcp.v<version>.zip`,解压后把 `funplay-cocos-mcp` 目录移动到项目的 `extensions/` 目录。
你也可以把目录复制到 Cocos Creator 的全局用户扩展目录中。
### 2. 启动 MCP Server
@@ -63,12 +68,19 @@ Funplay > MCP Server
服务默认运行在 `http://127.0.0.1:8765/`
如果配置端口已被占用,扩展会先检查已有 listener 是否属于同一个 Cocos 项目;同项目 listener 会被安全复用,无关 listener 才会自动回退到下一个可用本地端口。
面板刻意保持精简:
- 启用或停用 MCP Server
- 修改服务端口
-`core` / `full` 工具暴露模式之间切换
- 一键配置 AI 客户端
-`core` / `full` / `custom` 工具暴露模式之间切换
- 保存、套用、导入和导出命名工具 profile
- 检查当前安装版本是否落后于 GitHub 最新 Release
- 查看最近工具调用和运行日志预览
- 通过分类控制或单个工具名调整暴露范围
- 一键配置 AI 客户端,并随目标客户端预览对应配置
- 复制 `/health``/tools` 的快速 `curl` 排障命令
- 需要时再展开 Debug Output
### 3. 配置 AI 客户端
@@ -173,6 +185,31 @@ url = "http://127.0.0.1:8765/"
</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. 验证连接
先在 AI 客户端里试几个安全请求:
@@ -184,6 +221,13 @@ url = "http://127.0.0.1:8765/"
如果这些都正常返回,说明 MCP server、resources、prompts 和主执行工具已经连通。
如果要排查本地传输链路,面板可以直接复制下面的命令,也可以手动执行:
```bash
curl http://127.0.0.1:8765/health
curl http://127.0.0.1:8765/tools
```
### 5. 开始构建
可以在 AI 客户端里尝试:
@@ -194,8 +238,15 @@ url = "http://127.0.0.1:8765/"
- 这是一个 **仅限 Editor** 的扩展,用于自动化 Cocos Creator,不会给最终游戏包添加运行时依赖。
- MCP Server 默认监听 `http://127.0.0.1:8765/`
- 默认 `core` profile 暴露 19 个高频工具;如果需要完整工具集,可在面板切到 `full`,暴露全部 67 个工具
- 如果配置端口被占用,服务会先通过项目身份识别同项目已有 listener;无法确认同项目时才会自动回退到下一个可用端口,面板与一键客户端配置会使用实际运行端口
- `GET /health``GET /tools` 是只读调试端点,方便不用 MCP 客户端也能快速检查本地服务。
- 默认 `core` profile 暴露 37 个高频工具;如果需要完整工具集,可在面板切到 `full`,暴露全部 101 个工具;也可以用 `custom` 按分类或工具名增删。
- 面板提供手动更新检查,会对比当前安装版本和 GitHub 最新 Release。
- Streamable HTTP 响应已补齐 MCP 传输层要求,包括 `Accept``MCP-Protocol-Version`、JSON-RPC notification/response,以及可选 `Mcp-Session-Id` session。
- 工具列表会包含 MCP `outputSchema``annotations`;结构化工具结果统一使用包含 `ok``tool``callId``summary``data``refs` 的标准 envelope。
- `execute_javascript` 安全检查默认开启,会拦截明显高风险的文件系统和 shell 模式,例如删除/截断调用、原始写入流、路径穿越、用户/系统绝对路径和 `child_process`。这是防护栏,不是完整沙箱;确认风险后可在单次调用中显式传入 `safety_checks: false`
- 所有已暴露的 MCP 工具都会直接执行,Cocos 扩展里没有额外 approval 开关。
- 文件工具和 `cocos://asset/path/...` 资源默认只能访问当前 Cocos 项目根目录内的路径。
- 推荐工作流是优先使用 `execute_javascript`,再配合截图、诊断、资产、检查类工具。
- 如果在面板里修改端口或工具暴露模式,扩展会自动保存配置,并在需要时重启服务。
@@ -204,16 +255,16 @@ url = "http://127.0.0.1:8765/"
- **`execute_javascript` 主工具优先** — 一个高灵活度 JavaScript 工具就能编排场景/运行态和编辑器自动化,避免 AI 客户端被大量细碎工具干扰
- **嵌入式 Cocos 扩展** — Cocos 侧不需要单独 Python 守护进程或外部 bridge
- **一键客户端配置** — 在 Cocos Creator 内直接配置 Claude Code、Cursor、VS Code、Trae、Kiro、Codex
- **内建项目上下文** — 直接暴露项目、场景、选择、脚本诊断和交互历史资源
- **默认聚焦,必要时全量** — `core` 降低工具列表噪音,需要时切到 `full` 暴露全部工具
- **内建项目上下文** — 直接暴露项目、场景、选择、脚本诊断、日志和交互历史资源
- **默认聚焦,必要时全量** — `core` 降低工具列表噪音,需要时切到 `full` 暴露全部工具`custom` 与保存的 profile 可按分类或工具名调整并恢复
- **可视化验证** — 截图和输入模拟让 AI 能验证 UI 与玩法改动
## 核心特性
- **67 个内置工具** — 覆盖场景层级、资产、UI 创建、组件、文件、脚本诊断、截图、运行态控制和输入模拟
- **101 个内置工具** — 覆盖场景层级、编辑器状态、选择工作流、Prefab、资产、资产依赖、项目指令、UI 创建、组件、文件、日志、脚本诊断、截图、运行态控制、构建/预览辅助、编辑器偏好、事件绑定和输入模拟
- **统一主工具** — `execute_javascript` 同时支持 `scene``editor` 两种上下文
- **Resources 与 Prompts** — 实时项目资源,以及脚本修复、场景验证、可玩原型等可复用工作流
- **Cocos 图形面板** — `Funplay > MCP Server` 提供极简服务管理 MCP 客户端配置
- **Resources 与 Prompts** — 实时项目/日志资源,以及脚本修复、场景验证、可玩原型等可复用工作流
- **Cocos 图形面板** — `Funplay > MCP Server` 提供服务管理、更新检查、工具暴露、最近活动、日志、curl 排障和 MCP 客户端配置
- **截图与输入支持** — 支持编辑器/场景/Game/Preview 截图,以及 Electron 级鼠标键盘事件
- **厂商无关** — 兼容任意支持 HTTP JSON-RPC MCP 的 AI 客户端
@@ -227,20 +278,22 @@ Funplay MCP for Cocos 延续 Funplay MCP for Unity 的设计原则,并针对 C
| 内置服务 | 内嵌 HTTP MCP Server | 内嵌 HTTP MCP Server |
| 主执行工具 | `execute_javascript` | `execute_code` |
| 主语言 | 场景/编辑器上下文中的 JavaScript | Unity 编辑器/运行态中的 C# |
| 默认工具集 | `core`19 个工具 | 聚焦版 `core` 工具集 |
| 完整工具集 | 67 个工具 | 79 个工具 |
| 默认工具集 | `core`37 个工具 | 聚焦版 `core` 工具集 |
| 完整工具集 | 101 个工具,并支持 `custom` 暴露 | 79 个工具 |
| 客户端配置 | 一键配置面板 | 一键配置窗口 |
## MCP 能力结构
当前包提供四层能力:
- **Tools** — `core`19 个工具,`full`67 个工具
- **Tools** — `core`37 个工具,`full`101 个工具,并支持 `custom` include/exclude 规则和命名工具 profile
- **Primary execution** — `execute_javascript` 用于场景/运行态和编辑器/browser 自动化
- **Prompts** — `fix_script_errors``create_playable_prototype``scene_validation``auto_wire_scene`
- **Resources** — 项目上下文、场景摘要、当前选择、脚本诊断、资产选择和 MCP 交互历史
- **Resources** — 项目上下文、场景摘要、当前选择、脚本诊断、资产选择、日志和 MCP 交互历史
当前默认 `core` 工具集刻意保持精简,只包含:`execute_javascript``execute_scene_script``execute_editor_script``get_project_info``get_scene_info``get_hierarchy``list_scenes``open_scene``list_assets``inspect_asset``open_asset``select_asset``run_script_diagnostics``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
@@ -253,26 +306,31 @@ Funplay MCP for Cocos 延续 Funplay MCP for Unity 的设计原则,并针对 C
| `cocos://selection/current` | 当前编辑器选择 |
| `cocos://selection/asset` | 当前选中资产 |
| `cocos://errors/scripts` | 脚本诊断信息 |
| `cocos://logs/editor` | 最近 MCP 运行日志和工具交互 |
| `cocos://logs/project` | 常见项目日志文件的尾部内容 |
| `cocos://mcp/interactions` | 最近 MCP 交互历史 |
## 内置工具
Funplay MCP for Cocos 当前在 `full` profile 下提供 **67 个工具函数**
Funplay MCP for Cocos 当前在 `full` profile 下提供 **101 个工具函数**
| 分类 | 工具 |
|------|------|
| **脚本执行** | `execute_javascript`, `execute_scene_script`, `execute_editor_script` |
| **编辑器状态** | `get_editor_state`, `get_tool_catalog`, `check_for_updates`, `get_selection`, `set_selection`, `get_editor_selection` |
| **项目指令** | `list_project_instructions`, `read_project_instruction`, `write_project_instruction`, `create_project_skill`, `create_cocos_mcp_project_skill` |
| **项目与场景** | `get_project_info`, `get_scene_info`, `get_hierarchy`, `find_nodes`, `inspect_node`, `list_scenes`, `open_scene`, `run_scene_asset` |
| **节点编辑** | `create_node`, `delete_node`, `set_node_transform` |
| **资产与 Prefab** | `list_assets`, `inspect_asset`, `open_asset`, `select_asset`, `delete_asset`, `list_prefabs`, `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` |
| **UI** | `create_canvas`, `create_label`, `create_button`, `create_sprite` |
| **相机** | `list_cameras`, `create_camera`, `set_camera_properties` |
| **动画** | `list_animations`, `add_animation_clip`, `play_animation`, `stop_animation` |
| **文件** | `read_file`, `get_file_snippet`, `write_file`, `replace_in_file`, `search_files`, `list_directory`, `exists`, `refresh_assets` |
| **诊断** | `run_script_diagnostics`, `get_script_diagnostic_context` |
| **诊断与日志** | `run_script_diagnostics`, `get_script_diagnostic_context`, `get_recent_logs`, `search_project_logs`, `clear_logs`, `validate_scene`, `get_performance_snapshot` |
| **构建与编辑器** | `get_build_status`, `open_build_panel`, `run_project_preview`, `save_current_scene`, `get_editor_preference`, `set_editor_preference`, `broadcast_editor_message` |
| **运行态** | `get_runtime_state`, `pause_runtime`, `resume_runtime`, `set_time_scale` |
| **交互** | `emit_node_event`, `simulate_button_click`, `invoke_component_method`, `simulate_mouse_click`, `simulate_mouse_drag`, `simulate_key_press`, `simulate_key_combo`, `simulate_preview_input` |
| **交互与事件** | `emit_node_event`, `simulate_button_click`, `list_button_click_events`, `bind_button_click_event`, `invoke_component_method`, `simulate_mouse_click`, `simulate_mouse_drag`, `simulate_key_press`, `simulate_key_combo`, `simulate_preview_input` |
| **截图与窗口** | `capture_desktop_screenshot`, `capture_editor_screenshot`, `capture_scene_screenshot`, `capture_game_screenshot`, `capture_preview_screenshot`, `list_editor_windows` |
## 主工具示例
@@ -308,8 +366,16 @@ Editor 上下文脚本可以访问 `Editor`、`fs`、`path`、`os`、`require`
"host": "127.0.0.1",
"port": 8765,
"toolProfile": "core",
"enabledToolCategories": [],
"disabledToolCategories": [],
"enabledTools": [],
"disabledTools": [],
"enableSessions": false,
"executeJavascriptSafetyChecks": true,
"autostart": true,
"maxInteractionLogEntries": 50
"maxInteractionLogEntries": 50,
"activeToolProfileName": "",
"savedToolProfiles": []
}
```
@@ -319,6 +385,8 @@ Editor 上下文脚本可以访问 `Editor`、`fs`、`path`、`os`、`require`
- `COCOS_MCP_PORT`
- `COCOS_MCP_PROFILE`
`toolProfile: "custom"` 会从 `core` 集合开始,再加入 `enabledToolCategories` / `enabledTools`,并移除 `disabledToolCategories` / `disabledTools`。面板可以把这些暴露设置保存为命名 `savedToolProfiles`,方便恢复或分享。`enableSessions` 默认关闭,因为常规编辑器自动化不需要跨请求客户端状态。
## 架构
```text
@@ -335,17 +403,47 @@ Cocos Creator Extension
│ └─ Minimal MCP Server panel
└─ lib/
├─ assets, diagnostics, screenshots, input
├─ tool-profiles, javascript-safety
├─ tools/
│ ├─ files
│ ├─ assets-advanced
│ ├─ cocos-project
│ └─ scene-events
└─ server, resources, prompts, tool registry
```
服务使用 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
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;
});
}
+214 -32
View File
@@ -4,16 +4,20 @@ const path = require('path');
const fs = require('fs');
const os = require('os');
const manifest = require('./package.json');
const { configureTarget, getTargetStatuses } = require('./lib/client-config');
const { loadConfig, getProjectPath, getProjectName, getCocosVersion } = require('./lib/config');
const { SERVER_NAME, buildTargets, configureTarget, getTargetStatuses } = require('./lib/client-config');
const { loadConfig, getProjectPath, getProjectName, getProjectIdentity, getCocosVersion } = require('./lib/config');
const { McpServer } = require('./lib/server');
const { createToolRegistry } = require('./lib/tool-registry');
const { ResourceProvider } = require('./lib/resources');
const { PromptProvider } = require('./lib/prompts');
const { InteractionLog } = require('./lib/interaction-log');
const { RuntimeLog } = require('./lib/runtime-log');
const { checkForUpdate } = require('./lib/update-checker');
const { normalizeSavedToolProfiles } = require('./lib/tool-profiles');
const EXTENSION_NAME = manifest.name || 'funplay-cocos-mcp';
const LOG_PREFIX = '[Funplay Cocos MCP]';
const REPOSITORY_URL = 'https://github.com/FunplayAI/funplay-cocos-mcp';
const AsyncFunction = Object.getPrototypeOf(async function () {}).constructor;
class ExtensionService {
@@ -24,26 +28,42 @@ class ExtensionService {
this.resourceProvider = null;
this.promptProvider = null;
this.interactionLog = new InteractionLog();
this.runtimeLog = new RuntimeLog();
this.lastUpdateInfo = null;
}
log(level, message, details) {
if (this.runtimeLog && typeof this.runtimeLog.add === 'function') {
this.runtimeLog.add(level, message, details);
}
const output = `${LOG_PREFIX} ${message}`;
if (level === 'error') {
console.error(output);
} else if (level === 'warn') {
console.warn(output);
} else {
console.log(output);
}
}
load() {
console.log(`${LOG_PREFIX} Extension loading...`);
this.log('info', 'Extension loading...');
this.reloadRuntime();
if (this.config.autostart) {
console.log(`${LOG_PREFIX} Autostart is enabled, starting MCP server.`);
this.log('info', 'Autostart is enabled, starting MCP server.');
return this.startServer();
}
console.log(`${LOG_PREFIX} Autostart is disabled. MCP server is idle.`);
this.log('info', 'Autostart is disabled. MCP server is idle.');
return this.getStatus();
}
unload() {
console.log(`${LOG_PREFIX} Extension unloading...`);
this.log('info', 'Extension unloading...');
if (this.server) {
this.server.stop();
this.server = null;
}
console.log(`${LOG_PREFIX} Extension unloaded.`);
this.log('info', 'Extension unloaded.');
}
openPanel() {
@@ -55,11 +75,13 @@ class ExtensionService {
reloadRuntime() {
this.config = loadConfig();
console.log(
`${LOG_PREFIX} Runtime config loaded: host=${this.config.host}, port=${this.config.port}, ` +
this.interactionLog = new InteractionLog(this.config.maxInteractionLogEntries);
this.runtimeLog = new RuntimeLog(this.config.maxInteractionLogEntries);
this.log(
'info',
`Runtime config loaded: host=${this.config.host}, port=${this.config.port}, ` +
`profile=${this.config.toolProfile}, autostart=${this.config.autostart}`
);
this.interactionLog = new InteractionLog(this.config.maxInteractionLogEntries);
const sceneBridge = {
call: async (method, payload) => {
if (!global.Editor || !Editor.Message || typeof Editor.Message.request !== 'function') {
@@ -80,74 +102,114 @@ class ExtensionService {
config: this.config,
projectPath: getProjectPath(),
projectName: getProjectName(),
projectIdentity: getProjectIdentity(),
cocosVersion: getCocosVersion(),
packagePath: path.dirname(__filename),
});
this.toolRegistry = createToolRegistry({
getRuntimeContext: runtimeContext,
getStatus: () => this.getStatus(),
interactionLog: this.interactionLog,
runtimeLog: this.runtimeLog,
sceneBridge,
editorExecutor: async (payload) => await this.executeEditorScript(payload, runtimeContext),
});
this.resourceProvider = new ResourceProvider(runtimeContext, sceneBridge, this.interactionLog);
this.resourceProvider = new ResourceProvider(runtimeContext, sceneBridge, this.interactionLog, this.runtimeLog);
this.promptProvider = new PromptProvider(runtimeContext);
}
async startServer() {
if (this.server && this.server.isRunning()) {
console.log(`${LOG_PREFIX} Start requested but MCP server is already running at ${this.getStatus().url}`);
this.log('info', `Start requested but MCP server is already running at ${this.getStatus().url}`);
return this.getStatus();
}
console.log(`${LOG_PREFIX} Starting MCP server...`);
this.log('info', 'Starting MCP server...');
this.reloadRuntime();
this.server = new McpServer({
config: this.config,
interactionLog: this.interactionLog,
runtimeLog: this.runtimeLog,
toolRegistry: this.toolRegistry,
resourceProvider: this.resourceProvider,
promptProvider: this.promptProvider,
serverName: `Funplay Cocos MCP - ${getProjectName()}`,
serverVersion: manifest.version || '0.0.0',
projectName: getProjectName(),
projectIdentity: getProjectIdentity(),
});
await this.server.start();
console.log(`${LOG_PREFIX} MCP server started at ${this.getStatus().url}`);
this.log('info', `MCP server started at ${this.getStatus().url}`);
this.log('info', `If this tool saves you time, please consider giving it a Star on GitHub: ${REPOSITORY_URL}`);
return this.getStatus();
}
async stopServer() {
console.log(`${LOG_PREFIX} Stop requested.`);
this.log('info', 'Stop requested.');
if (this.server) {
await this.server.stop();
this.server = null;
console.log(`${LOG_PREFIX} MCP server stopped.`);
this.log('info', 'MCP server stopped.');
} else {
console.log(`${LOG_PREFIX} Stop requested but MCP server was not running.`);
this.log('info', 'Stop requested but MCP server was not running.');
}
return this.getStatus();
}
async restartServer() {
console.log(`${LOG_PREFIX} Restart requested.`);
this.log('info', 'Restart requested.');
await this.stopServer();
const status = await this.startServer();
console.log(`${LOG_PREFIX} Restart completed. MCP server running=${status.running}, url=${status.url}`);
this.log('info', `Restart completed. MCP server running=${status.running}, url=${status.url}`);
return status;
}
getEffectiveServerConnection() {
const port = this.server && this.server.isRunning() && typeof this.server.getPort === 'function'
? this.server.getPort()
: this.config.port;
return {
host: this.config.host,
port,
url: `http://${this.config.host}:${port}/`,
};
}
getStatus() {
const effective = this.getEffectiveServerConnection();
const fallbackInfo = this.server && this.server.isRunning() && typeof this.server.getPortFallbackInfo === 'function'
? this.server.getPortFallbackInfo()
: null;
const attachInfo = this.server && this.server.isRunning() && typeof this.server.getAttachInfo === 'function'
? this.server.getAttachInfo()
: null;
return {
running: Boolean(this.server && this.server.isRunning()),
attachedToExisting: Boolean(attachInfo),
attachInfo,
host: this.config.host,
port: this.config.port,
port: effective.port,
requestedPort: this.config.port,
portFallbackActive: Boolean(fallbackInfo),
portFallbackInfo: fallbackInfo,
toolProfile: this.config.toolProfile,
enabledTools: this.config.enabledTools,
disabledTools: this.config.disabledTools,
enabledToolCategories: this.config.enabledToolCategories,
disabledToolCategories: this.config.disabledToolCategories,
enableSessions: this.config.enableSessions,
executeJavascriptSafetyChecks: this.config.executeJavascriptSafetyChecks,
autostart: this.config.autostart,
activeToolProfileName: this.config.activeToolProfileName,
savedToolProfiles: this.config.savedToolProfiles,
version: manifest.version || '0.0.0',
projectPath: getProjectPath(),
projectName: getProjectName(),
projectIdentity: getProjectIdentity(),
cocosVersion: getCocosVersion(),
url: `http://${this.config.host}:${this.config.port}/`,
url: effective.url,
};
}
@@ -155,16 +217,22 @@ class ExtensionService {
this.ensureRuntime();
const status = this.getStatus();
const tools = this.toolRegistry.listTools();
const toolCatalog = typeof this.toolRegistry.listToolCatalog === 'function'
? this.toolRegistry.listToolCatalog()
: tools;
const resources = this.resourceProvider.listResources();
const prompts = this.promptProvider.listPrompts();
return {
status,
tools,
toolCatalog,
resources,
prompts,
recentInteractions: this.interactionLog.list(20),
recentRuntimeLogs: this.runtimeLog.list(20),
config: this.config,
updateInfo: this.lastUpdateInfo,
clientConfig: this.getClientConfig(),
clientTargets: getTargetStatuses(this.config),
};
@@ -177,10 +245,25 @@ class ExtensionService {
async callToolFromPanel(name, args) {
this.ensureRuntime();
console.log(`${LOG_PREFIX} Panel calling tool: ${name}`);
this.log('info', `Panel calling tool: ${name}`);
return await this.toolRegistry.callTool(name, args || {});
}
async checkUpdates() {
this.ensureRuntime();
this.log('info', 'Checking GitHub for newer Funplay Cocos MCP releases.');
this.lastUpdateInfo = await checkForUpdate({ currentVersion: manifest.version || '0.0.0' });
if (this.lastUpdateInfo.ok) {
this.log(
'info',
`Update check completed: current=${this.lastUpdateInfo.currentVersion}, latest=${this.lastUpdateInfo.latestVersion || 'unknown'}`
);
} else {
this.log('warn', `Update check failed: ${this.lastUpdateInfo.error}`);
}
return this.getPanelState();
}
async executeEditorScript(payload, runtimeContext) {
const code = String(payload && payload.code || '');
if (!code.trim()) {
@@ -229,12 +312,26 @@ class ExtensionService {
async readResourceFromPanel(uri) {
this.ensureRuntime();
console.log(`${LOG_PREFIX} Panel reading resource: ${uri}`);
this.log('info', `Panel reading resource: ${uri}`);
return await this.resourceProvider.readResource(uri);
}
getClientConfig() {
const url = `http://${this.config.host}:${this.config.port}/`;
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 {
url,
codex: `[mcp_servers.funplay_cocos]\nurl = "${url}"\n`,
@@ -245,14 +342,48 @@ class ExtensionService {
},
},
}, 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) {
this.ensureRuntime();
console.log(`${LOG_PREFIX} Configuring MCP client target: ${targetId}`);
const result = configureTarget(this.config, targetId);
console.log(`${LOG_PREFIX} MCP client configured: ${result.name} -> ${result.configPath}`);
this.log('info', `Configuring MCP client target: ${targetId}`);
const effective = this.getEffectiveServerConnection();
if (effective.port !== this.config.port) {
this.log(
'info',
`Using actual running port ${effective.port} for MCP client configuration ` +
`(requested: ${this.config.port}).`
);
}
const result = configureTarget(
{
...this.config,
host: effective.host,
port: effective.port,
},
targetId
);
this.log('info', `MCP client configured: ${result.name} -> ${result.configPath}`);
return {
...result,
clientTargets: getTargetStatuses(this.config),
@@ -267,28 +398,76 @@ class ExtensionService {
const nextMaxEntries = partialConfig && partialConfig.maxInteractionLogEntries !== undefined
? Number(partialConfig.maxInteractionLogEntries)
: this.config.maxInteractionLogEntries;
const normalizeList = (value, fallback) => {
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 fallback || [];
};
const normalizeCategories = (value, fallback) => normalizeList(value, fallback)
.map((item) => item.toLowerCase());
const nextProfile = partialConfig && partialConfig.toolProfile
? (partialConfig.toolProfile === 'full' || partialConfig.toolProfile === 'custom' ? partialConfig.toolProfile : 'core')
: this.config.toolProfile;
const nextConfig = {
host: partialConfig && partialConfig.host ? String(partialConfig.host) : this.config.host,
port: Number.isInteger(nextPort) && nextPort > 0 && nextPort <= 65535 ? nextPort : this.config.port,
toolProfile: partialConfig && partialConfig.toolProfile
? (partialConfig.toolProfile === 'full' ? 'full' : 'core')
: this.config.toolProfile,
toolProfile: nextProfile,
enabledTools: normalizeList(partialConfig && partialConfig.enabledTools, this.config.enabledTools),
disabledTools: normalizeList(partialConfig && partialConfig.disabledTools, this.config.disabledTools),
enabledToolCategories: normalizeCategories(
partialConfig && partialConfig.enabledToolCategories,
this.config.enabledToolCategories
),
disabledToolCategories: normalizeCategories(
partialConfig && partialConfig.disabledToolCategories,
this.config.disabledToolCategories
),
enableSessions: partialConfig && typeof partialConfig.enableSessions === 'boolean'
? partialConfig.enableSessions
: this.config.enableSessions,
executeJavascriptSafetyChecks: partialConfig && typeof partialConfig.executeJavascriptSafetyChecks === 'boolean'
? partialConfig.executeJavascriptSafetyChecks
: this.config.executeJavascriptSafetyChecks,
autostart: partialConfig && typeof partialConfig.autostart === 'boolean'
? partialConfig.autostart
: this.config.autostart,
maxInteractionLogEntries: Number.isInteger(nextMaxEntries)
? Math.max(10, Math.min(500, nextMaxEntries))
: this.config.maxInteractionLogEntries,
lastClientTargetId: partialConfig && partialConfig.lastClientTargetId
? String(partialConfig.lastClientTargetId)
: this.config.lastClientTargetId,
activeToolProfileName: partialConfig && typeof partialConfig.activeToolProfileName === 'string'
? String(partialConfig.activeToolProfileName)
: this.config.activeToolProfileName,
savedToolProfiles: partialConfig && Array.isArray(partialConfig.savedToolProfiles)
? normalizeSavedToolProfiles(partialConfig.savedToolProfiles)
: this.config.savedToolProfiles,
};
const configPath = this.config.configPath;
fs.writeFileSync(configPath, JSON.stringify(nextConfig, null, 2) + '\n', 'utf8');
const wasRunning = Boolean(this.server && this.server.isRunning());
if (wasRunning) {
const requiresRestart = wasRunning && (
nextConfig.host !== this.config.host ||
nextConfig.port !== this.config.port ||
nextConfig.toolProfile !== this.config.toolProfile ||
nextConfig.enableSessions !== this.config.enableSessions ||
nextConfig.executeJavascriptSafetyChecks !== this.config.executeJavascriptSafetyChecks ||
JSON.stringify(nextConfig.enabledTools) !== JSON.stringify(this.config.enabledTools) ||
JSON.stringify(nextConfig.disabledTools) !== JSON.stringify(this.config.disabledTools) ||
JSON.stringify(nextConfig.enabledToolCategories) !== JSON.stringify(this.config.enabledToolCategories) ||
JSON.stringify(nextConfig.disabledToolCategories) !== JSON.stringify(this.config.disabledToolCategories)
);
if (requiresRestart) {
await this.stopServer();
}
this.reloadRuntime();
if (wasRunning) {
if (requiresRestart) {
await this.startServer();
}
return this.getPanelState();
@@ -338,6 +517,9 @@ module.exports = {
callToolFromPanel(name, args) {
return service.callToolFromPanel(name, args);
},
checkUpdates() {
return service.checkUpdates();
},
readResourceFromPanel(uri) {
return service.readResourceFromPanel(uri);
},
+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. |
+28
View File
@@ -157,6 +157,32 @@ function selectAsset(uuid) {
return { selected: true, uuid };
}
function selectNode(uuid) {
if (!global.Editor || !Editor.Selection || typeof Editor.Selection.select !== 'function') {
throw new Error('Editor.Selection.select is unavailable in this Cocos environment.');
}
Editor.Selection.clear('node');
Editor.Selection.select('node', uuid);
return { selected: true, uuid };
}
function clearSelection(type) {
if (!global.Editor || !Editor.Selection || typeof Editor.Selection.clear !== 'function') {
throw new Error('Editor.Selection.clear is unavailable in this Cocos environment.');
}
const normalized = String(type || 'all').trim().toLowerCase();
if (normalized === 'asset' || normalized === 'node') {
Editor.Selection.clear(normalized);
return { cleared: true, type: normalized };
}
Editor.Selection.clear('asset');
Editor.Selection.clear('node');
return { cleared: true, type: 'all' };
}
function getCurrentSelection() {
if (!global.Editor || !Editor.Selection || typeof Editor.Selection.getSelected !== 'function') {
throw new Error('Editor.Selection API is unavailable in this Cocos environment.');
@@ -170,6 +196,7 @@ function getCurrentSelection() {
}
module.exports = {
clearSelection,
deleteAsset,
getCurrentSelection,
listAssets,
@@ -179,4 +206,5 @@ module.exports = {
queryAssetMeta,
queryAssetUrl,
selectAsset,
selectNode,
};
+41 -2
View File
@@ -6,6 +6,45 @@ const path = require('path');
const SERVER_NAME = 'funplay_cocos';
function getUserHomePath() {
const home = os.homedir();
if (home) {
return home;
}
const homeDrive = process.env.HOMEDRIVE;
const homePath = process.env.HOMEPATH;
if (homeDrive && homePath) {
return `${homeDrive}${homePath}`;
}
return process.env.HOME || '';
}
function getVSCodeConfigPath(homePath) {
switch (process.platform) {
case 'win32': {
const appData = process.env.APPDATA || path.join(homePath, 'AppData', 'Roaming');
return path.join(appData, 'Code', 'User', 'mcp.json');
}
case 'darwin': {
const primaryPath = path.join(homePath, 'Library', 'Application Support', 'Code', 'User', 'mcp.json');
const primaryDirectory = path.dirname(primaryPath);
if (fs.existsSync(primaryPath) || fs.existsSync(primaryDirectory)) {
return primaryPath;
}
return path.join(homePath, '.vscode', 'mcp.json');
}
case 'linux':
return path.join(homePath, '.config', 'Code', 'User', 'mcp.json');
default:
return path.join(homePath, '.vscode', 'mcp.json');
}
}
function ensureParent(filePath) {
const dir = path.dirname(filePath);
if (dir && !fs.existsSync(dir)) {
@@ -67,7 +106,7 @@ function configureTomlTarget(target) {
}
function buildTargets(config) {
const home = os.homedir();
const home = getUserHomePath();
const url = `http://${config.host}:${config.port}/`;
return [
@@ -88,7 +127,7 @@ function buildTargets(config) {
{
id: 'vscode',
name: 'VS Code',
configPath: path.join(home, '.vscode', 'mcp.json'),
configPath: getVSCodeConfigPath(home),
rootKey: 'servers',
entry: { type: 'http', url },
},
+63 -1
View File
@@ -1,14 +1,25 @@
'use strict';
const crypto = require('crypto');
const fs = require('fs');
const path = require('path');
const { normalizeSavedToolProfiles } = require('./tool-profiles');
const DEFAULTS = {
host: '127.0.0.1',
port: 8765,
toolProfile: 'core',
enabledTools: [],
disabledTools: [],
enabledToolCategories: [],
disabledToolCategories: [],
enableSessions: false,
executeJavascriptSafetyChecks: true,
autostart: true,
maxInteractionLogEntries: 50,
lastClientTargetId: 'claude_code',
activeToolProfileName: '',
savedToolProfiles: [],
};
function getProjectPath() {
@@ -22,6 +33,19 @@ function getProjectName() {
return path.basename(getProjectPath());
}
function normalizeProjectIdentityPath(projectPath) {
const normalized = path.resolve(String(projectPath || process.cwd())).replace(/\\/g, '/');
return process.platform === 'win32' ? normalized.toLowerCase() : normalized;
}
function getProjectIdentity(projectPath = getProjectPath()) {
return crypto
.createHash('sha256')
.update(`funplay-cocos-mcp:${normalizeProjectIdentityPath(projectPath)}`)
.digest('hex')
.slice(0, 24);
}
function getCocosVersion() {
if (global.Editor && Editor.App) {
if (typeof Editor.App.version === 'string' && Editor.App.version) {
@@ -57,7 +81,31 @@ function clampPort(value) {
}
function normalizeProfile(value) {
return String(value || DEFAULTS.toolProfile).toLowerCase() === 'full' ? 'full' : 'core';
const normalized = String(value || DEFAULTS.toolProfile).toLowerCase();
if (normalized === 'full' || normalized === 'custom') {
return normalized;
}
return 'core';
}
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 normalizeClientTargetId(value) {
const normalized = String(value || '').trim();
return normalized || DEFAULTS.lastClientTargetId;
}
function loadConfig() {
@@ -71,10 +119,21 @@ function loadConfig() {
host: process.env.COCOS_MCP_HOST || fileConfig.host || DEFAULTS.host,
port: clampPort(process.env.COCOS_MCP_PORT || fileConfig.port || DEFAULTS.port),
toolProfile: normalizeProfile(process.env.COCOS_MCP_PROFILE || fileConfig.toolProfile || DEFAULTS.toolProfile),
enabledTools: normalizeStringList(fileConfig.enabledTools),
disabledTools: normalizeStringList(fileConfig.disabledTools),
enabledToolCategories: normalizeStringList(fileConfig.enabledToolCategories).map((item) => item.toLowerCase()),
disabledToolCategories: normalizeStringList(fileConfig.disabledToolCategories).map((item) => item.toLowerCase()),
enableSessions: typeof fileConfig.enableSessions === 'boolean' ? fileConfig.enableSessions : DEFAULTS.enableSessions,
executeJavascriptSafetyChecks: typeof fileConfig.executeJavascriptSafetyChecks === 'boolean'
? fileConfig.executeJavascriptSafetyChecks
: DEFAULTS.executeJavascriptSafetyChecks,
autostart: typeof fileConfig.autostart === 'boolean' ? fileConfig.autostart : DEFAULTS.autostart,
maxInteractionLogEntries: Number.isInteger(fileConfig.maxInteractionLogEntries)
? Math.max(10, Math.min(500, fileConfig.maxInteractionLogEntries))
: DEFAULTS.maxInteractionLogEntries,
lastClientTargetId: normalizeClientTargetId(fileConfig.lastClientTargetId),
activeToolProfileName: typeof fileConfig.activeToolProfileName === 'string' ? fileConfig.activeToolProfileName : '',
savedToolProfiles: normalizeSavedToolProfiles(fileConfig.savedToolProfiles),
configPath,
configError: fileConfig.__error || '',
};
@@ -84,6 +143,9 @@ module.exports = {
DEFAULTS,
getProjectPath,
getProjectName,
getProjectIdentity,
getCocosVersion,
loadConfig,
normalizeProfile,
normalizeStringList,
};
+6
View File
@@ -23,6 +23,12 @@ class InteractionLog {
return this.entries.slice(0, Math.max(1, limit));
}
clear() {
const count = this.entries.length;
this.entries.length = 0;
return count;
}
summary(limit = 20) {
const items = this.list(limit);
if (!items.length) {
+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,
};
+206
View File
@@ -0,0 +1,206 @@
'use strict';
const fs = require('fs');
const path = require('path');
const { resolveProjectPath } = require('./path-safety');
const DEFAULT_LOG_DIRS = [
'temp/logs',
'temp',
'logs',
'local/logs',
'local',
];
const LOG_EXTENSIONS = new Set(['.log', '.txt']);
const MAX_READ_BYTES = 2 * 1024 * 1024;
function normalizeLimit(value, fallback, min, max) {
const number = Number(value);
if (!Number.isFinite(number)) {
return fallback;
}
return Math.max(min, Math.min(max, Math.floor(number)));
}
function shouldSkipDirectory(name) {
return name === '.git' || name === 'node_modules' || name === 'library';
}
function isLogFile(fileName) {
return LOG_EXTENSIONS.has(path.extname(fileName).toLowerCase());
}
function safeStat(filePath) {
try {
return fs.statSync(filePath);
} catch (error) {
return null;
}
}
function collectLogFiles(rootDir, maxDepth, limit) {
const files = [];
const stack = [{ dir: rootDir, depth: 0 }];
while (stack.length && files.length < limit) {
const current = stack.pop();
let entries;
try {
entries = fs.readdirSync(current.dir, { withFileTypes: true });
} catch (error) {
continue;
}
for (const entry of entries) {
const fullPath = path.join(current.dir, entry.name);
if (entry.isDirectory()) {
if (current.depth < maxDepth && !shouldSkipDirectory(entry.name)) {
stack.push({ dir: fullPath, depth: current.depth + 1 });
}
continue;
}
if (!entry.isFile() || !isLogFile(entry.name)) {
continue;
}
const stat = safeStat(fullPath);
if (stat) {
files.push({ fullPath, size: stat.size, mtimeMs: stat.mtimeMs });
}
if (files.length >= limit) {
break;
}
}
}
return files;
}
function findProjectLogFiles(projectPath, options = {}) {
const limit = normalizeLimit(options.limit, 20, 1, 200);
const maxDepth = normalizeLimit(options.maxDepth, 2, 0, 5);
const directories = options.directory
? [options.directory]
: DEFAULT_LOG_DIRS;
const seen = new Set();
const files = [];
for (const directory of directories) {
let rootDir;
try {
rootDir = resolveProjectPath(projectPath, directory);
} catch (error) {
continue;
}
if (!fs.existsSync(rootDir) || !fs.statSync(rootDir).isDirectory()) {
continue;
}
for (const file of collectLogFiles(rootDir, maxDepth, limit)) {
if (seen.has(file.fullPath)) {
continue;
}
seen.add(file.fullPath);
files.push(file);
if (files.length >= limit) {
break;
}
}
if (files.length >= limit) {
break;
}
}
return files
.sort((left, right) => right.mtimeMs - left.mtimeMs)
.slice(0, limit);
}
function readTail(filePath, maxLines = 80) {
const stat = safeStat(filePath);
if (!stat) {
return '';
}
const size = Math.min(stat.size, MAX_READ_BYTES);
const buffer = Buffer.alloc(size);
const fd = fs.openSync(filePath, 'r');
try {
fs.readSync(fd, buffer, 0, size, stat.size - size);
} finally {
fs.closeSync(fd);
}
return buffer
.toString('utf8')
.replace(/\s+$/g, '')
.split(/\r?\n/)
.slice(-normalizeLimit(maxLines, 80, 1, 1000))
.join('\n')
.trim();
}
function getRecentProjectLogs(projectPath, options = {}) {
const maxLines = normalizeLimit(options.lines, 80, 1, 1000);
return findProjectLogFiles(projectPath, options).map((file) => ({
path: path.relative(projectPath, file.fullPath).replace(/\\/g, '/'),
size: file.size,
mtime: new Date(file.mtimeMs).toISOString(),
text: readTail(file.fullPath, maxLines),
}));
}
function searchProjectLogs(projectPath, options = {}) {
const query = String(options.query || '').trim();
if (!query) {
throw new Error('query is required.');
}
const limit = normalizeLimit(options.limit, 50, 1, 500);
const flags = options.caseSensitive ? '' : 'i';
const pattern = options.regex ? new RegExp(query, flags) : null;
const lowerQuery = query.toLowerCase();
const results = [];
for (const file of findProjectLogFiles(projectPath, { ...options, limit: normalizeLimit(options.fileLimit, 40, 1, 200) })) {
const text = readTail(file.fullPath, normalizeLimit(options.linesPerFile, 2000, 1, 10000));
const lines = text.split(/\r?\n/);
for (let index = 0; index < lines.length; index += 1) {
const line = lines[index];
const matched = pattern
? pattern.test(line)
: (options.caseSensitive ? line.includes(query) : line.toLowerCase().includes(lowerQuery));
if (!matched) {
continue;
}
results.push({
path: path.relative(projectPath, file.fullPath).replace(/\\/g, '/'),
line: index + 1,
text: line,
});
if (results.length >= limit) {
return { query, count: results.length, matches: results };
}
}
}
return { query, count: results.length, matches: results };
}
function clearProjectLogFiles(projectPath, options = {}) {
const cleared = [];
for (const file of findProjectLogFiles(projectPath, options)) {
fs.truncateSync(file.fullPath, 0);
cleared.push(path.relative(projectPath, file.fullPath).replace(/\\/g, '/'));
}
return cleared;
}
module.exports = {
findProjectLogFiles,
getRecentProjectLogs,
searchProjectLogs,
clearProjectLogFiles,
};
+36
View File
@@ -0,0 +1,36 @@
'use strict';
const path = require('path');
function normalizeRoot(projectPath) {
return path.resolve(String(projectPath || process.cwd()));
}
function isPathInside(rootPath, targetPath) {
const root = normalizeRoot(rootPath);
const target = path.resolve(String(targetPath || ''));
const relative = path.relative(root, target);
return relative === '' || (relative && !relative.startsWith('..') && !path.isAbsolute(relative));
}
function resolveProjectPath(projectPath, rawPath) {
if (!rawPath || typeof rawPath !== 'string') {
throw new Error('path is required.');
}
const root = normalizeRoot(projectPath);
const targetPath = path.isAbsolute(rawPath)
? path.resolve(rawPath)
: path.resolve(root, rawPath);
if (!isPathInside(root, targetPath)) {
throw new Error(`Path is outside the Cocos project: ${rawPath}`);
}
return targetPath;
}
module.exports = {
isPathInside,
resolveProjectPath,
};
+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,
};
+42 -2
View File
@@ -4,6 +4,8 @@ const fs = require('fs');
const path = require('path');
const { getCurrentSelection, queryAssetInfo, queryAssetMeta } = require('./assets');
const { runScriptDiagnostics } = require('./diagnostics');
const { getRecentProjectLogs } = require('./logs');
const { resolveProjectPath } = require('./path-safety');
function createResource(uri, name, description) {
return { uri, name, description, mimeType: 'text/plain' };
@@ -48,10 +50,11 @@ function summarizeSelection() {
}
class ResourceProvider {
constructor(getRuntimeContext, sceneBridge, interactionLog) {
constructor(getRuntimeContext, sceneBridge, interactionLog, runtimeLog) {
this.getRuntimeContext = getRuntimeContext;
this.sceneBridge = sceneBridge;
this.interactionLog = interactionLog;
this.runtimeLog = runtimeLog;
}
listResources() {
@@ -64,6 +67,8 @@ class ResourceProvider {
createResource('cocos://selection/current', `${projectName} Current Selection`, 'Summary of the current editor selection.'),
createResource('cocos://selection/asset', `${projectName} Selected Asset`, 'Details for the currently selected asset.'),
createResource('cocos://errors/scripts', `${projectName} Script Diagnostics`, 'Latest TypeScript diagnostic summary for the project.'),
createResource('cocos://logs/editor', `${projectName} Editor Logs`, 'Recent MCP runtime logs and tool interaction history.'),
createResource('cocos://logs/project', `${projectName} Project Logs`, 'Recent tails from common project log files.'),
createResource('cocos://mcp/interactions', `${projectName} MCP Interactions`, 'Recent MCP tool interaction summaries.'),
];
}
@@ -120,6 +125,10 @@ class ResourceProvider {
return await this.getSelectedAssetText();
case 'cocos://errors/scripts':
return await this.getScriptDiagnosticsText(projectPath);
case 'cocos://logs/editor':
return this.getEditorLogsText();
case 'cocos://logs/project':
return this.getProjectLogsText(projectPath);
case 'cocos://mcp/interactions':
return this.interactionLog.summary();
default:
@@ -199,7 +208,7 @@ class ResourceProvider {
readAssetByPath(relativePath) {
const { projectPath } = this.getRuntimeContext();
const fullPath = path.isAbsolute(relativePath) ? relativePath : path.join(projectPath, relativePath);
const fullPath = resolveProjectPath(projectPath, relativePath);
if (!fs.existsSync(fullPath)) {
return `Asset not found: ${relativePath}`;
}
@@ -246,6 +255,37 @@ class ResourceProvider {
return `Script diagnostics failed: ${error.message}`;
}
}
getEditorLogsText() {
return [
'MCP Runtime Logs',
this.runtimeLog && typeof this.runtimeLog.summary === 'function'
? this.runtimeLog.summary(80)
: 'Runtime log is unavailable.',
'',
'MCP Tool Interactions',
this.interactionLog && typeof this.interactionLog.summary === 'function'
? this.interactionLog.summary(80)
: 'Interaction log is unavailable.',
].join('\n');
}
getProjectLogsText(projectPath) {
const logs = getRecentProjectLogs(projectPath, { limit: 10, lines: 100 });
if (!logs.length) {
return 'No project log files found in common project log directories.';
}
return logs
.map((log) => [
`# ${log.path}`,
`mtime: ${log.mtime}`,
`size: ${log.size}`,
'',
log.text,
].join('\n'))
.join('\n\n---\n\n');
}
}
module.exports = {
+46
View File
@@ -0,0 +1,46 @@
'use strict';
class RuntimeLog {
constructor(limit = 200) {
this.limit = Math.max(10, Number(limit) || 200);
this.entries = [];
}
add(level, message, details) {
this.entries.unshift({
level: String(level || 'info'),
message: String(message || ''),
details: details === undefined ? null : details,
timestamp: new Date().toISOString(),
});
if (this.entries.length > this.limit) {
this.entries.length = this.limit;
}
}
list(limit = 50) {
return this.entries.slice(0, Math.max(1, Number(limit) || 50));
}
clear() {
const count = this.entries.length;
this.entries.length = 0;
return count;
}
summary(limit = 50) {
const items = this.list(limit);
if (!items.length) {
return 'No MCP runtime logs recorded yet.';
}
return items
.map((entry) => `[${entry.timestamp}] ${entry.level.toUpperCase()} ${entry.message}`)
.join('\n');
}
}
module.exports = {
RuntimeLog,
};
+615 -48
View File
@@ -1,14 +1,40 @@
'use strict';
const crypto = require('crypto');
const http = require('http');
const { safeStringify } = require('./utils');
const IMAGE_DATA_URI_PREFIX = 'data:image/png;base64,';
const LOG_PREFIX = '[Funplay Cocos MCP Server]';
const MAX_PORT_FALLBACK_ATTEMPTS = 20;
const MAX_REQUEST_BODY_BYTES = 4 * 1024 * 1024;
const MCP_PROTOCOL_VERSION = '2025-11-25';
const SUPPORTED_PROTOCOL_VERSIONS = [
MCP_PROTOCOL_VERSION,
'2025-06-18',
'2025-03-26',
'2024-11-05',
];
function json(response, statusCode, payload) {
response.writeHead(statusCode, { 'Content-Type': 'application/json; charset=utf-8' });
function responseHeaders(protocolVersion = MCP_PROTOCOL_VERSION, extraHeaders = {}) {
return {
'MCP-Protocol-Version': protocolVersion,
...extraHeaders,
};
}
function json(response, statusCode, payload, protocolVersion = MCP_PROTOCOL_VERSION, extraHeaders = {}) {
response.writeHead(statusCode, {
'Content-Type': 'application/json; charset=utf-8',
...responseHeaders(protocolVersion, extraHeaders),
});
response.end(JSON.stringify(payload));
}
function empty(response, statusCode, protocolVersion = MCP_PROTOCOL_VERSION, extraHeaders = {}) {
response.writeHead(statusCode, responseHeaders(protocolVersion, extraHeaders));
response.end();
}
function textContent(value) {
if (typeof value === 'string' && value.startsWith(IMAGE_DATA_URI_PREFIX)) {
return [
@@ -32,6 +58,22 @@ function textContent(value) {
];
}
function isStructuredValue(value) {
return value !== null && typeof value === 'object' && !Buffer.isBuffer(value);
}
function structuredContent(value) {
if (!isStructuredValue(value)) {
return null;
}
try {
return JSON.parse(safeStringify(value));
} catch (error) {
return null;
}
}
class McpServer {
constructor(options) {
this.config = options.config;
@@ -39,98 +81,593 @@ class McpServer {
this.resourceProvider = options.resourceProvider;
this.promptProvider = options.promptProvider;
this.interactionLog = options.interactionLog;
this.runtimeLog = options.runtimeLog;
this.serverName = options.serverName;
this.serverVersion = options.serverVersion;
this.projectName = options.projectName || '';
this.projectIdentity = options.projectIdentity || '';
this.server = null;
this.attached = false;
this.attachedInfo = null;
this.actualPort = null;
this.portFallbackInfo = null;
this.negotiatedProtocolVersion = MCP_PROTOCOL_VERSION;
this.enableSessions = Boolean(this.config && this.config.enableSessions);
this.sessions = new Set();
}
isRunning() {
return Boolean(this.server && this.server.listening);
return Boolean(this.attached || (this.server && this.server.listening));
}
getPort() {
if (this.attached && this.actualPort) {
return this.actualPort;
}
if (this.server && typeof this.server.address === 'function') {
const address = this.server.address();
if (address && typeof address.port === 'number') {
return address.port;
}
}
return this.actualPort || this.config.port;
}
getRequestedPort() {
return this.config.port;
}
getPortFallbackInfo() {
return this.portFallbackInfo;
}
getAttachInfo() {
return this.attachedInfo;
}
log(level, message) {
if (this.runtimeLog && typeof this.runtimeLog.add === 'function') {
this.runtimeLog.add(level, message);
}
const output = `${LOG_PREFIX} ${message}`;
if (level === 'error') {
console.error(output);
} else if (level === 'warn') {
console.warn(output);
} else {
console.log(output);
}
}
async start() {
if (this.isRunning()) {
console.log(`${LOG_PREFIX} Start skipped: already running.`);
this.log('info', 'Start skipped: already running.');
return;
}
console.log(`${LOG_PREFIX} Creating HTTP server on ${this.config.host}:${this.config.port}...`);
this.server = http.createServer(async (request, response) => {
this.actualPort = null;
this.portFallbackInfo = null;
this.attached = false;
this.attachedInfo = null;
const requestHandler = async (request, response) => {
try {
if (request.method === 'GET' && request.url === '/health') {
console.log(`${LOG_PREFIX} GET /health`);
return json(response, 200, { ok: true, name: this.serverName, version: this.serverVersion });
const requestUrl = new URL(request.url || '/', 'http://localhost');
if (request.method === 'GET' && requestUrl.pathname === '/health') {
this.log('info', 'GET /health');
return json(response, 200, {
ok: true,
name: this.serverName,
version: this.serverVersion,
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)) {
this.log('warn', `Rejected ${request.method} ${request.url}: invalid Origin header.`);
return json(response, 403, { error: 'Forbidden: invalid Origin header' }, this.negotiatedProtocolVersion);
}
if (request.method === 'DELETE') {
return this.handleDelete(request, response);
}
if (request.method === 'GET') {
this.log('warn', `Rejected ${request.method} ${request.url}: SSE GET streams are not supported.`);
return json(response, 405, { error: 'Method Not Allowed: SSE streams are not supported' }, this.negotiatedProtocolVersion);
}
if (request.method !== 'POST') {
console.warn(`${LOG_PREFIX} Rejected ${request.method} ${request.url}: method not allowed.`);
return json(response, 405, { error: 'Method Not Allowed' });
this.log('warn', `Rejected ${request.method} ${request.url}: method not allowed.`);
return json(response, 405, { error: 'Method Not Allowed' }, this.negotiatedProtocolVersion);
}
const acceptHeaderError = this.validateAcceptHeader(request);
if (acceptHeaderError) {
return json(response, 406, acceptHeaderError, this.negotiatedProtocolVersion);
}
const body = await this.readBody(request);
if (!body) {
return json(response, 400, this.createError(null, -32700, 'Parse error: empty body'));
return json(response, 400, this.createError(null, -32700, 'Parse error: empty body'), this.negotiatedProtocolVersion);
}
const rpc = JSON.parse(body);
if (rpc && rpc.method) {
console.log(`${LOG_PREFIX} RPC ${rpc.method}`);
let rpc;
try {
rpc = JSON.parse(body);
} catch (error) {
return json(response, 400, this.createError(null, -32700, `Parse error: ${error.message}`), this.negotiatedProtocolVersion);
}
if (rpc && rpc.method) {
this.log('info', `RPC ${rpc.method}`);
}
const protocolHeaderError = this.validateProtocolVersionHeader(request, rpc);
if (protocolHeaderError) {
return json(response, 400, protocolHeaderError, this.negotiatedProtocolVersion);
}
const responseProtocolVersion = this.getProtocolVersionForResponse(request, rpc);
const sessionError = this.validateSession(request, rpc);
if (sessionError) {
return json(response, sessionError.statusCode, sessionError.error, responseProtocolVersion);
}
const messageType = this.classifyJsonRpcMessage(rpc);
if (messageType === 'response') {
return empty(response, 202, responseProtocolVersion);
}
if (messageType === 'notification') {
const notificationError = this.handleRpcNotification(rpc);
if (notificationError) {
return json(response, 400, notificationError, responseProtocolVersion);
}
return empty(response, 202, responseProtocolVersion);
}
if (messageType !== 'request') {
return json(response, 400, this.createError(rpc && rpc.id, -32600, 'Invalid Request'), responseProtocolVersion);
}
const result = await this.handleRpcRequest(rpc);
if (result == null) {
response.writeHead(204);
response.end();
return;
return empty(response, 202, responseProtocolVersion);
}
return json(response, 200, result);
} catch (error) {
console.error(`${LOG_PREFIX} Request handling failed: ${error.message}`);
return json(response, 500, this.createError(null, -32603, `Internal error: ${error.message}`));
}
});
const extraHeaders = {};
if (this.enableSessions && rpc.method === 'initialize' && result && !result.error) {
const sessionId = this.createSessionId();
this.sessions.add(sessionId);
extraHeaders['Mcp-Session-Id'] = sessionId;
}
return json(response, 200, result, this.getProtocolVersionForResponse(request, rpc), extraHeaders);
} catch (error) {
this.log('error', `Request handling failed: ${error.message}`);
const statusCode = error.statusCode || 500;
const rpcCode = error.rpcCode || -32603;
const message = statusCode === 500 ? `Internal error: ${error.message}` : error.message;
return json(response, statusCode, this.createError(null, rpcCode, message), this.negotiatedProtocolVersion);
}
};
let attempt = 0;
let port = this.config.port;
let lastError = null;
while (attempt <= MAX_PORT_FALLBACK_ATTEMPTS) {
this.log('info', `Creating HTTP server on ${this.config.host}:${port}...`);
const candidate = http.createServer(requestHandler);
try {
await this.listen(candidate, port, this.config.host);
this.server = candidate;
this.actualPort = candidate.address() && typeof candidate.address().port === 'number'
? candidate.address().port
: port;
if (this.config.port !== 0 && this.actualPort !== this.config.port) {
this.portFallbackInfo = {
requestedPort: this.config.port,
actualPort: this.actualPort,
attempts: attempt,
};
this.log(
'warn',
`Port ${this.config.port} was unavailable. Fell back to ${this.actualPort}.`
);
}
this.log('info', `Listening on http://${this.config.host}:${this.actualPort}/`);
return;
} catch (error) {
lastError = error;
candidate.removeAllListeners();
if (error && error.code === 'EADDRINUSE' && port < 65535 && attempt < MAX_PORT_FALLBACK_ATTEMPTS) {
if (await this.tryAttachToExisting(port)) {
return;
}
const nextPort = port + 1;
this.log(
'warn',
`Port ${port} is already in use. Trying fallback port ${nextPort}...`
);
port = nextPort;
attempt += 1;
continue;
}
break;
}
}
this.server = null;
this.actualPort = null;
this.portFallbackInfo = null;
throw lastError || new Error('Failed to start MCP server.');
}
async stop() {
if (this.attached) {
this.log('info', `Detached from existing MCP listener on ${this.config.host}:${this.actualPort}.`);
this.attached = false;
this.attachedInfo = null;
this.actualPort = null;
this.portFallbackInfo = null;
return;
}
if (!this.server) {
this.log('info', 'Stop skipped: server object is empty.');
return;
}
this.log('info', 'Closing HTTP server...');
const active = this.server;
this.server = null;
this.actualPort = null;
this.portFallbackInfo = null;
this.attachedInfo = null;
await new Promise((resolve, reject) => {
this.server.once('error', reject);
this.server.listen(this.config.port, this.config.host, () => {
this.server.off('error', reject);
console.log(`${LOG_PREFIX} Listening on http://${this.config.host}:${this.config.port}/`);
active.close((error) => {
if (error) {
this.log('error', `Close failed: ${error.message}`);
reject(error);
return;
}
this.log('info', 'HTTP server closed.');
resolve();
});
});
}
async stop() {
if (!this.server) {
console.log(`${LOG_PREFIX} Stop skipped: server object is empty.`);
return;
async tryAttachToExisting(port) {
if (!this.projectIdentity || this.config.attachToExisting === false || port === 0) {
return false;
}
console.log(`${LOG_PREFIX} Closing HTTP server...`);
const active = this.server;
this.server = null;
await new Promise((resolve, reject) => {
active.close((error) => {
if (error) {
console.error(`${LOG_PREFIX} Close failed: ${error.message}`);
reject(error);
return;
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);
}
});
}
console.log(`${LOG_PREFIX} HTTP server closed.`);
resolve();
);
request.on('timeout', () => {
request.destroy();
resolve(null);
});
request.on('error', () => resolve(null));
request.end(body);
});
}
listen(server, port, host) {
return new Promise((resolve, reject) => {
const onError = (error) => {
server.off('listening', onListening);
reject(error);
};
const onListening = () => {
server.off('error', onError);
resolve();
};
server.once('error', onError);
server.once('listening', onListening);
server.listen(port, host);
});
}
readBody(request) {
return new Promise((resolve, reject) => {
const chunks = [];
request.on('data', (chunk) => chunks.push(chunk));
let size = 0;
request.on('data', (chunk) => {
size += chunk.length;
if (size > MAX_REQUEST_BODY_BYTES) {
const error = new Error(`Request body exceeds ${MAX_REQUEST_BODY_BYTES} bytes.`);
error.statusCode = 413;
error.rpcCode = -32600;
reject(error);
request.destroy();
return;
}
chunks.push(chunk);
});
request.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
request.on('error', reject);
});
}
isAllowedOrigin(request) {
const origin = request.headers && request.headers.origin;
if (!origin) {
return true;
}
try {
const parsed = new URL(String(origin));
const hostname = parsed.hostname.toLowerCase();
const configuredHost = String(this.config.host || '').toLowerCase();
return hostname === 'localhost'
|| hostname === '127.0.0.1'
|| hostname === '::1'
|| (configuredHost && hostname === configuredHost);
} catch (error) {
return false;
}
}
validateAcceptHeader(request) {
const header = request.headers && request.headers.accept;
if (!header) {
return this.createError(
null,
-32600,
'Missing Accept header. Streamable HTTP clients must accept application/json and text/event-stream.'
);
}
const tokens = String(Array.isArray(header) ? header.join(',') : header)
.split(',')
.map((item) => item.split(';')[0].trim().toLowerCase())
.filter(Boolean);
const hasWildcard = tokens.includes('*/*');
const hasJson = hasWildcard || tokens.includes('application/json') || tokens.includes('application/*');
const hasSse = hasWildcard || tokens.includes('text/event-stream') || tokens.includes('text/*');
if (!hasJson || !hasSse) {
return this.createError(
null,
-32600,
'Invalid Accept header. Streamable HTTP clients must accept both application/json and text/event-stream.'
);
}
return null;
}
validateProtocolVersionHeader(request, rpc) {
const header = request.headers && request.headers['mcp-protocol-version'];
if (!header || (rpc && rpc.method === 'initialize')) {
return null;
}
const version = Array.isArray(header) ? header[0] : String(header);
if (!SUPPORTED_PROTOCOL_VERSIONS.includes(version)) {
return this.createError(
rpc && rpc.id,
-32600,
`Unsupported MCP protocol version header: ${version}`
);
}
return null;
}
getProtocolVersionForResponse(request, rpc) {
if (rpc && rpc.method === 'initialize') {
return this.negotiatedProtocolVersion;
}
const header = request.headers && request.headers['mcp-protocol-version'];
const version = Array.isArray(header) ? header[0] : header ? String(header) : '';
if (SUPPORTED_PROTOCOL_VERSIONS.includes(version)) {
return version;
}
return this.negotiatedProtocolVersion;
}
validateSession(request, rpc) {
if (!this.enableSessions || (rpc && rpc.method === 'initialize')) {
return null;
}
const sessionId = this.getSessionId(request);
if (!sessionId) {
return {
statusCode: 400,
error: this.createError(rpc && rpc.id, -32600, 'Missing Mcp-Session-Id header.'),
};
}
if (!this.sessions.has(sessionId)) {
return {
statusCode: 404,
error: this.createError(rpc && rpc.id, -32001, 'Unknown or expired MCP session.'),
};
}
return null;
}
getSessionId(request) {
const value = request.headers && (request.headers['mcp-session-id'] || request.headers['Mcp-Session-Id']);
if (Array.isArray(value)) {
return value[0] || '';
}
return value ? String(value) : '';
}
createSessionId() {
if (typeof crypto.randomUUID === 'function') {
return crypto.randomUUID();
}
return crypto.randomBytes(16).toString('hex');
}
handleDelete(request, response) {
if (!this.enableSessions) {
return json(response, 405, { error: 'Method Not Allowed: MCP sessions are disabled' }, this.negotiatedProtocolVersion);
}
const sessionId = this.getSessionId(request);
if (!sessionId) {
return json(
response,
400,
this.createError(null, -32600, 'Missing Mcp-Session-Id header.'),
this.negotiatedProtocolVersion
);
}
if (!this.sessions.has(sessionId)) {
return json(
response,
404,
this.createError(null, -32001, 'Unknown or expired MCP session.'),
this.negotiatedProtocolVersion
);
}
this.sessions.delete(sessionId);
return empty(response, 202, this.negotiatedProtocolVersion);
}
classifyJsonRpcMessage(message) {
if (!message || message.jsonrpc !== '2.0') {
return 'invalid';
}
if (typeof message.method === 'string') {
return Object.prototype.hasOwnProperty.call(message, 'id') ? 'request' : 'notification';
}
if (
Object.prototype.hasOwnProperty.call(message, 'id') &&
(Object.prototype.hasOwnProperty.call(message, 'result') || Object.prototype.hasOwnProperty.call(message, 'error'))
) {
return 'response';
}
return 'invalid';
}
handleRpcNotification(notification) {
if (!notification || notification.jsonrpc !== '2.0' || typeof notification.method !== 'string') {
return this.createError(null, -32600, 'Invalid Request');
}
if (notification.method.startsWith('notifications/')) {
return null;
}
return this.createError(null, -32601, `Notification method not found: ${notification.method}`);
}
async handleRpcRequest(request) {
if (!request || request.jsonrpc !== '2.0') {
return this.createError(request && request.id, -32600, 'Invalid Request');
@@ -142,12 +679,18 @@ class McpServer {
}
if (method === 'initialize') {
this.negotiatedProtocolVersion = this.negotiateProtocolVersion(request.params && request.params.protocolVersion);
return this.createResult(request.id, {
protocolVersion: '2024-11-05',
protocolVersion: this.negotiatedProtocolVersion,
serverInfo: {
name: this.serverName,
version: this.serverVersion,
},
funplay: {
server: 'funplay-cocos-mcp',
projectName: this.projectName,
projectIdentity: this.projectIdentity,
},
capabilities: {
tools: {},
resources: {},
@@ -171,10 +714,25 @@ class McpServer {
}
try {
const output = await this.toolRegistry.callTool(params.name, params.arguments || {});
return this.createResult(request.id, { content: textContent(output) });
const output = typeof this.toolRegistry.callToolDetailed === 'function'
? await this.toolRegistry.callToolDetailed(params.name, params.arguments || {})
: { value: null, text: await this.toolRegistry.callTool(params.name, params.arguments || {}) };
const result = { content: textContent(output.text) };
const structured = structuredContent(output.value);
if (structured) {
result.structuredContent = structured;
}
return this.createResult(request.id, result);
} catch (error) {
return this.createError(request.id, -32603, error.message);
const result = {
content: textContent(error.message),
isError: true,
};
const structured = structuredContent(error.toolEnvelope);
if (structured) {
result.structuredContent = structured;
}
return this.createResult(request.id, result);
}
}
@@ -209,6 +767,13 @@ class McpServer {
return this.createError(request.id, -32601, `Method not found: ${method}`);
}
negotiateProtocolVersion(clientVersion) {
if (SUPPORTED_PROTOCOL_VERSIONS.includes(clientVersion)) {
return clientVersion;
}
return MCP_PROTOCOL_VERSION;
}
createResult(id, result) {
return {
jsonrpc: '2.0',
@@ -231,4 +796,6 @@ class McpServer {
module.exports = {
McpServer,
MCP_PROTOCOL_VERSION,
SUPPORTED_PROTOCOL_VERSIONS,
};
+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,
};
+836 -281
View File
File diff suppressed because it is too large Load Diff
+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,
};
+109
View File
@@ -0,0 +1,109 @@
'use strict';
const https = require('https');
const LATEST_RELEASE_URL = 'https://api.github.com/repos/FunplayAI/funplay-cocos-mcp/releases/latest';
function normalizeVersion(value) {
return String(value || '')
.trim()
.replace(/^v/i, '')
.split(/[+-]/)[0];
}
function parseVersion(value) {
return normalizeVersion(value)
.split('.')
.map((part) => Number.parseInt(part, 10))
.map((part) => (Number.isFinite(part) ? part : 0));
}
function compareVersions(left, right) {
const leftParts = parseVersion(left);
const rightParts = parseVersion(right);
const length = Math.max(leftParts.length, rightParts.length, 3);
for (let index = 0; index < length; index += 1) {
const leftPart = leftParts[index] || 0;
const rightPart = rightParts[index] || 0;
if (leftPart > rightPart) return 1;
if (leftPart < rightPart) return -1;
}
return 0;
}
function fetchJson(url, timeoutMs = 5000) {
return new Promise((resolve, reject) => {
const request = https.get(
url,
{
headers: {
Accept: 'application/vnd.github+json',
'User-Agent': 'funplay-cocos-mcp-update-checker',
},
timeout: timeoutMs,
},
(response) => {
const chunks = [];
response.on('data', (chunk) => chunks.push(chunk));
response.on('end', () => {
const body = Buffer.concat(chunks).toString('utf8');
if (response.statusCode < 200 || response.statusCode >= 300) {
reject(new Error(`GitHub returned HTTP ${response.statusCode}: ${body.slice(0, 160)}`));
return;
}
try {
resolve(JSON.parse(body));
} catch (error) {
reject(new Error(`Failed to parse GitHub response: ${error.message}`));
}
});
}
);
request.on('timeout', () => {
request.destroy(new Error(`Update check timed out after ${timeoutMs}ms.`));
});
request.on('error', reject);
});
}
async function checkForUpdate(options = {}) {
const currentVersion = normalizeVersion(options.currentVersion || '0.0.0');
const checkedAt = new Date().toISOString();
try {
const release = await fetchJson(options.url || LATEST_RELEASE_URL, options.timeoutMs || 5000);
const latestVersion = normalizeVersion(release.tag_name || release.name || '');
const comparison = latestVersion ? compareVersions(latestVersion, currentVersion) : 0;
return {
ok: true,
checkedAt,
currentVersion,
latestVersion,
updateAvailable: comparison > 0,
releaseUrl: release.html_url || '',
publishedAt: release.published_at || '',
source: options.url || LATEST_RELEASE_URL,
};
} catch (error) {
return {
ok: false,
checkedAt,
currentVersion,
latestVersion: '',
updateAvailable: false,
releaseUrl: '',
publishedAt: '',
source: options.url || LATEST_RELEASE_URL,
error: error.message,
};
}
}
module.exports = {
LATEST_RELEASE_URL,
checkForUpdate,
compareVersions,
normalizeVersion,
};
+57 -2
View File
@@ -1,13 +1,63 @@
{
"name": "funplay-cocos-mcp",
"package_version": 2,
"version": "0.1.0",
"version": "0.4.0",
"description": "Embedded MCP server for Cocos Creator with scene script execution, project resources, prompts, and file/scene tools.",
"author": "Funplay",
"license": "MIT",
"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": {
"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/prompts.js && node --check lib/resources.js && node --check lib/screenshots.js && node --check lib/server.js && node --check lib/tool-registry.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",
"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": {
"default": {
@@ -76,6 +126,11 @@
"callToolFromPanel"
]
},
"check-updates": {
"methods": [
"checkUpdates"
]
},
"read-resource": {
"methods": [
"readResourceFromPanel"
+612 -9
View File
@@ -19,7 +19,7 @@ module.exports = Editor.Panel.define({
<header class="hero">
<div>
<h1>Funplay Cocos MCP</h1>
<p>Operate the embedded MCP server from Cocos Creator.</p>
<p id="versionText">Version</p>
</div>
<div class="status-pill" id="statusPill">Unknown</div>
</header>
@@ -34,6 +34,9 @@ module.exports = Editor.Panel.define({
</label>
<ui-button id="restartBtn">Restart</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>
</div>
<div class="grid">
<label>Server Port <ui-num-input id="portInput"></ui-num-input></label>
@@ -41,12 +44,65 @@ module.exports = Editor.Panel.define({
<ui-select id="profileSelect">
<option value="core">core</option>
<option value="full">full</option>
<option value="custom">custom</option>
</ui-select>
</label>
<label class="checkbox-line">
<ui-checkbox id="sessionsInput"></ui-checkbox>
MCP Sessions
</label>
<label class="checkbox-line">
<ui-checkbox id="javascriptSafetyInput"></ui-checkbox>
JavaScript Safety Checks
</label>
</div>
<div id="updateStatus" class="client-status muted"></div>
<p>Changes auto-save. Port/profile changes restart the server when needed.</p>
</section>
<section class="card">
<h2>Tool Manager</h2>
<div id="toolSummary" class="status-line muted"></div>
<div class="row tool-presets">
<ui-button id="useCoreBtn">Core</ui-button>
<ui-button id="useFullBtn">Full</ui-button>
<ui-button id="useCustomBtn">Custom</ui-button>
</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">
<label>Enabled Categories <ui-textarea id="enabledCategoriesInput"></ui-textarea></label>
<label>Disabled Categories <ui-textarea id="disabledCategoriesInput"></ui-textarea></label>
<label>Enabled Tools <ui-textarea id="enabledToolsInput"></ui-textarea></label>
<label>Disabled Tools <ui-textarea id="disabledToolsInput"></ui-textarea></label>
</div>
</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">
<h2>MCP Client Config</h2>
<div class="row">
@@ -91,6 +147,12 @@ module.exports = Editor.Panel.define({
margin: 0 0 10px 0;
font-size: 15px;
}
h3 {
margin: 0 0 6px 0;
font-size: 12px;
font-weight: 600;
color: var(--color-normal-contrast-weak);
}
p {
margin: 4px 0 0 0;
color: var(--color-normal-contrast-weakest);
@@ -129,6 +191,60 @@ module.exports = Editor.Panel.define({
gap: 8px;
align-items: end;
}
.tool-config-grid {
display: grid;
grid-template-columns: repeat(2, minmax(180px, 1fr));
gap: 8px;
margin-top: 8px;
}
.profile-manager {
margin-top: 8px;
}
#toolProfileNameInput {
min-width: 150px;
}
#savedToolProfileSelect {
min-width: 150px;
}
#toolProfileImportText {
min-height: 54px;
margin-top: 8px;
}
.category-controls {
display: grid;
grid-template-columns: repeat(2, minmax(220px, 1fr));
gap: 8px;
margin-top: 10px;
}
.category-row {
border: 1px solid var(--color-normal-border);
border-radius: 6px;
padding: 8px;
background: rgba(0,0,0,0.10);
display: grid;
gap: 6px;
}
.category-heading {
display: flex;
justify-content: space-between;
align-items: center;
gap: 8px;
}
.category-name {
color: var(--color-normal-contrast);
font-weight: 600;
word-break: break-word;
}
.category-count {
color: var(--color-normal-contrast-weakest);
font-size: 11px;
white-space: nowrap;
}
.category-actions {
display: flex;
gap: 6px;
flex-wrap: wrap;
}
label {
display: flex;
flex-direction: column;
@@ -138,6 +254,7 @@ module.exports = Editor.Panel.define({
.checkbox-line {
flex-direction: row;
align-items: center;
min-height: 28px;
}
.checkbox-inline {
padding-top: 0;
@@ -169,6 +286,46 @@ module.exports = Editor.Panel.define({
white-space: pre-wrap;
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 {
display: block;
}
@@ -184,6 +341,9 @@ module.exports = Editor.Panel.define({
width: 100%;
min-height: 100px;
}
.tool-config-grid ui-textarea {
min-height: 72px;
}
pre {
min-height: 120px;
max-height: 220px;
@@ -199,20 +359,54 @@ module.exports = Editor.Panel.define({
.primary {
border-color: #4aa3ff;
}
@media (max-width: 620px) {
.activity-grid {
grid-template-columns: 1fr;
}
.category-controls {
grid-template-columns: 1fr;
}
}
`,
$: {
root: '.mcp-root',
statusPill: '#statusPill',
versionText: '#versionText',
statusText: '#statusText',
enabledInput: '#enabledInput',
portInput: '#portInput',
profileSelect: '#profileSelect',
sessionsInput: '#sessionsInput',
javascriptSafetyInput: '#javascriptSafetyInput',
restartBtn: '#restartBtn',
copyUrlBtn: '#copyUrlBtn',
copyHealthCurlBtn: '#copyHealthCurlBtn',
copyToolsCurlBtn: '#copyToolsCurlBtn',
checkUpdatesBtn: '#checkUpdatesBtn',
updateStatus: '#updateStatus',
toolSummary: '#toolSummary',
useCoreBtn: '#useCoreBtn',
useFullBtn: '#useFullBtn',
useCustomBtn: '#useCustomBtn',
toolProfileNameInput: '#toolProfileNameInput',
savedToolProfileSelect: '#savedToolProfileSelect',
saveToolProfileBtn: '#saveToolProfileBtn',
applyToolProfileBtn: '#applyToolProfileBtn',
deleteToolProfileBtn: '#deleteToolProfileBtn',
exportToolProfilesBtn: '#exportToolProfilesBtn',
importToolProfilesBtn: '#importToolProfilesBtn',
toolProfileImportText: '#toolProfileImportText',
categoryControls: '#categoryControls',
enabledCategoriesInput: '#enabledCategoriesInput',
disabledCategoriesInput: '#disabledCategoriesInput',
enabledToolsInput: '#enabledToolsInput',
disabledToolsInput: '#disabledToolsInput',
clientTargetSelect: '#clientTargetSelect',
configureClientBtn: '#configureClientBtn',
clientTargetStatus: '#clientTargetStatus',
clientConfigText: '#clientConfigText',
recentCalls: '#recentCalls',
recentLogs: '#recentLogs',
output: '#output',
},
methods: {
@@ -230,21 +424,261 @@ module.exports = Editor.Panel.define({
const config = state.config || {};
const isRunning = Boolean(status.running);
this.$.versionText.textContent = `Version ${status.version || 'unknown'}`;
this.$.statusPill.textContent = isRunning ? 'Running' : 'Stopped';
this.$.statusPill.classList.toggle('running', isRunning);
this.$.statusPill.classList.toggle('stopped', !isRunning);
this.$.statusText.textContent = `${status.url || ''} | Project: ${status.projectName || ''} | Cocos ${status.cocosVersion || ''}`;
const portText = status.portFallbackActive
? ` | Port fallback: ${status.requestedPort} -> ${status.port}`
: '';
const attachText = status.attachedToExisting ? ' | Attached listener' : '';
this.$.statusText.textContent =
`${status.url || ''} | Project: ${status.projectName || ''} | Cocos ${status.cocosVersion || ''}${portText}${attachText}`;
this.$.enabledInput.value = Boolean(isRunning || config.autostart);
this.$.portInput.value = Number(config.port || status.port || 8765);
this.$.profileSelect.value = config.toolProfile || status.toolProfile || 'core';
this.$.clientConfigText.value = state.clientConfig ? state.clientConfig.codex : '';
this.$.sessionsInput.value = Boolean(config.enableSessions || status.enableSessions);
this.$.javascriptSafetyInput.value = config.executeJavascriptSafetyChecks !== false;
this.$.enabledCategoriesInput.value = this.formatList(config.enabledToolCategories);
this.$.disabledCategoriesInput.value = this.formatList(config.disabledToolCategories);
this.$.enabledToolsInput.value = this.formatList(config.enabledTools);
this.$.disabledToolsInput.value = this.formatList(config.disabledTools);
this.renderToolProfiles();
this.renderUpdateStatus();
this.renderToolSummary();
this.renderCategoryControls();
this.renderClientTargets();
this.renderActivity();
},
formatList(value) {
return Array.isArray(value) ? value.join('\n') : '';
},
parseList(value) {
if (Array.isArray(value)) {
return value.map((item) => String(item || '').trim()).filter(Boolean);
}
return String(value || '')
.split(/[\n,]/)
.map((item) => item.trim())
.filter(Boolean);
},
normalizeToolProfile(profile) {
const name = String(profile && profile.name || '').trim();
if (!name) {
throw new Error('Profile name is required.');
}
const mode = String(profile.toolProfile || 'core').toLowerCase();
return {
name: name.slice(0, 80),
toolProfile: mode === 'full' || mode === 'custom' ? mode : 'core',
enabledToolCategories: this.parseList(profile.enabledToolCategories).map((item) => item.toLowerCase()),
disabledToolCategories: this.parseList(profile.disabledToolCategories).map((item) => item.toLowerCase()),
enabledTools: this.parseList(profile.enabledTools),
disabledTools: this.parseList(profile.disabledTools),
updatedAt: profile.updatedAt || new Date().toISOString(),
};
},
normalizeToolProfiles(value) {
const result = [];
const seen = new Set();
(Array.isArray(value) ? value : []).forEach((profile) => {
try {
const normalized = this.normalizeToolProfile(profile);
const key = normalized.name.toLowerCase();
const existing = result.findIndex((item) => item.name.toLowerCase() === key);
if (existing >= 0) {
result[existing] = normalized;
} else if (!seen.has(key)) {
seen.add(key);
result.push(normalized);
}
} catch (error) {
// Ignore malformed imported entries in the panel; backend normalization repeats this.
}
});
return result.sort((left, right) => left.name.localeCompare(right.name));
},
currentToolProfileSnapshot(name) {
return this.normalizeToolProfile({
name,
toolProfile: this.$.profileSelect.value || 'core',
enabledToolCategories: this.parseList(this.$.enabledCategoriesInput.value).map((item) => item.toLowerCase()),
disabledToolCategories: this.parseList(this.$.disabledCategoriesInput.value).map((item) => item.toLowerCase()),
enabledTools: this.parseList(this.$.enabledToolsInput.value),
disabledTools: this.parseList(this.$.disabledToolsInput.value),
});
},
getSavedToolProfiles() {
const config = this.state && this.state.config ? this.state.config : {};
return this.normalizeToolProfiles(config.savedToolProfiles || []);
},
renderToolProfiles() {
const config = this.state && this.state.config ? this.state.config : {};
const profiles = this.getSavedToolProfiles();
const selected = this.$.savedToolProfileSelect.value || config.activeToolProfileName || (profiles[0] && profiles[0].name) || '';
this.$.savedToolProfileSelect.innerHTML = '';
if (profiles.length) {
profiles.forEach((profile) => {
const option = document.createElement('option');
option.value = profile.name;
option.textContent = profile.name;
this.$.savedToolProfileSelect.appendChild(option);
});
} else {
const option = document.createElement('option');
option.value = '';
option.textContent = 'No saved profiles';
this.$.savedToolProfileSelect.appendChild(option);
}
this.$.savedToolProfileSelect.value = selected;
if (!this.$.toolProfileNameInput.value) {
this.$.toolProfileNameInput.value = selected || config.activeToolProfileName || '';
}
},
renderCategoryControls() {
const catalog = (this.state && this.state.toolCatalog) || [];
const groups = catalog.reduce((acc, tool) => {
const category = tool.category || 'other';
if (!acc[category]) {
acc[category] = { total: 0, enabled: 0 };
}
acc[category].total += 1;
if (tool.enabled) {
acc[category].enabled += 1;
}
return acc;
}, {});
this.$.categoryControls.innerHTML = '';
Object.keys(groups).sort().forEach((category) => {
const row = document.createElement('div');
row.className = 'category-row';
const heading = document.createElement('div');
heading.className = 'category-heading';
const name = document.createElement('div');
name.className = 'category-name';
name.textContent = category;
const count = document.createElement('div');
count.className = 'category-count';
count.textContent = `${groups[category].enabled}/${groups[category].total}`;
heading.appendChild(name);
heading.appendChild(count);
const actions = document.createElement('div');
actions.className = 'category-actions';
[
['enable', 'Enable'],
['disable', 'Disable'],
['clear', 'Clear'],
].forEach(([mode, label]) => {
const button = document.createElement('ui-button');
button.textContent = label;
button.dataset.category = category;
button.dataset.mode = mode;
actions.appendChild(button);
});
row.appendChild(heading);
row.appendChild(actions);
this.$.categoryControls.appendChild(row);
});
},
renderUpdateStatus() {
const update = this.state && this.state.updateInfo;
if (!update) {
this.$.updateStatus.textContent = '';
return;
}
if (!update.ok) {
this.$.updateStatus.textContent = `Update check failed: ${update.error}`;
return;
}
this.$.updateStatus.textContent = update.updateAvailable
? `Update available: ${update.latestVersion} (${update.releaseUrl})`
: `Up to date: ${update.currentVersion}`;
},
renderToolSummary() {
const catalog = (this.state && this.state.toolCatalog) || [];
const enabled = catalog.filter((tool) => tool.enabled);
const categories = Array.from(new Set(catalog.map((tool) => tool.category))).sort();
this.$.toolSummary.textContent =
`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() {
const targets = (this.state && this.state.clientTargets) || [];
const selected = this.$.clientTargetSelect.value || (targets[0] && targets[0].id);
const preferred = this.state && this.state.config ? this.state.config.lastClientTargetId : '';
const selected = this.$.clientTargetSelect.value || preferred || (targets[0] && targets[0].id);
this.$.clientTargetSelect.innerHTML = targets
.map((target) => `<option value="${target.id}">${target.name}</option>`)
.join('');
@@ -261,10 +695,30 @@ module.exports = Editor.Panel.define({
return;
}
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) {
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 = {}) {
const { showOutput = false } = options;
try {
@@ -294,10 +748,108 @@ module.exports = Editor.Panel.define({
host: (this.state && this.state.config && this.state.config.host) || (this.state && this.state.status && this.state.status.host) || '127.0.0.1',
port: Number(this.$.portInput.value || 8765),
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),
enableSessions: Boolean(this.$.sessionsInput.value),
executeJavascriptSafetyChecks: Boolean(this.$.javascriptSafetyInput.value),
autostart: Boolean(this.$.enabledInput.value),
maxInteractionLogEntries: this.state && this.state.config ? this.state.config.maxInteractionLogEntries : 50,
lastClientTargetId: this.$.clientTargetSelect.value || 'claude_code',
activeToolProfileName: this.$.toolProfileNameInput.value || '',
savedToolProfiles: this.getSavedToolProfiles(),
};
},
async saveCurrentToolProfile() {
const name = this.$.toolProfileNameInput.value || this.$.savedToolProfileSelect.value;
const snapshot = this.currentToolProfileSnapshot(name);
const profiles = this.getSavedToolProfiles();
const key = snapshot.name.toLowerCase();
const existing = profiles.findIndex((profile) => profile.name.toLowerCase() === key);
if (existing >= 0) {
profiles[existing] = snapshot;
} else {
profiles.push(snapshot);
}
this.state.config.savedToolProfiles = this.normalizeToolProfiles(profiles);
this.state.config.activeToolProfileName = snapshot.name;
await this.persistConfig({ showOutput: true });
},
async applySavedToolProfile() {
const name = this.$.savedToolProfileSelect.value;
const profile = this.getSavedToolProfiles().find((item) => item.name === name);
if (!profile) {
this.showOutput('Select a saved profile first.');
return;
}
this.$.profileSelect.value = profile.toolProfile;
this.$.enabledCategoriesInput.value = this.formatList(profile.enabledToolCategories);
this.$.disabledCategoriesInput.value = this.formatList(profile.disabledToolCategories);
this.$.enabledToolsInput.value = this.formatList(profile.enabledTools);
this.$.disabledToolsInput.value = this.formatList(profile.disabledTools);
this.$.toolProfileNameInput.value = profile.name;
this.state.config.activeToolProfileName = profile.name;
await this.persistConfig({ showOutput: true });
},
async deleteSavedToolProfile() {
const name = this.$.savedToolProfileSelect.value;
if (!name) {
this.showOutput('Select a saved profile first.');
return;
}
this.state.config.savedToolProfiles = this.getSavedToolProfiles()
.filter((profile) => profile.name !== name);
if (this.state.config.activeToolProfileName === name) {
this.state.config.activeToolProfileName = '';
}
this.$.toolProfileNameInput.value = '';
await this.persistConfig({ showOutput: true });
},
exportSavedToolProfiles() {
const payload = JSON.stringify({ version: 1, profiles: this.getSavedToolProfiles() }, null, 2);
this.$.toolProfileImportText.value = payload;
this.copyText(payload, 'Copied tool profiles to clipboard.');
},
async importSavedToolProfiles() {
try {
const payload = JSON.parse(this.$.toolProfileImportText.value || '{}');
const incoming = Array.isArray(payload)
? payload
: Array.isArray(payload.profiles)
? payload.profiles
: [];
if (!incoming.length) {
throw new Error('No profiles found.');
}
this.state.config.savedToolProfiles = this.normalizeToolProfiles([
...this.getSavedToolProfiles(),
...incoming,
]);
await this.persistConfig({ showOutput: true });
} catch (error) {
this.showOutput(`Import profiles failed: ${error.message}`);
}
},
async setCategoryExposure(category, mode) {
const enabled = new Set(this.parseList(this.$.enabledCategoriesInput.value).map((item) => item.toLowerCase()));
const disabled = new Set(this.parseList(this.$.disabledCategoriesInput.value).map((item) => item.toLowerCase()));
const key = String(category || '').toLowerCase();
if (!key) {
return;
}
enabled.delete(key);
disabled.delete(key);
if (mode === 'enable') {
enabled.add(key);
} else if (mode === 'disable') {
disabled.add(key);
}
this.$.profileSelect.value = 'custom';
this.$.enabledCategoriesInput.value = Array.from(enabled).sort().join('\n');
this.$.disabledCategoriesInput.value = Array.from(disabled).sort().join('\n');
await this.persistConfig({ showOutput: true });
},
async handleEnableToggle() {
const shouldEnable = Boolean(this.$.enabledInput.value);
const wasRunning = Boolean(this.state && this.state.status && this.state.status.running);
@@ -321,15 +873,66 @@ module.exports = Editor.Panel.define({
this.$.copyUrlBtn.addEventListener('click', () => {
const status = this.state && this.state.status;
const text = status && status.url ? status.url : '';
navigator.clipboard.writeText(text)
.then(() => this.showOutput('Copied URL to clipboard.'))
.catch(() => this.showOutput(text));
this.copyText(text, 'Copied URL to clipboard.');
});
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.$.enabledInput.addEventListener('change', () => this.handleEnableToggle());
this.$.portInput.addEventListener('change', () => this.persistConfig({ showOutput: true }));
this.$.profileSelect.addEventListener('change', () => this.persistConfig({ showOutput: true }));
this.$.sessionsInput.addEventListener('change', () => this.persistConfig({ showOutput: true }));
this.$.javascriptSafetyInput.addEventListener('change', () => this.persistConfig({ showOutput: true }));
this.$.enabledCategoriesInput.addEventListener('change', () => this.persistConfig({ showOutput: true }));
this.$.disabledCategoriesInput.addEventListener('change', () => this.persistConfig({ showOutput: true }));
this.$.enabledToolsInput.addEventListener('change', () => this.persistConfig({ showOutput: true }));
this.$.disabledToolsInput.addEventListener('change', () => this.persistConfig({ showOutput: true }));
this.$.useCoreBtn.addEventListener('click', () => {
this.$.profileSelect.value = 'core';
this.$.enabledCategoriesInput.value = '';
this.$.disabledCategoriesInput.value = '';
this.$.enabledToolsInput.value = '';
this.$.disabledToolsInput.value = '';
this.persistConfig({ showOutput: true });
});
this.$.useFullBtn.addEventListener('click', () => {
this.$.profileSelect.value = 'full';
this.$.enabledCategoriesInput.value = '';
this.$.disabledCategoriesInput.value = '';
this.$.enabledToolsInput.value = '';
this.$.disabledToolsInput.value = '';
this.persistConfig({ showOutput: true });
});
this.$.useCustomBtn.addEventListener('click', () => {
this.$.profileSelect.value = 'custom';
this.persistConfig({ showOutput: true });
});
this.$.saveToolProfileBtn.addEventListener('click', () => this.saveCurrentToolProfile());
this.$.applyToolProfileBtn.addEventListener('click', () => this.applySavedToolProfile());
this.$.deleteToolProfileBtn.addEventListener('click', () => this.deleteSavedToolProfile());
this.$.exportToolProfilesBtn.addEventListener('click', () => this.exportSavedToolProfiles());
this.$.importToolProfilesBtn.addEventListener('click', () => this.importSavedToolProfiles());
this.$.savedToolProfileSelect.addEventListener('change', () => {
this.$.toolProfileNameInput.value = this.$.savedToolProfileSelect.value || '';
});
this.$.categoryControls.addEventListener('click', (event) => {
const target = event.target && typeof event.target.closest === 'function'
? event.target.closest('ui-button')
: event.target;
if (!target || !target.dataset || !target.dataset.category) {
return;
}
this.setCategoryExposure(target.dataset.category, target.dataset.mode);
});
this.$.clientTargetSelect.addEventListener('confirm', () => this.renderClientTargetStatus());
this.$.clientTargetSelect.addEventListener('change', () => this.renderClientTargetStatus());
this.$.clientTargetSelect.addEventListener('change', () => {
this.renderClientTargetStatus();
this.persistConfig();
});
this.$.configureClientBtn.addEventListener('click', () => {
const targetId = this.$.clientTargetSelect.value;
if (!targetId) {
+243
View File
@@ -16,6 +16,7 @@ const {
SceneAsset,
js,
Component,
EventHandler,
Canvas,
UITransform,
Label,
@@ -241,6 +242,23 @@ function findComponent(node, options = {}) {
return null;
}
function getEventHandlerClass() {
return (Component && Component.EventHandler) || EventHandler || null;
}
function serializeEventHandler(handler) {
if (!handler) {
return null;
}
return {
target: handler.target && handler.target.name ? getNodePath(handler.target) : '',
targetUuid: handler.target && handler.target.uuid ? handler.target.uuid : '',
component: handler.component || '',
handler: handler.handler || '',
customEventData: handler.customEventData || '',
};
}
function getOrAddComponent(node, componentClass) {
return node.getComponent(componentClass) || node.addComponent(componentClass);
}
@@ -308,6 +326,97 @@ function findComponentsByClass(componentClass) {
return results;
}
function getPrefabInfo(node) {
const prefab = node && node._prefab;
if (!prefab) {
return {
linked: false,
};
}
const asset = prefab.asset || prefab._asset || null;
return {
linked: Boolean(asset || prefab.fileId || prefab.root),
fileId: prefab.fileId || '',
asset: asset
? {
name: asset.name || '',
uuid: asset.uuid || asset._uuid || '',
}
: null,
instance: prefab.instance ? plain(prefab.instance) : null,
sync: prefab.sync,
rawKeys: Object.keys(prefab).slice(0, 50),
};
}
function collectSceneStats() {
const stats = {
nodeCount: 0,
activeNodeCount: 0,
inactiveNodeCount: 0,
maxDepth: 0,
componentCount: 0,
componentsByType: {},
prefabInstanceCount: 0,
uiTransformCount: 0,
canvasCount: 0,
cameraCount: 0,
labelCount: 0,
spriteCount: 0,
buttonCount: 0,
};
function visit(node, depth) {
if (node !== getScene()) {
stats.nodeCount += 1;
stats.maxDepth = Math.max(stats.maxDepth, depth);
if (node.active) stats.activeNodeCount += 1;
else stats.inactiveNodeCount += 1;
if (node._prefab) stats.prefabInstanceCount += 1;
}
for (const component of node.components || []) {
const name = component && component.constructor ? component.constructor.name : 'UnknownComponent';
stats.componentCount += 1;
stats.componentsByType[name] = (stats.componentsByType[name] || 0) + 1;
if (component instanceof UITransform) stats.uiTransformCount += 1;
if (component instanceof Canvas) stats.canvasCount += 1;
if (component instanceof Camera) stats.cameraCount += 1;
if (component instanceof Label) stats.labelCount += 1;
if (component instanceof Sprite) stats.spriteCount += 1;
if (component instanceof Button) stats.buttonCount += 1;
}
for (const child of node.children) {
visit(child, depth + 1);
}
}
visit(getScene(), 0);
return stats;
}
function buildSceneWarnings(stats) {
const warnings = [];
if (stats.nodeCount === 0) {
warnings.push({ severity: 'warn', code: 'empty_scene', message: 'The active scene has no child nodes.' });
}
if (stats.cameraCount === 0) {
warnings.push({ severity: 'warn', code: 'missing_camera', message: 'No Camera component was found in the active scene.' });
}
if (stats.nodeCount > 500) {
warnings.push({ severity: 'info', code: 'large_node_count', message: `Scene has ${stats.nodeCount} nodes.` });
}
if (stats.maxDepth > 12) {
warnings.push({ severity: 'info', code: 'deep_hierarchy', message: `Scene hierarchy depth is ${stats.maxDepth}.` });
}
if (stats.labelCount > 80) {
warnings.push({ severity: 'info', code: 'many_labels', message: `Scene has ${stats.labelCount} Label components.` });
}
return warnings;
}
function getScheduler() {
return typeof director.getScheduler === 'function' ? director.getScheduler() : null;
}
@@ -1149,6 +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() {
if (typeof director.pause === 'function') {
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 = {}) {
const node = findNode(options);
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/
);
});
+23
View File
@@ -0,0 +1,23 @@
'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 { getRecentProjectLogs, searchProjectLogs } = require('../lib/logs');
test('project log helpers read and search common project log files', () => {
const projectPath = fs.mkdtempSync(path.join(os.tmpdir(), 'funplay-cocos-logs-'));
const logDir = path.join(projectPath, 'temp', 'logs');
fs.mkdirSync(logDir, { recursive: true });
fs.writeFileSync(path.join(logDir, 'editor.log'), 'first line\nError: broken scene\nlast line\n', 'utf8');
const recent = getRecentProjectLogs(projectPath, { limit: 5, lines: 2 });
assert.equal(recent.length, 1);
assert.match(recent[0].text, /broken scene/);
const matches = searchProjectLogs(projectPath, { query: 'broken', limit: 5 });
assert.equal(matches.count, 1);
assert.equal(matches.matches[0].path, 'temp/logs/editor.log');
});
+38
View File
@@ -0,0 +1,38 @@
'use strict';
const assert = require('node:assert/strict');
const path = require('node:path');
const test = require('node:test');
const { isPathInside, resolveProjectPath } = require('../lib/path-safety');
test('resolveProjectPath resolves project-relative paths inside the project root', () => {
const root = path.resolve('/tmp/funplay-cocos-test-project');
assert.equal(resolveProjectPath(root, 'assets/player.ts'), path.join(root, 'assets', 'player.ts'));
});
test('resolveProjectPath allows absolute paths only when they stay inside the project root', () => {
const root = path.resolve('/tmp/funplay-cocos-test-project');
const inside = path.join(root, 'assets', 'scene.scene');
assert.equal(resolveProjectPath(root, inside), inside);
});
test('resolveProjectPath rejects path traversal outside the project root', () => {
const root = path.resolve('/tmp/funplay-cocos-test-project');
assert.throws(
() => resolveProjectPath(root, '../secret.txt'),
/outside the Cocos project/
);
});
test('resolveProjectPath rejects absolute paths outside the project root', () => {
const root = path.resolve('/tmp/funplay-cocos-test-project');
assert.throws(
() => resolveProjectPath(root, '/tmp/secret.txt'),
/outside the Cocos project/
);
});
test('isPathInside treats the project root as inside itself', () => {
const root = path.resolve('/tmp/funplay-cocos-test-project');
assert.equal(isPathInside(root, root), true);
});
+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/
);
});
+369
View File
@@ -0,0 +1,369 @@
'use strict';
const assert = require('node:assert/strict');
const http = require('node:http');
const test = require('node:test');
const {
MCP_PROTOCOL_VERSION,
McpServer,
SUPPORTED_PROTOCOL_VERSIONS,
} = require('../lib/server');
function createServer(toolRegistry = {}, config = {}, options = {}) {
return new McpServer({
config: { host: '127.0.0.1', port: 8765, ...config },
toolRegistry: {
listTools: () => [],
callTool: async () => 'ok',
...toolRegistry,
},
resourceProvider: {
listResources: () => [],
listResourceTemplates: () => [],
readResource: async () => ({ contents: [] }),
},
promptProvider: {
listPrompts: () => [],
getPrompt: () => ({ messages: [] }),
},
interactionLog: { add() {} },
runtimeLog: { add() {} },
serverName: options.serverName || 'test-server',
serverVersion: options.serverVersion || '0.0.0-test',
projectName: options.projectName || 'test-project',
projectIdentity: options.projectIdentity || 'test-project-id',
});
}
function httpJson(port, payload, headers = {}) {
const body = payload === undefined ? '' : JSON.stringify(payload);
return new Promise((resolve, reject) => {
const request = http.request(
{
host: '127.0.0.1',
port,
method: 'POST',
path: '/',
headers: {
Accept: 'application/json, text/event-stream',
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(body),
...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(body);
});
}
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 () => {
const server = createServer();
const response = await server.handleRpcRequest({
jsonrpc: '2.0',
id: 1,
method: 'initialize',
params: {},
});
assert.equal(response.result.protocolVersion, MCP_PROTOCOL_VERSION);
assert.equal(response.result.serverInfo.name, 'test-server');
assert.equal(response.result.funplay.projectIdentity, 'test-project-id');
});
test('initialize can negotiate an older supported MCP protocol version', async () => {
const server = createServer();
const olderVersion = SUPPORTED_PROTOCOL_VERSIONS[SUPPORTED_PROTOCOL_VERSIONS.length - 1];
const response = await server.handleRpcRequest({
jsonrpc: '2.0',
id: 1,
method: 'initialize',
params: { protocolVersion: olderVersion },
});
assert.equal(response.result.protocolVersion, olderVersion);
});
test('tool execution failures are returned as MCP tool errors', async () => {
const server = createServer({
callTool: async () => {
throw new Error('bad arguments');
},
});
const response = await server.handleRpcRequest({
jsonrpc: '2.0',
id: 2,
method: 'tools/call',
params: { name: 'example', arguments: {} },
});
assert.equal(response.error, undefined);
assert.equal(response.result.isError, true);
assert.deepEqual(response.result.content, [{ type: 'text', text: 'bad arguments' }]);
});
test('tool object results include structuredContent', async () => {
const value = { ok: true, count: 2 };
const server = createServer({
callToolDetailed: async () => ({
value,
text: JSON.stringify(value, null, 2),
}),
});
const response = await server.handleRpcRequest({
jsonrpc: '2.0',
id: 4,
method: 'tools/call',
params: { name: 'example', arguments: {} },
});
assert.deepEqual(response.result.structuredContent, value);
assert.equal(response.result.content[0].type, 'text');
});
test('structuredContent sanitizes circular values', async () => {
const value = { ok: true };
value.self = value;
const server = createServer({
callToolDetailed: async () => ({
value,
text: '{ "ok": true, "self": "[Circular]" }',
}),
});
const response = await server.handleRpcRequest({
jsonrpc: '2.0',
id: 5,
method: 'tools/call',
params: { name: 'example', arguments: {} },
});
assert.deepEqual(response.result.structuredContent, { ok: true, self: '[Circular]' });
});
test('unsupported protocol version headers are rejected when present after initialize', () => {
const server = createServer();
const response = server.validateProtocolVersionHeader(
{ headers: { 'mcp-protocol-version': '1999-01-01' } },
{ jsonrpc: '2.0', id: 3, method: 'tools/list' }
);
assert.equal(response.error.code, -32600);
assert.match(response.error.message, /Unsupported MCP protocol version/);
});
test('streamable HTTP Accept headers must allow json and event-stream', () => {
const server = createServer();
assert.equal(server.validateAcceptHeader({ headers: { accept: 'application/json, text/event-stream' } }), null);
assert.equal(server.validateAcceptHeader({ headers: { accept: '*/*' } }), null);
const response = server.validateAcceptHeader({ headers: { accept: 'application/json' } });
assert.equal(response.error.code, -32600);
assert.match(response.error.message, /Accept header/);
});
test('JSON-RPC responses and notifications are classified for 202 handling', () => {
const server = createServer();
assert.equal(server.classifyJsonRpcMessage({ jsonrpc: '2.0', id: 1, result: {} }), 'response');
assert.equal(server.classifyJsonRpcMessage({ jsonrpc: '2.0', method: 'notifications/initialized' }), 'notification');
assert.equal(server.classifyJsonRpcMessage({ jsonrpc: '2.0', id: 1, method: 'tools/list' }), 'request');
});
test('session validation requires a known session when enabled', () => {
const server = createServer({}, { enableSessions: true });
server.sessions.add('abc123');
assert.equal(
server.validateSession(
{ headers: { 'mcp-session-id': 'abc123' } },
{ jsonrpc: '2.0', id: 1, method: 'tools/list' }
),
null
);
const missing = server.validateSession(
{ headers: {} },
{ jsonrpc: '2.0', id: 1, method: 'tools/list' }
);
assert.equal(missing.statusCode, 400);
const unknown = server.validateSession(
{ headers: { 'mcp-session-id': 'nope' } },
{ jsonrpc: '2.0', id: 1, method: 'tools/list' }
);
assert.equal(unknown.statusCode, 404);
});
test('HTTP notifications return 202 Accepted with no body', async () => {
const server = createServer({}, { port: 0 });
await server.start();
try {
const response = await httpJson(server.getPort(), {
jsonrpc: '2.0',
method: 'notifications/initialized',
});
assert.equal(response.statusCode, 202);
assert.equal(response.body, '');
} finally {
await server.stop();
}
});
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 () => {
const server = createServer({}, { port: 0, enableSessions: true });
await server.start();
try {
const response = await httpJson(server.getPort(), {
jsonrpc: '2.0',
id: 1,
method: 'initialize',
params: { protocolVersion: MCP_PROTOCOL_VERSION },
});
assert.equal(response.statusCode, 200);
assert.match(response.headers['mcp-session-id'], /^[\x21-\x7e]+$/);
} finally {
await server.stop();
}
});
+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']);
});
+137
View File
@@ -0,0 +1,137 @@
'use strict';
const assert = require('node:assert/strict');
const path = require('node:path');
const test = require('node:test');
const { createToolRegistry } = require('../lib/tool-registry');
function createRegistry(profile, projectPath = path.resolve('/tmp/funplay-cocos-test-project'), configExtras = {}, overrides = {}) {
return createToolRegistry({
getRuntimeContext: () => ({
config: { toolProfile: profile, ...configExtras },
projectPath,
version: '0.0.0-test',
}),
interactionLog: { add() {} },
runtimeLog: { add() {}, list: () => [], clear: () => 0 },
sceneBridge: overrides.sceneBridge || { call: async () => ({ ok: true }) },
editorExecutor: overrides.editorExecutor || (async () => ({ ok: true })),
});
}
test('core profile exposes the documented focused tool set', () => {
const tools = createRegistry('core').listTools();
assert.equal(tools.length, 37);
assert.equal(tools.some((tool) => tool.name === 'execute_javascript'), true);
assert.equal(tools.some((tool) => tool.name === 'get_editor_state'), true);
assert.equal(tools.some((tool) => tool.name === 'get_tool_catalog'), true);
assert.equal(tools.some((tool) => tool.name === 'validate_scene'), true);
assert.equal(tools.some((tool) => tool.name === 'inspect_asset_dependencies'), true);
assert.equal(tools.some((tool) => tool.name === 'get_build_status'), true);
assert.equal(tools.some((tool) => tool.name === 'get_performance_snapshot'), true);
assert.equal(tools.some((tool) => tool.name === 'list_project_instructions'), true);
assert.equal(tools.some((tool) => tool.name === 'set_selection'), true);
assert.equal(tools.some((tool) => tool.name === 'write_file'), false);
});
test('full profile exposes all built-in tools', () => {
const tools = createRegistry('full').listTools();
assert.equal(tools.length, 101);
assert.equal(tools.some((tool) => tool.name === 'write_file'), true);
assert.equal(tools.some((tool) => tool.name === 'edit_prefab_json'), true);
assert.equal(tools.some((tool) => tool.name === 'create_project_skill'), true);
assert.equal(tools.some((tool) => tool.name === 'create_cocos_mcp_project_skill'), true);
assert.equal(tools.some((tool) => tool.name === 'bind_button_click_event'), true);
assert.equal(tools.some((tool) => tool.name === 'open_build_panel'), true);
assert.equal(tools.some((tool) => tool.name === 'broadcast_editor_message'), true);
assert.equal(tools.some((tool) => tool.name === 'get_editor_state'), true);
assert.equal(tools.some((tool) => tool.name === 'set_selection'), true);
});
test('tool definitions include MCP outputSchema and annotations', () => {
const tool = createRegistry('core').listTools().find((item) => item.name === 'get_project_info');
assert.equal(tool.outputSchema.type, 'object');
assert.equal(tool.outputSchema.properties.ok.type, 'boolean');
assert.equal(tool.annotations.readOnlyHint, true);
});
test('custom profile can expose a category and disable a specific tool', () => {
const tools = createRegistry('custom', path.resolve('/tmp/funplay-cocos-test-project'), {
enabledToolCategories: ['files'],
disabledTools: ['write_file'],
}).listTools();
assert.equal(tools.some((tool) => tool.name === 'read_file'), true);
assert.equal(tools.some((tool) => tool.name === 'write_file'), false);
assert.equal(tools.some((tool) => tool.name === 'execute_javascript'), true);
});
test('tool catalog reports disabled tools under the current exposure settings', () => {
const catalog = createRegistry('core', path.resolve('/tmp/funplay-cocos-test-project'), {
disabledTools: ['execute_javascript'],
}).listToolCatalog();
const executeTool = catalog.find((tool) => tool.name === 'execute_javascript');
assert.equal(executeTool.enabled, false);
assert.equal(executeTool.category, 'execution');
});
test('file tools reject writes outside the project root', async () => {
const registry = createRegistry('full');
await assert.rejects(
() => registry.callTool('write_file', { path: '../outside.txt', content: 'x' }),
/outside the Cocos project/
);
});
test('callToolDetailed preserves structured values and text output', async () => {
const registry = createRegistry('core');
const result = await registry.callToolDetailed('get_project_info', {});
assert.equal(result.value.ok, true);
assert.equal(result.value.tool, 'get_project_info');
assert.equal(result.value.data.projectPath, path.resolve('/tmp/funplay-cocos-test-project'));
assert.match(result.value.callId, /^fp_/);
assert.match(result.text, /projectPath/);
});
test('callToolDetailed preserves screenshot image text while keeping structured envelope small', async () => {
const dataUri = 'data:image/png;base64,AAAA';
const registry = createRegistry('core', path.resolve('/tmp/funplay-cocos-test-project'), {}, {
editorExecutor: async () => dataUri,
});
const result = await registry.callToolDetailed('execute_javascript', { context: 'editor', code: 'return image;' });
assert.equal(result.text, dataUri);
assert.equal(result.value.data.image, true);
assert.equal(result.value.data.mimeType, 'image/png');
});
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 });
}
});
+15
View File
@@ -0,0 +1,15 @@
'use strict';
const assert = require('node:assert/strict');
const test = require('node:test');
const { compareVersions, normalizeVersion } = require('../lib/update-checker');
test('normalizeVersion removes release tag prefixes and metadata', () => {
assert.equal(normalizeVersion('v1.2.3-beta+build'), '1.2.3');
});
test('compareVersions compares semantic version numbers', () => {
assert.equal(compareVersions('1.2.4', '1.2.3'), 1);
assert.equal(compareVersions('1.2.3', '1.2.4'), -1);
assert.equal(compareVersions('1.2.3', 'v1.2.3'), 0);
});