UNPKG

@akiojin/unity-editor-mcp

Version:

MCP server for Unity Editor integration - enables AI assistants to control Unity Editor

427 lines (370 loc) 14.7 kB
import net from 'net'; import { EventEmitter } from 'events'; import { config, logger } from './config.js'; /** * Manages TCP connection to Unity Editor */ export class UnityConnection extends EventEmitter { constructor() { super(); this.socket = null; this.connected = false; this.reconnectAttempts = 0; this.reconnectTimer = null; this.commandId = 0; this.pendingCommands = new Map(); this.isDisconnecting = false; this.messageBuffer = Buffer.alloc(0); // Simple concurrency limiter and send queue to avoid flooding Unity this.sendQueue = []; this.inFlight = 0; this.maxInFlight = 1; // process one command at a time by default } /** * Connects to Unity Editor * @returns {Promise<void>} */ async connect() { return new Promise((resolve, reject) => { if (this.connected) { resolve(); return; } // Skip connection in CI/test environments if (process.env.NODE_ENV === 'test' || process.env.CI === 'true') { logger.info('Skipping Unity connection in test/CI environment'); reject(new Error('Unity connection disabled in test environment')); return; } logger.info(`Connecting to Unity at ${config.unity.host}:${config.unity.port}...`); this.socket = new net.Socket(); let connectionTimeout = null; let resolved = false; // Helper to clean up the connection timeout const clearConnectionTimeout = () => { if (connectionTimeout) { clearTimeout(connectionTimeout); connectionTimeout = null; } }; // Set up event handlers this.socket.on('connect', () => { logger.info('Connected to Unity Editor'); this.connected = true; this.reconnectAttempts = 0; resolved = true; clearConnectionTimeout(); this.emit('connected'); resolve(); }); this.socket.on('data', (data) => { this.handleData(data); }); this.socket.on('error', (error) => { logger.error('Socket error:', error.message); this.emit('error', error); if (!this.connected && !resolved) { resolved = true; clearConnectionTimeout(); // Mark as disconnecting to prevent reconnection this.isDisconnecting = true; // Destroy the socket to clean up properly this.socket.destroy(); this.isDisconnecting = false; reject(error); } }); this.socket.on('close', () => { // Clear the connection timeout when socket closes clearConnectionTimeout(); // Check if we're already handling disconnection if (this.isDisconnecting || !this.socket) { return; } logger.info('Disconnected from Unity Editor'); this.connected = false; const wasSocket = this.socket; this.socket = null; // Clear message buffer this.messageBuffer = Buffer.alloc(0); // Clear pending commands for (const [id, pending] of this.pendingCommands) { pending.reject(new Error('Connection closed')); } this.pendingCommands.clear(); // Emit disconnected event this.emit('disconnected'); // Attempt reconnection only if not intentionally disconnecting if (!this.isDisconnecting && process.env.DISABLE_AUTO_RECONNECT !== 'true') { this.scheduleReconnect(); } }); // Attempt connection this.socket.connect(config.unity.port, config.unity.host); // Set timeout for initial connection connectionTimeout = setTimeout(() => { if (!this.connected && !resolved && this.socket) { resolved = true; // Remove event listeners before destroying to prevent callbacks after timeout this.socket.removeAllListeners(); this.socket.destroy(); reject(new Error('Connection timeout')); } }, config.unity.commandTimeout); }); } /** * Disconnects from Unity Editor */ disconnect() { this.isDisconnecting = true; if (this.reconnectTimer) { clearTimeout(this.reconnectTimer); this.reconnectTimer = null; } if (this.socket) { try { // Remove all listeners before destroying to prevent async callbacks this.socket.removeAllListeners(); this.socket.destroy(); } catch (error) { // Ignore errors during cleanup } this.socket = null; } this.connected = false; this.isDisconnecting = false; } /** * Schedules a reconnection attempt */ scheduleReconnect() { if (this.reconnectTimer) { return; } const delay = Math.min( config.unity.reconnectDelay * Math.pow(config.unity.reconnectBackoffMultiplier, this.reconnectAttempts), config.unity.maxReconnectDelay ); logger.info(`Scheduling reconnection in ${delay}ms (attempt ${this.reconnectAttempts + 1})`); this.reconnectTimer = setTimeout(() => { this.reconnectTimer = null; this.reconnectAttempts++; this.connect().catch((error) => { logger.error('Reconnection failed:', error.message); }); }, delay); } /** * Handles incoming data from Unity * @param {Buffer} data */ handleData(data) { // Check if this is an unframed Unity debug log if (data.length > 0 && !this.messageBuffer.length) { const dataStr = data.toString('utf8'); if (dataStr.startsWith('[Unity Editor MCP]') || dataStr.startsWith('[Unity]')) { logger.debug(`[Unity] Received unframed debug log: ${dataStr.trim()}`); // Don't process unframed logs as messages return; } } // Append new data to buffer this.messageBuffer = Buffer.concat([this.messageBuffer, data]); // Process complete messages while (this.messageBuffer.length >= 4) { // Read message length (first 4 bytes, big-endian) const messageLength = this.messageBuffer.readInt32BE(0); // Validate message length if (messageLength < 0 || messageLength > 1024 * 1024) { // Max 1MB messages logger.error(`[Unity] Invalid message length: ${messageLength}`); // Try to recover by looking for valid framed message // Look for a reasonable length value (positive, less than 10KB for typical responses) let recoveryIndex = -1; for (let i = 4; i < Math.min(this.messageBuffer.length - 4, 100); i++) { const testLength = this.messageBuffer.readInt32BE(i); if (testLength > 0 && testLength < 10240) { // Check if this could be a valid JSON message if (i + 4 + testLength <= this.messageBuffer.length) { const testData = this.messageBuffer.slice(i + 4, i + 4 + testLength).toString('utf8'); if (testData.trim().startsWith('{')) { recoveryIndex = i; break; } } } } if (recoveryIndex > 0) { logger.warn(`[Unity] Discarding ${recoveryIndex} bytes of invalid data`); this.messageBuffer = this.messageBuffer.slice(recoveryIndex); continue; } else { // Can't recover, clear buffer logger.error('[Unity] Unable to recover from invalid frame, clearing buffer'); this.messageBuffer = Buffer.alloc(0); break; } } // Check if we have the complete message if (this.messageBuffer.length >= 4 + messageLength) { // Extract message const messageData = this.messageBuffer.slice(4, 4 + messageLength); this.messageBuffer = this.messageBuffer.slice(4 + messageLength); // Process the message try { const message = messageData.toString('utf8'); // Skip non-JSON messages (like debug logs) if (!message.trim().startsWith('{')) { logger.warn(`[Unity] Skipping non-JSON message: ${message.substring(0, 50)}...`); continue; } logger.debug(`[Unity] Received framed message (length=${message.length})`); const response = JSON.parse(message); logger.debug(`[Unity] Parsed response id=${response.id || 'n/a'} status=${response.status || (response.success === false ? 'error' : 'success')}`); // Check if this is a response to a pending command if (response.id && this.pendingCommands.has(response.id)) { logger.info(`[Unity] Found pending command for ID ${response.id}`); const pending = this.pendingCommands.get(response.id); this.pendingCommands.delete(response.id); // Handle both old and new response formats if (response.status === 'success' || response.success === true) { logger.info(`[Unity] Command ${response.id} succeeded`); let result = response.result || response.data || {}; // If result is a string, try to parse it as JSON if (typeof result === 'string') { try { result = JSON.parse(result); logger.info(`[Unity] Parsed string result as JSON:`, result); } catch (parseError) { logger.warn(`[Unity] Failed to parse result as JSON: ${parseError.message}`); // Keep the original string value } } // Include version and editorState information if available if (response.version) { result._version = response.version; } if (response.editorState) { result._editorState = response.editorState; } logger.info(`[Unity] Command ${response.id} resolved successfully`); pending.resolve(result); } else if (response.status === 'error' || response.success === false) { logger.error(`[Unity] Command ${response.id} failed:`, response.error); pending.reject(new Error(response.error || 'Command failed')); } else { // Unknown format logger.warn(`[Unity] Command ${response.id} has unknown response format`); pending.resolve(response); } } else { // Handle unsolicited messages logger.debug(`[Unity] Received unsolicited message id=${response.id || 'n/a'}`); this.emit('message', response); } } catch (error) { logger.error('[Unity] Failed to parse response:', error.message); logger.debug(`[Unity] Raw message: ${messageData.toString().substring(0, 200)}...`); // Check if this looks like a Unity log message const messageStr = messageData.toString(); if (messageStr.includes('[Unity Editor MCP]')) { logger.debug('[Unity] Received Unity log message instead of JSON response'); // Don't treat this as a critical error } } } else { // Not enough data yet, wait for more break; } } } /** * Sends a command to Unity * @param {string} type - Command type * @param {object} params - Command parameters * @returns {Promise<any>} - Response from Unity */ async sendCommand(type, params = {}) { logger.info(`[Unity] enqueue sendCommand: ${type}`, { connected: this.connected }); if (!this.connected) { logger.error('[Unity] Cannot send command - not connected'); throw new Error('Not connected to Unity'); } // Create an external promise that will resolve when Unity responds return new Promise((outerResolve, outerReject) => { const task = { type, params, outerResolve, outerReject }; this.sendQueue.push(task); this._pumpQueue(); }); } _pumpQueue() { if (!this.connected) return; if (this.inFlight >= this.maxInFlight) return; const task = this.sendQueue.shift(); if (!task) return; const id = String(++this.commandId); const command = { id, type: task.type, params: task.params }; const json = JSON.stringify(command); const messageBuffer = Buffer.from(json, 'utf8'); const lengthBuffer = Buffer.allocUnsafe(4); lengthBuffer.writeInt32BE(messageBuffer.length, 0); const framedMessage = Buffer.concat([lengthBuffer, messageBuffer]); this.inFlight++; logger.info(`[Unity] Dispatching command ${id}: ${task.type}`); // Set up timeout only when actually dispatched const timeout = setTimeout(() => { logger.error(`[Unity] Command ${id} timed out after ${config.unity.commandTimeout}ms`); this.pendingCommands.delete(id); this.inFlight = Math.max(0, this.inFlight - 1); task.outerReject(new Error('Command timeout')); this._pumpQueue(); }, config.unity.commandTimeout); // Store pending with wrappers to manage queue progression this.pendingCommands.set(id, { resolve: (data) => { logger.info(`[Unity] Command ${id} resolved`); clearTimeout(timeout); try { task.outerResolve(data); } finally { this.inFlight = Math.max(0, this.inFlight - 1); this._pumpQueue(); } }, reject: (error) => { logger.error(`[Unity] Command ${id} rejected: ${error.message}`); clearTimeout(timeout); try { task.outerReject(error); } finally { this.inFlight = Math.max(0, this.inFlight - 1); this._pumpQueue(); } } }); // Send framed message this.socket.write(framedMessage, (error) => { if (error) { logger.error(`[Unity] Failed to write command ${id}: ${error.message}`); clearTimeout(timeout); this.pendingCommands.delete(id); this.inFlight = Math.max(0, this.inFlight - 1); task.outerReject(error); this._pumpQueue(); } else { logger.debug(`[Unity] Command ${id} written; awaiting response`); } }); } /** * Sends a ping command to Unity * @returns {Promise<any>} */ async ping() { // Use normal command sending for ping with proper framing return this.sendCommand('ping', {}); } /** * Checks if connected to Unity * @returns {boolean} */ isConnected() { return this.connected; } }