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.
- {ha_mcp_dev-7.4.1.dev422/src/ha_mcp_dev.egg-info → ha_mcp_dev-7.4.1.dev424}/PKG-INFO +1 -1
- {ha_mcp_dev-7.4.1.dev422 → ha_mcp_dev-7.4.1.dev424}/pyproject.toml +1 -1
- {ha_mcp_dev-7.4.1.dev422 → ha_mcp_dev-7.4.1.dev424}/src/ha_mcp/client/rest_client.py +39 -3
- {ha_mcp_dev-7.4.1.dev422 → ha_mcp_dev-7.4.1.dev424}/src/ha_mcp/client/websocket_client.py +55 -3
- {ha_mcp_dev-7.4.1.dev422 → ha_mcp_dev-7.4.1.dev424}/src/ha_mcp/config.py +13 -0
- {ha_mcp_dev-7.4.1.dev422 → ha_mcp_dev-7.4.1.dev424}/src/ha_mcp/tools/backup.py +6 -2
- {ha_mcp_dev-7.4.1.dev422 → ha_mcp_dev-7.4.1.dev424}/src/ha_mcp/tools/helpers.py +6 -2
- {ha_mcp_dev-7.4.1.dev422 → ha_mcp_dev-7.4.1.dev424}/src/ha_mcp/tools/tools_addons.py +307 -112
- {ha_mcp_dev-7.4.1.dev422 → ha_mcp_dev-7.4.1.dev424}/src/ha_mcp/tools/tools_history.py +3 -1
- {ha_mcp_dev-7.4.1.dev422 → ha_mcp_dev-7.4.1.dev424}/src/ha_mcp/tools/tools_system.py +3 -1
- {ha_mcp_dev-7.4.1.dev422 → ha_mcp_dev-7.4.1.dev424}/src/ha_mcp/tools/tools_traces.py +3 -1
- {ha_mcp_dev-7.4.1.dev422 → ha_mcp_dev-7.4.1.dev424/src/ha_mcp_dev.egg-info}/PKG-INFO +1 -1
- {ha_mcp_dev-7.4.1.dev422 → ha_mcp_dev-7.4.1.dev424}/LICENSE +0 -0
- {ha_mcp_dev-7.4.1.dev422 → ha_mcp_dev-7.4.1.dev424}/MANIFEST.in +0 -0
- {ha_mcp_dev-7.4.1.dev422 → ha_mcp_dev-7.4.1.dev424}/README.md +0 -0
- {ha_mcp_dev-7.4.1.dev422 → ha_mcp_dev-7.4.1.dev424}/setup.cfg +0 -0
- {ha_mcp_dev-7.4.1.dev422 → ha_mcp_dev-7.4.1.dev424}/src/ha_mcp/__init__.py +0 -0
- {ha_mcp_dev-7.4.1.dev422 → ha_mcp_dev-7.4.1.dev424}/src/ha_mcp/__main__.py +0 -0
- {ha_mcp_dev-7.4.1.dev422 → ha_mcp_dev-7.4.1.dev424}/src/ha_mcp/_pypi_marker +0 -0
- {ha_mcp_dev-7.4.1.dev422 → ha_mcp_dev-7.4.1.dev424}/src/ha_mcp/_version.py +0 -0
- {ha_mcp_dev-7.4.1.dev422 → ha_mcp_dev-7.4.1.dev424}/src/ha_mcp/auth/__init__.py +0 -0
- {ha_mcp_dev-7.4.1.dev422 → ha_mcp_dev-7.4.1.dev424}/src/ha_mcp/auth/consent_form.py +0 -0
- {ha_mcp_dev-7.4.1.dev422 → ha_mcp_dev-7.4.1.dev424}/src/ha_mcp/auth/provider.py +0 -0
- {ha_mcp_dev-7.4.1.dev422 → ha_mcp_dev-7.4.1.dev424}/src/ha_mcp/client/__init__.py +0 -0
- {ha_mcp_dev-7.4.1.dev422 → ha_mcp_dev-7.4.1.dev424}/src/ha_mcp/client/websocket_listener.py +0 -0
- {ha_mcp_dev-7.4.1.dev422 → ha_mcp_dev-7.4.1.dev424}/src/ha_mcp/errors.py +0 -0
- {ha_mcp_dev-7.4.1.dev422 → ha_mcp_dev-7.4.1.dev424}/src/ha_mcp/py.typed +0 -0
- {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
- {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
- {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
- {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
- {ha_mcp_dev-7.4.1.dev422 → ha_mcp_dev-7.4.1.dev424}/src/ha_mcp/resources/skills-vendor/AGENTS.md +0 -0
- {ha_mcp_dev-7.4.1.dev422 → ha_mcp_dev-7.4.1.dev424}/src/ha_mcp/resources/skills-vendor/CLAUDE.md +0 -0
- {ha_mcp_dev-7.4.1.dev422 → ha_mcp_dev-7.4.1.dev424}/src/ha_mcp/resources/skills-vendor/CONTRIBUTING.md +0 -0
- {ha_mcp_dev-7.4.1.dev422 → ha_mcp_dev-7.4.1.dev424}/src/ha_mcp/resources/skills-vendor/LICENSE +0 -0
- {ha_mcp_dev-7.4.1.dev422 → ha_mcp_dev-7.4.1.dev424}/src/ha_mcp/resources/skills-vendor/README.md +0 -0
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {ha_mcp_dev-7.4.1.dev422 → ha_mcp_dev-7.4.1.dev424}/src/ha_mcp/server.py +0 -0
- {ha_mcp_dev-7.4.1.dev422 → ha_mcp_dev-7.4.1.dev424}/src/ha_mcp/settings_ui.py +0 -0
- {ha_mcp_dev-7.4.1.dev422 → ha_mcp_dev-7.4.1.dev424}/src/ha_mcp/smoke_test.py +0 -0
- {ha_mcp_dev-7.4.1.dev422 → ha_mcp_dev-7.4.1.dev424}/src/ha_mcp/tools/__init__.py +0 -0
- {ha_mcp_dev-7.4.1.dev422 → ha_mcp_dev-7.4.1.dev424}/src/ha_mcp/tools/best_practice_checker.py +0 -0
- {ha_mcp_dev-7.4.1.dev422 → ha_mcp_dev-7.4.1.dev424}/src/ha_mcp/tools/device_control.py +0 -0
- {ha_mcp_dev-7.4.1.dev422 → ha_mcp_dev-7.4.1.dev424}/src/ha_mcp/tools/enhanced.py +0 -0
- {ha_mcp_dev-7.4.1.dev422 → ha_mcp_dev-7.4.1.dev424}/src/ha_mcp/tools/reference_validator.py +0 -0
- {ha_mcp_dev-7.4.1.dev422 → ha_mcp_dev-7.4.1.dev424}/src/ha_mcp/tools/registry.py +0 -0
- {ha_mcp_dev-7.4.1.dev422 → ha_mcp_dev-7.4.1.dev424}/src/ha_mcp/tools/smart_search.py +0 -0
- {ha_mcp_dev-7.4.1.dev422 → ha_mcp_dev-7.4.1.dev424}/src/ha_mcp/tools/tools_areas.py +0 -0
- {ha_mcp_dev-7.4.1.dev422 → ha_mcp_dev-7.4.1.dev424}/src/ha_mcp/tools/tools_blueprints.py +0 -0
- {ha_mcp_dev-7.4.1.dev422 → ha_mcp_dev-7.4.1.dev424}/src/ha_mcp/tools/tools_bug_report.py +0 -0
- {ha_mcp_dev-7.4.1.dev422 → ha_mcp_dev-7.4.1.dev424}/src/ha_mcp/tools/tools_calendar.py +0 -0
- {ha_mcp_dev-7.4.1.dev422 → ha_mcp_dev-7.4.1.dev424}/src/ha_mcp/tools/tools_camera.py +0 -0
- {ha_mcp_dev-7.4.1.dev422 → ha_mcp_dev-7.4.1.dev424}/src/ha_mcp/tools/tools_categories.py +0 -0
- {ha_mcp_dev-7.4.1.dev422 → ha_mcp_dev-7.4.1.dev424}/src/ha_mcp/tools/tools_config_automations.py +0 -0
- {ha_mcp_dev-7.4.1.dev422 → ha_mcp_dev-7.4.1.dev424}/src/ha_mcp/tools/tools_config_dashboards.py +0 -0
- {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
- {ha_mcp_dev-7.4.1.dev422 → ha_mcp_dev-7.4.1.dev424}/src/ha_mcp/tools/tools_config_helpers.py +0 -0
- {ha_mcp_dev-7.4.1.dev422 → ha_mcp_dev-7.4.1.dev424}/src/ha_mcp/tools/tools_config_scripts.py +0 -0
- {ha_mcp_dev-7.4.1.dev422 → ha_mcp_dev-7.4.1.dev424}/src/ha_mcp/tools/tools_energy.py +0 -0
- {ha_mcp_dev-7.4.1.dev422 → ha_mcp_dev-7.4.1.dev424}/src/ha_mcp/tools/tools_entities.py +0 -0
- {ha_mcp_dev-7.4.1.dev422 → ha_mcp_dev-7.4.1.dev424}/src/ha_mcp/tools/tools_filesystem.py +0 -0
- {ha_mcp_dev-7.4.1.dev422 → ha_mcp_dev-7.4.1.dev424}/src/ha_mcp/tools/tools_groups.py +0 -0
- {ha_mcp_dev-7.4.1.dev422 → ha_mcp_dev-7.4.1.dev424}/src/ha_mcp/tools/tools_hacs.py +0 -0
- {ha_mcp_dev-7.4.1.dev422 → ha_mcp_dev-7.4.1.dev424}/src/ha_mcp/tools/tools_integrations.py +0 -0
- {ha_mcp_dev-7.4.1.dev422 → ha_mcp_dev-7.4.1.dev424}/src/ha_mcp/tools/tools_labels.py +0 -0
- {ha_mcp_dev-7.4.1.dev422 → ha_mcp_dev-7.4.1.dev424}/src/ha_mcp/tools/tools_mcp_component.py +0 -0
- {ha_mcp_dev-7.4.1.dev422 → ha_mcp_dev-7.4.1.dev424}/src/ha_mcp/tools/tools_registry.py +0 -0
- {ha_mcp_dev-7.4.1.dev422 → ha_mcp_dev-7.4.1.dev424}/src/ha_mcp/tools/tools_resources.py +0 -0
- {ha_mcp_dev-7.4.1.dev422 → ha_mcp_dev-7.4.1.dev424}/src/ha_mcp/tools/tools_search.py +0 -0
- {ha_mcp_dev-7.4.1.dev422 → ha_mcp_dev-7.4.1.dev424}/src/ha_mcp/tools/tools_service.py +0 -0
- {ha_mcp_dev-7.4.1.dev422 → ha_mcp_dev-7.4.1.dev424}/src/ha_mcp/tools/tools_services.py +0 -0
- {ha_mcp_dev-7.4.1.dev422 → ha_mcp_dev-7.4.1.dev424}/src/ha_mcp/tools/tools_todo.py +0 -0
- {ha_mcp_dev-7.4.1.dev422 → ha_mcp_dev-7.4.1.dev424}/src/ha_mcp/tools/tools_updates.py +0 -0
- {ha_mcp_dev-7.4.1.dev422 → ha_mcp_dev-7.4.1.dev424}/src/ha_mcp/tools/tools_utility.py +0 -0
- {ha_mcp_dev-7.4.1.dev422 → ha_mcp_dev-7.4.1.dev424}/src/ha_mcp/tools/tools_voice_assistant.py +0 -0
- {ha_mcp_dev-7.4.1.dev422 → ha_mcp_dev-7.4.1.dev424}/src/ha_mcp/tools/tools_yaml_config.py +0 -0
- {ha_mcp_dev-7.4.1.dev422 → ha_mcp_dev-7.4.1.dev424}/src/ha_mcp/tools/tools_zones.py +0 -0
- {ha_mcp_dev-7.4.1.dev422 → ha_mcp_dev-7.4.1.dev424}/src/ha_mcp/tools/util_helpers.py +0 -0
- {ha_mcp_dev-7.4.1.dev422 → ha_mcp_dev-7.4.1.dev424}/src/ha_mcp/transforms/__init__.py +0 -0
- {ha_mcp_dev-7.4.1.dev422 → ha_mcp_dev-7.4.1.dev424}/src/ha_mcp/transforms/categorized_search.py +0 -0
- {ha_mcp_dev-7.4.1.dev422 → ha_mcp_dev-7.4.1.dev424}/src/ha_mcp/utils/__init__.py +0 -0
- {ha_mcp_dev-7.4.1.dev422 → ha_mcp_dev-7.4.1.dev424}/src/ha_mcp/utils/config_hash.py +0 -0
- {ha_mcp_dev-7.4.1.dev422 → ha_mcp_dev-7.4.1.dev424}/src/ha_mcp/utils/domain_handlers.py +0 -0
- {ha_mcp_dev-7.4.1.dev422 → ha_mcp_dev-7.4.1.dev424}/src/ha_mcp/utils/fuzzy_search.py +0 -0
- {ha_mcp_dev-7.4.1.dev422 → ha_mcp_dev-7.4.1.dev424}/src/ha_mcp/utils/operation_manager.py +0 -0
- {ha_mcp_dev-7.4.1.dev422 → ha_mcp_dev-7.4.1.dev424}/src/ha_mcp/utils/python_sandbox.py +0 -0
- {ha_mcp_dev-7.4.1.dev422 → ha_mcp_dev-7.4.1.dev424}/src/ha_mcp/utils/usage_logger.py +0 -0
- {ha_mcp_dev-7.4.1.dev422 → ha_mcp_dev-7.4.1.dev424}/src/ha_mcp_dev.egg-info/SOURCES.txt +0 -0
- {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
- {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
- {ha_mcp_dev-7.4.1.dev422 → ha_mcp_dev-7.4.1.dev424}/src/ha_mcp_dev.egg-info/requires.txt +0 -0
- {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
- {ha_mcp_dev-7.4.1.dev422 → ha_mcp_dev-7.4.1.dev424}/tests/__init__.py +0 -0
- {ha_mcp_dev-7.4.1.dev422 → ha_mcp_dev-7.4.1.dev424}/tests/test_constants.py +0 -0
- {ha_mcp_dev-7.4.1.dev422 → ha_mcp_dev-7.4.1.dev424}/tests/test_env_manager.py +0 -0
|
@@ -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.
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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.
|
|
595
|
-
|
|
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
|
-
|
|
732
|
-
|
|
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={
|
|
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
|
-
|
|
946
|
+
create_error_response(
|
|
947
|
+
ErrorCode.CONNECTION_FAILED,
|
|
741
948
|
f"Failed to connect to add-on '{addon_name}' WebSocket: {e!s}",
|
|
742
|
-
details="
|
|
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
|
|
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.
|
|
923
|
-
|
|
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.
|
|
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
|
-
|
|
988
|
-
|
|
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={
|
|
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
|
-
|
|
1180
|
+
create_error_response(
|
|
1181
|
+
ErrorCode.CONNECTION_FAILED,
|
|
997
1182
|
f"Failed to connect to add-on '{addon_name}': {e!s}",
|
|
998
|
-
details="
|
|
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
|
-
#
|
|
1105
|
-
#
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
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":
|
|
1112
|
-
"ports":
|
|
1113
|
-
"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
|
-
|
|
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,
|
|
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,
|
|
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,
|
|
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(
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{ha_mcp_dev-7.4.1.dev422 → ha_mcp_dev-7.4.1.dev424}/src/ha_mcp/resources/skills-vendor/AGENTS.md
RENAMED
|
File without changes
|
{ha_mcp_dev-7.4.1.dev422 → ha_mcp_dev-7.4.1.dev424}/src/ha_mcp/resources/skills-vendor/CLAUDE.md
RENAMED
|
File without changes
|
|
File without changes
|
{ha_mcp_dev-7.4.1.dev422 → ha_mcp_dev-7.4.1.dev424}/src/ha_mcp/resources/skills-vendor/LICENSE
RENAMED
|
File without changes
|
{ha_mcp_dev-7.4.1.dev422 → ha_mcp_dev-7.4.1.dev424}/src/ha_mcp/resources/skills-vendor/README.md
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{ha_mcp_dev-7.4.1.dev422 → ha_mcp_dev-7.4.1.dev424}/src/ha_mcp/tools/best_practice_checker.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{ha_mcp_dev-7.4.1.dev422 → ha_mcp_dev-7.4.1.dev424}/src/ha_mcp/tools/tools_config_automations.py
RENAMED
|
File without changes
|
{ha_mcp_dev-7.4.1.dev422 → ha_mcp_dev-7.4.1.dev424}/src/ha_mcp/tools/tools_config_dashboards.py
RENAMED
|
File without changes
|
{ha_mcp_dev-7.4.1.dev422 → ha_mcp_dev-7.4.1.dev424}/src/ha_mcp/tools/tools_config_entry_flow.py
RENAMED
|
File without changes
|
{ha_mcp_dev-7.4.1.dev422 → ha_mcp_dev-7.4.1.dev424}/src/ha_mcp/tools/tools_config_helpers.py
RENAMED
|
File without changes
|
{ha_mcp_dev-7.4.1.dev422 → ha_mcp_dev-7.4.1.dev424}/src/ha_mcp/tools/tools_config_scripts.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{ha_mcp_dev-7.4.1.dev422 → ha_mcp_dev-7.4.1.dev424}/src/ha_mcp/tools/tools_voice_assistant.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{ha_mcp_dev-7.4.1.dev422 → ha_mcp_dev-7.4.1.dev424}/src/ha_mcp/transforms/categorized_search.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{ha_mcp_dev-7.4.1.dev422 → ha_mcp_dev-7.4.1.dev424}/src/ha_mcp_dev.egg-info/dependency_links.txt
RENAMED
|
File without changes
|
{ha_mcp_dev-7.4.1.dev422 → ha_mcp_dev-7.4.1.dev424}/src/ha_mcp_dev.egg-info/entry_points.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|