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__}
|
||||
)
|
||||
Reference in New Issue
Block a user