Skip to content

Commit d93e437

Browse files
authored
Fix: HTTP/Stdio transport routing and middleware session persistence (#422)
* Fix: HTTP/Stdio transport routing and middleware session persistence - Ensure session persistence for stdio transport by using stable client_id/global fallback. - Fix Nagle algorithm latency issues by setting TCP_NODELAY. - Cleanup duplicate logging and imports. - Resolve C# NullReferenceException in EditorWindow health check. * Fix: thread-safe middleware singleton and re-enable repo stats schedule
1 parent eb9327c commit d93e437

File tree

9 files changed

+151
-45
lines changed

9 files changed

+151
-45
lines changed

MCPForUnity/Editor/Windows/MCPForUnityEditorWindow.cs

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -214,11 +214,21 @@ private void ScheduleHealthCheck()
214214
{
215215
EditorApplication.delayCall += async () =>
216216
{
217+
// Ensure window and components are still valid before execution
217218
if (this == null || connectionSection == null)
218219
{
219220
return;
220221
}
221-
await connectionSection.VerifyBridgeConnectionAsync();
222+
223+
try
224+
{
225+
await connectionSection.VerifyBridgeConnectionAsync();
226+
}
227+
catch (Exception ex)
228+
{
229+
// Log but don't crash if verification fails during cleanup
230+
McpLog.Warn($"Health check verification failed: {ex.Message}");
231+
}
222232
};
223233
}
224234
}

Server/src/main.py

Lines changed: 14 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,10 @@
2222
from core.telemetry import record_milestone, record_telemetry, MilestoneType, RecordType, get_package_version
2323
from services.tools import register_all_tools
2424
from transport.legacy.unity_connection import get_unity_connection_pool, UnityConnectionPool
25-
from transport.unity_instance_middleware import UnityInstanceMiddleware, set_unity_instance_middleware
25+
from transport.unity_instance_middleware import (
26+
UnityInstanceMiddleware,
27+
get_unity_instance_middleware
28+
)
2629

2730
# Configure logging using settings from config
2831
logging.basicConfig(
@@ -44,22 +47,25 @@
4447
_fh.setFormatter(logging.Formatter(config.log_format))
4548
_fh.setLevel(getattr(logging, config.log_level))
4649
logger.addHandler(_fh)
50+
logger.propagate = False # Prevent double logging to root logger
4751
# Also route telemetry logger to the same rotating file and normal level
4852
try:
4953
tlog = logging.getLogger("unity-mcp-telemetry")
5054
tlog.setLevel(getattr(logging, config.log_level))
5155
tlog.addHandler(_fh)
52-
except Exception:
56+
tlog.propagate = False # Prevent double logging for telemetry too
57+
except Exception as exc:
5358
# Never let logging setup break startup
54-
pass
55-
except Exception:
59+
logger.debug("Failed to configure telemetry logger", exc_info=exc)
60+
except Exception as exc:
5661
# Never let logging setup break startup
57-
pass
62+
logger.debug("Failed to configure main logger file handler", exc_info=exc)
5863
# Quieten noisy third-party loggers to avoid clutter during stdio handshake
59-
for noisy in ("httpx", "urllib3"):
64+
for noisy in ("httpx", "urllib3", "mcp.server.lowlevel.server"):
6065
try:
6166
logging.getLogger(noisy).setLevel(
6267
max(logging.WARNING, getattr(logging, config.log_level)))
68+
logging.getLogger(noisy).propagate = False
6369
except Exception:
6470
pass
6571

@@ -258,8 +264,8 @@ async def plugin_sessions_route(_: Request) -> JSONResponse:
258264

259265

260266
# Initialize and register middleware for session-based Unity instance routing
261-
unity_middleware = UnityInstanceMiddleware()
262-
set_unity_instance_middleware(unity_middleware)
267+
# Using the singleton getter ensures we use the same instance everywhere
268+
unity_middleware = get_unity_instance_middleware()
263269
mcp.add_middleware(unity_middleware)
264270
logger.info("Registered Unity instance middleware for session-based routing")
265271

Server/src/services/resources/unity_instances.py

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
from services.registry import mcp_for_unity_resource
88
from transport.legacy.unity_connection import get_unity_connection_pool
99
from transport.plugin_hub import PluginHub
10-
from transport.unity_transport import _is_http_transport
10+
from transport.unity_transport import _current_transport
1111

1212

1313
@mcp_for_unity_resource(
@@ -36,7 +36,8 @@ async def unity_instances(ctx: Context) -> dict[str, Any]:
3636
await ctx.info("Listing Unity instances")
3737

3838
try:
39-
if _is_http_transport():
39+
transport = _current_transport()
40+
if transport == "http":
4041
# HTTP/WebSocket transport: query PluginHub
4142
sessions_data = await PluginHub.get_sessions()
4243
sessions = sessions_data.sessions
@@ -71,7 +72,7 @@ async def unity_instances(ctx: Context) -> dict[str, Any]:
7172

7273
result = {
7374
"success": True,
74-
"transport": "http",
75+
"transport": transport,
7576
"instance_count": len(instances),
7677
"instances": instances,
7778
}
@@ -98,7 +99,7 @@ async def unity_instances(ctx: Context) -> dict[str, Any]:
9899

99100
result = {
100101
"success": True,
101-
"transport": "stdio",
102+
"transport": transport,
102103
"instance_count": len(instances),
103104
"instances": [inst.to_dict() for inst in instances],
104105
}

Server/src/services/tools/debug_request_context.py

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from fastmcp import Context
44
from services.registry import mcp_for_unity_tool
55
from transport.unity_instance_middleware import get_unity_instance_middleware
6+
from transport.plugin_hub import PluginHub
67

78

89
@mcp_for_unity_tool(
@@ -35,8 +36,15 @@ def debug_request_context(ctx: Context) -> dict[str, Any]:
3536

3637
# Get session state info via middleware
3738
middleware = get_unity_instance_middleware()
38-
derived_key = middleware._get_session_key(ctx)
39+
derived_key = middleware.get_session_key(ctx)
3940
active_instance = middleware.get_active_instance(ctx)
41+
42+
# Debugging middleware internals
43+
with middleware._lock:
44+
all_keys = list(middleware._active_by_key.keys())
45+
46+
# Debugging PluginHub state
47+
plugin_hub_configured = PluginHub.is_configured()
4048

4149
return {
4250
"success": True,
@@ -53,6 +61,9 @@ def debug_request_context(ctx: Context) -> dict[str, Any]:
5361
"session_state": {
5462
"derived_key": derived_key,
5563
"active_instance": active_instance,
64+
"all_keys_in_store": all_keys,
65+
"plugin_hub_configured": plugin_hub_configured,
66+
"middleware_id": id(middleware),
5667
},
5768
"available_attributes": ctx_attrs,
5869
},

Server/src/services/tools/set_active_instance.py

Lines changed: 59 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
from transport.legacy.unity_connection import get_unity_connection_pool
77
from transport.unity_instance_middleware import get_unity_instance_middleware
88
from transport.plugin_hub import PluginHub
9-
from transport.unity_transport import _is_http_transport
9+
from transport.unity_transport import _current_transport
1010

1111

1212
@mcp_for_unity_tool(
@@ -16,8 +16,10 @@ async def set_active_instance(
1616
ctx: Context,
1717
instance: Annotated[str, "Target instance (Name@hash or hash prefix)"]
1818
) -> dict[str, Any]:
19+
transport = _current_transport()
20+
1921
# Discover running instances based on transport
20-
if _is_http_transport():
22+
if transport == "http":
2123
sessions_data = await PluginHub.get_sessions()
2224
sessions = sessions_data.sessions
2325
instances = []
@@ -42,26 +44,69 @@ async def set_active_instance(
4244
"success": False,
4345
"error": "No Unity instances are currently connected. Start Unity and press 'Start Session'."
4446
}
45-
ids = {inst.id: inst for inst in instances}
46-
hashes = {}
47-
for inst in instances:
48-
# exact hash and prefix map; last write wins but we'll detect ambiguity
49-
hashes.setdefault(inst.hash, inst)
47+
ids = {inst.id: inst for inst in instances if getattr(inst, "id", None)}
5048

51-
# Disallow anything except the explicit Name@hash id to ensure determinism
5249
value = (instance or "").strip()
53-
if not value or "@" not in value:
50+
if not value:
5451
return {
5552
"success": False,
56-
"error": "Instance identifier must be Name@hash. "
57-
"Use unity://instances to copy the exact id (e.g., MyProject@abcd1234)."
53+
"error": "Instance identifier is required. "
54+
"Use unity://instances to copy a Name@hash or provide a hash prefix."
5855
}
5956
resolved = None
60-
resolved = ids.get(value)
57+
if "@" in value:
58+
resolved = ids.get(value)
59+
if resolved is None:
60+
return {
61+
"success": False,
62+
"error": f"Instance '{value}' not found. "
63+
"Use unity://instances to copy an exact Name@hash."
64+
}
65+
else:
66+
lookup = value.lower()
67+
matches = []
68+
for inst in instances:
69+
if not getattr(inst, "id", None):
70+
continue
71+
inst_hash = getattr(inst, "hash", "")
72+
if inst_hash and inst_hash.lower().startswith(lookup):
73+
matches.append(inst)
74+
if not matches:
75+
return {
76+
"success": False,
77+
"error": f"Instance hash '{value}' does not match any running Unity editors. "
78+
"Use unity://instances to confirm the available hashes."
79+
}
80+
if len(matches) > 1:
81+
matching_ids = ", ".join(
82+
inst.id for inst in matches if getattr(inst, "id", None)
83+
) or "multiple instances"
84+
return {
85+
"success": False,
86+
"error": f"Instance hash '{value}' is ambiguous ({matching_ids}). "
87+
"Provide the full Name@hash from unity://instances."
88+
}
89+
resolved = matches[0]
90+
6191
if resolved is None:
62-
return {"success": False, "error": f"Instance '{value}' not found. Use unity://instances to choose a valid Name@hash."}
92+
# Should be unreachable due to logic above, but satisfies static analysis
93+
return {
94+
"success": False,
95+
"error": "Internal error: Instance resolution failed."
96+
}
6397

6498
# Store selection in middleware (session-scoped)
6599
middleware = get_unity_instance_middleware()
100+
# We use middleware.set_active_instance to persist the selection.
101+
# The session key is an internal detail but useful for debugging response.
66102
middleware.set_active_instance(ctx, resolved.id)
67-
return {"success": True, "message": f"Active instance set to {resolved.id}", "data": {"instance": resolved.id}}
103+
session_key = middleware.get_session_key(ctx)
104+
105+
return {
106+
"success": True,
107+
"message": f"Active instance set to {resolved.id}",
108+
"data": {
109+
"instance": resolved.id,
110+
"session_key": session_key,
111+
},
112+
}

Server/src/transport/legacy/unity_connection.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,8 @@ def connect(self) -> bool:
6060
# Bounded connect to avoid indefinite blocking
6161
connect_timeout = float(
6262
getattr(config, "connection_timeout", 1.0))
63+
# We trust config.unity_host (default 127.0.0.1) but future improvements
64+
# could dynamically prefer 'localhost' depending on OS resolver behavior.
6365
self.sock = socket.create_connection(
6466
(self.host, self.port), connect_timeout)
6567
self._prepare_socket(self.sock)

Server/src/transport/unity_instance_middleware.py

Lines changed: 40 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -5,21 +5,29 @@
55
into the request-scoped state, allowing tools to access it via ctx.get_state("unity_instance").
66
"""
77
from threading import RLock
8+
import logging
89

910
from fastmcp.server.middleware import Middleware, MiddlewareContext
1011

1112
from transport.plugin_hub import PluginHub
1213

14+
logger = logging.getLogger("mcp-for-unity-server")
15+
1316
# Store a global reference to the middleware instance so tools can interact
1417
# with it to set or clear the active unity instance.
1518
_unity_instance_middleware = None
19+
_middleware_lock = RLock()
1620

1721

1822
def get_unity_instance_middleware() -> 'UnityInstanceMiddleware':
1923
"""Get the global Unity instance middleware."""
24+
global _unity_instance_middleware
2025
if _unity_instance_middleware is None:
21-
raise RuntimeError(
22-
"UnityInstanceMiddleware not initialized. Call set_unity_instance_middleware first.")
26+
with _middleware_lock:
27+
if _unity_instance_middleware is None:
28+
# Auto-initialize if not set (lazy singleton) to handle import order or test cases
29+
_unity_instance_middleware = UnityInstanceMiddleware()
30+
2331
return _unity_instance_middleware
2432

2533

@@ -42,37 +50,36 @@ def __init__(self):
4250
self._active_by_key: dict[str, str] = {}
4351
self._lock = RLock()
4452

45-
def _get_session_key(self, ctx) -> str:
53+
def get_session_key(self, ctx) -> str:
4654
"""
4755
Derive a stable key for the calling session.
4856
49-
Uses ctx.session_id if available, falls back to 'global'.
57+
Prioritizes client_id for stability.
58+
If client_id is missing, falls back to 'global' (assuming single-user local mode),
59+
ignoring session_id which can be unstable in some transports/clients.
5060
"""
51-
session_id = getattr(ctx, "session_id", None)
52-
if isinstance(session_id, str) and session_id:
53-
return session_id
54-
5561
client_id = getattr(ctx, "client_id", None)
5662
if isinstance(client_id, str) and client_id:
5763
return client_id
5864

65+
# Fallback to global for local dev stability
5966
return "global"
6067

6168
def set_active_instance(self, ctx, instance_id: str) -> None:
6269
"""Store the active instance for this session."""
63-
key = self._get_session_key(ctx)
70+
key = self.get_session_key(ctx)
6471
with self._lock:
6572
self._active_by_key[key] = instance_id
6673

6774
def get_active_instance(self, ctx) -> str | None:
6875
"""Retrieve the active instance for this session."""
69-
key = self._get_session_key(ctx)
76+
key = self.get_session_key(ctx)
7077
with self._lock:
7178
return self._active_by_key.get(key)
7279

7380
def clear_active_instance(self, ctx) -> None:
7481
"""Clear the stored instance for this session."""
75-
key = self._get_session_key(ctx)
82+
key = self.get_session_key(ctx)
7683
with self._lock:
7784
self._active_by_key.pop(key, None)
7885

@@ -82,13 +89,32 @@ async def on_call_tool(self, context: MiddlewareContext, call_next):
8289

8390
active_instance = self.get_active_instance(ctx)
8491
if active_instance:
92+
# If using HTTP transport (PluginHub configured), validate session
93+
# But for stdio transport (no PluginHub needed or maybe partially configured),
94+
# we should be careful not to clear instance just because PluginHub can't resolve it.
95+
# The 'active_instance' (Name@hash) might be valid for stdio even if PluginHub fails.
96+
8597
session_id: str | None = None
98+
# Only validate via PluginHub if we are actually using HTTP transport
99+
# OR if we want to support hybrid mode. For now, let's be permissive.
86100
if PluginHub.is_configured():
87101
try:
102+
# resolving session_id might fail if the plugin disconnected
103+
# We only need session_id for HTTP transport routing.
104+
# For stdio, we just need the instance ID.
88105
session_id = await PluginHub._resolve_session_id(active_instance)
89-
except Exception:
90-
self.clear_active_instance(ctx)
91-
return await call_next(context)
106+
except Exception as exc:
107+
# If resolution fails, it means the Unity instance is not reachable via HTTP/WS.
108+
# If we are in stdio mode, this might still be fine if the user is just setting state?
109+
# But usually if PluginHub is configured, we expect it to work.
110+
# Let's LOG the error but NOT clear the instance immediately to avoid flickering,
111+
# or at least debug why it's failing.
112+
logger.debug(
113+
"PluginHub session resolution failed for %s: %s; leaving active_instance unchanged",
114+
active_instance,
115+
exc,
116+
exc_info=True,
117+
)
92118

93119
ctx.set_state("unity_instance", active_instance)
94120
if session_id is not None:

Server/src/transport/unity_transport.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,11 @@ def _is_http_transport() -> bool:
1919
return os.environ.get("UNITY_MCP_TRANSPORT", "stdio").lower() == "http"
2020

2121

22+
def _current_transport() -> str:
23+
"""Expose the active transport mode as a simple string identifier."""
24+
return "http" if _is_http_transport() else "stdio"
25+
26+
2227
def with_unity_instance(
2328
log: str | Callable[[Context, tuple, dict, str | None], str] | None = None,
2429
*,

0 commit comments

Comments
 (0)