[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)');
+37 -57
View File
@@ -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 探活成功。
* excludePidrestart 时传被 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: {
+128
View File
@@ -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/);
});
+104
View File
@@ -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);
});