Skip to content

Commit 2264e39

Browse files
fix: stop the auto-updated in-process server crashing under an older custom component (#1792)
* fix: keep the server up when a single tool module fails to register A tool module that fails to import or register is now skipped with a loud error log (naming the module and the restart-HA recovery hint) instead of crashing the whole server; only a total failure - zero modules registered - still raises. Also read Settings.enable_dev_mode via getattr with a False default so a stale settings singleton from an older package generation degrades to "dev mode off" instead of AttributeError. This is the server half of issues #1783/#1785: the in-process auto-update installed ha-mcp 7.12.0 into a running Home Assistant whose component (1.0.0) predates the sys.modules purge, and the old cached registry imported the new on-disk tools_dev module into a stale module graph - one module's AttributeError took the whole server down. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01L7FvJAi5hk5vy7FvtdcZZe * fix: hold automatic server updates that shipped with a newer component Before reloading to install a newer server build, the component now fetches the component manifest at the candidate release's git tag: a component version there newer than the running one means that release changed the component too, so the automatic server install waits until HACS delivers the component update (the ordering that broke in issues #1783/#1785, where the server auto-installed before HACS had even surfaced the matching component release). The hold is loud and never indefinite-by-accident: it files a repair issue pointing at the HACS update and warns in the log on every check, the update entity keeps showing the pending build and its Install button bypasses the hold, pip-spec overrides (PR-tarball testing) never pass through the gate, and every failure inside the gate fails open so an unreachable GitHub cannot block updates. On the dev channel the release tag appears only after the binary builds, so a brand-new dev version fails open during that window (documented; the registry-side skip is the backstop there). Bumps the component to 1.0.2. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01L7FvJAi5hk5vy7FvtdcZZe --------- Co-authored-by: kingpanther13 <kingpanther13@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent d3295a8 commit 2264e39

9 files changed

Lines changed: 714 additions & 25 deletions

File tree

custom_components/ha_mcp_tools/const.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -243,6 +243,22 @@ def channel_for_dist(dist: str) -> str:
243243
# is DIST_NAME_STABLE or DIST_NAME_DEV depending on the selected channel.
244244
PYPI_JSON_URL = "https://pypi.org/pypi/{dist}/json"
245245

246+
# The component manifest as it existed at a server release's git tag. Its
247+
# ``version`` is the component version that SHIPPED with that server build, so
248+
# a value newer than the running component means the release changed the
249+
# component too — the pre-install auto-update gate in embedded_setup holds the
250+
# server update until HACS delivers the component (issues #1783/#1785).
251+
# Tag-timing caveat: stable ``vX.Y.Z`` tags exist before the PyPI publish
252+
# (semantic-release pushes the tag first), but a dev ``vX.Y.Z.devN`` tag is
253+
# only created when its draft GitHub release is published — AFTER the binary
254+
# builds, minutes after PyPI already has the version. During that dev window
255+
# this URL 404s and the gate deliberately fails open (the registry's
256+
# skip-on-failure is the backstop on that channel).
257+
COMPONENT_MANIFEST_AT_TAG_URL = (
258+
"https://raw.githubusercontent.com/homeassistant-ai/ha-mcp/"
259+
"v{version}/custom_components/ha_mcp_tools/manifest.json"
260+
)
261+
246262
# Options-flow keys (stored in entry.options).
247263
OPT_CHANNEL = "channel"
248264
# Automatic server-version updates toggle (default on). When on, the channel is
@@ -366,6 +382,14 @@ def channel_for_dist(dist: str) -> str:
366382
# the server expects; this points the user at the HACS component update
367383
# (non-blocking).
368384
ISSUE_COMPONENT_OUTDATED = "component_outdated"
385+
# Repair issue surfaced while an automatic server update is HELD because the
386+
# newer server release also shipped a newer custom component than the one
387+
# running (issues #1783/#1785): installing that server under the old component
388+
# is the combination that broke starts. Held is loud (this issue + a warning
389+
# log every check) and escapable — applying the HACS component update (which
390+
# takes an HA restart) unblocks the next check, and the update entity's
391+
# Install button bypasses the hold entirely.
392+
ISSUE_UPDATE_HELD = "server_update_held"
369393
# Repair issue surfaced when HACS is tracking the MAIN ha-mcp server repo for
370394
# this component (the pre-mirror install path — issue #1760). That install
371395
# keeps working (HACS downloads the repo snapshot at the release tag, which

custom_components/ha_mcp_tools/embedded_setup.py

Lines changed: 128 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,15 +19,18 @@
1919
from typing import TYPE_CHECKING
2020
from urllib.parse import urlparse
2121

22+
from aiohttp import ClientError
2223
from awesomeversion import AwesomeVersion, AwesomeVersionException
2324
from homeassistant.components import persistent_notification
2425
from homeassistant.core import HomeAssistant
2526
from homeassistant.helpers import issue_registry as ir
27+
from homeassistant.helpers.aiohttp_client import async_get_clientsession
2628
from homeassistant.loader import async_get_integration
2729

2830
from .const import (
2931
BIND_HOST_ALL,
3032
CHANNEL_DEV,
33+
COMPONENT_MANIFEST_AT_TAG_URL,
3134
DATA_BRINGUP_TASK,
3235
DATA_MANAGER,
3336
DATA_PENDING_UPDATE_NOTIFY,
@@ -43,6 +46,7 @@
4346
ISSUE_COMPONENT_OUTDATED,
4447
ISSUE_PACKAGE_FAILED,
4548
ISSUE_START_FAILED,
49+
ISSUE_UPDATE_HELD,
4650
OPT_AUTO_UPDATE,
4751
OPT_BIND_HOST,
4852
OPT_ENABLE_WEBHOOK,
@@ -65,7 +69,15 @@
6569

6670
_NOTIFICATION_ID = "ha_mcp_tools_server_connect"
6771
_UPDATE_NOTIFICATION_ID = "ha_mcp_tools_server_updated"
68-
_ISSUE_IDS = (ISSUE_PACKAGE_FAILED, ISSUE_START_FAILED)
72+
# ISSUE_UPDATE_HELD is cleared at bring-up start too: any reload that reaches
73+
# bring-up either bypassed the hold deliberately (the update entity's Install
74+
# button) or made it moot; if the hold still applies, the coordinator refresh
75+
# that follows setup re-files it within moments.
76+
_ISSUE_IDS = (ISSUE_PACKAGE_FAILED, ISSUE_START_FAILED, ISSUE_UPDATE_HELD)
77+
78+
# Per-request timeout for the component-manifest fetch behind the auto-update
79+
# gate — mirrors the coordinator's PyPI fetch budget; a miss fails open.
80+
_MANIFEST_FETCH_TIMEOUT_SECONDS = 30
6981

7082

7183
async def async_bring_up_server(hass: HomeAssistant, entry: ConfigEntry) -> None:
@@ -326,6 +338,16 @@ async def async_maybe_auto_update(
326338
coordinator's ``data`` type before its first successful refresh), or a
327339
bring-up is still in flight (below).
328340
341+
A pending update is additionally gated on component compatibility
342+
(issues #1783/#1785): when the candidate release also shipped a newer
343+
custom component than the one running, the reload is HELD — loudly (a
344+
repair issue plus a warning log every check) and escapably (applying the
345+
HACS component update — which takes an HA restart, as the issue text
346+
says — unblocks the next check; the update entity's Install button never
347+
passes through here, so manual installs — like pip-spec overrides above —
348+
bypass the hold entirely). Every failure inside the gate fails OPEN so a
349+
GitHub hiccup can never wedge updates.
350+
329351
Best-effort: an incomparable version string (AwesomeVersionException) is
330352
logged at debug and skipped; the next refresh retries. Genuine bugs
331353
propagate per the repo's no-silent-failure convention.
@@ -363,7 +385,39 @@ async def async_maybe_auto_update(
363385
return
364386

365387
if not newer:
388+
# Up to date: a hold that was pending is resolved (the component
389+
# update landed and the unblocked reload installed the server).
390+
ir.async_delete_issue(hass, DOMAIN, ISSUE_UPDATE_HELD)
391+
return
392+
393+
held = await _async_update_held_by_component(hass, info)
394+
if held is not None:
395+
shipped, running = held
396+
_LOGGER.warning(
397+
"HA-MCP server %s is available, but that release also updated the "
398+
"custom component (%s; running %s); holding the automatic server "
399+
"update until the component is updated via HACS. Press Install on "
400+
"the HA-MCP server update entity to install anyway.",
401+
info.latest,
402+
shipped,
403+
running,
404+
)
405+
ir.async_create_issue(
406+
hass,
407+
DOMAIN,
408+
ISSUE_UPDATE_HELD,
409+
is_fixable=False,
410+
severity=ir.IssueSeverity.WARNING,
411+
translation_key=ISSUE_UPDATE_HELD,
412+
translation_placeholders={
413+
"latest": str(info.latest),
414+
"shipped": shipped,
415+
"running": running,
416+
},
417+
learn_more_url=HACS_COMPONENT_URL,
418+
)
366419
return
420+
ir.async_delete_issue(hass, DOMAIN, ISSUE_UPDATE_HELD)
367421

368422
channel = channel_for_dist(info.dist)
369423
_LOGGER.info(
@@ -403,6 +457,79 @@ def _drop_pending_update_notify(hass: HomeAssistant) -> None:
403457
hass.data.get(DOMAIN, {}).pop(DATA_PENDING_UPDATE_NOTIFY, None)
404458

405459

460+
async def _async_update_held_by_component(
461+
hass: HomeAssistant, info: ServerVersionInfo
462+
) -> tuple[str, str] | None:
463+
"""Return ``(shipped, running)`` when the pending update must be held.
464+
465+
The #1783/#1785 breakage: a server release whose repo state also bumped the
466+
custom component auto-installed under the OLD component before HACS had
467+
even surfaced the component update. The component version in the manifest
468+
at the candidate release's git tag is what shipped with that server build —
469+
newer than the running component means the release changed the component
470+
too, so the automatic server install waits for the component.
471+
472+
Fails OPEN (returns None → install proceeds, the pre-gate behavior) on
473+
every expected failure: manifest unreachable, component version unreadable,
474+
incomparable versions. Blocking updates indefinitely on a transient would
475+
be worse than the crash this guards against — and the crash itself is now
476+
also survivable server-side (the tools registry skips a failing module).
477+
"""
478+
shipped = await _async_fetch_shipped_component_version(hass, str(info.latest))
479+
if shipped is None:
480+
return None
481+
482+
try:
483+
integration = await async_get_integration(hass, DOMAIN)
484+
running = str(integration.version)
485+
except Exception:
486+
# Same wide loader surface as _async_check_component_compat: advisory
487+
# gate, logged visibly rather than swallowed silently.
488+
_LOGGER.warning(
489+
"Could not read the HA-MCP component version for the auto-update "
490+
"gate; proceeding with the update",
491+
exc_info=True,
492+
)
493+
return None
494+
495+
try:
496+
if AwesomeVersion(running) < AwesomeVersion(shipped):
497+
return shipped, running
498+
except AwesomeVersionException as err:
499+
# Incomparable version strategies only; real bugs propagate.
500+
_LOGGER.debug("HA-MCP auto-update gate version compare failed: %s", err)
501+
return None
502+
503+
504+
async def _async_fetch_shipped_component_version(
505+
hass: HomeAssistant, server_version: str
506+
) -> str | None:
507+
"""Return the component version shipped at server release ``vX.Y.Z``.
508+
509+
Reads the component manifest as committed at the release's git tag (raw
510+
GitHub URL). Stable tags exist before the PyPI publish; a dev tag only
511+
appears after its binary builds finish, so a fresh dev version can 404
512+
here for some minutes — see COMPONENT_MANIFEST_AT_TAG_URL. Returns None
513+
on any failure; the caller treats that as "nothing to hold on"
514+
(fail-open).
515+
"""
516+
url = COMPONENT_MANIFEST_AT_TAG_URL.format(version=server_version)
517+
try:
518+
session = async_get_clientsession(hass)
519+
async with asyncio.timeout(_MANIFEST_FETCH_TIMEOUT_SECONDS):
520+
async with session.get(url) as resp:
521+
resp.raise_for_status()
522+
# content_type=None: raw.githubusercontent.com serves
523+
# text/plain, which aiohttp's default json() rejects.
524+
payload = await resp.json(content_type=None)
525+
return str(payload["version"])
526+
except (ClientError, TimeoutError, KeyError, TypeError, ValueError) as err:
527+
_LOGGER.debug(
528+
"HA-MCP shipped-component manifest fetch failed for %s: %s", url, err
529+
)
530+
return None
531+
532+
406533
async def _async_finish_update_cycle(hass: HomeAssistant) -> None:
407534
"""Refresh the version entity and fire the deferred update notification.
408535

custom_components/ha_mcp_tools/manifest.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,5 +19,5 @@
1919
"requirements": [
2020
"ruamel.yaml>=0.18.0"
2121
],
22-
"version": "1.0.1"
22+
"version": "1.0.2"
2323
}

custom_components/ha_mcp_tools/strings.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,10 @@
7474
"title": "Update the HA-MCP Custom Component via HACS",
7575
"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."
7676
},
77+
"server_update_held": {
78+
"title": "HA-MCP server update waiting for a component update",
79+
"description": "ha-mcp server {latest} is available, but that release also updated the HA-MCP Custom Component (to {shipped}; you are running {running}). To avoid starting a server version the running component has never been tested with, the automatic server update is on hold until the component is updated.\n\nUpdate the component via HACS (open the HA-MCP Custom Component entry and use 'Update information' if no update is shown yet), then restart Home Assistant - the server update installs automatically afterwards. To install the server update anyway, press Install on the HA-MCP server update entity."
80+
},
7781
"legacy_hacs_source": {
7882
"title": "Component installed from the legacy repository",
7983
"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."

custom_components/ha_mcp_tools/translations/en.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,10 @@
7474
"title": "Update the HA-MCP Custom Component via HACS",
7575
"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."
7676
},
77+
"server_update_held": {
78+
"title": "HA-MCP server update waiting for a component update",
79+
"description": "ha-mcp server {latest} is available, but that release also updated the HA-MCP Custom Component (to {shipped}; you are running {running}). To avoid starting a server version the running component has never been tested with, the automatic server update is on hold until the component is updated.\n\nUpdate the component via HACS (open the HA-MCP Custom Component entry and use 'Update information' if no update is shown yet), then restart Home Assistant - the server update installs automatically afterwards. To install the server update anyway, press Install on the HA-MCP server update entity."
80+
},
7781
"legacy_hacs_source": {
7882
"title": "Component installed from the legacy repository",
7983
"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."

src/ha_mcp/tools/registry.py

Lines changed: 56 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -143,7 +143,8 @@ def _import_and_register_module(
143143
When ``func_name`` is given, uses it directly (explicit mode); otherwise scans
144144
the module for an attribute matching the ``register_*_tools`` convention.
145145
Returns True if registered, False if no register function was found.
146-
Re-raises on import or registration failure (fail-fast).
146+
Re-raises on import or registration failure; ``register_all_tools``
147+
contains that failure to the one module (see its docstring).
147148
"""
148149
import importlib
149150

@@ -169,15 +170,23 @@ def _import_and_register_module(
169170
logger.warning(f"Module {module_name} has no register_*_tools function")
170171
return False
171172

172-
except Exception as e:
173-
logger.error(f"Failed to register tools from {module_name}: {e}")
173+
except Exception:
174+
logger.exception(f"Failed to register tools from {module_name}")
174175
raise
175176

176177
def register_all_tools(self) -> None:
177178
"""Register all tools with the MCP server using lazy auto-discovery.
178179
179180
Tool modules are imported and registered only when this method is called,
180181
which happens after the MCP server is ready to accept connections.
182+
183+
A module that fails to import or register is SKIPPED (logged loudly),
184+
not fatal: the in-process server auto-updates the ha-mcp package inside
185+
a running Home Assistant, and a mixed old/new module generation — or a
186+
module needing a newer custom component — previously took the whole
187+
server down over one module (issues #1783/#1785). Only a TOTAL failure
188+
(zero modules registered) still raises: a tool-less server "running"
189+
would hide a genuinely broken install.
181190
"""
182191
if self._modules_registered:
183192
logger.debug("Tools already registered, skipping")
@@ -189,23 +198,52 @@ def register_all_tools(self) -> None:
189198
"device_tools": self.device_tools,
190199
}
191200

201+
# tools_*.py modules by convention, then the explicit modules (those
202+
# not following the convention) — each only if discovery included it
203+
# (respects filtering).
204+
worklist: list[tuple[str, str | None]] = [
205+
(name, None)
206+
for name in self._discovered_modules
207+
if name not in EXPLICIT_MODULES
208+
]
209+
worklist += [
210+
(name, func_name)
211+
for name, func_name in EXPLICIT_MODULES.items()
212+
if name in self._discovered_modules
213+
]
214+
192215
registered_count = 0
216+
failed: list[tuple[str, Exception]] = []
217+
for module_name, func_name in worklist:
218+
try:
219+
if self._import_and_register_module(module_name, kwargs, func_name):
220+
registered_count += 1
221+
except Exception as e:
222+
failed.append((module_name, e))
193223

194-
# Import and register tools_*.py modules
195-
for module_name in self._discovered_modules:
196-
# Skip explicit modules - handled separately
197-
if module_name in EXPLICIT_MODULES:
198-
continue
199-
if self._import_and_register_module(module_name, kwargs):
200-
registered_count += 1
201-
202-
# Register explicit modules (those not following tools_*.py convention)
203-
# Only register if they were included in discovered modules (respects filtering)
204-
for module_name, func_name in EXPLICIT_MODULES.items():
205-
if module_name not in self._discovered_modules:
206-
continue
207-
if self._import_and_register_module(module_name, kwargs, func_name):
208-
registered_count += 1
224+
self._raise_or_log_registration_failures(failed, registered_count)
209225

210226
self._modules_registered = True
211227
logger.info(f"Auto-discovery registered tools from {registered_count} modules")
228+
229+
@staticmethod
230+
def _raise_or_log_registration_failures(
231+
failed: list[tuple[str, Exception]], registered_count: int
232+
) -> None:
233+
"""Raise on a total registration failure, log a summary on a partial one."""
234+
if not failed:
235+
return
236+
if registered_count == 0:
237+
raise RuntimeError(
238+
"No tool module could be registered "
239+
f"({', '.join(name for name, _ in failed)} all failed); "
240+
"the installed ha-mcp package is broken."
241+
) from failed[0][1]
242+
logger.error(
243+
"Skipped %d tool module(s) that failed to register: %s. The "
244+
"server is running without their tools. If this happened right "
245+
"after a package update, restart Home Assistant so every "
246+
"module loads from the same package version.",
247+
len(failed),
248+
", ".join(name for name, _ in failed),
249+
)

src/ha_mcp/tools/tools_dev.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -67,11 +67,15 @@ def is_dev_mode_enabled() -> bool:
6767
6868
Reads through :func:`config.get_global_settings` so the same
6969
env-var / override-file / default precedence path applies as
70-
every other runtime-editable Settings field.
70+
every other runtime-editable Settings field. ``getattr`` with a
71+
False default, not attribute access: during an in-process package
72+
update the cached settings singleton can predate this field
73+
(issues #1783/#1785), and that stale read must mean "dev mode
74+
off", never AttributeError.
7175
"""
7276
from ..config import get_global_settings
7377

74-
return bool(get_global_settings().enable_dev_mode)
78+
return bool(getattr(get_global_settings(), "enable_dev_mode", False))
7579

7680

7781
def _spawn_background(coro: Any) -> None:

0 commit comments

Comments
 (0)