unreal-engine-mcp-server 0.2.1 → 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.
- package/.env.production +6 -1
- package/Dockerfile +11 -28
- package/README.md +0 -17
- package/dist/index.js +108 -40
- package/dist/resources/actors.js +71 -13
- package/dist/resources/assets.d.ts +1 -1
- package/dist/resources/assets.js +3 -3
- package/dist/tools/consolidated-tool-definitions.js +278 -14
- package/dist/tools/consolidated-tool-handlers.js +5 -2
- package/dist/tools/tool-definitions.js +211 -37
- package/dist/tools/tool-handlers.js +42 -9
- 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.js +5 -4
- package/dist/utils/safe-json.js +1 -1
- package/package.json +4 -11
- package/server.json +2 -2
- package/src/index.ts +107 -42
- package/src/resources/actors.ts +51 -13
- package/src/resources/assets.ts +3 -3
- package/src/tools/consolidated-tool-definitions.ts +278 -14
- package/src/tools/consolidated-tool-handlers.ts +6 -2
- package/src/tools/tool-definitions.ts +211 -37
- package/src/tools/tool-handlers.ts +41 -9
- package/src/unreal-bridge.ts +163 -60
- package/src/utils/http.ts +7 -4
- package/src/utils/response-validator.ts +5 -4
- package/src/utils/safe-json.ts +1 -1
package/src/index.ts
CHANGED
|
@@ -43,7 +43,7 @@ import {
|
|
|
43
43
|
import { responseValidator } from './utils/response-validator.js';
|
|
44
44
|
import { ErrorHandler } from './utils/error-handler.js';
|
|
45
45
|
import { routeStdoutLogsToStderr } from './utils/stdio-redirect.js';
|
|
46
|
-
import {
|
|
46
|
+
import { cleanObject } from './utils/safe-json.js';
|
|
47
47
|
|
|
48
48
|
const log = new Logger('UE-MCP');
|
|
49
49
|
|
|
@@ -75,16 +75,20 @@ const metrics: PerformanceMetrics = {
|
|
|
75
75
|
recentErrors: []
|
|
76
76
|
};
|
|
77
77
|
|
|
78
|
+
// Health check timer and last success tracking (stop pings after inactivity)
|
|
79
|
+
let healthCheckTimer: NodeJS.Timeout | undefined;
|
|
80
|
+
let lastHealthSuccessAt = 0;
|
|
81
|
+
|
|
78
82
|
// Configuration
|
|
79
83
|
const CONFIG = {
|
|
80
|
-
// Tool mode: true = consolidated (
|
|
84
|
+
// Tool mode: true = consolidated (13 tools), false = individual (36+ tools)
|
|
81
85
|
USE_CONSOLIDATED_TOOLS: process.env.USE_CONSOLIDATED_TOOLS !== 'false',
|
|
82
86
|
// Connection retry settings
|
|
83
87
|
MAX_RETRY_ATTEMPTS: 3,
|
|
84
88
|
RETRY_DELAY_MS: 2000,
|
|
85
89
|
// Server info
|
|
86
90
|
SERVER_NAME: 'unreal-engine-mcp',
|
|
87
|
-
SERVER_VERSION: '0.
|
|
91
|
+
SERVER_VERSION: '0.3.1',
|
|
88
92
|
// Monitoring
|
|
89
93
|
HEALTH_CHECK_INTERVAL_MS: 30000 // 30 seconds
|
|
90
94
|
};
|
|
@@ -111,11 +115,16 @@ function trackPerformance(startTime: number, success: boolean) {
|
|
|
111
115
|
|
|
112
116
|
// Health check function
|
|
113
117
|
async function performHealthCheck(bridge: UnrealBridge): Promise<boolean> {
|
|
118
|
+
// If not connected, do not attempt any ping (stay quiet)
|
|
119
|
+
if (!bridge.isConnected) {
|
|
120
|
+
return false;
|
|
121
|
+
}
|
|
114
122
|
try {
|
|
115
123
|
// Use a safe echo command that doesn't affect any settings
|
|
116
124
|
await bridge.executeConsoleCommand('echo MCP Server Health Check');
|
|
117
125
|
metrics.connectionStatus = 'connected';
|
|
118
126
|
metrics.lastHealthCheck = new Date();
|
|
127
|
+
lastHealthSuccessAt = Date.now();
|
|
119
128
|
return true;
|
|
120
129
|
} catch (err1) {
|
|
121
130
|
// Fallback: minimal Python ping (if Python plugin is enabled)
|
|
@@ -123,11 +132,13 @@ async function performHealthCheck(bridge: UnrealBridge): Promise<boolean> {
|
|
|
123
132
|
await bridge.executePython("import sys; sys.stdout.write('OK')");
|
|
124
133
|
metrics.connectionStatus = 'connected';
|
|
125
134
|
metrics.lastHealthCheck = new Date();
|
|
135
|
+
lastHealthSuccessAt = Date.now();
|
|
126
136
|
return true;
|
|
127
137
|
} catch (err2) {
|
|
128
138
|
metrics.connectionStatus = 'error';
|
|
129
139
|
metrics.lastHealthCheck = new Date();
|
|
130
|
-
|
|
140
|
+
// Avoid noisy warnings when engine may be shutting down; log at debug
|
|
141
|
+
log.debug('Health check failed (console and python):', err1, err2);
|
|
131
142
|
return false;
|
|
132
143
|
}
|
|
133
144
|
}
|
|
@@ -135,50 +146,68 @@ async function performHealthCheck(bridge: UnrealBridge): Promise<boolean> {
|
|
|
135
146
|
|
|
136
147
|
export async function createServer() {
|
|
137
148
|
const bridge = new UnrealBridge();
|
|
149
|
+
// Disable auto-reconnect loops; connect only on-demand
|
|
150
|
+
bridge.setAutoReconnectEnabled(false);
|
|
138
151
|
|
|
139
152
|
// Initialize response validation with schemas
|
|
140
|
-
log.
|
|
153
|
+
log.debug('Initializing response validation...');
|
|
141
154
|
const toolDefs = CONFIG.USE_CONSOLIDATED_TOOLS ? consolidatedToolDefinitions : toolDefinitions;
|
|
142
155
|
toolDefs.forEach((tool: any) => {
|
|
143
156
|
if (tool.outputSchema) {
|
|
144
157
|
responseValidator.registerSchema(tool.name, tool.outputSchema);
|
|
145
158
|
}
|
|
146
159
|
});
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
)
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
log.warn('Could not connect to Unreal Engine after retries');
|
|
161
|
-
log.info('Server will start anyway - connection will be retried periodically');
|
|
162
|
-
log.info('Make sure Unreal Engine is running with Remote Control enabled');
|
|
163
|
-
metrics.connectionStatus = 'disconnected';
|
|
164
|
-
|
|
165
|
-
// Schedule automatic reconnection attempts
|
|
166
|
-
setInterval(async () => {
|
|
160
|
+
// Summary at debug level to avoid repeated noisy blocks in some shells
|
|
161
|
+
log.debug(`Registered ${responseValidator.getStats().totalSchemas} output schemas for validation`);
|
|
162
|
+
|
|
163
|
+
// Do NOT connect to Unreal at startup; connect on demand
|
|
164
|
+
log.debug('Server starting without connecting to Unreal Engine');
|
|
165
|
+
metrics.connectionStatus = 'disconnected';
|
|
166
|
+
|
|
167
|
+
// Health checks manager (only active when connected)
|
|
168
|
+
const startHealthChecks = () => {
|
|
169
|
+
if (healthCheckTimer) return;
|
|
170
|
+
lastHealthSuccessAt = Date.now();
|
|
171
|
+
healthCheckTimer = setInterval(async () => {
|
|
172
|
+
// Only attempt health pings while connected; stay silent otherwise
|
|
167
173
|
if (!bridge.isConnected) {
|
|
168
|
-
|
|
169
|
-
const
|
|
170
|
-
if (
|
|
171
|
-
|
|
172
|
-
|
|
174
|
+
// Optionally pause fully after 5 minutes of no success
|
|
175
|
+
const FIVE_MIN_MS = 5 * 60 * 1000;
|
|
176
|
+
if (!lastHealthSuccessAt || Date.now() - lastHealthSuccessAt > FIVE_MIN_MS) {
|
|
177
|
+
if (healthCheckTimer) {
|
|
178
|
+
clearInterval(healthCheckTimer);
|
|
179
|
+
healthCheckTimer = undefined;
|
|
180
|
+
}
|
|
181
|
+
log.info('Health checks paused after 5 minutes without a successful response');
|
|
173
182
|
}
|
|
183
|
+
return;
|
|
174
184
|
}
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
185
|
+
|
|
186
|
+
await performHealthCheck(bridge);
|
|
187
|
+
// Stop sending echoes if we haven't had a successful response in > 5 minutes
|
|
188
|
+
const FIVE_MIN_MS = 5 * 60 * 1000;
|
|
189
|
+
if (!lastHealthSuccessAt || Date.now() - lastHealthSuccessAt > FIVE_MIN_MS) {
|
|
190
|
+
if (healthCheckTimer) {
|
|
191
|
+
clearInterval(healthCheckTimer);
|
|
192
|
+
healthCheckTimer = undefined;
|
|
193
|
+
log.info('Health checks paused after 5 minutes without a successful response');
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
}, CONFIG.HEALTH_CHECK_INTERVAL_MS);
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
// On-demand connection helper
|
|
200
|
+
const ensureConnectedOnDemand = async (): Promise<boolean> => {
|
|
201
|
+
if (bridge.isConnected) return true;
|
|
202
|
+
const ok = await bridge.tryConnect(3, 5000, 1000);
|
|
203
|
+
if (ok) {
|
|
204
|
+
metrics.connectionStatus = 'connected';
|
|
205
|
+
startHealthChecks();
|
|
206
|
+
} else {
|
|
207
|
+
metrics.connectionStatus = 'disconnected';
|
|
208
|
+
}
|
|
209
|
+
return ok;
|
|
210
|
+
};
|
|
182
211
|
|
|
183
212
|
// Resources
|
|
184
213
|
const assetResources = new AssetResources(bridge);
|
|
@@ -272,6 +301,10 @@ export async function createServer() {
|
|
|
272
301
|
const uri = request.params.uri;
|
|
273
302
|
|
|
274
303
|
if (uri === 'ue://assets') {
|
|
304
|
+
const ok = await ensureConnectedOnDemand();
|
|
305
|
+
if (!ok) {
|
|
306
|
+
return { contents: [{ uri, mimeType: 'text/plain', text: 'Unreal Engine not connected (after 3 attempts).' }] };
|
|
307
|
+
}
|
|
275
308
|
const list = await assetResources.list('/Game', true);
|
|
276
309
|
return {
|
|
277
310
|
contents: [{
|
|
@@ -283,6 +316,10 @@ export async function createServer() {
|
|
|
283
316
|
}
|
|
284
317
|
|
|
285
318
|
if (uri === 'ue://actors') {
|
|
319
|
+
const ok = await ensureConnectedOnDemand();
|
|
320
|
+
if (!ok) {
|
|
321
|
+
return { contents: [{ uri, mimeType: 'text/plain', text: 'Unreal Engine not connected (after 3 attempts).' }] };
|
|
322
|
+
}
|
|
286
323
|
const list = await actorResources.listActors();
|
|
287
324
|
return {
|
|
288
325
|
contents: [{
|
|
@@ -294,6 +331,10 @@ export async function createServer() {
|
|
|
294
331
|
}
|
|
295
332
|
|
|
296
333
|
if (uri === 'ue://level') {
|
|
334
|
+
const ok = await ensureConnectedOnDemand();
|
|
335
|
+
if (!ok) {
|
|
336
|
+
return { contents: [{ uri, mimeType: 'text/plain', text: 'Unreal Engine not connected (after 3 attempts).' }] };
|
|
337
|
+
}
|
|
297
338
|
const level = await levelResources.getCurrentLevel();
|
|
298
339
|
return {
|
|
299
340
|
contents: [{
|
|
@@ -305,6 +346,10 @@ export async function createServer() {
|
|
|
305
346
|
}
|
|
306
347
|
|
|
307
348
|
if (uri === 'ue://exposed') {
|
|
349
|
+
const ok = await ensureConnectedOnDemand();
|
|
350
|
+
if (!ok) {
|
|
351
|
+
return { contents: [{ uri, mimeType: 'text/plain', text: 'Unreal Engine not connected (after 3 attempts).' }] };
|
|
352
|
+
}
|
|
308
353
|
try {
|
|
309
354
|
const exposed = await bridge.getExposed();
|
|
310
355
|
return {
|
|
@@ -327,11 +372,13 @@ export async function createServer() {
|
|
|
327
372
|
|
|
328
373
|
if (uri === 'ue://health') {
|
|
329
374
|
const uptimeMs = Date.now() - metrics.uptime;
|
|
330
|
-
// Query engine version and feature flags
|
|
375
|
+
// Query engine version and feature flags only when connected
|
|
331
376
|
let versionInfo: any = {};
|
|
332
377
|
let featureFlags: any = {};
|
|
333
|
-
|
|
334
|
-
|
|
378
|
+
if (bridge.isConnected) {
|
|
379
|
+
try { versionInfo = await bridge.getEngineVersion(); } catch {}
|
|
380
|
+
try { featureFlags = await bridge.getFeatureFlags(); } catch {}
|
|
381
|
+
}
|
|
335
382
|
const health = {
|
|
336
383
|
status: metrics.connectionStatus,
|
|
337
384
|
uptime: Math.floor(uptimeMs / 1000),
|
|
@@ -369,6 +416,10 @@ export async function createServer() {
|
|
|
369
416
|
}
|
|
370
417
|
|
|
371
418
|
if (uri === 'ue://version') {
|
|
419
|
+
const ok = await ensureConnectedOnDemand();
|
|
420
|
+
if (!ok) {
|
|
421
|
+
return { contents: [{ uri, mimeType: 'text/plain', text: 'Unreal Engine not connected (after 3 attempts).' }] };
|
|
422
|
+
}
|
|
372
423
|
const info = await bridge.getEngineVersion();
|
|
373
424
|
return {
|
|
374
425
|
contents: [{
|
|
@@ -382,7 +433,7 @@ export async function createServer() {
|
|
|
382
433
|
throw new Error(`Unknown resource: ${uri}`);
|
|
383
434
|
});
|
|
384
435
|
|
|
385
|
-
// Handle tool listing - switch between consolidated (
|
|
436
|
+
// Handle tool listing - switch between consolidated (13) or individual (36) tools
|
|
386
437
|
server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
387
438
|
log.info(`Serving ${CONFIG.USE_CONSOLIDATED_TOOLS ? 'consolidated' : 'individual'} tools`);
|
|
388
439
|
return {
|
|
@@ -390,10 +441,24 @@ export async function createServer() {
|
|
|
390
441
|
};
|
|
391
442
|
});
|
|
392
443
|
|
|
393
|
-
// Handle tool calls - switch between consolidated (
|
|
444
|
+
// Handle tool calls - switch between consolidated (13) or individual (36) tools
|
|
394
445
|
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
395
446
|
const { name, arguments: args } = request.params;
|
|
396
447
|
const startTime = Date.now();
|
|
448
|
+
|
|
449
|
+
// Ensure connection only when needed, with 3 attempts
|
|
450
|
+
const connected = await ensureConnectedOnDemand();
|
|
451
|
+
if (!connected) {
|
|
452
|
+
const notConnected = {
|
|
453
|
+
content: [{ type: 'text', text: 'Unreal Engine is not connected (after 3 attempts). Please open UE and try again.' }],
|
|
454
|
+
success: false,
|
|
455
|
+
error: 'UE_NOT_CONNECTED',
|
|
456
|
+
retriable: false,
|
|
457
|
+
scope: `tool-call/${name}`
|
|
458
|
+
} as any;
|
|
459
|
+
trackPerformance(startTime, false);
|
|
460
|
+
return notConnected;
|
|
461
|
+
}
|
|
397
462
|
|
|
398
463
|
// Create tools object for handler
|
|
399
464
|
const tools = {
|
package/src/resources/actors.ts
CHANGED
|
@@ -34,25 +34,63 @@ export class ActorResources {
|
|
|
34
34
|
// Use Python to get actors via EditorActorSubsystem
|
|
35
35
|
try {
|
|
36
36
|
const pythonCode = `
|
|
37
|
-
import unreal
|
|
37
|
+
import unreal, json
|
|
38
38
|
actor_subsystem = unreal.get_editor_subsystem(unreal.EditorActorSubsystem)
|
|
39
|
-
actors = actor_subsystem.get_all_level_actors()
|
|
39
|
+
actors = actor_subsystem.get_all_level_actors() if actor_subsystem else []
|
|
40
40
|
actor_list = []
|
|
41
41
|
for actor in actors:
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
42
|
+
try:
|
|
43
|
+
if actor:
|
|
44
|
+
actor_list.append({
|
|
45
|
+
'name': actor.get_name(),
|
|
46
|
+
'class': actor.get_class().get_name(),
|
|
47
|
+
'path': actor.get_path_name()
|
|
48
|
+
})
|
|
49
|
+
except Exception:
|
|
50
|
+
pass
|
|
51
|
+
print('RESULT:' + json.dumps({'success': True, 'count': len(actor_list), 'actors': actor_list}))
|
|
49
52
|
`.trim();
|
|
50
53
|
|
|
51
|
-
const
|
|
52
|
-
|
|
53
|
-
|
|
54
|
+
const resp = await this.bridge.executePythonWithResult(pythonCode);
|
|
55
|
+
if (resp && typeof resp === 'object' && resp.success === true && Array.isArray((resp as any).actors)) {
|
|
56
|
+
this.setCache('listActors', resp);
|
|
57
|
+
return resp;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Fallback manual extraction with bracket matching
|
|
61
|
+
const raw = await this.bridge.executePython(pythonCode);
|
|
62
|
+
let output = '';
|
|
63
|
+
if (raw?.LogOutput && Array.isArray(raw.LogOutput)) output = raw.LogOutput.map((l: any) => l.Output || '').join('');
|
|
64
|
+
else if (typeof raw === 'string') output = raw; else output = JSON.stringify(raw);
|
|
65
|
+
const marker = 'RESULT:';
|
|
66
|
+
const idx = output.lastIndexOf(marker);
|
|
67
|
+
if (idx !== -1) {
|
|
68
|
+
let i = idx + marker.length;
|
|
69
|
+
while (i < output.length && output[i] !== '{') i++;
|
|
70
|
+
if (i < output.length) {
|
|
71
|
+
let depth = 0, inStr = false, esc = false, j = i;
|
|
72
|
+
for (; j < output.length; j++) {
|
|
73
|
+
const ch = output[j];
|
|
74
|
+
if (esc) { esc = false; continue; }
|
|
75
|
+
if (ch === '\\') { esc = true; continue; }
|
|
76
|
+
if (ch === '"') { inStr = !inStr; continue; }
|
|
77
|
+
if (!inStr) {
|
|
78
|
+
if (ch === '{') depth++;
|
|
79
|
+
else if (ch === '}') { depth--; if (depth === 0) { j++; break; } }
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
const jsonStr = output.slice(i, j);
|
|
83
|
+
try {
|
|
84
|
+
const parsed = JSON.parse(jsonStr);
|
|
85
|
+
this.setCache('listActors', parsed);
|
|
86
|
+
return parsed;
|
|
87
|
+
} catch {}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return { success: false, error: 'Failed to parse actors list' };
|
|
54
92
|
} catch (err) {
|
|
55
|
-
return { error: `Failed to list actors: ${err}` };
|
|
93
|
+
return { success: false, error: `Failed to list actors: ${err}` };
|
|
56
94
|
}
|
|
57
95
|
}
|
|
58
96
|
|
package/src/resources/assets.ts
CHANGED
|
@@ -10,10 +10,10 @@ export class AssetResources {
|
|
|
10
10
|
return page !== undefined ? `${dir}::${recursive ? 1 : 0}::${page}` : `${dir}::${recursive ? 1 : 0}`;
|
|
11
11
|
}
|
|
12
12
|
|
|
13
|
-
async list(dir = '/Game',
|
|
13
|
+
async list(dir = '/Game', _recursive = false, limit = 50) {
|
|
14
14
|
// ALWAYS use non-recursive listing to show only immediate children
|
|
15
15
|
// This prevents timeouts and makes navigation clearer
|
|
16
|
-
|
|
16
|
+
_recursive = false; // Force non-recursive
|
|
17
17
|
|
|
18
18
|
// Cache fast-path
|
|
19
19
|
try {
|
|
@@ -105,7 +105,7 @@ export class AssetResources {
|
|
|
105
105
|
* Directory-based listing for paths with too many assets
|
|
106
106
|
* Shows only immediate children (folders and files) to avoid timeouts
|
|
107
107
|
*/
|
|
108
|
-
private async listDirectoryOnly(dir: string,
|
|
108
|
+
private async listDirectoryOnly(dir: string, _recursive: boolean, limit: number) {
|
|
109
109
|
// Always return only immediate children to avoid timeout and improve navigation
|
|
110
110
|
try {
|
|
111
111
|
const py = `
|