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:
furao
2026-06-06 11:33:19 +08:00
commit 14c5b00f14
96 changed files with 15855 additions and 0 deletions
Executable
+344
View File
@@ -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); });
// 编辑器进程管理 toolsspawn/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);
}
// 编辑器进程管理 toolsspawn/kill/restart/wait_readyrouter 本地执行,不走转发)
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)');