mirror of
https://github.com/HappyLifeOk/cc-3-8-x-mcp.git
synced 2026-08-21 22:17:08 +00:00
129 lines
4.9 KiB
JavaScript
129 lines
4.9 KiB
JavaScript
'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,
|
||
|
|
};
|