ha-mcp-dev 6.7.2.dev257__tar.gz → 6.7.2.dev259__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 (94) hide show
  1. {ha_mcp_dev-6.7.2.dev257/src/ha_mcp_dev.egg-info → ha_mcp_dev-6.7.2.dev259}/PKG-INFO +1 -1
  2. {ha_mcp_dev-6.7.2.dev257 → ha_mcp_dev-6.7.2.dev259}/pyproject.toml +1 -1
  3. {ha_mcp_dev-6.7.2.dev257 → ha_mcp_dev-6.7.2.dev259}/src/ha_mcp/__main__.py +49 -23
  4. {ha_mcp_dev-6.7.2.dev257 → ha_mcp_dev-6.7.2.dev259}/src/ha_mcp/auth/consent_form.py +61 -102
  5. {ha_mcp_dev-6.7.2.dev257 → ha_mcp_dev-6.7.2.dev259}/src/ha_mcp/auth/provider.py +42 -122
  6. {ha_mcp_dev-6.7.2.dev257 → ha_mcp_dev-6.7.2.dev259/src/ha_mcp_dev.egg-info}/PKG-INFO +1 -1
  7. {ha_mcp_dev-6.7.2.dev257 → ha_mcp_dev-6.7.2.dev259}/LICENSE +0 -0
  8. {ha_mcp_dev-6.7.2.dev257 → ha_mcp_dev-6.7.2.dev259}/MANIFEST.in +0 -0
  9. {ha_mcp_dev-6.7.2.dev257 → ha_mcp_dev-6.7.2.dev259}/README.md +0 -0
  10. {ha_mcp_dev-6.7.2.dev257 → ha_mcp_dev-6.7.2.dev259}/setup.cfg +0 -0
  11. {ha_mcp_dev-6.7.2.dev257 → ha_mcp_dev-6.7.2.dev259}/src/ha_mcp/__init__.py +0 -0
  12. {ha_mcp_dev-6.7.2.dev257 → ha_mcp_dev-6.7.2.dev259}/src/ha_mcp/_pypi_marker +0 -0
  13. {ha_mcp_dev-6.7.2.dev257 → ha_mcp_dev-6.7.2.dev259}/src/ha_mcp/auth/__init__.py +0 -0
  14. {ha_mcp_dev-6.7.2.dev257 → ha_mcp_dev-6.7.2.dev259}/src/ha_mcp/client/__init__.py +0 -0
  15. {ha_mcp_dev-6.7.2.dev257 → ha_mcp_dev-6.7.2.dev259}/src/ha_mcp/client/rest_client.py +0 -0
  16. {ha_mcp_dev-6.7.2.dev257 → ha_mcp_dev-6.7.2.dev259}/src/ha_mcp/client/websocket_client.py +0 -0
  17. {ha_mcp_dev-6.7.2.dev257 → ha_mcp_dev-6.7.2.dev259}/src/ha_mcp/client/websocket_listener.py +0 -0
  18. {ha_mcp_dev-6.7.2.dev257 → ha_mcp_dev-6.7.2.dev259}/src/ha_mcp/config.py +0 -0
  19. {ha_mcp_dev-6.7.2.dev257 → ha_mcp_dev-6.7.2.dev259}/src/ha_mcp/errors.py +0 -0
  20. {ha_mcp_dev-6.7.2.dev257 → ha_mcp_dev-6.7.2.dev259}/src/ha_mcp/py.typed +0 -0
  21. {ha_mcp_dev-6.7.2.dev257 → ha_mcp_dev-6.7.2.dev259}/src/ha_mcp/resources/card_types.json +0 -0
  22. {ha_mcp_dev-6.7.2.dev257 → ha_mcp_dev-6.7.2.dev259}/src/ha_mcp/resources/dashboard_guide.md +0 -0
  23. {ha_mcp_dev-6.7.2.dev257 → ha_mcp_dev-6.7.2.dev259}/src/ha_mcp/resources/skills-vendor/.claude/settings.json +0 -0
  24. {ha_mcp_dev-6.7.2.dev257 → ha_mcp_dev-6.7.2.dev259}/src/ha_mcp/resources/skills-vendor/.claude-plugin/marketplace.json +0 -0
  25. {ha_mcp_dev-6.7.2.dev257 → ha_mcp_dev-6.7.2.dev259}/src/ha_mcp/resources/skills-vendor/.claude-plugin/plugin.json +0 -0
  26. {ha_mcp_dev-6.7.2.dev257 → ha_mcp_dev-6.7.2.dev259}/src/ha_mcp/resources/skills-vendor/.github/ISSUE_TEMPLATE/skill-rca.md +0 -0
  27. {ha_mcp_dev-6.7.2.dev257 → ha_mcp_dev-6.7.2.dev259}/src/ha_mcp/resources/skills-vendor/AGENTS.md +0 -0
  28. {ha_mcp_dev-6.7.2.dev257 → ha_mcp_dev-6.7.2.dev259}/src/ha_mcp/resources/skills-vendor/CLAUDE.md +0 -0
  29. {ha_mcp_dev-6.7.2.dev257 → ha_mcp_dev-6.7.2.dev259}/src/ha_mcp/resources/skills-vendor/CONTRIBUTING.md +0 -0
  30. {ha_mcp_dev-6.7.2.dev257 → ha_mcp_dev-6.7.2.dev259}/src/ha_mcp/resources/skills-vendor/LICENSE +0 -0
  31. {ha_mcp_dev-6.7.2.dev257 → ha_mcp_dev-6.7.2.dev259}/src/ha_mcp/resources/skills-vendor/README.md +0 -0
  32. {ha_mcp_dev-6.7.2.dev257 → ha_mcp_dev-6.7.2.dev259}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/SKILL.md +0 -0
  33. {ha_mcp_dev-6.7.2.dev257 → ha_mcp_dev-6.7.2.dev259}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/automation-patterns.md +0 -0
  34. {ha_mcp_dev-6.7.2.dev257 → ha_mcp_dev-6.7.2.dev259}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/device-control.md +0 -0
  35. {ha_mcp_dev-6.7.2.dev257 → ha_mcp_dev-6.7.2.dev259}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/examples.yaml +0 -0
  36. {ha_mcp_dev-6.7.2.dev257 → ha_mcp_dev-6.7.2.dev259}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/helper-selection.md +0 -0
  37. {ha_mcp_dev-6.7.2.dev257 → ha_mcp_dev-6.7.2.dev259}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/safe-refactoring.md +0 -0
  38. {ha_mcp_dev-6.7.2.dev257 → ha_mcp_dev-6.7.2.dev259}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/template-guidelines.md +0 -0
  39. {ha_mcp_dev-6.7.2.dev257 → ha_mcp_dev-6.7.2.dev259}/src/ha_mcp/server.py +0 -0
  40. {ha_mcp_dev-6.7.2.dev257 → ha_mcp_dev-6.7.2.dev259}/src/ha_mcp/smoke_test.py +0 -0
  41. {ha_mcp_dev-6.7.2.dev257 → ha_mcp_dev-6.7.2.dev259}/src/ha_mcp/tools/__init__.py +0 -0
  42. {ha_mcp_dev-6.7.2.dev257 → ha_mcp_dev-6.7.2.dev259}/src/ha_mcp/tools/backup.py +0 -0
  43. {ha_mcp_dev-6.7.2.dev257 → ha_mcp_dev-6.7.2.dev259}/src/ha_mcp/tools/device_control.py +0 -0
  44. {ha_mcp_dev-6.7.2.dev257 → ha_mcp_dev-6.7.2.dev259}/src/ha_mcp/tools/enhanced.py +0 -0
  45. {ha_mcp_dev-6.7.2.dev257 → ha_mcp_dev-6.7.2.dev259}/src/ha_mcp/tools/helpers.py +0 -0
  46. {ha_mcp_dev-6.7.2.dev257 → ha_mcp_dev-6.7.2.dev259}/src/ha_mcp/tools/registry.py +0 -0
  47. {ha_mcp_dev-6.7.2.dev257 → ha_mcp_dev-6.7.2.dev259}/src/ha_mcp/tools/smart_search.py +0 -0
  48. {ha_mcp_dev-6.7.2.dev257 → ha_mcp_dev-6.7.2.dev259}/src/ha_mcp/tools/tools_addons.py +0 -0
  49. {ha_mcp_dev-6.7.2.dev257 → ha_mcp_dev-6.7.2.dev259}/src/ha_mcp/tools/tools_areas.py +0 -0
  50. {ha_mcp_dev-6.7.2.dev257 → ha_mcp_dev-6.7.2.dev259}/src/ha_mcp/tools/tools_blueprints.py +0 -0
  51. {ha_mcp_dev-6.7.2.dev257 → ha_mcp_dev-6.7.2.dev259}/src/ha_mcp/tools/tools_bug_report.py +0 -0
  52. {ha_mcp_dev-6.7.2.dev257 → ha_mcp_dev-6.7.2.dev259}/src/ha_mcp/tools/tools_calendar.py +0 -0
  53. {ha_mcp_dev-6.7.2.dev257 → ha_mcp_dev-6.7.2.dev259}/src/ha_mcp/tools/tools_camera.py +0 -0
  54. {ha_mcp_dev-6.7.2.dev257 → ha_mcp_dev-6.7.2.dev259}/src/ha_mcp/tools/tools_config_automations.py +0 -0
  55. {ha_mcp_dev-6.7.2.dev257 → ha_mcp_dev-6.7.2.dev259}/src/ha_mcp/tools/tools_config_dashboards.py +0 -0
  56. {ha_mcp_dev-6.7.2.dev257 → ha_mcp_dev-6.7.2.dev259}/src/ha_mcp/tools/tools_config_entry_flow.py +0 -0
  57. {ha_mcp_dev-6.7.2.dev257 → ha_mcp_dev-6.7.2.dev259}/src/ha_mcp/tools/tools_config_helpers.py +0 -0
  58. {ha_mcp_dev-6.7.2.dev257 → ha_mcp_dev-6.7.2.dev259}/src/ha_mcp/tools/tools_config_info.py +0 -0
  59. {ha_mcp_dev-6.7.2.dev257 → ha_mcp_dev-6.7.2.dev259}/src/ha_mcp/tools/tools_config_scripts.py +0 -0
  60. {ha_mcp_dev-6.7.2.dev257 → ha_mcp_dev-6.7.2.dev259}/src/ha_mcp/tools/tools_entities.py +0 -0
  61. {ha_mcp_dev-6.7.2.dev257 → ha_mcp_dev-6.7.2.dev259}/src/ha_mcp/tools/tools_filesystem.py +0 -0
  62. {ha_mcp_dev-6.7.2.dev257 → ha_mcp_dev-6.7.2.dev259}/src/ha_mcp/tools/tools_groups.py +0 -0
  63. {ha_mcp_dev-6.7.2.dev257 → ha_mcp_dev-6.7.2.dev259}/src/ha_mcp/tools/tools_hacs.py +0 -0
  64. {ha_mcp_dev-6.7.2.dev257 → ha_mcp_dev-6.7.2.dev259}/src/ha_mcp/tools/tools_history.py +0 -0
  65. {ha_mcp_dev-6.7.2.dev257 → ha_mcp_dev-6.7.2.dev259}/src/ha_mcp/tools/tools_integrations.py +0 -0
  66. {ha_mcp_dev-6.7.2.dev257 → ha_mcp_dev-6.7.2.dev259}/src/ha_mcp/tools/tools_labels.py +0 -0
  67. {ha_mcp_dev-6.7.2.dev257 → ha_mcp_dev-6.7.2.dev259}/src/ha_mcp/tools/tools_mcp_component.py +0 -0
  68. {ha_mcp_dev-6.7.2.dev257 → ha_mcp_dev-6.7.2.dev259}/src/ha_mcp/tools/tools_registry.py +0 -0
  69. {ha_mcp_dev-6.7.2.dev257 → ha_mcp_dev-6.7.2.dev259}/src/ha_mcp/tools/tools_resources.py +0 -0
  70. {ha_mcp_dev-6.7.2.dev257 → ha_mcp_dev-6.7.2.dev259}/src/ha_mcp/tools/tools_search.py +0 -0
  71. {ha_mcp_dev-6.7.2.dev257 → ha_mcp_dev-6.7.2.dev259}/src/ha_mcp/tools/tools_service.py +0 -0
  72. {ha_mcp_dev-6.7.2.dev257 → ha_mcp_dev-6.7.2.dev259}/src/ha_mcp/tools/tools_services.py +0 -0
  73. {ha_mcp_dev-6.7.2.dev257 → ha_mcp_dev-6.7.2.dev259}/src/ha_mcp/tools/tools_system.py +0 -0
  74. {ha_mcp_dev-6.7.2.dev257 → ha_mcp_dev-6.7.2.dev259}/src/ha_mcp/tools/tools_todo.py +0 -0
  75. {ha_mcp_dev-6.7.2.dev257 → ha_mcp_dev-6.7.2.dev259}/src/ha_mcp/tools/tools_traces.py +0 -0
  76. {ha_mcp_dev-6.7.2.dev257 → ha_mcp_dev-6.7.2.dev259}/src/ha_mcp/tools/tools_updates.py +0 -0
  77. {ha_mcp_dev-6.7.2.dev257 → ha_mcp_dev-6.7.2.dev259}/src/ha_mcp/tools/tools_utility.py +0 -0
  78. {ha_mcp_dev-6.7.2.dev257 → ha_mcp_dev-6.7.2.dev259}/src/ha_mcp/tools/tools_voice_assistant.py +0 -0
  79. {ha_mcp_dev-6.7.2.dev257 → ha_mcp_dev-6.7.2.dev259}/src/ha_mcp/tools/tools_zones.py +0 -0
  80. {ha_mcp_dev-6.7.2.dev257 → ha_mcp_dev-6.7.2.dev259}/src/ha_mcp/tools/util_helpers.py +0 -0
  81. {ha_mcp_dev-6.7.2.dev257 → ha_mcp_dev-6.7.2.dev259}/src/ha_mcp/utils/__init__.py +0 -0
  82. {ha_mcp_dev-6.7.2.dev257 → ha_mcp_dev-6.7.2.dev259}/src/ha_mcp/utils/domain_handlers.py +0 -0
  83. {ha_mcp_dev-6.7.2.dev257 → ha_mcp_dev-6.7.2.dev259}/src/ha_mcp/utils/fuzzy_search.py +0 -0
  84. {ha_mcp_dev-6.7.2.dev257 → ha_mcp_dev-6.7.2.dev259}/src/ha_mcp/utils/operation_manager.py +0 -0
  85. {ha_mcp_dev-6.7.2.dev257 → ha_mcp_dev-6.7.2.dev259}/src/ha_mcp/utils/python_sandbox.py +0 -0
  86. {ha_mcp_dev-6.7.2.dev257 → ha_mcp_dev-6.7.2.dev259}/src/ha_mcp/utils/usage_logger.py +0 -0
  87. {ha_mcp_dev-6.7.2.dev257 → ha_mcp_dev-6.7.2.dev259}/src/ha_mcp_dev.egg-info/SOURCES.txt +0 -0
  88. {ha_mcp_dev-6.7.2.dev257 → ha_mcp_dev-6.7.2.dev259}/src/ha_mcp_dev.egg-info/dependency_links.txt +0 -0
  89. {ha_mcp_dev-6.7.2.dev257 → ha_mcp_dev-6.7.2.dev259}/src/ha_mcp_dev.egg-info/entry_points.txt +0 -0
  90. {ha_mcp_dev-6.7.2.dev257 → ha_mcp_dev-6.7.2.dev259}/src/ha_mcp_dev.egg-info/requires.txt +0 -0
  91. {ha_mcp_dev-6.7.2.dev257 → ha_mcp_dev-6.7.2.dev259}/src/ha_mcp_dev.egg-info/top_level.txt +0 -0
  92. {ha_mcp_dev-6.7.2.dev257 → ha_mcp_dev-6.7.2.dev259}/tests/__init__.py +0 -0
  93. {ha_mcp_dev-6.7.2.dev257 → ha_mcp_dev-6.7.2.dev259}/tests/test_constants.py +0 -0
  94. {ha_mcp_dev-6.7.2.dev257 → ha_mcp_dev-6.7.2.dev259}/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: 6.7.2.dev257
3
+ Version: 6.7.2.dev259
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 = "6.7.2.dev257"
7
+ version = "6.7.2.dev259"
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"
@@ -19,7 +19,6 @@ from typing import TYPE_CHECKING, Any # noqa: E402
19
19
  if TYPE_CHECKING:
20
20
  from fastmcp import FastMCP
21
21
 
22
- from ha_mcp.auth.provider import HomeAssistantOAuthProvider
23
22
  from ha_mcp.client.rest_client import HomeAssistantClient
24
23
  from ha_mcp.config import Settings
25
24
  from ha_mcp.server import HomeAssistantSmartMCPServer
@@ -32,10 +31,13 @@ class OAuthProxyClient:
32
31
 
33
32
  This class is necessary because tools capture a reference to the client at registration time.
34
33
  The proxy allows us to inject different credentials per-request based on OAuth token claims.
34
+
35
+ The Home Assistant URL is fixed server-side (HOMEASSISTANT_URL env var).
36
+ Only the access token varies per-user (from OAuth consent form).
35
37
  """
36
38
 
37
- def __init__(self, auth_provider: "HomeAssistantOAuthProvider") -> None:
38
- self._auth_provider = auth_provider
39
+ def __init__(self, ha_url: str) -> None:
40
+ self._ha_url = ha_url.rstrip("/")
39
41
  self._oauth_clients: dict[str, HomeAssistantClient] = {}
40
42
  self._lock = threading.Lock()
41
43
 
@@ -52,26 +54,25 @@ class OAuthProxyClient:
52
54
  logger.warning("No access token in context")
53
55
  raise RuntimeError("No OAuth token in request context")
54
56
 
55
- # Extract HA credentials from token claims
57
+ # Extract HA token from claims (URL is server-side config)
56
58
  claims = token.claims
57
59
 
58
- if not claims or "ha_url" not in claims or "ha_token" not in claims:
60
+ if not claims or "ha_token" not in claims:
59
61
  logger.error(f"OAuth token missing HA credentials. Keys present: {list(claims.keys()) if claims else []}")
60
62
  raise RuntimeError("No Home Assistant credentials in OAuth token claims")
61
63
 
62
- ha_url = claims["ha_url"]
63
64
  ha_token = claims["ha_token"]
64
65
 
65
- # Hash credentials for cache key to avoid raw tokens appearing in dict keys
66
- client_key = hashlib.sha256(f"{ha_url}:{ha_token}".encode()).hexdigest()
66
+ # Hash token for cache key to avoid raw tokens appearing in dict keys
67
+ client_key = hashlib.sha256(ha_token.encode()).hexdigest()
67
68
 
68
69
  with self._lock:
69
70
  if client_key not in self._oauth_clients:
70
71
  self._oauth_clients[client_key] = HomeAssistantClient(
71
- base_url=ha_url,
72
+ base_url=self._ha_url,
72
73
  token=ha_token,
73
74
  )
74
- logger.info(f"Created OAuth client for {ha_url}")
75
+ logger.info(f"Created OAuth client for {self._ha_url}")
75
76
 
76
77
  return self._oauth_clients[client_key]
77
78
 
@@ -610,18 +611,19 @@ def main_sse() -> None:
610
611
  def main_oauth() -> None:
611
612
  """Run server with OAuth 2.1 authentication over HTTP.
612
613
 
613
- This mode enables zero-config authentication for MCP clients like Claude.ai.
614
- Users authenticate via a consent form where they enter their Home Assistant
615
- URL and Long-Lived Access Token.
614
+ This mode enables per-user authentication for MCP clients like Claude.ai.
615
+ Users authenticate via a consent form where they provide their
616
+ Long-Lived Access Token.
616
617
 
617
618
  Environment:
619
+ - HOMEASSISTANT_URL (required): URL of the Home Assistant instance
618
620
  - MCP_BASE_URL (required): Public URL where this server is accessible (e.g., https://your-tunnel.com)
619
621
  - MCP_PORT (optional, default: 8086)
620
622
  - MCP_SECRET_PATH (optional, default: "/mcp")
621
623
  - LOG_LEVEL (optional, default: INFO)
622
624
 
623
- Note: HOMEASSISTANT_URL and HOMEASSISTANT_TOKEN are NOT required in this mode.
624
- They are collected via the OAuth consent form.
625
+ Note: HOMEASSISTANT_TOKEN is NOT required in this mode.
626
+ Per-user tokens are collected via the OAuth consent form.
625
627
  """
626
628
  # Configure logging for OAuth mode
627
629
  log_level = os.getenv("LOG_LEVEL", "INFO").upper()
@@ -633,21 +635,45 @@ def main_oauth() -> None:
633
635
 
634
636
  port, path = _get_http_runtime(default_port=8086)
635
637
  base_url = os.getenv("MCP_BASE_URL")
638
+ ha_url = os.getenv("HOMEASSISTANT_URL")
636
639
 
640
+ missing = []
637
641
  if not base_url:
638
- logger.error("MCP_BASE_URL environment variable is required for OAuth mode")
639
- logger.error(
640
- "Example: export MCP_BASE_URL=https://your-tunnel.trycloudflare.com"
642
+ missing.append(" - MCP_BASE_URL (e.g., https://your-tunnel.trycloudflare.com)")
643
+ if not ha_url:
644
+ missing.append(" - HOMEASSISTANT_URL (e.g., http://homeassistant.local:8123)")
645
+
646
+ if missing:
647
+ missing_vars = "\n".join(missing)
648
+ print(
649
+ f"""
650
+ ==============================================================================
651
+ Home Assistant MCP Server - Configuration Error
652
+ ==============================================================================
653
+
654
+ Missing required environment variables for OAuth mode:
655
+ {missing_vars}
656
+
657
+ For setup instructions, see:
658
+ https://github.com/homeassistant-ai/ha-mcp/blob/master/docs/OAUTH.md
659
+
660
+ ==============================================================================
661
+ """,
662
+ file=sys.stderr,
641
663
  )
642
664
  sys.exit(1)
643
665
 
644
- _run_entrypoint(_run_oauth_server(base_url, port, path), "OAuth server")
666
+ # Type narrowing: ha_url and base_url are guaranteed non-None after the check above
667
+ assert ha_url is not None
668
+ assert base_url is not None
669
+ _run_entrypoint(_run_oauth_server(ha_url, base_url, port, path), "OAuth server")
645
670
 
646
671
 
647
- async def _run_oauth_server(base_url: str, port: int, path: str) -> None:
672
+ async def _run_oauth_server(ha_url: str, base_url: str, port: int, path: str) -> None:
648
673
  """Run the OAuth-authenticated MCP server.
649
674
 
650
675
  Args:
676
+ ha_url: Home Assistant instance URL (server-side config)
651
677
  base_url: Public URL where this server is accessible (required)
652
678
  port: Port to listen on
653
679
  path: MCP endpoint path
@@ -661,9 +687,9 @@ async def _run_oauth_server(base_url: str, port: int, path: str) -> None:
661
687
  service_documentation_url="https://github.com/homeassistant-ai/ha-mcp",
662
688
  )
663
689
 
664
- # In OAuth mode, credentials come from the OAuth consent form per-request.
665
- # The proxy client extracts them from token claims on each tool invocation.
666
- proxy_client = OAuthProxyClient(auth_provider)
690
+ # In OAuth mode, the HA URL is fixed server-side. Per-user tokens come
691
+ # from the OAuth consent form and are extracted from token claims.
692
+ proxy_client = OAuthProxyClient(ha_url)
667
693
 
668
694
  global _server
669
695
  _server = HomeAssistantSmartMCPServer(
@@ -2,16 +2,27 @@
2
2
  Consent form HTML template for Home Assistant OAuth authentication.
3
3
 
4
4
  This module provides the HTML consent form where users enter their
5
- Home Assistant URL and Long-Lived Access Token (LLAT).
5
+ Long-Lived Access Token (LLAT) to authorize MCP client access.
6
6
  """
7
7
 
8
+ import html
9
+ from urllib.parse import urlparse
10
+
11
+
12
+ def _extract_domain(redirect_uri: str) -> str:
13
+ """Extract display domain from redirect URI."""
14
+ try:
15
+ parsed = urlparse(redirect_uri)
16
+ return parsed.netloc or redirect_uri
17
+ except (AttributeError, TypeError, ValueError):
18
+ return redirect_uri
19
+
8
20
 
9
21
  def create_consent_html(
10
22
  client_id: str,
11
- client_name: str | None,
12
23
  redirect_uri: str,
13
24
  state: str,
14
- scopes: list[str],
25
+ txn_id: str,
15
26
  error_message: str | None = None,
16
27
  ) -> str:
17
28
  """
@@ -19,23 +30,27 @@ def create_consent_html(
19
30
 
20
31
  Args:
21
32
  client_id: OAuth client ID
22
- client_name: Human-readable client name
23
- redirect_uri: OAuth redirect URI
33
+ redirect_uri: OAuth redirect URI (used to derive the display domain)
24
34
  state: OAuth state parameter
25
- scopes: Requested OAuth scopes
35
+ txn_id: Transaction ID for this authorization request
26
36
  error_message: Optional error message to display
27
37
 
28
38
  Returns:
29
39
  HTML string for the consent form
30
40
  """
31
- display_name = client_name or client_id
32
- scopes_display = ", ".join(scopes) if scopes else "full access"
41
+ domain = _extract_domain(redirect_uri)
42
+ safe_domain = html.escape(domain)
43
+ safe_client_id = html.escape(client_id)
44
+ safe_redirect_uri = html.escape(redirect_uri)
45
+ safe_state = html.escape(state)
46
+ safe_txn_id = html.escape(txn_id)
33
47
 
34
48
  error_html = ""
35
49
  if error_message:
50
+ safe_error = html.escape(error_message)
36
51
  error_html = f"""
37
52
  <div class="error-message">
38
- <strong>Error:</strong> {error_message}
53
+ <strong>Error:</strong> {safe_error}
39
54
  </div>
40
55
  """
41
56
 
@@ -52,7 +67,8 @@ def create_consent_html(
52
67
  --primary-hover: #0288d1;
53
68
  --error-color: #f44336;
54
69
  --error-bg: #ffebee;
55
- --success-color: #4caf50;
70
+ --warning-color: #ff9800;
71
+ --warning-bg: #fff3e0;
56
72
  --text-color: #212121;
57
73
  --text-secondary: #757575;
58
74
  --border-color: #e0e0e0;
@@ -66,7 +82,8 @@ def create_consent_html(
66
82
  --primary-hover: #4fc3f7;
67
83
  --error-color: #ef5350;
68
84
  --error-bg: #3e2723;
69
- --success-color: #66bb6a;
85
+ --warning-color: #ffb74d;
86
+ --warning-bg: #2a1f0a;
70
87
  --text-color: #e0e0e0;
71
88
  --text-secondary: #9e9e9e;
72
89
  --border-color: #424242;
@@ -123,28 +140,6 @@ def create_consent_html(
123
140
  font-size: 14px;
124
141
  }}
125
142
 
126
- .client-info {{
127
- background: var(--bg-color);
128
- border-radius: 8px;
129
- padding: 16px;
130
- margin-bottom: 24px;
131
- }}
132
-
133
- .client-info p {{
134
- font-size: 14px;
135
- color: var(--text-secondary);
136
- }}
137
-
138
- .client-info strong {{
139
- color: var(--text-color);
140
- }}
141
-
142
- .scopes {{
143
- margin-top: 8px;
144
- font-size: 13px;
145
- color: var(--text-secondary);
146
- }}
147
-
148
143
  .error-message {{
149
144
  background: var(--error-bg);
150
145
  border: 1px solid var(--error-color);
@@ -155,6 +150,21 @@ def create_consent_html(
155
150
  color: var(--error-color);
156
151
  }}
157
152
 
153
+ .warning-box {{
154
+ background: var(--warning-bg);
155
+ border: 1px solid var(--warning-color);
156
+ border-radius: 8px;
157
+ padding: 12px 16px;
158
+ margin-bottom: 20px;
159
+ font-size: 13px;
160
+ color: var(--text-color);
161
+ line-height: 1.5;
162
+ }}
163
+
164
+ .warning-box strong {{
165
+ color: var(--warning-color);
166
+ }}
167
+
158
168
  .form-group {{
159
169
  margin-bottom: 20px;
160
170
  }}
@@ -166,8 +176,6 @@ def create_consent_html(
166
176
  margin-bottom: 8px;
167
177
  }}
168
178
 
169
- input[type="text"],
170
- input[type="url"],
171
179
  input[type="password"] {{
172
180
  width: 100%;
173
181
  padding: 12px 16px;
@@ -245,23 +253,6 @@ def create_consent_html(
245
253
  background: var(--bg-color);
246
254
  }}
247
255
 
248
- .security-note {{
249
- margin-top: 20px;
250
- padding: 12px;
251
- background: var(--bg-color);
252
- border-radius: 8px;
253
- font-size: 12px;
254
- color: var(--text-secondary);
255
- text-align: center;
256
- }}
257
-
258
- .security-note svg {{
259
- width: 14px;
260
- height: 14px;
261
- vertical-align: middle;
262
- margin-right: 4px;
263
- }}
264
-
265
256
  .loading {{
266
257
  display: none;
267
258
  }}
@@ -296,35 +287,23 @@ def create_consent_html(
296
287
  <circle fill="#18BCF2" cx="120" cy="120" r="40"/>
297
288
  </svg>
298
289
  <h1>Connect to Home Assistant</h1>
299
- <p class="subtitle">Authorize {display_name} to access your smart home</p>
300
- </div>
301
-
302
- <div class="client-info">
303
- <p>Application: <strong>{display_name}</strong></p>
304
- <p class="scopes">Requested access: <strong>{scopes_display}</strong></p>
290
+ <p class="subtitle">Authorization request from <strong>{safe_domain}</strong></p>
305
291
  </div>
306
292
 
307
293
  {error_html}
308
294
 
309
- <form method="POST" id="consent-form">
310
- <input type="hidden" name="client_id" value="{client_id}">
311
- <input type="hidden" name="redirect_uri" value="{redirect_uri}">
312
- <input type="hidden" name="state" value="{state}">
295
+ <div class="warning-box">
296
+ <strong>Important:</strong> Your access token will be shared with
297
+ <strong>{safe_domain}</strong> and used for ongoing access to your
298
+ Home Assistant instance. To revoke access, delete the token in
299
+ Home Assistant &rarr; Profile &rarr; Security &rarr; Long-Lived Access Tokens.
300
+ </div>
313
301
 
314
- <div class="form-group">
315
- <label for="ha_url">Home Assistant URL</label>
316
- <input
317
- type="url"
318
- id="ha_url"
319
- name="ha_url"
320
- placeholder="https://homeassistant.local:8123"
321
- required
322
- autocomplete="url"
323
- >
324
- <p class="help-text">
325
- The URL of your Home Assistant instance (e.g., http://homeassistant.local:8123)
326
- </p>
327
- </div>
302
+ <form method="POST" id="consent-form">
303
+ <input type="hidden" name="txn_id" value="{safe_txn_id}">
304
+ <input type="hidden" name="client_id" value="{safe_client_id}">
305
+ <input type="hidden" name="redirect_uri" value="{safe_redirect_uri}">
306
+ <input type="hidden" name="state" value="{safe_state}">
328
307
 
329
308
  <div class="form-group">
330
309
  <label for="ha_token">Long-Lived Access Token</label>
@@ -356,14 +335,6 @@ def create_consent_html(
356
335
  </button>
357
336
  </div>
358
337
  </form>
359
-
360
- <div class="security-note">
361
- <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
362
- <path d="M12 1L3 5v6c0 5.55 3.84 10.74 9 12 5.16-1.26 9-6.45 9-12V5l-9-4zm0 10.99h7c-.53 4.12-3.28 7.79-7 8.94V12H5V6.3l7-3.11v8.8z"/>
363
- </svg>
364
- Your credentials are validated directly with your Home Assistant instance
365
- and stored securely for this session only.
366
- </div>
367
338
  </div>
368
339
 
369
340
  <script>
@@ -374,22 +345,7 @@ def create_consent_html(
374
345
 
375
346
  btn.disabled = true;
376
347
  loading.classList.add('active');
377
- btnText.textContent = 'Validating...';
378
- }});
379
-
380
- // Try to detect Home Assistant URL from common patterns
381
- (function() {{
382
- var savedUrl = localStorage.getItem('ha_mcp_url');
383
- if (savedUrl) {{
384
- document.getElementById('ha_url').value = savedUrl;
385
- }}
386
- }})();
387
-
388
- // Save URL on successful form navigation
389
- document.getElementById('ha_url').addEventListener('change', function(e) {{
390
- if (e.target.value) {{
391
- localStorage.setItem('ha_mcp_url', e.target.value);
392
- }}
348
+ btnText.textContent = 'Authorizing...';
393
349
  }});
394
350
  </script>
395
351
  </body>
@@ -408,6 +364,9 @@ def create_error_html(error: str, error_description: str) -> str:
408
364
  Returns:
409
365
  HTML string for the error page
410
366
  """
367
+ safe_error = html.escape(error)
368
+ safe_description = html.escape(error_description)
369
+
411
370
  return f"""
412
371
  <!DOCTYPE html>
413
372
  <html lang="en">
@@ -493,8 +452,8 @@ def create_error_html(error: str, error_description: str) -> str:
493
452
  <path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 15h-2v-2h2v2zm0-4h-2V7h2v6z"/>
494
453
  </svg>
495
454
  <h1>Authentication Error</h1>
496
- <div class="error-code">{error}</div>
497
- <p>{error_description}</p>
455
+ <div class="error-code">{safe_error}</div>
456
+ <p>{safe_description}</p>
498
457
  </div>
499
458
  </body>
500
459
  </html>
@@ -3,7 +3,7 @@ Home Assistant OAuth 2.1 Provider.
3
3
 
4
4
  This module implements OAuth 2.1 authentication with Dynamic Client Registration (DCR)
5
5
  for Home Assistant MCP Server. Users authenticate via a consent form where they
6
- provide their Home Assistant URL and Long-Lived Access Token (LLAT).
6
+ provide their Long-Lived Access Token (LLAT).
7
7
  """
8
8
 
9
9
  import binascii
@@ -15,7 +15,6 @@ from base64 import urlsafe_b64decode, urlsafe_b64encode
15
15
  from typing import Any
16
16
  from urllib.parse import urlencode
17
17
 
18
- import httpx
19
18
  from fastmcp.server.auth.auth import (
20
19
  AccessToken, # FastMCP version has claims field
21
20
  ClientRegistrationOptions,
@@ -49,15 +48,13 @@ REFRESH_TOKEN_EXPIRY_SECONDS = 7 * 24 * 60 * 60 # 7 days
49
48
  class HomeAssistantCredentials:
50
49
  """Stores Home Assistant credentials for a client."""
51
50
 
52
- def __init__(self, ha_url: str, ha_token: str):
53
- self.ha_url = ha_url.rstrip("/")
51
+ def __init__(self, ha_token: str):
54
52
  self.ha_token = ha_token
55
53
  self.validated_at = time.time()
56
54
 
57
55
  def to_dict(self) -> dict[str, Any]:
58
56
  """Convert to dictionary for storage."""
59
57
  return {
60
- "ha_url": self.ha_url,
61
58
  "ha_token": self.ha_token,
62
59
  "validated_at": self.validated_at,
63
60
  }
@@ -73,11 +70,11 @@ class HomeAssistantOAuthProvider(OAuthProvider):
73
70
  - Custom consent form for collecting HA credentials
74
71
  - Stateless access tokens (base64-encoded JSON)
75
72
 
76
- The consent form collects the user's Home Assistant URL and
77
- Long-Lived Access Token, validates them, and encodes them into
78
- stateless access tokens for subsequent API calls.
73
+ The consent form collects the user's Long-Lived Access Token,
74
+ which is encoded into stateless access tokens for subsequent API calls.
75
+ The Home Assistant URL is configured server-side via HOMEASSISTANT_URL.
79
76
 
80
- Access tokens are base64-encoded JSON containing HA credentials.
77
+ Access tokens are base64-encoded JSON containing the HA token.
81
78
  No encryption or signing - security comes from HTTPS transport
82
79
  and the LLAT itself being the authorization boundary.
83
80
  """
@@ -140,16 +137,15 @@ class HomeAssistantOAuthProvider(OAuthProvider):
140
137
 
141
138
  logger.info(f"HomeAssistantOAuthProvider initialized with base_url={base_url}")
142
139
 
143
- def _encode_credentials(self, ha_url: str, ha_token: str) -> str:
140
+ def _encode_credentials(self, ha_token: str) -> str:
144
141
  """
145
- Encode HA credentials into a stateless access token.
142
+ Encode HA token into a stateless access token.
146
143
 
147
- Tokens are base64-encoded JSON containing HA credentials.
144
+ Tokens are base64-encoded JSON containing the HA token.
148
145
  No encryption or signing - credentials are readable but transmitted over HTTPS.
149
146
  The LLAT itself provides the security boundary.
150
147
  """
151
148
  payload = {
152
- "ha_url": ha_url,
153
149
  "ha_token": ha_token,
154
150
  "iat": int(time.time()),
155
151
  }
@@ -157,11 +153,11 @@ class HomeAssistantOAuthProvider(OAuthProvider):
157
153
  encoded = urlsafe_b64encode(json_str.encode()).decode().rstrip("=")
158
154
  return encoded
159
155
 
160
- def _decode_credentials(self, token: str) -> tuple[str, str] | None:
156
+ def _decode_credentials(self, token: str) -> str | None:
161
157
  """
162
- Decode access token to extract HA credentials.
158
+ Decode access token to extract HA token.
163
159
 
164
- Returns (ha_url, ha_token) or None if invalid.
160
+ Returns ha_token or None if invalid.
165
161
  """
166
162
  try:
167
163
  # Add padding if needed
@@ -172,11 +168,10 @@ class HomeAssistantOAuthProvider(OAuthProvider):
172
168
  decoded = urlsafe_b64decode(token.encode()).decode()
173
169
  payload = json.loads(decoded)
174
170
 
175
- ha_url = payload.get("ha_url")
176
171
  ha_token = payload.get("ha_token")
177
172
 
178
- if ha_url and ha_token:
179
- return ha_url, ha_token
173
+ if ha_token:
174
+ return ha_token
180
175
  return None
181
176
  except (binascii.Error, json.JSONDecodeError, UnicodeDecodeError) as e:
182
177
  logger.debug(f"Failed to decode token: {e}")
@@ -405,32 +400,34 @@ class HomeAssistantOAuthProvider(OAuthProvider):
405
400
  status_code=400,
406
401
  )
407
402
 
408
- html = create_consent_html(
403
+ redirect_uri = pending.get("redirect_uri", "")
404
+ if not redirect_uri:
405
+ return HTMLResponse(
406
+ create_error_html(
407
+ "invalid_request",
408
+ "No redirect URI provided. The client must specify a redirect URI.",
409
+ ),
410
+ status_code=400,
411
+ )
412
+
413
+ consent_html = create_consent_html(
409
414
  client_id=pending["client_id"],
410
- client_name=pending.get("client_name"),
411
- redirect_uri=pending["redirect_uri"],
415
+ redirect_uri=redirect_uri,
412
416
  state=pending.get("state", ""),
413
- scopes=pending.get("scopes", []),
417
+ txn_id=txn_id,
414
418
  error_message=error_message,
415
419
  )
416
420
 
417
- # Add txn_id as hidden field
418
- html = html.replace(
419
- '<input type="hidden" name="client_id"',
420
- f'<input type="hidden" name="txn_id" value="{txn_id}">\n <input type="hidden" name="client_id"',
421
- )
422
-
423
- return HTMLResponse(html)
421
+ return HTMLResponse(consent_html)
424
422
 
425
423
  async def _consent_post(self, request: Request) -> Response:
426
424
  """Handle POST request from consent form."""
427
- logger.info("📝 === CONSENT FORM POST RECEIVED ===")
425
+ logger.info("=== CONSENT FORM POST RECEIVED ===")
428
426
  form = await request.form()
429
427
 
430
428
  txn_id = form.get("txn_id")
431
- ha_url = form.get("ha_url")
432
429
  ha_token = form.get("ha_token")
433
- logger.info(f"📝 Form data: txn_id={txn_id}, ha_url={ha_url}, has_token={ha_token is not None}")
430
+ logger.info(f"Form data: txn_id={txn_id}, has_token={ha_token is not None}")
434
431
 
435
432
  if not txn_id:
436
433
  return HTMLResponse(
@@ -451,30 +448,13 @@ class HomeAssistantOAuthProvider(OAuthProvider):
451
448
  status_code=400,
452
449
  )
453
450
 
454
- if not ha_url or not ha_token:
451
+ if not ha_token:
455
452
  # Redirect back to form with error
456
453
  base = str(self.base_url).rstrip('/')
457
454
  error_params = urlencode(
458
455
  {
459
456
  "txn_id": txn_id,
460
- "error": "Please provide both Home Assistant URL and access token.",
461
- }
462
- )
463
- return RedirectResponse(
464
- f"{base}/consent?{error_params}",
465
- status_code=303,
466
- )
467
-
468
- # Validate HA credentials
469
- validation_error = await self._validate_ha_credentials(
470
- str(ha_url), str(ha_token)
471
- )
472
- if validation_error:
473
- base = str(self.base_url).rstrip('/')
474
- error_params = urlencode(
475
- {
476
- "txn_id": txn_id,
477
- "error": validation_error,
457
+ "error": "Please provide your Long-Lived Access Token.",
478
458
  }
479
459
  )
480
460
  return RedirectResponse(
@@ -482,13 +462,13 @@ class HomeAssistantOAuthProvider(OAuthProvider):
482
462
  status_code=303,
483
463
  )
484
464
 
485
- # Store validated credentials
465
+ # Store credentials (no server-side validation - the token will be
466
+ # validated on first actual API call to the configured HA instance)
486
467
  client_id = pending["client_id"]
487
468
  self.ha_credentials[client_id] = HomeAssistantCredentials(
488
- ha_url=str(ha_url),
489
469
  ha_token=str(ha_token),
490
470
  )
491
- logger.info(f"Stored HA credentials for client {client_id}: {str(ha_url)}")
471
+ logger.info(f"Stored HA credentials for client {client_id}")
492
472
 
493
473
  # Generate authorization code
494
474
  auth_code_value = f"ha_auth_code_{secrets.token_hex(16)}"
@@ -524,59 +504,6 @@ class HomeAssistantOAuthProvider(OAuthProvider):
524
504
  logger.info(f"Authorization successful for client {client_id}")
525
505
  return RedirectResponse(redirect_uri, status_code=303)
526
506
 
527
- async def _validate_ha_credentials(self, ha_url: str, ha_token: str) -> str | None:
528
- """
529
- Validate Home Assistant credentials by making a test API call.
530
-
531
- Args:
532
- ha_url: Home Assistant URL
533
- ha_token: Long-Lived Access Token
534
-
535
- Returns:
536
- Error message if validation failed, None if successful
537
- """
538
- try:
539
- ha_url = ha_url.rstrip("/")
540
-
541
- async with httpx.AsyncClient(timeout=10.0) as client:
542
- response = await client.get(
543
- f"{ha_url}/api/config",
544
- headers={
545
- "Authorization": f"Bearer {ha_token}",
546
- "Content-Type": "application/json",
547
- },
548
- )
549
-
550
- if response.status_code == 401:
551
- return "Invalid access token. Please check your Long-Lived Access Token."
552
-
553
- if response.status_code == 403:
554
- return "Access forbidden. The token may not have sufficient permissions."
555
-
556
- if response.status_code >= 400:
557
- return f"Failed to connect to Home Assistant: HTTP {response.status_code}"
558
-
559
- # Verify we got a valid config response
560
- try:
561
- config = response.json()
562
- if "location_name" not in config and "version" not in config:
563
- return "Invalid response from Home Assistant. Please check the URL."
564
- except Exception:
565
- return "Invalid response format from Home Assistant."
566
-
567
- logger.info(
568
- f"Validated HA credentials for {config.get('location_name', 'Unknown')}"
569
- )
570
- return None
571
-
572
- except httpx.ConnectError:
573
- return "Could not connect to Home Assistant. Please check the URL and ensure Home Assistant is accessible."
574
- except httpx.TimeoutException:
575
- return "Connection to Home Assistant timed out. Please check the URL."
576
- except Exception as e:
577
- logger.error(f"Error validating HA credentials: {e}")
578
- return f"Failed to validate credentials: {str(e)}"
579
-
580
507
  async def load_authorization_code(
581
508
  self, client: OAuthClientInformationFull, authorization_code: str
582
509
  ) -> AuthorizationCode | None:
@@ -607,10 +534,8 @@ class HomeAssistantOAuthProvider(OAuthProvider):
607
534
  raise TokenError("invalid_client", "Client ID is required")
608
535
 
609
536
  # Generate tokens
610
- access_token_value = f"ha_access_{secrets.token_hex(32)}"
611
537
  refresh_token_value = f"ha_refresh_{secrets.token_hex(32)}"
612
538
 
613
- access_token_expires_at = int(time.time() + ACCESS_TOKEN_EXPIRY_SECONDS)
614
539
  refresh_token_expires_at = int(time.time() + REFRESH_TOKEN_EXPIRY_SECONDS)
615
540
 
616
541
  # Get HA credentials for this client to encode in token
@@ -621,11 +546,9 @@ class HomeAssistantOAuthProvider(OAuthProvider):
621
546
  f"No Home Assistant credentials found for client {client.client_id}",
622
547
  )
623
548
 
624
- # STATELESS TOKEN: Encode HA credentials directly into the access token
549
+ # STATELESS TOKEN: Encode HA token directly into the access token
625
550
  # No server-side storage needed - token contains everything as base64-encoded JSON
626
- access_token_value = self._encode_credentials(
627
- ha_credentials.ha_url, ha_credentials.ha_token
628
- )
551
+ access_token_value = self._encode_credentials(ha_credentials.ha_token)
629
552
 
630
553
  # Still use random string for refresh token (less sensitive, can be in memory)
631
554
  # Refresh tokens are less frequently used and don't need to carry credentials
@@ -738,17 +661,15 @@ class HomeAssistantOAuthProvider(OAuthProvider):
738
661
  """
739
662
  Load and validate access token.
740
663
 
741
- STATELESS: Decodes token to extract HA credentials.
664
+ STATELESS: Decodes token to extract HA token.
742
665
  No server-side storage needed - token is self-contained base64-encoded JSON.
743
666
  """
744
667
 
745
- # Decode token to get HA credentials
746
- credentials = self._decode_credentials(token)
747
- if not credentials:
668
+ # Decode token to get HA token
669
+ ha_token = self._decode_credentials(token)
670
+ if not ha_token:
748
671
  return None
749
672
 
750
- ha_url, ha_token = credentials
751
-
752
673
  # Create AccessToken object with decoded credentials in claims
753
674
  # No expiry check - tokens don't expire (LLAT revocation handles security)
754
675
  return AccessToken(
@@ -757,7 +678,6 @@ class HomeAssistantOAuthProvider(OAuthProvider):
757
678
  scopes=["homeassistant", "mcp"],
758
679
  expires_at=None, # Stateless tokens don't expire
759
680
  claims={
760
- "ha_url": ha_url,
761
681
  "ha_token": ha_token,
762
682
  },
763
683
  )
@@ -801,7 +721,7 @@ class HomeAssistantOAuthProvider(OAuthProvider):
801
721
  """
802
722
  Get Home Assistant credentials for a client.
803
723
 
804
- This is used by the MCP server to get the HA URL and token
724
+ This is used by the MCP server to get the HA token
805
725
  for making API calls on behalf of the authenticated user.
806
726
 
807
727
  Args:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ha-mcp-dev
3
- Version: 6.7.2.dev257
3
+ Version: 6.7.2.dev259
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