Files
Bonchellon eba71c4ca8 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>
2026-06-24 01:07:24 +03:00

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