Skip to content

Commit eec0f1b

Browse files
fix: prevent false success and duplicate creation in ha_config_set_automation (#708)
* fix: prevent false success on automation creation and duplicate creation Two fixes for ha_config_set_automation robustness: 1. Reject config with existing 'id' when no identifier is provided (#698): When an agent retrieves an automation config (which includes an 'id' field) and passes it back without an identifier, the tool now rejects the request with a clear error instead of silently creating a duplicate automation. 2. Report failure when created automation is not found in state (#610): Replace the prediction fallback with an explicit failure. The client now polls with retries (1s, 2s, 3s) and sets entity_not_verified=True if the automation never appears. The tool layer converts this to a clear error telling users to check HA logs, rather than returning success with a fabricated entity_id. Closes #610 Closes #698 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: remove redundant asyncio import and fix E2E test assertion - Remove local `import asyncio` in upsert_automation_config (already imported at module level) — addresses Gemini review comment - Fix test_automation_creation_returns_verified_entity to handle ha_get_state response structure (entity data nested under 'data' key) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: kingpanther13 <kingpanther13@users.noreply.github.com> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent efaf8ac commit eec0f1b

3 files changed

Lines changed: 181 additions & 22 deletions

File tree

src/ha_mcp/client/rest_client.py

Lines changed: 26 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -502,45 +502,49 @@ async def upsert_automation_config(
502502

503503
# For new automations, query Home Assistant to get the actual entity_id that was assigned
504504
actual_entity_id = None
505+
entity_not_verified = False
505506
if operation == "created":
506507
try:
507-
# Give Home Assistant a moment to register the entity
508-
import asyncio
509-
510-
await asyncio.sleep(1)
511-
512-
# Get all automations and find the one with our unique_id
513-
states = await self.get_states()
514-
for state in states:
515-
if state.get("entity_id", "").startswith("automation."):
516-
attributes = state.get("attributes", {})
517-
if attributes.get("id") == unique_id:
518-
actual_entity_id = state.get("entity_id")
519-
logger.debug(
520-
f"Found actual entity_id for unique_id {unique_id}: {actual_entity_id}"
521-
)
522-
break
508+
# Poll with retries — slower hardware may need more time
509+
for attempt in range(3):
510+
await asyncio.sleep(1 * (attempt + 1))
511+
512+
states = await self.get_states()
513+
for state in states:
514+
if state.get("entity_id", "").startswith(
515+
"automation."
516+
):
517+
attributes = state.get("attributes", {})
518+
if attributes.get("id") == unique_id:
519+
actual_entity_id = state.get("entity_id")
520+
logger.debug(
521+
f"Found actual entity_id for unique_id {unique_id}: {actual_entity_id}"
522+
)
523+
break
524+
if actual_entity_id:
525+
break
523526

524527
if not actual_entity_id:
525-
# Fallback to predicted entity_id if we can't find it
526-
actual_entity_id = f"automation.{config.get('alias', unique_id).lower().replace(' ', '_').replace('-', '_')}"
528+
entity_not_verified = True
527529
logger.warning(
528-
f"Could not find actual entity_id for unique_id {unique_id}, using predicted: {actual_entity_id}"
530+
f"Automation with unique_id {unique_id} was not found in HA state after creation"
529531
)
530532

531533
except Exception as e:
534+
entity_not_verified = True
532535
logger.warning(
533536
f"Failed to query actual entity_id for unique_id {unique_id}: {e}"
534537
)
535-
# Fallback to predicted entity_id
536-
actual_entity_id = f"automation.{config.get('alias', unique_id).lower().replace(' ', '_').replace('-', '_')}"
537538

538-
return {
539+
result: dict[str, Any] = {
539540
"unique_id": unique_id,
540541
"entity_id": actual_entity_id,
541542
"result": response.get("result", "ok"),
542543
"operation": operation,
543544
}
545+
if entity_not_verified:
546+
result["entity_not_verified"] = True
547+
return result
544548
except Exception as e:
545549
if "400" in str(e):
546550
raise HomeAssistantAPIError(

src/ha_mcp/tools/tools_config_automations.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -457,8 +457,31 @@ async def ha_config_set_automation(
457457
missing_fields=missing_fields,
458458
))
459459

460+
# Prevent duplicate creation when config contains an existing automation id
461+
if identifier is None and "id" in config_dict:
462+
existing_id = config_dict["id"]
463+
raise_tool_error(create_validation_error(
464+
f"Config contains 'id' field ('{existing_id}') but no identifier was provided. "
465+
"This would create a duplicate automation instead of updating the existing one.",
466+
parameter="identifier",
467+
details=f"To update, pass identifier='{existing_id}' (or the automation's entity_id). "
468+
"To create a genuinely new automation, remove the 'id' field from the config.",
469+
))
470+
460471
result = await client.upsert_automation_config(config_dict, identifier)
461472

473+
# If the client could not verify the entity was registered, warn but don't hard-fail.
474+
# The automation may have been created but not yet visible (slow hardware, reload needed).
475+
if result.get("entity_not_verified"):
476+
result["warning"] = (
477+
"Automation was submitted to Home Assistant but the entity was not found "
478+
"after polling. The automation may still have been created — check Home "
479+
"Assistant logs and try reloading automations. Common causes: "
480+
"automations.yaml vs automation.yaml filename mismatch, invalid config "
481+
"that HA accepted but failed to load, or slow hardware."
482+
)
483+
result.pop("entity_not_verified", None)
484+
462485
# Wait for automation to be queryable
463486
wait_bool = coerce_bool_param(wait, "wait", default=True)
464487
entity_id = result.get("entity_id")

tests/src/e2e/workflows/automation/test_lifecycle.py

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1003,3 +1003,135 @@ async def test_automation_with_choose_block(mcp_client):
10031003
assert_mcp_success(delete_result)
10041004

10051005
logger.info("✅ Choose block normalization test completed successfully")
1006+
1007+
1008+
@pytest.mark.automation
1009+
async def test_duplicate_automation_prevention(mcp_client, cleanup_tracker):
1010+
"""
1011+
Test: Creating automation with existing 'id' in config but no identifier is rejected.
1012+
1013+
Validates fix for issue #698 — when an agent retrieves an automation config
1014+
(which contains an 'id' field) and passes it back to ha_config_set_automation
1015+
without an identifier, the tool should reject the request instead of silently
1016+
creating a duplicate.
1017+
"""
1018+
logger.info("Testing duplicate automation prevention...")
1019+
1020+
# First create a real automation so we have a valid config with an 'id' field
1021+
create_result = await safe_call_tool(
1022+
mcp_client,
1023+
"ha_config_set_automation",
1024+
{
1025+
"config": {
1026+
"alias": "Duplicate Prevention Test E2E",
1027+
"description": "E2E test - safe to delete",
1028+
"trigger": [{"platform": "time", "at": "06:00:00"}],
1029+
"action": [
1030+
{
1031+
"service": "persistent_notification.create",
1032+
"data": {"message": "test"},
1033+
}
1034+
],
1035+
}
1036+
},
1037+
)
1038+
assert create_result.get("success"), f"Initial creation failed: {create_result}"
1039+
automation_entity = create_result.get("entity_id")
1040+
unique_id = create_result.get("unique_id")
1041+
assert automation_entity, "No entity_id returned"
1042+
assert unique_id, "No unique_id returned"
1043+
cleanup_tracker.track("automation", automation_entity)
1044+
logger.info(f"Created test automation: {automation_entity} (id={unique_id})")
1045+
1046+
# Now retrieve the automation config — it will contain the 'id' field
1047+
config = await wait_for_automation(mcp_client, automation_entity, timeout=10)
1048+
assert config is not None, "Could not retrieve created automation"
1049+
assert "id" in config, f"Retrieved config should contain 'id' field: {config.keys()}"
1050+
1051+
# Attempt to create a new automation using this config WITHOUT passing identifier.
1052+
# This should be rejected because the config contains an existing 'id'.
1053+
logger.info("Attempting to create automation with existing 'id' in config (no identifier)...")
1054+
duplicate_result = await safe_call_tool(
1055+
mcp_client,
1056+
"ha_config_set_automation",
1057+
{"config": config},
1058+
)
1059+
1060+
assert not duplicate_result.get("success"), (
1061+
f"Should have rejected config with existing 'id' but got success: {duplicate_result}"
1062+
)
1063+
1064+
# Verify the error mentions the 'id' field and provides guidance
1065+
error = duplicate_result.get("error", {})
1066+
error_msg = error.get("message", "") if isinstance(error, dict) else str(error)
1067+
assert "id" in error_msg.lower(), (
1068+
f"Error should mention 'id' field: {error_msg}"
1069+
)
1070+
logger.info(f"Correctly rejected with error: {error_msg}")
1071+
1072+
# Clean up
1073+
delete_result = await mcp_client.call_tool(
1074+
"ha_config_remove_automation",
1075+
{"identifier": automation_entity},
1076+
)
1077+
assert_mcp_success(delete_result, "duplicate prevention test cleanup")
1078+
logger.info("Duplicate automation prevention test passed")
1079+
1080+
1081+
@pytest.mark.automation
1082+
async def test_automation_creation_returns_verified_entity(
1083+
mcp_client, cleanup_tracker, test_data_factory
1084+
):
1085+
"""
1086+
Test: Successful automation creation returns a verified entity_id.
1087+
1088+
Validates fix for issue #610 — after creating an automation, the tool should
1089+
return a real entity_id that was confirmed via state polling, not a predicted one.
1090+
The entity_id must be queryable immediately after creation returns.
1091+
"""
1092+
logger.info("Testing automation creation returns verified entity...")
1093+
1094+
config = test_data_factory.automation_config(
1095+
"Verified Entity",
1096+
trigger=[{"platform": "time", "at": "06:00:00"}],
1097+
action=[
1098+
{
1099+
"service": "persistent_notification.create",
1100+
"data": {"message": "verified entity test"},
1101+
}
1102+
],
1103+
)
1104+
1105+
create_result = await safe_call_tool(
1106+
mcp_client,
1107+
"ha_config_set_automation",
1108+
{"config": config},
1109+
)
1110+
assert create_result.get("success"), f"Automation creation failed: {create_result}"
1111+
1112+
entity_id = create_result.get("entity_id")
1113+
assert entity_id, "No entity_id returned from creation"
1114+
assert entity_id.startswith("automation."), f"Invalid entity_id format: {entity_id}"
1115+
cleanup_tracker.track("automation", entity_id)
1116+
1117+
# The returned entity_id should be immediately queryable since it was verified
1118+
logger.info(f"Verifying returned entity_id {entity_id} is queryable...")
1119+
state_result = await safe_call_tool(
1120+
mcp_client,
1121+
"ha_get_state",
1122+
{"entity_id": entity_id},
1123+
)
1124+
# ha_get_state nests entity data under 'data' key
1125+
state_data = state_result.get("data", state_result)
1126+
assert state_data.get("entity_id") == entity_id, (
1127+
f"Returned entity_id {entity_id} is not queryable: {state_result}"
1128+
)
1129+
logger.info(f"Entity {entity_id} is queryable - verified, not predicted")
1130+
1131+
# Clean up
1132+
delete_result = await mcp_client.call_tool(
1133+
"ha_config_remove_automation",
1134+
{"identifier": entity_id},
1135+
)
1136+
assert_mcp_success(delete_result, "verified entity test cleanup")
1137+
logger.info("Automation creation verified entity test passed")

0 commit comments

Comments
 (0)