Initial commit: funplay cocos mcp
This commit is contained in:
@@ -0,0 +1,8 @@
|
||||
.DS_Store
|
||||
node_modules/
|
||||
temp/
|
||||
Temp/
|
||||
Library/
|
||||
library/
|
||||
dist/
|
||||
build/
|
||||
@@ -0,0 +1,290 @@
|
||||
# Funplay Cocos MCP
|
||||
|
||||
一个嵌入在 `Cocos Creator 3.x` 扩展里的 MCP Server,对齐了你现有 `unity-mcp` 的核心思路:
|
||||
|
||||
- 编辑器内嵌 HTTP MCP 服务
|
||||
- `execute_javascript` 作为统一主工具
|
||||
- `tools / resources / prompts` 三层能力
|
||||
- 场景树、节点、文件系统、项目上下文的统一访问
|
||||
|
||||
当前实现更偏向 Unity MCP 的 `core` 能力集,而不是一次性把全部 Unity 工具逐个照搬。
|
||||
|
||||
## 已实现能力
|
||||
|
||||
- MCP 协议:
|
||||
- `initialize`
|
||||
- `tools/list`
|
||||
- `tools/call`
|
||||
- `resources/list`
|
||||
- `resources/read`
|
||||
- `resources/templates/list`
|
||||
- `prompts/list`
|
||||
- `prompts/get`
|
||||
- 核心工具:
|
||||
- `execute_javascript`
|
||||
- `execute_scene_script`
|
||||
- `execute_editor_script`
|
||||
- `get_scene_info`
|
||||
- `get_hierarchy`
|
||||
- `find_nodes`
|
||||
- `inspect_node`
|
||||
- `list_components`
|
||||
- `inspect_component`
|
||||
- `list_cameras`
|
||||
- `list_animations`
|
||||
- `play_animation`
|
||||
- `stop_animation`
|
||||
- `get_project_info`
|
||||
- `list_scenes`
|
||||
- `open_scene`
|
||||
- `list_prefabs`
|
||||
- `list_assets`
|
||||
- `inspect_asset`
|
||||
- `open_asset`
|
||||
- `select_asset`
|
||||
- `get_editor_selection`
|
||||
- `read_file`
|
||||
- `get_file_snippet`
|
||||
- `write_file`
|
||||
- `replace_in_file`
|
||||
- `search_files`
|
||||
- `list_directory`
|
||||
- `exists`
|
||||
- `refresh_assets`
|
||||
- `run_script_diagnostics`
|
||||
- `get_script_diagnostic_context`
|
||||
- `capture_desktop_screenshot`
|
||||
- `capture_editor_screenshot`
|
||||
- `capture_scene_screenshot`
|
||||
- `capture_game_screenshot`
|
||||
- `capture_preview_screenshot`
|
||||
- `list_editor_windows`
|
||||
- `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`
|
||||
- `full` 模式附加工具:
|
||||
- `create_node`
|
||||
- `delete_node`
|
||||
- `set_node_transform`
|
||||
- `add_component`
|
||||
- `remove_component`
|
||||
- `set_component_property`
|
||||
- `reset_component_property`
|
||||
- `create_canvas`
|
||||
- `create_label`
|
||||
- `create_button`
|
||||
- `create_sprite`
|
||||
- `create_camera`
|
||||
- `set_camera_properties`
|
||||
- `add_animation_clip`
|
||||
- `instantiate_prefab`
|
||||
- `run_scene_asset`
|
||||
- `delete_asset`
|
||||
- 资源:
|
||||
- `cocos://project/context`
|
||||
- `cocos://project/summary`
|
||||
- `cocos://scene/active`
|
||||
- `cocos://scene/current`
|
||||
- `cocos://selection/current`
|
||||
- `cocos://selection/asset`
|
||||
- `cocos://errors/scripts`
|
||||
- `cocos://mcp/interactions`
|
||||
|
||||
## 安装方式
|
||||
|
||||
把当前目录作为 Cocos Creator 扩展放到:
|
||||
|
||||
- 项目级:`<你的项目>/extensions/funplay-cocos-mcp`
|
||||
- 全局级:`<Cocos Creator 用户扩展目录>/funplay-cocos-mcp`
|
||||
|
||||
然后重启 Cocos Creator,扩展会默认自动启动 MCP 服务。
|
||||
|
||||
默认地址:
|
||||
|
||||
- `http://127.0.0.1:8765/`
|
||||
|
||||
## 图形化面板
|
||||
|
||||
扩展启用后,可以在 Cocos Creator 顶部菜单打开:
|
||||
|
||||
- `Funplay -> MCP Server`
|
||||
|
||||
面板里可以直接完成:
|
||||
|
||||
- 启动 / 停止 / 重启 MCP Server
|
||||
- 查看当前 URL、端口、Profile、项目名
|
||||
- 保存 `host / port / toolProfile / autostart` 配置
|
||||
- 一键写入 MCP Client 配置
|
||||
- 复制 Codex TOML 或 JSON MCP Client 配置
|
||||
- 查看工具列表、资源列表、最近交互日志
|
||||
- 直接从 Cocos 里调用工具并填写 JSON 参数
|
||||
- Quick Actions 优先给出 `execute_javascript`
|
||||
- 一键测试 `execute_javascript`、`get_project_info`、`get_scene_info`、`get_hierarchy`、截图、诊断等常用工具
|
||||
|
||||
一键配置当前支持:
|
||||
|
||||
- Claude Code / Claude Desktop:`~/.claude.json`
|
||||
- Cursor:`~/.cursor/mcp.json`
|
||||
- VS Code:`~/.vscode/mcp.json`
|
||||
- Trae:`~/.trae/mcp.json`
|
||||
- Kiro:`~/.kiro/settings/mcp.json`
|
||||
- Codex:`~/.codex/config.toml`
|
||||
|
||||
面板会使用当前端口写入 `funplay_cocos` MCP server。写入后请重启对应客户端。
|
||||
|
||||
## 可选配置
|
||||
|
||||
在 Cocos 项目根目录放一个 `funplay-cocos-mcp.config.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"host": "127.0.0.1",
|
||||
"port": 8765,
|
||||
"toolProfile": "core",
|
||||
"autostart": true,
|
||||
"maxInteractionLogEntries": 50
|
||||
}
|
||||
```
|
||||
|
||||
也支持环境变量:
|
||||
|
||||
- `COCOS_MCP_HOST`
|
||||
- `COCOS_MCP_PORT`
|
||||
- `COCOS_MCP_PROFILE`
|
||||
|
||||
## MCP Client 示例
|
||||
|
||||
### Codex
|
||||
|
||||
```toml
|
||||
[mcp_servers.funplay_cocos]
|
||||
url = "http://127.0.0.1:8765/"
|
||||
```
|
||||
|
||||
### Claude / Cursor
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"funplay_cocos": {
|
||||
"url": "http://127.0.0.1:8765/"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 推荐首测
|
||||
|
||||
先在 MCP Client 里调用:
|
||||
|
||||
1. `get_project_info`
|
||||
2. `get_scene_info`
|
||||
3. `get_hierarchy`
|
||||
4. `resources/read` with `cocos://project/context`
|
||||
5. `execute_javascript`(`context: "scene"`)
|
||||
6. `execute_javascript`(`context: "editor"`)
|
||||
7. `run_script_diagnostics`
|
||||
8. `capture_desktop_screenshot`
|
||||
9. `list_components`
|
||||
10. `get_script_diagnostic_context`
|
||||
11. `get_runtime_state`
|
||||
12. `capture_editor_screenshot`
|
||||
13. `list_editor_windows`
|
||||
14. `capture_scene_screenshot`
|
||||
15. `simulate_mouse_click`
|
||||
|
||||
其中 `execute_javascript` 是统一主工具,`execute_scene_script` / `execute_editor_script` 主要保留给兼容调用。
|
||||
|
||||
`execute_javascript` 示例,场景上下文:
|
||||
|
||||
```json
|
||||
{
|
||||
"context": "scene",
|
||||
"code": "return { sceneName: scene.name, rootCount: scene.children.length };"
|
||||
}
|
||||
```
|
||||
|
||||
`execute_javascript` 示例,编辑器上下文:
|
||||
|
||||
```json
|
||||
{
|
||||
"context": "editor",
|
||||
"code": "return { projectPath: context.projectPath, toolCount: helpers.listTools().length };"
|
||||
}
|
||||
```
|
||||
|
||||
`execute_scene_script.code` 兼容示例:
|
||||
|
||||
```js
|
||||
return {
|
||||
sceneName: scene.name,
|
||||
rootChildren: scene.children.map((node) => node.name),
|
||||
};
|
||||
```
|
||||
|
||||
## 和 Unity MCP 的对应关系
|
||||
|
||||
- Unity `execute_code` → Cocos `execute_javascript`
|
||||
- Unity `execute_code` 的场景/编辑器兼容拆分入口 → `execute_scene_script` / `execute_editor_script`
|
||||
- Unity `get_scene_info` → Cocos `get_scene_info`
|
||||
- Unity `get_hierarchy` → Cocos `get_hierarchy`
|
||||
- Unity `read_file/write_file/search_files` → 同名 Cocos 文件工具
|
||||
- Unity `resources/prompts` → 同结构的 Cocos 资源与提示词
|
||||
- Unity `core/full` → Cocos `toolProfile=core|full`
|
||||
|
||||
## 当前边界
|
||||
|
||||
这版已经进入第二阶段,补上了:
|
||||
|
||||
- 资产查询 / 打开 / 删除 / 选中
|
||||
- TypeScript 脚本诊断
|
||||
- 本机桌面截图回传 MCP image
|
||||
|
||||
现在第三段也补上了:
|
||||
|
||||
- 组件增删查改
|
||||
- Scene / Prefab 资产工具
|
||||
- 脚本修复辅助链路:`get_script_diagnostic_context` + `replace_in_file`
|
||||
|
||||
第四段继续补上了:
|
||||
|
||||
- UI 专用工具:Canvas / Label / Button / Sprite 创建
|
||||
- Camera 专用工具:列出、创建、属性设置
|
||||
- Animation 专用工具:列出、添加 Clip、播放、停止
|
||||
- 运行态控制:暂停、恢复、时间缩放、状态查询
|
||||
- 输入/交互模拟:按钮点击、节点事件、组件方法调用
|
||||
- 截图增强:桌面截图、Editor 窗口截图、场景截图别名
|
||||
|
||||
这次把之前两块缺口也补了:
|
||||
|
||||
- `capture_scene_screenshot` 现在优先按 Editor 内部面板区域裁剪,不再只是整窗截图别名
|
||||
- 新增 `capture_game_screenshot` / `capture_preview_screenshot`
|
||||
- 新增 `list_editor_windows` 方便先看 Cocos/Electron 当前有哪些窗口
|
||||
- 新增基于 Electron `webContents.sendInputEvent` 的底层输入注入:
|
||||
- `simulate_mouse_click`
|
||||
- `simulate_mouse_drag`
|
||||
- `simulate_key_press`
|
||||
- `simulate_key_combo`
|
||||
- `simulate_preview_input`
|
||||
|
||||
暂时还没有直接补齐 Unity MCP 里的这些能力:
|
||||
|
||||
- 对 Scene/Game 面板做“语义级”识别仍然是 best-effort,依赖当前 Cocos 面板 DOM 结构
|
||||
- Preview/Simulator 底层输入已经支持,但不同窗口标题和焦点状态可能需要先用 `list_editor_windows` 确认目标
|
||||
- 动画曲线/状态机深度编辑
|
||||
|
||||
如果你要,我下一步可以继续补下一阶段,专门给你做:
|
||||
|
||||
1. `SceneView/GameView 精确截图裁剪`
|
||||
2. `Preview/Simulator 键鼠事件注入`
|
||||
3. `动画曲线和状态机编辑`
|
||||
4. `更像 unity-mcp 的完整工具分层`
|
||||
+351
@@ -0,0 +1,351 @@
|
||||
'use strict';
|
||||
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const manifest = require('./package.json');
|
||||
const { configureTarget, getTargetStatuses } = require('./lib/client-config');
|
||||
const { loadConfig, getProjectPath, getProjectName, getCocosVersion } = require('./lib/config');
|
||||
const { McpServer } = require('./lib/server');
|
||||
const { createToolRegistry } = require('./lib/tool-registry');
|
||||
const { ResourceProvider } = require('./lib/resources');
|
||||
const { PromptProvider } = require('./lib/prompts');
|
||||
const { InteractionLog } = require('./lib/interaction-log');
|
||||
|
||||
const EXTENSION_NAME = manifest.name || 'funplay-cocos-mcp';
|
||||
const LOG_PREFIX = '[Funplay Cocos MCP]';
|
||||
const AsyncFunction = Object.getPrototypeOf(async function () {}).constructor;
|
||||
|
||||
class ExtensionService {
|
||||
constructor() {
|
||||
this.config = null;
|
||||
this.server = null;
|
||||
this.toolRegistry = null;
|
||||
this.resourceProvider = null;
|
||||
this.promptProvider = null;
|
||||
this.interactionLog = new InteractionLog();
|
||||
}
|
||||
|
||||
load() {
|
||||
console.log(`${LOG_PREFIX} Extension loading...`);
|
||||
this.reloadRuntime();
|
||||
if (this.config.autostart) {
|
||||
console.log(`${LOG_PREFIX} Autostart is enabled, starting MCP server.`);
|
||||
return this.startServer();
|
||||
}
|
||||
console.log(`${LOG_PREFIX} Autostart is disabled. MCP server is idle.`);
|
||||
return this.getStatus();
|
||||
}
|
||||
|
||||
unload() {
|
||||
console.log(`${LOG_PREFIX} Extension unloading...`);
|
||||
if (this.server) {
|
||||
this.server.stop();
|
||||
this.server = null;
|
||||
}
|
||||
console.log(`${LOG_PREFIX} Extension unloaded.`);
|
||||
}
|
||||
|
||||
openPanel() {
|
||||
if (!global.Editor || !Editor.Panel || typeof Editor.Panel.open !== 'function') {
|
||||
throw new Error('Editor.Panel.open is unavailable in this Cocos extension host.');
|
||||
}
|
||||
return Editor.Panel.open(EXTENSION_NAME);
|
||||
}
|
||||
|
||||
reloadRuntime() {
|
||||
this.config = loadConfig();
|
||||
console.log(
|
||||
`${LOG_PREFIX} 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') {
|
||||
throw new Error('Editor.Message.request is unavailable in the Cocos extension host.');
|
||||
}
|
||||
|
||||
return await Editor.Message.request('scene', 'execute-scene-script', {
|
||||
name: EXTENSION_NAME,
|
||||
method,
|
||||
args: [payload || {}],
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
const runtimeContext = () => ({
|
||||
extensionName: EXTENSION_NAME,
|
||||
version: manifest.version || '0.0.0',
|
||||
config: this.config,
|
||||
projectPath: getProjectPath(),
|
||||
projectName: getProjectName(),
|
||||
cocosVersion: getCocosVersion(),
|
||||
packagePath: path.dirname(__filename),
|
||||
});
|
||||
|
||||
this.toolRegistry = createToolRegistry({
|
||||
getRuntimeContext: runtimeContext,
|
||||
interactionLog: this.interactionLog,
|
||||
sceneBridge,
|
||||
editorExecutor: async (payload) => await this.executeEditorScript(payload, runtimeContext),
|
||||
});
|
||||
this.resourceProvider = new ResourceProvider(runtimeContext, sceneBridge, this.interactionLog);
|
||||
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}`);
|
||||
return this.getStatus();
|
||||
}
|
||||
|
||||
console.log(`${LOG_PREFIX} Starting MCP server...`);
|
||||
this.reloadRuntime();
|
||||
this.server = new McpServer({
|
||||
config: this.config,
|
||||
interactionLog: this.interactionLog,
|
||||
toolRegistry: this.toolRegistry,
|
||||
resourceProvider: this.resourceProvider,
|
||||
promptProvider: this.promptProvider,
|
||||
serverName: `Funplay Cocos MCP - ${getProjectName()}`,
|
||||
serverVersion: manifest.version || '0.0.0',
|
||||
});
|
||||
|
||||
await this.server.start();
|
||||
console.log(`${LOG_PREFIX} MCP server started at ${this.getStatus().url}`);
|
||||
return this.getStatus();
|
||||
}
|
||||
|
||||
async stopServer() {
|
||||
console.log(`${LOG_PREFIX} Stop requested.`);
|
||||
if (this.server) {
|
||||
await this.server.stop();
|
||||
this.server = null;
|
||||
console.log(`${LOG_PREFIX} MCP server stopped.`);
|
||||
} else {
|
||||
console.log(`${LOG_PREFIX} Stop requested but MCP server was not running.`);
|
||||
}
|
||||
return this.getStatus();
|
||||
}
|
||||
|
||||
async restartServer() {
|
||||
console.log(`${LOG_PREFIX} Restart requested.`);
|
||||
await this.stopServer();
|
||||
const status = await this.startServer();
|
||||
console.log(`${LOG_PREFIX} Restart completed. MCP server running=${status.running}, url=${status.url}`);
|
||||
return status;
|
||||
}
|
||||
|
||||
getStatus() {
|
||||
return {
|
||||
running: Boolean(this.server && this.server.isRunning()),
|
||||
host: this.config.host,
|
||||
port: this.config.port,
|
||||
toolProfile: this.config.toolProfile,
|
||||
autostart: this.config.autostart,
|
||||
projectPath: getProjectPath(),
|
||||
projectName: getProjectName(),
|
||||
cocosVersion: getCocosVersion(),
|
||||
url: `http://${this.config.host}:${this.config.port}/`,
|
||||
};
|
||||
}
|
||||
|
||||
getPanelState() {
|
||||
this.ensureRuntime();
|
||||
const status = this.getStatus();
|
||||
const tools = this.toolRegistry.listTools();
|
||||
const resources = this.resourceProvider.listResources();
|
||||
const prompts = this.promptProvider.listPrompts();
|
||||
|
||||
return {
|
||||
status,
|
||||
tools,
|
||||
resources,
|
||||
prompts,
|
||||
recentInteractions: this.interactionLog.list(20),
|
||||
config: this.config,
|
||||
clientConfig: this.getClientConfig(),
|
||||
clientTargets: getTargetStatuses(this.config),
|
||||
};
|
||||
}
|
||||
|
||||
listToolsForPanel() {
|
||||
this.ensureRuntime();
|
||||
return this.toolRegistry.listTools();
|
||||
}
|
||||
|
||||
async callToolFromPanel(name, args) {
|
||||
this.ensureRuntime();
|
||||
console.log(`${LOG_PREFIX} Panel calling tool: ${name}`);
|
||||
return await this.toolRegistry.callTool(name, args || {});
|
||||
}
|
||||
|
||||
async executeEditorScript(payload, runtimeContext) {
|
||||
const code = String(payload && payload.code || '');
|
||||
if (!code.trim()) {
|
||||
throw new Error('code is required.');
|
||||
}
|
||||
|
||||
const args = payload && payload.args ? payload.args : {};
|
||||
const context = runtimeContext();
|
||||
const helpers = {
|
||||
getStatus: () => this.getStatus(),
|
||||
listTools: () => this.toolRegistry.listTools(),
|
||||
readResource: async (uri) => await this.resourceProvider.readResource(uri),
|
||||
callTool: async (name, toolArgs) => await this.toolRegistry.callTool(name, toolArgs || {}),
|
||||
listClientTargets: () => getTargetStatuses(this.config),
|
||||
getClientConfig: () => this.getClientConfig(),
|
||||
configureClient: async (targetId) => this.configureClient(targetId),
|
||||
};
|
||||
|
||||
const runner = new AsyncFunction(
|
||||
'require',
|
||||
'Editor',
|
||||
'args',
|
||||
'context',
|
||||
'helpers',
|
||||
'fs',
|
||||
'path',
|
||||
'os',
|
||||
`
|
||||
const module = { exports: {} };
|
||||
const exports = module.exports;
|
||||
${code}
|
||||
if (typeof run === 'function') {
|
||||
return await run({ Editor, args, context, helpers, fs, path, os, require });
|
||||
}
|
||||
if (typeof module.exports === 'function') {
|
||||
return await module.exports({ Editor, args, context, helpers, fs, path, os, require });
|
||||
}
|
||||
if (module.exports && typeof module.exports.run === 'function') {
|
||||
return await module.exports.run({ Editor, args, context, helpers, fs, path, os, require });
|
||||
}
|
||||
`
|
||||
);
|
||||
|
||||
return await runner(require, global.Editor, args, context, helpers, fs, path, os);
|
||||
}
|
||||
|
||||
async readResourceFromPanel(uri) {
|
||||
this.ensureRuntime();
|
||||
console.log(`${LOG_PREFIX} Panel reading resource: ${uri}`);
|
||||
return await this.resourceProvider.readResource(uri);
|
||||
}
|
||||
|
||||
getClientConfig() {
|
||||
const url = `http://${this.config.host}:${this.config.port}/`;
|
||||
return {
|
||||
url,
|
||||
codex: `[mcp_servers.funplay_cocos]\nurl = "${url}"\n`,
|
||||
json: JSON.stringify({
|
||||
mcpServers: {
|
||||
funplay_cocos: {
|
||||
url,
|
||||
},
|
||||
},
|
||||
}, null, 2),
|
||||
};
|
||||
}
|
||||
|
||||
configureClient(targetId) {
|
||||
this.ensureRuntime();
|
||||
console.log(`${LOG_PREFIX} Configuring MCP client target: ${targetId}`);
|
||||
const result = configureTarget(this.config, targetId);
|
||||
console.log(`${LOG_PREFIX} MCP client configured: ${result.name} -> ${result.configPath}`);
|
||||
return {
|
||||
...result,
|
||||
clientTargets: getTargetStatuses(this.config),
|
||||
};
|
||||
}
|
||||
|
||||
async saveConfig(partialConfig) {
|
||||
this.ensureRuntime();
|
||||
const nextPort = partialConfig && partialConfig.port !== undefined
|
||||
? Number(partialConfig.port)
|
||||
: this.config.port;
|
||||
const nextMaxEntries = partialConfig && partialConfig.maxInteractionLogEntries !== undefined
|
||||
? Number(partialConfig.maxInteractionLogEntries)
|
||||
: this.config.maxInteractionLogEntries;
|
||||
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,
|
||||
autostart: partialConfig && typeof partialConfig.autostart === 'boolean'
|
||||
? partialConfig.autostart
|
||||
: this.config.autostart,
|
||||
maxInteractionLogEntries: Number.isInteger(nextMaxEntries)
|
||||
? Math.max(10, Math.min(500, nextMaxEntries))
|
||||
: this.config.maxInteractionLogEntries,
|
||||
};
|
||||
|
||||
const configPath = this.config.configPath;
|
||||
fs.writeFileSync(configPath, JSON.stringify(nextConfig, null, 2) + '\n', 'utf8');
|
||||
const wasRunning = Boolean(this.server && this.server.isRunning());
|
||||
if (wasRunning) {
|
||||
await this.stopServer();
|
||||
}
|
||||
this.reloadRuntime();
|
||||
if (wasRunning) {
|
||||
await this.startServer();
|
||||
}
|
||||
return this.getPanelState();
|
||||
}
|
||||
|
||||
ensureRuntime() {
|
||||
if (!this.config || !this.toolRegistry || !this.resourceProvider || !this.promptProvider) {
|
||||
this.reloadRuntime();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const service = new ExtensionService();
|
||||
|
||||
module.exports = {
|
||||
load() {
|
||||
return service.load();
|
||||
},
|
||||
unload() {
|
||||
return service.unload();
|
||||
},
|
||||
methods: {
|
||||
openPanel() {
|
||||
return service.openPanel();
|
||||
},
|
||||
startServer() {
|
||||
return service.startServer();
|
||||
},
|
||||
stopServer() {
|
||||
return service.stopServer();
|
||||
},
|
||||
restartServer() {
|
||||
return service.restartServer();
|
||||
},
|
||||
getStatus() {
|
||||
return service.getStatus();
|
||||
},
|
||||
getPanelState() {
|
||||
return service.getPanelState();
|
||||
},
|
||||
saveConfig(config) {
|
||||
return service.saveConfig(config);
|
||||
},
|
||||
listToolsForPanel() {
|
||||
return service.listToolsForPanel();
|
||||
},
|
||||
callToolFromPanel(name, args) {
|
||||
return service.callToolFromPanel(name, args);
|
||||
},
|
||||
readResourceFromPanel(uri) {
|
||||
return service.readResourceFromPanel(uri);
|
||||
},
|
||||
getClientConfig() {
|
||||
return service.getClientConfig();
|
||||
},
|
||||
configureClient(targetId) {
|
||||
return service.configureClient(targetId);
|
||||
},
|
||||
},
|
||||
};
|
||||
+182
@@ -0,0 +1,182 @@
|
||||
'use strict';
|
||||
|
||||
async function safeRequest(channel, method, ...args) {
|
||||
if (!global.Editor || !Editor.Message || typeof Editor.Message.request !== 'function') {
|
||||
throw new Error('Editor.Message.request is unavailable in the Cocos extension host.');
|
||||
}
|
||||
return await Editor.Message.request(channel, method, ...args);
|
||||
}
|
||||
|
||||
function buildAssetTargetCandidates(uuidOrPath) {
|
||||
const raw = String(uuidOrPath || '').trim().replace(/\\/g, '/');
|
||||
const candidates = [];
|
||||
const add = (value) => {
|
||||
if (value && !candidates.includes(value)) {
|
||||
candidates.push(value);
|
||||
}
|
||||
};
|
||||
|
||||
add(raw);
|
||||
|
||||
if (raw.startsWith('assets/')) {
|
||||
add(`db://${raw}`);
|
||||
} else if (raw.startsWith('/assets/')) {
|
||||
add(`db://${raw.slice(1)}`);
|
||||
}
|
||||
|
||||
if (raw.includes('/assets/')) {
|
||||
add(`db://assets/${raw.split('/assets/').pop()}`);
|
||||
}
|
||||
|
||||
if (raw.startsWith('db://assets/') && !raw.match(/\.[a-z0-9]+$/i)) {
|
||||
add(`${raw}.scene`);
|
||||
add(`${raw}.prefab`);
|
||||
add(`${raw}.ts`);
|
||||
}
|
||||
|
||||
return candidates;
|
||||
}
|
||||
|
||||
async function requestFirst(method, uuidOrPath) {
|
||||
const candidates = buildAssetTargetCandidates(uuidOrPath);
|
||||
let lastError = null;
|
||||
|
||||
for (const candidate of candidates) {
|
||||
try {
|
||||
const result = await safeRequest('asset-db', method, candidate);
|
||||
if (result != null) {
|
||||
return result;
|
||||
}
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
}
|
||||
}
|
||||
|
||||
if (lastError) {
|
||||
throw lastError;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function listAssets(options = {}) {
|
||||
const payload = {};
|
||||
if (options.pattern) {
|
||||
payload.pattern = options.pattern;
|
||||
}
|
||||
if (options.ccType) {
|
||||
payload.ccType = options.ccType;
|
||||
}
|
||||
const result = await safeRequest('asset-db', 'query-assets', payload);
|
||||
return Array.isArray(result) ? result : [];
|
||||
}
|
||||
|
||||
async function queryAssetInfo(uuidOrPath) {
|
||||
if (!uuidOrPath) {
|
||||
throw new Error('Asset uuid or path is required.');
|
||||
}
|
||||
|
||||
const direct = await requestFirst('query-asset-info', uuidOrPath);
|
||||
if (direct) {
|
||||
return direct;
|
||||
}
|
||||
|
||||
const url = await queryAssetUrl(uuidOrPath).catch(() => null);
|
||||
if (url) {
|
||||
const fromUrl = await safeRequest('asset-db', 'query-asset-info', url);
|
||||
if (fromUrl) {
|
||||
return fromUrl;
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(`Asset not found: ${uuidOrPath}`);
|
||||
}
|
||||
|
||||
async function queryAssetMeta(uuidOrPath) {
|
||||
if (!uuidOrPath) {
|
||||
throw new Error('Asset uuid or path is required.');
|
||||
}
|
||||
|
||||
const direct = await requestFirst('query-asset-meta', uuidOrPath);
|
||||
if (direct) {
|
||||
return direct;
|
||||
}
|
||||
|
||||
const info = await queryAssetInfo(uuidOrPath);
|
||||
return await safeRequest('asset-db', 'query-asset-meta', info.uuid || info.url || uuidOrPath);
|
||||
}
|
||||
|
||||
async function queryAssetData(uuidOrPath) {
|
||||
if (!uuidOrPath) {
|
||||
throw new Error('Asset uuid or path is required.');
|
||||
}
|
||||
|
||||
const direct = await requestFirst('query-asset-data', uuidOrPath);
|
||||
if (direct) {
|
||||
return direct;
|
||||
}
|
||||
|
||||
const info = await queryAssetInfo(uuidOrPath);
|
||||
return await safeRequest('asset-db', 'query-asset-data', info.uuid || info.url || uuidOrPath);
|
||||
}
|
||||
|
||||
async function queryAssetUrl(uuidOrPath) {
|
||||
if (!uuidOrPath) {
|
||||
throw new Error('Asset uuid or path is required.');
|
||||
}
|
||||
const result = await requestFirst('query-url', uuidOrPath);
|
||||
if (result) {
|
||||
return result;
|
||||
}
|
||||
throw new Error(`Asset URL not found: ${uuidOrPath}`);
|
||||
}
|
||||
|
||||
async function openAsset(uuidOrPath) {
|
||||
const info = await queryAssetInfo(uuidOrPath);
|
||||
await safeRequest('asset-db', 'open-asset', info.uuid || uuidOrPath);
|
||||
return info;
|
||||
}
|
||||
|
||||
async function deleteAsset(uuidOrPath) {
|
||||
let url = String(uuidOrPath || '');
|
||||
if (!url.startsWith('db://')) {
|
||||
const info = await queryAssetInfo(uuidOrPath);
|
||||
url = info.url || (await queryAssetUrl(info.uuid || uuidOrPath));
|
||||
}
|
||||
|
||||
await safeRequest('asset-db', 'delete-asset', url);
|
||||
return { deleted: true, url };
|
||||
}
|
||||
|
||||
function selectAsset(uuid) {
|
||||
if (!global.Editor || !Editor.Selection || typeof Editor.Selection.select !== 'function') {
|
||||
throw new Error('Editor.Selection.select is unavailable in this Cocos environment.');
|
||||
}
|
||||
|
||||
Editor.Selection.clear('asset');
|
||||
Editor.Selection.select('asset', uuid);
|
||||
return { selected: true, uuid };
|
||||
}
|
||||
|
||||
function getCurrentSelection() {
|
||||
if (!global.Editor || !Editor.Selection || typeof Editor.Selection.getSelected !== 'function') {
|
||||
throw new Error('Editor.Selection API is unavailable in this Cocos environment.');
|
||||
}
|
||||
|
||||
return {
|
||||
asset: Editor.Selection.getSelected('asset') || '',
|
||||
node: Editor.Selection.getSelected('node') || '',
|
||||
type: typeof Editor.Selection.getLastSelectedType === 'function' ? Editor.Selection.getLastSelectedType() : '',
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
deleteAsset,
|
||||
getCurrentSelection,
|
||||
listAssets,
|
||||
openAsset,
|
||||
queryAssetData,
|
||||
queryAssetInfo,
|
||||
queryAssetMeta,
|
||||
queryAssetUrl,
|
||||
selectAsset,
|
||||
};
|
||||
@@ -0,0 +1,156 @@
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
|
||||
const SERVER_NAME = 'funplay_cocos';
|
||||
|
||||
function ensureParent(filePath) {
|
||||
const dir = path.dirname(filePath);
|
||||
if (dir && !fs.existsSync(dir)) {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
function readJson(filePath) {
|
||||
if (!fs.existsSync(filePath)) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const text = fs.readFileSync(filePath, 'utf8').trim();
|
||||
if (!text) {
|
||||
return {};
|
||||
}
|
||||
|
||||
return JSON.parse(text);
|
||||
}
|
||||
|
||||
function writeJson(filePath, value) {
|
||||
ensureParent(filePath);
|
||||
fs.writeFileSync(filePath, JSON.stringify(value, null, 2) + '\n', 'utf8');
|
||||
}
|
||||
|
||||
function configureJsonTarget(target) {
|
||||
const root = readJson(target.configPath);
|
||||
const rootKey = target.rootKey || 'mcpServers';
|
||||
if (!root[rootKey] || typeof root[rootKey] !== 'object' || Array.isArray(root[rootKey])) {
|
||||
root[rootKey] = {};
|
||||
}
|
||||
root[rootKey][SERVER_NAME] = target.entry;
|
||||
writeJson(target.configPath, root);
|
||||
}
|
||||
|
||||
function configureTomlTarget(target) {
|
||||
ensureParent(target.configPath);
|
||||
const sectionHeader = `[mcp_servers.${SERVER_NAME}]`;
|
||||
const section = `${sectionHeader}\nurl = "${target.url}"\n`;
|
||||
let content = fs.existsSync(target.configPath) ? fs.readFileSync(target.configPath, 'utf8') : '';
|
||||
|
||||
if (content.includes(sectionHeader)) {
|
||||
const start = content.indexOf(sectionHeader);
|
||||
const afterHeader = start + sectionHeader.length;
|
||||
const nextSection = content.indexOf('\n[', afterHeader);
|
||||
const end = nextSection >= 0 ? nextSection : content.length;
|
||||
content = `${content.slice(0, start)}${section}${content.slice(end)}`;
|
||||
} else {
|
||||
if (content.length > 0 && !content.endsWith('\n')) {
|
||||
content += '\n';
|
||||
}
|
||||
if (content.length > 0) {
|
||||
content += '\n';
|
||||
}
|
||||
content += section;
|
||||
}
|
||||
|
||||
fs.writeFileSync(target.configPath, content, 'utf8');
|
||||
}
|
||||
|
||||
function buildTargets(config) {
|
||||
const home = os.homedir();
|
||||
const url = `http://${config.host}:${config.port}/`;
|
||||
|
||||
return [
|
||||
{
|
||||
id: 'claude_code',
|
||||
name: 'Claude Code / Claude Desktop',
|
||||
configPath: path.join(home, '.claude.json'),
|
||||
rootKey: 'mcpServers',
|
||||
entry: { type: 'http', url },
|
||||
},
|
||||
{
|
||||
id: 'cursor',
|
||||
name: 'Cursor',
|
||||
configPath: path.join(home, '.cursor', 'mcp.json'),
|
||||
rootKey: 'mcpServers',
|
||||
entry: { url },
|
||||
},
|
||||
{
|
||||
id: 'vscode',
|
||||
name: 'VS Code',
|
||||
configPath: path.join(home, '.vscode', 'mcp.json'),
|
||||
rootKey: 'servers',
|
||||
entry: { type: 'http', url },
|
||||
},
|
||||
{
|
||||
id: 'trae',
|
||||
name: 'Trae',
|
||||
configPath: path.join(home, '.trae', 'mcp.json'),
|
||||
rootKey: 'mcpServers',
|
||||
entry: { url },
|
||||
},
|
||||
{
|
||||
id: 'kiro',
|
||||
name: 'Kiro',
|
||||
configPath: path.join(home, '.kiro', 'settings', 'mcp.json'),
|
||||
rootKey: 'mcpServers',
|
||||
entry: { type: 'http', url },
|
||||
},
|
||||
{
|
||||
id: 'codex',
|
||||
name: 'Codex',
|
||||
configPath: path.join(home, '.codex', 'config.toml'),
|
||||
isToml: true,
|
||||
url,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
function getTargetStatuses(config) {
|
||||
return buildTargets(config).map((target) => ({
|
||||
id: target.id,
|
||||
name: target.name,
|
||||
configPath: target.configPath,
|
||||
configured: fs.existsSync(target.configPath),
|
||||
isToml: Boolean(target.isToml),
|
||||
}));
|
||||
}
|
||||
|
||||
function configureTarget(config, targetId) {
|
||||
const targets = buildTargets(config);
|
||||
const target = targets.find((item) => item.id === targetId);
|
||||
if (!target) {
|
||||
throw new Error(`Unknown MCP client target: ${targetId}`);
|
||||
}
|
||||
|
||||
if (target.isToml) {
|
||||
configureTomlTarget(target);
|
||||
} else {
|
||||
configureJsonTarget(target);
|
||||
}
|
||||
|
||||
return {
|
||||
id: target.id,
|
||||
name: target.name,
|
||||
configPath: target.configPath,
|
||||
configured: true,
|
||||
restartHint: `Please restart ${target.name} for the MCP configuration to take effect.`,
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
SERVER_NAME,
|
||||
buildTargets,
|
||||
configureTarget,
|
||||
getTargetStatuses,
|
||||
};
|
||||
@@ -0,0 +1,89 @@
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const DEFAULTS = {
|
||||
host: '127.0.0.1',
|
||||
port: 8765,
|
||||
toolProfile: 'core',
|
||||
autostart: true,
|
||||
maxInteractionLogEntries: 50,
|
||||
};
|
||||
|
||||
function getProjectPath() {
|
||||
if (global.Editor && Editor.Project && typeof Editor.Project.path === 'string' && Editor.Project.path) {
|
||||
return Editor.Project.path;
|
||||
}
|
||||
return process.cwd();
|
||||
}
|
||||
|
||||
function getProjectName() {
|
||||
return path.basename(getProjectPath());
|
||||
}
|
||||
|
||||
function getCocosVersion() {
|
||||
if (global.Editor && Editor.App) {
|
||||
if (typeof Editor.App.version === 'string' && Editor.App.version) {
|
||||
return Editor.App.version;
|
||||
}
|
||||
if (typeof Editor.App.ver === 'string' && Editor.App.ver) {
|
||||
return Editor.App.ver;
|
||||
}
|
||||
}
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
function loadJson(filePath) {
|
||||
if (!fs.existsSync(filePath)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
||||
} catch (error) {
|
||||
return {
|
||||
__error: `Failed to parse config file '${filePath}': ${error.message}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function clampPort(value) {
|
||||
const port = Number(value);
|
||||
if (!Number.isInteger(port) || port <= 0 || port > 65535) {
|
||||
return DEFAULTS.port;
|
||||
}
|
||||
return port;
|
||||
}
|
||||
|
||||
function normalizeProfile(value) {
|
||||
return String(value || DEFAULTS.toolProfile).toLowerCase() === 'full' ? 'full' : 'core';
|
||||
}
|
||||
|
||||
function loadConfig() {
|
||||
const projectPath = getProjectPath();
|
||||
const configPath = path.join(projectPath, 'funplay-cocos-mcp.config.json');
|
||||
const fileConfig = loadJson(configPath) || {};
|
||||
|
||||
return {
|
||||
...DEFAULTS,
|
||||
...fileConfig,
|
||||
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),
|
||||
autostart: typeof fileConfig.autostart === 'boolean' ? fileConfig.autostart : DEFAULTS.autostart,
|
||||
maxInteractionLogEntries: Number.isInteger(fileConfig.maxInteractionLogEntries)
|
||||
? Math.max(10, Math.min(500, fileConfig.maxInteractionLogEntries))
|
||||
: DEFAULTS.maxInteractionLogEntries,
|
||||
configPath,
|
||||
configError: fileConfig.__error || '',
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
DEFAULTS,
|
||||
getProjectPath,
|
||||
getProjectName,
|
||||
getCocosVersion,
|
||||
loadConfig,
|
||||
};
|
||||
@@ -0,0 +1,150 @@
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { execFile } = require('child_process');
|
||||
|
||||
function exists(filePath) {
|
||||
try {
|
||||
return fs.existsSync(filePath);
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function findTypescriptBinary(projectPath) {
|
||||
const tscName = process.platform === 'win32' ? 'tsc.cmd' : 'tsc';
|
||||
const possibleRoots = [
|
||||
global.Editor && Editor.App && Editor.App.path,
|
||||
process.resourcesPath,
|
||||
global.Editor && Editor.App && Editor.App.path ? path.dirname(Editor.App.path) : '',
|
||||
global.Editor && Editor.App && Editor.App.path ? path.resolve(Editor.App.path, '..') : '',
|
||||
].filter(Boolean);
|
||||
|
||||
const editorBundledCandidates = [];
|
||||
for (const root of possibleRoots) {
|
||||
editorBundledCandidates.push(
|
||||
path.join(root, 'resources', '3d', 'engine', 'node_modules', '.bin', tscName),
|
||||
path.join(root, 'resources', '3d', 'engine', 'node_modules', 'typescript', 'bin', 'tsc'),
|
||||
path.join(root, 'resources', '3d', 'engine', 'node_modules', '@cocos', 'typescript', 'bin', 'tsc'),
|
||||
path.join(root, 'app.asar.unpacked', 'node_modules', 'typescript', 'bin', 'tsc'),
|
||||
path.join(root, 'Contents', 'Resources', 'resources', '3d', 'engine', 'node_modules', '.bin', tscName),
|
||||
path.join(root, 'Contents', 'Resources', 'resources', '3d', 'engine', 'node_modules', 'typescript', 'bin', 'tsc'),
|
||||
path.join(root, 'Contents', 'Resources', 'resources', '3d', 'engine', 'node_modules', '@cocos', 'typescript', 'bin', 'tsc')
|
||||
);
|
||||
}
|
||||
|
||||
const candidates = [
|
||||
path.join(projectPath, 'node_modules', '.bin', tscName),
|
||||
path.join(projectPath, 'node_modules', 'typescript', 'bin', 'tsc'),
|
||||
...editorBundledCandidates,
|
||||
process.platform === 'win32' ? 'npx.cmd' : 'npx',
|
||||
];
|
||||
|
||||
for (const candidate of candidates) {
|
||||
if (candidate.includes(path.sep) && exists(candidate)) {
|
||||
return candidate;
|
||||
}
|
||||
if (!candidate.includes(path.sep)) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
return process.platform === 'win32' ? 'npx.cmd' : 'npx';
|
||||
}
|
||||
|
||||
function findTsConfig(projectPath, explicitPath) {
|
||||
if (explicitPath) {
|
||||
return path.isAbsolute(explicitPath) ? explicitPath : path.join(projectPath, explicitPath);
|
||||
}
|
||||
|
||||
const candidates = [
|
||||
path.join(projectPath, 'tsconfig.json'),
|
||||
path.join(projectPath, 'temp', 'tsconfig.cocos.json'),
|
||||
];
|
||||
|
||||
return candidates.find((candidate) => exists(candidate)) || '';
|
||||
}
|
||||
|
||||
function runExec(file, args, cwd) {
|
||||
return new Promise((resolve) => {
|
||||
execFile(file, args, { cwd, maxBuffer: 8 * 1024 * 1024 }, (error, stdout, stderr) => {
|
||||
resolve({
|
||||
code: error && typeof error.code === 'number' ? error.code : 0,
|
||||
stdout: stdout || '',
|
||||
stderr: stderr || '',
|
||||
error: error ? error.message : '',
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function parseTscOutput(output) {
|
||||
const lines = String(output || '')
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
const diagnostics = [];
|
||||
const regex = /^(.*)\((\d+),(\d+)\):\s+error\s+(TS\d+):\s+(.*)$/i;
|
||||
for (const line of lines) {
|
||||
const match = regex.exec(line);
|
||||
if (!match) {
|
||||
continue;
|
||||
}
|
||||
|
||||
diagnostics.push({
|
||||
file: match[1],
|
||||
line: Number(match[2]),
|
||||
column: Number(match[3]),
|
||||
code: match[4],
|
||||
message: match[5],
|
||||
});
|
||||
}
|
||||
|
||||
return diagnostics;
|
||||
}
|
||||
|
||||
async function runScriptDiagnostics(projectPath, options = {}) {
|
||||
const tsconfigPath = findTsConfig(projectPath, options.tsconfigPath);
|
||||
if (!tsconfigPath || !exists(tsconfigPath)) {
|
||||
return {
|
||||
ok: false,
|
||||
tool: 'typescript',
|
||||
summary: 'No tsconfig.json was found in the Cocos project.',
|
||||
diagnostics: [],
|
||||
stdout: '',
|
||||
stderr: '',
|
||||
};
|
||||
}
|
||||
|
||||
const binary = findTypescriptBinary(projectPath);
|
||||
const args = binary.endsWith('npx') || binary.endsWith('npx.cmd')
|
||||
? ['tsc', '--noEmit', '-p', tsconfigPath, '--pretty', 'false']
|
||||
: ['--noEmit', '-p', tsconfigPath, '--pretty', 'false'];
|
||||
|
||||
const result = await runExec(binary, args, projectPath);
|
||||
const mergedOutput = [result.stdout, result.stderr, result.error].filter(Boolean).join('\n').trim();
|
||||
const diagnostics = parseTscOutput(mergedOutput);
|
||||
const ok = result.code === 0 && diagnostics.length === 0;
|
||||
|
||||
return {
|
||||
ok,
|
||||
tool: 'typescript',
|
||||
binary,
|
||||
tsconfigPath,
|
||||
exitCode: result.code,
|
||||
summary: ok
|
||||
? 'TypeScript diagnostics completed successfully with no errors.'
|
||||
: diagnostics.length
|
||||
? `Found ${diagnostics.length} TypeScript error(s).`
|
||||
: mergedOutput || 'TypeScript diagnostics reported a non-zero exit code.',
|
||||
diagnostics,
|
||||
stdout: result.stdout,
|
||||
stderr: result.stderr,
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
runScriptDiagnostics,
|
||||
};
|
||||
@@ -0,0 +1,281 @@
|
||||
'use strict';
|
||||
|
||||
function getElectron() {
|
||||
try {
|
||||
return require('electron');
|
||||
} catch (error) {
|
||||
throw new Error(`Electron APIs are unavailable: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeText(value) {
|
||||
return String(value || '').trim().toLowerCase();
|
||||
}
|
||||
|
||||
function getAllWindows() {
|
||||
const electron = getElectron();
|
||||
const BrowserWindow = electron.BrowserWindow;
|
||||
if (!BrowserWindow || typeof BrowserWindow.getAllWindows !== 'function') {
|
||||
throw new Error('Electron BrowserWindow API is unavailable.');
|
||||
}
|
||||
|
||||
return BrowserWindow.getAllWindows().filter((window) => window && !window.isDestroyed());
|
||||
}
|
||||
|
||||
function inferWindowKind(title) {
|
||||
const normalized = normalizeText(title);
|
||||
if (normalized.includes('simulator')) {
|
||||
return 'simulator';
|
||||
}
|
||||
if (normalized.includes('preview')) {
|
||||
return 'preview';
|
||||
}
|
||||
if (normalized.includes('cocos creator') || normalized.includes('cocos')) {
|
||||
return 'editor';
|
||||
}
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
function listWindows() {
|
||||
return getAllWindows().map((window, index) => ({
|
||||
index,
|
||||
id: typeof window.id === 'number' ? window.id : index,
|
||||
title: typeof window.getTitle === 'function' ? window.getTitle() : '',
|
||||
bounds: typeof window.getBounds === 'function' ? window.getBounds() : null,
|
||||
visible: typeof window.isVisible === 'function' ? window.isVisible() : true,
|
||||
focused: typeof window.isFocused === 'function' ? window.isFocused() : false,
|
||||
kind: inferWindowKind(typeof window.getTitle === 'function' ? window.getTitle() : ''),
|
||||
}));
|
||||
}
|
||||
|
||||
function pickWindow(options = {}) {
|
||||
const electron = getElectron();
|
||||
const BrowserWindow = electron.BrowserWindow;
|
||||
const windows = getAllWindows();
|
||||
if (!windows.length) {
|
||||
throw new Error('No Electron windows are available.');
|
||||
}
|
||||
|
||||
const titleContains = normalizeText(options.titleContains);
|
||||
const windowKind = normalizeText(options.windowKind || 'focused');
|
||||
const focusedWindow = BrowserWindow.getFocusedWindow && BrowserWindow.getFocusedWindow();
|
||||
|
||||
const titleMatches = (window) => {
|
||||
if (!titleContains) {
|
||||
return true;
|
||||
}
|
||||
return normalizeText(window.getTitle && window.getTitle()).includes(titleContains);
|
||||
};
|
||||
|
||||
const kindMatches = (window) => {
|
||||
const kind = inferWindowKind(window.getTitle && window.getTitle());
|
||||
switch (windowKind) {
|
||||
case 'focused':
|
||||
return true;
|
||||
case 'editor':
|
||||
case 'simulator':
|
||||
case 'preview':
|
||||
return kind === windowKind;
|
||||
default:
|
||||
return true;
|
||||
}
|
||||
};
|
||||
|
||||
const candidates = windows.filter((window) => kindMatches(window) && titleMatches(window));
|
||||
const target = (focusedWindow && candidates.includes(focusedWindow) && focusedWindow)
|
||||
|| candidates.find((window) => typeof window.isVisible === 'function' ? window.isVisible() : true)
|
||||
|| candidates[0]
|
||||
|| windows[0];
|
||||
|
||||
if (!target) {
|
||||
throw new Error(`No BrowserWindow matched windowKind='${windowKind}' titleContains='${titleContains}'.`);
|
||||
}
|
||||
|
||||
return target;
|
||||
}
|
||||
|
||||
async function executeJavaScript(window, script) {
|
||||
if (!window || !window.webContents || typeof window.webContents.executeJavaScript !== 'function') {
|
||||
throw new Error('Target window does not support webContents.executeJavaScript.');
|
||||
}
|
||||
return await window.webContents.executeJavaScript(script, true);
|
||||
}
|
||||
|
||||
function buildPanelBoundsScript(panelName) {
|
||||
const panel = JSON.stringify(String(panelName || 'scene'));
|
||||
return `
|
||||
(() => {
|
||||
const panelName = ${panel}.toLowerCase();
|
||||
const results = [];
|
||||
|
||||
const isVisible = (element) => {
|
||||
if (!element || typeof element.getBoundingClientRect !== 'function') return false;
|
||||
const style = window.getComputedStyle(element);
|
||||
const rect = element.getBoundingClientRect();
|
||||
return style && style.display !== 'none' && style.visibility !== 'hidden' && rect.width > 4 && rect.height > 4;
|
||||
};
|
||||
|
||||
const textOf = (element) => {
|
||||
const parts = [
|
||||
element.getAttribute && element.getAttribute('name'),
|
||||
element.getAttribute && element.getAttribute('title'),
|
||||
element.id,
|
||||
element.className,
|
||||
element.textContent,
|
||||
];
|
||||
return parts.filter(Boolean).join(' ').toLowerCase();
|
||||
};
|
||||
|
||||
const collectAll = (root, bucket) => {
|
||||
if (!root) return;
|
||||
const nodes = root.querySelectorAll ? root.querySelectorAll('*') : [];
|
||||
for (const node of nodes) {
|
||||
bucket.push(node);
|
||||
if (node.shadowRoot) {
|
||||
collectAll(node.shadowRoot, bucket);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const all = [];
|
||||
collectAll(document, all);
|
||||
|
||||
const scoreElement = (element) => {
|
||||
const text = textOf(element);
|
||||
let score = 0;
|
||||
if (text.includes(panelName)) score += 100;
|
||||
if (text.includes('panel-frame')) score += 5;
|
||||
if (panelName === 'scene' && text.includes('game')) score -= 10;
|
||||
if (panelName === 'game' && text.includes('scene')) score -= 10;
|
||||
return score;
|
||||
};
|
||||
|
||||
const largestChildRect = (element) => {
|
||||
const bucket = [element];
|
||||
if (element.shadowRoot) collectAll(element.shadowRoot, bucket);
|
||||
const children = bucket
|
||||
.filter((node) => isVisible(node))
|
||||
.map((node) => {
|
||||
const rect = node.getBoundingClientRect();
|
||||
const text = textOf(node);
|
||||
let score = rect.width * rect.height;
|
||||
if (node.tagName && node.tagName.toLowerCase() === 'canvas') score += 50000;
|
||||
if (text.includes(panelName)) score += 20000;
|
||||
if (text.includes('canvas') || text.includes('preview') || text.includes('viewport')) score += 10000;
|
||||
return { rect, score, tag: node.tagName || '', text };
|
||||
})
|
||||
.sort((a, b) => b.score - a.score);
|
||||
return children[0] || null;
|
||||
};
|
||||
|
||||
const candidates = all
|
||||
.filter((element) => isVisible(element))
|
||||
.map((element) => {
|
||||
const panelScore = scoreElement(element);
|
||||
if (panelScore <= 0) return null;
|
||||
const rectInfo = largestChildRect(element) || { rect: element.getBoundingClientRect(), score: 0, tag: element.tagName || '', text: textOf(element) };
|
||||
return {
|
||||
score: panelScore + rectInfo.score,
|
||||
elementTag: element.tagName || '',
|
||||
rect: rectInfo.rect,
|
||||
text: textOf(element),
|
||||
innerTag: rectInfo.tag,
|
||||
innerText: rectInfo.text,
|
||||
};
|
||||
})
|
||||
.filter(Boolean)
|
||||
.sort((a, b) => b.score - a.score);
|
||||
|
||||
const fallbackCanvases = all
|
||||
.filter((element) => isVisible(element) && element.tagName && element.tagName.toLowerCase() === 'canvas')
|
||||
.map((element) => {
|
||||
const rect = element.getBoundingClientRect();
|
||||
const text = textOf(element);
|
||||
let score = rect.width * rect.height;
|
||||
if (text.includes(panelName)) score += 50000;
|
||||
return { score, rect, elementTag: 'CANVAS', text, innerTag: 'CANVAS', innerText: text };
|
||||
})
|
||||
.sort((a, b) => b.score - a.score);
|
||||
|
||||
const target = candidates[0] || fallbackCanvases[0];
|
||||
if (!target) return null;
|
||||
|
||||
return {
|
||||
x: Math.max(0, Math.floor(target.rect.left)),
|
||||
y: Math.max(0, Math.floor(target.rect.top)),
|
||||
width: Math.max(1, Math.floor(target.rect.width)),
|
||||
height: Math.max(1, Math.floor(target.rect.height)),
|
||||
elementTag: target.elementTag,
|
||||
innerTag: target.innerTag,
|
||||
text: target.text,
|
||||
innerText: target.innerText,
|
||||
};
|
||||
})();
|
||||
`;
|
||||
}
|
||||
|
||||
async function getPanelBounds(window, panelName) {
|
||||
const result = await executeJavaScript(window, buildPanelBoundsScript(panelName));
|
||||
if (!result || !result.width || !result.height) {
|
||||
throw new Error(`Could not locate a visible '${panelName}' panel in the target window.`);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function buildPanelFocusScript(panelName, offsetX, offsetY) {
|
||||
return `
|
||||
(() => {
|
||||
const panelName = ${JSON.stringify(String(panelName || 'scene').toLowerCase())};
|
||||
const offsetX = ${Number(offsetX || 0)};
|
||||
const offsetY = ${Number(offsetY || 0)};
|
||||
const isVisible = (element) => {
|
||||
if (!element || typeof element.getBoundingClientRect !== 'function') return false;
|
||||
const style = window.getComputedStyle(element);
|
||||
const rect = element.getBoundingClientRect();
|
||||
return style && style.display !== 'none' && style.visibility !== 'hidden' && rect.width > 4 && rect.height > 4;
|
||||
};
|
||||
const textOf = (element) => [
|
||||
element.getAttribute && element.getAttribute('name'),
|
||||
element.getAttribute && element.getAttribute('title'),
|
||||
element.id,
|
||||
element.className,
|
||||
element.textContent,
|
||||
].filter(Boolean).join(' ').toLowerCase();
|
||||
const collectAll = (root, bucket) => {
|
||||
if (!root) return;
|
||||
const nodes = root.querySelectorAll ? root.querySelectorAll('*') : [];
|
||||
for (const node of nodes) {
|
||||
bucket.push(node);
|
||||
if (node.shadowRoot) collectAll(node.shadowRoot, bucket);
|
||||
}
|
||||
};
|
||||
const all = [];
|
||||
collectAll(document, all);
|
||||
const target = all.find((element) => isVisible(element) && textOf(element).includes(panelName));
|
||||
const rect = target ? target.getBoundingClientRect() : { left: 0, top: 0, width: window.innerWidth, height: window.innerHeight };
|
||||
const x = Math.floor(rect.left + rect.width / 2 + offsetX);
|
||||
const y = Math.floor(rect.top + rect.height / 2 + offsetY);
|
||||
const focusable = document.elementFromPoint(x, y) || target || document.body;
|
||||
if (focusable && typeof focusable.focus === 'function') focusable.focus();
|
||||
return { x, y };
|
||||
})();
|
||||
`;
|
||||
}
|
||||
|
||||
async function getPanelPoint(window, panelName, offsetX, offsetY) {
|
||||
return await executeJavaScript(window, buildPanelFocusScript(panelName, offsetX, offsetY));
|
||||
}
|
||||
|
||||
function sleep(milliseconds) {
|
||||
return new Promise((resolve) => setTimeout(resolve, milliseconds));
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
executeJavaScript,
|
||||
getAllWindows,
|
||||
getPanelBounds,
|
||||
getPanelPoint,
|
||||
listWindows,
|
||||
pickWindow,
|
||||
sleep,
|
||||
};
|
||||
+151
@@ -0,0 +1,151 @@
|
||||
'use strict';
|
||||
|
||||
const { getPanelPoint, listWindows, pickWindow, sleep } = require('./electron-tools');
|
||||
|
||||
function normalizeButton(button) {
|
||||
const value = String(button || 'left').toLowerCase();
|
||||
return ['left', 'right', 'middle'].includes(value) ? value : 'left';
|
||||
}
|
||||
|
||||
function normalizeModifiers(modifiers) {
|
||||
return Array.isArray(modifiers) ? modifiers.map((item) => String(item)) : [];
|
||||
}
|
||||
|
||||
async function focusTarget(window, panel, x, y) {
|
||||
if (typeof window.focus === 'function') {
|
||||
window.focus();
|
||||
}
|
||||
if (panel) {
|
||||
return await getPanelPoint(window, panel, x, y);
|
||||
}
|
||||
return { x: Math.floor(x || 0), y: Math.floor(y || 0) };
|
||||
}
|
||||
|
||||
async function resolvePoint(window, panel, x, y) {
|
||||
if (panel) {
|
||||
return await getPanelPoint(window, panel, x, y);
|
||||
}
|
||||
return { x: Math.floor(x || 0), y: Math.floor(y || 0) };
|
||||
}
|
||||
|
||||
async function sendMouseClick(options = {}) {
|
||||
const window = pickWindow(options);
|
||||
const point = await focusTarget(window, options.panel, options.x, options.y);
|
||||
const button = normalizeButton(options.button);
|
||||
const clickCount = Number.isFinite(options.clickCount) ? Math.max(1, options.clickCount) : 1;
|
||||
const modifiers = normalizeModifiers(options.modifiers);
|
||||
|
||||
window.webContents.sendInputEvent({
|
||||
type: 'mouseMove',
|
||||
x: point.x,
|
||||
y: point.y,
|
||||
button,
|
||||
modifiers,
|
||||
});
|
||||
window.webContents.sendInputEvent({
|
||||
type: 'mouseDown',
|
||||
x: point.x,
|
||||
y: point.y,
|
||||
button,
|
||||
clickCount,
|
||||
modifiers,
|
||||
});
|
||||
window.webContents.sendInputEvent({
|
||||
type: 'mouseUp',
|
||||
x: point.x,
|
||||
y: point.y,
|
||||
button,
|
||||
clickCount,
|
||||
modifiers,
|
||||
});
|
||||
|
||||
return {
|
||||
sent: true,
|
||||
type: 'mouse_click',
|
||||
point,
|
||||
button,
|
||||
clickCount,
|
||||
windowTitle: typeof window.getTitle === 'function' ? window.getTitle() : '',
|
||||
};
|
||||
}
|
||||
|
||||
async function sendMouseDrag(options = {}) {
|
||||
const window = pickWindow(options);
|
||||
const start = await focusTarget(window, options.panel, options.startX, options.startY);
|
||||
const end = await resolvePoint(window, options.panel, options.endX ?? 0, options.endY ?? 0);
|
||||
const steps = Number.isFinite(options.steps) ? Math.max(1, Math.min(60, options.steps)) : 10;
|
||||
const button = normalizeButton(options.button);
|
||||
const modifiers = normalizeModifiers(options.modifiers);
|
||||
|
||||
window.webContents.sendInputEvent({ type: 'mouseMove', x: start.x, y: start.y, button, modifiers });
|
||||
window.webContents.sendInputEvent({ type: 'mouseDown', x: start.x, y: start.y, button, clickCount: 1, modifiers });
|
||||
for (let step = 1; step <= steps; step += 1) {
|
||||
const x = Math.round(start.x + ((end.x - start.x) * step) / steps);
|
||||
const y = Math.round(start.y + ((end.y - start.y) * step) / steps);
|
||||
window.webContents.sendInputEvent({ type: 'mouseMove', x, y, button, modifiers });
|
||||
if (options.stepDelayMs) {
|
||||
await sleep(options.stepDelayMs);
|
||||
}
|
||||
}
|
||||
window.webContents.sendInputEvent({ type: 'mouseUp', x: end.x, y: end.y, button, clickCount: 1, modifiers });
|
||||
|
||||
return {
|
||||
sent: true,
|
||||
type: 'mouse_drag',
|
||||
from: start,
|
||||
to: end,
|
||||
steps,
|
||||
windowTitle: typeof window.getTitle === 'function' ? window.getTitle() : '',
|
||||
};
|
||||
}
|
||||
|
||||
async function sendKeyPress(options = {}) {
|
||||
const window = pickWindow(options);
|
||||
if (typeof window.focus === 'function') {
|
||||
window.focus();
|
||||
}
|
||||
if (options.panel) {
|
||||
await getPanelPoint(window, options.panel, 0, 0);
|
||||
}
|
||||
|
||||
const keyCode = String(options.keyCode || '').trim();
|
||||
if (!keyCode) {
|
||||
throw new Error('keyCode is required.');
|
||||
}
|
||||
|
||||
const modifiers = normalizeModifiers(options.modifiers);
|
||||
window.webContents.sendInputEvent({ type: 'keyDown', keyCode, modifiers });
|
||||
if (options.text) {
|
||||
window.webContents.sendInputEvent({ type: 'char', keyCode: String(options.text), modifiers });
|
||||
}
|
||||
window.webContents.sendInputEvent({ type: 'keyUp', keyCode, modifiers });
|
||||
|
||||
return {
|
||||
sent: true,
|
||||
type: 'key_press',
|
||||
keyCode,
|
||||
modifiers,
|
||||
windowTitle: typeof window.getTitle === 'function' ? window.getTitle() : '',
|
||||
};
|
||||
}
|
||||
|
||||
async function sendKeyCombo(options = {}) {
|
||||
const modifiers = normalizeModifiers(options.modifiers);
|
||||
const keyCode = String(options.keyCode || '').trim();
|
||||
if (!keyCode) {
|
||||
throw new Error('keyCode is required.');
|
||||
}
|
||||
return await sendKeyPress({
|
||||
...options,
|
||||
keyCode,
|
||||
modifiers,
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
listWindows,
|
||||
sendKeyCombo,
|
||||
sendKeyPress,
|
||||
sendMouseClick,
|
||||
sendMouseDrag,
|
||||
};
|
||||
@@ -0,0 +1,40 @@
|
||||
'use strict';
|
||||
|
||||
class InteractionLog {
|
||||
constructor(limit = 200) {
|
||||
this.limit = limit;
|
||||
this.entries = [];
|
||||
}
|
||||
|
||||
add(toolName, status, summary) {
|
||||
this.entries.unshift({
|
||||
toolName,
|
||||
status,
|
||||
summary,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
if (this.entries.length > this.limit) {
|
||||
this.entries.length = this.limit;
|
||||
}
|
||||
}
|
||||
|
||||
list(limit = 20) {
|
||||
return this.entries.slice(0, Math.max(1, limit));
|
||||
}
|
||||
|
||||
summary(limit = 20) {
|
||||
const items = this.list(limit);
|
||||
if (!items.length) {
|
||||
return 'No MCP interactions recorded yet.';
|
||||
}
|
||||
|
||||
return items
|
||||
.map((entry) => `[${entry.timestamp}] ${entry.status.toUpperCase()} ${entry.toolName}: ${entry.summary}`)
|
||||
.join('\n');
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
InteractionLog,
|
||||
};
|
||||
@@ -0,0 +1,66 @@
|
||||
'use strict';
|
||||
|
||||
class PromptProvider {
|
||||
constructor(getRuntimeContext) {
|
||||
this.getRuntimeContext = getRuntimeContext;
|
||||
}
|
||||
|
||||
listPrompts() {
|
||||
const { projectName } = this.getRuntimeContext();
|
||||
return [
|
||||
this.createPrompt('fix_script_errors', `Use execute_javascript first to diagnose and repair current Cocos script problems in '${projectName}'.`),
|
||||
this.createPrompt('create_playable_prototype', `Use execute_javascript first to build a playable Cocos prototype in '${projectName}' from a short idea.`),
|
||||
this.createPrompt('scene_validation', `Use execute_javascript first to validate a scene change in '${projectName}' with hierarchy checks and focused inspection.`),
|
||||
this.createPrompt('auto_wire_scene', `Use execute_javascript first to inspect a target setup in '${projectName}' and wire missing scene relationships.`),
|
||||
];
|
||||
}
|
||||
|
||||
getPrompt(name) {
|
||||
const { projectName, projectPath } = this.getRuntimeContext();
|
||||
let text = '';
|
||||
|
||||
switch (name) {
|
||||
case 'fix_script_errors':
|
||||
text = 'Prefer `execute_javascript` first: use `context="editor"` for diagnostics, filesystem edits, and asset-db workflows, and `context="scene"` when runtime or scene validation is needed. Run script diagnostics, inspect source snippets for each error, patch the smallest safe regions with focused file edits, refresh assets, and verify the project returns to a healthy state.';
|
||||
break;
|
||||
case 'create_playable_prototype':
|
||||
text = 'Prefer `execute_javascript` as the primary tool: use `context="scene"` for node/component/runtime orchestration and `context="editor"` for editor-side automation. Create a playable Cocos prototype from the provided idea. Build scene nodes, scripts, prefabs, UI, camera, animation hooks, helper controls, and verify the result with runtime state and screenshots.';
|
||||
break;
|
||||
case 'scene_validation':
|
||||
text = 'Prefer `execute_javascript` as the first tool, usually with `context="scene"`. Inspect the active scene, verify hierarchy, nodes, components, prefab instances, cameras, animations, runtime state, screenshots, and targeted checks.';
|
||||
break;
|
||||
case 'auto_wire_scene':
|
||||
text = 'Prefer `execute_javascript` as the first tool, using `context="scene"` for hierarchy and component repair and `context="editor"` for file or asset-side repair. Inspect the target node structure, identify missing scene references, UI children, camera/animation setup, or expected children, and repair them with the smallest safe change.';
|
||||
break;
|
||||
default:
|
||||
text = `Prompt not found: ${name}`;
|
||||
break;
|
||||
}
|
||||
|
||||
const fullText = `Target Cocos project: ${projectName}\nProject path: ${projectPath}\n\n${text}`;
|
||||
return {
|
||||
description: fullText,
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
content: {
|
||||
type: 'text',
|
||||
text: fullText,
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
createPrompt(name, description) {
|
||||
return {
|
||||
name,
|
||||
description,
|
||||
arguments: [],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
PromptProvider,
|
||||
};
|
||||
@@ -0,0 +1,253 @@
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { getCurrentSelection, queryAssetInfo, queryAssetMeta } = require('./assets');
|
||||
const { runScriptDiagnostics } = require('./diagnostics');
|
||||
|
||||
function createResource(uri, name, description) {
|
||||
return { uri, name, description, mimeType: 'text/plain' };
|
||||
}
|
||||
|
||||
function createTemplate(uriTemplate, name, description) {
|
||||
return { uriTemplate, name, description, mimeType: 'text/plain' };
|
||||
}
|
||||
|
||||
function truncate(text, limit = 12000) {
|
||||
if (typeof text !== 'string') {
|
||||
return text;
|
||||
}
|
||||
if (text.length <= limit) {
|
||||
return text;
|
||||
}
|
||||
return `${text.slice(0, limit)}\n... (truncated)`;
|
||||
}
|
||||
|
||||
function toText(value) {
|
||||
if (typeof value === 'string') {
|
||||
return value;
|
||||
}
|
||||
return JSON.stringify(value, null, 2);
|
||||
}
|
||||
|
||||
function summarizeSelection() {
|
||||
if (!global.Editor || !Editor.Selection || typeof Editor.Selection.getSelected !== 'function') {
|
||||
return 'Selection API is unavailable in this Cocos environment.';
|
||||
}
|
||||
|
||||
try {
|
||||
const selectedNode = Editor.Selection.getSelected('node');
|
||||
const selectedAsset = Editor.Selection.getSelected('asset');
|
||||
return [
|
||||
`Selected node: ${selectedNode || '(none)'}`,
|
||||
`Selected asset: ${selectedAsset || '(none)'}`,
|
||||
].join('\n');
|
||||
} catch (error) {
|
||||
return `Failed to inspect current selection: ${error.message}`;
|
||||
}
|
||||
}
|
||||
|
||||
class ResourceProvider {
|
||||
constructor(getRuntimeContext, sceneBridge, interactionLog) {
|
||||
this.getRuntimeContext = getRuntimeContext;
|
||||
this.sceneBridge = sceneBridge;
|
||||
this.interactionLog = interactionLog;
|
||||
}
|
||||
|
||||
listResources() {
|
||||
const { projectName } = this.getRuntimeContext();
|
||||
return [
|
||||
createResource('cocos://project/context', `${projectName} Project Context`, 'Live Cocos project context summary.'),
|
||||
createResource('cocos://project/summary', `${projectName} Project Summary`, 'Project path, folder summary, and asset overview.'),
|
||||
createResource('cocos://scene/active', `${projectName} Active Scene`, 'Summary of the active Cocos scene.'),
|
||||
createResource('cocos://scene/current', `${projectName} Current Scene`, 'Alias of the active Cocos scene summary.'),
|
||||
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://mcp/interactions', `${projectName} MCP Interactions`, 'Recent MCP tool interaction summaries.'),
|
||||
];
|
||||
}
|
||||
|
||||
listResourceTemplates() {
|
||||
return [
|
||||
createTemplate('cocos://scene/node/{path}', 'Scene Node', 'Inspect a scene node by hierarchy path.'),
|
||||
createTemplate('cocos://asset/path/{relative_path}', 'Asset By Path', 'Read a script or text asset by project-relative path.'),
|
||||
createTemplate('cocos://asset/info/{uuid_or_path}', 'Asset Info', 'Inspect an asset by uuid, db url, or path.'),
|
||||
];
|
||||
}
|
||||
|
||||
async readResource(uri) {
|
||||
const text = await this.resolveResourceText(uri);
|
||||
return {
|
||||
contents: [
|
||||
{
|
||||
uri,
|
||||
mimeType: 'text/plain',
|
||||
text,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
async resolveResourceText(uri) {
|
||||
const { projectName, projectPath, cocosVersion, version, config } = this.getRuntimeContext();
|
||||
|
||||
switch (uri) {
|
||||
case 'cocos://project/context':
|
||||
return [
|
||||
'Funplay Cocos MCP Project Context',
|
||||
`Project: ${projectName}`,
|
||||
`Project Path: ${projectPath}`,
|
||||
`Cocos Creator: ${cocosVersion}`,
|
||||
`Extension Version: ${version}`,
|
||||
`Tool Profile: ${config.toolProfile}`,
|
||||
`Server: http://${config.host}:${config.port}/`,
|
||||
'',
|
||||
'Selection',
|
||||
summarizeSelection(),
|
||||
'',
|
||||
'Active Scene',
|
||||
await this.safeSceneCall('getSceneInfo', { maxDepth: 2 }),
|
||||
].join('\n');
|
||||
case 'cocos://project/summary':
|
||||
return this.buildProjectSummary(projectPath, cocosVersion, version);
|
||||
case 'cocos://scene/active':
|
||||
case 'cocos://scene/current':
|
||||
return await this.safeSceneCall('getSceneInfo', { maxDepth: 3 });
|
||||
case 'cocos://selection/current':
|
||||
return summarizeSelection();
|
||||
case 'cocos://selection/asset':
|
||||
return await this.getSelectedAssetText();
|
||||
case 'cocos://errors/scripts':
|
||||
return await this.getScriptDiagnosticsText(projectPath);
|
||||
case 'cocos://mcp/interactions':
|
||||
return this.interactionLog.summary();
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
if (uri.startsWith('cocos://scene/node/')) {
|
||||
const nodePath = decodeURIComponent(uri.slice('cocos://scene/node/'.length));
|
||||
return await this.safeSceneCall('inspectNode', { path: nodePath });
|
||||
}
|
||||
|
||||
if (uri.startsWith('cocos://asset/path/')) {
|
||||
const relativePath = decodeURIComponent(uri.slice('cocos://asset/path/'.length));
|
||||
return this.readAssetByPath(relativePath);
|
||||
}
|
||||
|
||||
if (uri.startsWith('cocos://asset/info/')) {
|
||||
const uuidOrPath = decodeURIComponent(uri.slice('cocos://asset/info/'.length));
|
||||
return await this.getAssetInfoText(uuidOrPath);
|
||||
}
|
||||
|
||||
return `Resource not found: ${uri}`;
|
||||
}
|
||||
|
||||
buildProjectSummary(projectPath, cocosVersion, version) {
|
||||
const topLevel = fs.existsSync(projectPath)
|
||||
? fs
|
||||
.readdirSync(projectPath, { withFileTypes: true })
|
||||
.filter((entry) => entry.isDirectory())
|
||||
.map((entry) => entry.name)
|
||||
.sort((left, right) => left.localeCompare(right))
|
||||
: [];
|
||||
|
||||
const assetsDir = path.join(projectPath, 'assets');
|
||||
const scriptCount = this.countFiles(assetsDir, ['.ts', '.js']);
|
||||
const prefabCount = this.countFiles(assetsDir, ['.prefab']);
|
||||
const sceneCount = this.countFiles(assetsDir, ['.scene']);
|
||||
|
||||
return [
|
||||
'Project Summary',
|
||||
`Project Root: ${projectPath}`,
|
||||
`Assets Path: ${assetsDir}`,
|
||||
`Cocos Creator: ${cocosVersion}`,
|
||||
`Extension Version: ${version}`,
|
||||
`Scripts: ${scriptCount}`,
|
||||
`Prefabs: ${prefabCount}`,
|
||||
`Scenes: ${sceneCount}`,
|
||||
'',
|
||||
`Top-Level Directories (${topLevel.length})`,
|
||||
...topLevel.map((name) => `- ${name}`),
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
countFiles(rootDir, extensions) {
|
||||
if (!fs.existsSync(rootDir)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
let count = 0;
|
||||
const stack = [rootDir];
|
||||
while (stack.length) {
|
||||
const current = stack.pop();
|
||||
const entries = fs.readdirSync(current, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
const fullPath = path.join(current, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
stack.push(fullPath);
|
||||
continue;
|
||||
}
|
||||
if (extensions.includes(path.extname(entry.name).toLowerCase())) {
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
readAssetByPath(relativePath) {
|
||||
const { projectPath } = this.getRuntimeContext();
|
||||
const fullPath = path.isAbsolute(relativePath) ? relativePath : path.join(projectPath, relativePath);
|
||||
if (!fs.existsSync(fullPath)) {
|
||||
return `Asset not found: ${relativePath}`;
|
||||
}
|
||||
return truncate(`[${relativePath}]\n${fs.readFileSync(fullPath, 'utf8')}`);
|
||||
}
|
||||
|
||||
async safeSceneCall(method, payload) {
|
||||
try {
|
||||
const result = await this.sceneBridge.call(method, payload);
|
||||
return toText(result);
|
||||
} catch (error) {
|
||||
return `Scene bridge error (${method}): ${error.message}`;
|
||||
}
|
||||
}
|
||||
|
||||
async getSelectedAssetText() {
|
||||
try {
|
||||
const selection = getCurrentSelection();
|
||||
if (!selection.asset) {
|
||||
return 'No asset is currently selected.';
|
||||
}
|
||||
|
||||
return await this.getAssetInfoText(selection.asset);
|
||||
} catch (error) {
|
||||
return `Selected asset lookup failed: ${error.message}`;
|
||||
}
|
||||
}
|
||||
|
||||
async getAssetInfoText(uuidOrPath) {
|
||||
try {
|
||||
const info = await queryAssetInfo(uuidOrPath);
|
||||
const meta = await queryAssetMeta(uuidOrPath).catch(() => null);
|
||||
return JSON.stringify({ info, meta }, null, 2);
|
||||
} catch (error) {
|
||||
return `Asset lookup failed: ${error.message}`;
|
||||
}
|
||||
}
|
||||
|
||||
async getScriptDiagnosticsText(projectPath) {
|
||||
try {
|
||||
const result = await runScriptDiagnostics(projectPath);
|
||||
return JSON.stringify(result, null, 2);
|
||||
} catch (error) {
|
||||
return `Script diagnostics failed: ${error.message}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
ResourceProvider,
|
||||
};
|
||||
@@ -0,0 +1,110 @@
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
const { execFile } = require('child_process');
|
||||
const { getPanelBounds, pickWindow } = require('./electron-tools');
|
||||
|
||||
function ensureDir(dirPath) {
|
||||
fs.mkdirSync(dirPath, { recursive: true });
|
||||
}
|
||||
|
||||
function exec(file, args) {
|
||||
return new Promise((resolve, reject) => {
|
||||
execFile(file, args, { maxBuffer: 8 * 1024 * 1024 }, (error, stdout, stderr) => {
|
||||
if (error) {
|
||||
reject(new Error(stderr || stdout || error.message));
|
||||
return;
|
||||
}
|
||||
resolve({ stdout, stderr });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function captureDesktopScreenshot(projectPath, options = {}) {
|
||||
const outputDir = path.join(projectPath, 'temp', 'mcp-captures');
|
||||
ensureDir(outputDir);
|
||||
const filePath = path.join(outputDir, options.fileName || `desktop-${Date.now()}.png`);
|
||||
|
||||
if (process.platform === 'darwin') {
|
||||
await exec('screencapture', ['-x', filePath]);
|
||||
} else if (process.platform === 'win32') {
|
||||
const script = `
|
||||
Add-Type -AssemblyName System.Windows.Forms
|
||||
Add-Type -AssemblyName System.Drawing
|
||||
$bounds = [System.Windows.Forms.SystemInformation]::VirtualScreen
|
||||
$bitmap = New-Object System.Drawing.Bitmap $bounds.Width, $bounds.Height
|
||||
$graphics = [System.Drawing.Graphics]::FromImage($bitmap)
|
||||
$graphics.CopyFromScreen($bounds.Location, [System.Drawing.Point]::Empty, $bounds.Size)
|
||||
$bitmap.Save('${filePath.replace(/\\/g, '\\\\')}', [System.Drawing.Imaging.ImageFormat]::Png)
|
||||
$graphics.Dispose()
|
||||
$bitmap.Dispose()
|
||||
`;
|
||||
await exec('powershell', ['-NoProfile', '-Command', script]);
|
||||
} else {
|
||||
try {
|
||||
await exec('gnome-screenshot', ['-f', filePath]);
|
||||
} catch (error) {
|
||||
await exec('import', ['-window', 'root', filePath]);
|
||||
}
|
||||
}
|
||||
|
||||
const data = fs.readFileSync(filePath).toString('base64');
|
||||
return {
|
||||
filePath,
|
||||
dataUri: `data:image/png;base64,${data}`,
|
||||
size: fs.statSync(filePath).size,
|
||||
platform: os.platform(),
|
||||
};
|
||||
}
|
||||
|
||||
async function captureEditorWindowScreenshot(projectPath, options = {}) {
|
||||
const outputDir = path.join(projectPath, 'temp', 'mcp-captures');
|
||||
ensureDir(outputDir);
|
||||
const filePath = path.join(outputDir, options.fileName || `editor-${Date.now()}.png`);
|
||||
|
||||
const target = pickWindow(options);
|
||||
|
||||
const image = await target.capturePage();
|
||||
const png = image.toPNG();
|
||||
fs.writeFileSync(filePath, png);
|
||||
|
||||
return {
|
||||
filePath,
|
||||
dataUri: `data:image/png;base64,${png.toString('base64')}`,
|
||||
size: png.length,
|
||||
title: typeof target.getTitle === 'function' ? target.getTitle() : '',
|
||||
};
|
||||
}
|
||||
|
||||
async function capturePanelScreenshot(projectPath, options = {}) {
|
||||
const outputDir = path.join(projectPath, 'temp', 'mcp-captures');
|
||||
ensureDir(outputDir);
|
||||
const filePath = path.join(outputDir, options.fileName || `${options.panel || 'panel'}-${Date.now()}.png`);
|
||||
|
||||
const target = pickWindow(options);
|
||||
const bounds = await getPanelBounds(target, options.panel || 'scene');
|
||||
const image = await target.capturePage({
|
||||
x: bounds.x,
|
||||
y: bounds.y,
|
||||
width: bounds.width,
|
||||
height: bounds.height,
|
||||
});
|
||||
const png = image.toPNG();
|
||||
fs.writeFileSync(filePath, png);
|
||||
|
||||
return {
|
||||
filePath,
|
||||
dataUri: `data:image/png;base64,${png.toString('base64')}`,
|
||||
size: png.length,
|
||||
title: typeof target.getTitle === 'function' ? target.getTitle() : '',
|
||||
bounds,
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
captureDesktopScreenshot,
|
||||
captureEditorWindowScreenshot,
|
||||
capturePanelScreenshot,
|
||||
};
|
||||
+234
@@ -0,0 +1,234 @@
|
||||
'use strict';
|
||||
|
||||
const http = require('http');
|
||||
const IMAGE_DATA_URI_PREFIX = 'data:image/png;base64,';
|
||||
const LOG_PREFIX = '[Funplay Cocos MCP Server]';
|
||||
|
||||
function json(response, statusCode, payload) {
|
||||
response.writeHead(statusCode, { 'Content-Type': 'application/json; charset=utf-8' });
|
||||
response.end(JSON.stringify(payload));
|
||||
}
|
||||
|
||||
function textContent(value) {
|
||||
if (typeof value === 'string' && value.startsWith(IMAGE_DATA_URI_PREFIX)) {
|
||||
return [
|
||||
{
|
||||
type: 'image',
|
||||
data: value.slice(IMAGE_DATA_URI_PREFIX.length),
|
||||
mimeType: 'image/png',
|
||||
},
|
||||
{
|
||||
type: 'text',
|
||||
text: 'Screenshot captured successfully.',
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
type: 'text',
|
||||
text: typeof value === 'string' ? value : JSON.stringify(value, null, 2),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
class McpServer {
|
||||
constructor(options) {
|
||||
this.config = options.config;
|
||||
this.toolRegistry = options.toolRegistry;
|
||||
this.resourceProvider = options.resourceProvider;
|
||||
this.promptProvider = options.promptProvider;
|
||||
this.interactionLog = options.interactionLog;
|
||||
this.serverName = options.serverName;
|
||||
this.serverVersion = options.serverVersion;
|
||||
this.server = null;
|
||||
}
|
||||
|
||||
isRunning() {
|
||||
return Boolean(this.server && this.server.listening);
|
||||
}
|
||||
|
||||
async start() {
|
||||
if (this.isRunning()) {
|
||||
console.log(`${LOG_PREFIX} Start skipped: already running.`);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`${LOG_PREFIX} Creating HTTP server on ${this.config.host}:${this.config.port}...`);
|
||||
this.server = http.createServer(async (request, response) => {
|
||||
try {
|
||||
if (request.method === 'GET' && request.url === '/health') {
|
||||
console.log(`${LOG_PREFIX} GET /health`);
|
||||
return json(response, 200, { ok: true, name: this.serverName, version: this.serverVersion });
|
||||
}
|
||||
|
||||
if (request.method !== 'POST') {
|
||||
console.warn(`${LOG_PREFIX} Rejected ${request.method} ${request.url}: method not allowed.`);
|
||||
return json(response, 405, { error: 'Method Not Allowed' });
|
||||
}
|
||||
|
||||
const body = await this.readBody(request);
|
||||
if (!body) {
|
||||
return json(response, 400, this.createError(null, -32700, 'Parse error: empty body'));
|
||||
}
|
||||
|
||||
const rpc = JSON.parse(body);
|
||||
if (rpc && rpc.method) {
|
||||
console.log(`${LOG_PREFIX} RPC ${rpc.method}`);
|
||||
}
|
||||
const result = await this.handleRpcRequest(rpc);
|
||||
if (result == null) {
|
||||
response.writeHead(204);
|
||||
response.end();
|
||||
return;
|
||||
}
|
||||
|
||||
return json(response, 200, result);
|
||||
} catch (error) {
|
||||
console.error(`${LOG_PREFIX} Request handling failed: ${error.message}`);
|
||||
return json(response, 500, this.createError(null, -32603, `Internal error: ${error.message}`));
|
||||
}
|
||||
});
|
||||
|
||||
await new Promise((resolve, reject) => {
|
||||
this.server.once('error', reject);
|
||||
this.server.listen(this.config.port, this.config.host, () => {
|
||||
this.server.off('error', reject);
|
||||
console.log(`${LOG_PREFIX} Listening on http://${this.config.host}:${this.config.port}/`);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async stop() {
|
||||
if (!this.server) {
|
||||
console.log(`${LOG_PREFIX} Stop skipped: server object is empty.`);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`${LOG_PREFIX} Closing HTTP server...`);
|
||||
const active = this.server;
|
||||
this.server = null;
|
||||
await new Promise((resolve, reject) => {
|
||||
active.close((error) => {
|
||||
if (error) {
|
||||
console.error(`${LOG_PREFIX} Close failed: ${error.message}`);
|
||||
reject(error);
|
||||
return;
|
||||
}
|
||||
console.log(`${LOG_PREFIX} HTTP server closed.`);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
readBody(request) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const chunks = [];
|
||||
request.on('data', (chunk) => chunks.push(chunk));
|
||||
request.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
|
||||
request.on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
async handleRpcRequest(request) {
|
||||
if (!request || request.jsonrpc !== '2.0') {
|
||||
return this.createError(request && request.id, -32600, 'Invalid Request');
|
||||
}
|
||||
|
||||
const method = request.method;
|
||||
if (typeof method !== 'string' || !method) {
|
||||
return this.createError(request.id, -32600, 'Invalid Request: method is required');
|
||||
}
|
||||
|
||||
if (method === 'initialize') {
|
||||
return this.createResult(request.id, {
|
||||
protocolVersion: '2024-11-05',
|
||||
serverInfo: {
|
||||
name: this.serverName,
|
||||
version: this.serverVersion,
|
||||
},
|
||||
capabilities: {
|
||||
tools: {},
|
||||
resources: {},
|
||||
prompts: {},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (method === 'notifications/initialized' || method === 'notifications/cancelled' || method.startsWith('notifications/')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (method === 'tools/list') {
|
||||
return this.createResult(request.id, { tools: this.toolRegistry.listTools() });
|
||||
}
|
||||
|
||||
if (method === 'tools/call') {
|
||||
const params = request.params || {};
|
||||
if (typeof params.name !== 'string' || !params.name) {
|
||||
return this.createError(request.id, -32602, "Invalid params: 'name' is required");
|
||||
}
|
||||
|
||||
try {
|
||||
const output = await this.toolRegistry.callTool(params.name, params.arguments || {});
|
||||
return this.createResult(request.id, { content: textContent(output) });
|
||||
} catch (error) {
|
||||
return this.createError(request.id, -32603, error.message);
|
||||
}
|
||||
}
|
||||
|
||||
if (method === 'resources/list') {
|
||||
return this.createResult(request.id, { resources: this.resourceProvider.listResources() });
|
||||
}
|
||||
|
||||
if (method === 'resources/read') {
|
||||
const params = request.params || {};
|
||||
if (typeof params.uri !== 'string' || !params.uri) {
|
||||
return this.createError(request.id, -32602, "Invalid params: 'uri' is required");
|
||||
}
|
||||
return this.createResult(request.id, await this.resourceProvider.readResource(params.uri));
|
||||
}
|
||||
|
||||
if (method === 'resources/templates/list') {
|
||||
return this.createResult(request.id, { resourceTemplates: this.resourceProvider.listResourceTemplates() });
|
||||
}
|
||||
|
||||
if (method === 'prompts/list') {
|
||||
return this.createResult(request.id, { prompts: this.promptProvider.listPrompts() });
|
||||
}
|
||||
|
||||
if (method === 'prompts/get') {
|
||||
const params = request.params || {};
|
||||
if (typeof params.name !== 'string' || !params.name) {
|
||||
return this.createError(request.id, -32602, "Invalid params: 'name' is required");
|
||||
}
|
||||
return this.createResult(request.id, this.promptProvider.getPrompt(params.name, params.arguments || {}));
|
||||
}
|
||||
|
||||
return this.createError(request.id, -32601, `Method not found: ${method}`);
|
||||
}
|
||||
|
||||
createResult(id, result) {
|
||||
return {
|
||||
jsonrpc: '2.0',
|
||||
id,
|
||||
result,
|
||||
};
|
||||
}
|
||||
|
||||
createError(id, code, message) {
|
||||
return {
|
||||
jsonrpc: '2.0',
|
||||
id,
|
||||
error: {
|
||||
code,
|
||||
message,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
McpServer,
|
||||
};
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,31 @@
|
||||
'use strict';
|
||||
|
||||
function safeJsonParse(text, fallback = null) {
|
||||
try {
|
||||
return JSON.parse(text);
|
||||
} catch (error) {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
function safeStringify(value) {
|
||||
const seen = new WeakSet();
|
||||
return JSON.stringify(
|
||||
value,
|
||||
(key, current) => {
|
||||
if (typeof current === 'object' && current !== null) {
|
||||
if (seen.has(current)) {
|
||||
return '[Circular]';
|
||||
}
|
||||
seen.add(current);
|
||||
}
|
||||
return current;
|
||||
},
|
||||
2
|
||||
);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
safeJsonParse,
|
||||
safeStringify,
|
||||
};
|
||||
@@ -0,0 +1,99 @@
|
||||
{
|
||||
"name": "funplay-cocos-mcp",
|
||||
"package_version": 2,
|
||||
"version": "0.1.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/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"
|
||||
},
|
||||
"panels": {
|
||||
"default": {
|
||||
"title": "Funplay Cocos MCP",
|
||||
"type": "dockable",
|
||||
"main": "panel/index.js",
|
||||
"size": {
|
||||
"min-width": 520,
|
||||
"min-height": 480,
|
||||
"width": 760,
|
||||
"height": 720
|
||||
}
|
||||
}
|
||||
},
|
||||
"contributions": {
|
||||
"menu": [
|
||||
{
|
||||
"path": "Funplay",
|
||||
"label": "MCP Server",
|
||||
"message": "open-panel"
|
||||
}
|
||||
],
|
||||
"messages": {
|
||||
"open-panel": {
|
||||
"methods": [
|
||||
"openPanel"
|
||||
]
|
||||
},
|
||||
"start-server": {
|
||||
"methods": [
|
||||
"startServer"
|
||||
]
|
||||
},
|
||||
"stop-server": {
|
||||
"methods": [
|
||||
"stopServer"
|
||||
]
|
||||
},
|
||||
"restart-server": {
|
||||
"methods": [
|
||||
"restartServer"
|
||||
]
|
||||
},
|
||||
"get-status": {
|
||||
"methods": [
|
||||
"getStatus"
|
||||
]
|
||||
},
|
||||
"get-panel-state": {
|
||||
"methods": [
|
||||
"getPanelState"
|
||||
]
|
||||
},
|
||||
"save-config": {
|
||||
"methods": [
|
||||
"saveConfig"
|
||||
]
|
||||
},
|
||||
"list-tools": {
|
||||
"methods": [
|
||||
"listToolsForPanel"
|
||||
]
|
||||
},
|
||||
"call-tool": {
|
||||
"methods": [
|
||||
"callToolFromPanel"
|
||||
]
|
||||
},
|
||||
"read-resource": {
|
||||
"methods": [
|
||||
"readResourceFromPanel"
|
||||
]
|
||||
},
|
||||
"get-client-config": {
|
||||
"methods": [
|
||||
"getClientConfig"
|
||||
]
|
||||
},
|
||||
"configure-client": {
|
||||
"methods": [
|
||||
"configureClient"
|
||||
]
|
||||
}
|
||||
},
|
||||
"scene": {
|
||||
"script": "scene.js"
|
||||
}
|
||||
}
|
||||
}
|
||||
+345
@@ -0,0 +1,345 @@
|
||||
'use strict';
|
||||
|
||||
const PKG = 'funplay-cocos-mcp';
|
||||
|
||||
function request(message, ...args) {
|
||||
return Editor.Message.request(PKG, message, ...args);
|
||||
}
|
||||
|
||||
function stringify(value) {
|
||||
if (typeof value === 'string') {
|
||||
return value;
|
||||
}
|
||||
return JSON.stringify(value, null, 2);
|
||||
}
|
||||
|
||||
module.exports = Editor.Panel.define({
|
||||
template: `
|
||||
<div class="mcp-root">
|
||||
<header class="hero">
|
||||
<div>
|
||||
<h1>Funplay Cocos MCP</h1>
|
||||
<p>Operate the embedded MCP server from Cocos Creator.</p>
|
||||
</div>
|
||||
<div class="status-pill" id="statusPill">Unknown</div>
|
||||
</header>
|
||||
|
||||
<section class="card">
|
||||
<h2>Service</h2>
|
||||
<div id="statusText" class="status-line muted"></div>
|
||||
<div class="row top-actions">
|
||||
<label class="checkbox-line checkbox-inline">
|
||||
<ui-checkbox id="enabledInput"></ui-checkbox>
|
||||
Enable MCP Server
|
||||
</label>
|
||||
<ui-button id="restartBtn">Restart</ui-button>
|
||||
<ui-button id="copyUrlBtn">Copy URL</ui-button>
|
||||
</div>
|
||||
<div class="grid">
|
||||
<label>Server Port <ui-num-input id="portInput"></ui-num-input></label>
|
||||
<label>Tool Exposure
|
||||
<ui-select id="profileSelect">
|
||||
<option value="core">core</option>
|
||||
<option value="full">full</option>
|
||||
</ui-select>
|
||||
</label>
|
||||
</div>
|
||||
<p>Changes auto-save. Port/profile changes restart the server when needed.</p>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h2>MCP Client Config</h2>
|
||||
<div class="row">
|
||||
<ui-select id="clientTargetSelect"></ui-select>
|
||||
<ui-button id="configureClientBtn" class="primary">One-Click Configure</ui-button>
|
||||
</div>
|
||||
<div id="clientTargetStatus" class="client-status muted"></div>
|
||||
<ui-textarea id="clientConfigText"></ui-textarea>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<details>
|
||||
<summary>Debug Output</summary>
|
||||
<pre id="output"></pre>
|
||||
</details>
|
||||
</section>
|
||||
</div>
|
||||
`,
|
||||
style: `
|
||||
:host {
|
||||
color: var(--color-normal-contrast);
|
||||
background: var(--color-normal-fill);
|
||||
font-size: 13px;
|
||||
}
|
||||
.mcp-root {
|
||||
padding: 14px;
|
||||
box-sizing: border-box;
|
||||
overflow: auto;
|
||||
height: 100%;
|
||||
}
|
||||
.hero {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
h1 {
|
||||
margin: 0;
|
||||
font-size: 22px;
|
||||
}
|
||||
h2 {
|
||||
margin: 0 0 10px 0;
|
||||
font-size: 15px;
|
||||
}
|
||||
p {
|
||||
margin: 4px 0 0 0;
|
||||
color: var(--color-normal-contrast-weakest);
|
||||
}
|
||||
.tip {
|
||||
margin: 0 0 10px 0;
|
||||
}
|
||||
code {
|
||||
font-family: Menlo, monospace;
|
||||
background: rgba(255,255,255,0.08);
|
||||
padding: 1px 4px;
|
||||
border-radius: 4px;
|
||||
color: var(--color-normal-contrast);
|
||||
}
|
||||
.card {
|
||||
border: 1px solid var(--color-normal-border);
|
||||
border-radius: 8px;
|
||||
background: var(--color-normal-fill-emphasis);
|
||||
padding: 12px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 8px;
|
||||
}
|
||||
.top-actions {
|
||||
margin-top: 0;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(140px, 1fr));
|
||||
gap: 8px;
|
||||
align-items: end;
|
||||
}
|
||||
label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
color: var(--color-normal-contrast-weak);
|
||||
}
|
||||
.checkbox-line {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
}
|
||||
.checkbox-inline {
|
||||
padding-top: 0;
|
||||
color: var(--color-normal-contrast);
|
||||
gap: 6px;
|
||||
}
|
||||
.status-pill {
|
||||
border-radius: 999px;
|
||||
padding: 6px 10px;
|
||||
background: #555;
|
||||
color: white;
|
||||
font-weight: 600;
|
||||
}
|
||||
.status-pill.running {
|
||||
background: #1f8f4d;
|
||||
}
|
||||
.status-pill.stopped {
|
||||
background: #8f3d3d;
|
||||
}
|
||||
.muted {
|
||||
color: var(--color-normal-contrast-weakest);
|
||||
}
|
||||
.status-line {
|
||||
margin-bottom: 10px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.client-status {
|
||||
margin: 8px 0;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
}
|
||||
details {
|
||||
display: block;
|
||||
}
|
||||
summary {
|
||||
cursor: pointer;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: var(--color-normal-contrast);
|
||||
outline: none;
|
||||
user-select: none;
|
||||
}
|
||||
ui-textarea {
|
||||
width: 100%;
|
||||
min-height: 100px;
|
||||
}
|
||||
pre {
|
||||
min-height: 120px;
|
||||
max-height: 220px;
|
||||
overflow: auto;
|
||||
margin: 0;
|
||||
background: #111;
|
||||
color: #d7ffd7;
|
||||
padding: 10px;
|
||||
border-radius: 6px;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
.primary {
|
||||
border-color: #4aa3ff;
|
||||
}
|
||||
`,
|
||||
$: {
|
||||
root: '.mcp-root',
|
||||
statusPill: '#statusPill',
|
||||
statusText: '#statusText',
|
||||
enabledInput: '#enabledInput',
|
||||
portInput: '#portInput',
|
||||
profileSelect: '#profileSelect',
|
||||
restartBtn: '#restartBtn',
|
||||
copyUrlBtn: '#copyUrlBtn',
|
||||
clientTargetSelect: '#clientTargetSelect',
|
||||
configureClientBtn: '#configureClientBtn',
|
||||
clientTargetStatus: '#clientTargetStatus',
|
||||
clientConfigText: '#clientConfigText',
|
||||
output: '#output',
|
||||
},
|
||||
methods: {
|
||||
async refresh() {
|
||||
try {
|
||||
this.state = await request('get-panel-state');
|
||||
this.renderState();
|
||||
} catch (error) {
|
||||
this.showOutput(`Refresh failed: ${error.message}`);
|
||||
}
|
||||
},
|
||||
renderState() {
|
||||
const state = this.state || {};
|
||||
const status = state.status || {};
|
||||
const config = state.config || {};
|
||||
const isRunning = Boolean(status.running);
|
||||
|
||||
this.$.statusPill.textContent = isRunning ? 'Running' : 'Stopped';
|
||||
this.$.statusPill.classList.toggle('running', isRunning);
|
||||
this.$.statusPill.classList.toggle('stopped', !isRunning);
|
||||
this.$.statusText.textContent = `${status.url || ''} | Project: ${status.projectName || ''} | Cocos ${status.cocosVersion || ''}`;
|
||||
|
||||
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.$.clientConfigText.value = state.clientConfig ? state.clientConfig.codex : '';
|
||||
this.renderClientTargets();
|
||||
},
|
||||
renderClientTargets() {
|
||||
const targets = (this.state && this.state.clientTargets) || [];
|
||||
const selected = this.$.clientTargetSelect.value || (targets[0] && targets[0].id);
|
||||
this.$.clientTargetSelect.innerHTML = targets
|
||||
.map((target) => `<option value="${target.id}">${target.name}</option>`)
|
||||
.join('');
|
||||
if (selected) {
|
||||
this.$.clientTargetSelect.value = selected;
|
||||
}
|
||||
this.renderClientTargetStatus();
|
||||
},
|
||||
renderClientTargetStatus() {
|
||||
const targets = (this.state && this.state.clientTargets) || [];
|
||||
const target = targets.find((item) => item.id === this.$.clientTargetSelect.value) || targets[0];
|
||||
if (!target) {
|
||||
this.$.clientTargetStatus.textContent = 'No client targets available.';
|
||||
return;
|
||||
}
|
||||
this.$.clientTargetStatus.textContent = `${target.configured ? 'Configured' : 'Not configured'}: ${target.configPath}`;
|
||||
},
|
||||
showOutput(value) {
|
||||
this.$.output.textContent = stringify(value);
|
||||
},
|
||||
async persistConfig(options = {}) {
|
||||
const { showOutput = false } = options;
|
||||
try {
|
||||
const panelState = await request('save-config', this.collectConfig());
|
||||
this.state = panelState;
|
||||
this.renderState();
|
||||
if (showOutput) {
|
||||
this.showOutput('Configuration saved.');
|
||||
}
|
||||
return panelState;
|
||||
} catch (error) {
|
||||
this.showOutput(`Save config failed: ${error.message}`);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
async runAction(action) {
|
||||
try {
|
||||
const result = await action();
|
||||
this.showOutput(result);
|
||||
await this.refresh();
|
||||
} catch (error) {
|
||||
this.showOutput(`Error: ${error.message}`);
|
||||
}
|
||||
},
|
||||
collectConfig() {
|
||||
return {
|
||||
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',
|
||||
autostart: Boolean(this.$.enabledInput.value),
|
||||
maxInteractionLogEntries: this.state && this.state.config ? this.state.config.maxInteractionLogEntries : 50,
|
||||
};
|
||||
},
|
||||
async handleEnableToggle() {
|
||||
const shouldEnable = Boolean(this.$.enabledInput.value);
|
||||
const wasRunning = Boolean(this.state && this.state.status && this.state.status.running);
|
||||
|
||||
await this.persistConfig();
|
||||
if (shouldEnable && !wasRunning) {
|
||||
await this.runAction(() => request('start-server'));
|
||||
return;
|
||||
}
|
||||
if (!shouldEnable && wasRunning) {
|
||||
await this.runAction(() => request('stop-server'));
|
||||
return;
|
||||
}
|
||||
await this.refresh();
|
||||
},
|
||||
},
|
||||
ready() {
|
||||
this.state = null;
|
||||
|
||||
this.$.restartBtn.addEventListener('click', () => this.runAction(() => request('restart-server')));
|
||||
this.$.copyUrlBtn.addEventListener('click', () => {
|
||||
const status = this.state && this.state.status;
|
||||
const text = status && status.url ? status.url : '';
|
||||
navigator.clipboard.writeText(text)
|
||||
.then(() => this.showOutput('Copied URL to clipboard.'))
|
||||
.catch(() => this.showOutput(text));
|
||||
});
|
||||
this.$.enabledInput.addEventListener('change', () => this.handleEnableToggle());
|
||||
this.$.portInput.addEventListener('change', () => this.persistConfig({ showOutput: true }));
|
||||
this.$.profileSelect.addEventListener('change', () => this.persistConfig({ showOutput: true }));
|
||||
this.$.clientTargetSelect.addEventListener('confirm', () => this.renderClientTargetStatus());
|
||||
this.$.clientTargetSelect.addEventListener('change', () => this.renderClientTargetStatus());
|
||||
this.$.configureClientBtn.addEventListener('click', () => {
|
||||
const targetId = this.$.clientTargetSelect.value;
|
||||
if (!targetId) {
|
||||
this.showOutput('Select a client target first.');
|
||||
return;
|
||||
}
|
||||
this.runAction(() => request('configure-client', targetId));
|
||||
});
|
||||
|
||||
this.refresh();
|
||||
},
|
||||
close() {},
|
||||
});
|
||||
Reference in New Issue
Block a user