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({
Operate the embedded MCP server from Cocos Creator. VersionFunplay Cocos MCP
-
Changes auto-save. Port/profile changes restart the server when needed.
+