unreal-engine-mcp-server 0.4.3 → 0.4.5

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.
@@ -2,6 +2,7 @@ import WebSocket from 'ws';
2
2
  import { createHttpClient } from './utils/http.js';
3
3
  import { Logger } from './utils/logger.js';
4
4
  import { loadEnv } from './types/env.js';
5
+ import { ErrorHandler } from './utils/error-handler.js';
5
6
 
6
7
  // RcMessage interface reserved for future WebSocket message handling
7
8
  // interface RcMessage {
@@ -44,6 +45,12 @@ export class UnrealBridge {
44
45
  private engineVersionCache?: { value: { version: string; major: number; minor: number; patch: number; isUE56OrAbove: boolean }; timestamp: number };
45
46
  private readonly ENGINE_VERSION_TTL_MS = 5 * 60 * 1000;
46
47
 
48
+ // WebSocket health monitoring (best practice from WebSocket optimization guides)
49
+ private lastPongReceived = 0;
50
+ private pingInterval?: NodeJS.Timeout;
51
+ private readonly PING_INTERVAL_MS = 30000; // 30 seconds
52
+ private readonly PONG_TIMEOUT_MS = 10000; // 10 seconds
53
+
47
54
  // Command queue for throttling
48
55
  private commandQueue: CommandQueueItem[] = [];
49
56
  private isProcessing = false;
@@ -323,11 +330,12 @@ except Exception as e:
323
330
  get isConnected() { return this.connected; }
324
331
 
325
332
  /**
326
- * Attempt to connect with retries
333
+ * Attempt to connect with exponential backoff retry strategy
334
+ * Uses optimized retry pattern from TypeScript best practices
327
335
  * @param maxAttempts Maximum number of connection attempts
328
336
  * @param timeoutMs Timeout for each connection attempt in milliseconds
329
- * @param retryDelayMs Delay between retry attempts in milliseconds
330
- * @returns Promise that resolves when connected or rejects after all attempts fail
337
+ * @param retryDelayMs Initial delay between retry attempts in milliseconds
338
+ * @returns Promise that resolves to true if connected, false otherwise
331
339
  */
332
340
  private connectPromise?: Promise<void>;
333
341
 
@@ -343,36 +351,25 @@ except Exception as e:
343
351
  return this.connected;
344
352
  }
345
353
 
346
- this.connectPromise = (async () => {
347
- for (let attempt = 1; attempt <= maxAttempts; attempt++) {
348
- // Early exit if another concurrent attempt already connected
349
- if (this.connected) {
350
- this.log.debug('Already connected; skipping remaining retry attempts');
351
- return;
352
- }
353
- try {
354
- this.log.debug(`Connection attempt ${attempt}/${maxAttempts}`);
355
- await this.connect(timeoutMs);
356
- return; // Successfully connected
357
- } catch (err: any) {
358
- const msg = (err?.message || String(err));
359
- this.log.debug(`Connection attempt ${attempt} failed: ${msg}`);
360
- if (attempt < maxAttempts) {
361
- this.log.debug(`Retrying in ${retryDelayMs}ms...`);
362
- // Sleep, but allow early break if we became connected during the wait
363
- const start = Date.now();
364
- while (Date.now() - start < retryDelayMs) {
365
- if (this.connected) return; // someone else connected
366
- await new Promise(r => setTimeout(r, 50));
367
- }
368
- } else {
369
- // Keep this at warn (not error) and avoid stack spam
370
- this.log.warn(`All ${maxAttempts} connection attempts failed`);
371
- return; // exit, connected remains false
372
- }
354
+ // Use ErrorHandler's retryWithBackoff for consistent retry behavior
355
+ this.connectPromise = ErrorHandler.retryWithBackoff(
356
+ () => this.connect(timeoutMs),
357
+ {
358
+ maxRetries: maxAttempts - 1,
359
+ initialDelay: retryDelayMs,
360
+ maxDelay: 10000,
361
+ backoffMultiplier: 1.5,
362
+ shouldRetry: (error) => {
363
+ // Only retry on connection-related errors
364
+ const msg = (error as Error)?.message?.toLowerCase() || '';
365
+ return msg.includes('timeout') || msg.includes('connection') || msg.includes('econnrefused');
373
366
  }
374
367
  }
375
- })();
368
+ ).then(() => {
369
+ // Success
370
+ }).catch((err) => {
371
+ this.log.warn(`Connection failed after ${maxAttempts} attempts:`, err.message);
372
+ });
376
373
 
377
374
  try {
378
375
  await this.connectPromise;
@@ -162,4 +162,116 @@ export class ErrorHandler {
162
162
  } catch {}
163
163
  return false;
164
164
  }
165
- }
165
+
166
+ /**
167
+ * Retry an async operation with exponential backoff
168
+ * Best practice from TypeScript async programming patterns
169
+ * @param operation - Async operation to retry
170
+ * @param options - Retry configuration
171
+ * @returns Result of the operation
172
+ */
173
+ static async retryWithBackoff<T>(
174
+ operation: () => Promise<T>,
175
+ options: {
176
+ maxRetries?: number;
177
+ initialDelay?: number;
178
+ maxDelay?: number;
179
+ backoffMultiplier?: number;
180
+ shouldRetry?: (error: unknown) => boolean;
181
+ } = {}
182
+ ): Promise<T> {
183
+ const {
184
+ maxRetries = 3,
185
+ initialDelay = 100,
186
+ maxDelay = 10000,
187
+ backoffMultiplier = 2,
188
+ shouldRetry = (error) => this.isRetriable(error)
189
+ } = options;
190
+
191
+ let lastError: unknown;
192
+ let delay = initialDelay;
193
+
194
+ for (let attempt = 0; attempt <= maxRetries; attempt++) {
195
+ try {
196
+ return await operation();
197
+ } catch (error) {
198
+ lastError = error;
199
+
200
+ if (attempt === maxRetries || !shouldRetry(error)) {
201
+ throw error;
202
+ }
203
+
204
+ log.debug(`Retry attempt ${attempt + 1}/${maxRetries} after ${delay}ms`);
205
+ await new Promise(resolve => setTimeout(resolve, delay));
206
+
207
+ delay = Math.min(delay * backoffMultiplier, maxDelay);
208
+ }
209
+ }
210
+
211
+ throw lastError;
212
+ }
213
+
214
+ /**
215
+ * Add timeout to any promise
216
+ * @param promise - Promise to add timeout to
217
+ * @param timeoutMs - Timeout in milliseconds
218
+ * @param errorMessage - Custom error message for timeout
219
+ * @returns Promise that rejects on timeout
220
+ */
221
+ static async withTimeout<T>(
222
+ promise: Promise<T>,
223
+ timeoutMs: number,
224
+ errorMessage = 'Operation timed out'
225
+ ): Promise<T> {
226
+ let timeoutHandle: NodeJS.Timeout | undefined;
227
+
228
+ const timeoutPromise = new Promise<never>((_, reject) => {
229
+ timeoutHandle = setTimeout(() => {
230
+ reject(new Error(errorMessage));
231
+ }, timeoutMs);
232
+ });
233
+
234
+ try {
235
+ return await Promise.race([promise, timeoutPromise]);
236
+ } finally {
237
+ if (timeoutHandle !== undefined) {
238
+ clearTimeout(timeoutHandle);
239
+ }
240
+ }
241
+ }
242
+
243
+ /**
244
+ * Execute multiple operations with Promise.allSettled for better error handling
245
+ * Returns detailed results for each operation, including failures
246
+ * @param operations - Array of async operations to execute
247
+ * @returns Object with successful and failed operations separated
248
+ */
249
+ static async batchExecute<T>(
250
+ operations: Array<() => Promise<T>>
251
+ ): Promise<{
252
+ successful: Array<{ index: number; value: T }>;
253
+ failed: Array<{ index: number; reason: unknown }>;
254
+ successCount: number;
255
+ failureCount: number;
256
+ }> {
257
+ const results = await Promise.allSettled(operations.map(op => op()));
258
+
259
+ const successful: Array<{ index: number; value: T }> = [];
260
+ const failed: Array<{ index: number; reason: unknown }> = [];
261
+
262
+ results.forEach((result, index) => {
263
+ if (result.status === 'fulfilled') {
264
+ successful.push({ index, value: result.value });
265
+ } else {
266
+ failed.push({ index, reason: result.reason });
267
+ }
268
+ });
269
+
270
+ return {
271
+ successful,
272
+ failed,
273
+ successCount: successful.length,
274
+ failureCount: failed.length
275
+ };
276
+ }
277
+ }
package/src/utils/http.ts CHANGED
@@ -3,6 +3,60 @@ import http from 'http';
3
3
  import https from 'https';
4
4
  import { Logger } from './logger.js';
5
5
 
6
+ /**
7
+ * Simple in-memory cache for HTTP responses
8
+ * Based on best practices from Axios optimization guides
9
+ */
10
+ interface CacheEntry {
11
+ data: any;
12
+ timestamp: number;
13
+ ttl: number;
14
+ }
15
+
16
+ class SimpleCache {
17
+ private cache = new Map<string, CacheEntry>();
18
+ private readonly maxSize = 100;
19
+
20
+ set(key: string, data: any, ttl: number = 60000): void {
21
+ // Prevent unbounded growth
22
+ if (this.cache.size >= this.maxSize) {
23
+ const firstKey = this.cache.keys().next().value;
24
+ if (firstKey !== undefined) {
25
+ this.cache.delete(firstKey);
26
+ }
27
+ }
28
+
29
+ this.cache.set(key, {
30
+ data,
31
+ timestamp: Date.now(),
32
+ ttl
33
+ });
34
+ }
35
+
36
+ get(key: string): any | null {
37
+ const entry = this.cache.get(key);
38
+ if (!entry) return null;
39
+
40
+ // Check if expired
41
+ if (Date.now() - entry.timestamp > entry.ttl) {
42
+ this.cache.delete(key);
43
+ return null;
44
+ }
45
+
46
+ return entry.data;
47
+ }
48
+
49
+ clear(): void {
50
+ this.cache.clear();
51
+ }
52
+
53
+ getStats(): { size: number; maxSize: number } {
54
+ return { size: this.cache.size, maxSize: this.maxSize };
55
+ }
56
+ }
57
+
58
+ const responseCache = new SimpleCache();
59
+
6
60
  // Enhanced connection pooling configuration to prevent socket failures
7
61
  const httpAgent = new http.Agent({
8
62
  keepAlive: true,
@@ -55,28 +109,73 @@ export function createHttpClient(baseURL: string): AxiosInstance {
55
109
  decompress: true
56
110
  });
57
111
 
58
- // Add request interceptor for timing
112
+ // Request interceptor: timing, caching check, and logging
59
113
  client.interceptors.request.use(
60
114
  (config) => {
61
115
  // Add metadata for performance tracking
62
116
  (config as any).metadata = { startTime: Date.now() };
117
+
118
+ // Check cache for GET requests
119
+ if (config.method?.toLowerCase() === 'get' && config.url) {
120
+ const cacheKey = `${config.url}:${JSON.stringify(config.params || {})}`;
121
+ const cached = responseCache.get(cacheKey);
122
+ if (cached) {
123
+ log.debug(`[HTTP Cache Hit] ${config.url}`);
124
+ // Return cached response
125
+ (config as any).cached = cached;
126
+ }
127
+ }
128
+
63
129
  return config;
64
130
  },
65
131
  (error) => {
132
+ log.error('[HTTP Request Error]', error);
66
133
  return Promise.reject(error);
67
134
  }
68
135
  );
69
136
 
70
- // Add response interceptor for timing and logging
137
+ // Response interceptor: timing, caching, and error handling
71
138
  client.interceptors.response.use(
72
139
  (response) => {
140
+ // Check if we used cached response
141
+ if ((response.config as any).cached) {
142
+ return Promise.resolve({
143
+ ...response,
144
+ data: (response.config as any).cached,
145
+ status: 200,
146
+ statusText: 'OK (Cached)',
147
+ headers: {},
148
+ config: response.config
149
+ });
150
+ }
151
+
152
+ // Performance tracking
73
153
  const duration = Date.now() - ((response.config as any).metadata?.startTime || 0);
74
154
  if (duration > 5000) {
75
- log.warn(`[HTTP] Slow request: ${response.config.url} took ${duration}ms`);
155
+ log.warn(`[HTTP Slow] ${response.config.url} took ${duration}ms`);
156
+ } else if (duration > 1000) {
157
+ log.debug(`[HTTP] ${response.config.url} took ${duration}ms`);
76
158
  }
159
+
160
+ // Cache successful GET responses
161
+ if (response.config.method?.toLowerCase() === 'get' &&
162
+ response.status === 200 &&
163
+ response.config.url) {
164
+ const cacheKey = `${response.config.url}:${JSON.stringify(response.config.params || {})}`;
165
+ // Cache for 30 seconds by default
166
+ responseCache.set(cacheKey, response.data, 30000);
167
+ }
168
+
77
169
  return response;
78
170
  },
79
171
  (error) => {
172
+ // Enhanced error logging
173
+ const duration = Date.now() - ((error.config as any)?.metadata?.startTime || 0);
174
+ log.error(`[HTTP Error] ${error.config?.url} failed after ${duration}ms:`, {
175
+ status: error.response?.status,
176
+ message: error.message,
177
+ code: error.code
178
+ });
80
179
  return Promise.reject(error);
81
180
  }
82
181
  );
@@ -9,11 +9,16 @@ const log = new Logger('ResponseValidator');
9
9
  * Validates tool responses against their defined output schemas
10
10
  */
11
11
  export class ResponseValidator {
12
- private ajv: Ajv;
12
+ // Keep ajv as any to avoid complex interop typing issues with Ajv's ESM/CJS dual export
13
+ // shape when using NodeNext module resolution.
14
+ private ajv: any;
13
15
  private validators: Map<string, any> = new Map();
14
16
 
15
17
  constructor() {
16
- this.ajv = new Ajv({
18
+ // Cast Ajv to any for construction to avoid errors when TypeScript's NodeNext
19
+ // module resolution represents the import as a namespace object.
20
+ const AjvCtor: any = (Ajv as any)?.default ?? Ajv;
21
+ this.ajv = new AjvCtor({
17
22
  allErrors: true,
18
23
  verbose: true,
19
24
  strict: false // Allow additional properties for flexibility
package/tsconfig.json CHANGED
@@ -1,26 +1,49 @@
1
1
  {
2
2
  "compilerOptions": {
3
- "target": "ES2022",
4
- "module": "ES2022",
5
- "moduleResolution": "Bundler",
3
+ "target": "ES2022",
4
+ "module": "NodeNext",
5
+ "moduleResolution": "NodeNext",
6
6
  "outDir": "dist",
7
7
  "rootDir": "src",
8
+
9
+ // Strict Type Checking
8
10
  "strict": true,
11
+ "noImplicitAny": true,
12
+ "strictNullChecks": true,
13
+ "strictFunctionTypes": true,
14
+ "strictBindCallApply": true,
15
+ "strictPropertyInitialization": true,
16
+ "noImplicitThis": true,
17
+ "alwaysStrict": true,
18
+
19
+ // Additional Checks
20
+ "noUnusedLocals": false,
21
+ "noUnusedParameters": false,
22
+ "noImplicitReturns": true,
23
+ "noFallthroughCasesInSwitch": true,
24
+ "allowUnreachableCode": false,
25
+ "noUncheckedIndexedAccess": false,
26
+
27
+ // Module & Interop
9
28
  "esModuleInterop": true,
29
+ "allowSyntheticDefaultImports": true,
10
30
  "forceConsistentCasingInFileNames": true,
11
- "skipLibCheck": true,
31
+ "resolveJsonModule": true,
32
+
33
+ // Emit
12
34
  "declaration": true,
13
35
  "declarationMap": true,
14
36
  "sourceMap": true,
15
- "resolveJsonModule": true,
16
- "incremental": true,
17
- "tsBuildInfoFile": ".tsbuildinfo",
18
- "noUnusedLocals": false,
19
- "noUnusedParameters": false,
20
- "noImplicitReturns": true,
21
- "noFallthroughCasesInSwitch": true,
22
- "allowUnreachableCode": false,
23
- "types": ["node"]
37
+ "removeComments": false,
38
+ "importHelpers": false,
39
+ "emitDeclarationOnly": false,
40
+
41
+ // Performance
42
+ "skipLibCheck": true,
43
+ "incremental": false,
44
+
45
+ "types": ["node"],
46
+ "noEmitOnError": false
24
47
  },
25
48
  "include": ["src/**/*"],
26
49
  "exclude": [