Files
mcp-bridge/main.js

820 lines
23 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");
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);
// 【修改】移除 Editor.log保持编辑器控制台干净
// 仅在非常严重的系统错误时才输出到编辑器
if (type === "error") {
Editor.error(`[MCP] ${message}`); // 如果你完全不想在编辑器看,可以注释掉
}
}
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"],
},
},
];
};
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 }));
}
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,
},
],
};
addLog(err ? "error" : "success", `RES <- [${name}]`);
res.writeHead(200);
res.end(JSON.stringify(response));
});
} catch (e) {
addLog("error", `JSON Parse Error: ${e.message}`);
res.writeHead(400);
res.end(JSON.stringify({ error: "Invalid JSON" }));
}
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);
}
},
// 统一处理逻辑,方便日志记录
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":
Editor.Scene.callSceneScript(
"mcp-bridge",
"set-property",
{
id: args.id,
path: "name",
value: args.newName,
},
callback,
);
break;
case "save_scene":
isSceneBusy = true;
addLog("info", "Preparing to save scene... Waiting for UI sync.");
// 强制延迟保存,防止死锁
setTimeout(() => {
Editor.Ipc.sendToMain("scene:save-scene");
addLog("info", "Executing Safe Save...");
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":
Editor.Scene.callSceneScript("mcp-bridge", "update-node-transform", args, callback);
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;
default:
callback(`Unknown tool: ${name}`);
break;
}
},
// 管理脚本文件
manageScript(args, callback) {
const { action, path, content } = args;
switch (action) {
case "create":
if (Editor.assetdb.exists(path)) {
return callback(`Script 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 || `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 ${path}`);
});
break;
case "delete":
if (!Editor.assetdb.exists(path)) {
return callback(`Script not found at ${path}`);
}
Editor.assetdb.delete([path], (err) => {
callback(err, err ? null : `Script deleted at ${path}`);
});
break;
case "read":
Editor.assetdb.queryInfoByUuid(Editor.assetdb.urlToUuid(path), (err, info) => {
if (err) {
return callback(`Failed to get script info: ${err}`);
}
Editor.assetdb.loadAny(path, (err, content) => {
callback(err, err ? null : content);
});
});
break;
case "write":
Editor.assetdb.create(path, content, (err) => {
callback(err, err ? null : `Script updated at ${path}`);
});
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":
Editor.assetdb.queryInfoByUuid(Editor.assetdb.urlToUuid(path), (err, info) => {
callback(err, err ? null : info);
});
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":
Editor.assetdb.queryInfoByUuid(Editor.assetdb.urlToUuid(path), (err, info) => {
callback(err, err ? null : info);
});
break;
default:
callback(`Unknown scene action: ${action}`);
break;
}
},
// 预制体管理
prefabManagement(args, callback) {
const { action, path, nodeId, parentId } = args;
switch (action) {
case "create":
if (!nodeId) {
return callback(`Node ID is required for create operation`);
}
if (Editor.assetdb.exists(path)) {
return callback(`Prefab 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.Ipc.sendToMain("scene:create-prefab", nodeId, path);
callback(null, `Command sent: Creating prefab from node ${nodeId} at ${path}`);
break;
case "update":
if (!nodeId) {
return callback(`Node ID is required for update operation`);
}
if (!Editor.assetdb.exists(path)) {
return callback(`Prefab not found at ${path}`);
}
// 更新预制体
Editor.Ipc.sendToMain("scene:update-prefab", nodeId, path);
callback(null, `Command sent: Updating prefab ${path} from node ${nodeId}`);
break;
case "instantiate":
if (!Editor.assetdb.exists(path)) {
return callback(`Prefab not found at ${path}`);
}
// 实例化预制体
Editor.Scene.callSceneScript("mcp-bridge", "instantiate-prefab", {
prefabPath: path,
parentId: parentId
}, callback);
break;
case "get_info":
Editor.assetdb.queryInfoByUuid(Editor.assetdb.urlToUuid(path), (err, info) => {
callback(err, err ? null : info);
});
break;
default:
callback(`Unknown prefab action: ${action}`);
break;
}
},
// 暴露给 MCP 或面板的 API 封装
messages: {
"open-test-panel"() {
Editor.Panel.open("mcp-bridge");
},
"get-server-state"(event) {
event.reply(null, { config: serverConfig, logs: logBuffer });
},
"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}`);
},
},
};