diff --git a/CHANGELOG.md b/CHANGELOG.md
index 36f12a6..5bfae2e 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,35 @@ 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.4.0] - 2026-07-08 — ue_x3d_* : the harness on the wire
+
+The v0.3.0 UE↔X3D harness (`x3d_bridge`) is now exposed as MCP tools, so Claude
+can export a live level to X3D, validate it, apply transform edits back, and
+preview it — inside the editor. All four are **CORE-tier** (mounted by default);
+Epic's MCP has no X3D surface.
+
+### Added
+- **`ue_x3d_export`** — serialize the current level to X3D. Reads each actor's
+ quaternion transform, static mesh, material, and attach parent; converts UE
+ (Z-up, cm) → X3D (Y-up, m). `mesh_only` by default.
+- **`ue_x3d_validate`** — the paper boundary as a tool: rejects out-of-grammar
+ nodes, dangling `USE`, wrong arity, non-X3D roots, NaN/inf — without touching
+ the editor.
+- **`ue_x3d_apply`** — apply an X3D document's actor transforms back to the live
+ level (validated, guids sanitized via `sanitize_object_path`, emitted through
+ `ue_execute_python`). Transforms in v0.4.0; spawn / material / reparent apply
+ are documented follow-ups.
+- **`ue_x3d_preview`** — render X3D to a standalone X_ITE browser page.
+- `tests/test_x3d_async.py` + exec-sim coverage for the two codegen tools (stub
+ extended with `Quat` / `Transform` / `get_actor_transform`). Tier table pinned
+ at 60 tools / 22 CORE.
+
+### Notes
+- Editor-independent and CI-green; the **live** round-trip against a real 5.8
+ scene is gated on `ue_preflight` reading green (i.e. `bAllowAnyRemoteFunctionCall`
+ applied). `B` remains analytic (not glTF-calibrated) — the round trip is
+ basis-agnostic, so that is fidelity-only.
+
## [0.3.1] - 2026-07-08 — 5.8 survival: the Capability Ladder
The bridge was **silently broken on UE 5.8**: 5.8 defaults
diff --git a/tests/exec_sim/registry.py b/tests/exec_sim/registry.py
index d063648..8d25cd4 100644
--- a/tests/exec_sim/registry.py
+++ b/tests/exec_sim/registry.py
@@ -56,6 +56,17 @@ class ToolEntry:
notes: str = ""
+# Minimal valid X3D for the DIRECT x3d tools, and an apply sentinel whose DEF is a
+# seeded stub actor so the generated SetTransform resolves to a real actor.
+_X3D_MIN = ''
+_X3D_APPLY_SENTINEL = (
+ ''
+ ''
+ ""
+)
+
+
_ENTRIES = [
# ------------------------------------------------------------- actors.py
ToolEntry(
@@ -375,6 +386,29 @@ class ToolEntry:
expect_error=True,
notes="honest not-implemented report: an error RESULT is the CORRECT behavior",
),
+ # ----------------------------------------------------------------- x3d.py
+ ToolEntry(
+ "ue_x3d_export", CODEGEN,
+ kwargs=dict(mesh_only=True),
+ notes="reads every level actor via a static script; no user args interpolated",
+ ),
+ ToolEntry(
+ "ue_x3d_validate", DIRECT,
+ kwargs=dict(x3d=_X3D_MIN),
+ notes="pure x3d_bridge.validate; never touches the editor",
+ ),
+ ToolEntry(
+ "ue_x3d_apply", CODEGEN,
+ kwargs=dict(x3d=_X3D_APPLY_SENTINEL),
+ # translation/rotation/scale pass through x3d_bridge.coordinates (X3D->UE
+ # basis) before emission, so they are NOT literal in the generated source.
+ notes="applies transforms; DEF must be a real actor path (SENTINEL_ACTOR_PATH)",
+ ),
+ ToolEntry(
+ "ue_x3d_preview", DIRECT,
+ kwargs=dict(x3d=_X3D_MIN),
+ notes="renders X3D to HTML in-process; never touches the editor",
+ ),
]
REGISTRY: dict[str, ToolEntry] = {e.tool_name: e for e in _ENTRIES}
diff --git a/tests/exec_sim/unreal_stub.py b/tests/exec_sim/unreal_stub.py
index 429e3a7..1b4d77c 100644
--- a/tests/exec_sim/unreal_stub.py
+++ b/tests/exec_sim/unreal_stub.py
@@ -66,6 +66,21 @@ def __init__(self, pitch=0.0, yaw=0.0, roll=0.0):
self.pitch, self.yaw, self.roll = float(pitch), float(yaw), float(roll)
+class Quat:
+ def __init__(self, x=0.0, y=0.0, z=0.0, w=1.0):
+ self.x, self.y, self.z, self.w = float(x), float(y), float(z), float(w)
+
+ def rotator(self):
+ return Rotator()
+
+
+class Transform:
+ def __init__(self, translation=None, rotation=None, scale3d=None):
+ self.translation = translation if translation is not None else Vector()
+ self.rotation = rotation if rotation is not None else Quat()
+ self.scale3d = scale3d if scale3d is not None else Vector(1.0, 1.0, 1.0)
+
+
class LinearColor:
def __init__(self, r=0.0, g=0.0, b=0.0, a=1.0):
self.r, self.g, self.b, self.a = float(r), float(g), float(b), float(a)
@@ -354,6 +369,9 @@ def set_actor_scale3d(self, v):
self._scale = v
return True
+ def get_actor_transform(self):
+ return Transform(self._location, Quat(), self._scale)
+
def get_actor_bounds(self, only_colliding, *args):
return (Vector(0.0, 0.0, 100.0), Vector(50.0, 50.0, 100.0))
@@ -702,6 +720,7 @@ def find_object(outer, path):
exported = {
"Vector": Vector, "Vector4": Vector4, "Rotator": Rotator,
+ "Quat": Quat, "Transform": Transform,
"LinearColor": LinearColor, "Color": Color, "Name": Name,
"EditorActorSubsystem": EditorActorSubsystem,
"UnrealEditorSubsystem": UnrealEditorSubsystem,
diff --git a/tests/test_registry_tiers.py b/tests/test_registry_tiers.py
index 1856caf..482c0ae 100644
--- a/tests/test_registry_tiers.py
+++ b/tests/test_registry_tiers.py
@@ -54,9 +54,9 @@ def test_matrix_arithmetic_is_pinned():
"""docs/EPIC_MCP_MATRIX.md §4: 36 RETIRE; KEEP/KEEP-PARTIAL = 18 CORE here
(ue_status/ue_health_check live in mcp_server.py, outside this registry);
undo/redo are the two honest not-implemented EXPERIMENTAL slots."""
- assert len(TIERS) == 56
+ assert len(TIERS) == 60
assert len(LEGACY_NAMES) == 36
- assert len(CORE_NAMES) == 18
+ assert len(CORE_NAMES) == 22
assert EXP_NAMES == {"ue_undo", "ue_redo"}
diff --git a/tests/test_x3d_async.py b/tests/test_x3d_async.py
new file mode 100644
index 0000000..aeb1eeb
--- /dev/null
+++ b/tests/test_x3d_async.py
@@ -0,0 +1,135 @@
+"""Async tests for ue_mcp/tools/x3d.py -- the ue_x3d_* MCP tools.
+
+Mocked bridge; the exec-sim harness proves the generated read/apply scripts run
+against the strict fake `unreal`. Live round-trip is proven by ue_preflight +
+smoke_live against a real editor.
+"""
+
+import ast
+import json
+
+import pytest
+from mcp.server.fastmcp import FastMCP
+
+from ue_mcp.tools.x3d import register
+
+
+@pytest.fixture
+def server(mock_ue):
+ s = FastMCP("test")
+ register(s, mock_ue)
+ return s
+
+
+def _fn(server, name):
+ return server._tool_manager._tools[name].fn
+
+
+_ACTOR_ROW = {
+ "guid": "/Game/Maps/M.M:PersistentLevel.Rock_1",
+ "mesh": "/Game/Meshes/SM_Rock",
+ "material": "/Game/Mat/M_Granite",
+ "t": [420.0, 0.0, 155.0],
+ "r": [0.0, 0.0, 0.0, 1.0],
+ "s": [1.0, 1.0, 1.0],
+ "parent": None,
+}
+
+_APPLY_X3D = (
+ ''
+ ''
+)
+
+
+class TestExport:
+ @pytest.mark.asyncio
+ async def test_serializes_scene(self, server, mock_ue):
+ mock_ue.execute_python.return_value = {"result": [_ACTOR_ROW], "output": "", "error": None}
+ data = json.loads(await _fn(server, "ue_x3d_export")(mesh_only=True))
+ assert "error" not in data
+ assert data["actor_count"] == 1
+ assert 'DEF="/Game/Maps/M.M:PersistentLevel.Rock_1"' in data["x3d"]
+ assert "/Game/Meshes/SM_Rock" in data["x3d"]
+ mock_ue.execute_python.assert_awaited_once()
+
+ @pytest.mark.asyncio
+ async def test_read_script_is_valid_python(self, server, mock_ue):
+ mock_ue.execute_python.return_value = {"result": [], "output": "", "error": None}
+ await _fn(server, "ue_x3d_export")()
+ code = mock_ue.execute_python.call_args[0][0]
+ ast.parse(code) # generated UE Python must compile
+ assert "get_all_level_actors" in code
+ assert "get_actor_transform" in code
+
+ @pytest.mark.asyncio
+ async def test_mesh_only_filters_meshless(self, server, mock_ue):
+ rows = [_ACTOR_ROW, {**_ACTOR_ROW, "guid": "Light_1", "mesh": ""}]
+ mock_ue.execute_python.return_value = {"result": rows, "output": "", "error": None}
+ data = json.loads(await _fn(server, "ue_x3d_export")(mesh_only=True))
+ assert data["actor_count"] == 1
+
+ @pytest.mark.asyncio
+ async def test_reports_read_failure(self, server, mock_ue):
+ mock_ue.execute_python.return_value = {"result": None, "output": "", "error": "boom"}
+ data = json.loads(await _fn(server, "ue_x3d_export")())
+ assert "error" in data
+
+
+class TestValidate:
+ @pytest.mark.asyncio
+ async def test_accepts_good(self, server, mock_ue):
+ x3d = ''
+ data = json.loads(await _fn(server, "ue_x3d_validate")(x3d=x3d))
+ assert data["ok"] is True
+ mock_ue.execute_python.assert_not_awaited()
+
+ @pytest.mark.asyncio
+ async def test_rejects_out_of_grammar(self, server, mock_ue):
+ data = json.loads(await _fn(server, "ue_x3d_validate")(x3d=""))
+ assert data["ok"] is False
+ assert data["errors"]
+
+
+class TestApply:
+ @pytest.mark.asyncio
+ async def test_emits_set_transform(self, server, mock_ue):
+ mock_ue.execute_python.return_value = {"result": {"applied": 1}, "output": "", "error": None}
+ await _fn(server, "ue_x3d_apply")(x3d=_APPLY_X3D)
+ mock_ue.execute_python.assert_awaited_once()
+ code = mock_ue.execute_python.call_args[0][0]
+ ast.parse(code)
+ assert "set_actor_location" in code
+ assert "RESULT:" in code # the emitted ops are wrapped with a result line
+
+ @pytest.mark.asyncio
+ async def test_rejects_invalid_x3d_before_editor(self, server, mock_ue):
+ data = json.loads(await _fn(server, "ue_x3d_apply")(x3d=""))
+ assert "error" in data
+ mock_ue.execute_python.assert_not_awaited()
+
+ @pytest.mark.asyncio
+ async def test_dangerous_guid_never_reaches_codegen(self, server, mock_ue):
+ # a DEF carrying an injection attempt must be rejected, or at minimum not
+ # appear verbatim in the generated Python.
+ x3d = (
+ ''
+ ''
+ )
+ result = await _fn(server, "ue_x3d_apply")(x3d=x3d)
+ data = json.loads(result)
+ if "error" not in data:
+ code = mock_ue.execute_python.call_args[0][0]
+ assert "import os" not in code
+
+
+class TestPreview:
+ @pytest.mark.asyncio
+ async def test_embeds_x3d_no_editor(self, server, mock_ue):
+ x3d = ''
+ data = json.loads(await _fn(server, "ue_x3d_preview")(x3d=x3d))
+ assert data["chars"] > 0
+ assert "" in data["html"]
+ assert 'DEF="A"' in data["html"]
+ mock_ue.execute_python.assert_not_awaited()
diff --git a/ue_mcp/__version__.py b/ue_mcp/__version__.py
index 77e23f2..7172027 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.1"
-__version_info__ = (0, 3, 1)
+__version__ = "0.4.0"
+__version_info__ = (0, 4, 0)
diff --git a/ue_mcp/tools/__init__.py b/ue_mcp/tools/__init__.py
index 06fefc8..6b948a8 100644
--- a/ue_mcp/tools/__init__.py
+++ b/ue_mcp/tools/__init__.py
@@ -31,6 +31,7 @@
from .scene import register as register_scene
from .sequencer import register as register_sequencer
from .spatial import register as register_spatial
+from .x3d import register as register_x3d
class Tier(StrEnum):
@@ -116,6 +117,11 @@ class Tier(StrEnum):
"ue_play_sequence": _LEGACY,
"ue_add_actor_to_sequence": _LEGACY,
"ue_add_keyframe": _LEGACY,
+ # x3d.py — UE<->X3D thin-slice harness (no Epic counterpart)
+ "ue_x3d_export": _CORE,
+ "ue_x3d_validate": _CORE,
+ "ue_x3d_apply": _CORE,
+ "ue_x3d_preview": _CORE,
}
PROFILES: dict[str, set[Tier]] = {
@@ -172,6 +178,7 @@ def tool(self, *, name: str, description: str, annotations: dict | None = None):
register_materials,
register_editor,
register_sequencer,
+ register_x3d,
)
diff --git a/ue_mcp/tools/x3d.py b/ue_mcp/tools/x3d.py
new file mode 100644
index 0000000..7b9e678
--- /dev/null
+++ b/ue_mcp/tools/x3d.py
@@ -0,0 +1,186 @@
+"""
+x3d.py
+
+MCP tools exposing the x3d_bridge UE<->X3D harness inside the editor.
+
+The harness (x3d_bridge/) round-trips a level's assembly state through a closed
+X3D grammar with a lossless invariant and a validation boundary. These tools put
+it on the wire:
+
+- ue_x3d_export Read + Serialize : the live level -> X3D text
+- ue_x3d_validate Validate : the paper boundary, as a tool
+- ue_x3d_apply Apply : X3D transforms -> the editor via execute_python
+- ue_x3d_preview Preview : X3D -> a standalone X_ITE browser page
+
+Coordinate conversion, the grammar, and op emission all live in x3d_bridge; this
+module is a thin, validated adapter between it and the Remote Control bridge.
+
+v0.4.0 scope: apply sets actor transforms (spawn / delete / material / reparent
+apply are a documented follow-up). Export captures mesh/material/parent so the
+X3D is complete even though apply currently acts on transforms only.
+"""
+
+from __future__ import annotations
+
+import json
+import logging
+
+from x3d_bridge import (
+ Actor,
+ SetTransform,
+ X3DGrammarError,
+ deserialize,
+ emit_python,
+ serialize,
+ to_preview_html,
+)
+from x3d_bridge import validate as x3d_validate
+
+from ._types import MCPServer, UEBridge
+from ._validation import make_error, sanitize_object_path
+
+logger = logging.getLogger("ue5-mcp.tools.x3d")
+
+# Read every level actor's identity + UE-native transform (rotation as a
+# QUATERNION, read straight off the actor transform so we never reconstruct UE's
+# Euler convention), mesh path, first material, and attach parent. Static: no
+# user input is interpolated, so no escaping is required.
+_READ_ACTORS_CODE = """
+import unreal, json
+sub = unreal.get_editor_subsystem(unreal.EditorActorSubsystem)
+out = []
+for a in sub.get_all_level_actors():
+ try:
+ t = a.get_actor_transform()
+ loc = t.translation; rot = t.rotation; scl = t.scale3d
+ mesh_path = ""
+ mat_path = None
+ comp = a.get_component_by_class(unreal.StaticMeshComponent)
+ if comp:
+ try:
+ sm = comp.static_mesh
+ except Exception:
+ sm = None
+ if sm:
+ mesh_path = sm.get_path_name()
+ try:
+ if comp.get_num_materials() > 0:
+ m = comp.get_material(0)
+ if m:
+ mat_path = m.get_path_name()
+ except Exception:
+ pass
+ parent = a.get_attach_parent_actor()
+ out.append({
+ "guid": a.get_path_name(),
+ "mesh": mesh_path,
+ "material": mat_path,
+ "t": [loc.x, loc.y, loc.z],
+ "r": [rot.x, rot.y, rot.z, rot.w],
+ "s": [scl.x, scl.y, scl.z],
+ "parent": parent.get_path_name() if parent else None,
+ })
+ except Exception:
+ pass
+print("RESULT:" + json.dumps(out))
+"""
+
+
+def register(server: MCPServer, ue: UEBridge) -> None:
+ @server.tool(
+ name="ue_x3d_export",
+ description=(
+ "Serialize the current level to X3D text (the x3d_bridge thin-slice "
+ "grammar). Reads each actor's quaternion transform, static mesh, "
+ "material, and attach parent; converts UE (Z-up, cm) to X3D "
+ "(Y-up, m). mesh_only=True (default) exports only actors with a "
+ "static mesh. Returns {actor_count, x3d}."
+ ),
+ annotations={"readOnlyHint": True, "destructiveHint": False, "idempotentHint": True},
+ )
+ async def x3d_export(mesh_only: bool = True) -> str:
+ result = await ue.execute_python(_READ_ACTORS_CODE)
+ rows = result.get("result")
+ if not isinstance(rows, list):
+ detail = result.get("error") or result.get("output") or "no result"
+ return make_error(f"could not read level actors: {detail}")
+ actors = []
+ for row in rows:
+ if mesh_only and not row.get("mesh"):
+ continue
+ actors.append(
+ Actor(
+ guid=row["guid"],
+ mesh=row.get("mesh") or "",
+ material=row.get("material"),
+ parent=row.get("parent"),
+ t=tuple(row["t"]),
+ r=tuple(row["r"]),
+ s=tuple(row["s"]),
+ )
+ )
+ return json.dumps({"actor_count": len(actors), "x3d": serialize(actors)}, indent=2)
+
+ @server.tool(
+ name="ue_x3d_validate",
+ description=(
+ "Validate an X3D document against the closed thin-slice grammar "
+ "without touching the editor: rejects out-of-grammar nodes, dangling "
+ "USE references, wrong numeric arity, non-X3D roots, and NaN/inf. "
+ "Returns {ok, errors}. Run this on model-edited X3D before apply."
+ ),
+ annotations={"readOnlyHint": True, "destructiveHint": False, "idempotentHint": True},
+ )
+ async def x3d_validate_doc(x3d: str) -> str:
+ ok, errors = x3d_validate(x3d)
+ return json.dumps({"ok": ok, "errors": errors}, indent=2)
+
+ @server.tool(
+ name="ue_x3d_apply",
+ description=(
+ "Apply an X3D document's actor transforms back to the live level. "
+ "Validates the document, then for each Transform (addressed by its "
+ "DEF = the actor's object path) sets location/rotation/scale in the "
+ "editor. Actors must already exist. Returns {applied} or an error. "
+ "v0.4.0 applies transforms only."
+ ),
+ annotations={"readOnlyHint": False, "destructiveHint": True, "idempotentHint": False},
+ )
+ async def x3d_apply(x3d: str) -> str:
+ ok, errors = x3d_validate(x3d)
+ if not ok:
+ return make_error("invalid X3D: " + "; ".join(errors[:5]))
+ try:
+ actors = deserialize(x3d)
+ except X3DGrammarError as exc:
+ return make_error(f"X3D parse error: {exc}")
+
+ ops = []
+ for a in actors:
+ if err := sanitize_object_path(a.guid, "guid (X3D DEF)"):
+ return make_error(err)
+ ops.append(SetTransform(guid=a.guid, t=a.t, r=a.r, s=a.s))
+ if not ops:
+ return json.dumps({"applied": 0, "note": "no actors in document"}, indent=2)
+
+ code = (
+ "import unreal, json\n"
+ + "\n".join(emit_python(op) for op in ops)
+ + f'\nprint("RESULT:" + json.dumps({{"applied": {len(ops)}}}))\n'
+ )
+ result = await ue.execute_python(code)
+ return json.dumps(result, indent=2)
+
+ @server.tool(
+ name="ue_x3d_preview",
+ description=(
+ "Render an X3D document to a standalone HTML page that views it in a "
+ "browser via the X_ITE runtime (no editor needed). Returns "
+ "{chars, html}. The page references X_ITE from a CDN, so viewing it "
+ "needs network access; the X3D payload is embedded inline."
+ ),
+ annotations={"readOnlyHint": True, "destructiveHint": False, "idempotentHint": True},
+ )
+ async def x3d_preview(x3d: str, title: str = "UE x X3D preview") -> str:
+ html = to_preview_html(x3d, title=title)
+ return json.dumps({"chars": len(html), "html": html}, indent=2)