Files
spacegame/mcp-server/generate_catalog.py
Joshua Deville 496320cc8c 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).
2026-07-08 14:56:57 -04:00

154 lines
4.7 KiB
Python

#!/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()