429 files
This commit is contained in:
@@ -85,6 +85,15 @@ from .const import (
|
||||
SERVER_USER_NAME,
|
||||
dist_for_channel,
|
||||
)
|
||||
from .dependency_diagnostics import (
|
||||
DependencyViolation,
|
||||
PinningIntegration,
|
||||
audit_dependency_graph,
|
||||
describe_dependency_failure,
|
||||
find_pinning_integrations,
|
||||
requirement_forces_conflict,
|
||||
root_import_failure,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Callable
|
||||
@@ -217,6 +226,67 @@ class EmbeddedServerError(Exception):
|
||||
self.kind = kind
|
||||
|
||||
|
||||
def _worker_startup_failure(exc: BaseException) -> EmbeddedServerError:
|
||||
"""Return the bring-up error to surface for a worker-thread crash ``exc``.
|
||||
|
||||
An :class:`EmbeddedServerError` composed inside the worker — the dependency
|
||||
diagnosis, the SystemExit unwrap, the sentinel-connection refusal — is
|
||||
surfaced verbatim: it already carries both its message and its failure
|
||||
``kind``, and re-wrapping it is what left the caller logging
|
||||
"failed to start: HA-MCP in-process server failed to start: ...".
|
||||
"""
|
||||
if isinstance(exc, EmbeddedServerError):
|
||||
return exc
|
||||
failure = EmbeddedServerError(f"the worker thread crashed: {exc}")
|
||||
# Chained here rather than with ``raise ... from``: the caller raises this
|
||||
# object as-is, and the branch above must not chain an error to itself.
|
||||
failure.__cause__ = exc
|
||||
return failure
|
||||
|
||||
|
||||
def _install_log_filters_if_available() -> None:
|
||||
"""Attach the shared MCP SDK/fastmcp log-noise filters, if this ha-mcp has them.
|
||||
|
||||
Mirrors the ``register_browser_landing`` guard just above ``_serve``'s call
|
||||
site: the installed server version is user-controlled (channel choice,
|
||||
pip-spec override), so an older ha-mcp without ``ha_mcp.log_filters`` must
|
||||
keep serving -- the filters are simply absent there, as they are today.
|
||||
|
||||
Only a ``ModuleNotFoundError`` for exactly ``ha_mcp.log_filters`` is that
|
||||
"older ha-mcp" case and is swallowed silently. Anything else -- a version
|
||||
mismatch in a third-party dependency this import touches (fastmcp,
|
||||
pydantic), which ``_purge_ha_mcp_modules`` deliberately does NOT
|
||||
reinstall per config entry, so a stale one left over from a previous
|
||||
install can break this import -- is logged instead of silently doing
|
||||
nothing: log cosmetics must never block startup, but they also must
|
||||
never fail invisibly.
|
||||
"""
|
||||
try:
|
||||
from ha_mcp.log_filters import install_sdk_log_filters
|
||||
except ModuleNotFoundError as err:
|
||||
if err.name != "ha_mcp.log_filters":
|
||||
_LOGGER.warning(
|
||||
"Could not install MCP SDK log-noise filters (missing "
|
||||
"dependency %s); continuing without them: %s",
|
||||
err.name,
|
||||
err,
|
||||
)
|
||||
return
|
||||
except ImportError as err:
|
||||
_LOGGER.warning(
|
||||
"Could not install MCP SDK log-noise filters; continuing without them: %s",
|
||||
err,
|
||||
)
|
||||
return
|
||||
try:
|
||||
install_sdk_log_filters()
|
||||
except Exception as err:
|
||||
_LOGGER.warning(
|
||||
"Could not install MCP SDK log-noise filters; continuing without them: %s",
|
||||
err,
|
||||
)
|
||||
|
||||
|
||||
class EmbeddedServerManager:
|
||||
"""Manage the lifecycle of the in-process ha-mcp server for one config entry."""
|
||||
|
||||
@@ -264,6 +334,10 @@ class EmbeddedServerManager:
|
||||
self._pip_spec: str = self._resolve_pip_spec()
|
||||
self._secret_path: str = str(entry.data.get(DATA_SECRET_PATH, ""))
|
||||
self._config_dir: str = hass.config.path(SERVER_CONFIG_SUBDIR)
|
||||
# Home Assistant's own configuration directory, captured here on the
|
||||
# event loop: the dependency diagnosis scans custom_components/ from
|
||||
# the WORKER thread, which must never touch hass.
|
||||
self._hass_config_dir: str = hass.config.config_dir
|
||||
|
||||
# Worker-thread state. ``_loop`` and ``_stop_event`` are created in the
|
||||
# thread before its loop runs, so a stop request can always reach them.
|
||||
@@ -334,6 +408,7 @@ class EmbeddedServerManager:
|
||||
ready_version = await self._async_ensure_package(
|
||||
defer_mutations=_prune_and_check_importing_workers()
|
||||
)
|
||||
await self._async_warn_on_dependency_conflicts()
|
||||
access_token = await self._async_provision_token()
|
||||
await self._hass.async_add_executor_job(self._prepare_config_dir)
|
||||
|
||||
@@ -643,6 +718,14 @@ class EmbeddedServerManager:
|
||||
|
||||
async def _async_ensure_package(
|
||||
self, *, defer_mutations: bool = False
|
||||
) -> str | None:
|
||||
"""Use the externally managed package or ensure a managed install."""
|
||||
if self._hass.config.skip_pip:
|
||||
return await self._async_externally_managed_package_version()
|
||||
return await self._async_ensure_managed_package(defer_mutations=defer_mutations)
|
||||
|
||||
async def _async_ensure_managed_package(
|
||||
self, *, defer_mutations: bool = False
|
||||
) -> str | None:
|
||||
"""Ensure ``ha-mcp`` is importable, installing the pip spec if needed.
|
||||
|
||||
@@ -813,6 +896,85 @@ class EmbeddedServerManager:
|
||||
self._store_installed_spec()
|
||||
return version
|
||||
|
||||
async def _async_externally_managed_package_version(self) -> str:
|
||||
"""Validate and return the package supplied outside Home Assistant.
|
||||
|
||||
skip_pip means the surrounding system owns this interpreter. This
|
||||
path therefore performs metadata/importability reads only: it never
|
||||
calls Home Assistant's requirements manager, UV, or the config-entry
|
||||
marker writers used by manual and automatic installs.
|
||||
"""
|
||||
importable_version: str | None = await self._hass.async_add_executor_job(
|
||||
_installed_ha_mcp_version
|
||||
)
|
||||
stable_version: str | None = await self._hass.async_add_executor_job(
|
||||
_installed_dist_version, DIST_NAME_STABLE
|
||||
)
|
||||
dev_version: str | None = await self._hass.async_add_executor_job(
|
||||
_installed_dist_version, DIST_NAME_DEV
|
||||
)
|
||||
target_dist = dist_for_channel(self._channel)
|
||||
target_version = (
|
||||
stable_version if target_dist == DIST_NAME_STABLE else dev_version
|
||||
)
|
||||
other_dist = (
|
||||
DIST_NAME_DEV if target_dist == DIST_NAME_STABLE else DIST_NAME_STABLE
|
||||
)
|
||||
other_version = (
|
||||
dev_version if target_dist == DIST_NAME_STABLE else stable_version
|
||||
)
|
||||
|
||||
if importable_version is None:
|
||||
raise EmbeddedServerError(
|
||||
"Home Assistant was started with skip_pip enabled, so HA-MCP "
|
||||
"will not install the externally managed server package. Use "
|
||||
f"the system package manager to install {target_dist} "
|
||||
f"{MIN_EMBEDDED_SERVER_VERSION} or newer, then reload this "
|
||||
"integration.",
|
||||
kind="package",
|
||||
)
|
||||
|
||||
if stable_version is not None and dev_version is not None:
|
||||
raise EmbeddedServerError(
|
||||
f"Both {DIST_NAME_STABLE} {stable_version} and "
|
||||
f"{DIST_NAME_DEV} {dev_version} are installed while skip_pip "
|
||||
"is enabled. They share the ha_mcp import package, so HA-MCP "
|
||||
"cannot safely select one without modifying the environment. "
|
||||
"Use the system package manager to leave exactly one installed, "
|
||||
"then reload this integration.",
|
||||
kind="package",
|
||||
)
|
||||
|
||||
if target_version is None:
|
||||
raise EmbeddedServerError(
|
||||
f"The configured {self._channel} channel expects {target_dist}, "
|
||||
f"but only {other_dist} {other_version} is installed while "
|
||||
"skip_pip is enabled. Use the system package manager to install "
|
||||
f"{target_dist} {MIN_EMBEDDED_SERVER_VERSION} or newer, or "
|
||||
f"change the HA-MCP release channel to match {other_dist}, then "
|
||||
"reload this integration.",
|
||||
kind="package",
|
||||
)
|
||||
|
||||
if not _is_compatible_embedded_version(target_version):
|
||||
raise EmbeddedServerError(
|
||||
f"The externally managed {target_dist} {target_version} is "
|
||||
"incompatible while skip_pip is enabled; this in-process "
|
||||
f"component requires {MIN_EMBEDDED_SERVER_VERSION} or newer. "
|
||||
"Upgrade it with the system package manager, then reload this "
|
||||
"integration.",
|
||||
kind="package",
|
||||
)
|
||||
|
||||
_LOGGER.info(
|
||||
"HA-MCP externally managed %s package ready (version %s; "
|
||||
"skip_pip enabled, channel %s)",
|
||||
target_dist,
|
||||
target_version,
|
||||
self._channel,
|
||||
)
|
||||
return target_version
|
||||
|
||||
async def _async_remove_legacy_target(
|
||||
self, target_dist: str, installed_version: str | None
|
||||
) -> None:
|
||||
@@ -1208,6 +1370,154 @@ class EmbeddedServerManager:
|
||||
"""Create the server's persistent data directory (blocking)."""
|
||||
os.makedirs(self._config_dir, exist_ok=True)
|
||||
|
||||
# -- dependency diagnostics --------------------------------------------
|
||||
|
||||
async def _async_warn_on_dependency_conflicts(self) -> None:
|
||||
"""Log the dependency diagnosis when the installed graph is already broken.
|
||||
|
||||
Runs between the install step and the worker's first ``ha_mcp`` import,
|
||||
so a conflict a third-party integration reintroduces at every startup
|
||||
(issue #2239: a manifest pinning ``mcp==1.14.1`` drags the shared
|
||||
package below fastmcp's floor) is named in the log even when the import
|
||||
that follows still succeeds — it does whenever a good copy is already
|
||||
cached in ``sys.modules`` from before the downgrade.
|
||||
|
||||
Advisory only: no repair issue, no failure, and a diagnosis that itself
|
||||
fails is logged at debug and dropped. Bring-up continues either way.
|
||||
"""
|
||||
try:
|
||||
report = await self._hass.async_add_executor_job(
|
||||
self._dependency_conflict_report
|
||||
)
|
||||
except Exception:
|
||||
_LOGGER.debug("HA-MCP dependency pre-flight audit failed", exc_info=True)
|
||||
return
|
||||
if report is not None:
|
||||
_LOGGER.warning(
|
||||
"HA-MCP in-process server: the installed dependency tree is "
|
||||
"inconsistent. %s",
|
||||
report,
|
||||
)
|
||||
|
||||
def _dependency_conflict_report(self) -> str | None:
|
||||
"""Describe the installed graph's violations, or None when it is sound.
|
||||
|
||||
Blocking (metadata + manifest reads): call from an executor job or the
|
||||
worker thread, never from the event loop.
|
||||
"""
|
||||
violations = audit_dependency_graph(self._audit_dist_name())
|
||||
if not violations:
|
||||
return None
|
||||
return describe_dependency_failure(
|
||||
None, violations, self._pinning_integrations(violations)
|
||||
)
|
||||
|
||||
def _dependency_failure(self, err: BaseException) -> EmbeddedServerError | None:
|
||||
"""Re-diagnose an import crash as a dependency conflict (blocking).
|
||||
|
||||
Returns None when ``err``'s chain holds no ``ImportError`` — every
|
||||
other crash keeps its own error untouched. The chain, not the outermost
|
||||
exception, decides: fastmcp catches the real import failure and
|
||||
re-raises its generic "FastMCP server support is not installed" hint,
|
||||
which names neither the package nor the version that moved.
|
||||
|
||||
Best-effort by construction: the diagnosis must never replace the
|
||||
failure it explains, so any error inside it degrades to the root
|
||||
``ImportError`` text — still strictly better than the outer hint.
|
||||
"""
|
||||
root = root_import_failure(err)
|
||||
if not isinstance(root, ImportError):
|
||||
return None
|
||||
try:
|
||||
violations = audit_dependency_graph(self._audit_dist_name())
|
||||
detail = describe_dependency_failure(
|
||||
root, violations, self._pinning_integrations(violations)
|
||||
)
|
||||
except Exception:
|
||||
_LOGGER.warning(
|
||||
"HA-MCP dependency diagnostics failed; reporting the import "
|
||||
"error itself",
|
||||
exc_info=True,
|
||||
)
|
||||
detail = f"The server package failed to import: {root}"
|
||||
return EmbeddedServerError(detail, kind="package")
|
||||
|
||||
def _audit_dist_name(self) -> str:
|
||||
"""Distribution whose requirement graph the audit walks (blocking).
|
||||
|
||||
A pip-spec OVERRIDE names its root explicitly — the literal
|
||||
distribution its requirement installs (:func:`_override_dist_name`:
|
||||
a pin on ``ha-mcp``, or any ``name @ url`` form, names that
|
||||
distribution whatever the channel selector says), or ``ha-mcp`` for
|
||||
a bare URL, since a repository tarball installs under that name on
|
||||
every channel. An explicit root
|
||||
is returned even with its metadata missing: auditing it then
|
||||
reports that root as the violation — the true story when its
|
||||
install failed — where any fallback would walk a stale graph
|
||||
instead. Override installs skip the conflicting-channel removal, so
|
||||
stale metadata for the unselected distribution can coexist with the
|
||||
one actually running.
|
||||
|
||||
Without an override the channel's distribution is the root, falling
|
||||
back to the other channel's when only that one has metadata —
|
||||
auditing a root that is not installed reports the root itself
|
||||
instead of the real conflict.
|
||||
"""
|
||||
if self._pip_spec_override:
|
||||
named = _override_dist_name(self._pip_spec)
|
||||
if named is None and _spec_names_no_distribution(self._pip_spec):
|
||||
# A bare URL names nothing: prefer stable (a repository
|
||||
# tarball installs as ha-mcp on every channel) but accept
|
||||
# whichever known distribution actually has metadata — a
|
||||
# dev wheel URL installs ha-mcp-dev, and auditing the
|
||||
# absent stable would report a phantom missing root on
|
||||
# every healthy bring-up (Codex on #2245).
|
||||
named = next(
|
||||
(
|
||||
dist
|
||||
for dist in (DIST_NAME_STABLE, DIST_NAME_DEV)
|
||||
if _dist_installed(dist)
|
||||
),
|
||||
DIST_NAME_STABLE,
|
||||
)
|
||||
if named is not None:
|
||||
return named
|
||||
preferred = dist_for_channel(self._channel)
|
||||
if _dist_installed(preferred):
|
||||
return preferred
|
||||
other = DIST_NAME_STABLE if preferred == DIST_NAME_DEV else DIST_NAME_DEV
|
||||
return other if _dist_installed(other) else preferred
|
||||
|
||||
def _pinning_integrations(
|
||||
self, violations: list[DependencyViolation]
|
||||
) -> list[PinningIntegration]:
|
||||
"""Find the custom integrations forcing each violation (blocking).
|
||||
|
||||
The manifest scan matches by package name; the
|
||||
:func:`requirement_forces_conflict` filter then drops integrations
|
||||
whose requirement is compatible with what the violated specifier
|
||||
needs — naming those would send the user to uninstall an innocent
|
||||
integration. This component's own domain is excluded outright: it
|
||||
declares the server requirement legitimately and is never the pin
|
||||
to remove.
|
||||
"""
|
||||
pinners: list[PinningIntegration] = []
|
||||
by_package: dict[str, list[DependencyViolation]] = {}
|
||||
for violation in violations:
|
||||
by_package.setdefault(violation.package, []).append(violation)
|
||||
for package, package_violations in by_package.items():
|
||||
pinners.extend(
|
||||
pinner
|
||||
for pinner in find_pinning_integrations(
|
||||
self._hass_config_dir, package, exclude_domains=(DOMAIN,)
|
||||
)
|
||||
if any(
|
||||
requirement_forces_conflict(pinner.requirement, violation)
|
||||
for violation in package_violations
|
||||
)
|
||||
)
|
||||
return pinners
|
||||
|
||||
# -- worker thread -----------------------------------------------------
|
||||
|
||||
def _note_startup_phase(self, phase: str) -> None:
|
||||
@@ -1266,8 +1576,12 @@ class EmbeddedServerManager:
|
||||
detail,
|
||||
)
|
||||
except Exception as err:
|
||||
self._thread_exc = err
|
||||
_LOGGER.exception("HA-MCP in-process server thread crashed")
|
||||
# Published LAST, after the (blocking) diagnosis: the readiness
|
||||
# poll surfaces _thread_exc the moment it is set, so assigning the
|
||||
# raw error first would race the enriched one out of the repair
|
||||
# issue.
|
||||
self._thread_exc = self._dependency_failure(err) or err
|
||||
finally:
|
||||
with _IMPORTING_WORKERS_LOCK:
|
||||
_IMPORTING_WORKERS.discard(threading.current_thread())
|
||||
@@ -1398,6 +1712,12 @@ class EmbeddedServerManager:
|
||||
else:
|
||||
register_browser_landing(server.mcp, self._secret_path)
|
||||
|
||||
# Parity with the CLI HTTP runner: demote the MCP SDK/fastmcp log
|
||||
# noise (routine stateless teardown, benign tool-validation
|
||||
# tracebacks, disconnect-caused "session crashed" tracebacks) that
|
||||
# every other HTTP launcher already filters.
|
||||
_install_log_filters_if_available()
|
||||
|
||||
# Own the uvicorn server instead of calling mcp.run_async(): cancelling
|
||||
# run_async's task does NOT release the listening socket in-process
|
||||
# (live-found: the next bring-up failed with EADDRINUSE and uvicorn's
|
||||
@@ -1518,9 +1838,7 @@ class EmbeddedServerManager:
|
||||
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}"
|
||||
) from self._thread_exc
|
||||
raise _worker_startup_failure(self._thread_exc)
|
||||
if self._thread is not None and not self._thread.is_alive():
|
||||
raise EmbeddedServerError(
|
||||
"HA-MCP in-process server thread exited during startup."
|
||||
@@ -2147,6 +2465,38 @@ def _pin_moves_off_installed(spec: str, installed_version: str) -> bool:
|
||||
return not requirement.specifier.contains(installed_version, prereleases=True)
|
||||
|
||||
|
||||
def _override_dist_name(spec: str) -> str | None:
|
||||
"""Distribution name a pip-spec override installs, or None for a bare URL.
|
||||
|
||||
Unlike :meth:`EmbeddedServerManager._replaced_dist_name` — which maps
|
||||
onto the two known channel distributions for the replaced-source
|
||||
checks — the audit needs the LITERAL name: an override may install an
|
||||
arbitrary distribution (``acme-ha-mcp @ file:///pkg.whl``), and that
|
||||
distribution's graph is the one the worker imports (CodeRabbit on
|
||||
#2245).
|
||||
"""
|
||||
try:
|
||||
return Requirement(spec).name
|
||||
except InvalidRequirement:
|
||||
return None
|
||||
|
||||
|
||||
def _spec_names_no_distribution(spec: str) -> bool:
|
||||
"""True when ``spec`` does not parse as a PEP 508 requirement at all.
|
||||
|
||||
A bare URL (or any unparseable spec) names no distribution of its own;
|
||||
the same conservative shape test :func:`_scoped_install_flags` routes
|
||||
on. A ``name @ url`` form parses and is therefore False here — its name
|
||||
is already what :meth:`EmbeddedServerManager._replaced_dist_name`
|
||||
reports.
|
||||
"""
|
||||
try:
|
||||
Requirement(spec)
|
||||
except InvalidRequirement:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _spec_is_url_requirement(spec: str) -> bool:
|
||||
"""True when ``spec`` installs from a URL rather than an index.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user