ha-mcp-dev 7.6.0.dev612__tar.gz → 7.6.0.dev614__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 (124) hide show
  1. {ha_mcp_dev-7.6.0.dev612/src/ha_mcp_dev.egg-info → ha_mcp_dev-7.6.0.dev614}/PKG-INFO +1 -1
  2. {ha_mcp_dev-7.6.0.dev612 → ha_mcp_dev-7.6.0.dev614}/pyproject.toml +1 -1
  3. {ha_mcp_dev-7.6.0.dev612 → ha_mcp_dev-7.6.0.dev614}/src/ha_mcp/auth/provider.py +163 -12
  4. {ha_mcp_dev-7.6.0.dev612 → ha_mcp_dev-7.6.0.dev614}/src/ha_mcp/tools/tools_filesystem.py +22 -3
  5. {ha_mcp_dev-7.6.0.dev612 → ha_mcp_dev-7.6.0.dev614/src/ha_mcp_dev.egg-info}/PKG-INFO +1 -1
  6. {ha_mcp_dev-7.6.0.dev612 → ha_mcp_dev-7.6.0.dev614}/LICENSE +0 -0
  7. {ha_mcp_dev-7.6.0.dev612 → ha_mcp_dev-7.6.0.dev614}/MANIFEST.in +0 -0
  8. {ha_mcp_dev-7.6.0.dev612 → ha_mcp_dev-7.6.0.dev614}/README.md +0 -0
  9. {ha_mcp_dev-7.6.0.dev612 → ha_mcp_dev-7.6.0.dev614}/setup.cfg +0 -0
  10. {ha_mcp_dev-7.6.0.dev612 → ha_mcp_dev-7.6.0.dev614}/src/ha_mcp/__init__.py +0 -0
  11. {ha_mcp_dev-7.6.0.dev612 → ha_mcp_dev-7.6.0.dev614}/src/ha_mcp/__main__.py +0 -0
  12. {ha_mcp_dev-7.6.0.dev612 → ha_mcp_dev-7.6.0.dev614}/src/ha_mcp/_pypi_marker +0 -0
  13. {ha_mcp_dev-7.6.0.dev612 → ha_mcp_dev-7.6.0.dev614}/src/ha_mcp/_version.py +0 -0
  14. {ha_mcp_dev-7.6.0.dev612 → ha_mcp_dev-7.6.0.dev614}/src/ha_mcp/auth/__init__.py +0 -0
  15. {ha_mcp_dev-7.6.0.dev612 → ha_mcp_dev-7.6.0.dev614}/src/ha_mcp/auth/consent_form.py +0 -0
  16. {ha_mcp_dev-7.6.0.dev612 → ha_mcp_dev-7.6.0.dev614}/src/ha_mcp/backup_manager.py +0 -0
  17. {ha_mcp_dev-7.6.0.dev612 → ha_mcp_dev-7.6.0.dev614}/src/ha_mcp/client/__init__.py +0 -0
  18. {ha_mcp_dev-7.6.0.dev612 → ha_mcp_dev-7.6.0.dev614}/src/ha_mcp/client/rest_client.py +0 -0
  19. {ha_mcp_dev-7.6.0.dev612 → ha_mcp_dev-7.6.0.dev614}/src/ha_mcp/client/supervisor_client.py +0 -0
  20. {ha_mcp_dev-7.6.0.dev612 → ha_mcp_dev-7.6.0.dev614}/src/ha_mcp/client/websocket_client.py +0 -0
  21. {ha_mcp_dev-7.6.0.dev612 → ha_mcp_dev-7.6.0.dev614}/src/ha_mcp/client/websocket_listener.py +0 -0
  22. {ha_mcp_dev-7.6.0.dev612 → ha_mcp_dev-7.6.0.dev614}/src/ha_mcp/config.py +0 -0
  23. {ha_mcp_dev-7.6.0.dev612 → ha_mcp_dev-7.6.0.dev614}/src/ha_mcp/errors.py +0 -0
  24. {ha_mcp_dev-7.6.0.dev612 → ha_mcp_dev-7.6.0.dev614}/src/ha_mcp/policy/__init__.py +0 -0
  25. {ha_mcp_dev-7.6.0.dev612 → ha_mcp_dev-7.6.0.dev614}/src/ha_mcp/policy/approval_queue.py +0 -0
  26. {ha_mcp_dev-7.6.0.dev612 → ha_mcp_dev-7.6.0.dev614}/src/ha_mcp/policy/evaluator.py +0 -0
  27. {ha_mcp_dev-7.6.0.dev612 → ha_mcp_dev-7.6.0.dev614}/src/ha_mcp/policy/handlers.py +0 -0
  28. {ha_mcp_dev-7.6.0.dev612 → ha_mcp_dev-7.6.0.dev614}/src/ha_mcp/policy/middleware.py +0 -0
  29. {ha_mcp_dev-7.6.0.dev612 → ha_mcp_dev-7.6.0.dev614}/src/ha_mcp/policy/model.py +0 -0
  30. {ha_mcp_dev-7.6.0.dev612 → ha_mcp_dev-7.6.0.dev614}/src/ha_mcp/policy/persistence.py +0 -0
  31. {ha_mcp_dev-7.6.0.dev612 → ha_mcp_dev-7.6.0.dev614}/src/ha_mcp/policy/value_sources.py +0 -0
  32. {ha_mcp_dev-7.6.0.dev612 → ha_mcp_dev-7.6.0.dev614}/src/ha_mcp/py.typed +0 -0
  33. {ha_mcp_dev-7.6.0.dev612 → ha_mcp_dev-7.6.0.dev614}/src/ha_mcp/resources/skills-vendor/.claude/settings.json +0 -0
  34. {ha_mcp_dev-7.6.0.dev612 → ha_mcp_dev-7.6.0.dev614}/src/ha_mcp/resources/skills-vendor/.claude-plugin/marketplace.json +0 -0
  35. {ha_mcp_dev-7.6.0.dev612 → ha_mcp_dev-7.6.0.dev614}/src/ha_mcp/resources/skills-vendor/.claude-plugin/plugin.json +0 -0
  36. {ha_mcp_dev-7.6.0.dev612 → ha_mcp_dev-7.6.0.dev614}/src/ha_mcp/resources/skills-vendor/.github/ISSUE_TEMPLATE/skill-rca.md +0 -0
  37. {ha_mcp_dev-7.6.0.dev612 → ha_mcp_dev-7.6.0.dev614}/src/ha_mcp/resources/skills-vendor/.github/pull_request_template.md +0 -0
  38. {ha_mcp_dev-7.6.0.dev612 → ha_mcp_dev-7.6.0.dev614}/src/ha_mcp/resources/skills-vendor/AGENTS.md +0 -0
  39. {ha_mcp_dev-7.6.0.dev612 → ha_mcp_dev-7.6.0.dev614}/src/ha_mcp/resources/skills-vendor/CLAUDE.md +0 -0
  40. {ha_mcp_dev-7.6.0.dev612 → ha_mcp_dev-7.6.0.dev614}/src/ha_mcp/resources/skills-vendor/CONTRIBUTING.md +0 -0
  41. {ha_mcp_dev-7.6.0.dev612 → ha_mcp_dev-7.6.0.dev614}/src/ha_mcp/resources/skills-vendor/LICENSE +0 -0
  42. {ha_mcp_dev-7.6.0.dev612 → ha_mcp_dev-7.6.0.dev614}/src/ha_mcp/resources/skills-vendor/README.md +0 -0
  43. {ha_mcp_dev-7.6.0.dev612 → ha_mcp_dev-7.6.0.dev614}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/SKILL.md +0 -0
  44. {ha_mcp_dev-7.6.0.dev612 → ha_mcp_dev-7.6.0.dev614}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/evals/evals.json +0 -0
  45. {ha_mcp_dev-7.6.0.dev612 → ha_mcp_dev-7.6.0.dev614}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/automation-patterns.md +0 -0
  46. {ha_mcp_dev-7.6.0.dev612 → ha_mcp_dev-7.6.0.dev614}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/dashboard-cards.md +0 -0
  47. {ha_mcp_dev-7.6.0.dev612 → ha_mcp_dev-7.6.0.dev614}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/dashboard-guide.md +0 -0
  48. {ha_mcp_dev-7.6.0.dev612 → ha_mcp_dev-7.6.0.dev614}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/device-control.md +0 -0
  49. {ha_mcp_dev-7.6.0.dev612 → ha_mcp_dev-7.6.0.dev614}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/domain-docs.md +0 -0
  50. {ha_mcp_dev-7.6.0.dev612 → ha_mcp_dev-7.6.0.dev614}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/examples.yaml +0 -0
  51. {ha_mcp_dev-7.6.0.dev612 → ha_mcp_dev-7.6.0.dev614}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/helper-selection.md +0 -0
  52. {ha_mcp_dev-7.6.0.dev612 → ha_mcp_dev-7.6.0.dev614}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/safe-refactoring.md +0 -0
  53. {ha_mcp_dev-7.6.0.dev612 → ha_mcp_dev-7.6.0.dev614}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/template-guidelines.md +0 -0
  54. {ha_mcp_dev-7.6.0.dev612 → ha_mcp_dev-7.6.0.dev614}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/yaml-only-integrations.md +0 -0
  55. {ha_mcp_dev-7.6.0.dev612 → ha_mcp_dev-7.6.0.dev614}/src/ha_mcp/server.py +0 -0
  56. {ha_mcp_dev-7.6.0.dev612 → ha_mcp_dev-7.6.0.dev614}/src/ha_mcp/settings_ui.py +0 -0
  57. {ha_mcp_dev-7.6.0.dev612 → ha_mcp_dev-7.6.0.dev614}/src/ha_mcp/smoke_test.py +0 -0
  58. {ha_mcp_dev-7.6.0.dev612 → ha_mcp_dev-7.6.0.dev614}/src/ha_mcp/stdio_settings_sidecar.py +0 -0
  59. {ha_mcp_dev-7.6.0.dev612 → ha_mcp_dev-7.6.0.dev614}/src/ha_mcp/tools/__init__.py +0 -0
  60. {ha_mcp_dev-7.6.0.dev612 → ha_mcp_dev-7.6.0.dev614}/src/ha_mcp/tools/auto_backup.py +0 -0
  61. {ha_mcp_dev-7.6.0.dev612 → ha_mcp_dev-7.6.0.dev614}/src/ha_mcp/tools/backup.py +0 -0
  62. {ha_mcp_dev-7.6.0.dev612 → ha_mcp_dev-7.6.0.dev614}/src/ha_mcp/tools/best_practice_checker.py +0 -0
  63. {ha_mcp_dev-7.6.0.dev612 → ha_mcp_dev-7.6.0.dev614}/src/ha_mcp/tools/device_control.py +0 -0
  64. {ha_mcp_dev-7.6.0.dev612 → ha_mcp_dev-7.6.0.dev614}/src/ha_mcp/tools/enhanced.py +0 -0
  65. {ha_mcp_dev-7.6.0.dev612 → ha_mcp_dev-7.6.0.dev614}/src/ha_mcp/tools/helpers.py +0 -0
  66. {ha_mcp_dev-7.6.0.dev612 → ha_mcp_dev-7.6.0.dev614}/src/ha_mcp/tools/reference_validator.py +0 -0
  67. {ha_mcp_dev-7.6.0.dev612 → ha_mcp_dev-7.6.0.dev614}/src/ha_mcp/tools/registry.py +0 -0
  68. {ha_mcp_dev-7.6.0.dev612 → ha_mcp_dev-7.6.0.dev614}/src/ha_mcp/tools/smart_search.py +0 -0
  69. {ha_mcp_dev-7.6.0.dev612 → ha_mcp_dev-7.6.0.dev614}/src/ha_mcp/tools/tools_addons.py +0 -0
  70. {ha_mcp_dev-7.6.0.dev612 → ha_mcp_dev-7.6.0.dev614}/src/ha_mcp/tools/tools_areas.py +0 -0
  71. {ha_mcp_dev-7.6.0.dev612 → ha_mcp_dev-7.6.0.dev614}/src/ha_mcp/tools/tools_blueprints.py +0 -0
  72. {ha_mcp_dev-7.6.0.dev612 → ha_mcp_dev-7.6.0.dev614}/src/ha_mcp/tools/tools_bug_report.py +0 -0
  73. {ha_mcp_dev-7.6.0.dev612 → ha_mcp_dev-7.6.0.dev614}/src/ha_mcp/tools/tools_calendar.py +0 -0
  74. {ha_mcp_dev-7.6.0.dev612 → ha_mcp_dev-7.6.0.dev614}/src/ha_mcp/tools/tools_camera.py +0 -0
  75. {ha_mcp_dev-7.6.0.dev612 → ha_mcp_dev-7.6.0.dev614}/src/ha_mcp/tools/tools_categories.py +0 -0
  76. {ha_mcp_dev-7.6.0.dev612 → ha_mcp_dev-7.6.0.dev614}/src/ha_mcp/tools/tools_code.py +0 -0
  77. {ha_mcp_dev-7.6.0.dev612 → ha_mcp_dev-7.6.0.dev614}/src/ha_mcp/tools/tools_config_automations.py +0 -0
  78. {ha_mcp_dev-7.6.0.dev612 → ha_mcp_dev-7.6.0.dev614}/src/ha_mcp/tools/tools_config_dashboards.py +0 -0
  79. {ha_mcp_dev-7.6.0.dev612 → ha_mcp_dev-7.6.0.dev614}/src/ha_mcp/tools/tools_config_entry_flow.py +0 -0
  80. {ha_mcp_dev-7.6.0.dev612 → ha_mcp_dev-7.6.0.dev614}/src/ha_mcp/tools/tools_config_helpers.py +0 -0
  81. {ha_mcp_dev-7.6.0.dev612 → ha_mcp_dev-7.6.0.dev614}/src/ha_mcp/tools/tools_config_scenes.py +0 -0
  82. {ha_mcp_dev-7.6.0.dev612 → ha_mcp_dev-7.6.0.dev614}/src/ha_mcp/tools/tools_config_scripts.py +0 -0
  83. {ha_mcp_dev-7.6.0.dev612 → ha_mcp_dev-7.6.0.dev614}/src/ha_mcp/tools/tools_energy.py +0 -0
  84. {ha_mcp_dev-7.6.0.dev612 → ha_mcp_dev-7.6.0.dev614}/src/ha_mcp/tools/tools_entities.py +0 -0
  85. {ha_mcp_dev-7.6.0.dev612 → ha_mcp_dev-7.6.0.dev614}/src/ha_mcp/tools/tools_groups.py +0 -0
  86. {ha_mcp_dev-7.6.0.dev612 → ha_mcp_dev-7.6.0.dev614}/src/ha_mcp/tools/tools_hacs.py +0 -0
  87. {ha_mcp_dev-7.6.0.dev612 → ha_mcp_dev-7.6.0.dev614}/src/ha_mcp/tools/tools_history.py +0 -0
  88. {ha_mcp_dev-7.6.0.dev612 → ha_mcp_dev-7.6.0.dev614}/src/ha_mcp/tools/tools_integrations.py +0 -0
  89. {ha_mcp_dev-7.6.0.dev612 → ha_mcp_dev-7.6.0.dev614}/src/ha_mcp/tools/tools_labels.py +0 -0
  90. {ha_mcp_dev-7.6.0.dev612 → ha_mcp_dev-7.6.0.dev614}/src/ha_mcp/tools/tools_mcp_component.py +0 -0
  91. {ha_mcp_dev-7.6.0.dev612 → ha_mcp_dev-7.6.0.dev614}/src/ha_mcp/tools/tools_registry.py +0 -0
  92. {ha_mcp_dev-7.6.0.dev612 → ha_mcp_dev-7.6.0.dev614}/src/ha_mcp/tools/tools_resources.py +0 -0
  93. {ha_mcp_dev-7.6.0.dev612 → ha_mcp_dev-7.6.0.dev614}/src/ha_mcp/tools/tools_search.py +0 -0
  94. {ha_mcp_dev-7.6.0.dev612 → ha_mcp_dev-7.6.0.dev614}/src/ha_mcp/tools/tools_service.py +0 -0
  95. {ha_mcp_dev-7.6.0.dev612 → ha_mcp_dev-7.6.0.dev614}/src/ha_mcp/tools/tools_services.py +0 -0
  96. {ha_mcp_dev-7.6.0.dev612 → ha_mcp_dev-7.6.0.dev614}/src/ha_mcp/tools/tools_system.py +0 -0
  97. {ha_mcp_dev-7.6.0.dev612 → ha_mcp_dev-7.6.0.dev614}/src/ha_mcp/tools/tools_todo.py +0 -0
  98. {ha_mcp_dev-7.6.0.dev612 → ha_mcp_dev-7.6.0.dev614}/src/ha_mcp/tools/tools_traces.py +0 -0
  99. {ha_mcp_dev-7.6.0.dev612 → ha_mcp_dev-7.6.0.dev614}/src/ha_mcp/tools/tools_updates.py +0 -0
  100. {ha_mcp_dev-7.6.0.dev612 → ha_mcp_dev-7.6.0.dev614}/src/ha_mcp/tools/tools_utility.py +0 -0
  101. {ha_mcp_dev-7.6.0.dev612 → ha_mcp_dev-7.6.0.dev614}/src/ha_mcp/tools/tools_voice_assistant.py +0 -0
  102. {ha_mcp_dev-7.6.0.dev612 → ha_mcp_dev-7.6.0.dev614}/src/ha_mcp/tools/tools_yaml_config.py +0 -0
  103. {ha_mcp_dev-7.6.0.dev612 → ha_mcp_dev-7.6.0.dev614}/src/ha_mcp/tools/tools_zones.py +0 -0
  104. {ha_mcp_dev-7.6.0.dev612 → ha_mcp_dev-7.6.0.dev614}/src/ha_mcp/tools/util_helpers.py +0 -0
  105. {ha_mcp_dev-7.6.0.dev612 → ha_mcp_dev-7.6.0.dev614}/src/ha_mcp/transforms/__init__.py +0 -0
  106. {ha_mcp_dev-7.6.0.dev612 → ha_mcp_dev-7.6.0.dev614}/src/ha_mcp/transforms/categorized_search.py +0 -0
  107. {ha_mcp_dev-7.6.0.dev612 → ha_mcp_dev-7.6.0.dev614}/src/ha_mcp/transforms/lite_docstrings.py +0 -0
  108. {ha_mcp_dev-7.6.0.dev612 → ha_mcp_dev-7.6.0.dev614}/src/ha_mcp/utils/__init__.py +0 -0
  109. {ha_mcp_dev-7.6.0.dev612 → ha_mcp_dev-7.6.0.dev614}/src/ha_mcp/utils/config_hash.py +0 -0
  110. {ha_mcp_dev-7.6.0.dev612 → ha_mcp_dev-7.6.0.dev614}/src/ha_mcp/utils/data_paths.py +0 -0
  111. {ha_mcp_dev-7.6.0.dev612 → ha_mcp_dev-7.6.0.dev614}/src/ha_mcp/utils/domain_handlers.py +0 -0
  112. {ha_mcp_dev-7.6.0.dev612 → ha_mcp_dev-7.6.0.dev614}/src/ha_mcp/utils/fuzzy_search.py +0 -0
  113. {ha_mcp_dev-7.6.0.dev612 → ha_mcp_dev-7.6.0.dev614}/src/ha_mcp/utils/kill_signal_diagnostics.py +0 -0
  114. {ha_mcp_dev-7.6.0.dev612 → ha_mcp_dev-7.6.0.dev614}/src/ha_mcp/utils/operation_manager.py +0 -0
  115. {ha_mcp_dev-7.6.0.dev612 → ha_mcp_dev-7.6.0.dev614}/src/ha_mcp/utils/python_sandbox.py +0 -0
  116. {ha_mcp_dev-7.6.0.dev612 → ha_mcp_dev-7.6.0.dev614}/src/ha_mcp/utils/usage_logger.py +0 -0
  117. {ha_mcp_dev-7.6.0.dev612 → ha_mcp_dev-7.6.0.dev614}/src/ha_mcp_dev.egg-info/SOURCES.txt +0 -0
  118. {ha_mcp_dev-7.6.0.dev612 → ha_mcp_dev-7.6.0.dev614}/src/ha_mcp_dev.egg-info/dependency_links.txt +0 -0
  119. {ha_mcp_dev-7.6.0.dev612 → ha_mcp_dev-7.6.0.dev614}/src/ha_mcp_dev.egg-info/entry_points.txt +0 -0
  120. {ha_mcp_dev-7.6.0.dev612 → ha_mcp_dev-7.6.0.dev614}/src/ha_mcp_dev.egg-info/requires.txt +0 -0
  121. {ha_mcp_dev-7.6.0.dev612 → ha_mcp_dev-7.6.0.dev614}/src/ha_mcp_dev.egg-info/top_level.txt +0 -0
  122. {ha_mcp_dev-7.6.0.dev612 → ha_mcp_dev-7.6.0.dev614}/tests/__init__.py +0 -0
  123. {ha_mcp_dev-7.6.0.dev612 → ha_mcp_dev-7.6.0.dev614}/tests/test_constants.py +0 -0
  124. {ha_mcp_dev-7.6.0.dev612 → ha_mcp_dev-7.6.0.dev614}/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.6.0.dev612
3
+ Version: 7.6.0.dev614
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.6.0.dev612"
7
+ version = "7.6.0.dev614"
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"
@@ -7,13 +7,16 @@ provide their Long-Lived Access Token (LLAT).
7
7
  """
8
8
 
9
9
  import binascii
10
+ import contextlib
10
11
  import hashlib
11
12
  import hmac
12
13
  import json
13
14
  import logging
15
+ import os
14
16
  import secrets
15
17
  import time
16
18
  from base64 import urlsafe_b64decode, urlsafe_b64encode
19
+ from pathlib import Path
17
20
  from typing import Any
18
21
  from urllib.parse import urlencode
19
22
 
@@ -32,11 +35,12 @@ from mcp.server.auth.provider import (
32
35
  construct_redirect_uri,
33
36
  )
34
37
  from mcp.shared.auth import OAuthClientInformationFull, OAuthToken
35
- from pydantic import AnyHttpUrl
38
+ from pydantic import AnyHttpUrl, ValidationError
36
39
  from starlette.requests import Request
37
40
  from starlette.responses import HTMLResponse, RedirectResponse, Response
38
41
  from starlette.routing import Route
39
42
 
43
+ from ..utils.data_paths import get_data_dir
40
44
  from .consent_form import create_consent_html, create_error_html
41
45
 
42
46
  logger = logging.getLogger(__name__)
@@ -66,9 +70,16 @@ class HomeAssistantOAuthProvider(OAuthProvider):
66
70
 
67
71
  The consent form collects the user's Long-Lived Access Token (LLAT),
68
72
  which is encoded into both access and refresh tokens as base64 JSON.
69
- No server-side token state is stored, so the server survives container
70
- restarts without losing sessions (clients re-register via DCR
71
- automatically).
73
+ Tokens are stateless (HMAC-signed base64 JSON) so no per-token state is
74
+ stored server-side.
75
+
76
+ DCR client registrations and the HMAC signing secret are persisted to
77
+ ``get_data_dir() / "oauth_clients.json"`` and
78
+ ``get_data_dir() / "oauth_hmac_secret"`` respectively, so registered
79
+ clients survive container restarts. If the data directory is not
80
+ writable (e.g. Docker without a mounted volume) the server falls back
81
+ to in-memory-only storage and logs a warning; existing clients must
82
+ re-register after each restart in that case.
72
83
 
73
84
  Security comes from HTTPS transport and the LLAT itself being the
74
85
  authorization boundary — revoking the LLAT in Home Assistant
@@ -117,8 +128,10 @@ class HomeAssistantOAuthProvider(OAuthProvider):
117
128
  required_scopes=required_scopes,
118
129
  )
119
130
 
120
- # In-memory storage (session-scoped, not persisted)
121
- self.clients: dict[str, OAuthClientInformationFull] = {}
131
+ # DCR client registry persisted to disk so clients survive restarts.
132
+ self.clients: dict[str, OAuthClientInformationFull] = self._load_clients()
133
+
134
+ # Short-lived in-memory state (no persistence needed or wanted)
122
135
  self.auth_codes: dict[str, AuthorizationCode] = {}
123
136
 
124
137
  # Home Assistant credentials storage (keyed by client_id)
@@ -129,13 +142,149 @@ class HomeAssistantOAuthProvider(OAuthProvider):
129
142
  self.pending_authorizations: dict[str, dict[str, Any]] = {}
130
143
 
131
144
  # Server-side secret for HMAC-protecting LLATs in refresh tokens.
132
- # Regenerated each startup — existing refresh tokens become invalid
133
- # on restart, but that's acceptable since access tokens still work
134
- # and clients will re-authenticate via the consent form.
135
- self._hmac_secret = secrets.token_bytes(32)
145
+ # Persisted so existing refresh tokens survive server restarts.
146
+ self._hmac_secret = self._load_hmac_secret()
136
147
 
137
148
  logger.info(f"HomeAssistantOAuthProvider initialized with base_url={base_url}")
138
149
 
150
+ # ------------------------------------------------------------------
151
+ # Persistence helpers
152
+ # ------------------------------------------------------------------
153
+
154
+ @staticmethod
155
+ def _clients_file() -> Path:
156
+ return get_data_dir() / "oauth_clients.json"
157
+
158
+ @staticmethod
159
+ def _hmac_secret_file() -> Path:
160
+ return get_data_dir() / "oauth_hmac_secret"
161
+
162
+ def _load_clients(self) -> dict[str, OAuthClientInformationFull]:
163
+ """Load persisted DCR client registrations from disk.
164
+
165
+ Returns an empty dict if the file is absent, unreadable, or corrupt.
166
+ """
167
+ path = self._clients_file()
168
+ try:
169
+ data: dict[str, Any] = json.loads(path.read_text())
170
+ clients = {
171
+ cid: OAuthClientInformationFull.model_validate(c)
172
+ for cid, c in data.items()
173
+ }
174
+ logger.info("Loaded %d OAuth client(s) from %s", len(clients), path)
175
+ return clients
176
+ except FileNotFoundError:
177
+ logger.debug("No persistent OAuth client registry found at %s", path)
178
+ return {}
179
+ except (json.JSONDecodeError, OSError, ValidationError) as exc:
180
+ logger.warning(
181
+ "Could not load OAuth client registry from %s (%s: %s); "
182
+ "starting with empty registry.",
183
+ path,
184
+ type(exc).__name__,
185
+ exc,
186
+ )
187
+ return {}
188
+
189
+ def _save_clients(self) -> None:
190
+ """Persist DCR client registrations to disk.
191
+
192
+ Failures are logged as warnings; the server continues with in-memory
193
+ state so existing sessions are not disrupted.
194
+ """
195
+ if not self.clients:
196
+ return
197
+ path = self._clients_file()
198
+ # Serialize before touching the filesystem. A model that won't
199
+ # serialize is a programming error and should surface loudly, not be
200
+ # swallowed as if it were a transient disk fault in the write below.
201
+ data = {
202
+ cid: client.model_dump(mode="json") for cid, client in self.clients.items()
203
+ }
204
+ payload = json.dumps(data, indent=2).encode()
205
+ tmp_path = path.parent / f".{path.name}.tmp"
206
+ try:
207
+ # Write to a temp file first, then atomically rename. Use os.open
208
+ # with mode 0o600 (owner read/write only) because a confidential
209
+ # client's client_secret can be stored in this file.
210
+ fd = os.open(str(tmp_path), os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
211
+ try:
212
+ os.write(fd, payload)
213
+ finally:
214
+ os.close(fd)
215
+ os.replace(str(tmp_path), str(path))
216
+ logger.debug("Persisted %d OAuth client(s) to %s", len(data), path)
217
+ except OSError as exc:
218
+ with contextlib.suppress(OSError):
219
+ tmp_path.unlink()
220
+ logger.warning(
221
+ "Failed to persist OAuth client registry to %s (%s: %s). "
222
+ "Clients will need to re-register after the next restart. "
223
+ "Mount a persistent volume to fix this: "
224
+ "docker run -v ha_mcp_data:/home/mcpuser/.ha-mcp ...",
225
+ path,
226
+ type(exc).__name__,
227
+ exc,
228
+ )
229
+
230
+ def _load_hmac_secret(self) -> bytes:
231
+ """Load or generate the HMAC signing secret.
232
+
233
+ Persists a newly generated secret so refresh tokens survive restarts.
234
+ Falls back to a fresh (non-persisted) secret if the file is unreadable.
235
+ """
236
+ path = self._hmac_secret_file()
237
+ try:
238
+ hex_secret = path.read_text().strip()
239
+ if not hex_secret:
240
+ raise ValueError("HMAC secret file is empty")
241
+ secret = bytes.fromhex(hex_secret)
242
+ if len(secret) != 32:
243
+ raise ValueError(
244
+ f"Invalid HMAC secret length: {len(secret)} bytes (expected 32)"
245
+ )
246
+ logger.debug("Loaded HMAC secret from %s", path)
247
+ return secret
248
+ except FileNotFoundError:
249
+ pass # first start — generate and persist below
250
+ except (OSError, ValueError) as exc:
251
+ logger.warning(
252
+ "Could not load HMAC secret from %s (%s: %s); "
253
+ "generating a new one — existing refresh tokens will be invalidated.",
254
+ path,
255
+ type(exc).__name__,
256
+ exc,
257
+ )
258
+
259
+ secret = secrets.token_bytes(32)
260
+ tmp_path = path.parent / f".{path.name}.tmp"
261
+ try:
262
+ # Write to a temp file first, then atomically rename — prevents a
263
+ # partial write from corrupting the secret on an interrupted flush.
264
+ # os.open with mode 0o600 keeps the file owner read/write only.
265
+ fd = os.open(str(tmp_path), os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
266
+ try:
267
+ os.write(fd, secret.hex().encode())
268
+ finally:
269
+ os.close(fd)
270
+ os.replace(str(tmp_path), str(path))
271
+ logger.debug("Persisted new HMAC secret to %s", path)
272
+ except OSError as exc:
273
+ with contextlib.suppress(OSError):
274
+ tmp_path.unlink()
275
+ logger.warning(
276
+ "Failed to persist HMAC secret to %s (%s: %s). "
277
+ "Refresh tokens will be invalidated on the next restart.",
278
+ path,
279
+ type(exc).__name__,
280
+ exc,
281
+ )
282
+ return secret
283
+
284
+ # ------------------------------------------------------------------
285
+ # OAuthProvider interface
286
+ # ------------------------------------------------------------------
287
+
139
288
  def _sign_payload(self, payload_json: bytes) -> str:
140
289
  """Compute HMAC-SHA256 signature of a token payload."""
141
290
  return hmac.new(self._hmac_secret, payload_json, hashlib.sha256).hexdigest()
@@ -159,8 +308,9 @@ class HomeAssistantOAuthProvider(OAuthProvider):
159
308
  timestamp, and (for refresh tokens) client/scope metadata and
160
309
  expiry.
161
310
 
162
- Tokens are invalidated on server restart (new HMAC secret).
163
- Clients re-authenticate via the consent form.
311
+ The signing secret is persisted (see ``_load_hmac_secret``), so signed
312
+ tokens remain valid across server restarts. Clients re-authenticate via
313
+ the consent form only when a token expires or the secret is rotated.
164
314
  """
165
315
  payload: dict[str, Any] = {
166
316
  "ha_token": ha_token,
@@ -373,6 +523,7 @@ class HomeAssistantOAuthProvider(OAuthProvider):
373
523
 
374
524
  self.clients[client_info.client_id] = client_info
375
525
  logger.info(f"Registered OAuth client: {client_info.client_id}")
526
+ self._save_clients()
376
527
 
377
528
  async def authorize(
378
529
  self, client: OAuthClientInformationFull, params: AuthorizationParams
@@ -63,8 +63,9 @@ def _version_tuple(version: str) -> tuple[int, ...]:
63
63
  segment to ``0`` would not actually achieve the "fail closed"
64
64
  intent: a malformed high-order segment like ``"1.x.0"`` would
65
65
  still parse to ``(1, 0, 0)`` and pass a ``>= (0, 5, 1)`` gate.
66
- Caller routes the ValueError through ``_raise_component_too_old``
67
- so the actionable update prompt fires.
66
+ The caller surfaces the ValueError as a distinct "malformed version"
67
+ error (reinstall / file-issue remediation), separate from the
68
+ "too old" update prompt.
68
69
  """
69
70
  return tuple(int(segment) for segment in version.split("."))
70
71
 
@@ -175,7 +176,25 @@ async def _fetch_caller_token(client: Any) -> str:
175
176
  try:
176
177
  parsed = _version_tuple(version)
177
178
  except ValueError:
178
- _raise_component_too_old(f"malformed version: {version!r}")
179
+ # A malformed version (non-numeric segment like "1.x.0", or a
180
+ # future suffixed/date scheme _version_tuple can't parse) is a
181
+ # different failure mode from "too old": a HACS update won't help
182
+ # if the reported version itself is wrong. Point at reinstall /
183
+ # issue-filing instead of the version-bump remediation.
184
+ raise_tool_error(
185
+ create_error_response(
186
+ ErrorCode.COMPONENT_NOT_INSTALLED,
187
+ "The installed ha_mcp_tools custom component reports a "
188
+ f"malformed version: {version!r}, so ha-mcp can't verify it "
189
+ f"meets the required >= {MIN_COMPONENT_VERSION}.",
190
+ suggestions=[
191
+ "Reinstall ha_mcp_tools via HACS",
192
+ "Restart Home Assistant after reinstalling",
193
+ "If the version is still malformed, file an issue at "
194
+ "https://github.com/homeassistant-ai/ha-mcp/issues",
195
+ ],
196
+ )
197
+ )
179
198
  if parsed < _version_tuple(MIN_COMPONENT_VERSION):
180
199
  _raise_component_too_old(f"reported version is {version}")
181
200
  _CALLER_TOKEN_CACHE[client] = token
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ha-mcp-dev
3
- Version: 7.6.0.dev612
3
+ Version: 7.6.0.dev614
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