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