unreal-engine-mcp-server 0.3.0 → 0.4.0

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.
Files changed (43) hide show
  1. package/.env.production +6 -1
  2. package/Dockerfile +11 -28
  3. package/README.md +1 -2
  4. package/dist/index.js +120 -54
  5. package/dist/resources/actors.js +71 -13
  6. package/dist/resources/assets.d.ts +3 -2
  7. package/dist/resources/assets.js +96 -72
  8. package/dist/resources/levels.js +2 -2
  9. package/dist/tools/assets.js +6 -2
  10. package/dist/tools/build_environment_advanced.js +46 -42
  11. package/dist/tools/consolidated-tool-definitions.d.ts +232 -15
  12. package/dist/tools/consolidated-tool-definitions.js +173 -8
  13. package/dist/tools/consolidated-tool-handlers.js +331 -718
  14. package/dist/tools/debug.js +4 -6
  15. package/dist/tools/rc.js +2 -2
  16. package/dist/tools/sequence.js +21 -2
  17. package/dist/unreal-bridge.d.ts +4 -1
  18. package/dist/unreal-bridge.js +211 -53
  19. package/dist/utils/http.js +4 -2
  20. package/dist/utils/response-validator.d.ts +6 -1
  21. package/dist/utils/response-validator.js +43 -15
  22. package/package.json +5 -5
  23. package/server.json +2 -2
  24. package/src/index.ts +120 -56
  25. package/src/resources/actors.ts +51 -13
  26. package/src/resources/assets.ts +97 -73
  27. package/src/resources/levels.ts +2 -2
  28. package/src/tools/assets.ts +6 -2
  29. package/src/tools/build_environment_advanced.ts +46 -42
  30. package/src/tools/consolidated-tool-definitions.ts +173 -8
  31. package/src/tools/consolidated-tool-handlers.ts +318 -747
  32. package/src/tools/debug.ts +4 -6
  33. package/src/tools/rc.ts +2 -2
  34. package/src/tools/sequence.ts +21 -2
  35. package/src/unreal-bridge.ts +163 -60
  36. package/src/utils/http.ts +7 -4
  37. package/src/utils/response-validator.ts +48 -19
  38. package/dist/tools/tool-definitions.d.ts +0 -4919
  39. package/dist/tools/tool-definitions.js +0 -1007
  40. package/dist/tools/tool-handlers.d.ts +0 -47
  41. package/dist/tools/tool-handlers.js +0 -863
  42. package/src/tools/tool-definitions.ts +0 -1023
  43. package/src/tools/tool-handlers.ts +0 -973
@@ -22,13 +22,11 @@ export class DebugVisualizationTools {
22
22
  private async pyDraw(scriptBody: string) {
23
23
  const script = `
24
24
  import unreal
25
- # Use modern UnrealEditorSubsystem instead of deprecated EditorLevelLibrary
25
+ # Strict modern API: require UnrealEditorSubsystem (UE 5.1+)
26
26
  ues = unreal.get_editor_subsystem(unreal.UnrealEditorSubsystem)
27
- if ues:
28
- world = ues.get_editor_world()
29
- else:
30
- # Fallback to deprecated API if subsystem not available
31
- world = unreal.EditorLevelLibrary.get_editor_world()
27
+ if not ues:
28
+ raise Exception('UnrealEditorSubsystem not available')
29
+ world = ues.get_editor_world()
32
30
  ${scriptBody}
33
31
  `.trim()
34
32
  .replace(/\r?\n/g, '\n');
package/src/tools/rc.ts CHANGED
@@ -176,7 +176,7 @@ except Exception as e:
176
176
 
177
177
  // Expose an actor by label/name into a preset
178
178
  async exposeActor(params: { presetPath: string; actorName: string }) {
179
- const python = `\nimport unreal, json\npreset_path = r"${params.presetPath}"\nactor_name = r"${params.actorName}"\ntry:\n preset = unreal.EditorAssetLibrary.load_asset(preset_path)\n if not preset:\n print('RESULT:' + json.dumps({'success': False, 'error': 'Preset not found'}))\n else:\n actor_sub = unreal.get_editor_subsystem(unreal.EditorActorSubsystem)\n target = None\n for a in actor_sub.get_all_level_actors():\n if not a: continue\n try:\n if a.get_actor_label() == actor_name or a.get_name() == actor_name:\n target = a; break\n except Exception: pass\n if not target:\n print('RESULT:' + json.dumps({'success': False, 'error': 'Actor not found'}))\n else:\n try:\n unreal.RemoteControlFunctionLibrary.expose_actor(preset, target, None)\n unreal.EditorAssetLibrary.save_asset(preset_path)\n print('RESULT:' + json.dumps({'success': True}))\n except Exception as e:\n print('RESULT:' + json.dumps({'success': False, 'error': str(e)}))\nexcept Exception as e:\n print('RESULT:' + json.dumps({'success': False, 'error': str(e)}))\n`.trim();
179
+ const python = `\nimport unreal, json\npreset_path = r"${params.presetPath}"\nactor_name = r"${params.actorName}"\ntry:\n preset = unreal.EditorAssetLibrary.load_asset(preset_path)\n if not preset:\n print('RESULT:' + json.dumps({'success': False, 'error': 'Preset not found'}))\n else:\n actor_sub = unreal.get_editor_subsystem(unreal.EditorActorSubsystem)\n target = None\n for a in actor_sub.get_all_level_actors():\n if not a: continue\n try:\n if a.get_actor_label() == actor_name or a.get_name() == actor_name:\n target = a; break\n except Exception: pass\n if not target:\n print('RESULT:' + json.dumps({'success': False, 'error': 'Actor not found'}))\n else:\n try:\n # Expose with a default-initialized optional args struct (cannot pass None)\n args = unreal.RemoteControlOptionalExposeArgs()\n unreal.RemoteControlFunctionLibrary.expose_actor(preset, target, args)\n unreal.EditorAssetLibrary.save_asset(preset_path)\n print('RESULT:' + json.dumps({'success': True}))\n except Exception as e:\n print('RESULT:' + json.dumps({'success': False, 'error': str(e)}))\nexcept Exception as e:\n print('RESULT:' + json.dumps({'success': False, 'error': str(e)}))\n`.trim();
180
180
  const resp = await this.executeWithRetry(
181
181
  () => this.bridge.executePython(python),
182
182
  'exposeActor'
@@ -194,7 +194,7 @@ except Exception as e:
194
194
 
195
195
  // Expose a property on an object into a preset
196
196
  async exposeProperty(params: { presetPath: string; objectPath: string; propertyName: string }) {
197
- const python = `\nimport unreal, json\npreset_path = r"${params.presetPath}"\nobj_path = r"${params.objectPath}"\nprop_name = r"${params.propertyName}"\ntry:\n preset = unreal.EditorAssetLibrary.load_asset(preset_path)\n obj = unreal.load_object(None, obj_path)\n if not preset or not obj:\n print('RESULT:' + json.dumps({'success': False, 'error': 'Preset or object not found'}))\n else:\n try:\n unreal.RemoteControlFunctionLibrary.expose_property(preset, obj, prop_name, None)\n unreal.EditorAssetLibrary.save_asset(preset_path)\n print('RESULT:' + json.dumps({'success': True}))\n except Exception as e:\n print('RESULT:' + json.dumps({'success': False, 'error': str(e)}))\nexcept Exception as e:\n print('RESULT:' + json.dumps({'success': False, 'error': str(e)}))\n`.trim();
197
+ const python = `\nimport unreal, json\npreset_path = r"${params.presetPath}"\nobj_path = r"${params.objectPath}"\nprop_name = r"${params.propertyName}"\ntry:\n preset = unreal.EditorAssetLibrary.load_asset(preset_path)\n obj = unreal.load_object(None, obj_path)\n if not preset or not obj:\n print('RESULT:' + json.dumps({'success': False, 'error': 'Preset or object not found'}))\n else:\n try:\n # Expose with default optional args struct (cannot pass None)\n args = unreal.RemoteControlOptionalExposeArgs()\n unreal.RemoteControlFunctionLibrary.expose_property(preset, obj, prop_name, args)\n unreal.EditorAssetLibrary.save_asset(preset_path)\n print('RESULT:' + json.dumps({'success': True}))\n except Exception as e:\n print('RESULT:' + json.dumps({'success': False, 'error': str(e)}))\nexcept Exception as e:\n print('RESULT:' + json.dumps({'success': False, 'error': str(e)}))\n`.trim();
198
198
  const resp = await this.executeWithRetry(
199
199
  () => this.bridge.executePython(python),
200
200
  'exposeProperty'
@@ -334,12 +334,31 @@ except Exception as e:
334
334
  * Play the current level sequence
335
335
  */
336
336
  async play(params?: { startTime?: number; loopMode?: 'once' | 'loop' | 'pingpong' }) {
337
+ const loop = params?.loopMode || '';
337
338
  const py = `
338
339
  import unreal, json
340
+
341
+ # Helper to resolve SequencerLoopMode from a friendly string
342
+ def _resolve_loop_mode(mode_str):
343
+ try:
344
+ m = str(mode_str).lower()
345
+ slm = unreal.SequencerLoopMode
346
+ if m in ('once','noloop','no_loop'):
347
+ return getattr(slm, 'SLM_NoLoop', getattr(slm, 'NoLoop'))
348
+ if m in ('loop',):
349
+ return getattr(slm, 'SLM_Loop', getattr(slm, 'Loop'))
350
+ if m in ('pingpong','ping_pong'):
351
+ return getattr(slm, 'SLM_PingPong', getattr(slm, 'PingPong'))
352
+ except Exception:
353
+ pass
354
+ return None
355
+
339
356
  try:
340
357
  unreal.LevelSequenceEditorBlueprintLibrary.play()
341
- ${params?.loopMode ? `unreal.LevelSequenceEditorBlueprintLibrary.set_loop_mode('${params.loopMode}')` : ''}
342
- print('RESULT:' + json.dumps({'success': True, 'playing': True}))
358
+ loop_mode = _resolve_loop_mode('${loop}')
359
+ if loop_mode is not None:
360
+ unreal.LevelSequenceEditorBlueprintLibrary.set_loop_mode(loop_mode)
361
+ print('RESULT:' + json.dumps({'success': True, 'playing': True, 'loopMode': '${loop || 'default'}'}))
343
362
  except Exception as e:
344
363
  print('RESULT:' + json.dumps({'success': False, 'error': str(e)}))
345
364
  `.trim();
@@ -40,6 +40,7 @@ export class UnrealBridge {
40
40
  private reconnectAttempts = 0;
41
41
  private readonly MAX_RECONNECT_ATTEMPTS = 5;
42
42
  private readonly BASE_RECONNECT_DELAY = 1000;
43
+ private autoReconnectEnabled = false; // disabled by default to prevent looping retries
43
44
 
44
45
  // Command queue for throttling
45
46
  private commandQueue: CommandQueueItem[] = [];
@@ -185,54 +186,100 @@ print(f"RESULT:{{'success': {saved}, 'message': 'All dirty packages saved'}}")
185
186
  * @param retryDelayMs Delay between retry attempts in milliseconds
186
187
  * @returns Promise that resolves when connected or rejects after all attempts fail
187
188
  */
189
+ private connectPromise?: Promise<void>;
190
+
188
191
  async tryConnect(maxAttempts: number = 3, timeoutMs: number = 5000, retryDelayMs: number = 2000): Promise<boolean> {
189
- for (let attempt = 1; attempt <= maxAttempts; attempt++) {
192
+ if (this.connected) return true;
193
+
194
+ if (this.connectPromise) {
190
195
  try {
191
- this.log.info(`Connection attempt ${attempt}/${maxAttempts}`);
192
- await this.connect(timeoutMs);
193
- return true; // Successfully connected
194
- } catch (err) {
195
- this.log.warn(`Connection attempt ${attempt} failed:`, err);
196
-
197
- if (attempt < maxAttempts) {
198
- this.log.info(`Retrying in ${retryDelayMs}ms...`);
199
- await new Promise(resolve => setTimeout(resolve, retryDelayMs));
200
- } else {
201
- this.log.error(`All ${maxAttempts} connection attempts failed`);
202
- return false; // All attempts failed
196
+ await this.connectPromise;
197
+ } catch {
198
+ // swallow, we'll return connected flag
199
+ }
200
+ return this.connected;
201
+ }
202
+
203
+ this.connectPromise = (async () => {
204
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
205
+ // Early exit if another concurrent attempt already connected
206
+ if (this.connected) {
207
+ this.log.debug('Already connected; skipping remaining retry attempts');
208
+ return;
209
+ }
210
+ try {
211
+ this.log.debug(`Connection attempt ${attempt}/${maxAttempts}`);
212
+ await this.connect(timeoutMs);
213
+ return; // Successfully connected
214
+ } catch (err: any) {
215
+ const msg = (err?.message || String(err));
216
+ this.log.debug(`Connection attempt ${attempt} failed: ${msg}`);
217
+ if (attempt < maxAttempts) {
218
+ this.log.debug(`Retrying in ${retryDelayMs}ms...`);
219
+ // Sleep, but allow early break if we became connected during the wait
220
+ const start = Date.now();
221
+ while (Date.now() - start < retryDelayMs) {
222
+ if (this.connected) return; // someone else connected
223
+ await new Promise(r => setTimeout(r, 50));
224
+ }
225
+ } else {
226
+ // Keep this at warn (not error) and avoid stack spam
227
+ this.log.warn(`All ${maxAttempts} connection attempts failed`);
228
+ return; // exit, connected remains false
229
+ }
203
230
  }
204
231
  }
232
+ })();
233
+
234
+ try {
235
+ await this.connectPromise;
236
+ } finally {
237
+ this.connectPromise = undefined;
205
238
  }
206
- return false;
239
+
240
+ return this.connected;
207
241
  }
208
242
 
209
243
  async connect(timeoutMs: number = 5000): Promise<void> {
244
+ // If already connected and socket is open, do nothing
245
+ if (this.connected && this.ws && this.ws.readyState === WebSocket.OPEN) {
246
+ this.log.debug('connect() called but already connected; skipping');
247
+ return;
248
+ }
249
+
210
250
  const wsUrl = `ws://${this.env.UE_HOST}:${this.env.UE_RC_WS_PORT}`;
211
251
  const httpBase = `http://${this.env.UE_HOST}:${this.env.UE_RC_HTTP_PORT}`;
212
252
  this.http = createHttpClient(httpBase);
213
253
 
214
- this.log.info(`Connecting to UE Remote Control: ${wsUrl}`);
254
+ this.log.debug(`Connecting to UE Remote Control: ${wsUrl}`);
215
255
  this.ws = new WebSocket(wsUrl);
216
256
 
217
257
  await new Promise<void>((resolve, reject) => {
218
258
  if (!this.ws) return reject(new Error('WS not created'));
219
259
 
260
+ // Guard against double-resolution/rejection
261
+ let settled = false;
262
+ const safeResolve = () => { if (!settled) { settled = true; resolve(); } };
263
+ const safeReject = (err: Error) => { if (!settled) { settled = true; reject(err); } };
264
+
220
265
  // Setup timeout
221
266
  const timeout = setTimeout(() => {
222
267
  this.log.warn(`Connection timeout after ${timeoutMs}ms`);
223
268
  if (this.ws) {
224
- this.ws.removeAllListeners();
225
- // Only close if the websocket is in CONNECTING state
226
- if (this.ws.readyState === WebSocket.CONNECTING) {
227
- try {
228
- this.ws.terminate(); // Use terminate instead of close for immediate cleanup
229
- } catch (_e) {
230
- // Ignore close errors
269
+ try {
270
+ // Attach a temporary error handler to avoid unhandled 'error' events on abort
271
+ this.ws.on('error', () => {});
272
+ // Prefer graceful close; terminate as a fallback
273
+ if (this.ws.readyState === WebSocket.CONNECTING || this.ws.readyState === WebSocket.OPEN) {
274
+ try { this.ws.close(); } catch {}
275
+ try { this.ws.terminate(); } catch {}
231
276
  }
277
+ } finally {
278
+ try { this.ws.removeAllListeners(); } catch {}
279
+ this.ws = undefined;
232
280
  }
233
- this.ws = undefined;
234
281
  }
235
- reject(new Error('Connection timeout: Unreal Engine may not be running or Remote Control is not enabled'));
282
+ safeReject(new Error('Connection timeout: Unreal Engine may not be running or Remote Control is not enabled'));
236
283
  }, timeoutMs);
237
284
 
238
285
  // Success handler
@@ -241,37 +288,43 @@ print(f"RESULT:{{'success': {saved}, 'message': 'All dirty packages saved'}}")
241
288
  this.connected = true;
242
289
  this.log.info('Connected to Unreal Remote Control');
243
290
  this.startCommandProcessor(); // Start command processor on connect
244
- resolve();
291
+ safeResolve();
245
292
  };
246
293
 
247
294
  // Error handler
248
295
  const onError = (err: Error) => {
249
296
  clearTimeout(timeout);
250
- this.log.error('WebSocket error', err);
297
+ // Keep error logs concise to avoid stack spam when UE is not running
298
+ this.log.debug(`WebSocket error during connect: ${(err && (err as any).code) || ''} ${err.message}`);
251
299
  if (this.ws) {
252
- this.ws.removeAllListeners();
253
300
  try {
301
+ // Attach a temporary error handler to avoid unhandled 'error' events while aborting
302
+ this.ws.on('error', () => {});
254
303
  if (this.ws.readyState === WebSocket.OPEN || this.ws.readyState === WebSocket.CONNECTING) {
255
- this.ws.terminate();
304
+ try { this.ws.close(); } catch {}
305
+ try { this.ws.terminate(); } catch {}
256
306
  }
257
- } catch (_e) {
258
- // Ignore close errors
307
+ } finally {
308
+ try { this.ws.removeAllListeners(); } catch {}
309
+ this.ws = undefined;
259
310
  }
260
- this.ws = undefined;
261
311
  }
262
- reject(new Error(`Failed to connect: ${err.message}`));
312
+ safeReject(new Error(`Failed to connect: ${err.message}`));
263
313
  };
264
314
 
265
315
  // Close handler (if closed before open)
266
316
  const onClose = () => {
267
317
  if (!this.connected) {
268
318
  clearTimeout(timeout);
269
- reject(new Error('Connection closed before establishing'));
319
+ safeReject(new Error('Connection closed before establishing'));
270
320
  } else {
271
321
  // Normal close after connection was established
272
322
  this.connected = false;
323
+ this.ws = undefined;
273
324
  this.log.warn('WebSocket closed');
274
- this.scheduleReconnect();
325
+ if (this.autoReconnectEnabled) {
326
+ this.scheduleReconnect();
327
+ }
275
328
  }
276
329
  };
277
330
 
@@ -280,8 +333,8 @@ print(f"RESULT:{{'success': {saved}, 'message': 'All dirty packages saved'}}")
280
333
  try {
281
334
  const msg = JSON.parse(String(raw));
282
335
  this.log.debug('WS message', msg);
283
- } catch (e) {
284
- this.log.error('Failed parsing WS message', e);
336
+ } catch (_e) {
337
+ // Noise reduction: keep at debug and do nothing on parse errors
285
338
  }
286
339
  };
287
340
 
@@ -295,6 +348,11 @@ print(f"RESULT:{{'success': {saved}, 'message': 'All dirty packages saved'}}")
295
348
 
296
349
 
297
350
  async httpCall<T = any>(path: string, method: 'GET' | 'POST' | 'PUT' = 'POST', body?: any): Promise<T> {
351
+ // Guard: if not connected, do not attempt HTTP
352
+ if (!this.connected) {
353
+ throw new Error('Not connected to Unreal Engine');
354
+ }
355
+
298
356
  const url = path.startsWith('/') ? path : `/${path}`;
299
357
  const started = Date.now();
300
358
 
@@ -377,16 +435,18 @@ print(f"RESULT:{{'success': {saved}, 'message': 'All dirty packages saved'}}")
377
435
 
378
436
  // Log timeout errors specifically
379
437
  if (error.message?.includes('timeout')) {
380
- this.log.warn(`HTTP request timed out (attempt ${attempt + 1}/3): ${url}`);
438
+ this.log.debug(`HTTP request timed out (attempt ${attempt + 1}/3): ${url}`);
381
439
  }
382
440
 
383
441
  if (attempt < 2) {
384
- this.log.warn(`HTTP request failed (attempt ${attempt + 1}/3), retrying in ${delay}ms...`);
442
+ this.log.debug(`HTTP request failed (attempt ${attempt + 1}/3), retrying in ${delay}ms...`);
385
443
  await new Promise(resolve => setTimeout(resolve, delay));
386
444
 
387
445
  // If connection error, try to reconnect
388
446
  if (error.code === 'ECONNREFUSED' || error.code === 'ETIMEDOUT' || error.message?.includes('timeout')) {
389
- this.scheduleReconnect();
447
+ if (this.autoReconnectEnabled) {
448
+ this.scheduleReconnect();
449
+ }
390
450
  }
391
451
  }
392
452
  }
@@ -397,6 +457,7 @@ print(f"RESULT:{{'success': {saved}, 'message': 'All dirty packages saved'}}")
397
457
 
398
458
  // Generic function call via Remote Control HTTP API
399
459
  async call(body: RcCallBody): Promise<any> {
460
+ if (!this.connected) throw new Error('Not connected to Unreal Engine');
400
461
  // Using HTTP endpoint /remote/object/call
401
462
  const result = await this.httpCall<any>('/remote/object/call', 'PUT', {
402
463
  generateTransaction: false,
@@ -406,11 +467,15 @@ print(f"RESULT:{{'success': {saved}, 'message': 'All dirty packages saved'}}")
406
467
  }
407
468
 
408
469
  async getExposed(): Promise<any> {
470
+ if (!this.connected) throw new Error('Not connected to Unreal Engine');
409
471
  return this.httpCall('/remote/preset', 'GET');
410
472
  }
411
473
 
412
474
  // Execute a console command safely with validation and throttling
413
475
  async executeConsoleCommand(command: string): Promise<any> {
476
+ if (!this.connected) {
477
+ throw new Error('Not connected to Unreal Engine');
478
+ }
414
479
  // Validate command is not empty
415
480
  if (!command || typeof command !== 'string') {
416
481
  throw new Error('Invalid command: must be a non-empty string');
@@ -484,6 +549,9 @@ print(f"RESULT:{{'success': {saved}, 'message': 'All dirty packages saved'}}")
484
549
 
485
550
  // Try to execute a Python command via the PythonScriptPlugin, fallback to `py` console command.
486
551
  async executePython(command: string): Promise<any> {
552
+ if (!this.connected) {
553
+ throw new Error('Not connected to Unreal Engine');
554
+ }
487
555
  const isMultiLine = /[\r\n]/.test(command) || command.includes(';');
488
556
  try {
489
557
  // Use ExecutePythonCommandEx with appropriate mode based on content
@@ -579,8 +647,17 @@ print(f"RESULT:{{'success': {saved}, 'message': 'All dirty packages saved'}}")
579
647
  }
580
648
  }
581
649
 
650
+ // Allow callers to enable/disable auto-reconnect behavior
651
+ setAutoReconnectEnabled(enabled: boolean): void {
652
+ this.autoReconnectEnabled = enabled;
653
+ }
654
+
582
655
  // Connection recovery
583
656
  private scheduleReconnect(): void {
657
+ if (!this.autoReconnectEnabled) {
658
+ this.log.info('Auto-reconnect disabled; not scheduling reconnection');
659
+ return;
660
+ }
584
661
  if (this.reconnectTimer || this.connected) {
585
662
  return;
586
663
  }
@@ -596,7 +673,7 @@ print(f"RESULT:{{'success': {saved}, 'message': 'All dirty packages saved'}}")
596
673
  30000 // Max 30 seconds
597
674
  );
598
675
 
599
- this.log.info(`Scheduling reconnection attempt ${this.reconnectAttempts + 1}/${this.MAX_RECONNECT_ATTEMPTS} in ${Math.round(delay)}ms`);
676
+ this.log.debug(`Scheduling reconnection attempt ${this.reconnectAttempts + 1}/${this.MAX_RECONNECT_ATTEMPTS} in ${Math.round(delay)}ms`);
600
677
 
601
678
  this.reconnectTimer = setTimeout(async () => {
602
679
  this.reconnectTimer = undefined;
@@ -607,7 +684,7 @@ print(f"RESULT:{{'success': {saved}, 'message': 'All dirty packages saved'}}")
607
684
  this.reconnectAttempts = 0;
608
685
  this.log.info('Successfully reconnected to Unreal Engine');
609
686
  } catch (err) {
610
- this.log.error('Reconnection attempt failed:', err);
687
+ this.log.warn('Reconnection attempt failed:', err);
611
688
  this.scheduleReconnect();
612
689
  }
613
690
  }, delay);
@@ -621,8 +698,15 @@ print(f"RESULT:{{'success': {saved}, 'message': 'All dirty packages saved'}}")
621
698
  }
622
699
 
623
700
  if (this.ws) {
624
- this.ws.close();
625
- this.ws = undefined;
701
+ try {
702
+ // Avoid unhandled error during shutdown
703
+ this.ws.on('error', () => {});
704
+ try { this.ws.close(); } catch {}
705
+ try { this.ws.terminate(); } catch {}
706
+ } finally {
707
+ try { this.ws.removeAllListeners(); } catch {}
708
+ this.ws = undefined;
709
+ }
626
710
  }
627
711
 
628
712
  this.connected = false;
@@ -663,7 +747,8 @@ print(f"RESULT:{{'success': {saved}, 'message': 'All dirty packages saved'}}")
663
747
  /**
664
748
  * Execute Python script and parse the result
665
749
  */
666
- private async executePythonWithResult(script: string): Promise<any> {
750
+ // Expose for internal consumers (resources) that want parsed RESULT blocks
751
+ public async executePythonWithResult(script: string): Promise<any> {
667
752
  try {
668
753
  // Wrap script to capture output so we can parse RESULT: lines reliably
669
754
  const wrappedScript = `
@@ -689,7 +774,6 @@ finally:
689
774
  if (response && typeof response === 'string') {
690
775
  out = response;
691
776
  } else if (response && typeof response === 'object') {
692
- // Common RC Python response contains LogOutput array entries with .Output strings
693
777
  if (Array.isArray((response as any).LogOutput)) {
694
778
  out = (response as any).LogOutput.map((l: any) => l.Output || '').join('');
695
779
  } else if (typeof (response as any).Output === 'string') {
@@ -697,7 +781,6 @@ finally:
697
781
  } else if (typeof (response as any).result === 'string') {
698
782
  out = (response as any).result;
699
783
  } else {
700
- // Fallback to stringifying object (may still include RESULT in nested fields)
701
784
  out = JSON.stringify(response);
702
785
  }
703
786
  }
@@ -705,20 +788,38 @@ finally:
705
788
  out = String(response || '');
706
789
  }
707
790
 
708
- // Find the last RESULT: JSON block in the output for robustness
709
- const matches = Array.from(out.matchAll(/RESULT:({[\s\S]*?})/g));
791
+ // Robust RESULT parsing with bracket matching (handles nested objects)
792
+ const marker = 'RESULT:';
793
+ const idx = out.lastIndexOf(marker);
794
+ if (idx !== -1) {
795
+ // Find first '{' after the marker
796
+ let i = idx + marker.length;
797
+ while (i < out.length && out[i] !== '{') i++;
798
+ if (i < out.length && out[i] === '{') {
799
+ let depth = 0;
800
+ let inStr = false;
801
+ let esc = false;
802
+ let j = i;
803
+ for (; j < out.length; j++) {
804
+ const ch = out[j];
805
+ if (esc) { esc = false; continue; }
806
+ if (ch === '\\') { esc = true; continue; }
807
+ if (ch === '"') { inStr = !inStr; continue; }
808
+ if (!inStr) {
809
+ if (ch === '{') depth++;
810
+ else if (ch === '}') { depth--; if (depth === 0) { j++; break; } }
811
+ }
812
+ }
813
+ const jsonStr = out.slice(i, j);
814
+ try { return JSON.parse(jsonStr); } catch {}
815
+ }
816
+ }
817
+
818
+ // Fallback to previous regex approach (best-effort)
819
+ const matches = Array.from(out.matchAll(/RESULT:({[\s\S]*})/g));
710
820
  if (matches.length > 0) {
711
821
  const last = matches[matches.length - 1][1];
712
- try {
713
- // Accept single quotes and True/False from Python repr if present
714
- const normalized = last
715
- .replace(/'/g, '"')
716
- .replace(/\bTrue\b/g, 'true')
717
- .replace(/\bFalse\b/g, 'false');
718
- return JSON.parse(normalized);
719
- } catch {
720
- return { raw: last };
721
- }
822
+ try { return JSON.parse(last); } catch { return { raw: last }; }
722
823
  }
723
824
 
724
825
  // If no RESULT: marker, return the best-effort textual output or original response
@@ -938,13 +1039,15 @@ print('RESULT:' + json.dumps(flags))
938
1039
  try {
939
1040
  const result = await item.command();
940
1041
  item.resolve(result);
941
- } catch (error) {
1042
+ } catch (error: any) {
942
1043
  // Retry logic for transient failures
1044
+ const msg = (error?.message || String(error)).toLowerCase();
1045
+ const notConnected = msg.includes('not connected to unreal');
943
1046
  if (item.retryCount === undefined) {
944
1047
  item.retryCount = 0;
945
1048
  }
946
1049
 
947
- if (item.retryCount < 3) {
1050
+ if (!notConnected && item.retryCount < 3) {
948
1051
  item.retryCount++;
949
1052
  this.log.warn(`Command failed, retrying (${item.retryCount}/3)`);
950
1053
 
package/src/utils/http.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import axios, { AxiosInstance, AxiosRequestConfig, AxiosError } from 'axios';
2
2
  import http from 'http';
3
3
  import https from 'https';
4
+ import { Logger } from './logger.js';
4
5
 
5
6
  // Connection pooling configuration for better performance
6
7
  const httpAgent = new http.Agent({
@@ -29,6 +30,8 @@ interface RetryConfig {
29
30
  retryableErrors: string[];
30
31
  }
31
32
 
33
+ const log = new Logger('HTTP');
34
+
32
35
  const defaultRetryConfig: RetryConfig = {
33
36
  maxRetries: 3,
34
37
  initialDelay: 1000,
@@ -99,8 +102,8 @@ export function createHttpClient(baseURL: string): AxiosInstance {
99
102
  client.interceptors.response.use(
100
103
  (response) => {
101
104
  const duration = Date.now() - ((response.config as any).metadata?.startTime || 0);
102
- if (duration > 5000) {
103
- console.warn(`[HTTP] Slow request: ${response.config.url} took ${duration}ms`);
105
+ if (duration > 5000) {
106
+ log.warn(`[HTTP] Slow request: ${response.config.url} took ${duration}ms`);
104
107
  }
105
108
  return response;
106
109
  },
@@ -147,8 +150,8 @@ export async function requestWithRetry<T = any>(
147
150
  }
148
151
 
149
152
  // Calculate delay and wait
150
- const delay = calculateBackoff(attempt, retry);
151
- console.error(`[HTTP] Retry attempt ${attempt}/${retry.maxRetries} after ${Math.round(delay)}ms`);
153
+ const delay = calculateBackoff(attempt, retry);
154
+ log.debug(`[HTTP] Retry attempt ${attempt}/${retry.maxRetries} after ${Math.round(delay)}ms`);
152
155
  await new Promise(resolve => setTimeout(resolve, delay));
153
156
  }
154
157
  }
@@ -32,7 +32,8 @@ export class ResponseValidator {
32
32
  try {
33
33
  const validator = this.ajv.compile(outputSchema);
34
34
  this.validators.set(toolName, validator);
35
- log.info(`Registered output schema for tool: ${toolName}`);
35
+ // Demote per-tool schema registration to debug to reduce log noise
36
+ log.debug(`Registered output schema for tool: ${toolName}`);
36
37
  } catch (_error) {
37
38
  log.error(`Failed to compile output schema for ${toolName}:`, _error);
38
39
  }
@@ -94,40 +95,68 @@ export class ResponseValidator {
94
95
  }
95
96
 
96
97
  /**
97
- * Wrap a tool response with validation
98
+ * Wrap a tool response with validation and MCP-compliant content shape.
99
+ *
100
+ * MCP tools/call responses must contain a `content` array. Many internal
101
+ * handlers return structured JSON objects (e.g., { success, message, ... }).
102
+ * This wrapper serializes such objects into a single text block while keeping
103
+ * existing `content` responses intact.
98
104
  */
99
105
  wrapResponse(toolName: string, response: any): any {
100
106
  // Ensure response is safe to serialize first
101
107
  try {
102
- // The response should already be cleaned, but double-check
103
108
  if (response && typeof response === 'object') {
104
- // Make sure we can serialize it
105
109
  JSON.stringify(response);
106
110
  }
107
111
  } catch (_error) {
108
112
  log.error(`Response for ${toolName} contains circular references, cleaning...`);
109
113
  response = cleanObject(response);
110
114
  }
111
-
112
- const validation = this.validateResponse(toolName, response);
113
-
114
- // Add validation metadata
115
+
116
+ // If handler already returned MCP content, keep it as-is (still validate)
117
+ const alreadyMcpShaped = response && typeof response === 'object' && Array.isArray(response.content);
118
+
119
+ // Choose the payload to validate: if already MCP-shaped, validate the
120
+ // structured content extracted from text; otherwise validate the object directly.
121
+ const validationTarget = alreadyMcpShaped ? response : response;
122
+ const validation = this.validateResponse(toolName, validationTarget);
123
+
115
124
  if (!validation.valid) {
116
125
  log.warn(`Tool ${toolName} response validation failed:`, validation.errors);
117
-
118
- // Add warning to response but don't fail
119
- if (response && typeof response === 'object') {
120
- response._validation = {
121
- valid: false,
122
- errors: validation.errors
123
- };
126
+ }
127
+
128
+ // If it's already MCP-shaped, return as-is (optionally append validation meta)
129
+ if (alreadyMcpShaped) {
130
+ if (!validation.valid) {
131
+ try {
132
+ (response as any)._validation = { valid: false, errors: validation.errors };
133
+ } catch {}
124
134
  }
135
+ return response;
125
136
  }
126
137
 
127
- // Don't add structuredContent to the response - it's for internal validation only
128
- // Adding it can cause circular references
129
-
130
- return response;
138
+ // Otherwise, wrap structured result into MCP content
139
+ let text: string;
140
+ try {
141
+ // Pretty-print small objects for readability
142
+ text = typeof response === 'string'
143
+ ? response
144
+ : JSON.stringify(response ?? { success: true }, null, 2);
145
+ } catch (_e) {
146
+ text = String(response);
147
+ }
148
+
149
+ const wrapped = {
150
+ content: [
151
+ { type: 'text', text }
152
+ ]
153
+ } as any;
154
+
155
+ if (!validation.valid) {
156
+ wrapped._validation = { valid: false, errors: validation.errors };
157
+ }
158
+
159
+ return wrapped;
131
160
  }
132
161
 
133
162
  /**