ha-mcp-dev 7.4.1.dev423__tar.gz → 7.4.1.dev425__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.dev423/src/ha_mcp_dev.egg-info → ha_mcp_dev-7.4.1.dev425}/PKG-INFO +1 -1
- {ha_mcp_dev-7.4.1.dev423 → ha_mcp_dev-7.4.1.dev425}/pyproject.toml +1 -1
- {ha_mcp_dev-7.4.1.dev423 → ha_mcp_dev-7.4.1.dev425}/src/ha_mcp/tools/tools_addons.py +304 -111
- {ha_mcp_dev-7.4.1.dev423 → ha_mcp_dev-7.4.1.dev425}/src/ha_mcp/tools/tools_filesystem.py +10 -6
- {ha_mcp_dev-7.4.1.dev423 → ha_mcp_dev-7.4.1.dev425}/src/ha_mcp/tools/tools_yaml_config.py +76 -3
- {ha_mcp_dev-7.4.1.dev423 → ha_mcp_dev-7.4.1.dev425/src/ha_mcp_dev.egg-info}/PKG-INFO +1 -1
- {ha_mcp_dev-7.4.1.dev423 → ha_mcp_dev-7.4.1.dev425}/LICENSE +0 -0
- {ha_mcp_dev-7.4.1.dev423 → ha_mcp_dev-7.4.1.dev425}/MANIFEST.in +0 -0
- {ha_mcp_dev-7.4.1.dev423 → ha_mcp_dev-7.4.1.dev425}/README.md +0 -0
- {ha_mcp_dev-7.4.1.dev423 → ha_mcp_dev-7.4.1.dev425}/setup.cfg +0 -0
- {ha_mcp_dev-7.4.1.dev423 → ha_mcp_dev-7.4.1.dev425}/src/ha_mcp/__init__.py +0 -0
- {ha_mcp_dev-7.4.1.dev423 → ha_mcp_dev-7.4.1.dev425}/src/ha_mcp/__main__.py +0 -0
- {ha_mcp_dev-7.4.1.dev423 → ha_mcp_dev-7.4.1.dev425}/src/ha_mcp/_pypi_marker +0 -0
- {ha_mcp_dev-7.4.1.dev423 → ha_mcp_dev-7.4.1.dev425}/src/ha_mcp/_version.py +0 -0
- {ha_mcp_dev-7.4.1.dev423 → ha_mcp_dev-7.4.1.dev425}/src/ha_mcp/auth/__init__.py +0 -0
- {ha_mcp_dev-7.4.1.dev423 → ha_mcp_dev-7.4.1.dev425}/src/ha_mcp/auth/consent_form.py +0 -0
- {ha_mcp_dev-7.4.1.dev423 → ha_mcp_dev-7.4.1.dev425}/src/ha_mcp/auth/provider.py +0 -0
- {ha_mcp_dev-7.4.1.dev423 → ha_mcp_dev-7.4.1.dev425}/src/ha_mcp/client/__init__.py +0 -0
- {ha_mcp_dev-7.4.1.dev423 → ha_mcp_dev-7.4.1.dev425}/src/ha_mcp/client/rest_client.py +0 -0
- {ha_mcp_dev-7.4.1.dev423 → ha_mcp_dev-7.4.1.dev425}/src/ha_mcp/client/websocket_client.py +0 -0
- {ha_mcp_dev-7.4.1.dev423 → ha_mcp_dev-7.4.1.dev425}/src/ha_mcp/client/websocket_listener.py +0 -0
- {ha_mcp_dev-7.4.1.dev423 → ha_mcp_dev-7.4.1.dev425}/src/ha_mcp/config.py +0 -0
- {ha_mcp_dev-7.4.1.dev423 → ha_mcp_dev-7.4.1.dev425}/src/ha_mcp/errors.py +0 -0
- {ha_mcp_dev-7.4.1.dev423 → ha_mcp_dev-7.4.1.dev425}/src/ha_mcp/py.typed +0 -0
- {ha_mcp_dev-7.4.1.dev423 → ha_mcp_dev-7.4.1.dev425}/src/ha_mcp/resources/skills-vendor/.claude/settings.json +0 -0
- {ha_mcp_dev-7.4.1.dev423 → ha_mcp_dev-7.4.1.dev425}/src/ha_mcp/resources/skills-vendor/.claude-plugin/marketplace.json +0 -0
- {ha_mcp_dev-7.4.1.dev423 → ha_mcp_dev-7.4.1.dev425}/src/ha_mcp/resources/skills-vendor/.claude-plugin/plugin.json +0 -0
- {ha_mcp_dev-7.4.1.dev423 → ha_mcp_dev-7.4.1.dev425}/src/ha_mcp/resources/skills-vendor/.github/ISSUE_TEMPLATE/skill-rca.md +0 -0
- {ha_mcp_dev-7.4.1.dev423 → ha_mcp_dev-7.4.1.dev425}/src/ha_mcp/resources/skills-vendor/AGENTS.md +0 -0
- {ha_mcp_dev-7.4.1.dev423 → ha_mcp_dev-7.4.1.dev425}/src/ha_mcp/resources/skills-vendor/CLAUDE.md +0 -0
- {ha_mcp_dev-7.4.1.dev423 → ha_mcp_dev-7.4.1.dev425}/src/ha_mcp/resources/skills-vendor/CONTRIBUTING.md +0 -0
- {ha_mcp_dev-7.4.1.dev423 → ha_mcp_dev-7.4.1.dev425}/src/ha_mcp/resources/skills-vendor/LICENSE +0 -0
- {ha_mcp_dev-7.4.1.dev423 → ha_mcp_dev-7.4.1.dev425}/src/ha_mcp/resources/skills-vendor/README.md +0 -0
- {ha_mcp_dev-7.4.1.dev423 → ha_mcp_dev-7.4.1.dev425}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/SKILL.md +0 -0
- {ha_mcp_dev-7.4.1.dev423 → ha_mcp_dev-7.4.1.dev425}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/evals/evals.json +0 -0
- {ha_mcp_dev-7.4.1.dev423 → ha_mcp_dev-7.4.1.dev425}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/automation-patterns.md +0 -0
- {ha_mcp_dev-7.4.1.dev423 → ha_mcp_dev-7.4.1.dev425}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/dashboard-cards.md +0 -0
- {ha_mcp_dev-7.4.1.dev423 → ha_mcp_dev-7.4.1.dev425}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/dashboard-guide.md +0 -0
- {ha_mcp_dev-7.4.1.dev423 → ha_mcp_dev-7.4.1.dev425}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/device-control.md +0 -0
- {ha_mcp_dev-7.4.1.dev423 → ha_mcp_dev-7.4.1.dev425}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/domain-docs.md +0 -0
- {ha_mcp_dev-7.4.1.dev423 → ha_mcp_dev-7.4.1.dev425}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/examples.yaml +0 -0
- {ha_mcp_dev-7.4.1.dev423 → ha_mcp_dev-7.4.1.dev425}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/helper-selection.md +0 -0
- {ha_mcp_dev-7.4.1.dev423 → ha_mcp_dev-7.4.1.dev425}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/safe-refactoring.md +0 -0
- {ha_mcp_dev-7.4.1.dev423 → ha_mcp_dev-7.4.1.dev425}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/template-guidelines.md +0 -0
- {ha_mcp_dev-7.4.1.dev423 → ha_mcp_dev-7.4.1.dev425}/src/ha_mcp/server.py +0 -0
- {ha_mcp_dev-7.4.1.dev423 → ha_mcp_dev-7.4.1.dev425}/src/ha_mcp/settings_ui.py +0 -0
- {ha_mcp_dev-7.4.1.dev423 → ha_mcp_dev-7.4.1.dev425}/src/ha_mcp/smoke_test.py +0 -0
- {ha_mcp_dev-7.4.1.dev423 → ha_mcp_dev-7.4.1.dev425}/src/ha_mcp/tools/__init__.py +0 -0
- {ha_mcp_dev-7.4.1.dev423 → ha_mcp_dev-7.4.1.dev425}/src/ha_mcp/tools/backup.py +0 -0
- {ha_mcp_dev-7.4.1.dev423 → ha_mcp_dev-7.4.1.dev425}/src/ha_mcp/tools/best_practice_checker.py +0 -0
- {ha_mcp_dev-7.4.1.dev423 → ha_mcp_dev-7.4.1.dev425}/src/ha_mcp/tools/device_control.py +0 -0
- {ha_mcp_dev-7.4.1.dev423 → ha_mcp_dev-7.4.1.dev425}/src/ha_mcp/tools/enhanced.py +0 -0
- {ha_mcp_dev-7.4.1.dev423 → ha_mcp_dev-7.4.1.dev425}/src/ha_mcp/tools/helpers.py +0 -0
- {ha_mcp_dev-7.4.1.dev423 → ha_mcp_dev-7.4.1.dev425}/src/ha_mcp/tools/reference_validator.py +0 -0
- {ha_mcp_dev-7.4.1.dev423 → ha_mcp_dev-7.4.1.dev425}/src/ha_mcp/tools/registry.py +0 -0
- {ha_mcp_dev-7.4.1.dev423 → ha_mcp_dev-7.4.1.dev425}/src/ha_mcp/tools/smart_search.py +0 -0
- {ha_mcp_dev-7.4.1.dev423 → ha_mcp_dev-7.4.1.dev425}/src/ha_mcp/tools/tools_areas.py +0 -0
- {ha_mcp_dev-7.4.1.dev423 → ha_mcp_dev-7.4.1.dev425}/src/ha_mcp/tools/tools_blueprints.py +0 -0
- {ha_mcp_dev-7.4.1.dev423 → ha_mcp_dev-7.4.1.dev425}/src/ha_mcp/tools/tools_bug_report.py +0 -0
- {ha_mcp_dev-7.4.1.dev423 → ha_mcp_dev-7.4.1.dev425}/src/ha_mcp/tools/tools_calendar.py +0 -0
- {ha_mcp_dev-7.4.1.dev423 → ha_mcp_dev-7.4.1.dev425}/src/ha_mcp/tools/tools_camera.py +0 -0
- {ha_mcp_dev-7.4.1.dev423 → ha_mcp_dev-7.4.1.dev425}/src/ha_mcp/tools/tools_categories.py +0 -0
- {ha_mcp_dev-7.4.1.dev423 → ha_mcp_dev-7.4.1.dev425}/src/ha_mcp/tools/tools_config_automations.py +0 -0
- {ha_mcp_dev-7.4.1.dev423 → ha_mcp_dev-7.4.1.dev425}/src/ha_mcp/tools/tools_config_dashboards.py +0 -0
- {ha_mcp_dev-7.4.1.dev423 → ha_mcp_dev-7.4.1.dev425}/src/ha_mcp/tools/tools_config_entry_flow.py +0 -0
- {ha_mcp_dev-7.4.1.dev423 → ha_mcp_dev-7.4.1.dev425}/src/ha_mcp/tools/tools_config_helpers.py +0 -0
- {ha_mcp_dev-7.4.1.dev423 → ha_mcp_dev-7.4.1.dev425}/src/ha_mcp/tools/tools_config_scripts.py +0 -0
- {ha_mcp_dev-7.4.1.dev423 → ha_mcp_dev-7.4.1.dev425}/src/ha_mcp/tools/tools_energy.py +0 -0
- {ha_mcp_dev-7.4.1.dev423 → ha_mcp_dev-7.4.1.dev425}/src/ha_mcp/tools/tools_entities.py +0 -0
- {ha_mcp_dev-7.4.1.dev423 → ha_mcp_dev-7.4.1.dev425}/src/ha_mcp/tools/tools_groups.py +0 -0
- {ha_mcp_dev-7.4.1.dev423 → ha_mcp_dev-7.4.1.dev425}/src/ha_mcp/tools/tools_hacs.py +0 -0
- {ha_mcp_dev-7.4.1.dev423 → ha_mcp_dev-7.4.1.dev425}/src/ha_mcp/tools/tools_history.py +0 -0
- {ha_mcp_dev-7.4.1.dev423 → ha_mcp_dev-7.4.1.dev425}/src/ha_mcp/tools/tools_integrations.py +0 -0
- {ha_mcp_dev-7.4.1.dev423 → ha_mcp_dev-7.4.1.dev425}/src/ha_mcp/tools/tools_labels.py +0 -0
- {ha_mcp_dev-7.4.1.dev423 → ha_mcp_dev-7.4.1.dev425}/src/ha_mcp/tools/tools_mcp_component.py +0 -0
- {ha_mcp_dev-7.4.1.dev423 → ha_mcp_dev-7.4.1.dev425}/src/ha_mcp/tools/tools_registry.py +0 -0
- {ha_mcp_dev-7.4.1.dev423 → ha_mcp_dev-7.4.1.dev425}/src/ha_mcp/tools/tools_resources.py +0 -0
- {ha_mcp_dev-7.4.1.dev423 → ha_mcp_dev-7.4.1.dev425}/src/ha_mcp/tools/tools_search.py +0 -0
- {ha_mcp_dev-7.4.1.dev423 → ha_mcp_dev-7.4.1.dev425}/src/ha_mcp/tools/tools_service.py +0 -0
- {ha_mcp_dev-7.4.1.dev423 → ha_mcp_dev-7.4.1.dev425}/src/ha_mcp/tools/tools_services.py +0 -0
- {ha_mcp_dev-7.4.1.dev423 → ha_mcp_dev-7.4.1.dev425}/src/ha_mcp/tools/tools_system.py +0 -0
- {ha_mcp_dev-7.4.1.dev423 → ha_mcp_dev-7.4.1.dev425}/src/ha_mcp/tools/tools_todo.py +0 -0
- {ha_mcp_dev-7.4.1.dev423 → ha_mcp_dev-7.4.1.dev425}/src/ha_mcp/tools/tools_traces.py +0 -0
- {ha_mcp_dev-7.4.1.dev423 → ha_mcp_dev-7.4.1.dev425}/src/ha_mcp/tools/tools_updates.py +0 -0
- {ha_mcp_dev-7.4.1.dev423 → ha_mcp_dev-7.4.1.dev425}/src/ha_mcp/tools/tools_utility.py +0 -0
- {ha_mcp_dev-7.4.1.dev423 → ha_mcp_dev-7.4.1.dev425}/src/ha_mcp/tools/tools_voice_assistant.py +0 -0
- {ha_mcp_dev-7.4.1.dev423 → ha_mcp_dev-7.4.1.dev425}/src/ha_mcp/tools/tools_zones.py +0 -0
- {ha_mcp_dev-7.4.1.dev423 → ha_mcp_dev-7.4.1.dev425}/src/ha_mcp/tools/util_helpers.py +0 -0
- {ha_mcp_dev-7.4.1.dev423 → ha_mcp_dev-7.4.1.dev425}/src/ha_mcp/transforms/__init__.py +0 -0
- {ha_mcp_dev-7.4.1.dev423 → ha_mcp_dev-7.4.1.dev425}/src/ha_mcp/transforms/categorized_search.py +0 -0
- {ha_mcp_dev-7.4.1.dev423 → ha_mcp_dev-7.4.1.dev425}/src/ha_mcp/utils/__init__.py +0 -0
- {ha_mcp_dev-7.4.1.dev423 → ha_mcp_dev-7.4.1.dev425}/src/ha_mcp/utils/config_hash.py +0 -0
- {ha_mcp_dev-7.4.1.dev423 → ha_mcp_dev-7.4.1.dev425}/src/ha_mcp/utils/domain_handlers.py +0 -0
- {ha_mcp_dev-7.4.1.dev423 → ha_mcp_dev-7.4.1.dev425}/src/ha_mcp/utils/fuzzy_search.py +0 -0
- {ha_mcp_dev-7.4.1.dev423 → ha_mcp_dev-7.4.1.dev425}/src/ha_mcp/utils/operation_manager.py +0 -0
- {ha_mcp_dev-7.4.1.dev423 → ha_mcp_dev-7.4.1.dev425}/src/ha_mcp/utils/python_sandbox.py +0 -0
- {ha_mcp_dev-7.4.1.dev423 → ha_mcp_dev-7.4.1.dev425}/src/ha_mcp/utils/usage_logger.py +0 -0
- {ha_mcp_dev-7.4.1.dev423 → ha_mcp_dev-7.4.1.dev425}/src/ha_mcp_dev.egg-info/SOURCES.txt +0 -0
- {ha_mcp_dev-7.4.1.dev423 → ha_mcp_dev-7.4.1.dev425}/src/ha_mcp_dev.egg-info/dependency_links.txt +0 -0
- {ha_mcp_dev-7.4.1.dev423 → ha_mcp_dev-7.4.1.dev425}/src/ha_mcp_dev.egg-info/entry_points.txt +0 -0
- {ha_mcp_dev-7.4.1.dev423 → ha_mcp_dev-7.4.1.dev425}/src/ha_mcp_dev.egg-info/requires.txt +0 -0
- {ha_mcp_dev-7.4.1.dev423 → ha_mcp_dev-7.4.1.dev425}/src/ha_mcp_dev.egg-info/top_level.txt +0 -0
- {ha_mcp_dev-7.4.1.dev423 → ha_mcp_dev-7.4.1.dev425}/tests/__init__.py +0 -0
- {ha_mcp_dev-7.4.1.dev423 → ha_mcp_dev-7.4.1.dev425}/tests/test_constants.py +0 -0
- {ha_mcp_dev-7.4.1.dev423 → ha_mcp_dev-7.4.1.dev425}/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.dev425"
|
|
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"
|
|
@@ -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
|
|
@@ -288,6 +288,217 @@ async def _supervisor_api_call(
|
|
|
288
288
|
pass
|
|
289
289
|
|
|
290
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
|
+
|
|
291
502
|
async def get_addon_info(client: HomeAssistantClient, slug: str) -> dict[str, Any]:
|
|
292
503
|
"""Get detailed info for a specific add-on.
|
|
293
504
|
|
|
@@ -519,6 +730,11 @@ async def _call_addon_ws(
|
|
|
519
730
|
) -> dict[str, Any]:
|
|
520
731
|
"""Connect to an add-on's WebSocket API and collect messages.
|
|
521
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
|
+
|
|
522
738
|
Args:
|
|
523
739
|
client: Home Assistant REST client
|
|
524
740
|
slug: Add-on slug (e.g., "5c53de3b_esphome")
|
|
@@ -593,38 +809,8 @@ async def _call_addon_ws(
|
|
|
593
809
|
)
|
|
594
810
|
)
|
|
595
811
|
|
|
596
|
-
# 5.
|
|
597
|
-
|
|
598
|
-
if port:
|
|
599
|
-
if not addon_ip:
|
|
600
|
-
raise_tool_error(
|
|
601
|
-
create_error_response(
|
|
602
|
-
ErrorCode.INTERNAL_ERROR,
|
|
603
|
-
f"Add-on '{addon_name}' is missing ip_address",
|
|
604
|
-
context={"slug": slug},
|
|
605
|
-
)
|
|
606
|
-
)
|
|
607
|
-
target_port = port
|
|
608
|
-
else:
|
|
609
|
-
ingress_port = addon.get("ingress_port")
|
|
610
|
-
if not addon_ip or not ingress_port:
|
|
611
|
-
raise_tool_error(
|
|
612
|
-
create_error_response(
|
|
613
|
-
ErrorCode.INTERNAL_ERROR,
|
|
614
|
-
f"Add-on '{addon_name}' is missing network info",
|
|
615
|
-
context={"slug": slug},
|
|
616
|
-
)
|
|
617
|
-
)
|
|
618
|
-
target_port = ingress_port
|
|
619
|
-
|
|
620
|
-
ws_url = f"ws://{addon_ip}:{target_port}/{normalized}"
|
|
621
|
-
|
|
622
|
-
# 6. Build connection headers
|
|
623
|
-
headers: dict[str, str] = {}
|
|
624
|
-
if not port:
|
|
625
|
-
ingress_entry = addon.get("ingress_entry", "")
|
|
626
|
-
headers["X-Ingress-Path"] = ingress_entry
|
|
627
|
-
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)
|
|
628
814
|
|
|
629
815
|
# 7. Connect and exchange messages
|
|
630
816
|
collected: list[str] = []
|
|
@@ -705,14 +891,25 @@ async def _call_addon_ws(
|
|
|
705
891
|
total_size += len(clean)
|
|
706
892
|
|
|
707
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
|
+
]
|
|
708
908
|
raise_tool_error(
|
|
709
909
|
create_error_response(
|
|
710
910
|
ErrorCode.SERVICE_CALL_FAILED,
|
|
711
911
|
f"WebSocket handshake failed with '{addon_name}': {e!s}",
|
|
712
|
-
suggestions=
|
|
713
|
-
"Check that the add-on supports WebSocket on this path",
|
|
714
|
-
f"Use ha_get_addon(slug='{slug}') to inspect available endpoints",
|
|
715
|
-
],
|
|
912
|
+
suggestions=suggestions,
|
|
716
913
|
context={"slug": slug, "path": path},
|
|
717
914
|
)
|
|
718
915
|
)
|
|
@@ -730,19 +927,28 @@ async def _call_addon_ws(
|
|
|
730
927
|
)
|
|
731
928
|
except TimeoutError:
|
|
732
929
|
raise_tool_error(
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
timeout,
|
|
930
|
+
create_error_response(
|
|
931
|
+
ErrorCode.TIMEOUT_OPERATION,
|
|
932
|
+
f"Operation 'WebSocket connection to {addon_name!r}' timed out after {timeout}s",
|
|
736
933
|
details=f"path={path}",
|
|
737
|
-
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),
|
|
738
942
|
)
|
|
739
943
|
)
|
|
740
944
|
except OSError as e:
|
|
741
945
|
raise_tool_error(
|
|
742
|
-
|
|
946
|
+
create_error_response(
|
|
947
|
+
ErrorCode.CONNECTION_FAILED,
|
|
743
948
|
f"Failed to connect to add-on '{addon_name}' WebSocket: {e!s}",
|
|
744
|
-
details="
|
|
745
|
-
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),
|
|
746
952
|
)
|
|
747
953
|
)
|
|
748
954
|
|
|
@@ -854,7 +1060,21 @@ async def _call_addon_api(
|
|
|
854
1060
|
limit: int | None = None,
|
|
855
1061
|
python_transform: str | None = None,
|
|
856
1062
|
) -> dict[str, Any]:
|
|
857
|
-
"""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.
|
|
858
1078
|
|
|
859
1079
|
Args:
|
|
860
1080
|
client: Home Assistant REST client
|
|
@@ -870,9 +1090,6 @@ async def _call_addon_api(
|
|
|
870
1090
|
parsed response body. The variable ``response`` is bound to
|
|
871
1091
|
``dict | list | str`` depending on content-type. Transform runs
|
|
872
1092
|
after offset/limit slicing.
|
|
873
|
-
|
|
874
|
-
Returns:
|
|
875
|
-
Dictionary with response data, status code, and content type.
|
|
876
1093
|
"""
|
|
877
1094
|
# 1. Sanitize path to prevent traversal attacks (including URL-encoded)
|
|
878
1095
|
normalized = unquote(path).lstrip("/")
|
|
@@ -921,52 +1138,10 @@ async def _call_addon_api(
|
|
|
921
1138
|
)
|
|
922
1139
|
)
|
|
923
1140
|
|
|
924
|
-
# 5.
|
|
925
|
-
|
|
1141
|
+
# 5. Resolve route (direct-port / addon-variant / off-host).
|
|
1142
|
+
url, headers = await _resolve_http_route(client, addon, normalized, port)
|
|
926
1143
|
|
|
927
|
-
|
|
928
|
-
# Direct port access: connect to the add-on's mapped network port
|
|
929
|
-
# (e.g., 1880 for Node-RED, 6052 for ESPHome) instead of the ingress port.
|
|
930
|
-
# Requires 'leave_front_door_open' or equivalent setting on the add-on.
|
|
931
|
-
if not addon_ip:
|
|
932
|
-
raise_tool_error(
|
|
933
|
-
create_error_response(
|
|
934
|
-
ErrorCode.INTERNAL_ERROR,
|
|
935
|
-
f"Add-on '{addon_name}' is missing ip_address",
|
|
936
|
-
context={"slug": slug, "ip_address": addon_ip},
|
|
937
|
-
)
|
|
938
|
-
)
|
|
939
|
-
target_port = port
|
|
940
|
-
else:
|
|
941
|
-
# Default: use the ingress port for direct container communication
|
|
942
|
-
ingress_port = addon.get("ingress_port")
|
|
943
|
-
if not addon_ip or not ingress_port:
|
|
944
|
-
raise_tool_error(
|
|
945
|
-
create_error_response(
|
|
946
|
-
ErrorCode.INTERNAL_ERROR,
|
|
947
|
-
f"Add-on '{addon_name}' is missing network info (ip_address or ingress_port)",
|
|
948
|
-
context={
|
|
949
|
-
"slug": slug,
|
|
950
|
-
"ip_address": addon_ip,
|
|
951
|
-
"ingress_port": ingress_port,
|
|
952
|
-
},
|
|
953
|
-
)
|
|
954
|
-
)
|
|
955
|
-
target_port = ingress_port
|
|
956
|
-
|
|
957
|
-
url = f"http://{addon_ip}:{target_port}/{normalized}"
|
|
958
|
-
|
|
959
|
-
# 6. Make HTTP request directly to the add-on container
|
|
960
|
-
# Include Ingress headers so the add-on's web server (e.g., Nginx) recognizes
|
|
961
|
-
# this as an authenticated Ingress request and bypasses its own auth layer.
|
|
962
|
-
# When using a direct port, skip Ingress headers (not needed/recognized).
|
|
963
|
-
ingress_entry = addon.get("ingress_entry", "")
|
|
964
|
-
headers: dict[str, str] = {}
|
|
965
|
-
if not port:
|
|
966
|
-
headers["X-Ingress-Path"] = ingress_entry
|
|
967
|
-
headers["X-Hass-Source"] = "core.ingress"
|
|
968
|
-
|
|
969
|
-
# Set content type based on body type
|
|
1144
|
+
# 6. Set content type based on body type
|
|
970
1145
|
if isinstance(body, dict):
|
|
971
1146
|
headers["Content-Type"] = "application/json"
|
|
972
1147
|
request_content = json.dumps(body).encode()
|
|
@@ -986,19 +1161,28 @@ async def _call_addon_api(
|
|
|
986
1161
|
)
|
|
987
1162
|
except httpx.TimeoutException:
|
|
988
1163
|
raise_tool_error(
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
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",
|
|
992
1167
|
details=f"path={path}, method={method}",
|
|
993
|
-
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),
|
|
994
1176
|
)
|
|
995
1177
|
)
|
|
996
1178
|
except httpx.ConnectError as e:
|
|
997
1179
|
raise_tool_error(
|
|
998
|
-
|
|
1180
|
+
create_error_response(
|
|
1181
|
+
ErrorCode.CONNECTION_FAILED,
|
|
999
1182
|
f"Failed to connect to add-on '{addon_name}': {e!s}",
|
|
1000
|
-
details="
|
|
1001
|
-
context={"slug": slug},
|
|
1183
|
+
details=f"url={url}",
|
|
1184
|
+
context={"slug": slug, "direct_port": bool(port)},
|
|
1185
|
+
suggestions=_addon_connection_failure_suggestions(client, port),
|
|
1002
1186
|
)
|
|
1003
1187
|
)
|
|
1004
1188
|
|
|
@@ -1103,16 +1287,21 @@ async def _call_addon_api(
|
|
|
1103
1287
|
|
|
1104
1288
|
if response.status_code >= 400:
|
|
1105
1289
|
result["error"] = f"Add-on API returned HTTP {response.status_code}"
|
|
1106
|
-
#
|
|
1107
|
-
#
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
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:
|
|
1112
1301
|
result["addon_config"] = {
|
|
1113
|
-
"options":
|
|
1114
|
-
"ports":
|
|
1115
|
-
"host_network":
|
|
1302
|
+
"options": addon.get("options"),
|
|
1303
|
+
"ports": addon.get("network") or addon.get("ports"),
|
|
1304
|
+
"host_network": addon.get("host_network"),
|
|
1116
1305
|
"ingress_port": addon.get("ingress_port"),
|
|
1117
1306
|
}
|
|
1118
1307
|
result["suggestion"] = (
|
|
@@ -1413,7 +1602,11 @@ def register_addon_tools(mcp: Any, client: HomeAssistantClient, **kwargs: Any) -
|
|
|
1413
1602
|
are fetched and merged automatically (including one level of nested dicts).
|
|
1414
1603
|
|
|
1415
1604
|
**Proxy mode** (when path is provided):
|
|
1416
|
-
|
|
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).
|
|
1417
1610
|
Use ha_get_addon(slug="...") to discover available ports and endpoints.
|
|
1418
1611
|
|
|
1419
1612
|
**Response shaping (proxy mode):**
|
|
@@ -51,10 +51,11 @@ READABLE_PATTERNS = [
|
|
|
51
51
|
"www/**",
|
|
52
52
|
"themes/**",
|
|
53
53
|
"custom_templates/**",
|
|
54
|
+
"dashboards/**",
|
|
54
55
|
"custom_components/**/*.py",
|
|
55
56
|
]
|
|
56
57
|
|
|
57
|
-
WRITABLE_DIRS = ["www", "themes", "custom_templates"]
|
|
58
|
+
WRITABLE_DIRS = ["www", "themes", "custom_templates", "dashboards"]
|
|
58
59
|
|
|
59
60
|
|
|
60
61
|
def is_filesystem_tools_enabled() -> bool:
|
|
@@ -115,7 +116,7 @@ class FilesystemTools:
|
|
|
115
116
|
Field(
|
|
116
117
|
description=(
|
|
117
118
|
"Relative directory path from config directory. "
|
|
118
|
-
"Allowed paths: www/, themes/, custom_templates/. "
|
|
119
|
+
"Allowed paths: www/, themes/, custom_templates/, dashboards/. "
|
|
119
120
|
"Example: 'www/' or 'themes/my_theme'"
|
|
120
121
|
),
|
|
121
122
|
),
|
|
@@ -133,13 +134,14 @@ class FilesystemTools:
|
|
|
133
134
|
) -> dict[str, Any]:
|
|
134
135
|
"""List files in a directory within the Home Assistant config directory.
|
|
135
136
|
|
|
136
|
-
Lists files in allowed directories (www/, themes/, custom_templates/) with
|
|
137
|
+
Lists files in allowed directories (www/, themes/, custom_templates/, dashboards/) with
|
|
137
138
|
optional glob pattern filtering. Returns file names, sizes, and modification times.
|
|
138
139
|
|
|
139
140
|
**Allowed Directories:**
|
|
140
141
|
- `www/` - Web assets (CSS, JS, images for dashboards)
|
|
141
142
|
- `themes/` - Theme files
|
|
142
143
|
- `custom_templates/` - Jinja2 template files
|
|
144
|
+
- `dashboards/` - YAML-mode dashboard files
|
|
143
145
|
|
|
144
146
|
**Security:** Only directories in the allowed list can be accessed.
|
|
145
147
|
Path traversal attempts (../) are blocked.
|
|
@@ -237,7 +239,7 @@ class FilesystemTools:
|
|
|
237
239
|
- `secrets.yaml` (values masked)
|
|
238
240
|
- `packages/*.yaml`
|
|
239
241
|
- `home-assistant.log` (tail only)
|
|
240
|
-
- `www/**`, `themes/**`, `custom_templates/**`
|
|
242
|
+
- `www/**`, `themes/**`, `custom_templates/**`, `dashboards/**`
|
|
241
243
|
- `custom_components/**/*.py` (read-only)
|
|
242
244
|
|
|
243
245
|
**Security:**
|
|
@@ -322,7 +324,7 @@ class FilesystemTools:
|
|
|
322
324
|
Field(
|
|
323
325
|
description=(
|
|
324
326
|
"Relative path from config directory. "
|
|
325
|
-
"Must be in www/, themes/, or
|
|
327
|
+
"Must be in www/, themes/, custom_templates/, or dashboards/. "
|
|
326
328
|
"Example: 'www/custom.css', 'themes/my_theme.yaml'"
|
|
327
329
|
),
|
|
328
330
|
),
|
|
@@ -365,6 +367,7 @@ class FilesystemTools:
|
|
|
365
367
|
- `www/` - Web assets for dashboards
|
|
366
368
|
- `themes/` - Theme YAML files
|
|
367
369
|
- `custom_templates/` - Jinja2 template files
|
|
370
|
+
- `dashboards/` - YAML-mode dashboard files
|
|
368
371
|
|
|
369
372
|
**Security:**
|
|
370
373
|
- Only the directories above allow writes
|
|
@@ -453,7 +456,7 @@ class FilesystemTools:
|
|
|
453
456
|
Field(
|
|
454
457
|
description=(
|
|
455
458
|
"Relative path from config directory. "
|
|
456
|
-
"Must be in www/, themes/, or
|
|
459
|
+
"Must be in www/, themes/, custom_templates/, or dashboards/. "
|
|
457
460
|
"Example: 'www/old-file.css'"
|
|
458
461
|
),
|
|
459
462
|
),
|
|
@@ -478,6 +481,7 @@ class FilesystemTools:
|
|
|
478
481
|
- `www/` - Web assets
|
|
479
482
|
- `themes/` - Theme files
|
|
480
483
|
- `custom_templates/` - Template files
|
|
484
|
+
- `dashboards/` - YAML-mode dashboard files
|
|
481
485
|
|
|
482
486
|
**Security:**
|
|
483
487
|
- Only the directories above allow deletions
|
|
@@ -28,6 +28,68 @@ from .util_helpers import coerce_bool_param, unwrap_service_response
|
|
|
28
28
|
|
|
29
29
|
logger = logging.getLogger(__name__)
|
|
30
30
|
|
|
31
|
+
_LOVELACE_DASHBOARD_PREFIX = "lovelace.dashboards."
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
async def _check_storage_mode_dashboard_collision(
|
|
35
|
+
client: Any, yaml_path: str
|
|
36
|
+
) -> None:
|
|
37
|
+
"""Raise a ToolError if a storage-mode dashboard already owns the requested
|
|
38
|
+
url_path; otherwise return without doing anything.
|
|
39
|
+
|
|
40
|
+
Only runs for yaml_path values starting with 'lovelace.dashboards.'.
|
|
41
|
+
A WebSocket failure or unexpected response shape warns and skips the check
|
|
42
|
+
(fail-open) so that a transient HA outage doesn't block dashboard creation.
|
|
43
|
+
"""
|
|
44
|
+
if not yaml_path.startswith(_LOVELACE_DASHBOARD_PREFIX):
|
|
45
|
+
return
|
|
46
|
+
url_path = yaml_path[len(_LOVELACE_DASHBOARD_PREFIX):]
|
|
47
|
+
try:
|
|
48
|
+
result = await client.send_websocket_message(
|
|
49
|
+
{"type": "lovelace/dashboards/list"}
|
|
50
|
+
)
|
|
51
|
+
except Exception as exc:
|
|
52
|
+
logger.warning(
|
|
53
|
+
"lovelace/dashboards/list WS query failed (%s); skipping collision check",
|
|
54
|
+
exc,
|
|
55
|
+
)
|
|
56
|
+
return
|
|
57
|
+
|
|
58
|
+
if isinstance(result, dict) and "result" in result:
|
|
59
|
+
dashboards = result["result"]
|
|
60
|
+
elif isinstance(result, list):
|
|
61
|
+
dashboards = result
|
|
62
|
+
else:
|
|
63
|
+
logger.warning(
|
|
64
|
+
"lovelace/dashboards/list returned unexpected shape (%s); "
|
|
65
|
+
"skipping collision check",
|
|
66
|
+
type(result).__name__,
|
|
67
|
+
)
|
|
68
|
+
return
|
|
69
|
+
|
|
70
|
+
for entry in dashboards or []:
|
|
71
|
+
if (
|
|
72
|
+
isinstance(entry, dict)
|
|
73
|
+
and entry.get("url_path") == url_path
|
|
74
|
+
and entry.get("mode") == "storage"
|
|
75
|
+
):
|
|
76
|
+
raise_tool_error(
|
|
77
|
+
create_error_response(
|
|
78
|
+
ErrorCode.VALIDATION_INVALID_PARAMETER,
|
|
79
|
+
(
|
|
80
|
+
f"A storage-mode dashboard already owns url_path "
|
|
81
|
+
f"'{url_path}'. Delete it via ha_config_delete_dashboard "
|
|
82
|
+
"or pick a different url_path before registering a "
|
|
83
|
+
"YAML-mode dashboard."
|
|
84
|
+
),
|
|
85
|
+
context={"url_path": url_path, "existing_id": entry.get("id")},
|
|
86
|
+
suggestions=[
|
|
87
|
+
f"ha_config_delete_dashboard(url_path='{url_path}')",
|
|
88
|
+
"Pick a different url_path for your YAML-mode dashboard.",
|
|
89
|
+
],
|
|
90
|
+
)
|
|
91
|
+
)
|
|
92
|
+
|
|
31
93
|
|
|
32
94
|
def register_yaml_config_tools(mcp: Any, client: Any, **kwargs: Any) -> None:
|
|
33
95
|
"""Register YAML config editing tools with the MCP server.
|
|
@@ -59,8 +121,11 @@ def register_yaml_config_tools(mcp: Any, client: Any, **kwargs: Any) -> None:
|
|
|
59
121
|
description=(
|
|
60
122
|
"Top-level YAML key to modify. Only a narrow allowlist of "
|
|
61
123
|
"YAML-only integration keys is accepted (e.g., 'command_line', "
|
|
62
|
-
"'rest', 'shell_command', 'notify').
|
|
63
|
-
"
|
|
124
|
+
"'rest', 'shell_command', 'notify'). For YAML-mode dashboards, "
|
|
125
|
+
"use the dotted form 'lovelace.dashboards.<url_path>' where "
|
|
126
|
+
"<url_path> is lowercase, hyphenated, and not a reserved HA "
|
|
127
|
+
"route. No other dotted paths are supported. Not for template "
|
|
128
|
+
"sensors (use ha_config_set_helper), automations, scripts, "
|
|
64
129
|
"scenes, or input_* helpers — those have dedicated tools."
|
|
65
130
|
),
|
|
66
131
|
),
|
|
@@ -121,7 +186,9 @@ def register_yaml_config_tools(mcp: Any, client: Any, **kwargs: Any) -> None:
|
|
|
121
186
|
trend, filter, switch_as_x, etc.) -> ha_config_set_helper
|
|
122
187
|
|
|
123
188
|
Intended for YAML-only integrations with no config-flow or API
|
|
124
|
-
equivalent (command_line, rest, shell_command, notify platforms)
|
|
189
|
+
equivalent (command_line, rest, shell_command, notify platforms),
|
|
190
|
+
and for registering YAML-mode dashboards via
|
|
191
|
+
``lovelace.dashboards.<url_path>`` (no other ``lovelace.*`` keys).
|
|
125
192
|
Check ``post_action`` in the response: most keys need a full HA
|
|
126
193
|
restart; template, mqtt, and group support reload. Preserves YAML
|
|
127
194
|
comments and HA tags (``!include``, ``!secret``) on round-trip;
|
|
@@ -160,6 +227,12 @@ def register_yaml_config_tools(mcp: Any, client: Any, **kwargs: Any) -> None:
|
|
|
160
227
|
# Coerce boolean parameter
|
|
161
228
|
backup_bool = coerce_bool_param(backup, "backup", default=True)
|
|
162
229
|
|
|
230
|
+
# Storage-mode dashboard collision check (only for lovelace.dashboards.*).
|
|
231
|
+
# Skip on `remove` so users can clean up YAML entries that conflict
|
|
232
|
+
# with a storage-mode dashboard (e.g., during a migration).
|
|
233
|
+
if action in ("add", "replace"):
|
|
234
|
+
await _check_storage_mode_dashboard_collision(client, yaml_path)
|
|
235
|
+
|
|
163
236
|
# Check if custom component is available
|
|
164
237
|
await _assert_mcp_tools_available(client)
|
|
165
238
|
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{ha_mcp_dev-7.4.1.dev423 → ha_mcp_dev-7.4.1.dev425}/src/ha_mcp/resources/skills-vendor/AGENTS.md
RENAMED
|
File without changes
|
{ha_mcp_dev-7.4.1.dev423 → ha_mcp_dev-7.4.1.dev425}/src/ha_mcp/resources/skills-vendor/CLAUDE.md
RENAMED
|
File without changes
|
|
File without changes
|
{ha_mcp_dev-7.4.1.dev423 → ha_mcp_dev-7.4.1.dev425}/src/ha_mcp/resources/skills-vendor/LICENSE
RENAMED
|
File without changes
|
{ha_mcp_dev-7.4.1.dev423 → ha_mcp_dev-7.4.1.dev425}/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
|
|
File without changes
|
{ha_mcp_dev-7.4.1.dev423 → ha_mcp_dev-7.4.1.dev425}/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
|
|
File without changes
|
{ha_mcp_dev-7.4.1.dev423 → ha_mcp_dev-7.4.1.dev425}/src/ha_mcp/tools/tools_config_automations.py
RENAMED
|
File without changes
|
{ha_mcp_dev-7.4.1.dev423 → ha_mcp_dev-7.4.1.dev425}/src/ha_mcp/tools/tools_config_dashboards.py
RENAMED
|
File without changes
|
{ha_mcp_dev-7.4.1.dev423 → ha_mcp_dev-7.4.1.dev425}/src/ha_mcp/tools/tools_config_entry_flow.py
RENAMED
|
File without changes
|
{ha_mcp_dev-7.4.1.dev423 → ha_mcp_dev-7.4.1.dev425}/src/ha_mcp/tools/tools_config_helpers.py
RENAMED
|
File without changes
|
{ha_mcp_dev-7.4.1.dev423 → ha_mcp_dev-7.4.1.dev425}/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
|
|
File without changes
|
|
File without changes
|
{ha_mcp_dev-7.4.1.dev423 → ha_mcp_dev-7.4.1.dev425}/src/ha_mcp/tools/tools_voice_assistant.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{ha_mcp_dev-7.4.1.dev423 → ha_mcp_dev-7.4.1.dev425}/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.dev423 → ha_mcp_dev-7.4.1.dev425}/src/ha_mcp_dev.egg-info/dependency_links.txt
RENAMED
|
File without changes
|
{ha_mcp_dev-7.4.1.dev423 → ha_mcp_dev-7.4.1.dev425}/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
|