Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e3dc45468b | ||
|
|
63c18b0b5f | ||
|
|
c57cf140a8 | ||
|
|
3ab48e2457 |
@@ -75,3 +75,9 @@ jobs:
|
||||
|
||||
- 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
|
||||
|
||||
@@ -6,3 +6,5 @@ Library/
|
||||
library/
|
||||
dist/
|
||||
build/
|
||||
.release-tmp/
|
||||
releases/
|
||||
|
||||
@@ -6,6 +6,61 @@ This project follows a simple changelog format inspired by [Keep a Changelog](ht
|
||||
|
||||
## [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
|
||||
|
||||
@@ -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.
|
||||
|
||||
For a non-git install, download `Funplay.CocosMcp.v<version>.zip` from the GitHub Releases page, unzip it, and move the extracted `funplay-cocos-mcp` folder into your project `extensions/` directory.
|
||||
|
||||
You can also install it globally by copying the folder into your Cocos Creator user extensions directory.
|
||||
|
||||
### 2. Start the MCP Server
|
||||
@@ -69,7 +71,9 @@ The panel is intentionally small:
|
||||
|
||||
- Enable or disable the MCP server
|
||||
- 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
|
||||
- Expand debug output only when needed
|
||||
|
||||
@@ -175,6 +179,31 @@ url = "http://127.0.0.1:8765/"
|
||||
|
||||
</details>
|
||||
|
||||
### Optional: npm stdio Wrapper
|
||||
|
||||
If your MCP client prefers a local `stdio` command, install the npm wrapper after starting the Cocos editor server:
|
||||
|
||||
```bash
|
||||
npm install -g funplay-cocos-mcp
|
||||
```
|
||||
|
||||
Example MCP client entry:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"funplay_cocos": {
|
||||
"command": "funplay-cocos-mcp",
|
||||
"env": {
|
||||
"FUNPLAY_COCOS_MCP_URL": "http://127.0.0.1:8765/"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The wrapper bridges stdio MCP traffic to the embedded Cocos HTTP endpoint. You can also run it with `npx funplay-cocos-mcp --url http://127.0.0.1:8765/`.
|
||||
|
||||
### 4. Verify the Connection
|
||||
|
||||
Open your AI client and try a few safe requests first:
|
||||
@@ -197,7 +226,10 @@ Try a higher-level prompt in your AI client:
|
||||
- This extension is **Editor-only**. It is meant to automate Cocos Creator, not to add runtime dependencies to your final game build.
|
||||
- The MCP server listens on `http://127.0.0.1:8765/` by default.
|
||||
- If the configured port is busy, the server automatically falls back to the next available port and the panel/client config use the actual running port.
|
||||
- The default `core` profile exposes 22 high-signal tools. Switch to `full` in the panel if you want all 70 tools exposed.
|
||||
- The default `core` profile exposes 34 high-signal tools. Switch to `full` for all 89 tools, or use `custom` to include/exclude tool categories and individual tools.
|
||||
- The panel includes a manual update check against the latest GitHub release.
|
||||
- Streamable HTTP responses follow the MCP transport requirements for `Accept`, `MCP-Protocol-Version`, JSON-RPC notifications/responses, and optional `Mcp-Session-Id` sessions.
|
||||
- Tool listings include MCP `outputSchema` and `annotations`; structured tool results use a standard envelope with `ok`, `tool`, `callId`, `summary`, `data`, and follow-up `refs`.
|
||||
- All exposed MCP tools execute directly. There is no extra approval toggle inside the Cocos extension.
|
||||
- File tools and `cocos://asset/path/...` resources are restricted to the active Cocos project root.
|
||||
- The recommended workflow is `execute_javascript` first, then focused helper tools for screenshots, diagnostics, assets, and inspection.
|
||||
@@ -208,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
|
||||
- **Embedded Cocos Extension** — No separate Python daemon or external bridge process is required for the Cocos-side plugin
|
||||
- **One-Click Client Configuration** — Configure Claude Code, Cursor, VS Code, Trae, Kiro, and Codex directly from Cocos Creator
|
||||
- **Project Context Built In** — Exposes live project, scene, selection, script diagnostics, and interaction-history resources
|
||||
- **Focused by Default, Full When Needed** — `core` reduces tool-list noise; `full` exposes every available tool
|
||||
- **Project Context Built In** — Exposes live project, scene, selection, script diagnostics, logs, and interaction-history resources
|
||||
- **Focused by Default, Full When Needed** — `core` reduces tool-list noise; `full` exposes every available tool; `custom` lets you tune by category or tool
|
||||
- **Visual Validation** — Scene/editor/preview screenshots and input simulation help AI verify UI and gameplay changes
|
||||
|
||||
## Highlights
|
||||
|
||||
- **70 Built-in Tools** — Scene hierarchy, editor state, selection workflows, 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
|
||||
- **Resources & Prompts** — Live project resources plus reusable workflows like script fixing, scene validation, and playable prototype creation
|
||||
- **Cocos Panel UI** — A minimal `Funplay > MCP Server` panel for service management and MCP client setup
|
||||
- **Resources & Prompts** — Live project/log resources plus reusable workflows like script fixing, scene validation, and playable prototype creation
|
||||
- **Cocos Panel UI** — A 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
|
||||
- **Vendor Agnostic** — Works with any AI client that supports MCP over HTTP JSON-RPC
|
||||
|
||||
@@ -231,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 |
|
||||
| Primary execution tool | `execute_javascript` | `execute_code` |
|
||||
| Primary language | JavaScript in scene/editor contexts | C# in Unity editor/runtime contexts |
|
||||
| Default profile | `core` with 22 tools | `core` focused tool profile |
|
||||
| Full profile | 70 tools | 79 tools |
|
||||
| Default profile | `core` with 34 tools | `core` focused tool profile |
|
||||
| Full profile | 89 tools plus `custom` exposure | 79 tools |
|
||||
| Client setup | One-click config panel | One-click config window |
|
||||
|
||||
## MCP Capabilities
|
||||
|
||||
The current package exposes four capability layers:
|
||||
|
||||
- **Tools** — 22 tools in `core`, 70 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
|
||||
- **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_editor_state`, `get_selection`, `set_selection`, `get_project_info`, `get_scene_info`, `get_hierarchy`, `list_scenes`, `open_scene`, `list_assets`, `inspect_asset`, `open_asset`, `select_asset`, `run_script_diagnostics`, `get_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
|
||||
|
||||
@@ -257,25 +289,28 @@ The default `core` set is intentionally small: `execute_javascript`, `execute_sc
|
||||
| `cocos://selection/current` | Current editor selection |
|
||||
| `cocos://selection/asset` | Current selected asset |
|
||||
| `cocos://errors/scripts` | Script diagnostics |
|
||||
| `cocos://logs/editor` | Recent MCP runtime logs and tool interactions |
|
||||
| `cocos://logs/project` | Recent tails from common project log files |
|
||||
| `cocos://mcp/interactions` | Recent MCP interaction history |
|
||||
|
||||
## Built-in Tools
|
||||
|
||||
Funplay MCP for Cocos currently ships with **70 tool functions** in the `full` profile:
|
||||
Funplay MCP for Cocos currently ships with **89 tool functions** in the `full` profile:
|
||||
|
||||
| Category | Tools |
|
||||
|----------|-------|
|
||||
| **Script Execution** | `execute_javascript`, `execute_scene_script`, `execute_editor_script` |
|
||||
| **Editor State** | `get_editor_state`, `get_selection`, `set_selection`, `get_editor_selection` |
|
||||
| **Editor State** | `get_editor_state`, `get_tool_catalog`, `check_for_updates`, `get_selection`, `set_selection`, `get_editor_selection` |
|
||||
| **Project Instructions** | `list_project_instructions`, `read_project_instruction`, `write_project_instruction`, `create_project_skill` |
|
||||
| **Project & Scene** | `get_project_info`, `get_scene_info`, `get_hierarchy`, `find_nodes`, `inspect_node`, `list_scenes`, `open_scene`, `run_scene_asset` |
|
||||
| **Node Editing** | `create_node`, `delete_node`, `set_node_transform` |
|
||||
| **Assets & Prefabs** | `list_assets`, `inspect_asset`, `open_asset`, `select_asset`, `delete_asset`, `list_prefabs`, `instantiate_prefab`, `get_editor_selection` |
|
||||
| **Assets & Prefabs** | `list_assets`, `inspect_asset`, `open_asset`, `select_asset`, `delete_asset`, `list_prefabs`, `inspect_prefab`, `validate_prefab_references`, `duplicate_prefab`, `edit_prefab_json`, `create_prefab_instance`, `inspect_prefab_instance`, `apply_prefab_instance`, `revert_prefab_instance`, `instantiate_prefab` |
|
||||
| **Components** | `list_components`, `inspect_component`, `add_component`, `remove_component`, `set_component_property`, `reset_component_property` |
|
||||
| **UI** | `create_canvas`, `create_label`, `create_button`, `create_sprite` |
|
||||
| **Camera** | `list_cameras`, `create_camera`, `set_camera_properties` |
|
||||
| **Animation** | `list_animations`, `add_animation_clip`, `play_animation`, `stop_animation` |
|
||||
| **Files** | `read_file`, `get_file_snippet`, `write_file`, `replace_in_file`, `search_files`, `list_directory`, `exists`, `refresh_assets` |
|
||||
| **Diagnostics** | `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` |
|
||||
| **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` |
|
||||
@@ -313,6 +348,11 @@ Place `funplay-cocos-mcp.config.json` in the Cocos project root:
|
||||
"host": "127.0.0.1",
|
||||
"port": 8765,
|
||||
"toolProfile": "core",
|
||||
"enabledToolCategories": [],
|
||||
"disabledToolCategories": [],
|
||||
"enabledTools": [],
|
||||
"disabledTools": [],
|
||||
"enableSessions": false,
|
||||
"autostart": true,
|
||||
"maxInteractionLogEntries": 50
|
||||
}
|
||||
@@ -324,6 +364,8 @@ Environment variables are also supported:
|
||||
- `COCOS_MCP_PORT`
|
||||
- `COCOS_MCP_PROFILE`
|
||||
|
||||
`toolProfile: "custom"` starts from the `core` set, then adds `enabledToolCategories` / `enabledTools` and removes `disabledToolCategories` / `disabledTools`. `enableSessions` is off by default because this server does not need cross-request client state for normal editor automation.
|
||||
|
||||
## Architecture
|
||||
|
||||
```text
|
||||
@@ -347,10 +389,27 @@ The server speaks MCP-style HTTP JSON-RPC 2.0 and supports tools, resources, res
|
||||
|
||||
## Development
|
||||
|
||||
Run a syntax check before publishing changes:
|
||||
Run checks before publishing changes:
|
||||
|
||||
```bash
|
||||
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
|
||||
|
||||
+76
-17
@@ -51,6 +51,8 @@ git clone https://github.com/FunplayAI/funplay-cocos-mcp.git extensions/funplay-
|
||||
|
||||
然后重启 Cocos Creator,或在编辑器里重新加载扩展。
|
||||
|
||||
如果不想用 git 安装,可以从 GitHub Releases 下载 `Funplay.CocosMcp.v<version>.zip`,解压后把 `funplay-cocos-mcp` 目录移动到项目的 `extensions/` 目录。
|
||||
|
||||
你也可以把目录复制到 Cocos Creator 的全局用户扩展目录中。
|
||||
|
||||
### 2. 启动 MCP Server
|
||||
@@ -69,7 +71,9 @@ Funplay > MCP Server
|
||||
|
||||
- 启用或停用 MCP Server
|
||||
- 修改服务端口
|
||||
- 在 `core` / `full` 工具暴露模式之间切换
|
||||
- 在 `core` / `full` / `custom` 工具暴露模式之间切换
|
||||
- 检查当前安装版本是否落后于 GitHub 最新 Release
|
||||
- 按工具分类或单个工具调整暴露范围
|
||||
- 一键配置 AI 客户端
|
||||
- 需要时再展开 Debug Output
|
||||
|
||||
@@ -175,6 +179,31 @@ url = "http://127.0.0.1:8765/"
|
||||
|
||||
</details>
|
||||
|
||||
### 可选:npm stdio Wrapper
|
||||
|
||||
如果 MCP 客户端更适合使用本地 `stdio` 命令,可以在启动 Cocos 编辑器内置服务后安装 npm wrapper:
|
||||
|
||||
```bash
|
||||
npm install -g funplay-cocos-mcp
|
||||
```
|
||||
|
||||
示例 MCP 客户端配置:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"funplay_cocos": {
|
||||
"command": "funplay-cocos-mcp",
|
||||
"env": {
|
||||
"FUNPLAY_COCOS_MCP_URL": "http://127.0.0.1:8765/"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
这个 wrapper 会把 stdio MCP 流量桥接到 Cocos 内置 HTTP endpoint。也可以直接运行 `npx funplay-cocos-mcp --url http://127.0.0.1:8765/`。
|
||||
|
||||
### 4. 验证连接
|
||||
|
||||
先在 AI 客户端里试几个安全请求:
|
||||
@@ -197,7 +226,10 @@ url = "http://127.0.0.1:8765/"
|
||||
- 这是一个 **仅限 Editor** 的扩展,用于自动化 Cocos Creator,不会给最终游戏包添加运行时依赖。
|
||||
- MCP Server 默认监听 `http://127.0.0.1:8765/`。
|
||||
- 如果配置端口被占用,服务会自动回退到下一个可用端口,面板与一键客户端配置会使用实际运行端口。
|
||||
- 默认 `core` profile 暴露 22 个高频工具;如果需要完整工具集,可在面板切到 `full`,暴露全部 70 个工具。
|
||||
- 默认 `core` profile 暴露 34 个高频工具;如果需要完整工具集,可在面板切到 `full`,暴露全部 89 个工具;也可以用 `custom` 按分类或工具名增删。
|
||||
- 面板提供手动更新检查,会对比当前安装版本和 GitHub 最新 Release。
|
||||
- Streamable HTTP 响应已补齐 MCP 传输层要求,包括 `Accept`、`MCP-Protocol-Version`、JSON-RPC notification/response,以及可选 `Mcp-Session-Id` session。
|
||||
- 工具列表会包含 MCP `outputSchema` 和 `annotations`;结构化工具结果统一使用包含 `ok`、`tool`、`callId`、`summary`、`data`、`refs` 的标准 envelope。
|
||||
- 所有已暴露的 MCP 工具都会直接执行,Cocos 扩展里没有额外 approval 开关。
|
||||
- 文件工具和 `cocos://asset/path/...` 资源默认只能访问当前 Cocos 项目根目录内的路径。
|
||||
- 推荐工作流是优先使用 `execute_javascript`,再配合截图、诊断、资产、检查类工具。
|
||||
@@ -208,16 +240,16 @@ url = "http://127.0.0.1:8765/"
|
||||
- **`execute_javascript` 主工具优先** — 一个高灵活度 JavaScript 工具就能编排场景/运行态和编辑器自动化,避免 AI 客户端被大量细碎工具干扰
|
||||
- **嵌入式 Cocos 扩展** — Cocos 侧不需要单独 Python 守护进程或外部 bridge
|
||||
- **一键客户端配置** — 在 Cocos Creator 内直接配置 Claude Code、Cursor、VS Code、Trae、Kiro、Codex
|
||||
- **内建项目上下文** — 直接暴露项目、场景、选择、脚本诊断和交互历史资源
|
||||
- **默认聚焦,必要时全量** — `core` 降低工具列表噪音,需要时切到 `full` 暴露全部工具
|
||||
- **内建项目上下文** — 直接暴露项目、场景、选择、脚本诊断、日志和交互历史资源
|
||||
- **默认聚焦,必要时全量** — `core` 降低工具列表噪音,需要时切到 `full` 暴露全部工具;`custom` 可按分类或工具名调整
|
||||
- **可视化验证** — 截图和输入模拟让 AI 能验证 UI 与玩法改动
|
||||
|
||||
## 核心特性
|
||||
|
||||
- **70 个内置工具** — 覆盖场景层级、编辑器状态、选择工作流、资产、UI 创建、组件、文件、脚本诊断、截图、运行态控制和输入模拟
|
||||
- **89 个内置工具** — 覆盖场景层级、编辑器状态、选择工作流、Prefab、资产、项目指令、UI 创建、组件、文件、日志、脚本诊断、截图、运行态控制和输入模拟
|
||||
- **统一主工具** — `execute_javascript` 同时支持 `scene` 和 `editor` 两种上下文
|
||||
- **Resources 与 Prompts** — 实时项目资源,以及脚本修复、场景验证、可玩原型等可复用工作流
|
||||
- **Cocos 图形面板** — `Funplay > MCP Server` 提供极简服务管理与 MCP 客户端配置
|
||||
- **Resources 与 Prompts** — 实时项目/日志资源,以及脚本修复、场景验证、可玩原型等可复用工作流
|
||||
- **Cocos 图形面板** — `Funplay > MCP Server` 提供服务管理、更新检查、工具暴露和 MCP 客户端配置
|
||||
- **截图与输入支持** — 支持编辑器/场景/Game/Preview 截图,以及 Electron 级鼠标键盘事件
|
||||
- **厂商无关** — 兼容任意支持 HTTP JSON-RPC MCP 的 AI 客户端
|
||||
|
||||
@@ -231,20 +263,20 @@ Funplay MCP for Cocos 延续 Funplay MCP for Unity 的设计原则,并针对 C
|
||||
| 内置服务 | 内嵌 HTTP MCP Server | 内嵌 HTTP MCP Server |
|
||||
| 主执行工具 | `execute_javascript` | `execute_code` |
|
||||
| 主语言 | 场景/编辑器上下文中的 JavaScript | Unity 编辑器/运行态中的 C# |
|
||||
| 默认工具集 | `core`,22 个工具 | 聚焦版 `core` 工具集 |
|
||||
| 完整工具集 | 70 个工具 | 79 个工具 |
|
||||
| 默认工具集 | `core`,34 个工具 | 聚焦版 `core` 工具集 |
|
||||
| 完整工具集 | 89 个工具,并支持 `custom` 暴露 | 79 个工具 |
|
||||
| 客户端配置 | 一键配置面板 | 一键配置窗口 |
|
||||
|
||||
## MCP 能力结构
|
||||
|
||||
当前包提供四层能力:
|
||||
|
||||
- **Tools** — `core` 下 22 个工具,`full` 下 70 个工具
|
||||
- **Tools** — `core` 下 34 个工具,`full` 下 89 个工具,并支持 `custom` include/exclude 规则
|
||||
- **Primary execution** — `execute_javascript` 用于场景/运行态和编辑器/browser 自动化
|
||||
- **Prompts** — `fix_script_errors`、`create_playable_prototype`、`scene_validation`、`auto_wire_scene`
|
||||
- **Resources** — 项目上下文、场景摘要、当前选择、脚本诊断、资产选择和 MCP 交互历史
|
||||
- **Resources** — 项目上下文、场景摘要、当前选择、脚本诊断、资产选择、日志和 MCP 交互历史
|
||||
|
||||
当前默认 `core` 工具集刻意保持精简,只包含:`execute_javascript`、`execute_scene_script`、`execute_editor_script`、`get_editor_state`、`get_selection`、`set_selection`、`get_project_info`、`get_scene_info`、`get_hierarchy`、`list_scenes`、`open_scene`、`list_assets`、`inspect_asset`、`open_asset`、`select_asset`、`run_script_diagnostics`、`get_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
|
||||
|
||||
@@ -257,25 +289,28 @@ Funplay MCP for Cocos 延续 Funplay MCP for Unity 的设计原则,并针对 C
|
||||
| `cocos://selection/current` | 当前编辑器选择 |
|
||||
| `cocos://selection/asset` | 当前选中资产 |
|
||||
| `cocos://errors/scripts` | 脚本诊断信息 |
|
||||
| `cocos://logs/editor` | 最近 MCP 运行日志和工具交互 |
|
||||
| `cocos://logs/project` | 常见项目日志文件的尾部内容 |
|
||||
| `cocos://mcp/interactions` | 最近 MCP 交互历史 |
|
||||
|
||||
## 内置工具
|
||||
|
||||
Funplay MCP for Cocos 当前在 `full` profile 下提供 **70 个工具函数**:
|
||||
Funplay MCP for Cocos 当前在 `full` profile 下提供 **89 个工具函数**:
|
||||
|
||||
| 分类 | 工具 |
|
||||
|------|------|
|
||||
| **脚本执行** | `execute_javascript`, `execute_scene_script`, `execute_editor_script` |
|
||||
| **编辑器状态** | `get_editor_state`, `get_selection`, `set_selection`, `get_editor_selection` |
|
||||
| **编辑器状态** | `get_editor_state`, `get_tool_catalog`, `check_for_updates`, `get_selection`, `set_selection`, `get_editor_selection` |
|
||||
| **项目指令** | `list_project_instructions`, `read_project_instruction`, `write_project_instruction`, `create_project_skill` |
|
||||
| **项目与场景** | `get_project_info`, `get_scene_info`, `get_hierarchy`, `find_nodes`, `inspect_node`, `list_scenes`, `open_scene`, `run_scene_asset` |
|
||||
| **节点编辑** | `create_node`, `delete_node`, `set_node_transform` |
|
||||
| **资产与 Prefab** | `list_assets`, `inspect_asset`, `open_asset`, `select_asset`, `delete_asset`, `list_prefabs`, `instantiate_prefab`, `get_editor_selection` |
|
||||
| **资产与 Prefab** | `list_assets`, `inspect_asset`, `open_asset`, `select_asset`, `delete_asset`, `list_prefabs`, `inspect_prefab`, `validate_prefab_references`, `duplicate_prefab`, `edit_prefab_json`, `create_prefab_instance`, `inspect_prefab_instance`, `apply_prefab_instance`, `revert_prefab_instance`, `instantiate_prefab` |
|
||||
| **组件** | `list_components`, `inspect_component`, `add_component`, `remove_component`, `set_component_property`, `reset_component_property` |
|
||||
| **UI** | `create_canvas`, `create_label`, `create_button`, `create_sprite` |
|
||||
| **相机** | `list_cameras`, `create_camera`, `set_camera_properties` |
|
||||
| **动画** | `list_animations`, `add_animation_clip`, `play_animation`, `stop_animation` |
|
||||
| **文件** | `read_file`, `get_file_snippet`, `write_file`, `replace_in_file`, `search_files`, `list_directory`, `exists`, `refresh_assets` |
|
||||
| **诊断** | `run_script_diagnostics`, `get_script_diagnostic_context` |
|
||||
| **诊断与日志** | `run_script_diagnostics`, `get_script_diagnostic_context`, `get_recent_logs`, `search_project_logs`, `clear_logs`, `validate_scene`, `get_performance_snapshot` |
|
||||
| **运行态** | `get_runtime_state`, `pause_runtime`, `resume_runtime`, `set_time_scale` |
|
||||
| **交互** | `emit_node_event`, `simulate_button_click`, `invoke_component_method`, `simulate_mouse_click`, `simulate_mouse_drag`, `simulate_key_press`, `simulate_key_combo`, `simulate_preview_input` |
|
||||
| **截图与窗口** | `capture_desktop_screenshot`, `capture_editor_screenshot`, `capture_scene_screenshot`, `capture_game_screenshot`, `capture_preview_screenshot`, `list_editor_windows` |
|
||||
@@ -313,6 +348,11 @@ Editor 上下文脚本可以访问 `Editor`、`fs`、`path`、`os`、`require`
|
||||
"host": "127.0.0.1",
|
||||
"port": 8765,
|
||||
"toolProfile": "core",
|
||||
"enabledToolCategories": [],
|
||||
"disabledToolCategories": [],
|
||||
"enabledTools": [],
|
||||
"disabledTools": [],
|
||||
"enableSessions": false,
|
||||
"autostart": true,
|
||||
"maxInteractionLogEntries": 50
|
||||
}
|
||||
@@ -324,6 +364,8 @@ Editor 上下文脚本可以访问 `Editor`、`fs`、`path`、`os`、`require`
|
||||
- `COCOS_MCP_PORT`
|
||||
- `COCOS_MCP_PROFILE`
|
||||
|
||||
`toolProfile: "custom"` 会从 `core` 集合开始,再加入 `enabledToolCategories` / `enabledTools`,并移除 `disabledToolCategories` / `disabledTools`。`enableSessions` 默认关闭,因为常规编辑器自动化不需要跨请求客户端状态。
|
||||
|
||||
## 架构
|
||||
|
||||
```text
|
||||
@@ -347,10 +389,27 @@ Cocos Creator Extension
|
||||
|
||||
## 开发
|
||||
|
||||
发布改动前可以跑语法检查:
|
||||
发布改动前可以跑检查:
|
||||
|
||||
```bash
|
||||
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
|
||||
```
|
||||
|
||||
## 协议
|
||||
|
||||
@@ -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
|
||||
@@ -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`.
|
||||
Executable
+356
@@ -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;
|
||||
});
|
||||
}
|
||||
+111
-30
@@ -11,6 +11,8 @@ const { createToolRegistry } = require('./lib/tool-registry');
|
||||
const { ResourceProvider } = require('./lib/resources');
|
||||
const { PromptProvider } = require('./lib/prompts');
|
||||
const { InteractionLog } = require('./lib/interaction-log');
|
||||
const { RuntimeLog } = require('./lib/runtime-log');
|
||||
const { checkForUpdate } = require('./lib/update-checker');
|
||||
|
||||
const EXTENSION_NAME = manifest.name || 'funplay-cocos-mcp';
|
||||
const LOG_PREFIX = '[Funplay Cocos MCP]';
|
||||
@@ -25,26 +27,42 @@ class ExtensionService {
|
||||
this.resourceProvider = null;
|
||||
this.promptProvider = null;
|
||||
this.interactionLog = new InteractionLog();
|
||||
this.runtimeLog = new RuntimeLog();
|
||||
this.lastUpdateInfo = null;
|
||||
}
|
||||
|
||||
log(level, message, details) {
|
||||
if (this.runtimeLog && typeof this.runtimeLog.add === 'function') {
|
||||
this.runtimeLog.add(level, message, details);
|
||||
}
|
||||
const output = `${LOG_PREFIX} ${message}`;
|
||||
if (level === 'error') {
|
||||
console.error(output);
|
||||
} else if (level === 'warn') {
|
||||
console.warn(output);
|
||||
} else {
|
||||
console.log(output);
|
||||
}
|
||||
}
|
||||
|
||||
load() {
|
||||
console.log(`${LOG_PREFIX} Extension loading...`);
|
||||
this.log('info', 'Extension loading...');
|
||||
this.reloadRuntime();
|
||||
if (this.config.autostart) {
|
||||
console.log(`${LOG_PREFIX} Autostart is enabled, starting MCP server.`);
|
||||
this.log('info', 'Autostart is enabled, starting MCP server.');
|
||||
return this.startServer();
|
||||
}
|
||||
console.log(`${LOG_PREFIX} Autostart is disabled. MCP server is idle.`);
|
||||
this.log('info', 'Autostart is disabled. MCP server is idle.');
|
||||
return this.getStatus();
|
||||
}
|
||||
|
||||
unload() {
|
||||
console.log(`${LOG_PREFIX} Extension unloading...`);
|
||||
this.log('info', 'Extension unloading...');
|
||||
if (this.server) {
|
||||
this.server.stop();
|
||||
this.server = null;
|
||||
}
|
||||
console.log(`${LOG_PREFIX} Extension unloaded.`);
|
||||
this.log('info', 'Extension unloaded.');
|
||||
}
|
||||
|
||||
openPanel() {
|
||||
@@ -56,11 +74,13 @@ class ExtensionService {
|
||||
|
||||
reloadRuntime() {
|
||||
this.config = loadConfig();
|
||||
console.log(
|
||||
`${LOG_PREFIX} Runtime config loaded: host=${this.config.host}, port=${this.config.port}, ` +
|
||||
this.interactionLog = new InteractionLog(this.config.maxInteractionLogEntries);
|
||||
this.runtimeLog = new RuntimeLog(this.config.maxInteractionLogEntries);
|
||||
this.log(
|
||||
'info',
|
||||
`Runtime config loaded: host=${this.config.host}, port=${this.config.port}, ` +
|
||||
`profile=${this.config.toolProfile}, autostart=${this.config.autostart}`
|
||||
);
|
||||
this.interactionLog = new InteractionLog(this.config.maxInteractionLogEntries);
|
||||
const sceneBridge = {
|
||||
call: async (method, payload) => {
|
||||
if (!global.Editor || !Editor.Message || typeof Editor.Message.request !== 'function') {
|
||||
@@ -89,24 +109,26 @@ class ExtensionService {
|
||||
getRuntimeContext: runtimeContext,
|
||||
getStatus: () => this.getStatus(),
|
||||
interactionLog: this.interactionLog,
|
||||
runtimeLog: this.runtimeLog,
|
||||
sceneBridge,
|
||||
editorExecutor: async (payload) => await this.executeEditorScript(payload, runtimeContext),
|
||||
});
|
||||
this.resourceProvider = new ResourceProvider(runtimeContext, sceneBridge, this.interactionLog);
|
||||
this.resourceProvider = new ResourceProvider(runtimeContext, sceneBridge, this.interactionLog, this.runtimeLog);
|
||||
this.promptProvider = new PromptProvider(runtimeContext);
|
||||
}
|
||||
|
||||
async startServer() {
|
||||
if (this.server && this.server.isRunning()) {
|
||||
console.log(`${LOG_PREFIX} Start requested but MCP server is already running at ${this.getStatus().url}`);
|
||||
this.log('info', `Start requested but MCP server is already running at ${this.getStatus().url}`);
|
||||
return this.getStatus();
|
||||
}
|
||||
|
||||
console.log(`${LOG_PREFIX} Starting MCP server...`);
|
||||
this.log('info', 'Starting MCP server...');
|
||||
this.reloadRuntime();
|
||||
this.server = new McpServer({
|
||||
config: this.config,
|
||||
interactionLog: this.interactionLog,
|
||||
runtimeLog: this.runtimeLog,
|
||||
toolRegistry: this.toolRegistry,
|
||||
resourceProvider: this.resourceProvider,
|
||||
promptProvider: this.promptProvider,
|
||||
@@ -115,30 +137,28 @@ class ExtensionService {
|
||||
});
|
||||
|
||||
await this.server.start();
|
||||
console.log(`${LOG_PREFIX} MCP server started at ${this.getStatus().url}`);
|
||||
console.log(
|
||||
`${LOG_PREFIX} If this tool saves you time, please consider giving it a Star on GitHub: ${REPOSITORY_URL}`
|
||||
);
|
||||
this.log('info', `MCP server started at ${this.getStatus().url}`);
|
||||
this.log('info', `If this tool saves you time, please consider giving it a Star on GitHub: ${REPOSITORY_URL}`);
|
||||
return this.getStatus();
|
||||
}
|
||||
|
||||
async stopServer() {
|
||||
console.log(`${LOG_PREFIX} Stop requested.`);
|
||||
this.log('info', 'Stop requested.');
|
||||
if (this.server) {
|
||||
await this.server.stop();
|
||||
this.server = null;
|
||||
console.log(`${LOG_PREFIX} MCP server stopped.`);
|
||||
this.log('info', 'MCP server stopped.');
|
||||
} else {
|
||||
console.log(`${LOG_PREFIX} Stop requested but MCP server was not running.`);
|
||||
this.log('info', 'Stop requested but MCP server was not running.');
|
||||
}
|
||||
return this.getStatus();
|
||||
}
|
||||
|
||||
async restartServer() {
|
||||
console.log(`${LOG_PREFIX} Restart requested.`);
|
||||
this.log('info', 'Restart requested.');
|
||||
await this.stopServer();
|
||||
const status = await this.startServer();
|
||||
console.log(`${LOG_PREFIX} Restart completed. MCP server running=${status.running}, url=${status.url}`);
|
||||
this.log('info', `Restart completed. MCP server running=${status.running}, url=${status.url}`);
|
||||
return status;
|
||||
}
|
||||
|
||||
@@ -166,7 +186,13 @@ class ExtensionService {
|
||||
portFallbackActive: Boolean(fallbackInfo),
|
||||
portFallbackInfo: fallbackInfo,
|
||||
toolProfile: this.config.toolProfile,
|
||||
enabledTools: this.config.enabledTools,
|
||||
disabledTools: this.config.disabledTools,
|
||||
enabledToolCategories: this.config.enabledToolCategories,
|
||||
disabledToolCategories: this.config.disabledToolCategories,
|
||||
enableSessions: this.config.enableSessions,
|
||||
autostart: this.config.autostart,
|
||||
version: manifest.version || '0.0.0',
|
||||
projectPath: getProjectPath(),
|
||||
projectName: getProjectName(),
|
||||
cocosVersion: getCocosVersion(),
|
||||
@@ -178,16 +204,22 @@ class ExtensionService {
|
||||
this.ensureRuntime();
|
||||
const status = this.getStatus();
|
||||
const tools = this.toolRegistry.listTools();
|
||||
const toolCatalog = typeof this.toolRegistry.listToolCatalog === 'function'
|
||||
? this.toolRegistry.listToolCatalog()
|
||||
: tools;
|
||||
const resources = this.resourceProvider.listResources();
|
||||
const prompts = this.promptProvider.listPrompts();
|
||||
|
||||
return {
|
||||
status,
|
||||
tools,
|
||||
toolCatalog,
|
||||
resources,
|
||||
prompts,
|
||||
recentInteractions: this.interactionLog.list(20),
|
||||
recentRuntimeLogs: this.runtimeLog.list(20),
|
||||
config: this.config,
|
||||
updateInfo: this.lastUpdateInfo,
|
||||
clientConfig: this.getClientConfig(),
|
||||
clientTargets: getTargetStatuses(this.config),
|
||||
};
|
||||
@@ -200,10 +232,25 @@ class ExtensionService {
|
||||
|
||||
async callToolFromPanel(name, args) {
|
||||
this.ensureRuntime();
|
||||
console.log(`${LOG_PREFIX} Panel calling tool: ${name}`);
|
||||
this.log('info', `Panel calling tool: ${name}`);
|
||||
return await this.toolRegistry.callTool(name, args || {});
|
||||
}
|
||||
|
||||
async checkUpdates() {
|
||||
this.ensureRuntime();
|
||||
this.log('info', 'Checking GitHub for newer Funplay Cocos MCP releases.');
|
||||
this.lastUpdateInfo = await checkForUpdate({ currentVersion: manifest.version || '0.0.0' });
|
||||
if (this.lastUpdateInfo.ok) {
|
||||
this.log(
|
||||
'info',
|
||||
`Update check completed: current=${this.lastUpdateInfo.currentVersion}, latest=${this.lastUpdateInfo.latestVersion || 'unknown'}`
|
||||
);
|
||||
} else {
|
||||
this.log('warn', `Update check failed: ${this.lastUpdateInfo.error}`);
|
||||
}
|
||||
return this.getPanelState();
|
||||
}
|
||||
|
||||
async executeEditorScript(payload, runtimeContext) {
|
||||
const code = String(payload && payload.code || '');
|
||||
if (!code.trim()) {
|
||||
@@ -252,7 +299,7 @@ class ExtensionService {
|
||||
|
||||
async readResourceFromPanel(uri) {
|
||||
this.ensureRuntime();
|
||||
console.log(`${LOG_PREFIX} Panel reading resource: ${uri}`);
|
||||
this.log('info', `Panel reading resource: ${uri}`);
|
||||
return await this.resourceProvider.readResource(uri);
|
||||
}
|
||||
|
||||
@@ -273,11 +320,12 @@ class ExtensionService {
|
||||
|
||||
configureClient(targetId) {
|
||||
this.ensureRuntime();
|
||||
console.log(`${LOG_PREFIX} Configuring MCP client target: ${targetId}`);
|
||||
this.log('info', `Configuring MCP client target: ${targetId}`);
|
||||
const effective = this.getEffectiveServerConnection();
|
||||
if (effective.port !== this.config.port) {
|
||||
console.log(
|
||||
`${LOG_PREFIX} Using actual running port ${effective.port} for MCP client configuration ` +
|
||||
this.log(
|
||||
'info',
|
||||
`Using actual running port ${effective.port} for MCP client configuration ` +
|
||||
`(requested: ${this.config.port}).`
|
||||
);
|
||||
}
|
||||
@@ -289,7 +337,7 @@ class ExtensionService {
|
||||
},
|
||||
targetId
|
||||
);
|
||||
console.log(`${LOG_PREFIX} MCP client configured: ${result.name} -> ${result.configPath}`);
|
||||
this.log('info', `MCP client configured: ${result.name} -> ${result.configPath}`);
|
||||
return {
|
||||
...result,
|
||||
clientTargets: getTargetStatuses(this.config),
|
||||
@@ -304,12 +352,37 @@ class ExtensionService {
|
||||
const nextMaxEntries = partialConfig && partialConfig.maxInteractionLogEntries !== undefined
|
||||
? Number(partialConfig.maxInteractionLogEntries)
|
||||
: this.config.maxInteractionLogEntries;
|
||||
const normalizeList = (value, fallback) => {
|
||||
if (Array.isArray(value)) {
|
||||
return value.map((item) => String(item || '').trim()).filter(Boolean);
|
||||
}
|
||||
if (typeof value === 'string') {
|
||||
return value.split(/[\n,]/).map((item) => item.trim()).filter(Boolean);
|
||||
}
|
||||
return fallback || [];
|
||||
};
|
||||
const normalizeCategories = (value, fallback) => normalizeList(value, fallback)
|
||||
.map((item) => item.toLowerCase());
|
||||
const nextProfile = partialConfig && partialConfig.toolProfile
|
||||
? (partialConfig.toolProfile === 'full' || partialConfig.toolProfile === 'custom' ? partialConfig.toolProfile : 'core')
|
||||
: this.config.toolProfile;
|
||||
const nextConfig = {
|
||||
host: partialConfig && partialConfig.host ? String(partialConfig.host) : this.config.host,
|
||||
port: Number.isInteger(nextPort) && nextPort > 0 && nextPort <= 65535 ? nextPort : this.config.port,
|
||||
toolProfile: partialConfig && partialConfig.toolProfile
|
||||
? (partialConfig.toolProfile === 'full' ? 'full' : 'core')
|
||||
: this.config.toolProfile,
|
||||
toolProfile: nextProfile,
|
||||
enabledTools: normalizeList(partialConfig && partialConfig.enabledTools, this.config.enabledTools),
|
||||
disabledTools: normalizeList(partialConfig && partialConfig.disabledTools, this.config.disabledTools),
|
||||
enabledToolCategories: normalizeCategories(
|
||||
partialConfig && partialConfig.enabledToolCategories,
|
||||
this.config.enabledToolCategories
|
||||
),
|
||||
disabledToolCategories: normalizeCategories(
|
||||
partialConfig && partialConfig.disabledToolCategories,
|
||||
this.config.disabledToolCategories
|
||||
),
|
||||
enableSessions: partialConfig && typeof partialConfig.enableSessions === 'boolean'
|
||||
? partialConfig.enableSessions
|
||||
: this.config.enableSessions,
|
||||
autostart: partialConfig && typeof partialConfig.autostart === 'boolean'
|
||||
? partialConfig.autostart
|
||||
: this.config.autostart,
|
||||
@@ -327,7 +400,12 @@ class ExtensionService {
|
||||
const requiresRestart = wasRunning && (
|
||||
nextConfig.host !== this.config.host ||
|
||||
nextConfig.port !== this.config.port ||
|
||||
nextConfig.toolProfile !== this.config.toolProfile
|
||||
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();
|
||||
@@ -383,6 +461,9 @@ module.exports = {
|
||||
callToolFromPanel(name, args) {
|
||||
return service.callToolFromPanel(name, args);
|
||||
},
|
||||
checkUpdates() {
|
||||
return service.checkUpdates();
|
||||
},
|
||||
readResourceFromPanel(uri) {
|
||||
return service.readResourceFromPanel(uri);
|
||||
},
|
||||
|
||||
+32
-1
@@ -7,6 +7,11 @@ const DEFAULTS = {
|
||||
host: '127.0.0.1',
|
||||
port: 8765,
|
||||
toolProfile: 'core',
|
||||
enabledTools: [],
|
||||
disabledTools: [],
|
||||
enabledToolCategories: [],
|
||||
disabledToolCategories: [],
|
||||
enableSessions: false,
|
||||
autostart: true,
|
||||
maxInteractionLogEntries: 50,
|
||||
lastClientTargetId: 'claude_code',
|
||||
@@ -58,7 +63,26 @@ function clampPort(value) {
|
||||
}
|
||||
|
||||
function normalizeProfile(value) {
|
||||
return String(value || DEFAULTS.toolProfile).toLowerCase() === 'full' ? 'full' : 'core';
|
||||
const normalized = String(value || DEFAULTS.toolProfile).toLowerCase();
|
||||
if (normalized === 'full' || normalized === 'custom') {
|
||||
return normalized;
|
||||
}
|
||||
return 'core';
|
||||
}
|
||||
|
||||
function normalizeStringList(value) {
|
||||
if (Array.isArray(value)) {
|
||||
return value
|
||||
.map((item) => String(item || '').trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
if (typeof value === 'string') {
|
||||
return value
|
||||
.split(/[\n,]/)
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
function normalizeClientTargetId(value) {
|
||||
@@ -77,6 +101,11 @@ function loadConfig() {
|
||||
host: process.env.COCOS_MCP_HOST || fileConfig.host || DEFAULTS.host,
|
||||
port: clampPort(process.env.COCOS_MCP_PORT || fileConfig.port || DEFAULTS.port),
|
||||
toolProfile: normalizeProfile(process.env.COCOS_MCP_PROFILE || fileConfig.toolProfile || DEFAULTS.toolProfile),
|
||||
enabledTools: normalizeStringList(fileConfig.enabledTools),
|
||||
disabledTools: normalizeStringList(fileConfig.disabledTools),
|
||||
enabledToolCategories: normalizeStringList(fileConfig.enabledToolCategories).map((item) => item.toLowerCase()),
|
||||
disabledToolCategories: normalizeStringList(fileConfig.disabledToolCategories).map((item) => item.toLowerCase()),
|
||||
enableSessions: typeof fileConfig.enableSessions === 'boolean' ? fileConfig.enableSessions : DEFAULTS.enableSessions,
|
||||
autostart: typeof fileConfig.autostart === 'boolean' ? fileConfig.autostart : DEFAULTS.autostart,
|
||||
maxInteractionLogEntries: Number.isInteger(fileConfig.maxInteractionLogEntries)
|
||||
? Math.max(10, Math.min(500, fileConfig.maxInteractionLogEntries))
|
||||
@@ -93,4 +122,6 @@ module.exports = {
|
||||
getProjectName,
|
||||
getCocosVersion,
|
||||
loadConfig,
|
||||
normalizeProfile,
|
||||
normalizeStringList,
|
||||
};
|
||||
|
||||
@@ -23,6 +23,12 @@ class InteractionLog {
|
||||
return this.entries.slice(0, Math.max(1, limit));
|
||||
}
|
||||
|
||||
clear() {
|
||||
const count = this.entries.length;
|
||||
this.entries.length = 0;
|
||||
return count;
|
||||
}
|
||||
|
||||
summary(limit = 20) {
|
||||
const items = this.list(limit);
|
||||
if (!items.length) {
|
||||
|
||||
+206
@@ -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,
|
||||
};
|
||||
+269
@@ -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,
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
+40
-1
@@ -4,6 +4,7 @@ const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { getCurrentSelection, queryAssetInfo, queryAssetMeta } = require('./assets');
|
||||
const { runScriptDiagnostics } = require('./diagnostics');
|
||||
const { getRecentProjectLogs } = require('./logs');
|
||||
const { resolveProjectPath } = require('./path-safety');
|
||||
|
||||
function createResource(uri, name, description) {
|
||||
@@ -49,10 +50,11 @@ function summarizeSelection() {
|
||||
}
|
||||
|
||||
class ResourceProvider {
|
||||
constructor(getRuntimeContext, sceneBridge, interactionLog) {
|
||||
constructor(getRuntimeContext, sceneBridge, interactionLog, runtimeLog) {
|
||||
this.getRuntimeContext = getRuntimeContext;
|
||||
this.sceneBridge = sceneBridge;
|
||||
this.interactionLog = interactionLog;
|
||||
this.runtimeLog = runtimeLog;
|
||||
}
|
||||
|
||||
listResources() {
|
||||
@@ -65,6 +67,8 @@ class ResourceProvider {
|
||||
createResource('cocos://selection/current', `${projectName} Current Selection`, 'Summary of the current editor selection.'),
|
||||
createResource('cocos://selection/asset', `${projectName} Selected Asset`, 'Details for the currently selected asset.'),
|
||||
createResource('cocos://errors/scripts', `${projectName} Script Diagnostics`, 'Latest TypeScript diagnostic summary for the project.'),
|
||||
createResource('cocos://logs/editor', `${projectName} Editor Logs`, 'Recent MCP runtime logs and tool interaction history.'),
|
||||
createResource('cocos://logs/project', `${projectName} Project Logs`, 'Recent tails from common project log files.'),
|
||||
createResource('cocos://mcp/interactions', `${projectName} MCP Interactions`, 'Recent MCP tool interaction summaries.'),
|
||||
];
|
||||
}
|
||||
@@ -121,6 +125,10 @@ class ResourceProvider {
|
||||
return await this.getSelectedAssetText();
|
||||
case 'cocos://errors/scripts':
|
||||
return await this.getScriptDiagnosticsText(projectPath);
|
||||
case 'cocos://logs/editor':
|
||||
return this.getEditorLogsText();
|
||||
case 'cocos://logs/project':
|
||||
return this.getProjectLogsText(projectPath);
|
||||
case 'cocos://mcp/interactions':
|
||||
return this.interactionLog.summary();
|
||||
default:
|
||||
@@ -247,6 +255,37 @@ class ResourceProvider {
|
||||
return `Script diagnostics failed: ${error.message}`;
|
||||
}
|
||||
}
|
||||
|
||||
getEditorLogsText() {
|
||||
return [
|
||||
'MCP Runtime Logs',
|
||||
this.runtimeLog && typeof this.runtimeLog.summary === 'function'
|
||||
? this.runtimeLog.summary(80)
|
||||
: 'Runtime log is unavailable.',
|
||||
'',
|
||||
'MCP Tool Interactions',
|
||||
this.interactionLog && typeof this.interactionLog.summary === 'function'
|
||||
? this.interactionLog.summary(80)
|
||||
: 'Interaction log is unavailable.',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
getProjectLogsText(projectPath) {
|
||||
const logs = getRecentProjectLogs(projectPath, { limit: 10, lines: 100 });
|
||||
if (!logs.length) {
|
||||
return 'No project log files found in common project log directories.';
|
||||
}
|
||||
|
||||
return logs
|
||||
.map((log) => [
|
||||
`# ${log.path}`,
|
||||
`mtime: ${log.mtime}`,
|
||||
`size: ${log.size}`,
|
||||
'',
|
||||
log.text,
|
||||
].join('\n'))
|
||||
.join('\n\n---\n\n');
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
+238
-27
@@ -1,5 +1,6 @@
|
||||
'use strict';
|
||||
|
||||
const crypto = require('crypto');
|
||||
const http = require('http');
|
||||
const { safeStringify } = require('./utils');
|
||||
const IMAGE_DATA_URI_PREFIX = 'data:image/png;base64,';
|
||||
@@ -14,14 +15,26 @@ const SUPPORTED_PROTOCOL_VERSIONS = [
|
||||
'2024-11-05',
|
||||
];
|
||||
|
||||
function json(response, statusCode, payload, protocolVersion = MCP_PROTOCOL_VERSION) {
|
||||
function responseHeaders(protocolVersion = MCP_PROTOCOL_VERSION, extraHeaders = {}) {
|
||||
return {
|
||||
'MCP-Protocol-Version': protocolVersion,
|
||||
...extraHeaders,
|
||||
};
|
||||
}
|
||||
|
||||
function json(response, statusCode, payload, protocolVersion = MCP_PROTOCOL_VERSION, extraHeaders = {}) {
|
||||
response.writeHead(statusCode, {
|
||||
'Content-Type': 'application/json; charset=utf-8',
|
||||
'MCP-Protocol-Version': protocolVersion,
|
||||
...responseHeaders(protocolVersion, extraHeaders),
|
||||
});
|
||||
response.end(JSON.stringify(payload));
|
||||
}
|
||||
|
||||
function empty(response, statusCode, protocolVersion = MCP_PROTOCOL_VERSION, extraHeaders = {}) {
|
||||
response.writeHead(statusCode, responseHeaders(protocolVersion, extraHeaders));
|
||||
response.end();
|
||||
}
|
||||
|
||||
function textContent(value) {
|
||||
if (typeof value === 'string' && value.startsWith(IMAGE_DATA_URI_PREFIX)) {
|
||||
return [
|
||||
@@ -68,12 +81,15 @@ class McpServer {
|
||||
this.resourceProvider = options.resourceProvider;
|
||||
this.promptProvider = options.promptProvider;
|
||||
this.interactionLog = options.interactionLog;
|
||||
this.runtimeLog = options.runtimeLog;
|
||||
this.serverName = options.serverName;
|
||||
this.serverVersion = options.serverVersion;
|
||||
this.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() {
|
||||
@@ -98,9 +114,24 @@ class McpServer {
|
||||
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() {
|
||||
if (this.isRunning()) {
|
||||
console.log(`${LOG_PREFIX} Start skipped: already running.`);
|
||||
this.log('info', 'Start skipped: already running.');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -110,20 +141,34 @@ class McpServer {
|
||||
const requestHandler = async (request, response) => {
|
||||
try {
|
||||
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 }, this.negotiatedProtocolVersion);
|
||||
}
|
||||
|
||||
if (!this.isAllowedOrigin(request)) {
|
||||
console.warn(`${LOG_PREFIX} Rejected ${request.method} ${request.url}: invalid Origin header.`);
|
||||
this.log('warn', `Rejected ${request.method} ${request.url}: invalid Origin header.`);
|
||||
return json(response, 403, { error: 'Forbidden: invalid Origin header' }, this.negotiatedProtocolVersion);
|
||||
}
|
||||
|
||||
if (request.method === 'DELETE') {
|
||||
return this.handleDelete(request, response);
|
||||
}
|
||||
|
||||
if (request.method === 'GET') {
|
||||
this.log('warn', `Rejected ${request.method} ${request.url}: SSE GET streams are not supported.`);
|
||||
return json(response, 405, { error: 'Method Not Allowed: SSE streams are not supported' }, this.negotiatedProtocolVersion);
|
||||
}
|
||||
|
||||
if (request.method !== 'POST') {
|
||||
console.warn(`${LOG_PREFIX} Rejected ${request.method} ${request.url}: method not allowed.`);
|
||||
this.log('warn', `Rejected ${request.method} ${request.url}: method not allowed.`);
|
||||
return json(response, 405, { error: 'Method Not Allowed' }, this.negotiatedProtocolVersion);
|
||||
}
|
||||
|
||||
const acceptHeaderError = this.validateAcceptHeader(request);
|
||||
if (acceptHeaderError) {
|
||||
return json(response, 406, acceptHeaderError, this.negotiatedProtocolVersion);
|
||||
}
|
||||
|
||||
const body = await this.readBody(request);
|
||||
if (!body) {
|
||||
return json(response, 400, this.createError(null, -32700, 'Parse error: empty body'), this.negotiatedProtocolVersion);
|
||||
@@ -137,24 +182,52 @@ class McpServer {
|
||||
}
|
||||
|
||||
if (rpc && rpc.method) {
|
||||
console.log(`${LOG_PREFIX} RPC ${rpc.method}`);
|
||||
this.log('info', `RPC ${rpc.method}`);
|
||||
}
|
||||
|
||||
const protocolHeaderError = this.validateProtocolVersionHeader(request, rpc);
|
||||
if (protocolHeaderError) {
|
||||
return json(response, 400, protocolHeaderError, this.negotiatedProtocolVersion);
|
||||
}
|
||||
const responseProtocolVersion = this.getProtocolVersionForResponse(request, rpc);
|
||||
|
||||
const sessionError = this.validateSession(request, rpc);
|
||||
if (sessionError) {
|
||||
return json(response, sessionError.statusCode, sessionError.error, responseProtocolVersion);
|
||||
}
|
||||
|
||||
const messageType = this.classifyJsonRpcMessage(rpc);
|
||||
if (messageType === 'response') {
|
||||
return empty(response, 202, responseProtocolVersion);
|
||||
}
|
||||
|
||||
if (messageType === 'notification') {
|
||||
const notificationError = this.handleRpcNotification(rpc);
|
||||
if (notificationError) {
|
||||
return json(response, 400, notificationError, responseProtocolVersion);
|
||||
}
|
||||
return empty(response, 202, responseProtocolVersion);
|
||||
}
|
||||
|
||||
if (messageType !== 'request') {
|
||||
return json(response, 400, this.createError(rpc && rpc.id, -32600, 'Invalid Request'), responseProtocolVersion);
|
||||
}
|
||||
|
||||
const result = await this.handleRpcRequest(rpc);
|
||||
if (result == null) {
|
||||
response.writeHead(204, { 'MCP-Protocol-Version': this.negotiatedProtocolVersion });
|
||||
response.end();
|
||||
return;
|
||||
return empty(response, 202, responseProtocolVersion);
|
||||
}
|
||||
|
||||
return json(response, 200, result, this.negotiatedProtocolVersion);
|
||||
const extraHeaders = {};
|
||||
if (this.enableSessions && rpc.method === 'initialize' && result && !result.error) {
|
||||
const sessionId = this.createSessionId();
|
||||
this.sessions.add(sessionId);
|
||||
extraHeaders['Mcp-Session-Id'] = sessionId;
|
||||
}
|
||||
|
||||
return json(response, 200, result, this.getProtocolVersionForResponse(request, rpc), extraHeaders);
|
||||
} catch (error) {
|
||||
console.error(`${LOG_PREFIX} Request handling failed: ${error.message}`);
|
||||
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;
|
||||
@@ -167,7 +240,7 @@ class McpServer {
|
||||
let lastError = null;
|
||||
|
||||
while (attempt <= MAX_PORT_FALLBACK_ATTEMPTS) {
|
||||
console.log(`${LOG_PREFIX} Creating HTTP server on ${this.config.host}:${port}...`);
|
||||
this.log('info', `Creating HTTP server on ${this.config.host}:${port}...`);
|
||||
const candidate = http.createServer(requestHandler);
|
||||
|
||||
try {
|
||||
@@ -177,27 +250,27 @@ class McpServer {
|
||||
? candidate.address().port
|
||||
: port;
|
||||
|
||||
if (this.actualPort !== this.config.port) {
|
||||
if (this.config.port !== 0 && this.actualPort !== this.config.port) {
|
||||
this.portFallbackInfo = {
|
||||
requestedPort: this.config.port,
|
||||
actualPort: this.actualPort,
|
||||
attempts: attempt,
|
||||
};
|
||||
console.warn(
|
||||
`${LOG_PREFIX} Port ${this.config.port} was unavailable. ` +
|
||||
`Fell back to ${this.actualPort}.`
|
||||
this.log(
|
||||
'warn',
|
||||
`Port ${this.config.port} was unavailable. Fell back to ${this.actualPort}.`
|
||||
);
|
||||
}
|
||||
|
||||
console.log(`${LOG_PREFIX} Listening on http://${this.config.host}:${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;
|
||||
console.warn(
|
||||
`${LOG_PREFIX} Port ${port} is already in use. ` +
|
||||
`Trying fallback port ${nextPort}...`
|
||||
this.log(
|
||||
'warn',
|
||||
`Port ${port} is already in use. Trying fallback port ${nextPort}...`
|
||||
);
|
||||
port = nextPort;
|
||||
attempt += 1;
|
||||
@@ -217,11 +290,11 @@ class McpServer {
|
||||
|
||||
async stop() {
|
||||
if (!this.server) {
|
||||
console.log(`${LOG_PREFIX} Stop skipped: server object is empty.`);
|
||||
this.log('info', 'Stop skipped: server object is empty.');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`${LOG_PREFIX} Closing HTTP server...`);
|
||||
this.log('info', 'Closing HTTP server...');
|
||||
const active = this.server;
|
||||
this.server = null;
|
||||
this.actualPort = null;
|
||||
@@ -229,11 +302,11 @@ class McpServer {
|
||||
await new Promise((resolve, reject) => {
|
||||
active.close((error) => {
|
||||
if (error) {
|
||||
console.error(`${LOG_PREFIX} Close failed: ${error.message}`);
|
||||
this.log('error', `Close failed: ${error.message}`);
|
||||
reject(error);
|
||||
return;
|
||||
}
|
||||
console.log(`${LOG_PREFIX} HTTP server closed.`);
|
||||
this.log('info', 'HTTP server closed.');
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
@@ -295,6 +368,35 @@ class McpServer {
|
||||
}
|
||||
}
|
||||
|
||||
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')) {
|
||||
@@ -313,6 +415,110 @@ class McpServer {
|
||||
return null;
|
||||
}
|
||||
|
||||
getProtocolVersionForResponse(request, rpc) {
|
||||
if (rpc && rpc.method === 'initialize') {
|
||||
return this.negotiatedProtocolVersion;
|
||||
}
|
||||
|
||||
const header = request.headers && request.headers['mcp-protocol-version'];
|
||||
const version = Array.isArray(header) ? header[0] : header ? String(header) : '';
|
||||
if (SUPPORTED_PROTOCOL_VERSIONS.includes(version)) {
|
||||
return version;
|
||||
}
|
||||
return this.negotiatedProtocolVersion;
|
||||
}
|
||||
|
||||
validateSession(request, rpc) {
|
||||
if (!this.enableSessions || (rpc && rpc.method === 'initialize')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const sessionId = this.getSessionId(request);
|
||||
if (!sessionId) {
|
||||
return {
|
||||
statusCode: 400,
|
||||
error: this.createError(rpc && rpc.id, -32600, 'Missing Mcp-Session-Id header.'),
|
||||
};
|
||||
}
|
||||
if (!this.sessions.has(sessionId)) {
|
||||
return {
|
||||
statusCode: 404,
|
||||
error: this.createError(rpc && rpc.id, -32001, 'Unknown or expired MCP session.'),
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
getSessionId(request) {
|
||||
const value = request.headers && (request.headers['mcp-session-id'] || request.headers['Mcp-Session-Id']);
|
||||
if (Array.isArray(value)) {
|
||||
return value[0] || '';
|
||||
}
|
||||
return value ? String(value) : '';
|
||||
}
|
||||
|
||||
createSessionId() {
|
||||
if (typeof crypto.randomUUID === 'function') {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
return crypto.randomBytes(16).toString('hex');
|
||||
}
|
||||
|
||||
handleDelete(request, response) {
|
||||
if (!this.enableSessions) {
|
||||
return json(response, 405, { error: 'Method Not Allowed: MCP sessions are disabled' }, this.negotiatedProtocolVersion);
|
||||
}
|
||||
|
||||
const sessionId = this.getSessionId(request);
|
||||
if (!sessionId) {
|
||||
return json(
|
||||
response,
|
||||
400,
|
||||
this.createError(null, -32600, 'Missing Mcp-Session-Id header.'),
|
||||
this.negotiatedProtocolVersion
|
||||
);
|
||||
}
|
||||
if (!this.sessions.has(sessionId)) {
|
||||
return json(
|
||||
response,
|
||||
404,
|
||||
this.createError(null, -32001, 'Unknown or expired MCP session.'),
|
||||
this.negotiatedProtocolVersion
|
||||
);
|
||||
}
|
||||
|
||||
this.sessions.delete(sessionId);
|
||||
return empty(response, 202, this.negotiatedProtocolVersion);
|
||||
}
|
||||
|
||||
classifyJsonRpcMessage(message) {
|
||||
if (!message || message.jsonrpc !== '2.0') {
|
||||
return 'invalid';
|
||||
}
|
||||
if (typeof message.method === 'string') {
|
||||
return Object.prototype.hasOwnProperty.call(message, 'id') ? 'request' : 'notification';
|
||||
}
|
||||
if (
|
||||
Object.prototype.hasOwnProperty.call(message, 'id') &&
|
||||
(Object.prototype.hasOwnProperty.call(message, 'result') || Object.prototype.hasOwnProperty.call(message, 'error'))
|
||||
) {
|
||||
return 'response';
|
||||
}
|
||||
return 'invalid';
|
||||
}
|
||||
|
||||
handleRpcNotification(notification) {
|
||||
if (!notification || notification.jsonrpc !== '2.0' || typeof notification.method !== 'string') {
|
||||
return this.createError(null, -32600, 'Invalid Request');
|
||||
}
|
||||
|
||||
if (notification.method.startsWith('notifications/')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return this.createError(null, -32601, `Notification method not found: ${notification.method}`);
|
||||
}
|
||||
|
||||
async handleRpcRequest(request) {
|
||||
if (!request || request.jsonrpc !== '2.0') {
|
||||
return this.createError(request && request.id, -32600, 'Invalid Request');
|
||||
@@ -364,10 +570,15 @@ class McpServer {
|
||||
}
|
||||
return this.createResult(request.id, result);
|
||||
} catch (error) {
|
||||
return this.createResult(request.id, {
|
||||
const result = {
|
||||
content: textContent(error.message),
|
||||
isError: true,
|
||||
});
|
||||
};
|
||||
const structured = structuredContent(error.toolEnvelope);
|
||||
if (structured) {
|
||||
result.structuredContent = structured;
|
||||
}
|
||||
return this.createResult(request.id, result);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+684
-6
@@ -1,5 +1,6 @@
|
||||
'use strict';
|
||||
|
||||
const crypto = require('crypto');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const {
|
||||
@@ -16,9 +17,51 @@ const {
|
||||
} = require('./assets');
|
||||
const { runScriptDiagnostics } = require('./diagnostics');
|
||||
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 { checkForUpdate } = require('./update-checker');
|
||||
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) {
|
||||
const schema = {
|
||||
@@ -31,6 +74,231 @@ function createSchema(properties, required) {
|
||||
return schema;
|
||||
}
|
||||
|
||||
function createOutputSchema(dataSchema = {}) {
|
||||
return {
|
||||
type: 'object',
|
||||
properties: {
|
||||
ok: { type: 'boolean', description: 'Whether the tool call completed successfully.' },
|
||||
tool: { type: 'string', description: 'Tool name that produced this result.' },
|
||||
callId: { type: 'string', description: 'Stable identifier for this tool call result.' },
|
||||
timestamp: { type: 'string', description: 'ISO timestamp when the result envelope was produced.' },
|
||||
summary: { type: 'string', description: 'Short human-readable result summary.' },
|
||||
data: dataSchema,
|
||||
refs: {
|
||||
type: 'array',
|
||||
description: 'Stable references discovered in the result for follow-up tool calls.',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
type: { type: 'string' },
|
||||
id: { type: 'string' },
|
||||
path: { type: 'string' },
|
||||
name: { type: 'string' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
required: ['ok', 'tool', 'callId', 'timestamp', 'data'],
|
||||
};
|
||||
}
|
||||
|
||||
function inferToolCategory(toolName) {
|
||||
for (const [category, pattern] of TOOL_CATEGORY_RULES) {
|
||||
if (pattern.test(toolName)) {
|
||||
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) {
|
||||
if (typeof value === 'string') {
|
||||
return value;
|
||||
@@ -123,7 +391,18 @@ async function refreshAssets(projectPath, targetPath) {
|
||||
return 'File written outside assets directory; no asset-db refresh was needed.';
|
||||
}
|
||||
|
||||
function createToolRegistry({ getRuntimeContext, getStatus, 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 = [
|
||||
{
|
||||
name: 'execute_javascript',
|
||||
@@ -225,6 +504,31 @@ function createToolRegistry({ getRuntimeContext, getStatus, interactionLog, scen
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
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',
|
||||
@@ -232,6 +536,67 @@ function createToolRegistry({ getRuntimeContext, getStatus, interactionLog, scen
|
||||
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',
|
||||
@@ -418,6 +783,166 @@ function createToolRegistry({ getRuntimeContext, getStatus, interactionLog, scen
|
||||
return { count: assets.length, prefabs: assets.slice(0, 200) };
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'inspect_prefab',
|
||||
profile: 'core',
|
||||
description: '[specialist] Inspect a prefab asset, its metadata, serialized file path, and UUID-like asset references.',
|
||||
inputSchema: createSchema(
|
||||
{
|
||||
target: { type: 'string', description: 'Prefab uuid, db url, or project path.' },
|
||||
},
|
||||
['target']
|
||||
),
|
||||
handler: async (args) => {
|
||||
const { projectPath } = getRuntimeContext();
|
||||
return await inspectPrefab(projectPath, args.target);
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'validate_prefab_references',
|
||||
profile: 'core',
|
||||
description: '[specialist] Validate prefab asset references by checking serialized UUID references against asset-db.',
|
||||
inputSchema: createSchema(
|
||||
{
|
||||
target: { type: 'string', description: 'Optional prefab uuid, db url, or path. When omitted, scans prefab assets.' },
|
||||
pattern: { type: 'string', description: 'Optional asset-db pattern used when scanning prefabs.' },
|
||||
limit: { type: 'number', description: 'Maximum prefab assets to scan when target is omitted.' },
|
||||
},
|
||||
[]
|
||||
),
|
||||
handler: async (args) => {
|
||||
const { projectPath } = getRuntimeContext();
|
||||
return await validatePrefabReferences(projectPath, args);
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'duplicate_prefab',
|
||||
profile: 'full',
|
||||
description: '[core] Create a new prefab asset by duplicating an existing prefab file without copying its .meta UUID.',
|
||||
inputSchema: createSchema(
|
||||
{
|
||||
source: { type: 'string', description: 'Source prefab uuid, db url, or project path.' },
|
||||
target: { type: 'string', description: 'Project-relative target path under assets, with or without .prefab.' },
|
||||
overwrite: { type: 'boolean', description: 'Overwrite target prefab if it already exists.' },
|
||||
},
|
||||
['source', 'target']
|
||||
),
|
||||
handler: async (args) => {
|
||||
const { projectPath } = getRuntimeContext();
|
||||
const result = await duplicatePrefab(projectPath, args);
|
||||
return { ...result, refresh: await refreshAssets(projectPath, resolveProjectPath(projectPath, result.target)) };
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'edit_prefab_json',
|
||||
profile: 'full',
|
||||
description: '[core] Edit a prefab JSON file by JSON path assignment or literal search/replace, then validate references.',
|
||||
inputSchema: createSchema(
|
||||
{
|
||||
target: { type: 'string', description: 'Prefab uuid, db url, or project path.' },
|
||||
jsonPath: { type: 'string', description: 'JSON path such as /0/_name or 0._name when assigning valueJson.' },
|
||||
valueJson: { type: 'string', description: 'JSON encoded value to assign at jsonPath.' },
|
||||
search: { type: 'string', description: 'Literal text to search for instead of jsonPath assignment.' },
|
||||
replace: { type: 'string', description: 'Replacement text for literal search.' },
|
||||
replaceAll: { type: 'boolean', description: 'Replace all literal matches.' },
|
||||
createBackup: { type: 'boolean', description: 'Create a .bak file before writing.' },
|
||||
},
|
||||
['target']
|
||||
),
|
||||
handler: async (args) => {
|
||||
const { projectPath } = getRuntimeContext();
|
||||
const result = await editPrefabJson(projectPath, args);
|
||||
return { ...result, refresh: await refreshAssets(projectPath, resolveProjectPath(projectPath, result.path)) };
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'create_prefab_instance',
|
||||
profile: 'full',
|
||||
description: '[core] Create a linked prefab instance in the editor hierarchy using Cocos scene create-node when available.',
|
||||
inputSchema: createSchema(
|
||||
{
|
||||
prefabUuid: { type: 'string', description: 'Prefab asset uuid, db url, or path.' },
|
||||
parentPath: { type: 'string', description: 'Optional parent node path.' },
|
||||
name: { type: 'string', description: 'Optional override node name.' },
|
||||
position: { type: 'object', description: 'Optional position {x,y,z}; fallback runtime path only.' },
|
||||
},
|
||||
['prefabUuid']
|
||||
),
|
||||
handler: async (args) => {
|
||||
const info = await queryAssetInfo(args.prefabUuid);
|
||||
const payload = {
|
||||
assetUuid: info.uuid || args.prefabUuid,
|
||||
unlinkPrefab: false,
|
||||
};
|
||||
if (args.parentPath) {
|
||||
payload.parent = await resolveNodeUuid(sceneBridge, { path: args.parentPath });
|
||||
}
|
||||
if (args.name) {
|
||||
payload.name = args.name;
|
||||
}
|
||||
|
||||
if (global.Editor && Editor.Message && typeof Editor.Message.request === 'function') {
|
||||
try {
|
||||
const createdUuid = await Editor.Message.request('scene', 'create-node', payload);
|
||||
return {
|
||||
created: true,
|
||||
linkedPrefab: true,
|
||||
prefabUuid: payload.assetUuid,
|
||||
uuid: createdUuid,
|
||||
};
|
||||
} catch (error) {
|
||||
runtimeLog && runtimeLog.add('warn', `Linked prefab create-node failed: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
return await sceneBridge.call('instantiatePrefab', {
|
||||
...args,
|
||||
prefabUuid: info.uuid || args.prefabUuid,
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'inspect_prefab_instance',
|
||||
profile: 'core',
|
||||
description: '[specialist] Inspect whether a scene node is linked to a prefab instance and return prefab metadata when available.',
|
||||
inputSchema: createSchema(
|
||||
{
|
||||
path: { type: 'string', description: 'Node hierarchy path.' },
|
||||
uuid: { type: 'string', description: 'Node uuid.' },
|
||||
name: { type: 'string', description: 'Fallback exact node name.' },
|
||||
},
|
||||
[]
|
||||
),
|
||||
handler: async (args) => sceneBridge.call('getPrefabInstanceInfo', args),
|
||||
},
|
||||
{
|
||||
name: 'apply_prefab_instance',
|
||||
profile: 'full',
|
||||
description: '[core] Apply a scene prefab instance back to its associated prefab asset using the Cocos editor scene apply-prefab message.',
|
||||
inputSchema: createSchema(
|
||||
{
|
||||
path: { type: 'string', description: 'Node hierarchy path.' },
|
||||
uuid: { type: 'string', description: 'Node uuid.' },
|
||||
name: { type: 'string', description: 'Fallback exact node name.' },
|
||||
},
|
||||
[]
|
||||
),
|
||||
handler: async (args) => await applyPrefabInstance(await resolveNodeUuid(sceneBridge, args)),
|
||||
},
|
||||
{
|
||||
name: 'revert_prefab_instance',
|
||||
profile: 'full',
|
||||
description: '[core] Revert a scene prefab instance from its associated prefab asset using available Cocos editor prefab revert messages.',
|
||||
inputSchema: createSchema(
|
||||
{
|
||||
path: { type: 'string', description: 'Node hierarchy path.' },
|
||||
uuid: { type: 'string', description: 'Node uuid.' },
|
||||
name: { type: 'string', description: 'Fallback exact node name.' },
|
||||
},
|
||||
[]
|
||||
),
|
||||
handler: async (args) => await revertPrefabInstance(await resolveNodeUuid(sceneBridge, args)),
|
||||
},
|
||||
{
|
||||
name: 'instantiate_prefab',
|
||||
profile: 'full',
|
||||
@@ -1016,6 +1541,138 @@ function createToolRegistry({ getRuntimeContext, getStatus, interactionLog, scen
|
||||
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',
|
||||
profile: 'core',
|
||||
@@ -1333,33 +1990,54 @@ function createToolRegistry({ getRuntimeContext, getStatus, interactionLog, scen
|
||||
listTools() {
|
||||
const { config } = getRuntimeContext();
|
||||
return tools
|
||||
.filter((tool) => config.toolProfile === 'full' || tool.profile === 'core')
|
||||
.filter((tool) => isToolExposed(config || {}, tool))
|
||||
.map((tool) => ({
|
||||
name: tool.name,
|
||||
description: tool.description,
|
||||
inputSchema: tool.inputSchema,
|
||||
outputSchema: tool.outputSchema || createOutputSchema(tool.dataSchema),
|
||||
annotations: inferToolAnnotations(tool),
|
||||
}));
|
||||
},
|
||||
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 tool = tools.find((item) => item.name === name);
|
||||
if (!tool) {
|
||||
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}'.`);
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await tool.handler(args || {});
|
||||
const output = toOutput(result);
|
||||
interactionLog.add(name, 'success', output.slice(0, 500));
|
||||
const envelope = createResultEnvelope(tool, args || {}, result);
|
||||
const output = typeof result === 'string' && result.startsWith(IMAGE_DATA_URI_PREFIX)
|
||||
? result
|
||||
: toOutput(envelope);
|
||||
interactionLog.add(name, 'success', envelope.summary.slice(0, 500));
|
||||
return {
|
||||
value: result,
|
||||
value: envelope,
|
||||
text: output,
|
||||
};
|
||||
} catch (error) {
|
||||
interactionLog.add(name, 'error', error.message);
|
||||
error.toolEnvelope = createResultEnvelope(tool, args || {}, { message: error.message }, {
|
||||
ok: false,
|
||||
summary: error.message,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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
-3
@@ -1,14 +1,60 @@
|
||||
{
|
||||
"name": "funplay-cocos-mcp",
|
||||
"package_version": 2,
|
||||
"version": "0.1.4",
|
||||
"version": "0.3.2",
|
||||
"description": "Embedded MCP server for Cocos Creator with scene script execution, project resources, prompts, and file/scene tools.",
|
||||
"author": "Funplay",
|
||||
"license": "MIT",
|
||||
"main": "browser.js",
|
||||
"bin": {
|
||||
"funplay-cocos-mcp": "bin/funplay-cocos-mcp.js"
|
||||
},
|
||||
"mcpName": "io.github.FunplayAI/funplay-cocos-mcp",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/FunplayAI/funplay-cocos-mcp.git"
|
||||
},
|
||||
"homepage": "https://github.com/FunplayAI/funplay-cocos-mcp#readme",
|
||||
"bugs": {
|
||||
"url": "https://github.com/FunplayAI/funplay-cocos-mcp/issues"
|
||||
},
|
||||
"keywords": [
|
||||
"mcp",
|
||||
"cocos",
|
||||
"cocos-creator",
|
||||
"funplay",
|
||||
"model-context-protocol",
|
||||
"ai"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"files": [
|
||||
"bin/",
|
||||
"lib/",
|
||||
"panel/",
|
||||
"browser.js",
|
||||
"scene.js",
|
||||
"server.json",
|
||||
"README.md",
|
||||
"README_CN.md",
|
||||
"CHANGELOG.md",
|
||||
"CONTRIBUTING.md",
|
||||
"LICENSE",
|
||||
"RELEASE_WORKFLOW.md",
|
||||
"RELEASE_CHECKLIST.md"
|
||||
],
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"scripts": {
|
||||
"check": "node --check browser.js && node --check scene.js && node --check panel/index.js && node --check lib/assets.js && node --check lib/client-config.js && node --check lib/config.js && node --check lib/diagnostics.js && node --check lib/electron-tools.js && node --check lib/input.js && node --check lib/interaction-log.js && node --check lib/path-safety.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",
|
||||
"test": "node --test"
|
||||
"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": {
|
||||
"default": {
|
||||
@@ -77,6 +123,11 @@
|
||||
"callToolFromPanel"
|
||||
]
|
||||
},
|
||||
"check-updates": {
|
||||
"methods": [
|
||||
"checkUpdates"
|
||||
]
|
||||
},
|
||||
"read-resource": {
|
||||
"methods": [
|
||||
"readResourceFromPanel"
|
||||
|
||||
+115
-1
@@ -19,7 +19,7 @@ module.exports = Editor.Panel.define({
|
||||
<header class="hero">
|
||||
<div>
|
||||
<h1>Funplay Cocos MCP</h1>
|
||||
<p>Operate the embedded MCP server from Cocos Creator.</p>
|
||||
<p id="versionText">Version</p>
|
||||
</div>
|
||||
<div class="status-pill" id="statusPill">Unknown</div>
|
||||
</header>
|
||||
@@ -34,6 +34,7 @@ module.exports = Editor.Panel.define({
|
||||
</label>
|
||||
<ui-button id="restartBtn">Restart</ui-button>
|
||||
<ui-button id="copyUrlBtn">Copy URL</ui-button>
|
||||
<ui-button id="checkUpdatesBtn">Check Updates</ui-button>
|
||||
</div>
|
||||
<div class="grid">
|
||||
<label>Server Port <ui-num-input id="portInput"></ui-num-input></label>
|
||||
@@ -41,12 +42,34 @@ module.exports = Editor.Panel.define({
|
||||
<ui-select id="profileSelect">
|
||||
<option value="core">core</option>
|
||||
<option value="full">full</option>
|
||||
<option value="custom">custom</option>
|
||||
</ui-select>
|
||||
</label>
|
||||
<label class="checkbox-line">
|
||||
<ui-checkbox id="sessionsInput"></ui-checkbox>
|
||||
MCP Sessions
|
||||
</label>
|
||||
</div>
|
||||
<div id="updateStatus" class="client-status muted"></div>
|
||||
<p>Changes auto-save. Port/profile changes restart the server when needed.</p>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h2>Tool Manager</h2>
|
||||
<div id="toolSummary" class="status-line muted"></div>
|
||||
<div class="row tool-presets">
|
||||
<ui-button id="useCoreBtn">Core</ui-button>
|
||||
<ui-button id="useFullBtn">Full</ui-button>
|
||||
<ui-button id="useCustomBtn">Custom</ui-button>
|
||||
</div>
|
||||
<div class="tool-config-grid">
|
||||
<label>Enabled Categories <ui-textarea id="enabledCategoriesInput"></ui-textarea></label>
|
||||
<label>Disabled Categories <ui-textarea id="disabledCategoriesInput"></ui-textarea></label>
|
||||
<label>Enabled Tools <ui-textarea id="enabledToolsInput"></ui-textarea></label>
|
||||
<label>Disabled Tools <ui-textarea id="disabledToolsInput"></ui-textarea></label>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h2>MCP Client Config</h2>
|
||||
<div class="row">
|
||||
@@ -129,6 +152,12 @@ module.exports = Editor.Panel.define({
|
||||
gap: 8px;
|
||||
align-items: end;
|
||||
}
|
||||
.tool-config-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(180px, 1fr));
|
||||
gap: 8px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -138,6 +167,7 @@ module.exports = Editor.Panel.define({
|
||||
.checkbox-line {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
min-height: 28px;
|
||||
}
|
||||
.checkbox-inline {
|
||||
padding-top: 0;
|
||||
@@ -184,6 +214,9 @@ module.exports = Editor.Panel.define({
|
||||
width: 100%;
|
||||
min-height: 100px;
|
||||
}
|
||||
.tool-config-grid ui-textarea {
|
||||
min-height: 72px;
|
||||
}
|
||||
pre {
|
||||
min-height: 120px;
|
||||
max-height: 220px;
|
||||
@@ -203,12 +236,24 @@ module.exports = Editor.Panel.define({
|
||||
$: {
|
||||
root: '.mcp-root',
|
||||
statusPill: '#statusPill',
|
||||
versionText: '#versionText',
|
||||
statusText: '#statusText',
|
||||
enabledInput: '#enabledInput',
|
||||
portInput: '#portInput',
|
||||
profileSelect: '#profileSelect',
|
||||
sessionsInput: '#sessionsInput',
|
||||
restartBtn: '#restartBtn',
|
||||
copyUrlBtn: '#copyUrlBtn',
|
||||
checkUpdatesBtn: '#checkUpdatesBtn',
|
||||
updateStatus: '#updateStatus',
|
||||
toolSummary: '#toolSummary',
|
||||
useCoreBtn: '#useCoreBtn',
|
||||
useFullBtn: '#useFullBtn',
|
||||
useCustomBtn: '#useCustomBtn',
|
||||
enabledCategoriesInput: '#enabledCategoriesInput',
|
||||
disabledCategoriesInput: '#disabledCategoriesInput',
|
||||
enabledToolsInput: '#enabledToolsInput',
|
||||
disabledToolsInput: '#disabledToolsInput',
|
||||
clientTargetSelect: '#clientTargetSelect',
|
||||
configureClientBtn: '#configureClientBtn',
|
||||
clientTargetStatus: '#clientTargetStatus',
|
||||
@@ -230,6 +275,7 @@ module.exports = Editor.Panel.define({
|
||||
const config = state.config || {};
|
||||
const isRunning = Boolean(status.running);
|
||||
|
||||
this.$.versionText.textContent = `Version ${status.version || 'unknown'}`;
|
||||
this.$.statusPill.textContent = isRunning ? 'Running' : 'Stopped';
|
||||
this.$.statusPill.classList.toggle('running', isRunning);
|
||||
this.$.statusPill.classList.toggle('stopped', !isRunning);
|
||||
@@ -242,10 +288,47 @@ module.exports = Editor.Panel.define({
|
||||
this.$.enabledInput.value = Boolean(isRunning || config.autostart);
|
||||
this.$.portInput.value = Number(config.port || status.port || 8765);
|
||||
this.$.profileSelect.value = config.toolProfile || status.toolProfile || 'core';
|
||||
this.$.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.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() {
|
||||
const targets = (this.state && this.state.clientTargets) || [];
|
||||
const preferred = this.state && this.state.config ? this.state.config.lastClientTargetId : '';
|
||||
@@ -299,6 +382,11 @@ module.exports = Editor.Panel.define({
|
||||
host: (this.state && this.state.config && this.state.config.host) || (this.state && this.state.status && this.state.status.host) || '127.0.0.1',
|
||||
port: Number(this.$.portInput.value || 8765),
|
||||
toolProfile: this.$.profileSelect.value || 'core',
|
||||
enabledToolCategories: this.parseList(this.$.enabledCategoriesInput.value).map((item) => item.toLowerCase()),
|
||||
disabledToolCategories: this.parseList(this.$.disabledCategoriesInput.value).map((item) => item.toLowerCase()),
|
||||
enabledTools: this.parseList(this.$.enabledToolsInput.value),
|
||||
disabledTools: this.parseList(this.$.disabledToolsInput.value),
|
||||
enableSessions: Boolean(this.$.sessionsInput.value),
|
||||
autostart: Boolean(this.$.enabledInput.value),
|
||||
maxInteractionLogEntries: this.state && this.state.config ? this.state.config.maxInteractionLogEntries : 50,
|
||||
lastClientTargetId: this.$.clientTargetSelect.value || 'claude_code',
|
||||
@@ -331,9 +419,35 @@ module.exports = Editor.Panel.define({
|
||||
.then(() => this.showOutput('Copied URL to clipboard.'))
|
||||
.catch(() => this.showOutput(text));
|
||||
});
|
||||
this.$.checkUpdatesBtn.addEventListener('click', () => this.runAction(() => request('check-updates')));
|
||||
this.$.enabledInput.addEventListener('change', () => this.handleEnableToggle());
|
||||
this.$.portInput.addEventListener('change', () => this.persistConfig({ showOutput: true }));
|
||||
this.$.profileSelect.addEventListener('change', () => this.persistConfig({ showOutput: true }));
|
||||
this.$.sessionsInput.addEventListener('change', () => this.persistConfig({ showOutput: true }));
|
||||
this.$.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('change', () => {
|
||||
this.renderClientTargetStatus();
|
||||
|
||||
@@ -308,6 +308,97 @@ function findComponentsByClass(componentClass) {
|
||||
return results;
|
||||
}
|
||||
|
||||
function getPrefabInfo(node) {
|
||||
const prefab = node && node._prefab;
|
||||
if (!prefab) {
|
||||
return {
|
||||
linked: false,
|
||||
};
|
||||
}
|
||||
|
||||
const asset = prefab.asset || prefab._asset || null;
|
||||
return {
|
||||
linked: Boolean(asset || prefab.fileId || prefab.root),
|
||||
fileId: prefab.fileId || '',
|
||||
asset: asset
|
||||
? {
|
||||
name: asset.name || '',
|
||||
uuid: asset.uuid || asset._uuid || '',
|
||||
}
|
||||
: null,
|
||||
instance: prefab.instance ? plain(prefab.instance) : null,
|
||||
sync: prefab.sync,
|
||||
rawKeys: Object.keys(prefab).slice(0, 50),
|
||||
};
|
||||
}
|
||||
|
||||
function collectSceneStats() {
|
||||
const stats = {
|
||||
nodeCount: 0,
|
||||
activeNodeCount: 0,
|
||||
inactiveNodeCount: 0,
|
||||
maxDepth: 0,
|
||||
componentCount: 0,
|
||||
componentsByType: {},
|
||||
prefabInstanceCount: 0,
|
||||
uiTransformCount: 0,
|
||||
canvasCount: 0,
|
||||
cameraCount: 0,
|
||||
labelCount: 0,
|
||||
spriteCount: 0,
|
||||
buttonCount: 0,
|
||||
};
|
||||
|
||||
function visit(node, depth) {
|
||||
if (node !== getScene()) {
|
||||
stats.nodeCount += 1;
|
||||
stats.maxDepth = Math.max(stats.maxDepth, depth);
|
||||
if (node.active) stats.activeNodeCount += 1;
|
||||
else stats.inactiveNodeCount += 1;
|
||||
if (node._prefab) stats.prefabInstanceCount += 1;
|
||||
}
|
||||
|
||||
for (const component of node.components || []) {
|
||||
const name = component && component.constructor ? component.constructor.name : 'UnknownComponent';
|
||||
stats.componentCount += 1;
|
||||
stats.componentsByType[name] = (stats.componentsByType[name] || 0) + 1;
|
||||
if (component instanceof UITransform) stats.uiTransformCount += 1;
|
||||
if (component instanceof Canvas) stats.canvasCount += 1;
|
||||
if (component instanceof Camera) stats.cameraCount += 1;
|
||||
if (component instanceof Label) stats.labelCount += 1;
|
||||
if (component instanceof Sprite) stats.spriteCount += 1;
|
||||
if (component instanceof Button) stats.buttonCount += 1;
|
||||
}
|
||||
|
||||
for (const child of node.children) {
|
||||
visit(child, depth + 1);
|
||||
}
|
||||
}
|
||||
|
||||
visit(getScene(), 0);
|
||||
return stats;
|
||||
}
|
||||
|
||||
function buildSceneWarnings(stats) {
|
||||
const warnings = [];
|
||||
if (stats.nodeCount === 0) {
|
||||
warnings.push({ severity: 'warn', code: 'empty_scene', message: 'The active scene has no child nodes.' });
|
||||
}
|
||||
if (stats.cameraCount === 0) {
|
||||
warnings.push({ severity: 'warn', code: 'missing_camera', message: 'No Camera component was found in the active scene.' });
|
||||
}
|
||||
if (stats.nodeCount > 500) {
|
||||
warnings.push({ severity: 'info', code: 'large_node_count', message: `Scene has ${stats.nodeCount} nodes.` });
|
||||
}
|
||||
if (stats.maxDepth > 12) {
|
||||
warnings.push({ severity: 'info', code: 'deep_hierarchy', message: `Scene hierarchy depth is ${stats.maxDepth}.` });
|
||||
}
|
||||
if (stats.labelCount > 80) {
|
||||
warnings.push({ severity: 'info', code: 'many_labels', message: `Scene has ${stats.labelCount} Label components.` });
|
||||
}
|
||||
return warnings;
|
||||
}
|
||||
|
||||
function getScheduler() {
|
||||
return typeof director.getScheduler === 'function' ? director.getScheduler() : null;
|
||||
}
|
||||
@@ -1149,6 +1240,45 @@ exports.methods = {
|
||||
};
|
||||
},
|
||||
|
||||
async getPerformanceSnapshot() {
|
||||
const scheduler = getScheduler();
|
||||
const stats = collectSceneStats();
|
||||
const memory = typeof performance !== 'undefined' && performance.memory
|
||||
? {
|
||||
jsHeapSizeLimit: performance.memory.jsHeapSizeLimit,
|
||||
totalJSHeapSize: performance.memory.totalJSHeapSize,
|
||||
usedJSHeapSize: performance.memory.usedJSHeapSize,
|
||||
}
|
||||
: null;
|
||||
|
||||
return {
|
||||
sceneName: getScene().name,
|
||||
runtime: {
|
||||
paused: typeof director.isPaused === 'function' ? director.isPaused() : false,
|
||||
timeScale: scheduler && typeof scheduler.getTimeScale === 'function' ? scheduler.getTimeScale() : 1,
|
||||
totalFrames: typeof director.getTotalFrames === 'function' ? director.getTotalFrames() : undefined,
|
||||
},
|
||||
stats,
|
||||
memory,
|
||||
warnings: buildSceneWarnings(stats),
|
||||
};
|
||||
},
|
||||
|
||||
async getPrefabInstanceInfo(options = {}) {
|
||||
const node = findNode(options);
|
||||
if (!node) {
|
||||
throw new Error('Target node was not found.');
|
||||
}
|
||||
return {
|
||||
node: {
|
||||
name: node.name,
|
||||
path: getNodePath(node),
|
||||
uuid: node.uuid,
|
||||
},
|
||||
prefab: getPrefabInfo(node),
|
||||
};
|
||||
},
|
||||
|
||||
async pauseRuntime() {
|
||||
if (typeof director.pause === 'function') {
|
||||
director.pause();
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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/
|
||||
);
|
||||
});
|
||||
+115
-2
@@ -1,6 +1,7 @@
|
||||
'use strict';
|
||||
|
||||
const assert = require('node:assert/strict');
|
||||
const http = require('node:http');
|
||||
const test = require('node:test');
|
||||
const {
|
||||
MCP_PROTOCOL_VERSION,
|
||||
@@ -8,9 +9,9 @@ const {
|
||||
SUPPORTED_PROTOCOL_VERSIONS,
|
||||
} = require('../lib/server');
|
||||
|
||||
function createServer(toolRegistry = {}) {
|
||||
function createServer(toolRegistry = {}, config = {}) {
|
||||
return new McpServer({
|
||||
config: { host: '127.0.0.1', port: 8765 },
|
||||
config: { host: '127.0.0.1', port: 8765, ...config },
|
||||
toolRegistry: {
|
||||
listTools: () => [],
|
||||
callTool: async () => 'ok',
|
||||
@@ -26,11 +27,45 @@ function createServer(toolRegistry = {}) {
|
||||
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({
|
||||
@@ -126,3 +161,81 @@ test('unsupported protocol version headers are rejected when present after initi
|
||||
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();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
});
|
||||
@@ -5,35 +5,70 @@ 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')) {
|
||||
function createRegistry(profile, projectPath = path.resolve('/tmp/funplay-cocos-test-project'), configExtras = {}, overrides = {}) {
|
||||
return createToolRegistry({
|
||||
getRuntimeContext: () => ({
|
||||
config: { toolProfile: profile },
|
||||
config: { toolProfile: profile, ...configExtras },
|
||||
projectPath,
|
||||
version: '0.0.0-test',
|
||||
}),
|
||||
interactionLog: { add() {} },
|
||||
sceneBridge: { call: async () => ({ ok: true }) },
|
||||
editorExecutor: async () => ({ ok: true }),
|
||||
runtimeLog: { add() {}, list: () => [], clear: () => 0 },
|
||||
sceneBridge: overrides.sceneBridge || { call: async () => ({ ok: true }) },
|
||||
editorExecutor: overrides.editorExecutor || (async () => ({ ok: true })),
|
||||
});
|
||||
}
|
||||
|
||||
test('core profile exposes the documented focused tool set', () => {
|
||||
const tools = createRegistry('core').listTools();
|
||||
assert.equal(tools.length, 22);
|
||||
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, 70);
|
||||
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(
|
||||
@@ -45,6 +80,21 @@ test('file tools reject writes outside the project root', async () => {
|
||||
test('callToolDetailed preserves structured values and text output', async () => {
|
||||
const registry = createRegistry('core');
|
||||
const result = await registry.callToolDetailed('get_project_info', {});
|
||||
assert.equal(result.value.projectPath, path.resolve('/tmp/funplay-cocos-test-project'));
|
||||
assert.equal(result.value.ok, true);
|
||||
assert.equal(result.value.tool, 'get_project_info');
|
||||
assert.equal(result.value.data.projectPath, path.resolve('/tmp/funplay-cocos-test-project'));
|
||||
assert.match(result.value.callId, /^fp_/);
|
||||
assert.match(result.text, /projectPath/);
|
||||
});
|
||||
|
||||
test('callToolDetailed preserves screenshot image text while keeping structured envelope small', async () => {
|
||||
const dataUri = 'data:image/png;base64,AAAA';
|
||||
const registry = createRegistry('core', path.resolve('/tmp/funplay-cocos-test-project'), {}, {
|
||||
editorExecutor: async () => dataUri,
|
||||
});
|
||||
|
||||
const result = await registry.callToolDetailed('execute_javascript', { context: 'editor', code: 'return image;' });
|
||||
assert.equal(result.text, dataUri);
|
||||
assert.equal(result.value.data.image, true);
|
||||
assert.equal(result.value.data.mimeType, 'image/png');
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
Reference in New Issue
Block a user