[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
+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,
};