Added Alexa Music

This commit is contained in:
2026-07-17 10:12:15 -04:00
parent 92c5268dc8
commit 28a8cb98f6
757 changed files with 151171 additions and 85450 deletions
File diff suppressed because it is too large Load Diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 426 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 214 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 991 KiB

@@ -0,0 +1,509 @@
"""Config + options flow for the HA-MCP custom component.
One config flow serves two entry types under the shared domain, chosen from a
menu on the first step:
* ``tools`` — the privileged file / YAML services (the original component).
A single confirm step creates the entry. Single-instance, keyed on
``DOMAIN``.
* ``server`` — the in-process ha-mcp FastMCP server (issue #1527). A single
confirm step creates the entry (entry-exists = the server runs);
single-instance, keyed on ``DOMAIN-server``. Its options flow tunes the
channel / port / bind host / webhook auth / pip spec / server URL.
The two entry types are discriminated by ``entry.data[CONF_ENTRY_TYPE]``; the
options-flow dispatcher branches on it so only the server entry gets a
configurable options flow (the tools entry aborts with ``no_options``).
"""
from __future__ import annotations
import logging
from typing import Any
import voluptuous as vol
from homeassistant.config_entries import (
ConfigEntry,
ConfigFlow,
ConfigFlowResult,
OptionsFlow,
)
from homeassistant.const import __version__ as HA_VERSION
from homeassistant.core import callback
from homeassistant.helpers.selector import (
SelectOptionDict,
SelectSelector,
SelectSelectorConfig,
SelectSelectorMode,
)
from homeassistant.loader import async_get_integration
from packaging.version import InvalidVersion, Version
from .const import (
BIND_HOST_ALL,
BIND_HOST_LOOPBACK,
CHANNEL_DEV,
CHANNEL_STABLE,
CONF_ENTRY_TYPE,
DATA_SECRET_PATH,
DATA_WEBHOOK_ID,
DEFAULT_AUTO_UPDATE,
DEFAULT_BIND_HOST,
DEFAULT_CHANNEL,
DEFAULT_ENABLE_LLM_API,
DEFAULT_LLM_API_EXPOSURE,
DEFAULT_LOOPBACK_URL,
DEFAULT_PIP_SPEC,
DEFAULT_SERVER_PORT,
DIST_NAME_DEV,
DIST_NAME_STABLE,
DOMAIN,
ENTRY_TYPE_SERVER,
ENTRY_TYPE_TOOLS,
EXPOSURE_BOTH,
EXPOSURE_FULL,
EXPOSURE_TOOL_SEARCH,
LLM_API_DOCS_URL,
MIN_EMBEDDED_HOME_ASSISTANT_VERSION,
OPT_AUTO_UPDATE,
OPT_BIND_HOST,
OPT_CHANNEL,
OPT_ENABLE_LLM_API,
OPT_ENABLE_SIDEBAR_PANEL,
OPT_ENABLE_STARTUP_NOTIFICATION,
OPT_ENABLE_WEBHOOK,
OPT_EXTERNAL_URL,
OPT_LLM_API_EXPOSURE,
OPT_PIP_SPEC,
OPT_REGENERATE_SECRETS,
OPT_SECRET_PATH_OVERRIDE,
OPT_SERVER_PORT,
OPT_SERVER_URL,
OPT_WEBHOOK_AUTH,
OPT_WEBHOOK_ID_OVERRIDE,
WEBHOOK_AUTH_HA,
WEBHOOK_AUTH_NONE,
)
# Titles shown for each entry in the integration tile's entry list.
_TOOLS_ENTRY_TITLE = "HA MCP Tools"
_SERVER_ENTRY_TITLE = "HA-MCP Server"
# The single-instance server entry's unique id — distinct from the tools entry's
# unique id (``DOMAIN``) so both entry types coexist under the one domain.
_SERVER_UNIQUE_ID = f"{DOMAIN}-server"
_LOGGER = logging.getLogger(__name__)
def _installed_server_version() -> str | None:
"""Return the installed ha-mcp server version, or None if not installed.
Checks both channel distributions (only one is ever installed at a time).
Kept dependency-free (``importlib.metadata``) and swallow-nothing-surprising
so a read can never break the options form.
"""
import importlib.metadata
for dist in (DIST_NAME_STABLE, DIST_NAME_DEV):
try:
return importlib.metadata.version(dist)
except importlib.metadata.PackageNotFoundError:
continue
return None
class HaMcpToolsConfigFlow(ConfigFlow, domain=DOMAIN): # type: ignore[call-arg]
"""Handle the config flow for the HA-MCP custom component (both entry types)."""
VERSION = 1
@staticmethod
@callback
def async_get_options_flow(config_entry: ConfigEntry) -> OptionsFlow:
"""Return the options flow for this entry type.
Only the in-process server entry has options (channel / port / bind /
auth / pip spec / URL). The tools services entry has none, so it returns
a flow that aborts with an explanatory message.
"""
if config_entry.data.get(CONF_ENTRY_TYPE) == ENTRY_TYPE_SERVER:
return HaMcpServerOptionsFlow()
return _NoOptionsFlow()
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Choose which entry type to add: the services tools or the server."""
return self.async_show_menu(
step_id="user",
menu_options=[ENTRY_TYPE_SERVER, ENTRY_TYPE_TOOLS],
)
# -- tools entry: privileged file / YAML services -----------------------
async def async_step_tools(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Set up the services (tools) entry — single-instance, keyed on DOMAIN.
Plain confirm-and-create on every install type. (The add-on bootstrap
this step used to offer on Supervisor installs was removed: the
in-process server entry is the one-click way to get a server, and a
second install path only caused confusion. The add-on remains fully
supported - installed from the add-on store as always.)
"""
await self.async_set_unique_id(DOMAIN)
self._abort_if_unique_id_configured()
if user_input is not None:
return self._create_tools_entry()
return self.async_show_form(step_id="tools")
def _create_tools_entry(self) -> ConfigFlowResult:
"""Create the services (tools) config entry."""
return self.async_create_entry(
title=_TOOLS_ENTRY_TITLE,
data={CONF_ENTRY_TYPE: ENTRY_TYPE_TOOLS},
)
# -- server entry: in-process MCP server (issue #1527) ------------------
async def async_step_server(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Confirm and create the single in-process server entry.
Creating the entry starts the in-process server with the defaults (port
9584, LAN-reachable like the add-on, secret-URL auth); everything is
tunable afterward in the integration options.
"""
try:
supported = Version(HA_VERSION) >= Version(
MIN_EMBEDDED_HOME_ASSISTANT_VERSION
)
except InvalidVersion:
supported = False
if not supported:
return self.async_abort(
reason="unsupported_home_assistant",
description_placeholders={
"installed": HA_VERSION,
"required": MIN_EMBEDDED_HOME_ASSISTANT_VERSION,
},
)
await self.async_set_unique_id(_SERVER_UNIQUE_ID)
self._abort_if_unique_id_configured()
if user_input is not None:
return self.async_create_entry(
title=_SERVER_ENTRY_TITLE,
data={CONF_ENTRY_TYPE: ENTRY_TYPE_SERVER},
options={},
)
return self.async_show_form(step_id="server")
class _NoOptionsFlow(OptionsFlow):
"""Options flow for the tools entry: it has no configurable options."""
async def async_step_init(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Abort immediately — the services entry exposes no options."""
return self.async_abort(reason="no_options")
class HaMcpServerOptionsFlow(OptionsFlow):
"""Options flow: configure the in-process MCP server (issue #1527)."""
async def async_step_init(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Show / apply the server options."""
if user_input is not None:
return self.async_create_entry(title="", data=self._normalize(user_input))
opts = self.config_entry.options
schema = vol.Schema(
{
vol.Required(
OPT_CHANNEL,
default=opts.get(OPT_CHANNEL, DEFAULT_CHANNEL),
): SelectSelector(
SelectSelectorConfig(
options=[CHANNEL_STABLE, CHANNEL_DEV],
translation_key="server_channel",
mode=SelectSelectorMode.DROPDOWN,
)
),
vol.Required(
OPT_AUTO_UPDATE,
default=bool(opts.get(OPT_AUTO_UPDATE, DEFAULT_AUTO_UPDATE)),
): bool,
vol.Required(
OPT_SERVER_PORT,
default=opts.get(OPT_SERVER_PORT, DEFAULT_SERVER_PORT),
): vol.All(vol.Coerce(int), vol.Range(min=1, max=65535)),
vol.Required(
OPT_BIND_HOST,
default=opts.get(OPT_BIND_HOST, DEFAULT_BIND_HOST),
): SelectSelector(
# Inline labels: hassfest forbids dots in translation
# keys, so the IP-valued options cannot use strings.json
# selector translations.
SelectSelectorConfig(
options=[
SelectOptionDict(
value=BIND_HOST_ALL,
label="Local network (default)",
),
SelectOptionDict(
value=BIND_HOST_LOOPBACK,
label="This machine only (loopback)",
),
],
mode=SelectSelectorMode.DROPDOWN,
)
),
vol.Required(
OPT_WEBHOOK_AUTH,
default=opts.get(OPT_WEBHOOK_AUTH, WEBHOOK_AUTH_NONE),
): SelectSelector(
SelectSelectorConfig(
options=[WEBHOOK_AUTH_NONE, WEBHOOK_AUTH_HA],
translation_key="server_webhook_auth",
mode=SelectSelectorMode.DROPDOWN,
)
),
vol.Optional(
OPT_PIP_SPEC,
# Pre-fill via suggested_value, NOT a schema default: a
# default equal to the saved value makes the field
# impossible to clear. HA's frontend drops an emptied
# optional field from the submitted payload, so voluptuous
# re-applies the default (the old override) and clearing
# never sticks. suggested_value pre-fills the same value but
# is not re-injected on an empty submit. (Applies to every
# optional text field below.) Only a genuinely saved
# override is suggested; the normalized "no override" state
# renders an EMPTY field — the help text says "Leave empty",
# and pre-filling DEFAULT_PIP_SPEC would show the STABLE dist
# name even on the dev channel.
description={"suggested_value": opts.get(OPT_PIP_SPEC, "")},
): str,
vol.Optional(
OPT_SERVER_URL,
description={
"suggested_value": opts.get(
OPT_SERVER_URL, DEFAULT_LOOPBACK_URL
)
},
): str,
vol.Required(
OPT_ENABLE_WEBHOOK,
default=bool(opts.get(OPT_ENABLE_WEBHOOK, True)),
): bool,
vol.Required(
OPT_ENABLE_STARTUP_NOTIFICATION,
default=bool(opts.get(OPT_ENABLE_STARTUP_NOTIFICATION, True)),
): bool,
vol.Required(
OPT_ENABLE_SIDEBAR_PANEL,
default=bool(opts.get(OPT_ENABLE_SIDEBAR_PANEL, True)),
): bool,
vol.Required(
OPT_ENABLE_LLM_API,
default=bool(opts.get(OPT_ENABLE_LLM_API, DEFAULT_ENABLE_LLM_API)),
): bool,
vol.Required(
OPT_LLM_API_EXPOSURE,
default=str(
opts.get(OPT_LLM_API_EXPOSURE, DEFAULT_LLM_API_EXPOSURE)
),
): SelectSelector(
SelectSelectorConfig(
options=[EXPOSURE_TOOL_SEARCH, EXPOSURE_FULL, EXPOSURE_BOTH],
translation_key="llm_api_exposure",
mode=SelectSelectorMode.DROPDOWN,
)
),
# suggested_value (not default) so these clear properly on an
# empty submit — see the OPT_PIP_SPEC note above.
vol.Optional(
OPT_EXTERNAL_URL,
description={"suggested_value": opts.get(OPT_EXTERNAL_URL, "")},
): str,
vol.Optional(
OPT_WEBHOOK_ID_OVERRIDE,
description={
"suggested_value": opts.get(OPT_WEBHOOK_ID_OVERRIDE, "")
},
): str,
vol.Optional(
OPT_SECRET_PATH_OVERRIDE,
description={
"suggested_value": opts.get(OPT_SECRET_PATH_OVERRIDE, "")
},
): str,
vol.Optional(
OPT_REGENERATE_SECRETS,
default=False,
): bool,
}
)
# The sidebar-panel sentence in the description is only truthful while
# the panel is registered; drop it (from the CURRENT stored options, not
# the unsaved form state) when the panel is off so the link cannot point
# at a route that 404s. The trailing space keeps the surrounding prose
# spaced correctly whether the sentence is present or empty.
panel_hint = (
"Open the [HA-MCP settings panel](/ha-mcp) for tool management and "
"server settings. "
if bool(opts.get(OPT_ENABLE_SIDEBAR_PANEL, True))
else ""
)
return self.async_show_form(
step_id="init",
data_schema=schema,
description_placeholders={
"versions": await self._versions_hint(),
"connect_url": self._connect_url_hint(),
"llm_api_docs_url": LLM_API_DOCS_URL,
"panel_hint": panel_hint,
},
)
@staticmethod
def _normalize(user_input: dict[str, Any]) -> dict[str, Any]:
"""Normalize the submitted options before they are persisted.
Collapses the pip-spec field to empty when it is empty or equals
``DEFAULT_PIP_SPEC`` (the unpinned ``ha-mcp`` distribution): the field is
pre-filled with the saved override or blank, but a user may also type the
default dist name, and persisting it verbatim would read as an
intentional override and disable the stable channel's automatic updates.
Empty means "no override" (track the selected channel); any other string
is a genuine override, stored as-is. Also strips the URL / secret
override fields, and drops a blank ``server_url`` so its default applies.
"""
cleaned = dict(user_input)
if cleaned.get(OPT_PIP_SPEC, "").strip() in ("", DEFAULT_PIP_SPEC):
cleaned[OPT_PIP_SPEC] = ""
for key in (
OPT_EXTERNAL_URL,
OPT_WEBHOOK_ID_OVERRIDE,
OPT_SECRET_PATH_OVERRIDE,
):
cleaned[key] = str(cleaned.get(key, "") or "").strip()
cleaned[OPT_EXTERNAL_URL] = cleaned[OPT_EXTERNAL_URL].rstrip("/")
# server_url gets no _normalize-forced empty like the fields above; strip
# it and drop it entirely when blank so a whitespace-only value can't be
# stored verbatim (it would bypass the consumer's empty -> loopback
# fallback and break the HA connection).
server_url = str(cleaned.get(OPT_SERVER_URL, "") or "").strip().rstrip("/")
if server_url:
cleaned[OPT_SERVER_URL] = server_url
else:
cleaned.pop(OPT_SERVER_URL, None)
return cleaned
async def _versions_hint(self) -> str:
"""Return a one-line component + server version summary for the form.
Reads the component version from the integration manifest and the
installed server version from the channel's distribution metadata.
Failure-proof like the connect-URL hint: any read error degrades to a
best-effort string ("unknown" / "not installed yet") rather than
breaking the options form.
"""
opts = self.config_entry.options
channel = str(opts.get(OPT_CHANNEL) or DEFAULT_CHANNEL)
component_version = "unknown"
hass = getattr(self, "hass", None)
if hass is not None:
try:
integration = await async_get_integration(hass, DOMAIN)
component_version = str(integration.version)
except Exception as err:
_LOGGER.debug(
"Could not read component version for the options hint: %s", err
)
try:
# importlib.metadata scans dist-info via os.listdir (blocking I/O),
# so run it on the executor rather than the event loop.
raw_version = (
await hass.async_add_executor_job(_installed_server_version)
if hass is not None
else _installed_server_version()
)
server_version = raw_version or "not installed yet"
except Exception as err:
_LOGGER.debug("Could not read server version for the options hint: %s", err)
server_version = "not installed yet"
return (
f"Component {component_version} - "
f"Server ha-mcp {server_version} ({channel} channel)"
)
def _connect_url_hint(self) -> str:
"""Return the connect URLs for the options form.
The Configure screen is admin-only, so it shows the real resolved
URLs (the start-up notification deliberately does not - it is visible
to every signed-in user). Falls back to a placeholder form when
resolution is unavailable.
"""
webhook_id = self.config_entry.data.get(DATA_WEBHOOK_ID)
secret_path = self.config_entry.data.get(DATA_SECRET_PATH)
if not webhook_id:
return (
"The connect URLs appear here (and in the Home Assistant log) "
"once the server has started."
)
webhook_enabled = bool(self.config_entry.options.get(OPT_ENABLE_WEBHOOK, True))
port = self.config_entry.options.get(OPT_SERVER_PORT, DEFAULT_SERVER_PORT)
hass = getattr(self, "hass", None)
if hass is not None:
try:
from .embedded_setup import build_connect_urls
urls = build_connect_urls(
hass, self.config_entry, webhook_enabled=webhook_enabled
)
if urls:
return "Connect URL(s):\n" + "\n".join(f"- {u}" for u in urls)
except Exception as err:
# The hint is auxiliary display data: a resolution bug must not
# take down the whole options form, but the degradation should
# be visible by default - hence warning, not debug.
_LOGGER.warning(
"Falling back to the placeholder connect-URL hint: %s", err
)
if not webhook_enabled:
# Local-only mode: the webhook endpoint is never registered, so
# a webhook URL here would 404. With loopback binding the builder
# resolves no URLs at all - state that instead of inventing one.
hint = "Remote access via webhook is disabled (local-only mode)."
if secret_path:
hint += (
f"\nDirect access from the Home Assistant machine: "
f"http://127.0.0.1:{port}{secret_path}"
)
return hint
external = str(self.config_entry.options.get(OPT_EXTERNAL_URL) or "").rstrip(
"/"
)
base = external or "<your-home-assistant-url>"
hint = f"Remote connect URL: {base}/api/webhook/{webhook_id}"
if secret_path:
hint += (
f"\nLocal/LAN (when bind host is 0.0.0.0): "
f"http://<home-assistant-ip>:{port}{secret_path}"
)
return hint
+451
View File
@@ -0,0 +1,451 @@
"""Constants for the HA-MCP custom component.
The integration serves two config-entry types under one domain
(:data:`DOMAIN`), discriminated by ``entry.data[CONF_ENTRY_TYPE]``:
* ``tools`` — the privileged file / YAML services (the original component).
Pre-existing entries carry no ``entry_type`` key, so a missing value is
treated as ``tools`` (no migration needed).
* ``server`` — the in-process ha-mcp FastMCP server (issue #1527), exposed
through a Home Assistant webhook.
The two halves keep their constants in separate blocks below; the ``server``
block was folded in from the former standalone ``ha_mcp_server`` integration.
"""
import re
from datetime import timedelta
DOMAIN = "ha_mcp_tools"
# Component version, kept in lockstep with ``manifest.json``'s ``version``.
# ``ha_mcp_tools/info`` reports this so the server can display/debug the running
# component build; ``TestManifestVersionParity`` pins the two together so a
# manifest bump that forgets this constant (or vice-versa) fails in CI. The
# capability negotiation — not this version — gates each WS command (see
# ``websocket_api.CAPABILITIES``).
COMPONENT_VERSION = "1.1.0"
# Config-entry discriminator (``entry.data[CONF_ENTRY_TYPE]``). A missing value
# means "tools" so the pre-existing services entry keeps working across the
# component update with no migration.
CONF_ENTRY_TYPE = "entry_type"
ENTRY_TYPE_TOOLS = "tools"
ENTRY_TYPE_SERVER = "server"
MIN_EMBEDDED_HOME_ASSISTANT_VERSION = "2026.6.0"
# Allowed directories for file operations (relative to config dir)
ALLOWED_READ_DIRS = ["www", "themes", "custom_templates", "dashboards"]
ALLOWED_WRITE_DIRS = ["www", "themes", "custom_templates", "dashboards"]
# NON-OVERRIDABLE deny floor for the user-configurable extra read/write
# directories (issue #1567). The custom allowlist is applied ON TOP of the
# built-in ALLOWED_*_DIRS, but a custom directory can NEVER grant access to
# these. The floor is re-checked before any allow decision on every read,
# write, list, and delete, so neither a stored entry nor an in-flight one can
# punch through it.
#
# .storage holds HA's auth database (refresh/access tokens), hashed passwords,
# and every integration's cleartext credentials (core.config_entries,
# application_credentials, cloud) — including this component's OWN caller
# token (.storage/ha_mcp_tools_auth). Letting a custom dir reach it would both
# leak secrets and hand out the key to this component's own auth gate.
DENY_PATH_SEGMENTS = frozenset({".storage"})
# secrets.yaml is reachable ONLY as the canonical config-root file, where the
# read handler masks its values. Any OTHER secrets.yaml surfaced via a custom
# dir would be returned UNMASKED (masking keys off the literal root path), so
# the floor blocks the basename everywhere except that one canonical location.
DENY_READ_BASENAMES = frozenset({"secrets.yaml"})
# HAOS sibling-volume mounts (issue #1586). These live OUTSIDE the config dir,
# so the config-relative custom-directory allowlist (issue #1567) cannot reach
# them — its normalizer rejects every absolute path. A user may instead add one
# of these fixed absolute roots — or a subdirectory of one — to the custom
# directory list; access is then enforced against the volume root exactly as a
# config-relative entry is enforced against the config dir (issue #1586).
#
# The component runs inside HA Core, so a volume is reachable only if the HA
# Core container actually mounts it (the standard HAOS/Supervised mounts are
# config/share/media/ssl/backup). An unmounted or non-existent root simply
# yields a "not found" at use time — adding it is harmless. As with the
# config-relative list, a configured volume grants BOTH read and write, and the
# non-overridable deny floor (.storage / secrets.yaml) still applies.
ALLOWED_VOLUME_ROOTS = ("/share", "/media", "/ssl", "/backup")
# Files allowed for managed YAML editing
ALLOWED_YAML_CONFIG_FILES = ["configuration.yaml"]
# Also allows <packages-folder>/*.yaml via pattern matching, where the folder is
# the one the user binds under ``homeassistant: packages:`` (default "packages",
# detected at runtime — see _detect_package_dirs), plus themes/*.yaml.
# Top-level YAML keys allowed for editing in any allowed file
# (configuration.yaml or packages/*.yaml).
# ONLY keys that have no UI/API alternative belong here.
# Keys manageable via ha_config_set_helper (input_*, counter, timer, schedule)
# are intentionally excluded. automation/script/scene live in
# PACKAGES_ONLY_YAML_KEYS below — they have storage-mode equivalents
# (ha_config_set_automation/script/scene) but are still exposed in
# packages/*.yaml for the YAML-packages workflow.
ALLOWED_YAML_KEYS = frozenset(
{
"template",
"sensor",
"binary_sensor",
"command_line",
"rest",
"knx",
"mqtt",
"shell_command",
"switch",
"light",
"fan",
"cover",
"climate",
"notify",
"group",
"utility_meter",
# recorder is YAML-only (no UI or storage-mode helper): purge_keep_days,
# include/exclude, commit_interval. Its surface is smaller than keys
# already here — it only controls what HA records and for how long, with
# no code-execution path like command_line/shell_command/rest (#1852).
"recorder",
}
)
# Top-level YAML keys allowed ONLY inside packages/*.yaml files, never in
# configuration.yaml. Storage-mode UI/API equivalents already exist
# (ha_config_set_automation/script/scene), so these are exposed here only
# for the YAML-packages workflow used by git-managed configs — where users
# expect to keep automations/scripts/scenes alongside templates and other
# YAML-defined items. Writes to configuration.yaml for these keys remain
# rejected so storage-mode and YAML-mode collections don't collide.
PACKAGES_ONLY_YAML_KEYS = frozenset(
{
"automation",
"script",
"scene",
}
)
# Post-edit action required for each YAML key.
# template, mqtt, group, automation, script, and scene have first-party
# reload services in HA core. All others require a full HA restart.
# ``TestPostActionTableContract`` pins the in-repo shape; the HA-core
# side of the contract is a write-time snapshot, not a continuous check.
YAML_KEY_POST_ACTIONS: dict[str, dict[str, str]] = {
"template": {
"post_action": "reload_available",
"reload_service": "homeassistant.reload_custom_templates",
},
"mqtt": {
"post_action": "reload_available",
"reload_service": "mqtt.reload",
},
"group": {
"post_action": "reload_available",
"reload_service": "group.reload",
},
"automation": {
"post_action": "reload_available",
"reload_service": "automation.reload",
},
"script": {
"post_action": "reload_available",
"reload_service": "script.reload",
},
"scene": {
"post_action": "reload_available",
"reload_service": "scene.reload",
},
}
# Default for keys not in YAML_KEY_POST_ACTIONS:
YAML_KEY_DEFAULT_POST_ACTION = {"post_action": "restart_required"}
# YAML-mode dashboard url_path validation (issue #1034).
# Pattern: lowercase letters/digits, hyphen-separated, must contain at least
# one hyphen (HA's lovelace dashboard rule). No leading/trailing/double hyphens.
DASHBOARD_URL_PATH_PATTERN = re.compile(r"[a-z0-9]+(?:-[a-z0-9]+)+")
# url_paths reserved by HA core dashboards/routes — must not be registered as
# YAML-mode dashboards or they will shadow / collide with built-ins.
RESERVED_DASHBOARD_URL_PATHS = frozenset(
{
"lovelace",
"overview",
"map",
"logbook",
"history",
"energy",
"developer-tools",
"config",
"profile",
"media-browser",
"todo",
"calendar",
}
)
# ---------------------------------------------------------------------------
# HA-MCP Server entry (issue #1527)
#
# Folded in from the former standalone ``ha_mcp_server`` integration. The
# "server" config-entry type runs the full ha-mcp FastMCP server in-process
# inside Home Assistant (a dedicated thread with its own asyncio loop) and
# exposes it remotely through a Home Assistant webhook, exactly like the
# webhook-proxy add-on. Creating the entry starts the server; disabling or
# removing the entry stops it. Everything below is namespaced under the shared
# ``DOMAIN`` (distinct hass.data sub-keys, distinct entry unique_id).
# ---------------------------------------------------------------------------
# PyPI distribution names. Stable ships as ``ha-mcp``; the dev channel ships as
# ``ha-mcp-dev`` — published on every master push. BOTH are installed unpinned,
# so every install / reload resolves the newest build of the selected channel
# (the component auto-updates the server rather than pinning a lockstep version
# — see ``UPDATE_CHECK_INTERVAL`` and
# ``EmbeddedServerManager._async_ensure_package``). Both wheels contain the
# *same* ``ha_mcp`` import package (publish-dev.yml only renames the
# distribution), so only one may be installed at a time — see
# EmbeddedServerManager's channel-switch handling.
DIST_NAME_STABLE = "ha-mcp"
DIST_NAME_DEV = "ha-mcp-dev"
# Default pip requirement for the stable channel: the unpinned ``ha-mcp``
# distribution, so each install resolves the newest stable release. The options
# flow's advanced "pip requirement" field overrides this with any pip spec
# (e.g. a version pin or a GitHub tarball URL) for pre-release testing — an
# explicit override also disables automatic updates.
DEFAULT_PIP_SPEC = DIST_NAME_STABLE
DEV_PIP_SPEC = DIST_NAME_DEV
# Release channels (options-flow selector). ``stable`` installs the unpinned
# ``ha-mcp`` and ``dev`` installs the unpinned ``ha-mcp-dev``; both refresh to
# the newest build of that channel on every entry reload / HA restart, and the
# periodic auto-update check reloads the entry when PyPI publishes a newer one.
# An explicit OPT_PIP_SPEC override wins over both and disables auto-update.
CHANNEL_STABLE = "stable"
CHANNEL_DEV = "dev"
DEFAULT_CHANNEL = CHANNEL_STABLE
def dist_for_channel(channel: str) -> str:
"""Map a release channel to its PyPI distribution name.
The channel <-> distribution correspondence is used by the version
coordinator, the auto-update notification, and the server manager's pip
resolution — one shared mapping so a future third channel cannot be added
to some sites and missed in others (review finding on #1760).
"""
return DIST_NAME_DEV if channel == CHANNEL_DEV else DIST_NAME_STABLE
def channel_for_dist(dist: str) -> str:
"""Inverse of :func:`dist_for_channel`."""
return CHANNEL_DEV if dist == DIST_NAME_DEV else CHANNEL_STABLE
# Interval of the ServerVersionCoordinator's PyPI poll (coordinator.py). The
# poll itself ALWAYS runs — it feeds the `update` platform entity, which must
# stay populated even when automatic updates are off (issue #1760). Whether a
# newer build actually triggers a reload/reinstall is decided separately, per
# refresh, in embedded_setup.async_maybe_auto_update (gated on OPT_AUTO_UPDATE
# and on no pip-spec override). Only an explicit pip-spec override skips the
# PyPI fetch — comparing PyPI-latest against an arbitrary pip spec is
# meaningless.
UPDATE_CHECK_INTERVAL = timedelta(hours=6)
# PyPI JSON API for the latest published version of a distribution. ``{dist}``
# is DIST_NAME_STABLE or DIST_NAME_DEV depending on the selected channel.
PYPI_JSON_URL = "https://pypi.org/pypi/{dist}/json"
# The component manifest as it existed at a server release's git tag. Its
# ``version`` is the component version that SHIPPED with that server build, so
# a value newer than the running component means the release changed the
# component too — the pre-install auto-update gate in embedded_setup holds the
# server update until HACS delivers the component (issues #1783/#1785).
# Tag-timing caveat: stable ``vX.Y.Z`` tags exist before the PyPI publish
# (semantic-release pushes the tag first), but a dev ``vX.Y.Z.devN`` tag is
# only created when its draft GitHub release is published — AFTER the binary
# builds, minutes after PyPI already has the version. During that dev window
# this URL 404s and the gate deliberately fails open (the registry's
# skip-on-failure is the backstop on that channel).
COMPONENT_MANIFEST_AT_TAG_URL = (
"https://raw.githubusercontent.com/homeassistant-ai/ha-mcp/"
"v{version}/custom_components/ha_mcp_tools/manifest.json"
)
# Options-flow keys (stored in entry.options).
OPT_CHANNEL = "channel"
# Automatic server-version updates toggle (default on). When on, the channel is
# unpinned and auto-updates (force-install on reload/restart + a reload when the
# periodic check sees a newer build). When off, the server stays on the version
# currently installed: _resolve_pip_spec pins the channel's dist to that version
# — but the periodic PyPI check KEEPS running so the update entity still shows
# newer builds; its Install button is the manual path (issue #1760). Governs the
# ha-mcp server package only — component updates still come through HACS. An
# explicit OPT_PIP_SPEC override wins over both and skips the check entirely.
OPT_AUTO_UPDATE = "auto_update"
DEFAULT_AUTO_UPDATE = True
OPT_SERVER_PORT = "server_port"
OPT_BIND_HOST = "bind_host"
OPT_WEBHOOK_AUTH = "webhook_auth"
OPT_PIP_SPEC = "pip_spec"
OPT_SERVER_URL = "server_url"
# Connect-URL surface + secret management (owner request, parity with the
# webhook-proxy app's external-URL option and the add-on's secret-path
# override). All optional; empty string = automatic/keep-current.
OPT_EXTERNAL_URL = "external_url"
OPT_WEBHOOK_ID_OVERRIDE = "webhook_id_override"
OPT_SECRET_PATH_OVERRIDE = "secret_path_override"
OPT_REGENERATE_SECRETS = "regenerate_secrets"
# Local-only mode (owner request): when False, the HA webhook is never
# registered, so nothing - including Nabu Casa remote UI - can reach the
# server through Home Assistant; only the direct server port (+ the
# admin-only sidebar panel, which proxies over loopback) remains.
OPT_ENABLE_WEBHOOK = "enable_webhook"
# Conversation-agent LLM API (#1745): when False, the toolset is not
# registered as a Home Assistant LLM API, so it never appears in any
# conversation agent's "Control Home Assistant" selector. On by default —
# registering the API only makes it selectable; nothing is exposed until a
# user picks it on an agent.
OPT_ENABLE_LLM_API = "enable_llm_api"
DEFAULT_ENABLE_LLM_API = True
# Which exposure shape(s) the LLM API offers to conversation agents:
# ``tool_search`` (default) registers a compact API — pinned tools plus
# search/execute meta-tools — the shape context-limited models need; ``full``
# registers the whole exposed catalog as one API; ``both`` registers the two
# side by side so the choice is made per agent in HA's own selector.
OPT_LLM_API_EXPOSURE = "llm_api_exposure"
EXPOSURE_TOOL_SEARCH = "tool_search"
EXPOSURE_FULL = "full"
EXPOSURE_BOTH = "both"
DEFAULT_LLM_API_EXPOSURE = EXPOSURE_TOOL_SEARCH
# When False, the persistent notification created on every server bring-up is
# suppressed; the connect URLs still reach the admin-only Home Assistant log.
OPT_ENABLE_STARTUP_NOTIFICATION = "enable_startup_notification"
# When False, the admin-only "HA-MCP" sidebar settings panel is not registered;
# the server's options stay reachable on the entry's Configure screen.
OPT_ENABLE_SIDEBAR_PANEL = "enable_sidebar_panel"
# entry.data keys (persisted ids + secrets; entry.data is fine for secrets).
DATA_WEBHOOK_ID = "webhook_id"
DATA_SECRET_PATH = "secret_path"
DATA_SERVER_USER_ID = "server_user_id"
DATA_REFRESH_TOKEN_ID = "refresh_token_id"
DATA_ACCESS_TOKEN = "access_token"
# Last pip spec that was successfully installed. Lets a changed spec (the
# pre-release test channel) force an actual reinstall on the next start instead
# of hitting the requirements manager's is-installed shortcut.
DATA_LAST_PIP_SPEC = "last_pip_spec"
# One-shot marker set by the update entity's Install button (issue #1760):
# with auto-update off, EmbeddedServerManager._resolve_pip_spec pins the
# channel to the CURRENTLY installed version, so a bare reload would just
# reinstall the same build. This pins the next install to a specific version
# regardless of auto_update; embedded_server clears it when it CONSUMES it
# (before the install attempt) — one marker buys exactly one attempt, so a
# failing pinned version can never re-pin later reloads (review finding).
DATA_PENDING_INSTALL_VERSION = "pending_install_version"
# hass.data[DOMAIN] sub-keys for the server runtime. Distinct from the tools
# entry's sub-keys ("caller_token" / "allowed_paths") so both entry types can
# share hass.data[DOMAIN] without collision.
DATA_MANAGER = "manager"
DATA_WEBHOOK = "webhook"
DATA_BRINGUP_TASK = "bringup_task"
# Snapshot of entry.options taken at setup 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 trigger a self-reload.
DATA_LAST_OPTIONS = "last_options"
# The ServerVersionCoordinator instance backing the `update` platform entity
# (issue #1760) — stored so the platform's async_setup_entry can retrieve it.
DATA_UPDATE_COORDINATOR = "update_coordinator"
# Set by async_maybe_auto_update right before it reloads the entry for an
# automatic update ({"old": <version>}): the "server updated" notification must
# only fire once the reloaded entry's bring-up actually installed and started
# the new build — the reload call returns as soon as entry SETUP finishes,
# while the pip install still runs in the background and can fail (review
# finding on #1760). Bring-up pops it: notification on success, silent drop on
# failure (the package/start repair issues cover that path).
DATA_PENDING_UPDATE_NOTIFY = "pending_update_notify"
# Unregister callback for the conversation-agent LLM API (#1745), stored by
# the bring-up success path and invoked (idempotently) by teardown.
DATA_LLM_API_UNSUB = "llm_api_unsub"
# Webhook auth modes (mirrors the webhook-proxy add-on's default posture).
WEBHOOK_AUTH_NONE = "none" # secret webhook URL is the shared secret (default)
WEBHOOK_AUTH_HA = "ha_auth" # HA-native bearer (HA core is the OAuth AS)
# Default bind host + port. 9584 (not the add-on's 9583) so this in-process
# server and an add-on install can coexist on the same box.
DEFAULT_SERVER_PORT = 9584
# LAN-reachable by default - parity with the add-on, whose port has always
# been directly reachable with the secret path as the credential. Loopback
# is the optional hardening choice, not the default (owner decision).
DEFAULT_BIND_HOST = "0.0.0.0"
BIND_HOST_ALL = "0.0.0.0"
BIND_HOST_LOOPBACK = "127.0.0.1"
# Loopback base URL the server uses to reach HA core (REST + WS).
DEFAULT_LOOPBACK_URL = "http://127.0.0.1:8123"
# Persistent data dir for the in-process server, under the HA config dir so it
# survives restarts and is isolated from an add-on's /data. Generic ".ha_mcp"
# to match the merged integration's naming (unreleased server entry, so no
# migration from the former ".ha_mcp_server").
SERVER_CONFIG_SUBDIR = ".ha_mcp"
# Client name recorded on the provisioned long-lived access token, and the name
# of the local admin user the server logs in as. Stable so a reused token is
# recognizable in Settings -> People -> <user> -> tokens. "HA-MCP" phrasing (not
# "Home Assistant MCP Server") to avoid confusion with HA's official MCP Server
# integration.
SERVER_TOKEN_CLIENT_NAME = "HA-MCP Server"
SERVER_USER_NAME = "HA-MCP Server"
# RFC 8414 / RFC 9728 discovery documents for ha_auth mode are served under this
# namespace (mirrors the webhook-proxy add-on's /api/mcp_proxy/oauth base).
OAUTH_BASE = "/api/ha_mcp_tools/oauth"
# HACS "add repository" deep link for the custom component. Shared learn_more_url
# for every repair issue that ends with "install/reinstall the component via
# HACS" (the component-outdated issue and the legacy-HACS-source issue below).
HACS_COMPONENT_URL = (
"https://my.home-assistant.io/redirect/hacs_repository/"
"?owner=homeassistant-ai&repository=ha-mcp-integration&category=integration"
)
# Usage guide for the conversation-agent LLM API option (#1745). Injected into
# the options form as a description placeholder — hassfest forbids literal
# URLs inside strings.json.
LLM_API_DOCS_URL = (
"https://github.com/homeassistant-ai/ha-mcp/blob/master/docs/"
"in-process-server.md"
"#chat-with-the-toolset-from-home-assistant-conversation-agents--voice"
)
# Repair-issue ids surfaced when server bring-up fails.
ISSUE_PACKAGE_FAILED = "server_package_install_failed"
ISSUE_START_FAILED = "server_start_failed"
# Repair issue surfaced when the installed ha-mcp server requires a newer
# custom component than the one running. The server package updates
# independently of the HACS component, so the running component can lag what
# the server expects; this points the user at the HACS component update
# (non-blocking).
ISSUE_COMPONENT_OUTDATED = "component_outdated"
# Repair issue surfaced while an automatic server update is HELD because the
# newer server release also shipped a newer custom component than the one
# running (issues #1783/#1785): installing that server under the old component
# is the combination that broke starts. Held is loud (this issue + a warning
# log every check) and escapable — applying the HACS component update (which
# takes an HA restart) unblocks the next check, and the update entity's
# Install button bypasses the hold entirely.
ISSUE_UPDATE_HELD = "server_update_held"
# Repair issue surfaced when HACS is tracking the MAIN ha-mcp server repo for
# this component (the pre-mirror install path — issue #1760). That install
# keeps working (HACS downloads the repo snapshot at the release tag, which
# contains the component), but HACS shows the SERVER's version numbers and
# release notes, not the component's own; HACS has no repository-migration
# mechanism, so this only self-resolves if the user re-adds the dedicated
# mirror (homeassistant-ai/ha-mcp-integration).
ISSUE_LEGACY_HACS_SOURCE = "legacy_hacs_source"
@@ -0,0 +1,112 @@
"""Poll the server package's installed vs. latest PyPI version (issue #1760).
Backs the ``update`` platform entity (:mod:`update`) and the automatic-update
decision (:func:`embedded_setup.async_maybe_auto_update`). Runs on
:data:`UPDATE_CHECK_INTERVAL` regardless of the ``auto_update`` option — unlike
the check this replaces, visibility must not depend on auto-update being on
(issue #1760: with auto-update off, users previously got zero signal that a
server update existed). Only the resulting *reload* is gated on ``auto_update``,
in :mod:`embedded_setup`.
"""
from __future__ import annotations
import asyncio
import logging
from dataclasses import dataclass
from typing import TYPE_CHECKING
from aiohttp import ClientError
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
from .const import (
DEFAULT_CHANNEL,
DEFAULT_PIP_SPEC,
DOMAIN,
OPT_CHANNEL,
OPT_PIP_SPEC,
PYPI_JSON_URL,
UPDATE_CHECK_INTERVAL,
dist_for_channel,
)
from .embedded_server import _installed_dist_version
if TYPE_CHECKING:
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
_LOGGER = logging.getLogger(__name__)
# Per-request timeout for the PyPI version-check fetch - short so a slow or
# wedged PyPI never ties up the coordinator; a miss just retries next interval
# (moved here from the old embedded_setup.async_check_for_update).
_PYPI_TIMEOUT_SECONDS = 30
@dataclass(frozen=True)
class ServerVersionInfo:
"""Installed vs. latest server-package version for one config entry."""
installed: str | None
latest: str | None
dist: str
class ServerVersionCoordinator(DataUpdateCoordinator[ServerVersionInfo]):
"""Poll the installed + PyPI-latest version of the in-process server package.
Deliberately NOT scoped to the ``auto_update`` option: the update entity
must stay populated and the periodic check must keep running even when the
user has automatic updates turned off - that visibility is the point of
issue #1760. ``embedded_entry`` schedules this coordinator's listener to
decide whether to actually reload.
"""
def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None:
"""Bind to the config entry and schedule on UPDATE_CHECK_INTERVAL."""
self._entry = entry
super().__init__(
hass,
_LOGGER,
config_entry=entry,
name=f"{DOMAIN} server version",
update_interval=UPDATE_CHECK_INTERVAL,
)
async def _async_update_data(self) -> ServerVersionInfo:
"""Return the installed + latest version for the configured channel.
Never raises ``UpdateFailed`` for an expected PyPI transient - the
entity must stay available (showing the installed version) even when
PyPI is unreachable; :meth:`_async_fetch_latest`'s own narrow except
clause is the only one expected to fire in normal operation.
"""
options = self._entry.options
channel = str(options.get(OPT_CHANNEL) or DEFAULT_CHANNEL)
dist = dist_for_channel(channel)
installed = await self.hass.async_add_executor_job(
_installed_dist_version, dist
)
override = str(options.get(OPT_PIP_SPEC) or "").strip()
if override and override != DEFAULT_PIP_SPEC:
# An explicit pip-spec override (a version pin, a tarball URL)
# makes a PyPI-latest comparison meaningless - skip the fetch.
return ServerVersionInfo(installed=installed, latest=None, dist=dist)
latest = await self._async_fetch_latest(dist)
return ServerVersionInfo(installed=installed, latest=latest, dist=dist)
async def _async_fetch_latest(self, dist: str) -> str | None:
"""Return the newest PyPI version for ``dist``, or None on any failure."""
try:
session = async_get_clientsession(self.hass)
async with asyncio.timeout(_PYPI_TIMEOUT_SECONDS):
async with session.get(PYPI_JSON_URL.format(dist=dist)) as resp:
resp.raise_for_status()
payload = await resp.json()
return str(payload["info"]["version"])
except (ClientError, TimeoutError, KeyError, ValueError) as err:
_LOGGER.debug("HA-MCP server version check failed for %s: %s", dist, err)
return None
@@ -0,0 +1,231 @@
"""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_SECRET_PATH,
DATA_UPDATE_COORDINATOR,
DATA_WEBHOOK_ID,
DOMAIN,
OPT_ENABLE_SIDEBAR_PANEL,
OPT_REGENERATE_SECRETS,
OPT_SECRET_PATH_OVERRIDE,
OPT_WEBHOOK_ID_OVERRIDE,
)
# 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)
# 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 _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.
"""
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] = ""
hass.config_entries.async_update_entry(entry, data=data, options=options)
return
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 changed:
hass.config_entries.async_update_entry(entry, data=data)
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,713 @@
"""Bring the in-process ha-mcp server up and down for the config entry (#1527).
Orchestration between :mod:`embedded_server` (the server thread + token
provisioning) and :mod:`mcp_webhook` (the ingress webhook): the bring-up sequence,
repair issues on failure, connect-URL surfacing, and teardown. Kept out of
``__init__.py`` so the entry-point wiring stays thin and this logic is
independently testable.
Every failure here is contained: a failure files a repair issue and returns
rather than propagating out of the background bring-up task, so the rest of Home
Assistant keeps running even when the server can't be installed or started.
"""
from __future__ import annotations
import asyncio
import logging
from contextlib import suppress
from typing import TYPE_CHECKING
from urllib.parse import urlparse
from aiohttp import ClientError
from awesomeversion import AwesomeVersion, AwesomeVersionException
from homeassistant.components import persistent_notification
from homeassistant.core import HomeAssistant
from homeassistant.helpers import issue_registry as ir
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.loader import async_get_integration
from .const import (
BIND_HOST_ALL,
CHANNEL_DEV,
COMPONENT_MANIFEST_AT_TAG_URL,
DATA_BRINGUP_TASK,
DATA_MANAGER,
DATA_PENDING_UPDATE_NOTIFY,
DATA_SECRET_PATH,
DATA_UPDATE_COORDINATOR,
DATA_WEBHOOK_ID,
DEFAULT_AUTO_UPDATE,
DEFAULT_BIND_HOST,
DEFAULT_ENABLE_LLM_API,
DEFAULT_PIP_SPEC,
DEFAULT_SERVER_PORT,
DOMAIN,
HACS_COMPONENT_URL,
ISSUE_COMPONENT_OUTDATED,
ISSUE_PACKAGE_FAILED,
ISSUE_START_FAILED,
ISSUE_UPDATE_HELD,
OPT_AUTO_UPDATE,
OPT_BIND_HOST,
OPT_ENABLE_LLM_API,
OPT_ENABLE_SIDEBAR_PANEL,
OPT_ENABLE_STARTUP_NOTIFICATION,
OPT_ENABLE_WEBHOOK,
OPT_EXTERNAL_URL,
OPT_PIP_SPEC,
OPT_SERVER_PORT,
OPT_WEBHOOK_AUTH,
WEBHOOK_AUTH_NONE,
channel_for_dist,
)
from .embedded_server import EmbeddedServerError, EmbeddedServerManager
from .llm_api import async_register_llm_api, async_unregister_llm_api
from .mcp_webhook import async_register_webhook, async_unregister_webhook
if TYPE_CHECKING:
from homeassistant.config_entries import ConfigEntry
from .coordinator import ServerVersionInfo
_LOGGER = logging.getLogger(__name__)
_NOTIFICATION_ID = "ha_mcp_tools_server_connect"
_UPDATE_NOTIFICATION_ID = "ha_mcp_tools_server_updated"
# ISSUE_UPDATE_HELD is cleared at bring-up start too: any reload that reaches
# bring-up either bypassed the hold deliberately (the update entity's Install
# button) or made it moot; if the hold still applies, the coordinator refresh
# that follows setup re-files it within moments.
_ISSUE_IDS = (ISSUE_PACKAGE_FAILED, ISSUE_START_FAILED, ISSUE_UPDATE_HELD)
# Per-request timeout for the component-manifest fetch behind the auto-update
# gate — mirrors the coordinator's PyPI fetch budget; a miss fails open.
_MANIFEST_FETCH_TIMEOUT_SECONDS = 30
async def async_bring_up_server(hass: HomeAssistant, entry: ConfigEntry) -> None:
"""Install, start, and expose the server. Runs as a background task.
On failure files the matching repair issue and returns — Home Assistant stays
up. On cancellation (the entry is being unloaded mid-bring-up) tears down any
partial state and re-raises so the task ends cancelled. The secret webhook id
and secret path must already exist in ``entry.data`` (the entry setup writes
them before scheduling this task).
"""
_clear_issues(hass)
manager = EmbeddedServerManager(hass, entry)
hass.data.setdefault(DOMAIN, {})[DATA_MANAGER] = manager
try:
await manager.async_start()
# The package is installed and importable now: verify the running
# component satisfies the server's MIN_COMPONENT_VERSION and file/clear
# the component-outdated repair issue. Advisory only — it never blocks
# the (already started) server.
await _async_check_component_compat(hass, entry)
auth_mode = str(entry.options.get(OPT_WEBHOOK_AUTH, WEBHOOK_AUTH_NONE))
secret_path = str(entry.data[DATA_SECRET_PATH])
webhook_enabled = bool(entry.options.get(OPT_ENABLE_WEBHOOK, True))
# Always set up the loopback forwarding config — the sidebar settings
# panel proxies through it (#1803); the option gates only the public
# webhook endpoint.
await async_register_webhook(
hass,
entry,
port=manager.port,
secret_path=secret_path,
auth_mode=auth_mode,
register_endpoint=webhook_enabled,
)
if not webhook_enabled:
_LOGGER.info(
"Webhook access disabled by option - the server is local-only "
"(direct port + sidebar panel)"
)
_surface_connect_urls(hass, entry, auth_mode, webhook_enabled=webhook_enabled)
# Conversation-agent LLM API (#1745), gated on its option (default on).
# Advisory: registration failures are contained inside (logged, feature
# absent) — the running server must never be taken down by them.
if bool(entry.options.get(OPT_ENABLE_LLM_API, DEFAULT_ENABLE_LLM_API)):
await async_register_llm_api(
hass, entry, port=manager.port, secret_path=secret_path
)
else:
_LOGGER.info(
"Conversation-agent LLM API disabled by option - the toolset "
"will not be offered to Home Assistant conversation agents"
)
await _async_finish_update_cycle(hass)
except asyncio.CancelledError:
# Unloaded mid-bring-up: undo whatever partial state exists, then let the
# cancellation propagate so the task ends cancelled. The pending
# update-notification marker (if any) deliberately survives — it
# belongs to a bring-up that has not run yet, not to this one.
await async_teardown_server(hass)
raise
except EmbeddedServerError as err:
_LOGGER.error("HA-MCP in-process server failed to start: %s", err)
# suppress: filing the repair issue must be UNCONDITIONAL (review
# finding) - a raising teardown would otherwise leave the entry
# looking healthy with the failure visible only in the log.
with suppress(Exception):
await async_teardown_server(hass)
_create_issue(hass, err.kind, str(err))
# The install did not land: never fire the "updated" notification for
# it — the repair issue above is the user-facing signal.
_drop_pending_update_notify(hass)
except Exception as err:
_LOGGER.exception("HA-MCP in-process server: bring-up failed")
with suppress(Exception):
await async_teardown_server(hass)
_create_issue(hass, "start", str(err))
_drop_pending_update_notify(hass)
async def async_teardown_server(hass: HomeAssistant) -> None:
"""Unregister the LLM API + webhook and stop the server thread (reload-safe,
idempotent).
Does NOT revoke the provisioned token — a reload must keep it. The ha_auth
discovery views stay bound (aiohttp can't unregister them until HA restarts);
they 404 while the entry is not live.
"""
async_unregister_llm_api(hass)
await async_unregister_webhook(hass)
manager = hass.data.get(DOMAIN, {}).pop(DATA_MANAGER, None)
if isinstance(manager, EmbeddedServerManager):
await manager.async_stop()
async def async_revoke_credentials_on_remove(
hass: HomeAssistant, entry: ConfigEntry
) -> None:
"""Revoke the provisioned credentials when the config entry is removed."""
await EmbeddedServerManager(hass, entry).async_revoke_credentials()
_clear_issues(hass)
ir.async_delete_issue(hass, DOMAIN, ISSUE_COMPONENT_OUTDATED)
def build_connect_urls(
hass: HomeAssistant,
entry: ConfigEntry,
*,
webhook_enabled: bool = True,
) -> list[str]:
"""Resolve the entry's connect URLs (webhook forms first, then direct).
Shared by the admin-only surfaces that show real URLs: the Home Assistant
log on start-up and the entry's Configure screen (the notification
deliberately carries none - it is visible to every signed-in user). Each
source is best-effort: a URL that cannot be resolved is omitted.
"""
from homeassistant.helpers.network import NoURLAvailableError, get_url
webhook_id = entry.data.get(DATA_WEBHOOK_ID)
urls: list[str] = []
external = str(entry.options.get(OPT_EXTERNAL_URL) or "").rstrip("/")
if not webhook_enabled:
# Local-only mode: no webhook exists, so no webhook URLs to surface.
external = ""
webhook_id = None
if external:
# Owner-requested parity with the webhook-proxy app: a configured
# external URL leads the list (any reverse proxy, not just Nabu Casa).
urls.append(f"{external}/api/webhook/{webhook_id}")
# Nabu Casa remote URL (only when the cloud integration is set up + logged in).
try:
from homeassistant.components.cloud import (
CloudNotAvailable,
async_remote_ui_url,
)
try:
if webhook_id:
cloud_base = async_remote_ui_url(hass)
urls.append(f"{cloud_base}/api/webhook/{webhook_id}")
except CloudNotAvailable:
pass # Cloud not logged in / remote UI off - no remote URL to show.
except ImportError:
pass # Cloud integration not installed (e.g. HA Core) - local URL only.
local_host: str | None = None
try:
local_base = get_url(hass, allow_external=False, prefer_external=False)
local_host = urlparse(local_base).hostname
if webhook_id:
urls.append(f"{local_base}/api/webhook/{webhook_id}")
except NoURLAvailableError:
pass # No internal/local URL configured - fall through to the hint form.
if not urls and webhook_id:
urls.append(f"/api/webhook/{webhook_id} (prefix with your Home Assistant URL)")
port = int(entry.options.get(OPT_SERVER_PORT, DEFAULT_SERVER_PORT))
bind_host = str(entry.options.get(OPT_BIND_HOST, DEFAULT_BIND_HOST))
secret_path = entry.data.get(DATA_SECRET_PATH)
if bind_host == BIND_HOST_ALL and secret_path:
# Direct-access URL: admin-gated surfaces only (log + Configure screen).
# Guarded on the secret path so a missing one omits the line instead of
# rendering a valid-looking URL without its credential segment.
urls.append(
f"http://{local_host or '<home-assistant-ip>'}:{port}{secret_path}"
" (direct access)"
)
return urls
def _surface_connect_urls(
hass: HomeAssistant,
entry: ConfigEntry,
auth_mode: str,
*,
webhook_enabled: bool = True,
) -> None:
"""Log the connect URLs and (re)create a persistent notification."""
urls = build_connect_urls(hass, entry, webhook_enabled=webhook_enabled)
auth_note = (
"Webhook access is disabled (local-only mode)."
if not webhook_enabled
else "The webhook URL is the shared secret (no bearer required)."
if auth_mode == WEBHOOK_AUTH_NONE
else "Clients authenticate with your Home Assistant account (ha_auth)."
)
url_lines = "\n".join(f"- {url}" for url in urls)
_LOGGER.info(
"HA-MCP in-process server is running. Connect URL(s):\n%s\n%s",
url_lines,
auth_note,
)
if not bool(entry.options.get(OPT_ENABLE_STARTUP_NOTIFICATION, True)):
# Notification suppressed by option: clear any notification created
# before the toggle was turned off, then skip creating a fresh one. The
# connect URLs still reached the admin-only log above.
persistent_notification.async_dismiss(hass, _NOTIFICATION_ID)
return
# The sidebar-panel line is included only while the panel is registered:
# with the panel option off the /ha-mcp route does not exist and the link
# would 404.
panel_line = (
"Manage it from the [HA-MCP settings panel](/ha-mcp) in the sidebar.\n\n"
if bool(entry.options.get(OPT_ENABLE_SIDEBAR_PANEL, True))
else ""
)
# SECURITY (review finding): persistent notifications are visible to EVERY
# authenticated Home Assistant user - core's persistent_notification/get
# and /subscribe carry no admin gate. In the default posture the connect
# URL IS an admin-equivalent credential, so the notification deliberately
# carries NO secrets: it points at the admin-only surfaces (the sidebar
# panel and the entry's Configure screen). The URLs above still go to the
# log at INFO, which only admin-gated surfaces expose - the same posture
# as the add-on printing its URL to the admin-only add-on log.
message = (
"The HA-MCP Server is now running inside Home Assistant.\n\n"
f"{panel_line}"
"The connect URL is shown on the entry's Configure screen "
"(Settings - Devices & Services - HA-MCP Custom Component - "
"HA-MCP Server - Configure) and in the Home Assistant log - both "
"administrator-only, because the URL is the credential.\n\n"
f"{auth_note}\n"
)
persistent_notification.async_create(
hass,
message,
title="HA-MCP Server",
notification_id=_NOTIFICATION_ID,
)
_ISSUE_BY_KIND = {
"package": ISSUE_PACKAGE_FAILED,
"start": ISSUE_START_FAILED,
}
def _create_issue(hass: HomeAssistant, kind: str, detail: str) -> None:
"""File the repair issue matching the failure ``kind`` (package / start).
Exhaustive lookup on purpose: an unknown kind is a coding error and must
raise here rather than silently filing the wrong user-facing repair issue.
"""
issue_id = _ISSUE_BY_KIND[kind]
ir.async_create_issue(
hass,
DOMAIN,
issue_id,
is_fixable=False,
severity=ir.IssueSeverity.ERROR,
translation_key=issue_id,
translation_placeholders={"detail": detail},
)
def _clear_issues(hass: HomeAssistant) -> None:
"""Clear any previously-filed server-bring-up repair issues."""
for issue_id in _ISSUE_IDS:
ir.async_delete_issue(hass, DOMAIN, issue_id)
# ---------------------------------------------------------------------------
# Automatic server-version updates (channel auto-update)
# ---------------------------------------------------------------------------
async def async_maybe_auto_update(
hass: HomeAssistant, entry: ConfigEntry, info: ServerVersionInfo | None
) -> None:
"""Reload the entry when ``info`` shows a newer build AND auto-update is on.
Called from the :class:`~.coordinator.ServerVersionCoordinator` listener
registered by :mod:`embedded_entry` on every refresh (every
``UPDATE_CHECK_INTERVAL``, plus once shortly after setup). The coordinator
itself always fetches (see its docstring) so the `update` platform entity
stays populated regardless of this option; only the reload decided here is
gated on it.
Skips entirely when: auto-update is off, a pip-spec override is set,
either version is unknown (``info`` may still be ``None`` — the
coordinator's ``data`` type before its first successful refresh), or a
bring-up is still in flight (below).
A pending update is additionally gated on component compatibility
(issues #1783/#1785): when the candidate release also shipped a newer
custom component than the one running, the reload is HELD — loudly (a
repair issue plus a warning log every check) and escapably (applying the
HACS component update — which takes an HA restart, as the issue text
says — unblocks the next check; the update entity's Install button never
passes through here, so manual installs — like pip-spec overrides above —
bypass the hold entirely). Every failure inside the gate fails OPEN so a
GitHub hiccup can never wedge updates.
Best-effort: an incomparable version string (AwesomeVersionException) is
logged at debug and skipped; the next refresh retries. Genuine bugs
propagate per the repo's no-silent-failure convention.
"""
if not bool(entry.options.get(OPT_AUTO_UPDATE, DEFAULT_AUTO_UPDATE)):
# Auto-update turned off: stay on the currently-installed version.
return
override = str(entry.options.get(OPT_PIP_SPEC) or "").strip()
if override and override != DEFAULT_PIP_SPEC:
return
if info is None or info.installed is None or info.latest is None:
# Nothing to compare (not installed yet, or the PyPI fetch failed /
# was skipped) - the bring-up path installs the newest build itself.
return
bringup_task = hass.data.get(DOMAIN, {}).get(DATA_BRINGUP_TASK)
if bringup_task is not None and not bringup_task.done():
# The coordinator's first refresh runs shortly after setup, while the
# background bring-up (embedded_entry.async_setup_server_entry) may
# still be installing the package for the first time. Reloading here
# would cancel that in-flight install (async_unload_server_entry
# cancels the bring-up task on unload) and can loop: the reload's own
# bring-up starts a fresh install that the NEXT refresh could again
# interrupt.
return
try:
newer = AwesomeVersion(info.latest) > AwesomeVersion(info.installed)
except AwesomeVersionException as err:
# Incomparable version strategies (e.g. a non-semver build string) — the
# only expected failure here. Real bugs (TypeError, etc.) propagate.
_LOGGER.debug("HA-MCP auto-update version compare failed: %s", err)
return
if not newer:
# Up to date: a hold that was pending is resolved (the component
# update landed and the unblocked reload installed the server).
ir.async_delete_issue(hass, DOMAIN, ISSUE_UPDATE_HELD)
return
held = await _async_update_held_by_component(hass, info)
if held is not None:
shipped, running = held
_LOGGER.warning(
"HA-MCP server %s is available, but that release also updated the "
"custom component (%s; running %s); holding the automatic server "
"update until the component is updated via HACS. Press Install on "
"the HA-MCP server update entity to install anyway.",
info.latest,
shipped,
running,
)
ir.async_create_issue(
hass,
DOMAIN,
ISSUE_UPDATE_HELD,
is_fixable=False,
severity=ir.IssueSeverity.WARNING,
translation_key=ISSUE_UPDATE_HELD,
translation_placeholders={
"latest": str(info.latest),
"shipped": shipped,
"running": running,
},
learn_more_url=HACS_COMPONENT_URL,
)
return
ir.async_delete_issue(hass, DOMAIN, ISSUE_UPDATE_HELD)
channel = channel_for_dist(info.dist)
_LOGGER.info(
"HA-MCP server update available on the %s channel (%s -> %s); "
"reloading the entry to install it.",
channel,
info.installed,
info.latest,
)
# The "updated" notification must wait for the reloaded entry's bring-up to
# actually install and start the new build — async_reload returns when
# entry SETUP finishes, while the pip install still runs in the background
# and can fail (review finding). Leave a marker for bring-up to pop:
# notification on success (_async_finish_update_cycle), silent drop on
# failure (the package/start repair issues cover that path).
hass.data.setdefault(DOMAIN, {})[DATA_PENDING_UPDATE_NOTIFY] = {
"old": info.installed
}
try:
await hass.config_entries.async_reload(entry.entry_id)
except Exception:
# A raising reload leaves no repair issue behind (those are filed by
# bring-up, which never ran), so this ERROR log is the only signal —
# it must not be swallowed or left at debug (review finding). The next
# coordinator refresh retries the whole cycle.
_drop_pending_update_notify(hass)
_LOGGER.exception(
"HA-MCP auto-update reload failed (%s -> %s on the %s channel)",
info.installed,
info.latest,
channel,
)
def _drop_pending_update_notify(hass: HomeAssistant) -> None:
"""Drop the deferred update-notification marker without notifying."""
hass.data.get(DOMAIN, {}).pop(DATA_PENDING_UPDATE_NOTIFY, None)
async def _async_update_held_by_component(
hass: HomeAssistant, info: ServerVersionInfo
) -> tuple[str, str] | None:
"""Return ``(shipped, running)`` when the pending update must be held.
The #1783/#1785 breakage: a server release whose repo state also bumped the
custom component auto-installed under the OLD component before HACS had
even surfaced the component update. The component version in the manifest
at the candidate release's git tag is what shipped with that server build —
newer than the running component means the release changed the component
too, so the automatic server install waits for the component.
Fails OPEN (returns None → install proceeds, the pre-gate behavior) on
every expected failure: manifest unreachable, component version unreadable,
incomparable versions. Blocking updates indefinitely on a transient would
be worse than the crash this guards against — and the crash itself is now
also survivable server-side (the tools registry skips a failing module).
"""
shipped = await _async_fetch_shipped_component_version(hass, str(info.latest))
if shipped is None:
return None
try:
integration = await async_get_integration(hass, DOMAIN)
running = str(integration.version)
except Exception:
# Same wide loader surface as _async_check_component_compat: advisory
# gate, logged visibly rather than swallowed silently.
_LOGGER.warning(
"Could not read the HA-MCP component version for the auto-update "
"gate; proceeding with the update",
exc_info=True,
)
return None
try:
if AwesomeVersion(running) < AwesomeVersion(shipped):
return shipped, running
except AwesomeVersionException as err:
# Incomparable version strategies only; real bugs propagate.
_LOGGER.debug("HA-MCP auto-update gate version compare failed: %s", err)
return None
async def _async_fetch_shipped_component_version(
hass: HomeAssistant, server_version: str
) -> str | None:
"""Return the component version shipped at server release ``vX.Y.Z``.
Reads the component manifest as committed at the release's git tag (raw
GitHub URL). Stable tags exist before the PyPI publish; a dev tag only
appears after its binary builds finish, so a fresh dev version can 404
here for some minutes — see COMPONENT_MANIFEST_AT_TAG_URL. Returns None
on any failure; the caller treats that as "nothing to hold on"
(fail-open).
"""
url = COMPONENT_MANIFEST_AT_TAG_URL.format(version=server_version)
try:
session = async_get_clientsession(hass)
async with asyncio.timeout(_MANIFEST_FETCH_TIMEOUT_SECONDS):
async with session.get(url) as resp:
resp.raise_for_status()
# content_type=None: raw.githubusercontent.com serves
# text/plain, which aiohttp's default json() rejects.
payload = await resp.json(content_type=None)
return str(payload["version"])
except (ClientError, TimeoutError, KeyError, TypeError, ValueError) as err:
_LOGGER.debug(
"HA-MCP shipped-component manifest fetch failed for %s: %s", url, err
)
return None
async def _async_finish_update_cycle(hass: HomeAssistant) -> None:
"""Refresh the version entity and fire the deferred update notification.
Runs at the end of a fully successful bring-up. Both halves belong exactly
here (review findings): the freshly installed version is only knowable once
the install landed — without a refresh the `update` entity keeps showing a
stale "update available" for up to UPDATE_CHECK_INTERVAL after a successful
install — and the notification deferred by async_maybe_auto_update must
only fire for an install that actually happened. Advisory: a failure here
must never fail the (already running) server, so it is logged visibly and
swallowed. No reload loop: the refresh's listener re-enters
async_maybe_auto_update, which no-ops on the still-running bring-up task.
"""
domain_data = hass.data.get(DOMAIN, {})
coordinator = domain_data.get(DATA_UPDATE_COORDINATOR)
try:
if coordinator is not None:
await coordinator.async_refresh()
except Exception:
_LOGGER.warning("HA-MCP: post-install version refresh failed", exc_info=True)
marker = domain_data.pop(DATA_PENDING_UPDATE_NOTIFY, None)
if marker is None or coordinator is None or coordinator.data is None:
return
installed = coordinator.data.installed
old = marker.get("old")
if installed is None or installed == old:
# The reload ran but the installed version did not actually move (the
# install can legitimately resolve to the same build) — an "updated
# to" notification would be false.
return
_create_update_notification(
hass, channel_for_dist(coordinator.data.dist), old, installed
)
def _create_update_notification(
hass: HomeAssistant, channel: str, old_version: str, new_version: str
) -> None:
"""Notify that an automatic server update installed and the server is up.
Only called from :func:`_async_finish_update_cycle` after a successful
bring-up, so the versions are the confirmed before/after pair, never a
prediction. SECURITY: same posture as ``_surface_connect_urls`` -
persistent notifications are visible to every authenticated Home Assistant
user, so this carries no secrets or connect URLs, only version numbers and
a public GitHub link.
"""
release_url = (
"https://github.com/homeassistant-ai/ha-mcp/commits/master"
if channel == CHANNEL_DEV
else f"https://github.com/homeassistant-ai/ha-mcp/releases/tag/v{new_version}"
)
message = (
f"The HA-MCP server was automatically updated from {old_version} to "
f"{new_version} on the {channel} channel.\n\n"
f"[Release notes]({release_url})"
)
persistent_notification.async_create(
hass,
message,
title="HA-MCP Server updated",
notification_id=_UPDATE_NOTIFICATION_ID,
)
# ---------------------------------------------------------------------------
# Component / server version-compatibility repair issue
# ---------------------------------------------------------------------------
def _read_min_component_version() -> str | None:
"""Return the server's declared ``MIN_COMPONENT_VERSION``, or None (blocking).
Imported here (in an executor thread) so the heavy ``ha_mcp`` import stays
off the event loop and out of this module's top level. Guards older/newer
server layouts that do not expose the constant by returning None (skip).
"""
try:
from ha_mcp.tools.tools_filesystem import MIN_COMPONENT_VERSION
except (ImportError, AttributeError):
return None
return str(MIN_COMPONENT_VERSION)
async def _async_check_component_compat(
hass: HomeAssistant, entry: ConfigEntry
) -> None:
"""File/clear the component-outdated repair issue for the running server.
The ha-mcp server declares the minimum custom-component version it needs
(``MIN_COMPONENT_VERSION``). The server package updates independently of
the HACS component (this manager pip-installs new server builds), so the
running component can lag what the server expects. When it does, surface a
WARNING repair issue pointing at the HACS component update; clear it once
the component is new enough.
Advisory only — it must never block or fail server startup, so an
unexpected error is logged (visible, not silent) and swallowed rather than
propagated to the bring-up's failure handling.
"""
required = await hass.async_add_executor_job(_read_min_component_version)
if required is None:
# Server predates MIN_COMPONENT_VERSION, or a newer layout moved it —
# nothing to enforce.
return
try:
integration = await async_get_integration(hass, DOMAIN)
own = str(integration.version)
except Exception:
# The loader legitimately raises a wide, varied surface
# (IntegrationNotFound, manifest errors); advisory check, logged
# visibly with the traceback rather than swallowed silently.
_LOGGER.warning(
"Could not read the HA-MCP component version for the compatibility check",
exc_info=True,
)
return
try:
outdated = AwesomeVersion(own) < AwesomeVersion(required)
except AwesomeVersionException as err:
# Incomparable version strategies only; real bugs propagate.
_LOGGER.debug("HA-MCP component-compat version compare failed: %s", err)
return
if outdated:
_LOGGER.warning(
"The installed ha-mcp server requires HA-MCP Custom Component %s or "
"newer, but %s is running; update the component via HACS.",
required,
own,
)
ir.async_create_issue(
hass,
DOMAIN,
ISSUE_COMPONENT_OUTDATED,
is_fixable=False,
severity=ir.IssueSeverity.WARNING,
translation_key=ISSUE_COMPONENT_OUTDATED,
translation_placeholders={"required": required, "installed": own},
learn_more_url=HACS_COMPONENT_URL,
)
else:
ir.async_delete_issue(hass, DOMAIN, ISSUE_COMPONENT_OUTDATED)
+4
View File
@@ -0,0 +1,4 @@
{
"name": "HA-MCP Custom Component",
"render_readme": true
}
@@ -0,0 +1,110 @@
"""Detect a legacy (main-repo) HACS install source and warn (issue #1760).
Before the dedicated HACS mirror (``homeassistant-ai/ha-mcp-integration``)
existed, the README told users to add the MAIN ``ha-mcp`` server repository as
a HACS custom repository. Those installs still work — HACS downloads the repo
snapshot at the release tag, which contains this component — but HACS shows
the SERVER's version numbers (``7.x``) and the server/add-on release notes in
the update dialog, as if the component were the server itself. HACS has no
repository-migration mechanism, so this population stays confused forever
unless the component itself detects the legacy source and points them at the
mirror. The legacy install keeps working either way — this only files an
advisory repair issue, never blocks anything.
"""
from __future__ import annotations
import logging
from typing import TYPE_CHECKING
from homeassistant.const import EVENT_HOMEASSISTANT_STARTED
from homeassistant.core import CoreState, callback
from homeassistant.helpers import issue_registry as ir
from .const import DOMAIN, HACS_COMPONENT_URL, ISSUE_LEGACY_HACS_SOURCE
if TYPE_CHECKING:
from homeassistant.core import Event, HomeAssistant
_LOGGER = logging.getLogger(__name__)
# The main server repo's full_name, as HACS's repository registry keys it —
# the legacy (pre-mirror) install path this module detects.
_LEGACY_REPO_FULL_NAME = "homeassistant-ai/ha-mcp"
# Guards the schedule below so multiple config entries (tools + server) on the
# same HA run only ever schedule the check once.
_DATA_SCHEDULED = "install_source_check_scheduled"
def async_schedule_install_source_check(hass: HomeAssistant) -> None:
"""Schedule the legacy-HACS-source check to run once per Home Assistant run.
Deferred to (or past) Home Assistant startup rather than run at
component-setup time: HACS is a separate integration that may set up AFTER
this one on a fresh boot, so checking here directly would race it and could
misread a legitimate HACS-managed install as "no HACS" before HACS has
populated ``hass.data["hacs"]``. ``EVENT_HOMEASSISTANT_STARTED`` only fires
once every integration's config entries have finished setup, which is the
guarantee this check needs. When hass has already reached that point (a
config entry added or reloaded after startup), the event has already fired
and never will again this run, so the check runs immediately instead.
Guarded by a once-flag in ``hass.data[DOMAIN]``: both entry types call this
on setup, and this must run at most once per HA run.
"""
domain_data = hass.data.setdefault(DOMAIN, {})
if domain_data.get(_DATA_SCHEDULED):
return
domain_data[_DATA_SCHEDULED] = True
if hass.state == CoreState.running:
hass.async_create_task(
_async_check_install_source(hass), f"{DOMAIN}_install_source_check"
)
return
@callback
def _on_started(_event: Event) -> None:
hass.async_create_task(
_async_check_install_source(hass), f"{DOMAIN}_install_source_check"
)
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STARTED, _on_started)
async def _async_check_install_source(hass: HomeAssistant) -> None:
"""File/clear the legacy-HACS-source repair issue.
Wraps the entire HACS interaction in a broad except: HACS is a third-party
integration whose internals this reaches into directly (no public API
exists for "what repository is this component tracking"), so any shape
change there must degrade to a warning log rather than break Home
Assistant. Advisory only — a failure changes nothing in the issue registry.
"""
try:
hacs = hass.data.get("hacs")
installed = False
if hacs is not None:
repo = hacs.repositories.get_by_full_name(_LEGACY_REPO_FULL_NAME)
installed = repo is not None and bool(repo.data.installed)
except Exception:
_LOGGER.warning(
"HA-MCP: could not determine the HACS install source", exc_info=True
)
return
if installed:
ir.async_create_issue(
hass,
DOMAIN,
ISSUE_LEGACY_HACS_SOURCE,
is_fixable=False,
severity=ir.IssueSeverity.WARNING,
translation_key=ISSUE_LEGACY_HACS_SOURCE,
learn_more_url=HACS_COMPONENT_URL,
)
else:
# Not installed via the legacy repo (including: no HACS at all, e.g. a
# manual install) — clear any issue filed before the user migrated.
ir.async_delete_issue(hass, DOMAIN, ISSUE_LEGACY_HACS_SOURCE)
+680
View File
@@ -0,0 +1,680 @@
"""Expose the in-process server's toolset as a Home Assistant LLM API (#1745).
While the in-process server entry is up, the ha-mcp toolset is registered as
one or two LLM APIs (``homeassistant.helpers.llm``). Any Home Assistant
conversation agent — OpenAI, Google, Ollama, Anthropic, or any other — can
then select it in its "Control Home Assistant" option, and the user chats
with the toolset through the surfaces Home Assistant already has: the Assist
chat UI, the companion apps, and voice satellites. No separate chat frontend
is needed.
Two exposure modes (the ``llm_api_exposure`` entry option picks which are
registered; default is tool-search only):
* **tool search** — the agent gets a tiny catalog: the server's pinned tools
mirrored directly, plus two meta-tools synthesized here: ``ha_search_tools``
(find tools by task) and ``ha_call_tool`` (execute a discovered tool). This
keeps per-turn context small — the shape context-limited models need.
* **full** — every exposed tool is mirrored directly into the agent's tool
list, one schema each.
**Per-tool exposure is decided by the server, not here.** The server stamps
every ``tools/list`` entry with ``_meta.ha_mcp = {llm_api_exposed, pinned}``
(see ``src/ha_mcp/llm_exposure.py``): user toggles from the settings UI, with
deny-by-default for beta/developer/restart-reload-backup tools. Both modes
filter on the stamp, and the tool-search ``ha_call_tool`` forwarder re-checks
it at call time — a hidden tool is invisible (absent from lists and search
results) and a hallucinated call to one gets a plain unknown-tool error, the
same answer a nonexistent tool gets, so nothing leaks. Globally-disabled
tools never appear in ``tools/list`` at all and the server rejects calling
them by name, and every forwarded call traverses the server's policy /
read-only middleware exactly like any MCP client's call.
The server runs on its own worker thread behind a loopback HTTP listener, and
``ha_mcp`` must never be imported in the HA main process (see
:mod:`embedded_server`), so this module talks real MCP to the server over
loopback streamable HTTP. The ``mcp`` client SDK arrives with the
runtime-installed ha-mcp package (a fastmcp dependency), so every SDK import
here is lazy and the first one runs on the executor.
The tool list is fetched fresh on every ``async_get_api_instance`` call (once
per conversation turn): exposure toggles and runtime-registered custom tools
apply on the agent's next message, and two loopback round-trips per turn are
noise next to the LLM call itself. Tool calls likewise open a short-lived
stateless session each — the in-process server serves ``stateless_http=True``.
"""
from __future__ import annotations
import asyncio
import importlib
import logging
from collections.abc import AsyncIterator, Iterable
from contextlib import asynccontextmanager
from dataclasses import dataclass
from typing import TYPE_CHECKING, Any, cast
import voluptuous as vol
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import llm
from homeassistant.helpers.httpx_client import get_async_client
from voluptuous_openapi import convert_to_voluptuous
from .const import (
DATA_LLM_API_UNSUB,
DEFAULT_LLM_API_EXPOSURE,
DOMAIN,
EXPOSURE_BOTH,
EXPOSURE_FULL,
EXPOSURE_TOOL_SEARCH,
OPT_LLM_API_EXPOSURE,
)
if TYPE_CHECKING:
from homeassistant.config_entries import ConfigEntry
from homeassistant.util.json import JsonObjectType
from mcp import types as mcp_types
from mcp.client.session import ClientSession
_LOGGER = logging.getLogger(__name__)
# Listing tools is two loopback round-trips (initialize + tools/list); a slow
# answer means the server thread is wedged, not that the network is slow.
_LIST_TOOLS_TIMEOUT_SECONDS = 10.0
# Tool calls run real work — WebSocket-verified device control, dashboard
# screenshots, config writes that poll for completion — well beyond the 10s a
# remote-server integration would allow. The conversation agent shows a spinner
# for the duration, so err generous rather than kill a legitimate slow tool.
_CALL_TOOL_TIMEOUT_SECONDS = 300.0
# The server-side stamp this module filters on (mirrors
# src/ha_mcp/llm_exposure.py — keep the names in sync).
_META_NAMESPACE = "ha_mcp"
_META_EXPOSED_KEY = "llm_api_exposed"
_META_PINNED_KEY = "pinned"
# Fallback exposure policy for servers that predate the stamp: hide the
# operational-hazard names and the known beta/developer tools. Imperfect by
# construction (a newer beta tool on an old server can't be known here) but
# strictly safer than exposing everything, and logged once per instance
# build. The real policy lives server-side.
_FALLBACK_DENY_PREFIXES = ("ha_dev_",)
_FALLBACK_DENY_TOOLS = frozenset(
{
"ha_restart",
"ha_reload_core",
"ha_manage_backup",
# Beta-tagged tools as of the stamp's introduction (server-side the
# gate is tag-based and future-proof; this list is only the legacy
# fallback).
"ha_config_set_yaml",
"ha_manage_custom_tool",
"ha_get_dashboard_screenshot",
"ha_install_mcp_tools",
"ha_list_files",
"ha_read_file",
"ha_write_file",
"ha_delete_file",
}
)
# Names of the meta-tools synthesized for the tool-search mode. ha_search_tools
# deliberately matches the server's own tool-search terminology; if the server
# itself runs ENABLE_TOOL_SEARCH its identically-named tool is excluded from
# mirroring/search results to avoid duplicates.
_SEARCH_TOOL_NAME = "ha_search_tools"
_CALL_TOOL_NAME = "ha_call_tool"
_SEARCH_RESULT_LIMIT = 8
# Used when the server's initialize result carries no instructions (it always
# should — ha-mcp ships server-level instructions — but never render an empty
# prompt if a build does not).
_FALLBACK_API_PROMPT = (
"The following tools are provided by the HA-MCP server running inside "
"Home Assistant. They give full control over this Home Assistant "
"instance: entities, automations, scripts, dashboards, helpers, and "
"configuration."
)
_TOOL_SEARCH_PROMPT = (
"\n\n## Tool Discovery\n"
"This assistant uses search-based tool discovery: most tools are NOT "
"listed directly.\n"
f"1. Call {_SEARCH_TOOL_NAME}(query=...) to find tools for the task; "
"results include each tool's name, description, and input schema.\n"
f"2. Execute a discovered tool with {_CALL_TOOL_NAME}(name=..., "
"arguments={...}) — discovered tools are NOT directly callable here.\n"
"3. The few tools listed directly can be called as usual.\n"
"Search once per task, not per call — tool names stay valid all "
"conversation."
)
def _transport_error_leaves() -> tuple[type[BaseException], ...]:
"""Return the non-group exception classes a loopback exchange can raise.
OSError covers a refused/dropped loopback connect; TimeoutError comes
from our asyncio.timeout budget. httpx errors and protocol-level McpError
can also escape a session call UNWRAPPED (HA core's mcp integration
catches both the same way), but neither class is importable at module
level — both arrive with the runtime-installed server package — hence a
function instead of a module constant.
"""
errors: tuple[type[BaseException], ...] = (TimeoutError, OSError)
try:
import httpx
from mcp import McpError
except ImportError: # pragma: no cover - SDK-less builds never open a session
return errors
return (*errors, httpx.HTTPError, McpError)
def _transport_errors() -> tuple[type[BaseException], ...]:
"""Return the ``except`` target for one loopback MCP exchange.
Evaluated at exception time (an ``except`` expression is), so the lazy
imports in :func:`_transport_error_leaves` have already succeeded by
then. Includes ExceptionGroup because the SDK's anyio task groups wrap
in-session failures — but a caught group must still pass
:func:`_is_transport_failure` before being mapped to a friendly error,
or a genuine bug that happened inside the task group would be relabeled
as a transport failure (review finding).
"""
return (*_transport_error_leaves(), ExceptionGroup)
def _is_transport_failure(err: BaseException) -> bool:
"""Return True when ``err`` is purely a transport failure.
A group counts only when EVERY leaf (nested groups included) is a
transport error: a group carrying any non-transport member is a genuine
bug that must propagate with its loud traceback instead of being
remapped to a "could not reach the server" message.
"""
if isinstance(err, ExceptionGroup):
return all(_is_transport_failure(exc) for exc in err.exceptions)
return isinstance(err, _transport_error_leaves())
def _import_mcp_sdk() -> None:
"""Import the mcp client SDK modules (blocking; run on the executor).
Raises ImportError when the SDK is not importable — the caller decides
whether that skips registration (SDK missing entirely) or surfaces as a
conversation error.
"""
importlib.import_module("mcp.client.session")
importlib.import_module("mcp.client.streamable_http")
async def async_probe_mcp_sdk(hass: HomeAssistant) -> bool:
"""Return True when the mcp client SDK imports (first import off-loop)."""
try:
await hass.async_add_executor_job(_import_mcp_sdk)
except ImportError as err:
_LOGGER.warning(
"The installed server package provides no importable 'mcp' client "
"SDK (%s); the conversation-agent LLM API will not be available",
err,
)
return False
return True
@asynccontextmanager
async def _mcp_session(
url: str,
http_client: Any = None,
) -> AsyncIterator[tuple[ClientSession, mcp_types.InitializeResult]]:
"""Open an initialized MCP session against the loopback server.
Imports resolve from ``sys.modules`` — :func:`async_probe_mcp_sdk` did the
real (blocking) import on the executor before the API was registered.
``http_client`` is Home Assistant's shared httpx client
(``helpers.httpx_client.get_async_client``). Passing it is what keeps
this loop-safe: without it the SDK constructs its own httpx client per
session, whose SSL setup loads the CA bundle SYNCHRONOUSLY inside HA's
event loop (live-found — HA's blocking-call monitor flagged this exact
line). HA's shared client is built against the process-cached SSL
context, and the SDK does not close caller-owned clients (HA core's mcp
integration relies on the same contract).
"""
from mcp.client.session import ClientSession
try:
from mcp.client.streamable_http import streamable_http_client
transport = (
streamable_http_client(url=url, http_client=http_client)
if http_client is not None
else streamable_http_client(url=url)
)
except ImportError:
# Pre-rename SDK (an older ha-mcp resolved by a pip-spec override
# pins an older fastmcp/mcp): same call shape, deprecated name, but
# no http_client kwarg — it builds its own client, so on those old
# SDKs the blocking-SSL-setup warning is the accepted cost.
from mcp.client.streamable_http import (
streamablehttp_client,
)
transport = streamablehttp_client(url=url)
async with (
transport as (read_stream, write_stream, _),
ClientSession(read_stream, write_stream) as session,
):
init_result = await session.initialize()
yield session, init_result
def _tool_meta_namespace(tool: Any) -> dict[str, Any] | None:
"""Return the tool's ``_meta.ha_mcp`` namespace, or None when absent."""
meta = getattr(tool, "meta", None)
if not isinstance(meta, dict):
return None
namespace = meta.get(_META_NAMESPACE)
return namespace if isinstance(namespace, dict) else None
def _fallback_exposed(name: str) -> bool:
"""Legacy exposure policy for servers that predate the meta stamp."""
if name.startswith(_FALLBACK_DENY_PREFIXES):
return False
return name not in _FALLBACK_DENY_TOOLS
def _partition_tools(tools: Iterable[Any]) -> tuple[list[Any], set[str], bool]:
"""Split a raw tools/list into (exposed tools, pinned names, stamped).
``stamped`` is False when NO tool carried the server's exposure stamp —
an older server package — in which case the conservative component-side
fallback policy was applied instead.
"""
stamped = False
exposed: list[Any] = []
pinned: set[str] = set()
for tool in tools:
namespace = _tool_meta_namespace(tool)
if namespace is not None and _META_EXPOSED_KEY in namespace:
stamped = True
if namespace.get(_META_PINNED_KEY):
pinned.add(tool.name)
if namespace.get(_META_EXPOSED_KEY):
exposed.append(tool)
elif _fallback_exposed(tool.name):
exposed.append(tool)
if not stamped:
# The fallback path already filtered; recompute pinned as empty (an
# unstamped server gives no pinned signal — the tool-search mode then
# simply mirrors nothing directly).
pinned = set()
return exposed, pinned, stamped
class HaMcpTool(llm.Tool):
"""One ha-mcp tool, called over loopback MCP."""
def __init__(
self,
name: str,
description: str | None,
parameters: vol.Schema,
server_url: str,
) -> None:
"""Store the converted schema and the loopback endpoint."""
self.name = name
self.description = description
self.parameters = parameters
self._server_url = server_url
async def async_call(
self,
hass: HomeAssistant,
tool_input: llm.ToolInput,
llm_context: llm.LLMContext,
) -> JsonObjectType:
"""Call the tool on the in-process server and return its result."""
return await _forward_tool_call(
hass, self._server_url, self.name, tool_input.tool_args
)
async def _forward_tool_call(
hass: HomeAssistant, server_url: str, name: str, arguments: dict[str, Any]
) -> JsonObjectType:
"""Forward one tool call over loopback and dump the result for the agent."""
try:
async with (
asyncio.timeout(_CALL_TOOL_TIMEOUT_SECONDS),
_mcp_session(server_url, get_async_client(hass)) as (session, _init),
):
result = await session.call_tool(name, arguments)
except _transport_errors() as err:
if not _is_transport_failure(err):
raise
raise HomeAssistantError(
f"Error calling the HA-MCP tool {name}: {err}"
) from err
# Full CallToolResult (content blocks, structuredContent, isError) —
# the same shape HA core's mcp integration hands to agents; ha-mcp
# signals tool failure via isError + structured error JSON, which the
# agent reads and reacts to like any tool output.
return result.model_dump(exclude_unset=True, exclude_none=True)
def _search_score(query_words: list[str], name: str, description: str) -> int:
"""Score a tool against the query (simple word overlap + substring)."""
haystack = f"{name} {description}".lower()
name_lower = name.lower()
score = 0
for word in query_words:
if word in name_lower:
score += 3
elif word in haystack:
score += 1
return score
class HaMcpSearchTool(llm.Tool):
"""Meta-tool: find ha-mcp tools relevant to a task (tool-search mode).
Searches only the EXPOSED catalog snapshot taken at instance build, so a
hidden tool can never appear in results.
"""
name = _SEARCH_TOOL_NAME
description = (
"Search the Home Assistant MCP toolset for tools relevant to a task. "
"Returns each match's name, description, and input schema. Execute "
f"matches with {_CALL_TOOL_NAME}."
)
parameters = vol.Schema({vol.Required("query"): str})
def __init__(self, catalog: list[dict[str, Any]]) -> None:
"""Hold the exposed-catalog snapshot (name/description/schema dicts)."""
self._catalog = catalog
async def async_call(
self,
hass: HomeAssistant,
tool_input: llm.ToolInput,
llm_context: llm.LLMContext,
) -> JsonObjectType:
"""Return the top-scoring exposed tools for the query."""
query_words = [
w for w in str(tool_input.tool_args.get("query", "")).lower().split() if w
]
scored = sorted(
(
(_search_score(query_words, t["name"], t["description"]), t)
for t in self._catalog
),
key=lambda pair: pair[0],
reverse=True,
)
results = [t for score, t in scored[:_SEARCH_RESULT_LIMIT] if score > 0]
if not results:
return {
"results": [],
"message": (
"No matching tools. Try different task words (e.g. "
"'automation', 'light', 'history', 'dashboard')."
),
}
return {"results": results}
class HaMcpCallTool(llm.Tool):
"""Meta-tool: execute a tool discovered via search (tool-search mode).
The exposure re-check at call time is the enforcement half of the
tool-search mode: hiding a tool from search results alone would not stop
a model that guesses a name. A non-exposed name gets the same
unknown-tool answer a nonexistent name gets — existence never leaks.
"""
name = _CALL_TOOL_NAME
description = (
"Execute a Home Assistant MCP tool by name with a dictionary of "
f"arguments. Discover tools and their schemas with {_SEARCH_TOOL_NAME} "
"first."
)
parameters = vol.Schema(
{
vol.Required("name"): str,
vol.Optional("arguments", default=dict): dict,
}
)
def __init__(self, server_url: str, exposed_names: set[str]) -> None:
"""Hold the loopback endpoint and the exposed-name allowlist."""
self._server_url = server_url
self._exposed_names = exposed_names
async def async_call(
self,
hass: HomeAssistant,
tool_input: llm.ToolInput,
llm_context: llm.LLMContext,
) -> JsonObjectType:
"""Forward the call when the target is exposed; unknown-tool otherwise."""
name = str(tool_input.tool_args.get("name", ""))
arguments = tool_input.tool_args.get("arguments") or {}
if name not in self._exposed_names:
return {
"error": f"Unknown tool '{name}'.",
"suggestion": (f"Use {_SEARCH_TOOL_NAME} to discover available tools."),
}
return await _forward_tool_call(hass, self._server_url, name, arguments)
@dataclass(kw_only=True)
class HaMcpLlmApi(llm.API):
"""The in-process ha-mcp server's toolset as a Home Assistant LLM API."""
server_url: str
# Valid instance modes are only tool_search and full — EXPOSURE_BOTH is
# an option value that _apis_for_mode expands into two instances and must
# never reach here. The default is the compact/safe shape, matching the
# option default (review finding: defaulting to full made an omitted
# mode maximally exposed).
mode: str = EXPOSURE_TOOL_SEARCH
async def async_get_api_instance(
self, llm_context: llm.LLMContext
) -> llm.APIInstance:
"""Fetch the current tool list and return an API instance.
Fetched fresh each conversation turn (see the module docstring); the
server's own initialize ``instructions`` become the API prompt, so the
agent gets the same guidance every MCP client gets.
"""
try:
async with (
asyncio.timeout(_LIST_TOOLS_TIMEOUT_SECONDS),
_mcp_session(self.server_url, get_async_client(self.hass)) as (
session,
init_result,
),
):
list_result = await session.list_tools()
except _transport_errors() as err:
if not _is_transport_failure(err):
raise
raise HomeAssistantError(
f"Could not reach the in-process HA-MCP server: {err}"
) from err
exposed, pinned, stamped = _partition_tools(list_result.tools)
# Never mirror or search a server-side tool that shares a synthesized
# meta-tool's name (the server's own tool-search mode registers an
# ha_search_tools) — one name, one behavior.
exposed = [
t for t in exposed if t.name not in (_SEARCH_TOOL_NAME, _CALL_TOOL_NAME)
]
if not stamped:
_LOGGER.warning(
"The running server does not stamp LLM-API exposure metadata "
"(older ha-mcp package); applying the component's built-in "
"conservative deny-list instead. Update the server package "
"for per-tool control from the settings UI."
)
prompt = init_result.instructions or _FALLBACK_API_PROMPT
# full is the explicit opt-in; anything else — including an unknown
# value — falls through to the compact/safe tool-search shape.
if self.mode == EXPOSURE_FULL:
tools = self._build_full_tools(exposed)
else:
tools = self._build_tool_search_tools(exposed, pinned)
prompt += _TOOL_SEARCH_PROMPT
return llm.APIInstance(self, prompt, llm_context, tools)
def _convert_parameters(self, tool: Any) -> vol.Schema | None:
"""Convert one tool's JSON schema, or None (logged) when it fails."""
try:
# cast: voluptuous_openapi is an untyped (ignored) import, so the
# call returns Any; its documented return type is vol.Schema.
return cast(vol.Schema, convert_to_voluptuous(tool.inputSchema))
except Exception:
# One unconvertible schema must not take down the whole
# toolset for the conversation — skip that tool, loudly.
_LOGGER.warning(
"Skipping tool %s: could not convert its input schema",
tool.name,
exc_info=True,
)
return None
def _build_full_tools(self, exposed: list[Any]) -> list[llm.Tool]:
"""Mirror every exposed tool directly (full-catalog mode)."""
tools: list[llm.Tool] = []
for tool in exposed:
parameters = self._convert_parameters(tool)
if parameters is None:
continue
tools.append(
HaMcpTool(tool.name, tool.description, parameters, self.server_url)
)
return tools
def _build_tool_search_tools(
self, exposed: list[Any], pinned: set[str]
) -> list[llm.Tool]:
"""Build the compact catalog: mirrored pinned tools + meta-tools."""
tools: list[llm.Tool] = []
exposed_names: set[str] = set()
catalog: list[dict[str, Any]] = []
for tool in exposed:
exposed_names.add(tool.name)
catalog.append(
{
"name": tool.name,
"description": tool.description or "",
"input_schema": tool.inputSchema,
}
)
if tool.name in pinned:
parameters = self._convert_parameters(tool)
if parameters is not None:
tools.append(
HaMcpTool(
tool.name, tool.description, parameters, self.server_url
)
)
tools.append(HaMcpSearchTool(catalog))
tools.append(HaMcpCallTool(self.server_url, exposed_names))
return tools
def _apis_for_mode(
hass: HomeAssistant, entry: ConfigEntry, server_url: str, exposure: str
) -> list[HaMcpLlmApi]:
"""Build the API registration set for the configured exposure mode."""
full = HaMcpLlmApi(
hass=hass,
id=f"{DOMAIN}-{entry.entry_id}",
name=entry.title,
server_url=server_url,
mode=EXPOSURE_FULL,
)
search = HaMcpLlmApi(
hass=hass,
id=f"{DOMAIN}-{entry.entry_id}-toolsearch",
name=f"{entry.title} (tool search)",
server_url=server_url,
mode=EXPOSURE_TOOL_SEARCH,
)
if exposure == EXPOSURE_FULL:
return [full]
if exposure == EXPOSURE_BOTH:
return [full, search]
# Default and explicit tool_search both land here; an unknown stored
# value degrades to the default rather than failing bring-up.
return [search]
async def async_register_llm_api(
hass: HomeAssistant,
entry: ConfigEntry,
*,
port: int,
secret_path: str,
) -> None:
"""Register the toolset as LLM API(s) per the exposure option (advisory).
Called from the bring-up success path. Never raises — and that has to be
literal, not aspirational: any exception escaping here lands in the
bring-up's outer ``except Exception``, which tears the already-running
server down and files a "start" repair issue for what is a cosmetic
failure (review finding). Hence the broad containment: whatever goes
wrong is logged and the feature is simply absent until the next (re)load.
Cancellation (a BaseException) still propagates.
"""
try:
if not await async_probe_mcp_sdk(hass):
return
# Re-registration guard: a bring-up after a teardown that could not
# run (or a duplicate bring-up) must replace the stale registration,
# not fail on the duplicate id.
async_unregister_llm_api(hass)
exposure = str(
entry.options.get(OPT_LLM_API_EXPOSURE, DEFAULT_LLM_API_EXPOSURE)
)
server_url = f"http://127.0.0.1:{port}{secret_path}"
unsubs = [
llm.async_register_api(hass, api)
for api in _apis_for_mode(hass, entry, server_url, exposure)
]
hass.data.setdefault(DOMAIN, {})[DATA_LLM_API_UNSUB] = unsubs
except Exception:
_LOGGER.warning(
"Could not register the HA-MCP LLM API; conversation agents will "
"not see the toolset until the entry is reloaded",
exc_info=True,
)
return
# The embedded e2e (test_llm_api_registered_inside_ha) asserts on this
# message to prove the registration ran inside a real HA — keep the
# "Registered the HA-MCP toolset as LLM API" prefix stable.
_LOGGER.info(
"Registered the HA-MCP toolset as LLM API (%s mode) — select it in a "
"conversation agent's settings to chat with it (text or voice)",
exposure,
)
def async_unregister_llm_api(hass: HomeAssistant) -> None:
"""Unregister the LLM API(s) if registered (idempotent, teardown-safe)."""
unsubs = hass.data.get(DOMAIN, {}).pop(DATA_LLM_API_UNSUB, None)
if not unsubs:
return
for unsub in unsubs:
unsub()
@@ -0,0 +1,23 @@
{
"domain": "ha_mcp_tools",
"name": "HA-MCP Custom Component",
"after_dependencies": [
"http",
"cloud",
"frontend"
],
"codeowners": [
"@homeassistant-ai"
],
"config_flow": true,
"dependencies": [
"webhook"
],
"documentation": "https://github.com/homeassistant-ai/ha-mcp",
"iot_class": "local_push",
"issue_tracker": "https://github.com/homeassistant-ai/ha-mcp/issues",
"requirements": [
"ruamel.yaml>=0.18.0"
],
"version": "1.1.0"
}
@@ -0,0 +1,589 @@
"""Webhook ingress for the in-process ha-mcp server (issue #1527).
Ported from the proven webhook-proxy add-on (``mcp_proxy``): an HA webhook
(``/api/webhook/<id>``) forwards MCP traffic to the loopback server and streams
the response back, so the server is reachable through Nabu Casa remote UI (or any
reverse proxy) with the webhook id as the shared secret.
Two auth postures, chosen in the options flow:
* ``none`` — the secret webhook URL *is* the credential (matches the add-on's
default). No bearer is required.
* ``ha_auth`` — Home Assistant core is the OAuth authorization server. This
module serves the RFC 8414 / RFC 9728 discovery documents (so claude.ai /
ChatGPT can sign in with the user's HA account) and validates inbound bearer
tokens via ``hass.auth``. There is no bespoke authorization-server code here —
every protocol step is HA core's own ``/auth/*``.
The forwarding handler mirrors ``mcp_proxy._handle_webhook`` exactly (hop-by-hop
header stripping, the SSE streaming branch with anti-buffering headers, the
content-type whitelist, ``Mcp-Session-Id`` propagation, and the 502/500 error
mapping); the ``ha_auth`` bearer check + discovery documents mirror the add-on's
``auth_native.py`` + the ``ha_auth`` subset of ``oauth.py``.
"""
from __future__ import annotations
import inspect
import logging
from contextlib import suppress
from typing import TYPE_CHECKING, Any
import aiohttp
from aiohttp import web
from homeassistant.components.http import HomeAssistantView
from homeassistant.components.webhook import async_register, async_unregister
from homeassistant.core import HomeAssistant
from .const import (
DATA_WEBHOOK,
DATA_WEBHOOK_ID,
DOMAIN,
OAUTH_BASE,
WEBHOOK_AUTH_HA,
WEBHOOK_AUTH_NONE,
)
if TYPE_CHECKING:
from homeassistant.config_entries import ConfigEntry
_LOGGER = logging.getLogger(__name__)
# Human-readable webhook name shown in the HA webhook registry.
_WEBHOOK_NAME = "HA-MCP in-process server"
# Hop-by-hop / sensitive request headers never forwarded upstream (identical set
# to mcp_proxy). ``authorization`` is stripped because the server authenticates
# to HA with its own provisioned token, not the caller's bearer.
_STRIPPED_REQUEST_HEADERS = frozenset(
{
"host",
"content-length",
"transfer-encoding",
"connection",
"cookie",
"authorization",
}
)
# Content-Types the forwarded response may carry as-is; anything else is coerced
# to JSON to prevent HTML injection / XSS through the proxy. ``text/plain`` is
# safe (a browser never executes it) and lets the server's friendly landing page
# — a plain-text 405 shown when a browser GETs the endpoint — render as text
# instead of a mislabeled JSON blob. ``text/html`` and friends stay coerced.
_ALLOWED_CONTENT_TYPES = ("application/json", "text/event-stream", "text/plain")
# Long timeout for streamed MCP responses (matches mcp_proxy).
_CLIENT_TIMEOUT = aiohttp.ClientTimeout(total=300, sock_connect=10, sock_read=300)
# TOP-LEVEL hass.data flag recording that the ha_auth discovery views are bound
# for this HA session. Deliberately NOT under DOMAIN so it survives
# async_unload_entry's teardown — aiohttp cannot unregister an HTTP view until HA
# restarts, so the views (and this ownership flag) must outlive the config entry.
_OAUTH_VIEWS_REGISTERED_KEY = "ha_mcp_tools_oauth_metadata_views_registered"
# ---------------------------------------------------------------------------
# ha_auth resource server (HA core is the OAuth authorization server)
# ---------------------------------------------------------------------------
def _build_base_url(request: web.Request) -> str:
"""Build the public base URL from the request (host-derived).
ha_auth is always host-derived so the SAME install works via the Nabu Casa
cloud URL AND any other external URL. Reads ``X-Forwarded-Proto/Host`` as
sent: HA's forwarded middleware only validates proxy headers when
``X-Forwarded-For`` is present, so these can reach us raw. A peer can
thereby only shape the discovery/WWW-Authenticate URLs in its OWN
response (no cross-user vector), which is within SECURITY.md's
local-network trust model; treat stricter proxy validation as optional
hardening.
"""
host = request.headers.get("X-Forwarded-Host") or request.headers.get("Host", "")
scheme = request.headers.get("X-Forwarded-Proto", request.scheme)
return f"{scheme}://{host}"
def _authorization_server_document(base: str) -> dict[str, Any]:
"""RFC 8414 authorization-server metadata pointing at HA core's OAuth.
Advertises HA core's own ``/auth/authorize`` + ``/auth/token`` as a public
client (``token_endpoint_auth_methods_supported: ["none"]``) and
``client_id_metadata_document_supported`` so clients present a URL-shaped
``client_id`` (CIMD) that HA core's long-standing IndieAuth handling accepts —
the user never pastes a credential. No ``registration_endpoint``: HA offers no
dynamic client registration; CIMD replaces it.
"""
return {
"issuer": f"{base}{OAUTH_BASE}",
"authorization_endpoint": f"{base}/auth/authorize",
"token_endpoint": f"{base}/auth/token",
"response_types_supported": ["code"],
"grant_types_supported": ["authorization_code", "refresh_token"],
"code_challenge_methods_supported": ["S256"],
"token_endpoint_auth_methods_supported": ["none"],
"client_id_metadata_document_supported": True,
}
class ResourceServer:
"""ha_auth resource server: bearer validation + discovery URL building.
Owns no signing key, no client credentials, and binds no root views — HA core
is the authorization server. Held by the discovery views and the webhook
handler.
"""
def __init__(self, hass: HomeAssistant, webhook_id: str) -> None:
"""Bind to the HA instance and this install's webhook id."""
self._hass = hass
self._webhook_id = webhook_id
@property
def webhook_id(self) -> str:
"""This install's private webhook id."""
return self._webhook_id
def resource_url(self, base_url: str) -> str:
"""Absolute URL of the protected webhook resource under ``base_url``."""
return f"{base_url}/api/webhook/{self._webhook_id}"
def authorization_server_url(self, base_url: str) -> str:
"""Issuer / authorization-server URL under ``base_url``."""
return f"{base_url}{OAUTH_BASE}"
async def validate_request(self, request: web.Request) -> bool:
"""Return True iff the request carries a Bearer token HA core accepts.
A missing/malformed ``Authorization`` header is rejected without touching
the validator. ``hass.auth.async_validate_access_token`` is a synchronous
``@callback`` in HA core; it is awaited defensively in case a future
release makes it a coroutine, and any raise is treated as unauthorized so
a crafted token yields a 401 challenge rather than a 500.
"""
header = request.headers.get("Authorization", "")
if not header.lower().startswith("bearer "):
return False
token = header[7:].strip()
if not token:
return False
try:
result = self._hass.auth.async_validate_access_token(token)
if inspect.isawaitable(result):
result = await result
except Exception:
_LOGGER.debug(
"ha_auth: bearer validation raised; treating as unauthorized",
exc_info=True,
)
return False
if result is None:
return False
# ADMIN-ONLY: the server performs every Home Assistant operation with
# its own provisioned ADMIN token, so accepting any valid login would
# grant every household member admin-equivalent control. Require an
# active, human, administrator account (mirrors the settings panel).
user = getattr(result, "user", None)
if user is None:
return False
if getattr(user, "system_generated", False):
return False
if not getattr(user, "is_active", False):
return False
return bool(getattr(user, "is_admin", False))
# ---------------------------------------------------------------------------
# RFC 8414 / RFC 9728 discovery views (ha_auth mode only)
# ---------------------------------------------------------------------------
def _active_resource_server(hass: HomeAssistant) -> ResourceServer | None:
"""Return the CURRENT entry's ha_auth resource server, or None.
The discovery views resolve this per request instead of binding a provider
at registration time: aiohttp can't drop a bound view until HA restarts, so
a remove + re-add of the config entry (which mints a NEW webhook id in the
same HA session) would otherwise leave the views advertising the old id.
Returns None when no entry is live, the webhook auth mode is not ha_auth,
or the public endpoint is disabled (local-only mode constructs no resource
server even under ha_auth) — the views then 404 like an unregistered route.
"""
domain_data = hass.data.get(DOMAIN)
if not isinstance(domain_data, dict):
return None
cfg = domain_data.get(DATA_WEBHOOK)
if not isinstance(cfg, dict) or cfg.get("auth_mode") != WEBHOOK_AUTH_HA:
return None
provider = cfg.get("resource_server")
return provider if isinstance(provider, ResourceServer) else None
def _json_not_found() -> web.Response:
"""404 JSON body used by stale-but-bound discovery views."""
return web.json_response({"error": "not_found"}, status=404)
def _protected_resource_document(provider: ResourceServer, base: str) -> dict[str, Any]:
"""RFC 9728 protected-resource document for ``provider`` under ``base``."""
return {
"resource": provider.resource_url(base),
"authorization_servers": [provider.authorization_server_url(base)],
"bearer_methods_supported": ["header"],
"resource_documentation": "https://github.com/homeassistant-ai/ha-mcp",
}
class _ProtectedResourceMetadataView(HomeAssistantView):
"""RFC 9728 Protected Resource Metadata."""
requires_auth = False
cors_allowed = True
url = f"{OAUTH_BASE}/protected-resource"
name = "ha_mcp_tools:oauth:protected-resource"
def __init__(self, hass: HomeAssistant) -> None:
"""Bind the view to the HA instance; the provider is resolved per request."""
self._hass = hass
async def get(self, request: web.Request) -> web.Response:
"""Serve the protected-resource document (or 404 when ha_auth is off)."""
provider = _active_resource_server(self._hass)
if provider is None:
return _json_not_found()
return web.json_response(
_protected_resource_document(provider, _build_base_url(request))
)
class _AuthorizationServerMetadataView(HomeAssistantView):
"""RFC 8414 Authorization Server Metadata (points at HA core's OAuth)."""
requires_auth = False
cors_allowed = True
url = f"{OAUTH_BASE}/authorization-server"
name = "ha_mcp_tools:oauth:authorization-server"
def __init__(self, hass: HomeAssistant) -> None:
"""Bind the view to the HA instance; liveness is resolved per request."""
self._hass = hass
async def get(self, request: web.Request) -> web.Response:
"""Serve the authorization-server document (or 404 when ha_auth is off)."""
if _active_resource_server(self._hass) is None:
return _json_not_found()
base = _build_base_url(request)
return web.json_response(_authorization_server_document(base))
class _WellKnownProtectedResourceView(HomeAssistantView):
"""RFC 9728 §3.1 path-scoped Protected Resource Metadata.
Same document as :class:`_ProtectedResourceMetadataView`, served at the
well-known location derived from the webhook resource URL — claude.ai's
first fallback probe when the 401's ``resource_metadata`` pointer is
missing. The webhook id is a ROUTE PARAMETER (not baked into the path at
registration): a remove + re-add of the entry mints a new webhook id in the
same HA session, and the bound view must serve whichever id is currently
live (404 for any other). Standalone view (not a subclass of the plain
document view) because its handler takes the extra route parameter.
"""
requires_auth = False
cors_allowed = True
name = "ha_mcp_tools:oauth:wellknown-protected-resource"
url = "/.well-known/oauth-protected-resource/api/webhook/{webhook_id}"
def __init__(self, hass: HomeAssistant) -> None:
"""Bind the view to the HA instance; the provider is resolved per request."""
self._hass = hass
async def get(self, request: web.Request, webhook_id: str) -> web.Response:
"""Serve the document only for the CURRENT entry's webhook id."""
provider = _active_resource_server(self._hass)
if provider is None or webhook_id != provider.webhook_id:
return _json_not_found()
return web.json_response(
_protected_resource_document(provider, _build_base_url(request))
)
class _WellKnownAuthorizationServerMetadataView(_AuthorizationServerMetadataView):
"""RFC 8414 / OIDC-discovery locations for the AS metadata document.
Same document as :class:`_AuthorizationServerMetadataView`, registered at the
well-known URLs MCP clients actually probe for the issuer.
"""
def __init__(self, hass: HomeAssistant, url: str, name: str) -> None:
"""Bind and set an explicit well-known URL + unique view name."""
super().__init__(hass)
self.url = url
self.name = name
def _metadata_views(hass: HomeAssistant) -> list[HomeAssistantView]:
"""Build the seven ha_auth discovery-document views (provider-agnostic)."""
views: list[HomeAssistantView] = [
_ProtectedResourceMetadataView(hass),
_AuthorizationServerMetadataView(hass),
_WellKnownProtectedResourceView(hass),
]
for url, name in (
(
f"/.well-known/oauth-authorization-server{OAUTH_BASE}",
"ha_mcp_tools:oauth:wellknown-as-rfc8414",
),
(
f"/.well-known/openid-configuration{OAUTH_BASE}",
"ha_mcp_tools:oauth:wellknown-oidc-prefixed",
),
(
f"{OAUTH_BASE}/.well-known/openid-configuration",
"ha_mcp_tools:oauth:wellknown-oidc-suffixed",
),
(
f"{OAUTH_BASE}/.well-known/oauth-authorization-server",
"ha_mcp_tools:oauth:wellknown-as-suffixed",
),
):
views.append(
_WellKnownAuthorizationServerMetadataView(hass, url=url, name=name)
)
return views
def _register_metadata_views(hass: HomeAssistant) -> None:
"""Register the ha_auth discovery views at most once per HA session.
aiohttp cannot unregister a bound view, so a reload / re-enable / re-add must
reuse the already-bound views — they resolve the ACTIVE provider from
hass.data per request, so a later entry (even with a new webhook id) is
served correctly. The guard flag lives at a top-level hass.data key that
survives config-entry teardown.
"""
if hass.data.get(_OAUTH_VIEWS_REGISTERED_KEY):
return
for view in _metadata_views(hass):
hass.http.register_view(view)
hass.data[_OAUTH_VIEWS_REGISTERED_KEY] = True
def _build_unauthorized_response(
request: web.Request, provider: ResourceServer
) -> web.Response:
"""Build the 401 + ``WWW-Authenticate`` challenge MCP clients use to discover.
Per RFC 9728 §5.1 / MCP spec, the ``resource_metadata`` parameter points to
the protected-resource metadata URL where the client finds the authorization
server.
"""
base = _build_base_url(request)
metadata_url = f"{base}{OAUTH_BASE}/protected-resource"
return web.Response(
status=401,
text="Unauthorized",
headers={
"WWW-Authenticate": (
f'Bearer realm="HA-MCP", resource_metadata="{metadata_url}"'
)
},
)
# ---------------------------------------------------------------------------
# Webhook forwarding handler
# ---------------------------------------------------------------------------
async def _async_handle_webhook(
hass: HomeAssistant, webhook_id: str, request: web.Request
) -> web.StreamResponse:
"""Forward an MCP request to the loopback server and stream the reply back."""
domain_data = hass.data.get(DOMAIN)
cfg = domain_data.get(DATA_WEBHOOK) if isinstance(domain_data, dict) else None
if not isinstance(cfg, dict):
return web.Response(status=503, text="MCP server is not available")
# Auth gate. ``none`` = the secret webhook URL is the credential; ``ha_auth``
# = validate the bearer via HA core, and on failure emit the 401 discovery
# challenge so the client can start the OAuth flow. Gate on the PROVIDER
# (constructed only for ha_auth) rather than a string compare, so the
# coupling "provider present <=> ha_auth" has a single owner and an
# inconsistent cfg cannot fail open.
provider = cfg.get("resource_server")
if provider is not None:
if not await provider.validate_request(request):
return _build_unauthorized_response(request, provider)
target_url: str = cfg["target_url"]
session: aiohttp.ClientSession = cfg["session"]
body = await request.read()
forward_headers = {
key: value
for key, value in request.headers.items()
if key.lower() not in _STRIPPED_REQUEST_HEADERS
}
try:
async with session.request(
method=request.method,
url=target_url,
headers=forward_headers,
data=body if body else None,
) as upstream_resp:
content_type = upstream_resp.headers.get("Content-Type", "")
resp_headers = {
"Cache-Control": "no-cache, no-transform",
"Content-Encoding": "identity",
}
mcp_session = upstream_resp.headers.get("Mcp-Session-Id")
if mcp_session:
resp_headers["Mcp-Session-Id"] = mcp_session
if "text/event-stream" in content_type:
# SSE streaming: prevent HA's compression middleware from
# buffering/breaking the stream (supervisor#6470).
resp_headers["Content-Type"] = "text/event-stream"
resp_headers["X-Accel-Buffering"] = "no"
response = web.StreamResponse(
status=upstream_resp.status, headers=resp_headers
)
await response.prepare(request)
# Once prepare() has sent the 200 + headers, a mid-stream
# upstream failure can no longer become a 502 — returning a
# fresh Response here would be silently dropped and the client
# would see only a truncated stream with no log trail. End the
# prepared stream deterministically and log instead.
# Count forwarded bytes manually: StreamResponse.body_length
# is only assigned in write_eof(), so it is still 0 here.
bytes_forwarded = 0
try:
async for chunk in upstream_resp.content.iter_any():
await response.write(chunk)
bytes_forwarded += len(chunk)
except aiohttp.ClientError as err:
_LOGGER.error(
"MCP webhook: upstream dropped mid-stream after %d bytes: %s",
bytes_forwarded,
err,
)
with suppress(ConnectionResetError):
await response.write_eof()
return response
if not any(ct in content_type for ct in _ALLOWED_CONTENT_TYPES):
content_type = "application/json"
resp_headers["Content-Type"] = content_type
resp_body = await upstream_resp.read()
return web.Response(
status=upstream_resp.status, body=resp_body, headers=resp_headers
)
except aiohttp.ClientError as err:
_LOGGER.error("MCP webhook: upstream request failed: %s", err)
return web.Response(status=502, text="MCP server unavailable")
except Exception as err:
_LOGGER.exception("MCP webhook: unexpected error: %s", err)
return web.Response(status=500, text="MCP server internal error")
# ---------------------------------------------------------------------------
# Registration / teardown
# ---------------------------------------------------------------------------
async def async_register_webhook(
hass: HomeAssistant,
entry: ConfigEntry,
*,
port: int,
secret_path: str,
auth_mode: str,
register_endpoint: bool = True,
) -> None:
"""Register the ingress webhook (and, for ha_auth, the discovery views).
Stores the forwarding config in ``hass.data[DOMAIN][DATA_WEBHOOK]`` and opens
a long-lived aiohttp session for streaming. Raises on failure with the webhook
already unregistered, so the caller never leaves a half-configured endpoint
live. ``webhook`` is a manifest dependency, so HA guarantees it is set up
before this runs.
With ``register_endpoint=False`` (remote webhook access disabled by option)
no public endpoint or ha_auth surface is created — and any leftover endpoint
from a crashed unload is cleared, so off means off; only the forwarding
config is stored, which same-host consumers — the sidebar settings panel
proxy — need to reach the loopback server (#1803).
"""
if auth_mode not in (WEBHOOK_AUTH_NONE, WEBHOOK_AUTH_HA):
# Fail CLOSED on an unknown mode (corrupt/migrated options): refusing
# bring-up files a repair issue, instead of an unrecognized string
# silently taking the unauthenticated forward path.
raise ValueError(f"Unknown webhook auth mode: {auth_mode!r}")
webhook_id: str = entry.data[DATA_WEBHOOK_ID]
# Reload-safe and off-means-off: clear any leftover registration from a
# crashed unload before (re)registering — or before storing a local-only
# config (async_unregister is a no-op pop when nothing is registered).
# Runs before the session opens so a raise here cannot leak it.
async_unregister(hass, webhook_id)
target_url = f"http://127.0.0.1:{port}{secret_path}"
session = aiohttp.ClientSession(timeout=_CLIENT_TIMEOUT)
cfg: dict[str, Any] = {
"webhook_id": webhook_id,
"target_url": target_url,
"session": session,
"auth_mode": auth_mode,
"resource_server": None,
}
if register_endpoint:
try:
async_register(
hass,
DOMAIN,
_WEBHOOK_NAME,
webhook_id,
_async_handle_webhook,
allowed_methods=["POST", "GET"],
)
if auth_mode == WEBHOOK_AUTH_HA:
provider = ResourceServer(hass, webhook_id)
_register_metadata_views(hass)
cfg["resource_server"] = provider
except Exception:
# Never leave a live endpoint (or a leaked session) behind a failed
# auth-setup path. suppress: the ORIGINAL error must be what
# propagates (review finding) - a raising cleanup would mask it.
with suppress(Exception):
async_unregister(hass, webhook_id)
with suppress(Exception):
await session.close()
raise
hass.data.setdefault(DOMAIN, {})[DATA_WEBHOOK] = cfg
async def async_unregister_webhook(hass: HomeAssistant) -> None:
"""Unregister the ingress webhook and close its aiohttp session.
Idempotent. The ha_auth discovery views are intentionally left bound (aiohttp
can't unregister them until HA restarts); they 404 while ha_auth is not live.
"""
domain_data = hass.data.get(DOMAIN)
if not isinstance(domain_data, dict):
return
cfg = domain_data.pop(DATA_WEBHOOK, None)
if not isinstance(cfg, dict):
return
webhook_id = cfg.get("webhook_id")
if webhook_id:
async_unregister(hass, webhook_id)
session = cfg.get("session")
if session is not None:
await session.close()
@@ -0,0 +1,188 @@
edit_yaml_config:
name: Edit YAML Config
description: >-
Add, replace, or remove a top-level key in configuration.yaml,
package files, or theme files. Validates YAML, creates backups, and restricts edits
to a whitelist of allowed keys.
fields:
file:
name: File
description: >-
Relative path to the YAML file. Supports configuration.yaml,
packages/*.yaml, and themes/*.yaml.
required: true
example: "configuration.yaml"
selector:
text:
action:
name: Action
description: "Action to perform: add, replace, or remove."
required: true
example: "add"
selector:
select:
options:
- "add"
- "replace"
- "remove"
yaml_path:
name: YAML Path
description: >-
Top-level YAML key to modify (e.g., template, sensor,
binary_sensor) or theme name for themes/*.yaml files. Only whitelisted keys are allowed.
required: true
example: "template"
selector:
text:
content:
name: Content
description: >-
YAML content for the value under yaml_path. Required for add
and replace actions.
required: false
example: "- sensor:\n - name: My Sensor\n state: '{{ now() }}'"
selector:
text:
multiline: true
backup:
name: Backup
description: Create a backup before editing. Default is true.
required: false
default: true
selector:
boolean:
list_files:
name: List Files
description: List files in a directory within the Home Assistant config directory.
fields:
path:
name: Path
description: Relative path from config directory (e.g., "www/", "themes/")
required: true
example: "www/"
selector:
text:
pattern:
name: Pattern
description: Optional glob pattern to filter files (e.g., "*.css", "*.js")
required: false
example: "*.css"
selector:
text:
read_file:
name: Read File
description: Read a file from the Home Assistant config directory.
fields:
path:
name: Path
description: >-
Relative path from config directory. Allowed paths include
configuration.yaml, automations.yaml, scripts.yaml, scenes.yaml,
secrets.yaml (values masked), home-assistant.log, www/**, themes/**,
custom_templates/**, packages/*.yaml, custom_components/**/*.py
required: true
example: "configuration.yaml"
selector:
text:
tail_lines:
name: Tail Lines
description: For log files, return only the last N lines. Default is 1000 for logs.
required: false
example: 100
selector:
number:
min: 1
max: 10000
mode: box
write_file:
name: Write File
description: Write a file to allowed directories (www/, themes/, custom_templates/).
fields:
path:
name: Path
description: >-
Relative path from config directory. Must be in www/, themes/, or
custom_templates/.
required: true
example: "www/custom.css"
selector:
text:
content:
name: Content
description: The content to write to the file.
required: true
example: ".card { background: #333; }"
selector:
text:
multiline: true
overwrite:
name: Overwrite
description: Whether to overwrite if the file already exists. Default is false.
required: false
default: false
selector:
boolean:
create_dirs:
name: Create Directories
description: Whether to create parent directories if they don't exist. Default is true.
required: false
default: true
selector:
boolean:
delete_file:
name: Delete File
description: Delete a file from allowed directories (www/, themes/, custom_templates/).
fields:
path:
name: Path
description: >-
Relative path from config directory. Must be in www/, themes/, or
custom_templates/.
required: true
example: "www/old-file.css"
selector:
text:
get_caller_token:
name: Get Caller Token (Internal)
description: >-
Internal bootstrap service used by the ha-mcp server. Returns the auth
token that the other ha_mcp_tools.* services require in their
`_ha_mcp_token` field. The token is generated on first integration setup
and persisted to .storage. Not intended for direct invocation from
automations or scripts.
fields: {}
get_allowed_paths:
name: Get Allowed Filesystem Paths (Internal)
description: >-
Internal service used by the ha-mcp server settings UI. Returns the
user-configurable extra read/write directories, the built-in allowlists,
and the non-overridable deny floor. Restricted to the ha-mcp server
(requires the `_ha_mcp_token`) and admin auth. Not intended for direct
invocation from automations or scripts.
fields: {}
set_allowed_paths:
name: Set Allowed Filesystem Paths (Internal)
description: >-
Internal service used by the ha-mcp server settings UI. Replaces the
user-configurable extra read/write directories (each granted both read and
write). Entries that use path traversal, are absolute, escape the config
directory, or hit the non-overridable deny floor (e.g. .storage) are
dropped. Restricted to the ha-mcp server (requires the `_ha_mcp_token`) and
admin auth.
fields:
paths:
name: Paths
description: >-
Directories relative to the config directory, each granted read and
write (e.g. "pyscript", "python_scripts"). Replaces the current list.
required: false
example: '["pyscript", "python_scripts"]'
selector:
object:
+123
View File
@@ -0,0 +1,123 @@
{
"config": {
"step": {
"user": {
"title": "HA-MCP Custom Component",
"description": "Choose what to add. **HA-MCP Server** runs the full ha-mcp server inside Home Assistant and exposes it through a Home Assistant webhook - this is the install most people want. **HA MCP Tools** adds the privileged file and YAML editing services, which are only needed if you turn on ha-mcp's opt-in file/YAML tools; you can add it later at any time.",
"menu_options": {
"server": "HA-MCP Server (recommended)",
"tools": "HA MCP Tools (optional file & YAML services)"
}
},
"tools": {
"title": "HA MCP Tools",
"description": "Sets up the privileged file and YAML configuration services. Only needed if you enable ha-mcp's opt-in file/YAML editing tools (feature flags, off by default) - this applies to every server type, including the in-process HA-MCP Server. You can add or remove this entry at any time."
},
"server": {
"title": "HA-MCP Server",
"description": "This runs the full ha-mcp server inside Home Assistant and exposes it remotely through a Home Assistant webhook (reachable via Nabu Casa or any reverse proxy). Select **Submit** to start it; you can change the port, binding, and authentication afterward in the integration options."
}
},
"abort": {
"already_configured": "This entry is already set up.",
"unsupported_home_assistant": "The in-process HA-MCP Server requires Home Assistant {required} or newer, but this instance is running {installed}. You can still use this component's HA MCP Tools entry with an external ha-mcp server running as an add-on or Docker container."
}
},
"options": {
"abort": {
"no_options": "The HA MCP Tools services entry has no options to configure."
},
"step": {
"init": {
"title": "HA-MCP Server",
"description": "Configure the HA-MCP server. {panel_hint}Changes here are applied on save.\n\n{versions}\n\n{connect_url}",
"data": {
"channel": "Release channel",
"auto_update": "Automatic server updates",
"server_port": "MCP server listening port",
"bind_host": "Network access",
"webhook_auth": "Authentication mode",
"pip_spec": "Developer: ha-mcp package override",
"server_url": "Home Assistant URL (advanced)",
"external_url": "External URL (optional)",
"webhook_id_override": "Custom webhook secret (optional)",
"secret_path_override": "Custom direct-access path (optional)",
"regenerate_secrets": "Regenerate connect secrets now",
"enable_webhook": "Remote access via webhook",
"enable_llm_api": "Conversation-agent LLM API",
"llm_api_exposure": "Conversation-agent tool exposure",
"enable_startup_notification": "Startup notification",
"enable_sidebar_panel": "Sidebar settings panel"
},
"data_description": {
"channel": "Stable installs the latest stable release; Development installs the newest development build. When automatic updates are on, a reload or restart, plus a periodic check, install the newest build of the selected channel. A developer package override below takes precedence and disables automatic updates.",
"auto_update": "When on, the newest release of the selected channel is installed automatically - on a reload or restart, and via a periodic check. When off, the server stays on the version currently installed until you turn this back on. This controls the ha-mcp server package only; updates to the HA-MCP Custom Component itself still come through HACS.",
"server_port": "The port this server listens on. The ha-mcp add-on uses 9583, so this defaults to 9584 to let both run side by side - if you don't run the add-on, any free port works.",
"bind_host": "Who can connect to the MCP server port directly. The default matches the add-on: reachable on your local network, with the secret path as the credential. Choose loopback to allow only connections from the Home Assistant machine itself - the webhook URL and the sidebar panel work either way.",
"webhook_auth": "How MCP clients prove themselves at the webhook URL. With the secret URL, the link itself is the credential. With Home Assistant sign-in, clients such as claude.ai log in with a Home Assistant administrator account (OAuth).",
"pip_spec": "Leave empty. Only for testing a specific ha-mcp build (for example a pre-release pin); overrides the release channel and disables automatic updates until cleared.",
"server_url": "The URL the in-process server uses to reach your Home Assistant (usually this instance itself). Leave the default unless you know you need a different route.",
"external_url": "Shown as the primary connect URL - use this when Home Assistant sits behind your own domain or reverse proxy. Enter the full base address including the scheme. It must point directly at Home Assistant - opening it in a browser should reach your HA login page - and must not contain a port such as :8123 (or any other port), or remote MCP clients won't be able to reach it. Leave empty to use Nabu Casa / the local address automatically.",
"webhook_id_override": "Replaces the random webhook secret in the connect URL (/api/webhook/...). The URL is the credential - use a long, hard-to-guess value. Leave empty to keep the current one.",
"secret_path_override": "Replaces the random path used for direct access on the server port. Same rule: the path is the credential. Leave empty to keep the current one.",
"regenerate_secrets": "One-time action: mints a fresh random webhook secret and direct-access path, invalidating the old connect URLs immediately. Also clears the two override fields above.",
"enable_webhook": "Turn off for local-only mode: the Home Assistant webhook is not registered at all, so nothing - including Nabu Casa - can reach the server through Home Assistant. The direct server port and the sidebar panel keep working.",
"enable_llm_api": "Offer the full toolset to Home Assistant conversation agents (OpenAI, Google, Ollama, ...): while enabled, agents can select 'HA-MCP Server' under Control Home Assistant and drive the tools from Assist chat and voice. Enabling only makes it selectable - nothing is exposed until you pick it on an agent. Usage guide: {llm_api_docs_url}",
"llm_api_exposure": "Shape of the toolset offered to conversation agents. Tool search (default) keeps the agent's context small: a compact API with pinned tools plus search/execute meta-tools. Full catalog lists every exposed tool directly - better for large-context models. Both registers the two side by side so each agent picks its own under Control Home Assistant. Per-tool exposure is managed in the HA-MCP settings panel; details: {llm_api_docs_url}",
"enable_startup_notification": "Show a notification each time the server starts, pointing at the admin-only settings surfaces. Turn off to start silently - the connect URLs still appear in the Home Assistant log.",
"enable_sidebar_panel": "Show the HA-MCP settings panel in the sidebar (administrators only). Turn off to remove the sidebar entry - server options stay available on this screen."
}
}
}
},
"issues": {
"server_start_failed": {
"title": "The HA-MCP in-process server failed to start",
"description": "The HA-MCP in-process server could not be started inside Home Assistant:\n\n{detail}\n\nCheck the Home Assistant logs, then reload the integration (or fix the underlying problem and reload) to retry."
},
"server_package_install_failed": {
"title": "The HA-MCP in-process server package could not be installed",
"description": "Installing the ha-mcp package for the in-process server failed:\n\n{detail}\n\nResolve the compatibility or installation problem described above, then reload the integration to retry."
},
"component_outdated": {
"title": "Update the HA-MCP Custom Component via HACS",
"description": "The installed ha-mcp server requires HA-MCP Custom Component {required} or newer, but you have {installed}. Update the component via HACS and restart Home Assistant. The server keeps running in the meantime, but some newer features may not work until the component is updated."
},
"server_update_held": {
"title": "HA-MCP server update waiting for a component update",
"description": "ha-mcp server {latest} is available, but that release also updated the HA-MCP Custom Component (to {shipped}; you are running {running}). To avoid starting a server version the running component has never been tested with, the automatic server update is on hold until the component is updated.\n\nUpdate the component via HACS (open the HA-MCP Custom Component entry and use 'Update information' if no update is shown yet), then restart Home Assistant - the server update installs automatically afterwards. To install the server update anyway, press Install on the HA-MCP server update entity."
},
"legacy_hacs_source": {
"title": "Component installed from the legacy repository",
"description": "HACS is tracking the main ha-mcp server repository for this component, so HACS shows the server's version numbers (7.x) and the server's release notes here instead of the component's own (1.x). Updates keep working, but stay mislabeled this way. To fix: remove this repository from HACS (your integration settings and config entries are kept), add homeassistant-ai/ha-mcp-integration as a custom repository, reinstall the component from it, and restart Home Assistant."
}
},
"selector": {
"server_channel": {
"options": {
"stable": "Stable (recommended)",
"dev": "Development (latest build)"
}
},
"server_webhook_auth": {
"options": {
"none": "Secret webhook URL (default)",
"ha_auth": "Sign in with Home Assistant (OAuth)"
}
},
"llm_api_exposure": {
"options": {
"tool_search": "Tool search (compact, default)",
"full": "Full catalog",
"both": "Both (choose per agent)"
}
}
},
"entity": {
"update": {
"server_update": {
"name": "Update"
}
}
}
}
@@ -0,0 +1,123 @@
{
"config": {
"step": {
"user": {
"title": "HA-MCP Custom Component",
"description": "Choose what to add. **HA-MCP Server** runs the full ha-mcp server inside Home Assistant and exposes it through a Home Assistant webhook - this is the install most people want. **HA MCP Tools** adds the privileged file and YAML editing services, which are only needed if you turn on ha-mcp's opt-in file/YAML tools; you can add it later at any time.",
"menu_options": {
"server": "HA-MCP Server (recommended)",
"tools": "HA MCP Tools (optional file & YAML services)"
}
},
"tools": {
"title": "HA MCP Tools",
"description": "Sets up the privileged file and YAML configuration services. Only needed if you enable ha-mcp's opt-in file/YAML editing tools (feature flags, off by default) - this applies to every server type, including the in-process HA-MCP Server. You can add or remove this entry at any time."
},
"server": {
"title": "HA-MCP Server",
"description": "This runs the full ha-mcp server inside Home Assistant and exposes it remotely through a Home Assistant webhook (reachable via Nabu Casa or any reverse proxy). Select **Submit** to start it; you can change the port, binding, and authentication afterward in the integration options."
}
},
"abort": {
"already_configured": "This entry is already set up.",
"unsupported_home_assistant": "The in-process HA-MCP Server requires Home Assistant {required} or newer, but this instance is running {installed}. You can still use this component's HA MCP Tools entry with an external ha-mcp server running as an add-on or Docker container."
}
},
"options": {
"abort": {
"no_options": "The HA MCP Tools services entry has no options to configure."
},
"step": {
"init": {
"title": "HA-MCP Server",
"description": "Configure the HA-MCP server. {panel_hint}Changes here are applied on save.\n\n{versions}\n\n{connect_url}",
"data": {
"channel": "Release channel",
"auto_update": "Automatic server updates",
"server_port": "MCP server listening port",
"bind_host": "Network access",
"webhook_auth": "Authentication mode",
"pip_spec": "Developer: ha-mcp package override",
"server_url": "Home Assistant URL (advanced)",
"external_url": "External URL (optional)",
"webhook_id_override": "Custom webhook secret (optional)",
"secret_path_override": "Custom direct-access path (optional)",
"regenerate_secrets": "Regenerate connect secrets now",
"enable_webhook": "Remote access via webhook",
"enable_llm_api": "Conversation-agent LLM API",
"llm_api_exposure": "Conversation-agent tool exposure",
"enable_startup_notification": "Startup notification",
"enable_sidebar_panel": "Sidebar settings panel"
},
"data_description": {
"channel": "Stable installs the latest stable release; Development installs the newest development build. When automatic updates are on, a reload or restart, plus a periodic check, install the newest build of the selected channel. A developer package override below takes precedence and disables automatic updates.",
"auto_update": "When on, the newest release of the selected channel is installed automatically - on a reload or restart, and via a periodic check. When off, the server stays on the version currently installed until you turn this back on. This controls the ha-mcp server package only; updates to the HA-MCP Custom Component itself still come through HACS.",
"server_port": "The port this server listens on. The ha-mcp add-on uses 9583, so this defaults to 9584 to let both run side by side - if you don't run the add-on, any free port works.",
"bind_host": "Who can connect to the MCP server port directly. The default matches the add-on: reachable on your local network, with the secret path as the credential. Choose loopback to allow only connections from the Home Assistant machine itself - the webhook URL and the sidebar panel work either way.",
"webhook_auth": "How MCP clients prove themselves at the webhook URL. With the secret URL, the link itself is the credential. With Home Assistant sign-in, clients such as claude.ai log in with a Home Assistant administrator account (OAuth).",
"pip_spec": "Leave empty. Only for testing a specific ha-mcp build (for example a pre-release pin); overrides the release channel and disables automatic updates until cleared.",
"server_url": "The URL the in-process server uses to reach your Home Assistant (usually this instance itself). Leave the default unless you know you need a different route.",
"external_url": "Shown as the primary connect URL - use this when Home Assistant sits behind your own domain or reverse proxy. Enter the full base address including the scheme. It must point directly at Home Assistant - opening it in a browser should reach your HA login page - and must not contain a port such as :8123 (or any other port), or remote MCP clients won't be able to reach it. Leave empty to use Nabu Casa / the local address automatically.",
"webhook_id_override": "Replaces the random webhook secret in the connect URL (/api/webhook/...). The URL is the credential - use a long, hard-to-guess value. Leave empty to keep the current one.",
"secret_path_override": "Replaces the random path used for direct access on the server port. Same rule: the path is the credential. Leave empty to keep the current one.",
"regenerate_secrets": "One-time action: mints a fresh random webhook secret and direct-access path, invalidating the old connect URLs immediately. Also clears the two override fields above.",
"enable_webhook": "Turn off for local-only mode: the Home Assistant webhook is not registered at all, so nothing - including Nabu Casa - can reach the server through Home Assistant. The direct server port and the sidebar panel keep working.",
"enable_llm_api": "Offer the full toolset to Home Assistant conversation agents (OpenAI, Google, Ollama, ...): while enabled, agents can select 'HA-MCP Server' under Control Home Assistant and drive the tools from Assist chat and voice. Enabling only makes it selectable - nothing is exposed until you pick it on an agent. Usage guide: {llm_api_docs_url}",
"llm_api_exposure": "Shape of the toolset offered to conversation agents. Tool search (default) keeps the agent's context small: a compact API with pinned tools plus search/execute meta-tools. Full catalog lists every exposed tool directly - better for large-context models. Both registers the two side by side so each agent picks its own under Control Home Assistant. Per-tool exposure is managed in the HA-MCP settings panel; details: {llm_api_docs_url}",
"enable_startup_notification": "Show a notification each time the server starts, pointing at the admin-only settings surfaces. Turn off to start silently - the connect URLs still appear in the Home Assistant log.",
"enable_sidebar_panel": "Show the HA-MCP settings panel in the sidebar (administrators only). Turn off to remove the sidebar entry - server options stay available on this screen."
}
}
}
},
"issues": {
"server_start_failed": {
"title": "The HA-MCP in-process server failed to start",
"description": "The HA-MCP in-process server could not be started inside Home Assistant:\n\n{detail}\n\nCheck the Home Assistant logs, then reload the integration (or fix the underlying problem and reload) to retry."
},
"server_package_install_failed": {
"title": "The HA-MCP in-process server package could not be installed",
"description": "Installing the ha-mcp package for the in-process server failed:\n\n{detail}\n\nResolve the compatibility or installation problem described above, then reload the integration to retry."
},
"component_outdated": {
"title": "Update the HA-MCP Custom Component via HACS",
"description": "The installed ha-mcp server requires HA-MCP Custom Component {required} or newer, but you have {installed}. Update the component via HACS and restart Home Assistant. The server keeps running in the meantime, but some newer features may not work until the component is updated."
},
"server_update_held": {
"title": "HA-MCP server update waiting for a component update",
"description": "ha-mcp server {latest} is available, but that release also updated the HA-MCP Custom Component (to {shipped}; you are running {running}). To avoid starting a server version the running component has never been tested with, the automatic server update is on hold until the component is updated.\n\nUpdate the component via HACS (open the HA-MCP Custom Component entry and use 'Update information' if no update is shown yet), then restart Home Assistant - the server update installs automatically afterwards. To install the server update anyway, press Install on the HA-MCP server update entity."
},
"legacy_hacs_source": {
"title": "Component installed from the legacy repository",
"description": "HACS is tracking the main ha-mcp server repository for this component, so HACS shows the server's version numbers (7.x) and the server's release notes here instead of the component's own (1.x). Updates keep working, but stay mislabeled this way. To fix: remove this repository from HACS (your integration settings and config entries are kept), add homeassistant-ai/ha-mcp-integration as a custom repository, reinstall the component from it, and restart Home Assistant."
}
},
"selector": {
"server_channel": {
"options": {
"stable": "Stable (recommended)",
"dev": "Development (latest build)"
}
},
"server_webhook_auth": {
"options": {
"none": "Secret webhook URL (default)",
"ha_auth": "Sign in with Home Assistant (OAuth)"
}
},
"llm_api_exposure": {
"options": {
"tool_search": "Tool search (compact, default)",
"full": "Full catalog",
"both": "Both (choose per agent)"
}
}
},
"entity": {
"update": {
"server_update": {
"name": "Update"
}
}
}
}
+705
View File
@@ -0,0 +1,705 @@
"""Admin-only "Open Web UI" access to the in-process server's settings page (#1527).
The in-process ha-mcp server serves its web settings UI on the loopback interface
at ``http://127.0.0.1:<port><secret_path>/settings`` — unreachable from a browser
and guarded only by the secret path. This module gives every install type the
add-on's "Open Web UI" experience: an admin-only sidebar panel ("HA-MCP") that
opens that settings UI through Home Assistant's own HTTP server, so it works over
the Nabu Casa remote URL and never exposes the loopback secret path to the browser.
The sidebar entry is a built-in ``iframe`` panel — the panel type behind
"webpage" dashboards — NOT a custom panel. Home Assistant itself renders the
standard header (with the sidebar menu button) around our page, so navigation
behaves exactly like every other dashboard. The previous custom panel painted a
bare full-height iframe with no chrome, which left iOS companion-app users with
no way back to the HA UI: iOS has no system back button, edge swipes land inside
the iframe where the frontend cannot see them, and the app restores the trapped
route on every relaunch (#1795).
Auth model — an iframe panel navigation is a browser GET that carries no
``Authorization`` header, so Home Assistant's normal ``requires_auth`` cannot gate
it. HA's signed-path helper (:func:`homeassistant.components.http.async_sign_path`)
is also unusable: a signature binds ONE exact path + query string, but the settings
app issues relative ``./api/settings/*`` fetches that drop the query — each would
land on a different, unsigned path and 401. Instead:
1. The panel's iframe loads :class:`_BootView`, a tiny same-origin bootstrap
page (public glue, no secrets). Being same-origin with the authenticated
frontend, it reads the logged-in user's access token from the parent frame's
``home-assistant`` root element and POSTs it to the session endpoint below.
2. :class:`_SessionView` (``requires_auth=True``) authenticates that token the
normal way, refuses non-admins, and returns a short-lived HttpOnly,
SameSite=Strict session cookie scoped to the proxy path.
3. The boot page then embeds ``…/ui/app/settings`` in an inner iframe. The
browser attaches the cookie to every same-origin request under the proxy
path — including the settings app's relative sub-fetches — so the whole app
works unchanged.
4. :class:`_ProxyView` (``requires_auth=False`` because the iframe cannot send a
bearer) validates that cookie against a live admin user on every request and
forwards to the loopback settings server.
The proxy reuses the server's loopback forwarding config + aiohttp session
(``hass.data[DOMAIN][DATA_WEBHOOK]`` — stored whenever the server is running,
even when the public webhook endpoint is disabled), so it is available exactly
while the server is running and returns 503 otherwise.
"""
from __future__ import annotations
import logging
import secrets
import time
from typing import TYPE_CHECKING, Any
import aiohttp
from aiohttp import web
from homeassistant.components.http import HomeAssistantView
from homeassistant.core import HomeAssistant
from .const import DATA_WEBHOOK, DOMAIN
if TYPE_CHECKING:
from homeassistant.helpers.typing import ConfigType
_LOGGER = logging.getLogger(__name__)
# Sidebar panel identity. The url_path is the frontend route (…/ha-mcp).
# "HA-MCP" (not "MCP Server") avoids confusion with HA's official MCP Server
# integration.
PANEL_URL_PATH = "ha-mcp"
PANEL_TITLE = "HA-MCP"
PANEL_ICON = "mdi:robot-happy-outline"
# HTTP surface, all under one base so the session cookie can be tightly scoped.
_UI_BASE = "/api/ha_mcp_tools/ui"
_BOOT_URL = f"{_UI_BASE}/boot"
_SESSION_URL = f"{_UI_BASE}/session"
_APP_PREFIX = f"{_UI_BASE}/app/"
_PROXY_URL = f"{_UI_BASE}/app/{{path:.*}}"
# Session cookie. HttpOnly so page JS can never read it; SameSite=Strict so it
# rides only same-site requests (the iframe is same-origin with the frontend);
# path-scoped to the proxy so it is never sent to the boot/session endpoints.
_COOKIE_NAME = "ha_mcp_tools_ui_session"
_COOKIE_PATH = f"{_UI_BASE}/app"
# Session lifetime. Short by design; the panel re-mints well within it while open.
_SESSION_TTL_SECONDS = 8 * 60 * 60
# Top-level hass.data keys. Both must survive config-entry teardown: aiohttp
# cannot unregister a bound view, so the views (and the sessions they validate)
# outlive a reload / re-enable of the entry.
_VIEWS_REGISTERED_KEY = "ha_mcp_tools_ui_views_registered"
_SESSIONS_KEY = "ha_mcp_tools_ui_sessions"
# Request headers never forwarded to the loopback server. Hop-by-hop plus the
# browser's cookie/authorization (the loopback server has no auth on the secret
# path and must not receive the session cookie or the frontend bearer).
_STRIPPED_REQUEST_HEADERS = frozenset(
{
"host",
"content-length",
"transfer-encoding",
"connection",
"cookie",
"authorization",
}
)
# Response headers recomputed by aiohttp on the way out, or invalid once the body
# has been transparently decompressed by ``resp.read()``. Everything else
# (Content-Type, Cache-Control, …) passes through so the settings app behaves
# exactly as when reached directly.
_STRIPPED_RESPONSE_HEADERS = frozenset(
{
"transfer-encoding",
"connection",
"content-length",
"content-encoding",
"keep-alive",
}
)
# ---------------------------------------------------------------------------
# Session store (server-side; no secret ever placed in a URL)
# ---------------------------------------------------------------------------
def _sessions(hass: HomeAssistant) -> dict[str, dict[str, Any]]:
"""Return the token → ``{user_id, expires}`` store, creating it once."""
store = hass.data.get(_SESSIONS_KEY)
if not isinstance(store, dict):
store = {}
hass.data[_SESSIONS_KEY] = store
return store
def _prune_expired(store: dict[str, dict[str, Any]], now: float) -> None:
"""Drop expired sessions so the store cannot grow without bound."""
for token in [t for t, s in store.items() if s["expires"] <= now]:
del store[token]
def _mint_session(hass: HomeAssistant, user_id: str) -> str:
"""Create and store a new session token for ``user_id``; return the token."""
store = _sessions(hass)
now = time.monotonic()
_prune_expired(store, now)
token = secrets.token_urlsafe(32)
store[token] = {"user_id": user_id, "expires": now + _SESSION_TTL_SECONDS}
return token
async def _session_user_is_admin(hass: HomeAssistant, token: str | None) -> bool:
"""Return True iff ``token`` maps to a live, still-admin user session.
Re-checks the user's admin flag on every request so revoking admin (or the
user) takes effect immediately, not only when the session expires. A stale or
demoted session is dropped so it cannot be retried.
"""
if not token:
return False
store = _sessions(hass)
now = time.monotonic()
_prune_expired(store, now)
session = store.get(token)
if session is None:
return False
user = await hass.auth.async_get_user(session["user_id"])
if (
user is None
or getattr(user, "system_generated", False)
or not getattr(user, "is_active", False)
or not getattr(user, "is_admin", False)
):
# Same acceptance bar as the ha_auth webhook gate (review finding:
# the two admin gates must not drift): active, human, administrator.
store.pop(token, None)
return False
return True
# ---------------------------------------------------------------------------
# Views
# ---------------------------------------------------------------------------
class _BootView(HomeAssistantView):
"""Serve the bootstrap page the iframe panel embeds (public glue, no secrets).
``requires_auth`` is False because the panel's iframe loads this with a plain
GET that cannot attach a bearer. The page contains only the bootstrap that
mints a session (with the token it reads from the parent frontend frame) and
embeds the proxied settings app.
"""
requires_auth = False
cors_allowed = False
url = _BOOT_URL
name = "ha_mcp_tools:ui:boot"
async def get(self, request: web.Request) -> web.Response:
"""Return the bootstrap HTML page."""
return web.Response(
body=_BOOT_HTML.encode("utf-8"),
content_type="text/html",
charset="utf-8",
headers={"Cache-Control": "no-cache"},
)
class _SessionView(HomeAssistantView):
"""Mint a short-lived session cookie for an authenticated admin user.
``requires_auth`` is True, so Home Assistant validates the frontend's bearer
before this runs. The extra admin check refuses non-admins (the panel is
admin-only, and the settings UI can change privileged server settings).
"""
requires_auth = True
cors_allowed = False
url = _SESSION_URL
name = "ha_mcp_tools:ui:session"
async def post(self, request: web.Request) -> web.Response:
"""Issue the session cookie, or 403 for a non-admin caller."""
user = request.get("hass_user")
if user is None or not getattr(user, "is_admin", False):
return web.json_response({"error": "admin_required"}, status=403)
token = _mint_session(request.app["hass"], user.id)
response = web.json_response({"ttl": _SESSION_TTL_SECONDS})
response.set_cookie(
_COOKIE_NAME,
token,
max_age=_SESSION_TTL_SECONDS,
path=_COOKIE_PATH,
httponly=True,
samesite="Strict",
secure=_request_is_https(request),
)
return response
class _ProxyView(HomeAssistantView):
"""Forward settings-UI traffic to the loopback server for a valid session.
``requires_auth`` is False because the iframe (and its relative sub-fetches)
cannot send a bearer; the session cookie minted by :class:`_SessionView` is
the credential and is re-validated against a live admin user on every request.
Returns 503 while the server is not running and 401 without a valid session.
"""
requires_auth = False
cors_allowed = False
url = _PROXY_URL
name = "ha_mcp_tools:ui:proxy"
async def get(self, request: web.Request, path: str) -> web.StreamResponse:
"""Proxy a GET (the settings page and read endpoints)."""
return await self._forward(request, path)
async def post(self, request: web.Request, path: str) -> web.StreamResponse:
"""Proxy a POST (save endpoints)."""
return await self._forward(request, path)
async def put(self, request: web.Request, path: str) -> web.StreamResponse:
"""Proxy a PUT (policy-config writes)."""
return await self._forward(request, path)
async def delete(self, request: web.Request, path: str) -> web.StreamResponse:
"""Proxy a DELETE (backup deletion)."""
return await self._forward(request, path)
async def _forward(self, request: web.Request, path: str) -> web.StreamResponse:
"""Validate the session, then forward to the loopback settings server."""
hass: HomeAssistant = request.app["hass"]
if not await _session_user_is_admin(hass, request.cookies.get(_COOKIE_NAME)):
return web.Response(status=401, text="Unauthorized")
# Defense in depth: never let a crafted path escape the secret-path
# prefix on the loopback server (the caller is already an admin, so this
# only blocks confusing requests, but it keeps the target well-formed).
if any(segment == ".." for segment in path.split("/")):
return web.Response(status=400, text="Bad request")
cfg = _webhook_cfg(hass)
if cfg is None:
return web.Response(status=503, text="The MCP server is not running")
target = f"{cfg['target_url']}/{path}"
if request.query_string:
target = f"{target}?{request.query_string}"
session: aiohttp.ClientSession = cfg["session"]
body = await request.read()
forward_headers = {
key: value
for key, value in request.headers.items()
if key.lower() not in _STRIPPED_REQUEST_HEADERS
}
try:
async with session.request(
method=request.method,
url=target,
headers=forward_headers,
data=body if body else None,
) as upstream:
return await _relay_response(request, upstream)
except aiohttp.ClientError as err:
_LOGGER.error("HA-MCP settings proxy: upstream request failed: %s", err)
return web.Response(status=502, text="MCP settings server unavailable")
except Exception as err:
_LOGGER.exception("HA-MCP settings proxy: unexpected error: %s", err)
return web.Response(status=500, text="MCP settings server error")
async def _relay_response(
request: web.Request, upstream: aiohttp.ClientResponse
) -> web.StreamResponse:
"""Relay the loopback response, streaming when it is an event stream.
The loopback server is our own trusted process, so — unlike the MCP webhook —
the Content-Type is passed through unchanged (the settings page is text/html,
the API endpoints are JSON): coercing it would break the page.
"""
content_type = upstream.headers.get("Content-Type", "")
headers = {
key: value
for key, value in upstream.headers.items()
if key.lower() not in _STRIPPED_RESPONSE_HEADERS
}
if "text/event-stream" in content_type:
headers["Cache-Control"] = "no-cache, no-transform"
headers["X-Accel-Buffering"] = "no"
response = web.StreamResponse(status=upstream.status, headers=headers)
await response.prepare(request)
try:
async for chunk in upstream.content.iter_any():
await response.write(chunk)
except aiohttp.ClientError as err:
_LOGGER.error("HA-MCP settings proxy: upstream dropped mid-stream: %s", err)
with _suppress_connection_reset():
await response.write_eof()
return response
return web.Response(
status=upstream.status, body=await upstream.read(), headers=headers
)
# ---------------------------------------------------------------------------
# Registration / teardown
# ---------------------------------------------------------------------------
async def async_register_ui_panel(hass: HomeAssistant) -> None:
"""Register the settings-UI proxy views (once) and the sidebar panel.
Called from the server entry's setup. The views resolve the running server
from ``hass.data`` per request, so they are bound once per HA session and
reused across reloads; the panel is (re)added here and removed on unload.
Any failure is logged and swallowed — a frontend hiccup must never block the
config entry from loading.
"""
try:
_register_views(hass)
await _register_panel(hass)
except Exception:
_LOGGER.exception("HA-MCP: failed to register the settings-UI panel")
def async_unregister_ui_panel(hass: HomeAssistant) -> None:
"""Remove the sidebar panel on entry unload (the views stay bound).
aiohttp cannot unregister the views; they return 503 once the server is no
longer running, so removing the sidebar entry is enough to reflect the
paused/removed state.
"""
from homeassistant.components.frontend import async_remove_panel
with _suppress_all():
async_remove_panel(hass, PANEL_URL_PATH, warn_if_unknown=False)
def _register_views(hass: HomeAssistant) -> None:
"""Bind the boot / session / proxy views at most once per HA session."""
if hass.data.get(_VIEWS_REGISTERED_KEY):
return
hass.http.register_view(_BootView())
hass.http.register_view(_SessionView())
hass.http.register_view(_ProxyView())
hass.data[_VIEWS_REGISTERED_KEY] = True
async def _register_panel(hass: HomeAssistant) -> None:
"""Add the admin-only sidebar panel if it is not already present.
Registered as a built-in ``iframe`` panel (the "webpage dashboard" panel
type): Home Assistant renders its standard header around the page, so the
panel can never trap navigation the way a chrome-less custom panel did on
iOS (#1795).
"""
from homeassistant.components.frontend import (
async_panel_exists,
async_register_built_in_panel,
)
if async_panel_exists(hass, PANEL_URL_PATH):
return
cfg = panel_config()
async_register_built_in_panel(hass, cfg.pop("component_name"), **cfg)
# ---------------------------------------------------------------------------
# Small helpers
# ---------------------------------------------------------------------------
def _webhook_cfg(hass: HomeAssistant) -> dict[str, Any] | None:
"""Return the running server's forwarding config, or None when it is down."""
domain_data = hass.data.get(DOMAIN)
if not isinstance(domain_data, dict):
return None
cfg = domain_data.get(DATA_WEBHOOK)
return cfg if isinstance(cfg, dict) else None
def _request_is_https(request: web.Request) -> bool:
"""Return True when the request reached HA over HTTPS (honoring the proxy)."""
forwarded = request.headers.get("X-Forwarded-Proto")
return bool((forwarded or request.scheme) == "https")
class _suppress_connection_reset:
"""Swallow a ConnectionResetError from a client that closed mid-stream."""
def __enter__(self) -> None:
return None
def __exit__(self, exc_type: Any, exc: Any, tb: Any) -> bool:
return exc_type is not None and issubclass(exc_type, ConnectionResetError)
class _suppress_all:
"""Swallow any Exception from best-effort teardown, logged at WARNING."""
def __enter__(self) -> None:
return None
def __exit__(self, exc_type: Any, exc: Any, tb: Any) -> bool:
if exc_type is None or not issubclass(exc_type, Exception):
return False # never swallow KeyboardInterrupt/SystemExit
_LOGGER.warning("HA-MCP: settings-UI panel teardown error", exc_info=exc)
return True
# ---------------------------------------------------------------------------
# Bootstrap page (embedded by the built-in iframe panel)
# ---------------------------------------------------------------------------
#
# Plain same-origin page (no Lit / HA-frontend imports) so it never couples to a
# specific frontend build. Home Assistant's own iframe panel draws the standard
# header around it; this page only mints the session and embeds the proxied
# settings app. The script is a separate string so the node syntax test can
# parse it alone. Deliberately NOT registered in _js_harness._PY_RENDERERS
# (importing this module needs Home Assistant installed, which would break the
# harness for every surface); coverage = the node --check syntax test plus the
# Python-side session/proxy tests in test_ui_panel.py.
_BOOT_JS = f"""
const SESSION_URL = {_SESSION_URL!r};
const APP_URL = {_APP_PREFIX!r} + "settings";
// Re-mint at half the cookie lifetime so an open panel never expires mid-use.
const REFRESH_MS = {_SESSION_TTL_SECONDS // 2} * 1000;
// While the frontend is still booting (a cold start straight into this panel),
// the parent frame has no token yet -- poll gently until it does (local reads,
// no network). After TOKEN_HINT_AFTER misses, surface a hint but keep polling.
const TOKEN_RETRY_MS = 1000;
const TOKEN_HINT_AFTER = 20;
// Transient failures (network blip, server starting/restarting) retry on
// their own. Auth refusals (401/403) never auto-retry: every rejected bearer
// counts as a failed login for http.ban, and a retry loop got users IP-banned
// from their own instance (#1802).
const RETRY_MS = 5000;
const FETCH_TIMEOUT_MS = 15000;
const msg = document.querySelector(".msg");
const frame = document.querySelector("iframe");
let timer = null;
let busy = false;
let tokenMisses = 0;
let authDead = false;
function showMessage(text, isError) {{
frame.classList.add("hidden");
msg.classList.remove("hidden");
// Failure messages announce assertively (style guide: status regions switch
// to role=alert on the failure path); benign progress stays polite.
msg.setAttribute("role", isError ? "alert" : "status");
msg.setAttribute("aria-live", isError ? "assertive" : "polite");
msg.textContent = text;
}}
function transientFailure(text) {{
// Keep an already-working app visible through a transient blip (the iframe
// holds state); only surface the message while nothing is showing yet.
if (!timer) {{
showMessage(text, true);
}}
setTimeout(mint, RETRY_MS);
}}
function fetchWithTimeout(url, options) {{
// A stalled (never-settling) fetch would wedge `busy` and silently stop all
// future re-mints; a timeout resolves it into the retry path instead.
const controller = new AbortController();
const t = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
return fetch(url, Object.assign({{ signal: controller.signal }}, options)).finally(
() => clearTimeout(t)
);
}}
async function token() {{
// Same-origin parent = the authenticated HA frontend. Its root element owns
// the live `hass` object the frontend keeps fresh; its auth.accessToken is
// the same bearer the frontend itself uses. Refresh an expired token before
// use -- POSTing a stale bearer counts as a failed login for http.ban
// (#1802). A failed refresh means the sign-in itself is dead: mark it
// terminal rather than looping.
try {{
if (window.parent === window) return null;
const root = window.parent.document.querySelector("home-assistant");
const auth = root && root.hass && root.hass.auth;
if (!auth) return null;
if (auth.expired && typeof auth.refreshAccessToken === "function") {{
try {{
await auth.refreshAccessToken();
}} catch (err) {{
authDead = true;
return null;
}}
}}
return auth.accessToken || (auth.data && auth.data.access_token) || null;
}} catch (err) {{
return null; // cross-origin parent: not embedded in the HA frontend
}}
}}
async function mint() {{
if (busy) return;
busy = true;
try {{
const bearer = await token();
if (!bearer) {{
if (authDead) {{
showMessage(
"The Home Assistant sign-in has expired. Reload the page to try again.",
true
);
return;
}}
if (window.parent === window) {{
showMessage("Open this page from the HA-MCP entry in the Home Assistant sidebar.");
return;
}}
tokenMisses += 1;
if (tokenMisses === TOKEN_HINT_AFTER) {{
showMessage(
"Still waiting for the Home Assistant sign-in. If this page is not " +
"inside the Home Assistant frontend, open it from the HA-MCP " +
"sidebar entry."
);
}}
setTimeout(mint, TOKEN_RETRY_MS);
return;
}}
tokenMisses = 0;
let resp;
try {{
resp = await fetchWithTimeout(SESSION_URL, {{
method: "POST",
credentials: "same-origin",
headers: {{ Authorization: "Bearer " + bearer }},
}});
}} catch (err) {{
transientFailure("Could not reach Home Assistant to open the settings UI.");
return;
}}
if (resp.status === 401) {{
// Never loop on a rejected bearer -- see the RETRY_MS note (#1802).
showMessage(
"Home Assistant rejected the sign-in token. Reload the page to try again.",
true
);
return;
}}
if (resp.status === 403) {{
showMessage("The HA-MCP settings UI is available to administrators only.", true);
return;
}}
if (!resp.ok) {{
transientFailure("Could not open the settings UI (HTTP " + resp.status + ").");
return;
}}
await showApp();
}} finally {{
busy = false;
}}
}}
async function showApp() {{
// Probe the proxy so a not-yet-running server shows a friendly message
// instead of a raw 503 page inside the iframe.
let probe;
try {{
probe = await fetchWithTimeout(APP_URL, {{ credentials: "same-origin" }});
}} catch (err) {{
transientFailure("Could not reach Home Assistant to load the settings UI.");
return;
}}
if (probe.status === 503) {{
if (!timer) {{
showMessage(
"The in-process MCP server is starting or is not running yet. " +
"This view will refresh automatically."
);
}}
setTimeout(mint, RETRY_MS);
return;
}}
if (!probe.ok) {{
transientFailure("The settings UI returned HTTP " + probe.status + ".");
return;
}}
if (frame.getAttribute("src") !== APP_URL) {{
frame.setAttribute("src", APP_URL);
}}
msg.classList.add("hidden");
frame.classList.remove("hidden");
if (!timer) {{
timer = setInterval(mint, REFRESH_MS);
}}
}}
mint();
"""
_BOOT_HTML = f"""<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>HA-MCP settings</title>
<style>
html, body {{ height: 100%; margin: 0; background: #fafafa; }}
main {{ height: 100%; outline: none; }}
iframe {{ width: 100%; height: 100%; border: 0; display: block; }}
.msg {{
padding: 24px; max-width: 640px; margin: 0 auto; box-sizing: border-box;
font-family: Roboto, sans-serif; color: #212121;
}}
.hidden {{ display: none; }}
@media (prefers-color-scheme: dark) {{
html, body {{ background: #111111; }}
.msg {{ color: #e1e1e1; }}
}}
</style>
</head>
<body>
<main id="main-content" tabindex="-1">
<div class="msg" role="status" aria-live="polite">Loading the HA-MCP settings UI…</div>
<iframe class="hidden" title="HA-MCP settings"></iframe>
</main>
<script>
{_BOOT_JS}
</script>
</body>
</html>
"""
def render_boot_script() -> str:
"""Return the boot-page script source (used by the JS-parse tests)."""
return _BOOT_JS
def render_boot_page() -> str:
"""Return the boot-page HTML (used by the tests)."""
return _BOOT_HTML
def panel_config() -> ConfigType:
"""Return the sidebar-panel registration parameters (registration + tests)."""
return {
"component_name": "iframe",
"frontend_url_path": PANEL_URL_PATH,
"sidebar_title": PANEL_TITLE,
"sidebar_icon": PANEL_ICON,
"config": {"url": _BOOT_URL},
"require_admin": True,
}
+222
View File
@@ -0,0 +1,222 @@
"""Update platform for the in-process server package (issue #1760).
Exposes one ``update`` entity per "server" config entry for the ha-mcp server
package it runs in-process, backed by :class:`~.coordinator.ServerVersionCoordinator`.
The entity stays populated whether or not automatic updates are on - see the
coordinator's docstring for why.
"""
from __future__ import annotations
import asyncio
import logging
from typing import TYPE_CHECKING, Any
from aiohttp import ClientError
from awesomeversion import AwesomeVersion, AwesomeVersionException
from homeassistant.components.update import UpdateEntity, UpdateEntityFeature
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import (
DATA_BRINGUP_TASK,
DATA_PENDING_INSTALL_VERSION,
DATA_UPDATE_COORDINATOR,
DEFAULT_AUTO_UPDATE,
DIST_NAME_DEV,
DOMAIN,
OPT_AUTO_UPDATE,
)
from .coordinator import ServerVersionCoordinator
from .embedded_server import _installed_dist_version
if TYPE_CHECKING:
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
_LOGGER = logging.getLogger(__name__)
# GitHub releases API for the release-notes surface (stable channel only - the
# dev channel has no tagged releases, see release_url / supported_features).
_RELEASES_URL = (
"https://api.github.com/repos/homeassistant-ai/ha-mcp/releases?per_page=30"
)
_RELEASE_NOTES_TIMEOUT_SECONDS = 15
async def async_setup_entry(
hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
) -> None:
"""Add the single server-package update entity for this config entry."""
coordinator: ServerVersionCoordinator = hass.data[DOMAIN][DATA_UPDATE_COORDINATOR]
async_add_entities([ServerUpdateEntity(coordinator, entry)])
class ServerUpdateEntity(CoordinatorEntity[ServerVersionCoordinator], UpdateEntity):
"""Update entity for the ha-mcp server package the "server" entry runs."""
_attr_has_entity_name = True
_attr_translation_key = "server_update"
def __init__(
self, coordinator: ServerVersionCoordinator, entry: ConfigEntry
) -> None:
"""Bind to the coordinator and the owning config entry."""
super().__init__(coordinator)
self._entry = entry
self._attr_unique_id = f"{entry.entry_id}_server_update"
@property
def device_info(self) -> DeviceInfo:
"""Group under one device per config entry; sw_version = installed."""
return DeviceInfo(
identifiers={(DOMAIN, self._entry.entry_id)},
name="HA-MCP Server",
manufacturer="homeassistant-ai",
model="ha-mcp (in-process server)",
sw_version=self.installed_version,
configuration_url="https://github.com/homeassistant-ai/ha-mcp",
)
@property
def installed_version(self) -> str | None:
"""Return the installed server-package version, or None if unknown."""
data = self.coordinator.data
return data.installed if data is not None else None
@property
def latest_version(self) -> str | None:
"""Return the newest PyPI version, or None if unknown/unresolvable."""
data = self.coordinator.data
return data.latest if data is not None else None
@property
def auto_update(self) -> bool:
"""Reflect the entry's automatic-update option."""
return bool(self._entry.options.get(OPT_AUTO_UPDATE, DEFAULT_AUTO_UPDATE))
@property
def release_url(self) -> str | None:
"""Stable: the tagged GitHub release. Dev: the commit history (no tags)."""
data = self.coordinator.data
if data is None:
return None
if data.dist == DIST_NAME_DEV:
return "https://github.com/homeassistant-ai/ha-mcp/commits/master"
if data.latest is None:
return None
return f"https://github.com/homeassistant-ai/ha-mcp/releases/tag/v{data.latest}"
@property
def supported_features(self) -> UpdateEntityFeature:
"""RELEASE_NOTES only on the stable channel — dev builds have no tags."""
features = UpdateEntityFeature.INSTALL
data = self.coordinator.data
if data is not None and data.dist != DIST_NAME_DEV:
features |= UpdateEntityFeature.RELEASE_NOTES
return features
async def async_release_notes(self) -> str | None:
"""Concatenate GitHub release bodies between installed and latest.
Advisory-only (same reasoning as embedded_setup's
_async_check_component_compat): a GitHub fetch failure, rate limit, or
unexpected payload shape must degrade to None - the UI then falls back
to :attr:`release_url` - rather than break the update dialog.
"""
data = self.coordinator.data
if data is None or data.installed is None or data.latest is None:
return None
try:
installed = AwesomeVersion(data.installed)
latest = AwesomeVersion(data.latest)
session = async_get_clientsession(self.hass)
async with asyncio.timeout(_RELEASE_NOTES_TIMEOUT_SECONDS):
async with session.get(_RELEASES_URL) as resp:
resp.raise_for_status()
releases = await resp.json()
notes: list[tuple[AwesomeVersion, str]] = []
for release in releases:
tag = str(release.get("tag_name") or "").removeprefix("v")
try:
version = AwesomeVersion(tag)
except AwesomeVersionException:
continue
if installed < version <= latest:
notes.append((version, str(release.get("body") or "")))
except (ClientError, TimeoutError) as err:
# Expected transients (GitHub unreachable, rate-limited, slow) —
# quiet; the dialog falls back to release_url.
_LOGGER.debug("HA-MCP release-notes fetch failed: %s", err)
return None
except Exception:
# An unexpected payload shape (TypeError/AttributeError in the
# parse loop) is a bug or a GitHub API change — logged visibly per
# the repo's convention (review finding), still degrading to the
# release_url fallback rather than breaking the update dialog.
_LOGGER.warning("HA-MCP release-notes fetch failed", exc_info=True)
return None
if not notes:
return None
notes.sort(key=lambda item: item[0], reverse=True)
return "\n\n---\n\n".join(body for _, body in notes)
async def async_install(
self, version: str | None, backup: bool, **kwargs: Any
) -> None:
"""Reinstall pinned to ``version`` (or the latest known build).
With auto-update off, ``_resolve_pip_spec`` pins the install to the
currently-installed version, so a bare reload would just reinstall the
same build. The one-shot pending-install marker overrides that pin for
this single reload; embedded_server clears it when it consumes it (one
marker buys one attempt). The reload only completes entry SETUP — the
pip install runs in the reloaded entry's background bring-up — so this
waits for that bring-up and verifies the requested version actually
landed; returning at reload time would report success for an install
that can still fail (review finding).
"""
data = self.coordinator.data
target = version or self.latest_version
if target is None:
raise HomeAssistantError("No target version available to install.")
# Broad except is intentional here (unlike this repo's usual narrow
# convention): async_install feeds Home Assistant's update UI, which
# expects a HomeAssistantError for ANY failure rather than an opaque
# traceback in the install dialog. Logged with traceback first so a
# genuine bug still reaches the log (review finding).
try:
new_data = {**self._entry.data, DATA_PENDING_INSTALL_VERSION: target}
self.hass.config_entries.async_update_entry(self._entry, data=new_data)
await self.hass.config_entries.async_reload(self._entry.entry_id)
# The reloaded entry's bring-up task does the actual install; it
# contains its own failures (files repair issues instead of
# raising), so awaiting it tells us the attempt is over, not that
# it worked — the version read below is the success check.
bringup = self.hass.data.get(DOMAIN, {}).get(DATA_BRINGUP_TASK)
if bringup is not None:
await bringup
installed: str | None = None
if data is not None:
installed = await self.hass.async_add_executor_job(
_installed_dist_version, data.dist
)
except Exception as err:
_LOGGER.exception("HA-MCP server update install failed")
raise HomeAssistantError(
f"Could not install the HA-MCP server update: {err}"
) from err
# Outside the broad except: these raises must reach the UI as-is, not
# get re-wrapped into the generic message.
if data is not None and installed != target:
raise HomeAssistantError(
f"The HA-MCP server update to {target} did not complete "
f"(installed: {installed or 'none'}). See Settings > Repairs "
"for the failure details."
)
File diff suppressed because it is too large Load Diff
+173
View File
@@ -0,0 +1,173 @@
"""ruamel.yaml round-trip helpers preserving comments and HA custom tags."""
from __future__ import annotations
import re
import threading
from collections.abc import Callable
from io import StringIO
from typing import Any
from ruamel.yaml import YAML
class _TaggedScalar:
"""Wrapper that stores a YAML tag + scalar value for lossless round-trip."""
__slots__ = ("tag", "value")
def __init__(self, tag: str, value: str) -> None:
self.tag = tag
self.value = value
def __repr__(self) -> str:
return f"_TaggedScalar({self.tag!r}, {self.value!r})"
def __str__(self) -> str:
return self.value
def __eq__(self, other: object) -> bool:
if not isinstance(other, _TaggedScalar):
return NotImplemented
return self.tag == other.tag and self.value == other.value
def __hash__(self) -> int:
return hash((self.tag, self.value))
_HA_TAGS = (
"!include",
"!include_dir_list",
"!include_dir_named",
"!include_dir_merge_list",
"!include_dir_merge_named",
"!secret",
"!env_var",
)
def _make_tag_constructor(tag: str) -> Callable[[Any, Any], _TaggedScalar]:
"""Return a ruamel.yaml constructor function for *tag*."""
def _construct(loader: Any, node: Any) -> _TaggedScalar:
return _TaggedScalar(tag, loader.construct_scalar(node))
return _construct
def _represent_tagged_scalar(dumper: Any, data: _TaggedScalar) -> Any:
"""Representer that emits the original tag + scalar value."""
return dumper.represent_scalar(data.tag, data.value)
def _register_ha_tags() -> None:
"""Register HA tag constructors/representers on the shared class registries.
``add_constructor`` / ``add_representer`` mutate class-level registries
shared by all ``YAML(typ="rt")`` instances. We call this once at import
time; ``make_yaml()`` then only creates a fresh (thread-safe) instance.
"""
# Use a temporary instance to access the Constructor/Representer classes
_tmp = YAML(typ="rt")
for tag in _HA_TAGS:
_tmp.Constructor.add_constructor(tag, _make_tag_constructor(tag))
_tmp.Representer.add_representer(_TaggedScalar, _represent_tagged_scalar)
_register_ha_tags()
# Effectively-infinite emitter line width. ruamel's default (~80 columns)
# re-wraps long lines on dump; inside a ``>`` folded scalar a new wrap
# adjacent to a more-indented line becomes a LITERAL newline on re-parse,
# silently corrupting string literals in blocks an edit never touched
# (#1720). Never introducing new wraps also keeps untouched long lines
# byte-stable across edits.
_NEVER_WRAP_WIDTH = 2**31
# A top-level mapping key: starts at column 0, `key:` with nothing (or a
# comment) after the colon. Quoted/exotic keys never match — detection
# then just falls back to the default style, which is safe.
_TOP_LEVEL_KEY_RE = re.compile(r"^[A-Za-z0-9_][^\s:]*:\s*(?:#.*)?$")
_DASH_RE = re.compile(r"^( *)- ")
# ruamel's compact defaults for block sequences (dash at the parent
# column). Used to RESET the shared per-thread instance between dumps.
_DEFAULT_SEQ_STYLE = (2, 0)
def detect_seq_indent(text: str) -> tuple[int, int] | None:
"""Detect the file's top-level block-sequence style.
Returns ``(sequence, offset)`` for ``YAML.indent()`` — derived from
the first list item that directly follows a top-level key — or
``None`` when the file has no such sequence. Only top-level
sequences discriminate: nested dashes are indented in BOTH styles.
"""
lines = text.splitlines()
for i, line in enumerate(lines):
if not _TOP_LEVEL_KEY_RE.match(line):
continue
for nxt in lines[i + 1 :]:
if not nxt.strip() or nxt.lstrip().startswith("#"):
continue
m = _DASH_RE.match(nxt)
if m:
offset = len(m.group(1))
return (offset + 2, offset)
break # value is not a sequence — try the next top-level key
return None
def apply_seq_indent(ry: YAML, style: tuple[int, int] | None) -> None:
"""Apply a detected sequence style (or the compact default) to *ry*.
``make_yaml()`` instances are cached per-thread, so the style MUST be
(re)applied before every dump — passing ``None`` resets to the
default instead of leaking the previous file's style.
"""
sequence, offset = style if style is not None else _DEFAULT_SEQ_STYLE
ry.indent(mapping=2, sequence=sequence, offset=offset)
def _build_yaml() -> YAML:
"""Create a fresh round-trip YAML instance with HA tag support."""
ry = YAML(typ="rt")
ry.preserve_quotes = True
ry.width = _NEVER_WRAP_WIDTH
return ry
class _YAMLStorage(threading.local):
"""Thread-local storage for ruamel.yaml instances."""
def __init__(self) -> None:
self.yaml = _build_yaml()
_STORAGE = _YAMLStorage()
def make_yaml() -> YAML:
"""Return a round-trip YAML instance with HA tag support.
The instance is cached per-thread to prevent ruamel.yaml from performing
expensive plugin discovery (glob/scandir) on every call, which
causes CPU spikes and event loop blocking during bulk edits.
Thread-local storage is used because ruamel.yaml instances are not
thread-safe.
"""
try:
return _STORAGE.yaml
except AttributeError:
_STORAGE.yaml = _build_yaml()
return _STORAGE.yaml
def yaml_dumps(ry: YAML, data: Any) -> str:
"""Dump *data* to a string using the given YAML instance."""
buf = StringIO()
ry.dump(data, buf)
return buf.getvalue()