MCP server: hardened startup + telemetry queue
- Logging to stderr with force; quiet httpx/urllib3 - Async lifespan fix; defer telemetry in first second - Bounded telemetry queue with single worker - Reduce initial Unity connect timeout to 1s - Keep server_version in filemain
parent
ba45051a40
commit
bd55a56d1c
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,19 +45,27 @@ 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"
|
||||
# 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
|
||||
if (time.perf_counter() - start_clk) > 1.0:
|
||||
record_milestone(MilestoneType.FIRST_STARTUP)
|
||||
|
||||
try:
|
||||
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
|
||||
|
|
@ -57,6 +76,7 @@ async def server_lifespan(server: FastMCP) -> AsyncIterator[Dict[str, Any]]:
|
|||
_unity_connection = None
|
||||
|
||||
# Record connection failure
|
||||
if (time.perf_counter() - start_clk) > 1.0:
|
||||
record_telemetry(RecordType.UNITY_CONNECTION, {
|
||||
"status": "failed",
|
||||
"error": str(e)[:200],
|
||||
|
|
@ -65,6 +85,7 @@ async def server_lifespan(server: FastMCP) -> AsyncIterator[Dict[str, Any]]:
|
|||
except Exception as e:
|
||||
logger.warning("Unexpected error connecting to Unity on startup: %s", e)
|
||||
_unity_connection = None
|
||||
if (time.perf_counter() - start_clk) > 1.0:
|
||||
record_telemetry(RecordType.UNITY_CONNECTION, {
|
||||
"status": "failed",
|
||||
"error": str(e)[:200],
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -1 +1 @@
|
|||
3.3.0
|
||||
3.3.1
|
||||
|
|
|
|||
|
|
@ -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"""
|
||||
|
|
|
|||
Loading…
Reference in New Issue