406 lines
20 KiB
Python
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
|