Skip to content

Commit 8770ef4

Browse files
swissmoclaude
andcommitted
fix(bulk): narrow client: Any to HomeAssistantClient, pin deep-copy and example-value fixes
1. Narrow the untyped client seam the deleted malformed-states test used to protect. ServiceTools.__init__, _reject_operations_group_member_conflicts, register_service_tools, resolve_bulk_selector, and _load_topology now take HomeAssistantClient instead of Any -- so a states-shaped response from the wrong client (e.g. the websocket client's get_states() -> dict[str, Any], vs. the REST client's list[dict[str, Any]]) is a static error again, not a silent fail-open on the group-safety gate. This did cascade one step, as flagged as a possibility: registry.py's ToolsRegistry.__init__(server: Any, ...) was the remaining break in the chain (self.client = server.client inferred Any from it). Closed with a small Protocol (_ServerLike, matching the existing CustomRouteServer pattern in browser_landing.py) instead of importing the concrete HomeAssistantSmartMCPServer class, since server.py's own lazy import of ToolsRegistry exists specifically to avoid that circular import. Narrowing surfaced two real (independent, pre-existing) issues along the way, both fixed: _capture_initial_state called get_entity_state(entity_id) where entity_id was str | None -- safe today only because the one call site's should_wait guard embeds an entity_id is not None check the type checker can't see through, now made explicit. And the first Protocol draft declared plain (implicitly settable) attributes, which HomeAssistantSmartMCPServer's read-only client/device_tools @Property definitions don't satisfy -- fixed by declaring them as @Property in the Protocol too. Validated with the exact CI mypy command (mypy src/ custom_components/ homeassistant-addon/ scripts/, 255 files) -- zero new errors anywhere else in the codebase. 2. Add test_parameters_copies_are_genuinely_deep_not_shallow: the existing parameters-isolation test only reassigned a top-level scalar key, which a shallow dict(...) copy already protects against. This mutates a nested list value (rgb_color) both externally (caller's dict, after the call) and on one already-returned row, checking the resolution's stored copy and a fresh .operations re-read. Verified load-bearing by temporarily reverting each of the two copy.deepcopy call sites to dict() independently and confirming the test fails both times before restoring the fix. 3. Fix the selector-only-parameter example's placeholder rendering as a quoted string for every field, including timeout_seconds (a float). New _PER_ROW_PARAMETER_EXAMPLE_VALUES gives each field a correctly-typed sample (5 for timeout_seconds, {"brightness_pct": 30} for parameters, etc.) instead of a bare "...". Pinned by test_selector_only_parameter_example_uses_a_correctly_typed_sample. Full unit suite: 10725 passed (up from 10722 by the tests added here); the same 26 pre-existing Windows-platform-only failures as every prior baseline this session remain, unrelated to this change. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
1 parent bfb9245 commit 8770ef4

5 files changed

Lines changed: 155 additions & 10 deletions

File tree

src/ha_mcp/tools/bulk_selector.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212

1313
from pydantic import ConfigDict, Field
1414

15+
from ..client.rest_client import HomeAssistantClient
1516
from ..utils.domain_handlers import get_domain_handler
1617
from ..utils.entity_membership import normalize_member_entity_ids
1718
from ..visibility.resolver import VisibilityDataUnavailable, load_hidden_set
@@ -414,7 +415,7 @@ def _validate_selector(selector: Mapping[str, Any], action: str) -> _ValidatedSe
414415
# ACTUAL registry that failed ("the device registry fetch failed") instead
415416
# of a generic "a topology fetch failed" that gives an operator nothing to
416417
# search HA's own logs for.
417-
async def _load_topology(client: Any) -> _Topology:
418+
async def _load_topology(client: HomeAssistantClient) -> _Topology:
418419
"""Load the HA state and registry views used by one resolution.
419420
420421
``return_exceptions=True`` plus an explicit re-raise guard, mirroring
@@ -643,7 +644,7 @@ async def _load_hidden_entities(
643644

644645

645646
async def resolve_bulk_selector(
646-
client: Any,
647+
client: HomeAssistantClient,
647648
selector: Mapping[str, Any],
648649
*,
649650
action: str,

src/ha_mcp/tools/registry.py

Lines changed: 42 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,9 @@
2121
import logging
2222
import pkgutil
2323
from pathlib import Path
24-
from typing import Any
24+
from typing import Any, Protocol
25+
26+
from ..client.rest_client import HomeAssistantClient
2527

2628
logger = logging.getLogger(__name__)
2729

@@ -31,6 +33,43 @@
3133
"backup": "register_backup_tools",
3234
}
3335

36+
37+
class _ServerLike(Protocol):
38+
"""Structural type for the object ``ToolsRegistry`` reads tool
39+
dependencies from -- matches ``HomeAssistantSmartMCPServer`` without
40+
importing it, which ``server.py``'s own lazy
41+
``from .tools.registry import ToolsRegistry`` (inside its
42+
``tools_registry`` property) exists specifically to avoid a circular
43+
import for.
44+
45+
Only ``client`` is narrowed to its real type: it is the one attribute
46+
every ``register_*_tools(mcp, client, **kwargs)`` plugin function reads
47+
``.get_states()``/``.send_websocket_message()``/etc. from, and a
48+
dict-shaped response from the WRONG client implementation (e.g.
49+
``HomeAssistantWebSocketClient.get_states() -> dict[str, Any]``, vs.
50+
the REST client's ``list[dict[str, Any]]``) silently fails open
51+
downstream instead of raising -- see
52+
``_find_group_member_conflicts`` in ``tools_service.py``.
53+
``mcp``/``smart_tools``/``device_tools`` stay ``Any``, matching their
54+
own already-``Any`` typing on ``HomeAssistantSmartMCPServer`` itself.
55+
56+
Declared as read-only ``@property`` methods, not plain attributes: a
57+
bare ``client: HomeAssistantClient`` class-level annotation implies a
58+
SETTABLE member, which ``HomeAssistantSmartMCPServer.client`` (a
59+
getter-only ``@property``, lazily creating the client on first access)
60+
does not satisfy.
61+
"""
62+
63+
@property
64+
def client(self) -> HomeAssistantClient: ...
65+
@property
66+
def mcp(self) -> Any: ...
67+
@property
68+
def smart_tools(self) -> Any: ...
69+
@property
70+
def device_tools(self) -> Any: ...
71+
72+
3473
# Preset module groups for common use cases
3574
MODULE_PRESETS = {
3675
"automation": [
@@ -56,9 +95,9 @@ class ToolsRegistry:
5695
- Comma-separated list: Load specific modules
5796
"""
5897

59-
def __init__(self, server: Any, enabled_modules: str = "all") -> None:
98+
def __init__(self, server: _ServerLike, enabled_modules: str = "all") -> None:
6099
self.server = server
61-
self.client = server.client
100+
self.client: HomeAssistantClient = server.client
62101
self.mcp = server.mcp
63102
self._enabled_modules = enabled_modules
64103
# These are now lazily initialized via server properties

src/ha_mcp/tools/tools_service.py

Lines changed: 35 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
from pydantic import ConfigDict, Field, SkipValidation, TypeAdapter, ValidationError
1515

1616
from ..client.rest_client import (
17+
HomeAssistantClient,
1718
HomeAssistantCommandError,
1819
HomeAssistantCommandNotSent,
1920
HomeAssistantConnectionError,
@@ -312,7 +313,7 @@ def _find_group_member_conflicts(
312313

313314

314315
async def _reject_operations_group_member_conflicts(
315-
client: Any, operations: list[Any]
316+
client: HomeAssistantClient, operations: list[Any]
316317
) -> None:
317318
"""Fail closed when an operations-mode batch targets a group/aggregate
318319
entity together with one or more of its own members.
@@ -464,6 +465,18 @@ def _selector_only_parameter_offender(
464465
{"action", "parameters", "timeout_seconds", "validate_first"}
465466
)
466467

468+
# One representative, correctly-typed sample value per per-row parameter,
469+
# for the worked example below. A bare "..." placeholder rendered inside a
470+
# Python dict literal is always a quoted STRING regardless of the field's
471+
# real type -- for timeout_seconds (a float) that example teaches the
472+
# model the wrong shape for the exact value it is being told to copy.
473+
_PER_ROW_PARAMETER_EXAMPLE_VALUES: dict[str, Any] = {
474+
"action": "on",
475+
"parameters": {"brightness_pct": 30},
476+
"timeout_seconds": 5,
477+
"validate_first": True,
478+
}
479+
467480

468481
def _selector_only_parameter_message(offending_parameter: str) -> str:
469482
"""Build the remedy for one selector-only parameter used in operations mode.
@@ -483,7 +496,9 @@ def _selector_only_parameter_message(offending_parameter: str) -> str:
483496
example_row: dict[str, Any] = {"entity_id": "light.kitchen"}
484497
if offending_parameter != "action":
485498
example_row["action"] = "on"
486-
example_row[offending_parameter] = "..."
499+
example_row[offending_parameter] = _PER_ROW_PARAMETER_EXAMPLE_VALUES[
500+
offending_parameter
501+
]
487502
return (
488503
f"'{offending_parameter}' is a per-operation field in operations "
489504
f"mode (see BulkControlOperation), not a top-level tool argument "
@@ -746,7 +761,7 @@ def _build_service_suggestions(
746761
class ServiceTools:
747762
"""Service call and device operation tools for Home Assistant."""
748763

749-
def __init__(self, client: Any, device_tools: Any) -> None:
764+
def __init__(self, client: HomeAssistantClient, device_tools: Any) -> None:
750765
self._client = client
751766
self._device_tools = device_tools
752767

@@ -910,7 +925,20 @@ def _build_timeout_response(
910925
return response
911926

912927
async def _capture_initial_state(self, entity_id: str | None) -> str | None:
913-
"""Capture the current state of an entity before a service call."""
928+
"""Capture the current state of an entity before a service call.
929+
930+
``entity_id`` stays optional in the signature to match the caller's
931+
own ``str | None`` (a service call may target zero entities); the
932+
one current call site only reaches this when ``should_wait`` (which
933+
embeds an ``entity_id is not None`` check among several AND-ed
934+
conditions) is true, so ``entity_id`` is always real there in
935+
practice -- but that invariant lives in a boolean a few lines away,
936+
not in a form the type checker can see through. Narrowing here
937+
instead of trusting the caller keeps ``get_entity_state`` (which
938+
genuinely requires a ``str``) honestly typed.
939+
"""
940+
if entity_id is None:
941+
return None
914942
try:
915943
state_data = await self._client.get_entity_state(entity_id)
916944
return state_data.get("state") if state_data else None
@@ -2271,7 +2299,9 @@ async def ha_call_event(
22712299
}
22722300

22732301

2274-
def register_service_tools(mcp: Any, client: Any, **kwargs: Any) -> None:
2302+
def register_service_tools(
2303+
mcp: Any, client: HomeAssistantClient, **kwargs: Any
2304+
) -> None:
22752305
"""Register service call and operation monitoring tools with the MCP server."""
22762306
device_tools = kwargs.get("device_tools")
22772307
if not device_tools:

tests/src/unit/test_bulk_selector.py

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1190,6 +1190,60 @@ async def test_resolution_parameters_survive_caller_mutating_its_own_dict() -> N
11901190
assert result.operations[0]["parameters"]["brightness_pct"] == 50
11911191

11921192

1193+
@pytest.mark.asyncio
1194+
async def test_parameters_copies_are_genuinely_deep_not_shallow() -> None:
1195+
"""Both ``parameters`` copies (the resolution's own stored copy, and
1196+
each dispatch row's own copy) must be deep, not a shallow ``dict(...)``
1197+
that only stops top-level key REBINDING while still sharing nested
1198+
mutable values (e.g. an ``rgb_color`` list) by reference.
1199+
1200+
``test_resolution_parameters_survive_caller_mutating_its_own_dict``
1201+
above only reassigns a top-level scalar key after the call, which a
1202+
shallow ``dict(...)`` copy already protects against -- it would keep
1203+
passing even if either ``copy.deepcopy`` call were reverted. This
1204+
mutates a NESTED list value instead, at both call sites a caller could
1205+
plausibly touch:
1206+
1207+
1. The caller's own dict, after ``resolve_bulk_selector`` returns --
1208+
pins the resolution-level store-site copy.
1209+
2. One already-returned dispatch row, then re-reads ``.operations``
1210+
fresh -- pins the per-row copy. Both rows trace back to the SAME
1211+
``_operation_common["parameters"]`` object via ``**`` spread before
1212+
their own copy is made, so a shallow per-row copy would let row A's
1213+
mutation corrupt that shared source and leak into a fresh read of
1214+
row B.
1215+
"""
1216+
client = SelectorClient(
1217+
states=[_state("light.a"), _state("light.b")],
1218+
entities=[
1219+
{"entity_id": "light.a", "area_id": "salon"},
1220+
{"entity_id": "light.b", "area_id": "salon"},
1221+
],
1222+
)
1223+
caller_parameters = {"rgb_color": [255, 0, 0]}
1224+
1225+
result = await resolve_bulk_selector(
1226+
client,
1227+
{"domain": "light", "area_ids": ["salon"]},
1228+
action="on",
1229+
parameters=caller_parameters,
1230+
timeout_seconds=None,
1231+
validate_first=True,
1232+
)
1233+
assert len(result.operations) == 2, "test needs 2+ rows to prove no cross-row leak"
1234+
1235+
# 1. External mutation, after the call, must never reach the stored copy.
1236+
caller_parameters["rgb_color"][0] = 111
1237+
for row in result.operations:
1238+
assert row["parameters"]["rgb_color"] == [255, 0, 0]
1239+
1240+
# 2. Mutating one already-returned row must not corrupt the shared
1241+
# source a FRESH `.operations` read (a new property call) derives from.
1242+
result.operations[0]["parameters"]["rgb_color"][0] = -1
1243+
for row in result.operations: # freshly recomputed, not the mutated list
1244+
assert row["parameters"]["rgb_color"] == [255, 0, 0]
1245+
1246+
11931247
@pytest.mark.asyncio
11941248
async def test_resolution_equality_is_action_sensitive() -> None:
11951249
"""Two resolutions over the identical entity set but OPPOSITE actions

tests/src/unit/test_ha_bulk_control_selector.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -824,6 +824,27 @@ async def test_selector_only_action_example_never_duplicates_the_key() -> None:
824824
assert "'action': 'on', 'action'" not in message, message
825825

826826

827+
@pytest.mark.asyncio
828+
async def test_selector_only_parameter_example_uses_a_correctly_typed_sample() -> None:
829+
"""The worked example's placeholder value must match the offending
830+
field's real type -- a bare ``"..."`` string, rendered inside a Python
831+
dict literal, is always shown quoted regardless of what type the field
832+
actually is. For ``timeout_seconds`` (a ``float``) that taught the
833+
model to copy a string where a number belongs.
834+
"""
835+
tools = ServiceTools(MagicMock(), MagicMock())
836+
837+
with pytest.raises(ToolError) as exc_info:
838+
await tools.ha_bulk_control(
839+
operations=[{"entity_id": "light.one", "action": "off"}],
840+
timeout_seconds=5.0,
841+
)
842+
843+
message = json.loads(str(exc_info.value))["error"]["message"]
844+
assert "'timeout_seconds': 5" in message, message
845+
assert "'timeout_seconds': '...'" not in message, message
846+
847+
827848
@pytest.mark.asyncio
828849
async def test_selector_mode_requires_action() -> None:
829850
"""Selector mode with no ``action`` must fail before any registry read,

0 commit comments

Comments
 (0)