Files
Home-Assistant/custom_components/ha_mcp_tools/embedded_setup.py
T
2026-07-17 10:12:15 -04:00

714 lines
30 KiB
Python

"""Bring the in-process ha-mcp server up and down for the config entry (#1527).
Orchestration between :mod:`embedded_server` (the server thread + token
provisioning) and :mod:`mcp_webhook` (the ingress webhook): the bring-up sequence,
repair issues on failure, connect-URL surfacing, and teardown. Kept out of
``__init__.py`` so the entry-point wiring stays thin and this logic is
independently testable.
Every failure here is contained: a failure files a repair issue and returns
rather than propagating out of the background bring-up task, so the rest of Home
Assistant keeps running even when the server can't be installed or started.
"""
from __future__ import annotations
import asyncio
import logging
from contextlib import suppress
from typing import TYPE_CHECKING
from urllib.parse import urlparse
from aiohttp import ClientError
from awesomeversion import AwesomeVersion, AwesomeVersionException
from homeassistant.components import persistent_notification
from homeassistant.core import HomeAssistant
from homeassistant.helpers import issue_registry as ir
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.loader import async_get_integration
from .const import (
BIND_HOST_ALL,
CHANNEL_DEV,
COMPONENT_MANIFEST_AT_TAG_URL,
DATA_BRINGUP_TASK,
DATA_MANAGER,
DATA_PENDING_UPDATE_NOTIFY,
DATA_SECRET_PATH,
DATA_UPDATE_COORDINATOR,
DATA_WEBHOOK_ID,
DEFAULT_AUTO_UPDATE,
DEFAULT_BIND_HOST,
DEFAULT_ENABLE_LLM_API,
DEFAULT_PIP_SPEC,
DEFAULT_SERVER_PORT,
DOMAIN,
HACS_COMPONENT_URL,
ISSUE_COMPONENT_OUTDATED,
ISSUE_PACKAGE_FAILED,
ISSUE_START_FAILED,
ISSUE_UPDATE_HELD,
OPT_AUTO_UPDATE,
OPT_BIND_HOST,
OPT_ENABLE_LLM_API,
OPT_ENABLE_SIDEBAR_PANEL,
OPT_ENABLE_STARTUP_NOTIFICATION,
OPT_ENABLE_WEBHOOK,
OPT_EXTERNAL_URL,
OPT_PIP_SPEC,
OPT_SERVER_PORT,
OPT_WEBHOOK_AUTH,
WEBHOOK_AUTH_NONE,
channel_for_dist,
)
from .embedded_server import EmbeddedServerError, EmbeddedServerManager
from .llm_api import async_register_llm_api, async_unregister_llm_api
from .mcp_webhook import async_register_webhook, async_unregister_webhook
if TYPE_CHECKING:
from homeassistant.config_entries import ConfigEntry
from .coordinator import ServerVersionInfo
_LOGGER = logging.getLogger(__name__)
_NOTIFICATION_ID = "ha_mcp_tools_server_connect"
_UPDATE_NOTIFICATION_ID = "ha_mcp_tools_server_updated"
# ISSUE_UPDATE_HELD is cleared at bring-up start too: any reload that reaches
# bring-up either bypassed the hold deliberately (the update entity's Install
# button) or made it moot; if the hold still applies, the coordinator refresh
# that follows setup re-files it within moments.
_ISSUE_IDS = (ISSUE_PACKAGE_FAILED, ISSUE_START_FAILED, ISSUE_UPDATE_HELD)
# Per-request timeout for the component-manifest fetch behind the auto-update
# gate — mirrors the coordinator's PyPI fetch budget; a miss fails open.
_MANIFEST_FETCH_TIMEOUT_SECONDS = 30
async def async_bring_up_server(hass: HomeAssistant, entry: ConfigEntry) -> None:
"""Install, start, and expose the server. Runs as a background task.
On failure files the matching repair issue and returns — Home Assistant stays
up. On cancellation (the entry is being unloaded mid-bring-up) tears down any
partial state and re-raises so the task ends cancelled. The secret webhook id
and secret path must already exist in ``entry.data`` (the entry setup writes
them before scheduling this task).
"""
_clear_issues(hass)
manager = EmbeddedServerManager(hass, entry)
hass.data.setdefault(DOMAIN, {})[DATA_MANAGER] = manager
try:
await manager.async_start()
# The package is installed and importable now: verify the running
# component satisfies the server's MIN_COMPONENT_VERSION and file/clear
# the component-outdated repair issue. Advisory only — it never blocks
# the (already started) server.
await _async_check_component_compat(hass, entry)
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))
# 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(
hass,
entry,
port=manager.port,
secret_path=secret_path,
auth_mode=auth_mode,
register_endpoint=webhook_enabled,
)
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)
# 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.
if bool(entry.options.get(OPT_ENABLE_LLM_API, DEFAULT_ENABLE_LLM_API)):
await async_register_llm_api(
hass, entry, port=manager.port, secret_path=secret_path
)
else:
_LOGGER.info(
"Conversation-agent LLM API disabled by option - the toolset "
"will not be offered to Home Assistant conversation agents"
)
await _async_finish_update_cycle(hass)
except asyncio.CancelledError:
# Unloaded mid-bring-up: undo whatever partial state exists, then let the
# cancellation propagate so the task ends cancelled. The pending
# update-notification marker (if any) deliberately survives — it
# belongs to a bring-up that has not run yet, not to this one.
await async_teardown_server(hass)
raise
except EmbeddedServerError as err:
_LOGGER.error("HA-MCP in-process server failed to start: %s", err)
# suppress: filing the repair issue must be UNCONDITIONAL (review
# finding) - a raising teardown would otherwise leave the entry
# looking healthy with the failure visible only in the log.
with suppress(Exception):
await async_teardown_server(hass)
_create_issue(hass, err.kind, str(err))
# The install did not land: never fire the "updated" notification for
# it — the repair issue above is the user-facing signal.
_drop_pending_update_notify(hass)
except Exception as err:
_LOGGER.exception("HA-MCP in-process server: bring-up failed")
with suppress(Exception):
await async_teardown_server(hass)
_create_issue(hass, "start", str(err))
_drop_pending_update_notify(hass)
async def async_teardown_server(hass: HomeAssistant) -> None:
"""Unregister the LLM API + webhook and stop the server thread (reload-safe,
idempotent).
Does NOT revoke the provisioned token — a reload must keep it. The ha_auth
discovery views stay bound (aiohttp can't unregister them until HA restarts);
they 404 while the entry is not live.
"""
async_unregister_llm_api(hass)
await async_unregister_webhook(hass)
manager = hass.data.get(DOMAIN, {}).pop(DATA_MANAGER, None)
if isinstance(manager, EmbeddedServerManager):
await manager.async_stop()
async def async_revoke_credentials_on_remove(
hass: HomeAssistant, entry: ConfigEntry
) -> None:
"""Revoke the provisioned credentials when the config entry is removed."""
await EmbeddedServerManager(hass, entry).async_revoke_credentials()
_clear_issues(hass)
ir.async_delete_issue(hass, DOMAIN, ISSUE_COMPONENT_OUTDATED)
def build_connect_urls(
hass: HomeAssistant,
entry: ConfigEntry,
*,
webhook_enabled: bool = True,
) -> list[str]:
"""Resolve the entry's connect URLs (webhook forms first, then direct).
Shared by the admin-only surfaces that show real URLs: the Home Assistant
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
webhook_id = entry.data.get(DATA_WEBHOOK_ID)
urls: list[str] = []
external = str(entry.options.get(OPT_EXTERNAL_URL) or "").rstrip("/")
if not webhook_enabled:
# Local-only mode: no webhook exists, so no webhook URLs to surface.
external = ""
webhook_id = None
if external:
# Owner-requested parity with the webhook-proxy app: a configured
# external URL leads the list (any reverse proxy, not just Nabu Casa).
urls.append(f"{external}/api/webhook/{webhook_id}")
# Nabu Casa remote URL (only when the cloud integration is set up + logged in).
try:
from homeassistant.components.cloud import (
CloudNotAvailable,
async_remote_ui_url,
)
try:
if webhook_id:
cloud_base = async_remote_ui_url(hass)
urls.append(f"{cloud_base}/api/webhook/{webhook_id}")
except CloudNotAvailable:
pass # Cloud not logged in / remote UI off - no remote URL to show.
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.
if not urls and webhook_id:
urls.append(f"/api/webhook/{webhook_id} (prefix with your Home Assistant URL)")
port = int(entry.options.get(OPT_SERVER_PORT, DEFAULT_SERVER_PORT))
bind_host = str(entry.options.get(OPT_BIND_HOST, DEFAULT_BIND_HOST))
secret_path = entry.data.get(DATA_SECRET_PATH)
if bind_host == BIND_HOST_ALL and secret_path:
# 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)"
)
return urls
def _surface_connect_urls(
hass: HomeAssistant,
entry: ConfigEntry,
auth_mode: str,
*,
webhook_enabled: bool = True,
) -> 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)."
)
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,
)
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
# connect URLs still reached the admin-only log above.
persistent_notification.async_dismiss(hass, _NOTIFICATION_ID)
return
# The sidebar-panel line is included only while the panel is registered:
# with the panel option off the /ha-mcp route does not exist and the link
# would 404.
panel_line = (
"Manage it from the [HA-MCP settings panel](/ha-mcp) in the sidebar.\n\n"
if bool(entry.options.get(OPT_ENABLE_SIDEBAR_PANEL, True))
else ""
)
# SECURITY (review finding): persistent notifications are visible to EVERY
# authenticated Home Assistant user - core's persistent_notification/get
# and /subscribe carry no admin gate. In the default posture the connect
# URL IS an admin-equivalent credential, so the notification deliberately
# carries NO secrets: it points at the admin-only surfaces (the sidebar
# panel and the entry's Configure screen). The URLs above still go to the
# log at INFO, which only admin-gated surfaces expose - the same posture
# as the add-on printing its URL to the admin-only add-on log.
message = (
"The HA-MCP Server is now running inside Home Assistant.\n\n"
f"{panel_line}"
"The connect URL is shown on the entry's Configure screen "
"(Settings - Devices & Services - HA-MCP Custom Component - "
"HA-MCP Server - Configure) and in the Home Assistant log - both "
"administrator-only, because the URL is the credential.\n\n"
f"{auth_note}\n"
)
persistent_notification.async_create(
hass,
message,
title="HA-MCP Server",
notification_id=_NOTIFICATION_ID,
)
_ISSUE_BY_KIND = {
"package": ISSUE_PACKAGE_FAILED,
"start": ISSUE_START_FAILED,
}
def _create_issue(hass: HomeAssistant, kind: str, detail: str) -> None:
"""File the repair issue matching the failure ``kind`` (package / start).
Exhaustive lookup on purpose: an unknown kind is a coding error and must
raise here rather than silently filing the wrong user-facing repair issue.
"""
issue_id = _ISSUE_BY_KIND[kind]
ir.async_create_issue(
hass,
DOMAIN,
issue_id,
is_fixable=False,
severity=ir.IssueSeverity.ERROR,
translation_key=issue_id,
translation_placeholders={"detail": detail},
)
def _clear_issues(hass: HomeAssistant) -> None:
"""Clear any previously-filed server-bring-up repair issues."""
for issue_id in _ISSUE_IDS:
ir.async_delete_issue(hass, DOMAIN, issue_id)
# ---------------------------------------------------------------------------
# Automatic server-version updates (channel auto-update)
# ---------------------------------------------------------------------------
async def async_maybe_auto_update(
hass: HomeAssistant, entry: ConfigEntry, info: ServerVersionInfo | None
) -> None:
"""Reload the entry when ``info`` shows a newer build AND auto-update is on.
Called from the :class:`~.coordinator.ServerVersionCoordinator` listener
registered by :mod:`embedded_entry` on every refresh (every
``UPDATE_CHECK_INTERVAL``, plus once shortly after setup). The coordinator
itself always fetches (see its docstring) so the `update` platform entity
stays populated regardless of this option; only the reload decided here is
gated on it.
Skips entirely when: auto-update is off, a pip-spec override is set,
either version is unknown (``info`` may still be ``None`` — the
coordinator's ``data`` type before its first successful refresh), or a
bring-up is still in flight (below).
A pending update is additionally gated on component compatibility
(issues #1783/#1785): when the candidate release also shipped a newer
custom component than the one running, the reload is HELD — loudly (a
repair issue plus a warning log every check) and escapably (applying the
HACS component update — which takes an HA restart, as the issue text
says — unblocks the next check; the update entity's Install button never
passes through here, so manual installs — like pip-spec overrides above —
bypass the hold entirely). Every failure inside the gate fails OPEN so a
GitHub hiccup can never wedge updates.
Best-effort: an incomparable version string (AwesomeVersionException) is
logged at debug and skipped; the next refresh retries. Genuine bugs
propagate per the repo's no-silent-failure convention.
"""
if not bool(entry.options.get(OPT_AUTO_UPDATE, DEFAULT_AUTO_UPDATE)):
# Auto-update turned off: stay on the currently-installed version.
return
override = str(entry.options.get(OPT_PIP_SPEC) or "").strip()
if override and override != DEFAULT_PIP_SPEC:
return
if info is None or info.installed is None or info.latest is None:
# Nothing to compare (not installed yet, or the PyPI fetch failed /
# was skipped) - the bring-up path installs the newest build itself.
return
bringup_task = hass.data.get(DOMAIN, {}).get(DATA_BRINGUP_TASK)
if bringup_task is not None and not bringup_task.done():
# The coordinator's first refresh runs shortly after setup, while the
# background bring-up (embedded_entry.async_setup_server_entry) may
# still be installing the package for the first time. Reloading here
# would cancel that in-flight install (async_unload_server_entry
# cancels the bring-up task on unload) and can loop: the reload's own
# bring-up starts a fresh install that the NEXT refresh could again
# interrupt.
return
try:
newer = AwesomeVersion(info.latest) > AwesomeVersion(info.installed)
except AwesomeVersionException as err:
# Incomparable version strategies (e.g. a non-semver build string) — the
# only expected failure here. Real bugs (TypeError, etc.) propagate.
_LOGGER.debug("HA-MCP auto-update version compare failed: %s", err)
return
if not newer:
# Up to date: a hold that was pending is resolved (the component
# update landed and the unblocked reload installed the server).
ir.async_delete_issue(hass, DOMAIN, ISSUE_UPDATE_HELD)
return
held = await _async_update_held_by_component(hass, info)
if held is not None:
shipped, running = held
_LOGGER.warning(
"HA-MCP server %s is available, but that release also updated the "
"custom component (%s; running %s); holding the automatic server "
"update until the component is updated via HACS. Press Install on "
"the HA-MCP server update entity to install anyway.",
info.latest,
shipped,
running,
)
ir.async_create_issue(
hass,
DOMAIN,
ISSUE_UPDATE_HELD,
is_fixable=False,
severity=ir.IssueSeverity.WARNING,
translation_key=ISSUE_UPDATE_HELD,
translation_placeholders={
"latest": str(info.latest),
"shipped": shipped,
"running": running,
},
learn_more_url=HACS_COMPONENT_URL,
)
return
ir.async_delete_issue(hass, DOMAIN, ISSUE_UPDATE_HELD)
channel = channel_for_dist(info.dist)
_LOGGER.info(
"HA-MCP server update available on the %s channel (%s -> %s); "
"reloading the entry to install it.",
channel,
info.installed,
info.latest,
)
# The "updated" notification must wait for the reloaded entry's bring-up to
# actually install and start the new build — async_reload returns when
# entry SETUP finishes, while the pip install still runs in the background
# and can fail (review finding). Leave a marker for bring-up to pop:
# notification on success (_async_finish_update_cycle), silent drop on
# failure (the package/start repair issues cover that path).
hass.data.setdefault(DOMAIN, {})[DATA_PENDING_UPDATE_NOTIFY] = {
"old": info.installed
}
try:
await hass.config_entries.async_reload(entry.entry_id)
except Exception:
# A raising reload leaves no repair issue behind (those are filed by
# bring-up, which never ran), so this ERROR log is the only signal —
# it must not be swallowed or left at debug (review finding). The next
# coordinator refresh retries the whole cycle.
_drop_pending_update_notify(hass)
_LOGGER.exception(
"HA-MCP auto-update reload failed (%s -> %s on the %s channel)",
info.installed,
info.latest,
channel,
)
def _drop_pending_update_notify(hass: HomeAssistant) -> None:
"""Drop the deferred update-notification marker without notifying."""
hass.data.get(DOMAIN, {}).pop(DATA_PENDING_UPDATE_NOTIFY, None)
async def _async_update_held_by_component(
hass: HomeAssistant, info: ServerVersionInfo
) -> tuple[str, str] | None:
"""Return ``(shipped, running)`` when the pending update must be held.
The #1783/#1785 breakage: a server release whose repo state also bumped the
custom component auto-installed under the OLD component before HACS had
even surfaced the component update. The component version in the manifest
at the candidate release's git tag is what shipped with that server build —
newer than the running component means the release changed the component
too, so the automatic server install waits for the component.
Fails OPEN (returns None → install proceeds, the pre-gate behavior) on
every expected failure: manifest unreachable, component version unreadable,
incomparable versions. Blocking updates indefinitely on a transient would
be worse than the crash this guards against — and the crash itself is now
also survivable server-side (the tools registry skips a failing module).
"""
shipped = await _async_fetch_shipped_component_version(hass, str(info.latest))
if shipped is None:
return None
try:
integration = await async_get_integration(hass, DOMAIN)
running = str(integration.version)
except Exception:
# Same wide loader surface as _async_check_component_compat: advisory
# gate, logged visibly rather than swallowed silently.
_LOGGER.warning(
"Could not read the HA-MCP component version for the auto-update "
"gate; proceeding with the update",
exc_info=True,
)
return None
try:
if AwesomeVersion(running) < AwesomeVersion(shipped):
return shipped, running
except AwesomeVersionException as err:
# Incomparable version strategies only; real bugs propagate.
_LOGGER.debug("HA-MCP auto-update gate version compare failed: %s", err)
return None
async def _async_fetch_shipped_component_version(
hass: HomeAssistant, server_version: str
) -> str | None:
"""Return the component version shipped at server release ``vX.Y.Z``.
Reads the component manifest as committed at the release's git tag (raw
GitHub URL). Stable tags exist before the PyPI publish; a dev tag only
appears after its binary builds finish, so a fresh dev version can 404
here for some minutes — see COMPONENT_MANIFEST_AT_TAG_URL. Returns None
on any failure; the caller treats that as "nothing to hold on"
(fail-open).
"""
url = COMPONENT_MANIFEST_AT_TAG_URL.format(version=server_version)
try:
session = async_get_clientsession(hass)
async with asyncio.timeout(_MANIFEST_FETCH_TIMEOUT_SECONDS):
async with session.get(url) as resp:
resp.raise_for_status()
# content_type=None: raw.githubusercontent.com serves
# text/plain, which aiohttp's default json() rejects.
payload = await resp.json(content_type=None)
return str(payload["version"])
except (ClientError, TimeoutError, KeyError, TypeError, ValueError) as err:
_LOGGER.debug(
"HA-MCP shipped-component manifest fetch failed for %s: %s", url, err
)
return None
async def _async_finish_update_cycle(hass: HomeAssistant) -> None:
"""Refresh the version entity and fire the deferred update notification.
Runs at the end of a fully successful bring-up. Both halves belong exactly
here (review findings): the freshly installed version is only knowable once
the install landed — without a refresh the `update` entity keeps showing a
stale "update available" for up to UPDATE_CHECK_INTERVAL after a successful
install — and the notification deferred by async_maybe_auto_update must
only fire for an install that actually happened. Advisory: a failure here
must never fail the (already running) server, so it is logged visibly and
swallowed. No reload loop: the refresh's listener re-enters
async_maybe_auto_update, which no-ops on the still-running bring-up task.
"""
domain_data = hass.data.get(DOMAIN, {})
coordinator = domain_data.get(DATA_UPDATE_COORDINATOR)
try:
if coordinator is not None:
await coordinator.async_refresh()
except Exception:
_LOGGER.warning("HA-MCP: post-install version refresh failed", exc_info=True)
marker = domain_data.pop(DATA_PENDING_UPDATE_NOTIFY, None)
if marker is None or coordinator is None or coordinator.data is None:
return
installed = coordinator.data.installed
old = marker.get("old")
if installed is None or installed == old:
# The reload ran but the installed version did not actually move (the
# install can legitimately resolve to the same build) — an "updated
# to" notification would be false.
return
_create_update_notification(
hass, channel_for_dist(coordinator.data.dist), old, installed
)
def _create_update_notification(
hass: HomeAssistant, channel: str, old_version: str, new_version: str
) -> None:
"""Notify that an automatic server update installed and the server is up.
Only called from :func:`_async_finish_update_cycle` after a successful
bring-up, so the versions are the confirmed before/after pair, never a
prediction. SECURITY: same posture as ``_surface_connect_urls`` -
persistent notifications are visible to every authenticated Home Assistant
user, so this carries no secrets or connect URLs, only version numbers and
a public GitHub link.
"""
release_url = (
"https://github.com/homeassistant-ai/ha-mcp/commits/master"
if channel == CHANNEL_DEV
else f"https://github.com/homeassistant-ai/ha-mcp/releases/tag/v{new_version}"
)
message = (
f"The HA-MCP server was automatically updated from {old_version} to "
f"{new_version} on the {channel} channel.\n\n"
f"[Release notes]({release_url})"
)
persistent_notification.async_create(
hass,
message,
title="HA-MCP Server updated",
notification_id=_UPDATE_NOTIFICATION_ID,
)
# ---------------------------------------------------------------------------
# Component / server version-compatibility repair issue
# ---------------------------------------------------------------------------
def _read_min_component_version() -> str | None:
"""Return the server's declared ``MIN_COMPONENT_VERSION``, or None (blocking).
Imported here (in an executor thread) so the heavy ``ha_mcp`` import stays
off the event loop and out of this module's top level. Guards older/newer
server layouts that do not expose the constant by returning None (skip).
"""
try:
from ha_mcp.tools.tools_filesystem import MIN_COMPONENT_VERSION
except (ImportError, AttributeError):
return None
return str(MIN_COMPONENT_VERSION)
async def _async_check_component_compat(
hass: HomeAssistant, entry: ConfigEntry
) -> None:
"""File/clear the component-outdated repair issue for the running server.
The ha-mcp server declares the minimum custom-component version it needs
(``MIN_COMPONENT_VERSION``). The server package updates independently of
the HACS component (this manager pip-installs new server builds), so the
running component can lag what the server expects. When it does, surface a
WARNING repair issue pointing at the HACS component update; clear it once
the component is new enough.
Advisory only — it must never block or fail server startup, so an
unexpected error is logged (visible, not silent) and swallowed rather than
propagated to the bring-up's failure handling.
"""
required = await hass.async_add_executor_job(_read_min_component_version)
if required is None:
# Server predates MIN_COMPONENT_VERSION, or a newer layout moved it —
# nothing to enforce.
return
try:
integration = await async_get_integration(hass, DOMAIN)
own = str(integration.version)
except Exception:
# The loader legitimately raises a wide, varied surface
# (IntegrationNotFound, manifest errors); advisory check, logged
# visibly with the traceback rather than swallowed silently.
_LOGGER.warning(
"Could not read the HA-MCP component version for the compatibility check",
exc_info=True,
)
return
try:
outdated = AwesomeVersion(own) < AwesomeVersion(required)
except AwesomeVersionException as err:
# Incomparable version strategies only; real bugs propagate.
_LOGGER.debug("HA-MCP component-compat version compare failed: %s", err)
return
if outdated:
_LOGGER.warning(
"The installed ha-mcp server requires HA-MCP Custom Component %s or "
"newer, but %s is running; update the component via HACS.",
required,
own,
)
ir.async_create_issue(
hass,
DOMAIN,
ISSUE_COMPONENT_OUTDATED,
is_fixable=False,
severity=ir.IssueSeverity.WARNING,
translation_key=ISSUE_COMPONENT_OUTDATED,
translation_placeholders={"required": required, "installed": own},
learn_more_url=HACS_COMPONENT_URL,
)
else:
ir.async_delete_issue(hass, DOMAIN, ISSUE_COMPONENT_OUTDATED)