Skip to content

Commit 806e64e

Browse files
fix: block registry-disable on automation/script entities (#794) (#796)
Prevents ha_set_entity(enabled=False) from being used on automation.* and script.* entities. Registry-disabling removes entities from the HA state machine entirely, hiding them from the UI and making them unqueryable until re-enabled AND reloaded — a destructive side effect that agents are unlikely to intend. The error message directs agents to use the correct domain services (automation.turn_off / script.turn_off) which disable without removing. Also updates the enabled parameter description and docstring to warn about the destructive nature of registry-level disable for all entities. Closes #794 Co-authored-by: kingpanther13 <kingpanther13@users.noreply.github.com> Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 7c3b5e3 commit 806e64e

3 files changed

Lines changed: 194 additions & 3 deletions

File tree

site/src/data/tools.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -920,7 +920,7 @@
920920
{
921921
"name": "ha_set_entity",
922922
"title": "Set Entity",
923-
"description": "Update entity properties in the entity registry.\n\nAllows modifying entity metadata such as area assignment, display name,\nicon, enabled/disabled state, visibility, aliases, labels, and voice\nassistant exposure in a single call.\n\nBULK OPERATIONS:\nWhen entity_id is a list, only labels and expose_to parameters are supported.\nOther parameters (area_id, name, icon, enabled, hidden, aliases) require single entity.\n\nLABEL OPERATIONS:\n- label_operation=\"set\" (default): Replace all labels with the provided list. Use [] to clear.\n- label_operation=\"add\": Add labels to existing ones without removing any.\n- label_operation=\"remove\": Remove specified labels from the entity.\n\nUse ha_search_entities() or ha_get_device() to find entity IDs.\nUse ha_config_get_label() to find available label IDs.\n\nEXAMPLES:\nSingle entity:\n- Assign to area: ha_set_entity(\"sensor.temp\", area_id=\"living_room\")\n- Rename: ha_set_entity(\"sensor.temp\", name=\"Living Room Temperature\")\n- Set labels: ha_set_entity(\"light.lamp\", labels=[\"outdoor\", \"smart\"])\n- Add labels: ha_set_entity(\"light.lamp\", labels=[\"new_label\"], label_operation=\"add\")\n- Remove labels: ha_set_entity(\"light.lamp\", labels=[\"old_label\"], label_operation=\"remove\")\n- Clear labels: ha_set_entity(\"light.lamp\", labels=[])\n- Expose to Alexa: ha_set_entity(\"light.lamp\", expose_to={\"cloud.alexa\": True})\n\nBulk operations:\n- Set labels on multiple: ha_set_entity([\"light.a\", \"light.b\"], labels=[\"outdoor\"])\n- Add labels to multiple: ha_set_entity([\"light.a\", \"light.b\"], labels=[\"new\"], label_operation=\"add\")\n- Expose multiple to Alexa: ha_set_entity([\"light.a\", \"light.b\"], expose_to={\"cloud.alexa\": True})\n\nNOTE: To rename an entity_id (e.g., sensor.old -> sensor.new), use ha_rename_entity() instead.",
923+
"description": "Update entity properties in the entity registry.\n\nAllows modifying entity metadata such as area assignment, display name,\nicon, enabled/disabled state, visibility, aliases, labels, and voice\nassistant exposure in a single call.\n\nBULK OPERATIONS:\nWhen entity_id is a list, only labels and expose_to parameters are supported.\nOther parameters (area_id, name, icon, enabled, hidden, aliases) require single entity.\n\nLABEL OPERATIONS:\n- label_operation=\"set\" (default): Replace all labels with the provided list. Use [] to clear.\n- label_operation=\"add\": Add labels to existing ones without removing any.\n- label_operation=\"remove\": Remove specified labels from the entity.\n\nUse ha_search_entities() or ha_get_device() to find entity IDs.\nUse ha_config_get_label() to find available label IDs.\n\nEXAMPLES:\nSingle entity:\n- Assign to area: ha_set_entity(\"sensor.temp\", area_id=\"living_room\")\n- Rename: ha_set_entity(\"sensor.temp\", name=\"Living Room Temperature\")\n- Set labels: ha_set_entity(\"light.lamp\", labels=[\"outdoor\", \"smart\"])\n- Add labels: ha_set_entity(\"light.lamp\", labels=[\"new_label\"], label_operation=\"add\")\n- Remove labels: ha_set_entity(\"light.lamp\", labels=[\"old_label\"], label_operation=\"remove\")\n- Clear labels: ha_set_entity(\"light.lamp\", labels=[])\n- Expose to Alexa: ha_set_entity(\"light.lamp\", expose_to={\"cloud.alexa\": True})\n\nBulk operations:\n- Set labels on multiple: ha_set_entity([\"light.a\", \"light.b\"], labels=[\"outdoor\"])\n- Add labels to multiple: ha_set_entity([\"light.a\", \"light.b\"], labels=[\"new\"], label_operation=\"add\")\n- Expose multiple to Alexa: ha_set_entity([\"light.a\", \"light.b\"], expose_to={\"cloud.alexa\": True})\n\nNOTE: To rename an entity_id (e.g., sensor.old -> sensor.new), use ha_rename_entity() instead.\n\nENABLED/DISABLED WARNING:\nSetting enabled=False performs a **registry-level disable** — the entity is completely\nremoved from the Home Assistant state machine and hidden from the UI. It will NOT appear\nin state queries, dashboards, or automations until re-enabled AND the integration is\nreloaded. This is NOT the same as \"turning off\" an entity.\n\nFor automations and scripts, enabled=False is blocked. Use these instead:\n- ha_call_service(\"automation\", \"turn_off\", entity_id=\"automation.xxx\")\n- ha_call_service(\"script\", \"turn_off\", entity_id=\"script.xxx\")",
924924
"inputSchema": {
925925
"properties": {
926926
"entity_id": {
@@ -939,7 +939,7 @@
939939
"default": null
940940
},
941941
"enabled": {
942-
"type": "Annotated[bool | str | None, Field(description='True to enable the entity, False to disable it. Single entity only.', default=None)]",
942+
"type": "Annotated[bool | str | None, Field(description='True to enable the entity, False to disable it. Single entity only. WARNING: Setting enabled=False is a registry-level disable — it completely removes the entity from the state machine and hides it from the UI. A reload or restart is required to restore it after re-enabling. NOT allowed for automation or script entities — use automation.turn_off / script.turn_off via ha_call_service() instead.', default=None)]",
943943
"default": null
944944
},
945945
"hidden": {

src/ha_mcp/tools/tools_entities.py

Lines changed: 54 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -341,7 +341,14 @@ async def ha_set_entity(
341341
enabled: Annotated[
342342
bool | str | None,
343343
Field(
344-
description="True to enable the entity, False to disable it. Single entity only.",
344+
description=(
345+
"True to enable the entity, False to disable it. Single entity only. "
346+
"WARNING: Setting enabled=False is a registry-level disable — it completely "
347+
"removes the entity from the state machine and hides it from the UI. "
348+
"A reload or restart is required to restore it after re-enabling. "
349+
"NOT allowed for automation or script entities — use automation.turn_off / "
350+
"script.turn_off via ha_call_service() instead."
351+
),
345352
default=None,
346353
),
347354
] = None,
@@ -431,6 +438,16 @@ async def ha_set_entity(
431438
- Expose multiple to Alexa: ha_set_entity(["light.a", "light.b"], expose_to={"cloud.alexa": True})
432439
433440
NOTE: To rename an entity_id (e.g., sensor.old -> sensor.new), use ha_rename_entity() instead.
441+
442+
ENABLED/DISABLED WARNING:
443+
Setting enabled=False performs a **registry-level disable** — the entity is completely
444+
removed from the Home Assistant state machine and hidden from the UI. It will NOT appear
445+
in state queries, dashboards, or automations until re-enabled AND the integration is
446+
reloaded. This is NOT the same as "turning off" an entity.
447+
448+
For automations and scripts, enabled=False is blocked. Use these instead:
449+
- ha_call_service("automation", "turn_off", entity_id="automation.xxx")
450+
- ha_call_service("script", "turn_off", entity_id="script.xxx")
434451
"""
435452
try:
436453
# Parse entity_id - determine if bulk operation
@@ -491,6 +508,42 @@ async def ha_set_entity(
491508
)
492509
)
493510

511+
# Block registry-disable on automation and script entities.
512+
# Registry-disabling (enabled=False) removes the entity from the HA
513+
# state machine entirely, making it invisible in the UI and
514+
# unqueryable via state APIs until re-enabled AND the integration is
515+
# reloaded. For automations and scripts the correct way to
516+
# "disable" them is via their domain services (automation.turn_off /
517+
# script.turn_off) which simply prevent them from running while
518+
# keeping them visible and manageable.
519+
if enabled is not None:
520+
try:
521+
_enabled_check = coerce_bool_param(enabled, "enabled")
522+
except ValueError:
523+
_enabled_check = None # will be caught by _update_single_entity
524+
525+
if _enabled_check is False:
526+
blocked = [
527+
eid for eid in entity_ids
528+
if eid.split(".")[0] in ("automation", "script")
529+
]
530+
if blocked:
531+
_domain = blocked[0].split(".")[0]
532+
_service_hint = f"{_domain}.turn_off"
533+
raise_tool_error(create_error_response(
534+
ErrorCode.VALIDATION_INVALID_PARAMETER,
535+
f"Cannot registry-disable {_domain} entities with ha_set_entity(enabled=False). "
536+
f"This removes the entity from the state machine and hides it from the UI "
537+
f"until it is re-enabled AND the {_domain}s are reloaded. "
538+
f"Use ha_call_service('{_domain}', 'turn_off', entity_id='{blocked[0]}') instead "
539+
f"to disable it without removing it.",
540+
suggestions=[
541+
f"Use {_service_hint} to disable the {_domain} (keeps it visible and manageable)",
542+
f"Use {_domain}.turn_on to re-enable it later",
543+
"ha_set_entity(enabled=False) is for registry-level disable — it fully hides the entity",
544+
],
545+
))
546+
494547
# Parse list parameters if provided as strings
495548
parsed_aliases = None
496549
if aliases is not None:

tests/src/unit/test_tools_entities.py

Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1091,3 +1091,141 @@ async def test_bulk_label_add_operation(self, mock_mcp, mock_client):
10911091

10921092
assert result["success"] is True
10931093
assert result["succeeded_count"] == 2
1094+
1095+
1096+
class TestHaSetEntityRegistryDisableGuardrail:
1097+
"""Test that registry-disable (enabled=False) is blocked for automations and scripts."""
1098+
1099+
@pytest.fixture
1100+
def mock_mcp(self):
1101+
"""Create a mock MCP server."""
1102+
mcp = MagicMock()
1103+
self.registered_tools = {}
1104+
1105+
def tool_decorator(*args, **kwargs):
1106+
def wrapper(func):
1107+
self.registered_tools[func.__name__] = func
1108+
return func
1109+
return wrapper
1110+
1111+
mcp.tool = tool_decorator
1112+
return mcp
1113+
1114+
@pytest.fixture
1115+
def mock_client(self):
1116+
"""Create a mock Home Assistant client."""
1117+
client = MagicMock()
1118+
client.send_websocket_message = AsyncMock()
1119+
return client
1120+
1121+
@pytest.fixture
1122+
def set_entity_tool(self, mock_mcp, mock_client):
1123+
"""Register tools and return the ha_set_entity function."""
1124+
register_entity_tools(mock_mcp, mock_client)
1125+
return self.registered_tools["ha_set_entity"]
1126+
1127+
@pytest.mark.asyncio
1128+
async def test_disable_automation_blocked(self, set_entity_tool, mock_client):
1129+
"""enabled=False on automation entity should raise ToolError."""
1130+
with pytest.raises(ToolError) as exc_info:
1131+
await set_entity_tool(entity_id="automation.test", enabled=False)
1132+
1133+
error_text = str(exc_info.value)
1134+
assert "automation" in error_text.lower()
1135+
assert "turn_off" in error_text
1136+
# Ensure no WebSocket call was made
1137+
mock_client.send_websocket_message.assert_not_called()
1138+
1139+
@pytest.mark.asyncio
1140+
async def test_disable_script_blocked(self, set_entity_tool, mock_client):
1141+
"""enabled=False on script entity should raise ToolError."""
1142+
with pytest.raises(ToolError) as exc_info:
1143+
await set_entity_tool(entity_id="script.my_script", enabled=False)
1144+
1145+
error_text = str(exc_info.value)
1146+
assert "script" in error_text.lower()
1147+
assert "turn_off" in error_text
1148+
mock_client.send_websocket_message.assert_not_called()
1149+
1150+
@pytest.mark.asyncio
1151+
async def test_disable_automation_string_false_blocked(self, set_entity_tool, mock_client):
1152+
"""enabled='false' (string) on automation entity should also be blocked."""
1153+
with pytest.raises(ToolError) as exc_info:
1154+
await set_entity_tool(entity_id="automation.morning", enabled="false")
1155+
1156+
error_text = str(exc_info.value)
1157+
assert "automation" in error_text.lower()
1158+
assert "turn_off" in error_text
1159+
mock_client.send_websocket_message.assert_not_called()
1160+
1161+
@pytest.mark.asyncio
1162+
async def test_disable_automation_string_capital_false_blocked(self, set_entity_tool, mock_client):
1163+
"""enabled='False' (capital F, common from Python agents) should also be blocked."""
1164+
with pytest.raises(ToolError) as exc_info:
1165+
await set_entity_tool(entity_id="automation.evening", enabled="False")
1166+
1167+
error_text = str(exc_info.value)
1168+
assert "automation" in error_text.lower()
1169+
assert "turn_off" in error_text
1170+
mock_client.send_websocket_message.assert_not_called()
1171+
1172+
@pytest.mark.asyncio
1173+
async def test_disable_automation_single_element_list_blocked(self, set_entity_tool, mock_client):
1174+
"""enabled=False on single-element list ['automation.test'] should also be blocked."""
1175+
with pytest.raises(ToolError) as exc_info:
1176+
await set_entity_tool(entity_id=["automation.test"], enabled=False)
1177+
1178+
error_text = str(exc_info.value)
1179+
assert "automation" in error_text.lower()
1180+
assert "turn_off" in error_text
1181+
mock_client.send_websocket_message.assert_not_called()
1182+
1183+
@pytest.mark.asyncio
1184+
async def test_enable_automation_allowed(self, set_entity_tool, mock_client):
1185+
"""enabled=True on automation entity should be allowed (re-enabling is fine)."""
1186+
mock_client.send_websocket_message = AsyncMock(
1187+
return_value={
1188+
"success": True,
1189+
"result": {
1190+
"entity_entry": {
1191+
"entity_id": "automation.test",
1192+
"name": None,
1193+
"original_name": "Test",
1194+
"icon": None,
1195+
"area_id": None,
1196+
"disabled_by": None,
1197+
"hidden_by": None,
1198+
"aliases": [],
1199+
"labels": [],
1200+
}
1201+
},
1202+
}
1203+
)
1204+
1205+
result = await set_entity_tool(entity_id="automation.test", enabled=True)
1206+
assert result["success"] is True
1207+
1208+
@pytest.mark.asyncio
1209+
async def test_disable_other_domain_allowed(self, set_entity_tool, mock_client):
1210+
"""enabled=False on non-automation/script entities should still work."""
1211+
mock_client.send_websocket_message = AsyncMock(
1212+
return_value={
1213+
"success": True,
1214+
"result": {
1215+
"entity_entry": {
1216+
"entity_id": "sensor.temperature",
1217+
"name": None,
1218+
"original_name": "Temperature",
1219+
"icon": None,
1220+
"area_id": None,
1221+
"disabled_by": "user",
1222+
"hidden_by": None,
1223+
"aliases": [],
1224+
"labels": [],
1225+
}
1226+
},
1227+
}
1228+
)
1229+
1230+
result = await set_entity_tool(entity_id="sensor.temperature", enabled=False)
1231+
assert result["success"] is True

0 commit comments

Comments
 (0)