mirror of
https://github.com/HappyLifeOk/cc-3-8-x-mcp.git
synced 2026-08-21 14:07:21 +00:00
[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:
+72
-73
@@ -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)');
|
||||
|
||||
Reference in New Issue
Block a user