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:
249
mcp-server/src/unreal_mcp/core.py
Normal file
249
mcp-server/src/unreal_mcp/core.py
Normal 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__}
|
||||
)
|
||||
131
mcp-server/src/unreal_mcp/dispatcher.py
Normal file
131
mcp-server/src/unreal_mcp/dispatcher.py
Normal 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
|
||||
0
mcp-server/src/unreal_mcp/dispatchers/__init__.py
Normal file
0
mcp-server/src/unreal_mcp/dispatchers/__init__.py
Normal file
1061
mcp-server/src/unreal_mcp/dispatchers/_catalog.py
Normal file
1061
mcp-server/src/unreal_mcp/dispatchers/_catalog.py
Normal file
File diff suppressed because it is too large
Load Diff
24
mcp-server/src/unreal_mcp/main.py
Normal file
24
mcp-server/src/unreal_mcp/main.py
Normal 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()
|
||||
13
mcp-server/src/unreal_mcp/server.py
Normal file
13
mcp-server/src/unreal_mcp/server.py
Normal 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()
|
||||
Reference in New Issue
Block a user