11 Commits
Author SHA1 Message Date
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
35 changed files with 5058 additions and 135 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
+83
View File
@@ -0,0 +1,83 @@
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: Run release metadata checks
run: npm run release:check
- name: Run npm package dry-run
run: npm run pack:dry-run
+2
View File
@@ -6,3 +6,5 @@ Library/
library/ library/
dist/ dist/
build/ build/
.release-tmp/
releases/
+117
View File
@@ -4,6 +4,123 @@ 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. 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.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 ## [0.1.0] - 2026-04-15
### Added ### Added
+80 -16
View File
@@ -51,6 +51,8 @@ git clone https://github.com/FunplayAI/funplay-cocos-mcp.git extensions/funplay-
Then restart Cocos Creator or reload extensions from the editor. Then restart Cocos Creator or reload extensions from the editor.
For a non-git install, download `Funplay.CocosMcp.v<version>.zip` from the GitHub Releases page, unzip it, and move the extracted `funplay-cocos-mcp` folder into your project `extensions/` directory.
You can also install it globally by copying the folder into your Cocos Creator user extensions directory. You can also install it globally by copying the folder into your Cocos Creator user extensions directory.
### 2. Start the MCP Server ### 2. Start the MCP Server
@@ -63,11 +65,15 @@ Funplay > MCP Server
The server runs on `http://127.0.0.1:8765/` by default. The server runs on `http://127.0.0.1:8765/` by default.
If the configured port is already occupied, the extension automatically falls back to the next available local port and uses the actual running port for one-click MCP client configuration.
The panel is intentionally small: The panel is intentionally small:
- Enable or disable the MCP server - Enable or disable the MCP server
- Change the server port - Change the server port
- Switch tool exposure between `core` and `full` - Switch tool exposure between `core`, `full`, and `custom`
- Check the installed version against the latest GitHub release
- Tune tool exposure by category or individual tool
- Configure AI clients with one click - Configure AI clients with one click
- Expand debug output only when needed - Expand debug output only when needed
@@ -173,6 +179,31 @@ url = "http://127.0.0.1:8765/"
</details> </details>
### Optional: npm stdio Wrapper
If your MCP client prefers a local `stdio` command, install the npm wrapper after starting the Cocos editor server:
```bash
npm install -g funplay-cocos-mcp
```
Example MCP client entry:
```json
{
"mcpServers": {
"funplay_cocos": {
"command": "funplay-cocos-mcp",
"env": {
"FUNPLAY_COCOS_MCP_URL": "http://127.0.0.1:8765/"
}
}
}
}
```
The wrapper bridges stdio MCP traffic to the embedded Cocos HTTP endpoint. You can also run it with `npx funplay-cocos-mcp --url http://127.0.0.1:8765/`.
### 4. Verify the Connection ### 4. Verify the Connection
Open your AI client and try a few safe requests first: Open your AI client and try a few safe requests first:
@@ -194,8 +225,13 @@ Try a higher-level prompt in your AI client:
- This extension is **Editor-only**. It is meant to automate Cocos Creator, not to add runtime dependencies to your final game build. - This extension is **Editor-only**. It is meant to automate Cocos Creator, not to add runtime dependencies to your final game build.
- The MCP server listens on `http://127.0.0.1:8765/` by default. - The MCP server listens on `http://127.0.0.1:8765/` by default.
- 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.
- The default `core` profile exposes 34 high-signal tools. Switch to `full` for all 89 tools, or use `custom` to include/exclude tool categories and individual tools.
- The panel includes a manual update check against the latest GitHub release.
- Streamable HTTP responses follow the MCP transport requirements for `Accept`, `MCP-Protocol-Version`, JSON-RPC notifications/responses, and optional `Mcp-Session-Id` sessions.
- Tool listings include MCP `outputSchema` and `annotations`; structured tool results use a standard envelope with `ok`, `tool`, `callId`, `summary`, `data`, and follow-up `refs`.
- All exposed MCP tools execute directly. There is no extra approval toggle inside the Cocos extension. - 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. - 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. - 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 +240,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 - **`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 - **Embedded Cocos Extension** — No separate Python daemon or external bridge process is required for the Cocos-side plugin
- **One-Click Client Configuration** — Configure Claude Code, Cursor, VS Code, Trae, Kiro, and Codex directly from Cocos Creator - **One-Click Client Configuration** — Configure Claude Code, Cursor, VS Code, Trae, Kiro, and Codex directly from Cocos Creator
- **Project Context Built In** — Exposes live project, scene, selection, script diagnostics, and interaction-history resources - **Project Context Built In** — Exposes live project, scene, selection, script diagnostics, logs, and interaction-history resources
- **Focused by Default, Full When Needed** — `core` reduces tool-list noise; `full` exposes every available tool - **Focused by Default, Full When Needed** — `core` reduces tool-list noise; `full` exposes every available tool; `custom` lets you tune by category or tool
- **Visual Validation** — Scene/editor/preview screenshots and input simulation help AI verify UI and gameplay changes - **Visual Validation** — Scene/editor/preview screenshots and input simulation help AI verify UI and gameplay changes
## Highlights ## Highlights
- **67 Built-in Tools** — Scene hierarchy, assets, UI creation, components, files, script diagnostics, screenshots, runtime control, and input simulation - **89 Built-in Tools** — Scene hierarchy, editor state, selection workflows, prefabs, assets, project instructions, UI creation, components, files, logs, script diagnostics, screenshots, runtime control, and input simulation
- **Primary Unified Tool** — `execute_javascript` supports both `scene` and `editor` contexts - **Primary Unified Tool** — `execute_javascript` supports both `scene` and `editor` contexts
- **Resources & Prompts** — Live project resources plus reusable workflows like script fixing, scene validation, and playable prototype creation - **Resources & Prompts** — Live project/log resources plus reusable workflows like script fixing, scene validation, and playable prototype creation
- **Cocos Panel UI** — A minimal `Funplay > MCP Server` panel for service management and MCP client setup - **Cocos Panel UI** — A minimal `Funplay > MCP Server` panel for service management, update checks, tool exposure, and MCP client setup
- **Screenshot and Input Support** — Capture editor/scene/game/preview screenshots and send Electron-level mouse/keyboard events - **Screenshot and Input Support** — Capture editor/scene/game/preview screenshots and send Electron-level mouse/keyboard events
- **Vendor Agnostic** — Works with any AI client that supports MCP over HTTP JSON-RPC - **Vendor Agnostic** — Works with any AI client that supports MCP over HTTP JSON-RPC
@@ -227,20 +263,20 @@ Funplay MCP for Cocos follows the same design principles as Funplay MCP for Unit
| Embedded server | Built-in HTTP MCP server | Built-in HTTP MCP server | | Embedded server | Built-in HTTP MCP server | Built-in HTTP MCP server |
| Primary execution tool | `execute_javascript` | `execute_code` | | Primary execution tool | `execute_javascript` | `execute_code` |
| Primary language | JavaScript in scene/editor contexts | C# in Unity editor/runtime contexts | | Primary language | JavaScript in scene/editor contexts | C# in Unity editor/runtime contexts |
| Default profile | `core` with 19 tools | `core` focused tool profile | | Default profile | `core` with 34 tools | `core` focused tool profile |
| Full profile | 67 tools | 79 tools | | Full profile | 89 tools plus `custom` exposure | 79 tools |
| Client setup | One-click config panel | One-click config window | | Client setup | One-click config panel | One-click config window |
## MCP Capabilities ## MCP Capabilities
The current package exposes four capability layers: The current package exposes four capability layers:
- **Tools** — 19 tools in `core`, 67 tools in `full` - **Tools** — 34 tools in `core`, 89 tools in `full`, plus `custom` include/exclude rules
- **Primary execution** — `execute_javascript` for scene/runtime and editor/browser automation - **Primary execution** — `execute_javascript` for scene/runtime and editor/browser automation
- **Prompts** — `fix_script_errors`, `create_playable_prototype`, `scene_validation`, and `auto_wire_scene` - **Prompts** — `fix_script_errors`, `create_playable_prototype`, `scene_validation`, and `auto_wire_scene`
- **Resources** — project context, scene summaries, current selection, script diagnostics, asset selection, 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`. The default `core` set is intentionally small: `execute_javascript`, `execute_scene_script`, `execute_editor_script`, `get_editor_state`, `get_tool_catalog`, `check_for_updates`, `get_selection`, `list_project_instructions`, `read_project_instruction`, `set_selection`, `get_project_info`, `get_scene_info`, `get_hierarchy`, `list_scenes`, `open_scene`, `inspect_prefab`, `validate_prefab_references`, `inspect_prefab_instance`, `list_assets`, `inspect_asset`, `open_asset`, `select_asset`, `run_script_diagnostics`, `get_recent_logs`, `search_project_logs`, `clear_logs`, `validate_scene`, `get_performance_snapshot`, `get_script_diagnostic_context`, `get_runtime_state`, `capture_editor_screenshot`, `capture_scene_screenshot`, `capture_preview_screenshot`, and `list_editor_windows`.
## Built-in Resources ## Built-in Resources
@@ -253,24 +289,28 @@ The default `core` set is intentionally small: `execute_javascript`, `execute_sc
| `cocos://selection/current` | Current editor selection | | `cocos://selection/current` | Current editor selection |
| `cocos://selection/asset` | Current selected asset | | `cocos://selection/asset` | Current selected asset |
| `cocos://errors/scripts` | Script diagnostics | | `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 | | `cocos://mcp/interactions` | Recent MCP interaction history |
## Built-in Tools ## Built-in Tools
Funplay MCP for Cocos currently ships with **67 tool functions** in the `full` profile: Funplay MCP for Cocos currently ships with **89 tool functions** in the `full` profile:
| Category | Tools | | Category | Tools |
|----------|-------| |----------|-------|
| **Script Execution** | `execute_javascript`, `execute_scene_script`, `execute_editor_script` | | **Script Execution** | `execute_javascript`, `execute_scene_script`, `execute_editor_script` |
| **Editor State** | `get_editor_state`, `get_tool_catalog`, `check_for_updates`, `get_selection`, `set_selection`, `get_editor_selection` |
| **Project Instructions** | `list_project_instructions`, `read_project_instruction`, `write_project_instruction`, `create_project_skill` |
| **Project & Scene** | `get_project_info`, `get_scene_info`, `get_hierarchy`, `find_nodes`, `inspect_node`, `list_scenes`, `open_scene`, `run_scene_asset` | | **Project & Scene** | `get_project_info`, `get_scene_info`, `get_hierarchy`, `find_nodes`, `inspect_node`, `list_scenes`, `open_scene`, `run_scene_asset` |
| **Node Editing** | `create_node`, `delete_node`, `set_node_transform` | | **Node Editing** | `create_node`, `delete_node`, `set_node_transform` |
| **Assets & Prefabs** | `list_assets`, `inspect_asset`, `open_asset`, `select_asset`, `delete_asset`, `list_prefabs`, `instantiate_prefab`, `get_editor_selection` | | **Assets & Prefabs** | `list_assets`, `inspect_asset`, `open_asset`, `select_asset`, `delete_asset`, `list_prefabs`, `inspect_prefab`, `validate_prefab_references`, `duplicate_prefab`, `edit_prefab_json`, `create_prefab_instance`, `inspect_prefab_instance`, `apply_prefab_instance`, `revert_prefab_instance`, `instantiate_prefab` |
| **Components** | `list_components`, `inspect_component`, `add_component`, `remove_component`, `set_component_property`, `reset_component_property` | | **Components** | `list_components`, `inspect_component`, `add_component`, `remove_component`, `set_component_property`, `reset_component_property` |
| **UI** | `create_canvas`, `create_label`, `create_button`, `create_sprite` | | **UI** | `create_canvas`, `create_label`, `create_button`, `create_sprite` |
| **Camera** | `list_cameras`, `create_camera`, `set_camera_properties` | | **Camera** | `list_cameras`, `create_camera`, `set_camera_properties` |
| **Animation** | `list_animations`, `add_animation_clip`, `play_animation`, `stop_animation` | | **Animation** | `list_animations`, `add_animation_clip`, `play_animation`, `stop_animation` |
| **Files** | `read_file`, `get_file_snippet`, `write_file`, `replace_in_file`, `search_files`, `list_directory`, `exists`, `refresh_assets` | | **Files** | `read_file`, `get_file_snippet`, `write_file`, `replace_in_file`, `search_files`, `list_directory`, `exists`, `refresh_assets` |
| **Diagnostics** | `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` |
| **Runtime** | `get_runtime_state`, `pause_runtime`, `resume_runtime`, `set_time_scale` | | **Runtime** | `get_runtime_state`, `pause_runtime`, `resume_runtime`, `set_time_scale` |
| **Interaction** | `emit_node_event`, `simulate_button_click`, `invoke_component_method`, `simulate_mouse_click`, `simulate_mouse_drag`, `simulate_key_press`, `simulate_key_combo`, `simulate_preview_input` | | **Interaction** | `emit_node_event`, `simulate_button_click`, `invoke_component_method`, `simulate_mouse_click`, `simulate_mouse_drag`, `simulate_key_press`, `simulate_key_combo`, `simulate_preview_input` |
| **Screenshots & Windows** | `capture_desktop_screenshot`, `capture_editor_screenshot`, `capture_scene_screenshot`, `capture_game_screenshot`, `capture_preview_screenshot`, `list_editor_windows` | | **Screenshots & Windows** | `capture_desktop_screenshot`, `capture_editor_screenshot`, `capture_scene_screenshot`, `capture_game_screenshot`, `capture_preview_screenshot`, `list_editor_windows` |
@@ -308,6 +348,11 @@ Place `funplay-cocos-mcp.config.json` in the Cocos project root:
"host": "127.0.0.1", "host": "127.0.0.1",
"port": 8765, "port": 8765,
"toolProfile": "core", "toolProfile": "core",
"enabledToolCategories": [],
"disabledToolCategories": [],
"enabledTools": [],
"disabledTools": [],
"enableSessions": false,
"autostart": true, "autostart": true,
"maxInteractionLogEntries": 50 "maxInteractionLogEntries": 50
} }
@@ -319,6 +364,8 @@ Environment variables are also supported:
- `COCOS_MCP_PORT` - `COCOS_MCP_PORT`
- `COCOS_MCP_PROFILE` - `COCOS_MCP_PROFILE`
`toolProfile: "custom"` starts from the `core` set, then adds `enabledToolCategories` / `enabledTools` and removes `disabledToolCategories` / `disabledTools`. `enableSessions` is off by default because this server does not need cross-request client state for normal editor automation.
## Architecture ## Architecture
```text ```text
@@ -342,10 +389,27 @@ The server speaks MCP-style HTTP JSON-RPC 2.0 and supports tools, resources, res
## Development ## Development
Run a syntax check before publishing changes: Run checks before publishing changes:
```bash ```bash
npm run check npm run check
npm test
npm run release:check
npm run pack:dry-run
```
To generate a GitHub Release-ready extension package:
```bash
npm run release:package
```
The package is written to `releases/<version>/` with a zip, manifest, checksum file, and release README. See [RELEASE_WORKFLOW.md](./RELEASE_WORKFLOW.md) and [RELEASE_CHECKLIST.md](./RELEASE_CHECKLIST.md) for the full process.
Validate MCP Registry metadata before publishing:
```bash
npm run registry:validate
``` ```
## License ## License
+80 -16
View File
@@ -51,6 +51,8 @@ git clone https://github.com/FunplayAI/funplay-cocos-mcp.git extensions/funplay-
然后重启 Cocos Creator,或在编辑器里重新加载扩展。 然后重启 Cocos Creator,或在编辑器里重新加载扩展。
如果不想用 git 安装,可以从 GitHub Releases 下载 `Funplay.CocosMcp.v<version>.zip`,解压后把 `funplay-cocos-mcp` 目录移动到项目的 `extensions/` 目录。
你也可以把目录复制到 Cocos Creator 的全局用户扩展目录中。 你也可以把目录复制到 Cocos Creator 的全局用户扩展目录中。
### 2. 启动 MCP Server ### 2. 启动 MCP Server
@@ -63,11 +65,15 @@ Funplay > MCP Server
服务默认运行在 `http://127.0.0.1:8765/` 服务默认运行在 `http://127.0.0.1:8765/`
如果配置端口已被占用,扩展会自动回退到下一个可用本地端口,并在一键客户端配置时使用实际运行端口。
面板刻意保持精简: 面板刻意保持精简:
- 启用或停用 MCP Server - 启用或停用 MCP Server
- 修改服务端口 - 修改服务端口
-`core` / `full` 工具暴露模式之间切换 -`core` / `full` / `custom` 工具暴露模式之间切换
- 检查当前安装版本是否落后于 GitHub 最新 Release
- 按工具分类或单个工具调整暴露范围
- 一键配置 AI 客户端 - 一键配置 AI 客户端
- 需要时再展开 Debug Output - 需要时再展开 Debug Output
@@ -173,6 +179,31 @@ url = "http://127.0.0.1:8765/"
</details> </details>
### 可选:npm stdio Wrapper
如果 MCP 客户端更适合使用本地 `stdio` 命令,可以在启动 Cocos 编辑器内置服务后安装 npm wrapper
```bash
npm install -g funplay-cocos-mcp
```
示例 MCP 客户端配置:
```json
{
"mcpServers": {
"funplay_cocos": {
"command": "funplay-cocos-mcp",
"env": {
"FUNPLAY_COCOS_MCP_URL": "http://127.0.0.1:8765/"
}
}
}
}
```
这个 wrapper 会把 stdio MCP 流量桥接到 Cocos 内置 HTTP endpoint。也可以直接运行 `npx funplay-cocos-mcp --url http://127.0.0.1:8765/`
### 4. 验证连接 ### 4. 验证连接
先在 AI 客户端里试几个安全请求: 先在 AI 客户端里试几个安全请求:
@@ -194,8 +225,13 @@ url = "http://127.0.0.1:8765/"
- 这是一个 **仅限 Editor** 的扩展,用于自动化 Cocos Creator,不会给最终游戏包添加运行时依赖。 - 这是一个 **仅限 Editor** 的扩展,用于自动化 Cocos Creator,不会给最终游戏包添加运行时依赖。
- MCP Server 默认监听 `http://127.0.0.1:8765/` - MCP Server 默认监听 `http://127.0.0.1:8765/`
- 默认 `core` profile 暴露 19 个高频工具;如果需要完整工具集,可在面板切到 `full`,暴露全部 67 个工具 - 如果配置端口被占用,服务会自动回退到下一个可用端口,面板与一键客户端配置会使用实际运行端口
- 默认 `core` profile 暴露 34 个高频工具;如果需要完整工具集,可在面板切到 `full`,暴露全部 89 个工具;也可以用 `custom` 按分类或工具名增删。
- 面板提供手动更新检查,会对比当前安装版本和 GitHub 最新 Release。
- Streamable HTTP 响应已补齐 MCP 传输层要求,包括 `Accept``MCP-Protocol-Version`、JSON-RPC notification/response,以及可选 `Mcp-Session-Id` session。
- 工具列表会包含 MCP `outputSchema``annotations`;结构化工具结果统一使用包含 `ok``tool``callId``summary``data``refs` 的标准 envelope。
- 所有已暴露的 MCP 工具都会直接执行,Cocos 扩展里没有额外 approval 开关。 - 所有已暴露的 MCP 工具都会直接执行,Cocos 扩展里没有额外 approval 开关。
- 文件工具和 `cocos://asset/path/...` 资源默认只能访问当前 Cocos 项目根目录内的路径。
- 推荐工作流是优先使用 `execute_javascript`,再配合截图、诊断、资产、检查类工具。 - 推荐工作流是优先使用 `execute_javascript`,再配合截图、诊断、资产、检查类工具。
- 如果在面板里修改端口或工具暴露模式,扩展会自动保存配置,并在需要时重启服务。 - 如果在面板里修改端口或工具暴露模式,扩展会自动保存配置,并在需要时重启服务。
@@ -204,16 +240,16 @@ url = "http://127.0.0.1:8765/"
- **`execute_javascript` 主工具优先** — 一个高灵活度 JavaScript 工具就能编排场景/运行态和编辑器自动化,避免 AI 客户端被大量细碎工具干扰 - **`execute_javascript` 主工具优先** — 一个高灵活度 JavaScript 工具就能编排场景/运行态和编辑器自动化,避免 AI 客户端被大量细碎工具干扰
- **嵌入式 Cocos 扩展** — Cocos 侧不需要单独 Python 守护进程或外部 bridge - **嵌入式 Cocos 扩展** — Cocos 侧不需要单独 Python 守护进程或外部 bridge
- **一键客户端配置** — 在 Cocos Creator 内直接配置 Claude Code、Cursor、VS Code、Trae、Kiro、Codex - **一键客户端配置** — 在 Cocos Creator 内直接配置 Claude Code、Cursor、VS Code、Trae、Kiro、Codex
- **内建项目上下文** — 直接暴露项目、场景、选择、脚本诊断和交互历史资源 - **内建项目上下文** — 直接暴露项目、场景、选择、脚本诊断、日志和交互历史资源
- **默认聚焦,必要时全量** — `core` 降低工具列表噪音,需要时切到 `full` 暴露全部工具 - **默认聚焦,必要时全量** — `core` 降低工具列表噪音,需要时切到 `full` 暴露全部工具`custom` 可按分类或工具名调整
- **可视化验证** — 截图和输入模拟让 AI 能验证 UI 与玩法改动 - **可视化验证** — 截图和输入模拟让 AI 能验证 UI 与玩法改动
## 核心特性 ## 核心特性
- **67 个内置工具** — 覆盖场景层级、资产、UI 创建、组件、文件、脚本诊断、截图、运行态控制和输入模拟 - **89 个内置工具** — 覆盖场景层级、编辑器状态、选择工作流、Prefab、资产、项目指令、UI 创建、组件、文件、日志、脚本诊断、截图、运行态控制和输入模拟
- **统一主工具** — `execute_javascript` 同时支持 `scene``editor` 两种上下文 - **统一主工具** — `execute_javascript` 同时支持 `scene``editor` 两种上下文
- **Resources 与 Prompts** — 实时项目资源,以及脚本修复、场景验证、可玩原型等可复用工作流 - **Resources 与 Prompts** — 实时项目/日志资源,以及脚本修复、场景验证、可玩原型等可复用工作流
- **Cocos 图形面板** — `Funplay > MCP Server` 提供极简服务管理 MCP 客户端配置 - **Cocos 图形面板** — `Funplay > MCP Server` 提供服务管理、更新检查、工具暴露和 MCP 客户端配置
- **截图与输入支持** — 支持编辑器/场景/Game/Preview 截图,以及 Electron 级鼠标键盘事件 - **截图与输入支持** — 支持编辑器/场景/Game/Preview 截图,以及 Electron 级鼠标键盘事件
- **厂商无关** — 兼容任意支持 HTTP JSON-RPC MCP 的 AI 客户端 - **厂商无关** — 兼容任意支持 HTTP JSON-RPC MCP 的 AI 客户端
@@ -227,20 +263,20 @@ Funplay MCP for Cocos 延续 Funplay MCP for Unity 的设计原则,并针对 C
| 内置服务 | 内嵌 HTTP MCP Server | 内嵌 HTTP MCP Server | | 内置服务 | 内嵌 HTTP MCP Server | 内嵌 HTTP MCP Server |
| 主执行工具 | `execute_javascript` | `execute_code` | | 主执行工具 | `execute_javascript` | `execute_code` |
| 主语言 | 场景/编辑器上下文中的 JavaScript | Unity 编辑器/运行态中的 C# | | 主语言 | 场景/编辑器上下文中的 JavaScript | Unity 编辑器/运行态中的 C# |
| 默认工具集 | `core`19 个工具 | 聚焦版 `core` 工具集 | | 默认工具集 | `core`34 个工具 | 聚焦版 `core` 工具集 |
| 完整工具集 | 67 个工具 | 79 个工具 | | 完整工具集 | 89 个工具,并支持 `custom` 暴露 | 79 个工具 |
| 客户端配置 | 一键配置面板 | 一键配置窗口 | | 客户端配置 | 一键配置面板 | 一键配置窗口 |
## MCP 能力结构 ## MCP 能力结构
当前包提供四层能力: 当前包提供四层能力:
- **Tools** — `core`19 个工具,`full`67 个工具 - **Tools** — `core`34 个工具,`full`89 个工具,并支持 `custom` include/exclude 规则
- **Primary execution** — `execute_javascript` 用于场景/运行态和编辑器/browser 自动化 - **Primary execution** — `execute_javascript` 用于场景/运行态和编辑器/browser 自动化
- **Prompts** — `fix_script_errors``create_playable_prototype``scene_validation``auto_wire_scene` - **Prompts** — `fix_script_errors``create_playable_prototype``scene_validation``auto_wire_scene`
- **Resources** — 项目上下文、场景摘要、当前选择、脚本诊断、资产选择和 MCP 交互历史 - **Resources** — 项目上下文、场景摘要、当前选择、脚本诊断、资产选择、日志和 MCP 交互历史
当前默认 `core` 工具集刻意保持精简,只包含:`execute_javascript``execute_scene_script``execute_editor_script``get_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` 当前默认 `core` 工具集刻意保持精简,只包含:`execute_javascript``execute_scene_script``execute_editor_script``get_editor_state``get_tool_catalog``check_for_updates``get_selection``list_project_instructions``read_project_instruction``set_selection``get_project_info``get_scene_info``get_hierarchy``list_scenes``open_scene``inspect_prefab``validate_prefab_references``inspect_prefab_instance``list_assets``inspect_asset``open_asset``select_asset``run_script_diagnostics``get_recent_logs``search_project_logs``clear_logs``validate_scene``get_performance_snapshot``get_script_diagnostic_context``get_runtime_state``capture_editor_screenshot``capture_scene_screenshot``capture_preview_screenshot``list_editor_windows`
## 内置 Resources ## 内置 Resources
@@ -253,24 +289,28 @@ Funplay MCP for Cocos 延续 Funplay MCP for Unity 的设计原则,并针对 C
| `cocos://selection/current` | 当前编辑器选择 | | `cocos://selection/current` | 当前编辑器选择 |
| `cocos://selection/asset` | 当前选中资产 | | `cocos://selection/asset` | 当前选中资产 |
| `cocos://errors/scripts` | 脚本诊断信息 | | `cocos://errors/scripts` | 脚本诊断信息 |
| `cocos://logs/editor` | 最近 MCP 运行日志和工具交互 |
| `cocos://logs/project` | 常见项目日志文件的尾部内容 |
| `cocos://mcp/interactions` | 最近 MCP 交互历史 | | `cocos://mcp/interactions` | 最近 MCP 交互历史 |
## 内置工具 ## 内置工具
Funplay MCP for Cocos 当前在 `full` profile 下提供 **67 个工具函数** Funplay MCP for Cocos 当前在 `full` profile 下提供 **89 个工具函数**
| 分类 | 工具 | | 分类 | 工具 |
|------|------| |------|------|
| **脚本执行** | `execute_javascript`, `execute_scene_script`, `execute_editor_script` | | **脚本执行** | `execute_javascript`, `execute_scene_script`, `execute_editor_script` |
| **编辑器状态** | `get_editor_state`, `get_tool_catalog`, `check_for_updates`, `get_selection`, `set_selection`, `get_editor_selection` |
| **项目指令** | `list_project_instructions`, `read_project_instruction`, `write_project_instruction`, `create_project_skill` |
| **项目与场景** | `get_project_info`, `get_scene_info`, `get_hierarchy`, `find_nodes`, `inspect_node`, `list_scenes`, `open_scene`, `run_scene_asset` | | **项目与场景** | `get_project_info`, `get_scene_info`, `get_hierarchy`, `find_nodes`, `inspect_node`, `list_scenes`, `open_scene`, `run_scene_asset` |
| **节点编辑** | `create_node`, `delete_node`, `set_node_transform` | | **节点编辑** | `create_node`, `delete_node`, `set_node_transform` |
| **资产与 Prefab** | `list_assets`, `inspect_asset`, `open_asset`, `select_asset`, `delete_asset`, `list_prefabs`, `instantiate_prefab`, `get_editor_selection` | | **资产与 Prefab** | `list_assets`, `inspect_asset`, `open_asset`, `select_asset`, `delete_asset`, `list_prefabs`, `inspect_prefab`, `validate_prefab_references`, `duplicate_prefab`, `edit_prefab_json`, `create_prefab_instance`, `inspect_prefab_instance`, `apply_prefab_instance`, `revert_prefab_instance`, `instantiate_prefab` |
| **组件** | `list_components`, `inspect_component`, `add_component`, `remove_component`, `set_component_property`, `reset_component_property` | | **组件** | `list_components`, `inspect_component`, `add_component`, `remove_component`, `set_component_property`, `reset_component_property` |
| **UI** | `create_canvas`, `create_label`, `create_button`, `create_sprite` | | **UI** | `create_canvas`, `create_label`, `create_button`, `create_sprite` |
| **相机** | `list_cameras`, `create_camera`, `set_camera_properties` | | **相机** | `list_cameras`, `create_camera`, `set_camera_properties` |
| **动画** | `list_animations`, `add_animation_clip`, `play_animation`, `stop_animation` | | **动画** | `list_animations`, `add_animation_clip`, `play_animation`, `stop_animation` |
| **文件** | `read_file`, `get_file_snippet`, `write_file`, `replace_in_file`, `search_files`, `list_directory`, `exists`, `refresh_assets` | | **文件** | `read_file`, `get_file_snippet`, `write_file`, `replace_in_file`, `search_files`, `list_directory`, `exists`, `refresh_assets` |
| **诊断** | `run_script_diagnostics`, `get_script_diagnostic_context` | | **诊断与日志** | `run_script_diagnostics`, `get_script_diagnostic_context`, `get_recent_logs`, `search_project_logs`, `clear_logs`, `validate_scene`, `get_performance_snapshot` |
| **运行态** | `get_runtime_state`, `pause_runtime`, `resume_runtime`, `set_time_scale` | | **运行态** | `get_runtime_state`, `pause_runtime`, `resume_runtime`, `set_time_scale` |
| **交互** | `emit_node_event`, `simulate_button_click`, `invoke_component_method`, `simulate_mouse_click`, `simulate_mouse_drag`, `simulate_key_press`, `simulate_key_combo`, `simulate_preview_input` | | **交互** | `emit_node_event`, `simulate_button_click`, `invoke_component_method`, `simulate_mouse_click`, `simulate_mouse_drag`, `simulate_key_press`, `simulate_key_combo`, `simulate_preview_input` |
| **截图与窗口** | `capture_desktop_screenshot`, `capture_editor_screenshot`, `capture_scene_screenshot`, `capture_game_screenshot`, `capture_preview_screenshot`, `list_editor_windows` | | **截图与窗口** | `capture_desktop_screenshot`, `capture_editor_screenshot`, `capture_scene_screenshot`, `capture_game_screenshot`, `capture_preview_screenshot`, `list_editor_windows` |
@@ -308,6 +348,11 @@ Editor 上下文脚本可以访问 `Editor`、`fs`、`path`、`os`、`require`
"host": "127.0.0.1", "host": "127.0.0.1",
"port": 8765, "port": 8765,
"toolProfile": "core", "toolProfile": "core",
"enabledToolCategories": [],
"disabledToolCategories": [],
"enabledTools": [],
"disabledTools": [],
"enableSessions": false,
"autostart": true, "autostart": true,
"maxInteractionLogEntries": 50 "maxInteractionLogEntries": 50
} }
@@ -319,6 +364,8 @@ Editor 上下文脚本可以访问 `Editor`、`fs`、`path`、`os`、`require`
- `COCOS_MCP_PORT` - `COCOS_MCP_PORT`
- `COCOS_MCP_PROFILE` - `COCOS_MCP_PROFILE`
`toolProfile: "custom"` 会从 `core` 集合开始,再加入 `enabledToolCategories` / `enabledTools`,并移除 `disabledToolCategories` / `disabledTools``enableSessions` 默认关闭,因为常规编辑器自动化不需要跨请求客户端状态。
## 架构 ## 架构
```text ```text
@@ -342,10 +389,27 @@ Cocos Creator Extension
## 开发 ## 开发
发布改动前可以跑语法检查: 发布改动前可以跑检查:
```bash ```bash
npm run check npm run check
npm test
npm run release:check
npm run pack:dry-run
```
生成可上传到 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
``` ```
## 协议 ## 协议
+94
View File
@@ -0,0 +1,94 @@
# 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 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
## 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-manifest.json` references the correct GitHub download URL
- [ ] `SHA256SUMS.txt` includes the zip, manifest, 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 renders the release notes and 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
+329
View File
@@ -0,0 +1,329 @@
# 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"`
Optional but recommended:
4. `README.md`
5. `README_CN.md`
6. 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
- 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 /path/to/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
```
### 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.
### 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;
});
}
+156 -30
View File
@@ -11,9 +11,12 @@ const { createToolRegistry } = require('./lib/tool-registry');
const { ResourceProvider } = require('./lib/resources'); const { ResourceProvider } = require('./lib/resources');
const { PromptProvider } = require('./lib/prompts'); const { PromptProvider } = require('./lib/prompts');
const { InteractionLog } = require('./lib/interaction-log'); const { InteractionLog } = require('./lib/interaction-log');
const { RuntimeLog } = require('./lib/runtime-log');
const { checkForUpdate } = require('./lib/update-checker');
const EXTENSION_NAME = manifest.name || 'funplay-cocos-mcp'; const EXTENSION_NAME = manifest.name || 'funplay-cocos-mcp';
const LOG_PREFIX = '[Funplay Cocos MCP]'; const LOG_PREFIX = '[Funplay Cocos MCP]';
const REPOSITORY_URL = 'https://github.com/FunplayAI/funplay-cocos-mcp';
const AsyncFunction = Object.getPrototypeOf(async function () {}).constructor; const AsyncFunction = Object.getPrototypeOf(async function () {}).constructor;
class ExtensionService { class ExtensionService {
@@ -24,26 +27,42 @@ class ExtensionService {
this.resourceProvider = null; this.resourceProvider = null;
this.promptProvider = null; this.promptProvider = null;
this.interactionLog = new InteractionLog(); 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() { load() {
console.log(`${LOG_PREFIX} Extension loading...`); this.log('info', 'Extension loading...');
this.reloadRuntime(); this.reloadRuntime();
if (this.config.autostart) { 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(); 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(); return this.getStatus();
} }
unload() { unload() {
console.log(`${LOG_PREFIX} Extension unloading...`); this.log('info', 'Extension unloading...');
if (this.server) { if (this.server) {
this.server.stop(); this.server.stop();
this.server = null; this.server = null;
} }
console.log(`${LOG_PREFIX} Extension unloaded.`); this.log('info', 'Extension unloaded.');
} }
openPanel() { openPanel() {
@@ -55,11 +74,13 @@ class ExtensionService {
reloadRuntime() { reloadRuntime() {
this.config = loadConfig(); this.config = loadConfig();
console.log( this.interactionLog = new InteractionLog(this.config.maxInteractionLogEntries);
`${LOG_PREFIX} Runtime config loaded: host=${this.config.host}, port=${this.config.port}, ` + 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}` `profile=${this.config.toolProfile}, autostart=${this.config.autostart}`
); );
this.interactionLog = new InteractionLog(this.config.maxInteractionLogEntries);
const sceneBridge = { const sceneBridge = {
call: async (method, payload) => { call: async (method, payload) => {
if (!global.Editor || !Editor.Message || typeof Editor.Message.request !== 'function') { if (!global.Editor || !Editor.Message || typeof Editor.Message.request !== 'function') {
@@ -86,25 +107,28 @@ class ExtensionService {
this.toolRegistry = createToolRegistry({ this.toolRegistry = createToolRegistry({
getRuntimeContext: runtimeContext, getRuntimeContext: runtimeContext,
getStatus: () => this.getStatus(),
interactionLog: this.interactionLog, interactionLog: this.interactionLog,
runtimeLog: this.runtimeLog,
sceneBridge, sceneBridge,
editorExecutor: async (payload) => await this.executeEditorScript(payload, runtimeContext), 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); this.promptProvider = new PromptProvider(runtimeContext);
} }
async startServer() { async startServer() {
if (this.server && this.server.isRunning()) { 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(); return this.getStatus();
} }
console.log(`${LOG_PREFIX} Starting MCP server...`); this.log('info', 'Starting MCP server...');
this.reloadRuntime(); this.reloadRuntime();
this.server = new McpServer({ this.server = new McpServer({
config: this.config, config: this.config,
interactionLog: this.interactionLog, interactionLog: this.interactionLog,
runtimeLog: this.runtimeLog,
toolRegistry: this.toolRegistry, toolRegistry: this.toolRegistry,
resourceProvider: this.resourceProvider, resourceProvider: this.resourceProvider,
promptProvider: this.promptProvider, promptProvider: this.promptProvider,
@@ -113,41 +137,66 @@ class ExtensionService {
}); });
await this.server.start(); 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(); return this.getStatus();
} }
async stopServer() { async stopServer() {
console.log(`${LOG_PREFIX} Stop requested.`); this.log('info', 'Stop requested.');
if (this.server) { if (this.server) {
await this.server.stop(); await this.server.stop();
this.server = null; this.server = null;
console.log(`${LOG_PREFIX} MCP server stopped.`); this.log('info', 'MCP server stopped.');
} else { } 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(); return this.getStatus();
} }
async restartServer() { async restartServer() {
console.log(`${LOG_PREFIX} Restart requested.`); this.log('info', 'Restart requested.');
await this.stopServer(); await this.stopServer();
const status = await this.startServer(); 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; 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() { getStatus() {
const effective = this.getEffectiveServerConnection();
const fallbackInfo = this.server && this.server.isRunning() && typeof this.server.getPortFallbackInfo === 'function'
? this.server.getPortFallbackInfo()
: null;
return { return {
running: Boolean(this.server && this.server.isRunning()), running: Boolean(this.server && this.server.isRunning()),
host: this.config.host, host: this.config.host,
port: this.config.port, port: effective.port,
requestedPort: this.config.port,
portFallbackActive: Boolean(fallbackInfo),
portFallbackInfo: fallbackInfo,
toolProfile: this.config.toolProfile, toolProfile: this.config.toolProfile,
enabledTools: this.config.enabledTools,
disabledTools: this.config.disabledTools,
enabledToolCategories: this.config.enabledToolCategories,
disabledToolCategories: this.config.disabledToolCategories,
enableSessions: this.config.enableSessions,
autostart: this.config.autostart, autostart: this.config.autostart,
version: manifest.version || '0.0.0',
projectPath: getProjectPath(), projectPath: getProjectPath(),
projectName: getProjectName(), projectName: getProjectName(),
cocosVersion: getCocosVersion(), cocosVersion: getCocosVersion(),
url: `http://${this.config.host}:${this.config.port}/`, url: effective.url,
}; };
} }
@@ -155,16 +204,22 @@ class ExtensionService {
this.ensureRuntime(); this.ensureRuntime();
const status = this.getStatus(); const status = this.getStatus();
const tools = this.toolRegistry.listTools(); const tools = this.toolRegistry.listTools();
const toolCatalog = typeof this.toolRegistry.listToolCatalog === 'function'
? this.toolRegistry.listToolCatalog()
: tools;
const resources = this.resourceProvider.listResources(); const resources = this.resourceProvider.listResources();
const prompts = this.promptProvider.listPrompts(); const prompts = this.promptProvider.listPrompts();
return { return {
status, status,
tools, tools,
toolCatalog,
resources, resources,
prompts, prompts,
recentInteractions: this.interactionLog.list(20), recentInteractions: this.interactionLog.list(20),
recentRuntimeLogs: this.runtimeLog.list(20),
config: this.config, config: this.config,
updateInfo: this.lastUpdateInfo,
clientConfig: this.getClientConfig(), clientConfig: this.getClientConfig(),
clientTargets: getTargetStatuses(this.config), clientTargets: getTargetStatuses(this.config),
}; };
@@ -177,10 +232,25 @@ class ExtensionService {
async callToolFromPanel(name, args) { async callToolFromPanel(name, args) {
this.ensureRuntime(); this.ensureRuntime();
console.log(`${LOG_PREFIX} Panel calling tool: ${name}`); this.log('info', `Panel calling tool: ${name}`);
return await this.toolRegistry.callTool(name, args || {}); 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) { async executeEditorScript(payload, runtimeContext) {
const code = String(payload && payload.code || ''); const code = String(payload && payload.code || '');
if (!code.trim()) { if (!code.trim()) {
@@ -229,12 +299,12 @@ class ExtensionService {
async readResourceFromPanel(uri) { async readResourceFromPanel(uri) {
this.ensureRuntime(); this.ensureRuntime();
console.log(`${LOG_PREFIX} Panel reading resource: ${uri}`); this.log('info', `Panel reading resource: ${uri}`);
return await this.resourceProvider.readResource(uri); return await this.resourceProvider.readResource(uri);
} }
getClientConfig() { getClientConfig() {
const url = `http://${this.config.host}:${this.config.port}/`; const { url } = this.getEffectiveServerConnection();
return { return {
url, url,
codex: `[mcp_servers.funplay_cocos]\nurl = "${url}"\n`, codex: `[mcp_servers.funplay_cocos]\nurl = "${url}"\n`,
@@ -250,9 +320,24 @@ class ExtensionService {
configureClient(targetId) { configureClient(targetId) {
this.ensureRuntime(); this.ensureRuntime();
console.log(`${LOG_PREFIX} Configuring MCP client target: ${targetId}`); this.log('info', `Configuring MCP client target: ${targetId}`);
const result = configureTarget(this.config, targetId); const effective = this.getEffectiveServerConnection();
console.log(`${LOG_PREFIX} MCP client configured: ${result.name} -> ${result.configPath}`); 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 { return {
...result, ...result,
clientTargets: getTargetStatuses(this.config), clientTargets: getTargetStatuses(this.config),
@@ -267,28 +352,66 @@ class ExtensionService {
const nextMaxEntries = partialConfig && partialConfig.maxInteractionLogEntries !== undefined const nextMaxEntries = partialConfig && partialConfig.maxInteractionLogEntries !== undefined
? Number(partialConfig.maxInteractionLogEntries) ? Number(partialConfig.maxInteractionLogEntries)
: this.config.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 = { const nextConfig = {
host: partialConfig && partialConfig.host ? String(partialConfig.host) : this.config.host, host: partialConfig && partialConfig.host ? String(partialConfig.host) : this.config.host,
port: Number.isInteger(nextPort) && nextPort > 0 && nextPort <= 65535 ? nextPort : this.config.port, port: Number.isInteger(nextPort) && nextPort > 0 && nextPort <= 65535 ? nextPort : this.config.port,
toolProfile: partialConfig && partialConfig.toolProfile toolProfile: nextProfile,
? (partialConfig.toolProfile === 'full' ? 'full' : 'core') enabledTools: normalizeList(partialConfig && partialConfig.enabledTools, this.config.enabledTools),
: this.config.toolProfile, 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,
autostart: partialConfig && typeof partialConfig.autostart === 'boolean' autostart: partialConfig && typeof partialConfig.autostart === 'boolean'
? partialConfig.autostart ? partialConfig.autostart
: this.config.autostart, : this.config.autostart,
maxInteractionLogEntries: Number.isInteger(nextMaxEntries) maxInteractionLogEntries: Number.isInteger(nextMaxEntries)
? Math.max(10, Math.min(500, nextMaxEntries)) ? Math.max(10, Math.min(500, nextMaxEntries))
: this.config.maxInteractionLogEntries, : this.config.maxInteractionLogEntries,
lastClientTargetId: partialConfig && partialConfig.lastClientTargetId
? String(partialConfig.lastClientTargetId)
: this.config.lastClientTargetId,
}; };
const configPath = this.config.configPath; const configPath = this.config.configPath;
fs.writeFileSync(configPath, JSON.stringify(nextConfig, null, 2) + '\n', 'utf8'); fs.writeFileSync(configPath, JSON.stringify(nextConfig, null, 2) + '\n', 'utf8');
const wasRunning = Boolean(this.server && this.server.isRunning()); 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 ||
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(); await this.stopServer();
} }
this.reloadRuntime(); this.reloadRuntime();
if (wasRunning) { if (requiresRestart) {
await this.startServer(); await this.startServer();
} }
return this.getPanelState(); return this.getPanelState();
@@ -338,6 +461,9 @@ module.exports = {
callToolFromPanel(name, args) { callToolFromPanel(name, args) {
return service.callToolFromPanel(name, args); return service.callToolFromPanel(name, args);
}, },
checkUpdates() {
return service.checkUpdates();
},
readResourceFromPanel(uri) { readResourceFromPanel(uri) {
return service.readResourceFromPanel(uri); return service.readResourceFromPanel(uri);
}, },
+28
View File
@@ -157,6 +157,32 @@ function selectAsset(uuid) {
return { selected: true, 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() { function getCurrentSelection() {
if (!global.Editor || !Editor.Selection || typeof Editor.Selection.getSelected !== 'function') { if (!global.Editor || !Editor.Selection || typeof Editor.Selection.getSelected !== 'function') {
throw new Error('Editor.Selection API is unavailable in this Cocos environment.'); throw new Error('Editor.Selection API is unavailable in this Cocos environment.');
@@ -170,6 +196,7 @@ function getCurrentSelection() {
} }
module.exports = { module.exports = {
clearSelection,
deleteAsset, deleteAsset,
getCurrentSelection, getCurrentSelection,
listAssets, listAssets,
@@ -179,4 +206,5 @@ module.exports = {
queryAssetMeta, queryAssetMeta,
queryAssetUrl, queryAssetUrl,
selectAsset, selectAsset,
selectNode,
}; };
+41 -2
View File
@@ -6,6 +6,45 @@ const path = require('path');
const SERVER_NAME = 'funplay_cocos'; 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) { function ensureParent(filePath) {
const dir = path.dirname(filePath); const dir = path.dirname(filePath);
if (dir && !fs.existsSync(dir)) { if (dir && !fs.existsSync(dir)) {
@@ -67,7 +106,7 @@ function configureTomlTarget(target) {
} }
function buildTargets(config) { function buildTargets(config) {
const home = os.homedir(); const home = getUserHomePath();
const url = `http://${config.host}:${config.port}/`; const url = `http://${config.host}:${config.port}/`;
return [ return [
@@ -88,7 +127,7 @@ function buildTargets(config) {
{ {
id: 'vscode', id: 'vscode',
name: 'VS Code', name: 'VS Code',
configPath: path.join(home, '.vscode', 'mcp.json'), configPath: getVSCodeConfigPath(home),
rootKey: 'servers', rootKey: 'servers',
entry: { type: 'http', url }, entry: { type: 'http', url },
}, },
+39 -1
View File
@@ -7,8 +7,14 @@ const DEFAULTS = {
host: '127.0.0.1', host: '127.0.0.1',
port: 8765, port: 8765,
toolProfile: 'core', toolProfile: 'core',
enabledTools: [],
disabledTools: [],
enabledToolCategories: [],
disabledToolCategories: [],
enableSessions: false,
autostart: true, autostart: true,
maxInteractionLogEntries: 50, maxInteractionLogEntries: 50,
lastClientTargetId: 'claude_code',
}; };
function getProjectPath() { function getProjectPath() {
@@ -57,7 +63,31 @@ function clampPort(value) {
} }
function normalizeProfile(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() { function loadConfig() {
@@ -71,10 +101,16 @@ function loadConfig() {
host: process.env.COCOS_MCP_HOST || fileConfig.host || DEFAULTS.host, host: process.env.COCOS_MCP_HOST || fileConfig.host || DEFAULTS.host,
port: clampPort(process.env.COCOS_MCP_PORT || fileConfig.port || DEFAULTS.port), port: clampPort(process.env.COCOS_MCP_PORT || fileConfig.port || DEFAULTS.port),
toolProfile: normalizeProfile(process.env.COCOS_MCP_PROFILE || fileConfig.toolProfile || DEFAULTS.toolProfile), 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,
autostart: typeof fileConfig.autostart === 'boolean' ? fileConfig.autostart : DEFAULTS.autostart, autostart: typeof fileConfig.autostart === 'boolean' ? fileConfig.autostart : DEFAULTS.autostart,
maxInteractionLogEntries: Number.isInteger(fileConfig.maxInteractionLogEntries) maxInteractionLogEntries: Number.isInteger(fileConfig.maxInteractionLogEntries)
? Math.max(10, Math.min(500, fileConfig.maxInteractionLogEntries)) ? Math.max(10, Math.min(500, fileConfig.maxInteractionLogEntries))
: DEFAULTS.maxInteractionLogEntries, : DEFAULTS.maxInteractionLogEntries,
lastClientTargetId: normalizeClientTargetId(fileConfig.lastClientTargetId),
configPath, configPath,
configError: fileConfig.__error || '', configError: fileConfig.__error || '',
}; };
@@ -86,4 +122,6 @@ module.exports = {
getProjectName, getProjectName,
getCocosVersion, getCocosVersion,
loadConfig, loadConfig,
normalizeProfile,
normalizeStringList,
}; };
+6
View File
@@ -23,6 +23,12 @@ class InteractionLog {
return this.entries.slice(0, Math.max(1, limit)); return this.entries.slice(0, Math.max(1, limit));
} }
clear() {
const count = this.entries.length;
this.entries.length = 0;
return count;
}
summary(limit = 20) { summary(limit = 20) {
const items = this.list(limit); const items = this.list(limit);
if (!items.length) { if (!items.length) {
+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,
};
+155
View File
@@ -0,0 +1,155 @@
'use strict';
const fs = require('fs');
const path = require('path');
const { resolveProjectPath } = require('./path-safety');
const KNOWN_INSTRUCTION_PATHS = [
'AGENTS.md',
'CLAUDE.md',
'GEMINI.md',
'.cursorrules',
'.windsurfrules',
'.github/copilot-instructions.md',
];
function normalizeSkillName(value) {
const normalized = String(value || '')
.trim()
.toLowerCase()
.replace(/[^a-z0-9_-]+/g, '-')
.replace(/^-+|-+$/g, '');
if (!normalized) {
throw new Error('skillName is required.');
}
return normalized;
}
function statFile(filePath) {
try {
return fs.statSync(filePath);
} catch (error) {
return null;
}
}
function listSkillFiles(projectPath) {
const skillRoot = resolveProjectPath(projectPath, '.codex/skills');
if (!fs.existsSync(skillRoot)) {
return [];
}
const skills = [];
const stack = [skillRoot];
while (stack.length) {
const current = stack.pop();
for (const entry of fs.readdirSync(current, { withFileTypes: true })) {
const fullPath = path.join(current, entry.name);
if (entry.isDirectory()) {
stack.push(fullPath);
continue;
}
if (entry.name === 'SKILL.md') {
const stat = statFile(fullPath);
skills.push({
path: path.relative(projectPath, fullPath).replace(/\\/g, '/'),
size: stat ? stat.size : 0,
mtime: stat ? stat.mtime.toISOString() : '',
});
}
}
}
return skills.sort((left, right) => left.path.localeCompare(right.path));
}
function listProjectInstructions(projectPath) {
const files = [];
for (const relativePath of KNOWN_INSTRUCTION_PATHS) {
const fullPath = resolveProjectPath(projectPath, relativePath);
const stat = statFile(fullPath);
if (stat && stat.isFile()) {
files.push({
path: relativePath,
size: stat.size,
mtime: stat.mtime.toISOString(),
});
}
}
return {
files,
skills: listSkillFiles(projectPath),
};
}
function readProjectInstruction(projectPath, target) {
const relativePath = String(target || '').trim();
if (!relativePath) {
throw new Error('target is required.');
}
const fullPath = resolveProjectPath(projectPath, relativePath);
if (!fs.existsSync(fullPath) || !fs.statSync(fullPath).isFile()) {
throw new Error(`Instruction file not found: ${relativePath}`);
}
return {
path: relativePath,
content: fs.readFileSync(fullPath, 'utf8'),
};
}
function writeProjectInstruction(projectPath, options = {}) {
const relativePath = String(options.target || '').trim();
if (!relativePath) {
throw new Error('target is required.');
}
const content = String(options.content || '');
const fullPath = resolveProjectPath(projectPath, relativePath);
if (fs.existsSync(fullPath) && options.overwrite === false) {
throw new Error(`Instruction file already exists: ${relativePath}`);
}
fs.mkdirSync(path.dirname(fullPath), { recursive: true });
fs.writeFileSync(fullPath, content, 'utf8');
const stat = fs.statSync(fullPath);
return {
written: true,
path: relativePath,
size: stat.size,
mtime: stat.mtime.toISOString(),
};
}
function createProjectSkill(projectPath, options = {}) {
const skillName = normalizeSkillName(options.skillName);
const title = String(options.title || skillName).trim();
const description = String(options.description || `Project-specific workflow for ${title}.`).trim();
const body = String(options.instructions || '').trim() || [
`Use this skill for ${title} work in this Cocos project.`,
'',
'- Inspect the active scene and project context before editing.',
'- Prefer focused MCP tools before broad manual file edits.',
'- Run relevant validation tools after changes.',
].join('\n');
const relativePath = `.codex/skills/${skillName}/SKILL.md`;
const content = [
`# ${title}`,
'',
`Description: ${description}`,
'',
'## Instructions',
body,
'',
].join('\n');
return writeProjectInstruction(projectPath, {
target: relativePath,
content,
overwrite: options.overwrite !== false,
});
}
module.exports = {
KNOWN_INSTRUCTION_PATHS,
createProjectSkill,
listProjectInstructions,
readProjectInstruction,
writeProjectInstruction,
};
+42 -2
View File
@@ -4,6 +4,8 @@ const fs = require('fs');
const path = require('path'); const path = require('path');
const { getCurrentSelection, queryAssetInfo, queryAssetMeta } = require('./assets'); const { getCurrentSelection, queryAssetInfo, queryAssetMeta } = require('./assets');
const { runScriptDiagnostics } = require('./diagnostics'); const { runScriptDiagnostics } = require('./diagnostics');
const { getRecentProjectLogs } = require('./logs');
const { resolveProjectPath } = require('./path-safety');
function createResource(uri, name, description) { function createResource(uri, name, description) {
return { uri, name, description, mimeType: 'text/plain' }; return { uri, name, description, mimeType: 'text/plain' };
@@ -48,10 +50,11 @@ function summarizeSelection() {
} }
class ResourceProvider { class ResourceProvider {
constructor(getRuntimeContext, sceneBridge, interactionLog) { constructor(getRuntimeContext, sceneBridge, interactionLog, runtimeLog) {
this.getRuntimeContext = getRuntimeContext; this.getRuntimeContext = getRuntimeContext;
this.sceneBridge = sceneBridge; this.sceneBridge = sceneBridge;
this.interactionLog = interactionLog; this.interactionLog = interactionLog;
this.runtimeLog = runtimeLog;
} }
listResources() { listResources() {
@@ -64,6 +67,8 @@ class ResourceProvider {
createResource('cocos://selection/current', `${projectName} Current Selection`, 'Summary of the current editor selection.'), 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://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://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.'), createResource('cocos://mcp/interactions', `${projectName} MCP Interactions`, 'Recent MCP tool interaction summaries.'),
]; ];
} }
@@ -120,6 +125,10 @@ class ResourceProvider {
return await this.getSelectedAssetText(); return await this.getSelectedAssetText();
case 'cocos://errors/scripts': case 'cocos://errors/scripts':
return await this.getScriptDiagnosticsText(projectPath); return await this.getScriptDiagnosticsText(projectPath);
case 'cocos://logs/editor':
return this.getEditorLogsText();
case 'cocos://logs/project':
return this.getProjectLogsText(projectPath);
case 'cocos://mcp/interactions': case 'cocos://mcp/interactions':
return this.interactionLog.summary(); return this.interactionLog.summary();
default: default:
@@ -199,7 +208,7 @@ class ResourceProvider {
readAssetByPath(relativePath) { readAssetByPath(relativePath) {
const { projectPath } = this.getRuntimeContext(); const { projectPath } = this.getRuntimeContext();
const fullPath = path.isAbsolute(relativePath) ? relativePath : path.join(projectPath, relativePath); const fullPath = resolveProjectPath(projectPath, relativePath);
if (!fs.existsSync(fullPath)) { if (!fs.existsSync(fullPath)) {
return `Asset not found: ${relativePath}`; return `Asset not found: ${relativePath}`;
} }
@@ -246,6 +255,37 @@ class ResourceProvider {
return `Script diagnostics failed: ${error.message}`; 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 = { 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,
};
+462 -49
View File
@@ -1,14 +1,40 @@
'use strict'; 'use strict';
const crypto = require('crypto');
const http = require('http'); const http = require('http');
const { safeStringify } = require('./utils');
const IMAGE_DATA_URI_PREFIX = 'data:image/png;base64,'; const IMAGE_DATA_URI_PREFIX = 'data:image/png;base64,';
const LOG_PREFIX = '[Funplay Cocos MCP Server]'; 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) { function responseHeaders(protocolVersion = MCP_PROTOCOL_VERSION, extraHeaders = {}) {
response.writeHead(statusCode, { 'Content-Type': 'application/json; charset=utf-8' }); 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)); 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) { function textContent(value) {
if (typeof value === 'string' && value.startsWith(IMAGE_DATA_URI_PREFIX)) { if (typeof value === 'string' && value.startsWith(IMAGE_DATA_URI_PREFIX)) {
return [ 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 { class McpServer {
constructor(options) { constructor(options) {
this.config = options.config; this.config = options.config;
@@ -39,98 +81,444 @@ class McpServer {
this.resourceProvider = options.resourceProvider; this.resourceProvider = options.resourceProvider;
this.promptProvider = options.promptProvider; this.promptProvider = options.promptProvider;
this.interactionLog = options.interactionLog; this.interactionLog = options.interactionLog;
this.runtimeLog = options.runtimeLog;
this.serverName = options.serverName; this.serverName = options.serverName;
this.serverVersion = options.serverVersion; this.serverVersion = options.serverVersion;
this.server = null; this.server = 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() { isRunning() {
return Boolean(this.server && this.server.listening); return Boolean(this.server && this.server.listening);
} }
getPort() {
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;
}
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() { async start() {
if (this.isRunning()) { if (this.isRunning()) {
console.log(`${LOG_PREFIX} Start skipped: already running.`); this.log('info', 'Start skipped: already running.');
return; return;
} }
console.log(`${LOG_PREFIX} Creating HTTP server on ${this.config.host}:${this.config.port}...`); this.actualPort = null;
this.server = http.createServer(async (request, response) => { this.portFallbackInfo = null;
const requestHandler = async (request, response) => {
try { try {
if (request.method === 'GET' && request.url === '/health') { if (request.method === 'GET' && request.url === '/health') {
console.log(`${LOG_PREFIX} GET /health`); this.log('info', 'GET /health');
return json(response, 200, { ok: true, name: this.serverName, version: this.serverVersion }); return json(response, 200, { ok: true, name: this.serverName, version: this.serverVersion }, 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') { if (request.method !== 'POST') {
console.warn(`${LOG_PREFIX} Rejected ${request.method} ${request.url}: method not allowed.`); this.log('warn', `Rejected ${request.method} ${request.url}: method not allowed.`);
return json(response, 405, { error: '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); const body = await this.readBody(request);
if (!body) { 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); let rpc;
if (rpc && rpc.method) { try {
console.log(`${LOG_PREFIX} RPC ${rpc.method}`); 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); const result = await this.handleRpcRequest(rpc);
if (result == null) { if (result == null) {
response.writeHead(204); return empty(response, 202, responseProtocolVersion);
response.end();
return;
} }
return json(response, 200, result); const extraHeaders = {};
} catch (error) { if (this.enableSessions && rpc.method === 'initialize' && result && !result.error) {
console.error(`${LOG_PREFIX} Request handling failed: ${error.message}`); const sessionId = this.createSessionId();
return json(response, 500, this.createError(null, -32603, `Internal error: ${error.message}`)); 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;
if (error && error.code === 'EADDRINUSE' && port < 65535 && attempt < MAX_PORT_FALLBACK_ATTEMPTS) {
const nextPort = port + 1;
this.log(
'warn',
`Port ${port} is already in use. Trying fallback port ${nextPort}...`
);
port = nextPort;
attempt += 1;
continue;
}
candidate.removeAllListeners();
break;
}
}
this.server = null;
this.actualPort = null;
this.portFallbackInfo = null;
throw lastError || new Error('Failed to start MCP server.');
}
async stop() {
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;
await new Promise((resolve, reject) => { await new Promise((resolve, reject) => {
this.server.once('error', reject); active.close((error) => {
this.server.listen(this.config.port, this.config.host, () => { if (error) {
this.server.off('error', reject); this.log('error', `Close failed: ${error.message}`);
console.log(`${LOG_PREFIX} Listening on http://${this.config.host}:${this.config.port}/`); reject(error);
return;
}
this.log('info', 'HTTP server closed.');
resolve(); resolve();
}); });
}); });
} }
async stop() { listen(server, port, host) {
if (!this.server) { return new Promise((resolve, reject) => {
console.log(`${LOG_PREFIX} Stop skipped: server object is empty.`); const onError = (error) => {
return; server.off('listening', onListening);
} reject(error);
};
console.log(`${LOG_PREFIX} Closing HTTP server...`); const onListening = () => {
const active = this.server; server.off('error', onError);
this.server = null;
await new Promise((resolve, reject) => {
active.close((error) => {
if (error) {
console.error(`${LOG_PREFIX} Close failed: ${error.message}`);
reject(error);
return;
}
console.log(`${LOG_PREFIX} HTTP server closed.`);
resolve(); resolve();
}); };
server.once('error', onError);
server.once('listening', onListening);
server.listen(port, host);
}); });
} }
readBody(request) { readBody(request) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const chunks = []; 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('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
request.on('error', reject); 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) { async handleRpcRequest(request) {
if (!request || request.jsonrpc !== '2.0') { if (!request || request.jsonrpc !== '2.0') {
return this.createError(request && request.id, -32600, 'Invalid Request'); return this.createError(request && request.id, -32600, 'Invalid Request');
@@ -142,8 +530,9 @@ class McpServer {
} }
if (method === 'initialize') { if (method === 'initialize') {
this.negotiatedProtocolVersion = this.negotiateProtocolVersion(request.params && request.params.protocolVersion);
return this.createResult(request.id, { return this.createResult(request.id, {
protocolVersion: '2024-11-05', protocolVersion: this.negotiatedProtocolVersion,
serverInfo: { serverInfo: {
name: this.serverName, name: this.serverName,
version: this.serverVersion, version: this.serverVersion,
@@ -171,10 +560,25 @@ class McpServer {
} }
try { try {
const output = await this.toolRegistry.callTool(params.name, params.arguments || {}); const output = typeof this.toolRegistry.callToolDetailed === 'function'
return this.createResult(request.id, { content: textContent(output) }); ? 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) { } 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 +613,13 @@ class McpServer {
return this.createError(request.id, -32601, `Method not found: ${method}`); 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) { createResult(id, result) {
return { return {
jsonrpc: '2.0', jsonrpc: '2.0',
@@ -231,4 +642,6 @@ class McpServer {
module.exports = { module.exports = {
McpServer, McpServer,
MCP_PROTOCOL_VERSION,
SUPPORTED_PROTOCOL_VERSIONS,
}; };
+780 -13
View File
@@ -1,8 +1,10 @@
'use strict'; 'use strict';
const crypto = require('crypto');
const fs = require('fs'); const fs = require('fs');
const path = require('path'); const path = require('path');
const { const {
clearSelection,
deleteAsset, deleteAsset,
getCurrentSelection, getCurrentSelection,
listAssets, listAssets,
@@ -11,11 +13,55 @@ const {
queryAssetInfo, queryAssetInfo,
queryAssetMeta, queryAssetMeta,
selectAsset, selectAsset,
selectNode,
} = require('./assets'); } = require('./assets');
const { runScriptDiagnostics } = require('./diagnostics'); const { runScriptDiagnostics } = require('./diagnostics');
const { listWindows, sendKeyCombo, sendKeyPress, sendMouseClick, sendMouseDrag } = require('./input'); const { listWindows, sendKeyCombo, sendKeyPress, sendMouseClick, sendMouseDrag } = require('./input');
const {
clearProjectLogFiles,
getRecentProjectLogs,
searchProjectLogs,
} = require('./logs');
const { resolveProjectPath } = require('./path-safety');
const {
createProjectSkill,
listProjectInstructions,
readProjectInstruction,
writeProjectInstruction,
} = require('./project-instructions');
const {
applyPrefabInstance,
duplicatePrefab,
editPrefabJson,
inspectPrefab,
revertPrefabInstance,
validatePrefabReferences,
} = require('./prefabs');
const { captureDesktopScreenshot, captureEditorWindowScreenshot, capturePanelScreenshot } = require('./screenshots'); const { captureDesktopScreenshot, captureEditorWindowScreenshot, capturePanelScreenshot } = require('./screenshots');
const { checkForUpdate } = require('./update-checker');
const { safeStringify } = require('./utils'); const { safeStringify } = require('./utils');
const IMAGE_DATA_URI_PREFIX = 'data:image/png;base64,';
const TOOL_CATEGORY_RULES = [
['updates', /update/],
['logs', /log/],
['diagnostics', /diagnostic|validate/],
['screenshots', /screenshot|capture/],
['input', /mouse|key|input|button_click/],
['files', /file|directory|exists|refresh_assets/],
['assets', /asset|scene$|scenes|open_scene|run_scene_asset/],
['prefabs', /prefab/],
['instructions', /instruction|skill/],
['selection', /selection|select_/],
['components', /component/],
['ui', /canvas|label|button|sprite/],
['camera', /camera/],
['animation', /animation|clip/],
['runtime', /runtime|time_scale|node_event|invoke_component/],
['scene', /scene|hierarchy|node/],
['execution', /execute_/],
['project', /project|editor_state|tool_catalog/],
];
function createSchema(properties, required) { function createSchema(properties, required) {
const schema = { const schema = {
@@ -28,6 +74,231 @@ function createSchema(properties, required) {
return schema; return schema;
} }
function createOutputSchema(dataSchema = {}) {
return {
type: 'object',
properties: {
ok: { type: 'boolean', description: 'Whether the tool call completed successfully.' },
tool: { type: 'string', description: 'Tool name that produced this result.' },
callId: { type: 'string', description: 'Stable identifier for this tool call result.' },
timestamp: { type: 'string', description: 'ISO timestamp when the result envelope was produced.' },
summary: { type: 'string', description: 'Short human-readable result summary.' },
data: dataSchema,
refs: {
type: 'array',
description: 'Stable references discovered in the result for follow-up tool calls.',
items: {
type: 'object',
properties: {
type: { type: 'string' },
id: { type: 'string' },
path: { type: 'string' },
name: { type: 'string' },
},
},
},
},
required: ['ok', 'tool', 'callId', 'timestamp', 'data'],
};
}
function inferToolCategory(toolName) {
for (const [category, pattern] of TOOL_CATEGORY_RULES) {
if (pattern.test(toolName)) {
return category;
}
}
return 'other';
}
function normalizeNameSet(values) {
return new Set(
(Array.isArray(values) ? values : [])
.map((value) => String(value || '').trim())
.filter(Boolean)
);
}
function normalizeCategorySet(values) {
return new Set(
(Array.isArray(values) ? values : [])
.map((value) => String(value || '').trim().toLowerCase())
.filter(Boolean)
);
}
function toolCategory(tool) {
return tool.category || inferToolCategory(tool.name);
}
function inferToolAnnotations(tool) {
const name = tool.name;
const category = toolCategory(tool);
const readOnly = /^(get|list|inspect|find|read|search|check|validate|exists|capture)/.test(name);
const destructive = /(delete|remove|clear|replace|write|reset|set_|execute|run_scene|invoke|emit|simulate)/.test(name);
const idempotent = readOnly || /^(set|select|open|pause|resume|stop|refresh)/.test(name);
return {
title: name
.split('_')
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
.join(' '),
readOnlyHint: readOnly,
destructiveHint: readOnly ? false : destructive,
idempotentHint: idempotent,
openWorldHint: category === 'updates',
...(tool.annotations || {}),
};
}
function isToolExposed(config, tool) {
const profile = config && config.toolProfile === 'full'
? 'full'
: config && config.toolProfile === 'custom'
? 'custom'
: 'core';
const category = toolCategory(tool);
const enabledTools = normalizeNameSet(config && config.enabledTools);
const disabledTools = normalizeNameSet(config && config.disabledTools);
const enabledCategories = normalizeCategorySet(config && config.enabledToolCategories);
const disabledCategories = normalizeCategorySet(config && config.disabledToolCategories);
let exposed = profile === 'full' || tool.profile === 'core';
if (profile === 'custom') {
exposed = tool.profile === 'core' || enabledTools.has(tool.name) || enabledCategories.has(category);
} else if (enabledTools.has(tool.name) || enabledCategories.has(category)) {
exposed = true;
}
if (disabledTools.has(tool.name) || disabledCategories.has(category)) {
exposed = false;
}
return exposed;
}
function hashObject(value) {
return crypto
.createHash('sha256')
.update(safeStringify(value))
.digest('hex')
.slice(0, 16);
}
function summarizeResult(result) {
if (typeof result === 'string') {
if (result.startsWith(IMAGE_DATA_URI_PREFIX)) {
return 'Image payload returned.';
}
return result.length > 160 ? `${result.slice(0, 160)}...` : result;
}
if (!result || typeof result !== 'object') {
return String(result);
}
if (typeof result.summary === 'string') {
return result.summary;
}
for (const key of ['message', 'path', 'url', 'sceneName', 'projectName']) {
if (typeof result[key] === 'string' && result[key]) {
return `${key}: ${result[key]}`;
}
}
if (Number.isFinite(result.count)) {
return `count: ${result.count}`;
}
return 'Structured result returned.';
}
function normalizeEnvelopeData(result) {
if (typeof result === 'string' && result.startsWith(IMAGE_DATA_URI_PREFIX)) {
return {
image: true,
mimeType: 'image/png',
byteLength: Buffer.byteLength(result.slice(IMAGE_DATA_URI_PREFIX.length), 'base64'),
};
}
return result;
}
function addRef(refs, type, id, extra = {}) {
if (!id) {
return;
}
const key = `${type}:${id}`;
if (refs.some((ref) => ref.key === key)) {
return;
}
refs.push({ key, type, id: String(id), ...extra });
}
function collectRefs(value, refs = [], depth = 0, seen = new WeakSet()) {
if (!value || depth > 5) {
return refs;
}
if (Array.isArray(value)) {
for (const item of value) {
collectRefs(item, refs, depth + 1, seen);
}
return refs;
}
if (typeof value !== 'object') {
return refs;
}
if (seen.has(value)) {
return refs;
}
seen.add(value);
const uuid = value.uuid || value.prefabUuid || value.sceneUuid || value.assetUuid;
const pathValue = value.path || value.node || value.url;
if (uuid) {
addRef(refs, pathValue && String(pathValue).startsWith('db://') ? 'asset' : 'uuid', uuid, {
path: pathValue ? String(pathValue) : undefined,
name: value.name ? String(value.name) : undefined,
});
}
if (typeof pathValue === 'string' && pathValue) {
addRef(refs, pathValue.startsWith('db://') ? 'asset' : 'path', pathValue, {
name: value.name ? String(value.name) : undefined,
});
}
for (const item of Object.values(value)) {
collectRefs(item, refs, depth + 1, seen);
}
return refs;
}
function createResultEnvelope(tool, args, result, options = {}) {
const data = normalizeEnvelopeData(result);
const refs = collectRefs(data).map(({ key, ...ref }) => ref);
const timestamp = new Date().toISOString();
const summary = options.summary || summarizeResult(result);
const callId = `fp_${hashObject({ tool: tool.name, args: args || {}, result: data })}`;
return {
ok: options.ok !== false,
tool: tool.name,
callId,
timestamp,
summary,
data,
refs,
};
}
function summarizeDiagnostics(result) {
if (!result) {
return null;
}
return {
ok: Boolean(result.ok),
tool: result.tool,
summary: result.summary,
diagnosticCount: Array.isArray(result.diagnostics) ? result.diagnostics.length : 0,
diagnostics: Array.isArray(result.diagnostics) ? result.diagnostics.slice(0, 20) : [],
};
}
function toOutput(value) { function toOutput(value) {
if (typeof value === 'string') { if (typeof value === 'string') {
return value; return value;
@@ -35,10 +306,6 @@ function toOutput(value) {
return safeStringify(value); return safeStringify(value);
} }
function resolveProjectPath(projectPath, rawPath) {
return path.isAbsolute(rawPath) ? rawPath : path.join(projectPath, rawPath);
}
function matchesPattern(fileName, pattern) { function matchesPattern(fileName, pattern) {
const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*'); const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*');
return new RegExp(`^${escaped}$`, 'i').test(fileName); return new RegExp(`^${escaped}$`, 'i').test(fileName);
@@ -124,7 +391,18 @@ async function refreshAssets(projectPath, targetPath) {
return 'File written outside assets directory; no asset-db refresh was needed.'; return 'File written outside assets directory; no asset-db refresh was needed.';
} }
function createToolRegistry({ getRuntimeContext, interactionLog, sceneBridge, editorExecutor }) { async function resolveNodeUuid(sceneBridge, args) {
if (args && args.uuid) {
return String(args.uuid);
}
const inspected = await sceneBridge.call('inspectNode', args || {});
if (!inspected || !inspected.uuid) {
throw new Error('Target node uuid could not be resolved.');
}
return inspected.uuid;
}
function createToolRegistry({ getRuntimeContext, getStatus, interactionLog, runtimeLog, sceneBridge, editorExecutor }) {
const tools = [ const tools = [
{ {
name: 'execute_javascript', name: 'execute_javascript',
@@ -183,6 +461,173 @@ function createToolRegistry({ getRuntimeContext, interactionLog, sceneBridge, ed
return await editorExecutor({ code: args.code, args: args.args || {} }); return await editorExecutor({ code: args.code, args: args.args || {} });
}, },
}, },
{
name: 'get_editor_state',
profile: 'core',
description: '[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.',
inputSchema: createSchema({}, []),
handler: async () => {
const runtimeContext = getRuntimeContext();
const status = typeof getStatus === 'function' ? getStatus() : null;
let scene = null;
try {
const sceneInfo = await sceneBridge.call('getSceneInfo', { maxDepth: 1, includeComponents: false });
scene = sceneInfo
? {
sceneName: sceneInfo.sceneName,
uuid: sceneInfo.uuid,
childCount: sceneInfo.childCount,
}
: null;
} catch (error) {
scene = { error: error.message };
}
let windows = [];
try {
windows = listWindows();
} catch (error) {
windows = [{ error: error.message }];
}
return {
extensionName: runtimeContext.extensionName,
version: runtimeContext.version,
projectName: runtimeContext.projectName,
projectPath: runtimeContext.projectPath,
cocosVersion: runtimeContext.cocosVersion,
toolProfile: runtimeContext.config ? runtimeContext.config.toolProfile : 'core',
status,
selection: getCurrentSelection(),
scene,
windows,
};
},
},
{
name: 'get_tool_catalog',
profile: 'core',
description: '[specialist] Return every built-in MCP tool with profile, category, and current exposure state. Use this before changing custom tool exposure.',
inputSchema: createSchema({}, []),
handler: async () => registry.listToolCatalog(),
},
{
name: 'check_for_updates',
profile: 'core',
description: '[specialist] Check the latest Funplay Cocos MCP GitHub release and compare it with the installed extension version.',
inputSchema: createSchema(
{
timeoutMs: { type: 'number', description: 'Optional network timeout in milliseconds.' },
},
[]
),
handler: async (args) => {
const runtimeContext = getRuntimeContext();
return await checkForUpdate({
currentVersion: runtimeContext.version,
timeoutMs: Number.isFinite(args.timeoutMs) ? args.timeoutMs : 5000,
});
},
},
{
name: 'get_selection',
profile: 'core',
description: '[specialist] Return the current editor selection in a compact structured form. Prefer this when selection state matters for the next action.',
inputSchema: createSchema({}, []),
handler: async () => getCurrentSelection(),
},
{
name: 'list_project_instructions',
profile: 'core',
description: '[specialist] List project AI instruction files and local Codex project skills.',
inputSchema: createSchema({}, []),
handler: async () => {
const { projectPath } = getRuntimeContext();
return listProjectInstructions(projectPath);
},
},
{
name: 'read_project_instruction',
profile: 'core',
description: '[specialist] Read a project AI instruction file such as AGENTS.md, CLAUDE.md, or a .codex skill SKILL.md.',
inputSchema: createSchema(
{
target: { type: 'string', description: 'Project-relative instruction path.' },
},
['target']
),
handler: async (args) => {
const { projectPath } = getRuntimeContext();
return readProjectInstruction(projectPath, args.target);
},
},
{
name: 'write_project_instruction',
profile: 'full',
description: '[core] Create or update a project AI instruction file inside the Cocos project.',
inputSchema: createSchema(
{
target: { type: 'string', description: 'Project-relative instruction path.' },
content: { type: 'string', description: 'Instruction file content.' },
overwrite: { type: 'boolean', description: 'Allow overwriting an existing file. Defaults to true.' },
},
['target', 'content']
),
handler: async (args) => {
const { projectPath } = getRuntimeContext();
return writeProjectInstruction(projectPath, args);
},
},
{
name: 'create_project_skill',
profile: 'full',
description: '[core] Create a local Codex project skill under .codex/skills/{skillName}/SKILL.md.',
inputSchema: createSchema(
{
skillName: { type: 'string', description: 'Filesystem-safe project skill name.' },
title: { type: 'string', description: 'Human-readable skill title.' },
description: { type: 'string', description: 'Skill trigger description.' },
instructions: { type: 'string', description: 'Skill instructions body.' },
overwrite: { type: 'boolean', description: 'Allow overwriting an existing skill. Defaults to true.' },
},
['skillName']
),
handler: async (args) => {
const { projectPath } = getRuntimeContext();
return createProjectSkill(projectPath, args);
},
},
{
name: 'set_selection',
profile: 'core',
description: '[specialist] Set or clear the current editor selection for an asset or node. Use this when downstream editor workflows depend on selection state.',
inputSchema: createSchema(
{
type: { type: 'string', description: 'Selection target type: asset, node, or clear.' },
target: { type: 'string', description: 'Asset uuid/path/db url, or node uuid when type=node.' },
clearMode: { type: 'string', description: 'When type=clear, choose asset, node, or all.' },
},
['type']
),
handler: async (args) => {
const type = String(args.type || '').trim().toLowerCase();
if (type === 'clear') {
return clearSelection(args.clearMode || 'all');
}
if (type === 'asset') {
const info = await queryAssetInfo(args.target);
return selectAsset(info.uuid || args.target);
}
if (type === 'node') {
const target = String(args.target || '').trim();
if (!target) {
throw new Error('target is required when type=node.');
}
return selectNode(target);
}
throw new Error(`Unknown selection type '${args.type}'. Expected asset, node, or clear.`);
},
},
{ {
name: 'get_scene_info', name: 'get_scene_info',
profile: 'core', profile: 'core',
@@ -338,6 +783,166 @@ function createToolRegistry({ getRuntimeContext, interactionLog, sceneBridge, ed
return { count: assets.length, prefabs: assets.slice(0, 200) }; return { count: assets.length, prefabs: assets.slice(0, 200) };
}, },
}, },
{
name: 'inspect_prefab',
profile: 'core',
description: '[specialist] Inspect a prefab asset, its metadata, serialized file path, and UUID-like asset references.',
inputSchema: createSchema(
{
target: { type: 'string', description: 'Prefab uuid, db url, or project path.' },
},
['target']
),
handler: async (args) => {
const { projectPath } = getRuntimeContext();
return await inspectPrefab(projectPath, args.target);
},
},
{
name: 'validate_prefab_references',
profile: 'core',
description: '[specialist] Validate prefab asset references by checking serialized UUID references against asset-db.',
inputSchema: createSchema(
{
target: { type: 'string', description: 'Optional prefab uuid, db url, or path. When omitted, scans prefab assets.' },
pattern: { type: 'string', description: 'Optional asset-db pattern used when scanning prefabs.' },
limit: { type: 'number', description: 'Maximum prefab assets to scan when target is omitted.' },
},
[]
),
handler: async (args) => {
const { projectPath } = getRuntimeContext();
return await validatePrefabReferences(projectPath, args);
},
},
{
name: 'duplicate_prefab',
profile: 'full',
description: '[core] Create a new prefab asset by duplicating an existing prefab file without copying its .meta UUID.',
inputSchema: createSchema(
{
source: { type: 'string', description: 'Source prefab uuid, db url, or project path.' },
target: { type: 'string', description: 'Project-relative target path under assets, with or without .prefab.' },
overwrite: { type: 'boolean', description: 'Overwrite target prefab if it already exists.' },
},
['source', 'target']
),
handler: async (args) => {
const { projectPath } = getRuntimeContext();
const result = await duplicatePrefab(projectPath, args);
return { ...result, refresh: await refreshAssets(projectPath, resolveProjectPath(projectPath, result.target)) };
},
},
{
name: 'edit_prefab_json',
profile: 'full',
description: '[core] Edit a prefab JSON file by JSON path assignment or literal search/replace, then validate references.',
inputSchema: createSchema(
{
target: { type: 'string', description: 'Prefab uuid, db url, or project path.' },
jsonPath: { type: 'string', description: 'JSON path such as /0/_name or 0._name when assigning valueJson.' },
valueJson: { type: 'string', description: 'JSON encoded value to assign at jsonPath.' },
search: { type: 'string', description: 'Literal text to search for instead of jsonPath assignment.' },
replace: { type: 'string', description: 'Replacement text for literal search.' },
replaceAll: { type: 'boolean', description: 'Replace all literal matches.' },
createBackup: { type: 'boolean', description: 'Create a .bak file before writing.' },
},
['target']
),
handler: async (args) => {
const { projectPath } = getRuntimeContext();
const result = await editPrefabJson(projectPath, args);
return { ...result, refresh: await refreshAssets(projectPath, resolveProjectPath(projectPath, result.path)) };
},
},
{
name: 'create_prefab_instance',
profile: 'full',
description: '[core] Create a linked prefab instance in the editor hierarchy using Cocos scene create-node when available.',
inputSchema: createSchema(
{
prefabUuid: { type: 'string', description: 'Prefab asset uuid, db url, or path.' },
parentPath: { type: 'string', description: 'Optional parent node path.' },
name: { type: 'string', description: 'Optional override node name.' },
position: { type: 'object', description: 'Optional position {x,y,z}; fallback runtime path only.' },
},
['prefabUuid']
),
handler: async (args) => {
const info = await queryAssetInfo(args.prefabUuid);
const payload = {
assetUuid: info.uuid || args.prefabUuid,
unlinkPrefab: false,
};
if (args.parentPath) {
payload.parent = await resolveNodeUuid(sceneBridge, { path: args.parentPath });
}
if (args.name) {
payload.name = args.name;
}
if (global.Editor && Editor.Message && typeof Editor.Message.request === 'function') {
try {
const createdUuid = await Editor.Message.request('scene', 'create-node', payload);
return {
created: true,
linkedPrefab: true,
prefabUuid: payload.assetUuid,
uuid: createdUuid,
};
} catch (error) {
runtimeLog && runtimeLog.add('warn', `Linked prefab create-node failed: ${error.message}`);
}
}
return await sceneBridge.call('instantiatePrefab', {
...args,
prefabUuid: info.uuid || args.prefabUuid,
});
},
},
{
name: 'inspect_prefab_instance',
profile: 'core',
description: '[specialist] Inspect whether a scene node is linked to a prefab instance and return prefab metadata when available.',
inputSchema: createSchema(
{
path: { type: 'string', description: 'Node hierarchy path.' },
uuid: { type: 'string', description: 'Node uuid.' },
name: { type: 'string', description: 'Fallback exact node name.' },
},
[]
),
handler: async (args) => sceneBridge.call('getPrefabInstanceInfo', args),
},
{
name: 'apply_prefab_instance',
profile: 'full',
description: '[core] Apply a scene prefab instance back to its associated prefab asset using the Cocos editor scene apply-prefab message.',
inputSchema: createSchema(
{
path: { type: 'string', description: 'Node hierarchy path.' },
uuid: { type: 'string', description: 'Node uuid.' },
name: { type: 'string', description: 'Fallback exact node name.' },
},
[]
),
handler: async (args) => await applyPrefabInstance(await resolveNodeUuid(sceneBridge, args)),
},
{
name: 'revert_prefab_instance',
profile: 'full',
description: '[core] Revert a scene prefab instance from its associated prefab asset using available Cocos editor prefab revert messages.',
inputSchema: createSchema(
{
path: { type: 'string', description: 'Node hierarchy path.' },
uuid: { type: 'string', description: 'Node uuid.' },
name: { type: 'string', description: 'Fallback exact node name.' },
},
[]
),
handler: async (args) => await revertPrefabInstance(await resolveNodeUuid(sceneBridge, args)),
},
{ {
name: 'instantiate_prefab', name: 'instantiate_prefab',
profile: 'full', profile: 'full',
@@ -444,7 +1049,7 @@ function createToolRegistry({ getRuntimeContext, interactionLog, sceneBridge, ed
{ {
name: 'get_editor_selection', name: 'get_editor_selection',
profile: 'full', profile: 'full',
description: '[core] Return the current node and asset selection in the Cocos editor.', description: '[compat] Return the current node and asset selection in the Cocos editor. Prefer get_selection as the primary structured selection read tool.',
inputSchema: createSchema({}, []), inputSchema: createSchema({}, []),
handler: async () => getCurrentSelection(), handler: async () => getCurrentSelection(),
}, },
@@ -936,6 +1541,138 @@ function createToolRegistry({ getRuntimeContext, interactionLog, sceneBridge, ed
return await runScriptDiagnostics(projectPath, args); return await runScriptDiagnostics(projectPath, args);
}, },
}, },
{
name: 'get_recent_logs',
profile: 'core',
description: '[specialist] Return recent MCP runtime logs, recent tool interactions, and tails of common project log files.',
inputSchema: createSchema(
{
limit: { type: 'number', description: 'Maximum in-memory runtime/interactions to return.' },
includeProjectLogs: { type: 'boolean', description: 'Include tails from common project log files.' },
projectLogLines: { type: 'number', description: 'Tail lines to read per project log file.' },
},
[]
),
handler: async (args) => {
const { projectPath } = getRuntimeContext();
const limit = Number.isFinite(args.limit) ? Math.max(1, Math.min(200, args.limit)) : 50;
return {
runtimeLogs: runtimeLog && typeof runtimeLog.list === 'function' ? runtimeLog.list(limit) : [],
interactions: interactionLog && typeof interactionLog.list === 'function' ? interactionLog.list(limit) : [],
projectLogs: args.includeProjectLogs === false
? []
: getRecentProjectLogs(projectPath, {
limit: 10,
lines: Number.isFinite(args.projectLogLines) ? args.projectLogLines : 80,
}),
};
},
},
{
name: 'search_project_logs',
profile: 'core',
description: '[specialist] Search common Cocos project log files for a string or regular expression.',
inputSchema: createSchema(
{
query: { type: 'string', description: 'Text or regex pattern to search for.' },
regex: { type: 'boolean', description: 'Treat query as a JavaScript regular expression.' },
caseSensitive: { type: 'boolean', description: 'Use case-sensitive matching.' },
limit: { type: 'number', description: 'Maximum matches to return.' },
directory: { type: 'string', description: 'Optional project-relative log directory to search.' },
},
['query']
),
handler: async (args) => {
const { projectPath } = getRuntimeContext();
return searchProjectLogs(projectPath, args);
},
},
{
name: 'clear_logs',
profile: 'core',
description: '[specialist] Clear in-memory MCP logs and, only with explicit confirmation, truncate common project log files.',
inputSchema: createSchema(
{
scope: { type: 'string', description: 'mcp, project, or all. Defaults to mcp.' },
confirmProjectLogs: { type: 'boolean', description: 'Required when scope includes project log files.' },
directory: { type: 'string', description: 'Optional project-relative log directory to clear.' },
},
[]
),
handler: async (args) => {
const { projectPath } = getRuntimeContext();
const scope = String(args.scope || 'mcp').toLowerCase();
const clearMcp = scope === 'mcp' || scope === 'all';
const clearProject = scope === 'project' || scope === 'all';
const result = {
runtimeLogEntriesCleared: 0,
interactionEntriesCleared: 0,
projectLogFilesCleared: [],
};
if (clearMcp) {
result.runtimeLogEntriesCleared = runtimeLog && typeof runtimeLog.clear === 'function' ? runtimeLog.clear() : 0;
result.interactionEntriesCleared = interactionLog && typeof interactionLog.clear === 'function' ? interactionLog.clear() : 0;
}
if (clearProject) {
if (!args.confirmProjectLogs) {
throw new Error('confirmProjectLogs=true is required before truncating project log files.');
}
result.projectLogFilesCleared = clearProjectLogFiles(projectPath, { directory: args.directory, limit: 50 });
}
if (!clearMcp && !clearProject) {
throw new Error("scope must be 'mcp', 'project', or 'all'.");
}
return result;
},
},
{
name: 'validate_scene',
profile: 'core',
description: '[specialist] Run a compact validation pass over the active scene, runtime state, TypeScript diagnostics, and recent project log errors.',
inputSchema: createSchema(
{
maxDepth: { type: 'number', description: 'Scene hierarchy depth for the scene snapshot.' },
includeScriptDiagnostics: { type: 'boolean', description: 'Run TypeScript diagnostics as part of validation.' },
includeLogErrors: { type: 'boolean', description: 'Search project logs for error lines.' },
},
[]
),
handler: async (args) => {
const { projectPath } = getRuntimeContext();
const scene = await sceneBridge.call('getSceneInfo', {
maxDepth: Number.isFinite(args.maxDepth) ? args.maxDepth : 2,
includeComponents: true,
}).catch((error) => ({ ok: false, error: error.message }));
const runtime = await sceneBridge.call('getRuntimeState', {}).catch((error) => ({ ok: false, error: error.message }));
const performance = await sceneBridge.call('getPerformanceSnapshot', {}).catch((error) => ({ ok: false, error: error.message }));
const diagnostics = args.includeScriptDiagnostics === false
? null
: summarizeDiagnostics(await runScriptDiagnostics(projectPath, args).catch((error) => ({ ok: false, summary: error.message, diagnostics: [] })));
const logErrors = args.includeLogErrors === false
? null
: searchProjectLogs(projectPath, { query: 'error', limit: 20 }).matches;
return {
ok: !scene.error && !runtime.error && !performance.error && (!diagnostics || diagnostics.ok) && (!logErrors || logErrors.length === 0),
scene,
runtime,
performance,
diagnostics,
logErrors,
};
},
},
{
name: 'get_performance_snapshot',
profile: 'core',
description: '[specialist] Return scene scale and runtime performance-oriented counters such as node/component counts, UI counts, depth, memory, and warnings.',
inputSchema: createSchema({}, []),
handler: async (args) => sceneBridge.call('getPerformanceSnapshot', args),
},
{ {
name: 'get_runtime_state', name: 'get_runtime_state',
profile: 'core', profile: 'core',
@@ -1249,38 +1986,68 @@ function createToolRegistry({ getRuntimeContext, interactionLog, sceneBridge, ed
}, },
]; ];
return { const registry = {
listTools() { listTools() {
const { config } = getRuntimeContext(); const { config } = getRuntimeContext();
return tools return tools
.filter((tool) => config.toolProfile === 'full' || tool.profile === 'core') .filter((tool) => isToolExposed(config || {}, tool))
.map((tool) => ({ .map((tool) => ({
name: tool.name, name: tool.name,
description: tool.description, description: tool.description,
inputSchema: tool.inputSchema, inputSchema: tool.inputSchema,
outputSchema: tool.outputSchema || createOutputSchema(tool.dataSchema),
annotations: inferToolAnnotations(tool),
})); }));
}, },
async callTool(name, args) { listToolCatalog() {
const { config } = getRuntimeContext();
return tools.map((tool) => ({
name: tool.name,
description: tool.description,
profile: tool.profile,
category: toolCategory(tool),
annotations: inferToolAnnotations(tool),
outputSchema: tool.outputSchema || createOutputSchema(tool.dataSchema),
enabled: isToolExposed(config || {}, tool),
}));
},
async callToolDetailed(name, args) {
const { config } = getRuntimeContext(); const { config } = getRuntimeContext();
const tool = tools.find((item) => item.name === name); const tool = tools.find((item) => item.name === name);
if (!tool) { if (!tool) {
throw new Error(`Unknown tool '${name}'`); throw new Error(`Unknown tool '${name}'`);
} }
if (config.toolProfile !== 'full' && tool.profile !== 'core') { if (!isToolExposed(config || {}, tool)) {
throw new Error(`Tool '${name}' is not exposed by the current MCP tool profile '${config.toolProfile}'.`); throw new Error(`Tool '${name}' is not exposed by the current MCP tool profile '${config.toolProfile}'.`);
} }
try { try {
const result = await tool.handler(args || {}); const result = await tool.handler(args || {});
const output = toOutput(result); const envelope = createResultEnvelope(tool, args || {}, result);
interactionLog.add(name, 'success', output.slice(0, 500)); const output = typeof result === 'string' && result.startsWith(IMAGE_DATA_URI_PREFIX)
return output; ? result
: toOutput(envelope);
interactionLog.add(name, 'success', envelope.summary.slice(0, 500));
return {
value: envelope,
text: output,
};
} catch (error) { } catch (error) {
interactionLog.add(name, 'error', error.message); interactionLog.add(name, 'error', error.message);
error.toolEnvelope = createResultEnvelope(tool, args || {}, { message: error.message }, {
ok: false,
summary: error.message,
});
throw error; throw error;
} }
}, },
async callTool(name, args) {
const result = await registry.callToolDetailed(name, args);
return result.text;
},
}; };
return registry;
} }
module.exports = { module.exports = {
+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,
};
+54 -2
View File
@@ -1,13 +1,60 @@
{ {
"name": "funplay-cocos-mcp", "name": "funplay-cocos-mcp",
"package_version": 2, "package_version": 2,
"version": "0.1.0", "version": "0.3.2",
"description": "Embedded MCP server for Cocos Creator with scene script execution, project resources, prompts, and file/scene tools.", "description": "Embedded MCP server for Cocos Creator with scene script execution, project resources, prompts, and file/scene tools.",
"author": "Funplay", "author": "Funplay",
"license": "MIT", "license": "MIT",
"main": "browser.js", "main": "browser.js",
"bin": {
"funplay-cocos-mcp": "bin/funplay-cocos-mcp.js"
},
"mcpName": "io.github.FunplayAI/funplay-cocos-mcp",
"repository": {
"type": "git",
"url": "git+https://github.com/FunplayAI/funplay-cocos-mcp.git"
},
"homepage": "https://github.com/FunplayAI/funplay-cocos-mcp#readme",
"bugs": {
"url": "https://github.com/FunplayAI/funplay-cocos-mcp/issues"
},
"keywords": [
"mcp",
"cocos",
"cocos-creator",
"funplay",
"model-context-protocol",
"ai"
],
"engines": {
"node": ">=18"
},
"files": [
"bin/",
"lib/",
"panel/",
"browser.js",
"scene.js",
"server.json",
"README.md",
"README_CN.md",
"CHANGELOG.md",
"CONTRIBUTING.md",
"LICENSE",
"RELEASE_WORKFLOW.md",
"RELEASE_CHECKLIST.md"
],
"publishConfig": {
"access": "public"
},
"scripts": { "scripts": {
"check": "node --check browser.js && node --check scene.js && node --check panel/index.js && node --check lib/assets.js && node --check lib/client-config.js && node --check lib/config.js && node --check lib/diagnostics.js && node --check lib/electron-tools.js && node --check lib/input.js && node --check lib/interaction-log.js && node --check lib/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/logs.js && node --check lib/path-safety.js && node --check lib/prefabs.js && node --check lib/project-instructions.js && node --check lib/prompts.js && node --check lib/resources.js && node --check lib/runtime-log.js && node --check lib/screenshots.js && node --check lib/server.js && node --check lib/tool-registry.js && node --check lib/update-checker.js && node --check lib/utils.js && node --check scripts/release.js",
"test": "node --test",
"pack:dry-run": "npm pack --dry-run",
"registry:validate": "mcp-publisher validate server.json",
"release:check": "node scripts/release.js check",
"release:package": "node scripts/release.js package",
"release:verify": "npm run check && npm test && npm run release:check && npm run pack:dry-run && npm run release:package"
}, },
"panels": { "panels": {
"default": { "default": {
@@ -76,6 +123,11 @@
"callToolFromPanel" "callToolFromPanel"
] ]
}, },
"check-updates": {
"methods": [
"checkUpdates"
]
},
"read-resource": { "read-resource": {
"methods": [ "methods": [
"readResourceFromPanel" "readResourceFromPanel"
+127 -4
View File
@@ -19,7 +19,7 @@ module.exports = Editor.Panel.define({
<header class="hero"> <header class="hero">
<div> <div>
<h1>Funplay Cocos MCP</h1> <h1>Funplay Cocos MCP</h1>
<p>Operate the embedded MCP server from Cocos Creator.</p> <p id="versionText">Version</p>
</div> </div>
<div class="status-pill" id="statusPill">Unknown</div> <div class="status-pill" id="statusPill">Unknown</div>
</header> </header>
@@ -34,6 +34,7 @@ module.exports = Editor.Panel.define({
</label> </label>
<ui-button id="restartBtn">Restart</ui-button> <ui-button id="restartBtn">Restart</ui-button>
<ui-button id="copyUrlBtn">Copy URL</ui-button> <ui-button id="copyUrlBtn">Copy URL</ui-button>
<ui-button id="checkUpdatesBtn">Check Updates</ui-button>
</div> </div>
<div class="grid"> <div class="grid">
<label>Server Port <ui-num-input id="portInput"></ui-num-input></label> <label>Server Port <ui-num-input id="portInput"></ui-num-input></label>
@@ -41,12 +42,34 @@ module.exports = Editor.Panel.define({
<ui-select id="profileSelect"> <ui-select id="profileSelect">
<option value="core">core</option> <option value="core">core</option>
<option value="full">full</option> <option value="full">full</option>
<option value="custom">custom</option>
</ui-select> </ui-select>
</label> </label>
<label class="checkbox-line">
<ui-checkbox id="sessionsInput"></ui-checkbox>
MCP Sessions
</label>
</div> </div>
<div id="updateStatus" class="client-status muted"></div>
<p>Changes auto-save. Port/profile changes restart the server when needed.</p> <p>Changes auto-save. Port/profile changes restart the server when needed.</p>
</section> </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="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"> <section class="card">
<h2>MCP Client Config</h2> <h2>MCP Client Config</h2>
<div class="row"> <div class="row">
@@ -129,6 +152,12 @@ module.exports = Editor.Panel.define({
gap: 8px; gap: 8px;
align-items: end; align-items: end;
} }
.tool-config-grid {
display: grid;
grid-template-columns: repeat(2, minmax(180px, 1fr));
gap: 8px;
margin-top: 8px;
}
label { label {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -138,6 +167,7 @@ module.exports = Editor.Panel.define({
.checkbox-line { .checkbox-line {
flex-direction: row; flex-direction: row;
align-items: center; align-items: center;
min-height: 28px;
} }
.checkbox-inline { .checkbox-inline {
padding-top: 0; padding-top: 0;
@@ -184,6 +214,9 @@ module.exports = Editor.Panel.define({
width: 100%; width: 100%;
min-height: 100px; min-height: 100px;
} }
.tool-config-grid ui-textarea {
min-height: 72px;
}
pre { pre {
min-height: 120px; min-height: 120px;
max-height: 220px; max-height: 220px;
@@ -203,12 +236,24 @@ module.exports = Editor.Panel.define({
$: { $: {
root: '.mcp-root', root: '.mcp-root',
statusPill: '#statusPill', statusPill: '#statusPill',
versionText: '#versionText',
statusText: '#statusText', statusText: '#statusText',
enabledInput: '#enabledInput', enabledInput: '#enabledInput',
portInput: '#portInput', portInput: '#portInput',
profileSelect: '#profileSelect', profileSelect: '#profileSelect',
sessionsInput: '#sessionsInput',
restartBtn: '#restartBtn', restartBtn: '#restartBtn',
copyUrlBtn: '#copyUrlBtn', copyUrlBtn: '#copyUrlBtn',
checkUpdatesBtn: '#checkUpdatesBtn',
updateStatus: '#updateStatus',
toolSummary: '#toolSummary',
useCoreBtn: '#useCoreBtn',
useFullBtn: '#useFullBtn',
useCustomBtn: '#useCustomBtn',
enabledCategoriesInput: '#enabledCategoriesInput',
disabledCategoriesInput: '#disabledCategoriesInput',
enabledToolsInput: '#enabledToolsInput',
disabledToolsInput: '#disabledToolsInput',
clientTargetSelect: '#clientTargetSelect', clientTargetSelect: '#clientTargetSelect',
configureClientBtn: '#configureClientBtn', configureClientBtn: '#configureClientBtn',
clientTargetStatus: '#clientTargetStatus', clientTargetStatus: '#clientTargetStatus',
@@ -230,21 +275,64 @@ module.exports = Editor.Panel.define({
const config = state.config || {}; const config = state.config || {};
const isRunning = Boolean(status.running); const isRunning = Boolean(status.running);
this.$.versionText.textContent = `Version ${status.version || 'unknown'}`;
this.$.statusPill.textContent = isRunning ? 'Running' : 'Stopped'; this.$.statusPill.textContent = isRunning ? 'Running' : 'Stopped';
this.$.statusPill.classList.toggle('running', isRunning); this.$.statusPill.classList.toggle('running', isRunning);
this.$.statusPill.classList.toggle('stopped', !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}`
: '';
this.$.statusText.textContent =
`${status.url || ''} | Project: ${status.projectName || ''} | Cocos ${status.cocosVersion || ''}${portText}`;
this.$.enabledInput.value = Boolean(isRunning || config.autostart); this.$.enabledInput.value = Boolean(isRunning || config.autostart);
this.$.portInput.value = Number(config.port || status.port || 8765); this.$.portInput.value = Number(config.port || status.port || 8765);
this.$.profileSelect.value = config.toolProfile || status.toolProfile || 'core'; this.$.profileSelect.value = config.toolProfile || status.toolProfile || 'core';
this.$.sessionsInput.value = Boolean(config.enableSessions || status.enableSessions);
this.$.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.renderUpdateStatus();
this.renderToolSummary();
this.$.clientConfigText.value = state.clientConfig ? state.clientConfig.codex : ''; this.$.clientConfigText.value = state.clientConfig ? state.clientConfig.codex : '';
this.renderClientTargets(); this.renderClientTargets();
}, },
formatList(value) {
return Array.isArray(value) ? value.join('\n') : '';
},
parseList(value) {
return String(value || '')
.split(/[\n,]/)
.map((item) => item.trim())
.filter(Boolean);
},
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(', ')}`;
},
renderClientTargets() { renderClientTargets() {
const targets = (this.state && this.state.clientTargets) || []; 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 this.$.clientTargetSelect.innerHTML = targets
.map((target) => `<option value="${target.id}">${target.name}</option>`) .map((target) => `<option value="${target.id}">${target.name}</option>`)
.join(''); .join('');
@@ -294,8 +382,14 @@ 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', 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), port: Number(this.$.portInput.value || 8765),
toolProfile: this.$.profileSelect.value || 'core', 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),
autostart: Boolean(this.$.enabledInput.value), autostart: Boolean(this.$.enabledInput.value),
maxInteractionLogEntries: this.state && this.state.config ? this.state.config.maxInteractionLogEntries : 50, maxInteractionLogEntries: this.state && this.state.config ? this.state.config.maxInteractionLogEntries : 50,
lastClientTargetId: this.$.clientTargetSelect.value || 'claude_code',
}; };
}, },
async handleEnableToggle() { async handleEnableToggle() {
@@ -325,11 +419,40 @@ module.exports = Editor.Panel.define({
.then(() => this.showOutput('Copied URL to clipboard.')) .then(() => this.showOutput('Copied URL to clipboard.'))
.catch(() => this.showOutput(text)); .catch(() => this.showOutput(text));
}); });
this.$.checkUpdatesBtn.addEventListener('click', () => this.runAction(() => request('check-updates')));
this.$.enabledInput.addEventListener('change', () => this.handleEnableToggle()); this.$.enabledInput.addEventListener('change', () => this.handleEnableToggle());
this.$.portInput.addEventListener('change', () => this.persistConfig({ showOutput: true })); this.$.portInput.addEventListener('change', () => this.persistConfig({ showOutput: true }));
this.$.profileSelect.addEventListener('change', () => this.persistConfig({ showOutput: true })); this.$.profileSelect.addEventListener('change', () => this.persistConfig({ showOutput: true }));
this.$.sessionsInput.addEventListener('change', () => this.persistConfig({ showOutput: true }));
this.$.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.$.clientTargetSelect.addEventListener('confirm', () => this.renderClientTargetStatus()); this.$.clientTargetSelect.addEventListener('confirm', () => this.renderClientTargetStatus());
this.$.clientTargetSelect.addEventListener('change', () => this.renderClientTargetStatus()); this.$.clientTargetSelect.addEventListener('change', () => {
this.renderClientTargetStatus();
this.persistConfig();
});
this.$.configureClientBtn.addEventListener('click', () => { this.$.configureClientBtn.addEventListener('click', () => {
const targetId = this.$.clientTargetSelect.value; const targetId = this.$.clientTargetSelect.value;
if (!targetId) { if (!targetId) {
+130
View File
@@ -308,6 +308,97 @@ function findComponentsByClass(componentClass) {
return results; return results;
} }
function getPrefabInfo(node) {
const prefab = node && node._prefab;
if (!prefab) {
return {
linked: false,
};
}
const asset = prefab.asset || prefab._asset || null;
return {
linked: Boolean(asset || prefab.fileId || prefab.root),
fileId: prefab.fileId || '',
asset: asset
? {
name: asset.name || '',
uuid: asset.uuid || asset._uuid || '',
}
: null,
instance: prefab.instance ? plain(prefab.instance) : null,
sync: prefab.sync,
rawKeys: Object.keys(prefab).slice(0, 50),
};
}
function collectSceneStats() {
const stats = {
nodeCount: 0,
activeNodeCount: 0,
inactiveNodeCount: 0,
maxDepth: 0,
componentCount: 0,
componentsByType: {},
prefabInstanceCount: 0,
uiTransformCount: 0,
canvasCount: 0,
cameraCount: 0,
labelCount: 0,
spriteCount: 0,
buttonCount: 0,
};
function visit(node, depth) {
if (node !== getScene()) {
stats.nodeCount += 1;
stats.maxDepth = Math.max(stats.maxDepth, depth);
if (node.active) stats.activeNodeCount += 1;
else stats.inactiveNodeCount += 1;
if (node._prefab) stats.prefabInstanceCount += 1;
}
for (const component of node.components || []) {
const name = component && component.constructor ? component.constructor.name : 'UnknownComponent';
stats.componentCount += 1;
stats.componentsByType[name] = (stats.componentsByType[name] || 0) + 1;
if (component instanceof UITransform) stats.uiTransformCount += 1;
if (component instanceof Canvas) stats.canvasCount += 1;
if (component instanceof Camera) stats.cameraCount += 1;
if (component instanceof Label) stats.labelCount += 1;
if (component instanceof Sprite) stats.spriteCount += 1;
if (component instanceof Button) stats.buttonCount += 1;
}
for (const child of node.children) {
visit(child, depth + 1);
}
}
visit(getScene(), 0);
return stats;
}
function buildSceneWarnings(stats) {
const warnings = [];
if (stats.nodeCount === 0) {
warnings.push({ severity: 'warn', code: 'empty_scene', message: 'The active scene has no child nodes.' });
}
if (stats.cameraCount === 0) {
warnings.push({ severity: 'warn', code: 'missing_camera', message: 'No Camera component was found in the active scene.' });
}
if (stats.nodeCount > 500) {
warnings.push({ severity: 'info', code: 'large_node_count', message: `Scene has ${stats.nodeCount} nodes.` });
}
if (stats.maxDepth > 12) {
warnings.push({ severity: 'info', code: 'deep_hierarchy', message: `Scene hierarchy depth is ${stats.maxDepth}.` });
}
if (stats.labelCount > 80) {
warnings.push({ severity: 'info', code: 'many_labels', message: `Scene has ${stats.labelCount} Label components.` });
}
return warnings;
}
function getScheduler() { function getScheduler() {
return typeof director.getScheduler === 'function' ? director.getScheduler() : null; return typeof director.getScheduler === 'function' ? director.getScheduler() : null;
} }
@@ -1149,6 +1240,45 @@ exports.methods = {
}; };
}, },
async getPerformanceSnapshot() {
const scheduler = getScheduler();
const stats = collectSceneStats();
const memory = typeof performance !== 'undefined' && performance.memory
? {
jsHeapSizeLimit: performance.memory.jsHeapSizeLimit,
totalJSHeapSize: performance.memory.totalJSHeapSize,
usedJSHeapSize: performance.memory.usedJSHeapSize,
}
: null;
return {
sceneName: getScene().name,
runtime: {
paused: typeof director.isPaused === 'function' ? director.isPaused() : false,
timeScale: scheduler && typeof scheduler.getTimeScale === 'function' ? scheduler.getTimeScale() : 1,
totalFrames: typeof director.getTotalFrames === 'function' ? director.getTotalFrames() : undefined,
},
stats,
memory,
warnings: buildSceneWarnings(stats),
};
},
async getPrefabInstanceInfo(options = {}) {
const node = findNode(options);
if (!node) {
throw new Error('Target node was not found.');
}
return {
node: {
name: node.name,
path: getNodePath(node),
uuid: node.uuid,
},
prefab: getPrefabInfo(node),
};
},
async pauseRuntime() { async pauseRuntime() {
if (typeof director.pause === 'function') { if (typeof director.pause === 'function') {
director.pause(); director.pause();
+539
View File
@@ -0,0 +1,539 @@
#!/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',
'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',
'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'
]);
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('- 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);
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 checksums = [
checksumLine(zipPath, zipName),
checksumLine(manifestPath, 'release-manifest.json'),
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 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 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.3.2",
"packages": [
{
"registryType": "npm",
"identifier": "funplay-cocos-mcp",
"version": "0.3.2",
"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
}
]
}
]
}
+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);
});
+48
View File
@@ -0,0 +1,48 @@
'use strict';
const assert = require('node:assert/strict');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const test = require('node:test');
const {
createProjectSkill,
listProjectInstructions,
readProjectInstruction,
writeProjectInstruction,
} = require('../lib/project-instructions');
test('project instruction helpers list, read, and write safe project files', () => {
const projectPath = fs.mkdtempSync(path.join(os.tmpdir(), 'funplay-cocos-instructions-'));
const write = writeProjectInstruction(projectPath, {
target: 'AGENTS.md',
content: '# Agent Notes\n',
});
assert.equal(write.written, true);
assert.equal(readProjectInstruction(projectPath, 'AGENTS.md').content, '# Agent Notes\n');
const listed = listProjectInstructions(projectPath);
assert.equal(listed.files.some((file) => file.path === 'AGENTS.md'), true);
});
test('createProjectSkill writes a Codex project skill', () => {
const projectPath = fs.mkdtempSync(path.join(os.tmpdir(), 'funplay-cocos-skill-'));
const result = createProjectSkill(projectPath, {
skillName: 'scene qa',
title: 'Scene QA',
description: 'Validate Cocos scenes.',
});
assert.equal(result.path, '.codex/skills/scene-qa/SKILL.md');
const listed = listProjectInstructions(projectPath);
assert.equal(listed.skills.some((skill) => skill.path === result.path), true);
});
test('project instruction helpers reject traversal outside the project', () => {
const projectPath = fs.mkdtempSync(path.join(os.tmpdir(), 'funplay-cocos-instructions-safe-'));
assert.throws(
() => writeProjectInstruction(projectPath, { target: '../AGENTS.md', content: 'x' }),
/outside the Cocos project/
);
});
+241
View File
@@ -0,0 +1,241 @@
'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 = {}) {
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: 'test-server',
serverVersion: '0.0.0-test',
});
}
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);
});
}
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');
});
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 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));
}
});
+100
View File
@@ -0,0 +1,100 @@
'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, 34);
assert.equal(tools.some((tool) => tool.name === 'execute_javascript'), true);
assert.equal(tools.some((tool) => tool.name === 'get_editor_state'), true);
assert.equal(tools.some((tool) => tool.name === 'get_tool_catalog'), true);
assert.equal(tools.some((tool) => tool.name === 'validate_scene'), true);
assert.equal(tools.some((tool) => tool.name === 'get_performance_snapshot'), true);
assert.equal(tools.some((tool) => tool.name === 'list_project_instructions'), true);
assert.equal(tools.some((tool) => tool.name === 'set_selection'), true);
assert.equal(tools.some((tool) => tool.name === 'write_file'), false);
});
test('full profile exposes all built-in tools', () => {
const tools = createRegistry('full').listTools();
assert.equal(tools.length, 89);
assert.equal(tools.some((tool) => tool.name === 'write_file'), true);
assert.equal(tools.some((tool) => tool.name === 'edit_prefab_json'), true);
assert.equal(tools.some((tool) => tool.name === 'create_project_skill'), true);
assert.equal(tools.some((tool) => tool.name === 'get_editor_state'), true);
assert.equal(tools.some((tool) => tool.name === 'set_selection'), true);
});
test('tool definitions include MCP outputSchema and annotations', () => {
const tool = createRegistry('core').listTools().find((item) => item.name === 'get_project_info');
assert.equal(tool.outputSchema.type, 'object');
assert.equal(tool.outputSchema.properties.ok.type, 'boolean');
assert.equal(tool.annotations.readOnlyHint, true);
});
test('custom profile can expose a category and disable a specific tool', () => {
const tools = createRegistry('custom', path.resolve('/tmp/funplay-cocos-test-project'), {
enabledToolCategories: ['files'],
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');
});
+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);
});