Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
69ab5710f6 | ||
|
|
a903f7f0af | ||
|
|
8c46313ac6 | ||
|
|
be53f3c56c | ||
|
|
140d6295a8 |
@@ -0,0 +1,14 @@
|
|||||||
|
## What changed
|
||||||
|
|
||||||
|
- Describe the change briefly
|
||||||
|
- Explain the user impact or motivation
|
||||||
|
|
||||||
|
## Checklist
|
||||||
|
|
||||||
|
- [ ] I tested the extension in a clean Cocos Creator 3.8+ project
|
||||||
|
- [ ] I verified `Funplay > MCP Server` opens and the MCP server can start correctly
|
||||||
|
- [ ] If I changed setup, one-click config, or port/config behavior, I verified the affected flow end-to-end
|
||||||
|
- [ ] I ran `npm run check`
|
||||||
|
- [ ] I updated docs for any user-facing behavior changes
|
||||||
|
- [ ] I did not commit local junk such as `.DS_Store`, `temp/`, or `library/`
|
||||||
|
- [ ] I updated `CHANGELOG.md` when the change affects users
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
name: CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
validate:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '20'
|
||||||
|
|
||||||
|
- name: Validate repository metadata
|
||||||
|
run: |
|
||||||
|
python - <<'PY'
|
||||||
|
import json
|
||||||
|
import pathlib
|
||||||
|
import sys
|
||||||
|
|
||||||
|
root = pathlib.Path('.')
|
||||||
|
errors = []
|
||||||
|
|
||||||
|
package_path = root / 'package.json'
|
||||||
|
try:
|
||||||
|
package = json.loads(package_path.read_text(encoding='utf-8'))
|
||||||
|
except Exception as exc:
|
||||||
|
errors.append(f"package.json is not valid JSON: {exc}")
|
||||||
|
else:
|
||||||
|
for key in ('name', 'version', 'main', 'package_version'):
|
||||||
|
if key not in package or package[key] in (None, ''):
|
||||||
|
errors.append(f"package.json is missing required key: {key}")
|
||||||
|
|
||||||
|
required_docs = [
|
||||||
|
'README.md',
|
||||||
|
'README_CN.md',
|
||||||
|
'LICENSE',
|
||||||
|
'CHANGELOG.md',
|
||||||
|
'CONTRIBUTING.md',
|
||||||
|
]
|
||||||
|
for relative in required_docs:
|
||||||
|
if not (root / relative).exists():
|
||||||
|
errors.append(f"Missing required repository file: {relative}")
|
||||||
|
|
||||||
|
forbidden_paths = []
|
||||||
|
for path in root.rglob('*'):
|
||||||
|
if '.git' in path.parts:
|
||||||
|
continue
|
||||||
|
if path.name == '.DS_Store' or '.idea' in path.parts or 'library' in path.parts or 'Library' in path.parts:
|
||||||
|
forbidden_paths.append(str(path))
|
||||||
|
|
||||||
|
if forbidden_paths:
|
||||||
|
errors.append('Repository contains local junk files:\n- ' + '\n- '.join(sorted(forbidden_paths)))
|
||||||
|
|
||||||
|
if errors:
|
||||||
|
print('Validation failed:\n')
|
||||||
|
for error in errors:
|
||||||
|
print(f'- {error}')
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
print('Repository validation passed.')
|
||||||
|
PY
|
||||||
|
|
||||||
|
- name: Run syntax checks
|
||||||
|
run: npm run check
|
||||||
|
|
||||||
|
- name: Run tests
|
||||||
|
run: npm test
|
||||||
@@ -4,6 +4,61 @@ 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.1.3] - 2026-05-11
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added `get_editor_state` as a compact structured editor-state summary tool.
|
||||||
|
- Added `get_selection` and `set_selection` as explicit selection workflow tools for editor-side automation.
|
||||||
|
- Added persistence for the selected one-click MCP client target in the Cocos panel configuration.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Expanded the default `core` profile from 19 tools to 22 tools by promoting editor-state and selection workflows.
|
||||||
|
- Expanded the `full` profile from 67 tools to 70 tools.
|
||||||
|
- Updated panel config persistence so changing the selected MCP client target no longer restarts the server unnecessarily.
|
||||||
|
|
||||||
|
## [0.1.2] - 2026-04-30
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added Node.js unit tests for MCP protocol negotiation, tool profile exports, tool execution errors, and project file path safety.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Updated the MCP initialize response to negotiate protocol version `2025-11-25` by default while retaining compatibility with older supported protocol versions.
|
||||||
|
- Added `structuredContent` to tool call results when a tool returns structured JSON data.
|
||||||
|
- Changed tool execution failures to return MCP tool errors instead of JSON-RPC internal errors, improving client-side self-correction.
|
||||||
|
- Updated CI to run the new Node.js test suite.
|
||||||
|
|
||||||
|
### Security
|
||||||
|
|
||||||
|
- Restricted project file and asset-path resources to paths inside the active Cocos project root.
|
||||||
|
- Added HTTP request body size limits and invalid `Origin` header rejection for the embedded MCP server.
|
||||||
|
|
||||||
|
## [0.1.1] - 2026-04-16
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added automatic port fallback when the configured MCP port is already occupied.
|
||||||
|
- Added actual-running-port reporting in MCP server status and panel state.
|
||||||
|
- Added `.github/pull_request_template.md` for repository contribution guidance.
|
||||||
|
- Added `.github/workflows/ci.yml` for lightweight GitHub validation.
|
||||||
|
- Added a lightweight GitHub Star promotion log after successful MCP server startup.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Updated one-click MCP client configuration to write the actual running server port instead of the requested port when port fallback is active.
|
||||||
|
- Updated the MCP panel status line to show configured-port to actual-port fallback information.
|
||||||
|
- Updated the English and Chinese README files to document automatic port fallback behavior.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed VS Code one-click configuration to use platform-specific config paths with macOS fallback behavior.
|
||||||
|
- Fixed Windows one-click MCP configuration path resolution by using a more reliable home/appdata lookup strategy.
|
||||||
|
|
||||||
## [0.1.0] - 2026-04-15
|
## [0.1.0] - 2026-04-15
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -63,6 +63,8 @@ Funplay > MCP Server
|
|||||||
|
|
||||||
The server runs on `http://127.0.0.1:8765/` by default.
|
The server runs on `http://127.0.0.1:8765/` by default.
|
||||||
|
|
||||||
|
If the configured port is already occupied, the extension automatically falls back to the next available local port and uses the actual running port for one-click MCP client configuration.
|
||||||
|
|
||||||
The panel is intentionally small:
|
The panel is intentionally small:
|
||||||
|
|
||||||
- Enable or disable the MCP server
|
- Enable or disable the MCP server
|
||||||
@@ -194,8 +196,10 @@ Try a higher-level prompt in your AI client:
|
|||||||
|
|
||||||
- This extension is **Editor-only**. It is meant to automate Cocos Creator, not to add runtime dependencies to your final game build.
|
- This extension is **Editor-only**. It is meant to automate Cocos Creator, not to add runtime dependencies to your final game build.
|
||||||
- The MCP server listens on `http://127.0.0.1:8765/` by default.
|
- The MCP server listens on `http://127.0.0.1:8765/` by default.
|
||||||
- The default `core` profile exposes 19 high-signal tools. Switch to `full` in the panel if you want all 67 tools exposed.
|
- If the configured port is busy, the server automatically falls back to the next available port and the panel/client config use the actual running port.
|
||||||
|
- The default `core` profile exposes 22 high-signal tools. Switch to `full` in the panel if you want all 70 tools exposed.
|
||||||
- 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.
|
||||||
|
|
||||||
@@ -210,7 +214,7 @@ Try a higher-level prompt in your AI client:
|
|||||||
|
|
||||||
## Highlights
|
## Highlights
|
||||||
|
|
||||||
- **67 Built-in Tools** — Scene hierarchy, assets, UI creation, components, files, script diagnostics, screenshots, runtime control, and input simulation
|
- **70 Built-in Tools** — Scene hierarchy, editor state, selection workflows, assets, UI creation, components, files, 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 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 and MCP client setup
|
||||||
@@ -227,20 +231,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 22 tools | `core` focused tool profile |
|
||||||
| Full profile | 67 tools | 79 tools |
|
| Full profile | 70 tools | 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** — 22 tools in `core`, 70 tools in `full`
|
||||||
- **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, 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_selection`, `set_selection`, `get_project_info`, `get_scene_info`, `get_hierarchy`, `list_scenes`, `open_scene`, `list_assets`, `inspect_asset`, `open_asset`, `select_asset`, `run_script_diagnostics`, `get_script_diagnostic_context`, `get_runtime_state`, `capture_editor_screenshot`, `capture_scene_screenshot`, `capture_preview_screenshot`, and `list_editor_windows`.
|
||||||
|
|
||||||
## Built-in Resources
|
## Built-in Resources
|
||||||
|
|
||||||
@@ -257,11 +261,12 @@ The default `core` set is intentionally small: `execute_javascript`, `execute_sc
|
|||||||
|
|
||||||
## 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 **70 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_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` |
|
||||||
|
|||||||
+12
-7
@@ -63,6 +63,8 @@ Funplay > MCP Server
|
|||||||
|
|
||||||
服务默认运行在 `http://127.0.0.1:8765/`。
|
服务默认运行在 `http://127.0.0.1:8765/`。
|
||||||
|
|
||||||
|
如果配置端口已被占用,扩展会自动回退到下一个可用本地端口,并在一键客户端配置时使用实际运行端口。
|
||||||
|
|
||||||
面板刻意保持精简:
|
面板刻意保持精简:
|
||||||
|
|
||||||
- 启用或停用 MCP Server
|
- 启用或停用 MCP Server
|
||||||
@@ -194,8 +196,10 @@ 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 暴露 22 个高频工具;如果需要完整工具集,可在面板切到 `full`,暴露全部 70 个工具。
|
||||||
- 所有已暴露的 MCP 工具都会直接执行,Cocos 扩展里没有额外 approval 开关。
|
- 所有已暴露的 MCP 工具都会直接执行,Cocos 扩展里没有额外 approval 开关。
|
||||||
|
- 文件工具和 `cocos://asset/path/...` 资源默认只能访问当前 Cocos 项目根目录内的路径。
|
||||||
- 推荐工作流是优先使用 `execute_javascript`,再配合截图、诊断、资产、检查类工具。
|
- 推荐工作流是优先使用 `execute_javascript`,再配合截图、诊断、资产、检查类工具。
|
||||||
- 如果在面板里修改端口或工具暴露模式,扩展会自动保存配置,并在需要时重启服务。
|
- 如果在面板里修改端口或工具暴露模式,扩展会自动保存配置,并在需要时重启服务。
|
||||||
|
|
||||||
@@ -210,7 +214,7 @@ url = "http://127.0.0.1:8765/"
|
|||||||
|
|
||||||
## 核心特性
|
## 核心特性
|
||||||
|
|
||||||
- **67 个内置工具** — 覆盖场景层级、资产、UI 创建、组件、文件、脚本诊断、截图、运行态控制和输入模拟
|
- **70 个内置工具** — 覆盖场景层级、编辑器状态、选择工作流、资产、UI 创建、组件、文件、脚本诊断、截图、运行态控制和输入模拟
|
||||||
- **统一主工具** — `execute_javascript` 同时支持 `scene` 和 `editor` 两种上下文
|
- **统一主工具** — `execute_javascript` 同时支持 `scene` 和 `editor` 两种上下文
|
||||||
- **Resources 与 Prompts** — 实时项目资源,以及脚本修复、场景验证、可玩原型等可复用工作流
|
- **Resources 与 Prompts** — 实时项目资源,以及脚本修复、场景验证、可玩原型等可复用工作流
|
||||||
- **Cocos 图形面板** — `Funplay > MCP Server` 提供极简服务管理与 MCP 客户端配置
|
- **Cocos 图形面板** — `Funplay > MCP Server` 提供极简服务管理与 MCP 客户端配置
|
||||||
@@ -227,20 +231,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`,22 个工具 | 聚焦版 `core` 工具集 |
|
||||||
| 完整工具集 | 67 个工具 | 79 个工具 |
|
| 完整工具集 | 70 个工具 | 79 个工具 |
|
||||||
| 客户端配置 | 一键配置面板 | 一键配置窗口 |
|
| 客户端配置 | 一键配置面板 | 一键配置窗口 |
|
||||||
|
|
||||||
## MCP 能力结构
|
## MCP 能力结构
|
||||||
|
|
||||||
当前包提供四层能力:
|
当前包提供四层能力:
|
||||||
|
|
||||||
- **Tools** — `core` 下 19 个工具,`full` 下 67 个工具
|
- **Tools** — `core` 下 22 个工具,`full` 下 70 个工具
|
||||||
- **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_selection`、`set_selection`、`get_project_info`、`get_scene_info`、`get_hierarchy`、`list_scenes`、`open_scene`、`list_assets`、`inspect_asset`、`open_asset`、`select_asset`、`run_script_diagnostics`、`get_script_diagnostic_context`、`get_runtime_state`、`capture_editor_screenshot`、`capture_scene_screenshot`、`capture_preview_screenshot`、`list_editor_windows`。
|
||||||
|
|
||||||
## 内置 Resources
|
## 内置 Resources
|
||||||
|
|
||||||
@@ -257,11 +261,12 @@ Funplay MCP for Cocos 延续 Funplay MCP for Unity 的设计原则,并针对 C
|
|||||||
|
|
||||||
## 内置工具
|
## 内置工具
|
||||||
|
|
||||||
Funplay MCP for Cocos 当前在 `full` profile 下提供 **67 个工具函数**:
|
Funplay MCP for Cocos 当前在 `full` profile 下提供 **70 个工具函数**:
|
||||||
|
|
||||||
| 分类 | 工具 |
|
| 分类 | 工具 |
|
||||||
|------|------|
|
|------|------|
|
||||||
| **脚本执行** | `execute_javascript`, `execute_scene_script`, `execute_editor_script` |
|
| **脚本执行** | `execute_javascript`, `execute_scene_script`, `execute_editor_script` |
|
||||||
|
| **编辑器状态** | `get_editor_state`, `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` |
|
||||||
|
|||||||
+51
-6
@@ -14,6 +14,7 @@ const { InteractionLog } = require('./lib/interaction-log');
|
|||||||
|
|
||||||
const EXTENSION_NAME = manifest.name || 'funplay-cocos-mcp';
|
const EXTENSION_NAME = manifest.name || 'funplay-cocos-mcp';
|
||||||
const LOG_PREFIX = '[Funplay Cocos MCP]';
|
const LOG_PREFIX = '[Funplay Cocos MCP]';
|
||||||
|
const REPOSITORY_URL = 'https://github.com/FunplayAI/funplay-cocos-mcp';
|
||||||
const AsyncFunction = Object.getPrototypeOf(async function () {}).constructor;
|
const AsyncFunction = Object.getPrototypeOf(async function () {}).constructor;
|
||||||
|
|
||||||
class ExtensionService {
|
class ExtensionService {
|
||||||
@@ -86,6 +87,7 @@ class ExtensionService {
|
|||||||
|
|
||||||
this.toolRegistry = createToolRegistry({
|
this.toolRegistry = createToolRegistry({
|
||||||
getRuntimeContext: runtimeContext,
|
getRuntimeContext: runtimeContext,
|
||||||
|
getStatus: () => this.getStatus(),
|
||||||
interactionLog: this.interactionLog,
|
interactionLog: this.interactionLog,
|
||||||
sceneBridge,
|
sceneBridge,
|
||||||
editorExecutor: async (payload) => await this.executeEditorScript(payload, runtimeContext),
|
editorExecutor: async (payload) => await this.executeEditorScript(payload, runtimeContext),
|
||||||
@@ -114,6 +116,9 @@ class ExtensionService {
|
|||||||
|
|
||||||
await this.server.start();
|
await this.server.start();
|
||||||
console.log(`${LOG_PREFIX} MCP server started at ${this.getStatus().url}`);
|
console.log(`${LOG_PREFIX} MCP server started at ${this.getStatus().url}`);
|
||||||
|
console.log(
|
||||||
|
`${LOG_PREFIX} If this tool saves you time, please consider giving it a Star on GitHub: ${REPOSITORY_URL}`
|
||||||
|
);
|
||||||
return this.getStatus();
|
return this.getStatus();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -137,17 +142,35 @@ class ExtensionService {
|
|||||||
return status;
|
return status;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getEffectiveServerConnection() {
|
||||||
|
const port = this.server && this.server.isRunning() && typeof this.server.getPort === 'function'
|
||||||
|
? this.server.getPort()
|
||||||
|
: this.config.port;
|
||||||
|
return {
|
||||||
|
host: this.config.host,
|
||||||
|
port,
|
||||||
|
url: `http://${this.config.host}:${port}/`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
getStatus() {
|
getStatus() {
|
||||||
|
const effective = this.getEffectiveServerConnection();
|
||||||
|
const fallbackInfo = this.server && this.server.isRunning() && typeof this.server.getPortFallbackInfo === 'function'
|
||||||
|
? this.server.getPortFallbackInfo()
|
||||||
|
: null;
|
||||||
return {
|
return {
|
||||||
running: Boolean(this.server && this.server.isRunning()),
|
running: Boolean(this.server && this.server.isRunning()),
|
||||||
host: this.config.host,
|
host: this.config.host,
|
||||||
port: this.config.port,
|
port: effective.port,
|
||||||
|
requestedPort: this.config.port,
|
||||||
|
portFallbackActive: Boolean(fallbackInfo),
|
||||||
|
portFallbackInfo: fallbackInfo,
|
||||||
toolProfile: this.config.toolProfile,
|
toolProfile: this.config.toolProfile,
|
||||||
autostart: this.config.autostart,
|
autostart: this.config.autostart,
|
||||||
projectPath: getProjectPath(),
|
projectPath: getProjectPath(),
|
||||||
projectName: getProjectName(),
|
projectName: getProjectName(),
|
||||||
cocosVersion: getCocosVersion(),
|
cocosVersion: getCocosVersion(),
|
||||||
url: `http://${this.config.host}:${this.config.port}/`,
|
url: effective.url,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -234,7 +257,7 @@ class ExtensionService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
getClientConfig() {
|
getClientConfig() {
|
||||||
const url = `http://${this.config.host}:${this.config.port}/`;
|
const { url } = this.getEffectiveServerConnection();
|
||||||
return {
|
return {
|
||||||
url,
|
url,
|
||||||
codex: `[mcp_servers.funplay_cocos]\nurl = "${url}"\n`,
|
codex: `[mcp_servers.funplay_cocos]\nurl = "${url}"\n`,
|
||||||
@@ -251,7 +274,21 @@ class ExtensionService {
|
|||||||
configureClient(targetId) {
|
configureClient(targetId) {
|
||||||
this.ensureRuntime();
|
this.ensureRuntime();
|
||||||
console.log(`${LOG_PREFIX} Configuring MCP client target: ${targetId}`);
|
console.log(`${LOG_PREFIX} Configuring MCP client target: ${targetId}`);
|
||||||
const result = configureTarget(this.config, targetId);
|
const effective = this.getEffectiveServerConnection();
|
||||||
|
if (effective.port !== this.config.port) {
|
||||||
|
console.log(
|
||||||
|
`${LOG_PREFIX} Using actual running port ${effective.port} for MCP client configuration ` +
|
||||||
|
`(requested: ${this.config.port}).`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const result = configureTarget(
|
||||||
|
{
|
||||||
|
...this.config,
|
||||||
|
host: effective.host,
|
||||||
|
port: effective.port,
|
||||||
|
},
|
||||||
|
targetId
|
||||||
|
);
|
||||||
console.log(`${LOG_PREFIX} MCP client configured: ${result.name} -> ${result.configPath}`);
|
console.log(`${LOG_PREFIX} MCP client configured: ${result.name} -> ${result.configPath}`);
|
||||||
return {
|
return {
|
||||||
...result,
|
...result,
|
||||||
@@ -279,16 +316,24 @@ class ExtensionService {
|
|||||||
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
|
||||||
|
);
|
||||||
|
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();
|
||||||
|
|||||||
@@ -157,6 +157,32 @@ function selectAsset(uuid) {
|
|||||||
return { selected: true, uuid };
|
return { selected: true, uuid };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function selectNode(uuid) {
|
||||||
|
if (!global.Editor || !Editor.Selection || typeof Editor.Selection.select !== 'function') {
|
||||||
|
throw new Error('Editor.Selection.select is unavailable in this Cocos environment.');
|
||||||
|
}
|
||||||
|
|
||||||
|
Editor.Selection.clear('node');
|
||||||
|
Editor.Selection.select('node', uuid);
|
||||||
|
return { selected: true, uuid };
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearSelection(type) {
|
||||||
|
if (!global.Editor || !Editor.Selection || typeof Editor.Selection.clear !== 'function') {
|
||||||
|
throw new Error('Editor.Selection.clear is unavailable in this Cocos environment.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalized = String(type || 'all').trim().toLowerCase();
|
||||||
|
if (normalized === 'asset' || normalized === 'node') {
|
||||||
|
Editor.Selection.clear(normalized);
|
||||||
|
return { cleared: true, type: normalized };
|
||||||
|
}
|
||||||
|
|
||||||
|
Editor.Selection.clear('asset');
|
||||||
|
Editor.Selection.clear('node');
|
||||||
|
return { cleared: true, type: 'all' };
|
||||||
|
}
|
||||||
|
|
||||||
function getCurrentSelection() {
|
function getCurrentSelection() {
|
||||||
if (!global.Editor || !Editor.Selection || typeof Editor.Selection.getSelected !== 'function') {
|
if (!global.Editor || !Editor.Selection || typeof Editor.Selection.getSelected !== 'function') {
|
||||||
throw new Error('Editor.Selection API is unavailable in this Cocos environment.');
|
throw new Error('Editor.Selection API is unavailable in this Cocos environment.');
|
||||||
@@ -170,6 +196,7 @@ function getCurrentSelection() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
|
clearSelection,
|
||||||
deleteAsset,
|
deleteAsset,
|
||||||
getCurrentSelection,
|
getCurrentSelection,
|
||||||
listAssets,
|
listAssets,
|
||||||
@@ -179,4 +206,5 @@ module.exports = {
|
|||||||
queryAssetMeta,
|
queryAssetMeta,
|
||||||
queryAssetUrl,
|
queryAssetUrl,
|
||||||
selectAsset,
|
selectAsset,
|
||||||
|
selectNode,
|
||||||
};
|
};
|
||||||
|
|||||||
+41
-2
@@ -6,6 +6,45 @@ const path = require('path');
|
|||||||
|
|
||||||
const SERVER_NAME = 'funplay_cocos';
|
const SERVER_NAME = 'funplay_cocos';
|
||||||
|
|
||||||
|
function getUserHomePath() {
|
||||||
|
const home = os.homedir();
|
||||||
|
if (home) {
|
||||||
|
return home;
|
||||||
|
}
|
||||||
|
|
||||||
|
const homeDrive = process.env.HOMEDRIVE;
|
||||||
|
const homePath = process.env.HOMEPATH;
|
||||||
|
if (homeDrive && homePath) {
|
||||||
|
return `${homeDrive}${homePath}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return process.env.HOME || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function getVSCodeConfigPath(homePath) {
|
||||||
|
switch (process.platform) {
|
||||||
|
case 'win32': {
|
||||||
|
const appData = process.env.APPDATA || path.join(homePath, 'AppData', 'Roaming');
|
||||||
|
return path.join(appData, 'Code', 'User', 'mcp.json');
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'darwin': {
|
||||||
|
const primaryPath = path.join(homePath, 'Library', 'Application Support', 'Code', 'User', 'mcp.json');
|
||||||
|
const primaryDirectory = path.dirname(primaryPath);
|
||||||
|
if (fs.existsSync(primaryPath) || fs.existsSync(primaryDirectory)) {
|
||||||
|
return primaryPath;
|
||||||
|
}
|
||||||
|
return path.join(homePath, '.vscode', 'mcp.json');
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'linux':
|
||||||
|
return path.join(homePath, '.config', 'Code', 'User', 'mcp.json');
|
||||||
|
|
||||||
|
default:
|
||||||
|
return path.join(homePath, '.vscode', 'mcp.json');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function ensureParent(filePath) {
|
function ensureParent(filePath) {
|
||||||
const dir = path.dirname(filePath);
|
const dir = path.dirname(filePath);
|
||||||
if (dir && !fs.existsSync(dir)) {
|
if (dir && !fs.existsSync(dir)) {
|
||||||
@@ -67,7 +106,7 @@ function configureTomlTarget(target) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function buildTargets(config) {
|
function buildTargets(config) {
|
||||||
const home = os.homedir();
|
const home = getUserHomePath();
|
||||||
const url = `http://${config.host}:${config.port}/`;
|
const url = `http://${config.host}:${config.port}/`;
|
||||||
|
|
||||||
return [
|
return [
|
||||||
@@ -88,7 +127,7 @@ function buildTargets(config) {
|
|||||||
{
|
{
|
||||||
id: 'vscode',
|
id: 'vscode',
|
||||||
name: 'VS Code',
|
name: 'VS Code',
|
||||||
configPath: path.join(home, '.vscode', 'mcp.json'),
|
configPath: getVSCodeConfigPath(home),
|
||||||
rootKey: 'servers',
|
rootKey: 'servers',
|
||||||
entry: { type: 'http', url },
|
entry: { type: 'http', url },
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ const DEFAULTS = {
|
|||||||
toolProfile: 'core',
|
toolProfile: 'core',
|
||||||
autostart: true,
|
autostart: true,
|
||||||
maxInteractionLogEntries: 50,
|
maxInteractionLogEntries: 50,
|
||||||
|
lastClientTargetId: 'claude_code',
|
||||||
};
|
};
|
||||||
|
|
||||||
function getProjectPath() {
|
function getProjectPath() {
|
||||||
@@ -60,6 +61,11 @@ function normalizeProfile(value) {
|
|||||||
return String(value || DEFAULTS.toolProfile).toLowerCase() === 'full' ? 'full' : 'core';
|
return String(value || DEFAULTS.toolProfile).toLowerCase() === 'full' ? 'full' : 'core';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeClientTargetId(value) {
|
||||||
|
const normalized = String(value || '').trim();
|
||||||
|
return normalized || DEFAULTS.lastClientTargetId;
|
||||||
|
}
|
||||||
|
|
||||||
function loadConfig() {
|
function loadConfig() {
|
||||||
const projectPath = getProjectPath();
|
const projectPath = getProjectPath();
|
||||||
const configPath = path.join(projectPath, 'funplay-cocos-mcp.config.json');
|
const configPath = path.join(projectPath, 'funplay-cocos-mcp.config.json');
|
||||||
@@ -75,6 +81,7 @@ function loadConfig() {
|
|||||||
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 || '',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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,
|
||||||
|
};
|
||||||
+2
-1
@@ -4,6 +4,7 @@ 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 { 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' };
|
||||||
@@ -199,7 +200,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}`;
|
||||||
}
|
}
|
||||||
|
|||||||
+227
-25
@@ -1,11 +1,24 @@
|
|||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
const http = require('http');
|
const http = require('http');
|
||||||
|
const { safeStringify } = require('./utils');
|
||||||
const IMAGE_DATA_URI_PREFIX = 'data:image/png;base64,';
|
const IMAGE_DATA_URI_PREFIX = 'data:image/png;base64,';
|
||||||
const LOG_PREFIX = '[Funplay Cocos MCP Server]';
|
const LOG_PREFIX = '[Funplay Cocos MCP Server]';
|
||||||
|
const MAX_PORT_FALLBACK_ATTEMPTS = 20;
|
||||||
|
const MAX_REQUEST_BODY_BYTES = 4 * 1024 * 1024;
|
||||||
|
const MCP_PROTOCOL_VERSION = '2025-11-25';
|
||||||
|
const SUPPORTED_PROTOCOL_VERSIONS = [
|
||||||
|
MCP_PROTOCOL_VERSION,
|
||||||
|
'2025-06-18',
|
||||||
|
'2025-03-26',
|
||||||
|
'2024-11-05',
|
||||||
|
];
|
||||||
|
|
||||||
function json(response, statusCode, payload) {
|
function json(response, statusCode, payload, protocolVersion = MCP_PROTOCOL_VERSION) {
|
||||||
response.writeHead(statusCode, { 'Content-Type': 'application/json; charset=utf-8' });
|
response.writeHead(statusCode, {
|
||||||
|
'Content-Type': 'application/json; charset=utf-8',
|
||||||
|
'MCP-Protocol-Version': protocolVersion,
|
||||||
|
});
|
||||||
response.end(JSON.stringify(payload));
|
response.end(JSON.stringify(payload));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -32,6 +45,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;
|
||||||
@@ -42,62 +71,148 @@ class McpServer {
|
|||||||
this.serverName = options.serverName;
|
this.serverName = options.serverName;
|
||||||
this.serverVersion = options.serverVersion;
|
this.serverVersion = options.serverVersion;
|
||||||
this.server = null;
|
this.server = null;
|
||||||
|
this.actualPort = null;
|
||||||
|
this.portFallbackInfo = null;
|
||||||
|
this.negotiatedProtocolVersion = MCP_PROTOCOL_VERSION;
|
||||||
}
|
}
|
||||||
|
|
||||||
isRunning() {
|
isRunning() {
|
||||||
return Boolean(this.server && this.server.listening);
|
return Boolean(this.server && this.server.listening);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getPort() {
|
||||||
|
if (this.server && typeof this.server.address === 'function') {
|
||||||
|
const address = this.server.address();
|
||||||
|
if (address && typeof address.port === 'number') {
|
||||||
|
return address.port;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return this.actualPort || this.config.port;
|
||||||
|
}
|
||||||
|
|
||||||
|
getRequestedPort() {
|
||||||
|
return this.config.port;
|
||||||
|
}
|
||||||
|
|
||||||
|
getPortFallbackInfo() {
|
||||||
|
return this.portFallbackInfo;
|
||||||
|
}
|
||||||
|
|
||||||
async start() {
|
async start() {
|
||||||
if (this.isRunning()) {
|
if (this.isRunning()) {
|
||||||
console.log(`${LOG_PREFIX} Start skipped: already running.`);
|
console.log(`${LOG_PREFIX} Start skipped: already running.`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`${LOG_PREFIX} Creating HTTP server on ${this.config.host}:${this.config.port}...`);
|
this.actualPort = null;
|
||||||
this.server = http.createServer(async (request, response) => {
|
this.portFallbackInfo = null;
|
||||||
|
|
||||||
|
const requestHandler = async (request, response) => {
|
||||||
try {
|
try {
|
||||||
if (request.method === 'GET' && request.url === '/health') {
|
if (request.method === 'GET' && request.url === '/health') {
|
||||||
console.log(`${LOG_PREFIX} GET /health`);
|
console.log(`${LOG_PREFIX} 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)) {
|
||||||
|
console.warn(`${LOG_PREFIX} Rejected ${request.method} ${request.url}: invalid Origin header.`);
|
||||||
|
return json(response, 403, { error: 'Forbidden: invalid Origin header' }, this.negotiatedProtocolVersion);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (request.method !== 'POST') {
|
if (request.method !== 'POST') {
|
||||||
console.warn(`${LOG_PREFIX} Rejected ${request.method} ${request.url}: method not allowed.`);
|
console.warn(`${LOG_PREFIX} 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 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
let rpc;
|
||||||
|
try {
|
||||||
|
rpc = JSON.parse(body);
|
||||||
|
} catch (error) {
|
||||||
|
return json(response, 400, this.createError(null, -32700, `Parse error: ${error.message}`), this.negotiatedProtocolVersion);
|
||||||
}
|
}
|
||||||
|
|
||||||
const rpc = JSON.parse(body);
|
|
||||||
if (rpc && rpc.method) {
|
if (rpc && rpc.method) {
|
||||||
console.log(`${LOG_PREFIX} RPC ${rpc.method}`);
|
console.log(`${LOG_PREFIX} RPC ${rpc.method}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const protocolHeaderError = this.validateProtocolVersionHeader(request, rpc);
|
||||||
|
if (protocolHeaderError) {
|
||||||
|
return json(response, 400, protocolHeaderError, this.negotiatedProtocolVersion);
|
||||||
|
}
|
||||||
|
|
||||||
const result = await this.handleRpcRequest(rpc);
|
const result = await this.handleRpcRequest(rpc);
|
||||||
if (result == null) {
|
if (result == null) {
|
||||||
response.writeHead(204);
|
response.writeHead(204, { 'MCP-Protocol-Version': this.negotiatedProtocolVersion });
|
||||||
response.end();
|
response.end();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
return json(response, 200, result);
|
return json(response, 200, result, this.negotiatedProtocolVersion);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`${LOG_PREFIX} Request handling failed: ${error.message}`);
|
console.error(`${LOG_PREFIX} 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);
|
||||||
}
|
}
|
||||||
});
|
};
|
||||||
|
|
||||||
await new Promise((resolve, reject) => {
|
let attempt = 0;
|
||||||
this.server.once('error', reject);
|
let port = this.config.port;
|
||||||
this.server.listen(this.config.port, this.config.host, () => {
|
let lastError = null;
|
||||||
this.server.off('error', reject);
|
|
||||||
console.log(`${LOG_PREFIX} Listening on http://${this.config.host}:${this.config.port}/`);
|
while (attempt <= MAX_PORT_FALLBACK_ATTEMPTS) {
|
||||||
resolve();
|
console.log(`${LOG_PREFIX} Creating HTTP server on ${this.config.host}:${port}...`);
|
||||||
});
|
const candidate = http.createServer(requestHandler);
|
||||||
});
|
|
||||||
|
try {
|
||||||
|
await this.listen(candidate, port, this.config.host);
|
||||||
|
this.server = candidate;
|
||||||
|
this.actualPort = candidate.address() && typeof candidate.address().port === 'number'
|
||||||
|
? candidate.address().port
|
||||||
|
: port;
|
||||||
|
|
||||||
|
if (this.actualPort !== this.config.port) {
|
||||||
|
this.portFallbackInfo = {
|
||||||
|
requestedPort: this.config.port,
|
||||||
|
actualPort: this.actualPort,
|
||||||
|
attempts: attempt,
|
||||||
|
};
|
||||||
|
console.warn(
|
||||||
|
`${LOG_PREFIX} Port ${this.config.port} was unavailable. ` +
|
||||||
|
`Fell back to ${this.actualPort}.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`${LOG_PREFIX} Listening on http://${this.config.host}:${this.actualPort}/`);
|
||||||
|
return;
|
||||||
|
} catch (error) {
|
||||||
|
lastError = error;
|
||||||
|
if (error && error.code === 'EADDRINUSE' && port < 65535 && attempt < MAX_PORT_FALLBACK_ATTEMPTS) {
|
||||||
|
const nextPort = port + 1;
|
||||||
|
console.warn(
|
||||||
|
`${LOG_PREFIX} Port ${port} is already in use. ` +
|
||||||
|
`Trying fallback port ${nextPort}...`
|
||||||
|
);
|
||||||
|
port = nextPort;
|
||||||
|
attempt += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
candidate.removeAllListeners();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.server = null;
|
||||||
|
this.actualPort = null;
|
||||||
|
this.portFallbackInfo = null;
|
||||||
|
throw lastError || new Error('Failed to start MCP server.');
|
||||||
}
|
}
|
||||||
|
|
||||||
async stop() {
|
async stop() {
|
||||||
@@ -109,6 +224,8 @@ class McpServer {
|
|||||||
console.log(`${LOG_PREFIX} Closing HTTP server...`);
|
console.log(`${LOG_PREFIX} Closing HTTP server...`);
|
||||||
const active = this.server;
|
const active = this.server;
|
||||||
this.server = null;
|
this.server = null;
|
||||||
|
this.actualPort = null;
|
||||||
|
this.portFallbackInfo = null;
|
||||||
await new Promise((resolve, reject) => {
|
await new Promise((resolve, reject) => {
|
||||||
active.close((error) => {
|
active.close((error) => {
|
||||||
if (error) {
|
if (error) {
|
||||||
@@ -122,15 +239,80 @@ class McpServer {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
listen(server, port, host) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const onError = (error) => {
|
||||||
|
server.off('listening', onListening);
|
||||||
|
reject(error);
|
||||||
|
};
|
||||||
|
const onListening = () => {
|
||||||
|
server.off('error', onError);
|
||||||
|
resolve();
|
||||||
|
};
|
||||||
|
server.once('error', onError);
|
||||||
|
server.once('listening', onListening);
|
||||||
|
server.listen(port, host);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
readBody(request) {
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
async handleRpcRequest(request) {
|
async handleRpcRequest(request) {
|
||||||
if (!request || request.jsonrpc !== '2.0') {
|
if (!request || request.jsonrpc !== '2.0') {
|
||||||
return this.createError(request && request.id, -32600, 'Invalid Request');
|
return this.createError(request && request.id, -32600, 'Invalid Request');
|
||||||
@@ -142,8 +324,9 @@ class McpServer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (method === 'initialize') {
|
if (method === 'initialize') {
|
||||||
|
this.negotiatedProtocolVersion = this.negotiateProtocolVersion(request.params && request.params.protocolVersion);
|
||||||
return this.createResult(request.id, {
|
return this.createResult(request.id, {
|
||||||
protocolVersion: '2024-11-05',
|
protocolVersion: this.negotiatedProtocolVersion,
|
||||||
serverInfo: {
|
serverInfo: {
|
||||||
name: this.serverName,
|
name: this.serverName,
|
||||||
version: this.serverVersion,
|
version: this.serverVersion,
|
||||||
@@ -171,10 +354,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,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -209,6 +402,13 @@ class McpServer {
|
|||||||
return this.createError(request.id, -32601, `Method not found: ${method}`);
|
return this.createError(request.id, -32601, `Method not found: ${method}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
negotiateProtocolVersion(clientVersion) {
|
||||||
|
if (SUPPORTED_PROTOCOL_VERSIONS.includes(clientVersion)) {
|
||||||
|
return clientVersion;
|
||||||
|
}
|
||||||
|
return MCP_PROTOCOL_VERSION;
|
||||||
|
}
|
||||||
|
|
||||||
createResult(id, result) {
|
createResult(id, result) {
|
||||||
return {
|
return {
|
||||||
jsonrpc: '2.0',
|
jsonrpc: '2.0',
|
||||||
@@ -231,4 +431,6 @@ class McpServer {
|
|||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
McpServer,
|
McpServer,
|
||||||
|
MCP_PROTOCOL_VERSION,
|
||||||
|
SUPPORTED_PROTOCOL_VERSIONS,
|
||||||
};
|
};
|
||||||
|
|||||||
+98
-9
@@ -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,9 +12,11 @@ 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 { resolveProjectPath } = require('./path-safety');
|
||||||
const { captureDesktopScreenshot, captureEditorWindowScreenshot, capturePanelScreenshot } = require('./screenshots');
|
const { captureDesktopScreenshot, captureEditorWindowScreenshot, capturePanelScreenshot } = require('./screenshots');
|
||||||
const { safeStringify } = require('./utils');
|
const { safeStringify } = require('./utils');
|
||||||
|
|
||||||
@@ -35,10 +38,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 +123,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, sceneBridge, editorExecutor }) {
|
||||||
const tools = [
|
const tools = [
|
||||||
{
|
{
|
||||||
name: 'execute_javascript',
|
name: 'execute_javascript',
|
||||||
@@ -183,6 +182,87 @@ 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_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 +524,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(),
|
||||||
},
|
},
|
||||||
@@ -1249,7 +1329,7 @@ function createToolRegistry({ getRuntimeContext, interactionLog, sceneBridge, ed
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
return {
|
const registry = {
|
||||||
listTools() {
|
listTools() {
|
||||||
const { config } = getRuntimeContext();
|
const { config } = getRuntimeContext();
|
||||||
return tools
|
return tools
|
||||||
@@ -1260,7 +1340,7 @@ function createToolRegistry({ getRuntimeContext, interactionLog, sceneBridge, ed
|
|||||||
inputSchema: tool.inputSchema,
|
inputSchema: tool.inputSchema,
|
||||||
}));
|
}));
|
||||||
},
|
},
|
||||||
async callTool(name, args) {
|
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) {
|
||||||
@@ -1274,13 +1354,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 = {
|
||||||
|
|||||||
+3
-2
@@ -1,13 +1,14 @@
|
|||||||
{
|
{
|
||||||
"name": "funplay-cocos-mcp",
|
"name": "funplay-cocos-mcp",
|
||||||
"package_version": 2,
|
"package_version": 2,
|
||||||
"version": "0.1.0",
|
"version": "0.1.3",
|
||||||
"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/path-safety.js && node --check lib/prompts.js && node --check lib/resources.js && node --check lib/screenshots.js && node --check lib/server.js && node --check lib/tool-registry.js && node --check lib/utils.js",
|
||||||
|
"test": "node --test"
|
||||||
},
|
},
|
||||||
"panels": {
|
"panels": {
|
||||||
"default": {
|
"default": {
|
||||||
|
|||||||
+12
-3
@@ -233,7 +233,11 @@ module.exports = Editor.Panel.define({
|
|||||||
this.$.statusPill.textContent = isRunning ? 'Running' : 'Stopped';
|
this.$.statusPill.textContent = isRunning ? 'Running' : 'Stopped';
|
||||||
this.$.statusPill.classList.toggle('running', isRunning);
|
this.$.statusPill.classList.toggle('running', isRunning);
|
||||||
this.$.statusPill.classList.toggle('stopped', !isRunning);
|
this.$.statusPill.classList.toggle('stopped', !isRunning);
|
||||||
this.$.statusText.textContent = `${status.url || ''} | Project: ${status.projectName || ''} | Cocos ${status.cocosVersion || ''}`;
|
const portText = status.portFallbackActive
|
||||||
|
? ` | Port fallback: ${status.requestedPort} -> ${status.port}`
|
||||||
|
: '';
|
||||||
|
this.$.statusText.textContent =
|
||||||
|
`${status.url || ''} | Project: ${status.projectName || ''} | Cocos ${status.cocosVersion || ''}${portText}`;
|
||||||
|
|
||||||
this.$.enabledInput.value = Boolean(isRunning || config.autostart);
|
this.$.enabledInput.value = Boolean(isRunning || config.autostart);
|
||||||
this.$.portInput.value = Number(config.port || status.port || 8765);
|
this.$.portInput.value = Number(config.port || status.port || 8765);
|
||||||
@@ -244,7 +248,8 @@ module.exports = Editor.Panel.define({
|
|||||||
},
|
},
|
||||||
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('');
|
||||||
@@ -296,6 +301,7 @@ module.exports = Editor.Panel.define({
|
|||||||
toolProfile: this.$.profileSelect.value || 'core',
|
toolProfile: this.$.profileSelect.value || 'core',
|
||||||
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,7 +335,10 @@ module.exports = Editor.Panel.define({
|
|||||||
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.$.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,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,128 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const test = require('node:test');
|
||||||
|
const {
|
||||||
|
MCP_PROTOCOL_VERSION,
|
||||||
|
McpServer,
|
||||||
|
SUPPORTED_PROTOCOL_VERSIONS,
|
||||||
|
} = require('../lib/server');
|
||||||
|
|
||||||
|
function createServer(toolRegistry = {}) {
|
||||||
|
return new McpServer({
|
||||||
|
config: { host: '127.0.0.1', port: 8765 },
|
||||||
|
toolRegistry: {
|
||||||
|
listTools: () => [],
|
||||||
|
callTool: async () => 'ok',
|
||||||
|
...toolRegistry,
|
||||||
|
},
|
||||||
|
resourceProvider: {
|
||||||
|
listResources: () => [],
|
||||||
|
listResourceTemplates: () => [],
|
||||||
|
readResource: async () => ({ contents: [] }),
|
||||||
|
},
|
||||||
|
promptProvider: {
|
||||||
|
listPrompts: () => [],
|
||||||
|
getPrompt: () => ({ messages: [] }),
|
||||||
|
},
|
||||||
|
interactionLog: { add() {} },
|
||||||
|
serverName: 'test-server',
|
||||||
|
serverVersion: '0.0.0-test',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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/);
|
||||||
|
});
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
'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')) {
|
||||||
|
return createToolRegistry({
|
||||||
|
getRuntimeContext: () => ({
|
||||||
|
config: { toolProfile: profile },
|
||||||
|
projectPath,
|
||||||
|
}),
|
||||||
|
interactionLog: { add() {} },
|
||||||
|
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, 19);
|
||||||
|
assert.equal(tools.some((tool) => tool.name === 'execute_javascript'), 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, 67);
|
||||||
|
assert.equal(tools.some((tool) => tool.name === 'write_file'), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
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/);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user