ha-mcp-dev 7.1.0.dev285__tar.gz → 7.1.0.dev286__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 (95) hide show
  1. {ha_mcp_dev-7.1.0.dev285/src/ha_mcp_dev.egg-info → ha_mcp_dev-7.1.0.dev286}/PKG-INFO +1 -1
  2. {ha_mcp_dev-7.1.0.dev285 → ha_mcp_dev-7.1.0.dev286}/pyproject.toml +1 -1
  3. {ha_mcp_dev-7.1.0.dev285 → ha_mcp_dev-7.1.0.dev286}/src/ha_mcp/auth/provider.py +162 -39
  4. {ha_mcp_dev-7.1.0.dev285 → ha_mcp_dev-7.1.0.dev286/src/ha_mcp_dev.egg-info}/PKG-INFO +1 -1
  5. {ha_mcp_dev-7.1.0.dev285 → ha_mcp_dev-7.1.0.dev286}/LICENSE +0 -0
  6. {ha_mcp_dev-7.1.0.dev285 → ha_mcp_dev-7.1.0.dev286}/MANIFEST.in +0 -0
  7. {ha_mcp_dev-7.1.0.dev285 → ha_mcp_dev-7.1.0.dev286}/README.md +0 -0
  8. {ha_mcp_dev-7.1.0.dev285 → ha_mcp_dev-7.1.0.dev286}/setup.cfg +0 -0
  9. {ha_mcp_dev-7.1.0.dev285 → ha_mcp_dev-7.1.0.dev286}/src/ha_mcp/__init__.py +0 -0
  10. {ha_mcp_dev-7.1.0.dev285 → ha_mcp_dev-7.1.0.dev286}/src/ha_mcp/__main__.py +0 -0
  11. {ha_mcp_dev-7.1.0.dev285 → ha_mcp_dev-7.1.0.dev286}/src/ha_mcp/_pypi_marker +0 -0
  12. {ha_mcp_dev-7.1.0.dev285 → ha_mcp_dev-7.1.0.dev286}/src/ha_mcp/auth/__init__.py +0 -0
  13. {ha_mcp_dev-7.1.0.dev285 → ha_mcp_dev-7.1.0.dev286}/src/ha_mcp/auth/consent_form.py +0 -0
  14. {ha_mcp_dev-7.1.0.dev285 → ha_mcp_dev-7.1.0.dev286}/src/ha_mcp/client/__init__.py +0 -0
  15. {ha_mcp_dev-7.1.0.dev285 → ha_mcp_dev-7.1.0.dev286}/src/ha_mcp/client/rest_client.py +0 -0
  16. {ha_mcp_dev-7.1.0.dev285 → ha_mcp_dev-7.1.0.dev286}/src/ha_mcp/client/websocket_client.py +0 -0
  17. {ha_mcp_dev-7.1.0.dev285 → ha_mcp_dev-7.1.0.dev286}/src/ha_mcp/client/websocket_listener.py +0 -0
  18. {ha_mcp_dev-7.1.0.dev285 → ha_mcp_dev-7.1.0.dev286}/src/ha_mcp/config.py +0 -0
  19. {ha_mcp_dev-7.1.0.dev285 → ha_mcp_dev-7.1.0.dev286}/src/ha_mcp/errors.py +0 -0
  20. {ha_mcp_dev-7.1.0.dev285 → ha_mcp_dev-7.1.0.dev286}/src/ha_mcp/py.typed +0 -0
  21. {ha_mcp_dev-7.1.0.dev285 → ha_mcp_dev-7.1.0.dev286}/src/ha_mcp/resources/card_types.json +0 -0
  22. {ha_mcp_dev-7.1.0.dev285 → ha_mcp_dev-7.1.0.dev286}/src/ha_mcp/resources/dashboard_guide.md +0 -0
  23. {ha_mcp_dev-7.1.0.dev285 → ha_mcp_dev-7.1.0.dev286}/src/ha_mcp/resources/skills-vendor/.claude/settings.json +0 -0
  24. {ha_mcp_dev-7.1.0.dev285 → ha_mcp_dev-7.1.0.dev286}/src/ha_mcp/resources/skills-vendor/.claude-plugin/marketplace.json +0 -0
  25. {ha_mcp_dev-7.1.0.dev285 → ha_mcp_dev-7.1.0.dev286}/src/ha_mcp/resources/skills-vendor/.claude-plugin/plugin.json +0 -0
  26. {ha_mcp_dev-7.1.0.dev285 → ha_mcp_dev-7.1.0.dev286}/src/ha_mcp/resources/skills-vendor/.github/ISSUE_TEMPLATE/skill-rca.md +0 -0
  27. {ha_mcp_dev-7.1.0.dev285 → ha_mcp_dev-7.1.0.dev286}/src/ha_mcp/resources/skills-vendor/AGENTS.md +0 -0
  28. {ha_mcp_dev-7.1.0.dev285 → ha_mcp_dev-7.1.0.dev286}/src/ha_mcp/resources/skills-vendor/CLAUDE.md +0 -0
  29. {ha_mcp_dev-7.1.0.dev285 → ha_mcp_dev-7.1.0.dev286}/src/ha_mcp/resources/skills-vendor/CONTRIBUTING.md +0 -0
  30. {ha_mcp_dev-7.1.0.dev285 → ha_mcp_dev-7.1.0.dev286}/src/ha_mcp/resources/skills-vendor/LICENSE +0 -0
  31. {ha_mcp_dev-7.1.0.dev285 → ha_mcp_dev-7.1.0.dev286}/src/ha_mcp/resources/skills-vendor/README.md +0 -0
  32. {ha_mcp_dev-7.1.0.dev285 → ha_mcp_dev-7.1.0.dev286}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/SKILL.md +0 -0
  33. {ha_mcp_dev-7.1.0.dev285 → ha_mcp_dev-7.1.0.dev286}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/automation-patterns.md +0 -0
  34. {ha_mcp_dev-7.1.0.dev285 → ha_mcp_dev-7.1.0.dev286}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/device-control.md +0 -0
  35. {ha_mcp_dev-7.1.0.dev285 → ha_mcp_dev-7.1.0.dev286}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/examples.yaml +0 -0
  36. {ha_mcp_dev-7.1.0.dev285 → ha_mcp_dev-7.1.0.dev286}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/helper-selection.md +0 -0
  37. {ha_mcp_dev-7.1.0.dev285 → ha_mcp_dev-7.1.0.dev286}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/safe-refactoring.md +0 -0
  38. {ha_mcp_dev-7.1.0.dev285 → ha_mcp_dev-7.1.0.dev286}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/template-guidelines.md +0 -0
  39. {ha_mcp_dev-7.1.0.dev285 → ha_mcp_dev-7.1.0.dev286}/src/ha_mcp/server.py +0 -0
  40. {ha_mcp_dev-7.1.0.dev285 → ha_mcp_dev-7.1.0.dev286}/src/ha_mcp/smoke_test.py +0 -0
  41. {ha_mcp_dev-7.1.0.dev285 → ha_mcp_dev-7.1.0.dev286}/src/ha_mcp/tools/__init__.py +0 -0
  42. {ha_mcp_dev-7.1.0.dev285 → ha_mcp_dev-7.1.0.dev286}/src/ha_mcp/tools/backup.py +0 -0
  43. {ha_mcp_dev-7.1.0.dev285 → ha_mcp_dev-7.1.0.dev286}/src/ha_mcp/tools/best_practice_checker.py +0 -0
  44. {ha_mcp_dev-7.1.0.dev285 → ha_mcp_dev-7.1.0.dev286}/src/ha_mcp/tools/device_control.py +0 -0
  45. {ha_mcp_dev-7.1.0.dev285 → ha_mcp_dev-7.1.0.dev286}/src/ha_mcp/tools/enhanced.py +0 -0
  46. {ha_mcp_dev-7.1.0.dev285 → ha_mcp_dev-7.1.0.dev286}/src/ha_mcp/tools/helpers.py +0 -0
  47. {ha_mcp_dev-7.1.0.dev285 → ha_mcp_dev-7.1.0.dev286}/src/ha_mcp/tools/registry.py +0 -0
  48. {ha_mcp_dev-7.1.0.dev285 → ha_mcp_dev-7.1.0.dev286}/src/ha_mcp/tools/smart_search.py +0 -0
  49. {ha_mcp_dev-7.1.0.dev285 → ha_mcp_dev-7.1.0.dev286}/src/ha_mcp/tools/tools_addons.py +0 -0
  50. {ha_mcp_dev-7.1.0.dev285 → ha_mcp_dev-7.1.0.dev286}/src/ha_mcp/tools/tools_areas.py +0 -0
  51. {ha_mcp_dev-7.1.0.dev285 → ha_mcp_dev-7.1.0.dev286}/src/ha_mcp/tools/tools_blueprints.py +0 -0
  52. {ha_mcp_dev-7.1.0.dev285 → ha_mcp_dev-7.1.0.dev286}/src/ha_mcp/tools/tools_bug_report.py +0 -0
  53. {ha_mcp_dev-7.1.0.dev285 → ha_mcp_dev-7.1.0.dev286}/src/ha_mcp/tools/tools_calendar.py +0 -0
  54. {ha_mcp_dev-7.1.0.dev285 → ha_mcp_dev-7.1.0.dev286}/src/ha_mcp/tools/tools_camera.py +0 -0
  55. {ha_mcp_dev-7.1.0.dev285 → ha_mcp_dev-7.1.0.dev286}/src/ha_mcp/tools/tools_config_automations.py +0 -0
  56. {ha_mcp_dev-7.1.0.dev285 → ha_mcp_dev-7.1.0.dev286}/src/ha_mcp/tools/tools_config_dashboards.py +0 -0
  57. {ha_mcp_dev-7.1.0.dev285 → ha_mcp_dev-7.1.0.dev286}/src/ha_mcp/tools/tools_config_entry_flow.py +0 -0
  58. {ha_mcp_dev-7.1.0.dev285 → ha_mcp_dev-7.1.0.dev286}/src/ha_mcp/tools/tools_config_helpers.py +0 -0
  59. {ha_mcp_dev-7.1.0.dev285 → ha_mcp_dev-7.1.0.dev286}/src/ha_mcp/tools/tools_config_info.py +0 -0
  60. {ha_mcp_dev-7.1.0.dev285 → ha_mcp_dev-7.1.0.dev286}/src/ha_mcp/tools/tools_config_scripts.py +0 -0
  61. {ha_mcp_dev-7.1.0.dev285 → ha_mcp_dev-7.1.0.dev286}/src/ha_mcp/tools/tools_entities.py +0 -0
  62. {ha_mcp_dev-7.1.0.dev285 → ha_mcp_dev-7.1.0.dev286}/src/ha_mcp/tools/tools_filesystem.py +0 -0
  63. {ha_mcp_dev-7.1.0.dev285 → ha_mcp_dev-7.1.0.dev286}/src/ha_mcp/tools/tools_groups.py +0 -0
  64. {ha_mcp_dev-7.1.0.dev285 → ha_mcp_dev-7.1.0.dev286}/src/ha_mcp/tools/tools_hacs.py +0 -0
  65. {ha_mcp_dev-7.1.0.dev285 → ha_mcp_dev-7.1.0.dev286}/src/ha_mcp/tools/tools_history.py +0 -0
  66. {ha_mcp_dev-7.1.0.dev285 → ha_mcp_dev-7.1.0.dev286}/src/ha_mcp/tools/tools_integrations.py +0 -0
  67. {ha_mcp_dev-7.1.0.dev285 → ha_mcp_dev-7.1.0.dev286}/src/ha_mcp/tools/tools_labels.py +0 -0
  68. {ha_mcp_dev-7.1.0.dev285 → ha_mcp_dev-7.1.0.dev286}/src/ha_mcp/tools/tools_mcp_component.py +0 -0
  69. {ha_mcp_dev-7.1.0.dev285 → ha_mcp_dev-7.1.0.dev286}/src/ha_mcp/tools/tools_registry.py +0 -0
  70. {ha_mcp_dev-7.1.0.dev285 → ha_mcp_dev-7.1.0.dev286}/src/ha_mcp/tools/tools_resources.py +0 -0
  71. {ha_mcp_dev-7.1.0.dev285 → ha_mcp_dev-7.1.0.dev286}/src/ha_mcp/tools/tools_search.py +0 -0
  72. {ha_mcp_dev-7.1.0.dev285 → ha_mcp_dev-7.1.0.dev286}/src/ha_mcp/tools/tools_service.py +0 -0
  73. {ha_mcp_dev-7.1.0.dev285 → ha_mcp_dev-7.1.0.dev286}/src/ha_mcp/tools/tools_services.py +0 -0
  74. {ha_mcp_dev-7.1.0.dev285 → ha_mcp_dev-7.1.0.dev286}/src/ha_mcp/tools/tools_system.py +0 -0
  75. {ha_mcp_dev-7.1.0.dev285 → ha_mcp_dev-7.1.0.dev286}/src/ha_mcp/tools/tools_todo.py +0 -0
  76. {ha_mcp_dev-7.1.0.dev285 → ha_mcp_dev-7.1.0.dev286}/src/ha_mcp/tools/tools_traces.py +0 -0
  77. {ha_mcp_dev-7.1.0.dev285 → ha_mcp_dev-7.1.0.dev286}/src/ha_mcp/tools/tools_updates.py +0 -0
  78. {ha_mcp_dev-7.1.0.dev285 → ha_mcp_dev-7.1.0.dev286}/src/ha_mcp/tools/tools_utility.py +0 -0
  79. {ha_mcp_dev-7.1.0.dev285 → ha_mcp_dev-7.1.0.dev286}/src/ha_mcp/tools/tools_voice_assistant.py +0 -0
  80. {ha_mcp_dev-7.1.0.dev285 → ha_mcp_dev-7.1.0.dev286}/src/ha_mcp/tools/tools_zones.py +0 -0
  81. {ha_mcp_dev-7.1.0.dev285 → ha_mcp_dev-7.1.0.dev286}/src/ha_mcp/tools/util_helpers.py +0 -0
  82. {ha_mcp_dev-7.1.0.dev285 → ha_mcp_dev-7.1.0.dev286}/src/ha_mcp/utils/__init__.py +0 -0
  83. {ha_mcp_dev-7.1.0.dev285 → ha_mcp_dev-7.1.0.dev286}/src/ha_mcp/utils/domain_handlers.py +0 -0
  84. {ha_mcp_dev-7.1.0.dev285 → ha_mcp_dev-7.1.0.dev286}/src/ha_mcp/utils/fuzzy_search.py +0 -0
  85. {ha_mcp_dev-7.1.0.dev285 → ha_mcp_dev-7.1.0.dev286}/src/ha_mcp/utils/operation_manager.py +0 -0
  86. {ha_mcp_dev-7.1.0.dev285 → ha_mcp_dev-7.1.0.dev286}/src/ha_mcp/utils/python_sandbox.py +0 -0
  87. {ha_mcp_dev-7.1.0.dev285 → ha_mcp_dev-7.1.0.dev286}/src/ha_mcp/utils/usage_logger.py +0 -0
  88. {ha_mcp_dev-7.1.0.dev285 → ha_mcp_dev-7.1.0.dev286}/src/ha_mcp_dev.egg-info/SOURCES.txt +0 -0
  89. {ha_mcp_dev-7.1.0.dev285 → ha_mcp_dev-7.1.0.dev286}/src/ha_mcp_dev.egg-info/dependency_links.txt +0 -0
  90. {ha_mcp_dev-7.1.0.dev285 → ha_mcp_dev-7.1.0.dev286}/src/ha_mcp_dev.egg-info/entry_points.txt +0 -0
  91. {ha_mcp_dev-7.1.0.dev285 → ha_mcp_dev-7.1.0.dev286}/src/ha_mcp_dev.egg-info/requires.txt +0 -0
  92. {ha_mcp_dev-7.1.0.dev285 → ha_mcp_dev-7.1.0.dev286}/src/ha_mcp_dev.egg-info/top_level.txt +0 -0
  93. {ha_mcp_dev-7.1.0.dev285 → ha_mcp_dev-7.1.0.dev286}/tests/__init__.py +0 -0
  94. {ha_mcp_dev-7.1.0.dev285 → ha_mcp_dev-7.1.0.dev286}/tests/test_constants.py +0 -0
  95. {ha_mcp_dev-7.1.0.dev285 → ha_mcp_dev-7.1.0.dev286}/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.1.0.dev285
3
+ Version: 7.1.0.dev286
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.1.0.dev285"
7
+ version = "7.1.0.dev286"
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,11 +7,15 @@ provide their Long-Lived Access Token (LLAT).
7
7
  """
8
8
 
9
9
  import binascii
10
+ import errno
10
11
  import json
11
12
  import logging
13
+ import os
12
14
  import secrets
15
+ import tempfile
13
16
  import time
14
17
  from base64 import urlsafe_b64decode, urlsafe_b64encode
18
+ from pathlib import Path
15
19
  from typing import Any
16
20
  from urllib.parse import urlencode
17
21
 
@@ -87,6 +91,7 @@ class HomeAssistantOAuthProvider(OAuthProvider):
87
91
  client_registration_options: ClientRegistrationOptions | None = None,
88
92
  revocation_options: RevocationOptions | None = None,
89
93
  required_scopes: list[str] | None = None,
94
+ state_dir: str | Path | None = None,
90
95
  ):
91
96
  """
92
97
  Initialize the Home Assistant OAuth provider.
@@ -98,6 +103,11 @@ class HomeAssistantOAuthProvider(OAuthProvider):
98
103
  client_registration_options: Options for client registration
99
104
  revocation_options: Options for token revocation
100
105
  required_scopes: Scopes required for all requests
106
+ state_dir: Directory for persisting OAuth state; writes
107
+ ``oauth_state.json`` inside it. Defaults to ``~/.ha-mcp``.
108
+ Note: the state file contains HA access tokens in
109
+ base64-encoded form. The 0o600 permissions are the security
110
+ boundary. Treat this file like a credential file.
101
111
  """
102
112
  # Enable DCR by default
103
113
  if client_registration_options is None:
@@ -131,10 +141,13 @@ class HomeAssistantOAuthProvider(OAuthProvider):
131
141
  # Pending authorization requests (for consent form flow)
132
142
  self.pending_authorizations: dict[str, dict[str, Any]] = {}
133
143
 
134
- # Token mapping for revocation
135
- self._access_to_refresh_map: dict[str, str] = {}
144
+ # Maps refresh token → stateless access token (for recovering HA credentials on refresh)
136
145
  self._refresh_to_access_map: dict[str, str] = {}
137
146
 
147
+ # Persistent state file
148
+ self._state_file = Path(state_dir or Path.home() / ".ha-mcp") / "oauth_state.json"
149
+ self._load_state()
150
+
138
151
  logger.info(f"HomeAssistantOAuthProvider initialized with base_url={base_url}")
139
152
 
140
153
  def _encode_credentials(self, ha_token: str) -> str:
@@ -177,6 +190,122 @@ class HomeAssistantOAuthProvider(OAuthProvider):
177
190
  logger.debug(f"Failed to decode token: {e}")
178
191
  return None
179
192
 
193
+ def _save_state(self) -> None:
194
+ """Persist OAuth state to disk for container restart survival.
195
+
196
+ Note: ``ha_credentials`` is intentionally not persisted — HA tokens
197
+ are recoverable from the stateless access tokens stored in
198
+ ``_refresh_to_access_map``.
199
+ """
200
+ tmp_fd = None
201
+ tmp_path = None
202
+ try:
203
+ state = {
204
+ "clients": {
205
+ cid: client.model_dump(mode='json')
206
+ for cid, client in self.clients.items()
207
+ },
208
+ "refresh_tokens": {
209
+ tid: {
210
+ "token": rt.token,
211
+ "client_id": rt.client_id,
212
+ "scopes": rt.scopes,
213
+ "expires_at": rt.expires_at,
214
+ }
215
+ for tid, rt in self.refresh_tokens.items()
216
+ },
217
+ "refresh_to_access_map": dict(self._refresh_to_access_map),
218
+ }
219
+
220
+ created = not self._state_file.parent.exists()
221
+ self._state_file.parent.mkdir(parents=True, exist_ok=True)
222
+ if created:
223
+ os.chmod(self._state_file.parent, 0o700)
224
+
225
+ # Use a unique tmp file to avoid conflicts if two processes
226
+ # write concurrently. Source and destination must be on the
227
+ # same filesystem for rename to be atomic.
228
+ tmp_fd, tmp_name = tempfile.mkstemp(
229
+ dir=self._state_file.parent, suffix='.tmp'
230
+ )
231
+ tmp_path = Path(tmp_name)
232
+ tmp_path.write_text(json.dumps(state, indent=2))
233
+ os.close(tmp_fd)
234
+ tmp_fd = None # Mark as closed
235
+ os.chmod(tmp_path, 0o600)
236
+ tmp_path.replace(self._state_file)
237
+ tmp_path = None # Mark as renamed (no cleanup needed)
238
+ logger.debug(f"OAuth state saved to {self._state_file}")
239
+ except (OSError, TypeError, ValueError) as e:
240
+ # Clean up tmp file if it was written but not renamed
241
+ if tmp_fd is not None:
242
+ try:
243
+ os.close(tmp_fd)
244
+ except OSError:
245
+ pass # Best-effort cleanup; fd may already be closed
246
+ if tmp_path is not None:
247
+ try:
248
+ tmp_path.unlink()
249
+ except OSError as cleanup_err:
250
+ logger.debug(f"Failed to clean up tmp file: {cleanup_err}")
251
+ logger.error(
252
+ "Failed to save OAuth state to %s — active sessions will "
253
+ "not survive restart: %s",
254
+ self._state_file, e,
255
+ )
256
+
257
+ def _load_state(self) -> None:
258
+ """Load persisted OAuth state from disk."""
259
+ try:
260
+ text = self._state_file.read_text()
261
+ except OSError as e:
262
+ if e.errno == errno.ENOENT:
263
+ return # Normal first-start, no prior state
264
+ logger.error("Cannot read OAuth state file %s: %s", self._state_file, e)
265
+ return
266
+
267
+ try:
268
+ state = json.loads(text)
269
+ now = time.time()
270
+
271
+ # Build into local dicts first; assign atomically only if
272
+ # everything succeeds — avoids inconsistent in-memory state
273
+ # if validation fails partway through.
274
+ new_clients = {
275
+ cid: OAuthClientInformationFull.model_validate(client_data)
276
+ for cid, client_data in state.get("clients", {}).items()
277
+ }
278
+
279
+ new_tokens = {}
280
+ for tid, rt_data in state.get("refresh_tokens", {}).items():
281
+ expires_at = rt_data.get("expires_at")
282
+ if expires_at is not None and expires_at < now:
283
+ continue # Skip expired refresh tokens
284
+ new_tokens[tid] = RefreshToken(
285
+ token=rt_data["token"],
286
+ client_id=rt_data["client_id"],
287
+ scopes=rt_data["scopes"],
288
+ expires_at=expires_at,
289
+ )
290
+
291
+ # Only load mappings for refresh tokens that were actually loaded
292
+ stored_map = state.get("refresh_to_access_map", {})
293
+ new_map = {
294
+ k: v for k, v in stored_map.items() if k in new_tokens
295
+ }
296
+
297
+ # Commit atomically
298
+ self.clients, self.refresh_tokens, self._refresh_to_access_map = (
299
+ new_clients, new_tokens, new_map
300
+ )
301
+
302
+ logger.info(
303
+ f"OAuth state loaded: {len(self.clients)} clients, "
304
+ f"{len(self.refresh_tokens)} refresh tokens"
305
+ )
306
+ except (json.JSONDecodeError, KeyError, TypeError) as e:
307
+ logger.error(f"Failed to load OAuth state from {self._state_file}: {e}")
308
+
180
309
  def get_routes(self, mcp_path: str | None = None) -> list[Route]:
181
310
  """
182
311
  Get OAuth routes including custom consent form routes.
@@ -320,6 +449,7 @@ class HomeAssistantOAuthProvider(OAuthProvider):
320
449
  raise ValueError("client_id is required for client registration")
321
450
 
322
451
  self.clients[client_info.client_id] = client_info
452
+ self._save_state()
323
453
  logger.info(f"Registered OAuth client: {client_info.client_id}")
324
454
 
325
455
  async def authorize(
@@ -559,13 +689,14 @@ class HomeAssistantOAuthProvider(OAuthProvider):
559
689
  expires_at=refresh_token_expires_at,
560
690
  )
561
691
 
562
- # Map for revocation (refresh token only, access token is stateless)
563
- self._refresh_to_access_map[refresh_token_value] = client.client_id or ""
692
+ # Map refresh token to access token so we can recover HA credentials on refresh
693
+ self._refresh_to_access_map[refresh_token_value] = access_token_value
564
694
 
565
695
  # Clean up temporary credentials storage (no longer needed after token issued)
566
696
  if client.client_id in self.ha_credentials:
567
697
  del self.ha_credentials[client.client_id]
568
698
 
699
+ self._save_state()
569
700
  logger.info(f"Issued stateless access token for client {client.client_id}")
570
701
 
571
702
  return OAuthToken(
@@ -600,7 +731,7 @@ class HomeAssistantOAuthProvider(OAuthProvider):
600
731
  refresh_token: RefreshToken,
601
732
  scopes: list[str],
602
733
  ) -> OAuthToken:
603
- """Exchange refresh token for new access token."""
734
+ """Exchange refresh token for new stateless access token."""
604
735
  # Validate scopes
605
736
  original_scopes = set(refresh_token.scopes)
606
737
  requested_scopes = set(scopes)
@@ -613,32 +744,30 @@ class HomeAssistantOAuthProvider(OAuthProvider):
613
744
  if client.client_id is None:
614
745
  raise TokenError("invalid_client", "Client ID is required")
615
746
 
616
- # Preserve claims from old access token before revoking
747
+ # Recover HA token from the old stateless access token
617
748
  old_access_token_str = self._refresh_to_access_map.get(refresh_token.token)
618
- old_claims = {}
619
- if old_access_token_str and old_access_token_str in self.access_tokens:
620
- old_access_token = self.access_tokens[old_access_token_str]
621
- old_claims = old_access_token.claims or {}
749
+ if not old_access_token_str:
750
+ raise TokenError(
751
+ "invalid_grant",
752
+ "No access token associated with this refresh token.",
753
+ )
754
+
755
+ ha_token = self._decode_credentials(old_access_token_str)
756
+ if not ha_token:
757
+ raise TokenError(
758
+ "invalid_grant",
759
+ "Cannot recover credentials for token refresh.",
760
+ )
622
761
 
623
762
  # Revoke old tokens
624
763
  self._revoke_internal(refresh_token_str=refresh_token.token)
625
764
 
626
- # Issue new tokens
627
- new_access_token_value = f"ha_access_{secrets.token_hex(32)}"
765
+ # Issue new stateless access token with recovered HA credentials
766
+ new_access_token_value = self._encode_credentials(ha_token)
628
767
  new_refresh_token_value = f"ha_refresh_{secrets.token_hex(32)}"
629
768
 
630
- access_token_expires_at = int(time.time() + ACCESS_TOKEN_EXPIRY_SECONDS)
631
769
  refresh_token_expires_at = int(time.time() + REFRESH_TOKEN_EXPIRY_SECONDS)
632
770
 
633
- # Preserve HA credentials in new access token claims
634
- self.access_tokens[new_access_token_value] = AccessToken(
635
- token=new_access_token_value,
636
- client_id=client.client_id,
637
- scopes=scopes,
638
- expires_at=access_token_expires_at,
639
- claims=old_claims, # Preserve HA credentials across token refresh
640
- )
641
-
642
771
  self.refresh_tokens[new_refresh_token_value] = RefreshToken(
643
772
  token=new_refresh_token_value,
644
773
  client_id=client.client_id,
@@ -646,9 +775,11 @@ class HomeAssistantOAuthProvider(OAuthProvider):
646
775
  expires_at=refresh_token_expires_at,
647
776
  )
648
777
 
649
- self._access_to_refresh_map[new_access_token_value] = new_refresh_token_value
778
+ # Map new refresh token to new access token for future refreshes
650
779
  self._refresh_to_access_map[new_refresh_token_value] = new_access_token_value
651
780
 
781
+ self._save_state()
782
+
652
783
  return OAuthToken(
653
784
  access_token=new_access_token_value,
654
785
  token_type="Bearer",
@@ -691,24 +822,15 @@ class HomeAssistantOAuthProvider(OAuthProvider):
691
822
  ) -> None:
692
823
  """Internal helper to remove tokens and their associations."""
693
824
  if access_token_str:
694
- if access_token_str in self.access_tokens:
695
- del self.access_tokens[access_token_str]
696
-
697
- associated_refresh = self._access_to_refresh_map.pop(access_token_str, None)
698
- if associated_refresh:
699
- if associated_refresh in self.refresh_tokens:
700
- del self.refresh_tokens[associated_refresh]
701
- self._refresh_to_access_map.pop(associated_refresh, None)
825
+ # Stateless access tokens are not stored in self.access_tokens,
826
+ # so there is no server-side cascade to revoke. The paired
827
+ # refresh token is only revoked when revoke_token() is called
828
+ # with a RefreshToken argument directly.
829
+ self.access_tokens.pop(access_token_str, None)
702
830
 
703
831
  if refresh_token_str:
704
- if refresh_token_str in self.refresh_tokens:
705
- del self.refresh_tokens[refresh_token_str]
706
-
707
- associated_access = self._refresh_to_access_map.pop(refresh_token_str, None)
708
- if associated_access:
709
- if associated_access in self.access_tokens:
710
- del self.access_tokens[associated_access]
711
- self._access_to_refresh_map.pop(associated_access, None)
832
+ self.refresh_tokens.pop(refresh_token_str, None)
833
+ self._refresh_to_access_map.pop(refresh_token_str, None)
712
834
 
713
835
  async def revoke_token(self, token: AccessToken | RefreshToken) -> None:
714
836
  """Revoke an access or refresh token."""
@@ -716,6 +838,7 @@ class HomeAssistantOAuthProvider(OAuthProvider):
716
838
  self._revoke_internal(access_token_str=token.token)
717
839
  elif isinstance(token, RefreshToken):
718
840
  self._revoke_internal(refresh_token_str=token.token)
841
+ self._save_state()
719
842
 
720
843
  def get_ha_credentials(self, client_id: str) -> HomeAssistantCredentials | None:
721
844
  """
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ha-mcp-dev
3
- Version: 7.1.0.dev285
3
+ Version: 7.1.0.dev286
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