mirror of
https://github.com/HappyLifeOk/cc-3-8-x-mcp.git
synced 2026-06-10 17:56:47 +00:00
345 lines
13 KiB
JavaScript
345 lines
13 KiB
JavaScript
|
|
#!/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)');
|