Files
mcp-bridge/main.js

1788 lines
51 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use strict";
const http = require("http");
const path = require("path");
const fs = require("fs");
let logBuffer = []; // 存储所有日志
let mcpServer = null;
let isSceneBusy = false;
let serverConfig = {
port: 3456,
active: false,
};
// 封装日志函数,同时发送给面板和编辑器控制台
// 封装日志函数,同时发送给面板和编辑器控制台
function addLog(type, message) {
const logEntry = {
time: new Date().toLocaleTimeString(),
type: type,
content: message,
};
logBuffer.push(logEntry);
Editor.Ipc.sendToPanel("mcp-bridge", "mcp-bridge:on-log", logEntry);
// 【修改】确保所有日志都输出到编辑器控制台,以便用户查看
if (type === "error") {
Editor.error(`[MCP] ${message}`);
} else if (type === "warn") {
Editor.warn(`[MCP] ${message}`);
} else {
}
}
function getLogContent() {
return logBuffer.map(entry => `[${entry.time}] [${entry.type}] ${entry.content}`).join('\n');
}
const getNewSceneTemplate = () => {
// 尝试获取 UUID 生成函数
let newId = "";
if (Editor.Utils && Editor.Utils.uuid) {
newId = Editor.Utils.uuid();
} else if (Editor.Utils && Editor.Utils.UuidUtils && Editor.Utils.UuidUtils.uuid) {
newId = Editor.Utils.UuidUtils.uuid();
} else {
// 兜底方案:如果找不到编辑器 API生成一个随机字符串
newId = Math.random().toString(36).substring(2, 15);
}
const sceneData = [
{
__type__: "cc.SceneAsset",
_name: "",
_objFlags: 0,
_native: "",
scene: { __id__: 1 },
},
{
__id__: 1,
__type__: "cc.Scene",
_name: "",
_objFlags: 0,
_parent: null,
_children: [],
_active: true,
_level: 0,
_components: [],
autoReleaseAssets: false,
_id: newId,
},
];
return JSON.stringify(sceneData);
};
const getToolsList = () => {
return [
{
name: "get_selected_node",
description: "获取当前编辑器中选中的节点 ID",
inputSchema: { type: "object", properties: {} },
},
{
name: "set_node_name",
description: "修改指定节点的名称",
inputSchema: {
type: "object",
properties: {
id: { type: "string", description: "节点的 UUID" },
newName: { type: "string", description: "新的节点名称" },
},
required: ["id", "newName"],
},
},
{
name: "save_scene",
description: "保存当前场景的修改",
inputSchema: { type: "object", properties: {} },
},
{
name: "get_scene_hierarchy",
description: "获取当前场景的完整节点树结构(包括 UUID、名称和层级关系",
inputSchema: { type: "object", properties: {} },
},
{
name: "update_node_transform",
description: "修改节点的坐标、缩放或颜色",
inputSchema: {
type: "object",
properties: {
id: { type: "string", description: "节点 UUID" },
x: { type: "number" },
y: { type: "number" },
scaleX: { type: "number" },
scaleY: { type: "number" },
color: { type: "string", description: "HEX 颜色代码如 #FF0000" },
},
required: ["id"],
},
},
{
name: "create_scene",
description: "在 assets 目录下创建一个新的场景文件",
inputSchema: {
type: "object",
properties: {
sceneName: { type: "string", description: "场景名称" },
},
required: ["sceneName"],
},
},
{
name: "create_prefab",
description: "将场景中的某个节点保存为预制体资源",
inputSchema: {
type: "object",
properties: {
nodeId: { type: "string", description: "节点 UUID" },
prefabName: { type: "string", description: "预制体名称" },
},
required: ["nodeId", "prefabName"],
},
},
{
name: "open_scene",
description: "打开场景文件。注意:这是一个异步且耗时的操作,打开后请等待几秒再进行节点创建或保存操作。",
inputSchema: {
type: "object",
properties: {
url: {
type: "string",
description: "场景资源路径,如 db://assets/NewScene.fire",
},
},
required: ["url"],
},
},
{
name: "create_node",
description: "在当前场景中创建一个新节点",
inputSchema: {
type: "object",
properties: {
name: { type: "string", description: "节点名称" },
parentId: {
type: "string",
description: "父节点 UUID (可选,不传则挂在场景根部)",
},
type: {
type: "string",
enum: ["empty", "sprite", "label"],
description: "节点预设类型",
},
},
required: ["name"],
},
},
{
name: "manage_components",
description: "管理节点组件",
inputSchema: {
type: "object",
properties: {
nodeId: { type: "string", description: "节点 UUID" },
action: { type: "string", enum: ["add", "remove", "get"], description: "操作类型" },
componentType: { type: "string", description: "组件类型,如 cc.Sprite" },
componentId: { type: "string", description: "组件 ID (用于 remove 操作)" },
properties: { type: "object", description: "组件属性 (用于 add 操作)" },
},
required: ["nodeId", "action"],
},
},
{
name: "manage_script",
description: "管理脚本文件",
inputSchema: {
type: "object",
properties: {
action: { type: "string", enum: ["create", "delete", "read", "write"], description: "操作类型" },
path: { type: "string", description: "脚本路径,如 db://assets/scripts/NewScript.js" },
content: { type: "string", description: "脚本内容 (用于 create 和 write 操作)" },
name: { type: "string", description: "脚本名称 (用于 create 操作)" },
},
required: ["action", "path"],
},
},
{
name: "batch_execute",
description: "批处理执行多个操作",
inputSchema: {
type: "object",
properties: {
operations: {
type: "array",
items: {
type: "object",
properties: {
tool: { type: "string", description: "工具名称" },
params: { type: "object", description: "工具参数" },
},
required: ["tool", "params"],
},
description: "操作列表",
},
},
required: ["operations"],
},
},
{
name: "manage_asset",
description: "管理资源",
inputSchema: {
type: "object",
properties: {
action: { type: "string", enum: ["create", "delete", "move", "get_info"], description: "操作类型" },
path: { type: "string", description: "资源路径,如 db://assets/textures" },
targetPath: { type: "string", description: "目标路径 (用于 move 操作)" },
content: { type: "string", description: "资源内容 (用于 create 操作)" },
},
required: ["action", "path"],
},
},
{
name: "scene_management",
description: "场景管理",
inputSchema: {
type: "object",
properties: {
action: {
type: "string",
enum: ["create", "delete", "duplicate", "get_info"],
description: "操作类型",
},
path: { type: "string", description: "场景路径,如 db://assets/scenes/NewScene.fire" },
targetPath: { type: "string", description: "目标路径 (用于 duplicate 操作)" },
name: { type: "string", description: "场景名称 (用于 create 操作)" },
},
required: ["action", "path"],
},
},
{
name: "prefab_management",
description: "预制体管理",
inputSchema: {
type: "object",
properties: {
action: {
type: "string",
enum: ["create", "update", "instantiate", "get_info"],
description: "操作类型",
},
path: { type: "string", description: "预制体路径,如 db://assets/prefabs/NewPrefab.prefab" },
nodeId: { type: "string", description: "节点 ID (用于 create 操作)" },
parentId: { type: "string", description: "父节点 ID (用于 instantiate 操作)" },
},
required: ["action", "path"],
},
},
{
name: "manage_editor",
description: "管理编辑器",
inputSchema: {
type: "object",
properties: {
action: {
type: "string",
enum: ["get_selection", "set_selection", "refresh_editor"],
description: "操作类型",
},
target: {
type: "string",
enum: ["node", "asset"],
description: "目标类型 (用于 set_selection 操作)",
},
properties: { type: "object", description: "操作属性" },
},
required: ["action"],
},
},
{
name: "find_gameobjects",
description: "查找游戏对象",
inputSchema: {
type: "object",
properties: {
conditions: { type: "object", description: "查找条件" },
recursive: { type: "boolean", default: true, description: "是否递归查找" },
},
required: ["conditions"],
},
},
{
name: "manage_material",
description: "管理材质",
inputSchema: {
type: "object",
properties: {
action: { type: "string", enum: ["create", "delete", "get_info"], description: "操作类型" },
path: { type: "string", description: "材质路径,如 db://assets/materials/NewMaterial.mat" },
properties: { type: "object", description: "材质属性" },
},
required: ["action", "path"],
},
},
{
name: "manage_texture",
description: "管理纹理",
inputSchema: {
type: "object",
properties: {
action: { type: "string", enum: ["create", "delete", "get_info"], description: "操作类型" },
path: { type: "string", description: "纹理路径,如 db://assets/textures/NewTexture.png" },
properties: { type: "object", description: "纹理属性" },
},
required: ["action", "path"],
},
},
{
name: "execute_menu_item",
description: "执行菜单项",
inputSchema: {
type: "object",
properties: {
menuPath: { type: "string", description: "菜单项路径" },
},
required: ["menuPath"],
},
},
{
name: "apply_text_edits",
description: "应用文本编辑",
inputSchema: {
type: "object",
properties: {
filePath: { type: "string", description: "文件路径" },
edits: { type: "array", items: { type: "object" }, description: "编辑操作列表" },
},
required: ["filePath", "edits"],
},
},
{
name: "read_console",
description: "读取控制台",
inputSchema: {
type: "object",
properties: {
limit: { type: "number", description: "输出限制" },
type: { type: "string", enum: ["log", "error", "warn"], description: "输出类型" },
},
},
},
{
name: "validate_script",
description: "验证脚本",
inputSchema: {
type: "object",
properties: {
filePath: { type: "string", description: "脚本路径" },
},
required: ["filePath"],
},
},
{
name: "find_in_file",
description: "在项目中全局搜索文本内容",
inputSchema: {
type: "object",
properties: {
query: { type: "string", description: "搜索关键词" },
extensions: {
type: "array",
items: { type: "string" },
description: "文件后缀列表 (例如 ['.js', '.ts'])",
default: [".js", ".ts", ".json", ".fire", ".prefab", ".xml", ".txt", ".md"]
},
includeSubpackages: { type: "boolean", default: true, description: "是否搜索子包 (暂时默认搜索 assets 目录)" }
},
required: ["query"]
}
},
{
name: "manage_undo",
description: "管理编辑器的撤销和重做历史",
inputSchema: {
type: "object",
properties: {
action: {
type: "string",
enum: ["undo", "redo", "begin_group", "end_group", "cancel_group"],
description: "操作类型"
},
description: { type: "string", description: "撤销组的描述 (用于 begin_group)" }
},
required: ["action"]
}
},
{
name: "manage_vfx",
description: "管理全场景特效 (粒子系统)",
inputSchema: {
type: "object",
properties: {
action: {
type: "string",
enum: ["create", "update", "get_info"],
description: "操作类型"
},
nodeId: { type: "string", description: "节点 UUID (用于 update/get_info)" },
properties: {
type: "object",
description: "粒子系统属性 (用于 create/update)",
properties: {
duration: { type: "number", description: "发射时长" },
emissionRate: { type: "number", description: "发射速率" },
life: { type: "number", description: "生命周期" },
lifeVar: { type: "number", description: "生命周期变化" },
startColor: { type: "string", description: "起始颜色 (Hex)" },
endColor: { type: "string", description: "结束颜色 (Hex)" },
startSize: { type: "number", description: "起始大小" },
endSize: { type: "number", description: "结束大小" },
speed: { type: "number", description: "速度" },
angle: { type: "number", description: "角度" },
gravity: { type: "object", properties: { x: { type: "number" }, y: { type: "number" } } },
file: { type: "string", description: "粒子文件路径 (plist) 或 texture 路径" }
}
},
name: { type: "string", description: "节点名称 (用于 create)" },
parentId: { type: "string", description: "父节点 ID (用于 create)" }
},
required: ["action"]
}
}
];
};
module.exports = {
"scene-script": "scene-script.js",
load() {
addLog("info", "MCP Bridge Plugin Loaded");
// 读取配置
let profile = this.getProfile();
serverConfig.port = profile.get("last-port") || 3456;
let autoStart = profile.get("auto-start");
if (autoStart) {
addLog("info", "Auto-start is enabled. Initializing server...");
// 延迟一点启动,确保编辑器环境完全就绪
setTimeout(() => {
this.startServer(serverConfig.port);
}, 1000);
}
},
// 获取配置文件的辅助函数
getProfile() {
// 'local' 表示存储在项目本地local/mcp-bridge.json
return Editor.Profile.load("profile://local/mcp-bridge.json", "mcp-bridge");
},
unload() {
this.stopServer();
},
startServer(port) {
if (mcpServer) this.stopServer();
try {
mcpServer = http.createServer((req, res) => {
res.setHeader("Content-Type", "application/json");
res.setHeader("Access-Control-Allow-Origin", "*");
let body = "";
req.on("data", (chunk) => {
body += chunk;
});
req.on("end", () => {
const url = req.url;
if (url === "/list-tools") {
const tools = getToolsList();
addLog("info", `AI Client requested tool list`);
// 明确返回成功结构
res.writeHead(200);
return res.end(JSON.stringify({ tools: tools }));
res.writeHead(200);
return res.end(JSON.stringify({ tools: tools }));
}
if (url === "/list-resources") {
const resources = this.getResourcesList();
addLog("info", `AI Client requested resource list`);
res.writeHead(200);
return res.end(JSON.stringify({ resources: resources }));
}
if (url === "/read-resource") {
try {
const { uri } = JSON.parse(body || "{}");
addLog("mcp", `READ -> [${uri}]`);
this.handleReadResource(uri, (err, content) => {
if (err) {
addLog("error", `读取失败: ${err}`);
res.writeHead(500);
return res.end(JSON.stringify({ error: err }));
}
addLog("success", `读取成功: ${uri}`);
res.writeHead(200);
// 返回 MCP Resource 格式: { contents: [{ uri, mimeType, text }] }
res.end(JSON.stringify({
contents: [{
uri: uri,
mimeType: "application/json",
text: typeof content === 'string' ? content : JSON.stringify(content)
}]
}));
});
} catch (e) {
res.writeHead(500);
res.end(JSON.stringify({ error: e.message }));
}
return;
}
if (url === "/call-tool") {
try {
const { name, arguments: args } = JSON.parse(body || "{}");
addLog("mcp", `REQ -> [${name}]`);
this.handleMcpCall(name, args, (err, result) => {
const response = {
content: [
{
type: "text",
text: err
? `Error: ${err}`
: typeof result === "object"
? JSON.stringify(result, null, 2)
: result,
},
],
};
if (err) {
addLog("error", `RES <- [${name}] 失败: ${err}`);
} else {
// 成功时尝试捕获简单的结果预览(如果是字符串或简短对象)
let preview = "";
if (typeof result === 'string') {
preview = result.length > 100 ? result.substring(0, 100) + "..." : result;
} else if (typeof result === 'object') {
try {
const jsonStr = JSON.stringify(result);
preview = jsonStr.length > 100 ? jsonStr.substring(0, 100) + "..." : jsonStr;
} catch (e) {
preview = "Object (Circular/Unserializable)";
}
}
addLog("success", `RES <- [${name}] 成功 : ${preview}`);
}
res.writeHead(200);
res.end(JSON.stringify(response));
});
} catch (e) {
if (e instanceof SyntaxError) {
addLog("error", `JSON Parse Error: ${e.message}`);
res.writeHead(400);
res.end(JSON.stringify({ error: "Invalid JSON" }));
} else {
addLog("error", `Internal Server Error: ${e.message}`);
res.writeHead(500);
res.end(JSON.stringify({ error: e.message }));
}
}
return;
}
// --- 兜底处理 (404) ---
res.writeHead(404);
res.end(JSON.stringify({ error: "Not Found", url: url }));
});
});
mcpServer.on("error", (e) => {
addLog("error", `Server Error: ${e.message}`);
});
mcpServer.listen(port, () => {
serverConfig.active = true;
addLog("success", `MCP Server running at http://127.0.0.1:${port}`);
Editor.Ipc.sendToPanel("mcp-bridge", "mcp-bridge:state-changed", serverConfig);
});
// 启动成功后顺便存一下端口
this.getProfile().set("last-port", port);
this.getProfile().save();
} catch (e) {
addLog("error", `Failed to start server: ${e.message}`);
}
},
stopServer() {
if (mcpServer) {
mcpServer.close();
mcpServer = null;
serverConfig.active = false;
addLog("warn", "MCP Server stopped");
Editor.Ipc.sendToPanel("mcp-bridge", "mcp-bridge:state-changed", serverConfig);
}
},
getResourcesList() {
return [
{
uri: "cocos://hierarchy",
name: "Scene Hierarchy",
description: "当前场景层级的 JSON 快照",
mimeType: "application/json"
},
{
uri: "cocos://selection",
name: "Current Selection",
description: "当前选中节点的 UUID 列表",
mimeType: "application/json"
},
{
uri: "cocos://logs/latest",
name: "Editor Logs",
description: "最新的编辑器日志 (内存缓存)",
mimeType: "text/plain"
}
];
},
handleReadResource(uri, callback) {
let parsed;
try {
parsed = new URL(uri);
} catch (e) {
return callback(`Invalid URI: ${uri}`);
}
if (parsed.protocol !== "cocos:") {
return callback(`Unsupported protocol: ${parsed.protocol}`);
}
const type = parsed.hostname; // hierarchy, selection, logs
switch (type) {
case "hierarchy":
// 注意: query-hierarchy 是异步的
Editor.Ipc.sendToPanel("scene", "scene:query-hierarchy", (err, sceneId, hierarchy) => {
if (err) return callback(err);
callback(null, JSON.stringify(hierarchy, null, 2));
});
break;
case "selection":
const selection = Editor.Selection.curSelection("node");
callback(null, JSON.stringify(selection));
break;
case "logs":
callback(null, getLogContent());
break;
default:
callback(`Resource not found: ${uri}`);
break;
}
},
handleMcpCall(name, args, callback) {
if (isSceneBusy && (name === "save_scene" || name === "create_node")) {
return callback("Editor is busy (Processing Scene), please wait a moment.");
}
switch (name) {
case "get_selected_node":
const ids = Editor.Selection.curSelection("node");
callback(null, ids);
break;
case "set_node_name":
// 使用 scene:set-property 以支持撤销
Editor.Ipc.sendToPanel("scene", "scene:set-property", {
id: args.id,
path: "name",
type: "String",
value: args.newName,
isSubProp: false
});
callback(null, `Node name updated to ${args.newName}`);
break;
case "save_scene":
isSceneBusy = true;
addLog("info", "Preparing to save scene... Waiting for UI sync.");
// 强制延迟保存,防止死锁
setTimeout(() => {
// 使用 stash-and-save 替代 save-scene这更接近 Ctrl+S 的行为
Editor.Ipc.sendToMain("scene:stash-and-save");
addLog("info", "Executing Safe Save (Stash)...");
setTimeout(() => {
isSceneBusy = false;
addLog("info", "Safe Save completed.");
callback(null, "Scene saved successfully.");
}, 1000);
}, 500);
break;
case "get_scene_hierarchy":
Editor.Scene.callSceneScript("mcp-bridge", "get-hierarchy", callback);
break;
case "update_node_transform":
// 直接调用场景脚本更新属性,绕过可能导致 "Unknown object" 的复杂 Undo 系统
Editor.Scene.callSceneScript("mcp-bridge", "update-node-transform", args, (err, result) => {
if (err) {
addLog("error", `Transform update failed: ${err}`);
callback(err);
} else {
callback(null, "Transform updated");
}
});
break;
case "create_scene":
const sceneUrl = `db://assets/${args.sceneName}.fire`;
if (Editor.assetdb.exists(sceneUrl)) {
return callback("Scene already exists");
}
Editor.assetdb.create(sceneUrl, getNewSceneTemplate(), (err) => {
callback(err, err ? null : `Standard Scene created at ${sceneUrl}`);
});
break;
case "create_prefab":
const prefabUrl = `db://assets/${args.prefabName}.prefab`;
Editor.Ipc.sendToMain("scene:create-prefab", args.nodeId, prefabUrl);
callback(null, `Command sent: Creating prefab '${args.prefabName}'`);
break;
case "open_scene":
isSceneBusy = true; // 锁定
const openUuid = Editor.assetdb.urlToUuid(args.url);
if (openUuid) {
Editor.Ipc.sendToMain("scene:open-by-uuid", openUuid);
setTimeout(() => {
isSceneBusy = false;
callback(null, `Success: Opening scene ${args.url}`);
}, 2000);
} else {
isSceneBusy = false;
callback(`Could not find asset with URL ${args.url}`);
}
break;
case "create_node":
Editor.Scene.callSceneScript("mcp-bridge", "create-node", args, callback);
break;
case "manage_components":
Editor.Scene.callSceneScript("mcp-bridge", "manage-components", args, callback);
break;
case "manage_script":
this.manageScript(args, callback);
break;
case "batch_execute":
this.batchExecute(args, callback);
break;
case "manage_asset":
this.manageAsset(args, callback);
break;
case "scene_management":
this.sceneManagement(args, callback);
break;
case "prefab_management":
this.prefabManagement(args, callback);
break;
case "manage_editor":
this.manageEditor(args, callback);
break;
case "find_gameobjects":
Editor.Scene.callSceneScript("mcp-bridge", "find-gameobjects", args, callback);
break;
case "manage_material":
this.manageMaterial(args, callback);
break;
case "manage_texture":
this.manageTexture(args, callback);
break;
case "execute_menu_item":
this.executeMenuItem(args, callback);
break;
case "apply_text_edits":
this.applyTextEdits(args, callback);
break;
case "read_console":
this.readConsole(args, callback);
break;
case "validate_script":
this.validateScript(args, callback);
break;
case "find_in_file":
this.findInFile(args, callback);
break;
case "manage_undo":
this.manageUndo(args, callback);
break;
case "manage_vfx":
// 【修复】在主进程预先解析 URL 为 UUID因为渲染进程(scene-script)无法访问 Editor.assetdb
if (args.properties && args.properties.file) {
if (typeof args.properties.file === 'string' && args.properties.file.startsWith("db://")) {
const uuid = Editor.assetdb.urlToUuid(args.properties.file);
if (uuid) {
args.properties.file = uuid; // 替换为 UUID
} else {
console.warn(`Failed to resolve path to UUID: ${args.properties.file}`);
}
}
}
// 预先获取默认贴图 UUID (尝试多个可能的路径)
const defaultPaths = [
"db://internal/image/default_sprite_splash",
"db://internal/image/default_sprite_splash.png",
"db://internal/image/default_particle",
"db://internal/image/default_particle.png"
];
for (const path of defaultPaths) {
const uuid = Editor.assetdb.urlToUuid(path);
if (uuid) {
args.defaultSpriteUuid = uuid;
addLog("info", `[mcp-bridge] Resolved Default Sprite UUID: ${uuid} from ${path}`);
break;
}
}
if (!args.defaultSpriteUuid) {
addLog("warn", "[mcp-bridge] Failed to resolve any default sprite UUID.");
}
Editor.Scene.callSceneScript("mcp-bridge", "manage-vfx", args, callback);
break;
default:
callback(`Unknown tool: ${name}`);
break;
}
},
// 管理脚本文件
manageScript(args, callback) {
const { action, path: scriptPath, content } = args;
switch (action) {
case "create":
if (Editor.assetdb.exists(scriptPath)) {
return callback(`Script already exists at ${scriptPath}`);
}
// 确保父目录存在
const absolutePath = Editor.assetdb.urlToFspath(scriptPath);
const dirPath = path.dirname(absolutePath);
if (!fs.existsSync(dirPath)) {
fs.mkdirSync(dirPath, { recursive: true });
}
Editor.assetdb.create(
scriptPath,
content ||
`const { ccclass, property } = cc._decorator;
@ccclass
export default class NewScript extends cc.Component {
@property(cc.Label)
label: cc.Label = null;
@property
text: string = 'hello';
// LIFE-CYCLE CALLBACKS:
onLoad () {}
start () {}
update (dt) {}
}`,
(err) => {
callback(err, err ? null : `Script created at ${scriptPath}`);
},
);
break;
case "delete":
if (!Editor.assetdb.exists(scriptPath)) {
return callback(`Script not found at ${scriptPath}`);
}
Editor.assetdb.delete([scriptPath], (err) => {
callback(err, err ? null : `Script deleted at ${scriptPath}`);
});
break;
case "read":
// 使用 fs 读取,绕过 assetdb.loadAny
const readFsPath = Editor.assetdb.urlToFspath(scriptPath);
if (!readFsPath || !fs.existsSync(readFsPath)) {
return callback(`Script not found at ${scriptPath}`);
}
try {
const content = fs.readFileSync(readFsPath, "utf-8");
callback(null, content);
} catch (e) {
callback(`Failed to read script: ${e.message}`);
}
break;
case "write":
// 使用 fs 写入 + refresh确保覆盖成功
const writeFsPath = Editor.assetdb.urlToFspath(scriptPath);
if (!writeFsPath) {
return callback(`Invalid path: ${scriptPath}`);
}
try {
fs.writeFileSync(writeFsPath, content, "utf-8");
Editor.assetdb.refresh(scriptPath, (err) => {
if (err) addLog("warn", `Refresh failed after write: ${err}`);
callback(null, `Script updated at ${scriptPath}`);
});
} catch (e) {
callback(`Failed to write script: ${e.message}`);
}
break;
default:
callback(`Unknown script action: ${action}`);
break;
}
},
// 批处理执行
batchExecute(args, callback) {
const { operations } = args;
const results = [];
let completed = 0;
if (!operations || operations.length === 0) {
return callback("No operations provided");
}
operations.forEach((operation, index) => {
this.handleMcpCall(operation.tool, operation.params, (err, result) => {
results[index] = { tool: operation.tool, error: err, result: result };
completed++;
if (completed === operations.length) {
callback(null, results);
}
});
});
},
// 管理资源
manageAsset(args, callback) {
const { action, path, targetPath, content } = args;
switch (action) {
case "create":
if (Editor.assetdb.exists(path)) {
return callback(`Asset already exists at ${path}`);
}
// 确保父目录存在
const fs = require("fs");
const pathModule = require("path");
const absolutePath = Editor.assetdb.urlToFspath(path);
const dirPath = pathModule.dirname(absolutePath);
if (!fs.existsSync(dirPath)) {
fs.mkdirSync(dirPath, { recursive: true });
}
Editor.assetdb.create(path, content || "", (err) => {
callback(err, err ? null : `Asset created at ${path}`);
});
break;
case "delete":
if (!Editor.assetdb.exists(path)) {
return callback(`Asset not found at ${path}`);
}
Editor.assetdb.delete([path], (err) => {
callback(err, err ? null : `Asset deleted at ${path}`);
});
break;
case "move":
if (!Editor.assetdb.exists(path)) {
return callback(`Asset not found at ${path}`);
}
if (Editor.assetdb.exists(targetPath)) {
return callback(`Target asset already exists at ${targetPath}`);
}
Editor.assetdb.move(path, targetPath, (err) => {
callback(err, err ? null : `Asset moved from ${path} to ${targetPath}`);
});
break;
case "get_info":
try {
if (!Editor.assetdb.exists(path)) {
return callback(`Asset not found: ${path}`);
}
const uuid = Editor.assetdb.urlToUuid(path);
const info = Editor.assetdb.assetInfoByUuid(uuid);
if (info) {
callback(null, info);
} else {
// Fallback if API returns nothing but asset exists
callback(null, { url: path, uuid: uuid, exists: true });
}
} catch (e) {
callback(`Error getting asset info: ${e.message}`);
}
break;
default:
callback(`Unknown asset action: ${action}`);
break;
}
},
// 场景管理
sceneManagement(args, callback) {
const { action, path, targetPath, name } = args;
switch (action) {
case "create":
if (Editor.assetdb.exists(path)) {
return callback(`Scene already exists at ${path}`);
}
// 确保父目录存在
const fs = require("fs");
const pathModule = require("path");
const absolutePath = Editor.assetdb.urlToFspath(path);
const dirPath = pathModule.dirname(absolutePath);
if (!fs.existsSync(dirPath)) {
fs.mkdirSync(dirPath, { recursive: true });
}
Editor.assetdb.create(path, getNewSceneTemplate(), (err) => {
callback(err, err ? null : `Scene created at ${path}`);
});
break;
case "delete":
if (!Editor.assetdb.exists(path)) {
return callback(`Scene not found at ${path}`);
}
Editor.assetdb.delete([path], (err) => {
callback(err, err ? null : `Scene deleted at ${path}`);
});
break;
case "duplicate":
if (!Editor.assetdb.exists(path)) {
return callback(`Scene not found at ${path}`);
}
if (!targetPath) {
return callback(`Target path is required for duplicate operation`);
}
if (Editor.assetdb.exists(targetPath)) {
return callback(`Target scene already exists at ${targetPath}`);
}
// 读取原场景内容
Editor.assetdb.loadAny(path, (err, content) => {
if (err) {
return callback(`Failed to read scene: ${err}`);
}
// 确保目标目录存在
const fs = require("fs");
const pathModule = require("path");
const targetAbsolutePath = Editor.assetdb.urlToFspath(targetPath);
const targetDirPath = pathModule.dirname(targetAbsolutePath);
if (!fs.existsSync(targetDirPath)) {
fs.mkdirSync(targetDirPath, { recursive: true });
}
// 创建复制的场景
Editor.assetdb.create(targetPath, content, (err) => {
callback(err, err ? null : `Scene duplicated from ${path} to ${targetPath}`);
});
});
break;
case "get_info":
if (Editor.assetdb.exists(path)) {
const uuid = Editor.assetdb.urlToUuid(path);
const info = Editor.assetdb.assetInfoByUuid(uuid);
callback(null, info || { url: path, uuid: uuid, exists: true });
} else {
callback(`Scene not found: ${path}`);
}
break;
default:
callback(`Unknown scene action: ${action}`);
break;
}
},
// 预制体管理
prefabManagement(args, callback) {
const { action, path: prefabPath, nodeId, parentId } = args;
switch (action) {
case "create":
if (!nodeId) {
return callback(`Node ID is required for create operation`);
}
if (Editor.assetdb.exists(prefabPath)) {
return callback(`Prefab already exists at ${prefabPath}`);
}
// 确保父目录存在
const absolutePath = Editor.assetdb.urlToFspath(prefabPath);
const dirPath = path.dirname(absolutePath);
if (!fs.existsSync(dirPath)) {
fs.mkdirSync(dirPath, { recursive: true });
}
// 解析目标目录和文件名
// db://assets/folder/PrefabName.prefab -> db://assets/folder, PrefabName
const targetDir = prefabPath.substring(0, prefabPath.lastIndexOf('/'));
const fileName = prefabPath.substring(prefabPath.lastIndexOf('/') + 1);
const prefabName = fileName.replace('.prefab', '');
// 1. 重命名节点以匹配预制体名称
Editor.Ipc.sendToPanel("scene", "scene:set-property", {
id: nodeId,
path: "name",
type: "String",
value: prefabName,
isSubProp: false
});
// 2. 发送创建命令 (参数: [uuids], dirPath)
// 注意: scene:create-prefab 第三个参数必须是 db:// 目录路径
setTimeout(() => {
Editor.Ipc.sendToPanel("scene", "scene:create-prefab", [nodeId], targetDir);
}, 100); // 稍微延迟以确保重命名生效
callback(null, `Command sent: Creating prefab from node ${nodeId} at ${targetDir} as ${prefabName}`);
break;
case "update":
if (!nodeId) {
return callback(`Node ID is required for update operation`);
}
if (!Editor.assetdb.exists(prefabPath)) {
return callback(`Prefab not found at ${prefabPath}`);
}
// 更新预制体
Editor.Ipc.sendToPanel("scene", "scene:update-prefab", nodeId, prefabPath);
callback(null, `Command sent: Updating prefab ${prefabPath} from node ${nodeId}`);
break;
case "instantiate":
if (!Editor.assetdb.exists(prefabPath)) {
return callback(`Prefab not found at ${prefabPath}`);
}
// 实例化预制体
const prefabUuid = Editor.assetdb.urlToUuid(prefabPath);
Editor.Scene.callSceneScript(
"mcp-bridge",
"instantiate-prefab",
{
prefabUuid: prefabUuid,
parentId: parentId,
},
callback,
);
break;
case "get_info":
if (Editor.assetdb.exists(prefabPath)) {
const uuid = Editor.assetdb.urlToUuid(prefabPath);
const info = Editor.assetdb.assetInfoByUuid(uuid);
// 确保返回对象包含 exists: true以满足测试验证
const result = info || { url: prefabPath, uuid: uuid };
result.exists = true;
callback(null, result);
} else {
callback(`Prefab not found: ${prefabPath}`);
}
break;
default:
callback(`Unknown prefab action: ${action}`);
}
},
// 管理编辑器
manageEditor(args, callback) {
const { action, target, properties } = args;
switch (action) {
case "get_selection":
// 获取当前选中的资源或节点
const nodeSelection = Editor.Selection.curSelection("node");
const assetSelection = Editor.Selection.curSelection("asset");
callback(null, {
nodes: nodeSelection,
assets: assetSelection,
});
break;
case "set_selection":
// 设置选中状态
if (target === "node" && properties.nodes) {
Editor.Selection.select("node", properties.nodes);
} else if (target === "asset" && properties.assets) {
Editor.Selection.select("asset", properties.assets);
}
callback(null, "Selection updated");
break;
case "refresh_editor":
// 刷新编辑器
const refreshPath = (properties && properties.path) ? properties.path : 'db://assets/scripts';
Editor.assetdb.refresh(refreshPath, (err) => {
if (err) {
addLog("error", `Refresh failed: ${err}`);
callback(err);
} else {
callback(null, `Editor refreshed: ${refreshPath}`);
}
});
break;
default:
callback("Unknown action");
break;
}
},
// 管理材质
manageMaterial(args, callback) {
const { action, path, properties } = args;
switch (action) {
case "create":
if (Editor.assetdb.exists(path)) {
return callback(`Material already exists at ${path}`);
}
// 确保父目录存在
const fs = require("fs");
const pathModule = require("path");
const absolutePath = Editor.assetdb.urlToFspath(path);
const dirPath = pathModule.dirname(absolutePath);
if (!fs.existsSync(dirPath)) {
fs.mkdirSync(dirPath, { recursive: true });
}
// 创建材质资源
const materialContent = JSON.stringify({
__type__: "cc.Material",
_name: "",
_objFlags: 0,
_native: "",
effects: [
{
technique: 0,
defines: {},
uniforms: properties.uniforms || {},
},
],
});
Editor.assetdb.create(path, materialContent, (err) => {
callback(err, err ? null : `Material created at ${path}`);
});
break;
case "delete":
if (!Editor.assetdb.exists(path)) {
return callback(`Material not found at ${path}`);
}
Editor.assetdb.delete([path], (err) => {
callback(err, err ? null : `Material deleted at ${path}`);
});
break;
case "get_info":
if (Editor.assetdb.exists(path)) {
const uuid = Editor.assetdb.urlToUuid(path);
const info = Editor.assetdb.assetInfoByUuid(uuid);
callback(null, info || { url: path, uuid: uuid, exists: true });
} else {
callback(`Material not found: ${path}`);
}
break;
default:
callback(`Unknown material action: ${action}`);
break;
}
},
// 管理纹理
manageTexture(args, callback) {
const { action, path, properties } = args;
switch (action) {
case "create":
if (Editor.assetdb.exists(path)) {
return callback(`Texture already exists at ${path}`);
}
// 确保父目录存在
const fs = require("fs");
const pathModule = require("path");
const absolutePath = Editor.assetdb.urlToFspath(path);
const dirPath = pathModule.dirname(absolutePath);
if (!fs.existsSync(dirPath)) {
fs.mkdirSync(dirPath, { recursive: true });
}
// 创建纹理资源(简化版,实际需要处理纹理文件)
const textureContent = JSON.stringify({
__type__: "cc.Texture2D",
_name: "",
_objFlags: 0,
_native: properties.native || "",
width: properties.width || 128,
height: properties.height || 128,
});
Editor.assetdb.create(path, textureContent, (err) => {
callback(err, err ? null : `Texture created at ${path}`);
});
break;
case "delete":
if (!Editor.assetdb.exists(path)) {
return callback(`Texture not found at ${path}`);
}
Editor.assetdb.delete([path], (err) => {
callback(err, err ? null : `Texture deleted at ${path}`);
});
break;
case "get_info":
if (Editor.assetdb.exists(path)) {
const uuid = Editor.assetdb.urlToUuid(path);
const info = Editor.assetdb.assetInfoByUuid(uuid);
callback(null, info || { url: path, uuid: uuid, exists: true });
} else {
callback(`Texture not found: ${path}`);
}
break;
default:
callback(`Unknown texture action: ${action}`);
break;
}
},
// 应用文本编辑
applyTextEdits(args, callback) {
const { filePath, edits } = args;
// 1. 获取文件系统路径
const fspath = Editor.assetdb.urlToFspath(filePath);
if (!fspath) {
return callback(`File not found or invalid URL: ${filePath}`);
}
const fs = require("fs");
if (!fs.existsSync(fspath)) {
return callback(`File does not exist: ${fspath}`);
}
try {
// 2. 读取
let updatedContent = fs.readFileSync(fspath, "utf-8");
// 3. 应用编辑
// 必须按倒序应用编辑,否则后续编辑的位置会偏移 (假设edits未排序这里简单处理实际上LSP通常建议客户端倒序应用或计算偏移)
// 这里假设edits已经按照位置排序或者用户负责如果需要严谨应先按 start/position 倒序排序
// 简单做个排序保险:
const sortedEdits = [...edits].sort((a, b) => {
const posA = a.position !== undefined ? a.position : a.start;
const posB = b.position !== undefined ? b.position : b.start;
return posB - posA; // big to small
});
sortedEdits.forEach((edit) => {
switch (edit.type) {
case "insert":
updatedContent =
updatedContent.slice(0, edit.position) +
edit.text +
updatedContent.slice(edit.position);
break;
case "delete":
updatedContent = updatedContent.slice(0, edit.start) + updatedContent.slice(edit.end);
break;
case "replace":
updatedContent =
updatedContent.slice(0, edit.start) + edit.text + updatedContent.slice(edit.end);
break;
}
});
// 4. 写入
fs.writeFileSync(fspath, updatedContent, "utf-8");
// 5. 通知编辑器资源变化 (重要)
Editor.assetdb.refresh(filePath, (err) => {
if (err) addLog("warn", `Refresh failed for ${filePath}: ${err}`);
callback(null, `Text edits applied to ${filePath}`);
});
} catch (err) {
callback(`Action failed: ${err.message}`);
}
},
// 读取控制台
readConsole(args, callback) {
const { limit, type } = args;
let filteredOutput = logBuffer;
if (type) {
filteredOutput = filteredOutput.filter((item) => item.type === type);
}
if (limit) {
filteredOutput = filteredOutput.slice(-limit);
}
callback(null, filteredOutput);
},
executeMenuItem(args, callback) {
const { menuPath } = args;
if (!menuPath) {
return callback("Menu path is required");
}
addLog("info", `Executing Menu Item: ${menuPath}`);
// 尝试通过 IPC 触发菜单 (Cocos 2.x 常用方式)
// 如果是保存场景,直接使用对应的 stash-and-save IPC
if (menuPath === 'File/Save Scene') {
Editor.Ipc.sendToMain("scene:stash-and-save");
} else {
// 通用尝试 (可能不工作,取决于编辑器版本)
// Editor.Ipc.sendToMain('ui:menu-click', menuPath);
// 兜底:仅记录日志,暂不支持通用菜单点击
addLog("warn", "Generic menu execution partial support.");
}
callback(null, `Menu action triggered: ${menuPath}`);
},
// 验证脚本
validateScript(args, callback) {
const { filePath } = args;
// 1. 获取文件系统路径
const fspath = Editor.assetdb.urlToFspath(filePath);
if (!fspath) {
return callback(`File not found or invalid URL: ${filePath}`);
}
// 2. 检查文件是否存在
const fs = require("fs");
if (!fs.existsSync(fspath)) {
return callback(`File does not exist: ${fspath}`);
}
// 3. 读取内容并验证
try {
const content = fs.readFileSync(fspath, "utf-8");
// 对于 JavaScript 脚本,使用 eval 进行简单验证
if (filePath.endsWith(".js")) {
// 包装在函数中以避免变量污染
const wrapper = `(function() { ${content} })`;
try {
new Function(wrapper); // 使用 Function 构造器比 direct eval稍微安全一点点虽在这个场景下差别不大
} catch (syntaxErr) {
return callback(null, { valid: false, message: syntaxErr.message });
}
}
// 对于 TypeScript暂不进行复杂编译检查仅确保文件可读
callback(null, { valid: true, message: "Script syntax is valid" });
} catch (err) {
callback(null, { valid: false, message: `Read Error: ${err.message}` });
}
},
// 暴露给 MCP 或面板的 API 封装
messages: {
"open-test-panel"() {
Editor.Panel.open("mcp-bridge");
},
"toggle-server"(event, port) {
if (serverConfig.active) this.stopServer();
else this.startServer(port);
},
"clear-logs"() {
logBuffer = [];
addLog("info", "Logs cleared");
},
// 修改场景中的节点(需要通过 scene-script
"set-node-property"(event, args) {
addLog("mcp", `Creating node: ${args.name} (${args.type})`);
// 确保第一个参数 'mcp-bridge' 和 package.json 的 name 一致
Editor.Scene.callSceneScript("mcp-bridge", "set-property", args, (err, result) => {
if (err) {
Editor.error("Scene Script Error:", err);
}
if (event && event.reply) {
event.reply(err, result);
}
});
},
"create-node"(event, args) {
addLog("mcp", `Creating node: ${args.name} (${args.type})`);
Editor.Scene.callSceneScript("mcp-bridge", "create-node", args, (err, result) => {
if (err) addLog("error", `CreateNode Failed: ${err}`);
else addLog("success", `Node Created: ${result}`);
event.reply(err, result);
});
},
"get-server-state"(event) {
let profile = this.getProfile();
event.reply(null, {
config: serverConfig,
logs: logBuffer,
autoStart: profile.get("auto-start"), // 返回自动启动状态
});
},
"set-auto-start"(event, value) {
this.getProfile().set("auto-start", value);
this.getProfile().save();
addLog("info", `Auto-start set to: ${value}`);
},
"inspect-apis"() {
addLog("info", "[API Inspector] Starting DEEP inspection...");
// Helper to get function arguments
const getArgs = (func) => {
try {
const str = func.toString();
const match = str.match(/function\s.*?\(([^)]*)\)/) || str.match(/.*?\(([^)]*)\)/);
if (match) {
return match[1].split(",").map(arg => arg.trim()).filter(a => a).join(", ");
}
return `${func.length} args`;
} catch (e) {
return "?";
}
};
// Helper to inspect an object
const inspectObj = (name, obj) => {
if (!obj) return { name, exists: false };
const props = {};
const proto = Object.getPrototypeOf(obj);
// 组合自身属性和原型属性
const allKeys = new Set([...Object.getOwnPropertyNames(obj), ...Object.getOwnPropertyNames(proto || {})]);
allKeys.forEach(key => {
if (key.startsWith("_")) return; // Skip private
try {
const val = obj[key];
if (typeof val === 'function') {
props[key] = `func(${getArgs(val)})`;
} else {
props[key] = typeof val;
}
} catch (e) { }
});
return { name, exists: true, props };
};
// 1. Inspect Standard Objects
const standardObjects = {
"Editor.assetdb": Editor.assetdb,
"Editor.Selection": Editor.Selection,
"Editor.Ipc": Editor.Ipc,
"Editor.Panel": Editor.Panel,
"Editor.Scene": Editor.Scene,
"Editor.Utils": Editor.Utils,
"Editor.remote": Editor.remote
};
const report = {};
Object.keys(standardObjects).forEach(key => {
report[key] = inspectObj(key, standardObjects[key]);
});
// 2. Check Specific Forum APIs
const forumChecklist = [
"Editor.assetdb.queryInfoByUuid",
"Editor.assetdb.assetInfoByUuid",
"Editor.assetdb.move",
"Editor.assetdb.createOrSave",
"Editor.assetdb.delete",
"Editor.assetdb.urlToUuid",
"Editor.assetdb.uuidToUrl",
"Editor.assetdb.fspathToUrl",
"Editor.assetdb.urlToFspath",
"Editor.remote.assetdb.uuidToUrl",
"Editor.Selection.select",
"Editor.Selection.clear",
"Editor.Selection.curSelection",
"Editor.Selection.curGlobalActivate"
];
const checklistResults = {};
forumChecklist.forEach(path => {
const parts = path.split(".");
let curr = global; // In main process, Editor is global
let exists = true;
for (const part of parts) {
if (curr && curr[part]) {
curr = curr[part];
} else {
exists = false;
break;
}
}
checklistResults[path] = exists ? (typeof curr === 'function' ? `Available(${getArgs(curr)})` : "Available") : "Missing";
});
addLog("info", `[API Inspector] Standard Objects:\n${JSON.stringify(report, null, 2)}`);
addLog("info", `[API Inspector] Forum Checklist:\n${JSON.stringify(checklistResults, null, 2)}`);
// 3. Inspect Built-in Package IPCs
const ipcReport = {};
const builtinPackages = ["scene", "builder", "assets"]; // 核心内置包
const fs = require("fs");
builtinPackages.forEach(pkgName => {
try {
const pkgPath = Editor.url(`packages://${pkgName}/package.json`);
if (pkgPath && fs.existsSync(pkgPath)) {
const pkgData = JSON.parse(fs.readFileSync(pkgPath, "utf-8"));
if (pkgData.messages) {
ipcReport[pkgName] = Object.keys(pkgData.messages);
} else {
ipcReport[pkgName] = "No messages defined";
}
} else {
ipcReport[pkgName] = "Package path not found";
}
} catch (e) {
ipcReport[pkgName] = `Error: ${e.message}`;
}
});
addLog("info", `[API Inspector] Built-in IPC Messages:\n${JSON.stringify(ipcReport, null, 2)}`);
},
},
// 全局文件搜索
findInFile(args, callback) {
const { query, extensions, includeSubpackages } = args;
const fs = require('fs');
const path = require('path');
const assetsPath = Editor.assetdb.urlToFspath("db://assets");
const validExtensions = extensions || [".js", ".ts", ".json", ".fire", ".prefab", ".xml", ".txt", ".md"];
const results = [];
const MAX_RESULTS = 500; // 限制返回结果数量,防止溢出
try {
// 递归遍历函数
const walk = (dir) => {
if (results.length >= MAX_RESULTS) return;
const list = fs.readdirSync(dir);
list.forEach((file) => {
if (results.length >= MAX_RESULTS) return;
// 忽略隐藏文件和 node_modules
if (file.startsWith('.') || file === 'node_modules' || file === 'bin' || file === 'local') return;
const filePath = path.join(dir, file);
const stat = fs.statSync(filePath);
if (stat && stat.isDirectory()) {
walk(filePath);
} else {
// 检查后缀
const ext = path.extname(file).toLowerCase();
if (validExtensions.includes(ext)) {
try {
const content = fs.readFileSync(filePath, 'utf8');
// 简单的行匹配
const lines = content.split('\n');
lines.forEach((line, index) => {
if (results.length >= MAX_RESULTS) return;
if (line.includes(query)) {
// 转换为项目相对路径 (db://assets/...)
const relativePath = path.relative(assetsPath, filePath);
// 统一使用 forward slash
const dbPath = "db://assets/" + relativePath.split(path.sep).join('/');
results.push({
filePath: dbPath,
line: index + 1,
content: line.trim()
});
}
});
} catch (e) {
// 读取文件出错,跳过
}
}
}
});
};
walk(assetsPath);
callback(null, results);
} catch (err) {
callback(`Find in file failed: ${err.message}`);
}
},
// 管理撤销/重做
manageUndo(args, callback) {
const { action, description } = args;
try {
switch (action) {
case "undo":
Editor.Ipc.sendToPanel("scene", "scene:undo");
callback(null, "Undo command executed");
break;
case "redo":
Editor.Ipc.sendToPanel("scene", "scene:redo");
callback(null, "Redo command executed");
break;
case "begin_group":
// scene:undo-record [id]
// 这里的 id 好像是可选的,或者用于区分不同的事务
Editor.Ipc.sendToPanel("scene", "scene:undo-record", description || "MCP Action");
callback(null, `Undo group started: ${description || "MCP Action"}`);
break;
case "end_group":
Editor.Ipc.sendToPanel("scene", "scene:undo-commit");
callback(null, "Undo group committed");
break;
case "cancel_group":
Editor.Ipc.sendToPanel("scene", "scene:undo-cancel");
callback(null, "Undo group cancelled");
break;
default:
callback(`Unknown undo action: ${action}`);
}
} catch (err) {
callback(`Undo operation failed: ${err.message}`);
}
},
};