mirror of
https://github.com/HappyLifeOk/cc-3-8-x-mcp.git
synced 2026-08-20 13:37:13 +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)');
|
||||
|
||||
@@ -5,8 +5,8 @@
|
||||
*
|
||||
* Router 级编辑器进程管理 tool。spawn / kill / wait_ready / restart Cocos 编辑器进程。
|
||||
*
|
||||
* 为什么挂在 router(而不是编辑器内 server):
|
||||
* 编辑器内的 MCP server 寄生在编辑器进程里,kill 编辑器 = kill server 自己,自杀后没法
|
||||
* 为什么挂在 Gateway(而不是编辑器内 Bridge):
|
||||
* 编辑器内的 Bridge 寄生在编辑器进程里,kill 编辑器 = kill Bridge 自己,自杀后没法
|
||||
* 再把自己拉起来。router 是进程外的常驻 stdio 进程,编辑器死了它还活着,所以「关 / 重启 /
|
||||
* 等就绪」这类要跨越编辑器进程生死的能力只能放这里,跟 offline prefab tools 同类,不走转发。
|
||||
*
|
||||
@@ -21,13 +21,12 @@
|
||||
var fs = require('fs');
|
||||
var path = require('path');
|
||||
var os = require('os');
|
||||
var http = require('http');
|
||||
var cp = require('child_process');
|
||||
var crypto = require('crypto');
|
||||
var bridgeRpc = require('./http-json-rpc.js');
|
||||
|
||||
var REGISTRY_DIR = path.join(os.homedir(), '.cocos-mcp', 'editors');
|
||||
var STALE_MS = 120 * 1000; // 与 bin.js 对齐:2 分钟没心跳视为死
|
||||
var PROTOCOL_VERSION = '2024-11-05';
|
||||
|
||||
// ── 通用小工具 ──────────────────────────────────────────────────
|
||||
|
||||
@@ -458,53 +457,34 @@ function spawnEditor(execPath, projectPath, opts) {
|
||||
|
||||
// ── 就绪探测 ────────────────────────────────────────────────────
|
||||
|
||||
/** 通用 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); });
|
||||
/** Bridge ping 探活:能响应只代表扩展已启动,不代表 AssetDB 已就绪。 */
|
||||
function probeReady(entry) {
|
||||
return bridgeRpc.bridgeJsonRpc(entry.url, {
|
||||
jsonrpc: '2.0', id: 1, method: 'bridge/ping', params: {},
|
||||
}, {
|
||||
authToken: entry.authToken,
|
||||
timeoutMs: 4000,
|
||||
}).then(function (r) {
|
||||
return !!(r && !r.error && r.result && r.result.bridgeApiVersion === 1);
|
||||
}).catch(function () { return false; });
|
||||
}
|
||||
|
||||
/**
|
||||
* 项目就绪探测:MCP initialize 成功 ≠ 进了项目 —— 实测激进清登录态后 initialize 仍 ready,
|
||||
* 项目就绪探测:Bridge ping 成功 ≠ 进了项目 —— 扩展已启动时项目仍可能在加载,
|
||||
* 但编辑器 UI 卡在登录页。用 asset_query_assets 查 db://assets/* 探 asset-db 是否就绪:
|
||||
* 项目真打开才加载 asset-db、返回非空顶层资源;登录页 / 项目加载中则空或失败。
|
||||
* 正向(进项目非空)已实测;负向(登录页态返回啥)按逻辑推断,未在登录页态实测。
|
||||
*/
|
||||
function probeProjectReady(url) {
|
||||
return httpMcp(url, 'tools/call', {
|
||||
name: 'asset_query_assets', arguments: { pattern: 'db://assets/*' },
|
||||
}, 6000).then(function (r) {
|
||||
function probeProjectReady(entry) {
|
||||
return bridgeRpc.bridgeJsonRpc(entry.url, {
|
||||
jsonrpc: '2.0', id: 2, method: 'bridge/invoke',
|
||||
params: { name: 'asset_query_assets', arguments: { pattern: 'db://assets/*' } },
|
||||
}, {
|
||||
authToken: entry.authToken,
|
||||
timeoutMs: 6000,
|
||||
}).then(function (r) {
|
||||
return hasReadyAssetResult(r);
|
||||
});
|
||||
}).catch(function () { return false; });
|
||||
}
|
||||
|
||||
function hasReadyAssetResult(r) {
|
||||
@@ -537,7 +517,7 @@ function hasReadyAssetText(txt) {
|
||||
|
||||
/**
|
||||
* 轮询等指定项目的编辑器就绪。
|
||||
* 就绪判定:注册表有 projectPath 匹配、非 stale、pid≠excludePid 的 entry,且 probeReady 成功。
|
||||
* 就绪判定:注册表有 projectPath 匹配、非 stale、pid≠excludePid 的 entry,且 Bridge 探活成功。
|
||||
* excludePid:restart 时传被 kill 的旧 pid,避免匹配到尚未删净的旧注册。
|
||||
* 返回 { ready, entry?, reason?, waitedMs }。
|
||||
*/
|
||||
@@ -545,10 +525,10 @@ 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 requireProject = opts.requireProject !== false; // 默认要求项目就绪(区分登录页/加载中),传 false 只看 Bridge
|
||||
var start = Date.now();
|
||||
var lastReason = 'still waiting';
|
||||
var sawMcp = false;
|
||||
var sawBridge = false;
|
||||
|
||||
while (Date.now() - start < timeoutMs) {
|
||||
var hit = activeEditors().filter(function (e) {
|
||||
@@ -556,13 +536,13 @@ async function waitReady(projectPath, opts) {
|
||||
})[0];
|
||||
|
||||
if (hit) {
|
||||
var mcpOk = await probeReady(hit.url);
|
||||
if (mcpOk) {
|
||||
sawMcp = true;
|
||||
var projOk = requireProject ? await probeProjectReady(hit.url) : true;
|
||||
var bridgeOk = await probeReady(hit);
|
||||
if (bridgeOk) {
|
||||
sawBridge = true;
|
||||
var projOk = requireProject ? await probeProjectReady(hit) : true;
|
||||
if (projOk) {
|
||||
return {
|
||||
ready: true, mcpReady: true, projectReady: projOk,
|
||||
ready: true, bridgeReady: true, projectReady: projOk,
|
||||
entry: {
|
||||
shortName: sanitize(hit.projectShortName),
|
||||
pid: hit.pid, url: hit.url, port: hit.port,
|
||||
@@ -571,18 +551,18 @@ async function waitReady(projectPath, opts) {
|
||||
waitedMs: Date.now() - start,
|
||||
};
|
||||
}
|
||||
lastReason = 'MCP up (pid=' + hit.pid + ') 但 asset-db 未就绪,可能卡在 Cocos Developer Login 或项目加载中';
|
||||
lastReason = 'Bridge up (pid=' + hit.pid + ') 但 asset-db 未就绪,可能卡在 Cocos Developer Login 或项目加载中';
|
||||
} else {
|
||||
lastReason = 'registered (pid=' + hit.pid + ') but MCP server not responding yet';
|
||||
lastReason = 'registered (pid=' + hit.pid + ') but Editor Bridge 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 起来了但项目没就绪。若是 router 拉起/重启编辑器,请确认 spawnArgs 包含 --nologin;若是手动拉起,可能卡在 Cocos Developer Login 或仍在加载项目。';
|
||||
var res = { ready: false, bridgeReady: sawBridge, projectReady: false, reason: lastReason, waitedMs: Date.now() - start };
|
||||
if (sawBridge) {
|
||||
res.hint = '⚠️ Editor Bridge 起来了但项目没就绪。若是 Gateway 拉起/重启编辑器,请确认 spawnArgs 包含 --nologin;若是手动拉起,可能卡在 Cocos Developer Login 或仍在加载项目。';
|
||||
}
|
||||
return res;
|
||||
}
|
||||
@@ -619,7 +599,7 @@ var EDITOR_TOOLS = [
|
||||
},
|
||||
{
|
||||
name: 'editor_wait_ready',
|
||||
description: '[editor] 等指定项目的 Cocos 编辑器就绪(注册文件出现且 MCP server 能 initialize)。' +
|
||||
description: '[editor] 等指定项目的 Cocos 编辑器就绪(注册文件出现且 Editor Bridge 可探活)。' +
|
||||
'用于「拉起编辑器后等它起来再操作」。已就绪则立即返回。' +
|
||||
'编辑器尚未运行时必须传 projectPath(空注册表无法从 shortName 反推路径)。',
|
||||
inputSchema: {
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
'use strict';
|
||||
|
||||
const http = require('http');
|
||||
|
||||
const DEFAULT_PROBE_TIMEOUT_MS = 8000;
|
||||
const DEFAULT_TOOL_TIMEOUT_MS = 60000;
|
||||
const DEFAULT_LONG_TOOL_TIMEOUT_MS = 180000;
|
||||
|
||||
function positiveEnvInt(name, fallback) {
|
||||
const raw = process.env[name];
|
||||
if (!raw) return fallback;
|
||||
const value = Number(raw);
|
||||
return Number.isFinite(value) && value > 0 ? Math.floor(value) : fallback;
|
||||
}
|
||||
|
||||
const PROBE_TIMEOUT_MS = positiveEnvInt('COCOS_MCP_PROBE_TIMEOUT_MS', DEFAULT_PROBE_TIMEOUT_MS);
|
||||
const TOOL_TIMEOUT_MS = positiveEnvInt('COCOS_MCP_TOOL_TIMEOUT_MS', DEFAULT_TOOL_TIMEOUT_MS);
|
||||
const LONG_TOOL_TIMEOUT_MS = positiveEnvInt('COCOS_MCP_LONG_TOOL_TIMEOUT_MS', DEFAULT_LONG_TOOL_TIMEOUT_MS);
|
||||
|
||||
function isLoopbackUrl(rawUrl, expectedPath) {
|
||||
try {
|
||||
const parsed = new URL(rawUrl);
|
||||
const host = parsed.hostname.toLowerCase();
|
||||
const isLoopback = host === '127.0.0.1' || host === 'localhost' || host === '[::1]' || host === '::1';
|
||||
const port = Number(parsed.port);
|
||||
return parsed.protocol === 'http:'
|
||||
&& isLoopback
|
||||
&& Number.isInteger(port) && port > 0 && port <= 65535
|
||||
&& parsed.pathname === expectedPath
|
||||
&& !parsed.username && !parsed.password
|
||||
&& !parsed.search && !parsed.hash;
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function isLoopbackBridgeUrl(rawUrl) {
|
||||
return isLoopbackUrl(rawUrl, '/bridge');
|
||||
}
|
||||
|
||||
function isPidAlive(pid) {
|
||||
if (!Number.isInteger(Number(pid)) || Number(pid) <= 0) return false;
|
||||
try {
|
||||
process.kill(Number(pid), 0);
|
||||
return true;
|
||||
} catch (e) {
|
||||
return e && e.code === 'EPERM';
|
||||
}
|
||||
}
|
||||
|
||||
function validateRegistryEntry(info) {
|
||||
if (!info || typeof info !== 'object') return 'registry entry must be an object';
|
||||
if (!isPidAlive(info.pid)) return 'editor pid is not alive';
|
||||
if (typeof info.projectPath !== 'string' || !info.projectPath) return 'projectPath is required';
|
||||
if (typeof info.projectShortName !== 'string' || !info.projectShortName) return 'projectShortName is required';
|
||||
if (info.transport !== 'editor-bridge') return 'transport must be editor-bridge';
|
||||
if (!isLoopbackBridgeUrl(info.url)) return 'editor-bridge endpoint must be http://loopback:<port>/bridge';
|
||||
if (Number(info.gatewayApiVersion || 0) < 2) return 'editor-bridge requires gatewayApiVersion >= 2';
|
||||
if (Number(info.bridgeApiVersion || 0) !== 1) return 'unsupported bridgeApiVersion';
|
||||
if (!/^[0-9a-f]{64}$/i.test(info.authToken || '')) return 'editor-bridge authToken must be a 32-byte hex token';
|
||||
return null;
|
||||
}
|
||||
|
||||
function bridgeJsonRpc(targetUrl, body, options) {
|
||||
options = options || {};
|
||||
const timeoutMs = options.timeoutMs || PROBE_TIMEOUT_MS;
|
||||
|
||||
return new Promise(function (resolve, reject) {
|
||||
try {
|
||||
if (!isLoopbackBridgeUrl(targetUrl)) {
|
||||
reject(new Error('refusing invalid loopback Editor Bridge endpoint'));
|
||||
return;
|
||||
}
|
||||
const u = new URL(targetUrl);
|
||||
const data = JSON.stringify(body);
|
||||
const headers = {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
'Content-Length': Buffer.byteLength(data),
|
||||
'X-Cocos-Bridge-Version': '1',
|
||||
};
|
||||
if (options.authToken) headers.Authorization = 'Bearer ' + options.authToken;
|
||||
|
||||
const req = http.request({
|
||||
hostname: u.hostname,
|
||||
port: u.port,
|
||||
path: u.pathname,
|
||||
method: 'POST',
|
||||
headers,
|
||||
timeout: timeoutMs,
|
||||
}, function (res) {
|
||||
const chunks = [];
|
||||
res.on('data', function (c) { chunks.push(c); });
|
||||
res.on('end', function () {
|
||||
const raw = Buffer.concat(chunks).toString('utf-8');
|
||||
if (res.statusCode < 200 || res.statusCode >= 300) {
|
||||
reject(new Error('HTTP ' + res.statusCode + ' from Editor Bridge endpoint: ' + raw.slice(0, 160)));
|
||||
return;
|
||||
}
|
||||
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 after ' + timeoutMs + 'ms')); });
|
||||
req.write(data);
|
||||
req.end();
|
||||
} catch (e) { reject(e); }
|
||||
});
|
||||
}
|
||||
|
||||
function toolTimeoutMs(name) {
|
||||
return /(?:asset_refresh|asset_reimport|preview_refresh_and_reload|scene_open_scene|scene_save_scene)$/.test(name || '')
|
||||
? LONG_TOOL_TIMEOUT_MS
|
||||
: TOOL_TIMEOUT_MS;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
PROBE_TIMEOUT_MS,
|
||||
TOOL_TIMEOUT_MS,
|
||||
LONG_TOOL_TIMEOUT_MS,
|
||||
isLoopbackUrl,
|
||||
isLoopbackBridgeUrl,
|
||||
isPidAlive,
|
||||
validateRegistryEntry,
|
||||
bridgeJsonRpc,
|
||||
toolTimeoutMs,
|
||||
};
|
||||
@@ -0,0 +1,123 @@
|
||||
'use strict';
|
||||
|
||||
const { test, before, after } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const readline = require('node:readline');
|
||||
const { spawn } = require('node:child_process');
|
||||
|
||||
const bridgeModule = require('../../server/editor-bridge.js');
|
||||
|
||||
let tempHome;
|
||||
let bridge;
|
||||
let gateway;
|
||||
let nextId = 1;
|
||||
const pending = new Map();
|
||||
|
||||
function callGateway(method, params) {
|
||||
const id = nextId++;
|
||||
return new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
pending.delete(id);
|
||||
reject(new Error(`gateway response timeout: ${method}`));
|
||||
}, 5000);
|
||||
pending.set(id, { resolve, reject, timer });
|
||||
gateway.stdin.write(JSON.stringify({ jsonrpc: '2.0', id, method, params: params || {} }) + '\n');
|
||||
});
|
||||
}
|
||||
|
||||
before(async () => {
|
||||
tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'cocos-mcp-gateway-test-'));
|
||||
const token = 'f'.repeat(64);
|
||||
bridge = bridgeModule.createEditorBridge({
|
||||
name: 'integration-bridge',
|
||||
version: '3.0.0',
|
||||
port: 0,
|
||||
authToken: token,
|
||||
tools: [{
|
||||
name: 'echo',
|
||||
description: 'echo integration args',
|
||||
inputSchema: { type: 'object' },
|
||||
handler: async (args) => ({ echoed: args }),
|
||||
}],
|
||||
resources: [{
|
||||
uri: 'cocos://integration',
|
||||
name: 'integration resource',
|
||||
mimeType: 'application/json',
|
||||
read: async () => ({ integrated: true }),
|
||||
}],
|
||||
});
|
||||
await bridge.start();
|
||||
|
||||
const registryDir = path.join(tempHome, '.cocos-mcp', 'editors');
|
||||
fs.mkdirSync(registryDir, { recursive: true, mode: 0o700 });
|
||||
fs.writeFileSync(path.join(registryDir, `${process.pid}.json`), JSON.stringify({
|
||||
pid: process.pid,
|
||||
projectPath: '/tmp/integration-project',
|
||||
projectShortName: 'integration',
|
||||
url: `http://127.0.0.1:${bridge.port}/bridge`,
|
||||
transport: 'editor-bridge',
|
||||
extensionVersion: '3.0.0',
|
||||
gatewayApiVersion: 2,
|
||||
bridgeApiVersion: 1,
|
||||
authToken: token,
|
||||
startedAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
}, null, 2), { mode: 0o600 });
|
||||
|
||||
gateway = spawn(process.execPath, [path.join(__dirname, '..', 'bin.js')], {
|
||||
env: Object.assign({}, process.env, { HOME: tempHome }),
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
});
|
||||
gateway.on('exit', (code) => {
|
||||
for (const item of pending.values()) {
|
||||
clearTimeout(item.timer);
|
||||
item.reject(new Error(`gateway exited early: ${code}`));
|
||||
}
|
||||
pending.clear();
|
||||
});
|
||||
const lines = readline.createInterface({ input: gateway.stdout });
|
||||
lines.on('line', (line) => {
|
||||
let message;
|
||||
try { message = JSON.parse(line); } catch (error) { return; }
|
||||
const item = pending.get(message.id);
|
||||
if (!item) return;
|
||||
pending.delete(message.id);
|
||||
clearTimeout(item.timer);
|
||||
if (message.error) item.reject(new Error(message.error.message));
|
||||
else item.resolve(message.result);
|
||||
});
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
if (gateway && gateway.exitCode == null) gateway.kill('SIGTERM');
|
||||
if (bridge) await bridge.stop();
|
||||
if (tempHome) fs.rmSync(tempHome, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test('Gateway 通过 Editor Bridge 聚合并转发 tool/resource', async () => {
|
||||
const initialized = await callGateway('initialize', {
|
||||
protocolVersion: '2025-06-18',
|
||||
clientInfo: { name: 'integration-test', version: '1' },
|
||||
capabilities: {},
|
||||
});
|
||||
assert.equal(initialized.serverInfo.name, 'cocos-mcp-gateway');
|
||||
|
||||
const listed = await callGateway('tools/list');
|
||||
assert.equal(listed.tools.some((tool) => tool.name === 'integration__echo'), true);
|
||||
|
||||
const called = await callGateway('tools/call', {
|
||||
name: 'integration__echo',
|
||||
arguments: { value: 9 },
|
||||
});
|
||||
assert.match(called.content[0].text, /"value": 9/);
|
||||
|
||||
const resources = await callGateway('resources/list');
|
||||
const resource = resources.resources.find((item) => item.name.includes('integration resource'));
|
||||
assert.ok(resource);
|
||||
|
||||
const read = await callGateway('resources/read', { uri: resource.uri });
|
||||
assert.match(read.contents[0].text, /"integrated": true/);
|
||||
});
|
||||
@@ -0,0 +1,104 @@
|
||||
'use strict';
|
||||
|
||||
const { test, before, after } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const http = require('node:http');
|
||||
const { once } = require('node:events');
|
||||
|
||||
const rpc = require('../src/http-json-rpc.js');
|
||||
|
||||
let server;
|
||||
let bridgeUrl;
|
||||
let lastRequest;
|
||||
|
||||
before(async () => {
|
||||
server = http.createServer((req, res) => {
|
||||
const chunks = [];
|
||||
req.on('data', (chunk) => chunks.push(chunk));
|
||||
req.on('end', () => {
|
||||
const body = JSON.parse(Buffer.concat(chunks).toString('utf8'));
|
||||
lastRequest = { headers: req.headers, body };
|
||||
if (body.method === 'slow') {
|
||||
setTimeout(() => {
|
||||
if (!res.writableEnded) {
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ jsonrpc: '2.0', id: body.id, result: {} }));
|
||||
}
|
||||
}, 100);
|
||||
return;
|
||||
}
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ jsonrpc: '2.0', id: body.id, result: { ok: true } }));
|
||||
});
|
||||
});
|
||||
server.listen(0, '127.0.0.1');
|
||||
await once(server, 'listening');
|
||||
bridgeUrl = `http://127.0.0.1:${server.address().port}/bridge`;
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
if (!server) return;
|
||||
await new Promise((resolve) => server.close(resolve));
|
||||
});
|
||||
|
||||
test('Editor Bridge 只接受带显式端口的 loopback /bridge URL', () => {
|
||||
assert.equal(rpc.isLoopbackBridgeUrl('http://127.0.0.1:7523/bridge'), true);
|
||||
assert.equal(rpc.isLoopbackBridgeUrl('http://localhost:7523/bridge'), true);
|
||||
assert.equal(rpc.isLoopbackBridgeUrl('http://127.0.0.1:7523/mcp'), false);
|
||||
assert.equal(rpc.isLoopbackBridgeUrl('https://127.0.0.1:7523/bridge'), false);
|
||||
assert.equal(rpc.isLoopbackBridgeUrl('http://192.168.1.2:7523/bridge'), false);
|
||||
});
|
||||
|
||||
test('gateway v2 editor-bridge registry 必须声明 bridge 版本、loopback 地址和 token', () => {
|
||||
const base = {
|
||||
url: bridgeUrl,
|
||||
transport: 'editor-bridge',
|
||||
pid: process.pid,
|
||||
projectPath: '/tmp/project',
|
||||
projectShortName: 'project',
|
||||
gatewayApiVersion: 2,
|
||||
bridgeApiVersion: 1,
|
||||
authToken: 'd'.repeat(64),
|
||||
};
|
||||
assert.equal(rpc.validateRegistryEntry(base), null);
|
||||
assert.match(rpc.validateRegistryEntry(Object.assign({}, base, { url: 'http://127.0.0.1:7523/mcp' })), /bridge/);
|
||||
assert.match(rpc.validateRegistryEntry(Object.assign({}, base, { transport: 'mcp' })), /transport/);
|
||||
assert.match(rpc.validateRegistryEntry(Object.assign({}, base, { bridgeApiVersion: 2 })), /bridgeApiVersion/);
|
||||
assert.match(rpc.validateRegistryEntry(Object.assign({}, base, { gatewayApiVersion: 1 })), /gatewayApiVersion/);
|
||||
assert.match(rpc.validateRegistryEntry(Object.assign({}, base, { authToken: '' })), /authToken/);
|
||||
assert.match(rpc.validateRegistryEntry(Object.assign({}, base, { pid: 99999999 })), /not alive/);
|
||||
});
|
||||
|
||||
test('Bridge 转发只携带私有 Bridge 版本和 Authorization,不伪装 MCP 下游', async () => {
|
||||
const result = await rpc.bridgeJsonRpc(bridgeUrl, {
|
||||
jsonrpc: '2.0', id: 11, method: 'bridge/invoke',
|
||||
params: { name: 'scene_query_node', arguments: {} },
|
||||
}, {
|
||||
authToken: 'e'.repeat(64),
|
||||
timeoutMs: 1000,
|
||||
});
|
||||
|
||||
assert.deepEqual(result.result, { ok: true });
|
||||
assert.equal(lastRequest.headers['x-cocos-bridge-version'], '1');
|
||||
assert.equal(lastRequest.headers['mcp-protocol-version'], undefined);
|
||||
assert.equal(lastRequest.headers['mcp-method'], undefined);
|
||||
assert.equal(lastRequest.headers.authorization, `Bearer ${'e'.repeat(64)}`);
|
||||
});
|
||||
|
||||
test('拒绝非 loopback Bridge 目标,并对下游超时给出明确错误', async () => {
|
||||
await assert.rejects(
|
||||
rpc.bridgeJsonRpc('http://example.com:80/bridge', { jsonrpc: '2.0', id: 2, method: 'bridge/ping' }),
|
||||
/invalid loopback/
|
||||
);
|
||||
await assert.rejects(
|
||||
rpc.bridgeJsonRpc(bridgeUrl, { jsonrpc: '2.0', id: 3, method: 'slow' }, { timeoutMs: 20 }),
|
||||
/timeout after 20ms/
|
||||
);
|
||||
});
|
||||
|
||||
test('资源刷新和场景保存类操作使用长超时', () => {
|
||||
assert.equal(rpc.toolTimeoutMs('asset_refresh'), rpc.LONG_TOOL_TIMEOUT_MS);
|
||||
assert.equal(rpc.toolTimeoutMs('preview_refresh_and_reload'), rpc.LONG_TOOL_TIMEOUT_MS);
|
||||
assert.equal(rpc.toolTimeoutMs('scene_save_scene'), rpc.LONG_TOOL_TIMEOUT_MS);
|
||||
assert.equal(rpc.toolTimeoutMs('scene_query_node'), rpc.TOOL_TIMEOUT_MS);
|
||||
});
|
||||
Reference in New Issue
Block a user