Updated apps

This commit is contained in:
2026-07-20 22:52:35 -04:00
parent 28a8cb98f6
commit a0c3271743
1164 changed files with 94781 additions and 6892 deletions
File diff suppressed because it is too large Load Diff
+169 -35
View File
@@ -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."
)
+69 -4
View File
@@ -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"
+187 -13
View File
@@ -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
+699 -64
View File
@@ -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:
+245 -30
View File
@@ -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(
+6 -3
View File
@@ -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"
}
+267 -72
View File
@@ -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
+25 -13
View 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": "Обновление"
}
}
}
}
+71 -7
View File
@@ -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");
+78 -4
View File
@@ -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
+57
View File
@@ -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)