diff --git a/.mcp.json b/.mcp.json index fe9759d..a89ce0c 100644 --- a/.mcp.json +++ b/.mcp.json @@ -2,8 +2,7 @@ "mcpServers": { "unreal": { "command": "python", - "args": ["-m", "ue_mcp.mcp_server"], - "env": { "UE_MCP_PROFILE": "full" } + "args": ["-m", "ue_mcp.mcp_server"] }, "unreal-epic": { "type": "http", diff --git a/README.md b/README.md index ec16a6a..ada864d 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,9 @@ **Give Claude Code full control of Unreal Engine 5.7.** Spawn actors, tweak materials, light scenes, reason about space, capture the viewport, keyframe animations — all through natural language via the [Model Context Protocol](https://modelcontextprotocol.io/). -56 MCP tools | 14 tool modules | 415 tests | Python 3.11+ · [Changelog](CHANGELOG.md) · [Security](SECURITY.md) +58 MCP tools (20 mounted by default · commodity tier via `UE_MCP_PROFILE=full`) | 14 tool modules | 570+ tests | Python 3.11+ · [Changelog](CHANGELOG.md) · [Security](SECURITY.md) + +> **Epic MCP era:** UE 5.8 ships an official [Unreal MCP](docs/EPIC_MCP_MATRIX.md) covering the commodity control plane (830 tools with AllToolsets). This bridge now mounts its **differentiated core** by default — arbitrary editor Python, console exec, lighting moods, ClonerEffector mograph, continuous perception + scene diffing, normal-aware spatial reasoning, resilience — and keeps the 36 Epic-covered tools available behind `UE_MCP_PROFILE=full`. Dispositions: [EPIC_MCP_MATRIX.md](docs/EPIC_MCP_MATRIX.md). --- diff --git a/smoke_live.py b/smoke_live.py index e675c03..fff9708 100644 --- a/smoke_live.py +++ b/smoke_live.py @@ -197,7 +197,9 @@ async def run(read_only: bool) -> int: s = Smoke() ue = AsyncUnrealRemoteControl() server = FastMCP("uebridge-smoke") - register_all_tools(server, ue) + # The smoke deliberately exercises legacy-tier tools too — mount everything + # regardless of the default profile flip. + register_all_tools(server, ue, profile="all") def tool(name): return server._tool_manager._tools[name].fn diff --git a/tests/exec_sim/conftest.py b/tests/exec_sim/conftest.py index bd9ee48..4aded85 100644 --- a/tests/exec_sim/conftest.py +++ b/tests/exec_sim/conftest.py @@ -71,7 +71,9 @@ class Toolbox: def __init__(self): self.server = RecordingServer() self.ue = CaptureUE() - register_all_tools(self.server, self.ue) + # The harness gates every tool's codegen regardless of what the + # default profile mounts — always register the full surface. + register_all_tools(self.server, self.ue, profile="all") self._code_cache: dict[str, list[str]] = {} @property diff --git a/tests/test_registry_tiers.py b/tests/test_registry_tiers.py new file mode 100644 index 0000000..1856caf --- /dev/null +++ b/tests/test_registry_tiers.py @@ -0,0 +1,123 @@ +"""The M4 retirement flip: tiered tool registry vs docs/EPIC_MCP_MATRIX.md. + +TIERS is the executable form of the matrix's disposition table. These tests +pin the arithmetic (36 RETIRE / 18 CORE / 2 EXPERIMENTAL of 56), the profile +semantics, and the drift gates in both directions: a tool cannot register +without a tier, and a tier entry cannot outlive its tool. +""" + +from __future__ import annotations + +from unittest.mock import AsyncMock + +import pytest + +from ue_mcp.tools import ( + DEFAULT_PROFILE, + PROFILES, + TIERS, + Tier, + register_all_tools, +) + + +class RecordingServer: + def __init__(self): + self.tools: dict[str, dict | None] = {} + + def tool(self, *, name, description, annotations=None): + self.tools[name] = annotations + + def decorator(fn): + return fn + + return decorator + + +def _register(profile=None, monkeypatch=None, env=None): + if monkeypatch is not None: + if env is None: + monkeypatch.delenv("UE_MCP_PROFILE", raising=False) + else: + monkeypatch.setenv("UE_MCP_PROFILE", env) + server = RecordingServer() + registry = register_all_tools(server, AsyncMock(), profile=profile) + return server, registry + + +CORE_NAMES = {n for n, t in TIERS.items() if t is Tier.CORE} +LEGACY_NAMES = {n for n, t in TIERS.items() if t is Tier.LEGACY_COMMODITY} +EXP_NAMES = {n for n, t in TIERS.items() if t is Tier.EXPERIMENTAL} + + +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(LEGACY_NAMES) == 36 + assert len(CORE_NAMES) == 18 + assert EXP_NAMES == {"ue_undo", "ue_redo"} + + +def test_all_profile_mounts_exactly_the_tier_table(monkeypatch): + server, registry = _register(profile="all", monkeypatch=monkeypatch) + assert set(server.tools) == set(TIERS), ( + "drift between TIERS and the registered tool set — a tool was added, " + "removed, or renamed without updating the tier table" + ) + assert not registry.unclassified + assert not registry.skipped + + +def test_default_profile_is_the_core_flip(monkeypatch): + assert DEFAULT_PROFILE == "core" + server, registry = _register(monkeypatch=monkeypatch) # no env, no arg + assert registry.profile == "core" + assert set(server.tools) == CORE_NAMES + assert set(registry.skipped) == LEGACY_NAMES | EXP_NAMES + + +def test_full_profile_remounts_legacy_but_not_experimental(monkeypatch): + server, registry = _register(monkeypatch=monkeypatch, env="full") + assert registry.profile == "full" + assert set(server.tools) == CORE_NAMES | LEGACY_NAMES + assert set(registry.skipped) == EXP_NAMES + + +def test_explicit_profile_arg_beats_env(monkeypatch): + server, registry = _register(profile="all", monkeypatch=monkeypatch, env="core") + assert registry.profile == "all" + assert set(server.tools) == set(TIERS) + + +def test_unknown_profile_falls_back_to_default_with_warning(monkeypatch): + server, registry = _register(monkeypatch=monkeypatch, env="turbo") + assert registry.profile == DEFAULT_PROFILE + assert registry.profile_warning and "turbo" in registry.profile_warning + assert set(server.tools) == CORE_NAMES + + +def test_unclassified_tool_fails_open_and_is_flagged(): + """A brand-new tool without a TIERS entry mounts (fail-open at runtime) + but is reported, and test_all_profile_mounts_exactly_the_tier_table + fails CI until it is classified.""" + server = RecordingServer() + registry = register_all_tools(server, AsyncMock(), profile="core") + decorator = registry.tool(name="ue_brand_new_tool", description="x", annotations=None) + decorator(lambda: None) + assert "ue_brand_new_tool" in server.tools + assert registry.unclassified == ["ue_brand_new_tool"] + + +def test_annotations_pass_through_unchanged(): + server, _ = _register(profile="all") + annotations = server.tools["ue_execute_python"] + assert isinstance(annotations, dict) and "readOnlyHint" in annotations + + +@pytest.mark.parametrize("profile", sorted(PROFILES)) +def test_every_profile_mounts_all_core_tools(profile): + server, _ = _register(profile=profile) + missing = CORE_NAMES - set(server.tools) + assert not missing, f"profile {profile!r} dropped CORE tools: {sorted(missing)}" diff --git a/ue_mcp/mcp_server.py b/ue_mcp/mcp_server.py index b938554..4b35ac1 100644 --- a/ue_mcp/mcp_server.py +++ b/ue_mcp/mcp_server.py @@ -16,6 +16,7 @@ import os import tempfile +import httpx from mcp.server.fastmcp import FastMCP from remote_control import BASE_URL, AsyncUnrealRemoteControl @@ -30,8 +31,18 @@ server = FastMCP("unreal-engine") ue = AsyncUnrealRemoteControl() -# Register all tool modules -register_all_tools(server, ue) +# Epic's official Unreal MCP (UE 5.8+) — probed for the health report only. +EPIC_MCP_URL = os.environ.get("UE_EPIC_MCP_URL", "http://127.0.0.1:8000/mcp") + +# Register tool modules, filtered by UE_MCP_PROFILE (default "core" since the +# Epic-MCP retirement flip — see docs/EPIC_MCP_MATRIX.md). +registry = register_all_tools(server, ue) +if registry.profile_warning: + logger.warning(registry.profile_warning) +logger.info( + "tool profile %r: %d mounted, %d unmounted", + registry.profile, len(registry.registered), len(registry.skipped), +) # ══════════════════════════════════════════════════════════════════════════════ @@ -120,7 +131,17 @@ async def health_check() -> str: snap = metrics.snapshot() - return json.dumps({ + epic_reachable = False + try: + async with httpx.AsyncClient(timeout=1.5) as client: + await client.get(EPIC_MCP_URL) + epic_reachable = True # any HTTP response proves the server is up + except Exception: + # Diagnostics must never raise — e.g. httpx.InvalidURL from a + # misconfigured UE_EPIC_MCP_URL is not an HTTPError subclass. + pass + + report = { "version": __version__, "connected": connected, "base_url": BASE_URL, @@ -128,7 +149,16 @@ async def health_check() -> str: "uptime_s": snap["uptime_s"], "counters": snap["counters"], "latencies": snap["latencies"], - }, indent=2) + "tool_profile": registry.profile, + "tools_mounted": len(registry.registered), + "tools_unmounted": len(registry.skipped), + "epic_mcp": {"url": EPIC_MCP_URL, "reachable": epic_reachable}, + } + if registry.unclassified: + report["unclassified_tools"] = registry.unclassified + if registry.profile_warning: + report["profile_warning"] = registry.profile_warning + return json.dumps(report, indent=2) # ══════════════════════════════════════════════════════════════════════════════ diff --git a/ue_mcp/tools/__init__.py b/ue_mcp/tools/__init__.py index 1607933..06fefc8 100644 --- a/ue_mcp/tools/__init__.py +++ b/ue_mcp/tools/__init__.py @@ -1,4 +1,20 @@ -"""Tool registry for UE5 MCP server.""" +"""Tool registry for UE5 MCP server. + +Tools are classified into tiers per docs/EPIC_MCP_MATRIX.md (the retirement +contract-of-record, live-probed against Epic's official Unreal MCP on UE 5.8): + +- CORE differentiated capability Epic does not ship; always mounted +- LEGACY_COMMODITY covered by Epic's MCP surface (matrix verdict RETIRE); + mounted only under the "full" profile +- EXPERIMENTAL known-incomplete (honest not-implemented stubs); "all" only + +The active profile comes from the UE_MCP_PROFILE env var (default: "core" — +the M4 flip). Run with UE_MCP_PROFILE=full to remount the commodity tools, +e.g. when Epic's MCP is not enabled in the editor. +""" + +import os +from enum import StrEnum from ._types import MCPServer, UEBridge from .actors import register as register_actors @@ -17,19 +33,156 @@ from .spatial import register as register_spatial -def register_all_tools(server: MCPServer, ue: UEBridge) -> None: - """Register all tool modules with the MCP server.""" - register_actors(server, ue) - register_properties(server, ue) - register_python_exec(server, ue) - register_assets(server, ue) - register_level(server, ue) - register_mograph(server, ue) - register_blueprints(server, ue) - register_perception(server, ue) - register_scene(server, ue) - register_spatial(server, ue) - register_lighting(server, ue) - register_materials(server, ue) - register_editor(server, ue) - register_sequencer(server, ue) +class Tier(StrEnum): + CORE = "core" + LEGACY_COMMODITY = "legacy_commodity" + EXPERIMENTAL = "experimental" + + +_CORE = Tier.CORE +_LEGACY = Tier.LEGACY_COMMODITY +_EXP = Tier.EXPERIMENTAL + +# One entry per registered tool. Every LEGACY_COMMODITY verdict cites its row +# in docs/EPIC_MCP_MATRIX.md §2; keep the two in sync (tests pin the counts). +TIERS: dict[str, Tier] = { + # actors.py — Epic ActorTools/SceneTools cover all but duplication + "ue_spawn_actor": _LEGACY, # SceneTools.add_to_scene_from_class + "ue_delete_actor": _LEGACY, # SceneTools.remove_from_scene + "ue_list_actors": _LEGACY, # SceneTools.find_actors + "ue_set_transform": _LEGACY, # ActorTools.set_actor_transform + "ue_duplicate_actor": _CORE, # no level-actor duplication in the probe + "ue_get_actor_bounds": _LEGACY, # ActorTools.get_actor_bounds + # properties.py — Epic ObjectTools is exactly this + "ue_get_property": _LEGACY, # ObjectTools.get_properties + "ue_set_property": _LEGACY, # ObjectTools.set_properties + # python_exec.py — Epic's execute_tool_script is sandboxed (no unreal import) + "ue_execute_python": _CORE, + # assets.py + "ue_find_assets": _LEGACY, # AssetTools.find_assets (+ semantic search) + "ue_create_material": _LEGACY, # MaterialTools.create_material + graph tools + "ue_delete_asset": _LEGACY, # AssetTools.delete + # level.py + "ue_save_level": _LEGACY, # AssetTools.save_assets + SceneTools.save_actor + "ue_get_level_info": _LEGACY, # SceneTools.get_current_level + find_actors + "ue_load_level": _LEGACY, # SceneTools.load_level + "ue_get_world_info": _CORE, # no streaming-levels enumeration in the probe + # mograph.py + "ue_create_cloner": _CORE, # no ClonerEffector toolset in the probe + "ue_create_niagara_system": _LEGACY, # NiagaraToolset_System.CreateNiagaraSystem + "ue_create_pcg_graph": _LEGACY, # PCG.CreateGraph + SpawnGraphInstance + # blueprints.py — Epic BlueprintTools (53) + ActorTools/ObjectTools + "ue_create_blueprint": _LEGACY, + "ue_add_component": _LEGACY, + "ue_set_component_property": _LEGACY, + "ue_set_blueprint_defaults": _LEGACY, + "ue_compile_blueprint": _LEGACY, + "ue_get_actor_components": _LEGACY, + "ue_spawn_blueprint": _LEGACY, + # perception.py — continuous watch / diff / correlation have no Epic counterpart + "ue_viewport_percept": _CORE, # KEEP-PARTIAL: correlation + fallback stay ours + "ue_viewport_watch": _CORE, + "ue_viewport_config": _CORE, + "ue_viewport_diff": _CORE, + # scene.py + "ue_get_actor_details": _LEGACY, # find_actors + get_actor_transform + ... + "ue_query_scene": _LEGACY, # SceneTools.find_actors + "ue_get_component_details": _LEGACY, # get_components + get_properties + "ue_get_actor_hierarchy": _CORE, # no one-shot recursive attachment tree + # spatial.py — normal-aware reasoning; trace_world returns distance only + "ue_ground_trace": _CORE, + "ue_snap_to_ground": _CORE, + "ue_spatial_query": _CORE, + "ue_measure": _LEGACY, # arithmetic over two probe-verified reads + # lighting.py — no sky/atmosphere/mood tooling anywhere in the probe + "ue_setup_sky_atmosphere": _CORE, + "ue_set_time_of_day": _CORE, + "ue_list_mood_presets": _CORE, + "ue_apply_mood_preset": _CORE, + "ue_blend_mood_presets": _CORE, + # materials.py — Epic MaterialInstanceTools + mesh set_material + "ue_create_material_instance": _LEGACY, + "ue_set_material_parameter": _LEGACY, + "ue_get_material_parameters": _LEGACY, + "ue_assign_material": _LEGACY, + # editor.py + "ue_console_command": _CORE, # no console exec anywhere in the probe + "ue_undo": _EXP, # honest not-implemented; capability slot kept + "ue_redo": _EXP, + "ue_focus_actor": _LEGACY, # EditorApp.FocusOnActors + "ue_select_actors": _LEGACY, # EditorApp.SelectActors + # sequencer.py — Epic ships 140 SequencerTools + 22 KeyframingTools + "ue_create_level_sequence": _LEGACY, + "ue_play_sequence": _LEGACY, + "ue_add_actor_to_sequence": _LEGACY, + "ue_add_keyframe": _LEGACY, +} + +PROFILES: dict[str, set[Tier]] = { + "core": {Tier.CORE}, + "full": {Tier.CORE, Tier.LEGACY_COMMODITY}, + "all": {Tier.CORE, Tier.LEGACY_COMMODITY, Tier.EXPERIMENTAL}, +} + +DEFAULT_PROFILE = "core" + + +class ToolRegistry: + """Wraps the MCP server; drops registrations whose tier is outside the + active profile. Returned by register_all_tools as the mount report.""" + + def __init__(self, inner: MCPServer, profile: str): + self._inner = inner + self.profile = profile + self.profile_warning: str | None = None + if profile not in PROFILES: + self.profile_warning = ( + f"unknown UE_MCP_PROFILE {profile!r}; falling back to {DEFAULT_PROFILE!r}" + ) + self.profile = DEFAULT_PROFILE + self._active = PROFILES[self.profile] + self.registered: list[str] = [] + self.skipped: list[str] = [] + self.unclassified: list[str] = [] # fail-open; CI pins TIERS completeness + + def tool(self, *, name: str, description: str, annotations: dict | None = None): + tier = TIERS.get(name) + if tier is None: + self.unclassified.append(name) + tier = Tier.CORE + if tier not in self._active: + self.skipped.append(name) + return lambda fn: fn # no-op decorator: code stays, tool unmounted + self.registered.append(name) + return self._inner.tool(name=name, description=description, annotations=annotations) + + +_ALL_REGISTER_FNS = ( + register_actors, + register_properties, + register_python_exec, + register_assets, + register_level, + register_mograph, + register_blueprints, + register_perception, + register_scene, + register_spatial, + register_lighting, + register_materials, + register_editor, + register_sequencer, +) + + +def register_all_tools(server: MCPServer, ue: UEBridge, profile: str | None = None) -> ToolRegistry: + """Register tool modules with the MCP server, filtered by profile. + + Profile resolution: explicit arg > UE_MCP_PROFILE env > "core" (the default + since the Epic-MCP retirement flip; see docs/EPIC_MCP_MATRIX.md). + """ + resolved = profile or os.environ.get("UE_MCP_PROFILE") or DEFAULT_PROFILE + registry = ToolRegistry(server, resolved) + for register in _ALL_REGISTER_FNS: + register(registry, ue) + return registry