Add empty test level and Unreal MCP tooling
- L_TestFlight: empty, zero-gravity level for Phase 0 flight testing, set as editor/game default map. - Enable UnrealMCPython + Python Editor Script Plugin, third-party MCP plugin (GenOrca/unreal-mcp v2.2.0) giving direct editor control (actors, blueprints, levels, materials, etc.) alongside Epic's built-in Unreal MCP plugin. - Vendor the mcp-server Python source used to bridge to the plugin. - .mcp.json intentionally gitignored (machine-specific absolute paths).
This commit is contained in:
153
mcp-server/generate_catalog.py
Normal file
153
mcp-server/generate_catalog.py
Normal file
@@ -0,0 +1,153 @@
|
||||
#!/usr/bin/env python3
|
||||
# Copyright (c) 2025 GenOrca. All Rights Reserved.
|
||||
|
||||
"""
|
||||
Generates the dispatcher action catalog from the plugin's ue_* function signatures.
|
||||
|
||||
The catalog is the single source of truth that list_actions exposes to the LLM.
|
||||
It MUST match the real ue_* signatures exactly, because the dispatcher passes
|
||||
params straight through as ue_func(**params). Hand-transcribing signatures is
|
||||
error-prone, so we extract them via AST instead.
|
||||
|
||||
Usage:
|
||||
python generate_catalog.py # writes src/unreal_mcp/dispatchers/_catalog.py
|
||||
python generate_catalog.py --check # exits 1 if the committed catalog is stale
|
||||
|
||||
Run with --check in CI / validate_tools to catch drift.
|
||||
"""
|
||||
|
||||
import ast
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
MCP_SERVER_DIR = Path(__file__).parent
|
||||
PLUGIN_DIR = MCP_SERVER_DIR.parent / "Plugins" / "UnrealMCPython" / "Content" / "Python" / "UnrealMCPython"
|
||||
OUTPUT = MCP_SERVER_DIR / "src" / "unreal_mcp" / "dispatchers" / "_catalog.py"
|
||||
|
||||
# Domain → plugin action file (module is UnrealMCPython.<file stem>)
|
||||
DOMAINS = [
|
||||
"actor",
|
||||
"anim_blueprint",
|
||||
"animation",
|
||||
"asset",
|
||||
"behavior_tree",
|
||||
"blueprint",
|
||||
"control_rig",
|
||||
"data_table",
|
||||
"editor",
|
||||
"game",
|
||||
"gas",
|
||||
"layer",
|
||||
"level",
|
||||
"level_sequence",
|
||||
"material",
|
||||
"retarget",
|
||||
"static_mesh",
|
||||
"texture",
|
||||
"umg",
|
||||
"util",
|
||||
"vision",
|
||||
]
|
||||
|
||||
# Actions handled by the dispatcher but NOT backed by a ue_* function
|
||||
# (special TCP message types). Documented manually so list_actions shows them.
|
||||
EXTRA_ACTIONS = {
|
||||
"util": {
|
||||
"execute_python": {
|
||||
"params": "code",
|
||||
"doc": "Runs arbitrary Unreal Python code. Full API access; fastest path to prototype new actions.",
|
||||
},
|
||||
"livecoding_compile": {
|
||||
"params": "",
|
||||
"doc": "Triggers C++ Live Coding and waits for the compile result.",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
def _params(fn: ast.FunctionDef) -> str:
|
||||
a = fn.args
|
||||
defaults = a.defaults
|
||||
pad = len(a.args) - len(defaults)
|
||||
parts = []
|
||||
for i, arg in enumerate(a.args):
|
||||
if i >= pad:
|
||||
dv = defaults[i - pad]
|
||||
try:
|
||||
val = ast.literal_eval(dv)
|
||||
except Exception:
|
||||
val = "..."
|
||||
# default None means "required but validated inside" → show name only
|
||||
parts.append(arg.arg if val is None else f"{arg.arg}={val!r}")
|
||||
else:
|
||||
parts.append(arg.arg)
|
||||
return ", ".join(parts)
|
||||
|
||||
|
||||
def _doc(fn: ast.FunctionDef) -> str:
|
||||
d = ast.get_docstring(fn)
|
||||
return d.splitlines()[0].strip() if d else ""
|
||||
|
||||
|
||||
def _extract(domain: str) -> dict:
|
||||
f = PLUGIN_DIR / f"{domain}_actions.py"
|
||||
actions: dict[str, dict] = {}
|
||||
if f.exists():
|
||||
tree = ast.parse(f.read_text(encoding="utf-8"))
|
||||
for n in tree.body:
|
||||
if isinstance(n, ast.FunctionDef) and n.name.startswith("ue_"):
|
||||
action = n.name[3:] # strip ue_
|
||||
actions[action] = {"params": _params(n), "doc": _doc(n)}
|
||||
actions.update(EXTRA_ACTIONS.get(domain, {}))
|
||||
return dict(sorted(actions.items()))
|
||||
|
||||
|
||||
def build() -> dict:
|
||||
return {d: _extract(d) for d in DOMAINS}
|
||||
|
||||
|
||||
def render(catalog: dict) -> str:
|
||||
lines = [
|
||||
"# Copyright (c) 2025 GenOrca. All Rights Reserved.",
|
||||
"#",
|
||||
"# AUTO-GENERATED by generate_catalog.py — do not edit by hand.",
|
||||
"# Regenerate: python generate_catalog.py",
|
||||
"",
|
||||
"CATALOG = {",
|
||||
]
|
||||
for domain, actions in catalog.items():
|
||||
lines.append(f" {domain!r}: {{")
|
||||
for action, info in actions.items():
|
||||
lines.append(f" {action!r}: {{")
|
||||
lines.append(f" {'params'!r}: {info['params']!r},")
|
||||
lines.append(f" {'doc'!r}: {info['doc']!r},")
|
||||
lines.append(" },")
|
||||
lines.append(" },")
|
||||
lines.append("}")
|
||||
lines.append("")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def main():
|
||||
catalog = build()
|
||||
rendered = render(catalog)
|
||||
|
||||
if "--check" in sys.argv:
|
||||
if not OUTPUT.exists():
|
||||
print("FAIL: _catalog.py does not exist. Run generate_catalog.py.")
|
||||
sys.exit(1)
|
||||
current = OUTPUT.read_text(encoding="utf-8")
|
||||
if current.strip() != rendered.strip():
|
||||
print("FAIL: _catalog.py is stale. Run: python generate_catalog.py")
|
||||
sys.exit(1)
|
||||
total = sum(len(a) for a in catalog.values())
|
||||
print(f"OK: catalog in sync ({total} actions across {len(catalog)} domains).")
|
||||
sys.exit(0)
|
||||
|
||||
OUTPUT.write_text(rendered, encoding="utf-8")
|
||||
total = sum(len(a) for a in catalog.values())
|
||||
print(f"Wrote {OUTPUT} ({total} actions across {len(catalog)} domains).")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user