Release v0.2.0
This commit is contained in:
+32
-1
@@ -7,6 +7,11 @@ const DEFAULTS = {
|
||||
host: '127.0.0.1',
|
||||
port: 8765,
|
||||
toolProfile: 'core',
|
||||
enabledTools: [],
|
||||
disabledTools: [],
|
||||
enabledToolCategories: [],
|
||||
disabledToolCategories: [],
|
||||
enableSessions: false,
|
||||
autostart: true,
|
||||
maxInteractionLogEntries: 50,
|
||||
lastClientTargetId: 'claude_code',
|
||||
@@ -58,7 +63,26 @@ function clampPort(value) {
|
||||
}
|
||||
|
||||
function normalizeProfile(value) {
|
||||
return String(value || DEFAULTS.toolProfile).toLowerCase() === 'full' ? 'full' : 'core';
|
||||
const normalized = String(value || DEFAULTS.toolProfile).toLowerCase();
|
||||
if (normalized === 'full' || normalized === 'custom') {
|
||||
return normalized;
|
||||
}
|
||||
return 'core';
|
||||
}
|
||||
|
||||
function normalizeStringList(value) {
|
||||
if (Array.isArray(value)) {
|
||||
return value
|
||||
.map((item) => String(item || '').trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
if (typeof value === 'string') {
|
||||
return value
|
||||
.split(/[\n,]/)
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
function normalizeClientTargetId(value) {
|
||||
@@ -77,6 +101,11 @@ function loadConfig() {
|
||||
host: process.env.COCOS_MCP_HOST || fileConfig.host || DEFAULTS.host,
|
||||
port: clampPort(process.env.COCOS_MCP_PORT || fileConfig.port || DEFAULTS.port),
|
||||
toolProfile: normalizeProfile(process.env.COCOS_MCP_PROFILE || fileConfig.toolProfile || DEFAULTS.toolProfile),
|
||||
enabledTools: normalizeStringList(fileConfig.enabledTools),
|
||||
disabledTools: normalizeStringList(fileConfig.disabledTools),
|
||||
enabledToolCategories: normalizeStringList(fileConfig.enabledToolCategories).map((item) => item.toLowerCase()),
|
||||
disabledToolCategories: normalizeStringList(fileConfig.disabledToolCategories).map((item) => item.toLowerCase()),
|
||||
enableSessions: typeof fileConfig.enableSessions === 'boolean' ? fileConfig.enableSessions : DEFAULTS.enableSessions,
|
||||
autostart: typeof fileConfig.autostart === 'boolean' ? fileConfig.autostart : DEFAULTS.autostart,
|
||||
maxInteractionLogEntries: Number.isInteger(fileConfig.maxInteractionLogEntries)
|
||||
? Math.max(10, Math.min(500, fileConfig.maxInteractionLogEntries))
|
||||
@@ -93,4 +122,6 @@ module.exports = {
|
||||
getProjectName,
|
||||
getCocosVersion,
|
||||
loadConfig,
|
||||
normalizeProfile,
|
||||
normalizeStringList,
|
||||
};
|
||||
|
||||
@@ -23,6 +23,12 @@ class InteractionLog {
|
||||
return this.entries.slice(0, Math.max(1, limit));
|
||||
}
|
||||
|
||||
clear() {
|
||||
const count = this.entries.length;
|
||||
this.entries.length = 0;
|
||||
return count;
|
||||
}
|
||||
|
||||
summary(limit = 20) {
|
||||
const items = this.list(limit);
|
||||
if (!items.length) {
|
||||
|
||||
+206
@@ -0,0 +1,206 @@
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { resolveProjectPath } = require('./path-safety');
|
||||
|
||||
const DEFAULT_LOG_DIRS = [
|
||||
'temp/logs',
|
||||
'temp',
|
||||
'logs',
|
||||
'local/logs',
|
||||
'local',
|
||||
];
|
||||
const LOG_EXTENSIONS = new Set(['.log', '.txt']);
|
||||
const MAX_READ_BYTES = 2 * 1024 * 1024;
|
||||
|
||||
function normalizeLimit(value, fallback, min, max) {
|
||||
const number = Number(value);
|
||||
if (!Number.isFinite(number)) {
|
||||
return fallback;
|
||||
}
|
||||
return Math.max(min, Math.min(max, Math.floor(number)));
|
||||
}
|
||||
|
||||
function shouldSkipDirectory(name) {
|
||||
return name === '.git' || name === 'node_modules' || name === 'library';
|
||||
}
|
||||
|
||||
function isLogFile(fileName) {
|
||||
return LOG_EXTENSIONS.has(path.extname(fileName).toLowerCase());
|
||||
}
|
||||
|
||||
function safeStat(filePath) {
|
||||
try {
|
||||
return fs.statSync(filePath);
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function collectLogFiles(rootDir, maxDepth, limit) {
|
||||
const files = [];
|
||||
const stack = [{ dir: rootDir, depth: 0 }];
|
||||
|
||||
while (stack.length && files.length < limit) {
|
||||
const current = stack.pop();
|
||||
let entries;
|
||||
try {
|
||||
entries = fs.readdirSync(current.dir, { withFileTypes: true });
|
||||
} catch (error) {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const entry of entries) {
|
||||
const fullPath = path.join(current.dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
if (current.depth < maxDepth && !shouldSkipDirectory(entry.name)) {
|
||||
stack.push({ dir: fullPath, depth: current.depth + 1 });
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!entry.isFile() || !isLogFile(entry.name)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const stat = safeStat(fullPath);
|
||||
if (stat) {
|
||||
files.push({ fullPath, size: stat.size, mtimeMs: stat.mtimeMs });
|
||||
}
|
||||
if (files.length >= limit) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return files;
|
||||
}
|
||||
|
||||
function findProjectLogFiles(projectPath, options = {}) {
|
||||
const limit = normalizeLimit(options.limit, 20, 1, 200);
|
||||
const maxDepth = normalizeLimit(options.maxDepth, 2, 0, 5);
|
||||
const directories = options.directory
|
||||
? [options.directory]
|
||||
: DEFAULT_LOG_DIRS;
|
||||
const seen = new Set();
|
||||
const files = [];
|
||||
|
||||
for (const directory of directories) {
|
||||
let rootDir;
|
||||
try {
|
||||
rootDir = resolveProjectPath(projectPath, directory);
|
||||
} catch (error) {
|
||||
continue;
|
||||
}
|
||||
if (!fs.existsSync(rootDir) || !fs.statSync(rootDir).isDirectory()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const file of collectLogFiles(rootDir, maxDepth, limit)) {
|
||||
if (seen.has(file.fullPath)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(file.fullPath);
|
||||
files.push(file);
|
||||
if (files.length >= limit) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (files.length >= limit) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return files
|
||||
.sort((left, right) => right.mtimeMs - left.mtimeMs)
|
||||
.slice(0, limit);
|
||||
}
|
||||
|
||||
function readTail(filePath, maxLines = 80) {
|
||||
const stat = safeStat(filePath);
|
||||
if (!stat) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const size = Math.min(stat.size, MAX_READ_BYTES);
|
||||
const buffer = Buffer.alloc(size);
|
||||
const fd = fs.openSync(filePath, 'r');
|
||||
try {
|
||||
fs.readSync(fd, buffer, 0, size, stat.size - size);
|
||||
} finally {
|
||||
fs.closeSync(fd);
|
||||
}
|
||||
|
||||
return buffer
|
||||
.toString('utf8')
|
||||
.replace(/\s+$/g, '')
|
||||
.split(/\r?\n/)
|
||||
.slice(-normalizeLimit(maxLines, 80, 1, 1000))
|
||||
.join('\n')
|
||||
.trim();
|
||||
}
|
||||
|
||||
function getRecentProjectLogs(projectPath, options = {}) {
|
||||
const maxLines = normalizeLimit(options.lines, 80, 1, 1000);
|
||||
return findProjectLogFiles(projectPath, options).map((file) => ({
|
||||
path: path.relative(projectPath, file.fullPath).replace(/\\/g, '/'),
|
||||
size: file.size,
|
||||
mtime: new Date(file.mtimeMs).toISOString(),
|
||||
text: readTail(file.fullPath, maxLines),
|
||||
}));
|
||||
}
|
||||
|
||||
function searchProjectLogs(projectPath, options = {}) {
|
||||
const query = String(options.query || '').trim();
|
||||
if (!query) {
|
||||
throw new Error('query is required.');
|
||||
}
|
||||
|
||||
const limit = normalizeLimit(options.limit, 50, 1, 500);
|
||||
const flags = options.caseSensitive ? '' : 'i';
|
||||
const pattern = options.regex ? new RegExp(query, flags) : null;
|
||||
const lowerQuery = query.toLowerCase();
|
||||
const results = [];
|
||||
|
||||
for (const file of findProjectLogFiles(projectPath, { ...options, limit: normalizeLimit(options.fileLimit, 40, 1, 200) })) {
|
||||
const text = readTail(file.fullPath, normalizeLimit(options.linesPerFile, 2000, 1, 10000));
|
||||
const lines = text.split(/\r?\n/);
|
||||
for (let index = 0; index < lines.length; index += 1) {
|
||||
const line = lines[index];
|
||||
const matched = pattern
|
||||
? pattern.test(line)
|
||||
: (options.caseSensitive ? line.includes(query) : line.toLowerCase().includes(lowerQuery));
|
||||
if (!matched) {
|
||||
continue;
|
||||
}
|
||||
|
||||
results.push({
|
||||
path: path.relative(projectPath, file.fullPath).replace(/\\/g, '/'),
|
||||
line: index + 1,
|
||||
text: line,
|
||||
});
|
||||
if (results.length >= limit) {
|
||||
return { query, count: results.length, matches: results };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { query, count: results.length, matches: results };
|
||||
}
|
||||
|
||||
function clearProjectLogFiles(projectPath, options = {}) {
|
||||
const cleared = [];
|
||||
for (const file of findProjectLogFiles(projectPath, options)) {
|
||||
fs.truncateSync(file.fullPath, 0);
|
||||
cleared.push(path.relative(projectPath, file.fullPath).replace(/\\/g, '/'));
|
||||
}
|
||||
return cleared;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
findProjectLogFiles,
|
||||
getRecentProjectLogs,
|
||||
searchProjectLogs,
|
||||
clearProjectLogFiles,
|
||||
};
|
||||
+40
-1
@@ -4,6 +4,7 @@ const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { getCurrentSelection, queryAssetInfo, queryAssetMeta } = require('./assets');
|
||||
const { runScriptDiagnostics } = require('./diagnostics');
|
||||
const { getRecentProjectLogs } = require('./logs');
|
||||
const { resolveProjectPath } = require('./path-safety');
|
||||
|
||||
function createResource(uri, name, description) {
|
||||
@@ -49,10 +50,11 @@ function summarizeSelection() {
|
||||
}
|
||||
|
||||
class ResourceProvider {
|
||||
constructor(getRuntimeContext, sceneBridge, interactionLog) {
|
||||
constructor(getRuntimeContext, sceneBridge, interactionLog, runtimeLog) {
|
||||
this.getRuntimeContext = getRuntimeContext;
|
||||
this.sceneBridge = sceneBridge;
|
||||
this.interactionLog = interactionLog;
|
||||
this.runtimeLog = runtimeLog;
|
||||
}
|
||||
|
||||
listResources() {
|
||||
@@ -65,6 +67,8 @@ class ResourceProvider {
|
||||
createResource('cocos://selection/current', `${projectName} Current Selection`, 'Summary of the current editor selection.'),
|
||||
createResource('cocos://selection/asset', `${projectName} Selected Asset`, 'Details for the currently selected asset.'),
|
||||
createResource('cocos://errors/scripts', `${projectName} Script Diagnostics`, 'Latest TypeScript diagnostic summary for the project.'),
|
||||
createResource('cocos://logs/editor', `${projectName} Editor Logs`, 'Recent MCP runtime logs and tool interaction history.'),
|
||||
createResource('cocos://logs/project', `${projectName} Project Logs`, 'Recent tails from common project log files.'),
|
||||
createResource('cocos://mcp/interactions', `${projectName} MCP Interactions`, 'Recent MCP tool interaction summaries.'),
|
||||
];
|
||||
}
|
||||
@@ -121,6 +125,10 @@ class ResourceProvider {
|
||||
return await this.getSelectedAssetText();
|
||||
case 'cocos://errors/scripts':
|
||||
return await this.getScriptDiagnosticsText(projectPath);
|
||||
case 'cocos://logs/editor':
|
||||
return this.getEditorLogsText();
|
||||
case 'cocos://logs/project':
|
||||
return this.getProjectLogsText(projectPath);
|
||||
case 'cocos://mcp/interactions':
|
||||
return this.interactionLog.summary();
|
||||
default:
|
||||
@@ -247,6 +255,37 @@ class ResourceProvider {
|
||||
return `Script diagnostics failed: ${error.message}`;
|
||||
}
|
||||
}
|
||||
|
||||
getEditorLogsText() {
|
||||
return [
|
||||
'MCP Runtime Logs',
|
||||
this.runtimeLog && typeof this.runtimeLog.summary === 'function'
|
||||
? this.runtimeLog.summary(80)
|
||||
: 'Runtime log is unavailable.',
|
||||
'',
|
||||
'MCP Tool Interactions',
|
||||
this.interactionLog && typeof this.interactionLog.summary === 'function'
|
||||
? this.interactionLog.summary(80)
|
||||
: 'Interaction log is unavailable.',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
getProjectLogsText(projectPath) {
|
||||
const logs = getRecentProjectLogs(projectPath, { limit: 10, lines: 100 });
|
||||
if (!logs.length) {
|
||||
return 'No project log files found in common project log directories.';
|
||||
}
|
||||
|
||||
return logs
|
||||
.map((log) => [
|
||||
`# ${log.path}`,
|
||||
`mtime: ${log.mtime}`,
|
||||
`size: ${log.size}`,
|
||||
'',
|
||||
log.text,
|
||||
].join('\n'))
|
||||
.join('\n\n---\n\n');
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
'use strict';
|
||||
|
||||
class RuntimeLog {
|
||||
constructor(limit = 200) {
|
||||
this.limit = Math.max(10, Number(limit) || 200);
|
||||
this.entries = [];
|
||||
}
|
||||
|
||||
add(level, message, details) {
|
||||
this.entries.unshift({
|
||||
level: String(level || 'info'),
|
||||
message: String(message || ''),
|
||||
details: details === undefined ? null : details,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
if (this.entries.length > this.limit) {
|
||||
this.entries.length = this.limit;
|
||||
}
|
||||
}
|
||||
|
||||
list(limit = 50) {
|
||||
return this.entries.slice(0, Math.max(1, Number(limit) || 50));
|
||||
}
|
||||
|
||||
clear() {
|
||||
const count = this.entries.length;
|
||||
this.entries.length = 0;
|
||||
return count;
|
||||
}
|
||||
|
||||
summary(limit = 50) {
|
||||
const items = this.list(limit);
|
||||
if (!items.length) {
|
||||
return 'No MCP runtime logs recorded yet.';
|
||||
}
|
||||
|
||||
return items
|
||||
.map((entry) => `[${entry.timestamp}] ${entry.level.toUpperCase()} ${entry.message}`)
|
||||
.join('\n');
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
RuntimeLog,
|
||||
};
|
||||
+231
-25
@@ -1,5 +1,6 @@
|
||||
'use strict';
|
||||
|
||||
const crypto = require('crypto');
|
||||
const http = require('http');
|
||||
const { safeStringify } = require('./utils');
|
||||
const IMAGE_DATA_URI_PREFIX = 'data:image/png;base64,';
|
||||
@@ -14,14 +15,26 @@ const SUPPORTED_PROTOCOL_VERSIONS = [
|
||||
'2024-11-05',
|
||||
];
|
||||
|
||||
function json(response, statusCode, payload, protocolVersion = MCP_PROTOCOL_VERSION) {
|
||||
function responseHeaders(protocolVersion = MCP_PROTOCOL_VERSION, extraHeaders = {}) {
|
||||
return {
|
||||
'MCP-Protocol-Version': protocolVersion,
|
||||
...extraHeaders,
|
||||
};
|
||||
}
|
||||
|
||||
function json(response, statusCode, payload, protocolVersion = MCP_PROTOCOL_VERSION, extraHeaders = {}) {
|
||||
response.writeHead(statusCode, {
|
||||
'Content-Type': 'application/json; charset=utf-8',
|
||||
'MCP-Protocol-Version': protocolVersion,
|
||||
...responseHeaders(protocolVersion, extraHeaders),
|
||||
});
|
||||
response.end(JSON.stringify(payload));
|
||||
}
|
||||
|
||||
function empty(response, statusCode, protocolVersion = MCP_PROTOCOL_VERSION, extraHeaders = {}) {
|
||||
response.writeHead(statusCode, responseHeaders(protocolVersion, extraHeaders));
|
||||
response.end();
|
||||
}
|
||||
|
||||
function textContent(value) {
|
||||
if (typeof value === 'string' && value.startsWith(IMAGE_DATA_URI_PREFIX)) {
|
||||
return [
|
||||
@@ -68,12 +81,15 @@ class McpServer {
|
||||
this.resourceProvider = options.resourceProvider;
|
||||
this.promptProvider = options.promptProvider;
|
||||
this.interactionLog = options.interactionLog;
|
||||
this.runtimeLog = options.runtimeLog;
|
||||
this.serverName = options.serverName;
|
||||
this.serverVersion = options.serverVersion;
|
||||
this.server = null;
|
||||
this.actualPort = null;
|
||||
this.portFallbackInfo = null;
|
||||
this.negotiatedProtocolVersion = MCP_PROTOCOL_VERSION;
|
||||
this.enableSessions = Boolean(this.config && this.config.enableSessions);
|
||||
this.sessions = new Set();
|
||||
}
|
||||
|
||||
isRunning() {
|
||||
@@ -98,9 +114,24 @@ class McpServer {
|
||||
return this.portFallbackInfo;
|
||||
}
|
||||
|
||||
log(level, message) {
|
||||
if (this.runtimeLog && typeof this.runtimeLog.add === 'function') {
|
||||
this.runtimeLog.add(level, message);
|
||||
}
|
||||
|
||||
const output = `${LOG_PREFIX} ${message}`;
|
||||
if (level === 'error') {
|
||||
console.error(output);
|
||||
} else if (level === 'warn') {
|
||||
console.warn(output);
|
||||
} else {
|
||||
console.log(output);
|
||||
}
|
||||
}
|
||||
|
||||
async start() {
|
||||
if (this.isRunning()) {
|
||||
console.log(`${LOG_PREFIX} Start skipped: already running.`);
|
||||
this.log('info', 'Start skipped: already running.');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -110,20 +141,34 @@ class McpServer {
|
||||
const requestHandler = async (request, response) => {
|
||||
try {
|
||||
if (request.method === 'GET' && request.url === '/health') {
|
||||
console.log(`${LOG_PREFIX} GET /health`);
|
||||
this.log('info', 'GET /health');
|
||||
return json(response, 200, { ok: true, name: this.serverName, version: this.serverVersion }, this.negotiatedProtocolVersion);
|
||||
}
|
||||
|
||||
if (!this.isAllowedOrigin(request)) {
|
||||
console.warn(`${LOG_PREFIX} Rejected ${request.method} ${request.url}: invalid Origin header.`);
|
||||
this.log('warn', `Rejected ${request.method} ${request.url}: invalid Origin header.`);
|
||||
return json(response, 403, { error: 'Forbidden: invalid Origin header' }, this.negotiatedProtocolVersion);
|
||||
}
|
||||
|
||||
if (request.method === 'DELETE') {
|
||||
return this.handleDelete(request, response);
|
||||
}
|
||||
|
||||
if (request.method === 'GET') {
|
||||
this.log('warn', `Rejected ${request.method} ${request.url}: SSE GET streams are not supported.`);
|
||||
return json(response, 405, { error: 'Method Not Allowed: SSE streams are not supported' }, this.negotiatedProtocolVersion);
|
||||
}
|
||||
|
||||
if (request.method !== 'POST') {
|
||||
console.warn(`${LOG_PREFIX} Rejected ${request.method} ${request.url}: method not allowed.`);
|
||||
this.log('warn', `Rejected ${request.method} ${request.url}: method not allowed.`);
|
||||
return json(response, 405, { error: 'Method Not Allowed' }, this.negotiatedProtocolVersion);
|
||||
}
|
||||
|
||||
const acceptHeaderError = this.validateAcceptHeader(request);
|
||||
if (acceptHeaderError) {
|
||||
return json(response, 406, acceptHeaderError, this.negotiatedProtocolVersion);
|
||||
}
|
||||
|
||||
const body = await this.readBody(request);
|
||||
if (!body) {
|
||||
return json(response, 400, this.createError(null, -32700, 'Parse error: empty body'), this.negotiatedProtocolVersion);
|
||||
@@ -137,24 +182,52 @@ class McpServer {
|
||||
}
|
||||
|
||||
if (rpc && rpc.method) {
|
||||
console.log(`${LOG_PREFIX} RPC ${rpc.method}`);
|
||||
this.log('info', `RPC ${rpc.method}`);
|
||||
}
|
||||
|
||||
const protocolHeaderError = this.validateProtocolVersionHeader(request, rpc);
|
||||
if (protocolHeaderError) {
|
||||
return json(response, 400, protocolHeaderError, this.negotiatedProtocolVersion);
|
||||
}
|
||||
const responseProtocolVersion = this.getProtocolVersionForResponse(request, rpc);
|
||||
|
||||
const sessionError = this.validateSession(request, rpc);
|
||||
if (sessionError) {
|
||||
return json(response, sessionError.statusCode, sessionError.error, responseProtocolVersion);
|
||||
}
|
||||
|
||||
const messageType = this.classifyJsonRpcMessage(rpc);
|
||||
if (messageType === 'response') {
|
||||
return empty(response, 202, responseProtocolVersion);
|
||||
}
|
||||
|
||||
if (messageType === 'notification') {
|
||||
const notificationError = this.handleRpcNotification(rpc);
|
||||
if (notificationError) {
|
||||
return json(response, 400, notificationError, responseProtocolVersion);
|
||||
}
|
||||
return empty(response, 202, responseProtocolVersion);
|
||||
}
|
||||
|
||||
if (messageType !== 'request') {
|
||||
return json(response, 400, this.createError(rpc && rpc.id, -32600, 'Invalid Request'), responseProtocolVersion);
|
||||
}
|
||||
|
||||
const result = await this.handleRpcRequest(rpc);
|
||||
if (result == null) {
|
||||
response.writeHead(204, { 'MCP-Protocol-Version': this.negotiatedProtocolVersion });
|
||||
response.end();
|
||||
return;
|
||||
return empty(response, 202, responseProtocolVersion);
|
||||
}
|
||||
|
||||
return json(response, 200, result, this.negotiatedProtocolVersion);
|
||||
const extraHeaders = {};
|
||||
if (this.enableSessions && rpc.method === 'initialize' && result && !result.error) {
|
||||
const sessionId = this.createSessionId();
|
||||
this.sessions.add(sessionId);
|
||||
extraHeaders['Mcp-Session-Id'] = sessionId;
|
||||
}
|
||||
|
||||
return json(response, 200, result, this.getProtocolVersionForResponse(request, rpc), extraHeaders);
|
||||
} catch (error) {
|
||||
console.error(`${LOG_PREFIX} Request handling failed: ${error.message}`);
|
||||
this.log('error', `Request handling failed: ${error.message}`);
|
||||
const statusCode = error.statusCode || 500;
|
||||
const rpcCode = error.rpcCode || -32603;
|
||||
const message = statusCode === 500 ? `Internal error: ${error.message}` : error.message;
|
||||
@@ -167,7 +240,7 @@ class McpServer {
|
||||
let lastError = null;
|
||||
|
||||
while (attempt <= MAX_PORT_FALLBACK_ATTEMPTS) {
|
||||
console.log(`${LOG_PREFIX} Creating HTTP server on ${this.config.host}:${port}...`);
|
||||
this.log('info', `Creating HTTP server on ${this.config.host}:${port}...`);
|
||||
const candidate = http.createServer(requestHandler);
|
||||
|
||||
try {
|
||||
@@ -177,27 +250,27 @@ class McpServer {
|
||||
? candidate.address().port
|
||||
: port;
|
||||
|
||||
if (this.actualPort !== this.config.port) {
|
||||
if (this.config.port !== 0 && this.actualPort !== this.config.port) {
|
||||
this.portFallbackInfo = {
|
||||
requestedPort: this.config.port,
|
||||
actualPort: this.actualPort,
|
||||
attempts: attempt,
|
||||
};
|
||||
console.warn(
|
||||
`${LOG_PREFIX} Port ${this.config.port} was unavailable. ` +
|
||||
`Fell back to ${this.actualPort}.`
|
||||
this.log(
|
||||
'warn',
|
||||
`Port ${this.config.port} was unavailable. Fell back to ${this.actualPort}.`
|
||||
);
|
||||
}
|
||||
|
||||
console.log(`${LOG_PREFIX} Listening on http://${this.config.host}:${this.actualPort}/`);
|
||||
this.log('info', `Listening on http://${this.config.host}:${this.actualPort}/`);
|
||||
return;
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
if (error && error.code === 'EADDRINUSE' && port < 65535 && attempt < MAX_PORT_FALLBACK_ATTEMPTS) {
|
||||
const nextPort = port + 1;
|
||||
console.warn(
|
||||
`${LOG_PREFIX} Port ${port} is already in use. ` +
|
||||
`Trying fallback port ${nextPort}...`
|
||||
this.log(
|
||||
'warn',
|
||||
`Port ${port} is already in use. Trying fallback port ${nextPort}...`
|
||||
);
|
||||
port = nextPort;
|
||||
attempt += 1;
|
||||
@@ -217,11 +290,11 @@ class McpServer {
|
||||
|
||||
async stop() {
|
||||
if (!this.server) {
|
||||
console.log(`${LOG_PREFIX} Stop skipped: server object is empty.`);
|
||||
this.log('info', 'Stop skipped: server object is empty.');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`${LOG_PREFIX} Closing HTTP server...`);
|
||||
this.log('info', 'Closing HTTP server...');
|
||||
const active = this.server;
|
||||
this.server = null;
|
||||
this.actualPort = null;
|
||||
@@ -229,11 +302,11 @@ class McpServer {
|
||||
await new Promise((resolve, reject) => {
|
||||
active.close((error) => {
|
||||
if (error) {
|
||||
console.error(`${LOG_PREFIX} Close failed: ${error.message}`);
|
||||
this.log('error', `Close failed: ${error.message}`);
|
||||
reject(error);
|
||||
return;
|
||||
}
|
||||
console.log(`${LOG_PREFIX} HTTP server closed.`);
|
||||
this.log('info', 'HTTP server closed.');
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
@@ -295,6 +368,35 @@ class McpServer {
|
||||
}
|
||||
}
|
||||
|
||||
validateAcceptHeader(request) {
|
||||
const header = request.headers && request.headers.accept;
|
||||
if (!header) {
|
||||
return this.createError(
|
||||
null,
|
||||
-32600,
|
||||
'Missing Accept header. Streamable HTTP clients must accept application/json and text/event-stream.'
|
||||
);
|
||||
}
|
||||
|
||||
const tokens = String(Array.isArray(header) ? header.join(',') : header)
|
||||
.split(',')
|
||||
.map((item) => item.split(';')[0].trim().toLowerCase())
|
||||
.filter(Boolean);
|
||||
const hasWildcard = tokens.includes('*/*');
|
||||
const hasJson = hasWildcard || tokens.includes('application/json') || tokens.includes('application/*');
|
||||
const hasSse = hasWildcard || tokens.includes('text/event-stream') || tokens.includes('text/*');
|
||||
|
||||
if (!hasJson || !hasSse) {
|
||||
return this.createError(
|
||||
null,
|
||||
-32600,
|
||||
'Invalid Accept header. Streamable HTTP clients must accept both application/json and text/event-stream.'
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
validateProtocolVersionHeader(request, rpc) {
|
||||
const header = request.headers && request.headers['mcp-protocol-version'];
|
||||
if (!header || (rpc && rpc.method === 'initialize')) {
|
||||
@@ -313,6 +415,110 @@ class McpServer {
|
||||
return null;
|
||||
}
|
||||
|
||||
getProtocolVersionForResponse(request, rpc) {
|
||||
if (rpc && rpc.method === 'initialize') {
|
||||
return this.negotiatedProtocolVersion;
|
||||
}
|
||||
|
||||
const header = request.headers && request.headers['mcp-protocol-version'];
|
||||
const version = Array.isArray(header) ? header[0] : header ? String(header) : '';
|
||||
if (SUPPORTED_PROTOCOL_VERSIONS.includes(version)) {
|
||||
return version;
|
||||
}
|
||||
return this.negotiatedProtocolVersion;
|
||||
}
|
||||
|
||||
validateSession(request, rpc) {
|
||||
if (!this.enableSessions || (rpc && rpc.method === 'initialize')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const sessionId = this.getSessionId(request);
|
||||
if (!sessionId) {
|
||||
return {
|
||||
statusCode: 400,
|
||||
error: this.createError(rpc && rpc.id, -32600, 'Missing Mcp-Session-Id header.'),
|
||||
};
|
||||
}
|
||||
if (!this.sessions.has(sessionId)) {
|
||||
return {
|
||||
statusCode: 404,
|
||||
error: this.createError(rpc && rpc.id, -32001, 'Unknown or expired MCP session.'),
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
getSessionId(request) {
|
||||
const value = request.headers && (request.headers['mcp-session-id'] || request.headers['Mcp-Session-Id']);
|
||||
if (Array.isArray(value)) {
|
||||
return value[0] || '';
|
||||
}
|
||||
return value ? String(value) : '';
|
||||
}
|
||||
|
||||
createSessionId() {
|
||||
if (typeof crypto.randomUUID === 'function') {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
return crypto.randomBytes(16).toString('hex');
|
||||
}
|
||||
|
||||
handleDelete(request, response) {
|
||||
if (!this.enableSessions) {
|
||||
return json(response, 405, { error: 'Method Not Allowed: MCP sessions are disabled' }, this.negotiatedProtocolVersion);
|
||||
}
|
||||
|
||||
const sessionId = this.getSessionId(request);
|
||||
if (!sessionId) {
|
||||
return json(
|
||||
response,
|
||||
400,
|
||||
this.createError(null, -32600, 'Missing Mcp-Session-Id header.'),
|
||||
this.negotiatedProtocolVersion
|
||||
);
|
||||
}
|
||||
if (!this.sessions.has(sessionId)) {
|
||||
return json(
|
||||
response,
|
||||
404,
|
||||
this.createError(null, -32001, 'Unknown or expired MCP session.'),
|
||||
this.negotiatedProtocolVersion
|
||||
);
|
||||
}
|
||||
|
||||
this.sessions.delete(sessionId);
|
||||
return empty(response, 202, this.negotiatedProtocolVersion);
|
||||
}
|
||||
|
||||
classifyJsonRpcMessage(message) {
|
||||
if (!message || message.jsonrpc !== '2.0') {
|
||||
return 'invalid';
|
||||
}
|
||||
if (typeof message.method === 'string') {
|
||||
return Object.prototype.hasOwnProperty.call(message, 'id') ? 'request' : 'notification';
|
||||
}
|
||||
if (
|
||||
Object.prototype.hasOwnProperty.call(message, 'id') &&
|
||||
(Object.prototype.hasOwnProperty.call(message, 'result') || Object.prototype.hasOwnProperty.call(message, 'error'))
|
||||
) {
|
||||
return 'response';
|
||||
}
|
||||
return 'invalid';
|
||||
}
|
||||
|
||||
handleRpcNotification(notification) {
|
||||
if (!notification || notification.jsonrpc !== '2.0' || typeof notification.method !== 'string') {
|
||||
return this.createError(null, -32600, 'Invalid Request');
|
||||
}
|
||||
|
||||
if (notification.method.startsWith('notifications/')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return this.createError(null, -32601, `Notification method not found: ${notification.method}`);
|
||||
}
|
||||
|
||||
async handleRpcRequest(request) {
|
||||
if (!request || request.jsonrpc !== '2.0') {
|
||||
return this.createError(request && request.id, -32600, 'Invalid Request');
|
||||
|
||||
+255
-3
@@ -16,10 +16,36 @@ const {
|
||||
} = require('./assets');
|
||||
const { runScriptDiagnostics } = require('./diagnostics');
|
||||
const { listWindows, sendKeyCombo, sendKeyPress, sendMouseClick, sendMouseDrag } = require('./input');
|
||||
const {
|
||||
clearProjectLogFiles,
|
||||
getRecentProjectLogs,
|
||||
searchProjectLogs,
|
||||
} = require('./logs');
|
||||
const { resolveProjectPath } = require('./path-safety');
|
||||
const { captureDesktopScreenshot, captureEditorWindowScreenshot, capturePanelScreenshot } = require('./screenshots');
|
||||
const { checkForUpdate } = require('./update-checker');
|
||||
const { safeStringify } = require('./utils');
|
||||
|
||||
const TOOL_CATEGORY_RULES = [
|
||||
['updates', /update/],
|
||||
['logs', /log/],
|
||||
['diagnostics', /diagnostic|validate/],
|
||||
['screenshots', /screenshot|capture/],
|
||||
['input', /mouse|key|input|button_click/],
|
||||
['files', /file|directory|exists|refresh_assets/],
|
||||
['assets', /asset|scene$|scenes|open_scene|run_scene_asset/],
|
||||
['prefabs', /prefab/],
|
||||
['selection', /selection|select_/],
|
||||
['components', /component/],
|
||||
['ui', /canvas|label|button|sprite/],
|
||||
['camera', /camera/],
|
||||
['animation', /animation|clip/],
|
||||
['runtime', /runtime|time_scale|node_event|invoke_component/],
|
||||
['scene', /scene|hierarchy|node/],
|
||||
['execution', /execute_/],
|
||||
['project', /project|editor_state|tool_catalog/],
|
||||
];
|
||||
|
||||
function createSchema(properties, required) {
|
||||
const schema = {
|
||||
type: 'object',
|
||||
@@ -31,6 +57,74 @@ function createSchema(properties, required) {
|
||||
return schema;
|
||||
}
|
||||
|
||||
function inferToolCategory(toolName) {
|
||||
for (const [category, pattern] of TOOL_CATEGORY_RULES) {
|
||||
if (pattern.test(toolName)) {
|
||||
return category;
|
||||
}
|
||||
}
|
||||
return 'other';
|
||||
}
|
||||
|
||||
function normalizeNameSet(values) {
|
||||
return new Set(
|
||||
(Array.isArray(values) ? values : [])
|
||||
.map((value) => String(value || '').trim())
|
||||
.filter(Boolean)
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeCategorySet(values) {
|
||||
return new Set(
|
||||
(Array.isArray(values) ? values : [])
|
||||
.map((value) => String(value || '').trim().toLowerCase())
|
||||
.filter(Boolean)
|
||||
);
|
||||
}
|
||||
|
||||
function toolCategory(tool) {
|
||||
return tool.category || inferToolCategory(tool.name);
|
||||
}
|
||||
|
||||
function isToolExposed(config, tool) {
|
||||
const profile = config && config.toolProfile === 'full'
|
||||
? 'full'
|
||||
: config && config.toolProfile === 'custom'
|
||||
? 'custom'
|
||||
: 'core';
|
||||
const category = toolCategory(tool);
|
||||
const enabledTools = normalizeNameSet(config && config.enabledTools);
|
||||
const disabledTools = normalizeNameSet(config && config.disabledTools);
|
||||
const enabledCategories = normalizeCategorySet(config && config.enabledToolCategories);
|
||||
const disabledCategories = normalizeCategorySet(config && config.disabledToolCategories);
|
||||
|
||||
let exposed = profile === 'full' || tool.profile === 'core';
|
||||
if (profile === 'custom') {
|
||||
exposed = tool.profile === 'core' || enabledTools.has(tool.name) || enabledCategories.has(category);
|
||||
} else if (enabledTools.has(tool.name) || enabledCategories.has(category)) {
|
||||
exposed = true;
|
||||
}
|
||||
|
||||
if (disabledTools.has(tool.name) || disabledCategories.has(category)) {
|
||||
exposed = false;
|
||||
}
|
||||
|
||||
return exposed;
|
||||
}
|
||||
|
||||
function summarizeDiagnostics(result) {
|
||||
if (!result) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
ok: Boolean(result.ok),
|
||||
tool: result.tool,
|
||||
summary: result.summary,
|
||||
diagnosticCount: Array.isArray(result.diagnostics) ? result.diagnostics.length : 0,
|
||||
diagnostics: Array.isArray(result.diagnostics) ? result.diagnostics.slice(0, 20) : [],
|
||||
};
|
||||
}
|
||||
|
||||
function toOutput(value) {
|
||||
if (typeof value === 'string') {
|
||||
return value;
|
||||
@@ -123,7 +217,7 @@ async function refreshAssets(projectPath, targetPath) {
|
||||
return 'File written outside assets directory; no asset-db refresh was needed.';
|
||||
}
|
||||
|
||||
function createToolRegistry({ getRuntimeContext, getStatus, interactionLog, sceneBridge, editorExecutor }) {
|
||||
function createToolRegistry({ getRuntimeContext, getStatus, interactionLog, runtimeLog, sceneBridge, editorExecutor }) {
|
||||
const tools = [
|
||||
{
|
||||
name: 'execute_javascript',
|
||||
@@ -225,6 +319,31 @@ function createToolRegistry({ getRuntimeContext, getStatus, interactionLog, scen
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'get_tool_catalog',
|
||||
profile: 'core',
|
||||
description: '[specialist] Return every built-in MCP tool with profile, category, and current exposure state. Use this before changing custom tool exposure.',
|
||||
inputSchema: createSchema({}, []),
|
||||
handler: async () => registry.listToolCatalog(),
|
||||
},
|
||||
{
|
||||
name: 'check_for_updates',
|
||||
profile: 'core',
|
||||
description: '[specialist] Check the latest Funplay Cocos MCP GitHub release and compare it with the installed extension version.',
|
||||
inputSchema: createSchema(
|
||||
{
|
||||
timeoutMs: { type: 'number', description: 'Optional network timeout in milliseconds.' },
|
||||
},
|
||||
[]
|
||||
),
|
||||
handler: async (args) => {
|
||||
const runtimeContext = getRuntimeContext();
|
||||
return await checkForUpdate({
|
||||
currentVersion: runtimeContext.version,
|
||||
timeoutMs: Number.isFinite(args.timeoutMs) ? args.timeoutMs : 5000,
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'get_selection',
|
||||
profile: 'core',
|
||||
@@ -1016,6 +1135,129 @@ function createToolRegistry({ getRuntimeContext, getStatus, interactionLog, scen
|
||||
return await runScriptDiagnostics(projectPath, args);
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'get_recent_logs',
|
||||
profile: 'core',
|
||||
description: '[specialist] Return recent MCP runtime logs, recent tool interactions, and tails of common project log files.',
|
||||
inputSchema: createSchema(
|
||||
{
|
||||
limit: { type: 'number', description: 'Maximum in-memory runtime/interactions to return.' },
|
||||
includeProjectLogs: { type: 'boolean', description: 'Include tails from common project log files.' },
|
||||
projectLogLines: { type: 'number', description: 'Tail lines to read per project log file.' },
|
||||
},
|
||||
[]
|
||||
),
|
||||
handler: async (args) => {
|
||||
const { projectPath } = getRuntimeContext();
|
||||
const limit = Number.isFinite(args.limit) ? Math.max(1, Math.min(200, args.limit)) : 50;
|
||||
return {
|
||||
runtimeLogs: runtimeLog && typeof runtimeLog.list === 'function' ? runtimeLog.list(limit) : [],
|
||||
interactions: interactionLog && typeof interactionLog.list === 'function' ? interactionLog.list(limit) : [],
|
||||
projectLogs: args.includeProjectLogs === false
|
||||
? []
|
||||
: getRecentProjectLogs(projectPath, {
|
||||
limit: 10,
|
||||
lines: Number.isFinite(args.projectLogLines) ? args.projectLogLines : 80,
|
||||
}),
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'search_project_logs',
|
||||
profile: 'core',
|
||||
description: '[specialist] Search common Cocos project log files for a string or regular expression.',
|
||||
inputSchema: createSchema(
|
||||
{
|
||||
query: { type: 'string', description: 'Text or regex pattern to search for.' },
|
||||
regex: { type: 'boolean', description: 'Treat query as a JavaScript regular expression.' },
|
||||
caseSensitive: { type: 'boolean', description: 'Use case-sensitive matching.' },
|
||||
limit: { type: 'number', description: 'Maximum matches to return.' },
|
||||
directory: { type: 'string', description: 'Optional project-relative log directory to search.' },
|
||||
},
|
||||
['query']
|
||||
),
|
||||
handler: async (args) => {
|
||||
const { projectPath } = getRuntimeContext();
|
||||
return searchProjectLogs(projectPath, args);
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'clear_logs',
|
||||
profile: 'core',
|
||||
description: '[specialist] Clear in-memory MCP logs and, only with explicit confirmation, truncate common project log files.',
|
||||
inputSchema: createSchema(
|
||||
{
|
||||
scope: { type: 'string', description: 'mcp, project, or all. Defaults to mcp.' },
|
||||
confirmProjectLogs: { type: 'boolean', description: 'Required when scope includes project log files.' },
|
||||
directory: { type: 'string', description: 'Optional project-relative log directory to clear.' },
|
||||
},
|
||||
[]
|
||||
),
|
||||
handler: async (args) => {
|
||||
const { projectPath } = getRuntimeContext();
|
||||
const scope = String(args.scope || 'mcp').toLowerCase();
|
||||
const clearMcp = scope === 'mcp' || scope === 'all';
|
||||
const clearProject = scope === 'project' || scope === 'all';
|
||||
const result = {
|
||||
runtimeLogEntriesCleared: 0,
|
||||
interactionEntriesCleared: 0,
|
||||
projectLogFilesCleared: [],
|
||||
};
|
||||
|
||||
if (clearMcp) {
|
||||
result.runtimeLogEntriesCleared = runtimeLog && typeof runtimeLog.clear === 'function' ? runtimeLog.clear() : 0;
|
||||
result.interactionEntriesCleared = interactionLog && typeof interactionLog.clear === 'function' ? interactionLog.clear() : 0;
|
||||
}
|
||||
|
||||
if (clearProject) {
|
||||
if (!args.confirmProjectLogs) {
|
||||
throw new Error('confirmProjectLogs=true is required before truncating project log files.');
|
||||
}
|
||||
result.projectLogFilesCleared = clearProjectLogFiles(projectPath, { directory: args.directory, limit: 50 });
|
||||
}
|
||||
|
||||
if (!clearMcp && !clearProject) {
|
||||
throw new Error("scope must be 'mcp', 'project', or 'all'.");
|
||||
}
|
||||
|
||||
return result;
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'validate_scene',
|
||||
profile: 'core',
|
||||
description: '[specialist] Run a compact validation pass over the active scene, runtime state, TypeScript diagnostics, and recent project log errors.',
|
||||
inputSchema: createSchema(
|
||||
{
|
||||
maxDepth: { type: 'number', description: 'Scene hierarchy depth for the scene snapshot.' },
|
||||
includeScriptDiagnostics: { type: 'boolean', description: 'Run TypeScript diagnostics as part of validation.' },
|
||||
includeLogErrors: { type: 'boolean', description: 'Search project logs for error lines.' },
|
||||
},
|
||||
[]
|
||||
),
|
||||
handler: async (args) => {
|
||||
const { projectPath } = getRuntimeContext();
|
||||
const scene = await sceneBridge.call('getSceneInfo', {
|
||||
maxDepth: Number.isFinite(args.maxDepth) ? args.maxDepth : 2,
|
||||
includeComponents: true,
|
||||
}).catch((error) => ({ ok: false, error: error.message }));
|
||||
const runtime = await sceneBridge.call('getRuntimeState', {}).catch((error) => ({ ok: false, error: error.message }));
|
||||
const diagnostics = args.includeScriptDiagnostics === false
|
||||
? null
|
||||
: summarizeDiagnostics(await runScriptDiagnostics(projectPath, args).catch((error) => ({ ok: false, summary: error.message, diagnostics: [] })));
|
||||
const logErrors = args.includeLogErrors === false
|
||||
? null
|
||||
: searchProjectLogs(projectPath, { query: 'error', limit: 20 }).matches;
|
||||
|
||||
return {
|
||||
ok: !scene.error && !runtime.error && (!diagnostics || diagnostics.ok) && (!logErrors || logErrors.length === 0),
|
||||
scene,
|
||||
runtime,
|
||||
diagnostics,
|
||||
logErrors,
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'get_runtime_state',
|
||||
profile: 'core',
|
||||
@@ -1333,20 +1575,30 @@ function createToolRegistry({ getRuntimeContext, getStatus, interactionLog, scen
|
||||
listTools() {
|
||||
const { config } = getRuntimeContext();
|
||||
return tools
|
||||
.filter((tool) => config.toolProfile === 'full' || tool.profile === 'core')
|
||||
.filter((tool) => isToolExposed(config || {}, tool))
|
||||
.map((tool) => ({
|
||||
name: tool.name,
|
||||
description: tool.description,
|
||||
inputSchema: tool.inputSchema,
|
||||
}));
|
||||
},
|
||||
listToolCatalog() {
|
||||
const { config } = getRuntimeContext();
|
||||
return tools.map((tool) => ({
|
||||
name: tool.name,
|
||||
description: tool.description,
|
||||
profile: tool.profile,
|
||||
category: toolCategory(tool),
|
||||
enabled: isToolExposed(config || {}, tool),
|
||||
}));
|
||||
},
|
||||
async callToolDetailed(name, args) {
|
||||
const { config } = getRuntimeContext();
|
||||
const tool = tools.find((item) => item.name === name);
|
||||
if (!tool) {
|
||||
throw new Error(`Unknown tool '${name}'`);
|
||||
}
|
||||
if (config.toolProfile !== 'full' && tool.profile !== 'core') {
|
||||
if (!isToolExposed(config || {}, tool)) {
|
||||
throw new Error(`Tool '${name}' is not exposed by the current MCP tool profile '${config.toolProfile}'.`);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
'use strict';
|
||||
|
||||
const https = require('https');
|
||||
|
||||
const LATEST_RELEASE_URL = 'https://api.github.com/repos/FunplayAI/funplay-cocos-mcp/releases/latest';
|
||||
|
||||
function normalizeVersion(value) {
|
||||
return String(value || '')
|
||||
.trim()
|
||||
.replace(/^v/i, '')
|
||||
.split(/[+-]/)[0];
|
||||
}
|
||||
|
||||
function parseVersion(value) {
|
||||
return normalizeVersion(value)
|
||||
.split('.')
|
||||
.map((part) => Number.parseInt(part, 10))
|
||||
.map((part) => (Number.isFinite(part) ? part : 0));
|
||||
}
|
||||
|
||||
function compareVersions(left, right) {
|
||||
const leftParts = parseVersion(left);
|
||||
const rightParts = parseVersion(right);
|
||||
const length = Math.max(leftParts.length, rightParts.length, 3);
|
||||
for (let index = 0; index < length; index += 1) {
|
||||
const leftPart = leftParts[index] || 0;
|
||||
const rightPart = rightParts[index] || 0;
|
||||
if (leftPart > rightPart) return 1;
|
||||
if (leftPart < rightPart) return -1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
function fetchJson(url, timeoutMs = 5000) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = https.get(
|
||||
url,
|
||||
{
|
||||
headers: {
|
||||
Accept: 'application/vnd.github+json',
|
||||
'User-Agent': 'funplay-cocos-mcp-update-checker',
|
||||
},
|
||||
timeout: timeoutMs,
|
||||
},
|
||||
(response) => {
|
||||
const chunks = [];
|
||||
response.on('data', (chunk) => chunks.push(chunk));
|
||||
response.on('end', () => {
|
||||
const body = Buffer.concat(chunks).toString('utf8');
|
||||
if (response.statusCode < 200 || response.statusCode >= 300) {
|
||||
reject(new Error(`GitHub returned HTTP ${response.statusCode}: ${body.slice(0, 160)}`));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
resolve(JSON.parse(body));
|
||||
} catch (error) {
|
||||
reject(new Error(`Failed to parse GitHub response: ${error.message}`));
|
||||
}
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
request.on('timeout', () => {
|
||||
request.destroy(new Error(`Update check timed out after ${timeoutMs}ms.`));
|
||||
});
|
||||
request.on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
async function checkForUpdate(options = {}) {
|
||||
const currentVersion = normalizeVersion(options.currentVersion || '0.0.0');
|
||||
const checkedAt = new Date().toISOString();
|
||||
|
||||
try {
|
||||
const release = await fetchJson(options.url || LATEST_RELEASE_URL, options.timeoutMs || 5000);
|
||||
const latestVersion = normalizeVersion(release.tag_name || release.name || '');
|
||||
const comparison = latestVersion ? compareVersions(latestVersion, currentVersion) : 0;
|
||||
return {
|
||||
ok: true,
|
||||
checkedAt,
|
||||
currentVersion,
|
||||
latestVersion,
|
||||
updateAvailable: comparison > 0,
|
||||
releaseUrl: release.html_url || '',
|
||||
publishedAt: release.published_at || '',
|
||||
source: options.url || LATEST_RELEASE_URL,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
ok: false,
|
||||
checkedAt,
|
||||
currentVersion,
|
||||
latestVersion: '',
|
||||
updateAvailable: false,
|
||||
releaseUrl: '',
|
||||
publishedAt: '',
|
||||
source: options.url || LATEST_RELEASE_URL,
|
||||
error: error.message,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
LATEST_RELEASE_URL,
|
||||
checkForUpdate,
|
||||
compareVersions,
|
||||
normalizeVersion,
|
||||
};
|
||||
Reference in New Issue
Block a user