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:
Joshua Deville
2026-07-08 14:56:57 -04:00
parent 43494a614b
commit 496320cc8c
82 changed files with 19577 additions and 1 deletions

View File

@@ -0,0 +1,45 @@
# Copyright (c) 2025 GenOrca. All Rights Reserved.
"""Unit tests for the TCP response unwrapping (offline, no Unreal)."""
from unreal_mcp.core import _unwrap_result
def test_unwraps_action_dict_from_result_string():
wire = {
"success": True, # python-exec success, NOT action success
"message": "Python command executed successfully.",
"result": '{"success": true, "actor_label": "PointLight_2"}',
}
out = _unwrap_result(wire)
assert out == {"success": True, "actor_label": "PointLight_2"}
def test_unwrapped_inner_failure_is_surfaced():
"""Action failure (inner success=False) must survive unwrapping as data."""
wire = {
"success": True, # python ran fine...
"result": '{"success": false, "message": "Actor not found"}',
}
out = _unwrap_result(wire)
assert out["success"] is False
assert out["message"] == "Actor not found"
def test_unwraps_list_result():
wire = {"success": True, "result": '[1, 2, 3]'}
assert _unwrap_result(wire) == [1, 2, 3]
def test_non_json_result_returns_original():
wire = {"success": True, "result": "not json at all"}
assert _unwrap_result(wire) == wire
def test_missing_result_returns_original():
wire = {"success": True, "message": "ok"}
assert _unwrap_result(wire) == wire
def test_non_dict_passthrough():
assert _unwrap_result("raw") == "raw"

View File

@@ -0,0 +1,88 @@
# Copyright (c) 2025 GenOrca. All Rights Reserved.
"""
Action coverage enforcement (offline, no Unreal needed).
Every catalog action must be exercised by an in-editor unittest
(Plugins/.../tests/test_<domain>.py references "ue_<action>"), OR be listed
in KNOWN_UNTESTED below as acknowledged technical debt.
Why: the dispatcher auto-exposes any ue_* function. Without this gate, a new
action could ship with zero behavior test and all other gates stay green.
This makes "add an action without a test" a conscious, reviewable choice
(you must edit KNOWN_UNTESTED) rather than a silent gap.
KNOWN_UNTESTED is debt to shrink, not to grow. The stale-entry check fails if
an allowlisted action becomes tested or disappears, so the list self-cleans.
"""
import re
from pathlib import Path
import pytest
from unreal_mcp.dispatchers._catalog import CATALOG
PLUGIN_TESTS = (
Path(__file__).resolve().parents[2]
/ "Plugins" / "UnrealMCPython" / "Content" / "Python" / "UnrealMCPython" / "tests"
)
# Actions not routed as ue_<action> over TCP — covered by mcp-server pytest instead.
SPECIAL = {"util": {"execute_python", "livecoding_compile"}}
# ── Technical debt: actions with no in-editor behavior test yet. SHRINK over time. ──
# Adding a new action? Write a test in test_<domain>.py instead of adding it here.
KNOWN_UNTESTED: dict[str, set[str]] = {
# PIE start/stop change the editor play mode asynchronously; running them in the
# headless suite (which never ticks between calls) would leave the editor in PIE.
# Verified manually through the MCP chain instead.
"util": {"start_pie", "stop_pie"},
# save_current_level can raise a modal Save-As dialog on an untitled level,
# which would hang the headless suite; save_all_levels is grouped with it.
# Verified manually instead.
"level": {"save_current_level", "save_all_levels"},
}
def _referenced(domain: str) -> set[str]:
tf = PLUGIN_TESTS / f"test_{domain}.py"
if not tf.exists():
return set()
return set(re.findall(r"ue_(\w+)", tf.read_text(encoding="utf-8")))
def _allowed(domain: str) -> set[str]:
return SPECIAL.get(domain, set()) | KNOWN_UNTESTED.get(domain, set())
@pytest.mark.parametrize("domain", sorted(CATALOG))
def test_every_action_is_tested_or_allowlisted(domain):
referenced = _referenced(domain)
untested = [
a for a in CATALOG[domain]
if a not in referenced and a not in _allowed(domain)
]
assert not untested, (
f"{domain}: these actions have no in-editor test and are not allowlisted: "
f"{untested}. Add a test in test_{domain}.py, or (consciously) add them to "
f"KNOWN_UNTESTED in test_coverage.py."
)
def test_no_stale_allowlist_entries():
"""KNOWN_UNTESTED must not contain actions that are now tested or no longer exist."""
stale = []
for domain, actions in KNOWN_UNTESTED.items():
referenced = _referenced(domain)
for a in actions:
if a not in CATALOG.get(domain, {}):
stale.append(f"{domain}.{a} (not in catalog)")
elif a in referenced:
stale.append(f"{domain}.{a} (now tested — remove from allowlist)")
assert not stale, f"Stale KNOWN_UNTESTED entries: {stale}"
def test_plugin_tests_dir_exists():
"""Guard: if the layout moves, fail loudly instead of silently passing coverage."""
assert PLUGIN_TESTS.is_dir(), f"Plugin tests dir not found: {PLUGIN_TESTS}"

View File

@@ -0,0 +1,177 @@
# Copyright (c) 2025 GenOrca. All Rights Reserved.
"""
Offline routing tests for the namespace dispatcher.
These do NOT need Unreal running. They mock the TCP layer (send_to_unreal /
send_python_exec / send_livecoding_compile) and assert that each domain tool
routes (action, params) to the correct module + ue_<action> + params.
Run:
cd mcp-server && uv run pytest
"""
import asyncio
import importlib
import pytest
import unreal_mcp.dispatcher as disp
from unreal_mcp.dispatchers._catalog import CATALOG
def run(coro):
return asyncio.run(coro)
@pytest.fixture
def recorder(monkeypatch):
"""Replace send_to_unreal with an async recorder; return the call log."""
calls = []
async def fake_send_to_unreal(module, fn, params):
calls.append((module, fn, params))
return {"success": True, "echo": {"module": module, "fn": fn, "params": params}}
monkeypatch.setattr(disp, "send_to_unreal", fake_send_to_unreal)
return calls
# ─── entrypoint smoke ─────────────────────────────────────────────────────────
def test_entrypoints_import():
"""main.py and server.py must import cleanly (catches removed public names)."""
import unreal_mcp.server as server
import unreal_mcp.main as main
assert hasattr(server, "main_mcp")
assert hasattr(server, "run_server")
assert callable(main.main)
# ─── tool registration ────────────────────────────────────────────────────────
def test_domain_tools_match_catalog():
"""Exactly one MCP tool per catalog domain — no more, no less."""
tools = disp.dispatcher_mcp._tool_manager.list_tools()
names = sorted(t.name for t in tools)
assert names == sorted(CATALOG.keys())
# ─── standard routing ─────────────────────────────────────────────────────────
def test_standard_routing_module_fn_params(recorder):
result = run(disp._dispatch("actor", "set_location",
{"actor_label": "Cube", "location": [0, 0, 100]}))
assert result["success"] is True
assert recorder == [
("UnrealMCPython.actor_actions", "ue_set_location",
{"actor_label": "Cube", "location": [0, 0, 100]})
]
def _standard_action_pairs():
"""Every (domain, action) routed via the standard path (excludes hand-written domains)."""
pairs = []
for domain in CATALOG:
if domain in ("util", "vision"):
continue # special-cased handlers
for action in CATALOG[domain]:
pairs.append((domain, action))
return pairs
@pytest.mark.parametrize("domain,action", _standard_action_pairs())
def test_every_catalog_action_routes(domain, action, recorder):
"""Full coverage: each catalog action routes to its module + ue_<action>."""
run(disp._dispatch(domain, action, {}))
module, fn, _ = recorder[0]
assert module == f"UnrealMCPython.{domain}_actions"
assert fn == f"ue_{action}"
def test_params_passed_through_untouched(recorder):
payload = {"asset_path": "/Game/BP", "graph_name": "EventGraph", "node_json": {"type": "Branch"}}
run(disp._dispatch("blueprint", "add_blueprint_node", payload))
assert recorder[0][2] == payload # dispatcher must not mutate/translate params
# ─── list_actions ─────────────────────────────────────────────────────────────
def test_list_actions_returns_catalog(recorder):
result = run(disp._dispatch("material", "list_actions", {}))
assert result["success"] is True
assert result["domain"] == "material"
assert result["actions"] == CATALOG["material"]
assert recorder == [] # list_actions must NOT hit the TCP layer
def test_list_actions_entries_have_params_and_doc():
for domain, actions in CATALOG.items():
for action, info in actions.items():
assert "params" in info and "doc" in info, f"{domain}.{action} missing keys"
# ─── error handling ───────────────────────────────────────────────────────────
def test_unknown_action_errors_without_tcp(recorder):
result = run(disp._dispatch("actor", "fly_to_the_moon", {}))
assert result["success"] is False
assert "Unknown action" in result["message"]
assert recorder == [] # never reaches the TCP layer
# ─── util special routing ─────────────────────────────────────────────────────
def test_util_execute_python(monkeypatch):
seen = {}
async def fake_exec(code):
seen["code"] = code
return {"success": True}
monkeypatch.setattr(disp, "send_python_exec", fake_exec)
result = run(disp.util.fn(action="execute_python", params={"code": "import unreal"}))
assert result["success"] is True
assert seen["code"] == "import unreal"
def test_util_execute_python_requires_code(monkeypatch):
async def fake_exec(code):
raise AssertionError("should not be called when code is missing")
monkeypatch.setattr(disp, "send_python_exec", fake_exec)
result = run(disp.util.fn(action="execute_python", params={}))
assert result["success"] is False
assert "code is required" in result["message"]
def test_util_livecoding_compile(monkeypatch):
called = {"n": 0}
async def fake_compile():
called["n"] += 1
return {"success": True}
monkeypatch.setattr(disp, "send_livecoding_compile", fake_compile)
result = run(disp.util.fn(action="livecoding_compile", params={}))
assert result["success"] is True
assert called["n"] == 1
def test_util_get_output_log_routes_to_ue_function(recorder):
run(disp.util.fn(action="get_output_log", params={"line_count": 20}))
assert recorder == [
("UnrealMCPython.util_actions", "ue_get_output_log", {"line_count": 20})
]
def test_util_list_actions(recorder):
result = run(disp.util.fn(action="list_actions", params={}))
assert result["actions"] == CATALOG["util"]
assert recorder == []
def test_util_unknown_action(recorder):
result = run(disp.util.fn(action="nope", params={}))
assert result["success"] is False
assert "Unknown action" in result["message"]

View File

@@ -0,0 +1,222 @@
# Copyright (c) 2025 GenOrca. All Rights Reserved.
"""
End-to-end tests: MCP server dispatcher -> real TCP -> C++ server -> ue_* -> back.
Unlike test_dispatcher.py (which mocks the TCP layer), these run the FULL chain
with NO mocks. They prove the actual scenario an MCP client triggers:
routing + socket round-trip + C++ dispatch + response unwrapping.
Requires a running Unreal editor with the UnrealMCPython TCP server on :12029.
If the port is not reachable, the whole module is skipped (so CI / offline runs
stay green; run locally with the editor open to exercise these).
Run:
cd mcp-server && uv run --extra dev pytest tests/test_e2e.py -v
"""
import asyncio
import socket
import pytest
import unreal_mcp.dispatcher as disp
from unreal_mcp.dispatchers._catalog import CATALOG
HOST, PORT = "127.0.0.1", 12029
# Actions excluded from the exhaustive empty-param round-trip:
# execute_python — needs {code}; empty-param hits a dispatcher guard, not TCP
# livecoding_compile — triggers a real C++ compile (slow, side-effecting)
# start_pie/stop_pie — change editor PIE mode (no params to guard); would leave
# the editor in PIE during the sweep
_EXCLUDE = {
("util", "execute_python"),
("util", "livecoding_compile"),
("util", "start_pie"),
("util", "stop_pie"),
# save_current_level can raise a modal Save-As dialog on an untitled level.
("level", "save_current_level"),
("level", "save_all_levels"),
# vision returns an MCP Image (not a dict) — covered by a dedicated E2E test below.
("vision", "capture_viewport"),
}
def _editor_reachable() -> bool:
try:
with socket.create_connection((HOST, PORT), timeout=0.5):
return True
except OSError:
return False
pytestmark = pytest.mark.skipif(
not _editor_reachable(),
reason=f"Unreal TCP server not reachable on {HOST}:{PORT} (open the editor to run E2E).",
)
# Editor-crash guard: if the editor was up when the module started but dies
# mid-suite, every remaining test must FAIL (not skip, and not "pass" via a
# connection-error dict that happens to carry a 'success' key). A green E2E run
# must mean the editor survived the whole sweep — otherwise a `pytest && ...`
# release chain would happily commit/PR on top of a crash.
_EDITOR_WAS_UP = _editor_reachable()
_CONNECTION_ERROR_MARKERS = ("Connection refused", "ConnectionReset", "Socket timeout",
"No response received", "[WinError")
@pytest.fixture(autouse=True)
def _fail_if_editor_crashed():
if _EDITOR_WAS_UP and not _editor_reachable():
pytest.fail(f"Unreal editor crashed during the E2E suite "
f"({HOST}:{PORT} no longer reachable). Investigate before merging.")
yield
def _assert_not_connection_error(r, label):
if isinstance(r, dict):
msg = str(r.get("message", ""))
assert not any(m in msg for m in _CONNECTION_ERROR_MARKERS), \
f"{label}: editor connection lost mid-call: {msg}"
def run(coro):
return asyncio.run(coro)
def test_list_actors_round_trip():
"""A read action proves unwrapping: 'actors' is an INNER field of the action result."""
r = run(disp._dispatch("actor", "list_all_with_locations", {}))
assert r.get("success") is True
assert "actors" in r, f"inner field missing — response not unwrapped: {r}"
assert isinstance(r["actors"], list)
def test_spawn_and_delete_round_trip():
spawn = run(disp._dispatch("actor", "spawn_from_class",
{"class_path": "/Script/Engine.PointLight",
"location": [0, 0, 700]}))
assert spawn.get("success") is True, spawn
label = spawn.get("actor_label")
assert label, f"actor_label missing — not unwrapped: {spawn}"
# cleanup through the same chain
deleted = run(disp._dispatch("actor", "delete_by_label", {"actor_label": label}))
assert deleted.get("success") is True, deleted
def test_inner_action_failure_is_surfaced():
"""Action failure must come back as success=False (not buried in a result string)."""
r = run(disp._dispatch("actor", "set_transform",
{"actor_label": "NoSuchActor_E2E_XYZ", "location": [0, 0, 0]}))
assert r.get("success") is False
assert "message" in r
def test_list_actions_offline_path_still_works():
"""list_actions must not touch TCP even when the editor is up."""
r = run(disp._dispatch("material", "list_actions", {}))
assert r["success"] is True
assert r["domain"] == "material"
def test_execute_python_round_trip():
"""execute_python runs real Unreal Python through the chain."""
r = run(disp.util.fn(action="execute_python",
params={"code": "print('e2e_marker_42')"}))
# send_python_exec returns the raw wrapper; the printed marker is in 'result'.
blob = r.get("result", "") + r.get("message", "")
assert "e2e_marker_42" in blob, f"execute_python did not echo marker: {r}"
def test_vision_capture_returns_image():
"""vision capture_viewport returns an MCP Image (PNG) through the full chain."""
from fastmcp import Image
r = run(disp.vision.fn(action="capture_viewport", params={"width": 320, "height": 180}))
assert isinstance(r, Image), f"expected an Image, got {type(r).__name__}: {r}"
# the PNG bytes should carry a valid signature
data = getattr(r, "data", b"")
assert data[:4] == b"\x89PNG", "capture did not return a PNG"
def test_gltf_import_round_trip():
"""glTF import via the deferred-tick path: export the engine Cube to .glb, import it,
then poll get_gltf_import_status until done. Each dispatch is its own game-thread task,
so the editor ticks between calls and the Interchange async import can run + complete."""
import time
# 1. export /Engine/BasicShapes/Cube to a temp .glb (via execute_python)
export_code = (
"import unreal, os\n"
"glb = os.path.join(unreal.Paths.project_saved_dir(), 'MCP_E2E_gltf.glb').replace(chr(92), '/')\n"
"cube = unreal.EditorAssetLibrary.load_asset('/Engine/BasicShapes/Cube')\n"
"t = unreal.AssetExportTask(); t.object = cube; t.filename = glb\n"
"t.automated = True; t.replace_identical = True; t.prompt = False\n"
"ok = unreal.Exporter.run_asset_export_task(t)\n"
"print('GLBPATH=' + glb if (ok and os.path.isfile(glb)) else 'GLBFAIL')\n"
)
r = run(disp.util.fn(action="execute_python", params={"code": export_code}))
blob = (r.get("result", "") or "") + (r.get("message", "") or "")
assert "GLBPATH=" in blob, f"glb export failed: {blob[:300]}"
glb = blob.split("GLBPATH=", 1)[1].split()[0].strip().strip('"')
dest = "/Game/Tests/MCP_E2E/glb"
try:
imp = run(disp._dispatch("asset", "import_gltf",
{"file_path": glb, "destination_path": dest}))
assert imp.get("success") and imp.get("pending"), f"schedule failed: {imp}"
st = {}
for _ in range(40): # ~20s budget for the async Interchange import
st = run(disp._dispatch("asset", "get_gltf_import_status",
{"destination_path": dest}))
_assert_not_connection_error(st, "get_gltf_import_status")
if st.get("done"):
break
time.sleep(0.5)
assert st.get("success") and st.get("done"), f"import did not complete: {st}"
classes = [a["class"] for a in st["imported_assets"]]
assert "StaticMesh" in classes, f"no StaticMesh among imported: {st}"
finally:
run(disp.util.fn(action="execute_python", params={
"code": f"import unreal; unreal.EditorAssetLibrary.delete_directory('{dest}')"}))
# ── exhaustive: every catalog action survives the full chain ───────────────────
def _all_action_pairs():
pairs = []
for domain, actions in CATALOG.items():
for action in actions:
if (domain, action) in _EXCLUDE:
continue
pairs.append((domain, action))
return pairs
@pytest.mark.parametrize("domain,action", _all_action_pairs())
def test_every_action_round_trips(domain, action):
"""
Drive every action through the real chain with empty params and assert we get
back an unwrapped dict containing 'success'. This proves routing + TCP +
C++ dispatch + result unwrapping work for the action — independent of whether
the action *succeeds* with no args (validation failures still return a dict).
Empty params are safe: execute_action wraps any exception (incl. missing-arg
TypeError) as {"success": false, ...}, and ue_* functions validate required
params before doing work.
"""
if domain == "util":
r = run(disp.util.fn(action=action, params={}))
elif domain == "vision":
r = run(disp.vision.fn(action=action, params={}))
else:
r = run(disp._dispatch(domain, action, {}))
assert isinstance(r, dict), f"{domain}.{action} returned non-dict: {r!r}"
assert "success" in r, f"chain/unwrap failed for {domain}.{action}: {r!r}"
_assert_not_connection_error(r, f"{domain}.{action}")
def test_zzz_editor_survived_suite():
"""Last test in the file: the editor must still be alive after the full sweep."""
assert _editor_reachable(), "Unreal editor is no longer reachable after the E2E suite (it crashed mid-run)."