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