unreal-engine-mcp-server 0.3.1 → 0.4.0

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.
@@ -22,13 +22,11 @@ export class DebugVisualizationTools {
22
22
  private async pyDraw(scriptBody: string) {
23
23
  const script = `
24
24
  import unreal
25
- # Use modern UnrealEditorSubsystem instead of deprecated EditorLevelLibrary
25
+ # Strict modern API: require UnrealEditorSubsystem (UE 5.1+)
26
26
  ues = unreal.get_editor_subsystem(unreal.UnrealEditorSubsystem)
27
- if ues:
28
- world = ues.get_editor_world()
29
- else:
30
- # Fallback to deprecated API if subsystem not available
31
- world = unreal.EditorLevelLibrary.get_editor_world()
27
+ if not ues:
28
+ raise Exception('UnrealEditorSubsystem not available')
29
+ world = ues.get_editor_world()
32
30
  ${scriptBody}
33
31
  `.trim()
34
32
  .replace(/\r?\n/g, '\n');
package/src/tools/rc.ts CHANGED
@@ -176,7 +176,7 @@ except Exception as e:
176
176
 
177
177
  // Expose an actor by label/name into a preset
178
178
  async exposeActor(params: { presetPath: string; actorName: string }) {
179
- const python = `\nimport unreal, json\npreset_path = r"${params.presetPath}"\nactor_name = r"${params.actorName}"\ntry:\n preset = unreal.EditorAssetLibrary.load_asset(preset_path)\n if not preset:\n print('RESULT:' + json.dumps({'success': False, 'error': 'Preset not found'}))\n else:\n actor_sub = unreal.get_editor_subsystem(unreal.EditorActorSubsystem)\n target = None\n for a in actor_sub.get_all_level_actors():\n if not a: continue\n try:\n if a.get_actor_label() == actor_name or a.get_name() == actor_name:\n target = a; break\n except Exception: pass\n if not target:\n print('RESULT:' + json.dumps({'success': False, 'error': 'Actor not found'}))\n else:\n try:\n unreal.RemoteControlFunctionLibrary.expose_actor(preset, target, None)\n unreal.EditorAssetLibrary.save_asset(preset_path)\n print('RESULT:' + json.dumps({'success': True}))\n except Exception as e:\n print('RESULT:' + json.dumps({'success': False, 'error': str(e)}))\nexcept Exception as e:\n print('RESULT:' + json.dumps({'success': False, 'error': str(e)}))\n`.trim();
179
+ const python = `\nimport unreal, json\npreset_path = r"${params.presetPath}"\nactor_name = r"${params.actorName}"\ntry:\n preset = unreal.EditorAssetLibrary.load_asset(preset_path)\n if not preset:\n print('RESULT:' + json.dumps({'success': False, 'error': 'Preset not found'}))\n else:\n actor_sub = unreal.get_editor_subsystem(unreal.EditorActorSubsystem)\n target = None\n for a in actor_sub.get_all_level_actors():\n if not a: continue\n try:\n if a.get_actor_label() == actor_name or a.get_name() == actor_name:\n target = a; break\n except Exception: pass\n if not target:\n print('RESULT:' + json.dumps({'success': False, 'error': 'Actor not found'}))\n else:\n try:\n # Expose with a default-initialized optional args struct (cannot pass None)\n args = unreal.RemoteControlOptionalExposeArgs()\n unreal.RemoteControlFunctionLibrary.expose_actor(preset, target, args)\n unreal.EditorAssetLibrary.save_asset(preset_path)\n print('RESULT:' + json.dumps({'success': True}))\n except Exception as e:\n print('RESULT:' + json.dumps({'success': False, 'error': str(e)}))\nexcept Exception as e:\n print('RESULT:' + json.dumps({'success': False, 'error': str(e)}))\n`.trim();
180
180
  const resp = await this.executeWithRetry(
181
181
  () => this.bridge.executePython(python),
182
182
  'exposeActor'
@@ -194,7 +194,7 @@ except Exception as e:
194
194
 
195
195
  // Expose a property on an object into a preset
196
196
  async exposeProperty(params: { presetPath: string; objectPath: string; propertyName: string }) {
197
- const python = `\nimport unreal, json\npreset_path = r"${params.presetPath}"\nobj_path = r"${params.objectPath}"\nprop_name = r"${params.propertyName}"\ntry:\n preset = unreal.EditorAssetLibrary.load_asset(preset_path)\n obj = unreal.load_object(None, obj_path)\n if not preset or not obj:\n print('RESULT:' + json.dumps({'success': False, 'error': 'Preset or object not found'}))\n else:\n try:\n unreal.RemoteControlFunctionLibrary.expose_property(preset, obj, prop_name, None)\n unreal.EditorAssetLibrary.save_asset(preset_path)\n print('RESULT:' + json.dumps({'success': True}))\n except Exception as e:\n print('RESULT:' + json.dumps({'success': False, 'error': str(e)}))\nexcept Exception as e:\n print('RESULT:' + json.dumps({'success': False, 'error': str(e)}))\n`.trim();
197
+ const python = `\nimport unreal, json\npreset_path = r"${params.presetPath}"\nobj_path = r"${params.objectPath}"\nprop_name = r"${params.propertyName}"\ntry:\n preset = unreal.EditorAssetLibrary.load_asset(preset_path)\n obj = unreal.load_object(None, obj_path)\n if not preset or not obj:\n print('RESULT:' + json.dumps({'success': False, 'error': 'Preset or object not found'}))\n else:\n try:\n # Expose with default optional args struct (cannot pass None)\n args = unreal.RemoteControlOptionalExposeArgs()\n unreal.RemoteControlFunctionLibrary.expose_property(preset, obj, prop_name, args)\n unreal.EditorAssetLibrary.save_asset(preset_path)\n print('RESULT:' + json.dumps({'success': True}))\n except Exception as e:\n print('RESULT:' + json.dumps({'success': False, 'error': str(e)}))\nexcept Exception as e:\n print('RESULT:' + json.dumps({'success': False, 'error': str(e)}))\n`.trim();
198
198
  const resp = await this.executeWithRetry(
199
199
  () => this.bridge.executePython(python),
200
200
  'exposeProperty'
@@ -334,12 +334,31 @@ except Exception as e:
334
334
  * Play the current level sequence
335
335
  */
336
336
  async play(params?: { startTime?: number; loopMode?: 'once' | 'loop' | 'pingpong' }) {
337
+ const loop = params?.loopMode || '';
337
338
  const py = `
338
339
  import unreal, json
340
+
341
+ # Helper to resolve SequencerLoopMode from a friendly string
342
+ def _resolve_loop_mode(mode_str):
343
+ try:
344
+ m = str(mode_str).lower()
345
+ slm = unreal.SequencerLoopMode
346
+ if m in ('once','noloop','no_loop'):
347
+ return getattr(slm, 'SLM_NoLoop', getattr(slm, 'NoLoop'))
348
+ if m in ('loop',):
349
+ return getattr(slm, 'SLM_Loop', getattr(slm, 'Loop'))
350
+ if m in ('pingpong','ping_pong'):
351
+ return getattr(slm, 'SLM_PingPong', getattr(slm, 'PingPong'))
352
+ except Exception:
353
+ pass
354
+ return None
355
+
339
356
  try:
340
357
  unreal.LevelSequenceEditorBlueprintLibrary.play()
341
- ${params?.loopMode ? `unreal.LevelSequenceEditorBlueprintLibrary.set_loop_mode('${params.loopMode}')` : ''}
342
- print('RESULT:' + json.dumps({'success': True, 'playing': True}))
358
+ loop_mode = _resolve_loop_mode('${loop}')
359
+ if loop_mode is not None:
360
+ unreal.LevelSequenceEditorBlueprintLibrary.set_loop_mode(loop_mode)
361
+ print('RESULT:' + json.dumps({'success': True, 'playing': True, 'loopMode': '${loop || 'default'}'}))
343
362
  except Exception as e:
344
363
  print('RESULT:' + json.dumps({'success': False, 'error': str(e)}))
345
364
  `.trim();
@@ -95,40 +95,68 @@ export class ResponseValidator {
95
95
  }
96
96
 
97
97
  /**
98
- * Wrap a tool response with validation
98
+ * Wrap a tool response with validation and MCP-compliant content shape.
99
+ *
100
+ * MCP tools/call responses must contain a `content` array. Many internal
101
+ * handlers return structured JSON objects (e.g., { success, message, ... }).
102
+ * This wrapper serializes such objects into a single text block while keeping
103
+ * existing `content` responses intact.
99
104
  */
100
105
  wrapResponse(toolName: string, response: any): any {
101
106
  // Ensure response is safe to serialize first
102
107
  try {
103
- // The response should already be cleaned, but double-check
104
108
  if (response && typeof response === 'object') {
105
- // Make sure we can serialize it
106
109
  JSON.stringify(response);
107
110
  }
108
111
  } catch (_error) {
109
112
  log.error(`Response for ${toolName} contains circular references, cleaning...`);
110
113
  response = cleanObject(response);
111
114
  }
112
-
113
- const validation = this.validateResponse(toolName, response);
114
-
115
- // Add validation metadata
115
+
116
+ // If handler already returned MCP content, keep it as-is (still validate)
117
+ const alreadyMcpShaped = response && typeof response === 'object' && Array.isArray(response.content);
118
+
119
+ // Choose the payload to validate: if already MCP-shaped, validate the
120
+ // structured content extracted from text; otherwise validate the object directly.
121
+ const validationTarget = alreadyMcpShaped ? response : response;
122
+ const validation = this.validateResponse(toolName, validationTarget);
123
+
116
124
  if (!validation.valid) {
117
125
  log.warn(`Tool ${toolName} response validation failed:`, validation.errors);
118
-
119
- // Add warning to response but don't fail
120
- if (response && typeof response === 'object') {
121
- response._validation = {
122
- valid: false,
123
- errors: validation.errors
124
- };
126
+ }
127
+
128
+ // If it's already MCP-shaped, return as-is (optionally append validation meta)
129
+ if (alreadyMcpShaped) {
130
+ if (!validation.valid) {
131
+ try {
132
+ (response as any)._validation = { valid: false, errors: validation.errors };
133
+ } catch {}
125
134
  }
135
+ return response;
126
136
  }
127
137
 
128
- // Don't add structuredContent to the response - it's for internal validation only
129
- // Adding it can cause circular references
130
-
131
- return response;
138
+ // Otherwise, wrap structured result into MCP content
139
+ let text: string;
140
+ try {
141
+ // Pretty-print small objects for readability
142
+ text = typeof response === 'string'
143
+ ? response
144
+ : JSON.stringify(response ?? { success: true }, null, 2);
145
+ } catch (_e) {
146
+ text = String(response);
147
+ }
148
+
149
+ const wrapped = {
150
+ content: [
151
+ { type: 'text', text }
152
+ ]
153
+ } as any;
154
+
155
+ if (!validation.valid) {
156
+ wrapped._validation = { valid: false, errors: validation.errors };
157
+ }
158
+
159
+ return wrapped;
132
160
  }
133
161
 
134
162
  /**