Skip to content

Commit ddfc1f8

Browse files
feat: dev mode tools, embedded server update fixes, and version diagnosability (#1780)
* feat: add developer mode with hidden ha_dev_* server management tools Closes #1775. A new enable_dev_mode setting (Developer section at the very bottom of the web settings UI's Server Settings tab, confirm-gated, or HAMCP_ENABLE_DEV_MODE env var) registers two developer tools. When the flag is off (default) the tools are never registered, so MCP clients cannot see or call them. - ha_dev_manage_server: info / update_source / restart. update_source drives the ha_mcp_tools in-process server entry's existing options flow (channel or pip spec, e.g. a GitHub PR tarball URL) so the server reinstalls and restarts itself; no custom component changes needed. In embedded mode self-affecting calls are deferred until the tool response has flushed. - ha_dev_manage_settings: list / set / reset over the same settings matrix as the web UI, with origin awareness (env-pinned refused, addon-synced routed via Supervisor, file overrides written atomically under the shared override-file lock). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix: add destructiveHint annotations and satisfy CodeQL quality gate The annotations sweep requires every tool to declare readOnlyHint or destructiveHint explicitly; both dev tools modify server state. Also make the coercion helper's no-match path an explicit return and retrieve background-task results in the test drainer (CodeQL mixed-returns / ineffectual-statement). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * refactor: use public reset_global_settings and shared reset helper in dev-mode tests Applies Gemini review suggestions: the unit tests import the public reset seam instead of the private one, and the e2e module centralizes its cache-clear + settings-reset pair in one helper. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix: apply server updates on entry reload by purging cached ha_mcp modules Live-found on a real install: an options save reinstalled the package (web UI footer showed the new on-disk version) but the new worker thread silently reused the OLD code — Python resolves imports from the process-wide sys.modules cache, so installs only took effect after a full HA core restart. ha_mcp is pure Python with a single controlled import site (the worker thread), so the manager now purges ha_mcp* from sys.modules between the install and the thread spawn. Shared third-party dependencies are deliberately not purged; a post-start check warns loudly if the running and installed versions still diverge. Also stop pre-filling the pip-spec override field with the default dist name: the "leave blank" field always looked populated and showed the stable dist name even on the dev channel. A saved override still pre-fills. Component 1.0.0 -> 1.0.1. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat: truthful version/deployment diagnostics and embedded restart button Fallout fixes from the live stale-worker hunt: - ha_report_issue now reports the RUNNING version (__version__, frozen at import) and the INSTALLED on-disk version separately with an explicit version_mismatch flag, plus process identity (instance_id/started_at/ uptime) matching /api/settings/info, and the custom component version read via its get_caller_token bootstrap service. - Installation-method detection gains an 'embedded' branch checked before the /.dockerenv probe — in-process installs no longer misreport as docker (tool, web UI, and update hint all shared the same blind spot). - get_version() prefers the distribution that owns the imported ha_mcp package instead of a fixed name order, so leftover channel metadata can't misreport the version. - The web settings UI reports deployment_mode and, in embedded mode, shows a 'Restart HA-MCP Server' button that reloads the server config entry (shared entry-discovery/reload helpers extracted from the dev tools); update_command_hint points embedded users at the update entity. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * ci: derive dev build numbers from the commit count in both publish workflows publish-dev and addon-publish-dev each used their own github.run_number, so the same commit produced two unrelated dev numbers (PyPI/Docker at .dev779 while the dev add-on sat at .dev481) and version reports were incomparable across install methods. The commit count is identical for the same commit in every workflow and jumps both counters upward. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix: survive a wedged worker during embedded bring-up (HAOS e2e regression) The purge fix exposed a startup crash cascade on QEMU-slow HAOS: a cold import outlived the 30s readiness timeout, the stop path's bounded join gave up on the still-importing thread and nulled the shared stop event, and the zombie later crashed on the _serve assert while the next bring-up purged sys.modules out from under its in-flight imports. - _serve now receives its stop event as a local: async_stop clearing the published references can no longer crash a wedged worker. - A timed-out join remembers the orphaned thread; the next start skips the module purge while that thread may still be importing (loud warning; the post-start staleness check covers the consequence). - importlib.invalidate_caches() runs only when modules were actually purged — the finder-cache flush measurably slows cold imports on slow disks, which is what tipped HAOS over the readiness budget. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix: address review findings and widen embedded readiness budget - Snapshot sys.modules keys before the purge iteration (concurrent imports on other HA threads can mutate the dict mid-iteration). - Run the override-file read and atomic write in executor threads so ha_dev_manage_settings never blocks the event loop on file I/O. - Raise the embedded readiness budget 30s -> 90s: QEMU-slow HAOS cold imports have been observed to exceed 30s and a single readiness timeout fails bring-up outright (no retry); real deployments only pay the budget on the failure path. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix: apply pr-review-toolkit findings Code review round (four specialist agents, findings verified against source): - Entry-probe exception narrowing: find_server_config_entry now skips an entry only on HomeAssistantAPIError; connection/auth failures propagate as what they are instead of masquerading as 'no server entry exists' and steering users toward reinstalling a running component. - ha_dev_manage_server info degrades ALL entry-probe failures to the warnings list (a WS hiccup previously hard-failed the whole diagnostic while the sibling HA-version probe degraded). - Bug-report rendering keeps probe failures visible: an unverifiable installed version renders as such instead of reading like a healthy match, and a missing component version reads 'not detected (not installed, or probe failed)' instead of 'not installed'. - Dev version numbers count origin/master, not the checked-out ref — a workflow_dispatch on an older ref could mint a colliding number and clobber an existing dev release. - #restartNotice gets role=status + aria-live=polite (the embedded branch writes into it; siblings already announce). - Docstring accuracy: update_source only self-interrupts in embedded mode; 'most settings' need a restart (matches the tool's own note); deduplicated the twin stop-event lifecycle comments; docs note reset refuses env/addon-managed settings. - ~20 new unit tests covering the previously untested failure branches: corrupt/unreadable override-file guards, addon-origin set/reset via Supervisor (success + rejection), entry-discovery error taxonomy, info degradation warnings, the worker's real running-version capture, the embedded update hint, null-byte rejection, and end-to-end presence of the new report fields. 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 2913104 commit ddfc1f8

26 files changed

Lines changed: 2796 additions & 33 deletions

.github/workflows/addon-publish-dev.yml

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,13 +46,24 @@ jobs:
4646
short_sha: ${{ steps.version.outputs.short_sha }}
4747
steps:
4848
- uses: actions/checkout@v7
49+
with:
50+
# Full history: the dev version number is the commit count.
51+
fetch-depth: 0
4952

5053
- name: Generate dev version
5154
id: version
5255
run: |
5356
BASE_VERSION=$(grep '^version = ' pyproject.toml | sed 's/version = "\(.*\)"/\1/')
5457
SHORT_SHA=$(echo "${{ github.sha }}" | cut -c1-7)
55-
DEV_VERSION="${BASE_VERSION}.dev${{ github.run_number }}"
58+
# dev N = commit count on master: the same commit produces the SAME
59+
# number in every workflow (PyPI/Docker in publish-dev, the dev
60+
# add-on here), so all surfaces report one dev version. run_number
61+
# was per-workflow, which is how the add-on drifted ~300 builds
62+
# behind the PyPI counter and made version reports incomparable.
63+
# Count origin/master (not HEAD): a workflow_dispatch on an older
64+
# ref would otherwise mint a LOWER number that collides with an
65+
# existing dev release and clobbers it.
66+
DEV_VERSION="${BASE_VERSION}.dev$(git rev-list --count origin/master)"
5667
5768
echo "dev_version=$DEV_VERSION" >> $GITHUB_OUTPUT
5869
echo "short_sha=$SHORT_SHA" >> $GITHUB_OUTPUT

.github/workflows/publish-dev.yml

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,14 +45,25 @@ jobs:
4545
short_sha: ${{ steps.version.outputs.short_sha }}
4646
steps:
4747
- uses: actions/checkout@v7
48+
with:
49+
# Full history: the dev version number is the commit count.
50+
fetch-depth: 0
4851

4952
- name: Generate dev version
5053
id: version
5154
run: |
5255
# Get base version from pyproject.toml
5356
BASE_VERSION=$(grep '^version = ' pyproject.toml | sed 's/version = "\(.*\)"/\1/')
5457
SHORT_SHA=$(echo "${{ github.sha }}" | cut -c1-7)
55-
DEV_VERSION="${BASE_VERSION}.dev${{ github.run_number }}"
58+
# dev N = commit count on master: the same commit produces the SAME
59+
# number in every workflow (PyPI/Docker in publish-dev, the dev
60+
# add-on here), so all surfaces report one dev version. run_number
61+
# was per-workflow, which is how the add-on drifted ~300 builds
62+
# behind the PyPI counter and made version reports incomparable.
63+
# Count origin/master (not HEAD): a workflow_dispatch on an older
64+
# ref would otherwise mint a LOWER number that collides with an
65+
# existing dev release and clobbers it.
66+
DEV_VERSION="${BASE_VERSION}.dev$(git rev-list --count origin/master)"
5667
5768
echo "dev_version=$DEV_VERSION" >> $GITHUB_OUTPUT
5869
echo "short_sha=$SHORT_SHA" >> $GITHUB_OUTPUT

AGENTS.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -470,6 +470,7 @@ src/ha_mcp/
470470

471471
**Namespace prefixes**: An optional `<namespace>_` prefix between `ha_` and the verb is allowed for grouped tool families that share a domain. The full shape becomes `ha_<namespace>_<verb>_<noun>`:
472472
- `ha_config_<verb>_<noun>` — config-management tools (`ha_config_set_helper`, `ha_config_set_automation`, `ha_config_remove_automation`, `ha_config_delete_dashboard`)
473+
- `ha_dev_<verb>_<noun>` — developer-mode tools (`ha_dev_manage_server`, `ha_dev_manage_settings`); registered only when the `enable_dev_mode` setting is on (Developer section at the bottom of the web settings UI's Server Settings tab)
473474

474475
**Accepted exceptions**: A small set of tools name a single, distinct operation where forcing a `<verb>_<noun>` shape would read worse than the natural name. These are accepted as-is and should not be flagged:
475476
- `ha_restart`, `ha_reload_core`, `ha_eval_template`

custom_components/ha_mcp_tools/config_flow.py

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -252,10 +252,12 @@ async def async_step_init(
252252
),
253253
vol.Optional(
254254
OPT_PIP_SPEC,
255-
# ``or DEFAULT_PIP_SPEC`` so a stored-empty spec (the normalized
256-
# "no override" state) re-displays the default (the unpinned
257-
# stable distribution) as a hint.
258-
default=opts.get(OPT_PIP_SPEC) or DEFAULT_PIP_SPEC,
255+
# Pre-fill only a genuinely saved override. The normalized
256+
# "no override" state renders an EMPTY field — pre-filling
257+
# DEFAULT_PIP_SPEC as a hint made a field whose help text
258+
# says "leave blank" always look populated, and showed the
259+
# STABLE dist name even on the dev channel.
260+
default=opts.get(OPT_PIP_SPEC, ""),
259261
): str,
260262
vol.Optional(
261263
OPT_SERVER_URL,

custom_components/ha_mcp_tools/embedded_server.py

Lines changed: 106 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -90,8 +90,12 @@
9090
_ACCESS_TOKEN_TTL = timedelta(days=3650)
9191

9292
# Readiness probe: how long to wait for the server thread to accept a loopback
93-
# TCP connection before declaring the start failed.
94-
_READY_TIMEOUT_SECONDS = 30.0
93+
# TCP connection before declaring the start failed. Generous on purpose: a
94+
# cold import of the fastmcp tree takes 1-3s on real hardware but has been
95+
# observed to exceed 30s on QEMU-emulated HAOS (the e2e lane), and a single
96+
# readiness timeout fails the bring-up outright — there is no retry. Real
97+
# deployments only pay this budget on the failure path.
98+
_READY_TIMEOUT_SECONDS = 90.0
9599
_READY_POLL_INTERVAL_SECONDS = 0.5
96100

97101
# How long to wait for the worker thread to exit on stop before giving up and
@@ -169,6 +173,14 @@ def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None:
169173
self._loop: asyncio.AbstractEventLoop | None = None
170174
self._stop_event: asyncio.Event | None = None
171175
self._thread_exc: BaseException | None = None
176+
# A worker that refused to die within the stop-join timeout (e.g.
177+
# wedged in a slow cold import). Tracked so the next start can skip
178+
# the module purge while it might still be importing.
179+
self._orphaned_thread: threading.Thread | None = None
180+
# ha_mcp.__version__ as imported by the CURRENT worker thread (stashed
181+
# by _serve). Compared against the installed distribution after start
182+
# to detect a stale-code worker (see _purge_ha_mcp_modules).
183+
self._running_version: str | None = None
172184

173185
@property
174186
def port(self) -> int:
@@ -199,6 +211,33 @@ async def async_start(self) -> None:
199211
access_token = await self._async_provision_token()
200212
await self._hass.async_add_executor_job(self._prepare_config_dir)
201213

214+
# Drop cached ha_mcp modules so the worker imports the code that is on
215+
# disk NOW. Without this, a reload after a pip install keeps serving
216+
# the OLD code forever: all workers are threads of the one HA core
217+
# process, and Python resolves ``import ha_mcp`` from sys.modules —
218+
# installs only took effect after a full HA core restart (issue
219+
# observed live: options saves reinstalled the package, the web UI
220+
# footer showed the new on-disk version, yet the serving worker kept
221+
# reporting the version it was first imported with).
222+
#
223+
# SKIPPED while an orphaned worker may still be importing: ripping
224+
# entries out of sys.modules under a live importer corrupts its
225+
# import in progress (seen on QEMU-slow HAOS, where a cold import
226+
# can outlive both the readiness timeout and the stop-join budget).
227+
# The post-start staleness check below surfaces the consequence
228+
# (old code possibly serving) instead.
229+
orphan = self._orphaned_thread
230+
if orphan is not None and not orphan.is_alive():
231+
self._orphaned_thread = orphan = None
232+
if orphan is None:
233+
_purge_ha_mcp_modules()
234+
else:
235+
_LOGGER.warning(
236+
"Skipping the ha_mcp module purge: a previous worker thread "
237+
"is still shutting down. The new worker may serve the "
238+
"previously imported code until Home Assistant restarts."
239+
)
240+
202241
self._thread_exc = None
203242
self._thread = threading.Thread(
204243
target=self._thread_main,
@@ -210,6 +249,25 @@ async def async_start(self) -> None:
210249

211250
await self._async_wait_until_ready()
212251

252+
# Belt-and-braces staleness check: the worker stashed the
253+
# ha_mcp.__version__ it actually imported; if that disagrees with the
254+
# installed distribution the purge did not fully take (e.g. a stray
255+
# import of ha_mcp outside the worker re-cached old modules) and only
256+
# an HA core restart applies the update — say so instead of serving
257+
# old code silently.
258+
if self._running_version:
259+
installed = await self._hass.async_add_executor_job(
260+
_installed_ha_mcp_version
261+
)
262+
if installed and installed != self._running_version:
263+
_LOGGER.warning(
264+
"HA-MCP in-process server is running version %s but "
265+
"version %s is installed; restart Home Assistant to "
266+
"finish applying the update.",
267+
self._running_version,
268+
installed,
269+
)
270+
213271
async def async_stop(self) -> None:
214272
"""Signal the worker thread to shut down and join it (bounded).
215273
@@ -240,10 +298,16 @@ async def async_stop(self) -> None:
240298
"leaving it to terminate with the process.",
241299
_STOP_JOIN_TIMEOUT_SECONDS,
242300
)
301+
# Remember the zombie: the next start must not purge modules
302+
# while this thread may still be importing them. The worker
303+
# holds its own loop/stop-event as locals, so clearing the
304+
# published references below cannot crash it.
305+
self._orphaned_thread = thread
243306
self._thread = None
244307
self._loop = None
245308
self._stop_event = None
246309
self._thread_exc = None
310+
self._running_version = None
247311

248312
async def async_revoke_credentials(self) -> None:
249313
"""Revoke the provisioned refresh token and remove the server's user.
@@ -582,10 +646,14 @@ def _thread_main(self, access_token: str) -> None:
582646

583647
loop = asyncio.new_event_loop()
584648
asyncio.set_event_loop(loop)
649+
# Created here and handed to _serve as a LOCAL; publishing to self
650+
# is one-way, for async_stop's signaling only — see async_stop's
651+
# zombie-thread comment for why the worker must never re-read it.
652+
stop_event = asyncio.Event()
585653
self._loop = loop
586-
self._stop_event = asyncio.Event()
654+
self._stop_event = stop_event
587655
try:
588-
loop.run_until_complete(self._serve(access_token))
656+
loop.run_until_complete(self._serve(access_token, stop_event))
589657
except Exception as err:
590658
self._thread_exc = err
591659
_LOGGER.exception("HA-MCP in-process server thread crashed")
@@ -609,7 +677,7 @@ def _thread_main(self, access_token: str) -> None:
609677
)
610678
loop.close()
611679

612-
async def _serve(self, access_token: str) -> None:
680+
async def _serve(self, access_token: str, stop_event: asyncio.Event) -> None:
613681
"""Build the ha-mcp server and run it until a stop is signaled.
614682
615683
Mirrors the CLI HTTP runner in ``ha_mcp.__main__`` without importing it
@@ -621,6 +689,11 @@ async def _serve(self, access_token: str) -> None:
621689
# of os.environ is the whole point of the in-process channel.
622690
import ha_mcp.config as _hamcp_config
623691

692+
# Record which code generation this worker actually imported — the
693+
# post-start staleness check in async_start compares it against the
694+
# installed distribution.
695+
self._running_version = getattr(sys.modules.get("ha_mcp"), "__version__", None)
696+
624697
# Drop any settings singleton cached by a PREVIOUS start in this same
625698
# Python process: an entry reload must re-read the override files
626699
# (feature flags, advanced settings) exactly like an add-on restart
@@ -739,8 +812,7 @@ async def _serve(self, access_token: str) -> None:
739812
)
740813
uv_server = uvicorn.Server(config)
741814

742-
assert self._stop_event is not None
743-
stop_task = asyncio.create_task(self._stop_event.wait())
815+
stop_task = asyncio.create_task(stop_event.wait())
744816
async with server.mcp._lifespan_manager():
745817
serve_task = asyncio.create_task(uv_server.serve())
746818
done, _pending = await asyncio.wait(
@@ -812,6 +884,33 @@ async def _async_probe_port(self) -> bool:
812884
return True
813885

814886

887+
def _purge_ha_mcp_modules() -> None:
888+
"""Drop every cached ``ha_mcp`` module so the next import loads fresh code.
889+
890+
The in-process server runs as a thread of the HA core Python process, and
891+
Python resolves imports from the process-wide ``sys.modules`` cache — so
892+
after a pip install the next worker would silently reuse the OLD code
893+
unless the cache is purged first. Safe here because ``ha_mcp`` is pure
894+
Python and is only ever imported inside the (currently stopped) worker
895+
thread; third-party dependencies are deliberately NOT purged (they are
896+
shared with the rest of Home Assistant), so a dependency-version change
897+
still needs an HA core restart.
898+
"""
899+
# Snapshot the keys: sys.modules can be mutated by concurrent imports on
900+
# other threads mid-iteration (HA core is heavily threaded).
901+
purged = [
902+
name
903+
for name in list(sys.modules)
904+
if name == "ha_mcp" or name.startswith("ha_mcp.")
905+
]
906+
if not purged:
907+
return
908+
for name in purged:
909+
sys.modules.pop(name, None)
910+
importlib.invalidate_caches()
911+
_LOGGER.debug("Purged %d cached ha_mcp module(s) before worker start", len(purged))
912+
913+
815914
def _installed_ha_mcp_version() -> str | None:
816915
"""Return the installed ha-mcp distribution version, or None (blocking).
817916

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.0"
22+
"version": "1.0.1"
2323
}

docs/dev-mode.md

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
# Developer Mode
2+
3+
Developer mode registers two hidden MCP tools intended for people developing
4+
or testing ha-mcp itself. It is **off by default**, and while it is off the
5+
tools are never registered — MCP clients cannot see or call them.
6+
7+
> **Warning**: with developer mode on, any connected MCP client (i.e. any AI
8+
> agent using this server) can change server settings and replace the running
9+
> server version. Enable it only on instances used for development/testing.
10+
11+
## Enabling
12+
13+
The toggle lives at the **very bottom of the web settings UI**: Server
14+
Settings tab → **Developer** section (below the beta features). Flip the
15+
switch, confirm the warning, and restart the server for the tools to
16+
register. Alternatively set the `HAMCP_ENABLE_DEV_MODE=true` env var.
17+
18+
The flag is intentionally absent from the add-on Configuration page.
19+
20+
## Tools
21+
22+
### `ha_dev_manage_server`
23+
24+
| Action | What it does |
25+
| ------ | ------------- |
26+
| `info` | Reports server version, deployment mode (embedded / add-on / standalone), Python version, data dir, HA version, and — when the [in-process server](in-process-server.md) entry exists — its current channel and pip spec. |
27+
| `update_source` | Points the in-process (custom component) server at a release `channel` (`stable` / `dev`) or an explicit `pip_spec` — a version pin or a GitHub tarball URL such as `https://github.com/homeassistant-ai/ha-mcp/archive/refs/pull/<PR>/head.tar.gz` — then reinstalls and restarts it via the component's own options flow. |
28+
| `restart` | Restarts this server: config-entry reload in embedded mode, Supervisor self-restart in add-on mode. Standalone processes must be restarted externally. |
29+
30+
`update_source` makes PR testing a one-call operation on an in-process
31+
server install: point the pip spec at the PR tarball, wait for the reinstall,
32+
reconnect, and verify with `info`. No extra repos or add-on rebuilds needed.
33+
Server-code updates apply on the entry reload itself (component >= 1.0.1
34+
purges the module cache per worker start); a change that needs *newer
35+
third-party dependencies* still wants a Home Assistant core restart, since
36+
shared libraries already loaded by the HA process are not reloaded.
37+
38+
### `ha_dev_manage_settings`
39+
40+
| Action | What it does |
41+
| ------ | ------------- |
42+
| `list` | Returns the full server-settings matrix (the same fields as the web UI's Server Settings tab) with each value's origin: `env` (pinned, read-only), `file` (override file), `addon` (Supervisor-managed), or `default`. |
43+
| `set` | Validates and persists one setting through the same override layer the web UI uses. Env-pinned settings are refused; beta sub-flags still require the beta master to be on. |
44+
| `reset` | Removes one setting's override-file entry, returning it to its default. Refused for env-pinned and add-on-managed settings, like `set`. |
45+
46+
Changes persist immediately but — like the web UI — most settings only take
47+
effect after a restart (`ha_dev_manage_server` `restart`).
48+
49+
Backup settings and per-tool enable/disable state are separate surfaces
50+
(Backups tab / Tools tab) and are not covered by this tool.

docs/in-process-server.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -146,7 +146,11 @@ unpinned: an entry reload or a Home Assistant restart always reinstalls the
146146
newest build of the selected channel, and on top of that the component checks
147147
PyPI for a newer build every 6 hours and reloads the entry automatically when
148148
one is published — so a long-running instance picks up releases without a
149-
restart. Each automatic update also raises a notification naming the old and
149+
restart. The reload applies the new server code immediately (component >=
150+
1.0.1 reloads the module cache per worker start); only updates that require
151+
newer *third-party dependencies* still need a Home Assistant core restart.
152+
The web settings UI's **Restart HA-MCP Server** button performs the same
153+
entry reload. Each automatic update also raises a notification naming the old and
150154
new version, with a link to the release notes. Turn **Automatic server
151155
updates** off to freeze the server on the version currently installed:
152156
reloads/restarts keep that exact version until you turn it back on or install

src/ha_mcp/_version.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,18 @@ def get_version() -> str:
3030
"""
3131
if override := os.environ.get("HA_MCP_BUILD_VERSION"):
3232
return override
33+
# Prefer the distribution that actually OWNS the installed ha_mcp package.
34+
# Both channel dists (ha-mcp / ha-mcp-dev) can leave metadata behind — an
35+
# interrupted channel switch, a best-effort uninstall that failed — and a
36+
# fixed name order then reports the leftover dist's version instead of the
37+
# one whose files are really installed.
38+
try:
39+
owners = importlib.metadata.packages_distributions().get("ha_mcp", [])
40+
unique = sorted(set(owners))
41+
if len(unique) == 1:
42+
return importlib.metadata.version(unique[0])
43+
except Exception as exc:
44+
logger.debug("packages_distributions probe failed: %s", exc)
3345
for pkg_name in ("ha-mcp", "ha-mcp-dev"):
3446
try:
3547
return importlib.metadata.version(pkg_name)

0 commit comments

Comments
 (0)