Skip to content

Commit 915ea36

Browse files
authored
feat: add ha_get_states tool for bulk entity state retrieval (#588)
* feat: add ha_get_states tool for bulk entity state retrieval Adds a new ha_get_states tool that retrieves state information for multiple entities in a single call using parallel requests. Returns states as a dict keyed by entity_id for direct access. Features: - Parallel fetching via asyncio.gather - Input validation using create_validation_error - 100 entity upper-bound limit - Automatic deduplication preserving order - Partial success support (partial=True flag) - Structured error handling per entity (ENTITY_NOT_FOUND, etc.) Also expands AGENTS.md Error Handling section to document the dedicated error helper functions. Closes #377 * test: add E2E tests for ha_get_states bulk state retrieval Covers: multiple known entities, partial failure with nonexistent entities, all-fail case, empty list validation, response structure (dict-keyed states), and deduplication behavior. * refactor: delegate 404 classification to exception_to_structured_error Remove redundant manual 404 string matching in _fetch_state since exception_to_structured_error already handles this classification when entity_id is in context. Update AGENTS.md pattern accordingly.
1 parent 3cb07cf commit 915ea36

4 files changed

Lines changed: 537 additions & 8 deletions

File tree

AGENTS.md

Lines changed: 34 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -619,14 +619,41 @@ def register_<domain>_tools(mcp, client, **kwargs):
619619
| `idempotentHint: True` | `False` | Repeated calls with same args have no additional effect (only meaningful when `readOnlyHint` is false) |
620620

621621
### Error Handling
622-
Use structured errors from `errors.py`:
622+
623+
**Always use the dedicated error functions** from `errors.py` and `helpers.py`. Never construct raw error dicts manually — the helpers ensure consistent structure, error codes, and suggestions across all tools.
624+
625+
**Domain-specific errors** (`errors.py`) — use these when the error type is known:
623626
```python
624-
from ..errors import create_error_response, ErrorCode
625-
return create_error_response(
626-
code=ErrorCode.ENTITY_NOT_FOUND,
627-
message="Entity not found",
628-
suggestions=["Use ha_search_entities() to find valid IDs"]
629-
)
627+
from ..errors import create_entity_not_found_error, create_validation_error, create_service_error
628+
629+
# Entity lookup failures (404 / not found)
630+
return create_entity_not_found_error(entity_id, details=str(e))
631+
632+
# Invalid parameters
633+
return create_validation_error("Invalid format", parameter="entity_ids", details=str(e))
634+
635+
# Service call failures
636+
return create_service_error(domain, service, message=f"Service call failed: {e}", details=str(e))
637+
```
638+
639+
Available helpers: `create_entity_not_found_error`, `create_connection_error`, `create_auth_error`, `create_service_error`, `create_validation_error`, `create_config_error`, `create_timeout_error`, `create_resource_not_found_error`, and the generic `create_error_response`.
640+
641+
**Catch-all exception handler** (`helpers.py`) — use in `except Exception` blocks:
642+
```python
643+
from .helpers import exception_to_structured_error
644+
645+
except Exception as e:
646+
return exception_to_structured_error(e, context={"entity_id": entity_id})
647+
```
648+
649+
**Pattern for tools**: Use `exception_to_structured_error` as the catch-all — it already classifies 404s, auth errors, timeouts, etc. based on exception type and message. Pass `context={"entity_id": ...}` so it can produce `ENTITY_NOT_FOUND` for 404 errors automatically. No manual 404 string matching needed:
650+
```python
651+
try:
652+
result = await client.get_entity_state(entity_id)
653+
return await add_timezone_metadata(client, result)
654+
except Exception as e:
655+
error_response = exception_to_structured_error(e, context={"entity_id": entity_id})
656+
return await add_timezone_metadata(client, error_response)
630657
```
631658

632659
### Return Values

src/ha_mcp/tools/tools_search.py

Lines changed: 129 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010

1111
from pydantic import Field
1212

13-
from ..errors import create_entity_not_found_error
13+
from ..errors import create_entity_not_found_error, create_validation_error
1414
from .helpers import exception_to_structured_error, log_tool_usage
1515
from .util_helpers import (
1616
add_timezone_metadata,
@@ -682,3 +682,131 @@ async def ha_get_state(entity_id: str) -> dict[str, Any]:
682682
"Use ha_search_entities() to find correct entity IDs",
683683
]
684684
return await add_timezone_metadata(client, error_response)
685+
686+
@mcp.tool(
687+
annotations={
688+
"idempotentHint": True,
689+
"readOnlyHint": True,
690+
"tags": ["search"],
691+
"title": "Get Multiple Entity States",
692+
}
693+
)
694+
@log_tool_usage
695+
async def ha_get_states(
696+
entity_ids: Annotated[
697+
list[str],
698+
Field(
699+
description="List of entity IDs to retrieve states for (e.g., ['light.kitchen', 'sensor.temperature'])"
700+
),
701+
],
702+
) -> dict[str, Any]:
703+
"""Get state information for multiple Home Assistant entities in a single call.
704+
705+
Efficiently retrieves states for multiple entities using parallel requests
706+
instead of calling ha_get_state repeatedly. Maximum 100 entities per call.
707+
Duplicate entity IDs are automatically deduplicated.
708+
709+
Returns success=True if at least one entity state was retrieved.
710+
Check 'error_count' for any failed lookups in partial-success scenarios.
711+
712+
WHEN TO USE:
713+
- Checking states of multiple entities at once (e.g., verifying automation results)
714+
- Comparing states across related entities
715+
- Any scenario requiring 2+ entity state lookups
716+
717+
EXAMPLES:
718+
- ha_get_states(["light.kitchen", "light.living_room", "sensor.temperature"])
719+
- ha_get_states(["automation.morning_routine", "binary_sensor.motion"])
720+
721+
NOTE: For a single entity, use ha_get_state() instead.
722+
"""
723+
MAX_ENTITIES = 100
724+
725+
if not isinstance(entity_ids, list) or not entity_ids:
726+
return await add_timezone_metadata(
727+
client,
728+
create_validation_error(
729+
"entity_ids must be a non-empty list of entity ID strings",
730+
parameter="entity_ids",
731+
),
732+
)
733+
734+
if not all(isinstance(eid, str) for eid in entity_ids):
735+
return await add_timezone_metadata(
736+
client,
737+
create_validation_error(
738+
"All entity_ids values must be strings",
739+
parameter="entity_ids",
740+
),
741+
)
742+
743+
if len(entity_ids) > MAX_ENTITIES:
744+
return await add_timezone_metadata(
745+
client,
746+
create_validation_error(
747+
f"Too many entity IDs: {len(entity_ids)} exceeds maximum of {MAX_ENTITIES}",
748+
parameter="entity_ids",
749+
),
750+
)
751+
752+
# Deduplicate while preserving order
753+
unique_ids = list(dict.fromkeys(entity_ids))
754+
if len(unique_ids) < len(entity_ids):
755+
logger.debug(
756+
f"Deduplicated entity_ids: {len(entity_ids)} -> {len(unique_ids)}"
757+
)
758+
759+
try:
760+
async def _fetch_state(entity_id: str) -> dict[str, Any]:
761+
try:
762+
state = await client.get_entity_state(entity_id)
763+
return {"success": True, "entity_id": entity_id, "state": state}
764+
except Exception as e:
765+
logger.warning(f"Failed to fetch state for '{entity_id}': {e}")
766+
return exception_to_structured_error(
767+
e, context={"entity_id": entity_id}
768+
)
769+
770+
results = await asyncio.gather(
771+
*(_fetch_state(eid) for eid in unique_ids)
772+
)
773+
774+
states: dict[str, Any] = {}
775+
errors: list[dict[str, Any]] = []
776+
777+
for eid, result in zip(unique_ids, results, strict=True):
778+
if result.get("success") is True and "state" in result:
779+
states[eid] = result["state"]
780+
else:
781+
error_detail = result.get("error")
782+
if error_detail is None:
783+
error_detail = {"code": "INTERNAL_ERROR", "message": "Unknown error"}
784+
errors.append({
785+
"entity_id": result.get("entity_id", eid),
786+
"error": error_detail,
787+
})
788+
789+
response: dict[str, Any] = {
790+
"success": len(states) > 0,
791+
"count": len(states),
792+
"states": states,
793+
}
794+
795+
if errors:
796+
response["errors"] = errors
797+
response["error_count"] = len(errors)
798+
response["suggestions"] = [
799+
"Use ha_search_entities() to find correct entity IDs for failed lookups",
800+
"Verify entities exist in Home Assistant",
801+
]
802+
if states:
803+
response["partial"] = True
804+
805+
return await add_timezone_metadata(client, response)
806+
807+
except Exception as e:
808+
logger.error(f"Error getting bulk states: {e}", exc_info=True)
809+
error_response = exception_to_structured_error(
810+
e, context={"entity_ids": entity_ids}
811+
)
812+
return await add_timezone_metadata(client, error_response)
Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,177 @@
1+
"""
2+
E2E tests for ha_get_states tool - bulk entity state retrieval.
3+
4+
Tests the bulk state retrieval functionality that fetches multiple entity
5+
states in a single call using parallel requests.
6+
"""
7+
8+
import logging
9+
10+
import pytest
11+
12+
from ...utilities.assertions import assert_mcp_success, parse_mcp_result, safe_call_tool
13+
14+
logger = logging.getLogger(__name__)
15+
16+
17+
@pytest.mark.asyncio
18+
@pytest.mark.core
19+
class TestGetStates:
20+
"""Test ha_get_states bulk entity state retrieval."""
21+
22+
async def test_multiple_known_entities(self, mcp_client):
23+
"""Retrieve states for multiple known entities; all succeed."""
24+
logger.info("Testing ha_get_states with sun.sun + a sensor")
25+
26+
# Find a sensor entity to pair with sun.sun
27+
search_result = await mcp_client.call_tool(
28+
"ha_search_entities",
29+
{"query": "", "domain_filter": "sensor", "limit": 1},
30+
)
31+
search_data = parse_mcp_result(search_result)
32+
33+
if "data" in search_data:
34+
results = search_data.get("data", {}).get("results", [])
35+
else:
36+
results = search_data.get("results", [])
37+
38+
if not results:
39+
pytest.skip("No sensor entities available for testing")
40+
41+
sensor_id = results[0]["entity_id"]
42+
entity_ids = ["sun.sun", sensor_id]
43+
logger.info(f"Testing with entities: {entity_ids}")
44+
45+
result = await mcp_client.call_tool(
46+
"ha_get_states",
47+
{"entity_ids": entity_ids},
48+
)
49+
50+
data = assert_mcp_success(result, "Get multiple entity states")
51+
52+
assert "data" in data, f"Missing 'data' in response: {data}"
53+
inner = data["data"]
54+
55+
assert inner["success"] is True, f"Expected success: {inner}"
56+
assert inner["count"] == 2, f"Expected count 2: {inner}"
57+
assert isinstance(inner["states"], dict), f"states should be a dict: {inner}"
58+
assert "sun.sun" in inner["states"], f"Missing sun.sun: {inner['states']}"
59+
assert sensor_id in inner["states"], f"Missing {sensor_id}: {inner['states']}"
60+
61+
# Verify sun.sun state data
62+
sun_state = inner["states"]["sun.sun"]
63+
assert "state" in sun_state, f"Missing state in sun data: {sun_state}"
64+
assert sun_state["state"] in ["above_horizon", "below_horizon"]
65+
66+
# No errors should be present
67+
assert "errors" not in inner, f"Unexpected errors: {inner.get('errors')}"
68+
assert "partial" not in inner, f"Unexpected partial flag: {inner}"
69+
70+
# Verify metadata from add_timezone_metadata
71+
assert "metadata" in data, f"Missing metadata: {data}"
72+
73+
logger.info(f"Retrieved {inner['count']} states successfully")
74+
75+
async def test_partial_failure_with_nonexistent_entity(self, mcp_client):
76+
"""Mix of real and nonexistent entities returns partial success."""
77+
logger.info("Testing ha_get_states with partial failure")
78+
79+
result = await mcp_client.call_tool(
80+
"ha_get_states",
81+
{"entity_ids": ["sun.sun", "sensor.nonexistent_test_xyz_99999"]},
82+
)
83+
84+
data = assert_mcp_success(result, "Partial failure get_states")
85+
86+
assert "data" in data, f"Missing 'data': {data}"
87+
inner = data["data"]
88+
89+
assert inner["success"] is True, f"Partial should still be success: {inner}"
90+
assert inner["count"] == 1, f"Only one entity should succeed: {inner}"
91+
assert "sun.sun" in inner["states"], f"sun.sun should be present: {inner}"
92+
assert inner["partial"] is True, f"Should have partial flag: {inner}"
93+
assert inner["error_count"] == 1, f"Should have 1 error: {inner}"
94+
assert len(inner["errors"]) == 1, f"errors list should have 1 entry: {inner}"
95+
assert inner["errors"][0]["entity_id"] == "sensor.nonexistent_test_xyz_99999"
96+
assert "suggestions" in inner, f"Should have suggestions: {inner}"
97+
98+
logger.info("Partial failure handled correctly")
99+
100+
async def test_all_nonexistent_entities(self, mcp_client):
101+
"""All entities nonexistent returns success=False."""
102+
logger.info("Testing ha_get_states with all nonexistent entities")
103+
104+
result = await safe_call_tool(
105+
mcp_client,
106+
"ha_get_states",
107+
{"entity_ids": ["sensor.fake_aaa_111", "sensor.fake_bbb_222"]},
108+
)
109+
110+
inner = result.get("data", result)
111+
112+
assert inner.get("success") is False, f"Should be failure: {inner}"
113+
assert inner.get("count") == 0, f"No states should be returned: {inner}"
114+
assert len(inner.get("states", {})) == 0, f"states should be empty: {inner}"
115+
assert inner.get("error_count") == 2, f"Should have 2 errors: {inner}"
116+
assert "partial" not in inner, f"Should not have partial flag: {inner}"
117+
118+
logger.info("All-fail case handled correctly")
119+
120+
async def test_empty_entity_ids_rejected(self, mcp_client):
121+
"""Empty entity_ids list returns validation error."""
122+
logger.info("Testing ha_get_states with empty list")
123+
124+
result = await safe_call_tool(
125+
mcp_client,
126+
"ha_get_states",
127+
{"entity_ids": []},
128+
)
129+
130+
inner = result.get("data", result)
131+
132+
assert inner.get("success") is False, f"Should fail validation: {inner}"
133+
assert inner.get("error", {}).get("code") == "VALIDATION_FAILED", (
134+
f"Should be VALIDATION_FAILED: {inner}"
135+
)
136+
137+
logger.info("Empty list validation works correctly")
138+
139+
async def test_response_states_keyed_by_entity_id(self, mcp_client):
140+
"""Verify states dict is keyed by entity_id, not a list."""
141+
logger.info("Testing ha_get_states response structure")
142+
143+
result = await mcp_client.call_tool(
144+
"ha_get_states",
145+
{"entity_ids": ["sun.sun"]},
146+
)
147+
148+
data = assert_mcp_success(result, "Single entity get_states")
149+
150+
inner = data["data"]
151+
assert isinstance(inner["states"], dict), f"states must be dict: {type(inner['states'])}"
152+
assert "sun.sun" in inner["states"], f"Key should be entity_id: {inner['states']}"
153+
154+
sun_data = inner["states"]["sun.sun"]
155+
assert "entity_id" in sun_data, f"State data should contain entity_id: {sun_data}"
156+
assert "state" in sun_data, f"State data should contain state: {sun_data}"
157+
assert "attributes" in sun_data, f"State data should contain attributes: {sun_data}"
158+
159+
logger.info("Response structure is correct")
160+
161+
async def test_duplicate_entity_ids_deduplicated(self, mcp_client):
162+
"""Duplicate IDs are deduplicated; only one state returned per unique ID."""
163+
logger.info("Testing ha_get_states deduplication")
164+
165+
result = await mcp_client.call_tool(
166+
"ha_get_states",
167+
{"entity_ids": ["sun.sun", "sun.sun", "sun.sun"]},
168+
)
169+
170+
data = assert_mcp_success(result, "Deduplicated get_states")
171+
172+
inner = data["data"]
173+
assert inner["count"] == 1, f"Should have 1 unique state: {inner}"
174+
assert len(inner["states"]) == 1, f"states dict should have 1 entry: {inner}"
175+
assert "sun.sun" in inner["states"]
176+
177+
logger.info("Deduplication works correctly")

0 commit comments

Comments
 (0)