ha-mcp-dev 6.3.1.dev137__py3-none-any.whl

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 (71) hide show
  1. ha_mcp/__init__.py +51 -0
  2. ha_mcp/__main__.py +753 -0
  3. ha_mcp/_pypi_marker +0 -0
  4. ha_mcp/auth/__init__.py +10 -0
  5. ha_mcp/auth/consent_form.py +501 -0
  6. ha_mcp/auth/provider.py +786 -0
  7. ha_mcp/client/__init__.py +10 -0
  8. ha_mcp/client/rest_client.py +924 -0
  9. ha_mcp/client/websocket_client.py +656 -0
  10. ha_mcp/client/websocket_listener.py +340 -0
  11. ha_mcp/config.py +175 -0
  12. ha_mcp/errors.py +399 -0
  13. ha_mcp/py.typed +0 -0
  14. ha_mcp/resources/card_types.json +48 -0
  15. ha_mcp/resources/dashboard_guide.md +573 -0
  16. ha_mcp/server.py +189 -0
  17. ha_mcp/smoke_test.py +108 -0
  18. ha_mcp/tools/__init__.py +11 -0
  19. ha_mcp/tools/backup.py +457 -0
  20. ha_mcp/tools/device_control.py +687 -0
  21. ha_mcp/tools/enhanced.py +191 -0
  22. ha_mcp/tools/helpers.py +184 -0
  23. ha_mcp/tools/registry.py +199 -0
  24. ha_mcp/tools/smart_search.py +797 -0
  25. ha_mcp/tools/tools_addons.py +343 -0
  26. ha_mcp/tools/tools_areas.py +478 -0
  27. ha_mcp/tools/tools_blueprints.py +279 -0
  28. ha_mcp/tools/tools_bug_report.py +570 -0
  29. ha_mcp/tools/tools_calendar.py +391 -0
  30. ha_mcp/tools/tools_camera.py +149 -0
  31. ha_mcp/tools/tools_config_automations.py +508 -0
  32. ha_mcp/tools/tools_config_dashboards.py +1502 -0
  33. ha_mcp/tools/tools_config_entry_flow.py +223 -0
  34. ha_mcp/tools/tools_config_helpers.py +925 -0
  35. ha_mcp/tools/tools_config_info.py +258 -0
  36. ha_mcp/tools/tools_config_scripts.py +267 -0
  37. ha_mcp/tools/tools_entities.py +76 -0
  38. ha_mcp/tools/tools_filesystem.py +589 -0
  39. ha_mcp/tools/tools_groups.py +306 -0
  40. ha_mcp/tools/tools_hacs.py +787 -0
  41. ha_mcp/tools/tools_history.py +714 -0
  42. ha_mcp/tools/tools_integrations.py +297 -0
  43. ha_mcp/tools/tools_labels.py +713 -0
  44. ha_mcp/tools/tools_mcp_component.py +305 -0
  45. ha_mcp/tools/tools_registry.py +1115 -0
  46. ha_mcp/tools/tools_resources.py +622 -0
  47. ha_mcp/tools/tools_search.py +573 -0
  48. ha_mcp/tools/tools_service.py +211 -0
  49. ha_mcp/tools/tools_services.py +352 -0
  50. ha_mcp/tools/tools_system.py +363 -0
  51. ha_mcp/tools/tools_todo.py +530 -0
  52. ha_mcp/tools/tools_traces.py +496 -0
  53. ha_mcp/tools/tools_updates.py +476 -0
  54. ha_mcp/tools/tools_utility.py +519 -0
  55. ha_mcp/tools/tools_voice_assistant.py +345 -0
  56. ha_mcp/tools/tools_zones.py +387 -0
  57. ha_mcp/tools/util_helpers.py +199 -0
  58. ha_mcp/utils/__init__.py +30 -0
  59. ha_mcp/utils/domain_handlers.py +380 -0
  60. ha_mcp/utils/fuzzy_search.py +339 -0
  61. ha_mcp/utils/operation_manager.py +442 -0
  62. ha_mcp/utils/usage_logger.py +290 -0
  63. ha_mcp_dev-6.3.1.dev137.dist-info/METADATA +229 -0
  64. ha_mcp_dev-6.3.1.dev137.dist-info/RECORD +71 -0
  65. ha_mcp_dev-6.3.1.dev137.dist-info/WHEEL +5 -0
  66. ha_mcp_dev-6.3.1.dev137.dist-info/entry_points.txt +7 -0
  67. ha_mcp_dev-6.3.1.dev137.dist-info/licenses/LICENSE +21 -0
  68. ha_mcp_dev-6.3.1.dev137.dist-info/top_level.txt +2 -0
  69. tests/__init__.py +1 -0
  70. tests/test_constants.py +15 -0
  71. tests/test_env_manager.py +336 -0
@@ -0,0 +1,297 @@
1
+ """
2
+ Integration management tools for Home Assistant MCP server.
3
+
4
+ This module provides tools to list, enable, disable, and delete Home Assistant
5
+ integrations (config entries) via the REST and WebSocket APIs.
6
+ """
7
+
8
+ import logging
9
+ from typing import Annotated, Any
10
+
11
+ from pydantic import Field
12
+
13
+ from .helpers import exception_to_structured_error, log_tool_usage
14
+ from .util_helpers import coerce_bool_param
15
+
16
+ logger = logging.getLogger(__name__)
17
+
18
+
19
+ def register_integration_tools(mcp: Any, client: Any, **kwargs: Any) -> None:
20
+ """Register integration management tools with the MCP server."""
21
+
22
+ @mcp.tool(annotations={"idempotentHint": True, "readOnlyHint": True, "tags": ["integration"], "title": "Get Integration"})
23
+ @log_tool_usage
24
+ async def ha_get_integration(
25
+ entry_id: Annotated[
26
+ str | None,
27
+ Field(
28
+ description="Config entry ID to get details for. "
29
+ "If omitted, lists all integrations.",
30
+ default=None,
31
+ ),
32
+ ] = None,
33
+ query: Annotated[
34
+ str | None,
35
+ Field(
36
+ description="When listing, fuzzy search by domain or title.",
37
+ default=None,
38
+ ),
39
+ ] = None,
40
+ ) -> dict[str, Any]:
41
+ """
42
+ Get integration (config entry) information - list all or get a specific one.
43
+
44
+ Without an entry_id: Lists all configured integrations with optional fuzzy search.
45
+ With an entry_id: Returns detailed information about a specific config entry.
46
+
47
+ EXAMPLES:
48
+ - List all integrations: ha_get_integration()
49
+ - Search integrations: ha_get_integration(query="zigbee")
50
+ - Get specific entry: ha_get_integration(entry_id="abc123")
51
+
52
+ STATES: 'loaded' (running), 'setup_error', 'setup_retry', 'not_loaded',
53
+ 'failed_unload', 'migration_error'.
54
+
55
+ RETURNS (when listing):
56
+ - entries: List of integrations with domain, title, state, capabilities
57
+ - state_summary: Count of entries in each state
58
+
59
+ RETURNS (when getting specific entry):
60
+ - entry: Full config entry details
61
+ """
62
+ try:
63
+ # If entry_id provided, get specific config entry
64
+ if entry_id is not None:
65
+ try:
66
+ result = await client.get_config_entry(entry_id)
67
+ return {"success": True, "entry_id": entry_id, "entry": result}
68
+ except Exception as e:
69
+ error_msg = str(e)
70
+ if "404" in error_msg or "not found" in error_msg.lower():
71
+ return {
72
+ "success": False,
73
+ "error": f"Config entry not found: {entry_id}",
74
+ "entry_id": entry_id,
75
+ "suggestion": "Use ha_get_integration() without entry_id to see all config entries",
76
+ }
77
+ raise
78
+
79
+ # List mode - get all config entries
80
+ # Use REST API endpoint for config entries
81
+ response = await client._request(
82
+ "GET", "/config/config_entries/entry"
83
+ )
84
+
85
+ if not isinstance(response, list):
86
+ return {
87
+ "success": False,
88
+ "error": "Unexpected response format from Home Assistant",
89
+ "response_type": type(response).__name__,
90
+ }
91
+
92
+ entries = response
93
+
94
+ # Format entries for response
95
+ formatted_entries = []
96
+ for entry in entries:
97
+ formatted_entry = {
98
+ "entry_id": entry.get("entry_id"),
99
+ "domain": entry.get("domain"),
100
+ "title": entry.get("title"),
101
+ "state": entry.get("state"),
102
+ "source": entry.get("source"),
103
+ "supports_options": entry.get("supports_options", False),
104
+ "supports_unload": entry.get("supports_unload", False),
105
+ "disabled_by": entry.get("disabled_by"),
106
+ }
107
+
108
+ # Include pref_disable_new_entities and pref_disable_polling if present
109
+ if "pref_disable_new_entities" in entry:
110
+ formatted_entry["pref_disable_new_entities"] = entry[
111
+ "pref_disable_new_entities"
112
+ ]
113
+ if "pref_disable_polling" in entry:
114
+ formatted_entry["pref_disable_polling"] = entry[
115
+ "pref_disable_polling"
116
+ ]
117
+
118
+ formatted_entries.append(formatted_entry)
119
+
120
+ # Apply fuzzy search filter if query provided
121
+ if query and query.strip():
122
+ from ..utils.fuzzy_search import calculate_ratio
123
+
124
+ # Perform fuzzy search with both exact and fuzzy matching
125
+ matches = []
126
+ query_lower = query.strip().lower()
127
+
128
+ for entry in formatted_entries:
129
+ domain_lower = entry['domain'].lower()
130
+ title_lower = entry['title'].lower()
131
+
132
+ # Check for exact substring matches first (highest priority)
133
+ if query_lower in domain_lower or query_lower in title_lower:
134
+ # Exact substring match gets score of 100
135
+ matches.append((100, entry))
136
+ else:
137
+ # Try fuzzy matching on domain and title separately
138
+ domain_score = calculate_ratio(query_lower, domain_lower)
139
+ title_score = calculate_ratio(query_lower, title_lower)
140
+ best_score = max(domain_score, title_score)
141
+
142
+ if best_score >= 70: # threshold for fuzzy matches
143
+ matches.append((best_score, entry))
144
+
145
+ # Sort by score descending
146
+ matches.sort(key=lambda x: x[0], reverse=True)
147
+ formatted_entries = [match[1] for match in matches]
148
+
149
+ # Group by state for summary
150
+ state_summary: dict[str, int] = {}
151
+ for entry in formatted_entries:
152
+ state = entry.get("state", "unknown")
153
+ state_summary[state] = state_summary.get(state, 0) + 1
154
+
155
+ return {
156
+ "success": True,
157
+ "total": len(formatted_entries),
158
+ "entries": formatted_entries,
159
+ "state_summary": state_summary,
160
+ "query": query if query else None,
161
+ }
162
+
163
+ except Exception as e:
164
+ logger.error(f"Failed to get integrations: {e}")
165
+ return {
166
+ "success": False,
167
+ "error": f"Failed to get integrations: {str(e)}",
168
+ "suggestions": [
169
+ "Verify Home Assistant connection is working",
170
+ "Check that the API is accessible",
171
+ "Ensure your token has sufficient permissions",
172
+ ],
173
+ }
174
+
175
+ @mcp.tool(
176
+ annotations={
177
+ "destructiveHint": True,
178
+ "tags": ["integration"],
179
+ "title": "Set Integration Enabled",
180
+ }
181
+ )
182
+ @log_tool_usage
183
+ async def ha_set_integration_enabled(
184
+ entry_id: Annotated[str, Field(description="Config entry ID")],
185
+ enabled: Annotated[
186
+ bool | str, Field(description="True to enable, False to disable")
187
+ ],
188
+ ) -> dict[str, Any]:
189
+ """Enable/disable integration (config entry).
190
+
191
+ Use ha_get_integration() to find entry IDs.
192
+ """
193
+ try:
194
+ enabled_bool = coerce_bool_param(enabled, "enabled")
195
+
196
+ message = {
197
+ "type": "config_entries/disable",
198
+ "entry_id": entry_id,
199
+ "disabled_by": None if enabled_bool else "user",
200
+ }
201
+
202
+ result = await client.send_websocket_message(message)
203
+
204
+ if not result.get("success"):
205
+ error_msg = result.get("error", {})
206
+ if isinstance(error_msg, dict):
207
+ error_msg = error_msg.get("message", str(error_msg))
208
+ return {
209
+ "success": False,
210
+ "error": f"Failed to {'enable' if enabled_bool else 'disable'} integration: {error_msg}",
211
+ "entry_id": entry_id,
212
+ }
213
+
214
+ # Get updated entry info
215
+ require_restart = result.get("result", {}).get("require_restart", False)
216
+
217
+ if require_restart:
218
+ note = "Home Assistant restart required for changes to take effect."
219
+ else:
220
+ note = "Integration has been loaded." if enabled_bool else "Integration has been unloaded."
221
+
222
+ return {
223
+ "success": True,
224
+ "message": f"Integration {'enabled' if enabled_bool else 'disabled'} successfully",
225
+ "entry_id": entry_id,
226
+ "require_restart": require_restart,
227
+ "note": note,
228
+ }
229
+
230
+ except Exception as e:
231
+ logger.error(f"Failed to set integration enabled: {e}")
232
+ return exception_to_structured_error(e, context={"entry_id": entry_id})
233
+
234
+ @mcp.tool(
235
+ annotations={
236
+ "destructiveHint": True,
237
+ "tags": ["integration"],
238
+ "title": "Delete Config Entry",
239
+ }
240
+ )
241
+ @log_tool_usage
242
+ async def ha_delete_config_entry(
243
+ entry_id: Annotated[str, Field(description="Config entry ID")],
244
+ confirm: Annotated[
245
+ bool | str, Field(description="Must be True to confirm deletion")
246
+ ] = False,
247
+ ) -> dict[str, Any]:
248
+ """Delete config entry permanently. Requires confirm=True.
249
+
250
+ Use ha_get_integration() to find entry IDs.
251
+ """
252
+ try:
253
+ confirm_bool = coerce_bool_param(confirm, "confirm", default=False)
254
+
255
+ if not confirm_bool:
256
+ return {
257
+ "success": False,
258
+ "error": "Deletion not confirmed. Set confirm=True to proceed.",
259
+ "entry_id": entry_id,
260
+ "warning": "This will permanently delete the config entry. This cannot be undone.",
261
+ }
262
+
263
+ message = {
264
+ "type": "config_entries/delete",
265
+ "entry_id": entry_id,
266
+ }
267
+
268
+ result = await client.send_websocket_message(message)
269
+
270
+ if not result.get("success"):
271
+ error_msg = result.get("error", {})
272
+ if isinstance(error_msg, dict):
273
+ error_msg = error_msg.get("message", str(error_msg))
274
+ return {
275
+ "success": False,
276
+ "error": f"Failed to delete config entry: {error_msg}",
277
+ "entry_id": entry_id,
278
+ }
279
+
280
+ # Get result info
281
+ require_restart = result.get("result", {}).get("require_restart", False)
282
+
283
+ return {
284
+ "success": True,
285
+ "message": "Config entry deleted successfully",
286
+ "entry_id": entry_id,
287
+ "require_restart": require_restart,
288
+ "note": (
289
+ "The integration has been permanently removed."
290
+ if not require_restart
291
+ else "Home Assistant restart required to complete removal."
292
+ ),
293
+ }
294
+
295
+ except Exception as e:
296
+ logger.error(f"Failed to delete config entry: {e}")
297
+ return exception_to_structured_error(e, context={"entry_id": entry_id})