mcpforunityserver 9.3.0b20260128055651__py3-none-any.whl → 9.3.0b20260129121506__py3-none-any.whl

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 (61) hide show
  1. cli/commands/animation.py +6 -9
  2. cli/commands/asset.py +50 -80
  3. cli/commands/audio.py +14 -22
  4. cli/commands/batch.py +20 -33
  5. cli/commands/code.py +63 -70
  6. cli/commands/component.py +33 -55
  7. cli/commands/editor.py +122 -188
  8. cli/commands/gameobject.py +60 -83
  9. cli/commands/instance.py +28 -36
  10. cli/commands/lighting.py +54 -59
  11. cli/commands/material.py +39 -68
  12. cli/commands/prefab.py +63 -81
  13. cli/commands/scene.py +30 -54
  14. cli/commands/script.py +32 -50
  15. cli/commands/shader.py +43 -55
  16. cli/commands/texture.py +53 -51
  17. cli/commands/tool.py +24 -27
  18. cli/commands/ui.py +125 -130
  19. cli/commands/vfx.py +84 -138
  20. cli/utils/confirmation.py +37 -0
  21. cli/utils/connection.py +32 -2
  22. cli/utils/constants.py +23 -0
  23. cli/utils/parsers.py +112 -0
  24. core/config.py +0 -4
  25. core/telemetry.py +20 -2
  26. {mcpforunityserver-9.3.0b20260128055651.dist-info → mcpforunityserver-9.3.0b20260129121506.dist-info}/METADATA +21 -1
  27. mcpforunityserver-9.3.0b20260129121506.dist-info/RECORD +103 -0
  28. services/resources/active_tool.py +1 -1
  29. services/resources/custom_tools.py +1 -1
  30. services/resources/editor_state.py +1 -1
  31. services/resources/gameobject.py +4 -4
  32. services/resources/layers.py +1 -1
  33. services/resources/menu_items.py +1 -1
  34. services/resources/prefab.py +3 -3
  35. services/resources/prefab_stage.py +1 -1
  36. services/resources/project_info.py +1 -1
  37. services/resources/selection.py +1 -1
  38. services/resources/tags.py +1 -1
  39. services/resources/tests.py +40 -8
  40. services/resources/unity_instances.py +1 -1
  41. services/resources/windows.py +1 -1
  42. services/tools/__init__.py +3 -1
  43. services/tools/find_gameobjects.py +32 -11
  44. services/tools/manage_gameobject.py +11 -66
  45. services/tools/manage_material.py +4 -37
  46. services/tools/manage_prefabs.py +51 -7
  47. services/tools/manage_script.py +1 -1
  48. services/tools/manage_texture.py +10 -96
  49. services/tools/run_tests.py +67 -4
  50. services/tools/utils.py +217 -0
  51. transport/models.py +1 -0
  52. transport/plugin_hub.py +2 -1
  53. transport/plugin_registry.py +3 -0
  54. transport/unity_transport.py +0 -51
  55. utils/focus_nudge.py +291 -23
  56. mcpforunityserver-9.3.0b20260128055651.dist-info/RECORD +0 -101
  57. utils/reload_sentinel.py +0 -9
  58. {mcpforunityserver-9.3.0b20260128055651.dist-info → mcpforunityserver-9.3.0b20260129121506.dist-info}/WHEEL +0 -0
  59. {mcpforunityserver-9.3.0b20260128055651.dist-info → mcpforunityserver-9.3.0b20260129121506.dist-info}/entry_points.txt +0 -0
  60. {mcpforunityserver-9.3.0b20260128055651.dist-info → mcpforunityserver-9.3.0b20260129121506.dist-info}/licenses/LICENSE +0 -0
  61. {mcpforunityserver-9.3.0b20260128055651.dist-info → mcpforunityserver-9.3.0b20260129121506.dist-info}/top_level.txt +0 -0
@@ -9,44 +9,11 @@ from mcp.types import ToolAnnotations
9
9
 
10
10
  from services.registry import mcp_for_unity_tool
11
11
  from services.tools import get_unity_instance_from_context
12
- from services.tools.utils import parse_json_payload, coerce_int, normalize_properties
12
+ from services.tools.utils import parse_json_payload, coerce_int, normalize_properties, normalize_color
13
13
  from transport.unity_transport import send_with_unity_instance
14
14
  from transport.legacy.unity_connection import async_send_command_with_retry
15
15
 
16
16
 
17
- def _normalize_color(value: Any) -> tuple[list[float] | None, str | None]:
18
- """
19
- Normalize color parameter to [r, g, b] or [r, g, b, a] format.
20
- Returns (parsed_color, error_message).
21
- """
22
- if value is None:
23
- return None, None
24
-
25
- # Already a list - validate
26
- if isinstance(value, (list, tuple)):
27
- if len(value) in (3, 4):
28
- try:
29
- return [float(c) for c in value], None
30
- except (ValueError, TypeError):
31
- return None, f"color values must be numbers, got {value}"
32
- return None, f"color must have 3 or 4 components, got {len(value)}"
33
-
34
- # Try parsing as string
35
- if isinstance(value, str):
36
- if value in ("[object Object]", "undefined", "null", ""):
37
- return None, f"color received invalid value: '{value}'. Expected [r, g, b] or [r, g, b, a]"
38
-
39
- parsed = parse_json_payload(value)
40
- if isinstance(parsed, (list, tuple)) and len(parsed) in (3, 4):
41
- try:
42
- return [float(c) for c in parsed], None
43
- except (ValueError, TypeError):
44
- return None, f"color values must be numbers, got {parsed}"
45
- return None, f"Failed to parse color string: {value}"
46
-
47
- return None, f"color must be a list or JSON string, got {type(value).__name__}"
48
-
49
-
50
17
  @mcp_for_unity_tool(
51
18
  description="Manages Unity materials (set properties, colors, shaders, etc). Read-only actions: ping, get_material_info. Modifying actions: create, set_material_shader_property, set_material_color, assign_material_to_renderer, set_renderer_color.",
52
19
  annotations=ToolAnnotations(
@@ -82,8 +49,8 @@ async def manage_material(
82
49
  "Value to set (color array, float, texture path/instruction)"] | None = None,
83
50
 
84
51
  # set_material_color / set_renderer_color
85
- color: Annotated[list[float] | str,
86
- "Color as [r, g, b] or [r, g, b, a] array (list or JSON string)."] | None = None,
52
+ color: Annotated[list[float] | dict[str, float] | str,
53
+ "Color as [r, g, b] or [r, g, b, a] array, {r, g, b, a} object, or JSON string."] | None = None,
87
54
 
88
55
  # assign_material_to_renderer / set_renderer_color
89
56
  target: Annotated[str,
@@ -98,7 +65,7 @@ async def manage_material(
98
65
  unity_instance = get_unity_instance_from_context(ctx)
99
66
 
100
67
  # --- Normalize color with validation ---
101
- color, color_error = _normalize_color(color)
68
+ color, color_error = normalize_color(color, output_range="float")
102
69
  if color_error:
103
70
  return {"success": False, "message": color_error}
104
71
 
@@ -5,7 +5,7 @@ from mcp.types import ToolAnnotations
5
5
 
6
6
  from services.registry import mcp_for_unity_tool
7
7
  from services.tools import get_unity_instance_from_context
8
- from services.tools.utils import coerce_bool
8
+ from services.tools.utils import coerce_bool, normalize_vector3
9
9
  from transport.unity_transport import send_with_unity_instance
10
10
  from transport.legacy.unity_connection import async_send_command_with_retry
11
11
  from services.tools.preflight import preflight
@@ -25,6 +25,10 @@ REQUIRED_PARAMS = {
25
25
  "Manages Unity Prefab assets via headless operations (no UI, no prefab stages). "
26
26
  "Actions: get_info, get_hierarchy, create_from_gameobject, modify_contents. "
27
27
  "Use modify_contents for headless prefab editing - ideal for automated workflows. "
28
+ "Use create_child parameter with modify_contents to add child GameObjects to a prefab "
29
+ "(single object or array for batch creation in one save). "
30
+ "Example: create_child=[{\"name\": \"Child1\", \"primitive_type\": \"Sphere\", \"position\": [1,0,0]}, "
31
+ "{\"name\": \"Child2\", \"primitive_type\": \"Cube\", \"parent\": \"Child1\"}]. "
28
32
  "Use manage_asset action=search filterType=Prefab to list prefabs."
29
33
  ),
30
34
  annotations=ToolAnnotations(
@@ -49,9 +53,9 @@ async def manage_prefabs(
49
53
  search_inactive: Annotated[bool, "Include inactive GameObjects in search."] | None = None,
50
54
  unlink_if_instance: Annotated[bool, "Unlink from existing prefab before creating new one."] | None = None,
51
55
  # modify_contents parameters
52
- position: Annotated[list[float], "New local position [x, y, z] for modify_contents."] | None = None,
53
- rotation: Annotated[list[float], "New local rotation (euler angles) [x, y, z] for modify_contents."] | None = None,
54
- scale: Annotated[list[float], "New local scale [x, y, z] for modify_contents."] | None = None,
56
+ position: Annotated[list[float] | dict[str, float] | str, "New local position [x, y, z] or {x, y, z} for modify_contents."] | None = None,
57
+ rotation: Annotated[list[float] | dict[str, float] | str, "New local rotation (euler angles) [x, y, z] or {x, y, z} for modify_contents."] | None = None,
58
+ scale: Annotated[list[float] | dict[str, float] | str, "New local scale [x, y, z] or {x, y, z} for modify_contents."] | None = None,
55
59
  name: Annotated[str, "New name for the target object in modify_contents."] | None = None,
56
60
  tag: Annotated[str, "New tag for the target object in modify_contents."] | None = None,
57
61
  layer: Annotated[str, "New layer name for the target object in modify_contents."] | None = None,
@@ -59,6 +63,7 @@ async def manage_prefabs(
59
63
  parent: Annotated[str, "New parent object name/path within prefab for modify_contents."] | None = None,
60
64
  components_to_add: Annotated[list[str], "Component types to add in modify_contents."] | None = None,
61
65
  components_to_remove: Annotated[list[str], "Component types to remove in modify_contents."] | None = None,
66
+ create_child: Annotated[dict[str, Any] | list[dict[str, Any]], "Create child GameObject(s) in the prefab. Single object or array of objects, each with: name (required), parent (optional, defaults to target), primitive_type (optional: Cube, Sphere, Capsule, Cylinder, Plane, Quad), position, rotation, scale, components_to_add, tag, layer, set_active."] | None = None,
62
67
  ) -> dict[str, Any]:
63
68
  # Back-compat: map 'name' → 'target' for create_from_gameobject (Unity accepts both)
64
69
  if action == "create_from_gameobject" and target is None and name is not None:
@@ -114,11 +119,20 @@ async def manage_prefabs(
114
119
 
115
120
  # modify_contents parameters
116
121
  if position is not None:
117
- params["position"] = position
122
+ position_value, position_error = normalize_vector3(position, "position")
123
+ if position_error:
124
+ return {"success": False, "message": position_error}
125
+ params["position"] = position_value
118
126
  if rotation is not None:
119
- params["rotation"] = rotation
127
+ rotation_value, rotation_error = normalize_vector3(rotation, "rotation")
128
+ if rotation_error:
129
+ return {"success": False, "message": rotation_error}
130
+ params["rotation"] = rotation_value
120
131
  if scale is not None:
121
- params["scale"] = scale
132
+ scale_value, scale_error = normalize_vector3(scale, "scale")
133
+ if scale_error:
134
+ return {"success": False, "message": scale_error}
135
+ params["scale"] = scale_value
122
136
  if name is not None:
123
137
  params["name"] = name
124
138
  if tag is not None:
@@ -134,6 +148,36 @@ async def manage_prefabs(
134
148
  params["componentsToAdd"] = components_to_add
135
149
  if components_to_remove is not None:
136
150
  params["componentsToRemove"] = components_to_remove
151
+ if create_child is not None:
152
+ # Normalize vector fields within create_child (handles single object or array)
153
+ def normalize_child_params(child: Any, index: int | None = None) -> tuple[dict | None, str | None]:
154
+ prefix = f"create_child[{index}]" if index is not None else "create_child"
155
+ if not isinstance(child, dict):
156
+ return None, f"{prefix} must be a dict with child properties (name, primitive_type, position, etc.), got {type(child).__name__}"
157
+ child_params = dict(child)
158
+ for vec_field in ("position", "rotation", "scale"):
159
+ if vec_field in child_params and child_params[vec_field] is not None:
160
+ vec_val, vec_err = normalize_vector3(child_params[vec_field], f"{prefix}.{vec_field}")
161
+ if vec_err:
162
+ return None, vec_err
163
+ child_params[vec_field] = vec_val
164
+ return child_params, None
165
+
166
+ if isinstance(create_child, list):
167
+ # Array of children
168
+ normalized_children = []
169
+ for i, child in enumerate(create_child):
170
+ child_params, err = normalize_child_params(child, i)
171
+ if err:
172
+ return {"success": False, "message": err}
173
+ normalized_children.append(child_params)
174
+ params["createChild"] = normalized_children
175
+ else:
176
+ # Single child object
177
+ child_params, err = normalize_child_params(create_child)
178
+ if err:
179
+ return {"success": False, "message": err}
180
+ params["createChild"] = child_params
137
181
 
138
182
  # Send command to Unity
139
183
  response = await send_with_unity_instance(
@@ -613,7 +613,7 @@ async def manage_script_capabilities(ctx: Context) -> dict[str, Any]:
613
613
 
614
614
 
615
615
  @mcp_for_unity_tool(
616
- description="Get SHA256 and basic metadata for a Unity C# script without returning file contents",
616
+ description="Get SHA256 and basic metadata for a Unity C# script without returning file contents. Requires uri (script path under Assets/ or mcpforunity://path/Assets/... or file://...).",
617
617
  annotations=ToolAnnotations(
618
618
  title="Get SHA",
619
619
  readOnlyHint=True,
@@ -10,41 +10,12 @@ from mcp.types import ToolAnnotations
10
10
 
11
11
  from services.registry import mcp_for_unity_tool
12
12
  from services.tools import get_unity_instance_from_context
13
- from services.tools.utils import parse_json_payload, coerce_bool, coerce_int
13
+ from services.tools.utils import parse_json_payload, coerce_bool, coerce_int, normalize_color
14
14
  from transport.unity_transport import send_with_unity_instance
15
15
  from transport.legacy.unity_connection import async_send_command_with_retry
16
16
  from services.tools.preflight import preflight
17
17
 
18
18
 
19
- def _is_normalized_color(values: list) -> bool:
20
- """
21
- Check if color values appear to be in normalized 0.0-1.0 range.
22
- Returns True if all values are <= 1.0 and at least one is a float or between 0-1 exclusive.
23
- """
24
- if not values:
25
- return False
26
-
27
- try:
28
- numeric_values = [float(v) for v in values]
29
- except (TypeError, ValueError):
30
- return False
31
-
32
- # Check if all values are <= 1.0
33
- all_small = all(0 <= v <= 1.0 for v in numeric_values)
34
- if not all_small:
35
- return False
36
-
37
- # If any non-zero value is less than 1, it's likely normalized (e.g., 0.5)
38
- has_fractional = any(0 < v < 1 for v in numeric_values)
39
-
40
- # If all values are 0 or 1, and they're all integers, could be either format
41
- # In this ambiguous case (like [1, 0, 0, 1]), assume normalized since that's
42
- # what graphics programmers typically use
43
- all_binary = all(v in (0, 1, 0.0, 1.0) for v in numeric_values)
44
-
45
- return has_fractional or all_binary
46
-
47
-
48
19
  def _normalize_dimension(value: Any, name: str, default: int = 64) -> tuple[int | None, str | None]:
49
20
  if value is None:
50
21
  return default, None
@@ -65,66 +36,9 @@ def _normalize_positive_int(value: Any, name: str) -> tuple[int | None, str | No
65
36
  return coerced, None
66
37
 
67
38
 
68
- def _normalize_color(value: Any) -> tuple[list[int] | None, str | None]:
69
- """
70
- Normalize color parameter to [r, g, b, a] format (0-255).
71
- Auto-detects normalized float colors (0.0-1.0) and converts to 0-255.
72
- Returns (parsed_color, error_message).
73
- """
74
- if value is None:
75
- return None, None
76
-
77
- # Already a list - validate
78
- if isinstance(value, (list, tuple)):
79
- if len(value) == 3:
80
- value = list(value) + [1.0 if _is_normalized_color(value) else 255]
81
- if len(value) == 4:
82
- try:
83
- # Check if values appear to be normalized (0.0-1.0 range)
84
- if _is_normalized_color(value):
85
- # Convert from 0.0-1.0 to 0-255
86
- return [int(round(float(c) * 255)) for c in value], None
87
- else:
88
- # Already in 0-255 range
89
- return [int(c) for c in value], None
90
- except (ValueError, TypeError):
91
- return None, f"color values must be numeric, got {value}"
92
- return None, f"color must have 3 or 4 components, got {len(value)}"
93
-
94
- # Try parsing as string
95
- if isinstance(value, str):
96
- if value in ("[object Object]", "undefined", "null", ""):
97
- return None, f"color received invalid value: '{value}'. Expected [r, g, b] or [r, g, b, a]"
98
-
99
- # Handle Hex Colors
100
- if value.startswith("#"):
101
- h = value.lstrip("#")
102
- try:
103
- if len(h) == 6:
104
- return [int(h[i:i+2], 16) for i in (0, 2, 4)] + [255], None
105
- elif len(h) == 8:
106
- return [int(h[i:i+2], 16) for i in (0, 2, 4, 6)], None
107
- except ValueError:
108
- return None, f"Invalid hex color: {value}"
109
-
110
- parsed = parse_json_payload(value)
111
- if isinstance(parsed, (list, tuple)):
112
- if len(parsed) == 3:
113
- parsed = list(parsed) + [1.0 if _is_normalized_color(parsed) else 255]
114
- if len(parsed) == 4:
115
- try:
116
- # Check if values appear to be normalized (0.0-1.0 range)
117
- if _is_normalized_color(parsed):
118
- # Convert from 0.0-1.0 to 0-255
119
- return [int(round(float(c) * 255)) for c in parsed], None
120
- else:
121
- # Already in 0-255 range
122
- return [int(c) for c in parsed], None
123
- except (ValueError, TypeError):
124
- return None, f"color values must be numeric, got {parsed}"
125
- return None, f"Failed to parse color string: {value}"
126
-
127
- return None, f"color must be a list or JSON string, got {type(value).__name__}"
39
+ def _normalize_color_int(value: Any) -> tuple[list[int] | None, str | None]:
40
+ """Thin wrapper for normalize_color with int output for texture operations."""
41
+ return normalize_color(value, output_range="int")
128
42
 
129
43
 
130
44
  def _normalize_palette(value: Any) -> tuple[list[list[int]] | None, str | None]:
@@ -146,7 +60,7 @@ def _normalize_palette(value: Any) -> tuple[list[list[int]] | None, str | None]:
146
60
 
147
61
  normalized = []
148
62
  for i, color in enumerate(value):
149
- parsed, error = _normalize_color(color)
63
+ parsed, error = _normalize_color_int(color)
150
64
  if error:
151
65
  return None, f"palette[{i}]: {error}"
152
66
  normalized.append(parsed)
@@ -181,7 +95,7 @@ def _normalize_pixels(value: Any, width: int, height: int) -> tuple[list[list[in
181
95
 
182
96
  normalized = []
183
97
  for i, pixel in enumerate(value):
184
- parsed, error = _normalize_color(pixel)
98
+ parsed, error = _normalize_color_int(pixel)
185
99
  if error:
186
100
  return None, f"pixels[{i}]: {error}"
187
101
  normalized.append(parsed)
@@ -482,8 +396,8 @@ async def manage_texture(
482
396
  height: Annotated[int, "Texture height in pixels (default: 64)"] | None = None,
483
397
 
484
398
  # Solid fill (accepts both 0-255 integers and 0.0-1.0 normalized floats)
485
- fill_color: Annotated[list[int | float],
486
- "Fill color as [r, g, b] or [r, g, b, a]. Accepts both 0-255 range (e.g., [255, 0, 0]) or 0.0-1.0 normalized range (e.g., [1.0, 0, 0])"] | None = None,
399
+ fill_color: Annotated[list[int | float] | dict[str, int | float] | str,
400
+ "Fill color as [r, g, b] or [r, g, b, a] array, {r, g, b, a} object, or hex string. Accepts both 0-255 range (e.g., [255, 0, 0]) or 0.0-1.0 normalized range (e.g., [1.0, 0, 0])"] | None = None,
487
401
 
488
402
  # Pattern-based generation
489
403
  pattern: Annotated[Literal[
@@ -544,7 +458,7 @@ async def manage_texture(
544
458
  return gate.model_dump()
545
459
 
546
460
  # --- Normalize parameters ---
547
- fill_color, fill_error = _normalize_color(fill_color)
461
+ fill_color, fill_error = _normalize_color_int(fill_color)
548
462
  if fill_error:
549
463
  return {"success": False, "message": fill_error}
550
464
 
@@ -613,7 +527,7 @@ async def manage_texture(
613
527
 
614
528
  set_pixels_normalized = set_pixels.copy()
615
529
  if "color" in set_pixels_normalized:
616
- color, error = _normalize_color(set_pixels_normalized["color"])
530
+ color, error = _normalize_color_int(set_pixels_normalized["color"])
617
531
  if error:
618
532
  return {"success": False, "message": f"set_pixels.color: {error}"}
619
533
  set_pixels_normalized["color"] = color
@@ -16,11 +16,58 @@ from services.tools import get_unity_instance_from_context
16
16
  from services.tools.preflight import preflight
17
17
  import transport.unity_transport as unity_transport
18
18
  from transport.legacy.unity_connection import async_send_command_with_retry
19
- from utils.focus_nudge import nudge_unity_focus, should_nudge
19
+ from transport.plugin_hub import PluginHub
20
+ from utils.focus_nudge import nudge_unity_focus, should_nudge, reset_nudge_backoff
20
21
 
21
22
  logger = logging.getLogger(__name__)
22
23
 
23
24
 
25
+ async def _get_unity_project_path(unity_instance: str | None) -> str | None:
26
+ """Get the project root path for a Unity instance (for focus nudging).
27
+
28
+ Args:
29
+ unity_instance: Unity instance hash or "Name@hash" format or None
30
+
31
+ Returns:
32
+ Project root path (e.g., "/Users/name/project"), or falls back to project_name if path unavailable
33
+ """
34
+ if not unity_instance:
35
+ return None
36
+
37
+ try:
38
+ registry = PluginHub._registry
39
+ if not registry:
40
+ return None
41
+
42
+ # Parse Name@hash format if present (middleware stores instances as "Name@hash")
43
+ target_hash = unity_instance
44
+ if "@" in target_hash:
45
+ _, _, target_hash = target_hash.rpartition("@")
46
+ if not target_hash:
47
+ return None
48
+
49
+ # Get session by hash
50
+ session_id = await registry.get_session_id_by_hash(target_hash)
51
+ if not session_id:
52
+ return None
53
+
54
+ session = await registry.get_session(session_id)
55
+ if not session:
56
+ return None
57
+
58
+ except Exception as e:
59
+ # Re-raise cancellation errors so task cancellation propagates
60
+ if isinstance(e, asyncio.CancelledError):
61
+ raise
62
+ logger.debug(f"Could not get Unity project path: {e}")
63
+ return None
64
+ else:
65
+ # Return full path if available, otherwise fall back to project name
66
+ if session.project_path:
67
+ return session.project_path
68
+ return session.project_name if session.project_name else None
69
+
70
+
24
71
  class RunTestsSummary(BaseModel):
25
72
  total: int
26
73
  passed: int
@@ -200,6 +247,10 @@ async def get_test_job(
200
247
  if wait_timeout and wait_timeout > 0:
201
248
  deadline = asyncio.get_event_loop().time() + wait_timeout
202
249
  poll_interval = 2.0 # Poll Unity every 2 seconds
250
+ prev_last_update_unix_ms = None
251
+
252
+ # Get project path once for focus nudging (multi-instance support)
253
+ project_path = await _get_unity_project_path(unity_instance)
203
254
 
204
255
  while True:
205
256
  response = await _fetch_status()
@@ -216,12 +267,20 @@ async def get_test_job(
216
267
  if status in ("succeeded", "failed", "cancelled"):
217
268
  return GetTestJobResponse(**response)
218
269
 
270
+ # Detect progress and reset exponential backoff
271
+ last_update_unix_ms = data.get("last_update_unix_ms")
272
+ if prev_last_update_unix_ms is not None and last_update_unix_ms != prev_last_update_unix_ms:
273
+ # Progress detected - reset exponential backoff for next potential stall
274
+ reset_nudge_backoff()
275
+ logger.debug(f"Test job {job_id} made progress - reset nudge backoff")
276
+ prev_last_update_unix_ms = last_update_unix_ms
277
+
219
278
  # Check if Unity needs a focus nudge to make progress
220
279
  # This handles OS-level throttling (e.g., macOS App Nap) that can
221
280
  # stall PlayMode tests when Unity is in the background.
281
+ # Uses exponential backoff: 1s, 2s, 4s, 8s, 10s max between nudges.
222
282
  progress = data.get("progress", {})
223
283
  editor_is_focused = progress.get("editor_is_focused", True)
224
- last_update_unix_ms = data.get("last_update_unix_ms")
225
284
  current_time_ms = int(time.time() * 1000)
226
285
 
227
286
  if should_nudge(
@@ -229,10 +288,14 @@ async def get_test_job(
229
288
  editor_is_focused=editor_is_focused,
230
289
  last_update_unix_ms=last_update_unix_ms,
231
290
  current_time_ms=current_time_ms,
232
- stall_threshold_ms=10_000, # 10 seconds without progress
291
+ # Use default stall_threshold_ms (3s)
233
292
  ):
234
293
  logger.info(f"Test job {job_id} appears stalled (unfocused Unity), attempting nudge...")
235
- nudged = await nudge_unity_focus(focus_duration_s=0.5)
294
+ # Lazily resolve project path if not yet available (registry may have become ready)
295
+ if project_path is None:
296
+ project_path = await _get_unity_project_path(unity_instance)
297
+ # Pass project path for multi-instance support
298
+ nudged = await nudge_unity_focus(unity_project_path=project_path)
236
299
  if nudged:
237
300
  logger.info(f"Test job {job_id} nudge completed")
238
301
 
services/tools/utils.py CHANGED
@@ -3,6 +3,7 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import json
6
+ import math
6
7
  from typing import Any
7
8
 
8
9
  _TRUTHY = {"true", "1", "yes", "on"}
@@ -129,3 +130,219 @@ def normalize_properties(value: Any) -> tuple[dict[str, Any] | None, str | None]
129
130
  return None, f"properties must be a JSON object (dict), got string that parsed to {type(parsed).__name__}"
130
131
 
131
132
  return None, f"properties must be a dict or JSON string, got {type(value).__name__}"
133
+
134
+
135
+ def normalize_vector3(value: Any, param_name: str = "vector") -> tuple[list[float] | None, str | None]:
136
+ """
137
+ Normalize a vector parameter to [x, y, z] format.
138
+
139
+ Handles various input formats from MCP clients/LLMs:
140
+ - None -> (None, None)
141
+ - list/tuple [x, y, z] -> ([x, y, z], None)
142
+ - dict {x, y, z} -> ([x, y, z], None)
143
+ - JSON string "[x, y, z]" or "{x, y, z}" -> parsed and normalized
144
+ - comma-separated string "x, y, z" -> ([x, y, z], None)
145
+
146
+ Returns:
147
+ Tuple of (parsed_vector, error_message). If error_message is set, parsed_vector is None.
148
+ """
149
+ if value is None:
150
+ return None, None
151
+
152
+ # Handle dict with x/y/z keys (e.g., {"x": 0, "y": 1, "z": 2})
153
+ if isinstance(value, dict):
154
+ if all(k in value for k in ("x", "y", "z")):
155
+ try:
156
+ vec = [float(value["x"]), float(value["y"]), float(value["z"])]
157
+ if all(math.isfinite(n) for n in vec):
158
+ return vec, None
159
+ return None, f"{param_name} values must be finite numbers, got {value}"
160
+ except (ValueError, TypeError, KeyError):
161
+ return None, f"{param_name} dict values must be numbers, got {value}"
162
+ return None, f"{param_name} dict must have 'x', 'y', 'z' keys, got {list(value.keys())}"
163
+
164
+ # If already a list/tuple with 3 elements, convert to floats
165
+ if isinstance(value, (list, tuple)) and len(value) == 3:
166
+ try:
167
+ vec = [float(value[0]), float(value[1]), float(value[2])]
168
+ if all(math.isfinite(n) for n in vec):
169
+ return vec, None
170
+ return None, f"{param_name} values must be finite numbers, got {value}"
171
+ except (ValueError, TypeError):
172
+ return None, f"{param_name} values must be numbers, got {value}"
173
+
174
+ # Try parsing as string
175
+ if isinstance(value, str):
176
+ # Check for obviously invalid values
177
+ if value in ("[object Object]", "undefined", "null", ""):
178
+ return None, f"{param_name} received invalid value: '{value}'. Expected [x, y, z] array or {{x, y, z}} object"
179
+
180
+ parsed = parse_json_payload(value)
181
+
182
+ # Handle parsed dict
183
+ if isinstance(parsed, dict):
184
+ return normalize_vector3(parsed, param_name)
185
+
186
+ # Handle parsed list
187
+ if isinstance(parsed, list) and len(parsed) == 3:
188
+ try:
189
+ vec = [float(parsed[0]), float(parsed[1]), float(parsed[2])]
190
+ if all(math.isfinite(n) for n in vec):
191
+ return vec, None
192
+ return None, f"{param_name} values must be finite numbers, got {parsed}"
193
+ except (ValueError, TypeError):
194
+ return None, f"{param_name} values must be numbers, got {parsed}"
195
+
196
+ # Handle comma-separated strings "1,2,3", "[1,2,3]", or "(1,2,3)"
197
+ s = value.strip()
198
+ if (s.startswith("[") and s.endswith("]")) or (s.startswith("(") and s.endswith(")")):
199
+ s = s[1:-1]
200
+ parts = [p.strip() for p in (s.split(",") if "," in s else s.split())]
201
+ if len(parts) == 3:
202
+ try:
203
+ vec = [float(parts[0]), float(parts[1]), float(parts[2])]
204
+ if all(math.isfinite(n) for n in vec):
205
+ return vec, None
206
+ return None, f"{param_name} values must be finite numbers, got {value}"
207
+ except (ValueError, TypeError):
208
+ return None, f"{param_name} values must be numbers, got {value}"
209
+
210
+ return None, f"{param_name} must be a [x, y, z] array or {{x, y, z}} object, got: {value}"
211
+
212
+ return None, f"{param_name} must be a list, dict, or string, got {type(value).__name__}"
213
+
214
+
215
+ def normalize_color(value: Any, output_range: str = "float") -> tuple[list[float] | None, str | None]:
216
+ """
217
+ Normalize a color parameter to [r, g, b, a] format.
218
+
219
+ Handles various input formats from MCP clients/LLMs:
220
+ - None -> (None, None)
221
+ - list/tuple [r, g, b] or [r, g, b, a] -> normalized with optional alpha
222
+ - dict {r, g, b} or {r, g, b, a} -> converted to list
223
+ - hex string "#RGB", "#RRGGBB", "#RRGGBBAA" -> parsed to [r, g, b, a]
224
+ - JSON string -> parsed and normalized
225
+
226
+ Args:
227
+ value: The color value to normalize
228
+ output_range: "float" for 0.0-1.0 range, "int" for 0-255 range
229
+
230
+ Returns:
231
+ Tuple of (parsed_color, error_message). If error_message is set, parsed_color is None.
232
+ """
233
+ if value is None:
234
+ return None, None
235
+
236
+ def _to_output_range(components: list[float], from_hex: bool = False) -> list:
237
+ """Convert color components to the requested output range."""
238
+ if output_range == "int":
239
+ if from_hex:
240
+ # Already 0-255 from hex parsing
241
+ return [int(c) for c in components]
242
+ # Check if input is normalized (0-1) or already 0-255
243
+ if all(0 <= c <= 1 for c in components):
244
+ return [int(round(c * 255)) for c in components]
245
+ return [int(c) for c in components]
246
+ else: # float
247
+ if from_hex:
248
+ # Convert 0-255 to 0-1
249
+ return [c / 255.0 for c in components]
250
+ if any(c > 1 for c in components):
251
+ return [c / 255.0 for c in components]
252
+ return [float(c) for c in components]
253
+
254
+ # Handle dict with r/g/b keys
255
+ if isinstance(value, dict):
256
+ if all(k in value for k in ("r", "g", "b")):
257
+ try:
258
+ color = [float(value["r"]), float(value["g"]), float(value["b"])]
259
+ if "a" in value:
260
+ color.append(float(value["a"]))
261
+ else:
262
+ if output_range == "int" and all(0 <= c <= 1 for c in color):
263
+ color.append(1.0)
264
+ else:
265
+ color.append(1.0 if output_range == "float" else 255)
266
+ return _to_output_range(color), None
267
+ except (ValueError, TypeError, KeyError):
268
+ return None, f"color dict values must be numbers, got {value}"
269
+ return None, f"color dict must have 'r', 'g', 'b' keys, got {list(value.keys())}"
270
+
271
+ # Already a list/tuple - validate
272
+ if isinstance(value, (list, tuple)):
273
+ if len(value) in (3, 4):
274
+ try:
275
+ color = [float(c) for c in value]
276
+ if len(color) == 3:
277
+ if output_range == "int" and all(0 <= c <= 1 for c in color):
278
+ color.append(1.0)
279
+ else:
280
+ color.append(1.0 if output_range == "float" else 255)
281
+ return _to_output_range(color), None
282
+ except (ValueError, TypeError):
283
+ return None, f"color values must be numbers, got {value}"
284
+ return None, f"color must have 3 or 4 components, got {len(value)}"
285
+
286
+ # Try parsing as string
287
+ if isinstance(value, str):
288
+ if value in ("[object Object]", "undefined", "null", ""):
289
+ return None, f"color received invalid value: '{value}'. Expected [r, g, b, a] or {{r, g, b, a}}"
290
+
291
+ # Handle hex colors
292
+ if value.startswith("#"):
293
+ h = value.lstrip("#")
294
+ try:
295
+ if len(h) == 3:
296
+ # Short form #RGB -> expand to #RRGGBB
297
+ components = [int(c + c, 16) for c in h] + [255]
298
+ return _to_output_range(components, from_hex=True), None
299
+ elif len(h) == 6:
300
+ components = [int(h[i:i+2], 16) for i in (0, 2, 4)] + [255]
301
+ return _to_output_range(components, from_hex=True), None
302
+ elif len(h) == 8:
303
+ components = [int(h[i:i+2], 16) for i in (0, 2, 4, 6)]
304
+ return _to_output_range(components, from_hex=True), None
305
+ except ValueError:
306
+ return None, f"Invalid hex color: {value}"
307
+ return None, f"Invalid hex color length: {value}"
308
+
309
+ # Try parsing as JSON
310
+ parsed = parse_json_payload(value)
311
+
312
+ # Handle parsed dict
313
+ if isinstance(parsed, dict):
314
+ return normalize_color(parsed, output_range)
315
+
316
+ # Handle parsed list
317
+ if isinstance(parsed, (list, tuple)) and len(parsed) in (3, 4):
318
+ try:
319
+ color = [float(c) for c in parsed]
320
+ if len(color) == 3:
321
+ if output_range == "int" and all(0 <= c <= 1 for c in color):
322
+ color.append(1.0)
323
+ else:
324
+ color.append(1.0 if output_range == "float" else 255)
325
+ return _to_output_range(color), None
326
+ except (ValueError, TypeError):
327
+ return None, f"color values must be numbers, got {parsed}"
328
+
329
+ # Handle tuple-style strings "(r, g, b)" or "(r, g, b, a)"
330
+ s = value.strip()
331
+ if (s.startswith("[") and s.endswith("]")) or (s.startswith("(") and s.endswith(")")):
332
+ s = s[1:-1]
333
+ parts = [p.strip() for p in s.split(",")]
334
+ if len(parts) in (3, 4):
335
+ try:
336
+ color = [float(p) for p in parts]
337
+ if len(color) == 3:
338
+ if output_range == "int" and all(0 <= c <= 1 for c in color):
339
+ color.append(1.0)
340
+ else:
341
+ color.append(1.0 if output_range == "float" else 255)
342
+ return _to_output_range(color), None
343
+ except (ValueError, TypeError):
344
+ pass # Fall through to error message
345
+
346
+ return None, f"Failed to parse color string: {value}"
347
+
348
+ return None, f"color must be a list, dict, hex string, or JSON string, got {type(value).__name__}"