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/dist/tools/debug.js
CHANGED
|
@@ -20,13 +20,11 @@ export class DebugVisualizationTools {
|
|
|
20
20
|
async pyDraw(scriptBody) {
|
|
21
21
|
const script = `
|
|
22
22
|
import unreal
|
|
23
|
-
#
|
|
23
|
+
# Strict modern API: require UnrealEditorSubsystem (UE 5.1+)
|
|
24
24
|
ues = unreal.get_editor_subsystem(unreal.UnrealEditorSubsystem)
|
|
25
|
-
if ues:
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
# Fallback to deprecated API if subsystem not available
|
|
29
|
-
world = unreal.EditorLevelLibrary.get_editor_world()
|
|
25
|
+
if not ues:
|
|
26
|
+
raise Exception('UnrealEditorSubsystem not available')
|
|
27
|
+
world = ues.get_editor_world()
|
|
30
28
|
${scriptBody}
|
|
31
29
|
`.trim()
|
|
32
30
|
.replace(/\r?\n/g, '\n');
|
package/dist/tools/rc.js
CHANGED
|
@@ -142,7 +142,7 @@ except Exception as e:
|
|
|
142
142
|
}
|
|
143
143
|
// Expose an actor by label/name into a preset
|
|
144
144
|
async exposeActor(params) {
|
|
145
|
-
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,
|
|
145
|
+
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();
|
|
146
146
|
const resp = await this.executeWithRetry(() => this.bridge.executePython(python), 'exposeActor');
|
|
147
147
|
const result = this.parsePythonResult(resp, 'exposeActor');
|
|
148
148
|
// Clear cache for this preset to force refresh
|
|
@@ -153,7 +153,7 @@ except Exception as e:
|
|
|
153
153
|
}
|
|
154
154
|
// Expose a property on an object into a preset
|
|
155
155
|
async exposeProperty(params) {
|
|
156
|
-
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,
|
|
156
|
+
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();
|
|
157
157
|
const resp = await this.executeWithRetry(() => this.bridge.executePython(python), 'exposeProperty');
|
|
158
158
|
const result = this.parsePythonResult(resp, 'exposeProperty');
|
|
159
159
|
// Clear cache for this preset to force refresh
|
package/dist/tools/sequence.js
CHANGED
|
@@ -282,12 +282,31 @@ except Exception as e:
|
|
|
282
282
|
* Play the current level sequence
|
|
283
283
|
*/
|
|
284
284
|
async play(params) {
|
|
285
|
+
const loop = params?.loopMode || '';
|
|
285
286
|
const py = `
|
|
286
287
|
import unreal, json
|
|
288
|
+
|
|
289
|
+
# Helper to resolve SequencerLoopMode from a friendly string
|
|
290
|
+
def _resolve_loop_mode(mode_str):
|
|
291
|
+
try:
|
|
292
|
+
m = str(mode_str).lower()
|
|
293
|
+
slm = unreal.SequencerLoopMode
|
|
294
|
+
if m in ('once','noloop','no_loop'):
|
|
295
|
+
return getattr(slm, 'SLM_NoLoop', getattr(slm, 'NoLoop'))
|
|
296
|
+
if m in ('loop',):
|
|
297
|
+
return getattr(slm, 'SLM_Loop', getattr(slm, 'Loop'))
|
|
298
|
+
if m in ('pingpong','ping_pong'):
|
|
299
|
+
return getattr(slm, 'SLM_PingPong', getattr(slm, 'PingPong'))
|
|
300
|
+
except Exception:
|
|
301
|
+
pass
|
|
302
|
+
return None
|
|
303
|
+
|
|
287
304
|
try:
|
|
288
305
|
unreal.LevelSequenceEditorBlueprintLibrary.play()
|
|
289
|
-
|
|
290
|
-
|
|
306
|
+
loop_mode = _resolve_loop_mode('${loop}')
|
|
307
|
+
if loop_mode is not None:
|
|
308
|
+
unreal.LevelSequenceEditorBlueprintLibrary.set_loop_mode(loop_mode)
|
|
309
|
+
print('RESULT:' + json.dumps({'success': True, 'playing': True, 'loopMode': '${loop || 'default'}'}))
|
|
291
310
|
except Exception as e:
|
|
292
311
|
print('RESULT:' + json.dumps({'success': False, 'error': str(e)}))
|
|
293
312
|
`.trim();
|
package/dist/unreal-bridge.d.ts
CHANGED
|
@@ -14,6 +14,7 @@ export declare class UnrealBridge {
|
|
|
14
14
|
private reconnectAttempts;
|
|
15
15
|
private readonly MAX_RECONNECT_ATTEMPTS;
|
|
16
16
|
private readonly BASE_RECONNECT_DELAY;
|
|
17
|
+
private autoReconnectEnabled;
|
|
17
18
|
private commandQueue;
|
|
18
19
|
private isProcessing;
|
|
19
20
|
private readonly MIN_COMMAND_DELAY;
|
|
@@ -32,6 +33,7 @@ export declare class UnrealBridge {
|
|
|
32
33
|
* @param retryDelayMs Delay between retry attempts in milliseconds
|
|
33
34
|
* @returns Promise that resolves when connected or rejects after all attempts fail
|
|
34
35
|
*/
|
|
36
|
+
private connectPromise?;
|
|
35
37
|
tryConnect(maxAttempts?: number, timeoutMs?: number, retryDelayMs?: number): Promise<boolean>;
|
|
36
38
|
connect(timeoutMs?: number): Promise<void>;
|
|
37
39
|
httpCall<T = any>(path: string, method?: 'GET' | 'POST' | 'PUT', body?: any): Promise<T>;
|
|
@@ -39,6 +41,7 @@ export declare class UnrealBridge {
|
|
|
39
41
|
getExposed(): Promise<any>;
|
|
40
42
|
executeConsoleCommand(command: string): Promise<any>;
|
|
41
43
|
executePython(command: string): Promise<any>;
|
|
44
|
+
setAutoReconnectEnabled(enabled: boolean): void;
|
|
42
45
|
private scheduleReconnect;
|
|
43
46
|
disconnect(): Promise<void>;
|
|
44
47
|
/**
|
|
@@ -49,7 +52,7 @@ export declare class UnrealBridge {
|
|
|
49
52
|
/**
|
|
50
53
|
* Execute Python script and parse the result
|
|
51
54
|
*/
|
|
52
|
-
|
|
55
|
+
executePythonWithResult(script: string): Promise<any>;
|
|
53
56
|
/**
|
|
54
57
|
* Get the Unreal Engine version via Python and parse major/minor/patch.
|
|
55
58
|
*/
|
package/dist/unreal-bridge.js
CHANGED
|
@@ -12,6 +12,7 @@ export class UnrealBridge {
|
|
|
12
12
|
reconnectAttempts = 0;
|
|
13
13
|
MAX_RECONNECT_ATTEMPTS = 5;
|
|
14
14
|
BASE_RECONNECT_DELAY = 1000;
|
|
15
|
+
autoReconnectEnabled = false; // disabled by default to prevent looping retries
|
|
15
16
|
// Command queue for throttling
|
|
16
17
|
commandQueue = [];
|
|
17
18
|
isProcessing = false;
|
|
@@ -151,53 +152,112 @@ print(f"RESULT:{{'success': {saved}, 'message': 'All dirty packages saved'}}")
|
|
|
151
152
|
* @param retryDelayMs Delay between retry attempts in milliseconds
|
|
152
153
|
* @returns Promise that resolves when connected or rejects after all attempts fail
|
|
153
154
|
*/
|
|
155
|
+
connectPromise;
|
|
154
156
|
async tryConnect(maxAttempts = 3, timeoutMs = 5000, retryDelayMs = 2000) {
|
|
155
|
-
|
|
157
|
+
if (this.connected)
|
|
158
|
+
return true;
|
|
159
|
+
if (this.connectPromise) {
|
|
156
160
|
try {
|
|
157
|
-
this.
|
|
158
|
-
await this.connect(timeoutMs);
|
|
159
|
-
return true; // Successfully connected
|
|
161
|
+
await this.connectPromise;
|
|
160
162
|
}
|
|
161
|
-
catch
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
163
|
+
catch {
|
|
164
|
+
// swallow, we'll return connected flag
|
|
165
|
+
}
|
|
166
|
+
return this.connected;
|
|
167
|
+
}
|
|
168
|
+
this.connectPromise = (async () => {
|
|
169
|
+
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
170
|
+
// Early exit if another concurrent attempt already connected
|
|
171
|
+
if (this.connected) {
|
|
172
|
+
this.log.debug('Already connected; skipping remaining retry attempts');
|
|
173
|
+
return;
|
|
166
174
|
}
|
|
167
|
-
|
|
168
|
-
this.log.
|
|
169
|
-
|
|
175
|
+
try {
|
|
176
|
+
this.log.debug(`Connection attempt ${attempt}/${maxAttempts}`);
|
|
177
|
+
await this.connect(timeoutMs);
|
|
178
|
+
return; // Successfully connected
|
|
179
|
+
}
|
|
180
|
+
catch (err) {
|
|
181
|
+
const msg = (err?.message || String(err));
|
|
182
|
+
this.log.debug(`Connection attempt ${attempt} failed: ${msg}`);
|
|
183
|
+
if (attempt < maxAttempts) {
|
|
184
|
+
this.log.debug(`Retrying in ${retryDelayMs}ms...`);
|
|
185
|
+
// Sleep, but allow early break if we became connected during the wait
|
|
186
|
+
const start = Date.now();
|
|
187
|
+
while (Date.now() - start < retryDelayMs) {
|
|
188
|
+
if (this.connected)
|
|
189
|
+
return; // someone else connected
|
|
190
|
+
await new Promise(r => setTimeout(r, 50));
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
else {
|
|
194
|
+
// Keep this at warn (not error) and avoid stack spam
|
|
195
|
+
this.log.warn(`All ${maxAttempts} connection attempts failed`);
|
|
196
|
+
return; // exit, connected remains false
|
|
197
|
+
}
|
|
170
198
|
}
|
|
171
199
|
}
|
|
200
|
+
})();
|
|
201
|
+
try {
|
|
202
|
+
await this.connectPromise;
|
|
172
203
|
}
|
|
173
|
-
|
|
204
|
+
finally {
|
|
205
|
+
this.connectPromise = undefined;
|
|
206
|
+
}
|
|
207
|
+
return this.connected;
|
|
174
208
|
}
|
|
175
209
|
async connect(timeoutMs = 5000) {
|
|
210
|
+
// If already connected and socket is open, do nothing
|
|
211
|
+
if (this.connected && this.ws && this.ws.readyState === WebSocket.OPEN) {
|
|
212
|
+
this.log.debug('connect() called but already connected; skipping');
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
176
215
|
const wsUrl = `ws://${this.env.UE_HOST}:${this.env.UE_RC_WS_PORT}`;
|
|
177
216
|
const httpBase = `http://${this.env.UE_HOST}:${this.env.UE_RC_HTTP_PORT}`;
|
|
178
217
|
this.http = createHttpClient(httpBase);
|
|
179
|
-
this.log.
|
|
218
|
+
this.log.debug(`Connecting to UE Remote Control: ${wsUrl}`);
|
|
180
219
|
this.ws = new WebSocket(wsUrl);
|
|
181
220
|
await new Promise((resolve, reject) => {
|
|
182
221
|
if (!this.ws)
|
|
183
222
|
return reject(new Error('WS not created'));
|
|
223
|
+
// Guard against double-resolution/rejection
|
|
224
|
+
let settled = false;
|
|
225
|
+
const safeResolve = () => { if (!settled) {
|
|
226
|
+
settled = true;
|
|
227
|
+
resolve();
|
|
228
|
+
} };
|
|
229
|
+
const safeReject = (err) => { if (!settled) {
|
|
230
|
+
settled = true;
|
|
231
|
+
reject(err);
|
|
232
|
+
} };
|
|
184
233
|
// Setup timeout
|
|
185
234
|
const timeout = setTimeout(() => {
|
|
186
235
|
this.log.warn(`Connection timeout after ${timeoutMs}ms`);
|
|
187
236
|
if (this.ws) {
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
237
|
+
try {
|
|
238
|
+
// Attach a temporary error handler to avoid unhandled 'error' events on abort
|
|
239
|
+
this.ws.on('error', () => { });
|
|
240
|
+
// Prefer graceful close; terminate as a fallback
|
|
241
|
+
if (this.ws.readyState === WebSocket.CONNECTING || this.ws.readyState === WebSocket.OPEN) {
|
|
242
|
+
try {
|
|
243
|
+
this.ws.close();
|
|
244
|
+
}
|
|
245
|
+
catch { }
|
|
246
|
+
try {
|
|
247
|
+
this.ws.terminate();
|
|
248
|
+
}
|
|
249
|
+
catch { }
|
|
193
250
|
}
|
|
194
|
-
|
|
195
|
-
|
|
251
|
+
}
|
|
252
|
+
finally {
|
|
253
|
+
try {
|
|
254
|
+
this.ws.removeAllListeners();
|
|
196
255
|
}
|
|
256
|
+
catch { }
|
|
257
|
+
this.ws = undefined;
|
|
197
258
|
}
|
|
198
|
-
this.ws = undefined;
|
|
199
259
|
}
|
|
200
|
-
|
|
260
|
+
safeReject(new Error('Connection timeout: Unreal Engine may not be running or Remote Control is not enabled'));
|
|
201
261
|
}, timeoutMs);
|
|
202
262
|
// Success handler
|
|
203
263
|
const onOpen = () => {
|
|
@@ -205,37 +265,52 @@ print(f"RESULT:{{'success': {saved}, 'message': 'All dirty packages saved'}}")
|
|
|
205
265
|
this.connected = true;
|
|
206
266
|
this.log.info('Connected to Unreal Remote Control');
|
|
207
267
|
this.startCommandProcessor(); // Start command processor on connect
|
|
208
|
-
|
|
268
|
+
safeResolve();
|
|
209
269
|
};
|
|
210
270
|
// Error handler
|
|
211
271
|
const onError = (err) => {
|
|
212
272
|
clearTimeout(timeout);
|
|
213
|
-
|
|
273
|
+
// Keep error logs concise to avoid stack spam when UE is not running
|
|
274
|
+
this.log.debug(`WebSocket error during connect: ${(err && err.code) || ''} ${err.message}`);
|
|
214
275
|
if (this.ws) {
|
|
215
|
-
this.ws.removeAllListeners();
|
|
216
276
|
try {
|
|
277
|
+
// Attach a temporary error handler to avoid unhandled 'error' events while aborting
|
|
278
|
+
this.ws.on('error', () => { });
|
|
217
279
|
if (this.ws.readyState === WebSocket.OPEN || this.ws.readyState === WebSocket.CONNECTING) {
|
|
218
|
-
|
|
280
|
+
try {
|
|
281
|
+
this.ws.close();
|
|
282
|
+
}
|
|
283
|
+
catch { }
|
|
284
|
+
try {
|
|
285
|
+
this.ws.terminate();
|
|
286
|
+
}
|
|
287
|
+
catch { }
|
|
219
288
|
}
|
|
220
289
|
}
|
|
221
|
-
|
|
222
|
-
|
|
290
|
+
finally {
|
|
291
|
+
try {
|
|
292
|
+
this.ws.removeAllListeners();
|
|
293
|
+
}
|
|
294
|
+
catch { }
|
|
295
|
+
this.ws = undefined;
|
|
223
296
|
}
|
|
224
|
-
this.ws = undefined;
|
|
225
297
|
}
|
|
226
|
-
|
|
298
|
+
safeReject(new Error(`Failed to connect: ${err.message}`));
|
|
227
299
|
};
|
|
228
300
|
// Close handler (if closed before open)
|
|
229
301
|
const onClose = () => {
|
|
230
302
|
if (!this.connected) {
|
|
231
303
|
clearTimeout(timeout);
|
|
232
|
-
|
|
304
|
+
safeReject(new Error('Connection closed before establishing'));
|
|
233
305
|
}
|
|
234
306
|
else {
|
|
235
307
|
// Normal close after connection was established
|
|
236
308
|
this.connected = false;
|
|
309
|
+
this.ws = undefined;
|
|
237
310
|
this.log.warn('WebSocket closed');
|
|
238
|
-
this.
|
|
311
|
+
if (this.autoReconnectEnabled) {
|
|
312
|
+
this.scheduleReconnect();
|
|
313
|
+
}
|
|
239
314
|
}
|
|
240
315
|
};
|
|
241
316
|
// Message handler (currently best-effort logging)
|
|
@@ -244,8 +319,8 @@ print(f"RESULT:{{'success': {saved}, 'message': 'All dirty packages saved'}}")
|
|
|
244
319
|
const msg = JSON.parse(String(raw));
|
|
245
320
|
this.log.debug('WS message', msg);
|
|
246
321
|
}
|
|
247
|
-
catch (
|
|
248
|
-
|
|
322
|
+
catch (_e) {
|
|
323
|
+
// Noise reduction: keep at debug and do nothing on parse errors
|
|
249
324
|
}
|
|
250
325
|
};
|
|
251
326
|
// Attach listeners
|
|
@@ -256,6 +331,10 @@ print(f"RESULT:{{'success': {saved}, 'message': 'All dirty packages saved'}}")
|
|
|
256
331
|
});
|
|
257
332
|
}
|
|
258
333
|
async httpCall(path, method = 'POST', body) {
|
|
334
|
+
// Guard: if not connected, do not attempt HTTP
|
|
335
|
+
if (!this.connected) {
|
|
336
|
+
throw new Error('Not connected to Unreal Engine');
|
|
337
|
+
}
|
|
259
338
|
const url = path.startsWith('/') ? path : `/${path}`;
|
|
260
339
|
const started = Date.now();
|
|
261
340
|
// Fix Content-Length header issue - ensure body is properly handled
|
|
@@ -329,14 +408,16 @@ print(f"RESULT:{{'success': {saved}, 'message': 'All dirty packages saved'}}")
|
|
|
329
408
|
const delay = Math.min(1000 * Math.pow(2, attempt), 5000); // Exponential backoff with 5s max
|
|
330
409
|
// Log timeout errors specifically
|
|
331
410
|
if (error.message?.includes('timeout')) {
|
|
332
|
-
this.log.
|
|
411
|
+
this.log.debug(`HTTP request timed out (attempt ${attempt + 1}/3): ${url}`);
|
|
333
412
|
}
|
|
334
413
|
if (attempt < 2) {
|
|
335
|
-
this.log.
|
|
414
|
+
this.log.debug(`HTTP request failed (attempt ${attempt + 1}/3), retrying in ${delay}ms...`);
|
|
336
415
|
await new Promise(resolve => setTimeout(resolve, delay));
|
|
337
416
|
// If connection error, try to reconnect
|
|
338
417
|
if (error.code === 'ECONNREFUSED' || error.code === 'ETIMEDOUT' || error.message?.includes('timeout')) {
|
|
339
|
-
this.
|
|
418
|
+
if (this.autoReconnectEnabled) {
|
|
419
|
+
this.scheduleReconnect();
|
|
420
|
+
}
|
|
340
421
|
}
|
|
341
422
|
}
|
|
342
423
|
}
|
|
@@ -345,6 +426,8 @@ print(f"RESULT:{{'success': {saved}, 'message': 'All dirty packages saved'}}")
|
|
|
345
426
|
}
|
|
346
427
|
// Generic function call via Remote Control HTTP API
|
|
347
428
|
async call(body) {
|
|
429
|
+
if (!this.connected)
|
|
430
|
+
throw new Error('Not connected to Unreal Engine');
|
|
348
431
|
// Using HTTP endpoint /remote/object/call
|
|
349
432
|
const result = await this.httpCall('/remote/object/call', 'PUT', {
|
|
350
433
|
generateTransaction: false,
|
|
@@ -353,10 +436,15 @@ print(f"RESULT:{{'success': {saved}, 'message': 'All dirty packages saved'}}")
|
|
|
353
436
|
return result;
|
|
354
437
|
}
|
|
355
438
|
async getExposed() {
|
|
439
|
+
if (!this.connected)
|
|
440
|
+
throw new Error('Not connected to Unreal Engine');
|
|
356
441
|
return this.httpCall('/remote/preset', 'GET');
|
|
357
442
|
}
|
|
358
443
|
// Execute a console command safely with validation and throttling
|
|
359
444
|
async executeConsoleCommand(command) {
|
|
445
|
+
if (!this.connected) {
|
|
446
|
+
throw new Error('Not connected to Unreal Engine');
|
|
447
|
+
}
|
|
360
448
|
// Validate command is not empty
|
|
361
449
|
if (!command || typeof command !== 'string') {
|
|
362
450
|
throw new Error('Invalid command: must be a non-empty string');
|
|
@@ -420,6 +508,9 @@ print(f"RESULT:{{'success': {saved}, 'message': 'All dirty packages saved'}}")
|
|
|
420
508
|
}
|
|
421
509
|
// Try to execute a Python command via the PythonScriptPlugin, fallback to `py` console command.
|
|
422
510
|
async executePython(command) {
|
|
511
|
+
if (!this.connected) {
|
|
512
|
+
throw new Error('Not connected to Unreal Engine');
|
|
513
|
+
}
|
|
423
514
|
const isMultiLine = /[\r\n]/.test(command) || command.includes(';');
|
|
424
515
|
try {
|
|
425
516
|
// Use ExecutePythonCommandEx with appropriate mode based on content
|
|
@@ -511,8 +602,16 @@ print(f"RESULT:{{'success': {saved}, 'message': 'All dirty packages saved'}}")
|
|
|
511
602
|
}
|
|
512
603
|
}
|
|
513
604
|
}
|
|
605
|
+
// Allow callers to enable/disable auto-reconnect behavior
|
|
606
|
+
setAutoReconnectEnabled(enabled) {
|
|
607
|
+
this.autoReconnectEnabled = enabled;
|
|
608
|
+
}
|
|
514
609
|
// Connection recovery
|
|
515
610
|
scheduleReconnect() {
|
|
611
|
+
if (!this.autoReconnectEnabled) {
|
|
612
|
+
this.log.info('Auto-reconnect disabled; not scheduling reconnection');
|
|
613
|
+
return;
|
|
614
|
+
}
|
|
516
615
|
if (this.reconnectTimer || this.connected) {
|
|
517
616
|
return;
|
|
518
617
|
}
|
|
@@ -523,7 +622,7 @@ print(f"RESULT:{{'success': {saved}, 'message': 'All dirty packages saved'}}")
|
|
|
523
622
|
// Exponential backoff with jitter
|
|
524
623
|
const delay = Math.min(this.BASE_RECONNECT_DELAY * Math.pow(2, this.reconnectAttempts) + Math.random() * 1000, 30000 // Max 30 seconds
|
|
525
624
|
);
|
|
526
|
-
this.log.
|
|
625
|
+
this.log.debug(`Scheduling reconnection attempt ${this.reconnectAttempts + 1}/${this.MAX_RECONNECT_ATTEMPTS} in ${Math.round(delay)}ms`);
|
|
527
626
|
this.reconnectTimer = setTimeout(async () => {
|
|
528
627
|
this.reconnectTimer = undefined;
|
|
529
628
|
this.reconnectAttempts++;
|
|
@@ -533,7 +632,7 @@ print(f"RESULT:{{'success': {saved}, 'message': 'All dirty packages saved'}}")
|
|
|
533
632
|
this.log.info('Successfully reconnected to Unreal Engine');
|
|
534
633
|
}
|
|
535
634
|
catch (err) {
|
|
536
|
-
this.log.
|
|
635
|
+
this.log.warn('Reconnection attempt failed:', err);
|
|
537
636
|
this.scheduleReconnect();
|
|
538
637
|
}
|
|
539
638
|
}, delay);
|
|
@@ -545,8 +644,25 @@ print(f"RESULT:{{'success': {saved}, 'message': 'All dirty packages saved'}}")
|
|
|
545
644
|
this.reconnectTimer = undefined;
|
|
546
645
|
}
|
|
547
646
|
if (this.ws) {
|
|
548
|
-
|
|
549
|
-
|
|
647
|
+
try {
|
|
648
|
+
// Avoid unhandled error during shutdown
|
|
649
|
+
this.ws.on('error', () => { });
|
|
650
|
+
try {
|
|
651
|
+
this.ws.close();
|
|
652
|
+
}
|
|
653
|
+
catch { }
|
|
654
|
+
try {
|
|
655
|
+
this.ws.terminate();
|
|
656
|
+
}
|
|
657
|
+
catch { }
|
|
658
|
+
}
|
|
659
|
+
finally {
|
|
660
|
+
try {
|
|
661
|
+
this.ws.removeAllListeners();
|
|
662
|
+
}
|
|
663
|
+
catch { }
|
|
664
|
+
this.ws = undefined;
|
|
665
|
+
}
|
|
550
666
|
}
|
|
551
667
|
this.connected = false;
|
|
552
668
|
}
|
|
@@ -581,6 +697,7 @@ print(f"RESULT:{{'success': {saved}, 'message': 'All dirty packages saved'}}")
|
|
|
581
697
|
/**
|
|
582
698
|
* Execute Python script and parse the result
|
|
583
699
|
*/
|
|
700
|
+
// Expose for internal consumers (resources) that want parsed RESULT blocks
|
|
584
701
|
async executePythonWithResult(script) {
|
|
585
702
|
try {
|
|
586
703
|
// Wrap script to capture output so we can parse RESULT: lines reliably
|
|
@@ -606,7 +723,6 @@ finally:
|
|
|
606
723
|
out = response;
|
|
607
724
|
}
|
|
608
725
|
else if (response && typeof response === 'object') {
|
|
609
|
-
// Common RC Python response contains LogOutput array entries with .Output strings
|
|
610
726
|
if (Array.isArray(response.LogOutput)) {
|
|
611
727
|
out = response.LogOutput.map((l) => l.Output || '').join('');
|
|
612
728
|
}
|
|
@@ -617,7 +733,6 @@ finally:
|
|
|
617
733
|
out = response.result;
|
|
618
734
|
}
|
|
619
735
|
else {
|
|
620
|
-
// Fallback to stringifying object (may still include RESULT in nested fields)
|
|
621
736
|
out = JSON.stringify(response);
|
|
622
737
|
}
|
|
623
738
|
}
|
|
@@ -625,17 +740,58 @@ finally:
|
|
|
625
740
|
catch {
|
|
626
741
|
out = String(response || '');
|
|
627
742
|
}
|
|
628
|
-
//
|
|
629
|
-
const
|
|
743
|
+
// Robust RESULT parsing with bracket matching (handles nested objects)
|
|
744
|
+
const marker = 'RESULT:';
|
|
745
|
+
const idx = out.lastIndexOf(marker);
|
|
746
|
+
if (idx !== -1) {
|
|
747
|
+
// Find first '{' after the marker
|
|
748
|
+
let i = idx + marker.length;
|
|
749
|
+
while (i < out.length && out[i] !== '{')
|
|
750
|
+
i++;
|
|
751
|
+
if (i < out.length && out[i] === '{') {
|
|
752
|
+
let depth = 0;
|
|
753
|
+
let inStr = false;
|
|
754
|
+
let esc = false;
|
|
755
|
+
let j = i;
|
|
756
|
+
for (; j < out.length; j++) {
|
|
757
|
+
const ch = out[j];
|
|
758
|
+
if (esc) {
|
|
759
|
+
esc = false;
|
|
760
|
+
continue;
|
|
761
|
+
}
|
|
762
|
+
if (ch === '\\') {
|
|
763
|
+
esc = true;
|
|
764
|
+
continue;
|
|
765
|
+
}
|
|
766
|
+
if (ch === '"') {
|
|
767
|
+
inStr = !inStr;
|
|
768
|
+
continue;
|
|
769
|
+
}
|
|
770
|
+
if (!inStr) {
|
|
771
|
+
if (ch === '{')
|
|
772
|
+
depth++;
|
|
773
|
+
else if (ch === '}') {
|
|
774
|
+
depth--;
|
|
775
|
+
if (depth === 0) {
|
|
776
|
+
j++;
|
|
777
|
+
break;
|
|
778
|
+
}
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
}
|
|
782
|
+
const jsonStr = out.slice(i, j);
|
|
783
|
+
try {
|
|
784
|
+
return JSON.parse(jsonStr);
|
|
785
|
+
}
|
|
786
|
+
catch { }
|
|
787
|
+
}
|
|
788
|
+
}
|
|
789
|
+
// Fallback to previous regex approach (best-effort)
|
|
790
|
+
const matches = Array.from(out.matchAll(/RESULT:({[\s\S]*})/g));
|
|
630
791
|
if (matches.length > 0) {
|
|
631
792
|
const last = matches[matches.length - 1][1];
|
|
632
793
|
try {
|
|
633
|
-
|
|
634
|
-
const normalized = last
|
|
635
|
-
.replace(/'/g, '"')
|
|
636
|
-
.replace(/\bTrue\b/g, 'true')
|
|
637
|
-
.replace(/\bFalse\b/g, 'false');
|
|
638
|
-
return JSON.parse(normalized);
|
|
794
|
+
return JSON.parse(last);
|
|
639
795
|
}
|
|
640
796
|
catch {
|
|
641
797
|
return { raw: last };
|
|
@@ -835,10 +991,12 @@ print('RESULT:' + json.dumps(flags))
|
|
|
835
991
|
}
|
|
836
992
|
catch (error) {
|
|
837
993
|
// Retry logic for transient failures
|
|
994
|
+
const msg = (error?.message || String(error)).toLowerCase();
|
|
995
|
+
const notConnected = msg.includes('not connected to unreal');
|
|
838
996
|
if (item.retryCount === undefined) {
|
|
839
997
|
item.retryCount = 0;
|
|
840
998
|
}
|
|
841
|
-
if (item.retryCount < 3) {
|
|
999
|
+
if (!notConnected && item.retryCount < 3) {
|
|
842
1000
|
item.retryCount++;
|
|
843
1001
|
this.log.warn(`Command failed, retrying (${item.retryCount}/3)`);
|
|
844
1002
|
// Re-add to queue with increased priority
|
package/dist/utils/http.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import axios from 'axios';
|
|
2
2
|
import http from 'http';
|
|
3
3
|
import https from 'https';
|
|
4
|
+
import { Logger } from './logger.js';
|
|
4
5
|
// Connection pooling configuration for better performance
|
|
5
6
|
const httpAgent = new http.Agent({
|
|
6
7
|
keepAlive: true,
|
|
@@ -16,6 +17,7 @@ const httpsAgent = new https.Agent({
|
|
|
16
17
|
maxFreeSockets: 5,
|
|
17
18
|
timeout: 30000
|
|
18
19
|
});
|
|
20
|
+
const log = new Logger('HTTP');
|
|
19
21
|
const defaultRetryConfig = {
|
|
20
22
|
maxRetries: 3,
|
|
21
23
|
initialDelay: 1000,
|
|
@@ -75,7 +77,7 @@ export function createHttpClient(baseURL) {
|
|
|
75
77
|
client.interceptors.response.use((response) => {
|
|
76
78
|
const duration = Date.now() - (response.config.metadata?.startTime || 0);
|
|
77
79
|
if (duration > 5000) {
|
|
78
|
-
|
|
80
|
+
log.warn(`[HTTP] Slow request: ${response.config.url} took ${duration}ms`);
|
|
79
81
|
}
|
|
80
82
|
return response;
|
|
81
83
|
}, (error) => {
|
|
@@ -109,7 +111,7 @@ export async function requestWithRetry(client, config, retryConfig = {}) {
|
|
|
109
111
|
}
|
|
110
112
|
// Calculate delay and wait
|
|
111
113
|
const delay = calculateBackoff(attempt, retry);
|
|
112
|
-
|
|
114
|
+
log.debug(`[HTTP] Retry attempt ${attempt}/${retry.maxRetries} after ${Math.round(delay)}ms`);
|
|
113
115
|
await new Promise(resolve => setTimeout(resolve, delay));
|
|
114
116
|
}
|
|
115
117
|
}
|
|
@@ -19,7 +19,12 @@ export declare class ResponseValidator {
|
|
|
19
19
|
structuredContent?: any;
|
|
20
20
|
};
|
|
21
21
|
/**
|
|
22
|
-
* Wrap a tool response with validation
|
|
22
|
+
* Wrap a tool response with validation and MCP-compliant content shape.
|
|
23
|
+
*
|
|
24
|
+
* MCP tools/call responses must contain a `content` array. Many internal
|
|
25
|
+
* handlers return structured JSON objects (e.g., { success, message, ... }).
|
|
26
|
+
* This wrapper serializes such objects into a single text block while keeping
|
|
27
|
+
* existing `content` responses intact.
|
|
23
28
|
*/
|
|
24
29
|
wrapResponse(toolName: string, response: any): any;
|
|
25
30
|
/**
|