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.
- {ha_mcp_dev-7.1.0.dev285/src/ha_mcp_dev.egg-info → ha_mcp_dev-7.1.0.dev286}/PKG-INFO +1 -1
- {ha_mcp_dev-7.1.0.dev285 → ha_mcp_dev-7.1.0.dev286}/pyproject.toml +1 -1
- {ha_mcp_dev-7.1.0.dev285 → ha_mcp_dev-7.1.0.dev286}/src/ha_mcp/auth/provider.py +162 -39
- {ha_mcp_dev-7.1.0.dev285 → ha_mcp_dev-7.1.0.dev286/src/ha_mcp_dev.egg-info}/PKG-INFO +1 -1
- {ha_mcp_dev-7.1.0.dev285 → ha_mcp_dev-7.1.0.dev286}/LICENSE +0 -0
- {ha_mcp_dev-7.1.0.dev285 → ha_mcp_dev-7.1.0.dev286}/MANIFEST.in +0 -0
- {ha_mcp_dev-7.1.0.dev285 → ha_mcp_dev-7.1.0.dev286}/README.md +0 -0
- {ha_mcp_dev-7.1.0.dev285 → ha_mcp_dev-7.1.0.dev286}/setup.cfg +0 -0
- {ha_mcp_dev-7.1.0.dev285 → ha_mcp_dev-7.1.0.dev286}/src/ha_mcp/__init__.py +0 -0
- {ha_mcp_dev-7.1.0.dev285 → ha_mcp_dev-7.1.0.dev286}/src/ha_mcp/__main__.py +0 -0
- {ha_mcp_dev-7.1.0.dev285 → ha_mcp_dev-7.1.0.dev286}/src/ha_mcp/_pypi_marker +0 -0
- {ha_mcp_dev-7.1.0.dev285 → ha_mcp_dev-7.1.0.dev286}/src/ha_mcp/auth/__init__.py +0 -0
- {ha_mcp_dev-7.1.0.dev285 → ha_mcp_dev-7.1.0.dev286}/src/ha_mcp/auth/consent_form.py +0 -0
- {ha_mcp_dev-7.1.0.dev285 → ha_mcp_dev-7.1.0.dev286}/src/ha_mcp/client/__init__.py +0 -0
- {ha_mcp_dev-7.1.0.dev285 → ha_mcp_dev-7.1.0.dev286}/src/ha_mcp/client/rest_client.py +0 -0
- {ha_mcp_dev-7.1.0.dev285 → ha_mcp_dev-7.1.0.dev286}/src/ha_mcp/client/websocket_client.py +0 -0
- {ha_mcp_dev-7.1.0.dev285 → ha_mcp_dev-7.1.0.dev286}/src/ha_mcp/client/websocket_listener.py +0 -0
- {ha_mcp_dev-7.1.0.dev285 → ha_mcp_dev-7.1.0.dev286}/src/ha_mcp/config.py +0 -0
- {ha_mcp_dev-7.1.0.dev285 → ha_mcp_dev-7.1.0.dev286}/src/ha_mcp/errors.py +0 -0
- {ha_mcp_dev-7.1.0.dev285 → ha_mcp_dev-7.1.0.dev286}/src/ha_mcp/py.typed +0 -0
- {ha_mcp_dev-7.1.0.dev285 → ha_mcp_dev-7.1.0.dev286}/src/ha_mcp/resources/card_types.json +0 -0
- {ha_mcp_dev-7.1.0.dev285 → ha_mcp_dev-7.1.0.dev286}/src/ha_mcp/resources/dashboard_guide.md +0 -0
- {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
- {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
- {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
- {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
- {ha_mcp_dev-7.1.0.dev285 → ha_mcp_dev-7.1.0.dev286}/src/ha_mcp/resources/skills-vendor/AGENTS.md +0 -0
- {ha_mcp_dev-7.1.0.dev285 → ha_mcp_dev-7.1.0.dev286}/src/ha_mcp/resources/skills-vendor/CLAUDE.md +0 -0
- {ha_mcp_dev-7.1.0.dev285 → ha_mcp_dev-7.1.0.dev286}/src/ha_mcp/resources/skills-vendor/CONTRIBUTING.md +0 -0
- {ha_mcp_dev-7.1.0.dev285 → ha_mcp_dev-7.1.0.dev286}/src/ha_mcp/resources/skills-vendor/LICENSE +0 -0
- {ha_mcp_dev-7.1.0.dev285 → ha_mcp_dev-7.1.0.dev286}/src/ha_mcp/resources/skills-vendor/README.md +0 -0
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {ha_mcp_dev-7.1.0.dev285 → ha_mcp_dev-7.1.0.dev286}/src/ha_mcp/server.py +0 -0
- {ha_mcp_dev-7.1.0.dev285 → ha_mcp_dev-7.1.0.dev286}/src/ha_mcp/smoke_test.py +0 -0
- {ha_mcp_dev-7.1.0.dev285 → ha_mcp_dev-7.1.0.dev286}/src/ha_mcp/tools/__init__.py +0 -0
- {ha_mcp_dev-7.1.0.dev285 → ha_mcp_dev-7.1.0.dev286}/src/ha_mcp/tools/backup.py +0 -0
- {ha_mcp_dev-7.1.0.dev285 → ha_mcp_dev-7.1.0.dev286}/src/ha_mcp/tools/best_practice_checker.py +0 -0
- {ha_mcp_dev-7.1.0.dev285 → ha_mcp_dev-7.1.0.dev286}/src/ha_mcp/tools/device_control.py +0 -0
- {ha_mcp_dev-7.1.0.dev285 → ha_mcp_dev-7.1.0.dev286}/src/ha_mcp/tools/enhanced.py +0 -0
- {ha_mcp_dev-7.1.0.dev285 → ha_mcp_dev-7.1.0.dev286}/src/ha_mcp/tools/helpers.py +0 -0
- {ha_mcp_dev-7.1.0.dev285 → ha_mcp_dev-7.1.0.dev286}/src/ha_mcp/tools/registry.py +0 -0
- {ha_mcp_dev-7.1.0.dev285 → ha_mcp_dev-7.1.0.dev286}/src/ha_mcp/tools/smart_search.py +0 -0
- {ha_mcp_dev-7.1.0.dev285 → ha_mcp_dev-7.1.0.dev286}/src/ha_mcp/tools/tools_addons.py +0 -0
- {ha_mcp_dev-7.1.0.dev285 → ha_mcp_dev-7.1.0.dev286}/src/ha_mcp/tools/tools_areas.py +0 -0
- {ha_mcp_dev-7.1.0.dev285 → ha_mcp_dev-7.1.0.dev286}/src/ha_mcp/tools/tools_blueprints.py +0 -0
- {ha_mcp_dev-7.1.0.dev285 → ha_mcp_dev-7.1.0.dev286}/src/ha_mcp/tools/tools_bug_report.py +0 -0
- {ha_mcp_dev-7.1.0.dev285 → ha_mcp_dev-7.1.0.dev286}/src/ha_mcp/tools/tools_calendar.py +0 -0
- {ha_mcp_dev-7.1.0.dev285 → ha_mcp_dev-7.1.0.dev286}/src/ha_mcp/tools/tools_camera.py +0 -0
- {ha_mcp_dev-7.1.0.dev285 → ha_mcp_dev-7.1.0.dev286}/src/ha_mcp/tools/tools_config_automations.py +0 -0
- {ha_mcp_dev-7.1.0.dev285 → ha_mcp_dev-7.1.0.dev286}/src/ha_mcp/tools/tools_config_dashboards.py +0 -0
- {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
- {ha_mcp_dev-7.1.0.dev285 → ha_mcp_dev-7.1.0.dev286}/src/ha_mcp/tools/tools_config_helpers.py +0 -0
- {ha_mcp_dev-7.1.0.dev285 → ha_mcp_dev-7.1.0.dev286}/src/ha_mcp/tools/tools_config_info.py +0 -0
- {ha_mcp_dev-7.1.0.dev285 → ha_mcp_dev-7.1.0.dev286}/src/ha_mcp/tools/tools_config_scripts.py +0 -0
- {ha_mcp_dev-7.1.0.dev285 → ha_mcp_dev-7.1.0.dev286}/src/ha_mcp/tools/tools_entities.py +0 -0
- {ha_mcp_dev-7.1.0.dev285 → ha_mcp_dev-7.1.0.dev286}/src/ha_mcp/tools/tools_filesystem.py +0 -0
- {ha_mcp_dev-7.1.0.dev285 → ha_mcp_dev-7.1.0.dev286}/src/ha_mcp/tools/tools_groups.py +0 -0
- {ha_mcp_dev-7.1.0.dev285 → ha_mcp_dev-7.1.0.dev286}/src/ha_mcp/tools/tools_hacs.py +0 -0
- {ha_mcp_dev-7.1.0.dev285 → ha_mcp_dev-7.1.0.dev286}/src/ha_mcp/tools/tools_history.py +0 -0
- {ha_mcp_dev-7.1.0.dev285 → ha_mcp_dev-7.1.0.dev286}/src/ha_mcp/tools/tools_integrations.py +0 -0
- {ha_mcp_dev-7.1.0.dev285 → ha_mcp_dev-7.1.0.dev286}/src/ha_mcp/tools/tools_labels.py +0 -0
- {ha_mcp_dev-7.1.0.dev285 → ha_mcp_dev-7.1.0.dev286}/src/ha_mcp/tools/tools_mcp_component.py +0 -0
- {ha_mcp_dev-7.1.0.dev285 → ha_mcp_dev-7.1.0.dev286}/src/ha_mcp/tools/tools_registry.py +0 -0
- {ha_mcp_dev-7.1.0.dev285 → ha_mcp_dev-7.1.0.dev286}/src/ha_mcp/tools/tools_resources.py +0 -0
- {ha_mcp_dev-7.1.0.dev285 → ha_mcp_dev-7.1.0.dev286}/src/ha_mcp/tools/tools_search.py +0 -0
- {ha_mcp_dev-7.1.0.dev285 → ha_mcp_dev-7.1.0.dev286}/src/ha_mcp/tools/tools_service.py +0 -0
- {ha_mcp_dev-7.1.0.dev285 → ha_mcp_dev-7.1.0.dev286}/src/ha_mcp/tools/tools_services.py +0 -0
- {ha_mcp_dev-7.1.0.dev285 → ha_mcp_dev-7.1.0.dev286}/src/ha_mcp/tools/tools_system.py +0 -0
- {ha_mcp_dev-7.1.0.dev285 → ha_mcp_dev-7.1.0.dev286}/src/ha_mcp/tools/tools_todo.py +0 -0
- {ha_mcp_dev-7.1.0.dev285 → ha_mcp_dev-7.1.0.dev286}/src/ha_mcp/tools/tools_traces.py +0 -0
- {ha_mcp_dev-7.1.0.dev285 → ha_mcp_dev-7.1.0.dev286}/src/ha_mcp/tools/tools_updates.py +0 -0
- {ha_mcp_dev-7.1.0.dev285 → ha_mcp_dev-7.1.0.dev286}/src/ha_mcp/tools/tools_utility.py +0 -0
- {ha_mcp_dev-7.1.0.dev285 → ha_mcp_dev-7.1.0.dev286}/src/ha_mcp/tools/tools_voice_assistant.py +0 -0
- {ha_mcp_dev-7.1.0.dev285 → ha_mcp_dev-7.1.0.dev286}/src/ha_mcp/tools/tools_zones.py +0 -0
- {ha_mcp_dev-7.1.0.dev285 → ha_mcp_dev-7.1.0.dev286}/src/ha_mcp/tools/util_helpers.py +0 -0
- {ha_mcp_dev-7.1.0.dev285 → ha_mcp_dev-7.1.0.dev286}/src/ha_mcp/utils/__init__.py +0 -0
- {ha_mcp_dev-7.1.0.dev285 → ha_mcp_dev-7.1.0.dev286}/src/ha_mcp/utils/domain_handlers.py +0 -0
- {ha_mcp_dev-7.1.0.dev285 → ha_mcp_dev-7.1.0.dev286}/src/ha_mcp/utils/fuzzy_search.py +0 -0
- {ha_mcp_dev-7.1.0.dev285 → ha_mcp_dev-7.1.0.dev286}/src/ha_mcp/utils/operation_manager.py +0 -0
- {ha_mcp_dev-7.1.0.dev285 → ha_mcp_dev-7.1.0.dev286}/src/ha_mcp/utils/python_sandbox.py +0 -0
- {ha_mcp_dev-7.1.0.dev285 → ha_mcp_dev-7.1.0.dev286}/src/ha_mcp/utils/usage_logger.py +0 -0
- {ha_mcp_dev-7.1.0.dev285 → ha_mcp_dev-7.1.0.dev286}/src/ha_mcp_dev.egg-info/SOURCES.txt +0 -0
- {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
- {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
- {ha_mcp_dev-7.1.0.dev285 → ha_mcp_dev-7.1.0.dev286}/src/ha_mcp_dev.egg-info/requires.txt +0 -0
- {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
- {ha_mcp_dev-7.1.0.dev285 → ha_mcp_dev-7.1.0.dev286}/tests/__init__.py +0 -0
- {ha_mcp_dev-7.1.0.dev285 → ha_mcp_dev-7.1.0.dev286}/tests/test_constants.py +0 -0
- {ha_mcp_dev-7.1.0.dev285 → ha_mcp_dev-7.1.0.dev286}/tests/test_env_manager.py +0 -0
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "ha-mcp-dev"
|
|
7
|
-
version = "7.1.0.
|
|
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
|
-
#
|
|
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
|
|
563
|
-
self._refresh_to_access_map[refresh_token_value] =
|
|
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
|
-
#
|
|
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
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
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
|
|
627
|
-
new_access_token_value =
|
|
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
|
-
|
|
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
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
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
|
-
|
|
705
|
-
|
|
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
|
"""
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{ha_mcp_dev-7.1.0.dev285 → ha_mcp_dev-7.1.0.dev286}/src/ha_mcp/resources/skills-vendor/AGENTS.md
RENAMED
|
File without changes
|
{ha_mcp_dev-7.1.0.dev285 → ha_mcp_dev-7.1.0.dev286}/src/ha_mcp/resources/skills-vendor/CLAUDE.md
RENAMED
|
File without changes
|
|
File without changes
|
{ha_mcp_dev-7.1.0.dev285 → ha_mcp_dev-7.1.0.dev286}/src/ha_mcp/resources/skills-vendor/LICENSE
RENAMED
|
File without changes
|
{ha_mcp_dev-7.1.0.dev285 → ha_mcp_dev-7.1.0.dev286}/src/ha_mcp/resources/skills-vendor/README.md
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{ha_mcp_dev-7.1.0.dev285 → ha_mcp_dev-7.1.0.dev286}/src/ha_mcp/tools/best_practice_checker.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{ha_mcp_dev-7.1.0.dev285 → ha_mcp_dev-7.1.0.dev286}/src/ha_mcp/tools/tools_config_automations.py
RENAMED
|
File without changes
|
{ha_mcp_dev-7.1.0.dev285 → ha_mcp_dev-7.1.0.dev286}/src/ha_mcp/tools/tools_config_dashboards.py
RENAMED
|
File without changes
|
{ha_mcp_dev-7.1.0.dev285 → ha_mcp_dev-7.1.0.dev286}/src/ha_mcp/tools/tools_config_entry_flow.py
RENAMED
|
File without changes
|
{ha_mcp_dev-7.1.0.dev285 → ha_mcp_dev-7.1.0.dev286}/src/ha_mcp/tools/tools_config_helpers.py
RENAMED
|
File without changes
|
|
File without changes
|
{ha_mcp_dev-7.1.0.dev285 → ha_mcp_dev-7.1.0.dev286}/src/ha_mcp/tools/tools_config_scripts.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{ha_mcp_dev-7.1.0.dev285 → ha_mcp_dev-7.1.0.dev286}/src/ha_mcp/tools/tools_voice_assistant.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{ha_mcp_dev-7.1.0.dev285 → ha_mcp_dev-7.1.0.dev286}/src/ha_mcp_dev.egg-info/dependency_links.txt
RENAMED
|
File without changes
|
{ha_mcp_dev-7.1.0.dev285 → ha_mcp_dev-7.1.0.dev286}/src/ha_mcp_dev.egg-info/entry_points.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|