ha-mcp-dev 6.5.0.dev182__tar.gz → 6.5.0.dev184__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (79) hide show
  1. {ha_mcp_dev-6.5.0.dev182/src/ha_mcp_dev.egg-info → ha_mcp_dev-6.5.0.dev184}/PKG-INFO +1 -1
  2. {ha_mcp_dev-6.5.0.dev182 → ha_mcp_dev-6.5.0.dev184}/pyproject.toml +1 -1
  3. ha_mcp_dev-6.5.0.dev184/src/ha_mcp/tools/helpers.py +257 -0
  4. {ha_mcp_dev-6.5.0.dev182 → ha_mcp_dev-6.5.0.dev184}/src/ha_mcp/tools/tools_config_automations.py +17 -11
  5. {ha_mcp_dev-6.5.0.dev182 → ha_mcp_dev-6.5.0.dev184}/src/ha_mcp/tools/tools_registry.py +3 -3
  6. {ha_mcp_dev-6.5.0.dev182 → ha_mcp_dev-6.5.0.dev184}/src/ha_mcp/tools/tools_service.py +16 -11
  7. {ha_mcp_dev-6.5.0.dev182 → ha_mcp_dev-6.5.0.dev184/src/ha_mcp_dev.egg-info}/PKG-INFO +1 -1
  8. ha_mcp_dev-6.5.0.dev182/src/ha_mcp/tools/helpers.py +0 -184
  9. {ha_mcp_dev-6.5.0.dev182 → ha_mcp_dev-6.5.0.dev184}/LICENSE +0 -0
  10. {ha_mcp_dev-6.5.0.dev182 → ha_mcp_dev-6.5.0.dev184}/MANIFEST.in +0 -0
  11. {ha_mcp_dev-6.5.0.dev182 → ha_mcp_dev-6.5.0.dev184}/README.md +0 -0
  12. {ha_mcp_dev-6.5.0.dev182 → ha_mcp_dev-6.5.0.dev184}/setup.cfg +0 -0
  13. {ha_mcp_dev-6.5.0.dev182 → ha_mcp_dev-6.5.0.dev184}/src/ha_mcp/__init__.py +0 -0
  14. {ha_mcp_dev-6.5.0.dev182 → ha_mcp_dev-6.5.0.dev184}/src/ha_mcp/__main__.py +0 -0
  15. {ha_mcp_dev-6.5.0.dev182 → ha_mcp_dev-6.5.0.dev184}/src/ha_mcp/_pypi_marker +0 -0
  16. {ha_mcp_dev-6.5.0.dev182 → ha_mcp_dev-6.5.0.dev184}/src/ha_mcp/auth/__init__.py +0 -0
  17. {ha_mcp_dev-6.5.0.dev182 → ha_mcp_dev-6.5.0.dev184}/src/ha_mcp/auth/consent_form.py +0 -0
  18. {ha_mcp_dev-6.5.0.dev182 → ha_mcp_dev-6.5.0.dev184}/src/ha_mcp/auth/provider.py +0 -0
  19. {ha_mcp_dev-6.5.0.dev182 → ha_mcp_dev-6.5.0.dev184}/src/ha_mcp/client/__init__.py +0 -0
  20. {ha_mcp_dev-6.5.0.dev182 → ha_mcp_dev-6.5.0.dev184}/src/ha_mcp/client/rest_client.py +0 -0
  21. {ha_mcp_dev-6.5.0.dev182 → ha_mcp_dev-6.5.0.dev184}/src/ha_mcp/client/websocket_client.py +0 -0
  22. {ha_mcp_dev-6.5.0.dev182 → ha_mcp_dev-6.5.0.dev184}/src/ha_mcp/client/websocket_listener.py +0 -0
  23. {ha_mcp_dev-6.5.0.dev182 → ha_mcp_dev-6.5.0.dev184}/src/ha_mcp/config.py +0 -0
  24. {ha_mcp_dev-6.5.0.dev182 → ha_mcp_dev-6.5.0.dev184}/src/ha_mcp/errors.py +0 -0
  25. {ha_mcp_dev-6.5.0.dev182 → ha_mcp_dev-6.5.0.dev184}/src/ha_mcp/py.typed +0 -0
  26. {ha_mcp_dev-6.5.0.dev182 → ha_mcp_dev-6.5.0.dev184}/src/ha_mcp/resources/card_types.json +0 -0
  27. {ha_mcp_dev-6.5.0.dev182 → ha_mcp_dev-6.5.0.dev184}/src/ha_mcp/resources/dashboard_guide.md +0 -0
  28. {ha_mcp_dev-6.5.0.dev182 → ha_mcp_dev-6.5.0.dev184}/src/ha_mcp/server.py +0 -0
  29. {ha_mcp_dev-6.5.0.dev182 → ha_mcp_dev-6.5.0.dev184}/src/ha_mcp/smoke_test.py +0 -0
  30. {ha_mcp_dev-6.5.0.dev182 → ha_mcp_dev-6.5.0.dev184}/src/ha_mcp/tools/__init__.py +0 -0
  31. {ha_mcp_dev-6.5.0.dev182 → ha_mcp_dev-6.5.0.dev184}/src/ha_mcp/tools/backup.py +0 -0
  32. {ha_mcp_dev-6.5.0.dev182 → ha_mcp_dev-6.5.0.dev184}/src/ha_mcp/tools/device_control.py +0 -0
  33. {ha_mcp_dev-6.5.0.dev182 → ha_mcp_dev-6.5.0.dev184}/src/ha_mcp/tools/enhanced.py +0 -0
  34. {ha_mcp_dev-6.5.0.dev182 → ha_mcp_dev-6.5.0.dev184}/src/ha_mcp/tools/registry.py +0 -0
  35. {ha_mcp_dev-6.5.0.dev182 → ha_mcp_dev-6.5.0.dev184}/src/ha_mcp/tools/smart_search.py +0 -0
  36. {ha_mcp_dev-6.5.0.dev182 → ha_mcp_dev-6.5.0.dev184}/src/ha_mcp/tools/tools_addons.py +0 -0
  37. {ha_mcp_dev-6.5.0.dev182 → ha_mcp_dev-6.5.0.dev184}/src/ha_mcp/tools/tools_areas.py +0 -0
  38. {ha_mcp_dev-6.5.0.dev182 → ha_mcp_dev-6.5.0.dev184}/src/ha_mcp/tools/tools_blueprints.py +0 -0
  39. {ha_mcp_dev-6.5.0.dev182 → ha_mcp_dev-6.5.0.dev184}/src/ha_mcp/tools/tools_bug_report.py +0 -0
  40. {ha_mcp_dev-6.5.0.dev182 → ha_mcp_dev-6.5.0.dev184}/src/ha_mcp/tools/tools_calendar.py +0 -0
  41. {ha_mcp_dev-6.5.0.dev182 → ha_mcp_dev-6.5.0.dev184}/src/ha_mcp/tools/tools_camera.py +0 -0
  42. {ha_mcp_dev-6.5.0.dev182 → ha_mcp_dev-6.5.0.dev184}/src/ha_mcp/tools/tools_config_dashboards.py +0 -0
  43. {ha_mcp_dev-6.5.0.dev182 → ha_mcp_dev-6.5.0.dev184}/src/ha_mcp/tools/tools_config_entry_flow.py +0 -0
  44. {ha_mcp_dev-6.5.0.dev182 → ha_mcp_dev-6.5.0.dev184}/src/ha_mcp/tools/tools_config_helpers.py +0 -0
  45. {ha_mcp_dev-6.5.0.dev182 → ha_mcp_dev-6.5.0.dev184}/src/ha_mcp/tools/tools_config_info.py +0 -0
  46. {ha_mcp_dev-6.5.0.dev182 → ha_mcp_dev-6.5.0.dev184}/src/ha_mcp/tools/tools_config_scripts.py +0 -0
  47. {ha_mcp_dev-6.5.0.dev182 → ha_mcp_dev-6.5.0.dev184}/src/ha_mcp/tools/tools_entities.py +0 -0
  48. {ha_mcp_dev-6.5.0.dev182 → ha_mcp_dev-6.5.0.dev184}/src/ha_mcp/tools/tools_filesystem.py +0 -0
  49. {ha_mcp_dev-6.5.0.dev182 → ha_mcp_dev-6.5.0.dev184}/src/ha_mcp/tools/tools_groups.py +0 -0
  50. {ha_mcp_dev-6.5.0.dev182 → ha_mcp_dev-6.5.0.dev184}/src/ha_mcp/tools/tools_hacs.py +0 -0
  51. {ha_mcp_dev-6.5.0.dev182 → ha_mcp_dev-6.5.0.dev184}/src/ha_mcp/tools/tools_history.py +0 -0
  52. {ha_mcp_dev-6.5.0.dev182 → ha_mcp_dev-6.5.0.dev184}/src/ha_mcp/tools/tools_integrations.py +0 -0
  53. {ha_mcp_dev-6.5.0.dev182 → ha_mcp_dev-6.5.0.dev184}/src/ha_mcp/tools/tools_labels.py +0 -0
  54. {ha_mcp_dev-6.5.0.dev182 → ha_mcp_dev-6.5.0.dev184}/src/ha_mcp/tools/tools_mcp_component.py +0 -0
  55. {ha_mcp_dev-6.5.0.dev182 → ha_mcp_dev-6.5.0.dev184}/src/ha_mcp/tools/tools_resources.py +0 -0
  56. {ha_mcp_dev-6.5.0.dev182 → ha_mcp_dev-6.5.0.dev184}/src/ha_mcp/tools/tools_search.py +0 -0
  57. {ha_mcp_dev-6.5.0.dev182 → ha_mcp_dev-6.5.0.dev184}/src/ha_mcp/tools/tools_services.py +0 -0
  58. {ha_mcp_dev-6.5.0.dev182 → ha_mcp_dev-6.5.0.dev184}/src/ha_mcp/tools/tools_system.py +0 -0
  59. {ha_mcp_dev-6.5.0.dev182 → ha_mcp_dev-6.5.0.dev184}/src/ha_mcp/tools/tools_todo.py +0 -0
  60. {ha_mcp_dev-6.5.0.dev182 → ha_mcp_dev-6.5.0.dev184}/src/ha_mcp/tools/tools_traces.py +0 -0
  61. {ha_mcp_dev-6.5.0.dev182 → ha_mcp_dev-6.5.0.dev184}/src/ha_mcp/tools/tools_updates.py +0 -0
  62. {ha_mcp_dev-6.5.0.dev182 → ha_mcp_dev-6.5.0.dev184}/src/ha_mcp/tools/tools_utility.py +0 -0
  63. {ha_mcp_dev-6.5.0.dev182 → ha_mcp_dev-6.5.0.dev184}/src/ha_mcp/tools/tools_voice_assistant.py +0 -0
  64. {ha_mcp_dev-6.5.0.dev182 → ha_mcp_dev-6.5.0.dev184}/src/ha_mcp/tools/tools_zones.py +0 -0
  65. {ha_mcp_dev-6.5.0.dev182 → ha_mcp_dev-6.5.0.dev184}/src/ha_mcp/tools/util_helpers.py +0 -0
  66. {ha_mcp_dev-6.5.0.dev182 → ha_mcp_dev-6.5.0.dev184}/src/ha_mcp/utils/__init__.py +0 -0
  67. {ha_mcp_dev-6.5.0.dev182 → ha_mcp_dev-6.5.0.dev184}/src/ha_mcp/utils/domain_handlers.py +0 -0
  68. {ha_mcp_dev-6.5.0.dev182 → ha_mcp_dev-6.5.0.dev184}/src/ha_mcp/utils/fuzzy_search.py +0 -0
  69. {ha_mcp_dev-6.5.0.dev182 → ha_mcp_dev-6.5.0.dev184}/src/ha_mcp/utils/operation_manager.py +0 -0
  70. {ha_mcp_dev-6.5.0.dev182 → ha_mcp_dev-6.5.0.dev184}/src/ha_mcp/utils/python_sandbox.py +0 -0
  71. {ha_mcp_dev-6.5.0.dev182 → ha_mcp_dev-6.5.0.dev184}/src/ha_mcp/utils/usage_logger.py +0 -0
  72. {ha_mcp_dev-6.5.0.dev182 → ha_mcp_dev-6.5.0.dev184}/src/ha_mcp_dev.egg-info/SOURCES.txt +0 -0
  73. {ha_mcp_dev-6.5.0.dev182 → ha_mcp_dev-6.5.0.dev184}/src/ha_mcp_dev.egg-info/dependency_links.txt +0 -0
  74. {ha_mcp_dev-6.5.0.dev182 → ha_mcp_dev-6.5.0.dev184}/src/ha_mcp_dev.egg-info/entry_points.txt +0 -0
  75. {ha_mcp_dev-6.5.0.dev182 → ha_mcp_dev-6.5.0.dev184}/src/ha_mcp_dev.egg-info/requires.txt +0 -0
  76. {ha_mcp_dev-6.5.0.dev182 → ha_mcp_dev-6.5.0.dev184}/src/ha_mcp_dev.egg-info/top_level.txt +0 -0
  77. {ha_mcp_dev-6.5.0.dev182 → ha_mcp_dev-6.5.0.dev184}/tests/__init__.py +0 -0
  78. {ha_mcp_dev-6.5.0.dev182 → ha_mcp_dev-6.5.0.dev184}/tests/test_constants.py +0 -0
  79. {ha_mcp_dev-6.5.0.dev182 → ha_mcp_dev-6.5.0.dev184}/tests/test_env_manager.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ha-mcp-dev
3
- Version: 6.5.0.dev182
3
+ Version: 6.5.0.dev184
4
4
  Summary: Home Assistant MCP Server - Complete control of Home Assistant through MCP
5
5
  Author-email: Julien <github@qc-h.net>
6
6
  License: MIT
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "ha-mcp-dev"
7
- version = "6.5.0.dev182"
7
+ version = "6.5.0.dev184"
8
8
  description = "Home Assistant MCP Server - Complete control of Home Assistant through MCP"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.13,<3.14"
@@ -0,0 +1,257 @@
1
+ """
2
+ Reusable helper functions for MCP tools.
3
+
4
+ Centralized utilities that can be shared across multiple tool implementations.
5
+ """
6
+
7
+ import functools
8
+ import json
9
+ import logging
10
+ import time
11
+ from typing import Any, Literal, NoReturn, overload
12
+
13
+ from fastmcp.exceptions import ToolError
14
+
15
+ from ..client.rest_client import (
16
+ HomeAssistantAPIError,
17
+ HomeAssistantAuthError,
18
+ HomeAssistantConnectionError,
19
+ )
20
+ from ..client.websocket_client import HomeAssistantWebSocketClient
21
+ from ..errors import (
22
+ ErrorCode,
23
+ create_auth_error,
24
+ create_connection_error,
25
+ create_entity_not_found_error,
26
+ create_error_response,
27
+ create_timeout_error,
28
+ create_validation_error,
29
+ )
30
+ from ..utils.usage_logger import log_tool_call
31
+
32
+ logger = logging.getLogger(__name__)
33
+
34
+
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
+
63
+ async def get_connected_ws_client(
64
+ base_url: str, token: str
65
+ ) -> tuple[HomeAssistantWebSocketClient | None, dict[str, Any] | None]:
66
+ """
67
+ Create and connect a WebSocket client.
68
+
69
+ Args:
70
+ base_url: Home Assistant base URL
71
+ token: Authentication token
72
+
73
+ Returns:
74
+ Tuple of (ws_client, error_dict). If connection fails, ws_client is None.
75
+ """
76
+ ws_client = HomeAssistantWebSocketClient(base_url, token)
77
+ connected = await ws_client.connect()
78
+ if not connected:
79
+ return None, create_connection_error(
80
+ "Failed to connect to Home Assistant WebSocket",
81
+ details="WebSocket connection could not be established",
82
+ )
83
+ return ws_client, None
84
+
85
+
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
+
104
+ def exception_to_structured_error(
105
+ error: Exception,
106
+ context: dict[str, Any] | None = None,
107
+ *,
108
+ raise_error: bool = False,
109
+ ) -> dict[str, Any]:
110
+ """
111
+ Convert an exception to a structured error response.
112
+
113
+ This function maps common exception types to appropriate error codes
114
+ and creates informative error responses.
115
+
116
+ Args:
117
+ error: The exception to convert
118
+ 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.
125
+
126
+ Returns:
127
+ Structured error response dictionary (only if raise_error=False)
128
+
129
+ Raises:
130
+ ToolError: If raise_error=True, raises with JSON-serialized error
131
+ """
132
+ error_str = str(error).lower()
133
+ error_msg = str(error)
134
+
135
+ error_response: dict[str, Any]
136
+
137
+ # Handle specific exception types
138
+ if isinstance(error, HomeAssistantConnectionError):
139
+ if "timeout" in error_str:
140
+ error_response = create_connection_error(error_msg, timeout=True)
141
+ else:
142
+ error_response = create_connection_error(error_msg)
143
+
144
+ elif isinstance(error, HomeAssistantAuthError):
145
+ if "expired" in error_str:
146
+ error_response = create_auth_error(error_msg, expired=True)
147
+ else:
148
+ error_response = create_auth_error(error_msg)
149
+
150
+ elif isinstance(error, HomeAssistantAPIError):
151
+ # Check for specific error patterns
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
+ )
175
+
176
+ elif isinstance(error, TimeoutError):
177
+ operation = context.get("operation", "request") if context else "request"
178
+ timeout_seconds = context.get("timeout_seconds", 30) if context else 30
179
+ error_response = create_timeout_error(operation, timeout_seconds, details=error_msg)
180
+
181
+ elif isinstance(error, ValueError):
182
+ error_response = create_validation_error(error_msg)
183
+
184
+ # Check for common error patterns in error message
185
+ elif "not found" in error_str or "404" in error_str:
186
+ entity_id = context.get("entity_id") if context else None
187
+ if entity_id:
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,
209
+ error_msg,
210
+ details="An unexpected error occurred",
211
+ context=context,
212
+ )
213
+
214
+ if raise_error:
215
+ raise_tool_error(error_response)
216
+
217
+ return error_response
218
+
219
+
220
+ def log_tool_usage(func: Any) -> Any:
221
+ """
222
+ Decorator to automatically log MCP tool usage.
223
+
224
+ Tracks execution time, success/failure, and response size for all tool calls.
225
+ """
226
+
227
+ @functools.wraps(func)
228
+ async def wrapper(*args: Any, **kwargs: Any) -> Any:
229
+ start_time = time.time()
230
+ tool_name = func.__name__
231
+ success = True
232
+ error_message = None
233
+ response_size = None
234
+
235
+ try:
236
+ result = await func(*args, **kwargs)
237
+ if isinstance(result, str):
238
+ response_size = len(result.encode("utf-8"))
239
+ elif hasattr(result, "__len__"):
240
+ response_size = len(str(result).encode("utf-8"))
241
+ return result
242
+ except Exception as e:
243
+ success = False
244
+ error_message = str(e)
245
+ raise
246
+ finally:
247
+ execution_time_ms = (time.time() - start_time) * 1000
248
+ log_tool_call(
249
+ tool_name=tool_name,
250
+ parameters=kwargs,
251
+ execution_time_ms=execution_time_ms,
252
+ success=success,
253
+ error_message=error_message,
254
+ response_size_bytes=response_size,
255
+ )
256
+
257
+ return wrapper
@@ -8,6 +8,7 @@ Home Assistant automation configurations.
8
8
  import logging
9
9
  from typing import Annotated, Any, cast
10
10
 
11
+ from fastmcp.exceptions import ToolError
11
12
  from pydantic import Field
12
13
 
13
14
  from ..errors import (
@@ -15,7 +16,7 @@ from ..errors import (
15
16
  create_resource_not_found_error,
16
17
  create_validation_error,
17
18
  )
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
19
20
  from .util_helpers import parse_json_param
20
21
 
21
22
  logger = logging.getLogger(__name__)
@@ -249,12 +250,13 @@ def register_config_automation_tools(mcp: Any, client: Any, **kwargs: Any) -> No
249
250
  )
250
251
  error_response["action"] = "get"
251
252
  error_response["reason"] = "not_found"
252
- return error_response
253
+ raise_tool_error(error_response)
253
254
 
254
255
  logger.error(f"Error getting automation: {e}")
255
256
  error_response = exception_to_structured_error(
256
257
  e,
257
258
  context={"identifier": identifier, "action": "get"},
259
+ raise_error=False,
258
260
  )
259
261
  # Add automation-specific suggestions
260
262
  if "error" in error_response and isinstance(error_response["error"], dict):
@@ -263,7 +265,7 @@ def register_config_automation_tools(mcp: Any, client: Any, **kwargs: Any) -> No
263
265
  "Check Home Assistant connection",
264
266
  "Use ha_get_domain_docs('automation') for configuration help",
265
267
  ]
266
- return error_response
268
+ raise_tool_error(error_response)
267
269
 
268
270
  @mcp.tool(
269
271
  annotations={
@@ -411,19 +413,19 @@ def register_config_automation_tools(mcp: Any, client: Any, **kwargs: Any) -> No
411
413
  try:
412
414
  parsed_config = parse_json_param(config, "config")
413
415
  except ValueError as e:
414
- return create_validation_error(
416
+ raise_tool_error(create_validation_error(
415
417
  f"Invalid config parameter: {e}",
416
418
  parameter="config",
417
419
  invalid_json=True,
418
- )
420
+ ))
419
421
 
420
422
  # Ensure config is a dict
421
423
  if parsed_config is None or not isinstance(parsed_config, dict):
422
- return create_validation_error(
424
+ raise_tool_error(create_validation_error(
423
425
  "Config parameter must be a JSON object",
424
426
  parameter="config",
425
427
  details=f"Received type: {type(parsed_config).__name__}",
426
- )
428
+ ))
427
429
 
428
430
  config_dict = cast(dict[str, Any], parsed_config)
429
431
 
@@ -441,11 +443,11 @@ def register_config_automation_tools(mcp: Any, client: Any, **kwargs: Any) -> No
441
443
 
442
444
  missing_fields = [f for f in required_fields if f not in config_dict]
443
445
  if missing_fields:
444
- return create_config_error(
446
+ raise_tool_error(create_config_error(
445
447
  f"Missing required fields: {', '.join(missing_fields)}",
446
448
  identifier=identifier,
447
449
  missing_fields=missing_fields,
448
- )
450
+ ))
449
451
 
450
452
  result = await client.upsert_automation_config(config_dict, identifier)
451
453
  return {
@@ -454,11 +456,14 @@ def register_config_automation_tools(mcp: Any, client: Any, **kwargs: Any) -> No
454
456
  "config_provided": config_dict,
455
457
  }
456
458
 
459
+ except ToolError:
460
+ raise
457
461
  except Exception as e:
458
462
  logger.error(f"Error upserting automation: {e}")
459
463
  error_response = exception_to_structured_error(
460
464
  e,
461
465
  context={"identifier": identifier},
466
+ raise_error=False,
462
467
  )
463
468
  # Add automation-specific suggestions
464
469
  if "error" in error_response and isinstance(error_response["error"], dict):
@@ -469,7 +474,7 @@ def register_config_automation_tools(mcp: Any, client: Any, **kwargs: Any) -> No
469
474
  "Use ha_search_entities(domain_filter='automation') to find automations",
470
475
  "Use ha_get_domain_docs('automation') for comprehensive configuration help",
471
476
  ]
472
- return error_response
477
+ raise_tool_error(error_response)
473
478
 
474
479
  @mcp.tool(
475
480
  annotations={
@@ -513,6 +518,7 @@ def register_config_automation_tools(mcp: Any, client: Any, **kwargs: Any) -> No
513
518
  error_response = exception_to_structured_error(
514
519
  e,
515
520
  context={"identifier": identifier},
521
+ raise_error=False,
516
522
  )
517
523
  error_response["action"] = "delete"
518
524
  # Add automation-specific suggestions
@@ -522,4 +528,4 @@ def register_config_automation_tools(mcp: Any, client: Any, **kwargs: Any) -> No
522
528
  "Use entity_id format: automation.morning_routine or unique_id",
523
529
  "Check Home Assistant connection",
524
530
  ]
525
- return error_response
531
+ raise_tool_error(error_response)
@@ -16,7 +16,7 @@ from typing import Annotated, Any
16
16
  from pydantic import Field
17
17
 
18
18
  from ..errors import ErrorCode, create_error_response
19
- from .helpers import log_tool_usage
19
+ from .helpers import log_tool_usage, raise_tool_error
20
20
  from .util_helpers import coerce_bool_param, parse_string_list_param
21
21
 
22
22
  # Known voice assistant identifiers
@@ -785,10 +785,10 @@ def register_registry_tools(mcp: Any, client: Any, **kwargs: Any) -> None:
785
785
  try:
786
786
  parsed_labels = parse_string_list_param(labels, "labels")
787
787
  except ValueError as e:
788
- return create_error_response(
788
+ raise_tool_error(create_error_response(
789
789
  ErrorCode.VALIDATION_INVALID_PARAMETER,
790
790
  f"Invalid labels parameter: {e}",
791
- )
791
+ ))
792
792
 
793
793
  # Delegate to internal implementation
794
794
  return await _update_device_internal(
@@ -7,12 +7,13 @@ This module provides service execution and WebSocket-enabled operation monitorin
7
7
  from typing import Any, cast
8
8
 
9
9
  import httpx
10
+ from fastmcp.exceptions import ToolError
10
11
 
11
12
  from ..errors import (
12
13
  create_validation_error,
13
14
  )
14
15
  from ..client.rest_client import HomeAssistantConnectionError
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
16
17
  from .util_helpers import coerce_bool_param, parse_json_param
17
18
 
18
19
 
@@ -79,11 +80,11 @@ def register_service_tools(mcp, client, **kwargs):
79
80
  try:
80
81
  parsed_data = parse_json_param(data, "data")
81
82
  except ValueError as e:
82
- return create_validation_error(
83
+ raise_tool_error(create_validation_error(
83
84
  f"Invalid data parameter: {e}",
84
85
  parameter="data",
85
86
  invalid_json=True,
86
- )
87
+ ))
87
88
 
88
89
  # Ensure service_data is a dict
89
90
  service_data: dict[str, Any] = {}
@@ -91,11 +92,11 @@ def register_service_tools(mcp, client, **kwargs):
91
92
  if isinstance(parsed_data, dict):
92
93
  service_data = parsed_data
93
94
  else:
94
- return create_validation_error(
95
+ raise_tool_error(create_validation_error(
95
96
  "Data parameter must be a JSON object",
96
97
  parameter="data",
97
98
  details=f"Received type: {type(parsed_data).__name__}",
98
- )
99
+ ))
99
100
 
100
101
  if entity_id:
101
102
  service_data["entity_id"] = entity_id
@@ -154,10 +155,13 @@ def register_service_tools(mcp, client, **kwargs):
154
155
  "service": service,
155
156
  "entity_id": entity_id,
156
157
  },
158
+ raise_error=False,
157
159
  )
158
160
  if "error" in error_response and isinstance(error_response["error"], dict):
159
161
  error_response["error"]["suggestions"] = _build_service_suggestions(domain, service, entity_id)
160
- return error_response
162
+ raise_tool_error(error_response)
163
+ except ToolError:
164
+ raise
161
165
  except Exception as error:
162
166
  # Use structured error response
163
167
  error_response = exception_to_structured_error(
@@ -167,6 +171,7 @@ def register_service_tools(mcp, client, **kwargs):
167
171
  "service": service,
168
172
  "entity_id": entity_id,
169
173
  },
174
+ raise_error=False,
170
175
  )
171
176
  suggestions = _build_service_suggestions(domain, service, entity_id)
172
177
  if entity_id:
@@ -177,7 +182,7 @@ def register_service_tools(mcp, client, **kwargs):
177
182
  # Merge suggestions into error response
178
183
  if "error" in error_response and isinstance(error_response["error"], dict):
179
184
  error_response["error"]["suggestions"] = suggestions
180
- return error_response
185
+ raise_tool_error(error_response)
181
186
 
182
187
  @mcp.tool(annotations={"readOnlyHint": True, "title": "Get Operation Status"})
183
188
  @log_tool_usage
@@ -204,19 +209,19 @@ def register_service_tools(mcp, client, **kwargs):
204
209
  try:
205
210
  parsed_operations = parse_json_param(operations, "operations")
206
211
  except ValueError as e:
207
- return create_validation_error(
212
+ raise_tool_error(create_validation_error(
208
213
  f"Invalid operations parameter: {e}",
209
214
  parameter="operations",
210
215
  invalid_json=True,
211
- )
216
+ ))
212
217
 
213
218
  # Ensure operations is a list of dicts
214
219
  if parsed_operations is None or not isinstance(parsed_operations, list):
215
- return create_validation_error(
220
+ raise_tool_error(create_validation_error(
216
221
  "Operations parameter must be a list",
217
222
  parameter="operations",
218
223
  details=f"Received type: {type(parsed_operations).__name__}",
219
- )
224
+ ))
220
225
 
221
226
  operations_list = cast(list[dict[str, Any]], parsed_operations)
222
227
  result = await device_tools.bulk_device_control(
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ha-mcp-dev
3
- Version: 6.5.0.dev182
3
+ Version: 6.5.0.dev184
4
4
  Summary: Home Assistant MCP Server - Complete control of Home Assistant through MCP
5
5
  Author-email: Julien <github@qc-h.net>
6
6
  License: MIT
@@ -1,184 +0,0 @@
1
- """
2
- Reusable helper functions for MCP tools.
3
-
4
- Centralized utilities that can be shared across multiple tool implementations.
5
- """
6
-
7
- import functools
8
- import logging
9
- import time
10
- from typing import Any
11
-
12
- from ..client.rest_client import (
13
- HomeAssistantAPIError,
14
- HomeAssistantAuthError,
15
- HomeAssistantConnectionError,
16
- )
17
- from ..client.websocket_client import HomeAssistantWebSocketClient
18
- from ..errors import (
19
- ErrorCode,
20
- create_auth_error,
21
- create_connection_error,
22
- create_entity_not_found_error,
23
- create_error_response,
24
- create_timeout_error,
25
- create_validation_error,
26
- )
27
- from ..utils.usage_logger import log_tool_call
28
-
29
- logger = logging.getLogger(__name__)
30
-
31
-
32
- async def get_connected_ws_client(
33
- base_url: str, token: str
34
- ) -> tuple[HomeAssistantWebSocketClient | None, dict[str, Any] | None]:
35
- """
36
- Create and connect a WebSocket client.
37
-
38
- Args:
39
- base_url: Home Assistant base URL
40
- token: Authentication token
41
-
42
- Returns:
43
- Tuple of (ws_client, error_dict). If connection fails, ws_client is None.
44
- """
45
- ws_client = HomeAssistantWebSocketClient(base_url, token)
46
- connected = await ws_client.connect()
47
- if not connected:
48
- return None, create_connection_error(
49
- "Failed to connect to Home Assistant WebSocket",
50
- details="WebSocket connection could not be established",
51
- )
52
- return ws_client, None
53
-
54
-
55
- def exception_to_structured_error(
56
- error: Exception,
57
- context: dict[str, Any] | None = None,
58
- ) -> dict[str, Any]:
59
- """
60
- Convert an exception to a structured error response.
61
-
62
- This function maps common exception types to appropriate error codes
63
- and creates informative error responses.
64
-
65
- Args:
66
- error: The exception to convert
67
- context: Additional context to include in the response
68
-
69
- Returns:
70
- Structured error response dictionary
71
- """
72
- error_str = str(error).lower()
73
- error_msg = str(error)
74
-
75
- # Handle specific exception types
76
- if isinstance(error, HomeAssistantConnectionError):
77
- if "timeout" in error_str:
78
- return create_connection_error(error_msg, timeout=True)
79
- return create_connection_error(error_msg)
80
-
81
- if isinstance(error, HomeAssistantAuthError):
82
- if "expired" in error_str:
83
- return create_auth_error(error_msg, expired=True)
84
- return create_auth_error(error_msg)
85
-
86
- if isinstance(error, HomeAssistantAPIError):
87
- # 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
- )
109
-
110
- if isinstance(error, TimeoutError):
111
- operation = context.get("operation", "request") if context else "request"
112
- timeout_seconds = context.get("timeout_seconds", 30) if context else 30
113
- return create_timeout_error(operation, timeout_seconds, details=error_msg)
114
-
115
- if isinstance(error, ValueError):
116
- return create_validation_error(error_msg)
117
-
118
- # Check for common error patterns in error message
119
- if "not found" in error_str or "404" in error_str:
120
- entity_id = context.get("entity_id") if context else None
121
- 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,
125
- error_msg,
126
- context=context,
127
- )
128
-
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)
137
-
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
- )
145
-
146
-
147
- def log_tool_usage(func: Any) -> Any:
148
- """
149
- Decorator to automatically log MCP tool usage.
150
-
151
- Tracks execution time, success/failure, and response size for all tool calls.
152
- """
153
-
154
- @functools.wraps(func)
155
- async def wrapper(*args: Any, **kwargs: Any) -> Any:
156
- start_time = time.time()
157
- tool_name = func.__name__
158
- success = True
159
- error_message = None
160
- response_size = None
161
-
162
- try:
163
- result = await func(*args, **kwargs)
164
- if isinstance(result, str):
165
- response_size = len(result.encode("utf-8"))
166
- elif hasattr(result, "__len__"):
167
- response_size = len(str(result).encode("utf-8"))
168
- return result
169
- except Exception as e:
170
- success = False
171
- error_message = str(e)
172
- raise
173
- finally:
174
- execution_time_ms = (time.time() - start_time) * 1000
175
- log_tool_call(
176
- tool_name=tool_name,
177
- parameters=kwargs,
178
- execution_time_ms=execution_time_ms,
179
- success=success,
180
- error_message=error_message,
181
- response_size_bytes=response_size,
182
- )
183
-
184
- return wrapper