Skip to content

Commit b72550d

Browse files
committed
Merge branch 'fix/demote-stateless-session-disconnect-log' of https://github.com/swissmo/ha-mcp into fix/demote-stateless-session-disconnect-log
2 parents 63cf083 + d830b8e commit b72550d

33 files changed

Lines changed: 1703 additions & 230 deletions

custom_components/ha_mcp_tools/embedded_server.py

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -718,6 +718,14 @@ async def _async_wait_for_pending_install(self) -> None:
718718

719719
async def _async_ensure_package(
720720
self, *, defer_mutations: bool = False
721+
) -> str | None:
722+
"""Use the externally managed package or ensure a managed install."""
723+
if self._hass.config.skip_pip:
724+
return await self._async_externally_managed_package_version()
725+
return await self._async_ensure_managed_package(defer_mutations=defer_mutations)
726+
727+
async def _async_ensure_managed_package(
728+
self, *, defer_mutations: bool = False
721729
) -> str | None:
722730
"""Ensure ``ha-mcp`` is importable, installing the pip spec if needed.
723731
@@ -888,6 +896,85 @@ async def _async_ensure_package(
888896
self._store_installed_spec()
889897
return version
890898

899+
async def _async_externally_managed_package_version(self) -> str:
900+
"""Validate and return the package supplied outside Home Assistant.
901+
902+
skip_pip means the surrounding system owns this interpreter. This
903+
path therefore performs metadata/importability reads only: it never
904+
calls Home Assistant's requirements manager, UV, or the config-entry
905+
marker writers used by manual and automatic installs.
906+
"""
907+
importable_version: str | None = await self._hass.async_add_executor_job(
908+
_installed_ha_mcp_version
909+
)
910+
stable_version: str | None = await self._hass.async_add_executor_job(
911+
_installed_dist_version, DIST_NAME_STABLE
912+
)
913+
dev_version: str | None = await self._hass.async_add_executor_job(
914+
_installed_dist_version, DIST_NAME_DEV
915+
)
916+
target_dist = dist_for_channel(self._channel)
917+
target_version = (
918+
stable_version if target_dist == DIST_NAME_STABLE else dev_version
919+
)
920+
other_dist = (
921+
DIST_NAME_DEV if target_dist == DIST_NAME_STABLE else DIST_NAME_STABLE
922+
)
923+
other_version = (
924+
dev_version if target_dist == DIST_NAME_STABLE else stable_version
925+
)
926+
927+
if importable_version is None:
928+
raise EmbeddedServerError(
929+
"Home Assistant was started with skip_pip enabled, so HA-MCP "
930+
"will not install the externally managed server package. Use "
931+
f"the system package manager to install {target_dist} "
932+
f"{MIN_EMBEDDED_SERVER_VERSION} or newer, then reload this "
933+
"integration.",
934+
kind="package",
935+
)
936+
937+
if stable_version is not None and dev_version is not None:
938+
raise EmbeddedServerError(
939+
f"Both {DIST_NAME_STABLE} {stable_version} and "
940+
f"{DIST_NAME_DEV} {dev_version} are installed while skip_pip "
941+
"is enabled. They share the ha_mcp import package, so HA-MCP "
942+
"cannot safely select one without modifying the environment. "
943+
"Use the system package manager to leave exactly one installed, "
944+
"then reload this integration.",
945+
kind="package",
946+
)
947+
948+
if target_version is None:
949+
raise EmbeddedServerError(
950+
f"The configured {self._channel} channel expects {target_dist}, "
951+
f"but only {other_dist} {other_version} is installed while "
952+
"skip_pip is enabled. Use the system package manager to install "
953+
f"{target_dist} {MIN_EMBEDDED_SERVER_VERSION} or newer, or "
954+
f"change the HA-MCP release channel to match {other_dist}, then "
955+
"reload this integration.",
956+
kind="package",
957+
)
958+
959+
if not _is_compatible_embedded_version(target_version):
960+
raise EmbeddedServerError(
961+
f"The externally managed {target_dist} {target_version} is "
962+
"incompatible while skip_pip is enabled; this in-process "
963+
f"component requires {MIN_EMBEDDED_SERVER_VERSION} or newer. "
964+
"Upgrade it with the system package manager, then reload this "
965+
"integration.",
966+
kind="package",
967+
)
968+
969+
_LOGGER.info(
970+
"HA-MCP externally managed %s package ready (version %s; "
971+
"skip_pip enabled, channel %s)",
972+
target_dist,
973+
target_version,
974+
self._channel,
975+
)
976+
return target_version
977+
891978
async def _async_remove_legacy_target(
892979
self, target_dist: str, installed_version: str | None
893980
) -> None:

custom_components/ha_mcp_tools/embedded_setup.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -608,6 +608,10 @@ async def async_maybe_auto_update(
608608
logged at debug and skipped; the next refresh retries. Genuine bugs
609609
propagate per the repo's no-silent-failure convention.
610610
"""
611+
if hass.config.skip_pip:
612+
# The system package manager owns ha-mcp; never reload to mutate it.
613+
return
614+
611615
if not bool(entry.options.get(OPT_AUTO_UPDATE, DEFAULT_AUTO_UPDATE)):
612616
# Auto-update turned off: stay on the currently-installed version.
613617
return

custom_components/ha_mcp_tools/update.py

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -109,8 +109,10 @@ def latest_version(self) -> str | None:
109109

110110
@property
111111
def auto_update(self) -> bool:
112-
"""Reflect the entry's automatic-update option."""
113-
return bool(self._entry.options.get(OPT_AUTO_UPDATE, DEFAULT_AUTO_UPDATE))
112+
"""Reflect the effective automatic-update policy."""
113+
return not self.coordinator.hass.config.skip_pip and bool(
114+
self._entry.options.get(OPT_AUTO_UPDATE, DEFAULT_AUTO_UPDATE)
115+
)
114116

115117
@property
116118
def release_url(self) -> str | None:
@@ -126,8 +128,10 @@ def release_url(self) -> str | None:
126128

127129
@property
128130
def supported_features(self) -> UpdateEntityFeature:
129-
"""RELEASE_NOTES only on the stable channel — dev builds have no tags."""
130-
features = UpdateEntityFeature.INSTALL
131+
"""Expose install only when Home Assistant may manage the package."""
132+
features = UpdateEntityFeature(0)
133+
if not self.coordinator.hass.config.skip_pip:
134+
features |= UpdateEntityFeature.INSTALL
131135
data = self.coordinator.data
132136
if data is not None and data.dist != DIST_NAME_DEV:
133137
features |= UpdateEntityFeature.RELEASE_NOTES
@@ -257,6 +261,14 @@ async def async_install(
257261
that can still fail (review finding).
258262
"""
259263
data = self.coordinator.data
264+
if self.coordinator.hass.config.skip_pip:
265+
raise HomeAssistantError(
266+
"The HA-MCP server package is externally managed by the system "
267+
"package manager because Home Assistant was started with "
268+
"skip_pip. Install the update there, then reload this "
269+
"integration."
270+
)
271+
260272
target = version or self.latest_version
261273
if target is None:
262274
raise HomeAssistantError("No target version available to install.")

docs/beta.md

Lines changed: 34 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -177,18 +177,31 @@ token grants. If the token is missing or invalid, Puppet lands on the login
177177
page and (by its design) restarts; ha-mcp surfaces this as a clear "set the
178178
engine's access token" error rather than a silent failure.
179179

180-
Puppet's theme and dark-mode renderer controls used to dispatch Home
181-
Assistant's `settheme` event on every cold render, which Home Assistant
182-
persisted on the frontend profile of the user whose token the engine runs with
183-
— and synced to that user's real web and mobile sessions, flipping a dark-mode
184-
user's whole UI to light on every screenshot (#1909). Recent Puppet versions
185-
fixed that cold-render dispatch, so ha-mcp's snapshot/restore bracket around
186-
each capture is now disabled (#1991); the guard code is retained so it can be
187-
switched back on if a future engine regression reintroduces the write. If you
188-
run an older Puppet build, update the app (or your self-hosted sidecar
189-
image) — older engines still persist the theme selection and will keep
190-
flipping it. A dedicated Puppet account remains a sound belt-and-suspenders
191-
setup. Language selection is local to Puppet's browser session.
180+
Puppet dispatches Home Assistant's `settheme` event on cold renders, which
181+
Home Assistant persists on the frontend profile of the user whose token the
182+
engine runs with — and syncs to that user's real web and mobile sessions,
183+
flipping that user's whole UI on every screenshot (#1909). Upstream stopped
184+
the dispatch for renders that request no theme
185+
(balloob/home-assistant-addons#89), but by its own title only for that case,
186+
and it is unreleased as of Puppet 2.6.0 — so on current releases every render
187+
writes.
188+
189+
The screenshot and dashboard-get tools **detect** this and report it, but
190+
never write: they read the engine account's saved theme before and after the
191+
render and, when it changed, emit a warning naming the previous value. Undoing
192+
it is a separate, explicitly write-annotated call —
193+
`ha_manage_theme(action="set_engine_theme", value=..., expected_current=...)`
194+
— passing both values from the warning, so a theme changed in the meantime is
195+
refused rather than overwritten — so these tools stay
196+
honestly `readOnlyHint: True` (#1991). `ha_manage_theme(action=
197+
"get_engine_theme")` inspects the same value. Note this is the engine
198+
account's *per-user* profile, a different layer from the backend default that
199+
`action="set"` changes.
200+
201+
**Give the engine its own Home Assistant user and long-lived token** and the
202+
problem stops mattering: the write lands on an account nobody looks at, so no
203+
real session is disturbed. The warning is still emitted — ha-mcp has no signal
204+
telling it an account is dedicated — but it becomes safe to ignore. Language selection is local to Puppet's browser session.
192205

193206
To change the Puppet engine app's own options (such as `keep_browser_open`)
194207
or to restart it, use `ha_manage_app`; the screenshot tools only render and
@@ -262,10 +275,15 @@ render failure to a warning so it never breaks a write that already committed.
262275
`include_screenshot` (get) does not commit a dashboard/config write, and the
263276
screenshot *is* the requested payload, so a total render failure surfaces as
264277
an error (matching the standalone `ha_get_dashboard_screenshot` tool) rather
265-
than a warning a caller might miss. Because Puppet can persist theme/dark
266-
preferences (and the theme-restore bracket writes frontend user data to undo
267-
that), screenshot operations are blocked in server Read Only Mode; ordinary
268-
dashboard get/list/search calls remain available.
278+
than a warning a caller might miss. Screenshot operations stay **available** in server Read
279+
Only Mode: both entry points are `readOnlyHint: True`, so the transform does
280+
not hide them and the middleware does not block them (#1991 removed the
281+
exemption that used to block them, pinned by
282+
`test_dashboard_config_screenshot_now_passes`). Rendering still makes Puppet
283+
persist theme/dark preferences on the engine account, and ha-mcp still reports
284+
that in warnings — so in Read Only Mode you get the warning but cannot act on
285+
it, since `ha_manage_theme` is exempted only for its read actions
286+
(`get_engine_theme` inspects the value; `set_engine_theme` stays blocked).
269287

270288
**Raw rendered paths remain constrained.** `ha_get_dashboard_screenshot`
271289
validates legacy `dashboard_path` values (rejects URLs, query strings,

homeassistant-addon-dev/config.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
name: "Home Assistant MCP Server (Dev)"
22
description: "Development channel - AI assistant integration via MCP (unstable)"
3-
version: "8.3.0.dev2411"
3+
version: "8.3.0.dev2415"
44
slug: "ha_mcp_dev"
55
url: "https://github.com/homeassistant-ai/ha-mcp"
66
stage: experimental

site/src/data/tools.json

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -699,11 +699,11 @@
699699
"default": false
700700
},
701701
"theme": {
702-
"type": "Annotated[str | None, Field(description=\"Installed Home Assistant frontend theme name, applied to this render. The engine user's saved theme preference is restored after the capture (best effort).\")]",
702+
"type": "Annotated[str | None, Field(description=\"Installed Home Assistant frontend theme name, applied to this render. The engine persists this on the engine account's profile; this tool reports the change in warnings but does not undo it (see ha_manage_theme action='set_engine_theme').\")]",
703703
"default": null
704704
},
705705
"dark_mode": {
706-
"type": "Annotated[bool, Field(description=\"Render the requested theme in dark mode, applied to this render. The engine user's saved theme preference is restored after the capture (best effort).\")]",
706+
"type": "Annotated[bool, Field(description=\"Render the requested theme in dark mode, applied to this render. The engine persists this on the engine account's profile; this tool reports the change in warnings but does not undo it (see ha_manage_theme action='set_engine_theme').\")]",
707707
"default": false
708708
},
709709
"language": {
@@ -3415,16 +3415,28 @@
34153415
{
34163416
"name": "ha_manage_theme",
34173417
"title": "Manage Frontend Themes",
3418-
"description": "Manage Home Assistant frontend themes.\n\nWhen NOT to use: themes are YAML files - Home Assistant has no API to\ncreate or edit them. Installing community themes goes through HACS\n(ha_manage_hacs); editing custom theme files goes through\nha_config_set_yaml (beta, edits themes/<name>.yaml keyed by theme name\nand attempts an automatic theme reload).\n\nWhen to use: action='list' discovers installed theme names and the\ncurrent defaults; action='set' selects the backend default theme\n(optionally per light/dark mode).\n\nCaveats: action='set' changes the backend-selected default only -\nusers who explicitly picked a theme in their profile keep their\nchoice. Theme names are validated by Home Assistant at call time.\n\nEXAMPLES:\n- List themes: ha_manage_theme(action=\"list\")\n- Set default theme: ha_manage_theme(action=\"set\", theme_name=\"nord\")\n- Set dark-mode theme: ha_manage_theme(\n action=\"set\", theme_name=\"nord\", mode=\"dark\")\n- Restore built-in default: ha_manage_theme(\n action=\"set\", theme_name=\"default\")",
3418+
"description": "Manage Home Assistant frontend themes.\n\nWhen NOT to use: themes are YAML files - Home Assistant has no API to\ncreate or edit them. Installing community themes goes through HACS\n(ha_manage_hacs); editing custom theme files goes through\nha_config_set_yaml (beta, edits themes/<name>.yaml keyed by theme name\nand attempts an automatic theme reload).\n\nWhen to use: action='list' discovers installed theme names and the\ncurrent defaults; action='set' selects the backend default theme\n(optionally per light/dark mode).\n\nSCREENSHOT-ENGINE ACTIONS (per-user, not the backend default):\nTaking a dashboard screenshot makes the Puppet engine write the saved\ntheme of the Home Assistant user its token belongs to, which also\nflips that user's live web and mobile sessions. The screenshot tools\nare read-only and only *report* this; use action='set_engine_theme'\nwith the value quoted in their warning to put it back, and\naction='get_engine_theme' to inspect it. These act on that engine\naccount's per-user profile via frontend/set_user_data, which is a\ndifferent layer from the backend default that action='set' changes.\nGiving the engine its own dedicated user and token avoids the issue\nentirely.\n\nCaveats: action='set' changes the backend-selected default only -\nusers who explicitly picked a theme in their profile keep their\nchoice. Theme names are validated by Home Assistant at call time.\n\nEXAMPLES:\n- List themes: ha_manage_theme(action=\"list\")\n- Set default theme: ha_manage_theme(action=\"set\", theme_name=\"nord\")\n- Set dark-mode theme: ha_manage_theme(\n action=\"set\", theme_name=\"nord\", mode=\"dark\")\n- Restore built-in default: ha_manage_theme(\n action=\"set\", theme_name=\"default\")\n- Inspect the engine account's theme: ha_manage_theme(\n action=\"get_engine_theme\")\n- Undo a screenshot's theme change (pass BOTH values from the\n warning, so a theme changed since then is not overwritten):\n ha_manage_theme(action=\"set_engine_theme\",\n value={\"theme\": \"\", \"dark\": False},\n expected_current={\"theme\": \"default\", \"dark\": True})",
34193419
"inputSchema": {
34203420
"properties": {
34213421
"action": {
3422-
"type": "Annotated[ThemeAction, Field(description='Theme operation: list installed themes or set the default theme.')]"
3422+
"type": "Annotated[ThemeAction, Field(description=\"Theme operation: 'list' installed themes, 'set' the backend default theme, or read/restore the screenshot engine account's own per-user theme with 'get_engine_theme' / 'set_engine_theme' (a different layer from the backend default).\")]"
34233423
},
34243424
"theme_name": {
34253425
"type": "Annotated[str | None, Field(description=\"Theme name when action='set'. Must be an installed theme; 'default' restores the built-in theme, 'none' resets the chosen mode to the built-in default.\", default=None)]",
34263426
"default": null
34273427
},
3428+
"expected_current": {
3429+
"type": "Annotated[dict[str, Any] | None, JSON_STRING_COERCION, Field(description=\"Guard for action='set_engine_theme': the stored theme is read immediately before the write and the write is skipped if it no longer equals this. Omitting this value or passing null both mean 'expect no stored theme', enforced like any other value; the guard is always applied unless force is set. Best-effort, not atomic -- Home Assistant exposes no conditional write, so a change landing between that read and the write is not caught. Pass the expected_current value quoted in the screenshot tool's warning.\", default=None)]",
3430+
"default": null
3431+
},
3432+
"force": {
3433+
"type": "Annotated[bool, Field(description=\"action='set_engine_theme' only: skip the expected_current guard and overwrite unconditionally. Leave false unless you intend to discard whatever is stored.\", default=False)]",
3434+
"default": false
3435+
},
3436+
"value": {
3437+
"type": "Annotated[dict[str, Any] | None, JSON_STRING_COERCION, Field(description=\"Frontend user-data theme object when action='set_engine_theme', e.g. {'theme': '', 'dark': False}. An empty dict restores default/auto behavior. Take this verbatim from the warning a screenshot tool emitted.\", default=None)]",
3438+
"default": null
3439+
},
34283440
"mode": {
34293441
"type": "Annotated[Literal['light', 'dark'] | None, Field(description=\"Which mode the theme applies to when action='set'. Defaults to light.\", default=None)]",
34303442
"default": null

0 commit comments

Comments
 (0)