server: centralize reload-aware retries and single-source retry_after_ms via config; increase default retry window (40 x 250ms); preserve structured reloading failures
parent
b179ce1ed8
commit
1938756844
|
|
@ -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()
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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)}
|
||||||
|
|
@ -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)}"}
|
||||||
|
|
@ -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)}"}
|
||||||
|
|
@ -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)}"}
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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)}"}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue