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:
271
ue_side/cpp_scaffold.py
Normal file
271
ue_side/cpp_scaffold.py
Normal file
@ -0,0 +1,271 @@
|
||||
"""C++ class scaffold — generate .h/.cpp templates under a project module.
|
||||
|
||||
This does NOT compile the code — pair with live_coding.compile or a regular
|
||||
hot-reload after the files appear. UE will pick up new files on next build.
|
||||
|
||||
Ops:
|
||||
list_modules {} — read Source/*/Build.cs
|
||||
create_class {name, parent_class, module?(default: primary game module), subfolder?, header_only?:false, public?:true, api_macro?:auto}
|
||||
list_classes {module?, subfolder?} — list .h files under a module
|
||||
read_class {name, module?} — return .h+.cpp text
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import traceback
|
||||
|
||||
try:
|
||||
import unreal # type: ignore
|
||||
except ImportError:
|
||||
unreal = None
|
||||
|
||||
|
||||
def _project_dir():
|
||||
if unreal is not None:
|
||||
return unreal.Paths.convert_relative_path_to_full(unreal.Paths.project_dir()).rstrip("/\\")
|
||||
return os.getcwd()
|
||||
|
||||
|
||||
def _source_dir():
|
||||
return os.path.join(_project_dir(), "Source")
|
||||
|
||||
|
||||
def _project_name():
|
||||
"""Project name = the .uproject base name (also the conventional primary module)."""
|
||||
if unreal is not None:
|
||||
try:
|
||||
up = unreal.Paths.get_project_file_path() # e.g. .../MyGame.uproject
|
||||
return os.path.splitext(os.path.basename(up))[0]
|
||||
except Exception:
|
||||
pass
|
||||
return os.path.basename(_project_dir().rstrip("/\\"))
|
||||
|
||||
|
||||
def _default_module():
|
||||
"""Primary game module to scaffold into. Prefer the Runtime module declared in
|
||||
the .uproject; fall back to the module whose folder matches the project name;
|
||||
else the first Source module; else the project name."""
|
||||
proj = _project_name()
|
||||
# 1. Read the .uproject Modules list for a Runtime/Game module.
|
||||
try:
|
||||
up = unreal.Paths.get_project_file_path() if unreal is not None else None
|
||||
if up and os.path.isfile(up):
|
||||
with open(up, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
mods = data.get("Modules") or []
|
||||
runtime = [m for m in mods if str(m.get("Type", "")).lower() in ("runtime", "")]
|
||||
if runtime:
|
||||
return runtime[0].get("Name") or proj
|
||||
if mods:
|
||||
return mods[0].get("Name") or proj
|
||||
except Exception:
|
||||
pass
|
||||
# 2. Folder matching the project name.
|
||||
src = _source_dir()
|
||||
if os.path.isdir(os.path.join(src, proj)):
|
||||
return proj
|
||||
# 3. First Source module with a Build.cs.
|
||||
try:
|
||||
for name in sorted(os.listdir(src)):
|
||||
if os.path.isfile(os.path.join(src, name, f"{name}.Build.cs")):
|
||||
return name
|
||||
except Exception:
|
||||
pass
|
||||
return proj
|
||||
|
||||
|
||||
def list_modules(args):
|
||||
out = []
|
||||
src = _source_dir()
|
||||
if not os.path.isdir(src):
|
||||
return {"ok": False, "error": f"no Source dir at {src}"}
|
||||
for name in os.listdir(src):
|
||||
p = os.path.join(src, name)
|
||||
if os.path.isdir(p):
|
||||
build_cs = os.path.join(p, f"{name}.Build.cs")
|
||||
if os.path.isfile(build_cs):
|
||||
out.append({"name": name, "build_cs": build_cs,
|
||||
"has_public": os.path.isdir(os.path.join(p, "Public")),
|
||||
"has_private": os.path.isdir(os.path.join(p, "Private"))})
|
||||
return {"ok": True, "modules": out}
|
||||
|
||||
|
||||
def _api_macro(module):
|
||||
return f"{module.upper()}_API"
|
||||
|
||||
|
||||
_HEADER_TPL = """// Generated by UEBlueprintMCP cpp_scaffold.
|
||||
#pragma once
|
||||
|
||||
#include "CoreMinimal.h"
|
||||
#include "{base_header}"
|
||||
#include "{name}.generated.h"
|
||||
|
||||
UCLASS()
|
||||
class {api} {name} : public {parent}
|
||||
{{
|
||||
\tGENERATED_BODY()
|
||||
|
||||
public:
|
||||
\t{name}();
|
||||
}};
|
||||
"""
|
||||
|
||||
_CPP_TPL = """// Generated by UEBlueprintMCP cpp_scaffold.
|
||||
#include "{include_path}{name}.h"
|
||||
|
||||
{name}::{name}()
|
||||
{{
|
||||
\tPrimaryActorTick.bCanEverTick = false;
|
||||
}}
|
||||
"""
|
||||
|
||||
|
||||
# Map of common parents -> include they need
|
||||
_PARENT_HEADERS = {
|
||||
"AActor": "GameFramework/Actor.h",
|
||||
"APawn": "GameFramework/Pawn.h",
|
||||
"ACharacter": "GameFramework/Character.h",
|
||||
"APlayerController": "GameFramework/PlayerController.h",
|
||||
"AGameModeBase": "GameFramework/GameModeBase.h",
|
||||
"AAIController": "AIController.h",
|
||||
"UObject": "UObject/NoExportTypes.h",
|
||||
"UActorComponent": "Components/ActorComponent.h",
|
||||
"USceneComponent": "Components/SceneComponent.h",
|
||||
"UPrimitiveComponent": "Components/PrimitiveComponent.h",
|
||||
"UStaticMeshComponent": "Components/StaticMeshComponent.h",
|
||||
"USkeletalMeshComponent":"Components/SkeletalMeshComponent.h",
|
||||
"UAnimInstance": "Animation/AnimInstance.h",
|
||||
"UUserWidget": "Blueprint/UserWidget.h",
|
||||
"UDataAsset": "Engine/DataAsset.h",
|
||||
"UInterface": "UObject/Interface.h",
|
||||
}
|
||||
|
||||
|
||||
def create_class(args):
|
||||
name = args["name"]
|
||||
parent = args["parent_class"] # e.g. AActor / UObject / UActorComponent
|
||||
module = args.get("module") or _default_module()
|
||||
subfolder = (args.get("subfolder") or "").replace("\\", "/").strip("/")
|
||||
header_only = bool(args.get("header_only"))
|
||||
public = bool(args.get("public", True))
|
||||
|
||||
if not re.match(r"^[AU][A-Z]\w+$", name):
|
||||
return {"ok": False, "error": "class name must start with A or U followed by PascalCase (e.g. AMyActor, UMyComp)"}
|
||||
|
||||
src_mod = os.path.join(_source_dir(), module)
|
||||
if not os.path.isdir(src_mod):
|
||||
return {"ok": False, "error": f"module not found: {module}"}
|
||||
|
||||
pub_dir = os.path.join(src_mod, "Public", subfolder) if public else os.path.join(src_mod, subfolder)
|
||||
priv_dir = os.path.join(src_mod, "Private", subfolder) if public else os.path.join(src_mod, subfolder)
|
||||
os.makedirs(pub_dir, exist_ok=True)
|
||||
if not header_only:
|
||||
os.makedirs(priv_dir, exist_ok=True)
|
||||
|
||||
base_header = args.get("base_header") or _PARENT_HEADERS.get(parent, "CoreMinimal.h")
|
||||
api = args.get("api_macro") or _api_macro(module)
|
||||
|
||||
h_path = os.path.join(pub_dir, f"{name}.h")
|
||||
c_path = os.path.join(priv_dir, f"{name}.cpp")
|
||||
|
||||
if os.path.exists(h_path):
|
||||
return {"ok": False, "error": f"file exists: {h_path}"}
|
||||
|
||||
h_txt = _HEADER_TPL.format(name=name, parent=parent, api=api, base_header=base_header)
|
||||
with open(h_path, "w", encoding="utf-8", newline="\n") as f:
|
||||
f.write(h_txt)
|
||||
out = {"ok": True, "header": h_path}
|
||||
|
||||
if not header_only:
|
||||
include_rel = (subfolder + "/") if subfolder else ""
|
||||
c_txt = _CPP_TPL.format(name=name, include_path=include_rel)
|
||||
with open(c_path, "w", encoding="utf-8", newline="\n") as f:
|
||||
f.write(c_txt)
|
||||
out["cpp"] = c_path
|
||||
|
||||
out["note"] = "Files written. Run live_coding.compile or rebuild from VS to register the class."
|
||||
return out
|
||||
|
||||
|
||||
def list_classes(args):
|
||||
module = args.get("module") or _default_module()
|
||||
subfolder = (args.get("subfolder") or "").replace("\\", "/").strip("/")
|
||||
mod_root = os.path.join(_source_dir(), module)
|
||||
if not os.path.isdir(mod_root):
|
||||
return {"ok": False, "error": f"module not found: {mod_root}"}
|
||||
scan_root = os.path.join(mod_root, subfolder) if subfolder else mod_root
|
||||
out = []
|
||||
for dirpath, _, fnames in os.walk(scan_root):
|
||||
for fn in fnames:
|
||||
if fn.endswith(".h"):
|
||||
full = os.path.join(dirpath, fn)
|
||||
rel = os.path.relpath(full, mod_root).replace("\\", "/")
|
||||
# Skip the module's own header (e.g. <Module>.h)
|
||||
if rel == f"{module}.h":
|
||||
continue
|
||||
out.append(rel)
|
||||
return {"ok": True, "module": module, "headers": sorted(out), "count": len(out)}
|
||||
|
||||
|
||||
def read_class(args):
|
||||
module = args.get("module") or _default_module()
|
||||
name = args["name"]
|
||||
found = {"header": None, "cpp": None}
|
||||
for dirpath, _, fnames in os.walk(os.path.join(_source_dir(), module)):
|
||||
for fn in fnames:
|
||||
if fn == f"{name}.h":
|
||||
found["header"] = os.path.join(dirpath, fn)
|
||||
elif fn == f"{name}.cpp":
|
||||
found["cpp"] = os.path.join(dirpath, fn)
|
||||
out = {"ok": True}
|
||||
for k, p in found.items():
|
||||
if p and os.path.isfile(p):
|
||||
try:
|
||||
with open(p, "r", encoding="utf-8") as f:
|
||||
out[k] = {"path": p, "content": f.read()}
|
||||
except Exception as e:
|
||||
out[k] = {"path": p, "error": str(e)}
|
||||
if not (found["header"] or found["cpp"]):
|
||||
return {"ok": False, "error": f"class {name} not found under Source/{module}"}
|
||||
return out
|
||||
|
||||
|
||||
OPS = {
|
||||
"list_modules": list_modules,
|
||||
"create_class": create_class,
|
||||
"list_classes": list_classes,
|
||||
"read_class": read_class,
|
||||
}
|
||||
|
||||
|
||||
def entrypoint(payload_json):
|
||||
try:
|
||||
p = json.loads(payload_json)
|
||||
except Exception as e:
|
||||
return _tee({"ok": False, "error": f"bad json: {e}"}, {})
|
||||
op = p.get("op"); fn = OPS.get(op)
|
||||
if not fn:
|
||||
return _tee({"ok": False, "error": f"unknown op {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, p):
|
||||
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-CPP] " + txt[:1500])
|
||||
except Exception:
|
||||
pass
|
||||
return txt
|
||||
Reference in New Issue
Block a user