Unified, portable Unreal Engine MCP system plugin

Merge of the two project copies into one self-contained plugin (the superset:
variable_op + variables.py, full pcg_op runtime/declarative/preset ops, the
CreateWidgetFloatAnimation widget tool, and full Voxel graph authoring).

Made fully project- and machine-agnostic — no hardcoded paths:
- New src/projectPaths.js auto-detects the host .uproject (walk-up), project
  name, Editor build target, log file, and engine install (EngineAssociation
  via launcher manifest/registry, else installed-engine scan). All overridable
  via UE_* env vars.
- Rewired buildOrchestrator/insights/launcher/insightsExporter/server.js and the
  Python workers (cpp_scaffold, live_coding, apply_graph, console) off the old
  C:/Github/ihy, IHY*, E:/UE_Versions and UE_5.7 literals onto the resolver.
- Voxel made optional: Build.cs auto-detects the Voxel plugin (env
  UE_MCP_WITH_VOXEL override) and the C++ compiles to stubs under WITH_MCP_VOXEL,
  so the module builds in projects without Voxel; .uplugin marks Voxel optional.
- De-branded the agent-gateway and docs; scrubbed a leaked API key; excluded
  node_modules/Binaries/Intermediate/__pycache__/secrets from the repo.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Bonchellon
2026-06-24 01:07:24 +03:00
parent d46e03e53e
commit eba71c4ca8
76 changed files with 23838 additions and 1 deletions

215
ue_side/ai.py Normal file
View File

@ -0,0 +1,215 @@
"""AI ops — BehaviorTree, Blackboard, EQS, AIController.
Ops:
create_behavior_tree {path, blackboard?}
create_blackboard {path, parent?}
create_eqs_query {path}
create_aicontroller_bp {path, parent_class?:'AIController'}
add_blackboard_key {path, name, type:'Bool|Int|Float|String|Name|Vector|Rotator|Object|Class|Enum', base_class?}
remove_blackboard_key {path, name}
list_blackboard_keys {path}
set_bt_blackboard {bt_path, blackboard_path}
"""
from __future__ import annotations
import json
import os
import traceback
try:
import unreal # type: ignore
except ImportError:
unreal = None
def _at(): return unreal.AssetToolsHelpers.get_asset_tools()
def _load(p): return unreal.EditorAssetLibrary.load_asset(p)
def _split(p):
folder, name = p.rsplit("/", 1)
return folder, name
KEY_TYPES = {
"Bool": "BlackboardKeyType_Bool",
"Int": "BlackboardKeyType_Int",
"Float": "BlackboardKeyType_Float",
"String": "BlackboardKeyType_String",
"Name": "BlackboardKeyType_Name",
"Vector": "BlackboardKeyType_Vector",
"Rotator": "BlackboardKeyType_Rotator",
"Object": "BlackboardKeyType_Object",
"Class": "BlackboardKeyType_Class",
"Enum": "BlackboardKeyType_Enum",
}
def create_behavior_tree(args):
folder, name = _split(args["path"])
factory_cls = getattr(unreal, "BehaviorTreeFactory", None)
if factory_cls is None:
return {"ok": False, "error": "BehaviorTreeFactory missing — enable AIModule"}
asset = _at().create_asset(name, folder, unreal.BehaviorTree, factory_cls())
if asset is None:
return {"ok": False, "error": "create returned None"}
if args.get("blackboard"):
bb = _load(args["blackboard"])
if bb is not None:
asset.set_editor_property("blackboard_asset", bb)
unreal.EditorAssetLibrary.save_asset(asset.get_path_name())
return {"ok": True, "path": asset.get_path_name().split(".")[0]}
def create_blackboard(args):
folder, name = _split(args["path"])
factory_cls = getattr(unreal, "BlackboardDataFactory", None)
if factory_cls is None:
return {"ok": False, "error": "BlackboardDataFactory missing"}
asset = _at().create_asset(name, folder, unreal.BlackboardData, factory_cls())
if asset is None:
return {"ok": False, "error": "create returned None"}
if args.get("parent"):
parent = _load(args["parent"])
if parent is not None:
try:
asset.set_editor_property("parent", parent)
except Exception:
pass
unreal.EditorAssetLibrary.save_asset(asset.get_path_name())
return {"ok": True, "path": asset.get_path_name().split(".")[0]}
def create_eqs_query(args):
folder, name = _split(args["path"])
factory_cls = getattr(unreal, "EnvQueryFactory", None)
asset_cls = getattr(unreal, "EnvQuery", None)
if factory_cls is None or asset_cls is None:
return {"ok": False, "error": "EnvQuery factory missing — enable EQS in project settings"}
asset = _at().create_asset(name, folder, asset_cls, factory_cls())
if asset is None:
return {"ok": False, "error": "create returned None"}
unreal.EditorAssetLibrary.save_asset(asset.get_path_name())
return {"ok": True, "path": asset.get_path_name().split(".")[0]}
def create_aicontroller_bp(args):
folder, name = _split(args["path"])
parent_name = args.get("parent_class") or "AIController"
parent = unreal.load_class(None, "/Script/AIModule." + parent_name)
if parent is None:
parent = unreal.load_class(None, parent_name)
if parent is None:
return {"ok": False, "error": f"parent {parent_name!r} not found"}
factory = unreal.BlueprintFactory()
factory.set_editor_property("parent_class", parent)
asset = _at().create_asset(name, folder, unreal.Blueprint, factory)
if asset is None:
return {"ok": False, "error": "create returned None"}
unreal.EditorAssetLibrary.save_asset(asset.get_path_name())
return {"ok": True, "path": asset.get_path_name().split(".")[0]}
def add_blackboard_key(args):
bb = _load(args["path"])
if bb is None:
return {"ok": False, "error": "blackboard not found"}
type_name = KEY_TYPES.get(args["type"])
if not type_name:
return {"ok": False, "error": f"unknown key type {args['type']!r}; valid: {list(KEY_TYPES)}"}
key_type_cls = getattr(unreal, type_name, None)
if key_type_cls is None:
return {"ok": False, "error": f"{type_name} not exposed"}
entry_cls = getattr(unreal, "BlackboardEntry", None)
if entry_cls is None:
return {"ok": False, "error": "BlackboardEntry not exposed; edit via UI"}
entry = entry_cls()
entry.set_editor_property("entry_name", args["name"])
kt = key_type_cls()
if args.get("base_class") and hasattr(kt, "base_class"):
cls = unreal.load_class(None, args["base_class"])
if cls is not None:
kt.set_editor_property("base_class", cls)
entry.set_editor_property("key_type", kt)
keys = list(bb.get_editor_property("keys") or [])
keys.append(entry)
bb.set_editor_property("keys", keys)
unreal.EditorAssetLibrary.save_asset(bb.get_path_name())
return {"ok": True, "name": args["name"]}
def remove_blackboard_key(args):
bb = _load(args["path"])
if bb is None:
return {"ok": False, "error": "blackboard not found"}
target = args["name"]
keys = [k for k in (bb.get_editor_property("keys") or [])
if str(k.get_editor_property("entry_name")) != target]
bb.set_editor_property("keys", keys)
unreal.EditorAssetLibrary.save_asset(bb.get_path_name())
return {"ok": True, "removed": target}
def list_blackboard_keys(args):
bb = _load(args["path"])
if bb is None:
return {"ok": False, "error": "blackboard not found"}
out = []
for k in (bb.get_editor_property("keys") or []):
try:
kt = k.get_editor_property("key_type")
out.append({"name": str(k.get_editor_property("entry_name")),
"type": kt.get_class().get_name() if kt else None})
except Exception:
pass
return {"ok": True, "keys": out}
def set_bt_blackboard(args):
bt = _load(args["bt_path"])
bb = _load(args["blackboard_path"])
if bt is None or bb is None:
return {"ok": False, "error": "bt or blackboard not found"}
bt.set_editor_property("blackboard_asset", bb)
unreal.EditorAssetLibrary.save_asset(bt.get_path_name())
return {"ok": True}
OPS = {
"create_behavior_tree": create_behavior_tree,
"create_blackboard": create_blackboard,
"create_eqs_query": create_eqs_query,
"create_aicontroller_bp": create_aicontroller_bp,
"add_blackboard_key": add_blackboard_key,
"remove_blackboard_key": remove_blackboard_key,
"list_blackboard_keys": list_blackboard_keys,
"set_bt_blackboard": set_bt_blackboard,
}
def entrypoint(payload_json):
try:
p = json.loads(payload_json)
except Exception as e:
return _tee({"ok": False, "error": f"bad json: {e}"}, {})
op = p.get("op"); fn = OPS.get(op)
if not fn:
return _tee({"ok": False, "error": f"unknown op {op!r}", "available": list(OPS)}, p)
try:
return _tee(fn(p), p)
except Exception as e:
return _tee({"ok": False, "error": str(e), "traceback": traceback.format_exc()}, p)
def _tee(result, p):
txt = json.dumps(result)
try:
if unreal is not None:
sd = unreal.Paths.project_saved_dir() + "MCP/"
os.makedirs(sd, exist_ok=True)
rid = p.get("__rid") or "last"
with open(sd + "last.json", "w", encoding="utf-8") as f: f.write(txt)
if rid != "last":
with open(sd + f"{rid}.json", "w", encoding="utf-8") as f: f.write(txt)
unreal.log("[MCP-AI] " + txt[:1500])
except Exception:
pass
return txt

386
ue_side/animation.py Normal file
View File

@ -0,0 +1,386 @@
"""Animation ops — AnimBlueprint, AnimSequence, AnimMontage, Notifies, Skeleton sockets.
Ops:
create_anim_blueprint {path, skeleton}
create_anim_montage {path, sequence?}
create_blend_space {path, skeleton, dim?:1|2}
get_sequence_info {path} — length, fps, frames, additive type, skeleton
list_anim_assets {folder?, skeleton?}
list_sockets {skeleton}
add_socket {skeleton, name, bone, location?, rotation?}
remove_socket {skeleton, name}
list_montage_sections {path}
add_montage_section {path, name, start_time?}
list_notifies {sequence}
add_notify {sequence, name, time, notify_class?}
remove_notify {sequence, index}
list_state_machines {anim_bp} — best-effort (graph names)
retarget_animation {src, dst_skeleton, dst_path}
"""
from __future__ import annotations
import json
import os
import traceback
try:
import unreal # type: ignore
except ImportError:
unreal = None
def _at():
return unreal.AssetToolsHelpers.get_asset_tools()
def _load(path: str):
return unreal.EditorAssetLibrary.load_asset(path)
def _split_pkg(path: str):
folder, name = path.rsplit("/", 1)
return folder, name
def _vec(v, default=(0, 0, 0)):
if v is None:
v = default
return unreal.Vector(float(v[0]), float(v[1]), float(v[2]))
def _rot(v, default=(0, 0, 0)):
if v is None:
v = default
return unreal.Rotator(float(v[0]), float(v[1]), float(v[2]))
def create_anim_blueprint(args: dict) -> dict:
skel = _load(args["skeleton"])
if skel is None:
return {"ok": False, "error": f"skeleton not found: {args['skeleton']}"}
folder, name = _split_pkg(args["path"])
factory = unreal.AnimBlueprintFactory()
factory.set_editor_property("target_skeleton", skel)
asset = _at().create_asset(name, folder, unreal.AnimBlueprint, factory)
if asset is None:
return {"ok": False, "error": "create_asset returned None"}
unreal.EditorAssetLibrary.save_asset(asset.get_path_name())
return {"ok": True, "path": asset.get_path_name().split(".")[0]}
def create_anim_montage(args: dict) -> dict:
folder, name = _split_pkg(args["path"])
factory = unreal.AnimMontageFactory()
seq_path = args.get("sequence")
if seq_path:
seq = _load(seq_path)
if seq is None:
return {"ok": False, "error": f"sequence not found: {seq_path}"}
if hasattr(factory, "set_editor_property"):
try:
factory.set_editor_property("preview_skeletal_mesh", seq.get_editor_property("skeleton"))
except Exception:
pass
asset = _at().create_asset(name, folder, unreal.AnimMontage, factory)
if asset is None:
return {"ok": False, "error": "create_asset returned None"}
return {"ok": True, "path": asset.get_path_name().split(".")[0]}
def create_blend_space(args: dict) -> dict:
skel = _load(args["skeleton"])
if skel is None:
return {"ok": False, "error": "skeleton not found"}
folder, name = _split_pkg(args["path"])
dim = int(args.get("dim") or 2)
factory_cls = getattr(unreal, "BlendSpaceFactory1D" if dim == 1 else "BlendSpaceFactoryNew", None)
if factory_cls is None:
return {"ok": False, "error": "BlendSpace factory unavailable in this UE build"}
factory = factory_cls()
if hasattr(factory, "target_skeleton"):
factory.target_skeleton = skel
asset_cls = unreal.BlendSpace1D if dim == 1 else unreal.BlendSpace
asset = _at().create_asset(name, folder, asset_cls, factory)
if asset is None:
return {"ok": False, "error": "create_asset returned None"}
return {"ok": True, "path": asset.get_path_name().split(".")[0]}
def get_sequence_info(args: dict) -> dict:
seq = _load(args["path"])
if seq is None:
return {"ok": False, "error": "not found"}
out = {"ok": True, "path": args["path"], "class": seq.get_class().get_name()}
for attr in ("get_play_length", "sequence_length"):
try:
v = getattr(seq, attr)
out["length"] = v() if callable(v) else float(v)
break
except Exception:
pass
try:
out["num_frames"] = int(seq.get_editor_property("number_of_sampled_keys") or 0)
except Exception:
pass
try:
out["frame_rate"] = float(seq.get_editor_property("target_frame_rate") or 0)
except Exception:
pass
try:
skel = seq.get_editor_property("skeleton")
if skel:
out["skeleton"] = skel.get_path_name().split(".")[0]
except Exception:
pass
try:
out["additive_type"] = str(seq.get_editor_property("additive_anim_type"))
except Exception:
pass
return out
def list_anim_assets(args: dict) -> dict:
folder = args.get("folder") or "/Game"
skel_filter = args.get("skeleton")
ar = unreal.AssetRegistryHelpers.get_asset_registry()
filt = unreal.ARFilter(package_paths=[folder], recursive_paths=True,
class_names=["AnimSequence", "AnimMontage", "BlendSpace", "BlendSpace1D", "AnimBlueprint"])
items = []
for d in ar.get_assets(filt):
path = str(d.package_name)
if skel_filter:
try:
asset = _load(path)
sk = asset.get_editor_property("skeleton") if asset else None
if not sk or sk.get_path_name().split(".")[0] != skel_filter:
continue
except Exception:
continue
items.append({"path": path, "class": str(d.asset_class)})
return {"ok": True, "count": len(items), "items": items}
def list_sockets(args: dict) -> dict:
skel = _load(args["skeleton"])
if skel is None:
return {"ok": False, "error": "skeleton not found"}
try:
sockets = skel.get_editor_property("sockets") or []
except Exception:
sockets = []
out = []
for s in sockets:
try:
out.append({
"name": str(s.get_editor_property("socket_name")),
"bone": str(s.get_editor_property("bone_name")),
"location": list(s.get_editor_property("relative_location")),
"rotation": list(s.get_editor_property("relative_rotation")),
})
except Exception:
pass
return {"ok": True, "count": len(out), "sockets": out}
def add_socket(args: dict) -> dict:
skel = _load(args["skeleton"])
if skel is None:
return {"ok": False, "error": "skeleton not found"}
sock = unreal.SkeletalMeshSocket()
sock.set_editor_property("socket_name", args["name"])
sock.set_editor_property("bone_name", args["bone"])
sock.set_editor_property("relative_location", _vec(args.get("location")))
sock.set_editor_property("relative_rotation", _rot(args.get("rotation")))
sockets = list(skel.get_editor_property("sockets") or [])
sockets.append(sock)
skel.set_editor_property("sockets", sockets)
unreal.EditorAssetLibrary.save_asset(skel.get_path_name())
return {"ok": True, "name": args["name"]}
def remove_socket(args: dict) -> dict:
skel = _load(args["skeleton"])
if skel is None:
return {"ok": False, "error": "skeleton not found"}
target = args["name"]
sockets = [s for s in (skel.get_editor_property("sockets") or [])
if str(s.get_editor_property("socket_name")) != target]
skel.set_editor_property("sockets", sockets)
unreal.EditorAssetLibrary.save_asset(skel.get_path_name())
return {"ok": True, "removed": target}
def list_montage_sections(args: dict) -> dict:
m = _load(args["path"])
if m is None:
return {"ok": False, "error": "not found"}
try:
secs = m.get_editor_property("composite_sections") or []
except Exception:
secs = []
out = []
for s in secs:
try:
out.append({"name": str(s.get_editor_property("section_name")),
"start_time": float(s.get_editor_property("segment_begin_time") or 0)})
except Exception:
pass
return {"ok": True, "sections": out}
def add_montage_section(args: dict) -> dict:
m = _load(args["path"])
if m is None:
return {"ok": False, "error": "not found"}
sec = unreal.CompositeSection() if hasattr(unreal, "CompositeSection") else None
if sec is None:
return {"ok": False, "error": "CompositeSection not exposed in this UE build"}
sec.set_editor_property("section_name", args["name"])
sec.set_editor_property("segment_begin_time", float(args.get("start_time") or 0))
secs = list(m.get_editor_property("composite_sections") or [])
secs.append(sec)
m.set_editor_property("composite_sections", secs)
unreal.EditorAssetLibrary.save_asset(m.get_path_name())
return {"ok": True}
def list_notifies(args: dict) -> dict:
seq = _load(args["sequence"])
if seq is None:
return {"ok": False, "error": "not found"}
try:
notifies = seq.get_editor_property("notifies") or []
except Exception:
notifies = []
out = []
for n in notifies:
try:
cls = n.get_editor_property("notify")
out.append({
"name": str(n.get_editor_property("notify_name")),
"time": float(n.get_editor_property("trigger_time_offset") or 0) + float(n.get_editor_property("link_value") or 0),
"class": cls.get_class().get_name() if cls else None,
})
except Exception:
pass
return {"ok": True, "notifies": out}
def add_notify(args: dict) -> dict:
seq = _load(args["sequence"])
if seq is None:
return {"ok": False, "error": "not found"}
ev = unreal.AnimNotifyEvent() if hasattr(unreal, "AnimNotifyEvent") else None
if ev is None:
return {"ok": False, "error": "AnimNotifyEvent not exposed; use editor UI"}
ev.set_editor_property("notify_name", args["name"])
ev.set_editor_property("link_value", float(args["time"]))
notifies = list(seq.get_editor_property("notifies") or [])
notifies.append(ev)
seq.set_editor_property("notifies", notifies)
unreal.EditorAssetLibrary.save_asset(seq.get_path_name())
return {"ok": True}
def remove_notify(args: dict) -> dict:
seq = _load(args["sequence"])
if seq is None:
return {"ok": False, "error": "not found"}
notifies = list(seq.get_editor_property("notifies") or [])
idx = int(args["index"])
if idx < 0 or idx >= len(notifies):
return {"ok": False, "error": "index out of range"}
notifies.pop(idx)
seq.set_editor_property("notifies", notifies)
unreal.EditorAssetLibrary.save_asset(seq.get_path_name())
return {"ok": True}
def list_state_machines(args: dict) -> dict:
bp = _load(args["anim_bp"])
if bp is None:
return {"ok": False, "error": "not found"}
out = []
try:
for g in bp.get_editor_property("ubergraph_pages") or []:
out.append(g.get_name())
except Exception:
pass
try:
for g in bp.get_editor_property("function_graphs") or []:
out.append(g.get_name())
except Exception:
pass
return {"ok": True, "graphs": out}
def retarget_animation(args: dict) -> dict:
src = _load(args["src"])
dst_skel = _load(args["dst_skeleton"])
if src is None or dst_skel is None:
return {"ok": False, "error": "src or dst_skeleton not found"}
folder, name = _split_pkg(args["dst_path"])
try:
new_asset = unreal.AnimationLibrary.duplicate_animation_asset(src, name, folder) \
if hasattr(unreal, "AnimationLibrary") and hasattr(unreal.AnimationLibrary, "duplicate_animation_asset") \
else unreal.EditorAssetLibrary.duplicate_asset(src.get_path_name(), folder + "/" + name)
except Exception as e:
return {"ok": False, "error": f"duplicate failed: {e}"}
try:
new_asset.set_editor_property("skeleton", dst_skel)
except Exception:
pass
unreal.EditorAssetLibrary.save_asset(new_asset.get_path_name())
return {"ok": True, "path": new_asset.get_path_name().split(".")[0]}
OPS = {
"create_anim_blueprint": create_anim_blueprint,
"create_anim_montage": create_anim_montage,
"create_blend_space": create_blend_space,
"get_sequence_info": get_sequence_info,
"list_anim_assets": list_anim_assets,
"list_sockets": list_sockets,
"add_socket": add_socket,
"remove_socket": remove_socket,
"list_montage_sections": list_montage_sections,
"add_montage_section": add_montage_section,
"list_notifies": list_notifies,
"add_notify": add_notify,
"remove_notify": remove_notify,
"list_state_machines": list_state_machines,
"retarget_animation": retarget_animation,
}
def entrypoint(payload_json: str) -> str:
try:
p = json.loads(payload_json)
except Exception as e:
return _tee({"ok": False, "error": f"bad json: {e}"}, {})
op = p.get("op")
fn = OPS.get(op)
if not fn:
return _tee({"ok": False, "error": f"unknown op {op!r}", "available": list(OPS)}, p)
try:
return _tee(fn(p), p)
except Exception as e:
return _tee({"ok": False, "error": str(e), "traceback": traceback.format_exc()}, p)
def _tee(result: dict, p: dict) -> str:
txt = json.dumps(result)
try:
if unreal is not None:
sd = unreal.Paths.project_saved_dir() + "MCP/"
os.makedirs(sd, exist_ok=True)
rid = p.get("__rid") or "last"
with open(sd + "last.json", "w", encoding="utf-8") as f:
f.write(txt)
if rid != "last":
with open(sd + f"{rid}.json", "w", encoding="utf-8") as f:
f.write(txt)
unreal.log("[MCP-ANIM] " + txt[:1500])
except Exception:
pass
return txt

519
ue_side/apply_graph.py Normal file
View File

@ -0,0 +1,519 @@
"""
UE-side runtime for the Blueprint MCP server.
Reaches into the editor via `unreal.MCPGraphLibrary` — a C++ BlueprintFunction
library provided by Plugins/UEBlueprintMCP/Source. That library exposes K2 node
spawning, which is NOT available in stock UE Python.
Flow per apply():
1. Resolve target Blueprint (by path, or via Content Browser selection).
2. Resolve target graph (EventGraph by default, else named function).
3. For each NodeSpec — call the matching MCPGraphLibrary spawn function and
remember NodeGuid keyed by local id.
4. Set pin defaults (params).
5. For each EdgeSpec — call ConnectPins using NodeGuids.
6. Compile + save Blueprint.
7. Write the result envelope to <Project>/Saved/MCP/last.json and unreal.log.
"""
from __future__ import annotations
import json
import os
import traceback
import graph_layout
try:
import unreal # type: ignore
except ImportError: # pragma: no cover — tests run outside UE
unreal = None
def _lib():
"""Return the MCPGraphLibrary class, or raise if the plugin isn't built."""
L = getattr(unreal, "MCPGraphLibrary", None)
if L is None:
raise RuntimeError(
"unreal.MCPGraphLibrary not found — the UEBlueprintMCP C++ plugin "
"is not built. Right-click your .uproject → Generate VS project files, "
"then build the editor."
)
return L
# ---------------------------------------------------------------------------
# Blueprint / graph resolution
# ---------------------------------------------------------------------------
def _load_blueprint(path: str):
asset = unreal.EditorAssetLibrary.load_asset(path)
if asset is None:
raise RuntimeError(f"Blueprint not found at {path}")
if not isinstance(asset, unreal.Blueprint):
raise RuntimeError(f"Asset at {path} is not a Blueprint ({type(asset).__name__})")
return asset
def _active_blueprint():
"""Return (Blueprint, path) from Content Browser selection."""
selected = []
try:
selected = list(unreal.EditorUtilityLibrary.get_selected_assets() or [])
except Exception:
selected = []
bps = [a for a in selected if isinstance(a, unreal.Blueprint)]
if len(bps) == 1:
bp = bps[0]
return bp, bp.get_path_name().split(".")[0]
if len(bps) > 1:
raise RuntimeError(f"{len(bps)} Blueprints selected — select exactly one or pass bp_path")
raise RuntimeError(
"auto-detect needs one Blueprint selected in Content Browser. "
"Click your BP asset and retry, or pass bp_path explicitly."
)
def _find_graph(bp, function_name: str):
L = _lib()
if function_name in (None, "", "EventGraph"):
g = L.find_event_graph(bp)
if g is None:
raise RuntimeError("Blueprint has no EventGraph")
return g
g = L.find_function_graph(bp, function_name)
if g is None:
raise RuntimeError(f"Graph '{function_name}' not found on Blueprint")
return g
# ---------------------------------------------------------------------------
# Function target parsing
# ---------------------------------------------------------------------------
def _resolve_function_owner(target: str):
"""Parse 'KismetSystemLibrary.PrintString' or '/Script/Engine.KismetSystemLibrary:PrintString'.
Returns (UClass, FName-able str).
"""
if ":" in target:
cls_path, fn = target.split(":", 1)
elif "." in target:
cls_path, fn = target.rsplit(".", 1)
else:
raise RuntimeError(f"call_function target must be Class.Function, got {target!r}")
cls = None
if "/" in cls_path:
cls = unreal.load_class(None, cls_path)
else:
for prefix in ("/Script/Engine.", "/Script/CoreUObject.", "/Script/UMG.",
"/Script/GameplayTags.", "/Script/UnrealEd.", "/Script/Kismet."):
cls = unreal.load_class(None, prefix + cls_path)
if cls is not None:
break
if cls is None:
raise RuntimeError(f"Class not found for target {target!r}")
return cls, fn
# ---------------------------------------------------------------------------
# Apply
# ---------------------------------------------------------------------------
def apply(graph: dict) -> dict:
result = {
"ok": False, "spawned": {}, "node_guids": {},
"errors": [], "warnings": [], "compile": None,
}
if unreal is None:
result["errors"].append("unreal module not importable — must run inside UE editor")
return result
try:
L = _lib()
bp_path = graph.get("bp_path") or ""
if bp_path:
bp = _load_blueprint(bp_path)
result["resolved_bp"] = bp_path
else:
bp, bp_path = _active_blueprint()
result["resolved_bp"] = bp_path
result["auto_detected"] = True
function_name = graph.get("function") or "EventGraph"
target_graph = _find_graph(bp, function_name)
# ---- mode handling ----
mode = (graph.get("mode") or "append").lower()
if mode in ("replace_function", "replace_event"):
try:
cleared = L.clear_graph(target_graph)
result["cleared_nodes"] = int(cleared)
except Exception as e: # noqa: BLE001
result["warnings"].append(f"clear_graph failed: {e}")
layout = str(graph.get("layout") or "").lower()
auto_layout = bool(graph.get("auto_layout", False))
should_layout = auto_layout or layout in ("compact", "columns")
node_specs = graph_layout.normalize_specs(
graph.get("nodes", []),
graph.get("edges", []),
domain="blueprint",
) if should_layout else list(graph.get("nodes", []))
# ---- spawn nodes ----
guid_map: dict[str, str] = {}
for spec in node_specs:
try:
guid = _spawn_one(target_graph, spec, bp, L)
if not guid:
raise RuntimeError("spawn returned empty guid")
guid_map[spec["id"]] = guid
result["spawned"][spec["id"]] = guid
except Exception as e: # noqa: BLE001
result["errors"].append(f"node {spec.get('id')!r}: {e}")
# ---- post-spawn config (switch cases, format text, sequence pins are spawn-time) ----
for spec in node_specs:
local_id = spec.get("id")
guid = guid_map.get(local_id)
if not guid:
continue
p = spec.get("params") or {}
kind = spec.get("kind")
# FormatText: set Format & reconstruct so {arg} pins appear
if kind == "format_text" and "Format" in p:
try:
L.format_text_set_format(target_graph, guid, str(p["Format"]))
except Exception as e: # noqa: BLE001
result["warnings"].append(f"format_text_set_format: {local_id}: {e}")
# Switch nodes: add case pins
if kind in ("switch_int", "switch_string", "switch_enum"):
cases = int(p.get("cases", 0))
for _ in range(cases):
try:
L.add_case_pin_to_switch(target_graph, guid)
except Exception as e: # noqa: BLE001
result["warnings"].append(f"add_case_pin_to_switch: {local_id}: {e}")
# ---- set pin defaults ----
SKIP_PARAMS = {"cases", "then_count", "width", "height", "text", "Format"}
for spec in node_specs:
local_id = spec.get("id")
guid = guid_map.get(local_id)
if not guid:
continue
for pin_name, value in (spec.get("params") or {}).items():
if pin_name in SKIP_PARAMS:
continue
try:
ok = L.set_pin_default(target_graph, guid, pin_name, str(value))
if not ok:
result["warnings"].append(f"set_pin_default failed: {local_id}.{pin_name}")
except Exception as e: # noqa: BLE001
result["warnings"].append(f"set_pin_default raised: {local_id}.{pin_name}: {e}")
# ---- connect edges ----
def _resolve_ref(ref: str):
# "@guid:<NodeGuid>" -> raw guid (existing node)
if isinstance(ref, str) and ref.startswith("@guid:"):
return ref[len("@guid:"):]
return guid_map.get(ref)
for edge in graph.get("edges", []):
try:
(fa, fp) = edge["from"]
(tb, tp) = edge["to"]
ga, gb = _resolve_ref(fa), _resolve_ref(tb)
if not ga or not gb:
raise RuntimeError("edge references unspawned/unknown node")
ok = L.connect_pins(target_graph, ga, fp, gb, tp)
if not ok:
raise RuntimeError(f"connect_pins returned false ({fa}.{fp} -> {tb}.{tp})")
except Exception as e: # noqa: BLE001
result["errors"].append(f"edge {edge}: {e}")
if result["errors"]:
result["ok"] = False
else:
try:
msgs = list(L.compile_with_messages(bp))
result["compile_messages"] = msgs
result["compile"] = "ok" if all(not m.startswith("ERROR|") for m in msgs) else "errors"
except Exception:
L.compile_and_save_blueprint(bp)
result["compile"] = "ok"
unreal.EditorAssetLibrary.save_loaded_asset(bp)
result["ok"] = (result["compile"] == "ok")
except Exception as e: # noqa: BLE001
result["errors"].append(str(e))
result["traceback"] = traceback.format_exc()
return result
def _load_class(path_or_name: str):
if "/" in path_or_name:
return unreal.load_class(None, path_or_name)
for prefix in ("/Script/Engine.", "/Script/CoreUObject.", "/Script/UMG.",
"/Script/GameplayTags.", "/Script/UnrealEd.", "/Script/Kismet."):
c = unreal.load_class(None, prefix + path_or_name)
if c is not None:
return c
return None
def _load_struct(path: str):
if "/" in path:
return unreal.load_object(None, path)
return unreal.load_object(None, "/Script/CoreUObject." + path)
def _load_enum(path: str):
if "/" in path:
return unreal.load_object(None, path)
return unreal.load_object(None, "/Script/CoreUObject." + path)
def _spawn_one(graph, spec: dict, bp, L) -> str:
kind = spec["kind"]
pos = unreal.Vector2D(*(spec.get("position") or [0, 0]))
target = spec.get("target") or ""
# ---- Function / event ----
if kind == "call_function":
cls, fn = _resolve_function_owner(target)
return L.spawn_call_function_node(bp, graph, cls, fn, pos)
if kind == "event":
return L.spawn_event_node(bp, graph, None, target, pos)
if kind == "custom_event":
return L.spawn_custom_event_node(bp, graph, target, pos)
if kind == "component_event":
if "." not in target:
raise RuntimeError(f"component_event target must be 'Component.Delegate', got {target!r}")
comp, delegate = target.split(".", 1)
return L.spawn_component_bound_event(bp, graph, comp, delegate, pos)
# ---- Variables ----
if kind == "variable_get":
return L.spawn_variable_get_node(bp, graph, target, pos)
if kind == "variable_set":
return L.spawn_variable_set_node(bp, graph, target, pos)
if kind == "self":
return L.spawn_self_node(bp, graph, pos)
# ---- Flow control ----
if kind == "branch":
return L.spawn_branch_node(bp, graph, pos)
if kind == "sequence":
out_count = int((spec.get("params") or {}).get("then_count", 2))
return L.spawn_sequence_node(bp, graph, pos, out_count)
if kind == "switch_int":
return L.spawn_switch_on_int_node(bp, graph, pos)
if kind == "switch_string":
return L.spawn_switch_on_string_node(bp, graph, pos)
if kind == "switch_enum":
e = _load_enum(target)
if e is None:
raise RuntimeError(f"switch_enum: enum not found at {target!r}")
return L.spawn_switch_on_enum_node(bp, graph, e, pos)
if kind == "macro":
# target: macro name from StandardMacros (e.g. "ForLoop", "ForEachLoop",
# "WhileLoop", "DoOnce", "Gate", "FlipFlop", "MultiGate", "IsValid")
return L.spawn_standard_macro_node(bp, graph, target, pos)
# ---- Cast ----
if kind == "cast":
cls = _load_class(target)
if cls is None:
raise RuntimeError(f"cast: class not found at {target!r}")
return L.spawn_cast_node(bp, graph, cls, pos)
# ---- Collections / structs ----
if kind == "make_array":
return L.spawn_make_array_node(bp, graph, pos)
if kind == "make_struct":
s = _load_struct(target)
if s is None:
raise RuntimeError(f"make_struct: struct not found at {target!r}")
return L.spawn_make_struct_node(bp, graph, s, pos)
if kind == "break_struct":
s = _load_struct(target)
if s is None:
raise RuntimeError(f"break_struct: struct not found at {target!r}")
return L.spawn_break_struct_node(bp, graph, s, pos)
if kind == "set_fields_in_struct":
s = _load_struct(target)
if s is None:
raise RuntimeError(f"set_fields_in_struct: struct not found at {target!r}")
return L.spawn_set_fields_in_struct_node(bp, graph, s, pos)
# ---- Misc ----
if kind == "spawn_actor":
return L.spawn_spawn_actor_from_class_node(bp, graph, pos)
if kind == "format_text":
return L.spawn_format_text_node(bp, graph, pos)
if kind == "get_subsystem":
cls = _load_class(target)
if cls is None:
raise RuntimeError(f"get_subsystem: class not found at {target!r}")
return L.spawn_get_subsystem_node(bp, graph, cls, pos)
if kind == "load_asset":
return L.spawn_load_asset_node(bp, graph, pos)
# ---- Delegates ----
if kind in ("bind_delegate", "unbind_delegate", "assign_delegate"):
if "." not in target:
raise RuntimeError(f"{kind} target must be 'Component.Delegate', got {target!r}")
comp, delegate = target.split(".", 1)
fn = {
"bind_delegate": L.spawn_bind_delegate_node,
"unbind_delegate": L.spawn_unbind_delegate_node,
"assign_delegate": L.spawn_assign_delegate_node,
}[kind]
return fn(bp, graph, comp, delegate, pos)
if kind == "call_delegate":
return L.spawn_call_delegate_node(bp, graph, target, pos)
# ---- Tier 2 nodes ----
if kind == "knot":
return L.spawn_knot_node(bp, graph, pos)
if kind == "comment":
p = spec.get("params") or {}
size = unreal.Vector2D(float(p.get("width", 400)), float(p.get("height", 200)))
text = str(p.get("text", target or "Comment"))
return L.spawn_comment_node(bp, graph, pos, size, text)
if kind == "enum_literal":
e = _load_enum(target)
if e is None:
raise RuntimeError(f"enum_literal: enum not found at {target!r}")
return L.spawn_enum_literal_node(bp, graph, e, pos)
if kind == "get_class_defaults":
cls = _load_class(target)
if cls is None:
raise RuntimeError(f"get_class_defaults: class not found at {target!r}")
return L.spawn_get_class_defaults_node(bp, graph, cls, pos)
if kind == "function_result":
return L.spawn_function_result_node(bp, graph, pos)
if kind == "get_array_item":
return L.spawn_get_array_item_node(bp, graph, pos)
if kind == "operator":
# target = "+", "-", "*", etc.
return L.spawn_promotable_operator_node(bp, graph, target, pos)
raise RuntimeError(f"kind {kind!r} not supported")
# ---------------------------------------------------------------------------
# Validation (dry-resolve all targets without spawning)
# ---------------------------------------------------------------------------
def validate(graph: dict) -> dict:
"""Resolve all targets in NodeSpec without mutating UE.
Returns {ok, missing: [{id, kind, target, reason}], resolved: [{id, ...}]}.
"""
out = {"ok": True, "missing": [], "resolved": []}
if unreal is None:
out["ok"] = False
out["missing"].append({"reason": "unreal module not importable"})
return out
for spec in graph.get("nodes", []):
kind = spec.get("kind", "")
target = spec.get("target") or ""
try:
if kind == "call_function":
cls, fn = _resolve_function_owner(target)
if not cls.find_function(fn):
raise RuntimeError(f"function {fn!r} not on {cls.get_name()}")
elif kind in ("cast", "get_subsystem", "get_class_defaults"):
if _load_class(target) is None:
raise RuntimeError(f"class not found: {target!r}")
elif kind in ("make_struct", "break_struct", "set_fields_in_struct"):
if _load_struct(target) is None:
raise RuntimeError(f"struct not found: {target!r}")
elif kind in ("switch_enum", "enum_literal"):
if _load_enum(target) is None:
raise RuntimeError(f"enum not found: {target!r}")
out["resolved"].append({"id": spec.get("id"), "kind": kind, "target": target})
except Exception as e: # noqa: BLE001
out["ok"] = False
out["missing"].append({"id": spec.get("id"), "kind": kind, "target": target, "reason": str(e)})
return out
# ---------------------------------------------------------------------------
# read_graph — dump existing graph via C++ DumpGraphToJson
# ---------------------------------------------------------------------------
def read_graph(bp_path: str, function_name: str = "EventGraph") -> dict:
try:
L = _lib()
if bp_path:
bp = _load_blueprint(bp_path)
else:
bp, bp_path = _active_blueprint()
g = _find_graph(bp, function_name)
raw = L.dump_graph_to_json(g)
return {"ok": True, "bp_path": bp_path, "function": function_name, "graph": json.loads(raw)}
except Exception as e: # noqa: BLE001
return {"ok": False, "error": str(e), "traceback": traceback.format_exc()}
# ---------------------------------------------------------------------------
# Entrypoint (called by the MCP server's bootstrap snippet)
# ---------------------------------------------------------------------------
def entrypoint(payload_json: str) -> str:
try:
graph = json.loads(payload_json)
except Exception as e: # noqa: BLE001
result_json = json.dumps({"ok": False, "errors": [f"bad payload json: {e}"]})
_tee(result_json, {})
return result_json
op = graph.get("__op", "apply")
if op == "read":
out = read_graph(graph.get("bp_path") or "", graph.get("function") or "EventGraph")
elif op == "validate":
out = validate(graph)
elif op == "batch":
out = batch_apply(graph.get("ops") or [])
else:
out = apply(graph)
result_json = json.dumps(out)
_tee(result_json, graph)
return result_json
def batch_apply(ops: list) -> dict:
"""Run a list of apply()s atomically-ish — same transaction, single undo."""
results = []
for i, op in enumerate(ops):
op_kind = op.get("op") or "apply"
if op_kind == "apply":
results.append({"index": i, "op": "apply", "result": apply(op)})
elif op_kind == "validate":
results.append({"index": i, "op": "validate", "result": validate(op)})
elif op_kind == "read":
results.append({"index": i, "op": "read", "result": read_graph(op.get("bp_path") or "", op.get("function") or "EventGraph")})
else:
results.append({"index": i, "op": op_kind, "result": {"ok": False, "error": f"unknown op kind {op_kind!r}"}})
overall_ok = all(r["result"].get("ok") for r in results)
return {"ok": overall_ok, "results": results}
def _tee(result_json: str, payload: dict) -> None:
try:
if unreal is None:
return
proj_dir = unreal.Paths.project_saved_dir()
out_dir = proj_dir + "MCP/"
try:
os.makedirs(out_dir, exist_ok=True)
except Exception:
pass
rid = (payload or {}).get("__rid") or "last"
# Always write to last.json for human inspection, and to {rid}.json for race-free reads
with open(out_dir + "last.json", "w", encoding="utf-8") as f:
f.write(result_json)
if rid != "last":
with open(out_dir + f"{rid}.json", "w", encoding="utf-8") as f:
f.write(result_json)
unreal.log("[MCP] " + result_json[:1500])
except Exception:
pass

243
ue_side/assets.py Normal file
View File

@ -0,0 +1,243 @@
"""Asset CRUD via UE AssetTools — pure Python, no C++ needed.
Exposed ops (dispatched by `entrypoint(payload_json)` from the MCP server):
create_blueprint {path, parent_class}
create_widget_bp {path}
create_enum {path, values:[]}
create_struct {path, fields:[{name,type}]}
create_data_asset {path, class}
create_data_table {path, row_struct}
duplicate {src, dst}
rename {src, dst}
move {src, dst_folder}
delete {path}
reparent_blueprint {bp, new_parent_class}
"""
from __future__ import annotations
import json
import os
import traceback
try:
import unreal # type: ignore
except ImportError:
unreal = None
def _at():
return unreal.AssetToolsHelpers.get_asset_tools()
def _load_class(path_or_name: str):
if "/" in path_or_name:
return unreal.load_class(None, path_or_name)
for prefix in ("/Script/Engine.", "/Script/CoreUObject.", "/Script/UMG.",
"/Script/UnrealEd.", "/Script/Kismet."):
c = unreal.load_class(None, prefix + path_or_name)
if c is not None:
return c
return None
def _split_pkg(path: str):
"""/Game/Foo/Bar/Asset -> (/Game/Foo/Bar, Asset)"""
if "/" not in path:
raise RuntimeError(f"bad asset path {path!r}")
folder, name = path.rsplit("/", 1)
return folder, name
def create_blueprint(args: dict) -> dict:
path = args["path"]
parent = _load_class(args.get("parent_class") or "Actor")
if parent is None:
return {"ok": False, "error": f"parent class not found: {args.get('parent_class')!r}"}
folder, name = _split_pkg(path)
factory = unreal.BlueprintFactory()
factory.set_editor_property("parent_class", parent)
asset = _at().create_asset(name, folder, unreal.Blueprint, factory)
if asset is None:
return {"ok": False, "error": "create_asset returned None"}
unreal.EditorAssetLibrary.save_loaded_asset(asset)
return {"ok": True, "path": asset.get_path_name().split(".")[0], "parent": parent.get_name()}
def create_widget_bp(args: dict) -> dict:
path = args["path"]
folder, name = _split_pkg(path)
factory = unreal.WidgetBlueprintFactory()
asset = _at().create_asset(name, folder, unreal.WidgetBlueprint, factory)
if asset is None:
return {"ok": False, "error": "create_asset returned None"}
unreal.EditorAssetLibrary.save_loaded_asset(asset)
return {"ok": True, "path": asset.get_path_name().split(".")[0]}
def create_enum(args: dict) -> dict:
path = args["path"]
values = args.get("values") or []
folder, name = _split_pkg(path)
factory = unreal.EnumFactory()
asset = _at().create_asset(name, folder, unreal.UserDefinedEnum, factory)
if asset is None:
return {"ok": False, "error": "create_asset returned None"}
for i, v in enumerate(values):
try:
unreal.EnumEditorUtils.add_enumerator_to_user_defined_enum(asset, str(v))
except Exception:
pass
unreal.EditorAssetLibrary.save_loaded_asset(asset)
return {"ok": True, "path": asset.get_path_name().split(".")[0], "values_added": len(values)}
def create_struct(args: dict) -> dict:
path = args["path"]
fields = args.get("fields") or [] # [{name, type}]
folder, name = _split_pkg(path)
factory = unreal.StructureFactory()
asset = _at().create_asset(name, folder, unreal.UserDefinedStruct, factory)
if asset is None:
return {"ok": False, "error": "create_asset returned None"}
# Field editing via StructureEditorUtils not exposed reliably in stock Python.
# User can rename default member via UI; we just create the struct.
unreal.EditorAssetLibrary.save_loaded_asset(asset)
return {"ok": True, "path": asset.get_path_name().split(".")[0], "note": "Field add not exposed in stock Python; edit in editor or pre-create C++ struct."}
def create_data_asset(args: dict) -> dict:
path = args["path"]
cls = _load_class(args.get("class") or "")
if cls is None:
return {"ok": False, "error": f"class not found: {args.get('class')!r}"}
folder, name = _split_pkg(path)
factory = unreal.DataAssetFactory()
factory.set_editor_property("data_asset_class", cls)
asset = _at().create_asset(name, folder, cls, factory)
if asset is None:
return {"ok": False, "error": "create_asset returned None"}
unreal.EditorAssetLibrary.save_loaded_asset(asset)
return {"ok": True, "path": asset.get_path_name().split(".")[0]}
def create_data_table(args: dict) -> dict:
path = args["path"]
row_struct_path = args.get("row_struct") or ""
struct = unreal.load_object(None, row_struct_path) if row_struct_path else None
if struct is None:
return {"ok": False, "error": f"row_struct not found: {row_struct_path!r}"}
folder, name = _split_pkg(path)
factory = unreal.DataTableFactory()
factory.set_editor_property("struct", struct)
asset = _at().create_asset(name, folder, unreal.DataTable, factory)
if asset is None:
return {"ok": False, "error": "create_asset returned None"}
unreal.EditorAssetLibrary.save_loaded_asset(asset)
return {"ok": True, "path": asset.get_path_name().split(".")[0]}
def duplicate(args: dict) -> dict:
src, dst = args["src"], args["dst"]
asset = unreal.EditorAssetLibrary.duplicate_asset(src, dst)
return {"ok": asset is not None, "src": src, "dst": dst}
def rename(args: dict) -> dict:
src, dst = args["src"], args["dst"]
ok = unreal.EditorAssetLibrary.rename_asset(src, dst)
return {"ok": bool(ok), "src": src, "dst": dst}
def move(args: dict) -> dict:
src, folder = args["src"], args["dst_folder"]
name = src.rsplit("/", 1)[-1]
dst = folder.rstrip("/") + "/" + name
ok = unreal.EditorAssetLibrary.rename_asset(src, dst)
return {"ok": bool(ok), "src": src, "dst": dst}
def delete(args: dict) -> dict:
path = args["path"]
ok = unreal.EditorAssetLibrary.delete_asset(path)
return {"ok": bool(ok), "path": path}
def reparent_blueprint(args: dict) -> dict:
bp_path = args["bp"]
new_parent = _load_class(args["new_parent_class"])
if new_parent is None:
return {"ok": False, "error": f"new_parent_class not found: {args['new_parent_class']!r}"}
bp = unreal.EditorAssetLibrary.load_asset(bp_path)
if not isinstance(bp, unreal.Blueprint):
return {"ok": False, "error": "not a blueprint"}
# WidgetBlueprint (and others) don't expose ParentClass as a settable editor
# property — use the proper reparent API, fall back to set_editor_property.
try:
unreal.BlueprintEditorLibrary.reparent_blueprint(bp, new_parent)
except Exception:
bp.set_editor_property("parent_class", new_parent)
unreal.BlueprintEditorLibrary.compile_blueprint(bp)
unreal.EditorAssetLibrary.save_loaded_asset(bp)
return {"ok": True, "bp": bp_path, "new_parent": new_parent.get_name()}
OPS = {
"create_blueprint": create_blueprint,
"create_widget_bp": create_widget_bp,
"create_enum": create_enum,
"create_struct": create_struct,
"create_data_asset": create_data_asset,
"create_data_table": create_data_table,
"duplicate": duplicate,
"rename": rename,
"move": move,
"delete": delete,
"reparent_blueprint": reparent_blueprint,
}
# Member-variable authoring lives in variables.py and is surfaced as the
# dedicated `variable_op` tool. Merge its NON-colliding op names here too so the
# capability is reachable via `asset_op` without a JS server restart. (We skip
# the short aliases add/rename/delete/list — they would shadow asset ops.)
try:
import importlib as _il
import variables as _vars
_il.reload(_vars)
for _k in ("add_variable", "set_var_default", "set_var_meta",
"rename_variable", "delete_variable", "list_variables"):
OPS[_k] = _vars.VAR_OPS[_k]
except Exception:
pass
def entrypoint(payload_json: str) -> str:
try:
p = json.loads(payload_json)
except Exception as e: # noqa: BLE001
return _tee({"ok": False, "error": f"bad json: {e}"}, {})
op = p.get("op")
fn = OPS.get(op)
if not fn:
return _tee({"ok": False, "error": f"unknown op {op!r}", "available": list(OPS)}, p)
try:
return _tee(fn(p), p)
except Exception as e: # noqa: BLE001
return _tee({"ok": False, "error": str(e), "traceback": traceback.format_exc()}, p)
def _tee(result: dict, p: dict) -> str:
txt = json.dumps(result)
try:
if unreal is not None:
sd = unreal.Paths.project_saved_dir() + "MCP/"
os.makedirs(sd, exist_ok=True)
rid = p.get("__rid") or "last"
with open(sd + "last.json", "w", encoding="utf-8") as f:
f.write(txt)
if rid != "last":
with open(sd + f"{rid}.json", "w", encoding="utf-8") as f:
f.write(txt)
unreal.log("[MCP-ASSETS] " + txt[:1500])
except Exception:
pass
return txt

145
ue_side/audio.py Normal file
View File

@ -0,0 +1,145 @@
"""Audio ops — MetaSounds, SoundCue, Attenuation, SoundClass/Mix.
Ops:
create_metasound_source {path}
create_metasound_patch {path}
create_sound_cue {path}
create_sound_attenuation {path}
create_sound_class {path}
create_sound_mix {path}
set_property {path, property, value}
get_property {path, property}
list_sound_assets {folder?}
play_sound_2d {path} — preview in editor
"""
from __future__ import annotations
import json
import os
import traceback
try:
import unreal # type: ignore
except ImportError:
unreal = None
def _at():
return unreal.AssetToolsHelpers.get_asset_tools()
def _load(p): return unreal.EditorAssetLibrary.load_asset(p)
def _split(p):
folder, name = p.rsplit("/", 1)
return folder, name
def _create(path, asset_cls_name, factory_name):
folder, name = _split(path)
cls = getattr(unreal, asset_cls_name, None)
factory_cls = getattr(unreal, factory_name, None)
if cls is None or factory_cls is None:
return {"ok": False, "error": f"{asset_cls_name}/{factory_name} not exposed (plugin disabled?)"}
asset = _at().create_asset(name, folder, cls, factory_cls())
if asset is None:
return {"ok": False, "error": "create_asset returned None"}
unreal.EditorAssetLibrary.save_asset(asset.get_path_name())
return {"ok": True, "path": asset.get_path_name().split(".")[0]}
def create_metasound_source(args): return _create(args["path"], "MetaSoundSource", "MetaSoundSourceFactory")
def create_metasound_patch(args): return _create(args["path"], "MetaSoundPatch", "MetaSoundPatchFactory")
def create_sound_cue(args): return _create(args["path"], "SoundCue", "SoundCueFactoryNew")
def create_sound_attenuation(args): return _create(args["path"], "SoundAttenuation", "SoundAttenuationFactory")
def create_sound_class(args): return _create(args["path"], "SoundClass", "SoundClassFactory")
def create_sound_mix(args): return _create(args["path"], "SoundMix", "SoundMixFactory")
def set_property(args):
a = _load(args["path"])
if a is None:
return {"ok": False, "error": "asset not found"}
try:
a.set_editor_property(args["property"], args["value"])
unreal.EditorAssetLibrary.save_asset(a.get_path_name())
return {"ok": True}
except Exception as e:
return {"ok": False, "error": str(e)}
def get_property(args):
a = _load(args["path"])
if a is None:
return {"ok": False, "error": "asset not found"}
try:
v = a.get_editor_property(args["property"])
return {"ok": True, "value": str(v)}
except Exception as e:
return {"ok": False, "error": str(e)}
def list_sound_assets(args):
folder = args.get("folder") or "/Game"
ar = unreal.AssetRegistryHelpers.get_asset_registry()
classes = ["SoundCue", "SoundWave", "MetaSoundSource", "MetaSoundPatch",
"SoundAttenuation", "SoundClass", "SoundMix", "SoundConcurrency"]
filt = unreal.ARFilter(package_paths=[folder], recursive_paths=True, class_names=classes)
items = [{"path": str(d.package_name), "class": str(d.asset_class)} for d in ar.get_assets(filt)]
return {"ok": True, "count": len(items), "items": items}
def play_sound_2d(args):
a = _load(args["path"])
if a is None:
return {"ok": False, "error": "not found"}
try:
world = unreal.EditorLevelLibrary.get_editor_world()
unreal.GameplayStatics.play_sound2d(world, a)
return {"ok": True}
except Exception as e:
return {"ok": False, "error": str(e)}
OPS = {
"create_metasound_source": create_metasound_source,
"create_metasound_patch": create_metasound_patch,
"create_sound_cue": create_sound_cue,
"create_sound_attenuation": create_sound_attenuation,
"create_sound_class": create_sound_class,
"create_sound_mix": create_sound_mix,
"set_property": set_property,
"get_property": get_property,
"list_sound_assets": list_sound_assets,
"play_sound_2d": play_sound_2d,
}
def entrypoint(payload_json: str) -> str:
try:
p = json.loads(payload_json)
except Exception as e:
return _tee({"ok": False, "error": f"bad json: {e}"}, {})
op = p.get("op")
fn = OPS.get(op)
if not fn:
return _tee({"ok": False, "error": f"unknown op {op!r}", "available": list(OPS)}, p)
try:
return _tee(fn(p), p)
except Exception as e:
return _tee({"ok": False, "error": str(e), "traceback": traceback.format_exc()}, p)
def _tee(result, p):
txt = json.dumps(result)
try:
if unreal is not None:
sd = unreal.Paths.project_saved_dir() + "MCP/"
os.makedirs(sd, exist_ok=True)
rid = p.get("__rid") or "last"
with open(sd + "last.json", "w", encoding="utf-8") as f: f.write(txt)
if rid != "last":
with open(sd + f"{rid}.json", "w", encoding="utf-8") as f: f.write(txt)
unreal.log("[MCP-AUDIO] " + txt[:1500])
except Exception:
pass
return txt

213
ue_side/capture.py Normal file
View File

@ -0,0 +1,213 @@
"""Headless capture ops — SEE editor content without opening any tab/window.
Backed by the C++ helper unreal.MCPCaptureLibrary (Live-Coding compile the
plugin if it's missing). Every op writes a PNG under Saved/<out_dir>/ and
returns its absolute path + file:// URL. The JS layer (capture_op) reads that
PNG back and attaches it INLINE so the agent actually sees the image.
Ops:
asset {asset_path, size?:[512,512], out_dir?:'MCP/capture'}
Render ANY asset's thumbnail headlessly (mesh, material, texture,
blueprint, niagara, anim, sound, data asset, ...). No editor open.
thumbnail alias for asset.
material {material_path, size?} — alias for asset, reads nicer in intent.
mesh {mesh_path, size?} — alias for asset.
scene {location?:[x,y,z], rotation?:[pitch,yaw,roll], fov?:90,
size?:[1280,720], out_dir?} — render the editor world from an
arbitrary camera via an offscreen SceneCapture2D. No viewport.
scene_from_actor {actor, distance?:300, pitch?:-20, yaw?:0, fov?:75, size?}
— frame an existing level actor and capture it.
list_assets {dir} — quick content-browser listing to find paths to capture.
"""
from __future__ import annotations
import json
import os
import traceback
try:
import unreal # type: ignore
except ImportError:
unreal = None
def _cap_lib():
L = getattr(unreal, "MCPCaptureLibrary", None)
if L is None:
raise RuntimeError(
"MCPCaptureLibrary not exposed — Live-Coding compile the C++ plugin "
"(live_coding_op compile), then retry."
)
return L
def _out_dir(args) -> tuple[str, str]:
"""Returns (abs_dir_with_os_sep, abs_dir_forward_slash)."""
proj_saved = unreal.Paths.project_saved_dir()
sub = (args.get("out_dir") or "MCP/capture").strip("/")
fwd = proj_saved + sub + "/"
os.makedirs(fwd, exist_ok=True)
return fwd.rstrip("/").replace("/", os.sep), fwd
def _safe_name(path: str) -> str:
return path.rsplit("/", 1)[-1].split(".")[0] or "capture"
def _ok_png(full_path: str, strategy: str, size) -> dict:
norm = full_path.replace("\\", "/")
return {
"ok": True,
"strategy": strategy,
"png_path": full_path,
"file_url": "file:///" + norm,
"size": size,
}
def _vec(v, d=(0.0, 0.0, 0.0)):
if v is None:
v = d
return unreal.Vector(float(v[0]), float(v[1]), float(v[2]))
def _rot(v, d=(0.0, 0.0, 0.0)):
if v is None:
v = d
# UE Rotator(pitch, yaw, roll); we accept [pitch, yaw, roll].
return unreal.Rotator(float(v[0]), float(v[1]), float(v[2]))
def asset(args: dict) -> dict:
path = args.get("asset_path") or args.get("material_path") or args.get("mesh_path") or args.get("path")
if not path:
return {"ok": False, "error": "asset_path required"}
if not unreal.EditorAssetLibrary.does_asset_exist(path):
return {"ok": False, "error": f"asset does not exist: {path}"}
size = args.get("size") or [512, 512]
w, h = int(size[0]), int(size[1])
os_dir, _ = _out_dir(args)
name = _safe_name(path)
_ret = _cap_lib().render_asset_thumbnail_to_png(path, os_dir, name, w, h)
if isinstance(_ret, (tuple, list)):
if len(_ret) >= 3:
ok, full, err = _ret[0], _ret[1], _ret[2]
elif len(_ret) == 2:
full, err = _ret[0], _ret[1]
ok = bool(full)
else:
ok, full, err = bool(_ret[0]), (_ret[0] if isinstance(_ret[0], str) else ""), ""
else:
full = _ret
ok = bool(full)
err = ""
if not ok:
return {"ok": False, "error": err or "thumbnail render failed", "asset_path": path}
res = _ok_png(full, "AssetThumbnail", [w, h])
res["asset_path"] = path
return res
def scene(args: dict) -> dict:
size = args.get("size") or [1280, 720]
w, h = int(size[0]), int(size[1])
os_dir, _ = _out_dir(args)
name = args.get("name") or "scene"
loc = _vec(args.get("location"))
rot = _rot(args.get("rotation"))
fov = float(args.get("fov", 90.0))
ok, full, err = _cap_lib().capture_editor_scene_to_png(loc, rot, fov, os_dir, name, w, h)
if not ok:
return {"ok": False, "error": err or "scene capture failed"}
res = _ok_png(full, "SceneCapture2D", [w, h])
res["camera"] = {"location": [loc.x, loc.y, loc.z],
"rotation": [rot.pitch, rot.yaw, rot.roll], "fov": fov}
return res
def scene_from_actor(args: dict) -> dict:
name = args.get("actor")
if not name:
return {"ok": False, "error": "actor required"}
ess = unreal.get_editor_subsystem(unreal.EditorActorSubsystem)
target = None
for a in ess.get_all_level_actors():
if a and (a.get_actor_label() == name or a.get_name() == name):
target = a
break
if target is None:
return {"ok": False, "error": f"actor not found: {name}"}
origin, extent = target.get_actor_bounds(False)
radius = max(extent.x, extent.y, extent.z, 50.0)
pitch = float(args.get("pitch", -20.0))
yaw = float(args.get("yaw", 0.0))
fov = float(args.get("fov", 75.0))
dist = float(args.get("distance", radius * 3.0))
# Place camera 'dist' away along yaw/pitch, looking back at the actor center.
look_dir = unreal.MathLibrary.get_forward_vector(unreal.Rotator(pitch, yaw, 0.0))
cam_loc = origin - (look_dir * dist)
look_rot = unreal.MathLibrary.find_look_at_rotation(cam_loc, origin)
size = args.get("size") or [1280, 720]
w, h = int(size[0]), int(size[1])
os_dir, _ = _out_dir(args)
fname = args.get("name") or f"actor_{_safe_name(name)}"
ok, full, err = _cap_lib().capture_editor_scene_to_png(cam_loc, look_rot, fov, os_dir, fname, w, h)
if not ok:
return {"ok": False, "error": err or "scene capture failed"}
res = _ok_png(full, "SceneCapture2D/actor", [w, h])
res["actor"] = name
res["camera"] = {"location": [cam_loc.x, cam_loc.y, cam_loc.z], "fov": fov}
return res
def list_assets(args: dict) -> dict:
folder = args.get("dir") or "/Game"
recursive = bool(args.get("recursive", False))
paths = unreal.EditorAssetLibrary.list_assets(folder, recursive=recursive, include_folder=False)
return {"ok": True, "dir": folder, "count": len(paths), "assets": list(paths)[:500]}
OPS = {
"asset": asset,
"thumbnail": asset,
"material": asset,
"mesh": asset,
"scene": scene,
"scene_from_actor": scene_from_actor,
"list_assets": list_assets,
}
def entrypoint(payload_json: str) -> str:
try:
p = json.loads(payload_json)
except Exception as e: # noqa: BLE001
return _tee({"ok": False, "error": f"bad json: {e}"}, {})
fn = OPS.get(p.get("op"))
if not fn:
return _tee({"ok": False, "error": f"unknown op {p.get('op')!r}", "available": list(OPS)}, p)
try:
return _tee(fn(p), p)
except Exception as e: # noqa: BLE001
return _tee({"ok": False, "error": str(e), "traceback": traceback.format_exc()}, p)
def _tee(result: dict, p: dict) -> str:
txt = json.dumps(result)
try:
if unreal is not None:
sd = unreal.Paths.project_saved_dir() + "MCP/"
os.makedirs(sd, exist_ok=True)
rid = p.get("__rid") or "last"
with open(sd + "last.json", "w", encoding="utf-8") as f:
f.write(txt)
if rid != "last":
with open(sd + f"{rid}.json", "w", encoding="utf-8") as f:
f.write(txt)
unreal.log("[MCP-CAPTURE] " + txt[:1500])
except Exception:
pass
return txt

118
ue_side/console.py Normal file
View File

@ -0,0 +1,118 @@
"""Console commands and CVars.
Ops:
run {command} — execute editor console command
get_cvar {name}
set_cvar {name, value}
list_cvars {prefix?} — search registered cvars by prefix (best-effort)
stat {name, enable?:true} — toggle stat command (`stat fps`, `stat unit`, ...)
"""
from __future__ import annotations
import json
import os
import traceback
try:
import unreal # type: ignore
except ImportError:
unreal = None
def _world():
return unreal.EditorLevelLibrary.get_editor_world()
def run(args):
cmd = args["command"]
try:
unreal.SystemLibrary.execute_console_command(_world(), cmd)
return {"ok": True, "command": cmd}
except Exception as e:
return {"ok": False, "error": str(e)}
def get_cvar(args):
name = args["name"]
# No direct read API in stable Python; we issue ?-style print and grab from log.
try:
unreal.SystemLibrary.execute_console_command(_world(), name)
# Heuristic: also use ConsoleVariablesEditorFunctionLibrary if available
cvl = getattr(unreal, "ConsoleVariablesEditorFunctionLibrary", None)
if cvl and hasattr(cvl, "get_console_variable_string_value"):
return {"ok": True, "name": name, "value": cvl.get_console_variable_string_value(name)}
return {"ok": True, "name": name, "note": "value echoed to Output Log (no direct read API)"}
except Exception as e:
return {"ok": False, "error": str(e)}
def set_cvar(args):
name = args["name"]
val = args["value"]
cmd = f"{name} {val}"
try:
unreal.SystemLibrary.execute_console_command(_world(), cmd)
return {"ok": True, "command": cmd}
except Exception as e:
return {"ok": False, "error": str(e)}
def list_cvars(args):
prefix = args.get("prefix") or ""
cmd = f"DumpConsoleCommands {prefix}".strip()
try:
unreal.SystemLibrary.execute_console_command(_world(), cmd)
return {"ok": True, "note": "results dumped to log; use read_python_log or grep the project log",
"command": cmd}
except Exception as e:
return {"ok": False, "error": str(e)}
def stat(args):
name = args["name"]
if not name.lower().startswith("stat "):
name = "stat " + name
try:
unreal.SystemLibrary.execute_console_command(_world(), name)
return {"ok": True, "command": name}
except Exception as e:
return {"ok": False, "error": str(e)}
OPS = {
"run": run,
"get_cvar": get_cvar,
"set_cvar": set_cvar,
"list_cvars": list_cvars,
"stat": stat,
}
def entrypoint(payload_json):
try:
p = json.loads(payload_json)
except Exception as e:
return _tee({"ok": False, "error": f"bad json: {e}"}, {})
op = p.get("op"); fn = OPS.get(op)
if not fn:
return _tee({"ok": False, "error": f"unknown op {op!r}", "available": list(OPS)}, p)
try:
return _tee(fn(p), p)
except Exception as e:
return _tee({"ok": False, "error": str(e), "traceback": traceback.format_exc()}, p)
def _tee(result, p):
txt = json.dumps(result)
try:
if unreal is not None:
sd = unreal.Paths.project_saved_dir() + "MCP/"
os.makedirs(sd, exist_ok=True)
rid = p.get("__rid") or "last"
with open(sd + "last.json", "w", encoding="utf-8") as f: f.write(txt)
if rid != "last":
with open(sd + f"{rid}.json", "w", encoding="utf-8") as f: f.write(txt)
unreal.log("[MCP-CONSOLE] " + txt[:1500])
except Exception:
pass
return txt

271
ue_side/cpp_scaffold.py Normal file
View File

@ -0,0 +1,271 @@
"""C++ class scaffold — generate .h/.cpp templates under a project module.
This does NOT compile the code — pair with live_coding.compile or a regular
hot-reload after the files appear. UE will pick up new files on next build.
Ops:
list_modules {} — read Source/*/Build.cs
create_class {name, parent_class, module?(default: primary game module), subfolder?, header_only?:false, public?:true, api_macro?:auto}
list_classes {module?, subfolder?} — list .h files under a module
read_class {name, module?} — return .h+.cpp text
"""
from __future__ import annotations
import json
import os
import re
import traceback
try:
import unreal # type: ignore
except ImportError:
unreal = None
def _project_dir():
if unreal is not None:
return unreal.Paths.convert_relative_path_to_full(unreal.Paths.project_dir()).rstrip("/\\")
return os.getcwd()
def _source_dir():
return os.path.join(_project_dir(), "Source")
def _project_name():
"""Project name = the .uproject base name (also the conventional primary module)."""
if unreal is not None:
try:
up = unreal.Paths.get_project_file_path() # e.g. .../MyGame.uproject
return os.path.splitext(os.path.basename(up))[0]
except Exception:
pass
return os.path.basename(_project_dir().rstrip("/\\"))
def _default_module():
"""Primary game module to scaffold into. Prefer the Runtime module declared in
the .uproject; fall back to the module whose folder matches the project name;
else the first Source module; else the project name."""
proj = _project_name()
# 1. Read the .uproject Modules list for a Runtime/Game module.
try:
up = unreal.Paths.get_project_file_path() if unreal is not None else None
if up and os.path.isfile(up):
with open(up, "r", encoding="utf-8") as f:
data = json.load(f)
mods = data.get("Modules") or []
runtime = [m for m in mods if str(m.get("Type", "")).lower() in ("runtime", "")]
if runtime:
return runtime[0].get("Name") or proj
if mods:
return mods[0].get("Name") or proj
except Exception:
pass
# 2. Folder matching the project name.
src = _source_dir()
if os.path.isdir(os.path.join(src, proj)):
return proj
# 3. First Source module with a Build.cs.
try:
for name in sorted(os.listdir(src)):
if os.path.isfile(os.path.join(src, name, f"{name}.Build.cs")):
return name
except Exception:
pass
return proj
def list_modules(args):
out = []
src = _source_dir()
if not os.path.isdir(src):
return {"ok": False, "error": f"no Source dir at {src}"}
for name in os.listdir(src):
p = os.path.join(src, name)
if os.path.isdir(p):
build_cs = os.path.join(p, f"{name}.Build.cs")
if os.path.isfile(build_cs):
out.append({"name": name, "build_cs": build_cs,
"has_public": os.path.isdir(os.path.join(p, "Public")),
"has_private": os.path.isdir(os.path.join(p, "Private"))})
return {"ok": True, "modules": out}
def _api_macro(module):
return f"{module.upper()}_API"
_HEADER_TPL = """// Generated by UEBlueprintMCP cpp_scaffold.
#pragma once
#include "CoreMinimal.h"
#include "{base_header}"
#include "{name}.generated.h"
UCLASS()
class {api} {name} : public {parent}
{{
\tGENERATED_BODY()
public:
\t{name}();
}};
"""
_CPP_TPL = """// Generated by UEBlueprintMCP cpp_scaffold.
#include "{include_path}{name}.h"
{name}::{name}()
{{
\tPrimaryActorTick.bCanEverTick = false;
}}
"""
# Map of common parents -> include they need
_PARENT_HEADERS = {
"AActor": "GameFramework/Actor.h",
"APawn": "GameFramework/Pawn.h",
"ACharacter": "GameFramework/Character.h",
"APlayerController": "GameFramework/PlayerController.h",
"AGameModeBase": "GameFramework/GameModeBase.h",
"AAIController": "AIController.h",
"UObject": "UObject/NoExportTypes.h",
"UActorComponent": "Components/ActorComponent.h",
"USceneComponent": "Components/SceneComponent.h",
"UPrimitiveComponent": "Components/PrimitiveComponent.h",
"UStaticMeshComponent": "Components/StaticMeshComponent.h",
"USkeletalMeshComponent":"Components/SkeletalMeshComponent.h",
"UAnimInstance": "Animation/AnimInstance.h",
"UUserWidget": "Blueprint/UserWidget.h",
"UDataAsset": "Engine/DataAsset.h",
"UInterface": "UObject/Interface.h",
}
def create_class(args):
name = args["name"]
parent = args["parent_class"] # e.g. AActor / UObject / UActorComponent
module = args.get("module") or _default_module()
subfolder = (args.get("subfolder") or "").replace("\\", "/").strip("/")
header_only = bool(args.get("header_only"))
public = bool(args.get("public", True))
if not re.match(r"^[AU][A-Z]\w+$", name):
return {"ok": False, "error": "class name must start with A or U followed by PascalCase (e.g. AMyActor, UMyComp)"}
src_mod = os.path.join(_source_dir(), module)
if not os.path.isdir(src_mod):
return {"ok": False, "error": f"module not found: {module}"}
pub_dir = os.path.join(src_mod, "Public", subfolder) if public else os.path.join(src_mod, subfolder)
priv_dir = os.path.join(src_mod, "Private", subfolder) if public else os.path.join(src_mod, subfolder)
os.makedirs(pub_dir, exist_ok=True)
if not header_only:
os.makedirs(priv_dir, exist_ok=True)
base_header = args.get("base_header") or _PARENT_HEADERS.get(parent, "CoreMinimal.h")
api = args.get("api_macro") or _api_macro(module)
h_path = os.path.join(pub_dir, f"{name}.h")
c_path = os.path.join(priv_dir, f"{name}.cpp")
if os.path.exists(h_path):
return {"ok": False, "error": f"file exists: {h_path}"}
h_txt = _HEADER_TPL.format(name=name, parent=parent, api=api, base_header=base_header)
with open(h_path, "w", encoding="utf-8", newline="\n") as f:
f.write(h_txt)
out = {"ok": True, "header": h_path}
if not header_only:
include_rel = (subfolder + "/") if subfolder else ""
c_txt = _CPP_TPL.format(name=name, include_path=include_rel)
with open(c_path, "w", encoding="utf-8", newline="\n") as f:
f.write(c_txt)
out["cpp"] = c_path
out["note"] = "Files written. Run live_coding.compile or rebuild from VS to register the class."
return out
def list_classes(args):
module = args.get("module") or _default_module()
subfolder = (args.get("subfolder") or "").replace("\\", "/").strip("/")
mod_root = os.path.join(_source_dir(), module)
if not os.path.isdir(mod_root):
return {"ok": False, "error": f"module not found: {mod_root}"}
scan_root = os.path.join(mod_root, subfolder) if subfolder else mod_root
out = []
for dirpath, _, fnames in os.walk(scan_root):
for fn in fnames:
if fn.endswith(".h"):
full = os.path.join(dirpath, fn)
rel = os.path.relpath(full, mod_root).replace("\\", "/")
# Skip the module's own header (e.g. <Module>.h)
if rel == f"{module}.h":
continue
out.append(rel)
return {"ok": True, "module": module, "headers": sorted(out), "count": len(out)}
def read_class(args):
module = args.get("module") or _default_module()
name = args["name"]
found = {"header": None, "cpp": None}
for dirpath, _, fnames in os.walk(os.path.join(_source_dir(), module)):
for fn in fnames:
if fn == f"{name}.h":
found["header"] = os.path.join(dirpath, fn)
elif fn == f"{name}.cpp":
found["cpp"] = os.path.join(dirpath, fn)
out = {"ok": True}
for k, p in found.items():
if p and os.path.isfile(p):
try:
with open(p, "r", encoding="utf-8") as f:
out[k] = {"path": p, "content": f.read()}
except Exception as e:
out[k] = {"path": p, "error": str(e)}
if not (found["header"] or found["cpp"]):
return {"ok": False, "error": f"class {name} not found under Source/{module}"}
return out
OPS = {
"list_modules": list_modules,
"create_class": create_class,
"list_classes": list_classes,
"read_class": read_class,
}
def entrypoint(payload_json):
try:
p = json.loads(payload_json)
except Exception as e:
return _tee({"ok": False, "error": f"bad json: {e}"}, {})
op = p.get("op"); fn = OPS.get(op)
if not fn:
return _tee({"ok": False, "error": f"unknown op {op!r}", "available": list(OPS)}, p)
try:
return _tee(fn(p), p)
except Exception as e:
return _tee({"ok": False, "error": str(e), "traceback": traceback.format_exc()}, p)
def _tee(result, p):
txt = json.dumps(result)
try:
if unreal is not None:
sd = unreal.Paths.project_saved_dir() + "MCP/"
os.makedirs(sd, exist_ok=True)
rid = p.get("__rid") or "last"
with open(sd + "last.json", "w", encoding="utf-8") as f: f.write(txt)
if rid != "last":
with open(sd + f"{rid}.json", "w", encoding="utf-8") as f: f.write(txt)
unreal.log("[MCP-CPP] " + txt[:1500])
except Exception:
pass
return txt

227
ue_side/cull.py Normal file
View File

@ -0,0 +1,227 @@
"""Cull-distance ops — batch set/inspect Desired Max Draw Distance on ISM/HISM/StaticMesh components.
Encapsulates the gotchas learned the hard way:
* CachedMaxDrawDistance is READ-ONLY -> we only write LDMaxDrawDistance (+ SetCullDistance on live instances).
* SubobjectDataHandle must be unwrapped via k2_find_subobject_data_from_handle (UE 5.7).
* target='asset_template' edits the Blueprint asset (default for ALL instances, survives reconstruct);
target='level_instance' edits placed actors only (fast, but lost on BP recompile / PLA repack).
* only_zero keeps it idempotent and never clobbers hand-tuned non-zero distances.
Ops:
audit {scope?, asset_paths?, component_types?, target?} — read-only report, no mutation
apply {distance, scope?, asset_paths?, component_types?, only_zero?, target?, save?:true}
verify {...same as audit} — alias of audit (post-apply check)
scope: 'level' (default) | 'assets' | 'selection'
target: 'asset_template' (default) | 'level_instance'
component_types: subset of ['ISM','HISM','StaticMesh','Primitive'] (default ['ISM'])
distance: cm; 0 disables culling
"""
from __future__ import annotations
import json
import os
import traceback
try:
import unreal # type: ignore
except ImportError:
unreal = None
def _eas(): return unreal.get_editor_subsystem(unreal.EditorActorSubsystem)
def _sub(): return unreal.get_engine_subsystem(unreal.SubobjectDataSubsystem)
def _type_classes(args):
types = args.get("component_types") or ["ISM"]
mapping = {
"ISM": unreal.InstancedStaticMeshComponent,
"HISM": unreal.HierarchicalInstancedStaticMeshComponent,
"StaticMesh": unreal.StaticMeshComponent,
"Primitive": unreal.PrimitiveComponent,
}
out = []
for t in types:
cls = mapping.get(t)
if cls is None:
raise ValueError(f"unknown component_type {t!r}; allowed: {list(mapping)}")
out.append(cls)
return out
def _matches(obj, classes):
return any(isinstance(obj, c) for c in classes)
def _asset_path_from_actor(actor):
gen = actor.get_class().get_path_name()
asset = gen.split(".")[0]
return asset[:-2] if asset.endswith("_C") else asset
# ---- target='asset_template' : edit Blueprint component templates -----------
def _template_components(bp, classes):
"""Returns deduped list of (name, component_template) for matching types."""
handles = _sub().k2_gather_subobject_data_for_blueprint(bp)
seen, out = set(), []
for h in handles:
data = _sub().k2_find_subobject_data_from_handle(h)
obj = unreal.SubobjectDataBlueprintFunctionLibrary.get_object(data)
if obj is None or not _matches(obj, classes):
continue
nm = obj.get_name()
if nm in seen:
continue
seen.add(nm)
out.append((nm, obj))
return out
def _collect_assets(args):
"""Returns dict {asset_path: blueprint_asset} for the requested scope."""
ael = unreal.EditorAssetLibrary
paths = {}
scope = args.get("scope") or "level"
if scope == "assets":
for p in (args.get("asset_paths") or []):
paths[p.split(".")[0]] = None
else: # level / selection -> discover from placed actors
classes = _type_classes(args)
actors = (_eas().get_selected_level_actors() if scope == "selection"
else _eas().get_all_level_actors())
for a in actors:
if any(a.get_components_by_class(c) for c in classes):
paths[_asset_path_from_actor(a)] = None
result = {}
for p in paths:
result[p] = ael.load_asset(p)
return result
def _run_assets(args, mutate):
classes = _type_classes(args)
distance = float(args.get("distance", 0.0))
only_zero = args.get("only_zero", True)
save = args.get("save", True)
rows, total_set = [], 0
for path, bp in _collect_assets(args).items():
if bp is None:
rows.append({"asset": path, "error": "load failed"}); continue
comps = _template_components(bp, classes)
vals, set_n = [], 0
for _nm, c in comps:
cur = float(c.get_editor_property("ld_max_draw_distance"))
if mutate:
if not (only_zero and abs(cur) > 0.001):
c.set_editor_property("ld_max_draw_distance", distance)
set_n += 1; cur = distance
vals.append(round(cur, 1))
if mutate and set_n:
unreal.BlueprintEditorLibrary.compile_blueprint(bp)
if save:
unreal.EditorAssetLibrary.save_asset(path)
total_set += 1
rows.append({
"asset": path, "components": len(comps),
"zeros": sum(1 for v in vals if abs(v) < 0.001),
"distinct": sorted(set(vals)), "set": set_n,
})
return {"ok": True, "target": "asset_template",
"assets_modified": total_set, "rows": rows}
# ---- target='level_instance' : edit placed actors --------------------------
def _run_instances(args, mutate):
classes = _type_classes(args)
distance = float(args.get("distance", 0.0))
only_zero = args.get("only_zero", True)
scope = args.get("scope") or "level"
actors = (_eas().get_selected_level_actors() if scope == "selection"
else _eas().get_all_level_actors())
rows, changed_actors, changed_comps = [], 0, 0
for a in actors:
comps, seen = [], set()
for c in classes:
for comp in a.get_components_by_class(c):
if comp.get_name() not in seen:
seen.add(comp.get_name()); comps.append(comp)
if not comps:
continue
vals, set_n = [], 0
for comp in comps:
cur = float(comp.get_editor_property("ld_max_draw_distance"))
if mutate and not (only_zero and abs(cur) > 0.001):
comp.modify()
comp.set_editor_property("ld_max_draw_distance", distance)
comp.set_cull_distance(distance) # updates cached + re-registers
set_n += 1; cur = distance
vals.append(round(cur, 1))
if mutate and set_n:
a.modify(); changed_actors += 1; changed_comps += set_n
rows.append({"actor": a.get_actor_label(), "components": len(comps),
"zeros": sum(1 for v in vals if abs(v) < 0.001),
"distinct": sorted(set(vals)), "set": set_n})
return {"ok": True, "target": "level_instance",
"actors_modified": changed_actors, "components_set": changed_comps,
"rows": rows, "note": "instance edits are lost on BP recompile / PLA repack"}
# ---- op table --------------------------------------------------------------
def _dispatch(args, mutate):
target = args.get("target") or "asset_template"
if target == "asset_template":
return _run_assets(args, mutate)
if target == "level_instance":
return _run_instances(args, mutate)
return {"ok": False, "error": f"unknown target {target!r}; use 'asset_template' or 'level_instance'"}
def audit(args):
return _dispatch(args, mutate=False)
def verify(args):
return _dispatch(args, mutate=False)
def apply(args):
if "distance" not in args:
return {"ok": False, "error": "apply requires 'distance' (cm)"}
return _dispatch(args, mutate=True)
OPS = {"audit": audit, "verify": verify, "apply": apply}
def entrypoint(payload_json):
try:
p = json.loads(payload_json)
except Exception as e:
return _tee({"ok": False, "error": f"bad json: {e}"}, {})
op = p.get("op"); fn = OPS.get(op)
if not fn:
return _tee({"ok": False, "error": f"unknown op {op!r}", "available": list(OPS)}, p)
try:
return _tee(fn(p), p)
except Exception as e:
return _tee({"ok": False, "error": str(e), "traceback": traceback.format_exc()}, p)
def _tee(result, p):
txt = json.dumps(result)
try:
if unreal is not None:
sd = unreal.Paths.project_saved_dir() + "MCP/"
os.makedirs(sd, exist_ok=True)
rid = p.get("__rid") or "last"
with open(sd + "last.json", "w", encoding="utf-8") as f: f.write(txt)
if rid != "last":
with open(sd + f"{rid}.json", "w", encoding="utf-8") as f: f.write(txt)
unreal.log("[MCP-CULL] " + txt[:1500])
except Exception:
pass
return txt

153
ue_side/discover.py Normal file
View File

@ -0,0 +1,153 @@
"""Read-only discovery ops via MCPGraphLibrary v0.3.
Ops:
list_open_bps {} — open BP editors
list_graphs {bp_path} — all graphs of a BP
describe_bp {bp_path} — parent/interfaces/vars/components/funcs/dispatchers
get_selection {bp_path} — selected nodes in BP editor
resolve_call {bp_path, function, guid} — CallFunction node → source bp/class
find_var_refs {bp_path, name} — variable usage sites
find_fn_refs {bp_path, name} — function call sites in this BP
find_bp_by_parent {parent_class_path} — AssetRegistry search
get_refs_of {asset_path} — assets referenced by this asset
get_referencers {asset_path} — assets that reference this asset
read_function {bp_path, function} — wrapper over apply_graph.read_graph for function
"""
from __future__ import annotations
import json
import os
import traceback
try:
import unreal # type: ignore
except ImportError:
unreal = None
def _lib():
L = getattr(unreal, "MCPGraphLibrary", None)
if L is None:
raise RuntimeError("MCPGraphLibrary missing — rebuild C++ plugin")
return L
def _load_bp(path: str):
a = unreal.EditorAssetLibrary.load_asset(path)
if not isinstance(a, unreal.Blueprint):
raise RuntimeError(f"not a blueprint: {path!r}")
return a
def _find_graph(bp, name: str):
L = _lib()
if name in (None, "", "EventGraph"):
return L.find_event_graph(bp)
return L.find_function_graph(bp, name)
def list_open_bps(args: dict) -> dict:
return {"ok": True, "open": list(_lib().list_open_blueprints())}
def list_graphs(args: dict) -> dict:
bp = _load_bp(args["bp_path"])
raw = _lib().list_graphs_json(bp)
return {"ok": True, "bp_path": args["bp_path"], **json.loads(raw)}
def describe_bp(args: dict) -> dict:
bp = _load_bp(args["bp_path"])
raw = _lib().describe_blueprint_json(bp)
return {"ok": True, **json.loads(raw)}
def get_selection(args: dict) -> dict:
bp = _load_bp(args["bp_path"])
raw = _lib().get_selected_nodes_json(bp)
return {"ok": True, "bp_path": args["bp_path"], **json.loads(raw)}
def resolve_call(args: dict) -> dict:
bp = _load_bp(args["bp_path"])
g = _find_graph(bp, args.get("function") or "EventGraph")
if g is None:
return {"ok": False, "error": "graph not found"}
raw = _lib().resolve_function_source_json(g, args["guid"])
return {"ok": True, **json.loads(raw)}
def find_var_refs(args: dict) -> dict:
bp = _load_bp(args["bp_path"])
raw = _lib().find_variable_references_json(bp, args["name"])
return {"ok": True, **json.loads(raw)}
def find_fn_refs(args: dict) -> dict:
bp = _load_bp(args["bp_path"])
raw = _lib().find_function_references_json(bp, args["name"])
return {"ok": True, **json.loads(raw)}
def find_bp_by_parent(args: dict) -> dict:
found = list(_lib().find_blueprints_by_parent(args["parent_class_path"]))
return {"ok": True, "blueprints": found, "count": len(found)}
def get_refs_of(args: dict) -> dict:
return {"ok": True, "references": list(_lib().get_referenced_assets(args["asset_path"]))}
def get_referencers(args: dict) -> dict:
return {"ok": True, "referencers": list(_lib().get_referencers_of_asset(args["asset_path"]))}
def read_function(args: dict) -> dict:
# Convenience: read_graph for a named function
import importlib, apply_graph
importlib.reload(apply_graph)
return apply_graph.read_graph(args["bp_path"], args.get("function") or "EventGraph")
OPS = {
"list_open_bps": list_open_bps,
"list_graphs": list_graphs,
"describe_bp": describe_bp,
"get_selection": get_selection,
"resolve_call": resolve_call,
"find_var_refs": find_var_refs,
"find_fn_refs": find_fn_refs,
"find_bp_by_parent": find_bp_by_parent,
"get_refs_of": get_refs_of,
"get_referencers": get_referencers,
"read_function": read_function,
}
def entrypoint(payload_json: str) -> str:
try:
p = json.loads(payload_json)
except Exception as e: # noqa: BLE001
return _tee({"ok": False, "error": f"bad json: {e}"}, p={})
fn = OPS.get(p.get("op"))
if not fn:
return _tee({"ok": False, "error": f"unknown op {p.get('op')!r}", "available": list(OPS)}, p)
try:
return _tee(fn(p), p)
except Exception as e: # noqa: BLE001
return _tee({"ok": False, "error": str(e), "traceback": traceback.format_exc()}, p)
def _tee(result: dict, p: dict) -> str:
txt = json.dumps(result)
try:
if unreal is not None:
sd = unreal.Paths.project_saved_dir() + "MCP/"
os.makedirs(sd, exist_ok=True)
rid = p.get("__rid") or "last"
with open(sd + f"{rid}.json", "w", encoding="utf-8") as f:
f.write(txt)
unreal.log("[MCP-DISCOVER] " + txt[:1500])
except Exception:
pass
return txt

186
ue_side/edit_ops.py Normal file
View File

@ -0,0 +1,186 @@
"""Single-node / single-edge edit ops + navigation.
Ops:
delete_node {bp_path, function?, guid}
break_link {bp_path, function?, from:[guid,pin], to:[guid,pin]}
break_all {bp_path, function?, guid}
move_nodes {bp_path, function?, moves:[{guid,x,y}]}
resize_comment {bp_path, function?, guid, width, height}
set_comment {bp_path, function?, guid, text, bubble?}
open_bp {bp_path}
open_graph {bp_path, function}
jump_to_node {bp_path, function?, guid}
add_case_pin {bp_path, function?, guid} — switch ops
format_text {bp_path, function?, guid, format}
reconstruct {bp_path, function?, guid}
"""
from __future__ import annotations
import json
import os
import traceback
try:
import unreal # type: ignore
except ImportError:
unreal = None
def _lib():
L = getattr(unreal, "MCPGraphLibrary", None)
if L is None:
raise RuntimeError("MCPGraphLibrary missing — rebuild C++ plugin")
return L
def _load_bp(path: str):
a = unreal.EditorAssetLibrary.load_asset(path)
if not isinstance(a, unreal.Blueprint):
raise RuntimeError(f"not a blueprint: {path!r}")
return a
def _graph(bp, fn: str | None):
L = _lib()
if fn in (None, "", "EventGraph"):
return L.find_event_graph(bp)
return L.find_function_graph(bp, fn)
def _compile_save(bp):
_lib().compile_and_save_blueprint(bp)
unreal.EditorAssetLibrary.save_loaded_asset(bp)
def delete_node(args: dict) -> dict:
bp = _load_bp(args["bp_path"])
g = _graph(bp, args.get("function"))
ok = bool(_lib().delete_node(g, args["guid"]))
if ok: _compile_save(bp)
return {"ok": ok, "guid": args["guid"]}
def break_link(args: dict) -> dict:
bp = _load_bp(args["bp_path"])
g = _graph(bp, args.get("function"))
(fg, fp) = args["from"]; (tg, tp) = args["to"]
ok = bool(_lib().break_link(g, fg, fp, tg, tp))
if ok: _compile_save(bp)
return {"ok": ok}
def break_all(args: dict) -> dict:
bp = _load_bp(args["bp_path"])
g = _graph(bp, args.get("function"))
ok = bool(_lib().break_all_node_links(g, args["guid"]))
if ok: _compile_save(bp)
return {"ok": ok}
def move_nodes(args: dict) -> dict:
bp = _load_bp(args["bp_path"])
g = _graph(bp, args.get("function"))
moves = args.get("moves") or []
ids = [m["guid"] for m in moves]
pos = [unreal.Vector2D(float(m["x"]), float(m["y"])) for m in moves]
n = int(_lib().move_nodes(g, ids, pos))
return {"ok": n > 0, "moved": n}
def resize_comment(args: dict) -> dict:
bp = _load_bp(args["bp_path"])
g = _graph(bp, args.get("function"))
ok = bool(_lib().resize_comment_node(g, args["guid"], unreal.Vector2D(float(args["width"]), float(args["height"]))))
return {"ok": ok}
def set_comment(args: dict) -> dict:
bp = _load_bp(args["bp_path"])
g = _graph(bp, args.get("function"))
ok = bool(_lib().set_node_comment(g, args["guid"], str(args.get("text", "")), bool(args.get("bubble", False))))
return {"ok": ok}
def open_bp(args: dict) -> dict:
bp = _load_bp(args["bp_path"])
return {"ok": bool(_lib().open_blueprint_editor(bp))}
def open_graph(args: dict) -> dict:
bp = _load_bp(args["bp_path"])
g = _graph(bp, args.get("function"))
return {"ok": bool(_lib().open_graph_in_editor(bp, g))}
def jump_to_node(args: dict) -> dict:
bp = _load_bp(args["bp_path"])
g = _graph(bp, args.get("function"))
return {"ok": bool(_lib().jump_to_node_in_editor(bp, g, args["guid"]))}
def add_case_pin(args: dict) -> dict:
bp = _load_bp(args["bp_path"])
g = _graph(bp, args.get("function"))
ok = bool(_lib().add_case_pin_to_switch(g, args["guid"]))
if ok: _compile_save(bp)
return {"ok": ok}
def format_text(args: dict) -> dict:
bp = _load_bp(args["bp_path"])
g = _graph(bp, args.get("function"))
ok = bool(_lib().format_text_set_format(g, args["guid"], args["format"]))
if ok: _compile_save(bp)
return {"ok": ok}
def reconstruct(args: dict) -> dict:
bp = _load_bp(args["bp_path"])
g = _graph(bp, args.get("function"))
ok = bool(_lib().reconstruct_node(g, args["guid"]))
return {"ok": ok}
OPS = {
"delete_node": delete_node,
"break_link": break_link,
"break_all": break_all,
"move_nodes": move_nodes,
"resize_comment": resize_comment,
"set_comment": set_comment,
"open_bp": open_bp,
"open_graph": open_graph,
"jump_to_node": jump_to_node,
"add_case_pin": add_case_pin,
"format_text": format_text,
"reconstruct": reconstruct,
}
def entrypoint(payload_json: str) -> str:
try:
p = json.loads(payload_json)
except Exception as e: # noqa: BLE001
return _tee({"ok": False, "error": f"bad json: {e}"}, {})
fn = OPS.get(p.get("op"))
if not fn:
return _tee({"ok": False, "error": f"unknown op {p.get('op')!r}", "available": list(OPS)}, p)
try:
return _tee(fn(p), p)
except Exception as e: # noqa: BLE001
return _tee({"ok": False, "error": str(e), "traceback": traceback.format_exc()}, p)
def _tee(result: dict, p: dict) -> str:
txt = json.dumps(result)
try:
if unreal is not None:
sd = unreal.Paths.project_saved_dir() + "MCP/"
os.makedirs(sd, exist_ok=True)
rid = p.get("__rid") or "last"
with open(sd + f"{rid}.json", "w", encoding="utf-8") as f:
f.write(txt)
unreal.log("[MCP-EDIT] " + txt[:1500])
except Exception:
pass
return txt

192
ue_side/graph_layout.py Normal file
View File

@ -0,0 +1,192 @@
"""Small deterministic graph layout helpers shared by MCP graph tools.
The goal is not to replace Unreal's graph layout. It is a guard rail for
generated graphs: keep columns readable and prevent heavy nodes from being
spawned on top of each other.
"""
from __future__ import annotations
DEFAULT_NODE_W = 260
DEFAULT_NODE_H = 150
DEFAULT_GAP_X = 180
DEFAULT_GAP_Y = 80
def estimate_size(spec: dict, domain: str = "blueprint") -> tuple[int, int]:
if domain == "pcg":
cls = str(spec.get("class") or spec.get("type") or "").lower()
# I/O nodes and reroutes are slim; spawners/samplers carry big detail panels.
if cls in ("input", "output"):
return 220, 120
if "spawner" in cls or "sampler" in cls or "subgraph" in cls:
return 340, 240
if "filter" in cls or "transform" in cls or "createpoints" in cls:
return 320, 200
return 300, 170
if domain == "material":
cls = str(spec.get("class") or spec.get("target") or "").lower()
if "texturesample" in cls:
return 310, 360
if "vectorparameter" in cls or "constant3vector" in cls or "constant4vector" in cls:
return 300, 300
if "scalarparameter" in cls:
return 260, 150
if "panner" in cls:
return 280, 190
if "linearinterpolate" in cls or "multiply" in cls or "add" in cls:
return 260, 170
if "texturecoordinate" in cls:
return 260, 130
return 280, 180
kind = str(spec.get("kind") or "").lower()
params = spec.get("params") or {}
if kind == "comment":
return int(params.get("width", 400)), int(params.get("height", 220))
if kind in ("event", "custom_event", "branch", "sequence", "operator", "knot"):
return 240, 120
if kind in ("call_function", "spawn_actor", "format_text"):
return 340, 190
if kind in ("make_struct", "break_struct", "set_fields_in_struct"):
return 360, 260
if kind in ("variable_get", "variable_set", "self"):
return 260, 130
return DEFAULT_NODE_W, DEFAULT_NODE_H
def normalize_specs(nodes: list[dict], edges: list[dict] | None = None, domain: str = "blueprint",
gap_x: int = DEFAULT_GAP_X, gap_y: int = DEFAULT_GAP_Y,
snap_x: int = 360) -> list[dict]:
"""Return copied nodes with collision-resistant positions.
The algorithm preserves the user's left-to-right intent from positions, but
snaps nearby x values into columns and stacks each column by estimated node
height. This keeps generated graphs stable and readable.
"""
copied = [dict(n) for n in (nodes or [])]
if len(copied) < 2:
return copied
with_meta = []
for index, node in enumerate(copied):
pos = node.get("position") or [0, 0]
x = float(pos[0]) if len(pos) > 0 else 0.0
y = float(pos[1]) if len(pos) > 1 else 0.0
w, h = estimate_size(node, domain)
with_meta.append({"index": index, "node": node, "x": x, "y": y, "w": w, "h": h})
sorted_by_x = sorted(with_meta, key=lambda item: (item["x"], item["y"], item["index"]))
columns: list[list[dict]] = []
for item in sorted_by_x:
if not columns:
columns.append([item])
continue
avg_x = sum(i["x"] for i in columns[-1]) / len(columns[-1])
if abs(item["x"] - avg_x) <= max(120, snap_x * 0.45):
columns[-1].append(item)
else:
columns.append([item])
min_x = min(item["x"] for item in with_meta)
for column_index, column in enumerate(columns):
column_x = min_x + column_index * (max(item["w"] for item in column) + gap_x)
column.sort(key=lambda item: (item["y"], item["index"]))
cursor_y = min(item["y"] for item in column)
for item in column:
desired_y = item["y"]
y = max(desired_y, cursor_y)
item["node"]["position"] = [round(column_x), round(y)]
cursor_y = y + item["h"] + gap_y
return copied
def tidy_existing_nodes(nodes: list[dict], edges: list[dict] | None = None, domain: str = "material") -> list[dict]:
"""Return [{guid/name,x,y}] for existing graph dump nodes."""
if edges:
return _tidy_existing_by_edges(nodes, edges, domain)
specs = []
for node in nodes or []:
cls = node.get("class") or ""
x = node.get("x", (node.get("position") or [0, 0])[0])
y = node.get("y", (node.get("position") or [0, 0])[1])
specs.append({
"id": node.get("guid") or node.get("name"),
"class": cls,
"position": [float(x), float(y)],
})
normalized = normalize_specs(specs, domain=domain)
moves = []
for spec in normalized:
x, y = spec.get("position") or [0, 0]
moves.append({"guid": spec.get("id"), "x": x, "y": y})
return moves
def _tidy_existing_by_edges(nodes: list[dict], edges: list[dict], domain: str) -> list[dict]:
by_id = {}
for node in nodes or []:
node_id = node.get("guid") or node.get("name")
if node_id:
by_id[node_id] = node
if not by_id:
return []
incoming = {node_id: set() for node_id in by_id}
outgoing = {node_id: set() for node_id in by_id}
for edge in edges or []:
try:
src = edge["from"][0]
dst = edge["to"][0]
except Exception:
continue
if src in by_id and dst in by_id:
outgoing[src].add(dst)
incoming[dst].add(src)
depths = {node_id: 0 for node_id in by_id}
changed = True
guard = 0
while changed and guard < max(4, len(by_id) * 3):
changed = False
guard += 1
for src, dsts in outgoing.items():
for dst in dsts:
candidate = depths[src] + 1
if candidate > depths[dst]:
depths[dst] = candidate
changed = True
columns: dict[int, list[dict]] = {}
for node_id, node in by_id.items():
columns.setdefault(depths.get(node_id, 0), []).append(node)
all_x = []
all_y = []
for node in by_id.values():
pos = node.get("position")
all_x.append(float(node.get("x", pos[0] if pos else 0)))
all_y.append(float(node.get("y", pos[1] if pos else 0)))
min_x = min(all_x) if all_x else 0
min_y = min(all_y) if all_y else 0
moves = []
sorted_depths = sorted(columns)
col_x = min_x
for depth in sorted_depths:
column = columns[depth]
column.sort(key=lambda node: float(node.get("y", (node.get("position") or [0, 0])[1])))
max_w = 0
cursor_y = min_y
for node in column:
spec = {"class": node.get("class"), "kind": node.get("kind")}
w, h = estimate_size(spec, domain)
max_w = max(max_w, w)
node_id = node.get("guid") or node.get("name")
moves.append({"guid": node_id, "x": round(col_x), "y": round(cursor_y)})
cursor_y += h + DEFAULT_GAP_Y
col_x += max_w + DEFAULT_GAP_X
return moves

187
ue_side/input.py Normal file
View File

@ -0,0 +1,187 @@
"""Enhanced Input ops — Input Actions, Input Mapping Contexts.
Ops:
create_input_action {path, value_type?:'Bool|Axis1D|Axis2D|Axis3D'}
create_input_mapping_context {path}
add_mapping {imc_path, action_path, key, modifiers?:[class_path], triggers?:[class_path]}
remove_mapping {imc_path, index}
list_mappings {imc_path}
"""
from __future__ import annotations
import json
import os
import traceback
try:
import unreal # type: ignore
except ImportError:
unreal = None
def _at(): return unreal.AssetToolsHelpers.get_asset_tools()
def _load(p): return unreal.EditorAssetLibrary.load_asset(p)
def _split(p):
folder, name = p.rsplit("/", 1)
return folder, name
VALUE_TYPES = {
"Bool": "Boolean",
"Axis1D": "Axis1D",
"Axis2D": "Axis2D",
"Axis3D": "Axis3D",
}
def create_input_action(args):
folder, name = _split(args["path"])
factory_cls = getattr(unreal, "InputActionFactory", None)
asset_cls = getattr(unreal, "InputAction", None)
if factory_cls is None or asset_cls is None:
return {"ok": False, "error": "Enhanced Input plugin not enabled"}
asset = _at().create_asset(name, folder, asset_cls, factory_cls())
if asset is None:
return {"ok": False, "error": "create returned None"}
vt = args.get("value_type") or "Bool"
enum_name = VALUE_TYPES.get(vt)
if enum_name:
try:
enum_obj = getattr(unreal.InputActionValueType, enum_name)
asset.set_editor_property("value_type", enum_obj)
except Exception:
pass
unreal.EditorAssetLibrary.save_asset(asset.get_path_name())
return {"ok": True, "path": asset.get_path_name().split(".")[0]}
def create_input_mapping_context(args):
folder, name = _split(args["path"])
factory_cls = getattr(unreal, "InputMappingContextFactory", None)
asset_cls = getattr(unreal, "InputMappingContext", None)
if factory_cls is None or asset_cls is None:
return {"ok": False, "error": "Enhanced Input plugin not enabled"}
asset = _at().create_asset(name, folder, asset_cls, factory_cls())
if asset is None:
return {"ok": False, "error": "create returned None"}
unreal.EditorAssetLibrary.save_asset(asset.get_path_name())
return {"ok": True, "path": asset.get_path_name().split(".")[0]}
def add_mapping(args):
imc = _load(args["imc_path"])
ia = _load(args["action_path"])
if imc is None or ia is None:
return {"ok": False, "error": "imc or action not found"}
key = unreal.InputCoreLibrary.input_key_from_string(args["key"]) \
if hasattr(unreal, "InputCoreLibrary") and hasattr(unreal.InputCoreLibrary, "input_key_from_string") \
else unreal.Key(args["key"])
mapping_cls = getattr(unreal, "EnhancedActionKeyMapping", None)
if mapping_cls is None:
try:
imc.map_key(ia, key)
unreal.EditorAssetLibrary.save_asset(imc.get_path_name())
return {"ok": True, "note": "added via map_key()"}
except Exception as e:
return {"ok": False, "error": f"map_key failed: {e}"}
m = mapping_cls()
m.set_editor_property("action", ia)
m.set_editor_property("key", key)
# Modifiers/triggers (class CDOs):
for cp in (args.get("modifiers") or []):
mc = unreal.load_class(None, cp)
if mc is not None:
try:
inst = unreal.new_object(mc)
mods = list(m.get_editor_property("modifiers") or [])
mods.append(inst)
m.set_editor_property("modifiers", mods)
except Exception:
pass
for cp in (args.get("triggers") or []):
tc = unreal.load_class(None, cp)
if tc is not None:
try:
inst = unreal.new_object(tc)
trigs = list(m.get_editor_property("triggers") or [])
trigs.append(inst)
m.set_editor_property("triggers", trigs)
except Exception:
pass
mappings = list(imc.get_editor_property("mappings") or [])
mappings.append(m)
imc.set_editor_property("mappings", mappings)
unreal.EditorAssetLibrary.save_asset(imc.get_path_name())
return {"ok": True, "index": len(mappings) - 1}
def remove_mapping(args):
imc = _load(args["imc_path"])
if imc is None:
return {"ok": False, "error": "imc not found"}
mappings = list(imc.get_editor_property("mappings") or [])
idx = int(args["index"])
if idx < 0 or idx >= len(mappings):
return {"ok": False, "error": "index out of range"}
mappings.pop(idx)
imc.set_editor_property("mappings", mappings)
unreal.EditorAssetLibrary.save_asset(imc.get_path_name())
return {"ok": True}
def list_mappings(args):
imc = _load(args["imc_path"])
if imc is None:
return {"ok": False, "error": "imc not found"}
out = []
for i, m in enumerate(imc.get_editor_property("mappings") or []):
try:
a = m.get_editor_property("action")
k = m.get_editor_property("key")
out.append({
"index": i,
"action": a.get_path_name().split(".")[0] if a else None,
"key": str(k),
})
except Exception:
pass
return {"ok": True, "mappings": out}
OPS = {
"create_input_action": create_input_action,
"create_input_mapping_context": create_input_mapping_context,
"add_mapping": add_mapping,
"remove_mapping": remove_mapping,
"list_mappings": list_mappings,
}
def entrypoint(payload_json):
try:
p = json.loads(payload_json)
except Exception as e:
return _tee({"ok": False, "error": f"bad json: {e}"}, {})
op = p.get("op"); fn = OPS.get(op)
if not fn:
return _tee({"ok": False, "error": f"unknown op {op!r}", "available": list(OPS)}, p)
try:
return _tee(fn(p), p)
except Exception as e:
return _tee({"ok": False, "error": str(e), "traceback": traceback.format_exc()}, p)
def _tee(result, p):
txt = json.dumps(result)
try:
if unreal is not None:
sd = unreal.Paths.project_saved_dir() + "MCP/"
os.makedirs(sd, exist_ok=True)
rid = p.get("__rid") or "last"
with open(sd + "last.json", "w", encoding="utf-8") as f: f.write(txt)
if rid != "last":
with open(sd + f"{rid}.json", "w", encoding="utf-8") as f: f.write(txt)
unreal.log("[MCP-INPUT] " + txt[:1500])
except Exception:
pass
return txt

202
ue_side/level.py Normal file
View File

@ -0,0 +1,202 @@
"""Level / scene ops via UE EditorActorSubsystem — pure Python, no C++ needed.
Ops (dispatched by `entrypoint(payload_json)`):
spawn_actor {class, location:[x,y,z]?, rotation:[p,y,r]?, scale:[x,y,z]?, name?}
destroy {name} — by actor label or path
get_selected {} — list selected actors
select {names:[]}
get_by_class {class}
find_by_name {name}
set_transform {name, location?, rotation?, scale?}
attach {child, parent, socket?}
save_level {}
load_level {path}
current_level {}
"""
from __future__ import annotations
import json
import os
import traceback
try:
import unreal # type: ignore
except ImportError:
unreal = None
def _ess():
return unreal.get_editor_subsystem(unreal.EditorActorSubsystem)
def _load_class(p: str):
if "/" in p:
return unreal.load_class(None, p)
for prefix in ("/Script/Engine.", "/Script/UnrealEd."):
c = unreal.load_class(None, prefix + p)
if c is not None:
return c
return None
def _vec(v, default=(0, 0, 0)):
if v is None:
v = default
return unreal.Vector(float(v[0]), float(v[1]), float(v[2]))
def _rot(v, default=(0, 0, 0)):
if v is None:
v = default
# rotator: pitch, yaw, roll
return unreal.Rotator(float(v[0]), float(v[1]), float(v[2]))
def _find_actor(name: str):
for a in _ess().get_all_level_actors():
if not a:
continue
if a.get_actor_label() == name or a.get_name() == name or a.get_path_name() == name:
return a
return None
def spawn_actor(args: dict) -> dict:
cls = _load_class(args["class"])
if cls is None:
return {"ok": False, "error": f"class not found: {args['class']!r}"}
loc = _vec(args.get("location"))
rot = _rot(args.get("rotation"))
scale = _vec(args.get("scale"), (1, 1, 1))
actor = _ess().spawn_actor_from_class(cls, loc, rot)
if actor is None:
return {"ok": False, "error": "spawn returned None"}
actor.set_actor_scale3d(scale)
if args.get("name"):
actor.set_actor_label(args["name"])
return {"ok": True, "name": actor.get_actor_label(), "path": actor.get_path_name()}
def destroy(args: dict) -> dict:
a = _find_actor(args["name"])
if not a:
return {"ok": False, "error": "actor not found"}
_ess().destroy_actor(a)
return {"ok": True, "name": args["name"]}
def get_selected(args: dict) -> dict:
return {"ok": True, "actors": [a.get_actor_label() for a in _ess().get_selected_level_actors()]}
def select(args: dict) -> dict:
names = args.get("names") or []
found = [a for a in (_find_actor(n) for n in names) if a]
_ess().set_selected_level_actors(found)
return {"ok": True, "selected": [a.get_actor_label() for a in found]}
def get_by_class(args: dict) -> dict:
cls = _load_class(args["class"])
if cls is None:
return {"ok": False, "error": f"class not found: {args['class']!r}"}
found = [a for a in _ess().get_all_level_actors() if isinstance(a, cls)]
return {"ok": True, "actors": [a.get_actor_label() for a in found]}
def find_by_name(args: dict) -> dict:
a = _find_actor(args["name"])
if not a:
return {"ok": False, "error": "not found"}
return {"ok": True, "name": a.get_actor_label(), "path": a.get_path_name(), "class": a.get_class().get_name()}
def set_transform(args: dict) -> dict:
a = _find_actor(args["name"])
if not a:
return {"ok": False, "error": "actor not found"}
if args.get("location") is not None:
a.set_actor_location(_vec(args["location"]), False, False)
if args.get("rotation") is not None:
a.set_actor_rotation(_rot(args["rotation"]), False)
if args.get("scale") is not None:
a.set_actor_scale3d(_vec(args["scale"], (1, 1, 1)))
return {"ok": True}
def attach(args: dict) -> dict:
child = _find_actor(args["child"])
parent = _find_actor(args["parent"])
if not child or not parent:
return {"ok": False, "error": "child or parent not found"}
socket = args.get("socket") or ""
child.attach_to_actor(parent, socket, unreal.AttachmentRule.KEEP_WORLD,
unreal.AttachmentRule.KEEP_WORLD, unreal.AttachmentRule.KEEP_WORLD, False)
return {"ok": True}
def save_level(args: dict) -> dict:
les = unreal.get_editor_subsystem(unreal.LevelEditorSubsystem)
ok = les.save_current_level()
return {"ok": bool(ok)}
def load_level(args: dict) -> dict:
les = unreal.get_editor_subsystem(unreal.LevelEditorSubsystem)
ok = les.load_level(args["path"])
return {"ok": bool(ok), "path": args["path"]}
def current_level(args: dict) -> dict:
les = unreal.get_editor_subsystem(unreal.LevelEditorSubsystem)
name = les.get_current_level_name() if hasattr(les, "get_current_level_name") else ""
world = unreal.EditorLevelLibrary.get_editor_world() if hasattr(unreal, "EditorLevelLibrary") else None
return {"ok": True, "name": name, "world": world.get_path_name() if world else None}
OPS = {
"spawn_actor": spawn_actor,
"destroy": destroy,
"get_selected": get_selected,
"select": select,
"get_by_class": get_by_class,
"find_by_name": find_by_name,
"set_transform": set_transform,
"attach": attach,
"save_level": save_level,
"load_level": load_level,
"current_level": current_level,
}
def entrypoint(payload_json: str) -> str:
try:
p = json.loads(payload_json)
except Exception as e: # noqa: BLE001
return _tee({"ok": False, "error": f"bad json: {e}"}, {})
op = p.get("op")
fn = OPS.get(op)
if not fn:
return _tee({"ok": False, "error": f"unknown op {op!r}", "available": list(OPS)}, p)
try:
return _tee(fn(p), p)
except Exception as e: # noqa: BLE001
return _tee({"ok": False, "error": str(e), "traceback": traceback.format_exc()}, p)
def _tee(result: dict, p: dict) -> str:
txt = json.dumps(result)
try:
if unreal is not None:
sd = unreal.Paths.project_saved_dir() + "MCP/"
os.makedirs(sd, exist_ok=True)
rid = p.get("__rid") or "last"
with open(sd + "last.json", "w", encoding="utf-8") as f:
f.write(txt)
if rid != "last":
with open(sd + f"{rid}.json", "w", encoding="utf-8") as f:
f.write(txt)
unreal.log("[MCP-LEVEL] " + txt[:1500])
except Exception:
pass
return txt

307
ue_side/live_coding.py Normal file
View File

@ -0,0 +1,307 @@
"""Live Coding control — status, compile trigger, error parsing.
Why this exists: the AI repeatedly hits "Live Coding is open but won't build /
won't recompile". This module wraps ILiveCodingModule via Python where exposed,
falls back to console commands otherwise, and scrapes the latest LiveCoding log
for compile errors so the AI can iterate without screenshots.
Ops:
status {} — is_enabled, has_started_compile, pending_changes (best-effort)
compile {} — kick off a Live Coding patch
enable {}
disable {}
get_errors {tail?:200} — read last LiveCoding.log lines, filter for errors
open_lc_console{} — opens the LC window
"""
from __future__ import annotations
import json
import os
import re
import traceback
try:
import unreal # type: ignore
except ImportError:
unreal = None
def _project_log_path():
"""The main editor log: Saved/Logs/<ProjectName>.log. Project name is derived
from the .uproject so this is correct in any project, not just one."""
if unreal is None:
return None
try:
up = unreal.Paths.get_project_file_path()
name = os.path.splitext(os.path.basename(up))[0]
return unreal.Paths.project_saved_dir() + f"Logs/{name}.log"
except Exception:
return None
def _world():
return unreal.EditorLevelLibrary.get_editor_world()
def _exec(cmd):
unreal.SystemLibrary.execute_console_command(_world(), cmd)
def _lc_log_path():
"""Search a wide range of locations for LiveCoding.log + LiveCoding-backup-*.log.
Returns newest file by mtime."""
candidates = []
def _add_dir(d):
if not d or not os.path.isdir(d):
return
try:
for fn in os.listdir(d):
if fn.lower().startswith("livecoding") and fn.lower().endswith(".log"):
candidates.append(os.path.join(d, fn))
except Exception:
pass
# Project Saved/Logs
try:
_add_dir(unreal.Paths.project_saved_dir() + "Logs")
except Exception:
pass
# Engine Saved/Logs (project-relative)
try:
_add_dir(unreal.Paths.engine_saved_dir() + "Logs")
except Exception:
pass
# Engine Programs / LiveCodingConsole — this is where LC actually writes its server log.
# unreal.Paths.engine_dir() gives the engine root (relative or absolute).
try:
engine_root = unreal.Paths.convert_relative_path_to_full(unreal.Paths.engine_dir())
_add_dir(os.path.join(engine_root, "Programs", "LiveCodingConsole", "Saved", "Logs"))
except Exception:
pass
# %LOCALAPPDATA%\UnrealEngine\<Engine>\Saved\Logs
appdata = os.environ.get("LOCALAPPDATA", "")
if appdata:
ue_root = os.path.join(appdata, "UnrealEngine")
if os.path.isdir(ue_root):
for sub in os.listdir(ue_root):
_add_dir(os.path.join(ue_root, sub, "Saved", "Logs"))
# %APPDATA%\Unreal Engine\AutomationTool\Logs (sometimes UBT writes here)
appdata_roam = os.environ.get("APPDATA", "")
if appdata_roam:
_add_dir(os.path.join(appdata_roam, "Unreal Engine", "AutomationTool", "Logs"))
# Programs/UnrealBuildTool/Log.txt — different file but useful for compile errors
# We only care about LiveCoding here; UBT log is separate.
if not candidates:
return None
candidates.sort(key=lambda p: os.path.getmtime(p), reverse=True)
return candidates[0]
def status(args):
out = {"ok": True}
try:
lcs = getattr(unreal, "LiveCodingSubsystem", None)
sub = unreal.get_editor_subsystem(lcs) if lcs else None
if sub is not None:
for attr in ("is_enabled", "has_started_compile", "is_compiling"):
if hasattr(sub, attr):
out[attr] = bool(getattr(sub, attr)())
else:
out["note"] = "LiveCodingSubsystem not exposed in Python; using log scrape + console"
except Exception as e:
out["query_error"] = str(e)
log = _lc_log_path()
out["lc_log_path"] = log
out["lc_log_exists"] = bool(log)
# Always also expose the main project log as the fallback error source.
fb = _project_log_path()
if fb:
out["fallback_log"] = fb
return out
def compile(args):
try:
lcs = getattr(unreal, "LiveCodingSubsystem", None)
sub = unreal.get_editor_subsystem(lcs) if lcs else None
if sub is not None and hasattr(sub, "compile"):
sub.compile()
return {"ok": True, "via": "subsystem"}
except Exception as e:
return {"ok": False, "error": str(e)}
try:
_exec("LiveCoding.Compile")
return {"ok": True, "via": "console"}
except Exception as e:
return {"ok": False, "error": str(e)}
def enable(args):
try: _exec("LiveCoding.Enable")
except Exception as e: return {"ok": False, "error": str(e)}
return {"ok": True}
def disable(args):
try: _exec("LiveCoding.Disable")
except Exception as e: return {"ok": False, "error": str(e)}
return {"ok": True}
def open_lc_console(args):
try: _exec("LiveCoding.Console")
except Exception as e: return {"ok": False, "error": str(e)}
return {"ok": True}
_ERR_RX = re.compile(r"\b(error\s+[A-Z]\d{3,5}|fatal error|undefined reference|cannot open|LINK\s*:\s*fatal|: error:|: warning:)\b", re.I)
_LC_CATEGORY_RX = re.compile(r"Log(LiveCoding|LiveCodingServer|Compile|HotReload|UnrealBuildTool)\s*:", re.I)
_LC_COMPILE_START_RX = re.compile(r"(Manual recompile triggered|Creating patch|Compiling|Starting compile)", re.I)
_LC_COMPILE_END_RX = re.compile(r"Finished\s*\(([\d.]+)s\)", re.I)
_LC_NO_CHANGES_RX = re.compile(r"No changes detected|nothing to compile", re.I)
def get_errors(args):
tail = int(args.get("tail") or 200)
log = _lc_log_path()
sources_used = []
if log is not None:
sources_used.append(log)
try:
with open(log, "r", encoding="utf-8", errors="replace") as f:
lines = f.readlines()
errs = [l.rstrip() for l in lines if _ERR_RX.search(l)]
if errs:
return {"ok": True, "source": "LiveCoding.log", "log_path": log,
"total_lines": len(lines), "errors_count": len(errs),
"errors": errs[-tail:]}
except Exception as e:
sources_used.append(f"(read error: {e})")
# Fallback: project log, filtered by LiveCoding/Compile/HotReload/UBT categories
fallback = _project_log_path()
if fallback and os.path.isfile(fallback):
sources_used.append(fallback)
try:
with open(fallback, "r", encoding="utf-8", errors="replace") as f:
lines = f.readlines()
relevant = [l.rstrip() for l in lines
if _LC_CATEGORY_RX.search(l) or _ERR_RX.search(l)]
errs = [l for l in relevant if _ERR_RX.search(l) or "Error" in l or "Fatal" in l]
return {"ok": True, "source": "project_log_fallback",
"log_path": fallback, "total_lines": len(lines),
"lc_lines": len(relevant), "errors_count": len(errs),
"lc_recent": relevant[-tail:],
"errors": errs[-tail:],
"sources_searched": sources_used}
except Exception as e:
return {"ok": False, "error": f"fallback read failed: {e}",
"sources_searched": sources_used}
return {"ok": False, "error": "no LiveCoding.log and no fallback project log",
"sources_searched": sources_used}
def last_compile(args):
"""Find the most recent compile cycle in LiveCodingConsole.log.
Returns: status ('success'|'failed'|'in_progress'|'no_recent'),
duration_s, timestamp, lines for the cycle, and any errors.
"""
log = _lc_log_path()
if log is None:
return {"ok": False, "error": "no LC log found"}
try:
with open(log, "r", encoding="utf-8", errors="replace") as f:
lines = f.readlines()
except Exception as e:
return {"ok": False, "error": str(e)}
# Walk backwards: find the last "Creating patch" / "Manual recompile" marker
start_idx = -1
for i in range(len(lines) - 1, -1, -1):
if _LC_COMPILE_START_RX.search(lines[i]):
start_idx = i
break
if start_idx == -1:
return {"ok": True, "log_path": log, "status": "no_recent",
"note": "no compile cycle found in log"}
cycle = [l.rstrip() for l in lines[start_idx:]]
timestamp_match = re.search(r"\[([\d.\-:]+)\]", cycle[0])
timestamp = timestamp_match.group(1) if timestamp_match else None
end_match = None
for l in cycle:
m = _LC_COMPILE_END_RX.search(l)
if m:
end_match = m
break
errors = [l for l in cycle if _ERR_RX.search(l)]
if errors:
status_str = "failed"
elif end_match:
status_str = "success"
else:
status_str = "in_progress"
return {
"ok": True,
"log_path": log,
"status": status_str,
"timestamp": timestamp,
"duration_s": float(end_match.group(1)) if end_match else None,
"errors_count": len(errors),
"errors": errors,
"cycle_lines": cycle,
}
OPS = {
"status": status,
"compile": compile,
"enable": enable,
"disable": disable,
"open_lc_console": open_lc_console,
"get_errors": get_errors,
"last_compile": last_compile,
}
def entrypoint(payload_json):
try:
p = json.loads(payload_json)
except Exception as e:
return _tee({"ok": False, "error": f"bad json: {e}"}, {})
op = p.get("op"); fn = OPS.get(op)
if not fn:
return _tee({"ok": False, "error": f"unknown op {op!r}", "available": list(OPS)}, p)
try:
return _tee(fn(p), p)
except Exception as e:
return _tee({"ok": False, "error": str(e), "traceback": traceback.format_exc()}, p)
def _tee(result, p):
txt = json.dumps(result)
try:
if unreal is not None:
sd = unreal.Paths.project_saved_dir() + "MCP/"
os.makedirs(sd, exist_ok=True)
rid = p.get("__rid") or "last"
with open(sd + "last.json", "w", encoding="utf-8") as f: f.write(txt)
if rid != "last":
with open(sd + f"{rid}.json", "w", encoding="utf-8") as f: f.write(txt)
unreal.log("[MCP-LC] " + txt[:1500])
except Exception:
pass
return txt

776
ue_side/materials.py Normal file
View File

@ -0,0 +1,776 @@
"""Material graph and material instance operations for UEBlueprintMCP.
Ops:
create_material{path}
set_material_settings{asset_path,properties}
inspect_material_settings{asset_path,properties:[...]}
create_material_instance{path,parent?}
read_graph{asset_path}
apply_graph{asset_path,clear_existing?,layout?,save?,nodes:[...],edges:[...],outputs:{...}}
add_node{asset_path,id?,class,position?,properties?}
delete_node{asset_path,guid|name}
move_nodes{asset_path,moves:[{guid|name,x,y}]}
set_node_property{asset_path,guid|name,property,value}
connect{asset_path,from:[guid|name,output],to:[guid|name,input]}
connect_output{asset_path,from:[guid|name,output],property}
layout{asset_path}
recompile_save{asset_path}
set_instance_parent{asset_path,parent}
set_instance_scalar{asset_path,name,value}
set_instance_vector{asset_path,name,value}
set_instance_texture{asset_path,name,value}
list_parameters{asset_path}
open_asset{asset_path}
"""
from __future__ import annotations
import json
import os
import traceback
import graph_layout
try:
import unreal # type: ignore
except ImportError:
unreal = None
MATERIAL_PROPERTY_MAP = {
"basecolor": "MP_BASE_COLOR",
"base_color": "MP_BASE_COLOR",
"metallic": "MP_METALLIC",
"specular": "MP_SPECULAR",
"roughness": "MP_ROUGHNESS",
"emissive": "MP_EMISSIVE_COLOR",
"emissivecolor": "MP_EMISSIVE_COLOR",
"emissive_color": "MP_EMISSIVE_COLOR",
"opacity": "MP_OPACITY",
"opacitymask": "MP_OPACITY_MASK",
"opacity_mask": "MP_OPACITY_MASK",
"normal": "MP_NORMAL",
"worldpositionoffset": "MP_WORLD_POSITION_OFFSET",
"world_position_offset": "MP_WORLD_POSITION_OFFSET",
"ambientocclusion": "MP_AMBIENT_OCCLUSION",
"ambient_occlusion": "MP_AMBIENT_OCCLUSION",
"refraction": "MP_REFRACTION",
"tangent": "MP_TANGENT",
"displacement": "MP_DISPLACEMENT",
"subsurfacecolor": "MP_SUBSURFACE_COLOR",
"subsurface_color": "MP_SUBSURFACE_COLOR",
"customizeduvs0": "MP_CUSTOMIZED_UV_0",
"customizeduv0": "MP_CUSTOMIZED_UV_0",
"customizeduvs1": "MP_CUSTOMIZED_UV_1",
"customizeduv1": "MP_CUSTOMIZED_UV_1",
"customizeduvs2": "MP_CUSTOMIZED_UV_2",
"customizeduv2": "MP_CUSTOMIZED_UV_2",
"customizeduvs3": "MP_CUSTOMIZED_UV_3",
"customizeduv3": "MP_CUSTOMIZED_UV_3",
"customizeduvs4": "MP_CUSTOMIZED_UV_4",
"customizeduv4": "MP_CUSTOMIZED_UV_4",
"customizeduvs5": "MP_CUSTOMIZED_UV_5",
"customizeduv5": "MP_CUSTOMIZED_UV_5",
"customizeduvs6": "MP_CUSTOMIZED_UV_6",
"customizeduv6": "MP_CUSTOMIZED_UV_6",
"customizeduvs7": "MP_CUSTOMIZED_UV_7",
"customizeduv7": "MP_CUSTOMIZED_UV_7",
}
def _asset_tools():
return unreal.AssetToolsHelpers.get_asset_tools()
def _mel():
lib = getattr(unreal, "MaterialEditingLibrary", None)
if lib is None:
raise RuntimeError("unreal.MaterialEditingLibrary is unavailable. Enable the Material Editor scripting support in UE.")
return lib
def _split_pkg(path: str):
if "/" not in path:
raise RuntimeError(f"bad asset path {path!r}")
return path.rsplit("/", 1)
def _load_asset(path: str):
asset = unreal.EditorAssetLibrary.load_asset(path)
if asset is None:
try:
asset = unreal.load_asset(path)
except Exception:
asset = None
if asset is None and "." not in path.rsplit("/", 1)[-1]:
try:
object_path = path + "." + path.rsplit("/", 1)[-1]
asset = unreal.load_asset(object_path)
except Exception:
asset = None
if asset is None:
try:
for selected_asset in unreal.EditorUtilityLibrary.get_selected_assets():
if selected_asset.get_path_name().startswith(path):
asset = selected_asset
break
except Exception:
pass
if asset is None:
raise RuntimeError(f"asset not found: {path}")
return asset
def _load_material(path: str):
asset = _load_asset(path)
if not isinstance(asset, unreal.Material):
raise RuntimeError(f"{path} is not a Material ({type(asset).__name__})")
return asset
def _load_material_function(path: str):
asset = _load_asset(path)
if not isinstance(asset, unreal.MaterialFunction):
raise RuntimeError(f"{path} is not a MaterialFunction ({type(asset).__name__})")
return asset
def _load_material_interface(path: str):
asset = _load_asset(path)
if not isinstance(asset, unreal.MaterialInterface):
raise RuntimeError(f"{path} is not a MaterialInterface ({type(asset).__name__})")
return asset
def _load_expression_class(path_or_name: str):
if not path_or_name:
raise RuntimeError("expression class is required")
candidates = [path_or_name]
if "/" not in path_or_name:
name = path_or_name
if not name.startswith("MaterialExpression"):
candidates.append("MaterialExpression" + name)
candidates.extend([
"/Script/Engine." + name,
"/Script/Engine.MaterialExpression" + name,
])
for candidate in candidates:
cls = unreal.load_class(None, candidate) if "/" in candidate else None
if cls is not None:
return cls
raise RuntimeError(f"material expression class not found: {path_or_name!r}")
def _material_expressions(asset):
lib = getattr(unreal, "MCPMaterialLibrary", None)
if lib is not None:
try:
return list(lib.get_material_expressions(asset) or []), "MCPMaterialLibrary"
except Exception:
pass
for prop in ("expressions", "function_expressions"):
try:
expressions = asset.get_editor_property(prop)
if expressions is not None:
return list(expressions), prop
except Exception:
pass
return [], ""
def _find_expression(asset, ident: str):
if not ident:
raise RuntimeError("node guid/name is required")
lib = getattr(unreal, "MCPMaterialLibrary", None)
if lib is not None:
try:
expr = lib.find_material_expression(asset, ident)
if expr is not None:
return expr
except Exception:
pass
expressions, _ = _material_expressions(asset)
for expr in expressions:
if _expr_guid(expr) == ident or expr.get_name() == ident:
return expr
raise RuntimeError(f"material expression not found: {ident}")
def _connect_material_expressions(asset, from_expr, from_output: str, to_expr, to_input: str) -> dict:
try:
ok = _mel().connect_material_expressions(from_expr, str(from_output or ""), to_expr, str(to_input or ""))
if ok:
return {"ok": True, "method": "MaterialEditingLibrary"}
except Exception as e:
mel_error = str(e)
else:
mel_error = "MaterialEditingLibrary.connect_material_expressions returned false"
lib = getattr(unreal, "MCPMaterialLibrary", None)
if lib is None or not hasattr(lib, "connect_material_expressions_raw"):
return {"ok": False, "error": mel_error}
try:
raw = lib.connect_material_expressions_raw(asset, from_expr, str(from_output or ""), to_expr, str(to_input or ""))
result = json.loads(raw) if isinstance(raw, str) else dict(raw)
if result.get("ok"):
result.setdefault("method", "MCPMaterialLibrary.ConnectMaterialExpressionsRaw")
return result
result.setdefault("error", mel_error)
return result
except Exception as e:
return {"ok": False, "error": f"{mel_error}; raw fallback failed: {e}"}
def _expr_guid(expr) -> str:
try:
guid = expr.get_editor_property("material_expression_editor_x_guid")
if guid:
return str(guid)
except Exception:
pass
try:
return str(expr.get_outer().get_path_name()) + ":" + expr.get_name()
except Exception:
return expr.get_name()
def _expr_pos(expr):
try:
x, y = 0, 0
_mel().get_material_expression_node_position(expr, x, y)
except Exception:
pass
try:
return [int(expr.get_editor_property("material_expression_editor_x")),
int(expr.get_editor_property("material_expression_editor_y"))]
except Exception:
return [0, 0]
def _material_property(name: str):
enum_name = MATERIAL_PROPERTY_MAP.get(str(name).replace(" ", "").lower(), str(name))
try:
return getattr(unreal.MaterialProperty, enum_name)
except Exception:
pass
if not str(enum_name).startswith("MP_"):
try:
return getattr(unreal.MaterialProperty, "MP_" + str(enum_name).upper())
except Exception:
pass
raise RuntimeError(f"unknown material property {name!r}")
def _coerce_value(value, current=None):
# The loosely-typed "value" tool field arrives JSON-stringified; unwrap it.
if isinstance(value, str):
s = value.strip()
if current is not None and isinstance(current, bool):
if s.lower() in ("true", "1"):
return True
if s.lower() in ("false", "0"):
return False
if current is not None and isinstance(current, float):
try:
return float(s)
except Exception:
pass
if current is not None and isinstance(current, int) and not isinstance(current, bool):
try:
return int(float(s))
except Exception:
pass
if s[:1] in ("[", "{"):
try:
import json
return _coerce_value(json.loads(s), current)
except Exception:
pass
if s[:1] == "(" and "=" in s:
import re
low = {k.lower(): float(v) for k, v in re.findall(r"([A-Za-z]+)\s*=\s*(-?[0-9.eE+]+)", s)}
if {"r", "g", "b"}.issubset(low):
return unreal.LinearColor(low["r"], low["g"], low["b"], low.get("a", 1.0))
if {"x", "y", "z"}.issubset(low):
return unreal.Vector(low["x"], low["y"], low["z"])
if {"x", "y"}.issubset(low):
return unreal.Vector2D(low["x"], low["y"])
if current is not None:
enum_class = getattr(unreal, type(current).__name__, None) or getattr(current, "__class__", None)
if enum_class is not None and hasattr(enum_class, s):
return getattr(enum_class, s)
try:
return float(s)
except Exception:
return value
if isinstance(value, dict):
if "asset" in value:
return _load_asset(value["asset"])
low = {str(k).lower(): v for k, v in value.items()}
if {"r", "g", "b"}.issubset(low):
return unreal.LinearColor(float(low["r"]), float(low["g"]), float(low["b"]), float(low.get("a", 1.0)))
if {"x", "y", "z"}.issubset(low):
return unreal.Vector(float(low["x"]), float(low["y"]), float(low["z"]))
if {"x", "y"}.issubset(low):
return unreal.Vector2D(float(low["x"]), float(low["y"]))
if isinstance(value, list):
if len(value) in (3, 4):
if current is not None and type(current).__name__ in ("LinearColor", "Color"):
return unreal.LinearColor(float(value[0]), float(value[1]), float(value[2]), float(value[3]) if len(value) > 3 else 1.0)
if current is not None and type(current).__name__ in ("Vector", "Vector3f"):
return unreal.Vector(float(value[0]), float(value[1]), float(value[2]))
return unreal.LinearColor(float(value[0]), float(value[1]), float(value[2]), float(value[3]) if len(value) > 3 else 1.0)
if len(value) == 2:
return unreal.Vector2D(float(value[0]), float(value[1]))
if isinstance(value, str) and current is not None:
enum_class = getattr(unreal, type(current).__name__, None) or getattr(current, "__class__", None)
if enum_class is not None and hasattr(enum_class, value):
return getattr(enum_class, value)
if isinstance(value, int) and current is not None and hasattr(current, "__class__"):
try:
return current.__class__(value)
except Exception:
pass
return value
def _set_expr_properties(expr, properties: dict):
changed = []
for key, value in (properties or {}).items():
if key.lower() == "inputs" and isinstance(expr, unreal.MaterialExpressionCustom):
custom_inputs = []
for item in value:
input_name = item.get("name") if isinstance(item, dict) else str(item)
custom_input = unreal.CustomInput()
custom_input.set_editor_property("input_name", input_name)
custom_inputs.append(custom_input)
expr.set_editor_property("inputs", custom_inputs)
changed.append(key)
continue
current = None
try:
current = expr.get_editor_property(key)
except Exception:
pass
expr.set_editor_property(key, _coerce_value(value, current))
changed.append(key)
return changed
def _create_expression(asset, spec: dict):
cls = _load_expression_class(spec.get("class") or spec.get("target") or "")
pos = spec.get("position") or [0, 0]
x = int(pos[0]) if len(pos) > 0 else 0
y = int(pos[1]) if len(pos) > 1 else 0
if isinstance(asset, unreal.Material):
expr = _mel().create_material_expression(asset, cls, x, y)
elif isinstance(asset, unreal.MaterialFunction):
expr = _mel().create_material_expression_in_function(asset, cls, x, y)
else:
raise RuntimeError(f"unsupported graph asset type {type(asset).__name__}")
if expr is None:
raise RuntimeError("create_material_expression returned None")
if spec.get("name"):
try:
expr.rename(str(spec["name"]))
except Exception:
pass
_set_expr_properties(expr, spec.get("properties") or spec.get("params") or {})
return expr
def create_material(args: dict) -> dict:
folder, name = _split_pkg(args["path"])
asset = _asset_tools().create_asset(name, folder, unreal.Material, unreal.MaterialFactoryNew())
if asset is None:
return {"ok": False, "error": "create_asset returned None"}
unreal.EditorAssetLibrary.save_loaded_asset(asset)
return {"ok": True, "path": asset.get_path_name().split(".")[0]}
def set_material_settings(args: dict) -> dict:
asset = _load_material(args["asset_path"])
changed = []
for key, value in (args.get("properties") or {}).items():
current = None
try:
current = asset.get_editor_property(key)
except Exception:
pass
asset.set_editor_property(key, _coerce_value(value, current))
changed.append(key)
_update_asset(asset)
unreal.EditorAssetLibrary.save_loaded_asset(asset)
return {"ok": True, "changed": changed}
def inspect_material_settings(args: dict) -> dict:
asset = _load_material(args["asset_path"])
result = {"ok": True, "properties": {}}
for key in args.get("properties") or []:
value = asset.get_editor_property(key)
result["properties"][key] = {
"type": type(value).__name__,
"repr": repr(value),
"str": str(value),
"members": list(getattr(value.__class__, "__members__", {}).keys()),
"unreal_class": repr(getattr(unreal, type(value).__name__, None)),
"class_dir": [n for n in dir(value.__class__) if n.startswith("BL_") or n.startswith("MD_")],
}
return result
def create_material_instance(args: dict) -> dict:
folder, name = _split_pkg(args["path"])
asset = _asset_tools().create_asset(name, folder, unreal.MaterialInstanceConstant, unreal.MaterialInstanceConstantFactoryNew())
if asset is None:
return {"ok": False, "error": "create_asset returned None"}
parent = args.get("parent")
if parent:
_mel().set_material_instance_parent(asset, _load_material_interface(parent))
unreal.EditorAssetLibrary.save_loaded_asset(asset)
return {"ok": True, "path": asset.get_path_name().split(".")[0], "parent": parent or None}
def read_graph(args: dict) -> dict:
asset_path = args["asset_path"]
asset = _load_asset(asset_path)
lib = getattr(unreal, "MCPMaterialLibrary", None)
if lib is not None:
try:
return json.loads(lib.dump_material_graph_to_json(asset))
except Exception:
pass
expressions, _ = _material_expressions(asset)
nodes = []
edges = []
for expr in expressions:
node = {
"guid": _expr_guid(expr),
"name": expr.get_name(),
"class": expr.get_class().get_path_name(),
"position": _expr_pos(expr),
"inputs": [],
}
try:
node["input_names"] = list(_mel().get_material_expression_input_names(expr))
except Exception:
node["input_names"] = []
nodes.append(node)
if isinstance(asset, unreal.Material):
try:
inputs = list(_mel().get_inputs_for_material_expression(asset, expr))
for input_expr in inputs:
out_name = ""
try:
out_name = str(_mel().get_input_node_output_name_for_material_expression(expr, input_expr))
except Exception:
pass
edges.append({
"from": [_expr_guid(input_expr), out_name],
"to": [_expr_guid(expr), ""],
})
except Exception:
pass
outputs = {}
if isinstance(asset, unreal.Material):
for pretty, enum_name in MATERIAL_PROPERTY_MAP.items():
if pretty != pretty.replace("_", ""):
continue
try:
prop = getattr(unreal.MaterialProperty, enum_name)
input_node = _mel().get_material_property_input_node(asset, prop)
if input_node is not None:
outputs[pretty] = {
"from": [_expr_guid(input_node), str(_mel().get_material_property_input_node_output_name(asset, prop))],
}
except Exception:
pass
return {"ok": True, "asset_path": asset_path, "nodes": nodes, "edges": edges, "outputs": outputs}
def apply_graph(args: dict) -> dict:
asset_path = args["asset_path"]
asset = _load_asset(asset_path)
if not isinstance(asset, (unreal.Material, unreal.MaterialFunction)):
return {"ok": False, "error": f"{asset_path} is not a Material or MaterialFunction"}
result = {"ok": False, "asset_path": asset_path, "spawned": {}, "errors": [], "warnings": []}
if args.get("clear_existing"):
if isinstance(asset, unreal.Material):
_mel().delete_all_material_expressions(asset)
else:
_mel().delete_all_material_expressions_in_function(asset)
local_map = {}
node_specs = graph_layout.normalize_specs(
args.get("nodes") or [],
args.get("edges") or [],
domain="material",
) if args.get("auto_layout", True) else list(args.get("nodes") or [])
for spec in node_specs:
try:
expr = _create_expression(asset, spec)
local_id = spec.get("id") or expr.get_name()
guid = _expr_guid(expr)
local_map[local_id] = expr
result["spawned"][local_id] = {"guid": guid, "name": expr.get_name(), "class": expr.get_class().get_path_name()}
except Exception as e:
result["errors"].append(f"node {spec.get('id') or spec.get('class')}: {e}")
def resolve_node(ref):
if ref in local_map:
return local_map[ref]
return _find_expression(asset, ref)
for edge in args.get("edges") or []:
try:
from_ref, from_output = edge["from"]
to_ref, to_input = edge["to"]
connect_result = _connect_material_expressions(asset, resolve_node(from_ref), str(from_output or ""), resolve_node(to_ref), str(to_input or ""))
if not connect_result.get("ok"):
result["warnings"].append(f"connect returned false: {edge}: {connect_result.get('error') or connect_result}")
except Exception as e:
result["errors"].append(f"edge {edge}: {e}")
if isinstance(asset, unreal.Material):
for prop_name, out_ref in (args.get("outputs") or {}).items():
try:
if isinstance(out_ref, dict):
out_ref = out_ref.get("from")
from_ref, from_output = out_ref
ok = _mel().connect_material_property(resolve_node(from_ref), str(from_output or ""), _material_property(prop_name))
if not ok:
result["warnings"].append(f"connect output returned false: {prop_name}")
except Exception as e:
result["errors"].append(f"output {prop_name}: {e}")
if args.get("layout", False):
_layout_asset(asset)
_update_asset(asset)
if args.get("save", True):
unreal.EditorAssetLibrary.save_loaded_asset(asset)
result["ok"] = not result["errors"]
return result
def add_node(args: dict) -> dict:
asset = _load_asset(args["asset_path"])
expr = _create_expression(asset, args)
_update_asset(asset)
unreal.EditorAssetLibrary.save_loaded_asset(asset)
return {"ok": True, "guid": _expr_guid(expr), "name": expr.get_name(), "class": expr.get_class().get_path_name()}
def delete_node(args: dict) -> dict:
asset = _load_asset(args["asset_path"])
expr = _find_expression(asset, args.get("guid") or args.get("name"))
if isinstance(asset, unreal.Material):
_mel().delete_material_expression(asset, expr)
elif isinstance(asset, unreal.MaterialFunction):
_mel().delete_material_expression_in_function(asset, expr)
else:
raise RuntimeError("asset is not Material or MaterialFunction")
_update_asset(asset)
unreal.EditorAssetLibrary.save_loaded_asset(asset)
return {"ok": True}
def move_nodes(args: dict) -> dict:
asset = _load_asset(args["asset_path"])
moved = []
for move in args.get("moves") or []:
expr = _find_expression(asset, move.get("guid") or move.get("name"))
expr.set_editor_property("material_expression_editor_x", int(move["x"]))
expr.set_editor_property("material_expression_editor_y", int(move["y"]))
moved.append(_expr_guid(expr))
_update_asset(asset)
unreal.EditorAssetLibrary.save_loaded_asset(asset)
return {"ok": True, "moved": moved}
def set_node_property(args: dict) -> dict:
asset = _load_asset(args["asset_path"])
expr = _find_expression(asset, args.get("guid") or args.get("name"))
changed = _set_expr_properties(expr, {args["property"]: args.get("value")})
_update_asset(asset)
unreal.EditorAssetLibrary.save_loaded_asset(asset)
return {"ok": True, "changed": changed}
def connect(args: dict) -> dict:
asset = _load_asset(args["asset_path"])
from_ref, from_output = args["from"]
to_ref, to_input = args["to"]
result = _connect_material_expressions(
asset,
_find_expression(asset, from_ref),
str(from_output or ""),
_find_expression(asset, to_ref),
str(to_input or ""),
)
_update_asset(asset)
unreal.EditorAssetLibrary.save_loaded_asset(asset)
return result
def connect_output(args: dict) -> dict:
asset = _load_material(args["asset_path"])
from_ref, from_output = args["from"]
ok = _mel().connect_material_property(_find_expression(asset, from_ref), str(from_output or ""), _material_property(args["property"]))
_update_asset(asset)
unreal.EditorAssetLibrary.save_loaded_asset(asset)
return {"ok": bool(ok)}
def layout(args: dict) -> dict:
asset = _load_asset(args["asset_path"])
_layout_asset(asset)
unreal.EditorAssetLibrary.save_loaded_asset(asset)
return {"ok": True}
def tidy_graph(args: dict) -> dict:
asset = _load_asset(args["asset_path"])
graph = read_graph({"asset_path": args["asset_path"]})
if not graph.get("ok"):
return graph
moves = graph_layout.tidy_existing_nodes(graph.get("nodes") or [], graph.get("edges") or [], "material")
moved = []
for move in moves:
expr = _find_expression(asset, move["guid"])
expr.set_editor_property("material_expression_editor_x", int(move["x"]))
expr.set_editor_property("material_expression_editor_y", int(move["y"]))
moved.append(move)
_update_asset(asset)
unreal.EditorAssetLibrary.save_loaded_asset(asset)
return {"ok": True, "moved": moved, "count": len(moved)}
def recompile_save(args: dict) -> dict:
asset = _load_asset(args["asset_path"])
_update_asset(asset)
unreal.EditorAssetLibrary.save_loaded_asset(asset)
return {"ok": True}
def set_instance_parent(args: dict) -> dict:
asset = _load_asset(args["asset_path"])
if not isinstance(asset, unreal.MaterialInstanceConstant):
return {"ok": False, "error": "asset is not MaterialInstanceConstant"}
_mel().set_material_instance_parent(asset, _load_material_interface(args["parent"]))
unreal.EditorAssetLibrary.save_loaded_asset(asset)
return {"ok": True}
def set_instance_scalar(args: dict) -> dict:
asset = _load_asset(args["asset_path"])
ok = _mel().set_material_instance_scalar_parameter_value(asset, str(args["name"]), float(args["value"]))
unreal.EditorAssetLibrary.save_loaded_asset(asset)
return {"ok": bool(ok)}
def set_instance_vector(args: dict) -> dict:
asset = _load_asset(args["asset_path"])
ok = _mel().set_material_instance_vector_parameter_value(asset, str(args["name"]), _coerce_value(args["value"]))
unreal.EditorAssetLibrary.save_loaded_asset(asset)
return {"ok": bool(ok)}
def set_instance_texture(args: dict) -> dict:
asset = _load_asset(args["asset_path"])
ok = _mel().set_material_instance_texture_parameter_value(asset, str(args["name"]), _load_asset(args["value"]))
unreal.EditorAssetLibrary.save_loaded_asset(asset)
return {"ok": bool(ok)}
def list_parameters(args: dict) -> dict:
asset = _load_material_interface(args["asset_path"])
out = {"ok": True, "asset_path": args["asset_path"], "scalar": [], "vector": [], "texture": [], "static_switch": []}
for key, fn_name in (
("scalar", "get_scalar_parameter_names"),
("vector", "get_vector_parameter_names"),
("texture", "get_texture_parameter_names"),
("static_switch", "get_static_switch_parameter_names"),
):
try:
out[key] = [str(x) for x in getattr(_mel(), fn_name)(asset)]
except Exception as e:
out[key + "_error"] = str(e)
return out
def open_asset(args: dict) -> dict:
asset = _load_asset(args["asset_path"])
ok = unreal.AssetEditorSubsystem().open_editor_for_assets([asset])
return {"ok": bool(ok), "asset_path": args["asset_path"]}
def _layout_asset(asset):
if isinstance(asset, unreal.Material):
_mel().layout_material_expressions(asset)
elif isinstance(asset, unreal.MaterialFunction):
_mel().layout_material_function_expressions(asset)
def _update_asset(asset):
if isinstance(asset, unreal.Material):
_mel().recompile_material(asset)
elif isinstance(asset, unreal.MaterialFunction):
_mel().update_material_function(asset, None)
elif isinstance(asset, unreal.MaterialInstanceConstant):
_mel().update_material_instance(asset)
OPS = {
"create_material": create_material,
"set_material_settings": set_material_settings,
"inspect_material_settings": inspect_material_settings,
"create_material_instance": create_material_instance,
"read_graph": read_graph,
"apply_graph": apply_graph,
"add_node": add_node,
"delete_node": delete_node,
"move_nodes": move_nodes,
"set_node_property": set_node_property,
"connect": connect,
"connect_output": connect_output,
"layout": layout,
"tidy_graph": tidy_graph,
"recompile_save": recompile_save,
"set_instance_parent": set_instance_parent,
"set_instance_scalar": set_instance_scalar,
"set_instance_vector": set_instance_vector,
"set_instance_texture": set_instance_texture,
"list_parameters": list_parameters,
"open_asset": open_asset,
}
def entrypoint(payload_json: str) -> str:
try:
p = json.loads(payload_json)
except Exception as e:
return _tee({"ok": False, "error": f"bad json: {e}"}, {})
fn = OPS.get(p.get("op"))
if not fn:
return _tee({"ok": False, "error": f"unknown op {p.get('op')!r}", "available": list(OPS)}, p)
try:
return _tee(fn(p), p)
except Exception as e:
return _tee({"ok": False, "error": str(e), "traceback": traceback.format_exc()}, p)
def _tee(result: dict, p: dict) -> str:
txt = json.dumps(result)
try:
if unreal is not None:
sd = unreal.Paths.project_saved_dir() + "MCP/"
os.makedirs(sd, exist_ok=True)
rid = p.get("__rid") or "last"
with open(sd + "last.json", "w", encoding="utf-8") as f:
f.write(txt)
if rid != "last":
with open(sd + f"{rid}.json", "w", encoding="utf-8") as f:
f.write(txt)
unreal.log("[MCP-MATERIALS] " + txt[:1500])
except Exception:
pass
return txt

361
ue_side/mesh.py Normal file
View File

@ -0,0 +1,361 @@
"""Static / Skeletal Mesh ops — info, LODs, sockets, collision.
Ops:
static_info {path} — LODs, materials, collision, sockets, bounds, vertex/tri count
skeletal_info {path} — LODs, bones, sockets, skeleton, physics asset
list_sockets {path} — works for both static and skeletal
add_socket {path, name, bone?, location?, rotation?, scale?}
remove_socket {path, name}
list_lods {path}
set_lod_screen_size {path, lod_index, screen_size}
remove_lod {path, lod_index}
set_collision_complexity{path, complexity:'Default|UseSimpleAsComplex|UseComplexAsSimple'}
add_simple_collision {path, shape:'Box|Sphere|Capsule', extents_or_radius}
"""
from __future__ import annotations
import json
import os
import traceback
try:
import unreal # type: ignore
except ImportError:
unreal = None
def _load(p): return unreal.EditorAssetLibrary.load_asset(p)
def _vec(v, d=(0, 0, 0)):
if v is None: v = d
return unreal.Vector(float(v[0]), float(v[1]), float(v[2]))
def _rot(v, d=(0, 0, 0)):
if v is None: v = d
return unreal.Rotator(float(v[0]), float(v[1]), float(v[2]))
def static_info(args):
m = _load(args["path"])
if m is None or not isinstance(m, unreal.StaticMesh):
return {"ok": False, "error": "not a StaticMesh"}
out = {"ok": True, "path": args["path"]}
# UE 5.4+ moved methods from EditorStaticMeshLibrary to StaticMeshEditorSubsystem.
smes = unreal.get_editor_subsystem(unreal.StaticMeshEditorSubsystem) if hasattr(unreal, "StaticMeshEditorSubsystem") else None
legacy = unreal.EditorStaticMeshLibrary if hasattr(unreal, "EditorStaticMeshLibrary") else None
def _call(*sources, **kw):
method = kw["method"]; args_ = kw.get("args", ())
for src in sources:
if src is None: continue
fn = getattr(src, method, None)
if fn:
try: return fn(m, *args_)
except Exception: pass
return None
try:
v = _call(smes, legacy, method="get_lod_count")
if v is not None: out["lod_count"] = v
v = _call(smes, legacy, method="get_number_verts", args=(0,))
if v is not None: out["vertex_count"] = v
v = _call(smes, legacy, method="get_number_triangles", args=(0,))
if v is not None: out["triangle_count"] = v
slots = _call(smes, legacy, method="get_lod_material_slot_names", args=(0,))
if slots: out["material_slots"] = [str(n) for n in slots]
b = _call(smes, legacy, method="get_bounds")
if b is not None:
try: out["bounds"] = list(b.box_extent)
except Exception: pass
except Exception as e:
out["info_error"] = str(e)
try:
smes = unreal.get_editor_subsystem(unreal.StaticMeshEditorSubsystem) if hasattr(unreal, "StaticMeshEditorSubsystem") else None
out["sockets"] = [str(n) for n in (smes.get_sockets(m) if smes else [])]
except Exception:
out["sockets"] = []
try:
out["materials"] = [{"slot": str(s.material_slot_name),
"material": s.material_interface.get_path_name() if s.material_interface else None}
for s in (m.get_editor_property("static_materials") or [])]
except Exception:
pass
return out
def skeletal_info(args):
m = _load(args["path"])
if m is None:
return {"ok": False, "error": "not found"}
out = {"ok": True, "path": args["path"], "class": m.get_class().get_name()}
try:
sk = m.get_editor_property("skeleton")
out["skeleton"] = sk.get_path_name().split(".")[0] if sk else None
except Exception:
pass
try:
pa = m.get_editor_property("physics_asset")
out["physics_asset"] = pa.get_path_name().split(".")[0] if pa else None
except Exception:
pass
try:
ssel = unreal.EditorSkeletalMeshLibrary if hasattr(unreal, "EditorSkeletalMeshLibrary") else None
if ssel:
out["lod_count"] = ssel.get_lod_count(m)
except Exception:
pass
try:
out["sockets"] = [str(s.socket_name) for s in (m.get_editor_property("sockets") or [])]
except Exception:
out["sockets"] = []
try:
mats = m.get_editor_property("materials") or []
out["materials"] = [(str(x.material_slot_name), x.material_interface.get_path_name() if x.material_interface else None) for x in mats]
except Exception:
pass
return out
def list_sockets(args):
m = _load(args["path"])
if m is None:
return {"ok": False, "error": "not found"}
if isinstance(m, unreal.StaticMesh):
out = []
# Sockets array is protected — use StaticMeshEditorSubsystem or find_socket fallback.
names = []
smes = unreal.get_editor_subsystem(unreal.StaticMeshEditorSubsystem) if hasattr(unreal, "StaticMeshEditorSubsystem") else None
if smes and hasattr(smes, "get_sockets"):
try: names = [str(n) for n in (smes.get_sockets(m) or [])]
except Exception: names = []
for n in names:
try:
s = m.find_socket(n)
if s:
out.append({"name": n,
"location": list(s.get_editor_property("relative_location")),
"rotation": list(s.get_editor_property("relative_rotation"))})
except Exception:
out.append({"name": n})
else:
out = []
for s in (m.get_editor_property("sockets") or []):
try:
out.append({"name": str(s.get_editor_property("socket_name")),
"bone": str(s.get_editor_property("bone_name")),
"location": list(s.get_editor_property("relative_location")),
"rotation": list(s.get_editor_property("relative_rotation"))})
except Exception:
pass
return {"ok": True, "sockets": out}
def add_socket(args):
m = _load(args["path"])
if m is None:
return {"ok": False, "error": "not found"}
if isinstance(m, unreal.StaticMesh):
# The Sockets array is a protected UPROPERTY in Python — fall back to
# StaticMeshEditorSubsystem which exposes set_socket / set_sockets
# depending on UE version. If neither is exposed, instruct user to use UI.
smes = unreal.get_editor_subsystem(unreal.StaticMeshEditorSubsystem) if hasattr(unreal, "StaticMeshEditorSubsystem") else None
sock = unreal.StaticMeshSocket()
sock.set_editor_property("socket_name", args["name"])
sock.set_editor_property("relative_location", _vec(args.get("location")))
sock.set_editor_property("relative_rotation", _rot(args.get("rotation")))
sock.set_editor_property("relative_scale", _vec(args.get("scale"), (1, 1, 1)))
added = False
if smes:
for fn_name in ("set_socket", "add_socket"):
fn = getattr(smes, fn_name, None)
if fn:
try: fn(m, sock); added = True; break
except Exception: pass
if not added:
return {"ok": False, "error": "StaticMesh sockets array is protected; StaticMeshEditorSubsystem has no set_socket/add_socket on this build. Use Static Mesh Editor UI."}
else:
sock = unreal.SkeletalMeshSocket()
sock.set_editor_property("socket_name", args["name"])
sock.set_editor_property("bone_name", args.get("bone") or "")
sock.set_editor_property("relative_location", _vec(args.get("location")))
sock.set_editor_property("relative_rotation", _rot(args.get("rotation")))
sockets = list(m.get_editor_property("sockets") or [])
sockets.append(sock)
m.set_editor_property("sockets", sockets)
unreal.EditorAssetLibrary.save_asset(m.get_path_name())
return {"ok": True}
def remove_socket(args):
m = _load(args["path"])
if m is None:
return {"ok": False, "error": "not found"}
target = args["name"]
if isinstance(m, unreal.StaticMesh):
smes = unreal.get_editor_subsystem(unreal.StaticMeshEditorSubsystem) if hasattr(unreal, "StaticMeshEditorSubsystem") else None
removed = False
if smes:
for fn_name in ("remove_socket",):
fn = getattr(smes, fn_name, None)
if fn:
try: fn(m, target); removed = True; break
except Exception: pass
if not removed:
return {"ok": False, "error": "StaticMesh sockets array is protected; no remove_socket on subsystem in this build. Use UI."}
else:
m.set_editor_property("sockets",
[s for s in (m.get_editor_property("sockets") or [])
if str(s.get_editor_property("socket_name")) != target])
unreal.EditorAssetLibrary.save_asset(m.get_path_name())
return {"ok": True}
def list_lods(args):
m = _load(args["path"])
if m is None:
return {"ok": False, "error": "not found"}
out = []
if isinstance(m, unreal.StaticMesh):
smes = unreal.get_editor_subsystem(unreal.StaticMeshEditorSubsystem) if hasattr(unreal, "StaticMeshEditorSubsystem") else None
legacy = unreal.EditorStaticMeshLibrary if hasattr(unreal, "EditorStaticMeshLibrary") else None
def _c(method, *a):
for src in (smes, legacy):
if src is None: continue
fn = getattr(src, method, None)
if fn:
try: return fn(m, *a)
except Exception: pass
return None
n = _c("get_lod_count") or 0
for i in range(n):
lod = {"index": i}
ss = _c("get_lod_screen_size", i)
if ss is not None: lod["screen_size"] = ss
tr = _c("get_number_triangles", i)
if tr is not None: lod["tris"] = tr
out.append(lod)
else:
ssel = unreal.EditorSkeletalMeshLibrary if hasattr(unreal, "EditorSkeletalMeshLibrary") else None
sses = unreal.get_editor_subsystem(unreal.SkeletalMeshEditorSubsystem) if hasattr(unreal, "SkeletalMeshEditorSubsystem") else None
n = 0
for src in (sses, ssel):
if src and hasattr(src, "get_lod_count"):
try: n = src.get_lod_count(m); break
except Exception: pass
for i in range(n):
out.append({"index": i})
return {"ok": True, "lods": out}
def set_lod_screen_size(args):
m = _load(args["path"])
if m is None or not isinstance(m, unreal.StaticMesh):
return {"ok": False, "error": "static mesh only"}
smel = unreal.EditorStaticMeshLibrary
try:
smel.set_lod_reduction_settings if False else None
# No direct setter; use BuildSettings via set_lod_screen_size if exposed.
if hasattr(smel, "set_lod_screen_size"):
smel.set_lod_screen_size(m, int(args["lod_index"]), float(args["screen_size"]))
unreal.EditorAssetLibrary.save_asset(m.get_path_name())
return {"ok": True}
return {"ok": False, "error": "set_lod_screen_size not exposed in this UE build"}
except Exception as e:
return {"ok": False, "error": str(e)}
def remove_lod(args):
m = _load(args["path"])
if m is None or not isinstance(m, unreal.StaticMesh):
return {"ok": False, "error": "static mesh only"}
try:
unreal.EditorStaticMeshLibrary.remove_lods(m)
return {"ok": True, "note": "all generated LODs removed (UE API limitation)"}
except Exception as e:
return {"ok": False, "error": str(e)}
def set_collision_complexity(args):
m = _load(args["path"])
if m is None or not isinstance(m, unreal.StaticMesh):
return {"ok": False, "error": "static mesh only"}
mode = args["complexity"]
enum_map = {
"Default": unreal.CollisionTraceFlag.CTF_USE_DEFAULT,
"UseSimpleAsComplex": unreal.CollisionTraceFlag.CTF_USE_SIMPLE_AS_COMPLEX,
"UseComplexAsSimple": unreal.CollisionTraceFlag.CTF_USE_COMPLEX_AS_SIMPLE,
}
if mode not in enum_map:
return {"ok": False, "error": f"unknown mode; valid: {list(enum_map)}"}
try:
bs = m.get_editor_property("body_setup")
if bs:
bs.set_editor_property("collision_trace_flag", enum_map[mode])
unreal.EditorAssetLibrary.save_asset(m.get_path_name())
return {"ok": True}
return {"ok": False, "error": "body_setup missing"}
except Exception as e:
return {"ok": False, "error": str(e)}
def add_simple_collision(args):
m = _load(args["path"])
if m is None or not isinstance(m, unreal.StaticMesh):
return {"ok": False, "error": "static mesh only"}
smel = unreal.EditorStaticMeshLibrary
shape = args["shape"]
try:
if shape == "Box":
smel.add_simple_collisions(m, unreal.ScriptingCollisionShapeType.BOX)
elif shape == "Sphere":
smel.add_simple_collisions(m, unreal.ScriptingCollisionShapeType.SPHERE)
elif shape == "Capsule":
smel.add_simple_collisions(m, unreal.ScriptingCollisionShapeType.CAPSULE)
else:
return {"ok": False, "error": "shape must be Box|Sphere|Capsule"}
unreal.EditorAssetLibrary.save_asset(m.get_path_name())
return {"ok": True}
except Exception as e:
return {"ok": False, "error": str(e)}
OPS = {
"static_info": static_info,
"skeletal_info": skeletal_info,
"list_sockets": list_sockets,
"add_socket": add_socket,
"remove_socket": remove_socket,
"list_lods": list_lods,
"set_lod_screen_size": set_lod_screen_size,
"remove_lod": remove_lod,
"set_collision_complexity": set_collision_complexity,
"add_simple_collision": add_simple_collision,
}
def entrypoint(payload_json):
try:
p = json.loads(payload_json)
except Exception as e:
return _tee({"ok": False, "error": f"bad json: {e}"}, {})
op = p.get("op"); fn = OPS.get(op)
if not fn:
return _tee({"ok": False, "error": f"unknown op {op!r}", "available": list(OPS)}, p)
try:
return _tee(fn(p), p)
except Exception as e:
return _tee({"ok": False, "error": str(e), "traceback": traceback.format_exc()}, p)
def _tee(result, p):
txt = json.dumps(result)
try:
if unreal is not None:
sd = unreal.Paths.project_saved_dir() + "MCP/"
os.makedirs(sd, exist_ok=True)
rid = p.get("__rid") or "last"
with open(sd + "last.json", "w", encoding="utf-8") as f: f.write(txt)
if rid != "last":
with open(sd + f"{rid}.json", "w", encoding="utf-8") as f: f.write(txt)
unreal.log("[MCP-MESH] " + txt[:1500])
except Exception:
pass
return txt

302
ue_side/network.py Normal file
View File

@ -0,0 +1,302 @@
"""Network ops for Blueprint assets.
Ops:
audit_bp {bp_path}
set_actor_flags {bp_path, replicates?, replicate_movement?, net_load_on_client?,
always_relevant?, only_relevant_to_owner?, use_owner_relevancy?,
net_cull_distance_squared?, net_update_frequency?,
min_net_update_frequency?, net_priority?, net_dormancy?}
set_component_flags {bp_path, component, replicated?, net_addressable?}
set_custom_event_rpc {bp_path, function?, event?, guid?, mode, reliable?}
apply_profile {bp_path, profile}
RPC modes for set_custom_event_rpc:
none | server | client | multicast
"""
from __future__ import annotations
import json
import os
import traceback
try:
import unreal # type: ignore
except ImportError:
unreal = None
ACTOR_PROP_MAP = {
"replicates": "replicates",
"replicate_movement": "replicate_movement",
"net_load_on_client": "net_load_on_client",
"always_relevant": "always_relevant",
"only_relevant_to_owner": "only_relevant_to_owner",
"use_owner_relevancy": "net_use_owner_relevancy",
"net_cull_distance_squared": "net_cull_distance_squared",
"net_update_frequency": "net_update_frequency",
"min_net_update_frequency": "min_net_update_frequency",
"net_priority": "net_priority",
"net_dormancy": "net_dormancy",
}
def _load_bp(path: str):
bp = unreal.EditorAssetLibrary.load_asset(path)
if not isinstance(bp, unreal.Blueprint):
raise RuntimeError(f"not a Blueprint: {path!r}")
return bp
def _generated_class(bp_path: str):
cls_path = f"{bp_path}.{bp_path.rsplit('/', 1)[-1]}_C"
cls = unreal.load_class(None, cls_path)
if cls is None:
raise RuntimeError(f"generated class not found: {cls_path}")
return cls
def _cdo(bp_path: str):
return unreal.get_default_object(_generated_class(bp_path))
def _lib():
L = getattr(unreal, "MCPGraphLibrary", None)
if L is None:
raise RuntimeError("MCPGraphLibrary missing - rebuild C++ plugin")
return L
def _find_graph(bp, name: str):
L = _lib()
if name in (None, "", "EventGraph"):
return L.find_event_graph(bp)
return L.find_function_graph(bp, name)
def _get_prop(obj, prop):
try:
return obj.get_editor_property(prop)
except Exception:
return getattr(obj, prop)
def _set_prop(obj, prop, value):
try:
obj.set_editor_property(prop, value)
except Exception:
setattr(obj, prop, value)
def _save_compile(bp):
L = getattr(unreal, "MCPGraphLibrary", None)
messages = []
try:
if L:
messages = list(L.compile_with_messages(bp))
else:
unreal.KismetEditorUtilities.compile_blueprint(bp)
except Exception as e:
messages = [f"WARN||compile failed from network.py: {e}"]
unreal.EditorAssetLibrary.save_loaded_asset(bp)
return messages
def _read_actor_flags(cdo):
out = {}
for public_name, prop in ACTOR_PROP_MAP.items():
try:
v = _get_prop(cdo, prop)
out[public_name] = str(v) if public_name == "net_dormancy" else v
except Exception as e:
out[public_name] = {"error": str(e)}
return out
def audit_bp(args: dict) -> dict:
bp_path = args["bp_path"]
cdo = _cdo(bp_path)
result = {
"ok": True,
"bp_path": bp_path,
"actor": _read_actor_flags(cdo),
"components": [],
"custom_events": [],
"notes": [],
}
for name in args.get("components") or []:
item = {"name": name}
try:
comp = _get_prop(cdo, name)
item["class"] = comp.get_class().get_name()
for prop in ("replicates", "is_replicated", "net_addressable"):
try:
item[prop] = _get_prop(comp, prop)
except Exception:
pass
except Exception as e:
item["error"] = str(e)
result["components"].append(item)
try:
import apply_graph
raw = apply_graph.read_graph(bp_path, args.get("function") or "EventGraph")
for node in raw.get("graph", {}).get("nodes", []):
if node.get("class") == "K2Node_CustomEvent":
result["custom_events"].append({
"guid": node.get("guid"),
"title": node.get("title"),
"target": node.get("target"),
})
result["notes"].append("Custom event RPC flags require set_custom_event_rpc; read_graph does not expose them.")
except Exception as e:
result["notes"].append(f"custom event scan failed: {e}")
return result
def set_actor_flags(args: dict) -> dict:
bp_path = args["bp_path"]
bp = _load_bp(bp_path)
cdo = _cdo(bp_path)
changed = {}
errors = {}
for public_name, prop in ACTOR_PROP_MAP.items():
if public_name not in args:
continue
try:
_set_prop(cdo, prop, args[public_name])
changed[public_name] = args[public_name]
except Exception as e:
errors[public_name] = str(e)
messages = _save_compile(bp)
return {"ok": not errors, "bp_path": bp_path, "changed": changed, "errors": errors, "compile_messages": messages}
def _component_from_cdo(cdo, name: str):
try:
return _get_prop(cdo, name)
except Exception:
pass
getter = getattr(cdo, "get_component_by_class", None)
if getter:
# Fallback is intentionally conservative: caller should pass the BP component variable name.
return None
return None
def set_component_flags(args: dict) -> dict:
bp_path = args["bp_path"]
bp = _load_bp(bp_path)
cdo = _cdo(bp_path)
comp_name = args["component"]
comp = _component_from_cdo(cdo, comp_name)
if comp is None:
return {"ok": False, "error": f"component not found on CDO by name: {comp_name}"}
changed = {}
errors = {}
for public_name, candidates in {
"replicated": ("replicates", "is_replicated"),
"net_addressable": ("net_addressable",),
}.items():
if public_name not in args:
continue
done = False
for prop in candidates:
try:
_set_prop(comp, prop, args[public_name])
changed[public_name] = args[public_name]
done = True
break
except Exception:
continue
if not done:
errors[public_name] = f"no writable property among {candidates}"
messages = _save_compile(bp)
return {"ok": not errors, "bp_path": bp_path, "component": comp_name, "changed": changed, "errors": errors, "compile_messages": messages}
def set_custom_event_rpc(args: dict) -> dict:
bp_path = args["bp_path"]
bp = _load_bp(bp_path)
graph = _find_graph(bp, args.get("function") or "EventGraph")
if graph is None:
return {"ok": False, "error": "graph not found"}
target = args.get("guid") or args.get("event")
if not target:
return {"ok": False, "error": "set_custom_event_rpc requires guid or event"}
mode = args.get("mode") or "none"
reliable = bool(args.get("reliable", False))
L = _lib()
setter = getattr(L, "set_custom_event_network_flags", None)
if setter is None:
return {
"ok": False,
"error": "MCPGraphLibrary.set_custom_event_network_flags is unavailable. Rebuild/restart UE after the plugin C++ update.",
"requested": {"target": target, "mode": mode, "reliable": reliable},
}
ok = bool(setter(bp, graph, target, mode, reliable))
messages = _save_compile(bp) if ok else []
return {"ok": ok, "bp_path": bp_path, "target": target, "mode": mode, "reliable": reliable, "compile_messages": messages}
def apply_profile(args: dict) -> dict:
profile = args["profile"]
bp_path = args["bp_path"]
if profile == "replicated_world_actor":
return set_actor_flags({
"bp_path": bp_path,
"replicates": True,
"net_load_on_client": True,
"replicate_movement": bool(args.get("replicate_movement", False)),
})
if profile == "always_relevant_world_actor":
return set_actor_flags({
"bp_path": bp_path,
"replicates": True,
"net_load_on_client": True,
"always_relevant": True,
"replicate_movement": bool(args.get("replicate_movement", False)),
})
return {"ok": False, "error": f"unknown profile {profile!r}", "available": ["replicated_world_actor", "always_relevant_world_actor"]}
OPS = {
"audit_bp": audit_bp,
"set_actor_flags": set_actor_flags,
"set_component_flags": set_component_flags,
"set_custom_event_rpc": set_custom_event_rpc,
"apply_profile": apply_profile,
}
def entrypoint(payload_json: str) -> str:
try:
p = json.loads(payload_json)
except Exception as e:
return _tee({"ok": False, "error": f"bad json: {e}"}, {})
fn = OPS.get(p.get("op"))
if not fn:
return _tee({"ok": False, "error": f"unknown op {p.get('op')!r}", "available": list(OPS)}, p)
try:
return _tee(fn(p), p)
except Exception as e:
return _tee({"ok": False, "error": str(e), "traceback": traceback.format_exc()}, p)
def _tee(result: dict, p: dict) -> str:
txt = json.dumps(result)
try:
if unreal is not None:
sd = unreal.Paths.project_saved_dir() + "MCP/"
os.makedirs(sd, exist_ok=True)
rid = p.get("__rid") or "last"
with open(sd + f"{rid}.json", "w", encoding="utf-8") as f:
f.write(txt)
unreal.log("[MCP-NETWORK] " + txt[:1500])
except Exception:
pass
return txt

271
ue_side/niagara.py Normal file
View File

@ -0,0 +1,271 @@
"""Niagara authoring ops. Bridges to unreal.MCPNiagaraLibrary (C++) where
editor-only APIs are required, falls back to pure Python where possible.
Ops:
create_system {path}
create_emitter_asset {path, template_emitter?} — create empty UNiagaraEmitter asset
add_emitter {system, emitter_asset, name?} — add emitter to system, returns handle name
remove_emitter {system, emitter_name}
list_emitters {system}
list_modules {system, emitter_name?, group} — group: EmitterSpawn|EmitterUpdate|ParticleSpawn|ParticleUpdate|SystemSpawn|SystemUpdate
add_module {system, emitter_name?, group, module_script}
remove_module {system, emitter_name?, group, module_name}
add_renderer {system, emitter_name, renderer_class} — e.g. NiagaraSpriteRendererProperties
remove_renderer {system, emitter_name, renderer_class}
compile {system}
set_module_input STUB — see NOTES at bottom.
"""
from __future__ import annotations
import json
import os
import traceback
try:
import unreal # type: ignore
except ImportError:
unreal = None
# ---------- loaders ----------
def _load_asset(path: str):
if not path:
return None
return unreal.EditorAssetLibrary.load_asset(path)
def _load_class(p: str):
if not p:
return None
if "/" in p:
return unreal.load_class(None, p)
# Try common Niagara paths
for prefix in ("/Script/Niagara.", "/Script/NiagaraEditor."):
c = unreal.load_class(None, prefix + p)
if c is not None:
return c
return unreal.load_class(None, p)
def _lib():
if not hasattr(unreal, "MCPNiagaraLibrary"):
raise RuntimeError("MCPNiagaraLibrary not found — rebuild the UEBlueprintMCPEditor C++ module")
return unreal.MCPNiagaraLibrary
# ---------- ops ----------
def create_system(p):
path = p["path"]
ok = _lib().create_niagara_system(path)
return {"ok": bool(ok), "path": path}
def create_emitter_asset(p):
"""Create an empty UNiagaraEmitter asset. Niagara typically doesn't support
fully empty emitters via UI — users start from a template. We create the bare
asset; for fully usable emitters duplicate an existing template via asset_op.
"""
path = p["path"]
package_name, _, asset_name = path.rpartition("/")
factory = unreal.NiagaraEmitterFactoryNew() if hasattr(unreal, "NiagaraEmitterFactoryNew") else None
tools = unreal.AssetToolsHelpers.get_asset_tools()
if factory is None:
return {"ok": False, "error": "NiagaraEmitterFactoryNew not exposed to Python — duplicate a template emitter via asset_op instead"}
asset = tools.create_asset(asset_name, package_name, unreal.NiagaraEmitter, factory)
return {"ok": asset is not None, "path": path}
def add_emitter(p):
system = _load_asset(p["system"])
emitter = _load_asset(p["emitter_asset"])
if system is None or emitter is None:
return {"ok": False, "error": "system or emitter_asset not found"}
name = p.get("name", "")
handle_name = _lib().add_emitter_to_system(system, emitter, name)
if not handle_name:
return {"ok": False, "error": "AddEmitterToSystem returned empty"}
unreal.EditorAssetLibrary.save_asset(p["system"])
return {"ok": True, "handle_name": handle_name}
def remove_emitter(p):
system = _load_asset(p["system"])
if system is None:
return {"ok": False, "error": "system not found"}
ok = _lib().remove_emitter_from_system(system, p["emitter_name"])
if ok:
unreal.EditorAssetLibrary.save_asset(p["system"])
return {"ok": bool(ok)}
def list_emitters(p):
system = _load_asset(p["system"])
if system is None:
return {"ok": False, "error": "system not found"}
names = list(_lib().get_emitter_handle_names(system))
return {"ok": True, "emitters": names}
def list_modules(p):
system = _load_asset(p["system"])
if system is None:
return {"ok": False, "error": "system not found"}
modules = list(_lib().list_modules(system, p.get("emitter_name", ""), p["group"]))
return {"ok": True, "modules": modules}
def add_module(p):
system = _load_asset(p["system"])
script = _load_asset(p["module_script"])
if system is None or script is None:
return {"ok": False, "error": "system or module_script not found"}
name = _lib().add_module_to_stack(
system,
p.get("emitter_name", ""),
p["group"],
script,
int(p.get("index", -1)),
)
if not name:
return {"ok": False, "error": "AddModuleToStack returned empty (wrong group or script not a module?)"}
unreal.EditorAssetLibrary.save_asset(p["system"])
return {"ok": True, "module_name": name}
def remove_module(p):
system = _load_asset(p["system"])
if system is None:
return {"ok": False, "error": "system not found"}
ok = _lib().remove_module_from_stack(
system,
p.get("emitter_name", ""),
p["group"],
p["module_name"],
)
if ok:
unreal.EditorAssetLibrary.save_asset(p["system"])
return {"ok": bool(ok)}
def add_renderer(p):
system = _load_asset(p["system"])
if system is None:
return {"ok": False, "error": "system not found"}
cls = _load_class(p["renderer_class"])
if cls is None:
return {"ok": False, "error": f"renderer class {p['renderer_class']!r} not found"}
result = _lib().add_renderer_to_emitter(system, p["emitter_name"], cls)
if not result:
return {"ok": False, "error": "AddRendererToEmitter returned empty"}
unreal.EditorAssetLibrary.save_asset(p["system"])
return {"ok": True, "renderer": result}
def remove_renderer(p):
system = _load_asset(p["system"])
if system is None:
return {"ok": False, "error": "system not found"}
cls = _load_class(p["renderer_class"])
if cls is None:
return {"ok": False, "error": f"renderer class {p['renderer_class']!r} not found"}
n = int(_lib().remove_renderers_from_emitter(system, p["emitter_name"], cls))
if n:
unreal.EditorAssetLibrary.save_asset(p["system"])
return {"ok": True, "removed": n}
def assign_to_actor(p):
"""Assign a UNiagaraSystem to the NiagaraComponent of a NiagaraActor in the
current level. Identifies the actor by its label (use `name` field).
"""
system_path = p["system"]
actor_name = p["name"]
system = _load_asset(system_path)
if system is None:
return {"ok": False, "error": f"system {system_path!r} not found"}
ess = unreal.get_editor_subsystem(unreal.EditorActorSubsystem)
target = None
for a in ess.get_all_level_actors():
if a and (a.get_actor_label() == actor_name or a.get_name() == actor_name):
target = a
break
if target is None:
return {"ok": False, "error": f"actor {actor_name!r} not found"}
comp = target.get_component_by_class(unreal.NiagaraComponent)
if comp is None:
return {"ok": False, "error": "actor has no NiagaraComponent"}
comp.set_asset(system)
comp.activate(reset=True)
return {"ok": True, "actor": actor_name, "system": system_path}
def compile_system(p):
system = _load_asset(p["system"])
if system is None:
return {"ok": False, "error": "system not found"}
ok = _lib().request_compile(system)
return {"ok": bool(ok)}
def set_module_input(p):
return {
"ok": False,
"error": (
"set_module_input not implemented yet — requires "
"FNiagaraStackFunctionInputBinder + NiagaraClipboard plumbing. "
"Workaround: open the system in editor and use UI, or extend "
"MCPNiagaraLibrary with SetModuleInput(scalar|vector|enum) wrappers."
),
}
OPS = {
"create_system": create_system,
"create_emitter_asset": create_emitter_asset,
"add_emitter": add_emitter,
"remove_emitter": remove_emitter,
"list_emitters": list_emitters,
"list_modules": list_modules,
"add_module": add_module,
"remove_module": remove_module,
"add_renderer": add_renderer,
"remove_renderer": remove_renderer,
"compile": compile_system,
"assign_to_actor": assign_to_actor,
"set_module_input": set_module_input,
}
def entrypoint(payload_json: str) -> str:
try:
p = json.loads(payload_json)
except Exception as e: # noqa: BLE001
return _tee({"ok": False, "error": f"bad json: {e}"}, {})
op = p.get("op")
fn = OPS.get(op)
if not fn:
return _tee({"ok": False, "error": f"unknown op {op!r}", "available": list(OPS)}, p)
try:
return _tee(fn(p), p)
except Exception as e: # noqa: BLE001
return _tee({"ok": False, "error": str(e), "traceback": traceback.format_exc()}, p)
def _tee(result: dict, p: dict) -> str:
txt = json.dumps(result)
try:
if unreal is not None:
sd = unreal.Paths.project_saved_dir() + "MCP/"
os.makedirs(sd, exist_ok=True)
rid = p.get("__rid") or "last"
with open(sd + "last.json", "w", encoding="utf-8") as f:
f.write(txt)
if rid != "last":
with open(sd + f"{rid}.json", "w", encoding="utf-8") as f:
f.write(txt)
unreal.log("[MCP-NIAGARA] " + txt[:1500])
except Exception:
pass
return txt

1237
ue_side/pcg.py Normal file

File diff suppressed because it is too large Load Diff

167
ue_side/presets.py Normal file
View File

@ -0,0 +1,167 @@
"""Preset library — typical Blueprint patterns packaged as one-line MCP calls.
Each preset is a pure function: args dict → NodeSpec graph dict (apply_graph format).
The dispatcher then runs apply_graph on it.
Add a new preset:
1. Write `def my_preset(args): return {...nodes/edges...}` below.
2. Register it in PRESETS at the bottom.
3. Call via MCP: preset_op({name: "my_preset", args: {...}}).
Ships with:
print_on_begin_play {bp_path, text?} — BeginPlay → PrintString
print_on_event {bp_path, event, text?} — any event → PrintString
toggle_visibility {bp_path, event, component} — event → ToggleVisibility on a component
damage_drops_health {bp_path, health_var} — AnyDamage → Health -= Damage, branch on <=0 → DestroyActor
"""
from __future__ import annotations
import json
import os
import traceback
try:
import unreal # type: ignore
except ImportError:
unreal = None
# --- preset implementations ---------------------------------------------------
def print_on_begin_play(args: dict) -> dict:
text = args.get("text", "BeginPlay")
return {
"bp_path": args["bp_path"],
"function": "EventGraph",
"mode": "append",
"nodes": [
{"id": "ev", "kind": "event", "target": "ReceiveBeginPlay", "position": [0, 0]},
{"id": "print", "kind": "call_function", "target": "/Script/Engine.KismetSystemLibrary:PrintString",
"position": [300, 0], "params": {"InString": text}},
],
"edges": [{"from": ["ev", "then"], "to": ["print", "execute"]}],
}
def print_on_event(args: dict) -> dict:
event = args["event"] # e.g. "ReceiveActorBeginOverlap"
text = args.get("text", event)
return {
"bp_path": args["bp_path"],
"function": "EventGraph",
"mode": "append",
"nodes": [
{"id": "ev", "kind": "event", "target": event, "position": [0, 0]},
{"id": "print", "kind": "call_function", "target": "/Script/Engine.KismetSystemLibrary:PrintString",
"position": [300, 0], "params": {"InString": text}},
],
"edges": [{"from": ["ev", "then"], "to": ["print", "execute"]}],
}
def toggle_visibility(args: dict) -> dict:
event = args.get("event", "ReceiveActorBeginOverlap")
comp = args["component"] # variable name of a SceneComponent on this BP
return {
"bp_path": args["bp_path"],
"function": "EventGraph",
"mode": "append",
"nodes": [
{"id": "ev", "kind": "event", "target": event, "position": [0, 0]},
{"id": "getc", "kind": "variable_get", "target": comp, "position": [180, 80]},
{"id": "toggle", "kind": "call_function",
"target": "/Script/Engine.SceneComponent:ToggleVisibility",
"position": [380, 0]},
],
"edges": [
{"from": ["ev", "then"], "to": ["toggle", "execute"]},
{"from": ["getc", comp], "to": ["toggle", "self"]},
],
}
def damage_drops_health(args: dict) -> dict:
"""AnyDamage → Health -= Damage; if Health<=0 → DestroyActor."""
hv = args.get("health_var", "Health")
return {
"bp_path": args["bp_path"],
"function": "EventGraph",
"mode": "append",
"nodes": [
{"id": "ev", "kind": "event", "target": "ReceiveAnyDamage", "position": [-200, 0]},
{"id": "getH", "kind": "variable_get", "target": hv, "position": [-20, 60]},
{"id": "sub", "kind": "call_function",
"target": "/Script/Engine.KismetMathLibrary:Subtract_FloatFloat",
"position": [160, 60]},
{"id": "setH", "kind": "variable_set", "target": hv, "position": [340, 0]},
{"id": "getH2", "kind": "variable_get", "target": hv, "position": [560, 80]},
{"id": "le", "kind": "call_function",
"target": "/Script/Engine.KismetMathLibrary:LessEqual_FloatFloat",
"position": [720, 60]},
{"id": "br", "kind": "branch", "position": [900, 0]},
{"id": "destroy", "kind": "call_function",
"target": "/Script/Engine.Actor:K2_DestroyActor",
"position": [1080, 0]},
],
"edges": [
{"from": ["ev", "then"], "to": ["setH", "execute"]},
{"from": ["getH", hv], "to": ["sub", "A"]},
{"from": ["ev", "Damage"], "to": ["sub", "B"]},
{"from": ["sub", "ReturnValue"], "to": ["setH", hv]},
{"from": ["setH", "then"], "to": ["br", "execute"]},
{"from": ["getH2", hv], "to": ["le", "A"]},
{"from": ["le", "ReturnValue"], "to": ["br", "Condition"]},
{"from": ["br", "then"], "to": ["destroy", "execute"]},
],
}
# --- dispatch -----------------------------------------------------------------
PRESETS = {
"print_on_begin_play": print_on_begin_play,
"print_on_event": print_on_event,
"toggle_visibility": toggle_visibility,
"damage_drops_health": damage_drops_health,
}
def entrypoint(payload_json: str) -> str:
try:
p = json.loads(payload_json)
except Exception as e: # noqa: BLE001
return _tee({"ok": False, "error": f"bad json: {e}"}, {})
name = p.get("name")
if name == "__list__":
return _tee({"ok": True, "presets": sorted(PRESETS.keys())}, p)
fn = PRESETS.get(name)
if not fn:
return _tee({"ok": False, "error": f"unknown preset {name!r}", "available": sorted(PRESETS.keys())}, p)
try:
spec = fn(p.get("args") or {})
# Run through apply_graph
import importlib, apply_graph
importlib.reload(apply_graph)
result = apply_graph.apply(spec)
result["preset"] = name
return _tee(result, p)
except Exception as e: # noqa: BLE001
return _tee({"ok": False, "error": str(e), "traceback": traceback.format_exc(), "preset": name}, p)
def _tee(result: dict, p: dict) -> str:
txt = json.dumps(result)
try:
if unreal is not None:
sd = unreal.Paths.project_saved_dir() + "MCP/"
os.makedirs(sd, exist_ok=True)
rid = p.get("__rid") or "last"
with open(sd + "last.json", "w", encoding="utf-8") as f:
f.write(txt)
if rid != "last":
with open(sd + f"{rid}.json", "w", encoding="utf-8") as f:
f.write(txt)
unreal.log("[MCP-PRESET] " + txt[:1500])
except Exception:
pass
return txt

198
ue_side/render.py Normal file
View File

@ -0,0 +1,198 @@
"""Render ops — PostProcess volumes, Render targets, Scene captures, viewport screenshots.
Ops:
spawn_post_process_volume {location?, unbound?:true, settings?:{key:value}}
set_pp_setting {actor_name, key, value} — sets PostProcessVolume.Settings entries
list_pp_volumes {}
create_render_target_2d {path, width?:512, height?:512, format?:'RGBA16f|RGBA8'}
spawn_scene_capture_2d {target_render_target_path, location?, rotation?, name?}
take_screenshot {filename?, resolution?, hdr?:false} — uses HighResShot console cmd
set_viewport_realtime {enabled:bool}
capture_thumbnail {asset_path} — refresh editor thumbnail
"""
from __future__ import annotations
import json
import os
import traceback
try:
import unreal # type: ignore
except ImportError:
unreal = None
def _ess(): return unreal.get_editor_subsystem(unreal.EditorActorSubsystem)
def _world(): return unreal.EditorLevelLibrary.get_editor_world()
def _vec(v, d=(0, 0, 0)):
if v is None: v = d
return unreal.Vector(float(v[0]), float(v[1]), float(v[2]))
def _rot(v, d=(0, 0, 0)):
if v is None: v = d
return unreal.Rotator(float(v[0]), float(v[1]), float(v[2]))
def _find(name):
for a in _ess().get_all_level_actors():
if a and (a.get_actor_label() == name or a.get_name() == name):
return a
return None
def spawn_post_process_volume(args):
cls = unreal.load_class(None, "/Script/Engine.PostProcessVolume")
actor = _ess().spawn_actor_from_class(cls, _vec(args.get("location")), _rot(None))
if actor is None:
return {"ok": False, "error": "spawn failed"}
if args.get("unbound", True):
actor.set_editor_property("unbound", True)
settings = args.get("settings") or {}
if settings:
pp = actor.get_editor_property("settings")
for k, v in settings.items():
try:
pp.set_editor_property(k, v)
except Exception:
pass
actor.set_editor_property("settings", pp)
label = args.get("name") or "PostProcessVolume_MCP"
actor.set_actor_label(label)
return {"ok": True, "name": label, "path": actor.get_path_name()}
def set_pp_setting(args):
a = _find(args["actor_name"])
if a is None:
return {"ok": False, "error": "actor not found"}
try:
pp = a.get_editor_property("settings")
pp.set_editor_property(args["key"], args["value"])
a.set_editor_property("settings", pp)
return {"ok": True}
except Exception as e:
return {"ok": False, "error": str(e)}
def list_pp_volumes(args):
cls = unreal.load_class(None, "/Script/Engine.PostProcessVolume")
out = []
for a in _ess().get_all_level_actors():
if a and a.get_class() == cls.get_default_object().get_class():
out.append({"name": a.get_actor_label(), "unbound": bool(a.get_editor_property("unbound"))})
return {"ok": True, "volumes": out}
def create_render_target_2d(args):
folder, name = args["path"].rsplit("/", 1)
factory = unreal.CanvasRenderTarget2DFactoryNew() if hasattr(unreal, "CanvasRenderTarget2DFactoryNew") else None
asset_cls = unreal.TextureRenderTarget2D
factory = unreal.TextureRenderTargetFactoryNew() if hasattr(unreal, "TextureRenderTargetFactoryNew") else factory
if factory is None:
return {"ok": False, "error": "no RT factory exposed"}
at = unreal.AssetToolsHelpers.get_asset_tools()
asset = at.create_asset(name, folder, asset_cls, factory)
if asset is None:
return {"ok": False, "error": "create returned None"}
asset.set_editor_property("size_x", int(args.get("width") or 512))
asset.set_editor_property("size_y", int(args.get("height") or 512))
fmt = args.get("format") or "RGBA8"
fmt_enum = unreal.TextureRenderTargetFormat.RTF_RGBA16F if fmt == "RGBA16f" else unreal.TextureRenderTargetFormat.RTF_RGBA8
try:
asset.set_editor_property("render_target_format", fmt_enum)
except Exception:
pass
unreal.EditorAssetLibrary.save_asset(asset.get_path_name())
return {"ok": True, "path": asset.get_path_name().split(".")[0]}
def spawn_scene_capture_2d(args):
rt = unreal.EditorAssetLibrary.load_asset(args["target_render_target_path"])
if rt is None:
return {"ok": False, "error": "render target not found"}
cls = unreal.load_class(None, "/Script/Engine.SceneCapture2D")
actor = _ess().spawn_actor_from_class(cls, _vec(args.get("location")), _rot(args.get("rotation")))
if actor is None:
return {"ok": False, "error": "spawn failed"}
try:
comp = actor.get_component_by_class(unreal.SceneCaptureComponent2D)
comp.set_editor_property("texture_target", rt)
except Exception as e:
return {"ok": False, "error": f"set RT failed: {e}"}
label = args.get("name") or "SceneCapture2D_MCP"
actor.set_actor_label(label)
return {"ok": True, "name": label}
def take_screenshot(args):
res = args.get("resolution") or "1920x1080"
fname = args.get("filename") or ""
hdr = "1" if args.get("hdr") else ""
cmd = f"HighResShot {res} {fname} {hdr}".strip()
try:
unreal.SystemLibrary.execute_console_command(_world(), cmd)
return {"ok": True, "command": cmd, "note": "Saved to <Project>/Saved/Screenshots/<Platform>/"}
except Exception as e:
return {"ok": False, "error": str(e)}
def set_viewport_realtime(args):
try:
ues = unreal.get_editor_subsystem(unreal.UnrealEditorSubsystem)
# No direct API; use console command
cmd = "r.Realtime 1" if args.get("enabled") else "r.Realtime 0"
unreal.SystemLibrary.execute_console_command(_world(), cmd)
return {"ok": True, "command": cmd}
except Exception as e:
return {"ok": False, "error": str(e)}
def capture_thumbnail(args):
paths = unreal.Array(str)
paths.append(args["asset_path"])
try:
unreal.AssetEditorSubsystem if False else None
# No direct python wrapper; rely on AssetTools? Fall back to editor cmd
unreal.SystemLibrary.execute_console_command(_world(), f"AssetThumbnailCapture {args['asset_path']}")
return {"ok": True, "note": "issued console command"}
except Exception as e:
return {"ok": False, "error": str(e)}
OPS = {
"spawn_post_process_volume": spawn_post_process_volume,
"set_pp_setting": set_pp_setting,
"list_pp_volumes": list_pp_volumes,
"create_render_target_2d": create_render_target_2d,
"spawn_scene_capture_2d": spawn_scene_capture_2d,
"take_screenshot": take_screenshot,
"set_viewport_realtime": set_viewport_realtime,
"capture_thumbnail": capture_thumbnail,
}
def entrypoint(payload_json):
try:
p = json.loads(payload_json)
except Exception as e:
return _tee({"ok": False, "error": f"bad json: {e}"}, {})
op = p.get("op"); fn = OPS.get(op)
if not fn:
return _tee({"ok": False, "error": f"unknown op {op!r}", "available": list(OPS)}, p)
try:
return _tee(fn(p), p)
except Exception as e:
return _tee({"ok": False, "error": str(e), "traceback": traceback.format_exc()}, p)
def _tee(result, p):
txt = json.dumps(result)
try:
if unreal is not None:
sd = unreal.Paths.project_saved_dir() + "MCP/"
os.makedirs(sd, exist_ok=True)
rid = p.get("__rid") or "last"
with open(sd + "last.json", "w", encoding="utf-8") as f: f.write(txt)
if rid != "last":
with open(sd + f"{rid}.json", "w", encoding="utf-8") as f: f.write(txt)
unreal.log("[MCP-RENDER] " + txt[:1500])
except Exception:
pass
return txt

246
ue_side/selection.py Normal file
View File

@ -0,0 +1,246 @@
"""Selection-aware Blueprint graph ops.
Ops:
get_context {bp_path, function?}
apply_relative {bp_path, function?, placement?, gap_x?, gap_y?,
delete_selection?, nodes:[], edges:[]}
This module keeps partial graph generation out of the full-graph replace path:
LLMs can inspect the user's selected nodes, then append or replace only that
local area.
"""
from __future__ import annotations
import json
import os
import traceback
import graph_layout
try:
import unreal # type: ignore
except ImportError:
unreal = None
def _lib():
L = getattr(unreal, "MCPGraphLibrary", None)
if L is None:
raise RuntimeError("MCPGraphLibrary missing - rebuild C++ plugin")
return L
def _load_bp(path: str):
a = unreal.EditorAssetLibrary.load_asset(path)
if not isinstance(a, unreal.Blueprint):
raise RuntimeError(f"not a blueprint: {path!r}")
return a
def _graph(bp, fn: str | None):
L = _lib()
if fn in (None, "", "EventGraph"):
return L.find_event_graph(bp)
return L.find_function_graph(bp, fn)
def _selected(bp) -> dict:
raw = _lib().get_selected_nodes_json(bp)
data = json.loads(raw)
nodes = data.get("selected") or []
bbox = _bbox(nodes)
focused_graph = data.get("focused_graph")
draft_zones = _draft_zones(bp, focused_graph)
return {
"focused_graph": focused_graph,
"selected": nodes,
"selected_count": len(nodes),
"bbox": bbox,
"anchors": _anchors(bbox),
"draft_zones": draft_zones,
}
def _draft_zones(bp, function_name: str | None) -> list[dict]:
graph = _graph(bp, function_name or "EventGraph")
if graph is None:
return []
raw = _lib().dump_graph_to_json(graph)
data = json.loads(raw)
zones = []
for node in data.get("nodes") or []:
if node.get("class") != "EdGraphNode_Comment":
continue
comment = str(node.get("comment") or node.get("title") or "")
if comment.strip().lower() not in ("mcp zone", "mcp draft zone"):
continue
zone = dict(node)
zone["bbox"] = {
"min_x": float(node.get("x", 0)),
"min_y": float(node.get("y", 0)),
"max_x": float(node.get("x", 0)) + float(node.get("w", 0)),
"max_y": float(node.get("y", 0)) + float(node.get("h", 0)),
"width": float(node.get("w", 0)),
"height": float(node.get("h", 0)),
"center_x": float(node.get("x", 0)) + (float(node.get("w", 0)) / 2.0),
"center_y": float(node.get("y", 0)) + (float(node.get("h", 0)) / 2.0),
}
zones.append(zone)
return zones
def _bbox(nodes: list[dict]) -> dict | None:
if not nodes:
return None
min_x = min(float(n.get("x", 0)) for n in nodes)
min_y = min(float(n.get("y", 0)) for n in nodes)
max_x = max(float(n.get("x", 0)) + float(n.get("w", 220)) for n in nodes)
max_y = max(float(n.get("y", 0)) + float(n.get("h", 120)) for n in nodes)
return {
"min_x": min_x,
"min_y": min_y,
"max_x": max_x,
"max_y": max_y,
"width": max_x - min_x,
"height": max_y - min_y,
"center_x": min_x + ((max_x - min_x) / 2.0),
"center_y": min_y + ((max_y - min_y) / 2.0),
}
def _anchors(bbox: dict | None) -> dict:
if not bbox:
return {
"right": [0, 0],
"below": [0, 320],
"center": [0, 0],
}
return {
"right": [bbox["max_x"] + 320, bbox["min_y"]],
"below": [bbox["min_x"], bbox["max_y"] + 220],
"center": [bbox["center_x"], bbox["center_y"]],
}
def get_context(args: dict) -> dict:
bp = _load_bp(args["bp_path"])
ctx = _selected(bp)
return {"ok": True, "bp_path": args["bp_path"], **ctx}
def apply_relative(args: dict) -> dict:
import importlib
import apply_graph
importlib.reload(apply_graph)
bp = _load_bp(args["bp_path"])
ctx = _selected(bp)
function_name = args.get("function") or ctx.get("focused_graph") or "EventGraph"
target_graph = _graph(bp, function_name)
if target_graph is None:
return {"ok": False, "error": f"graph not found: {function_name!r}", "selection": ctx}
placement = args.get("placement") or "right"
gap_x = float(args.get("gap_x", 0))
gap_y = float(args.get("gap_y", 0))
zone_guid = args.get("zone_guid") or ""
zone = None
for candidate in ctx.get("draft_zones") or []:
if zone_guid and candidate.get("guid") == zone_guid:
zone = candidate
break
if zone is None and not ctx.get("selected") and ctx.get("draft_zones"):
zone = ctx["draft_zones"][-1]
if zone is not None:
bbox = zone["bbox"]
zone_anchors = {
"right": [bbox["min_x"] + 80, bbox["min_y"] + 80],
"below": [bbox["min_x"] + 80, bbox["min_y"] + 80],
"center": [bbox["center_x"], bbox["center_y"]],
}
anchor = zone_anchors.get(placement) or zone_anchors["right"]
else:
anchor = ctx["anchors"].get(placement)
if anchor is None:
anchor = ctx["anchors"]["right"]
ax, ay = float(anchor[0]) + gap_x, float(anchor[1]) + gap_y
relative_nodes = []
for spec in args.get("nodes") or []:
n = dict(spec)
x, y = n.get("position") or [0, 0]
n["position"] = [float(x) + ax, float(y) + ay]
relative_nodes.append(n)
nodes = graph_layout.normalize_specs(
relative_nodes,
args.get("edges") or [],
domain="blueprint",
) if args.get("auto_layout", True) else relative_nodes
deleted = []
if args.get("delete_selection"):
for n in ctx.get("selected") or []:
guid = n.get("guid")
if guid and _lib().delete_node(target_graph, guid):
deleted.append(guid)
graph = {
"bp_path": args["bp_path"],
"function": function_name,
"mode": "append",
"nodes": nodes,
"edges": args.get("edges") or [],
}
result = apply_graph.apply(graph)
return {
"ok": bool(result.get("ok")),
"bp_path": args["bp_path"],
"function": function_name,
"placement": placement,
"anchor": [ax, ay],
"zone_guid": zone.get("guid") if zone else None,
"deleted_selection": deleted,
"selection": ctx,
"applied": result,
}
OPS = {
"get_context": get_context,
"apply_relative": apply_relative,
}
def entrypoint(payload_json: str) -> str:
try:
p = json.loads(payload_json)
except Exception as e: # noqa: BLE001
return _tee({"ok": False, "error": f"bad json: {e}"}, {})
fn = OPS.get(p.get("op"))
if not fn:
return _tee({"ok": False, "error": f"unknown op {p.get('op')!r}", "available": list(OPS)}, p)
try:
return _tee(fn(p), p)
except Exception as e: # noqa: BLE001
return _tee({"ok": False, "error": str(e), "traceback": traceback.format_exc()}, p)
def _tee(result: dict, p: dict) -> str:
txt = json.dumps(result)
try:
if unreal is not None:
sd = unreal.Paths.project_saved_dir() + "MCP/"
os.makedirs(sd, exist_ok=True)
rid = p.get("__rid") or "last"
with open(sd + f"{rid}.json", "w", encoding="utf-8") as f:
f.write(txt)
with open(sd + "last.json", "w", encoding="utf-8") as f:
f.write(txt)
unreal.log("[MCP-SELECTION] " + txt[:1500])
except Exception:
pass
return txt

156
ue_side/validation.py Normal file
View File

@ -0,0 +1,156 @@
"""Asset validation, redirector cleanup, reference analysis.
Ops:
validate_assets {paths:[]} — run EditorValidatorSubsystem
validate_folder {path, recursive?}
fix_redirectors {path} — recursively resolve UObjectRedirectors under folder
get_references {asset_path} — assets THIS asset depends on
get_referencers {asset_path} — assets that REFER TO this asset
find_unused {folder, ignore_paths?:[]} — assets in folder with zero referencers
disk_size {folder} — total .uasset size on disk
list_redirectors {folder}
"""
from __future__ import annotations
import json
import os
import traceback
try:
import unreal # type: ignore
except ImportError:
unreal = None
def _ar():
return unreal.AssetRegistryHelpers.get_asset_registry()
def validate_assets(args):
paths = args.get("paths") or []
evs = unreal.get_editor_subsystem(unreal.EditorValidatorSubsystem)
if evs is None:
return {"ok": False, "error": "EditorValidatorSubsystem unavailable"}
objs = [unreal.EditorAssetLibrary.load_asset(p) for p in paths]
objs = [o for o in objs if o is not None]
results = evs.validate_assets(objs, True, True) if hasattr(evs, "validate_assets") else None
return {"ok": True, "validated_count": len(objs), "result": str(results) if results is not None else None}
def validate_folder(args):
folder = args["path"]
recursive = bool(args.get("recursive", True))
filt = unreal.ARFilter(package_paths=[folder], recursive_paths=recursive)
assets = _ar().get_assets(filt)
paths = [str(d.package_name) for d in assets]
return validate_assets({"paths": paths})
def fix_redirectors(args):
folder = args["path"]
filt = unreal.ARFilter(package_paths=[folder], recursive_paths=True, class_names=["ObjectRedirector"])
redirs = _ar().get_assets(filt)
if not redirs:
return {"ok": True, "fixed": 0}
objs = [d.get_asset() for d in redirs]
objs = [o for o in objs if o is not None]
try:
unreal.AssetToolsHelpers.get_asset_tools().fixup_referencers(objs)
return {"ok": True, "fixed": len(objs)}
except Exception as e:
return {"ok": False, "error": str(e), "found": len(redirs)}
def get_references(args):
deps = _ar().get_dependencies(unreal.Name(args["asset_path"]),
unreal.AssetRegistryDependencyOptions(include_soft_package_references=True,
include_hard_package_references=True))
return {"ok": True, "dependencies": [str(d) for d in (deps or [])]}
def get_referencers(args):
refs = _ar().get_referencers(unreal.Name(args["asset_path"]),
unreal.AssetRegistryDependencyOptions(include_soft_package_references=True,
include_hard_package_references=True))
return {"ok": True, "referencers": [str(d) for d in (refs or [])]}
def find_unused(args):
folder = args["folder"]
ignore = set(args.get("ignore_paths") or [])
filt = unreal.ARFilter(package_paths=[folder], recursive_paths=True)
out = []
for d in _ar().get_assets(filt):
pkg = str(d.package_name)
if pkg in ignore or any(pkg.startswith(p) for p in ignore):
continue
refs = _ar().get_referencers(unreal.Name(pkg),
unreal.AssetRegistryDependencyOptions(include_soft_package_references=True,
include_hard_package_references=True)) or []
if not refs:
out.append({"path": pkg, "class": str(d.asset_class)})
return {"ok": True, "count": len(out), "unused": out}
def disk_size(args):
folder = args["folder"]
# Translate /Game -> Content path
proj_content = unreal.Paths.project_content_dir()
rel = folder.replace("/Game", "").lstrip("/")
root = os.path.join(proj_content, rel)
total = 0; files = 0
for dirpath, _, fnames in os.walk(root):
for f in fnames:
if f.endswith((".uasset", ".umap")):
total += os.path.getsize(os.path.join(dirpath, f))
files += 1
return {"ok": True, "folder": folder, "bytes": total, "files": files, "mb": round(total / 1024 / 1024, 2)}
def list_redirectors(args):
folder = args["folder"]
filt = unreal.ARFilter(package_paths=[folder], recursive_paths=True, class_names=["ObjectRedirector"])
out = [str(d.package_name) for d in _ar().get_assets(filt)]
return {"ok": True, "count": len(out), "redirectors": out}
OPS = {
"validate_assets": validate_assets,
"validate_folder": validate_folder,
"fix_redirectors": fix_redirectors,
"get_references": get_references,
"get_referencers": get_referencers,
"find_unused": find_unused,
"disk_size": disk_size,
"list_redirectors": list_redirectors,
}
def entrypoint(payload_json):
try:
p = json.loads(payload_json)
except Exception as e:
return _tee({"ok": False, "error": f"bad json: {e}"}, {})
op = p.get("op"); fn = OPS.get(op)
if not fn:
return _tee({"ok": False, "error": f"unknown op {op!r}", "available": list(OPS)}, p)
try:
return _tee(fn(p), p)
except Exception as e:
return _tee({"ok": False, "error": str(e), "traceback": traceback.format_exc()}, p)
def _tee(result, p):
txt = json.dumps(result)
try:
if unreal is not None:
sd = unreal.Paths.project_saved_dir() + "MCP/"
os.makedirs(sd, exist_ok=True)
rid = p.get("__rid") or "last"
with open(sd + "last.json", "w", encoding="utf-8") as f: f.write(txt)
if rid != "last":
with open(sd + f"{rid}.json", "w", encoding="utf-8") as f: f.write(txt)
unreal.log("[MCP-VALIDATE] " + txt[:1500])
except Exception:
pass
return txt

180
ue_side/variables.py Normal file
View File

@ -0,0 +1,180 @@
"""Blueprint member-variable authoring ops.
Wraps the (already-compiled) C++ helpers on unreal.MCPGraphLibrary:
AddMemberVariable / SetVariableDefaultValue / SetVariableMetadata /
RenameVariable / DeleteVariable / DescribeBlueprintJson.
Reachable from JS as the `variable_op` tool, and also merged into `asset_op`'s
op-table for zero-restart availability (see assets.py).
Ops:
add {bp_path, name, type, default?, instance_editable?, expose_on_spawn?,
category?, tooltip?} — create a member variable, optionally set its
default value + metadata. type is one of
bool|int|float|double|string|name|text or a
class path / short name for an object ref.
set_default {bp_path, name, value}
set_meta {bp_path, name, instance_editable?, expose_on_spawn?, category?, tooltip?}
rename {bp_path, old, new}
delete {bp_path, name}
list {bp_path} — current member variables (name+type) as JSON.
"""
from __future__ import annotations
import json
import os
import traceback
try:
import unreal # type: ignore
except ImportError:
unreal = None
def _lib():
L = getattr(unreal, "MCPGraphLibrary", None)
if L is None:
raise RuntimeError("MCPGraphLibrary missing — rebuild C++ plugin")
return L
def _load_bp(path: str):
a = unreal.EditorAssetLibrary.load_asset(path)
if not isinstance(a, unreal.Blueprint):
raise RuntimeError(f"not a blueprint: {path!r}")
return a
def _compile_save(bp):
_lib().compile_and_save_blueprint(bp)
unreal.EditorAssetLibrary.save_loaded_asset(bp)
def _variables(bp) -> list:
"""Return [{name,type,...}] from DescribeBlueprintJson."""
try:
info = json.loads(_lib().describe_blueprint_json(bp))
return info.get("variables", [])
except Exception: # noqa: BLE001
return []
def _has_var(bp, name: str) -> bool:
return any(v.get("name") == name for v in _variables(bp))
def add(args: dict) -> dict:
bp = _load_bp(args["bp_path"])
name = args["name"]
type_spec = args.get("type") or args.get("type_spec")
if not name or not type_spec:
return {"ok": False, "error": "add requires {name, type}"}
if _has_var(bp, name):
return {"ok": False, "error": f"variable {name!r} already exists", "exists": True}
ok = bool(_lib().add_member_variable(bp, name, type_spec))
if not ok:
return {"ok": False, "error": f"add_member_variable failed (bad type {type_spec!r}?)"}
# Optional default value + metadata in the same transaction.
if "default" in args and args["default"] is not None:
_lib().set_variable_default_value(bp, name, str(args["default"]))
if any(k in args for k in ("instance_editable", "expose_on_spawn", "category", "tooltip")):
_lib().set_variable_metadata(
bp, name,
bool(args.get("instance_editable", False)),
bool(args.get("expose_on_spawn", False)),
str(args.get("category", "")),
str(args.get("tooltip", "")),
)
_compile_save(bp)
return {"ok": True, "name": name, "type": type_spec}
def set_default(args: dict) -> dict:
bp = _load_bp(args["bp_path"])
ok = bool(_lib().set_variable_default_value(bp, args["name"], str(args.get("value", ""))))
if ok: _compile_save(bp)
return {"ok": ok, "name": args["name"]}
def set_meta(args: dict) -> dict:
bp = _load_bp(args["bp_path"])
ok = bool(_lib().set_variable_metadata(
bp, args["name"],
bool(args.get("instance_editable", False)),
bool(args.get("expose_on_spawn", False)),
str(args.get("category", "")),
str(args.get("tooltip", "")),
))
if ok: _compile_save(bp)
return {"ok": ok, "name": args["name"]}
def rename(args: dict) -> dict:
bp = _load_bp(args["bp_path"])
ok = bool(_lib().rename_variable(bp, args["old"], args["new"]))
if ok: _compile_save(bp)
return {"ok": ok, "old": args["old"], "new": args["new"]}
def delete(args: dict) -> dict:
bp = _load_bp(args["bp_path"])
ok = bool(_lib().delete_variable(bp, args["name"]))
if ok: _compile_save(bp)
return {"ok": ok, "name": args["name"]}
def list_vars(args: dict) -> dict:
bp = _load_bp(args["bp_path"])
return {"ok": True, "variables": _variables(bp)}
# op-table — also merged into assets.OPS for zero-restart availability.
VAR_OPS = {
"add_variable": add,
"add": add,
"set_var_default": set_default,
"set_default": set_default,
"set_var_meta": set_meta,
"set_meta": set_meta,
"rename_variable": rename,
"rename": rename,
"delete_variable": delete,
"delete": delete,
"list_variables": list_vars,
"list": list_vars,
}
def entrypoint(payload_json: str) -> str:
try:
p = json.loads(payload_json)
except Exception as e: # noqa: BLE001
return _tee({"ok": False, "error": f"bad json: {e}"}, {})
op = p.get("op")
fn = VAR_OPS.get(op)
if not fn:
return _tee({"ok": False, "error": f"unknown op {op!r}", "available": sorted(VAR_OPS)}, p)
try:
return _tee(fn(p), p)
except Exception as e: # noqa: BLE001
return _tee({"ok": False, "error": str(e), "traceback": traceback.format_exc()}, p)
def _tee(result: dict, p: dict) -> str:
txt = json.dumps(result)
try:
if unreal is not None:
sd = unreal.Paths.project_saved_dir() + "MCP/"
os.makedirs(sd, exist_ok=True)
rid = p.get("__rid") or "last"
with open(sd + "last.json", "w", encoding="utf-8") as f:
f.write(txt)
if rid != "last":
with open(sd + f"{rid}.json", "w", encoding="utf-8") as f:
f.write(txt)
unreal.log("[MCP-VARS] " + txt[:1500])
except Exception:
pass
return txt

430
ue_side/voxel_graph.py Normal file
View File

@ -0,0 +1,430 @@
"""Voxel Graph MCP ops.
This worker mirrors the small, dependable graph-editing surface used by the
Blueprint worker, but targets Voxel Plugin terminal graphs.
Ops:
list_terminals {asset_path}
read_graph {asset_path, terminal?}
get_selection {asset_path, terminal?}
get_context {asset_path, terminal?}
list_node_types {asset_path?, filter?, limit?}
spawn_node {asset_path, terminal?, node, x,y, compile?}
create_graph {path}
add_property {asset_path, terminal?, kind, name, type?, category?}
list_properties {asset_path, terminal?, kind}
spawn_property {asset_path, terminal?, kind, ref, x,y, declaration?, compile?}
spawn_call {asset_path, terminal?, function, x,y, compile?}
compile {asset_path, terminal?}
spawn_comment {asset_path, terminal?, x,y,width,height,text?, color?}
clear_zones {asset_path, terminal?}
delete_node {asset_path, terminal?, guid}
break_link {asset_path, terminal?, from:[guid,pin], to:[guid,pin]}
break_all {asset_path, terminal?, guid}
connect_pins {asset_path, terminal?, from:[guid,pin], to:[guid,pin]}
set_pin_default {asset_path, terminal?, guid,pin,value}
move_nodes {asset_path, terminal?, moves:[{guid,x,y}]}
resize_comment {asset_path, terminal?, guid,width,height}
set_comment {asset_path, terminal?, guid,text,bubble?}
open_asset {asset_path}
"""
from __future__ import annotations
import json
import os
import traceback
try:
import unreal # type: ignore
except ImportError:
unreal = None
def _graph_lib():
lib = getattr(unreal, "MCPGraphLibrary", None)
if lib is None:
raise RuntimeError("MCPGraphLibrary missing - rebuild C++ plugin")
return lib
def _voxel_lib():
lib = getattr(unreal, "MCPVoxelGraphLibrary", None)
if lib is None:
raise RuntimeError("MCPVoxelGraphLibrary missing - rebuild C++ plugin")
return lib
def _load_asset(path: str):
asset = unreal.EditorAssetLibrary.load_asset(path)
if asset is None:
raise RuntimeError(f"asset not found: {path!r}")
if not bool(_voxel_lib().is_voxel_graph_asset(asset)):
raise RuntimeError(f"asset is not a UVoxelGraph: {path!r} ({asset.get_class().get_name()})")
return asset
def _terminal(args: dict) -> str:
return str(args.get("terminal") or "main")
def _ed_graph(asset, args: dict):
graph = _voxel_lib().find_terminal_ed_graph(asset, _terminal(args))
if graph is None:
raise RuntimeError(f"terminal graph not found: {_terminal(args)!r}")
return graph
def _save(asset):
try:
unreal.EditorAssetLibrary.save_loaded_asset(asset)
except Exception:
pass
def _changed(asset, graph, compile_graph: bool = True):
_voxel_lib().notify_graph_changed(graph, bool(compile_graph))
_save(asset)
def list_terminals(args: dict) -> dict:
asset = _load_asset(args["asset_path"])
return json.loads(_voxel_lib().list_terminal_graphs_json(asset))
def read_graph(args: dict) -> dict:
asset = _load_asset(args["asset_path"])
return json.loads(_voxel_lib().dump_terminal_graph_json(asset, _terminal(args)))
def get_selection(args: dict) -> dict:
asset = _load_asset(args["asset_path"])
return json.loads(_voxel_lib().get_selected_nodes_json(asset, _terminal(args)))
def _bbox(nodes: list[dict]) -> dict | None:
if not nodes:
return None
min_x = min(float(node.get("x", 0)) for node in nodes)
min_y = min(float(node.get("y", 0)) for node in nodes)
max_x = max(float(node.get("x", 0)) + float(node.get("w", 220)) for node in nodes)
max_y = max(float(node.get("y", 0)) + float(node.get("h", 120)) for node in nodes)
return {
"min_x": min_x,
"min_y": min_y,
"max_x": max_x,
"max_y": max_y,
"width": max_x - min_x,
"height": max_y - min_y,
"center_x": min_x + ((max_x - min_x) / 2.0),
"center_y": min_y + ((max_y - min_y) / 2.0),
}
def _zones(nodes: list[dict]) -> list[dict]:
out = []
for node in nodes:
if node.get("class") != "EdGraphNode_Comment":
continue
comment = str(node.get("comment") or node.get("title") or "").strip().lower()
if comment not in ("mcp zone", "mcp draft zone"):
continue
zone = dict(node)
zone["bbox"] = _bbox([node])
out.append(zone)
return out
def get_context(args: dict) -> dict:
graph_data = read_graph(args)
selection_data = get_selection(args)
selected = selection_data.get("selected") or []
return {
"ok": bool(graph_data.get("ok")),
"asset": graph_data.get("asset"),
"terminal": graph_data.get("terminal"),
"nodes": graph_data.get("nodes") or [],
"selected": selected,
"selected_count": len(selected),
"bbox": _bbox(selected),
"zones": _zones(graph_data.get("nodes") or []),
"selection_error": selection_data.get("error"),
}
def list_node_types(args: dict) -> dict:
# asset_path is optional here: the catalogue is global, but we keep the
# signature consistent with the rest of the worker when one is supplied.
flt = str(args.get("filter") or "")
limit = int(args.get("limit") or 0)
return json.loads(_voxel_lib().list_node_types_json(flt, limit))
def spawn_node(args: dict) -> dict:
asset = _load_asset(args["asset_path"])
graph = _ed_graph(asset, args)
node_id = str(args.get("node") or args.get("node_id") or "").strip()
if not node_id:
raise RuntimeError("spawn_node requires 'node' (id / display name)")
guid = _voxel_lib().spawn_struct_node(
graph,
node_id,
unreal.Vector2D(float(args.get("x", 0)), float(args.get("y", 0))),
)
ok = bool(guid)
if ok:
_changed(asset, graph, compile_graph=bool(args.get("compile", False)))
return {"ok": ok, "guid": guid, "node": node_id, "terminal": _terminal(args)}
def create_graph(args: dict) -> dict:
path = str(args["path"]).rstrip("/")
pkg_dir, _, name = path.rpartition("/")
if not name or not pkg_dir:
raise RuntimeError(f"bad path: {path!r} (expected e.g. /Game/MyGraph)")
tools = unreal.AssetToolsHelpers.get_asset_tools()
asset = tools.create_asset(name, pkg_dir, unreal.VoxelGraph, None)
if asset is None:
raise RuntimeError(f"failed to create voxel graph at {path}")
unreal.EditorAssetLibrary.save_loaded_asset(asset)
return {"ok": True, "asset": asset.get_path_name()}
def add_property(args: dict) -> dict:
asset = _load_asset(args["asset_path"])
result = json.loads(_voxel_lib().add_graph_property_json(
asset,
_terminal(args),
str(args.get("kind") or "parameter"),
str(args.get("name") or ""),
str(args.get("type") or "float"),
str(args.get("category") or ""),
))
if result.get("ok"):
_save(asset)
return result
def list_properties(args: dict) -> dict:
asset = _load_asset(args["asset_path"])
return json.loads(_voxel_lib().list_graph_properties_json(
asset, _terminal(args), str(args.get("kind") or "parameter")))
def spawn_property(args: dict) -> dict:
asset = _load_asset(args["asset_path"])
graph = _ed_graph(asset, args)
ref = str(args.get("ref") or args.get("name") or "").strip()
if not ref:
raise RuntimeError("spawn_property requires 'ref' (guid or name)")
guid = _voxel_lib().spawn_property_node(
graph,
str(args.get("kind") or "parameter"),
ref,
unreal.Vector2D(float(args.get("x", 0)), float(args.get("y", 0))),
bool(args.get("declaration", False)),
)
ok = bool(guid)
if ok:
_changed(asset, graph, compile_graph=bool(args.get("compile", False)))
return {"ok": ok, "guid": guid, "kind": str(args.get("kind") or "parameter"), "terminal": _terminal(args)}
def spawn_call(args: dict) -> dict:
asset = _load_asset(args["asset_path"])
graph = _ed_graph(asset, args)
func = str(args.get("function") or "").strip()
if not func:
raise RuntimeError("spawn_call requires 'function' (terminal guid or name)")
guid = _voxel_lib().spawn_call_function_node(
graph,
func,
unreal.Vector2D(float(args.get("x", 0)), float(args.get("y", 0))),
)
ok = bool(guid)
if ok:
_changed(asset, graph, compile_graph=bool(args.get("compile", False)))
return {"ok": ok, "guid": guid, "function": func, "terminal": _terminal(args)}
def compile_graph(args: dict) -> dict:
asset = _load_asset(args["asset_path"])
graph = _ed_graph(asset, args)
_changed(asset, graph, compile_graph=True)
return {"ok": True, "terminal": _terminal(args)}
def spawn_comment(args: dict) -> dict:
asset = _load_asset(args["asset_path"])
graph = _ed_graph(asset, args)
color = args.get("color") or [0.05, 0.45, 0.9, 0.35]
while len(color) < 4:
color.append(1.0)
guid = _voxel_lib().spawn_comment_node(
graph,
unreal.Vector2D(float(args.get("x", 0)), float(args.get("y", 0))),
unreal.Vector2D(float(args.get("width", 420)), float(args.get("height", 260))),
str(args.get("text") or "MCP Zone"),
unreal.LinearColor(float(color[0]), float(color[1]), float(color[2]), float(color[3])),
)
ok = bool(guid)
if ok:
_changed(asset, graph, compile_graph=False)
return {"ok": ok, "guid": guid, "terminal": _terminal(args)}
def clear_zones(args: dict) -> dict:
asset = _load_asset(args["asset_path"])
graph = _ed_graph(asset, args)
data = read_graph(args)
deleted = []
for zone in _zones(data.get("nodes") or []):
guid = zone.get("guid")
if guid and _graph_lib().delete_node(graph, guid):
deleted.append(guid)
if deleted:
_changed(asset, graph, compile_graph=False)
return {"ok": True, "deleted": deleted, "deleted_count": len(deleted)}
def delete_node(args: dict) -> dict:
asset = _load_asset(args["asset_path"])
graph = _ed_graph(asset, args)
ok = bool(_graph_lib().delete_node(graph, args["guid"]))
if ok:
_changed(asset, graph)
return {"ok": ok, "guid": args["guid"]}
def break_link(args: dict) -> dict:
asset = _load_asset(args["asset_path"])
graph = _ed_graph(asset, args)
from_guid, from_pin = args["from"]
to_guid, to_pin = args["to"]
ok = bool(_graph_lib().break_link(graph, from_guid, from_pin, to_guid, to_pin))
if ok:
_changed(asset, graph)
return {"ok": ok}
def break_all(args: dict) -> dict:
asset = _load_asset(args["asset_path"])
graph = _ed_graph(asset, args)
ok = bool(_graph_lib().break_all_node_links(graph, args["guid"]))
if ok:
_changed(asset, graph)
return {"ok": ok}
def connect_pins(args: dict) -> dict:
asset = _load_asset(args["asset_path"])
graph = _ed_graph(asset, args)
from_guid, from_pin = args["from"]
to_guid, to_pin = args["to"]
ok = bool(_graph_lib().connect_pins(graph, from_guid, from_pin, to_guid, to_pin))
if ok:
_changed(asset, graph)
return {"ok": ok}
def set_pin_default(args: dict) -> dict:
asset = _load_asset(args["asset_path"])
graph = _ed_graph(asset, args)
ok = bool(_graph_lib().set_pin_default(graph, args["guid"], args["pin"], str(args.get("value", ""))))
if ok:
_changed(asset, graph)
return {"ok": ok}
def move_nodes(args: dict) -> dict:
asset = _load_asset(args["asset_path"])
graph = _ed_graph(asset, args)
moves = args.get("moves") or []
ids = [m["guid"] for m in moves]
positions = [unreal.Vector2D(float(m["x"]), float(m["y"])) for m in moves]
count = int(_graph_lib().move_nodes(graph, ids, positions))
if count:
_changed(asset, graph, compile_graph=False)
return {"ok": count > 0, "moved": count}
def resize_comment(args: dict) -> dict:
asset = _load_asset(args["asset_path"])
graph = _ed_graph(asset, args)
ok = bool(_graph_lib().resize_comment_node(graph, args["guid"], unreal.Vector2D(float(args["width"]), float(args["height"]))))
if ok:
_changed(asset, graph, compile_graph=False)
return {"ok": ok}
def set_comment(args: dict) -> dict:
asset = _load_asset(args["asset_path"])
graph = _ed_graph(asset, args)
ok = bool(_graph_lib().set_node_comment(graph, args["guid"], str(args.get("text", "")), bool(args.get("bubble", False))))
if ok:
_changed(asset, graph, compile_graph=False)
return {"ok": ok}
def open_asset(args: dict) -> dict:
asset = _load_asset(args["asset_path"])
return {"ok": bool(_voxel_lib().open_voxel_graph_asset(asset))}
OPS = {
"list_terminals": list_terminals,
"read_graph": read_graph,
"get_selection": get_selection,
"get_context": get_context,
"list_node_types": list_node_types,
"spawn_node": spawn_node,
"create_graph": create_graph,
"add_property": add_property,
"list_properties": list_properties,
"spawn_property": spawn_property,
"spawn_call": spawn_call,
"compile": compile_graph,
"spawn_comment": spawn_comment,
"clear_zones": clear_zones,
"delete_node": delete_node,
"break_link": break_link,
"break_all": break_all,
"connect_pins": connect_pins,
"set_pin_default": set_pin_default,
"move_nodes": move_nodes,
"resize_comment": resize_comment,
"set_comment": set_comment,
"open_asset": open_asset,
}
def entrypoint(payload_json: str) -> str:
try:
payload = json.loads(payload_json)
except Exception as exc: # noqa: BLE001
return _tee({"ok": False, "error": f"bad json: {exc}"}, {})
fn = OPS.get(payload.get("op"))
if not fn:
return _tee({"ok": False, "error": f"unknown op {payload.get('op')!r}", "available": list(OPS)}, payload)
try:
return _tee(fn(payload), payload)
except Exception as exc: # noqa: BLE001
return _tee({"ok": False, "error": str(exc), "traceback": traceback.format_exc()}, payload)
def _tee(result: dict, payload: dict) -> str:
text = json.dumps(result)
try:
if unreal is not None:
saved_dir = unreal.Paths.project_saved_dir() + "MCP/"
os.makedirs(saved_dir, exist_ok=True)
rid = payload.get("__rid") or "last"
with open(saved_dir + f"{rid}.json", "w", encoding="utf-8") as handle:
handle.write(text)
with open(saved_dir + "last.json", "w", encoding="utf-8") as handle:
handle.write(text)
unreal.log("[MCP-VOXEL-GRAPH] " + text[:1500])
except Exception:
pass
return text

451
ue_side/widget_tree.py Normal file
View File

@ -0,0 +1,451 @@
"""UMG Designer / WidgetTree ops.
Ops:
describe {bp_path}
get_props {bp_path, widget}
get_slot_props {bp_path, widget}
add_widget {bp_path, class, name?, parent?, index?, is_variable?, properties?, canvas_slot?}
remove_widget {bp_path, widget}
rename_widget {bp_path, widget, new_name}
move_widget {bp_path, widget, parent?, index?}
set_root {bp_path, widget}
set_is_variable {bp_path, widget, is_variable}
set_property {bp_path, widget, property, value}
set_properties {bp_path, widget, properties}
set_slot_property {bp_path, widget, property, value}
set_canvas_slot {bp_path, widget, position?, size?, anchors_min?, anchors_max?, alignment?, auto_size?, z_order?}
set_named_slot {bp_path, host, slot, content}
clear_named_slot {bp_path, host, slot}
set_desired_focus {bp_path, widget}
apply_tree {bp_path, clear_existing?, widgets:[...], desired_focus?}
compile_save {bp_path}
"""
from __future__ import annotations
import json
import os
import traceback
try:
import unreal # type: ignore
except ImportError:
unreal = None
def _lib():
L = getattr(unreal, "MCPGraphLibrary", None)
if L is None:
raise RuntimeError("MCPGraphLibrary missing - rebuild C++ plugin")
return L
def _load_widget_bp(path: str):
asset = unreal.EditorAssetLibrary.load_asset(path)
if not isinstance(asset, unreal.WidgetBlueprint):
raise RuntimeError(f"not a WidgetBlueprint: {path!r}")
return asset
def _load_widget_class(path_or_name: str):
if not path_or_name:
raise RuntimeError("widget class required")
if "/" in path_or_name:
cls = unreal.load_class(None, path_or_name)
if cls:
return cls
for prefix in (
"/Script/UMG.",
"/Script/CommonUI.",
"/Script/AdvancedWidgets.",
"/Script/Engine.",
):
cls = unreal.load_class(None, prefix + path_or_name)
if cls:
return cls
raise RuntimeError(f"widget class not found: {path_or_name!r}")
def _vec2(value, default):
if value is None:
value = default
return unreal.Vector2D(float(value[0]), float(value[1]))
def _json(raw: str) -> dict:
return json.loads(raw or "{}")
def _save(bp):
unreal.BlueprintEditorLibrary.compile_blueprint(bp)
unreal.EditorAssetLibrary.save_loaded_asset(bp)
def describe(args: dict) -> dict:
bp = _load_widget_bp(args["bp_path"])
return {"ok": True, **_json(_lib().describe_widget_tree_json(bp))}
def get_props(args: dict) -> dict:
bp = _load_widget_bp(args["bp_path"])
return {"ok": True, "widget": args["widget"], **_json(_lib().get_widget_properties_json(bp, args["widget"]))}
def get_slot_props(args: dict) -> dict:
bp = _load_widget_bp(args["bp_path"])
return {"ok": True, "widget": args["widget"], **_json(_lib().get_widget_slot_properties_json(bp, args["widget"]))}
def _set_props(bp, widget_name: str, props: dict) -> list[str]:
warnings = []
for key, value in (props or {}).items():
ok = bool(_lib().set_widget_property(bp, widget_name, str(key), str(value)))
if not ok:
warnings.append(f"set_widget_property failed: {widget_name}.{key}")
return warnings
def _set_canvas_slot(bp, widget_name: str, slot: dict) -> bool:
return bool(_lib().set_canvas_slot_layout(
bp,
widget_name,
_vec2(slot.get("position"), (0, 0)),
_vec2(slot.get("size"), (100, 40)),
_vec2(slot.get("anchors_min"), (0, 0)),
_vec2(slot.get("anchors_max"), (0, 0)),
_vec2(slot.get("alignment"), (0, 0)),
bool(slot.get("auto_size", False)),
int(slot.get("z_order", 0)),
))
def add_widget(args: dict) -> dict:
bp = _load_widget_bp(args["bp_path"])
cls = _load_widget_class(args["class"])
name = _lib().add_widget_to_tree(
bp,
cls,
args.get("name") or "",
args.get("parent") or "",
int(args.get("index", -1)),
bool(args.get("is_variable", True)),
)
if not name:
return {"ok": False, "error": "add_widget_to_tree failed"}
warnings = _set_props(bp, name, args.get("properties") or {})
if args.get("canvas_slot") and not _set_canvas_slot(bp, name, args["canvas_slot"]):
warnings.append(f"set_canvas_slot failed: {name}")
_save(bp)
return {"ok": True, "bp_path": args["bp_path"], "widget": name, "warnings": warnings}
def remove_widget(args: dict) -> dict:
bp = _load_widget_bp(args["bp_path"])
ok = bool(_lib().remove_widget_from_tree(bp, args["widget"]))
_save(bp)
return {"ok": ok, "widget": args["widget"]}
def rename_widget(args: dict) -> dict:
bp = _load_widget_bp(args["bp_path"])
ok = bool(_lib().rename_widget_in_tree(bp, args["widget"], args["new_name"]))
_save(bp)
return {"ok": ok, "widget": args["widget"], "new_name": args["new_name"]}
def move_widget(args: dict) -> dict:
bp = _load_widget_bp(args["bp_path"])
ok = bool(_lib().move_widget_in_tree(bp, args["widget"], args.get("parent") or "", int(args.get("index", -1))))
_save(bp)
return {"ok": ok, "widget": args["widget"], "parent": args.get("parent") or ""}
def set_root(args: dict) -> dict:
bp = _load_widget_bp(args["bp_path"])
ok = bool(_lib().set_root_widget(bp, args["widget"]))
_save(bp)
return {"ok": ok, "root": args["widget"]}
def set_is_variable(args: dict) -> dict:
bp = _load_widget_bp(args["bp_path"])
ok = bool(_lib().set_widget_is_variable(bp, args["widget"], bool(args.get("is_variable", True))))
_save(bp)
return {"ok": ok, "widget": args["widget"], "is_variable": bool(args.get("is_variable", True))}
def set_property(args: dict) -> dict:
bp = _load_widget_bp(args["bp_path"])
ok = bool(_lib().set_widget_property(bp, args["widget"], args["property"], str(args.get("value", ""))))
_save(bp)
return {"ok": ok, "widget": args["widget"], "property": args["property"]}
def set_properties(args: dict) -> dict:
bp = _load_widget_bp(args["bp_path"])
warnings = _set_props(bp, args["widget"], args.get("properties") or {})
_save(bp)
return {"ok": not warnings, "widget": args["widget"], "warnings": warnings}
def set_slot_property(args: dict) -> dict:
bp = _load_widget_bp(args["bp_path"])
ok = bool(_lib().set_widget_slot_property(bp, args["widget"], args["property"], str(args.get("value", ""))))
_save(bp)
return {"ok": ok, "widget": args["widget"], "property": args["property"]}
def set_canvas_slot(args: dict) -> dict:
bp = _load_widget_bp(args["bp_path"])
ok = _set_canvas_slot(bp, args["widget"], args)
_save(bp)
return {"ok": ok, "widget": args["widget"]}
def set_named_slot(args: dict) -> dict:
bp = _load_widget_bp(args["bp_path"])
ok = bool(_lib().set_named_slot_content(bp, args["host"], args["slot"], args["content"]))
_save(bp)
return {"ok": ok, "host": args["host"], "slot": args["slot"], "content": args["content"]}
def clear_named_slot(args: dict) -> dict:
bp = _load_widget_bp(args["bp_path"])
ok = bool(_lib().clear_named_slot_content(bp, args["host"], args["slot"]))
_save(bp)
return {"ok": ok, "host": args["host"], "slot": args["slot"]}
def set_desired_focus(args: dict) -> dict:
bp = _load_widget_bp(args["bp_path"])
ok = bool(_lib().set_desired_focus_widget(bp, args["widget"]))
_save(bp)
return {"ok": ok, "widget": args["widget"]}
def apply_tree(args: dict) -> dict:
bp = _load_widget_bp(args["bp_path"])
warnings = []
created = []
if args.get("clear_existing"):
tree = _json(_lib().describe_widget_tree_json(bp))
root = tree.get("root")
if root:
_lib().remove_widget_from_tree(bp, root)
for spec in args.get("widgets") or []:
cls = _load_widget_class(spec["class"])
name = _lib().add_widget_to_tree(
bp,
cls,
spec.get("name") or "",
spec.get("parent") or "",
int(spec.get("index", -1)),
bool(spec.get("is_variable", True)),
)
if not name:
warnings.append(f"add failed: {spec.get('name') or spec.get('class')}")
continue
created.append(name)
warnings.extend(_set_props(bp, name, spec.get("properties") or {}))
if spec.get("canvas_slot") and not _set_canvas_slot(bp, name, spec["canvas_slot"]):
warnings.append(f"set_canvas_slot failed: {name}")
if args.get("desired_focus"):
if not _lib().set_desired_focus_widget(bp, args["desired_focus"]):
warnings.append(f"set_desired_focus failed: {args['desired_focus']}")
_save(bp)
return {"ok": not warnings, "created": created, "warnings": warnings, "tree": _json(_lib().describe_widget_tree_json(bp))}
def compile_save(args: dict) -> dict:
bp = _load_widget_bp(args["bp_path"])
_save(bp)
return {"ok": True, "bp_path": args["bp_path"]}
def create_animation(args: dict) -> dict:
"""Create a UMG WidgetAnimation that keys one float property on a widget.
{bp_path, name, widget, property?='RenderOpacity', times?, values?}
times/values are parallel lists (seconds / value). Omit both for a default
blink pulse (1.0 -> 0.12 -> 1.0 over 0.9s). Loop is set at PlayAnimation time
(NumLoopsToPlay=0), not stored on the animation.
"""
bp = _load_widget_bp(args["bp_path"])
name = args["name"]
widget = args["widget"]
prop = args.get("property") or "RenderOpacity"
times = args.get("times")
values = args.get("values")
if isinstance(times, str):
times = json.loads(times)
if isinstance(values, str):
values = json.loads(values)
if not times or not values:
times = [0.0, 0.45, 0.9]
values = [1.0, 0.12, 1.0]
if len(times) != len(values):
return {"ok": False, "error": "times and values must be the same length"}
times = [float(t) for t in times]
values = [float(v) for v in values]
ok = bool(_lib().create_widget_float_animation(bp, name, widget, prop, times, values))
if ok:
_save(bp)
return {"ok": ok, "name": name, "widget": widget, "property": prop,
"keys": list(zip(times, values))}
def screenshot(args: dict) -> dict:
"""Render a WidgetBlueprint to a PNG file. Vision loop for AI iteration.
Strategy stack (first that works wins):
1) unreal.WidgetRenderer -> RenderTarget -> ExportRenderTarget (clean, widget-only)
2) Open the WBP in the UMG editor and use HighResShot (captures editor chrome)
Args:
bp_path: /Game/.../WBP_Foo
size: [w, h] default [1920, 1080]
out_dir: project-relative folder under Saved/ default 'MCP/widgets'
Returns:
{ ok, png_path, file_url, strategy }
"""
bp_path = args["bp_path"]
bp = _load_widget_bp(bp_path)
if bp is None:
return {"ok": False, "error": "widget bp not found"}
size = args.get("size") or [1920, 1080]
w, h = int(size[0]), int(size[1])
proj_saved = unreal.Paths.project_saved_dir()
out_subdir = (args.get("out_dir") or "MCP/widgets").strip("/")
out_dir = proj_saved + out_subdir + "/"
os.makedirs(out_dir, exist_ok=True)
safe_name = bp_path.rsplit("/", 1)[-1]
png_name = f"{safe_name}.png"
# ---- Strategy 0: native C++ helper (best — pure widget, no editor chrome) ----
if hasattr(unreal, "MCPWidgetRenderLibrary"):
try:
widget_class = bp.generated_class()
ok, full_path, err = unreal.MCPWidgetRenderLibrary.render_widget_to_png(
widget_class, out_dir.rstrip("/").replace("/", os.sep), safe_name, w, h
)
if ok:
return {"ok": True, "strategy": "MCPWidgetRenderLibrary",
"png_path": full_path,
"file_url": "file:///" + full_path.replace("\\", "/"),
"size": [w, h]}
cpp_err = err or "unknown C++ error"
except Exception as e:
cpp_err = str(e)
else:
cpp_err = "MCPWidgetRenderLibrary not exposed — Live-Coding compile the plugin"
# ---- Strategy 1: WidgetRenderer (if exposed) ----
if hasattr(unreal, "WidgetRenderer"):
try:
world = unreal.EditorLevelLibrary.get_editor_world() if hasattr(unreal, "EditorLevelLibrary") else None
widget_class = bp.generated_class()
instance = unreal.create_widget(world, widget_class) if world else None
if instance is None:
raise RuntimeError("create_widget returned None")
# Transient render target
rt_factory = getattr(unreal, "TextureRenderTargetFactoryNew", None)
if rt_factory is None:
raise RuntimeError("TextureRenderTargetFactoryNew unavailable")
rt = unreal.AssetToolsHelpers.get_asset_tools().create_asset(
f"RT_MCP_{safe_name}", "/Game/_MCPTransient", unreal.TextureRenderTarget2D, rt_factory()
)
rt.set_editor_property("size_x", w)
rt.set_editor_property("size_y", h)
rt.set_editor_property("render_target_format", unreal.TextureRenderTargetFormat.RTF_RGBA8)
renderer = unreal.WidgetRenderer()
renderer.draw_widget(instance, rt, 0.016)
unreal.RenderingLibrary.export_render_target(world, rt, out_dir, png_name)
# Cleanup transient RT (best effort)
try:
unreal.EditorAssetLibrary.delete_asset(rt.get_path_name())
except Exception:
pass
full = out_dir + png_name
return {"ok": True, "strategy": "WidgetRenderer", "png_path": full,
"file_url": "file:///" + full.replace("\\", "/"),
"size": [w, h]}
except Exception as e:
renderer_err = str(e)
else:
renderer_err = "WidgetRenderer not exposed in this UE build"
# ---- Strategy 2: open in UMG editor + HighResShot ----
try:
aes = unreal.get_editor_subsystem(unreal.AssetEditorSubsystem)
aes.open_editor_for_assets([bp])
# Force a screenshot of the editor window
world = unreal.EditorLevelLibrary.get_editor_world()
cmd = f"HighResShot {w}x{h} {png_name}"
unreal.SystemLibrary.execute_console_command(world, cmd)
# UE saves under Saved/Screenshots/Windows/<png_name>
guessed = proj_saved + f"Screenshots/Windows/{png_name}"
return {"ok": True, "strategy": "HighResShot_editor_chrome",
"warning": f"widget-only render failed: {renderer_err}. Editor chrome included.",
"png_path": guessed,
"file_url": "file:///" + guessed.replace("\\", "/"),
"size": [w, h]}
except Exception as e:
return {"ok": False, "error": f"all strategies failed; cpp={cpp_err}; renderer={renderer_err}; screenshot={e}"}
OPS = {
"describe": describe,
"get_props": get_props,
"get_slot_props": get_slot_props,
"add_widget": add_widget,
"remove_widget": remove_widget,
"rename_widget": rename_widget,
"move_widget": move_widget,
"set_root": set_root,
"set_is_variable": set_is_variable,
"set_property": set_property,
"set_properties": set_properties,
"set_slot_property": set_slot_property,
"set_canvas_slot": set_canvas_slot,
"set_named_slot": set_named_slot,
"clear_named_slot": clear_named_slot,
"set_desired_focus": set_desired_focus,
"apply_tree": apply_tree,
"compile_save": compile_save,
"create_animation": create_animation,
"screenshot": screenshot,
}
def entrypoint(payload_json: str) -> str:
try:
p = json.loads(payload_json)
except Exception as e: # noqa: BLE001
return _tee({"ok": False, "error": f"bad json: {e}"}, {})
fn = OPS.get(p.get("op"))
if not fn:
return _tee({"ok": False, "error": f"unknown op {p.get('op')!r}", "available": list(OPS)}, p)
try:
return _tee(fn(p), p)
except Exception as e: # noqa: BLE001
return _tee({"ok": False, "error": str(e), "traceback": traceback.format_exc()}, p)
def _tee(result: dict, p: dict) -> str:
txt = json.dumps(result)
try:
if unreal is not None:
sd = unreal.Paths.project_saved_dir() + "MCP/"
os.makedirs(sd, exist_ok=True)
rid = p.get("__rid") or "last"
with open(sd + "last.json", "w", encoding="utf-8") as f:
f.write(txt)
if rid != "last":
with open(sd + f"{rid}.json", "w", encoding="utf-8") as f:
f.write(txt)
unreal.log("[MCP-WIDGET] " + txt[:1500])
except Exception:
pass
return txt