diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 32c4d27..965e160 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -76,6 +76,9 @@ jobs: - name: Run tests run: npm test + - name: Check generated docs + run: npm run docs:check + - name: Run release metadata checks run: npm run release:check diff --git a/CHANGELOG.md b/CHANGELOG.md index 3a915f4..70c1991 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,23 @@ This project follows a simple changelog format inspired by [Keep a Changelog](ht ## [Unreleased] +## [0.3.3] - 2026-05-20 + +### Added + +- Added generated tool reference documentation in `docs/TOOLS.md`. +- Added `docs:generate` and `docs:check` scripts to keep tool counts, profiles, categories, and descriptions synchronized with `lib/tool-registry.js`. +- Added CI and release validation for generated tool documentation. +- Added a read-only `GET /tools` debug endpoint with curl examples for quick local troubleshooting. +- Added panel activity previews for recent tool calls and runtime logs. +- Added panel curl copy actions for `/health` and `/tools`. + +### Changed + +- Refined tool category inference so `get_tool_catalog` is grouped with project/context tools. +- Updated the MCP client config panel so the preview follows the selected client target. +- Split file-system tools and asset refresh helpers into `lib/tools/files.js` as the first registry modularization step. + ## [0.3.2] - 2026-05-20 ### Added diff --git a/README.md b/README.md index 9259f45..04d6c2f 100644 --- a/README.md +++ b/README.md @@ -73,8 +73,10 @@ The panel is intentionally small: - Change the server port - Switch tool exposure between `core`, `full`, and `custom` - Check the installed version against the latest GitHub release +- Inspect recent tool calls and runtime log previews - Tune tool exposure by category or individual tool -- Configure AI clients with one click +- Configure AI clients with one click and preview the selected client config +- Copy quick `curl` commands for `/health` and `/tools` - Expand debug output only when needed ### 3. Configure Your AI Client @@ -215,6 +217,13 @@ Open your AI client and try a few safe requests first: If these work, the MCP server, resources, prompts, and primary execution tool are connected correctly. +For local transport debugging, the panel can copy these commands, or you can run them directly: + +```bash +curl http://127.0.0.1:8765/health +curl http://127.0.0.1:8765/tools +``` + ### 5. Start Building Try a higher-level prompt in your AI client: @@ -226,6 +235,7 @@ Try a higher-level prompt in your AI client: - This extension is **Editor-only**. It is meant to automate Cocos Creator, not to add runtime dependencies to your final game build. - The MCP server listens on `http://127.0.0.1:8765/` by default. - If the configured port is busy, the server automatically falls back to the next available port and the panel/client config use the actual running port. +- `GET /health` and `GET /tools` are read-only debug endpoints for quick local checks outside an MCP client. - The default `core` profile exposes 34 high-signal tools. Switch to `full` for all 89 tools, or use `custom` to include/exclude tool categories and individual tools. - The panel includes a manual update check against the latest GitHub release. - Streamable HTTP responses follow the MCP transport requirements for `Accept`, `MCP-Protocol-Version`, JSON-RPC notifications/responses, and optional `Mcp-Session-Id` sessions. @@ -249,7 +259,7 @@ Try a higher-level prompt in your AI client: - **89 Built-in Tools** — Scene hierarchy, editor state, selection workflows, prefabs, assets, project instructions, UI creation, components, files, logs, script diagnostics, screenshots, runtime control, and input simulation - **Primary Unified Tool** — `execute_javascript` supports both `scene` and `editor` contexts - **Resources & Prompts** — Live project/log resources plus reusable workflows like script fixing, scene validation, and playable prototype creation -- **Cocos Panel UI** — A minimal `Funplay > MCP Server` panel for service management, update checks, tool exposure, and MCP client setup +- **Cocos Panel UI** — A compact `Funplay > MCP Server` panel for service management, update checks, tool exposure, recent activity, logs, curl diagnostics, and MCP client setup - **Screenshot and Input Support** — Capture editor/scene/game/preview screenshots and send Electron-level mouse/keyboard events - **Vendor Agnostic** — Works with any AI client that supports MCP over HTTP JSON-RPC @@ -276,6 +286,8 @@ The current package exposes four capability layers: - **Prompts** — `fix_script_errors`, `create_playable_prototype`, `scene_validation`, and `auto_wire_scene` - **Resources** — project context, scene summaries, current selection, script diagnostics, asset selection, logs, and MCP interaction history +For the generated tool reference, including categories, profiles, and read/mutation hints, see [docs/TOOLS.md](./docs/TOOLS.md). + The default `core` set is intentionally small: `execute_javascript`, `execute_scene_script`, `execute_editor_script`, `get_editor_state`, `get_tool_catalog`, `check_for_updates`, `get_selection`, `list_project_instructions`, `read_project_instruction`, `set_selection`, `get_project_info`, `get_scene_info`, `get_hierarchy`, `list_scenes`, `open_scene`, `inspect_prefab`, `validate_prefab_references`, `inspect_prefab_instance`, `list_assets`, `inspect_asset`, `open_asset`, `select_asset`, `run_script_diagnostics`, `get_recent_logs`, `search_project_logs`, `clear_logs`, `validate_scene`, `get_performance_snapshot`, `get_script_diagnostic_context`, `get_runtime_state`, `capture_editor_screenshot`, `capture_scene_screenshot`, `capture_preview_screenshot`, and `list_editor_windows`. ## Built-in Resources @@ -385,7 +397,7 @@ Cocos Creator Extension └─ server, resources, prompts, tool registry ``` -The server speaks MCP-style HTTP JSON-RPC 2.0 and supports tools, resources, resource templates, prompts, and health checks. +The server speaks MCP-style HTTP JSON-RPC 2.0 and supports tools, resources, resource templates, prompts, health checks, and a read-only `/tools` debug endpoint. ## Development @@ -394,10 +406,17 @@ Run checks before publishing changes: ```bash npm run check npm test +npm run docs:check npm run release:check npm run pack:dry-run ``` +Regenerate the tool reference after changing `lib/tool-registry.js`: + +```bash +npm run docs:generate +``` + To generate a GitHub Release-ready extension package: ```bash diff --git a/README_CN.md b/README_CN.md index 2166b4b..fa97fb0 100644 --- a/README_CN.md +++ b/README_CN.md @@ -73,8 +73,10 @@ Funplay > MCP Server - 修改服务端口 - 在 `core` / `full` / `custom` 工具暴露模式之间切换 - 检查当前安装版本是否落后于 GitHub 最新 Release +- 查看最近工具调用和运行日志预览 - 按工具分类或单个工具调整暴露范围 -- 一键配置 AI 客户端 +- 一键配置 AI 客户端,并随目标客户端预览对应配置 +- 复制 `/health` 和 `/tools` 的快速 `curl` 排障命令 - 需要时再展开 Debug Output ### 3. 配置 AI 客户端 @@ -215,6 +217,13 @@ npm install -g funplay-cocos-mcp 如果这些都正常返回,说明 MCP server、resources、prompts 和主执行工具已经连通。 +如果要排查本地传输链路,面板可以直接复制下面的命令,也可以手动执行: + +```bash +curl http://127.0.0.1:8765/health +curl http://127.0.0.1:8765/tools +``` + ### 5. 开始构建 可以在 AI 客户端里尝试: @@ -226,6 +235,7 @@ npm install -g funplay-cocos-mcp - 这是一个 **仅限 Editor** 的扩展,用于自动化 Cocos Creator,不会给最终游戏包添加运行时依赖。 - MCP Server 默认监听 `http://127.0.0.1:8765/`。 - 如果配置端口被占用,服务会自动回退到下一个可用端口,面板与一键客户端配置会使用实际运行端口。 +- `GET /health` 和 `GET /tools` 是只读调试端点,方便不用 MCP 客户端也能快速检查本地服务。 - 默认 `core` profile 暴露 34 个高频工具;如果需要完整工具集,可在面板切到 `full`,暴露全部 89 个工具;也可以用 `custom` 按分类或工具名增删。 - 面板提供手动更新检查,会对比当前安装版本和 GitHub 最新 Release。 - Streamable HTTP 响应已补齐 MCP 传输层要求,包括 `Accept`、`MCP-Protocol-Version`、JSON-RPC notification/response,以及可选 `Mcp-Session-Id` session。 @@ -249,7 +259,7 @@ npm install -g funplay-cocos-mcp - **89 个内置工具** — 覆盖场景层级、编辑器状态、选择工作流、Prefab、资产、项目指令、UI 创建、组件、文件、日志、脚本诊断、截图、运行态控制和输入模拟 - **统一主工具** — `execute_javascript` 同时支持 `scene` 和 `editor` 两种上下文 - **Resources 与 Prompts** — 实时项目/日志资源,以及脚本修复、场景验证、可玩原型等可复用工作流 -- **Cocos 图形面板** — `Funplay > MCP Server` 提供服务管理、更新检查、工具暴露和 MCP 客户端配置 +- **Cocos 图形面板** — `Funplay > MCP Server` 提供服务管理、更新检查、工具暴露、最近活动、日志、curl 排障和 MCP 客户端配置 - **截图与输入支持** — 支持编辑器/场景/Game/Preview 截图,以及 Electron 级鼠标键盘事件 - **厂商无关** — 兼容任意支持 HTTP JSON-RPC MCP 的 AI 客户端 @@ -276,6 +286,8 @@ Funplay MCP for Cocos 延续 Funplay MCP for Unity 的设计原则,并针对 C - **Prompts** — `fix_script_errors`、`create_playable_prototype`、`scene_validation`、`auto_wire_scene` - **Resources** — 项目上下文、场景摘要、当前选择、脚本诊断、资产选择、日志和 MCP 交互历史 +自动生成的工具参考文档见 [docs/TOOLS.md](./docs/TOOLS.md),里面包含工具分类、profile 和读写/变更提示。 + 当前默认 `core` 工具集刻意保持精简,只包含:`execute_javascript`、`execute_scene_script`、`execute_editor_script`、`get_editor_state`、`get_tool_catalog`、`check_for_updates`、`get_selection`、`list_project_instructions`、`read_project_instruction`、`set_selection`、`get_project_info`、`get_scene_info`、`get_hierarchy`、`list_scenes`、`open_scene`、`inspect_prefab`、`validate_prefab_references`、`inspect_prefab_instance`、`list_assets`、`inspect_asset`、`open_asset`、`select_asset`、`run_script_diagnostics`、`get_recent_logs`、`search_project_logs`、`clear_logs`、`validate_scene`、`get_performance_snapshot`、`get_script_diagnostic_context`、`get_runtime_state`、`capture_editor_screenshot`、`capture_scene_screenshot`、`capture_preview_screenshot`、`list_editor_windows`。 ## 内置 Resources @@ -385,7 +397,7 @@ Cocos Creator Extension └─ server, resources, prompts, tool registry ``` -服务使用 MCP 风格的 HTTP JSON-RPC 2.0,支持 tools、resources、resource templates、prompts 和 health check。 +服务使用 MCP 风格的 HTTP JSON-RPC 2.0,支持 tools、resources、resource templates、prompts、health check 和只读 `/tools` 调试端点。 ## 开发 @@ -394,10 +406,17 @@ Cocos Creator Extension ```bash npm run check npm test +npm run docs:check npm run release:check npm run pack:dry-run ``` +修改 `lib/tool-registry.js` 后可以重新生成工具参考: + +```bash +npm run docs:generate +``` + 生成可上传到 GitHub Release 的扩展包: ```bash diff --git a/RELEASE_CHECKLIST.md b/RELEASE_CHECKLIST.md index 86a365e..137b747 100644 --- a/RELEASE_CHECKLIST.md +++ b/RELEASE_CHECKLIST.md @@ -14,6 +14,7 @@ Use this checklist before publishing a new release of Funplay MCP for Cocos. - [ ] `npm run check` passes - [ ] `npm test` passes +- [ ] `npm run docs:check` passes - [ ] `npm run release:check` passes - [ ] `npm run pack:dry-run` passes - [ ] `npm run registry:validate` passes when `mcp-publisher` is available diff --git a/RELEASE_WORKFLOW.md b/RELEASE_WORKFLOW.md index f122f7c..dc2c026 100644 --- a/RELEASE_WORKFLOW.md +++ b/RELEASE_WORKFLOW.md @@ -53,12 +53,14 @@ Update: 3. `server.json` - top-level `"version"` - npm package `"version"` +4. `docs/TOOLS.md` + - regenerate with `npm run docs:generate` after tool registry changes Optional but recommended: -4. `README.md` -5. `README_CN.md` -6. GitHub Release notes text +5. `README.md` +6. `README_CN.md` +7. GitHub Release notes text ## Release Steps @@ -90,6 +92,7 @@ This runs: - JavaScript syntax checks - Node.js tests +- generated tool documentation validation - release metadata validation - npm package dry-run validation - release package generation diff --git a/browser.js b/browser.js index 213c28a..99ce598 100644 --- a/browser.js +++ b/browser.js @@ -4,7 +4,7 @@ const path = require('path'); const fs = require('fs'); const os = require('os'); const manifest = require('./package.json'); -const { configureTarget, getTargetStatuses } = require('./lib/client-config'); +const { SERVER_NAME, buildTargets, configureTarget, getTargetStatuses } = require('./lib/client-config'); const { loadConfig, getProjectPath, getProjectName, getCocosVersion } = require('./lib/config'); const { McpServer } = require('./lib/server'); const { createToolRegistry } = require('./lib/tool-registry'); @@ -304,7 +304,21 @@ class ExtensionService { } getClientConfig() { - const { url } = this.getEffectiveServerConnection(); + const effective = this.getEffectiveServerConnection(); + const { url } = effective; + const targetConfig = { + ...this.config, + host: effective.host, + port: effective.port, + }; + const targets = buildTargets(targetConfig).map((target) => ({ + id: target.id, + name: target.name, + configPath: target.configPath, + isToml: Boolean(target.isToml), + preview: this.formatClientTargetPreview(target), + })); + const baseUrl = url.replace(/\/$/, ''); return { url, codex: `[mcp_servers.funplay_cocos]\nurl = "${url}"\n`, @@ -315,9 +329,28 @@ class ExtensionService { }, }, }, null, 2), + targets, + curl: { + health: `curl ${baseUrl}/health`, + tools: `curl ${baseUrl}/tools`, + catalog: `curl ${baseUrl}/tools?catalog=1`, + }, }; } + formatClientTargetPreview(target) { + if (target.isToml) { + return `[mcp_servers.${SERVER_NAME}]\nurl = "${target.url}"\n`; + } + + const rootKey = target.rootKey || 'mcpServers'; + return JSON.stringify({ + [rootKey]: { + [SERVER_NAME]: target.entry, + }, + }, null, 2); + } + configureClient(targetId) { this.ensureRuntime(); this.log('info', `Configuring MCP client target: ${targetId}`); diff --git a/docs/TOOLS.md b/docs/TOOLS.md new file mode 100644 index 0000000..b157330 --- /dev/null +++ b/docs/TOOLS.md @@ -0,0 +1,203 @@ +# Tool Reference + + + +Generated from `lib/tool-registry.js`. The default `core` profile exposes 34 tools; the `full` profile exposes 89 tools. + +## Profile Summary + +| Profile | Tool Count | Purpose | +|---|---:|---| +| `core` | 34 | Focused default surface for common editor automation. | +| `full` | 89 | All built-in tools, including destructive and low-level helpers. | + +## Core Tools + +`capture_editor_screenshot`, `capture_preview_screenshot`, `capture_scene_screenshot`, `check_for_updates`, `clear_logs`, `execute_editor_script`, `execute_javascript`, `execute_scene_script`, `get_editor_state`, `get_hierarchy`, `get_performance_snapshot`, `get_project_info`, `get_recent_logs`, `get_runtime_state`, `get_scene_info`, `get_script_diagnostic_context`, `get_selection`, `get_tool_catalog`, `inspect_asset`, `inspect_prefab`, `inspect_prefab_instance`, `list_assets`, `list_editor_windows`, `list_project_instructions`, `list_scenes`, `open_asset`, `open_scene`, `read_project_instruction`, `run_script_diagnostics`, `search_project_logs`, `select_asset`, `set_selection`, `validate_prefab_references`, `validate_scene` + +## Tools By Category + +### Animation + +| Tool | Profiles | Access | Description | +|---|---|---|---| +| `add_animation_clip` | `full` | stateful | Add an AnimationClip asset to a node Animation component. | +| `list_animations` | `full` | read-only | [core] List Animation components in the active scene or under one node. | +| `play_animation` | `full` | stateful | [core] Play an Animation component clip on a node. | +| `stop_animation` | `full` | stateful | [core] Stop an Animation component clip on a node. | + +### Assets + +| Tool | Profiles | Access | Description | +|---|---|---|---| +| `delete_asset` | `full` | mutating | Delete an asset from asset-db by uuid, db url, or path. | +| `inspect_asset` | `core`, `full` | read-only | [specialist] Inspect asset-db info, metadata, and serialized asset data by uuid or path. Prefer this when you need a precise structured asset read. | +| `list_assets` | `core`, `full` | read-only | [specialist] Query project assets from asset-db by pattern or asset type. Prefer this when you need exact asset discovery; otherwise use execute_javascript for broader automation. | +| `list_scenes` | `core`, `full` | read-only | [specialist] List scene assets in the project. Prefer this when you need exact scene discovery before opening one; otherwise stay in execute_javascript for broader workflows. | +| `open_asset` | `core`, `full` | stateful | [specialist] Open an asset inside Cocos Creator by uuid, db url, or path. Use this only when opening the asset itself is the explicit next step. | +| `open_scene` | `core`, `full` | stateful | [specialist] Open a scene asset in Cocos Creator by uuid, db url, or path. Use this when scene switching is the explicit goal; otherwise keep execute_javascript as the main planning tool. | +| `run_scene_asset` | `full` | mutating | Load a scene asset by uuid directly into the current runtime scene context. | +| `select_asset` | `core`, `full` | stateful | [specialist] Select an asset in the Cocos editor. Use this when editor selection state matters; otherwise keep execute_javascript as the primary workflow. | + +### Camera + +| Tool | Profiles | Access | Description | +|---|---|---|---| +| `create_camera` | `full` | stateful | Create a Camera node in the active scene. | +| `list_cameras` | `full` | read-only | [core] List Camera components in the active scene. | +| `set_camera_properties` | `full` | mutating | Set selected Camera component properties. | + +### Components + +| Tool | Profiles | Access | Description | +|---|---|---|---| +| `add_component` | `full` | stateful | Add a component to a node by component class name. | +| `inspect_component` | `full` | read-only | [core] Inspect a component attached to a node. | +| `invoke_component_method` | `full` | mutating | [core] Invoke a method on a component for runtime validation and test hooks. | +| `list_components` | `full` | read-only | [core] List components attached to a scene node. | +| `remove_component` | `full` | mutating | Remove a component from a node by name or index. | +| `reset_component_property` | `full` | mutating | Reset or clear a component property by dot path. | +| `set_component_property` | `full` | mutating | Set a component property by dot path using a JSON value. | + +### Diagnostics + +| Tool | Profiles | Access | Description | +|---|---|---|---| +| `get_script_diagnostic_context` | `core`, `full` | read-only | [specialist] Run TypeScript diagnostics and attach source snippets for each error. This is a preferred specialist tool for compile-error triage before repair. | +| `run_script_diagnostics` | `core`, `full` | stateful | [specialist] Run a TypeScript no-emit check for the current Cocos project and return parsed diagnostics. This is a preferred specialist tool for script errors when diagnostics are needed. | +| `validate_prefab_references` | `core`, `full` | read-only | [specialist] Validate prefab asset references by checking serialized UUID references against asset-db. | +| `validate_scene` | `core`, `full` | read-only | [specialist] Run a compact validation pass over the active scene, runtime state, TypeScript diagnostics, and recent project log errors. | + +### Execution + +| Tool | Profiles | Access | Description | +|---|---|---|---| +| `execute_editor_script` | `core`, `full` | mutating | [compat] Execute JavaScript in the editor/browser context. Prefer execute_javascript with context="editor" as the main unified tool; use this when you specifically want the editor-only compatibility entrypoint. | +| `execute_javascript` | `core`, `full` | mutating | [primary] Execute JavaScript in either the scene or editor context. Use context="scene" for live scene/runtime inspection and mutation, or context="editor" for Editor APIs, asset-db workflows, MCP orchestration, local filesystem access, and higher-level automation. Prefer this as the main flexible tool when many narrow tools would be noisy. | + +### Files + +| Tool | Profiles | Access | Description | +|---|---|---|---| +| `exists` | `full` | read-only | [core] Check whether a project file or directory exists. | +| `get_file_snippet` | `full` | read-only | [core] Read a focused snippet around a file line number. | +| `list_directory` | `full` | read-only | [core] List files and directories inside a project directory. | +| `read_file` | `full` | read-only | [core] Read a file from the Cocos project. | +| `refresh_assets` | `full` | stateful | [core] Best-effort asset database refresh for a file or the assets root. | +| `replace_in_file` | `full` | mutating | [core] Replace text in a file, useful for script auto-fix loops. | +| `search_files` | `full` | read-only | [core] Search project files by simple wildcard pattern. | +| `write_file` | `full` | mutating | [core] Write or overwrite a file in the Cocos project. | + +### Input + +| Tool | Profiles | Access | Description | +|---|---|---|---| +| `simulate_button_click` | `full` | mutating | [core] Simulate a Cocos Button click by emitting click events on the target button node. | +| `simulate_key_combo` | `full` | mutating | [core] Send a low-level Electron modified key press such as Ctrl+S or Cmd+P. | +| `simulate_key_press` | `full` | mutating | [core] Send a low-level Electron key press to the editor, preview, or simulator window. | +| `simulate_mouse_click` | `full` | mutating | [core] Send a low-level Electron mouse click to the editor, preview, or simulator window. | +| `simulate_mouse_drag` | `full` | mutating | [core] Send a low-level Electron mouse drag to the editor, preview, or simulator window. | +| `simulate_preview_input` | `full` | mutating | [core] Convenience wrapper for low-level preview/simulator input. Uses mouse click by default or key press when keyCode is provided. | + +### Instructions + +| Tool | Profiles | Access | Description | +|---|---|---|---| +| `create_project_skill` | `full` | stateful | [core] Create a local Codex project skill under .codex/skills/{skillName}/SKILL.md. | +| `list_project_instructions` | `core`, `full` | read-only | [specialist] List project AI instruction files and local Codex project skills. | +| `read_project_instruction` | `core`, `full` | read-only | [specialist] Read a project AI instruction file such as AGENTS.md, CLAUDE.md, or a .codex skill SKILL.md. | +| `write_project_instruction` | `full` | mutating | [core] Create or update a project AI instruction file inside the Cocos project. | + +### Logs + +| Tool | Profiles | Access | Description | +|---|---|---|---| +| `clear_logs` | `core`, `full` | mutating | [specialist] Clear in-memory MCP logs and, only with explicit confirmation, truncate common project log files. | +| `get_recent_logs` | `core`, `full` | read-only | [specialist] Return recent MCP runtime logs, recent tool interactions, and tails of common project log files. | +| `search_project_logs` | `core`, `full` | read-only | [specialist] Search common Cocos project log files for a string or regular expression. | + +### Other + +| Tool | Profiles | Access | Description | +|---|---|---|---| +| `get_performance_snapshot` | `core`, `full` | read-only | [specialist] Return scene scale and runtime performance-oriented counters such as node/component counts, UI counts, depth, memory, and warnings. | +| `list_editor_windows` | `core`, `full` | read-only | [specialist] List available Electron windows so screenshots or input-targeting can choose the correct window. Use this when window targeting is the explicit problem. | + +### Prefabs + +| Tool | Profiles | Access | Description | +|---|---|---|---| +| `apply_prefab_instance` | `full` | stateful | [core] Apply a scene prefab instance back to its associated prefab asset using the Cocos editor scene apply-prefab message. | +| `create_prefab_instance` | `full` | stateful | [core] Create a linked prefab instance in the editor hierarchy using Cocos scene create-node when available. | +| `duplicate_prefab` | `full` | stateful | [core] Create a new prefab asset by duplicating an existing prefab file without copying its .meta UUID. | +| `edit_prefab_json` | `full` | stateful | [core] Edit a prefab JSON file by JSON path assignment or literal search/replace, then validate references. | +| `inspect_prefab` | `core`, `full` | read-only | [specialist] Inspect a prefab asset, its metadata, serialized file path, and UUID-like asset references. | +| `inspect_prefab_instance` | `core`, `full` | read-only | [specialist] Inspect whether a scene node is linked to a prefab instance and return prefab metadata when available. | +| `instantiate_prefab` | `full` | stateful | Instantiate a prefab into the active scene by prefab uuid. | +| `list_prefabs` | `full` | read-only | [core] List prefab assets in the project. | +| `revert_prefab_instance` | `full` | stateful | [core] Revert a scene prefab instance from its associated prefab asset using available Cocos editor prefab revert messages. | + +### Project + +| Tool | Profiles | Access | Description | +|---|---|---|---| +| `get_editor_state` | `core`, `full` | read-only | [specialist] Return a structured editor-state snapshot including project info, runtime server status, current selection, and visible Electron windows. Prefer this when you want one compact editor summary. | +| `get_project_info` | `core`, `full` | read-only | [specialist] Return the active Cocos project path, version, and MCP server configuration. Prefer this for a fast structured project summary; use execute_javascript when you need to inspect and act in one step. | +| `get_tool_catalog` | `core`, `full` | read-only | [specialist] Return every built-in MCP tool with profile, category, and current exposure state. Use this before changing custom tool exposure. | + +### Runtime + +| Tool | Profiles | Access | Description | +|---|---|---|---| +| `emit_node_event` | `full` | mutating | [core] Emit a custom event on a target scene node with an optional JSON payload. | +| `get_runtime_state` | `core`, `full` | read-only | [specialist] Return structured Cocos runtime state including pause state, frame count, and scheduler time scale. Prefer this when you want a compact validation snapshot. | +| `pause_runtime` | `full` | stateful | [core] Pause Cocos director game logic execution. | +| `resume_runtime` | `full` | stateful | [core] Resume Cocos director game logic execution. | +| `set_time_scale` | `full` | mutating | [core] Set Cocos scheduler time scale for runtime validation. | + +### Scene + +| Tool | Profiles | Access | Description | +|---|---|---|---| +| `create_node` | `full` | stateful | Create a new node under the active scene or a specified parent path. | +| `delete_node` | `full` | mutating | Delete a node by path, uuid, or name. | +| `execute_scene_script` | `core`, `full` | mutating | [compat] Execute JavaScript in the active Cocos scene context. Prefer execute_javascript with context="scene" as the main unified tool; use this when you specifically want the scene-only compatibility entrypoint. | +| `find_nodes` | `full` | read-only | [core] Find scene nodes by exact name, partial path, or component type. | +| `get_hierarchy` | `core`, `full` | read-only | [specialist] Return a structured hierarchy tree from the active scene or a specific node path. Prefer execute_javascript for broader reasoning or repair; use this when you want a predictable hierarchy snapshot. | +| `get_scene_info` | `core`, `full` | read-only | [specialist] Return a structured summary of the active Cocos scene. Prefer execute_javascript for multi-step inspection or mutation; use this when you specifically want a compact scene snapshot. | +| `inspect_node` | `full` | read-only | [core] Inspect a specific node by path, uuid, or name. | +| `set_node_transform` | `full` | mutating | Update node position, rotation, scale, or active state. | + +### Screenshots + +| Tool | Profiles | Access | Description | +|---|---|---|---| +| `capture_desktop_screenshot` | `full` | read-only | [core] Capture a screenshot from the local desktop and return it as an MCP image payload. | +| `capture_editor_screenshot` | `core`, `full` | read-only | [specialist] Capture the focused Cocos Creator editor window and return it as an MCP image payload. Prefer screenshot tools only when visual verification is explicitly needed. | +| `capture_game_screenshot` | `full` | read-only | [core] Capture the Game/Preview panel region from the editor window with panel-level cropping when available. | +| `capture_preview_screenshot` | `core`, `full` | read-only | [specialist] Capture the preview or simulator window as an MCP image payload. Prefer this only when you need visual proof of game or preview output. | +| `capture_scene_screenshot` | `core`, `full` | read-only | [specialist] Capture the Scene panel region from the editor window with panel-level cropping when available. Prefer this only for visual validation of scene-side results. | + +### Selection + +| Tool | Profiles | Access | Description | +|---|---|---|---| +| `get_editor_selection` | `full` | read-only | [compat] Return the current node and asset selection in the Cocos editor. Prefer get_selection as the primary structured selection read tool. | +| `get_selection` | `core`, `full` | read-only | [specialist] Return the current editor selection in a compact structured form. Prefer this when selection state matters for the next action. | +| `set_selection` | `core`, `full` | mutating | [specialist] Set or clear the current editor selection for an asset or node. Use this when downstream editor workflows depend on selection state. | + +### Ui + +| Tool | Profiles | Access | Description | +|---|---|---|---| +| `create_button` | `full` | stateful | Create a UI Button node with child Label. | +| `create_canvas` | `full` | stateful | Create a Cocos Canvas node with UITransform. | +| `create_label` | `full` | stateful | Create a UI Label node under a parent. | +| `create_sprite` | `full` | stateful | Create a UI Sprite node, optionally assigning a SpriteFrame asset uuid. | + +### Updates + +| Tool | Profiles | Access | Description | +|---|---|---|---| +| `check_for_updates` | `core`, `full` | read-only | [specialist] Check the latest Funplay Cocos MCP GitHub release and compare it with the installed extension version. | + diff --git a/lib/server.js b/lib/server.js index a95612a..e11e627 100644 --- a/lib/server.js +++ b/lib/server.js @@ -140,11 +140,33 @@ class McpServer { const requestHandler = async (request, response) => { try { - if (request.method === 'GET' && request.url === '/health') { + const requestUrl = new URL(request.url || '/', 'http://localhost'); + if (request.method === 'GET' && requestUrl.pathname === '/health') { this.log('info', 'GET /health'); return json(response, 200, { ok: true, name: this.serverName, version: this.serverVersion }, this.negotiatedProtocolVersion); } + if (request.method === 'GET' && requestUrl.pathname === '/tools') { + this.log('info', 'GET /tools'); + const includeCatalog = requestUrl.searchParams.get('catalog') === '1'; + const tools = includeCatalog && typeof this.toolRegistry.listToolCatalog === 'function' + ? this.toolRegistry.listToolCatalog() + : this.toolRegistry.listTools(); + const baseUrl = `http://${request.headers.host || `${this.config.host}:${this.getPort()}`}`; + return json(response, 200, { + ok: true, + name: this.serverName, + version: this.serverVersion, + count: tools.length, + tools, + examples: { + health: `curl ${baseUrl}/health`, + tools: `curl ${baseUrl}/tools`, + catalog: `curl ${baseUrl}/tools?catalog=1`, + }, + }, this.negotiatedProtocolVersion); + } + if (!this.isAllowedOrigin(request)) { this.log('warn', `Rejected ${request.method} ${request.url}: invalid Origin header.`); return json(response, 403, { error: 'Forbidden: invalid Origin header' }, this.negotiatedProtocolVersion); diff --git a/lib/tool-registry.js b/lib/tool-registry.js index 4aecb74..5aa84f1 100644 --- a/lib/tool-registry.js +++ b/lib/tool-registry.js @@ -2,7 +2,6 @@ const crypto = require('crypto'); const fs = require('fs'); -const path = require('path'); const { clearSelection, deleteAsset, @@ -37,12 +36,14 @@ const { revertPrefabInstance, validatePrefabReferences, } = require('./prefabs'); +const { buildSnippet, createFileTools, refreshAssets } = require('./tools/files'); const { captureDesktopScreenshot, captureEditorWindowScreenshot, capturePanelScreenshot } = require('./screenshots'); const { checkForUpdate } = require('./update-checker'); const { safeStringify } = require('./utils'); const IMAGE_DATA_URI_PREFIX = 'data:image/png;base64,'; const TOOL_CATEGORY_RULES = [ + ['project', /^(get_project_info|get_editor_state|get_tool_catalog)$/], ['updates', /update/], ['logs', /log/], ['diagnostics', /diagnostic|validate/], @@ -60,7 +61,6 @@ const TOOL_CATEGORY_RULES = [ ['runtime', /runtime|time_scale|node_event|invoke_component/], ['scene', /scene|hierarchy|node/], ['execution', /execute_/], - ['project', /project|editor_state|tool_catalog/], ]; function createSchema(properties, required) { @@ -306,91 +306,6 @@ function toOutput(value) { return safeStringify(value); } -function matchesPattern(fileName, pattern) { - const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*'); - return new RegExp(`^${escaped}$`, 'i').test(fileName); -} - -function searchFiles(rootDir, pattern, limit) { - const results = []; - if (!fs.existsSync(rootDir)) { - return results; - } - - const stack = [rootDir]; - while (stack.length && results.length < limit) { - const current = stack.pop(); - const entries = fs.readdirSync(current, { withFileTypes: true }); - for (const entry of entries) { - if (entry.name === '.git' || entry.name === 'node_modules' || entry.name === 'temp' || entry.name === 'library') { - continue; - } - - const fullPath = path.join(current, entry.name); - if (entry.isDirectory()) { - stack.push(fullPath); - continue; - } - - if (matchesPattern(entry.name, pattern)) { - results.push(fullPath); - if (results.length >= limit) { - break; - } - } - } - } - - return results; -} - -function readLines(filePath) { - return fs.readFileSync(filePath, 'utf8').split(/\r?\n/); -} - -function buildSnippet(filePath, lineNumber, contextLines = 3) { - const lines = readLines(filePath); - const start = Math.max(1, Number(lineNumber || 1) - Math.max(0, contextLines)); - const end = Math.min(lines.length, Number(lineNumber || 1) + Math.max(0, contextLines)); - const snippet = []; - for (let line = start; line <= end; line += 1) { - const marker = line === Number(lineNumber || 1) ? '>' : ' '; - snippet.push(`${marker} ${String(line).padStart(4, ' ')} | ${lines[line - 1]}`); - } - return snippet.join('\n'); -} - -function replaceAllLiteral(content, search, replacement) { - if (!search) { - throw new Error('search text is required.'); - } - return content.split(search).join(replacement); -} - -async function refreshAssets(projectPath, targetPath) { - if (!global.Editor || !Editor.Message || typeof Editor.Message.request !== 'function') { - return 'Asset refresh API is unavailable; Cocos Creator should pick up file changes automatically.'; - } - - const relative = path.relative(path.join(projectPath, 'assets'), targetPath).replace(/\\/g, '/'); - if (!relative.startsWith('..')) { - const dbUrl = `db://assets/${relative}`; - try { - await Editor.Message.request('asset-db', 'refresh-asset', dbUrl); - return `Refreshed asset database for ${dbUrl}`; - } catch (error) { - try { - await Editor.Message.request('asset-db', 'refresh-asset', 'db://assets'); - return `Refreshed asset database after writing ${dbUrl}`; - } catch (innerError) { - return `File written, but asset refresh failed: ${innerError.message}`; - } - } - } - - return 'File written outside assets directory; no asset-db refresh was needed.'; -} - async function resolveNodeUuid(sceneBridge, args) { if (args && args.uuid) { return String(args.uuid); @@ -1337,195 +1252,7 @@ function createToolRegistry({ getRuntimeContext, getStatus, interactionLog, runt ), handler: async (args) => sceneBridge.call('stopAnimation', args), }, - { - name: 'read_file', - profile: 'full', - description: '[core] Read a file from the Cocos project.', - inputSchema: createSchema( - { - path: { type: 'string', description: 'Project-relative or absolute file path.' }, - }, - ['path'] - ), - handler: async (args) => { - const { projectPath } = getRuntimeContext(); - const fullPath = resolveProjectPath(projectPath, args.path); - if (!fs.existsSync(fullPath)) { - throw new Error(`File not found: ${args.path}`); - } - const content = fs.readFileSync(fullPath, 'utf8'); - return content.length > 12000 ? `${content.slice(0, 12000)}\n... (truncated)` : content; - }, - }, - { - name: 'get_file_snippet', - profile: 'full', - description: '[core] Read a focused snippet around a file line number.', - inputSchema: createSchema( - { - path: { type: 'string', description: 'Project-relative or absolute file path.' }, - line: { type: 'number', description: 'Target line number, starting at 1.' }, - contextLines: { type: 'number', description: 'Number of surrounding context lines.' }, - }, - ['path', 'line'] - ), - handler: async (args) => { - const { projectPath } = getRuntimeContext(); - const fullPath = resolveProjectPath(projectPath, args.path); - if (!fs.existsSync(fullPath)) { - throw new Error(`File not found: ${args.path}`); - } - return buildSnippet(fullPath, args.line, Number.isFinite(args.contextLines) ? args.contextLines : 3); - }, - }, - { - name: 'write_file', - profile: 'full', - description: '[core] Write or overwrite a file in the Cocos project.', - inputSchema: createSchema( - { - path: { type: 'string', description: 'Project-relative or absolute file path.' }, - content: { type: 'string', description: 'File content to write.' }, - }, - ['path', 'content'] - ), - handler: async (args) => { - const { projectPath } = getRuntimeContext(); - const fullPath = resolveProjectPath(projectPath, args.path); - fs.mkdirSync(path.dirname(fullPath), { recursive: true }); - fs.writeFileSync(fullPath, args.content, 'utf8'); - return `Wrote ${args.content.length} chars to ${args.path}\n${await refreshAssets(projectPath, fullPath)}`; - }, - }, - { - name: 'replace_in_file', - profile: 'full', - description: '[core] Replace text in a file, useful for script auto-fix loops.', - inputSchema: createSchema( - { - path: { type: 'string', description: 'Project-relative or absolute file path.' }, - search: { type: 'string', description: 'Literal text to search for.' }, - replace: { type: 'string', description: 'Replacement text.' }, - replaceAll: { type: 'boolean', description: 'Replace every occurrence instead of only the first.' }, - }, - ['path', 'search', 'replace'] - ), - handler: async (args) => { - const { projectPath } = getRuntimeContext(); - const fullPath = resolveProjectPath(projectPath, args.path); - if (!fs.existsSync(fullPath)) { - throw new Error(`File not found: ${args.path}`); - } - - const original = fs.readFileSync(fullPath, 'utf8'); - if (!original.includes(args.search)) { - throw new Error(`Search text was not found in ${args.path}`); - } - - const updated = args.replaceAll - ? replaceAllLiteral(original, args.search, args.replace) - : original.replace(args.search, args.replace); - - fs.writeFileSync(fullPath, updated, 'utf8'); - return `Updated ${args.path} (${args.replaceAll ? 'all matches' : 'first match'})\n${await refreshAssets(projectPath, fullPath)}`; - }, - }, - { - name: 'search_files', - profile: 'full', - description: '[core] Search project files by simple wildcard pattern.', - inputSchema: createSchema( - { - pattern: { type: 'string', description: "Wildcard file pattern such as '*.ts' or 'Player*'." }, - directory: { type: 'string', description: 'Project-relative search root. Defaults to assets.' }, - limit: { type: 'number', description: 'Maximum number of results to return.' }, - }, - ['pattern'] - ), - handler: async (args) => { - const { projectPath } = getRuntimeContext(); - const searchRoot = resolveProjectPath(projectPath, args.directory || 'assets'); - if (!fs.existsSync(searchRoot)) { - throw new Error(`Directory not found: ${args.directory || 'assets'}`); - } - - const limit = Number.isFinite(args.limit) ? Math.max(1, Math.min(500, args.limit)) : 100; - const results = searchFiles(searchRoot, args.pattern, limit).map((fullPath) => - path.relative(projectPath, fullPath).replace(/\\/g, '/') - ); - return { - count: results.length, - files: results, - }; - }, - }, - { - name: 'list_directory', - profile: 'full', - description: '[core] List files and directories inside a project directory.', - inputSchema: createSchema( - { - path: { type: 'string', description: 'Project-relative or absolute directory path.' }, - }, - ['path'] - ), - handler: async (args) => { - const { projectPath } = getRuntimeContext(); - const targetPath = resolveProjectPath(projectPath, args.path); - if (!fs.existsSync(targetPath) || !fs.statSync(targetPath).isDirectory()) { - throw new Error(`Directory not found: ${args.path}`); - } - - const entries = fs - .readdirSync(targetPath, { withFileTypes: true }) - .filter((entry) => !entry.name.startsWith('.')) - .map((entry) => ({ - name: entry.name, - type: entry.isDirectory() ? 'directory' : 'file', - })); - return { - path: args.path, - entries, - }; - }, - }, - { - name: 'exists', - profile: 'full', - description: '[core] Check whether a project file or directory exists.', - inputSchema: createSchema( - { - path: { type: 'string', description: 'Project-relative or absolute path.' }, - }, - ['path'] - ), - handler: async (args) => { - const { projectPath } = getRuntimeContext(); - const targetPath = resolveProjectPath(projectPath, args.path); - return { - path: args.path, - exists: fs.existsSync(targetPath), - isFile: fs.existsSync(targetPath) ? fs.statSync(targetPath).isFile() : false, - isDirectory: fs.existsSync(targetPath) ? fs.statSync(targetPath).isDirectory() : false, - }; - }, - }, - { - name: 'refresh_assets', - profile: 'full', - description: '[core] Best-effort asset database refresh for a file or the assets root.', - inputSchema: createSchema( - { - path: { type: 'string', description: 'Optional project-relative file path to refresh.' }, - }, - [] - ), - handler: async (args) => { - const { projectPath } = getRuntimeContext(); - const targetPath = resolveProjectPath(projectPath, args.path || 'assets'); - return await refreshAssets(projectPath, targetPath); - }, - }, + ...createFileTools({ createSchema, getRuntimeContext }), { name: 'run_script_diagnostics', profile: 'core', diff --git a/lib/tools/files.js b/lib/tools/files.js new file mode 100644 index 0000000..9cb44ca --- /dev/null +++ b/lib/tools/files.js @@ -0,0 +1,303 @@ +'use strict'; + +const fs = require('fs'); +const path = require('path'); +const { resolveProjectPath } = require('../path-safety'); + +/** + * Build a focused, line-numbered snippet around one file line. + * @param {string} filePath Absolute file path. + * @param {number} lineNumber One-based target line number. + * @param {number} contextLines Surrounding lines to include. + * @returns {string} + */ +function buildSnippet(filePath, lineNumber, contextLines = 3) { + const lines = fs.readFileSync(filePath, 'utf8').split(/\r?\n/); + const start = Math.max(1, Number(lineNumber || 1) - Math.max(0, contextLines)); + const end = Math.min(lines.length, Number(lineNumber || 1) + Math.max(0, contextLines)); + const snippet = []; + for (let line = start; line <= end; line += 1) { + const marker = line === Number(lineNumber || 1) ? '>' : ' '; + snippet.push(`${marker} ${String(line).padStart(4, ' ')} | ${lines[line - 1]}`); + } + return snippet.join('\n'); +} + +function matchesPattern(fileName, pattern) { + const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*'); + return new RegExp(`^${escaped}$`, 'i').test(fileName); +} + +function searchFiles(rootDir, pattern, limit) { + const results = []; + if (!fs.existsSync(rootDir)) { + return results; + } + + const stack = [rootDir]; + while (stack.length && results.length < limit) { + const current = stack.pop(); + const entries = fs.readdirSync(current, { withFileTypes: true }); + for (const entry of entries) { + if (entry.name === '.git' || entry.name === 'node_modules' || entry.name === 'temp' || entry.name === 'library') { + continue; + } + + const fullPath = path.join(current, entry.name); + if (entry.isDirectory()) { + stack.push(fullPath); + continue; + } + + if (matchesPattern(entry.name, pattern)) { + results.push(fullPath); + if (results.length >= limit) { + break; + } + } + } + } + + return results; +} + +function replaceAllLiteral(content, search, replacement) { + if (!search) { + throw new Error('search text is required.'); + } + return content.split(search).join(replacement); +} + +/** + * Best-effort refresh for Cocos asset database after external file edits. + * @param {string} projectPath Active Cocos project root. + * @param {string} targetPath Absolute file or directory path. + * @returns {Promise} + */ +async function refreshAssets(projectPath, targetPath) { + if (!global.Editor || !Editor.Message || typeof Editor.Message.request !== 'function') { + return 'Asset refresh API is unavailable; Cocos Creator should pick up file changes automatically.'; + } + + const relative = path.relative(path.join(projectPath, 'assets'), targetPath).replace(/\\/g, '/'); + if (!relative.startsWith('..')) { + const dbUrl = `db://assets/${relative}`; + try { + await Editor.Message.request('asset-db', 'refresh-asset', dbUrl); + return `Refreshed asset database for ${dbUrl}`; + } catch (error) { + try { + await Editor.Message.request('asset-db', 'refresh-asset', 'db://assets'); + return `Refreshed asset database after writing ${dbUrl}`; + } catch (innerError) { + return `File written, but asset refresh failed: ${innerError.message}`; + } + } + } + + return 'File written outside assets directory; no asset-db refresh was needed.'; +} + +/** + * File-system tools. Kept separate from the registry so path safety and asset + * refresh behavior can be tested and evolved independently. + */ +function createFileTools({ createSchema, getRuntimeContext }) { + return [ + { + name: 'read_file', + profile: 'full', + description: '[core] Read a file from the Cocos project.', + inputSchema: createSchema( + { + path: { type: 'string', description: 'Project-relative or absolute file path.' }, + }, + ['path'] + ), + handler: async (args) => { + const { projectPath } = getRuntimeContext(); + const fullPath = resolveProjectPath(projectPath, args.path); + if (!fs.existsSync(fullPath)) { + throw new Error(`File not found: ${args.path}`); + } + const content = fs.readFileSync(fullPath, 'utf8'); + return content.length > 12000 ? `${content.slice(0, 12000)}\n... (truncated)` : content; + }, + }, + { + name: 'get_file_snippet', + profile: 'full', + description: '[core] Read a focused snippet around a file line number.', + inputSchema: createSchema( + { + path: { type: 'string', description: 'Project-relative or absolute file path.' }, + line: { type: 'number', description: 'Target line number, starting at 1.' }, + contextLines: { type: 'number', description: 'Number of surrounding context lines.' }, + }, + ['path', 'line'] + ), + handler: async (args) => { + const { projectPath } = getRuntimeContext(); + const fullPath = resolveProjectPath(projectPath, args.path); + if (!fs.existsSync(fullPath)) { + throw new Error(`File not found: ${args.path}`); + } + return buildSnippet(fullPath, args.line, Number.isFinite(args.contextLines) ? args.contextLines : 3); + }, + }, + { + name: 'write_file', + profile: 'full', + description: '[core] Write or overwrite a file in the Cocos project.', + inputSchema: createSchema( + { + path: { type: 'string', description: 'Project-relative or absolute file path.' }, + content: { type: 'string', description: 'File content to write.' }, + }, + ['path', 'content'] + ), + handler: async (args) => { + const { projectPath } = getRuntimeContext(); + const fullPath = resolveProjectPath(projectPath, args.path); + fs.mkdirSync(path.dirname(fullPath), { recursive: true }); + fs.writeFileSync(fullPath, args.content, 'utf8'); + return `Wrote ${args.content.length} chars to ${args.path}\n${await refreshAssets(projectPath, fullPath)}`; + }, + }, + { + name: 'replace_in_file', + profile: 'full', + description: '[core] Replace text in a file, useful for script auto-fix loops.', + inputSchema: createSchema( + { + path: { type: 'string', description: 'Project-relative or absolute file path.' }, + search: { type: 'string', description: 'Literal text to search for.' }, + replace: { type: 'string', description: 'Replacement text.' }, + replaceAll: { type: 'boolean', description: 'Replace every occurrence instead of only the first.' }, + }, + ['path', 'search', 'replace'] + ), + handler: async (args) => { + const { projectPath } = getRuntimeContext(); + const fullPath = resolveProjectPath(projectPath, args.path); + if (!fs.existsSync(fullPath)) { + throw new Error(`File not found: ${args.path}`); + } + + const original = fs.readFileSync(fullPath, 'utf8'); + if (!original.includes(args.search)) { + throw new Error(`Search text was not found in ${args.path}`); + } + + const updated = args.replaceAll + ? replaceAllLiteral(original, args.search, args.replace) + : original.replace(args.search, args.replace); + + fs.writeFileSync(fullPath, updated, 'utf8'); + return `Updated ${args.path} (${args.replaceAll ? 'all matches' : 'first match'})\n${await refreshAssets(projectPath, fullPath)}`; + }, + }, + { + name: 'search_files', + profile: 'full', + description: '[core] Search project files by simple wildcard pattern.', + inputSchema: createSchema( + { + pattern: { type: 'string', description: "Wildcard file pattern such as '*.ts' or 'Player*'." }, + directory: { type: 'string', description: 'Project-relative search root. Defaults to assets.' }, + limit: { type: 'number', description: 'Maximum number of results to return.' }, + }, + ['pattern'] + ), + handler: async (args) => { + const { projectPath } = getRuntimeContext(); + const searchRoot = resolveProjectPath(projectPath, args.directory || 'assets'); + if (!fs.existsSync(searchRoot)) { + throw new Error(`Directory not found: ${args.directory || 'assets'}`); + } + + const limit = Number.isFinite(args.limit) ? Math.max(1, Math.min(500, args.limit)) : 100; + const results = searchFiles(searchRoot, args.pattern, limit).map((fullPath) => + path.relative(projectPath, fullPath).replace(/\\/g, '/') + ); + return { + count: results.length, + files: results, + }; + }, + }, + { + name: 'list_directory', + profile: 'full', + description: '[core] List files and directories inside a project directory.', + inputSchema: createSchema( + { + path: { type: 'string', description: 'Project-relative or absolute directory path.' }, + }, + ['path'] + ), + handler: async (args) => { + const { projectPath } = getRuntimeContext(); + const targetPath = resolveProjectPath(projectPath, args.path); + if (!fs.existsSync(targetPath) || !fs.statSync(targetPath).isDirectory()) { + throw new Error(`Directory not found: ${args.path}`); + } + + const entries = fs + .readdirSync(targetPath, { withFileTypes: true }) + .filter((entry) => !entry.name.startsWith('.')) + .map((entry) => ({ + name: entry.name, + type: entry.isDirectory() ? 'directory' : 'file', + })); + return { + path: args.path, + entries, + }; + }, + }, + { + name: 'exists', + profile: 'full', + description: '[core] Check whether a project file or directory exists.', + inputSchema: createSchema( + { + path: { type: 'string', description: 'Project-relative or absolute path.' }, + }, + ['path'] + ), + handler: async (args) => { + const { projectPath } = getRuntimeContext(); + const targetPath = resolveProjectPath(projectPath, args.path); + return { + path: args.path, + exists: fs.existsSync(targetPath), + isFile: fs.existsSync(targetPath) ? fs.statSync(targetPath).isFile() : false, + isDirectory: fs.existsSync(targetPath) ? fs.statSync(targetPath).isDirectory() : false, + }; + }, + }, + { + name: 'refresh_assets', + profile: 'full', + description: '[core] Best-effort asset database refresh for a file or the assets root.', + inputSchema: createSchema( + { + path: { type: 'string', description: 'Optional project-relative file path to refresh.' }, + }, + [] + ), + handler: async (args) => { + const { projectPath } = getRuntimeContext(); + const targetPath = resolveProjectPath(projectPath, args.path || 'assets'); + return await refreshAssets(projectPath, targetPath); + }, + }, + ]; +} + +module.exports = { + buildSnippet, + createFileTools, + refreshAssets, +}; diff --git a/package.json b/package.json index cbddbdd..cb5033c 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "funplay-cocos-mcp", "package_version": 2, - "version": "0.3.2", + "version": "0.3.3", "description": "Embedded MCP server for Cocos Creator with scene script execution, project resources, prompts, and file/scene tools.", "author": "Funplay", "license": "MIT", @@ -31,6 +31,7 @@ }, "files": [ "bin/", + "docs/", "lib/", "panel/", "browser.js", @@ -48,11 +49,13 @@ "access": "public" }, "scripts": { - "check": "node --check browser.js && node --check scene.js && node --check panel/index.js && node --check bin/funplay-cocos-mcp.js && node --check lib/assets.js && node --check lib/client-config.js && node --check lib/config.js && node --check lib/diagnostics.js && node --check lib/electron-tools.js && node --check lib/input.js && node --check lib/interaction-log.js && node --check lib/logs.js && node --check lib/path-safety.js && node --check lib/prefabs.js && node --check lib/project-instructions.js && node --check lib/prompts.js && node --check lib/resources.js && node --check lib/runtime-log.js && node --check lib/screenshots.js && node --check lib/server.js && node --check lib/tool-registry.js && node --check lib/update-checker.js && node --check lib/utils.js && node --check scripts/release.js", + "check": "node --check browser.js && node --check scene.js && node --check panel/index.js && node --check bin/funplay-cocos-mcp.js && node --check lib/assets.js && node --check lib/client-config.js && node --check lib/config.js && node --check lib/diagnostics.js && node --check lib/electron-tools.js && node --check lib/input.js && node --check lib/interaction-log.js && node --check lib/logs.js && node --check lib/path-safety.js && node --check lib/prefabs.js && node --check lib/project-instructions.js && node --check lib/prompts.js && node --check lib/resources.js && node --check lib/runtime-log.js && node --check lib/screenshots.js && node --check lib/server.js && node --check lib/tool-registry.js && node --check lib/tools/files.js && node --check lib/update-checker.js && node --check lib/utils.js && node --check scripts/generate-tool-docs.js && node --check scripts/release.js", "test": "node --test", + "docs:generate": "node scripts/generate-tool-docs.js", + "docs:check": "node scripts/generate-tool-docs.js --check", "pack:dry-run": "npm pack --dry-run", "registry:validate": "mcp-publisher validate server.json", - "release:check": "node scripts/release.js check", + "release:check": "node scripts/release.js check && npm run docs:check", "release:package": "node scripts/release.js package", "release:verify": "npm run check && npm test && npm run release:check && npm run pack:dry-run && npm run release:package" }, diff --git a/panel/index.js b/panel/index.js index c2d74ac..bfb806f 100644 --- a/panel/index.js +++ b/panel/index.js @@ -34,6 +34,8 @@ module.exports = Editor.Panel.define({ Restart Copy URL + Copy Health Curl + Copy Tools Curl Check Updates
@@ -70,6 +72,20 @@ module.exports = Editor.Panel.define({
+
+

Activity

+
+
+

Recent Calls

+
+
+
+

Log Preview

+
+
+
+
+

MCP Client Config

@@ -114,6 +130,12 @@ module.exports = Editor.Panel.define({ margin: 0 0 10px 0; font-size: 15px; } + h3 { + margin: 0 0 6px 0; + font-size: 12px; + font-weight: 600; + color: var(--color-normal-contrast-weak); + } p { margin: 4px 0 0 0; color: var(--color-normal-contrast-weakest); @@ -199,6 +221,46 @@ module.exports = Editor.Panel.define({ white-space: pre-wrap; word-break: break-all; } + .activity-grid { + display: grid; + grid-template-columns: repeat(2, minmax(180px, 1fr)); + gap: 10px; + } + .mini-list { + min-height: 74px; + max-height: 158px; + overflow: auto; + box-sizing: border-box; + border: 1px solid var(--color-normal-border); + border-radius: 6px; + padding: 8px; + background: rgba(0,0,0,0.12); + line-height: 1.35; + } + .mini-item { + padding: 0 0 8px 0; + margin-bottom: 8px; + border-bottom: 1px solid rgba(255,255,255,0.08); + } + .mini-item:last-child { + margin-bottom: 0; + padding-bottom: 0; + border-bottom: none; + } + .mini-title { + color: var(--color-normal-contrast); + font-weight: 600; + word-break: break-word; + } + .mini-meta { + margin-top: 2px; + color: var(--color-normal-contrast-weakest); + font-size: 11px; + } + .mini-body { + margin-top: 2px; + word-break: break-word; + } details { display: block; } @@ -232,6 +294,11 @@ module.exports = Editor.Panel.define({ .primary { border-color: #4aa3ff; } + @media (max-width: 620px) { + .activity-grid { + grid-template-columns: 1fr; + } + } `, $: { root: '.mcp-root', @@ -244,6 +311,8 @@ module.exports = Editor.Panel.define({ sessionsInput: '#sessionsInput', restartBtn: '#restartBtn', copyUrlBtn: '#copyUrlBtn', + copyHealthCurlBtn: '#copyHealthCurlBtn', + copyToolsCurlBtn: '#copyToolsCurlBtn', checkUpdatesBtn: '#checkUpdatesBtn', updateStatus: '#updateStatus', toolSummary: '#toolSummary', @@ -258,6 +327,8 @@ module.exports = Editor.Panel.define({ configureClientBtn: '#configureClientBtn', clientTargetStatus: '#clientTargetStatus', clientConfigText: '#clientConfigText', + recentCalls: '#recentCalls', + recentLogs: '#recentLogs', output: '#output', }, methods: { @@ -295,9 +366,8 @@ module.exports = Editor.Panel.define({ this.$.disabledToolsInput.value = this.formatList(config.disabledTools); this.renderUpdateStatus(); this.renderToolSummary(); - - this.$.clientConfigText.value = state.clientConfig ? state.clientConfig.codex : ''; this.renderClientTargets(); + this.renderActivity(); }, formatList(value) { return Array.isArray(value) ? value.join('\n') : ''; @@ -329,6 +399,75 @@ module.exports = Editor.Panel.define({ this.$.toolSummary.textContent = `Enabled ${enabled.length}/${catalog.length} tools | Categories: ${categories.join(', ')}`; }, + renderActivity() { + const state = this.state || {}; + this.renderMiniList( + this.$.recentCalls, + state.recentInteractions || [], + (entry) => ({ + title: `${String(entry.status || '').toUpperCase()} ${entry.toolName || 'tool'}`, + meta: this.formatTimestamp(entry.timestamp), + body: entry.summary || '', + }), + 'No recent MCP calls.' + ); + this.renderMiniList( + this.$.recentLogs, + state.recentRuntimeLogs || [], + (entry) => ({ + title: `${String(entry.level || 'info').toUpperCase()} ${entry.message || ''}`, + meta: this.formatTimestamp(entry.timestamp), + body: entry.details ? stringify(entry.details) : '', + }), + 'No runtime logs yet.' + ); + }, + renderMiniList(container, entries, formatEntry, emptyText) { + container.innerHTML = ''; + if (!entries.length) { + container.textContent = emptyText; + return; + } + + const fragment = document.createDocumentFragment(); + entries.slice(0, 6).forEach((entry) => { + const formatted = formatEntry(entry); + const item = document.createElement('div'); + item.className = 'mini-item'; + + const title = document.createElement('div'); + title.className = 'mini-title'; + title.textContent = formatted.title; + item.appendChild(title); + + if (formatted.meta) { + const meta = document.createElement('div'); + meta.className = 'mini-meta'; + meta.textContent = formatted.meta; + item.appendChild(meta); + } + + if (formatted.body) { + const body = document.createElement('div'); + body.className = 'mini-body'; + body.textContent = formatted.body; + item.appendChild(body); + } + + fragment.appendChild(item); + }); + container.appendChild(fragment); + }, + formatTimestamp(value) { + if (!value) { + return ''; + } + const date = new Date(value); + if (Number.isNaN(date.getTime())) { + return String(value); + } + return date.toLocaleTimeString(); + }, renderClientTargets() { const targets = (this.state && this.state.clientTargets) || []; const preferred = this.state && this.state.config ? this.state.config.lastClientTargetId : ''; @@ -349,10 +488,30 @@ module.exports = Editor.Panel.define({ return; } this.$.clientTargetStatus.textContent = `${target.configured ? 'Configured' : 'Not configured'}: ${target.configPath}`; + const previews = this.state && this.state.clientConfig && Array.isArray(this.state.clientConfig.targets) + ? this.state.clientConfig.targets + : []; + const preview = previews.find((item) => item.id === target.id); + this.$.clientConfigText.value = preview && preview.preview + ? preview.preview + : (this.state && this.state.clientConfig ? this.state.clientConfig.codex : ''); }, showOutput(value) { this.$.output.textContent = stringify(value); }, + copyText(text, successMessage) { + if (!text) { + this.showOutput('Nothing to copy.'); + return; + } + navigator.clipboard.writeText(text) + .then(() => this.showOutput(successMessage)) + .catch(() => this.showOutput(text)); + }, + getCurlCommand(key) { + const curl = this.state && this.state.clientConfig && this.state.clientConfig.curl; + return curl && curl[key] ? curl[key] : ''; + }, async persistConfig(options = {}) { const { showOutput = false } = options; try { @@ -415,9 +574,13 @@ module.exports = Editor.Panel.define({ this.$.copyUrlBtn.addEventListener('click', () => { const status = this.state && this.state.status; const text = status && status.url ? status.url : ''; - navigator.clipboard.writeText(text) - .then(() => this.showOutput('Copied URL to clipboard.')) - .catch(() => this.showOutput(text)); + this.copyText(text, 'Copied URL to clipboard.'); + }); + this.$.copyHealthCurlBtn.addEventListener('click', () => { + this.copyText(this.getCurlCommand('health'), 'Copied health curl command.'); + }); + this.$.copyToolsCurlBtn.addEventListener('click', () => { + this.copyText(this.getCurlCommand('tools'), 'Copied tools curl command.'); }); this.$.checkUpdatesBtn.addEventListener('click', () => this.runAction(() => request('check-updates'))); this.$.enabledInput.addEventListener('change', () => this.handleEnableToggle()); diff --git a/scripts/generate-tool-docs.js b/scripts/generate-tool-docs.js new file mode 100644 index 0000000..d35b8c3 --- /dev/null +++ b/scripts/generate-tool-docs.js @@ -0,0 +1,164 @@ +#!/usr/bin/env node +'use strict'; + +const fs = require('fs'); +const path = require('path'); +const { createToolRegistry } = require('../lib/tool-registry'); + +const ROOT = path.resolve(__dirname, '..'); +const OUTPUT_PATH = path.join(ROOT, 'docs', 'TOOLS.md'); + +function createRegistry(profile) { + return createToolRegistry({ + getRuntimeContext: () => ({ + config: { toolProfile: profile }, + projectPath: '/tmp/funplay-cocos-docs-project', + version: '0.0.0-docs', + }), + interactionLog: { add() {} }, + runtimeLog: { add() {}, list: () => [], clear: () => 0 }, + sceneBridge: { call: async () => ({ ok: true }) }, + editorExecutor: async () => ({ ok: true }), + }); +} + +function buildToolModel() { + const fullRegistry = createRegistry('full'); + const coreNames = new Set(createRegistry('core').listTools().map((tool) => tool.name)); + const fullNames = new Set(fullRegistry.listTools().map((tool) => tool.name)); + const catalog = fullRegistry.listToolCatalog() + .map((tool) => ({ + name: tool.name, + category: tool.category || 'other', + profile: tool.profile || 'full', + enabledInCore: coreNames.has(tool.name), + enabledInFull: fullNames.has(tool.name), + readOnly: Boolean(tool.annotations && tool.annotations.readOnlyHint), + destructive: Boolean(tool.annotations && tool.annotations.destructiveHint), + description: normalizeDescription(tool.description), + })) + .sort((a, b) => a.category.localeCompare(b.category) || a.name.localeCompare(b.name)); + + return { + coreCount: coreNames.size, + fullCount: fullNames.size, + catalog, + }; +} + +function normalizeDescription(description) { + return String(description || '') + .replace(/\s+/g, ' ') + .trim(); +} + +function buildMarkdown(model) { + const categories = groupBy(model.catalog, (tool) => tool.category); + const lines = [ + '# Tool Reference', + '', + '', + '', + `Generated from \`lib/tool-registry.js\`. The default \`core\` profile exposes ${model.coreCount} tools; the \`full\` profile exposes ${model.fullCount} tools.`, + '', + '## Profile Summary', + '', + '| Profile | Tool Count | Purpose |', + '|---|---:|---|', + `| \`core\` | ${model.coreCount} | Focused default surface for common editor automation. |`, + `| \`full\` | ${model.fullCount} | All built-in tools, including destructive and low-level helpers. |`, + '', + '## Core Tools', + '', + model.catalog + .filter((tool) => tool.enabledInCore) + .sort((a, b) => a.name.localeCompare(b.name)) + .map((tool) => `\`${tool.name}\``) + .join(', '), + '', + '## Tools By Category', + '', + ]; + + for (const category of Object.keys(categories).sort()) { + const tools = categories[category]; + lines.push(`### ${titleCase(category)}`); + lines.push(''); + lines.push('| Tool | Profiles | Access | Description |'); + lines.push('|---|---|---|---|'); + for (const tool of tools) { + lines.push(`| \`${tool.name}\` | ${profileLabel(tool)} | ${accessLabel(tool)} | ${escapeTableCell(tool.description)} |`); + } + lines.push(''); + } + + return `${lines.join('\n').replace(/\n{3,}/g, '\n\n')}\n`; +} + +function profileLabel(tool) { + return tool.enabledInCore ? '`core`, `full`' : '`full`'; +} + +function accessLabel(tool) { + if (tool.readOnly) { + return 'read-only'; + } + if (tool.destructive) { + return 'mutating'; + } + return 'stateful'; +} + +function titleCase(value) { + return String(value) + .split(/[-_\s]+/) + .filter(Boolean) + .map((part) => `${part.charAt(0).toUpperCase()}${part.slice(1)}`) + .join(' '); +} + +function escapeTableCell(value) { + return String(value || '') + .replace(/\|/g, '\\|') + .replace(/\n/g, '
'); +} + +function groupBy(values, getKey) { + return values.reduce((groups, value) => { + const key = getKey(value); + if (!groups[key]) { + groups[key] = []; + } + groups[key].push(value); + return groups; + }, {}); +} + +function writeDocs(markdown) { + const directory = path.dirname(OUTPUT_PATH); + if (!fs.existsSync(directory)) { + fs.mkdirSync(directory, { recursive: true }); + } + fs.writeFileSync(OUTPUT_PATH, markdown, 'utf8'); +} + +function checkDocs(markdown) { + const existing = fs.existsSync(OUTPUT_PATH) ? fs.readFileSync(OUTPUT_PATH, 'utf8') : ''; + if (existing !== markdown) { + console.error('docs/TOOLS.md is out of date. Run `npm run docs:generate`.'); + process.exitCode = 1; + } +} + +function main() { + const args = process.argv.slice(2); + const markdown = buildMarkdown(buildToolModel()); + if (args.includes('--check')) { + checkDocs(markdown); + return; + } + writeDocs(markdown); + console.log(`Wrote ${path.relative(ROOT, OUTPUT_PATH)}`); +} + +main(); diff --git a/scripts/release.js b/scripts/release.js index 097bf92..58ccab7 100644 --- a/scripts/release.js +++ b/scripts/release.js @@ -17,6 +17,7 @@ const REQUIRED_REPO_FILES = [ 'package.json', 'README.md', 'README_CN.md', + 'docs/TOOLS.md', 'RELEASE_WORKFLOW.md', 'RELEASE_CHECKLIST.md', 'CHANGELOG.md', @@ -35,6 +36,7 @@ const PACKAGE_INCLUDES = [ 'package.json', 'README.md', 'README_CN.md', + 'docs', 'CHANGELOG.md', 'CONTRIBUTING.md', 'LICENSE', diff --git a/server.json b/server.json index 0d0a5d2..e5277ac 100644 --- a/server.json +++ b/server.json @@ -7,12 +7,12 @@ "url": "https://github.com/FunplayAI/funplay-cocos-mcp", "source": "github" }, - "version": "0.3.2", + "version": "0.3.3", "packages": [ { "registryType": "npm", "identifier": "funplay-cocos-mcp", - "version": "0.3.2", + "version": "0.3.3", "transport": { "type": "stdio" }, diff --git a/test/server.test.js b/test/server.test.js index 4d378d5..7143181 100644 --- a/test/server.test.js +++ b/test/server.test.js @@ -66,6 +66,33 @@ function httpJson(port, payload, headers = {}) { }); } +function httpGet(port, path = '/', headers = {}) { + return new Promise((resolve, reject) => { + const request = http.request( + { + host: '127.0.0.1', + port, + method: 'GET', + path, + headers, + }, + (response) => { + const chunks = []; + response.on('data', (chunk) => chunks.push(chunk)); + response.on('end', () => { + resolve({ + statusCode: response.statusCode, + headers: response.headers, + body: Buffer.concat(chunks).toString('utf8'), + }); + }); + } + ); + request.on('error', reject); + request.end(); + }); +} + test('initialize negotiates the current MCP protocol version by default', async () => { const server = createServer(); const response = await server.handleRpcRequest({ @@ -222,6 +249,43 @@ test('HTTP notifications return 202 Accepted with no body', async () => { } }); +test('HTTP GET /tools returns debug tool metadata and curl examples', async () => { + const server = createServer({ + listTools: () => [ + { + name: 'get_project_info', + description: 'Return project info.', + inputSchema: { type: 'object', properties: {} }, + }, + ], + listToolCatalog: () => [ + { + name: 'get_project_info', + description: 'Return project info.', + category: 'project', + profile: 'core', + enabled: true, + }, + ], + }, { port: 0 }); + await server.start(); + try { + const response = await httpGet(server.getPort(), '/tools?catalog=1'); + const payload = JSON.parse(response.body); + + assert.equal(response.statusCode, 200); + assert.equal(payload.ok, true); + assert.equal(payload.name, 'test-server'); + assert.equal(payload.version, '0.0.0-test'); + assert.equal(payload.count, 1); + assert.equal(payload.tools[0].category, 'project'); + assert.match(payload.examples.health, /curl http:\/\/127\.0\.0\.1:\d+\/health/); + assert.match(payload.examples.tools, /curl http:\/\/127\.0\.0\.1:\d+\/tools/); + } finally { + await server.stop(); + } +}); + test('HTTP initialize can return an optional session id when sessions are enabled', async () => { const server = createServer({}, { port: 0, enableSessions: true }); await server.start(); diff --git a/test/tools-files.test.js b/test/tools-files.test.js new file mode 100644 index 0000000..95bd542 --- /dev/null +++ b/test/tools-files.test.js @@ -0,0 +1,83 @@ +'use strict'; + +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const test = require('node:test'); +const { buildSnippet, createFileTools } = require('../lib/tools/files'); + +function createSchema(properties, required) { + return { type: 'object', properties, required }; +} + +function createTools(projectPath) { + return createFileTools({ + createSchema, + getRuntimeContext: () => ({ projectPath }), + }); +} + +function getTool(tools, name) { + const tool = tools.find((item) => item.name === name); + assert.ok(tool, `Expected ${name} to exist`); + return tool; +} + +test('buildSnippet returns focused line-numbered context', () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'funplay-cocos-files-')); + try { + const filePath = path.join(root, 'sample.ts'); + fs.writeFileSync(filePath, ['alpha', 'beta', 'gamma', 'delta'].join('\n'), 'utf8'); + + const snippet = buildSnippet(filePath, 2, 1); + + assert.match(snippet, / 1 \| alpha/); + assert.match(snippet, />\s+2 \| beta/); + assert.match(snippet, / 3 \| gamma/); + assert.doesNotMatch(snippet, /delta/); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); + +test('file tools write, read, replace, search, list, and check project files', async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'funplay-cocos-files-')); + try { + fs.mkdirSync(path.join(root, 'assets'), { recursive: true }); + const tools = createTools(root); + + const writeResult = await getTool(tools, 'write_file').handler({ + path: 'assets/player.ts', + content: 'const name = "Hero";\nconst clone = "Hero";\n', + }); + assert.match(writeResult, /Wrote \d+ chars/); + + const readResult = await getTool(tools, 'read_file').handler({ path: 'assets/player.ts' }); + assert.match(readResult, /const name = "Hero"/); + + await getTool(tools, 'replace_in_file').handler({ + path: 'assets/player.ts', + search: 'Hero', + replace: 'Player', + replaceAll: true, + }); + assert.equal(fs.readFileSync(path.join(root, 'assets', 'player.ts'), 'utf8').includes('Hero'), false); + + const searchResult = await getTool(tools, 'search_files').handler({ pattern: '*.ts', directory: 'assets' }); + assert.deepEqual(searchResult.files, ['assets/player.ts']); + + const listResult = await getTool(tools, 'list_directory').handler({ path: 'assets' }); + assert.deepEqual(listResult.entries, [{ name: 'player.ts', type: 'file' }]); + + const existsResult = await getTool(tools, 'exists').handler({ path: 'assets/player.ts' }); + assert.deepEqual(existsResult, { + path: 'assets/player.ts', + exists: true, + isFile: true, + isDirectory: false, + }); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +});