Files
HomeAssistantVS/custom_components/ha_mcp_tools/embedded_entry.py
T

431 lines
20 KiB
Python

"""Config-entry wiring for the in-process MCP server entry type (issue #1527).
Runs the full ha-mcp FastMCP server in-process inside Home Assistant and exposes
it remotely through a Home Assistant webhook. Creating the "server" config entry
starts the server; disabling the entry pauses it (HA calls
:func:`async_unload_server_entry` via the domain dispatcher in ``__init__``);
removing the entry revokes the provisioned credentials.
``__init__.async_setup_entry`` dispatches to these functions for the "server"
entry type; the "tools" services entry is handled separately. This module is
intentionally thin — the HA entry-point wiring only. The bring-up / teardown
orchestration lives in :mod:`embedded_setup`, and the server thread + webhook
ingress in :mod:`embedded_server` / :mod:`mcp_webhook`.
"""
from __future__ import annotations
import asyncio
import secrets
from contextlib import suppress
from typing import TYPE_CHECKING
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant, callback
from .const import (
DATA_BRINGUP_TASK,
DATA_LAST_OPTIONS,
DATA_OAUTH_CLIENT_ID,
DATA_OAUTH_CLIENT_SECRET,
DATA_OAUTH_SIGNING_KEY,
DATA_SECRET_PATH,
DATA_UPDATE_COORDINATOR,
DATA_WEBHOOK_ID,
DOMAIN,
OPT_ENABLE_SIDEBAR_PANEL,
OPT_ENABLE_WEBHOOK,
OPT_OAUTH_CLIENT_ID,
OPT_OAUTH_CLIENT_SECRET,
OPT_OAUTH_REGENERATE,
OPT_REGENERATE_SECRETS,
OPT_SECRET_PATH_OVERRIDE,
OPT_WEBHOOK_AUTH,
OPT_WEBHOOK_ID_OVERRIDE,
WEBHOOK_AUTH_HA,
WEBHOOK_AUTH_LEGACY,
WEBHOOK_AUTH_NONE,
)
# NOTE: embedded_setup / coordinator (and their embedded_server / mcp_webhook
# chain), plus websocket_api, are imported lazily inside the entry lifecycle
# functions below, not at module top level. They pull in aiohttp, yaml and
# several homeassistant.* submodules (auth, requirements, util.package,
# components.http/webhook, components.websocket_api, half a dozen helpers) that
# the entry-point wiring here never touches directly, so a top-level import
# would make importing this package require that whole stack — breaking
# hermetic unit tests that stub only the modules they use.
if TYPE_CHECKING:
from homeassistant.config_entries import ConfigEntry
async def async_setup_server_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up the server entry: schedule the server bring-up as a background task.
The bring-up (first pip install of the fastmcp tree, token provisioning,
thread start, webhook registration) can take minutes, so it must not stall HA
startup. It runs as a config-entry background task — automatically cancelled
on unload. The secret webhook id and secret path are generated first, before
the update listener is registered, so those ``entry.data`` writes never
trigger a mid-setup reload. The ``ha_mcp_tools/*`` WebSocket command surface
is registered up front, before any of that.
"""
# Imported lazily (see the import note) so the aiohttp / auth / requirements
# chain is pulled in only when an entry is actually set up.
from .coordinator import ServerVersionCoordinator
from .embedded_setup import async_bring_up_server, async_maybe_auto_update
from .ui_panel import async_register_ui_panel
from .websocket_api import async_register_commands
# The ha_mcp_tools/* WS commands are entry-agnostic read/search machinery
# over HA-core state (entity components, storage collections, lovelace,
# registries) — nothing in them reads the tools entry's hass.data, and HA
# core authenticates the connection while @require_admin gates each command.
# So the server entry registers them too (#2289): a server-entry-only
# install used to have no ha_mcp_tools/* commands at all, silently dropping
# every ha_search to the legacy path. Only the filesystem/YAML HA *services*
# stay tools-entry-only. Registered first, before anything that can fail or
# await, so a broken bring-up later never costs the install this surface;
# registration is idempotent, so a dual-entry install re-registering is
# harmless.
async_register_commands(hass)
_ensure_secrets(hass, entry)
# Bind every applicable OAuth route synchronously here — before the slow
# background bring-up below — so it is live at boot (see the helper).
_prebind_oauth_views(hass, entry)
# Admin-only "Open Web UI" sidebar panel + proxy. Registered while the entry
# exists (its proxy returns 503 until the server is actually running), so the
# user sees the panel immediately and it reflects the running state. Gated on
# the sidebar-panel option; a change to it reloads the entry, and unload's
# unconditional async_unregister_ui_panel then removes the panel this skips.
if bool(entry.options.get(OPT_ENABLE_SIDEBAR_PANEL, True)):
await async_register_ui_panel(hass)
domain_data = hass.data.setdefault(DOMAIN, {})
# Snapshot the options so the update listener reloads only on a genuine
# options change — the background bring-up persists ids/token/pip spec to
# entry.data, and those writes must not self-reload.
domain_data[DATA_LAST_OPTIONS] = dict(entry.options)
# Server-version visibility + automatic updates (issue #1760): the
# coordinator polls PyPI on its own UPDATE_CHECK_INTERVAL regardless of the
# auto_update option, backing the `update` platform entity forwarded below.
# Its listener forwards every refresh to async_maybe_auto_update, which
# decides whether to actually reload. Created and stored BEFORE the
# bring-up task: bring-up's success path (_async_finish_update_cycle)
# refreshes this coordinator, so it must already be in hass.data whenever
# that task runs.
coordinator = ServerVersionCoordinator(hass, entry)
domain_data[DATA_UPDATE_COORDINATOR] = coordinator
task = entry.async_create_background_task(
hass, async_bring_up_server(hass, entry), f"{DOMAIN}_bring_up"
)
domain_data[DATA_BRINGUP_TASK] = task
entry.async_on_unload(entry.add_update_listener(_async_options_updated))
@callback
def _on_version_update() -> None:
# A reload must never run synchronously from inside this listener
# callback: it would unload the UPDATE platform this very coordinator
# drives (forwarded below), tearing the coordinator down mid-callback.
#
# hass-owned, NOT entry.async_create_background_task: entry background
# tasks are cancelled by the very unload that async_maybe_auto_update's
# reload performs, so an entry-owned task would cancel itself mid-reload
# and leave the entry unloaded without ever setting back up (server down
# until restart). The interval-timer wiring this replaces ran its checks
# as plain hass jobs for the same reason.
hass.async_create_background_task(
async_maybe_auto_update(hass, entry, coordinator.data),
f"{DOMAIN}_server_auto_update",
)
entry.async_on_unload(coordinator.async_add_listener(_on_version_update))
# Background, not awaited: entry setup must not block on a PyPI round-trip
# (this is why async_config_entry_first_refresh is NOT used here). The
# coordinator reschedules itself on UPDATE_CHECK_INTERVAL after this first
# refresh completes.
entry.async_create_background_task(
hass, coordinator.async_refresh(), f"{DOMAIN}_server_version_refresh"
)
await hass.config_entries.async_forward_entry_setups(entry, [Platform.UPDATE])
return True
async def async_unload_server_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Stop the server + ingress webhook (reload-safe; keeps the provisioned token).
Unloads the UPDATE platform first so the coordinator's entity is torn down
before the coordinator itself is popped from hass.data, then cancels the
bring-up task so a still-in-flight install/start is torn down before the
explicit teardown runs.
"""
from .embedded_setup import async_teardown_server # lazy (see import note)
from .ui_panel import async_unregister_ui_panel
await hass.config_entries.async_unload_platforms(entry, [Platform.UPDATE])
domain_data = hass.data.get(DOMAIN, {})
task = domain_data.pop(DATA_BRINGUP_TASK, None)
if task is not None and not task.done():
task.cancel()
with suppress(asyncio.CancelledError):
await task
await async_teardown_server(hass)
async_unregister_ui_panel(hass)
domain_data.pop(DATA_LAST_OPTIONS, None)
domain_data.pop(DATA_UPDATE_COORDINATOR, None)
return True
async def async_remove_server_entry(hass: HomeAssistant, entry: ConfigEntry) -> None:
"""Revoke the provisioned credentials when the server config entry is removed."""
from .embedded_setup import ( # lazy (see import note)
async_revoke_credentials_on_remove,
)
await async_revoke_credentials_on_remove(hass, entry)
async def _async_options_updated(hass: HomeAssistant, entry: ConfigEntry) -> None:
"""Reload the entry when its OPTIONS change (port / auth / pip spec / URL).
Ignores the ``entry.data`` writes the background bring-up performs (webhook
id, secret path, provisioned token ids, last pip spec): those fire the same
update listener but must not reload the entry.
"""
domain_data = hass.data.get(DOMAIN, {})
if domain_data.get(DATA_LAST_OPTIONS) == dict(entry.options):
return
await hass.config_entries.async_reload(entry.entry_id)
def _prebind_oauth_views(hass: HomeAssistant, entry: ConfigEntry) -> None:
"""Register OAuth routes before the background bring-up's slow install.
An aiohttp route is only ever live if it is registered before Home Assistant
freezes its HTTP app at the end of startup. Binding these views from the
background bring-up task races HA reaching RUNNING: on a slow-install boot
the advertised metadata, scoped authorize/token, and DCR routes would never
become live until a restart. Bind the shared metadata/scoped pair for every
remote mode, DCR for none/ha_auth, and legacy's root aliases when credentials
are available. The later webhook registration reuses these bound views and
supplies the live per-request providers through ``hass.data``.
A legacy root-route ownership conflict with the webhook-proxy add-on is
swallowed here; the scoped pair remains live and background bring-up selects
its scoped-only provider.
"""
if not bool(entry.options.get(OPT_ENABLE_WEBHOOK, True)):
return
auth_mode = str(entry.options.get(OPT_WEBHOOK_AUTH, ""))
if auth_mode not in (
WEBHOOK_AUTH_NONE,
WEBHOOK_AUTH_HA,
WEBHOOK_AUTH_LEGACY,
):
return
from .mcp_webhook import _register_metadata_views
from .oauth_autoapprove import bind_autoapprove_views
_register_metadata_views(hass)
bind_autoapprove_views(hass)
if auth_mode != WEBHOOK_AUTH_LEGACY:
from .oauth_dcr import bind_dcr_view
bind_dcr_view(hass)
return
client_id = entry.data.get(DATA_OAUTH_CLIENT_ID)
client_secret = entry.data.get(DATA_OAUTH_CLIENT_SECRET)
signing_key = entry.data.get(DATA_OAUTH_SIGNING_KEY)
if not (client_id and client_secret and signing_key):
# _ensure_secrets mints these whenever legacy mode is configured; a gap
# means a partial config — let the bring-up path surface it.
return
from .oauth_legacy import LegacyOAuthRouteConflict, bind_legacy_views
with suppress(LegacyOAuthRouteConflict):
bind_legacy_views(hass, client_id, client_secret, signing_key)
def _ensure_secrets(hass: HomeAssistant, entry: ConfigEntry) -> None:
"""Generate + persist the stable webhook id and secret path on first setup.
Both live in ``entry.data`` and stay stable across restarts so the connect
URL never changes. Three owner-requested management paths, applied in
priority order on every (re)load:
1. ``regenerate_secrets`` option: mint fresh random values for BOTH and
clear any overrides plus the flag itself (one-shot rotation - the old
URL dies on this reload).
2. Override options: a non-empty ``webhook_id_override`` /
``secret_path_override`` replaces the stored value (normalized: the
secret path gets a leading ``/``).
3. First setup: mint random values for whatever is still missing.
When the configured webhook auth mode is legacy, the same three-path
lifecycle additionally applies to the legacy OAuth client_id/client_secret
(see :func:`_ensure_legacy_oauth_secrets`) — folded into this same
read-mutate-write cycle so a single ``async_update_entry`` call covers
every field that changed this load, including when BOTH a webhook secret
regenerate and an OAuth credential regenerate are requested together.
"""
data = dict(entry.data)
options = dict(entry.options)
changed = False
if options.get(OPT_REGENERATE_SECRETS):
data[DATA_WEBHOOK_ID] = f"mcp_{secrets.token_hex(16)}"
data[DATA_SECRET_PATH] = f"/private_{secrets.token_urlsafe(16)}"
# One-shot: clear the flag AND the overrides so the fresh random
# values stick (leaving an override set would re-apply it below on
# the next reload, silently undoing the rotation).
options[OPT_REGENERATE_SECRETS] = False
options[OPT_WEBHOOK_ID_OVERRIDE] = ""
options[OPT_SECRET_PATH_OVERRIDE] = ""
changed = True
else:
webhook_override = str(options.get(OPT_WEBHOOK_ID_OVERRIDE) or "").strip()
if webhook_override and data.get(DATA_WEBHOOK_ID) != webhook_override:
data[DATA_WEBHOOK_ID] = webhook_override
changed = True
path_override = str(options.get(OPT_SECRET_PATH_OVERRIDE) or "").strip()
if path_override:
if not path_override.startswith("/"):
path_override = f"/{path_override}"
if data.get(DATA_SECRET_PATH) != path_override:
data[DATA_SECRET_PATH] = path_override
changed = True
if not data.get(DATA_WEBHOOK_ID):
data[DATA_WEBHOOK_ID] = f"mcp_{secrets.token_hex(16)}"
changed = True
if not data.get(DATA_SECRET_PATH):
data[DATA_SECRET_PATH] = f"/private_{secrets.token_urlsafe(16)}"
changed = True
if options.get(OPT_WEBHOOK_AUTH) == WEBHOOK_AUTH_LEGACY:
changed = _ensure_legacy_oauth_secrets(data, options) or changed
if changed:
hass.config_entries.async_update_entry(entry, data=data, options=options)
def _ensure_legacy_oauth_secrets(data: dict, options: dict) -> bool:
"""Mint/persist/rotate the legacy OAuth mode's credentials into ``data``
(mutated in place, along with ``options`` for the one-shot regenerate
flag). Only called while the configured webhook auth mode is legacy —
switching away leaves whatever was last minted in place, so switching
back reuses it rather than silently rotating.
Mirrors the ``OPT_REGENERATE_SECRETS`` shape above: ``OPT_OAUTH_REGENERATE``
is one-shot, minting a fresh client_id/client_secret and clearing itself
plus the two override fields.
ANY credential change — regenerate, a client_id override, or a
client_secret override — also rotates ``signing_key``, making every
rotation a hard revocation of outstanding tokens (they take effect at the
restart that rebinds the views). Rotating the key on a secret change is
load-bearing (token validation never involves the secret, so the cid
claim alone would not evict anything). Rotating it on a client_id change
is defence in depth: the cid claim already evicts on a normal rotation,
but without a fresh key an ``A → B → A`` client_id sequence would
resurrect id-A's still-unexpired tokens, since they re-match the cid
claim under the unchanged-key HMAC. Rotating the key kills that echo at
zero cost. Rotation does NOT shorten the pre-restart window — the bound
views keep serving the old identity until the restart (see
``oauth_legacy.bind_legacy_views``) — which is why the startup log also
withholds rotated credentials until they are active
(``embedded_setup._surface_connect_urls`` via
``oauth_legacy.legacy_credentials_active``; review findings on #1880).
Returns True if ``data``/``options`` were mutated.
"""
changed = False
if options.get(OPT_OAUTH_REGENERATE):
data[DATA_OAUTH_CLIENT_ID] = f"hamcp-{secrets.token_hex(16)}"
data[DATA_OAUTH_CLIENT_SECRET] = secrets.token_urlsafe(32)
# Every credential change rotates the key — see docstring (kills the
# A->B->A client_id resurrection; regenerate mints random ids so it
# can't recur to a former id, but the key rotation is kept uniform).
data[DATA_OAUTH_SIGNING_KEY] = secrets.token_hex(32)
options[OPT_OAUTH_REGENERATE] = False
options[OPT_OAUTH_CLIENT_ID] = ""
options[OPT_OAUTH_CLIENT_SECRET] = ""
changed = True
else:
client_id_override = str(options.get(OPT_OAUTH_CLIENT_ID) or "").strip()
if client_id_override and data.get(DATA_OAUTH_CLIENT_ID) != client_id_override:
data[DATA_OAUTH_CLIENT_ID] = client_id_override
# Rotate the key so a re-used former client_id can't resurrect its
# old tokens (see docstring).
data[DATA_OAUTH_SIGNING_KEY] = secrets.token_hex(32)
changed = True
client_secret_override = str(options.get(OPT_OAUTH_CLIENT_SECRET) or "").strip()
if (
client_secret_override
and data.get(DATA_OAUTH_CLIENT_SECRET) != client_secret_override
):
data[DATA_OAUTH_CLIENT_SECRET] = client_secret_override
# Evict outstanding tokens along with the old secret — see the
# docstring for why a secret-only rotation must not leave them
# valid for the rest of their TTL.
data[DATA_OAUTH_SIGNING_KEY] = secrets.token_hex(32)
changed = True
# Consume the OAuth override fields once applied. entry.options is
# readable through the server's own tools
# (ha_get_integration(include_options=True) rebuilds it from the
# options-form suggested_values), so a rotated client_secret left here
# in cleartext would let a pre-restart old-identity token holder read
# the NEW secret that way — the exact party the rotation evicts, and
# the same leak the startup log withholds. The resolved values live in
# entry.data and on the admin-only Configure screen
# (config_flow._oauth_creds_hint), so nothing is lost. Cleared even
# when the override matched the current value: the cleartext must not
# linger regardless of whether it changed data.
#
# DELIBERATE divergence from the webhook_id / secret_path overrides
# above, which persist. Three reasons the OAuth secret is different:
# (1) A client_secret override IS the OAuth rotation path and gates a
# per-connection revocable bearer, so a lingering copy defeats the
# very revocation the rotation performs. secret_path's own rotation
# (OPT_REGENERATE_SECRETS) already clears its override, so that
# path is not self-defeating; a standalone secret_path override is
# configuration, not rotation.
# (2) A connected legacy client learns the OAuth secret only via this
# options leak (entry.data is not tool-exposed; the webhook is
# OAuth-gated). secret_path gates the direct LAN port, and per
# SECURITY.md the local network is the trusted zone and the client
# is a trusted principal — LAN-peer access to standard-mode
# endpoints is explicitly out of scope.
# (3) Persisting the webhook_id/secret_path override is deliberate UX
# (the admin sees their configured value in the form).
if options.get(OPT_OAUTH_CLIENT_ID):
options[OPT_OAUTH_CLIENT_ID] = ""
changed = True
if options.get(OPT_OAUTH_CLIENT_SECRET):
options[OPT_OAUTH_CLIENT_SECRET] = ""
changed = True
if not data.get(DATA_OAUTH_CLIENT_ID):
data[DATA_OAUTH_CLIENT_ID] = f"hamcp-{secrets.token_hex(16)}"
changed = True
if not data.get(DATA_OAUTH_CLIENT_SECRET):
data[DATA_OAUTH_CLIENT_SECRET] = secrets.token_urlsafe(32)
changed = True
if not data.get(DATA_OAUTH_SIGNING_KEY):
data[DATA_OAUTH_SIGNING_KEY] = secrets.token_hex(32)
changed = True
return changed