Skip to content

Commit 2deadcb

Browse files
fix: handle service call timeouts gracefully and add missing @log_tool usage (fixes #550) (#555)
* fix: handle service call timeouts gracefully and add missing @log_tool_usage Service calls like update.install are inherently asynchronous and can timeout without indicating failure. Previously, timeouts were reported as CONNECTION_TIMEOUT errors even when the service was dispatched successfully. Now, timeout errors from service calls return a partial success response guiding the user to check entity state. Also adds the missing @log_tool_usage decorator to all tools in tools_service.py (ha_call_service, ha_get_operation_status, ha_bulk_control, ha_get_bulk_status) for proper call logging. Closes #550 https://claude.ai/code/session_017sxnmZqDVwxtqs8BesvxRP * refactor: address Gemini review - type check for timeout, DRY suggestions - Use isinstance(error.__cause__, httpx.TimeoutException) instead of string matching on error messages for more robust timeout detection - Extract _build_service_suggestions() helper to deduplicate the suggestion list used in both exception handlers https://claude.ai/code/session_017sxnmZqDVwxtqs8BesvxRP --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 1611cdd commit 2deadcb

1 file changed

Lines changed: 56 additions & 7 deletions

File tree

src/ha_mcp/tools/tools_service.py

Lines changed: 56 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,20 +6,33 @@
66

77
from typing import Any, cast
88

9+
import httpx
10+
911
from ..errors import (
1012
create_validation_error,
1113
)
12-
from .helpers import exception_to_structured_error
14+
from ..client.rest_client import HomeAssistantConnectionError
15+
from .helpers import exception_to_structured_error, log_tool_usage
1316
from .util_helpers import coerce_bool_param, parse_json_param
1417

1518

19+
def _build_service_suggestions(domain: str, service: str, entity_id: str | None) -> list[str]:
20+
"""Build common error suggestions for service call failures."""
21+
return [
22+
f"Verify {entity_id} exists using ha_get_state()" if entity_id else "Specify an entity_id for targeted service calls",
23+
f"Check available services for {domain} domain using ha_get_domain_docs()",
24+
"Use ha_search_entities() to find correct entity IDs",
25+
]
26+
27+
1628
def register_service_tools(mcp, client, **kwargs):
1729
"""Register service call and operation monitoring tools with the MCP server."""
1830
device_tools = kwargs.get("device_tools")
1931
if not device_tools:
2032
raise ValueError("device_tools is required for service tools registration")
2133

2234
@mcp.tool(annotations={"destructiveHint": True, "title": "Call Service"})
35+
@log_tool_usage
2336
async def ha_call_service(
2437
domain: str,
2538
service: str,
@@ -107,6 +120,44 @@ async def ha_call_service(
107120
response["service_response"] = result.get("service_response", result)
108121

109122
return response
123+
except HomeAssistantConnectionError as error:
124+
# Check if this is a timeout - for service calls, timeouts typically
125+
# mean the service was dispatched but HA didn't respond in time.
126+
# The operation is likely still running (e.g., update.install, long automations).
127+
if isinstance(error.__cause__, httpx.TimeoutException):
128+
return {
129+
"success": True,
130+
"partial": True,
131+
"domain": domain,
132+
"service": service,
133+
"entity_id": entity_id,
134+
"parameters": data,
135+
"message": (
136+
f"Service {domain}.{service} was dispatched but Home Assistant "
137+
f"did not respond within the timeout period. The operation is likely "
138+
f"still running in the background."
139+
),
140+
"warning": (
141+
"Response timed out. This is normal for long-running services "
142+
f"like updates or firmware installs. Use ha_get_state('{entity_id}') "
143+
"to check the current status."
144+
if entity_id
145+
else "Response timed out. This is normal for long-running services. "
146+
"The service was dispatched and may still be executing."
147+
),
148+
}
149+
# Non-timeout connection errors are real failures
150+
error_response = exception_to_structured_error(
151+
error,
152+
context={
153+
"domain": domain,
154+
"service": service,
155+
"entity_id": entity_id,
156+
},
157+
)
158+
if "error" in error_response and isinstance(error_response["error"], dict):
159+
error_response["error"]["suggestions"] = _build_service_suggestions(domain, service, entity_id)
160+
return error_response
110161
except Exception as error:
111162
# Use structured error response
112163
error_response = exception_to_structured_error(
@@ -117,12 +168,7 @@ async def ha_call_service(
117168
"entity_id": entity_id,
118169
},
119170
)
120-
# Add service-specific suggestions
121-
suggestions = [
122-
f"Verify {entity_id} exists using ha_get_state()" if entity_id else "Specify an entity_id for targeted service calls",
123-
f"Check available services for {domain} domain using ha_get_domain_docs()",
124-
"Use ha_search_entities() to find correct entity IDs",
125-
]
171+
suggestions = _build_service_suggestions(domain, service, entity_id)
126172
if entity_id:
127173
suggestions.extend([
128174
f"For automation: ha_call_service('automation', 'trigger', entity_id='{entity_id}')",
@@ -134,6 +180,7 @@ async def ha_call_service(
134180
return error_response
135181

136182
@mcp.tool(annotations={"readOnlyHint": True, "title": "Get Operation Status"})
183+
@log_tool_usage
137184
async def ha_get_operation_status(
138185
operation_id: str, timeout_seconds: int = 10
139186
) -> dict[str, Any]:
@@ -144,6 +191,7 @@ async def ha_get_operation_status(
144191
return cast(dict[str, Any], result)
145192

146193
@mcp.tool(annotations={"destructiveHint": True, "title": "Bulk Control"})
194+
@log_tool_usage
147195
async def ha_bulk_control(
148196
operations: str | list[dict[str, Any]], parallel: bool | str = True
149197
) -> dict[str, Any]:
@@ -177,6 +225,7 @@ async def ha_bulk_control(
177225
return cast(dict[str, Any], result)
178226

179227
@mcp.tool(annotations={"readOnlyHint": True, "title": "Get Bulk Operation Status"})
228+
@log_tool_usage
180229
async def ha_get_bulk_status(operation_ids: list[str]) -> dict[str, Any]:
181230
"""
182231
Check status of multiple device control operations.

0 commit comments

Comments
 (0)