ha-mcp-dev 7.4.1.dev422__tar.gz → 7.4.1.dev424__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (105) hide show
  1. {ha_mcp_dev-7.4.1.dev422/src/ha_mcp_dev.egg-info → ha_mcp_dev-7.4.1.dev424}/PKG-INFO +1 -1
  2. {ha_mcp_dev-7.4.1.dev422 → ha_mcp_dev-7.4.1.dev424}/pyproject.toml +1 -1
  3. {ha_mcp_dev-7.4.1.dev422 → ha_mcp_dev-7.4.1.dev424}/src/ha_mcp/client/rest_client.py +39 -3
  4. {ha_mcp_dev-7.4.1.dev422 → ha_mcp_dev-7.4.1.dev424}/src/ha_mcp/client/websocket_client.py +55 -3
  5. {ha_mcp_dev-7.4.1.dev422 → ha_mcp_dev-7.4.1.dev424}/src/ha_mcp/config.py +13 -0
  6. {ha_mcp_dev-7.4.1.dev422 → ha_mcp_dev-7.4.1.dev424}/src/ha_mcp/tools/backup.py +6 -2
  7. {ha_mcp_dev-7.4.1.dev422 → ha_mcp_dev-7.4.1.dev424}/src/ha_mcp/tools/helpers.py +6 -2
  8. {ha_mcp_dev-7.4.1.dev422 → ha_mcp_dev-7.4.1.dev424}/src/ha_mcp/tools/tools_addons.py +307 -112
  9. {ha_mcp_dev-7.4.1.dev422 → ha_mcp_dev-7.4.1.dev424}/src/ha_mcp/tools/tools_history.py +3 -1
  10. {ha_mcp_dev-7.4.1.dev422 → ha_mcp_dev-7.4.1.dev424}/src/ha_mcp/tools/tools_system.py +3 -1
  11. {ha_mcp_dev-7.4.1.dev422 → ha_mcp_dev-7.4.1.dev424}/src/ha_mcp/tools/tools_traces.py +3 -1
  12. {ha_mcp_dev-7.4.1.dev422 → ha_mcp_dev-7.4.1.dev424/src/ha_mcp_dev.egg-info}/PKG-INFO +1 -1
  13. {ha_mcp_dev-7.4.1.dev422 → ha_mcp_dev-7.4.1.dev424}/LICENSE +0 -0
  14. {ha_mcp_dev-7.4.1.dev422 → ha_mcp_dev-7.4.1.dev424}/MANIFEST.in +0 -0
  15. {ha_mcp_dev-7.4.1.dev422 → ha_mcp_dev-7.4.1.dev424}/README.md +0 -0
  16. {ha_mcp_dev-7.4.1.dev422 → ha_mcp_dev-7.4.1.dev424}/setup.cfg +0 -0
  17. {ha_mcp_dev-7.4.1.dev422 → ha_mcp_dev-7.4.1.dev424}/src/ha_mcp/__init__.py +0 -0
  18. {ha_mcp_dev-7.4.1.dev422 → ha_mcp_dev-7.4.1.dev424}/src/ha_mcp/__main__.py +0 -0
  19. {ha_mcp_dev-7.4.1.dev422 → ha_mcp_dev-7.4.1.dev424}/src/ha_mcp/_pypi_marker +0 -0
  20. {ha_mcp_dev-7.4.1.dev422 → ha_mcp_dev-7.4.1.dev424}/src/ha_mcp/_version.py +0 -0
  21. {ha_mcp_dev-7.4.1.dev422 → ha_mcp_dev-7.4.1.dev424}/src/ha_mcp/auth/__init__.py +0 -0
  22. {ha_mcp_dev-7.4.1.dev422 → ha_mcp_dev-7.4.1.dev424}/src/ha_mcp/auth/consent_form.py +0 -0
  23. {ha_mcp_dev-7.4.1.dev422 → ha_mcp_dev-7.4.1.dev424}/src/ha_mcp/auth/provider.py +0 -0
  24. {ha_mcp_dev-7.4.1.dev422 → ha_mcp_dev-7.4.1.dev424}/src/ha_mcp/client/__init__.py +0 -0
  25. {ha_mcp_dev-7.4.1.dev422 → ha_mcp_dev-7.4.1.dev424}/src/ha_mcp/client/websocket_listener.py +0 -0
  26. {ha_mcp_dev-7.4.1.dev422 → ha_mcp_dev-7.4.1.dev424}/src/ha_mcp/errors.py +0 -0
  27. {ha_mcp_dev-7.4.1.dev422 → ha_mcp_dev-7.4.1.dev424}/src/ha_mcp/py.typed +0 -0
  28. {ha_mcp_dev-7.4.1.dev422 → ha_mcp_dev-7.4.1.dev424}/src/ha_mcp/resources/skills-vendor/.claude/settings.json +0 -0
  29. {ha_mcp_dev-7.4.1.dev422 → ha_mcp_dev-7.4.1.dev424}/src/ha_mcp/resources/skills-vendor/.claude-plugin/marketplace.json +0 -0
  30. {ha_mcp_dev-7.4.1.dev422 → ha_mcp_dev-7.4.1.dev424}/src/ha_mcp/resources/skills-vendor/.claude-plugin/plugin.json +0 -0
  31. {ha_mcp_dev-7.4.1.dev422 → ha_mcp_dev-7.4.1.dev424}/src/ha_mcp/resources/skills-vendor/.github/ISSUE_TEMPLATE/skill-rca.md +0 -0
  32. {ha_mcp_dev-7.4.1.dev422 → ha_mcp_dev-7.4.1.dev424}/src/ha_mcp/resources/skills-vendor/AGENTS.md +0 -0
  33. {ha_mcp_dev-7.4.1.dev422 → ha_mcp_dev-7.4.1.dev424}/src/ha_mcp/resources/skills-vendor/CLAUDE.md +0 -0
  34. {ha_mcp_dev-7.4.1.dev422 → ha_mcp_dev-7.4.1.dev424}/src/ha_mcp/resources/skills-vendor/CONTRIBUTING.md +0 -0
  35. {ha_mcp_dev-7.4.1.dev422 → ha_mcp_dev-7.4.1.dev424}/src/ha_mcp/resources/skills-vendor/LICENSE +0 -0
  36. {ha_mcp_dev-7.4.1.dev422 → ha_mcp_dev-7.4.1.dev424}/src/ha_mcp/resources/skills-vendor/README.md +0 -0
  37. {ha_mcp_dev-7.4.1.dev422 → ha_mcp_dev-7.4.1.dev424}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/SKILL.md +0 -0
  38. {ha_mcp_dev-7.4.1.dev422 → ha_mcp_dev-7.4.1.dev424}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/evals/evals.json +0 -0
  39. {ha_mcp_dev-7.4.1.dev422 → ha_mcp_dev-7.4.1.dev424}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/automation-patterns.md +0 -0
  40. {ha_mcp_dev-7.4.1.dev422 → ha_mcp_dev-7.4.1.dev424}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/dashboard-cards.md +0 -0
  41. {ha_mcp_dev-7.4.1.dev422 → ha_mcp_dev-7.4.1.dev424}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/dashboard-guide.md +0 -0
  42. {ha_mcp_dev-7.4.1.dev422 → ha_mcp_dev-7.4.1.dev424}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/device-control.md +0 -0
  43. {ha_mcp_dev-7.4.1.dev422 → ha_mcp_dev-7.4.1.dev424}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/domain-docs.md +0 -0
  44. {ha_mcp_dev-7.4.1.dev422 → ha_mcp_dev-7.4.1.dev424}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/examples.yaml +0 -0
  45. {ha_mcp_dev-7.4.1.dev422 → ha_mcp_dev-7.4.1.dev424}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/helper-selection.md +0 -0
  46. {ha_mcp_dev-7.4.1.dev422 → ha_mcp_dev-7.4.1.dev424}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/safe-refactoring.md +0 -0
  47. {ha_mcp_dev-7.4.1.dev422 → ha_mcp_dev-7.4.1.dev424}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/template-guidelines.md +0 -0
  48. {ha_mcp_dev-7.4.1.dev422 → ha_mcp_dev-7.4.1.dev424}/src/ha_mcp/server.py +0 -0
  49. {ha_mcp_dev-7.4.1.dev422 → ha_mcp_dev-7.4.1.dev424}/src/ha_mcp/settings_ui.py +0 -0
  50. {ha_mcp_dev-7.4.1.dev422 → ha_mcp_dev-7.4.1.dev424}/src/ha_mcp/smoke_test.py +0 -0
  51. {ha_mcp_dev-7.4.1.dev422 → ha_mcp_dev-7.4.1.dev424}/src/ha_mcp/tools/__init__.py +0 -0
  52. {ha_mcp_dev-7.4.1.dev422 → ha_mcp_dev-7.4.1.dev424}/src/ha_mcp/tools/best_practice_checker.py +0 -0
  53. {ha_mcp_dev-7.4.1.dev422 → ha_mcp_dev-7.4.1.dev424}/src/ha_mcp/tools/device_control.py +0 -0
  54. {ha_mcp_dev-7.4.1.dev422 → ha_mcp_dev-7.4.1.dev424}/src/ha_mcp/tools/enhanced.py +0 -0
  55. {ha_mcp_dev-7.4.1.dev422 → ha_mcp_dev-7.4.1.dev424}/src/ha_mcp/tools/reference_validator.py +0 -0
  56. {ha_mcp_dev-7.4.1.dev422 → ha_mcp_dev-7.4.1.dev424}/src/ha_mcp/tools/registry.py +0 -0
  57. {ha_mcp_dev-7.4.1.dev422 → ha_mcp_dev-7.4.1.dev424}/src/ha_mcp/tools/smart_search.py +0 -0
  58. {ha_mcp_dev-7.4.1.dev422 → ha_mcp_dev-7.4.1.dev424}/src/ha_mcp/tools/tools_areas.py +0 -0
  59. {ha_mcp_dev-7.4.1.dev422 → ha_mcp_dev-7.4.1.dev424}/src/ha_mcp/tools/tools_blueprints.py +0 -0
  60. {ha_mcp_dev-7.4.1.dev422 → ha_mcp_dev-7.4.1.dev424}/src/ha_mcp/tools/tools_bug_report.py +0 -0
  61. {ha_mcp_dev-7.4.1.dev422 → ha_mcp_dev-7.4.1.dev424}/src/ha_mcp/tools/tools_calendar.py +0 -0
  62. {ha_mcp_dev-7.4.1.dev422 → ha_mcp_dev-7.4.1.dev424}/src/ha_mcp/tools/tools_camera.py +0 -0
  63. {ha_mcp_dev-7.4.1.dev422 → ha_mcp_dev-7.4.1.dev424}/src/ha_mcp/tools/tools_categories.py +0 -0
  64. {ha_mcp_dev-7.4.1.dev422 → ha_mcp_dev-7.4.1.dev424}/src/ha_mcp/tools/tools_config_automations.py +0 -0
  65. {ha_mcp_dev-7.4.1.dev422 → ha_mcp_dev-7.4.1.dev424}/src/ha_mcp/tools/tools_config_dashboards.py +0 -0
  66. {ha_mcp_dev-7.4.1.dev422 → ha_mcp_dev-7.4.1.dev424}/src/ha_mcp/tools/tools_config_entry_flow.py +0 -0
  67. {ha_mcp_dev-7.4.1.dev422 → ha_mcp_dev-7.4.1.dev424}/src/ha_mcp/tools/tools_config_helpers.py +0 -0
  68. {ha_mcp_dev-7.4.1.dev422 → ha_mcp_dev-7.4.1.dev424}/src/ha_mcp/tools/tools_config_scripts.py +0 -0
  69. {ha_mcp_dev-7.4.1.dev422 → ha_mcp_dev-7.4.1.dev424}/src/ha_mcp/tools/tools_energy.py +0 -0
  70. {ha_mcp_dev-7.4.1.dev422 → ha_mcp_dev-7.4.1.dev424}/src/ha_mcp/tools/tools_entities.py +0 -0
  71. {ha_mcp_dev-7.4.1.dev422 → ha_mcp_dev-7.4.1.dev424}/src/ha_mcp/tools/tools_filesystem.py +0 -0
  72. {ha_mcp_dev-7.4.1.dev422 → ha_mcp_dev-7.4.1.dev424}/src/ha_mcp/tools/tools_groups.py +0 -0
  73. {ha_mcp_dev-7.4.1.dev422 → ha_mcp_dev-7.4.1.dev424}/src/ha_mcp/tools/tools_hacs.py +0 -0
  74. {ha_mcp_dev-7.4.1.dev422 → ha_mcp_dev-7.4.1.dev424}/src/ha_mcp/tools/tools_integrations.py +0 -0
  75. {ha_mcp_dev-7.4.1.dev422 → ha_mcp_dev-7.4.1.dev424}/src/ha_mcp/tools/tools_labels.py +0 -0
  76. {ha_mcp_dev-7.4.1.dev422 → ha_mcp_dev-7.4.1.dev424}/src/ha_mcp/tools/tools_mcp_component.py +0 -0
  77. {ha_mcp_dev-7.4.1.dev422 → ha_mcp_dev-7.4.1.dev424}/src/ha_mcp/tools/tools_registry.py +0 -0
  78. {ha_mcp_dev-7.4.1.dev422 → ha_mcp_dev-7.4.1.dev424}/src/ha_mcp/tools/tools_resources.py +0 -0
  79. {ha_mcp_dev-7.4.1.dev422 → ha_mcp_dev-7.4.1.dev424}/src/ha_mcp/tools/tools_search.py +0 -0
  80. {ha_mcp_dev-7.4.1.dev422 → ha_mcp_dev-7.4.1.dev424}/src/ha_mcp/tools/tools_service.py +0 -0
  81. {ha_mcp_dev-7.4.1.dev422 → ha_mcp_dev-7.4.1.dev424}/src/ha_mcp/tools/tools_services.py +0 -0
  82. {ha_mcp_dev-7.4.1.dev422 → ha_mcp_dev-7.4.1.dev424}/src/ha_mcp/tools/tools_todo.py +0 -0
  83. {ha_mcp_dev-7.4.1.dev422 → ha_mcp_dev-7.4.1.dev424}/src/ha_mcp/tools/tools_updates.py +0 -0
  84. {ha_mcp_dev-7.4.1.dev422 → ha_mcp_dev-7.4.1.dev424}/src/ha_mcp/tools/tools_utility.py +0 -0
  85. {ha_mcp_dev-7.4.1.dev422 → ha_mcp_dev-7.4.1.dev424}/src/ha_mcp/tools/tools_voice_assistant.py +0 -0
  86. {ha_mcp_dev-7.4.1.dev422 → ha_mcp_dev-7.4.1.dev424}/src/ha_mcp/tools/tools_yaml_config.py +0 -0
  87. {ha_mcp_dev-7.4.1.dev422 → ha_mcp_dev-7.4.1.dev424}/src/ha_mcp/tools/tools_zones.py +0 -0
  88. {ha_mcp_dev-7.4.1.dev422 → ha_mcp_dev-7.4.1.dev424}/src/ha_mcp/tools/util_helpers.py +0 -0
  89. {ha_mcp_dev-7.4.1.dev422 → ha_mcp_dev-7.4.1.dev424}/src/ha_mcp/transforms/__init__.py +0 -0
  90. {ha_mcp_dev-7.4.1.dev422 → ha_mcp_dev-7.4.1.dev424}/src/ha_mcp/transforms/categorized_search.py +0 -0
  91. {ha_mcp_dev-7.4.1.dev422 → ha_mcp_dev-7.4.1.dev424}/src/ha_mcp/utils/__init__.py +0 -0
  92. {ha_mcp_dev-7.4.1.dev422 → ha_mcp_dev-7.4.1.dev424}/src/ha_mcp/utils/config_hash.py +0 -0
  93. {ha_mcp_dev-7.4.1.dev422 → ha_mcp_dev-7.4.1.dev424}/src/ha_mcp/utils/domain_handlers.py +0 -0
  94. {ha_mcp_dev-7.4.1.dev422 → ha_mcp_dev-7.4.1.dev424}/src/ha_mcp/utils/fuzzy_search.py +0 -0
  95. {ha_mcp_dev-7.4.1.dev422 → ha_mcp_dev-7.4.1.dev424}/src/ha_mcp/utils/operation_manager.py +0 -0
  96. {ha_mcp_dev-7.4.1.dev422 → ha_mcp_dev-7.4.1.dev424}/src/ha_mcp/utils/python_sandbox.py +0 -0
  97. {ha_mcp_dev-7.4.1.dev422 → ha_mcp_dev-7.4.1.dev424}/src/ha_mcp/utils/usage_logger.py +0 -0
  98. {ha_mcp_dev-7.4.1.dev422 → ha_mcp_dev-7.4.1.dev424}/src/ha_mcp_dev.egg-info/SOURCES.txt +0 -0
  99. {ha_mcp_dev-7.4.1.dev422 → ha_mcp_dev-7.4.1.dev424}/src/ha_mcp_dev.egg-info/dependency_links.txt +0 -0
  100. {ha_mcp_dev-7.4.1.dev422 → ha_mcp_dev-7.4.1.dev424}/src/ha_mcp_dev.egg-info/entry_points.txt +0 -0
  101. {ha_mcp_dev-7.4.1.dev422 → ha_mcp_dev-7.4.1.dev424}/src/ha_mcp_dev.egg-info/requires.txt +0 -0
  102. {ha_mcp_dev-7.4.1.dev422 → ha_mcp_dev-7.4.1.dev424}/src/ha_mcp_dev.egg-info/top_level.txt +0 -0
  103. {ha_mcp_dev-7.4.1.dev422 → ha_mcp_dev-7.4.1.dev424}/tests/__init__.py +0 -0
  104. {ha_mcp_dev-7.4.1.dev422 → ha_mcp_dev-7.4.1.dev424}/tests/test_constants.py +0 -0
  105. {ha_mcp_dev-7.4.1.dev422 → ha_mcp_dev-7.4.1.dev424}/tests/test_env_manager.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ha-mcp-dev
3
- Version: 7.4.1.dev422
3
+ Version: 7.4.1.dev424
4
4
  Summary: Home Assistant MCP Server - Complete control of Home Assistant through MCP
5
5
  Author-email: Julien <github@qc-h.net>
6
6
  License: MIT
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "ha-mcp-dev"
7
- version = "7.4.1.dev422"
7
+ version = "7.4.1.dev424"
8
8
  description = "Home Assistant MCP Server - Complete control of Home Assistant through MCP"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.13,<3.14"
@@ -5,12 +5,27 @@ Home Assistant HTTP client with authentication and error handling.
5
5
  import asyncio
6
6
  import json
7
7
  import logging
8
+ import ssl
8
9
  from typing import Any
9
10
 
10
11
  import httpx
11
12
 
12
13
  from ..config import get_global_settings
13
14
 
15
+
16
+ def _is_ssl_error(exc: BaseException) -> bool:
17
+ """True if ``exc`` (or anything in its cause chain) is an SSL error.
18
+
19
+ httpx wraps ``ssl.SSLError`` inside ``httpx.ConnectError``; the only
20
+ reliable check is to walk ``__cause__`` / ``__context__``.
21
+ """
22
+ cur: BaseException | None = exc
23
+ while cur is not None:
24
+ if isinstance(cur, ssl.SSLError):
25
+ return True
26
+ cur = cur.__cause__ or cur.__context__
27
+ return False
28
+
14
29
  logger = logging.getLogger(__name__)
15
30
 
16
31
 
@@ -62,6 +77,7 @@ class HomeAssistantClient:
62
77
  base_url: str | None = None,
63
78
  token: str | None = None,
64
79
  timeout: int | None = None,
80
+ verify_ssl: bool | None = None,
65
81
  ):
66
82
  """
67
83
  Initialize Home Assistant client.
@@ -70,18 +86,31 @@ class HomeAssistantClient:
70
86
  base_url: Home Assistant URL (defaults to config)
71
87
  token: Long-lived access token (defaults to config)
72
88
  timeout: Request timeout in seconds (defaults to config)
89
+ verify_ssl: Whether to verify the HA server's TLS certificate
90
+ (defaults to ``settings.verify_ssl``). Pass False to allow
91
+ self-signed certs or hostname mismatches.
73
92
  """
74
- # Only load settings if we need to use fallback values
75
- if base_url is None or token is None:
93
+ if base_url is None or token is None or verify_ssl is None:
76
94
  settings = get_global_settings()
77
95
  self.base_url = (base_url or settings.homeassistant_url).rstrip("/")
78
96
  self.token = token or settings.homeassistant_token
79
97
  self.timeout = timeout if timeout is not None else settings.timeout
98
+ self.verify_ssl = (
99
+ verify_ssl if verify_ssl is not None else settings.verify_ssl
100
+ )
80
101
  else:
81
- # All required parameters provided, use them directly without loading settings
82
102
  self.base_url = base_url.rstrip("/")
83
103
  self.token = token
84
104
  self.timeout = timeout if timeout is not None else 30 # Default timeout
105
+ self.verify_ssl = verify_ssl
106
+
107
+ if not self.verify_ssl:
108
+ logger.warning(
109
+ "TLS verification disabled for Home Assistant REST client "
110
+ "(HA_VERIFY_SSL=false). Connections to %s will accept "
111
+ "self-signed and mismatched certificates.",
112
+ self.base_url,
113
+ )
85
114
 
86
115
  # Create HTTP client with authentication headers
87
116
  self.httpx_client = httpx.AsyncClient(
@@ -91,6 +120,7 @@ class HomeAssistantClient:
91
120
  "Content-Type": "application/json",
92
121
  },
93
122
  timeout=httpx.Timeout(self.timeout),
123
+ verify=self.verify_ssl,
94
124
  )
95
125
 
96
126
  logger.info(f"Initialized Home Assistant client for {self.base_url}")
@@ -148,6 +178,12 @@ class HomeAssistantClient:
148
178
  return response
149
179
 
150
180
  except httpx.ConnectError as e:
181
+ if _is_ssl_error(e) and self.verify_ssl:
182
+ raise HomeAssistantConnectionError(
183
+ f"TLS verification failed for {self.base_url}: {e}. "
184
+ "If this is a self-signed certificate or hostname "
185
+ "mismatch, set HA_VERIFY_SSL=false to skip verification."
186
+ ) from e
151
187
  raise HomeAssistantConnectionError(
152
188
  f"Failed to connect to Home Assistant: {e}"
153
189
  ) from e
@@ -11,6 +11,7 @@ import asyncio
11
11
  import hashlib
12
12
  import json
13
13
  import logging
14
+ import ssl
14
15
  import time
15
16
  from collections import defaultdict
16
17
  from collections.abc import Awaitable, Callable
@@ -20,7 +21,11 @@ from urllib.parse import urlparse
20
21
  import websockets
21
22
 
22
23
  from ..config import get_global_settings
23
- from .rest_client import HomeAssistantCommandError, HomeAssistantConnectionError
24
+ from .rest_client import (
25
+ HomeAssistantCommandError,
26
+ HomeAssistantConnectionError,
27
+ _is_ssl_error,
28
+ )
24
29
 
25
30
  logger = logging.getLogger(__name__)
26
31
 
@@ -158,15 +163,33 @@ class WebSocketConnectionState:
158
163
  class HomeAssistantWebSocketClient:
159
164
  """WebSocket client for Home Assistant real-time communication."""
160
165
 
161
- def __init__(self, url: str, token: str):
166
+ def __init__(self, url: str, token: str, verify_ssl: bool | None = None):
162
167
  """Initialize WebSocket client.
163
168
 
164
169
  Args:
165
170
  url: Home Assistant URL (e.g., 'https://homeassistant.local:8123')
166
171
  token: Home Assistant long-lived access token
172
+ verify_ssl: Whether to verify the HA server's TLS certificate
173
+ for ``wss://`` connections. Defaults to
174
+ ``settings.verify_ssl``. Pass False to allow self-signed
175
+ certs or hostname mismatches.
167
176
  """
168
177
  self.base_url = url.rstrip("/")
169
178
  self.token = token
179
+ if verify_ssl is None:
180
+ try:
181
+ verify_ssl = get_global_settings().verify_ssl
182
+ except Exception as e:
183
+ # A bad env var elsewhere should not silently flip TLS off:
184
+ # log which key tripped and fall back to the secure default.
185
+ logger.warning(
186
+ "Could not load settings while resolving verify_ssl "
187
+ "(%s); falling back to verify_ssl=True.",
188
+ e,
189
+ )
190
+ verify_ssl = True
191
+ self.verify_ssl = verify_ssl
192
+ self._warned_verify_disabled = False
170
193
  self.websocket: websockets.ClientConnection | None = None
171
194
  self.background_task: asyncio.Task | None = None
172
195
  self._send_lock: asyncio.Lock | None = None
@@ -197,6 +220,25 @@ class HomeAssistantWebSocketClient:
197
220
  logger.info(f"Connecting to Home Assistant WebSocket: {self.ws_url}")
198
221
  self._state.reset_connection()
199
222
 
223
+ # Only configure an SSLContext for wss://; ws:// (Supervisor
224
+ # proxy) doesn't use TLS and gets ssl=None.
225
+ ssl_ctx: ssl.SSLContext | None = None
226
+ if self.ws_url.startswith("wss://"):
227
+ ssl_ctx = ssl.create_default_context()
228
+ if not self.verify_ssl:
229
+ if not self._warned_verify_disabled:
230
+ # Once per client — pool reconnects/HA restarts
231
+ # otherwise flood logs with the same warning.
232
+ logger.warning(
233
+ "TLS verification disabled for Home Assistant "
234
+ "WebSocket (HA_VERIFY_SSL=false). Connecting to "
235
+ "%s with hostname/cert checks off.",
236
+ self.ws_url,
237
+ )
238
+ self._warned_verify_disabled = True
239
+ ssl_ctx.check_hostname = False
240
+ ssl_ctx.verify_mode = ssl.CERT_NONE
241
+
200
242
  # Connect to WebSocket
201
243
  # Include Authorization header for Supervisor proxy compatibility
202
244
  # (required when connecting via http://supervisor/core/websocket)
@@ -205,6 +247,7 @@ class HomeAssistantWebSocketClient:
205
247
  ping_interval=30,
206
248
  ping_timeout=10,
207
249
  additional_headers={"Authorization": f"Bearer {self.token}"},
250
+ ssl=ssl_ctx,
208
251
  # Increase max message size to 20MB for large responses
209
252
  # (e.g., HACS repository list can be 2MB+)
210
253
  max_size=20 * 1024 * 1024,
@@ -241,7 +284,16 @@ class HomeAssistantWebSocketClient:
241
284
  return True
242
285
 
243
286
  except Exception as e:
244
- logger.error(f"WebSocket connection failed: {e}")
287
+ if _is_ssl_error(e) and self.verify_ssl:
288
+ logger.error(
289
+ "WebSocket TLS verification failed for %s: %s. "
290
+ "If this is a self-signed certificate or hostname "
291
+ "mismatch, set HA_VERIFY_SSL=false to skip verification.",
292
+ self.ws_url,
293
+ e,
294
+ )
295
+ else:
296
+ logger.error(f"WebSocket connection failed: {e}")
245
297
  await self.disconnect()
246
298
  return False
247
299
 
@@ -54,6 +54,9 @@ class Settings(BaseSettings):
54
54
  timeout: int = Field(30, alias="HA_TIMEOUT")
55
55
  max_retries: int = Field(3, alias="HA_MAX_RETRIES")
56
56
 
57
+ # False = skip TLS verification (self-signed / hostname mismatch). Trusted networks only.
58
+ verify_ssl: bool = Field(True, alias="HA_VERIFY_SSL")
59
+
57
60
  # Tool configuration
58
61
  fuzzy_threshold: int = Field(60, alias="FUZZY_THRESHOLD")
59
62
  entity_search_limit: int = Field(20, alias="ENTITY_SEARCH_LIMIT")
@@ -231,3 +234,13 @@ def get_global_settings() -> Settings:
231
234
  if _settings is None:
232
235
  _settings = get_settings()
233
236
  return _settings
237
+
238
+
239
+ def _reset_global_settings() -> None:
240
+ """Drop the cached settings singleton.
241
+
242
+ Test-only seam so suites that mutate ``HA_*`` env vars can force a
243
+ re-read without reaching into module-private state.
244
+ """
245
+ global _settings
246
+ _settings = None
@@ -179,7 +179,9 @@ async def create_backup(
179
179
 
180
180
  try:
181
181
  # Connect to WebSocket
182
- ws_client, error = await get_connected_ws_client(client.base_url, client.token)
182
+ ws_client, error = await get_connected_ws_client(
183
+ client.base_url, client.token, verify_ssl=client.verify_ssl
184
+ )
183
185
  if error:
184
186
  raise_tool_error(error or create_error_response(
185
187
  ErrorCode.CONNECTION_FAILED,
@@ -300,7 +302,9 @@ async def restore_backup(
300
302
 
301
303
  try:
302
304
  # Connect to WebSocket
303
- ws_client, error = await get_connected_ws_client(client.base_url, client.token)
305
+ ws_client, error = await get_connected_ws_client(
306
+ client.base_url, client.token, verify_ssl=client.verify_ssl
307
+ )
304
308
  if error:
305
309
  raise_tool_error(error or create_error_response(
306
310
  ErrorCode.CONNECTION_FAILED,
@@ -78,7 +78,7 @@ def extract_tool_error_message(te: ToolError) -> str:
78
78
 
79
79
 
80
80
  async def get_connected_ws_client(
81
- base_url: str, token: str
81
+ base_url: str, token: str, verify_ssl: bool | None = None
82
82
  ) -> tuple[HomeAssistantWebSocketClient | None, dict[str, Any] | None]:
83
83
  """
84
84
  Create and connect a WebSocket client.
@@ -86,11 +86,15 @@ async def get_connected_ws_client(
86
86
  Args:
87
87
  base_url: Home Assistant base URL
88
88
  token: Authentication token
89
+ verify_ssl: TLS verification override. Pass ``client.verify_ssl``
90
+ from the calling REST client so a programmatic
91
+ ``HomeAssistantClient(verify_ssl=False)`` propagates to the
92
+ WebSocket too. ``None`` falls back to ``settings.verify_ssl``.
89
93
 
90
94
  Returns:
91
95
  Tuple of (ws_client, error_dict). If connection fails, ws_client is None.
92
96
  """
93
- ws_client = HomeAssistantWebSocketClient(base_url, token)
97
+ ws_client = HomeAssistantWebSocketClient(base_url, token, verify_ssl=verify_ssl)
94
98
  connected = await ws_client.connect()
95
99
  if not connected:
96
100
  return None, create_connection_error(
@@ -13,19 +13,19 @@ import logging
13
13
  import re
14
14
  import time
15
15
  from typing import Annotated, Any
16
- from urllib.parse import unquote
16
+ from urllib.parse import unquote, urlsplit
17
17
 
18
18
  import httpx
19
19
  import websockets
20
20
  from fastmcp.exceptions import ToolError
21
21
  from pydantic import Field
22
22
 
23
+ from .._version import is_running_in_addon
23
24
  from ..client.rest_client import HomeAssistantClient
24
25
  from ..errors import (
25
26
  ErrorCode,
26
27
  create_connection_error,
27
28
  create_error_response,
28
- create_timeout_error,
29
29
  create_validation_error,
30
30
  )
31
31
  from ..utils.python_sandbox import PythonSandboxError, safe_execute_expression
@@ -232,7 +232,9 @@ async def _supervisor_api_call(
232
232
  """
233
233
  ws_client = None
234
234
  try:
235
- ws_client, error = await get_connected_ws_client(client.base_url, client.token)
235
+ ws_client, error = await get_connected_ws_client(
236
+ client.base_url, client.token, verify_ssl=client.verify_ssl
237
+ )
236
238
  if error or ws_client is None:
237
239
  return error or create_connection_error(
238
240
  "Failed to establish WebSocket connection",
@@ -286,6 +288,217 @@ async def _supervisor_api_call(
286
288
  pass
287
289
 
288
290
 
291
+ def _addon_connection_failure_suggestions(
292
+ client: HomeAssistantClient, port: int | None
293
+ ) -> list[str]:
294
+ """Suggestions for connect/timeout failures against an add-on.
295
+
296
+ Three modes — direct-port hits a container IP, the addon-variant ingress
297
+ route hits a sibling container's ingress port, the off-host ingress route
298
+ hits HA Core. Each mode fails for different reasons, so suggest different
299
+ next steps.
300
+ """
301
+ if port:
302
+ return [
303
+ "Check that the add-on is running",
304
+ "Direct-port access requires the MCP host to share Home "
305
+ "Assistant's container network. On PyPI/uvx installs, drop "
306
+ "the 'port' parameter to route through Ingress instead.",
307
+ ]
308
+ if is_running_in_addon():
309
+ return [
310
+ "The target add-on container may not be reachable from this "
311
+ "MCP add-on. Check that the target add-on is running.",
312
+ "If the failure persists, the addon Docker network may be "
313
+ "unhealthy — try restarting the target add-on, then this "
314
+ "MCP add-on.",
315
+ ]
316
+ return [
317
+ f"Verify Home Assistant is reachable at {client.base_url}",
318
+ "Check network connectivity from the MCP host to HA Core",
319
+ ]
320
+
321
+
322
+ async def _create_ingress_session(client: HomeAssistantClient) -> str:
323
+ """Create a Supervisor ingress session and return its token.
324
+
325
+ Sessions are minted via the WS `supervisor/api` proxy (which HA Core
326
+ authenticates on our behalf), so this works the same on HAOS, Supervised,
327
+ and PyPI/uvx hosts. The returned token is set as the `ingress_session`
328
+ cookie on requests to HA Core's `/api/hassio_ingress/<addon_token>/...`
329
+ endpoint, which Supervisor validates before proxying to the add-on
330
+ container. Sessions are valid for ~15 minutes; we mint a fresh one per
331
+ call to avoid managing lifetime.
332
+ """
333
+ response = await _supervisor_api_call(
334
+ client, "/ingress/session", method="POST", data={}
335
+ )
336
+ if not response.get("success"):
337
+ raise_tool_error(response)
338
+
339
+ session = response.get("result", {}).get("session")
340
+ if not isinstance(session, str) or not session:
341
+ raise_tool_error(
342
+ create_error_response(
343
+ ErrorCode.SERVICE_CALL_FAILED,
344
+ "Supervisor returned no ingress session token",
345
+ details=str(response),
346
+ )
347
+ )
348
+ return session
349
+
350
+
351
+ async def _resolve_http_route(
352
+ client: HomeAssistantClient,
353
+ addon: dict[str, Any],
354
+ normalized_path: str,
355
+ port: int | None,
356
+ ) -> tuple[str, dict[str, str]]:
357
+ """Pick the HTTP route shape based on `port` and install variant.
358
+
359
+ Three branches:
360
+ - `port` set → direct container port (`http://<ip>:<port>/...`), no
361
+ auth headers. Only reachable when the MCP host shares HA's container
362
+ network.
363
+ - Running as the HA add-on (`is_running_in_addon()` true) → direct
364
+ `<addon_ip>:<addon_ingress_port>` with `X-Ingress-Path` and
365
+ `X-Hass-Source: core.ingress` headers. This is the path the addon
366
+ variant always took on master; routing through HA Core's
367
+ `/api/hassio_ingress/...` proxy regresses here because
368
+ `client.base_url` is `http://supervisor/core` (a Supervisor proxy
369
+ mount that demands `Authorization: Bearer $SUPERVISOR_TOKEN`).
370
+ - Off-host → HA Core ingress proxy at
371
+ `<base_url>/api/hassio_ingress/<token>/<path>` with `Cookie:
372
+ ingress_session=<token>`. Mints a fresh session per call.
373
+ """
374
+ addon_name = addon.get("name", "")
375
+ headers: dict[str, str] = {}
376
+
377
+ if port:
378
+ addon_ip = addon.get("ip_address", "")
379
+ if not addon_ip:
380
+ raise_tool_error(
381
+ create_error_response(
382
+ ErrorCode.INTERNAL_ERROR,
383
+ f"Add-on '{addon_name}' is missing ip_address",
384
+ context={"slug": addon.get("slug"), "ip_address": addon_ip},
385
+ )
386
+ )
387
+ return f"http://{addon_ip}:{port}/{normalized_path}", headers
388
+
389
+ ingress_entry = addon.get("ingress_entry")
390
+ if not ingress_entry:
391
+ raise_tool_error(
392
+ create_error_response(
393
+ ErrorCode.INTERNAL_ERROR,
394
+ f"Add-on '{addon_name}' is missing ingress_entry",
395
+ context={"slug": addon.get("slug")},
396
+ )
397
+ )
398
+
399
+ if is_running_in_addon():
400
+ addon_ip = addon.get("ip_address", "")
401
+ ingress_port = addon.get("ingress_port")
402
+ if not addon_ip or not ingress_port:
403
+ raise_tool_error(
404
+ create_error_response(
405
+ ErrorCode.INTERNAL_ERROR,
406
+ f"Add-on '{addon_name}' is missing network info "
407
+ "(ip_address or ingress_port)",
408
+ context={
409
+ "slug": addon.get("slug"),
410
+ "ip_address": addon_ip,
411
+ "ingress_port": ingress_port,
412
+ },
413
+ )
414
+ )
415
+ # Sibling addon containers share the hassio bridge, so we hit the
416
+ # ingress port directly. The X-Ingress-Path / X-Hass-Source headers
417
+ # are what the addon's nginx trusts as authenticated ingress source.
418
+ headers["X-Ingress-Path"] = ingress_entry
419
+ headers["X-Hass-Source"] = "core.ingress"
420
+ return (
421
+ f"http://{addon_ip}:{ingress_port}/{normalized_path}",
422
+ headers,
423
+ )
424
+
425
+ session = await _create_ingress_session(client)
426
+ base = client.base_url.rstrip("/")
427
+ headers["Cookie"] = f"ingress_session={session}"
428
+ return f"{base}{ingress_entry}/{normalized_path}", headers
429
+
430
+
431
+ async def _resolve_ws_route(
432
+ client: HomeAssistantClient,
433
+ addon: dict[str, Any],
434
+ normalized_path: str,
435
+ port: int | None,
436
+ ) -> tuple[str, dict[str, str]]:
437
+ """Pick the WebSocket route shape. Mirrors `_resolve_http_route`.
438
+
439
+ The addon-variant and direct-port branches always speak `ws://` because
440
+ they hit the container directly. The off-host branch echoes
441
+ `client.base_url`'s scheme (so HTTPS-fronted HA gets `wss://`).
442
+ """
443
+ addon_name = addon.get("name", "")
444
+ headers: dict[str, str] = {}
445
+
446
+ if port:
447
+ addon_ip = addon.get("ip_address", "")
448
+ if not addon_ip:
449
+ raise_tool_error(
450
+ create_error_response(
451
+ ErrorCode.INTERNAL_ERROR,
452
+ f"Add-on '{addon_name}' is missing ip_address",
453
+ context={"slug": addon.get("slug")},
454
+ )
455
+ )
456
+ return f"ws://{addon_ip}:{port}/{normalized_path}", headers
457
+
458
+ ingress_entry = addon.get("ingress_entry")
459
+ if not ingress_entry:
460
+ raise_tool_error(
461
+ create_error_response(
462
+ ErrorCode.INTERNAL_ERROR,
463
+ f"Add-on '{addon_name}' is missing ingress_entry",
464
+ context={"slug": addon.get("slug")},
465
+ )
466
+ )
467
+
468
+ if is_running_in_addon():
469
+ addon_ip = addon.get("ip_address", "")
470
+ ingress_port = addon.get("ingress_port")
471
+ if not addon_ip or not ingress_port:
472
+ raise_tool_error(
473
+ create_error_response(
474
+ ErrorCode.INTERNAL_ERROR,
475
+ f"Add-on '{addon_name}' is missing network info "
476
+ "(ip_address or ingress_port)",
477
+ context={
478
+ "slug": addon.get("slug"),
479
+ "ip_address": addon_ip,
480
+ "ingress_port": ingress_port,
481
+ },
482
+ )
483
+ )
484
+ headers["X-Ingress-Path"] = ingress_entry
485
+ headers["X-Hass-Source"] = "core.ingress"
486
+ return (
487
+ f"ws://{addon_ip}:{ingress_port}/{normalized_path}",
488
+ headers,
489
+ )
490
+
491
+ session = await _create_ingress_session(client)
492
+ parsed = urlsplit(client.base_url)
493
+ ws_scheme = "wss" if parsed.scheme == "https" else "ws"
494
+ ws_path_prefix = parsed.path.rstrip("/")
495
+ headers["Cookie"] = f"ingress_session={session}"
496
+ return (
497
+ f"{ws_scheme}://{parsed.netloc}{ws_path_prefix}{ingress_entry}/{normalized_path}",
498
+ headers,
499
+ )
500
+
501
+
289
502
  async def get_addon_info(client: HomeAssistantClient, slug: str) -> dict[str, Any]:
290
503
  """Get detailed info for a specific add-on.
291
504
 
@@ -517,6 +730,11 @@ async def _call_addon_ws(
517
730
  ) -> dict[str, Any]:
518
731
  """Connect to an add-on's WebSocket API and collect messages.
519
732
 
733
+ Routing mirrors the HTTP variant (see `_resolve_ws_route`): off-host
734
+ ingress tunnels through HA Core's `/api/hassio_ingress` proxy; the
735
+ HA-add-on variant hits the container's ingress port directly;
736
+ direct-port mode (`port` set) connects to the container's mapped port.
737
+
520
738
  Args:
521
739
  client: Home Assistant REST client
522
740
  slug: Add-on slug (e.g., "5c53de3b_esphome")
@@ -591,38 +809,8 @@ async def _call_addon_ws(
591
809
  )
592
810
  )
593
811
 
594
- # 5. Build WebSocket URL
595
- addon_ip = addon.get("ip_address", "")
596
- if port:
597
- if not addon_ip:
598
- raise_tool_error(
599
- create_error_response(
600
- ErrorCode.INTERNAL_ERROR,
601
- f"Add-on '{addon_name}' is missing ip_address",
602
- context={"slug": slug},
603
- )
604
- )
605
- target_port = port
606
- else:
607
- ingress_port = addon.get("ingress_port")
608
- if not addon_ip or not ingress_port:
609
- raise_tool_error(
610
- create_error_response(
611
- ErrorCode.INTERNAL_ERROR,
612
- f"Add-on '{addon_name}' is missing network info",
613
- context={"slug": slug},
614
- )
615
- )
616
- target_port = ingress_port
617
-
618
- ws_url = f"ws://{addon_ip}:{target_port}/{normalized}"
619
-
620
- # 6. Build connection headers
621
- headers: dict[str, str] = {}
622
- if not port:
623
- ingress_entry = addon.get("ingress_entry", "")
624
- headers["X-Ingress-Path"] = ingress_entry
625
- headers["X-Hass-Source"] = "core.ingress"
812
+ # 5. Resolve route (direct-port / addon-variant / off-host).
813
+ ws_url, headers = await _resolve_ws_route(client, addon, normalized, port)
626
814
 
627
815
  # 7. Connect and exchange messages
628
816
  collected: list[str] = []
@@ -703,14 +891,25 @@ async def _call_addon_ws(
703
891
  total_size += len(clean)
704
892
 
705
893
  except websockets.exceptions.InvalidHandshake as e:
894
+ suggestions = [
895
+ "Check that the add-on supports WebSocket on this path",
896
+ f"Use ha_get_addon(slug='{slug}') to inspect available endpoints",
897
+ ]
898
+ # 401/403 means auth was rejected, not a path-shape problem.
899
+ if isinstance(e, websockets.exceptions.InvalidStatus):
900
+ status = e.response.status_code
901
+ if status in (401, 403):
902
+ suggestions = [
903
+ "The ingress session may have expired or your HA token "
904
+ "may lack the required scope. Verify the token has admin "
905
+ "rights and try again.",
906
+ f"Status {status} from the WebSocket handshake.",
907
+ ]
706
908
  raise_tool_error(
707
909
  create_error_response(
708
910
  ErrorCode.SERVICE_CALL_FAILED,
709
911
  f"WebSocket handshake failed with '{addon_name}': {e!s}",
710
- suggestions=[
711
- "Check that the add-on supports WebSocket on this path",
712
- f"Use ha_get_addon(slug='{slug}') to inspect available endpoints",
713
- ],
912
+ suggestions=suggestions,
714
913
  context={"slug": slug, "path": path},
715
914
  )
716
915
  )
@@ -728,19 +927,28 @@ async def _call_addon_ws(
728
927
  )
729
928
  except TimeoutError:
730
929
  raise_tool_error(
731
- create_timeout_error(
732
- f"WebSocket connection to '{addon_name}'",
733
- timeout,
930
+ create_error_response(
931
+ ErrorCode.TIMEOUT_OPERATION,
932
+ f"Operation 'WebSocket connection to {addon_name!r}' timed out after {timeout}s",
734
933
  details=f"path={path}",
735
- context={"slug": slug, "path": path},
934
+ context={
935
+ "slug": slug,
936
+ "path": path,
937
+ "operation": f"WebSocket connection to '{addon_name}'",
938
+ "timeout_seconds": timeout,
939
+ "direct_port": bool(port),
940
+ },
941
+ suggestions=_addon_connection_failure_suggestions(client, port),
736
942
  )
737
943
  )
738
944
  except OSError as e:
739
945
  raise_tool_error(
740
- create_connection_error(
946
+ create_error_response(
947
+ ErrorCode.CONNECTION_FAILED,
741
948
  f"Failed to connect to add-on '{addon_name}' WebSocket: {e!s}",
742
- details="Check that the add-on is running and the port is correct",
743
- context={"slug": slug},
949
+ details=f"url={ws_url}",
950
+ context={"slug": slug, "direct_port": bool(port)},
951
+ suggestions=_addon_connection_failure_suggestions(client, port),
744
952
  )
745
953
  )
746
954
 
@@ -852,7 +1060,21 @@ async def _call_addon_api(
852
1060
  limit: int | None = None,
853
1061
  python_transform: str | None = None,
854
1062
  ) -> dict[str, Any]:
855
- """Call an add-on's web API through Home Assistant's Ingress proxy.
1063
+ """Call an add-on's web API.
1064
+
1065
+ Routing is picked per install variant (see `_resolve_http_route`):
1066
+
1067
+ - **Ingress (default), off-host**: tunnels through HA Core's
1068
+ `/api/hassio_ingress/<token>/...` proxy with a per-call Supervisor
1069
+ session cookie. The path that makes off-host (PyPI/uvx) installs work.
1070
+ - **Ingress (default), HA add-on**: hits the addon container's
1071
+ ingress port directly with the `core.ingress` source headers. Avoids
1072
+ the Supervisor `/core` proxy hop that would otherwise demand
1073
+ `Authorization: Bearer $SUPERVISOR_TOKEN` on top of the cookie.
1074
+ - **Direct port** (when `port` is set): connects to
1075
+ `http://<addon_ip>:<port>/...` for add-ons that expose mapped ports
1076
+ (e.g. Node-RED on 1880). Only works when the MCP host shares HA's
1077
+ Docker network.
856
1078
 
857
1079
  Args:
858
1080
  client: Home Assistant REST client
@@ -868,9 +1090,6 @@ async def _call_addon_api(
868
1090
  parsed response body. The variable ``response`` is bound to
869
1091
  ``dict | list | str`` depending on content-type. Transform runs
870
1092
  after offset/limit slicing.
871
-
872
- Returns:
873
- Dictionary with response data, status code, and content type.
874
1093
  """
875
1094
  # 1. Sanitize path to prevent traversal attacks (including URL-encoded)
876
1095
  normalized = unquote(path).lstrip("/")
@@ -919,52 +1138,10 @@ async def _call_addon_api(
919
1138
  )
920
1139
  )
921
1140
 
922
- # 5. Build URL to the add-on container
923
- addon_ip = addon.get("ip_address", "")
924
-
925
- if port:
926
- # Direct port access: connect to the add-on's mapped network port
927
- # (e.g., 1880 for Node-RED, 6052 for ESPHome) instead of the ingress port.
928
- # Requires 'leave_front_door_open' or equivalent setting on the add-on.
929
- if not addon_ip:
930
- raise_tool_error(
931
- create_error_response(
932
- ErrorCode.INTERNAL_ERROR,
933
- f"Add-on '{addon_name}' is missing ip_address",
934
- context={"slug": slug, "ip_address": addon_ip},
935
- )
936
- )
937
- target_port = port
938
- else:
939
- # Default: use the ingress port for direct container communication
940
- ingress_port = addon.get("ingress_port")
941
- if not addon_ip or not ingress_port:
942
- raise_tool_error(
943
- create_error_response(
944
- ErrorCode.INTERNAL_ERROR,
945
- f"Add-on '{addon_name}' is missing network info (ip_address or ingress_port)",
946
- context={
947
- "slug": slug,
948
- "ip_address": addon_ip,
949
- "ingress_port": ingress_port,
950
- },
951
- )
952
- )
953
- target_port = ingress_port
954
-
955
- url = f"http://{addon_ip}:{target_port}/{normalized}"
1141
+ # 5. Resolve route (direct-port / addon-variant / off-host).
1142
+ url, headers = await _resolve_http_route(client, addon, normalized, port)
956
1143
 
957
- # 6. Make HTTP request directly to the add-on container
958
- # Include Ingress headers so the add-on's web server (e.g., Nginx) recognizes
959
- # this as an authenticated Ingress request and bypasses its own auth layer.
960
- # When using a direct port, skip Ingress headers (not needed/recognized).
961
- ingress_entry = addon.get("ingress_entry", "")
962
- headers: dict[str, str] = {}
963
- if not port:
964
- headers["X-Ingress-Path"] = ingress_entry
965
- headers["X-Hass-Source"] = "core.ingress"
966
-
967
- # Set content type based on body type
1144
+ # 6. Set content type based on body type
968
1145
  if isinstance(body, dict):
969
1146
  headers["Content-Type"] = "application/json"
970
1147
  request_content = json.dumps(body).encode()
@@ -984,19 +1161,28 @@ async def _call_addon_api(
984
1161
  )
985
1162
  except httpx.TimeoutException:
986
1163
  raise_tool_error(
987
- create_timeout_error(
988
- f"add-on API call to '{addon_name}'",
989
- timeout,
1164
+ create_error_response(
1165
+ ErrorCode.TIMEOUT_OPERATION,
1166
+ f"Operation 'add-on API call to {addon_name!r}' timed out after {timeout}s",
990
1167
  details=f"path={path}, method={method}",
991
- context={"slug": slug, "path": path},
1168
+ context={
1169
+ "slug": slug,
1170
+ "path": path,
1171
+ "operation": f"add-on API call to '{addon_name}'",
1172
+ "timeout_seconds": timeout,
1173
+ "direct_port": bool(port),
1174
+ },
1175
+ suggestions=_addon_connection_failure_suggestions(client, port),
992
1176
  )
993
1177
  )
994
1178
  except httpx.ConnectError as e:
995
1179
  raise_tool_error(
996
- create_connection_error(
1180
+ create_error_response(
1181
+ ErrorCode.CONNECTION_FAILED,
997
1182
  f"Failed to connect to add-on '{addon_name}': {e!s}",
998
- details="Check that the add-on is running and Home Assistant Ingress is working",
999
- context={"slug": slug},
1183
+ details=f"url={url}",
1184
+ context={"slug": slug, "direct_port": bool(port)},
1185
+ suggestions=_addon_connection_failure_suggestions(client, port),
1000
1186
  )
1001
1187
  )
1002
1188
 
@@ -1101,16 +1287,21 @@ async def _call_addon_api(
1101
1287
 
1102
1288
  if response.status_code >= 400:
1103
1289
  result["error"] = f"Add-on API returned HTTP {response.status_code}"
1104
- # On 403/401, include addon config so the LLM can spot relevant settings
1105
- # (e.g., "leave_front_door_open", auth toggles, port mappings)
1106
- if response.status_code in (401, 403):
1107
- addon_options = addon.get("options")
1108
- addon_ports = addon.get("network") or addon.get("ports")
1109
- addon_host_network = addon.get("host_network")
1290
+ # 401 = auth credential problem (token/scope/session); IP-restriction
1291
+ # hint and addon_config attachment would misdirect.
1292
+ # 403 = forbidden (likely Nginx ACL); addon_config helps the LLM spot
1293
+ # relevant toggles like leave_front_door_open and port mappings.
1294
+ if response.status_code == 401:
1295
+ result["suggestion"] = (
1296
+ "Authentication failed. The ingress session may have expired, "
1297
+ "or your HA token may lack the required scope. Verify the "
1298
+ "token has admin rights and try again."
1299
+ )
1300
+ elif response.status_code == 403:
1110
1301
  result["addon_config"] = {
1111
- "options": addon_options,
1112
- "ports": addon_ports,
1113
- "host_network": addon_host_network,
1302
+ "options": addon.get("options"),
1303
+ "ports": addon.get("network") or addon.get("ports"),
1304
+ "host_network": addon.get("host_network"),
1114
1305
  "ingress_port": addon.get("ingress_port"),
1115
1306
  }
1116
1307
  result["suggestion"] = (
@@ -1411,7 +1602,11 @@ def register_addon_tools(mcp: Any, client: HomeAssistantClient, **kwargs: Any) -
1411
1602
  are fetched and merged automatically (including one level of nested dicts).
1412
1603
 
1413
1604
  **Proxy mode** (when path is provided):
1414
- Sends requests directly to the add-on container's own web API via HTTP or WebSocket.
1605
+ Routes HTTP or WebSocket requests through Home Assistant's Ingress
1606
+ proxy by default (works on HAOS, Supervised, and off-host PyPI/uvx
1607
+ installs). Pass `port=...` to bypass Ingress and connect directly to
1608
+ an add-on's container port — that mode requires the MCP host to
1609
+ share Home Assistant's container network (i.e. only the HAOS addon).
1415
1610
  Use ha_get_addon(slug="...") to discover available ports and endpoints.
1416
1611
 
1417
1612
  **Response shaping (proxy mode):**
@@ -290,7 +290,9 @@ class HistoryTools:
290
290
 
291
291
  # Connect to WebSocket (shared by both sources)
292
292
  ws_client, error = await get_connected_ws_client(
293
- self._client.base_url, self._client.token
293
+ self._client.base_url,
294
+ self._client.token,
295
+ verify_ssl=self._client.verify_ssl,
294
296
  )
295
297
  if error or ws_client is None:
296
298
  raise_tool_error(error or create_error_response(
@@ -415,7 +415,9 @@ class SystemTools:
415
415
  for subsequent optional fetches.
416
416
  """
417
417
  ws_client, error = await get_connected_ws_client(
418
- self._client.base_url, self._client.token
418
+ self._client.base_url,
419
+ self._client.token,
420
+ verify_ssl=self._client.verify_ssl,
419
421
  )
420
422
  if error or ws_client is None:
421
423
  raise_tool_error(error or create_error_response(
@@ -167,7 +167,9 @@ class TraceTools:
167
167
 
168
168
  # Connect to WebSocket
169
169
  ws_client, error = await get_connected_ws_client(
170
- self._client.base_url, self._client.token
170
+ self._client.base_url,
171
+ self._client.token,
172
+ verify_ssl=self._client.verify_ssl,
171
173
  )
172
174
  if error or ws_client is None:
173
175
  raise_tool_error(error or create_error_response(
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ha-mcp-dev
3
- Version: 7.4.1.dev422
3
+ Version: 7.4.1.dev424
4
4
  Summary: Home Assistant MCP Server - Complete control of Home Assistant through MCP
5
5
  Author-email: Julien <github@qc-h.net>
6
6
  License: MIT