server: centralize reload-aware retries and single-source retry_after_ms via config; increase default retry window (40 x 250ms); preserve structured reloading failures

main
David Sarno 2025-08-10 22:49:24 -07:00
parent b179ce1ed8
commit 1938756844
10 changed files with 104 additions and 95 deletions

View File

@ -25,6 +25,11 @@ class ServerConfig:
# Server settings # Server settings
max_retries: int = 10 max_retries: int = 10
retry_delay: float = 0.25 retry_delay: float = 0.25
# Backoff hint returned to clients when Unity is reloading (milliseconds)
reload_retry_ms: int = 250
# Number of polite retries when Unity reports reloading
# 40 × 250ms ≈ 10s default window
reload_max_retries: int = 40
# Create a global config instance # Create a global config instance
config = ServerConfig() config = ServerConfig()

View File

@ -3,7 +3,8 @@ Defines the execute_menu_item tool for running Unity Editor menu commands.
""" """
from typing import Dict, Any from typing import Dict, Any
from mcp.server.fastmcp import FastMCP, Context from mcp.server.fastmcp import FastMCP, Context
from unity_connection import get_unity_connection # Import unity_connection module from unity_connection import get_unity_connection, send_command_with_retry # Import retry helper
from config import config
import time import time
def register_execute_menu_item_tools(mcp: FastMCP): def register_execute_menu_item_tools(mcp: FastMCP):
@ -43,15 +44,6 @@ def register_execute_menu_item_tools(mcp: FastMCP):
if "parameters" not in params_dict: if "parameters" not in params_dict:
params_dict["parameters"] = {} # Ensure parameters dict exists params_dict["parameters"] = {} # Ensure parameters dict exists
# Get Unity connection and send the command # Use centralized retry helper
# We use the unity_connection module to communicate with Unity resp = send_command_with_retry("execute_menu_item", params_dict)
unity_conn = get_unity_connection() return resp if isinstance(resp, dict) else {"success": False, "message": str(resp)}
# Send command to the ExecuteMenuItem C# handler
# The command type should match what the Unity side expects
resp = unity_conn.send_command("execute_menu_item", params_dict)
if isinstance(resp, dict) and not resp.get("success", True) and resp.get("state") == "reloading":
delay_ms = int(resp.get("retry_after_ms", 250))
time.sleep(max(0.0, delay_ms / 1000.0))
resp = unity_conn.send_command("execute_menu_item", params_dict)
return resp

View File

@ -5,7 +5,8 @@ import asyncio # Added: Import asyncio for running sync code in async
from typing import Dict, Any from typing import Dict, Any
from mcp.server.fastmcp import FastMCP, Context from mcp.server.fastmcp import FastMCP, Context
# from ..unity_connection import get_unity_connection # Original line that caused error # from ..unity_connection import get_unity_connection # Original line that caused error
from unity_connection import get_unity_connection # Use absolute import relative to Python dir from unity_connection import get_unity_connection, async_send_command_with_retry # Use centralized retry helper
from config import config
import time import time
def register_manage_asset_tools(mcp: FastMCP): def register_manage_asset_tools(mcp: FastMCP):
@ -72,22 +73,7 @@ def register_manage_asset_tools(mcp: FastMCP):
# Get the Unity connection instance # Get the Unity connection instance
connection = get_unity_connection() connection = get_unity_connection()
# Run the synchronous send_command in the default executor (thread pool) # Use centralized async retry helper to avoid blocking the event loop
# This prevents blocking the main async event loop. result = await async_send_command_with_retry("manage_asset", params_dict, loop=loop)
result = await loop.run_in_executor(
None, # Use default executor
connection.send_command, # The function to call
"manage_asset", # First argument for send_command
params_dict # Second argument for send_command
)
if isinstance(result, dict) and not result.get("success", True) and result.get("state") == "reloading":
delay_ms = int(result.get("retry_after_ms", 250))
await asyncio.sleep(max(0.0, delay_ms / 1000.0))
result = await loop.run_in_executor(
None,
connection.send_command,
"manage_asset",
params_dict
)
# Return the result obtained from Unity # Return the result obtained from Unity
return result return result if isinstance(result, dict) else {"success": False, "message": str(result)}

View File

@ -1,7 +1,8 @@
from mcp.server.fastmcp import FastMCP, Context from mcp.server.fastmcp import FastMCP, Context
import time import time
from typing import Dict, Any from typing import Dict, Any
from unity_connection import get_unity_connection from unity_connection import get_unity_connection, send_command_with_retry
from config import config
def register_manage_editor_tools(mcp: FastMCP): def register_manage_editor_tools(mcp: FastMCP):
"""Register all editor management tools with the MCP server.""" """Register all editor management tools with the MCP server."""
@ -41,18 +42,13 @@ def register_manage_editor_tools(mcp: FastMCP):
} }
params = {k: v for k, v in params.items() if v is not None} params = {k: v for k, v in params.items() if v is not None}
# Send command to Unity (with a single polite retry if reloading) # Send command using centralized retry helper
response = get_unity_connection().send_command("manage_editor", params) response = send_command_with_retry("manage_editor", params)
if isinstance(response, dict) and not response.get("success", True) and response.get("state") == "reloading":
delay_ms = int(response.get("retry_after_ms", 250))
time.sleep(max(0.0, delay_ms / 1000.0))
response = get_unity_connection().send_command("manage_editor", params)
# Process response # Preserve structured failure data; unwrap success into a friendlier shape
if response.get("success"): if isinstance(response, dict) and response.get("success"):
return {"success": True, "message": response.get("message", "Editor operation successful."), "data": response.get("data")} return {"success": True, "message": response.get("message", "Editor operation successful."), "data": response.get("data")}
else: return response if isinstance(response, dict) else {"success": False, "message": str(response)}
return {"success": False, "message": response.get("error", "An unknown error occurred during editor management.")}
except Exception as e: except Exception as e:
return {"success": False, "message": f"Python error managing editor: {str(e)}"} return {"success": False, "message": f"Python error managing editor: {str(e)}"}

View File

@ -1,6 +1,7 @@
from mcp.server.fastmcp import FastMCP, Context from mcp.server.fastmcp import FastMCP, Context
from typing import Dict, Any, List from typing import Dict, Any, List
from unity_connection import get_unity_connection from unity_connection import get_unity_connection, send_command_with_retry
from config import config
import time import time
def register_manage_gameobject_tools(mcp: FastMCP): def register_manage_gameobject_tools(mcp: FastMCP):
@ -123,21 +124,14 @@ def register_manage_gameobject_tools(mcp: FastMCP):
params.pop("prefab_folder", None) params.pop("prefab_folder", None)
# -------------------------------- # --------------------------------
# Send the command to Unity via the established connection # Use centralized retry helper
# Use the get_unity_connection function to retrieve the active connection instance response = send_command_with_retry("manage_gameobject", params)
# Changed "MANAGE_GAMEOBJECT" to "manage_gameobject" to potentially match Unity expectation
response = get_unity_connection().send_command("manage_gameobject", params)
if isinstance(response, dict) and not response.get("success", True) and response.get("state") == "reloading":
delay_ms = int(response.get("retry_after_ms", 250))
time.sleep(max(0.0, delay_ms / 1000.0))
response = get_unity_connection().send_command("manage_gameobject", params)
# Check if the response indicates success # Check if the response indicates success
# If the response is not successful, raise an exception with the error message # If the response is not successful, raise an exception with the error message
if response.get("success"): if isinstance(response, dict) and response.get("success"):
return {"success": True, "message": response.get("message", "GameObject operation successful."), "data": response.get("data")} return {"success": True, "message": response.get("message", "GameObject operation successful."), "data": response.get("data")}
else: return response if isinstance(response, dict) else {"success": False, "message": str(response)}
return {"success": False, "message": response.get("error", "An unknown error occurred during GameObject management.")}
except Exception as e: except Exception as e:
return {"success": False, "message": f"Python error managing GameObject: {str(e)}"} return {"success": False, "message": f"Python error managing GameObject: {str(e)}"}

View File

@ -1,6 +1,7 @@
from mcp.server.fastmcp import FastMCP, Context from mcp.server.fastmcp import FastMCP, Context
from typing import Dict, Any from typing import Dict, Any
from unity_connection import get_unity_connection from unity_connection import get_unity_connection, send_command_with_retry
from config import config
import time import time
def register_manage_scene_tools(mcp: FastMCP): def register_manage_scene_tools(mcp: FastMCP):
@ -35,18 +36,13 @@ def register_manage_scene_tools(mcp: FastMCP):
} }
params = {k: v for k, v in params.items() if v is not None} params = {k: v for k, v in params.items() if v is not None}
# Send command to Unity (with a single polite retry if reloading) # Use centralized retry helper
response = get_unity_connection().send_command("manage_scene", params) response = send_command_with_retry("manage_scene", params)
if isinstance(response, dict) and not response.get("success", True) and response.get("state") == "reloading":
delay_ms = int(response.get("retry_after_ms", 250))
time.sleep(max(0.0, delay_ms / 1000.0))
response = get_unity_connection().send_command("manage_scene", params)
# Process response # Preserve structured failure data; unwrap success into a friendlier shape
if response.get("success"): if isinstance(response, dict) and response.get("success"):
return {"success": True, "message": response.get("message", "Scene operation successful."), "data": response.get("data")} return {"success": True, "message": response.get("message", "Scene operation successful."), "data": response.get("data")}
else: return response if isinstance(response, dict) else {"success": False, "message": str(response)}
return {"success": False, "message": response.get("error", "An unknown error occurred during scene management.")}
except Exception as e: except Exception as e:
return {"success": False, "message": f"Python error managing scene: {str(e)}"} return {"success": False, "message": f"Python error managing scene: {str(e)}"}

View File

@ -1,6 +1,7 @@
from mcp.server.fastmcp import FastMCP, Context from mcp.server.fastmcp import FastMCP, Context
from typing import Dict, Any from typing import Dict, Any
from unity_connection import get_unity_connection from unity_connection import get_unity_connection, send_command_with_retry
from config import config
import time import time
import os import os
import base64 import base64
@ -54,15 +55,11 @@ def register_manage_script_tools(mcp: FastMCP):
# Remove None values so they don't get sent as null # Remove None values so they don't get sent as null
params = {k: v for k, v in params.items() if v is not None} params = {k: v for k, v in params.items() if v is not None}
# Send command to Unity (with single polite retry if reloading) # Send command via centralized retry helper
response = get_unity_connection().send_command("manage_script", params) response = send_command_with_retry("manage_script", params)
if isinstance(response, dict) and not response.get("success", True) and response.get("state") == "reloading":
delay_ms = int(response.get("retry_after_ms", 250))
time.sleep(max(0.0, delay_ms / 1000.0))
response = get_unity_connection().send_command("manage_script", params)
# Process response from Unity # Process response from Unity
if response.get("success"): if isinstance(response, dict) and response.get("success"):
# If the response contains base64 encoded content, decode it # If the response contains base64 encoded content, decode it
if response.get("data", {}).get("contentsEncoded"): if response.get("data", {}).get("contentsEncoded"):
decoded_contents = base64.b64decode(response["data"]["encodedContents"]).decode('utf-8') decoded_contents = base64.b64decode(response["data"]["encodedContents"]).decode('utf-8')
@ -71,8 +68,7 @@ def register_manage_script_tools(mcp: FastMCP):
del response["data"]["contentsEncoded"] del response["data"]["contentsEncoded"]
return {"success": True, "message": response.get("message", "Operation successful."), "data": response.get("data")} return {"success": True, "message": response.get("message", "Operation successful."), "data": response.get("data")}
else: return response if isinstance(response, dict) else {"success": False, "message": str(response)}
return {"success": False, "message": response.get("error", "An unknown error occurred.")}
except Exception as e: except Exception as e:
# Handle Python-side errors (e.g., connection issues) # Handle Python-side errors (e.g., connection issues)

View File

@ -1,6 +1,7 @@
from mcp.server.fastmcp import FastMCP, Context from mcp.server.fastmcp import FastMCP, Context
from typing import Dict, Any from typing import Dict, Any
from unity_connection import get_unity_connection from unity_connection import get_unity_connection, send_command_with_retry
from config import config
import time import time
import os import os
import base64 import base64
@ -47,15 +48,11 @@ def register_manage_shader_tools(mcp: FastMCP):
# Remove None values so they don't get sent as null # Remove None values so they don't get sent as null
params = {k: v for k, v in params.items() if v is not None} params = {k: v for k, v in params.items() if v is not None}
# Send command to Unity # Send command via centralized retry helper
response = get_unity_connection().send_command("manage_shader", params) response = send_command_with_retry("manage_shader", params)
if isinstance(response, dict) and not response.get("success", True) and response.get("state") == "reloading":
delay_ms = int(response.get("retry_after_ms", 250))
time.sleep(max(0.0, delay_ms / 1000.0))
response = get_unity_connection().send_command("manage_shader", params)
# Process response from Unity # Process response from Unity
if response.get("success"): if isinstance(response, dict) and response.get("success"):
# If the response contains base64 encoded content, decode it # If the response contains base64 encoded content, decode it
if response.get("data", {}).get("contentsEncoded"): if response.get("data", {}).get("contentsEncoded"):
decoded_contents = base64.b64decode(response["data"]["encodedContents"]).decode('utf-8') decoded_contents = base64.b64decode(response["data"]["encodedContents"]).decode('utf-8')
@ -64,8 +61,7 @@ def register_manage_shader_tools(mcp: FastMCP):
del response["data"]["contentsEncoded"] del response["data"]["contentsEncoded"]
return {"success": True, "message": response.get("message", "Operation successful."), "data": response.get("data")} return {"success": True, "message": response.get("message", "Operation successful."), "data": response.get("data")}
else: return response if isinstance(response, dict) else {"success": False, "message": str(response)}
return {"success": False, "message": response.get("error", "An unknown error occurred.")}
except Exception as e: except Exception as e:
# Handle Python-side errors (e.g., connection issues) # Handle Python-side errors (e.g., connection issues)

View File

@ -4,7 +4,8 @@ Defines the read_console tool for accessing Unity Editor console messages.
from typing import List, Dict, Any from typing import List, Dict, Any
import time import time
from mcp.server.fastmcp import FastMCP, Context from mcp.server.fastmcp import FastMCP, Context
from unity_connection import get_unity_connection from unity_connection import get_unity_connection, send_command_with_retry
from config import config
def register_read_console_tools(mcp: FastMCP): def register_read_console_tools(mcp: FastMCP):
"""Registers the read_console tool with the MCP server.""" """Registers the read_console tool with the MCP server."""
@ -67,10 +68,6 @@ def register_read_console_tools(mcp: FastMCP):
if 'count' not in params_dict: if 'count' not in params_dict:
params_dict['count'] = None params_dict['count'] = None
# Forward the command using the bridge's send_command method (with a single polite retry on reload) # Use centralized retry helper
resp = bridge.send_command("read_console", params_dict) resp = send_command_with_retry("read_console", params_dict)
if isinstance(resp, dict) and not resp.get("success", True) and resp.get("state") == "reloading": return resp if isinstance(resp, dict) else {"success": False, "message": str(resp)}
delay_ms = int(resp.get("retry_after_ms", 250))
time.sleep(max(0.0, delay_ms / 1000.0))
resp = bridge.send_command("read_console", params_dict)
return resp

View File

@ -139,7 +139,7 @@ class UnityConnection:
return { return {
"success": False, "success": False,
"state": "reloading", "state": "reloading",
"retry_after_ms": int(250), "retry_after_ms": int(config.reload_retry_ms),
"error": "Unity domain reload in progress", "error": "Unity domain reload in progress",
"message": "Unity is reloading scripts; please retry shortly" "message": "Unity is reloading scripts; please retry shortly"
} }
@ -278,3 +278,54 @@ def get_unity_connection() -> UnityConnection:
pass pass
_unity_connection = None _unity_connection = None
raise ConnectionError(f"Could not establish valid Unity connection: {str(e)}") raise ConnectionError(f"Could not establish valid Unity connection: {str(e)}")
# -----------------------------
# Centralized retry helpers
# -----------------------------
def _is_reloading_response(resp: dict) -> bool:
"""Return True if the Unity response indicates the editor is reloading."""
if not isinstance(resp, dict):
return False
if resp.get("state") == "reloading":
return True
message_text = (resp.get("message") or resp.get("error") or "").lower()
return "reload" in message_text
def send_command_with_retry(command_type: str, params: Dict[str, Any], *, max_retries: int | None = None, retry_ms: int | None = None) -> Dict[str, Any]:
"""Send a command via the shared connection, waiting politely through Unity reloads.
Uses config.reload_retry_ms and config.reload_max_retries by default. Preserves the
structured failure if retries are exhausted.
"""
conn = get_unity_connection()
if max_retries is None:
max_retries = getattr(config, "reload_max_retries", 40)
if retry_ms is None:
retry_ms = getattr(config, "reload_retry_ms", 250)
response = conn.send_command(command_type, params)
retries = 0
while _is_reloading_response(response) and retries < max_retries:
delay_ms = int(response.get("retry_after_ms", retry_ms)) if isinstance(response, dict) else retry_ms
time.sleep(max(0.0, delay_ms / 1000.0))
retries += 1
response = conn.send_command(command_type, params)
return response
async def async_send_command_with_retry(command_type: str, params: Dict[str, Any], *, loop=None, max_retries: int | None = None, retry_ms: int | None = None) -> Dict[str, Any]:
"""Async wrapper that runs the blocking retry helper in a thread pool."""
try:
import asyncio # local import to avoid mandatory asyncio dependency for sync callers
if loop is None:
loop = asyncio.get_running_loop()
return await loop.run_in_executor(
None,
lambda: send_command_with_retry(command_type, params, max_retries=max_retries, retry_ms=retry_ms),
)
except Exception as e:
# Return a structured error dict for consistency with other responses
return {"success": False, "error": f"Python async retry helper failed: {str(e)}"}