mirror of
https://github.com/HappyLifeOk/cc-3-8-x-mcp.git
synced 2026-06-10 09:46:47 +00:00
Initial public release: cc-3-8-x-mcp
Cocos Creator 3.8.x MCP bridge extension with a built-in offline CLI. Components: - Editor extension: in-process MCP server exposing scene / asset-db / preview / local / editor-process-control tools - stdio router: aggregates multiple editor instances on one machine, with shortName dedup - offline CLI (cocos-mcp-cli): headless prefab read/write + a wrapper around the Cocos CLI build Pure Node.js, zero third-party dependencies. Licensed under Apache-2.0.
This commit is contained in:
Executable
+344
@@ -0,0 +1,344 @@
|
||||
#!/usr/bin/env node
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* cocos-mcp-router
|
||||
*
|
||||
* stdio MCP server,聚合所有活跃的 Cocos 编辑器扩展,给客户端暴露统一的 tool 列表。
|
||||
*
|
||||
* 发现机制:
|
||||
* 扫 ~/.cocos-mcp/editors/*.json,每个文件代表一个活跃扩展实例
|
||||
* 过滤 mtime > 120s 的(视为已死)
|
||||
* 对每个活跃编辑器调 HTTP POST /mcp initialize + tools/list,拿到其 tool 清单
|
||||
*
|
||||
* 命名:
|
||||
* tool 名前缀化:<projectShortName>__<originalName>
|
||||
* 例:my-project__scene_query_node_tree
|
||||
*
|
||||
* 转发:
|
||||
* tools/call 收到前缀名 → 拆出 projectShortName → 查 editor URL → HTTP 转发
|
||||
*
|
||||
* 客户端接入:
|
||||
* claude mcp add cocos -- node /path/to/forest/extensions/cc-3-8-x-mcp/router/bin.js
|
||||
*/
|
||||
|
||||
var fs = require('fs');
|
||||
var path = require('path');
|
||||
var os = require('os');
|
||||
var http = require('http');
|
||||
|
||||
var offlineTools = require('./src/offline-tools.js');
|
||||
var editorControl = require('./src/editor-control.js');
|
||||
|
||||
var REGISTRY_DIR = path.join(os.homedir(), '.cocos-mcp', 'editors');
|
||||
var STALE_MS = 120 * 1000; // 2 分钟没心跳视为死
|
||||
var DISCOVERY_INTERVAL_MS = 15 * 1000;
|
||||
var PROTOCOL_VERSION = '2024-11-05';
|
||||
var ROUTER_INFO = { name: 'cocos-mcp-router', version: '0.1.0' };
|
||||
|
||||
function logErr() {
|
||||
// router 走 stdio,不能往 stdout 写非 JSON-RPC 内容,日志只能走 stderr
|
||||
var args = Array.prototype.slice.call(arguments);
|
||||
process.stderr.write('[cocos-mcp-router] ' + args.join(' ') + '\n');
|
||||
}
|
||||
|
||||
// ── 发现活跃编辑器 ──
|
||||
|
||||
/** @type {Map<string, {shortName, url, pid, tools: Array, lastProbed: number}>} */
|
||||
var editors = new Map();
|
||||
|
||||
function scanRegistry() {
|
||||
var entries = [];
|
||||
try {
|
||||
if (!fs.existsSync(REGISTRY_DIR)) return entries;
|
||||
var files = fs.readdirSync(REGISTRY_DIR);
|
||||
var now = Date.now();
|
||||
files.forEach(function (name) {
|
||||
if (!name.endsWith('.json')) return;
|
||||
var full = path.join(REGISTRY_DIR, name);
|
||||
try {
|
||||
var st = fs.statSync(full);
|
||||
if (now - st.mtimeMs > STALE_MS) {
|
||||
// stale:编辑器已退出/崩溃超 STALE_MS 未更新心跳。直接删文件而非仅跳过——
|
||||
// 崩溃/强杀不会调 removeRegistry,旧实现只 return 不删 → 死文件无限堆积(曾攒 1100+)。
|
||||
try { fs.unlinkSync(full); } catch (e) { /* ignore */ }
|
||||
return;
|
||||
}
|
||||
var info = JSON.parse(fs.readFileSync(full, 'utf-8'));
|
||||
if (!info || !info.url) return;
|
||||
entries.push(info);
|
||||
} catch (e) { /* ignore */ }
|
||||
});
|
||||
} catch (e) { logErr('scanRegistry:', e.message); }
|
||||
return entries;
|
||||
}
|
||||
|
||||
function httpJsonRpc(targetUrl, body) {
|
||||
return new Promise(function (resolve, reject) {
|
||||
try {
|
||||
var u = new URL(targetUrl);
|
||||
var data = JSON.stringify(body);
|
||||
var req = http.request({
|
||||
hostname: u.hostname,
|
||||
port: u.port,
|
||||
path: u.pathname,
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(data) },
|
||||
timeout: 8000,
|
||||
}, function (res) {
|
||||
var chunks = [];
|
||||
res.on('data', function (c) { chunks.push(c); });
|
||||
res.on('end', function () {
|
||||
var raw = Buffer.concat(chunks).toString('utf-8');
|
||||
try { resolve(JSON.parse(raw)); }
|
||||
catch (e) { reject(new Error('invalid json from ' + targetUrl + ': ' + raw.slice(0, 120))); }
|
||||
});
|
||||
});
|
||||
req.on('error', reject);
|
||||
req.on('timeout', function () { req.destroy(new Error('timeout')); });
|
||||
req.write(data);
|
||||
req.end();
|
||||
} catch (e) { reject(e); }
|
||||
});
|
||||
}
|
||||
|
||||
async function probeEditor(info) {
|
||||
try {
|
||||
var initRes = await httpJsonRpc(info.url, {
|
||||
jsonrpc: '2.0', id: 1, method: 'initialize', params: {
|
||||
protocolVersion: PROTOCOL_VERSION,
|
||||
clientInfo: { name: 'cocos-mcp-router', version: ROUTER_INFO.version },
|
||||
capabilities: {},
|
||||
},
|
||||
});
|
||||
if (initRes.error) throw new Error(initRes.error.message);
|
||||
var listRes = await httpJsonRpc(info.url, { jsonrpc: '2.0', id: 2, method: 'tools/list' });
|
||||
if (listRes.error) throw new Error(listRes.error.message);
|
||||
return listRes.result.tools || [];
|
||||
} catch (e) {
|
||||
logErr('probe failed', info.projectShortName, info.url, e.message);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** 去掉 shortName 里的非法字符,MCP tool 名只允许 [a-zA-Z0-9_-] */
|
||||
function sanitizeShortName(name) {
|
||||
return String(name || 'unknown').replace(/[^a-zA-Z0-9_-]/g, '_');
|
||||
}
|
||||
|
||||
async function discover() {
|
||||
var entries = scanRegistry();
|
||||
var seen = new Set();
|
||||
for (var i = 0; i < entries.length; i++) {
|
||||
var info = entries[i];
|
||||
var key = info.url;
|
||||
seen.add(key);
|
||||
if (editors.has(key)) continue; // 已知,不重复 probe
|
||||
var tools = await probeEditor(info);
|
||||
if (tools == null) continue;
|
||||
editors.set(key, {
|
||||
baseShortName: sanitizeShortName(info.projectShortName),
|
||||
shortName: sanitizeShortName(info.projectShortName), // dedupeShortNames 会按冲突重设
|
||||
projectPath: info.projectPath,
|
||||
pid: info.pid,
|
||||
url: info.url,
|
||||
tools: tools,
|
||||
lastProbed: Date.now(),
|
||||
});
|
||||
logErr('discovered editor', info.projectShortName, 'pid=' + info.pid, info.url, tools.length + ' tools');
|
||||
}
|
||||
// 清理已消失的
|
||||
for (var key2 of Array.from(editors.keys())) {
|
||||
if (!seen.has(key2)) {
|
||||
var old = editors.get(key2);
|
||||
logErr('lost editor', old.shortName, old.url);
|
||||
editors.delete(key2);
|
||||
}
|
||||
}
|
||||
// shortName 撞名去重(多编辑器 projectShortName 相同时,否则 tool 前缀冲突会把请求路由到错的编辑器)
|
||||
dedupeShortNames();
|
||||
}
|
||||
|
||||
/**
|
||||
* shortName 撞名去重:多个编辑器 projectShortName 算出来相同时(如某仓库下 my-app/client 和
|
||||
* my-app/server 两个项目都被 getProjectShortName 算成 my-app),
|
||||
* tool 前缀 `<shortName>__xxx` 冲突 → findEditorByPrefixedTool 只命中第一个 → 请求串到错的编辑器。
|
||||
* 冲突的用 projectPath 末段(client / server)加后缀区分,单实例保持原名不变。
|
||||
*/
|
||||
function dedupeShortNames() {
|
||||
var counts = {};
|
||||
for (var ed of editors.values()) {
|
||||
counts[ed.baseShortName] = (counts[ed.baseShortName] || 0) + 1;
|
||||
}
|
||||
var used = {};
|
||||
for (var ed2 of editors.values()) {
|
||||
if (counts[ed2.baseShortName] > 1) {
|
||||
var suffix = sanitizeShortName(path.basename(ed2.projectPath || 'unknown'));
|
||||
var name = ed2.baseShortName + '-' + suffix;
|
||||
// 极端情况末段也相同,再加序号兜底
|
||||
var n = 2;
|
||||
while (used[name] && used[name] !== ed2.url) { name = ed2.baseShortName + '-' + suffix + '-' + n; n++; }
|
||||
ed2.shortName = name;
|
||||
used[name] = ed2.url;
|
||||
} else {
|
||||
ed2.shortName = ed2.baseShortName;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function buildAggregatedToolList() {
|
||||
var out = [];
|
||||
for (var ed of editors.values()) {
|
||||
ed.tools.forEach(function (t) {
|
||||
out.push({
|
||||
name: ed.shortName + '__' + t.name,
|
||||
description: '[' + ed.shortName + '] ' + (t.description || ''),
|
||||
inputSchema: t.inputSchema || { type: 'object', properties: {} },
|
||||
});
|
||||
});
|
||||
}
|
||||
// router 自身的 meta tool
|
||||
out.push({
|
||||
name: 'router_list_editors',
|
||||
description: '列出当前 router 发现的所有活跃 Cocos 编辑器(shortName / pid / url / tool 数)',
|
||||
inputSchema: { type: 'object', properties: {} },
|
||||
});
|
||||
// offline prefab tools(不需要编辑器运行)
|
||||
offlineTools.OFFLINE_TOOLS.forEach(function (t) { out.push(t); });
|
||||
// 编辑器进程管理 tools(spawn/kill/restart/wait_ready,不需要编辑器运行)
|
||||
editorControl.EDITOR_TOOLS.forEach(function (t) { out.push(t); });
|
||||
return out;
|
||||
}
|
||||
|
||||
function findEditorByPrefixedTool(prefixedName) {
|
||||
for (var ed of editors.values()) {
|
||||
var pfx = ed.shortName + '__';
|
||||
if (prefixedName.indexOf(pfx) === 0) {
|
||||
return { editor: ed, originalName: prefixedName.substring(pfx.length) };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ── stdio JSON-RPC ──
|
||||
|
||||
var stdinBuf = '';
|
||||
process.stdin.setEncoding('utf-8');
|
||||
process.stdin.on('data', function (chunk) {
|
||||
stdinBuf += chunk;
|
||||
// MCP stdio 协议:按行切分 JSON-RPC 消息(每条独立 JSON)
|
||||
var lines = stdinBuf.split('\n');
|
||||
stdinBuf = lines.pop();
|
||||
lines.forEach(function (line) {
|
||||
line = line.trim();
|
||||
if (!line) return;
|
||||
var msg;
|
||||
try { msg = JSON.parse(line); }
|
||||
catch (e) { logErr('parse error:', line.slice(0, 120)); return; }
|
||||
handleMessage(msg);
|
||||
});
|
||||
});
|
||||
|
||||
function send(obj) {
|
||||
if (obj == null) return;
|
||||
process.stdout.write(JSON.stringify(obj) + '\n');
|
||||
}
|
||||
|
||||
async function handleMessage(msg) {
|
||||
if (msg.jsonrpc !== '2.0') return;
|
||||
var id = msg.id;
|
||||
var method = msg.method;
|
||||
var params = msg.params || {};
|
||||
|
||||
try {
|
||||
var result;
|
||||
switch (method) {
|
||||
case 'initialize':
|
||||
await discover();
|
||||
result = {
|
||||
protocolVersion: PROTOCOL_VERSION,
|
||||
serverInfo: ROUTER_INFO,
|
||||
capabilities: {
|
||||
tools: { listChanged: true },
|
||||
logging: {},
|
||||
},
|
||||
};
|
||||
break;
|
||||
case 'initialized':
|
||||
case 'notifications/initialized':
|
||||
if (id == null) return;
|
||||
result = {};
|
||||
break;
|
||||
case 'ping':
|
||||
result = {};
|
||||
break;
|
||||
case 'tools/list':
|
||||
await discover();
|
||||
result = { tools: buildAggregatedToolList() };
|
||||
break;
|
||||
case 'tools/call':
|
||||
result = await handleToolCall(params.name, params.arguments || {});
|
||||
break;
|
||||
default:
|
||||
throw Object.assign(new Error('method not found: ' + method), { code: -32601 });
|
||||
}
|
||||
if (id == null) return;
|
||||
send({ jsonrpc: '2.0', id: id, result: result });
|
||||
} catch (e) {
|
||||
if (id == null) return;
|
||||
send({ jsonrpc: '2.0', id: id, error: { code: e.code || -32603, message: e.message || 'internal error' } });
|
||||
}
|
||||
}
|
||||
|
||||
async function handleToolCall(name, args) {
|
||||
// Router 自身的 meta tool
|
||||
if (name === 'router_list_editors') {
|
||||
await discover();
|
||||
var list = Array.from(editors.values()).map(function (ed) {
|
||||
return { shortName: ed.shortName, pid: ed.pid, url: ed.url, projectPath: ed.projectPath, toolCount: ed.tools.length };
|
||||
});
|
||||
return { content: [{ type: 'text', text: JSON.stringify(list, null, 2) }] };
|
||||
}
|
||||
|
||||
// Offline prefab tools(不需要编辑器运行,同进程调用 cli)
|
||||
if (offlineTools.isOfflineTool(name)) {
|
||||
return await offlineTools.handleOfflineToolCall(name, args);
|
||||
}
|
||||
|
||||
// 编辑器进程管理 tools(spawn/kill/restart/wait_ready,router 本地执行,不走转发)
|
||||
if (editorControl.isEditorTool(name)) {
|
||||
return await editorControl.handleEditorToolCall(name, args);
|
||||
}
|
||||
|
||||
var hit = findEditorByPrefixedTool(name);
|
||||
if (!hit) {
|
||||
// 可能是新增编辑器,re-discover 再试一次
|
||||
await discover();
|
||||
hit = findEditorByPrefixedTool(name);
|
||||
}
|
||||
if (!hit) {
|
||||
return { content: [{ type: 'text', text: 'unknown tool: ' + name + '\n可用编辑器: ' + Array.from(editors.values()).map(function(e){return e.shortName;}).join(', ') }], isError: true };
|
||||
}
|
||||
|
||||
try {
|
||||
var forward = await httpJsonRpc(hit.editor.url, {
|
||||
jsonrpc: '2.0', id: Date.now(), method: 'tools/call',
|
||||
params: { name: hit.originalName, arguments: args },
|
||||
});
|
||||
if (forward.error) {
|
||||
return { content: [{ type: 'text', text: 'editor error: ' + forward.error.message }], isError: true };
|
||||
}
|
||||
return forward.result;
|
||||
} catch (e) {
|
||||
// 编辑器可能关闭了,移除缓存
|
||||
editors.delete(hit.editor.url);
|
||||
return { content: [{ type: 'text', text: 'forward failed: ' + e.message }], isError: true };
|
||||
}
|
||||
}
|
||||
|
||||
// ── 周期性重扫 ──
|
||||
setInterval(function () { discover().catch(function () {}); }, DISCOVERY_INTERVAL_MS);
|
||||
|
||||
// 启动首次发现
|
||||
discover().catch(function (e) { logErr('initial discover failed', e.message); });
|
||||
logErr('cocos-mcp-router started (stdio)');
|
||||
@@ -0,0 +1,562 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* router/src/editor-control.js
|
||||
*
|
||||
* Router 级编辑器进程管理 tool。spawn / kill / wait_ready / restart Cocos 编辑器进程。
|
||||
*
|
||||
* 为什么挂在 router(而不是编辑器内 server):
|
||||
* 编辑器内的 MCP server 寄生在编辑器进程里,kill 编辑器 = kill server 自己,自杀后没法
|
||||
* 再把自己拉起来。router 是进程外的常驻 stdio 进程,编辑器死了它还活着,所以「关 / 重启 /
|
||||
* 等就绪」这类要跨越编辑器进程生死的能力只能放这里,跟 offline prefab tools 同类,不走转发。
|
||||
*
|
||||
* 定位机制:直接读注册目录 ~/.cocos-mcp/editors/<pid>.json(与 bin.js scanRegistry 同源),
|
||||
* 不依赖 bin.js 的 editors Map —— 因为要管理「还没就绪」和「已被 kill」的实例,那些不在 Map 里。
|
||||
*
|
||||
* [editor] tool 命名不加 shortName 前缀(router 全局工具)。
|
||||
*
|
||||
* 仅支持 macOS(execPath 解析按 /Applications/Cocos/Creator/<version>/ 规律)。
|
||||
*/
|
||||
|
||||
var fs = require('fs');
|
||||
var path = require('path');
|
||||
var os = require('os');
|
||||
var http = require('http');
|
||||
var cp = require('child_process');
|
||||
|
||||
var REGISTRY_DIR = path.join(os.homedir(), '.cocos-mcp', 'editors');
|
||||
var STALE_MS = 120 * 1000; // 与 bin.js 对齐:2 分钟没心跳视为死
|
||||
var PROTOCOL_VERSION = '2024-11-05';
|
||||
|
||||
// ── 通用小工具 ──────────────────────────────────────────────────
|
||||
|
||||
function sleep(ms) { return new Promise(function (r) { setTimeout(r, ms); }); }
|
||||
|
||||
/** MCP tool 名只允许 [a-zA-Z0-9_-],与 bin.js sanitizeShortName 保持一致 */
|
||||
function sanitize(name) {
|
||||
return String(name || 'unknown').replace(/[^a-zA-Z0-9_-]/g, '_');
|
||||
}
|
||||
|
||||
function jsonContent(obj, isError) {
|
||||
var r = { content: [{ type: 'text', text: JSON.stringify(obj, null, 2) }] };
|
||||
if (isError) r.isError = true;
|
||||
return r;
|
||||
}
|
||||
|
||||
/** 进程是否存活:kill(pid, 0) 不抛 = 活;EPERM = 存在但无权限(仍算活) */
|
||||
function isAlive(pid) {
|
||||
if (!pid) return false;
|
||||
try { process.kill(pid, 0); return true; }
|
||||
catch (e) { return e.code === 'EPERM'; }
|
||||
}
|
||||
|
||||
// ── 注册表读取 ──────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* 读注册目录全部 entry,附带 stale 标记和 mtime。
|
||||
* 不删 stale 文件(删由 bin.js scanRegistry 负责,这里只读,避免和 bin.js 抢删竞态)。
|
||||
*/
|
||||
function readRegistryEntries() {
|
||||
var out = [];
|
||||
try {
|
||||
if (!fs.existsSync(REGISTRY_DIR)) return out;
|
||||
var now = Date.now();
|
||||
fs.readdirSync(REGISTRY_DIR).forEach(function (name) {
|
||||
if (!name.endsWith('.json')) return;
|
||||
var full = path.join(REGISTRY_DIR, name);
|
||||
try {
|
||||
var st = fs.statSync(full);
|
||||
var info = JSON.parse(fs.readFileSync(full, 'utf-8'));
|
||||
if (!info) return;
|
||||
info.stale = (now - st.mtimeMs > STALE_MS);
|
||||
info.mtimeMs = st.mtimeMs;
|
||||
out.push(info);
|
||||
} catch (e) { /* 单个坏文件跳过 */ }
|
||||
});
|
||||
} catch (e) { /* ignore */ }
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* 真正可用的编辑器实例。除 stale / url 外,必须 isAlive(pid) —— 关键:
|
||||
* 进程崩溃 / 被重启但没走优雅退出时,注册文件会残留到 120s stale 才被清,这段窗口内
|
||||
* 仅按 mtime 会把「已死实例」误判为活跃,污染 resolveTarget 的多实例判断(曾误拦无参 restart)。
|
||||
*/
|
||||
function activeEditors() {
|
||||
return readRegistryEntries().filter(function (e) { return !e.stale && e.url && isAlive(e.pid); });
|
||||
}
|
||||
|
||||
function removeRegistryFile(pid) {
|
||||
try {
|
||||
var f = path.join(REGISTRY_DIR, pid + '.json');
|
||||
if (fs.existsSync(f)) fs.unlinkSync(f);
|
||||
} catch (e) { /* ignore */ }
|
||||
}
|
||||
|
||||
// ── 目标解析 ────────────────────────────────────────────────────
|
||||
|
||||
/** 把活跃 entry 列成简短描述,给报错用 */
|
||||
function describeActive() {
|
||||
var list = activeEditors().map(function (e) {
|
||||
return sanitize(e.projectShortName) + '(pid=' + e.pid + ')';
|
||||
});
|
||||
return list.length ? list.join(', ') : '无';
|
||||
}
|
||||
|
||||
/**
|
||||
* 在「活跃」实例里按 shortName / projectPath / pid 定位唯一目标。
|
||||
* 无任何指定且只有一个活跃实例时默认它;零个或多个都报错要求显式指定。
|
||||
* 用于 kill / restart —— 它们都作用于「当前还活着的编辑器」。
|
||||
*/
|
||||
function resolveTarget(args) {
|
||||
args = args || {};
|
||||
var entries = activeEditors();
|
||||
|
||||
if (args.pid) {
|
||||
var byPid = entries.filter(function (e) { return e.pid === args.pid; });
|
||||
if (byPid.length) return byPid[0];
|
||||
throw new Error('editor-control: 没有 pid=' + args.pid + ' 的活跃编辑器。当前活跃:' + describeActive());
|
||||
}
|
||||
if (args.projectPath) {
|
||||
var byPath = entries.filter(function (e) { return e.projectPath === args.projectPath; });
|
||||
if (byPath.length) return byPath[0];
|
||||
throw new Error('editor-control: 没有 projectPath=' + args.projectPath + ' 的活跃编辑器。当前活跃:' + describeActive());
|
||||
}
|
||||
if (args.shortName) {
|
||||
var want = sanitize(args.shortName);
|
||||
var byName = entries.filter(function (e) { return sanitize(e.projectShortName) === want; });
|
||||
if (byName.length) return byName[0];
|
||||
throw new Error('editor-control: 没有 shortName=' + args.shortName + ' 的活跃编辑器。当前活跃:' + describeActive());
|
||||
}
|
||||
// 无指定
|
||||
if (entries.length === 1) return entries[0];
|
||||
if (entries.length === 0) {
|
||||
throw new Error('editor-control: 没有活跃的 Cocos 编辑器。若编辑器未运行,请用 editor_restart 并显式传 projectPath(无法从空注册表推断项目路径)。');
|
||||
}
|
||||
throw new Error('editor-control: 有多个活跃编辑器,请用 shortName / projectPath / pid 指定。当前活跃:' + describeActive());
|
||||
}
|
||||
|
||||
// ── execPath 解析(三级 fallback)───────────────────────────────
|
||||
|
||||
/** 从运行中进程的命令行抓可执行路径(编辑器启动命令首段,--project 之前) */
|
||||
function execPathFromPs(pid) {
|
||||
try {
|
||||
var out = cp.execFileSync('ps', ['-p', String(pid), '-o', 'command='], { encoding: 'utf-8' }).trim();
|
||||
if (!out) return '';
|
||||
// 形如:/Applications/Cocos/Creator/3.8.8/CocosCreator.app/Contents/MacOS/CocosCreator --project /x ...
|
||||
// 可执行路径本身不含 " --",按它切分安全
|
||||
var idx = out.indexOf(' --');
|
||||
return (idx >= 0 ? out.slice(0, idx) : out.split(/\s+/)[0]).trim();
|
||||
} catch (e) { return ''; }
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析编辑器可执行路径,三级 fallback:
|
||||
* 1. 注册文件 execPath 字段(main.js 写入,最准)
|
||||
* 2. 从活进程 ps 命令行抓(编辑器还活着时)—— restart 会在 kill 前调用,此时旧进程还在
|
||||
* 3. 按 editorVersion 拼标准安装路径
|
||||
* 全部失败抛错,提示带上尝试过的路径。
|
||||
*/
|
||||
function resolveExecPath(entry) {
|
||||
if (entry.execPath && fs.existsSync(entry.execPath)) return entry.execPath;
|
||||
|
||||
if (entry.pid && isAlive(entry.pid)) {
|
||||
var fromPs = execPathFromPs(entry.pid);
|
||||
if (fromPs && fs.existsSync(fromPs)) return fromPs;
|
||||
}
|
||||
|
||||
if (entry.editorVersion) {
|
||||
var guess = '/Applications/Cocos/Creator/' + entry.editorVersion +
|
||||
'/CocosCreator.app/Contents/MacOS/CocosCreator';
|
||||
if (fs.existsSync(guess)) return guess;
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
'editor-control: 无法解析 Cocos 编辑器可执行路径。\n' +
|
||||
' 注册文件 execPath: ' + (entry.execPath || '(无)') + '\n' +
|
||||
' editorVersion: ' + (entry.editorVersion || '(无)') + '\n' +
|
||||
'请确认 Cocos Creator 装在标准路径 /Applications/Cocos/Creator/<version>/,' +
|
||||
'或重启编辑器让扩展写入 execPath 字段后再试。'
|
||||
);
|
||||
}
|
||||
|
||||
// ── 进程操作 ────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* kill 指定编辑器:先 SIGTERM 优雅退,graceMs 内没退再 SIGKILL,最后主动删注册文件。
|
||||
* 主动删的原因:强杀 / 崩溃不会走 main.js removeRegistry,靠 router 120s stale 清理太慢,
|
||||
* 会导致 wait_ready 误匹配到「已死但文件还新鲜」的旧 entry。
|
||||
*/
|
||||
async function killEditor(pid, opts) {
|
||||
opts = opts || {};
|
||||
var graceMs = opts.graceMs || 6000;
|
||||
|
||||
if (!isAlive(pid)) {
|
||||
removeRegistryFile(pid);
|
||||
return { killed: false, reason: 'not running', pid: pid };
|
||||
}
|
||||
|
||||
var signal = opts.hard ? 'SIGKILL' : 'SIGTERM';
|
||||
try { process.kill(pid, signal); } catch (e) { /* 可能刚好退了 */ }
|
||||
|
||||
var start = Date.now();
|
||||
while (Date.now() - start < graceMs) {
|
||||
await sleep(150);
|
||||
if (!isAlive(pid)) break;
|
||||
}
|
||||
|
||||
var escalated = false;
|
||||
if (isAlive(pid)) {
|
||||
escalated = true;
|
||||
try { process.kill(pid, 'SIGKILL'); } catch (e) { /* ignore */ }
|
||||
await sleep(400);
|
||||
}
|
||||
|
||||
removeRegistryFile(pid);
|
||||
return {
|
||||
killed: !isAlive(pid),
|
||||
pid: pid,
|
||||
signal: signal,
|
||||
escalatedToSigkill: escalated,
|
||||
waitedMs: Date.now() - start,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 冷启动场景解析 execPath:进程已不在,没有活进程可 ps 抓,靠四级 fallback:
|
||||
* 1. args.execPath 显式
|
||||
* 2. args.version 拼标准路径
|
||||
* 3. 借任意一条注册 entry 的 execPath —— execPath 是机器级安装路径,跨项目通用,
|
||||
* 哪怕那条 entry 是别的项目 / 已 stale 也能用
|
||||
* 4. 扫 /Applications/Cocos/Creator 下唯一安装版本
|
||||
*/
|
||||
function resolveExecPathForSpawn(args, projectPath) {
|
||||
if (args.execPath && fs.existsSync(args.execPath)) return args.execPath;
|
||||
|
||||
if (args.version) {
|
||||
var byVer = '/Applications/Cocos/Creator/' + args.version + '/CocosCreator.app/Contents/MacOS/CocosCreator';
|
||||
if (fs.existsSync(byVer)) return byVer;
|
||||
}
|
||||
|
||||
var borrowed = readRegistryEntries().filter(function (e) { return e.execPath && fs.existsSync(e.execPath); })[0];
|
||||
if (borrowed) return borrowed.execPath;
|
||||
|
||||
var base = '/Applications/Cocos/Creator';
|
||||
try {
|
||||
var vers = fs.readdirSync(base).filter(function (v) {
|
||||
return fs.existsSync(base + '/' + v + '/CocosCreator.app/Contents/MacOS/CocosCreator');
|
||||
});
|
||||
if (vers.length === 1) return base + '/' + vers[0] + '/CocosCreator.app/Contents/MacOS/CocosCreator';
|
||||
if (vers.length > 1) {
|
||||
throw new Error('editor_spawn: ' + base + ' 下有多个版本 [' + vers.join(', ') + '],请用 version 指定要启动哪个。');
|
||||
}
|
||||
} catch (e) {
|
||||
if (/多个版本/.test(e.message)) throw e;
|
||||
}
|
||||
|
||||
throw new Error('editor_spawn: 无法解析 Cocos 可执行路径(execPath/version 都没给,注册表也无可借项)。请传 execPath 或 version。');
|
||||
}
|
||||
|
||||
/**
|
||||
* detached 拉起编辑器,立即与 router 解耦(router 退出不带走编辑器)。
|
||||
* 返回的是 launcher pid,不一定等于编辑器主进程最终 pid —— 真实 pid 以 wait_ready
|
||||
* 扫注册表拿到的为准,这里的 pid 仅供日志参考。
|
||||
*/
|
||||
function spawnEditor(execPath, projectPath) {
|
||||
var child = cp.spawn(execPath, ['--project', projectPath], {
|
||||
detached: true,
|
||||
stdio: 'ignore',
|
||||
});
|
||||
child.unref();
|
||||
return child.pid;
|
||||
}
|
||||
|
||||
// ── 就绪探测 ────────────────────────────────────────────────────
|
||||
|
||||
/** 通用 HTTP MCP 调用,返回完整 JSON-RPC 响应(失败返回 null)。probeReady/probeProjectReady 共用。 */
|
||||
function httpMcp(url, method, params, timeoutMs) {
|
||||
return new Promise(function (resolve) {
|
||||
try {
|
||||
var u = new URL(url);
|
||||
var body = JSON.stringify({ jsonrpc: '2.0', id: 1, method: method, params: params || {} });
|
||||
var req = http.request({
|
||||
hostname: u.hostname, port: u.port, path: u.pathname, method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) },
|
||||
timeout: timeoutMs || 4000,
|
||||
}, function (res) {
|
||||
var chunks = [];
|
||||
res.on('data', function (c) { chunks.push(c); });
|
||||
res.on('end', function () {
|
||||
try { resolve(JSON.parse(Buffer.concat(chunks).toString('utf-8'))); }
|
||||
catch (e) { resolve(null); }
|
||||
});
|
||||
});
|
||||
req.on('error', function () { resolve(null); });
|
||||
req.on('timeout', function () { req.destroy(); resolve(null); });
|
||||
req.write(body);
|
||||
req.end();
|
||||
} catch (e) { resolve(null); }
|
||||
});
|
||||
}
|
||||
|
||||
/** MCP initialize 探活:能 initialize = MCP server 起来了(但不代表进了项目,登录页态也能起)。 */
|
||||
function probeReady(url) {
|
||||
return httpMcp(url, 'initialize', {
|
||||
protocolVersion: PROTOCOL_VERSION,
|
||||
clientInfo: { name: 'editor-control', version: '0' },
|
||||
capabilities: {},
|
||||
}).then(function (r) { return !!(r && !r.error); });
|
||||
}
|
||||
|
||||
/**
|
||||
* 项目就绪探测:MCP initialize 成功 ≠ 进了项目 —— 实测激进清登录态后 initialize 仍 ready,
|
||||
* 但编辑器 UI 卡在登录页。用 asset_query_assets 探 asset-db 是否就绪:项目真打开才加载
|
||||
* asset-db、返回非空资源;登录页 / 项目加载中则空或失败。
|
||||
* 正向(进项目非空)已实测;负向(登录页态返回啥)按逻辑推断,未在登录页态实测。
|
||||
*/
|
||||
function probeProjectReady(url) {
|
||||
return httpMcp(url, 'tools/call', {
|
||||
name: 'asset_query_assets', arguments: { pattern: 'db://assets/**', type: 'scene' },
|
||||
}, 6000).then(function (r) {
|
||||
if (!r || r.error || !r.result || r.result.isError) return false;
|
||||
var txt = r.result.content && r.result.content[0] && r.result.content[0].text;
|
||||
if (!txt) return false;
|
||||
try { var arr = JSON.parse(txt); return Array.isArray(arr) && arr.length > 0; }
|
||||
catch (e) { return false; }
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 轮询等指定项目的编辑器就绪。
|
||||
* 就绪判定:注册表有 projectPath 匹配、非 stale、pid≠excludePid 的 entry,且 probeReady 成功。
|
||||
* excludePid:restart 时传被 kill 的旧 pid,避免匹配到尚未删净的旧注册。
|
||||
* 返回 { ready, entry?, reason?, waitedMs }。
|
||||
*/
|
||||
async function waitReady(projectPath, opts) {
|
||||
opts = opts || {};
|
||||
var timeoutMs = opts.timeoutMs || 90000; // 大项目冷启动慢,默认 90s
|
||||
var excludePid = opts.excludePid || 0;
|
||||
var requireProject = opts.requireProject !== false; // 默认要求项目就绪(区分登录页/加载中),传 false 退回只看 MCP
|
||||
var start = Date.now();
|
||||
var lastReason = 'still waiting';
|
||||
var sawMcp = false;
|
||||
|
||||
while (Date.now() - start < timeoutMs) {
|
||||
var hit = activeEditors().filter(function (e) {
|
||||
return e.projectPath === projectPath && e.pid !== excludePid;
|
||||
})[0];
|
||||
|
||||
if (hit) {
|
||||
var mcpOk = await probeReady(hit.url);
|
||||
if (mcpOk) {
|
||||
sawMcp = true;
|
||||
var projOk = requireProject ? await probeProjectReady(hit.url) : true;
|
||||
if (projOk) {
|
||||
return {
|
||||
ready: true, mcpReady: true, projectReady: projOk,
|
||||
entry: {
|
||||
shortName: sanitize(hit.projectShortName),
|
||||
pid: hit.pid, url: hit.url, port: hit.port,
|
||||
projectPath: hit.projectPath, editorVersion: hit.editorVersion,
|
||||
},
|
||||
waitedMs: Date.now() - start,
|
||||
};
|
||||
}
|
||||
lastReason = 'MCP up (pid=' + hit.pid + ') 但 asset-db 未就绪 — 疑似卡登录页或项目加载中';
|
||||
} else {
|
||||
lastReason = 'registered (pid=' + hit.pid + ') but MCP server not responding yet';
|
||||
}
|
||||
} else {
|
||||
lastReason = 'no fresh registry entry for project yet (editor still booting)';
|
||||
}
|
||||
await sleep(1000);
|
||||
}
|
||||
var res = { ready: false, mcpReady: sawMcp, projectReady: false, reason: lastReason, waitedMs: Date.now() - start };
|
||||
if (sawMcp) res.hint = '⚠️ MCP server 起来了但项目没就绪 — 很可能卡在登录页,请手动点 Sign In→skip 进项目后重试';
|
||||
return res;
|
||||
}
|
||||
|
||||
// ── Tool 定义 ───────────────────────────────────────────────────
|
||||
|
||||
var COMMON_TARGET_PROPS = {
|
||||
shortName: { type: 'string', description: '编辑器短名(工具前缀名,如 my-project)。只有一个编辑器时可省略。' },
|
||||
projectPath: { type: 'string', description: '项目绝对路径,定位最精确。编辑器未运行时(restart/wait_ready)必须用它。' },
|
||||
};
|
||||
|
||||
var EDITOR_TOOLS = [
|
||||
{
|
||||
name: 'editor_restart',
|
||||
description: '[editor] 重启 Cocos 编辑器进程(kill 旧实例 → 重新拉起 → 等就绪)。' +
|
||||
'不需要编辑器在运行也能调(挂 router 进程)。仅 macOS。' +
|
||||
'返回 oldPid / launchedPid / kill 结果 / ready 状态(含新 pid·port·url)。',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
shortName: COMMON_TARGET_PROPS.shortName,
|
||||
projectPath: COMMON_TARGET_PROPS.projectPath,
|
||||
pid: { type: 'number', description: '直接按 pid 定位要重启的编辑器。' },
|
||||
hard: { type: 'boolean', description: 'true=直接 SIGKILL,不给优雅退出窗口。默认 false(先 SIGTERM)。' },
|
||||
timeoutMs: { type: 'number', description: '等新实例就绪的超时(毫秒),默认 90000。' },
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'editor_wait_ready',
|
||||
description: '[editor] 等指定项目的 Cocos 编辑器就绪(注册文件出现且 MCP server 能 initialize)。' +
|
||||
'用于「拉起编辑器后等它起来再操作」。已就绪则立即返回。' +
|
||||
'编辑器尚未运行时必须传 projectPath(空注册表无法从 shortName 反推路径)。',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
shortName: COMMON_TARGET_PROPS.shortName,
|
||||
projectPath: COMMON_TARGET_PROPS.projectPath,
|
||||
timeoutMs: { type: 'number', description: '超时(毫秒),默认 90000。' },
|
||||
excludePid: { type: 'number', description: '排除某个 pid(如刚被 kill 的旧实例),避免误判旧注册为就绪。' },
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'editor_kill',
|
||||
description: '[editor] 关闭 Cocos 编辑器进程(SIGTERM,超时升级 SIGKILL,并清理注册文件)。' +
|
||||
'不需要编辑器内 server 配合(挂 router 进程)。',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
shortName: COMMON_TARGET_PROPS.shortName,
|
||||
projectPath: COMMON_TARGET_PROPS.projectPath,
|
||||
pid: { type: 'number', description: '直接按 pid 定位。' },
|
||||
hard: { type: 'boolean', description: 'true=直接 SIGKILL。默认 false。' },
|
||||
graceMs: { type: 'number', description: 'SIGTERM 后等待优雅退出的时长(毫秒),默认 6000,超时强杀。' },
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'editor_spawn',
|
||||
description: '[editor] 从零启动一个 Cocos 编辑器(进程完全不在时用,如崩溃后恢复)。' +
|
||||
'同项目已有活跃实例则直接返回不重复开(Cocos 不支持同项目多开)。仅 macOS。',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
projectPath: { type: 'string', description: '项目绝对路径(必填)。' },
|
||||
version: { type: 'string', description: 'Cocos 版本号(如 3.8.8),用于拼可执行路径。不传则从注册表借或扫唯一安装。' },
|
||||
execPath: { type: 'string', description: '直接指定可执行路径,优先级最高。' },
|
||||
timeoutMs: { type: 'number', description: '等就绪超时(毫秒),默认 90000。' },
|
||||
},
|
||||
required: ['projectPath'],
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
var EDITOR_TOOL_NAMES = new Set(EDITOR_TOOLS.map(function (t) { return t.name; }));
|
||||
|
||||
function isEditorTool(name) {
|
||||
return EDITOR_TOOL_NAMES.has(name);
|
||||
}
|
||||
|
||||
// ── Tool 调用处理 ───────────────────────────────────────────────
|
||||
|
||||
async function handleEditorToolCall(name, args) {
|
||||
args = args || {};
|
||||
|
||||
if (name === 'editor_restart') {
|
||||
var target;
|
||||
try {
|
||||
target = resolveTarget(args);
|
||||
} catch (e) {
|
||||
// 无活跃实例但给了 projectPath → 进程崩溃/消失了,降级为冷启动 spawn(崩溃恢复闭环)
|
||||
if (args.projectPath) return await handleEditorToolCall('editor_spawn', args);
|
||||
throw e;
|
||||
}
|
||||
var execPath = resolveExecPath(target); // kill 前解析,此时旧进程还活着,ps 兜底有效
|
||||
var projectPath = target.projectPath;
|
||||
var oldPid = target.pid;
|
||||
|
||||
var killRes = await killEditor(oldPid, { hard: args.hard });
|
||||
var launchedPid = spawnEditor(execPath, projectPath);
|
||||
var ready = await waitReady(projectPath, { timeoutMs: args.timeoutMs, excludePid: oldPid });
|
||||
|
||||
return jsonContent({
|
||||
action: 'restart',
|
||||
shortName: sanitize(target.projectShortName),
|
||||
projectPath: projectPath,
|
||||
execPath: execPath,
|
||||
oldPid: oldPid,
|
||||
launchedPid: launchedPid,
|
||||
kill: killRes,
|
||||
ready: ready,
|
||||
}, !ready.ready);
|
||||
}
|
||||
|
||||
if (name === 'editor_wait_ready') {
|
||||
var pp = args.projectPath;
|
||||
if (!pp) {
|
||||
// 没给 projectPath:尝试从活跃实例(可选 shortName 过滤)推断
|
||||
var act = activeEditors();
|
||||
if (args.shortName) {
|
||||
var want = sanitize(args.shortName);
|
||||
act = act.filter(function (e) { return sanitize(e.projectShortName) === want; });
|
||||
}
|
||||
if (act.length === 1) pp = act[0].projectPath;
|
||||
else if (act.length === 0) {
|
||||
throw new Error('editor_wait_ready: 没有活跃编辑器可推断 projectPath。编辑器尚未就绪时必须显式传 projectPath(指定等待哪个项目)。');
|
||||
} else {
|
||||
throw new Error('editor_wait_ready: 有多个活跃编辑器,请传 projectPath 或 shortName 指定。当前活跃:' + describeActive());
|
||||
}
|
||||
}
|
||||
var r = await waitReady(pp, { timeoutMs: args.timeoutMs, excludePid: args.excludePid });
|
||||
return jsonContent({ action: 'wait_ready', projectPath: pp, result: r }, !r.ready);
|
||||
}
|
||||
|
||||
if (name === 'editor_kill') {
|
||||
var t = resolveTarget(args);
|
||||
var res = await killEditor(t.pid, { hard: args.hard, graceMs: args.graceMs });
|
||||
return jsonContent({
|
||||
action: 'kill',
|
||||
shortName: sanitize(t.projectShortName),
|
||||
projectPath: t.projectPath,
|
||||
result: res,
|
||||
}, !res.killed);
|
||||
}
|
||||
|
||||
if (name === 'editor_spawn') {
|
||||
var spProject = args.projectPath;
|
||||
if (!spProject || !path.isAbsolute(spProject)) {
|
||||
throw new Error('editor_spawn: projectPath 必填且必须是绝对路径,收到 ' + JSON.stringify(spProject));
|
||||
}
|
||||
// 幂等:同项目已有活跃实例直接返回(Cocos 不支持同项目多开)
|
||||
var spRunning = activeEditors().filter(function (e) { return e.projectPath === spProject; })[0];
|
||||
if (spRunning) {
|
||||
return jsonContent({
|
||||
action: 'spawn', alreadyRunning: true,
|
||||
entry: {
|
||||
shortName: sanitize(spRunning.projectShortName), pid: spRunning.pid,
|
||||
url: spRunning.url, port: spRunning.port, projectPath: spRunning.projectPath,
|
||||
},
|
||||
});
|
||||
}
|
||||
var spExec = resolveExecPathForSpawn(args, spProject);
|
||||
var spPid = spawnEditor(spExec, spProject);
|
||||
var spReady = await waitReady(spProject, { timeoutMs: args.timeoutMs });
|
||||
return jsonContent({
|
||||
action: 'spawn', execPath: spExec, launchedPid: spPid, ready: spReady,
|
||||
}, !spReady.ready);
|
||||
}
|
||||
|
||||
throw new Error('editor-control: 未知 tool "' + name + '"');
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
EDITOR_TOOLS: EDITOR_TOOLS,
|
||||
isEditorTool: isEditorTool,
|
||||
handleEditorToolCall: handleEditorToolCall,
|
||||
// 导出内部函数供测试 / bin.js 复用
|
||||
readRegistryEntries: readRegistryEntries,
|
||||
activeEditors: activeEditors,
|
||||
resolveTarget: resolveTarget,
|
||||
resolveExecPath: resolveExecPath,
|
||||
resolveExecPathForSpawn: resolveExecPathForSpawn,
|
||||
waitReady: waitReady,
|
||||
probeReady: probeReady,
|
||||
probeProjectReady: probeProjectReady,
|
||||
isAlive: isAlive,
|
||||
};
|
||||
@@ -0,0 +1,197 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* router/src/offline-tools.js
|
||||
*
|
||||
* Router 级 offline prefab tool 定义。
|
||||
* 这些 tool 直接调用 cli/src/index.js 的 editPrefab / queryPrefab,
|
||||
* 同进程执行,无需 Cocos 编辑器运行。
|
||||
*
|
||||
* [offline] tool 命名不加 shortName 前缀(router 全局工具)。
|
||||
*/
|
||||
|
||||
var fs = require('fs');
|
||||
var path = require('path');
|
||||
|
||||
// 延迟 require,避免 bin.js 加载时 cli 路径不对
|
||||
// __dirname = router/src/,cli 相对路径为 ../../cli/src/index.js
|
||||
var CLI_INDEX = path.resolve(__dirname, '../../cli/src/index.js');
|
||||
|
||||
function getCli() {
|
||||
return require(CLI_INDEX);
|
||||
}
|
||||
|
||||
// ── 工具:绝对路径校验 ──────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* 校验 filePath 是否为绝对路径,否则抛错。
|
||||
* 原因:router 以 stdio 方式运行,cwd 不确定,相对路径有歧义。
|
||||
*
|
||||
* @param {string} filePath
|
||||
* @param {string} toolName
|
||||
*/
|
||||
function requireAbsolutePath(filePath, toolName) {
|
||||
if (typeof filePath !== 'string' || !path.isAbsolute(filePath)) {
|
||||
throw new Error(
|
||||
'[' + toolName + '] filePath 必须是绝对路径,收到: ' + JSON.stringify(filePath) +
|
||||
'\n原因:router 以 stdio 模式运行,cwd 不确定,相对路径会产生歧义。'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Tool 定义 ───────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* offline tool 定义列表(与 router 自身 buildAggregatedToolList 合并)
|
||||
* 格式与编辑器 tool 相同,供 handleOfflineToolCall 分发。
|
||||
*/
|
||||
var OFFLINE_TOOLS = [
|
||||
{
|
||||
name: 'prefab_query',
|
||||
description: '[offline] 不需要 Cocos 编辑器运行。查询 prefab 文件节点树或单节点详情。\n' +
|
||||
'selector.type 可选:\n' +
|
||||
' tree(默认)→ 精简节点树\n' +
|
||||
' node → { name } 按名称查单节点详情\n' +
|
||||
' find → { nodeType } 返回所有匹配 __type__ 的元素 id 列表',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
filePath: {
|
||||
type: 'string',
|
||||
description: 'prefab 文件的绝对路径,如 /path/to/HomeUI.prefab',
|
||||
},
|
||||
selector: {
|
||||
type: 'object',
|
||||
description: '查询选择器,不传时默认 type="tree"',
|
||||
properties: {
|
||||
type: { type: 'string', enum: ['tree', 'node', 'find'] },
|
||||
name: { type: 'string', description: 'selector.type="node" 时必填' },
|
||||
nodeType: { type: 'string', description: 'selector.type="find" 时必填,如 "cc.Label"' },
|
||||
},
|
||||
},
|
||||
},
|
||||
required: ['filePath'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'prefab_edit',
|
||||
description: '[offline] 不需要 Cocos 编辑器运行。声明式批量编辑 prefab 文件,全部 op 成功后一次性落盘。\n' +
|
||||
'支持的 op.op 类型:set-position / set-label-text / set-sprite-frame / set-active / add-node / remove-node / clone-node / add-component / set-component-ref\n' +
|
||||
'op.node / op.parent / op.refNode 可以是节点名称字符串,或 { id: N } 按 __id__ 定位。',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
filePath: {
|
||||
type: 'string',
|
||||
description: 'prefab 文件的绝对路径',
|
||||
},
|
||||
ops: {
|
||||
type: 'array',
|
||||
description: 'op 描述数组,参考 cli editPrefab 文档',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
op: { type: 'string' },
|
||||
node: {},
|
||||
},
|
||||
required: ['op', 'node'],
|
||||
},
|
||||
},
|
||||
},
|
||||
required: ['filePath', 'ops'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'prefab_batch',
|
||||
description: '[offline] 不需要 Cocos 编辑器运行。从 JSON 文件读取 ops 后批量编辑 prefab。\n' +
|
||||
'opsJsonPath 指向一个 JSON 文件,内容为 op 数组(与 prefab_edit 的 ops 格式相同)。\n' +
|
||||
'两个路径均必须为绝对路径。',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
filePath: {
|
||||
type: 'string',
|
||||
description: 'prefab 文件的绝对路径',
|
||||
},
|
||||
opsJsonPath: {
|
||||
type: 'string',
|
||||
description: 'ops JSON 文件的绝对路径',
|
||||
},
|
||||
},
|
||||
required: ['filePath', 'opsJsonPath'],
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
// ── Tool 名称集合(用于快速判断是否是 offline tool)──────────────
|
||||
|
||||
var OFFLINE_TOOL_NAMES = new Set(OFFLINE_TOOLS.map(function (t) { return t.name; }));
|
||||
|
||||
/**
|
||||
* 判断 name 是否是 offline tool
|
||||
* @param {string} name
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function isOfflineTool(name) {
|
||||
return OFFLINE_TOOL_NAMES.has(name);
|
||||
}
|
||||
|
||||
// ── Tool 调用处理 ───────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* 处理 offline tool 调用,返回 MCP content 对象。
|
||||
*
|
||||
* @param {string} name tool 名称
|
||||
* @param {object} args tool 参数
|
||||
* @returns {{ content: Array }}
|
||||
*/
|
||||
async function handleOfflineToolCall(name, args) {
|
||||
var cli = getCli();
|
||||
|
||||
if (name === 'prefab_query') {
|
||||
requireAbsolutePath(args.filePath, 'prefab_query');
|
||||
var result = cli.queryPrefab(args.filePath, args.selector);
|
||||
return {
|
||||
content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
|
||||
};
|
||||
}
|
||||
|
||||
if (name === 'prefab_edit') {
|
||||
requireAbsolutePath(args.filePath, 'prefab_edit');
|
||||
if (!Array.isArray(args.ops) || args.ops.length === 0) {
|
||||
throw new Error('[prefab_edit] ops 必须是非空数组');
|
||||
}
|
||||
var editResult = cli.editPrefab(args.filePath, args.ops);
|
||||
return {
|
||||
content: [{ type: 'text', text: JSON.stringify(editResult, null, 2) }],
|
||||
};
|
||||
}
|
||||
|
||||
if (name === 'prefab_batch') {
|
||||
requireAbsolutePath(args.filePath, 'prefab_batch');
|
||||
requireAbsolutePath(args.opsJsonPath, 'prefab_batch');
|
||||
var opsRaw = fs.readFileSync(args.opsJsonPath, 'utf-8');
|
||||
var ops;
|
||||
try {
|
||||
ops = JSON.parse(opsRaw);
|
||||
} catch (e) {
|
||||
throw new Error('[prefab_batch] opsJsonPath 解析失败: ' + e.message);
|
||||
}
|
||||
if (!Array.isArray(ops) || ops.length === 0) {
|
||||
throw new Error('[prefab_batch] opsJsonPath 文件内容必须是非空数组');
|
||||
}
|
||||
var batchResult = cli.editPrefab(args.filePath, ops);
|
||||
return {
|
||||
content: [{ type: 'text', text: JSON.stringify(batchResult, null, 2) }],
|
||||
};
|
||||
}
|
||||
|
||||
throw new Error('offline-tools: 未知 tool "' + name + '"');
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
OFFLINE_TOOLS: OFFLINE_TOOLS,
|
||||
isOfflineTool: isOfflineTool,
|
||||
handleOfflineToolCall: handleOfflineToolCall,
|
||||
requireAbsolutePath: requireAbsolutePath,
|
||||
};
|
||||
@@ -0,0 +1,218 @@
|
||||
'use strict';
|
||||
|
||||
// ============================================================
|
||||
// router/test/offline-tools.test.js
|
||||
// T13 offline tool 测试
|
||||
//
|
||||
// 直接 require router/src/offline-tools.js,测:
|
||||
// 1. prefab_query happy path(tree / node / find)
|
||||
// 2. prefab_edit happy path(set-active 写 tmp 文件)
|
||||
// 3. prefab_batch happy path(opsJson 文件 → editPrefab)
|
||||
// 4. 相对路径 filePath 报错
|
||||
// 5. prefab_batch opsJsonPath 相对路径报错
|
||||
// ============================================================
|
||||
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const os = require('os');
|
||||
|
||||
const {
|
||||
isOfflineTool,
|
||||
handleOfflineToolCall,
|
||||
requireAbsolutePath,
|
||||
OFFLINE_TOOLS,
|
||||
} = require('../src/offline-tools.js');
|
||||
|
||||
// fixture: HomeUI.prefab(只读,在 cli/test/fixtures/)
|
||||
const FIXTURE_PATH = path.resolve(
|
||||
__dirname,
|
||||
'../../cli/test/fixtures/HomeUI.prefab'
|
||||
);
|
||||
|
||||
// 复制 fixture 到 tmp 用于写操作
|
||||
function makeTmp(tag) {
|
||||
var dst = path.join(os.tmpdir(), 'HomeUI-router-' + tag + '-' + Date.now() + '.prefab');
|
||||
fs.copyFileSync(FIXTURE_PATH, dst);
|
||||
return dst;
|
||||
}
|
||||
|
||||
// ── OFFLINE_TOOLS 定义完整性 ────────────────────────────────────
|
||||
|
||||
test('OFFLINE_TOOLS 导出 3 个 tool,名称正确', () => {
|
||||
assert.equal(OFFLINE_TOOLS.length, 3);
|
||||
var names = OFFLINE_TOOLS.map(function (t) { return t.name; });
|
||||
assert.ok(names.includes('prefab_query'));
|
||||
assert.ok(names.includes('prefab_edit'));
|
||||
assert.ok(names.includes('prefab_batch'));
|
||||
});
|
||||
|
||||
test('isOfflineTool 对已知 name 返回 true,未知 name 返回 false', () => {
|
||||
assert.equal(isOfflineTool('prefab_query'), true);
|
||||
assert.equal(isOfflineTool('prefab_edit'), true);
|
||||
assert.equal(isOfflineTool('prefab_batch'), true);
|
||||
assert.equal(isOfflineTool('router_list_editors'), false);
|
||||
assert.equal(isOfflineTool('scene_set_property'), false);
|
||||
assert.equal(isOfflineTool(''), false);
|
||||
});
|
||||
|
||||
test('每个 offline tool description 包含 "[offline]" 标注', () => {
|
||||
for (var t of OFFLINE_TOOLS) {
|
||||
assert.ok(
|
||||
t.description.includes('[offline]'),
|
||||
'tool ' + t.name + ' description 应包含 "[offline]"'
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// ── requireAbsolutePath ────────────────────────────────────────
|
||||
|
||||
test('requireAbsolutePath 相对路径抛错', () => {
|
||||
assert.throws(
|
||||
function () { requireAbsolutePath('relative/path.prefab', 'test'); },
|
||||
/必须是绝对路径/
|
||||
);
|
||||
});
|
||||
|
||||
test('requireAbsolutePath 绝对路径不抛错', () => {
|
||||
assert.doesNotThrow(function () {
|
||||
requireAbsolutePath('/absolute/path.prefab', 'test');
|
||||
});
|
||||
});
|
||||
|
||||
// ── prefab_query happy path ─────────────────────────────────────
|
||||
|
||||
test('prefab_query type=tree 返回 MCP content,根节点名称为 HomeUI', async () => {
|
||||
var result = await handleOfflineToolCall('prefab_query', {
|
||||
filePath: FIXTURE_PATH,
|
||||
selector: { type: 'tree' },
|
||||
});
|
||||
|
||||
assert.ok(Array.isArray(result.content), 'result.content 应是数组');
|
||||
assert.equal(result.content[0].type, 'text');
|
||||
|
||||
var data = JSON.parse(result.content[0].text);
|
||||
assert.equal(data.name, 'HomeUI', '根节点 name 应为 HomeUI');
|
||||
assert.ok(Array.isArray(data.children), 'children 应是数组');
|
||||
});
|
||||
|
||||
test('prefab_query 无 selector 默认返回 tree', async () => {
|
||||
var result = await handleOfflineToolCall('prefab_query', {
|
||||
filePath: FIXTURE_PATH,
|
||||
});
|
||||
|
||||
var data = JSON.parse(result.content[0].text);
|
||||
assert.equal(data.name, 'HomeUI');
|
||||
});
|
||||
|
||||
test('prefab_query type=find 返回 cc.Label id 列表', async () => {
|
||||
var result = await handleOfflineToolCall('prefab_query', {
|
||||
filePath: FIXTURE_PATH,
|
||||
selector: { type: 'find', nodeType: 'cc.Label' },
|
||||
});
|
||||
|
||||
var ids = JSON.parse(result.content[0].text);
|
||||
assert.ok(Array.isArray(ids), 'find 结果应是数组');
|
||||
assert.ok(ids.length > 0, '应找到至少一个 cc.Label');
|
||||
ids.forEach(function (id) { assert.equal(typeof id, 'number'); });
|
||||
});
|
||||
|
||||
// ── prefab_query 相对路径报错 ────────────────────────────────────
|
||||
|
||||
test('prefab_query 相对路径 filePath 抛错', async () => {
|
||||
await assert.rejects(
|
||||
function () {
|
||||
return handleOfflineToolCall('prefab_query', {
|
||||
filePath: 'relative/HomeUI.prefab',
|
||||
});
|
||||
},
|
||||
/必须是绝对路径/
|
||||
);
|
||||
});
|
||||
|
||||
// ── prefab_edit happy path ──────────────────────────────────────
|
||||
|
||||
test('prefab_edit set-active 成功,返回 changed=true + opsApplied=1', async () => {
|
||||
var tmp = makeTmp('edit');
|
||||
try {
|
||||
var result = await handleOfflineToolCall('prefab_edit', {
|
||||
filePath: tmp,
|
||||
ops: [
|
||||
{ op: 'set-active', node: 'HomeUI', active: false },
|
||||
],
|
||||
});
|
||||
|
||||
var data = JSON.parse(result.content[0].text);
|
||||
assert.equal(data.changed, true, 'changed 应为 true');
|
||||
assert.equal(data.opsApplied, 1, 'opsApplied 应为 1');
|
||||
assert.ok(Array.isArray(data.nodesAffected), 'nodesAffected 应是数组');
|
||||
} finally {
|
||||
try { fs.unlinkSync(tmp); } catch (_) {}
|
||||
}
|
||||
});
|
||||
|
||||
// ── prefab_edit 相对路径报错 ─────────────────────────────────────
|
||||
|
||||
test('prefab_edit 相对路径 filePath 抛错', async () => {
|
||||
await assert.rejects(
|
||||
function () {
|
||||
return handleOfflineToolCall('prefab_edit', {
|
||||
filePath: './relative.prefab',
|
||||
ops: [{ op: 'set-active', node: 'HomeUI', active: false }],
|
||||
});
|
||||
},
|
||||
/必须是绝对路径/
|
||||
);
|
||||
});
|
||||
|
||||
// ── prefab_batch happy path ─────────────────────────────────────
|
||||
|
||||
test('prefab_batch 从 JSON 文件读取 ops,成功写回', async () => {
|
||||
var tmp = makeTmp('batch');
|
||||
var opsJson = path.join(os.tmpdir(), 'router-batch-ops-' + Date.now() + '.json');
|
||||
var ops = [
|
||||
{ op: 'set-active', node: 'HomeUI', active: true },
|
||||
];
|
||||
fs.writeFileSync(opsJson, JSON.stringify(ops), 'utf-8');
|
||||
|
||||
try {
|
||||
var result = await handleOfflineToolCall('prefab_batch', {
|
||||
filePath: tmp,
|
||||
opsJsonPath: opsJson,
|
||||
});
|
||||
|
||||
var data = JSON.parse(result.content[0].text);
|
||||
assert.equal(data.changed, true);
|
||||
assert.equal(data.opsApplied, 1);
|
||||
} finally {
|
||||
try { fs.unlinkSync(tmp); } catch (_) {}
|
||||
try { fs.unlinkSync(opsJson); } catch (_) {}
|
||||
}
|
||||
});
|
||||
|
||||
// ── prefab_batch 相对路径报错 ────────────────────────────────────
|
||||
|
||||
test('prefab_batch 相对路径 filePath 抛错', async () => {
|
||||
await assert.rejects(
|
||||
function () {
|
||||
return handleOfflineToolCall('prefab_batch', {
|
||||
filePath: 'relative.prefab',
|
||||
opsJsonPath: '/absolute/ops.json',
|
||||
});
|
||||
},
|
||||
/必须是绝对路径/
|
||||
);
|
||||
});
|
||||
|
||||
test('prefab_batch 相对路径 opsJsonPath 抛错', async () => {
|
||||
await assert.rejects(
|
||||
function () {
|
||||
return handleOfflineToolCall('prefab_batch', {
|
||||
filePath: FIXTURE_PATH,
|
||||
opsJsonPath: 'relative/ops.json',
|
||||
});
|
||||
},
|
||||
/必须是绝对路径/
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user