```
feat(mcp-bridge): 添加 MCP 桥接插件实现 Cocos Creator 与外部工具集成 - 新增 main.js 实现 MCP 服务器,提供 HTTP 接口供外部工具调用 - 实现 5 个核心工具接口:获取选中节点、修改节点名称、保存场景、 获取场景层级结构、更新节点变换属性 - 添加 panel 面板用于测试 MCP 功能,包含节点 ID 获取和名称修改功能 - 实现场景脚本 scene-script.js 处理节点属性修改和层级数据导出 - 配置 package.json 定义插件入口文件和菜单项 - 支持跨域请求便于调试,返回符合 MCP 规范的工具定义格式 ```
This commit is contained in:
186
main.js
Normal file
186
main.js
Normal file
@@ -0,0 +1,186 @@
|
|||||||
|
"use strict";
|
||||||
|
|
||||||
|
const http = require("http");
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
"scene-script": "scene-script.js",
|
||||||
|
load() {
|
||||||
|
// 插件加载时启动一个微型服务器供 MCP 使用 (默认端口 3000)
|
||||||
|
this.startMcpServer();
|
||||||
|
},
|
||||||
|
|
||||||
|
unload() {
|
||||||
|
if (this.server) this.server.close();
|
||||||
|
},
|
||||||
|
|
||||||
|
// 暴露给 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;
|
||||||
|
},
|
||||||
|
|
||||||
|
// 修改场景中的节点(需要通过 scene-script)
|
||||||
|
"set-node-property"(event, args) {
|
||||||
|
Editor.log("Calling scene script with:", args); // 打印日志确认 main 进程收到了面板的消息
|
||||||
|
|
||||||
|
// 确保第一个参数 '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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// 简易 MCP 桥接服务器
|
||||||
|
startMcpServer() {
|
||||||
|
const http = require("http");
|
||||||
|
|
||||||
|
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;
|
||||||
|
});
|
||||||
|
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"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
res.end(JSON.stringify({ tools }));
|
||||||
|
} else 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 {
|
||||||
|
res.statusCode = 404;
|
||||||
|
res.end(JSON.stringify({ error: "Not Found" }));
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
res.statusCode = 500;
|
||||||
|
res.end(JSON.stringify({ error: e.message }));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
this.server.listen(3456);
|
||||||
|
Editor.log("MCP Server standard interface listening on http://localhost:3456");
|
||||||
|
},
|
||||||
|
};
|
||||||
20
package.json
Normal file
20
package.json
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"name": "mcp-bridge",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Cocos Creator MCP Bridge",
|
||||||
|
"author": "User",
|
||||||
|
"main": "main.js",
|
||||||
|
"scene-script": "scene-script.js",
|
||||||
|
"main-menu": {
|
||||||
|
"Packages/MCP Bridge/Open Test Panel": {
|
||||||
|
"message": "mcp-bridge:open-test-panel"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"panel": {
|
||||||
|
"main": "panel/index.js",
|
||||||
|
"type": "dockable",
|
||||||
|
"title": "MCP Test Panel",
|
||||||
|
"width": 400,
|
||||||
|
"height": 300
|
||||||
|
}
|
||||||
|
}
|
||||||
17
panel/index.html
Normal file
17
panel/index.html
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
<div style="padding: 10px;">
|
||||||
|
<ui-prop name="Node ID">
|
||||||
|
<ui-input id="nodeId" placeholder="Click 'Get' to fetch ID"></ui-input>
|
||||||
|
</ui-prop>
|
||||||
|
<ui-prop name="New Name">
|
||||||
|
<ui-input id="newName" placeholder="Enter new name"></ui-input>
|
||||||
|
</ui-prop>
|
||||||
|
|
||||||
|
<div style="margin-top: 10px; display: flex; flex-direction: column; gap: 5px;">
|
||||||
|
<ui-button id="btn-get" class="green">获取选中节点 ID</ui-button>
|
||||||
|
<ui-button id="btn-set" class="blue">修改节点名称</ui-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="log" style="margin-top: 15px; font-size: 12px; color: #66ccff; border-top: 1px solid #555; padding-top: 5px;">
|
||||||
|
Status: Ready
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
59
panel/index.js
Normal file
59
panel/index.js
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
"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"),
|
||||||
|
|
||||||
|
// 面板渲染成功后的回调
|
||||||
|
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");
|
||||||
|
|
||||||
|
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";
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// 测试修改信息
|
||||||
|
btnSet.addEventListener("confirm", () => {
|
||||||
|
let data = {
|
||||||
|
id: nodeIdInput.value,
|
||||||
|
path: "name",
|
||||||
|
value: newNameInput.value,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!data.id) {
|
||||||
|
logDiv.innerText = "Error: Please get Node ID first";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Editor.Ipc.sendToMain("mcp-bridge:set-node-property", data, (err, res) => {
|
||||||
|
if (err) {
|
||||||
|
logDiv.innerText = "Error: " + err;
|
||||||
|
} else {
|
||||||
|
logDiv.innerText = "Success: " + res;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
91
scene-script.js
Normal file
91
scene-script.js
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
"use strict";
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
"set-property": function (event, args) {
|
||||||
|
const { id, path, value } = args;
|
||||||
|
|
||||||
|
// 1. 获取节点
|
||||||
|
let node = cc.engine.getInstanceById(id);
|
||||||
|
|
||||||
|
if (node) {
|
||||||
|
// 2. 修改属性
|
||||||
|
if (path === "name") {
|
||||||
|
node.name = value;
|
||||||
|
} else {
|
||||||
|
node[path] = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 【解决报错的关键】告诉编辑器场景变脏了(需要保存)
|
||||||
|
// 在场景进程中,我们发送 IPC 给主进程
|
||||||
|
Editor.Ipc.sendToMain("scene:dirty");
|
||||||
|
|
||||||
|
// 4. 【额外补丁】通知层级管理器(Hierarchy)同步更新节点名称
|
||||||
|
// 否则你修改了名字,层级管理器可能还是显示旧名字
|
||||||
|
Editor.Ipc.sendToAll("scene:node-changed", {
|
||||||
|
uuid: id,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (event.reply) {
|
||||||
|
event.reply(null, `Node ${id} updated to ${value}`);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (event.reply) {
|
||||||
|
event.reply(new Error("Scene Script: Node not found " + id));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"get-hierarchy": function (event) {
|
||||||
|
const scene = cc.director.getScene();
|
||||||
|
|
||||||
|
function dumpNodes(node) {
|
||||||
|
// 【优化】跳过编辑器内部的私有节点,减少数据量
|
||||||
|
if (node.name.startsWith("Editor Scene") || node.name === "gizmoRoot") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
let nodeData = {
|
||||||
|
name: node.name,
|
||||||
|
uuid: node.uuid,
|
||||||
|
active: node.active,
|
||||||
|
position: { x: Math.round(node.x), y: Math.round(node.y) },
|
||||||
|
scale: { x: node.scaleX, y: node.scaleY },
|
||||||
|
size: { width: node.width, height: node.height },
|
||||||
|
// 记录组件类型,让 AI 知道这是个什么节点
|
||||||
|
components: node._components.map((c) => c.__typename),
|
||||||
|
children: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
for (let i = 0; i < node.childrenCount; i++) {
|
||||||
|
let childData = dumpNodes(node.children[i]);
|
||||||
|
if (childData) nodeData.children.push(childData);
|
||||||
|
}
|
||||||
|
return nodeData;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hierarchy = dumpNodes(scene);
|
||||||
|
if (event.reply) event.reply(null, hierarchy);
|
||||||
|
},
|
||||||
|
|
||||||
|
"update-node-transform": function (event, args) {
|
||||||
|
const { id, x, y, scaleX, scaleY, color } = args;
|
||||||
|
let node = cc.engine.getInstanceById(id);
|
||||||
|
|
||||||
|
if (node) {
|
||||||
|
if (x !== undefined) node.x = x;
|
||||||
|
if (y !== undefined) node.y = y;
|
||||||
|
if (scaleX !== undefined) node.scaleX = scaleX;
|
||||||
|
if (scaleY !== undefined) node.scaleY = scaleY;
|
||||||
|
if (color) {
|
||||||
|
// color 格式如 "#FF0000"
|
||||||
|
node.color = new cc.Color().fromHEX(color);
|
||||||
|
}
|
||||||
|
|
||||||
|
Editor.Ipc.sendToMain("scene:dirty");
|
||||||
|
Editor.Ipc.sendToAll("scene:node-changed", { uuid: id });
|
||||||
|
|
||||||
|
if (event.reply) event.reply(null, "Transform updated");
|
||||||
|
} else {
|
||||||
|
if (event.reply) event.reply(new Error("Node not found"));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user