[cc-3-8-x-mcp] 重构: 改为 Gateway 私有 Editor Bridge

改了什么:移除项目级 MCP Server 与 universal-mcp-sdk,新增带随机 token 的本机 Editor Bridge、Gateway v2 注册和分级超时转发。

为什么:MCP 协议与多项目路由统一交给全局 Gateway,项目扩展只保留 Cocos Editor.Message 和离线 CLI 职责。

影响范围:扩展启动与面板、编辑器注册、router 转发、安全边界、使用文档及自动化测试。
This commit is contained in:
furao
2026-08-14 12:10:09 +08:00
parent a3b016ea8e
commit ea364d3470
16 changed files with 1167 additions and 314 deletions
+72 -73
View File
@@ -2,14 +2,14 @@
'use strict';
/**
* cocos-mcp-router
* cocos-mcp-gateway
*
* stdio MCP server,聚合所有活跃的 Cocos 编辑器扩展,给客户端暴露统一的 tool 列表。
* 全局 stdio MCP Gateway,聚合所有活跃的 Cocos 编辑器扩展,给客户端暴露统一的 tool 列表。
*
* 发现机制:
* 扫 ~/.cocos-mcp/editors/*.json,每个文件代表一个活跃扩展实例
* 过滤 mtime > 120s 的(视为已死)
* 对每个活跃编辑器调 HTTP POST /mcp initialize + tools/list,拿到其 tool 清单
* 对每个活跃编辑器调用私有 /bridge,读取 tool/resource 清单
*
* 命名:
* tool 名前缀化:<projectShortName>__<originalName>
@@ -19,27 +19,34 @@
* tools/call 收到前缀名 → 拆出 projectShortName → 查 editor URL → HTTP 转发
*
* 客户端接入:
* claude mcp add cocos -- node /path/to/forest/extensions/cc-3-8-x-mcp/router/bin.js
* claude mcp add cocos -- node /path/to/cocos-mcp-gateway/runtime/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 rpc = require('./src/http-json-rpc.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' };
var PROTOCOL_VERSION = '2025-06-18';
var GATEWAY_INFO = { name: 'cocos-mcp-gateway', version: '0.3.0' };
try {
if (!fs.existsSync(REGISTRY_DIR)) fs.mkdirSync(REGISTRY_DIR, { recursive: true, mode: 0o700 });
fs.chmodSync(REGISTRY_DIR, 0o700);
} catch (e) {
logErr('registry directory hardening failed:', e.message);
}
function logErr() {
// router 走 stdio,不能往 stdout 写非 JSON-RPC 内容,日志只能走 stderr
var args = Array.prototype.slice.call(arguments);
process.stderr.write('[cocos-mcp-router] ' + args.join(' ') + '\n');
process.stderr.write('[cocos-mcp-gateway] ' + args.join(' ') + '\n');
}
// ── 发现活跃编辑器 ──
@@ -65,7 +72,11 @@ function scanRegistry() {
return;
}
var info = JSON.parse(fs.readFileSync(full, 'utf-8'));
if (!info || !info.url) return;
var invalidReason = rpc.validateRegistryEntry(info);
if (invalidReason) {
logErr('ignored registry entry', name, invalidReason);
return;
}
entries.push(info);
} catch (e) { /* ignore */ }
});
@@ -73,64 +84,30 @@ function scanRegistry() {
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: {},
},
var describeRes = await rpc.bridgeJsonRpc(info.url, {
jsonrpc: '2.0', id: 1, method: 'bridge/describe', params: {},
}, {
authToken: info.authToken,
timeoutMs: rpc.PROBE_TIMEOUT_MS,
});
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 || [];
if (describeRes.error) throw new Error(describeRes.error.message);
var description = describeRes.result || {};
if (description.bridgeApiVersion !== 1) {
throw new Error('unsupported bridge api version: ' + description.bridgeApiVersion);
}
return {
tools: description.tools || [],
resources: description.resources || [],
bridgeApiVersion: description.bridgeApiVersion,
};
} catch (e) {
logErr('probe failed', info.projectShortName, info.url, e.message);
return null;
}
}
async function probeEditorResources(info) {
try {
var listRes = await httpJsonRpc(info.url, { jsonrpc: '2.0', id: 3, method: 'resources/list' });
if (listRes.error) throw new Error(listRes.error.message);
return listRes.result.resources || [];
} catch (e) {
return [];
}
}
/** 去掉 shortName 里的非法字符,MCP tool 名只允许 [a-zA-Z0-9_-] */
function sanitizeShortName(name) {
return String(name || 'unknown').replace(/[^a-zA-Z0-9_-]/g, '_');
@@ -148,9 +125,10 @@ async function discover() {
// 首次 probe 只拿到部分/空工具;之后 asset-db 就绪、业务工具全激活,必须重新 probe 刷新,
// 否则 claude 永远看不到后激活的工具(如 forest__preview_refresh_and_reload)。
var existing = editors.get(key);
var tools = await probeEditor(info);
if (tools == null) continue; // probe 失败:保留旧缓存(editor 可能临时忙/重启中),不覆盖
var resources = await probeEditorResources(info);
var description = await probeEditor(info);
if (description == null) continue; // probe 失败:保留旧缓存(editor 可能临时忙/重启中),不覆盖
var tools = description.tools;
var resources = description.resources;
editors.set(key, {
baseShortName: sanitizeShortName(info.projectShortName),
shortName: sanitizeShortName(info.projectShortName), // dedupeShortNames 会按冲突重设
@@ -158,6 +136,11 @@ async function discover() {
pid: info.pid,
startedAt: Date.parse(info.startedAt) || 0, // 同项目双实例时 dedupe 按它选代表
url: info.url,
authToken: info.authToken || '',
extensionVersion: info.extensionVersion || '',
gatewayApiVersion: info.gatewayApiVersion || 0,
transport: info.transport,
bridgeApiVersion: description.bridgeApiVersion || info.bridgeApiVersion || 0,
tools: tools,
resources: resources,
lastProbed: Date.now(),
@@ -264,8 +247,8 @@ function buildAggregatedToolList() {
}
// router 自身的 meta tool
out.push({
name: 'router_list_editors',
description: '列出当前 router 发现的所有活跃 Cocos 编辑器(shortName / pid / url / tool 数',
name: 'gateway_list_editors',
description: '列出当前 Gateway 发现的所有活跃 Cocos 编辑器(实例、Bridge 版本、tool 数;不暴露 token',
inputSchema: { type: 'object', properties: {} },
});
// offline prefab tools(不需要编辑器运行)
@@ -276,11 +259,11 @@ function buildAggregatedToolList() {
}
function encodeRouterResourceUri(ed, uri) {
return 'cocos-router://' + ed.shortName + '/' + encodeURIComponent(uri);
return 'cocos-gateway://' + ed.shortName + '/' + encodeURIComponent(uri);
}
function decodeRouterResourceUri(uri) {
var m = String(uri || '').match(/^cocos-router:\/\/([^\/]+)\/(.+)$/);
var m = String(uri || '').match(/^cocos-gateway:\/\/([^\/]+)\/(.+)$/);
if (!m) return null;
return { shortName: m[1], uri: decodeURIComponent(m[2]) };
}
@@ -354,7 +337,7 @@ async function handleMessage(msg) {
await discover();
result = {
protocolVersion: PROTOCOL_VERSION,
serverInfo: ROUTER_INFO,
serverInfo: GATEWAY_INFO,
capabilities: {
tools: { listChanged: true },
resources: {},
@@ -398,10 +381,20 @@ async function handleMessage(msg) {
async function handleToolCall(name, args) {
// Router 自身的 meta tool
if (name === 'router_list_editors') {
if (name === 'gateway_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 {
shortName: ed.shortName,
pid: ed.pid,
url: ed.url,
projectPath: ed.projectPath,
extensionVersion: ed.extensionVersion,
gatewayApiVersion: ed.gatewayApiVersion,
transport: ed.transport,
bridgeApiVersion: ed.bridgeApiVersion,
toolCount: ed.tools.length,
};
});
return { content: [{ type: 'text', text: JSON.stringify(list, null, 2) }] };
}
@@ -427,9 +420,12 @@ async function handleToolCall(name, args) {
}
try {
var forward = await httpJsonRpc(hit.editor.url, {
jsonrpc: '2.0', id: Date.now(), method: 'tools/call',
var forward = await rpc.bridgeJsonRpc(hit.editor.url, {
jsonrpc: '2.0', id: Date.now(), method: 'bridge/invoke',
params: { name: hit.originalName, arguments: args },
}, {
authToken: hit.editor.authToken,
timeoutMs: rpc.toolTimeoutMs(hit.originalName),
});
if (forward.error) {
return { content: [{ type: 'text', text: 'editor error: ' + forward.error.message }], isError: true };
@@ -454,9 +450,12 @@ async function handleResourceRead(uri) {
return { contents: [{ type: 'text', text: 'editor not found for resource: ' + decoded.shortName, mimeType: 'text/plain' }] };
}
try {
var forward = await httpJsonRpc(ed.url, {
jsonrpc: '2.0', id: Date.now(), method: 'resources/read',
var forward = await rpc.bridgeJsonRpc(ed.url, {
jsonrpc: '2.0', id: Date.now(), method: 'bridge/read-resource',
params: { uri: decoded.uri },
}, {
authToken: ed.authToken,
timeoutMs: rpc.TOOL_TIMEOUT_MS,
});
if (forward.error) {
return { contents: [{ type: 'text', text: 'editor error: ' + forward.error.message, mimeType: 'text/plain' }] };
@@ -472,4 +471,4 @@ setInterval(function () { discover().catch(function () {}); }, DISCOVERY_INTERVA
// 启动首次发现
discover().catch(function (e) { logErr('initial discover failed', e.message); });
logErr('cocos-mcp-router started (stdio)');
logErr('cocos-mcp-gateway started (stdio)');