Skip to content

Commit 5d1efe2

Browse files
Patch76claude
andauthored
fix: return actionable not-found for ha_config_set_label with an unknown label_id (#1926)
ha_config_set_label routed create vs update purely on whether label_id was passed (action = "update" if label_id else "create"). Passing a label_id for a not-yet-existing label therefore dispatched config/label_registry/update, which HA rejects with an opaque "Command failed: Unknown error" — the tool's not-found branch keys on "not found"/"doesn't exist" substrings and never matched HA's actual error text, so callers saw a generic SERVICE_CALL_FAILED (the report has an agent retrying 23 times before it found the create path). Verify the label_id exists in the registry before routing to update, and raise RESOURCE_NOT_FOUND with an "omit label_id to create" hint when it does not. config/label_registry/create cannot honor a caller-supplied id anyway (HA's create schema takes only name/color/icon/description and derives the id from the name), so the contract stays strictly update-only rather than upserting to a differently-derived id. Extract the shared registry list into _list_labels() (used by get and set); it raises on a non-list envelope rather than collapsing to an empty registry, so a degraded fetch is never misreported as "label missing". Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 69b40af commit 5d1efe2

4 files changed

Lines changed: 385 additions & 18 deletions

File tree

src/ha_mcp/tools/tools_labels.py

Lines changed: 82 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,73 @@ class LabelTools:
3131
def __init__(self, client: Any) -> None:
3232
self._client = client
3333

34+
async def _list_labels(
35+
self, context: dict[str, Any] | None = None
36+
) -> list[dict[str, Any]]:
37+
"""Return all labels from the registry (shared by get/set).
38+
39+
Raises ToolError (SERVICE_CALL_FAILED) if the list call fails or returns
40+
an unexpected non-list envelope — a degraded response must not collapse
41+
to an empty registry, or callers would confidently report a real label
42+
as missing (mirrors ``backup_manager._require_list``).
43+
"""
44+
result = await self._client.send_websocket_message(
45+
{"type": "config/label_registry/list"}
46+
)
47+
if not result.get("success"):
48+
raise_tool_error(
49+
create_error_response(
50+
ErrorCode.SERVICE_CALL_FAILED,
51+
result.get("error", "Failed to get labels"),
52+
context=context,
53+
)
54+
)
55+
# No ``[]`` default: a success envelope that omits ``result`` (or sends
56+
# a non-list) is a degraded response, not an empty registry — defaulting
57+
# would let callers confidently report a real label as missing.
58+
labels = result.get("result")
59+
if not isinstance(labels, list):
60+
raise_tool_error(
61+
create_error_response(
62+
ErrorCode.SERVICE_CALL_FAILED,
63+
"Label registry returned a missing or non-list result",
64+
context=context,
65+
)
66+
)
67+
return labels
68+
69+
async def _require_existing_label(self, label_id: str, name: str) -> None:
70+
"""Raise RESOURCE_NOT_FOUND if ``label_id`` is not in the registry.
71+
72+
Enforces the strict update-only contract (issue #1860): update of an
73+
unknown id yields an opaque "Unknown error", and create cannot honor a
74+
caller-supplied id (HA derives it from the name), so an unknown id is a
75+
clear error with a create hint rather than a silently divergent upsert.
76+
"""
77+
existing = await self._list_labels(context={"name": name, "label_id": label_id})
78+
if any(lbl.get("label_id") == label_id for lbl in existing):
79+
return
80+
raise_tool_error(
81+
create_error_response(
82+
ErrorCode.RESOURCE_NOT_FOUND,
83+
f"Label not found: {label_id}",
84+
context={
85+
"name": name,
86+
"label_id": label_id,
87+
"available_label_ids": [
88+
lbl.get("label_id") for lbl in existing[:10]
89+
],
90+
},
91+
suggestions=[
92+
"To create a new label, omit label_id — Home Assistant "
93+
+ "derives the id from the name (e.g. 'vendor:tapo' becomes "
94+
+ "'vendor_tapo')",
95+
"To update an existing label, pass its exact current "
96+
+ "label_id (list all with ha_config_get_label())",
97+
],
98+
)
99+
)
100+
34101
@tool(
35102
name="ha_config_get_label",
36103
tags={"Labels & Categories"},
@@ -82,22 +149,7 @@ async def ha_config_get_label(
82149
],
83150
context={"action": "get"},
84151
)
85-
message: dict[str, Any] = {
86-
"type": "config/label_registry/list",
87-
}
88-
89-
result = await self._client.send_websocket_message(message)
90-
91-
if not result.get("success"):
92-
raise_tool_error(
93-
create_error_response(
94-
ErrorCode.SERVICE_CALL_FAILED,
95-
result.get("error", "Failed to get labels"),
96-
context={"label_id": label_id},
97-
)
98-
)
99-
100-
labels = result.get("result", [])
152+
labels = await self._list_labels(context={"label_id": label_id})
101153

102154
if label_id is None:
103155
return {
@@ -223,6 +275,13 @@ async def ha_config_set_label(
223275
],
224276
context={"action": "set", "name": name},
225277
)
278+
# Strict update-only contract (issue #1860): routing to
279+
# label_registry/update with an unknown id returns an opaque
280+
# "Unknown error", and label_registry/create cannot honor a
281+
# caller-supplied id (HA derives it from the name). Verify the
282+
# id exists up front and return actionable guidance instead of
283+
# dispatching an update that fails cryptically.
284+
await self._require_existing_label(label_id, name)
226285
action = "update" if label_id else "create"
227286

228287
message: dict[str, Any] = {
@@ -254,6 +313,13 @@ async def ha_config_set_label(
254313
"message": f"Successfully {action_past} label: {name}",
255314
}
256315
else:
316+
# The unknown-id case is caught up front by
317+
# _require_existing_label. This substring match only catches HA
318+
# error texts containing "not found"/"doesn't exist"; HA's label
319+
# update does NOT emit those for an unknown/deleted id (it
320+
# surfaces "Unknown error"), so it is a best-effort guard for
321+
# other/future phrasings only — the check-then-update delete race
322+
# is not handled here and still surfaces as SERVICE_CALL_FAILED.
257323
error_str = str(result.get("error", "")).lower()
258324
if "not found" in error_str or "doesn't exist" in error_str:
259325
raise_tool_error(

tests/src/e2e/workflows/config/test_label_crud.py

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -240,6 +240,42 @@ async def test_get_nonexistent_label(self, mcp_client):
240240
)
241241
logger.info("Non-existent label properly returned error")
242242

243+
async def test_set_label_with_unknown_id_returns_not_found_1860(self, mcp_client):
244+
"""Regression for #1860: ha_config_set_label with a label_id that does
245+
not exist must return a structured RESOURCE_NOT_FOUND that points at the
246+
create path — NOT the opaque "Failed to update label: Command failed:
247+
Unknown error" HA emits when label_registry/update receives an unknown
248+
id (an agent in the report retried 23 times before finding the create
249+
path).
250+
251+
Source path: tools_labels.py — set_label now verifies the id exists via
252+
_list_labels() before routing to update, and raises RESOURCE_NOT_FOUND
253+
with an "omit label_id" suggestion when it does not.
254+
"""
255+
logger.info("Testing set_label with unknown label_id (#1860)")
256+
257+
data = await safe_call_tool(
258+
mcp_client,
259+
"ha_config_set_label",
260+
{"name": "vendor:tapo", "label_id": "vendor_nonexistent_1860_xyz"},
261+
)
262+
263+
assert data.get("success") is False, f"Should fail for unknown label_id: {data}"
264+
assert data["error"]["code"] == "RESOURCE_NOT_FOUND", (
265+
f"Expected RESOURCE_NOT_FOUND (not the opaque update failure), "
266+
f"got: {data['error']}"
267+
)
268+
error_msg = data["error"]["message"].lower()
269+
assert "unknown error" not in error_msg, (
270+
f"The opaque HA update failure must not leak through: {data['error']}"
271+
)
272+
# Actionable guidance must steer the caller to the create path.
273+
suggestion = str(data["error"].get("suggestion", "")).lower()
274+
assert "omit label_id" in suggestion, (
275+
f"Expected an 'omit label_id' create hint, got: {data['error']}"
276+
)
277+
logger.info("Unknown label_id returned actionable RESOURCE_NOT_FOUND (#1860)")
278+
243279
async def test_delete_nonexistent_label(self, mcp_client):
244280
"""Test deleting a non-existent label."""
245281
logger.info("Testing delete non-existent label")

tests/src/unit/test_error_code_consistency_1297.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -263,9 +263,12 @@ def tools(self, mock_ws_client):
263263
return LabelTools(mock_ws_client)
264264

265265
async def test_set_update_with_missing_label_id(self, tools, mock_ws_client):
266+
# set_label lists the registry first (issue #1860); "missing" is absent,
267+
# so the existence pre-check short-circuits to RESOURCE_NOT_FOUND before
268+
# any label_registry/update call is dispatched.
266269
mock_ws_client.send_websocket_message.return_value = {
267-
"success": False,
268-
"error": "Label not found",
270+
"success": True,
271+
"result": [{"label_id": "other", "name": "Other"}],
269272
}
270273

271274
with pytest.raises(ToolError) as exc_info:

0 commit comments

Comments
 (0)