Skip to content

Commit 97faac5

Browse files
Issue #518 part1 (#551)
* fix: Signal tool errors via isError for automation, service, and registry tools (#518) Part 1 of the isError signaling fix. Adds core infrastructure and applies to tools that already have comprehensive E2E test coverage. Core changes: - Add raise_tool_error() helper to convert error dicts to ToolError exceptions - Update exception_to_structured_error() to raise by default (raise_error=True) - Add @overload type signatures for raise_error parameter - Add safe_call_tool() and tool_error_to_result() test utilities Tool changes (3 tools with existing E2E tests): - tools_config_automations: 5 error paths now raise ToolError - tools_service: 4 error paths now raise ToolError - tools_registry: 1 error path now raises ToolError Test updates: - Update 11 E2E test files to use safe_call_tool for error path testing - Add unit tests for raise_tool_error and exception_to_structured_error - Add retry logic for entity rename tests (timing robustness) Closes part of #518 https://claude.ai/code/session_01MvDDV6qmWosBfGhmTLYzo8 * refactor: use match statement and keep raise_error=False as default Per code review feedback: 1. Use match statement for HTTP status code dispatch (Python 3.13) 2. Keep raise_error=False as default to avoid regression during PR stack - Tools explicitly pass raise_error=True or use raise_error=False + raise_tool_error() - Part 5 will flip the default after all tools are migrated 3. Update unit tests to reflect new default behavior https://claude.ai/code/session_01MvDDV6qmWosBfGhmTLYzo8 * fix: Address code review feedback on PR #551 Fixes for HIGH priority issues: - H1: Add `except ToolError: raise` before `except Exception` handlers in tools_config_automations.py and tools_service.py to prevent double-wrapping - H2: Change return type annotation to `dict[str, Any]` (NoReturn in union is meaningless) - H3: Add case 403 to match statement for auth/permission errors Fixes for MEDIUM priority issues: - M2: Add `default=str` to json.dumps in raise_tool_error for non-serializable fallback - M3: Change pytest.skip to pytest.xfail in test_lights.py to not mask regressions https://claude.ai/code/session_01MvDDV6qmWosBfGhmTLYzo8 --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent c847979 commit 97faac5

16 files changed

Lines changed: 705 additions & 255 deletions

File tree

src/ha_mcp/tools/helpers.py

Lines changed: 125 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,12 @@
55
"""
66

77
import functools
8+
import json
89
import logging
910
import time
10-
from typing import Any
11+
from typing import Any, Literal, NoReturn, overload
12+
13+
from fastmcp.exceptions import ToolError
1114

1215
from ..client.rest_client import (
1316
HomeAssistantAPIError,
@@ -29,6 +32,34 @@
2932
logger = logging.getLogger(__name__)
3033

3134

35+
def raise_tool_error(error_response: dict[str, Any]) -> NoReturn:
36+
"""
37+
Raise a ToolError with structured error information.
38+
39+
This function converts a structured error response dictionary into a ToolError
40+
exception, which signals to MCP clients that the tool execution failed via
41+
the isError flag in the protocol response.
42+
43+
The structured error information is preserved as JSON in the error message,
44+
allowing AI agents to parse and act on the detailed error information.
45+
46+
Args:
47+
error_response: Structured error response dictionary with 'success': False
48+
and 'error' containing code, message, suggestions, etc.
49+
50+
Raises:
51+
ToolError: Always raises with the JSON-serialized error response
52+
53+
Example:
54+
>>> error = create_error_response(
55+
... ErrorCode.ENTITY_NOT_FOUND,
56+
... "Entity light.nonexistent not found"
57+
... )
58+
>>> raise_tool_error(error) # Raises ToolError with isError=true
59+
"""
60+
raise ToolError(json.dumps(error_response, indent=2, default=str))
61+
62+
3263
async def get_connected_ws_client(
3364
base_url: str, token: str
3465
) -> tuple[HomeAssistantWebSocketClient | None, dict[str, Any] | None]:
@@ -52,9 +83,29 @@ async def get_connected_ws_client(
5283
return ws_client, None
5384

5485

86+
@overload
87+
def exception_to_structured_error(
88+
error: Exception,
89+
context: dict[str, Any] | None = None,
90+
*,
91+
raise_error: Literal[False] = False,
92+
) -> dict[str, Any]: ...
93+
94+
95+
@overload
96+
def exception_to_structured_error(
97+
error: Exception,
98+
context: dict[str, Any] | None = None,
99+
*,
100+
raise_error: Literal[True],
101+
) -> NoReturn: ...
102+
103+
55104
def exception_to_structured_error(
56105
error: Exception,
57106
context: dict[str, Any] | None = None,
107+
*,
108+
raise_error: bool = False,
58109
) -> dict[str, Any]:
59110
"""
60111
Convert an exception to a structured error response.
@@ -65,83 +116,105 @@ def exception_to_structured_error(
65116
Args:
66117
error: The exception to convert
67118
context: Additional context to include in the response
119+
raise_error: If True, raises ToolError with the structured error.
120+
If False (default), returns the error dict.
121+
122+
NOTE: The default will change to True in a future PR once
123+
all tools are updated to use ToolError. New code should
124+
explicitly pass raise_error=True for forward compatibility.
68125
69126
Returns:
70-
Structured error response dictionary
127+
Structured error response dictionary (only if raise_error=False)
128+
129+
Raises:
130+
ToolError: If raise_error=True, raises with JSON-serialized error
71131
"""
72132
error_str = str(error).lower()
73133
error_msg = str(error)
74134

135+
error_response: dict[str, Any]
136+
75137
# Handle specific exception types
76138
if isinstance(error, HomeAssistantConnectionError):
77139
if "timeout" in error_str:
78-
return create_connection_error(error_msg, timeout=True)
79-
return create_connection_error(error_msg)
140+
error_response = create_connection_error(error_msg, timeout=True)
141+
else:
142+
error_response = create_connection_error(error_msg)
80143

81-
if isinstance(error, HomeAssistantAuthError):
144+
elif isinstance(error, HomeAssistantAuthError):
82145
if "expired" in error_str:
83-
return create_auth_error(error_msg, expired=True)
84-
return create_auth_error(error_msg)
146+
error_response = create_auth_error(error_msg, expired=True)
147+
else:
148+
error_response = create_auth_error(error_msg)
85149

86-
if isinstance(error, HomeAssistantAPIError):
150+
elif isinstance(error, HomeAssistantAPIError):
87151
# Check for specific error patterns
88-
if error.status_code == 404:
89-
# Entity or resource not found
90-
entity_id = context.get("entity_id") if context else None
91-
if entity_id:
92-
return create_entity_not_found_error(entity_id, details=error_msg)
93-
return create_error_response(
94-
ErrorCode.RESOURCE_NOT_FOUND,
95-
error_msg,
96-
context=context,
97-
)
98-
if error.status_code == 401:
99-
return create_auth_error(error_msg)
100-
if error.status_code == 400:
101-
return create_validation_error(error_msg, context=context)
102-
103-
# Generic API error
104-
return create_error_response(
105-
ErrorCode.SERVICE_CALL_FAILED,
106-
error_msg,
107-
context=context,
108-
)
152+
match error.status_code:
153+
case 404:
154+
# Entity or resource not found
155+
entity_id = context.get("entity_id") if context else None
156+
if entity_id:
157+
error_response = create_entity_not_found_error(entity_id, details=error_msg)
158+
else:
159+
error_response = create_error_response(
160+
ErrorCode.RESOURCE_NOT_FOUND,
161+
error_msg,
162+
context=context,
163+
)
164+
case 401 | 403:
165+
error_response = create_auth_error(error_msg)
166+
case 400:
167+
error_response = create_validation_error(error_msg, context=context)
168+
case _:
169+
# Generic API error
170+
error_response = create_error_response(
171+
ErrorCode.SERVICE_CALL_FAILED,
172+
error_msg,
173+
context=context,
174+
)
109175

110-
if isinstance(error, TimeoutError):
176+
elif isinstance(error, TimeoutError):
111177
operation = context.get("operation", "request") if context else "request"
112178
timeout_seconds = context.get("timeout_seconds", 30) if context else 30
113-
return create_timeout_error(operation, timeout_seconds, details=error_msg)
179+
error_response = create_timeout_error(operation, timeout_seconds, details=error_msg)
114180

115-
if isinstance(error, ValueError):
116-
return create_validation_error(error_msg)
181+
elif isinstance(error, ValueError):
182+
error_response = create_validation_error(error_msg)
117183

118184
# Check for common error patterns in error message
119-
if "not found" in error_str or "404" in error_str:
185+
elif "not found" in error_str or "404" in error_str:
120186
entity_id = context.get("entity_id") if context else None
121187
if entity_id:
122-
return create_entity_not_found_error(entity_id, details=error_msg)
123-
return create_error_response(
124-
ErrorCode.RESOURCE_NOT_FOUND,
188+
error_response = create_entity_not_found_error(entity_id, details=error_msg)
189+
else:
190+
error_response = create_error_response(
191+
ErrorCode.RESOURCE_NOT_FOUND,
192+
error_msg,
193+
context=context,
194+
)
195+
196+
elif "timeout" in error_str:
197+
error_response = create_timeout_error("operation", 30, details=error_msg)
198+
199+
elif "connection" in error_str or "connect" in error_str:
200+
error_response = create_connection_error(error_msg)
201+
202+
elif "auth" in error_str or "token" in error_str or "401" in error_str:
203+
error_response = create_auth_error(error_msg)
204+
205+
else:
206+
# Default to internal error
207+
error_response = create_error_response(
208+
ErrorCode.INTERNAL_ERROR,
125209
error_msg,
210+
details="An unexpected error occurred",
126211
context=context,
127212
)
128213

129-
if "timeout" in error_str:
130-
return create_timeout_error("operation", 30, details=error_msg)
131-
132-
if "connection" in error_str or "connect" in error_str:
133-
return create_connection_error(error_msg)
134-
135-
if "auth" in error_str or "token" in error_str or "401" in error_str:
136-
return create_auth_error(error_msg)
214+
if raise_error:
215+
raise_tool_error(error_response)
137216

138-
# Default to internal error
139-
return create_error_response(
140-
ErrorCode.INTERNAL_ERROR,
141-
error_msg,
142-
details="An unexpected error occurred",
143-
context=context,
144-
)
217+
return error_response
145218

146219

147220
def log_tool_usage(func: Any) -> Any:

src/ha_mcp/tools/tools_config_automations.py

Lines changed: 17 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -8,14 +8,15 @@
88
import logging
99
from typing import Annotated, Any, cast
1010

11+
from fastmcp.exceptions import ToolError
1112
from pydantic import Field
1213

1314
from ..errors import (
1415
create_config_error,
1516
create_resource_not_found_error,
1617
create_validation_error,
1718
)
18-
from .helpers import exception_to_structured_error, log_tool_usage
19+
from .helpers import exception_to_structured_error, log_tool_usage, raise_tool_error
1920
from .util_helpers import parse_json_param
2021

2122
logger = logging.getLogger(__name__)
@@ -249,12 +250,13 @@ async def ha_config_get_automation(
249250
)
250251
error_response["action"] = "get"
251252
error_response["reason"] = "not_found"
252-
return error_response
253+
raise_tool_error(error_response)
253254

254255
logger.error(f"Error getting automation: {e}")
255256
error_response = exception_to_structured_error(
256257
e,
257258
context={"identifier": identifier, "action": "get"},
259+
raise_error=False,
258260
)
259261
# Add automation-specific suggestions
260262
if "error" in error_response and isinstance(error_response["error"], dict):
@@ -263,7 +265,7 @@ async def ha_config_get_automation(
263265
"Check Home Assistant connection",
264266
"Use ha_get_domain_docs('automation') for configuration help",
265267
]
266-
return error_response
268+
raise_tool_error(error_response)
267269

268270
@mcp.tool(
269271
annotations={
@@ -411,19 +413,19 @@ async def ha_config_set_automation(
411413
try:
412414
parsed_config = parse_json_param(config, "config")
413415
except ValueError as e:
414-
return create_validation_error(
416+
raise_tool_error(create_validation_error(
415417
f"Invalid config parameter: {e}",
416418
parameter="config",
417419
invalid_json=True,
418-
)
420+
))
419421

420422
# Ensure config is a dict
421423
if parsed_config is None or not isinstance(parsed_config, dict):
422-
return create_validation_error(
424+
raise_tool_error(create_validation_error(
423425
"Config parameter must be a JSON object",
424426
parameter="config",
425427
details=f"Received type: {type(parsed_config).__name__}",
426-
)
428+
))
427429

428430
config_dict = cast(dict[str, Any], parsed_config)
429431

@@ -441,11 +443,11 @@ async def ha_config_set_automation(
441443

442444
missing_fields = [f for f in required_fields if f not in config_dict]
443445
if missing_fields:
444-
return create_config_error(
446+
raise_tool_error(create_config_error(
445447
f"Missing required fields: {', '.join(missing_fields)}",
446448
identifier=identifier,
447449
missing_fields=missing_fields,
448-
)
450+
))
449451

450452
result = await client.upsert_automation_config(config_dict, identifier)
451453
return {
@@ -454,11 +456,14 @@ async def ha_config_set_automation(
454456
"config_provided": config_dict,
455457
}
456458

459+
except ToolError:
460+
raise
457461
except Exception as e:
458462
logger.error(f"Error upserting automation: {e}")
459463
error_response = exception_to_structured_error(
460464
e,
461465
context={"identifier": identifier},
466+
raise_error=False,
462467
)
463468
# Add automation-specific suggestions
464469
if "error" in error_response and isinstance(error_response["error"], dict):
@@ -469,7 +474,7 @@ async def ha_config_set_automation(
469474
"Use ha_search_entities(domain_filter='automation') to find automations",
470475
"Use ha_get_domain_docs('automation') for comprehensive configuration help",
471476
]
472-
return error_response
477+
raise_tool_error(error_response)
473478

474479
@mcp.tool(
475480
annotations={
@@ -513,6 +518,7 @@ async def ha_config_remove_automation(
513518
error_response = exception_to_structured_error(
514519
e,
515520
context={"identifier": identifier},
521+
raise_error=False,
516522
)
517523
error_response["action"] = "delete"
518524
# Add automation-specific suggestions
@@ -522,4 +528,4 @@ async def ha_config_remove_automation(
522528
"Use entity_id format: automation.morning_routine or unique_id",
523529
"Check Home Assistant connection",
524530
]
525-
return error_response
531+
raise_tool_error(error_response)

src/ha_mcp/tools/tools_registry.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
from pydantic import Field
1717

1818
from ..errors import ErrorCode, create_error_response
19-
from .helpers import log_tool_usage
19+
from .helpers import log_tool_usage, raise_tool_error
2020
from .util_helpers import coerce_bool_param, parse_string_list_param
2121

2222
# Known voice assistant identifiers
@@ -785,10 +785,10 @@ async def ha_update_device(
785785
try:
786786
parsed_labels = parse_string_list_param(labels, "labels")
787787
except ValueError as e:
788-
return create_error_response(
788+
raise_tool_error(create_error_response(
789789
ErrorCode.VALIDATION_INVALID_PARAMETER,
790790
f"Invalid labels parameter: {e}",
791-
)
791+
))
792792

793793
# Delegate to internal implementation
794794
return await _update_device_internal(

0 commit comments

Comments
 (0)