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,249 @@
# Copyright (c) 2025 GenOrca. All Rights Reserved.
import socket
import json
import sys
# Custom Exception classes
class ToolInputError(Exception):
pass
class UnrealExecutionError(Exception):
def __init__(self, message, details=None):
super().__init__(message)
self.details = details if details is not None else {}
async def send_unreal_action(action_module: str, params: dict) -> dict:
"""
Convention-based wrapper for send_to_unreal.
Auto-derives action_name from the calling function's name.
Convention: caller 'foo_bar' → action 'ue_foo_bar'
Also includes standard error handling.
"""
caller_name = sys._getframe(1).f_code.co_name
action_name = f"ue_{caller_name}"
try:
return await send_to_unreal(action_module, action_name, params)
except UnrealExecutionError as e:
return {"success": False, "message": str(e), "details": e.details}
except Exception as e:
return {"success": False, "message": f"An unexpected error occurred: {str(e)}"}
def _unwrap_result(response: dict) -> dict:
"""
The TCP server's python_call path double-wraps the action's JSON return.
The C++ server runs `print(execute_action(...))` and captures stdout into a
string "result" field, so the wire shape is:
{"success": <python-exec ok>, "message": ..., "result": "<action json string>"}
The OUTER "success" is whether Python *ran*, NOT whether the action
succeeded. The real action dict (with its own "success", "actor_label", ...)
is the JSON string in "result". Unwrap it so callers get the action dict.
"""
if not isinstance(response, dict):
return response
inner = response.get("result")
if isinstance(inner, str):
try:
parsed = json.loads(inner)
except (ValueError, TypeError):
return response
if isinstance(parsed, (dict, list)):
return parsed
return response
# Core send_to_unreal function
async def send_to_unreal(action_module: str, action_name: str, params: dict) -> dict:
"""
Sends a command to the Unreal Engine Python script via socket communication.
Args:
action_module (str): The Python module in Unreal (e.g., 'actor_actions').
action_name (str): The function name in the module (e.g., 'ue_spawn_actor_from_class').
params (dict): A dictionary of parameters for the action.
Returns:
dict: The JSON response from Unreal.
Raises:
UnrealExecutionError: If any error occurs during socket communication or JSON processing.
ToolInputError: If there's an issue with the input that can be determined client-side (though less common here).
"""
HOST = '127.0.0.1'
PORT = 12029
command = {
"type": "python_call",
"module": action_module,
"function": action_name,
"args": params
}
response_str = ""
try:
json_str = json.dumps(command, ensure_ascii=False)
message_bytes = json_str.encode('utf-8')
# Using asyncio for socket communication would be better for a fully async server,
# but standard socket is used here as per existing structure.
# If FastMCP's .run() uses an async server like uvicorn, this blocking call
# will run in a thread pool.
with socket.create_connection((HOST, PORT), timeout=30) as sock:
sock.sendall(message_bytes)
response_buffer = b''
while True:
chunk = sock.recv(16384)
if not chunk:
break
response_buffer += chunk
if not response_buffer:
raise UnrealExecutionError("No response received from Unreal.", details={"host": HOST, "port": PORT})
response_str = response_buffer.decode('utf-8')
response_json = json.loads(response_str)
# Outer "success" is python-exec success. False = Python failed to run
# (bad module/function, syntax error) → propagate as an error.
if isinstance(response_json, dict) and response_json.get("success") is False:
raise UnrealExecutionError(
response_json.get("message", "Unknown error from Unreal action."),
details=response_json.get("details")
)
# Python ran; unwrap the action's real result out of the "result" string.
# The action's own success/failure is preserved in the unwrapped dict.
return _unwrap_result(response_json)
except socket.timeout:
raise UnrealExecutionError(f"Socket timeout ({HOST}:{PORT}): No response from Unreal.", details={"host": HOST, "port": PORT})
except ConnectionRefusedError:
raise UnrealExecutionError(f"Connection refused ({HOST}:{PORT}). Ensure Unreal MCPython TCP server is active.", details={"host": HOST, "port": PORT})
except json.JSONDecodeError as je:
raise UnrealExecutionError(f"Failed to decode JSON response from Unreal: {je}. Raw response: '{response_str}'", details={"host": HOST, "port": PORT, "raw_response": response_str})
except socket.error as se:
raise UnrealExecutionError(f"Socket error ({HOST}:{PORT}): {se}", details={"host": HOST, "port": PORT})
except UnrealExecutionError: # Re-raise if it's already our specific error type
raise
except Exception as e: # Catch any other unexpected errors
raise UnrealExecutionError(f"An unexpected error occurred in send_to_unreal ({HOST}:{PORT}): {type(e).__name__} - {e}", details={"host": HOST, "port": PORT, "error_type": type(e).__name__})
async def send_python_exec(code: str) -> dict:
"""
Sends raw Python code to the Unreal Engine TCP server for execution.
Uses the existing "type": "python" dispatch path.
The C++ server executes the code and captures print() output.
"""
HOST = '127.0.0.1'
PORT = 12029
TIMEOUT = 30
command = {"type": "python", "code": code}
response_str = ""
try:
json_str = json.dumps(command, ensure_ascii=False)
message_bytes = json_str.encode('utf-8')
with socket.create_connection((HOST, PORT), timeout=TIMEOUT) as sock:
sock.sendall(message_bytes)
response_buffer = b''
while True:
chunk = sock.recv(16384)
if not chunk:
break
response_buffer += chunk
if not response_buffer:
raise UnrealExecutionError(
"No response received from Unreal for Python execution.",
details={"host": HOST, "port": PORT}
)
response_str = response_buffer.decode('utf-8')
response_json = json.loads(response_str)
return response_json
except socket.timeout:
raise UnrealExecutionError(
f"Socket timeout ({HOST}:{PORT}): Python execution did not complete within {TIMEOUT}s.",
details={"host": HOST, "port": PORT}
)
except ConnectionRefusedError:
raise UnrealExecutionError(
f"Connection refused ({HOST}:{PORT}). Ensure Unreal MCPython TCP server is active.",
details={"host": HOST, "port": PORT}
)
except json.JSONDecodeError as je:
raise UnrealExecutionError(
f"Failed to decode JSON response: {je}. Raw: '{response_str}'",
details={"host": HOST, "port": PORT, "raw_response": response_str}
)
except UnrealExecutionError:
raise
except Exception as e:
raise UnrealExecutionError(
f"Unexpected error during Python execution: {type(e).__name__} - {e}",
details={"host": HOST, "port": PORT, "error_type": type(e).__name__}
)
async def send_livecoding_compile() -> dict:
"""
Sends a livecoding_compile command to the Unreal Engine TCP server.
Triggers C++ hot-reload via the LiveCoding module.
Waits for compilation to complete before returning the result.
"""
HOST = '127.0.0.1'
PORT = 12029
TIMEOUT = 180
command = {"type": "livecoding_compile"}
response_str = ""
try:
json_str = json.dumps(command, ensure_ascii=False)
message_bytes = json_str.encode('utf-8')
with socket.create_connection((HOST, PORT), timeout=TIMEOUT) as sock:
sock.sendall(message_bytes)
response_buffer = b''
while True:
chunk = sock.recv(16384)
if not chunk:
break
response_buffer += chunk
if not response_buffer:
raise UnrealExecutionError(
"No response received from Unreal for LiveCoding compile.",
details={"host": HOST, "port": PORT}
)
response_str = response_buffer.decode('utf-8')
response_json = json.loads(response_str)
if isinstance(response_json, dict) and response_json.get("success") is False:
raise UnrealExecutionError(
response_json.get("message", "LiveCoding compile failed."),
details=response_json
)
return response_json
except socket.timeout:
raise UnrealExecutionError(
f"Socket timeout ({HOST}:{PORT}): LiveCoding compilation did not complete within {TIMEOUT}s.",
details={"host": HOST, "port": PORT}
)
except ConnectionRefusedError:
raise UnrealExecutionError(
f"Connection refused ({HOST}:{PORT}). Ensure Unreal MCPython TCP server is active.",
details={"host": HOST, "port": PORT}
)
except json.JSONDecodeError as je:
raise UnrealExecutionError(
f"Failed to decode JSON response: {je}. Raw: '{response_str}'",
details={"host": HOST, "port": PORT, "raw_response": response_str}
)
except UnrealExecutionError:
raise
except Exception as e:
raise UnrealExecutionError(
f"Unexpected error during LiveCoding compile: {type(e).__name__} - {e}",
details={"host": HOST, "port": PORT, "error_type": type(e).__name__}
)

View File

@@ -0,0 +1,131 @@
# Copyright (c) 2025 GenOrca. All Rights Reserved.
"""
Namespace dispatcher — thin assembler.
One MCP tool per domain. Each accepts (action, params) and routes to the
Unreal TCP server as ue_<action>(**params). action='list_actions' returns the
domain's action catalog (param names + one-line docs).
The catalog is AUTO-GENERATED from the plugin's ue_* signatures
(see generate_catalog.py). Param names are therefore guaranteed to match —
no hand transcription. This file never grows when actions are added.
"""
import base64
from typing import Annotated
from pydantic import Field
from fastmcp import FastMCP, Image
from unreal_mcp.core import send_to_unreal, UnrealExecutionError, send_python_exec, send_livecoding_compile
from unreal_mcp.dispatchers._catalog import CATALOG
dispatcher_mcp = FastMCP(
name="UnrealMCP",
description=(
"Unreal Engine MCP via namespace dispatchers. "
"Each domain tool accepts (action, params). "
"Pass action='list_actions' to any tool to get available actions and parameter docs."
)
)
# Domains using standard python_call routing. util and vision have hand-written
# handlers (special TCP types / MCP Image return).
_SPECIAL_DOMAINS = {"util", "vision"}
_STANDARD_DOMAINS = [d for d in CATALOG if d not in _SPECIAL_DOMAINS]
def _module(domain: str) -> str:
return f"UnrealMCPython.{domain}_actions"
async def _dispatch(domain: str, action: str, params: dict) -> dict:
if action == "list_actions":
return {"success": True, "domain": domain, "actions": CATALOG[domain]}
if action not in CATALOG[domain]:
return {"success": False, "message": f"Unknown action '{action}'. Available: {list(CATALOG[domain])}"}
try:
return await send_to_unreal(_module(domain), f"ue_{action}", params)
except UnrealExecutionError as e:
return {"success": False, "message": str(e), "details": getattr(e, "details", {})}
except Exception as e:
return {"success": False, "message": f"Unexpected error: {e}"}
def _desc(domain: str) -> str:
actions = ", ".join(CATALOG[domain])
return f"Unreal {domain} tools. Actions: {actions}. Pass action='list_actions' for parameter docs."
def _make_handler(domain: str):
async def handler(
action: Annotated[str, Field(description="Action name. Use 'list_actions' for full parameter docs.")],
params: Annotated[dict, Field(description="Action parameters (keys must match the action's documented params).")] = {}
) -> dict:
return await _dispatch(domain, action, params)
handler.__name__ = domain
return handler
for _domain in _STANDARD_DOMAINS:
dispatcher_mcp.tool(name=_domain, description=_desc(_domain))(_make_handler(_domain))
# ─── util: special routing (execute_python / livecoding_compile use dedicated TCP types) ──
@dispatcher_mcp.tool(name="util", description=_desc("util"))
async def util(
action: Annotated[str, Field(description="Action name. Use 'list_actions' for full parameter docs.")],
params: Annotated[dict, Field(description="Action parameters. execute_python: {code}. get_output_log: {line_count, keyword}.")] = {}
) -> dict:
if action == "list_actions":
return {"success": True, "domain": "util", "actions": CATALOG["util"]}
if action == "execute_python":
code = params.get("code", "")
if not code:
return {"success": False, "message": "params.code is required"}
try:
return await send_python_exec(code)
except UnrealExecutionError as e:
return {"success": False, "message": str(e)}
if action == "livecoding_compile":
try:
return await send_livecoding_compile()
except UnrealExecutionError as e:
return {"success": False, "message": str(e)}
if action in CATALOG["util"]:
try:
return await send_to_unreal(_module("util"), f"ue_{action}", params)
except UnrealExecutionError as e:
return {"success": False, "message": str(e)}
return {"success": False, "message": f"Unknown action '{action}'. Available: {list(CATALOG['util'])}"}
# ─── vision: special routing (returns an MCP Image for captures) ────────────────
@dispatcher_mcp.tool(name="vision", description=_desc("vision"))
async def vision(
action: Annotated[str, Field(description="Action name. Use 'list_actions' for full parameter docs.")],
params: Annotated[dict, Field(description="Action parameters. capture_viewport: {width, height, fov}.")] = {}
) -> "Image | dict":
if action == "list_actions":
return {"success": True, "domain": "vision", "actions": CATALOG["vision"]}
if action not in CATALOG["vision"]:
return {"success": False, "message": f"Unknown action '{action}'. Available: {list(CATALOG['vision'])}"}
try:
result = await send_to_unreal(_module("vision"), f"ue_{action}", params)
except UnrealExecutionError as e:
return {"success": False, "message": str(e)}
# Capture actions return base64 PNG in 'image_data' → hand back an MCP Image.
if isinstance(result, dict):
img_b64 = result.get("image_data")
if result.get("success") and img_b64:
return Image(data=base64.b64decode(img_b64), format="png")
return result

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,24 @@
# Copyright (c) 2025 GenOrca. All Rights Reserved.
import os
import sys
import asyncio
sys.path.append(os.path.dirname(__file__))
from unreal_mcp.server import main_mcp, run_server
from fastmcp import Client
async def test_server():
print("Testing Unreal MCP Server...")
tools = await main_mcp.get_tools()
print(f"Available tools: {list(tools.keys())}")
def main():
if len(sys.argv) > 1 and sys.argv[1] == "--test":
asyncio.run(test_server())
else:
run_server()
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,13 @@
# Copyright (c) 2025 GenOrca. All Rights Reserved.
from unreal_mcp.dispatcher import dispatcher_mcp
# Public name kept stable for main.py and external imports.
main_mcp = dispatcher_mcp
def run_server():
"""Entry point function for the Unreal MCP Server"""
main_mcp.run(transport="stdio")
if __name__ == "__main__":
run_server()