mcpforunityserver 9.3.0b20260131003150__tar.gz → 9.3.0b20260131004250__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (117) hide show
  1. {mcpforunityserver-9.3.0b20260131003150 → mcpforunityserver-9.3.0b20260131004250}/PKG-INFO +1 -1
  2. {mcpforunityserver-9.3.0b20260131003150 → mcpforunityserver-9.3.0b20260131004250}/pyproject.toml +1 -1
  3. {mcpforunityserver-9.3.0b20260131003150 → mcpforunityserver-9.3.0b20260131004250}/src/mcpforunityserver.egg-info/PKG-INFO +1 -1
  4. {mcpforunityserver-9.3.0b20260131003150 → mcpforunityserver-9.3.0b20260131004250}/src/transport/legacy/unity_connection.py +29 -7
  5. {mcpforunityserver-9.3.0b20260131003150 → mcpforunityserver-9.3.0b20260131004250}/src/transport/models.py +5 -0
  6. {mcpforunityserver-9.3.0b20260131003150 → mcpforunityserver-9.3.0b20260131004250}/src/transport/plugin_hub.py +114 -12
  7. {mcpforunityserver-9.3.0b20260131003150 → mcpforunityserver-9.3.0b20260131004250}/src/transport/unity_instance_middleware.py +4 -3
  8. {mcpforunityserver-9.3.0b20260131003150 → mcpforunityserver-9.3.0b20260131004250}/src/transport/unity_transport.py +1 -1
  9. {mcpforunityserver-9.3.0b20260131003150 → mcpforunityserver-9.3.0b20260131004250}/tests/test_core_infrastructure_characterization.py +4 -2
  10. {mcpforunityserver-9.3.0b20260131003150 → mcpforunityserver-9.3.0b20260131004250}/LICENSE +0 -0
  11. {mcpforunityserver-9.3.0b20260131003150 → mcpforunityserver-9.3.0b20260131004250}/README.md +0 -0
  12. {mcpforunityserver-9.3.0b20260131003150 → mcpforunityserver-9.3.0b20260131004250}/setup.cfg +0 -0
  13. {mcpforunityserver-9.3.0b20260131003150 → mcpforunityserver-9.3.0b20260131004250}/src/cli/__init__.py +0 -0
  14. {mcpforunityserver-9.3.0b20260131003150 → mcpforunityserver-9.3.0b20260131004250}/src/cli/commands/__init__.py +0 -0
  15. {mcpforunityserver-9.3.0b20260131003150 → mcpforunityserver-9.3.0b20260131004250}/src/cli/commands/animation.py +0 -0
  16. {mcpforunityserver-9.3.0b20260131003150 → mcpforunityserver-9.3.0b20260131004250}/src/cli/commands/asset.py +0 -0
  17. {mcpforunityserver-9.3.0b20260131003150 → mcpforunityserver-9.3.0b20260131004250}/src/cli/commands/audio.py +0 -0
  18. {mcpforunityserver-9.3.0b20260131003150 → mcpforunityserver-9.3.0b20260131004250}/src/cli/commands/batch.py +0 -0
  19. {mcpforunityserver-9.3.0b20260131003150 → mcpforunityserver-9.3.0b20260131004250}/src/cli/commands/code.py +0 -0
  20. {mcpforunityserver-9.3.0b20260131003150 → mcpforunityserver-9.3.0b20260131004250}/src/cli/commands/component.py +0 -0
  21. {mcpforunityserver-9.3.0b20260131003150 → mcpforunityserver-9.3.0b20260131004250}/src/cli/commands/editor.py +0 -0
  22. {mcpforunityserver-9.3.0b20260131003150 → mcpforunityserver-9.3.0b20260131004250}/src/cli/commands/gameobject.py +0 -0
  23. {mcpforunityserver-9.3.0b20260131003150 → mcpforunityserver-9.3.0b20260131004250}/src/cli/commands/instance.py +0 -0
  24. {mcpforunityserver-9.3.0b20260131003150 → mcpforunityserver-9.3.0b20260131004250}/src/cli/commands/lighting.py +0 -0
  25. {mcpforunityserver-9.3.0b20260131003150 → mcpforunityserver-9.3.0b20260131004250}/src/cli/commands/material.py +0 -0
  26. {mcpforunityserver-9.3.0b20260131003150 → mcpforunityserver-9.3.0b20260131004250}/src/cli/commands/prefab.py +0 -0
  27. {mcpforunityserver-9.3.0b20260131003150 → mcpforunityserver-9.3.0b20260131004250}/src/cli/commands/scene.py +0 -0
  28. {mcpforunityserver-9.3.0b20260131003150 → mcpforunityserver-9.3.0b20260131004250}/src/cli/commands/script.py +0 -0
  29. {mcpforunityserver-9.3.0b20260131003150 → mcpforunityserver-9.3.0b20260131004250}/src/cli/commands/shader.py +0 -0
  30. {mcpforunityserver-9.3.0b20260131003150 → mcpforunityserver-9.3.0b20260131004250}/src/cli/commands/texture.py +0 -0
  31. {mcpforunityserver-9.3.0b20260131003150 → mcpforunityserver-9.3.0b20260131004250}/src/cli/commands/tool.py +0 -0
  32. {mcpforunityserver-9.3.0b20260131003150 → mcpforunityserver-9.3.0b20260131004250}/src/cli/commands/ui.py +0 -0
  33. {mcpforunityserver-9.3.0b20260131003150 → mcpforunityserver-9.3.0b20260131004250}/src/cli/commands/vfx.py +0 -0
  34. {mcpforunityserver-9.3.0b20260131003150 → mcpforunityserver-9.3.0b20260131004250}/src/cli/main.py +0 -0
  35. {mcpforunityserver-9.3.0b20260131003150 → mcpforunityserver-9.3.0b20260131004250}/src/cli/utils/__init__.py +0 -0
  36. {mcpforunityserver-9.3.0b20260131003150 → mcpforunityserver-9.3.0b20260131004250}/src/cli/utils/config.py +0 -0
  37. {mcpforunityserver-9.3.0b20260131003150 → mcpforunityserver-9.3.0b20260131004250}/src/cli/utils/confirmation.py +0 -0
  38. {mcpforunityserver-9.3.0b20260131003150 → mcpforunityserver-9.3.0b20260131004250}/src/cli/utils/connection.py +0 -0
  39. {mcpforunityserver-9.3.0b20260131003150 → mcpforunityserver-9.3.0b20260131004250}/src/cli/utils/constants.py +0 -0
  40. {mcpforunityserver-9.3.0b20260131003150 → mcpforunityserver-9.3.0b20260131004250}/src/cli/utils/output.py +0 -0
  41. {mcpforunityserver-9.3.0b20260131003150 → mcpforunityserver-9.3.0b20260131004250}/src/cli/utils/parsers.py +0 -0
  42. {mcpforunityserver-9.3.0b20260131003150 → mcpforunityserver-9.3.0b20260131004250}/src/cli/utils/suggestions.py +0 -0
  43. {mcpforunityserver-9.3.0b20260131003150 → mcpforunityserver-9.3.0b20260131004250}/src/core/__init__.py +0 -0
  44. {mcpforunityserver-9.3.0b20260131003150 → mcpforunityserver-9.3.0b20260131004250}/src/core/config.py +0 -0
  45. {mcpforunityserver-9.3.0b20260131003150 → mcpforunityserver-9.3.0b20260131004250}/src/core/constants.py +0 -0
  46. {mcpforunityserver-9.3.0b20260131003150 → mcpforunityserver-9.3.0b20260131004250}/src/core/logging_decorator.py +0 -0
  47. {mcpforunityserver-9.3.0b20260131003150 → mcpforunityserver-9.3.0b20260131004250}/src/core/telemetry.py +0 -0
  48. {mcpforunityserver-9.3.0b20260131003150 → mcpforunityserver-9.3.0b20260131004250}/src/core/telemetry_decorator.py +0 -0
  49. {mcpforunityserver-9.3.0b20260131003150 → mcpforunityserver-9.3.0b20260131004250}/src/main.py +0 -0
  50. {mcpforunityserver-9.3.0b20260131003150 → mcpforunityserver-9.3.0b20260131004250}/src/mcpforunityserver.egg-info/SOURCES.txt +0 -0
  51. {mcpforunityserver-9.3.0b20260131003150 → mcpforunityserver-9.3.0b20260131004250}/src/mcpforunityserver.egg-info/dependency_links.txt +0 -0
  52. {mcpforunityserver-9.3.0b20260131003150 → mcpforunityserver-9.3.0b20260131004250}/src/mcpforunityserver.egg-info/entry_points.txt +0 -0
  53. {mcpforunityserver-9.3.0b20260131003150 → mcpforunityserver-9.3.0b20260131004250}/src/mcpforunityserver.egg-info/requires.txt +0 -0
  54. {mcpforunityserver-9.3.0b20260131003150 → mcpforunityserver-9.3.0b20260131004250}/src/mcpforunityserver.egg-info/top_level.txt +0 -0
  55. {mcpforunityserver-9.3.0b20260131003150 → mcpforunityserver-9.3.0b20260131004250}/src/models/__init__.py +0 -0
  56. {mcpforunityserver-9.3.0b20260131003150 → mcpforunityserver-9.3.0b20260131004250}/src/models/models.py +0 -0
  57. {mcpforunityserver-9.3.0b20260131003150 → mcpforunityserver-9.3.0b20260131004250}/src/models/unity_response.py +0 -0
  58. {mcpforunityserver-9.3.0b20260131003150 → mcpforunityserver-9.3.0b20260131004250}/src/services/__init__.py +0 -0
  59. {mcpforunityserver-9.3.0b20260131003150 → mcpforunityserver-9.3.0b20260131004250}/src/services/api_key_service.py +0 -0
  60. {mcpforunityserver-9.3.0b20260131003150 → mcpforunityserver-9.3.0b20260131004250}/src/services/custom_tool_service.py +0 -0
  61. {mcpforunityserver-9.3.0b20260131003150 → mcpforunityserver-9.3.0b20260131004250}/src/services/registry/__init__.py +0 -0
  62. {mcpforunityserver-9.3.0b20260131003150 → mcpforunityserver-9.3.0b20260131004250}/src/services/registry/resource_registry.py +0 -0
  63. {mcpforunityserver-9.3.0b20260131003150 → mcpforunityserver-9.3.0b20260131004250}/src/services/registry/tool_registry.py +0 -0
  64. {mcpforunityserver-9.3.0b20260131003150 → mcpforunityserver-9.3.0b20260131004250}/src/services/resources/__init__.py +0 -0
  65. {mcpforunityserver-9.3.0b20260131003150 → mcpforunityserver-9.3.0b20260131004250}/src/services/resources/active_tool.py +0 -0
  66. {mcpforunityserver-9.3.0b20260131003150 → mcpforunityserver-9.3.0b20260131004250}/src/services/resources/custom_tools.py +0 -0
  67. {mcpforunityserver-9.3.0b20260131003150 → mcpforunityserver-9.3.0b20260131004250}/src/services/resources/editor_state.py +0 -0
  68. {mcpforunityserver-9.3.0b20260131003150 → mcpforunityserver-9.3.0b20260131004250}/src/services/resources/gameobject.py +0 -0
  69. {mcpforunityserver-9.3.0b20260131003150 → mcpforunityserver-9.3.0b20260131004250}/src/services/resources/layers.py +0 -0
  70. {mcpforunityserver-9.3.0b20260131003150 → mcpforunityserver-9.3.0b20260131004250}/src/services/resources/menu_items.py +0 -0
  71. {mcpforunityserver-9.3.0b20260131003150 → mcpforunityserver-9.3.0b20260131004250}/src/services/resources/prefab.py +0 -0
  72. {mcpforunityserver-9.3.0b20260131003150 → mcpforunityserver-9.3.0b20260131004250}/src/services/resources/prefab_stage.py +0 -0
  73. {mcpforunityserver-9.3.0b20260131003150 → mcpforunityserver-9.3.0b20260131004250}/src/services/resources/project_info.py +0 -0
  74. {mcpforunityserver-9.3.0b20260131003150 → mcpforunityserver-9.3.0b20260131004250}/src/services/resources/selection.py +0 -0
  75. {mcpforunityserver-9.3.0b20260131003150 → mcpforunityserver-9.3.0b20260131004250}/src/services/resources/tags.py +0 -0
  76. {mcpforunityserver-9.3.0b20260131003150 → mcpforunityserver-9.3.0b20260131004250}/src/services/resources/tests.py +0 -0
  77. {mcpforunityserver-9.3.0b20260131003150 → mcpforunityserver-9.3.0b20260131004250}/src/services/resources/unity_instances.py +0 -0
  78. {mcpforunityserver-9.3.0b20260131003150 → mcpforunityserver-9.3.0b20260131004250}/src/services/resources/windows.py +0 -0
  79. {mcpforunityserver-9.3.0b20260131003150 → mcpforunityserver-9.3.0b20260131004250}/src/services/state/external_changes_scanner.py +0 -0
  80. {mcpforunityserver-9.3.0b20260131003150 → mcpforunityserver-9.3.0b20260131004250}/src/services/tools/__init__.py +0 -0
  81. {mcpforunityserver-9.3.0b20260131003150 → mcpforunityserver-9.3.0b20260131004250}/src/services/tools/batch_execute.py +0 -0
  82. {mcpforunityserver-9.3.0b20260131003150 → mcpforunityserver-9.3.0b20260131004250}/src/services/tools/debug_request_context.py +0 -0
  83. {mcpforunityserver-9.3.0b20260131003150 → mcpforunityserver-9.3.0b20260131004250}/src/services/tools/execute_custom_tool.py +0 -0
  84. {mcpforunityserver-9.3.0b20260131003150 → mcpforunityserver-9.3.0b20260131004250}/src/services/tools/execute_menu_item.py +0 -0
  85. {mcpforunityserver-9.3.0b20260131003150 → mcpforunityserver-9.3.0b20260131004250}/src/services/tools/find_gameobjects.py +0 -0
  86. {mcpforunityserver-9.3.0b20260131003150 → mcpforunityserver-9.3.0b20260131004250}/src/services/tools/find_in_file.py +0 -0
  87. {mcpforunityserver-9.3.0b20260131003150 → mcpforunityserver-9.3.0b20260131004250}/src/services/tools/manage_asset.py +0 -0
  88. {mcpforunityserver-9.3.0b20260131003150 → mcpforunityserver-9.3.0b20260131004250}/src/services/tools/manage_components.py +0 -0
  89. {mcpforunityserver-9.3.0b20260131003150 → mcpforunityserver-9.3.0b20260131004250}/src/services/tools/manage_editor.py +0 -0
  90. {mcpforunityserver-9.3.0b20260131003150 → mcpforunityserver-9.3.0b20260131004250}/src/services/tools/manage_gameobject.py +0 -0
  91. {mcpforunityserver-9.3.0b20260131003150 → mcpforunityserver-9.3.0b20260131004250}/src/services/tools/manage_material.py +0 -0
  92. {mcpforunityserver-9.3.0b20260131003150 → mcpforunityserver-9.3.0b20260131004250}/src/services/tools/manage_prefabs.py +0 -0
  93. {mcpforunityserver-9.3.0b20260131003150 → mcpforunityserver-9.3.0b20260131004250}/src/services/tools/manage_scene.py +0 -0
  94. {mcpforunityserver-9.3.0b20260131003150 → mcpforunityserver-9.3.0b20260131004250}/src/services/tools/manage_script.py +0 -0
  95. {mcpforunityserver-9.3.0b20260131003150 → mcpforunityserver-9.3.0b20260131004250}/src/services/tools/manage_scriptable_object.py +0 -0
  96. {mcpforunityserver-9.3.0b20260131003150 → mcpforunityserver-9.3.0b20260131004250}/src/services/tools/manage_shader.py +0 -0
  97. {mcpforunityserver-9.3.0b20260131003150 → mcpforunityserver-9.3.0b20260131004250}/src/services/tools/manage_texture.py +0 -0
  98. {mcpforunityserver-9.3.0b20260131003150 → mcpforunityserver-9.3.0b20260131004250}/src/services/tools/manage_vfx.py +0 -0
  99. {mcpforunityserver-9.3.0b20260131003150 → mcpforunityserver-9.3.0b20260131004250}/src/services/tools/preflight.py +0 -0
  100. {mcpforunityserver-9.3.0b20260131003150 → mcpforunityserver-9.3.0b20260131004250}/src/services/tools/read_console.py +0 -0
  101. {mcpforunityserver-9.3.0b20260131003150 → mcpforunityserver-9.3.0b20260131004250}/src/services/tools/refresh_unity.py +0 -0
  102. {mcpforunityserver-9.3.0b20260131003150 → mcpforunityserver-9.3.0b20260131004250}/src/services/tools/run_tests.py +0 -0
  103. {mcpforunityserver-9.3.0b20260131003150 → mcpforunityserver-9.3.0b20260131004250}/src/services/tools/script_apply_edits.py +0 -0
  104. {mcpforunityserver-9.3.0b20260131003150 → mcpforunityserver-9.3.0b20260131004250}/src/services/tools/set_active_instance.py +0 -0
  105. {mcpforunityserver-9.3.0b20260131003150 → mcpforunityserver-9.3.0b20260131004250}/src/services/tools/utils.py +0 -0
  106. {mcpforunityserver-9.3.0b20260131003150 → mcpforunityserver-9.3.0b20260131004250}/src/transport/__init__.py +0 -0
  107. {mcpforunityserver-9.3.0b20260131003150 → mcpforunityserver-9.3.0b20260131004250}/src/transport/legacy/port_discovery.py +0 -0
  108. {mcpforunityserver-9.3.0b20260131003150 → mcpforunityserver-9.3.0b20260131004250}/src/transport/legacy/stdio_port_registry.py +0 -0
  109. {mcpforunityserver-9.3.0b20260131003150 → mcpforunityserver-9.3.0b20260131004250}/src/transport/plugin_registry.py +0 -0
  110. {mcpforunityserver-9.3.0b20260131003150 → mcpforunityserver-9.3.0b20260131004250}/src/utils/focus_nudge.py +0 -0
  111. {mcpforunityserver-9.3.0b20260131003150 → mcpforunityserver-9.3.0b20260131004250}/src/utils/module_discovery.py +0 -0
  112. {mcpforunityserver-9.3.0b20260131003150 → mcpforunityserver-9.3.0b20260131004250}/tests/test_cli.py +0 -0
  113. {mcpforunityserver-9.3.0b20260131003150 → mcpforunityserver-9.3.0b20260131004250}/tests/test_cli_commands_characterization.py +0 -0
  114. {mcpforunityserver-9.3.0b20260131003150 → mcpforunityserver-9.3.0b20260131004250}/tests/test_models_characterization.py +0 -0
  115. {mcpforunityserver-9.3.0b20260131003150 → mcpforunityserver-9.3.0b20260131004250}/tests/test_param_normalizer.py +0 -0
  116. {mcpforunityserver-9.3.0b20260131003150 → mcpforunityserver-9.3.0b20260131004250}/tests/test_transport_characterization.py +0 -0
  117. {mcpforunityserver-9.3.0b20260131003150 → mcpforunityserver-9.3.0b20260131004250}/tests/test_utilities_characterization.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mcpforunityserver
3
- Version: 9.3.0b20260131003150
3
+ Version: 9.3.0b20260131004250
4
4
  Summary: MCP for Unity Server: A Unity package for Unity Editor integration via the Model Context Protocol (MCP).
5
5
  Author-email: Marcus Sanatan <msanatan@gmail.com>, David Sarno <david.sarno@gmail.com>, Wu Shutong <martinwfire@gmail.com>
6
6
  License-Expression: MIT
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "mcpforunityserver"
3
- version = "9.3.0b20260131003150"
3
+ version = "9.3.0b20260131004250"
4
4
  description = "MCP for Unity Server: A Unity package for Unity Editor integration via the Model Context Protocol (MCP)."
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mcpforunityserver
3
- Version: 9.3.0b20260131003150
3
+ Version: 9.3.0b20260131004250
4
4
  Summary: MCP for Unity Server: A Unity package for Unity Editor integration via the Model Context Protocol (MCP).
5
5
  Author-email: Marcus Sanatan <msanatan@gmail.com>, David Sarno <david.sarno@gmail.com>, Wu Shutong <martinwfire@gmail.com>
6
6
  License-Expression: MIT
@@ -306,8 +306,10 @@ class UnityConnection:
306
306
  for attempt in range(attempts + 1):
307
307
  try:
308
308
  # Ensure connected (handshake occurs within connect())
309
+ t_conn_start = time.time()
309
310
  if not self.sock and not self.connect():
310
311
  raise ConnectionError("Could not connect to Unity")
312
+ logger.info("[TIMING-STDIO] connect took %.3fs command=%s", time.time() - t_conn_start, command_type)
311
313
 
312
314
  # Build payload
313
315
  if command_type == 'ping':
@@ -324,12 +326,14 @@ class UnityConnection:
324
326
  with contextlib.suppress(Exception):
325
327
  logger.debug(
326
328
  f"send {len(payload)} bytes; mode={mode}; head={payload[:32].decode('utf-8', 'ignore')}")
329
+ t_send_start = time.time()
327
330
  if self.use_framing:
328
331
  header = struct.pack('>Q', len(payload))
329
332
  self.sock.sendall(header)
330
333
  self.sock.sendall(payload)
331
334
  else:
332
335
  self.sock.sendall(payload)
336
+ logger.info("[TIMING-STDIO] sendall took %.3fs command=%s", time.time() - t_send_start, command_type)
333
337
 
334
338
  # During retry bursts use a short receive timeout and ensure restoration
335
339
  restore_timeout = None
@@ -337,7 +341,9 @@ class UnityConnection:
337
341
  restore_timeout = self.sock.gettimeout()
338
342
  self.sock.settimeout(1.0)
339
343
  try:
344
+ t_recv_start = time.time()
340
345
  response_data = self.receive_full_response(self.sock)
346
+ logger.info("[TIMING-STDIO] receive took %.3fs command=%s len=%d", time.time() - t_recv_start, command_type, len(response_data))
341
347
  with contextlib.suppress(Exception):
342
348
  logger.debug(
343
349
  f"recv {len(response_data)} bytes; mode={mode}")
@@ -419,7 +425,8 @@ class UnityConnection:
419
425
 
420
426
  # Cap backoff depending on state
421
427
  if status and status.get('reloading'):
422
- cap = 0.8
428
+ # Domain reload can take 10-20s; use longer waits
429
+ cap = 5.0
423
430
  elif fast_error:
424
431
  cap = 0.25
425
432
  else:
@@ -761,22 +768,36 @@ def send_command_with_retry(
761
768
  Uses config.reload_retry_ms and config.reload_max_retries by default. Preserves the
762
769
  structured failure if retries are exhausted.
763
770
  """
771
+ t_retry_start = time.time()
772
+ logger.info("[TIMING-STDIO] send_command_with_retry START command=%s", command_type)
773
+ t_get_conn = time.time()
764
774
  conn = get_unity_connection(instance_id)
775
+ logger.info("[TIMING-STDIO] get_unity_connection took %.3fs command=%s", time.time() - t_get_conn, command_type)
765
776
  if max_retries is None:
766
777
  max_retries = getattr(config, "reload_max_retries", 40)
767
778
  if retry_ms is None:
768
779
  retry_ms = getattr(config, "reload_retry_ms", 250)
780
+ # Default to 20s to handle domain reloads (which can take 10-20s after tests or script changes).
781
+ #
782
+ # NOTE: This wait can impact agentic workflows where domain reloads happen
783
+ # frequently (e.g., after test runs, script compilation). The 20s default
784
+ # balances handling slow reloads vs. avoiding unnecessary delays.
785
+ #
786
+ # TODO: Make this more deterministic by detecting Unity's actual reload state
787
+ # rather than blindly waiting up to 20s. See Issue #657.
788
+ #
789
+ # Configurable via: UNITY_MCP_RELOAD_MAX_WAIT_S (default: 20.0, max: 20.0)
769
790
  try:
770
791
  max_wait_s = float(os.environ.get(
771
- "UNITY_MCP_RELOAD_MAX_WAIT_S", "2.0"))
792
+ "UNITY_MCP_RELOAD_MAX_WAIT_S", "20.0"))
772
793
  except ValueError as e:
773
- raw_val = os.environ.get("UNITY_MCP_RELOAD_MAX_WAIT_S", "2.0")
794
+ raw_val = os.environ.get("UNITY_MCP_RELOAD_MAX_WAIT_S", "20.0")
774
795
  logger.warning(
775
- "Invalid UNITY_MCP_RELOAD_MAX_WAIT_S=%r, using default 2.0: %s",
796
+ "Invalid UNITY_MCP_RELOAD_MAX_WAIT_S=%r, using default 20.0: %s",
776
797
  raw_val, e)
777
- max_wait_s = 2.0
778
- # Clamp to [0, 30] to prevent misconfiguration from causing excessive waits
779
- max_wait_s = max(0.0, min(max_wait_s, 30.0))
798
+ max_wait_s = 20.0
799
+ # Clamp to [0, 20] to prevent misconfiguration from causing excessive waits
800
+ max_wait_s = max(0.0, min(max_wait_s, 20.0))
780
801
 
781
802
  # If retry_on_reload=False, disable connection-level retries too (issue #577)
782
803
  # Commands that trigger compilation/reload shouldn't retry on disconnect
@@ -847,6 +868,7 @@ def send_command_with_retry(
847
868
  instance_id or "default",
848
869
  waited,
849
870
  )
871
+ logger.info("[TIMING-STDIO] send_command_with_retry DONE total=%.3fs command=%s", time.time() - t_retry_start, command_type)
850
872
  return response
851
873
 
852
874
 
@@ -23,6 +23,11 @@ class ExecuteCommandMessage(BaseModel):
23
23
  params: dict[str, Any]
24
24
  timeout: float
25
25
 
26
+
27
+ class PingMessage(BaseModel):
28
+ """Server-initiated ping to detect dead connections."""
29
+ type: str = "ping"
30
+
26
31
  # Incoming (Plugin -> Server)
27
32
 
28
33
 
@@ -7,7 +7,7 @@ import logging
7
7
  import os
8
8
  import time
9
9
  import uuid
10
- from typing import Any
10
+ from typing import Any, ClassVar
11
11
 
12
12
  from starlette.endpoints import WebSocketEndpoint
13
13
  from starlette.websockets import WebSocket
@@ -21,6 +21,7 @@ from transport.models import (
21
21
  WelcomeMessage,
22
22
  RegisteredMessage,
23
23
  ExecuteCommandMessage,
24
+ PingMessage,
24
25
  RegisterMessage,
25
26
  RegisterToolsMessage,
26
27
  PongMessage,
@@ -29,7 +30,7 @@ from transport.models import (
29
30
  SessionDetails,
30
31
  )
31
32
 
32
- logger = logging.getLogger("mcp-for-unity-server")
33
+ logger = logging.getLogger(__name__)
33
34
 
34
35
 
35
36
  class PluginDisconnectedError(RuntimeError):
@@ -63,6 +64,10 @@ class PluginHub(WebSocketEndpoint):
63
64
  KEEP_ALIVE_INTERVAL = 15
64
65
  SERVER_TIMEOUT = 30
65
66
  COMMAND_TIMEOUT = 30
67
+ # Server-side ping interval (seconds) - how often to send pings to Unity
68
+ PING_INTERVAL = 10
69
+ # Max time (seconds) to wait for pong before considering connection dead
70
+ PING_TIMEOUT = 20
66
71
  # Timeout (seconds) for fast-fail commands like ping/read_console/get_editor_state.
67
72
  # Keep short so MCP clients aren't blocked during Unity compilation/reload/unfocused throttling.
68
73
  FAST_FAIL_TIMEOUT = 2.0
@@ -78,6 +83,10 @@ class PluginHub(WebSocketEndpoint):
78
83
  _pending: dict[str, dict[str, Any]] = {}
79
84
  _lock: asyncio.Lock | None = None
80
85
  _loop: asyncio.AbstractEventLoop | None = None
86
+ # session_id -> last pong timestamp (monotonic)
87
+ _last_pong: ClassVar[dict[str, float]] = {}
88
+ # session_id -> ping task
89
+ _ping_tasks: ClassVar[dict[str, asyncio.Task]] = {}
81
90
 
82
91
  @classmethod
83
92
  def configure(
@@ -176,12 +185,20 @@ class PluginHub(WebSocketEndpoint):
176
185
  (sid for sid, ws in cls._connections.items() if ws is websocket), None)
177
186
  if session_id:
178
187
  cls._connections.pop(session_id, None)
188
+ # Stop the ping loop for this session
189
+ ping_task = cls._ping_tasks.pop(session_id, None)
190
+ if ping_task and not ping_task.done():
191
+ ping_task.cancel()
192
+ # Clean up last pong tracking
193
+ cls._last_pong.pop(session_id, None)
179
194
  # Fail-fast any in-flight commands for this session to avoid waiting for COMMAND_TIMEOUT.
180
195
  pending_ids = [
181
196
  command_id
182
197
  for command_id, entry in cls._pending.items()
183
198
  if entry.get("session_id") == session_id
184
199
  ]
200
+ if pending_ids:
201
+ logger.debug(f"Cancelling {len(pending_ids)} pending commands for disconnected session")
185
202
  for command_id in pending_ids:
186
203
  entry = cls._pending.get(command_id)
187
204
  future = entry.get("future") if isinstance(
@@ -364,10 +381,18 @@ class PluginHub(WebSocketEndpoint):
364
381
  session = await registry.register(session_id, project_name, project_hash, unity_version, project_path, user_id=user_id)
365
382
  async with lock:
366
383
  cls._connections[session.session_id] = websocket
384
+ # Initialize last pong time and start ping loop for this session
385
+ cls._last_pong[session_id] = time.monotonic()
386
+ # Cancel any existing ping task for this session (shouldn't happen, but be safe)
387
+ old_task = cls._ping_tasks.pop(session_id, None)
388
+ if old_task and not old_task.done():
389
+ old_task.cancel()
390
+ # Start the server-side ping loop
391
+ ping_task = asyncio.create_task(cls._ping_loop(session_id, websocket))
392
+ cls._ping_tasks[session_id] = ping_task
367
393
 
368
394
  if user_id:
369
- logger.info(
370
- f"Plugin registered: {project_name} ({project_hash}) for user {user_id}")
395
+ logger.info(f"Plugin registered: {project_name} ({project_hash}) for user {user_id}")
371
396
  else:
372
397
  logger.info(f"Plugin registered: {project_name} ({project_hash})")
373
398
 
@@ -429,11 +454,77 @@ class PluginHub(WebSocketEndpoint):
429
454
  async def _handle_pong(self, payload: PongMessage) -> None:
430
455
  cls = type(self)
431
456
  registry = cls._registry
457
+ lock = cls._lock
432
458
  if registry is None:
433
459
  return
434
460
  session_id = payload.session_id
435
461
  if session_id:
436
462
  await registry.touch(session_id)
463
+ # Record last pong time for staleness detection (under lock for consistency)
464
+ if lock is not None:
465
+ async with lock:
466
+ cls._last_pong[session_id] = time.monotonic()
467
+
468
+ @classmethod
469
+ async def _ping_loop(cls, session_id: str, websocket: WebSocket) -> None:
470
+ """Server-initiated ping loop to detect dead connections.
471
+
472
+ Sends periodic pings to the Unity client. If no pong is received within
473
+ PING_TIMEOUT seconds, the connection is considered dead and closed.
474
+ This helps detect connections that die silently (e.g., Windows OSError 64).
475
+ """
476
+ logger.debug(f"[Ping] Starting ping loop for session {session_id}")
477
+ try:
478
+ while True:
479
+ await asyncio.sleep(cls.PING_INTERVAL)
480
+
481
+ # Check if we're still supposed to be running and get last pong time (under lock)
482
+ lock = cls._lock
483
+ if lock is None:
484
+ break
485
+ async with lock:
486
+ if session_id not in cls._connections:
487
+ logger.debug(f"[Ping] Session {session_id} no longer in connections, stopping ping loop")
488
+ break
489
+ # Read last pong time under lock for consistency
490
+ last_pong = cls._last_pong.get(session_id, 0)
491
+
492
+ # Check staleness: has it been too long since we got a pong?
493
+ elapsed = time.monotonic() - last_pong
494
+ if elapsed > cls.PING_TIMEOUT:
495
+ logger.warning(
496
+ f"[Ping] Session {session_id} stale: no pong for {elapsed:.1f}s "
497
+ f"(timeout={cls.PING_TIMEOUT}s). Closing connection."
498
+ )
499
+ try:
500
+ await websocket.close(code=1001) # Going away
501
+ except Exception as close_ex:
502
+ logger.debug(f"[Ping] Error closing stale websocket: {close_ex}")
503
+ break
504
+
505
+ # Send a ping to the client
506
+ try:
507
+ ping_msg = PingMessage()
508
+ await websocket.send_json(ping_msg.model_dump())
509
+ logger.debug(f"[Ping] Sent ping to session {session_id}")
510
+ except Exception as send_ex:
511
+ # Send failed - connection is dead
512
+ logger.warning(
513
+ f"[Ping] Failed to send ping to session {session_id}: {send_ex}. "
514
+ "Connection likely dead."
515
+ )
516
+ try:
517
+ await websocket.close(code=1006) # Abnormal closure
518
+ except Exception:
519
+ pass
520
+ break
521
+
522
+ except asyncio.CancelledError:
523
+ logger.debug(f"[Ping] Ping loop cancelled for session {session_id}")
524
+ except Exception as ex:
525
+ logger.warning(f"[Ping] Ping loop error for session {session_id}: {ex}")
526
+ finally:
527
+ logger.debug(f"[Ping] Ping loop ended for session {session_id}")
437
528
 
438
529
  @classmethod
439
530
  async def _get_connection(cls, session_id: str) -> WebSocket:
@@ -465,19 +556,30 @@ class PluginHub(WebSocketEndpoint):
465
556
  if cls._registry is None:
466
557
  raise RuntimeError("Plugin registry not configured")
467
558
 
468
- # Bound waiting for Unity sessions so calls fail fast when editors are not ready.
559
+ # Bound waiting for Unity sessions. Default to 20s to handle domain reloads
560
+ # (which can take 10-20s after test runs or script changes).
561
+ #
562
+ # NOTE: This wait can impact agentic workflows where domain reloads happen
563
+ # frequently (e.g., after test runs, script compilation). The 20s default
564
+ # balances handling slow reloads vs. avoiding unnecessary delays.
565
+ #
566
+ # TODO: Make this more deterministic by detecting Unity's actual reload state
567
+ # (e.g., via status file, heartbeat, or explicit "reloading" signal from Unity)
568
+ # rather than blindly waiting up to 20s. See Issue #657.
569
+ #
570
+ # Configurable via: UNITY_MCP_SESSION_RESOLVE_MAX_WAIT_S (default: 20.0, max: 20.0)
469
571
  try:
470
572
  max_wait_s = float(
471
- os.environ.get("UNITY_MCP_SESSION_RESOLVE_MAX_WAIT_S", "2.0"))
573
+ os.environ.get("UNITY_MCP_SESSION_RESOLVE_MAX_WAIT_S", "20.0"))
472
574
  except ValueError as e:
473
575
  raw_val = os.environ.get(
474
- "UNITY_MCP_SESSION_RESOLVE_MAX_WAIT_S", "2.0")
576
+ "UNITY_MCP_SESSION_RESOLVE_MAX_WAIT_S", "20.0")
475
577
  logger.warning(
476
- "Invalid UNITY_MCP_SESSION_RESOLVE_MAX_WAIT_S=%r, using default 2.0: %s",
578
+ "Invalid UNITY_MCP_SESSION_RESOLVE_MAX_WAIT_S=%r, using default 20.0: %s",
477
579
  raw_val, e)
478
- max_wait_s = 2.0
479
- # Clamp to [0, 30] to prevent misconfiguration from causing excessive waits
480
- max_wait_s = max(0.0, min(max_wait_s, 30.0))
580
+ max_wait_s = 20.0
581
+ # Clamp to [0, 20] to prevent misconfiguration from causing excessive waits
582
+ max_wait_s = max(0.0, min(max_wait_s, 20.0))
481
583
  retry_ms = float(getattr(config, "reload_retry_ms", 250))
482
584
  sleep_seconds = max(0.05, min(0.25, retry_ms / 1000.0))
483
585
 
@@ -613,7 +715,7 @@ class PluginHub(WebSocketEndpoint):
613
715
  "Invalid UNITY_MCP_SESSION_READY_WAIT_SECONDS=%r, using default 6.0: %s",
614
716
  raw_val, e)
615
717
  max_wait_s = 6.0
616
- max_wait_s = max(0.0, min(max_wait_s, 30.0))
718
+ max_wait_s = max(0.0, min(max_wait_s, 20.0))
617
719
  if max_wait_s > 0:
618
720
  deadline = time.monotonic() + max_wait_s
619
721
  while time.monotonic() < deadline:
@@ -214,9 +214,10 @@ class UnityInstanceMiddleware(Middleware):
214
214
  # The 'active_instance' (Name@hash) might be valid for stdio even if PluginHub fails.
215
215
 
216
216
  session_id: str | None = None
217
- # Only validate via PluginHub if we are actually using HTTP transport
218
- # OR if we want to support hybrid mode. For now, let's be permissive.
219
- if PluginHub.is_configured():
217
+ # Only validate via PluginHub if we are actually using HTTP transport.
218
+ # For stdio transport, skip PluginHub entirely - we only need the instance ID.
219
+ from transport.unity_transport import _is_http_transport
220
+ if _is_http_transport() and PluginHub.is_configured():
220
221
  try:
221
222
  # resolving session_id might fail if the plugin disconnected
222
223
  # We only need session_id for HTTP transport routing.
@@ -11,8 +11,8 @@ from services.api_key_service import ApiKeyService
11
11
  from models.models import MCPResponse
12
12
  from models.unity_response import normalize_unity_response
13
13
 
14
+ logger = logging.getLogger(__name__)
14
15
  T = TypeVar("T")
15
- logger = logging.getLogger("mcp-for-unity-server")
16
16
 
17
17
 
18
18
  def _is_http_transport() -> bool:
@@ -628,7 +628,8 @@ class TestTelemetryDuration:
628
628
  assert result == "done"
629
629
  assert mock_record.called
630
630
  duration_ms = mock_record.call_args[0][2]
631
- assert duration_ms >= 50
631
+ # Allow 20% variance for timer resolution (especially on Windows)
632
+ assert duration_ms >= 40
632
633
 
633
634
  def test_telemetry_duration_recorded_even_on_error(self):
634
635
  """Verify duration is recorded even when tool raises exception."""
@@ -1270,7 +1271,8 @@ class TestConfigurationEnvironmentInteraction:
1270
1271
  disable_vars = ["DISABLE_TELEMETRY", "UNITY_MCP_DISABLE_TELEMETRY", "MCP_DISABLE_TELEMETRY"]
1271
1272
 
1272
1273
  for var_name in disable_vars:
1273
- with patch.dict(os.environ, {var_name: "true"}, clear=True):
1274
+ # Don't use clear=True as it removes HOME/USERPROFILE which breaks Path.home() on Windows
1275
+ with patch.dict(os.environ, {var_name: "true"}):
1274
1276
  with patch("core.telemetry.import_module", side_effect=Exception("No module")):
1275
1277
  config = TelemetryConfig()
1276
1278
  assert config.enabled is False, f"{var_name} did not disable telemetry"