Skip to content

Commit 4d04870

Browse files
feat: server update entity, auto-update notifications, and real HACS release notes (#1760) (#1776)
* feat: surface in-process server updates via an update entity and repair issues (#1760) The embedded server self-updates from PyPI with only an INFO log line, and with auto-update off the PyPI poll never ran at all, so users had zero signal that a server update existed. The component and server stay independently versioned (no lockstep); this adds the missing visibility: - ServerVersionCoordinator polls installed + PyPI-latest on the existing 6h interval regardless of the auto_update option; only the reload stays gated on it (plus a new guard against reloading while bring-up is still in flight, since the first refresh now runs shortly after setup). - New update platform entity per server entry: installed/latest version, auto_update reflection, channel-aware release_url, GitHub release notes on the stable channel, device sw_version, and a manual Install that works with auto-update off via a one-shot pending-install marker the server manager pins to and clears after a successful install. - Persistent notification (no secrets) when an automatic update installs, naming old/new versions and linking the release notes. - Legacy-HACS-source repair issue: installs whose HACS entry still tracks the main server repo see 7.x server versions and server release notes in HACS; detect that via HACS's registry after HA start and point at the mirror. The legacy path keeps working. - hacs.json HA floor to 2024.11.0 (DataUpdateCoordinator's config_entry kwarg exists since 2024.11). - Component version 0.15.0. Hermetic unit tests for all of the above; unit test stubs no longer clobber each other's homeassistant.core module (sys.modules setdefault), which full-suite collection order exposed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * ci: generate real component release notes for the HACS mirror (#1760) Mirror releases carried a one-line stub body, so even clean mirror installs saw no meaningful changelog in the HACS update dialog. The deploy key can push git refs but cannot set a release body via the API, so the notes travel inside an annotated tag message: the sync workflow builds them from commit subjects touching custom_components/ha_mcp_tools/ between the last two stable server releases (scripts/build_mirror_release_notes.py) and tags with --cleanup=verbatim (the -F default strips #-prefixed markdown headings). The mirror's release-on-tag workflow now lives canonically in this repo under .github/integration-mirror-workflows/, is synced over like the rest of the mirror, and uses the tag message as the release body with the old stub as fallback for lightweight tags. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs: document the update entity, auto-update notification, and legacy-repo repair issue Also fixes the now-stale claim that turning automatic updates off stops the periodic check — the check keeps running to feed the update entity; only the automatic install is gated. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * chore: bump the custom component to 1.0.0 Owner decision: the component has shipped the full in-process server since the installation overhaul and should have left 0.x then; doing it with this release. Supersedes the 0.15.0 bump earlier on this branch. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix: harden the update flow per PR review (marker one-shot, notify-on-success, honest Install) Five review agents plus Gemini surfaced interlocking gaps in the new update flow; all confirmed against source and fixed: - Pending-install marker is consumed on ATTEMPT, not on success: cleared only on success, a marker for a failing version re-pinned every later reload -- including periodic auto-update ones -- to the same broken version, looping the failure forever with the server down (critical finding). - The "server updated" notification no longer fires optimistically right after requesting the reload: async_maybe_auto_update leaves a hass.data marker and bring-up's success path fires the notification with the actually-installed version; failure paths drop it (repair issues cover them). The auto-update reload is also guarded now -- a raising reload gets an ERROR log instead of dying silently in a background task. - Bring-up's success path refreshes the version coordinator, so the update entity reflects a completed install immediately instead of showing a stale "update available" for up to 6 hours. - update entity's Install now waits for the reloaded entry's bring-up and verifies the requested version actually landed, raising into the update UI when it did not; previously it reported success at reload time, before the pip install had even run. - async_release_notes logs unexpected payload shapes at warning with traceback (debug stays for expected transients); async_install logs before wrapping into HomeAssistantError. - Mirror release-on-tag workflow: lightweight-tag detection now uses the tag object type (%(contents) falls through to the commit message, so the old emptiness check could never fire), and release-create failures are no longer swallowed as "release exists" -- existence is checked explicitly. - async_maybe_auto_update's info parameter typed ServerVersionInfo | None to match its documented and tested None handling; channel<->dist mapping consolidated into const.dist_for_channel/channel_for_dist (5 inline copies). - Stale text swept: const.py's auto-update comment blocks still described the old poll-stops-when-off behavior; the legacy-repo repair issue said 0.x for a 1.x component. - Coordinator stored before the bring-up task is created (its success path now reads it), and tests for every behavior above (+14). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: kingpanther13 <kingpanther13@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent e2c28a4 commit 4d04870

32 files changed

Lines changed: 2745 additions & 300 deletions
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
name: Release on tag
2+
3+
# Canonical copy: this file lives in the main repo (homeassistant-ai/ha-mcp)
4+
# at .github/integration-mirror-workflows/release-on-tag.yml and is synced
5+
# into the ha-mcp-integration mirror by sync-integration-mirror.yml. Do not
6+
# hand-edit it in the mirror -- edits are overwritten on the next sync.
7+
#
8+
# The monorepo sync pushes vX.Y.Z tags (deploy keys cannot call the GitHub
9+
# API to create a release directly, only push git refs -- including
10+
# annotated tags and, per this same sync workflow, this file). Since the
11+
# deploy key can't set a release body via the API either, the tag itself
12+
# carries the body: the sync workflow creates an ANNOTATED tag whose message
13+
# is the generated component release notes, and this job uses that message
14+
# verbatim as the release body. Tags predating this change (or any that
15+
# somehow end up lightweight/empty) fall back to the old generic stub so
16+
# behavior degrades gracefully instead of shipping a blank release.
17+
on:
18+
push:
19+
tags: ["v*"]
20+
21+
permissions:
22+
contents: write
23+
24+
jobs:
25+
release:
26+
runs-on: ubuntu-latest
27+
steps:
28+
- uses: actions/checkout@v7
29+
with:
30+
# A shallow, ref-only checkout of a tag-push event is not
31+
# guaranteed to bring down the full annotated tag object (only
32+
# the commit it points to) -- fetch full history and tags so the
33+
# tag message is actually present locally for the next step.
34+
fetch-depth: 0
35+
fetch-tags: true
36+
37+
- name: Extract tag message
38+
run: |
39+
# A lightweight tag has no message of its own — %(contents) on one
40+
# falls through to the pointed-to COMMIT's message, so an emptiness
41+
# check cannot detect it (review finding). Check the tag object's
42+
# actual type instead: only a real annotated tag ("tag" object)
43+
# carries the generated release notes; anything else gets the stub.
44+
if [ "$(git cat-file -t "refs/tags/$GITHUB_REF_NAME")" = "tag" ]; then
45+
git tag -l --format='%(contents)' "$GITHUB_REF_NAME" > /tmp/notes.md
46+
else
47+
: > /tmp/notes.md
48+
fi
49+
if ! grep -q '[^[:space:]]' /tmp/notes.md; then
50+
echo "Component snapshot synced from homeassistant-ai/ha-mcp." > /tmp/notes.md
51+
fi
52+
53+
- name: Create release for tag
54+
env:
55+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
56+
run: |
57+
VER="${GITHUB_REF_NAME#v}"
58+
# Check existence explicitly instead of `|| echo "release exists"`:
59+
# that pattern treated EVERY create failure (auth, rate limit, bad
60+
# notes file) as already-exists and reported green with no release
61+
# published (review finding). Now only a genuine duplicate no-ops;
62+
# a real create failure fails the job visibly.
63+
if gh release view "$GITHUB_REF_NAME" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then
64+
echo "release exists"
65+
exit 0
66+
fi
67+
gh release create "$GITHUB_REF_NAME" --repo "$GITHUB_REPOSITORY" \
68+
--title "$VER" -F /tmp/notes.md

.github/workflows/sync-integration-mirror.yml

Lines changed: 34 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ on:
2525
- "README.md"
2626
- ".github/integration-mirror-readme-prefix.md"
2727
- "scripts/build_mirror_readme.py"
28+
- ".github/integration-mirror-workflows/**"
2829
workflow_run:
2930
workflows: ["SemVer Release"]
3031
types: [completed]
@@ -35,6 +36,12 @@ jobs:
3536
runs-on: ubuntu-latest
3637
steps:
3738
- uses: actions/checkout@v7
39+
with:
40+
# The release-notes step needs the full stable-tag history
41+
# (git tag -l 'v*') to find the previous and current server
42+
# release, not just the default single-commit shallow checkout.
43+
fetch-depth: 0
44+
fetch-tags: true
3845

3946
- name: Set up mirror push key
4047
env:
@@ -62,6 +69,13 @@ jobs:
6269
--prefix .github/integration-mirror-readme-prefix.md \
6370
--readme README.md \
6471
--out /tmp/mirror/README.md
72+
# Deploy keys can push workflow files (unlike a PAT, they aren't
73+
# subject to the `workflow` scope restriction), and the mirror
74+
# carries no hand-made changes, so its release-on-tag.yml is
75+
# synced from here too rather than hand-edited in the mirror.
76+
mkdir -p /tmp/mirror/.github/workflows
77+
cp .github/integration-mirror-workflows/release-on-tag.yml \
78+
/tmp/mirror/.github/workflows/release-on-tag.yml
6579
6680
- name: Commit and push
6781
env:
@@ -80,11 +94,18 @@ jobs:
8094
8195
- name: Tag mirror for stable release
8296
# Tags the mirror with the current component version after a successful
83-
# SemVer Release (or a manual re-run). The mirror's own release-on-tag
84-
# workflow turns this tag into a GitHub release (the monorepo
85-
# GITHUB_TOKEN has no cross-repo API access; the deploy key can only
86-
# push). Idempotent: an existing tag no-ops, so a release that did not
87-
# change the component version simply re-confirms the current tag.
97+
# SemVer Release (or a manual re-run). The tag is ANNOTATED and its
98+
# message carries the release notes body: the deploy key can push git
99+
# refs (including annotated tags) but has no cross-repo GitHub API
100+
# access to set a release body directly, so the mirror's
101+
# release-on-tag.yml (synced from
102+
# .github/integration-mirror-workflows/release-on-tag.yml) reads the
103+
# tag message and uses it as-is. `--cleanup=verbatim` is required:
104+
# `git tag -a -F` defaults to `--cleanup=strip`, which silently drops
105+
# any line starting with "#" -- including the markdown heading the
106+
# notes script emits. Idempotent: an existing tag no-ops, so a
107+
# release that did not change the component version simply
108+
# re-confirms the current tag.
88109
if: >-
89110
github.event_name == 'workflow_dispatch' ||
90111
(github.event_name == 'workflow_run' &&
@@ -93,10 +114,15 @@ jobs:
93114
GIT_SSH_COMMAND: ssh -i ~/.ssh/mirror_key -o IdentitiesOnly=yes
94115
run: |
95116
VER=$(python3 -c "import json; print(json.load(open('custom_components/ha_mcp_tools/manifest.json'))['version'])")
96-
cd /tmp/mirror
97-
if git ls-remote --tags origin "refs/tags/v${VER}" | grep -q .; then
117+
if git -C /tmp/mirror ls-remote --tags origin "refs/tags/v${VER}" | grep -q .; then
98118
echo "mirror tag v${VER} exists"
99119
exit 0
100120
fi
101-
git tag "v${VER}"
121+
python3 scripts/build_mirror_release_notes.py \
122+
--component-version "$VER" \
123+
--out /tmp/mirror_notes.md
124+
echo "--- release notes for v${VER} ---"
125+
cat /tmp/mirror_notes.md
126+
cd /tmp/mirror
127+
git tag -a "v${VER}" -F /tmp/mirror_notes.md --cleanup=verbatim
102128
git push origin "v${VER}"

custom_components/ha_mcp_tools/__init__.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1771,6 +1771,16 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
17711771
(issue #1527). A missing ``entry_type`` means ``tools`` so pre-existing
17721772
entries keep working across the component update with no migration.
17731773
"""
1774+
# Lazy import (matches the embedded chain's own convention in
1775+
# embedded_entry.py): keeps homeassistant.const's import surface out of
1776+
# every hermetic unit test that merely imports this package, not just the
1777+
# ones that actually call async_setup_entry.
1778+
from .install_source_check import async_schedule_install_source_check
1779+
1780+
# Runs for BOTH entry types: HACS delivers the component as a whole, not
1781+
# just the server entry, so a legacy-repo install needs detecting either way.
1782+
async_schedule_install_source_check(hass)
1783+
17741784
if entry.data.get(CONF_ENTRY_TYPE) == ENTRY_TYPE_SERVER:
17751785
from .embedded_entry import async_setup_server_entry
17761786

custom_components/ha_mcp_tools/const.py

Lines changed: 67 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -212,12 +212,31 @@
212212
CHANNEL_DEV = "dev"
213213
DEFAULT_CHANNEL = CHANNEL_STABLE
214214

215-
# Automatic server-version updates. Both channels are unpinned, so an entry
216-
# reload / HA restart already reinstalls the newest build; on top of that the
217-
# component polls PyPI on this interval and reloads the entry when a newer build
218-
# is published, so a long-running instance picks up releases without a restart.
219-
# An explicit pip-spec override disables the check, as does turning off the
220-
# ``auto_update`` option (OPT_AUTO_UPDATE).
215+
216+
def dist_for_channel(channel: str) -> str:
217+
"""Map a release channel to its PyPI distribution name.
218+
219+
The channel <-> distribution correspondence is used by the version
220+
coordinator, the auto-update notification, and the server manager's pip
221+
resolution — one shared mapping so a future third channel cannot be added
222+
to some sites and missed in others (review finding on #1760).
223+
"""
224+
return DIST_NAME_DEV if channel == CHANNEL_DEV else DIST_NAME_STABLE
225+
226+
227+
def channel_for_dist(dist: str) -> str:
228+
"""Inverse of :func:`dist_for_channel`."""
229+
return CHANNEL_DEV if dist == DIST_NAME_DEV else CHANNEL_STABLE
230+
231+
232+
# Interval of the ServerVersionCoordinator's PyPI poll (coordinator.py). The
233+
# poll itself ALWAYS runs — it feeds the `update` platform entity, which must
234+
# stay populated even when automatic updates are off (issue #1760). Whether a
235+
# newer build actually triggers a reload/reinstall is decided separately, per
236+
# refresh, in embedded_setup.async_maybe_auto_update (gated on OPT_AUTO_UPDATE
237+
# and on no pip-spec override). Only an explicit pip-spec override skips the
238+
# PyPI fetch — comparing PyPI-latest against an arbitrary pip spec is
239+
# meaningless.
221240
UPDATE_CHECK_INTERVAL = timedelta(hours=6)
222241

223242
# PyPI JSON API for the latest published version of a distribution. ``{dist}``
@@ -227,11 +246,13 @@
227246
# Options-flow keys (stored in entry.options).
228247
OPT_CHANNEL = "channel"
229248
# Automatic server-version updates toggle (default on). When on, the channel is
230-
# unpinned and auto-updates (force-install on reload/restart + the periodic
231-
# check). When off, the server stays on the version currently installed:
232-
# _resolve_pip_spec pins the channel's dist to that version and the periodic
233-
# check is skipped. Governs the ha-mcp server package only — component updates
234-
# still come through HACS. An explicit OPT_PIP_SPEC override wins over both.
249+
# unpinned and auto-updates (force-install on reload/restart + a reload when the
250+
# periodic check sees a newer build). When off, the server stays on the version
251+
# currently installed: _resolve_pip_spec pins the channel's dist to that version
252+
# — but the periodic PyPI check KEEPS running so the update entity still shows
253+
# newer builds; its Install button is the manual path (issue #1760). Governs the
254+
# ha-mcp server package only — component updates still come through HACS. An
255+
# explicit OPT_PIP_SPEC override wins over both and skips the check entirely.
235256
OPT_AUTO_UPDATE = "auto_update"
236257
DEFAULT_AUTO_UPDATE = True
237258
OPT_SERVER_PORT = "server_port"
@@ -262,6 +283,14 @@
262283
# pre-release test channel) force an actual reinstall on the next start instead
263284
# of hitting the requirements manager's is-installed shortcut.
264285
DATA_LAST_PIP_SPEC = "last_pip_spec"
286+
# One-shot marker set by the update entity's Install button (issue #1760):
287+
# with auto-update off, EmbeddedServerManager._resolve_pip_spec pins the
288+
# channel to the CURRENTLY installed version, so a bare reload would just
289+
# reinstall the same build. This pins the next install to a specific version
290+
# regardless of auto_update; embedded_server clears it when it CONSUMES it
291+
# (before the install attempt) — one marker buys exactly one attempt, so a
292+
# failing pinned version can never re-pin later reloads (review finding).
293+
DATA_PENDING_INSTALL_VERSION = "pending_install_version"
265294

266295
# hass.data[DOMAIN] sub-keys for the server runtime. Distinct from the tools
267296
# entry's sub-keys ("caller_token" / "allowed_paths") so both entry types can
@@ -273,6 +302,17 @@
273302
# on a genuine options change — the background bring-up persists ids/token/pip
274303
# spec to entry.data, and those writes must not trigger a self-reload.
275304
DATA_LAST_OPTIONS = "last_options"
305+
# The ServerVersionCoordinator instance backing the `update` platform entity
306+
# (issue #1760) — stored so the platform's async_setup_entry can retrieve it.
307+
DATA_UPDATE_COORDINATOR = "update_coordinator"
308+
# Set by async_maybe_auto_update right before it reloads the entry for an
309+
# automatic update ({"old": <version>}): the "server updated" notification must
310+
# only fire once the reloaded entry's bring-up actually installed and started
311+
# the new build — the reload call returns as soon as entry SETUP finishes,
312+
# while the pip install still runs in the background and can fail (review
313+
# finding on #1760). Bring-up pops it: notification on success, silent drop on
314+
# failure (the package/start repair issues cover that path).
315+
DATA_PENDING_UPDATE_NOTIFY = "pending_update_notify"
276316

277317
# Webhook auth modes (mirrors the webhook-proxy add-on's default posture).
278318
WEBHOOK_AUTH_NONE = "none" # secret webhook URL is the shared secret (default)
@@ -309,6 +349,14 @@
309349
# namespace (mirrors the webhook-proxy add-on's /api/mcp_proxy/oauth base).
310350
OAUTH_BASE = "/api/ha_mcp_tools/oauth"
311351

352+
# HACS "add repository" deep link for the custom component. Shared learn_more_url
353+
# for every repair issue that ends with "install/reinstall the component via
354+
# HACS" (the component-outdated issue and the legacy-HACS-source issue below).
355+
HACS_COMPONENT_URL = (
356+
"https://my.home-assistant.io/redirect/hacs_repository/"
357+
"?owner=homeassistant-ai&repository=ha-mcp-integration&category=integration"
358+
)
359+
312360
# Repair-issue ids surfaced when server bring-up fails.
313361
ISSUE_PACKAGE_FAILED = "server_package_install_failed"
314362
ISSUE_START_FAILED = "server_start_failed"
@@ -318,3 +366,11 @@
318366
# the server expects; this points the user at the HACS component update
319367
# (non-blocking).
320368
ISSUE_COMPONENT_OUTDATED = "component_outdated"
369+
# Repair issue surfaced when HACS is tracking the MAIN ha-mcp server repo for
370+
# this component (the pre-mirror install path — issue #1760). That install
371+
# keeps working (HACS downloads the repo snapshot at the release tag, which
372+
# contains the component), but HACS shows the SERVER's version numbers and
373+
# release notes, not the component's own; HACS has no repository-migration
374+
# mechanism, so this only self-resolves if the user re-adds the dedicated
375+
# mirror (homeassistant-ai/ha-mcp-integration).
376+
ISSUE_LEGACY_HACS_SOURCE = "legacy_hacs_source"
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
"""Poll the server package's installed vs. latest PyPI version (issue #1760).
2+
3+
Backs the ``update`` platform entity (:mod:`update`) and the automatic-update
4+
decision (:func:`embedded_setup.async_maybe_auto_update`). Runs on
5+
:data:`UPDATE_CHECK_INTERVAL` regardless of the ``auto_update`` option — unlike
6+
the check this replaces, visibility must not depend on auto-update being on
7+
(issue #1760: with auto-update off, users previously got zero signal that a
8+
server update existed). Only the resulting *reload* is gated on ``auto_update``,
9+
in :mod:`embedded_setup`.
10+
"""
11+
12+
from __future__ import annotations
13+
14+
import asyncio
15+
import logging
16+
from dataclasses import dataclass
17+
from typing import TYPE_CHECKING
18+
19+
from aiohttp import ClientError
20+
from homeassistant.helpers.aiohttp_client import async_get_clientsession
21+
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
22+
23+
from .const import (
24+
DEFAULT_CHANNEL,
25+
DEFAULT_PIP_SPEC,
26+
DOMAIN,
27+
OPT_CHANNEL,
28+
OPT_PIP_SPEC,
29+
PYPI_JSON_URL,
30+
UPDATE_CHECK_INTERVAL,
31+
dist_for_channel,
32+
)
33+
from .embedded_server import _installed_dist_version
34+
35+
if TYPE_CHECKING:
36+
from homeassistant.config_entries import ConfigEntry
37+
from homeassistant.core import HomeAssistant
38+
39+
_LOGGER = logging.getLogger(__name__)
40+
41+
# Per-request timeout for the PyPI version-check fetch - short so a slow or
42+
# wedged PyPI never ties up the coordinator; a miss just retries next interval
43+
# (moved here from the old embedded_setup.async_check_for_update).
44+
_PYPI_TIMEOUT_SECONDS = 30
45+
46+
47+
@dataclass(frozen=True)
48+
class ServerVersionInfo:
49+
"""Installed vs. latest server-package version for one config entry."""
50+
51+
installed: str | None
52+
latest: str | None
53+
dist: str
54+
55+
56+
class ServerVersionCoordinator(DataUpdateCoordinator[ServerVersionInfo]):
57+
"""Poll the installed + PyPI-latest version of the in-process server package.
58+
59+
Deliberately NOT scoped to the ``auto_update`` option: the update entity
60+
must stay populated and the periodic check must keep running even when the
61+
user has automatic updates turned off - that visibility is the point of
62+
issue #1760. ``embedded_entry`` schedules this coordinator's listener to
63+
decide whether to actually reload.
64+
"""
65+
66+
def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None:
67+
"""Bind to the config entry and schedule on UPDATE_CHECK_INTERVAL."""
68+
self._entry = entry
69+
super().__init__(
70+
hass,
71+
_LOGGER,
72+
config_entry=entry,
73+
name=f"{DOMAIN} server version",
74+
update_interval=UPDATE_CHECK_INTERVAL,
75+
)
76+
77+
async def _async_update_data(self) -> ServerVersionInfo:
78+
"""Return the installed + latest version for the configured channel.
79+
80+
Never raises ``UpdateFailed`` for an expected PyPI transient - the
81+
entity must stay available (showing the installed version) even when
82+
PyPI is unreachable; :meth:`_async_fetch_latest`'s own narrow except
83+
clause is the only one expected to fire in normal operation.
84+
"""
85+
options = self._entry.options
86+
channel = str(options.get(OPT_CHANNEL) or DEFAULT_CHANNEL)
87+
dist = dist_for_channel(channel)
88+
installed = await self.hass.async_add_executor_job(
89+
_installed_dist_version, dist
90+
)
91+
92+
override = str(options.get(OPT_PIP_SPEC) or "").strip()
93+
if override and override != DEFAULT_PIP_SPEC:
94+
# An explicit pip-spec override (a version pin, a tarball URL)
95+
# makes a PyPI-latest comparison meaningless - skip the fetch.
96+
return ServerVersionInfo(installed=installed, latest=None, dist=dist)
97+
98+
latest = await self._async_fetch_latest(dist)
99+
return ServerVersionInfo(installed=installed, latest=latest, dist=dist)
100+
101+
async def _async_fetch_latest(self, dist: str) -> str | None:
102+
"""Return the newest PyPI version for ``dist``, or None on any failure."""
103+
try:
104+
session = async_get_clientsession(self.hass)
105+
async with asyncio.timeout(_PYPI_TIMEOUT_SECONDS):
106+
async with session.get(PYPI_JSON_URL.format(dist=dist)) as resp:
107+
resp.raise_for_status()
108+
payload = await resp.json()
109+
return str(payload["info"]["version"])
110+
except (ClientError, TimeoutError, KeyError, ValueError) as err:
111+
_LOGGER.debug("HA-MCP server version check failed for %s: %s", dist, err)
112+
return None

0 commit comments

Comments
 (0)