From 3ab48e245724136d86f66d632eeb535efd95579f Mon Sep 17 00:00:00 2001 From: winlifes <110321103+Winlifes@users.noreply.github.com> Date: Wed, 20 May 2026 06:06:36 -0700 Subject: [PATCH] Release v0.2.0 --- CHANGELOG.md | 22 +++ README.md | 43 +++--- README_CN.md | 43 +++--- browser.js | 141 +++++++++++++++----- lib/config.js | 33 ++++- lib/interaction-log.js | 6 + lib/logs.js | 206 ++++++++++++++++++++++++++++ lib/resources.js | 41 +++++- lib/runtime-log.js | 46 +++++++ lib/server.js | 256 +++++++++++++++++++++++++++++++---- lib/tool-registry.js | 258 +++++++++++++++++++++++++++++++++++- lib/update-checker.js | 109 +++++++++++++++ package.json | 9 +- panel/index.js | 116 +++++++++++++++- test/logs.test.js | 23 ++++ test/server.test.js | 117 +++++++++++++++- test/tool-registry.test.js | 32 ++++- test/update-checker.test.js | 15 +++ 18 files changed, 1417 insertions(+), 99 deletions(-) create mode 100644 lib/logs.js create mode 100644 lib/runtime-log.js create mode 100644 lib/update-checker.js create mode 100644 test/logs.test.js create mode 100644 test/update-checker.test.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 9838a3c..1596a22 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,28 @@ This project follows a simple changelog format inspired by [Keep a Changelog](ht ## [Unreleased] +## [0.2.0] - 2026-05-20 + +### Added + +- Added a panel and tool-level update checker for comparing the installed extension version with the latest GitHub release. +- Added `custom` tool exposure with per-category and per-tool include/exclude configuration. +- Added `get_tool_catalog`, `check_for_updates`, `get_recent_logs`, `search_project_logs`, `clear_logs`, and `validate_scene`. +- Added MCP log resources: `cocos://logs/editor` and `cocos://logs/project`. +- Added in-memory runtime logs alongside the existing MCP interaction history. +- Added optional Streamable HTTP session support via `enableSessions` and `Mcp-Session-Id`. + +### Changed + +- Expanded the default `core` profile from 22 tools to 28 tools. +- Expanded the `full` profile from 70 tools to 76 tools. +- Updated the Cocos panel to show installed version, update status, session toggle, and tool exposure controls. +- Tightened Streamable HTTP behavior for `Accept` headers, JSON-RPC notifications/responses, `202 Accepted`, unsupported GET/SSE requests, and DELETE session termination. + +### Fixed + +- Improved project log tailing so trailing blank lines do not hide the last useful log entries. + ## [0.1.4] - 2026-05-11 ### Fixed diff --git a/README.md b/README.md index 8291e14..89ba032 100644 --- a/README.md +++ b/README.md @@ -69,7 +69,9 @@ The panel is intentionally small: - Enable or disable the MCP server - Change the server port -- Switch tool exposure between `core` and `full` +- Switch tool exposure between `core`, `full`, and `custom` +- Check the installed version against the latest GitHub release +- Tune tool exposure by category or individual tool - Configure AI clients with one click - Expand debug output only when needed @@ -197,7 +199,9 @@ Try a higher-level prompt in your AI client: - This extension is **Editor-only**. It is meant to automate Cocos Creator, not to add runtime dependencies to your final game build. - The MCP server listens on `http://127.0.0.1:8765/` by default. - If the configured port is busy, the server automatically falls back to the next available port and the panel/client config use the actual running port. -- The default `core` profile exposes 22 high-signal tools. Switch to `full` in the panel if you want all 70 tools exposed. +- The default `core` profile exposes 28 high-signal tools. Switch to `full` for all 76 tools, or use `custom` to include/exclude tool categories and individual tools. +- The panel includes a manual update check against the latest GitHub release. +- Streamable HTTP responses follow the MCP transport requirements for `Accept`, `MCP-Protocol-Version`, JSON-RPC notifications/responses, and optional `Mcp-Session-Id` sessions. - All exposed MCP tools execute directly. There is no extra approval toggle inside the Cocos extension. - File tools and `cocos://asset/path/...` resources are restricted to the active Cocos project root. - The recommended workflow is `execute_javascript` first, then focused helper tools for screenshots, diagnostics, assets, and inspection. @@ -208,16 +212,16 @@ Try a higher-level prompt in your AI client: - **`execute_javascript` First** — One high-flexibility JavaScript tool can orchestrate scene/runtime work and editor-side automation without flooding AI clients with too many narrow tool calls - **Embedded Cocos Extension** — No separate Python daemon or external bridge process is required for the Cocos-side plugin - **One-Click Client Configuration** — Configure Claude Code, Cursor, VS Code, Trae, Kiro, and Codex directly from Cocos Creator -- **Project Context Built In** — Exposes live project, scene, selection, script diagnostics, and interaction-history resources -- **Focused by Default, Full When Needed** — `core` reduces tool-list noise; `full` exposes every available tool +- **Project Context Built In** — Exposes live project, scene, selection, script diagnostics, logs, and interaction-history resources +- **Focused by Default, Full When Needed** — `core` reduces tool-list noise; `full` exposes every available tool; `custom` lets you tune by category or tool - **Visual Validation** — Scene/editor/preview screenshots and input simulation help AI verify UI and gameplay changes ## Highlights -- **70 Built-in Tools** — Scene hierarchy, editor state, selection workflows, assets, UI creation, components, files, script diagnostics, screenshots, runtime control, and input simulation +- **76 Built-in Tools** — Scene hierarchy, editor state, selection workflows, assets, UI creation, components, files, logs, script diagnostics, screenshots, runtime control, and input simulation - **Primary Unified Tool** — `execute_javascript` supports both `scene` and `editor` contexts -- **Resources & Prompts** — Live project resources plus reusable workflows like script fixing, scene validation, and playable prototype creation -- **Cocos Panel UI** — A minimal `Funplay > MCP Server` panel for service management and MCP client setup +- **Resources & Prompts** — Live project/log resources plus reusable workflows like script fixing, scene validation, and playable prototype creation +- **Cocos Panel UI** — A minimal `Funplay > MCP Server` panel for service management, update checks, tool exposure, and MCP client setup - **Screenshot and Input Support** — Capture editor/scene/game/preview screenshots and send Electron-level mouse/keyboard events - **Vendor Agnostic** — Works with any AI client that supports MCP over HTTP JSON-RPC @@ -231,20 +235,20 @@ Funplay MCP for Cocos follows the same design principles as Funplay MCP for Unit | Embedded server | Built-in HTTP MCP server | Built-in HTTP MCP server | | Primary execution tool | `execute_javascript` | `execute_code` | | Primary language | JavaScript in scene/editor contexts | C# in Unity editor/runtime contexts | -| Default profile | `core` with 22 tools | `core` focused tool profile | -| Full profile | 70 tools | 79 tools | +| Default profile | `core` with 28 tools | `core` focused tool profile | +| Full profile | 76 tools plus `custom` exposure | 79 tools | | Client setup | One-click config panel | One-click config window | ## MCP Capabilities The current package exposes four capability layers: -- **Tools** — 22 tools in `core`, 70 tools in `full` +- **Tools** — 28 tools in `core`, 76 tools in `full`, plus `custom` include/exclude rules - **Primary execution** — `execute_javascript` for scene/runtime and editor/browser automation - **Prompts** — `fix_script_errors`, `create_playable_prototype`, `scene_validation`, and `auto_wire_scene` -- **Resources** — project context, scene summaries, current selection, script diagnostics, asset selection, and MCP interaction history +- **Resources** — project context, scene summaries, current selection, script diagnostics, asset selection, logs, and MCP interaction history -The default `core` set is intentionally small: `execute_javascript`, `execute_scene_script`, `execute_editor_script`, `get_editor_state`, `get_selection`, `set_selection`, `get_project_info`, `get_scene_info`, `get_hierarchy`, `list_scenes`, `open_scene`, `list_assets`, `inspect_asset`, `open_asset`, `select_asset`, `run_script_diagnostics`, `get_script_diagnostic_context`, `get_runtime_state`, `capture_editor_screenshot`, `capture_scene_screenshot`, `capture_preview_screenshot`, and `list_editor_windows`. +The default `core` set is intentionally small: `execute_javascript`, `execute_scene_script`, `execute_editor_script`, `get_editor_state`, `get_tool_catalog`, `check_for_updates`, `get_selection`, `set_selection`, `get_project_info`, `get_scene_info`, `get_hierarchy`, `list_scenes`, `open_scene`, `list_assets`, `inspect_asset`, `open_asset`, `select_asset`, `run_script_diagnostics`, `get_recent_logs`, `search_project_logs`, `clear_logs`, `validate_scene`, `get_script_diagnostic_context`, `get_runtime_state`, `capture_editor_screenshot`, `capture_scene_screenshot`, `capture_preview_screenshot`, and `list_editor_windows`. ## Built-in Resources @@ -257,16 +261,18 @@ The default `core` set is intentionally small: `execute_javascript`, `execute_sc | `cocos://selection/current` | Current editor selection | | `cocos://selection/asset` | Current selected asset | | `cocos://errors/scripts` | Script diagnostics | +| `cocos://logs/editor` | Recent MCP runtime logs and tool interactions | +| `cocos://logs/project` | Recent tails from common project log files | | `cocos://mcp/interactions` | Recent MCP interaction history | ## Built-in Tools -Funplay MCP for Cocos currently ships with **70 tool functions** in the `full` profile: +Funplay MCP for Cocos currently ships with **76 tool functions** in the `full` profile: | Category | Tools | |----------|-------| | **Script Execution** | `execute_javascript`, `execute_scene_script`, `execute_editor_script` | -| **Editor State** | `get_editor_state`, `get_selection`, `set_selection`, `get_editor_selection` | +| **Editor State** | `get_editor_state`, `get_tool_catalog`, `check_for_updates`, `get_selection`, `set_selection`, `get_editor_selection` | | **Project & Scene** | `get_project_info`, `get_scene_info`, `get_hierarchy`, `find_nodes`, `inspect_node`, `list_scenes`, `open_scene`, `run_scene_asset` | | **Node Editing** | `create_node`, `delete_node`, `set_node_transform` | | **Assets & Prefabs** | `list_assets`, `inspect_asset`, `open_asset`, `select_asset`, `delete_asset`, `list_prefabs`, `instantiate_prefab`, `get_editor_selection` | @@ -275,7 +281,7 @@ Funplay MCP for Cocos currently ships with **70 tool functions** in the `full` p | **Camera** | `list_cameras`, `create_camera`, `set_camera_properties` | | **Animation** | `list_animations`, `add_animation_clip`, `play_animation`, `stop_animation` | | **Files** | `read_file`, `get_file_snippet`, `write_file`, `replace_in_file`, `search_files`, `list_directory`, `exists`, `refresh_assets` | -| **Diagnostics** | `run_script_diagnostics`, `get_script_diagnostic_context` | +| **Diagnostics & Logs** | `run_script_diagnostics`, `get_script_diagnostic_context`, `get_recent_logs`, `search_project_logs`, `clear_logs`, `validate_scene` | | **Runtime** | `get_runtime_state`, `pause_runtime`, `resume_runtime`, `set_time_scale` | | **Interaction** | `emit_node_event`, `simulate_button_click`, `invoke_component_method`, `simulate_mouse_click`, `simulate_mouse_drag`, `simulate_key_press`, `simulate_key_combo`, `simulate_preview_input` | | **Screenshots & Windows** | `capture_desktop_screenshot`, `capture_editor_screenshot`, `capture_scene_screenshot`, `capture_game_screenshot`, `capture_preview_screenshot`, `list_editor_windows` | @@ -313,6 +319,11 @@ Place `funplay-cocos-mcp.config.json` in the Cocos project root: "host": "127.0.0.1", "port": 8765, "toolProfile": "core", + "enabledToolCategories": [], + "disabledToolCategories": [], + "enabledTools": [], + "disabledTools": [], + "enableSessions": false, "autostart": true, "maxInteractionLogEntries": 50 } @@ -324,6 +335,8 @@ Environment variables are also supported: - `COCOS_MCP_PORT` - `COCOS_MCP_PROFILE` +`toolProfile: "custom"` starts from the `core` set, then adds `enabledToolCategories` / `enabledTools` and removes `disabledToolCategories` / `disabledTools`. `enableSessions` is off by default because this server does not need cross-request client state for normal editor automation. + ## Architecture ```text diff --git a/README_CN.md b/README_CN.md index c77cd44..ecbea9b 100644 --- a/README_CN.md +++ b/README_CN.md @@ -69,7 +69,9 @@ Funplay > MCP Server - 启用或停用 MCP Server - 修改服务端口 -- 在 `core` / `full` 工具暴露模式之间切换 +- 在 `core` / `full` / `custom` 工具暴露模式之间切换 +- 检查当前安装版本是否落后于 GitHub 最新 Release +- 按工具分类或单个工具调整暴露范围 - 一键配置 AI 客户端 - 需要时再展开 Debug Output @@ -197,7 +199,9 @@ url = "http://127.0.0.1:8765/" - 这是一个 **仅限 Editor** 的扩展,用于自动化 Cocos Creator,不会给最终游戏包添加运行时依赖。 - MCP Server 默认监听 `http://127.0.0.1:8765/`。 - 如果配置端口被占用,服务会自动回退到下一个可用端口,面板与一键客户端配置会使用实际运行端口。 -- 默认 `core` profile 暴露 22 个高频工具;如果需要完整工具集,可在面板切到 `full`,暴露全部 70 个工具。 +- 默认 `core` profile 暴露 28 个高频工具;如果需要完整工具集,可在面板切到 `full`,暴露全部 76 个工具;也可以用 `custom` 按分类或工具名增删。 +- 面板提供手动更新检查,会对比当前安装版本和 GitHub 最新 Release。 +- Streamable HTTP 响应已补齐 MCP 传输层要求,包括 `Accept`、`MCP-Protocol-Version`、JSON-RPC notification/response,以及可选 `Mcp-Session-Id` session。 - 所有已暴露的 MCP 工具都会直接执行,Cocos 扩展里没有额外 approval 开关。 - 文件工具和 `cocos://asset/path/...` 资源默认只能访问当前 Cocos 项目根目录内的路径。 - 推荐工作流是优先使用 `execute_javascript`,再配合截图、诊断、资产、检查类工具。 @@ -208,16 +212,16 @@ url = "http://127.0.0.1:8765/" - **`execute_javascript` 主工具优先** — 一个高灵活度 JavaScript 工具就能编排场景/运行态和编辑器自动化,避免 AI 客户端被大量细碎工具干扰 - **嵌入式 Cocos 扩展** — Cocos 侧不需要单独 Python 守护进程或外部 bridge - **一键客户端配置** — 在 Cocos Creator 内直接配置 Claude Code、Cursor、VS Code、Trae、Kiro、Codex -- **内建项目上下文** — 直接暴露项目、场景、选择、脚本诊断和交互历史资源 -- **默认聚焦,必要时全量** — `core` 降低工具列表噪音,需要时切到 `full` 暴露全部工具 +- **内建项目上下文** — 直接暴露项目、场景、选择、脚本诊断、日志和交互历史资源 +- **默认聚焦,必要时全量** — `core` 降低工具列表噪音,需要时切到 `full` 暴露全部工具;`custom` 可按分类或工具名调整 - **可视化验证** — 截图和输入模拟让 AI 能验证 UI 与玩法改动 ## 核心特性 -- **70 个内置工具** — 覆盖场景层级、编辑器状态、选择工作流、资产、UI 创建、组件、文件、脚本诊断、截图、运行态控制和输入模拟 +- **76 个内置工具** — 覆盖场景层级、编辑器状态、选择工作流、资产、UI 创建、组件、文件、日志、脚本诊断、截图、运行态控制和输入模拟 - **统一主工具** — `execute_javascript` 同时支持 `scene` 和 `editor` 两种上下文 -- **Resources 与 Prompts** — 实时项目资源,以及脚本修复、场景验证、可玩原型等可复用工作流 -- **Cocos 图形面板** — `Funplay > MCP Server` 提供极简服务管理与 MCP 客户端配置 +- **Resources 与 Prompts** — 实时项目/日志资源,以及脚本修复、场景验证、可玩原型等可复用工作流 +- **Cocos 图形面板** — `Funplay > MCP Server` 提供服务管理、更新检查、工具暴露和 MCP 客户端配置 - **截图与输入支持** — 支持编辑器/场景/Game/Preview 截图,以及 Electron 级鼠标键盘事件 - **厂商无关** — 兼容任意支持 HTTP JSON-RPC MCP 的 AI 客户端 @@ -231,20 +235,20 @@ Funplay MCP for Cocos 延续 Funplay MCP for Unity 的设计原则,并针对 C | 内置服务 | 内嵌 HTTP MCP Server | 内嵌 HTTP MCP Server | | 主执行工具 | `execute_javascript` | `execute_code` | | 主语言 | 场景/编辑器上下文中的 JavaScript | Unity 编辑器/运行态中的 C# | -| 默认工具集 | `core`,22 个工具 | 聚焦版 `core` 工具集 | -| 完整工具集 | 70 个工具 | 79 个工具 | +| 默认工具集 | `core`,28 个工具 | 聚焦版 `core` 工具集 | +| 完整工具集 | 76 个工具,并支持 `custom` 暴露 | 79 个工具 | | 客户端配置 | 一键配置面板 | 一键配置窗口 | ## MCP 能力结构 当前包提供四层能力: -- **Tools** — `core` 下 22 个工具,`full` 下 70 个工具 +- **Tools** — `core` 下 28 个工具,`full` 下 76 个工具,并支持 `custom` include/exclude 规则 - **Primary execution** — `execute_javascript` 用于场景/运行态和编辑器/browser 自动化 - **Prompts** — `fix_script_errors`、`create_playable_prototype`、`scene_validation`、`auto_wire_scene` -- **Resources** — 项目上下文、场景摘要、当前选择、脚本诊断、资产选择和 MCP 交互历史 +- **Resources** — 项目上下文、场景摘要、当前选择、脚本诊断、资产选择、日志和 MCP 交互历史 -当前默认 `core` 工具集刻意保持精简,只包含:`execute_javascript`、`execute_scene_script`、`execute_editor_script`、`get_editor_state`、`get_selection`、`set_selection`、`get_project_info`、`get_scene_info`、`get_hierarchy`、`list_scenes`、`open_scene`、`list_assets`、`inspect_asset`、`open_asset`、`select_asset`、`run_script_diagnostics`、`get_script_diagnostic_context`、`get_runtime_state`、`capture_editor_screenshot`、`capture_scene_screenshot`、`capture_preview_screenshot`、`list_editor_windows`。 +当前默认 `core` 工具集刻意保持精简,只包含:`execute_javascript`、`execute_scene_script`、`execute_editor_script`、`get_editor_state`、`get_tool_catalog`、`check_for_updates`、`get_selection`、`set_selection`、`get_project_info`、`get_scene_info`、`get_hierarchy`、`list_scenes`、`open_scene`、`list_assets`、`inspect_asset`、`open_asset`、`select_asset`、`run_script_diagnostics`、`get_recent_logs`、`search_project_logs`、`clear_logs`、`validate_scene`、`get_script_diagnostic_context`、`get_runtime_state`、`capture_editor_screenshot`、`capture_scene_screenshot`、`capture_preview_screenshot`、`list_editor_windows`。 ## 内置 Resources @@ -257,16 +261,18 @@ Funplay MCP for Cocos 延续 Funplay MCP for Unity 的设计原则,并针对 C | `cocos://selection/current` | 当前编辑器选择 | | `cocos://selection/asset` | 当前选中资产 | | `cocos://errors/scripts` | 脚本诊断信息 | +| `cocos://logs/editor` | 最近 MCP 运行日志和工具交互 | +| `cocos://logs/project` | 常见项目日志文件的尾部内容 | | `cocos://mcp/interactions` | 最近 MCP 交互历史 | ## 内置工具 -Funplay MCP for Cocos 当前在 `full` profile 下提供 **70 个工具函数**: +Funplay MCP for Cocos 当前在 `full` profile 下提供 **76 个工具函数**: | 分类 | 工具 | |------|------| | **脚本执行** | `execute_javascript`, `execute_scene_script`, `execute_editor_script` | -| **编辑器状态** | `get_editor_state`, `get_selection`, `set_selection`, `get_editor_selection` | +| **编辑器状态** | `get_editor_state`, `get_tool_catalog`, `check_for_updates`, `get_selection`, `set_selection`, `get_editor_selection` | | **项目与场景** | `get_project_info`, `get_scene_info`, `get_hierarchy`, `find_nodes`, `inspect_node`, `list_scenes`, `open_scene`, `run_scene_asset` | | **节点编辑** | `create_node`, `delete_node`, `set_node_transform` | | **资产与 Prefab** | `list_assets`, `inspect_asset`, `open_asset`, `select_asset`, `delete_asset`, `list_prefabs`, `instantiate_prefab`, `get_editor_selection` | @@ -275,7 +281,7 @@ Funplay MCP for Cocos 当前在 `full` profile 下提供 **70 个工具函数** | **相机** | `list_cameras`, `create_camera`, `set_camera_properties` | | **动画** | `list_animations`, `add_animation_clip`, `play_animation`, `stop_animation` | | **文件** | `read_file`, `get_file_snippet`, `write_file`, `replace_in_file`, `search_files`, `list_directory`, `exists`, `refresh_assets` | -| **诊断** | `run_script_diagnostics`, `get_script_diagnostic_context` | +| **诊断与日志** | `run_script_diagnostics`, `get_script_diagnostic_context`, `get_recent_logs`, `search_project_logs`, `clear_logs`, `validate_scene` | | **运行态** | `get_runtime_state`, `pause_runtime`, `resume_runtime`, `set_time_scale` | | **交互** | `emit_node_event`, `simulate_button_click`, `invoke_component_method`, `simulate_mouse_click`, `simulate_mouse_drag`, `simulate_key_press`, `simulate_key_combo`, `simulate_preview_input` | | **截图与窗口** | `capture_desktop_screenshot`, `capture_editor_screenshot`, `capture_scene_screenshot`, `capture_game_screenshot`, `capture_preview_screenshot`, `list_editor_windows` | @@ -313,6 +319,11 @@ Editor 上下文脚本可以访问 `Editor`、`fs`、`path`、`os`、`require` "host": "127.0.0.1", "port": 8765, "toolProfile": "core", + "enabledToolCategories": [], + "disabledToolCategories": [], + "enabledTools": [], + "disabledTools": [], + "enableSessions": false, "autostart": true, "maxInteractionLogEntries": 50 } @@ -324,6 +335,8 @@ Editor 上下文脚本可以访问 `Editor`、`fs`、`path`、`os`、`require` - `COCOS_MCP_PORT` - `COCOS_MCP_PROFILE` +`toolProfile: "custom"` 会从 `core` 集合开始,再加入 `enabledToolCategories` / `enabledTools`,并移除 `disabledToolCategories` / `disabledTools`。`enableSessions` 默认关闭,因为常规编辑器自动化不需要跨请求客户端状态。 + ## 架构 ```text diff --git a/browser.js b/browser.js index d2e975f..213c28a 100644 --- a/browser.js +++ b/browser.js @@ -11,6 +11,8 @@ const { createToolRegistry } = require('./lib/tool-registry'); const { ResourceProvider } = require('./lib/resources'); const { PromptProvider } = require('./lib/prompts'); const { InteractionLog } = require('./lib/interaction-log'); +const { RuntimeLog } = require('./lib/runtime-log'); +const { checkForUpdate } = require('./lib/update-checker'); const EXTENSION_NAME = manifest.name || 'funplay-cocos-mcp'; const LOG_PREFIX = '[Funplay Cocos MCP]'; @@ -25,26 +27,42 @@ class ExtensionService { this.resourceProvider = null; this.promptProvider = null; this.interactionLog = new InteractionLog(); + this.runtimeLog = new RuntimeLog(); + this.lastUpdateInfo = null; + } + + log(level, message, details) { + if (this.runtimeLog && typeof this.runtimeLog.add === 'function') { + this.runtimeLog.add(level, message, details); + } + const output = `${LOG_PREFIX} ${message}`; + if (level === 'error') { + console.error(output); + } else if (level === 'warn') { + console.warn(output); + } else { + console.log(output); + } } load() { - console.log(`${LOG_PREFIX} Extension loading...`); + this.log('info', 'Extension loading...'); this.reloadRuntime(); if (this.config.autostart) { - console.log(`${LOG_PREFIX} Autostart is enabled, starting MCP server.`); + this.log('info', 'Autostart is enabled, starting MCP server.'); return this.startServer(); } - console.log(`${LOG_PREFIX} Autostart is disabled. MCP server is idle.`); + this.log('info', 'Autostart is disabled. MCP server is idle.'); return this.getStatus(); } unload() { - console.log(`${LOG_PREFIX} Extension unloading...`); + this.log('info', 'Extension unloading...'); if (this.server) { this.server.stop(); this.server = null; } - console.log(`${LOG_PREFIX} Extension unloaded.`); + this.log('info', 'Extension unloaded.'); } openPanel() { @@ -56,11 +74,13 @@ class ExtensionService { reloadRuntime() { this.config = loadConfig(); - console.log( - `${LOG_PREFIX} Runtime config loaded: host=${this.config.host}, port=${this.config.port}, ` + + this.interactionLog = new InteractionLog(this.config.maxInteractionLogEntries); + this.runtimeLog = new RuntimeLog(this.config.maxInteractionLogEntries); + this.log( + 'info', + `Runtime config loaded: host=${this.config.host}, port=${this.config.port}, ` + `profile=${this.config.toolProfile}, autostart=${this.config.autostart}` ); - this.interactionLog = new InteractionLog(this.config.maxInteractionLogEntries); const sceneBridge = { call: async (method, payload) => { if (!global.Editor || !Editor.Message || typeof Editor.Message.request !== 'function') { @@ -89,24 +109,26 @@ class ExtensionService { getRuntimeContext: runtimeContext, getStatus: () => this.getStatus(), interactionLog: this.interactionLog, + runtimeLog: this.runtimeLog, sceneBridge, editorExecutor: async (payload) => await this.executeEditorScript(payload, runtimeContext), }); - this.resourceProvider = new ResourceProvider(runtimeContext, sceneBridge, this.interactionLog); + this.resourceProvider = new ResourceProvider(runtimeContext, sceneBridge, this.interactionLog, this.runtimeLog); this.promptProvider = new PromptProvider(runtimeContext); } async startServer() { if (this.server && this.server.isRunning()) { - console.log(`${LOG_PREFIX} Start requested but MCP server is already running at ${this.getStatus().url}`); + this.log('info', `Start requested but MCP server is already running at ${this.getStatus().url}`); return this.getStatus(); } - console.log(`${LOG_PREFIX} Starting MCP server...`); + this.log('info', 'Starting MCP server...'); this.reloadRuntime(); this.server = new McpServer({ config: this.config, interactionLog: this.interactionLog, + runtimeLog: this.runtimeLog, toolRegistry: this.toolRegistry, resourceProvider: this.resourceProvider, promptProvider: this.promptProvider, @@ -115,30 +137,28 @@ class ExtensionService { }); await this.server.start(); - console.log(`${LOG_PREFIX} MCP server started at ${this.getStatus().url}`); - console.log( - `${LOG_PREFIX} If this tool saves you time, please consider giving it a Star on GitHub: ${REPOSITORY_URL}` - ); + this.log('info', `MCP server started at ${this.getStatus().url}`); + this.log('info', `If this tool saves you time, please consider giving it a Star on GitHub: ${REPOSITORY_URL}`); return this.getStatus(); } async stopServer() { - console.log(`${LOG_PREFIX} Stop requested.`); + this.log('info', 'Stop requested.'); if (this.server) { await this.server.stop(); this.server = null; - console.log(`${LOG_PREFIX} MCP server stopped.`); + this.log('info', 'MCP server stopped.'); } else { - console.log(`${LOG_PREFIX} Stop requested but MCP server was not running.`); + this.log('info', 'Stop requested but MCP server was not running.'); } return this.getStatus(); } async restartServer() { - console.log(`${LOG_PREFIX} Restart requested.`); + this.log('info', 'Restart requested.'); await this.stopServer(); const status = await this.startServer(); - console.log(`${LOG_PREFIX} Restart completed. MCP server running=${status.running}, url=${status.url}`); + this.log('info', `Restart completed. MCP server running=${status.running}, url=${status.url}`); return status; } @@ -166,7 +186,13 @@ class ExtensionService { portFallbackActive: Boolean(fallbackInfo), portFallbackInfo: fallbackInfo, toolProfile: this.config.toolProfile, + enabledTools: this.config.enabledTools, + disabledTools: this.config.disabledTools, + enabledToolCategories: this.config.enabledToolCategories, + disabledToolCategories: this.config.disabledToolCategories, + enableSessions: this.config.enableSessions, autostart: this.config.autostart, + version: manifest.version || '0.0.0', projectPath: getProjectPath(), projectName: getProjectName(), cocosVersion: getCocosVersion(), @@ -178,16 +204,22 @@ class ExtensionService { this.ensureRuntime(); const status = this.getStatus(); const tools = this.toolRegistry.listTools(); + const toolCatalog = typeof this.toolRegistry.listToolCatalog === 'function' + ? this.toolRegistry.listToolCatalog() + : tools; const resources = this.resourceProvider.listResources(); const prompts = this.promptProvider.listPrompts(); return { status, tools, + toolCatalog, resources, prompts, recentInteractions: this.interactionLog.list(20), + recentRuntimeLogs: this.runtimeLog.list(20), config: this.config, + updateInfo: this.lastUpdateInfo, clientConfig: this.getClientConfig(), clientTargets: getTargetStatuses(this.config), }; @@ -200,10 +232,25 @@ class ExtensionService { async callToolFromPanel(name, args) { this.ensureRuntime(); - console.log(`${LOG_PREFIX} Panel calling tool: ${name}`); + this.log('info', `Panel calling tool: ${name}`); return await this.toolRegistry.callTool(name, args || {}); } + async checkUpdates() { + this.ensureRuntime(); + this.log('info', 'Checking GitHub for newer Funplay Cocos MCP releases.'); + this.lastUpdateInfo = await checkForUpdate({ currentVersion: manifest.version || '0.0.0' }); + if (this.lastUpdateInfo.ok) { + this.log( + 'info', + `Update check completed: current=${this.lastUpdateInfo.currentVersion}, latest=${this.lastUpdateInfo.latestVersion || 'unknown'}` + ); + } else { + this.log('warn', `Update check failed: ${this.lastUpdateInfo.error}`); + } + return this.getPanelState(); + } + async executeEditorScript(payload, runtimeContext) { const code = String(payload && payload.code || ''); if (!code.trim()) { @@ -252,7 +299,7 @@ class ExtensionService { async readResourceFromPanel(uri) { this.ensureRuntime(); - console.log(`${LOG_PREFIX} Panel reading resource: ${uri}`); + this.log('info', `Panel reading resource: ${uri}`); return await this.resourceProvider.readResource(uri); } @@ -273,11 +320,12 @@ class ExtensionService { configureClient(targetId) { this.ensureRuntime(); - console.log(`${LOG_PREFIX} Configuring MCP client target: ${targetId}`); + this.log('info', `Configuring MCP client target: ${targetId}`); const effective = this.getEffectiveServerConnection(); if (effective.port !== this.config.port) { - console.log( - `${LOG_PREFIX} Using actual running port ${effective.port} for MCP client configuration ` + + this.log( + 'info', + `Using actual running port ${effective.port} for MCP client configuration ` + `(requested: ${this.config.port}).` ); } @@ -289,7 +337,7 @@ class ExtensionService { }, targetId ); - console.log(`${LOG_PREFIX} MCP client configured: ${result.name} -> ${result.configPath}`); + this.log('info', `MCP client configured: ${result.name} -> ${result.configPath}`); return { ...result, clientTargets: getTargetStatuses(this.config), @@ -304,12 +352,37 @@ class ExtensionService { const nextMaxEntries = partialConfig && partialConfig.maxInteractionLogEntries !== undefined ? Number(partialConfig.maxInteractionLogEntries) : this.config.maxInteractionLogEntries; + const normalizeList = (value, fallback) => { + if (Array.isArray(value)) { + return value.map((item) => String(item || '').trim()).filter(Boolean); + } + if (typeof value === 'string') { + return value.split(/[\n,]/).map((item) => item.trim()).filter(Boolean); + } + return fallback || []; + }; + const normalizeCategories = (value, fallback) => normalizeList(value, fallback) + .map((item) => item.toLowerCase()); + const nextProfile = partialConfig && partialConfig.toolProfile + ? (partialConfig.toolProfile === 'full' || partialConfig.toolProfile === 'custom' ? partialConfig.toolProfile : 'core') + : this.config.toolProfile; const nextConfig = { host: partialConfig && partialConfig.host ? String(partialConfig.host) : this.config.host, port: Number.isInteger(nextPort) && nextPort > 0 && nextPort <= 65535 ? nextPort : this.config.port, - toolProfile: partialConfig && partialConfig.toolProfile - ? (partialConfig.toolProfile === 'full' ? 'full' : 'core') - : this.config.toolProfile, + toolProfile: nextProfile, + enabledTools: normalizeList(partialConfig && partialConfig.enabledTools, this.config.enabledTools), + disabledTools: normalizeList(partialConfig && partialConfig.disabledTools, this.config.disabledTools), + enabledToolCategories: normalizeCategories( + partialConfig && partialConfig.enabledToolCategories, + this.config.enabledToolCategories + ), + disabledToolCategories: normalizeCategories( + partialConfig && partialConfig.disabledToolCategories, + this.config.disabledToolCategories + ), + enableSessions: partialConfig && typeof partialConfig.enableSessions === 'boolean' + ? partialConfig.enableSessions + : this.config.enableSessions, autostart: partialConfig && typeof partialConfig.autostart === 'boolean' ? partialConfig.autostart : this.config.autostart, @@ -327,7 +400,12 @@ class ExtensionService { const requiresRestart = wasRunning && ( nextConfig.host !== this.config.host || nextConfig.port !== this.config.port || - nextConfig.toolProfile !== this.config.toolProfile + nextConfig.toolProfile !== this.config.toolProfile || + nextConfig.enableSessions !== this.config.enableSessions || + JSON.stringify(nextConfig.enabledTools) !== JSON.stringify(this.config.enabledTools) || + JSON.stringify(nextConfig.disabledTools) !== JSON.stringify(this.config.disabledTools) || + JSON.stringify(nextConfig.enabledToolCategories) !== JSON.stringify(this.config.enabledToolCategories) || + JSON.stringify(nextConfig.disabledToolCategories) !== JSON.stringify(this.config.disabledToolCategories) ); if (requiresRestart) { await this.stopServer(); @@ -383,6 +461,9 @@ module.exports = { callToolFromPanel(name, args) { return service.callToolFromPanel(name, args); }, + checkUpdates() { + return service.checkUpdates(); + }, readResourceFromPanel(uri) { return service.readResourceFromPanel(uri); }, diff --git a/lib/config.js b/lib/config.js index 32a9a99..b61ea57 100644 --- a/lib/config.js +++ b/lib/config.js @@ -7,6 +7,11 @@ const DEFAULTS = { host: '127.0.0.1', port: 8765, toolProfile: 'core', + enabledTools: [], + disabledTools: [], + enabledToolCategories: [], + disabledToolCategories: [], + enableSessions: false, autostart: true, maxInteractionLogEntries: 50, lastClientTargetId: 'claude_code', @@ -58,7 +63,26 @@ function clampPort(value) { } function normalizeProfile(value) { - return String(value || DEFAULTS.toolProfile).toLowerCase() === 'full' ? 'full' : 'core'; + const normalized = String(value || DEFAULTS.toolProfile).toLowerCase(); + if (normalized === 'full' || normalized === 'custom') { + return normalized; + } + return 'core'; +} + +function normalizeStringList(value) { + if (Array.isArray(value)) { + return value + .map((item) => String(item || '').trim()) + .filter(Boolean); + } + if (typeof value === 'string') { + return value + .split(/[\n,]/) + .map((item) => item.trim()) + .filter(Boolean); + } + return []; } function normalizeClientTargetId(value) { @@ -77,6 +101,11 @@ function loadConfig() { host: process.env.COCOS_MCP_HOST || fileConfig.host || DEFAULTS.host, port: clampPort(process.env.COCOS_MCP_PORT || fileConfig.port || DEFAULTS.port), toolProfile: normalizeProfile(process.env.COCOS_MCP_PROFILE || fileConfig.toolProfile || DEFAULTS.toolProfile), + enabledTools: normalizeStringList(fileConfig.enabledTools), + disabledTools: normalizeStringList(fileConfig.disabledTools), + enabledToolCategories: normalizeStringList(fileConfig.enabledToolCategories).map((item) => item.toLowerCase()), + disabledToolCategories: normalizeStringList(fileConfig.disabledToolCategories).map((item) => item.toLowerCase()), + enableSessions: typeof fileConfig.enableSessions === 'boolean' ? fileConfig.enableSessions : DEFAULTS.enableSessions, autostart: typeof fileConfig.autostart === 'boolean' ? fileConfig.autostart : DEFAULTS.autostart, maxInteractionLogEntries: Number.isInteger(fileConfig.maxInteractionLogEntries) ? Math.max(10, Math.min(500, fileConfig.maxInteractionLogEntries)) @@ -93,4 +122,6 @@ module.exports = { getProjectName, getCocosVersion, loadConfig, + normalizeProfile, + normalizeStringList, }; diff --git a/lib/interaction-log.js b/lib/interaction-log.js index 83ca795..e93434f 100644 --- a/lib/interaction-log.js +++ b/lib/interaction-log.js @@ -23,6 +23,12 @@ class InteractionLog { return this.entries.slice(0, Math.max(1, limit)); } + clear() { + const count = this.entries.length; + this.entries.length = 0; + return count; + } + summary(limit = 20) { const items = this.list(limit); if (!items.length) { diff --git a/lib/logs.js b/lib/logs.js new file mode 100644 index 0000000..5c947c1 --- /dev/null +++ b/lib/logs.js @@ -0,0 +1,206 @@ +'use strict'; + +const fs = require('fs'); +const path = require('path'); +const { resolveProjectPath } = require('./path-safety'); + +const DEFAULT_LOG_DIRS = [ + 'temp/logs', + 'temp', + 'logs', + 'local/logs', + 'local', +]; +const LOG_EXTENSIONS = new Set(['.log', '.txt']); +const MAX_READ_BYTES = 2 * 1024 * 1024; + +function normalizeLimit(value, fallback, min, max) { + const number = Number(value); + if (!Number.isFinite(number)) { + return fallback; + } + return Math.max(min, Math.min(max, Math.floor(number))); +} + +function shouldSkipDirectory(name) { + return name === '.git' || name === 'node_modules' || name === 'library'; +} + +function isLogFile(fileName) { + return LOG_EXTENSIONS.has(path.extname(fileName).toLowerCase()); +} + +function safeStat(filePath) { + try { + return fs.statSync(filePath); + } catch (error) { + return null; + } +} + +function collectLogFiles(rootDir, maxDepth, limit) { + const files = []; + const stack = [{ dir: rootDir, depth: 0 }]; + + while (stack.length && files.length < limit) { + const current = stack.pop(); + let entries; + try { + entries = fs.readdirSync(current.dir, { withFileTypes: true }); + } catch (error) { + continue; + } + + for (const entry of entries) { + const fullPath = path.join(current.dir, entry.name); + if (entry.isDirectory()) { + if (current.depth < maxDepth && !shouldSkipDirectory(entry.name)) { + stack.push({ dir: fullPath, depth: current.depth + 1 }); + } + continue; + } + + if (!entry.isFile() || !isLogFile(entry.name)) { + continue; + } + + const stat = safeStat(fullPath); + if (stat) { + files.push({ fullPath, size: stat.size, mtimeMs: stat.mtimeMs }); + } + if (files.length >= limit) { + break; + } + } + } + + return files; +} + +function findProjectLogFiles(projectPath, options = {}) { + const limit = normalizeLimit(options.limit, 20, 1, 200); + const maxDepth = normalizeLimit(options.maxDepth, 2, 0, 5); + const directories = options.directory + ? [options.directory] + : DEFAULT_LOG_DIRS; + const seen = new Set(); + const files = []; + + for (const directory of directories) { + let rootDir; + try { + rootDir = resolveProjectPath(projectPath, directory); + } catch (error) { + continue; + } + if (!fs.existsSync(rootDir) || !fs.statSync(rootDir).isDirectory()) { + continue; + } + + for (const file of collectLogFiles(rootDir, maxDepth, limit)) { + if (seen.has(file.fullPath)) { + continue; + } + seen.add(file.fullPath); + files.push(file); + if (files.length >= limit) { + break; + } + } + if (files.length >= limit) { + break; + } + } + + return files + .sort((left, right) => right.mtimeMs - left.mtimeMs) + .slice(0, limit); +} + +function readTail(filePath, maxLines = 80) { + const stat = safeStat(filePath); + if (!stat) { + return ''; + } + + const size = Math.min(stat.size, MAX_READ_BYTES); + const buffer = Buffer.alloc(size); + const fd = fs.openSync(filePath, 'r'); + try { + fs.readSync(fd, buffer, 0, size, stat.size - size); + } finally { + fs.closeSync(fd); + } + + return buffer + .toString('utf8') + .replace(/\s+$/g, '') + .split(/\r?\n/) + .slice(-normalizeLimit(maxLines, 80, 1, 1000)) + .join('\n') + .trim(); +} + +function getRecentProjectLogs(projectPath, options = {}) { + const maxLines = normalizeLimit(options.lines, 80, 1, 1000); + return findProjectLogFiles(projectPath, options).map((file) => ({ + path: path.relative(projectPath, file.fullPath).replace(/\\/g, '/'), + size: file.size, + mtime: new Date(file.mtimeMs).toISOString(), + text: readTail(file.fullPath, maxLines), + })); +} + +function searchProjectLogs(projectPath, options = {}) { + const query = String(options.query || '').trim(); + if (!query) { + throw new Error('query is required.'); + } + + const limit = normalizeLimit(options.limit, 50, 1, 500); + const flags = options.caseSensitive ? '' : 'i'; + const pattern = options.regex ? new RegExp(query, flags) : null; + const lowerQuery = query.toLowerCase(); + const results = []; + + for (const file of findProjectLogFiles(projectPath, { ...options, limit: normalizeLimit(options.fileLimit, 40, 1, 200) })) { + const text = readTail(file.fullPath, normalizeLimit(options.linesPerFile, 2000, 1, 10000)); + const lines = text.split(/\r?\n/); + for (let index = 0; index < lines.length; index += 1) { + const line = lines[index]; + const matched = pattern + ? pattern.test(line) + : (options.caseSensitive ? line.includes(query) : line.toLowerCase().includes(lowerQuery)); + if (!matched) { + continue; + } + + results.push({ + path: path.relative(projectPath, file.fullPath).replace(/\\/g, '/'), + line: index + 1, + text: line, + }); + if (results.length >= limit) { + return { query, count: results.length, matches: results }; + } + } + } + + return { query, count: results.length, matches: results }; +} + +function clearProjectLogFiles(projectPath, options = {}) { + const cleared = []; + for (const file of findProjectLogFiles(projectPath, options)) { + fs.truncateSync(file.fullPath, 0); + cleared.push(path.relative(projectPath, file.fullPath).replace(/\\/g, '/')); + } + return cleared; +} + +module.exports = { + findProjectLogFiles, + getRecentProjectLogs, + searchProjectLogs, + clearProjectLogFiles, +}; diff --git a/lib/resources.js b/lib/resources.js index 85b44f9..3f5b10e 100644 --- a/lib/resources.js +++ b/lib/resources.js @@ -4,6 +4,7 @@ const fs = require('fs'); const path = require('path'); const { getCurrentSelection, queryAssetInfo, queryAssetMeta } = require('./assets'); const { runScriptDiagnostics } = require('./diagnostics'); +const { getRecentProjectLogs } = require('./logs'); const { resolveProjectPath } = require('./path-safety'); function createResource(uri, name, description) { @@ -49,10 +50,11 @@ function summarizeSelection() { } class ResourceProvider { - constructor(getRuntimeContext, sceneBridge, interactionLog) { + constructor(getRuntimeContext, sceneBridge, interactionLog, runtimeLog) { this.getRuntimeContext = getRuntimeContext; this.sceneBridge = sceneBridge; this.interactionLog = interactionLog; + this.runtimeLog = runtimeLog; } listResources() { @@ -65,6 +67,8 @@ class ResourceProvider { createResource('cocos://selection/current', `${projectName} Current Selection`, 'Summary of the current editor selection.'), createResource('cocos://selection/asset', `${projectName} Selected Asset`, 'Details for the currently selected asset.'), createResource('cocos://errors/scripts', `${projectName} Script Diagnostics`, 'Latest TypeScript diagnostic summary for the project.'), + createResource('cocos://logs/editor', `${projectName} Editor Logs`, 'Recent MCP runtime logs and tool interaction history.'), + createResource('cocos://logs/project', `${projectName} Project Logs`, 'Recent tails from common project log files.'), createResource('cocos://mcp/interactions', `${projectName} MCP Interactions`, 'Recent MCP tool interaction summaries.'), ]; } @@ -121,6 +125,10 @@ class ResourceProvider { return await this.getSelectedAssetText(); case 'cocos://errors/scripts': return await this.getScriptDiagnosticsText(projectPath); + case 'cocos://logs/editor': + return this.getEditorLogsText(); + case 'cocos://logs/project': + return this.getProjectLogsText(projectPath); case 'cocos://mcp/interactions': return this.interactionLog.summary(); default: @@ -247,6 +255,37 @@ class ResourceProvider { return `Script diagnostics failed: ${error.message}`; } } + + getEditorLogsText() { + return [ + 'MCP Runtime Logs', + this.runtimeLog && typeof this.runtimeLog.summary === 'function' + ? this.runtimeLog.summary(80) + : 'Runtime log is unavailable.', + '', + 'MCP Tool Interactions', + this.interactionLog && typeof this.interactionLog.summary === 'function' + ? this.interactionLog.summary(80) + : 'Interaction log is unavailable.', + ].join('\n'); + } + + getProjectLogsText(projectPath) { + const logs = getRecentProjectLogs(projectPath, { limit: 10, lines: 100 }); + if (!logs.length) { + return 'No project log files found in common project log directories.'; + } + + return logs + .map((log) => [ + `# ${log.path}`, + `mtime: ${log.mtime}`, + `size: ${log.size}`, + '', + log.text, + ].join('\n')) + .join('\n\n---\n\n'); + } } module.exports = { diff --git a/lib/runtime-log.js b/lib/runtime-log.js new file mode 100644 index 0000000..111bdf3 --- /dev/null +++ b/lib/runtime-log.js @@ -0,0 +1,46 @@ +'use strict'; + +class RuntimeLog { + constructor(limit = 200) { + this.limit = Math.max(10, Number(limit) || 200); + this.entries = []; + } + + add(level, message, details) { + this.entries.unshift({ + level: String(level || 'info'), + message: String(message || ''), + details: details === undefined ? null : details, + timestamp: new Date().toISOString(), + }); + + if (this.entries.length > this.limit) { + this.entries.length = this.limit; + } + } + + list(limit = 50) { + return this.entries.slice(0, Math.max(1, Number(limit) || 50)); + } + + clear() { + const count = this.entries.length; + this.entries.length = 0; + return count; + } + + summary(limit = 50) { + const items = this.list(limit); + if (!items.length) { + return 'No MCP runtime logs recorded yet.'; + } + + return items + .map((entry) => `[${entry.timestamp}] ${entry.level.toUpperCase()} ${entry.message}`) + .join('\n'); + } +} + +module.exports = { + RuntimeLog, +}; diff --git a/lib/server.js b/lib/server.js index 15c2584..fb8bddf 100644 --- a/lib/server.js +++ b/lib/server.js @@ -1,5 +1,6 @@ 'use strict'; +const crypto = require('crypto'); const http = require('http'); const { safeStringify } = require('./utils'); const IMAGE_DATA_URI_PREFIX = 'data:image/png;base64,'; @@ -14,14 +15,26 @@ const SUPPORTED_PROTOCOL_VERSIONS = [ '2024-11-05', ]; -function json(response, statusCode, payload, protocolVersion = MCP_PROTOCOL_VERSION) { +function responseHeaders(protocolVersion = MCP_PROTOCOL_VERSION, extraHeaders = {}) { + return { + 'MCP-Protocol-Version': protocolVersion, + ...extraHeaders, + }; +} + +function json(response, statusCode, payload, protocolVersion = MCP_PROTOCOL_VERSION, extraHeaders = {}) { response.writeHead(statusCode, { 'Content-Type': 'application/json; charset=utf-8', - 'MCP-Protocol-Version': protocolVersion, + ...responseHeaders(protocolVersion, extraHeaders), }); response.end(JSON.stringify(payload)); } +function empty(response, statusCode, protocolVersion = MCP_PROTOCOL_VERSION, extraHeaders = {}) { + response.writeHead(statusCode, responseHeaders(protocolVersion, extraHeaders)); + response.end(); +} + function textContent(value) { if (typeof value === 'string' && value.startsWith(IMAGE_DATA_URI_PREFIX)) { return [ @@ -68,12 +81,15 @@ class McpServer { this.resourceProvider = options.resourceProvider; this.promptProvider = options.promptProvider; this.interactionLog = options.interactionLog; + this.runtimeLog = options.runtimeLog; this.serverName = options.serverName; this.serverVersion = options.serverVersion; this.server = null; this.actualPort = null; this.portFallbackInfo = null; this.negotiatedProtocolVersion = MCP_PROTOCOL_VERSION; + this.enableSessions = Boolean(this.config && this.config.enableSessions); + this.sessions = new Set(); } isRunning() { @@ -98,9 +114,24 @@ class McpServer { return this.portFallbackInfo; } + log(level, message) { + if (this.runtimeLog && typeof this.runtimeLog.add === 'function') { + this.runtimeLog.add(level, message); + } + + const output = `${LOG_PREFIX} ${message}`; + if (level === 'error') { + console.error(output); + } else if (level === 'warn') { + console.warn(output); + } else { + console.log(output); + } + } + async start() { if (this.isRunning()) { - console.log(`${LOG_PREFIX} Start skipped: already running.`); + this.log('info', 'Start skipped: already running.'); return; } @@ -110,20 +141,34 @@ class McpServer { const requestHandler = async (request, response) => { try { if (request.method === 'GET' && request.url === '/health') { - console.log(`${LOG_PREFIX} GET /health`); + this.log('info', 'GET /health'); return json(response, 200, { ok: true, name: this.serverName, version: this.serverVersion }, this.negotiatedProtocolVersion); } if (!this.isAllowedOrigin(request)) { - console.warn(`${LOG_PREFIX} Rejected ${request.method} ${request.url}: invalid Origin header.`); + this.log('warn', `Rejected ${request.method} ${request.url}: invalid Origin header.`); return json(response, 403, { error: 'Forbidden: invalid Origin header' }, this.negotiatedProtocolVersion); } + if (request.method === 'DELETE') { + return this.handleDelete(request, response); + } + + if (request.method === 'GET') { + this.log('warn', `Rejected ${request.method} ${request.url}: SSE GET streams are not supported.`); + return json(response, 405, { error: 'Method Not Allowed: SSE streams are not supported' }, this.negotiatedProtocolVersion); + } + if (request.method !== 'POST') { - console.warn(`${LOG_PREFIX} Rejected ${request.method} ${request.url}: method not allowed.`); + this.log('warn', `Rejected ${request.method} ${request.url}: method not allowed.`); return json(response, 405, { error: 'Method Not Allowed' }, this.negotiatedProtocolVersion); } + const acceptHeaderError = this.validateAcceptHeader(request); + if (acceptHeaderError) { + return json(response, 406, acceptHeaderError, this.negotiatedProtocolVersion); + } + const body = await this.readBody(request); if (!body) { return json(response, 400, this.createError(null, -32700, 'Parse error: empty body'), this.negotiatedProtocolVersion); @@ -137,24 +182,52 @@ class McpServer { } if (rpc && rpc.method) { - console.log(`${LOG_PREFIX} RPC ${rpc.method}`); + this.log('info', `RPC ${rpc.method}`); } const protocolHeaderError = this.validateProtocolVersionHeader(request, rpc); if (protocolHeaderError) { return json(response, 400, protocolHeaderError, this.negotiatedProtocolVersion); } + const responseProtocolVersion = this.getProtocolVersionForResponse(request, rpc); + + const sessionError = this.validateSession(request, rpc); + if (sessionError) { + return json(response, sessionError.statusCode, sessionError.error, responseProtocolVersion); + } + + const messageType = this.classifyJsonRpcMessage(rpc); + if (messageType === 'response') { + return empty(response, 202, responseProtocolVersion); + } + + if (messageType === 'notification') { + const notificationError = this.handleRpcNotification(rpc); + if (notificationError) { + return json(response, 400, notificationError, responseProtocolVersion); + } + return empty(response, 202, responseProtocolVersion); + } + + if (messageType !== 'request') { + return json(response, 400, this.createError(rpc && rpc.id, -32600, 'Invalid Request'), responseProtocolVersion); + } const result = await this.handleRpcRequest(rpc); if (result == null) { - response.writeHead(204, { 'MCP-Protocol-Version': this.negotiatedProtocolVersion }); - response.end(); - return; + return empty(response, 202, responseProtocolVersion); } - return json(response, 200, result, this.negotiatedProtocolVersion); + const extraHeaders = {}; + if (this.enableSessions && rpc.method === 'initialize' && result && !result.error) { + const sessionId = this.createSessionId(); + this.sessions.add(sessionId); + extraHeaders['Mcp-Session-Id'] = sessionId; + } + + return json(response, 200, result, this.getProtocolVersionForResponse(request, rpc), extraHeaders); } catch (error) { - console.error(`${LOG_PREFIX} Request handling failed: ${error.message}`); + this.log('error', `Request handling failed: ${error.message}`); const statusCode = error.statusCode || 500; const rpcCode = error.rpcCode || -32603; const message = statusCode === 500 ? `Internal error: ${error.message}` : error.message; @@ -167,7 +240,7 @@ class McpServer { let lastError = null; while (attempt <= MAX_PORT_FALLBACK_ATTEMPTS) { - console.log(`${LOG_PREFIX} Creating HTTP server on ${this.config.host}:${port}...`); + this.log('info', `Creating HTTP server on ${this.config.host}:${port}...`); const candidate = http.createServer(requestHandler); try { @@ -177,27 +250,27 @@ class McpServer { ? candidate.address().port : port; - if (this.actualPort !== this.config.port) { + if (this.config.port !== 0 && this.actualPort !== this.config.port) { this.portFallbackInfo = { requestedPort: this.config.port, actualPort: this.actualPort, attempts: attempt, }; - console.warn( - `${LOG_PREFIX} Port ${this.config.port} was unavailable. ` + - `Fell back to ${this.actualPort}.` + this.log( + 'warn', + `Port ${this.config.port} was unavailable. Fell back to ${this.actualPort}.` ); } - console.log(`${LOG_PREFIX} Listening on http://${this.config.host}:${this.actualPort}/`); + this.log('info', `Listening on http://${this.config.host}:${this.actualPort}/`); return; } catch (error) { lastError = error; if (error && error.code === 'EADDRINUSE' && port < 65535 && attempt < MAX_PORT_FALLBACK_ATTEMPTS) { const nextPort = port + 1; - console.warn( - `${LOG_PREFIX} Port ${port} is already in use. ` + - `Trying fallback port ${nextPort}...` + this.log( + 'warn', + `Port ${port} is already in use. Trying fallback port ${nextPort}...` ); port = nextPort; attempt += 1; @@ -217,11 +290,11 @@ class McpServer { async stop() { if (!this.server) { - console.log(`${LOG_PREFIX} Stop skipped: server object is empty.`); + this.log('info', 'Stop skipped: server object is empty.'); return; } - console.log(`${LOG_PREFIX} Closing HTTP server...`); + this.log('info', 'Closing HTTP server...'); const active = this.server; this.server = null; this.actualPort = null; @@ -229,11 +302,11 @@ class McpServer { await new Promise((resolve, reject) => { active.close((error) => { if (error) { - console.error(`${LOG_PREFIX} Close failed: ${error.message}`); + this.log('error', `Close failed: ${error.message}`); reject(error); return; } - console.log(`${LOG_PREFIX} HTTP server closed.`); + this.log('info', 'HTTP server closed.'); resolve(); }); }); @@ -295,6 +368,35 @@ class McpServer { } } + validateAcceptHeader(request) { + const header = request.headers && request.headers.accept; + if (!header) { + return this.createError( + null, + -32600, + 'Missing Accept header. Streamable HTTP clients must accept application/json and text/event-stream.' + ); + } + + const tokens = String(Array.isArray(header) ? header.join(',') : header) + .split(',') + .map((item) => item.split(';')[0].trim().toLowerCase()) + .filter(Boolean); + const hasWildcard = tokens.includes('*/*'); + const hasJson = hasWildcard || tokens.includes('application/json') || tokens.includes('application/*'); + const hasSse = hasWildcard || tokens.includes('text/event-stream') || tokens.includes('text/*'); + + if (!hasJson || !hasSse) { + return this.createError( + null, + -32600, + 'Invalid Accept header. Streamable HTTP clients must accept both application/json and text/event-stream.' + ); + } + + return null; + } + validateProtocolVersionHeader(request, rpc) { const header = request.headers && request.headers['mcp-protocol-version']; if (!header || (rpc && rpc.method === 'initialize')) { @@ -313,6 +415,110 @@ class McpServer { return null; } + getProtocolVersionForResponse(request, rpc) { + if (rpc && rpc.method === 'initialize') { + return this.negotiatedProtocolVersion; + } + + const header = request.headers && request.headers['mcp-protocol-version']; + const version = Array.isArray(header) ? header[0] : header ? String(header) : ''; + if (SUPPORTED_PROTOCOL_VERSIONS.includes(version)) { + return version; + } + return this.negotiatedProtocolVersion; + } + + validateSession(request, rpc) { + if (!this.enableSessions || (rpc && rpc.method === 'initialize')) { + return null; + } + + const sessionId = this.getSessionId(request); + if (!sessionId) { + return { + statusCode: 400, + error: this.createError(rpc && rpc.id, -32600, 'Missing Mcp-Session-Id header.'), + }; + } + if (!this.sessions.has(sessionId)) { + return { + statusCode: 404, + error: this.createError(rpc && rpc.id, -32001, 'Unknown or expired MCP session.'), + }; + } + return null; + } + + getSessionId(request) { + const value = request.headers && (request.headers['mcp-session-id'] || request.headers['Mcp-Session-Id']); + if (Array.isArray(value)) { + return value[0] || ''; + } + return value ? String(value) : ''; + } + + createSessionId() { + if (typeof crypto.randomUUID === 'function') { + return crypto.randomUUID(); + } + return crypto.randomBytes(16).toString('hex'); + } + + handleDelete(request, response) { + if (!this.enableSessions) { + return json(response, 405, { error: 'Method Not Allowed: MCP sessions are disabled' }, this.negotiatedProtocolVersion); + } + + const sessionId = this.getSessionId(request); + if (!sessionId) { + return json( + response, + 400, + this.createError(null, -32600, 'Missing Mcp-Session-Id header.'), + this.negotiatedProtocolVersion + ); + } + if (!this.sessions.has(sessionId)) { + return json( + response, + 404, + this.createError(null, -32001, 'Unknown or expired MCP session.'), + this.negotiatedProtocolVersion + ); + } + + this.sessions.delete(sessionId); + return empty(response, 202, this.negotiatedProtocolVersion); + } + + classifyJsonRpcMessage(message) { + if (!message || message.jsonrpc !== '2.0') { + return 'invalid'; + } + if (typeof message.method === 'string') { + return Object.prototype.hasOwnProperty.call(message, 'id') ? 'request' : 'notification'; + } + if ( + Object.prototype.hasOwnProperty.call(message, 'id') && + (Object.prototype.hasOwnProperty.call(message, 'result') || Object.prototype.hasOwnProperty.call(message, 'error')) + ) { + return 'response'; + } + return 'invalid'; + } + + handleRpcNotification(notification) { + if (!notification || notification.jsonrpc !== '2.0' || typeof notification.method !== 'string') { + return this.createError(null, -32600, 'Invalid Request'); + } + + if (notification.method.startsWith('notifications/')) { + return null; + } + + return this.createError(null, -32601, `Notification method not found: ${notification.method}`); + } + async handleRpcRequest(request) { if (!request || request.jsonrpc !== '2.0') { return this.createError(request && request.id, -32600, 'Invalid Request'); diff --git a/lib/tool-registry.js b/lib/tool-registry.js index 08c70aa..83cdd1e 100644 --- a/lib/tool-registry.js +++ b/lib/tool-registry.js @@ -16,10 +16,36 @@ const { } = require('./assets'); const { runScriptDiagnostics } = require('./diagnostics'); const { listWindows, sendKeyCombo, sendKeyPress, sendMouseClick, sendMouseDrag } = require('./input'); +const { + clearProjectLogFiles, + getRecentProjectLogs, + searchProjectLogs, +} = require('./logs'); const { resolveProjectPath } = require('./path-safety'); const { captureDesktopScreenshot, captureEditorWindowScreenshot, capturePanelScreenshot } = require('./screenshots'); +const { checkForUpdate } = require('./update-checker'); const { safeStringify } = require('./utils'); +const TOOL_CATEGORY_RULES = [ + ['updates', /update/], + ['logs', /log/], + ['diagnostics', /diagnostic|validate/], + ['screenshots', /screenshot|capture/], + ['input', /mouse|key|input|button_click/], + ['files', /file|directory|exists|refresh_assets/], + ['assets', /asset|scene$|scenes|open_scene|run_scene_asset/], + ['prefabs', /prefab/], + ['selection', /selection|select_/], + ['components', /component/], + ['ui', /canvas|label|button|sprite/], + ['camera', /camera/], + ['animation', /animation|clip/], + ['runtime', /runtime|time_scale|node_event|invoke_component/], + ['scene', /scene|hierarchy|node/], + ['execution', /execute_/], + ['project', /project|editor_state|tool_catalog/], +]; + function createSchema(properties, required) { const schema = { type: 'object', @@ -31,6 +57,74 @@ function createSchema(properties, required) { return schema; } +function inferToolCategory(toolName) { + for (const [category, pattern] of TOOL_CATEGORY_RULES) { + if (pattern.test(toolName)) { + return category; + } + } + return 'other'; +} + +function normalizeNameSet(values) { + return new Set( + (Array.isArray(values) ? values : []) + .map((value) => String(value || '').trim()) + .filter(Boolean) + ); +} + +function normalizeCategorySet(values) { + return new Set( + (Array.isArray(values) ? values : []) + .map((value) => String(value || '').trim().toLowerCase()) + .filter(Boolean) + ); +} + +function toolCategory(tool) { + return tool.category || inferToolCategory(tool.name); +} + +function isToolExposed(config, tool) { + const profile = config && config.toolProfile === 'full' + ? 'full' + : config && config.toolProfile === 'custom' + ? 'custom' + : 'core'; + const category = toolCategory(tool); + const enabledTools = normalizeNameSet(config && config.enabledTools); + const disabledTools = normalizeNameSet(config && config.disabledTools); + const enabledCategories = normalizeCategorySet(config && config.enabledToolCategories); + const disabledCategories = normalizeCategorySet(config && config.disabledToolCategories); + + let exposed = profile === 'full' || tool.profile === 'core'; + if (profile === 'custom') { + exposed = tool.profile === 'core' || enabledTools.has(tool.name) || enabledCategories.has(category); + } else if (enabledTools.has(tool.name) || enabledCategories.has(category)) { + exposed = true; + } + + if (disabledTools.has(tool.name) || disabledCategories.has(category)) { + exposed = false; + } + + return exposed; +} + +function summarizeDiagnostics(result) { + if (!result) { + return null; + } + return { + ok: Boolean(result.ok), + tool: result.tool, + summary: result.summary, + diagnosticCount: Array.isArray(result.diagnostics) ? result.diagnostics.length : 0, + diagnostics: Array.isArray(result.diagnostics) ? result.diagnostics.slice(0, 20) : [], + }; +} + function toOutput(value) { if (typeof value === 'string') { return value; @@ -123,7 +217,7 @@ async function refreshAssets(projectPath, targetPath) { return 'File written outside assets directory; no asset-db refresh was needed.'; } -function createToolRegistry({ getRuntimeContext, getStatus, interactionLog, sceneBridge, editorExecutor }) { +function createToolRegistry({ getRuntimeContext, getStatus, interactionLog, runtimeLog, sceneBridge, editorExecutor }) { const tools = [ { name: 'execute_javascript', @@ -225,6 +319,31 @@ function createToolRegistry({ getRuntimeContext, getStatus, interactionLog, scen }; }, }, + { + name: 'get_tool_catalog', + profile: 'core', + description: '[specialist] Return every built-in MCP tool with profile, category, and current exposure state. Use this before changing custom tool exposure.', + inputSchema: createSchema({}, []), + handler: async () => registry.listToolCatalog(), + }, + { + name: 'check_for_updates', + profile: 'core', + description: '[specialist] Check the latest Funplay Cocos MCP GitHub release and compare it with the installed extension version.', + inputSchema: createSchema( + { + timeoutMs: { type: 'number', description: 'Optional network timeout in milliseconds.' }, + }, + [] + ), + handler: async (args) => { + const runtimeContext = getRuntimeContext(); + return await checkForUpdate({ + currentVersion: runtimeContext.version, + timeoutMs: Number.isFinite(args.timeoutMs) ? args.timeoutMs : 5000, + }); + }, + }, { name: 'get_selection', profile: 'core', @@ -1016,6 +1135,129 @@ function createToolRegistry({ getRuntimeContext, getStatus, interactionLog, scen return await runScriptDiagnostics(projectPath, args); }, }, + { + name: 'get_recent_logs', + profile: 'core', + description: '[specialist] Return recent MCP runtime logs, recent tool interactions, and tails of common project log files.', + inputSchema: createSchema( + { + limit: { type: 'number', description: 'Maximum in-memory runtime/interactions to return.' }, + includeProjectLogs: { type: 'boolean', description: 'Include tails from common project log files.' }, + projectLogLines: { type: 'number', description: 'Tail lines to read per project log file.' }, + }, + [] + ), + handler: async (args) => { + const { projectPath } = getRuntimeContext(); + const limit = Number.isFinite(args.limit) ? Math.max(1, Math.min(200, args.limit)) : 50; + return { + runtimeLogs: runtimeLog && typeof runtimeLog.list === 'function' ? runtimeLog.list(limit) : [], + interactions: interactionLog && typeof interactionLog.list === 'function' ? interactionLog.list(limit) : [], + projectLogs: args.includeProjectLogs === false + ? [] + : getRecentProjectLogs(projectPath, { + limit: 10, + lines: Number.isFinite(args.projectLogLines) ? args.projectLogLines : 80, + }), + }; + }, + }, + { + name: 'search_project_logs', + profile: 'core', + description: '[specialist] Search common Cocos project log files for a string or regular expression.', + inputSchema: createSchema( + { + query: { type: 'string', description: 'Text or regex pattern to search for.' }, + regex: { type: 'boolean', description: 'Treat query as a JavaScript regular expression.' }, + caseSensitive: { type: 'boolean', description: 'Use case-sensitive matching.' }, + limit: { type: 'number', description: 'Maximum matches to return.' }, + directory: { type: 'string', description: 'Optional project-relative log directory to search.' }, + }, + ['query'] + ), + handler: async (args) => { + const { projectPath } = getRuntimeContext(); + return searchProjectLogs(projectPath, args); + }, + }, + { + name: 'clear_logs', + profile: 'core', + description: '[specialist] Clear in-memory MCP logs and, only with explicit confirmation, truncate common project log files.', + inputSchema: createSchema( + { + scope: { type: 'string', description: 'mcp, project, or all. Defaults to mcp.' }, + confirmProjectLogs: { type: 'boolean', description: 'Required when scope includes project log files.' }, + directory: { type: 'string', description: 'Optional project-relative log directory to clear.' }, + }, + [] + ), + handler: async (args) => { + const { projectPath } = getRuntimeContext(); + const scope = String(args.scope || 'mcp').toLowerCase(); + const clearMcp = scope === 'mcp' || scope === 'all'; + const clearProject = scope === 'project' || scope === 'all'; + const result = { + runtimeLogEntriesCleared: 0, + interactionEntriesCleared: 0, + projectLogFilesCleared: [], + }; + + if (clearMcp) { + result.runtimeLogEntriesCleared = runtimeLog && typeof runtimeLog.clear === 'function' ? runtimeLog.clear() : 0; + result.interactionEntriesCleared = interactionLog && typeof interactionLog.clear === 'function' ? interactionLog.clear() : 0; + } + + if (clearProject) { + if (!args.confirmProjectLogs) { + throw new Error('confirmProjectLogs=true is required before truncating project log files.'); + } + result.projectLogFilesCleared = clearProjectLogFiles(projectPath, { directory: args.directory, limit: 50 }); + } + + if (!clearMcp && !clearProject) { + throw new Error("scope must be 'mcp', 'project', or 'all'."); + } + + return result; + }, + }, + { + name: 'validate_scene', + profile: 'core', + description: '[specialist] Run a compact validation pass over the active scene, runtime state, TypeScript diagnostics, and recent project log errors.', + inputSchema: createSchema( + { + maxDepth: { type: 'number', description: 'Scene hierarchy depth for the scene snapshot.' }, + includeScriptDiagnostics: { type: 'boolean', description: 'Run TypeScript diagnostics as part of validation.' }, + includeLogErrors: { type: 'boolean', description: 'Search project logs for error lines.' }, + }, + [] + ), + handler: async (args) => { + const { projectPath } = getRuntimeContext(); + const scene = await sceneBridge.call('getSceneInfo', { + maxDepth: Number.isFinite(args.maxDepth) ? args.maxDepth : 2, + includeComponents: true, + }).catch((error) => ({ ok: false, error: error.message })); + const runtime = await sceneBridge.call('getRuntimeState', {}).catch((error) => ({ ok: false, error: error.message })); + const diagnostics = args.includeScriptDiagnostics === false + ? null + : summarizeDiagnostics(await runScriptDiagnostics(projectPath, args).catch((error) => ({ ok: false, summary: error.message, diagnostics: [] }))); + const logErrors = args.includeLogErrors === false + ? null + : searchProjectLogs(projectPath, { query: 'error', limit: 20 }).matches; + + return { + ok: !scene.error && !runtime.error && (!diagnostics || diagnostics.ok) && (!logErrors || logErrors.length === 0), + scene, + runtime, + diagnostics, + logErrors, + }; + }, + }, { name: 'get_runtime_state', profile: 'core', @@ -1333,20 +1575,30 @@ function createToolRegistry({ getRuntimeContext, getStatus, interactionLog, scen listTools() { const { config } = getRuntimeContext(); return tools - .filter((tool) => config.toolProfile === 'full' || tool.profile === 'core') + .filter((tool) => isToolExposed(config || {}, tool)) .map((tool) => ({ name: tool.name, description: tool.description, inputSchema: tool.inputSchema, })); }, + listToolCatalog() { + const { config } = getRuntimeContext(); + return tools.map((tool) => ({ + name: tool.name, + description: tool.description, + profile: tool.profile, + category: toolCategory(tool), + enabled: isToolExposed(config || {}, tool), + })); + }, async callToolDetailed(name, args) { const { config } = getRuntimeContext(); const tool = tools.find((item) => item.name === name); if (!tool) { throw new Error(`Unknown tool '${name}'`); } - if (config.toolProfile !== 'full' && tool.profile !== 'core') { + if (!isToolExposed(config || {}, tool)) { throw new Error(`Tool '${name}' is not exposed by the current MCP tool profile '${config.toolProfile}'.`); } diff --git a/lib/update-checker.js b/lib/update-checker.js new file mode 100644 index 0000000..494013c --- /dev/null +++ b/lib/update-checker.js @@ -0,0 +1,109 @@ +'use strict'; + +const https = require('https'); + +const LATEST_RELEASE_URL = 'https://api.github.com/repos/FunplayAI/funplay-cocos-mcp/releases/latest'; + +function normalizeVersion(value) { + return String(value || '') + .trim() + .replace(/^v/i, '') + .split(/[+-]/)[0]; +} + +function parseVersion(value) { + return normalizeVersion(value) + .split('.') + .map((part) => Number.parseInt(part, 10)) + .map((part) => (Number.isFinite(part) ? part : 0)); +} + +function compareVersions(left, right) { + const leftParts = parseVersion(left); + const rightParts = parseVersion(right); + const length = Math.max(leftParts.length, rightParts.length, 3); + for (let index = 0; index < length; index += 1) { + const leftPart = leftParts[index] || 0; + const rightPart = rightParts[index] || 0; + if (leftPart > rightPart) return 1; + if (leftPart < rightPart) return -1; + } + return 0; +} + +function fetchJson(url, timeoutMs = 5000) { + return new Promise((resolve, reject) => { + const request = https.get( + url, + { + headers: { + Accept: 'application/vnd.github+json', + 'User-Agent': 'funplay-cocos-mcp-update-checker', + }, + timeout: timeoutMs, + }, + (response) => { + const chunks = []; + response.on('data', (chunk) => chunks.push(chunk)); + response.on('end', () => { + const body = Buffer.concat(chunks).toString('utf8'); + if (response.statusCode < 200 || response.statusCode >= 300) { + reject(new Error(`GitHub returned HTTP ${response.statusCode}: ${body.slice(0, 160)}`)); + return; + } + + try { + resolve(JSON.parse(body)); + } catch (error) { + reject(new Error(`Failed to parse GitHub response: ${error.message}`)); + } + }); + } + ); + + request.on('timeout', () => { + request.destroy(new Error(`Update check timed out after ${timeoutMs}ms.`)); + }); + request.on('error', reject); + }); +} + +async function checkForUpdate(options = {}) { + const currentVersion = normalizeVersion(options.currentVersion || '0.0.0'); + const checkedAt = new Date().toISOString(); + + try { + const release = await fetchJson(options.url || LATEST_RELEASE_URL, options.timeoutMs || 5000); + const latestVersion = normalizeVersion(release.tag_name || release.name || ''); + const comparison = latestVersion ? compareVersions(latestVersion, currentVersion) : 0; + return { + ok: true, + checkedAt, + currentVersion, + latestVersion, + updateAvailable: comparison > 0, + releaseUrl: release.html_url || '', + publishedAt: release.published_at || '', + source: options.url || LATEST_RELEASE_URL, + }; + } catch (error) { + return { + ok: false, + checkedAt, + currentVersion, + latestVersion: '', + updateAvailable: false, + releaseUrl: '', + publishedAt: '', + source: options.url || LATEST_RELEASE_URL, + error: error.message, + }; + } +} + +module.exports = { + LATEST_RELEASE_URL, + checkForUpdate, + compareVersions, + normalizeVersion, +}; diff --git a/package.json b/package.json index 67a72c3..5bda709 100644 --- a/package.json +++ b/package.json @@ -1,13 +1,13 @@ { "name": "funplay-cocos-mcp", "package_version": 2, - "version": "0.1.4", + "version": "0.2.0", "description": "Embedded MCP server for Cocos Creator with scene script execution, project resources, prompts, and file/scene tools.", "author": "Funplay", "license": "MIT", "main": "browser.js", "scripts": { - "check": "node --check browser.js && node --check scene.js && node --check panel/index.js && node --check lib/assets.js && node --check lib/client-config.js && node --check lib/config.js && node --check lib/diagnostics.js && node --check lib/electron-tools.js && node --check lib/input.js && node --check lib/interaction-log.js && node --check lib/path-safety.js && node --check lib/prompts.js && node --check lib/resources.js && node --check lib/screenshots.js && node --check lib/server.js && node --check lib/tool-registry.js && node --check lib/utils.js", + "check": "node --check browser.js && node --check scene.js && node --check panel/index.js && node --check lib/assets.js && node --check lib/client-config.js && node --check lib/config.js && node --check lib/diagnostics.js && node --check lib/electron-tools.js && node --check lib/input.js && node --check lib/interaction-log.js && node --check lib/logs.js && node --check lib/path-safety.js && node --check lib/prompts.js && node --check lib/resources.js && node --check lib/runtime-log.js && node --check lib/screenshots.js && node --check lib/server.js && node --check lib/tool-registry.js && node --check lib/update-checker.js && node --check lib/utils.js", "test": "node --test" }, "panels": { @@ -77,6 +77,11 @@ "callToolFromPanel" ] }, + "check-updates": { + "methods": [ + "checkUpdates" + ] + }, "read-resource": { "methods": [ "readResourceFromPanel" diff --git a/panel/index.js b/panel/index.js index 0741a12..c2d74ac 100644 --- a/panel/index.js +++ b/panel/index.js @@ -19,7 +19,7 @@ module.exports = Editor.Panel.define({

Funplay Cocos MCP

-

Operate the embedded MCP server from Cocos Creator.

+

Version

Unknown
@@ -34,6 +34,7 @@ module.exports = Editor.Panel.define({ Restart Copy URL + Check Updates
@@ -41,12 +42,34 @@ module.exports = Editor.Panel.define({ + +
+

Changes auto-save. Port/profile changes restart the server when needed.

+
+

Tool Manager

+
+
+ Core + Full + Custom +
+
+ + + + +
+
+

MCP Client Config

@@ -129,6 +152,12 @@ module.exports = Editor.Panel.define({ gap: 8px; align-items: end; } + .tool-config-grid { + display: grid; + grid-template-columns: repeat(2, minmax(180px, 1fr)); + gap: 8px; + margin-top: 8px; + } label { display: flex; flex-direction: column; @@ -138,6 +167,7 @@ module.exports = Editor.Panel.define({ .checkbox-line { flex-direction: row; align-items: center; + min-height: 28px; } .checkbox-inline { padding-top: 0; @@ -184,6 +214,9 @@ module.exports = Editor.Panel.define({ width: 100%; min-height: 100px; } + .tool-config-grid ui-textarea { + min-height: 72px; + } pre { min-height: 120px; max-height: 220px; @@ -203,12 +236,24 @@ module.exports = Editor.Panel.define({ $: { root: '.mcp-root', statusPill: '#statusPill', + versionText: '#versionText', statusText: '#statusText', enabledInput: '#enabledInput', portInput: '#portInput', profileSelect: '#profileSelect', + sessionsInput: '#sessionsInput', restartBtn: '#restartBtn', copyUrlBtn: '#copyUrlBtn', + checkUpdatesBtn: '#checkUpdatesBtn', + updateStatus: '#updateStatus', + toolSummary: '#toolSummary', + useCoreBtn: '#useCoreBtn', + useFullBtn: '#useFullBtn', + useCustomBtn: '#useCustomBtn', + enabledCategoriesInput: '#enabledCategoriesInput', + disabledCategoriesInput: '#disabledCategoriesInput', + enabledToolsInput: '#enabledToolsInput', + disabledToolsInput: '#disabledToolsInput', clientTargetSelect: '#clientTargetSelect', configureClientBtn: '#configureClientBtn', clientTargetStatus: '#clientTargetStatus', @@ -230,6 +275,7 @@ module.exports = Editor.Panel.define({ const config = state.config || {}; const isRunning = Boolean(status.running); + this.$.versionText.textContent = `Version ${status.version || 'unknown'}`; this.$.statusPill.textContent = isRunning ? 'Running' : 'Stopped'; this.$.statusPill.classList.toggle('running', isRunning); this.$.statusPill.classList.toggle('stopped', !isRunning); @@ -242,10 +288,47 @@ module.exports = Editor.Panel.define({ this.$.enabledInput.value = Boolean(isRunning || config.autostart); this.$.portInput.value = Number(config.port || status.port || 8765); this.$.profileSelect.value = config.toolProfile || status.toolProfile || 'core'; + this.$.sessionsInput.value = Boolean(config.enableSessions || status.enableSessions); + this.$.enabledCategoriesInput.value = this.formatList(config.enabledToolCategories); + this.$.disabledCategoriesInput.value = this.formatList(config.disabledToolCategories); + this.$.enabledToolsInput.value = this.formatList(config.enabledTools); + this.$.disabledToolsInput.value = this.formatList(config.disabledTools); + this.renderUpdateStatus(); + this.renderToolSummary(); this.$.clientConfigText.value = state.clientConfig ? state.clientConfig.codex : ''; this.renderClientTargets(); }, + formatList(value) { + return Array.isArray(value) ? value.join('\n') : ''; + }, + parseList(value) { + return String(value || '') + .split(/[\n,]/) + .map((item) => item.trim()) + .filter(Boolean); + }, + renderUpdateStatus() { + const update = this.state && this.state.updateInfo; + if (!update) { + this.$.updateStatus.textContent = ''; + return; + } + if (!update.ok) { + this.$.updateStatus.textContent = `Update check failed: ${update.error}`; + return; + } + this.$.updateStatus.textContent = update.updateAvailable + ? `Update available: ${update.latestVersion} (${update.releaseUrl})` + : `Up to date: ${update.currentVersion}`; + }, + renderToolSummary() { + const catalog = (this.state && this.state.toolCatalog) || []; + const enabled = catalog.filter((tool) => tool.enabled); + const categories = Array.from(new Set(catalog.map((tool) => tool.category))).sort(); + this.$.toolSummary.textContent = + `Enabled ${enabled.length}/${catalog.length} tools | Categories: ${categories.join(', ')}`; + }, renderClientTargets() { const targets = (this.state && this.state.clientTargets) || []; const preferred = this.state && this.state.config ? this.state.config.lastClientTargetId : ''; @@ -299,6 +382,11 @@ module.exports = Editor.Panel.define({ host: (this.state && this.state.config && this.state.config.host) || (this.state && this.state.status && this.state.status.host) || '127.0.0.1', port: Number(this.$.portInput.value || 8765), toolProfile: this.$.profileSelect.value || 'core', + enabledToolCategories: this.parseList(this.$.enabledCategoriesInput.value).map((item) => item.toLowerCase()), + disabledToolCategories: this.parseList(this.$.disabledCategoriesInput.value).map((item) => item.toLowerCase()), + enabledTools: this.parseList(this.$.enabledToolsInput.value), + disabledTools: this.parseList(this.$.disabledToolsInput.value), + enableSessions: Boolean(this.$.sessionsInput.value), autostart: Boolean(this.$.enabledInput.value), maxInteractionLogEntries: this.state && this.state.config ? this.state.config.maxInteractionLogEntries : 50, lastClientTargetId: this.$.clientTargetSelect.value || 'claude_code', @@ -331,9 +419,35 @@ module.exports = Editor.Panel.define({ .then(() => this.showOutput('Copied URL to clipboard.')) .catch(() => this.showOutput(text)); }); + this.$.checkUpdatesBtn.addEventListener('click', () => this.runAction(() => request('check-updates'))); this.$.enabledInput.addEventListener('change', () => this.handleEnableToggle()); this.$.portInput.addEventListener('change', () => this.persistConfig({ showOutput: true })); this.$.profileSelect.addEventListener('change', () => this.persistConfig({ showOutput: true })); + this.$.sessionsInput.addEventListener('change', () => this.persistConfig({ showOutput: true })); + this.$.enabledCategoriesInput.addEventListener('change', () => this.persistConfig({ showOutput: true })); + this.$.disabledCategoriesInput.addEventListener('change', () => this.persistConfig({ showOutput: true })); + this.$.enabledToolsInput.addEventListener('change', () => this.persistConfig({ showOutput: true })); + this.$.disabledToolsInput.addEventListener('change', () => this.persistConfig({ showOutput: true })); + this.$.useCoreBtn.addEventListener('click', () => { + this.$.profileSelect.value = 'core'; + this.$.enabledCategoriesInput.value = ''; + this.$.disabledCategoriesInput.value = ''; + this.$.enabledToolsInput.value = ''; + this.$.disabledToolsInput.value = ''; + this.persistConfig({ showOutput: true }); + }); + this.$.useFullBtn.addEventListener('click', () => { + this.$.profileSelect.value = 'full'; + this.$.enabledCategoriesInput.value = ''; + this.$.disabledCategoriesInput.value = ''; + this.$.enabledToolsInput.value = ''; + this.$.disabledToolsInput.value = ''; + this.persistConfig({ showOutput: true }); + }); + this.$.useCustomBtn.addEventListener('click', () => { + this.$.profileSelect.value = 'custom'; + this.persistConfig({ showOutput: true }); + }); this.$.clientTargetSelect.addEventListener('confirm', () => this.renderClientTargetStatus()); this.$.clientTargetSelect.addEventListener('change', () => { this.renderClientTargetStatus(); diff --git a/test/logs.test.js b/test/logs.test.js new file mode 100644 index 0000000..4ca42ba --- /dev/null +++ b/test/logs.test.js @@ -0,0 +1,23 @@ +'use strict'; + +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const test = require('node:test'); +const { getRecentProjectLogs, searchProjectLogs } = require('../lib/logs'); + +test('project log helpers read and search common project log files', () => { + const projectPath = fs.mkdtempSync(path.join(os.tmpdir(), 'funplay-cocos-logs-')); + const logDir = path.join(projectPath, 'temp', 'logs'); + fs.mkdirSync(logDir, { recursive: true }); + fs.writeFileSync(path.join(logDir, 'editor.log'), 'first line\nError: broken scene\nlast line\n', 'utf8'); + + const recent = getRecentProjectLogs(projectPath, { limit: 5, lines: 2 }); + assert.equal(recent.length, 1); + assert.match(recent[0].text, /broken scene/); + + const matches = searchProjectLogs(projectPath, { query: 'broken', limit: 5 }); + assert.equal(matches.count, 1); + assert.equal(matches.matches[0].path, 'temp/logs/editor.log'); +}); diff --git a/test/server.test.js b/test/server.test.js index 85cb354..4d378d5 100644 --- a/test/server.test.js +++ b/test/server.test.js @@ -1,6 +1,7 @@ 'use strict'; const assert = require('node:assert/strict'); +const http = require('node:http'); const test = require('node:test'); const { MCP_PROTOCOL_VERSION, @@ -8,9 +9,9 @@ const { SUPPORTED_PROTOCOL_VERSIONS, } = require('../lib/server'); -function createServer(toolRegistry = {}) { +function createServer(toolRegistry = {}, config = {}) { return new McpServer({ - config: { host: '127.0.0.1', port: 8765 }, + config: { host: '127.0.0.1', port: 8765, ...config }, toolRegistry: { listTools: () => [], callTool: async () => 'ok', @@ -26,11 +27,45 @@ function createServer(toolRegistry = {}) { getPrompt: () => ({ messages: [] }), }, interactionLog: { add() {} }, + runtimeLog: { add() {} }, serverName: 'test-server', serverVersion: '0.0.0-test', }); } +function httpJson(port, payload, headers = {}) { + const body = payload === undefined ? '' : JSON.stringify(payload); + return new Promise((resolve, reject) => { + const request = http.request( + { + host: '127.0.0.1', + port, + method: 'POST', + path: '/', + headers: { + Accept: 'application/json, text/event-stream', + 'Content-Type': 'application/json', + 'Content-Length': Buffer.byteLength(body), + ...headers, + }, + }, + (response) => { + const chunks = []; + response.on('data', (chunk) => chunks.push(chunk)); + response.on('end', () => { + resolve({ + statusCode: response.statusCode, + headers: response.headers, + body: Buffer.concat(chunks).toString('utf8'), + }); + }); + } + ); + request.on('error', reject); + request.end(body); + }); +} + test('initialize negotiates the current MCP protocol version by default', async () => { const server = createServer(); const response = await server.handleRpcRequest({ @@ -126,3 +161,81 @@ test('unsupported protocol version headers are rejected when present after initi assert.equal(response.error.code, -32600); assert.match(response.error.message, /Unsupported MCP protocol version/); }); + +test('streamable HTTP Accept headers must allow json and event-stream', () => { + const server = createServer(); + + assert.equal(server.validateAcceptHeader({ headers: { accept: 'application/json, text/event-stream' } }), null); + assert.equal(server.validateAcceptHeader({ headers: { accept: '*/*' } }), null); + + const response = server.validateAcceptHeader({ headers: { accept: 'application/json' } }); + assert.equal(response.error.code, -32600); + assert.match(response.error.message, /Accept header/); +}); + +test('JSON-RPC responses and notifications are classified for 202 handling', () => { + const server = createServer(); + + assert.equal(server.classifyJsonRpcMessage({ jsonrpc: '2.0', id: 1, result: {} }), 'response'); + assert.equal(server.classifyJsonRpcMessage({ jsonrpc: '2.0', method: 'notifications/initialized' }), 'notification'); + assert.equal(server.classifyJsonRpcMessage({ jsonrpc: '2.0', id: 1, method: 'tools/list' }), 'request'); +}); + +test('session validation requires a known session when enabled', () => { + const server = createServer({}, { enableSessions: true }); + server.sessions.add('abc123'); + + assert.equal( + server.validateSession( + { headers: { 'mcp-session-id': 'abc123' } }, + { jsonrpc: '2.0', id: 1, method: 'tools/list' } + ), + null + ); + + const missing = server.validateSession( + { headers: {} }, + { jsonrpc: '2.0', id: 1, method: 'tools/list' } + ); + assert.equal(missing.statusCode, 400); + + const unknown = server.validateSession( + { headers: { 'mcp-session-id': 'nope' } }, + { jsonrpc: '2.0', id: 1, method: 'tools/list' } + ); + assert.equal(unknown.statusCode, 404); +}); + +test('HTTP notifications return 202 Accepted with no body', async () => { + const server = createServer({}, { port: 0 }); + await server.start(); + try { + const response = await httpJson(server.getPort(), { + jsonrpc: '2.0', + method: 'notifications/initialized', + }); + + assert.equal(response.statusCode, 202); + assert.equal(response.body, ''); + } finally { + await server.stop(); + } +}); + +test('HTTP initialize can return an optional session id when sessions are enabled', async () => { + const server = createServer({}, { port: 0, enableSessions: true }); + await server.start(); + try { + const response = await httpJson(server.getPort(), { + jsonrpc: '2.0', + id: 1, + method: 'initialize', + params: { protocolVersion: MCP_PROTOCOL_VERSION }, + }); + + assert.equal(response.statusCode, 200); + assert.match(response.headers['mcp-session-id'], /^[\x21-\x7e]+$/); + } finally { + await server.stop(); + } +}); diff --git a/test/tool-registry.test.js b/test/tool-registry.test.js index 1da8931..e82fa6d 100644 --- a/test/tool-registry.test.js +++ b/test/tool-registry.test.js @@ -5,13 +5,15 @@ const path = require('node:path'); const test = require('node:test'); const { createToolRegistry } = require('../lib/tool-registry'); -function createRegistry(profile, projectPath = path.resolve('/tmp/funplay-cocos-test-project')) { +function createRegistry(profile, projectPath = path.resolve('/tmp/funplay-cocos-test-project'), configExtras = {}) { return createToolRegistry({ getRuntimeContext: () => ({ - config: { toolProfile: profile }, + config: { toolProfile: profile, ...configExtras }, projectPath, + version: '0.0.0-test', }), interactionLog: { add() {} }, + runtimeLog: { list: () => [], clear: () => 0 }, sceneBridge: { call: async () => ({ ok: true }) }, editorExecutor: async () => ({ ok: true }), }); @@ -19,21 +21,43 @@ function createRegistry(profile, projectPath = path.resolve('/tmp/funplay-cocos- test('core profile exposes the documented focused tool set', () => { const tools = createRegistry('core').listTools(); - assert.equal(tools.length, 22); + assert.equal(tools.length, 28); assert.equal(tools.some((tool) => tool.name === 'execute_javascript'), true); assert.equal(tools.some((tool) => tool.name === 'get_editor_state'), true); + assert.equal(tools.some((tool) => tool.name === 'get_tool_catalog'), true); + assert.equal(tools.some((tool) => tool.name === 'validate_scene'), true); assert.equal(tools.some((tool) => tool.name === 'set_selection'), true); assert.equal(tools.some((tool) => tool.name === 'write_file'), false); }); test('full profile exposes all built-in tools', () => { const tools = createRegistry('full').listTools(); - assert.equal(tools.length, 70); + assert.equal(tools.length, 76); assert.equal(tools.some((tool) => tool.name === 'write_file'), true); assert.equal(tools.some((tool) => tool.name === 'get_editor_state'), true); assert.equal(tools.some((tool) => tool.name === 'set_selection'), true); }); +test('custom profile can expose a category and disable a specific tool', () => { + const tools = createRegistry('custom', path.resolve('/tmp/funplay-cocos-test-project'), { + enabledToolCategories: ['files'], + disabledTools: ['write_file'], + }).listTools(); + + assert.equal(tools.some((tool) => tool.name === 'read_file'), true); + assert.equal(tools.some((tool) => tool.name === 'write_file'), false); + assert.equal(tools.some((tool) => tool.name === 'execute_javascript'), true); +}); + +test('tool catalog reports disabled tools under the current exposure settings', () => { + const catalog = createRegistry('core', path.resolve('/tmp/funplay-cocos-test-project'), { + disabledTools: ['execute_javascript'], + }).listToolCatalog(); + const executeTool = catalog.find((tool) => tool.name === 'execute_javascript'); + assert.equal(executeTool.enabled, false); + assert.equal(executeTool.category, 'execution'); +}); + test('file tools reject writes outside the project root', async () => { const registry = createRegistry('full'); await assert.rejects( diff --git a/test/update-checker.test.js b/test/update-checker.test.js new file mode 100644 index 0000000..79ff87e --- /dev/null +++ b/test/update-checker.test.js @@ -0,0 +1,15 @@ +'use strict'; + +const assert = require('node:assert/strict'); +const test = require('node:test'); +const { compareVersions, normalizeVersion } = require('../lib/update-checker'); + +test('normalizeVersion removes release tag prefixes and metadata', () => { + assert.equal(normalizeVersion('v1.2.3-beta+build'), '1.2.3'); +}); + +test('compareVersions compares semantic version numbers', () => { + assert.equal(compareVersions('1.2.4', '1.2.3'), 1); + assert.equal(compareVersions('1.2.3', '1.2.4'), -1); + assert.equal(compareVersions('1.2.3', 'v1.2.3'), 0); +});