Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 1 addition & 2 deletions .mcp.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).

---

Expand Down
4 changes: 3 additions & 1 deletion smoke_live.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 3 additions & 1 deletion tests/exec_sim/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
123 changes: 123 additions & 0 deletions tests/test_registry_tiers.py
Original file line number Diff line number Diff line change
@@ -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)}"
38 changes: 34 additions & 4 deletions ue_mcp/mcp_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
import os
import tempfile

import httpx
from mcp.server.fastmcp import FastMCP

from remote_control import BASE_URL, AsyncUnrealRemoteControl
Expand All @@ -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),
)


# ══════════════════════════════════════════════════════════════════════════════
Expand Down Expand Up @@ -120,15 +131,34 @@ 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,
"circuit_breaker": cb_state,
"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)


# ══════════════════════════════════════════════════════════════════════════════
Expand Down
Loading
Loading