mcpforunityserver 8.5.0__py3-none-any.whl → 9.1.0__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 (79) hide show
  1. cli/__init__.py +3 -0
  2. cli/commands/__init__.py +3 -0
  3. cli/commands/animation.py +87 -0
  4. cli/commands/asset.py +310 -0
  5. cli/commands/audio.py +133 -0
  6. cli/commands/batch.py +184 -0
  7. cli/commands/code.py +189 -0
  8. cli/commands/component.py +212 -0
  9. cli/commands/editor.py +487 -0
  10. cli/commands/gameobject.py +510 -0
  11. cli/commands/instance.py +101 -0
  12. cli/commands/lighting.py +128 -0
  13. cli/commands/material.py +268 -0
  14. cli/commands/prefab.py +144 -0
  15. cli/commands/scene.py +255 -0
  16. cli/commands/script.py +240 -0
  17. cli/commands/shader.py +238 -0
  18. cli/commands/ui.py +263 -0
  19. cli/commands/vfx.py +439 -0
  20. cli/main.py +248 -0
  21. cli/utils/__init__.py +31 -0
  22. cli/utils/config.py +58 -0
  23. cli/utils/connection.py +191 -0
  24. cli/utils/output.py +195 -0
  25. main.py +207 -62
  26. {mcpforunityserver-8.5.0.dist-info → mcpforunityserver-9.1.0.dist-info}/METADATA +4 -2
  27. mcpforunityserver-9.1.0.dist-info/RECORD +96 -0
  28. {mcpforunityserver-8.5.0.dist-info → mcpforunityserver-9.1.0.dist-info}/WHEEL +1 -1
  29. {mcpforunityserver-8.5.0.dist-info → mcpforunityserver-9.1.0.dist-info}/entry_points.txt +1 -0
  30. {mcpforunityserver-8.5.0.dist-info → mcpforunityserver-9.1.0.dist-info}/top_level.txt +1 -2
  31. services/custom_tool_service.py +179 -19
  32. services/resources/__init__.py +6 -1
  33. services/resources/active_tool.py +1 -1
  34. services/resources/custom_tools.py +2 -2
  35. services/resources/editor_state.py +283 -21
  36. services/resources/gameobject.py +243 -0
  37. services/resources/layers.py +1 -1
  38. services/resources/prefab_stage.py +1 -1
  39. services/resources/project_info.py +1 -1
  40. services/resources/selection.py +1 -1
  41. services/resources/tags.py +1 -1
  42. services/resources/unity_instances.py +1 -1
  43. services/resources/windows.py +1 -1
  44. services/state/external_changes_scanner.py +245 -0
  45. services/tools/__init__.py +6 -1
  46. services/tools/batch_execute.py +24 -9
  47. services/tools/debug_request_context.py +8 -2
  48. services/tools/execute_custom_tool.py +6 -1
  49. services/tools/execute_menu_item.py +6 -3
  50. services/tools/find_gameobjects.py +89 -0
  51. services/tools/find_in_file.py +26 -19
  52. services/tools/manage_asset.py +19 -43
  53. services/tools/manage_components.py +131 -0
  54. services/tools/manage_editor.py +9 -8
  55. services/tools/manage_gameobject.py +120 -79
  56. services/tools/manage_material.py +80 -31
  57. services/tools/manage_prefabs.py +7 -1
  58. services/tools/manage_scene.py +34 -13
  59. services/tools/manage_script.py +62 -19
  60. services/tools/manage_scriptable_object.py +22 -10
  61. services/tools/manage_shader.py +8 -1
  62. services/tools/manage_vfx.py +738 -0
  63. services/tools/preflight.py +110 -0
  64. services/tools/read_console.py +81 -18
  65. services/tools/refresh_unity.py +153 -0
  66. services/tools/run_tests.py +202 -41
  67. services/tools/script_apply_edits.py +15 -7
  68. services/tools/set_active_instance.py +12 -7
  69. services/tools/utils.py +60 -6
  70. transport/legacy/port_discovery.py +2 -2
  71. transport/legacy/unity_connection.py +129 -26
  72. transport/plugin_hub.py +191 -19
  73. transport/unity_instance_middleware.py +93 -2
  74. transport/unity_transport.py +17 -6
  75. utils/focus_nudge.py +321 -0
  76. __init__.py +0 -0
  77. mcpforunityserver-8.5.0.dist-info/RECORD +0 -66
  78. routes/__init__.py +0 -0
  79. {mcpforunityserver-8.5.0.dist-info → mcpforunityserver-9.1.0.dist-info}/licenses/LICENSE +0 -0
@@ -1,15 +1,24 @@
1
- """Tool for executing Unity Test Runner suites."""
2
- from typing import Annotated, Literal, Any
1
+ """Async Unity Test Runner jobs: start + poll."""
2
+ from __future__ import annotations
3
+
4
+ import asyncio
5
+ import logging
6
+ import time
7
+ from typing import Annotated, Any, Literal
3
8
 
4
9
  from fastmcp import Context
5
- from pydantic import BaseModel, Field
10
+ from mcp.types import ToolAnnotations
11
+ from pydantic import BaseModel
6
12
 
7
13
  from models import MCPResponse
8
14
  from services.registry import mcp_for_unity_tool
9
15
  from services.tools import get_unity_instance_from_context
10
- from services.tools.utils import coerce_int
11
- from transport.unity_transport import send_with_unity_instance
16
+ from services.tools.preflight import preflight
17
+ import transport.unity_transport as unity_transport
12
18
  from transport.legacy.unity_connection import async_send_command_with_retry
19
+ from utils.focus_nudge import nudge_unity_focus, should_nudge
20
+
21
+ logger = logging.getLogger(__name__)
13
22
 
14
23
 
15
24
  class RunTestsSummary(BaseModel):
@@ -34,28 +43,86 @@ class RunTestsTestResult(BaseModel):
34
43
  class RunTestsResult(BaseModel):
35
44
  mode: str
36
45
  summary: RunTestsSummary
37
- results: list[RunTestsTestResult]
46
+ results: list[RunTestsTestResult] | None = None
47
+
48
+
49
+ class RunTestsStartData(BaseModel):
50
+ job_id: str
51
+ status: str
52
+ mode: str | None = None
53
+ include_details: bool | None = None
54
+ include_failed_tests: bool | None = None
55
+
56
+
57
+ class RunTestsStartResponse(MCPResponse):
58
+ data: RunTestsStartData | None = None
59
+
60
+
61
+ class TestJobFailure(BaseModel):
62
+ full_name: str | None = None
63
+ message: str | None = None
64
+
65
+
66
+ class TestJobProgress(BaseModel):
67
+ completed: int | None = None
68
+ total: int | None = None
69
+ current_test_full_name: str | None = None
70
+ current_test_started_unix_ms: int | None = None
71
+ last_finished_test_full_name: str | None = None
72
+ last_finished_unix_ms: int | None = None
73
+ stuck_suspected: bool | None = None
74
+ editor_is_focused: bool | None = None
75
+ blocked_reason: str | None = None
76
+ failures_so_far: list[TestJobFailure] | None = None
77
+ failures_capped: bool | None = None
38
78
 
39
79
 
40
- class RunTestsResponse(MCPResponse):
41
- data: RunTestsResult | None = None
80
+ class GetTestJobData(BaseModel):
81
+ job_id: str
82
+ status: str
83
+ mode: str | None = None
84
+ started_unix_ms: int | None = None
85
+ finished_unix_ms: int | None = None
86
+ last_update_unix_ms: int | None = None
87
+ progress: TestJobProgress | None = None
88
+ error: str | None = None
89
+ result: RunTestsResult | None = None
90
+
91
+
92
+ class GetTestJobResponse(MCPResponse):
93
+ data: GetTestJobData | None = None
42
94
 
43
95
 
44
96
  @mcp_for_unity_tool(
45
- description="Runs Unity tests for the specified mode"
97
+ description="Starts a Unity test run asynchronously and returns a job_id immediately. Poll with get_test_job for progress.",
98
+ annotations=ToolAnnotations(
99
+ title="Run Tests",
100
+ destructiveHint=True,
101
+ ),
46
102
  )
47
103
  async def run_tests(
48
104
  ctx: Context,
49
- mode: Annotated[Literal["EditMode", "PlayMode"], "Unity test mode to run"] = "EditMode",
50
- timeout_seconds: Annotated[int | str, "Optional timeout in seconds for the test run"] | None = None,
51
- test_names: Annotated[list[str] | str, "Full names of specific tests to run (e.g., 'MyNamespace.MyTests.TestMethod')"] | None = None,
52
- group_names: Annotated[list[str] | str, "Same as test_names, except it allows for Regex"] | None = None,
53
- category_names: Annotated[list[str] | str, "NUnit category names to filter by (tests marked with [Category] attribute)"] | None = None,
54
- assembly_names: Annotated[list[str] | str, "Assembly names to filter tests by"] | None = None,
55
- ) -> RunTestsResponse:
105
+ mode: Annotated[Literal["EditMode", "PlayMode"],
106
+ "Unity test mode to run"] = "EditMode",
107
+ test_names: Annotated[list[str] | str,
108
+ "Full names of specific tests to run"] | None = None,
109
+ group_names: Annotated[list[str] | str,
110
+ "Same as test_names, except it allows for Regex"] | None = None,
111
+ category_names: Annotated[list[str] | str,
112
+ "NUnit category names to filter by"] | None = None,
113
+ assembly_names: Annotated[list[str] | str,
114
+ "Assembly names to filter tests by"] | None = None,
115
+ include_failed_tests: Annotated[bool,
116
+ "Include details for failed/skipped tests only (default: false)"] = False,
117
+ include_details: Annotated[bool,
118
+ "Include details for all tests (default: false)"] = False,
119
+ ) -> RunTestsStartResponse | MCPResponse:
56
120
  unity_instance = get_unity_instance_from_context(ctx)
57
121
 
58
- # Coerce string or list to list of strings
122
+ gate = await preflight(ctx, requires_no_tests=True, wait_for_no_compile=True, refresh_if_dirty=True)
123
+ if isinstance(gate, MCPResponse):
124
+ return gate
125
+
59
126
  def _coerce_string_list(value) -> list[str] | None:
60
127
  if value is None:
61
128
  return None
@@ -67,27 +134,121 @@ async def run_tests(
67
134
  return None
68
135
 
69
136
  params: dict[str, Any] = {"mode": mode}
70
- ts = coerce_int(timeout_seconds)
71
- if ts is not None:
72
- params["timeoutSeconds"] = ts
73
-
74
- # Add filter parameters if provided
75
- test_names_list = _coerce_string_list(test_names)
76
- if test_names_list:
77
- params["testNames"] = test_names_list
78
-
79
- group_names_list = _coerce_string_list(group_names)
80
- if group_names_list:
81
- params["groupNames"] = group_names_list
82
-
83
- category_names_list = _coerce_string_list(category_names)
84
- if category_names_list:
85
- params["categoryNames"] = category_names_list
86
-
87
- assembly_names_list = _coerce_string_list(assembly_names)
88
- if assembly_names_list:
89
- params["assemblyNames"] = assembly_names_list
90
-
91
- response = await send_with_unity_instance(async_send_command_with_retry, unity_instance, "run_tests", params)
92
- await ctx.info(f'Response {response}')
93
- return RunTestsResponse(**response) if isinstance(response, dict) else response
137
+ if (t := _coerce_string_list(test_names)):
138
+ params["testNames"] = t
139
+ if (g := _coerce_string_list(group_names)):
140
+ params["groupNames"] = g
141
+ if (c := _coerce_string_list(category_names)):
142
+ params["categoryNames"] = c
143
+ if (a := _coerce_string_list(assembly_names)):
144
+ params["assemblyNames"] = a
145
+ if include_failed_tests:
146
+ params["includeFailedTests"] = True
147
+ if include_details:
148
+ params["includeDetails"] = True
149
+
150
+ response = await unity_transport.send_with_unity_instance(
151
+ async_send_command_with_retry,
152
+ unity_instance,
153
+ "run_tests",
154
+ params,
155
+ )
156
+
157
+ if isinstance(response, dict):
158
+ if not response.get("success", True):
159
+ return MCPResponse(**response)
160
+ return RunTestsStartResponse(**response)
161
+ return MCPResponse(success=False, error=str(response))
162
+
163
+
164
+ @mcp_for_unity_tool(
165
+ description="Polls an async Unity test job by job_id.",
166
+ annotations=ToolAnnotations(
167
+ title="Get Test Job",
168
+ readOnlyHint=True,
169
+ ),
170
+ )
171
+ async def get_test_job(
172
+ ctx: Context,
173
+ job_id: Annotated[str, "Job id returned by run_tests"],
174
+ include_failed_tests: Annotated[bool,
175
+ "Include details for failed/skipped tests only (default: false)"] = False,
176
+ include_details: Annotated[bool,
177
+ "Include details for all tests (default: false)"] = False,
178
+ wait_timeout: Annotated[int | None,
179
+ "If set, wait up to this many seconds for tests to complete before returning. "
180
+ "Reduces polling frequency and avoids client-side loop detection. "
181
+ "Recommended: 30-60 seconds. Returns immediately if tests complete sooner."] = None,
182
+ ) -> GetTestJobResponse | MCPResponse:
183
+ unity_instance = get_unity_instance_from_context(ctx)
184
+
185
+ params: dict[str, Any] = {"job_id": job_id}
186
+ if include_failed_tests:
187
+ params["includeFailedTests"] = True
188
+ if include_details:
189
+ params["includeDetails"] = True
190
+
191
+ async def _fetch_status() -> dict[str, Any]:
192
+ return await unity_transport.send_with_unity_instance(
193
+ async_send_command_with_retry,
194
+ unity_instance,
195
+ "get_test_job",
196
+ params,
197
+ )
198
+
199
+ # If wait_timeout is specified, poll server-side until complete or timeout
200
+ if wait_timeout and wait_timeout > 0:
201
+ deadline = asyncio.get_event_loop().time() + wait_timeout
202
+ poll_interval = 2.0 # Poll Unity every 2 seconds
203
+
204
+ while True:
205
+ response = await _fetch_status()
206
+
207
+ if not isinstance(response, dict):
208
+ return MCPResponse(success=False, error=str(response))
209
+
210
+ if not response.get("success", True):
211
+ return MCPResponse(**response)
212
+
213
+ # Check if tests are done
214
+ data = response.get("data", {})
215
+ status = data.get("status", "")
216
+ if status in ("succeeded", "failed", "cancelled"):
217
+ return GetTestJobResponse(**response)
218
+
219
+ # Check if Unity needs a focus nudge to make progress
220
+ # This handles OS-level throttling (e.g., macOS App Nap) that can
221
+ # stall PlayMode tests when Unity is in the background.
222
+ progress = data.get("progress", {})
223
+ editor_is_focused = progress.get("editor_is_focused", True)
224
+ last_update_unix_ms = data.get("last_update_unix_ms")
225
+ current_time_ms = int(time.time() * 1000)
226
+
227
+ if should_nudge(
228
+ status=status,
229
+ editor_is_focused=editor_is_focused,
230
+ last_update_unix_ms=last_update_unix_ms,
231
+ current_time_ms=current_time_ms,
232
+ stall_threshold_ms=10_000, # 10 seconds without progress
233
+ ):
234
+ logger.info(f"Test job {job_id} appears stalled (unfocused Unity), attempting nudge...")
235
+ nudged = await nudge_unity_focus(focus_duration_s=0.5)
236
+ if nudged:
237
+ logger.info(f"Test job {job_id} nudge completed")
238
+
239
+ # Check timeout
240
+ remaining = deadline - asyncio.get_event_loop().time()
241
+ if remaining <= 0:
242
+ # Timeout reached, return current status
243
+ return GetTestJobResponse(**response)
244
+
245
+ # Wait before next poll (but don't exceed remaining time)
246
+ await asyncio.sleep(min(poll_interval, remaining))
247
+
248
+ # No wait_timeout - return immediately (original behavior)
249
+ response = await _fetch_status()
250
+ if isinstance(response, dict):
251
+ if not response.get("success", True):
252
+ return MCPResponse(**response)
253
+ return GetTestJobResponse(**response)
254
+ return MCPResponse(success=False, error=str(response))
@@ -4,6 +4,7 @@ import re
4
4
  from typing import Annotated, Any, Union
5
5
 
6
6
  from fastmcp import Context
7
+ from mcp.types import ToolAnnotations
7
8
 
8
9
  from services.registry import mcp_for_unity_tool
9
10
  from services.tools import get_unity_instance_from_context
@@ -228,7 +229,7 @@ def _normalize_script_locator(name: str, path: str) -> tuple[str, str]:
228
229
  - name = "SmartReach.cs", path = "Assets/Scripts/Interaction"
229
230
  - name = "Assets/Scripts/Interaction/SmartReach.cs", path = ""
230
231
  - path = "Assets/Scripts/Interaction/SmartReach.cs" (name empty)
231
- - name or path using uri prefixes: unity://path/..., file://...
232
+ - name or path using uri prefixes: mcpforunity://path/..., file://...
232
233
  - accidental duplicates like "Assets/.../SmartReach.cs/SmartReach.cs"
233
234
 
234
235
  Returns (name_without_extension, directory_path_under_Assets).
@@ -237,8 +238,8 @@ def _normalize_script_locator(name: str, path: str) -> tuple[str, str]:
237
238
  p = (path or "").strip()
238
239
 
239
240
  def strip_prefix(s: str) -> str:
240
- if s.startswith("unity://path/"):
241
- return s[len("unity://path/"):]
241
+ if s.startswith("mcpforunity://path/"):
242
+ return s[len("mcpforunity://path/"):]
242
243
  if s.startswith("file://"):
243
244
  return s[len("file://"):]
244
245
  return s
@@ -309,8 +310,10 @@ def _err(code: str, message: str, *, expected: dict[str, Any] | None = None, rew
309
310
  # Natural-language parsing removed; clients should send structured edits.
310
311
 
311
312
 
312
- @mcp_for_unity_tool(name="script_apply_edits", description=(
313
- """Structured C# edits (methods/classes) with safer boundaries - prefer this over raw text.
313
+ @mcp_for_unity_tool(
314
+ name="script_apply_edits",
315
+ description=(
316
+ """Structured C# edits (methods/classes) with safer boundaries - prefer this over raw text.
314
317
  Best practices:
315
318
  - Prefer anchor_* ops for pattern-based insert/replace near stable markers
316
319
  - Use replace_method/delete_method for whole-method changes (keeps signatures balanced)
@@ -356,7 +359,12 @@ def _err(code: str, message: str, *, expected: dict[str, Any] | None = None, rew
356
359
  ],
357
360
  }
358
361
  ]"""
359
- ))
362
+ ),
363
+ annotations=ToolAnnotations(
364
+ title="Script Apply Edits",
365
+ destructiveHint=True,
366
+ ),
367
+ )
360
368
  async def script_apply_edits(
361
369
  ctx: Context,
362
370
  name: Annotated[str, "Name of the script to edit"],
@@ -372,7 +380,7 @@ async def script_apply_edits(
372
380
  unity_instance = get_unity_instance_from_context(ctx)
373
381
  await ctx.info(
374
382
  f"Processing script_apply_edits: {name} (unity_instance={unity_instance or 'default'})")
375
-
383
+
376
384
  # Parse edits if they came as a stringified JSON
377
385
  edits = parse_json_payload(edits)
378
386
  if not isinstance(edits, list):
@@ -2,6 +2,8 @@ from typing import Annotated, Any
2
2
  from types import SimpleNamespace
3
3
 
4
4
  from fastmcp import Context
5
+ from mcp.types import ToolAnnotations
6
+
5
7
  from services.registry import mcp_for_unity_tool
6
8
  from transport.legacy.unity_connection import get_unity_connection_pool
7
9
  from transport.unity_instance_middleware import get_unity_instance_middleware
@@ -10,7 +12,10 @@ from transport.unity_transport import _current_transport
10
12
 
11
13
 
12
14
  @mcp_for_unity_tool(
13
- description="Set the active Unity instance for this client/session. Accepts Name@hash or hash."
15
+ description="Set the active Unity instance for this client/session. Accepts Name@hash or hash.",
16
+ annotations=ToolAnnotations(
17
+ title="Set Active Instance",
18
+ ),
14
19
  )
15
20
  async def set_active_instance(
16
21
  ctx: Context,
@@ -51,7 +56,7 @@ async def set_active_instance(
51
56
  return {
52
57
  "success": False,
53
58
  "error": "Instance identifier is required. "
54
- "Use unity://instances to copy a Name@hash or provide a hash prefix."
59
+ "Use mcpforunity://instances to copy a Name@hash or provide a hash prefix."
55
60
  }
56
61
  resolved = None
57
62
  if "@" in value:
@@ -60,7 +65,7 @@ async def set_active_instance(
60
65
  return {
61
66
  "success": False,
62
67
  "error": f"Instance '{value}' not found. "
63
- "Use unity://instances to copy an exact Name@hash."
68
+ "Use mcpforunity://instances to copy an exact Name@hash."
64
69
  }
65
70
  else:
66
71
  lookup = value.lower()
@@ -75,7 +80,7 @@ async def set_active_instance(
75
80
  return {
76
81
  "success": False,
77
82
  "error": f"Instance hash '{value}' does not match any running Unity editors. "
78
- "Use unity://instances to confirm the available hashes."
83
+ "Use mcpforunity://instances to confirm the available hashes."
79
84
  }
80
85
  if len(matches) > 1:
81
86
  matching_ids = ", ".join(
@@ -84,10 +89,10 @@ async def set_active_instance(
84
89
  return {
85
90
  "success": False,
86
91
  "error": f"Instance hash '{value}' is ambiguous ({matching_ids}). "
87
- "Provide the full Name@hash from unity://instances."
92
+ "Provide the full Name@hash from mcpforunity://instances."
88
93
  }
89
94
  resolved = matches[0]
90
-
95
+
91
96
  if resolved is None:
92
97
  # Should be unreachable due to logic above, but satisfies static analysis
93
98
  return {
@@ -101,7 +106,7 @@ async def set_active_instance(
101
106
  # The session key is an internal detail but useful for debugging response.
102
107
  middleware.set_active_instance(ctx, resolved.id)
103
108
  session_key = middleware.get_session_key(ctx)
104
-
109
+
105
110
  return {
106
111
  "success": True,
107
112
  "message": f"Active instance set to {resolved.id}",
services/tools/utils.py CHANGED
@@ -8,6 +8,7 @@ from typing import Any
8
8
  _TRUTHY = {"true", "1", "yes", "on"}
9
9
  _FALSY = {"false", "0", "no", "off"}
10
10
 
11
+
11
12
  def coerce_bool(value: Any, default: bool | None = None) -> bool | None:
12
13
  """Attempt to coerce a loosely-typed value to a boolean."""
13
14
  if value is None:
@@ -27,26 +28,26 @@ def coerce_bool(value: Any, default: bool | None = None) -> bool | None:
27
28
  def parse_json_payload(value: Any) -> Any:
28
29
  """
29
30
  Attempt to parse a value that might be a JSON string into its native object.
30
-
31
+
31
32
  This is a tolerant parser used to handle cases where MCP clients or LLMs
32
33
  serialize complex objects (lists, dicts) into strings. It also handles
33
34
  scalar values like numbers, booleans, and null.
34
-
35
+
35
36
  Args:
36
37
  value: The input value (can be str, list, dict, etc.)
37
-
38
+
38
39
  Returns:
39
40
  The parsed JSON object/list if the input was a valid JSON string,
40
41
  or the original value if parsing failed or wasn't necessary.
41
42
  """
42
43
  if not isinstance(value, str):
43
44
  return value
44
-
45
+
45
46
  val_trimmed = value.strip()
46
-
47
+
47
48
  # Fast path: if it doesn't look like JSON structure, return as is
48
49
  if not (
49
- (val_trimmed.startswith("{") and val_trimmed.endswith("}")) or
50
+ (val_trimmed.startswith("{") and val_trimmed.endswith("}")) or
50
51
  (val_trimmed.startswith("[") and val_trimmed.endswith("]")) or
51
52
  val_trimmed in ("true", "false", "null") or
52
53
  (val_trimmed.replace(".", "", 1).replace("-", "", 1).isdigit())
@@ -75,3 +76,56 @@ def coerce_int(value: Any, default: int | None = None) -> int | None:
75
76
  return int(float(s))
76
77
  except Exception:
77
78
  return default
79
+
80
+
81
+ def coerce_float(value: Any, default: float | None = None) -> float | None:
82
+ """Attempt to coerce a loosely-typed value to a float-like number."""
83
+ if value is None:
84
+ return default
85
+ try:
86
+ # Treat booleans as invalid numeric input instead of coercing to 0/1.
87
+ if isinstance(value, bool):
88
+ return default
89
+ if isinstance(value, (int, float)):
90
+ return float(value)
91
+ s = str(value).strip()
92
+ if s.lower() in ("", "none", "null"):
93
+ return default
94
+ return float(s)
95
+ except (TypeError, ValueError):
96
+ return default
97
+
98
+
99
+ def normalize_properties(value: Any) -> tuple[dict[str, Any] | None, str | None]:
100
+ """
101
+ Robustly normalize a properties parameter to a dict.
102
+
103
+ Handles various input formats from MCP clients/LLMs:
104
+ - None -> (None, None)
105
+ - dict -> (dict, None)
106
+ - JSON string -> (parsed_dict, None) or (None, error_message)
107
+ - Invalid values -> (None, error_message)
108
+
109
+ Returns:
110
+ Tuple of (parsed_dict, error_message). If error_message is set, parsed_dict is None.
111
+ """
112
+ if value is None:
113
+ return None, None
114
+
115
+ # Already a dict - return as-is
116
+ if isinstance(value, dict):
117
+ return value, None
118
+
119
+ # Try parsing as string
120
+ if isinstance(value, str):
121
+ # Check for obviously invalid values from serialization bugs
122
+ if value in ("[object Object]", "undefined", "null", ""):
123
+ return None, f"properties received invalid value: '{value}'. Expected a JSON object like {{\"key\": value}}"
124
+
125
+ parsed = parse_json_payload(value)
126
+ if isinstance(parsed, dict):
127
+ return parsed, None
128
+
129
+ return None, f"properties must be a JSON object (dict), got string that parsed to {type(parsed).__name__}"
130
+
131
+ return None, f"properties must be a dict or JSON string, got {type(value).__name__}"
@@ -279,9 +279,9 @@ class PortDiscovery:
279
279
  if freshness.tzinfo:
280
280
  from datetime import timezone
281
281
  now = datetime.now(timezone.utc)
282
-
282
+
283
283
  age_s = (now - freshness).total_seconds()
284
-
284
+
285
285
  if is_reloading and age_s < 60:
286
286
  pass # keep it, status="reloading"
287
287
  else: