Skip to content

Commit 60d1ebc

Browse files
authored
Merge branch 'master' into fix/addon-stateless-http-and-runtime-python
2 parents 086cc53 + c835619 commit 60d1ebc

13 files changed

Lines changed: 188 additions & 120 deletions

src/ha_mcp/errors.py

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -257,20 +257,22 @@ def create_connection_error(
257257
message: str,
258258
details: str | None = None,
259259
timeout: bool = False,
260+
context: dict[str, Any] | None = None,
260261
) -> dict[str, Any]:
261262
"""Create a connection error response."""
262263
code = ErrorCode.CONNECTION_TIMEOUT if timeout else ErrorCode.CONNECTION_FAILED
263-
return create_error_response(code, message, details)
264+
return create_error_response(code, message, details, context=context)
264265

265266

266267
def create_auth_error(
267268
message: str,
268269
details: str | None = None,
269270
expired: bool = False,
271+
context: dict[str, Any] | None = None,
270272
) -> dict[str, Any]:
271273
"""Create an authentication error response."""
272274
code = ErrorCode.AUTH_EXPIRED if expired else ErrorCode.AUTH_INVALID_TOKEN
273-
return create_error_response(code, message, details)
275+
return create_error_response(code, message, details, context=context)
274276

275277

276278
def create_entity_not_found_error(
@@ -350,13 +352,19 @@ def create_timeout_error(
350352
operation: str,
351353
timeout_seconds: float,
352354
details: str | None = None,
355+
context: dict[str, Any] | None = None,
353356
) -> dict[str, Any]:
354357
"""Create a timeout error response."""
358+
final_context: dict[str, Any] = {}
359+
if context:
360+
final_context.update(context)
361+
final_context["operation"] = operation
362+
final_context["timeout_seconds"] = timeout_seconds
355363
return create_error_response(
356364
ErrorCode.TIMEOUT_OPERATION,
357365
f"Operation '{operation}' timed out after {timeout_seconds}s",
358366
details=details,
359-
context={"operation": operation, "timeout_seconds": timeout_seconds},
367+
context=final_context,
360368
)
361369

362370

src/ha_mcp/tools/helpers.py

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -137,15 +137,15 @@ def exception_to_structured_error(
137137
# Handle specific exception types
138138
if isinstance(error, HomeAssistantConnectionError):
139139
if "timeout" in error_str:
140-
error_response = create_connection_error(error_msg, timeout=True)
140+
error_response = create_connection_error(error_msg, timeout=True, context=context)
141141
else:
142-
error_response = create_connection_error(error_msg)
142+
error_response = create_connection_error(error_msg, context=context)
143143

144144
elif isinstance(error, HomeAssistantAuthError):
145145
if "expired" in error_str:
146-
error_response = create_auth_error(error_msg, expired=True)
146+
error_response = create_auth_error(error_msg, expired=True, context=context)
147147
else:
148-
error_response = create_auth_error(error_msg)
148+
error_response = create_auth_error(error_msg, context=context)
149149

150150
elif isinstance(error, HomeAssistantAPIError):
151151
# Check for specific error patterns
@@ -162,7 +162,7 @@ def exception_to_structured_error(
162162
context=context,
163163
)
164164
case 401 | 403:
165-
error_response = create_auth_error(error_msg)
165+
error_response = create_auth_error(error_msg, context=context)
166166
case 400:
167167
error_response = create_validation_error(error_msg, context=context)
168168
case _:
@@ -176,10 +176,10 @@ def exception_to_structured_error(
176176
elif isinstance(error, TimeoutError):
177177
operation = context.get("operation", "request") if context else "request"
178178
timeout_seconds = context.get("timeout_seconds", 30) if context else 30
179-
error_response = create_timeout_error(operation, timeout_seconds, details=error_msg)
179+
error_response = create_timeout_error(operation, timeout_seconds, details=error_msg, context=context)
180180

181181
elif isinstance(error, ValueError):
182-
error_response = create_validation_error(error_msg)
182+
error_response = create_validation_error(error_msg, context=context)
183183

184184
# Check for common error patterns in error message
185185
elif "not found" in error_str or "404" in error_str:
@@ -194,13 +194,13 @@ def exception_to_structured_error(
194194
)
195195

196196
elif "timeout" in error_str:
197-
error_response = create_timeout_error("operation", 30, details=error_msg)
197+
error_response = create_timeout_error("operation", 30, details=error_msg, context=context)
198198

199199
elif "connection" in error_str or "connect" in error_str:
200-
error_response = create_connection_error(error_msg)
200+
error_response = create_connection_error(error_msg, context=context)
201201

202202
elif "auth" in error_str or "token" in error_str or "401" in error_str:
203-
error_response = create_auth_error(error_msg)
203+
error_response = create_auth_error(error_msg, context=context)
204204

205205
else:
206206
# Default to internal error -- use generic message to avoid leaking internals

src/ha_mcp/tools/tools_areas.py

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

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

1314
from ..errors import ErrorCode, create_error_response
14-
from .helpers import log_tool_usage
15+
from .helpers import log_tool_usage, raise_tool_error
1516
from .util_helpers import parse_string_list_param
1617

1718
logger = logging.getLogger(__name__)
@@ -121,10 +122,10 @@ async def ha_config_set_area(
121122
try:
122123
parsed_aliases = parse_string_list_param(aliases, "aliases")
123124
except ValueError as e:
124-
return create_error_response(
125+
raise_tool_error(create_error_response(
125126
ErrorCode.VALIDATION_INVALID_PARAMETER,
126127
f"Invalid aliases parameter: {e}",
127-
)
128+
))
128129

129130
# Determine if this is a create or update operation
130131
if area_id:
@@ -193,6 +194,8 @@ async def ha_config_set_area(
193194
error_response["name"] = name
194195
return error_response
195196

197+
except ToolError:
198+
raise
196199
except Exception as e:
197200
logger.error(f"Error in ha_config_set_area: {e}")
198201
error_response = {
@@ -352,10 +355,10 @@ async def ha_config_set_floor(
352355
try:
353356
parsed_aliases = parse_string_list_param(aliases, "aliases")
354357
except ValueError as e:
355-
return create_error_response(
358+
raise_tool_error(create_error_response(
356359
ErrorCode.VALIDATION_INVALID_PARAMETER,
357360
f"Invalid aliases parameter: {e}",
358-
)
361+
))
359362

360363
# Determine if this is a create or update operation
361364
if floor_id:
@@ -420,6 +423,8 @@ async def ha_config_set_floor(
420423
error_response["name"] = name
421424
return error_response
422425

426+
except ToolError:
427+
raise
423428
except Exception as e:
424429
logger.error(f"Error in ha_config_set_floor: {e}")
425430
error_response = {

src/ha_mcp/tools/tools_config_entry_flow.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -157,7 +157,7 @@ async def ha_create_config_entry_helper(
157157
context = {"helper_type": helper_type}
158158
if flow_id:
159159
context["flow_id"] = flow_id
160-
return exception_to_structured_error(e, context=context)
160+
exception_to_structured_error(e, context=context, raise_error=True)
161161

162162
@mcp.tool(
163163
annotations={
@@ -220,4 +220,4 @@ async def ha_get_helper_schema(
220220

221221
except Exception as e:
222222
logger.error(f"Error getting helper schema: {e}")
223-
return exception_to_structured_error(e, context={"helper_type": helper_type})
223+
exception_to_structured_error(e, context={"helper_type": helper_type}, raise_error=True)

src/ha_mcp/tools/tools_config_helpers.py

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,11 @@
1010
import logging
1111
from typing import Annotated, Any, Literal
1212

13+
from fastmcp.exceptions import ToolError
1314
from pydantic import Field
1415

1516
from ..errors import ErrorCode, create_error_response
16-
from .helpers import log_tool_usage
17+
from .helpers import log_tool_usage, raise_tool_error
1718
from .util_helpers import (
1819
coerce_bool_param,
1920
parse_string_list_param,
@@ -399,10 +400,10 @@ async def ha_config_set_helper(
399400
labels = parse_string_list_param(labels, "labels")
400401
options = parse_string_list_param(options, "options")
401402
except ValueError as e:
402-
return create_error_response(
403+
raise_tool_error(create_error_response(
403404
ErrorCode.VALIDATION_INVALID_PARAMETER,
404405
f"Invalid list parameter: {e}",
405-
)
406+
))
406407

407408
# Determine if this is a create or update based on helper_id
408409
action = "update" if helper_id else "create"
@@ -708,6 +709,8 @@ async def ha_config_set_helper(
708709
"error": f"Unexpected action: {action}",
709710
}
710711

712+
except ToolError:
713+
raise
711714
except Exception as e:
712715
return {
713716
"success": False,

src/ha_mcp/tools/tools_entities.py

Lines changed: 22 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,11 @@
99
import logging
1010
from typing import Annotated, Any, Literal
1111

12+
from fastmcp.exceptions import ToolError
1213
from pydantic import Field
1314

1415
from ..errors import ErrorCode, create_error_response
15-
from .helpers import exception_to_structured_error, log_tool_usage
16+
from .helpers import exception_to_structured_error, log_tool_usage, raise_tool_error
1617
from .tools_voice_assistant import KNOWN_ASSISTANTS
1718
from .util_helpers import coerce_bool_param, parse_json_param, parse_string_list_param
1819

@@ -445,65 +446,65 @@ async def ha_set_entity(
445446
try:
446447
parsed_aliases = parse_string_list_param(aliases, "aliases")
447448
except ValueError as e:
448-
return create_error_response(
449+
raise_tool_error(create_error_response(
449450
ErrorCode.VALIDATION_INVALID_PARAMETER,
450451
f"Invalid aliases parameter: {e}",
451-
)
452+
))
452453

453454
parsed_labels = None
454455
if labels is not None:
455456
try:
456457
parsed_labels = parse_string_list_param(labels, "labels")
457458
except ValueError as e:
458-
return create_error_response(
459+
raise_tool_error(create_error_response(
459460
ErrorCode.VALIDATION_INVALID_PARAMETER,
460461
f"Invalid labels parameter: {e}",
461-
)
462+
))
462463

463464
# Parse and validate expose_to parameter
464465
parsed_expose_to: dict[str, bool] | None = None
465466
if expose_to is not None:
466467
try:
467468
parsed = parse_json_param(expose_to, "expose_to")
468469
except ValueError as e:
469-
return create_error_response(
470+
raise_tool_error(create_error_response(
470471
ErrorCode.VALIDATION_INVALID_PARAMETER,
471472
str(e),
472-
)
473+
))
473474

474475
if not isinstance(parsed, dict):
475-
return create_error_response(
476+
raise_tool_error(create_error_response(
476477
ErrorCode.VALIDATION_INVALID_PARAMETER,
477478
"expose_to must be a dict mapping assistant IDs to booleans, "
478479
'e.g. {"conversation": true, "cloud.alexa": false}',
479-
)
480+
))
480481
parsed_expose_to = parsed
481482

482483
# Validate assistant names
483484
invalid_assistants = [
484485
a for a in parsed_expose_to if a not in KNOWN_ASSISTANTS
485486
]
486487
if invalid_assistants:
487-
return create_error_response(
488+
raise_tool_error(create_error_response(
488489
ErrorCode.VALIDATION_INVALID_PARAMETER,
489490
f"Invalid assistant(s) in expose_to: {invalid_assistants}. "
490491
f"Valid: {KNOWN_ASSISTANTS}",
491-
)
492+
))
492493

493494
# Coerce values to bool
494495
for asst, val in parsed_expose_to.items():
495496
try:
496497
coerced = coerce_bool_param(val, f"expose_to[{asst}]")
497498
except ValueError as e:
498-
return create_error_response(
499+
raise_tool_error(create_error_response(
499500
ErrorCode.VALIDATION_INVALID_PARAMETER,
500501
str(e),
501-
)
502+
))
502503
if coerced is None:
503-
return create_error_response(
504+
raise_tool_error(create_error_response(
504505
ErrorCode.VALIDATION_INVALID_PARAMETER,
505506
f"expose_to[{asst}] must be a boolean value",
506-
)
507+
))
507508
parsed_expose_to[asst] = coerced
508509

509510
# Single entity case - use existing logic
@@ -579,10 +580,12 @@ async def ha_set_entity(
579580

580581
return response
581582

583+
except ToolError:
584+
raise
582585
except Exception as e:
583586
logger.error(f"Error updating entity: {e}")
584587
eid_context = entity_id if isinstance(entity_id, str) else entity_ids
585-
return exception_to_structured_error(e, context={"entity_id": eid_context})
588+
exception_to_structured_error(e, context={"entity_id": eid_context}, raise_error=True)
586589

587590
@mcp.tool(
588591
annotations={
@@ -768,6 +771,7 @@ async def _fetch_entity(eid: str) -> dict[str, Any]:
768771

769772
except Exception as e:
770773
logger.error(f"Error getting entity: {e}")
771-
return exception_to_structured_error(
772-
e, context={"entity_id": entity_id if isinstance(entity_id, str) else entity_ids}
774+
exception_to_structured_error(
775+
e, context={"entity_id": entity_id if isinstance(entity_id, str) else entity_ids},
776+
raise_error=True,
773777
)

src/ha_mcp/tools/tools_filesystem.py

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@
1919

2020
from pydantic import Field
2121

22-
from .helpers import exception_to_structured_error, log_tool_usage
22+
from .helpers import exception_to_structured_error, log_tool_usage, raise_tool_error
2323
from .util_helpers import add_timezone_metadata, coerce_bool_param, coerce_int_param
2424

2525
logger = logging.getLogger(__name__)
@@ -192,8 +192,10 @@ async def ha_list_files(
192192
error_response = exception_to_structured_error(
193193
e,
194194
context={"tool": "ha_list_files", "path": path, "pattern": pattern},
195+
raise_error=False,
195196
)
196-
return await add_timezone_metadata(client, error_response)
197+
error_with_tz = await add_timezone_metadata(client, error_response)
198+
raise_tool_error(error_with_tz)
197199

198200
@mcp.tool(
199201
annotations={
@@ -316,8 +318,10 @@ async def ha_read_file(
316318
error_response = exception_to_structured_error(
317319
e,
318320
context={"tool": "ha_read_file", "path": path},
321+
raise_error=False,
319322
)
320-
return await add_timezone_metadata(client, error_response)
323+
error_with_tz = await add_timezone_metadata(client, error_response)
324+
raise_tool_error(error_with_tz)
321325

322326
@mcp.tool(
323327
annotations={
@@ -458,8 +462,10 @@ async def ha_write_file(
458462
error_response = exception_to_structured_error(
459463
e,
460464
context={"tool": "ha_write_file", "path": path},
465+
raise_error=False,
461466
)
462-
return await add_timezone_metadata(client, error_response)
467+
error_with_tz = await add_timezone_metadata(client, error_response)
468+
raise_tool_error(error_with_tz)
463469

464470
@mcp.tool(
465471
annotations={
@@ -585,5 +591,7 @@ async def ha_delete_file(
585591
error_response = exception_to_structured_error(
586592
e,
587593
context={"tool": "ha_delete_file", "path": path},
594+
raise_error=False,
588595
)
589-
return await add_timezone_metadata(client, error_response)
596+
error_with_tz = await add_timezone_metadata(client, error_response)
597+
raise_tool_error(error_with_tz)

0 commit comments

Comments
 (0)