Skip to content

Commit 9e6988a

Browse files
fix: return RESOURCE_NOT_FOUND instead of false success on dashboard deletion (#680)
* fix: return RESOURCE_NOT_FOUND instead of false success on dashboard deletion ha_config_delete_dashboard and ha_config_delete_dashboard_resource previously returned success:true when the target didn't exist, misleading AI agents into believing the operation succeeded. Changes: - ha_config_delete_dashboard now resolves both url_path and internal ID, returning RESOURCE_NOT_FOUND when neither matches - ha_config_delete_dashboard_resource now returns RESOURCE_NOT_FOUND instead of false success on non-existent resources - Both tools use structured error helpers and exception_to_structured_error - Removed idempotentHint from both tools (no longer silently succeed) - Updated E2E tests to expect RESOURCE_NOT_FOUND on non-existent targets Closes #671 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: update unit test to expect RESOURCE_NOT_FOUND on missing resource 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 9f9fbf5 commit 9e6988a

5 files changed

Lines changed: 98 additions & 101 deletions

File tree

src/ha_mcp/tools/tools_config_dashboards.py

Lines changed: 59 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
from pydantic import Field
1717

1818
from ..config import get_global_settings
19-
from ..errors import ErrorCode, create_error_response
19+
from ..errors import ErrorCode, create_error_response, create_resource_not_found_error
2020
from ..utils.python_sandbox import (
2121
PythonSandboxError,
2222
get_security_documentation,
@@ -1144,7 +1144,6 @@ async def ha_config_set_dashboard(
11441144
@mcp.tool(
11451145
annotations={
11461146
"destructiveHint": True,
1147-
"idempotentHint": True,
11481147
"tags": ["dashboard"],
11491148
"title": "Delete Dashboard",
11501149
}
@@ -1153,7 +1152,9 @@ async def ha_config_set_dashboard(
11531152
async def ha_config_delete_dashboard(
11541153
dashboard_id: Annotated[
11551154
str,
1156-
Field(description="Dashboard ID to delete (typically same as url_path)"),
1155+
Field(
1156+
description="Dashboard ID or URL path to delete (e.g., 'my-dashboard' or 'my_dashboard')"
1157+
),
11571158
],
11581159
) -> dict[str, Any]:
11591160
"""
@@ -1162,14 +1163,49 @@ async def ha_config_delete_dashboard(
11621163
WARNING: This permanently deletes the dashboard and all its configuration.
11631164
Cannot be undone. Does not work on YAML-mode dashboards.
11641165
1166+
Accepts either the internal dashboard ID or the URL path.
1167+
The tool resolves url_path to internal ID automatically.
1168+
11651169
EXAMPLES:
11661170
- Delete dashboard: ha_config_delete_dashboard("mobile-dashboard")
11671171
11681172
Note: The default dashboard cannot be deleted via this method.
11691173
"""
11701174
try:
1175+
# Fetch dashboard list to resolve the provided identifier.
1176+
# HA internal IDs may differ from url_path (e.g. hyphens → underscores),
1177+
# so we accept either and resolve to the actual registry ID.
1178+
list_result = await client.send_websocket_message(
1179+
{"type": "lovelace/dashboards/list"}
1180+
)
1181+
if isinstance(list_result, dict) and "result" in list_result:
1182+
dashboards = list_result["result"]
1183+
elif isinstance(list_result, list):
1184+
dashboards = list_result
1185+
else:
1186+
dashboards = []
1187+
1188+
resolved_id = None
1189+
for d in dashboards:
1190+
if d.get("id") == dashboard_id:
1191+
resolved_id = d["id"]
1192+
break
1193+
if d.get("url_path") == dashboard_id:
1194+
resolved_id = d["id"]
1195+
break
1196+
1197+
if resolved_id is None:
1198+
return create_resource_not_found_error(
1199+
"Dashboard",
1200+
dashboard_id,
1201+
details=(
1202+
f"No dashboard found with ID or URL path '{dashboard_id}'. "
1203+
"Use ha_config_get_dashboard(list_only=True) to see available dashboards."
1204+
),
1205+
)
1206+
11711207
response = await client.send_websocket_message(
1172-
{"type": "lovelace/dashboards/delete", "dashboard_id": dashboard_id}
1208+
{"type": "lovelace/dashboards/delete", "dashboard_id": resolved_id}
11731209
)
11741210

11751211
# Check response for error indication
@@ -1182,68 +1218,39 @@ async def ha_config_delete_dashboard(
11821218

11831219
logger.error(f"Error deleting dashboard: {error_str}")
11841220

1185-
# If the error is "not found" / "doesn't exist", treat as success (idempotent)
1186-
if (
1187-
"unable to find" in error_str.lower()
1188-
or "not found" in error_str.lower()
1189-
):
1190-
return {
1191-
"success": True,
1192-
"action": "delete",
1193-
"dashboard_id": dashboard_id,
1194-
"message": "Dashboard already deleted or does not exist",
1195-
}
1196-
1197-
# For other errors, return failure
1198-
return {
1199-
"success": False,
1200-
"action": "delete",
1201-
"dashboard_id": dashboard_id,
1202-
"error": error_str,
1203-
"suggestions": [
1221+
return create_error_response(
1222+
code=ErrorCode.SERVICE_CALL_FAILED,
1223+
message=f"Failed to delete dashboard: {error_str}",
1224+
context={"action": "delete", "dashboard_id": dashboard_id},
1225+
suggestions=[
12041226
"Verify dashboard exists and is storage-mode",
12051227
"Check that you have admin permissions",
12061228
"Use ha_config_get_dashboard(list_only=True) to see available dashboards",
12071229
"Cannot delete YAML-mode or default dashboard",
12081230
],
1209-
}
1231+
)
12101232

12111233
# Delete successful
1212-
return {
1234+
result: dict[str, Any] = {
12131235
"success": True,
12141236
"action": "delete",
12151237
"dashboard_id": dashboard_id,
12161238
"message": "Dashboard deleted successfully",
12171239
}
1240+
if resolved_id != dashboard_id:
1241+
result["resolved_id"] = resolved_id
1242+
return result
12181243
except Exception as e:
1219-
error_str = str(e)
1220-
logger.error(f"Error deleting dashboard: {error_str}")
1221-
1222-
# If the error is "not found" / "doesn't exist", treat as success (idempotent)
1223-
if (
1224-
"unable to find" in error_str.lower()
1225-
or "not found" in error_str.lower()
1226-
):
1227-
return {
1228-
"success": True,
1229-
"action": "delete",
1230-
"dashboard_id": dashboard_id,
1231-
"message": "Dashboard already deleted or does not exist",
1232-
}
1233-
1234-
# For other errors, return failure
1235-
return {
1236-
"success": False,
1237-
"action": "delete",
1238-
"dashboard_id": dashboard_id,
1239-
"error": error_str,
1240-
"suggestions": [
1241-
"Verify dashboard exists and is storage-mode",
1242-
"Check that you have admin permissions",
1243-
"Use ha_config_get_dashboard(list_only=True) to see available dashboards",
1244-
"Cannot delete YAML-mode or default dashboard",
1244+
logger.error(f"Error deleting dashboard: {e}")
1245+
return exception_to_structured_error(
1246+
e,
1247+
context={"action": "delete", "dashboard_id": dashboard_id},
1248+
raise_error=False,
1249+
suggestions=[
1250+
"Check Home Assistant connection",
1251+
"Verify dashboard exists with ha_config_get_dashboard(list_only=True)",
12451252
],
1246-
}
1253+
)
12471254

12481255
@mcp.tool(
12491256
annotations={

src/ha_mcp/tools/tools_resources.py

Lines changed: 27 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,8 @@
1414

1515
from pydantic import Field
1616

17-
from ..errors import ErrorCode, create_error_response
18-
from .helpers import log_tool_usage
17+
from ..errors import ErrorCode, create_error_response, create_resource_not_found_error
18+
from .helpers import exception_to_structured_error, log_tool_usage
1919

2020
logger = logging.getLogger(__name__)
2121

@@ -498,7 +498,6 @@ class MyCard extends HTMLElement {
498498
@mcp.tool(
499499
annotations={
500500
"destructiveHint": True,
501-
"idempotentHint": True,
502501
"tags": ["dashboard", "resources"],
503502
"title": "Delete Dashboard Resource",
504503
}
@@ -516,8 +515,7 @@ async def ha_config_delete_dashboard_resource(
516515
Delete a dashboard resource.
517516
518517
Removes a resource from Home Assistant. The resource will no longer
519-
be loaded on dashboards. This operation is idempotent - deleting
520-
a non-existent resource will succeed.
518+
be loaded on dashboards.
521519
522520
WARNING: Deleting a resource used by custom cards in your dashboards
523521
will cause those cards to fail to load.
@@ -544,21 +542,25 @@ async def ha_config_delete_dashboard_resource(
544542
else:
545543
error_str = str(error_msg)
546544

547-
# If "not found", treat as success (idempotent)
548545
if "not found" in error_str.lower() or "unable to find" in error_str.lower():
549-
return {
550-
"success": True,
551-
"action": "delete",
552-
"resource_id": resource_id,
553-
"message": "Resource already deleted or does not exist",
554-
}
546+
return create_resource_not_found_error(
547+
"Dashboard resource",
548+
resource_id,
549+
details=(
550+
f"Resource '{resource_id}' not found. "
551+
"Use ha_config_list_dashboard_resources() to see available resources."
552+
),
553+
)
555554

556-
return {
557-
"success": False,
558-
"action": "delete",
559-
"resource_id": resource_id,
560-
"error": error_str,
561-
}
555+
return create_error_response(
556+
code=ErrorCode.SERVICE_CALL_FAILED,
557+
message=f"Failed to delete dashboard resource: {error_str}",
558+
context={"action": "delete", "resource_id": resource_id},
559+
suggestions=[
560+
"Verify resource ID using ha_config_list_dashboard_resources()",
561+
"Check that you have admin permissions",
562+
],
563+
)
562564

563565
logger.info(f"Dashboard resource deleted: id={resource_id}")
564566

@@ -569,25 +571,13 @@ async def ha_config_delete_dashboard_resource(
569571
"message": "Resource deleted successfully",
570572
}
571573
except Exception as e:
572-
error_str = str(e)
573-
logger.error(f"Error deleting dashboard resource: {error_str}")
574-
575-
# If "not found", treat as success (idempotent)
576-
if "not found" in error_str.lower() or "unable to find" in error_str.lower():
577-
return {
578-
"success": True,
579-
"action": "delete",
580-
"resource_id": resource_id,
581-
"message": "Resource already deleted or does not exist",
582-
}
583-
584-
return {
585-
"success": False,
586-
"action": "delete",
587-
"resource_id": resource_id,
588-
"error": error_str,
589-
"suggestions": [
574+
logger.error(f"Error deleting dashboard resource: {e}")
575+
return exception_to_structured_error(
576+
e,
577+
context={"action": "delete", "resource_id": resource_id},
578+
raise_error=False,
579+
suggestions=[
590580
"Verify resource ID using ha_config_list_dashboard_resources()",
591581
"Check that you have admin permissions",
592582
],
593-
}
583+
)

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

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -344,17 +344,16 @@ async def test_get_nonexistent_dashboard(self, mcp_client):
344344
logger.info("Get nonexistent dashboard test completed successfully")
345345

346346
async def test_delete_nonexistent_dashboard(self, mcp_client):
347-
"""Test deleting non-existent dashboard."""
347+
"""Test deleting non-existent dashboard returns RESOURCE_NOT_FOUND."""
348348
logger.info("Starting delete nonexistent dashboard test")
349349

350350
result = await mcp_client.call_tool(
351351
"ha_config_delete_dashboard",
352352
{"dashboard_id": "nonexistent-dashboard-67890"},
353353
)
354354
data = parse_mcp_result(result)
355-
# Home Assistant handles delete as idempotent - deleting nonexistent item succeeds
356-
# This is expected behavior and consistent with other HA operations
357-
assert data["success"] is True
355+
assert data["success"] is False
356+
assert data["error"]["code"] == "RESOURCE_NOT_FOUND"
358357

359358
logger.info("Delete nonexistent dashboard test completed successfully")
360359

tests/src/e2e/workflows/dashboards/test_resources.py

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -259,16 +259,17 @@ async def test_invalid_resource_type(self, mcp_client):
259259
logger.info("Invalid resource type test completed successfully")
260260

261261
async def test_delete_nonexistent_resource(self, mcp_client):
262-
"""Test that deleting nonexistent resource is idempotent (succeeds)."""
262+
"""Test that deleting nonexistent resource returns RESOURCE_NOT_FOUND."""
263263
logger.info("Starting delete nonexistent resource test")
264264
mcp = MCPAssertions(mcp_client)
265265

266-
# Deleting a resource that doesn't exist should succeed (idempotent)
267-
delete_data = await mcp.call_tool_success(
266+
# Deleting a resource that doesn't exist should return RESOURCE_NOT_FOUND
267+
delete_data = await mcp.call_tool_failure(
268268
"ha_config_delete_dashboard_resource",
269269
{"resource_id": "nonexistent-resource-id-12345"},
270+
expected_error="not found",
270271
)
271-
assert delete_data["success"] is True
272+
assert delete_data["success"] is False
272273

273274
logger.info("Delete nonexistent resource test completed successfully")
274275

tests/src/unit/test_tools_resources.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -411,17 +411,17 @@ async def test_delete_success(self, delete_tool, mock_client):
411411
assert result["resource_id"] == "abc123"
412412

413413
@pytest.mark.asyncio
414-
async def test_delete_idempotent_not_found(self, delete_tool, mock_client):
415-
"""Test that deleting non-existent resource is idempotent."""
414+
async def test_delete_not_found_returns_error(self, delete_tool, mock_client):
415+
"""Test that deleting non-existent resource returns RESOURCE_NOT_FOUND."""
416416
mock_client.send_websocket_message.return_value = {
417417
"success": False,
418418
"error": {"message": "Resource not found"},
419419
}
420420

421421
result = await delete_tool(resource_id="nonexistent")
422422

423-
assert result["success"] is True # Idempotent
424-
assert "already deleted" in result["message"].lower()
423+
assert result["success"] is False
424+
assert result["error"]["code"] == "RESOURCE_NOT_FOUND"
425425

426426

427427
class TestToolRegistration:

0 commit comments

Comments
 (0)