Skip to content

Commit bd55a56

Browse files
committed
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 file
1 parent ba45051 commit bd55a56

File tree

4 files changed

+84
-47
lines changed

4 files changed

+84
-47
lines changed

UnityMcpBridge/UnityMcpServer~/src/config.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ class ServerConfig:
1515
mcp_port: int = 6500
1616

1717
# Connection settings
18-
connection_timeout: float = 60.0 # default steady-state timeout; retries use shorter timeouts
18+
connection_timeout: float = 1.0 # short initial timeout; retries use shorter timeouts
1919
buffer_size: int = 16 * 1024 * 1024 # 16MB buffer
2020
# Framed receive behavior
2121
framed_receive_timeout: float = 2.0 # max seconds to wait while consuming heartbeats only

UnityMcpBridge/UnityMcpServer~/src/server.py

Lines changed: 59 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,31 @@
11
from mcp.server.fastmcp import FastMCP, Context, Image
22
import logging
3+
import os
34
from dataclasses import dataclass
45
from contextlib import asynccontextmanager
56
from typing import AsyncIterator, Dict, Any, List
67
from config import config
78
from tools import register_all_tools
89
from unity_connection import get_unity_connection, UnityConnection
9-
from telemetry import record_telemetry, record_milestone, RecordType, MilestoneType
1010
import time
1111

1212
# Configure logging using settings from config
1313
logging.basicConfig(
1414
level=getattr(logging, config.log_level),
15-
format=config.log_format
15+
format=config.log_format,
16+
stream=None, # None -> defaults to sys.stderr; avoid stdout used by MCP stdio
17+
force=True # Ensure our handler replaces any prior stdout handlers
1618
)
1719
logger = logging.getLogger("mcp-for-unity-server")
20+
# Quieten noisy third-party loggers to avoid clutter during stdio handshake
21+
for noisy in ("httpx", "urllib3"):
22+
try:
23+
logging.getLogger(noisy).setLevel(max(logging.WARNING, getattr(logging, config.log_level)))
24+
except Exception:
25+
pass
26+
27+
# Import telemetry only after logging is configured to ensure its logs use stderr and proper levels
28+
from telemetry import record_telemetry, record_milestone, RecordType, MilestoneType
1829

1930
# Global connection state
2031
_unity_connection: UnityConnection = None
@@ -34,42 +45,52 @@ async def server_lifespan(server: FastMCP) -> AsyncIterator[Dict[str, Any]]:
3445
server_version = ver_path.read_text(encoding="utf-8").strip()
3546
except Exception:
3647
server_version = "unknown"
37-
record_telemetry(RecordType.STARTUP, {
38-
"server_version": server_version,
39-
"startup_time": start_time
40-
})
48+
# Defer telemetry for first second to avoid interfering with stdio handshake
49+
if (time.perf_counter() - start_clk) > 1.0:
50+
record_telemetry(RecordType.STARTUP, {
51+
"server_version": server_version,
52+
"startup_time": start_time
53+
})
4154

4255
# Record first startup milestone
43-
record_milestone(MilestoneType.FIRST_STARTUP)
56+
if (time.perf_counter() - start_clk) > 1.0:
57+
record_milestone(MilestoneType.FIRST_STARTUP)
4458

4559
try:
46-
_unity_connection = get_unity_connection()
47-
logger.info("Connected to Unity on startup")
48-
49-
# Record successful Unity connection
50-
record_telemetry(RecordType.UNITY_CONNECTION, {
51-
"status": "connected",
52-
"connection_time_ms": (time.time() - start_time) * 1000
53-
})
54-
60+
skip_connect = os.environ.get("UNITY_MCP_SKIP_STARTUP_CONNECT", "").lower() in ("1", "true", "yes", "on")
61+
if skip_connect:
62+
logger.info("Skipping Unity connection on startup (UNITY_MCP_SKIP_STARTUP_CONNECT=1)")
63+
else:
64+
_unity_connection = get_unity_connection()
65+
logger.info("Connected to Unity on startup")
66+
67+
# Record successful Unity connection
68+
if (time.perf_counter() - start_clk) > 1.0:
69+
record_telemetry(RecordType.UNITY_CONNECTION, {
70+
"status": "connected",
71+
"connection_time_ms": (time.time() - start_time) * 1000
72+
})
73+
5574
except ConnectionError as e:
5675
logger.warning("Could not connect to Unity on startup: %s", e)
5776
_unity_connection = None
5877

5978
# Record connection failure
60-
record_telemetry(RecordType.UNITY_CONNECTION, {
61-
"status": "failed",
62-
"error": str(e)[:200],
63-
"connection_time_ms": (time.perf_counter() - start_clk) * 1000
64-
})
79+
if (time.perf_counter() - start_clk) > 1.0:
80+
record_telemetry(RecordType.UNITY_CONNECTION, {
81+
"status": "failed",
82+
"error": str(e)[:200],
83+
"connection_time_ms": (time.perf_counter() - start_clk) * 1000
84+
})
6585
except Exception as e:
6686
logger.warning("Unexpected error connecting to Unity on startup: %s", e)
6787
_unity_connection = None
68-
record_telemetry(RecordType.UNITY_CONNECTION, {
69-
"status": "failed",
70-
"error": str(e)[:200],
71-
"connection_time_ms": (time.perf_counter() - start_clk) * 1000
72-
})
88+
if (time.perf_counter() - start_clk) > 1.0:
89+
record_telemetry(RecordType.UNITY_CONNECTION, {
90+
"status": "failed",
91+
"error": str(e)[:200],
92+
"connection_time_ms": (time.perf_counter() - start_clk) * 1000
93+
})
7394

7495
try:
7596
# Yield the connection object so it can be attached to the context
@@ -97,18 +118,18 @@ async def server_lifespan(server: FastMCP) -> AsyncIterator[Dict[str, Any]]:
97118
def asset_creation_strategy() -> str:
98119
"""Guide for discovering and using MCP for Unity tools effectively."""
99120
return (
100-
"Available MCP for Unity Server Tools:\\n\\n"
101-
"- `manage_editor`: Controls editor state and queries info.\\n"
102-
"- `execute_menu_item`: Executes Unity Editor menu items by path.\\n"
103-
"- `read_console`: Reads or clears Unity console messages, with filtering options.\\n"
104-
"- `manage_scene`: Manages scenes.\\n"
105-
"- `manage_gameobject`: Manages GameObjects in the scene.\\n"
106-
"- `manage_script`: Manages C# script files.\\n"
107-
"- `manage_asset`: Manages prefabs and assets.\\n"
108-
"- `manage_shader`: Manages shaders.\\n\\n"
109-
"Tips:\\n"
110-
"- Create prefabs for reusable GameObjects.\\n"
111-
"- Always include a camera and main light in your scenes.\\n"
121+
"Available MCP for Unity Server Tools:\n\n"
122+
"- `manage_editor`: Controls editor state and queries info.\n"
123+
"- `execute_menu_item`: Executes Unity Editor menu items by path.\n"
124+
"- `read_console`: Reads or clears Unity console messages, with filtering options.\n"
125+
"- `manage_scene`: Manages scenes.\n"
126+
"- `manage_gameobject`: Manages GameObjects in the scene.\n"
127+
"- `manage_script`: Manages C# script files.\n"
128+
"- `manage_asset`: Manages prefabs and assets.\n"
129+
"- `manage_shader`: Manages shaders.\n\n"
130+
"Tips:\n"
131+
"- Create prefabs for reusable GameObjects.\n"
132+
"- Always include a camera and main light in your scenes.\n"
112133
)
113134

114135
# Run the server
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
3.3.0
1+
3.3.1

UnityMcpBridge/UnityMcpServer~/src/telemetry.py

Lines changed: 23 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@
1818
from typing import Optional, Dict, Any, List
1919
from pathlib import Path
2020
import importlib
21+
import queue
22+
import contextlib
2123

2224
try:
2325
import httpx
@@ -158,6 +160,10 @@ def __init__(self):
158160
self._customer_uuid: Optional[str] = None
159161
self._milestones: Dict[str, Dict[str, Any]] = {}
160162
self._lock: threading.Lock = threading.Lock()
163+
# Bounded queue with single background worker to avoid spawning a thread per event
164+
self._queue: "queue.Queue[tuple[contextvars.Context, TelemetryRecord]]" = queue.Queue(maxsize=1000)
165+
self._worker: threading.Thread = threading.Thread(target=self._worker_loop, daemon=True)
166+
self._worker.start()
161167
self._load_persistent_data()
162168

163169
def _load_persistent_data(self):
@@ -242,14 +248,24 @@ def record(self,
242248
data=data,
243249
milestone=milestone
244250
)
245-
246-
# Send in background thread to avoid blocking
251+
# Enqueue for background worker (non-blocking). Drop on backpressure.
247252
current_context = contextvars.copy_context()
248-
thread = threading.Thread(
249-
target=lambda: current_context.run(self._send_telemetry, record),
250-
daemon=True
251-
)
252-
thread.start()
253+
try:
254+
self._queue.put_nowait((current_context, record))
255+
except queue.Full:
256+
logger.debug("Telemetry queue full; dropping %s", record.record_type)
257+
258+
def _worker_loop(self):
259+
"""Background worker that serializes telemetry sends."""
260+
while True:
261+
ctx, rec = self._queue.get()
262+
try:
263+
ctx.run(self._send_telemetry, rec)
264+
except Exception:
265+
logger.debug("Telemetry worker send failed", exc_info=True)
266+
finally:
267+
with contextlib.suppress(Exception):
268+
self._queue.task_done()
253269

254270
def _send_telemetry(self, record: TelemetryRecord):
255271
"""Send telemetry data to endpoint"""

0 commit comments

Comments
 (0)