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.
- package/.env.production +6 -1
- package/Dockerfile +11 -28
- package/dist/index.js +105 -37
- package/dist/resources/actors.js +71 -13
- package/dist/tools/consolidated-tool-definitions.js +127 -5
- package/dist/tools/consolidated-tool-handlers.js +4 -1
- package/dist/tools/tool-definitions.js +77 -19
- package/dist/unreal-bridge.d.ts +4 -1
- package/dist/unreal-bridge.js +211 -53
- package/dist/utils/http.js +4 -2
- package/dist/utils/response-validator.js +2 -1
- package/package.json +2 -2
- package/server.json +2 -2
- package/src/index.ts +103 -38
- package/src/resources/actors.ts +51 -13
- package/src/tools/consolidated-tool-definitions.ts +127 -5
- package/src/tools/consolidated-tool-handlers.ts +5 -1
- package/src/tools/tool-definitions.ts +77 -19
- package/src/unreal-bridge.ts +163 -60
- package/src/utils/http.ts +7 -4
- package/src/utils/response-validator.ts +2 -1
|
@@ -572,7 +572,7 @@ Example:
|
|
|
572
572
|
description: `Stream in/out a sublevel and set visibility.
|
|
573
573
|
|
|
574
574
|
Example:
|
|
575
|
-
- {
|
|
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
|
-
- {
|
|
603
|
-
- {
|
|
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:
|
|
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:
|
|
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:
|
|
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
|
-
- {
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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: {
|
package/src/unreal-bridge.ts
CHANGED
|
@@ -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
|
-
|
|
192
|
+
if (this.connected) return true;
|
|
193
|
+
|
|
194
|
+
if (this.connectPromise) {
|
|
190
195
|
try {
|
|
191
|
-
this.
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
}
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
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
|
-
|
|
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
|
-
|
|
291
|
+
safeResolve();
|
|
245
292
|
};
|
|
246
293
|
|
|
247
294
|
// Error handler
|
|
248
295
|
const onError = (err: Error) => {
|
|
249
296
|
clearTimeout(timeout);
|
|
250
|
-
|
|
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.
|
|
304
|
+
try { this.ws.close(); } catch {}
|
|
305
|
+
try { this.ws.terminate(); } catch {}
|
|
256
306
|
}
|
|
257
|
-
}
|
|
258
|
-
|
|
307
|
+
} finally {
|
|
308
|
+
try { this.ws.removeAllListeners(); } catch {}
|
|
309
|
+
this.ws = undefined;
|
|
259
310
|
}
|
|
260
|
-
this.ws = undefined;
|
|
261
311
|
}
|
|
262
|
-
|
|
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
|
-
|
|
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.
|
|
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 (
|
|
284
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
625
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
709
|
-
const
|
|
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
|
-
|
|
103
|
-
|
|
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
|
-
|
|
151
|
-
|
|
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
|
-
|
|
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
|
}
|