- 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).
250 lines
10 KiB
Python
250 lines
10 KiB
Python
# 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__}
|
|
)
|