ha-mcp-dev 7.2.0.dev330__py3-none-any.whl → 7.2.0.dev332__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/tools/backup.py +24 -23
- ha_mcp/tools/device_control.py +22 -20
- ha_mcp/tools/tools_entities.py +26 -51
- ha_mcp/tools/tools_hacs.py +277 -339
- {ha_mcp_dev-7.2.0.dev330.dist-info → ha_mcp_dev-7.2.0.dev332.dist-info}/METADATA +4 -4
- {ha_mcp_dev-7.2.0.dev330.dist-info → ha_mcp_dev-7.2.0.dev332.dist-info}/RECORD +10 -10
- {ha_mcp_dev-7.2.0.dev330.dist-info → ha_mcp_dev-7.2.0.dev332.dist-info}/WHEEL +0 -0
- {ha_mcp_dev-7.2.0.dev330.dist-info → ha_mcp_dev-7.2.0.dev332.dist-info}/entry_points.txt +0 -0
- {ha_mcp_dev-7.2.0.dev330.dist-info → ha_mcp_dev-7.2.0.dev332.dist-info}/licenses/LICENSE +0 -0
- {ha_mcp_dev-7.2.0.dev330.dist-info → ha_mcp_dev-7.2.0.dev332.dist-info}/top_level.txt +0 -0
ha_mcp/tools/backup.py
CHANGED
|
@@ -51,7 +51,7 @@ def _get_backup_hint_text() -> str:
|
|
|
51
51
|
|
|
52
52
|
async def _get_backup_password(
|
|
53
53
|
ws_client: HomeAssistantWebSocketClient,
|
|
54
|
-
) ->
|
|
54
|
+
) -> str:
|
|
55
55
|
"""
|
|
56
56
|
Retrieve default backup password from Home Assistant configuration.
|
|
57
57
|
|
|
@@ -59,27 +59,30 @@ async def _get_backup_password(
|
|
|
59
59
|
ws_client: Connected WebSocket client
|
|
60
60
|
|
|
61
61
|
Returns:
|
|
62
|
-
|
|
62
|
+
The backup password string.
|
|
63
|
+
|
|
64
|
+
Raises:
|
|
65
|
+
ToolError: If backup config cannot be retrieved or no password is configured.
|
|
63
66
|
"""
|
|
64
67
|
backup_config = await ws_client.send_command("backup/config/info")
|
|
65
68
|
if not backup_config.get("success"):
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
"
|
|
69
|
-
"details": backup_config,
|
|
70
|
-
|
|
69
|
+
raise_tool_error(create_error_response(
|
|
70
|
+
ErrorCode.SERVICE_CALL_FAILED,
|
|
71
|
+
"Failed to retrieve backup configuration",
|
|
72
|
+
context={"details": backup_config},
|
|
73
|
+
))
|
|
71
74
|
|
|
72
75
|
config_data = backup_config.get("result", {}).get("config", {})
|
|
73
76
|
default_password = config_data.get("create_backup", {}).get("password")
|
|
74
77
|
|
|
75
78
|
if not default_password:
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
"
|
|
79
|
-
"
|
|
80
|
-
|
|
79
|
+
raise_tool_error(create_error_response(
|
|
80
|
+
ErrorCode.SERVICE_CALL_FAILED,
|
|
81
|
+
"No default backup password configured in Home Assistant",
|
|
82
|
+
suggestions=["Configure automatic backups in Home Assistant settings to set a default password"],
|
|
83
|
+
))
|
|
81
84
|
|
|
82
|
-
return
|
|
85
|
+
return cast(str, default_password)
|
|
83
86
|
|
|
84
87
|
|
|
85
88
|
async def create_backup(
|
|
@@ -107,13 +110,8 @@ async def create_backup(
|
|
|
107
110
|
))
|
|
108
111
|
ws_client = cast(HomeAssistantWebSocketClient, ws_client)
|
|
109
112
|
|
|
110
|
-
# Get backup password
|
|
111
|
-
password
|
|
112
|
-
if error:
|
|
113
|
-
raise_tool_error(create_error_response(
|
|
114
|
-
ErrorCode.SERVICE_CALL_FAILED,
|
|
115
|
-
error.get("error", "Failed to retrieve backup password"),
|
|
116
|
-
))
|
|
113
|
+
# Get backup password (raises ToolError on failure)
|
|
114
|
+
password = await _get_backup_password(ws_client)
|
|
117
115
|
|
|
118
116
|
# Generate backup name if not provided
|
|
119
117
|
if not name:
|
|
@@ -283,12 +281,15 @@ async def restore_backup(
|
|
|
283
281
|
safety_backup_name = f"PreRestore_Safety_{now.strftime('%Y-%m-%d_%H:%M:%S')}"
|
|
284
282
|
|
|
285
283
|
# Get backup password
|
|
286
|
-
|
|
287
|
-
|
|
284
|
+
try:
|
|
285
|
+
password = await _get_backup_password(ws_client)
|
|
286
|
+
except ToolError:
|
|
288
287
|
# Password error - log warning but continue (restore might still work)
|
|
289
288
|
logger.warning("No default password - proceeding without safety backup")
|
|
289
|
+
password = None
|
|
290
290
|
safety_backup_id = None
|
|
291
|
-
|
|
291
|
+
|
|
292
|
+
if password is not None:
|
|
292
293
|
safety_backup = await ws_client.send_command(
|
|
293
294
|
"backup/generate",
|
|
294
295
|
name=safety_backup_name,
|
ha_mcp/tools/device_control.py
CHANGED
|
@@ -421,38 +421,40 @@ class DeviceControlTools:
|
|
|
421
421
|
}
|
|
422
422
|
|
|
423
423
|
elif operation.status.value == "failed":
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
"
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
424
|
+
raise_tool_error(create_error_response(
|
|
425
|
+
ErrorCode.SERVICE_CALL_FAILED,
|
|
426
|
+
operation.error_message or "Device operation failed",
|
|
427
|
+
context={
|
|
428
|
+
"operation_id": operation_id,
|
|
429
|
+
"entity_id": operation.entity_id,
|
|
430
|
+
"action": operation.action,
|
|
431
|
+
"duration_ms": operation.duration_ms,
|
|
432
|
+
},
|
|
433
|
+
suggestions=[
|
|
433
434
|
"Check if device is available and responding",
|
|
434
435
|
"Verify device supports the requested action",
|
|
435
436
|
"Check Home Assistant logs for error details",
|
|
436
437
|
"Try a simpler action like toggle",
|
|
437
438
|
],
|
|
438
|
-
|
|
439
|
+
))
|
|
439
440
|
|
|
440
441
|
elif operation.status.value == "timeout":
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
"
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
442
|
+
raise_tool_error(create_error_response(
|
|
443
|
+
ErrorCode.TIMEOUT_OPERATION,
|
|
444
|
+
f"Operation timed out after {operation.timeout_ms}ms",
|
|
445
|
+
context={
|
|
446
|
+
"operation_id": operation_id,
|
|
447
|
+
"entity_id": operation.entity_id,
|
|
448
|
+
"action": operation.action,
|
|
449
|
+
"elapsed_ms": operation.elapsed_ms,
|
|
450
|
+
},
|
|
451
|
+
suggestions=[
|
|
450
452
|
"Device may be slow to respond or offline",
|
|
451
453
|
"Check device connectivity",
|
|
452
454
|
"Try increasing timeout for slow devices",
|
|
453
455
|
"Verify device is powered on",
|
|
454
456
|
],
|
|
455
|
-
|
|
457
|
+
))
|
|
456
458
|
|
|
457
459
|
else: # pending
|
|
458
460
|
return {
|
ha_mcp/tools/tools_entities.py
CHANGED
|
@@ -240,21 +240,20 @@ def register_entity_tools(mcp: Any, client: Any, **kwargs: Any) -> None:
|
|
|
240
240
|
else str(error)
|
|
241
241
|
)
|
|
242
242
|
failed = dict.fromkeys(assistants, should_expose)
|
|
243
|
-
|
|
244
|
-
"success": False,
|
|
245
|
-
"error": {
|
|
246
|
-
"code": ErrorCode.SERVICE_CALL_FAILED.value,
|
|
247
|
-
"message": f"Exposure failed: {error_msg}",
|
|
248
|
-
"suggestion": "Check Home Assistant connection and entity availability",
|
|
249
|
-
},
|
|
243
|
+
context: dict[str, Any] = {
|
|
250
244
|
"entity_id": entity_id,
|
|
251
245
|
"exposure_succeeded": succeeded,
|
|
252
246
|
"exposure_failed": failed,
|
|
253
247
|
}
|
|
254
248
|
if has_registry_updates:
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
249
|
+
context["partial"] = True
|
|
250
|
+
context["entity_entry"] = _format_entity_entry(entity_entry)
|
|
251
|
+
raise_tool_error(create_error_response(
|
|
252
|
+
ErrorCode.SERVICE_CALL_FAILED,
|
|
253
|
+
f"Exposure failed: {error_msg}",
|
|
254
|
+
context=context,
|
|
255
|
+
suggestions=["Check Home Assistant connection and entity availability"],
|
|
256
|
+
))
|
|
258
257
|
|
|
259
258
|
# Track successful exposures
|
|
260
259
|
for a in assistants:
|
|
@@ -272,21 +271,15 @@ def register_entity_tools(mcp: Any, client: Any, **kwargs: Any) -> None:
|
|
|
272
271
|
if get_result.get("success"):
|
|
273
272
|
entity_entry = get_result.get("result", {})
|
|
274
273
|
else:
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
"
|
|
278
|
-
"
|
|
279
|
-
|
|
280
|
-
"message": f"Entity '{entity_id}' not found in registry after applying exposure changes",
|
|
281
|
-
"suggestion": "Use ha_search_entities() to verify the entity exists",
|
|
282
|
-
},
|
|
283
|
-
"entity_id": entity_id,
|
|
284
|
-
"suggestions": [
|
|
274
|
+
raise_tool_error(create_error_response(
|
|
275
|
+
ErrorCode.ENTITY_NOT_FOUND,
|
|
276
|
+
f"Entity '{entity_id}' not found in registry after applying exposure changes",
|
|
277
|
+
context={"entity_id": entity_id, "exposure_succeeded": exposure_result},
|
|
278
|
+
suggestions=[
|
|
285
279
|
"Verify the entity_id exists using ha_search_entities()",
|
|
286
280
|
"The entity's exposure settings were likely changed, but its current state could not be confirmed.",
|
|
287
281
|
],
|
|
288
|
-
|
|
289
|
-
}
|
|
282
|
+
))
|
|
290
283
|
|
|
291
284
|
response_data: dict[str, Any] = {
|
|
292
285
|
"success": True,
|
|
@@ -831,15 +824,10 @@ def register_entity_tools(mcp: Any, client: Any, **kwargs: Any) -> None:
|
|
|
831
824
|
if isinstance(error, dict)
|
|
832
825
|
else str(error)
|
|
833
826
|
)
|
|
834
|
-
|
|
835
|
-
"success": False,
|
|
836
|
-
"entity_id": eid,
|
|
837
|
-
"error": error_msg,
|
|
838
|
-
}
|
|
827
|
+
raise ValueError(error_msg)
|
|
839
828
|
|
|
840
829
|
entry = result.get("result", {})
|
|
841
830
|
return {
|
|
842
|
-
"success": True,
|
|
843
831
|
"entity_id": entry.get("entity_id"),
|
|
844
832
|
"name": entry.get("name"),
|
|
845
833
|
"original_name": entry.get("original_name"),
|
|
@@ -861,21 +849,13 @@ def register_entity_tools(mcp: Any, client: Any, **kwargs: Any) -> None:
|
|
|
861
849
|
if not is_bulk:
|
|
862
850
|
eid = entity_ids[0]
|
|
863
851
|
logger.info(f"Getting entity registry entry for {eid}")
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
return {
|
|
868
|
-
"success": True,
|
|
869
|
-
"entity_id": eid,
|
|
870
|
-
"entity_entry": {
|
|
871
|
-
k: v for k, v in result.items() if k not in ("success",)
|
|
872
|
-
},
|
|
873
|
-
}
|
|
874
|
-
else:
|
|
852
|
+
try:
|
|
853
|
+
result = await _fetch_entity(eid)
|
|
854
|
+
except ValueError as e:
|
|
875
855
|
raise_tool_error(
|
|
876
856
|
create_error_response(
|
|
877
857
|
ErrorCode.SERVICE_CALL_FAILED,
|
|
878
|
-
f"Entity not found: {
|
|
858
|
+
f"Entity not found: {e}",
|
|
879
859
|
context={"entity_id": eid},
|
|
880
860
|
suggestions=[
|
|
881
861
|
"Use ha_search_entities() to find valid entity IDs",
|
|
@@ -883,6 +863,11 @@ def register_entity_tools(mcp: Any, client: Any, **kwargs: Any) -> None:
|
|
|
883
863
|
],
|
|
884
864
|
)
|
|
885
865
|
)
|
|
866
|
+
return {
|
|
867
|
+
"success": True,
|
|
868
|
+
"entity_id": eid,
|
|
869
|
+
"entity_entry": result,
|
|
870
|
+
}
|
|
886
871
|
|
|
887
872
|
# Bulk case - fetch all entities
|
|
888
873
|
logger.info(
|
|
@@ -904,18 +889,8 @@ def register_entity_tools(mcp: Any, client: Any, **kwargs: Any) -> None:
|
|
|
904
889
|
"error": str(fetch_result),
|
|
905
890
|
}
|
|
906
891
|
)
|
|
907
|
-
continue
|
|
908
|
-
if fetch_result.get("success"):
|
|
909
|
-
entity_entries.append(
|
|
910
|
-
{k: v for k, v in fetch_result.items() if k not in ("success",)}
|
|
911
|
-
)
|
|
912
892
|
else:
|
|
913
|
-
|
|
914
|
-
{
|
|
915
|
-
"entity_id": eid,
|
|
916
|
-
"error": fetch_result.get("error", "Unknown error"),
|
|
917
|
-
}
|
|
918
|
-
)
|
|
893
|
+
entity_entries.append(fetch_result)
|
|
919
894
|
|
|
920
895
|
response: dict[str, Any] = {
|
|
921
896
|
"success": True,
|
ha_mcp/tools/tools_hacs.py
CHANGED
|
@@ -13,7 +13,7 @@ from pydantic import Field
|
|
|
13
13
|
|
|
14
14
|
from ..errors import ErrorCode, create_error_response
|
|
15
15
|
from .helpers import exception_to_structured_error, log_tool_usage, raise_tool_error
|
|
16
|
-
from .util_helpers import add_timezone_metadata, coerce_int_param
|
|
16
|
+
from .util_helpers import add_timezone_metadata, coerce_bool_param, coerce_int_param
|
|
17
17
|
|
|
18
18
|
logger = logging.getLogger(__name__)
|
|
19
19
|
|
|
@@ -33,220 +33,85 @@ CATEGORY_DISPLAY = {v: k for k, v in CATEGORY_MAP.items()}
|
|
|
33
33
|
CATEGORY_DISPLAY["plugin"] = "lovelace" # Display as lovelace for users
|
|
34
34
|
|
|
35
35
|
|
|
36
|
-
async def _is_hacs_available() -> bool:
|
|
37
|
-
"""Return True if HACS is installed and responding via WebSocket.
|
|
38
|
-
|
|
39
|
-
Raises if the WebSocket connection fails — callers handle API errors via
|
|
40
|
-
their own exception_to_structured_error blocks.
|
|
41
|
-
"""
|
|
42
|
-
from ..client.websocket_client import get_websocket_client
|
|
43
|
-
ws_client = await get_websocket_client()
|
|
44
|
-
response = await ws_client.send_command("hacs/info")
|
|
45
|
-
return bool(response.get("success"))
|
|
46
|
-
|
|
47
|
-
|
|
48
36
|
async def _assert_hacs_available() -> None:
|
|
49
|
-
"""Raise ToolError if HACS is not
|
|
37
|
+
"""Raise ToolError if HACS is not installed or not responding.
|
|
38
|
+
|
|
39
|
+
Distinguishes "unknown command" (HACS not installed) from other failures
|
|
40
|
+
(HACS installed but broken) so the error message is accurate.
|
|
50
41
|
|
|
51
42
|
Must be called within a try block that handles API errors via
|
|
52
43
|
exception_to_structured_error, so connection failures are classified
|
|
53
44
|
correctly rather than masked as COMPONENT_NOT_INSTALLED.
|
|
54
45
|
"""
|
|
55
|
-
|
|
56
|
-
|
|
46
|
+
from ..client.websocket_client import get_websocket_client
|
|
47
|
+
|
|
48
|
+
ws_client = await get_websocket_client()
|
|
49
|
+
response = await ws_client.send_command("hacs/info")
|
|
50
|
+
if response.get("success"):
|
|
51
|
+
return
|
|
52
|
+
|
|
53
|
+
error = response.get("error", {})
|
|
54
|
+
error_code = error.get("code") if isinstance(error, dict) else None
|
|
55
|
+
error_message = (
|
|
56
|
+
error.get("message", "") if isinstance(error, dict) else str(error)
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
# "unknown_command" means HACS is not installed at all
|
|
60
|
+
if error_code == "unknown_command" or "unknown command" in error_message.lower():
|
|
61
|
+
raise_tool_error(
|
|
62
|
+
create_error_response(
|
|
63
|
+
ErrorCode.COMPONENT_NOT_INSTALLED,
|
|
64
|
+
"HACS is not installed.",
|
|
65
|
+
suggestions=[
|
|
66
|
+
"Install HACS from https://hacs.xyz/",
|
|
67
|
+
"Restart Home Assistant after HACS installation",
|
|
68
|
+
],
|
|
69
|
+
)
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
# HACS is installed but not responding correctly
|
|
73
|
+
raise_tool_error(
|
|
74
|
+
create_error_response(
|
|
57
75
|
ErrorCode.COMPONENT_NOT_INSTALLED,
|
|
58
|
-
"HACS is not
|
|
76
|
+
f"HACS is installed but not responding: {error_message or 'unknown error'}",
|
|
59
77
|
suggestions=[
|
|
60
|
-
"
|
|
61
|
-
"Restart Home Assistant after HACS installation",
|
|
78
|
+
"Restart Home Assistant",
|
|
62
79
|
"Check Home Assistant logs for HACS errors",
|
|
80
|
+
"Verify HACS is up to date",
|
|
63
81
|
],
|
|
64
|
-
)
|
|
82
|
+
)
|
|
83
|
+
)
|
|
65
84
|
|
|
66
85
|
|
|
67
86
|
def register_hacs_tools(mcp: Any, client: Any, **kwargs: Any) -> None:
|
|
68
87
|
"""Register HACS integration tools with the MCP server."""
|
|
69
88
|
|
|
70
|
-
@mcp.tool(
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
- Stage (running, startup, etc.)
|
|
79
|
-
- Lovelace mode
|
|
80
|
-
- Disabled reason (if any)
|
|
81
|
-
|
|
82
|
-
This is useful for validating that HACS is installed and operational
|
|
83
|
-
before using other HACS tools.
|
|
84
|
-
|
|
85
|
-
**HACS Installation:**
|
|
86
|
-
If HACS is not installed, visit https://hacs.xyz/ for installation instructions.
|
|
87
|
-
|
|
88
|
-
Returns:
|
|
89
|
-
Dictionary with HACS status information or error if HACS is not available.
|
|
90
|
-
"""
|
|
91
|
-
try:
|
|
92
|
-
# Check if HACS is available
|
|
93
|
-
await _assert_hacs_available()
|
|
94
|
-
|
|
95
|
-
# Get HACS info via WebSocket
|
|
96
|
-
from ..client.websocket_client import get_websocket_client
|
|
97
|
-
ws_client = await get_websocket_client()
|
|
98
|
-
response = await ws_client.send_command("hacs/info")
|
|
99
|
-
|
|
100
|
-
if not response.get("success"):
|
|
101
|
-
exception_to_structured_error(
|
|
102
|
-
Exception(f"HACS info request failed: {response}"),
|
|
103
|
-
context={"command": "hacs/info"},
|
|
104
|
-
raise_error=True,
|
|
105
|
-
)
|
|
106
|
-
|
|
107
|
-
result = response.get("result", {})
|
|
108
|
-
|
|
109
|
-
return await add_timezone_metadata(client, {
|
|
110
|
-
"success": True,
|
|
111
|
-
"version": result.get("version"),
|
|
112
|
-
"categories": result.get("categories", []),
|
|
113
|
-
"stage": result.get("stage"),
|
|
114
|
-
"lovelace_mode": result.get("lovelace_mode"),
|
|
115
|
-
"disabled_reason": result.get("disabled_reason"),
|
|
116
|
-
"data": result,
|
|
117
|
-
})
|
|
118
|
-
|
|
119
|
-
except ToolError:
|
|
120
|
-
raise
|
|
121
|
-
except Exception as e:
|
|
122
|
-
exception_to_structured_error(
|
|
123
|
-
e,
|
|
124
|
-
context={"tool": "ha_hacs_info"},
|
|
125
|
-
suggestions=[
|
|
126
|
-
"Verify HACS is installed: https://hacs.xyz/",
|
|
127
|
-
"Check Home Assistant connection",
|
|
128
|
-
"Restart Home Assistant if HACS was recently installed",
|
|
129
|
-
],
|
|
130
|
-
)
|
|
131
|
-
|
|
132
|
-
@mcp.tool(tags={"HACS"}, annotations={"idempotentHint": True, "readOnlyHint": True, "title": "List HACS Installed"})
|
|
133
|
-
@log_tool_usage
|
|
134
|
-
async def ha_hacs_list_installed(
|
|
135
|
-
category: Annotated[
|
|
136
|
-
Literal["integration", "lovelace", "theme", "appdaemon", "python_script"] | None,
|
|
137
|
-
Field(
|
|
138
|
-
default=None,
|
|
139
|
-
description=(
|
|
140
|
-
"Filter by category: 'integration', 'lovelace', 'theme', "
|
|
141
|
-
"'appdaemon', or 'python_script'. Use None for all categories."
|
|
142
|
-
),
|
|
143
|
-
),
|
|
144
|
-
] = None,
|
|
145
|
-
) -> dict[str, Any]:
|
|
146
|
-
"""List installed HACS repositories with focused, small response.
|
|
147
|
-
|
|
148
|
-
**DASHBOARD TIP:** Use `category="lovelace"` to discover installed custom cards
|
|
149
|
-
for use with `ha_config_set_dashboard()`.
|
|
150
|
-
|
|
151
|
-
Returns a list of installed repositories with key information:
|
|
152
|
-
- name: Repository name
|
|
153
|
-
- full_name: Full GitHub repository name (owner/repo)
|
|
154
|
-
- category: Type of repository (integration, lovelace, theme, etc.)
|
|
155
|
-
- installed_version: Currently installed version
|
|
156
|
-
- available_version: Latest available version
|
|
157
|
-
- pending_update: Whether an update is available
|
|
158
|
-
- description: Repository description
|
|
159
|
-
|
|
160
|
-
**Categories:**
|
|
161
|
-
- `integration`: Custom integrations and components
|
|
162
|
-
- `lovelace`: Custom dashboard cards and panels
|
|
163
|
-
- `theme`: Custom themes for the UI
|
|
164
|
-
- `appdaemon`: AppDaemon apps
|
|
165
|
-
- `python_script`: Python scripts
|
|
166
|
-
|
|
167
|
-
Args:
|
|
168
|
-
category: Filter results by category (default: all categories)
|
|
169
|
-
|
|
170
|
-
Returns:
|
|
171
|
-
List of installed HACS repositories or error if HACS is not available.
|
|
172
|
-
"""
|
|
173
|
-
try:
|
|
174
|
-
# Check if HACS is available
|
|
175
|
-
await _assert_hacs_available()
|
|
176
|
-
|
|
177
|
-
# Get installed repositories via WebSocket
|
|
178
|
-
from ..client.websocket_client import get_websocket_client
|
|
179
|
-
ws_client = await get_websocket_client()
|
|
180
|
-
|
|
181
|
-
# Build command parameters - map user-friendly category to HACS internal name
|
|
182
|
-
kwargs_cmd: dict[str, Any] = {}
|
|
183
|
-
if category:
|
|
184
|
-
hacs_category = CATEGORY_MAP.get(category, category)
|
|
185
|
-
kwargs_cmd["categories"] = [hacs_category]
|
|
186
|
-
|
|
187
|
-
response = await ws_client.send_command("hacs/repositories/list", **kwargs_cmd)
|
|
188
|
-
|
|
189
|
-
if not response.get("success"):
|
|
190
|
-
exception_to_structured_error(
|
|
191
|
-
Exception(f"HACS repositories list request failed: {response}"),
|
|
192
|
-
context={"command": "hacs/repositories/list", "category": category},
|
|
193
|
-
raise_error=True,
|
|
194
|
-
)
|
|
195
|
-
|
|
196
|
-
repositories = response.get("result", [])
|
|
197
|
-
|
|
198
|
-
# Filter to only installed repositories and extract key info
|
|
199
|
-
installed = []
|
|
200
|
-
for repo in repositories:
|
|
201
|
-
if repo.get("installed", False):
|
|
202
|
-
# Map HACS internal category back to user-friendly name
|
|
203
|
-
repo_category = repo.get("category", "")
|
|
204
|
-
display_category = CATEGORY_DISPLAY.get(repo_category, repo_category)
|
|
205
|
-
installed.append({
|
|
206
|
-
"name": repo.get("name"),
|
|
207
|
-
"full_name": repo.get("full_name"),
|
|
208
|
-
"category": display_category,
|
|
209
|
-
"id": repo.get("id"), # Include numeric ID for repository_info
|
|
210
|
-
"installed_version": repo.get("installed_version"),
|
|
211
|
-
"available_version": repo.get("available_version"),
|
|
212
|
-
"pending_update": repo.get("pending_upgrade", False),
|
|
213
|
-
"description": repo.get("description"),
|
|
214
|
-
"authors": repo.get("authors", []),
|
|
215
|
-
"domain": repo.get("domain"), # For integrations
|
|
216
|
-
"stars": repo.get("stars", 0),
|
|
217
|
-
})
|
|
218
|
-
|
|
219
|
-
return await add_timezone_metadata(client, {
|
|
220
|
-
"success": True,
|
|
221
|
-
"category_filter": category,
|
|
222
|
-
"total_installed": len(installed),
|
|
223
|
-
"repositories": installed,
|
|
224
|
-
})
|
|
225
|
-
|
|
226
|
-
except ToolError:
|
|
227
|
-
raise
|
|
228
|
-
except Exception as e:
|
|
229
|
-
exception_to_structured_error(
|
|
230
|
-
e,
|
|
231
|
-
context={"tool": "ha_hacs_list_installed", "category": category},
|
|
232
|
-
suggestions=[
|
|
233
|
-
"Verify HACS is installed: https://hacs.xyz/",
|
|
234
|
-
"Check category name is valid: integration, lovelace, theme, appdaemon, python_script",
|
|
235
|
-
"Check Home Assistant connection",
|
|
236
|
-
],
|
|
237
|
-
)
|
|
238
|
-
|
|
239
|
-
@mcp.tool(tags={"HACS"}, annotations={"idempotentHint": True, "readOnlyHint": True, "title": "Search HACS Store"})
|
|
89
|
+
@mcp.tool(
|
|
90
|
+
tags={"HACS"},
|
|
91
|
+
annotations={
|
|
92
|
+
"idempotentHint": True,
|
|
93
|
+
"readOnlyHint": True,
|
|
94
|
+
"title": "Search HACS Store",
|
|
95
|
+
},
|
|
96
|
+
)
|
|
240
97
|
@log_tool_usage
|
|
241
98
|
async def ha_hacs_search(
|
|
242
|
-
query: str,
|
|
99
|
+
query: str = "",
|
|
243
100
|
category: Annotated[
|
|
244
|
-
Literal["integration", "lovelace", "theme", "appdaemon", "python_script"]
|
|
101
|
+
Literal["integration", "lovelace", "theme", "appdaemon", "python_script"]
|
|
102
|
+
| None,
|
|
245
103
|
Field(
|
|
246
104
|
default=None,
|
|
247
105
|
description="Filter by category (optional)",
|
|
248
106
|
),
|
|
249
107
|
] = None,
|
|
108
|
+
installed_only: Annotated[
|
|
109
|
+
bool | str,
|
|
110
|
+
Field(
|
|
111
|
+
default=False,
|
|
112
|
+
description="Only return installed repositories (default: False)",
|
|
113
|
+
),
|
|
114
|
+
] = False,
|
|
250
115
|
max_results: Annotated[
|
|
251
116
|
int | str,
|
|
252
117
|
Field(
|
|
@@ -262,56 +127,55 @@ def register_hacs_tools(mcp: Any, client: Any, **kwargs: Any) -> None:
|
|
|
262
127
|
),
|
|
263
128
|
] = 0,
|
|
264
129
|
) -> dict[str, Any]:
|
|
265
|
-
"""Search HACS store for repositories
|
|
130
|
+
"""Search HACS store for repositories, or list installed repositories.
|
|
266
131
|
|
|
267
|
-
|
|
268
|
-
|
|
132
|
+
**Search mode** (default): Searches by keyword across name, description, and authors.
|
|
133
|
+
**Browse mode** (no query, `installed_only=False`): Returns all HACS store repos
|
|
134
|
+
sorted alphabetically, paginated by `max_results` and `offset`.
|
|
135
|
+
**Installed mode** (`installed_only=True`): Lists installed repos (no query needed).
|
|
269
136
|
|
|
270
|
-
**Use
|
|
137
|
+
**DASHBOARD TIP:** Use `installed_only=True, category="lovelace"` to discover
|
|
138
|
+
installed custom cards for use with `ha_config_set_dashboard()`.
|
|
139
|
+
|
|
140
|
+
**Examples:**
|
|
271
141
|
- Find custom cards: `ha_hacs_search("mushroom", category="lovelace")`
|
|
272
142
|
- Find integrations: `ha_hacs_search("nest", category="integration")`
|
|
273
|
-
-
|
|
274
|
-
|
|
275
|
-
Results include:
|
|
276
|
-
- name: Repository name
|
|
277
|
-
- full_name: Full GitHub repository name
|
|
278
|
-
- description: Repository description
|
|
279
|
-
- category: Type of repository
|
|
280
|
-
- stars: GitHub stars count
|
|
281
|
-
- downloads: Number of HACS installations
|
|
282
|
-
- authors: Repository authors
|
|
283
|
-
- installed: Whether already installed
|
|
143
|
+
- List installed: `ha_hacs_search(installed_only=True)`
|
|
144
|
+
- Installed by category: `ha_hacs_search(installed_only=True, category="lovelace")`
|
|
284
145
|
|
|
285
146
|
Args:
|
|
286
|
-
query: Search query (repository name, description, author)
|
|
147
|
+
query: Search query (repository name, description, author). Empty string with
|
|
148
|
+
installed_only=True lists all installed repos.
|
|
287
149
|
category: Filter by category (optional)
|
|
150
|
+
installed_only: Only return installed repositories (default: False)
|
|
288
151
|
max_results: Maximum results to return (default: 10, max: 100)
|
|
289
152
|
offset: Number of results to skip for pagination (default: 0)
|
|
290
|
-
|
|
291
|
-
Returns:
|
|
292
|
-
Search results from HACS store or error if HACS is not available.
|
|
293
153
|
"""
|
|
294
154
|
try:
|
|
295
|
-
# Coerce
|
|
155
|
+
# Coerce parameters
|
|
156
|
+
installed_only_bool = coerce_bool_param(
|
|
157
|
+
installed_only, "installed_only", default=False
|
|
158
|
+
)
|
|
296
159
|
max_results_int = coerce_int_param(
|
|
297
160
|
max_results,
|
|
298
161
|
"max_results",
|
|
299
162
|
default=10,
|
|
300
163
|
min_value=1,
|
|
301
164
|
max_value=100,
|
|
302
|
-
)
|
|
165
|
+
)
|
|
303
166
|
offset_int = coerce_int_param(
|
|
304
167
|
offset,
|
|
305
168
|
"offset",
|
|
306
169
|
default=0,
|
|
307
170
|
min_value=0,
|
|
308
|
-
)
|
|
171
|
+
)
|
|
309
172
|
|
|
310
173
|
# Check if HACS is available
|
|
311
174
|
await _assert_hacs_available()
|
|
312
175
|
|
|
313
176
|
# Get all repositories via WebSocket
|
|
314
177
|
from ..client.websocket_client import get_websocket_client
|
|
178
|
+
|
|
315
179
|
ws_client = await get_websocket_client()
|
|
316
180
|
|
|
317
181
|
# Build command parameters - map user-friendly category to HACS internal name
|
|
@@ -320,22 +184,31 @@ def register_hacs_tools(mcp: Any, client: Any, **kwargs: Any) -> None:
|
|
|
320
184
|
hacs_category = CATEGORY_MAP.get(category, category)
|
|
321
185
|
kwargs_cmd["categories"] = [hacs_category]
|
|
322
186
|
|
|
323
|
-
response = await ws_client.send_command(
|
|
187
|
+
response = await ws_client.send_command(
|
|
188
|
+
"hacs/repositories/list", **kwargs_cmd
|
|
189
|
+
)
|
|
324
190
|
|
|
325
191
|
if not response.get("success"):
|
|
326
192
|
exception_to_structured_error(
|
|
327
193
|
Exception(f"HACS search request failed: {response}"),
|
|
328
|
-
context={
|
|
194
|
+
context={
|
|
195
|
+
"command": "hacs/repositories/list",
|
|
196
|
+
"query": query,
|
|
197
|
+
"category": category,
|
|
198
|
+
},
|
|
329
199
|
raise_error=True,
|
|
330
200
|
)
|
|
331
201
|
|
|
332
202
|
all_repositories = response.get("result", [])
|
|
333
203
|
|
|
334
|
-
# Simple search: filter by query string in name, description, or authors
|
|
335
204
|
query_lower = query.lower().strip()
|
|
336
205
|
matches = []
|
|
337
206
|
|
|
338
207
|
for repo in all_repositories:
|
|
208
|
+
# Filter to installed only when requested
|
|
209
|
+
if installed_only_bool and not repo.get("installed", False):
|
|
210
|
+
continue
|
|
211
|
+
|
|
339
212
|
# Handle None values safely
|
|
340
213
|
name = (repo.get("name") or "").lower()
|
|
341
214
|
description = (repo.get("description") or "").lower()
|
|
@@ -343,60 +216,83 @@ def register_hacs_tools(mcp: Any, client: Any, **kwargs: Any) -> None:
|
|
|
343
216
|
authors_list = repo.get("authors") or []
|
|
344
217
|
authors = " ".join(authors_list).lower()
|
|
345
218
|
|
|
346
|
-
# Calculate relevance score
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
219
|
+
# Calculate relevance score (all repos match when query is empty)
|
|
220
|
+
if query_lower:
|
|
221
|
+
score = 0
|
|
222
|
+
if query_lower in name:
|
|
223
|
+
score += 100
|
|
224
|
+
if query_lower in full_name:
|
|
225
|
+
score += 50
|
|
226
|
+
if query_lower in description:
|
|
227
|
+
score += 30
|
|
228
|
+
if query_lower in authors:
|
|
229
|
+
score += 20
|
|
230
|
+
if score == 0:
|
|
231
|
+
continue
|
|
232
|
+
else:
|
|
233
|
+
score = 0 # No scoring when listing all
|
|
234
|
+
|
|
235
|
+
# Map HACS internal category back to user-friendly name
|
|
236
|
+
repo_category = repo.get("category", "")
|
|
237
|
+
display_category = CATEGORY_DISPLAY.get(repo_category, repo_category)
|
|
238
|
+
entry: dict[str, Any] = {
|
|
239
|
+
"name": repo.get("name"),
|
|
240
|
+
"full_name": repo.get("full_name"),
|
|
241
|
+
"description": repo.get("description"),
|
|
242
|
+
"category": display_category,
|
|
243
|
+
"id": repo.get("id"),
|
|
244
|
+
"stars": repo.get("stars", 0),
|
|
245
|
+
"downloads": repo.get("downloads", 0),
|
|
246
|
+
"authors": authors_list,
|
|
247
|
+
"installed": repo.get("installed", False),
|
|
248
|
+
"installed_version": repo.get("installed_version")
|
|
249
|
+
if repo.get("installed")
|
|
250
|
+
else None,
|
|
251
|
+
"available_version": repo.get("available_version"),
|
|
252
|
+
}
|
|
253
|
+
if query_lower:
|
|
254
|
+
entry["score"] = score
|
|
255
|
+
if repo.get("installed"):
|
|
256
|
+
entry["pending_update"] = repo.get("pending_upgrade", False)
|
|
257
|
+
entry["domain"] = repo.get("domain")
|
|
258
|
+
matches.append(entry)
|
|
259
|
+
|
|
260
|
+
# Sort by score (descending) when searching, by name when listing
|
|
261
|
+
if query_lower:
|
|
262
|
+
matches.sort(key=lambda x: x.get("score", 0), reverse=True)
|
|
263
|
+
else:
|
|
264
|
+
matches.sort(key=lambda x: (x.get("name") or "").lower())
|
|
265
|
+
|
|
266
|
+
limited_matches = matches[offset_int : offset_int + max_results_int]
|
|
379
267
|
has_more = (offset_int + len(limited_matches)) < len(matches)
|
|
380
268
|
|
|
381
|
-
return await add_timezone_metadata(
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
269
|
+
return await add_timezone_metadata(
|
|
270
|
+
client,
|
|
271
|
+
{
|
|
272
|
+
"success": True,
|
|
273
|
+
"query": query if query_lower else None,
|
|
274
|
+
"category_filter": category,
|
|
275
|
+
"installed_only": installed_only_bool,
|
|
276
|
+
"total_matches": len(matches),
|
|
277
|
+
"offset": offset_int,
|
|
278
|
+
"limit": max_results_int,
|
|
279
|
+
"count": len(limited_matches),
|
|
280
|
+
"has_more": has_more,
|
|
281
|
+
"next_offset": offset_int + max_results_int if has_more else None,
|
|
282
|
+
"results": limited_matches,
|
|
283
|
+
},
|
|
284
|
+
)
|
|
393
285
|
|
|
394
286
|
except ToolError:
|
|
395
287
|
raise
|
|
396
288
|
except Exception as e:
|
|
397
289
|
exception_to_structured_error(
|
|
398
290
|
e,
|
|
399
|
-
context={
|
|
291
|
+
context={
|
|
292
|
+
"tool": "ha_hacs_search",
|
|
293
|
+
"query": query,
|
|
294
|
+
"category": category,
|
|
295
|
+
},
|
|
400
296
|
suggestions=[
|
|
401
297
|
"Verify HACS is installed: https://hacs.xyz/",
|
|
402
298
|
"Try a simpler search query",
|
|
@@ -404,7 +300,14 @@ def register_hacs_tools(mcp: Any, client: Any, **kwargs: Any) -> None:
|
|
|
404
300
|
],
|
|
405
301
|
)
|
|
406
302
|
|
|
407
|
-
@mcp.tool(
|
|
303
|
+
@mcp.tool(
|
|
304
|
+
tags={"HACS"},
|
|
305
|
+
annotations={
|
|
306
|
+
"idempotentHint": True,
|
|
307
|
+
"readOnlyHint": True,
|
|
308
|
+
"title": "Get HACS Repository Info",
|
|
309
|
+
},
|
|
310
|
+
)
|
|
408
311
|
@log_tool_usage
|
|
409
312
|
async def ha_hacs_repository_info(repository_id: str) -> dict[str, Any]:
|
|
410
313
|
"""Get detailed repository information including README and documentation.
|
|
@@ -423,7 +326,7 @@ def register_hacs_tools(mcp: Any, client: Any, **kwargs: Any) -> None:
|
|
|
423
326
|
- Find theme customization options
|
|
424
327
|
|
|
425
328
|
**Note:** The repository_id is the numeric ID from HACS, not the GitHub path.
|
|
426
|
-
Use `
|
|
329
|
+
Use `ha_hacs_search()` to find the numeric ID.
|
|
427
330
|
|
|
428
331
|
Args:
|
|
429
332
|
repository_id: Repository numeric ID (e.g., "441028036") or GitHub path (e.g., "dvd-dev/hilo")
|
|
@@ -436,6 +339,7 @@ def register_hacs_tools(mcp: Any, client: Any, **kwargs: Any) -> None:
|
|
|
436
339
|
await _assert_hacs_available()
|
|
437
340
|
|
|
438
341
|
from ..client.websocket_client import get_websocket_client
|
|
342
|
+
|
|
439
343
|
ws_client = await get_websocket_client()
|
|
440
344
|
|
|
441
345
|
# If repository_id contains a slash, it's a GitHub path - need to look up numeric ID
|
|
@@ -450,58 +354,70 @@ def register_hacs_tools(mcp: Any, client: Any, **kwargs: Any) -> None:
|
|
|
450
354
|
actual_id = str(repo.get("id"))
|
|
451
355
|
break
|
|
452
356
|
else:
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
357
|
+
raise_tool_error(
|
|
358
|
+
create_error_response(
|
|
359
|
+
ErrorCode.RESOURCE_NOT_FOUND,
|
|
360
|
+
f"Repository '{repository_id}' not found in HACS",
|
|
361
|
+
suggestions=[
|
|
362
|
+
"Use ha_hacs_search() to find the repository",
|
|
363
|
+
"Check the repository name is correct (case-insensitive)",
|
|
364
|
+
"The repository may need to be added to HACS first",
|
|
365
|
+
],
|
|
366
|
+
)
|
|
367
|
+
)
|
|
463
368
|
|
|
464
369
|
# Get repository info via WebSocket using numeric ID
|
|
465
|
-
response = await ws_client.send_command(
|
|
370
|
+
response = await ws_client.send_command(
|
|
371
|
+
"hacs/repository/info", repository_id=actual_id
|
|
372
|
+
)
|
|
466
373
|
|
|
467
374
|
if not response.get("success"):
|
|
468
375
|
exception_to_structured_error(
|
|
469
376
|
Exception(f"HACS repository info request failed: {response}"),
|
|
470
|
-
context={
|
|
377
|
+
context={
|
|
378
|
+
"command": "hacs/repository/info",
|
|
379
|
+
"repository_id": repository_id,
|
|
380
|
+
},
|
|
471
381
|
raise_error=True,
|
|
472
382
|
)
|
|
473
383
|
|
|
474
384
|
result = response.get("result", {})
|
|
475
385
|
|
|
476
386
|
# Extract and structure the most useful information
|
|
477
|
-
return await add_timezone_metadata(
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
387
|
+
return await add_timezone_metadata(
|
|
388
|
+
client,
|
|
389
|
+
{
|
|
390
|
+
"success": True,
|
|
391
|
+
"repository_id": repository_id,
|
|
392
|
+
"name": result.get("name"),
|
|
393
|
+
"full_name": result.get("full_name"),
|
|
394
|
+
"description": result.get("description"),
|
|
395
|
+
"category": result.get("category"),
|
|
396
|
+
"authors": result.get("authors", []),
|
|
397
|
+
"domain": result.get("domain"), # For integrations
|
|
398
|
+
"installed": result.get("installed", False),
|
|
399
|
+
"installed_version": result.get("installed_version"),
|
|
400
|
+
"available_version": result.get("available_version"),
|
|
401
|
+
"pending_update": result.get("pending_upgrade", False),
|
|
402
|
+
"stars": result.get("stars", 0),
|
|
403
|
+
"downloads": result.get("downloads", 0),
|
|
404
|
+
"topics": result.get("topics", []),
|
|
405
|
+
"releases": result.get("releases", []),
|
|
406
|
+
"default_branch": result.get("default_branch"),
|
|
407
|
+
"readme": result.get("readme"), # Full README content
|
|
408
|
+
"data": result, # Full response for advanced use
|
|
409
|
+
},
|
|
410
|
+
)
|
|
498
411
|
|
|
499
412
|
except ToolError:
|
|
500
413
|
raise
|
|
501
414
|
except Exception as e:
|
|
502
415
|
exception_to_structured_error(
|
|
503
416
|
e,
|
|
504
|
-
context={
|
|
417
|
+
context={
|
|
418
|
+
"tool": "ha_hacs_repository_info",
|
|
419
|
+
"repository_id": repository_id,
|
|
420
|
+
},
|
|
505
421
|
suggestions=[
|
|
506
422
|
"Verify HACS is installed: https://hacs.xyz/",
|
|
507
423
|
"Check repository ID format (e.g., 'hacs/integration' or 'owner/repo')",
|
|
@@ -509,7 +425,10 @@ def register_hacs_tools(mcp: Any, client: Any, **kwargs: Any) -> None:
|
|
|
509
425
|
],
|
|
510
426
|
)
|
|
511
427
|
|
|
512
|
-
@mcp.tool(
|
|
428
|
+
@mcp.tool(
|
|
429
|
+
tags={"HACS"},
|
|
430
|
+
annotations={"destructiveHint": True, "title": "Add HACS Repository"},
|
|
431
|
+
)
|
|
513
432
|
@log_tool_usage
|
|
514
433
|
async def ha_hacs_add_repository(
|
|
515
434
|
repository: str,
|
|
@@ -559,18 +478,20 @@ def register_hacs_tools(mcp: Any, client: Any, **kwargs: Any) -> None:
|
|
|
559
478
|
|
|
560
479
|
# Validate repository format
|
|
561
480
|
if "/" not in repository:
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
481
|
+
raise_tool_error(
|
|
482
|
+
create_error_response(
|
|
483
|
+
ErrorCode.VALIDATION_INVALID_PARAMETER,
|
|
484
|
+
"Invalid repository format. Must be 'owner/repo'",
|
|
485
|
+
suggestions=[
|
|
486
|
+
"Use format: 'owner/repo' (e.g., 'hacs/integration')",
|
|
487
|
+
"Check the repository exists on GitHub",
|
|
488
|
+
],
|
|
489
|
+
)
|
|
490
|
+
)
|
|
571
491
|
|
|
572
492
|
# Add repository via WebSocket
|
|
573
493
|
from ..client.websocket_client import get_websocket_client
|
|
494
|
+
|
|
574
495
|
ws_client = await get_websocket_client()
|
|
575
496
|
|
|
576
497
|
# Map user-friendly category to HACS internal name
|
|
@@ -595,14 +516,17 @@ def register_hacs_tools(mcp: Any, client: Any, **kwargs: Any) -> None:
|
|
|
595
516
|
|
|
596
517
|
result = response.get("result", {})
|
|
597
518
|
|
|
598
|
-
return await add_timezone_metadata(
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
519
|
+
return await add_timezone_metadata(
|
|
520
|
+
client,
|
|
521
|
+
{
|
|
522
|
+
"success": True,
|
|
523
|
+
"repository": repository,
|
|
524
|
+
"category": category,
|
|
525
|
+
"repository_id": result.get("id"),
|
|
526
|
+
"message": f"Successfully added {repository} to HACS",
|
|
527
|
+
"data": result,
|
|
528
|
+
},
|
|
529
|
+
)
|
|
606
530
|
|
|
607
531
|
except ToolError:
|
|
608
532
|
raise
|
|
@@ -623,7 +547,13 @@ def register_hacs_tools(mcp: Any, client: Any, **kwargs: Any) -> None:
|
|
|
623
547
|
],
|
|
624
548
|
)
|
|
625
549
|
|
|
626
|
-
@mcp.tool(
|
|
550
|
+
@mcp.tool(
|
|
551
|
+
tags={"HACS"},
|
|
552
|
+
annotations={
|
|
553
|
+
"destructiveHint": True,
|
|
554
|
+
"title": "Download/Install HACS Repository",
|
|
555
|
+
},
|
|
556
|
+
)
|
|
627
557
|
@log_tool_usage
|
|
628
558
|
async def ha_hacs_download(
|
|
629
559
|
repository_id: str,
|
|
@@ -642,7 +572,7 @@ def register_hacs_tools(mcp: Any, client: Any, **kwargs: Any) -> None:
|
|
|
642
572
|
|
|
643
573
|
**Prerequisites:**
|
|
644
574
|
- The repository must already be in HACS (either from the default store or added via `ha_hacs_add_repository`)
|
|
645
|
-
- Use `ha_hacs_search()`
|
|
575
|
+
- Use `ha_hacs_search()` to find the repository ID
|
|
646
576
|
|
|
647
577
|
**Examples:**
|
|
648
578
|
```python
|
|
@@ -671,6 +601,7 @@ def register_hacs_tools(mcp: Any, client: Any, **kwargs: Any) -> None:
|
|
|
671
601
|
await _assert_hacs_available()
|
|
672
602
|
|
|
673
603
|
from ..client.websocket_client import get_websocket_client
|
|
604
|
+
|
|
674
605
|
ws_client = await get_websocket_client()
|
|
675
606
|
|
|
676
607
|
# If repository_id contains a slash, it's a GitHub path - need to look up numeric ID
|
|
@@ -687,16 +618,17 @@ def register_hacs_tools(mcp: Any, client: Any, **kwargs: Any) -> None:
|
|
|
687
618
|
repo_name = repo.get("name") or repository_id
|
|
688
619
|
break
|
|
689
620
|
else:
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
621
|
+
raise_tool_error(
|
|
622
|
+
create_error_response(
|
|
623
|
+
ErrorCode.RESOURCE_NOT_FOUND,
|
|
624
|
+
f"Repository '{repository_id}' not found in HACS",
|
|
625
|
+
suggestions=[
|
|
626
|
+
"Use ha_hacs_add_repository() to add the repository first",
|
|
627
|
+
"Use ha_hacs_search() to find available repositories",
|
|
628
|
+
"Check the repository name is correct (case-insensitive)",
|
|
629
|
+
],
|
|
630
|
+
)
|
|
631
|
+
)
|
|
700
632
|
|
|
701
633
|
# Build download command parameters
|
|
702
634
|
download_kwargs: dict[str, Any] = {"repository": actual_id}
|
|
@@ -704,7 +636,9 @@ def register_hacs_tools(mcp: Any, client: Any, **kwargs: Any) -> None:
|
|
|
704
636
|
download_kwargs["version"] = version
|
|
705
637
|
|
|
706
638
|
# Download/install the repository
|
|
707
|
-
response = await ws_client.send_command(
|
|
639
|
+
response = await ws_client.send_command(
|
|
640
|
+
"hacs/repository/download", **download_kwargs
|
|
641
|
+
)
|
|
708
642
|
|
|
709
643
|
if not response.get("success"):
|
|
710
644
|
exception_to_structured_error(
|
|
@@ -719,15 +653,19 @@ def register_hacs_tools(mcp: Any, client: Any, **kwargs: Any) -> None:
|
|
|
719
653
|
|
|
720
654
|
result = response.get("result", {})
|
|
721
655
|
|
|
722
|
-
return await add_timezone_metadata(
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
656
|
+
return await add_timezone_metadata(
|
|
657
|
+
client,
|
|
658
|
+
{
|
|
659
|
+
"success": True,
|
|
660
|
+
"repository_id": actual_id,
|
|
661
|
+
"repository": repo_name,
|
|
662
|
+
"version": version or "latest",
|
|
663
|
+
"message": f"Successfully installed {repo_name}"
|
|
664
|
+
+ (f" version {version}" if version else ""),
|
|
665
|
+
"note": "For integrations, restart Home Assistant to activate. For Lovelace cards, clear browser cache.",
|
|
666
|
+
"data": result,
|
|
667
|
+
},
|
|
668
|
+
)
|
|
731
669
|
|
|
732
670
|
except ToolError:
|
|
733
671
|
raise
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: ha-mcp-dev
|
|
3
|
-
Version: 7.2.0.
|
|
3
|
+
Version: 7.2.0.dev332
|
|
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
|
|
@@ -37,7 +37,7 @@ Dynamic: license-file
|
|
|
37
37
|
<!-- mcp-name: io.github.homeassistant-ai/ha-mcp -->
|
|
38
38
|
|
|
39
39
|
<p align="center">
|
|
40
|
-
<img src="https://img.shields.io/badge/tools-
|
|
40
|
+
<img src="https://img.shields.io/badge/tools-91-blue" alt="95+ Tools">
|
|
41
41
|
<a href="https://github.com/homeassistant-ai/ha-mcp/releases"><img src="https://img.shields.io/github/v/release/homeassistant-ai/ha-mcp" alt="Release"></a>
|
|
42
42
|
<a href="https://github.com/homeassistant-ai/ha-mcp/actions/workflows/e2e-tests.yml"><img src="https://img.shields.io/github/actions/workflow/status/homeassistant-ai/ha-mcp/e2e-tests.yml?branch=master&label=E2E%20Tests" alt="E2E Tests"></a>
|
|
43
43
|
<a href="LICENSE.md"><img src="https://img.shields.io/github/license/homeassistant-ai/ha-mcp.svg" alt="License"></a>
|
|
@@ -160,7 +160,7 @@ Spend less time configuring, more time enjoying your smart home.
|
|
|
160
160
|
<details>
|
|
161
161
|
<!-- TOOLS_TABLE_START -->
|
|
162
162
|
|
|
163
|
-
<summary><b>Complete Tool List (
|
|
163
|
+
<summary><b>Complete Tool List (91 tools)</b></summary>
|
|
164
164
|
|
|
165
165
|
| Category | Tools |
|
|
166
166
|
|----------|-------|
|
|
@@ -175,7 +175,7 @@ Spend less time configuring, more time enjoying your smart home.
|
|
|
175
175
|
| **Entity Registry** | `ha_get_entity_exposure`, `ha_get_entity`, `ha_remove_entity`, `ha_set_entity` |
|
|
176
176
|
| **Files** | `ha_delete_file`, `ha_list_files`, `ha_read_file`, `ha_write_file` |
|
|
177
177
|
| **Groups** | `ha_config_list_groups`, `ha_config_remove_group`, `ha_config_set_group` |
|
|
178
|
-
| **HACS** | `ha_hacs_add_repository`, `ha_hacs_download`, `
|
|
178
|
+
| **HACS** | `ha_hacs_add_repository`, `ha_hacs_download`, `ha_hacs_repository_info`, `ha_hacs_search` |
|
|
179
179
|
| **Helper Entities** | `ha_config_list_helpers`, `ha_config_remove_helper`, `ha_config_set_helper`, `ha_get_helper_schema`, `ha_set_config_entry_helper` |
|
|
180
180
|
| **History & Statistics** | `ha_get_automation_traces`, `ha_get_history`, `ha_get_logs`, `ha_get_statistics` |
|
|
181
181
|
| **Integrations** | `ha_delete_config_entry`, `ha_get_integration`, `ha_set_integration_enabled` |
|
|
@@ -34,9 +34,9 @@ ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/h
|
|
|
34
34
|
ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/safe-refactoring.md,sha256=RVkY-J-ZrBDDD3INHwbbkkPocukDgNg8eOuApswRlqk,8014
|
|
35
35
|
ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/template-guidelines.md,sha256=rrcM8KrJ0hZw1jd4PPsVnE4rTFnsA1uA7SgltVjewkw,17856
|
|
36
36
|
ha_mcp/tools/__init__.py,sha256=79ML0aicKZ5WJQn47eTPaeQntJZe1Mzt30E31_v6tXU,334
|
|
37
|
-
ha_mcp/tools/backup.py,sha256=
|
|
37
|
+
ha_mcp/tools/backup.py,sha256=QN0ZhLke1ItivS0ykJV74WAtsiirPduZfhIMXW4pfJc,18372
|
|
38
38
|
ha_mcp/tools/best_practice_checker.py,sha256=wyeeB5L3wPFG11VX6acg7F0fz6ISbgK-5wrUhxntIPE,14576
|
|
39
|
-
ha_mcp/tools/device_control.py,sha256=
|
|
39
|
+
ha_mcp/tools/device_control.py,sha256=8vI7LqcbNeJi-j4pA0GCUMOKPy8mD90IDYbEAy38_ok,28586
|
|
40
40
|
ha_mcp/tools/enhanced.py,sha256=plvrTJmuAJ-55M0yznEq1Vv5TFDl_2FgegTYK7RaLC8,6668
|
|
41
41
|
ha_mcp/tools/helpers.py,sha256=GveFEr0UPmnGwv18gHTkzPenrOOYtr8PT8EF8b4mmus,9504
|
|
42
42
|
ha_mcp/tools/registry.py,sha256=LDU17zgJCgrlsv-yoBHm6TxAvkfnGoIdYsWfzLTmhGs,7718
|
|
@@ -53,10 +53,10 @@ ha_mcp/tools/tools_config_dashboards.py,sha256=3eQKQI-x7ESEBpVdIztp6YCZhWGl26Plj
|
|
|
53
53
|
ha_mcp/tools/tools_config_entry_flow.py,sha256=AlDGCQxCWZPRTEyee3ak-KeDzlQM83AFNYAi-D7tS4I,21065
|
|
54
54
|
ha_mcp/tools/tools_config_helpers.py,sha256=7CHDyCBsKkArZRDBf9hsNlszTGcW3bqIBOyyucBXahg,55094
|
|
55
55
|
ha_mcp/tools/tools_config_scripts.py,sha256=92CJmlx5u2IQCrIPXnqIjpvDLvAbmAmWQ4EY3oGLA_A,16218
|
|
56
|
-
ha_mcp/tools/tools_entities.py,sha256=
|
|
56
|
+
ha_mcp/tools/tools_entities.py,sha256=EgnR95p8SZatCKW4oFyhbDiIVvMte81qycZuJi_sVl4,42469
|
|
57
57
|
ha_mcp/tools/tools_filesystem.py,sha256=-ZEOPD7Q4_4ULoxg2Cye8vJkdCUESHUhn1-bUHi_1_g,17846
|
|
58
58
|
ha_mcp/tools/tools_groups.py,sha256=OQe3BHD8L86IAov-B6tDBkEJZIFq5p8_VmpkbfYKJ0k,14283
|
|
59
|
-
ha_mcp/tools/tools_hacs.py,sha256=
|
|
59
|
+
ha_mcp/tools/tools_hacs.py,sha256=YHDwb2YzBbJLajNrfsQC-UpRjGNbQ9jOmd2lzB0aGjk,27168
|
|
60
60
|
ha_mcp/tools/tools_history.py,sha256=C6a83UBaTasY-hurS_-GP00T4Ty4g43Y31B3UWLb2Sk,29568
|
|
61
61
|
ha_mcp/tools/tools_integrations.py,sha256=U50nh-B99fTnYFH1TawL-a1174nQX-W01lJL5ApC5ns,17351
|
|
62
62
|
ha_mcp/tools/tools_labels.py,sha256=o9ZCHY786j80zHNzn7XMTzVjdZJqeYOYC-EtS1Xhews,9868
|
|
@@ -83,12 +83,12 @@ ha_mcp/utils/fuzzy_search.py,sha256=bvT1wnGVVb2q2a4GtAnXK4uSLRU8wfGZBeGVf6CQhR0,
|
|
|
83
83
|
ha_mcp/utils/operation_manager.py,sha256=1ETI_L2TFNhnJUUJwtuH4R0s6ZP3_rscIOfdehYSmkU,14266
|
|
84
84
|
ha_mcp/utils/python_sandbox.py,sha256=mVBrBR1caQksXso3voUw2YlqY2OQJDXkt3EAZpasE0M,7488
|
|
85
85
|
ha_mcp/utils/usage_logger.py,sha256=ZXbr3vHV2WT7IozrEnuNCulKt3wLXDUJI1dxaBVq0kQ,9294
|
|
86
|
-
ha_mcp_dev-7.2.0.
|
|
86
|
+
ha_mcp_dev-7.2.0.dev332.dist-info/licenses/LICENSE,sha256=7rJXXKBJWgJF8595wk-YTxwVTEi1kQaIqyy9dh5o_oY,1062
|
|
87
87
|
tests/__init__.py,sha256=YRpec-ZFYCJ48oD_7ZcNY7dB8avoTWOrZICjaM-BYJ0,39
|
|
88
88
|
tests/test_constants.py,sha256=F14Pf5QMzG77RhsecaNWWaEL-B_8ykHJLIvVMcJxT8M,609
|
|
89
89
|
tests/test_env_manager.py,sha256=wEYSfwmkga9IPanzVkSo03fsY77KVw71zJG5S7Kkdr8,12045
|
|
90
|
-
ha_mcp_dev-7.2.0.
|
|
91
|
-
ha_mcp_dev-7.2.0.
|
|
92
|
-
ha_mcp_dev-7.2.0.
|
|
93
|
-
ha_mcp_dev-7.2.0.
|
|
94
|
-
ha_mcp_dev-7.2.0.
|
|
90
|
+
ha_mcp_dev-7.2.0.dev332.dist-info/METADATA,sha256=IapaC2pCw7BgQNDcf5JIyR65aCI3yHH1rcAVAvFMOSM,18573
|
|
91
|
+
ha_mcp_dev-7.2.0.dev332.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
92
|
+
ha_mcp_dev-7.2.0.dev332.dist-info/entry_points.txt,sha256=ckO8PIrfV4-YQEyjqgO8wIzcQiMFTTJNWKZLyRtFpms,292
|
|
93
|
+
ha_mcp_dev-7.2.0.dev332.dist-info/top_level.txt,sha256=cqJLEmgh4gQBKg_vBqj0ahS4DCg4J0qBXYgZCDQ2IWs,13
|
|
94
|
+
ha_mcp_dev-7.2.0.dev332.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|