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.
@@ -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
- for (let attempt = 1; attempt <= maxAttempts; attempt++) {
157
+ if (this.connected)
158
+ return true;
159
+ if (this.connectPromise) {
156
160
  try {
157
- this.log.info(`Connection attempt ${attempt}/${maxAttempts}`);
158
- await this.connect(timeoutMs);
159
- return true; // Successfully connected
161
+ await this.connectPromise;
160
162
  }
161
- catch (err) {
162
- this.log.warn(`Connection attempt ${attempt} failed:`, err);
163
- if (attempt < maxAttempts) {
164
- this.log.info(`Retrying in ${retryDelayMs}ms...`);
165
- await new Promise(resolve => setTimeout(resolve, retryDelayMs));
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
- else {
168
- this.log.error(`All ${maxAttempts} connection attempts failed`);
169
- return false; // All attempts failed
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
- return false;
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.info(`Connecting to UE Remote Control: ${wsUrl}`);
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
- this.ws.removeAllListeners();
189
- // Only close if the websocket is in CONNECTING state
190
- if (this.ws.readyState === WebSocket.CONNECTING) {
191
- try {
192
- this.ws.terminate(); // Use terminate instead of close for immediate cleanup
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
- catch (_e) {
195
- // Ignore close errors
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
- reject(new Error('Connection timeout: Unreal Engine may not be running or Remote Control is not enabled'));
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
- resolve();
268
+ safeResolve();
209
269
  };
210
270
  // Error handler
211
271
  const onError = (err) => {
212
272
  clearTimeout(timeout);
213
- this.log.error('WebSocket error', err);
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
- this.ws.terminate();
280
+ try {
281
+ this.ws.close();
282
+ }
283
+ catch { }
284
+ try {
285
+ this.ws.terminate();
286
+ }
287
+ catch { }
219
288
  }
220
289
  }
221
- catch (_e) {
222
- // Ignore close errors
290
+ finally {
291
+ try {
292
+ this.ws.removeAllListeners();
293
+ }
294
+ catch { }
295
+ this.ws = undefined;
223
296
  }
224
- this.ws = undefined;
225
297
  }
226
- reject(new Error(`Failed to connect: ${err.message}`));
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
- reject(new Error('Connection closed before establishing'));
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.scheduleReconnect();
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 (e) {
248
- this.log.error('Failed parsing WS message', e);
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.warn(`HTTP request timed out (attempt ${attempt + 1}/3): ${url}`);
411
+ this.log.debug(`HTTP request timed out (attempt ${attempt + 1}/3): ${url}`);
333
412
  }
334
413
  if (attempt < 2) {
335
- this.log.warn(`HTTP request failed (attempt ${attempt + 1}/3), retrying in ${delay}ms...`);
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.scheduleReconnect();
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.info(`Scheduling reconnection attempt ${this.reconnectAttempts + 1}/${this.MAX_RECONNECT_ATTEMPTS} in ${Math.round(delay)}ms`);
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.error('Reconnection attempt failed:', err);
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
- this.ws.close();
549
- this.ws = undefined;
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
- // Find the last RESULT: JSON block in the output for robustness
629
- const matches = Array.from(out.matchAll(/RESULT:({[\s\S]*?})/g));
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
- // Accept single quotes and True/False from Python repr if present
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
@@ -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
- console.warn(`[HTTP] Slow request: ${response.config.url} took ${duration}ms`);
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
- console.error(`[HTTP] Retry attempt ${attempt}/${retry.maxRetries} after ${Math.round(delay)}ms`);
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
  }
@@ -27,7 +27,8 @@ export class ResponseValidator {
27
27
  try {
28
28
  const validator = this.ajv.compile(outputSchema);
29
29
  this.validators.set(toolName, validator);
30
- log.info(`Registered output schema for tool: ${toolName}`);
30
+ // Demote per-tool schema registration to debug to reduce log noise
31
+ log.debug(`Registered output schema for tool: ${toolName}`);
31
32
  }
32
33
  catch (_error) {
33
34
  log.error(`Failed to compile output schema for ${toolName}:`, _error);
package/package.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "unreal-engine-mcp-server",
3
- "version": "0.3.0",
3
+ "version": "0.3.1",
4
4
  "description": "Production-ready MCP server for Unreal Engine integration with consolidated and individual tool modes",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
7
7
  "types": "dist/index.d.ts",
8
8
  "bin": {
9
- "unreal-mcp-server": "dist/cli.js"
9
+ "unreal-engine-mcp-server": "dist/cli.js"
10
10
  },
11
11
  "scripts": {
12
12
  "build": "tsc -p tsconfig.json",
package/server.json CHANGED
@@ -2,13 +2,13 @@
2
2
  "$schema": "https://static.modelcontextprotocol.io/schemas/2025-07-09/server.schema.json",
3
3
  "name": "io.github.ChiR24/unreal-engine-mcp",
4
4
  "description": "Production-ready MCP server for Unreal Engine with comprehensive game development tools",
5
- "version": "0.3.0",
5
+ "version": "0.3.1",
6
6
  "packages": [
7
7
  {
8
8
  "registry_type": "npm",
9
9
  "registry_base_url": "https://registry.npmjs.org",
10
10
  "identifier": "unreal-engine-mcp-server",
11
- "version": "0.3.0",
11
+ "version": "0.3.1",
12
12
  "transport": {
13
13
  "type": "stdio"
14
14
  },