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.
- ha_mcp/__init__.py +51 -0
- ha_mcp/__main__.py +753 -0
- ha_mcp/_pypi_marker +0 -0
- ha_mcp/auth/__init__.py +10 -0
- ha_mcp/auth/consent_form.py +501 -0
- ha_mcp/auth/provider.py +786 -0
- ha_mcp/client/__init__.py +10 -0
- ha_mcp/client/rest_client.py +924 -0
- ha_mcp/client/websocket_client.py +656 -0
- ha_mcp/client/websocket_listener.py +340 -0
- ha_mcp/config.py +175 -0
- ha_mcp/errors.py +399 -0
- ha_mcp/py.typed +0 -0
- ha_mcp/resources/card_types.json +48 -0
- ha_mcp/resources/dashboard_guide.md +573 -0
- ha_mcp/server.py +189 -0
- ha_mcp/smoke_test.py +108 -0
- ha_mcp/tools/__init__.py +11 -0
- ha_mcp/tools/backup.py +457 -0
- ha_mcp/tools/device_control.py +687 -0
- ha_mcp/tools/enhanced.py +191 -0
- ha_mcp/tools/helpers.py +184 -0
- ha_mcp/tools/registry.py +199 -0
- ha_mcp/tools/smart_search.py +797 -0
- ha_mcp/tools/tools_addons.py +343 -0
- ha_mcp/tools/tools_areas.py +478 -0
- ha_mcp/tools/tools_blueprints.py +279 -0
- ha_mcp/tools/tools_bug_report.py +570 -0
- ha_mcp/tools/tools_calendar.py +391 -0
- ha_mcp/tools/tools_camera.py +149 -0
- ha_mcp/tools/tools_config_automations.py +508 -0
- ha_mcp/tools/tools_config_dashboards.py +1502 -0
- ha_mcp/tools/tools_config_entry_flow.py +223 -0
- ha_mcp/tools/tools_config_helpers.py +925 -0
- ha_mcp/tools/tools_config_info.py +258 -0
- ha_mcp/tools/tools_config_scripts.py +267 -0
- ha_mcp/tools/tools_entities.py +76 -0
- ha_mcp/tools/tools_filesystem.py +589 -0
- ha_mcp/tools/tools_groups.py +306 -0
- ha_mcp/tools/tools_hacs.py +787 -0
- ha_mcp/tools/tools_history.py +714 -0
- ha_mcp/tools/tools_integrations.py +297 -0
- ha_mcp/tools/tools_labels.py +713 -0
- ha_mcp/tools/tools_mcp_component.py +305 -0
- ha_mcp/tools/tools_registry.py +1115 -0
- ha_mcp/tools/tools_resources.py +622 -0
- ha_mcp/tools/tools_search.py +573 -0
- ha_mcp/tools/tools_service.py +211 -0
- ha_mcp/tools/tools_services.py +352 -0
- ha_mcp/tools/tools_system.py +363 -0
- ha_mcp/tools/tools_todo.py +530 -0
- ha_mcp/tools/tools_traces.py +496 -0
- ha_mcp/tools/tools_updates.py +476 -0
- ha_mcp/tools/tools_utility.py +519 -0
- ha_mcp/tools/tools_voice_assistant.py +345 -0
- ha_mcp/tools/tools_zones.py +387 -0
- ha_mcp/tools/util_helpers.py +199 -0
- ha_mcp/utils/__init__.py +30 -0
- ha_mcp/utils/domain_handlers.py +380 -0
- ha_mcp/utils/fuzzy_search.py +339 -0
- ha_mcp/utils/operation_manager.py +442 -0
- ha_mcp/utils/usage_logger.py +290 -0
- ha_mcp_dev-6.3.1.dev137.dist-info/METADATA +229 -0
- ha_mcp_dev-6.3.1.dev137.dist-info/RECORD +71 -0
- ha_mcp_dev-6.3.1.dev137.dist-info/WHEEL +5 -0
- ha_mcp_dev-6.3.1.dev137.dist-info/entry_points.txt +7 -0
- ha_mcp_dev-6.3.1.dev137.dist-info/licenses/LICENSE +21 -0
- ha_mcp_dev-6.3.1.dev137.dist-info/top_level.txt +2 -0
- tests/__init__.py +1 -0
- tests/test_constants.py +15 -0
- 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})
|