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.
- package/.env.production +6 -1
- package/Dockerfile +11 -28
- package/README.md +1 -2
- package/dist/index.js +120 -54
- package/dist/resources/actors.js +71 -13
- package/dist/resources/assets.d.ts +3 -2
- package/dist/resources/assets.js +96 -72
- package/dist/resources/levels.js +2 -2
- package/dist/tools/assets.js +6 -2
- package/dist/tools/build_environment_advanced.js +46 -42
- package/dist/tools/consolidated-tool-definitions.d.ts +232 -15
- package/dist/tools/consolidated-tool-definitions.js +173 -8
- package/dist/tools/consolidated-tool-handlers.js +331 -718
- package/dist/tools/debug.js +4 -6
- package/dist/tools/rc.js +2 -2
- package/dist/tools/sequence.js +21 -2
- 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.d.ts +6 -1
- package/dist/utils/response-validator.js +43 -15
- package/package.json +5 -5
- package/server.json +2 -2
- package/src/index.ts +120 -56
- package/src/resources/actors.ts +51 -13
- package/src/resources/assets.ts +97 -73
- package/src/resources/levels.ts +2 -2
- package/src/tools/assets.ts +6 -2
- package/src/tools/build_environment_advanced.ts +46 -42
- package/src/tools/consolidated-tool-definitions.ts +173 -8
- package/src/tools/consolidated-tool-handlers.ts +318 -747
- package/src/tools/debug.ts +4 -6
- package/src/tools/rc.ts +2 -2
- package/src/tools/sequence.ts +21 -2
- package/src/unreal-bridge.ts +163 -60
- package/src/utils/http.ts +7 -4
- package/src/utils/response-validator.ts +48 -19
- package/dist/tools/tool-definitions.d.ts +0 -4919
- package/dist/tools/tool-definitions.js +0 -1007
- package/dist/tools/tool-handlers.d.ts +0 -47
- package/dist/tools/tool-handlers.js +0 -863
- package/src/tools/tool-definitions.ts +0 -1023
- package/src/tools/tool-handlers.ts +0 -973
package/src/tools/debug.ts
CHANGED
|
@@ -22,13 +22,11 @@ export class DebugVisualizationTools {
|
|
|
22
22
|
private async pyDraw(scriptBody: string) {
|
|
23
23
|
const script = `
|
|
24
24
|
import unreal
|
|
25
|
-
#
|
|
25
|
+
# Strict modern API: require UnrealEditorSubsystem (UE 5.1+)
|
|
26
26
|
ues = unreal.get_editor_subsystem(unreal.UnrealEditorSubsystem)
|
|
27
|
-
if ues:
|
|
28
|
-
|
|
29
|
-
|
|
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,
|
|
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,
|
|
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'
|
package/src/tools/sequence.ts
CHANGED
|
@@ -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
|
-
|
|
342
|
-
|
|
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();
|
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
|
}
|
|
@@ -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
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
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
|
-
//
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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
|
/**
|