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 CHANGED
@@ -10,11 +10,16 @@ UE_RC_WS_PORT=30020
10
10
  USE_CONSOLIDATED_TOOLS=true
11
11
 
12
12
  # Logging Level
13
+ # Available levels (from quietest to most verbose):
14
+ # - error : only errors
15
+ # - warn : warnings and errors
16
+ # - info : info, warnings, errors (default)
17
+ # - debug : full debug, very verbose
13
18
  LOG_LEVEL=info
14
19
 
15
20
  # Server Settings
16
21
  SERVER_NAME=unreal-engine-mcp
17
- SERVER_VERSION=1.0.0
22
+ SERVER_VERSION=0.3.1
18
23
 
19
24
  # Connection Settings
20
25
  MAX_RETRY_ATTEMPTS=3
package/Dockerfile CHANGED
@@ -1,5 +1,5 @@
1
1
  # Multi-stage build for efficient image size
2
- FROM node:20-alpine AS builder
2
+ FROM node:22-alpine AS builder
3
3
 
4
4
  # Set working directory
5
5
  WORKDIR /app
@@ -18,37 +18,20 @@ COPY src ./src
18
18
  RUN npm run build
19
19
 
20
20
  # Production stage
21
- FROM node:20-alpine
21
+ FROM cgr.dev/chainguard/node:latest
22
22
 
23
- # Install dumb-init for proper signal handling
24
- RUN apk add --no-cache dumb-init
23
+ ENV NODE_ENV=production
25
24
 
26
- # Create non-root user
27
- RUN addgroup -g 1001 -S nodejs && \
28
- adduser -S nodejs -u 1001
29
-
30
- # Set working directory
25
+ # Chainguard node runs as nonroot by default (user: node)
31
26
  WORKDIR /app
32
27
 
33
- # Copy package files
34
- COPY package*.json ./
35
-
36
- # Install production dependencies only (skip prepare script)
37
- RUN npm ci --only=production --ignore-scripts && \
38
- npm cache clean --force
28
+ # Copy only package manifests and install production deps in a clean layer
29
+ COPY --chown=node:node package*.json ./
30
+ RUN npm ci --omit=dev --ignore-scripts && npm cache clean --force
39
31
 
40
32
  # Copy built application from builder stage
41
- COPY --from=builder /app/dist ./dist
42
-
43
- # Change ownership to nodejs user
44
- RUN chown -R nodejs:nodejs /app
45
-
46
- # Switch to non-root user
47
- USER nodejs
48
-
49
- # MCP servers use stdio, no port exposure needed
50
- # Use dumb-init to handle signals properly
51
- ENTRYPOINT ["dumb-init", "--"]
33
+ COPY --chown=node:node --from=builder /app/dist ./dist
52
34
 
53
- # Run the MCP server
54
- CMD ["node", "dist/cli.js"]
35
+ # No shell, no init needed; run as provided nonroot user
36
+ ENTRYPOINT ["/usr/bin/node"]
37
+ CMD ["dist/cli.js"]
package/README.md CHANGED
@@ -78,23 +78,6 @@ Then enable Python execution in: Edit > Project Settings > Plugins > Remote Cont
78
78
 
79
79
  ### Claude Desktop / Cursor
80
80
 
81
- #### For NPM Installation (Global)
82
-
83
- ```json
84
- {
85
- "mcpServers": {
86
- "unreal-engine": {
87
- "command": "unreal-engine-mcp-server",
88
- "env": {
89
- "UE_HOST": "127.0.0.1",
90
- "UE_RC_HTTP_PORT": "30010",
91
- "UE_RC_WS_PORT": "30020"
92
- }
93
- }
94
- }
95
- }
96
- ```
97
-
98
81
  #### For NPM Installation (Local)
99
82
 
100
83
  ```json
package/dist/index.js CHANGED
@@ -51,16 +51,19 @@ const metrics = {
51
51
  uptime: Date.now(),
52
52
  recentErrors: []
53
53
  };
54
+ // Health check timer and last success tracking (stop pings after inactivity)
55
+ let healthCheckTimer;
56
+ let lastHealthSuccessAt = 0;
54
57
  // Configuration
55
58
  const CONFIG = {
56
- // Tool mode: true = consolidated (10 tools), false = individual (36+ tools)
59
+ // Tool mode: true = consolidated (13 tools), false = individual (36+ tools)
57
60
  USE_CONSOLIDATED_TOOLS: process.env.USE_CONSOLIDATED_TOOLS !== 'false',
58
61
  // Connection retry settings
59
62
  MAX_RETRY_ATTEMPTS: 3,
60
63
  RETRY_DELAY_MS: 2000,
61
64
  // Server info
62
65
  SERVER_NAME: 'unreal-engine-mcp',
63
- SERVER_VERSION: '0.2.1',
66
+ SERVER_VERSION: '0.3.1',
64
67
  // Monitoring
65
68
  HEALTH_CHECK_INTERVAL_MS: 30000 // 30 seconds
66
69
  };
@@ -84,11 +87,16 @@ function trackPerformance(startTime, success) {
84
87
  }
85
88
  // Health check function
86
89
  async function performHealthCheck(bridge) {
90
+ // If not connected, do not attempt any ping (stay quiet)
91
+ if (!bridge.isConnected) {
92
+ return false;
93
+ }
87
94
  try {
88
95
  // Use a safe echo command that doesn't affect any settings
89
96
  await bridge.executeConsoleCommand('echo MCP Server Health Check');
90
97
  metrics.connectionStatus = 'connected';
91
98
  metrics.lastHealthCheck = new Date();
99
+ lastHealthSuccessAt = Date.now();
92
100
  return true;
93
101
  }
94
102
  catch (err1) {
@@ -97,55 +105,80 @@ async function performHealthCheck(bridge) {
97
105
  await bridge.executePython("import sys; sys.stdout.write('OK')");
98
106
  metrics.connectionStatus = 'connected';
99
107
  metrics.lastHealthCheck = new Date();
108
+ lastHealthSuccessAt = Date.now();
100
109
  return true;
101
110
  }
102
111
  catch (err2) {
103
112
  metrics.connectionStatus = 'error';
104
113
  metrics.lastHealthCheck = new Date();
105
- log.warn('Health check failed (console and python):', err1, err2);
114
+ // Avoid noisy warnings when engine may be shutting down; log at debug
115
+ log.debug('Health check failed (console and python):', err1, err2);
106
116
  return false;
107
117
  }
108
118
  }
109
119
  }
110
120
  export async function createServer() {
111
121
  const bridge = new UnrealBridge();
122
+ // Disable auto-reconnect loops; connect only on-demand
123
+ bridge.setAutoReconnectEnabled(false);
112
124
  // Initialize response validation with schemas
113
- log.info('Initializing response validation...');
125
+ log.debug('Initializing response validation...');
114
126
  const toolDefs = CONFIG.USE_CONSOLIDATED_TOOLS ? consolidatedToolDefinitions : toolDefinitions;
115
127
  toolDefs.forEach((tool) => {
116
128
  if (tool.outputSchema) {
117
129
  responseValidator.registerSchema(tool.name, tool.outputSchema);
118
130
  }
119
131
  });
120
- log.info(`Registered ${responseValidator.getStats().totalSchemas} output schemas for validation`);
121
- // Connect to UE5 Remote Control with retries and timeout
122
- const connected = await bridge.tryConnect(CONFIG.MAX_RETRY_ATTEMPTS, 5000, // 5 second timeout per attempt
123
- CONFIG.RETRY_DELAY_MS);
124
- if (connected) {
125
- metrics.connectionStatus = 'connected';
126
- log.info('Successfully connected to Unreal Engine');
127
- }
128
- else {
129
- log.warn('Could not connect to Unreal Engine after retries');
130
- log.info('Server will start anyway - connection will be retried periodically');
131
- log.info('Make sure Unreal Engine is running with Remote Control enabled');
132
- metrics.connectionStatus = 'disconnected';
133
- // Schedule automatic reconnection attempts
134
- setInterval(async () => {
132
+ // Summary at debug level to avoid repeated noisy blocks in some shells
133
+ log.debug(`Registered ${responseValidator.getStats().totalSchemas} output schemas for validation`);
134
+ // Do NOT connect to Unreal at startup; connect on demand
135
+ log.debug('Server starting without connecting to Unreal Engine');
136
+ metrics.connectionStatus = 'disconnected';
137
+ // Health checks manager (only active when connected)
138
+ const startHealthChecks = () => {
139
+ if (healthCheckTimer)
140
+ return;
141
+ lastHealthSuccessAt = Date.now();
142
+ healthCheckTimer = setInterval(async () => {
143
+ // Only attempt health pings while connected; stay silent otherwise
135
144
  if (!bridge.isConnected) {
136
- log.info('Attempting to reconnect to Unreal Engine...');
137
- const reconnected = await bridge.tryConnect(1, 5000, 0); // Single attempt
138
- if (reconnected) {
139
- log.info('Reconnected to Unreal Engine successfully');
140
- metrics.connectionStatus = 'connected';
145
+ // Optionally pause fully after 5 minutes of no success
146
+ const FIVE_MIN_MS = 5 * 60 * 1000;
147
+ if (!lastHealthSuccessAt || Date.now() - lastHealthSuccessAt > FIVE_MIN_MS) {
148
+ if (healthCheckTimer) {
149
+ clearInterval(healthCheckTimer);
150
+ healthCheckTimer = undefined;
151
+ }
152
+ log.info('Health checks paused after 5 minutes without a successful response');
141
153
  }
154
+ return;
142
155
  }
143
- }, 10000); // Try every 10 seconds
144
- }
145
- // Start periodic health checks
146
- setInterval(() => {
147
- performHealthCheck(bridge);
148
- }, CONFIG.HEALTH_CHECK_INTERVAL_MS);
156
+ await performHealthCheck(bridge);
157
+ // Stop sending echoes if we haven't had a successful response in > 5 minutes
158
+ const FIVE_MIN_MS = 5 * 60 * 1000;
159
+ if (!lastHealthSuccessAt || Date.now() - lastHealthSuccessAt > FIVE_MIN_MS) {
160
+ if (healthCheckTimer) {
161
+ clearInterval(healthCheckTimer);
162
+ healthCheckTimer = undefined;
163
+ log.info('Health checks paused after 5 minutes without a successful response');
164
+ }
165
+ }
166
+ }, CONFIG.HEALTH_CHECK_INTERVAL_MS);
167
+ };
168
+ // On-demand connection helper
169
+ const ensureConnectedOnDemand = async () => {
170
+ if (bridge.isConnected)
171
+ return true;
172
+ const ok = await bridge.tryConnect(3, 5000, 1000);
173
+ if (ok) {
174
+ metrics.connectionStatus = 'connected';
175
+ startHealthChecks();
176
+ }
177
+ else {
178
+ metrics.connectionStatus = 'disconnected';
179
+ }
180
+ return ok;
181
+ };
149
182
  // Resources
150
183
  const assetResources = new AssetResources(bridge);
151
184
  const actorResources = new ActorResources(bridge);
@@ -230,6 +263,10 @@ export async function createServer() {
230
263
  server.setRequestHandler(ReadResourceRequestSchema, async (request) => {
231
264
  const uri = request.params.uri;
232
265
  if (uri === 'ue://assets') {
266
+ const ok = await ensureConnectedOnDemand();
267
+ if (!ok) {
268
+ return { contents: [{ uri, mimeType: 'text/plain', text: 'Unreal Engine not connected (after 3 attempts).' }] };
269
+ }
233
270
  const list = await assetResources.list('/Game', true);
234
271
  return {
235
272
  contents: [{
@@ -240,6 +277,10 @@ export async function createServer() {
240
277
  };
241
278
  }
242
279
  if (uri === 'ue://actors') {
280
+ const ok = await ensureConnectedOnDemand();
281
+ if (!ok) {
282
+ return { contents: [{ uri, mimeType: 'text/plain', text: 'Unreal Engine not connected (after 3 attempts).' }] };
283
+ }
243
284
  const list = await actorResources.listActors();
244
285
  return {
245
286
  contents: [{
@@ -250,6 +291,10 @@ export async function createServer() {
250
291
  };
251
292
  }
252
293
  if (uri === 'ue://level') {
294
+ const ok = await ensureConnectedOnDemand();
295
+ if (!ok) {
296
+ return { contents: [{ uri, mimeType: 'text/plain', text: 'Unreal Engine not connected (after 3 attempts).' }] };
297
+ }
253
298
  const level = await levelResources.getCurrentLevel();
254
299
  return {
255
300
  contents: [{
@@ -260,6 +305,10 @@ export async function createServer() {
260
305
  };
261
306
  }
262
307
  if (uri === 'ue://exposed') {
308
+ const ok = await ensureConnectedOnDemand();
309
+ if (!ok) {
310
+ return { contents: [{ uri, mimeType: 'text/plain', text: 'Unreal Engine not connected (after 3 attempts).' }] };
311
+ }
263
312
  try {
264
313
  const exposed = await bridge.getExposed();
265
314
  return {
@@ -282,17 +331,19 @@ export async function createServer() {
282
331
  }
283
332
  if (uri === 'ue://health') {
284
333
  const uptimeMs = Date.now() - metrics.uptime;
285
- // Query engine version and feature flags (best-effort)
334
+ // Query engine version and feature flags only when connected
286
335
  let versionInfo = {};
287
336
  let featureFlags = {};
288
- try {
289
- versionInfo = await bridge.getEngineVersion();
290
- }
291
- catch { }
292
- try {
293
- featureFlags = await bridge.getFeatureFlags();
337
+ if (bridge.isConnected) {
338
+ try {
339
+ versionInfo = await bridge.getEngineVersion();
340
+ }
341
+ catch { }
342
+ try {
343
+ featureFlags = await bridge.getFeatureFlags();
344
+ }
345
+ catch { }
294
346
  }
295
- catch { }
296
347
  const health = {
297
348
  status: metrics.connectionStatus,
298
349
  uptime: Math.floor(uptimeMs / 1000),
@@ -328,6 +379,10 @@ export async function createServer() {
328
379
  };
329
380
  }
330
381
  if (uri === 'ue://version') {
382
+ const ok = await ensureConnectedOnDemand();
383
+ if (!ok) {
384
+ return { contents: [{ uri, mimeType: 'text/plain', text: 'Unreal Engine not connected (after 3 attempts).' }] };
385
+ }
331
386
  const info = await bridge.getEngineVersion();
332
387
  return {
333
388
  contents: [{
@@ -339,17 +394,30 @@ export async function createServer() {
339
394
  }
340
395
  throw new Error(`Unknown resource: ${uri}`);
341
396
  });
342
- // Handle tool listing - switch between consolidated (10) or individual (36) tools
397
+ // Handle tool listing - switch between consolidated (13) or individual (36) tools
343
398
  server.setRequestHandler(ListToolsRequestSchema, async () => {
344
399
  log.info(`Serving ${CONFIG.USE_CONSOLIDATED_TOOLS ? 'consolidated' : 'individual'} tools`);
345
400
  return {
346
401
  tools: CONFIG.USE_CONSOLIDATED_TOOLS ? consolidatedToolDefinitions : toolDefinitions
347
402
  };
348
403
  });
349
- // Handle tool calls - switch between consolidated (10) or individual (36) tools
404
+ // Handle tool calls - switch between consolidated (13) or individual (36) tools
350
405
  server.setRequestHandler(CallToolRequestSchema, async (request) => {
351
406
  const { name, arguments: args } = request.params;
352
407
  const startTime = Date.now();
408
+ // Ensure connection only when needed, with 3 attempts
409
+ const connected = await ensureConnectedOnDemand();
410
+ if (!connected) {
411
+ const notConnected = {
412
+ content: [{ type: 'text', text: 'Unreal Engine is not connected (after 3 attempts). Please open UE and try again.' }],
413
+ success: false,
414
+ error: 'UE_NOT_CONNECTED',
415
+ retriable: false,
416
+ scope: `tool-call/${name}`
417
+ };
418
+ trackPerformance(startTime, false);
419
+ return notConnected;
420
+ }
353
421
  // Create tools object for handler
354
422
  const tools = {
355
423
  actorTools,
@@ -25,25 +25,83 @@ export class ActorResources {
25
25
  // Use Python to get actors via EditorActorSubsystem
26
26
  try {
27
27
  const pythonCode = `
28
- import unreal
28
+ import unreal, json
29
29
  actor_subsystem = unreal.get_editor_subsystem(unreal.EditorActorSubsystem)
30
- actors = actor_subsystem.get_all_level_actors()
30
+ actors = actor_subsystem.get_all_level_actors() if actor_subsystem else []
31
31
  actor_list = []
32
32
  for actor in actors:
33
- if actor:
34
- actor_list.append({
35
- 'name': actor.get_name(),
36
- 'class': actor.get_class().get_name(),
37
- 'path': actor.get_path_name()
38
- })
39
- print(f"Found {len(actor_list)} actors")
33
+ try:
34
+ if actor:
35
+ actor_list.append({
36
+ 'name': actor.get_name(),
37
+ 'class': actor.get_class().get_name(),
38
+ 'path': actor.get_path_name()
39
+ })
40
+ except Exception:
41
+ pass
42
+ print('RESULT:' + json.dumps({'success': True, 'count': len(actor_list), 'actors': actor_list}))
40
43
  `.trim();
41
- const result = await this.bridge.executePython(pythonCode);
42
- this.setCache('listActors', result);
43
- return result;
44
+ const resp = await this.bridge.executePythonWithResult(pythonCode);
45
+ if (resp && typeof resp === 'object' && resp.success === true && Array.isArray(resp.actors)) {
46
+ this.setCache('listActors', resp);
47
+ return resp;
48
+ }
49
+ // Fallback manual extraction with bracket matching
50
+ const raw = await this.bridge.executePython(pythonCode);
51
+ let output = '';
52
+ if (raw?.LogOutput && Array.isArray(raw.LogOutput))
53
+ output = raw.LogOutput.map((l) => l.Output || '').join('');
54
+ else if (typeof raw === 'string')
55
+ output = raw;
56
+ else
57
+ output = JSON.stringify(raw);
58
+ const marker = 'RESULT:';
59
+ const idx = output.lastIndexOf(marker);
60
+ if (idx !== -1) {
61
+ let i = idx + marker.length;
62
+ while (i < output.length && output[i] !== '{')
63
+ i++;
64
+ if (i < output.length) {
65
+ let depth = 0, inStr = false, esc = false, j = i;
66
+ for (; j < output.length; j++) {
67
+ const ch = output[j];
68
+ if (esc) {
69
+ esc = false;
70
+ continue;
71
+ }
72
+ if (ch === '\\') {
73
+ esc = true;
74
+ continue;
75
+ }
76
+ if (ch === '"') {
77
+ inStr = !inStr;
78
+ continue;
79
+ }
80
+ if (!inStr) {
81
+ if (ch === '{')
82
+ depth++;
83
+ else if (ch === '}') {
84
+ depth--;
85
+ if (depth === 0) {
86
+ j++;
87
+ break;
88
+ }
89
+ }
90
+ }
91
+ }
92
+ const jsonStr = output.slice(i, j);
93
+ try {
94
+ const parsed = JSON.parse(jsonStr);
95
+ this.setCache('listActors', parsed);
96
+ return parsed;
97
+ }
98
+ catch { }
99
+ }
100
+ }
101
+ return { success: false, error: 'Failed to parse actors list' };
44
102
  }
45
103
  catch (err) {
46
- return { error: `Failed to list actors: ${err}` };
104
+ return { success: false, error: `Failed to list actors: ${err}` };
47
105
  }
48
106
  }
49
107
  async getActorByName(actorName) {
@@ -5,7 +5,7 @@ export declare class AssetResources {
5
5
  private cache;
6
6
  private get ttlMs();
7
7
  private makeKey;
8
- list(dir?: string, recursive?: boolean, limit?: number): Promise<any>;
8
+ list(dir?: string, _recursive?: boolean, limit?: number): Promise<any>;
9
9
  /**
10
10
  * List assets with pagination support
11
11
  * @param dir Directory to list assets from
@@ -9,10 +9,10 @@ export class AssetResources {
9
9
  makeKey(dir, recursive, page) {
10
10
  return page !== undefined ? `${dir}::${recursive ? 1 : 0}::${page}` : `${dir}::${recursive ? 1 : 0}`;
11
11
  }
12
- async list(dir = '/Game', recursive = false, limit = 50) {
12
+ async list(dir = '/Game', _recursive = false, limit = 50) {
13
13
  // ALWAYS use non-recursive listing to show only immediate children
14
14
  // This prevents timeouts and makes navigation clearer
15
- recursive = false; // Force non-recursive
15
+ _recursive = false; // Force non-recursive
16
16
  // Cache fast-path
17
17
  try {
18
18
  const key = this.makeKey(dir, false);
@@ -94,7 +94,7 @@ export class AssetResources {
94
94
  * Directory-based listing for paths with too many assets
95
95
  * Shows only immediate children (folders and files) to avoid timeouts
96
96
  */
97
- async listDirectoryOnly(dir, recursive, limit) {
97
+ async listDirectoryOnly(dir, _recursive, limit) {
98
98
  // Always return only immediate children to avoid timeout and improve navigation
99
99
  try {
100
100
  const py = `