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.
- {ha_mcp_dev-6.7.2.dev257/src/ha_mcp_dev.egg-info → ha_mcp_dev-6.7.2.dev259}/PKG-INFO +1 -1
- {ha_mcp_dev-6.7.2.dev257 → ha_mcp_dev-6.7.2.dev259}/pyproject.toml +1 -1
- {ha_mcp_dev-6.7.2.dev257 → ha_mcp_dev-6.7.2.dev259}/src/ha_mcp/__main__.py +49 -23
- {ha_mcp_dev-6.7.2.dev257 → ha_mcp_dev-6.7.2.dev259}/src/ha_mcp/auth/consent_form.py +61 -102
- {ha_mcp_dev-6.7.2.dev257 → ha_mcp_dev-6.7.2.dev259}/src/ha_mcp/auth/provider.py +42 -122
- {ha_mcp_dev-6.7.2.dev257 → ha_mcp_dev-6.7.2.dev259/src/ha_mcp_dev.egg-info}/PKG-INFO +1 -1
- {ha_mcp_dev-6.7.2.dev257 → ha_mcp_dev-6.7.2.dev259}/LICENSE +0 -0
- {ha_mcp_dev-6.7.2.dev257 → ha_mcp_dev-6.7.2.dev259}/MANIFEST.in +0 -0
- {ha_mcp_dev-6.7.2.dev257 → ha_mcp_dev-6.7.2.dev259}/README.md +0 -0
- {ha_mcp_dev-6.7.2.dev257 → ha_mcp_dev-6.7.2.dev259}/setup.cfg +0 -0
- {ha_mcp_dev-6.7.2.dev257 → ha_mcp_dev-6.7.2.dev259}/src/ha_mcp/__init__.py +0 -0
- {ha_mcp_dev-6.7.2.dev257 → ha_mcp_dev-6.7.2.dev259}/src/ha_mcp/_pypi_marker +0 -0
- {ha_mcp_dev-6.7.2.dev257 → ha_mcp_dev-6.7.2.dev259}/src/ha_mcp/auth/__init__.py +0 -0
- {ha_mcp_dev-6.7.2.dev257 → ha_mcp_dev-6.7.2.dev259}/src/ha_mcp/client/__init__.py +0 -0
- {ha_mcp_dev-6.7.2.dev257 → ha_mcp_dev-6.7.2.dev259}/src/ha_mcp/client/rest_client.py +0 -0
- {ha_mcp_dev-6.7.2.dev257 → ha_mcp_dev-6.7.2.dev259}/src/ha_mcp/client/websocket_client.py +0 -0
- {ha_mcp_dev-6.7.2.dev257 → ha_mcp_dev-6.7.2.dev259}/src/ha_mcp/client/websocket_listener.py +0 -0
- {ha_mcp_dev-6.7.2.dev257 → ha_mcp_dev-6.7.2.dev259}/src/ha_mcp/config.py +0 -0
- {ha_mcp_dev-6.7.2.dev257 → ha_mcp_dev-6.7.2.dev259}/src/ha_mcp/errors.py +0 -0
- {ha_mcp_dev-6.7.2.dev257 → ha_mcp_dev-6.7.2.dev259}/src/ha_mcp/py.typed +0 -0
- {ha_mcp_dev-6.7.2.dev257 → ha_mcp_dev-6.7.2.dev259}/src/ha_mcp/resources/card_types.json +0 -0
- {ha_mcp_dev-6.7.2.dev257 → ha_mcp_dev-6.7.2.dev259}/src/ha_mcp/resources/dashboard_guide.md +0 -0
- {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
- {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
- {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
- {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
- {ha_mcp_dev-6.7.2.dev257 → ha_mcp_dev-6.7.2.dev259}/src/ha_mcp/resources/skills-vendor/AGENTS.md +0 -0
- {ha_mcp_dev-6.7.2.dev257 → ha_mcp_dev-6.7.2.dev259}/src/ha_mcp/resources/skills-vendor/CLAUDE.md +0 -0
- {ha_mcp_dev-6.7.2.dev257 → ha_mcp_dev-6.7.2.dev259}/src/ha_mcp/resources/skills-vendor/CONTRIBUTING.md +0 -0
- {ha_mcp_dev-6.7.2.dev257 → ha_mcp_dev-6.7.2.dev259}/src/ha_mcp/resources/skills-vendor/LICENSE +0 -0
- {ha_mcp_dev-6.7.2.dev257 → ha_mcp_dev-6.7.2.dev259}/src/ha_mcp/resources/skills-vendor/README.md +0 -0
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {ha_mcp_dev-6.7.2.dev257 → ha_mcp_dev-6.7.2.dev259}/src/ha_mcp/server.py +0 -0
- {ha_mcp_dev-6.7.2.dev257 → ha_mcp_dev-6.7.2.dev259}/src/ha_mcp/smoke_test.py +0 -0
- {ha_mcp_dev-6.7.2.dev257 → ha_mcp_dev-6.7.2.dev259}/src/ha_mcp/tools/__init__.py +0 -0
- {ha_mcp_dev-6.7.2.dev257 → ha_mcp_dev-6.7.2.dev259}/src/ha_mcp/tools/backup.py +0 -0
- {ha_mcp_dev-6.7.2.dev257 → ha_mcp_dev-6.7.2.dev259}/src/ha_mcp/tools/device_control.py +0 -0
- {ha_mcp_dev-6.7.2.dev257 → ha_mcp_dev-6.7.2.dev259}/src/ha_mcp/tools/enhanced.py +0 -0
- {ha_mcp_dev-6.7.2.dev257 → ha_mcp_dev-6.7.2.dev259}/src/ha_mcp/tools/helpers.py +0 -0
- {ha_mcp_dev-6.7.2.dev257 → ha_mcp_dev-6.7.2.dev259}/src/ha_mcp/tools/registry.py +0 -0
- {ha_mcp_dev-6.7.2.dev257 → ha_mcp_dev-6.7.2.dev259}/src/ha_mcp/tools/smart_search.py +0 -0
- {ha_mcp_dev-6.7.2.dev257 → ha_mcp_dev-6.7.2.dev259}/src/ha_mcp/tools/tools_addons.py +0 -0
- {ha_mcp_dev-6.7.2.dev257 → ha_mcp_dev-6.7.2.dev259}/src/ha_mcp/tools/tools_areas.py +0 -0
- {ha_mcp_dev-6.7.2.dev257 → ha_mcp_dev-6.7.2.dev259}/src/ha_mcp/tools/tools_blueprints.py +0 -0
- {ha_mcp_dev-6.7.2.dev257 → ha_mcp_dev-6.7.2.dev259}/src/ha_mcp/tools/tools_bug_report.py +0 -0
- {ha_mcp_dev-6.7.2.dev257 → ha_mcp_dev-6.7.2.dev259}/src/ha_mcp/tools/tools_calendar.py +0 -0
- {ha_mcp_dev-6.7.2.dev257 → ha_mcp_dev-6.7.2.dev259}/src/ha_mcp/tools/tools_camera.py +0 -0
- {ha_mcp_dev-6.7.2.dev257 → ha_mcp_dev-6.7.2.dev259}/src/ha_mcp/tools/tools_config_automations.py +0 -0
- {ha_mcp_dev-6.7.2.dev257 → ha_mcp_dev-6.7.2.dev259}/src/ha_mcp/tools/tools_config_dashboards.py +0 -0
- {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
- {ha_mcp_dev-6.7.2.dev257 → ha_mcp_dev-6.7.2.dev259}/src/ha_mcp/tools/tools_config_helpers.py +0 -0
- {ha_mcp_dev-6.7.2.dev257 → ha_mcp_dev-6.7.2.dev259}/src/ha_mcp/tools/tools_config_info.py +0 -0
- {ha_mcp_dev-6.7.2.dev257 → ha_mcp_dev-6.7.2.dev259}/src/ha_mcp/tools/tools_config_scripts.py +0 -0
- {ha_mcp_dev-6.7.2.dev257 → ha_mcp_dev-6.7.2.dev259}/src/ha_mcp/tools/tools_entities.py +0 -0
- {ha_mcp_dev-6.7.2.dev257 → ha_mcp_dev-6.7.2.dev259}/src/ha_mcp/tools/tools_filesystem.py +0 -0
- {ha_mcp_dev-6.7.2.dev257 → ha_mcp_dev-6.7.2.dev259}/src/ha_mcp/tools/tools_groups.py +0 -0
- {ha_mcp_dev-6.7.2.dev257 → ha_mcp_dev-6.7.2.dev259}/src/ha_mcp/tools/tools_hacs.py +0 -0
- {ha_mcp_dev-6.7.2.dev257 → ha_mcp_dev-6.7.2.dev259}/src/ha_mcp/tools/tools_history.py +0 -0
- {ha_mcp_dev-6.7.2.dev257 → ha_mcp_dev-6.7.2.dev259}/src/ha_mcp/tools/tools_integrations.py +0 -0
- {ha_mcp_dev-6.7.2.dev257 → ha_mcp_dev-6.7.2.dev259}/src/ha_mcp/tools/tools_labels.py +0 -0
- {ha_mcp_dev-6.7.2.dev257 → ha_mcp_dev-6.7.2.dev259}/src/ha_mcp/tools/tools_mcp_component.py +0 -0
- {ha_mcp_dev-6.7.2.dev257 → ha_mcp_dev-6.7.2.dev259}/src/ha_mcp/tools/tools_registry.py +0 -0
- {ha_mcp_dev-6.7.2.dev257 → ha_mcp_dev-6.7.2.dev259}/src/ha_mcp/tools/tools_resources.py +0 -0
- {ha_mcp_dev-6.7.2.dev257 → ha_mcp_dev-6.7.2.dev259}/src/ha_mcp/tools/tools_search.py +0 -0
- {ha_mcp_dev-6.7.2.dev257 → ha_mcp_dev-6.7.2.dev259}/src/ha_mcp/tools/tools_service.py +0 -0
- {ha_mcp_dev-6.7.2.dev257 → ha_mcp_dev-6.7.2.dev259}/src/ha_mcp/tools/tools_services.py +0 -0
- {ha_mcp_dev-6.7.2.dev257 → ha_mcp_dev-6.7.2.dev259}/src/ha_mcp/tools/tools_system.py +0 -0
- {ha_mcp_dev-6.7.2.dev257 → ha_mcp_dev-6.7.2.dev259}/src/ha_mcp/tools/tools_todo.py +0 -0
- {ha_mcp_dev-6.7.2.dev257 → ha_mcp_dev-6.7.2.dev259}/src/ha_mcp/tools/tools_traces.py +0 -0
- {ha_mcp_dev-6.7.2.dev257 → ha_mcp_dev-6.7.2.dev259}/src/ha_mcp/tools/tools_updates.py +0 -0
- {ha_mcp_dev-6.7.2.dev257 → ha_mcp_dev-6.7.2.dev259}/src/ha_mcp/tools/tools_utility.py +0 -0
- {ha_mcp_dev-6.7.2.dev257 → ha_mcp_dev-6.7.2.dev259}/src/ha_mcp/tools/tools_voice_assistant.py +0 -0
- {ha_mcp_dev-6.7.2.dev257 → ha_mcp_dev-6.7.2.dev259}/src/ha_mcp/tools/tools_zones.py +0 -0
- {ha_mcp_dev-6.7.2.dev257 → ha_mcp_dev-6.7.2.dev259}/src/ha_mcp/tools/util_helpers.py +0 -0
- {ha_mcp_dev-6.7.2.dev257 → ha_mcp_dev-6.7.2.dev259}/src/ha_mcp/utils/__init__.py +0 -0
- {ha_mcp_dev-6.7.2.dev257 → ha_mcp_dev-6.7.2.dev259}/src/ha_mcp/utils/domain_handlers.py +0 -0
- {ha_mcp_dev-6.7.2.dev257 → ha_mcp_dev-6.7.2.dev259}/src/ha_mcp/utils/fuzzy_search.py +0 -0
- {ha_mcp_dev-6.7.2.dev257 → ha_mcp_dev-6.7.2.dev259}/src/ha_mcp/utils/operation_manager.py +0 -0
- {ha_mcp_dev-6.7.2.dev257 → ha_mcp_dev-6.7.2.dev259}/src/ha_mcp/utils/python_sandbox.py +0 -0
- {ha_mcp_dev-6.7.2.dev257 → ha_mcp_dev-6.7.2.dev259}/src/ha_mcp/utils/usage_logger.py +0 -0
- {ha_mcp_dev-6.7.2.dev257 → ha_mcp_dev-6.7.2.dev259}/src/ha_mcp_dev.egg-info/SOURCES.txt +0 -0
- {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
- {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
- {ha_mcp_dev-6.7.2.dev257 → ha_mcp_dev-6.7.2.dev259}/src/ha_mcp_dev.egg-info/requires.txt +0 -0
- {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
- {ha_mcp_dev-6.7.2.dev257 → ha_mcp_dev-6.7.2.dev259}/tests/__init__.py +0 -0
- {ha_mcp_dev-6.7.2.dev257 → ha_mcp_dev-6.7.2.dev259}/tests/test_constants.py +0 -0
- {ha_mcp_dev-6.7.2.dev257 → ha_mcp_dev-6.7.2.dev259}/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 = "6.7.2.
|
|
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,
|
|
38
|
-
self.
|
|
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
|
|
57
|
+
# Extract HA token from claims (URL is server-side config)
|
|
56
58
|
claims = token.claims
|
|
57
59
|
|
|
58
|
-
if not claims or "
|
|
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
|
|
66
|
-
client_key = hashlib.sha256(
|
|
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=
|
|
72
|
+
base_url=self._ha_url,
|
|
72
73
|
token=ha_token,
|
|
73
74
|
)
|
|
74
|
-
logger.info(f"Created OAuth client for {
|
|
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
|
|
614
|
-
Users authenticate via a consent form where they
|
|
615
|
-
|
|
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:
|
|
624
|
-
|
|
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
|
-
|
|
639
|
-
|
|
640
|
-
|
|
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
|
-
|
|
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,
|
|
665
|
-
#
|
|
666
|
-
proxy_client = OAuthProxyClient(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
32
|
-
|
|
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> {
|
|
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
|
-
--
|
|
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
|
-
--
|
|
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">
|
|
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
|
-
<
|
|
310
|
-
<
|
|
311
|
-
<
|
|
312
|
-
|
|
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 → Profile → Security → Long-Lived Access Tokens.
|
|
300
|
+
</div>
|
|
313
301
|
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
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 = '
|
|
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">{
|
|
497
|
-
<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
|
|
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,
|
|
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
|
|
77
|
-
|
|
78
|
-
|
|
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
|
|
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,
|
|
140
|
+
def _encode_credentials(self, ha_token: str) -> str:
|
|
144
141
|
"""
|
|
145
|
-
Encode HA
|
|
142
|
+
Encode HA token into a stateless access token.
|
|
146
143
|
|
|
147
|
-
Tokens are base64-encoded JSON containing HA
|
|
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) ->
|
|
156
|
+
def _decode_credentials(self, token: str) -> str | None:
|
|
161
157
|
"""
|
|
162
|
-
Decode access token to extract HA
|
|
158
|
+
Decode access token to extract HA token.
|
|
163
159
|
|
|
164
|
-
Returns
|
|
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
|
|
179
|
-
return
|
|
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
|
-
|
|
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
|
-
|
|
411
|
-
redirect_uri=pending["redirect_uri"],
|
|
415
|
+
redirect_uri=redirect_uri,
|
|
412
416
|
state=pending.get("state", ""),
|
|
413
|
-
|
|
417
|
+
txn_id=txn_id,
|
|
414
418
|
error_message=error_message,
|
|
415
419
|
)
|
|
416
420
|
|
|
417
|
-
|
|
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("
|
|
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"
|
|
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
|
|
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
|
|
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
|
|
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"
|
|
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
|
|
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
|
|
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
|
|
746
|
-
|
|
747
|
-
if not
|
|
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
|
|
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:
|
|
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-6.7.2.dev257 → ha_mcp_dev-6.7.2.dev259}/src/ha_mcp/resources/skills-vendor/AGENTS.md
RENAMED
|
File without changes
|
{ha_mcp_dev-6.7.2.dev257 → ha_mcp_dev-6.7.2.dev259}/src/ha_mcp/resources/skills-vendor/CLAUDE.md
RENAMED
|
File without changes
|
|
File without changes
|
{ha_mcp_dev-6.7.2.dev257 → ha_mcp_dev-6.7.2.dev259}/src/ha_mcp/resources/skills-vendor/LICENSE
RENAMED
|
File without changes
|
{ha_mcp_dev-6.7.2.dev257 → ha_mcp_dev-6.7.2.dev259}/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
|
|
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-6.7.2.dev257 → ha_mcp_dev-6.7.2.dev259}/src/ha_mcp/tools/tools_config_automations.py
RENAMED
|
File without changes
|
{ha_mcp_dev-6.7.2.dev257 → ha_mcp_dev-6.7.2.dev259}/src/ha_mcp/tools/tools_config_dashboards.py
RENAMED
|
File without changes
|
{ha_mcp_dev-6.7.2.dev257 → ha_mcp_dev-6.7.2.dev259}/src/ha_mcp/tools/tools_config_entry_flow.py
RENAMED
|
File without changes
|
{ha_mcp_dev-6.7.2.dev257 → ha_mcp_dev-6.7.2.dev259}/src/ha_mcp/tools/tools_config_helpers.py
RENAMED
|
File without changes
|
|
File without changes
|
{ha_mcp_dev-6.7.2.dev257 → ha_mcp_dev-6.7.2.dev259}/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-6.7.2.dev257 → ha_mcp_dev-6.7.2.dev259}/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-6.7.2.dev257 → ha_mcp_dev-6.7.2.dev259}/src/ha_mcp_dev.egg-info/dependency_links.txt
RENAMED
|
File without changes
|
{ha_mcp_dev-6.7.2.dev257 → ha_mcp_dev-6.7.2.dev259}/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
|