unreal-engine-mcp-server 0.3.0 → 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.
@@ -572,7 +572,7 @@ Example:
572
572
  description: `Stream in/out a sublevel and set visibility.
573
573
 
574
574
  Example:
575
- - {'levelName':'Sublevel_A','shouldBeLoaded':true,'shouldBeVisible':true}`,
575
+ - {"levelName":"Sublevel_A","shouldBeLoaded":true,"shouldBeVisible":true}`,
576
576
  inputSchema: {
577
577
  type: 'object',
578
578
  properties: {
@@ -599,8 +599,8 @@ Example:
599
599
  description: `Create a light (Directional/Point/Spot/Rect/Sky) with optional transform/intensity.
600
600
 
601
601
  Examples:
602
- - {'lightType':'Directional','name':'KeyLight','intensity':5.0}
603
- - {'lightType':'Point','name':'Fill','location':{'x':0,'y':100,'z':200},'intensity':2000}`,
602
+ - {"lightType":"Directional","name":"KeyLight","intensity":5.0}
603
+ - {"lightType":"Point","name":"Fill","location":{"x":0,"y":100,"z":200},"intensity":2000}`,
604
604
  inputSchema: {
605
605
  type: 'object',
606
606
  properties: {
@@ -629,7 +629,13 @@ Examples:
629
629
  },
630
630
  {
631
631
  name: 'build_lighting',
632
- description: 'Start a lighting build at an optional quality level (Preview/Medium/High/Production).',
632
+ description: `Start a lighting build.
633
+
634
+ When to use:
635
+ - Bake lights for preview or final output (choose quality).
636
+
637
+ Example:
638
+ - {"quality":"High"}`,
633
639
  inputSchema: {
634
640
  type: 'object',
635
641
  properties: {
@@ -649,7 +655,13 @@ Examples:
649
655
  // Landscape Tools
650
656
  {
651
657
  name: 'create_landscape',
652
- description: 'Attempt to create a landscape. Native Python APIs are limited; you may receive a guidance message to use Landscape Mode in the editor.',
658
+ description: `Attempt to create a landscape.
659
+
660
+ Notes:
661
+ - Native Python APIs are limited; you may be guided to use Landscape Mode in the editor.
662
+
663
+ Example:
664
+ - {"name":"Landscape_Basic","sizeX":1024,"sizeY":1024}`,
653
665
  inputSchema: {
654
666
  type: 'object',
655
667
  properties: {
@@ -671,7 +683,10 @@ Examples:
671
683
  },
672
684
  {
673
685
  name: 'sculpt_landscape',
674
- description: 'Sculpt a landscape using editor tools (best-effort; some operations may require manual Landscape Mode).',
686
+ description: `Sculpt a landscape using editor tools (best-effort; some operations may require manual Landscape Mode).
687
+
688
+ Example:
689
+ - {"landscapeName":"Landscape_Basic","tool":"Smooth","brushSize":300,"strength":0.5}`,
675
690
  inputSchema: {
676
691
  type: 'object',
677
692
  properties: {
@@ -697,7 +712,7 @@ Examples:
697
712
  description: `Create or load a FoliageType asset for instanced foliage workflows.
698
713
 
699
714
  Example:
700
- - {'name':'FT_Grass','meshPath':'/Game/Foliage/SM_Grass','density':300}`,
715
+ - {"name":"FT_Grass","meshPath":"/Game/Foliage/SM_Grass","density":300}`,
701
716
  inputSchema: {
702
717
  type: 'object',
703
718
  properties: {
@@ -718,7 +733,13 @@ Example:
718
733
  },
719
734
  {
720
735
  name: 'paint_foliage',
721
- description: 'Paint foliage on landscape',
736
+ description: `Paint foliage onto the world.
737
+
738
+ When to use:
739
+ - Scatter instances using an existing FoliageType.
740
+
741
+ Example:
742
+ - {"foliageType":"/Game/Foliage/Types/FT_Grass","position":{"x":0,"y":0,"z":0},"brushSize":300}`,
722
743
  inputSchema: {
723
744
  type: 'object',
724
745
  properties: {
@@ -748,7 +769,10 @@ Example:
748
769
  // Debug Visualization Tools
749
770
  {
750
771
  name: 'draw_debug_shape',
751
- description: 'Draw a debug shape',
772
+ description: `Draw a debug shape.
773
+
774
+ Example:
775
+ - {"shape":"Sphere","position":{"x":0,"y":0,"z":0},"size":100,"color":[255,0,0,255],"duration":3}`,
752
776
  inputSchema: {
753
777
  type: 'object',
754
778
  properties: {
@@ -781,7 +805,10 @@ Example:
781
805
  },
782
806
  {
783
807
  name: 'set_view_mode',
784
- description: 'Set the viewport view mode',
808
+ description: `Set the viewport view mode.
809
+
810
+ Example:
811
+ - {"mode":"Wireframe"}`,
785
812
  inputSchema: {
786
813
  type: 'object',
787
814
  properties: {
@@ -802,7 +829,10 @@ Example:
802
829
  // Performance Tools
803
830
  {
804
831
  name: 'start_profiling',
805
- description: 'Start performance profiling',
832
+ description: `Start performance profiling.
833
+
834
+ Example:
835
+ - {"type":"GPU","duration":10}`,
806
836
  inputSchema: {
807
837
  type: 'object',
808
838
  properties: {
@@ -822,7 +852,10 @@ Example:
822
852
  },
823
853
  {
824
854
  name: 'show_fps',
825
- description: 'Show FPS counter',
855
+ description: `Show/hide the FPS counter.
856
+
857
+ Example:
858
+ - {"enabled":true,"verbose":false}`,
826
859
  inputSchema: {
827
860
  type: 'object',
828
861
  properties: {
@@ -842,7 +875,10 @@ Example:
842
875
  },
843
876
  {
844
877
  name: 'set_scalability',
845
- description: 'Set scalability settings',
878
+ description: `Set scalability/quality levels.
879
+
880
+ Example:
881
+ - {"category":"Shadows","level":2}`,
846
882
  inputSchema: {
847
883
  type: 'object',
848
884
  properties: {
@@ -864,7 +900,10 @@ Example:
864
900
  // Audio Tools
865
901
  {
866
902
  name: 'play_sound',
867
- description: 'Play a sound',
903
+ description: `Play a sound.
904
+
905
+ Example:
906
+ - {"soundPath":"/Game/Audio/SFX/Click","volume":0.5,"is3D":true}`,
868
907
  inputSchema: {
869
908
  type: 'object',
870
909
  properties: {
@@ -893,7 +932,10 @@ Example:
893
932
  },
894
933
  {
895
934
  name: 'create_ambient_sound',
896
- description: 'Create an ambient sound',
935
+ description: `Create an ambient sound actor.
936
+
937
+ Example:
938
+ - {"name":"Amb_Wind","soundPath":"/Game/Audio/Amb/AMB_Wind","location":{"x":0,"y":0,"z":0},"radius":1000}`,
897
939
  inputSchema: {
898
940
  type: 'object',
899
941
  properties: {
@@ -924,7 +966,10 @@ Example:
924
966
  // UI Tools
925
967
  {
926
968
  name: 'create_widget',
927
- description: 'Create a UI widget',
969
+ description: `Create a UI widget.
970
+
971
+ Example:
972
+ - {"name":"HUDMain","type":"HUD","savePath":"/Game/UI"}`,
928
973
  inputSchema: {
929
974
  type: 'object',
930
975
  properties: {
@@ -945,7 +990,10 @@ Example:
945
990
  },
946
991
  {
947
992
  name: 'show_widget',
948
- description: 'Show or hide a widget',
993
+ description: `Show or hide a widget.
994
+
995
+ Example:
996
+ - {"widgetName":"HUDMain","visible":true}`,
949
997
  inputSchema: {
950
998
  type: 'object',
951
999
  properties: {
@@ -965,7 +1013,10 @@ Example:
965
1013
  },
966
1014
  {
967
1015
  name: 'create_hud',
968
- description: 'Create a HUD',
1016
+ description: `Create a HUD description/layout.
1017
+
1018
+ Example:
1019
+ - {"name":"GameHUD","elements":[{"type":"Text","position":[10,10]}]}`,
969
1020
  inputSchema: {
970
1021
  type: 'object',
971
1022
  properties: {
@@ -999,7 +1050,14 @@ Example:
999
1050
  // Console command (universal tool)
1000
1051
  {
1001
1052
  name: 'console_command',
1002
- description: 'Execute any console command in Unreal Engine',
1053
+ description: `Execute a console command.
1054
+
1055
+ When to use:
1056
+ - Quick toggles like "stat fps", "viewmode wireframe", or r.* cvars.
1057
+
1058
+ Examples:
1059
+ - {"command":"stat fps"}
1060
+ - {"command":"r.ScreenPercentage 75"}`,
1003
1061
  inputSchema: {
1004
1062
  type: 'object',
1005
1063
  properties: {
@@ -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
  }