Skip to content

Commit c4a367a

Browse files
fix: pooled-WS connection-error residue and verify_ssl pool-key split (#1833)
* fix: pooled-WS connection-error residue and verify_ssl pool-key split Follow-up to the two review notes on #1832: - shared is_connection_error_message() helper (extracted from the history classifier); calendar's WS sites re-raise transport-shaped failures as HomeAssistantConnectionError and both calendar error handlers return connectivity guidance instead of rrule/uid hints; radio's _resolve_entity_device raises CONNECTION_FAILED for a transport drop instead of ENTITY_NOT_FOUND - WebSocketManager.get_client keys on the EFFECTIVE verify_ssl (None resolves to the settings default), so callers passing the resolved default share the pooled connection with callers that omit the argument; only genuine overrides get an isolated connection Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DdbHee9XY1vr1277vcJsdt * fix: review-round hardening for the connection classifier and pool key - is_connection_error_message stringifies its payload (HA error frames can carry a dict or None in the error slot) - pool-key settings lookup falls back to verify_ssl=True on settings load failure, mirroring HomeAssistantWebSocketClient.__init__ (extracted to _effective_verify_ssl to stay under the complexity cap) - broaden the connection signatures with the pool's own close messages: 'closed while waiting', 'connection dropped', 'not authenticated' Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DdbHee9XY1vr1277vcJsdt --------- Co-authored-by: kingpanther13 <kingpanther13@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent a81cd44 commit c4a367a

8 files changed

Lines changed: 250 additions & 47 deletions

File tree

src/ha_mcp/client/websocket_client.py

Lines changed: 27 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1083,6 +1083,23 @@ def _client_key(url: str, token: str) -> str:
10831083
"""Create a cache key from credentials."""
10841084
return hashlib.sha256(f"{url.rstrip('/')}:{token}".encode()).hexdigest()
10851085

1086+
@staticmethod
1087+
def _effective_verify_ssl(verify_ssl: bool | None) -> bool:
1088+
"""Resolve the effective TLS-verification mode for the pool key."""
1089+
if verify_ssl is not None:
1090+
return verify_ssl
1091+
try:
1092+
return bool(get_global_settings().verify_ssl)
1093+
except Exception as e:
1094+
# Mirror HomeAssistantWebSocketClient.__init__: a bad env var
1095+
# elsewhere should not crash pooling or silently flip TLS off.
1096+
logger.warning(
1097+
"Could not load settings while resolving the pool verify_ssl "
1098+
"key (%s); falling back to verify_ssl=True.",
1099+
e,
1100+
)
1101+
return True
1102+
10861103
async def get_client(
10871104
self,
10881105
url: str | None = None,
@@ -1138,9 +1155,16 @@ async def get_client(
11381155
ws_url = settings.homeassistant_url
11391156
ws_token = settings.homeassistant_token
11401157

1141-
key = self._client_key(ws_url, ws_token)
1142-
if verify_ssl is not None:
1143-
key = f"{key}|verify_ssl={verify_ssl}"
1158+
# Key on the EFFECTIVE verification mode: a caller passing the
1159+
# resolved settings default (send_websocket_message) must share
1160+
# the pooled connection with callers that omit the argument
1161+
# (listener, HACS, installer) — only a genuine override such as
1162+
# verify_ssl=False gets its own isolated connection.
1163+
effective_verify_ssl = self._effective_verify_ssl(verify_ssl)
1164+
key = (
1165+
f"{self._client_key(ws_url, ws_token)}"
1166+
f"|verify_ssl={effective_verify_ssl}"
1167+
)
11441168

11451169
# Return existing connected client for these credentials
11461170
existing = self._clients.get(key)

src/ha_mcp/tools/tools_calendar.py

Lines changed: 61 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,10 @@
1515
from fastmcp.tools import tool
1616
from pydantic import Field
1717

18-
from ..client.rest_client import HomeAssistantCommandError
18+
from ..client.rest_client import (
19+
HomeAssistantCommandError,
20+
HomeAssistantConnectionError,
21+
)
1922
from ..errors import ErrorCode, create_error_response
2023
from .auto_backup import with_auto_backup
2124
from .helpers import (
@@ -25,6 +28,7 @@
2528
register_tool_methods,
2629
validate_identifier_not_empty,
2730
)
31+
from .util_helpers import is_connection_error_message
2832

2933
logger = logging.getLogger(__name__)
3034

@@ -201,17 +205,20 @@ async def _create_recurring_calendar_event(
201205

202206
# Route through the shared pooled WebSocket (issue #1813) instead of a
203207
# dedicated connect/auth handshake per call. The pooled path collapses a
204-
# failed WS command into ``{"success": False, "error": ...}``; re-raise
205-
# it as the same ``HomeAssistantCommandError`` the dedicated send_command
206-
# used to raise so the caller's error handler still attaches the
207-
# rrule-specific suggestions.
208+
# failed WS command into ``{"success": False, "error": ...}``; a
209+
# transport-shaped failure re-raises as ``HomeAssistantConnectionError``
210+
# (the classifier type-matches it to connectivity guidance), anything
211+
# else as the same ``HomeAssistantCommandError`` the dedicated
212+
# send_command used to raise so the caller's error handler still
213+
# attaches the rrule-specific suggestions.
208214
result = await self._client.send_websocket_message(
209215
{"type": "calendar/event/create", "entity_id": entity_id, "event": event}
210216
)
211217
if not result.get("success"):
212-
raise HomeAssistantCommandError(
213-
str(result.get("error", "calendar/event/create failed"))
214-
)
218+
error = str(result.get("error", "calendar/event/create failed"))
219+
if is_connection_error_message(error):
220+
raise HomeAssistantConnectionError(error)
221+
raise HomeAssistantCommandError(error)
215222
return result
216223

217224
async def _create_simple_calendar_event(
@@ -242,6 +249,13 @@ def _build_set_calendar_event_error_suggestions(
242249
self, entity_id: str, rrule: str | None, error: Exception
243250
) -> list[str]:
244251
"""Build suggestions for a failed ha_config_set_calendar_event call."""
252+
if isinstance(error, HomeAssistantConnectionError):
253+
# A transport drop is not a calendar problem — domain hints would
254+
# send the agent chasing a non-issue during an HA restart.
255+
return [
256+
"Home Assistant may be restarting or unreachable — retry shortly",
257+
"Check the connection to Home Assistant",
258+
]
245259
suggestions = [
246260
f"Verify calendar entity '{entity_id}' exists and supports event creation",
247261
"Check datetime format (ISO 8601)",
@@ -527,17 +541,20 @@ async def ha_config_remove_calendar_event(
527541
ws_kwargs["recurrence_range"] = recurrence_range
528542

529543
# Route through the shared pooled WebSocket (issue #1813) instead of a
530-
# dedicated connect/auth handshake per call. Re-raise a failed WS
531-
# command as the same ``HomeAssistantCommandError`` the dedicated
532-
# send_command used to raise so the outer handler builds the
533-
# delete-specific suggestions (404 / not-supported).
544+
# dedicated connect/auth handshake per call. Transport-shaped
545+
# failures raise ``HomeAssistantConnectionError`` (connectivity
546+
# guidance); anything else re-raises as the same
547+
# ``HomeAssistantCommandError`` the dedicated send_command used to
548+
# raise so the outer handler builds the delete-specific
549+
# suggestions (404 / not-supported).
534550
result = await self._client.send_websocket_message(
535551
{"type": "calendar/event/delete", **ws_kwargs}
536552
)
537553
if not result.get("success"):
538-
raise HomeAssistantCommandError(
539-
str(result.get("error", "calendar/event/delete failed"))
540-
)
554+
error = str(result.get("error", "calendar/event/delete failed"))
555+
if is_connection_error_message(error):
556+
raise HomeAssistantConnectionError(error)
557+
raise HomeAssistantCommandError(error)
541558

542559
return {
543560
"success": True,
@@ -554,28 +571,41 @@ async def ha_config_remove_calendar_event(
554571
except Exception as error:
555572
logger.error(f"Failed to delete calendar event from {entity_id}: {error}")
556573

557-
suggestions = [
558-
f"Verify calendar entity '{entity_id}' exists",
559-
f"Verify event with UID '{uid}' exists in the calendar",
560-
"Use ha_config_get_calendar_events() to find the correct event UID",
561-
"Some calendar integrations may not support event deletion",
562-
]
563-
564-
error_str = str(error)
565-
if "404" in error_str or "not found" in error_str.lower():
566-
suggestions.insert(
567-
0, f"Calendar entity '{entity_id}' or event '{uid}' not found"
568-
)
569-
if "not supported" in error_str.lower():
570-
suggestions.insert(0, "This calendar does not support event deletion")
571-
572574
exception_to_structured_error(
573575
error,
574576
context={"entity_id": entity_id, "uid": uid},
575-
suggestions=suggestions,
577+
suggestions=self._build_remove_calendar_event_error_suggestions(
578+
entity_id, uid, error
579+
),
576580
)
577581
return None # unreachable: exception_to_structured_error always raises
578582

583+
def _build_remove_calendar_event_error_suggestions(
584+
self, entity_id: str, uid: str, error: Exception
585+
) -> list[str]:
586+
"""Build suggestions for a failed ha_config_remove_calendar_event call."""
587+
if isinstance(error, HomeAssistantConnectionError):
588+
# A transport drop is not a calendar problem — domain hints would
589+
# send the agent chasing a non-issue during an HA restart.
590+
return [
591+
"Home Assistant may be restarting or unreachable — retry shortly",
592+
"Check the connection to Home Assistant",
593+
]
594+
suggestions = [
595+
f"Verify calendar entity '{entity_id}' exists",
596+
f"Verify event with UID '{uid}' exists in the calendar",
597+
"Use ha_config_get_calendar_events() to find the correct event UID",
598+
"Some calendar integrations may not support event deletion",
599+
]
600+
error_str = str(error)
601+
if "404" in error_str or "not found" in error_str.lower():
602+
suggestions.insert(
603+
0, f"Calendar entity '{entity_id}' or event '{uid}' not found"
604+
)
605+
if "not supported" in error_str.lower():
606+
suggestions.insert(0, "This calendar does not support event deletion")
607+
return suggestions
608+
579609

580610
def register_calendar_tools(mcp: Any, client: Any, **kwargs: Any) -> None:
581611
"""Register calendar management tools with the MCP server."""

src/ha_mcp/tools/tools_history.py

Lines changed: 2 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
JSON_STRING_COERCION,
3333
add_timezone_metadata,
3434
build_pagination_metadata,
35+
is_connection_error_message,
3536
parse_string_list_param,
3637
project_fields,
3738
)
@@ -490,16 +491,6 @@ def _parse_time_range(
490491
return start_dt, end_dt
491492

492493

493-
_WS_CONNECTION_SIGNATURES = (
494-
"failed to connect",
495-
"timed out",
496-
"timeout",
497-
"connection closed",
498-
"disconnected",
499-
"not connected",
500-
)
501-
502-
503494
def _raise_recorder_ws_failure(
504495
kind: str,
505496
error_msg: str,
@@ -513,8 +504,7 @@ def _raise_recorder_ws_failure(
513504
as CONNECTION_FAILED (retry/connectivity guidance) instead of presenting
514505
recorder-retention suggestions during an HA restart or WS outage.
515506
"""
516-
lowered = error_msg.lower()
517-
if any(sig in lowered for sig in _WS_CONNECTION_SIGNATURES):
507+
if is_connection_error_message(error_msg):
518508
raise_tool_error(
519509
create_error_response(
520510
ErrorCode.CONNECTION_FAILED,

src/ha_mcp/tools/tools_radio.py

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@
3131
from .radio import zigbee as zigbee_handler
3232
from .radio import zwave as zwave_handler
3333
from .radio.base import confirm_required, require
34-
from .util_helpers import JSON_STRING_COERCION
34+
from .util_helpers import JSON_STRING_COERCION, is_connection_error_message
3535

3636
logger = logging.getLogger(__name__)
3737

@@ -62,6 +62,20 @@ async def _resolve_entity_device(self, entity_id: str) -> str:
6262
device_id = (result.get("result") or {}).get("device_id")
6363
if device_id:
6464
return str(device_id)
65+
else:
66+
error = str(result.get("error", ""))
67+
if is_connection_error_message(error):
68+
# A transport drop is not evidence the entity is missing.
69+
raise_tool_error(
70+
create_error_response(
71+
ErrorCode.CONNECTION_FAILED,
72+
f"Could not resolve entity '{entity_id}': {error}",
73+
context={"entity_id": entity_id},
74+
suggestions=[
75+
"Home Assistant may be restarting or unreachable — retry shortly",
76+
],
77+
)
78+
)
6579
raise_tool_error(
6680
create_error_response(
6781
ErrorCode.ENTITY_NOT_FOUND,

src/ha_mcp/tools/util_helpers.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2236,3 +2236,35 @@ def merge_visibility_warnings(
22362236
if warnings:
22372237
response.setdefault("warnings", []).extend(warnings)
22382238
return response
2239+
2240+
2241+
# Error strings produced by the pooled WebSocket path when the transport (not
2242+
# the command) fails: the manager's connect raise, send timeouts, and socket
2243+
# drops. ``send_websocket_message`` collapses these into
2244+
# ``{"success": False, "error": ...}``, so callers that attach domain-specific
2245+
# suggestions must first check the shape or an HA restart gets presented as a
2246+
# domain problem (issue #1832 review).
2247+
WS_CONNECTION_SIGNATURES = (
2248+
"failed to connect",
2249+
"timed out",
2250+
"timeout",
2251+
"connection closed",
2252+
# reset_connection: "WebSocket connection to Home Assistant closed while
2253+
# waiting for a response" — "connection closed" is not adjacent there.
2254+
"closed while waiting",
2255+
# listener close reason: "connection dropped without a close frame".
2256+
"connection dropped",
2257+
"disconnected",
2258+
"not connected",
2259+
"not authenticated",
2260+
)
2261+
2262+
2263+
def is_connection_error_message(error_msg: Any) -> bool:
2264+
"""True when a pooled-WS failure payload is connection/transport-shaped.
2265+
2266+
Accepts any payload shape: HA error frames can carry a dict (or None)
2267+
in the error slot, so the value is stringified before matching.
2268+
"""
2269+
lowered = str(error_msg).lower()
2270+
return any(sig in lowered for sig in WS_CONNECTION_SIGNATURES)

tests/src/unit/test_oauth.py

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1650,3 +1650,70 @@ def test_hmac_secret_file_written_owner_only(self, tmp_data_dir):
16501650
secret_file = tmp_data_dir / "oauth_hmac_secret"
16511651
assert secret_file.exists()
16521652
assert stat.S_IMODE(secret_file.stat().st_mode) == 0o600
1653+
1654+
1655+
class TestWebSocketManagerVerifySslKey:
1656+
"""Pool-key resolution for verify_ssl (issue #1832 review follow-up)."""
1657+
1658+
@pytest.fixture(autouse=True)
1659+
def reset_manager(self):
1660+
from ha_mcp.client.websocket_client import WebSocketManager
1661+
1662+
WebSocketManager._instance = None
1663+
yield
1664+
WebSocketManager._instance = None
1665+
1666+
@pytest.mark.asyncio
1667+
async def test_defaulted_verify_ssl_shares_connection_with_omitted(self):
1668+
"""get_client(verify_ssl=<settings default>) and get_client() must key
1669+
identically — one pooled connection, not a split."""
1670+
from ha_mcp.client.websocket_client import WebSocketManager
1671+
from ha_mcp.config import get_global_settings
1672+
1673+
mock_client = MagicMock()
1674+
mock_client.is_connected = True
1675+
mock_client.connect = AsyncMock(return_value=True)
1676+
1677+
calls = []
1678+
1679+
def factory(url, token, **kwargs):
1680+
calls.append(kwargs)
1681+
return mock_client
1682+
1683+
manager = WebSocketManager()
1684+
manager.configure(client_factory=factory)
1685+
1686+
default = get_global_settings().verify_ssl
1687+
a = await manager.get_client(url="http://ha.local:8123", token="tok")
1688+
b = await manager.get_client(
1689+
url="http://ha.local:8123", token="tok", verify_ssl=default
1690+
)
1691+
assert a is b
1692+
assert len(calls) == 1
1693+
1694+
@pytest.mark.asyncio
1695+
async def test_verify_ssl_override_gets_isolated_connection(self):
1696+
"""A genuine verify_ssl override must NOT share the default pool entry."""
1697+
from ha_mcp.client.websocket_client import WebSocketManager
1698+
from ha_mcp.config import get_global_settings
1699+
1700+
default = get_global_settings().verify_ssl
1701+
1702+
clients = []
1703+
1704+
def factory(url, token, **kwargs):
1705+
c = MagicMock()
1706+
c.is_connected = True
1707+
c.connect = AsyncMock(return_value=True)
1708+
clients.append(c)
1709+
return c
1710+
1711+
manager = WebSocketManager()
1712+
manager.configure(client_factory=factory)
1713+
1714+
a = await manager.get_client(url="http://ha.local:8123", token="tok")
1715+
b = await manager.get_client(
1716+
url="http://ha.local:8123", token="tok", verify_ssl=not default
1717+
)
1718+
assert a is not b
1719+
assert len(clients) == 2

tests/src/unit/test_radio_management.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -572,3 +572,23 @@ async def test_resolve_entity_device_not_found(self):
572572
# Uses the targeted get keyed by the requested entity_id.
573573
reg = [m for m in record if m["type"] == "config/entity_registry/get"]
574574
assert reg and reg[0]["entity_id"] == "light.ghost"
575+
576+
577+
@pytest.mark.asyncio
578+
async def test_resolve_entity_device_connection_failure_not_entity_not_found():
579+
"""A transport-shaped registry-get failure must surface as a connection
580+
error, never ENTITY_NOT_FOUND (issue #1832 review)."""
581+
client = _client(
582+
{
583+
"config/entity_registry/get": {
584+
"success": False,
585+
"error": "Failed to connect to Home Assistant WebSocket",
586+
}
587+
},
588+
)
589+
radio = _capture(register_radio_tools, client)["ha_manage_radio"]
590+
with pytest.raises(ToolError) as exc:
591+
await radio(radio="matter", action="diagnostics", entity_id="light.real")
592+
msg = str(exc.value)
593+
assert "CONNECTION_FAILED" in msg
594+
assert "ENTITY_NOT_FOUND" not in msg

0 commit comments

Comments
 (0)