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>
431 lines
14 KiB
Python
431 lines
14 KiB
Python
"""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
|