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:
430
ue_side/voxel_graph.py
Normal file
430
ue_side/voxel_graph.py
Normal 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
|
||||
Reference in New Issue
Block a user