Files
HomeAssistantVS/custom_components/ha_mcp_tools/embedded_entry.py
T
2026-07-20 22:52:35 -04:00

406 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_LEGACY,
)
# NOTE: embedded_setup / coordinator (and their embedded_server / mcp_webhook
# chain) are imported lazily inside the entry lifecycle functions below, not at
# module top level. They pull in aiohttp and several homeassistant.* submodules
# (auth, requirements, util.package, components.http/webhook) 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.
"""
# 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
_ensure_secrets(hass, entry)
# Bind the legacy OAuth root views synchronously here — before the slow
# background bring-up below — so they are live at boot (see the helper).
_prebind_legacy_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_legacy_oauth_views(hass: HomeAssistant, entry: ConfigEntry) -> None:
"""Register the legacy OAuth root ``/authorize`` + ``/token`` views during
entry setup, before the background bring-up's (slow) package 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 routes would register AFTER the freeze — never live until a restart —
and ``bind_legacy_views`` would see ``hass.is_running`` True and file a
restart repair that the restart cannot clear. Binding here, while the entry
is still setting up (``hass.is_running`` is False at boot), mirrors the
webhook-proxy add-on, which binds in its own ``async_setup_entry``. The
bring-up's ``async_register_webhook`` then reuses this already-bound provider.
Only relevant when legacy is the configured mode and the webhook endpoint is
enabled (legacy OAuth guards that endpoint; with no webhook there is nothing
to protect). A route-ownership conflict with the webhook-proxy add-on is
swallowed here — the bring-up re-encounters it and files the user-facing
start-failed repair.
"""
if str(entry.options.get(OPT_WEBHOOK_AUTH, "")) != WEBHOOK_AUTH_LEGACY:
return
if not bool(entry.options.get(OPT_ENABLE_WEBHOOK, True)):
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 .mcp_webhook import _register_metadata_views
from .oauth_legacy import LegacyOAuthRouteConflict, bind_legacy_views
with suppress(LegacyOAuthRouteConflict):
# Register the RFC 8414/9728 discovery views alongside the root
# /authorize + /token views, both at setup time, so the discovery
# doc's resource_metadata URL resolves at boot for RFC-compliant
# clients — not just the root views. Both are idempotent, so the
# bring-up's async_register_webhook reuses them.
_register_metadata_views(hass)
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