Updated apps
This commit is contained in:
File diff suppressed because it is too large
Load Diff
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -12,8 +12,9 @@ menu on the first step:
|
||||
channel / port / bind host / webhook auth / pip spec / server URL.
|
||||
|
||||
The two entry types are discriminated by ``entry.data[CONF_ENTRY_TYPE]``; the
|
||||
options-flow dispatcher branches on it so only the server entry gets a
|
||||
configurable options flow (the tools entry aborts with ``no_options``).
|
||||
options-flow dispatcher branches on it — the server entry gets the configurable
|
||||
options flow, and the tools entry gets a light informational options flow
|
||||
(nothing to configure yet).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -29,7 +30,7 @@ from homeassistant.config_entries import (
|
||||
OptionsFlow,
|
||||
)
|
||||
from homeassistant.const import __version__ as HA_VERSION
|
||||
from homeassistant.core import callback
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers.selector import (
|
||||
SelectOptionDict,
|
||||
SelectSelector,
|
||||
@@ -45,6 +46,9 @@ from .const import (
|
||||
CHANNEL_DEV,
|
||||
CHANNEL_STABLE,
|
||||
CONF_ENTRY_TYPE,
|
||||
DATA_OAUTH_CLIENT_ID,
|
||||
DATA_OAUTH_CLIENT_SECRET,
|
||||
DATA_OAUTH_SIGNING_KEY,
|
||||
DATA_SECRET_PATH,
|
||||
DATA_WEBHOOK_ID,
|
||||
DEFAULT_AUTO_UPDATE,
|
||||
@@ -74,6 +78,9 @@ from .const import (
|
||||
OPT_ENABLE_WEBHOOK,
|
||||
OPT_EXTERNAL_URL,
|
||||
OPT_LLM_API_EXPOSURE,
|
||||
OPT_OAUTH_CLIENT_ID,
|
||||
OPT_OAUTH_CLIENT_SECRET,
|
||||
OPT_OAUTH_REGENERATE,
|
||||
OPT_PIP_SPEC,
|
||||
OPT_REGENERATE_SECRETS,
|
||||
OPT_SECRET_PATH_OVERRIDE,
|
||||
@@ -81,12 +88,14 @@ from .const import (
|
||||
OPT_SERVER_URL,
|
||||
OPT_WEBHOOK_AUTH,
|
||||
OPT_WEBHOOK_ID_OVERRIDE,
|
||||
TOOLS_ENTRY_TITLE,
|
||||
WEBHOOK_AUTH_HA,
|
||||
WEBHOOK_AUTH_LEGACY,
|
||||
WEBHOOK_AUTH_NONE,
|
||||
)
|
||||
|
||||
# Titles shown for each entry in the integration tile's entry list.
|
||||
_TOOLS_ENTRY_TITLE = "HA MCP Tools"
|
||||
# Title shown for the server entry in the integration tile's entry list; the
|
||||
# tools entry's title lives in const.py (setup migration in __init__ needs it).
|
||||
_SERVER_ENTRY_TITLE = "HA-MCP Server"
|
||||
|
||||
# The single-instance server entry's unique id — distinct from the tools entry's
|
||||
@@ -97,6 +106,29 @@ _SERVER_UNIQUE_ID = f"{DOMAIN}-server"
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _legacy_credentials_active(
|
||||
hass: HomeAssistant, client_id: str, client_secret: str, signing_key: str
|
||||
) -> bool:
|
||||
"""Deferred-import seam for :func:`oauth_legacy.legacy_credentials_active`.
|
||||
|
||||
oauth_legacy pulls in the aiohttp/HTTP view layer at import time; the
|
||||
config-flow module must stay importable without it (Home Assistant imports
|
||||
config flows early, and the flow unit tests stub only the config-entries
|
||||
surface) — same pattern as ``oauth_legacy._live_auth_mode``.
|
||||
"""
|
||||
from .oauth_legacy import legacy_credentials_active
|
||||
|
||||
return legacy_credentials_active(hass, client_id, client_secret, signing_key)
|
||||
|
||||
|
||||
def _legacy_restart_pending(hass: HomeAssistant) -> bool:
|
||||
"""Deferred-import seam for :func:`oauth_legacy.legacy_restart_pending`
|
||||
(see :func:`_legacy_credentials_active` for why the import is deferred)."""
|
||||
from .oauth_legacy import legacy_restart_pending
|
||||
|
||||
return legacy_restart_pending(hass)
|
||||
|
||||
|
||||
def _installed_server_version() -> str | None:
|
||||
"""Return the installed ha-mcp server version, or None if not installed.
|
||||
|
||||
@@ -124,13 +156,14 @@ class HaMcpToolsConfigFlow(ConfigFlow, domain=DOMAIN): # type: ignore[call-arg]
|
||||
def async_get_options_flow(config_entry: ConfigEntry) -> OptionsFlow:
|
||||
"""Return the options flow for this entry type.
|
||||
|
||||
Only the in-process server entry has options (channel / port / bind /
|
||||
auth / pip spec / URL). The tools services entry has none, so it returns
|
||||
a flow that aborts with an explanatory message.
|
||||
The in-process server entry gets the configurable options flow (channel /
|
||||
port / bind / auth / pip spec / URL). The tools services entry has
|
||||
nothing to configure yet, so it gets a light informational options flow
|
||||
instead of aborting.
|
||||
"""
|
||||
if config_entry.data.get(CONF_ENTRY_TYPE) == ENTRY_TYPE_SERVER:
|
||||
return HaMcpServerOptionsFlow()
|
||||
return _NoOptionsFlow()
|
||||
return HaMcpToolsInfoOptionsFlow()
|
||||
|
||||
async def async_step_user(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
@@ -164,7 +197,7 @@ class HaMcpToolsConfigFlow(ConfigFlow, domain=DOMAIN): # type: ignore[call-arg]
|
||||
def _create_tools_entry(self) -> ConfigFlowResult:
|
||||
"""Create the services (tools) config entry."""
|
||||
return self.async_create_entry(
|
||||
title=_TOOLS_ENTRY_TITLE,
|
||||
title=TOOLS_ENTRY_TITLE,
|
||||
data={CONF_ENTRY_TYPE: ENTRY_TYPE_TOOLS},
|
||||
)
|
||||
|
||||
@@ -206,14 +239,31 @@ class HaMcpToolsConfigFlow(ConfigFlow, domain=DOMAIN): # type: ignore[call-arg]
|
||||
return self.async_show_form(step_id="server")
|
||||
|
||||
|
||||
class _NoOptionsFlow(OptionsFlow):
|
||||
"""Options flow for the tools entry: it has no configurable options."""
|
||||
class HaMcpToolsInfoOptionsFlow(OptionsFlow):
|
||||
"""Options flow for the tools entry: a light informational form.
|
||||
|
||||
The tools services entry has nothing to configure yet, but aborting the
|
||||
Configure dialog reads as an error. Show an empty-schema form that explains
|
||||
what the entry provides instead; submitting persists an empty options
|
||||
payload.
|
||||
|
||||
The form uses the ``tools_info`` step id, NOT ``init``: the server options
|
||||
flow already owns ``options.step.init`` in strings.json, so a shared step id
|
||||
would collide. ``async_step_init`` is the required entry point (it renders
|
||||
the form); HA routes the form's submit to ``async_step_tools_info``.
|
||||
"""
|
||||
|
||||
async def async_step_init(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Abort immediately — the services entry exposes no options."""
|
||||
return self.async_abort(reason="no_options")
|
||||
"""Render the informational form under the ``tools_info`` step id."""
|
||||
return self.async_show_form(step_id="tools_info", data_schema=vol.Schema({}))
|
||||
|
||||
async def async_step_tools_info(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Persist an empty options payload once the info form is submitted."""
|
||||
return self.async_create_entry(title="", data={})
|
||||
|
||||
|
||||
class HaMcpServerOptionsFlow(OptionsFlow):
|
||||
@@ -229,6 +279,24 @@ class HaMcpServerOptionsFlow(OptionsFlow):
|
||||
opts = self.config_entry.options
|
||||
schema = vol.Schema(
|
||||
{
|
||||
# Authentication mode first, directly under the connect URLs
|
||||
# shown in the step description (#1875): it is the setting users
|
||||
# most need to find, and legacy mode is what unblocks OAuth-only
|
||||
# clients such as Google Gemini Spark and Copilot CLI.
|
||||
vol.Required(
|
||||
OPT_WEBHOOK_AUTH,
|
||||
default=opts.get(OPT_WEBHOOK_AUTH, WEBHOOK_AUTH_NONE),
|
||||
): SelectSelector(
|
||||
SelectSelectorConfig(
|
||||
options=[
|
||||
WEBHOOK_AUTH_NONE,
|
||||
WEBHOOK_AUTH_HA,
|
||||
WEBHOOK_AUTH_LEGACY,
|
||||
],
|
||||
translation_key="server_webhook_auth",
|
||||
mode=SelectSelectorMode.DROPDOWN,
|
||||
)
|
||||
),
|
||||
vol.Required(
|
||||
OPT_CHANNEL,
|
||||
default=opts.get(OPT_CHANNEL, DEFAULT_CHANNEL),
|
||||
@@ -268,16 +336,6 @@ class HaMcpServerOptionsFlow(OptionsFlow):
|
||||
mode=SelectSelectorMode.DROPDOWN,
|
||||
)
|
||||
),
|
||||
vol.Required(
|
||||
OPT_WEBHOOK_AUTH,
|
||||
default=opts.get(OPT_WEBHOOK_AUTH, WEBHOOK_AUTH_NONE),
|
||||
): SelectSelector(
|
||||
SelectSelectorConfig(
|
||||
options=[WEBHOOK_AUTH_NONE, WEBHOOK_AUTH_HA],
|
||||
translation_key="server_webhook_auth",
|
||||
mode=SelectSelectorMode.DROPDOWN,
|
||||
)
|
||||
),
|
||||
vol.Optional(
|
||||
OPT_PIP_SPEC,
|
||||
# Pre-fill via suggested_value, NOT a schema default: a
|
||||
@@ -296,11 +354,13 @@ class HaMcpServerOptionsFlow(OptionsFlow):
|
||||
): str,
|
||||
vol.Optional(
|
||||
OPT_SERVER_URL,
|
||||
description={
|
||||
"suggested_value": opts.get(
|
||||
OPT_SERVER_URL, DEFAULT_LOOPBACK_URL
|
||||
)
|
||||
},
|
||||
# Only a genuinely saved override is suggested (same
|
||||
# pattern as OPT_PIP_SPEC above). Pre-filling
|
||||
# DEFAULT_LOOPBACK_URL made every options save store the
|
||||
# constant as an explicit override, which would pin the
|
||||
# scheme/port even after issue #1890's SSL/port-aware
|
||||
# loopback derivation.
|
||||
description={"suggested_value": opts.get(OPT_SERVER_URL, "")},
|
||||
): str,
|
||||
vol.Required(
|
||||
OPT_ENABLE_WEBHOOK,
|
||||
@@ -352,6 +412,23 @@ class HaMcpServerOptionsFlow(OptionsFlow):
|
||||
OPT_REGENERATE_SECRETS,
|
||||
default=False,
|
||||
): bool,
|
||||
# Legacy OAuth mode (Google Gemini Spark) credential overrides —
|
||||
# same shape as the webhook id/secret-path overrides above.
|
||||
# Empty = auto-generate/keep the current value.
|
||||
vol.Optional(
|
||||
OPT_OAUTH_CLIENT_ID,
|
||||
description={"suggested_value": opts.get(OPT_OAUTH_CLIENT_ID, "")},
|
||||
): str,
|
||||
vol.Optional(
|
||||
OPT_OAUTH_CLIENT_SECRET,
|
||||
description={
|
||||
"suggested_value": opts.get(OPT_OAUTH_CLIENT_SECRET, "")
|
||||
},
|
||||
): str,
|
||||
vol.Optional(
|
||||
OPT_OAUTH_REGENERATE,
|
||||
default=False,
|
||||
): bool,
|
||||
}
|
||||
)
|
||||
# The sidebar-panel sentence in the description is only truthful while
|
||||
@@ -370,7 +447,8 @@ class HaMcpServerOptionsFlow(OptionsFlow):
|
||||
data_schema=schema,
|
||||
description_placeholders={
|
||||
"versions": await self._versions_hint(),
|
||||
"connect_url": self._connect_url_hint(),
|
||||
"connect_url": await self._connect_url_hint(),
|
||||
"oauth_creds": self._oauth_creds_hint(),
|
||||
"llm_api_docs_url": LLM_API_DOCS_URL,
|
||||
"panel_hint": panel_hint,
|
||||
},
|
||||
@@ -396,15 +474,21 @@ class HaMcpServerOptionsFlow(OptionsFlow):
|
||||
OPT_EXTERNAL_URL,
|
||||
OPT_WEBHOOK_ID_OVERRIDE,
|
||||
OPT_SECRET_PATH_OVERRIDE,
|
||||
OPT_OAUTH_CLIENT_ID,
|
||||
OPT_OAUTH_CLIENT_SECRET,
|
||||
):
|
||||
cleaned[key] = str(cleaned.get(key, "") or "").strip()
|
||||
cleaned[OPT_EXTERNAL_URL] = cleaned[OPT_EXTERNAL_URL].rstrip("/")
|
||||
# server_url gets no _normalize-forced empty like the fields above; strip
|
||||
# it and drop it entirely when blank so a whitespace-only value can't be
|
||||
# stored verbatim (it would bypass the consumer's empty -> loopback
|
||||
# fallback and break the HA connection).
|
||||
# fallback and break the HA connection). A value equal to
|
||||
# DEFAULT_LOOPBACK_URL is likewise dropped: older forms pre-filled it,
|
||||
# so it reaches here from users who never chose an override, and
|
||||
# storing it would pin the scheme/port the #1890 loopback derivation
|
||||
# exists to resolve.
|
||||
server_url = str(cleaned.get(OPT_SERVER_URL, "") or "").strip().rstrip("/")
|
||||
if server_url:
|
||||
if server_url and server_url != DEFAULT_LOOPBACK_URL:
|
||||
cleaned[OPT_SERVER_URL] = server_url
|
||||
else:
|
||||
cleaned.pop(OPT_SERVER_URL, None)
|
||||
@@ -451,7 +535,7 @@ class HaMcpServerOptionsFlow(OptionsFlow):
|
||||
f"Server ha-mcp {server_version} ({channel} channel)"
|
||||
)
|
||||
|
||||
def _connect_url_hint(self) -> str:
|
||||
async def _connect_url_hint(self) -> str:
|
||||
"""Return the connect URLs for the options form.
|
||||
|
||||
The Configure screen is admin-only, so it shows the real resolved
|
||||
@@ -471,10 +555,13 @@ class HaMcpServerOptionsFlow(OptionsFlow):
|
||||
hass = getattr(self, "hass", None)
|
||||
if hass is not None:
|
||||
try:
|
||||
from .embedded_setup import build_connect_urls
|
||||
from .embedded_setup import async_get_lan_hosts, build_connect_urls
|
||||
|
||||
urls = build_connect_urls(
|
||||
hass, self.config_entry, webhook_enabled=webhook_enabled
|
||||
hass,
|
||||
self.config_entry,
|
||||
webhook_enabled=webhook_enabled,
|
||||
extra_hosts=await async_get_lan_hosts(hass),
|
||||
)
|
||||
if urls:
|
||||
return "Connect URL(s):\n" + "\n".join(f"- {u}" for u in urls)
|
||||
@@ -507,3 +594,50 @@ class HaMcpServerOptionsFlow(OptionsFlow):
|
||||
f"http://<home-assistant-ip>:{port}{secret_path}"
|
||||
)
|
||||
return hint
|
||||
|
||||
def _oauth_creds_hint(self) -> str:
|
||||
"""Return the resolved legacy OAuth Client ID + Secret for the options
|
||||
form, or a note pointing at the mode selector when legacy mode isn't
|
||||
the CONFIGURED mode. Admin-only screen (like ``_connect_url_hint``),
|
||||
so showing the secret in cleartext here is acceptable — and this is
|
||||
the surface the startup log points at while a rotation is pending
|
||||
(``_surface_connect_urls`` withholds pending credentials from the
|
||||
log, where a still-valid old-identity token could read them; an HA
|
||||
admin here is trusted). A pending rotation gets a caveat so the admin
|
||||
doesn't paste values that only start working after the restart.
|
||||
"""
|
||||
configured_mode = str(self.config_entry.options.get(OPT_WEBHOOK_AUTH) or "")
|
||||
if configured_mode != WEBHOOK_AUTH_LEGACY:
|
||||
return (
|
||||
"Set Authentication mode to legacy OAuth above and save to "
|
||||
"generate a Client ID and Client Secret."
|
||||
)
|
||||
client_id = self.config_entry.data.get(DATA_OAUTH_CLIENT_ID)
|
||||
client_secret = self.config_entry.data.get(DATA_OAUTH_CLIENT_SECRET)
|
||||
if not client_id or not client_secret:
|
||||
# Not minted yet — the entry hasn't finished a bring-up cycle
|
||||
# since legacy mode was selected (e.g. this save just turned it
|
||||
# on). They appear after the next reload.
|
||||
return (
|
||||
"The Client ID and Client Secret appear here once the server "
|
||||
"has started."
|
||||
)
|
||||
creds = f"Client ID: {client_id}\nClient Secret: {client_secret}"
|
||||
signing_key = str(self.config_entry.data.get(DATA_OAUTH_SIGNING_KEY) or "")
|
||||
active = _legacy_credentials_active(
|
||||
self.hass, str(client_id), str(client_secret), signing_key
|
||||
)
|
||||
if active and not _legacy_restart_pending(self.hass):
|
||||
return creds # bound and live
|
||||
# Not serving these credentials yet: a mid-session first enable
|
||||
# (bound but not live until the restart), a pending rotation (the old
|
||||
# identity still bound), the webhook disabled, or another integration
|
||||
# owning the routes. State-agnostic wording — the previous "the
|
||||
# previous Client ID and Client Secret remain active" was false at a
|
||||
# first enable and when nothing of ours is bound. Matches the startup
|
||||
# log's first-enable caveat and the oauth_regenerate help text.
|
||||
return (
|
||||
f"{creds}\n"
|
||||
"Legacy OAuth is not serving these yet — restart Home Assistant "
|
||||
"when it asks you to, to activate them."
|
||||
)
|
||||
|
||||
@@ -24,7 +24,7 @@ DOMAIN = "ha_mcp_tools"
|
||||
# manifest bump that forgets this constant (or vice-versa) fails in CI. The
|
||||
# capability negotiation — not this version — gates each WS command (see
|
||||
# ``websocket_api.CAPABILITIES``).
|
||||
COMPONENT_VERSION = "1.1.0"
|
||||
COMPONENT_VERSION = "1.2.2"
|
||||
|
||||
# Config-entry discriminator (``entry.data[CONF_ENTRY_TYPE]``). A missing value
|
||||
# means "tools" so the pre-existing services entry keeps working across the
|
||||
@@ -32,6 +32,12 @@ COMPONENT_VERSION = "1.1.0"
|
||||
CONF_ENTRY_TYPE = "entry_type"
|
||||
ENTRY_TYPE_TOOLS = "tools"
|
||||
ENTRY_TYPE_SERVER = "server"
|
||||
|
||||
# Titles shown for each entry in the integration tile's entry list. Public so
|
||||
# __init__'s setup migration can retitle pre-#1853 tools entries still
|
||||
# carrying the legacy default (a user-customized title is left alone).
|
||||
TOOLS_ENTRY_TITLE = "HA-MCP File & YAML Tools"
|
||||
TOOLS_ENTRY_LEGACY_TITLE = "HA MCP Tools"
|
||||
MIN_EMBEDDED_HOME_ASSISTANT_VERSION = "2026.6.0"
|
||||
|
||||
# Allowed directories for file operations (relative to config dir)
|
||||
@@ -290,6 +296,16 @@ DEFAULT_AUTO_UPDATE = True
|
||||
OPT_SERVER_PORT = "server_port"
|
||||
OPT_BIND_HOST = "bind_host"
|
||||
OPT_WEBHOOK_AUTH = "webhook_auth"
|
||||
# Legacy OAuth mode (self-hosted authorization server, static client_id/secret
|
||||
# for Google Gemini Spark) credential management — mirrors the
|
||||
# OPT_WEBHOOK_ID_OVERRIDE / OPT_REGENERATE_SECRETS shape below. Empty override
|
||||
# fields mean "keep the current value"; OPT_OAUTH_REGENERATE is one-shot.
|
||||
# _override suffix distinguishes these OPTIONS keys from the DATA_OAUTH_*
|
||||
# entry.data keys (which store the resolved values under the un-suffixed
|
||||
# names) — mirrors OPT_WEBHOOK_ID_OVERRIDE vs DATA_WEBHOOK_ID.
|
||||
OPT_OAUTH_CLIENT_ID = "oauth_client_id_override"
|
||||
OPT_OAUTH_CLIENT_SECRET = "oauth_client_secret_override"
|
||||
OPT_OAUTH_REGENERATE = "oauth_regenerate"
|
||||
OPT_PIP_SPEC = "pip_spec"
|
||||
OPT_SERVER_URL = "server_url"
|
||||
# Connect-URL surface + secret management (owner request, parity with the
|
||||
@@ -331,6 +347,22 @@ OPT_ENABLE_SIDEBAR_PANEL = "enable_sidebar_panel"
|
||||
# entry.data keys (persisted ids + secrets; entry.data is fine for secrets).
|
||||
DATA_WEBHOOK_ID = "webhook_id"
|
||||
DATA_SECRET_PATH = "secret_path"
|
||||
# Legacy OAuth mode credentials, minted by embedded_entry._ensure_secrets and
|
||||
# consumed by oauth_legacy.LegacyOAuthProvider. DATA_OAUTH_SIGNING_KEY is a hex
|
||||
# string (entry.data must be JSON-serializable, so raw bytes aren't stored
|
||||
# directly) — the provider converts it with bytes.fromhex(). The signed token
|
||||
# payload carries the client_id (not the secret), so rotating the client_id
|
||||
# revokes every outstanding token at the restart that rebinds the views (see
|
||||
# LegacyOAuthProvider._validate_token).
|
||||
# Because validation never involves the client_secret, a secret-only override
|
||||
# change instead rotates the signing key, evicting outstanding tokens at the
|
||||
# restart that activates the new credentials (see
|
||||
# embedded_entry._ensure_legacy_oauth_secrets). Until that restart the bound
|
||||
# views keep serving the OLD identity, so the startup log withholds rotated
|
||||
# credentials (embedded_setup._surface_connect_urls).
|
||||
DATA_OAUTH_CLIENT_ID = "oauth_client_id"
|
||||
DATA_OAUTH_CLIENT_SECRET = "oauth_client_secret"
|
||||
DATA_OAUTH_SIGNING_KEY = "oauth_signing_key"
|
||||
DATA_SERVER_USER_ID = "server_user_id"
|
||||
DATA_REFRESH_TOKEN_ID = "refresh_token_id"
|
||||
DATA_ACCESS_TOKEN = "access_token"
|
||||
@@ -375,6 +407,12 @@ DATA_LLM_API_UNSUB = "llm_api_unsub"
|
||||
# Webhook auth modes (mirrors the webhook-proxy add-on's default posture).
|
||||
WEBHOOK_AUTH_NONE = "none" # secret webhook URL is the shared secret (default)
|
||||
WEBHOOK_AUTH_HA = "ha_auth" # HA-native bearer (HA core is the OAuth AS)
|
||||
# Self-hosted OAuth 2.1 authorization server with a static client_id/secret,
|
||||
# ported from the webhook-proxy add-on's "legacy" mode. Needed because HA
|
||||
# core's /auth/authorize does not yet fetch Client ID Metadata Documents for
|
||||
# cross-origin redirect_uris (home-assistant/core#176282), which is what
|
||||
# Google Gemini Spark's custom connected apps require.
|
||||
WEBHOOK_AUTH_LEGACY = "legacy"
|
||||
|
||||
# Default bind host + port. 9584 (not the add-on's 9583) so this in-process
|
||||
# server and an add-on install can coexist on the same box.
|
||||
@@ -407,14 +445,34 @@ SERVER_USER_NAME = "HA-MCP Server"
|
||||
# namespace (mirrors the webhook-proxy add-on's /api/mcp_proxy/oauth base).
|
||||
OAUTH_BASE = "/api/ha_mcp_tools/oauth"
|
||||
|
||||
# HACS "add repository" deep link for the custom component. Shared learn_more_url
|
||||
# for every repair issue that ends with "install/reinstall the component via
|
||||
# HACS" (the component-outdated issue and the legacy-HACS-source issue below).
|
||||
# HACS repository full_names (``owner/repo``, the key HACS's repository registry
|
||||
# uses) this component may be tracked under: the dedicated integration mirror is
|
||||
# the current install path; the main ha-mcp server repo is the legacy pre-mirror
|
||||
# path (see install_source_check). Shared by the legacy-source check and the
|
||||
# HACS refresh nudge (hacs_nudge) so a repository rename lands in one place.
|
||||
HACS_MIRROR_REPO_FULL_NAME = "homeassistant-ai/ha-mcp-integration"
|
||||
HACS_LEGACY_REPO_FULL_NAME = "homeassistant-ai/ha-mcp"
|
||||
|
||||
# HACS "add repository" deep link for the custom component. learn_more_url for
|
||||
# the legacy-HACS-source repair (install_source_check) only, whose fix really is
|
||||
# re-adding the repository. The update-held and component-outdated issues point
|
||||
# at UPDATE_HOLD_DOCS_URL instead — for an already-installed component this deep
|
||||
# link just opens a blank "add repository" dialog.
|
||||
HACS_COMPONENT_URL = (
|
||||
"https://my.home-assistant.io/redirect/hacs_repository/"
|
||||
"?owner=homeassistant-ai&repository=ha-mcp-integration&category=integration"
|
||||
)
|
||||
|
||||
# Docs section explaining the automatic-update hold, linked as learn_more_url
|
||||
# from the update-held and component-outdated repair issues (both resolve by
|
||||
# updating an already-installed component, not by re-adding a repository). The
|
||||
# anchor is the GitHub slug of the "Held server updates" heading in
|
||||
# docs/in-process-server.md; hassfest forbids literal URLs inside strings.json.
|
||||
UPDATE_HOLD_DOCS_URL = (
|
||||
"https://github.com/homeassistant-ai/ha-mcp/blob/master/docs/"
|
||||
"in-process-server.md#held-server-updates"
|
||||
)
|
||||
|
||||
# Usage guide for the conversation-agent LLM API option (#1745). Injected into
|
||||
# the options form as a description placeholder — hassfest forbids literal
|
||||
# URLs inside strings.json.
|
||||
@@ -449,3 +507,10 @@ ISSUE_UPDATE_HELD = "server_update_held"
|
||||
# mechanism, so this only self-resolves if the user re-adds the dedicated
|
||||
# mirror (homeassistant-ai/ha-mcp-integration).
|
||||
ISSUE_LEGACY_HACS_SOURCE = "legacy_hacs_source"
|
||||
# Repair issue surfaced when the legacy OAuth mode's root /authorize + /token
|
||||
# views are out of sync with the CONFIGURED webhook_auth mode — either just
|
||||
# enabled (views not yet bound with the current credentials) or just disabled
|
||||
# (views still bound and serving the old identity). aiohttp can neither bind
|
||||
# nor unbind an HTTP view without a full Home Assistant restart, so both
|
||||
# transitions need one; see oauth_legacy.bind_legacy_views.
|
||||
ISSUE_LEGACY_OAUTH_RESTART = "legacy_oauth_restart"
|
||||
|
||||
@@ -26,14 +26,23 @@ from homeassistant.core import HomeAssistant, callback
|
||||
from .const import (
|
||||
DATA_BRINGUP_TASK,
|
||||
DATA_LAST_OPTIONS,
|
||||
DATA_OAUTH_CLIENT_ID,
|
||||
DATA_OAUTH_CLIENT_SECRET,
|
||||
DATA_OAUTH_SIGNING_KEY,
|
||||
DATA_SECRET_PATH,
|
||||
DATA_UPDATE_COORDINATOR,
|
||||
DATA_WEBHOOK_ID,
|
||||
DOMAIN,
|
||||
OPT_ENABLE_SIDEBAR_PANEL,
|
||||
OPT_ENABLE_WEBHOOK,
|
||||
OPT_OAUTH_CLIENT_ID,
|
||||
OPT_OAUTH_CLIENT_SECRET,
|
||||
OPT_OAUTH_REGENERATE,
|
||||
OPT_REGENERATE_SECRETS,
|
||||
OPT_SECRET_PATH_OVERRIDE,
|
||||
OPT_WEBHOOK_AUTH,
|
||||
OPT_WEBHOOK_ID_OVERRIDE,
|
||||
WEBHOOK_AUTH_LEGACY,
|
||||
)
|
||||
|
||||
# NOTE: embedded_setup / coordinator (and their embedded_server / mcp_webhook
|
||||
@@ -65,6 +74,9 @@ async def async_setup_server_entry(hass: HomeAssistant, entry: ConfigEntry) -> b
|
||||
from .ui_panel import async_register_ui_panel
|
||||
|
||||
_ensure_secrets(hass, entry)
|
||||
# Bind the legacy OAuth root views synchronously here — before the slow
|
||||
# background bring-up below — so they are live at boot (see the helper).
|
||||
_prebind_legacy_oauth_views(hass, entry)
|
||||
|
||||
# Admin-only "Open Web UI" sidebar panel + proxy. Registered while the entry
|
||||
# exists (its proxy returns 503 until the server is actually running), so the
|
||||
@@ -178,6 +190,50 @@ async def _async_options_updated(hass: HomeAssistant, entry: ConfigEntry) -> Non
|
||||
await hass.config_entries.async_reload(entry.entry_id)
|
||||
|
||||
|
||||
def _prebind_legacy_oauth_views(hass: HomeAssistant, entry: ConfigEntry) -> None:
|
||||
"""Register the legacy OAuth root ``/authorize`` + ``/token`` views during
|
||||
entry setup, before the background bring-up's (slow) package install.
|
||||
|
||||
An aiohttp route is only ever live if it is registered before Home Assistant
|
||||
freezes its HTTP app at the end of startup. Binding these views from the
|
||||
background bring-up task races HA reaching RUNNING: on a slow-install boot
|
||||
the routes would register AFTER the freeze — never live until a restart —
|
||||
and ``bind_legacy_views`` would see ``hass.is_running`` True and file a
|
||||
restart repair that the restart cannot clear. Binding here, while the entry
|
||||
is still setting up (``hass.is_running`` is False at boot), mirrors the
|
||||
webhook-proxy add-on, which binds in its own ``async_setup_entry``. The
|
||||
bring-up's ``async_register_webhook`` then reuses this already-bound provider.
|
||||
|
||||
Only relevant when legacy is the configured mode and the webhook endpoint is
|
||||
enabled (legacy OAuth guards that endpoint; with no webhook there is nothing
|
||||
to protect). A route-ownership conflict with the webhook-proxy add-on is
|
||||
swallowed here — the bring-up re-encounters it and files the user-facing
|
||||
start-failed repair.
|
||||
"""
|
||||
if str(entry.options.get(OPT_WEBHOOK_AUTH, "")) != WEBHOOK_AUTH_LEGACY:
|
||||
return
|
||||
if not bool(entry.options.get(OPT_ENABLE_WEBHOOK, True)):
|
||||
return
|
||||
client_id = entry.data.get(DATA_OAUTH_CLIENT_ID)
|
||||
client_secret = entry.data.get(DATA_OAUTH_CLIENT_SECRET)
|
||||
signing_key = entry.data.get(DATA_OAUTH_SIGNING_KEY)
|
||||
if not (client_id and client_secret and signing_key):
|
||||
# _ensure_secrets mints these whenever legacy mode is configured; a gap
|
||||
# means a partial config — let the bring-up path surface it.
|
||||
return
|
||||
from .mcp_webhook import _register_metadata_views
|
||||
from .oauth_legacy import LegacyOAuthRouteConflict, bind_legacy_views
|
||||
|
||||
with suppress(LegacyOAuthRouteConflict):
|
||||
# Register the RFC 8414/9728 discovery views alongside the root
|
||||
# /authorize + /token views, both at setup time, so the discovery
|
||||
# doc's resource_metadata URL resolves at boot for RFC-compliant
|
||||
# clients — not just the root views. Both are idempotent, so the
|
||||
# bring-up's async_register_webhook reuses them.
|
||||
_register_metadata_views(hass)
|
||||
bind_legacy_views(hass, client_id, client_secret, signing_key)
|
||||
|
||||
|
||||
def _ensure_secrets(hass: HomeAssistant, entry: ConfigEntry) -> None:
|
||||
"""Generate + persist the stable webhook id and secret path on first setup.
|
||||
|
||||
@@ -192,6 +248,13 @@ def _ensure_secrets(hass: HomeAssistant, entry: ConfigEntry) -> None:
|
||||
``secret_path_override`` replaces the stored value (normalized: the
|
||||
secret path gets a leading ``/``).
|
||||
3. First setup: mint random values for whatever is still missing.
|
||||
|
||||
When the configured webhook auth mode is legacy, the same three-path
|
||||
lifecycle additionally applies to the legacy OAuth client_id/client_secret
|
||||
(see :func:`_ensure_legacy_oauth_secrets`) — folded into this same
|
||||
read-mutate-write cycle so a single ``async_update_entry`` call covers
|
||||
every field that changed this load, including when BOTH a webhook secret
|
||||
regenerate and an OAuth credential regenerate are requested together.
|
||||
"""
|
||||
data = dict(entry.data)
|
||||
options = dict(entry.options)
|
||||
@@ -206,20 +269,19 @@ def _ensure_secrets(hass: HomeAssistant, entry: ConfigEntry) -> None:
|
||||
options[OPT_REGENERATE_SECRETS] = False
|
||||
options[OPT_WEBHOOK_ID_OVERRIDE] = ""
|
||||
options[OPT_SECRET_PATH_OVERRIDE] = ""
|
||||
hass.config_entries.async_update_entry(entry, data=data, options=options)
|
||||
return
|
||||
|
||||
webhook_override = str(options.get(OPT_WEBHOOK_ID_OVERRIDE) or "").strip()
|
||||
if webhook_override and data.get(DATA_WEBHOOK_ID) != webhook_override:
|
||||
data[DATA_WEBHOOK_ID] = webhook_override
|
||||
changed = True
|
||||
path_override = str(options.get(OPT_SECRET_PATH_OVERRIDE) or "").strip()
|
||||
if path_override:
|
||||
if not path_override.startswith("/"):
|
||||
path_override = f"/{path_override}"
|
||||
if data.get(DATA_SECRET_PATH) != path_override:
|
||||
data[DATA_SECRET_PATH] = path_override
|
||||
else:
|
||||
webhook_override = str(options.get(OPT_WEBHOOK_ID_OVERRIDE) or "").strip()
|
||||
if webhook_override and data.get(DATA_WEBHOOK_ID) != webhook_override:
|
||||
data[DATA_WEBHOOK_ID] = webhook_override
|
||||
changed = True
|
||||
path_override = str(options.get(OPT_SECRET_PATH_OVERRIDE) or "").strip()
|
||||
if path_override:
|
||||
if not path_override.startswith("/"):
|
||||
path_override = f"/{path_override}"
|
||||
if data.get(DATA_SECRET_PATH) != path_override:
|
||||
data[DATA_SECRET_PATH] = path_override
|
||||
changed = True
|
||||
|
||||
if not data.get(DATA_WEBHOOK_ID):
|
||||
data[DATA_WEBHOOK_ID] = f"mcp_{secrets.token_hex(16)}"
|
||||
@@ -227,5 +289,117 @@ def _ensure_secrets(hass: HomeAssistant, entry: ConfigEntry) -> None:
|
||||
if not data.get(DATA_SECRET_PATH):
|
||||
data[DATA_SECRET_PATH] = f"/private_{secrets.token_urlsafe(16)}"
|
||||
changed = True
|
||||
|
||||
if options.get(OPT_WEBHOOK_AUTH) == WEBHOOK_AUTH_LEGACY:
|
||||
changed = _ensure_legacy_oauth_secrets(data, options) or changed
|
||||
|
||||
if changed:
|
||||
hass.config_entries.async_update_entry(entry, data=data)
|
||||
hass.config_entries.async_update_entry(entry, data=data, options=options)
|
||||
|
||||
|
||||
def _ensure_legacy_oauth_secrets(data: dict, options: dict) -> bool:
|
||||
"""Mint/persist/rotate the legacy OAuth mode's credentials into ``data``
|
||||
(mutated in place, along with ``options`` for the one-shot regenerate
|
||||
flag). Only called while the configured webhook auth mode is legacy —
|
||||
switching away leaves whatever was last minted in place, so switching
|
||||
back reuses it rather than silently rotating.
|
||||
|
||||
Mirrors the ``OPT_REGENERATE_SECRETS`` shape above: ``OPT_OAUTH_REGENERATE``
|
||||
is one-shot, minting a fresh client_id/client_secret and clearing itself
|
||||
plus the two override fields.
|
||||
|
||||
ANY credential change — regenerate, a client_id override, or a
|
||||
client_secret override — also rotates ``signing_key``, making every
|
||||
rotation a hard revocation of outstanding tokens (they take effect at the
|
||||
restart that rebinds the views). Rotating the key on a secret change is
|
||||
load-bearing (token validation never involves the secret, so the cid
|
||||
claim alone would not evict anything). Rotating it on a client_id change
|
||||
is defence in depth: the cid claim already evicts on a normal rotation,
|
||||
but without a fresh key an ``A → B → A`` client_id sequence would
|
||||
resurrect id-A's still-unexpired tokens, since they re-match the cid
|
||||
claim under the unchanged-key HMAC. Rotating the key kills that echo at
|
||||
zero cost. Rotation does NOT shorten the pre-restart window — the bound
|
||||
views keep serving the old identity until the restart (see
|
||||
``oauth_legacy.bind_legacy_views``) — which is why the startup log also
|
||||
withholds rotated credentials until they are active
|
||||
(``embedded_setup._surface_connect_urls`` via
|
||||
``oauth_legacy.legacy_credentials_active``; review findings on #1880).
|
||||
|
||||
Returns True if ``data``/``options`` were mutated.
|
||||
"""
|
||||
changed = False
|
||||
if options.get(OPT_OAUTH_REGENERATE):
|
||||
data[DATA_OAUTH_CLIENT_ID] = f"hamcp-{secrets.token_hex(16)}"
|
||||
data[DATA_OAUTH_CLIENT_SECRET] = secrets.token_urlsafe(32)
|
||||
# Every credential change rotates the key — see docstring (kills the
|
||||
# A->B->A client_id resurrection; regenerate mints random ids so it
|
||||
# can't recur to a former id, but the key rotation is kept uniform).
|
||||
data[DATA_OAUTH_SIGNING_KEY] = secrets.token_hex(32)
|
||||
options[OPT_OAUTH_REGENERATE] = False
|
||||
options[OPT_OAUTH_CLIENT_ID] = ""
|
||||
options[OPT_OAUTH_CLIENT_SECRET] = ""
|
||||
changed = True
|
||||
else:
|
||||
client_id_override = str(options.get(OPT_OAUTH_CLIENT_ID) or "").strip()
|
||||
if client_id_override and data.get(DATA_OAUTH_CLIENT_ID) != client_id_override:
|
||||
data[DATA_OAUTH_CLIENT_ID] = client_id_override
|
||||
# Rotate the key so a re-used former client_id can't resurrect its
|
||||
# old tokens (see docstring).
|
||||
data[DATA_OAUTH_SIGNING_KEY] = secrets.token_hex(32)
|
||||
changed = True
|
||||
client_secret_override = str(options.get(OPT_OAUTH_CLIENT_SECRET) or "").strip()
|
||||
if (
|
||||
client_secret_override
|
||||
and data.get(DATA_OAUTH_CLIENT_SECRET) != client_secret_override
|
||||
):
|
||||
data[DATA_OAUTH_CLIENT_SECRET] = client_secret_override
|
||||
# Evict outstanding tokens along with the old secret — see the
|
||||
# docstring for why a secret-only rotation must not leave them
|
||||
# valid for the rest of their TTL.
|
||||
data[DATA_OAUTH_SIGNING_KEY] = secrets.token_hex(32)
|
||||
changed = True
|
||||
# Consume the OAuth override fields once applied. entry.options is
|
||||
# readable through the server's own tools
|
||||
# (ha_get_integration(include_options=True) rebuilds it from the
|
||||
# options-form suggested_values), so a rotated client_secret left here
|
||||
# in cleartext would let a pre-restart old-identity token holder read
|
||||
# the NEW secret that way — the exact party the rotation evicts, and
|
||||
# the same leak the startup log withholds. The resolved values live in
|
||||
# entry.data and on the admin-only Configure screen
|
||||
# (config_flow._oauth_creds_hint), so nothing is lost. Cleared even
|
||||
# when the override matched the current value: the cleartext must not
|
||||
# linger regardless of whether it changed data.
|
||||
#
|
||||
# DELIBERATE divergence from the webhook_id / secret_path overrides
|
||||
# above, which persist. Three reasons the OAuth secret is different:
|
||||
# (1) A client_secret override IS the OAuth rotation path and gates a
|
||||
# per-connection revocable bearer, so a lingering copy defeats the
|
||||
# very revocation the rotation performs. secret_path's own rotation
|
||||
# (OPT_REGENERATE_SECRETS) already clears its override, so that
|
||||
# path is not self-defeating; a standalone secret_path override is
|
||||
# configuration, not rotation.
|
||||
# (2) A connected legacy client learns the OAuth secret only via this
|
||||
# options leak (entry.data is not tool-exposed; the webhook is
|
||||
# OAuth-gated). secret_path gates the direct LAN port, and per
|
||||
# SECURITY.md the local network is the trusted zone and the client
|
||||
# is a trusted principal — LAN-peer access to standard-mode
|
||||
# endpoints is explicitly out of scope.
|
||||
# (3) Persisting the webhook_id/secret_path override is deliberate UX
|
||||
# (the admin sees their configured value in the form).
|
||||
if options.get(OPT_OAUTH_CLIENT_ID):
|
||||
options[OPT_OAUTH_CLIENT_ID] = ""
|
||||
changed = True
|
||||
if options.get(OPT_OAUTH_CLIENT_SECRET):
|
||||
options[OPT_OAUTH_CLIENT_SECRET] = ""
|
||||
changed = True
|
||||
|
||||
if not data.get(DATA_OAUTH_CLIENT_ID):
|
||||
data[DATA_OAUTH_CLIENT_ID] = f"hamcp-{secrets.token_hex(16)}"
|
||||
changed = True
|
||||
if not data.get(DATA_OAUTH_CLIENT_SECRET):
|
||||
data[DATA_OAUTH_CLIENT_SECRET] = secrets.token_urlsafe(32)
|
||||
changed = True
|
||||
if not data.get(DATA_OAUTH_SIGNING_KEY):
|
||||
data[DATA_OAUTH_SIGNING_KEY] = secrets.token_hex(32)
|
||||
changed = True
|
||||
return changed
|
||||
|
||||
@@ -50,6 +50,8 @@ from homeassistant.requirements import (
|
||||
pip_kwargs,
|
||||
)
|
||||
from homeassistant.util.package import install_package
|
||||
from packaging.requirements import InvalidRequirement, Requirement
|
||||
from packaging.utils import canonicalize_name
|
||||
from packaging.version import InvalidVersion, Version
|
||||
|
||||
from .const import (
|
||||
@@ -83,6 +85,8 @@ from .const import (
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Callable
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
@@ -92,13 +96,20 @@ _LOGGER = logging.getLogger(__name__)
|
||||
# from the same refresh token on every start regardless.
|
||||
_ACCESS_TOKEN_TTL = timedelta(days=3650)
|
||||
|
||||
# Readiness probe: how long to wait for the server thread to accept a loopback
|
||||
# TCP connection before declaring the start failed. Generous on purpose: a
|
||||
# cold import of the fastmcp tree takes 1-3s on real hardware but has been
|
||||
# observed to exceed 30s on QEMU-emulated HAOS (the e2e lane), and a single
|
||||
# readiness timeout fails the bring-up outright — there is no retry. Real
|
||||
# deployments only pay this budget on the failure path.
|
||||
_READY_TIMEOUT_SECONDS = 90.0
|
||||
# Readiness probe: fail the bring-up only when there is no observable startup
|
||||
# progress (no new modules landing in sys.modules, no phase advance) for this
|
||||
# long. The previous flat 90s deadline assumed real hardware imports the
|
||||
# server tree in seconds — issue #1904 (HA Green) showed a cold import alone
|
||||
# can take minutes there, and killing the still-importing worker at the
|
||||
# deadline is what created the orphaned-thread/port-collision cascade: the
|
||||
# worker cannot be joined mid-import, lingered as a zombie, and later bound
|
||||
# the port out from under the retry. The module count is process-wide (see
|
||||
# _progress_signature), so a wedged worker trips the stall budget on a quiet
|
||||
# instance and the absolute cap below at the latest.
|
||||
_READY_STALL_TIMEOUT_SECONDS = 90.0
|
||||
# Absolute ceiling on one bring-up regardless of apparent progress, matching
|
||||
# the HAOS e2e lane's own 600s readiness deadline.
|
||||
_READY_TOTAL_CAP_SECONDS = 600.0
|
||||
_READY_POLL_INTERVAL_SECONDS = 0.5
|
||||
|
||||
# How long to wait for the worker thread to exit on stop before giving up and
|
||||
@@ -113,12 +124,66 @@ _PIP_INSTALL_TIMEOUT_SECONDS = 300
|
||||
# subprocess can never tie up an executor thread indefinitely.
|
||||
_PIP_UNINSTALL_TIMEOUT_SECONDS = 120
|
||||
|
||||
# How long a bring-up waits for an install job orphaned by a CANCELLED
|
||||
# previous bring-up before giving up: asyncio cancellation detaches the
|
||||
# awaiter but an executor pip job runs to completion regardless. Sized to
|
||||
# the same absolute budget as the readiness cap: pip's own timeout (300s)
|
||||
# is per-download, so a healthy cold install pulling the whole dependency
|
||||
# tree on slow hardware can legitimately run for minutes — a tighter bound
|
||||
# would misreport it as stuck. On expiry the bring-up fails with a clear
|
||||
# message and the next reload retries.
|
||||
_PENDING_INSTALL_WAIT_SECONDS = 600.0
|
||||
|
||||
# The in-process connection API was added with the embedded server in 7.10.0.
|
||||
# Older distributions can be left behind by an unsupported Core version's
|
||||
# constraints and must never enter the worker thread.
|
||||
MIN_EMBEDDED_SERVER_VERSION = "7.10.0"
|
||||
|
||||
|
||||
def _derive_loopback_url(hass: HomeAssistant) -> tuple[str, bool | None]:
|
||||
"""Resolve the loopback base URL for HA core from the http integration.
|
||||
|
||||
Returns ``(url, verify_ssl)`` where ``verify_ssl`` is ``False`` when the
|
||||
URL is ``https`` (HA's certificate is issued for its hostname, never for
|
||||
127.0.0.1, so verification on the loopback hop can only fail) and ``None``
|
||||
when no override of the server's default is needed.
|
||||
|
||||
The hardcoded ``http://127.0.0.1:8123`` default this replaces broke every
|
||||
instance with ``http.ssl_certificate`` configured (issue #1890): port 8123
|
||||
speaks TLS there, so the server's plaintext REST/WS calls died with
|
||||
"Server disconnected without sending a response" / "did not receive a
|
||||
valid HTTP response" on every tool call — while the MCP handshake and
|
||||
tools/list (no HA round-trip) kept working. A custom ``server_port``
|
||||
similarly broke the hardcoded port. Both live in ``hass.config.api``,
|
||||
set by the ``http`` integration this component depends on; the constant
|
||||
remains the fallback if it is ever absent.
|
||||
"""
|
||||
api = getattr(hass.config, "api", None)
|
||||
if api is None:
|
||||
# Leave a trail: if this ever fires on a real instance, the resulting
|
||||
# failure looks exactly like issue #1890 (TLS loopback broken, MCP
|
||||
# handshake fine) and took a live reproduction to diagnose last time.
|
||||
_LOGGER.debug(
|
||||
"hass.config.api unavailable; using hardcoded loopback default %s",
|
||||
DEFAULT_LOOPBACK_URL,
|
||||
)
|
||||
return DEFAULT_LOOPBACK_URL, None
|
||||
# Strict type checks (not coercion / truthiness): a malformed api object
|
||||
# must resolve to the plaintext default on port 8123, never to a surprise
|
||||
# port or https flip. bool is excluded because it is an int subclass.
|
||||
port_raw = getattr(api, "port", None)
|
||||
port = (
|
||||
port_raw
|
||||
if isinstance(port_raw, int)
|
||||
and not isinstance(port_raw, bool)
|
||||
and 0 < port_raw <= 65535
|
||||
else 8123
|
||||
)
|
||||
if getattr(api, "use_ssl", False) is True:
|
||||
return f"https://127.0.0.1:{port}", False
|
||||
return f"http://127.0.0.1:{port}", None
|
||||
|
||||
|
||||
class EmbeddedServerError(Exception):
|
||||
"""Raised when the in-process ha-mcp server could not be installed or started.
|
||||
|
||||
@@ -146,9 +211,17 @@ class EmbeddedServerManager:
|
||||
options = entry.options
|
||||
self._port: int = int(options.get(OPT_SERVER_PORT, DEFAULT_SERVER_PORT))
|
||||
self._bind_host: str = str(options.get(OPT_BIND_HOST, DEFAULT_BIND_HOST))
|
||||
self._server_url: str = str(
|
||||
options.get(OPT_SERVER_URL) or DEFAULT_LOOPBACK_URL
|
||||
).rstrip("/")
|
||||
# An explicit server_url override wins verbatim (the operator manages
|
||||
# scheme/verification themselves via the settings UI). A stored value
|
||||
# equal to DEFAULT_LOOPBACK_URL is treated as no-override: the options
|
||||
# form used to pre-fill that constant as suggested_value, so existing
|
||||
# entries carry it without the user ever having chosen it.
|
||||
_url_override = str(options.get(OPT_SERVER_URL) or "").rstrip("/")
|
||||
self._loopback_verify_ssl: bool | None = None
|
||||
if _url_override and _url_override != DEFAULT_LOOPBACK_URL:
|
||||
self._server_url: str = _url_override
|
||||
else:
|
||||
self._server_url, self._loopback_verify_ssl = _derive_loopback_url(hass)
|
||||
self._channel: str = str(options.get(OPT_CHANNEL) or DEFAULT_CHANNEL)
|
||||
# An explicit pip-spec override (the pre-release test channel) wins over
|
||||
# the channel selector. DEFAULT_PIP_SPEC in the field means "no override,
|
||||
@@ -189,6 +262,10 @@ class EmbeddedServerManager:
|
||||
# Compared against the installed distribution after start to detect a
|
||||
# stale-code worker (see _purge_ha_mcp_modules).
|
||||
self._running_version: str | None = None
|
||||
# Startup phase marker (plain attribute writes: init markers from the
|
||||
# main thread, _note_startup_phase transitions from the worker). Read
|
||||
# by the readiness poll for progress detection and error messages.
|
||||
self._startup_phase: str = "not started"
|
||||
|
||||
@property
|
||||
def port(self) -> int:
|
||||
@@ -233,45 +310,39 @@ class EmbeddedServerManager:
|
||||
kind="package",
|
||||
)
|
||||
|
||||
await self._async_ensure_package()
|
||||
# Read the importer registry BEFORE the package step too: replacing
|
||||
# the distribution's files on disk under a live importer corrupts it
|
||||
# exactly like the sys.modules purge does (review finding), so a
|
||||
# mutating install is deferred while any registered worker is alive.
|
||||
ready_version = await self._async_ensure_package(
|
||||
defer_mutations=_prune_and_check_importing_workers()
|
||||
)
|
||||
access_token = await self._async_provision_token()
|
||||
await self._hass.async_add_executor_job(self._prepare_config_dir)
|
||||
|
||||
# Drop cached ha_mcp modules so the worker imports the code that is on
|
||||
# disk NOW. Without this, a reload after a pip install keeps serving
|
||||
# the OLD code forever: all workers are threads of the one HA core
|
||||
# process, and Python resolves ``import ha_mcp`` from sys.modules —
|
||||
# installs only took effect after a full HA core restart (issue
|
||||
# observed live: options saves reinstalled the package, the web UI
|
||||
# footer showed the new on-disk version, yet the serving worker kept
|
||||
# reporting the version it was first imported with).
|
||||
#
|
||||
# SKIPPED while an orphaned worker may still be importing: ripping
|
||||
# entries out of sys.modules under a live importer corrupts its
|
||||
# import in progress (seen on QEMU-slow HAOS, where a cold import
|
||||
# can outlive both the readiness timeout and the stop-join budget).
|
||||
# The post-start staleness check below surfaces the consequence
|
||||
# (old code possibly serving) instead.
|
||||
orphan = self._orphaned_thread
|
||||
if orphan is not None and not orphan.is_alive():
|
||||
self._orphaned_thread = orphan = None
|
||||
if orphan is None:
|
||||
_purge_ha_mcp_modules()
|
||||
else:
|
||||
_LOGGER.warning(
|
||||
"Skipping the ha_mcp module purge: a previous worker thread "
|
||||
"is still shutting down. The new worker may serve the "
|
||||
"previously imported code until Home Assistant restarts."
|
||||
)
|
||||
self._maybe_purge_stale_modules(ready_version)
|
||||
|
||||
self._thread_exc = None
|
||||
self._startup_phase = "waiting for the worker thread"
|
||||
self._thread = threading.Thread(
|
||||
target=self._thread_main,
|
||||
args=(access_token,),
|
||||
name="ha-mcp-server",
|
||||
daemon=True,
|
||||
)
|
||||
self._thread.start()
|
||||
# Registered by THIS (main) thread before start(): every bring-up
|
||||
# runs its gate → register → start section synchronously on the one
|
||||
# event loop, so another bring-up's purge gate can never interleave
|
||||
# with a worker that exists but has not yet registered itself
|
||||
# (review finding). The worker itself only ever deregisters.
|
||||
with _IMPORTING_WORKERS_LOCK:
|
||||
_IMPORTING_WORKERS.add(self._thread)
|
||||
try:
|
||||
self._thread.start()
|
||||
except BaseException:
|
||||
with _IMPORTING_WORKERS_LOCK:
|
||||
_IMPORTING_WORKERS.discard(self._thread)
|
||||
raise
|
||||
|
||||
await self._async_wait_until_ready()
|
||||
|
||||
@@ -402,9 +473,171 @@ class EmbeddedServerManager:
|
||||
return None
|
||||
return DIST_NAME_STABLE if self._channel == CHANNEL_DEV else DIST_NAME_DEV
|
||||
|
||||
async def _async_ensure_package(self) -> None:
|
||||
def _maybe_purge_stale_modules(self, ready_version: str | None) -> None:
|
||||
"""Purge cached ha_mcp modules unless doing so is unsafe or pointless.
|
||||
|
||||
The purge makes the next worker import the code that is on disk NOW.
|
||||
Without it, a reload after a pip install keeps serving the OLD code
|
||||
forever: all workers are threads of the one HA core process, and
|
||||
Python resolves ``import ha_mcp`` from sys.modules — installs only
|
||||
took effect after a full HA core restart (observed live: options
|
||||
saves reinstalled the package, the web UI footer showed the new
|
||||
on-disk version, yet the serving worker kept reporting the version
|
||||
it was first imported with).
|
||||
|
||||
SKIPPED while any previous worker may still be importing — ripping
|
||||
entries out of sys.modules under a live importer corrupts its import
|
||||
in progress. Two guards cover that: the per-manager orphan (a worker
|
||||
this manager's stop could not join), and the process-global
|
||||
``_IMPORTING_WORKERS`` registry, because every bring-up constructs a
|
||||
FRESH manager (async_bring_up_server) and an entry reload during a
|
||||
slow cold import otherwise hands the purge to a manager that has
|
||||
never heard of the still-importing worker — which then crashed
|
||||
mid-import with KeyError: 'ha_mcp.config' (issue #1904, live on
|
||||
1.1.1-dev.107). The post-start staleness check surfaces the
|
||||
consequence (old code possibly serving) instead.
|
||||
|
||||
Also skipped when the cached modules already ARE the generation on
|
||||
disk: purging on every attempt made each retry pay the full cold
|
||||
import again, so slow hardware that missed the readiness window once
|
||||
could never recover (#1904). Never skipped under a pip-spec override
|
||||
— the one workflow where a reinstall can change the code without
|
||||
changing the version string (re-pointed tarball/pin), which a
|
||||
version-keyed skip would serve stale; channel installs mint a
|
||||
distinct version per build.
|
||||
"""
|
||||
orphan = self._orphaned_thread
|
||||
if orphan is not None and not orphan.is_alive():
|
||||
self._orphaned_thread = orphan = None
|
||||
importing_busy = _prune_and_check_importing_workers()
|
||||
if orphan is not None:
|
||||
_LOGGER.warning(
|
||||
"Skipping the ha_mcp module purge: a previous worker thread "
|
||||
"is still shutting down. The new worker may serve the "
|
||||
"previously imported code until Home Assistant restarts."
|
||||
)
|
||||
elif importing_busy:
|
||||
# Distinct from the orphan message: that worker is still STARTING
|
||||
# (mid cold-import, the #1904 incident shape), not shutting down —
|
||||
# naming the actual state matters when reading logs during one.
|
||||
_LOGGER.warning(
|
||||
"Skipping the ha_mcp module purge: a previous bring-up's "
|
||||
"worker thread is still importing. The new worker may serve "
|
||||
"the previously imported code until Home Assistant restarts."
|
||||
)
|
||||
elif (
|
||||
not self._pip_spec_override
|
||||
and ready_version is not None
|
||||
and _CACHED_IMPORT_VERSION is not None
|
||||
and ready_version == _CACHED_IMPORT_VERSION
|
||||
):
|
||||
_LOGGER.debug(
|
||||
"Cached ha_mcp modules already match installed version %s; "
|
||||
"skipping the module purge (warm start)",
|
||||
ready_version,
|
||||
)
|
||||
else:
|
||||
_purge_ha_mcp_modules()
|
||||
|
||||
async def _async_run_tracked_install_job(
|
||||
self, func: Callable[[], object]
|
||||
) -> object:
|
||||
"""Run a package-mutating executor job, tracked process-wide.
|
||||
|
||||
Registration happens BEFORE dispatch and completion is signalled on
|
||||
the executor thread in a finally, so a bring-up cancelled mid-job
|
||||
(install or uninstall) leaves behind a waitable job instead of an
|
||||
invisible one.
|
||||
"""
|
||||
global _PENDING_INSTALL_DONE
|
||||
done = threading.Event()
|
||||
with _PENDING_INSTALL_LOCK:
|
||||
_PENDING_INSTALL_DONE = done
|
||||
|
||||
def _run() -> object:
|
||||
global _PENDING_INSTALL_DONE
|
||||
try:
|
||||
return func()
|
||||
finally:
|
||||
done.set()
|
||||
with _PENDING_INSTALL_LOCK:
|
||||
# A newer job may already have replaced the slot.
|
||||
if _PENDING_INSTALL_DONE is done:
|
||||
_PENDING_INSTALL_DONE = None
|
||||
|
||||
try:
|
||||
# Shielded: cancelling the awaiter must NOT cancel the executor
|
||||
# job. Unshielded, a cancel landing while the job is still
|
||||
# QUEUED removes it from the pool — _run never starts, nothing
|
||||
# ever sets the event, and the next bring-up waits the full
|
||||
# budget on a job that does not exist (review finding). With the
|
||||
# shield, _run always runs and its finally always fires; the
|
||||
# awaiter still detaches immediately on cancel.
|
||||
return await asyncio.shield(self._hass.async_add_executor_job(_run))
|
||||
except asyncio.CancelledError:
|
||||
# The job is queued or running and its finally will clear the
|
||||
# slot; leaving it registered is the whole point — the next
|
||||
# bring-up must wait it out.
|
||||
raise
|
||||
except BaseException:
|
||||
# A DISPATCH failure (e.g. executor already shut down) means
|
||||
# _run never ran and nothing will ever set the event — clear our
|
||||
# own registration or the next bring-up waits the full budget on
|
||||
# a job that does not exist. The identity check makes this a
|
||||
# no-op if _run did run and already cleaned up.
|
||||
with _PENDING_INSTALL_LOCK:
|
||||
if _PENDING_INSTALL_DONE is done:
|
||||
_PENDING_INSTALL_DONE = None
|
||||
raise
|
||||
|
||||
async def _async_wait_for_pending_install(self) -> None:
|
||||
"""Wait out an install job orphaned by a cancelled previous bring-up.
|
||||
|
||||
Raises :class:`EmbeddedServerError` if it is still running after the
|
||||
bounded wait — mutating the package (or importing from it) underneath
|
||||
a live pip job is the same corruption class as purging sys.modules
|
||||
under a live importer.
|
||||
|
||||
The wait occupies one pooled executor thread (bounded): the setter
|
||||
runs on an executor thread with no handle to this loop, so a
|
||||
loop-side wakeup would need cross-thread plumbing this rare recovery
|
||||
path does not justify.
|
||||
"""
|
||||
with _PENDING_INSTALL_LOCK:
|
||||
pending = _PENDING_INSTALL_DONE
|
||||
if pending is None or pending.is_set():
|
||||
return
|
||||
_LOGGER.warning(
|
||||
"A previous bring-up's install job is still running on the "
|
||||
"executor (the bring-up was cancelled but pip cannot be); "
|
||||
"waiting for it to finish before touching the ha-mcp package."
|
||||
)
|
||||
finished = await self._hass.async_add_executor_job(
|
||||
pending.wait, _PENDING_INSTALL_WAIT_SECONDS
|
||||
)
|
||||
if not finished:
|
||||
raise EmbeddedServerError(
|
||||
f"A previous install job was still running after "
|
||||
f"{_PENDING_INSTALL_WAIT_SECONDS:.0f}s; refusing to modify "
|
||||
"the ha-mcp package underneath it. Reload the integration "
|
||||
"to retry.",
|
||||
kind="package",
|
||||
)
|
||||
|
||||
async def _async_ensure_package(
|
||||
self, *, defer_mutations: bool = False
|
||||
) -> str | None:
|
||||
"""Ensure ``ha-mcp`` is importable, installing the pip spec if needed.
|
||||
|
||||
Returns the installed version that the worker is about to run, for the
|
||||
caller's warm-cache purge decision.
|
||||
|
||||
``defer_mutations=True`` (a previous bring-up's worker is still
|
||||
importing) downgrades any would-be uninstall/force-install to the
|
||||
non-mutating fast path: replacing the distribution's files on disk
|
||||
under a live importer corrupts it the same way a sys.modules purge
|
||||
does. The deferred update applies on the next reload or HA restart.
|
||||
|
||||
With auto-update on (the default) both channels install their
|
||||
distribution UNPINNED, so every entry reload / HA restart must pick up
|
||||
the newest build. Such a spec ALWAYS takes the force-install path
|
||||
@@ -419,9 +652,12 @@ class EmbeddedServerManager:
|
||||
When that spec matches the one last installed and the package imports,
|
||||
delegate the "already satisfied?" decision to Home Assistant's
|
||||
requirements manager; a pinned spec does not move, so there is nothing to
|
||||
upgrade to. A CHANGED spec (a new override, a toggled auto-update, a
|
||||
channel switch) still falls through to the force-install path below so
|
||||
the change actually takes effect.
|
||||
upgrade to. A CHANGED spec (a new override, a cleared override, a
|
||||
toggled auto-update, a channel switch) falls through to the
|
||||
force-install path below — and additionally uninstalls the replaced
|
||||
distribution first (:meth:`_async_remove_replaced_source`), because
|
||||
``upgrade=True`` alone decides by version and a changed SOURCE can keep
|
||||
the version string (issue #1914).
|
||||
|
||||
On a channel switch the other channel's distribution is uninstalled first
|
||||
(:meth:`_async_remove_conflicting_dist`): ``ha-mcp`` and ``ha-mcp-dev``
|
||||
@@ -437,14 +673,14 @@ class EmbeddedServerManager:
|
||||
Never imports ``ha_mcp`` in this (main) process — that happens only inside
|
||||
the worker thread.
|
||||
"""
|
||||
await self._async_wait_for_pending_install()
|
||||
|
||||
stored_spec = self._entry.data.get(DATA_LAST_PIP_SPEC)
|
||||
installed_version = await self._hass.async_add_executor_job(
|
||||
_installed_ha_mcp_version
|
||||
)
|
||||
|
||||
pending_version = str(
|
||||
self._entry.data.get(DATA_PENDING_INSTALL_VERSION) or ""
|
||||
).strip()
|
||||
pending_version = self._pending_install_version(defer_mutations)
|
||||
target_dist = dist_for_channel(self._channel)
|
||||
if not self._pip_spec_override and pending_version:
|
||||
# Pin to the requested version. Its own value differs from
|
||||
@@ -498,13 +734,19 @@ class EmbeddedServerManager:
|
||||
and installed_version is not None
|
||||
and _is_compatible_embedded_version(installed_version)
|
||||
)
|
||||
deferred = False
|
||||
if fast_path_ok:
|
||||
await self._async_process_requirements_fast()
|
||||
elif defer_mutations:
|
||||
deferred = True
|
||||
await self._async_defer_package_mutations(installed_version)
|
||||
else:
|
||||
await self._async_remove_conflicting_dist()
|
||||
await self._async_remove_legacy_target(target_dist, installed_version)
|
||||
await self._async_remove_replaced_source(stored_spec, installed_version)
|
||||
await self._async_force_install()
|
||||
|
||||
version: str | None
|
||||
if not self._pip_spec_override and self._channel == CHANNEL_DEV:
|
||||
version = await self._hass.async_add_executor_job(
|
||||
_installed_ha_mcp_version, target_dist
|
||||
@@ -527,8 +769,13 @@ class EmbeddedServerManager:
|
||||
kind="package",
|
||||
)
|
||||
_LOGGER.info("HA-MCP in-process server package ready (version %s)", version)
|
||||
if stored_spec != self._pip_spec:
|
||||
# A DEFERRED spec change must not be recorded as installed: with the
|
||||
# stored spec advanced, the next reload would see "unchanged", take the
|
||||
# fast path (for a stable spec) and skip the replaced-source uninstall,
|
||||
# so the deferred change would silently never apply.
|
||||
if not deferred and stored_spec != self._pip_spec:
|
||||
self._store_installed_spec()
|
||||
return version
|
||||
|
||||
async def _async_remove_legacy_target(
|
||||
self, target_dist: str, installed_version: str | None
|
||||
@@ -555,6 +802,165 @@ class EmbeddedServerManager:
|
||||
)
|
||||
await self._async_remove_distribution(target_dist)
|
||||
|
||||
def _pending_install_version(self, defer_mutations: bool) -> str:
|
||||
"""Return the update entity's pending-install version, or ``""``.
|
||||
|
||||
Always empty while mutations are deferred, leaving the marker in
|
||||
``entry.data`` untouched: the deferred branch runs no install, and
|
||||
consuming the marker without an attempt would lose the user's Install
|
||||
click entirely (with auto-update off, the next reload re-pins to the
|
||||
OLD installed version). One-shot means one real ATTEMPT — a deferred
|
||||
bring-up never attempts, so the marker survives to the next
|
||||
undeferred reload (review finding on #1923).
|
||||
"""
|
||||
if defer_mutations:
|
||||
return ""
|
||||
return str(self._entry.data.get(DATA_PENDING_INSTALL_VERSION) or "").strip()
|
||||
|
||||
async def _async_defer_package_mutations(
|
||||
self, installed_version: str | None
|
||||
) -> None:
|
||||
"""Handle the defer-mutations branch of :meth:`_async_ensure_package`.
|
||||
|
||||
A previous bring-up's worker is still importing, so the package files
|
||||
must not be replaced under it. With an importable build on disk
|
||||
(``installed_version`` non-None — importability only, compatibility is
|
||||
checked by the caller afterwards) nothing is touched at all — not even
|
||||
the requirements manager, which installs any unsatisfied spec and
|
||||
would mutate exactly like the deferred force install. When nothing
|
||||
imports there are no distribution files to replace under the live
|
||||
importer, and without an install this bring-up cannot produce a
|
||||
server at all, so the requirements manager still runs.
|
||||
"""
|
||||
_LOGGER.warning(
|
||||
"Deferring the ha-mcp install/upgrade: a previous bring-up's "
|
||||
"worker thread is still importing, and replacing the package "
|
||||
"files under it could corrupt that import. The currently "
|
||||
"installed build will be used; reload the integration (or "
|
||||
"restart Home Assistant) to apply the update."
|
||||
)
|
||||
if installed_version is None:
|
||||
await self._async_process_requirements_fast()
|
||||
|
||||
def _replaced_dist_name(self) -> str | None:
|
||||
"""Return the distribution whose presence could no-op the new spec.
|
||||
|
||||
This is the distribution the effective spec installs *by name* — the
|
||||
channel's distribution for a channel spec, or the named distribution
|
||||
of an override that parses as a requirement (a pin like
|
||||
``ha-mcp==X``, matched against the two known channel dists). It is
|
||||
deliberately NOT the channel's dist for every override: a repo
|
||||
tarball installs as ``ha-mcp`` regardless of the selected channel, so
|
||||
keying on the channel would miss the dev-channel + override case.
|
||||
|
||||
Returns None for an override that names an unknown distribution or
|
||||
does not parse as a requirement at all (a direct URL): the installer
|
||||
re-fetches and rebuilds URL requirements under ``upgrade=True``
|
||||
regardless of the installed version, so a URL install is already
|
||||
real and nothing needs removing.
|
||||
"""
|
||||
if not self._pip_spec_override:
|
||||
return dist_for_channel(self._channel)
|
||||
try:
|
||||
name = canonicalize_name(Requirement(self._pip_spec_override).name)
|
||||
except InvalidRequirement:
|
||||
return None
|
||||
for dist_name in (DIST_NAME_STABLE, DIST_NAME_DEV):
|
||||
if name == canonicalize_name(dist_name):
|
||||
return dist_name
|
||||
return None
|
||||
|
||||
async def _async_remove_replaced_source(
|
||||
self, stored_spec: str | None, installed_version: str | None
|
||||
) -> None:
|
||||
"""Uninstall the replaced distribution when the requested source changed.
|
||||
|
||||
The forced install that follows relies on ``upgrade=True``, and the
|
||||
installer decides "already satisfied" by VERSION alone — but a source
|
||||
change can keep the version string. A PR branch's committed
|
||||
``project.version`` equals the release it branched from (only release
|
||||
automation bumps it), so its tarball installs with that same version
|
||||
string; clearing the override then resolves the channel spec to the
|
||||
exact version already on disk and the install swaps nothing, leaving
|
||||
the PR code running while the entry reports a clean channel install
|
||||
(issue #1914). The same version-blindness bites a manual spec edit
|
||||
that pins the version already installed. The installer cannot see the
|
||||
difference, so when the spec that produced the current install
|
||||
differs from the one about to be installed, the distribution the new
|
||||
spec resolves to by name (:meth:`_replaced_dist_name`) is removed
|
||||
first — the install that follows is then unconditionally real.
|
||||
|
||||
Skipped when nothing is installed, when the last-installed spec is
|
||||
unknown (nothing to compare: first install, or entry data predating
|
||||
the spec tracking), when the spec is unchanged (the routine
|
||||
reload/restart path, where ``upgrade=True`` alone is correct and an
|
||||
uninstall would churn — and briefly break — a healthy install on
|
||||
every restart), when the new spec is a direct URL (always installs
|
||||
for real), when the named distribution is not installed (e.g. a
|
||||
cross-channel switch already removed it), when the stored spec is
|
||||
an index requirement on the SAME distribution (a repin — e.g.
|
||||
toggling auto-update rewrites bare ``ha-mcp`` to ``ha-mcp==X`` —
|
||||
draws from the same index either way, so version resolution is
|
||||
faithful and uninstalling a healthy install on a preference toggle
|
||||
would only add an offline-breakage window), or when the new spec is
|
||||
an exact pin on a version provably different from the installed one
|
||||
(the install cannot no-op, so the working build stays in place as
|
||||
the fallback if it fails).
|
||||
|
||||
Unlike the other pre-install uninstalls this one is NOT best-effort:
|
||||
if the distribution survives a failed uninstall, the forced install
|
||||
would no-op as "already satisfied", the new spec would be persisted,
|
||||
and the next reload would see it as unchanged — reproducing #1914 and
|
||||
then permanently masking it. Raising instead keeps the stored spec on
|
||||
the old value, so the next reload retries the whole source change.
|
||||
"""
|
||||
if installed_version is None or stored_spec is None:
|
||||
return
|
||||
if stored_spec == self._pip_spec:
|
||||
return
|
||||
replaced_dist = self._replaced_dist_name()
|
||||
if replaced_dist is None:
|
||||
return
|
||||
if _spec_is_index_requirement_on(stored_spec, replaced_dist):
|
||||
# Same distribution, same index — only the pin changed. The old
|
||||
# code on disk came from the index too, so "already satisfied by
|
||||
# version" is the truth, not the #1914 lie.
|
||||
return
|
||||
pinned = _exact_pinned_version(self._pip_spec)
|
||||
if pinned is not None:
|
||||
try:
|
||||
version_moves = Version(pinned) != Version(installed_version)
|
||||
except InvalidVersion:
|
||||
version_moves = False # unprovable — keep the uninstall
|
||||
if version_moves:
|
||||
# The new pin cannot be satisfied by the installed version, so
|
||||
# the forced install is guaranteed to be real without any
|
||||
# uninstall — and keeping the working build in place preserves
|
||||
# it as the fallback if that install fails (e.g. offline).
|
||||
return
|
||||
if not await self._hass.async_add_executor_job(_dist_installed, replaced_dist):
|
||||
return
|
||||
_LOGGER.info(
|
||||
"The requested server source changed (%r -> %r); removing the "
|
||||
"installed %r first so the reinstall cannot be skipped as "
|
||||
"already satisfied",
|
||||
stored_spec,
|
||||
self._pip_spec,
|
||||
replaced_dist,
|
||||
)
|
||||
removed = await self._async_remove_distribution(replaced_dist)
|
||||
if not removed and await self._hass.async_add_executor_job(
|
||||
_dist_installed, replaced_dist
|
||||
):
|
||||
raise EmbeddedServerError(
|
||||
f"Could not remove the installed {replaced_dist!r} (from "
|
||||
f"{stored_spec!r}) before installing {self._pip_spec!r}: the "
|
||||
"installer would report the new source as already satisfied "
|
||||
"and keep the old code running. Uninstall details are logged "
|
||||
"above; reload the integration to retry the source change.",
|
||||
kind="package",
|
||||
)
|
||||
|
||||
async def _async_process_requirements_fast(self) -> None:
|
||||
"""Fast path: let HA's requirements manager satisfy the override spec."""
|
||||
try:
|
||||
@@ -583,7 +989,7 @@ class EmbeddedServerManager:
|
||||
kwargs["timeout"] = max(
|
||||
int(kwargs.get("timeout") or 0), _PIP_INSTALL_TIMEOUT_SECONDS
|
||||
)
|
||||
installed = await self._hass.async_add_executor_job(
|
||||
installed = await self._async_run_tracked_install_job(
|
||||
partial(install_package, self._pip_spec, upgrade=True, **kwargs)
|
||||
)
|
||||
if not installed:
|
||||
@@ -624,15 +1030,25 @@ class EmbeddedServerManager:
|
||||
)
|
||||
await self._async_remove_distribution(other)
|
||||
|
||||
async def _async_remove_distribution(self, dist_name: str) -> None:
|
||||
"""Remove a distribution from the same target used for installation."""
|
||||
async def _async_remove_distribution(self, dist_name: str) -> bool:
|
||||
"""Remove a distribution from the same target used for installation.
|
||||
|
||||
Tracked like the install: an uninstall mutates the same package files.
|
||||
Returns whether the uninstall subprocess reported success; callers
|
||||
decide whether a failure is best-effort (channel-conflict / legacy
|
||||
cleanup) or fatal (the replaced-source removal, whose failure would
|
||||
silently void the reinstall — see ``_async_remove_replaced_source``).
|
||||
"""
|
||||
target = pip_kwargs(self._hass.config.config_dir).get("target")
|
||||
if target is None:
|
||||
await self._hass.async_add_executor_job(_uninstall_distribution, dist_name)
|
||||
result = await self._async_run_tracked_install_job(
|
||||
partial(_uninstall_distribution, dist_name)
|
||||
)
|
||||
else:
|
||||
await self._hass.async_add_executor_job(
|
||||
result = await self._async_run_tracked_install_job(
|
||||
partial(_uninstall_distribution, dist_name, target=target)
|
||||
)
|
||||
return bool(result)
|
||||
|
||||
def _store_installed_spec(self) -> None:
|
||||
"""Persist the pip spec just installed so a restart skips the reinstall."""
|
||||
@@ -724,6 +1140,15 @@ class EmbeddedServerManager:
|
||||
|
||||
# -- worker thread -----------------------------------------------------
|
||||
|
||||
def _note_startup_phase(self, phase: str) -> None:
|
||||
"""Publish the worker's startup phase (a plain attribute write).
|
||||
|
||||
Read by the readiness poll: a phase advance counts as progress, and the
|
||||
failure message names the phase the worker was last seen in.
|
||||
"""
|
||||
self._startup_phase = phase
|
||||
_LOGGER.debug("HA-MCP in-process server startup: %s", phase)
|
||||
|
||||
def _thread_main(self, access_token: str) -> None:
|
||||
"""Thread entry point: stage non-secret env, then run the server.
|
||||
|
||||
@@ -744,12 +1169,38 @@ class EmbeddedServerManager:
|
||||
stop_event = asyncio.Event()
|
||||
self._loop = loop
|
||||
self._stop_event = stop_event
|
||||
# Registration in _IMPORTING_WORKERS happened on the MAIN thread,
|
||||
# before start() — see async_start. This thread only deregisters:
|
||||
# in _serve once the import section completes, and in the finally
|
||||
# below on exit as the backstop.
|
||||
try:
|
||||
loop.run_until_complete(self._serve(access_token, stop_event))
|
||||
except SystemExit as err:
|
||||
# uvicorn signals a startup failure (e.g. the port is already in
|
||||
# use) with SystemExit(STARTUP_FAILURE), which ``except Exception``
|
||||
# misses — live issue #1904 saw the real bind error surface only
|
||||
# in HA's generic task-exception log while the component reported
|
||||
# a bare readiness timeout. Unwrap the original error so the
|
||||
# repair issue names the actual cause; a bare SystemExit (no
|
||||
# chained exception) is reported by repr so an empty/zero exit
|
||||
# code still reads as what it is. The phase names where in
|
||||
# _serve the exit happened instead of hardcoding a bind failure.
|
||||
cause = err.__context__ or err.__cause__
|
||||
detail = str(cause) if cause is not None else repr(err)
|
||||
self._thread_exc = EmbeddedServerError(
|
||||
f"the server exited during startup ({self._startup_phase}): {detail}"
|
||||
)
|
||||
_LOGGER.error(
|
||||
"HA-MCP in-process server exited during startup (%s): %s",
|
||||
self._startup_phase,
|
||||
detail,
|
||||
)
|
||||
except Exception as err:
|
||||
self._thread_exc = err
|
||||
_LOGGER.exception("HA-MCP in-process server thread crashed")
|
||||
finally:
|
||||
with _IMPORTING_WORKERS_LOCK:
|
||||
_IMPORTING_WORKERS.discard(threading.current_thread())
|
||||
# Teardown is best-effort but never SILENT (review finding): a
|
||||
# raise here must not mask the primary outcome, yet a recurring
|
||||
# cleanup failure (leaking executor threads across reloads) has
|
||||
@@ -779,6 +1230,7 @@ class EmbeddedServerManager:
|
||||
# Hand ha-mcp the loopback URL + provisioned admin token in memory, before
|
||||
# the server (and its settings singleton) is built. Keeping the token out
|
||||
# of os.environ is the whole point of the in-process channel.
|
||||
self._note_startup_phase("importing the server package")
|
||||
import ha_mcp.config as _hamcp_config
|
||||
|
||||
# Record which code generation this worker imported. Prefer the
|
||||
@@ -786,6 +1238,11 @@ class EmbeddedServerManager:
|
||||
# ha_mcp.__version__ itself checks stable first and stale stable
|
||||
# metadata can otherwise make a fresh dev worker look outdated.
|
||||
self._running_version = _running_ha_mcp_version(self._channel)
|
||||
# The cache in sys.modules now holds this generation — remembered
|
||||
# process-wide so the next start can skip the purge when the install
|
||||
# has not changed (issue #1904).
|
||||
global _CACHED_IMPORT_VERSION
|
||||
_CACHED_IMPORT_VERSION = self._running_version
|
||||
|
||||
# Drop any settings singleton cached by a PREVIOUS start in this same
|
||||
# Python process: an entry reload must re-read the override files
|
||||
@@ -805,12 +1262,33 @@ class EmbeddedServerManager:
|
||||
"entry may serve stale override values until HA restarts"
|
||||
)
|
||||
|
||||
_hamcp_config.set_embedded_connection(self._server_url, access_token)
|
||||
if self._loopback_verify_ssl is None:
|
||||
_hamcp_config.set_embedded_connection(self._server_url, access_token)
|
||||
else:
|
||||
try:
|
||||
_hamcp_config.set_embedded_connection(
|
||||
self._server_url,
|
||||
access_token,
|
||||
verify_ssl=self._loopback_verify_ssl,
|
||||
)
|
||||
except TypeError:
|
||||
# Server predates the verify_ssl parameter (< the release
|
||||
# carrying issue #1890's fix). Register url+token the old way;
|
||||
# on an SSL-enabled instance the wss loopback will fail
|
||||
# certificate verification until the server package updates —
|
||||
# no worse than the plaintext failure it replaces.
|
||||
_LOGGER.warning(
|
||||
"Installed ha-mcp server does not accept verify_ssl for "
|
||||
"the embedded connection; loopback TLS verification stays "
|
||||
"enabled until the server package updates"
|
||||
)
|
||||
_hamcp_config.set_embedded_connection(self._server_url, access_token)
|
||||
|
||||
# Imported here, in the worker thread, after the connection is registered.
|
||||
from ha_mcp.server import HomeAssistantSmartMCPServer
|
||||
from ha_mcp.settings_ui import register_settings_routes
|
||||
|
||||
self._note_startup_phase("building the server")
|
||||
server = HomeAssistantSmartMCPServer()
|
||||
|
||||
# Startup observability (no secrets): confirm the in-memory connection
|
||||
@@ -849,6 +1327,7 @@ class EmbeddedServerManager:
|
||||
|
||||
# Parity with the CLI HTTP runner: serve the web settings UI under the
|
||||
# same secret path as the MCP endpoint.
|
||||
self._note_startup_phase("registering web routes")
|
||||
register_settings_routes(server.mcp, server, secret_path=self._secret_path)
|
||||
|
||||
# Parity with the CLI HTTP runner: answer a browser GET on the MCP path
|
||||
@@ -891,6 +1370,16 @@ class EmbeddedServerManager:
|
||||
else:
|
||||
ensure_host_origin_guard_default_off()
|
||||
|
||||
# The cold import — the multi-minute window that crashed in #1904 —
|
||||
# is complete: every explicit ha_mcp import in _serve precedes this
|
||||
# line. Leave the registry so a long-running healthy server never
|
||||
# blocks later bring-ups' purges (removal from sys.modules cannot
|
||||
# unload already-bound modules; only in-flight imports are
|
||||
# corruptible, and any later lazy import is outside the window this
|
||||
# registry protects).
|
||||
with _IMPORTING_WORKERS_LOCK:
|
||||
_IMPORTING_WORKERS.discard(threading.current_thread())
|
||||
|
||||
app = server.mcp.http_app(path=self._secret_path, stateless_http=True)
|
||||
config = uvicorn.Config(
|
||||
app,
|
||||
@@ -905,6 +1394,7 @@ class EmbeddedServerManager:
|
||||
)
|
||||
uv_server = uvicorn.Server(config)
|
||||
|
||||
self._note_startup_phase("starting the HTTP listener")
|
||||
stop_task = asyncio.create_task(stop_event.wait())
|
||||
async with server.mcp._lifespan_manager():
|
||||
serve_task = asyncio.create_task(uv_server.serve())
|
||||
@@ -924,15 +1414,37 @@ class EmbeddedServerManager:
|
||||
# Surface a server that exited on its own (bind failure, etc.).
|
||||
serve_task.result()
|
||||
|
||||
def _progress_signature(self) -> tuple[int, str]:
|
||||
"""Snapshot the observable startup progress of the worker thread.
|
||||
|
||||
``len(sys.modules)`` moves continuously while the worker grinds
|
||||
through a cold import (the single longest startup step — minutes on
|
||||
slow hardware, issue #1904), and the published phase moves between
|
||||
steps. Any change in the pair counts as progress. The module count is
|
||||
PROCESS-wide — an approximation: nothing finer-grained is observable
|
||||
from outside a thread stuck inside one ``import`` statement, and any
|
||||
other HA thread importing concurrently also refreshes the stall
|
||||
budget. Erring toward patience is the point; the absolute cap bounds
|
||||
the wait regardless.
|
||||
"""
|
||||
return (len(sys.modules), self._startup_phase)
|
||||
|
||||
async def _async_wait_until_ready(self) -> None:
|
||||
"""Poll a loopback TCP connect until the server accepts, or fail.
|
||||
|
||||
On failure (timeout or an early thread crash) stops the thread and raises
|
||||
Patience is progress-based: the wait only gives up when there is no
|
||||
observable progress for ``_READY_STALL_TIMEOUT_SECONDS`` (or the
|
||||
absolute ``_READY_TOTAL_CAP_SECONDS`` ceiling is hit). A slow cold
|
||||
import keeps the wait alive; a wedged worker is caught by the stall
|
||||
budget on a quiet instance, by the cap at the latest (the progress
|
||||
signal is process-wide). On failure stops the thread and raises
|
||||
:class:`EmbeddedServerError` so the caller leaves the webhook
|
||||
unregistered and files a repair issue.
|
||||
"""
|
||||
deadline = self._hass.loop.time() + _READY_TIMEOUT_SECONDS
|
||||
while self._hass.loop.time() < deadline:
|
||||
start = self._hass.loop.time()
|
||||
last_progress = start
|
||||
last_signature = self._progress_signature()
|
||||
while True:
|
||||
if self._thread_exc is not None:
|
||||
raise EmbeddedServerError(
|
||||
f"HA-MCP in-process server failed to start: {self._thread_exc}"
|
||||
@@ -948,15 +1460,32 @@ class EmbeddedServerManager:
|
||||
self._port,
|
||||
)
|
||||
return
|
||||
now = self._hass.loop.time()
|
||||
signature = self._progress_signature()
|
||||
if signature != last_signature:
|
||||
last_signature = signature
|
||||
last_progress = now
|
||||
if now - start >= _READY_TOTAL_CAP_SECONDS:
|
||||
failure = (
|
||||
f"HA-MCP in-process server did not become reachable on "
|
||||
f"port {self._port} within {_READY_TOTAL_CAP_SECONDS:.0f}s "
|
||||
f"(last startup phase: {self._startup_phase})."
|
||||
)
|
||||
break
|
||||
if now - last_progress >= _READY_STALL_TIMEOUT_SECONDS:
|
||||
failure = (
|
||||
f"HA-MCP in-process server did not become reachable on "
|
||||
f"port {self._port}: no startup progress observed for "
|
||||
f"{_READY_STALL_TIMEOUT_SECONDS:.0f}s (last phase: "
|
||||
f"{self._startup_phase}; {now - start:.0f}s since start)."
|
||||
)
|
||||
break
|
||||
await asyncio.sleep(_READY_POLL_INTERVAL_SECONDS)
|
||||
|
||||
# Timed out — tear the thread down so we never leave a half-started
|
||||
# Gave up — tear the thread down so we never leave a half-started
|
||||
# server behind an unregistered webhook.
|
||||
await self.async_stop()
|
||||
raise EmbeddedServerError(
|
||||
f"HA-MCP in-process server did not become reachable on port "
|
||||
f"{self._port} within {_READY_TIMEOUT_SECONDS:.0f}s."
|
||||
)
|
||||
raise EmbeddedServerError(failure)
|
||||
|
||||
async def _async_probe_port(self) -> bool:
|
||||
"""Return True if a loopback TCP connection to the server port succeeds.
|
||||
@@ -977,6 +1506,68 @@ class EmbeddedServerManager:
|
||||
return True
|
||||
|
||||
|
||||
# Worker threads that may still be executing their ha_mcp imports. Purging
|
||||
# sys.modules while any of them is alive corrupts the in-flight import
|
||||
# (KeyError from frozen importlib — issue #1904). Process-global because a
|
||||
# manager is recreated on every bring-up, so per-manager orphan tracking
|
||||
# cannot see a previous manager's abandoned worker. The spawning (main)
|
||||
# thread registers the worker BEFORE start() — see async_start — and the
|
||||
# worker deregisters once its import section completes (or on thread exit,
|
||||
# whichever comes first). All access
|
||||
# goes through the lock: CPython's GIL would make the individual set ops
|
||||
# atomic, but the purge gate's prune-then-check is a composite read and the
|
||||
# lock keeps its correctness independent of GIL scheduling arguments.
|
||||
# Deliberate tradeoff: a worker wedged forever inside its import stays
|
||||
# registered and blocks every later purge until an HA core restart — evicting
|
||||
# a live importer on a timer would reintroduce the very corruption this
|
||||
# registry prevents, and the skip warning plus the post-start staleness check
|
||||
# surface the condition.
|
||||
_IMPORTING_WORKERS_LOCK = threading.Lock()
|
||||
_IMPORTING_WORKERS: set[threading.Thread] = set()
|
||||
|
||||
|
||||
def _prune_and_check_importing_workers() -> bool:
|
||||
"""Drop dead workers from the registry; return True if any live one remains."""
|
||||
with _IMPORTING_WORKERS_LOCK:
|
||||
_IMPORTING_WORKERS.difference_update(
|
||||
[t for t in _IMPORTING_WORKERS if not t.is_alive()]
|
||||
)
|
||||
return bool(_IMPORTING_WORKERS)
|
||||
|
||||
|
||||
# Completion event of the package-mutating install/uninstall job currently on
|
||||
# the executor, if any. asyncio cancellation of a bring-up detaches the
|
||||
# awaiter, but the executor job keeps running to completion — untracked, an
|
||||
# orphaned pip could swap the distribution's files under the NEXT bring-up's
|
||||
# install or its worker's cold import (found in review of PR #1911, the
|
||||
# #1904 fixes; pre-existing). The dispatching coroutine registers the event BEFORE handing
|
||||
# the job to the executor, and the executor fn sets it in a finally that
|
||||
# survives cancellation; the next bring-up waits on it before mutating
|
||||
# anything. Process-global for the same reason as _IMPORTING_WORKERS: a
|
||||
# manager is recreated on every bring-up.
|
||||
#
|
||||
# Single slot BY DESIGN: at most one tracked job can exist at a time — the
|
||||
# server entry is single-instance, an entry reload cancels-and-awaits the
|
||||
# previous bring-up before setting up, and every dispatch site sits behind
|
||||
# _async_wait_for_pending_install. A second concurrent dispatcher would
|
||||
# overwrite the slot and silently lose the older live job — keep any new
|
||||
# package-mutating call site behind the wait gate. The slot tracking wraps
|
||||
# the DIRECT-pip sites (force install, uninstalls); the fast path goes
|
||||
# through HA's requirements manager, which is behind the gate but untracked —
|
||||
# it only dispatches pip when the package is missing outright, which cannot
|
||||
# co-occur with a live orphaned job worth waiting on.
|
||||
_PENDING_INSTALL_LOCK = threading.Lock()
|
||||
_PENDING_INSTALL_DONE: threading.Event | None = None
|
||||
|
||||
|
||||
# Version of the ha_mcp generation currently cached in sys.modules — set by
|
||||
# the worker right after its first import lands, cleared by the purge. Process-wide
|
||||
# (the module cache it describes is process-wide too). Lets a retry with an
|
||||
# unchanged install keep the warm cache instead of paying the full cold import
|
||||
# again (issue #1904).
|
||||
_CACHED_IMPORT_VERSION: str | None = None
|
||||
|
||||
|
||||
def _purge_ha_mcp_modules() -> None:
|
||||
"""Drop every cached ``ha_mcp`` module so the next import loads fresh code.
|
||||
|
||||
@@ -984,11 +1575,15 @@ def _purge_ha_mcp_modules() -> None:
|
||||
Python resolves imports from the process-wide ``sys.modules`` cache — so
|
||||
after a pip install the next worker would silently reuse the OLD code
|
||||
unless the cache is purged first. Safe here because ``ha_mcp`` is pure
|
||||
Python and is only ever imported inside the (currently stopped) worker
|
||||
thread; third-party dependencies are deliberately NOT purged (they are
|
||||
shared with the rest of Home Assistant), so a dependency-version change
|
||||
still needs an HA core restart.
|
||||
Python and is only ever imported inside worker threads, and the caller's
|
||||
gate guarantees no registered worker is mid-import when this runs (a
|
||||
worker past its imports keeps its already-bound modules regardless);
|
||||
third-party dependencies are deliberately NOT purged (they are shared
|
||||
with the rest of Home Assistant), so a dependency-version change still
|
||||
needs an HA core restart.
|
||||
"""
|
||||
global _CACHED_IMPORT_VERSION
|
||||
_CACHED_IMPORT_VERSION = None
|
||||
# Snapshot the keys: sys.modules can be mutated by concurrent imports on
|
||||
# other threads mid-iteration (HA core is heavily threaded).
|
||||
purged = [
|
||||
@@ -1120,6 +1715,46 @@ def _uninstall_distribution(dist_name: str, *, target: str | None = None) -> boo
|
||||
return True
|
||||
|
||||
|
||||
def _exact_pinned_version(spec: str) -> str | None:
|
||||
"""Return the version of an exact ``==``/``===`` single-clause pin, or None.
|
||||
|
||||
Anything else — URL specs, bare names, ranges, multi-clause specifiers —
|
||||
returns None: only an exact pin lets the caller prove, without asking the
|
||||
resolver, whether the installed version could satisfy the spec. A
|
||||
wildcard pin (``==7.13.*``) is returned as-is; the caller's ``Version``
|
||||
parse rejects it, which conservatively keeps the uninstall.
|
||||
"""
|
||||
try:
|
||||
req = Requirement(spec)
|
||||
except InvalidRequirement:
|
||||
return None
|
||||
if req.url:
|
||||
return None
|
||||
clauses = list(req.specifier)
|
||||
if len(clauses) != 1 or clauses[0].operator not in ("==", "==="):
|
||||
return None
|
||||
return clauses[0].version
|
||||
|
||||
|
||||
def _spec_is_index_requirement_on(spec: str, dist_name: str) -> bool:
|
||||
"""Return whether ``spec`` is a plain index requirement on ``dist_name``.
|
||||
|
||||
True only for a PEP 508 requirement with no direct-URL part whose
|
||||
canonical name matches — i.e. a spec that installs ``dist_name`` from the
|
||||
package index (bare name or version pin). A direct URL (whether a plain
|
||||
URL string, which does not parse as a requirement, or a ``name @ url``
|
||||
form) returns False: its origin is not the index, so it is a genuine
|
||||
source change for the replaced-source check.
|
||||
"""
|
||||
try:
|
||||
req = Requirement(spec)
|
||||
except InvalidRequirement:
|
||||
return False
|
||||
if req.url:
|
||||
return False
|
||||
return canonicalize_name(req.name) == canonicalize_name(dist_name)
|
||||
|
||||
|
||||
def _is_compatible_embedded_version(version: str) -> bool:
|
||||
"""Return whether a server distribution provides the embedded API."""
|
||||
try:
|
||||
|
||||
@@ -33,6 +33,9 @@ from .const import (
|
||||
COMPONENT_MANIFEST_AT_TAG_URL,
|
||||
DATA_BRINGUP_TASK,
|
||||
DATA_MANAGER,
|
||||
DATA_OAUTH_CLIENT_ID,
|
||||
DATA_OAUTH_CLIENT_SECRET,
|
||||
DATA_OAUTH_SIGNING_KEY,
|
||||
DATA_PENDING_UPDATE_NOTIFY,
|
||||
DATA_SECRET_PATH,
|
||||
DATA_UPDATE_COORDINATOR,
|
||||
@@ -43,8 +46,8 @@ from .const import (
|
||||
DEFAULT_PIP_SPEC,
|
||||
DEFAULT_SERVER_PORT,
|
||||
DOMAIN,
|
||||
HACS_COMPONENT_URL,
|
||||
ISSUE_COMPONENT_OUTDATED,
|
||||
ISSUE_LEGACY_OAUTH_RESTART,
|
||||
ISSUE_PACKAGE_FAILED,
|
||||
ISSUE_START_FAILED,
|
||||
ISSUE_UPDATE_HELD,
|
||||
@@ -58,12 +61,16 @@ from .const import (
|
||||
OPT_PIP_SPEC,
|
||||
OPT_SERVER_PORT,
|
||||
OPT_WEBHOOK_AUTH,
|
||||
UPDATE_HOLD_DOCS_URL,
|
||||
WEBHOOK_AUTH_LEGACY,
|
||||
WEBHOOK_AUTH_NONE,
|
||||
channel_for_dist,
|
||||
)
|
||||
from .embedded_server import EmbeddedServerError, EmbeddedServerManager
|
||||
from .hacs_nudge import async_schedule_hacs_nudge
|
||||
from .llm_api import async_register_llm_api, async_unregister_llm_api
|
||||
from .mcp_webhook import async_register_webhook, async_unregister_webhook
|
||||
from .oauth_legacy import legacy_credentials_active
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
@@ -111,23 +118,51 @@ async def async_bring_up_server(hass: HomeAssistant, entry: ConfigEntry) -> None
|
||||
auth_mode = str(entry.options.get(OPT_WEBHOOK_AUTH, WEBHOOK_AUTH_NONE))
|
||||
secret_path = str(entry.data[DATA_SECRET_PATH])
|
||||
webhook_enabled = bool(entry.options.get(OPT_ENABLE_WEBHOOK, True))
|
||||
oauth_client_id = entry.data.get(DATA_OAUTH_CLIENT_ID)
|
||||
oauth_client_secret = entry.data.get(DATA_OAUTH_CLIENT_SECRET)
|
||||
# Always set up the loopback forwarding config — the sidebar settings
|
||||
# panel proxies through it (#1803); the option gates only the public
|
||||
# webhook endpoint.
|
||||
await async_register_webhook(
|
||||
# webhook endpoint. oauth_* args are ignored unless auth_mode is legacy.
|
||||
oauth_restart_needed = await async_register_webhook(
|
||||
hass,
|
||||
entry,
|
||||
port=manager.port,
|
||||
secret_path=secret_path,
|
||||
auth_mode=auth_mode,
|
||||
register_endpoint=webhook_enabled,
|
||||
oauth_client_id=oauth_client_id,
|
||||
oauth_client_secret=oauth_client_secret,
|
||||
oauth_signing_key=entry.data.get(DATA_OAUTH_SIGNING_KEY),
|
||||
)
|
||||
_async_update_legacy_oauth_issue(hass, oauth_restart_needed)
|
||||
if not webhook_enabled:
|
||||
_LOGGER.info(
|
||||
"Webhook access disabled by option - the server is local-only "
|
||||
"(direct port + sidebar panel)"
|
||||
)
|
||||
_surface_connect_urls(hass, entry, auth_mode, webhook_enabled=webhook_enabled)
|
||||
# Only surface cleartext credentials once the bound provider actually
|
||||
# serves them: while a rotation is pending restart, an old-identity
|
||||
# token still validates and can read this log (see
|
||||
# legacy_credentials_active).
|
||||
oauth_creds_active = True
|
||||
if webhook_enabled and auth_mode == WEBHOOK_AUTH_LEGACY:
|
||||
oauth_creds_active = legacy_credentials_active(
|
||||
hass,
|
||||
str(oauth_client_id or ""),
|
||||
str(oauth_client_secret or ""),
|
||||
str(entry.data.get(DATA_OAUTH_SIGNING_KEY) or ""),
|
||||
)
|
||||
_surface_connect_urls(
|
||||
hass,
|
||||
entry,
|
||||
auth_mode,
|
||||
webhook_enabled=webhook_enabled,
|
||||
extra_hosts=await async_get_lan_hosts(hass),
|
||||
oauth_client_id=oauth_client_id,
|
||||
oauth_client_secret=oauth_client_secret,
|
||||
oauth_creds_active=oauth_creds_active,
|
||||
oauth_restart_pending=oauth_restart_needed,
|
||||
)
|
||||
# Conversation-agent LLM API (#1745), gated on its option (default on).
|
||||
# Advisory: registration failures are contained inside (logged, feature
|
||||
# absent) — the running server must never be taken down by them.
|
||||
@@ -189,6 +224,87 @@ async def async_revoke_credentials_on_remove(
|
||||
await EmbeddedServerManager(hass, entry).async_revoke_credentials()
|
||||
_clear_issues(hass)
|
||||
ir.async_delete_issue(hass, DOMAIN, ISSUE_COMPONENT_OUTDATED)
|
||||
# Clear the legacy-OAuth restart repair too: it is filed only from bring-up,
|
||||
# which never runs again for a removed entry, so a restart that was still
|
||||
# pending at removal would otherwise leave a dangling warning for a server
|
||||
# that no longer exists. (Re-enabling legacy on a fresh entry re-files it.)
|
||||
ir.async_delete_issue(hass, DOMAIN, ISSUE_LEGACY_OAUTH_RESTART)
|
||||
|
||||
|
||||
async def async_get_lan_hosts(hass: HomeAssistant) -> list[str]:
|
||||
"""Every IPv4 address on an enabled network adapter, in adapter order (#1862).
|
||||
|
||||
Lets the connect-URL surfaces list one entry per interface on a
|
||||
multi-interface / multi-VLAN host, instead of only the single address
|
||||
``get_url`` resolves. IPv4 only: ``async_get_adapters`` returns a bare
|
||||
IPv6 ``address`` with a separate ``scope_id`` int, so a link-local adapter
|
||||
address is not a usable URL host without rejoining that zone id (and every
|
||||
IPv6 host additionally needs bracket-wrapping), so building those correctly
|
||||
is out of scope; the reported setups are IPv4. Best-effort: any failure
|
||||
yields an empty list so URL
|
||||
surfacing degrades to the single ``get_url`` host rather than taking down
|
||||
the caller (bring-up would otherwise file a repair issue for a display-only
|
||||
lookup).
|
||||
"""
|
||||
try:
|
||||
from homeassistant.components import network
|
||||
|
||||
adapters = await network.async_get_adapters(hass)
|
||||
# The whole extraction is inside the try (not just the fetch): a
|
||||
# malformed adapter entry must degrade to the single get_url host too,
|
||||
# never escape into async_bring_up_server's handler, which would tear
|
||||
# the running server down and file a start-failure for a display-only
|
||||
# lookup.
|
||||
return [
|
||||
ipv4["address"]
|
||||
for adapter in adapters
|
||||
if adapter["enabled"]
|
||||
for ipv4 in adapter["ipv4"]
|
||||
]
|
||||
except Exception: # display-only enumeration; must never fail the caller
|
||||
_LOGGER.warning(
|
||||
"Adapter enumeration failed; using the single resolved host",
|
||||
exc_info=True,
|
||||
)
|
||||
return []
|
||||
|
||||
|
||||
def _swap_url_host(base: str, host: str) -> str:
|
||||
"""Return ``base`` with its host replaced by ``host`` (scheme/port/path kept)."""
|
||||
parsed = urlparse(base)
|
||||
netloc = host if parsed.port is None else f"{host}:{parsed.port}"
|
||||
return parsed._replace(netloc=netloc).geturl()
|
||||
|
||||
|
||||
def _dedup_hosts(primary: str | None, extra: list[str] | None) -> list[str]:
|
||||
"""Ordered unique hosts, ``primary`` (the canonical get_url host) first."""
|
||||
ordered: list[str] = []
|
||||
for host in (primary, *(extra or [])):
|
||||
if host and host not in ordered:
|
||||
ordered.append(host)
|
||||
return ordered
|
||||
|
||||
|
||||
def _resolve_local_url(hass: HomeAssistant) -> tuple[str | None, str | None]:
|
||||
"""The get_url internal base URL and its host, or ``(None, None)``."""
|
||||
from homeassistant.helpers.network import NoURLAvailableError, get_url
|
||||
|
||||
try:
|
||||
base = get_url(hass, allow_external=False, prefer_external=False)
|
||||
except NoURLAvailableError:
|
||||
return None, None # No internal/local URL configured - hint form instead.
|
||||
return base, urlparse(base).hostname
|
||||
|
||||
|
||||
def _local_webhook_urls(
|
||||
local_base: str, local_host: str | None, lan_hosts: list[str], webhook_id: str
|
||||
) -> list[str]:
|
||||
"""One local webhook URL per LAN host (canonical ``local_base`` verbatim)."""
|
||||
return [
|
||||
f"{local_base if host == local_host else _swap_url_host(local_base, host)}"
|
||||
f"/api/webhook/{webhook_id}"
|
||||
for host in lan_hosts
|
||||
]
|
||||
|
||||
|
||||
def build_connect_urls(
|
||||
@@ -196,6 +312,7 @@ def build_connect_urls(
|
||||
entry: ConfigEntry,
|
||||
*,
|
||||
webhook_enabled: bool = True,
|
||||
extra_hosts: list[str] | None = None,
|
||||
) -> list[str]:
|
||||
"""Resolve the entry's connect URLs (webhook forms first, then direct).
|
||||
|
||||
@@ -203,9 +320,13 @@ def build_connect_urls(
|
||||
log on start-up and the entry's Configure screen (the notification
|
||||
deliberately carries none - it is visible to every signed-in user). Each
|
||||
source is best-effort: a URL that cannot be resolved is omitted.
|
||||
"""
|
||||
from homeassistant.helpers.network import NoURLAvailableError, get_url
|
||||
|
||||
``extra_hosts`` (from :func:`async_get_lan_hosts`) adds one webhook and one
|
||||
direct-access URL per additional LAN address, so a multi-interface /
|
||||
multi-VLAN host surfaces every reachable interface rather than only the
|
||||
single host ``get_url`` picks (#1862). The ``get_url`` host stays canonical
|
||||
and first; any repeat of it in ``extra_hosts`` is deduped away.
|
||||
"""
|
||||
webhook_id = entry.data.get(DATA_WEBHOOK_ID)
|
||||
urls: list[str] = []
|
||||
external = str(entry.options.get(OPT_EXTERNAL_URL) or "").rstrip("/")
|
||||
@@ -234,14 +355,15 @@ def build_connect_urls(
|
||||
except ImportError:
|
||||
pass # Cloud integration not installed (e.g. HA Core) - local URL only.
|
||||
|
||||
local_host: str | None = None
|
||||
try:
|
||||
local_base = get_url(hass, allow_external=False, prefer_external=False)
|
||||
local_host = urlparse(local_base).hostname
|
||||
if webhook_id:
|
||||
urls.append(f"{local_base}/api/webhook/{webhook_id}")
|
||||
except NoURLAvailableError:
|
||||
pass # No internal/local URL configured - fall through to the hint form.
|
||||
local_base, local_host = _resolve_local_url(hass)
|
||||
|
||||
# One entry per enabled LAN address so a multi-interface / multi-VLAN host
|
||||
# surfaces every reachable interface rather than get_url's single pick
|
||||
# (#1862). The get_url host stays canonical and first.
|
||||
lan_hosts = _dedup_hosts(local_host, extra_hosts)
|
||||
|
||||
if local_base and webhook_id:
|
||||
urls.extend(_local_webhook_urls(local_base, local_host, lan_hosts, webhook_id))
|
||||
|
||||
if not urls and webhook_id:
|
||||
urls.append(f"/api/webhook/{webhook_id} (prefix with your Home Assistant URL)")
|
||||
@@ -253,9 +375,9 @@ def build_connect_urls(
|
||||
# Direct-access URL: admin-gated surfaces only (log + Configure screen).
|
||||
# Guarded on the secret path so a missing one omits the line instead of
|
||||
# rendering a valid-looking URL without its credential segment.
|
||||
urls.append(
|
||||
f"http://{local_host or '<home-assistant-ip>'}:{port}{secret_path}"
|
||||
" (direct access)"
|
||||
urls.extend(
|
||||
f"http://{host}:{port}{secret_path} (direct access)"
|
||||
for host in lan_hosts or ["<home-assistant-ip>"]
|
||||
)
|
||||
return urls
|
||||
|
||||
@@ -266,23 +388,83 @@ def _surface_connect_urls(
|
||||
auth_mode: str,
|
||||
*,
|
||||
webhook_enabled: bool = True,
|
||||
extra_hosts: list[str] | None = None,
|
||||
oauth_client_id: str | None = None,
|
||||
oauth_client_secret: str | None = None,
|
||||
oauth_creds_active: bool = True,
|
||||
oauth_restart_pending: bool = False,
|
||||
) -> None:
|
||||
"""Log the connect URLs and (re)create a persistent notification."""
|
||||
urls = build_connect_urls(hass, entry, webhook_enabled=webhook_enabled)
|
||||
auth_note = (
|
||||
"Webhook access is disabled (local-only mode)."
|
||||
if not webhook_enabled
|
||||
else "The webhook URL is the shared secret (no bearer required)."
|
||||
if auth_mode == WEBHOOK_AUTH_NONE
|
||||
else "Clients authenticate with your Home Assistant account (ha_auth)."
|
||||
urls = build_connect_urls(
|
||||
hass, entry, webhook_enabled=webhook_enabled, extra_hosts=extra_hosts
|
||||
)
|
||||
if not webhook_enabled:
|
||||
auth_note = "Webhook access is disabled (local-only mode)."
|
||||
elif auth_mode == WEBHOOK_AUTH_NONE:
|
||||
auth_note = "The webhook URL is the shared secret (no bearer required)."
|
||||
elif auth_mode == WEBHOOK_AUTH_LEGACY:
|
||||
# Kept secret-free (unlike the log line below) — see the SECURITY note
|
||||
# on the persistent notification further down, which reuses this text.
|
||||
creds_where = (
|
||||
"the Home Assistant log or the entry's Configure screen"
|
||||
if oauth_creds_active
|
||||
else "the entry's Configure screen"
|
||||
)
|
||||
auth_note = (
|
||||
"OAuth (Beta) is ENABLED for this URL (legacy mode) - see "
|
||||
f"{creds_where} for the Client ID and Client Secret to paste "
|
||||
"into your MCP client."
|
||||
)
|
||||
else:
|
||||
auth_note = "Clients authenticate with your Home Assistant account (ha_auth)."
|
||||
|
||||
url_lines = "\n".join(f"- {url}" for url in urls)
|
||||
_LOGGER.info(
|
||||
"HA-MCP in-process server is running. Connect URL(s):\n%s\n%s",
|
||||
url_lines,
|
||||
auth_note,
|
||||
log_message = (
|
||||
"HA-MCP in-process server is running. "
|
||||
f"Connect URL(s):\n{url_lines}\n{auth_note}"
|
||||
)
|
||||
if webhook_enabled and auth_mode == WEBHOOK_AUTH_LEGACY:
|
||||
if oauth_creds_active:
|
||||
# Admin-only log (mirrors the webhook-proxy add-on's own startup
|
||||
# log, start.py). Cleartext credentials — deliberately NOT in the
|
||||
# persistent notification below, which every signed-in user can
|
||||
# see.
|
||||
log_message += (
|
||||
f"\n OAuth Client ID: {oauth_client_id}"
|
||||
f"\n OAuth Client Secret: {oauth_client_secret}"
|
||||
)
|
||||
if oauth_restart_pending:
|
||||
# First-enable mid-session late-binds the root views, so
|
||||
# /authorize is not live until the restart the repair asks
|
||||
# for. The credentials ARE the ones that will be served
|
||||
# (oauth_creds_active is True), but pasting them now gets a
|
||||
# connection that fails until the restart — same caveat the
|
||||
# rotation branch, the options hint, and the oauth_regenerate
|
||||
# help text carry.
|
||||
log_message += (
|
||||
"\n Legacy OAuth is not live until the restart Home "
|
||||
"Assistant is asking for; these credentials work once "
|
||||
"you restart."
|
||||
)
|
||||
log_message += (
|
||||
"\n Paste both into your MCP client's OAuth connector setup "
|
||||
"(e.g. Google Gemini Spark: Advanced settings)."
|
||||
)
|
||||
else:
|
||||
# SECURITY (review finding on #1880): while a credential rotation
|
||||
# is pending the restart, the bound root views still serve the OLD
|
||||
# identity, so a token issued under it stays valid — and could
|
||||
# read this log through the server's own log tools. Logging the
|
||||
# NEW credentials here would hand them to exactly the party the
|
||||
# rotation is meant to evict, so they are withheld until the
|
||||
# restart makes them active (which also kills every old token).
|
||||
log_message += (
|
||||
"\n The OAuth credentials were rotated and take effect after "
|
||||
"the restart Home Assistant is asking for; until then the "
|
||||
"previous credentials remain active. The new Client ID and "
|
||||
"Client Secret are on the entry's Configure screen."
|
||||
)
|
||||
_LOGGER.info(log_message)
|
||||
if not bool(entry.options.get(OPT_ENABLE_STARTUP_NOTIFICATION, True)):
|
||||
# Notification suppressed by option: clear any notification created
|
||||
# before the toggle was turned off, then skip creating a fresh one. The
|
||||
@@ -322,6 +504,29 @@ def _surface_connect_urls(
|
||||
)
|
||||
|
||||
|
||||
def _async_update_legacy_oauth_issue(hass: HomeAssistant, restart_needed: bool) -> None:
|
||||
"""File/clear the legacy-OAuth restart repair per ``async_register_webhook``'s
|
||||
return value.
|
||||
|
||||
Raised on BOTH transitions (see that function's docstring): enabling
|
||||
legacy mode (the root views just bound, or bound with different
|
||||
credentials than before) and disabling it (the views are still bound from
|
||||
a prior legacy registration). aiohttp can neither bind nor release an HTTP
|
||||
view without a full Home Assistant restart either way.
|
||||
"""
|
||||
if restart_needed:
|
||||
ir.async_create_issue(
|
||||
hass,
|
||||
DOMAIN,
|
||||
ISSUE_LEGACY_OAUTH_RESTART,
|
||||
is_fixable=False,
|
||||
severity=ir.IssueSeverity.WARNING,
|
||||
translation_key=ISSUE_LEGACY_OAUTH_RESTART,
|
||||
)
|
||||
else:
|
||||
ir.async_delete_issue(hass, DOMAIN, ISSUE_LEGACY_OAUTH_RESTART)
|
||||
|
||||
|
||||
_ISSUE_BY_KIND = {
|
||||
"package": ISSUE_PACKAGE_FAILED,
|
||||
"start": ISSUE_START_FAILED,
|
||||
@@ -450,8 +655,13 @@ async def async_maybe_auto_update(
|
||||
"shipped": shipped,
|
||||
"running": running,
|
||||
},
|
||||
learn_more_url=HACS_COMPONENT_URL,
|
||||
learn_more_url=UPDATE_HOLD_DOCS_URL,
|
||||
)
|
||||
# A newer component exists but HACS may not surface it for ~48h; ask
|
||||
# HACS to refresh this repository now so the update becomes visible
|
||||
# promptly. Fire-and-forget + throttled per shipped version; fully
|
||||
# advisory (see hacs_nudge).
|
||||
async_schedule_hacs_nudge(hass, shipped)
|
||||
return
|
||||
ir.async_delete_issue(hass, DOMAIN, ISSUE_UPDATE_HELD)
|
||||
|
||||
@@ -707,7 +917,12 @@ async def _async_check_component_compat(
|
||||
severity=ir.IssueSeverity.WARNING,
|
||||
translation_key=ISSUE_COMPONENT_OUTDATED,
|
||||
translation_placeholders={"required": required, "installed": own},
|
||||
learn_more_url=HACS_COMPONENT_URL,
|
||||
learn_more_url=UPDATE_HOLD_DOCS_URL,
|
||||
)
|
||||
# The server needs a newer component than HACS has surfaced; ask HACS
|
||||
# to refresh this repository now so the required update becomes visible
|
||||
# promptly. Fire-and-forget + throttled per required version; fully
|
||||
# advisory (see hacs_nudge).
|
||||
async_schedule_hacs_nudge(hass, required)
|
||||
else:
|
||||
ir.async_delete_issue(hass, DOMAIN, ISSUE_COMPONENT_OUTDATED)
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
"""Nudge HACS to refresh this component's repository info when a newer
|
||||
component is detected (#1783/#1785 follow-up).
|
||||
|
||||
The component notices a newer custom component quickly — it checks PyPI every
|
||||
6 hours (and on every reload/restart) and the auto-update gate reads the
|
||||
component version shipped at each server release's tag. HACS, by contrast,
|
||||
refreshes a *custom* repository's release data only about every 48 hours, so
|
||||
the component update the hold (or the component-outdated repair) is waiting on
|
||||
is usually not yet visible in HACS. This module runs the same force-refresh
|
||||
that HACS's own repository "Update information" menu action performs, so HACS's
|
||||
update entity flips promptly and Home Assistant advertises the component update
|
||||
natively instead of the user waiting out HACS's cache.
|
||||
|
||||
HACS registers no service and ``homeassistant.update_entity`` is a no-op on its
|
||||
entities, so there is no supported API for this: the refresh reaches directly
|
||||
into HACS internals (``hass.data["hacs"]``). Those internals can change under us
|
||||
at any HACS release, so EVERY access here is defensive and the whole interaction
|
||||
is advisory — any failure degrades to a debug log and never touches the caller's
|
||||
update-check path (which runs on bring-up, gating webhook registration, and on
|
||||
the version coordinator's listener). The same unsupported ``hass.data["hacs"]``
|
||||
reach as install_source_check, but deliberately logged at debug where that
|
||||
module warns: this path retries on every 6h check, so a persistent HACS shape
|
||||
change would otherwise warn forever about an advisory nicety.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from .const import (
|
||||
DOMAIN,
|
||||
HACS_LEGACY_REPO_FULL_NAME,
|
||||
HACS_MIRROR_REPO_FULL_NAME,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
# hass.data[DOMAIN] sub-key holding the SET of component versions HACS was
|
||||
# already asked to refresh for. The hold check runs every 6h; without this the
|
||||
# same pending version would force a fresh HACS network refresh on every pass.
|
||||
# A set, not a single string: the update hold nudges with the shipped version
|
||||
# while the component-outdated check nudges with the required version, and a
|
||||
# scalar marker would ping-pong between the two, re-refreshing every pass
|
||||
# (review finding). Distinct from the other DOMAIN sub-keys (const.py) so both
|
||||
# entry types can share hass.data[DOMAIN].
|
||||
_DATA_HACS_NUDGED_VERSIONS = "hacs_nudged_versions"
|
||||
|
||||
# The repository full_names (owner/repo) HACS may track this component under, in
|
||||
# lookup order: the dedicated mirror first (the current install path), the
|
||||
# legacy main-repo path second (pre-mirror installs — see install_source_check).
|
||||
_CANDIDATE_REPO_FULL_NAMES = (
|
||||
HACS_MIRROR_REPO_FULL_NAME,
|
||||
HACS_LEGACY_REPO_FULL_NAME,
|
||||
)
|
||||
|
||||
|
||||
def async_schedule_hacs_nudge(hass: HomeAssistant, target_version: str) -> None:
|
||||
"""Fire-and-forget the HACS refresh nudge for ``target_version``.
|
||||
|
||||
Scheduled as a background task rather than awaited: ``update_repository``
|
||||
makes a GitHub network call, and the callers must not block on it — the
|
||||
component-compat check is awaited inside the server bring-up *before*
|
||||
webhook registration (blocking it would delay the connect URLs for a
|
||||
display-only refresh), and the auto-update hold runs on the version
|
||||
coordinator's listener. ``async_create_task`` keeps a strong reference so
|
||||
the task is not garbage-collected mid-flight; every failure is contained
|
||||
inside :func:`async_nudge_hacs_refresh`.
|
||||
"""
|
||||
hass.async_create_task(
|
||||
async_nudge_hacs_refresh(hass, target_version),
|
||||
f"{DOMAIN}_hacs_nudge",
|
||||
)
|
||||
|
||||
|
||||
async def async_nudge_hacs_refresh(hass: HomeAssistant, target_version: str) -> None:
|
||||
"""Ask HACS to re-fetch this component's repository info for ``target_version``.
|
||||
|
||||
Throttled to at most one refresh per detected component version (the marker
|
||||
lives in ``hass.data[DOMAIN]``): the hold check repeats every 6h, and
|
||||
hammering HACS's GitHub fetch each pass for the same pending version would
|
||||
be pointless. Absent / broken HACS and a not-yet-registered repository stay
|
||||
unthrottled so a later pass (once HACS is ready) still gets its one refresh.
|
||||
"""
|
||||
domain_data = hass.data.setdefault(DOMAIN, {})
|
||||
nudged_versions: set[str] = domain_data.setdefault(
|
||||
_DATA_HACS_NUDGED_VERSIONS, set()
|
||||
)
|
||||
if target_version in nudged_versions:
|
||||
# Already refreshed HACS for this pending component version.
|
||||
return
|
||||
|
||||
try:
|
||||
refreshed = await _async_force_hacs_repo_refresh(hass)
|
||||
except Exception:
|
||||
# HACS internals are unsupported and may change shape (missing hacs,
|
||||
# renamed attributes, a network failure inside update_repository); any
|
||||
# of it must degrade to a debug log, never fault the caller.
|
||||
_LOGGER.debug(
|
||||
"HA-MCP: could not nudge HACS to refresh the component repository",
|
||||
exc_info=True,
|
||||
)
|
||||
return
|
||||
|
||||
if refreshed:
|
||||
# Throttle only on a completed refresh, so a transient miss (HACS not
|
||||
# set up yet, repo not registered this pass) is retried next check
|
||||
# rather than suppressed for this version forever.
|
||||
nudged_versions.add(target_version)
|
||||
|
||||
|
||||
async def _async_force_hacs_repo_refresh(hass: HomeAssistant) -> bool:
|
||||
"""Run HACS's "Update information" force-refresh for this component's repo.
|
||||
|
||||
Returns True when a tracked repository was found and its refresh completed,
|
||||
False when there is nothing to refresh (no HACS, or no INSTALLED repository
|
||||
under either candidate name). Reaches into HACS internals —
|
||||
the top-level lookups are ``getattr``-guarded so a wholly different HACS
|
||||
shape returns False cleanly; anything deeper that changes shape raises and is
|
||||
swallowed by :func:`async_nudge_hacs_refresh`.
|
||||
"""
|
||||
hacs = hass.data.get("hacs")
|
||||
if hacs is None:
|
||||
# No HACS (manual/copy install, or HACS set up later this run) — nothing
|
||||
# to refresh; the caller leaves the throttle unset so a later pass retries.
|
||||
return False
|
||||
|
||||
repositories = getattr(hacs, "repositories", None)
|
||||
get_by_full_name = getattr(repositories, "get_by_full_name", None)
|
||||
if get_by_full_name is None:
|
||||
return False
|
||||
|
||||
repository = None
|
||||
for full_name in _CANDIDATE_REPO_FULL_NAMES:
|
||||
candidate = get_by_full_name(full_name)
|
||||
if candidate is None:
|
||||
continue
|
||||
# HACS keeps a repository record for every ADDED repo, downloaded or
|
||||
# not, but only creates an update entity for DOWNLOADED ones — and a
|
||||
# legacy->mirror migration can leave the mirror added but not yet
|
||||
# (re)installed while the running component is still tracked under the
|
||||
# legacy record. Refreshing an uninstalled record lights up nothing, so
|
||||
# only an installed candidate counts (review finding).
|
||||
if not getattr(getattr(candidate, "data", None), "installed", False):
|
||||
continue
|
||||
repository = candidate
|
||||
break
|
||||
if repository is None:
|
||||
return False
|
||||
|
||||
# The repository's "Update information" menu action: re-fetch its release
|
||||
# data ignoring cached state, then push the fresh data to HACS's own update
|
||||
# entity so Home Assistant advertises the component update immediately.
|
||||
await repository.update_repository(ignore_issues=True, force=True)
|
||||
# The refresh is complete at this point; the listener push below only
|
||||
# re-publishes the fresh data to HACS's update entity sooner. Guarded
|
||||
# separately so a HACS shape change here cannot void the completed
|
||||
# refresh's throttle and re-run the network fetch every pass (review
|
||||
# finding).
|
||||
try:
|
||||
coordinators = getattr(hacs, "coordinators", None) or {}
|
||||
category = getattr(getattr(repository, "data", None), "category", None)
|
||||
coordinator = coordinators.get(category)
|
||||
if coordinator is not None:
|
||||
coordinator.async_update_listeners()
|
||||
except Exception:
|
||||
_LOGGER.debug(
|
||||
"HA-MCP: HACS listener push after the repository refresh failed",
|
||||
exc_info=True,
|
||||
)
|
||||
return True
|
||||
@@ -21,17 +21,18 @@ from homeassistant.const import EVENT_HOMEASSISTANT_STARTED
|
||||
from homeassistant.core import CoreState, callback
|
||||
from homeassistant.helpers import issue_registry as ir
|
||||
|
||||
from .const import DOMAIN, HACS_COMPONENT_URL, ISSUE_LEGACY_HACS_SOURCE
|
||||
from .const import (
|
||||
DOMAIN,
|
||||
HACS_COMPONENT_URL,
|
||||
HACS_LEGACY_REPO_FULL_NAME,
|
||||
ISSUE_LEGACY_HACS_SOURCE,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from homeassistant.core import Event, HomeAssistant
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
# The main server repo's full_name, as HACS's repository registry keys it —
|
||||
# the legacy (pre-mirror) install path this module detects.
|
||||
_LEGACY_REPO_FULL_NAME = "homeassistant-ai/ha-mcp"
|
||||
|
||||
# Guards the schedule below so multiple config entries (tools + server) on the
|
||||
# same HA run only ever schedule the check once.
|
||||
_DATA_SCHEDULED = "install_source_check_scheduled"
|
||||
@@ -86,7 +87,7 @@ async def _async_check_install_source(hass: HomeAssistant) -> None:
|
||||
hacs = hass.data.get("hacs")
|
||||
installed = False
|
||||
if hacs is not None:
|
||||
repo = hacs.repositories.get_by_full_name(_LEGACY_REPO_FULL_NAME)
|
||||
repo = hacs.repositories.get_by_full_name(HACS_LEGACY_REPO_FULL_NAME)
|
||||
installed = repo is not None and bool(repo.data.installed)
|
||||
except Exception:
|
||||
_LOGGER.warning(
|
||||
|
||||
@@ -2,9 +2,12 @@
|
||||
"domain": "ha_mcp_tools",
|
||||
"name": "HA-MCP Custom Component",
|
||||
"after_dependencies": [
|
||||
"http",
|
||||
"backup",
|
||||
"cloud",
|
||||
"frontend"
|
||||
"frontend",
|
||||
"http",
|
||||
"lovelace",
|
||||
"network"
|
||||
],
|
||||
"codeowners": [
|
||||
"@homeassistant-ai"
|
||||
@@ -19,5 +22,5 @@
|
||||
"requirements": [
|
||||
"ruamel.yaml>=0.18.0"
|
||||
],
|
||||
"version": "1.1.0"
|
||||
"version": "1.2.2"
|
||||
}
|
||||
|
||||
@@ -5,21 +5,34 @@ Ported from the proven webhook-proxy add-on (``mcp_proxy``): an HA webhook
|
||||
the response back, so the server is reachable through Nabu Casa remote UI (or any
|
||||
reverse proxy) with the webhook id as the shared secret.
|
||||
|
||||
Two auth postures, chosen in the options flow:
|
||||
Three auth postures, chosen in the options flow:
|
||||
|
||||
* ``none`` — the secret webhook URL *is* the credential (matches the add-on's
|
||||
default). No bearer is required.
|
||||
default). No bearer is required and the forwarder always returns 200. It still
|
||||
serves our own corrected RFC 8414 / RFC 9728 discovery documents plus an
|
||||
invisible auto-approve authorization server (:mod:`oauth_autoapprove`), so
|
||||
claude.ai's intermittent OAuth discovery resolves against us — not HA core's
|
||||
broken origin-root doc — and connects with no HA login (issue #1969).
|
||||
* ``ha_auth`` — Home Assistant core is the OAuth authorization server. This
|
||||
module serves the RFC 8414 / RFC 9728 discovery documents (so claude.ai /
|
||||
ChatGPT can sign in with the user's HA account) and validates inbound bearer
|
||||
tokens via ``hass.auth``. There is no bespoke authorization-server code here —
|
||||
every protocol step is HA core's own ``/auth/*``.
|
||||
* ``legacy`` — this module (via :mod:`oauth_legacy`) is its own OAuth 2.1
|
||||
authorization server with a static client_id/secret, for MCP clients (Google
|
||||
Gemini Spark) that need a credential to paste rather than an HA sign-in.
|
||||
|
||||
The forwarding handler mirrors ``mcp_proxy._handle_webhook`` exactly (hop-by-hop
|
||||
header stripping, the SSE streaming branch with anti-buffering headers, the
|
||||
content-type whitelist, ``Mcp-Session-Id`` propagation, and the 502/500 error
|
||||
mapping); the ``ha_auth`` bearer check + discovery documents mirror the add-on's
|
||||
``auth_native.py`` + the ``ha_auth`` subset of ``oauth.py``.
|
||||
``auth_native.py`` + the ``ha_auth`` subset of ``oauth.py``; the ``legacy``
|
||||
provider + its root ``/authorize`` + ``/token`` views live in
|
||||
:mod:`oauth_legacy`, ported from the ``legacy`` subset of the add-on's
|
||||
``oauth.py``. The seven RFC 8414 / RFC 9728 discovery views below are shared by
|
||||
``ha_auth``, ``legacy``, and ``none`` (which serves a distinct auto-approve
|
||||
authorization-server document pointing at :mod:`oauth_autoapprove`'s endpoints)
|
||||
— see :func:`active_auth_mode`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -41,8 +54,22 @@ from .const import (
|
||||
DOMAIN,
|
||||
OAUTH_BASE,
|
||||
WEBHOOK_AUTH_HA,
|
||||
WEBHOOK_AUTH_LEGACY,
|
||||
WEBHOOK_AUTH_NONE,
|
||||
)
|
||||
from .oauth_autoapprove import (
|
||||
CFG_AUTOAPPROVE_PROVIDER,
|
||||
AutoApproveProvider,
|
||||
bind_autoapprove_views,
|
||||
)
|
||||
from .oauth_legacy import (
|
||||
AUTHORIZE_PATH,
|
||||
OAUTH_ROUTE_OWNER_KEY,
|
||||
TOKEN_PATH,
|
||||
LegacyOAuthProvider,
|
||||
LegacyOAuthRouteConflict,
|
||||
bind_legacy_views,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
@@ -145,14 +172,6 @@ class ResourceServer:
|
||||
"""This install's private webhook id."""
|
||||
return self._webhook_id
|
||||
|
||||
def resource_url(self, base_url: str) -> str:
|
||||
"""Absolute URL of the protected webhook resource under ``base_url``."""
|
||||
return f"{base_url}/api/webhook/{self._webhook_id}"
|
||||
|
||||
def authorization_server_url(self, base_url: str) -> str:
|
||||
"""Issuer / authorization-server URL under ``base_url``."""
|
||||
return f"{base_url}{OAUTH_BASE}"
|
||||
|
||||
async def validate_request(self, request: web.Request) -> bool:
|
||||
"""Return True iff the request carries a Bearer token HA core accepts.
|
||||
|
||||
@@ -195,29 +214,62 @@ class ResourceServer:
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# RFC 8414 / RFC 9728 discovery views (ha_auth mode only)
|
||||
# RFC 8414 / RFC 9728 discovery views (ha_auth + legacy modes)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _active_resource_server(hass: HomeAssistant) -> ResourceServer | None:
|
||||
"""Return the CURRENT entry's ha_auth resource server, or None.
|
||||
|
||||
The discovery views resolve this per request instead of binding a provider
|
||||
at registration time: aiohttp can't drop a bound view until HA restarts, so
|
||||
a remove + re-add of the config entry (which mints a NEW webhook id in the
|
||||
same HA session) would otherwise leave the views advertising the old id.
|
||||
Returns None when no entry is live, the webhook auth mode is not ha_auth,
|
||||
or the public endpoint is disabled (local-only mode constructs no resource
|
||||
server even under ha_auth) — the views then 404 like an unregistered route.
|
||||
"""
|
||||
def _active_webhook_cfg(hass: HomeAssistant) -> dict[str, Any] | None:
|
||||
"""Return the live webhook forwarding cfg dict, or None if not set up."""
|
||||
domain_data = hass.data.get(DOMAIN)
|
||||
if not isinstance(domain_data, dict):
|
||||
return None
|
||||
cfg = domain_data.get(DATA_WEBHOOK)
|
||||
if not isinstance(cfg, dict) or cfg.get("auth_mode") != WEBHOOK_AUTH_HA:
|
||||
return cfg if isinstance(cfg, dict) else None
|
||||
|
||||
|
||||
def active_auth_mode(hass: HomeAssistant) -> str | None:
|
||||
"""Return the OAuth-relevant auth mode of the live webhook registration.
|
||||
|
||||
``WEBHOOK_AUTH_HA``, ``WEBHOOK_AUTH_LEGACY``, or ``WEBHOOK_AUTH_NONE`` (the
|
||||
none-mode auto-approve surface, issue #1969), or None when no discovery
|
||||
surface is live. Checked via PROVIDER PRESENCE, not the raw configured
|
||||
``auth_mode`` string, so local-only mode (remote webhook disabled by
|
||||
option — ``register_endpoint=False`` in ``async_register_webhook``)
|
||||
correctly reports None even when ``webhook_auth`` is set: no provider is
|
||||
constructed for a webhook that was never registered, so there is nothing to
|
||||
advertise or authenticate against. Read live from hass.data (not captured at view/provider
|
||||
construction time) so the SAME registered/bound instances serve whichever
|
||||
mode is active now — mirrors the add-on's ``_active_oauth_mode``. Used by
|
||||
the discovery views below AND by ``LegacyOAuthProvider.is_active`` (via
|
||||
the getter passed into :func:`oauth_legacy.bind_legacy_views`) so the
|
||||
root ``/authorize``/``/token`` views, which aiohttp can never unbind, 404
|
||||
once the operator switches away from legacy (or to local-only mode)
|
||||
without a restart. The webhook forwarder's own bearer gate
|
||||
(``_async_handle_webhook``) reads ``cfg["resource_server"]`` /
|
||||
``cfg["oauth_provider"]`` directly instead of through this function — it
|
||||
already has ``cfg`` in hand and needs the provider OBJECT, not just the
|
||||
mode name.
|
||||
"""
|
||||
cfg = _active_webhook_cfg(hass)
|
||||
if cfg is None:
|
||||
return None
|
||||
provider = cfg.get("resource_server")
|
||||
return provider if isinstance(provider, ResourceServer) else None
|
||||
if cfg.get("resource_server") is not None:
|
||||
return WEBHOOK_AUTH_HA
|
||||
if cfg.get("oauth_provider") is not None:
|
||||
return WEBHOOK_AUTH_LEGACY
|
||||
if cfg.get(CFG_AUTOAPPROVE_PROVIDER) is not None:
|
||||
return WEBHOOK_AUTH_NONE
|
||||
return None
|
||||
|
||||
|
||||
def _active_webhook_id(hass: HomeAssistant) -> str | None:
|
||||
"""Webhook id of the live registration, gated the same as the AS document
|
||||
(None whenever :func:`active_auth_mode` is None) so the protected-resource
|
||||
document 404s in exactly the same cases."""
|
||||
if active_auth_mode(hass) is None:
|
||||
return None
|
||||
cfg = _active_webhook_cfg(hass)
|
||||
return cfg.get("webhook_id") if cfg is not None else None
|
||||
|
||||
|
||||
def _json_not_found() -> web.Response:
|
||||
@@ -225,16 +277,63 @@ def _json_not_found() -> web.Response:
|
||||
return web.json_response({"error": "not_found"}, status=404)
|
||||
|
||||
|
||||
def _protected_resource_document(provider: ResourceServer, base: str) -> dict[str, Any]:
|
||||
"""RFC 9728 protected-resource document for ``provider`` under ``base``."""
|
||||
def _protected_resource_document(webhook_id: str, base: str) -> dict[str, Any]:
|
||||
"""RFC 9728 protected-resource document for ``webhook_id`` under ``base``.
|
||||
|
||||
Identical shape in both OAuth modes — only the authorization-server
|
||||
document (below) differs by mode.
|
||||
"""
|
||||
return {
|
||||
"resource": provider.resource_url(base),
|
||||
"authorization_servers": [provider.authorization_server_url(base)],
|
||||
"resource": f"{base}/api/webhook/{webhook_id}",
|
||||
"authorization_servers": [f"{base}{OAUTH_BASE}"],
|
||||
"bearer_methods_supported": ["header"],
|
||||
"resource_documentation": "https://github.com/homeassistant-ai/ha-mcp",
|
||||
}
|
||||
|
||||
|
||||
def _legacy_authorization_server_document(base: str) -> dict[str, Any]:
|
||||
"""RFC 8414 authorization-server metadata for legacy mode's own root
|
||||
``/authorize`` + ``/token`` views (see :mod:`oauth_legacy`)."""
|
||||
return {
|
||||
"issuer": f"{base}{OAUTH_BASE}",
|
||||
"authorization_endpoint": f"{base}{AUTHORIZE_PATH}",
|
||||
"token_endpoint": f"{base}{TOKEN_PATH}",
|
||||
"response_types_supported": ["code"],
|
||||
"grant_types_supported": ["authorization_code", "refresh_token"],
|
||||
"code_challenge_methods_supported": ["S256"],
|
||||
"token_endpoint_auth_methods_supported": [
|
||||
"client_secret_basic",
|
||||
"client_secret_post",
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def _none_mode_authorization_server_document(base: str) -> dict[str, Any]:
|
||||
"""RFC 8414 authorization-server metadata for none mode's auto-approve server.
|
||||
|
||||
Points at OUR OWN ``OAUTH_BASE`` ``/authorize`` + ``/token`` (the invisible
|
||||
auto-approve endpoints in :mod:`oauth_autoapprove`), NOT HA core's
|
||||
``/auth/*``. Serving this — with ``token_endpoint_auth_methods_supported:
|
||||
["none"]`` (public PKCE client) and ``client_id_metadata_document_supported``
|
||||
— is the none-mode fix: claude.ai's intermittent discovery resolves against
|
||||
this corrected document instead of HA core's origin-root
|
||||
``/.well-known/oauth-authorization-server``, which omits the ``"none"`` auth
|
||||
method and has no ``registration_endpoint`` (issue #1969). No refresh grant:
|
||||
the token is cosmetic (none mode ignores bearers), so only
|
||||
``authorization_code`` is advertised.
|
||||
"""
|
||||
return {
|
||||
"issuer": f"{base}{OAUTH_BASE}",
|
||||
"authorization_endpoint": f"{base}{OAUTH_BASE}/authorize",
|
||||
"token_endpoint": f"{base}{OAUTH_BASE}/token",
|
||||
"response_types_supported": ["code"],
|
||||
"grant_types_supported": ["authorization_code"],
|
||||
"code_challenge_methods_supported": ["S256"],
|
||||
"token_endpoint_auth_methods_supported": ["none"],
|
||||
"client_id_metadata_document_supported": True,
|
||||
}
|
||||
|
||||
|
||||
class _ProtectedResourceMetadataView(HomeAssistantView):
|
||||
"""RFC 9728 Protected Resource Metadata."""
|
||||
|
||||
@@ -244,21 +343,36 @@ class _ProtectedResourceMetadataView(HomeAssistantView):
|
||||
name = "ha_mcp_tools:oauth:protected-resource"
|
||||
|
||||
def __init__(self, hass: HomeAssistant) -> None:
|
||||
"""Bind the view to the HA instance; the provider is resolved per request."""
|
||||
"""Bind the view to the HA instance; liveness is resolved per request."""
|
||||
self._hass = hass
|
||||
|
||||
async def get(self, request: web.Request) -> web.Response:
|
||||
"""Serve the protected-resource document (or 404 when ha_auth is off)."""
|
||||
provider = _active_resource_server(self._hass)
|
||||
if provider is None:
|
||||
"""Serve the protected-resource document for the bearer-gated modes only.
|
||||
|
||||
SECURITY (#1976 review): this ANONYMOUS, fixed (guessable) path exposes
|
||||
``resource: <base>/api/webhook/<id>``. In none mode the webhook id is the
|
||||
SOLE credential, so serving it here would leak it to any unauthenticated
|
||||
GET. Serve only for ``ha_auth``/``legacy`` (where the id is not a secret
|
||||
and the 401 ``WWW-Authenticate`` pointer legitimately directs a client
|
||||
here); 404 otherwise. The PATH-SCOPED well-known view still serves in none
|
||||
mode — its caller must already know the id (it is a route parameter).
|
||||
"""
|
||||
if active_auth_mode(self._hass) not in (WEBHOOK_AUTH_HA, WEBHOOK_AUTH_LEGACY):
|
||||
return _json_not_found()
|
||||
webhook_id = _active_webhook_id(self._hass)
|
||||
if webhook_id is None:
|
||||
return _json_not_found()
|
||||
return web.json_response(
|
||||
_protected_resource_document(provider, _build_base_url(request))
|
||||
_protected_resource_document(webhook_id, _build_base_url(request))
|
||||
)
|
||||
|
||||
|
||||
class _AuthorizationServerMetadataView(HomeAssistantView):
|
||||
"""RFC 8414 Authorization Server Metadata (points at HA core's OAuth)."""
|
||||
"""RFC 8414 Authorization Server Metadata.
|
||||
|
||||
Mode-aware: ha_auth points at HA core's own ``/auth/*``; legacy points at
|
||||
this module's root ``/authorize``/``/token`` views.
|
||||
"""
|
||||
|
||||
requires_auth = False
|
||||
cors_allowed = True
|
||||
@@ -270,10 +384,15 @@ class _AuthorizationServerMetadataView(HomeAssistantView):
|
||||
self._hass = hass
|
||||
|
||||
async def get(self, request: web.Request) -> web.Response:
|
||||
"""Serve the authorization-server document (or 404 when ha_auth is off)."""
|
||||
if _active_resource_server(self._hass) is None:
|
||||
"""Serve the AS document (or 404 when no OAuth mode is live)."""
|
||||
mode = active_auth_mode(self._hass)
|
||||
if mode is None:
|
||||
return _json_not_found()
|
||||
base = _build_base_url(request)
|
||||
if mode == WEBHOOK_AUTH_LEGACY:
|
||||
return web.json_response(_legacy_authorization_server_document(base))
|
||||
if mode == WEBHOOK_AUTH_NONE:
|
||||
return web.json_response(_none_mode_authorization_server_document(base))
|
||||
return web.json_response(_authorization_server_document(base))
|
||||
|
||||
|
||||
@@ -296,16 +415,16 @@ class _WellKnownProtectedResourceView(HomeAssistantView):
|
||||
url = "/.well-known/oauth-protected-resource/api/webhook/{webhook_id}"
|
||||
|
||||
def __init__(self, hass: HomeAssistant) -> None:
|
||||
"""Bind the view to the HA instance; the provider is resolved per request."""
|
||||
"""Bind the view to the HA instance; liveness is resolved per request."""
|
||||
self._hass = hass
|
||||
|
||||
async def get(self, request: web.Request, webhook_id: str) -> web.Response:
|
||||
"""Serve the document only for the CURRENT entry's webhook id."""
|
||||
provider = _active_resource_server(self._hass)
|
||||
if provider is None or webhook_id != provider.webhook_id:
|
||||
active_id = _active_webhook_id(self._hass)
|
||||
if active_id is None or webhook_id != active_id:
|
||||
return _json_not_found()
|
||||
return web.json_response(
|
||||
_protected_resource_document(provider, _build_base_url(request))
|
||||
_protected_resource_document(active_id, _build_base_url(request))
|
||||
)
|
||||
|
||||
|
||||
@@ -324,7 +443,8 @@ class _WellKnownAuthorizationServerMetadataView(_AuthorizationServerMetadataView
|
||||
|
||||
|
||||
def _metadata_views(hass: HomeAssistant) -> list[HomeAssistantView]:
|
||||
"""Build the seven ha_auth discovery-document views (provider-agnostic)."""
|
||||
"""Build the seven discovery-document views, shared by ha_auth and legacy
|
||||
(mode-agnostic — each view resolves the active mode per request)."""
|
||||
views: list[HomeAssistantView] = [
|
||||
_ProtectedResourceMetadataView(hass),
|
||||
_AuthorizationServerMetadataView(hass),
|
||||
@@ -355,13 +475,14 @@ def _metadata_views(hass: HomeAssistant) -> list[HomeAssistantView]:
|
||||
|
||||
|
||||
def _register_metadata_views(hass: HomeAssistant) -> None:
|
||||
"""Register the ha_auth discovery views at most once per HA session.
|
||||
"""Register the seven discovery views at most once per HA session.
|
||||
|
||||
aiohttp cannot unregister a bound view, so a reload / re-enable / re-add must
|
||||
reuse the already-bound views — they resolve the ACTIVE provider from
|
||||
hass.data per request, so a later entry (even with a new webhook id) is
|
||||
served correctly. The guard flag lives at a top-level hass.data key that
|
||||
survives config-entry teardown.
|
||||
aiohttp cannot unregister a bound view, so a reload / re-enable / re-add /
|
||||
ha_auth<->legacy mode switch must all reuse the already-bound views — they
|
||||
resolve the ACTIVE mode + provider from hass.data per request (see
|
||||
``active_auth_mode``), so a later entry (even with a new webhook id, or a
|
||||
different auth mode) is served correctly. The guard flag lives at a
|
||||
top-level hass.data key that survives config-entry teardown.
|
||||
"""
|
||||
if hass.data.get(_OAUTH_VIEWS_REGISTERED_KEY):
|
||||
return
|
||||
@@ -370,9 +491,7 @@ def _register_metadata_views(hass: HomeAssistant) -> None:
|
||||
hass.data[_OAUTH_VIEWS_REGISTERED_KEY] = True
|
||||
|
||||
|
||||
def _build_unauthorized_response(
|
||||
request: web.Request, provider: ResourceServer
|
||||
) -> web.Response:
|
||||
def _build_unauthorized_response(request: web.Request) -> web.Response:
|
||||
"""Build the 401 + ``WWW-Authenticate`` challenge MCP clients use to discover.
|
||||
|
||||
Per RFC 9728 §5.1 / MCP spec, the ``resource_metadata`` parameter points to
|
||||
@@ -397,6 +516,29 @@ def _build_unauthorized_response(
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def _check_webhook_auth(
|
||||
request: web.Request, cfg: dict[str, Any]
|
||||
) -> web.StreamResponse | None:
|
||||
"""Return a 401 challenge response if the request fails the auth gate, else None."""
|
||||
# Auth gate. ``none`` = the secret webhook URL is the credential; ``ha_auth``
|
||||
# validates the bearer via HA core; ``legacy`` validates it against this
|
||||
# module's own opaque tokens. Either failure emits the same 401 discovery
|
||||
# challenge so the client can start the OAuth flow. Gate on the PROVIDER
|
||||
# (constructed only for the matching mode) rather than a string compare,
|
||||
# so the coupling "provider present <=> mode" has a single owner and an
|
||||
# inconsistent cfg cannot fail open. auth_mode makes the two mutually
|
||||
# exclusive, so at most one of these is ever set.
|
||||
resource_server: ResourceServer | None = cfg.get("resource_server")
|
||||
if resource_server is not None and not await resource_server.validate_request(
|
||||
request
|
||||
):
|
||||
return _build_unauthorized_response(request)
|
||||
oauth_provider: LegacyOAuthProvider | None = cfg.get("oauth_provider")
|
||||
if oauth_provider is not None and not oauth_provider.validate_bearer(request):
|
||||
return _build_unauthorized_response(request)
|
||||
return None
|
||||
|
||||
|
||||
async def _async_handle_webhook(
|
||||
hass: HomeAssistant, webhook_id: str, request: web.Request
|
||||
) -> web.StreamResponse:
|
||||
@@ -406,16 +548,9 @@ async def _async_handle_webhook(
|
||||
if not isinstance(cfg, dict):
|
||||
return web.Response(status=503, text="MCP server is not available")
|
||||
|
||||
# Auth gate. ``none`` = the secret webhook URL is the credential; ``ha_auth``
|
||||
# = validate the bearer via HA core, and on failure emit the 401 discovery
|
||||
# challenge so the client can start the OAuth flow. Gate on the PROVIDER
|
||||
# (constructed only for ha_auth) rather than a string compare, so the
|
||||
# coupling "provider present <=> ha_auth" has a single owner and an
|
||||
# inconsistent cfg cannot fail open.
|
||||
provider = cfg.get("resource_server")
|
||||
if provider is not None:
|
||||
if not await provider.validate_request(request):
|
||||
return _build_unauthorized_response(request, provider)
|
||||
auth_response = await _check_webhook_auth(request, cfg)
|
||||
if auth_response is not None:
|
||||
return auth_response
|
||||
|
||||
target_url: str = cfg["target_url"]
|
||||
session: aiohttp.ClientSession = cfg["session"]
|
||||
@@ -504,22 +639,34 @@ async def async_register_webhook(
|
||||
secret_path: str,
|
||||
auth_mode: str,
|
||||
register_endpoint: bool = True,
|
||||
) -> None:
|
||||
"""Register the ingress webhook (and, for ha_auth, the discovery views).
|
||||
oauth_client_id: str | None = None,
|
||||
oauth_client_secret: str | None = None,
|
||||
oauth_signing_key: str | None = None,
|
||||
) -> bool:
|
||||
"""Register the ingress webhook (and, for ha_auth/legacy, the OAuth surface).
|
||||
|
||||
Stores the forwarding config in ``hass.data[DOMAIN][DATA_WEBHOOK]`` and opens
|
||||
a long-lived aiohttp session for streaming. Raises on failure with the webhook
|
||||
already unregistered, so the caller never leaves a half-configured endpoint
|
||||
live. ``webhook`` is a manifest dependency, so HA guarantees it is set up
|
||||
before this runs.
|
||||
before this runs. ``oauth_client_id``/``oauth_client_secret``/
|
||||
``oauth_signing_key`` are required when ``auth_mode == WEBHOOK_AUTH_LEGACY``
|
||||
(ignored otherwise); ``oauth_signing_key`` is the hex string persisted in
|
||||
``entry.data`` — see ``oauth_legacy._normalize_signing_key``.
|
||||
|
||||
With ``register_endpoint=False`` (remote webhook access disabled by option)
|
||||
no public endpoint or ha_auth surface is created — and any leftover endpoint
|
||||
from a crashed unload is cleared, so off means off; only the forwarding
|
||||
config is stored, which same-host consumers — the sidebar settings panel
|
||||
proxy — need to reach the loopback server (#1803).
|
||||
no public endpoint or ha_auth/legacy surface is created — and any leftover
|
||||
endpoint from a crashed unload is cleared, so off means off; only the
|
||||
forwarding config is stored, which same-host consumers — the sidebar
|
||||
settings panel proxy — need to reach the loopback server (#1803).
|
||||
|
||||
Returns True when the caller should surface ``ISSUE_LEGACY_OAUTH_RESTART``:
|
||||
the root ``/authorize``/``/token`` views just bound for the first time this
|
||||
HA session (or with changed credentials), or they are still bound from a
|
||||
prior legacy registration that this call has moved away from — either way
|
||||
aiohttp cannot bind or release a view without a full HA restart.
|
||||
"""
|
||||
if auth_mode not in (WEBHOOK_AUTH_NONE, WEBHOOK_AUTH_HA):
|
||||
if auth_mode not in (WEBHOOK_AUTH_NONE, WEBHOOK_AUTH_HA, WEBHOOK_AUTH_LEGACY):
|
||||
# Fail CLOSED on an unknown mode (corrupt/migrated options): refusing
|
||||
# bring-up files a repair issue, instead of an unrecognized string
|
||||
# silently taking the unauthenticated forward path.
|
||||
@@ -540,8 +687,11 @@ async def async_register_webhook(
|
||||
"session": session,
|
||||
"auth_mode": auth_mode,
|
||||
"resource_server": None,
|
||||
"oauth_provider": None,
|
||||
CFG_AUTOAPPROVE_PROVIDER: None,
|
||||
}
|
||||
|
||||
oauth_restart_needed = False
|
||||
if register_endpoint:
|
||||
try:
|
||||
async_register(
|
||||
@@ -556,6 +706,38 @@ async def async_register_webhook(
|
||||
provider = ResourceServer(hass, webhook_id)
|
||||
_register_metadata_views(hass)
|
||||
cfg["resource_server"] = provider
|
||||
elif auth_mode == WEBHOOK_AUTH_LEGACY:
|
||||
if not (oauth_client_id and oauth_client_secret and oauth_signing_key):
|
||||
raise ValueError(
|
||||
"legacy webhook auth mode requires oauth_client_id, "
|
||||
"oauth_client_secret, and oauth_signing_key"
|
||||
)
|
||||
_register_metadata_views(hass)
|
||||
try:
|
||||
oauth_provider, oauth_restart_needed = bind_legacy_views(
|
||||
hass, oauth_client_id, oauth_client_secret, oauth_signing_key
|
||||
)
|
||||
except LegacyOAuthRouteConflict as err:
|
||||
raise ValueError(
|
||||
"The Webhook Proxy add-on (or its dev flavor) already "
|
||||
f"owns the root /authorize and /token routes ({err}). "
|
||||
"Stop that add-on and restart Home Assistant, then "
|
||||
"enable legacy mode again."
|
||||
) from err
|
||||
cfg["oauth_provider"] = oauth_provider
|
||||
else:
|
||||
# WEBHOOK_AUTH_NONE (the only remaining mode — unknown modes
|
||||
# already raised above). The secret webhook URL is the
|
||||
# credential, but we still serve our own corrected discovery +
|
||||
# an invisible auto-approve authorization server so claude.ai's
|
||||
# intermittent OAuth discovery resolves against us instead of HA
|
||||
# core's broken origin-root document, and completes with no HA
|
||||
# login (issue #1969). Both view bundles bind at most once per
|
||||
# HA session; the per-request resolvers gate them on this cfg,
|
||||
# so a none<->ha_auth switch needs no restart.
|
||||
_register_metadata_views(hass)
|
||||
bind_autoapprove_views(hass)
|
||||
cfg[CFG_AUTOAPPROVE_PROVIDER] = AutoApproveProvider()
|
||||
except Exception:
|
||||
# Never leave a live endpoint (or a leaked session) behind a failed
|
||||
# auth-setup path. suppress: the ORIGINAL error must be what
|
||||
@@ -566,14 +748,27 @@ async def async_register_webhook(
|
||||
await session.close()
|
||||
raise
|
||||
|
||||
# A PRIOR registration this HA session may still own the legacy root views
|
||||
# even though THIS call bound no legacy provider — either the mode is no
|
||||
# longer legacy, OR legacy is still selected but the webhook endpoint is now
|
||||
# off (register_endpoint=False skips the bind block above). aiohttp can never
|
||||
# release a bound view without a restart, so gate on "no provider bound this
|
||||
# call" (not the mode string) to surface the restart that releases route
|
||||
# ownership in both cases.
|
||||
if cfg["oauth_provider"] is None and hass.data.get(OAUTH_ROUTE_OWNER_KEY) == DOMAIN:
|
||||
oauth_restart_needed = True
|
||||
|
||||
hass.data.setdefault(DOMAIN, {})[DATA_WEBHOOK] = cfg
|
||||
return oauth_restart_needed
|
||||
|
||||
|
||||
async def async_unregister_webhook(hass: HomeAssistant) -> None:
|
||||
"""Unregister the ingress webhook and close its aiohttp session.
|
||||
|
||||
Idempotent. The ha_auth discovery views are intentionally left bound (aiohttp
|
||||
can't unregister them until HA restarts); they 404 while ha_auth is not live.
|
||||
Idempotent. The discovery views and the legacy root ``/authorize``/``/token``
|
||||
views are intentionally left bound (aiohttp can't unregister them until HA
|
||||
restarts); they 404 while their mode is not live (see ``active_auth_mode``
|
||||
/ ``LegacyOAuthProvider.is_active``).
|
||||
"""
|
||||
domain_data = hass.data.get(DOMAIN)
|
||||
if not isinstance(domain_data, dict):
|
||||
|
||||
@@ -0,0 +1,303 @@
|
||||
"""None-mode auto-approve OAuth authorization server (issue #1969).
|
||||
|
||||
In ``none`` webhook auth mode the secret webhook URL *is* the credential, so no
|
||||
bearer is required and the forwarder always returns 200. But claude.ai's
|
||||
connector onboarding intermittently front-loads OAuth discovery, and because the
|
||||
component registers no ``/.well-known`` views in none mode, claude.ai falls
|
||||
through to Home Assistant *core*'s own origin-root
|
||||
``/.well-known/oauth-authorization-server`` — which advertises
|
||||
``client_id_metadata_document_supported`` but omits
|
||||
``token_endpoint_auth_methods_supported: ["none"]`` and has no
|
||||
``registration_endpoint``. claude.ai then can neither use CIMD nor do dynamic
|
||||
client registration and shows "Automatic client registration isn't supported…".
|
||||
|
||||
This module is the none-mode fix's authorization-server half: a pair of
|
||||
path-scoped ``OAUTH_BASE`` endpoints that complete OAuth *invisibly* — no login,
|
||||
no consent — so a connector that does run discovery resolves against our own
|
||||
corrected documents (served by :mod:`mcp_webhook`) instead of HA core's broken
|
||||
root doc, and connects with zero HA login:
|
||||
|
||||
* ``GET {OAUTH_BASE}/authorize`` issues a PKCE-bound one-time code and
|
||||
immediately 302-redirects back to the client with ``?code=…&state=…`` — no
|
||||
page is rendered.
|
||||
* ``POST {OAUTH_BASE}/token`` exchanges that code (public client, PKCE S256, no
|
||||
``client_secret``) for an opaque access token. The token is *cosmetic* — none
|
||||
mode ignores bearers entirely — but is a real random string so a spec-strict
|
||||
client is satisfied.
|
||||
|
||||
Both views are gated per request off ``hass.data`` (they 404 unless none mode is
|
||||
the live webhook auth mode), mirroring the discovery views, so a
|
||||
``none``\\ ↔\\ ``ha_auth`` switch needs no restart. The PKCE code store and the
|
||||
redirect-URI floor are reused from :mod:`oauth_legacy` rather than copied.
|
||||
|
||||
**Open-redirect defence.** ``/authorize`` 302-redirects to a caller-supplied
|
||||
``redirect_uri`` on the Home Assistant origin, so an unvalidated target would be
|
||||
an open redirector. On top of :func:`oauth_legacy._is_valid_redirect_uri`'s
|
||||
scheme/host/port floor, the redirect must EXACTLY match a known MCP callback
|
||||
(:data:`_AUTOAPPROVE_REDIRECT_ALLOWLIST`). Anything else is a hard 400 (no
|
||||
redirect). A "same origin as the client_id" rule was deliberately NOT used: the
|
||||
client_id is fully attacker-controlled, so ``client_id == redirect_uri origin``
|
||||
still lets an attacker bounce a victim to any site of their choosing (a real
|
||||
open redirect on a public HA origin). Properly honouring an arbitrary CIMD
|
||||
client would require fetching the attacker-supplied client_id URL — an SSRF
|
||||
vector — so the allowlist is both the safe and the simple choice. Add a client's
|
||||
callback here to support it.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import secrets
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from aiohttp import web
|
||||
from homeassistant.components.http import HomeAssistantView
|
||||
|
||||
from .const import DATA_WEBHOOK, DOMAIN, OAUTH_BASE
|
||||
from .oauth_legacy import (
|
||||
_PKCE_CHALLENGE_RE,
|
||||
_TOKEN_RESPONSE_HEADERS,
|
||||
ACCESS_TOKEN_TTL,
|
||||
PKCECodeStore,
|
||||
_is_valid_redirect_uri,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
|
||||
# cfg (hass.data[DOMAIN][DATA_WEBHOOK]) key holding the live AutoApproveProvider.
|
||||
# Present ONLY in none mode with the remote endpoint enabled; its presence is
|
||||
# how :func:`mcp_webhook.active_auth_mode` recognises the none-autoapprove live
|
||||
# mode (mirrors the "resource_server"/"oauth_provider" presence keys).
|
||||
CFG_AUTOAPPROVE_PROVIDER = "autoapprove_provider"
|
||||
|
||||
# TOP-LEVEL hass.data flag recording that the two auto-approve views are bound
|
||||
# for this HA session. Not under DOMAIN so it survives async_unload_entry's
|
||||
# teardown — aiohttp cannot unregister a bound view until HA restarts, so the
|
||||
# views (and this ownership flag) must outlive the config entry (mirrors
|
||||
# mcp_webhook._OAUTH_VIEWS_REGISTERED_KEY).
|
||||
_AUTOAPPROVE_VIEWS_REGISTERED_KEY = "ha_mcp_tools_oauth_autoapprove_views_registered"
|
||||
|
||||
# Known MCP OAuth callback URLs always accepted as a redirect target even when
|
||||
# the client_id is not a same-origin URL — claude.ai's connector onboarding
|
||||
# posts its authorization code here. Exact-match only (never a prefix test, so
|
||||
# ``https://claude.ai/api/mcp/auth_callback.evil.example`` cannot slip through).
|
||||
_AUTOAPPROVE_REDIRECT_ALLOWLIST = frozenset(
|
||||
{
|
||||
"https://claude.ai/api/mcp/auth_callback",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _json_not_found() -> web.Response:
|
||||
"""404 JSON body used when none-autoapprove is not the live mode."""
|
||||
return web.json_response({"error": "not_found"}, status=404)
|
||||
|
||||
|
||||
def _json_error(
|
||||
error: str, status: int, description: str | None = None
|
||||
) -> web.Response:
|
||||
"""OAuth-style JSON error (RFC 6749 §5.2 shape) with no-store headers."""
|
||||
body: dict[str, str] = {"error": error}
|
||||
if description is not None:
|
||||
body["error_description"] = description
|
||||
return web.json_response(body, status=status, headers=_TOKEN_RESPONSE_HEADERS)
|
||||
|
||||
|
||||
def _is_valid_autoapprove_redirect(redirect_uri: str) -> bool:
|
||||
"""Open-redirect gate for the auto-approve ``/authorize`` view.
|
||||
|
||||
Exact-match allowlist only, on top of
|
||||
:func:`oauth_legacy._is_valid_redirect_uri`'s scheme/host/port floor. The
|
||||
``client_id`` is NOT consulted: it is attacker-controlled, so validating the
|
||||
redirect against it (even "same origin") does not constrain the redirect
|
||||
target to a trusted host. See the module docstring.
|
||||
"""
|
||||
return (
|
||||
_is_valid_redirect_uri(redirect_uri)
|
||||
and redirect_uri in _AUTOAPPROVE_REDIRECT_ALLOWLIST
|
||||
)
|
||||
|
||||
|
||||
def _redirect_with(redirect_uri: str, **params: str) -> web.Response:
|
||||
"""302 to ``redirect_uri`` with ``params`` merged into its query string."""
|
||||
# yarl ships with aiohttp and handles existing-query merging + encoding
|
||||
# correctly — safer than hand-rolling (matches oauth_legacy.AuthorizeView).
|
||||
import yarl
|
||||
|
||||
url = yarl.URL(redirect_uri).update_query(params)
|
||||
return web.Response(status=302, headers={"Location": str(url)})
|
||||
|
||||
|
||||
class AutoApproveProvider:
|
||||
"""None-mode auto-approve authorization-server state.
|
||||
|
||||
Holds only the PKCE code store shared with :mod:`oauth_legacy`; it owns no
|
||||
signing key and no client credentials (the token it issues is cosmetic).
|
||||
Constructed per registration and stored in ``cfg`` — the views resolve it
|
||||
from ``hass.data`` per request, so a reload minting a fresh provider is
|
||||
transparent (no bound view captures the old one, unlike legacy mode).
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._code_store = PKCECodeStore()
|
||||
|
||||
def issue_code(self, redirect_uri: str, code_challenge: str) -> str | None:
|
||||
"""Issue a one-shot PKCE-bound authorization code (see PKCECodeStore)."""
|
||||
return self._code_store.issue_code(redirect_uri, code_challenge)
|
||||
|
||||
def consume_code(self, code: str, redirect_uri: str, code_verifier: str) -> bool:
|
||||
"""Verify PKCE S256 + one-shot consume a code (see PKCECodeStore)."""
|
||||
return self._code_store.consume_code(code, redirect_uri, code_verifier)
|
||||
|
||||
@staticmethod
|
||||
def issue_access_token() -> str:
|
||||
"""Mint an opaque access token.
|
||||
|
||||
None mode ignores bearers (the secret webhook URL is the credential),
|
||||
so this token grants nothing — but it is a real random string, so a
|
||||
spec-strict client that stores/echoes it is satisfied.
|
||||
"""
|
||||
return secrets.token_urlsafe(32)
|
||||
|
||||
|
||||
def _active_autoapprove_provider(hass: HomeAssistant) -> AutoApproveProvider | None:
|
||||
"""The live none-mode auto-approve provider, or None when it is not live.
|
||||
|
||||
Read live from ``hass.data`` (not captured at view construction) so the
|
||||
bound views serve only while none-autoapprove is the active mode and 404
|
||||
otherwise — mirrors ``mcp_webhook._active_webhook_id``'s per-request gating.
|
||||
"""
|
||||
domain_data = hass.data.get(DOMAIN)
|
||||
if not isinstance(domain_data, dict):
|
||||
return None
|
||||
cfg = domain_data.get(DATA_WEBHOOK)
|
||||
if not isinstance(cfg, dict):
|
||||
return None
|
||||
provider = cfg.get(CFG_AUTOAPPROVE_PROVIDER)
|
||||
return provider if isinstance(provider, AutoApproveProvider) else None
|
||||
|
||||
|
||||
class AutoApproveAuthorizeView(HomeAssistantView):
|
||||
"""None-mode auto-approve ``/authorize`` — issues a code, 302s, no UI.
|
||||
|
||||
Validates ``response_type=code``, PKCE S256, and the redirect_uri
|
||||
open-redirect gate, then issues a PKCE-bound one-time code and redirects
|
||||
straight back to the client. No login page and no consent screen render, so
|
||||
claude.ai's OAuth flow completes invisibly (issue #1969).
|
||||
"""
|
||||
|
||||
requires_auth = False
|
||||
cors_allowed = True
|
||||
url = f"{OAUTH_BASE}/authorize"
|
||||
name = "ha_mcp_tools:oauth:autoapprove-authorize"
|
||||
|
||||
def __init__(self, hass: HomeAssistant) -> None:
|
||||
"""Bind the view to the HA instance; liveness is resolved per request."""
|
||||
self._hass = hass
|
||||
|
||||
async def get(self, request: web.Request) -> web.Response:
|
||||
"""Auto-approve the authorization request or reject with a 400/404."""
|
||||
provider = _active_autoapprove_provider(self._hass)
|
||||
if provider is None:
|
||||
return _json_not_found()
|
||||
|
||||
params = request.query
|
||||
response_type = params.get("response_type", "")
|
||||
redirect_uri = params.get("redirect_uri", "")
|
||||
state = params.get("state", "")
|
||||
code_challenge = params.get("code_challenge", "")
|
||||
code_challenge_method = params.get("code_challenge_method", "")
|
||||
|
||||
if response_type != "code":
|
||||
return _json_error("unsupported_response_type", 400)
|
||||
if code_challenge_method != "S256":
|
||||
return _json_error(
|
||||
"invalid_request", 400, "code_challenge_method must be S256"
|
||||
)
|
||||
if not _PKCE_CHALLENGE_RE.match(code_challenge):
|
||||
return _json_error(
|
||||
"invalid_request", 400, "invalid code_challenge (43-char base64url)"
|
||||
)
|
||||
# SECURITY: an unvalidated redirect_uri would be an open redirector on
|
||||
# the HA origin. Reject in-place (never redirect) unless it exactly
|
||||
# matches a known MCP callback (client_id is attacker-controlled and is
|
||||
# deliberately not consulted — see module docstring).
|
||||
if not _is_valid_autoapprove_redirect(redirect_uri):
|
||||
return _json_error("invalid_request", 400, "invalid redirect_uri")
|
||||
|
||||
code = provider.issue_code(redirect_uri, code_challenge)
|
||||
if code is None:
|
||||
# Pending-code store at capacity (abuse guard) — surface per
|
||||
# RFC 6749 §4.1.2.1 instead of a silent failure.
|
||||
return _redirect_with(
|
||||
redirect_uri, error="temporarily_unavailable", state=state
|
||||
)
|
||||
redirect_params = {"code": code}
|
||||
if state:
|
||||
redirect_params["state"] = state
|
||||
return _redirect_with(redirect_uri, **redirect_params)
|
||||
|
||||
|
||||
class AutoApproveTokenView(HomeAssistantView):
|
||||
"""None-mode auto-approve ``/token`` — PKCE code → opaque access token.
|
||||
|
||||
Public client (no ``client_secret``): the PKCE code_verifier is the only
|
||||
proof required. The returned access token is cosmetic (none mode ignores
|
||||
bearers), but real and opaque. Only the ``authorization_code`` grant is
|
||||
supported — none mode has no refresh cycle.
|
||||
"""
|
||||
|
||||
requires_auth = False
|
||||
cors_allowed = True
|
||||
url = f"{OAUTH_BASE}/token"
|
||||
name = "ha_mcp_tools:oauth:autoapprove-token"
|
||||
|
||||
def __init__(self, hass: HomeAssistant) -> None:
|
||||
"""Bind the view to the HA instance; liveness is resolved per request."""
|
||||
self._hass = hass
|
||||
|
||||
async def post(self, request: web.Request) -> web.Response:
|
||||
"""Exchange a PKCE authorization code for an opaque access token."""
|
||||
provider = _active_autoapprove_provider(self._hass)
|
||||
if provider is None:
|
||||
return _json_not_found()
|
||||
|
||||
form: dict[str, Any] = dict(await request.post())
|
||||
if form.get("grant_type", "") != "authorization_code":
|
||||
return _json_error("unsupported_grant_type", 400)
|
||||
|
||||
code = str(form.get("code", ""))
|
||||
redirect_uri = str(form.get("redirect_uri", ""))
|
||||
code_verifier = str(form.get("code_verifier", ""))
|
||||
if not (code and redirect_uri and code_verifier):
|
||||
return _json_error("invalid_request", 400)
|
||||
if not provider.consume_code(code, redirect_uri, code_verifier):
|
||||
return _json_error("invalid_grant", 400)
|
||||
|
||||
return web.json_response(
|
||||
{
|
||||
"access_token": provider.issue_access_token(),
|
||||
"token_type": "Bearer",
|
||||
"expires_in": ACCESS_TOKEN_TTL,
|
||||
},
|
||||
headers=_TOKEN_RESPONSE_HEADERS,
|
||||
)
|
||||
|
||||
|
||||
def bind_autoapprove_views(hass: HomeAssistant) -> None:
|
||||
"""Bind the two auto-approve views at most once per HA session.
|
||||
|
||||
aiohttp cannot unregister a bound view, so a reload / re-enable / mode
|
||||
switch must reuse the already-bound views — they resolve the active
|
||||
provider from ``hass.data`` per request (see
|
||||
:func:`_active_autoapprove_provider`), so they serve only while
|
||||
none-autoapprove is live and 404 otherwise. The guard flag lives at a
|
||||
top-level ``hass.data`` key that survives config-entry teardown (mirrors
|
||||
:func:`mcp_webhook._register_metadata_views`).
|
||||
"""
|
||||
if hass.data.get(_AUTOAPPROVE_VIEWS_REGISTERED_KEY):
|
||||
return
|
||||
hass.http.register_view(AutoApproveAuthorizeView(hass))
|
||||
hass.http.register_view(AutoApproveTokenView(hass))
|
||||
hass.data[_AUTOAPPROVE_VIEWS_REGISTERED_KEY] = True
|
||||
@@ -0,0 +1,846 @@
|
||||
"""Legacy OAuth 2.1 authorization server for the HA-MCP Server webhook.
|
||||
|
||||
Ported from the webhook-proxy add-on's ``mcp_proxy/oauth.py`` ``OAuthProvider``
|
||||
+ ``AuthorizeView`` + ``TokenView`` (the proven ``legacy`` mode). Self-hosted,
|
||||
single-tenant authorization server with a static client_id/client_secret pair,
|
||||
for MCP clients that need a credential to paste rather than HA core's native
|
||||
OAuth (``ha_auth``) — currently just Google Gemini Spark, whose custom
|
||||
connected apps use the Client ID Metadata Document pattern that HA core's
|
||||
``/auth/authorize`` does not yet support for cross-origin redirect_uris
|
||||
(home-assistant/core#176282).
|
||||
|
||||
Unlike the add-on, this module holds no reference to ``hass``, the webhook id,
|
||||
or a public base URL: the discovery-document layer (RFC 8414 / RFC 9728) and
|
||||
base-URL resolution already live in ``mcp_webhook.py``, shared with the
|
||||
``ha_auth`` mode, and are extended there to be mode-aware. This module owns
|
||||
only the OAuth-specific state (tokens, PKCE codes, client auth) and the two
|
||||
root views the add-on's ``ha_auth`` mode never needed (``ha_auth`` mode has HA
|
||||
core serve its own ``/auth/authorize`` + ``/auth/token``).
|
||||
|
||||
Tokens are opaque, HMAC-SHA256 signed (``body.sig``), and carry enough state
|
||||
(``{kind, iat, exp, jti, cid}``) to validate without a server-side store, so
|
||||
the integration survives HA restarts. ``cid`` (the client_id at issuance time)
|
||||
means rotating the client_id revokes every outstanding token at the restart
|
||||
that rebinds the root views with the new identity — until then the bound
|
||||
provider keeps the old client_id and old tokens keep validating (see
|
||||
:func:`bind_legacy_views`).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import binascii
|
||||
import hashlib
|
||||
import hmac
|
||||
import ipaddress
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
import secrets
|
||||
import time
|
||||
from collections.abc import Callable
|
||||
from html import escape
|
||||
from typing import TYPE_CHECKING, TypedDict
|
||||
from urllib.parse import unquote_plus, urlparse
|
||||
|
||||
from aiohttp import web
|
||||
from homeassistant.components.http import HomeAssistantView
|
||||
|
||||
from .const import WEBHOOK_AUTH_LEGACY
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
# Registered at the HA ROOT rather than under a component-namespaced path, to
|
||||
# match the add-on's legacy mode (see the ownership guard below). This was
|
||||
# motivated by an MCP client that builds ``<host>/authorize`` from the resource
|
||||
# host root instead of reading the ``authorization_endpoint`` metadata field —
|
||||
# observed with Google Gemini Spark, which is what legacy mode exists to serve.
|
||||
# claude.ai, by contrast, DOES honor the advertised ``authorization_endpoint``:
|
||||
# issue #1969's none-mode auto-approve server advertises a component-scoped
|
||||
# ``/authorize`` (see :mod:`oauth_autoapprove`) and claude.ai calls exactly that,
|
||||
# proven live. So the root registration is for the metadata-ignoring clients, not
|
||||
# a hard requirement for claude.ai.
|
||||
AUTHORIZE_PATH = "/authorize"
|
||||
TOKEN_PATH = "/token"
|
||||
|
||||
ACCESS_TOKEN_TTL = 60 * 60 # 1 hour
|
||||
REFRESH_TOKEN_TTL = 30 * 24 * 60 * 60 # 30 days
|
||||
AUTH_CODE_TTL = 5 * 60 # 5 minutes
|
||||
TOKEN_KIND_ACCESS = "access"
|
||||
TOKEN_KIND_REFRESH = "refresh"
|
||||
|
||||
# RFC 6749 §5.1: a /token response body carries the access/refresh credentials,
|
||||
# so it MUST NOT be cached by any intermediary (reverse proxy, Nabu Casa, etc.).
|
||||
_TOKEN_RESPONSE_HEADERS = {"Cache-Control": "no-store", "Pragma": "no-cache"}
|
||||
|
||||
# RFC 8252 §7.3: native/CLI OAuth clients (e.g. GitHub Copilot CLI) receive the
|
||||
# authorization code on a loopback redirect, for which the spec explicitly
|
||||
# permits a plain http scheme. Every non-loopback redirect must still be https.
|
||||
_LOOPBACK_HOSTNAMES = frozenset({"localhost"})
|
||||
|
||||
# RFC 7636 §4.1: code_verifier is 43-128 chars from the unreserved URL set.
|
||||
PKCE_VERIFIER_MIN = 43
|
||||
PKCE_VERIFIER_MAX = 128
|
||||
# SHA-256 → 32 bytes → 43 base64url chars (no padding).
|
||||
PKCE_S256_CHALLENGE_LEN = 43
|
||||
_PKCE_VERIFIER_RE = re.compile(r"^[A-Za-z0-9._~-]+$")
|
||||
_PKCE_CHALLENGE_RE = re.compile(r"^[A-Za-z0-9_-]{43}$")
|
||||
|
||||
# Pending-code dict cap. An attacker spamming /authorize with valid params
|
||||
# could grow the dict between the prune passes that run on each issuance.
|
||||
# 1000 codes is well past anything legitimate (5-min TTL, single-tenant).
|
||||
MAX_PENDING_CODES = 1000
|
||||
|
||||
_RESTART_HINT = (
|
||||
"If this persists, fully restart Home Assistant (Settings -> System -> "
|
||||
"Restart) -- Home Assistant cannot rebind the /authorize and /token "
|
||||
"endpoints to new credentials without a full restart."
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Root-route ownership guard
|
||||
# ---------------------------------------------------------------------------
|
||||
#
|
||||
# The webhook-proxy add-on's legacy mode (both the stable and dev flavors)
|
||||
# ALSO claims the root /authorize + /token routes in the same HA instance —
|
||||
# aiohttp lets the first-registered path win and silently shadows a later
|
||||
# duplicate, and HA cannot unregister a bound view until it restarts. These
|
||||
# two literals are therefore the SAME strings as
|
||||
# ``mcp_proxy.OAUTH_ROUTE_OWNER_KEY`` / ``mcp_proxy.OAUTH_ROUTE_KEY_FINGERPRINT``
|
||||
# (deliberately domain-neutral, not namespaced under this component's DOMAIN)
|
||||
# so whichever integration binds first can be recognized by the other and the
|
||||
# second registrant fails loud instead of being silently shadowed.
|
||||
OAUTH_ROUTE_OWNER_KEY = "webhook_proxy_oauth_route_owner"
|
||||
OAUTH_ROUTE_KEY_FINGERPRINT = "webhook_proxy_oauth_route_key_fingerprint"
|
||||
|
||||
# TOP-LEVEL hass.data key (NOT under DOMAIN, so it survives
|
||||
# async_unload_entry's hass.data.pop(DOMAIN)) holding the LegacyOAuthProvider
|
||||
# instance bound to the root views for this HA session. A reload reuses it
|
||||
# when the credentials match; see bind_legacy_views.
|
||||
_LEGACY_PROVIDER_KEY = "ha_mcp_tools_oauth_legacy_provider"
|
||||
|
||||
# TOP-LEVEL hass.data flag recording whether the currently-bound root views were
|
||||
# registered MID-SESSION (hass already running → the route is not actually live
|
||||
# until a full HA restart) rather than at boot (live immediately). It cannot be
|
||||
# cleared without a real process restart, which wipes hass.data — so it stays
|
||||
# True for the life of a session that late-bound, and is absent/False for a
|
||||
# session that bound cleanly at boot. Read on every reuse so an unrelated reload
|
||||
# before that restart does not falsely report "no restart needed" and clear the
|
||||
# repair (the views are still not live). See bind_legacy_views.
|
||||
_LEGACY_PENDING_RESTART_KEY = "ha_mcp_tools_oauth_legacy_pending_restart"
|
||||
|
||||
# This component's DOMAIN, duplicated here (rather than imported) to avoid a
|
||||
# module-level dependency on const.DOMAIN for the ownership-marker value —
|
||||
# the value written IS "ha_mcp_tools", checked against by name below.
|
||||
_DOMAIN = "ha_mcp_tools"
|
||||
|
||||
|
||||
class LegacyOAuthRouteConflict(RuntimeError):
|
||||
"""Raised when another integration already owns the root OAuth routes."""
|
||||
|
||||
|
||||
def _oauth_route_fingerprint(
|
||||
client_id: str, client_secret: str, signing_key: bytes
|
||||
) -> str:
|
||||
"""Stable fingerprint of the OAuth identity bound to the root views."""
|
||||
h = hashlib.sha256()
|
||||
h.update(client_id.encode())
|
||||
h.update(b"\0")
|
||||
h.update(client_secret.encode())
|
||||
h.update(b"\0")
|
||||
h.update(signing_key)
|
||||
return h.hexdigest()
|
||||
|
||||
|
||||
def _normalize_signing_key(signing_key: bytes | str) -> bytes:
|
||||
"""Accept either raw bytes or a hex string (how entry.data stores it,
|
||||
since entry.data must be JSON-serializable)."""
|
||||
return bytes.fromhex(signing_key) if isinstance(signing_key, str) else signing_key
|
||||
|
||||
|
||||
def bind_legacy_views(
|
||||
hass: HomeAssistant,
|
||||
client_id: str,
|
||||
client_secret: str,
|
||||
signing_key: bytes | str,
|
||||
) -> tuple[LegacyOAuthProvider, bool]:
|
||||
"""Bind the root ``/authorize`` + ``/token`` views at most once per HA session.
|
||||
|
||||
Returns ``(provider, restart_needed)``. ``provider`` is the identity now
|
||||
authoritative for the webhook's bearer gate — on a reload with unchanged
|
||||
credentials this REUSES the already-bound provider (aiohttp cannot rebind
|
||||
a view, so a fresh provider object would mint tokens the bound views never
|
||||
issued and vice versa). ``restart_needed`` is True when:
|
||||
|
||||
* the currently-bound views were registered after HA finished starting (a
|
||||
route registered while ``hass.is_running`` is not actually live until a
|
||||
restart — this mirrors the add-on's ``oauth_restart_needed =
|
||||
hass.is_running``) and that restart has not happened yet — this pending
|
||||
state persists across config-entry reloads until a real HA restart wipes
|
||||
``hass.data``, or
|
||||
* the credentials changed since the currently-bound views were registered
|
||||
(the bound views keep serving the OLD identity until a restart rebinds
|
||||
them with the new one).
|
||||
|
||||
Raises :class:`LegacyOAuthRouteConflict` when the webhook-proxy add-on (or
|
||||
its dev flavor) already owns the root routes in this HA instance.
|
||||
"""
|
||||
key_bytes = _normalize_signing_key(signing_key)
|
||||
fingerprint = _oauth_route_fingerprint(client_id, client_secret, key_bytes)
|
||||
|
||||
owner = hass.data.get(OAUTH_ROUTE_OWNER_KEY)
|
||||
if owner is not None and owner != _DOMAIN:
|
||||
_LOGGER.error(
|
||||
"HA-MCP: cannot enable legacy OAuth mode -- the Webhook Proxy "
|
||||
"add-on ('%s') already owns the root /authorize and /token routes "
|
||||
"in this Home Assistant instance, and Home Assistant cannot "
|
||||
"release them until it restarts. Stop that add-on and restart "
|
||||
"Home Assistant, then enable legacy mode again.",
|
||||
owner,
|
||||
)
|
||||
raise LegacyOAuthRouteConflict(owner)
|
||||
|
||||
bound_provider = hass.data.get(_LEGACY_PROVIDER_KEY)
|
||||
if owner == _DOMAIN and isinstance(bound_provider, LegacyOAuthProvider):
|
||||
bound_fingerprint = hass.data.get(OAUTH_ROUTE_KEY_FINGERPRINT)
|
||||
creds_changed = bound_fingerprint != fingerprint
|
||||
if creds_changed:
|
||||
_LOGGER.warning(
|
||||
"HA-MCP: legacy OAuth credentials changed but the bound root "
|
||||
"views still use the previous ones -- a Home Assistant "
|
||||
"restart is required to activate the new credentials."
|
||||
)
|
||||
# Still restart-pending if the views were late-bound this session (flag
|
||||
# persisted below) OR the credentials just changed. Reading the flag
|
||||
# rather than recomputing keeps an unrelated reload from clearing a
|
||||
# still-pending restart repair while the routes remain not-live.
|
||||
pending = bool(hass.data.get(_LEGACY_PENDING_RESTART_KEY))
|
||||
return bound_provider, pending or creds_changed
|
||||
|
||||
# First registration this HA session.
|
||||
provider = LegacyOAuthProvider(
|
||||
client_id=client_id,
|
||||
client_secret=client_secret,
|
||||
signing_key=key_bytes,
|
||||
active_mode_getter=lambda: _live_auth_mode(hass),
|
||||
)
|
||||
hass.http.register_view(AuthorizeView(provider))
|
||||
hass.http.register_view(TokenView(provider))
|
||||
hass.data[OAUTH_ROUTE_OWNER_KEY] = _DOMAIN
|
||||
hass.data[OAUTH_ROUTE_KEY_FINGERPRINT] = fingerprint
|
||||
hass.data[_LEGACY_PROVIDER_KEY] = provider
|
||||
# A first registration happening mid-session isn't live until a full HA
|
||||
# restart; flag it. At HA boot (hass.is_running is still False while
|
||||
# integrations are being set up) it binds cleanly. Persist the pending
|
||||
# state so a later reload before that restart reuses it (see the reuse
|
||||
# branch above) rather than recomputing "no restart needed".
|
||||
pending_restart = hass.is_running
|
||||
hass.data[_LEGACY_PENDING_RESTART_KEY] = pending_restart
|
||||
return provider, pending_restart
|
||||
|
||||
|
||||
def legacy_credentials_active(
|
||||
hass: HomeAssistant,
|
||||
client_id: str,
|
||||
client_secret: str,
|
||||
signing_key: bytes | str,
|
||||
) -> bool:
|
||||
"""Whether the bound root views currently serve exactly these credentials.
|
||||
|
||||
False while a credential rotation is pending a restart (the bound provider
|
||||
keeps the previous identity until then — see :func:`bind_legacy_views`),
|
||||
when another integration owns the routes, or when legacy OAuth was never
|
||||
bound this session. Callers use this to withhold rotated credentials from
|
||||
surfaces a still-valid old-identity token can read — the admin startup log
|
||||
in particular, which is reachable through the server's own log tools
|
||||
(review finding on #1880).
|
||||
"""
|
||||
if hass.data.get(OAUTH_ROUTE_OWNER_KEY) != _DOMAIN:
|
||||
return False
|
||||
bound = hass.data.get(OAUTH_ROUTE_KEY_FINGERPRINT)
|
||||
if not isinstance(bound, str):
|
||||
return False
|
||||
current = _oauth_route_fingerprint(
|
||||
client_id, client_secret, _normalize_signing_key(signing_key)
|
||||
)
|
||||
return hmac.compare_digest(bound, current)
|
||||
|
||||
|
||||
def legacy_restart_pending(hass: HomeAssistant) -> bool:
|
||||
"""Whether the root views were bound mid-session and are not live until a
|
||||
restart (see :func:`bind_legacy_views`). Distinct from
|
||||
:func:`legacy_credentials_active`: at a mid-session FIRST enable the bound
|
||||
views serve exactly the current credentials (active is True) yet
|
||||
``/authorize`` is not live until the pending restart — the admin surfaces
|
||||
(options hint, startup log) use this to caveat credentials that are
|
||||
correct but not yet serving."""
|
||||
return bool(hass.data.get(_LEGACY_PENDING_RESTART_KEY))
|
||||
|
||||
|
||||
def _live_auth_mode(hass: HomeAssistant) -> str | None:
|
||||
"""Read the CURRENTLY configured webhook auth mode from hass.data.
|
||||
|
||||
Deferred import to avoid a module cycle: mcp_webhook imports this module
|
||||
for the views/provider it registers, so this module cannot import
|
||||
mcp_webhook at load time.
|
||||
"""
|
||||
from .mcp_webhook import active_auth_mode
|
||||
|
||||
return active_auth_mode(hass)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Small helpers (ported from the add-on's oauth.py)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _b64url_encode(raw: bytes) -> str:
|
||||
return base64.urlsafe_b64encode(raw).rstrip(b"=").decode("ascii")
|
||||
|
||||
|
||||
def _b64url_decode(s: str) -> bytes:
|
||||
pad = "=" * (-len(s) % 4)
|
||||
return base64.urlsafe_b64decode(s + pad)
|
||||
|
||||
|
||||
def _is_loopback_host(hostname: str) -> bool:
|
||||
"""True for the loopback hosts RFC 8252 §7.3/§8.3 allows over plain http."""
|
||||
if hostname in _LOOPBACK_HOSTNAMES:
|
||||
return True
|
||||
try:
|
||||
# Covers all of 127.0.0.0/8 and ::1, not just the literal 127.0.0.1.
|
||||
return ipaddress.ip_address(hostname).is_loopback
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
|
||||
def _is_valid_redirect_uri(redirect_uri: str) -> bool:
|
||||
"""Spec-floor validation for OAuth redirect_uri: an https:// URL — or an
|
||||
http:// loopback URL (RFC 8252 §7.3, for native/CLI clients) — with a
|
||||
non-empty host, a valid port, and no fragment. Single-tenant — no per-client
|
||||
allowlist, but reject the obvious bad shapes that would let an attacker
|
||||
direct the flow to an empty/malformed URL."""
|
||||
if not redirect_uri:
|
||||
return False
|
||||
try:
|
||||
parsed = urlparse(redirect_uri)
|
||||
# Accessing .port validates it: urlparse defers the range/format check
|
||||
# until access, so a crafted ':999999' or ':abc' port raises ValueError
|
||||
# HERE (→ clean 400) instead of later in yarl inside _redirect_with,
|
||||
# where it would escape as an uncaught 500 on an unauthenticated view.
|
||||
_ = parsed.port
|
||||
except ValueError:
|
||||
return False
|
||||
if not parsed.hostname:
|
||||
return False
|
||||
if parsed.scheme == "http":
|
||||
# Plain http only for loopback callbacks (native-client flow).
|
||||
if not _is_loopback_host(parsed.hostname):
|
||||
return False
|
||||
elif parsed.scheme != "https":
|
||||
return False
|
||||
# Fragments are not allowed in OAuth redirect URIs (RFC 6749 §3.1.2).
|
||||
return not parsed.fragment
|
||||
|
||||
|
||||
def _text_error(
|
||||
status: int, message: str, *, restart_hint: bool = False
|
||||
) -> web.Response:
|
||||
"""Plain-text error response. ``restart_hint`` appends ``_RESTART_HINT`` —
|
||||
set it only for the stale-registration cases a full HA restart actually
|
||||
unsticks (invalid client_id), not for client-side request mistakes."""
|
||||
text = f"{message}. {_RESTART_HINT}" if restart_hint else message
|
||||
return web.Response(status=status, text=text)
|
||||
|
||||
|
||||
def _json_error(
|
||||
error: str,
|
||||
status: int,
|
||||
headers: dict[str, str] | None = None,
|
||||
*,
|
||||
restart_hint: bool = False,
|
||||
) -> web.Response:
|
||||
"""OAuth JSON error response. ``restart_hint`` carries ``_RESTART_HINT`` in
|
||||
``error_description`` — set it only for the stale-registration case
|
||||
(``invalid_client``), not client-side protocol errors."""
|
||||
body = {"error": error}
|
||||
if restart_hint:
|
||||
body["error_description"] = _RESTART_HINT
|
||||
return web.json_response(body, status=status, headers=headers)
|
||||
|
||||
|
||||
def _json_not_found() -> web.Response:
|
||||
"""404 for a root view whose route is disabled in the active mode, or
|
||||
whose bound provider is stale (see LegacyOAuthProvider.is_active)."""
|
||||
return web.json_response({"error": "not_found"}, status=404)
|
||||
|
||||
|
||||
class _PendingCode(TypedDict):
|
||||
"""Shape of an entry in PKCECodeStore._codes. TypedDict so a typo on
|
||||
one of these keys fails type-check rather than silently treating it as
|
||||
missing."""
|
||||
|
||||
redirect_uri: str
|
||||
code_challenge: str
|
||||
expires: float
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# PKCECodeStore (shared PKCE S256 authorization-code lifecycle)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class PKCECodeStore:
|
||||
"""In-memory PKCE (S256) authorization-code store.
|
||||
|
||||
Shared by :class:`LegacyOAuthProvider` and the none-mode auto-approve
|
||||
server (:mod:`oauth_autoapprove`) so the one-shot code lifecycle — issue at
|
||||
``/authorize``, verify + consume at ``/token`` — has a single
|
||||
implementation instead of two copies. Codes are short-lived
|
||||
(:data:`AUTH_CODE_TTL`), one-shot, bound to the ``redirect_uri`` +
|
||||
``code_challenge`` presented at issuance, and capped
|
||||
(:data:`MAX_PENDING_CODES`) with an expiry prune on each issue. A restart
|
||||
wipes the store, which only forces in-flight authorize/token round-trips to
|
||||
retry.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._codes: dict[str, _PendingCode] = {}
|
||||
|
||||
def issue_code(self, redirect_uri: str, code_challenge: str) -> str | None:
|
||||
"""Issue a one-shot authorization code, or None if the pending-code
|
||||
store is at capacity (signals an abuse attempt — see MAX_PENDING_CODES)."""
|
||||
now = time.time()
|
||||
self._codes = {k: v for k, v in self._codes.items() if v["expires"] > now}
|
||||
if len(self._codes) >= MAX_PENDING_CODES:
|
||||
_LOGGER.warning(
|
||||
"HA-MCP OAuth: pending-code store at cap (%d); refusing "
|
||||
"new issuance until existing codes expire or are consumed.",
|
||||
MAX_PENDING_CODES,
|
||||
)
|
||||
return None
|
||||
code = secrets.token_urlsafe(32)
|
||||
self._codes[code] = {
|
||||
"redirect_uri": redirect_uri,
|
||||
"code_challenge": code_challenge,
|
||||
"expires": now + AUTH_CODE_TTL,
|
||||
}
|
||||
return code
|
||||
|
||||
def consume_code(self, code: str, redirect_uri: str, code_verifier: str) -> bool:
|
||||
"""One-shot consume ``code``, verifying its PKCE S256 challenge.
|
||||
|
||||
Returns True only for a live, unexpired code whose stored
|
||||
``redirect_uri`` matches and whose ``code_challenge`` equals
|
||||
``base64url(SHA-256(code_verifier))``. The code is popped (one-shot)
|
||||
before any check that can fail, so a failed attempt still burns it.
|
||||
"""
|
||||
# Validate the verifier shape per RFC 7636 §4.1 before doing any
|
||||
# crypto. A confused client passing an empty/short verifier should be
|
||||
# rejected explicitly rather than silently hashing junk.
|
||||
if not (PKCE_VERIFIER_MIN <= len(code_verifier) <= PKCE_VERIFIER_MAX):
|
||||
return False
|
||||
if not _PKCE_VERIFIER_RE.match(code_verifier):
|
||||
return False
|
||||
entry = self._codes.pop(code, None)
|
||||
if entry is None:
|
||||
return False
|
||||
if entry["expires"] < time.time():
|
||||
return False
|
||||
if entry["redirect_uri"] != redirect_uri:
|
||||
return False
|
||||
# PKCE S256 verification: SHA-256(verifier) base64url(no pad) == challenge
|
||||
derived = _b64url_encode(hashlib.sha256(code_verifier.encode()).digest())
|
||||
return hmac.compare_digest(
|
||||
derived.encode("ascii"), entry["code_challenge"].encode("ascii")
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# LegacyOAuthProvider
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class LegacyOAuthProvider:
|
||||
"""Holds legacy-OAuth state: token issue/validate, PKCE codes, client auth.
|
||||
|
||||
Constructed once per bind (see :func:`bind_legacy_views`), not once per
|
||||
config-entry reload — see that function's docstring for why. Holds no
|
||||
reference to ``hass``; ``active_mode_getter`` is how the bound root views
|
||||
learn whether legacy is STILL the live mode on each request (a reload that
|
||||
switches away leaves this same instance bound but inactive — see
|
||||
:meth:`is_active`).
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
client_id: str,
|
||||
client_secret: str,
|
||||
signing_key: bytes | str,
|
||||
active_mode_getter: Callable[[], str | None],
|
||||
) -> None:
|
||||
if not client_id:
|
||||
raise ValueError("client_id must be a non-empty string")
|
||||
if not client_secret:
|
||||
raise ValueError("client_secret must be a non-empty string")
|
||||
key_bytes = _normalize_signing_key(signing_key)
|
||||
if len(key_bytes) < 32:
|
||||
raise ValueError("signing_key must be at least 32 bytes")
|
||||
self._client_id = client_id
|
||||
self._client_secret = client_secret
|
||||
self._signing_key = key_bytes
|
||||
self._active_mode_getter = active_mode_getter
|
||||
# PKCE authorization codes live in the shared store (also used by the
|
||||
# none-mode auto-approve server) — see :class:`PKCECodeStore`.
|
||||
self._code_store = PKCECodeStore()
|
||||
|
||||
@property
|
||||
def client_id(self) -> str:
|
||||
return self._client_id
|
||||
|
||||
def is_active(self) -> bool:
|
||||
"""True iff legacy is the CURRENTLY configured + live webhook auth mode."""
|
||||
return self._active_mode_getter() == WEBHOOK_AUTH_LEGACY
|
||||
|
||||
# -----------------------------------------------------------------
|
||||
# Token issuance / validation
|
||||
# -----------------------------------------------------------------
|
||||
|
||||
def _issue_token(self, kind: str, ttl: int) -> str:
|
||||
now = int(time.time())
|
||||
payload = {
|
||||
"kind": kind,
|
||||
"iat": now,
|
||||
"exp": now + ttl,
|
||||
"jti": secrets.token_urlsafe(12),
|
||||
"cid": self._client_id,
|
||||
}
|
||||
body = _b64url_encode(json.dumps(payload, separators=(",", ":")).encode())
|
||||
sig = hmac.new(self._signing_key, body.encode("ascii"), hashlib.sha256).digest()
|
||||
return f"{body}.{_b64url_encode(sig)}"
|
||||
|
||||
def _validate_token(self, token: str, expected_kind: str) -> bool:
|
||||
try:
|
||||
body, sig_part = token.rsplit(".", 1)
|
||||
except ValueError:
|
||||
return False
|
||||
try:
|
||||
actual_sig = _b64url_decode(sig_part)
|
||||
# body.encode("ascii") is inside the try: a bearer whose
|
||||
# pre-signature segment carries a non-ASCII char raises
|
||||
# UnicodeEncodeError, which must be caught here (return False)
|
||||
# rather than escaping the webhook gate.
|
||||
expected_sig = hmac.new(
|
||||
self._signing_key, body.encode("ascii"), hashlib.sha256
|
||||
).digest()
|
||||
except (ValueError, binascii.Error, UnicodeEncodeError):
|
||||
return False
|
||||
if not hmac.compare_digest(actual_sig, expected_sig):
|
||||
return False
|
||||
try:
|
||||
payload = json.loads(_b64url_decode(body))
|
||||
except (ValueError, json.JSONDecodeError):
|
||||
return False
|
||||
if not isinstance(payload, dict):
|
||||
return False
|
||||
if payload.get("kind") != expected_kind:
|
||||
return False
|
||||
if payload.get("cid") != self._client_id:
|
||||
# Token was issued for a previous client_id config — reject so a
|
||||
# client_id rotation revokes outstanding tokens once the restart
|
||||
# binds a provider carrying the new identity. Pre-restart the
|
||||
# bound provider still holds the OLD client_id, so old tokens
|
||||
# keep validating until then (see bind_legacy_views).
|
||||
return False
|
||||
# Valid up to but not including `exp` (RFC 7519 §4.1.4 convention).
|
||||
return bool(payload.get("exp", 0) > int(time.time()))
|
||||
|
||||
def issue_access_token(self) -> str:
|
||||
return self._issue_token(TOKEN_KIND_ACCESS, ACCESS_TOKEN_TTL)
|
||||
|
||||
def issue_refresh_token(self) -> str:
|
||||
return self._issue_token(TOKEN_KIND_REFRESH, REFRESH_TOKEN_TTL)
|
||||
|
||||
def validate_access_token(self, token: str) -> bool:
|
||||
return self._validate_token(token, TOKEN_KIND_ACCESS)
|
||||
|
||||
def validate_refresh_token(self, token: str) -> bool:
|
||||
return self._validate_token(token, TOKEN_KIND_REFRESH)
|
||||
|
||||
def validate_bearer(self, request: web.Request) -> bool:
|
||||
header = request.headers.get("Authorization", "")
|
||||
if not header.lower().startswith("bearer "):
|
||||
return False
|
||||
token = header[7:].strip()
|
||||
return self.validate_access_token(token)
|
||||
|
||||
# -----------------------------------------------------------------
|
||||
# Authorization codes (PKCE)
|
||||
# -----------------------------------------------------------------
|
||||
|
||||
def issue_code(self, redirect_uri: str, code_challenge: str) -> str | None:
|
||||
"""Issue a one-shot PKCE-bound authorization code (see PKCECodeStore)."""
|
||||
return self._code_store.issue_code(redirect_uri, code_challenge)
|
||||
|
||||
def consume_code(self, code: str, redirect_uri: str, code_verifier: str) -> bool:
|
||||
"""Verify PKCE S256 + one-shot consume a code (see PKCECodeStore)."""
|
||||
return self._code_store.consume_code(code, redirect_uri, code_verifier)
|
||||
|
||||
# -----------------------------------------------------------------
|
||||
# Client authentication
|
||||
# -----------------------------------------------------------------
|
||||
|
||||
def authenticate_client(
|
||||
self, client_id: str | None, client_secret: str | None
|
||||
) -> bool:
|
||||
if not client_id or not client_secret:
|
||||
return False
|
||||
return hmac.compare_digest(
|
||||
client_id.encode(), self._client_id.encode()
|
||||
) and hmac.compare_digest(client_secret.encode(), self._client_secret.encode())
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Views (root /authorize + /token)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class AuthorizeView(HomeAssistantView):
|
||||
"""OAuth /authorize endpoint with a minimal consent page."""
|
||||
|
||||
requires_auth = False
|
||||
url = AUTHORIZE_PATH
|
||||
name = "ha_mcp_tools:oauth:authorize"
|
||||
|
||||
def __init__(self, provider: LegacyOAuthProvider) -> None:
|
||||
self._provider = provider
|
||||
|
||||
@staticmethod
|
||||
def _redirect_with(redirect_uri: str, **params: str) -> web.Response:
|
||||
# yarl ships with aiohttp and handles existing-query-string merging
|
||||
# plus parameter encoding correctly — safer than hand-rolling.
|
||||
import yarl
|
||||
|
||||
url = yarl.URL(redirect_uri).update_query(params)
|
||||
return web.Response(status=302, headers={"Location": str(url)})
|
||||
|
||||
async def get(self, request: web.Request) -> web.Response:
|
||||
if not self._provider.is_active():
|
||||
# Serve ONLY while legacy is the live mode. Both ha_auth/none (HA
|
||||
# core or the secret URL is the authority) and a not-yet-live
|
||||
# entry mean this root view must not serve — HA can't rebind or
|
||||
# drop it without a restart, so a mode switch away leaves it
|
||||
# bound. Refuse it.
|
||||
return _text_error(404, "not found")
|
||||
params = request.query
|
||||
client_id = params.get("client_id", "")
|
||||
redirect_uri = params.get("redirect_uri", "")
|
||||
state = params.get("state", "")
|
||||
code_challenge = params.get("code_challenge", "")
|
||||
code_challenge_method = params.get("code_challenge_method", "")
|
||||
response_type = params.get("response_type", "")
|
||||
|
||||
err = self._validate_authorize_params(
|
||||
response_type=response_type,
|
||||
client_id=client_id,
|
||||
redirect_uri=redirect_uri,
|
||||
code_challenge=code_challenge,
|
||||
code_challenge_method=code_challenge_method,
|
||||
)
|
||||
if err is not None:
|
||||
return err
|
||||
|
||||
# Render minimal consent page. Showing the redirect_uri lets the user
|
||||
# verify the flow goes back to a domain they recognize.
|
||||
html = f"""<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Authorize MCP Connector</title>
|
||||
<style>
|
||||
body {{ font-family: system-ui, sans-serif; max-width: 36rem; margin: 4rem auto; padding: 0 1rem; }}
|
||||
code {{ background: #f4f4f4; padding: 2px 6px; border-radius: 3px; word-break: break-all; }}
|
||||
button {{ padding: 0.5rem 1rem; font-size: 1rem; margin-right: 0.5rem; }}
|
||||
.approve {{ background: #2563eb; color: white; border: none; }}
|
||||
.deny {{ background: #e5e7eb; color: #111; border: none; }}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Authorize MCP Connector</h1>
|
||||
<p>An MCP client is requesting access to your Home Assistant MCP server.</p>
|
||||
<p>It will redirect to:<br><code>{escape(redirect_uri)}</code></p>
|
||||
<p>Only allow this if you started this connection yourself.</p>
|
||||
<form method="POST" action="{AUTHORIZE_PATH}">
|
||||
<input type="hidden" name="client_id" value="{escape(client_id)}">
|
||||
<input type="hidden" name="redirect_uri" value="{escape(redirect_uri)}">
|
||||
<input type="hidden" name="state" value="{escape(state)}">
|
||||
<input type="hidden" name="code_challenge" value="{escape(code_challenge)}">
|
||||
<button class="approve" type="submit" name="action" value="approve">Allow</button>
|
||||
<button class="deny" type="submit" name="action" value="deny">Deny</button>
|
||||
</form>
|
||||
</body>
|
||||
</html>"""
|
||||
return web.Response(text=html, content_type="text/html")
|
||||
|
||||
async def post(self, request: web.Request) -> web.Response:
|
||||
if not self._provider.is_active():
|
||||
return _text_error(404, "not found")
|
||||
data = await request.post()
|
||||
action = str(data.get("action", ""))
|
||||
client_id = str(data.get("client_id", ""))
|
||||
redirect_uri = str(data.get("redirect_uri", ""))
|
||||
state = str(data.get("state", ""))
|
||||
code_challenge = str(data.get("code_challenge", ""))
|
||||
|
||||
# Re-validate everything from the form — never trust hidden fields.
|
||||
# response_type/method aren't carried on the POST so we hard-code the
|
||||
# spec values here; the validator still applies all the same rules to
|
||||
# the user-influenceable fields.
|
||||
err = self._validate_authorize_params(
|
||||
response_type="code",
|
||||
client_id=client_id,
|
||||
redirect_uri=redirect_uri,
|
||||
code_challenge=code_challenge,
|
||||
code_challenge_method="S256",
|
||||
)
|
||||
if err is not None:
|
||||
return err
|
||||
|
||||
if action == "deny":
|
||||
return self._redirect_with(redirect_uri, error="access_denied", state=state)
|
||||
if action != "approve":
|
||||
return _text_error(400, "invalid action")
|
||||
|
||||
code = self._provider.issue_code(redirect_uri, code_challenge)
|
||||
if code is None:
|
||||
# Pending-code store at cap → signal back per RFC 6749 §4.1.2.1
|
||||
# instead of silently failing.
|
||||
return self._redirect_with(
|
||||
redirect_uri, error="temporarily_unavailable", state=state
|
||||
)
|
||||
return self._redirect_with(redirect_uri, code=code, state=state)
|
||||
|
||||
def _validate_authorize_params(
|
||||
self,
|
||||
*,
|
||||
response_type: str,
|
||||
client_id: str,
|
||||
redirect_uri: str,
|
||||
code_challenge: str,
|
||||
code_challenge_method: str,
|
||||
) -> web.Response | None:
|
||||
"""Return a 400 web.Response if any /authorize param is invalid, or
|
||||
None if all checks pass. Centralized so GET and POST share identical
|
||||
validation — the POST path explicitly re-validates the hidden form
|
||||
fields rather than trusting them."""
|
||||
if response_type != "code":
|
||||
return _text_error(400, "unsupported_response_type")
|
||||
if code_challenge_method != "S256":
|
||||
return _text_error(400, "invalid code_challenge_method (S256 required)")
|
||||
if not _PKCE_CHALLENGE_RE.match(code_challenge):
|
||||
return _text_error(
|
||||
400, "invalid code_challenge (must be 43-char base64url)"
|
||||
)
|
||||
if client_id != self._provider.client_id:
|
||||
return _text_error(400, "invalid client_id", restart_hint=True)
|
||||
if not _is_valid_redirect_uri(redirect_uri):
|
||||
return _text_error(
|
||||
400,
|
||||
"redirect_uri must be an https:// URL (or an http:// loopback "
|
||||
"URL) with a valid host and port",
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
class TokenView(HomeAssistantView):
|
||||
"""OAuth /token endpoint: authorization_code + refresh_token grants."""
|
||||
|
||||
requires_auth = False
|
||||
cors_allowed = True
|
||||
url = TOKEN_PATH
|
||||
name = "ha_mcp_tools:oauth:token"
|
||||
|
||||
def __init__(self, provider: LegacyOAuthProvider) -> None:
|
||||
self._provider = provider
|
||||
|
||||
@staticmethod
|
||||
def _extract_client_creds(
|
||||
request: web.Request, form: dict
|
||||
) -> tuple[str | None, str | None]:
|
||||
"""Pull client_id/secret from Basic auth header OR form body."""
|
||||
header = request.headers.get("Authorization", "")
|
||||
if header.lower().startswith("basic "):
|
||||
try:
|
||||
decoded = base64.b64decode(header[6:].strip(), validate=True).decode(
|
||||
"utf-8"
|
||||
)
|
||||
except (ValueError, UnicodeDecodeError, binascii.Error):
|
||||
return None, None
|
||||
if ":" in decoded:
|
||||
cid, _, sec = decoded.partition(":")
|
||||
# RFC 6749 §2.3.1: client_secret_basic values are
|
||||
# application/x-www-form-urlencoded before base64, so decode
|
||||
# them back with unquote_PLUS ("+" means space in that
|
||||
# encoding — plain unquote would leave it literal). A no-op for
|
||||
# the generated credentials (URL-safe alphabets, nothing to
|
||||
# decode) but required for custom overrides containing reserved
|
||||
# characters — matches the form-body path below, which aiohttp
|
||||
# also form-decodes ("+" → space).
|
||||
return unquote_plus(cid), unquote_plus(sec)
|
||||
return None, None
|
||||
return form.get("client_id"), form.get("client_secret")
|
||||
|
||||
async def post(self, request: web.Request) -> web.Response:
|
||||
if not self._provider.is_active():
|
||||
return _json_not_found()
|
||||
form = dict(await request.post())
|
||||
client_id, client_secret = self._extract_client_creds(request, form)
|
||||
if not self._provider.authenticate_client(client_id, client_secret):
|
||||
return _json_error(
|
||||
"invalid_client",
|
||||
401,
|
||||
headers={"WWW-Authenticate": 'Basic realm="HA-MCP OAuth"'},
|
||||
restart_hint=True,
|
||||
)
|
||||
|
||||
grant_type = form.get("grant_type", "")
|
||||
if grant_type == "authorization_code":
|
||||
return await self._handle_authorization_code(form)
|
||||
if grant_type == "refresh_token":
|
||||
return await self._handle_refresh(form)
|
||||
return _json_error("unsupported_grant_type", 400)
|
||||
|
||||
async def _handle_authorization_code(self, form: dict) -> web.Response:
|
||||
code = str(form.get("code", ""))
|
||||
redirect_uri = str(form.get("redirect_uri", ""))
|
||||
code_verifier = str(form.get("code_verifier", ""))
|
||||
if not (code and redirect_uri and code_verifier):
|
||||
return _json_error("invalid_request", 400)
|
||||
if not self._provider.consume_code(code, redirect_uri, code_verifier):
|
||||
return _json_error("invalid_grant", 400)
|
||||
return web.json_response(
|
||||
{
|
||||
"access_token": self._provider.issue_access_token(),
|
||||
"token_type": "Bearer",
|
||||
"expires_in": ACCESS_TOKEN_TTL,
|
||||
"refresh_token": self._provider.issue_refresh_token(),
|
||||
},
|
||||
headers=_TOKEN_RESPONSE_HEADERS,
|
||||
)
|
||||
|
||||
async def _handle_refresh(self, form: dict) -> web.Response:
|
||||
refresh = str(form.get("refresh_token", ""))
|
||||
if not refresh or not self._provider.validate_refresh_token(refresh):
|
||||
return _json_error("invalid_grant", 400)
|
||||
return web.json_response(
|
||||
{
|
||||
"access_token": self._provider.issue_access_token(),
|
||||
"token_type": "Bearer",
|
||||
"expires_in": ACCESS_TOKEN_TTL,
|
||||
"refresh_token": self._provider.issue_refresh_token(),
|
||||
},
|
||||
headers=_TOKEN_RESPONSE_HEADERS,
|
||||
)
|
||||
@@ -96,6 +96,26 @@ read_file:
|
||||
min: 1
|
||||
max: 10000
|
||||
mode: box
|
||||
yaml_path:
|
||||
name: YAML Path
|
||||
description: >-
|
||||
Dotted key path. When set, the response also carries the round-trip
|
||||
text of that YAML subtree under "subtree". Comments and HA tags
|
||||
(!secret, !include) are preserved as written.
|
||||
required: false
|
||||
example: "alert2"
|
||||
selector:
|
||||
text:
|
||||
include_parsed:
|
||||
name: Include Parsed
|
||||
description: >-
|
||||
With yaml_path, also return the subtree as structured data under
|
||||
"parsed". HA tags are rendered to their source form (!secret api_key)
|
||||
and never resolved, so no secret value is exposed.
|
||||
required: false
|
||||
default: false
|
||||
selector:
|
||||
boolean:
|
||||
|
||||
write_file:
|
||||
name: Write File
|
||||
|
||||
@@ -3,40 +3,44 @@
|
||||
"step": {
|
||||
"user": {
|
||||
"title": "HA-MCP Custom Component",
|
||||
"description": "Choose what to add. **HA-MCP Server** runs the full ha-mcp server inside Home Assistant and exposes it through a Home Assistant webhook - this is the install most people want. **HA MCP Tools** adds the privileged file and YAML editing services, which are only needed if you turn on ha-mcp's opt-in file/YAML tools; you can add it later at any time.",
|
||||
"description": "Choose what to add. **HA-MCP Server** runs the full ha-mcp server inside Home Assistant and exposes it through a Home Assistant webhook - this is the install most people want. It is a standalone server and a complete replacement for every other install method (add-on, Docker, uvx/PyPI, stdio); if you run it, do not also run one of those. **HA-MCP File & YAML Tools** adds the privileged file and YAML editing services, needed only if you turn on ha-mcp's opt-in file/YAML tools. If your ha-mcp server already runs elsewhere (add-on, Docker, uvx), you do not need the server entry - add only this File & YAML entry, and only if you use those tools. It works with every server type and can be added later at any time.",
|
||||
"menu_options": {
|
||||
"server": "HA-MCP Server (recommended)",
|
||||
"tools": "HA MCP Tools (optional file & YAML services)"
|
||||
"tools": "HA-MCP File & YAML Tools (optional)"
|
||||
}
|
||||
},
|
||||
"tools": {
|
||||
"title": "HA MCP Tools",
|
||||
"title": "HA-MCP File & YAML Tools",
|
||||
"description": "Sets up the privileged file and YAML configuration services. Only needed if you enable ha-mcp's opt-in file/YAML editing tools (feature flags, off by default) - this applies to every server type, including the in-process HA-MCP Server. You can add or remove this entry at any time."
|
||||
},
|
||||
"server": {
|
||||
"title": "HA-MCP Server",
|
||||
"description": "This runs the full ha-mcp server inside Home Assistant and exposes it remotely through a Home Assistant webhook (reachable via Nabu Casa or any reverse proxy). Select **Submit** to start it; you can change the port, binding, and authentication afterward in the integration options."
|
||||
"description": "This runs the full ha-mcp server inside Home Assistant and exposes it remotely through a Home Assistant webhook (reachable via Nabu Casa or any reverse proxy). It is a standalone server and a complete replacement for the add-on, Docker, uvx/PyPI, and stdio install methods - do not run it alongside another ha-mcp server. Select **Submit** to start it; you can change the port, binding, and authentication afterward in the integration options."
|
||||
}
|
||||
},
|
||||
"abort": {
|
||||
"already_configured": "This entry is already set up.",
|
||||
"unsupported_home_assistant": "The in-process HA-MCP Server requires Home Assistant {required} or newer, but this instance is running {installed}. You can still use this component's HA MCP Tools entry with an external ha-mcp server running as an add-on or Docker container."
|
||||
"unsupported_home_assistant": "The in-process HA-MCP Server requires Home Assistant {required} or newer, but this instance is running {installed}. You can still use this component's HA-MCP File & YAML Tools entry with an external ha-mcp server running as an add-on or Docker container."
|
||||
}
|
||||
},
|
||||
"options": {
|
||||
"abort": {
|
||||
"no_options": "The HA MCP Tools services entry has no options to configure."
|
||||
},
|
||||
"step": {
|
||||
"tools_info": {
|
||||
"title": "HA-MCP File & YAML Tools",
|
||||
"description": "This entry provides the privileged file and YAML editing services used by ha-mcp's opt-in file/YAML tools. There is nothing to configure here yet. Which directories the file tools may access is managed from the ha-mcp server's own settings (its Settings UI / allowed-paths), not here. Per-entry options may appear on this screen in a future release."
|
||||
},
|
||||
"init": {
|
||||
"title": "HA-MCP Server",
|
||||
"description": "Configure the HA-MCP server. {panel_hint}Changes here are applied on save.\n\n{versions}\n\n{connect_url}",
|
||||
"description": "Configure the HA-MCP server. {panel_hint}Changes here are applied on save.\n\n{versions}\n\n{connect_url}\n\n{oauth_creds}",
|
||||
"data": {
|
||||
"channel": "Release channel",
|
||||
"auto_update": "Automatic server updates",
|
||||
"server_port": "MCP server listening port",
|
||||
"bind_host": "Network access",
|
||||
"webhook_auth": "Authentication mode",
|
||||
"oauth_client_id_override": "Legacy OAuth: custom Client ID (optional)",
|
||||
"oauth_client_secret_override": "Legacy OAuth: custom Client Secret (optional)",
|
||||
"oauth_regenerate": "Legacy OAuth: regenerate Client ID/Secret now",
|
||||
"pip_spec": "Developer: ha-mcp package override",
|
||||
"server_url": "Home Assistant URL (advanced)",
|
||||
"external_url": "External URL (optional)",
|
||||
@@ -54,9 +58,12 @@
|
||||
"auto_update": "When on, the newest release of the selected channel is installed automatically - on a reload or restart, and via a periodic check. When off, the server stays on the version currently installed until you turn this back on. This controls the ha-mcp server package only; updates to the HA-MCP Custom Component itself still come through HACS.",
|
||||
"server_port": "The port this server listens on. The ha-mcp add-on uses 9583, so this defaults to 9584 to let both run side by side - if you don't run the add-on, any free port works.",
|
||||
"bind_host": "Who can connect to the MCP server port directly. The default matches the add-on: reachable on your local network, with the secret path as the credential. Choose loopback to allow only connections from the Home Assistant machine itself - the webhook URL and the sidebar panel work either way.",
|
||||
"webhook_auth": "How MCP clients prove themselves at the webhook URL. With the secret URL, the link itself is the credential. With Home Assistant sign-in, clients such as claude.ai log in with a Home Assistant administrator account (OAuth).",
|
||||
"webhook_auth": "How MCP clients prove themselves at the webhook URL. With the secret URL, the link itself is the credential. With Home Assistant sign-in, clients such as claude.ai log in with a Home Assistant administrator account (OAuth). With legacy OAuth, this integration issues its own Client ID and Secret to paste into clients that require one, such as Google Gemini Spark - it needs a Home Assistant restart to turn on or off.",
|
||||
"oauth_client_id_override": "Replaces the auto-generated legacy OAuth Client ID. Leave empty to keep the current one. Only used while Authentication mode is set to legacy OAuth.",
|
||||
"oauth_client_secret_override": "Replaces the auto-generated legacy OAuth Client Secret. Leave empty to keep the current one. Only used while Authentication mode is set to legacy OAuth.",
|
||||
"oauth_regenerate": "One-time action: mints a fresh Client ID and Secret for legacy OAuth mode. It takes effect only after the Home Assistant restart the repair prompt asks for - until you restart, the previous Client ID and Secret keep working and the new ones do not. Also clears the two override fields above.",
|
||||
"pip_spec": "Leave empty. Only for testing a specific ha-mcp build (for example a pre-release pin); overrides the release channel and disables automatic updates until cleared.",
|
||||
"server_url": "The URL the in-process server uses to reach your Home Assistant (usually this instance itself). Leave the default unless you know you need a different route.",
|
||||
"server_url": "The URL the in-process server uses to reach your Home Assistant (usually this instance itself). Leave empty to derive it from this instance's port and SSL settings; set a value only when the server needs a different route.",
|
||||
"external_url": "Shown as the primary connect URL - use this when Home Assistant sits behind your own domain or reverse proxy. Enter the full base address including the scheme. It must point directly at Home Assistant - opening it in a browser should reach your HA login page - and must not contain a port such as :8123 (or any other port), or remote MCP clients won't be able to reach it. Leave empty to use Nabu Casa / the local address automatically.",
|
||||
"webhook_id_override": "Replaces the random webhook secret in the connect URL (/api/webhook/...). The URL is the credential - use a long, hard-to-guess value. Leave empty to keep the current one.",
|
||||
"secret_path_override": "Replaces the random path used for direct access on the server port. Same rule: the path is the credential. Leave empty to keep the current one.",
|
||||
@@ -81,7 +88,7 @@
|
||||
},
|
||||
"component_outdated": {
|
||||
"title": "Update the HA-MCP Custom Component via HACS",
|
||||
"description": "The installed ha-mcp server requires HA-MCP Custom Component {required} or newer, but you have {installed}. Update the component via HACS and restart Home Assistant. The server keeps running in the meantime, but some newer features may not work until the component is updated."
|
||||
"description": "The installed ha-mcp server requires HA-MCP Custom Component {required} or newer, but you have {installed}. Update the component via HACS (open the HA-MCP Custom Component entry and use 'Update information' if no update is shown yet) and restart Home Assistant. The server keeps running in the meantime, but some newer features may not work until the component is updated."
|
||||
},
|
||||
"server_update_held": {
|
||||
"title": "HA-MCP server update waiting for a component update",
|
||||
@@ -90,6 +97,10 @@
|
||||
"legacy_hacs_source": {
|
||||
"title": "Component installed from the legacy repository",
|
||||
"description": "HACS is tracking the main ha-mcp server repository for this component, so HACS shows the server's version numbers (7.x) and the server's release notes here instead of the component's own (1.x). Updates keep working, but stay mislabeled this way. To fix: remove this repository from HACS (your integration settings and config entries are kept), add homeassistant-ai/ha-mcp-integration as a custom repository, reinstall the component from it, and restart Home Assistant."
|
||||
},
|
||||
"legacy_oauth_restart": {
|
||||
"title": "Restart Home Assistant to apply the legacy OAuth change",
|
||||
"description": "The legacy OAuth authentication mode registers its own /authorize and /token web endpoints, which Home Assistant can only bind or release when it fully restarts - whether you just turned this mode on, turned it off, or changed its Client ID/Secret. Restart Home Assistant (Settings - System - Restart) to apply the change; until then the previous behavior stays in effect."
|
||||
}
|
||||
},
|
||||
"selector": {
|
||||
@@ -102,7 +113,8 @@
|
||||
"server_webhook_auth": {
|
||||
"options": {
|
||||
"none": "Secret webhook URL (default)",
|
||||
"ha_auth": "Sign in with Home Assistant (OAuth)"
|
||||
"ha_auth": "Sign in with Home Assistant (OAuth)",
|
||||
"legacy": "Legacy OAuth (Client ID/Secret, for Google Gemini Spark)"
|
||||
}
|
||||
},
|
||||
"llm_api_exposure": {
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
{
|
||||
"config": {
|
||||
"step": {
|
||||
"user": {
|
||||
"title": "HA-MCP Custom Component",
|
||||
"description": "Wähle aus, was du hinzufügen möchtest. **HA-MCP Server** führt den vollständigen ha-mcp-Server in Home Assistant aus und stellt ihn über einen Home Assistant Webhook bereit – das ist die Installation, die die meisten Nutzer brauchen. Er ist ein eigenständiger Server und ein vollständiger Ersatz für alle anderen Installationsmethoden (Add-on, Docker, uvx/PyPI, stdio); wenn du ihn nutzt, solltest du keine dieser anderen Methoden gleichzeitig verwenden. **HA-MCP File & YAML Tools** fügt die privilegierten Datei- und YAML-Bearbeitungsdienste hinzu, die nur benötigt werden, wenn du die opt-in Datei/YAML-Tools von ha-mcp aktivierst. Wenn dein ha-mcp-Server bereits woanders läuft (Add-on, Docker, uvx), benötigst du den Server-Eintrag nicht – füge nur diesen File & YAML-Eintrag hinzu, und auch nur, wenn du diese Tools verwendest. Er funktioniert mit jedem Server-Typ und kann jederzeit später hinzugefügt werden.",
|
||||
"menu_options": {
|
||||
"server": "HA-MCP Server (empfohlen)",
|
||||
"tools": "HA-MCP File & YAML Tools (optional)"
|
||||
}
|
||||
},
|
||||
"tools": {
|
||||
"title": "HA-MCP File & YAML Tools",
|
||||
"description": "Richtet die privilegierten Datei- und YAML-Konfigurationsdienste ein. Nur erforderlich, wenn du die opt-in Datei/YAML-Bearbeitungstools von ha-mcp aktivierst (Feature-Flags, standardmäßig deaktiviert) – dies gilt für jeden Server-Typ, einschließlich des In-Process HA-MCP Servers. Du kannst diesen Eintrag jederzeit hinzufügen oder entfernen."
|
||||
},
|
||||
"server": {
|
||||
"title": "HA-MCP Server",
|
||||
"description": "Dies führt den vollständigen ha-mcp-Server in Home Assistant aus und stellt ihn remote über einen Home Assistant Webhook bereit (erreichbar über Nabu Casa oder jeden Reverse Proxy). Er ist ein eigenständiger Server und ein vollständiger Ersatz für die Add-on-, Docker-, uvx/PyPI- und stdio-Installationsmethoden – führe ihn nicht neben einem anderen ha-mcp-Server aus. Wähle **Absenden**, um ihn zu starten; du kannst Port, Binding und Authentifizierung danach in den Integrationsoptionen ändern."
|
||||
}
|
||||
},
|
||||
"abort": {
|
||||
"already_configured": "Dieser Eintrag ist bereits eingerichtet.",
|
||||
"unsupported_home_assistant": "Der In-Process HA-MCP Server benötigt Home Assistant {required} oder neuer, aber diese Instanz läuft mit {installed}. Du kannst weiterhin den HA-MCP File & YAML Tools-Eintrag dieses Components mit einem externen ha-mcp-Server verwenden, der als Add-on oder Docker-Container läuft."
|
||||
}
|
||||
},
|
||||
"options": {
|
||||
"step": {
|
||||
"tools_info": {
|
||||
"title": "HA-MCP File & YAML Tools",
|
||||
"description": "Dieser Eintrag stellt die privilegierten Datei- und YAML-Bearbeitungsdienste bereit, die von den opt-in Datei/YAML-Tools von ha-mcp verwendet werden. Hier gibt es noch nichts zu konfigurieren. Welche Verzeichnisse die Datei-Tools nutzen dürfen, wird in den eigenen Einstellungen des ha-mcp-Servers (Settings UI / allowed-paths) verwaltet, nicht hier. Pro-Eintrag-Optionen könnten in einem zukünftigen Release auf diesem Bildschirm erscheinen."
|
||||
},
|
||||
"init": {
|
||||
"title": "HA-MCP Server",
|
||||
"description": "Konfiguriere den HA-MCP Server. {panel_hint}Änderungen hier werden beim Speichern angewendet.\n\n{versions}\n\n{connect_url}\n\n{oauth_creds}",
|
||||
"data": {
|
||||
"channel": "Release-Kanal",
|
||||
"auto_update": "Automatische Server-Updates",
|
||||
"server_port": "MCP-Server Listening-Port",
|
||||
"bind_host": "Netzwerkzugriff",
|
||||
"webhook_auth": "Authentifizierungsmodus",
|
||||
"oauth_client_id_override": "Legacy OAuth: benutzerdefinierte Client ID (optional)",
|
||||
"oauth_client_secret_override": "Legacy OAuth: benutzerdefiniertes Client Secret (optional)",
|
||||
"oauth_regenerate": "Legacy OAuth: Client ID/Secret jetzt neu generieren",
|
||||
"pip_spec": "Entwickler: ha-mcp Package Override",
|
||||
"server_url": "Home Assistant URL (erweitert)",
|
||||
"external_url": "Externe URL (optional)",
|
||||
"webhook_id_override": "Benutzerdefiniertes Webhook-Secret (optional)",
|
||||
"secret_path_override": "Benutzerdefinierter Direktzugriffs-Pfad (optional)",
|
||||
"regenerate_secrets": "Verbindungs-Secrets jetzt neu generieren",
|
||||
"enable_webhook": "Remote-Zugriff über Webhook",
|
||||
"enable_llm_api": "Conversation-Agent LLM API",
|
||||
"llm_api_exposure": "Conversation-Agent Tool-Bereitstellung",
|
||||
"enable_startup_notification": "Start-Benachrichtigung",
|
||||
"enable_sidebar_panel": "Sidebar-Einstellungen-Panel"
|
||||
},
|
||||
"data_description": {
|
||||
"channel": "Stable installiert das neueste stabile Release; Development installiert den neuesten Development-Build. Wenn automatische Updates an sind, installieren ein Reload oder Restart sowie eine regelmäßige Prüfung den neuesten Build des ausgewählten Kanals. Ein Developer Package Override unten hat Vorrang und deaktiviert automatische Updates.",
|
||||
"auto_update": "Wenn an, wird das neueste Release des ausgewählten Kanals automatisch installiert – bei einem Reload oder Restart und über eine regelmäßige Prüfung. Wenn aus, bleibt der Server auf der aktuell installierten Version, bis du dies wieder einschaltest. Dies steuert nur das ha-mcp Server-Package; Updates für das HA-MCP Custom Component selbst kommen weiterhin über HACS.",
|
||||
"server_port": "Der Port, auf dem dieser Server lauscht. Das ha-mcp Add-on nutzt 9583, daher ist hier standardmäßig 9584 eingestellt, damit beide parallel laufen können – wenn du das Add-on nicht nutzt, funktioniert jeder freie Port.",
|
||||
"bind_host": "Wer sich direkt mit dem MCP-Server-Port verbinden kann. Der Standard entspricht dem Add-on: erreichbar in deinem lokalen Netzwerk, mit dem Secret-Pfad als Credential. Wähle Loopback, um nur Verbindungen von der Home Assistant Maschine selbst zuzulassen – die Webhook-URL und das Sidebar-Panel funktionieren in beiden Fällen.",
|
||||
"webhook_auth": "Wie sich MCP-Clients an der Webhook-URL authentifizieren. Mit der Secret-URL ist der Link selbst das Credential. Mit Home Assistant Sign-in melden sich Clients wie claude.ai mit einem Home Assistant Administrator-Konto an (OAuth). Mit Legacy OAuth stellt diese Integration eigene Client ID und Secret aus, die in Clients eingefügt werden, die eines benötigen, wie Google Gemini Spark – ein Home Assistant Restart ist zum Ein- oder Ausschalten erforderlich.",
|
||||
"oauth_client_id_override": "Ersetzt die automatisch generierte Legacy OAuth Client ID. Leer lassen, um die aktuelle zu behalten. Wird nur verwendet, während der Authentifizierungsmodus auf Legacy OAuth gesetzt ist.",
|
||||
"oauth_client_secret_override": "Ersetzt das automatisch generierte Legacy OAuth Client Secret. Leer lassen, um das aktuelle zu behalten. Wird nur verwendet, während der Authentifizierungsmodus auf Legacy OAuth gesetzt ist.",
|
||||
"oauth_regenerate": "Einmalige Aktion: erstellt eine neue Client ID und Secret für den Legacy OAuth Modus. Sie wird erst nach dem Home Assistant Restart wirksam, zu dem die Repair-Meldung auffordert – bis zum Restart funktionieren die vorherigen Client ID und Secret weiter, die neuen noch nicht. Löscht auch die beiden Override-Felder oben.",
|
||||
"pip_spec": "Leer lassen. Nur zum Testen eines spezifischen ha-mcp Builds (z.B. ein Pre-Release-Pin); überschreibt den Release-Kanal und deaktiviert automatische Updates, bis es geleert wird.",
|
||||
"server_url": "Die URL, die der In-Process-Server nutzt, um deinen Home Assistant zu erreichen (normalerweise diese Instanz selbst). Leer lassen, um sie aus Port und SSL-Einstellungen dieser Instanz abzuleiten; setze nur einen Wert, wenn der Server eine andere Route benötigt.",
|
||||
"external_url": "Wird als primäre Verbindungs-URL angezeigt – nutze dies, wenn Home Assistant hinter deiner eigenen Domain oder einem Reverse Proxy liegt. Gib die vollständige Basisadresse inklusive Schema ein. Sie muss direkt auf Home Assistant zeigen – beim Öffnen im Browser sollte deine HA-Login-Seite erscheinen – und darf keinen Port wie :8123 (oder einen anderen Port) enthalten, sonst können remote MCP-Clients sie nicht erreichen. Leer lassen, um automatisch Nabu Casa / die lokale Adresse zu verwenden.",
|
||||
"webhook_id_override": "Ersetzt das zufällige Webhook-Secret in der Verbindungs-URL (/api/webhook/...). Die URL ist das Credential – nutze einen langen, schwer zu erratenden Wert. Leer lassen, um das aktuelle zu behalten.",
|
||||
"secret_path_override": "Ersetzt den zufälligen Pfad für den Direktzugriff auf dem Server-Port. Gleiche Regel: Der Pfad ist das Credential. Leer lassen, um den aktuellen zu behalten.",
|
||||
"regenerate_secrets": "Einmalige Aktion: erstellt ein neues zufälliges Webhook-Secret und einen Direktzugriffs-Pfad, wodurch die alten Verbindungs-URLs sofort ungültig werden. Löscht auch die beiden Override-Felder oben.",
|
||||
"enable_webhook": "Ausschalten für nur-lokal-Modus: Der Home Assistant Webhook wird gar nicht registriert, sodass nichts – einschließlich Nabu Casa – den Server über Home Assistant erreichen kann. Der direkte Server-Port und das Sidebar-Panel funktionieren weiter.",
|
||||
"enable_llm_api": "Biete das vollständige Toolset für Home Assistant Conversation-Agenten (OpenAI, Google, Ollama, ...) an: Während aktiviert, können Agenten 'HA-MCP Server' unter Control Home Assistant auswählen und die Tools aus Assist-Chat und Sprache nutzen. Das Aktivieren macht es nur auswählbar – nichts wird bereitgestellt, bis du es bei einem Agenten auswählst. Nutzungsanleitung: {llm_api_docs_url}",
|
||||
"llm_api_exposure": "Form des Toolsets, das Conversation-Agenten angeboten wird. Tool Search (Standard) hält den Kontext des Agenten klein: eine kompakte API mit angehefteten Tools plus Such/Ausführungs-Meta-Tools. Full Catalog listet jedes bereitgestellte Tool direkt auf – besser für Modelle mit großem Kontext. Both registriert beide nebeneinander, sodass jeder Agent unter Control Home Assistant sein eigenes wählt. Die Tool-Bereitstellung pro Tool wird im HA-MCP Settings Panel verwaltet; Details: {llm_api_docs_url}",
|
||||
"enable_startup_notification": "Zeige bei jedem Server-Start eine Benachrichtigung, die auf die nur-für-Administratoren-Einstellungsoberflächen verweist. Ausschalten, um still zu starten – die Verbindungs-URLs erscheinen weiterhin im Home Assistant Log.",
|
||||
"enable_sidebar_panel": "Zeige das HA-MCP Settings Panel in der Sidebar (nur Administratoren). Ausschalten, um den Sidebar-Eintrag zu entfernen – Server-Optionen bleiben auf diesem Bildschirm verfügbar."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"issues": {
|
||||
"server_start_failed": {
|
||||
"title": "Der HA-MCP In-Process-Server konnte nicht gestartet werden",
|
||||
"description": "Der HA-MCP In-Process-Server konnte nicht in Home Assistant gestartet werden:\n\n{detail}\n\nPrüfe die Home Assistant Logs und lade dann die Integration neu (oder behebe das zugrunde liegende Problem und lade neu), um es erneut zu versuchen."
|
||||
},
|
||||
"server_package_install_failed": {
|
||||
"title": "Das HA-MCP In-Process-Server Package konnte nicht installiert werden",
|
||||
"description": "Die Installation des ha-mcp Packages für den In-Process-Server ist fehlgeschlagen:\n\n{detail}\n\nBehebe das oben beschriebene Kompatibilitäts- oder Installationsproblem und lade dann die Integration neu, um es erneut zu versuchen."
|
||||
},
|
||||
"component_outdated": {
|
||||
"title": "Aktualisiere das HA-MCP Custom Component über HACS",
|
||||
"description": "Der installierte ha-mcp Server benötigt HA-MCP Custom Component {required} oder neuer, aber du hast {installed}. Aktualisiere das Component über HACS (öffne den HA-MCP Custom Component-Eintrag und nutze 'Update information', falls noch kein Update angezeigt wird) und starte Home Assistant neu. Der Server läuft in der Zwischenzeit weiter, aber einige neuere Features funktionieren möglicherweise nicht, bis das Component aktualisiert ist."
|
||||
},
|
||||
"server_update_held": {
|
||||
"title": "HA-MCP Server-Update wartet auf ein Component-Update",
|
||||
"description": "ha-mcp Server {latest} ist verfügbar, aber dieses Release hat auch das HA-MCP Custom Component aktualisiert (auf {shipped}; du nutzt {running}). Um zu vermeiden, dass eine Server-Version gestartet wird, die nie mit dem laufenden Component getestet wurde, ist das automatische Server-Update ausgesetzt, bis das Component aktualisiert ist.\n\nAktualisiere das Component über HACS (öffne den HA-MCP Custom Component-Eintrag und nutze 'Update information', falls noch kein Update angezeigt wird), starte dann Home Assistant neu – das Server-Update wird danach automatisch installiert. Um das Server-Update trotzdem zu installieren, drücke Install bei der HA-MCP Server Update-Entität."
|
||||
},
|
||||
"legacy_hacs_source": {
|
||||
"title": "Component aus dem Legacy-Repository installiert",
|
||||
"description": "HACS verfolgt das Haupt-ha-mcp-Server-Repository für dieses Component, daher zeigt HACS hier die Versionsnummern des Servers (7.x) und die Release-Notes des Servers statt der eigenen des Components (1.x). Updates funktionieren weiter, bleiben aber so falsch beschriftet. Um zu beheben: Entferne dieses Repository aus HACS (deine Integrationseinstellungen und Config-Einträge bleiben erhalten), füge homeassistant-ai/ha-mcp-integration als Custom Repository hinzu, installiere das Component daraus neu und starte Home Assistant neu."
|
||||
},
|
||||
"legacy_oauth_restart": {
|
||||
"title": "Starte Home Assistant neu, um die Legacy OAuth-Änderung anzuwenden",
|
||||
"description": "Der Legacy OAuth-Authentifizierungsmodus registriert eigene /authorize- und /token-Web-Endpunkte, die Home Assistant nur bei einem vollständigen Restart binden oder freigeben kann – egal ob du diesen Modus gerade ein- oder ausgeschaltet oder Client ID/Secret geändert hast. Starte Home Assistant neu (Einstellungen - System - Neustart), um die Änderung anzuwenden; bis dahin bleibt das vorherige Verhalten aktiv."
|
||||
}
|
||||
},
|
||||
"selector": {
|
||||
"server_channel": {
|
||||
"options": {
|
||||
"stable": "Stable (empfohlen)",
|
||||
"dev": "Development (neuester Build)"
|
||||
}
|
||||
},
|
||||
"server_webhook_auth": {
|
||||
"options": {
|
||||
"none": "Secret Webhook URL (Standard)",
|
||||
"ha_auth": "Mit Home Assistant anmelden (OAuth)",
|
||||
"legacy": "Legacy OAuth (Client ID/Secret, für Google Gemini Spark)"
|
||||
}
|
||||
},
|
||||
"llm_api_exposure": {
|
||||
"options": {
|
||||
"tool_search": "Tool Search (kompakt, Standard)",
|
||||
"full": "Full Catalog",
|
||||
"both": "Beides (pro Agent wählen)"
|
||||
}
|
||||
}
|
||||
},
|
||||
"entity": {
|
||||
"update": {
|
||||
"server_update": {
|
||||
"name": "Update"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,40 +3,44 @@
|
||||
"step": {
|
||||
"user": {
|
||||
"title": "HA-MCP Custom Component",
|
||||
"description": "Choose what to add. **HA-MCP Server** runs the full ha-mcp server inside Home Assistant and exposes it through a Home Assistant webhook - this is the install most people want. **HA MCP Tools** adds the privileged file and YAML editing services, which are only needed if you turn on ha-mcp's opt-in file/YAML tools; you can add it later at any time.",
|
||||
"description": "Choose what to add. **HA-MCP Server** runs the full ha-mcp server inside Home Assistant and exposes it through a Home Assistant webhook - this is the install most people want. It is a standalone server and a complete replacement for every other install method (add-on, Docker, uvx/PyPI, stdio); if you run it, do not also run one of those. **HA-MCP File & YAML Tools** adds the privileged file and YAML editing services, needed only if you turn on ha-mcp's opt-in file/YAML tools. If your ha-mcp server already runs elsewhere (add-on, Docker, uvx), you do not need the server entry - add only this File & YAML entry, and only if you use those tools. It works with every server type and can be added later at any time.",
|
||||
"menu_options": {
|
||||
"server": "HA-MCP Server (recommended)",
|
||||
"tools": "HA MCP Tools (optional file & YAML services)"
|
||||
"tools": "HA-MCP File & YAML Tools (optional)"
|
||||
}
|
||||
},
|
||||
"tools": {
|
||||
"title": "HA MCP Tools",
|
||||
"title": "HA-MCP File & YAML Tools",
|
||||
"description": "Sets up the privileged file and YAML configuration services. Only needed if you enable ha-mcp's opt-in file/YAML editing tools (feature flags, off by default) - this applies to every server type, including the in-process HA-MCP Server. You can add or remove this entry at any time."
|
||||
},
|
||||
"server": {
|
||||
"title": "HA-MCP Server",
|
||||
"description": "This runs the full ha-mcp server inside Home Assistant and exposes it remotely through a Home Assistant webhook (reachable via Nabu Casa or any reverse proxy). Select **Submit** to start it; you can change the port, binding, and authentication afterward in the integration options."
|
||||
"description": "This runs the full ha-mcp server inside Home Assistant and exposes it remotely through a Home Assistant webhook (reachable via Nabu Casa or any reverse proxy). It is a standalone server and a complete replacement for the add-on, Docker, uvx/PyPI, and stdio install methods - do not run it alongside another ha-mcp server. Select **Submit** to start it; you can change the port, binding, and authentication afterward in the integration options."
|
||||
}
|
||||
},
|
||||
"abort": {
|
||||
"already_configured": "This entry is already set up.",
|
||||
"unsupported_home_assistant": "The in-process HA-MCP Server requires Home Assistant {required} or newer, but this instance is running {installed}. You can still use this component's HA MCP Tools entry with an external ha-mcp server running as an add-on or Docker container."
|
||||
"unsupported_home_assistant": "The in-process HA-MCP Server requires Home Assistant {required} or newer, but this instance is running {installed}. You can still use this component's HA-MCP File & YAML Tools entry with an external ha-mcp server running as an add-on or Docker container."
|
||||
}
|
||||
},
|
||||
"options": {
|
||||
"abort": {
|
||||
"no_options": "The HA MCP Tools services entry has no options to configure."
|
||||
},
|
||||
"step": {
|
||||
"tools_info": {
|
||||
"title": "HA-MCP File & YAML Tools",
|
||||
"description": "This entry provides the privileged file and YAML editing services used by ha-mcp's opt-in file/YAML tools. There is nothing to configure here yet. Which directories the file tools may access is managed from the ha-mcp server's own settings (its Settings UI / allowed-paths), not here. Per-entry options may appear on this screen in a future release."
|
||||
},
|
||||
"init": {
|
||||
"title": "HA-MCP Server",
|
||||
"description": "Configure the HA-MCP server. {panel_hint}Changes here are applied on save.\n\n{versions}\n\n{connect_url}",
|
||||
"description": "Configure the HA-MCP server. {panel_hint}Changes here are applied on save.\n\n{versions}\n\n{connect_url}\n\n{oauth_creds}",
|
||||
"data": {
|
||||
"channel": "Release channel",
|
||||
"auto_update": "Automatic server updates",
|
||||
"server_port": "MCP server listening port",
|
||||
"bind_host": "Network access",
|
||||
"webhook_auth": "Authentication mode",
|
||||
"oauth_client_id_override": "Legacy OAuth: custom Client ID (optional)",
|
||||
"oauth_client_secret_override": "Legacy OAuth: custom Client Secret (optional)",
|
||||
"oauth_regenerate": "Legacy OAuth: regenerate Client ID/Secret now",
|
||||
"pip_spec": "Developer: ha-mcp package override",
|
||||
"server_url": "Home Assistant URL (advanced)",
|
||||
"external_url": "External URL (optional)",
|
||||
@@ -54,9 +58,12 @@
|
||||
"auto_update": "When on, the newest release of the selected channel is installed automatically - on a reload or restart, and via a periodic check. When off, the server stays on the version currently installed until you turn this back on. This controls the ha-mcp server package only; updates to the HA-MCP Custom Component itself still come through HACS.",
|
||||
"server_port": "The port this server listens on. The ha-mcp add-on uses 9583, so this defaults to 9584 to let both run side by side - if you don't run the add-on, any free port works.",
|
||||
"bind_host": "Who can connect to the MCP server port directly. The default matches the add-on: reachable on your local network, with the secret path as the credential. Choose loopback to allow only connections from the Home Assistant machine itself - the webhook URL and the sidebar panel work either way.",
|
||||
"webhook_auth": "How MCP clients prove themselves at the webhook URL. With the secret URL, the link itself is the credential. With Home Assistant sign-in, clients such as claude.ai log in with a Home Assistant administrator account (OAuth).",
|
||||
"webhook_auth": "How MCP clients prove themselves at the webhook URL. With the secret URL, the link itself is the credential. With Home Assistant sign-in, clients such as claude.ai log in with a Home Assistant administrator account (OAuth). With legacy OAuth, this integration issues its own Client ID and Secret to paste into clients that require one, such as Google Gemini Spark - it needs a Home Assistant restart to turn on or off.",
|
||||
"oauth_client_id_override": "Replaces the auto-generated legacy OAuth Client ID. Leave empty to keep the current one. Only used while Authentication mode is set to legacy OAuth.",
|
||||
"oauth_client_secret_override": "Replaces the auto-generated legacy OAuth Client Secret. Leave empty to keep the current one. Only used while Authentication mode is set to legacy OAuth.",
|
||||
"oauth_regenerate": "One-time action: mints a fresh Client ID and Secret for legacy OAuth mode. It takes effect only after the Home Assistant restart the repair prompt asks for - until you restart, the previous Client ID and Secret keep working and the new ones do not. Also clears the two override fields above.",
|
||||
"pip_spec": "Leave empty. Only for testing a specific ha-mcp build (for example a pre-release pin); overrides the release channel and disables automatic updates until cleared.",
|
||||
"server_url": "The URL the in-process server uses to reach your Home Assistant (usually this instance itself). Leave the default unless you know you need a different route.",
|
||||
"server_url": "The URL the in-process server uses to reach your Home Assistant (usually this instance itself). Leave empty to derive it from this instance's port and SSL settings; set a value only when the server needs a different route.",
|
||||
"external_url": "Shown as the primary connect URL - use this when Home Assistant sits behind your own domain or reverse proxy. Enter the full base address including the scheme. It must point directly at Home Assistant - opening it in a browser should reach your HA login page - and must not contain a port such as :8123 (or any other port), or remote MCP clients won't be able to reach it. Leave empty to use Nabu Casa / the local address automatically.",
|
||||
"webhook_id_override": "Replaces the random webhook secret in the connect URL (/api/webhook/...). The URL is the credential - use a long, hard-to-guess value. Leave empty to keep the current one.",
|
||||
"secret_path_override": "Replaces the random path used for direct access on the server port. Same rule: the path is the credential. Leave empty to keep the current one.",
|
||||
@@ -81,7 +88,7 @@
|
||||
},
|
||||
"component_outdated": {
|
||||
"title": "Update the HA-MCP Custom Component via HACS",
|
||||
"description": "The installed ha-mcp server requires HA-MCP Custom Component {required} or newer, but you have {installed}. Update the component via HACS and restart Home Assistant. The server keeps running in the meantime, but some newer features may not work until the component is updated."
|
||||
"description": "The installed ha-mcp server requires HA-MCP Custom Component {required} or newer, but you have {installed}. Update the component via HACS (open the HA-MCP Custom Component entry and use 'Update information' if no update is shown yet) and restart Home Assistant. The server keeps running in the meantime, but some newer features may not work until the component is updated."
|
||||
},
|
||||
"server_update_held": {
|
||||
"title": "HA-MCP server update waiting for a component update",
|
||||
@@ -90,6 +97,10 @@
|
||||
"legacy_hacs_source": {
|
||||
"title": "Component installed from the legacy repository",
|
||||
"description": "HACS is tracking the main ha-mcp server repository for this component, so HACS shows the server's version numbers (7.x) and the server's release notes here instead of the component's own (1.x). Updates keep working, but stay mislabeled this way. To fix: remove this repository from HACS (your integration settings and config entries are kept), add homeassistant-ai/ha-mcp-integration as a custom repository, reinstall the component from it, and restart Home Assistant."
|
||||
},
|
||||
"legacy_oauth_restart": {
|
||||
"title": "Restart Home Assistant to apply the legacy OAuth change",
|
||||
"description": "The legacy OAuth authentication mode registers its own /authorize and /token web endpoints, which Home Assistant can only bind or release when it fully restarts - whether you just turned this mode on, turned it off, or changed its Client ID/Secret. Restart Home Assistant (Settings - System - Restart) to apply the change; until then the previous behavior stays in effect."
|
||||
}
|
||||
},
|
||||
"selector": {
|
||||
@@ -102,7 +113,8 @@
|
||||
"server_webhook_auth": {
|
||||
"options": {
|
||||
"none": "Secret webhook URL (default)",
|
||||
"ha_auth": "Sign in with Home Assistant (OAuth)"
|
||||
"ha_auth": "Sign in with Home Assistant (OAuth)",
|
||||
"legacy": "Legacy OAuth (Client ID/Secret, for Google Gemini Spark)"
|
||||
}
|
||||
},
|
||||
"llm_api_exposure": {
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
{
|
||||
"config": {
|
||||
"step": {
|
||||
"user": {
|
||||
"title": "Пользовательский компонент HA-MCP",
|
||||
"description": "Выберите, что добавить. **Сервер HA-MCP** запускает полноценный сервер ha-mcp внутри Home Assistant и предоставляет к нему доступ через вебхук Home Assistant — этот вариант установки подходит большинству пользователей. Это автономный сервер, полностью заменяющий все остальные способы установки (дополнение, Docker, uvx/PyPI, stdio); если он запущен, не запускайте одновременно ни один из них. **Файловые инструменты и инструменты YAML HA-MCP** добавляют привилегированные службы для работы с файлами и редактирования YAML. Они нужны, только если вы включите отключённые по умолчанию файловые инструменты и инструменты YAML в ha-mcp. Если сервер ha-mcp уже работает в другом месте (как дополнение, в Docker или через uvx), добавлять серверную запись не нужно — добавьте только эту запись для файлов и YAML и только в том случае, если используете эти инструменты. Она работает с сервером любого типа, и её можно добавить позднее в любое время.",
|
||||
"menu_options": {
|
||||
"server": "Сервер HA-MCP (рекомендуется)",
|
||||
"tools": "Файловые инструменты и инструменты YAML HA-MCP (необязательно)"
|
||||
}
|
||||
},
|
||||
"tools": {
|
||||
"title": "Файловые инструменты и инструменты YAML HA-MCP",
|
||||
"description": "Настраивает привилегированные службы для работы с файлами и конфигурацией YAML. Требуется, только если вы включаете отключённые по умолчанию файловые инструменты и инструменты редактирования YAML в ha-mcp (флаги функций). Это относится к серверу любого типа, в том числе к внутрипроцессному серверу HA-MCP. Эту запись можно добавить или удалить в любое время."
|
||||
},
|
||||
"server": {
|
||||
"title": "Сервер HA-MCP",
|
||||
"description": "Запускает полноценный сервер ha-mcp внутри Home Assistant и предоставляет удалённый доступ к нему через вебхук Home Assistant (доступный через Nabu Casa или любой обратный прокси-сервер). Это автономный сервер, полностью заменяющий способы установки в виде дополнения, Docker, uvx/PyPI и stdio; не запускайте его одновременно с другим сервером ha-mcp. Нажмите **Отправить**, чтобы запустить его. После этого порт, сетевой интерфейс и способ аутентификации можно изменить в параметрах интеграции."
|
||||
}
|
||||
},
|
||||
"abort": {
|
||||
"already_configured": "Эта запись уже настроена.",
|
||||
"unsupported_home_assistant": "Для внутрипроцессного сервера HA-MCP требуется Home Assistant версии {required} или новее, но в этом экземпляре установлена версия {installed}. Вы по-прежнему можете использовать запись «Файловые инструменты и инструменты YAML HA-MCP» этого компонента с внешним сервером ha-mcp, работающим как дополнение или контейнер Docker."
|
||||
}
|
||||
},
|
||||
"options": {
|
||||
"step": {
|
||||
"tools_info": {
|
||||
"title": "Файловые инструменты и инструменты YAML HA-MCP",
|
||||
"description": "Эта запись предоставляет привилегированные службы для работы с файлами и редактирования YAML, которые используются отключёнными по умолчанию файловыми инструментами и инструментами YAML в ha-mcp. Пока здесь нечего настраивать. Каталоги, доступные файловым инструментам, задаются в собственных настройках сервера ha-mcp (в его интерфейсе настроек, в разделе разрешённых путей), а не здесь. В будущей версии на этом экране могут появиться параметры для отдельных записей."
|
||||
},
|
||||
"init": {
|
||||
"title": "Сервер HA-MCP",
|
||||
"description": "Настройте сервер HA-MCP. {panel_hint}Изменения применяются при сохранении.\n\n{versions}\n\n{connect_url}\n\n{oauth_creds}",
|
||||
"data": {
|
||||
"channel": "Канал выпуска",
|
||||
"auto_update": "Автоматические обновления сервера",
|
||||
"server_port": "Порт прослушивания MCP-сервера",
|
||||
"bind_host": "Сетевой доступ",
|
||||
"webhook_auth": "Режим аутентификации",
|
||||
"oauth_client_id_override": "Устаревший OAuth: пользовательский Client ID (необязательно)",
|
||||
"oauth_client_secret_override": "Устаревший OAuth: пользовательский Client Secret (необязательно)",
|
||||
"oauth_regenerate": "Устаревший OAuth: создать новые Client ID и Secret сейчас",
|
||||
"pip_spec": "Для разработчиков: переопределение пакета ha-mcp",
|
||||
"server_url": "URL-адрес Home Assistant (расширенная настройка)",
|
||||
"external_url": "Внешний URL-адрес (необязательно)",
|
||||
"webhook_id_override": "Пользовательский секрет вебхука (необязательно)",
|
||||
"secret_path_override": "Пользовательский путь прямого доступа (необязательно)",
|
||||
"regenerate_secrets": "Создать новые секреты подключения сейчас",
|
||||
"enable_webhook": "Удалённый доступ через вебхук",
|
||||
"enable_llm_api": "LLM API диалогового агента",
|
||||
"llm_api_exposure": "Набор инструментов для диалогового агента",
|
||||
"enable_startup_notification": "Уведомление о запуске",
|
||||
"enable_sidebar_panel": "Панель настроек в боковой панели"
|
||||
},
|
||||
"data_description": {
|
||||
"channel": "Стабильный канал устанавливает последнюю стабильную версию, а канал разработки — самую новую сборку для разработки. Если автоматические обновления включены, последняя сборка выбранного канала устанавливается при перезагрузке интеграции или перезапуске, а также во время периодической проверки. Указанное ниже переопределение пакета для разработчиков имеет приоритет и отключает автоматические обновления.",
|
||||
"auto_update": "Если параметр включён, последняя версия выбранного канала устанавливается автоматически при перезагрузке интеграции или перезапуске, а также во время периодической проверки. Если параметр выключен, сервер остаётся на текущей установленной версии, пока вы снова не включите его. Это относится только к пакету сервера ha-mcp; обновления самого пользовательского компонента HA-MCP по-прежнему устанавливаются через HACS.",
|
||||
"server_port": "Порт, который прослушивает этот сервер. Дополнение ha-mcp использует порт 9583, поэтому по умолчанию выбран порт 9584, чтобы оба сервера могли работать одновременно. Если дополнение не используется, подойдёт любой свободный порт.",
|
||||
"bind_host": "Определяет, кто может подключаться непосредственно к порту MCP-сервера. Значение по умолчанию соответствует дополнению: сервер доступен в локальной сети, а секретный путь используется как учётные данные. Выберите кольцевой интерфейс, чтобы разрешить подключения только с самого компьютера Home Assistant. URL-адрес вебхука и панель в боковом меню будут работать в любом случае.",
|
||||
"webhook_auth": "Определяет, как MCP-клиенты подтверждают свою подлинность при обращении к URL-адресу вебхука. При использовании секретного URL-адреса учётными данными служит сама ссылка. При использовании входа через Home Assistant такие клиенты, как claude.ai, входят с учётной записью администратора Home Assistant (OAuth). При использовании устаревшего OAuth интеграция выдаёт собственные Client ID и Secret, которые нужно вставить в клиенты, где они требуются, например Google Gemini Spark. Для включения или выключения этого режима необходимо перезапустить Home Assistant.",
|
||||
"oauth_client_id_override": "Заменяет автоматически созданный Client ID устаревшего OAuth. Оставьте поле пустым, чтобы сохранить текущее значение. Используется, только когда выбран режим аутентификации «Устаревший OAuth».",
|
||||
"oauth_client_secret_override": "Заменяет автоматически созданный Client Secret устаревшего OAuth. Оставьте поле пустым, чтобы сохранить текущее значение. Используется, только когда выбран режим аутентификации «Устаревший OAuth».",
|
||||
"oauth_regenerate": "Одноразовое действие: создаёт новые Client ID и Secret для режима устаревшего OAuth. Они начнут действовать только после перезапуска Home Assistant, который будет предложен в уведомлении о восстановлении. До перезапуска прежние Client ID и Secret продолжат работать, а новые работать не будут. Также очищает два поля переопределения выше.",
|
||||
"pip_spec": "Оставьте пустым. Предназначено только для тестирования определённой сборки ha-mcp (например, для фиксации предварительной версии). Переопределяет канал выпуска и отключает автоматические обновления, пока поле не будет очищено.",
|
||||
"server_url": "URL-адрес, по которому внутрипроцессный сервер обращается к Home Assistant (обычно к этому же экземпляру). Оставьте пустым, чтобы сформировать адрес из порта и настроек SSL этого экземпляра. Указывайте значение только в том случае, если сервер должен использовать другой маршрут.",
|
||||
"external_url": "Отображается как основной URL-адрес подключения. Используйте его, если Home Assistant доступен через собственный домен или обратный прокси-сервер. Введите полный базовый адрес, включая схему. Он должен указывать непосредственно на Home Assistant: при открытии в браузере должна появляться страница входа HA. Адрес не должен содержать порт, например :8123 или любой другой, иначе удалённые MCP-клиенты не смогут подключиться. Оставьте поле пустым, чтобы автоматически использовать Nabu Casa или локальный адрес.",
|
||||
"webhook_id_override": "Заменяет случайный секрет вебхука в URL-адресе подключения (/api/webhook/...). URL-адрес служит учётными данными, поэтому используйте длинное значение, которое трудно угадать. Оставьте поле пустым, чтобы сохранить текущее значение.",
|
||||
"secret_path_override": "Заменяет случайный путь для прямого доступа через порт сервера. Действует то же правило: путь служит учётными данными. Оставьте поле пустым, чтобы сохранить текущее значение.",
|
||||
"regenerate_secrets": "Одноразовое действие: создаёт новый случайный секрет вебхука и путь прямого доступа, немедленно делая прежние URL-адреса подключения недействительными. Также очищает два поля переопределения выше.",
|
||||
"enable_webhook": "Выключите для работы только в локальном режиме: вебхук Home Assistant вообще не будет зарегистрирован, поэтому ничто, включая Nabu Casa, не сможет обратиться к серверу через Home Assistant. Прямой порт сервера и панель в боковом меню продолжат работать.",
|
||||
"enable_llm_api": "Предоставляет полный набор инструментов диалоговым агентам Home Assistant (OpenAI, Google, Ollama и другим). Пока параметр включён, агенты могут выбрать «Сервер HA-MCP» в разделе управления Home Assistant и использовать инструменты из чата и голосового интерфейса Assist. Включение лишь делает его доступным для выбора: ничего не предоставляется, пока вы не выберете его в настройках агента. Руководство по использованию: {llm_api_docs_url}",
|
||||
"llm_api_exposure": "Определяет вид набора инструментов, предоставляемого диалоговым агентам. Поиск инструментов (по умолчанию) уменьшает контекст агента: используется компактный API с закреплёнными инструментами и метаинструментами поиска и выполнения. Полный каталог перечисляет все доступные инструменты напрямую и лучше подходит моделям с большим контекстом. Вариант «Оба» регистрирует оба набора параллельно, чтобы каждый агент мог выбрать свой в разделе управления Home Assistant. Доступность отдельных инструментов настраивается в панели HA-MCP; подробности: {llm_api_docs_url}",
|
||||
"enable_startup_notification": "Показывает уведомление при каждом запуске сервера со ссылками на доступные только администраторам страницы настроек. Выключите, чтобы запускать сервер без уведомлений. URL-адреса подключения всё равно будут записываться в журнал Home Assistant.",
|
||||
"enable_sidebar_panel": "Показывает панель настроек HA-MCP в боковом меню (только для администраторов). Выключите, чтобы убрать пункт из бокового меню. Параметры сервера останутся доступны на этом экране."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"issues": {
|
||||
"server_start_failed": {
|
||||
"title": "Не удалось запустить внутрипроцессный сервер HA-MCP",
|
||||
"description": "Не удалось запустить внутрипроцессный сервер HA-MCP внутри Home Assistant:\n\n{detail}\n\nПроверьте журналы Home Assistant, затем перезагрузите интеграцию (либо устраните основную проблему и перезагрузите её), чтобы повторить попытку."
|
||||
},
|
||||
"server_package_install_failed": {
|
||||
"title": "Не удалось установить пакет внутрипроцессного сервера HA-MCP",
|
||||
"description": "Не удалось установить пакет ha-mcp для внутрипроцессного сервера:\n\n{detail}\n\nУстраните описанную выше проблему совместимости или установки, затем перезагрузите интеграцию, чтобы повторить попытку."
|
||||
},
|
||||
"component_outdated": {
|
||||
"title": "Обновите пользовательский компонент HA-MCP через HACS",
|
||||
"description": "Для установленного сервера ha-mcp требуется пользовательский компонент HA-MCP версии {required} или новее, но у вас установлена версия {installed}. Обновите компонент через HACS (откройте запись пользовательского компонента HA-MCP и выберите «Обновить информацию», если обновление ещё не отображается) и перезапустите Home Assistant. Тем временем сервер продолжит работать, но некоторые новые функции могут не работать до обновления компонента."
|
||||
},
|
||||
"server_update_held": {
|
||||
"title": "Обновление сервера HA-MCP ожидает обновления компонента",
|
||||
"description": "Доступен сервер ha-mcp версии {latest}, но в этом выпуске также обновлён пользовательский компонент HA-MCP (до версии {shipped}; у вас работает версия {running}). Чтобы не запускать версию сервера, которая не тестировалась с работающим компонентом, автоматическое обновление сервера приостановлено до обновления компонента.\n\nОбновите компонент через HACS (откройте запись пользовательского компонента HA-MCP и выберите «Обновить информацию», если обновление ещё не отображается), затем перезапустите Home Assistant. После этого обновление сервера установится автоматически. Чтобы всё равно установить обновление сервера, нажмите «Установить» у сущности обновления сервера HA-MCP."
|
||||
},
|
||||
"legacy_hacs_source": {
|
||||
"title": "Компонент установлен из устаревшего репозитория",
|
||||
"description": "HACS отслеживает для этого компонента основной репозиторий сервера ha-mcp, поэтому здесь отображаются номера версий сервера (7.x) и примечания к его выпускам вместо собственных версий компонента (1.x). Обновления продолжают работать, но будут и дальше обозначаться неверно. Чтобы исправить это, удалите этот репозиторий из HACS (настройки интеграции и записи конфигурации сохранятся), добавьте homeassistant-ai/ha-mcp-integration как пользовательский репозиторий, переустановите из него компонент и перезапустите Home Assistant."
|
||||
},
|
||||
"legacy_oauth_restart": {
|
||||
"title": "Перезапустите Home Assistant, чтобы применить изменение устаревшего OAuth",
|
||||
"description": "Режим аутентификации с устаревшим OAuth регистрирует собственные веб-точки /authorize и /token, которые Home Assistant может привязать или освободить только при полном перезапуске — независимо от того, включили вы этот режим, выключили его или изменили Client ID/Secret. Перезапустите Home Assistant («Настройки» → «Система» → «Перезапустить»), чтобы применить изменение. До этого продолжит действовать прежнее поведение."
|
||||
}
|
||||
},
|
||||
"selector": {
|
||||
"server_channel": {
|
||||
"options": {
|
||||
"stable": "Стабильный (рекомендуется)",
|
||||
"dev": "Для разработки (последняя сборка)"
|
||||
}
|
||||
},
|
||||
"server_webhook_auth": {
|
||||
"options": {
|
||||
"none": "Секретный URL-адрес вебхука (по умолчанию)",
|
||||
"ha_auth": "Вход через Home Assistant (OAuth)",
|
||||
"legacy": "Устаревший OAuth (Client ID/Secret, для Google Gemini Spark)"
|
||||
}
|
||||
},
|
||||
"llm_api_exposure": {
|
||||
"options": {
|
||||
"tool_search": "Поиск инструментов (компактный, по умолчанию)",
|
||||
"full": "Полный каталог",
|
||||
"both": "Оба (выбор для каждого агента)"
|
||||
}
|
||||
}
|
||||
},
|
||||
"entity": {
|
||||
"update": {
|
||||
"server_update": {
|
||||
"name": "Обновление"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -82,6 +82,31 @@ _PROXY_URL = f"{_UI_BASE}/app/{{path:.*}}"
|
||||
# path-scoped to the proxy so it is never sent to the boot/session endpoints.
|
||||
_COOKIE_NAME = "ha_mcp_tools_ui_session"
|
||||
_COOKIE_PATH = f"{_UI_BASE}/app"
|
||||
# Keep in sync with ``ha_mcp.settings_ui._i18n.LOCALE_COOKIE`` without
|
||||
# importing the separately installed server package into the HA component.
|
||||
_LOCALE_COOKIE_NAME = "ha_mcp_locale"
|
||||
|
||||
|
||||
def _is_valid_locale_cookie_value(value: str) -> bool:
|
||||
"""True for BCP-47-like values: ASCII-alphanumeric runs joined by single
|
||||
``-``/``_`` separators (no leading/trailing/doubled separators).
|
||||
|
||||
A plain character walk instead of a regex: linear by construction, where
|
||||
CodeQL's backtracking model flagged every regex shape for this language
|
||||
as potentially polynomial.
|
||||
"""
|
||||
prev_is_sep = True # a separator may not open the value
|
||||
for ch in value:
|
||||
if ch in "-_":
|
||||
if prev_is_sep:
|
||||
return False
|
||||
prev_is_sep = True
|
||||
elif ch.isascii() and ch.isalnum():
|
||||
prev_is_sep = False
|
||||
else:
|
||||
return False
|
||||
return bool(value) and not prev_is_sep
|
||||
|
||||
|
||||
# Session lifetime. Short by design; the panel re-mints well within it while open.
|
||||
_SESSION_TTL_SECONDS = 8 * 60 * 60
|
||||
@@ -94,7 +119,9 @@ _SESSIONS_KEY = "ha_mcp_tools_ui_sessions"
|
||||
|
||||
# Request headers never forwarded to the loopback server. Hop-by-hop plus the
|
||||
# browser's cookie/authorization (the loopback server has no auth on the secret
|
||||
# path and must not receive the session cookie or the frontend bearer).
|
||||
# path and must not receive the session cookie or the frontend bearer). The
|
||||
# locale cookie is reconstructed separately from the parsed cookie jar so no
|
||||
# other browser cookie can cross this trust boundary.
|
||||
_STRIPPED_REQUEST_HEADERS = frozenset(
|
||||
{
|
||||
"host",
|
||||
@@ -121,6 +148,24 @@ _STRIPPED_RESPONSE_HEADERS = frozenset(
|
||||
)
|
||||
|
||||
|
||||
def _forwarded_locale_cookie(request: web.Request) -> str | None:
|
||||
"""Return the single safe locale cookie header allowed upstream.
|
||||
|
||||
The settings app stores a manual language override in ``ha_mcp_locale``.
|
||||
Forwarding the browser's raw Cookie header would also expose Home
|
||||
Assistant's authenticated session cookie to the unauthenticated loopback
|
||||
server, so rebuild a header containing only a short BCP-47-like value.
|
||||
"""
|
||||
value = request.cookies.get(_LOCALE_COOKIE_NAME)
|
||||
if (
|
||||
not isinstance(value, str)
|
||||
or len(value) > 64
|
||||
or not _is_valid_locale_cookie_value(value)
|
||||
):
|
||||
return None
|
||||
return f"{_LOCALE_COOKIE_NAME}={value}"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Session store (server-side; no secret ever placed in a URL)
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -300,6 +345,9 @@ class _ProxyView(HomeAssistantView):
|
||||
for key, value in request.headers.items()
|
||||
if key.lower() not in _STRIPPED_REQUEST_HEADERS
|
||||
}
|
||||
locale_cookie = _forwarded_locale_cookie(request)
|
||||
if locale_cookie is not None:
|
||||
forward_headers["Cookie"] = locale_cookie
|
||||
|
||||
try:
|
||||
async with session.request(
|
||||
@@ -473,7 +521,7 @@ class _suppress_all:
|
||||
|
||||
_BOOT_JS = f"""
|
||||
const SESSION_URL = {_SESSION_URL!r};
|
||||
const APP_URL = {_APP_PREFIX!r} + "settings";
|
||||
const APP_BASE_URL = {_APP_PREFIX!r} + "settings";
|
||||
// Re-mint at half the cookie lifetime so an open panel never expires mid-use.
|
||||
const REFRESH_MS = {_SESSION_TTL_SECONDS // 2} * 1000;
|
||||
// While the frontend is still booting (a cold start straight into this panel),
|
||||
@@ -495,6 +543,22 @@ let busy = false;
|
||||
let tokenMisses = 0;
|
||||
let authDead = false;
|
||||
|
||||
function homeAssistantRoot() {{
|
||||
try {{
|
||||
if (window.parent === window) return null;
|
||||
return window.parent.document.querySelector("home-assistant");
|
||||
}} catch (err) {{
|
||||
return null;
|
||||
}}
|
||||
}}
|
||||
|
||||
function appUrl() {{
|
||||
const root = homeAssistantRoot();
|
||||
const language = root && root.hass && root.hass.language;
|
||||
if (!language) return APP_BASE_URL;
|
||||
return APP_BASE_URL + "?ha_lang=" + encodeURIComponent(language);
|
||||
}}
|
||||
|
||||
function showMessage(text, isError) {{
|
||||
frame.classList.add("hidden");
|
||||
msg.classList.remove("hidden");
|
||||
@@ -532,8 +596,7 @@ async function token() {{
|
||||
// (#1802). A failed refresh means the sign-in itself is dead: mark it
|
||||
// terminal rather than looping.
|
||||
try {{
|
||||
if (window.parent === window) return null;
|
||||
const root = window.parent.document.querySelector("home-assistant");
|
||||
const root = homeAssistantRoot();
|
||||
const auth = root && root.hass && root.hass.auth;
|
||||
if (!auth) return null;
|
||||
if (auth.expired && typeof auth.refreshAccessToken === "function") {{
|
||||
@@ -615,9 +678,10 @@ async function mint() {{
|
||||
async function showApp() {{
|
||||
// Probe the proxy so a not-yet-running server shows a friendly message
|
||||
// instead of a raw 503 page inside the iframe.
|
||||
const targetUrl = appUrl();
|
||||
let probe;
|
||||
try {{
|
||||
probe = await fetchWithTimeout(APP_URL, {{ credentials: "same-origin" }});
|
||||
probe = await fetchWithTimeout(targetUrl, {{ credentials: "same-origin" }});
|
||||
}} catch (err) {{
|
||||
transientFailure("Could not reach Home Assistant to load the settings UI.");
|
||||
return;
|
||||
@@ -636,8 +700,8 @@ async function showApp() {{
|
||||
transientFailure("The settings UI returned HTTP " + probe.status + ".");
|
||||
return;
|
||||
}}
|
||||
if (frame.getAttribute("src") !== APP_URL) {{
|
||||
frame.setAttribute("src", APP_URL);
|
||||
if (frame.getAttribute("src") !== targetUrl) {{
|
||||
frame.setAttribute("src", targetUrl);
|
||||
}}
|
||||
msg.classList.add("hidden");
|
||||
frame.classList.remove("hidden");
|
||||
|
||||
@@ -29,8 +29,9 @@ from .const import (
|
||||
DOMAIN,
|
||||
OPT_AUTO_UPDATE,
|
||||
)
|
||||
from .coordinator import ServerVersionCoordinator
|
||||
from .coordinator import ServerVersionCoordinator, ServerVersionInfo
|
||||
from .embedded_server import _installed_dist_version
|
||||
from .embedded_setup import _async_update_held_by_component
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
@@ -46,6 +47,19 @@ _RELEASES_URL = (
|
||||
)
|
||||
_RELEASE_NOTES_TIMEOUT_SECONDS = 15
|
||||
|
||||
# Prepended to the release notes while the automatic server update is HELD on a
|
||||
# newer custom component (embedded_setup's auto-update gate). ``ha-alert`` renders
|
||||
# as a prominent banner in Home Assistant's markdown; ``{shipped}`` is the
|
||||
# component version the release ships, ``{running}`` the one currently installed.
|
||||
_COMPONENT_HOLD_WARNING = (
|
||||
'<ha-alert alert-type="warning">\n'
|
||||
"This release also updates the HA-MCP Custom Component (to {shipped}; you "
|
||||
"are running {running}). Update the component in HACS first — installing "
|
||||
"this server update now runs a server build the HACS component has never "
|
||||
"been tested with.\n"
|
||||
"</ha-alert>"
|
||||
)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
|
||||
@@ -120,17 +134,77 @@ class ServerUpdateEntity(CoordinatorEntity[ServerVersionCoordinator], UpdateEnti
|
||||
return features
|
||||
|
||||
async def async_release_notes(self) -> str | None:
|
||||
"""Concatenate GitHub release bodies between installed and latest.
|
||||
"""Return the GitHub release notes, with a component-update warning
|
||||
prepended when the pending update is held on a component update.
|
||||
|
||||
When the newer server release also ships a newer custom component than
|
||||
the one running, the auto-update gate HOLDS the install (see
|
||||
embedded_setup._async_update_held_by_component), so the dialog leads
|
||||
with a prominent warning to update the component in HACS first. That
|
||||
warning must survive even a failed or empty notes fetch — surfacing it
|
||||
is the whole point of opening a held update's dialog — so a held update
|
||||
returns the warning alone rather than None. When not held, behaviour is
|
||||
unchanged.
|
||||
|
||||
Advisory-only (same reasoning as embedded_setup's
|
||||
_async_check_component_compat): a GitHub fetch failure, rate limit, or
|
||||
unexpected payload shape must degrade to None - the UI then falls back
|
||||
to :attr:`release_url` - rather than break the update dialog.
|
||||
unexpected payload shape degrades to the :attr:`release_url` fallback
|
||||
rather than breaking the update dialog.
|
||||
"""
|
||||
data = self.coordinator.data
|
||||
if data is None or data.installed is None or data.latest is None:
|
||||
return None
|
||||
|
||||
# Both probes are advisory network calls that contain their own
|
||||
# failures and timeouts; run them concurrently so the dialog waits for
|
||||
# the slower of the two, not their sum — a blocked/slow manifest host
|
||||
# must not stall the ordinary notes fetch (review finding).
|
||||
warning, notes = await asyncio.gather(
|
||||
self._async_component_hold_warning(data),
|
||||
self._async_fetch_release_notes(data),
|
||||
)
|
||||
|
||||
if warning is None:
|
||||
# Not held: exactly the pre-existing behaviour (the notes, or None
|
||||
# on any failure/empty — the UI then falls back to release_url).
|
||||
return notes
|
||||
# Held: the warning must always surface, even when the notes fetch
|
||||
# failed or returned nothing — it must never vanish with the notes.
|
||||
if notes is None:
|
||||
return warning
|
||||
return f"{warning}\n\n{notes}"
|
||||
|
||||
async def _async_component_hold_warning(
|
||||
self, data: ServerVersionInfo
|
||||
) -> str | None:
|
||||
"""Return the markdown component-hold warning, or None when not held.
|
||||
|
||||
Reuses the auto-update gate's own held-check so this dialog and the
|
||||
Repairs hold agree on when the component is behind. Fully advisory: any
|
||||
failure — including an unexpected error escaping the gate — degrades to
|
||||
None so the hold warning can never break the release-notes dialog.
|
||||
"""
|
||||
try:
|
||||
held = await _async_update_held_by_component(self.hass, data)
|
||||
except Exception:
|
||||
# The gate contains all its expected failures internally, so an
|
||||
# exception escaping it is a bug — logged visibly per the repo's
|
||||
# convention (review finding), still degrading to plain notes.
|
||||
_LOGGER.warning(
|
||||
"HA-MCP release-notes component-hold check failed", exc_info=True
|
||||
)
|
||||
return None
|
||||
if held is None:
|
||||
return None
|
||||
shipped, running = held
|
||||
return _COMPONENT_HOLD_WARNING.format(shipped=shipped, running=running)
|
||||
|
||||
async def _async_fetch_release_notes(self, data: ServerVersionInfo) -> str | None:
|
||||
"""Concatenate GitHub release bodies between installed and latest.
|
||||
|
||||
Advisory-only: a GitHub fetch failure, rate limit, or unexpected payload
|
||||
shape degrades to None so the dialog falls back to :attr:`release_url`.
|
||||
"""
|
||||
try:
|
||||
installed = AwesomeVersion(data.installed)
|
||||
latest = AwesomeVersion(data.latest)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -2,13 +2,16 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
import re
|
||||
import threading
|
||||
from collections.abc import Callable
|
||||
from datetime import date, datetime
|
||||
from io import StringIO
|
||||
from typing import Any
|
||||
|
||||
from ruamel.yaml import YAML
|
||||
from ruamel.yaml.scalarbool import ScalarBoolean
|
||||
|
||||
|
||||
class _TaggedScalar:
|
||||
@@ -171,3 +174,57 @@ def yaml_dumps(ry: YAML, data: Any) -> str:
|
||||
buf = StringIO()
|
||||
ry.dump(data, buf)
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
def _jsonify_float(node: float) -> float | str:
|
||||
"""Narrow a float to something json can encode.
|
||||
|
||||
``.inf``/``.nan`` are valid YAML with no JSON encoding, so they render back
|
||||
to their YAML source form — the same treatment a tag gets.
|
||||
"""
|
||||
if math.isnan(node):
|
||||
return ".nan"
|
||||
if math.isinf(node):
|
||||
return ".inf" if node > 0 else "-.inf"
|
||||
return float(node)
|
||||
|
||||
|
||||
def yaml_jsonify(node: Any) -> Any:
|
||||
"""Convert a round-trip node into JSON-serializable plain Python.
|
||||
|
||||
An HA tag is rendered back to its SOURCE form (``!secret api_key``), never
|
||||
resolved: the value behind a ``!secret`` lives in secrets.yaml and is not
|
||||
looked up here, so a parsed view carries no plaintext-secret surface — the
|
||||
same property the round-trip text view has. Lives here because this module
|
||||
owns ``_TaggedScalar`` and the tag registry.
|
||||
|
||||
ruamel's scalar types subclass the builtins (``ScalarInt``/``ScalarFloat``/
|
||||
``ScalarString``), so they are narrowed to the plain type; timestamps
|
||||
(``!!timestamp``) come back as ``date``/``datetime``, which json cannot
|
||||
encode, and become ISO strings. Non-finite floats (``.inf``/``.nan``) have
|
||||
no JSON encoding either, so they render back to their YAML source form —
|
||||
the same treatment a tag gets.
|
||||
"""
|
||||
if isinstance(node, _TaggedScalar):
|
||||
return f"{node.tag} {node.value}".strip()
|
||||
if isinstance(node, dict):
|
||||
return {str(key): yaml_jsonify(value) for key, value in node.items()}
|
||||
if isinstance(node, (list, tuple)):
|
||||
return [yaml_jsonify(item) for item in node]
|
||||
# Both branches must precede int: plain bool subclasses int, and a bool
|
||||
# carrying an anchor loads as ruamel's ScalarBoolean, which subclasses int
|
||||
# WITHOUT subclassing bool — so an `enabled: &flag true` would otherwise
|
||||
# serialize as 1.
|
||||
if node is None or isinstance(node, bool):
|
||||
return node
|
||||
if isinstance(node, ScalarBoolean):
|
||||
return bool(node)
|
||||
if isinstance(node, int):
|
||||
return int(node)
|
||||
if isinstance(node, float):
|
||||
return _jsonify_float(node)
|
||||
if isinstance(node, str):
|
||||
return str(node)
|
||||
if isinstance(node, (datetime, date)):
|
||||
return node.isoformat()
|
||||
return str(node)
|
||||
|
||||
Reference in New Issue
Block a user