import { Server } from '@modelcontextprotocol/sdk/server/index.js'; import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; import { Logger } from './utils/logger.js'; import { UnrealBridge } from './unreal-bridge.js'; import { AutomationBridge } from './automation/index.js'; import { createRequire } from 'node:module'; import { responseValidator } from './utils/response-validator.js'; import { z } from 'zod'; import { consolidatedToolDefinitions } from './tools/consolidated-tool-definitions.js'; import { HealthMonitor } from './services/health-monitor.js'; import { ServerSetup } from './server-setup.js'; import { startMetricsServer } from './services/metrics-server.js'; import { config } from './config.js'; const require = createRequire(import.meta.url); const packageInfo: { name?: string; version?: string } = (() => { try { return require('../package.json'); } catch (error) { const log = new Logger('UE-MCP'); log.debug('Unable to read package.json for server metadata', error); return {}; } })(); const DEFAULT_SERVER_NAME = typeof packageInfo.name === 'string' && packageInfo.name.trim().length > 0 ? packageInfo.name : 'unreal-engine-mcp'; const DEFAULT_SERVER_VERSION = typeof packageInfo.version === 'string' && packageInfo.version.trim().length > 0 ? packageInfo.version : '0.5.30'; function routeStdoutLogsToStderr(): void { if (!config.MCP_ROUTE_STDOUT_LOGS) { return; } const writeToStderr = (...args: unknown[]): void => { const line = args .map((a) => (typeof a === 'string' ? a : JSON.stringify(a))) .join(' '); process.stderr.write(`${line}\n`); }; console.log = writeToStderr; console.info = writeToStderr; if (typeof console.debug === 'function') { console.debug = writeToStderr; } } const log = new Logger('UE-MCP'); // Ensure stdout remains JSON-only for MCP by routing logs to stderr unless opted out. routeStdoutLogsToStderr(); // Configuration const CONFIG = { MAX_RETRY_ATTEMPTS: 3, RETRY_DELAY_MS: 2000, SERVER_NAME: DEFAULT_SERVER_NAME, SERVER_VERSION: DEFAULT_SERVER_VERSION, AUTOMATION_HEARTBEAT_MS: 15000, HEALTH_CHECK_INTERVAL_MS: 30000 }; type MaybeClosableServer = { close?: () => void | Promise }; function getErrorCode(error: unknown): unknown { if (error && typeof error === 'object' && 'code' in error) { return (error as { code?: unknown }).code; } return undefined; } export function createServer() { const bridge = new UnrealBridge(); const healthMonitor = new HealthMonitor(log); const automationBridge = new AutomationBridge({ serverName: CONFIG.SERVER_NAME, serverVersion: CONFIG.SERVER_VERSION, heartbeatIntervalMs: CONFIG.AUTOMATION_HEARTBEAT_MS, clientMode: config.MCP_AUTOMATION_CLIENT_MODE }); bridge.setAutomationBridge(automationBridge); automationBridge.on('connected', ({ metadata, port, protocol }) => { log.info( `Automation bridge connected (port=${port}, protocol=${protocol ?? 'none'})`, metadata ); }); automationBridge.on('disconnected', ({ code, reason, port, protocol }) => { log.info( `Automation bridge disconnected (code=${code}, reason=${reason || 'n/a'}, port=${port}, protocol=${protocol ?? 'none'})` ); }); automationBridge.on('handshakeFailed', ({ reason, port }) => { log.warn(`Automation bridge handshake failed (port=${port}): ${reason}`); }); automationBridge.on('message', (message) => { log.debug('Automation bridge inbound message', message); }); automationBridge.on('error', (error) => { log.error('Automation bridge error', error); }); // Optionally expose Prometheus-style metrics via /metrics const metricsServer = startMetricsServer({ healthMonitor, automationBridge, logger: log }); // Initialize response validation with schemas log.debug('Initializing response validation...'); const toolDefs = consolidatedToolDefinitions as Array<{ name: string; outputSchema?: Record }>; toolDefs.forEach((tool) => { if (tool.outputSchema) { responseValidator.registerSchema(tool.name, tool.outputSchema); } }); log.debug(`Registered ${responseValidator.getStats().totalSchemas} output schemas for validation`); log.debug('Server starting without connecting to Unreal Engine'); healthMonitor.metrics.connectionStatus = 'disconnected'; const server = new Server( { name: CONFIG.SERVER_NAME, version: CONFIG.SERVER_VERSION }, { capabilities: { tools: { listChanged: true }, resources: {}, prompts: {} } } ); // Setup server using helper class const serverSetup = new ServerSetup(server, bridge, automationBridge, log, healthMonitor); serverSetup.setup(); // Register tools, resources, and prompts return { server, bridge, automationBridge, metricsServer }; } // Export configuration schema for session UI and runtime validation export const configSchema = z.object({ logLevel: z.enum(['debug', 'info', 'warn', 'error']).optional().default('info').describe('Runtime log level'), projectPath: z.string().optional().default('C:/Users/YourName/Documents/Unreal Projects/YourProject').describe('Absolute path to your Unreal .uproject file') }); // Default export for runtime configuration support. export default function createServerDefault({ config }: { config?: Record } = {}) { try { if (config) { if (typeof config.logLevel === 'string') process.env.LOG_LEVEL = config.logLevel; if (typeof config.projectPath === 'string' && (config.projectPath as string).trim()) process.env.UE_PROJECT_PATH = config.projectPath as string; } } catch (e) { const errObj = e as Record | null; log.debug('[createServerDefault] Failed to apply config to environment:', errObj?.message ? String(errObj.message) : String(e)); } const { server } = createServer(); return server; } export async function startStdioServer() { const { server, bridge, automationBridge, metricsServer } = createServer(); const transport = new StdioServerTransport(); let shuttingDown = false; const closeMetricsServer = async (): Promise => { if (!metricsServer) { return; } await new Promise((resolve) => { try { metricsServer.close((error?: Error) => { const errorCode = getErrorCode(error); if (error && errorCode !== 'ERR_SERVER_NOT_RUNNING') { log.warn('Failed to close metrics server cleanly', error); } resolve(); }); } catch (error) { const errorCode = getErrorCode(error); if (errorCode !== 'ERR_SERVER_NOT_RUNNING') { log.warn('Failed to close metrics server cleanly', error); } resolve(); } }); }; const handleShutdown = async (signal?: NodeJS.Signals) => { if (shuttingDown) { return; } shuttingDown = true; const reason = signal ? ` due to ${signal}` : ''; log.info(`Shutting down MCP server${reason}`); try { automationBridge.stop(); } catch (error) { log.warn('Failed to stop automation bridge cleanly', error); } try { bridge.dispose(); } catch (error) { log.warn('Failed to dispose Unreal bridge cleanly', error); } try { await closeMetricsServer(); } catch (error) { log.warn('Failed to close metrics server cleanly', error); } try { const closeServer = (server as MaybeClosableServer).close; if (typeof closeServer === 'function') { await closeServer.call(server); } } catch (error) { log.warn('Failed to close MCP server transport cleanly', error); } if (signal) { process.exit(0); } }; ['SIGINT', 'SIGTERM'].forEach((signal) => { process.once(signal as NodeJS.Signals, () => { void handleShutdown(signal as NodeJS.Signals); }); }); process.stdin.once('end', () => { void handleShutdown(); }); process.stdin.once('close', () => { void handleShutdown(); }); process.stdin.once('error', (error) => { log.warn('Stdio input closed with an error', error); void handleShutdown(); }); const runLifecycleCleanup = (eventName: 'beforeExit' | 'exit'): void => { const runCleanup = (operation: string, cleanup: () => void): void => { try { cleanup(); } catch (error) { log.debug(`Failed to ${operation} during ${eventName}`, error); } }; runCleanup('stop automation bridge', () => automationBridge.stop()); runCleanup('dispose Unreal bridge', () => bridge.dispose()); runCleanup('close metrics server', () => { metricsServer?.close(); }); }; process.once('beforeExit', () => { runLifecycleCleanup('beforeExit'); }); process.once('exit', () => { runLifecycleCleanup('exit'); }); const originalWrite = process.stdout.write; process.stdout.write = function (...args: [string | Uint8Array, ...unknown[]]) { const message = args[0]; if (typeof message === 'string' && message.includes('jsonrpc')) { log.debug(`Sending to client: ${message.substring(0, 200)}...`); } return originalWrite.apply(process.stdout, args as Parameters); } as typeof process.stdout.write; await server.connect(transport); log.info('Unreal Engine MCP Server started on stdio'); } // Direct execution is handled via src/cli.ts to keep this module side-effect free.