[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
@@ -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);
});