@createlex/createlexgenai 1.0.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.
@@ -0,0 +1,4720 @@
1
+ import socket
2
+ import json
3
+ import sys
4
+ import os
5
+ import shutil
6
+ from fastmcp import FastMCP
7
+ import re
8
+ from pathlib import Path
9
+ import time
10
+ from typing import Any, List, Dict, Optional
11
+
12
+ _CURRENT_DIR = Path(__file__).resolve().parent
13
+ if str(_CURRENT_DIR) not in sys.path:
14
+ sys.path.insert(0, str(_CURRENT_DIR))
15
+
16
+ try:
17
+ from ui_slice_host import auto_slice_ui_mockup_host, has_pillow as host_slice_has_pillow
18
+ except Exception as host_slice_import_error:
19
+ auto_slice_ui_mockup_host = None
20
+
21
+ def host_slice_has_pillow():
22
+ return False, str(host_slice_import_error)
23
+
24
+ # THIS FILE WILL RUN OUTSIDE THE UNREAL ENGINE SCOPE,
25
+ # DO NOT IMPORT UNREAL MODULES HERE OR EXECUTE IT IN THE UNREAL ENGINE PYTHON INTERPRETER
26
+
27
+ # Global context cache for Blueprint analysis and iterative refinement
28
+ _blueprint_context_cache = {
29
+ "cached_context": None,
30
+ "cache_timestamp": 0,
31
+ "cache_blueprint_path": None,
32
+ "cache_duration": 300, # 5 minutes cache duration
33
+ "generation_history": [], # Track generation attempts for iterative refinement
34
+ "last_generated_function": None, # Store last generated function for refinement
35
+ "validation_results": None # Store validation results for iteration
36
+ }
37
+
38
+ # Create a PID file to let the Unreal plugin know this process is running
39
+ def write_pid_file():
40
+ try:
41
+ pid = os.getpid()
42
+ pid_dir = os.path.join(os.path.expanduser("~"), ".unrealgenai")
43
+ os.makedirs(pid_dir, exist_ok=True)
44
+ pid_path = os.path.join(pid_dir, "mcp_server.pid")
45
+ with open(pid_path, "w") as f:
46
+ f.write(f"{pid}\n8080") # Store PID and port
47
+
48
+ # Register to delete the PID file on exit
49
+ import atexit
50
+ def cleanup_pid_file():
51
+ try:
52
+ if os.path.exists(pid_path):
53
+ os.remove(pid_path)
54
+ except:
55
+ pass
56
+ atexit.register(cleanup_pid_file)
57
+ return pid_path
58
+ except Exception as e:
59
+ print(f"Failed to write PID file: {e}", file=sys.stderr)
60
+ return None
61
+
62
+ # Write PID file on startup
63
+ pid_file = write_pid_file()
64
+ if pid_file:
65
+ print(f"MCP Server started with PID file at: {pid_file}", file=sys.stderr)
66
+
67
+ # Create an MCP server
68
+ mcp = FastMCP("UnrealHandshake")
69
+
70
+ # Context caching and validation utilities
71
+ def get_cached_blueprint_context(force_refresh=False):
72
+ """Get cached Blueprint context or fetch fresh if needed"""
73
+ global _blueprint_context_cache
74
+
75
+ current_time = time.time()
76
+ cache = _blueprint_context_cache
77
+
78
+ # Check if cache is valid and not forced to refresh
79
+ if (not force_refresh and
80
+ cache["cached_context"] and
81
+ (current_time - cache["cache_timestamp"]) < cache["cache_duration"]):
82
+ print(f"Using cached context (age: {current_time - cache['cache_timestamp']:.1f}s)")
83
+ return cache["cached_context"]
84
+
85
+ # Fetch fresh context
86
+ try:
87
+ context_cmd = {
88
+ "type": "get_blueprint_context",
89
+ "include_editor_state": True,
90
+ "include_graph_info": True,
91
+ "include_selected_nodes": True,
92
+ "include_open_editors": False
93
+ }
94
+
95
+ context_response = send_to_unreal(context_cmd)
96
+
97
+ if context_response.get("success"):
98
+ # Cache the context
99
+ cache["cached_context"] = context_response
100
+ cache["cache_timestamp"] = current_time
101
+ cache["cache_blueprint_path"] = context_response.get("active_blueprint", {}).get("path", "")
102
+
103
+ blueprint_name = context_response.get("active_blueprint", {}).get("name", "Unknown")
104
+ node_count = len(context_response.get("all_nodes", {}).get("nodes", []))
105
+ print(f"Context refreshed - Blueprint: {blueprint_name} ({node_count} nodes)")
106
+
107
+ return context_response
108
+ else:
109
+ return None
110
+
111
+ except Exception as e:
112
+ print(f"Error fetching Blueprint context: {e}")
113
+ return None
114
+
115
+ def validate_generated_function(implementation_result):
116
+ """Validate the generated function and identify issues"""
117
+ validation_result = {
118
+ "is_valid": True,
119
+ "issues": [],
120
+ "suggestions": [],
121
+ "needs_refinement": False
122
+ }
123
+
124
+ if not implementation_result.get("success"):
125
+ validation_result["is_valid"] = False
126
+ validation_result["issues"].append("Function creation failed")
127
+ validation_result["needs_refinement"] = True
128
+
129
+ errors = implementation_result.get("errors", [])
130
+ if errors:
131
+ validation_result["is_valid"] = False
132
+ validation_result["issues"].extend(errors)
133
+ validation_result["needs_refinement"] = True
134
+
135
+ # Analyze error types and suggest fixes
136
+ for error in errors:
137
+ if "node connection failed" in error.lower():
138
+ validation_result["suggestions"].append("Check node pin compatibility and availability")
139
+ elif "failed to add" in error.lower() and "node" in error.lower():
140
+ validation_result["suggestions"].append("Verify node type exists in current Unreal Engine version")
141
+ elif "compilation" in error.lower():
142
+ validation_result["suggestions"].append("Check Blueprint syntax and node logic")
143
+
144
+ # Check implementation completeness
145
+ created_nodes = implementation_result.get("created_nodes", [])
146
+ implementation_steps = implementation_result.get("implementation_steps", [])
147
+
148
+ if len(created_nodes) < 2:
149
+ validation_result["suggestions"].append("Function seems too simple, consider adding more logic nodes")
150
+
151
+ successful_steps = [step for step in implementation_steps if step.get("success")]
152
+ if len(successful_steps) < len(implementation_steps) * 0.8: # Less than 80% success rate
153
+ validation_result["needs_refinement"] = True
154
+ validation_result["suggestions"].append("Multiple implementation steps failed, function needs rework")
155
+
156
+ return validation_result
157
+
158
+ def refine_function_based_on_issues(original_generation, validation_result, context):
159
+ """Refine function generation based on validation issues and updated context"""
160
+
161
+ # Extract original specification
162
+ original_spec = original_generation.get("function_specification", {})
163
+ original_description = original_generation.get("function_description", "")
164
+
165
+ # Create refined specification
166
+ refined_spec = original_spec.copy()
167
+ refined_description = original_description
168
+
169
+ # Apply refinements based on issues
170
+ issues = validation_result.get("issues", [])
171
+ suggestions = validation_result.get("suggestions", [])
172
+
173
+ # Analyze what currently exists in the Blueprint to avoid duplication
174
+ existing_functions = []
175
+ existing_node_types = set()
176
+
177
+ if context and context.get("all_nodes", {}).get("nodes"):
178
+ for node in context["all_nodes"]["nodes"]:
179
+ node_class = node.get("class_name", "")
180
+ node_title = node.get("title", "")
181
+ existing_node_types.add(node_class)
182
+
183
+ # Look for function calls that might conflict
184
+ if "Function" in node_class or "Call Function" in node_title:
185
+ existing_functions.append(node_title)
186
+
187
+ # Check if our function might conflict with existing ones
188
+ proposed_name = refined_spec.get("function_name", "")
189
+ if any(proposed_name.lower() in func.lower() for func in existing_functions):
190
+ # Append iteration number to avoid conflict
191
+ iteration_num = original_generation.get("refinement_iteration", 0) + 1
192
+ refined_spec["function_name"] = f"{proposed_name}V{iteration_num}"
193
+ print(f"Function name conflict detected, renamed to: {refined_spec['function_name']}")
194
+
195
+ # Use existing node patterns from the Blueprint for better compatibility
196
+ commonly_used_nodes = []
197
+ if context and context.get("all_nodes", {}).get("nodes"):
198
+ node_count_map = {}
199
+ for node in context["all_nodes"]["nodes"]:
200
+ node_class = node.get("class_name", "")
201
+ if node_class.startswith("K2Node_"):
202
+ simple_name = node_class.replace("K2Node_", "").replace("_", " ")
203
+ node_count_map[simple_name] = node_count_map.get(simple_name, 0) + 1
204
+
205
+ # Get the top 3 most used node types
206
+ commonly_used_nodes = [node for node, count in sorted(node_count_map.items(), key=lambda x: x[1], reverse=True)[:3]]
207
+ print(f"Most commonly used nodes in Blueprint: {commonly_used_nodes}")
208
+
209
+ # Refine node suggestions based on errors
210
+ if any("node connection failed" in issue.lower() for issue in issues):
211
+ # Simplify connections - use more basic nodes
212
+ refined_spec["suggested_nodes"] = [
213
+ node for node in refined_spec.get("suggested_nodes", [])
214
+ if node in ["Branch", "Print String", "Set", "Get", "Add", "Multiply", "Greater", "Less"]
215
+ ]
216
+
217
+ # Add basic execution flow nodes
218
+ if "Branch" not in refined_spec["suggested_nodes"]:
219
+ refined_spec["suggested_nodes"].insert(0, "Branch")
220
+ if "Print String" not in refined_spec["suggested_nodes"]:
221
+ refined_spec["suggested_nodes"].append("Print String")
222
+
223
+ if any("failed to add" in issue.lower() and "node" in issue.lower() for issue in issues):
224
+ # Replace complex nodes with basic equivalents
225
+ node_replacements = {
226
+ "Launch Character": "Set Actor Location",
227
+ "Apply Damage": "Set Health",
228
+ "Spawn Actor from Class": "Create Object",
229
+ "Play Animation": "Set Animation Mode",
230
+ "Set Timer by Function Name": "Delay"
231
+ }
232
+
233
+ new_nodes = []
234
+ for node in refined_spec.get("suggested_nodes", []):
235
+ if node in node_replacements:
236
+ new_nodes.append(node_replacements[node])
237
+ else:
238
+ new_nodes.append(node)
239
+ refined_spec["suggested_nodes"] = new_nodes
240
+
241
+ # Prioritize commonly used nodes from the existing Blueprint
242
+ if commonly_used_nodes:
243
+ # Replace some suggested nodes with commonly used ones for better compatibility
244
+ final_nodes = []
245
+
246
+ # Start with commonly used nodes that make sense
247
+ for common_node in commonly_used_nodes:
248
+ if common_node not in ["Event", "Comment"] and len(final_nodes) < 3: # Skip event nodes and comments
249
+ final_nodes.append(common_node)
250
+
251
+ # Add some of the original suggestions
252
+ for suggested_node in refined_spec.get("suggested_nodes", []):
253
+ if suggested_node not in final_nodes and len(final_nodes) < 6:
254
+ final_nodes.append(suggested_node)
255
+
256
+ # Ensure we have basic nodes for functionality
257
+ essential_nodes = ["Branch", "Print String"]
258
+ for essential in essential_nodes:
259
+ if essential not in final_nodes and len(final_nodes) < 6:
260
+ final_nodes.append(essential)
261
+
262
+ refined_spec["suggested_nodes"] = final_nodes
263
+ print(f"Refined node suggestions (using common Blueprint patterns): {final_nodes}")
264
+
265
+ # Limit complexity for better success rate
266
+ if len(refined_spec.get("suggested_nodes", [])) > 6:
267
+ refined_spec["suggested_nodes"] = refined_spec["suggested_nodes"][:6]
268
+
269
+ # Create new generation result with refinements
270
+ refined_generation = original_generation.copy()
271
+ refined_generation["function_specification"] = refined_spec
272
+ refined_generation["refinement_iteration"] = original_generation.get("refinement_iteration", 0) + 1
273
+ refined_generation["refinement_reason"] = f"Issues found: {', '.join(issues[:3])}" # First 3 issues
274
+
275
+ # Update implementation plan
276
+ refined_plan = create_implementation_plan(refined_spec, original_generation.get("context_analysis", {}))
277
+ refined_generation["implementation_plan"] = refined_plan
278
+
279
+ return refined_generation
280
+
281
+ # Function to send a message to Unreal Engine via socket
282
+ # Falls back to Python Remote Execution or Web Remote Control if plugin socket unavailable
283
+ def send_to_unreal(command):
284
+ # Get Unreal Engine port from environment variable, default to 9878 (Unreal Engine's socket server)
285
+ unreal_port = int(os.environ.get('UNREAL_PORT', '9878'))
286
+
287
+ # Try 1: Plugin socket (fastest, full feature support)
288
+ try:
289
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
290
+ s.settimeout(5)
291
+ s.connect(('localhost', unreal_port))
292
+
293
+ json_str = json.dumps(command)
294
+ s.sendall(json_str.encode('utf-8'))
295
+
296
+ buffer_size = 8192
297
+ response_data = b""
298
+
299
+ while True:
300
+ chunk = s.recv(buffer_size)
301
+ if not chunk:
302
+ break
303
+ response_data += chunk
304
+
305
+ try:
306
+ json.loads(response_data.decode('utf-8'))
307
+ break
308
+ except json.JSONDecodeError:
309
+ continue
310
+
311
+ if response_data:
312
+ return json.loads(response_data.decode('utf-8'))
313
+ else:
314
+ return {"success": False, "error": "No response received"}
315
+ except ConnectionRefusedError:
316
+ print(f"Plugin socket unavailable (port {unreal_port}), trying fallback backends...", file=sys.stderr)
317
+ except Exception as e:
318
+ # For non-connection errors on the plugin socket, still try fallback
319
+ if "Connection refused" in str(e) or "No connection" in str(e) or "WinError 10061" in str(e):
320
+ print(f"Plugin socket unavailable, trying fallback backends...", file=sys.stderr)
321
+ else:
322
+ print(f"Error sending to Unreal via plugin: {e}", file=sys.stderr)
323
+ return {"success": False, "error": str(e)}
324
+
325
+ # Try 2: Generate native Python and execute via Remote Execution or Web Remote Control
326
+ return _send_via_fallback(command)
327
+
328
+
329
+ def _send_via_fallback(command):
330
+ """Execute a command via UE's built-in Remote Execution or Web Remote Control."""
331
+ try:
332
+ from ue_native_handler import build_handler_script
333
+ except ImportError:
334
+ return {"success": False, "error": "Fallback handler not available. Install the CreatelexGenAI plugin or ensure ue_native_handler.py is in the Python path."}
335
+
336
+ script = build_handler_script(command)
337
+
338
+ # Try Web Remote Control API first (HTTP, simpler)
339
+ result = _try_web_remote_control(script)
340
+ if result is not None:
341
+ return result
342
+
343
+ # Try Python Remote Execution (UDP multicast)
344
+ result = _try_remote_execution(script)
345
+ if result is not None:
346
+ return result
347
+
348
+ return {
349
+ "success": False,
350
+ "error": "No connection to Unreal Engine. The plugin socket, Web Remote Control, and Python Remote Execution are all unavailable. Start UE with at least one enabled."
351
+ }
352
+
353
+
354
+ def _try_web_remote_control(script):
355
+ """Try to execute a Python script via UE's Web Remote Control HTTP API."""
356
+ import urllib.request
357
+ import urllib.error
358
+
359
+ port = int(os.environ.get('WEB_REMOTE_PORT', '30010'))
360
+ url = f"http://127.0.0.1:{port}/remote/object/call"
361
+
362
+ payload = json.dumps({
363
+ "objectPath": "/Script/PythonScriptPlugin.Default__PythonScriptLibrary",
364
+ "functionName": "ExecutePythonCommandEx",
365
+ "parameters": {
366
+ "PythonCommand": script,
367
+ "ExecutionMode": "ExecuteStatement",
368
+ "FileExecutionScope": "Public"
369
+ }
370
+ }).encode('utf-8')
371
+
372
+ try:
373
+ req = urllib.request.Request(url, data=payload, headers={"Content-Type": "application/json"}, method="PUT")
374
+ with urllib.request.urlopen(req, timeout=30) as resp:
375
+ data = json.loads(resp.read().decode('utf-8'))
376
+ # Try to extract the printed JSON result from output
377
+ output = str(data.get("ReturnValue", data.get("result", "")))
378
+ if output:
379
+ try:
380
+ return json.loads(output)
381
+ except json.JSONDecodeError:
382
+ pass
383
+ return data
384
+ except urllib.error.URLError:
385
+ return None
386
+ except Exception as e:
387
+ print(f"Web Remote Control failed: {e}", file=sys.stderr)
388
+ return None
389
+
390
+
391
+ def _try_remote_execution(script):
392
+ """Try to execute a Python script via UE's built-in Remote Execution protocol (UDP/TCP)."""
393
+ import threading
394
+ import queue
395
+
396
+ MULTICAST_GROUP = '239.0.0.1'
397
+ MULTICAST_PORT = 6766
398
+
399
+ try:
400
+ import uuid
401
+ client_id = str(uuid.uuid4())
402
+ except ImportError:
403
+ import hashlib
404
+ client_id = hashlib.md5(str(time.time()).encode()).hexdigest()
405
+
406
+ result_queue = queue.Queue()
407
+
408
+ # Step 1: Start a TCP server to receive UE's connection
409
+ tcp_server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
410
+ tcp_server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
411
+ tcp_server.settimeout(10)
412
+ tcp_server.bind(('127.0.0.1', 0))
413
+ tcp_port = tcp_server.getsockname()[1]
414
+ tcp_server.listen(1)
415
+
416
+ def handle_connection():
417
+ try:
418
+ conn, addr = tcp_server.accept()
419
+ conn.settimeout(30)
420
+
421
+ # Send the command
422
+ cmd_msg = json.dumps({
423
+ "magic": "ue_py",
424
+ "version": 1,
425
+ "type": "command",
426
+ "source": client_id,
427
+ "command": script,
428
+ "unattended": False,
429
+ "exec_mode": "ExecuteStatement"
430
+ })
431
+ conn.sendall(cmd_msg.encode('utf-8'))
432
+
433
+ # Receive response
434
+ response_data = b""
435
+ while True:
436
+ try:
437
+ chunk = conn.recv(8192)
438
+ if not chunk:
439
+ break
440
+ response_data += chunk
441
+ try:
442
+ json.loads(response_data.decode('utf-8'))
443
+ break
444
+ except json.JSONDecodeError:
445
+ continue
446
+ except socket.timeout:
447
+ break
448
+
449
+ conn.close()
450
+
451
+ if response_data:
452
+ resp = json.loads(response_data.decode('utf-8'))
453
+ # Extract output from Remote Execution response
454
+ output_log = resp.get("output_log", [])
455
+ output_text = ""
456
+ for entry in output_log:
457
+ if isinstance(entry, dict):
458
+ output_text += entry.get("message", "") + "\n"
459
+ else:
460
+ output_text += str(entry) + "\n"
461
+
462
+ # Try to parse the output as JSON (our handler prints JSON)
463
+ for line in output_text.strip().split("\n"):
464
+ line = line.strip()
465
+ if line.startswith("{"):
466
+ try:
467
+ result_queue.put(json.loads(line))
468
+ return
469
+ except json.JSONDecodeError:
470
+ pass
471
+
472
+ result_queue.put({"success": resp.get("success", False), "output": output_text.strip()})
473
+ else:
474
+ result_queue.put(None)
475
+ except Exception as e:
476
+ result_queue.put(None)
477
+ finally:
478
+ tcp_server.close()
479
+
480
+ # Start TCP handler in background thread
481
+ tcp_thread = threading.Thread(target=handle_connection, daemon=True)
482
+ tcp_thread.start()
483
+
484
+ # Step 2: Send open_connection via UDP multicast
485
+ try:
486
+ udp_sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP)
487
+ udp_sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
488
+ udp_sock.settimeout(2)
489
+
490
+ open_msg = json.dumps({
491
+ "magic": "ue_py",
492
+ "version": 1,
493
+ "type": "open_connection",
494
+ "source": client_id,
495
+ "dest": "",
496
+ "command_ip": "127.0.0.1",
497
+ "command_port": tcp_port
498
+ })
499
+
500
+ udp_sock.sendto(open_msg.encode('utf-8'), (MULTICAST_GROUP, MULTICAST_PORT))
501
+ udp_sock.close()
502
+ except Exception as e:
503
+ tcp_server.close()
504
+ print(f"Remote Execution UDP failed: {e}", file=sys.stderr)
505
+ return None
506
+
507
+ # Step 3: Wait for result
508
+ tcp_thread.join(timeout=15)
509
+
510
+ try:
511
+ result = result_queue.get_nowait()
512
+ return result
513
+ except queue.Empty:
514
+ return None
515
+
516
+ def _ensure_dict_response(response):
517
+ """Normalize Unreal responses to dictionaries for cross-version compatibility."""
518
+ if isinstance(response, dict):
519
+ return response
520
+ if isinstance(response, str):
521
+ try:
522
+ return json.loads(response)
523
+ except json.JSONDecodeError as parse_err:
524
+ return {"success": False, "error": f"Invalid JSON response: {parse_err}", "raw": response}
525
+ return {"success": False, "error": "Unsupported response type from Unreal", "raw": repr(response)}
526
+
527
+
528
+ _HOST_SLICE_IMPORT_MARKER = "__CREATELEX_HOST_SLICE_IMPORT_RESULT__"
529
+
530
+
531
+ def _response_text_blob(response: Dict[str, Any]) -> str:
532
+ if not isinstance(response, dict):
533
+ return str(response or "")
534
+ fields = [
535
+ response.get("error"),
536
+ response.get("message"),
537
+ response.get("details"),
538
+ response.get("raw"),
539
+ response.get("output"),
540
+ ]
541
+ return " ".join(str(field) for field in fields if field)
542
+
543
+
544
+ def _is_unreal_pillow_error(response: Dict[str, Any]) -> bool:
545
+ text = _response_text_blob(response).lower()
546
+ if "pillow" not in text:
547
+ return False
548
+ return any(
549
+ token in text
550
+ for token in [
551
+ "not available",
552
+ "install pillow",
553
+ "no module named",
554
+ "python imaging library",
555
+ ]
556
+ )
557
+
558
+
559
+ def _extract_host_import_result(output: str) -> Optional[Dict[str, Any]]:
560
+ if not output:
561
+ return None
562
+ for line in output.splitlines():
563
+ stripped = line.strip()
564
+ if not stripped.startswith(_HOST_SLICE_IMPORT_MARKER):
565
+ continue
566
+ payload = stripped[len(_HOST_SLICE_IMPORT_MARKER):].strip()
567
+ if not payload:
568
+ continue
569
+ try:
570
+ parsed = json.loads(payload)
571
+ if isinstance(parsed, dict):
572
+ return parsed
573
+ except json.JSONDecodeError:
574
+ return None
575
+ return None
576
+
577
+
578
+ def _build_unreal_import_script(slice_entries: List[Dict[str, str]], destination_folder: str) -> str:
579
+ entries_json = json.dumps(slice_entries, separators=(",", ":"), ensure_ascii=False)
580
+ return "\n".join(
581
+ [
582
+ "import json",
583
+ "import unreal",
584
+ f"entries = json.loads({json.dumps(entries_json)})",
585
+ f"destination_folder = {json.dumps(destination_folder)}",
586
+ f"result_marker = {json.dumps(_HOST_SLICE_IMPORT_MARKER)}",
587
+ "asset_tools = unreal.AssetToolsHelpers.get_asset_tools()",
588
+ "imported = []",
589
+ "failures = []",
590
+ "for entry in entries:",
591
+ " name = str(entry.get('name') or '').strip()",
592
+ " file_path = str(entry.get('file_path') or '').strip()",
593
+ " if not name or not file_path:",
594
+ " failures.append({'name': name, 'file_path': file_path, 'error': 'Missing name or file_path'})",
595
+ " continue",
596
+ " task = unreal.AssetImportTask()",
597
+ " task.filename = file_path",
598
+ " task.destination_path = destination_folder",
599
+ " task.destination_name = name",
600
+ " task.automated = True",
601
+ " task.replace_existing = True",
602
+ " task.save = True",
603
+ " try:",
604
+ " asset_tools.import_asset_tasks([task])",
605
+ " except Exception as import_error:",
606
+ " failures.append({'name': name, 'file_path': file_path, 'error': str(import_error)})",
607
+ " continue",
608
+ " imported_paths = [str(path) for path in (task.imported_object_paths or []) if path]",
609
+ " asset_path = imported_paths[0] if imported_paths else ''",
610
+ " if asset_path:",
611
+ " imported.append({'name': name, 'asset_path': asset_path, 'imported_object_paths': imported_paths})",
612
+ " else:",
613
+ " failures.append({'name': name, 'file_path': file_path, 'error': 'Import succeeded with no object path'})",
614
+ "result = {'success': len(imported) > 0, 'imported': imported, 'failures': failures}",
615
+ "print(result_marker + json.dumps(result, separators=(',', ':')))",
616
+ ]
617
+ )
618
+
619
+
620
+ def _get_manifest_component_slices(manifest: Dict[str, Any]) -> List[Dict[str, Any]]:
621
+ if not isinstance(manifest, dict):
622
+ return []
623
+ slices = manifest.get("slices", [])
624
+ if not isinstance(slices, list):
625
+ return []
626
+ return [
627
+ slice_info
628
+ for slice_info in slices
629
+ if isinstance(slice_info, dict) and str(slice_info.get("kind") or "").lower() != "full_mockup"
630
+ ]
631
+
632
+
633
+ def _manifest_component_count(manifest: Dict[str, Any]) -> int:
634
+ return len(_get_manifest_component_slices(manifest))
635
+
636
+
637
+ def _should_retry_aggressive_slice(manifest: Dict[str, Any]) -> bool:
638
+ # If "auto" effectively produced only a background/full-image result, force one aggressive retry.
639
+ return _manifest_component_count(manifest) <= 1
640
+
641
+
642
+ def _pick_better_slice_response(
643
+ primary_response: Dict[str, Any],
644
+ aggressive_response: Dict[str, Any],
645
+ ) -> Dict[str, Any]:
646
+ primary_manifest = primary_response.get("manifest", {}) if isinstance(primary_response, dict) else {}
647
+ aggressive_manifest = aggressive_response.get("manifest", {}) if isinstance(aggressive_response, dict) else {}
648
+ if _manifest_component_count(aggressive_manifest) > _manifest_component_count(primary_manifest):
649
+ aggressive_response["auto_retry_aggressive"] = True
650
+ aggressive_response["retry_reason"] = (
651
+ "Initial auto segmentation yielded too few component slices; aggressive retry selected."
652
+ )
653
+ return aggressive_response
654
+ return primary_response
655
+
656
+
657
+ def _run_host_slice_import(manifest: Dict[str, Any], destination_folder: str) -> Dict[str, Any]:
658
+ slices = manifest.get("slices", []) if isinstance(manifest, dict) else []
659
+ if not isinstance(slices, list) or not slices:
660
+ return {"success": False, "error": "Host slicing manifest is empty."}
661
+
662
+ slice_entries: List[Dict[str, str]] = []
663
+ for slice_info in slices:
664
+ if not isinstance(slice_info, dict):
665
+ continue
666
+ name = str(slice_info.get("name") or "").strip()
667
+ file_path = str(slice_info.get("file_path") or "").strip()
668
+ if name and file_path:
669
+ slice_entries.append({"name": name, "file_path": file_path})
670
+
671
+ if not slice_entries:
672
+ return {"success": False, "error": "Host slicing produced no valid files to import."}
673
+
674
+ script = _build_unreal_import_script(slice_entries, destination_folder)
675
+ import_response = _ensure_dict_response(send_to_unreal({"type": "execute_python", "script": script}))
676
+ if not import_response.get("success"):
677
+ return {
678
+ "success": False,
679
+ "error": f"Failed to import host-generated slices in Unreal: {_response_text_blob(import_response)}",
680
+ }
681
+
682
+ import_result = _extract_host_import_result(import_response.get("output", ""))
683
+ if not import_result:
684
+ return {
685
+ "success": False,
686
+ "error": "Unreal import script ran, but no parseable import result marker was returned.",
687
+ "output": import_response.get("output", ""),
688
+ }
689
+
690
+ imported_map = {
691
+ str(item.get("name")): str(item.get("asset_path"))
692
+ for item in import_result.get("imported", [])
693
+ if isinstance(item, dict) and item.get("name") and item.get("asset_path")
694
+ }
695
+
696
+ imported_count = 0
697
+ for slice_info in slices:
698
+ if not isinstance(slice_info, dict):
699
+ continue
700
+ asset_path = imported_map.get(str(slice_info.get("name")))
701
+ if asset_path:
702
+ slice_info["asset_path"] = asset_path
703
+ imported_count += 1
704
+
705
+ if imported_count <= 0:
706
+ return {
707
+ "success": False,
708
+ "error": "Host slices were generated but no assets were imported into Unreal.",
709
+ "import_result": import_result,
710
+ }
711
+
712
+ non_fallback_assets = [
713
+ slice_info.get("asset_path")
714
+ for slice_info in slices
715
+ if isinstance(slice_info, dict) and slice_info.get("asset_path") and not slice_info.get("is_fallback")
716
+ ]
717
+ any_assets = [
718
+ slice_info.get("asset_path")
719
+ for slice_info in slices
720
+ if isinstance(slice_info, dict) and slice_info.get("asset_path")
721
+ ]
722
+ manifest["background_asset_path"] = (
723
+ non_fallback_assets[0] if non_fallback_assets else (any_assets[0] if any_assets else "")
724
+ )
725
+
726
+ return {
727
+ "success": True,
728
+ "imported_count": imported_count,
729
+ "import_result": import_result,
730
+ "background_asset_path": manifest.get("background_asset_path", ""),
731
+ }
732
+
733
+
734
+ def _run_auto_slice_with_fallback(
735
+ source_image_path: str,
736
+ destination_folder: str,
737
+ base_name: str,
738
+ max_slices: int,
739
+ min_component_area_ratio: float,
740
+ background_tolerance: int,
741
+ slice_mode: str,
742
+ include_full_image: bool,
743
+ enable_chroma_key: bool = True,
744
+ chroma_tolerance: int = 24,
745
+ enable_host_fallback: bool = True,
746
+ ) -> Dict[str, Any]:
747
+ command = {
748
+ "type": "auto_slice_ui_mockup",
749
+ "source_image_path": source_image_path,
750
+ "destination_folder": destination_folder,
751
+ "base_name": base_name,
752
+ "max_slices": max_slices,
753
+ "min_component_area_ratio": min_component_area_ratio,
754
+ "background_tolerance": background_tolerance,
755
+ "slice_mode": slice_mode,
756
+ "include_full_image": include_full_image,
757
+ "enable_chroma_key": enable_chroma_key,
758
+ "chroma_tolerance": chroma_tolerance,
759
+ }
760
+
761
+ unreal_response = _ensure_dict_response(send_to_unreal(command))
762
+ if unreal_response.get("success"):
763
+ if slice_mode == "auto":
764
+ initial_manifest = unreal_response.get("manifest", {})
765
+ if _should_retry_aggressive_slice(initial_manifest):
766
+ aggressive_command = dict(command)
767
+ aggressive_command["slice_mode"] = "aggressive"
768
+ aggressive_response = _ensure_dict_response(send_to_unreal(aggressive_command))
769
+ if aggressive_response.get("success"):
770
+ return _pick_better_slice_response(unreal_response, aggressive_response)
771
+ return unreal_response
772
+ if not enable_host_fallback:
773
+ return unreal_response
774
+ if not _is_unreal_pillow_error(unreal_response):
775
+ return unreal_response
776
+
777
+ if auto_slice_ui_mockup_host is None:
778
+ return {
779
+ "success": False,
780
+ "error": (
781
+ "Unreal auto-slice failed because Pillow is missing, and host fallback module is unavailable. "
782
+ f"Original error: {_response_text_blob(unreal_response)}"
783
+ ),
784
+ }
785
+
786
+ host_has_pillow, host_pillow_error = host_slice_has_pillow()
787
+ if not host_has_pillow:
788
+ return {
789
+ "success": False,
790
+ "error": (
791
+ "Unreal auto-slice failed because Pillow is missing, and host fallback also lacks Pillow. "
792
+ f"Host import error: {host_pillow_error}"
793
+ ),
794
+ }
795
+
796
+ host_result = auto_slice_ui_mockup_host(
797
+ source_image_path=source_image_path,
798
+ destination_folder=destination_folder,
799
+ base_name=base_name,
800
+ max_slices=max_slices,
801
+ min_component_area_ratio=min_component_area_ratio,
802
+ background_tolerance=background_tolerance,
803
+ include_full_image=include_full_image,
804
+ slice_mode=slice_mode,
805
+ enable_chroma_key=enable_chroma_key,
806
+ chroma_tolerance=chroma_tolerance,
807
+ )
808
+
809
+ temp_dirs = set()
810
+ if isinstance(host_result, dict) and host_result.get("temp_dir"):
811
+ temp_dirs.add(str(host_result.get("temp_dir")))
812
+ try:
813
+ if not isinstance(host_result, dict) or not host_result.get("success"):
814
+ host_error = (
815
+ host_result.get("error", "Unknown host slicing error.")
816
+ if isinstance(host_result, dict)
817
+ else "Unknown host slicing error."
818
+ )
819
+ return {
820
+ "success": False,
821
+ "error": (
822
+ "Unreal auto-slice failed because Pillow is missing, and host fallback failed. "
823
+ f"Unreal error: {_response_text_blob(unreal_response)} Host error: {host_error}"
824
+ ),
825
+ }
826
+
827
+ if slice_mode == "auto":
828
+ initial_manifest = host_result.get("manifest", {}) if isinstance(host_result, dict) else {}
829
+ if _should_retry_aggressive_slice(initial_manifest):
830
+ aggressive_host_result = auto_slice_ui_mockup_host(
831
+ source_image_path=source_image_path,
832
+ destination_folder=destination_folder,
833
+ base_name=base_name,
834
+ max_slices=max_slices,
835
+ min_component_area_ratio=min_component_area_ratio,
836
+ background_tolerance=background_tolerance,
837
+ include_full_image=include_full_image,
838
+ slice_mode="aggressive",
839
+ enable_chroma_key=enable_chroma_key,
840
+ chroma_tolerance=chroma_tolerance,
841
+ )
842
+ if isinstance(aggressive_host_result, dict) and aggressive_host_result.get("temp_dir"):
843
+ temp_dirs.add(str(aggressive_host_result.get("temp_dir")))
844
+ if isinstance(aggressive_host_result, dict) and aggressive_host_result.get("success"):
845
+ host_result = _pick_better_slice_response(host_result, aggressive_host_result)
846
+
847
+ manifest = host_result.get("manifest")
848
+ if not isinstance(manifest, dict):
849
+ return {"success": False, "error": "Host fallback returned no valid manifest."}
850
+
851
+ import_result = _run_host_slice_import(manifest, destination_folder)
852
+ if not import_result.get("success"):
853
+ return {
854
+ "success": False,
855
+ "error": (
856
+ "Host slicing succeeded but Unreal import failed during fallback. "
857
+ f"{import_result.get('error', 'Unknown import error.')}"
858
+ ),
859
+ "fallback_reason": _response_text_blob(unreal_response),
860
+ "host_import_result": import_result.get("import_result"),
861
+ }
862
+
863
+ return {
864
+ "success": True,
865
+ "message": (
866
+ f"Generated and imported {import_result.get('imported_count', 0)} texture assets via host fallback."
867
+ ),
868
+ "slice_count": int(host_result.get("slice_count", import_result.get("imported_count", 0))),
869
+ "background_asset_path": import_result.get("background_asset_path", ""),
870
+ "segmentation_mode": host_result.get("segmentation_mode", ""),
871
+ "segmentation_metrics": host_result.get("segmentation_metrics", {}),
872
+ "manifest": manifest,
873
+ "host_fallback_used": True,
874
+ "fallback_reason": _response_text_blob(unreal_response),
875
+ }
876
+ finally:
877
+ for temp_dir in temp_dirs:
878
+ if temp_dir and os.path.isdir(temp_dir):
879
+ try:
880
+ shutil.rmtree(temp_dir, ignore_errors=True)
881
+ except Exception:
882
+ pass
883
+
884
+
885
+ def _collect_widget_nodes(hierarchy_node: Optional[Dict[str, Any]], out_nodes: List[Dict[str, str]]) -> None:
886
+ if not isinstance(hierarchy_node, dict):
887
+ return
888
+ widget_name = str(hierarchy_node.get("name") or "").strip()
889
+ widget_class = str(hierarchy_node.get("class") or "").strip()
890
+ if widget_name:
891
+ out_nodes.append({"name": widget_name, "class": widget_class})
892
+
893
+ children = hierarchy_node.get("children", [])
894
+ if not isinstance(children, list):
895
+ return
896
+ for child in children:
897
+ child_obj = child
898
+ if isinstance(child, dict) and "Object" in child and isinstance(child.get("Object"), dict):
899
+ child_obj = child.get("Object")
900
+ _collect_widget_nodes(child_obj, out_nodes)
901
+
902
+
903
+ def _is_panel_class_name(widget_class_name: str) -> bool:
904
+ class_lc = (widget_class_name or "").lower()
905
+ return any(
906
+ token in class_lc
907
+ for token in [
908
+ "panel",
909
+ "canvas",
910
+ "verticalbox",
911
+ "horizontalbox",
912
+ "overlay",
913
+ "grid",
914
+ "wrapbox",
915
+ "scrollbox",
916
+ "sizebox",
917
+ "border",
918
+ "namedslot",
919
+ "widgetswitcher",
920
+ ]
921
+ )
922
+
923
+
924
+ def _is_button_like_slice_manifest_entry(slice_info: Dict[str, Any], manifest: Dict[str, Any]) -> bool:
925
+ if not isinstance(slice_info, dict):
926
+ return False
927
+
928
+ kind = str(slice_info.get("kind") or "").lower()
929
+ label = str(slice_info.get("label") or "").lower()
930
+ name = str(slice_info.get("name") or "").lower()
931
+ if "button" in kind or "button" in label or "button" in name:
932
+ return True
933
+
934
+ source_width = float(manifest.get("source_width") or 0.0)
935
+ source_height = float(manifest.get("source_height") or 0.0)
936
+ bbox = slice_info.get("bbox", [])
937
+ if (
938
+ source_width <= 0.0
939
+ or source_height <= 0.0
940
+ or not isinstance(bbox, list)
941
+ or len(bbox) != 4
942
+ ):
943
+ return False
944
+
945
+ try:
946
+ min_x, min_y, max_x, max_y = [float(value) for value in bbox]
947
+ except (TypeError, ValueError):
948
+ return False
949
+
950
+ width = max(1.0, max_x - min_x + 1.0)
951
+ height = max(1.0, max_y - min_y + 1.0)
952
+ width_ratio = width / source_width
953
+ height_ratio = height / source_height
954
+ area_ratio = (width * height) / max(1.0, source_width * source_height)
955
+ center_y_ratio = ((min_y + max_y) * 0.5) / source_height
956
+ aspect_ratio = width / max(1.0, height)
957
+
958
+ return (
959
+ width_ratio >= 0.15
960
+ and width_ratio <= 0.92
961
+ and height_ratio >= 0.03
962
+ and height_ratio <= 0.25
963
+ and area_ratio <= 0.20
964
+ and center_y_ratio >= 0.40
965
+ and aspect_ratio >= 1.5
966
+ )
967
+
968
+
969
+ def _button_slice_priority(slice_info: Dict[str, Any], manifest: Dict[str, Any]) -> tuple:
970
+ kind = str(slice_info.get("kind") or "").lower()
971
+ explicit_priority = 0
972
+ if kind == "button_primary":
973
+ explicit_priority = 3
974
+ elif kind == "button_secondary":
975
+ explicit_priority = 2
976
+ elif "button" in kind:
977
+ explicit_priority = 1
978
+
979
+ area = 0.0
980
+ try:
981
+ area = float(slice_info.get("area") or 0.0)
982
+ except (TypeError, ValueError):
983
+ area = 0.0
984
+
985
+ is_heuristic_button = 1 if _is_button_like_slice_manifest_entry(slice_info, manifest) else 0
986
+ return (explicit_priority, is_heuristic_button, area)
987
+
988
+
989
+ def _is_background_like_slice_manifest_entry(slice_info: Dict[str, Any]) -> bool:
990
+ if not isinstance(slice_info, dict):
991
+ return False
992
+ kind = str(slice_info.get("kind") or "").lower()
993
+ label = str(slice_info.get("label") or "").lower()
994
+ return kind in {"full_mockup", "panel_main", "header"} or any(
995
+ token in kind or token in label
996
+ for token in ["background", "panel", "header", "full"]
997
+ )
998
+
999
+
1000
+ def _preferred_widget_base_name(slice_info: Dict[str, Any], manifest: Dict[str, Any]) -> str:
1001
+ kind = str(slice_info.get("kind") or "").lower()
1002
+ label = str(slice_info.get("label") or "").lower()
1003
+ if _is_button_like_slice_manifest_entry(slice_info, manifest):
1004
+ if "primary" in kind or "primary" in label:
1005
+ return "PrimaryButton"
1006
+ if "secondary" in kind or "secondary" in label:
1007
+ return "SecondaryButton"
1008
+ return "ActionButton"
1009
+ if _is_background_like_slice_manifest_entry(slice_info):
1010
+ if "header" in kind or "header" in label:
1011
+ return "HeaderImage"
1012
+ if "panel" in kind or "panel" in label:
1013
+ return "MainPanelImage"
1014
+ return "BackgroundImage"
1015
+ if "icon_left" in kind:
1016
+ return "LeftIconImage"
1017
+ if "icon_right" in kind:
1018
+ return "RightIconImage"
1019
+ if "icon" in kind or "icon" in label:
1020
+ return "IconImage"
1021
+ if "star" in kind or "star" in label:
1022
+ return "StarImage"
1023
+ return "SliceImage"
1024
+
1025
+
1026
+ def _make_unique_widget_name(base_name: str, existing_names: set) -> str:
1027
+ candidate = base_name
1028
+ suffix = 1
1029
+ while candidate in existing_names:
1030
+ suffix += 1
1031
+ candidate = f"{base_name}_{suffix}"
1032
+ return candidate
1033
+
1034
+
1035
+ def _ensure_slice_target_widgets_for_manifest(
1036
+ user_widget_path: str,
1037
+ manifest: Dict[str, Any],
1038
+ max_auto_components: int = 12,
1039
+ max_auto_buttons: int = 3,
1040
+ ) -> Dict[str, Any]:
1041
+ if not isinstance(manifest, dict):
1042
+ return {"success": False, "error": "Invalid manifest for target widget provisioning."}
1043
+
1044
+ hierarchy_response = _ensure_dict_response(
1045
+ send_to_unreal({"type": "get_widget_hierarchy", "user_widget_path": user_widget_path})
1046
+ )
1047
+ if not hierarchy_response.get("success"):
1048
+ return {
1049
+ "success": False,
1050
+ "error": (
1051
+ "Failed to inspect widget hierarchy before target provisioning: "
1052
+ f"{hierarchy_response.get('error', 'Unknown error')}"
1053
+ ),
1054
+ }
1055
+
1056
+ hierarchy_root = hierarchy_response.get("hierarchy")
1057
+ widget_nodes: List[Dict[str, str]] = []
1058
+ _collect_widget_nodes(hierarchy_root, widget_nodes)
1059
+
1060
+ existing_names = {node.get("name", "") for node in widget_nodes if node.get("name")}
1061
+ existing_image_names = [
1062
+ node.get("name", "")
1063
+ for node in widget_nodes
1064
+ if "image" in (node.get("class", "").lower())
1065
+ ]
1066
+ existing_button_names = [
1067
+ node.get("name", "")
1068
+ for node in widget_nodes
1069
+ if "button" in (node.get("class", "").lower())
1070
+ ]
1071
+ background_like_images = [
1072
+ name
1073
+ for name in existing_image_names
1074
+ if any(token in name.lower() for token in ["background", "bg", "frame", "panel", "root"])
1075
+ ]
1076
+ component_image_budget = max(0, len(existing_image_names) - len(background_like_images))
1077
+
1078
+ root_name = ""
1079
+ root_class = ""
1080
+ if isinstance(hierarchy_root, dict):
1081
+ root_name = str(hierarchy_root.get("name") or "").strip()
1082
+ root_class = str(hierarchy_root.get("class") or "").strip()
1083
+ parent_widget_name = root_name if (root_name and _is_panel_class_name(root_class)) else ""
1084
+
1085
+ slices = manifest.get("slices", [])
1086
+ if not isinstance(slices, list) or not slices:
1087
+ return {"success": True, "added": [], "message": "No slices in manifest."}
1088
+
1089
+ component_slices = [
1090
+ slice_info
1091
+ for slice_info in slices
1092
+ if isinstance(slice_info, dict) and str(slice_info.get("kind") or "").lower() != "full_mockup"
1093
+ ]
1094
+ component_slices.sort(
1095
+ key=lambda entry: float(entry.get("area", 0) or 0),
1096
+ reverse=True,
1097
+ )
1098
+ if max_auto_components > 0:
1099
+ component_slices = component_slices[:max_auto_components]
1100
+
1101
+ add_results = []
1102
+ add_failures = []
1103
+
1104
+ def add_widget(widget_type: str, base_name: str) -> Optional[str]:
1105
+ nonlocal add_results, add_failures
1106
+ unique_name = _make_unique_widget_name(base_name, existing_names)
1107
+ add_command = {
1108
+ "type": "add_widget_to_user_widget",
1109
+ "user_widget_path": user_widget_path,
1110
+ "widget_type": widget_type,
1111
+ "widget_name": unique_name,
1112
+ "parent_widget_name": parent_widget_name,
1113
+ }
1114
+ add_response = _ensure_dict_response(send_to_unreal(add_command))
1115
+ if add_response.get("success"):
1116
+ actual_name = str(add_response.get("widget_name") or unique_name)
1117
+ existing_names.add(actual_name)
1118
+ add_results.append({"widget_type": widget_type, "widget_name": actual_name})
1119
+ return actual_name
1120
+ add_failures.append(
1121
+ {
1122
+ "widget_type": widget_type,
1123
+ "requested_name": unique_name,
1124
+ "error": add_response.get("error", "Unknown error"),
1125
+ }
1126
+ )
1127
+ return None
1128
+
1129
+ if not existing_image_names:
1130
+ background_name = "BackgroundImage"
1131
+ add_widget("Image", background_name)
1132
+ existing_image_names = [entry["widget_name"] for entry in add_results if entry["widget_type"] == "Image"]
1133
+
1134
+ created_button_count = 0
1135
+ for slice_info in component_slices:
1136
+ is_button_slice = _is_button_like_slice_manifest_entry(slice_info, manifest)
1137
+ if is_button_slice:
1138
+ if existing_button_names:
1139
+ existing_button_names.pop(0)
1140
+ continue
1141
+ if created_button_count >= max_auto_buttons:
1142
+ continue
1143
+ base_name = _preferred_widget_base_name(slice_info, manifest)
1144
+ created_name = add_widget("Button", base_name)
1145
+ if created_name:
1146
+ created_button_count += 1
1147
+ continue
1148
+
1149
+ if component_image_budget > 0:
1150
+ component_image_budget -= 1
1151
+ continue
1152
+ base_name = _preferred_widget_base_name(slice_info, manifest)
1153
+ created_name = add_widget("Image", base_name)
1154
+ if created_name:
1155
+ component_image_budget += 0
1156
+
1157
+ return {
1158
+ "success": len(add_failures) == 0,
1159
+ "added": add_results,
1160
+ "failures": add_failures,
1161
+ "existing_image_count": len(existing_image_names),
1162
+ "existing_button_count": len(
1163
+ [
1164
+ node.get("name", "")
1165
+ for node in widget_nodes
1166
+ if "button" in (node.get("class", "").lower())
1167
+ ]
1168
+ ),
1169
+ "parent_widget_name": parent_widget_name,
1170
+ }
1171
+
1172
+
1173
+ def _ensure_button_widgets_for_manifest(
1174
+ user_widget_path: str,
1175
+ manifest: Dict[str, Any],
1176
+ max_auto_buttons: int = 2,
1177
+ ) -> Dict[str, Any]:
1178
+ if not isinstance(manifest, dict):
1179
+ return {"success": False, "error": "Invalid manifest for button provisioning."}
1180
+ if max_auto_buttons <= 0:
1181
+ return {"success": True, "added": [], "message": "Button auto-provisioning disabled."}
1182
+
1183
+ hierarchy_response = _ensure_dict_response(
1184
+ send_to_unreal({"type": "get_widget_hierarchy", "user_widget_path": user_widget_path})
1185
+ )
1186
+ if not hierarchy_response.get("success"):
1187
+ return {
1188
+ "success": False,
1189
+ "error": (
1190
+ f"Failed to inspect widget hierarchy before button provisioning: "
1191
+ f"{hierarchy_response.get('error', 'Unknown error')}"
1192
+ ),
1193
+ }
1194
+
1195
+ hierarchy_root = hierarchy_response.get("hierarchy")
1196
+ widget_nodes: List[Dict[str, str]] = []
1197
+ _collect_widget_nodes(hierarchy_root, widget_nodes)
1198
+
1199
+ existing_names = {node.get("name", "") for node in widget_nodes if node.get("name")}
1200
+ existing_button_names = [
1201
+ node.get("name", "")
1202
+ for node in widget_nodes
1203
+ if "button" in (node.get("class", "").lower())
1204
+ ]
1205
+
1206
+ slices = manifest.get("slices", [])
1207
+ if not isinstance(slices, list) or not slices:
1208
+ return {"success": True, "added": [], "message": "No slices in manifest."}
1209
+
1210
+ button_candidates = [
1211
+ slice_info
1212
+ for slice_info in slices
1213
+ if isinstance(slice_info, dict)
1214
+ and str(slice_info.get("kind") or "").lower() != "full_mockup"
1215
+ and _is_button_like_slice_manifest_entry(slice_info, manifest)
1216
+ ]
1217
+ if not button_candidates:
1218
+ return {"success": True, "added": [], "message": "No button-like slices detected."}
1219
+
1220
+ button_candidates.sort(
1221
+ key=lambda entry: _button_slice_priority(entry, manifest),
1222
+ reverse=True,
1223
+ )
1224
+ desired_button_count = min(max_auto_buttons, len(button_candidates))
1225
+ if len(existing_button_names) >= desired_button_count:
1226
+ return {
1227
+ "success": True,
1228
+ "added": [],
1229
+ "message": (
1230
+ f"Button widgets already present ({len(existing_button_names)}), no provisioning needed."
1231
+ ),
1232
+ }
1233
+
1234
+ root_name = ""
1235
+ root_class = ""
1236
+ if isinstance(hierarchy_root, dict):
1237
+ root_name = str(hierarchy_root.get("name") or "").strip()
1238
+ root_class = str(hierarchy_root.get("class") or "").strip()
1239
+ parent_widget_name = root_name if (root_name and _is_panel_class_name(root_class)) else ""
1240
+
1241
+ preferred_names = ["PrimaryButton", "SecondaryButton", "TertiaryButton", "QuaternaryButton"]
1242
+ added = []
1243
+ provision_failures = []
1244
+ needed = max(0, desired_button_count - len(existing_button_names))
1245
+ for index in range(needed):
1246
+ base_name = preferred_names[index] if index < len(preferred_names) else f"AutoButton{index + 1}"
1247
+ candidate_name = base_name
1248
+ suffix = 1
1249
+ while candidate_name in existing_names:
1250
+ suffix += 1
1251
+ candidate_name = f"{base_name}_{suffix}"
1252
+
1253
+ add_command = {
1254
+ "type": "add_widget_to_user_widget",
1255
+ "user_widget_path": user_widget_path,
1256
+ "widget_type": "Button",
1257
+ "widget_name": candidate_name,
1258
+ "parent_widget_name": parent_widget_name,
1259
+ }
1260
+ add_response = _ensure_dict_response(send_to_unreal(add_command))
1261
+ if add_response.get("success"):
1262
+ actual_name = str(add_response.get("widget_name") or candidate_name)
1263
+ existing_names.add(actual_name)
1264
+ added.append(actual_name)
1265
+ else:
1266
+ provision_failures.append(
1267
+ {
1268
+ "requested_name": candidate_name,
1269
+ "error": add_response.get("error", "Unknown error"),
1270
+ }
1271
+ )
1272
+
1273
+ return {
1274
+ "success": len(provision_failures) == 0,
1275
+ "added": added,
1276
+ "failures": provision_failures,
1277
+ "existing_button_count": len(existing_button_names),
1278
+ "desired_button_count": desired_button_count,
1279
+ "parent_widget_name": parent_widget_name,
1280
+ }
1281
+
1282
+
1283
+ def _coerce_numeric_list(value, expected_length: Optional[int] = None) -> List[float]:
1284
+ if value is None:
1285
+ return []
1286
+
1287
+ if isinstance(value, (list, tuple)):
1288
+ result = []
1289
+ for item in value:
1290
+ try:
1291
+ result.append(float(item))
1292
+ except (TypeError, ValueError):
1293
+ return []
1294
+ return result
1295
+
1296
+ if isinstance(value, str):
1297
+ stripped = value.strip()
1298
+ if not stripped:
1299
+ return []
1300
+
1301
+ try:
1302
+ parsed = json.loads(stripped)
1303
+ return _coerce_numeric_list(parsed, expected_length)
1304
+ except json.JSONDecodeError:
1305
+ pass
1306
+
1307
+ stripped = stripped.strip("[]()")
1308
+ if not stripped:
1309
+ return []
1310
+ tokens = re.split(r"[\s,;]+", stripped)
1311
+ result = []
1312
+ for token in tokens:
1313
+ if not token:
1314
+ continue
1315
+ try:
1316
+ result.append(float(token))
1317
+ except ValueError:
1318
+ return []
1319
+ return result
1320
+
1321
+ return []
1322
+
1323
+ def _ensure_vector(value, fallback: Optional[List[float]] = None) -> List[float]:
1324
+ vector = _coerce_numeric_list(value, expected_length=3)
1325
+ if len(vector) == 3:
1326
+ return vector
1327
+ return [0.0, 0.0, 0.0] if fallback is None else fallback
1328
+
1329
+ def _ensure_two_d(value, fallback: Optional[List[float]] = None) -> List[float]:
1330
+ vector = _coerce_numeric_list(value, expected_length=2)
1331
+ if len(vector) == 2:
1332
+ return vector
1333
+ return [0.0, 0.0] if fallback is None else fallback
1334
+
1335
+ @mcp.tool()
1336
+ def how_to_use() -> str:
1337
+ """Hey LLM, this grabs the how_to_use.md from knowledge_base—it's your cheat sheet for running Unreal with this MCP. Fetch it at the start of a new chat session to get the lowdown on quirks and how shit works."""
1338
+ try:
1339
+ current_dir = Path(__file__).parent
1340
+ md_file_path = current_dir / "knowledge_base" / "how_to_use.md"
1341
+ if not md_file_path.exists():
1342
+ return "Error: how_to_use.md not found in knowledge_base subfolder."
1343
+ with open(md_file_path, "r", encoding="utf-8") as md_file:
1344
+ return md_file.read()
1345
+ except Exception as e:
1346
+ return f"Error loading how_to_use.md: {str(e)}—fix your shit."
1347
+
1348
+ # Define basic tools for Claude to call
1349
+ @mcp.tool()
1350
+ def handshake_test(message: str) -> str:
1351
+ """Send a handshake message to Unreal Engine"""
1352
+ try:
1353
+ command = {
1354
+ "type": "handshake",
1355
+ "message": message
1356
+ }
1357
+ response = send_to_unreal(command)
1358
+ if response.get("success"):
1359
+ return f"Handshake successful: {response['message']}"
1360
+ else:
1361
+ return f"Handshake failed: {response.get('error', 'Unknown error')}"
1362
+ except Exception as e:
1363
+ return f"Error communicating with Unreal: {str(e)}"
1364
+
1365
+ @mcp.tool()
1366
+ def execute_python_script(script: str) -> str:
1367
+ """
1368
+ Execute a Python script within Unreal Engine's Python interpreter.
1369
+ Args:
1370
+ script: A string containing the Python code to execute in Unreal Engine.
1371
+ Returns:
1372
+ Message indicating success, failure, or a request for confirmation.
1373
+ Note:
1374
+ This tool sends the script to Unreal Engine, where it is executed via a temporary file using Unreal's internal Python execution system (similar to GEngine->Exec).
1375
+ This method is stable but may not handle Blueprint-specific APIs as seamlessly as direct Python API calls.
1376
+ For Blueprint manipulation, consider using dedicated tools like `add_node_to_blueprint` or ensuring the script uses stable `unreal` module functions.
1377
+ Use this tool for Python script execution instead of `execute_unreal_command` with 'py' commands.
1378
+ """
1379
+ try:
1380
+ if is_potentially_destructive(script):
1381
+ return ("This script appears to involve potentially destructive actions (e.g., deleting or saving files) "
1382
+ "that were not explicitly requested. Please confirm if you want to proceed by saying 'Yes, execute it' "
1383
+ "or modify your request to explicitly allow such actions.")
1384
+
1385
+ command = {
1386
+ "type": "execute_python",
1387
+ "script": script
1388
+ }
1389
+ response = send_to_unreal(command)
1390
+ if response.get("success"):
1391
+ output = response.get("output", "No output returned")
1392
+ return f"Script executed successfully. Output: {output}"
1393
+ else:
1394
+ error = response.get("error", "Unknown error")
1395
+ output = response.get("output", "")
1396
+ if output:
1397
+ error += f"\n\nPartial output before error: {output}"
1398
+ return f"Failed to execute script: {response.get('error', 'Unknown error')}"
1399
+ except Exception as e:
1400
+ return f"Error sending script to Unreal: {str(e)}"
1401
+
1402
+ @mcp.tool()
1403
+ def execute_unreal_command(command: str) -> str:
1404
+ """
1405
+ Execute an Unreal Engine command-line (CMD) command.
1406
+ Args:
1407
+ command: A string containing the Unreal Engine command to execute (e.g., "obj list", "stat fps").
1408
+ Returns:
1409
+ Message indicating success or failure, including any output or errors.
1410
+ Note:
1411
+ This tool executes commands directly in Unreal Engine's command system, similar to the editor's console.
1412
+ It is intended for built-in editor commands (e.g., "stat fps", "obj list") and not for running Python scripts.
1413
+ Do not use this tool with 'py' commands (e.g., "py script.py"); instead, use `execute_python_script` for Python execution,
1414
+ which provides dedicated safety checks and output handling.
1415
+ Output capture is limited; for detailed output, consider wrapping the command in a Python script with `execute_python_script`.
1416
+ """
1417
+ try:
1418
+ # Check if the command is attempting to run a Python script
1419
+ if command.strip().lower().startswith("py "):
1420
+ return (
1421
+ "Error: Use `execute_python_script` to run Python scripts instead of `execute_unreal_command` with 'py' commands. "
1422
+ "For example, use `execute_python_script(script='your_code_here')` for Python execution.")
1423
+
1424
+ # Check for potentially destructive commands
1425
+ destructive_keywords = ["delete", "save", "quit", "exit", "restart"]
1426
+ if any(keyword in command.lower() for keyword in destructive_keywords):
1427
+ return ("This command appears to involve potentially destructive actions (e.g., deleting or saving). "
1428
+ "Please confirm by saying 'Yes, execute it' or explicitly request such actions.")
1429
+
1430
+ command_dict = {
1431
+ "type": "execute_unreal_command",
1432
+ "command": command
1433
+ }
1434
+ response = send_to_unreal(command_dict)
1435
+ if response.get("success"):
1436
+ output = response.get("output", "Command executed with no detailed output returned")
1437
+ return f"Command '{command}' executed successfully. Output: {output}"
1438
+ else:
1439
+ return f"Failed to execute command '{command}': {response.get('error', 'Unknown error')}"
1440
+ except Exception as e:
1441
+ return f"Error sending command to Unreal: {str(e)}"
1442
+
1443
+ #
1444
+ # Basic Object Commands
1445
+ #
1446
+
1447
+ @mcp.tool()
1448
+ def spawn_object(actor_class: str, location: Optional[List[float]] = None, rotation: Optional[List[float]] = None, scale: Optional[List[float]] = None, actor_label: Optional[str] = None) -> str:
1449
+ """
1450
+ Spawn an object in the Unreal Engine level
1451
+ Args:
1452
+ actor_class: For basic shapes, use: "Cube", "Sphere", "Cylinder", or "Cone".
1453
+ For other actors, use class name like "PointLight" or full path.
1454
+ location: [X, Y, Z] coordinates
1455
+ rotation: [Pitch, Yaw, Roll] in degrees
1456
+ scale: [X, Y, Z] scale factors
1457
+ actor_label: Optional custom name for the actor
1458
+ Returns:
1459
+ Message indicating success or failure
1460
+ """
1461
+ location = _ensure_vector(location, [0.0, 0.0, 0.0])
1462
+ rotation = _ensure_vector(rotation, [0.0, 0.0, 0.0])
1463
+ scale = _ensure_vector(scale, [1.0, 1.0, 1.0])
1464
+
1465
+ command = {
1466
+ "type": "spawn",
1467
+ "actor_class": actor_class,
1468
+ "location": location,
1469
+ "rotation": rotation,
1470
+ "scale": scale,
1471
+ "actor_label": actor_label
1472
+ }
1473
+ response = send_to_unreal(command)
1474
+ if response.get("success"):
1475
+ return f"Successfully spawned {actor_class}" + (f" with label '{actor_label}'" if actor_label else "")
1476
+ else:
1477
+ error = response.get('error', 'Unknown error')
1478
+ # Add hint for Claude to understand what went wrong
1479
+ if "not found" in error:
1480
+ hint = "\nHint: For basic shapes, use 'Cube', 'Sphere', 'Cylinder', or 'Cone'. For other actors, try using '/Script/Engine.PointLight' format."
1481
+ error += hint
1482
+ return f"Failed to spawn object: {error}"
1483
+
1484
+ @mcp.tool()
1485
+ def edit_component_property(blueprint_path: str, component_name: str, property_name: str, value: str, is_scene_actor: bool = False, actor_name: str = "") -> str:
1486
+ """
1487
+ Edit a property of a component in a Blueprint or scene actor.
1488
+ Args:
1489
+ blueprint_path: Path to the Blueprint (e.g., "/Game/FlappyBird/BP_FlappyBird") or "" for scene actors
1490
+ component_name: Name of the component (e.g., "BirdMesh", "RootComponent")
1491
+ property_name: Name of the property to edit (e.g., "StaticMesh", "RelativeLocation")
1492
+ value: New value as a string (e.g., "'/Engine/BasicShapes/Sphere.Sphere'", "100,200,300")
1493
+ is_scene_actor: If True, edit a component on a scene actor (default: False)
1494
+ actor_name: Name of the actor in the scene (required if is_scene_actor is True, e.g., "Cube_1")
1495
+ Returns:
1496
+ Message indicating success or failure, with optional property suggestions if the property is not found.
1497
+ Capabilities:
1498
+ - Set component properties in Blueprints (e.g., StaticMesh, bSimulatePhysics).
1499
+ - Modify scene actor components (e.g., position, rotation, scale, material).
1500
+ - Supports scalar types (double, int, bool), objects (e.g., materials), and vectors/rotators (e.g., "100,200,300" for FVector). Note: UE 5.6 uses double for numeric operations.
1501
+ - Examples:
1502
+ - Set a mesh: edit_component_property("/Game/FlappyBird/BP_FlappyBird", "BirdMesh", "StaticMesh", "'/Engine/BasicShapes/Sphere.Sphere'")
1503
+ - Move an actor: edit_component_property("", "RootComponent", "RelativeLocation", "100,200,300", True, "Cube_1")
1504
+ - Rotate an actor: edit_component_property("", "RootComponent", "RelativeRotation", "0,90,0", True, "Cube_1")
1505
+ - Scale an actor: edit_component_property("", "RootComponent", "RelativeScale3D", "2,2,2", True, "Cube_1")
1506
+ - Enable physics: edit_component_property("/Game/FlappyBird/BP_FlappyBird", "BirdMesh", "bSimulatePhysics", "true")
1507
+ """
1508
+ command = {
1509
+ "type": "edit_component_property",
1510
+ "blueprint_path": blueprint_path,
1511
+ "component_name": component_name,
1512
+ "property_name": property_name,
1513
+ "value": value,
1514
+ "is_scene_actor": is_scene_actor,
1515
+ "actor_name": actor_name
1516
+ }
1517
+ response = send_to_unreal(command)
1518
+
1519
+ # CHANGED: Improved response handling to support both string and dict responses
1520
+ try:
1521
+ # Handle case where response is already a dict
1522
+ if isinstance(response, dict):
1523
+ result = response
1524
+ # Handle case where response is a string
1525
+ elif isinstance(response, str):
1526
+ import json
1527
+ result = json.loads(response)
1528
+ else:
1529
+ return f"Error: Unexpected response type: {type(response)}"
1530
+
1531
+ if result.get("success"):
1532
+ return result.get("message", f"Set {property_name} of {component_name} to {value}")
1533
+ else:
1534
+ error = result.get("error", "Unknown error")
1535
+ if "suggestions" in result:
1536
+ error += f"\nSuggestions: {result['suggestions']}"
1537
+ return f"Failed: {error}"
1538
+ except Exception as e:
1539
+ return f"Error: {str(e)}\nRaw response: {response}"
1540
+
1541
+ @mcp.tool()
1542
+ def create_material(material_name: str, color: List[float]) -> str:
1543
+ """
1544
+ Create a new material with the specified color
1545
+ Args:
1546
+ material_name: Name for the new material
1547
+ color: [R, G, B] color values (0-1)
1548
+ Returns:
1549
+ Message indicating success or failure, and the material path if successful
1550
+ """
1551
+ normalized_color = _coerce_numeric_list(color)
1552
+ if len(normalized_color) == 3:
1553
+ color = normalized_color
1554
+
1555
+ command = {
1556
+ "type": "create_material",
1557
+ "material_name": material_name,
1558
+ "color": color
1559
+ }
1560
+ response = send_to_unreal(command)
1561
+ if response.get("success"):
1562
+ return f"Successfully created material '{material_name}' with path: {response.get('material_path')}"
1563
+ else:
1564
+ return f"Failed to create material: {response.get('error', 'Unknown error')}"
1565
+
1566
+ #
1567
+ # Blueprint Commands
1568
+ #
1569
+
1570
+ @mcp.tool()
1571
+ def create_blueprint(blueprint_name: str, parent_class: str = "Actor", save_path: str = "/Game/Blueprints") -> str:
1572
+ """
1573
+ Create a new Blueprint class
1574
+ Args:
1575
+ blueprint_name: Name for the new Blueprint
1576
+ parent_class: Parent class name or path (e.g., "Actor", "/Script/Engine.Actor")
1577
+ save_path: Path to save the Blueprint asset
1578
+ Returns:
1579
+ Message indicating success or failure
1580
+ """
1581
+ command = {
1582
+ "type": "create_blueprint",
1583
+ "blueprint_name": blueprint_name,
1584
+ "parent_class": parent_class,
1585
+ "save_path": save_path
1586
+ }
1587
+ response = send_to_unreal(command)
1588
+ if response.get("success"):
1589
+ return f"Successfully created Blueprint '{blueprint_name}' with path: {response.get('blueprint_path', save_path + '/' + blueprint_name)}"
1590
+ else:
1591
+ return f"Failed to create Blueprint: {response.get('error', 'Unknown error')}"
1592
+
1593
+ @mcp.tool()
1594
+ def add_component_to_blueprint(blueprint_path: str, component_class: str, component_name: str = None) -> str:
1595
+ """
1596
+ Add a component to a Blueprint
1597
+ Args:
1598
+ blueprint_path: Path to the Blueprint asset
1599
+ component_class: Component class to add (e.g., "StaticMeshComponent", "PointLightComponent")
1600
+ component_name: Name for the new component (optional)
1601
+ Returns:
1602
+ Message indicating success or failure
1603
+ """
1604
+ command = {
1605
+ "type": "add_component",
1606
+ "blueprint_path": blueprint_path,
1607
+ "component_class": component_class,
1608
+ "component_name": component_name
1609
+ }
1610
+ response = send_to_unreal(command)
1611
+ if response.get("success"):
1612
+ return f"Successfully added {component_class} to Blueprint at {blueprint_path}"
1613
+ else:
1614
+ return f"Failed to add component: {response.get('error', 'Unknown error')}"
1615
+
1616
+ @mcp.tool()
1617
+ def add_variable_to_blueprint(blueprint_path: str, variable_name: str, variable_type: str, default_value: str = None, category: str = "Default") -> str:
1618
+ """
1619
+ Add a variable to a Blueprint
1620
+ Args:
1621
+ blueprint_path: Path to the Blueprint asset
1622
+ variable_name: Name for the new variable
1623
+ variable_type: Type of the variable (e.g., "double", "vector", "boolean") - Note: UE 5.6 uses double for numeric values
1624
+ default_value: Default value for the variable (optional)
1625
+ category: Category for organizing variables in the Blueprint editor (optional)
1626
+ Returns:
1627
+ Message indicating success or failure
1628
+ """
1629
+ # Convert default_value to string if it's a number
1630
+ if default_value is not None and not isinstance(default_value, str):
1631
+ default_value = str(default_value)
1632
+
1633
+ command = {
1634
+ "type": "add_variable",
1635
+ "blueprint_path": blueprint_path,
1636
+ "variable_name": variable_name,
1637
+ "variable_type": variable_type,
1638
+ "default_value": default_value,
1639
+ "category": category
1640
+ }
1641
+ response = send_to_unreal(command)
1642
+ if response.get("success"):
1643
+ return f"Successfully added {variable_type} variable '{variable_name}' to Blueprint at {blueprint_path}"
1644
+ else:
1645
+ return f"Failed to add variable: {response.get('error', 'Unknown error')}"
1646
+
1647
+ @mcp.tool()
1648
+ def add_function_to_blueprint(blueprint_path: str, function_name: str, inputs: Optional[List[Dict]] = None, outputs: Optional[List[Dict]] = None) -> str:
1649
+ """
1650
+ Add a function to a Blueprint using UE 5.6 Kismet type system
1651
+ Args:
1652
+ blueprint_path: Path to the Blueprint asset
1653
+ function_name: Name for the new function
1654
+ inputs: List of input parameters with UE 5.6 types:
1655
+ [{"name": "param1", "type": "double"}, {"name": "param2", "type": "string"}, ...]
1656
+ Supported UE 5.6 types: boolean, byte, int, double, float, string, text, name,
1657
+ vector, rotator, transform, color, object (with class name)
1658
+ outputs: List of output parameters with same type format as inputs
1659
+ Returns:
1660
+ Message indicating success or failure
1661
+ """
1662
+ if inputs is None:
1663
+ inputs = []
1664
+ if outputs is None:
1665
+ outputs = []
1666
+
1667
+ command = {
1668
+ "type": "add_function",
1669
+ "blueprint_path": blueprint_path,
1670
+ "function_name": function_name,
1671
+ "inputs": inputs,
1672
+ "outputs": outputs
1673
+ }
1674
+ response = send_to_unreal(command)
1675
+ if response.get("success"):
1676
+ return f"Successfully added function '{function_name}' to Blueprint at {blueprint_path} with ID: {response.get('function_id', 'unknown')}"
1677
+ else:
1678
+ return f"Failed to add function: {response.get('error', 'Unknown error')}"
1679
+
1680
+ @mcp.tool()
1681
+ def add_node_to_blueprint(blueprint_path: str, function_id: str, node_type: str, node_position: Optional[List[int]] = None, node_properties: Optional[Dict] = None) -> str:
1682
+ """
1683
+ Add a node to a Blueprint graph with comprehensive node discovery
1684
+ Args:
1685
+ blueprint_path: Path to the Blueprint asset
1686
+ function_id: ID of the function to add the node to
1687
+ node_type: Type of node to add. The system now supports ANY Blueprint-callable function with intelligent discovery:
1688
+
1689
+ **FLEXIBLE NODE TYPES** - The system automatically finds the correct function:
1690
+ - Simple names: "Add", "Multiply", "SetActorRotation", "PrintString"
1691
+ - Library.Function format: "KismetMathLibrary.Add", "Actor.SetActorLocation"
1692
+ - Variations: "SetRotation", "MoveComponent", "CreateWidget"
1693
+
1694
+ **COMPREHENSIVE COVERAGE** - Searches across ALL Blueprint libraries:
1695
+ - Math: "Add", "Multiply", "Divide", "Subtract", "MakeVector", "BreakVector"
1696
+ - Actor: "SetActorLocation", "SetActorRotation", "GetActorLocation", "SetActorScale3D"
1697
+ - Components: "SetWorldLocation", "SetRelativeLocation", "SetWorldRotation"
1698
+ - String: "Append", "Contains", "Split", "ToUpper", "ToLower"
1699
+ - Array: "Add Item", "Remove Item", "Get", "Length", "Contains"
1700
+ - System: "PrintString", "Delay", "Branch", "Sequence", "IsValid"
1701
+ - Gameplay: "GetPlayerController", "GetPlayerPawn", "SpawnActor"
1702
+
1703
+ **INTELLIGENT FALLBACKS** - If exact match fails, automatically tries:
1704
+ - Common variations (Set/Get prefixes, library corrections)
1705
+ - Searches ALL loaded Blueprint-accessible classes
1706
+ - Returns helpful suggestions for similar functions
1707
+
1708
+ **EXAMPLES THAT NOW WORK**:
1709
+ - "SetActorRotation" → finds Actor.SetActorRotation
1710
+ - "KismetMathLibrary.SetActorRotation" → corrects to Actor.SetActorRotation
1711
+ - "Add" → finds KismetMathLibrary.Add (with proper type suffix)
1712
+ - "MoveComponent" → finds appropriate component movement function
1713
+
1714
+ node_position: Position of the node in the graph [X, Y]. **IMPORTANT**: Space nodes at least 400 units apart horizontally and 300 units vertically to avoid overlap and ensure a clean, organized graph (e.g., [0, 0], [400, 0], [800, 0] for a chain).
1715
+ node_properties: Properties to set on the node (optional)
1716
+ Returns:
1717
+ On success: The node ID (GUID) and confirmation message
1718
+ On failure: Detailed error with suggestions for alternative node types
1719
+ Note:
1720
+ The enhanced discovery system searches across 30+ Blueprint libraries and ALL loaded classes.
1721
+ It handles incorrect library prefixes, tries common variations, and provides intelligent suggestions.
1722
+ This dramatically improves the success rate for any Blueprint-callable function.
1723
+ """
1724
+ if node_properties is None:
1725
+ node_properties = {}
1726
+
1727
+ if isinstance(node_position, str) or node_position is None:
1728
+ node_position = _ensure_two_d(node_position, [0.0, 0.0])
1729
+ elif isinstance(node_position, (list, tuple)):
1730
+ node_position = _ensure_two_d(node_position, [0.0, 0.0])
1731
+ else:
1732
+ node_position = [0.0, 0.0]
1733
+
1734
+ command = {
1735
+ "type": "add_node",
1736
+ "blueprint_path": blueprint_path,
1737
+ "function_id": function_id,
1738
+ "node_type": node_type,
1739
+ "node_position": node_position,
1740
+ "node_properties": node_properties
1741
+ }
1742
+ response = send_to_unreal(command)
1743
+
1744
+ if response.get("success") and response.get("node_id"):
1745
+ return f"Successfully added {node_type} node to function {function_id} in Blueprint at {blueprint_path} with ID: {response.get('node_id')}"
1746
+ else:
1747
+ error_msg = response.get('error', 'Unknown error')
1748
+
1749
+ # Check if the response contains suggestions
1750
+ if "SUGGESTIONS:" in error_msg:
1751
+ return f"Node '{node_type}' not found, but here are alternatives:\n{error_msg}"
1752
+ else:
1753
+ return f"Failed to add node '{node_type}': {error_msg}\n\nTip: The system now searches comprehensively across all Blueprint libraries. Try simpler names like 'Add', 'SetActorLocation', or 'PrintString'."
1754
+
1755
+ @mcp.tool()
1756
+ def get_node_suggestions(node_type: str) -> str:
1757
+ """
1758
+ Get suggestions for a node type in Unreal Blueprints using comprehensive discovery
1759
+ Args:
1760
+ node_type: The partial or full node type to get suggestions for (e.g., "Add", "Vector", "Math", "Actor")
1761
+ Returns:
1762
+ A string with suggestions or error message
1763
+
1764
+ Enhanced Discovery Features:
1765
+ - Searches across ALL Blueprint-accessible classes (30+ libraries)
1766
+ - Handles any node name format (simple names, Library.Function, variations)
1767
+ - Provides intelligent scoring and ranking
1768
+ - Includes functions from: Math, Actor, Component, String, Array, System, Gameplay, etc.
1769
+
1770
+ Examples that now work comprehensively:
1771
+ - "Add" → finds all addition functions across libraries
1772
+ - "Vector" → finds all vector-related functions
1773
+ - "Actor" → finds all Actor class functions
1774
+ - "SetRotation" → finds rotation-setting functions across all classes
1775
+ - "Math" → finds all mathematical operations
1776
+ - "Component" → finds all component-related functions
1777
+
1778
+ The system now searches universally across all loaded Blueprint classes,
1779
+ so virtually any Blueprint-callable function can be discovered.
1780
+ """
1781
+ command = {
1782
+ "type": "get_node_suggestions",
1783
+ "node_type": node_type
1784
+ }
1785
+ response = send_to_unreal(command)
1786
+ if response.get("success"):
1787
+ suggestions = response.get("suggestions", [])
1788
+ if suggestions:
1789
+ return f"Found {len(suggestions)} suggestions for '{node_type}' using comprehensive discovery:\n" + "\n".join(suggestions)
1790
+ else:
1791
+ return f"No suggestions found for '{node_type}' - try broader search terms like 'Math', 'Actor', 'Vector', or 'String'"
1792
+ else:
1793
+ error = response.get("error", "Unknown error")
1794
+ return f"Failed to get suggestions for '{node_type}': {error}"
1795
+
1796
+ @mcp.tool()
1797
+ def delete_node_from_blueprint(blueprint_path: str, function_id: str, node_id: str) -> str:
1798
+ """
1799
+ Delete a node from a Blueprint graph
1800
+ Args:
1801
+ blueprint_path: Path to the Blueprint asset
1802
+ function_id: ID of the function containing the node
1803
+ node_id: ID of the node to delete
1804
+ Returns:
1805
+ Success or failure message
1806
+ """
1807
+ command = {
1808
+ "type": "delete_node",
1809
+ "blueprint_path": blueprint_path,
1810
+ "function_id": function_id,
1811
+ "node_id": node_id
1812
+ }
1813
+ response = send_to_unreal(command)
1814
+ if response.get("success"):
1815
+ return f"Successfully deleted node {node_id} from function {function_id} in Blueprint at {blueprint_path}"
1816
+ else:
1817
+ return f"Failed to delete node: {response.get('error', 'Unknown error')}"
1818
+
1819
+ @mcp.tool()
1820
+ def get_all_nodes_in_graph(blueprint_path: str, function_id: str) -> str:
1821
+ """
1822
+ Get all nodes in a Blueprint graph with their positions and types
1823
+ Args:
1824
+ blueprint_path: Path to the Blueprint asset
1825
+ function_id: ID of the function to get nodes from
1826
+ Returns:
1827
+ JSON string containing all nodes with their GUIDs, types, and positions
1828
+ """
1829
+ command = {
1830
+ "type": "get_all_nodes",
1831
+ "blueprint_path": blueprint_path,
1832
+ "function_id": function_id
1833
+ }
1834
+ response = send_to_unreal(command)
1835
+ if response.get("success"):
1836
+ return response.get("nodes", "[]")
1837
+ else:
1838
+ return f"Failed to get nodes: {response.get('error', 'Unknown error')}"
1839
+
1840
+ @mcp.tool()
1841
+ def connect_blueprint_nodes(blueprint_path: str, function_id: str, source_node_id: str, source_pin: str, target_node_id: str, target_pin: str) -> str:
1842
+ command = {
1843
+ "type": "connect_nodes",
1844
+ "blueprint_path": blueprint_path,
1845
+ "function_id": function_id,
1846
+ "source_node_id": source_node_id,
1847
+ "source_pin": source_pin,
1848
+ "target_node_id": target_node_id,
1849
+ "target_pin": target_pin
1850
+ }
1851
+ response = send_to_unreal(command)
1852
+ if response.get("success"):
1853
+ return f"Successfully connected {source_node_id}.{source_pin} to {target_node_id}.{target_pin} in Blueprint at {blueprint_path}"
1854
+ else:
1855
+ error = response.get("error", "Unknown error")
1856
+ if "source_available_pins" in response and "target_available_pins" in response:
1857
+ error += f"\nAvailable pins on source ({source_node_id}): {json.dumps(response['source_available_pins'], indent=2)}"
1858
+ error += f"\nAvailable pins on target ({target_node_id}): {json.dumps(response['target_available_pins'], indent=2)}"
1859
+ return f"Failed to connect nodes: {error}"
1860
+
1861
+ @mcp.tool()
1862
+ def compile_blueprint(blueprint_path: str) -> str:
1863
+ """
1864
+ Compile a Blueprint
1865
+ Args:
1866
+ blueprint_path: Path to the Blueprint asset
1867
+ Returns:
1868
+ Message indicating success or failure
1869
+ """
1870
+ command = {
1871
+ "type": "compile_blueprint",
1872
+ "blueprint_path": blueprint_path
1873
+ }
1874
+ response = send_to_unreal(command)
1875
+ if response.get("success"):
1876
+ return f"Successfully compiled Blueprint at {blueprint_path}"
1877
+ else:
1878
+ return f"Failed to compile Blueprint: {response.get('error', 'Unknown error')}"
1879
+
1880
+ @mcp.tool()
1881
+ def spawn_blueprint_actor(blueprint_path: str, location: Optional[List[float]] = None, rotation: Optional[List[float]] = None, scale: Optional[List[float]] = None, actor_label: Optional[str] = None) -> str:
1882
+ """
1883
+ Spawn a Blueprint actor in the level
1884
+ Args:
1885
+ blueprint_path: Path to the Blueprint asset
1886
+ location: [X, Y, Z] coordinates
1887
+ rotation: [Pitch, Yaw, Roll] in degrees
1888
+ scale: [X, Y, Z] scale factors
1889
+ actor_label: Optional custom name for the actor
1890
+ Returns:
1891
+ Message indicating success or failure
1892
+ """
1893
+ location = _ensure_vector(location, [0.0, 0.0, 0.0])
1894
+ rotation = _ensure_vector(rotation, [0.0, 0.0, 0.0])
1895
+ scale = _ensure_vector(scale, [1.0, 1.0, 1.0])
1896
+
1897
+ command = {
1898
+ "type": "spawn_blueprint",
1899
+ "blueprint_path": blueprint_path,
1900
+ "location": location,
1901
+ "rotation": rotation,
1902
+ "scale": scale,
1903
+ "actor_label": actor_label
1904
+ }
1905
+ response = send_to_unreal(command)
1906
+ if response.get("success"):
1907
+ return f"Successfully spawned Blueprint {blueprint_path}" + (
1908
+ f" with label '{actor_label}'" if actor_label else "")
1909
+ else:
1910
+ return f"Failed to spawn Blueprint: {response.get('error', 'Unknown error')}"
1911
+
1912
+ @mcp.tool()
1913
+ def add_component_with_events(blueprint_path: str, component_name: str, component_class: str) -> str:
1914
+ """
1915
+ Add a component to a Blueprint with overlap events if applicable.
1916
+ Args:
1917
+ blueprint_path: Path to the Blueprint (e.g., "/Game/FlappyBird/BP_FlappyBird")
1918
+ component_name: Name of the new component (e.g., "TriggerBox")
1919
+ component_class: Class of the component (e.g., "BoxComponent")
1920
+ Returns:
1921
+ Message with success, error, and event GUIDs if created
1922
+ """
1923
+ command = {
1924
+ "type": "add_component_with_events",
1925
+ "blueprint_path": blueprint_path,
1926
+ "component_name": component_name,
1927
+ "component_class": component_class
1928
+ }
1929
+ response = _ensure_dict_response(send_to_unreal(command))
1930
+ if response.get("success"):
1931
+ msg = response.get("message", f"Added component {component_name}")
1932
+ begin_guid = response.get("begin_overlap_guid")
1933
+ end_guid = response.get("end_overlap_guid")
1934
+ if not (begin_guid or end_guid):
1935
+ events_payload = response.get("events")
1936
+ events_data = {}
1937
+ if isinstance(events_payload, str):
1938
+ try:
1939
+ events_data = json.loads(events_payload)
1940
+ except json.JSONDecodeError:
1941
+ events_data = {}
1942
+ elif isinstance(events_payload, dict):
1943
+ events_data = events_payload
1944
+ begin_guid = events_data.get("begin_guid", begin_guid)
1945
+ end_guid = events_data.get("end_guid", end_guid)
1946
+ if begin_guid or end_guid:
1947
+ msg += (
1948
+ f"\nBeginOverlap GUID: {begin_guid or 'N/A'}"
1949
+ f"\nEndOverlap GUID: {end_guid or 'N/A'}"
1950
+ )
1951
+ return msg
1952
+ error_message = response.get("error", "Unknown error")
1953
+ raw_suffix = f"\nRaw response: {response.get('raw')}" if "raw" in response else ""
1954
+ return f"Failed: {error_message}{raw_suffix}"
1955
+
1956
+ @mcp.tool()
1957
+ def connect_blueprint_nodes_bulk(blueprint_path: str, function_id: str, connections: List[Dict], function_name: Optional[str] = None) -> str:
1958
+ """
1959
+ Connect multiple pairs of nodes in a Blueprint graph
1960
+ Args:
1961
+ blueprint_path: Path to the Blueprint asset
1962
+ function_id: ID of the function containing the nodes
1963
+ connections: Array of connection definitions, each containing:
1964
+ - source_node_id: ID of the source node
1965
+ - source_pin: Name of the source pin
1966
+ - target_node_id: ID of the target node
1967
+ - target_pin: Name of the target pin
1968
+ Returns:
1969
+ Message indicating success or failure, with details on which connections succeeded or failed
1970
+ """
1971
+ if isinstance(connections, str):
1972
+ try:
1973
+ connections = json.loads(connections)
1974
+ except json.JSONDecodeError as parse_err:
1975
+ return f"Failed to parse connections: {parse_err}"
1976
+
1977
+ normalized_connections: List[Dict] = []
1978
+ raw_connections = connections
1979
+ if isinstance(raw_connections, dict):
1980
+ raw_connections = [raw_connections]
1981
+
1982
+ for entry in raw_connections or []:
1983
+ if isinstance(entry, str):
1984
+ try:
1985
+ entry = json.loads(entry)
1986
+ except json.JSONDecodeError:
1987
+ continue
1988
+ if not isinstance(entry, dict):
1989
+ continue
1990
+ normalized_connections.append({
1991
+ "source_node_id": entry.get("source_node_id") or entry.get("source_node_guid"),
1992
+ "source_pin": entry.get("source_pin", "exec"),
1993
+ "target_node_id": entry.get("target_node_id") or entry.get("target_node_guid"),
1994
+ "target_pin": entry.get("target_pin", "exec")
1995
+ })
1996
+
1997
+ if not normalized_connections and isinstance(connections, list):
1998
+ normalized_connections = connections
1999
+
2000
+ if not normalized_connections:
2001
+ return "Failed: no valid connections provided"
2002
+
2003
+ command = {
2004
+ "type": "connect_nodes_bulk",
2005
+ "blueprint_path": blueprint_path,
2006
+ "function_id": function_id,
2007
+ "function_name": function_name or function_id,
2008
+ "connections": normalized_connections
2009
+ }
2010
+ response = _ensure_dict_response(send_to_unreal(command))
2011
+
2012
+ # Handle the new detailed response format
2013
+ if response.get("success"):
2014
+ successful = response.get("successful_connections", 0)
2015
+ total = response.get("total_connections", 0)
2016
+ return f"Successfully connected {successful}/{total} node pairs in Blueprint at {blueprint_path}"
2017
+ else:
2018
+ # Extract detailed error information
2019
+ error_message = response.get("error", "Unknown error")
2020
+ failed_connections = []
2021
+
2022
+ for result in response.get("results", []):
2023
+ if not result.get("success", False):
2024
+ idx = result.get("connection_index", -1)
2025
+ src = result.get("source_node", "unknown")
2026
+ tgt = result.get("target_node", "unknown")
2027
+ err = result.get("error", "unknown error")
2028
+ failed_connections.append(f"Connection {idx}: {src} to {tgt} - {err}")
2029
+
2030
+ raw_suffix = f"\nRaw response: {response.get('raw')}" if "raw" in response else ""
2031
+ if failed_connections:
2032
+ detailed_errors = "\n- " + "\n- ".join(failed_connections)
2033
+ return f"Failed to connect nodes: {error_message}{detailed_errors}{raw_suffix}"
2034
+ else:
2035
+ return f"Failed to connect nodes: {error_message}{raw_suffix}"
2036
+
2037
+ @mcp.tool()
2038
+ def get_blueprint_node_guid(blueprint_path: str, graph_type: str = "EventGraph", node_name: str = None, function_id: str = None) -> str:
2039
+ """
2040
+ Retrieve the GUID of a pre-existing node in a Blueprint graph.
2041
+ Args:
2042
+ blueprint_path: Path to the Blueprint asset (e.g., "/Game/Blueprints/TestBulkBlueprint")
2043
+ graph_type: Type of graph to query ("EventGraph" or "FunctionGraph", default: "EventGraph")
2044
+ node_name: Name of the node to find (e.g., "BeginPlay" for EventGraph, optional if using function_id)
2045
+ function_id: ID of the function to get the FunctionEntry node for (optional, used with graph_type="FunctionGraph")
2046
+ Returns:
2047
+ Message with the node's GUID or an error if not found
2048
+ """
2049
+ command = {
2050
+ "type": "get_node_guid",
2051
+ "blueprint_path": blueprint_path,
2052
+ "graph_type": graph_type,
2053
+ "node_name": node_name if node_name else "",
2054
+ "function_id": function_id if function_id else ""
2055
+ }
2056
+ response = send_to_unreal(command)
2057
+ if response.get("success"):
2058
+ guid = response.get("node_guid")
2059
+ return f"Node GUID for {node_name or 'FunctionEntry'} in {graph_type} of {blueprint_path}: {guid}"
2060
+ else:
2061
+ return f"Failed to get node GUID: {response.get('error', 'Unknown error')}"
2062
+
2063
+ # Scene Control
2064
+ @mcp.tool()
2065
+ def get_all_scene_objects() -> str:
2066
+ """
2067
+ Retrieve all actors in the current Unreal Engine level.
2068
+ Returns:
2069
+ JSON string of actors with their names, classes, and locations.
2070
+ """
2071
+ command = {"type": "get_all_scene_objects"}
2072
+ response = send_to_unreal(command)
2073
+ return json.dumps(response) if response.get("success") else f"Failed: {response.get('error', 'Unknown error')}"
2074
+
2075
+ @mcp.tool()
2076
+ def get_blueprint_context(include_editor_state: bool = True, include_graph_info: bool = True,
2077
+ include_selected_nodes: bool = True, include_open_editors: bool = False,
2078
+ specific_blueprint_path: str = None, auto_discover_nodes: bool = True) -> str:
2079
+ """
2080
+ Get comprehensive context about the currently active Blueprint editor in Unreal Engine.
2081
+ This provides AI tools with complete awareness of what Blueprint is being edited,
2082
+ what graph is active, selected nodes, compilation status, and more.
2083
+
2084
+ When called from "Sync with AI" button, this also discovers available Blueprint nodes
2085
+ for the current UE version and project, solving node creation issues.
2086
+
2087
+ Args:
2088
+ include_editor_state: Include detailed editor state (compilation status, Blueprint type, etc.) - default True
2089
+ include_graph_info: Include information about all graphs in the Blueprint - default True
2090
+ include_selected_nodes: Include information about currently selected nodes - default True
2091
+ include_open_editors: Include list of all open Blueprint editors - default False
2092
+ specific_blueprint_path: Focus on a specific Blueprint instead of the active one (optional)
2093
+ auto_discover_nodes: Automatically discover available Blueprint nodes for this UE version - default True
2094
+
2095
+ Returns:
2096
+ JSON string containing comprehensive Blueprint context information including:
2097
+ - Active Blueprint name and path
2098
+ - Current graph type (EventGraph, FunctionGraph, etc.)
2099
+ - Selected nodes with details (class, position, pins)
2100
+ - Compilation status and errors
2101
+ - All available graphs
2102
+ - Available Blueprint nodes for this UE version (if auto_discover_nodes=True)
2103
+ - Quick summary for AI analysis
2104
+
2105
+ Example response structure:
2106
+ {
2107
+ "success": true,
2108
+ "has_active_editor": true,
2109
+ "active_blueprint": {"name": "MyActor", "path": "/Game/Blueprints/MyActor"},
2110
+ "active_graph": {"name": "EventGraph", "type": "EventGraph"},
2111
+ "available_nodes": {...}, // Discovered nodes for this UE version
2112
+ "summary": {
2113
+ "active_blueprint_name": "MyActor",
2114
+ "active_blueprint_path": "/Game/Blueprints/MyActor",
2115
+ "active_graph_name": "EventGraph",
2116
+ "active_graph_type": "EventGraph",
2117
+ "has_selected_nodes": true,
2118
+ "nodes_discovered": true
2119
+ },
2120
+ "editor_state": {...}, // If include_editor_state = true
2121
+ "graph_info": {...}, // If include_graph_info = true
2122
+ "selected_nodes": {...} // If include_selected_nodes = true
2123
+ }
2124
+
2125
+ This tool enables context-aware AI assistance by providing complete visibility
2126
+ into the current Blueprint editing session AND available node inventory.
2127
+ """
2128
+ try:
2129
+ command = {
2130
+ "type": "get_blueprint_context",
2131
+ "include_editor_state": include_editor_state,
2132
+ "include_graph_info": include_graph_info,
2133
+ "include_selected_nodes": include_selected_nodes,
2134
+ "include_open_editors": include_open_editors
2135
+ }
2136
+
2137
+ if specific_blueprint_path:
2138
+ command["specific_blueprint_path"] = specific_blueprint_path
2139
+
2140
+ response = send_to_unreal(command)
2141
+
2142
+ if response.get("success"):
2143
+ # If auto_discover_nodes is enabled, also discover available nodes
2144
+ if auto_discover_nodes:
2145
+ print("Auto-discovering available Blueprint nodes for this UE version...")
2146
+ discovery_result = discover_available_blueprint_nodes()
2147
+ try:
2148
+ discovery_data = json.loads(discovery_result)
2149
+ if discovery_data.get("success"):
2150
+ response["available_nodes"] = discovery_data.get("discovered_nodes", {})
2151
+ response["node_discovery"] = {
2152
+ "success": True,
2153
+ "total_nodes_found": discovery_data.get("total_nodes_found", 0),
2154
+ "unreal_version": discovery_data.get("unreal_version", "Unknown")
2155
+ }
2156
+ print(f"Node discovery completed: {discovery_data.get('total_nodes_found', 0)} nodes found")
2157
+ else:
2158
+ response["available_nodes"] = {}
2159
+ response["node_discovery"] = {
2160
+ "success": False,
2161
+ "error": "Node discovery failed"
2162
+ }
2163
+ except json.JSONDecodeError as e:
2164
+ print(f"Failed to parse discovery result: {e}")
2165
+ response["available_nodes"] = {}
2166
+ response["node_discovery"] = {
2167
+ "success": False,
2168
+ "error": f"JSON decode error: {str(e)}"
2169
+ }
2170
+ try:
2171
+ discovery_data = json.loads(discovery_result)
2172
+ if discovery_data.get("success"):
2173
+ # CURSOR STATUS FIX: Limit available_nodes to prevent large responses
2174
+ discovered_nodes = discovery_data.get("discovered_nodes", {})
2175
+ limited_nodes = {}
2176
+
2177
+ for category, nodes in discovered_nodes.items():
2178
+ if isinstance(nodes, list):
2179
+ # Limit each category to first 10 nodes
2180
+ limited_nodes[category] = nodes[:10]
2181
+ if len(nodes) > 10:
2182
+ limited_nodes[category].append(f"... and {len(nodes) - 10} more {category} nodes")
2183
+ else:
2184
+ limited_nodes[category] = nodes
2185
+
2186
+ response["available_nodes"] = limited_nodes
2187
+ response["node_discovery"] = {
2188
+ "success": True,
2189
+ "total_nodes_found": discovery_data.get("total_nodes_found", 0),
2190
+ "unreal_version": discovery_data.get("unreal_version", "Unknown"),
2191
+ "limited": True,
2192
+ "note": "Node lists limited to 10 per category for Cursor compatibility"
2193
+ }
2194
+ print(f"Node discovery completed: {discovery_data.get('total_nodes_found', 0)} nodes found (limited for Cursor)")
2195
+ else:
2196
+ response["node_discovery"] = {
2197
+ "success": False,
2198
+ "error": discovery_data.get("error", "Discovery failed")
2199
+ }
2200
+ print("Node discovery failed, but Blueprint context still available")
2201
+ except Exception as e:
2202
+ response["node_discovery"] = {
2203
+ "success": False,
2204
+ "error": f"Discovery parsing error: {str(e)}"
2205
+ }
2206
+ print(f"Node discovery parsing failed: {str(e)}")
2207
+
2208
+ # CURSOR STATUS FIX: Limit response size to prevent truncation
2209
+ response_str = json.dumps(response, indent=2)
2210
+ response_size = len(response_str.encode('utf-8'))
2211
+
2212
+ # If response is too large (>80KB), truncate the all_nodes data
2213
+ if response_size > 80 * 1024: # 80KB limit
2214
+ print(f"Blueprint context response too large ({response_size} bytes), truncating node data...")
2215
+
2216
+ # Keep essential data but limit all_nodes
2217
+ if "all_nodes" in response and "nodes" in response["all_nodes"]:
2218
+ original_nodes = response["all_nodes"]["nodes"]
2219
+ node_count = len(original_nodes)
2220
+
2221
+ # Keep only first 3 nodes with essential data
2222
+ truncated_nodes = []
2223
+ for i, node in enumerate(original_nodes[:3]):
2224
+ truncated_node = {
2225
+ "title": node.get("title", ""),
2226
+ "class_name": node.get("class_name", ""),
2227
+ "pos_x": node.get("pos_x", 0),
2228
+ "pos_y": node.get("pos_y", 0),
2229
+ "guid": node.get("guid", ""),
2230
+ "is_enabled": node.get("is_enabled", True)
2231
+ }
2232
+
2233
+ # Keep only essential pins (first 2)
2234
+ if "pins" in node:
2235
+ truncated_node["pins"] = []
2236
+ for pin in node["pins"][:2]:
2237
+ truncated_pin = {
2238
+ "name": pin.get("name", ""),
2239
+ "direction": pin.get("direction", ""),
2240
+ "type": pin.get("type", ""),
2241
+ "connected": pin.get("connected", False)
2242
+ }
2243
+ truncated_node["pins"].append(truncated_pin)
2244
+
2245
+ if len(node["pins"]) > 2:
2246
+ truncated_node["pins"].append({
2247
+ "name": f"... and {len(node['pins']) - 2} more pins",
2248
+ "direction": "Info",
2249
+ "type": "truncated",
2250
+ "connected": False
2251
+ })
2252
+
2253
+ truncated_nodes.append(truncated_node)
2254
+
2255
+ # Add summary info
2256
+ if node_count > 3:
2257
+ truncated_nodes.append({
2258
+ "title": f"... and {node_count - 3} more nodes (use get_all_nodes_in_graph for full data)",
2259
+ "class_name": "TruncatedInfo",
2260
+ "pos_x": 0,
2261
+ "pos_y": 0,
2262
+ "guid": "TRUNCATED",
2263
+ "is_enabled": True,
2264
+ "pins": []
2265
+ })
2266
+
2267
+ response["all_nodes"]["nodes"] = truncated_nodes
2268
+ response["all_nodes"]["total_nodes"] = node_count
2269
+ response["all_nodes"]["truncated"] = True
2270
+ response["all_nodes"]["truncation_reason"] = "Response too large for Cursor MCP - use get_all_nodes_in_graph for complete data"
2271
+
2272
+ # Recalculate response size
2273
+ response_str = json.dumps(response, indent=2)
2274
+ new_size = len(response_str.encode('utf-8'))
2275
+ print(f"Response truncated: {response_size} -> {new_size} bytes")
2276
+
2277
+ # Return the (possibly truncated) context as JSON for AI analysis
2278
+ return response_str
2279
+ else:
2280
+ return f"Failed to get Blueprint context: {response.get('error', 'No active Blueprint editor found')}"
2281
+
2282
+ except Exception as e:
2283
+ return f"Error getting Blueprint context: {str(e)}"
2284
+
2285
+ def _validate_blueprint_for_functions(blueprint_context: dict) -> dict:
2286
+ """
2287
+ Validate if a Blueprint can have custom functions added to it.
2288
+
2289
+ Args:
2290
+ blueprint_context: Blueprint context information
2291
+
2292
+ Returns:
2293
+ dict: Validation result with success status and recommendations
2294
+ """
2295
+ blueprint_info = blueprint_context.get("blueprint_info", {})
2296
+ blueprint_type = blueprint_info.get("type", "")
2297
+ blueprint_name = blueprint_info.get("name", "Unknown")
2298
+ blueprint_path = blueprint_info.get("path", "")
2299
+
2300
+ # Level Script Blueprints DO support custom functions - removed incorrect restriction
2301
+
2302
+ return {
2303
+ "can_add_functions": True,
2304
+ "blueprint_type": blueprint_type,
2305
+ "blueprint_name": blueprint_name
2306
+ }
2307
+
2308
+ def _generate_blueprint_function_from_context(function_description: str, analysis_approach: str = "smart",
2309
+ target_blueprint_path: str = None) -> str:
2310
+ """
2311
+ Generate a complete Blueprint function based on the current Blueprint context and a description.
2312
+ This tool analyzes the existing Blueprint structure and creates contextually appropriate functions.
2313
+
2314
+ IMPORTANT: Level Script Blueprints do not support custom functions. If targeting a Level Script,
2315
+ this tool will suggest creating a regular Blueprint class instead.
2316
+
2317
+ Args:
2318
+ function_description: Natural language description of what the function should do
2319
+ (e.g., "Create a health system that damages the player when they take damage")
2320
+ analysis_approach: How to analyze the context - "smart" (default), "minimal", or "comprehensive"
2321
+ target_blueprint_path: Specific Blueprint to target (optional, uses active Blueprint if not specified)
2322
+
2323
+ Returns:
2324
+ JSON string containing the generated function details and implementation steps
2325
+
2326
+ Example:
2327
+ generate_blueprint_function_from_context(
2328
+ "Add a jump ability that plays a sound effect and particle effect",
2329
+ "smart"
2330
+ )
2331
+
2332
+ This tool will:
2333
+ 1. Get current Blueprint context (nodes, connections, variables, etc.)
2334
+ 2. Analyze existing patterns and architecture
2335
+ 3. Generate appropriate function with inputs/outputs
2336
+ 4. Create node sequence that integrates well with existing code
2337
+ 5. Return implementation details and reasoning
2338
+ """
2339
+ try:
2340
+ # Use cached context if available, otherwise fetch fresh
2341
+ context_response = get_cached_blueprint_context()
2342
+
2343
+ # If specific Blueprint path is provided or cache is invalid, fetch fresh
2344
+ if target_blueprint_path or not context_response:
2345
+ context_cmd = {
2346
+ "type": "get_blueprint_context",
2347
+ "include_editor_state": True,
2348
+ "include_graph_info": True,
2349
+ "include_selected_nodes": True,
2350
+ "include_open_editors": False
2351
+ }
2352
+
2353
+ if target_blueprint_path:
2354
+ context_cmd["specific_blueprint_path"] = target_blueprint_path
2355
+
2356
+ context_response = send_to_unreal(context_cmd)
2357
+
2358
+ # Update cache with fresh context
2359
+ if context_response.get("success"):
2360
+ global _blueprint_context_cache
2361
+ _blueprint_context_cache["cached_context"] = context_response
2362
+ _blueprint_context_cache["cache_timestamp"] = time.time()
2363
+ _blueprint_context_cache["cache_blueprint_path"] = context_response.get("active_blueprint", {}).get("path", "")
2364
+
2365
+ if not context_response or not context_response.get("success"):
2366
+ return f"Failed to get Blueprint context: {context_response.get('error', 'Unknown error') if context_response else 'No context available'}"
2367
+
2368
+ # Level Script Blueprints DO support custom functions - removed incorrect validation
2369
+
2370
+ # Analyze the Blueprint context for function generation
2371
+ analysis = analyze_blueprint_for_function_generation(context_response, function_description, analysis_approach)
2372
+
2373
+ # Generate the function specification
2374
+ function_spec = generate_function_specification(analysis, function_description)
2375
+
2376
+ # Create implementation plan
2377
+ implementation_plan = create_implementation_plan(function_spec, analysis)
2378
+
2379
+ # Return comprehensive function generation result
2380
+ result = {
2381
+ "success": True,
2382
+ "function_description": function_description,
2383
+ "target_blueprint": analysis.get("blueprint_info", {}).get("name", "Unknown"),
2384
+ "analysis_approach": analysis_approach,
2385
+ "context_analysis": analysis,
2386
+ "function_specification": function_spec,
2387
+ "implementation_plan": implementation_plan,
2388
+ "integration_suggestions": generate_integration_suggestions(analysis, function_spec)
2389
+ }
2390
+
2391
+ return json.dumps(result, indent=2)
2392
+
2393
+ except Exception as e:
2394
+ return f"Error generating Blueprint function: {str(e)}"
2395
+
2396
+ def analyze_blueprint_for_function_generation(context, function_description, approach):
2397
+ """Analyze Blueprint context to inform function generation"""
2398
+ analysis = {
2399
+ "blueprint_info": {},
2400
+ "existing_patterns": [],
2401
+ "available_variables": [],
2402
+ "node_usage_patterns": [],
2403
+ "architectural_insights": [],
2404
+ "integration_opportunities": []
2405
+ }
2406
+
2407
+ # Extract basic Blueprint info
2408
+ if context.get("active_blueprint"):
2409
+ analysis["blueprint_info"] = {
2410
+ "name": context["active_blueprint"].get("name", "Unknown"),
2411
+ "path": context["active_blueprint"].get("path", ""),
2412
+ "type": context.get("editor_state", {}).get("blueprint_type", "Unknown")
2413
+ }
2414
+
2415
+ # Analyze existing nodes and patterns
2416
+ all_nodes = context.get("all_nodes", {}).get("nodes", [])
2417
+ if all_nodes:
2418
+ # Count node types
2419
+ node_types = {}
2420
+ execution_patterns = []
2421
+
2422
+ for node in all_nodes:
2423
+ node_class = node.get("class_name", "Unknown")
2424
+ node_types[node_class] = node_types.get(node_class, 0) + 1
2425
+
2426
+ # Analyze execution patterns
2427
+ if node.get("pins"):
2428
+ exec_outputs = [pin for pin in node["pins"]
2429
+ if pin.get("type") == "exec" and pin.get("direction") == "Output"]
2430
+ if exec_outputs and any(pin.get("connected") for pin in exec_outputs):
2431
+ execution_patterns.append({
2432
+ "node": node.get("title", "Unknown"),
2433
+ "class": node_class,
2434
+ "connects_to": [conn.get("connected_node_title")
2435
+ for pin in exec_outputs
2436
+ for conn in pin.get("connections", [])]
2437
+ })
2438
+
2439
+ analysis["node_usage_patterns"] = [
2440
+ {"type": node_type, "count": count}
2441
+ for node_type, count in node_types.items()
2442
+ ]
2443
+ analysis["execution_patterns"] = execution_patterns
2444
+
2445
+ # Analyze architectural patterns (enhanced)
2446
+ node_titles = [node.get("title", "") for node in all_nodes]
2447
+ node_classes = [node.get("class_name", "") for node in all_nodes]
2448
+
2449
+ # Check for common Blueprint patterns
2450
+ if any("Event" in title for title in node_titles):
2451
+ analysis["architectural_insights"].append("Blueprint uses event-driven architecture")
2452
+ if any("Widget" in title or "UI" in title for title in node_titles):
2453
+ analysis["architectural_insights"].append("Blueprint includes UI/Widget functionality")
2454
+ if any("Component" in title or "Component" in cls for title, cls in zip(node_titles, node_classes)):
2455
+ analysis["architectural_insights"].append("Blueprint uses component-based architecture")
2456
+ if any("Timer" in title for title in node_titles):
2457
+ analysis["architectural_insights"].append("Blueprint uses timer-based functionality")
2458
+ if any("Input" in title for title in node_titles):
2459
+ analysis["architectural_insights"].append("Blueprint handles input events")
2460
+ if any("Animation" in title or "Anim" in title for title in node_titles):
2461
+ analysis["architectural_insights"].append("Blueprint includes animation system")
2462
+ if any("Collision" in title or "Overlap" in title for title in node_titles):
2463
+ analysis["architectural_insights"].append("Blueprint handles collision/overlap events")
2464
+ if any("AI" in title or "Pawn" in title for title in node_titles):
2465
+ analysis["architectural_insights"].append("Blueprint includes AI/character behavior")
2466
+
2467
+ # Analyze variable usage patterns from nodes
2468
+ variable_nodes = [node for node in all_nodes if "Variable" in node.get("class_name", "") or "Get" in node.get("title", "") or "Set" in node.get("title", "")]
2469
+ if variable_nodes:
2470
+ analysis["architectural_insights"].append(f"Blueprint uses {len(variable_nodes)} variable operations")
2471
+
2472
+ # Detect common game patterns
2473
+ if any(word in " ".join(node_titles).lower() for word in ["health", "damage", "death"]):
2474
+ analysis["architectural_insights"].append("Blueprint implements health/damage system")
2475
+ if any(word in " ".join(node_titles).lower() for word in ["score", "points", "win", "lose"]):
2476
+ analysis["architectural_insights"].append("Blueprint implements scoring/game state system")
2477
+ if any(word in " ".join(node_titles).lower() for word in ["spawn", "enemy", "projectile"]):
2478
+ analysis["architectural_insights"].append("Blueprint implements spawning/combat system")
2479
+
2480
+ # Suggest integration opportunities based on existing nodes
2481
+ existing_node_titles = [node.get("title", "") for node in all_nodes]
2482
+ if "Event BeginPlay" in existing_node_titles:
2483
+ analysis["integration_opportunities"].append("Can integrate with game initialization")
2484
+ if any("Create" in title for title in existing_node_titles):
2485
+ analysis["integration_opportunities"].append("Can extend object creation patterns")
2486
+
2487
+ return analysis
2488
+
2489
+ def generate_function_specification(analysis, function_description):
2490
+ """Generate detailed function specification based on analysis"""
2491
+
2492
+ # Determine function name from description (enhanced)
2493
+ import re
2494
+
2495
+ # Extract meaningful words and create a proper function name
2496
+ words = function_description.lower().split()
2497
+
2498
+ # Remove common stop words and get meaningful terms
2499
+ stop_words = {"a", "an", "the", "to", "for", "with", "by", "from", "and", "or", "but", "in", "on", "at", "that", "this", "is", "are", "was", "were", "be", "been", "being"}
2500
+ meaningful_words = [word for word in words if word.isalpha() and word not in stop_words]
2501
+
2502
+ # Take first 2-3 meaningful words and capitalize them (without adding "Function" suffix)
2503
+ if meaningful_words:
2504
+ if len(meaningful_words) >= 3:
2505
+ function_name = ''.join(word.capitalize() for word in meaningful_words[:3])
2506
+ elif len(meaningful_words) == 2:
2507
+ function_name = ''.join(word.capitalize() for word in meaningful_words)
2508
+ else:
2509
+ function_name = meaningful_words[0].capitalize()
2510
+ else:
2511
+ function_name = "GeneratedFunction"
2512
+
2513
+ # Ensure function name starts with a verb if possible
2514
+ action_words = {"create", "add", "remove", "delete", "update", "set", "get", "play", "stop", "start", "enable", "disable", "spawn", "destroy", "move", "rotate", "scale", "handle", "process", "check", "validate"}
2515
+ first_word = words[0] if words else ""
2516
+
2517
+ if first_word in action_words:
2518
+ # Good, starts with action word
2519
+ pass
2520
+ elif any(word in action_words for word in words[:3]):
2521
+ # Move action word to front
2522
+ action_word = next(word for word in words[:3] if word in action_words)
2523
+ other_words = [word for word in meaningful_words[:3] if word != action_word]
2524
+ function_name = action_word.capitalize() + ''.join(word.capitalize() for word in other_words)
2525
+
2526
+ # Clean up the function name
2527
+ function_name = re.sub(r'[^a-zA-Z0-9]', '', function_name)
2528
+ if not function_name or not function_name[0].isalpha():
2529
+ function_name = "GeneratedFunction"
2530
+
2531
+ spec = {
2532
+ "function_name": function_name,
2533
+ "description": function_description,
2534
+ "inputs": [],
2535
+ "outputs": [],
2536
+ "suggested_nodes": [],
2537
+ "complexity_estimate": "Medium"
2538
+ }
2539
+
2540
+ # Analyze description for common patterns and suggest inputs/outputs
2541
+ desc_lower = function_description.lower()
2542
+
2543
+ # Enhanced input patterns based on actual description analysis
2544
+ words = function_description.lower().split()
2545
+
2546
+ # Look for specific damage/health parameters
2547
+ if "damage" in desc_lower:
2548
+ if any(word in desc_lower for word in ["amount", "value", "points"]):
2549
+ spec["inputs"].append({"name": "DamageAmount", "type": "float", "description": "Amount of damage to apply"})
2550
+ if "player" in desc_lower or "character" in desc_lower:
2551
+ spec["inputs"].append({"name": "DamagedActor", "type": "object", "description": "Actor receiving damage"})
2552
+
2553
+ # Look for health system parameters
2554
+ if "health" in desc_lower:
2555
+ if "max" in desc_lower or "maximum" in desc_lower:
2556
+ spec["inputs"].append({"name": "MaxHealth", "type": "float", "description": "Maximum health value"})
2557
+ if "current" in desc_lower or "set" in desc_lower:
2558
+ spec["inputs"].append({"name": "NewHealthValue", "type": "float", "description": "New health amount"})
2559
+
2560
+ # Look for movement/jump parameters
2561
+ if "jump" in desc_lower:
2562
+ if "force" in desc_lower or "power" in desc_lower or "strength" in desc_lower:
2563
+ spec["inputs"].append({"name": "JumpForce", "type": "vector", "description": "Jump force vector"})
2564
+
2565
+ # Look for spawn parameters
2566
+ if "spawn" in desc_lower or "create" in desc_lower:
2567
+ if "location" in desc_lower or "position" in desc_lower:
2568
+ spec["inputs"].append({"name": "SpawnLocation", "type": "vector", "description": "Location to spawn object"})
2569
+ if "class" in desc_lower or "type" in desc_lower:
2570
+ spec["inputs"].append({"name": "ActorClass", "type": "class", "description": "Class of actor to spawn"})
2571
+
2572
+ # Look for UI/widget parameters
2573
+ if any(word in desc_lower for word in ["widget", "ui", "screen", "menu"]):
2574
+ if "show" in desc_lower or "display" in desc_lower:
2575
+ spec["inputs"].append({"name": "WidgetClass", "type": "class", "description": "Widget class to display"})
2576
+
2577
+ # Look for sound parameters
2578
+ if "sound" in desc_lower or "audio" in desc_lower:
2579
+ spec["inputs"].append({"name": "SoundToPlay", "type": "object", "description": "Sound asset to play"})
2580
+ if "location" in desc_lower:
2581
+ spec["inputs"].append({"name": "SoundLocation", "type": "vector", "description": "Location to play sound"})
2582
+
2583
+ # Look for timer parameters
2584
+ if "timer" in desc_lower:
2585
+ if "delay" in desc_lower or "time" in desc_lower or "duration" in desc_lower:
2586
+ spec["inputs"].append({"name": "TimerDuration", "type": "float", "description": "Timer duration in seconds"})
2587
+
2588
+ # Common output patterns
2589
+ if any(word in desc_lower for word in ["success", "result", "completed"]):
2590
+ spec["outputs"].append({"name": "Success", "type": "boolean", "description": "Whether operation succeeded"})
2591
+ if any(word in desc_lower for word in ["create", "spawn", "generate"]):
2592
+ spec["outputs"].append({"name": "CreatedObject", "type": "object", "description": "The created object reference"})
2593
+
2594
+ # Suggest nodes based on description (enhanced with more patterns)
2595
+ if "damage" in desc_lower:
2596
+ spec["suggested_nodes"].extend(["Apply Damage", "Set Health", "Branch", "Greater", "Print String"])
2597
+ if "sound" in desc_lower:
2598
+ spec["suggested_nodes"].extend(["Play Sound", "Play Sound at Location", "Spawn Sound 2D"])
2599
+ if "jump" in desc_lower:
2600
+ spec["suggested_nodes"].extend(["Launch Character", "Set Movement Mode", "Branch", "Get Velocity"])
2601
+ if "widget" in desc_lower or "ui" in desc_lower:
2602
+ spec["suggested_nodes"].extend(["Create Widget", "Add to Viewport", "Set Visibility"])
2603
+ if "timer" in desc_lower:
2604
+ spec["suggested_nodes"].extend(["Set Timer by Function Name", "Clear Timer", "Get Timer Remaining"])
2605
+ if "move" in desc_lower or "movement" in desc_lower:
2606
+ spec["suggested_nodes"].extend(["Add Movement Input", "Set Actor Location", "Get Forward Vector"])
2607
+ if "health" in desc_lower:
2608
+ spec["suggested_nodes"].extend(["Set Health", "Get Health", "Branch", "Less Equal"])
2609
+ if "spawn" in desc_lower or "create" in desc_lower:
2610
+ spec["suggested_nodes"].extend(["Spawn Actor from Class", "Set Actor Location", "IsValid"])
2611
+ if "collision" in desc_lower or "overlap" in desc_lower:
2612
+ spec["suggested_nodes"].extend(["Set Collision Response", "OnComponentBeginOverlap", "Cast To"])
2613
+ if "animation" in desc_lower or "anim" in desc_lower:
2614
+ spec["suggested_nodes"].extend(["Play Animation", "Set Animation Mode", "Get Anim Instance"])
2615
+ if "input" in desc_lower:
2616
+ spec["suggested_nodes"].extend(["InputAction", "InputAxis", "Enable Input", "Disable Input"])
2617
+
2618
+ # Add common utility nodes for most functions
2619
+ if not spec["suggested_nodes"]: # If no specific nodes were suggested
2620
+ spec["suggested_nodes"].extend(["Branch", "Print String", "IsValid"])
2621
+
2622
+ # Always add debug/utility nodes for complex functions
2623
+ if len(spec["suggested_nodes"]) > 3:
2624
+ spec["suggested_nodes"].append("Print String") # For debugging
2625
+
2626
+ # Context-aware node suggestions based on existing Blueprint patterns
2627
+ existing_patterns = analysis.get("node_usage_patterns", [])
2628
+ architectural_insights = analysis.get("architectural_insights", [])
2629
+
2630
+ # Suggest nodes based on existing Blueprint architecture
2631
+ if any("health" in insight.lower() for insight in architectural_insights):
2632
+ if "health" in desc_lower and "Set Health" not in spec["suggested_nodes"]:
2633
+ spec["suggested_nodes"].insert(0, "Set Health")
2634
+
2635
+ if any("ui" in insight.lower() or "widget" in insight.lower() for insight in architectural_insights):
2636
+ if any(word in desc_lower for word in ["show", "display", "update"]) and "Get Widget" not in spec["suggested_nodes"]:
2637
+ spec["suggested_nodes"].append("Get Widget")
2638
+
2639
+ if any("input" in insight.lower() for insight in architectural_insights):
2640
+ if any(word in desc_lower for word in ["enable", "disable", "control"]):
2641
+ spec["suggested_nodes"].append("Set Input Mode")
2642
+
2643
+ # Add commonly used nodes from existing Blueprint
2644
+ common_node_types = [pattern["type"] for pattern in existing_patterns if pattern["count"] > 2]
2645
+ for node_type in common_node_types[:2]: # Add up to 2 commonly used node types
2646
+ if any(common in node_type for common in ["K2Node_CallFunction", "K2Node_Event"]):
2647
+ continue # Skip these base types
2648
+ if node_type not in spec["suggested_nodes"]:
2649
+ spec["suggested_nodes"].append(node_type.replace("K2Node_", "").replace("_", " "))
2650
+
2651
+ # Remove duplicates while preserving order
2652
+ seen = set()
2653
+ spec["suggested_nodes"] = [node for node in spec["suggested_nodes"] if not (node in seen or seen.add(node))]
2654
+
2655
+ # Ensure reasonable node count (between 2-8 nodes typically)
2656
+ if len(spec["suggested_nodes"]) > 8:
2657
+ spec["suggested_nodes"] = spec["suggested_nodes"][:8]
2658
+ elif len(spec["suggested_nodes"]) < 2:
2659
+ spec["suggested_nodes"].extend(["Branch", "Print String"])
2660
+
2661
+ return spec
2662
+
2663
+ def create_implementation_plan(function_spec, analysis):
2664
+ """Create step-by-step implementation plan"""
2665
+ plan = {
2666
+ "steps": [],
2667
+ "estimated_nodes": len(function_spec["suggested_nodes"]) + 2, # +2 for entry/result
2668
+ "integration_points": [],
2669
+ "considerations": []
2670
+ }
2671
+
2672
+ # Step 1: Create function
2673
+ plan["steps"].append({
2674
+ "step": 1,
2675
+ "action": "create_function",
2676
+ "description": f"Create function '{function_spec['function_name']}'",
2677
+ "details": {
2678
+ "function_name": function_spec["function_name"],
2679
+ "inputs": function_spec["inputs"],
2680
+ "outputs": function_spec["outputs"]
2681
+ }
2682
+ })
2683
+
2684
+ # Step 2: Add nodes
2685
+ for i, node_type in enumerate(function_spec["suggested_nodes"]):
2686
+ plan["steps"].append({
2687
+ "step": i + 2,
2688
+ "action": "add_node",
2689
+ "description": f"Add {node_type} node",
2690
+ "details": {
2691
+ "node_type": node_type,
2692
+ "position": [300 + i * 200, 100],
2693
+ "purpose": f"Implements part of: {function_spec['description']}"
2694
+ }
2695
+ })
2696
+
2697
+ # Step 3: Connect nodes
2698
+ plan["steps"].append({
2699
+ "step": len(function_spec["suggested_nodes"]) + 2,
2700
+ "action": "connect_nodes",
2701
+ "description": "Connect all nodes in execution order",
2702
+ "details": "Sequential execution flow from function entry to result"
2703
+ })
2704
+
2705
+ # Integration suggestions
2706
+ existing_patterns = analysis.get("execution_patterns", [])
2707
+ if existing_patterns:
2708
+ plan["integration_points"].append(
2709
+ f"Can integrate with existing pattern: {existing_patterns[0].get('node', 'Unknown')}"
2710
+ )
2711
+
2712
+ # Considerations
2713
+ blueprint_type = analysis.get("blueprint_info", {}).get("type", "")
2714
+ if blueprint_type == "LevelScript":
2715
+ plan["considerations"].append("Level Blueprint - consider using global events")
2716
+ if "Widget" in str(analysis):
2717
+ plan["considerations"].append("UI elements present - consider widget interactions")
2718
+
2719
+ return plan
2720
+
2721
+ def generate_integration_suggestions(analysis, function_spec):
2722
+ """Generate suggestions for integrating the new function"""
2723
+ suggestions = []
2724
+
2725
+ # Based on existing nodes
2726
+ existing_events = []
2727
+ all_nodes = analysis.get("execution_patterns", [])
2728
+
2729
+ for pattern in all_nodes:
2730
+ if "Event" in pattern.get("node", ""):
2731
+ existing_events.append(pattern["node"])
2732
+
2733
+ if existing_events:
2734
+ suggestions.append({
2735
+ "type": "event_integration",
2736
+ "description": f"Call {function_spec['function_name']} from existing event",
2737
+ "target": existing_events[0],
2738
+ "implementation": f"Add call to {function_spec['function_name']} after {existing_events[0]}"
2739
+ })
2740
+
2741
+ # Based on Blueprint type
2742
+ blueprint_type = analysis.get("blueprint_info", {}).get("type", "")
2743
+ if blueprint_type == "LevelScript":
2744
+ suggestions.append({
2745
+ "type": "level_limitation_warning",
2746
+ "description": "Level Script Blueprints cannot have custom functions",
2747
+ "implementation": "Create a regular Blueprint class (Actor, Object, etc.) for custom functions, or use events in the Level Script"
2748
+ })
2749
+ suggestions.append({
2750
+ "type": "level_integration",
2751
+ "description": "Integrate with level-specific gameplay events",
2752
+ "implementation": "Consider calling from BeginPlay or level-specific triggers"
2753
+ })
2754
+
2755
+ return suggestions
2756
+
2757
+ def _implement_generated_blueprint_function(generation_result: str, execute_plan: bool = True) -> str:
2758
+ """
2759
+ Actually implement a Blueprint function that was generated by generate_blueprint_function_from_context.
2760
+ This tool takes the output from the generator and creates the actual Blueprint function.
2761
+
2762
+ Args:
2763
+ generation_result: JSON string output from generate_blueprint_function_from_context
2764
+ execute_plan: Whether to automatically execute the implementation plan (default: True)
2765
+
2766
+ Returns:
2767
+ JSON string with implementation results and created function details
2768
+
2769
+ Example:
2770
+ 1. First generate: result = generate_blueprint_function_from_context("Add jump ability")
2771
+ 2. Then implement: implement_generated_blueprint_function(result, True)
2772
+
2773
+ This tool will:
2774
+ 1. Parse the generation result
2775
+ 2. Create the Blueprint function with specified inputs/outputs
2776
+ 3. Add all suggested nodes in correct positions
2777
+ 4. Connect nodes according to the implementation plan
2778
+ 5. Return success/failure details with function ID for further use
2779
+ """
2780
+ try:
2781
+ # Parse the generation result
2782
+ try:
2783
+ gen_data = json.loads(generation_result)
2784
+ except json.JSONDecodeError:
2785
+ return json.dumps({
2786
+ "success": False,
2787
+ "error": "Invalid generation result JSON"
2788
+ })
2789
+
2790
+ if not gen_data.get("success"):
2791
+ return json.dumps({
2792
+ "success": False,
2793
+ "error": "Generation result indicates failure"
2794
+ })
2795
+
2796
+ function_spec = gen_data.get("function_specification", {})
2797
+ implementation_plan = gen_data.get("implementation_plan", {})
2798
+ target_blueprint = gen_data.get("target_blueprint", "Unknown")
2799
+
2800
+ # Get the target Blueprint path from context
2801
+ context_cmd = {"type": "get_blueprint_context", "include_editor_state": True}
2802
+ context_response = send_to_unreal(context_cmd)
2803
+
2804
+ if not context_response.get("success"):
2805
+ return json.dumps({
2806
+ "success": False,
2807
+ "error": f"Cannot get Blueprint context: {context_response.get('error')}"
2808
+ })
2809
+
2810
+ blueprint_path = context_response.get("active_blueprint", {}).get("path", "")
2811
+ if not blueprint_path:
2812
+ return json.dumps({
2813
+ "success": False,
2814
+ "error": "No active Blueprint path found"
2815
+ })
2816
+
2817
+ implementation_results = {
2818
+ "success": True,
2819
+ "target_blueprint": target_blueprint,
2820
+ "blueprint_path": blueprint_path,
2821
+ "function_name": function_spec.get("function_name", "Unknown"),
2822
+ "implementation_steps": [],
2823
+ "created_function_id": None,
2824
+ "created_nodes": [],
2825
+ "errors": []
2826
+ }
2827
+
2828
+ if not execute_plan:
2829
+ implementation_results["message"] = "Plan parsed successfully but not executed (execute_plan=False)"
2830
+ return json.dumps(implementation_results, indent=2)
2831
+
2832
+ # Step 1: Create the function (check if it already exists first)
2833
+ step1 = implementation_plan.get("steps", [{}])[0]
2834
+ if step1.get("action") == "create_function":
2835
+ details = step1.get("details", {})
2836
+ function_name = details.get("function_name", "GeneratedFunction")
2837
+
2838
+ # Check if function already exists to prevent duplication
2839
+ check_function_cmd = {
2840
+ "type": "get_all_nodes",
2841
+ "blueprint_path": blueprint_path
2842
+ }
2843
+
2844
+ existing_check = send_to_unreal(check_function_cmd)
2845
+ function_already_exists = False
2846
+ existing_function_id = None
2847
+
2848
+ if existing_check.get("success"):
2849
+ # Check if function with EXACT same name already exists (prevent duplicates)
2850
+ for graph_name, graph_data in existing_check.get("graphs", {}).items():
2851
+ # Only match exact function names, not partial matches
2852
+ graph_clean_name = graph_name.replace(" ", "").lower()
2853
+ function_clean_name = function_name.replace(" ", "").lower()
2854
+
2855
+ if graph_clean_name == function_clean_name:
2856
+ function_already_exists = True
2857
+ # Try to get the function entry node ID
2858
+ for node in graph_data.get("nodes", []):
2859
+ if node.get("type") == "FunctionEntry":
2860
+ existing_function_id = node.get("id")
2861
+ break
2862
+ break
2863
+
2864
+ # If function exists, suggest a unique name instead of reusing
2865
+ if function_already_exists:
2866
+ counter = 2
2867
+ original_name = function_name
2868
+ while function_already_exists:
2869
+ function_name = f"{original_name}{counter}"
2870
+ function_already_exists = False
2871
+ for graph_name, graph_data in existing_check.get("graphs", {}).items():
2872
+ graph_clean_name = graph_name.replace(" ", "").lower()
2873
+ function_clean_name = function_name.replace(" ", "").lower()
2874
+ if graph_clean_name == function_clean_name:
2875
+ function_already_exists = True
2876
+ break
2877
+ counter += 1
2878
+ if counter > 10: # Prevent infinite loop
2879
+ break
2880
+
2881
+ # Update the function name in details
2882
+ details["function_name"] = function_name
2883
+ function_already_exists = False # Create with new name
2884
+
2885
+ if function_already_exists and existing_function_id:
2886
+ # Function already exists, use it instead of creating a new one
2887
+ implementation_results["created_function_id"] = existing_function_id
2888
+ implementation_results["implementation_steps"].append({
2889
+ "step": 1,
2890
+ "action": "use_existing_function",
2891
+ "success": True,
2892
+ "result": f"Using existing function: {function_name}"
2893
+ })
2894
+ function_result = {"success": True, "function_id": existing_function_id}
2895
+ else:
2896
+ # Create new function
2897
+ inputs_json = json.dumps(details.get("inputs", []))
2898
+ outputs_json = json.dumps(details.get("outputs", []))
2899
+
2900
+ create_function_cmd = {
2901
+ "type": "add_function",
2902
+ "blueprint_path": blueprint_path,
2903
+ "function_name": function_name,
2904
+ "inputs": inputs_json,
2905
+ "outputs": outputs_json
2906
+ }
2907
+
2908
+ function_result = send_to_unreal(create_function_cmd)
2909
+
2910
+ if function_result.get("success") or function_result.get("function_id"):
2911
+ function_id = function_result.get("function_id", "")
2912
+ implementation_results["created_function_id"] = function_id
2913
+ implementation_results["implementation_steps"].append({
2914
+ "step": 1,
2915
+ "action": "create_function",
2916
+ "success": True,
2917
+ "result": f"Created function: {details.get('function_name')}"
2918
+ })
2919
+
2920
+ # Step 2: Add nodes to the function
2921
+ suggested_nodes = function_spec.get("suggested_nodes", [])
2922
+ node_positions = {}
2923
+
2924
+ for i, node_type in enumerate(suggested_nodes):
2925
+ add_node_cmd = {
2926
+ "type": "add_node",
2927
+ "blueprint_path": blueprint_path,
2928
+ "function_id": function_id,
2929
+ "node_type": node_type,
2930
+ "node_position": [300 + i * 250, 100 + (i % 2) * 150],
2931
+ "node_properties": {}
2932
+ }
2933
+
2934
+ node_result = send_to_unreal(add_node_cmd)
2935
+
2936
+ if node_result.get("success") or node_result.get("node_id"):
2937
+ node_id = node_result.get("node_id", "")
2938
+ implementation_results["created_nodes"].append({
2939
+ "node_type": node_type,
2940
+ "node_id": node_id,
2941
+ "position": add_node_cmd["node_position"]
2942
+ })
2943
+ node_positions[node_type] = node_id
2944
+
2945
+ implementation_results["implementation_steps"].append({
2946
+ "step": i + 2,
2947
+ "action": "add_node",
2948
+ "success": True,
2949
+ "result": f"Added {node_type} node"
2950
+ })
2951
+ else:
2952
+ implementation_results["errors"].append(f"Failed to add {node_type} node: {node_result.get('error', 'Unknown error')}")
2953
+
2954
+ # Step 3: Connect nodes (basic sequential connection)
2955
+ if len(implementation_results["created_nodes"]) > 1:
2956
+ connections = []
2957
+
2958
+ # Connect function entry to first node
2959
+ if implementation_results["created_nodes"]:
2960
+ first_node = implementation_results["created_nodes"][0]
2961
+ connections.append({
2962
+ "source_node_id": "function_entry", # Will need to get actual entry node ID
2963
+ "source_pin": "exec",
2964
+ "target_node_id": first_node["node_id"],
2965
+ "target_pin": "exec"
2966
+ })
2967
+
2968
+ # Connect nodes sequentially
2969
+ for i in range(len(implementation_results["created_nodes"]) - 1):
2970
+ source_node = implementation_results["created_nodes"][i]
2971
+ target_node = implementation_results["created_nodes"][i + 1]
2972
+
2973
+ connections.append({
2974
+ "source_node_id": source_node["node_id"],
2975
+ "source_pin": "exec", # Default exec pin
2976
+ "target_node_id": target_node["node_id"],
2977
+ "target_pin": "exec"
2978
+ })
2979
+
2980
+ # Attempt to connect nodes
2981
+ if connections:
2982
+ connect_cmd = {
2983
+ "type": "connect_nodes_bulk",
2984
+ "blueprint_path": blueprint_path,
2985
+ "function_id": function_id,
2986
+ "connections": connections
2987
+ }
2988
+
2989
+ connect_result = send_to_unreal(connect_cmd)
2990
+
2991
+ if connect_result.get("success") or connect_result.get("successful_connections", 0) > 0:
2992
+ implementation_results["implementation_steps"].append({
2993
+ "step": len(suggested_nodes) + 2,
2994
+ "action": "connect_nodes",
2995
+ "success": True,
2996
+ "result": f"Connected {connect_result.get('successful_connections', 0)} node pairs"
2997
+ })
2998
+ else:
2999
+ implementation_results["errors"].append(f"Node connection failed: {connect_result.get('error', 'Unknown error')}")
3000
+
3001
+ # Compile the Blueprint
3002
+ compile_cmd = {
3003
+ "type": "compile_blueprint",
3004
+ "blueprint_path": blueprint_path
3005
+ }
3006
+
3007
+ compile_result = send_to_unreal(compile_cmd)
3008
+ implementation_results["implementation_steps"].append({
3009
+ "step": len(suggested_nodes) + 3,
3010
+ "action": "compile_blueprint",
3011
+ "success": compile_result.get("success", False),
3012
+ "result": "Blueprint compilation " + ("succeeded" if compile_result.get("success") else "failed")
3013
+ })
3014
+
3015
+ else:
3016
+ implementation_results["success"] = False
3017
+ implementation_results["errors"].append(f"Failed to create function: {function_result.get('error', 'Unknown error')}")
3018
+
3019
+ # Final assessment
3020
+ if implementation_results["errors"]:
3021
+ implementation_results["success"] = len(implementation_results["errors"]) < len(implementation_results["implementation_steps"])
3022
+ implementation_results["message"] = f"Completed with {len(implementation_results['errors'])} errors"
3023
+ else:
3024
+ implementation_results["message"] = "Function implemented successfully"
3025
+
3026
+ return json.dumps(implementation_results, indent=2)
3027
+
3028
+ except Exception as e:
3029
+ return json.dumps({
3030
+ "success": False,
3031
+ "error": f"Implementation failed: {str(e)}"
3032
+ })
3033
+
3034
+ # Node Discovery System
3035
+ @mcp.tool()
3036
+ def discover_available_blueprint_nodes(search_categories: Optional[List[str]] = None) -> str:
3037
+ """
3038
+ Discover what Blueprint nodes are actually available in the current Unreal Engine version and project.
3039
+ This runs automatically when 'Sync with AI' is clicked to build a real-time inventory.
3040
+
3041
+ Args:
3042
+ search_categories: List of categories to search ["math", "vector", "string", "gameplay", "system", "all"]
3043
+ If None, searches all categories
3044
+
3045
+ Returns:
3046
+ JSON string with comprehensive node inventory including:
3047
+ - Available node types and their exact names
3048
+ - Library mappings (KismetMathLibrary, etc.)
3049
+ - UE version-specific naming conventions
3050
+ - Custom project nodes and plugins
3051
+
3052
+ This solves the "MakeVector not found" issue by showing what's actually available.
3053
+ """
3054
+ try:
3055
+ if search_categories is None:
3056
+ search_categories = ["math", "vector", "string", "gameplay", "system", "conversion"]
3057
+
3058
+ command = {
3059
+ "type": "discover_available_blueprint_nodes",
3060
+ "search_categories": search_categories,
3061
+ "include_libraries": True,
3062
+ "include_custom_nodes": True,
3063
+ "include_plugin_nodes": True
3064
+ }
3065
+ response = send_to_unreal(command)
3066
+
3067
+ if response.get("success"):
3068
+ # Cache the discovered nodes for this session
3069
+ global _discovered_nodes_cache
3070
+ _discovered_nodes_cache = response.get("discovered_nodes", {})
3071
+
3072
+ return json.dumps({
3073
+ "success": True,
3074
+ "message": "Node discovery completed successfully",
3075
+ "unreal_version": response.get("unreal_version", "Unknown"),
3076
+ "total_nodes_found": response.get("total_nodes_found", 0),
3077
+ "categories_searched": search_categories,
3078
+ "discovered_nodes": response.get("discovered_nodes", {}),
3079
+ "library_mappings": response.get("library_mappings", {}),
3080
+ "recommended_alternatives": response.get("recommended_alternatives", {}),
3081
+ "discovery_timestamp": response.get("discovery_timestamp", "Unknown")
3082
+ }, indent=2)
3083
+ else:
3084
+ return json.dumps({
3085
+ "success": False,
3086
+ "error": response.get("error", "Failed to discover nodes"),
3087
+ "message": "Node discovery unavailable - implementing fallback discovery",
3088
+ "fallback_attempted": True
3089
+ }, indent=2)
3090
+ except Exception as e:
3091
+ return json.dumps({
3092
+ "success": False,
3093
+ "error": f"Error discovering nodes: {str(e)}",
3094
+ "message": "Critical error in node discovery system"
3095
+ }, indent=2)
3096
+
3097
+ @mcp.tool()
3098
+ def get_node_alternatives(failed_node_name: str) -> str:
3099
+ """
3100
+ Get alternative node names for a failed node creation attempt.
3101
+ Uses the discovered node cache to suggest working alternatives.
3102
+
3103
+ Args:
3104
+ failed_node_name: The node name that failed to be created (e.g., "MakeVector")
3105
+
3106
+ Returns:
3107
+ JSON string with alternative node names that might work
3108
+ """
3109
+ try:
3110
+ command = {
3111
+ "type": "get_node_alternatives",
3112
+ "failed_node_name": failed_node_name,
3113
+ "use_discovery_cache": True
3114
+ }
3115
+ response = send_to_unreal(command)
3116
+
3117
+ if response.get("success"):
3118
+ return json.dumps(response, indent=2)
3119
+ else:
3120
+ # Fallback: use cached discovery data if available
3121
+ global _discovered_nodes_cache
3122
+ if _discovered_nodes_cache:
3123
+ alternatives = []
3124
+ search_term = failed_node_name.lower()
3125
+
3126
+ for category, nodes in _discovered_nodes_cache.items():
3127
+ for node_name in nodes:
3128
+ if search_term in node_name.lower() or "vector" in node_name.lower():
3129
+ alternatives.append({
3130
+ "node_name": node_name,
3131
+ "category": category,
3132
+ "confidence": "medium"
3133
+ })
3134
+
3135
+ return json.dumps({
3136
+ "success": True,
3137
+ "failed_node": failed_node_name,
3138
+ "alternatives_found": len(alternatives),
3139
+ "alternatives": alternatives,
3140
+ "source": "cached_discovery"
3141
+ }, indent=2)
3142
+ else:
3143
+ return json.dumps({
3144
+ "success": False,
3145
+ "error": "No alternatives found and no discovery cache available",
3146
+ "suggestion": "Run discover_available_blueprint_nodes first"
3147
+ }, indent=2)
3148
+ except Exception as e:
3149
+ return json.dumps({
3150
+ "success": False,
3151
+ "error": f"Error getting alternatives: {str(e)}"
3152
+ }, indent=2)
3153
+
3154
+ @mcp.tool()
3155
+ def smart_add_node_with_discovery(blueprint_path: str, function_id: str, requested_node: str,
3156
+ node_position: list = [0, 0], auto_find_alternatives: bool = True) -> str:
3157
+ """
3158
+ Smart node addition with enhanced discovery - now largely redundant due to comprehensive built-in discovery.
3159
+ The main add_node_to_blueprint function now handles comprehensive discovery automatically.
3160
+
3161
+ Args:
3162
+ blueprint_path: Path to the Blueprint asset
3163
+ function_id: ID of the function to add the node to
3164
+ requested_node: The node type you want (any format accepted - "Add", "SetActorRotation", "KismetMathLibrary.MakeVector", etc.)
3165
+ node_position: Position of the node in the graph [X, Y]
3166
+ auto_find_alternatives: If True, tries additional alternatives (usually not needed with enhanced discovery)
3167
+
3168
+ Returns:
3169
+ JSON string with creation result and any alternatives tried
3170
+
3171
+ Note:
3172
+ With the enhanced discovery system, this function is mostly a wrapper around add_node_to_blueprint.
3173
+ The main function now handles comprehensive discovery, variations, and suggestions automatically.
3174
+ This function is maintained for backward compatibility and additional fallback options.
3175
+ """
3176
+ try:
3177
+ result = {
3178
+ "success": False,
3179
+ "requested_node": requested_node,
3180
+ "attempts": [],
3181
+ "final_node_id": None,
3182
+ "discovery_method": "enhanced_comprehensive",
3183
+ "alternatives_tried": []
3184
+ }
3185
+
3186
+ # The enhanced add_node_to_blueprint now handles comprehensive discovery automatically
3187
+ first_attempt = add_node_to_blueprint(blueprint_path, function_id, requested_node, node_position)
3188
+ result["attempts"].append({
3189
+ "node_name": requested_node,
3190
+ "result": first_attempt,
3191
+ "success": "Successfully added" in first_attempt,
3192
+ "method": "comprehensive_discovery"
3193
+ })
3194
+
3195
+ if "Successfully added" in first_attempt:
3196
+ result["success"] = True
3197
+ if "with ID:" in first_attempt:
3198
+ result["final_node_id"] = first_attempt.split("with ID:")[-1].strip()
3199
+ result["message"] = "Node created successfully using enhanced discovery system"
3200
+ return json.dumps(result, indent=2)
3201
+
3202
+ # If it still failed, check if suggestions were provided
3203
+ if "SUGGESTIONS:" in first_attempt:
3204
+ result["suggestions_provided"] = True
3205
+ result["message"] = "Enhanced discovery provided suggestions for alternatives"
3206
+ else:
3207
+ result["suggestions_provided"] = False
3208
+ result["message"] = "Node creation failed even with comprehensive discovery"
3209
+
3210
+ # Only try additional alternatives if specifically requested and no suggestions were provided
3211
+ if auto_find_alternatives and not result["suggestions_provided"]:
3212
+ alternatives_response = get_node_alternatives(requested_node)
3213
+ try:
3214
+ alternatives_data = json.loads(alternatives_response)
3215
+ if alternatives_data.get("success") and alternatives_data.get("alternatives"):
3216
+
3217
+ for alt in alternatives_data["alternatives"][:2]: # Try top 2 alternatives as fallback
3218
+ alt_name = alt["node_name"]
3219
+ alt_attempt = add_node_to_blueprint(blueprint_path, function_id, alt_name, node_position)
3220
+
3221
+ result["attempts"].append({
3222
+ "node_name": alt_name,
3223
+ "result": alt_attempt,
3224
+ "success": "Successfully added" in alt_attempt,
3225
+ "alternative_for": requested_node,
3226
+ "method": "fallback_alternatives"
3227
+ })
3228
+ result["alternatives_tried"].append(alt_name)
3229
+
3230
+ if "Successfully added" in alt_attempt:
3231
+ result["success"] = True
3232
+ if "with ID:" in alt_attempt:
3233
+ result["final_node_id"] = alt_attempt.split("with ID:")[-1].strip()
3234
+ result["successful_alternative"] = alt_name
3235
+ result["message"] = f"Node created using fallback alternative: {alt_name}"
3236
+ break
3237
+ except:
3238
+ pass # Continue with original failure
3239
+
3240
+ return json.dumps(result, indent=2)
3241
+
3242
+ except Exception as e:
3243
+ return json.dumps({
3244
+ "success": False,
3245
+ "error": f"Error in smart node addition: {str(e)}",
3246
+ "requested_node": requested_node
3247
+ }, indent=2)
3248
+
3249
+ # Global cache for discovered nodes
3250
+ _discovered_nodes_cache = {}
3251
+
3252
+ @mcp.tool()
3253
+ def set_node_pin_default_value(blueprint_path: str, function_guid: str, node_guid: str, pin_name: str, value: str) -> str:
3254
+ """
3255
+ Set the default value of a node pin - SOLVES the automation limitation!
3256
+ This is the key function that allows full automation without manual steps.
3257
+
3258
+ Args:
3259
+ blueprint_path: Path to the Blueprint asset
3260
+ function_guid: GUID of the function containing the node
3261
+ node_guid: GUID of the node (returned from add_node_to_blueprint)
3262
+ pin_name: Name of the pin to set (e.g., "X", "Y", "Z" for MakeVector)
3263
+ value: Value to set (e.g., "0.0", "1.0")
3264
+
3265
+ Returns:
3266
+ Success/failure message
3267
+
3268
+ Example usage for MakeVector:
3269
+ set_node_pin_default_value(blueprint_path, func_id, makevector_node_id, "X", "0.0")
3270
+ set_node_pin_default_value(blueprint_path, func_id, makevector_node_id, "Y", "0.0")
3271
+ set_node_pin_default_value(blueprint_path, func_id, makevector_node_id, "Z", "1.0")
3272
+ """
3273
+ try:
3274
+ command = {
3275
+ "type": "set_node_pin_default_value",
3276
+ "blueprint_path": blueprint_path,
3277
+ "function_guid": function_guid,
3278
+ "node_guid": node_guid,
3279
+ "pin_name": pin_name,
3280
+ "value": value
3281
+ }
3282
+ response = send_to_unreal(command)
3283
+
3284
+ if response.get("success"):
3285
+ return f"Successfully set {pin_name} = {value} on node {node_guid}"
3286
+ else:
3287
+ return f"Failed to set pin value: {response.get('error', 'Unknown error')}"
3288
+
3289
+ except Exception as e:
3290
+ return f"Error setting pin value: {str(e)}"
3291
+
3292
+ @mcp.tool()
3293
+ def get_node_pin_info(blueprint_path: str, function_guid: str, node_guid: str) -> str:
3294
+ """
3295
+ Get information about all pins on a node.
3296
+ Useful for discovering what pins are available and their current values.
3297
+
3298
+ Args:
3299
+ blueprint_path: Path to the Blueprint asset
3300
+ function_guid: GUID of the function containing the node
3301
+ node_guid: GUID of the node
3302
+
3303
+ Returns:
3304
+ JSON string with pin information including names, types, directions, and current values
3305
+ """
3306
+ try:
3307
+ command = {
3308
+ "type": "get_node_pin_info",
3309
+ "blueprint_path": blueprint_path,
3310
+ "function_guid": function_guid,
3311
+ "node_guid": node_guid
3312
+ }
3313
+ response = send_to_unreal(command)
3314
+
3315
+ if response.get("success"):
3316
+ return json.dumps(response, indent=2)
3317
+ else:
3318
+ return json.dumps({
3319
+ "success": False,
3320
+ "error": response.get("error", "Failed to get pin info"),
3321
+ "node_guid": node_guid
3322
+ }, indent=2)
3323
+
3324
+ except Exception as e:
3325
+ return json.dumps({
3326
+ "success": False,
3327
+ "error": f"Error getting pin info: {str(e)}",
3328
+ "node_guid": node_guid
3329
+ }, indent=2)
3330
+
3331
+ # Project Control
3332
+ @mcp.tool()
3333
+ def create_project_folder(folder_path: str) -> str:
3334
+ """
3335
+ Create a new folder in the Unreal project content directory.
3336
+ Args:
3337
+ folder_path: Path relative to /Game (e.g., "FlappyBird/Assets")
3338
+ """
3339
+ command = {"type": "create_project_folder", "folder_path": folder_path}
3340
+ response = send_to_unreal(command)
3341
+ return response.get("message", f"Failed: {response.get('error', 'Unknown error')}")
3342
+
3343
+ @mcp.tool()
3344
+ def get_files_in_folder(folder_path: str) -> str:
3345
+ """
3346
+ List all files in a specified project folder.
3347
+ Args:
3348
+ folder_path: Path relative to /Game (e.g., "FlappyBird/Assets")
3349
+ """
3350
+ command = {"type": "get_files_in_folder", "folder_path": folder_path}
3351
+ response = send_to_unreal(command)
3352
+ return json.dumps(response.get("files", [])) if response.get("success") else f"Failed: {response.get('error')}"
3353
+
3354
+ @mcp.tool()
3355
+ def create_game_mode(game_mode_path: str, pawn_blueprint_path: str, base_class: str = "GameModeBase") -> str:
3356
+ """Create a game mode Blueprint, set its default pawn, and assign it as the current scene's default game mode.
3357
+ Args:
3358
+ game_mode_path: Path for new game mode (e.g., "/Game/MyGameMode")
3359
+ pawn_blueprint_path: Path to pawn Blueprint (e.g., "/Game/Blueprints/BP_Player")
3360
+ base_class: Base class for game mode (default: "GameModeBase")
3361
+ """
3362
+ try:
3363
+ command = {
3364
+ "type": "create_game_mode",
3365
+ "game_mode_path": game_mode_path,
3366
+ "pawn_blueprint_path": pawn_blueprint_path,
3367
+ "base_class": base_class
3368
+ }
3369
+ response = _ensure_dict_response(send_to_unreal(command))
3370
+ if response.get("success"):
3371
+ return response.get(
3372
+ "message",
3373
+ f"Created game mode {game_mode_path} with pawn {pawn_blueprint_path}"
3374
+ )
3375
+ return f"Failed: {response.get('error', 'Unknown error')}"
3376
+ except Exception as e:
3377
+ return f"Error creating game mode: {str(e)}"
3378
+
3379
+ @mcp.tool()
3380
+ def add_widget_to_user_widget(user_widget_path: str, widget_type: str, widget_name: str, parent_widget_name: str = "") -> str:
3381
+ """
3382
+ Adds a new widget (like TextBlock, Button, Image, CanvasPanel, VerticalBox) to a User Widget Blueprint.
3383
+ Args:
3384
+ user_widget_path: Path to the User Widget Blueprint (e.g., "/Game/UI/WBP_MainMenu").
3385
+ widget_type: Class name of the widget to add (e.g., "TextBlock", "Button", "Image", "CanvasPanel", "VerticalBox", "HorizontalBox", "SizeBox", "Border"). Case-sensitive.
3386
+ widget_name: A unique desired name for the new widget variable (e.g., "TitleText", "StartButton", "PlayerHealthBar"). The actual name might get adjusted for uniqueness.
3387
+ parent_widget_name: Optional. The name of an existing Panel widget (like CanvasPanel, VerticalBox) inside the User Widget to attach this new widget to. If empty, attempts to attach to the root or the first available CanvasPanel.
3388
+ Returns:
3389
+ JSON string indicating success (with actual widget name) or failure with an error message.
3390
+ """
3391
+ command = {
3392
+ "type": "add_widget_to_user_widget",
3393
+ "user_widget_path": user_widget_path,
3394
+ "widget_type": widget_type,
3395
+ "widget_name": widget_name,
3396
+ "parent_widget_name": parent_widget_name
3397
+ }
3398
+
3399
+ # Use json.loads to parse the JSON string returned by send_to_unreal
3400
+ response = _ensure_dict_response(send_to_unreal(command))
3401
+ if response.get("success"):
3402
+ actual_name = response.get("widget_name", widget_name)
3403
+ return response.get(
3404
+ "message",
3405
+ f"Successfully added widget '{actual_name}' of type '{widget_type}' to '{user_widget_path}'."
3406
+ )
3407
+ error_message = response.get("error", "Unknown C++ error")
3408
+ raw_suffix = f" Raw response: {response.get('raw')}" if "raw" in response else ""
3409
+ return f"Failed to add widget: {error_message}{raw_suffix}"
3410
+
3411
+ @mcp.tool()
3412
+ def edit_widget_property(user_widget_path: str, widget_name: str, property_name: str, value: str) -> str:
3413
+ """
3414
+ Edits a property of a specific widget within a User Widget Blueprint.
3415
+ Args:
3416
+ user_widget_path: Path to the User Widget Blueprint (e.g., "/Game/UI/WBP_MainMenu").
3417
+ widget_name: The name of the widget inside the User Widget whose property you want to change (e.g., "TitleText", "StartButton").
3418
+ property_name: The name of the property to edit. For layout properties controlled by the parent panel (like position, size, anchors in a CanvasPanel), prefix with "Slot." (e.g., "Text", "ColorAndOpacity", "Brush.ImageSize", "Slot.Position", "Slot.Size", "Slot.Anchors", "Slot.Alignment"). For UImage textures you can also use "Texture", "BrushFromTexture", or "Brush.ResourceObject". For UButton styles use "ButtonTexture" (all states) or "NormalTexture"/"HoveredTexture"/"PressedTexture". Case-sensitive.
3419
+ value: The new value for the property, formatted as a string EXACTLY as Unreal expects for ImportText. Examples:
3420
+ - Text: '"Hello World!"' (Note: String literal requires inner quotes)
3421
+ - Float: '150.0'
3422
+ - Integer: '10'
3423
+ - Boolean: 'true' or 'false'
3424
+ - LinearColor: '(R=1.0,G=0.0,B=0.0,A=1.0)'
3425
+ - Vector2D (for Size, Position): '(X=200.0,Y=50.0)'
3426
+ - Anchors: '(Minimum=(X=0.5,Y=0.0),Maximum=(X=0.5,Y=0.0))' (Top Center Anchor)
3427
+ - Alignment (Vector2D): '(X=0.5,Y=0.5)' (Center Alignment)
3428
+ - Font (FSlateFontInfo): "(FontObject=Font'/Engine/EngineFonts/Roboto.Roboto',Size=24)"
3429
+ - Texture (Object Path): "Texture2D'/Game/Textures/MyIcon.MyIcon'" OR "/Game/Textures/MyIcon"
3430
+ - Enum (e.g., Stretch): 'ScaleToFit'
3431
+ Returns:
3432
+ JSON string indicating success or failure with an error message.
3433
+ """
3434
+ command = {
3435
+ "type": "edit_widget_property",
3436
+ "user_widget_path": user_widget_path,
3437
+ "widget_name": widget_name,
3438
+ "property_name": property_name,
3439
+ "value": value # Pass the string value directly
3440
+ }
3441
+
3442
+ # Use json.loads to parse the JSON string returned by send_to_unreal
3443
+ response = _ensure_dict_response(send_to_unreal(command))
3444
+ if response.get("success"):
3445
+ return response.get(
3446
+ "message",
3447
+ f"Successfully set property '{property_name}' on widget '{widget_name}'."
3448
+ )
3449
+ error_message = response.get("error", "Unknown C++ error")
3450
+ raw_suffix = f" Raw response: {response.get('raw')}" if "raw" in response else ""
3451
+ return f"Failed to edit widget property: {error_message}{raw_suffix}"
3452
+
3453
+ # --- NEW UI TOOLS ---
3454
+
3455
+ @mcp.tool()
3456
+ def create_user_widget(widget_name: str, save_path: str = "/Game/UI", root_widget_type: str = "CanvasPanel") -> str:
3457
+ """
3458
+ Creates a new empty Widget Blueprint with a configurable root panel.
3459
+ Args:
3460
+ widget_name: Name for the new Widget Blueprint asset (e.g., "WBP_MainMenu", "WBP_HUD").
3461
+ save_path: Content folder path to save the Widget Blueprint to (e.g., "/Game/UI", "/Game/Widgets"). Defaults to "/Game/UI".
3462
+ root_widget_type: Class name of the root panel widget. Must be a panel type. Common values: "CanvasPanel" (default, free-form positioning), "VerticalBox" (vertical layout), "HorizontalBox" (horizontal layout), "Overlay" (stacked layers), "ScrollBox" (scrollable container).
3463
+ Returns:
3464
+ JSON with success, widget_path, and root_widget_name.
3465
+ """
3466
+ command = {
3467
+ "type": "create_user_widget",
3468
+ "widget_name": widget_name,
3469
+ "save_path": save_path,
3470
+ "root_widget_type": root_widget_type
3471
+ }
3472
+
3473
+ response = _ensure_dict_response(send_to_unreal(command))
3474
+ if response.get("success"):
3475
+ widget_path = response.get("widget_path", "")
3476
+ root_name = response.get("root_widget_name", "")
3477
+ return response.get("message", f"Created Widget Blueprint '{widget_name}' at {widget_path} with root '{root_name}'.")
3478
+ error_message = response.get("error", "Unknown error")
3479
+ raw_suffix = f" Raw response: {response.get('raw')}" if "raw" in response else ""
3480
+ return f"Failed to create widget: {error_message}{raw_suffix}"
3481
+
3482
+
3483
+ @mcp.tool()
3484
+ def get_widget_hierarchy(user_widget_path: str) -> str:
3485
+ """
3486
+ Inspects the full widget tree structure of a User Widget Blueprint. Returns a nested JSON hierarchy showing every widget, its type, slot type, and children.
3487
+ Use this to understand the current state of a UI before making changes.
3488
+ Args:
3489
+ user_widget_path: Path to the User Widget Blueprint (e.g., "/Game/UI/WBP_MainMenu").
3490
+ Returns:
3491
+ JSON with nested hierarchy tree: each node has "name", "class", "slot_type", "is_variable", and "children" array.
3492
+ """
3493
+ command = {
3494
+ "type": "get_widget_hierarchy",
3495
+ "user_widget_path": user_widget_path
3496
+ }
3497
+
3498
+ response = _ensure_dict_response(send_to_unreal(command))
3499
+ if response.get("success"):
3500
+ # Return the full JSON response so the AI can parse the hierarchy
3501
+ return json.dumps(response)
3502
+ error_message = response.get("error", "Unknown error")
3503
+ return f"Failed to get widget hierarchy: {error_message}"
3504
+
3505
+
3506
+ @mcp.tool()
3507
+ def get_widget_properties(user_widget_path: str, widget_name: str, property_names: str = "") -> str:
3508
+ """
3509
+ Reads current property values of a specific widget within a User Widget Blueprint.
3510
+ Args:
3511
+ user_widget_path: Path to the User Widget Blueprint (e.g., "/Game/UI/WBP_MainMenu").
3512
+ widget_name: Name of the widget to inspect (e.g., "TitleText", "StartButton").
3513
+ property_names: Optional comma-separated list of specific properties to read (e.g., "Text,ColorAndOpacity,Slot.Size"). If empty, returns all properties.
3514
+ Returns:
3515
+ JSON with widget properties and slot properties.
3516
+ """
3517
+ command = {
3518
+ "type": "get_widget_properties",
3519
+ "user_widget_path": user_widget_path,
3520
+ "widget_name": widget_name,
3521
+ "property_names": property_names
3522
+ }
3523
+
3524
+ response = _ensure_dict_response(send_to_unreal(command))
3525
+ if response.get("success"):
3526
+ return json.dumps(response)
3527
+ error_message = response.get("error", "Unknown error")
3528
+ return f"Failed to get widget properties: {error_message}"
3529
+
3530
+
3531
+ @mcp.tool()
3532
+ def remove_widget_from_user_widget(user_widget_path: str, widget_name: str, remove_children: bool = True) -> str:
3533
+ """
3534
+ Removes a widget from a User Widget Blueprint's hierarchy.
3535
+ Args:
3536
+ user_widget_path: Path to the User Widget Blueprint (e.g., "/Game/UI/WBP_MainMenu").
3537
+ widget_name: Name of the widget to remove (e.g., "OldButton", "UnusedImage").
3538
+ remove_children: If True (default), removes the widget and all its children. If False, reparents children to the removed widget's parent panel.
3539
+ Returns:
3540
+ JSON indicating success or failure.
3541
+ """
3542
+ command = {
3543
+ "type": "remove_widget_from_user_widget",
3544
+ "user_widget_path": user_widget_path,
3545
+ "widget_name": widget_name,
3546
+ "remove_children": remove_children
3547
+ }
3548
+
3549
+ response = _ensure_dict_response(send_to_unreal(command))
3550
+ if response.get("success"):
3551
+ return response.get("message", f"Successfully removed widget '{widget_name}'.")
3552
+ error_message = response.get("error", "Unknown error")
3553
+ return f"Failed to remove widget: {error_message}"
3554
+
3555
+
3556
+ @mcp.tool()
3557
+ def add_widgets_bulk(user_widget_path: str, widgets: str) -> str:
3558
+ """
3559
+ Adds multiple widgets to a User Widget Blueprint in a single call. Much more efficient than calling add_widget_to_user_widget multiple times.
3560
+ Args:
3561
+ user_widget_path: Path to the User Widget Blueprint (e.g., "/Game/UI/WBP_MainMenu").
3562
+ widgets: JSON array string where each element is an object with:
3563
+ - "widget_type": Class name (e.g., "TextBlock", "Button", "Image")
3564
+ - "widget_name": Desired unique name (e.g., "TitleText", "StartButton")
3565
+ - "parent_widget_name": Optional parent panel name (e.g., "RootPanel"). If omitted, attaches to root.
3566
+ Example: '[{"widget_type":"TextBlock","widget_name":"Title","parent_widget_name":"RootPanel"},{"widget_type":"Button","widget_name":"PlayBtn","parent_widget_name":"RootPanel"}]'
3567
+ Returns:
3568
+ JSON with results array (per-widget success/failure), failed_count, and total_count.
3569
+ """
3570
+ # Parse the widgets string to ensure it's valid JSON, then pass as list
3571
+ try:
3572
+ widgets_list = json.loads(widgets) if isinstance(widgets, str) else widgets
3573
+ except json.JSONDecodeError:
3574
+ return f"Failed to parse widgets JSON: invalid JSON format"
3575
+
3576
+ command = {
3577
+ "type": "add_widgets_bulk",
3578
+ "user_widget_path": user_widget_path,
3579
+ "widgets": widgets_list
3580
+ }
3581
+
3582
+ response = _ensure_dict_response(send_to_unreal(command))
3583
+ if response.get("success"):
3584
+ total = response.get("total_count", 0)
3585
+ failed = response.get("failed_count", 0)
3586
+ return f"Successfully added {total - failed}/{total} widgets to '{user_widget_path}'."
3587
+ # Even partial success returns useful info
3588
+ if "results" in response:
3589
+ total = response.get("total_count", 0)
3590
+ failed = response.get("failed_count", 0)
3591
+ return f"Bulk add partially completed: {total - failed}/{total} succeeded. Details: {json.dumps(response.get('results', []))}"
3592
+ error_message = response.get("error", "Unknown error")
3593
+ return f"Failed bulk add: {error_message}"
3594
+
3595
+
3596
+ @mcp.tool()
3597
+ def edit_widget_properties_bulk(user_widget_path: str, edits: str) -> str:
3598
+ """
3599
+ Sets multiple properties across multiple widgets in a single call. Much more efficient than calling edit_widget_property multiple times.
3600
+ Args:
3601
+ user_widget_path: Path to the User Widget Blueprint (e.g., "/Game/UI/WBP_MainMenu").
3602
+ edits: JSON array string where each element is an object with:
3603
+ - "widget_name": Name of the target widget
3604
+ - "property_name": Property to edit (prefix "Slot." for layout properties)
3605
+ - "value": New value string in Unreal ImportText format
3606
+ Example: '[{"widget_name":"Title","property_name":"Text","value":"\"Welcome!\""},{"widget_name":"Title","property_name":"Slot.Size","value":"(X=400.0,Y=60.0)"}]'
3607
+ Returns:
3608
+ JSON with results array (per-edit success/failure), failed_count, and total_count.
3609
+ """
3610
+ try:
3611
+ edits_list = json.loads(edits) if isinstance(edits, str) else edits
3612
+ except json.JSONDecodeError:
3613
+ return f"Failed to parse edits JSON: invalid JSON format"
3614
+
3615
+ command = {
3616
+ "type": "edit_widget_properties_bulk",
3617
+ "user_widget_path": user_widget_path,
3618
+ "edits": edits_list
3619
+ }
3620
+
3621
+ response = _ensure_dict_response(send_to_unreal(command))
3622
+ if response.get("success"):
3623
+ total = response.get("total_count", 0)
3624
+ failed = response.get("failed_count", 0)
3625
+ return f"Successfully applied {total - failed}/{total} property edits in '{user_widget_path}'."
3626
+ if "results" in response:
3627
+ total = response.get("total_count", 0)
3628
+ failed = response.get("failed_count", 0)
3629
+ return f"Bulk edit partially completed: {total - failed}/{total} succeeded. Details: {json.dumps(response.get('results', []))}"
3630
+ error_message = response.get("error", "Unknown error")
3631
+ return f"Failed bulk edit: {error_message}"
3632
+
3633
+
3634
+ @mcp.tool()
3635
+ def list_available_widget_types(include_engine_widgets: bool = True, include_project_widgets: bool = True) -> str:
3636
+ """
3637
+ Lists all available widget types that can be used with add_widget_to_user_widget and add_widgets_bulk.
3638
+ Use this to discover which widget classes are available before building a UI.
3639
+ Args:
3640
+ include_engine_widgets: Include built-in UMG/Engine widget types (TextBlock, Button, Image, etc.). Default: True.
3641
+ include_project_widgets: Include project-specific (user-created) widget types. Default: True.
3642
+ Returns:
3643
+ JSON with categorized list of widget types. Each entry has: class_name, category (Layout/Input/Display/Utility/Other), is_panel (can contain children), is_engine.
3644
+ """
3645
+ command = {
3646
+ "type": "list_available_widget_types",
3647
+ "include_engine_widgets": include_engine_widgets,
3648
+ "include_project_widgets": include_project_widgets
3649
+ }
3650
+
3651
+ response = _ensure_dict_response(send_to_unreal(command))
3652
+ if response.get("success"):
3653
+ return json.dumps(response)
3654
+ error_message = response.get("error", "Unknown error")
3655
+ return f"Failed to list widget types: {error_message}"
3656
+
3657
+
3658
+ @mcp.tool()
3659
+ def bind_widget_event(user_widget_path: str, widget_name: str, event_name: str) -> str:
3660
+ """
3661
+ Binds a widget event (like OnClicked, OnHovered) to a Blueprint event node in the Widget Blueprint's EventGraph.
3662
+ Automatically sets the widget as a variable (bIsVariable=true) if not already set.
3663
+ Args:
3664
+ user_widget_path: Path to the User Widget Blueprint (e.g., "/Game/UI/WBP_MainMenu").
3665
+ widget_name: Name of the widget to bind the event on (e.g., "StartButton", "SettingsCheckbox").
3666
+ event_name: Name of the multicast delegate event on the widget. Common events:
3667
+ - Button: "OnClicked", "OnPressed", "OnReleased", "OnHovered", "OnUnhovered"
3668
+ - CheckBox: "OnCheckStateChanged"
3669
+ - Slider: "OnValueChanged", "OnMouseCaptureBegin", "OnMouseCaptureEnd"
3670
+ - ComboBoxString: "OnSelectionChanged", "OnOpening"
3671
+ - EditableText/EditableTextBox: "OnTextChanged", "OnTextCommitted"
3672
+ Returns:
3673
+ JSON with success, event_node_guid, event_name, and widget_name.
3674
+ """
3675
+ command = {
3676
+ "type": "bind_widget_event",
3677
+ "user_widget_path": user_widget_path,
3678
+ "widget_name": widget_name,
3679
+ "event_name": event_name
3680
+ }
3681
+
3682
+ response = _ensure_dict_response(send_to_unreal(command))
3683
+ if response.get("success"):
3684
+ event_guid = response.get("event_node_guid", "")
3685
+ return response.get("message", f"Successfully bound event '{event_name}' on '{widget_name}' (GUID: {event_guid}).")
3686
+ error_message = response.get("error", "Unknown error")
3687
+ raw_suffix = f" Raw response: {response.get('raw')}" if "raw" in response else ""
3688
+ return f"Failed to bind widget event: {error_message}{raw_suffix}"
3689
+
3690
+
3691
+ @mcp.tool()
3692
+ def validate_ui_spec(ui_spec: str) -> str:
3693
+ """
3694
+ Validate a strict JSON UI spec before generation.
3695
+ Args:
3696
+ ui_spec: JSON object string with schema:
3697
+ {
3698
+ "schema_version": 1,
3699
+ "blueprint": {
3700
+ "widget_name": "WBP_Menu",
3701
+ "save_path": "/Game/UI",
3702
+ "root_widget_type": "CanvasPanel",
3703
+ "allow_overwrite": false
3704
+ },
3705
+ "widgets": [
3706
+ {"widget_name":"TitleText","widget_type":"TextBlock","parent_widget_name":"$root"}
3707
+ ],
3708
+ "properties": [
3709
+ {"widget_name":"TitleText","property_name":"Text","value":"\"Hello\""}
3710
+ ]
3711
+ }
3712
+ Returns:
3713
+ Full JSON validation response with `success`, `errors`, `warnings`, and summary metadata.
3714
+ """
3715
+ try:
3716
+ parsed_spec = json.loads(ui_spec) if isinstance(ui_spec, str) else ui_spec
3717
+ except json.JSONDecodeError as parse_error:
3718
+ return f"Failed to parse ui_spec JSON: {parse_error}"
3719
+
3720
+ if not isinstance(parsed_spec, dict):
3721
+ return "ui_spec must be a JSON object"
3722
+
3723
+ command = {
3724
+ "type": "validate_ui_spec",
3725
+ "ui_spec": parsed_spec,
3726
+ }
3727
+ response = _ensure_dict_response(send_to_unreal(command))
3728
+ if response.get("success") or "errors" in response or "warnings" in response:
3729
+ return json.dumps(response)
3730
+ error_message = response.get("error", "Unknown error")
3731
+ raw_suffix = f" Raw response: {response.get('raw')}" if "raw" in response else ""
3732
+ return f"Failed to validate ui_spec: {error_message}{raw_suffix}"
3733
+
3734
+
3735
+ @mcp.tool()
3736
+ def generate_widget_blueprint_from_spec(ui_spec: str) -> str:
3737
+ """
3738
+ Generate or regenerate a Widget Blueprint from a strict JSON UI spec.
3739
+ This is the preferred code-first UI pipeline and does not require Pillow/image slicing.
3740
+ Args:
3741
+ ui_spec: JSON object string in the strict schema validated by `validate_ui_spec`.
3742
+ Returns:
3743
+ Full JSON response with `widget_path`, operation-level results, warnings, and failures.
3744
+ """
3745
+ try:
3746
+ parsed_spec = json.loads(ui_spec) if isinstance(ui_spec, str) else ui_spec
3747
+ except json.JSONDecodeError as parse_error:
3748
+ return f"Failed to parse ui_spec JSON: {parse_error}"
3749
+
3750
+ if not isinstance(parsed_spec, dict):
3751
+ return "ui_spec must be a JSON object"
3752
+
3753
+ command = {
3754
+ "type": "generate_widget_blueprint_from_spec",
3755
+ "ui_spec": parsed_spec,
3756
+ }
3757
+ response = _ensure_dict_response(send_to_unreal(command))
3758
+ if response.get("success") or "widget_results" in response or "property_results" in response:
3759
+ return json.dumps(response)
3760
+ error_message = response.get("error", "Unknown error")
3761
+ raw_suffix = f" Raw response: {response.get('raw')}" if "raw" in response else ""
3762
+ return f"Failed to generate widget from spec: {error_message}{raw_suffix}"
3763
+
3764
+
3765
+ @mcp.tool()
3766
+ def export_widget_to_ui_spec(
3767
+ user_widget_path: str,
3768
+ include_properties: bool = True,
3769
+ property_names: str = ""
3770
+ ) -> str:
3771
+ """
3772
+ Export an existing Widget Blueprint into the strict JSON UI spec format.
3773
+ Args:
3774
+ user_widget_path: Path to widget blueprint (e.g., "/Game/UI/WBP_Menu").
3775
+ include_properties: Include property operations in exported spec.
3776
+ property_names: Optional comma-separated filter (e.g., "Text,Visibility,Slot.LayoutData,Texture").
3777
+ Empty exports all available properties.
3778
+ Returns:
3779
+ JSON response containing `ui_spec`.
3780
+ """
3781
+ command = {
3782
+ "type": "export_widget_to_ui_spec",
3783
+ "user_widget_path": user_widget_path,
3784
+ "include_properties": include_properties,
3785
+ "property_names": property_names,
3786
+ }
3787
+ response = _ensure_dict_response(send_to_unreal(command))
3788
+ if response.get("success"):
3789
+ return json.dumps(response)
3790
+ error_message = response.get("error", "Unknown error")
3791
+ raw_suffix = f" Raw response: {response.get('raw')}" if "raw" in response else ""
3792
+ return f"Failed to export widget to ui_spec: {error_message}{raw_suffix}"
3793
+
3794
+
3795
+ @mcp.tool()
3796
+ def auto_slice_ui_mockup(
3797
+ source_image_path: str,
3798
+ destination_folder: str = "/Game/UI/AutoSlices",
3799
+ base_name: str = "UI_Mockup",
3800
+ max_slices: int = 16,
3801
+ min_component_area_ratio: float = 0.0025,
3802
+ background_tolerance: int = 28,
3803
+ slice_mode: str = "auto",
3804
+ include_full_image: bool = True,
3805
+ enable_chroma_key: bool = True,
3806
+ chroma_tolerance: int = 24,
3807
+ enable_host_fallback: bool = True
3808
+ ) -> str:
3809
+ """
3810
+ Auto-slice a flattened UI mockup image into multiple textures and import them into Unreal.
3811
+ This is the first step for turning a single concept image into assignable widget art assets.
3812
+ Args:
3813
+ source_image_path: Absolute/local filesystem path to the mockup image file (PNG/JPG).
3814
+ destination_folder: Unreal content folder for imported textures (must start with /Game).
3815
+ base_name: Prefix used for generated texture asset names.
3816
+ max_slices: Maximum number of connected-component slices to generate.
3817
+ min_component_area_ratio: Ignore tiny components below this percentage of the full image area.
3818
+ background_tolerance: Color-distance threshold for separating foreground from a flat/checkerboard background.
3819
+ slice_mode: Segmentation mode: "auto" (default), "connected", or "aggressive".
3820
+ include_full_image: Also import the original full image as a fallback background texture.
3821
+ enable_chroma_key: If true, attempt simple corner-color chroma keying on each extracted slice.
3822
+ chroma_tolerance: RGB tolerance used by chroma-keying (higher removes more near-matching background).
3823
+ enable_host_fallback: If Unreal-side Pillow is missing, slice on MCP host and import results into Unreal.
3824
+ Returns:
3825
+ Full JSON response with a `manifest` field containing imported asset paths and slice metadata.
3826
+ """
3827
+ response = _run_auto_slice_with_fallback(
3828
+ source_image_path=source_image_path,
3829
+ destination_folder=destination_folder,
3830
+ base_name=base_name,
3831
+ max_slices=max_slices,
3832
+ min_component_area_ratio=min_component_area_ratio,
3833
+ background_tolerance=background_tolerance,
3834
+ slice_mode=slice_mode,
3835
+ include_full_image=include_full_image,
3836
+ enable_chroma_key=enable_chroma_key,
3837
+ chroma_tolerance=chroma_tolerance,
3838
+ enable_host_fallback=enable_host_fallback,
3839
+ )
3840
+ if response.get("success"):
3841
+ return json.dumps(response)
3842
+ error_message = response.get("error", "Unknown error")
3843
+ raw_suffix = f" Raw response: {response.get('raw')}" if "raw" in response else ""
3844
+ return f"Failed to auto-slice UI mockup: {error_message}{raw_suffix}"
3845
+
3846
+
3847
+ @mcp.tool()
3848
+ def apply_ui_slices_to_widget(
3849
+ user_widget_path: str,
3850
+ slice_manifest: str,
3851
+ assign_background: bool = True,
3852
+ assign_components: bool = True,
3853
+ max_component_assignments: int = 12,
3854
+ apply_layout_from_slices: bool = True,
3855
+ layout_scale: float = 1.0,
3856
+ auto_create_buttons_if_missing: bool = True
3857
+ ) -> str:
3858
+ """
3859
+ Apply an auto-slice manifest to a Widget Blueprint's UImage/UButton widgets.
3860
+ The tool uses name/geometry heuristics to map generated textures, and can also place widgets
3861
+ using slice bbox coordinates from the source image.
3862
+ Args:
3863
+ user_widget_path: Target widget blueprint path (e.g., "/Game/UI/WBP_RateUs").
3864
+ slice_manifest: JSON string from `auto_slice_ui_mockup` response field `manifest`.
3865
+ assign_background: If true, assign the best background candidate first.
3866
+ assign_components: If true, assign remaining slices to image/button widgets heuristically.
3867
+ max_component_assignments: Max number of non-background slice assignments.
3868
+ apply_layout_from_slices: If true, apply CanvasPanel slot layout from slice bbox coordinates.
3869
+ layout_scale: Uniform multiplier for bbox-based placement/sizing (useful for DPI/canvas differences).
3870
+ auto_create_buttons_if_missing: If true, auto-add missing slice targets (Button/Image), including button widgets for button-like slices.
3871
+ Returns:
3872
+ JSON response with assignment/failure details.
3873
+ """
3874
+ try:
3875
+ manifest_obj = json.loads(slice_manifest) if isinstance(slice_manifest, str) else slice_manifest
3876
+ except json.JSONDecodeError:
3877
+ return "Failed to parse slice_manifest JSON: invalid format"
3878
+
3879
+ provisioning_result = None
3880
+ if auto_create_buttons_if_missing and isinstance(manifest_obj, dict):
3881
+ provisioning_result = _ensure_slice_target_widgets_for_manifest(
3882
+ user_widget_path=user_widget_path,
3883
+ manifest=manifest_obj,
3884
+ max_auto_components=max_component_assignments,
3885
+ )
3886
+
3887
+ command = {
3888
+ "type": "apply_ui_slices_to_widget",
3889
+ "user_widget_path": user_widget_path,
3890
+ "slice_manifest": manifest_obj,
3891
+ "assign_background": assign_background,
3892
+ "assign_components": assign_components,
3893
+ "max_component_assignments": max_component_assignments,
3894
+ "apply_layout_from_slices": apply_layout_from_slices,
3895
+ "layout_scale": layout_scale
3896
+ }
3897
+
3898
+ response = _ensure_dict_response(send_to_unreal(command))
3899
+ if response.get("success"):
3900
+ if isinstance(provisioning_result, dict):
3901
+ response["button_widget_provisioning"] = provisioning_result
3902
+ response["target_widget_provisioning"] = provisioning_result
3903
+ return json.dumps(response)
3904
+ error_message = response.get("error", "Unknown error")
3905
+ raw_suffix = f" Raw response: {response.get('raw')}" if "raw" in response else ""
3906
+ return f"Failed to apply UI slices: {error_message}{raw_suffix}"
3907
+
3908
+
3909
+ @mcp.tool()
3910
+ def auto_slice_and_apply_ui_mockup(
3911
+ user_widget_path: str,
3912
+ source_image_path: str,
3913
+ destination_folder: str = "/Game/UI/AutoSlices",
3914
+ base_name: str = "UI_Mockup",
3915
+ max_slices: int = 16,
3916
+ min_component_area_ratio: float = 0.0025,
3917
+ background_tolerance: int = 28,
3918
+ slice_mode: str = "auto",
3919
+ include_full_image: bool = True,
3920
+ enable_chroma_key: bool = True,
3921
+ chroma_tolerance: int = 24,
3922
+ assign_background: bool = True,
3923
+ assign_components: bool = True,
3924
+ max_component_assignments: int = 12,
3925
+ apply_layout_from_slices: bool = True,
3926
+ layout_scale: float = 1.0,
3927
+ enable_host_fallback: bool = True,
3928
+ auto_create_buttons_if_missing: bool = True
3929
+ ) -> str:
3930
+ """
3931
+ Full one-shot pipeline: slice/import textures from a flattened mockup image and auto-assign them to widget images.
3932
+ Args:
3933
+ user_widget_path: Target widget blueprint path.
3934
+ source_image_path: Local image path for the mockup.
3935
+ destination_folder: Unreal content folder where generated texture assets will be imported.
3936
+ base_name: Asset-name prefix for imported textures.
3937
+ max_slices: Maximum number of slice components to generate.
3938
+ min_component_area_ratio: Minimum connected-component area ratio retained as a slice.
3939
+ background_tolerance: Color tolerance for background subtraction in flattened images.
3940
+ slice_mode: Segmentation mode: "auto" (default), "connected", or "aggressive".
3941
+ include_full_image: Import full image as fallback texture.
3942
+ enable_chroma_key: If true, attempt simple corner-color chroma keying on each extracted slice.
3943
+ chroma_tolerance: RGB tolerance used by chroma-keying (higher removes more near-matching background).
3944
+ assign_background: Auto-assign background-like slice to background-like widget.
3945
+ assign_components: Auto-assign remaining slices to image placeholders.
3946
+ max_component_assignments: Maximum number of component assignments.
3947
+ apply_layout_from_slices: Apply CanvasPanel slot layout from slice bbox coordinates.
3948
+ layout_scale: Uniform multiplier for bbox-based placement/sizing.
3949
+ enable_host_fallback: If Unreal-side Pillow is missing, slice on MCP host and then apply manifest.
3950
+ auto_create_buttons_if_missing: If true, auto-add missing slice targets (Button/Image), including button widgets for button-like slices.
3951
+ Returns:
3952
+ Full JSON with both slice and apply results.
3953
+ """
3954
+ slice_response = _run_auto_slice_with_fallback(
3955
+ source_image_path=source_image_path,
3956
+ destination_folder=destination_folder,
3957
+ base_name=base_name,
3958
+ max_slices=max_slices,
3959
+ min_component_area_ratio=min_component_area_ratio,
3960
+ background_tolerance=background_tolerance,
3961
+ slice_mode=slice_mode,
3962
+ include_full_image=include_full_image,
3963
+ enable_chroma_key=enable_chroma_key,
3964
+ chroma_tolerance=chroma_tolerance,
3965
+ enable_host_fallback=enable_host_fallback,
3966
+ )
3967
+ if not slice_response.get("success"):
3968
+ error_message = slice_response.get("error", "Unknown error")
3969
+ raw_suffix = f" Raw response: {slice_response.get('raw')}" if "raw" in slice_response else ""
3970
+ return f"Failed auto-slice/apply pipeline: {error_message}{raw_suffix}"
3971
+
3972
+ provisioning_result = None
3973
+ if auto_create_buttons_if_missing:
3974
+ manifest_obj = slice_response.get("manifest")
3975
+ if isinstance(manifest_obj, dict):
3976
+ provisioning_result = _ensure_slice_target_widgets_for_manifest(
3977
+ user_widget_path=user_widget_path,
3978
+ manifest=manifest_obj,
3979
+ max_auto_components=max_component_assignments,
3980
+ )
3981
+
3982
+ apply_command = {
3983
+ "type": "apply_ui_slices_to_widget",
3984
+ "user_widget_path": user_widget_path,
3985
+ "slice_manifest": slice_response.get("manifest"),
3986
+ "assign_background": assign_background,
3987
+ "assign_components": assign_components,
3988
+ "max_component_assignments": max_component_assignments,
3989
+ "apply_layout_from_slices": apply_layout_from_slices,
3990
+ "layout_scale": layout_scale,
3991
+ }
3992
+ apply_response = _ensure_dict_response(send_to_unreal(apply_command))
3993
+ combined_response = {
3994
+ "success": bool(slice_response.get("success")) and bool(apply_response.get("success")),
3995
+ "message": "Auto-slice and apply pipeline completed.",
3996
+ "slice_result": slice_response,
3997
+ "apply_result": apply_response,
3998
+ "manifest": slice_response.get("manifest"),
3999
+ "host_fallback_used": bool(slice_response.get("host_fallback_used")),
4000
+ }
4001
+ if isinstance(provisioning_result, dict):
4002
+ combined_response["button_widget_provisioning"] = provisioning_result
4003
+ combined_response["target_widget_provisioning"] = provisioning_result
4004
+ if combined_response.get("success"):
4005
+ return json.dumps(combined_response)
4006
+
4007
+ apply_error = apply_response.get("error", "Unknown apply error")
4008
+ return f"Failed auto-slice/apply pipeline: {apply_error}"
4009
+
4010
+
4011
+ # Input
4012
+ @mcp.tool()
4013
+ def add_input_binding(action_name: str, key: str) -> str:
4014
+ """
4015
+ Add an input action binding to Project Settings.
4016
+ Args:
4017
+ action_name: Name of the action (e.g., "Flap")
4018
+ key: Key to bind (e.g., "Space Bar")
4019
+ """
4020
+ command = {"type": "add_input_binding", "action_name": action_name, "key": key}
4021
+ response = send_to_unreal(command)
4022
+ if response.get("success"):
4023
+ message = response.get("message", f"Added input binding {action_name} -> {key}")
4024
+ details = response.get("details")
4025
+ if details:
4026
+ return f"{message} ({details})"
4027
+ return message
4028
+
4029
+ error_message = response.get("error", "Unknown error")
4030
+ details = response.get("details")
4031
+ if details:
4032
+ return f"Failed: {error_message} | {details}"
4033
+ return f"Failed: {error_message}"
4034
+
4035
+ #
4036
+ # Actor Management Tools
4037
+ #
4038
+
4039
+ @mcp.tool()
4040
+ def find_actors_by_name(name_pattern: str, exact_match: bool = False) -> str:
4041
+ """
4042
+ Find actors in the scene by name pattern.
4043
+ Args:
4044
+ name_pattern: Name pattern to search for (supports wildcards like "*")
4045
+ exact_match: If True, perform exact match instead of pattern matching
4046
+ Returns:
4047
+ JSON string with list of matching actors
4048
+ """
4049
+ command = {
4050
+ "type": "find_actors_by_name",
4051
+ "name_pattern": name_pattern,
4052
+ "exact_match": exact_match
4053
+ }
4054
+ response = send_to_unreal(command)
4055
+ if response.get("success"):
4056
+ actors = response.get("actors", [])
4057
+ return json.dumps({"success": True, "actors": actors, "count": len(actors)})
4058
+ return json.dumps({"success": False, "error": response.get("error", "Unknown error")})
4059
+
4060
+ @mcp.tool()
4061
+ def delete_actor(actor_name: str) -> str:
4062
+ """
4063
+ Delete an actor from the level.
4064
+ Args:
4065
+ actor_name: Name of the actor to delete
4066
+ Returns:
4067
+ Message indicating success or failure
4068
+ """
4069
+ command = {"type": "delete_actor", "actor_name": actor_name}
4070
+ response = send_to_unreal(command)
4071
+ if response.get("success"):
4072
+ return response.get("message", f"Deleted actor: {actor_name}")
4073
+ return f"Failed to delete actor: {response.get('error', 'Unknown error')}"
4074
+
4075
+ @mcp.tool()
4076
+ def set_actor_transform(actor_name: str, location: Optional[List[float]] = None, rotation: Optional[List[float]] = None, scale: Optional[List[float]] = None) -> str:
4077
+ """
4078
+ Set actor transform (position, rotation, scale) in one call.
4079
+ Args:
4080
+ actor_name: Name of the actor
4081
+ location: [X, Y, Z] coordinates (optional)
4082
+ rotation: [Pitch, Yaw, Roll] in degrees (optional)
4083
+ scale: [X, Y, Z] scale factors (optional)
4084
+ Returns:
4085
+ Message indicating success or failure
4086
+ """
4087
+ command = {
4088
+ "type": "set_actor_transform",
4089
+ "actor_name": actor_name,
4090
+ "location": location,
4091
+ "rotation": rotation,
4092
+ "scale": scale
4093
+ }
4094
+ response = send_to_unreal(command)
4095
+ if response.get("success"):
4096
+ return response.get("message", f"Set transform for {actor_name}")
4097
+ return f"Failed: {response.get('error', 'Unknown error')}"
4098
+
4099
+ @mcp.tool()
4100
+ def get_actor_material_info(actor_name: str) -> str:
4101
+ """
4102
+ Get material information from an actor.
4103
+ Args:
4104
+ actor_name: Name of the actor
4105
+ Returns:
4106
+ JSON string with material information
4107
+ """
4108
+ command = {"type": "get_actor_material_info", "actor_name": actor_name}
4109
+ response = send_to_unreal(command)
4110
+ return json.dumps(response) if response else json.dumps({"success": False, "error": "No response"})
4111
+
4112
+ #
4113
+ # Material Management Tools
4114
+ #
4115
+
4116
+ @mcp.tool()
4117
+ def get_available_materials(folder_path: str = "/Game/Materials", recursive: bool = True) -> str:
4118
+ """
4119
+ Get all available materials in the project.
4120
+ Args:
4121
+ folder_path: Folder path to search in (default: "/Game/Materials")
4122
+ recursive: Whether to search recursively (default: True)
4123
+ Returns:
4124
+ JSON string with list of materials
4125
+ """
4126
+ command = {
4127
+ "type": "get_available_materials",
4128
+ "folder_path": folder_path,
4129
+ "recursive": recursive
4130
+ }
4131
+ response = send_to_unreal(command)
4132
+ return json.dumps(response) if response else json.dumps({"success": False, "error": "No response"})
4133
+
4134
+ @mcp.tool()
4135
+ def apply_material_to_actor(actor_name: str, material_path: str, material_slot: int = 0) -> str:
4136
+ """
4137
+ Apply a material to a scene actor.
4138
+ Args:
4139
+ actor_name: Name of the actor
4140
+ material_path: Path to the material (e.g., "/Game/Materials/MyMaterial")
4141
+ material_slot: Material slot index (default: 0)
4142
+ Returns:
4143
+ Message indicating success or failure
4144
+ """
4145
+ command = {
4146
+ "type": "apply_material_to_actor",
4147
+ "actor_name": actor_name,
4148
+ "material_path": material_path,
4149
+ "material_slot": material_slot
4150
+ }
4151
+ response = send_to_unreal(command)
4152
+ if response.get("success"):
4153
+ return response.get("message", f"Applied material to {actor_name}")
4154
+ return f"Failed: {response.get('error', 'Unknown error')}"
4155
+
4156
+ @mcp.tool()
4157
+ def apply_material_to_blueprint(blueprint_path: str, component_name: str, material_path: str, material_slot: int = 0) -> str:
4158
+ """
4159
+ Apply a material to a Blueprint component.
4160
+ Args:
4161
+ blueprint_path: Path to the Blueprint
4162
+ component_name: Name of the component
4163
+ material_path: Path to the material
4164
+ material_slot: Material slot index (default: 0)
4165
+ Returns:
4166
+ Message indicating success or failure
4167
+ """
4168
+ command = {
4169
+ "type": "apply_material_to_blueprint",
4170
+ "blueprint_path": blueprint_path,
4171
+ "component_name": component_name,
4172
+ "material_path": material_path,
4173
+ "material_slot": material_slot
4174
+ }
4175
+ response = send_to_unreal(command)
4176
+ if response.get("success"):
4177
+ return response.get("message", f"Applied material to {component_name}")
4178
+ return f"Failed: {response.get('error', 'Unknown error')}"
4179
+
4180
+ @mcp.tool()
4181
+ def set_mesh_material_color(material_path: str, color: List[float], property_name: str = "BaseColor") -> str:
4182
+ """
4183
+ Set material color properties on a mesh material.
4184
+ Args:
4185
+ material_path: Path to the material to modify
4186
+ color: [R, G, B] color values (0-1)
4187
+ property_name: Material property name (default: "BaseColor")
4188
+ Returns:
4189
+ Message indicating success or failure
4190
+ """
4191
+ normalized_color = _coerce_numeric_list(color)
4192
+ if len(normalized_color) == 3:
4193
+ color = normalized_color
4194
+
4195
+ command = {
4196
+ "type": "set_mesh_material_color",
4197
+ "material_path": material_path,
4198
+ "color": color,
4199
+ "property_name": property_name
4200
+ }
4201
+ response = send_to_unreal(command)
4202
+ if response.get("success"):
4203
+ return response.get("message", f"Set color for {material_path}")
4204
+ return f"Failed: {response.get('error', 'Unknown error')}"
4205
+
4206
+ #
4207
+ # Physics Tools
4208
+ #
4209
+
4210
+ @mcp.tool()
4211
+ def spawn_physics_blueprint_actor(blueprint_path: str, location: Optional[List[float]] = None, rotation: Optional[List[float]] = None, scale: Optional[List[float]] = None, actor_label: Optional[str] = None, simulate_physics: bool = True) -> str:
4212
+ """
4213
+ Spawn a Blueprint actor with physics enabled.
4214
+ Args:
4215
+ blueprint_path: Path to the Blueprint asset
4216
+ location: [X, Y, Z] coordinates (optional)
4217
+ rotation: [Pitch, Yaw, Roll] in degrees (optional)
4218
+ scale: [X, Y, Z] scale factors (optional)
4219
+ actor_label: Optional custom name for the actor
4220
+ simulate_physics: Enable physics simulation (default: True)
4221
+ Returns:
4222
+ Message indicating success or failure
4223
+ """
4224
+ location = _ensure_vector(location, [0.0, 0.0, 0.0])
4225
+ rotation = _ensure_vector(rotation, [0.0, 0.0, 0.0])
4226
+ scale = _ensure_vector(scale, [1.0, 1.0, 1.0])
4227
+
4228
+ command = {
4229
+ "type": "spawn_physics_blueprint_actor",
4230
+ "blueprint_path": blueprint_path,
4231
+ "location": location,
4232
+ "rotation": rotation,
4233
+ "scale": scale,
4234
+ "actor_label": actor_label,
4235
+ "simulate_physics": simulate_physics
4236
+ }
4237
+ response = send_to_unreal(command)
4238
+ if response.get("success"):
4239
+ actor_name = response.get("actor_name", "actor")
4240
+ return f"Successfully spawned physics-enabled {blueprint_path} as {actor_name}"
4241
+ return f"Failed: {response.get('error', 'Unknown error')}"
4242
+
4243
+ @mcp.tool()
4244
+ def set_physics_properties(actor_name: str, mass: Optional[float] = None, linear_damping: Optional[float] = None, angular_damping: Optional[float] = None, enable_gravity: Optional[bool] = None, simulate_physics: Optional[bool] = None, collision_enabled: Optional[bool] = None) -> str:
4245
+ """
4246
+ Set physics properties on an actor.
4247
+ Args:
4248
+ actor_name: Name of the actor
4249
+ mass: Mass in kg (optional)
4250
+ linear_damping: Linear damping coefficient (optional)
4251
+ angular_damping: Angular damping coefficient (optional)
4252
+ enable_gravity: Enable gravity (optional)
4253
+ simulate_physics: Enable physics simulation (optional)
4254
+ collision_enabled: Enable collision (optional)
4255
+ Returns:
4256
+ Message indicating success or failure
4257
+ """
4258
+ command = {
4259
+ "type": "set_physics_properties",
4260
+ "actor_name": actor_name
4261
+ }
4262
+
4263
+ # Only include provided parameters
4264
+ if mass is not None:
4265
+ command["mass"] = mass
4266
+ if linear_damping is not None:
4267
+ command["linear_damping"] = linear_damping
4268
+ if angular_damping is not None:
4269
+ command["angular_damping"] = angular_damping
4270
+ if enable_gravity is not None:
4271
+ command["enable_gravity"] = enable_gravity
4272
+ if simulate_physics is not None:
4273
+ command["simulate_physics"] = simulate_physics
4274
+ if collision_enabled is not None:
4275
+ command["collision_enabled"] = collision_enabled
4276
+
4277
+ response = send_to_unreal(command)
4278
+ if response.get("success"):
4279
+ return response.get("message", f"Set physics properties for {actor_name}")
4280
+ return f"Failed: {response.get('error', 'Unknown error')}"
4281
+
4282
+ #
4283
+ # World Building Tools
4284
+ #
4285
+
4286
+ @mcp.tool()
4287
+ def create_town(town_size: str = "medium", architectural_style: str = "modern", building_density: float = 0.7, include_advanced_features: bool = False, center_location: Optional[List[float]] = None) -> str:
4288
+ """
4289
+ Generate an entire town with buildings, streets, and infrastructure.
4290
+ Args:
4291
+ town_size: Size of the town ("small", "medium", "large", "massive")
4292
+ architectural_style: Style of buildings ("medieval", "modern", "futuristic")
4293
+ building_density: Density of buildings (0.0-1.0, default: 0.7)
4294
+ include_advanced_features: Include parks, fountains, etc. (default: False)
4295
+ center_location: [X, Y, Z] center location (optional)
4296
+ Returns:
4297
+ Message indicating success with town information
4298
+ """
4299
+ center_location = _ensure_vector(center_location, [0.0, 0.0, 0.0])
4300
+
4301
+ command = {
4302
+ "type": "create_town",
4303
+ "town_size": town_size,
4304
+ "architectural_style": architectural_style,
4305
+ "building_density": building_density,
4306
+ "include_advanced_features": include_advanced_features,
4307
+ "center_location": center_location
4308
+ }
4309
+ response = send_to_unreal(command)
4310
+ if response.get("success"):
4311
+ count = response.get("count", 0)
4312
+ return f"Successfully created {town_size} town with {count} buildings"
4313
+ return f"Failed: {response.get('error', 'Unknown error')}"
4314
+
4315
+ @mcp.tool()
4316
+ def construct_house(house_style: str = "simple", width: float = 800.0, height: float = 400.0, depth: float = 600.0, num_rooms: int = 4, location: Optional[List[float]] = None) -> str:
4317
+ """
4318
+ Build a multi-room house with windows, doors, and roof.
4319
+ Args:
4320
+ house_style: Style of house ("simple", "modern", "victorian")
4321
+ width: Width of the house (default: 800)
4322
+ height: Height of the house (default: 400)
4323
+ depth: Depth of the house (default: 600)
4324
+ num_rooms: Number of rooms (default: 4)
4325
+ location: [X, Y, Z] location (optional)
4326
+ Returns:
4327
+ Message indicating success with house information
4328
+ """
4329
+ location = _ensure_vector(location, [0.0, 0.0, 0.0])
4330
+
4331
+ command = {
4332
+ "type": "construct_house",
4333
+ "house_style": house_style,
4334
+ "width": width,
4335
+ "height": height,
4336
+ "depth": depth,
4337
+ "num_rooms": num_rooms,
4338
+ "location": location
4339
+ }
4340
+ response = send_to_unreal(command)
4341
+ if response.get("success"):
4342
+ count = response.get("count", 0)
4343
+ return f"Successfully constructed {house_style} house with {num_rooms} rooms ({count} parts)"
4344
+ return f"Failed: {response.get('error', 'Unknown error')}"
4345
+
4346
+ @mcp.tool()
4347
+ def construct_mansion(width: float = 1500.0, height: float = 900.0, depth: float = 1200.0, num_wings: int = 2, num_towers: int = 4, location: Optional[List[float]] = None) -> str:
4348
+ """
4349
+ Create a mansion complex with wings and towers.
4350
+ Args:
4351
+ width: Width of main building (default: 1500)
4352
+ height: Height of main building (default: 900)
4353
+ depth: Depth of main building (default: 1200)
4354
+ num_wings: Number of wings (default: 2)
4355
+ num_towers: Number of towers (default: 4)
4356
+ location: [X, Y, Z] location (optional)
4357
+ Returns:
4358
+ Message indicating success with mansion information
4359
+ """
4360
+ location = _ensure_vector(location, [0.0, 0.0, 0.0])
4361
+
4362
+ command = {
4363
+ "type": "construct_mansion",
4364
+ "width": width,
4365
+ "height": height,
4366
+ "depth": depth,
4367
+ "num_wings": num_wings,
4368
+ "num_towers": num_towers,
4369
+ "location": location
4370
+ }
4371
+ response = send_to_unreal(command)
4372
+ if response.get("success"):
4373
+ count = response.get("count", 0)
4374
+ return f"Successfully constructed mansion with {num_wings} wings and {num_towers} towers ({count} parts)"
4375
+ return f"Failed: {response.get('error', 'Unknown error')}"
4376
+
4377
+ @mcp.tool()
4378
+ def create_tower(tower_style: str = "medieval", height: float = 1000.0, radius: float = 200.0, location: Optional[List[float]] = None) -> str:
4379
+ """
4380
+ Create a tower structure.
4381
+ Args:
4382
+ tower_style: Style of tower ("medieval", "modern", "futuristic")
4383
+ height: Height of the tower (default: 1000)
4384
+ radius: Radius of the tower (default: 200)
4385
+ location: [X, Y, Z] location (optional)
4386
+ Returns:
4387
+ Message indicating success
4388
+ """
4389
+ location = _ensure_vector(location, [0.0, 0.0, 0.0])
4390
+
4391
+ command = {
4392
+ "type": "create_tower",
4393
+ "tower_style": tower_style,
4394
+ "height": height,
4395
+ "radius": radius,
4396
+ "location": location
4397
+ }
4398
+ response = send_to_unreal(command)
4399
+ if response.get("success"):
4400
+ return f"Successfully created {tower_style} tower"
4401
+ return f"Failed: {response.get('error', 'Unknown error')}"
4402
+
4403
+ @mcp.tool()
4404
+ def create_arch(width: float = 400.0, height: float = 600.0, depth: float = 200.0, location: Optional[List[float]] = None) -> str:
4405
+ """
4406
+ Create an architectural arch.
4407
+ Args:
4408
+ width: Width of the arch (default: 400)
4409
+ height: Height of the arch (default: 600)
4410
+ depth: Depth of the arch (default: 200)
4411
+ location: [X, Y, Z] location (optional)
4412
+ Returns:
4413
+ Message indicating success
4414
+ """
4415
+ location = _ensure_vector(location, [0.0, 0.0, 0.0])
4416
+
4417
+ command = {
4418
+ "type": "create_arch",
4419
+ "width": width,
4420
+ "height": height,
4421
+ "depth": depth,
4422
+ "location": location
4423
+ }
4424
+ response = send_to_unreal(command)
4425
+ if response.get("success"):
4426
+ count = response.get("count", 0)
4427
+ return f"Successfully created arch ({count} parts)"
4428
+ return f"Failed: {response.get('error', 'Unknown error')}"
4429
+
4430
+ @mcp.tool()
4431
+ def create_staircase(num_steps: int = 10, step_width: float = 200.0, step_depth: float = 50.0, step_height: float = 20.0, location: Optional[List[float]] = None) -> str:
4432
+ """
4433
+ Create a staircase.
4434
+ Args:
4435
+ num_steps: Number of steps (default: 10)
4436
+ step_width: Width of each step (default: 200)
4437
+ step_depth: Depth of each step (default: 50)
4438
+ step_height: Height of each step (default: 20)
4439
+ location: [X, Y, Z] starting location (optional)
4440
+ Returns:
4441
+ Message indicating success
4442
+ """
4443
+ location = _ensure_vector(location, [0.0, 0.0, 0.0])
4444
+
4445
+ command = {
4446
+ "type": "create_staircase",
4447
+ "num_steps": num_steps,
4448
+ "step_width": step_width,
4449
+ "step_depth": step_depth,
4450
+ "step_height": step_height,
4451
+ "location": location
4452
+ }
4453
+ response = send_to_unreal(command)
4454
+ if response.get("success"):
4455
+ count = response.get("count", 0)
4456
+ return f"Successfully created staircase with {num_steps} steps"
4457
+ return f"Failed: {response.get('error', 'Unknown error')}"
4458
+
4459
+ @mcp.tool()
4460
+ def create_castle_fortress(size: str = "medium", num_towers: int = 4, wall_height: float = 800.0, location: Optional[List[float]] = None) -> str:
4461
+ """
4462
+ Build a medieval fortress with walls and towers.
4463
+ Args:
4464
+ size: Size of the fortress ("small", "medium", "large")
4465
+ num_towers: Number of corner towers (default: 4)
4466
+ wall_height: Height of walls (default: 800)
4467
+ location: [X, Y, Z] center location (optional)
4468
+ Returns:
4469
+ Message indicating success with fortress information
4470
+ """
4471
+ location = _ensure_vector(location, [0.0, 0.0, 0.0])
4472
+
4473
+ command = {
4474
+ "type": "create_castle_fortress",
4475
+ "size": size,
4476
+ "num_towers": num_towers,
4477
+ "wall_height": wall_height,
4478
+ "location": location
4479
+ }
4480
+ response = send_to_unreal(command)
4481
+ if response.get("success"):
4482
+ count = response.get("count", 0)
4483
+ return f"Successfully created {size} fortress with {num_towers} towers ({count} parts)"
4484
+ return f"Failed: {response.get('error', 'Unknown error')}"
4485
+
4486
+ @mcp.tool()
4487
+ def create_suspension_bridge(length: float = 2000.0, width: float = 400.0, height: float = 200.0, location: Optional[List[float]] = None) -> str:
4488
+ """
4489
+ Create a suspension bridge.
4490
+ Args:
4491
+ length: Length of the bridge (default: 2000)
4492
+ width: Width of the bridge (default: 400)
4493
+ height: Height above ground (default: 200)
4494
+ location: [X, Y, Z] starting location (optional)
4495
+ Returns:
4496
+ Message indicating success
4497
+ """
4498
+ location = _ensure_vector(location, [0.0, 0.0, 0.0])
4499
+
4500
+ command = {
4501
+ "type": "create_suspension_bridge",
4502
+ "length": length,
4503
+ "width": width,
4504
+ "height": height,
4505
+ "location": location
4506
+ }
4507
+ response = send_to_unreal(command)
4508
+ if response.get("success"):
4509
+ count = response.get("count", 0)
4510
+ return f"Successfully created suspension bridge ({count} parts)"
4511
+ return f"Failed: {response.get('error', 'Unknown error')}"
4512
+
4513
+ @mcp.tool()
4514
+ def create_aqueduct(length: float = 1500.0, height: float = 500.0, num_arches: int = 5, location: Optional[List[float]] = None) -> str:
4515
+ """
4516
+ Create an aqueduct structure.
4517
+ Args:
4518
+ length: Length of the aqueduct (default: 1500)
4519
+ height: Height of supports (default: 500)
4520
+ num_arches: Number of arches (default: 5)
4521
+ location: [X, Y, Z] starting location (optional)
4522
+ Returns:
4523
+ Message indicating success
4524
+ """
4525
+ location = _ensure_vector(location, [0.0, 0.0, 0.0])
4526
+
4527
+ command = {
4528
+ "type": "create_aqueduct",
4529
+ "length": length,
4530
+ "height": height,
4531
+ "num_arches": num_arches,
4532
+ "location": location
4533
+ }
4534
+ response = send_to_unreal(command)
4535
+ if response.get("success"):
4536
+ count = response.get("count", 0)
4537
+ return f"Successfully created aqueduct with {num_arches} arches ({count} parts)"
4538
+ return f"Failed: {response.get('error', 'Unknown error')}"
4539
+
4540
+ @mcp.tool()
4541
+ def create_maze(rows: int = 15, cols: int = 15, cell_size: float = 250.0, wall_height: float = 4.0, location: Optional[List[float]] = None) -> str:
4542
+ """
4543
+ Generate a solvable maze using recursive backtracking.
4544
+ Args:
4545
+ rows: Number of rows in the maze (default: 15)
4546
+ cols: Number of columns in the maze (default: 15)
4547
+ cell_size: Size of each cell (default: 250)
4548
+ wall_height: Height of walls (default: 4)
4549
+ location: [X, Y, Z] starting location (optional)
4550
+ Returns:
4551
+ Message indicating success with maze information
4552
+ """
4553
+ location = _ensure_vector(location, [0.0, 0.0, 0.0])
4554
+
4555
+ command = {
4556
+ "type": "create_maze",
4557
+ "rows": rows,
4558
+ "cols": cols,
4559
+ "cell_size": cell_size,
4560
+ "wall_height": wall_height,
4561
+ "location": location
4562
+ }
4563
+ response = send_to_unreal(command)
4564
+ if response.get("success"):
4565
+ count = response.get("count", 0)
4566
+ return f"Successfully created {rows}x{cols} maze with {count} walls"
4567
+ return f"Failed: {response.get('error', 'Unknown error')}"
4568
+
4569
+ @mcp.tool()
4570
+ def create_pyramid(base_size: float = 1000.0, height: float = 800.0, location: Optional[List[float]] = None) -> str:
4571
+ """
4572
+ Create a pyramid structure.
4573
+ Args:
4574
+ base_size: Size of the base (default: 1000)
4575
+ height: Height of the pyramid (default: 800)
4576
+ location: [X, Y, Z] center location (optional)
4577
+ Returns:
4578
+ Message indicating success
4579
+ """
4580
+ location = _ensure_vector(location, [0.0, 0.0, 0.0])
4581
+
4582
+ command = {
4583
+ "type": "create_pyramid",
4584
+ "base_size": base_size,
4585
+ "height": height,
4586
+ "location": location
4587
+ }
4588
+ response = send_to_unreal(command)
4589
+ if response.get("success"):
4590
+ return f"Successfully created pyramid"
4591
+ return f"Failed: {response.get('error', 'Unknown error')}"
4592
+
4593
+ @mcp.tool()
4594
+ def create_wall(length: float = 1000.0, height: float = 400.0, thickness: float = 50.0, location: Optional[List[float]] = None, rotation: Optional[List[float]] = None) -> str:
4595
+ """
4596
+ Create a wall structure.
4597
+ Args:
4598
+ length: Length of the wall (default: 1000)
4599
+ height: Height of the wall (default: 400)
4600
+ thickness: Thickness of the wall (default: 50)
4601
+ location: [X, Y, Z] center location (optional)
4602
+ rotation: [Pitch, Yaw, Roll] rotation in degrees (optional)
4603
+ Returns:
4604
+ Message indicating success
4605
+ """
4606
+ location = _ensure_vector(location, [0.0, 0.0, 0.0])
4607
+ rotation = _ensure_vector(rotation, [0.0, 0.0, 0.0])
4608
+
4609
+ command = {
4610
+ "type": "create_wall",
4611
+ "length": length,
4612
+ "height": height,
4613
+ "thickness": thickness,
4614
+ "location": location,
4615
+ "rotation": rotation
4616
+ }
4617
+ response = send_to_unreal(command)
4618
+ if response.get("success"):
4619
+ return f"Successfully created wall"
4620
+ return f"Failed: {response.get('error', 'Unknown error')}"
4621
+
4622
+ #
4623
+ # Blueprint Enhancement
4624
+ #
4625
+
4626
+ @mcp.tool()
4627
+ def set_static_mesh_properties(blueprint_path: str, component_name: str, static_mesh_path: Optional[str] = None, collision_enabled: Optional[bool] = None, cast_shadow: Optional[bool] = None, receive_shadow: Optional[bool] = None) -> str:
4628
+ """
4629
+ Set static mesh properties on a Blueprint component.
4630
+ Args:
4631
+ blueprint_path: Path to the Blueprint
4632
+ component_name: Name of the StaticMeshComponent
4633
+ static_mesh_path: Path to the static mesh to set (optional)
4634
+ collision_enabled: Enable collision (optional)
4635
+ cast_shadow: Cast shadows (optional)
4636
+ receive_shadow: Receive shadows (optional)
4637
+ Returns:
4638
+ Message indicating success or failure
4639
+ """
4640
+ command = {
4641
+ "type": "set_static_mesh_properties",
4642
+ "blueprint_path": blueprint_path,
4643
+ "component_name": component_name
4644
+ }
4645
+
4646
+ if static_mesh_path is not None:
4647
+ command["static_mesh_path"] = static_mesh_path
4648
+ if collision_enabled is not None:
4649
+ command["collision_enabled"] = collision_enabled
4650
+ if cast_shadow is not None:
4651
+ command["cast_shadow"] = cast_shadow
4652
+ if receive_shadow is not None:
4653
+ command["receive_shadow"] = receive_shadow
4654
+
4655
+ response = send_to_unreal(command)
4656
+ if response.get("success"):
4657
+ props_set = response.get("properties_set", [])
4658
+ return f"Successfully set properties for {component_name}: {', '.join(props_set)}"
4659
+ return f"Failed: {response.get('error', 'Unknown error')}"
4660
+
4661
+ # Safety check for potentially destructive actions
4662
+ def is_potentially_destructive(script: str) -> bool:
4663
+ """
4664
+ Check if the script contains potentially destructive actions like deleting or saving files.
4665
+ Returns True if such actions are detected and not explicitly requested.
4666
+ """
4667
+ destructive_keywords = [
4668
+ r'unreal\.EditorAssetLibrary\.delete_asset',
4669
+ r'unreal\.EditorLevelLibrary\.destroy_actor',
4670
+ r'unreal\.save_package',
4671
+ r'os\.remove',
4672
+ r'shutil\.rmtree',
4673
+ r'file\.write',
4674
+ r'unreal\.EditorAssetLibrary\.save_asset'
4675
+ ]
4676
+ for keyword in destructive_keywords:
4677
+ if re.search(keyword, script, re.IGNORECASE):
4678
+ return True
4679
+ return False
4680
+
4681
+ if __name__ == "__main__":
4682
+ import traceback
4683
+ try:
4684
+ print("Server starting...", file=sys.stderr)
4685
+ mcp.run("stdio")
4686
+ except Exception as e:
4687
+ print(f"Server crashed with error: {e}", file=sys.stderr)
4688
+ traceback.print_exc(file=sys.stderr)
4689
+ raise
4690
+
4691
+ # Safety check for potentially destructive actions
4692
+ def is_potentially_destructive(script: str) -> bool:
4693
+ """
4694
+ Check if the script contains potentially destructive actions like deleting or saving files.
4695
+ Returns True if such actions are detected and not explicitly requested.
4696
+ """
4697
+ destructive_keywords = [
4698
+ r'unreal\.EditorAssetLibrary\.delete_asset',
4699
+ r'unreal\.EditorLevelLibrary\.destroy_actor',
4700
+ r'unreal\.save_package',
4701
+ r'os\.remove',
4702
+ r'shutil\.rmtree',
4703
+ r'file\.write',
4704
+ r'unreal\.EditorAssetLibrary\.save_asset'
4705
+ ]
4706
+
4707
+ for keyword in destructive_keywords:
4708
+ if re.search(keyword, script, re.IGNORECASE):
4709
+ return True
4710
+ return False
4711
+
4712
+ if __name__ == "__main__":
4713
+ import traceback
4714
+ try:
4715
+ print("Server starting...", file=sys.stderr)
4716
+ mcp.run("stdio")
4717
+ except Exception as e:
4718
+ print(f"Server crashed with error: {e}", file=sys.stderr)
4719
+ traceback.print_exc(file=sys.stderr)
4720
+ raise