Skip to content

Commit 9a8b68c

Browse files
committed
fix(addons): complete Supervisor error hardening
1 parent e79ea30 commit 9a8b68c

2 files changed

Lines changed: 93 additions & 11 deletions

File tree

src/ha_mcp/tools/tools_addons.py

Lines changed: 28 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,12 @@
6969
# Supervisor is local to the app network, so connection acquisition should
7070
# remain short even when an app operation needs a multi-minute response budget.
7171
_SUPERVISOR_ACQUIRE_TIMEOUT = 10.0
72+
_SUPERVISOR_AVAILABILITY_SUGGESTION = (
73+
"Check Home Assistant connection and Supervisor availability"
74+
)
75+
_SUPERVISOR_RESPONSE_TRUNCATION_SUFFIX = (
76+
f"\n[Supervisor response truncated to {_MAX_RESPONSE_SIZE // 1024} KiB]"
77+
)
7278

7379
# Hard safety cap on WebSocket messages collected per call. `message_limit`
7480
# can lower this but never raise it.
@@ -310,6 +316,15 @@ def _supervisor_invalid_response(
310316
return _supervisor_rest_failure(response, error)
311317

312318

319+
def _bounded_supervisor_response_text(response: httpx.Response) -> str:
320+
"""Return a model-safe Supervisor response body with visible truncation."""
321+
body = response.text.strip()
322+
if len(body) <= _MAX_RESPONSE_SIZE:
323+
return body
324+
prefix_size = _MAX_RESPONSE_SIZE - len(_SUPERVISOR_RESPONSE_TRUNCATION_SUFFIX)
325+
return body[:prefix_size] + _SUPERVISOR_RESPONSE_TRUNCATION_SUFFIX
326+
327+
313328
def _normalize_supervisor_rest_response(
314329
response: httpx.Response,
315330
endpoint: str,
@@ -321,7 +336,7 @@ def _normalize_supervisor_rest_response(
321336
300 <= response.status_code < 400 or response.status_code >= 500
322337
)
323338
if write_outcome_unknown:
324-
response_body = response.text.strip()[:_MAX_RESPONSE_SIZE]
339+
response_body = _bounded_supervisor_response_text(response)
325340
_raise_supervisor_write_outcome_unknown(
326341
ErrorCode.SERVICE_CALL_FAILED,
327342
f"Supervisor API {verb} {endpoint} returned HTTP "
@@ -335,7 +350,7 @@ def _normalize_supervisor_rest_response(
335350
try:
336351
payload = response.json()
337352
except ValueError:
338-
body = response.text.strip()
353+
body = _bounded_supervisor_response_text(response)
339354
error_message = (
340355
body or f"Supervisor returned invalid JSON (HTTP {response.status_code})"
341356
)
@@ -750,21 +765,24 @@ async def _supervisor_api_call(
750765
raise
751766
except Exception as e:
752767
logger.error(f"Error calling Supervisor API {endpoint}: {e}")
753-
suggestions = None
754-
if isinstance(e, HomeAssistantAPIError) and e.status_code == 404:
755-
suggestions = [
756-
"Check Home Assistant connection and Supervisor availability"
757-
]
758-
exception_to_structured_error(
768+
error_response = exception_to_structured_error(
759769
e,
760770
context={
761771
"endpoint": endpoint,
762772
"operation": f"Supervisor API {endpoint}",
763773
"timeout_seconds": wait_timeout,
764774
},
765-
suggestions=suggestions,
775+
raise_error=False,
766776
)
767-
return None # unreachable: exception_to_structured_error always raises
777+
error_details = error_response.get("error")
778+
if (
779+
isinstance(error_details, dict)
780+
and error_details.get("code") == ErrorCode.RESOURCE_NOT_FOUND.value
781+
):
782+
error_details["suggestion"] = _SUPERVISOR_AVAILABILITY_SUGGESTION
783+
error_details["suggestions"] = [_SUPERVISOR_AVAILABILITY_SUGGESTION]
784+
raise_tool_error(error_response)
785+
return None # unreachable: raise_tool_error always raises
768786

769787

770788
def _addon_connection_failure_suggestions(

tests/src/unit/test_tools_addons.py

Lines changed: 65 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4292,6 +4292,32 @@ async def test_addon_mode_preserves_direct_http_status_classification(
42924292

42934293
client.send_websocket_message.assert_not_awaited()
42944294

4295+
@pytest.mark.asyncio
4296+
async def test_websocket_not_found_preserves_supervisor_suggestion(
4297+
self, monkeypatch
4298+
):
4299+
"""Core-routed not-found errors retain Supervisor recovery guidance."""
4300+
from ha_mcp.tools.tools_addons import _supervisor_api_call
4301+
4302+
monkeypatch.delenv("SUPERVISOR_TOKEN", raising=False)
4303+
client = _make_mock_client()
4304+
client.send_websocket_message = AsyncMock(
4305+
return_value={
4306+
"success": False,
4307+
"error": "Command failed: Add-on not found",
4308+
}
4309+
)
4310+
4311+
with pytest.raises(ToolError) as exc_info:
4312+
await _supervisor_api_call(client, "/addons/missing/info")
4313+
4314+
payload = _parse_tool_error(exc_info)
4315+
assert payload["error"]["code"] == "RESOURCE_NOT_FOUND"
4316+
assert (
4317+
payload["error"]["suggestion"]
4318+
== "Check Home Assistant connection and Supervisor availability"
4319+
)
4320+
42954321
@pytest.mark.asyncio
42964322
@pytest.mark.parametrize(
42974323
("transport_error", "expected_code"),
@@ -4557,7 +4583,45 @@ async def test_addon_mode_write_server_error_caps_response_body(self, monkeypatc
45574583
)
45584584

45594585
payload = _parse_tool_error(exc_info)
4560-
assert payload["response_body"] == "x" * (50 * 1024)
4586+
suffix = "\n[Supervisor response truncated to 50 KiB]"
4587+
assert payload["response_body"] == ("x" * (50 * 1024 - len(suffix)) + suffix)
4588+
4589+
@pytest.mark.asyncio
4590+
async def test_addon_mode_invalid_json_error_caps_response_body(self, monkeypatch):
4591+
"""A non-JSON Supervisor error cannot produce an unbounded tool error."""
4592+
from ha_mcp.tools.tools_addons import _supervisor_api_call
4593+
4594+
monkeypatch.setenv("SUPERVISOR_TOKEN", "test-supervisor-token")
4595+
client = _make_mock_client()
4596+
client.send_websocket_message = AsyncMock()
4597+
direct_client = AsyncMock()
4598+
direct_client.request.return_value = httpx.Response(
4599+
502,
4600+
text="x" * (50 * 1024 + 1),
4601+
)
4602+
context = MagicMock()
4603+
context.__aenter__ = AsyncMock(return_value=direct_client)
4604+
context.__aexit__ = AsyncMock(return_value=False)
4605+
4606+
with (
4607+
patch(
4608+
"ha_mcp.tools.tools_addons.make_supervisor_httpx_client",
4609+
return_value=context,
4610+
create=True,
4611+
),
4612+
pytest.raises(ToolError) as exc_info,
4613+
):
4614+
await _supervisor_api_call(client, "/addons")
4615+
4616+
payload = _parse_tool_error(exc_info)
4617+
assert payload["error"]["code"] == "SERVICE_CALL_FAILED"
4618+
prefix = "Command failed: "
4619+
message = payload["error"]["message"]
4620+
assert message.startswith(prefix)
4621+
suffix = "\n[Supervisor response truncated to 50 KiB]"
4622+
assert message.removeprefix(prefix) == (
4623+
"x" * (50 * 1024 - len(suffix)) + suffix
4624+
)
45614625

45624626
@pytest.mark.asyncio
45634627
async def test_addon_mode_write_redirect_reports_unknown_outcome(self, monkeypatch):

0 commit comments

Comments
 (0)