Skip to main content
Glama
index.ts26.4 kB
import 'dotenv/config'; 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 { AssetResources } from './resources/assets.js'; import { ActorResources } from './resources/actors.js'; import { LevelResources } from './resources/levels.js'; import { ActorTools } from './tools/actors.js'; import { AssetTools } from './tools/assets.js'; import { EditorTools } from './tools/editor.js'; import { MaterialTools } from './tools/materials.js'; import { AnimationTools } from './tools/animation.js'; import { PhysicsTools } from './tools/physics.js'; import { NiagaraTools } from './tools/niagara.js'; import { BlueprintTools } from './tools/blueprint.js'; import { LevelTools } from './tools/level.js'; import { LightingTools } from './tools/lighting.js'; import { LandscapeTools } from './tools/landscape.js'; import { BuildEnvironmentAdvanced } from './tools/build_environment_advanced.js'; import { FoliageTools } from './tools/foliage.js'; import { DebugVisualizationTools } from './tools/debug.js'; import { PerformanceTools } from './tools/performance.js'; import { AudioTools } from './tools/audio.js'; import { UITools } from './tools/ui.js'; import { RcTools } from './tools/rc.js'; import { SequenceTools } from './tools/sequence.js'; import { IntrospectionTools } from './tools/introspection.js'; import { VisualTools } from './tools/visual.js'; import { EngineTools } from './tools/engine.js'; import { LogTools } from './tools/logs.js'; import { consolidatedToolDefinitions } from './tools/consolidated-tool-definitions.js'; import { handleConsolidatedToolCall } from './tools/consolidated-tool-handlers.js'; import { prompts } from './prompts/index.js'; import { CallToolRequestSchema, ListToolsRequestSchema, ListResourcesRequestSchema, ReadResourceRequestSchema, ListPromptsRequestSchema, GetPromptRequestSchema } from '@modelcontextprotocol/sdk/types.js'; import { responseValidator } from './utils/response-validator.js'; import { z } from 'zod'; import { routeStdoutLogsToStderr } from './utils/stdio-redirect.js'; import { createElicitationHelper } from './utils/elicitation.js'; import { cleanObject } from './utils/safe-json.js'; import { ErrorHandler } from './utils/error-handler.js'; const log = new Logger('UE-MCP'); // Ensure stdout remains JSON-only for MCP by routing logs to stderr unless opted out. routeStdoutLogsToStderr(); // Performance metrics interface PerformanceMetrics { totalRequests: number; successfulRequests: number; failedRequests: number; averageResponseTime: number; responseTimes: number[]; connectionStatus: 'connected' | 'disconnected' | 'error'; lastHealthCheck: Date; uptime: number; recentErrors: Array<{ time: string; scope: string; type: string; message: string; retriable: boolean }>; } const metrics: PerformanceMetrics = { totalRequests: 0, successfulRequests: 0, failedRequests: 0, averageResponseTime: 0, responseTimes: [], connectionStatus: 'disconnected', lastHealthCheck: new Date(), uptime: Date.now(), recentErrors: [] }; // Health check timer and last success tracking (stop pings after inactivity) let healthCheckTimer: NodeJS.Timeout | undefined; let lastHealthSuccessAt = 0; // Configuration const CONFIG = { // Tooling: use consolidated tools only (13 tools) // Connection retry settings MAX_RETRY_ATTEMPTS: 3, RETRY_DELAY_MS: 2000, // Server info SERVER_NAME: 'unreal-engine-mcp', SERVER_VERSION: '0.4.7', // Monitoring HEALTH_CHECK_INTERVAL_MS: 30000 // 30 seconds }; // Helper function to track performance function trackPerformance(startTime: number, success: boolean) { const responseTime = Date.now() - startTime; metrics.totalRequests++; if (success) { metrics.successfulRequests++; } else { metrics.failedRequests++; } // Keep last 100 response times for average calculation metrics.responseTimes.push(responseTime); if (metrics.responseTimes.length > 100) { metrics.responseTimes.shift(); } // Calculate average metrics.averageResponseTime = metrics.responseTimes.reduce((a, b) => a + b, 0) / metrics.responseTimes.length; } // Health check function async function performHealthCheck(bridge: UnrealBridge): Promise<boolean> { // If not connected, do not attempt any ping (stay quiet) if (!bridge.isConnected) { return false; } try { // Use a safe echo command that doesn't affect any settings await bridge.executeConsoleCommand('echo MCP Server Health Check'); metrics.connectionStatus = 'connected'; metrics.lastHealthCheck = new Date(); lastHealthSuccessAt = Date.now(); return true; } catch (err1) { // Fallback: minimal Python ping (if Python plugin is enabled) try { await bridge.executePython("import sys; sys.stdout.write('OK')"); metrics.connectionStatus = 'connected'; metrics.lastHealthCheck = new Date(); lastHealthSuccessAt = Date.now(); return true; } catch (err2) { metrics.connectionStatus = 'error'; metrics.lastHealthCheck = new Date(); // Avoid noisy warnings when engine may be shutting down; log at debug log.debug('Health check failed (console and python):', err1, err2); return false; } } } export function createServer() { const bridge = new UnrealBridge(); // Disable auto-reconnect loops; connect only on-demand bridge.setAutoReconnectEnabled(false); // Initialize response validation with schemas log.debug('Initializing response validation...'); const toolDefs = consolidatedToolDefinitions; toolDefs.forEach((tool: any) => { if (tool.outputSchema) { responseValidator.registerSchema(tool.name, tool.outputSchema); } }); // Summary at debug level to avoid repeated noisy blocks in some shells log.debug(`Registered ${responseValidator.getStats().totalSchemas} output schemas for validation`); // Do NOT connect to Unreal at startup; connect on demand log.debug('Server starting without connecting to Unreal Engine'); metrics.connectionStatus = 'disconnected'; // Health checks manager (only active when connected) const startHealthChecks = () => { if (healthCheckTimer) return; lastHealthSuccessAt = Date.now(); healthCheckTimer = setInterval(async () => { // Only attempt health pings while connected; stay silent otherwise if (!bridge.isConnected) { // Optionally pause fully after 5 minutes of no success const FIVE_MIN_MS = 5 * 60 * 1000; if (!lastHealthSuccessAt || Date.now() - lastHealthSuccessAt > FIVE_MIN_MS) { if (healthCheckTimer) { clearInterval(healthCheckTimer); healthCheckTimer = undefined; } log.info('Health checks paused after 5 minutes without a successful response'); } return; } await performHealthCheck(bridge); // Stop sending echoes if we haven't had a successful response in > 5 minutes const FIVE_MIN_MS = 5 * 60 * 1000; if (!lastHealthSuccessAt || Date.now() - lastHealthSuccessAt > FIVE_MIN_MS) { if (healthCheckTimer) { clearInterval(healthCheckTimer); healthCheckTimer = undefined; log.info('Health checks paused after 5 minutes without a successful response'); } } }, CONFIG.HEALTH_CHECK_INTERVAL_MS); }; // On-demand connection helper const ensureConnectedOnDemand = async (): Promise<boolean> => { if (bridge.isConnected) return true; const ok = await bridge.tryConnect(3, 5000, 1000); if (ok) { metrics.connectionStatus = 'connected'; startHealthChecks(); } else { metrics.connectionStatus = 'disconnected'; } return ok; }; // Resources const assetResources = new AssetResources(bridge); const actorResources = new ActorResources(bridge); const levelResources = new LevelResources(bridge); // Tools const actorTools = new ActorTools(bridge); const assetTools = new AssetTools(bridge); const editorTools = new EditorTools(bridge); const materialTools = new MaterialTools(bridge); const animationTools = new AnimationTools(bridge); const physicsTools = new PhysicsTools(bridge); const niagaraTools = new NiagaraTools(bridge); const blueprintTools = new BlueprintTools(bridge); const levelTools = new LevelTools(bridge); const lightingTools = new LightingTools(bridge); const landscapeTools = new LandscapeTools(bridge); const foliageTools = new FoliageTools(bridge); const buildEnvAdvanced = new BuildEnvironmentAdvanced(bridge); const debugTools = new DebugVisualizationTools(bridge); const performanceTools = new PerformanceTools(bridge); const audioTools = new AudioTools(bridge); const uiTools = new UITools(bridge); const rcTools = new RcTools(bridge); const sequenceTools = new SequenceTools(bridge); const introspectionTools = new IntrospectionTools(bridge); const visualTools = new VisualTools(bridge); const engineTools = new EngineTools(bridge); const logTools = new LogTools(bridge); const server = new Server( { name: CONFIG.SERVER_NAME, version: CONFIG.SERVER_VERSION }, { capabilities: { resources: {}, tools: {}, prompts: { listChanged: false }, logging: {} } } ); // Optional elicitation helper – used only if client supports it. const elicitation = createElicitationHelper(server as any, log); const defaultElicitationTimeoutMs = elicitation.getDefaultTimeoutMs(); const createNotConnectedResponse = (toolName: string) => { const payload = { success: false, error: 'UE_NOT_CONNECTED', message: 'Unreal Engine is not connected (after 3 attempts). Please open UE and try again.', retriable: false, scope: `tool-call/${toolName}` } as const; return responseValidator.wrapResponse(toolName, { ...payload, content: [ { type: 'text', text: JSON.stringify(payload, null, 2) } ] }); }; // Handle resource listing server.setRequestHandler(ListResourcesRequestSchema, async () => { return { resources: [ { uri: 'ue://assets', name: 'Project Assets', description: 'List all assets in the project', mimeType: 'application/json' }, { uri: 'ue://actors', name: 'Level Actors', description: 'List all actors in the current level', mimeType: 'application/json' }, { uri: 'ue://level', name: 'Current Level', description: 'Information about the current level', mimeType: 'application/json' }, { uri: 'ue://exposed', name: 'Remote Control Exposed', description: 'List all exposed properties via Remote Control', mimeType: 'application/json' }, { uri: 'ue://health', name: 'Health Status', description: 'Server health and performance metrics', mimeType: 'application/json' }, { uri: 'ue://version', name: 'Engine Version', description: 'Unreal Engine version and compatibility info', mimeType: 'application/json' } ] }; }); // Handle resource reading server.setRequestHandler(ReadResourceRequestSchema, async (request) => { const uri = request.params.uri; if (uri === 'ue://assets') { const ok = await ensureConnectedOnDemand(); if (!ok) { return { contents: [{ uri, mimeType: 'text/plain', text: 'Unreal Engine not connected (after 3 attempts).' }] }; } const list = await assetResources.list('/Game', true); return { contents: [{ uri, mimeType: 'application/json', text: JSON.stringify(list, null, 2) }] }; } if (uri === 'ue://actors') { const ok = await ensureConnectedOnDemand(); if (!ok) { return { contents: [{ uri, mimeType: 'text/plain', text: 'Unreal Engine not connected (after 3 attempts).' }] }; } const list = await actorResources.listActors(); return { contents: [{ uri, mimeType: 'application/json', text: JSON.stringify(list, null, 2) }] }; } if (uri === 'ue://level') { const ok = await ensureConnectedOnDemand(); if (!ok) { return { contents: [{ uri, mimeType: 'text/plain', text: 'Unreal Engine not connected (after 3 attempts).' }] }; } const level = await levelResources.getCurrentLevel(); return { contents: [{ uri, mimeType: 'application/json', text: JSON.stringify(level, null, 2) }] }; } if (uri === 'ue://exposed') { const ok = await ensureConnectedOnDemand(); if (!ok) { return { contents: [{ uri, mimeType: 'text/plain', text: 'Unreal Engine not connected (after 3 attempts).' }] }; } try { const exposed = await bridge.getExposed(); return { contents: [{ uri, mimeType: 'application/json', text: JSON.stringify(exposed, null, 2) }] }; } catch { return { contents: [{ uri, mimeType: 'text/plain', text: 'Failed to get exposed properties. Ensure Remote Control is configured.' }] }; } } if (uri === 'ue://health') { const uptimeMs = Date.now() - metrics.uptime; // Query engine version and feature flags only when connected let versionInfo: any = {}; let featureFlags: any = {}; if (bridge.isConnected) { try { versionInfo = await bridge.getEngineVersion(); } catch {} try { featureFlags = await bridge.getFeatureFlags(); } catch {} } const health = { status: metrics.connectionStatus, uptime: Math.floor(uptimeMs / 1000), performance: { totalRequests: metrics.totalRequests, successfulRequests: metrics.successfulRequests, failedRequests: metrics.failedRequests, successRate: metrics.totalRequests > 0 ? (metrics.successfulRequests / metrics.totalRequests * 100).toFixed(2) + '%' : 'N/A', averageResponseTime: Math.round(metrics.averageResponseTime) + 'ms' }, lastHealthCheck: metrics.lastHealthCheck.toISOString(), unrealConnection: { status: bridge.isConnected ? 'connected' : 'disconnected', host: process.env.UE_HOST || 'localhost', httpPort: process.env.UE_RC_HTTP_PORT || 30010, wsPort: process.env.UE_RC_WS_PORT || 30020, engineVersion: versionInfo, features: { pythonEnabled: featureFlags.pythonEnabled === true, subsystems: featureFlags.subsystems || {}, rcHttpReachable: bridge.isConnected } }, recentErrors: metrics.recentErrors.slice(-5) }; return { contents: [{ uri, mimeType: 'application/json', text: JSON.stringify(health, null, 2) }] }; } if (uri === 'ue://version') { const ok = await ensureConnectedOnDemand(); if (!ok) { return { contents: [{ uri, mimeType: 'text/plain', text: 'Unreal Engine not connected (after 3 attempts).' }] }; } const info = await bridge.getEngineVersion(); return { contents: [{ uri, mimeType: 'application/json', text: JSON.stringify(info, null, 2) }] }; } throw new Error(`Unknown resource: ${uri}`); }); // Handle tool listing - consolidated tools only server.setRequestHandler(ListToolsRequestSchema, async () => { log.info('Serving consolidated tools'); return { tools: consolidatedToolDefinitions }; }); // Handle tool calls - consolidated tools only (13) server.setRequestHandler(CallToolRequestSchema, async (request) => { const { name } = request.params; let args: any = request.params.arguments || {}; const startTime = Date.now(); let requiresEngine = true; try { const n = String(name); if (n === 'system_control') { const action = String((args || {}).action || '').trim(); if (action === 'read_log') { requiresEngine = false; } } } catch {} if (requiresEngine) { const connected = await ensureConnectedOnDemand(); if (!connected) { trackPerformance(startTime, false); return createNotConnectedResponse(name); } } // Create tools object for handler const tools = { actorTools, assetTools, materialTools, editorTools, animationTools, physicsTools, niagaraTools, blueprintTools, levelTools, lightingTools, landscapeTools, foliageTools, buildEnvAdvanced, debugTools, performanceTools, audioTools, uiTools, rcTools, sequenceTools, introspectionTools, visualTools, engineTools, logTools, // Elicitation (client-optional) elicit: elicitation.elicit, supportsElicitation: elicitation.supports, elicitationTimeoutMs: defaultElicitationTimeoutMs, // Resources for listing and info assetResources, actorResources, levelResources, bridge }; // Execute consolidated tool handler try { log.debug(`Executing tool: ${name}`); // Opportunistic generic elicitation for missing primitive required fields try { const toolDef: any = (consolidatedToolDefinitions as any[]).find(t => t.name === name); const inputSchema: any = toolDef?.inputSchema; const elicitFn: any = (tools as any).elicit; if (inputSchema && typeof elicitFn === 'function') { const props = inputSchema.properties || {}; const required: string[] = Array.isArray(inputSchema.required) ? inputSchema.required : []; const missing = required.filter((k: string) => { const v = (args as any)[k]; if (v === undefined || v === null) return true; if (typeof v === 'string' && v.trim() === '') return true; return false; }); // Build a flat primitive-only schema subset per MCP Elicitation rules const primitiveProps: any = {}; for (const k of missing) { const p = props[k]; if (!p || typeof p !== 'object') continue; const t = (p.type || '').toString(); const isEnum = Array.isArray(p.enum); if (t === 'string' || t === 'number' || t === 'integer' || t === 'boolean' || isEnum) { primitiveProps[k] = { type: t || (isEnum ? 'string' : undefined), title: p.title, description: p.description, enum: p.enum, enumNames: p.enumNames, minimum: p.minimum, maximum: p.maximum, minLength: p.minLength, maxLength: p.maxLength, pattern: p.pattern, format: p.format, default: p.default }; } } if (Object.keys(primitiveProps).length > 0) { const elicitOptions: any = { fallback: async () => ({ ok: false, error: 'missing-params' }) }; if (typeof (tools as any).elicitationTimeoutMs === 'number' && Number.isFinite((tools as any).elicitationTimeoutMs)) { elicitOptions.timeoutMs = (tools as any).elicitationTimeoutMs; } const elicitRes = await elicitFn( `Provide missing parameters for ${name}`, { type: 'object', properties: primitiveProps, required: Object.keys(primitiveProps) }, elicitOptions ); if (elicitRes && elicitRes.ok && elicitRes.value) { args = { ...args, ...elicitRes.value }; } } } } catch (e) { log.debug('Generic elicitation prefill skipped', { err: (e as any)?.message || String(e) }); } let result = await handleConsolidatedToolCall(name, args, tools); log.debug(`Tool ${name} returned result`); // Clean the result to remove circular references result = cleanObject(result); // Validate and enhance response result = responseValidator.wrapResponse(name, result); trackPerformance(startTime, true); log.info(`Tool ${name} completed successfully in ${Date.now() - startTime}ms`); // Log that we're returning the response const responsePreview = JSON.stringify(result).substring(0, 100); log.debug(`Returning response to MCP client: ${responsePreview}...`); return result; } catch (error) { trackPerformance(startTime, false); // Use consistent error handling const errorResponse = ErrorHandler.createErrorResponse(error, name, { ...args, scope: `tool-call/${name}` }); log.error(`Tool execution failed: ${name}`, errorResponse); // Record error for health diagnostics try { metrics.recentErrors.push({ time: new Date().toISOString(), scope: (errorResponse as any).scope || `tool-call/${name}`, type: (errorResponse as any)._debug?.errorType || 'UNKNOWN', message: (errorResponse as any).error || (errorResponse as any).message || 'Unknown error', retriable: Boolean((errorResponse as any).retriable) }); if (metrics.recentErrors.length > 20) metrics.recentErrors.splice(0, metrics.recentErrors.length - 20); } catch {} const sanitizedError = cleanObject(errorResponse); let errorText = ''; try { errorText = JSON.stringify(sanitizedError, null, 2); } catch { errorText = sanitizedError.message || errorResponse.message || `Failed to execute ${name}`; } const wrappedError = { ...sanitizedError, isError: true, content: [{ type: 'text', text: errorText }] }; return responseValidator.wrapResponse(name, wrappedError); } }); // Handle prompt listing server.setRequestHandler(ListPromptsRequestSchema, async () => { return { prompts: prompts.map(p => ({ name: p.name, description: p.description, arguments: Object.entries(p.arguments || {}).map(([name, schema]) => { const meta: Record<string, unknown> = {}; if (schema.type) meta.type = schema.type; if (schema.enum) meta.enum = schema.enum; if (schema.default !== undefined) meta.default = schema.default; return { name, description: schema.description, required: schema.required ?? false, ...(Object.keys(meta).length ? { _meta: meta } : {}) }; }) })) }; }); // Handle prompt retrieval server.setRequestHandler(GetPromptRequestSchema, async (request) => { const prompt = prompts.find(p => p.name === request.params.name); if (!prompt) { throw new Error(`Unknown prompt: ${request.params.name}`); } const args = (request.params.arguments || {}) as Record<string, unknown>; const messages = prompt.build(args); return { description: prompt.description, messages }; }); return { server, bridge }; } // Export configuration schema for Smithery session UI and validation export const configSchema = z.object({ ueHost: z.string().optional().default('127.0.0.1').describe('Unreal Engine host (e.g. 127.0.0.1)'), ueHttpPort: z.number().int().optional().default(30010).describe('Remote Control HTTP port'), ueWsPort: z.number().int().optional().default(30020).describe('Remote Control WebSocket port'), 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 expected by Smithery TypeScript runtime. Accepts an optional config object // and injects values into the environment before creating the server. export default function createServerDefault({ config }: { config?: any } = {}) { try { if (config) { if (typeof config.ueHost === 'string' && config.ueHost.trim()) process.env.UE_HOST = config.ueHost; if (config.ueHttpPort !== undefined) process.env.UE_RC_HTTP_PORT = String(config.ueHttpPort); if (config.ueWsPort !== undefined) process.env.UE_RC_WS_PORT = String(config.ueWsPort); if (typeof config.logLevel === 'string') process.env.LOG_LEVEL = config.logLevel; if (typeof config.projectPath === 'string' && config.projectPath.trim()) process.env.UE_PROJECT_PATH = config.projectPath; } } catch (e) { // Non-fatal: log and continue (console to avoid circular logger dependencies at top-level) console.debug('[createServerDefault] Failed to apply config to environment:', (e as any)?.message || e); } const { server } = createServer(); return server; } export async function startStdioServer() { const { server } = createServer(); const transport = new StdioServerTransport(); // Add debugging for transport messages const originalWrite = process.stdout.write; process.stdout.write = function(...args: any[]) { 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 any); } as any; 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.

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/ChiR24/Unreal_mcp'

If you have feedback or need assistance with the MCP directory API, please join our Discord server