diff --git a/main.js b/main.js index 6655e69..a584218 100644 --- a/main.js +++ b/main.js @@ -3,6 +3,29 @@ const http = require("http"); const path = require("path"); +let logBuffer = []; // 存储所有日志 +let mcpServer = null; +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 = ""; @@ -43,31 +66,320 @@ const getNewSceneTemplate = () => { module.exports = { "scene-script": "scene-script.js", load() { - // 插件加载时启动一个微型服务器供 MCP 使用 (默认端口 3000) - this.startMcpServer(); + 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() { - if (this.server) this.server.close(); + this.stopServer(); + }, + startServer(port) { + if (mcpServer) this.stopServer(); + + try { + mcpServer = http.createServer((req, res) => { + // 设置 CORS 方便调试 + res.setHeader("Access-Control-Allow-Origin", "*"); + res.setHeader("Access-Control-Allow-Headers", "Content-Type"); + res.setHeader("Content-Type", "application/json"); + + if (req.method === "OPTIONS") { + res.end(); + return; + } + + let body = ""; + req.on("data", (chunk) => { + body += chunk; + }); + req.on("end", () => { + try { + // 简单的路由处理 + if (req.url === "/list-tools" && req.method === "GET") { + // 1. 返回工具定义 (符合 MCP 规范) + const tools = [ + { + 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"], + }, + }, + ]; + return res.end(JSON.stringify({ tools })); + } + if (req.url === "/call-tool" && req.method === "POST") { + try { + const { name, arguments: args } = JSON.parse(body); + + addLog("mcp", `REQ -> [${name}] ${JSON.stringify(args)}`); + + this.handleMcpCall(name, args, (err, result) => { + // 3. 构建 MCP 标准响应格式 + const response = { + content: [ + { + type: "text", + text: err + ? `Error: ${err}` + : typeof result === "object" + ? JSON.stringify(result, null, 2) + : result, + }, + ], + }; + + // 4. 记录返回日志 + if (err) { + addLog("error", `RES <- [${name}] Failed: ${err}`); + } else { + // 日志里只显示简短的返回值,防止长 JSON(如 hierarchy)刷屏 + const logRes = typeof result === "object" ? "[Object Data]" : result; + addLog("success", `RES <- [${name}] Success: ${logRes}`); + } + res.end(JSON.stringify(response)); + }); + } catch (e) { + addLog("error", `Parse Error: ${e.message}`); + res.end(JSON.stringify({ content: [{ type: "text", text: `Error: ${e.message}` }] })); + } + } else { + res.statusCode = 404; + res.end(JSON.stringify({ error: "Not Found" })); + } + } catch (e) { + res.statusCode = 500; + res.end(JSON.stringify({ error: e.message })); + } + }); + }); + + mcpServer.listen(port, () => { + serverConfig.active = true; + addLog("success", `Server started on port ${port}`); + Editor.Ipc.sendToPanel("mcp-bridge", "mcp-bridge:state-changed", serverConfig); + }); + + mcpServer.on("error", (err) => { + addLog("error", `Server Error: ${err.message}`); + this.stopServer(); + }); + // 启动成功后顺便存一下端口 + 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) { + 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": + Editor.Ipc.sendToMain("scene:save-scene"); + callback(null, "Scene saved successfully"); + 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": + const openUuid = Editor.assetdb.urlToUuid(args.url); + if (openUuid) { + Editor.Ipc.sendToMain("scene:open-by-uuid", openUuid); + callback(null, `Success: Opening scene ${args.url}`); + } else { + callback(`Could not find asset with URL ${args.url}`); + } + break; + + case "create_node": + Editor.Scene.callSceneScript("mcp-bridge", "create-node", args, callback); + break; + + default: + callback(`Unknown tool: ${name}`); + break; + } + }, // 暴露给 MCP 或面板的 API 封装 messages: { "open-test-panel"() { Editor.Panel.open("mcp-bridge"); }, - - // 获取当前选中节点信息 - "get-selected-info"(event) { - let selection = Editor.Selection.curSelection("node"); - if (event) event.reply(null, selection); - return selection; + "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) { - Editor.log("Calling scene script with:", args); // 打印日志确认 main 进程收到了面板的消息 - + 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) { @@ -78,279 +390,27 @@ module.exports = { } }); }, - }, - - // 简易 MCP 桥接服务器 - startMcpServer() { - this.server = http.createServer((req, res) => { - // 设置 CORS 方便调试 - res.setHeader("Access-Control-Allow-Origin", "*"); - res.setHeader("Access-Control-Allow-Headers", "Content-Type"); - res.setHeader("Content-Type", "application/json"); - - if (req.method === "OPTIONS") { - res.end(); - return; - } - - let body = ""; - req.on("data", (chunk) => { - body += chunk; + "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); }); - req.on("end", () => { - try { - // 简单的路由处理 - if (req.url === "/list-tools" && req.method === "GET") { - // 1. 返回工具定义 (符合 MCP 规范) - const tools = [ - { - 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"], - }, - }, - ]; - return res.end(JSON.stringify({ tools })); - } - if (req.url === "/call-tool" && req.method === "POST") { - // 2. 执行工具逻辑 - const { name, arguments: args } = JSON.parse(body); - - if (name === "get_selected_node") { - let ids = Editor.Selection.curSelection("node"); - res.end(JSON.stringify({ content: [{ type: "text", text: JSON.stringify(ids) }] })); - } else if (name === "set_node_name") { - Editor.Scene.callSceneScript( - "mcp-bridge", - "set-property", - { - id: args.id, - path: "name", - value: args.newName, - }, - (err, result) => { - res.end( - JSON.stringify({ - content: [ - { type: "text", text: err ? `Error: ${err}` : `Success: ${result}` }, - ], - }), - ); - }, - ); - } else if (name === "save_scene") { - // 触发编辑器保存指令 - Editor.Ipc.sendToMain("scene:save-scene"); - res.end(JSON.stringify({ content: [{ type: "text", text: "Scene saved successfully" }] })); - } else if (name === "get_scene_hierarchy") { - Editor.Scene.callSceneScript("mcp-bridge", "get-hierarchy", (err, hierarchy) => { - if (err) { - res.end( - JSON.stringify({ - content: [ - { type: "text", text: "Error fetching hierarchy: " + err.message }, - ], - }), - ); - } else { - res.end( - JSON.stringify({ - content: [{ type: "text", text: JSON.stringify(hierarchy, null, 2) }], - }), - ); - } - }); - } else if (name === "update_node_transform") { - Editor.Scene.callSceneScript("mcp-bridge", "update-node-transform", args, (err, result) => { - res.end( - JSON.stringify({ - content: [{ type: "text", text: err ? `Error: ${err}` : result }], - }), - ); - }); - } else if (name === "create_scene") { - const url = `db://assets/${args.sceneName}.fire`; - if (Editor.assetdb.exists(url)) { - return res.end( - JSON.stringify({ - content: [{ type: "text", text: "Error: Scene already exists" }], - }), - ); - } - - // 生成标准场景内容 - const sceneJson = getNewSceneTemplate(); - - Editor.assetdb.create(url, sceneJson, (err, results) => { - if (err) { - res.end( - JSON.stringify({ - content: [{ type: "text", text: "Error creating scene: " + err }], - }), - ); - } else { - res.end( - JSON.stringify({ - content: [{ type: "text", text: `Standard Scene created at ${url}` }], - }), - ); - } - }); - } else if (name === "create_prefab") { - const url = `db://assets/${args.prefabName}.prefab`; - // 2.4.x 创建预制体的 IPC 消息 - Editor.Ipc.sendToMain("scene:create-prefab", args.nodeId, url); - res.end( - JSON.stringify({ - content: [ - { type: "text", text: `Command sent: Creating prefab '${args.prefabName}'` }, - ], - }), - ); - } else if (name === "open_scene") { - const url = args.url; - // 1. 将 db:// 路径转换为 UUID - const uuid = Editor.assetdb.urlToUuid(url); - - if (uuid) { - // 2. 发送核心 IPC 消息给主进程 - // scene:open-by-uuid 是编辑器内置的场景打开逻辑 - Editor.Ipc.sendToMain("scene:open-by-uuid", uuid); - - res.end( - JSON.stringify({ - content: [ - { type: "text", text: `Success: Opening scene ${url} (UUID: ${uuid})` }, - ], - }), - ); - } else { - res.end( - JSON.stringify({ - content: [ - { type: "text", text: `Error: Could not find asset with URL ${url}` }, - ], - }), - ); - } - } else if (name === "create_node") { - // 转发给场景脚本处理 - Editor.Scene.callSceneScript("mcp-bridge", "create-node", args, (err, result) => { - res.end( - JSON.stringify({ - content: [ - { type: "text", text: err ? `Error: ${err}` : `Node created: ${result}` }, - ], - }), - ); - }); - } - } else { - res.statusCode = 404; - res.end(JSON.stringify({ error: "Not Found" })); - } - } catch (e) { - res.statusCode = 500; - res.end(JSON.stringify({ error: e.message })); - } + }, + "get-server-state"(event) { + let profile = this.getProfile(); + event.reply(null, { + config: serverConfig, + logs: logBuffer, + autoStart: profile.get("auto-start"), // 返回自动启动状态 }); - }); + }, - this.server.listen(3456); - Editor.log("MCP Server standard interface listening on http://localhost:3456"); + "set-auto-start"(event, value) { + this.getProfile().set("auto-start", value); + this.getProfile().save(); + addLog("info", `Auto-start set to: ${value}`); + }, }, }; diff --git a/package.json b/package.json index 954238b..be82c4a 100644 --- a/package.json +++ b/package.json @@ -16,5 +16,11 @@ "title": "MCP Test Panel", "width": 400, "height": 300 + }, + "profiles": { + "local": { + "auto-start": false, + "last-port": 3456 + } } } diff --git a/panel/index.html b/panel/index.html index 70a9bbd..37e9a40 100644 --- a/panel/index.html +++ b/panel/index.html @@ -1,17 +1,105 @@ -
- - - - - - +
+
+
+ Port: + + Start +
-
- 获取选中节点 ID - 修改节点名称 -
+ +
+ Auto Start +
-
- Status: Ready -
-
\ No newline at end of file +
+ Clear + Copy All +
+ + +
+
+ + diff --git a/panel/index.js b/panel/index.js index 0c2276d..f662c04 100644 --- a/panel/index.js +++ b/panel/index.js @@ -1,59 +1,89 @@ "use strict"; const fs = require("fs"); -const path = require("path"); Editor.Panel.extend({ - // 读取样式和模板 style: fs.readFileSync(Editor.url("packages://mcp-bridge/panel/index.html"), "utf-8"), template: fs.readFileSync(Editor.url("packages://mcp-bridge/panel/index.html"), "utf-8"), - // 面板渲染成功后的回调 + messages: { + "mcp-bridge:on-log"(event, log) { + this.renderLog(log); + }, + "mcp-bridge:state-changed"(event, config) { + this.updateUI(config.active); + }, + }, + ready() { - // 使用 querySelector 确保能拿到元素,避免依赖可能为 undefined 的 this.$ - const btnGet = this.shadowRoot.querySelector("#btn-get"); - const btnSet = this.shadowRoot.querySelector("#btn-set"); - const nodeIdInput = this.shadowRoot.querySelector("#nodeId"); - const newNameInput = this.shadowRoot.querySelector("#newName"); - const logDiv = this.shadowRoot.querySelector("#log"); + const portInput = this.shadowRoot.querySelector("#portInput"); + const btnToggle = this.shadowRoot.querySelector("#btnToggle"); + const autoStartCheck = this.shadowRoot.querySelector("#autoStartCheck"); + const btnClear = this.shadowRoot.querySelector("#btnClear"); + const btnCopy = this.shadowRoot.querySelector("#btnCopy"); + const logView = this.shadowRoot.querySelector("#logConsole"); - if (!btnGet || !btnSet) { - Editor.error("Failed to find UI elements. Check if IDs in HTML match."); - return; - } - - // 测试获取信息 - btnGet.addEventListener("confirm", () => { - Editor.Ipc.sendToMain("mcp-bridge:get-selected-info", (err, ids) => { - if (ids && ids.length > 0) { - nodeIdInput.value = ids[0]; - logDiv.innerText = "Status: Selected Node " + ids[0]; - } else { - logDiv.innerText = "Status: No node selected"; - } - }); + // 初始化 + Editor.Ipc.sendToMain("mcp-bridge:get-server-state", (err, data) => { + if (data) { + portInput.value = data.config.port; + this.updateUI(data.config.active); + data.logs.forEach((log) => this.renderLog(log)); + } }); - // 测试修改信息 - btnSet.addEventListener("confirm", () => { - let data = { - id: nodeIdInput.value, - path: "name", - value: newNameInput.value, - }; + btnToggle.addEventListener("confirm", () => { + Editor.Ipc.sendToMain("mcp-bridge:toggle-server", parseInt(portInput.value)); + }); - if (!data.id) { - logDiv.innerText = "Error: Please get Node ID first"; - return; + btnClear.addEventListener("confirm", () => { + logView.innerHTML = ""; + Editor.Ipc.sendToMain("mcp-bridge:clear-logs"); + }); + + btnCopy.addEventListener("confirm", () => { + require("electron").clipboard.writeText(logView.innerText); + Editor.success("All logs copied!"); + }); + Editor.Ipc.sendToMain("mcp-bridge:get-server-state", (err, data) => { + if (data) { + portInput.value = data.config.port; + this.updateUI(data.config.active); + + // 设置自动启动复选框状态 + autoStartCheck.value = data.autoStart; + + data.logs.forEach((log) => this.renderLog(log)); } - - Editor.Ipc.sendToMain("mcp-bridge:set-node-property", data, (err, res) => { - if (err) { - logDiv.innerText = "Error: " + err; - } else { - logDiv.innerText = "Success: " + res; - } - }); + }); + autoStartCheck.addEventListener("change", (event) => { + // event.target.value 在 ui-checkbox 中是布尔值 + Editor.Ipc.sendToMain("mcp-bridge:set-auto-start", event.target.value); }); }, + + renderLog(log) { + const logView = this.shadowRoot.querySelector("#logConsole"); + if (!logView) return; + + // 记录当前滚动条位置 + const isAtBottom = logView.scrollHeight - logView.scrollTop <= logView.clientHeight + 50; + + const el = document.createElement("div"); + el.className = `log-item ${log.type}`; + el.innerHTML = `${log.time}${log.content}`; + logView.appendChild(el); + + // 如果用户正在向上翻看,不自动滚动;否则自动滚到底部 + if (isAtBottom) { + logView.scrollTop = logView.scrollHeight; + } + }, + + updateUI(isActive) { + const btnToggle = this.shadowRoot.querySelector("#btnToggle"); + if (!btnToggle) return; + btnToggle.innerText = isActive ? "Stop" : "Start"; + btnToggle.style.backgroundColor = isActive ? "#aa4444" : "#44aa44"; + }, });