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.
- package/.env.production +1 -1
- package/.github/workflows/publish-mcp.yml +2 -1
- package/.github/workflows/smithery-build.yml +29 -0
- package/CHANGELOG.md +26 -0
- package/README.md +13 -2
- package/claude_desktop_config_example.json +2 -1
- package/dist/index.d.ts +46 -1
- package/dist/index.js +40 -14
- package/dist/tools/consolidated-tool-definitions.d.ts +43 -0
- package/dist/tools/consolidated-tool-definitions.js +126 -116
- package/dist/tools/landscape.js +77 -15
- package/dist/unreal-bridge.d.ts +8 -3
- package/dist/unreal-bridge.js +25 -35
- package/dist/utils/error-handler.d.ts +40 -0
- package/dist/utils/error-handler.js +75 -0
- package/dist/utils/http.js +80 -3
- package/dist/utils/response-validator.js +6 -1
- package/docs/unreal-tool-test-cases.md +572 -0
- package/eslint.config.mjs +68 -0
- package/package.json +18 -9
- package/server.json +8 -2
- package/smithery.yaml +29 -0
- package/src/index.ts +37 -14
- package/src/tools/consolidated-tool-definitions.ts +126 -116
- package/src/tools/landscape.ts +77 -15
- package/src/unreal-bridge.ts +28 -31
- package/src/utils/error-handler.ts +113 -1
- package/src/utils/http.ts +102 -3
- package/src/utils/response-validator.ts +7 -2
- package/tsconfig.json +36 -13
package/src/unreal-bridge.ts
CHANGED
|
@@ -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
|
|
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
|
|
330
|
-
* @returns Promise that resolves
|
|
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
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
4
|
-
|
|
5
|
-
|
|
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
|
-
"
|
|
31
|
+
"resolveJsonModule": true,
|
|
32
|
+
|
|
33
|
+
// Emit
|
|
12
34
|
"declaration": true,
|
|
13
35
|
"declarationMap": true,
|
|
14
36
|
"sourceMap": true,
|
|
15
|
-
"
|
|
16
|
-
"
|
|
17
|
-
"
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
"
|
|
21
|
-
|
|
22
|
-
|
|
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": [
|