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:
@@ -0,0 +1,283 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Cocos Editor Bridge
|
||||
*
|
||||
* 这是 Gateway 与 Cocos 编辑器扩展之间的本机私有 JSON-RPC 协议,不是 MCP Server。
|
||||
* MCP 握手、工具聚合和客户端兼容全部由全局 cocos-mcp-gateway 负责。
|
||||
*/
|
||||
|
||||
var http = require('http');
|
||||
var crypto = require('crypto');
|
||||
|
||||
var BRIDGE_API_VERSION = 1;
|
||||
var DEFAULT_MAX_BODY_BYTES = 4 * 1024 * 1024;
|
||||
|
||||
function textContent(text) {
|
||||
return { type: 'text', text: String(text) };
|
||||
}
|
||||
|
||||
function wrapContent(result) {
|
||||
if (result === undefined || result === null) return [textContent('(ok)')];
|
||||
if (typeof result === 'string') return [textContent(result)];
|
||||
if (Array.isArray(result) && result[0] && result[0].type) return result;
|
||||
if (result && result.type && result.text !== undefined) return [result];
|
||||
return [textContent(JSON.stringify(result, null, 2))];
|
||||
}
|
||||
|
||||
function normalizeToolResult(result) {
|
||||
if (result && typeof result === 'object' && !Array.isArray(result)) {
|
||||
var out = {};
|
||||
if (result.content !== undefined) out.content = result.content;
|
||||
if (result.structuredContent !== undefined) out.structuredContent = result.structuredContent;
|
||||
if (result._meta !== undefined) out._meta = result._meta;
|
||||
if (result.isError !== undefined) out.isError = result.isError;
|
||||
if (Object.keys(out).length > 0) {
|
||||
if (!out.content) out.content = wrapContent(out.structuredContent || '(ok)');
|
||||
return out;
|
||||
}
|
||||
}
|
||||
return { content: wrapContent(result) };
|
||||
}
|
||||
|
||||
function toolSchema(tool) {
|
||||
var schema = {
|
||||
name: tool.name,
|
||||
description: tool.description || '',
|
||||
inputSchema: tool.inputSchema || { type: 'object', properties: {} },
|
||||
};
|
||||
if (tool.outputSchema) schema.outputSchema = tool.outputSchema;
|
||||
if (tool.annotations) schema.annotations = tool.annotations;
|
||||
if (tool._meta) schema._meta = tool._meta;
|
||||
return schema;
|
||||
}
|
||||
|
||||
function resourceSchema(resource) {
|
||||
return {
|
||||
uri: resource.uri,
|
||||
name: resource.name || resource.uri,
|
||||
description: resource.description || '',
|
||||
mimeType: resource.mimeType || 'text/plain',
|
||||
};
|
||||
}
|
||||
|
||||
function safeTokenEquals(actual, token) {
|
||||
var actualBuffer = Buffer.from(String(actual || ''));
|
||||
var expectedBuffer = Buffer.from('Bearer ' + token);
|
||||
return actualBuffer.length === expectedBuffer.length
|
||||
&& crypto.timingSafeEqual(actualBuffer, expectedBuffer);
|
||||
}
|
||||
|
||||
function createEditorBridge(options) {
|
||||
options = options || {};
|
||||
var name = options.name || 'cocos-mcp-editor-bridge';
|
||||
var version = options.version || '1.0.0';
|
||||
var host = options.host || '127.0.0.1';
|
||||
var port = options.port === undefined ? 7523 : options.port;
|
||||
var bridgePath = options.path || '/bridge';
|
||||
var authToken = options.authToken || '';
|
||||
var tools = options.tools || [];
|
||||
var resources = options.resources || [];
|
||||
var maxBodyBytes = Number.isFinite(options.maxBodyBytes) && options.maxBodyBytes > 0
|
||||
? Math.floor(options.maxBodyBytes)
|
||||
: DEFAULT_MAX_BODY_BYTES;
|
||||
var server = null;
|
||||
|
||||
function response(id, result) {
|
||||
return { jsonrpc: '2.0', id: id, result: result };
|
||||
}
|
||||
|
||||
function errorResponse(id, code, message) {
|
||||
return { jsonrpc: '2.0', id: id === undefined ? null : id, error: { code: code, message: message } };
|
||||
}
|
||||
|
||||
async function dispatch(message) {
|
||||
if (!message || message.jsonrpc !== '2.0' || typeof message.method !== 'string') {
|
||||
return errorResponse(message && message.id, -32600, 'Invalid Request');
|
||||
}
|
||||
|
||||
var params = message.params || {};
|
||||
switch (message.method) {
|
||||
case 'bridge/ping':
|
||||
return response(message.id, { bridgeApiVersion: BRIDGE_API_VERSION });
|
||||
case 'bridge/describe':
|
||||
return response(message.id, {
|
||||
bridgeApiVersion: BRIDGE_API_VERSION,
|
||||
serverInfo: { name: name, version: version },
|
||||
tools: tools.map(toolSchema),
|
||||
resources: resources.map(resourceSchema),
|
||||
});
|
||||
case 'bridge/invoke': {
|
||||
if (!params.name) return errorResponse(message.id, -32602, 'Missing tool name');
|
||||
var tool = tools.find(function (item) { return item.name === params.name; });
|
||||
if (!tool) {
|
||||
return response(message.id, {
|
||||
content: [textContent('Tool not found: ' + params.name)],
|
||||
isError: true,
|
||||
});
|
||||
}
|
||||
try {
|
||||
var result = await tool.handler(params.arguments || {});
|
||||
return response(message.id, normalizeToolResult(result));
|
||||
} catch (e) {
|
||||
return response(message.id, {
|
||||
content: [textContent('[' + params.name + '] Error: ' + (e.message || e))],
|
||||
isError: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
case 'bridge/read-resource': {
|
||||
if (!params.uri) return errorResponse(message.id, -32602, 'Missing resource uri');
|
||||
var resource = resources.find(function (item) { return item.uri === params.uri; });
|
||||
if (!resource) {
|
||||
return response(message.id, {
|
||||
contents: [textContent('Resource not found: ' + params.uri)],
|
||||
});
|
||||
}
|
||||
try {
|
||||
var data = await resource.read();
|
||||
var text = typeof data === 'string' ? data : JSON.stringify(data, null, 2);
|
||||
return response(message.id, {
|
||||
contents: [{
|
||||
type: 'text',
|
||||
text: text,
|
||||
mimeType: resource.mimeType || 'text/plain',
|
||||
}],
|
||||
});
|
||||
} catch (e) {
|
||||
return response(message.id, {
|
||||
contents: [textContent('[' + params.uri + '] Error: ' + (e.message || e))],
|
||||
});
|
||||
}
|
||||
}
|
||||
default:
|
||||
return errorResponse(message.id, -32601, 'Method not found: ' + message.method);
|
||||
}
|
||||
}
|
||||
|
||||
function sendJson(res, statusCode, body) {
|
||||
var data = JSON.stringify(body);
|
||||
res.writeHead(statusCode, {
|
||||
'Content-Type': 'application/json',
|
||||
'Content-Length': Buffer.byteLength(data),
|
||||
'Cache-Control': 'no-store',
|
||||
});
|
||||
res.end(data);
|
||||
}
|
||||
|
||||
function handle(req, res) {
|
||||
var pathname;
|
||||
try { pathname = new URL(req.url, 'http://127.0.0.1').pathname; }
|
||||
catch (e) { pathname = ''; }
|
||||
|
||||
if (pathname === '/' && req.method === 'GET') {
|
||||
sendJson(res, 200, {
|
||||
status: 'ok',
|
||||
name: name,
|
||||
version: version,
|
||||
bridgeApiVersion: BRIDGE_API_VERSION,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (pathname !== bridgePath) {
|
||||
sendJson(res, 404, errorResponse(null, -32600, 'Not found'));
|
||||
return;
|
||||
}
|
||||
if (req.headers.origin) {
|
||||
sendJson(res, 403, errorResponse(null, -32600, 'Browser Origin is not allowed'));
|
||||
return;
|
||||
}
|
||||
if (!authToken || !safeTokenEquals(req.headers.authorization, authToken)) {
|
||||
sendJson(res, 401, errorResponse(null, -32001, 'Unauthorized'));
|
||||
return;
|
||||
}
|
||||
if (req.method !== 'POST') {
|
||||
sendJson(res, 405, errorResponse(null, -32600, 'Method not allowed, use POST'));
|
||||
return;
|
||||
}
|
||||
var contentType = String(req.headers['content-type'] || '').toLowerCase();
|
||||
if (!contentType.startsWith('application/json')) {
|
||||
sendJson(res, 415, errorResponse(null, -32600, 'Content-Type must be application/json'));
|
||||
return;
|
||||
}
|
||||
|
||||
var chunks = [];
|
||||
var bodyBytes = 0;
|
||||
var bodyTooLarge = false;
|
||||
req.on('data', function (chunk) {
|
||||
bodyBytes += chunk.length;
|
||||
if (bodyBytes > maxBodyBytes) {
|
||||
bodyTooLarge = true;
|
||||
return;
|
||||
}
|
||||
chunks.push(chunk);
|
||||
});
|
||||
req.on('end', function () {
|
||||
if (bodyTooLarge) {
|
||||
sendJson(res, 413, errorResponse(null, -32600, 'Request body exceeds ' + maxBodyBytes + ' bytes'));
|
||||
return;
|
||||
}
|
||||
var message;
|
||||
try { message = JSON.parse(Buffer.concat(chunks).toString('utf-8')); }
|
||||
catch (e) {
|
||||
sendJson(res, 400, errorResponse(null, -32700, 'Parse error'));
|
||||
return;
|
||||
}
|
||||
dispatch(message).then(function (result) {
|
||||
sendJson(res, 200, result);
|
||||
}).catch(function (e) {
|
||||
sendJson(res, 500, errorResponse(message.id, -32603, 'Internal error: ' + (e.message || e)));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function start() {
|
||||
if (server) return Promise.resolve(api);
|
||||
return new Promise(function (resolve, reject) {
|
||||
var candidate = http.createServer(handle);
|
||||
function onError(error) {
|
||||
candidate.removeListener('listening', onListening);
|
||||
reject(error);
|
||||
}
|
||||
function onListening() {
|
||||
candidate.removeListener('error', onError);
|
||||
candidate.on('error', function (error) {
|
||||
console.error('[cocos-mcp-editor-bridge] HTTP server error:', error.message || error);
|
||||
});
|
||||
server = candidate;
|
||||
var address = server.address();
|
||||
if (address && typeof address === 'object') port = address.port;
|
||||
resolve(api);
|
||||
}
|
||||
candidate.once('error', onError);
|
||||
candidate.once('listening', onListening);
|
||||
candidate.listen(port, host);
|
||||
});
|
||||
}
|
||||
|
||||
function stop() {
|
||||
if (!server) return Promise.resolve();
|
||||
var closing = server;
|
||||
server = null;
|
||||
return new Promise(function (resolve) {
|
||||
closing.close(function () { resolve(); });
|
||||
});
|
||||
}
|
||||
|
||||
var api = {
|
||||
start: start,
|
||||
stop: stop,
|
||||
dispatch: dispatch,
|
||||
get host() { return host; },
|
||||
get port() { return port; },
|
||||
path: bridgePath,
|
||||
bridgeApiVersion: BRIDGE_API_VERSION,
|
||||
};
|
||||
return api;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
BRIDGE_API_VERSION: BRIDGE_API_VERSION,
|
||||
createEditorBridge: createEditorBridge,
|
||||
normalizeToolResult: normalizeToolResult,
|
||||
};
|
||||
@@ -0,0 +1,33 @@
|
||||
'use strict';
|
||||
|
||||
var DEV_RELOAD_INFO_KEYS = [
|
||||
'projectPath',
|
||||
'projectName',
|
||||
'editorPid',
|
||||
'editorVersion',
|
||||
'previewUrl',
|
||||
'previewPort',
|
||||
];
|
||||
|
||||
function isSameDevReloadInfo(previous, next) {
|
||||
if (!previous || !next) return false;
|
||||
return DEV_RELOAD_INFO_KEYS.every(function (key) {
|
||||
return previous[key] === next[key];
|
||||
});
|
||||
}
|
||||
|
||||
function isProcessAlive(pid) {
|
||||
var value = Number(pid);
|
||||
if (!Number.isInteger(value) || value <= 0) return false;
|
||||
try {
|
||||
process.kill(value, 0);
|
||||
return true;
|
||||
} catch (e) {
|
||||
return !!(e && e.code === 'EPERM');
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
isSameDevReloadInfo: isSameDevReloadInfo,
|
||||
isProcessAlive: isProcessAlive,
|
||||
};
|
||||
@@ -0,0 +1,119 @@
|
||||
'use strict';
|
||||
|
||||
const { test, before, after } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const http = require('node:http');
|
||||
|
||||
const bridgeModule = require('../editor-bridge.js');
|
||||
|
||||
let bridge;
|
||||
let bridgeUrl;
|
||||
const token = 'a'.repeat(64);
|
||||
|
||||
function post(body, options) {
|
||||
options = options || {};
|
||||
return new Promise((resolve, reject) => {
|
||||
const url = new URL(bridgeUrl);
|
||||
const data = options.rawBody === undefined ? JSON.stringify(body) : options.rawBody;
|
||||
const headers = {
|
||||
'Content-Type': options.contentType || 'application/json',
|
||||
'Content-Length': Buffer.byteLength(data),
|
||||
};
|
||||
if (options.authorized !== false) headers.Authorization = `Bearer ${token}`;
|
||||
if (options.origin) headers.Origin = options.origin;
|
||||
const req = http.request({
|
||||
hostname: url.hostname,
|
||||
port: url.port,
|
||||
path: url.pathname,
|
||||
method: 'POST',
|
||||
headers,
|
||||
}, (res) => {
|
||||
const chunks = [];
|
||||
res.on('data', (chunk) => chunks.push(chunk));
|
||||
res.on('end', () => {
|
||||
const raw = Buffer.concat(chunks).toString('utf8');
|
||||
resolve({ statusCode: res.statusCode, body: JSON.parse(raw) });
|
||||
});
|
||||
});
|
||||
req.on('error', reject);
|
||||
req.end(data);
|
||||
});
|
||||
}
|
||||
|
||||
before(async () => {
|
||||
bridge = bridgeModule.createEditorBridge({
|
||||
name: 'bridge-test',
|
||||
version: '3.0.0',
|
||||
port: 0,
|
||||
authToken: token,
|
||||
maxBodyBytes: 256,
|
||||
tools: [
|
||||
{
|
||||
name: 'echo',
|
||||
description: 'echo args',
|
||||
inputSchema: { type: 'object' },
|
||||
handler: async (args) => args,
|
||||
},
|
||||
{
|
||||
name: 'fail',
|
||||
handler: async () => { throw new Error('expected failure'); },
|
||||
},
|
||||
],
|
||||
resources: [
|
||||
{
|
||||
uri: 'cocos://test',
|
||||
name: 'test resource',
|
||||
mimeType: 'application/json',
|
||||
read: async () => ({ ok: true }),
|
||||
},
|
||||
],
|
||||
});
|
||||
await bridge.start();
|
||||
bridgeUrl = `http://127.0.0.1:${bridge.port}/bridge`;
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
if (bridge) await bridge.stop();
|
||||
});
|
||||
|
||||
test('describe 一次返回 Bridge 版本、tools 和 resources', async () => {
|
||||
const res = await post({ jsonrpc: '2.0', id: 1, method: 'bridge/describe', params: {} });
|
||||
assert.equal(res.statusCode, 200);
|
||||
assert.equal(res.body.result.bridgeApiVersion, 1);
|
||||
assert.equal(res.body.result.serverInfo.name, 'bridge-test');
|
||||
assert.deepEqual(res.body.result.tools.map((tool) => tool.name), ['echo', 'fail']);
|
||||
assert.deepEqual(res.body.result.resources.map((resource) => resource.uri), ['cocos://test']);
|
||||
});
|
||||
|
||||
test('invoke 保持原 MCP tool result 契约,并把异常变成 isError', async () => {
|
||||
const ok = await post({
|
||||
jsonrpc: '2.0', id: 2, method: 'bridge/invoke',
|
||||
params: { name: 'echo', arguments: { value: 7 } },
|
||||
});
|
||||
assert.equal(ok.body.result.isError, undefined);
|
||||
assert.match(ok.body.result.content[0].text, /"value": 7/);
|
||||
|
||||
const failed = await post({
|
||||
jsonrpc: '2.0', id: 3, method: 'bridge/invoke',
|
||||
params: { name: 'fail', arguments: {} },
|
||||
});
|
||||
assert.equal(failed.body.result.isError, true);
|
||||
assert.match(failed.body.result.content[0].text, /expected failure/);
|
||||
});
|
||||
|
||||
test('read-resource 返回 Gateway 可直接转成 MCP 的 contents', async () => {
|
||||
const res = await post({
|
||||
jsonrpc: '2.0', id: 4, method: 'bridge/read-resource',
|
||||
params: { uri: 'cocos://test' },
|
||||
});
|
||||
assert.equal(res.body.result.contents[0].mimeType, 'application/json');
|
||||
assert.match(res.body.result.contents[0].text, /"ok": true/);
|
||||
});
|
||||
|
||||
test('拒绝无 token、浏览器 Origin、错误 Content-Type 和超限 body', async () => {
|
||||
const request = { jsonrpc: '2.0', id: 5, method: 'bridge/ping', params: {} };
|
||||
assert.equal((await post(request, { authorized: false })).statusCode, 401);
|
||||
assert.equal((await post(request, { origin: 'http://example.com' })).statusCode, 403);
|
||||
assert.equal((await post(request, { contentType: 'text/plain' })).statusCode, 415);
|
||||
assert.equal((await post(null, { rawBody: JSON.stringify({ data: 'x'.repeat(300) }) })).statusCode, 413);
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
'use strict';
|
||||
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const localStatus = require('../local-status');
|
||||
|
||||
function makeInfo(overrides) {
|
||||
return Object.assign({
|
||||
projectPath: '/project/forest',
|
||||
projectName: 'forest',
|
||||
editorPid: 123,
|
||||
editorVersion: '3.8.8',
|
||||
previewUrl: 'http://127.0.0.1:7456',
|
||||
previewPort: 7456,
|
||||
}, overrides || {});
|
||||
}
|
||||
|
||||
test('dev-reload info 仅 updatedAt 变化时不重复写入', () => {
|
||||
const previous = Object.assign(makeInfo(), { updatedAt: '2026-08-14T00:00:00.000Z' });
|
||||
assert.equal(localStatus.isSameDevReloadInfo(previous, makeInfo()), true);
|
||||
});
|
||||
|
||||
test('预览端口或编辑器进程变化时需要更新 dev-reload info', () => {
|
||||
assert.equal(localStatus.isSameDevReloadInfo(makeInfo(), makeInfo({ previewPort: 7457 })), false);
|
||||
assert.equal(localStatus.isSameDevReloadInfo(makeInfo(), makeInfo({ editorPid: 456 })), false);
|
||||
});
|
||||
|
||||
test('进程存活检测接受当前进程并拒绝非法 PID', () => {
|
||||
assert.equal(localStatus.isProcessAlive(process.pid), true);
|
||||
assert.equal(localStatus.isProcessAlive(0), false);
|
||||
assert.equal(localStatus.isProcessAlive('invalid'), false);
|
||||
});
|
||||
Reference in New Issue
Block a user