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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (105) hide show
  1. {ha_mcp_dev-7.4.1.dev423/src/ha_mcp_dev.egg-info → ha_mcp_dev-7.4.1.dev424}/PKG-INFO +1 -1
  2. {ha_mcp_dev-7.4.1.dev423 → ha_mcp_dev-7.4.1.dev424}/pyproject.toml +1 -1
  3. {ha_mcp_dev-7.4.1.dev423 → ha_mcp_dev-7.4.1.dev424}/src/ha_mcp/tools/tools_addons.py +304 -111
  4. {ha_mcp_dev-7.4.1.dev423 → ha_mcp_dev-7.4.1.dev424/src/ha_mcp_dev.egg-info}/PKG-INFO +1 -1
  5. {ha_mcp_dev-7.4.1.dev423 → ha_mcp_dev-7.4.1.dev424}/LICENSE +0 -0
  6. {ha_mcp_dev-7.4.1.dev423 → ha_mcp_dev-7.4.1.dev424}/MANIFEST.in +0 -0
  7. {ha_mcp_dev-7.4.1.dev423 → ha_mcp_dev-7.4.1.dev424}/README.md +0 -0
  8. {ha_mcp_dev-7.4.1.dev423 → ha_mcp_dev-7.4.1.dev424}/setup.cfg +0 -0
  9. {ha_mcp_dev-7.4.1.dev423 → ha_mcp_dev-7.4.1.dev424}/src/ha_mcp/__init__.py +0 -0
  10. {ha_mcp_dev-7.4.1.dev423 → ha_mcp_dev-7.4.1.dev424}/src/ha_mcp/__main__.py +0 -0
  11. {ha_mcp_dev-7.4.1.dev423 → ha_mcp_dev-7.4.1.dev424}/src/ha_mcp/_pypi_marker +0 -0
  12. {ha_mcp_dev-7.4.1.dev423 → ha_mcp_dev-7.4.1.dev424}/src/ha_mcp/_version.py +0 -0
  13. {ha_mcp_dev-7.4.1.dev423 → ha_mcp_dev-7.4.1.dev424}/src/ha_mcp/auth/__init__.py +0 -0
  14. {ha_mcp_dev-7.4.1.dev423 → ha_mcp_dev-7.4.1.dev424}/src/ha_mcp/auth/consent_form.py +0 -0
  15. {ha_mcp_dev-7.4.1.dev423 → ha_mcp_dev-7.4.1.dev424}/src/ha_mcp/auth/provider.py +0 -0
  16. {ha_mcp_dev-7.4.1.dev423 → ha_mcp_dev-7.4.1.dev424}/src/ha_mcp/client/__init__.py +0 -0
  17. {ha_mcp_dev-7.4.1.dev423 → ha_mcp_dev-7.4.1.dev424}/src/ha_mcp/client/rest_client.py +0 -0
  18. {ha_mcp_dev-7.4.1.dev423 → ha_mcp_dev-7.4.1.dev424}/src/ha_mcp/client/websocket_client.py +0 -0
  19. {ha_mcp_dev-7.4.1.dev423 → ha_mcp_dev-7.4.1.dev424}/src/ha_mcp/client/websocket_listener.py +0 -0
  20. {ha_mcp_dev-7.4.1.dev423 → ha_mcp_dev-7.4.1.dev424}/src/ha_mcp/config.py +0 -0
  21. {ha_mcp_dev-7.4.1.dev423 → ha_mcp_dev-7.4.1.dev424}/src/ha_mcp/errors.py +0 -0
  22. {ha_mcp_dev-7.4.1.dev423 → ha_mcp_dev-7.4.1.dev424}/src/ha_mcp/py.typed +0 -0
  23. {ha_mcp_dev-7.4.1.dev423 → ha_mcp_dev-7.4.1.dev424}/src/ha_mcp/resources/skills-vendor/.claude/settings.json +0 -0
  24. {ha_mcp_dev-7.4.1.dev423 → ha_mcp_dev-7.4.1.dev424}/src/ha_mcp/resources/skills-vendor/.claude-plugin/marketplace.json +0 -0
  25. {ha_mcp_dev-7.4.1.dev423 → ha_mcp_dev-7.4.1.dev424}/src/ha_mcp/resources/skills-vendor/.claude-plugin/plugin.json +0 -0
  26. {ha_mcp_dev-7.4.1.dev423 → ha_mcp_dev-7.4.1.dev424}/src/ha_mcp/resources/skills-vendor/.github/ISSUE_TEMPLATE/skill-rca.md +0 -0
  27. {ha_mcp_dev-7.4.1.dev423 → ha_mcp_dev-7.4.1.dev424}/src/ha_mcp/resources/skills-vendor/AGENTS.md +0 -0
  28. {ha_mcp_dev-7.4.1.dev423 → ha_mcp_dev-7.4.1.dev424}/src/ha_mcp/resources/skills-vendor/CLAUDE.md +0 -0
  29. {ha_mcp_dev-7.4.1.dev423 → ha_mcp_dev-7.4.1.dev424}/src/ha_mcp/resources/skills-vendor/CONTRIBUTING.md +0 -0
  30. {ha_mcp_dev-7.4.1.dev423 → ha_mcp_dev-7.4.1.dev424}/src/ha_mcp/resources/skills-vendor/LICENSE +0 -0
  31. {ha_mcp_dev-7.4.1.dev423 → ha_mcp_dev-7.4.1.dev424}/src/ha_mcp/resources/skills-vendor/README.md +0 -0
  32. {ha_mcp_dev-7.4.1.dev423 → ha_mcp_dev-7.4.1.dev424}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/SKILL.md +0 -0
  33. {ha_mcp_dev-7.4.1.dev423 → ha_mcp_dev-7.4.1.dev424}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/evals/evals.json +0 -0
  34. {ha_mcp_dev-7.4.1.dev423 → ha_mcp_dev-7.4.1.dev424}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/automation-patterns.md +0 -0
  35. {ha_mcp_dev-7.4.1.dev423 → ha_mcp_dev-7.4.1.dev424}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/dashboard-cards.md +0 -0
  36. {ha_mcp_dev-7.4.1.dev423 → ha_mcp_dev-7.4.1.dev424}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/dashboard-guide.md +0 -0
  37. {ha_mcp_dev-7.4.1.dev423 → ha_mcp_dev-7.4.1.dev424}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/device-control.md +0 -0
  38. {ha_mcp_dev-7.4.1.dev423 → ha_mcp_dev-7.4.1.dev424}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/domain-docs.md +0 -0
  39. {ha_mcp_dev-7.4.1.dev423 → ha_mcp_dev-7.4.1.dev424}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/examples.yaml +0 -0
  40. {ha_mcp_dev-7.4.1.dev423 → ha_mcp_dev-7.4.1.dev424}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/helper-selection.md +0 -0
  41. {ha_mcp_dev-7.4.1.dev423 → ha_mcp_dev-7.4.1.dev424}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/safe-refactoring.md +0 -0
  42. {ha_mcp_dev-7.4.1.dev423 → ha_mcp_dev-7.4.1.dev424}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/template-guidelines.md +0 -0
  43. {ha_mcp_dev-7.4.1.dev423 → ha_mcp_dev-7.4.1.dev424}/src/ha_mcp/server.py +0 -0
  44. {ha_mcp_dev-7.4.1.dev423 → ha_mcp_dev-7.4.1.dev424}/src/ha_mcp/settings_ui.py +0 -0
  45. {ha_mcp_dev-7.4.1.dev423 → ha_mcp_dev-7.4.1.dev424}/src/ha_mcp/smoke_test.py +0 -0
  46. {ha_mcp_dev-7.4.1.dev423 → ha_mcp_dev-7.4.1.dev424}/src/ha_mcp/tools/__init__.py +0 -0
  47. {ha_mcp_dev-7.4.1.dev423 → ha_mcp_dev-7.4.1.dev424}/src/ha_mcp/tools/backup.py +0 -0
  48. {ha_mcp_dev-7.4.1.dev423 → ha_mcp_dev-7.4.1.dev424}/src/ha_mcp/tools/best_practice_checker.py +0 -0
  49. {ha_mcp_dev-7.4.1.dev423 → ha_mcp_dev-7.4.1.dev424}/src/ha_mcp/tools/device_control.py +0 -0
  50. {ha_mcp_dev-7.4.1.dev423 → ha_mcp_dev-7.4.1.dev424}/src/ha_mcp/tools/enhanced.py +0 -0
  51. {ha_mcp_dev-7.4.1.dev423 → ha_mcp_dev-7.4.1.dev424}/src/ha_mcp/tools/helpers.py +0 -0
  52. {ha_mcp_dev-7.4.1.dev423 → ha_mcp_dev-7.4.1.dev424}/src/ha_mcp/tools/reference_validator.py +0 -0
  53. {ha_mcp_dev-7.4.1.dev423 → ha_mcp_dev-7.4.1.dev424}/src/ha_mcp/tools/registry.py +0 -0
  54. {ha_mcp_dev-7.4.1.dev423 → ha_mcp_dev-7.4.1.dev424}/src/ha_mcp/tools/smart_search.py +0 -0
  55. {ha_mcp_dev-7.4.1.dev423 → ha_mcp_dev-7.4.1.dev424}/src/ha_mcp/tools/tools_areas.py +0 -0
  56. {ha_mcp_dev-7.4.1.dev423 → ha_mcp_dev-7.4.1.dev424}/src/ha_mcp/tools/tools_blueprints.py +0 -0
  57. {ha_mcp_dev-7.4.1.dev423 → ha_mcp_dev-7.4.1.dev424}/src/ha_mcp/tools/tools_bug_report.py +0 -0
  58. {ha_mcp_dev-7.4.1.dev423 → ha_mcp_dev-7.4.1.dev424}/src/ha_mcp/tools/tools_calendar.py +0 -0
  59. {ha_mcp_dev-7.4.1.dev423 → ha_mcp_dev-7.4.1.dev424}/src/ha_mcp/tools/tools_camera.py +0 -0
  60. {ha_mcp_dev-7.4.1.dev423 → ha_mcp_dev-7.4.1.dev424}/src/ha_mcp/tools/tools_categories.py +0 -0
  61. {ha_mcp_dev-7.4.1.dev423 → ha_mcp_dev-7.4.1.dev424}/src/ha_mcp/tools/tools_config_automations.py +0 -0
  62. {ha_mcp_dev-7.4.1.dev423 → ha_mcp_dev-7.4.1.dev424}/src/ha_mcp/tools/tools_config_dashboards.py +0 -0
  63. {ha_mcp_dev-7.4.1.dev423 → ha_mcp_dev-7.4.1.dev424}/src/ha_mcp/tools/tools_config_entry_flow.py +0 -0
  64. {ha_mcp_dev-7.4.1.dev423 → ha_mcp_dev-7.4.1.dev424}/src/ha_mcp/tools/tools_config_helpers.py +0 -0
  65. {ha_mcp_dev-7.4.1.dev423 → ha_mcp_dev-7.4.1.dev424}/src/ha_mcp/tools/tools_config_scripts.py +0 -0
  66. {ha_mcp_dev-7.4.1.dev423 → ha_mcp_dev-7.4.1.dev424}/src/ha_mcp/tools/tools_energy.py +0 -0
  67. {ha_mcp_dev-7.4.1.dev423 → ha_mcp_dev-7.4.1.dev424}/src/ha_mcp/tools/tools_entities.py +0 -0
  68. {ha_mcp_dev-7.4.1.dev423 → ha_mcp_dev-7.4.1.dev424}/src/ha_mcp/tools/tools_filesystem.py +0 -0
  69. {ha_mcp_dev-7.4.1.dev423 → ha_mcp_dev-7.4.1.dev424}/src/ha_mcp/tools/tools_groups.py +0 -0
  70. {ha_mcp_dev-7.4.1.dev423 → ha_mcp_dev-7.4.1.dev424}/src/ha_mcp/tools/tools_hacs.py +0 -0
  71. {ha_mcp_dev-7.4.1.dev423 → ha_mcp_dev-7.4.1.dev424}/src/ha_mcp/tools/tools_history.py +0 -0
  72. {ha_mcp_dev-7.4.1.dev423 → ha_mcp_dev-7.4.1.dev424}/src/ha_mcp/tools/tools_integrations.py +0 -0
  73. {ha_mcp_dev-7.4.1.dev423 → ha_mcp_dev-7.4.1.dev424}/src/ha_mcp/tools/tools_labels.py +0 -0
  74. {ha_mcp_dev-7.4.1.dev423 → ha_mcp_dev-7.4.1.dev424}/src/ha_mcp/tools/tools_mcp_component.py +0 -0
  75. {ha_mcp_dev-7.4.1.dev423 → ha_mcp_dev-7.4.1.dev424}/src/ha_mcp/tools/tools_registry.py +0 -0
  76. {ha_mcp_dev-7.4.1.dev423 → ha_mcp_dev-7.4.1.dev424}/src/ha_mcp/tools/tools_resources.py +0 -0
  77. {ha_mcp_dev-7.4.1.dev423 → ha_mcp_dev-7.4.1.dev424}/src/ha_mcp/tools/tools_search.py +0 -0
  78. {ha_mcp_dev-7.4.1.dev423 → ha_mcp_dev-7.4.1.dev424}/src/ha_mcp/tools/tools_service.py +0 -0
  79. {ha_mcp_dev-7.4.1.dev423 → ha_mcp_dev-7.4.1.dev424}/src/ha_mcp/tools/tools_services.py +0 -0
  80. {ha_mcp_dev-7.4.1.dev423 → ha_mcp_dev-7.4.1.dev424}/src/ha_mcp/tools/tools_system.py +0 -0
  81. {ha_mcp_dev-7.4.1.dev423 → ha_mcp_dev-7.4.1.dev424}/src/ha_mcp/tools/tools_todo.py +0 -0
  82. {ha_mcp_dev-7.4.1.dev423 → ha_mcp_dev-7.4.1.dev424}/src/ha_mcp/tools/tools_traces.py +0 -0
  83. {ha_mcp_dev-7.4.1.dev423 → ha_mcp_dev-7.4.1.dev424}/src/ha_mcp/tools/tools_updates.py +0 -0
  84. {ha_mcp_dev-7.4.1.dev423 → ha_mcp_dev-7.4.1.dev424}/src/ha_mcp/tools/tools_utility.py +0 -0
  85. {ha_mcp_dev-7.4.1.dev423 → ha_mcp_dev-7.4.1.dev424}/src/ha_mcp/tools/tools_voice_assistant.py +0 -0
  86. {ha_mcp_dev-7.4.1.dev423 → ha_mcp_dev-7.4.1.dev424}/src/ha_mcp/tools/tools_yaml_config.py +0 -0
  87. {ha_mcp_dev-7.4.1.dev423 → ha_mcp_dev-7.4.1.dev424}/src/ha_mcp/tools/tools_zones.py +0 -0
  88. {ha_mcp_dev-7.4.1.dev423 → ha_mcp_dev-7.4.1.dev424}/src/ha_mcp/tools/util_helpers.py +0 -0
  89. {ha_mcp_dev-7.4.1.dev423 → ha_mcp_dev-7.4.1.dev424}/src/ha_mcp/transforms/__init__.py +0 -0
  90. {ha_mcp_dev-7.4.1.dev423 → ha_mcp_dev-7.4.1.dev424}/src/ha_mcp/transforms/categorized_search.py +0 -0
  91. {ha_mcp_dev-7.4.1.dev423 → ha_mcp_dev-7.4.1.dev424}/src/ha_mcp/utils/__init__.py +0 -0
  92. {ha_mcp_dev-7.4.1.dev423 → ha_mcp_dev-7.4.1.dev424}/src/ha_mcp/utils/config_hash.py +0 -0
  93. {ha_mcp_dev-7.4.1.dev423 → ha_mcp_dev-7.4.1.dev424}/src/ha_mcp/utils/domain_handlers.py +0 -0
  94. {ha_mcp_dev-7.4.1.dev423 → ha_mcp_dev-7.4.1.dev424}/src/ha_mcp/utils/fuzzy_search.py +0 -0
  95. {ha_mcp_dev-7.4.1.dev423 → ha_mcp_dev-7.4.1.dev424}/src/ha_mcp/utils/operation_manager.py +0 -0
  96. {ha_mcp_dev-7.4.1.dev423 → ha_mcp_dev-7.4.1.dev424}/src/ha_mcp/utils/python_sandbox.py +0 -0
  97. {ha_mcp_dev-7.4.1.dev423 → ha_mcp_dev-7.4.1.dev424}/src/ha_mcp/utils/usage_logger.py +0 -0
  98. {ha_mcp_dev-7.4.1.dev423 → ha_mcp_dev-7.4.1.dev424}/src/ha_mcp_dev.egg-info/SOURCES.txt +0 -0
  99. {ha_mcp_dev-7.4.1.dev423 → ha_mcp_dev-7.4.1.dev424}/src/ha_mcp_dev.egg-info/dependency_links.txt +0 -0
  100. {ha_mcp_dev-7.4.1.dev423 → ha_mcp_dev-7.4.1.dev424}/src/ha_mcp_dev.egg-info/entry_points.txt +0 -0
  101. {ha_mcp_dev-7.4.1.dev423 → ha_mcp_dev-7.4.1.dev424}/src/ha_mcp_dev.egg-info/requires.txt +0 -0
  102. {ha_mcp_dev-7.4.1.dev423 → ha_mcp_dev-7.4.1.dev424}/src/ha_mcp_dev.egg-info/top_level.txt +0 -0
  103. {ha_mcp_dev-7.4.1.dev423 → ha_mcp_dev-7.4.1.dev424}/tests/__init__.py +0 -0
  104. {ha_mcp_dev-7.4.1.dev423 → ha_mcp_dev-7.4.1.dev424}/tests/test_constants.py +0 -0
  105. {ha_mcp_dev-7.4.1.dev423 → ha_mcp_dev-7.4.1.dev424}/tests/test_env_manager.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ha-mcp-dev
3
- Version: 7.4.1.dev423
3
+ Version: 7.4.1.dev424
4
4
  Summary: Home Assistant MCP Server - Complete control of Home Assistant through MCP
5
5
  Author-email: Julien <github@qc-h.net>
6
6
  License: MIT
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "ha-mcp-dev"
7
- version = "7.4.1.dev423"
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"
@@ -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. Build WebSocket URL
597
- addon_ip = addon.get("ip_address", "")
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
- create_timeout_error(
734
- f"WebSocket connection to '{addon_name}'",
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={"slug": slug, "path": path},
934
+ context={
935
+ "slug": slug,
936
+ "path": path,
937
+ "operation": f"WebSocket connection to '{addon_name}'",
938
+ "timeout_seconds": timeout,
939
+ "direct_port": bool(port),
940
+ },
941
+ suggestions=_addon_connection_failure_suggestions(client, port),
738
942
  )
739
943
  )
740
944
  except OSError as e:
741
945
  raise_tool_error(
742
- create_connection_error(
946
+ create_error_response(
947
+ ErrorCode.CONNECTION_FAILED,
743
948
  f"Failed to connect to add-on '{addon_name}' WebSocket: {e!s}",
744
- details="Check that the add-on is running and the port is correct",
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 through Home Assistant's Ingress proxy.
1063
+ """Call an add-on's web API.
1064
+
1065
+ Routing is picked per install variant (see `_resolve_http_route`):
1066
+
1067
+ - **Ingress (default), off-host**: tunnels through HA Core's
1068
+ `/api/hassio_ingress/<token>/...` proxy with a per-call Supervisor
1069
+ session cookie. The path that makes off-host (PyPI/uvx) installs work.
1070
+ - **Ingress (default), HA add-on**: hits the addon container's
1071
+ ingress port directly with the `core.ingress` source headers. Avoids
1072
+ the Supervisor `/core` proxy hop that would otherwise demand
1073
+ `Authorization: Bearer $SUPERVISOR_TOKEN` on top of the cookie.
1074
+ - **Direct port** (when `port` is set): connects to
1075
+ `http://<addon_ip>:<port>/...` for add-ons that expose mapped ports
1076
+ (e.g. Node-RED on 1880). Only works when the MCP host shares HA's
1077
+ Docker network.
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. Build URL to the add-on container
925
- addon_ip = addon.get("ip_address", "")
1141
+ # 5. Resolve route (direct-port / addon-variant / off-host).
1142
+ url, headers = await _resolve_http_route(client, addon, normalized, port)
926
1143
 
927
- if port:
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
- create_timeout_error(
990
- f"add-on API call to '{addon_name}'",
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={"slug": slug, "path": path},
1168
+ context={
1169
+ "slug": slug,
1170
+ "path": path,
1171
+ "operation": f"add-on API call to '{addon_name}'",
1172
+ "timeout_seconds": timeout,
1173
+ "direct_port": bool(port),
1174
+ },
1175
+ suggestions=_addon_connection_failure_suggestions(client, port),
994
1176
  )
995
1177
  )
996
1178
  except httpx.ConnectError as e:
997
1179
  raise_tool_error(
998
- create_connection_error(
1180
+ create_error_response(
1181
+ ErrorCode.CONNECTION_FAILED,
999
1182
  f"Failed to connect to add-on '{addon_name}': {e!s}",
1000
- details="Check that the add-on is running and Home Assistant Ingress is working",
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
- # On 403/401, include addon config so the LLM can spot relevant settings
1107
- # (e.g., "leave_front_door_open", auth toggles, port mappings)
1108
- if response.status_code in (401, 403):
1109
- addon_options = addon.get("options")
1110
- addon_ports = addon.get("network") or addon.get("ports")
1111
- addon_host_network = addon.get("host_network")
1290
+ # 401 = auth credential problem (token/scope/session); IP-restriction
1291
+ # hint and addon_config attachment would misdirect.
1292
+ # 403 = forbidden (likely Nginx ACL); addon_config helps the LLM spot
1293
+ # relevant toggles like leave_front_door_open and port mappings.
1294
+ if response.status_code == 401:
1295
+ result["suggestion"] = (
1296
+ "Authentication failed. The ingress session may have expired, "
1297
+ "or your HA token may lack the required scope. Verify the "
1298
+ "token has admin rights and try again."
1299
+ )
1300
+ elif response.status_code == 403:
1112
1301
  result["addon_config"] = {
1113
- "options": addon_options,
1114
- "ports": addon_ports,
1115
- "host_network": addon_host_network,
1302
+ "options": addon.get("options"),
1303
+ "ports": addon.get("network") or addon.get("ports"),
1304
+ "host_network": addon.get("host_network"),
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
- Sends requests directly to the add-on container's own web API via HTTP or WebSocket.
1605
+ Routes HTTP or WebSocket requests through Home Assistant's Ingress
1606
+ proxy by default (works on HAOS, Supervised, and off-host PyPI/uvx
1607
+ installs). Pass `port=...` to bypass Ingress and connect directly to
1608
+ an add-on's container port — that mode requires the MCP host to
1609
+ share Home Assistant's container network (i.e. only the HAOS addon).
1417
1610
  Use ha_get_addon(slug="...") to discover available ports and endpoints.
1418
1611
 
1419
1612
  **Response shaping (proxy mode):**
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ha-mcp-dev
3
- Version: 7.4.1.dev423
3
+ Version: 7.4.1.dev424
4
4
  Summary: Home Assistant MCP Server - Complete control of Home Assistant through MCP
5
5
  Author-email: Julien <github@qc-h.net>
6
6
  License: MIT