Release v0.2.0

This commit is contained in:
winlifes
2026-05-20 06:06:36 -07:00
parent e01f058e3e
commit 3ab48e2457
18 changed files with 1417 additions and 99 deletions
+32 -1
View File
@@ -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,
};
+6
View File
@@ -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
View File
@@ -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
View File
@@ -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 = {
+46
View File
@@ -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
View File
@@ -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
View File
@@ -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}'.`);
}
+109
View File
@@ -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,
};