unreal-engine-mcp-server 0.2.1 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/index.ts CHANGED
@@ -43,7 +43,7 @@ import {
43
43
  import { responseValidator } from './utils/response-validator.js';
44
44
  import { ErrorHandler } from './utils/error-handler.js';
45
45
  import { routeStdoutLogsToStderr } from './utils/stdio-redirect.js';
46
- import { sanitizeResponse, cleanObject } from './utils/safe-json.js';
46
+ import { cleanObject } from './utils/safe-json.js';
47
47
 
48
48
  const log = new Logger('UE-MCP');
49
49
 
@@ -75,16 +75,20 @@ const metrics: PerformanceMetrics = {
75
75
  recentErrors: []
76
76
  };
77
77
 
78
+ // Health check timer and last success tracking (stop pings after inactivity)
79
+ let healthCheckTimer: NodeJS.Timeout | undefined;
80
+ let lastHealthSuccessAt = 0;
81
+
78
82
  // Configuration
79
83
  const CONFIG = {
80
- // Tool mode: true = consolidated (10 tools), false = individual (36+ tools)
84
+ // Tool mode: true = consolidated (13 tools), false = individual (36+ tools)
81
85
  USE_CONSOLIDATED_TOOLS: process.env.USE_CONSOLIDATED_TOOLS !== 'false',
82
86
  // Connection retry settings
83
87
  MAX_RETRY_ATTEMPTS: 3,
84
88
  RETRY_DELAY_MS: 2000,
85
89
  // Server info
86
90
  SERVER_NAME: 'unreal-engine-mcp',
87
- SERVER_VERSION: '0.2.1',
91
+ SERVER_VERSION: '0.3.1',
88
92
  // Monitoring
89
93
  HEALTH_CHECK_INTERVAL_MS: 30000 // 30 seconds
90
94
  };
@@ -111,11 +115,16 @@ function trackPerformance(startTime: number, success: boolean) {
111
115
 
112
116
  // Health check function
113
117
  async function performHealthCheck(bridge: UnrealBridge): Promise<boolean> {
118
+ // If not connected, do not attempt any ping (stay quiet)
119
+ if (!bridge.isConnected) {
120
+ return false;
121
+ }
114
122
  try {
115
123
  // Use a safe echo command that doesn't affect any settings
116
124
  await bridge.executeConsoleCommand('echo MCP Server Health Check');
117
125
  metrics.connectionStatus = 'connected';
118
126
  metrics.lastHealthCheck = new Date();
127
+ lastHealthSuccessAt = Date.now();
119
128
  return true;
120
129
  } catch (err1) {
121
130
  // Fallback: minimal Python ping (if Python plugin is enabled)
@@ -123,11 +132,13 @@ async function performHealthCheck(bridge: UnrealBridge): Promise<boolean> {
123
132
  await bridge.executePython("import sys; sys.stdout.write('OK')");
124
133
  metrics.connectionStatus = 'connected';
125
134
  metrics.lastHealthCheck = new Date();
135
+ lastHealthSuccessAt = Date.now();
126
136
  return true;
127
137
  } catch (err2) {
128
138
  metrics.connectionStatus = 'error';
129
139
  metrics.lastHealthCheck = new Date();
130
- log.warn('Health check failed (console and python):', err1, err2);
140
+ // Avoid noisy warnings when engine may be shutting down; log at debug
141
+ log.debug('Health check failed (console and python):', err1, err2);
131
142
  return false;
132
143
  }
133
144
  }
@@ -135,50 +146,68 @@ async function performHealthCheck(bridge: UnrealBridge): Promise<boolean> {
135
146
 
136
147
  export async function createServer() {
137
148
  const bridge = new UnrealBridge();
149
+ // Disable auto-reconnect loops; connect only on-demand
150
+ bridge.setAutoReconnectEnabled(false);
138
151
 
139
152
  // Initialize response validation with schemas
140
- log.info('Initializing response validation...');
153
+ log.debug('Initializing response validation...');
141
154
  const toolDefs = CONFIG.USE_CONSOLIDATED_TOOLS ? consolidatedToolDefinitions : toolDefinitions;
142
155
  toolDefs.forEach((tool: any) => {
143
156
  if (tool.outputSchema) {
144
157
  responseValidator.registerSchema(tool.name, tool.outputSchema);
145
158
  }
146
159
  });
147
- log.info(`Registered ${responseValidator.getStats().totalSchemas} output schemas for validation`);
148
-
149
- // Connect to UE5 Remote Control with retries and timeout
150
- const connected = await bridge.tryConnect(
151
- CONFIG.MAX_RETRY_ATTEMPTS,
152
- 5000, // 5 second timeout per attempt
153
- CONFIG.RETRY_DELAY_MS
154
- );
155
-
156
- if (connected) {
157
- metrics.connectionStatus = 'connected';
158
- log.info('Successfully connected to Unreal Engine');
159
- } else {
160
- log.warn('Could not connect to Unreal Engine after retries');
161
- log.info('Server will start anyway - connection will be retried periodically');
162
- log.info('Make sure Unreal Engine is running with Remote Control enabled');
163
- metrics.connectionStatus = 'disconnected';
164
-
165
- // Schedule automatic reconnection attempts
166
- setInterval(async () => {
160
+ // Summary at debug level to avoid repeated noisy blocks in some shells
161
+ log.debug(`Registered ${responseValidator.getStats().totalSchemas} output schemas for validation`);
162
+
163
+ // Do NOT connect to Unreal at startup; connect on demand
164
+ log.debug('Server starting without connecting to Unreal Engine');
165
+ metrics.connectionStatus = 'disconnected';
166
+
167
+ // Health checks manager (only active when connected)
168
+ const startHealthChecks = () => {
169
+ if (healthCheckTimer) return;
170
+ lastHealthSuccessAt = Date.now();
171
+ healthCheckTimer = setInterval(async () => {
172
+ // Only attempt health pings while connected; stay silent otherwise
167
173
  if (!bridge.isConnected) {
168
- log.info('Attempting to reconnect to Unreal Engine...');
169
- const reconnected = await bridge.tryConnect(1, 5000, 0); // Single attempt
170
- if (reconnected) {
171
- log.info('Reconnected to Unreal Engine successfully');
172
- metrics.connectionStatus = 'connected';
174
+ // Optionally pause fully after 5 minutes of no success
175
+ const FIVE_MIN_MS = 5 * 60 * 1000;
176
+ if (!lastHealthSuccessAt || Date.now() - lastHealthSuccessAt > FIVE_MIN_MS) {
177
+ if (healthCheckTimer) {
178
+ clearInterval(healthCheckTimer);
179
+ healthCheckTimer = undefined;
180
+ }
181
+ log.info('Health checks paused after 5 minutes without a successful response');
173
182
  }
183
+ return;
174
184
  }
175
- }, 10000); // Try every 10 seconds
176
- }
177
-
178
- // Start periodic health checks
179
- setInterval(() => {
180
- performHealthCheck(bridge);
181
- }, CONFIG.HEALTH_CHECK_INTERVAL_MS);
185
+
186
+ await performHealthCheck(bridge);
187
+ // Stop sending echoes if we haven't had a successful response in > 5 minutes
188
+ const FIVE_MIN_MS = 5 * 60 * 1000;
189
+ if (!lastHealthSuccessAt || Date.now() - lastHealthSuccessAt > FIVE_MIN_MS) {
190
+ if (healthCheckTimer) {
191
+ clearInterval(healthCheckTimer);
192
+ healthCheckTimer = undefined;
193
+ log.info('Health checks paused after 5 minutes without a successful response');
194
+ }
195
+ }
196
+ }, CONFIG.HEALTH_CHECK_INTERVAL_MS);
197
+ };
198
+
199
+ // On-demand connection helper
200
+ const ensureConnectedOnDemand = async (): Promise<boolean> => {
201
+ if (bridge.isConnected) return true;
202
+ const ok = await bridge.tryConnect(3, 5000, 1000);
203
+ if (ok) {
204
+ metrics.connectionStatus = 'connected';
205
+ startHealthChecks();
206
+ } else {
207
+ metrics.connectionStatus = 'disconnected';
208
+ }
209
+ return ok;
210
+ };
182
211
 
183
212
  // Resources
184
213
  const assetResources = new AssetResources(bridge);
@@ -272,6 +301,10 @@ export async function createServer() {
272
301
  const uri = request.params.uri;
273
302
 
274
303
  if (uri === 'ue://assets') {
304
+ const ok = await ensureConnectedOnDemand();
305
+ if (!ok) {
306
+ return { contents: [{ uri, mimeType: 'text/plain', text: 'Unreal Engine not connected (after 3 attempts).' }] };
307
+ }
275
308
  const list = await assetResources.list('/Game', true);
276
309
  return {
277
310
  contents: [{
@@ -283,6 +316,10 @@ export async function createServer() {
283
316
  }
284
317
 
285
318
  if (uri === 'ue://actors') {
319
+ const ok = await ensureConnectedOnDemand();
320
+ if (!ok) {
321
+ return { contents: [{ uri, mimeType: 'text/plain', text: 'Unreal Engine not connected (after 3 attempts).' }] };
322
+ }
286
323
  const list = await actorResources.listActors();
287
324
  return {
288
325
  contents: [{
@@ -294,6 +331,10 @@ export async function createServer() {
294
331
  }
295
332
 
296
333
  if (uri === 'ue://level') {
334
+ const ok = await ensureConnectedOnDemand();
335
+ if (!ok) {
336
+ return { contents: [{ uri, mimeType: 'text/plain', text: 'Unreal Engine not connected (after 3 attempts).' }] };
337
+ }
297
338
  const level = await levelResources.getCurrentLevel();
298
339
  return {
299
340
  contents: [{
@@ -305,6 +346,10 @@ export async function createServer() {
305
346
  }
306
347
 
307
348
  if (uri === 'ue://exposed') {
349
+ const ok = await ensureConnectedOnDemand();
350
+ if (!ok) {
351
+ return { contents: [{ uri, mimeType: 'text/plain', text: 'Unreal Engine not connected (after 3 attempts).' }] };
352
+ }
308
353
  try {
309
354
  const exposed = await bridge.getExposed();
310
355
  return {
@@ -327,11 +372,13 @@ export async function createServer() {
327
372
 
328
373
  if (uri === 'ue://health') {
329
374
  const uptimeMs = Date.now() - metrics.uptime;
330
- // Query engine version and feature flags (best-effort)
375
+ // Query engine version and feature flags only when connected
331
376
  let versionInfo: any = {};
332
377
  let featureFlags: any = {};
333
- try { versionInfo = await bridge.getEngineVersion(); } catch {}
334
- try { featureFlags = await bridge.getFeatureFlags(); } catch {}
378
+ if (bridge.isConnected) {
379
+ try { versionInfo = await bridge.getEngineVersion(); } catch {}
380
+ try { featureFlags = await bridge.getFeatureFlags(); } catch {}
381
+ }
335
382
  const health = {
336
383
  status: metrics.connectionStatus,
337
384
  uptime: Math.floor(uptimeMs / 1000),
@@ -369,6 +416,10 @@ export async function createServer() {
369
416
  }
370
417
 
371
418
  if (uri === 'ue://version') {
419
+ const ok = await ensureConnectedOnDemand();
420
+ if (!ok) {
421
+ return { contents: [{ uri, mimeType: 'text/plain', text: 'Unreal Engine not connected (after 3 attempts).' }] };
422
+ }
372
423
  const info = await bridge.getEngineVersion();
373
424
  return {
374
425
  contents: [{
@@ -382,7 +433,7 @@ export async function createServer() {
382
433
  throw new Error(`Unknown resource: ${uri}`);
383
434
  });
384
435
 
385
- // Handle tool listing - switch between consolidated (10) or individual (36) tools
436
+ // Handle tool listing - switch between consolidated (13) or individual (36) tools
386
437
  server.setRequestHandler(ListToolsRequestSchema, async () => {
387
438
  log.info(`Serving ${CONFIG.USE_CONSOLIDATED_TOOLS ? 'consolidated' : 'individual'} tools`);
388
439
  return {
@@ -390,10 +441,24 @@ export async function createServer() {
390
441
  };
391
442
  });
392
443
 
393
- // Handle tool calls - switch between consolidated (10) or individual (36) tools
444
+ // Handle tool calls - switch between consolidated (13) or individual (36) tools
394
445
  server.setRequestHandler(CallToolRequestSchema, async (request) => {
395
446
  const { name, arguments: args } = request.params;
396
447
  const startTime = Date.now();
448
+
449
+ // Ensure connection only when needed, with 3 attempts
450
+ const connected = await ensureConnectedOnDemand();
451
+ if (!connected) {
452
+ const notConnected = {
453
+ content: [{ type: 'text', text: 'Unreal Engine is not connected (after 3 attempts). Please open UE and try again.' }],
454
+ success: false,
455
+ error: 'UE_NOT_CONNECTED',
456
+ retriable: false,
457
+ scope: `tool-call/${name}`
458
+ } as any;
459
+ trackPerformance(startTime, false);
460
+ return notConnected;
461
+ }
397
462
 
398
463
  // Create tools object for handler
399
464
  const tools = {
@@ -34,25 +34,63 @@ export class ActorResources {
34
34
  // Use Python to get actors via EditorActorSubsystem
35
35
  try {
36
36
  const pythonCode = `
37
- import unreal
37
+ import unreal, json
38
38
  actor_subsystem = unreal.get_editor_subsystem(unreal.EditorActorSubsystem)
39
- actors = actor_subsystem.get_all_level_actors()
39
+ actors = actor_subsystem.get_all_level_actors() if actor_subsystem else []
40
40
  actor_list = []
41
41
  for actor in actors:
42
- if actor:
43
- actor_list.append({
44
- 'name': actor.get_name(),
45
- 'class': actor.get_class().get_name(),
46
- 'path': actor.get_path_name()
47
- })
48
- print(f"Found {len(actor_list)} actors")
42
+ try:
43
+ if actor:
44
+ actor_list.append({
45
+ 'name': actor.get_name(),
46
+ 'class': actor.get_class().get_name(),
47
+ 'path': actor.get_path_name()
48
+ })
49
+ except Exception:
50
+ pass
51
+ print('RESULT:' + json.dumps({'success': True, 'count': len(actor_list), 'actors': actor_list}))
49
52
  `.trim();
50
53
 
51
- const result = await this.bridge.executePython(pythonCode);
52
- this.setCache('listActors', result);
53
- return result;
54
+ const resp = await this.bridge.executePythonWithResult(pythonCode);
55
+ if (resp && typeof resp === 'object' && resp.success === true && Array.isArray((resp as any).actors)) {
56
+ this.setCache('listActors', resp);
57
+ return resp;
58
+ }
59
+
60
+ // Fallback manual extraction with bracket matching
61
+ const raw = await this.bridge.executePython(pythonCode);
62
+ let output = '';
63
+ if (raw?.LogOutput && Array.isArray(raw.LogOutput)) output = raw.LogOutput.map((l: any) => l.Output || '').join('');
64
+ else if (typeof raw === 'string') output = raw; else output = JSON.stringify(raw);
65
+ const marker = 'RESULT:';
66
+ const idx = output.lastIndexOf(marker);
67
+ if (idx !== -1) {
68
+ let i = idx + marker.length;
69
+ while (i < output.length && output[i] !== '{') i++;
70
+ if (i < output.length) {
71
+ let depth = 0, inStr = false, esc = false, j = i;
72
+ for (; j < output.length; j++) {
73
+ const ch = output[j];
74
+ if (esc) { esc = false; continue; }
75
+ if (ch === '\\') { esc = true; continue; }
76
+ if (ch === '"') { inStr = !inStr; continue; }
77
+ if (!inStr) {
78
+ if (ch === '{') depth++;
79
+ else if (ch === '}') { depth--; if (depth === 0) { j++; break; } }
80
+ }
81
+ }
82
+ const jsonStr = output.slice(i, j);
83
+ try {
84
+ const parsed = JSON.parse(jsonStr);
85
+ this.setCache('listActors', parsed);
86
+ return parsed;
87
+ } catch {}
88
+ }
89
+ }
90
+
91
+ return { success: false, error: 'Failed to parse actors list' };
54
92
  } catch (err) {
55
- return { error: `Failed to list actors: ${err}` };
93
+ return { success: false, error: `Failed to list actors: ${err}` };
56
94
  }
57
95
  }
58
96
 
@@ -10,10 +10,10 @@ export class AssetResources {
10
10
  return page !== undefined ? `${dir}::${recursive ? 1 : 0}::${page}` : `${dir}::${recursive ? 1 : 0}`;
11
11
  }
12
12
 
13
- async list(dir = '/Game', recursive = false, limit = 50) {
13
+ async list(dir = '/Game', _recursive = false, limit = 50) {
14
14
  // ALWAYS use non-recursive listing to show only immediate children
15
15
  // This prevents timeouts and makes navigation clearer
16
- recursive = false; // Force non-recursive
16
+ _recursive = false; // Force non-recursive
17
17
 
18
18
  // Cache fast-path
19
19
  try {
@@ -105,7 +105,7 @@ export class AssetResources {
105
105
  * Directory-based listing for paths with too many assets
106
106
  * Shows only immediate children (folders and files) to avoid timeouts
107
107
  */
108
- private async listDirectoryOnly(dir: string, recursive: boolean, limit: number) {
108
+ private async listDirectoryOnly(dir: string, _recursive: boolean, limit: number) {
109
109
  // Always return only immediate children to avoid timeout and improve navigation
110
110
  try {
111
111
  const py = `