'use strict'; const path = require('path'); const fs = require('fs'); const os = require('os'); const manifest = require('./package.json'); const { configureTarget, getTargetStatuses } = require('./lib/client-config'); const { loadConfig, getProjectPath, getProjectName, getCocosVersion } = require('./lib/config'); const { McpServer } = require('./lib/server'); const { createToolRegistry } = require('./lib/tool-registry'); const { ResourceProvider } = require('./lib/resources'); const { PromptProvider } = require('./lib/prompts'); const { InteractionLog } = require('./lib/interaction-log'); const { RuntimeLog } = require('./lib/runtime-log'); const { checkForUpdate } = require('./lib/update-checker'); const EXTENSION_NAME = manifest.name || 'funplay-cocos-mcp'; const LOG_PREFIX = '[Funplay Cocos MCP]'; const REPOSITORY_URL = 'https://github.com/FunplayAI/funplay-cocos-mcp'; const AsyncFunction = Object.getPrototypeOf(async function () {}).constructor; class ExtensionService { constructor() { this.config = null; this.server = null; this.toolRegistry = null; this.resourceProvider = null; this.promptProvider = null; this.interactionLog = new InteractionLog(); this.runtimeLog = new RuntimeLog(); this.lastUpdateInfo = null; } log(level, message, details) { if (this.runtimeLog && typeof this.runtimeLog.add === 'function') { this.runtimeLog.add(level, message, details); } const output = `${LOG_PREFIX} ${message}`; if (level === 'error') { console.error(output); } else if (level === 'warn') { console.warn(output); } else { console.log(output); } } load() { this.log('info', 'Extension loading...'); this.reloadRuntime(); if (this.config.autostart) { this.log('info', 'Autostart is enabled, starting MCP server.'); return this.startServer(); } this.log('info', 'Autostart is disabled. MCP server is idle.'); return this.getStatus(); } unload() { this.log('info', 'Extension unloading...'); if (this.server) { this.server.stop(); this.server = null; } this.log('info', 'Extension unloaded.'); } openPanel() { if (!global.Editor || !Editor.Panel || typeof Editor.Panel.open !== 'function') { throw new Error('Editor.Panel.open is unavailable in this Cocos extension host.'); } return Editor.Panel.open(EXTENSION_NAME); } reloadRuntime() { this.config = loadConfig(); this.interactionLog = new InteractionLog(this.config.maxInteractionLogEntries); this.runtimeLog = new RuntimeLog(this.config.maxInteractionLogEntries); this.log( 'info', `Runtime config loaded: host=${this.config.host}, port=${this.config.port}, ` + `profile=${this.config.toolProfile}, autostart=${this.config.autostart}` ); const sceneBridge = { call: async (method, payload) => { if (!global.Editor || !Editor.Message || typeof Editor.Message.request !== 'function') { throw new Error('Editor.Message.request is unavailable in the Cocos extension host.'); } return await Editor.Message.request('scene', 'execute-scene-script', { name: EXTENSION_NAME, method, args: [payload || {}], }); }, }; const runtimeContext = () => ({ extensionName: EXTENSION_NAME, version: manifest.version || '0.0.0', config: this.config, projectPath: getProjectPath(), projectName: getProjectName(), cocosVersion: getCocosVersion(), packagePath: path.dirname(__filename), }); this.toolRegistry = createToolRegistry({ getRuntimeContext: runtimeContext, getStatus: () => this.getStatus(), interactionLog: this.interactionLog, runtimeLog: this.runtimeLog, sceneBridge, editorExecutor: async (payload) => await this.executeEditorScript(payload, runtimeContext), }); this.resourceProvider = new ResourceProvider(runtimeContext, sceneBridge, this.interactionLog, this.runtimeLog); this.promptProvider = new PromptProvider(runtimeContext); } async startServer() { if (this.server && this.server.isRunning()) { this.log('info', `Start requested but MCP server is already running at ${this.getStatus().url}`); return this.getStatus(); } this.log('info', 'Starting MCP server...'); this.reloadRuntime(); this.server = new McpServer({ config: this.config, interactionLog: this.interactionLog, runtimeLog: this.runtimeLog, toolRegistry: this.toolRegistry, resourceProvider: this.resourceProvider, promptProvider: this.promptProvider, serverName: `Funplay Cocos MCP - ${getProjectName()}`, serverVersion: manifest.version || '0.0.0', }); await this.server.start(); this.log('info', `MCP server started at ${this.getStatus().url}`); this.log('info', `If this tool saves you time, please consider giving it a Star on GitHub: ${REPOSITORY_URL}`); return this.getStatus(); } async stopServer() { this.log('info', 'Stop requested.'); if (this.server) { await this.server.stop(); this.server = null; this.log('info', 'MCP server stopped.'); } else { this.log('info', 'Stop requested but MCP server was not running.'); } return this.getStatus(); } async restartServer() { this.log('info', 'Restart requested.'); await this.stopServer(); const status = await this.startServer(); this.log('info', `Restart completed. MCP server running=${status.running}, url=${status.url}`); return status; } getEffectiveServerConnection() { const port = this.server && this.server.isRunning() && typeof this.server.getPort === 'function' ? this.server.getPort() : this.config.port; return { host: this.config.host, port, url: `http://${this.config.host}:${port}/`, }; } getStatus() { const effective = this.getEffectiveServerConnection(); const fallbackInfo = this.server && this.server.isRunning() && typeof this.server.getPortFallbackInfo === 'function' ? this.server.getPortFallbackInfo() : null; return { running: Boolean(this.server && this.server.isRunning()), host: this.config.host, port: effective.port, requestedPort: this.config.port, portFallbackActive: Boolean(fallbackInfo), portFallbackInfo: fallbackInfo, toolProfile: this.config.toolProfile, enabledTools: this.config.enabledTools, disabledTools: this.config.disabledTools, enabledToolCategories: this.config.enabledToolCategories, disabledToolCategories: this.config.disabledToolCategories, enableSessions: this.config.enableSessions, autostart: this.config.autostart, version: manifest.version || '0.0.0', projectPath: getProjectPath(), projectName: getProjectName(), cocosVersion: getCocosVersion(), url: effective.url, }; } getPanelState() { this.ensureRuntime(); const status = this.getStatus(); const tools = this.toolRegistry.listTools(); const toolCatalog = typeof this.toolRegistry.listToolCatalog === 'function' ? this.toolRegistry.listToolCatalog() : tools; const resources = this.resourceProvider.listResources(); const prompts = this.promptProvider.listPrompts(); return { status, tools, toolCatalog, resources, prompts, recentInteractions: this.interactionLog.list(20), recentRuntimeLogs: this.runtimeLog.list(20), config: this.config, updateInfo: this.lastUpdateInfo, clientConfig: this.getClientConfig(), clientTargets: getTargetStatuses(this.config), }; } listToolsForPanel() { this.ensureRuntime(); return this.toolRegistry.listTools(); } async callToolFromPanel(name, args) { this.ensureRuntime(); this.log('info', `Panel calling tool: ${name}`); return await this.toolRegistry.callTool(name, args || {}); } async checkUpdates() { this.ensureRuntime(); this.log('info', 'Checking GitHub for newer Funplay Cocos MCP releases.'); this.lastUpdateInfo = await checkForUpdate({ currentVersion: manifest.version || '0.0.0' }); if (this.lastUpdateInfo.ok) { this.log( 'info', `Update check completed: current=${this.lastUpdateInfo.currentVersion}, latest=${this.lastUpdateInfo.latestVersion || 'unknown'}` ); } else { this.log('warn', `Update check failed: ${this.lastUpdateInfo.error}`); } return this.getPanelState(); } async executeEditorScript(payload, runtimeContext) { const code = String(payload && payload.code || ''); if (!code.trim()) { throw new Error('code is required.'); } const args = payload && payload.args ? payload.args : {}; const context = runtimeContext(); const helpers = { getStatus: () => this.getStatus(), listTools: () => this.toolRegistry.listTools(), readResource: async (uri) => await this.resourceProvider.readResource(uri), callTool: async (name, toolArgs) => await this.toolRegistry.callTool(name, toolArgs || {}), listClientTargets: () => getTargetStatuses(this.config), getClientConfig: () => this.getClientConfig(), configureClient: async (targetId) => this.configureClient(targetId), }; const runner = new AsyncFunction( 'require', 'Editor', 'args', 'context', 'helpers', 'fs', 'path', 'os', ` const module = { exports: {} }; const exports = module.exports; ${code} if (typeof run === 'function') { return await run({ Editor, args, context, helpers, fs, path, os, require }); } if (typeof module.exports === 'function') { return await module.exports({ Editor, args, context, helpers, fs, path, os, require }); } if (module.exports && typeof module.exports.run === 'function') { return await module.exports.run({ Editor, args, context, helpers, fs, path, os, require }); } ` ); return await runner(require, global.Editor, args, context, helpers, fs, path, os); } async readResourceFromPanel(uri) { this.ensureRuntime(); this.log('info', `Panel reading resource: ${uri}`); return await this.resourceProvider.readResource(uri); } getClientConfig() { const { url } = this.getEffectiveServerConnection(); return { url, codex: `[mcp_servers.funplay_cocos]\nurl = "${url}"\n`, json: JSON.stringify({ mcpServers: { funplay_cocos: { url, }, }, }, null, 2), }; } configureClient(targetId) { this.ensureRuntime(); this.log('info', `Configuring MCP client target: ${targetId}`); const effective = this.getEffectiveServerConnection(); if (effective.port !== this.config.port) { this.log( 'info', `Using actual running port ${effective.port} for MCP client configuration ` + `(requested: ${this.config.port}).` ); } const result = configureTarget( { ...this.config, host: effective.host, port: effective.port, }, targetId ); this.log('info', `MCP client configured: ${result.name} -> ${result.configPath}`); return { ...result, clientTargets: getTargetStatuses(this.config), }; } async saveConfig(partialConfig) { this.ensureRuntime(); const nextPort = partialConfig && partialConfig.port !== undefined ? Number(partialConfig.port) : this.config.port; const nextMaxEntries = partialConfig && partialConfig.maxInteractionLogEntries !== undefined ? Number(partialConfig.maxInteractionLogEntries) : this.config.maxInteractionLogEntries; const normalizeList = (value, fallback) => { 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 fallback || []; }; const normalizeCategories = (value, fallback) => normalizeList(value, fallback) .map((item) => item.toLowerCase()); const nextProfile = partialConfig && partialConfig.toolProfile ? (partialConfig.toolProfile === 'full' || partialConfig.toolProfile === 'custom' ? partialConfig.toolProfile : 'core') : this.config.toolProfile; const nextConfig = { host: partialConfig && partialConfig.host ? String(partialConfig.host) : this.config.host, port: Number.isInteger(nextPort) && nextPort > 0 && nextPort <= 65535 ? nextPort : this.config.port, toolProfile: nextProfile, enabledTools: normalizeList(partialConfig && partialConfig.enabledTools, this.config.enabledTools), disabledTools: normalizeList(partialConfig && partialConfig.disabledTools, this.config.disabledTools), enabledToolCategories: normalizeCategories( partialConfig && partialConfig.enabledToolCategories, this.config.enabledToolCategories ), disabledToolCategories: normalizeCategories( partialConfig && partialConfig.disabledToolCategories, this.config.disabledToolCategories ), enableSessions: partialConfig && typeof partialConfig.enableSessions === 'boolean' ? partialConfig.enableSessions : this.config.enableSessions, autostart: partialConfig && typeof partialConfig.autostart === 'boolean' ? partialConfig.autostart : this.config.autostart, maxInteractionLogEntries: Number.isInteger(nextMaxEntries) ? Math.max(10, Math.min(500, nextMaxEntries)) : this.config.maxInteractionLogEntries, lastClientTargetId: partialConfig && partialConfig.lastClientTargetId ? String(partialConfig.lastClientTargetId) : this.config.lastClientTargetId, }; const configPath = this.config.configPath; fs.writeFileSync(configPath, JSON.stringify(nextConfig, null, 2) + '\n', 'utf8'); const wasRunning = Boolean(this.server && this.server.isRunning()); const requiresRestart = wasRunning && ( nextConfig.host !== this.config.host || nextConfig.port !== this.config.port || nextConfig.toolProfile !== this.config.toolProfile || nextConfig.enableSessions !== this.config.enableSessions || JSON.stringify(nextConfig.enabledTools) !== JSON.stringify(this.config.enabledTools) || JSON.stringify(nextConfig.disabledTools) !== JSON.stringify(this.config.disabledTools) || JSON.stringify(nextConfig.enabledToolCategories) !== JSON.stringify(this.config.enabledToolCategories) || JSON.stringify(nextConfig.disabledToolCategories) !== JSON.stringify(this.config.disabledToolCategories) ); if (requiresRestart) { await this.stopServer(); } this.reloadRuntime(); if (requiresRestart) { await this.startServer(); } return this.getPanelState(); } ensureRuntime() { if (!this.config || !this.toolRegistry || !this.resourceProvider || !this.promptProvider) { this.reloadRuntime(); } } } const service = new ExtensionService(); module.exports = { load() { return service.load(); }, unload() { return service.unload(); }, methods: { openPanel() { return service.openPanel(); }, startServer() { return service.startServer(); }, stopServer() { return service.stopServer(); }, restartServer() { return service.restartServer(); }, getStatus() { return service.getStatus(); }, getPanelState() { return service.getPanelState(); }, saveConfig(config) { return service.saveConfig(config); }, listToolsForPanel() { return service.listToolsForPanel(); }, callToolFromPanel(name, args) { return service.callToolFromPanel(name, args); }, checkUpdates() { return service.checkUpdates(); }, readResourceFromPanel(uri) { return service.readResourceFromPanel(uri); }, getClientConfig() { return service.getClientConfig(); }, configureClient(targetId) { return service.configureClient(targetId); }, }, };