"""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