diff --git a/UnityMcpBridge/UnityMcpServer~/src/config.py b/UnityMcpBridge/UnityMcpServer~/src/config.py index e41b272..0f22fed 100644 --- a/UnityMcpBridge/UnityMcpServer~/src/config.py +++ b/UnityMcpBridge/UnityMcpServer~/src/config.py @@ -15,7 +15,7 @@ class ServerConfig: mcp_port: int = 6500 # Connection settings - connection_timeout: float = 60.0 # default steady-state timeout; retries use shorter timeouts + connection_timeout: float = 1.0 # short initial timeout; retries use shorter timeouts buffer_size: int = 16 * 1024 * 1024 # 16MB buffer # Framed receive behavior framed_receive_timeout: float = 2.0 # max seconds to wait while consuming heartbeats only diff --git a/UnityMcpBridge/UnityMcpServer~/src/server.py b/UnityMcpBridge/UnityMcpServer~/src/server.py index cda5f3f..26b824f 100644 --- a/UnityMcpBridge/UnityMcpServer~/src/server.py +++ b/UnityMcpBridge/UnityMcpServer~/src/server.py @@ -1,20 +1,31 @@ from mcp.server.fastmcp import FastMCP, Context, Image import logging +import os from dataclasses import dataclass from contextlib import asynccontextmanager from typing import AsyncIterator, Dict, Any, List from config import config from tools import register_all_tools from unity_connection import get_unity_connection, UnityConnection -from telemetry import record_telemetry, record_milestone, RecordType, MilestoneType import time # Configure logging using settings from config logging.basicConfig( level=getattr(logging, config.log_level), - format=config.log_format + format=config.log_format, + stream=None, # None -> defaults to sys.stderr; avoid stdout used by MCP stdio + force=True # Ensure our handler replaces any prior stdout handlers ) logger = logging.getLogger("mcp-for-unity-server") +# Quieten noisy third-party loggers to avoid clutter during stdio handshake +for noisy in ("httpx", "urllib3"): + try: + logging.getLogger(noisy).setLevel(max(logging.WARNING, getattr(logging, config.log_level))) + except Exception: + pass + +# Import telemetry only after logging is configured to ensure its logs use stderr and proper levels +from telemetry import record_telemetry, record_milestone, RecordType, MilestoneType # Global connection state _unity_connection: UnityConnection = None @@ -34,42 +45,52 @@ async def server_lifespan(server: FastMCP) -> AsyncIterator[Dict[str, Any]]: server_version = ver_path.read_text(encoding="utf-8").strip() except Exception: server_version = "unknown" - record_telemetry(RecordType.STARTUP, { - "server_version": server_version, - "startup_time": start_time - }) + # Defer telemetry for first second to avoid interfering with stdio handshake + if (time.perf_counter() - start_clk) > 1.0: + record_telemetry(RecordType.STARTUP, { + "server_version": server_version, + "startup_time": start_time + }) # Record first startup milestone - record_milestone(MilestoneType.FIRST_STARTUP) + if (time.perf_counter() - start_clk) > 1.0: + record_milestone(MilestoneType.FIRST_STARTUP) try: - _unity_connection = get_unity_connection() - logger.info("Connected to Unity on startup") - - # Record successful Unity connection - record_telemetry(RecordType.UNITY_CONNECTION, { - "status": "connected", - "connection_time_ms": (time.time() - start_time) * 1000 - }) - + skip_connect = os.environ.get("UNITY_MCP_SKIP_STARTUP_CONNECT", "").lower() in ("1", "true", "yes", "on") + if skip_connect: + logger.info("Skipping Unity connection on startup (UNITY_MCP_SKIP_STARTUP_CONNECT=1)") + else: + _unity_connection = get_unity_connection() + logger.info("Connected to Unity on startup") + + # Record successful Unity connection + if (time.perf_counter() - start_clk) > 1.0: + record_telemetry(RecordType.UNITY_CONNECTION, { + "status": "connected", + "connection_time_ms": (time.time() - start_time) * 1000 + }) + except ConnectionError as e: logger.warning("Could not connect to Unity on startup: %s", e) _unity_connection = None # Record connection failure - record_telemetry(RecordType.UNITY_CONNECTION, { - "status": "failed", - "error": str(e)[:200], - "connection_time_ms": (time.perf_counter() - start_clk) * 1000 - }) + if (time.perf_counter() - start_clk) > 1.0: + record_telemetry(RecordType.UNITY_CONNECTION, { + "status": "failed", + "error": str(e)[:200], + "connection_time_ms": (time.perf_counter() - start_clk) * 1000 + }) except Exception as e: logger.warning("Unexpected error connecting to Unity on startup: %s", e) _unity_connection = None - record_telemetry(RecordType.UNITY_CONNECTION, { - "status": "failed", - "error": str(e)[:200], - "connection_time_ms": (time.perf_counter() - start_clk) * 1000 - }) + if (time.perf_counter() - start_clk) > 1.0: + record_telemetry(RecordType.UNITY_CONNECTION, { + "status": "failed", + "error": str(e)[:200], + "connection_time_ms": (time.perf_counter() - start_clk) * 1000 + }) try: # Yield the connection object so it can be attached to the context @@ -97,18 +118,18 @@ register_all_tools(mcp) def asset_creation_strategy() -> str: """Guide for discovering and using MCP for Unity tools effectively.""" return ( - "Available MCP for Unity Server Tools:\\n\\n" - "- `manage_editor`: Controls editor state and queries info.\\n" - "- `execute_menu_item`: Executes Unity Editor menu items by path.\\n" - "- `read_console`: Reads or clears Unity console messages, with filtering options.\\n" - "- `manage_scene`: Manages scenes.\\n" - "- `manage_gameobject`: Manages GameObjects in the scene.\\n" - "- `manage_script`: Manages C# script files.\\n" - "- `manage_asset`: Manages prefabs and assets.\\n" - "- `manage_shader`: Manages shaders.\\n\\n" - "Tips:\\n" - "- Create prefabs for reusable GameObjects.\\n" - "- Always include a camera and main light in your scenes.\\n" + "Available MCP for Unity Server Tools:\n\n" + "- `manage_editor`: Controls editor state and queries info.\n" + "- `execute_menu_item`: Executes Unity Editor menu items by path.\n" + "- `read_console`: Reads or clears Unity console messages, with filtering options.\n" + "- `manage_scene`: Manages scenes.\n" + "- `manage_gameobject`: Manages GameObjects in the scene.\n" + "- `manage_script`: Manages C# script files.\n" + "- `manage_asset`: Manages prefabs and assets.\n" + "- `manage_shader`: Manages shaders.\n\n" + "Tips:\n" + "- Create prefabs for reusable GameObjects.\n" + "- Always include a camera and main light in your scenes.\n" ) # Run the server diff --git a/UnityMcpBridge/UnityMcpServer~/src/server_version.txt b/UnityMcpBridge/UnityMcpServer~/src/server_version.txt index 15a2799..bea438e 100644 --- a/UnityMcpBridge/UnityMcpServer~/src/server_version.txt +++ b/UnityMcpBridge/UnityMcpServer~/src/server_version.txt @@ -1 +1 @@ -3.3.0 +3.3.1 diff --git a/UnityMcpBridge/UnityMcpServer~/src/telemetry.py b/UnityMcpBridge/UnityMcpServer~/src/telemetry.py index bc62b95..8aed4ce 100644 --- a/UnityMcpBridge/UnityMcpServer~/src/telemetry.py +++ b/UnityMcpBridge/UnityMcpServer~/src/telemetry.py @@ -18,6 +18,8 @@ from dataclasses import dataclass, asdict from typing import Optional, Dict, Any, List from pathlib import Path import importlib +import queue +import contextlib try: import httpx @@ -158,6 +160,10 @@ class TelemetryCollector: self._customer_uuid: Optional[str] = None self._milestones: Dict[str, Dict[str, Any]] = {} self._lock: threading.Lock = threading.Lock() + # Bounded queue with single background worker to avoid spawning a thread per event + self._queue: "queue.Queue[tuple[contextvars.Context, TelemetryRecord]]" = queue.Queue(maxsize=1000) + self._worker: threading.Thread = threading.Thread(target=self._worker_loop, daemon=True) + self._worker.start() self._load_persistent_data() def _load_persistent_data(self): @@ -242,14 +248,24 @@ class TelemetryCollector: data=data, milestone=milestone ) - - # Send in background thread to avoid blocking + # Enqueue for background worker (non-blocking). Drop on backpressure. current_context = contextvars.copy_context() - thread = threading.Thread( - target=lambda: current_context.run(self._send_telemetry, record), - daemon=True - ) - thread.start() + try: + self._queue.put_nowait((current_context, record)) + except queue.Full: + logger.debug("Telemetry queue full; dropping %s", record.record_type) + + def _worker_loop(self): + """Background worker that serializes telemetry sends.""" + while True: + ctx, rec = self._queue.get() + try: + ctx.run(self._send_telemetry, rec) + except Exception: + logger.debug("Telemetry worker send failed", exc_info=True) + finally: + with contextlib.suppress(Exception): + self._queue.task_done() def _send_telemetry(self, record: TelemetryRecord): """Send telemetry data to endpoint"""