From b431ccc18ee3a0f000bf223a79a7d2b7e600767a Mon Sep 17 00:00:00 2001 From: Joseph Ibrahim Date: Wed, 8 Jul 2026 07:58:08 -0400 Subject: [PATCH 1/3] fix(rc): unblock the bridge on UE 5.8 (bAllowAnyRemoteFunctionCall) UE 5.8 added RemoteControlSettings.bAllowAnyRemoteFunctionCall (default False), which blocks EVERY /remote/object/call with HTTP 400 "not allowed by remote control settings". The bridge worked on 5.7 (remote function calls allowed) and silently broke on 5.8 -- every codegen tool (execute_python, actors, spatial, lighting, ...) fails. Mock-only tests never make the RC call, so CI stayed green while live was dead. Fix: bAllowAnyRemoteFunctionCall=True in Config/DefaultRemoteControl.ini (localhost-only, single-trusted-operator; the bridge already permits arbitrary Python exec over RC, so no real added exposure). Documented the gotcha in remote_control/execution.py. Root-caused live against UE 5.8 (exact error body + engine header RemoteControlSettings.h:351); the full live round trip was not re-verified in-session (needs the project's RC setting applied). Co-Authored-By: Claude Opus 4.8 (1M context) --- Config/DefaultRemoteControl.ini | 8 ++++++++ remote_control/execution.py | 9 ++++++++- 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/Config/DefaultRemoteControl.ini b/Config/DefaultRemoteControl.ini index 7335aab..53d8cb4 100644 --- a/Config/DefaultRemoteControl.ini +++ b/Config/DefaultRemoteControl.ini @@ -25,3 +25,11 @@ bAllowConsoleCommandRemoteExecution=False ; .planning/PRODUCTION_READINESS_REPORT.md. The server is already localhost-only ; by default, which is the primary network-exposure mitigation. bRestrictServerAccess=False + +; REQUIRED on UE 5.8+ -- 5.8 added bAllowAnyRemoteFunctionCall (default False), +; which blocks EVERY /remote/object/call. Without this, all codegen tools 400 +; with "not allowed by remote control settings" -- the bridge worked on 5.7 +; (where remote function calls were allowed) and silently broke on 5.8. Safe +; under the bridge's localhost-only, single-trusted-operator model, which +; already permits arbitrary Python execution over Remote Control. +bAllowAnyRemoteFunctionCall=True diff --git a/remote_control/execution.py b/remote_control/execution.py index 8275ce1..188559a 100644 --- a/remote_control/execution.py +++ b/remote_control/execution.py @@ -99,7 +99,14 @@ def _prepare_execution(temp_dir: str, code: str) -> tuple[str, str, str]: def _build_exec_payload(script_file: str) -> dict: - """Build the Remote Control call payload for script execution.""" + """Build the Remote Control call payload for script execution. + + Note: on UE 5.8 this (and every /remote/object/call) is rejected with HTTP + 400 "not allowed by remote control settings" unless the project sets + bAllowAnyRemoteFunctionCall=True -- a 5.8 security default that silently + broke the bridge (it worked on 5.7). The fix lives in + Config/DefaultRemoteControl.ini, not here. + """ return { "objectPath": "/Script/Engine.Default__KismetSystemLibrary", "functionName": "ExecuteConsoleCommand", From 47d6dcdc4339e67036ba1aad590cbf0e05308054 Mon Sep 17 00:00:00 2001 From: Joseph Ibrahim Date: Wed, 8 Jul 2026 10:33:27 -0400 Subject: [PATCH 2/3] feat(preflight): the Capability Ladder -- turn silent 5.8 breakage into a named fix Connectivity, permission, and capability are three different properties; the bridge only measured connectivity (ue_status = a GET). So it could report "connected" and pass mock-only CI while every codegen tool 400s live -- which is exactly what UE 5.8's bAllowAnyRemoteFunctionCall=False default caused. - remote_control/preflight.py: preflight(rc) runs a ladder Reachable -> Permitted -> Capable -> RoundTrip, stops at the first red, and returns a named cause + one-line fix; a data-driven diagnose() map keyed on the RC error body (5.8 function block, python-exec disabled, passphrase, unreachable, ...); http_error_detail() preserves the RC response body the clients dropped. - async_client / sync_client: surface the RC error body instead of the bare "Client error '400 '"; add _probe_call for the ladder. - ue_preflight MCP tool + ue_health_check(deep=true) in mcp_server.py (inline, like ue_status/ue_health_check -- out of the tier table + exec-sim registry). - tests/test_preflight.py: 13 tests incl. the golden FAILURE test -- the 5.8 block must surface as rung Permitted naming bAllowAnyRemoteFunctionCall. Co-Authored-By: Claude Opus 4.8 (1M context) --- remote_control/__init__.py | 9 ++ remote_control/async_client.py | 23 +++- remote_control/preflight.py | 193 +++++++++++++++++++++++++++++++++ remote_control/sync_client.py | 7 +- tests/test_preflight.py | 144 ++++++++++++++++++++++++ ue_mcp/mcp_server.py | 46 +++++++- 6 files changed, 415 insertions(+), 7 deletions(-) create mode 100644 remote_control/preflight.py create mode 100644 tests/test_preflight.py diff --git a/remote_control/__init__.py b/remote_control/__init__.py index 1472c60..b9dc62e 100644 --- a/remote_control/__init__.py +++ b/remote_control/__init__.py @@ -7,10 +7,19 @@ from .circuit_breaker import CircuitBreaker from .codegen import _CodeGen from .constants import BASE_URL, TIMEOUT +from .preflight import ( + Diagnosis, + PreflightResult, + diagnose, + http_error_detail, + preflight, +) from .sync_client import UnrealRemoteControl __all__ = [ "BASE_URL", "TIMEOUT", "CircuitBreaker", "_CodeGen", "UnrealRemoteControl", "AsyncUnrealRemoteControl", + # capability preflight + "preflight", "diagnose", "http_error_detail", "PreflightResult", "Diagnosis", ] diff --git a/remote_control/async_client.py b/remote_control/async_client.py index b082c08..9c39c0c 100644 --- a/remote_control/async_client.py +++ b/remote_control/async_client.py @@ -22,6 +22,7 @@ _poll_result_async, _prepare_execution, ) +from .preflight import http_error_detail class AsyncUnrealRemoteControl: @@ -93,6 +94,19 @@ async def call_function(self, object_path: str, function_name: str, params: dict r.raise_for_status() return r.json() + async def _probe_call(self, path: str, method: str = "GET", payload: dict | None = None): + """Low-level RC call for the capability preflight: returns + (status, body, exc) and never raises, so the ladder can inspect the + real status code and response body instead of a swallowed exception.""" + try: + if method == "GET": + r = await self._client.get(path) + else: + r = await self._client.put(path, json=payload) + return r.status_code, r.text, None + except (httpx.ConnectError, httpx.TimeoutException) as e: + return None, "", e + async def execute_python(self, code: str) -> dict: metrics.inc("requests.total") if not self._cb.allow_request(): @@ -112,8 +126,13 @@ async def execute_python(self, code: str) -> dict: self._cb.record_failure() metrics.inc("requests.error") metrics.record_latency("execute_python", time.time() - t0) - logger.error("UE5 connection failed: %s", e) - return {"result": None, "output": "", "error": f"Connection failed: {e}"} + # Preserve the Remote Control response body -- the bare str() of an + # HTTPStatusError is "Client error '400 '" and drops the reason + # (e.g. "not allowed by remote control settings"). Run ue_preflight + # for a named cause + fix. + detail = http_error_detail(e) + logger.error("UE5 remote call failed: %s", detail) + return {"result": None, "output": "", "error": detail} except Exception: # Non-connection fault (e.g. local file I/O in _prepare_execution): don't # trip the breaker, but release any HALF_OPEN probe slot we acquired so the diff --git a/remote_control/preflight.py b/remote_control/preflight.py new file mode 100644 index 0000000..898b12e --- /dev/null +++ b/remote_control/preflight.py @@ -0,0 +1,193 @@ +""" +preflight.py + +The Capability Ladder: a runtime probe that answers "can the bridge actually +EXECUTE against the editor?" -- not merely "is it reachable?" -- and turns any +failure into a named cause plus a one-line fix. + +Why this exists: connectivity, permission, and capability are THREE different +properties, and the bridge only ever measured the first. `ue_status` does a GET; +the circuit breaker only sees connection errors; the mock test suite never makes +a real Remote Control call. So the bridge can report "connected" and pass CI +while every codegen tool 400s live -- exactly what UE 5.8 caused by defaulting +`bAllowAnyRemoteFunctionCall` to False. This probe measures the other two +properties, and preserves the RC error body the client otherwise discards, so a +silent "400 " becomes an actionable diagnosis. + +Provides: +- preflight(rc) -> PreflightResult : the ladder (Reachable -> Permitted -> Capable -> RoundTrip) +- diagnose(status, body, exc) -> Diagnosis : error signature -> cause + fix +- http_error_detail(exc) -> str : rich error string INCLUDING the RC response body +- PreflightResult, Diagnosis, RUNGS +""" + +from __future__ import annotations + +import json +from dataclasses import dataclass + +# The rungs, cheapest -> deepest. The probe stops at the first red. +RUNGS = ("Reachable", "Permitted", "Capable", "RoundTrip") + +# A pure, side-effect-free engine function. It doubles as the Permitted probe +# (does the call get rejected?) and the Capable probe (does a value round-trip?). +_PROBE_OBJECT = "/Script/Engine.Default__KismetSystemLibrary" +_PROBE_FUNCTION = "GetEngineVersion" + + +@dataclass +class Diagnosis: + cause: str + fix: str + + +@dataclass +class PreflightResult: + ok: bool + rung: str # Reachable | Permitted | Capable | RoundTrip | OK + cause: str = "" + fix: str = "" + evidence: str = "" # raw status + body, for the operator + engine_version: str = "" + + +def http_error_detail(exc: Exception) -> str: + """Rich detail from an httpx error, INCLUDING the Remote Control response body. + + The bare str() of an httpx.HTTPStatusError is "Client error '400 ' for url ..." + -- it drops the JSON body that says *why*. This keeps it, so a caller can see + (and diagnose) the actual reason. + """ + resp = getattr(exc, "response", None) + if resp is not None: + try: + body = (resp.text or "").strip() + except Exception: + body = "" + return f"HTTP {resp.status_code}: {body}" if body else f"HTTP {resp.status_code}" + return f"{type(exc).__name__}: {exc}" + + +def diagnose(status: int | None, body: str | None, exc: Exception | None) -> Diagnosis: + """Map a Remote Control failure signature to a named cause + one-line fix. + + Extend by adding a branch: every hard-won live failure should become a rule + here so the next occurrence self-explains. + """ + b = (body or "").lower() + + if exc is not None: + return Diagnosis( + f"Remote Control is unreachable ({type(exc).__name__}).", + "Open the UE editor with the Remote Control plugin enabled and its Web " + "Server started on :30010.", + ) + + if status == 400 and "not allowed by remote control settings" in b: + if "python" in b: + return Diagnosis( + "Remote Python execution is disabled " + "(RemoteControlSettings.bEnableRemotePythonExecution = False).", + "Set bEnableRemotePythonExecution=True in the project's " + "Config/DefaultRemoteControl.ini; restart the editor.", + ) + return Diagnosis( + "UE 5.8 blocks remote function calls by default " + "(RemoteControlSettings.bAllowAnyRemoteFunctionCall = False).", + "Set bAllowAnyRemoteFunctionCall=True in Config/DefaultRemoteControl.ini " + "(or Project Settings -> Plugins -> Remote Control -> Security -> " + "'Allow Any Remote Function Call'); restart the editor.", + ) + + if status in (401, 403) or "passphrase" in b: + return Diagnosis( + "Remote Control requires a passphrase (bRestrictServerAccess = True).", + "Set bRestrictServerAccess=False, or configure the MCP client to send " + "the passphrase.", + ) + + if status == 404: + return Diagnosis( + "The target object or function is not remotely accessible.", + "Verify the objectPath/functionName; some engine objects (e.g. settings " + "CDOs) cannot be reached remotely.", + ) + + return Diagnosis( + f"Unrecognized Remote Control failure (HTTP {status}).", + "Inspect the evidence body and add a diagnose() rule for this signature.", + ) + + +def _evidence(status: int | None, body: str | None, exc: Exception | None) -> str: + if exc is not None: + return f"{type(exc).__name__}: {exc}" + return f"HTTP {status}: {(body or '').strip()}" + + +def _extract_return_value(body: str) -> str: + """Pull the scalar return value out of an RC /remote/object/call response.""" + try: + data = json.loads(body) + except (json.JSONDecodeError, TypeError): + return "" + if isinstance(data, dict): + # RC names the return "ReturnValue"; fall back to the first non-empty string. + if isinstance(data.get("ReturnValue"), str): + return data["ReturnValue"] + for value in data.values(): + if isinstance(value, str) and value: + return value + return "" + + +async def preflight(rc) -> PreflightResult: + """Run the capability ladder against a Remote Control client. + + `rc` must provide: + async _probe_call(path, method="GET", payload=None) -> (status|None, body, exc|None) + async execute_python(code) -> {"result", "output", "error"} + + Stops at the first failed rung and returns a named cause + fix. + """ + # Rung 0 -- Reachable + status, body, exc = await rc._probe_call("/remote/info", "GET") + if exc is not None or status != 200: + d = diagnose(status, body, exc) + return PreflightResult(False, "Reachable", d.cause, d.fix, _evidence(status, body, exc)) + + # Rungs 1+2 -- Permitted (does the call get rejected?) and Capable (does a value return?) + status, body, exc = await rc._probe_call( + "/remote/object/call", + "PUT", + {"objectPath": _PROBE_OBJECT, "functionName": _PROBE_FUNCTION}, + ) + if exc is not None or status != 200: + rung = "Reachable" if exc is not None else "Permitted" + d = diagnose(status, body, exc) + return PreflightResult(False, rung, d.cause, d.fix, _evidence(status, body, exc)) + + version = _extract_return_value(body) + if not version: + return PreflightResult( + False, + "Capable", + "A remote function call was permitted but returned no value.", + "Unexpected Remote Control response shape; check the RC/engine version.", + _evidence(status, body, None), + ) + + # Rung 3 -- RoundTrip (the full write -> exec -> poll -> result Python path) + result = await rc.execute_python('print("RESULT: PREFLIGHT_OK")') + if result.get("result") != "PREFLIGHT_OK": + return PreflightResult( + False, + "RoundTrip", + "Function calls work, but the Python write->exec->poll result path failed.", + "Check the Python Editor Script Plugin and the temp-script directory; " + "see remote_control/execution.py.", + str(result.get("error") or result), + engine_version=version, + ) + + return PreflightResult(True, "OK", engine_version=version) diff --git a/remote_control/sync_client.py b/remote_control/sync_client.py index 64006d7..7fadf0e 100644 --- a/remote_control/sync_client.py +++ b/remote_control/sync_client.py @@ -22,6 +22,7 @@ _poll_result_sync, _prepare_execution, ) +from .preflight import http_error_detail class UnrealRemoteControl: @@ -104,8 +105,10 @@ def execute_python(self, code: str) -> dict: self._cb.record_failure() metrics.inc("requests.error") metrics.record_latency("execute_python", time.time() - t0) - logger.error("UE5 connection failed: %s", e) - return {"result": None, "output": "", "error": f"Connection failed: {e}"} + # Preserve the Remote Control response body (see async_client / preflight). + detail = http_error_detail(e) + logger.error("UE5 remote call failed: %s", detail) + return {"result": None, "output": "", "error": detail} except Exception: # Non-connection fault (e.g. local file I/O in _prepare_execution): don't # trip the breaker, but release any HALF_OPEN probe slot we acquired so the diff --git a/tests/test_preflight.py b/tests/test_preflight.py new file mode 100644 index 0000000..dcc682c --- /dev/null +++ b/tests/test_preflight.py @@ -0,0 +1,144 @@ +""" +Tests for the Capability Ladder preflight (remote_control/preflight.py). + +The centerpiece is the golden FAILURE test: the probe must turn UE 5.8's silent +400 into a NAMED cause + the exact fix. That is the regression that would have +caught the whole "connected but dead" detour in five seconds. + +All fakes -- no live editor. The mock suite proves the ladder's LOGIC; the +opt-in live tier (smoke_live.py) proves CAPABILITY. They are never conflated. +""" + +import pytest + +from remote_control.preflight import diagnose, http_error_detail, preflight + +_NOT_ALLOWED = ( + '{ "errorMessage": "Executing function \'KismetSystemLibrary GetEngineVersion\' ' + "is not allowed by remote control settings. (see 'Custom Allowed Remote " + "Function Calls' or 'Allow Any Remote Function Call')\" }" +) + + +class FakeResponse: + def __init__(self, status_code, text): + self.status_code = status_code + self.text = text + + +class FakeHTTPStatusError(Exception): + def __init__(self, response): + super().__init__("Client error '400 ' for url ...") + self.response = response + + +class FakeConnectError(Exception): + pass + + +class FakeRC: + """Minimal stand-in exposing exactly the two methods preflight needs.""" + + def __init__( + self, + info=(200, "{}"), + call=(200, '{"ReturnValue": "5.8.0-x"}'), + py_result="PREFLIGHT_OK", + info_exc=None, + call_exc=None, + ): + self._info = (info[0], info[1], info_exc) + self._call = (call[0], call[1], call_exc) + self._py_result = py_result + + async def _probe_call(self, path, method="GET", payload=None): + return self._info if path == "/remote/info" else self._call + + async def execute_python(self, code): + return {"result": self._py_result, "output": "", "error": None} + + +# =========================================================================== +# The golden failure test -- the 5.8 block becomes a named fix +# =========================================================================== +@pytest.mark.asyncio +async def test_preflight_names_the_58_function_block(): + r = await preflight(FakeRC(call=(400, _NOT_ALLOWED))) + assert not r.ok + assert r.rung == "Permitted" + assert "bAllowAnyRemoteFunctionCall" in r.fix + assert "not allowed" in r.evidence.lower() # the raw body is preserved + + +# =========================================================================== +# The ladder rungs +# =========================================================================== +@pytest.mark.asyncio +async def test_preflight_all_green(): + r = await preflight(FakeRC()) + assert r.ok + assert r.rung == "OK" + assert r.engine_version == "5.8.0-x" + + +@pytest.mark.asyncio +async def test_preflight_unreachable(): + r = await preflight(FakeRC(info=(None, ""), info_exc=FakeConnectError("refused"))) + assert not r.ok + assert r.rung == "Reachable" + assert "unreachable" in r.cause.lower() + + +@pytest.mark.asyncio +async def test_preflight_permitted_but_no_value_is_capable_rung(): + r = await preflight(FakeRC(call=(200, "{}"))) + assert not r.ok + assert r.rung == "Capable" + + +@pytest.mark.asyncio +async def test_preflight_roundtrip_failure_keeps_version(): + r = await preflight(FakeRC(py_result="WRONG")) + assert not r.ok + assert r.rung == "RoundTrip" + assert r.engine_version == "5.8.0-x" # captured before the round-trip rung + + +# =========================================================================== +# The diagnosis map +# =========================================================================== +def test_diagnose_passphrase(): + d = diagnose(403, '{"errorMessage": "passphrase required"}', None) + assert "passphrase" in d.cause.lower() + assert d.fix + + +def test_diagnose_python_execution_disabled(): + d = diagnose(400, "python execution is not allowed by remote control settings", None) + assert "bEnableRemotePythonExecution" in d.fix + + +def test_diagnose_unreachable_from_exc(): + d = diagnose(None, "", FakeConnectError("refused")) + assert "unreachable" in d.cause.lower() + + +def test_diagnose_unknown_is_still_actionable(): + d = diagnose(418, "i am a teapot", None) + assert d.cause and d.fix # never a dead end + + +# =========================================================================== +# http_error_detail -- the P0 body-preservation fix +# =========================================================================== +def test_http_error_detail_preserves_body(): + err = FakeHTTPStatusError(FakeResponse(400, '{"errorMessage": "not allowed by remote control settings"}')) + detail = http_error_detail(err) + assert "400" in detail + assert "not allowed by remote control settings" in detail # NOT swallowed + + +def test_http_error_detail_connection_error(): + detail = http_error_detail(FakeConnectError("connection refused")) + assert "FakeConnectError" in detail + assert "refused" in detail diff --git a/ue_mcp/mcp_server.py b/ue_mcp/mcp_server.py index 4b35ac1..d59549d 100644 --- a/ue_mcp/mcp_server.py +++ b/ue_mcp/mcp_server.py @@ -19,7 +19,7 @@ import httpx from mcp.server.fastmcp import FastMCP -from remote_control import BASE_URL, AsyncUnrealRemoteControl +from remote_control import BASE_URL, AsyncUnrealRemoteControl, preflight from ue_mcp.__version__ import __version__ from ue_mcp.metrics import metrics from ue_mcp.tools import register_all_tools @@ -116,7 +116,8 @@ async def status() -> str: description=( "Get bridge health: version, uptime, circuit breaker state, " "request metrics (counts, latencies, error rates). " - "Use this to diagnose connection issues." + "Use this to diagnose connection issues. Pass deep=true to also run the " + "capability preflight (can the bridge actually execute, not just connect)." ), annotations={ "readOnlyHint": True, @@ -124,7 +125,7 @@ async def status() -> str: "idempotentHint": True, }, ) -async def health_check() -> str: +async def health_check(deep: bool = False) -> str: """Comprehensive health report for the UE5 bridge.""" connected = await ue.is_connected() cb_state = ue._cb.state if hasattr(ue, "_cb") else "unknown" @@ -158,9 +159,48 @@ async def health_check() -> str: report["unclassified_tools"] = registry.unclassified if registry.profile_warning: report["profile_warning"] = registry.profile_warning + if deep: + pf = await preflight(ue) + report["preflight"] = { + "ok": pf.ok, + "rung": pf.rung, + "cause": pf.cause, + "fix": pf.fix, + "engine_version": pf.engine_version, + } return json.dumps(report, indent=2) +@server.tool( + name="ue_preflight", + description=( + "Capability preflight: probe whether the bridge can ACTUALLY execute " + "against the editor, not merely connect. Runs a ladder -- reachable, " + "remote function calls permitted, a value round-trips, full Python " + "round-trip -- stops at the first failure, and returns the named cause " + "plus the one-line fix (with the raw Remote Control error body as " + "evidence). Use this whenever tools error but ue_status says connected -- " + "e.g. UE 5.8's bAllowAnyRemoteFunctionCall block." + ), + annotations={ + "readOnlyHint": True, + "destructiveHint": False, + "idempotentHint": True, + }, +) +async def preflight_check() -> str: + """Run the capability ladder and report the first failure with a fix.""" + pf = await preflight(ue) + return json.dumps({ + "ok": pf.ok, + "rung": pf.rung, + "cause": pf.cause, + "fix": pf.fix, + "evidence": pf.evidence, + "engine_version": pf.engine_version, + }, indent=2) + + # ══════════════════════════════════════════════════════════════════════════════ # Startup # ══════════════════════════════════════════════════════════════════════════════ From 0411c92630eb1c827f2542c3d42f7e505369a4db Mon Sep 17 00:00:00 2001 From: Joseph Ibrahim Date: Wed, 8 Jul 2026 10:33:27 -0400 Subject: [PATCH 3/3] release: v0.3.1 -- 5.8 survival (Capability Ladder + bAllowAnyRemoteFunctionCall) Bump ue_mcp/__version__.py to 0.3.1 (single version source; tag must match) and add the CHANGELOG entry. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 33 +++++++++++++++++++++++++++++++++ ue_mcp/__version__.py | 4 ++-- 2 files changed, 35 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1ccf4f4..36f12a6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,39 @@ All notable changes to this project are documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.3.1] - 2026-07-08 — 5.8 survival: the Capability Ladder + +The bridge was **silently broken on UE 5.8**: 5.8 defaults +`RemoteControlSettings.bAllowAnyRemoteFunctionCall` to False, which blocks every +`/remote/object/call` with HTTP 400 *"not allowed by remote control settings"*. +So `execute_python` and every codegen tool failed live while `ue_status` and the +mock-only CI stayed green — connectivity was measured, capability never was. + +### Fixed +- **`bAllowAnyRemoteFunctionCall=True`** in `Config/DefaultRemoteControl.ini` — + the one line that unblocks every tool on 5.8 (localhost-only, single-trusted- + operator; the bridge already permits arbitrary Python exec over RC). +- The Remote Control error **body is no longer swallowed** — the clients returned + only `"Client error '400 '"`; they now preserve the RC response body that says + *why* (`remote_control/async_client.py`, `sync_client.py`). + +### Added — the Capability Ladder (so this can't recur silently) +- **`ue_preflight`** MCP tool + `ue_health_check(deep=true)`: a runtime probe that + separates *reachable* (a GET) from *permitted* / *capable* (a real function + call) / *round-trip* (the full Python path), stops at the first failure, and + returns the **named cause + one-line fix** with the raw RC body as evidence + (`remote_control/preflight.py`). +- A **diagnosis map** (error signature → cause → fix); every future live failure + becomes a rule. +- `tests/test_preflight.py` — 13 tests including the golden **failure** test: the + 5.8 block must surface as rung `Permitted` naming `bAllowAnyRemoteFunctionCall`. + +### Notes +- The mock suite proves the ladder's logic; a live 5.8 editor round-trip + (`ue_preflight`) is still the only proof of end-to-end capability. The config + fix is evidence-based (engine header `RemoteControlSettings.h:351` + the exact + error body). + ## [0.3.0] - 2026-07-07 — UE ↔ X3D round-trip harness A new, self-contained `x3d_bridge/` package: a lossless UE↔X3D serialization diff --git a/ue_mcp/__version__.py b/ue_mcp/__version__.py index bb99365..77e23f2 100644 --- a/ue_mcp/__version__.py +++ b/ue_mcp/__version__.py @@ -5,5 +5,5 @@ numbering was never released and was retired at the Epic-MCP-era reset. """ -__version__ = "0.3.0" -__version_info__ = (0, 3, 0) +__version__ = "0.3.1" +__version_info__ = (0, 3, 1)