'use strict'; const http = require('http'); const IMAGE_DATA_URI_PREFIX = 'data:image/png;base64,'; const LOG_PREFIX = '[Funplay Cocos MCP Server]'; const MAX_PORT_FALLBACK_ATTEMPTS = 20; function json(response, statusCode, payload) { response.writeHead(statusCode, { 'Content-Type': 'application/json; charset=utf-8' }); response.end(JSON.stringify(payload)); } function textContent(value) { if (typeof value === 'string' && value.startsWith(IMAGE_DATA_URI_PREFIX)) { return [ { type: 'image', data: value.slice(IMAGE_DATA_URI_PREFIX.length), mimeType: 'image/png', }, { type: 'text', text: 'Screenshot captured successfully.', }, ]; } return [ { type: 'text', text: typeof value === 'string' ? value : JSON.stringify(value, null, 2), }, ]; } class McpServer { constructor(options) { this.config = options.config; this.toolRegistry = options.toolRegistry; this.resourceProvider = options.resourceProvider; this.promptProvider = options.promptProvider; this.interactionLog = options.interactionLog; this.serverName = options.serverName; this.serverVersion = options.serverVersion; this.server = null; this.actualPort = null; this.portFallbackInfo = null; } isRunning() { return Boolean(this.server && this.server.listening); } getPort() { if (this.server && typeof this.server.address === 'function') { const address = this.server.address(); if (address && typeof address.port === 'number') { return address.port; } } return this.actualPort || this.config.port; } getRequestedPort() { return this.config.port; } getPortFallbackInfo() { return this.portFallbackInfo; } async start() { if (this.isRunning()) { console.log(`${LOG_PREFIX} Start skipped: already running.`); return; } this.actualPort = null; this.portFallbackInfo = null; const requestHandler = async (request, response) => { try { if (request.method === 'GET' && request.url === '/health') { console.log(`${LOG_PREFIX} GET /health`); return json(response, 200, { ok: true, name: this.serverName, version: this.serverVersion }); } if (request.method !== 'POST') { console.warn(`${LOG_PREFIX} Rejected ${request.method} ${request.url}: method not allowed.`); return json(response, 405, { error: 'Method Not Allowed' }); } const body = await this.readBody(request); if (!body) { return json(response, 400, this.createError(null, -32700, 'Parse error: empty body')); } const rpc = JSON.parse(body); if (rpc && rpc.method) { console.log(`${LOG_PREFIX} RPC ${rpc.method}`); } const result = await this.handleRpcRequest(rpc); if (result == null) { response.writeHead(204); response.end(); return; } return json(response, 200, result); } catch (error) { console.error(`${LOG_PREFIX} Request handling failed: ${error.message}`); return json(response, 500, this.createError(null, -32603, `Internal error: ${error.message}`)); } }; let attempt = 0; let port = this.config.port; let lastError = null; while (attempt <= MAX_PORT_FALLBACK_ATTEMPTS) { console.log(`${LOG_PREFIX} Creating HTTP server on ${this.config.host}:${port}...`); const candidate = http.createServer(requestHandler); try { await this.listen(candidate, port, this.config.host); this.server = candidate; this.actualPort = candidate.address() && typeof candidate.address().port === 'number' ? candidate.address().port : port; if (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}.` ); } console.log(`${LOG_PREFIX} 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}...` ); port = nextPort; attempt += 1; continue; } candidate.removeAllListeners(); break; } } this.server = null; this.actualPort = null; this.portFallbackInfo = null; throw lastError || new Error('Failed to start MCP server.'); } async stop() { if (!this.server) { console.log(`${LOG_PREFIX} Stop skipped: server object is empty.`); return; } console.log(`${LOG_PREFIX} Closing HTTP server...`); const active = this.server; this.server = null; this.actualPort = null; this.portFallbackInfo = null; await new Promise((resolve, reject) => { active.close((error) => { if (error) { console.error(`${LOG_PREFIX} Close failed: ${error.message}`); reject(error); return; } console.log(`${LOG_PREFIX} HTTP server closed.`); resolve(); }); }); } listen(server, port, host) { return new Promise((resolve, reject) => { const onError = (error) => { server.off('listening', onListening); reject(error); }; const onListening = () => { server.off('error', onError); resolve(); }; server.once('error', onError); server.once('listening', onListening); server.listen(port, host); }); } readBody(request) { return new Promise((resolve, reject) => { const chunks = []; request.on('data', (chunk) => chunks.push(chunk)); request.on('end', () => resolve(Buffer.concat(chunks).toString('utf8'))); request.on('error', reject); }); } async handleRpcRequest(request) { if (!request || request.jsonrpc !== '2.0') { return this.createError(request && request.id, -32600, 'Invalid Request'); } const method = request.method; if (typeof method !== 'string' || !method) { return this.createError(request.id, -32600, 'Invalid Request: method is required'); } if (method === 'initialize') { return this.createResult(request.id, { protocolVersion: '2024-11-05', serverInfo: { name: this.serverName, version: this.serverVersion, }, capabilities: { tools: {}, resources: {}, prompts: {}, }, }); } if (method === 'notifications/initialized' || method === 'notifications/cancelled' || method.startsWith('notifications/')) { return null; } if (method === 'tools/list') { return this.createResult(request.id, { tools: this.toolRegistry.listTools() }); } if (method === 'tools/call') { const params = request.params || {}; if (typeof params.name !== 'string' || !params.name) { return this.createError(request.id, -32602, "Invalid params: 'name' is required"); } try { const output = await this.toolRegistry.callTool(params.name, params.arguments || {}); return this.createResult(request.id, { content: textContent(output) }); } catch (error) { return this.createError(request.id, -32603, error.message); } } if (method === 'resources/list') { return this.createResult(request.id, { resources: this.resourceProvider.listResources() }); } if (method === 'resources/read') { const params = request.params || {}; if (typeof params.uri !== 'string' || !params.uri) { return this.createError(request.id, -32602, "Invalid params: 'uri' is required"); } return this.createResult(request.id, await this.resourceProvider.readResource(params.uri)); } if (method === 'resources/templates/list') { return this.createResult(request.id, { resourceTemplates: this.resourceProvider.listResourceTemplates() }); } if (method === 'prompts/list') { return this.createResult(request.id, { prompts: this.promptProvider.listPrompts() }); } if (method === 'prompts/get') { const params = request.params || {}; if (typeof params.name !== 'string' || !params.name) { return this.createError(request.id, -32602, "Invalid params: 'name' is required"); } return this.createResult(request.id, this.promptProvider.getPrompt(params.name, params.arguments || {})); } return this.createError(request.id, -32601, `Method not found: ${method}`); } createResult(id, result) { return { jsonrpc: '2.0', id, result, }; } createError(id, code, message) { return { jsonrpc: '2.0', id, error: { code, message, }, }; } } module.exports = { McpServer, };