Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3ab48e2457 | ||
|
|
e01f058e3e | ||
|
|
6fc15e5ebe | ||
|
|
69ab5710f6 | ||
|
|
a903f7f0af |
@@ -72,3 +72,6 @@ jobs:
|
|||||||
|
|
||||||
- name: Run syntax checks
|
- name: Run syntax checks
|
||||||
run: npm run check
|
run: npm run check
|
||||||
|
|
||||||
|
- name: Run tests
|
||||||
|
run: npm test
|
||||||
|
|||||||
@@ -4,6 +4,69 @@ All notable changes to Funplay MCP for Cocos will be documented in this file.
|
|||||||
|
|
||||||
This project follows a simple changelog format inspired by [Keep a Changelog](https://keepachangelog.com/), and uses semantic versioning when releases are tagged.
|
This project follows a simple changelog format inspired by [Keep a Changelog](https://keepachangelog.com/), and uses semantic versioning when releases are tagged.
|
||||||
|
|
||||||
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [0.2.0] - 2026-05-20
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added a panel and tool-level update checker for comparing the installed extension version with the latest GitHub release.
|
||||||
|
- Added `custom` tool exposure with per-category and per-tool include/exclude configuration.
|
||||||
|
- Added `get_tool_catalog`, `check_for_updates`, `get_recent_logs`, `search_project_logs`, `clear_logs`, and `validate_scene`.
|
||||||
|
- Added MCP log resources: `cocos://logs/editor` and `cocos://logs/project`.
|
||||||
|
- Added in-memory runtime logs alongside the existing MCP interaction history.
|
||||||
|
- Added optional Streamable HTTP session support via `enableSessions` and `Mcp-Session-Id`.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Expanded the default `core` profile from 22 tools to 28 tools.
|
||||||
|
- Expanded the `full` profile from 70 tools to 76 tools.
|
||||||
|
- Updated the Cocos panel to show installed version, update status, session toggle, and tool exposure controls.
|
||||||
|
- Tightened Streamable HTTP behavior for `Accept` headers, JSON-RPC notifications/responses, `202 Accepted`, unsupported GET/SSE requests, and DELETE session termination.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Improved project log tailing so trailing blank lines do not hide the last useful log entries.
|
||||||
|
|
||||||
|
## [0.1.4] - 2026-05-11
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed CI test expectations after expanding the documented `core` and `full` tool profiles.
|
||||||
|
- Aligned the latest release line with the current tested `main` branch state.
|
||||||
|
|
||||||
|
## [0.1.3] - 2026-05-11
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added `get_editor_state` as a compact structured editor-state summary tool.
|
||||||
|
- Added `get_selection` and `set_selection` as explicit selection workflow tools for editor-side automation.
|
||||||
|
- Added persistence for the selected one-click MCP client target in the Cocos panel configuration.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Expanded the default `core` profile from 19 tools to 22 tools by promoting editor-state and selection workflows.
|
||||||
|
- Expanded the `full` profile from 67 tools to 70 tools.
|
||||||
|
- Updated panel config persistence so changing the selected MCP client target no longer restarts the server unnecessarily.
|
||||||
|
|
||||||
|
## [0.1.2] - 2026-04-30
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added Node.js unit tests for MCP protocol negotiation, tool profile exports, tool execution errors, and project file path safety.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Updated the MCP initialize response to negotiate protocol version `2025-11-25` by default while retaining compatibility with older supported protocol versions.
|
||||||
|
- Added `structuredContent` to tool call results when a tool returns structured JSON data.
|
||||||
|
- Changed tool execution failures to return MCP tool errors instead of JSON-RPC internal errors, improving client-side self-correction.
|
||||||
|
- Updated CI to run the new Node.js test suite.
|
||||||
|
|
||||||
|
### Security
|
||||||
|
|
||||||
|
- Restricted project file and asset-path resources to paths inside the active Cocos project root.
|
||||||
|
- Added HTTP request body size limits and invalid `Origin` header rejection for the embedded MCP server.
|
||||||
|
|
||||||
## [0.1.1] - 2026-04-16
|
## [0.1.1] - 2026-04-16
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -69,7 +69,9 @@ The panel is intentionally small:
|
|||||||
|
|
||||||
- Enable or disable the MCP server
|
- Enable or disable the MCP server
|
||||||
- Change the server port
|
- Change the server port
|
||||||
- Switch tool exposure between `core` and `full`
|
- Switch tool exposure between `core`, `full`, and `custom`
|
||||||
|
- Check the installed version against the latest GitHub release
|
||||||
|
- Tune tool exposure by category or individual tool
|
||||||
- Configure AI clients with one click
|
- Configure AI clients with one click
|
||||||
- Expand debug output only when needed
|
- Expand debug output only when needed
|
||||||
|
|
||||||
@@ -197,8 +199,11 @@ Try a higher-level prompt in your AI client:
|
|||||||
- This extension is **Editor-only**. It is meant to automate Cocos Creator, not to add runtime dependencies to your final game build.
|
- This extension is **Editor-only**. It is meant to automate Cocos Creator, not to add runtime dependencies to your final game build.
|
||||||
- The MCP server listens on `http://127.0.0.1:8765/` by default.
|
- The MCP server listens on `http://127.0.0.1:8765/` by default.
|
||||||
- If the configured port is busy, the server automatically falls back to the next available port and the panel/client config use the actual running port.
|
- If the configured port is busy, the server automatically falls back to the next available port and the panel/client config use the actual running port.
|
||||||
- The default `core` profile exposes 19 high-signal tools. Switch to `full` in the panel if you want all 67 tools exposed.
|
- The default `core` profile exposes 28 high-signal tools. Switch to `full` for all 76 tools, or use `custom` to include/exclude tool categories and individual tools.
|
||||||
|
- 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.
|
||||||
- All exposed MCP tools execute directly. There is no extra approval toggle inside the Cocos extension.
|
- All exposed MCP tools execute directly. There is no extra approval toggle inside the Cocos extension.
|
||||||
|
- File tools and `cocos://asset/path/...` resources are restricted to the active Cocos project root.
|
||||||
- The recommended workflow is `execute_javascript` first, then focused helper tools for screenshots, diagnostics, assets, and inspection.
|
- The recommended workflow is `execute_javascript` first, then focused helper tools for screenshots, diagnostics, assets, and inspection.
|
||||||
- If you change the server port or tool exposure in the panel, the extension saves the config and restarts the server when needed.
|
- If you change the server port or tool exposure in the panel, the extension saves the config and restarts the server when needed.
|
||||||
|
|
||||||
@@ -207,16 +212,16 @@ Try a higher-level prompt in your AI client:
|
|||||||
- **`execute_javascript` First** — One high-flexibility JavaScript tool can orchestrate scene/runtime work and editor-side automation without flooding AI clients with too many narrow tool calls
|
- **`execute_javascript` First** — One high-flexibility JavaScript tool can orchestrate scene/runtime work and editor-side automation without flooding AI clients with too many narrow tool calls
|
||||||
- **Embedded Cocos Extension** — No separate Python daemon or external bridge process is required for the Cocos-side plugin
|
- **Embedded Cocos Extension** — No separate Python daemon or external bridge process is required for the Cocos-side plugin
|
||||||
- **One-Click Client Configuration** — Configure Claude Code, Cursor, VS Code, Trae, Kiro, and Codex directly from Cocos Creator
|
- **One-Click Client Configuration** — Configure Claude Code, Cursor, VS Code, Trae, Kiro, and Codex directly from Cocos Creator
|
||||||
- **Project Context Built In** — Exposes live project, scene, selection, script diagnostics, and interaction-history resources
|
- **Project Context Built In** — Exposes live project, scene, selection, script diagnostics, logs, and interaction-history resources
|
||||||
- **Focused by Default, Full When Needed** — `core` reduces tool-list noise; `full` exposes every available tool
|
- **Focused by Default, Full When Needed** — `core` reduces tool-list noise; `full` exposes every available tool; `custom` lets you tune by category or tool
|
||||||
- **Visual Validation** — Scene/editor/preview screenshots and input simulation help AI verify UI and gameplay changes
|
- **Visual Validation** — Scene/editor/preview screenshots and input simulation help AI verify UI and gameplay changes
|
||||||
|
|
||||||
## Highlights
|
## Highlights
|
||||||
|
|
||||||
- **67 Built-in Tools** — Scene hierarchy, assets, UI creation, components, files, script diagnostics, screenshots, runtime control, and input simulation
|
- **76 Built-in Tools** — Scene hierarchy, editor state, selection workflows, assets, UI creation, components, files, logs, script diagnostics, screenshots, runtime control, and input simulation
|
||||||
- **Primary Unified Tool** — `execute_javascript` supports both `scene` and `editor` contexts
|
- **Primary Unified Tool** — `execute_javascript` supports both `scene` and `editor` contexts
|
||||||
- **Resources & Prompts** — Live project resources plus reusable workflows like script fixing, scene validation, and playable prototype creation
|
- **Resources & Prompts** — Live project/log resources plus reusable workflows like script fixing, scene validation, and playable prototype creation
|
||||||
- **Cocos Panel UI** — A minimal `Funplay > MCP Server` panel for service management and MCP client setup
|
- **Cocos Panel UI** — A minimal `Funplay > MCP Server` panel for service management, update checks, tool exposure, and MCP client setup
|
||||||
- **Screenshot and Input Support** — Capture editor/scene/game/preview screenshots and send Electron-level mouse/keyboard events
|
- **Screenshot and Input Support** — Capture editor/scene/game/preview screenshots and send Electron-level mouse/keyboard events
|
||||||
- **Vendor Agnostic** — Works with any AI client that supports MCP over HTTP JSON-RPC
|
- **Vendor Agnostic** — Works with any AI client that supports MCP over HTTP JSON-RPC
|
||||||
|
|
||||||
@@ -230,20 +235,20 @@ Funplay MCP for Cocos follows the same design principles as Funplay MCP for Unit
|
|||||||
| Embedded server | Built-in HTTP MCP server | Built-in HTTP MCP server |
|
| Embedded server | Built-in HTTP MCP server | Built-in HTTP MCP server |
|
||||||
| Primary execution tool | `execute_javascript` | `execute_code` |
|
| Primary execution tool | `execute_javascript` | `execute_code` |
|
||||||
| Primary language | JavaScript in scene/editor contexts | C# in Unity editor/runtime contexts |
|
| Primary language | JavaScript in scene/editor contexts | C# in Unity editor/runtime contexts |
|
||||||
| Default profile | `core` with 19 tools | `core` focused tool profile |
|
| Default profile | `core` with 28 tools | `core` focused tool profile |
|
||||||
| Full profile | 67 tools | 79 tools |
|
| Full profile | 76 tools plus `custom` exposure | 79 tools |
|
||||||
| Client setup | One-click config panel | One-click config window |
|
| Client setup | One-click config panel | One-click config window |
|
||||||
|
|
||||||
## MCP Capabilities
|
## MCP Capabilities
|
||||||
|
|
||||||
The current package exposes four capability layers:
|
The current package exposes four capability layers:
|
||||||
|
|
||||||
- **Tools** — 19 tools in `core`, 67 tools in `full`
|
- **Tools** — 28 tools in `core`, 76 tools in `full`, plus `custom` include/exclude rules
|
||||||
- **Primary execution** — `execute_javascript` for scene/runtime and editor/browser automation
|
- **Primary execution** — `execute_javascript` for scene/runtime and editor/browser automation
|
||||||
- **Prompts** — `fix_script_errors`, `create_playable_prototype`, `scene_validation`, and `auto_wire_scene`
|
- **Prompts** — `fix_script_errors`, `create_playable_prototype`, `scene_validation`, and `auto_wire_scene`
|
||||||
- **Resources** — project context, scene summaries, current selection, script diagnostics, asset selection, and MCP interaction history
|
- **Resources** — project context, scene summaries, current selection, script diagnostics, asset selection, logs, and MCP interaction history
|
||||||
|
|
||||||
The default `core` set is intentionally small: `execute_javascript`, `execute_scene_script`, `execute_editor_script`, `get_project_info`, `get_scene_info`, `get_hierarchy`, `list_scenes`, `open_scene`, `list_assets`, `inspect_asset`, `open_asset`, `select_asset`, `run_script_diagnostics`, `get_script_diagnostic_context`, `get_runtime_state`, `capture_editor_screenshot`, `capture_scene_screenshot`, `capture_preview_screenshot`, and `list_editor_windows`.
|
The default `core` set is intentionally small: `execute_javascript`, `execute_scene_script`, `execute_editor_script`, `get_editor_state`, `get_tool_catalog`, `check_for_updates`, `get_selection`, `set_selection`, `get_project_info`, `get_scene_info`, `get_hierarchy`, `list_scenes`, `open_scene`, `list_assets`, `inspect_asset`, `open_asset`, `select_asset`, `run_script_diagnostics`, `get_recent_logs`, `search_project_logs`, `clear_logs`, `validate_scene`, `get_script_diagnostic_context`, `get_runtime_state`, `capture_editor_screenshot`, `capture_scene_screenshot`, `capture_preview_screenshot`, and `list_editor_windows`.
|
||||||
|
|
||||||
## Built-in Resources
|
## Built-in Resources
|
||||||
|
|
||||||
@@ -256,15 +261,18 @@ The default `core` set is intentionally small: `execute_javascript`, `execute_sc
|
|||||||
| `cocos://selection/current` | Current editor selection |
|
| `cocos://selection/current` | Current editor selection |
|
||||||
| `cocos://selection/asset` | Current selected asset |
|
| `cocos://selection/asset` | Current selected asset |
|
||||||
| `cocos://errors/scripts` | Script diagnostics |
|
| `cocos://errors/scripts` | Script diagnostics |
|
||||||
|
| `cocos://logs/editor` | Recent MCP runtime logs and tool interactions |
|
||||||
|
| `cocos://logs/project` | Recent tails from common project log files |
|
||||||
| `cocos://mcp/interactions` | Recent MCP interaction history |
|
| `cocos://mcp/interactions` | Recent MCP interaction history |
|
||||||
|
|
||||||
## Built-in Tools
|
## Built-in Tools
|
||||||
|
|
||||||
Funplay MCP for Cocos currently ships with **67 tool functions** in the `full` profile:
|
Funplay MCP for Cocos currently ships with **76 tool functions** in the `full` profile:
|
||||||
|
|
||||||
| Category | Tools |
|
| Category | Tools |
|
||||||
|----------|-------|
|
|----------|-------|
|
||||||
| **Script Execution** | `execute_javascript`, `execute_scene_script`, `execute_editor_script` |
|
| **Script Execution** | `execute_javascript`, `execute_scene_script`, `execute_editor_script` |
|
||||||
|
| **Editor State** | `get_editor_state`, `get_tool_catalog`, `check_for_updates`, `get_selection`, `set_selection`, `get_editor_selection` |
|
||||||
| **Project & Scene** | `get_project_info`, `get_scene_info`, `get_hierarchy`, `find_nodes`, `inspect_node`, `list_scenes`, `open_scene`, `run_scene_asset` |
|
| **Project & Scene** | `get_project_info`, `get_scene_info`, `get_hierarchy`, `find_nodes`, `inspect_node`, `list_scenes`, `open_scene`, `run_scene_asset` |
|
||||||
| **Node Editing** | `create_node`, `delete_node`, `set_node_transform` |
|
| **Node Editing** | `create_node`, `delete_node`, `set_node_transform` |
|
||||||
| **Assets & Prefabs** | `list_assets`, `inspect_asset`, `open_asset`, `select_asset`, `delete_asset`, `list_prefabs`, `instantiate_prefab`, `get_editor_selection` |
|
| **Assets & Prefabs** | `list_assets`, `inspect_asset`, `open_asset`, `select_asset`, `delete_asset`, `list_prefabs`, `instantiate_prefab`, `get_editor_selection` |
|
||||||
@@ -273,7 +281,7 @@ Funplay MCP for Cocos currently ships with **67 tool functions** in the `full` p
|
|||||||
| **Camera** | `list_cameras`, `create_camera`, `set_camera_properties` |
|
| **Camera** | `list_cameras`, `create_camera`, `set_camera_properties` |
|
||||||
| **Animation** | `list_animations`, `add_animation_clip`, `play_animation`, `stop_animation` |
|
| **Animation** | `list_animations`, `add_animation_clip`, `play_animation`, `stop_animation` |
|
||||||
| **Files** | `read_file`, `get_file_snippet`, `write_file`, `replace_in_file`, `search_files`, `list_directory`, `exists`, `refresh_assets` |
|
| **Files** | `read_file`, `get_file_snippet`, `write_file`, `replace_in_file`, `search_files`, `list_directory`, `exists`, `refresh_assets` |
|
||||||
| **Diagnostics** | `run_script_diagnostics`, `get_script_diagnostic_context` |
|
| **Diagnostics & Logs** | `run_script_diagnostics`, `get_script_diagnostic_context`, `get_recent_logs`, `search_project_logs`, `clear_logs`, `validate_scene` |
|
||||||
| **Runtime** | `get_runtime_state`, `pause_runtime`, `resume_runtime`, `set_time_scale` |
|
| **Runtime** | `get_runtime_state`, `pause_runtime`, `resume_runtime`, `set_time_scale` |
|
||||||
| **Interaction** | `emit_node_event`, `simulate_button_click`, `invoke_component_method`, `simulate_mouse_click`, `simulate_mouse_drag`, `simulate_key_press`, `simulate_key_combo`, `simulate_preview_input` |
|
| **Interaction** | `emit_node_event`, `simulate_button_click`, `invoke_component_method`, `simulate_mouse_click`, `simulate_mouse_drag`, `simulate_key_press`, `simulate_key_combo`, `simulate_preview_input` |
|
||||||
| **Screenshots & Windows** | `capture_desktop_screenshot`, `capture_editor_screenshot`, `capture_scene_screenshot`, `capture_game_screenshot`, `capture_preview_screenshot`, `list_editor_windows` |
|
| **Screenshots & Windows** | `capture_desktop_screenshot`, `capture_editor_screenshot`, `capture_scene_screenshot`, `capture_game_screenshot`, `capture_preview_screenshot`, `list_editor_windows` |
|
||||||
@@ -311,6 +319,11 @@ Place `funplay-cocos-mcp.config.json` in the Cocos project root:
|
|||||||
"host": "127.0.0.1",
|
"host": "127.0.0.1",
|
||||||
"port": 8765,
|
"port": 8765,
|
||||||
"toolProfile": "core",
|
"toolProfile": "core",
|
||||||
|
"enabledToolCategories": [],
|
||||||
|
"disabledToolCategories": [],
|
||||||
|
"enabledTools": [],
|
||||||
|
"disabledTools": [],
|
||||||
|
"enableSessions": false,
|
||||||
"autostart": true,
|
"autostart": true,
|
||||||
"maxInteractionLogEntries": 50
|
"maxInteractionLogEntries": 50
|
||||||
}
|
}
|
||||||
@@ -322,6 +335,8 @@ Environment variables are also supported:
|
|||||||
- `COCOS_MCP_PORT`
|
- `COCOS_MCP_PORT`
|
||||||
- `COCOS_MCP_PROFILE`
|
- `COCOS_MCP_PROFILE`
|
||||||
|
|
||||||
|
`toolProfile: "custom"` starts from the `core` set, then adds `enabledToolCategories` / `enabledTools` and removes `disabledToolCategories` / `disabledTools`. `enableSessions` is off by default because this server does not need cross-request client state for normal editor automation.
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
```text
|
```text
|
||||||
|
|||||||
+29
-14
@@ -69,7 +69,9 @@ Funplay > MCP Server
|
|||||||
|
|
||||||
- 启用或停用 MCP Server
|
- 启用或停用 MCP Server
|
||||||
- 修改服务端口
|
- 修改服务端口
|
||||||
- 在 `core` / `full` 工具暴露模式之间切换
|
- 在 `core` / `full` / `custom` 工具暴露模式之间切换
|
||||||
|
- 检查当前安装版本是否落后于 GitHub 最新 Release
|
||||||
|
- 按工具分类或单个工具调整暴露范围
|
||||||
- 一键配置 AI 客户端
|
- 一键配置 AI 客户端
|
||||||
- 需要时再展开 Debug Output
|
- 需要时再展开 Debug Output
|
||||||
|
|
||||||
@@ -197,8 +199,11 @@ url = "http://127.0.0.1:8765/"
|
|||||||
- 这是一个 **仅限 Editor** 的扩展,用于自动化 Cocos Creator,不会给最终游戏包添加运行时依赖。
|
- 这是一个 **仅限 Editor** 的扩展,用于自动化 Cocos Creator,不会给最终游戏包添加运行时依赖。
|
||||||
- MCP Server 默认监听 `http://127.0.0.1:8765/`。
|
- MCP Server 默认监听 `http://127.0.0.1:8765/`。
|
||||||
- 如果配置端口被占用,服务会自动回退到下一个可用端口,面板与一键客户端配置会使用实际运行端口。
|
- 如果配置端口被占用,服务会自动回退到下一个可用端口,面板与一键客户端配置会使用实际运行端口。
|
||||||
- 默认 `core` profile 暴露 19 个高频工具;如果需要完整工具集,可在面板切到 `full`,暴露全部 67 个工具。
|
- 默认 `core` profile 暴露 28 个高频工具;如果需要完整工具集,可在面板切到 `full`,暴露全部 76 个工具;也可以用 `custom` 按分类或工具名增删。
|
||||||
|
- 面板提供手动更新检查,会对比当前安装版本和 GitHub 最新 Release。
|
||||||
|
- Streamable HTTP 响应已补齐 MCP 传输层要求,包括 `Accept`、`MCP-Protocol-Version`、JSON-RPC notification/response,以及可选 `Mcp-Session-Id` session。
|
||||||
- 所有已暴露的 MCP 工具都会直接执行,Cocos 扩展里没有额外 approval 开关。
|
- 所有已暴露的 MCP 工具都会直接执行,Cocos 扩展里没有额外 approval 开关。
|
||||||
|
- 文件工具和 `cocos://asset/path/...` 资源默认只能访问当前 Cocos 项目根目录内的路径。
|
||||||
- 推荐工作流是优先使用 `execute_javascript`,再配合截图、诊断、资产、检查类工具。
|
- 推荐工作流是优先使用 `execute_javascript`,再配合截图、诊断、资产、检查类工具。
|
||||||
- 如果在面板里修改端口或工具暴露模式,扩展会自动保存配置,并在需要时重启服务。
|
- 如果在面板里修改端口或工具暴露模式,扩展会自动保存配置,并在需要时重启服务。
|
||||||
|
|
||||||
@@ -207,16 +212,16 @@ url = "http://127.0.0.1:8765/"
|
|||||||
- **`execute_javascript` 主工具优先** — 一个高灵活度 JavaScript 工具就能编排场景/运行态和编辑器自动化,避免 AI 客户端被大量细碎工具干扰
|
- **`execute_javascript` 主工具优先** — 一个高灵活度 JavaScript 工具就能编排场景/运行态和编辑器自动化,避免 AI 客户端被大量细碎工具干扰
|
||||||
- **嵌入式 Cocos 扩展** — Cocos 侧不需要单独 Python 守护进程或外部 bridge
|
- **嵌入式 Cocos 扩展** — Cocos 侧不需要单独 Python 守护进程或外部 bridge
|
||||||
- **一键客户端配置** — 在 Cocos Creator 内直接配置 Claude Code、Cursor、VS Code、Trae、Kiro、Codex
|
- **一键客户端配置** — 在 Cocos Creator 内直接配置 Claude Code、Cursor、VS Code、Trae、Kiro、Codex
|
||||||
- **内建项目上下文** — 直接暴露项目、场景、选择、脚本诊断和交互历史资源
|
- **内建项目上下文** — 直接暴露项目、场景、选择、脚本诊断、日志和交互历史资源
|
||||||
- **默认聚焦,必要时全量** — `core` 降低工具列表噪音,需要时切到 `full` 暴露全部工具
|
- **默认聚焦,必要时全量** — `core` 降低工具列表噪音,需要时切到 `full` 暴露全部工具;`custom` 可按分类或工具名调整
|
||||||
- **可视化验证** — 截图和输入模拟让 AI 能验证 UI 与玩法改动
|
- **可视化验证** — 截图和输入模拟让 AI 能验证 UI 与玩法改动
|
||||||
|
|
||||||
## 核心特性
|
## 核心特性
|
||||||
|
|
||||||
- **67 个内置工具** — 覆盖场景层级、资产、UI 创建、组件、文件、脚本诊断、截图、运行态控制和输入模拟
|
- **76 个内置工具** — 覆盖场景层级、编辑器状态、选择工作流、资产、UI 创建、组件、文件、日志、脚本诊断、截图、运行态控制和输入模拟
|
||||||
- **统一主工具** — `execute_javascript` 同时支持 `scene` 和 `editor` 两种上下文
|
- **统一主工具** — `execute_javascript` 同时支持 `scene` 和 `editor` 两种上下文
|
||||||
- **Resources 与 Prompts** — 实时项目资源,以及脚本修复、场景验证、可玩原型等可复用工作流
|
- **Resources 与 Prompts** — 实时项目/日志资源,以及脚本修复、场景验证、可玩原型等可复用工作流
|
||||||
- **Cocos 图形面板** — `Funplay > MCP Server` 提供极简服务管理与 MCP 客户端配置
|
- **Cocos 图形面板** — `Funplay > MCP Server` 提供服务管理、更新检查、工具暴露和 MCP 客户端配置
|
||||||
- **截图与输入支持** — 支持编辑器/场景/Game/Preview 截图,以及 Electron 级鼠标键盘事件
|
- **截图与输入支持** — 支持编辑器/场景/Game/Preview 截图,以及 Electron 级鼠标键盘事件
|
||||||
- **厂商无关** — 兼容任意支持 HTTP JSON-RPC MCP 的 AI 客户端
|
- **厂商无关** — 兼容任意支持 HTTP JSON-RPC MCP 的 AI 客户端
|
||||||
|
|
||||||
@@ -230,20 +235,20 @@ Funplay MCP for Cocos 延续 Funplay MCP for Unity 的设计原则,并针对 C
|
|||||||
| 内置服务 | 内嵌 HTTP MCP Server | 内嵌 HTTP MCP Server |
|
| 内置服务 | 内嵌 HTTP MCP Server | 内嵌 HTTP MCP Server |
|
||||||
| 主执行工具 | `execute_javascript` | `execute_code` |
|
| 主执行工具 | `execute_javascript` | `execute_code` |
|
||||||
| 主语言 | 场景/编辑器上下文中的 JavaScript | Unity 编辑器/运行态中的 C# |
|
| 主语言 | 场景/编辑器上下文中的 JavaScript | Unity 编辑器/运行态中的 C# |
|
||||||
| 默认工具集 | `core`,19 个工具 | 聚焦版 `core` 工具集 |
|
| 默认工具集 | `core`,28 个工具 | 聚焦版 `core` 工具集 |
|
||||||
| 完整工具集 | 67 个工具 | 79 个工具 |
|
| 完整工具集 | 76 个工具,并支持 `custom` 暴露 | 79 个工具 |
|
||||||
| 客户端配置 | 一键配置面板 | 一键配置窗口 |
|
| 客户端配置 | 一键配置面板 | 一键配置窗口 |
|
||||||
|
|
||||||
## MCP 能力结构
|
## MCP 能力结构
|
||||||
|
|
||||||
当前包提供四层能力:
|
当前包提供四层能力:
|
||||||
|
|
||||||
- **Tools** — `core` 下 19 个工具,`full` 下 67 个工具
|
- **Tools** — `core` 下 28 个工具,`full` 下 76 个工具,并支持 `custom` include/exclude 规则
|
||||||
- **Primary execution** — `execute_javascript` 用于场景/运行态和编辑器/browser 自动化
|
- **Primary execution** — `execute_javascript` 用于场景/运行态和编辑器/browser 自动化
|
||||||
- **Prompts** — `fix_script_errors`、`create_playable_prototype`、`scene_validation`、`auto_wire_scene`
|
- **Prompts** — `fix_script_errors`、`create_playable_prototype`、`scene_validation`、`auto_wire_scene`
|
||||||
- **Resources** — 项目上下文、场景摘要、当前选择、脚本诊断、资产选择和 MCP 交互历史
|
- **Resources** — 项目上下文、场景摘要、当前选择、脚本诊断、资产选择、日志和 MCP 交互历史
|
||||||
|
|
||||||
当前默认 `core` 工具集刻意保持精简,只包含:`execute_javascript`、`execute_scene_script`、`execute_editor_script`、`get_project_info`、`get_scene_info`、`get_hierarchy`、`list_scenes`、`open_scene`、`list_assets`、`inspect_asset`、`open_asset`、`select_asset`、`run_script_diagnostics`、`get_script_diagnostic_context`、`get_runtime_state`、`capture_editor_screenshot`、`capture_scene_screenshot`、`capture_preview_screenshot`、`list_editor_windows`。
|
当前默认 `core` 工具集刻意保持精简,只包含:`execute_javascript`、`execute_scene_script`、`execute_editor_script`、`get_editor_state`、`get_tool_catalog`、`check_for_updates`、`get_selection`、`set_selection`、`get_project_info`、`get_scene_info`、`get_hierarchy`、`list_scenes`、`open_scene`、`list_assets`、`inspect_asset`、`open_asset`、`select_asset`、`run_script_diagnostics`、`get_recent_logs`、`search_project_logs`、`clear_logs`、`validate_scene`、`get_script_diagnostic_context`、`get_runtime_state`、`capture_editor_screenshot`、`capture_scene_screenshot`、`capture_preview_screenshot`、`list_editor_windows`。
|
||||||
|
|
||||||
## 内置 Resources
|
## 内置 Resources
|
||||||
|
|
||||||
@@ -256,15 +261,18 @@ Funplay MCP for Cocos 延续 Funplay MCP for Unity 的设计原则,并针对 C
|
|||||||
| `cocos://selection/current` | 当前编辑器选择 |
|
| `cocos://selection/current` | 当前编辑器选择 |
|
||||||
| `cocos://selection/asset` | 当前选中资产 |
|
| `cocos://selection/asset` | 当前选中资产 |
|
||||||
| `cocos://errors/scripts` | 脚本诊断信息 |
|
| `cocos://errors/scripts` | 脚本诊断信息 |
|
||||||
|
| `cocos://logs/editor` | 最近 MCP 运行日志和工具交互 |
|
||||||
|
| `cocos://logs/project` | 常见项目日志文件的尾部内容 |
|
||||||
| `cocos://mcp/interactions` | 最近 MCP 交互历史 |
|
| `cocos://mcp/interactions` | 最近 MCP 交互历史 |
|
||||||
|
|
||||||
## 内置工具
|
## 内置工具
|
||||||
|
|
||||||
Funplay MCP for Cocos 当前在 `full` profile 下提供 **67 个工具函数**:
|
Funplay MCP for Cocos 当前在 `full` profile 下提供 **76 个工具函数**:
|
||||||
|
|
||||||
| 分类 | 工具 |
|
| 分类 | 工具 |
|
||||||
|------|------|
|
|------|------|
|
||||||
| **脚本执行** | `execute_javascript`, `execute_scene_script`, `execute_editor_script` |
|
| **脚本执行** | `execute_javascript`, `execute_scene_script`, `execute_editor_script` |
|
||||||
|
| **编辑器状态** | `get_editor_state`, `get_tool_catalog`, `check_for_updates`, `get_selection`, `set_selection`, `get_editor_selection` |
|
||||||
| **项目与场景** | `get_project_info`, `get_scene_info`, `get_hierarchy`, `find_nodes`, `inspect_node`, `list_scenes`, `open_scene`, `run_scene_asset` |
|
| **项目与场景** | `get_project_info`, `get_scene_info`, `get_hierarchy`, `find_nodes`, `inspect_node`, `list_scenes`, `open_scene`, `run_scene_asset` |
|
||||||
| **节点编辑** | `create_node`, `delete_node`, `set_node_transform` |
|
| **节点编辑** | `create_node`, `delete_node`, `set_node_transform` |
|
||||||
| **资产与 Prefab** | `list_assets`, `inspect_asset`, `open_asset`, `select_asset`, `delete_asset`, `list_prefabs`, `instantiate_prefab`, `get_editor_selection` |
|
| **资产与 Prefab** | `list_assets`, `inspect_asset`, `open_asset`, `select_asset`, `delete_asset`, `list_prefabs`, `instantiate_prefab`, `get_editor_selection` |
|
||||||
@@ -273,7 +281,7 @@ Funplay MCP for Cocos 当前在 `full` profile 下提供 **67 个工具函数**
|
|||||||
| **相机** | `list_cameras`, `create_camera`, `set_camera_properties` |
|
| **相机** | `list_cameras`, `create_camera`, `set_camera_properties` |
|
||||||
| **动画** | `list_animations`, `add_animation_clip`, `play_animation`, `stop_animation` |
|
| **动画** | `list_animations`, `add_animation_clip`, `play_animation`, `stop_animation` |
|
||||||
| **文件** | `read_file`, `get_file_snippet`, `write_file`, `replace_in_file`, `search_files`, `list_directory`, `exists`, `refresh_assets` |
|
| **文件** | `read_file`, `get_file_snippet`, `write_file`, `replace_in_file`, `search_files`, `list_directory`, `exists`, `refresh_assets` |
|
||||||
| **诊断** | `run_script_diagnostics`, `get_script_diagnostic_context` |
|
| **诊断与日志** | `run_script_diagnostics`, `get_script_diagnostic_context`, `get_recent_logs`, `search_project_logs`, `clear_logs`, `validate_scene` |
|
||||||
| **运行态** | `get_runtime_state`, `pause_runtime`, `resume_runtime`, `set_time_scale` |
|
| **运行态** | `get_runtime_state`, `pause_runtime`, `resume_runtime`, `set_time_scale` |
|
||||||
| **交互** | `emit_node_event`, `simulate_button_click`, `invoke_component_method`, `simulate_mouse_click`, `simulate_mouse_drag`, `simulate_key_press`, `simulate_key_combo`, `simulate_preview_input` |
|
| **交互** | `emit_node_event`, `simulate_button_click`, `invoke_component_method`, `simulate_mouse_click`, `simulate_mouse_drag`, `simulate_key_press`, `simulate_key_combo`, `simulate_preview_input` |
|
||||||
| **截图与窗口** | `capture_desktop_screenshot`, `capture_editor_screenshot`, `capture_scene_screenshot`, `capture_game_screenshot`, `capture_preview_screenshot`, `list_editor_windows` |
|
| **截图与窗口** | `capture_desktop_screenshot`, `capture_editor_screenshot`, `capture_scene_screenshot`, `capture_game_screenshot`, `capture_preview_screenshot`, `list_editor_windows` |
|
||||||
@@ -311,6 +319,11 @@ Editor 上下文脚本可以访问 `Editor`、`fs`、`path`、`os`、`require`
|
|||||||
"host": "127.0.0.1",
|
"host": "127.0.0.1",
|
||||||
"port": 8765,
|
"port": 8765,
|
||||||
"toolProfile": "core",
|
"toolProfile": "core",
|
||||||
|
"enabledToolCategories": [],
|
||||||
|
"disabledToolCategories": [],
|
||||||
|
"enabledTools": [],
|
||||||
|
"disabledTools": [],
|
||||||
|
"enableSessions": false,
|
||||||
"autostart": true,
|
"autostart": true,
|
||||||
"maxInteractionLogEntries": 50
|
"maxInteractionLogEntries": 50
|
||||||
}
|
}
|
||||||
@@ -322,6 +335,8 @@ Editor 上下文脚本可以访问 `Editor`、`fs`、`path`、`os`、`require`
|
|||||||
- `COCOS_MCP_PORT`
|
- `COCOS_MCP_PORT`
|
||||||
- `COCOS_MCP_PROFILE`
|
- `COCOS_MCP_PROFILE`
|
||||||
|
|
||||||
|
`toolProfile: "custom"` 会从 `core` 集合开始,再加入 `enabledToolCategories` / `enabledTools`,并移除 `disabledToolCategories` / `disabledTools`。`enableSessions` 默认关闭,因为常规编辑器自动化不需要跨请求客户端状态。
|
||||||
|
|
||||||
## 架构
|
## 架构
|
||||||
|
|
||||||
```text
|
```text
|
||||||
|
|||||||
+121
-31
@@ -11,6 +11,8 @@ const { createToolRegistry } = require('./lib/tool-registry');
|
|||||||
const { ResourceProvider } = require('./lib/resources');
|
const { ResourceProvider } = require('./lib/resources');
|
||||||
const { PromptProvider } = require('./lib/prompts');
|
const { PromptProvider } = require('./lib/prompts');
|
||||||
const { InteractionLog } = require('./lib/interaction-log');
|
const { InteractionLog } = require('./lib/interaction-log');
|
||||||
|
const { RuntimeLog } = require('./lib/runtime-log');
|
||||||
|
const { checkForUpdate } = require('./lib/update-checker');
|
||||||
|
|
||||||
const EXTENSION_NAME = manifest.name || 'funplay-cocos-mcp';
|
const EXTENSION_NAME = manifest.name || 'funplay-cocos-mcp';
|
||||||
const LOG_PREFIX = '[Funplay Cocos MCP]';
|
const LOG_PREFIX = '[Funplay Cocos MCP]';
|
||||||
@@ -25,26 +27,42 @@ class ExtensionService {
|
|||||||
this.resourceProvider = null;
|
this.resourceProvider = null;
|
||||||
this.promptProvider = null;
|
this.promptProvider = null;
|
||||||
this.interactionLog = new InteractionLog();
|
this.interactionLog = new InteractionLog();
|
||||||
|
this.runtimeLog = new RuntimeLog();
|
||||||
|
this.lastUpdateInfo = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
log(level, message, details) {
|
||||||
|
if (this.runtimeLog && typeof this.runtimeLog.add === 'function') {
|
||||||
|
this.runtimeLog.add(level, message, details);
|
||||||
|
}
|
||||||
|
const output = `${LOG_PREFIX} ${message}`;
|
||||||
|
if (level === 'error') {
|
||||||
|
console.error(output);
|
||||||
|
} else if (level === 'warn') {
|
||||||
|
console.warn(output);
|
||||||
|
} else {
|
||||||
|
console.log(output);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
load() {
|
load() {
|
||||||
console.log(`${LOG_PREFIX} Extension loading...`);
|
this.log('info', 'Extension loading...');
|
||||||
this.reloadRuntime();
|
this.reloadRuntime();
|
||||||
if (this.config.autostart) {
|
if (this.config.autostart) {
|
||||||
console.log(`${LOG_PREFIX} Autostart is enabled, starting MCP server.`);
|
this.log('info', 'Autostart is enabled, starting MCP server.');
|
||||||
return this.startServer();
|
return this.startServer();
|
||||||
}
|
}
|
||||||
console.log(`${LOG_PREFIX} Autostart is disabled. MCP server is idle.`);
|
this.log('info', 'Autostart is disabled. MCP server is idle.');
|
||||||
return this.getStatus();
|
return this.getStatus();
|
||||||
}
|
}
|
||||||
|
|
||||||
unload() {
|
unload() {
|
||||||
console.log(`${LOG_PREFIX} Extension unloading...`);
|
this.log('info', 'Extension unloading...');
|
||||||
if (this.server) {
|
if (this.server) {
|
||||||
this.server.stop();
|
this.server.stop();
|
||||||
this.server = null;
|
this.server = null;
|
||||||
}
|
}
|
||||||
console.log(`${LOG_PREFIX} Extension unloaded.`);
|
this.log('info', 'Extension unloaded.');
|
||||||
}
|
}
|
||||||
|
|
||||||
openPanel() {
|
openPanel() {
|
||||||
@@ -56,11 +74,13 @@ class ExtensionService {
|
|||||||
|
|
||||||
reloadRuntime() {
|
reloadRuntime() {
|
||||||
this.config = loadConfig();
|
this.config = loadConfig();
|
||||||
console.log(
|
this.interactionLog = new InteractionLog(this.config.maxInteractionLogEntries);
|
||||||
`${LOG_PREFIX} Runtime config loaded: host=${this.config.host}, port=${this.config.port}, ` +
|
this.runtimeLog = new RuntimeLog(this.config.maxInteractionLogEntries);
|
||||||
|
this.log(
|
||||||
|
'info',
|
||||||
|
`Runtime config loaded: host=${this.config.host}, port=${this.config.port}, ` +
|
||||||
`profile=${this.config.toolProfile}, autostart=${this.config.autostart}`
|
`profile=${this.config.toolProfile}, autostart=${this.config.autostart}`
|
||||||
);
|
);
|
||||||
this.interactionLog = new InteractionLog(this.config.maxInteractionLogEntries);
|
|
||||||
const sceneBridge = {
|
const sceneBridge = {
|
||||||
call: async (method, payload) => {
|
call: async (method, payload) => {
|
||||||
if (!global.Editor || !Editor.Message || typeof Editor.Message.request !== 'function') {
|
if (!global.Editor || !Editor.Message || typeof Editor.Message.request !== 'function') {
|
||||||
@@ -87,25 +107,28 @@ class ExtensionService {
|
|||||||
|
|
||||||
this.toolRegistry = createToolRegistry({
|
this.toolRegistry = createToolRegistry({
|
||||||
getRuntimeContext: runtimeContext,
|
getRuntimeContext: runtimeContext,
|
||||||
|
getStatus: () => this.getStatus(),
|
||||||
interactionLog: this.interactionLog,
|
interactionLog: this.interactionLog,
|
||||||
|
runtimeLog: this.runtimeLog,
|
||||||
sceneBridge,
|
sceneBridge,
|
||||||
editorExecutor: async (payload) => await this.executeEditorScript(payload, runtimeContext),
|
editorExecutor: async (payload) => await this.executeEditorScript(payload, runtimeContext),
|
||||||
});
|
});
|
||||||
this.resourceProvider = new ResourceProvider(runtimeContext, sceneBridge, this.interactionLog);
|
this.resourceProvider = new ResourceProvider(runtimeContext, sceneBridge, this.interactionLog, this.runtimeLog);
|
||||||
this.promptProvider = new PromptProvider(runtimeContext);
|
this.promptProvider = new PromptProvider(runtimeContext);
|
||||||
}
|
}
|
||||||
|
|
||||||
async startServer() {
|
async startServer() {
|
||||||
if (this.server && this.server.isRunning()) {
|
if (this.server && this.server.isRunning()) {
|
||||||
console.log(`${LOG_PREFIX} Start requested but MCP server is already running at ${this.getStatus().url}`);
|
this.log('info', `Start requested but MCP server is already running at ${this.getStatus().url}`);
|
||||||
return this.getStatus();
|
return this.getStatus();
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`${LOG_PREFIX} Starting MCP server...`);
|
this.log('info', 'Starting MCP server...');
|
||||||
this.reloadRuntime();
|
this.reloadRuntime();
|
||||||
this.server = new McpServer({
|
this.server = new McpServer({
|
||||||
config: this.config,
|
config: this.config,
|
||||||
interactionLog: this.interactionLog,
|
interactionLog: this.interactionLog,
|
||||||
|
runtimeLog: this.runtimeLog,
|
||||||
toolRegistry: this.toolRegistry,
|
toolRegistry: this.toolRegistry,
|
||||||
resourceProvider: this.resourceProvider,
|
resourceProvider: this.resourceProvider,
|
||||||
promptProvider: this.promptProvider,
|
promptProvider: this.promptProvider,
|
||||||
@@ -114,30 +137,28 @@ class ExtensionService {
|
|||||||
});
|
});
|
||||||
|
|
||||||
await this.server.start();
|
await this.server.start();
|
||||||
console.log(`${LOG_PREFIX} MCP server started at ${this.getStatus().url}`);
|
this.log('info', `MCP server started at ${this.getStatus().url}`);
|
||||||
console.log(
|
this.log('info', `If this tool saves you time, please consider giving it a Star on GitHub: ${REPOSITORY_URL}`);
|
||||||
`${LOG_PREFIX} If this tool saves you time, please consider giving it a Star on GitHub: ${REPOSITORY_URL}`
|
|
||||||
);
|
|
||||||
return this.getStatus();
|
return this.getStatus();
|
||||||
}
|
}
|
||||||
|
|
||||||
async stopServer() {
|
async stopServer() {
|
||||||
console.log(`${LOG_PREFIX} Stop requested.`);
|
this.log('info', 'Stop requested.');
|
||||||
if (this.server) {
|
if (this.server) {
|
||||||
await this.server.stop();
|
await this.server.stop();
|
||||||
this.server = null;
|
this.server = null;
|
||||||
console.log(`${LOG_PREFIX} MCP server stopped.`);
|
this.log('info', 'MCP server stopped.');
|
||||||
} else {
|
} else {
|
||||||
console.log(`${LOG_PREFIX} Stop requested but MCP server was not running.`);
|
this.log('info', 'Stop requested but MCP server was not running.');
|
||||||
}
|
}
|
||||||
return this.getStatus();
|
return this.getStatus();
|
||||||
}
|
}
|
||||||
|
|
||||||
async restartServer() {
|
async restartServer() {
|
||||||
console.log(`${LOG_PREFIX} Restart requested.`);
|
this.log('info', 'Restart requested.');
|
||||||
await this.stopServer();
|
await this.stopServer();
|
||||||
const status = await this.startServer();
|
const status = await this.startServer();
|
||||||
console.log(`${LOG_PREFIX} Restart completed. MCP server running=${status.running}, url=${status.url}`);
|
this.log('info', `Restart completed. MCP server running=${status.running}, url=${status.url}`);
|
||||||
return status;
|
return status;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -165,7 +186,13 @@ class ExtensionService {
|
|||||||
portFallbackActive: Boolean(fallbackInfo),
|
portFallbackActive: Boolean(fallbackInfo),
|
||||||
portFallbackInfo: fallbackInfo,
|
portFallbackInfo: fallbackInfo,
|
||||||
toolProfile: this.config.toolProfile,
|
toolProfile: this.config.toolProfile,
|
||||||
|
enabledTools: this.config.enabledTools,
|
||||||
|
disabledTools: this.config.disabledTools,
|
||||||
|
enabledToolCategories: this.config.enabledToolCategories,
|
||||||
|
disabledToolCategories: this.config.disabledToolCategories,
|
||||||
|
enableSessions: this.config.enableSessions,
|
||||||
autostart: this.config.autostart,
|
autostart: this.config.autostart,
|
||||||
|
version: manifest.version || '0.0.0',
|
||||||
projectPath: getProjectPath(),
|
projectPath: getProjectPath(),
|
||||||
projectName: getProjectName(),
|
projectName: getProjectName(),
|
||||||
cocosVersion: getCocosVersion(),
|
cocosVersion: getCocosVersion(),
|
||||||
@@ -177,16 +204,22 @@ class ExtensionService {
|
|||||||
this.ensureRuntime();
|
this.ensureRuntime();
|
||||||
const status = this.getStatus();
|
const status = this.getStatus();
|
||||||
const tools = this.toolRegistry.listTools();
|
const tools = this.toolRegistry.listTools();
|
||||||
|
const toolCatalog = typeof this.toolRegistry.listToolCatalog === 'function'
|
||||||
|
? this.toolRegistry.listToolCatalog()
|
||||||
|
: tools;
|
||||||
const resources = this.resourceProvider.listResources();
|
const resources = this.resourceProvider.listResources();
|
||||||
const prompts = this.promptProvider.listPrompts();
|
const prompts = this.promptProvider.listPrompts();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
status,
|
status,
|
||||||
tools,
|
tools,
|
||||||
|
toolCatalog,
|
||||||
resources,
|
resources,
|
||||||
prompts,
|
prompts,
|
||||||
recentInteractions: this.interactionLog.list(20),
|
recentInteractions: this.interactionLog.list(20),
|
||||||
|
recentRuntimeLogs: this.runtimeLog.list(20),
|
||||||
config: this.config,
|
config: this.config,
|
||||||
|
updateInfo: this.lastUpdateInfo,
|
||||||
clientConfig: this.getClientConfig(),
|
clientConfig: this.getClientConfig(),
|
||||||
clientTargets: getTargetStatuses(this.config),
|
clientTargets: getTargetStatuses(this.config),
|
||||||
};
|
};
|
||||||
@@ -199,10 +232,25 @@ class ExtensionService {
|
|||||||
|
|
||||||
async callToolFromPanel(name, args) {
|
async callToolFromPanel(name, args) {
|
||||||
this.ensureRuntime();
|
this.ensureRuntime();
|
||||||
console.log(`${LOG_PREFIX} Panel calling tool: ${name}`);
|
this.log('info', `Panel calling tool: ${name}`);
|
||||||
return await this.toolRegistry.callTool(name, args || {});
|
return await this.toolRegistry.callTool(name, args || {});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async checkUpdates() {
|
||||||
|
this.ensureRuntime();
|
||||||
|
this.log('info', 'Checking GitHub for newer Funplay Cocos MCP releases.');
|
||||||
|
this.lastUpdateInfo = await checkForUpdate({ currentVersion: manifest.version || '0.0.0' });
|
||||||
|
if (this.lastUpdateInfo.ok) {
|
||||||
|
this.log(
|
||||||
|
'info',
|
||||||
|
`Update check completed: current=${this.lastUpdateInfo.currentVersion}, latest=${this.lastUpdateInfo.latestVersion || 'unknown'}`
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
this.log('warn', `Update check failed: ${this.lastUpdateInfo.error}`);
|
||||||
|
}
|
||||||
|
return this.getPanelState();
|
||||||
|
}
|
||||||
|
|
||||||
async executeEditorScript(payload, runtimeContext) {
|
async executeEditorScript(payload, runtimeContext) {
|
||||||
const code = String(payload && payload.code || '');
|
const code = String(payload && payload.code || '');
|
||||||
if (!code.trim()) {
|
if (!code.trim()) {
|
||||||
@@ -251,7 +299,7 @@ class ExtensionService {
|
|||||||
|
|
||||||
async readResourceFromPanel(uri) {
|
async readResourceFromPanel(uri) {
|
||||||
this.ensureRuntime();
|
this.ensureRuntime();
|
||||||
console.log(`${LOG_PREFIX} Panel reading resource: ${uri}`);
|
this.log('info', `Panel reading resource: ${uri}`);
|
||||||
return await this.resourceProvider.readResource(uri);
|
return await this.resourceProvider.readResource(uri);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -272,11 +320,12 @@ class ExtensionService {
|
|||||||
|
|
||||||
configureClient(targetId) {
|
configureClient(targetId) {
|
||||||
this.ensureRuntime();
|
this.ensureRuntime();
|
||||||
console.log(`${LOG_PREFIX} Configuring MCP client target: ${targetId}`);
|
this.log('info', `Configuring MCP client target: ${targetId}`);
|
||||||
const effective = this.getEffectiveServerConnection();
|
const effective = this.getEffectiveServerConnection();
|
||||||
if (effective.port !== this.config.port) {
|
if (effective.port !== this.config.port) {
|
||||||
console.log(
|
this.log(
|
||||||
`${LOG_PREFIX} Using actual running port ${effective.port} for MCP client configuration ` +
|
'info',
|
||||||
|
`Using actual running port ${effective.port} for MCP client configuration ` +
|
||||||
`(requested: ${this.config.port}).`
|
`(requested: ${this.config.port}).`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -288,7 +337,7 @@ class ExtensionService {
|
|||||||
},
|
},
|
||||||
targetId
|
targetId
|
||||||
);
|
);
|
||||||
console.log(`${LOG_PREFIX} MCP client configured: ${result.name} -> ${result.configPath}`);
|
this.log('info', `MCP client configured: ${result.name} -> ${result.configPath}`);
|
||||||
return {
|
return {
|
||||||
...result,
|
...result,
|
||||||
clientTargets: getTargetStatuses(this.config),
|
clientTargets: getTargetStatuses(this.config),
|
||||||
@@ -303,28 +352,66 @@ class ExtensionService {
|
|||||||
const nextMaxEntries = partialConfig && partialConfig.maxInteractionLogEntries !== undefined
|
const nextMaxEntries = partialConfig && partialConfig.maxInteractionLogEntries !== undefined
|
||||||
? Number(partialConfig.maxInteractionLogEntries)
|
? Number(partialConfig.maxInteractionLogEntries)
|
||||||
: this.config.maxInteractionLogEntries;
|
: this.config.maxInteractionLogEntries;
|
||||||
|
const normalizeList = (value, fallback) => {
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
return value.map((item) => String(item || '').trim()).filter(Boolean);
|
||||||
|
}
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
return value.split(/[\n,]/).map((item) => item.trim()).filter(Boolean);
|
||||||
|
}
|
||||||
|
return fallback || [];
|
||||||
|
};
|
||||||
|
const normalizeCategories = (value, fallback) => normalizeList(value, fallback)
|
||||||
|
.map((item) => item.toLowerCase());
|
||||||
|
const nextProfile = partialConfig && partialConfig.toolProfile
|
||||||
|
? (partialConfig.toolProfile === 'full' || partialConfig.toolProfile === 'custom' ? partialConfig.toolProfile : 'core')
|
||||||
|
: this.config.toolProfile;
|
||||||
const nextConfig = {
|
const nextConfig = {
|
||||||
host: partialConfig && partialConfig.host ? String(partialConfig.host) : this.config.host,
|
host: partialConfig && partialConfig.host ? String(partialConfig.host) : this.config.host,
|
||||||
port: Number.isInteger(nextPort) && nextPort > 0 && nextPort <= 65535 ? nextPort : this.config.port,
|
port: Number.isInteger(nextPort) && nextPort > 0 && nextPort <= 65535 ? nextPort : this.config.port,
|
||||||
toolProfile: partialConfig && partialConfig.toolProfile
|
toolProfile: nextProfile,
|
||||||
? (partialConfig.toolProfile === 'full' ? 'full' : 'core')
|
enabledTools: normalizeList(partialConfig && partialConfig.enabledTools, this.config.enabledTools),
|
||||||
: this.config.toolProfile,
|
disabledTools: normalizeList(partialConfig && partialConfig.disabledTools, this.config.disabledTools),
|
||||||
|
enabledToolCategories: normalizeCategories(
|
||||||
|
partialConfig && partialConfig.enabledToolCategories,
|
||||||
|
this.config.enabledToolCategories
|
||||||
|
),
|
||||||
|
disabledToolCategories: normalizeCategories(
|
||||||
|
partialConfig && partialConfig.disabledToolCategories,
|
||||||
|
this.config.disabledToolCategories
|
||||||
|
),
|
||||||
|
enableSessions: partialConfig && typeof partialConfig.enableSessions === 'boolean'
|
||||||
|
? partialConfig.enableSessions
|
||||||
|
: this.config.enableSessions,
|
||||||
autostart: partialConfig && typeof partialConfig.autostart === 'boolean'
|
autostart: partialConfig && typeof partialConfig.autostart === 'boolean'
|
||||||
? partialConfig.autostart
|
? partialConfig.autostart
|
||||||
: this.config.autostart,
|
: this.config.autostart,
|
||||||
maxInteractionLogEntries: Number.isInteger(nextMaxEntries)
|
maxInteractionLogEntries: Number.isInteger(nextMaxEntries)
|
||||||
? Math.max(10, Math.min(500, nextMaxEntries))
|
? Math.max(10, Math.min(500, nextMaxEntries))
|
||||||
: this.config.maxInteractionLogEntries,
|
: this.config.maxInteractionLogEntries,
|
||||||
|
lastClientTargetId: partialConfig && partialConfig.lastClientTargetId
|
||||||
|
? String(partialConfig.lastClientTargetId)
|
||||||
|
: this.config.lastClientTargetId,
|
||||||
};
|
};
|
||||||
|
|
||||||
const configPath = this.config.configPath;
|
const configPath = this.config.configPath;
|
||||||
fs.writeFileSync(configPath, JSON.stringify(nextConfig, null, 2) + '\n', 'utf8');
|
fs.writeFileSync(configPath, JSON.stringify(nextConfig, null, 2) + '\n', 'utf8');
|
||||||
const wasRunning = Boolean(this.server && this.server.isRunning());
|
const wasRunning = Boolean(this.server && this.server.isRunning());
|
||||||
if (wasRunning) {
|
const requiresRestart = wasRunning && (
|
||||||
|
nextConfig.host !== this.config.host ||
|
||||||
|
nextConfig.port !== this.config.port ||
|
||||||
|
nextConfig.toolProfile !== this.config.toolProfile ||
|
||||||
|
nextConfig.enableSessions !== this.config.enableSessions ||
|
||||||
|
JSON.stringify(nextConfig.enabledTools) !== JSON.stringify(this.config.enabledTools) ||
|
||||||
|
JSON.stringify(nextConfig.disabledTools) !== JSON.stringify(this.config.disabledTools) ||
|
||||||
|
JSON.stringify(nextConfig.enabledToolCategories) !== JSON.stringify(this.config.enabledToolCategories) ||
|
||||||
|
JSON.stringify(nextConfig.disabledToolCategories) !== JSON.stringify(this.config.disabledToolCategories)
|
||||||
|
);
|
||||||
|
if (requiresRestart) {
|
||||||
await this.stopServer();
|
await this.stopServer();
|
||||||
}
|
}
|
||||||
this.reloadRuntime();
|
this.reloadRuntime();
|
||||||
if (wasRunning) {
|
if (requiresRestart) {
|
||||||
await this.startServer();
|
await this.startServer();
|
||||||
}
|
}
|
||||||
return this.getPanelState();
|
return this.getPanelState();
|
||||||
@@ -374,6 +461,9 @@ module.exports = {
|
|||||||
callToolFromPanel(name, args) {
|
callToolFromPanel(name, args) {
|
||||||
return service.callToolFromPanel(name, args);
|
return service.callToolFromPanel(name, args);
|
||||||
},
|
},
|
||||||
|
checkUpdates() {
|
||||||
|
return service.checkUpdates();
|
||||||
|
},
|
||||||
readResourceFromPanel(uri) {
|
readResourceFromPanel(uri) {
|
||||||
return service.readResourceFromPanel(uri);
|
return service.readResourceFromPanel(uri);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -157,6 +157,32 @@ function selectAsset(uuid) {
|
|||||||
return { selected: true, uuid };
|
return { selected: true, uuid };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function selectNode(uuid) {
|
||||||
|
if (!global.Editor || !Editor.Selection || typeof Editor.Selection.select !== 'function') {
|
||||||
|
throw new Error('Editor.Selection.select is unavailable in this Cocos environment.');
|
||||||
|
}
|
||||||
|
|
||||||
|
Editor.Selection.clear('node');
|
||||||
|
Editor.Selection.select('node', uuid);
|
||||||
|
return { selected: true, uuid };
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearSelection(type) {
|
||||||
|
if (!global.Editor || !Editor.Selection || typeof Editor.Selection.clear !== 'function') {
|
||||||
|
throw new Error('Editor.Selection.clear is unavailable in this Cocos environment.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalized = String(type || 'all').trim().toLowerCase();
|
||||||
|
if (normalized === 'asset' || normalized === 'node') {
|
||||||
|
Editor.Selection.clear(normalized);
|
||||||
|
return { cleared: true, type: normalized };
|
||||||
|
}
|
||||||
|
|
||||||
|
Editor.Selection.clear('asset');
|
||||||
|
Editor.Selection.clear('node');
|
||||||
|
return { cleared: true, type: 'all' };
|
||||||
|
}
|
||||||
|
|
||||||
function getCurrentSelection() {
|
function getCurrentSelection() {
|
||||||
if (!global.Editor || !Editor.Selection || typeof Editor.Selection.getSelected !== 'function') {
|
if (!global.Editor || !Editor.Selection || typeof Editor.Selection.getSelected !== 'function') {
|
||||||
throw new Error('Editor.Selection API is unavailable in this Cocos environment.');
|
throw new Error('Editor.Selection API is unavailable in this Cocos environment.');
|
||||||
@@ -170,6 +196,7 @@ function getCurrentSelection() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
|
clearSelection,
|
||||||
deleteAsset,
|
deleteAsset,
|
||||||
getCurrentSelection,
|
getCurrentSelection,
|
||||||
listAssets,
|
listAssets,
|
||||||
@@ -179,4 +206,5 @@ module.exports = {
|
|||||||
queryAssetMeta,
|
queryAssetMeta,
|
||||||
queryAssetUrl,
|
queryAssetUrl,
|
||||||
selectAsset,
|
selectAsset,
|
||||||
|
selectNode,
|
||||||
};
|
};
|
||||||
|
|||||||
+39
-1
@@ -7,8 +7,14 @@ const DEFAULTS = {
|
|||||||
host: '127.0.0.1',
|
host: '127.0.0.1',
|
||||||
port: 8765,
|
port: 8765,
|
||||||
toolProfile: 'core',
|
toolProfile: 'core',
|
||||||
|
enabledTools: [],
|
||||||
|
disabledTools: [],
|
||||||
|
enabledToolCategories: [],
|
||||||
|
disabledToolCategories: [],
|
||||||
|
enableSessions: false,
|
||||||
autostart: true,
|
autostart: true,
|
||||||
maxInteractionLogEntries: 50,
|
maxInteractionLogEntries: 50,
|
||||||
|
lastClientTargetId: 'claude_code',
|
||||||
};
|
};
|
||||||
|
|
||||||
function getProjectPath() {
|
function getProjectPath() {
|
||||||
@@ -57,7 +63,31 @@ function clampPort(value) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function normalizeProfile(value) {
|
function normalizeProfile(value) {
|
||||||
return String(value || DEFAULTS.toolProfile).toLowerCase() === 'full' ? 'full' : 'core';
|
const normalized = String(value || DEFAULTS.toolProfile).toLowerCase();
|
||||||
|
if (normalized === 'full' || normalized === 'custom') {
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
return 'core';
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeStringList(value) {
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
return value
|
||||||
|
.map((item) => String(item || '').trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
}
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
return value
|
||||||
|
.split(/[\n,]/)
|
||||||
|
.map((item) => item.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeClientTargetId(value) {
|
||||||
|
const normalized = String(value || '').trim();
|
||||||
|
return normalized || DEFAULTS.lastClientTargetId;
|
||||||
}
|
}
|
||||||
|
|
||||||
function loadConfig() {
|
function loadConfig() {
|
||||||
@@ -71,10 +101,16 @@ function loadConfig() {
|
|||||||
host: process.env.COCOS_MCP_HOST || fileConfig.host || DEFAULTS.host,
|
host: process.env.COCOS_MCP_HOST || fileConfig.host || DEFAULTS.host,
|
||||||
port: clampPort(process.env.COCOS_MCP_PORT || fileConfig.port || DEFAULTS.port),
|
port: clampPort(process.env.COCOS_MCP_PORT || fileConfig.port || DEFAULTS.port),
|
||||||
toolProfile: normalizeProfile(process.env.COCOS_MCP_PROFILE || fileConfig.toolProfile || DEFAULTS.toolProfile),
|
toolProfile: normalizeProfile(process.env.COCOS_MCP_PROFILE || fileConfig.toolProfile || DEFAULTS.toolProfile),
|
||||||
|
enabledTools: normalizeStringList(fileConfig.enabledTools),
|
||||||
|
disabledTools: normalizeStringList(fileConfig.disabledTools),
|
||||||
|
enabledToolCategories: normalizeStringList(fileConfig.enabledToolCategories).map((item) => item.toLowerCase()),
|
||||||
|
disabledToolCategories: normalizeStringList(fileConfig.disabledToolCategories).map((item) => item.toLowerCase()),
|
||||||
|
enableSessions: typeof fileConfig.enableSessions === 'boolean' ? fileConfig.enableSessions : DEFAULTS.enableSessions,
|
||||||
autostart: typeof fileConfig.autostart === 'boolean' ? fileConfig.autostart : DEFAULTS.autostart,
|
autostart: typeof fileConfig.autostart === 'boolean' ? fileConfig.autostart : DEFAULTS.autostart,
|
||||||
maxInteractionLogEntries: Number.isInteger(fileConfig.maxInteractionLogEntries)
|
maxInteractionLogEntries: Number.isInteger(fileConfig.maxInteractionLogEntries)
|
||||||
? Math.max(10, Math.min(500, fileConfig.maxInteractionLogEntries))
|
? Math.max(10, Math.min(500, fileConfig.maxInteractionLogEntries))
|
||||||
: DEFAULTS.maxInteractionLogEntries,
|
: DEFAULTS.maxInteractionLogEntries,
|
||||||
|
lastClientTargetId: normalizeClientTargetId(fileConfig.lastClientTargetId),
|
||||||
configPath,
|
configPath,
|
||||||
configError: fileConfig.__error || '',
|
configError: fileConfig.__error || '',
|
||||||
};
|
};
|
||||||
@@ -86,4 +122,6 @@ module.exports = {
|
|||||||
getProjectName,
|
getProjectName,
|
||||||
getCocosVersion,
|
getCocosVersion,
|
||||||
loadConfig,
|
loadConfig,
|
||||||
|
normalizeProfile,
|
||||||
|
normalizeStringList,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -23,6 +23,12 @@ class InteractionLog {
|
|||||||
return this.entries.slice(0, Math.max(1, limit));
|
return this.entries.slice(0, Math.max(1, limit));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
clear() {
|
||||||
|
const count = this.entries.length;
|
||||||
|
this.entries.length = 0;
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
|
||||||
summary(limit = 20) {
|
summary(limit = 20) {
|
||||||
const items = this.list(limit);
|
const items = this.list(limit);
|
||||||
if (!items.length) {
|
if (!items.length) {
|
||||||
|
|||||||
+206
@@ -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,
|
||||||
|
};
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
function normalizeRoot(projectPath) {
|
||||||
|
return path.resolve(String(projectPath || process.cwd()));
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPathInside(rootPath, targetPath) {
|
||||||
|
const root = normalizeRoot(rootPath);
|
||||||
|
const target = path.resolve(String(targetPath || ''));
|
||||||
|
const relative = path.relative(root, target);
|
||||||
|
return relative === '' || (relative && !relative.startsWith('..') && !path.isAbsolute(relative));
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveProjectPath(projectPath, rawPath) {
|
||||||
|
if (!rawPath || typeof rawPath !== 'string') {
|
||||||
|
throw new Error('path is required.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const root = normalizeRoot(projectPath);
|
||||||
|
const targetPath = path.isAbsolute(rawPath)
|
||||||
|
? path.resolve(rawPath)
|
||||||
|
: path.resolve(root, rawPath);
|
||||||
|
|
||||||
|
if (!isPathInside(root, targetPath)) {
|
||||||
|
throw new Error(`Path is outside the Cocos project: ${rawPath}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return targetPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
isPathInside,
|
||||||
|
resolveProjectPath,
|
||||||
|
};
|
||||||
+42
-2
@@ -4,6 +4,8 @@ const fs = require('fs');
|
|||||||
const path = require('path');
|
const path = require('path');
|
||||||
const { getCurrentSelection, queryAssetInfo, queryAssetMeta } = require('./assets');
|
const { getCurrentSelection, queryAssetInfo, queryAssetMeta } = require('./assets');
|
||||||
const { runScriptDiagnostics } = require('./diagnostics');
|
const { runScriptDiagnostics } = require('./diagnostics');
|
||||||
|
const { getRecentProjectLogs } = require('./logs');
|
||||||
|
const { resolveProjectPath } = require('./path-safety');
|
||||||
|
|
||||||
function createResource(uri, name, description) {
|
function createResource(uri, name, description) {
|
||||||
return { uri, name, description, mimeType: 'text/plain' };
|
return { uri, name, description, mimeType: 'text/plain' };
|
||||||
@@ -48,10 +50,11 @@ function summarizeSelection() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class ResourceProvider {
|
class ResourceProvider {
|
||||||
constructor(getRuntimeContext, sceneBridge, interactionLog) {
|
constructor(getRuntimeContext, sceneBridge, interactionLog, runtimeLog) {
|
||||||
this.getRuntimeContext = getRuntimeContext;
|
this.getRuntimeContext = getRuntimeContext;
|
||||||
this.sceneBridge = sceneBridge;
|
this.sceneBridge = sceneBridge;
|
||||||
this.interactionLog = interactionLog;
|
this.interactionLog = interactionLog;
|
||||||
|
this.runtimeLog = runtimeLog;
|
||||||
}
|
}
|
||||||
|
|
||||||
listResources() {
|
listResources() {
|
||||||
@@ -64,6 +67,8 @@ class ResourceProvider {
|
|||||||
createResource('cocos://selection/current', `${projectName} Current Selection`, 'Summary of the current editor selection.'),
|
createResource('cocos://selection/current', `${projectName} Current Selection`, 'Summary of the current editor selection.'),
|
||||||
createResource('cocos://selection/asset', `${projectName} Selected Asset`, 'Details for the currently selected asset.'),
|
createResource('cocos://selection/asset', `${projectName} Selected Asset`, 'Details for the currently selected asset.'),
|
||||||
createResource('cocos://errors/scripts', `${projectName} Script Diagnostics`, 'Latest TypeScript diagnostic summary for the project.'),
|
createResource('cocos://errors/scripts', `${projectName} Script Diagnostics`, 'Latest TypeScript diagnostic summary for the project.'),
|
||||||
|
createResource('cocos://logs/editor', `${projectName} Editor Logs`, 'Recent MCP runtime logs and tool interaction history.'),
|
||||||
|
createResource('cocos://logs/project', `${projectName} Project Logs`, 'Recent tails from common project log files.'),
|
||||||
createResource('cocos://mcp/interactions', `${projectName} MCP Interactions`, 'Recent MCP tool interaction summaries.'),
|
createResource('cocos://mcp/interactions', `${projectName} MCP Interactions`, 'Recent MCP tool interaction summaries.'),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
@@ -120,6 +125,10 @@ class ResourceProvider {
|
|||||||
return await this.getSelectedAssetText();
|
return await this.getSelectedAssetText();
|
||||||
case 'cocos://errors/scripts':
|
case 'cocos://errors/scripts':
|
||||||
return await this.getScriptDiagnosticsText(projectPath);
|
return await this.getScriptDiagnosticsText(projectPath);
|
||||||
|
case 'cocos://logs/editor':
|
||||||
|
return this.getEditorLogsText();
|
||||||
|
case 'cocos://logs/project':
|
||||||
|
return this.getProjectLogsText(projectPath);
|
||||||
case 'cocos://mcp/interactions':
|
case 'cocos://mcp/interactions':
|
||||||
return this.interactionLog.summary();
|
return this.interactionLog.summary();
|
||||||
default:
|
default:
|
||||||
@@ -199,7 +208,7 @@ class ResourceProvider {
|
|||||||
|
|
||||||
readAssetByPath(relativePath) {
|
readAssetByPath(relativePath) {
|
||||||
const { projectPath } = this.getRuntimeContext();
|
const { projectPath } = this.getRuntimeContext();
|
||||||
const fullPath = path.isAbsolute(relativePath) ? relativePath : path.join(projectPath, relativePath);
|
const fullPath = resolveProjectPath(projectPath, relativePath);
|
||||||
if (!fs.existsSync(fullPath)) {
|
if (!fs.existsSync(fullPath)) {
|
||||||
return `Asset not found: ${relativePath}`;
|
return `Asset not found: ${relativePath}`;
|
||||||
}
|
}
|
||||||
@@ -246,6 +255,37 @@ class ResourceProvider {
|
|||||||
return `Script diagnostics failed: ${error.message}`;
|
return `Script diagnostics failed: ${error.message}`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getEditorLogsText() {
|
||||||
|
return [
|
||||||
|
'MCP Runtime Logs',
|
||||||
|
this.runtimeLog && typeof this.runtimeLog.summary === 'function'
|
||||||
|
? this.runtimeLog.summary(80)
|
||||||
|
: 'Runtime log is unavailable.',
|
||||||
|
'',
|
||||||
|
'MCP Tool Interactions',
|
||||||
|
this.interactionLog && typeof this.interactionLog.summary === 'function'
|
||||||
|
? this.interactionLog.summary(80)
|
||||||
|
: 'Interaction log is unavailable.',
|
||||||
|
].join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
getProjectLogsText(projectPath) {
|
||||||
|
const logs = getRecentProjectLogs(projectPath, { limit: 10, lines: 100 });
|
||||||
|
if (!logs.length) {
|
||||||
|
return 'No project log files found in common project log directories.';
|
||||||
|
}
|
||||||
|
|
||||||
|
return logs
|
||||||
|
.map((log) => [
|
||||||
|
`# ${log.path}`,
|
||||||
|
`mtime: ${log.mtime}`,
|
||||||
|
`size: ${log.size}`,
|
||||||
|
'',
|
||||||
|
log.text,
|
||||||
|
].join('\n'))
|
||||||
|
.join('\n\n---\n\n');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
};
|
||||||
+359
-35
@@ -1,15 +1,40 @@
|
|||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
|
const crypto = require('crypto');
|
||||||
const http = require('http');
|
const http = require('http');
|
||||||
|
const { safeStringify } = require('./utils');
|
||||||
const IMAGE_DATA_URI_PREFIX = 'data:image/png;base64,';
|
const IMAGE_DATA_URI_PREFIX = 'data:image/png;base64,';
|
||||||
const LOG_PREFIX = '[Funplay Cocos MCP Server]';
|
const LOG_PREFIX = '[Funplay Cocos MCP Server]';
|
||||||
const MAX_PORT_FALLBACK_ATTEMPTS = 20;
|
const MAX_PORT_FALLBACK_ATTEMPTS = 20;
|
||||||
|
const MAX_REQUEST_BODY_BYTES = 4 * 1024 * 1024;
|
||||||
|
const MCP_PROTOCOL_VERSION = '2025-11-25';
|
||||||
|
const SUPPORTED_PROTOCOL_VERSIONS = [
|
||||||
|
MCP_PROTOCOL_VERSION,
|
||||||
|
'2025-06-18',
|
||||||
|
'2025-03-26',
|
||||||
|
'2024-11-05',
|
||||||
|
];
|
||||||
|
|
||||||
function json(response, statusCode, payload) {
|
function responseHeaders(protocolVersion = MCP_PROTOCOL_VERSION, extraHeaders = {}) {
|
||||||
response.writeHead(statusCode, { 'Content-Type': 'application/json; charset=utf-8' });
|
return {
|
||||||
|
'MCP-Protocol-Version': protocolVersion,
|
||||||
|
...extraHeaders,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function json(response, statusCode, payload, protocolVersion = MCP_PROTOCOL_VERSION, extraHeaders = {}) {
|
||||||
|
response.writeHead(statusCode, {
|
||||||
|
'Content-Type': 'application/json; charset=utf-8',
|
||||||
|
...responseHeaders(protocolVersion, extraHeaders),
|
||||||
|
});
|
||||||
response.end(JSON.stringify(payload));
|
response.end(JSON.stringify(payload));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function empty(response, statusCode, protocolVersion = MCP_PROTOCOL_VERSION, extraHeaders = {}) {
|
||||||
|
response.writeHead(statusCode, responseHeaders(protocolVersion, extraHeaders));
|
||||||
|
response.end();
|
||||||
|
}
|
||||||
|
|
||||||
function textContent(value) {
|
function textContent(value) {
|
||||||
if (typeof value === 'string' && value.startsWith(IMAGE_DATA_URI_PREFIX)) {
|
if (typeof value === 'string' && value.startsWith(IMAGE_DATA_URI_PREFIX)) {
|
||||||
return [
|
return [
|
||||||
@@ -33,6 +58,22 @@ function textContent(value) {
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isStructuredValue(value) {
|
||||||
|
return value !== null && typeof value === 'object' && !Buffer.isBuffer(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function structuredContent(value) {
|
||||||
|
if (!isStructuredValue(value)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return JSON.parse(safeStringify(value));
|
||||||
|
} catch (error) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
class McpServer {
|
class McpServer {
|
||||||
constructor(options) {
|
constructor(options) {
|
||||||
this.config = options.config;
|
this.config = options.config;
|
||||||
@@ -40,11 +81,15 @@ class McpServer {
|
|||||||
this.resourceProvider = options.resourceProvider;
|
this.resourceProvider = options.resourceProvider;
|
||||||
this.promptProvider = options.promptProvider;
|
this.promptProvider = options.promptProvider;
|
||||||
this.interactionLog = options.interactionLog;
|
this.interactionLog = options.interactionLog;
|
||||||
|
this.runtimeLog = options.runtimeLog;
|
||||||
this.serverName = options.serverName;
|
this.serverName = options.serverName;
|
||||||
this.serverVersion = options.serverVersion;
|
this.serverVersion = options.serverVersion;
|
||||||
this.server = null;
|
this.server = null;
|
||||||
this.actualPort = null;
|
this.actualPort = null;
|
||||||
this.portFallbackInfo = null;
|
this.portFallbackInfo = null;
|
||||||
|
this.negotiatedProtocolVersion = MCP_PROTOCOL_VERSION;
|
||||||
|
this.enableSessions = Boolean(this.config && this.config.enableSessions);
|
||||||
|
this.sessions = new Set();
|
||||||
}
|
}
|
||||||
|
|
||||||
isRunning() {
|
isRunning() {
|
||||||
@@ -69,9 +114,24 @@ class McpServer {
|
|||||||
return this.portFallbackInfo;
|
return this.portFallbackInfo;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
log(level, message) {
|
||||||
|
if (this.runtimeLog && typeof this.runtimeLog.add === 'function') {
|
||||||
|
this.runtimeLog.add(level, message);
|
||||||
|
}
|
||||||
|
|
||||||
|
const output = `${LOG_PREFIX} ${message}`;
|
||||||
|
if (level === 'error') {
|
||||||
|
console.error(output);
|
||||||
|
} else if (level === 'warn') {
|
||||||
|
console.warn(output);
|
||||||
|
} else {
|
||||||
|
console.log(output);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async start() {
|
async start() {
|
||||||
if (this.isRunning()) {
|
if (this.isRunning()) {
|
||||||
console.log(`${LOG_PREFIX} Start skipped: already running.`);
|
this.log('info', 'Start skipped: already running.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -81,35 +141,97 @@ class McpServer {
|
|||||||
const requestHandler = async (request, response) => {
|
const requestHandler = async (request, response) => {
|
||||||
try {
|
try {
|
||||||
if (request.method === 'GET' && request.url === '/health') {
|
if (request.method === 'GET' && request.url === '/health') {
|
||||||
console.log(`${LOG_PREFIX} GET /health`);
|
this.log('info', 'GET /health');
|
||||||
return json(response, 200, { ok: true, name: this.serverName, version: this.serverVersion });
|
return json(response, 200, { ok: true, name: this.serverName, version: this.serverVersion }, this.negotiatedProtocolVersion);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.isAllowedOrigin(request)) {
|
||||||
|
this.log('warn', `Rejected ${request.method} ${request.url}: invalid Origin header.`);
|
||||||
|
return json(response, 403, { error: 'Forbidden: invalid Origin header' }, this.negotiatedProtocolVersion);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.method === 'DELETE') {
|
||||||
|
return this.handleDelete(request, response);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.method === 'GET') {
|
||||||
|
this.log('warn', `Rejected ${request.method} ${request.url}: SSE GET streams are not supported.`);
|
||||||
|
return json(response, 405, { error: 'Method Not Allowed: SSE streams are not supported' }, this.negotiatedProtocolVersion);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (request.method !== 'POST') {
|
if (request.method !== 'POST') {
|
||||||
console.warn(`${LOG_PREFIX} Rejected ${request.method} ${request.url}: method not allowed.`);
|
this.log('warn', `Rejected ${request.method} ${request.url}: method not allowed.`);
|
||||||
return json(response, 405, { error: 'Method Not Allowed' });
|
return json(response, 405, { error: 'Method Not Allowed' }, this.negotiatedProtocolVersion);
|
||||||
|
}
|
||||||
|
|
||||||
|
const acceptHeaderError = this.validateAcceptHeader(request);
|
||||||
|
if (acceptHeaderError) {
|
||||||
|
return json(response, 406, acceptHeaderError, this.negotiatedProtocolVersion);
|
||||||
}
|
}
|
||||||
|
|
||||||
const body = await this.readBody(request);
|
const body = await this.readBody(request);
|
||||||
if (!body) {
|
if (!body) {
|
||||||
return json(response, 400, this.createError(null, -32700, 'Parse error: empty body'));
|
return json(response, 400, this.createError(null, -32700, 'Parse error: empty body'), this.negotiatedProtocolVersion);
|
||||||
}
|
}
|
||||||
|
|
||||||
const rpc = JSON.parse(body);
|
let rpc;
|
||||||
if (rpc && rpc.method) {
|
try {
|
||||||
console.log(`${LOG_PREFIX} RPC ${rpc.method}`);
|
rpc = JSON.parse(body);
|
||||||
|
} catch (error) {
|
||||||
|
return json(response, 400, this.createError(null, -32700, `Parse error: ${error.message}`), this.negotiatedProtocolVersion);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (rpc && rpc.method) {
|
||||||
|
this.log('info', `RPC ${rpc.method}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const protocolHeaderError = this.validateProtocolVersionHeader(request, rpc);
|
||||||
|
if (protocolHeaderError) {
|
||||||
|
return json(response, 400, protocolHeaderError, this.negotiatedProtocolVersion);
|
||||||
|
}
|
||||||
|
const responseProtocolVersion = this.getProtocolVersionForResponse(request, rpc);
|
||||||
|
|
||||||
|
const sessionError = this.validateSession(request, rpc);
|
||||||
|
if (sessionError) {
|
||||||
|
return json(response, sessionError.statusCode, sessionError.error, responseProtocolVersion);
|
||||||
|
}
|
||||||
|
|
||||||
|
const messageType = this.classifyJsonRpcMessage(rpc);
|
||||||
|
if (messageType === 'response') {
|
||||||
|
return empty(response, 202, responseProtocolVersion);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (messageType === 'notification') {
|
||||||
|
const notificationError = this.handleRpcNotification(rpc);
|
||||||
|
if (notificationError) {
|
||||||
|
return json(response, 400, notificationError, responseProtocolVersion);
|
||||||
|
}
|
||||||
|
return empty(response, 202, responseProtocolVersion);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (messageType !== 'request') {
|
||||||
|
return json(response, 400, this.createError(rpc && rpc.id, -32600, 'Invalid Request'), responseProtocolVersion);
|
||||||
|
}
|
||||||
|
|
||||||
const result = await this.handleRpcRequest(rpc);
|
const result = await this.handleRpcRequest(rpc);
|
||||||
if (result == null) {
|
if (result == null) {
|
||||||
response.writeHead(204);
|
return empty(response, 202, responseProtocolVersion);
|
||||||
response.end();
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return json(response, 200, result);
|
const extraHeaders = {};
|
||||||
|
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) {
|
} catch (error) {
|
||||||
console.error(`${LOG_PREFIX} Request handling failed: ${error.message}`);
|
this.log('error', `Request handling failed: ${error.message}`);
|
||||||
return json(response, 500, this.createError(null, -32603, `Internal error: ${error.message}`));
|
const statusCode = error.statusCode || 500;
|
||||||
|
const rpcCode = error.rpcCode || -32603;
|
||||||
|
const message = statusCode === 500 ? `Internal error: ${error.message}` : error.message;
|
||||||
|
return json(response, statusCode, this.createError(null, rpcCode, message), this.negotiatedProtocolVersion);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -118,7 +240,7 @@ class McpServer {
|
|||||||
let lastError = null;
|
let lastError = null;
|
||||||
|
|
||||||
while (attempt <= MAX_PORT_FALLBACK_ATTEMPTS) {
|
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);
|
const candidate = http.createServer(requestHandler);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -128,27 +250,27 @@ class McpServer {
|
|||||||
? candidate.address().port
|
? candidate.address().port
|
||||||
: port;
|
: port;
|
||||||
|
|
||||||
if (this.actualPort !== this.config.port) {
|
if (this.config.port !== 0 && this.actualPort !== this.config.port) {
|
||||||
this.portFallbackInfo = {
|
this.portFallbackInfo = {
|
||||||
requestedPort: this.config.port,
|
requestedPort: this.config.port,
|
||||||
actualPort: this.actualPort,
|
actualPort: this.actualPort,
|
||||||
attempts: attempt,
|
attempts: attempt,
|
||||||
};
|
};
|
||||||
console.warn(
|
this.log(
|
||||||
`${LOG_PREFIX} Port ${this.config.port} was unavailable. ` +
|
'warn',
|
||||||
`Fell back to ${this.actualPort}.`
|
`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;
|
return;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
lastError = error;
|
lastError = error;
|
||||||
if (error && error.code === 'EADDRINUSE' && port < 65535 && attempt < MAX_PORT_FALLBACK_ATTEMPTS) {
|
if (error && error.code === 'EADDRINUSE' && port < 65535 && attempt < MAX_PORT_FALLBACK_ATTEMPTS) {
|
||||||
const nextPort = port + 1;
|
const nextPort = port + 1;
|
||||||
console.warn(
|
this.log(
|
||||||
`${LOG_PREFIX} Port ${port} is already in use. ` +
|
'warn',
|
||||||
`Trying fallback port ${nextPort}...`
|
`Port ${port} is already in use. Trying fallback port ${nextPort}...`
|
||||||
);
|
);
|
||||||
port = nextPort;
|
port = nextPort;
|
||||||
attempt += 1;
|
attempt += 1;
|
||||||
@@ -168,11 +290,11 @@ class McpServer {
|
|||||||
|
|
||||||
async stop() {
|
async stop() {
|
||||||
if (!this.server) {
|
if (!this.server) {
|
||||||
console.log(`${LOG_PREFIX} Stop skipped: server object is empty.`);
|
this.log('info', 'Stop skipped: server object is empty.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`${LOG_PREFIX} Closing HTTP server...`);
|
this.log('info', 'Closing HTTP server...');
|
||||||
const active = this.server;
|
const active = this.server;
|
||||||
this.server = null;
|
this.server = null;
|
||||||
this.actualPort = null;
|
this.actualPort = null;
|
||||||
@@ -180,11 +302,11 @@ class McpServer {
|
|||||||
await new Promise((resolve, reject) => {
|
await new Promise((resolve, reject) => {
|
||||||
active.close((error) => {
|
active.close((error) => {
|
||||||
if (error) {
|
if (error) {
|
||||||
console.error(`${LOG_PREFIX} Close failed: ${error.message}`);
|
this.log('error', `Close failed: ${error.message}`);
|
||||||
reject(error);
|
reject(error);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
console.log(`${LOG_PREFIX} HTTP server closed.`);
|
this.log('info', 'HTTP server closed.');
|
||||||
resolve();
|
resolve();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -209,12 +331,194 @@ class McpServer {
|
|||||||
readBody(request) {
|
readBody(request) {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const chunks = [];
|
const chunks = [];
|
||||||
request.on('data', (chunk) => chunks.push(chunk));
|
let size = 0;
|
||||||
|
request.on('data', (chunk) => {
|
||||||
|
size += chunk.length;
|
||||||
|
if (size > MAX_REQUEST_BODY_BYTES) {
|
||||||
|
const error = new Error(`Request body exceeds ${MAX_REQUEST_BODY_BYTES} bytes.`);
|
||||||
|
error.statusCode = 413;
|
||||||
|
error.rpcCode = -32600;
|
||||||
|
reject(error);
|
||||||
|
request.destroy();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
chunks.push(chunk);
|
||||||
|
});
|
||||||
request.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
|
request.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
|
||||||
request.on('error', reject);
|
request.on('error', reject);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
isAllowedOrigin(request) {
|
||||||
|
const origin = request.headers && request.headers.origin;
|
||||||
|
if (!origin) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = new URL(String(origin));
|
||||||
|
const hostname = parsed.hostname.toLowerCase();
|
||||||
|
const configuredHost = String(this.config.host || '').toLowerCase();
|
||||||
|
return hostname === 'localhost'
|
||||||
|
|| hostname === '127.0.0.1'
|
||||||
|
|| hostname === '::1'
|
||||||
|
|| (configuredHost && hostname === configuredHost);
|
||||||
|
} catch (error) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
validateAcceptHeader(request) {
|
||||||
|
const header = request.headers && request.headers.accept;
|
||||||
|
if (!header) {
|
||||||
|
return this.createError(
|
||||||
|
null,
|
||||||
|
-32600,
|
||||||
|
'Missing Accept header. Streamable HTTP clients must accept application/json and text/event-stream.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const tokens = String(Array.isArray(header) ? header.join(',') : header)
|
||||||
|
.split(',')
|
||||||
|
.map((item) => item.split(';')[0].trim().toLowerCase())
|
||||||
|
.filter(Boolean);
|
||||||
|
const hasWildcard = tokens.includes('*/*');
|
||||||
|
const hasJson = hasWildcard || tokens.includes('application/json') || tokens.includes('application/*');
|
||||||
|
const hasSse = hasWildcard || tokens.includes('text/event-stream') || tokens.includes('text/*');
|
||||||
|
|
||||||
|
if (!hasJson || !hasSse) {
|
||||||
|
return this.createError(
|
||||||
|
null,
|
||||||
|
-32600,
|
||||||
|
'Invalid Accept header. Streamable HTTP clients must accept both application/json and text/event-stream.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
validateProtocolVersionHeader(request, rpc) {
|
||||||
|
const header = request.headers && request.headers['mcp-protocol-version'];
|
||||||
|
if (!header || (rpc && rpc.method === 'initialize')) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const version = Array.isArray(header) ? header[0] : String(header);
|
||||||
|
if (!SUPPORTED_PROTOCOL_VERSIONS.includes(version)) {
|
||||||
|
return this.createError(
|
||||||
|
rpc && rpc.id,
|
||||||
|
-32600,
|
||||||
|
`Unsupported MCP protocol version header: ${version}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
getProtocolVersionForResponse(request, rpc) {
|
||||||
|
if (rpc && rpc.method === 'initialize') {
|
||||||
|
return this.negotiatedProtocolVersion;
|
||||||
|
}
|
||||||
|
|
||||||
|
const header = request.headers && request.headers['mcp-protocol-version'];
|
||||||
|
const version = Array.isArray(header) ? header[0] : header ? String(header) : '';
|
||||||
|
if (SUPPORTED_PROTOCOL_VERSIONS.includes(version)) {
|
||||||
|
return version;
|
||||||
|
}
|
||||||
|
return this.negotiatedProtocolVersion;
|
||||||
|
}
|
||||||
|
|
||||||
|
validateSession(request, rpc) {
|
||||||
|
if (!this.enableSessions || (rpc && rpc.method === 'initialize')) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sessionId = this.getSessionId(request);
|
||||||
|
if (!sessionId) {
|
||||||
|
return {
|
||||||
|
statusCode: 400,
|
||||||
|
error: this.createError(rpc && rpc.id, -32600, 'Missing Mcp-Session-Id header.'),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (!this.sessions.has(sessionId)) {
|
||||||
|
return {
|
||||||
|
statusCode: 404,
|
||||||
|
error: this.createError(rpc && rpc.id, -32001, 'Unknown or expired MCP session.'),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
getSessionId(request) {
|
||||||
|
const value = request.headers && (request.headers['mcp-session-id'] || request.headers['Mcp-Session-Id']);
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
return value[0] || '';
|
||||||
|
}
|
||||||
|
return value ? String(value) : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
createSessionId() {
|
||||||
|
if (typeof crypto.randomUUID === 'function') {
|
||||||
|
return crypto.randomUUID();
|
||||||
|
}
|
||||||
|
return crypto.randomBytes(16).toString('hex');
|
||||||
|
}
|
||||||
|
|
||||||
|
handleDelete(request, response) {
|
||||||
|
if (!this.enableSessions) {
|
||||||
|
return json(response, 405, { error: 'Method Not Allowed: MCP sessions are disabled' }, this.negotiatedProtocolVersion);
|
||||||
|
}
|
||||||
|
|
||||||
|
const sessionId = this.getSessionId(request);
|
||||||
|
if (!sessionId) {
|
||||||
|
return json(
|
||||||
|
response,
|
||||||
|
400,
|
||||||
|
this.createError(null, -32600, 'Missing Mcp-Session-Id header.'),
|
||||||
|
this.negotiatedProtocolVersion
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!this.sessions.has(sessionId)) {
|
||||||
|
return json(
|
||||||
|
response,
|
||||||
|
404,
|
||||||
|
this.createError(null, -32001, 'Unknown or expired MCP session.'),
|
||||||
|
this.negotiatedProtocolVersion
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.sessions.delete(sessionId);
|
||||||
|
return empty(response, 202, this.negotiatedProtocolVersion);
|
||||||
|
}
|
||||||
|
|
||||||
|
classifyJsonRpcMessage(message) {
|
||||||
|
if (!message || message.jsonrpc !== '2.0') {
|
||||||
|
return 'invalid';
|
||||||
|
}
|
||||||
|
if (typeof message.method === 'string') {
|
||||||
|
return Object.prototype.hasOwnProperty.call(message, 'id') ? 'request' : 'notification';
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
Object.prototype.hasOwnProperty.call(message, 'id') &&
|
||||||
|
(Object.prototype.hasOwnProperty.call(message, 'result') || Object.prototype.hasOwnProperty.call(message, 'error'))
|
||||||
|
) {
|
||||||
|
return 'response';
|
||||||
|
}
|
||||||
|
return 'invalid';
|
||||||
|
}
|
||||||
|
|
||||||
|
handleRpcNotification(notification) {
|
||||||
|
if (!notification || notification.jsonrpc !== '2.0' || typeof notification.method !== 'string') {
|
||||||
|
return this.createError(null, -32600, 'Invalid Request');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (notification.method.startsWith('notifications/')) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.createError(null, -32601, `Notification method not found: ${notification.method}`);
|
||||||
|
}
|
||||||
|
|
||||||
async handleRpcRequest(request) {
|
async handleRpcRequest(request) {
|
||||||
if (!request || request.jsonrpc !== '2.0') {
|
if (!request || request.jsonrpc !== '2.0') {
|
||||||
return this.createError(request && request.id, -32600, 'Invalid Request');
|
return this.createError(request && request.id, -32600, 'Invalid Request');
|
||||||
@@ -226,8 +530,9 @@ class McpServer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (method === 'initialize') {
|
if (method === 'initialize') {
|
||||||
|
this.negotiatedProtocolVersion = this.negotiateProtocolVersion(request.params && request.params.protocolVersion);
|
||||||
return this.createResult(request.id, {
|
return this.createResult(request.id, {
|
||||||
protocolVersion: '2024-11-05',
|
protocolVersion: this.negotiatedProtocolVersion,
|
||||||
serverInfo: {
|
serverInfo: {
|
||||||
name: this.serverName,
|
name: this.serverName,
|
||||||
version: this.serverVersion,
|
version: this.serverVersion,
|
||||||
@@ -255,10 +560,20 @@ class McpServer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const output = await this.toolRegistry.callTool(params.name, params.arguments || {});
|
const output = typeof this.toolRegistry.callToolDetailed === 'function'
|
||||||
return this.createResult(request.id, { content: textContent(output) });
|
? await this.toolRegistry.callToolDetailed(params.name, params.arguments || {})
|
||||||
|
: { value: null, text: await this.toolRegistry.callTool(params.name, params.arguments || {}) };
|
||||||
|
const result = { content: textContent(output.text) };
|
||||||
|
const structured = structuredContent(output.value);
|
||||||
|
if (structured) {
|
||||||
|
result.structuredContent = structured;
|
||||||
|
}
|
||||||
|
return this.createResult(request.id, result);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return this.createError(request.id, -32603, error.message);
|
return this.createResult(request.id, {
|
||||||
|
content: textContent(error.message),
|
||||||
|
isError: true,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -293,6 +608,13 @@ class McpServer {
|
|||||||
return this.createError(request.id, -32601, `Method not found: ${method}`);
|
return this.createError(request.id, -32601, `Method not found: ${method}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
negotiateProtocolVersion(clientVersion) {
|
||||||
|
if (SUPPORTED_PROTOCOL_VERSIONS.includes(clientVersion)) {
|
||||||
|
return clientVersion;
|
||||||
|
}
|
||||||
|
return MCP_PROTOCOL_VERSION;
|
||||||
|
}
|
||||||
|
|
||||||
createResult(id, result) {
|
createResult(id, result) {
|
||||||
return {
|
return {
|
||||||
jsonrpc: '2.0',
|
jsonrpc: '2.0',
|
||||||
@@ -315,4 +637,6 @@ class McpServer {
|
|||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
McpServer,
|
McpServer,
|
||||||
|
MCP_PROTOCOL_VERSION,
|
||||||
|
SUPPORTED_PROTOCOL_VERSIONS,
|
||||||
};
|
};
|
||||||
|
|||||||
+352
-11
@@ -3,6 +3,7 @@
|
|||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
const {
|
const {
|
||||||
|
clearSelection,
|
||||||
deleteAsset,
|
deleteAsset,
|
||||||
getCurrentSelection,
|
getCurrentSelection,
|
||||||
listAssets,
|
listAssets,
|
||||||
@@ -11,12 +12,40 @@ const {
|
|||||||
queryAssetInfo,
|
queryAssetInfo,
|
||||||
queryAssetMeta,
|
queryAssetMeta,
|
||||||
selectAsset,
|
selectAsset,
|
||||||
|
selectNode,
|
||||||
} = require('./assets');
|
} = require('./assets');
|
||||||
const { runScriptDiagnostics } = require('./diagnostics');
|
const { runScriptDiagnostics } = require('./diagnostics');
|
||||||
const { listWindows, sendKeyCombo, sendKeyPress, sendMouseClick, sendMouseDrag } = require('./input');
|
const { listWindows, sendKeyCombo, sendKeyPress, sendMouseClick, sendMouseDrag } = require('./input');
|
||||||
|
const {
|
||||||
|
clearProjectLogFiles,
|
||||||
|
getRecentProjectLogs,
|
||||||
|
searchProjectLogs,
|
||||||
|
} = require('./logs');
|
||||||
|
const { resolveProjectPath } = require('./path-safety');
|
||||||
const { captureDesktopScreenshot, captureEditorWindowScreenshot, capturePanelScreenshot } = require('./screenshots');
|
const { captureDesktopScreenshot, captureEditorWindowScreenshot, capturePanelScreenshot } = require('./screenshots');
|
||||||
|
const { checkForUpdate } = require('./update-checker');
|
||||||
const { safeStringify } = require('./utils');
|
const { safeStringify } = require('./utils');
|
||||||
|
|
||||||
|
const 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/],
|
||||||
|
['selection', /selection|select_/],
|
||||||
|
['components', /component/],
|
||||||
|
['ui', /canvas|label|button|sprite/],
|
||||||
|
['camera', /camera/],
|
||||||
|
['animation', /animation|clip/],
|
||||||
|
['runtime', /runtime|time_scale|node_event|invoke_component/],
|
||||||
|
['scene', /scene|hierarchy|node/],
|
||||||
|
['execution', /execute_/],
|
||||||
|
['project', /project|editor_state|tool_catalog/],
|
||||||
|
];
|
||||||
|
|
||||||
function createSchema(properties, required) {
|
function createSchema(properties, required) {
|
||||||
const schema = {
|
const schema = {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
@@ -28,6 +57,74 @@ function createSchema(properties, required) {
|
|||||||
return schema;
|
return schema;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 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 summarizeDiagnostics(result) {
|
||||||
|
if (!result) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
ok: Boolean(result.ok),
|
||||||
|
tool: result.tool,
|
||||||
|
summary: result.summary,
|
||||||
|
diagnosticCount: Array.isArray(result.diagnostics) ? result.diagnostics.length : 0,
|
||||||
|
diagnostics: Array.isArray(result.diagnostics) ? result.diagnostics.slice(0, 20) : [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function toOutput(value) {
|
function toOutput(value) {
|
||||||
if (typeof value === 'string') {
|
if (typeof value === 'string') {
|
||||||
return value;
|
return value;
|
||||||
@@ -35,10 +132,6 @@ function toOutput(value) {
|
|||||||
return safeStringify(value);
|
return safeStringify(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveProjectPath(projectPath, rawPath) {
|
|
||||||
return path.isAbsolute(rawPath) ? rawPath : path.join(projectPath, rawPath);
|
|
||||||
}
|
|
||||||
|
|
||||||
function matchesPattern(fileName, pattern) {
|
function matchesPattern(fileName, pattern) {
|
||||||
const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*');
|
const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*');
|
||||||
return new RegExp(`^${escaped}$`, 'i').test(fileName);
|
return new RegExp(`^${escaped}$`, 'i').test(fileName);
|
||||||
@@ -124,7 +217,7 @@ async function refreshAssets(projectPath, targetPath) {
|
|||||||
return 'File written outside assets directory; no asset-db refresh was needed.';
|
return 'File written outside assets directory; no asset-db refresh was needed.';
|
||||||
}
|
}
|
||||||
|
|
||||||
function createToolRegistry({ getRuntimeContext, interactionLog, sceneBridge, editorExecutor }) {
|
function createToolRegistry({ getRuntimeContext, getStatus, interactionLog, runtimeLog, sceneBridge, editorExecutor }) {
|
||||||
const tools = [
|
const tools = [
|
||||||
{
|
{
|
||||||
name: 'execute_javascript',
|
name: 'execute_javascript',
|
||||||
@@ -183,6 +276,112 @@ function createToolRegistry({ getRuntimeContext, interactionLog, sceneBridge, ed
|
|||||||
return await editorExecutor({ code: args.code, args: args.args || {} });
|
return await editorExecutor({ code: args.code, args: args.args || {} });
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: 'get_editor_state',
|
||||||
|
profile: 'core',
|
||||||
|
description: '[specialist] Return a structured editor-state snapshot including project info, runtime server status, current selection, and visible Electron windows. Prefer this when you want one compact editor summary.',
|
||||||
|
inputSchema: createSchema({}, []),
|
||||||
|
handler: async () => {
|
||||||
|
const runtimeContext = getRuntimeContext();
|
||||||
|
const status = typeof getStatus === 'function' ? getStatus() : null;
|
||||||
|
let scene = null;
|
||||||
|
try {
|
||||||
|
const sceneInfo = await sceneBridge.call('getSceneInfo', { maxDepth: 1, includeComponents: false });
|
||||||
|
scene = sceneInfo
|
||||||
|
? {
|
||||||
|
sceneName: sceneInfo.sceneName,
|
||||||
|
uuid: sceneInfo.uuid,
|
||||||
|
childCount: sceneInfo.childCount,
|
||||||
|
}
|
||||||
|
: null;
|
||||||
|
} catch (error) {
|
||||||
|
scene = { error: error.message };
|
||||||
|
}
|
||||||
|
|
||||||
|
let windows = [];
|
||||||
|
try {
|
||||||
|
windows = listWindows();
|
||||||
|
} catch (error) {
|
||||||
|
windows = [{ error: error.message }];
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
extensionName: runtimeContext.extensionName,
|
||||||
|
version: runtimeContext.version,
|
||||||
|
projectName: runtimeContext.projectName,
|
||||||
|
projectPath: runtimeContext.projectPath,
|
||||||
|
cocosVersion: runtimeContext.cocosVersion,
|
||||||
|
toolProfile: runtimeContext.config ? runtimeContext.config.toolProfile : 'core',
|
||||||
|
status,
|
||||||
|
selection: getCurrentSelection(),
|
||||||
|
scene,
|
||||||
|
windows,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'get_tool_catalog',
|
||||||
|
profile: 'core',
|
||||||
|
description: '[specialist] Return every built-in MCP tool with profile, category, and current exposure state. Use this before changing custom tool exposure.',
|
||||||
|
inputSchema: createSchema({}, []),
|
||||||
|
handler: async () => registry.listToolCatalog(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'check_for_updates',
|
||||||
|
profile: 'core',
|
||||||
|
description: '[specialist] Check the latest Funplay Cocos MCP GitHub release and compare it with the installed extension version.',
|
||||||
|
inputSchema: createSchema(
|
||||||
|
{
|
||||||
|
timeoutMs: { type: 'number', description: 'Optional network timeout in milliseconds.' },
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
),
|
||||||
|
handler: async (args) => {
|
||||||
|
const runtimeContext = getRuntimeContext();
|
||||||
|
return await checkForUpdate({
|
||||||
|
currentVersion: runtimeContext.version,
|
||||||
|
timeoutMs: Number.isFinite(args.timeoutMs) ? args.timeoutMs : 5000,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'get_selection',
|
||||||
|
profile: 'core',
|
||||||
|
description: '[specialist] Return the current editor selection in a compact structured form. Prefer this when selection state matters for the next action.',
|
||||||
|
inputSchema: createSchema({}, []),
|
||||||
|
handler: async () => getCurrentSelection(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'set_selection',
|
||||||
|
profile: 'core',
|
||||||
|
description: '[specialist] Set or clear the current editor selection for an asset or node. Use this when downstream editor workflows depend on selection state.',
|
||||||
|
inputSchema: createSchema(
|
||||||
|
{
|
||||||
|
type: { type: 'string', description: 'Selection target type: asset, node, or clear.' },
|
||||||
|
target: { type: 'string', description: 'Asset uuid/path/db url, or node uuid when type=node.' },
|
||||||
|
clearMode: { type: 'string', description: 'When type=clear, choose asset, node, or all.' },
|
||||||
|
},
|
||||||
|
['type']
|
||||||
|
),
|
||||||
|
handler: async (args) => {
|
||||||
|
const type = String(args.type || '').trim().toLowerCase();
|
||||||
|
if (type === 'clear') {
|
||||||
|
return clearSelection(args.clearMode || 'all');
|
||||||
|
}
|
||||||
|
if (type === 'asset') {
|
||||||
|
const info = await queryAssetInfo(args.target);
|
||||||
|
return selectAsset(info.uuid || args.target);
|
||||||
|
}
|
||||||
|
if (type === 'node') {
|
||||||
|
const target = String(args.target || '').trim();
|
||||||
|
if (!target) {
|
||||||
|
throw new Error('target is required when type=node.');
|
||||||
|
}
|
||||||
|
return selectNode(target);
|
||||||
|
}
|
||||||
|
throw new Error(`Unknown selection type '${args.type}'. Expected asset, node, or clear.`);
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: 'get_scene_info',
|
name: 'get_scene_info',
|
||||||
profile: 'core',
|
profile: 'core',
|
||||||
@@ -444,7 +643,7 @@ function createToolRegistry({ getRuntimeContext, interactionLog, sceneBridge, ed
|
|||||||
{
|
{
|
||||||
name: 'get_editor_selection',
|
name: 'get_editor_selection',
|
||||||
profile: 'full',
|
profile: 'full',
|
||||||
description: '[core] Return the current node and asset selection in the Cocos editor.',
|
description: '[compat] Return the current node and asset selection in the Cocos editor. Prefer get_selection as the primary structured selection read tool.',
|
||||||
inputSchema: createSchema({}, []),
|
inputSchema: createSchema({}, []),
|
||||||
handler: async () => getCurrentSelection(),
|
handler: async () => getCurrentSelection(),
|
||||||
},
|
},
|
||||||
@@ -936,6 +1135,129 @@ function createToolRegistry({ getRuntimeContext, interactionLog, sceneBridge, ed
|
|||||||
return await runScriptDiagnostics(projectPath, args);
|
return await runScriptDiagnostics(projectPath, args);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: 'get_recent_logs',
|
||||||
|
profile: 'core',
|
||||||
|
description: '[specialist] Return recent MCP runtime logs, recent tool interactions, and tails of common project log files.',
|
||||||
|
inputSchema: createSchema(
|
||||||
|
{
|
||||||
|
limit: { type: 'number', description: 'Maximum in-memory runtime/interactions to return.' },
|
||||||
|
includeProjectLogs: { type: 'boolean', description: 'Include tails from common project log files.' },
|
||||||
|
projectLogLines: { type: 'number', description: 'Tail lines to read per project log file.' },
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
),
|
||||||
|
handler: async (args) => {
|
||||||
|
const { projectPath } = getRuntimeContext();
|
||||||
|
const limit = Number.isFinite(args.limit) ? Math.max(1, Math.min(200, args.limit)) : 50;
|
||||||
|
return {
|
||||||
|
runtimeLogs: runtimeLog && typeof runtimeLog.list === 'function' ? runtimeLog.list(limit) : [],
|
||||||
|
interactions: interactionLog && typeof interactionLog.list === 'function' ? interactionLog.list(limit) : [],
|
||||||
|
projectLogs: args.includeProjectLogs === false
|
||||||
|
? []
|
||||||
|
: getRecentProjectLogs(projectPath, {
|
||||||
|
limit: 10,
|
||||||
|
lines: Number.isFinite(args.projectLogLines) ? args.projectLogLines : 80,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'search_project_logs',
|
||||||
|
profile: 'core',
|
||||||
|
description: '[specialist] Search common Cocos project log files for a string or regular expression.',
|
||||||
|
inputSchema: createSchema(
|
||||||
|
{
|
||||||
|
query: { type: 'string', description: 'Text or regex pattern to search for.' },
|
||||||
|
regex: { type: 'boolean', description: 'Treat query as a JavaScript regular expression.' },
|
||||||
|
caseSensitive: { type: 'boolean', description: 'Use case-sensitive matching.' },
|
||||||
|
limit: { type: 'number', description: 'Maximum matches to return.' },
|
||||||
|
directory: { type: 'string', description: 'Optional project-relative log directory to search.' },
|
||||||
|
},
|
||||||
|
['query']
|
||||||
|
),
|
||||||
|
handler: async (args) => {
|
||||||
|
const { projectPath } = getRuntimeContext();
|
||||||
|
return searchProjectLogs(projectPath, args);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'clear_logs',
|
||||||
|
profile: 'core',
|
||||||
|
description: '[specialist] Clear in-memory MCP logs and, only with explicit confirmation, truncate common project log files.',
|
||||||
|
inputSchema: createSchema(
|
||||||
|
{
|
||||||
|
scope: { type: 'string', description: 'mcp, project, or all. Defaults to mcp.' },
|
||||||
|
confirmProjectLogs: { type: 'boolean', description: 'Required when scope includes project log files.' },
|
||||||
|
directory: { type: 'string', description: 'Optional project-relative log directory to clear.' },
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
),
|
||||||
|
handler: async (args) => {
|
||||||
|
const { projectPath } = getRuntimeContext();
|
||||||
|
const scope = String(args.scope || 'mcp').toLowerCase();
|
||||||
|
const clearMcp = scope === 'mcp' || scope === 'all';
|
||||||
|
const clearProject = scope === 'project' || scope === 'all';
|
||||||
|
const result = {
|
||||||
|
runtimeLogEntriesCleared: 0,
|
||||||
|
interactionEntriesCleared: 0,
|
||||||
|
projectLogFilesCleared: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
if (clearMcp) {
|
||||||
|
result.runtimeLogEntriesCleared = runtimeLog && typeof runtimeLog.clear === 'function' ? runtimeLog.clear() : 0;
|
||||||
|
result.interactionEntriesCleared = interactionLog && typeof interactionLog.clear === 'function' ? interactionLog.clear() : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (clearProject) {
|
||||||
|
if (!args.confirmProjectLogs) {
|
||||||
|
throw new Error('confirmProjectLogs=true is required before truncating project log files.');
|
||||||
|
}
|
||||||
|
result.projectLogFilesCleared = clearProjectLogFiles(projectPath, { directory: args.directory, limit: 50 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!clearMcp && !clearProject) {
|
||||||
|
throw new Error("scope must be 'mcp', 'project', or 'all'.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'validate_scene',
|
||||||
|
profile: 'core',
|
||||||
|
description: '[specialist] Run a compact validation pass over the active scene, runtime state, TypeScript diagnostics, and recent project log errors.',
|
||||||
|
inputSchema: createSchema(
|
||||||
|
{
|
||||||
|
maxDepth: { type: 'number', description: 'Scene hierarchy depth for the scene snapshot.' },
|
||||||
|
includeScriptDiagnostics: { type: 'boolean', description: 'Run TypeScript diagnostics as part of validation.' },
|
||||||
|
includeLogErrors: { type: 'boolean', description: 'Search project logs for error lines.' },
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
),
|
||||||
|
handler: async (args) => {
|
||||||
|
const { projectPath } = getRuntimeContext();
|
||||||
|
const scene = await sceneBridge.call('getSceneInfo', {
|
||||||
|
maxDepth: Number.isFinite(args.maxDepth) ? args.maxDepth : 2,
|
||||||
|
includeComponents: true,
|
||||||
|
}).catch((error) => ({ ok: false, error: error.message }));
|
||||||
|
const runtime = await sceneBridge.call('getRuntimeState', {}).catch((error) => ({ ok: false, error: error.message }));
|
||||||
|
const 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 && (!diagnostics || diagnostics.ok) && (!logErrors || logErrors.length === 0),
|
||||||
|
scene,
|
||||||
|
runtime,
|
||||||
|
diagnostics,
|
||||||
|
logErrors,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: 'get_runtime_state',
|
name: 'get_runtime_state',
|
||||||
profile: 'core',
|
profile: 'core',
|
||||||
@@ -1249,24 +1571,34 @@ function createToolRegistry({ getRuntimeContext, interactionLog, sceneBridge, ed
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
return {
|
const registry = {
|
||||||
listTools() {
|
listTools() {
|
||||||
const { config } = getRuntimeContext();
|
const { config } = getRuntimeContext();
|
||||||
return tools
|
return tools
|
||||||
.filter((tool) => config.toolProfile === 'full' || tool.profile === 'core')
|
.filter((tool) => isToolExposed(config || {}, tool))
|
||||||
.map((tool) => ({
|
.map((tool) => ({
|
||||||
name: tool.name,
|
name: tool.name,
|
||||||
description: tool.description,
|
description: tool.description,
|
||||||
inputSchema: tool.inputSchema,
|
inputSchema: tool.inputSchema,
|
||||||
}));
|
}));
|
||||||
},
|
},
|
||||||
async callTool(name, args) {
|
listToolCatalog() {
|
||||||
|
const { config } = getRuntimeContext();
|
||||||
|
return tools.map((tool) => ({
|
||||||
|
name: tool.name,
|
||||||
|
description: tool.description,
|
||||||
|
profile: tool.profile,
|
||||||
|
category: toolCategory(tool),
|
||||||
|
enabled: isToolExposed(config || {}, tool),
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
async callToolDetailed(name, args) {
|
||||||
const { config } = getRuntimeContext();
|
const { config } = getRuntimeContext();
|
||||||
const tool = tools.find((item) => item.name === name);
|
const tool = tools.find((item) => item.name === name);
|
||||||
if (!tool) {
|
if (!tool) {
|
||||||
throw new Error(`Unknown tool '${name}'`);
|
throw new Error(`Unknown tool '${name}'`);
|
||||||
}
|
}
|
||||||
if (config.toolProfile !== 'full' && tool.profile !== 'core') {
|
if (!isToolExposed(config || {}, tool)) {
|
||||||
throw new Error(`Tool '${name}' is not exposed by the current MCP tool profile '${config.toolProfile}'.`);
|
throw new Error(`Tool '${name}' is not exposed by the current MCP tool profile '${config.toolProfile}'.`);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1274,13 +1606,22 @@ function createToolRegistry({ getRuntimeContext, interactionLog, sceneBridge, ed
|
|||||||
const result = await tool.handler(args || {});
|
const result = await tool.handler(args || {});
|
||||||
const output = toOutput(result);
|
const output = toOutput(result);
|
||||||
interactionLog.add(name, 'success', output.slice(0, 500));
|
interactionLog.add(name, 'success', output.slice(0, 500));
|
||||||
return output;
|
return {
|
||||||
|
value: result,
|
||||||
|
text: output,
|
||||||
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
interactionLog.add(name, 'error', error.message);
|
interactionLog.add(name, 'error', error.message);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
async callTool(name, args) {
|
||||||
|
const result = await registry.callToolDetailed(name, args);
|
||||||
|
return result.text;
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
return registry;
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
};
|
||||||
+8
-2
@@ -1,13 +1,14 @@
|
|||||||
{
|
{
|
||||||
"name": "funplay-cocos-mcp",
|
"name": "funplay-cocos-mcp",
|
||||||
"package_version": 2,
|
"package_version": 2,
|
||||||
"version": "0.1.1",
|
"version": "0.2.0",
|
||||||
"description": "Embedded MCP server for Cocos Creator with scene script execution, project resources, prompts, and file/scene tools.",
|
"description": "Embedded MCP server for Cocos Creator with scene script execution, project resources, prompts, and file/scene tools.",
|
||||||
"author": "Funplay",
|
"author": "Funplay",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"main": "browser.js",
|
"main": "browser.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"check": "node --check browser.js && node --check scene.js && node --check panel/index.js && node --check lib/assets.js && node --check lib/client-config.js && node --check lib/config.js && node --check lib/diagnostics.js && node --check lib/electron-tools.js && node --check lib/input.js && node --check lib/interaction-log.js && node --check lib/prompts.js && node --check lib/resources.js && node --check lib/screenshots.js && node --check lib/server.js && node --check lib/tool-registry.js && node --check lib/utils.js"
|
"check": "node --check browser.js && node --check scene.js && node --check panel/index.js && node --check lib/assets.js && node --check lib/client-config.js && node --check lib/config.js && node --check lib/diagnostics.js && node --check lib/electron-tools.js && node --check lib/input.js && node --check lib/interaction-log.js && node --check lib/logs.js && node --check lib/path-safety.js && node --check lib/prompts.js && node --check lib/resources.js && node --check lib/runtime-log.js && node --check lib/screenshots.js && node --check lib/server.js && node --check lib/tool-registry.js && node --check lib/update-checker.js && node --check lib/utils.js",
|
||||||
|
"test": "node --test"
|
||||||
},
|
},
|
||||||
"panels": {
|
"panels": {
|
||||||
"default": {
|
"default": {
|
||||||
@@ -76,6 +77,11 @@
|
|||||||
"callToolFromPanel"
|
"callToolFromPanel"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"check-updates": {
|
||||||
|
"methods": [
|
||||||
|
"checkUpdates"
|
||||||
|
]
|
||||||
|
},
|
||||||
"read-resource": {
|
"read-resource": {
|
||||||
"methods": [
|
"methods": [
|
||||||
"readResourceFromPanel"
|
"readResourceFromPanel"
|
||||||
|
|||||||
+122
-3
@@ -19,7 +19,7 @@ module.exports = Editor.Panel.define({
|
|||||||
<header class="hero">
|
<header class="hero">
|
||||||
<div>
|
<div>
|
||||||
<h1>Funplay Cocos MCP</h1>
|
<h1>Funplay Cocos MCP</h1>
|
||||||
<p>Operate the embedded MCP server from Cocos Creator.</p>
|
<p id="versionText">Version</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="status-pill" id="statusPill">Unknown</div>
|
<div class="status-pill" id="statusPill">Unknown</div>
|
||||||
</header>
|
</header>
|
||||||
@@ -34,6 +34,7 @@ module.exports = Editor.Panel.define({
|
|||||||
</label>
|
</label>
|
||||||
<ui-button id="restartBtn">Restart</ui-button>
|
<ui-button id="restartBtn">Restart</ui-button>
|
||||||
<ui-button id="copyUrlBtn">Copy URL</ui-button>
|
<ui-button id="copyUrlBtn">Copy URL</ui-button>
|
||||||
|
<ui-button id="checkUpdatesBtn">Check Updates</ui-button>
|
||||||
</div>
|
</div>
|
||||||
<div class="grid">
|
<div class="grid">
|
||||||
<label>Server Port <ui-num-input id="portInput"></ui-num-input></label>
|
<label>Server Port <ui-num-input id="portInput"></ui-num-input></label>
|
||||||
@@ -41,12 +42,34 @@ module.exports = Editor.Panel.define({
|
|||||||
<ui-select id="profileSelect">
|
<ui-select id="profileSelect">
|
||||||
<option value="core">core</option>
|
<option value="core">core</option>
|
||||||
<option value="full">full</option>
|
<option value="full">full</option>
|
||||||
|
<option value="custom">custom</option>
|
||||||
</ui-select>
|
</ui-select>
|
||||||
</label>
|
</label>
|
||||||
|
<label class="checkbox-line">
|
||||||
|
<ui-checkbox id="sessionsInput"></ui-checkbox>
|
||||||
|
MCP Sessions
|
||||||
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
<div id="updateStatus" class="client-status muted"></div>
|
||||||
<p>Changes auto-save. Port/profile changes restart the server when needed.</p>
|
<p>Changes auto-save. Port/profile changes restart the server when needed.</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section class="card">
|
||||||
|
<h2>Tool Manager</h2>
|
||||||
|
<div id="toolSummary" class="status-line muted"></div>
|
||||||
|
<div class="row tool-presets">
|
||||||
|
<ui-button id="useCoreBtn">Core</ui-button>
|
||||||
|
<ui-button id="useFullBtn">Full</ui-button>
|
||||||
|
<ui-button id="useCustomBtn">Custom</ui-button>
|
||||||
|
</div>
|
||||||
|
<div class="tool-config-grid">
|
||||||
|
<label>Enabled Categories <ui-textarea id="enabledCategoriesInput"></ui-textarea></label>
|
||||||
|
<label>Disabled Categories <ui-textarea id="disabledCategoriesInput"></ui-textarea></label>
|
||||||
|
<label>Enabled Tools <ui-textarea id="enabledToolsInput"></ui-textarea></label>
|
||||||
|
<label>Disabled Tools <ui-textarea id="disabledToolsInput"></ui-textarea></label>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<section class="card">
|
<section class="card">
|
||||||
<h2>MCP Client Config</h2>
|
<h2>MCP Client Config</h2>
|
||||||
<div class="row">
|
<div class="row">
|
||||||
@@ -129,6 +152,12 @@ module.exports = Editor.Panel.define({
|
|||||||
gap: 8px;
|
gap: 8px;
|
||||||
align-items: end;
|
align-items: end;
|
||||||
}
|
}
|
||||||
|
.tool-config-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(180px, 1fr));
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
label {
|
label {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -138,6 +167,7 @@ module.exports = Editor.Panel.define({
|
|||||||
.checkbox-line {
|
.checkbox-line {
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
min-height: 28px;
|
||||||
}
|
}
|
||||||
.checkbox-inline {
|
.checkbox-inline {
|
||||||
padding-top: 0;
|
padding-top: 0;
|
||||||
@@ -184,6 +214,9 @@ module.exports = Editor.Panel.define({
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
min-height: 100px;
|
min-height: 100px;
|
||||||
}
|
}
|
||||||
|
.tool-config-grid ui-textarea {
|
||||||
|
min-height: 72px;
|
||||||
|
}
|
||||||
pre {
|
pre {
|
||||||
min-height: 120px;
|
min-height: 120px;
|
||||||
max-height: 220px;
|
max-height: 220px;
|
||||||
@@ -203,12 +236,24 @@ module.exports = Editor.Panel.define({
|
|||||||
$: {
|
$: {
|
||||||
root: '.mcp-root',
|
root: '.mcp-root',
|
||||||
statusPill: '#statusPill',
|
statusPill: '#statusPill',
|
||||||
|
versionText: '#versionText',
|
||||||
statusText: '#statusText',
|
statusText: '#statusText',
|
||||||
enabledInput: '#enabledInput',
|
enabledInput: '#enabledInput',
|
||||||
portInput: '#portInput',
|
portInput: '#portInput',
|
||||||
profileSelect: '#profileSelect',
|
profileSelect: '#profileSelect',
|
||||||
|
sessionsInput: '#sessionsInput',
|
||||||
restartBtn: '#restartBtn',
|
restartBtn: '#restartBtn',
|
||||||
copyUrlBtn: '#copyUrlBtn',
|
copyUrlBtn: '#copyUrlBtn',
|
||||||
|
checkUpdatesBtn: '#checkUpdatesBtn',
|
||||||
|
updateStatus: '#updateStatus',
|
||||||
|
toolSummary: '#toolSummary',
|
||||||
|
useCoreBtn: '#useCoreBtn',
|
||||||
|
useFullBtn: '#useFullBtn',
|
||||||
|
useCustomBtn: '#useCustomBtn',
|
||||||
|
enabledCategoriesInput: '#enabledCategoriesInput',
|
||||||
|
disabledCategoriesInput: '#disabledCategoriesInput',
|
||||||
|
enabledToolsInput: '#enabledToolsInput',
|
||||||
|
disabledToolsInput: '#disabledToolsInput',
|
||||||
clientTargetSelect: '#clientTargetSelect',
|
clientTargetSelect: '#clientTargetSelect',
|
||||||
configureClientBtn: '#configureClientBtn',
|
configureClientBtn: '#configureClientBtn',
|
||||||
clientTargetStatus: '#clientTargetStatus',
|
clientTargetStatus: '#clientTargetStatus',
|
||||||
@@ -230,6 +275,7 @@ module.exports = Editor.Panel.define({
|
|||||||
const config = state.config || {};
|
const config = state.config || {};
|
||||||
const isRunning = Boolean(status.running);
|
const isRunning = Boolean(status.running);
|
||||||
|
|
||||||
|
this.$.versionText.textContent = `Version ${status.version || 'unknown'}`;
|
||||||
this.$.statusPill.textContent = isRunning ? 'Running' : 'Stopped';
|
this.$.statusPill.textContent = isRunning ? 'Running' : 'Stopped';
|
||||||
this.$.statusPill.classList.toggle('running', isRunning);
|
this.$.statusPill.classList.toggle('running', isRunning);
|
||||||
this.$.statusPill.classList.toggle('stopped', !isRunning);
|
this.$.statusPill.classList.toggle('stopped', !isRunning);
|
||||||
@@ -242,13 +288,51 @@ module.exports = Editor.Panel.define({
|
|||||||
this.$.enabledInput.value = Boolean(isRunning || config.autostart);
|
this.$.enabledInput.value = Boolean(isRunning || config.autostart);
|
||||||
this.$.portInput.value = Number(config.port || status.port || 8765);
|
this.$.portInput.value = Number(config.port || status.port || 8765);
|
||||||
this.$.profileSelect.value = config.toolProfile || status.toolProfile || 'core';
|
this.$.profileSelect.value = config.toolProfile || status.toolProfile || 'core';
|
||||||
|
this.$.sessionsInput.value = Boolean(config.enableSessions || status.enableSessions);
|
||||||
|
this.$.enabledCategoriesInput.value = this.formatList(config.enabledToolCategories);
|
||||||
|
this.$.disabledCategoriesInput.value = this.formatList(config.disabledToolCategories);
|
||||||
|
this.$.enabledToolsInput.value = this.formatList(config.enabledTools);
|
||||||
|
this.$.disabledToolsInput.value = this.formatList(config.disabledTools);
|
||||||
|
this.renderUpdateStatus();
|
||||||
|
this.renderToolSummary();
|
||||||
|
|
||||||
this.$.clientConfigText.value = state.clientConfig ? state.clientConfig.codex : '';
|
this.$.clientConfigText.value = state.clientConfig ? state.clientConfig.codex : '';
|
||||||
this.renderClientTargets();
|
this.renderClientTargets();
|
||||||
},
|
},
|
||||||
|
formatList(value) {
|
||||||
|
return Array.isArray(value) ? value.join('\n') : '';
|
||||||
|
},
|
||||||
|
parseList(value) {
|
||||||
|
return String(value || '')
|
||||||
|
.split(/[\n,]/)
|
||||||
|
.map((item) => item.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
},
|
||||||
|
renderUpdateStatus() {
|
||||||
|
const update = this.state && this.state.updateInfo;
|
||||||
|
if (!update) {
|
||||||
|
this.$.updateStatus.textContent = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!update.ok) {
|
||||||
|
this.$.updateStatus.textContent = `Update check failed: ${update.error}`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.$.updateStatus.textContent = update.updateAvailable
|
||||||
|
? `Update available: ${update.latestVersion} (${update.releaseUrl})`
|
||||||
|
: `Up to date: ${update.currentVersion}`;
|
||||||
|
},
|
||||||
|
renderToolSummary() {
|
||||||
|
const catalog = (this.state && this.state.toolCatalog) || [];
|
||||||
|
const enabled = catalog.filter((tool) => tool.enabled);
|
||||||
|
const categories = Array.from(new Set(catalog.map((tool) => tool.category))).sort();
|
||||||
|
this.$.toolSummary.textContent =
|
||||||
|
`Enabled ${enabled.length}/${catalog.length} tools | Categories: ${categories.join(', ')}`;
|
||||||
|
},
|
||||||
renderClientTargets() {
|
renderClientTargets() {
|
||||||
const targets = (this.state && this.state.clientTargets) || [];
|
const targets = (this.state && this.state.clientTargets) || [];
|
||||||
const selected = this.$.clientTargetSelect.value || (targets[0] && targets[0].id);
|
const preferred = this.state && this.state.config ? this.state.config.lastClientTargetId : '';
|
||||||
|
const selected = this.$.clientTargetSelect.value || preferred || (targets[0] && targets[0].id);
|
||||||
this.$.clientTargetSelect.innerHTML = targets
|
this.$.clientTargetSelect.innerHTML = targets
|
||||||
.map((target) => `<option value="${target.id}">${target.name}</option>`)
|
.map((target) => `<option value="${target.id}">${target.name}</option>`)
|
||||||
.join('');
|
.join('');
|
||||||
@@ -298,8 +382,14 @@ module.exports = Editor.Panel.define({
|
|||||||
host: (this.state && this.state.config && this.state.config.host) || (this.state && this.state.status && this.state.status.host) || '127.0.0.1',
|
host: (this.state && this.state.config && this.state.config.host) || (this.state && this.state.status && this.state.status.host) || '127.0.0.1',
|
||||||
port: Number(this.$.portInput.value || 8765),
|
port: Number(this.$.portInput.value || 8765),
|
||||||
toolProfile: this.$.profileSelect.value || 'core',
|
toolProfile: this.$.profileSelect.value || 'core',
|
||||||
|
enabledToolCategories: this.parseList(this.$.enabledCategoriesInput.value).map((item) => item.toLowerCase()),
|
||||||
|
disabledToolCategories: this.parseList(this.$.disabledCategoriesInput.value).map((item) => item.toLowerCase()),
|
||||||
|
enabledTools: this.parseList(this.$.enabledToolsInput.value),
|
||||||
|
disabledTools: this.parseList(this.$.disabledToolsInput.value),
|
||||||
|
enableSessions: Boolean(this.$.sessionsInput.value),
|
||||||
autostart: Boolean(this.$.enabledInput.value),
|
autostart: Boolean(this.$.enabledInput.value),
|
||||||
maxInteractionLogEntries: this.state && this.state.config ? this.state.config.maxInteractionLogEntries : 50,
|
maxInteractionLogEntries: this.state && this.state.config ? this.state.config.maxInteractionLogEntries : 50,
|
||||||
|
lastClientTargetId: this.$.clientTargetSelect.value || 'claude_code',
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
async handleEnableToggle() {
|
async handleEnableToggle() {
|
||||||
@@ -329,11 +419,40 @@ module.exports = Editor.Panel.define({
|
|||||||
.then(() => this.showOutput('Copied URL to clipboard.'))
|
.then(() => this.showOutput('Copied URL to clipboard.'))
|
||||||
.catch(() => this.showOutput(text));
|
.catch(() => this.showOutput(text));
|
||||||
});
|
});
|
||||||
|
this.$.checkUpdatesBtn.addEventListener('click', () => this.runAction(() => request('check-updates')));
|
||||||
this.$.enabledInput.addEventListener('change', () => this.handleEnableToggle());
|
this.$.enabledInput.addEventListener('change', () => this.handleEnableToggle());
|
||||||
this.$.portInput.addEventListener('change', () => this.persistConfig({ showOutput: true }));
|
this.$.portInput.addEventListener('change', () => this.persistConfig({ showOutput: true }));
|
||||||
this.$.profileSelect.addEventListener('change', () => this.persistConfig({ showOutput: true }));
|
this.$.profileSelect.addEventListener('change', () => this.persistConfig({ showOutput: true }));
|
||||||
|
this.$.sessionsInput.addEventListener('change', () => this.persistConfig({ showOutput: true }));
|
||||||
|
this.$.enabledCategoriesInput.addEventListener('change', () => this.persistConfig({ showOutput: true }));
|
||||||
|
this.$.disabledCategoriesInput.addEventListener('change', () => this.persistConfig({ showOutput: true }));
|
||||||
|
this.$.enabledToolsInput.addEventListener('change', () => this.persistConfig({ showOutput: true }));
|
||||||
|
this.$.disabledToolsInput.addEventListener('change', () => this.persistConfig({ showOutput: true }));
|
||||||
|
this.$.useCoreBtn.addEventListener('click', () => {
|
||||||
|
this.$.profileSelect.value = 'core';
|
||||||
|
this.$.enabledCategoriesInput.value = '';
|
||||||
|
this.$.disabledCategoriesInput.value = '';
|
||||||
|
this.$.enabledToolsInput.value = '';
|
||||||
|
this.$.disabledToolsInput.value = '';
|
||||||
|
this.persistConfig({ showOutput: true });
|
||||||
|
});
|
||||||
|
this.$.useFullBtn.addEventListener('click', () => {
|
||||||
|
this.$.profileSelect.value = 'full';
|
||||||
|
this.$.enabledCategoriesInput.value = '';
|
||||||
|
this.$.disabledCategoriesInput.value = '';
|
||||||
|
this.$.enabledToolsInput.value = '';
|
||||||
|
this.$.disabledToolsInput.value = '';
|
||||||
|
this.persistConfig({ showOutput: true });
|
||||||
|
});
|
||||||
|
this.$.useCustomBtn.addEventListener('click', () => {
|
||||||
|
this.$.profileSelect.value = 'custom';
|
||||||
|
this.persistConfig({ showOutput: true });
|
||||||
|
});
|
||||||
this.$.clientTargetSelect.addEventListener('confirm', () => this.renderClientTargetStatus());
|
this.$.clientTargetSelect.addEventListener('confirm', () => this.renderClientTargetStatus());
|
||||||
this.$.clientTargetSelect.addEventListener('change', () => this.renderClientTargetStatus());
|
this.$.clientTargetSelect.addEventListener('change', () => {
|
||||||
|
this.renderClientTargetStatus();
|
||||||
|
this.persistConfig();
|
||||||
|
});
|
||||||
this.$.configureClientBtn.addEventListener('click', () => {
|
this.$.configureClientBtn.addEventListener('click', () => {
|
||||||
const targetId = this.$.clientTargetSelect.value;
|
const targetId = this.$.clientTargetSelect.value;
|
||||||
if (!targetId) {
|
if (!targetId) {
|
||||||
|
|||||||
@@ -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,38 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const path = require('node:path');
|
||||||
|
const test = require('node:test');
|
||||||
|
const { isPathInside, resolveProjectPath } = require('../lib/path-safety');
|
||||||
|
|
||||||
|
test('resolveProjectPath resolves project-relative paths inside the project root', () => {
|
||||||
|
const root = path.resolve('/tmp/funplay-cocos-test-project');
|
||||||
|
assert.equal(resolveProjectPath(root, 'assets/player.ts'), path.join(root, 'assets', 'player.ts'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('resolveProjectPath allows absolute paths only when they stay inside the project root', () => {
|
||||||
|
const root = path.resolve('/tmp/funplay-cocos-test-project');
|
||||||
|
const inside = path.join(root, 'assets', 'scene.scene');
|
||||||
|
assert.equal(resolveProjectPath(root, inside), inside);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('resolveProjectPath rejects path traversal outside the project root', () => {
|
||||||
|
const root = path.resolve('/tmp/funplay-cocos-test-project');
|
||||||
|
assert.throws(
|
||||||
|
() => resolveProjectPath(root, '../secret.txt'),
|
||||||
|
/outside the Cocos project/
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('resolveProjectPath rejects absolute paths outside the project root', () => {
|
||||||
|
const root = path.resolve('/tmp/funplay-cocos-test-project');
|
||||||
|
assert.throws(
|
||||||
|
() => resolveProjectPath(root, '/tmp/secret.txt'),
|
||||||
|
/outside the Cocos project/
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('isPathInside treats the project root as inside itself', () => {
|
||||||
|
const root = path.resolve('/tmp/funplay-cocos-test-project');
|
||||||
|
assert.equal(isPathInside(root, root), true);
|
||||||
|
});
|
||||||
@@ -0,0 +1,241 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const http = require('node:http');
|
||||||
|
const test = require('node:test');
|
||||||
|
const {
|
||||||
|
MCP_PROTOCOL_VERSION,
|
||||||
|
McpServer,
|
||||||
|
SUPPORTED_PROTOCOL_VERSIONS,
|
||||||
|
} = require('../lib/server');
|
||||||
|
|
||||||
|
function createServer(toolRegistry = {}, config = {}) {
|
||||||
|
return new McpServer({
|
||||||
|
config: { host: '127.0.0.1', port: 8765, ...config },
|
||||||
|
toolRegistry: {
|
||||||
|
listTools: () => [],
|
||||||
|
callTool: async () => 'ok',
|
||||||
|
...toolRegistry,
|
||||||
|
},
|
||||||
|
resourceProvider: {
|
||||||
|
listResources: () => [],
|
||||||
|
listResourceTemplates: () => [],
|
||||||
|
readResource: async () => ({ contents: [] }),
|
||||||
|
},
|
||||||
|
promptProvider: {
|
||||||
|
listPrompts: () => [],
|
||||||
|
getPrompt: () => ({ messages: [] }),
|
||||||
|
},
|
||||||
|
interactionLog: { add() {} },
|
||||||
|
runtimeLog: { add() {} },
|
||||||
|
serverName: 'test-server',
|
||||||
|
serverVersion: '0.0.0-test',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function httpJson(port, payload, headers = {}) {
|
||||||
|
const body = payload === undefined ? '' : JSON.stringify(payload);
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const request = http.request(
|
||||||
|
{
|
||||||
|
host: '127.0.0.1',
|
||||||
|
port,
|
||||||
|
method: 'POST',
|
||||||
|
path: '/',
|
||||||
|
headers: {
|
||||||
|
Accept: 'application/json, text/event-stream',
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Content-Length': Buffer.byteLength(body),
|
||||||
|
...headers,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
(response) => {
|
||||||
|
const chunks = [];
|
||||||
|
response.on('data', (chunk) => chunks.push(chunk));
|
||||||
|
response.on('end', () => {
|
||||||
|
resolve({
|
||||||
|
statusCode: response.statusCode,
|
||||||
|
headers: response.headers,
|
||||||
|
body: Buffer.concat(chunks).toString('utf8'),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
);
|
||||||
|
request.on('error', reject);
|
||||||
|
request.end(body);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test('initialize negotiates the current MCP protocol version by default', async () => {
|
||||||
|
const server = createServer();
|
||||||
|
const response = await server.handleRpcRequest({
|
||||||
|
jsonrpc: '2.0',
|
||||||
|
id: 1,
|
||||||
|
method: 'initialize',
|
||||||
|
params: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(response.result.protocolVersion, MCP_PROTOCOL_VERSION);
|
||||||
|
assert.equal(response.result.serverInfo.name, 'test-server');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('initialize can negotiate an older supported MCP protocol version', async () => {
|
||||||
|
const server = createServer();
|
||||||
|
const olderVersion = SUPPORTED_PROTOCOL_VERSIONS[SUPPORTED_PROTOCOL_VERSIONS.length - 1];
|
||||||
|
const response = await server.handleRpcRequest({
|
||||||
|
jsonrpc: '2.0',
|
||||||
|
id: 1,
|
||||||
|
method: 'initialize',
|
||||||
|
params: { protocolVersion: olderVersion },
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(response.result.protocolVersion, olderVersion);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('tool execution failures are returned as MCP tool errors', async () => {
|
||||||
|
const server = createServer({
|
||||||
|
callTool: async () => {
|
||||||
|
throw new Error('bad arguments');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await server.handleRpcRequest({
|
||||||
|
jsonrpc: '2.0',
|
||||||
|
id: 2,
|
||||||
|
method: 'tools/call',
|
||||||
|
params: { name: 'example', arguments: {} },
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(response.error, undefined);
|
||||||
|
assert.equal(response.result.isError, true);
|
||||||
|
assert.deepEqual(response.result.content, [{ type: 'text', text: 'bad arguments' }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('tool object results include structuredContent', async () => {
|
||||||
|
const value = { ok: true, count: 2 };
|
||||||
|
const server = createServer({
|
||||||
|
callToolDetailed: async () => ({
|
||||||
|
value,
|
||||||
|
text: JSON.stringify(value, null, 2),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await server.handleRpcRequest({
|
||||||
|
jsonrpc: '2.0',
|
||||||
|
id: 4,
|
||||||
|
method: 'tools/call',
|
||||||
|
params: { name: 'example', arguments: {} },
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.deepEqual(response.result.structuredContent, value);
|
||||||
|
assert.equal(response.result.content[0].type, 'text');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('structuredContent sanitizes circular values', async () => {
|
||||||
|
const value = { ok: true };
|
||||||
|
value.self = value;
|
||||||
|
const server = createServer({
|
||||||
|
callToolDetailed: async () => ({
|
||||||
|
value,
|
||||||
|
text: '{ "ok": true, "self": "[Circular]" }',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await server.handleRpcRequest({
|
||||||
|
jsonrpc: '2.0',
|
||||||
|
id: 5,
|
||||||
|
method: 'tools/call',
|
||||||
|
params: { name: 'example', arguments: {} },
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.deepEqual(response.result.structuredContent, { ok: true, self: '[Circular]' });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('unsupported protocol version headers are rejected when present after initialize', () => {
|
||||||
|
const server = createServer();
|
||||||
|
const response = server.validateProtocolVersionHeader(
|
||||||
|
{ headers: { 'mcp-protocol-version': '1999-01-01' } },
|
||||||
|
{ jsonrpc: '2.0', id: 3, method: 'tools/list' }
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(response.error.code, -32600);
|
||||||
|
assert.match(response.error.message, /Unsupported MCP protocol version/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('streamable HTTP Accept headers must allow json and event-stream', () => {
|
||||||
|
const server = createServer();
|
||||||
|
|
||||||
|
assert.equal(server.validateAcceptHeader({ headers: { accept: 'application/json, text/event-stream' } }), null);
|
||||||
|
assert.equal(server.validateAcceptHeader({ headers: { accept: '*/*' } }), null);
|
||||||
|
|
||||||
|
const response = server.validateAcceptHeader({ headers: { accept: 'application/json' } });
|
||||||
|
assert.equal(response.error.code, -32600);
|
||||||
|
assert.match(response.error.message, /Accept header/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('JSON-RPC responses and notifications are classified for 202 handling', () => {
|
||||||
|
const server = createServer();
|
||||||
|
|
||||||
|
assert.equal(server.classifyJsonRpcMessage({ jsonrpc: '2.0', id: 1, result: {} }), 'response');
|
||||||
|
assert.equal(server.classifyJsonRpcMessage({ jsonrpc: '2.0', method: 'notifications/initialized' }), 'notification');
|
||||||
|
assert.equal(server.classifyJsonRpcMessage({ jsonrpc: '2.0', id: 1, method: 'tools/list' }), 'request');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('session validation requires a known session when enabled', () => {
|
||||||
|
const server = createServer({}, { enableSessions: true });
|
||||||
|
server.sessions.add('abc123');
|
||||||
|
|
||||||
|
assert.equal(
|
||||||
|
server.validateSession(
|
||||||
|
{ headers: { 'mcp-session-id': 'abc123' } },
|
||||||
|
{ jsonrpc: '2.0', id: 1, method: 'tools/list' }
|
||||||
|
),
|
||||||
|
null
|
||||||
|
);
|
||||||
|
|
||||||
|
const missing = server.validateSession(
|
||||||
|
{ headers: {} },
|
||||||
|
{ jsonrpc: '2.0', id: 1, method: 'tools/list' }
|
||||||
|
);
|
||||||
|
assert.equal(missing.statusCode, 400);
|
||||||
|
|
||||||
|
const unknown = server.validateSession(
|
||||||
|
{ headers: { 'mcp-session-id': 'nope' } },
|
||||||
|
{ jsonrpc: '2.0', id: 1, method: 'tools/list' }
|
||||||
|
);
|
||||||
|
assert.equal(unknown.statusCode, 404);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('HTTP notifications return 202 Accepted with no body', async () => {
|
||||||
|
const server = createServer({}, { port: 0 });
|
||||||
|
await server.start();
|
||||||
|
try {
|
||||||
|
const response = await httpJson(server.getPort(), {
|
||||||
|
jsonrpc: '2.0',
|
||||||
|
method: 'notifications/initialized',
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(response.statusCode, 202);
|
||||||
|
assert.equal(response.body, '');
|
||||||
|
} finally {
|
||||||
|
await server.stop();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('HTTP initialize can return an optional session id when sessions are enabled', async () => {
|
||||||
|
const server = createServer({}, { port: 0, enableSessions: true });
|
||||||
|
await server.start();
|
||||||
|
try {
|
||||||
|
const response = await httpJson(server.getPort(), {
|
||||||
|
jsonrpc: '2.0',
|
||||||
|
id: 1,
|
||||||
|
method: 'initialize',
|
||||||
|
params: { protocolVersion: MCP_PROTOCOL_VERSION },
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(response.statusCode, 200);
|
||||||
|
assert.match(response.headers['mcp-session-id'], /^[\x21-\x7e]+$/);
|
||||||
|
} finally {
|
||||||
|
await server.stop();
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const path = require('node:path');
|
||||||
|
const test = require('node:test');
|
||||||
|
const { createToolRegistry } = require('../lib/tool-registry');
|
||||||
|
|
||||||
|
function createRegistry(profile, projectPath = path.resolve('/tmp/funplay-cocos-test-project'), configExtras = {}) {
|
||||||
|
return createToolRegistry({
|
||||||
|
getRuntimeContext: () => ({
|
||||||
|
config: { toolProfile: profile, ...configExtras },
|
||||||
|
projectPath,
|
||||||
|
version: '0.0.0-test',
|
||||||
|
}),
|
||||||
|
interactionLog: { add() {} },
|
||||||
|
runtimeLog: { list: () => [], clear: () => 0 },
|
||||||
|
sceneBridge: { call: async () => ({ ok: true }) },
|
||||||
|
editorExecutor: async () => ({ ok: true }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test('core profile exposes the documented focused tool set', () => {
|
||||||
|
const tools = createRegistry('core').listTools();
|
||||||
|
assert.equal(tools.length, 28);
|
||||||
|
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 === '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, 76);
|
||||||
|
assert.equal(tools.some((tool) => tool.name === 'write_file'), true);
|
||||||
|
assert.equal(tools.some((tool) => tool.name === 'get_editor_state'), true);
|
||||||
|
assert.equal(tools.some((tool) => tool.name === 'set_selection'), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('custom profile can expose a category and disable a specific tool', () => {
|
||||||
|
const tools = createRegistry('custom', path.resolve('/tmp/funplay-cocos-test-project'), {
|
||||||
|
enabledToolCategories: ['files'],
|
||||||
|
disabledTools: ['write_file'],
|
||||||
|
}).listTools();
|
||||||
|
|
||||||
|
assert.equal(tools.some((tool) => tool.name === 'read_file'), true);
|
||||||
|
assert.equal(tools.some((tool) => tool.name === 'write_file'), false);
|
||||||
|
assert.equal(tools.some((tool) => tool.name === 'execute_javascript'), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('tool catalog reports disabled tools under the current exposure settings', () => {
|
||||||
|
const catalog = createRegistry('core', path.resolve('/tmp/funplay-cocos-test-project'), {
|
||||||
|
disabledTools: ['execute_javascript'],
|
||||||
|
}).listToolCatalog();
|
||||||
|
const executeTool = catalog.find((tool) => tool.name === 'execute_javascript');
|
||||||
|
assert.equal(executeTool.enabled, false);
|
||||||
|
assert.equal(executeTool.category, 'execution');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('file tools reject writes outside the project root', async () => {
|
||||||
|
const registry = createRegistry('full');
|
||||||
|
await assert.rejects(
|
||||||
|
() => registry.callTool('write_file', { path: '../outside.txt', content: 'x' }),
|
||||||
|
/outside the Cocos project/
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('callToolDetailed preserves structured values and text output', async () => {
|
||||||
|
const registry = createRegistry('core');
|
||||||
|
const result = await registry.callToolDetailed('get_project_info', {});
|
||||||
|
assert.equal(result.value.projectPath, path.resolve('/tmp/funplay-cocos-test-project'));
|
||||||
|
assert.match(result.text, /projectPath/);
|
||||||
|
});
|
||||||
@@ -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