112 files

This commit is contained in:
Home Assistant Version Control committed 2026-09-18 15:36:46 +00:00
1 parent 94867222bd
commit b1fd0abc94
112 files changed
+11348 -2701

No files matched your search

+71 -14
View File
@@ -271,8 +271,15 @@ ALLOWED_READ_FILES = [
"scenes.yaml",
"secrets.yaml",
"home-assistant.log",
# faulthandler's crash dump (HA Core's ``__main__`` enables it on this file).
# Written only on a native fatal signal, so it is the one place that
# traceback exists: journald / error_log never see it (issue #2373).
"home-assistant.log.fault",
]
# Root-level log files that the read service tails by default.
TAILED_LOG_FILES = frozenset({"home-assistant.log", "home-assistant.log.fault"})
# Default tail lines for log files
DEFAULT_LOG_TAIL_LINES = 1000
@@ -1345,6 +1352,53 @@ def _read_file_sync(target_file: Path) -> dict[str, Any]:
return {"content": content, "size": stat.st_size, "mtime": stat.st_mtime}
def _read_log_tail_sync(
target_file: Path, tail_lines: int, *, chunk_size: int = 1 << 16
) -> dict[str, Any]:
"""Tail a log for one executor offload without materialising the whole file.
HA Core appends to ``home-assistant.log.fault`` for the life of the
install, so reading the file whole just to keep its last ``tail_lines``
lines scales with the crash history rather than the request. Line count
streams the file in chunks; the tail seeks back from the end until it holds
``tail_lines`` newlines. The result carries the same ``content`` /
``total_lines`` / ``truncated`` the whole-file split produced (``split("\n")``
semantics, so a trailing newline yields one empty last line), plus
``size``/``mtime`` like ``_read_file_sync``.
"""
if not target_file.exists():
return {"_error": "not_found"}
if not target_file.is_file():
return {"_error": "not_a_file"}
stat = target_file.stat()
newlines = 0
with target_file.open("rb") as fh:
for chunk in iter(lambda: fh.read(chunk_size), b""):
newlines += chunk.count(b"\n")
total_lines = newlines + 1
pos = fh.tell()
buf = b""
while pos > 0 and buf.count(b"\n") < tail_lines:
step = min(chunk_size, pos)
pos -= step
fh.seek(pos)
buf = fh.read(step) + buf
parts = buf.split(b"\n")
truncated = total_lines > tail_lines
if truncated:
# The leading part may start mid-character at a chunk boundary; it is
# never part of the tail, so decode only what is returned.
parts = parts[-tail_lines:]
content = b"\n".join(parts).decode("utf-8")
return {
"content": content,
"size": stat.st_size,
"mtime": stat.st_mtime,
"total_lines": total_lines,
"truncated": truncated,
}
def _write_file_sync(
target_file: Path,
content: str,
@@ -2772,25 +2826,19 @@ async def _shape_read_file_response(
if normalized == "secrets.yaml":
content = await hass.async_add_executor_job(_mask_secrets_content, content)
# Apply tail for log files
if normalized == "home-assistant.log":
lines = content.split("\n")
# Log files arrive already tailed by ``_read_log_tail_sync``.
if normalized in TAILED_LOG_FILES:
limit = tail_lines if tail_lines else DEFAULT_LOG_TAIL_LINES
if len(lines) > limit:
content = "\n".join(lines[-limit:])
truncated = True
else:
truncated = False
total_lines = result["total_lines"]
return {
"success": True,
"path": rel_path,
"content": content,
"size": stat_size,
"modified": modified_dt.isoformat(),
"lines_returned": min(len(lines), limit),
"total_lines": len(lines),
"truncated": truncated,
"lines_returned": min(total_lines, limit),
"total_lines": total_lines,
"truncated": result["truncated"],
}
# Apply tail for other files if requested
@@ -2853,13 +2901,22 @@ def _build_read_file_handler(
)
return {
"success": False,
"error": f"Path not allowed. Allowed patterns: {', '.join(allowed_patterns)}",
"error": f"Path not allowed. Allowed paths: {', '.join(allowed_patterns)}",
}
target_file = config_dir / rel_path
try:
result = await hass.async_add_executor_job(_read_file_sync, target_file)
if os.path.normpath(rel_path) in TAILED_LOG_FILES: # noqa: ASYNC240
# Bounded read: the fault log is append-only for the life of
# the install, so never materialise the whole file for a tail.
result = await hass.async_add_executor_job(
_read_log_tail_sync,
target_file,
tail_lines if tail_lines else DEFAULT_LOG_TAIL_LINES,
)
else:
result = await hass.async_add_executor_job(_read_file_sync, target_file)
except PermissionError:
_LOGGER.error("Permission denied reading: %s", rel_path)
return {
+5 -4
View File
@@ -25,7 +25,7 @@ DOMAIN = "ha_mcp_tools"
# in CI. The
# capability negotiation — not this version — gates each WS command (see
# ``websocket_api.CAPABILITIES``).
COMPONENT_VERSION = "2.1.3"
COMPONENT_VERSION = "2.2.0"
# Config-entry discriminator (``entry.data[CONF_ENTRY_TYPE]``). A missing value
# means "tools" so the pre-existing services entry keeps working across the
@@ -53,9 +53,10 @@ MIN_EMBEDDED_HOME_ASSISTANT_VERSION = "2026.8.0"
# allowlist, not an absolute guarantee: an admin who adds "blueprints" as a
# custom extra directory (issue #1567, see _current_extra_dirs) grants it
# read+write, since extra_dirs are honored on the write path too. Blueprint
# writes should instead go through ha_import_blueprint (which invokes the
# blueprint/save WS command internally). Prefer ha_get_blueprint for the parsed
# body; raw read is the escape hatch for the exact on-disk text.
# writes should instead go through ha_manage_blueprints(action="import") (which
# invokes the blueprint/save WS command internally). Prefer
# ha_manage_blueprints(action="get") for the parsed body; raw read is the escape
# hatch for the exact on-disk text.
ALLOWED_READ_DIRS = ["www", "themes", "custom_templates", "dashboards", "blueprints"]
ALLOWED_WRITE_DIRS = ["www", "themes", "custom_templates", "dashboards"]
@@ -1520,6 +1520,49 @@ class EmbeddedServerManager:
# -- worker thread -----------------------------------------------------
def _register_embedded_connection(
self, hamcp_config: Any, access_token: str
) -> None:
"""Hand the loopback URL, admin token and config dir over in memory.
Optional keywords are dropped one at a time, newest first, when the
installed server rejects them with ``TypeError``: an older release
predates ``config_dir`` (issue #2329) or ``verify_ssl`` (issue #1890).
Only the rejected keyword is dropped — falling straight back to the
two-argument call would re-open #1890's certificate failure on an
SSL-enabled instance just because the server is missing an unrelated,
newer parameter.
"""
kwargs: dict[str, Any] = {"config_dir": self._hass_config_dir}
if self._loopback_verify_ssl is not None:
kwargs["verify_ssl"] = self._loopback_verify_ssl
for optional in ("config_dir", "verify_ssl", None):
try:
hamcp_config.set_embedded_connection(
self._server_url, access_token, **kwargs
)
return
except TypeError:
if optional is None:
raise
if optional not in kwargs:
continue
del kwargs[optional]
if optional == "config_dir":
# Only costs the direct blueprint file read; the server
# falls back to reading through this component.
_LOGGER.debug(
"Installed ha-mcp server does not accept config_dir for "
"the embedded connection; blueprint reads route through "
"the component until the server package updates"
)
else:
_LOGGER.warning(
"Installed ha-mcp server does not accept verify_ssl for "
"the embedded connection; loopback TLS verification stays "
"enabled until the server package updates"
)
def _note_startup_phase(self, phase: str) -> None:
"""Publish the worker's startup phase (a plain attribute write).
@@ -1629,27 +1672,7 @@ class EmbeddedServerManager:
"entry may serve stale override values until HA restarts"
)
if self._loopback_verify_ssl is None:
_hamcp_config.set_embedded_connection(self._server_url, access_token)
else:
try:
_hamcp_config.set_embedded_connection(
self._server_url,
access_token,
verify_ssl=self._loopback_verify_ssl,
)
except TypeError:
# Server predates the verify_ssl parameter (< the release
# carrying issue #1890's fix). Register url+token the old way;
# on an SSL-enabled instance the wss loopback will fail
# certificate verification until the server package updates —
# no worse than the plaintext failure it replaces.
_LOGGER.warning(
"Installed ha-mcp server does not accept verify_ssl for "
"the embedded connection; loopback TLS verification stays "
"enabled until the server package updates"
)
_hamcp_config.set_embedded_connection(self._server_url, access_token)
self._register_embedded_connection(_hamcp_config, access_token)
# Imported here, in the worker thread, after the connection is registered.
from ha_mcp.server import HomeAssistantSmartMCPServer
+74 -36
View File
@@ -33,9 +33,9 @@ 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.
loopback streamable HTTP with Home Assistant's shared ``mcp`` SDK (the manifest
requires ``mcp>=1.24.0``, so 1.x or 2.x), never ha-mcp's vendored copy. 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
@@ -51,6 +51,7 @@ import copy
import importlib
import logging
import math
import sys
from collections import Counter
from collections.abc import AsyncIterator, Callable, Iterable
from contextlib import AsyncExitStack, asynccontextmanager
@@ -389,28 +390,37 @@ 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.
from our asyncio.timeout budget. HTTP-client errors and the protocol-level
MCP error can also escape a session call UNWRAPPED (HA core's mcp
integration catches both the same way); SDK 1.x raises ``httpx`` /
``McpError``, 2.x raises ``httpx2`` / ``MCPError``. Read from ``sys.modules``:
the SDK already imported whichever it raised from, and this runs on the
event loop, where an import is a blocking call.
"""
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)
errors: list[type[BaseException]] = [TimeoutError, OSError]
for module_name, attribute in (
("httpx", "HTTPError"),
("httpx2", "HTTPError"),
("mcp", "McpError"),
("mcp", "MCPError"),
):
module = sys.modules.get(module_name)
if module is None:
continue
error_class = getattr(module, attribute, None)
if isinstance(error_class, type) and error_class not in errors:
errors.append(error_class)
return tuple(errors)
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
Evaluated at exception time (an ``except`` expression is), so whichever
HTTP/MCP module raised is already in ``sys.modules`` for
:func:`_transport_error_leaves` to find. 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).
@@ -547,34 +557,40 @@ async def _mcp_session(
"""
from mcp.client.session import ClientSession
sdk_transport = importlib.import_module("mcp.client.streamable_http")
async with AsyncExitStack() as stack:
try:
from mcp.client.streamable_http import streamable_http_client
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,
streamable_http_client = getattr(sdk_transport, "streamable_http_client", None)
if streamable_http_client is None:
# Pre-1.24 SDK (a third-party pin forcing the shared mcp below the
# manifest's floor): same call shape, deprecated name,
# and no http_client kwarg — but it does accept a factory for the
# client it builds internally, so _loopback_httpx_client_factory
# keeps this fallback on the same verify=False/trust_env=False
# posture as the canonical path below.
from mcp.client.streamable_http import streamablehttp_client
transport = streamablehttp_client(
transport = sdk_transport.streamablehttp_client(
url=url, httpx_client_factory=_loopback_httpx_client_factory
)
else:
import httpx
# SDK 1.x takes an httpx client, 2.x an httpx2 one: use the library
# the transport module itself imported.
http_lib = getattr(sdk_transport, "httpx2", None) or getattr(
sdk_transport, "httpx", None
)
if http_lib is None:
http_lib = importlib.import_module("httpx")
http_client = await stack.enter_async_context(
httpx.AsyncClient(
http_lib.AsyncClient(
verify=False,
trust_env=False,
timeout=httpx.Timeout(_CALL_TOOL_TIMEOUT_SECONDS),
timeout=http_lib.Timeout(_CALL_TOOL_TIMEOUT_SECONDS),
)
)
transport = streamable_http_client(url=url, http_client=http_client)
read_stream, write_stream, _ = await stack.enter_async_context(transport)
# 1.x yields (read, write, get_session_id); 2.x yields (read, write).
streams = await stack.enter_async_context(transport)
read_stream, write_stream = streams[0], streams[1]
session = await stack.enter_async_context(
ClientSession(read_stream, write_stream)
)
@@ -673,8 +689,30 @@ async def _forward_tool_call(
# 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)
# agent reads and reacts to like any tool output. Dumped by alias (2.x
# fields are snake_case) with ``meta`` kept at its 1.x key, so both SDK
# lines hand agents the same keys.
dumped = result.model_dump(exclude_unset=True, exclude_none=True, by_alias=True)
return cast("JsonObjectType", _meta_as_field_name(dumped))
def _meta_as_field_name(dumped: dict[str, Any]) -> dict[str, Any]:
"""Rename ``_meta`` to ``meta`` on the SDK models only, never in tool payloads."""
models: list[Any] = [dumped]
for block in dumped.get("content", []):
models += [block, block.get("resource") if isinstance(block, dict) else None]
for model in models:
if isinstance(model, dict) and "_meta" in model:
model["meta"] = model.pop("_meta")
return dumped
def _tool_input_schema(tool: Any) -> Any:
"""``input_schema`` on SDK 2.x, ``inputSchema`` on 1.x."""
schema = getattr(tool, "input_schema", None)
if schema is None:
schema = getattr(tool, "inputSchema", None)
return schema
def _search_score(query_words: list[str], name: str, description: str) -> int:
@@ -868,7 +906,7 @@ class HaMcpLlmApi(llm.API):
"""Mirror every exposed tool directly (full-catalog mode)."""
tools: list[llm.Tool] = []
for tool in exposed:
schema = _normalise_schema(tool.inputSchema, tool.name)
schema = _normalise_schema(_tool_input_schema(tool), tool.name)
parameters = self._convert_parameters(tool, schema)
if parameters is None:
continue
@@ -896,7 +934,7 @@ class HaMcpLlmApi(llm.API):
# recorded no mutating call at all. A
# converter that wrote back would corrupt the catalog entry, so
# re-check this before pointing the component at a third one.
schema = _normalise_schema(tool.inputSchema, tool.name)
schema = _normalise_schema(_tool_input_schema(tool), tool.name)
catalog.append(
{
"name": tool.name,
+2 -1
View File
@@ -20,8 +20,9 @@
"iot_class": "local_push",
"issue_tracker": "https://github.com/homeassistant-ai/ha-mcp/issues",
"requirements": [
"mcp>=1.24.0",
"ruamel.yaml>=0.18.0",
"voluptuous-openapi>=0.4.1"
],
"version": "2.1.3"
"version": "2.2.0"
}
+24 -5
View File
@@ -72,12 +72,24 @@ from .oauth_legacy import (
build_unbound_legacy_provider,
clear_scoped_legacy_credentials,
)
from .readonly_webhook import (
readonly_url,
register_readonly_webhook,
unregister_readonly_webhook,
)
if TYPE_CHECKING:
from homeassistant.config_entries import ConfigEntry
_LOGGER = logging.getLogger(__name__)
def _unregister_webhook(hass: HomeAssistant, webhook_id: str) -> None:
"""Remove both endpoint variants, including on partial setup failure."""
unregister_readonly_webhook(hass, webhook_id)
async_unregister(hass, webhook_id)
# Human-readable webhook name shown in the HA webhook registry.
_WEBHOOK_NAME = "HA-MCP in-process server"
@@ -585,7 +597,11 @@ async def _check_webhook_auth(
async def _async_handle_webhook(
hass: HomeAssistant, webhook_id: str, request: web.Request
hass: HomeAssistant,
webhook_id: str,
request: web.Request,
*,
read_only: bool = False,
) -> web.StreamResponse:
"""Forward an MCP request to the loopback server and stream the reply back."""
domain_data = hass.data.get(DOMAIN)
@@ -597,7 +613,9 @@ async def _async_handle_webhook(
if auth_response is not None:
return auth_response
target_url: str = cfg["target_url"]
target_url: str = (
readonly_url(cfg["target_url"]) if read_only else cfg["target_url"]
)
session: aiohttp.ClientSession = cfg["session"]
body = await request.read()
@@ -824,7 +842,7 @@ async def async_register_webhook(
# 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)
_unregister_webhook(hass, webhook_id)
target_url = f"http://127.0.0.1:{port}{secret_path}"
session = aiohttp.ClientSession(timeout=_CLIENT_TIMEOUT)
cimd_session: aiohttp.ClientSession | None = None
@@ -862,6 +880,7 @@ async def async_register_webhook(
_async_handle_webhook,
allowed_methods=["POST", "GET"],
)
register_readonly_webhook(hass, webhook_id, _async_handle_webhook)
if auth_mode == WEBHOOK_AUTH_HA:
_bind_ha_auth_surface(hass, cfg, webhook_id, dcr_signing_key)
elif auth_mode == WEBHOOK_AUTH_LEGACY:
@@ -877,7 +896,7 @@ async def async_register_webhook(
# 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)
_unregister_webhook(hass, webhook_id)
with suppress(Exception):
await session.close()
if cimd_session is not None:
@@ -917,7 +936,7 @@ async def async_unregister_webhook(hass: HomeAssistant) -> None:
return
webhook_id = cfg.get("webhook_id")
if webhook_id:
async_unregister(hass, webhook_id)
_unregister_webhook(hass, webhook_id)
session = cfg.get("session")
if session is not None:
await session.close()
@@ -60,6 +60,7 @@ from .oauth_legacy import (
_PKCE_CHALLENGE_RE,
_TOKEN_RESPONSE_HEADERS,
ACCESS_TOKEN_TTL,
PKCE_S256_CHALLENGE_LEN,
PKCECodeStore,
_is_valid_redirect_uri,
_issuer_for,
@@ -165,20 +166,6 @@ def _webhook_cfg(hass: HomeAssistant) -> dict[str, Any] | None:
return cfg if isinstance(cfg, dict) else None
def _active_autoapprove_provider(hass: HomeAssistant) -> AutoApproveProvider | None:
"""The live none-mode auto-approve provider, or None when it is not live.
Read live from ``hass.data`` (not captured at view construction) so the
bound views serve only while none-autoapprove is the active mode and 404
otherwise — mirrors ``mcp_webhook._active_webhook_id``'s per-request gating.
"""
cfg = _webhook_cfg(hass)
if cfg is None:
return None
provider = cfg.get(CFG_AUTOAPPROVE_PROVIDER)
return provider if isinstance(provider, AutoApproveProvider) else None
def _validate_autoapprove_authorize(params: Any) -> web.Response | None:
"""Validate the none-mode /authorize query; a 400 Response, or None if OK.
@@ -197,7 +184,9 @@ def _validate_autoapprove_authorize(params: Any) -> web.Response | None:
return _json_error("invalid_request", 400, "code_challenge_method must be S256")
if not _PKCE_CHALLENGE_RE.fullmatch(params.get("code_challenge", "")):
return _json_error(
"invalid_request", 400, "invalid code_challenge (43-char base64url)"
"invalid_request",
400,
f"invalid code_challenge ({PKCE_S256_CHALLENGE_LEN}-char base64url)",
)
if not _is_valid_redirect_uri(params.get("redirect_uri", "")):
return _json_error("invalid_request", 400, "invalid redirect_uri")
@@ -89,7 +89,7 @@ PKCE_VERIFIER_MAX = 128
# SHA-256 → 32 bytes → 43 base64url chars (no padding).
PKCE_S256_CHALLENGE_LEN = 43
_PKCE_VERIFIER_RE = re.compile(r"[A-Za-z0-9._~-]+")
_PKCE_CHALLENGE_RE = re.compile(r"[A-Za-z0-9_-]{43}")
_PKCE_CHALLENGE_RE = re.compile(rf"[A-Za-z0-9_-]{{{PKCE_S256_CHALLENGE_LEN}}}")
# Pending-code dict cap. An attacker spamming /authorize with valid params
# could grow the dict between the prune passes that run on each issuance.
@@ -676,7 +676,8 @@ class LegacyOAuthProvider:
return _text_error(400, "invalid code_challenge_method (S256 required)")
if not _PKCE_CHALLENGE_RE.fullmatch(code_challenge):
return _text_error(
400, "invalid code_challenge (must be 43-char base64url)"
400,
f"invalid code_challenge (must be {PKCE_S256_CHALLENGE_LEN}-char base64url)",
)
if client_id != self.client_id:
return _text_error(400, "invalid client_id", restart_hint=True)
+2 -1
View File
@@ -83,7 +83,8 @@ read_file:
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/**,
secrets.yaml (values masked), home-assistant.log,
home-assistant.log.fault, www/**, themes/**,
custom_templates/**, dashboards/**, blueprints/**, packages/*.yaml,
custom_components/**/*.py
required: true
+1 -1
View File
@@ -33,7 +33,7 @@
"extra_yaml_keys": "Extra YAML write keys"
},
"data_description": {
"allowed_dirs": "Paths relative to the config directory, including exact filenames such as sensor.yaml, or paths under /share, /media, /ssl, and /backup. Each entry is granted read and write for that path and anything below it; enter every path as a separate list item. Traversal and other disallowed paths are dropped.",
"allowed_dirs": "Paths relative to the config directory, including exact filenames such as sensor.yaml, or paths under /share, /media, /ssl, and /backup. Each entry is granted read and write for that path and anything below it; enter every path as a separate list item. Entries are matched literally; wildcards such as * are not supported. Traversal and other disallowed paths are dropped.",
"extra_yaml_keys": "Top-level keys ha_config_set_yaml may write in addition to the built-in ones, for YAML-first integrations on this install (e.g. alert2). Trust-boundary keys are dropped."
}
},
@@ -33,7 +33,7 @@
"extra_yaml_keys": "Další klíče pro zápis YAML"
},
"data_description": {
"allowed_dirs": "Cesty relativní k adresáři konfigurace, včetně přesných názvů souborů, jako je sensor.yaml, nebo cesty pod /share, /media, /ssl a /backup. Každá položka má uděleno oprávnění pro čtení a zápis pro danou cestu a vše pod ní; každou cestu zadejte jako samostatnou položku seznamu. Adresářový posun (traversal) a jiné nepovolené cesty budou ignorovány.",
"allowed_dirs": "Cesty relativní ke složce konfigurační složky, včetně přesných názvů souborů jako sensor.yaml, nebo cesty pod /share, /media, /ssl a /backup. Každá položka má uděleno právo čtení i zápisu pro danou cestu a vše pod ní; zadejte každou cestu jako samostatnou položku seznamu. Položky se porovnávají doslovně; zástupné znaky jako * nejsou podporovány. Průchod adresářovou strukturou a jiné nepovolené cesty jsou zahazovány.",
"extra_yaml_keys": "Klíče nejvyšší úrovně, které ha_config_set_yaml může zapisovat navíc k vestavěným, pro integrace s prioritou YAML v této instalaci (např. alert2). Klíče hranice důvěry jsou zahozeny."
}
},
@@ -33,7 +33,7 @@
"extra_yaml_keys": "Zusätzliche YAML-Schreib-Keys"
},
"data_description": {
"allowed_dirs": "Pfade relativ zum Konfigurationsverzeichnis, einschließlich genauer Dateinamen wie sensor.yaml, oder Pfade unter /share, /media, /ssl und /backup. Jedem Eintrag wird Lese- und Schreibzugriff für diesen Pfad und alles darunter gewährt; gib jeden Pfad als separaten Listeneintrag an. Verzeichnistraversierung und andere nicht erlaubte Pfade werden verworfen.",
"allowed_dirs": "Pfade relativ zum Konfigurationsverzeichnis, einschließlich exakter Dateinamen wie sensor.yaml, oder Pfade unter /share, /media, /ssl und /backup. Jedem Eintrag wird Lese- und Schreibzugriff für diesen Pfad und alles darunter gewährt; gib jeden Pfad als separates Listenelement ein. Einträge werden wörtlich abgeglichen; Platzhalter wie * werden nicht unterstützt. Pfadtraversierung und andere unzulässige Pfade werden verworfen.",
"extra_yaml_keys": "Top-Level-Keys, die ha_config_set_yaml zusätzlich zu den eingebauten schreiben darf, für YAML-first-Integrationen auf dieser Installation (z. B. alert2). Vertrauensgrenzen-Keys werden verworfen."
}
},
@@ -33,7 +33,7 @@
"extra_yaml_keys": "Extra YAML write keys"
},
"data_description": {
"allowed_dirs": "Paths relative to the config directory, including exact filenames such as sensor.yaml, or paths under /share, /media, /ssl, and /backup. Each entry is granted read and write for that path and anything below it; enter every path as a separate list item. Traversal and other disallowed paths are dropped.",
"allowed_dirs": "Paths relative to the config directory, including exact filenames such as sensor.yaml, or paths under /share, /media, /ssl, and /backup. Each entry is granted read and write for that path and anything below it; enter every path as a separate list item. Entries are matched literally; wildcards such as * are not supported. Traversal and other disallowed paths are dropped.",
"extra_yaml_keys": "Top-level keys ha_config_set_yaml may write in addition to the built-in ones, for YAML-first integrations on this install (e.g. alert2). Trust-boundary keys are dropped."
}
},
@@ -33,7 +33,7 @@
"extra_yaml_keys": "Ekstraj YAML-skribaj ŝlosiloj"
},
"data_description": {
"allowed_dirs": "Vojoj relative al la agorda dosierujo, inkluzive de ekzakta dosiernomo kiel sensor.yaml, aŭ vojoj sub /share, /media, /ssl, kaj /backup. Ĉiu eniro ricevas leg- kaj skrib-permeson por tiu vojo kaj ĉio sub ĝi; enigu ĉiun vojon kiel apartan listan eron. Trairado kaj aliaj nepermesitaj vojoj estas forigitaj.",
"allowed_dirs": "Poj do la agorda dosierujo, inkluzive de ekzakta dosiernomo kiel sensor.yaml, aŭ vojoj sub /share, /media, /ssl, kaj /backup. Ĉiu eniro ricevas leg- kaj skrib-permeson por tiu vojo kaj ĉio sub ĝi; enigu ĉiun vojon kiel apartan listan eron. Eniroj estas kongruigataj laŭlitere; sovaĝaj signoj kiel * ne estas subtenataj. Trapasado kaj aliaj nepermesitaj vojoj estas forĵetitaj.",
"extra_yaml_keys": "Ĉefnivelaj ŝlosiloj, kiujn ha_config_set_yaml povas skribi krom la enkonstruitaj, por YAML-unuaj integriĝoj en ĉi tiu instalaĵo (ekz. alert2). Fid-limaj ŝlosiloj estas forĵetitaj."
}
},
@@ -33,7 +33,7 @@
"extra_yaml_keys": "Claves YAML de escritura adicionales"
},
"data_description": {
"allowed_dirs": "Rutas relativas al directorio de configuración, incluidos nombres de archivo exactos como sensor.yaml, o rutas bajo /share, /media, /ssl y /backup. A cada entrada se le concede lectura y escritura para esa ruta y todo lo que esté debajo de ella; introduce cada ruta como un elemento de lista separado. Se descartarán los recorridos de directorios y otras rutas no permitidas.",
"allowed_dirs": "Rutas relativas al directorio de configuración, incluidos nombres de archivo exactos como sensor.yaml, o rutas bajo /share, /media, /ssl y /backup. A cada entrada se le concede lectura y escritura para esa ruta y todo lo que esté debajo de ella; introduce cada entrada como un elemento de lista separado. Las entradas se comparan literalmente; no se admiten comodines como *. Se omiten las rutas con recorrido de directorios y otras no permitidas.",
"extra_yaml_keys": "Claves de primer nivel que ha_config_set_yaml puede escribir además de las integradas, para integraciones basadas en YAML de esta instalación (p. ej. alert2). Las claves de la frontera de confianza se descartan."
}
},
@@ -33,7 +33,7 @@
"extra_yaml_keys": "Clés d'écriture YAML supplémentaires"
},
"data_description": {
"allowed_dirs": "Chemins relatifs au répertoire de configuration, incluant les noms de fichiers exacts tels que sensor.yaml, ou les chemins sous /share, /media, /ssl et /backup. Chaque entrée bénéficie d'un accès en lecture et en écriture pour ce chemin et tout ce qu'il contient ; saisissez chaque chemin comme un élément de liste séparé. Les parcours de répertoires (traversal) et autres chemins non autorisés sont ignorés.",
"allowed_dirs": "Chemins relatifs au répertoire de configuration, y compris les noms de fichiers exacts tels que sensor.yaml, ou les chemins sous /share, /media, /ssl et /backup. Chaque entrée se voit accorder un accès en lecture et en écriture pour ce chemin et tout ce qui se trouve en dessous ; entrez chaque chemin comme un élément de liste séparé. Les entrées sont comparées littéralement ; les caractères génériques tels que * ne sont pas pris en charge. Les traversées de répertoires et autres chemins non autorisés sont ignorés.",
"extra_yaml_keys": "Clés de premier niveau que ha_config_set_yaml peut écrire en plus de celles intégrées, pour les intégrations YAML-first de cette installation (ex. alert2). Les clés de limite de confiance sont ignorées."
}
},
@@ -33,7 +33,7 @@
"extra_yaml_keys": "Chiavi YAML scrivibili aggiuntive"
},
"data_description": {
"allowed_dirs": "Percorsi relativi alla directory di configurazione, inclusi nomi di file esatti come sensor.yaml, o percorsi sotto /share, /media, /ssl e /backup. A ciascuna voce viene concesso l'accesso in lettura e scrittura per quel percorso e per tutto ciò che si trova al di sotto; inserisci ogni percorso come elemento di elenco separato. I percorsi di attraversamento e altri non consentiti vengono scartati.",
"allowed_dirs": "Percorsi relativi alla directory di configurazione, inclusi nomi file esatti come sensor.yaml, o percorsi sotto /share, /media, /ssl e /backup. A ogni voce vengono concessi i permessi di lettura e scrittura per quel percorso e per tutto ciò che si trova al di sotto di esso; inserisci ogni percorso come elemento di elenco separato. Le voci vengono confrontate letteralmente; i caratteri jolly come * non sono supportati. I percorsi di attraversamento e altri percorsi non consentiti vengono scartati.",
"extra_yaml_keys": "Chiavi di primo livello che ha_config_set_yaml può scrivere oltre a quelle integrate, per le integrazioni basate su YAML di questa installazione (per esempio alert2). Le chiavi del confine di fiducia vengono scartate."
}
},
@@ -33,7 +33,7 @@
"extra_yaml_keys": "추가 YAML 쓰기 허용 키"
},
"data_description": {
"allowed_dirs": "sensor.yaml 같은 정확한 파일 이름을 포함하여 config 디렉토리 상대 경로, 또는 /share, /media, /ssl, /backup 아래의 경로입니다. 각 항목에는 해당 경로 및 그 하위 경로에 대한 읽기 및 쓰기 권한이 부여되며, 각 경로는 별도의 목록 항목으로 입력하세요. 상위 디렉토리 순회 및 허용되지 않는 경로는 제외됩니다.",
"allowed_dirs": "config 디렉터리를 기준으로 한 상대 경로이며, sensor.yaml 같은 정확한 파일 이름이나 /share, /media, /ssl, /backup 아래의 경로를 포함합니다. 각 항목은 해당 경로와 그 하위 항목에 대한 읽기 및 쓰기 권한이 부여되며, 모든 경로는 별도의 목록 항목으로 입력해야 합니다. 항목은 정확히 일치하는 문자열로 처리되며, * 같은 와일드카드는 지원되지 않습니다. 상위 디렉터리 접근(Traversal) 및 허용되지 않는 경로는 무시됩니다.",
"extra_yaml_keys": "이 설치본에서 YAML 우선으로 동작하는 통합 구성요소(예: alert2)를 위해, ha_config_set_yaml이 기본 허용 키 외에 추가로 쓸 수 있는 최상위 키입니다. 신뢰 경계 키는 무시됩니다."
}
},
@@ -33,7 +33,7 @@
"extra_yaml_keys": "Extra YAML-schrijfsleutels"
},
"data_description": {
"allowed_dirs": "Paden relatief ten opzichte van deconfiguratiemap, inclusief exacte bestandsnamen zoals sensor.yaml, of paden onder /share, /media, /ssl en /backup. Elk opgegeven pad en alles daaronder krijgt lees- en schrijfrechten; voer elk pad in als een apart lijstitem. Padtraversatie en andere niet-toegestane paden worden genegeerd.",
"allowed_dirs": "Paden relatief ten opzichte van de config-directory, inclusief exacte bestandsnamen zoals sensor.yaml, of paden onder /share, /media, /ssl en /backup. Aan elk item wordt lees- en schrijftoegang verleend voor dat pad en alles daaronder; voer elk pad in als een apart lijstitem. Items worden letterlijk gematcht; jokers zoals * worden niet ondersteund. Directory-traversal en andere niet-toegestane paden worden geweigerd.",
"extra_yaml_keys": "Top-level sleutels die ha_config_set_yaml mag schrijven naast de ingebouwde, voor YAML-first integraties op deze installatie (bijv. alert2). Trust-boundary sleutels worden genegeerd."
}
},
@@ -33,7 +33,7 @@
"extra_yaml_keys": "Dodatkowe klucze zapisu YAML"
},
"data_description": {
"allowed_dirs": "Ścieżki względne do katalogu konfiguracyjnego, w tym dokładne nazwy plików, takie jak sensor.yaml, lub ścieżki w katalogach /share, /media, /ssl i /backup. Każdy wpis otrzymuje uprawnienia do odczytu i zapisu dla danej ścieżki oraz wszystkiego, co się w niej znajduje; wpisz każdą ścieżkę jako oddzielny element listy. Przechodzenie w górę drzewa katalogów (traversal) i inne niedozwolone ścieżki są odrzucane.",
"allowed_dirs": "Ścieżki względem katalogu konfiguracyjnego, w tym dokładne nazwy plików, takie jak sensor.yaml, lub ścieżki w katalogach /share, /media, /ssl i /backup. Każdy wpis otrzymuje uprawnienia do odczytu i zapisu dla tej ścieżki oraz wszystkiego, co się w niej znajduje; wpisz każdą ścieżkę jako osobny element listy. Wpisy są dopasowywane dosłownie; symbole wieloznaczne, takie jak *, nie są obsługiwane. Przechodzenie w górę drzewa katalogów (traversal) i inne niedozwolone ścieżki są odrzucane.",
"extra_yaml_keys": "Klucze najwyższego poziomu, które ha_config_set_yaml może zapisywać oprócz wbudowanych, dla integracji opartych na YAML w tej instalacji (np. alert2). Klucze graniczne zaufania są odrzucane."
}
},
@@ -33,7 +33,7 @@
"extra_yaml_keys": "Дополнительные YAML-ключи для записи"
},
"data_description": {
"allowed_dirs": "Пути относительно каталога конфигурации, включая точные имена файлов, такие как sensor.yaml, или пути в директориях /share, /media, /ssl и /backup. Для каждого элемента предоставляется доступ на чтение и запись к самому пути и всему, что находится в нем; вводите каждый путь как отдельный элемент списка. Обход каталогов и другие запрещенные пути отбрасываются.",
"allowed_dirs": "Пути относительно каталога конфигурации, включая точные имена файлов, такие как sensor.yaml, или пути в каталогах /share, /media, /ssl и /backup. Для каждого элемента предоставляются права на чтение и запись для указанного пути и всего, что находится внутри него; каждый путь указывается как отдельный элемент списка. Элементы сопоставляются точным совпадением; подстановочные знаки, такие как *, не поддерживаются. Обход каталогов и другие запрещенные пути отклоняются.",
"extra_yaml_keys": "Верхнеуровневые ключи, которые ha_config_set_yaml может записывать вдобавок к встроенным, — для YAML-ориентированных интеграций на этой установке (например, alert2). Ключи границы доверия отбрасываются."
}
},
@@ -33,7 +33,7 @@
"extra_yaml_keys": "Extra YAML-skrivnycklar"
},
"data_description": {
"allowed_dirs": "Sökvägar relativt konfigurationskatalogen, inklusive exakta filnamn som sensor.yaml, eller sökvägar under /share, /media, /ssl och /backup. Varje post ges läs- och skrivbehörighet för den sökvägen och allt under den; ange varje sökväg som ett separat listobjekt. Sökordsbläddring (traversal) och andra otillåtna sökvägar tas bort.",
"allowed_dirs": "Sökvägar relativa till konfigurationskatalogen, inklusive exakta filnamn som sensor.yaml, eller sökvägar under /share, /media, /ssl och /backup. Varje post ges läs- och skrivbehörighet för den sökvägen och allt under den; ange varje sökväg som ett separat listobjekt. Poster matchas bokstavligt; jokertecken som * stöds inte. Sökvägsöverskridningar och andra otillåtna sökvägar ignoreras.",
"extra_yaml_keys": "Toppnivånycklar som ha_config_set_yaml får skriva utöver de inbyggda, för YAML-först-integrationer på denna installation (t.ex. alert2). Nycklar för förtroendegränser ignoreras."
}
},
@@ -33,7 +33,7 @@
"extra_yaml_keys": "额外的 YAML 可写键"
},
"data_description": {
"allowed_dirs": "相对于配置目录的路径,包括诸如 sensor.yaml 的确切文件名,或 /share、/media、/ssl 和 /backup 下的路径。每个条目均被授予对该路径及其中所有内容的读写权限;每个路径请作为一个单独的列表项输入。将丢弃目录遍历及其他不允许的路径。",
"allowed_dirs": "相对于 config 目录的路径,包括诸如 sensor.yaml 的准确文件名,或 /share、/media、/ssl 和 /backup 下的路径。每个条目都会被授予对该路径及其中任何内容的读写权限;请输入每个路径作为单独的列表项。条目按字面量匹配,不支持诸如 * 的通配符。路径遍历和其他不允许的路径将被丢弃。",
"extra_yaml_keys": "除内置键外,ha_config_set_yaml 还可写入的顶层键,适用于本安装中以 YAML 为主的集成(例如 alert2)。信任边界键会被丢弃。"
}
},
@@ -0,0 +1,168 @@
{
"config": {
"step": {
"user": {
"title": "HA-MCP 自訂元件",
"description": "選擇要新增的項目。**HA-MCP Server** 會在 Home Assistant 內部執行完整的 ha-mcp 伺服器,並透過 Home Assistant Webhook 對外暴露——這是多數人想要的安裝方式。它是獨立伺服器,可完全取代所有其他安裝方法:App(附加元件)、Docker、uvx/PyPI、stdio;若執行了此項,請勿同時執行其中任一其他方式。**HA-MCP File & YAML Tools** 有兩種角色。如果您的 ha-mcp 伺服器是在 Home Assistant 外部執行(app、Docker、uvx/PyPI、stdio),此項目可讓該伺服器獲得無法從外部取得的處理程序內元件功能(快速的處理程序內搜尋、設定項目後設資料、登錄檔支援的讀取),因此非常值得新增至每個外部安裝中,它同時也是 ha-mcp 選擇性啟用的檔案/YAML 工具所需的具特權檔案與 YAML 編輯服務之閘道。如果您改為新增 **HA-MCP Server** 項目,該項目已提供這些共用功能,因此只有在需要作為選擇性檔案/YAML 工具的閘道時,才需要這個第二個項目。它適用於所有伺服器類型,且隨時都能在之後新增。",
"menu_options": {
"server": "HA-MCP Server(建議)",
"tools": "HA-MCP File & YAML Tools(選用)"
}
},
"tools": {
"title": "HA-MCP File & YAML Tools",
"description": "設定具特權的檔案與 YAML 設定服務,這些服務受限於 ha-mcp 選擇性啟用的檔案/YAML 編輯工具(功能旗標,預設為關閉)之閘道保護——這適用於所有伺服器類型,包含處理程序內的 HA-MCP Server。如果您的 ha-mcp 伺服器在 Home Assistant 外部執行,此項目還能讓它獲得無法從外部取得的處理程序內元件功能(快速的處理程序內搜尋、設定項目後設資料、登錄檔支援的讀取),即使未開啟檔案/YAML 工具也很值得擁有。您可以隨時新增或移除此項目。"
},
"server": {
"title": "HA-MCP Server",
"description": "這會在 Home Assistant 內部執行完整的 ha-mcp 伺服器,並透過 Home Assistant Webhook 將其遠端暴露(可透過 Nabu Casa 或任何反向代理存取)。它是獨立伺服器,可完全取代 app(附加元件)、Docker、uvx/PyPI 與 stdio 安裝方法——請勿與其他 ha-mcp 伺服器同時執行。選取**提交**以啟動;您隨後可在整合選項中變更連接埠、繫結與驗證。"
}
},
"abort": {
"already_configured": "此項目已經設定過了。",
"unsupported_home_assistant": "處理程序內的 HA-MCP Server 需要 Home Assistant {required} 或更新版本,但此執行個體目前執行的版本為 {installed}。您仍然可以在以 app(附加元件)或 Docker 容器方式執行的外部 ha-mcp 伺服器上,使用此元件的 HA-MCP File & YAML Tools 項目。"
}
},
"options": {
"step": {
"tools_info": {
"title": "HA-MCP File & YAML Tools",
"description": "設定 ha-mcp 選擇性啟用的檔案/YAML 編輯工具所使用的具特權檔案與 YAML 編輯服務。這些設定也會顯示在 ha-mcp 伺服器的自身的設定 UI 中,並立即生效而無需重新啟動。無法覆寫的拒絕底線仍會阻擋敏感路徑(例如 .storage)與信任邊界金鑰(homeassistant、http、frontend、lovelace)。",
"data": {
"allowed_dirs": "額外檔案路徑",
"extra_yaml_keys": "額外的 YAML 寫入金鑰"
},
"data_description": {
"allowed_dirs": "相對於 config 目錄的路徑,包含精確的檔名(例如 sensor.yaml),或是 /share、/media、/ssl 與 /backup底下的路徑。每個項目都會被授予該路徑及其下方所有內容的讀寫權限;請將每個路徑輸入為獨立的列表項目。項目會以字面值進行比對;不支援如 * 的萬用字元。會略過目錄普遍遍歷與其他不允許的路徑。",
"extra_yaml_keys": "除了內建金鑰之外,ha_config_set_yaml 可以寫入的頂層金鑰,適用於此安裝上的 YAML 優先整合(例如 alert2)。信任邊界金鑰將被略過。"
}
},
"init": {
"title": "HA-MCP Server",
"description": "設定 HA-MCP 伺服器。{panel_hint}此處的變更會在儲存時套用。\n\n{versions}\n\n{connect_url}\n\n{oauth_creds}",
"data": {
"channel": "發布管道",
"auto_update": "自動伺服器更新",
"server_port": "MCP 伺服器監聽連接埠",
"bind_host": "網路存取",
"webhook_auth": "驗證模式",
"oauth_client_id_override": "舊版 OAuth:自訂用戶端 ID(選用)",
"oauth_client_secret_override": "舊版 OAuth:自訂用戶端密鑰(選用)",
"oauth_regenerate": "舊版 OAuth:立即重新產生用戶端 ID/密鑰",
"pip_spec": "開發人員:ha-mcp 套件覆寫",
"server_url": "Home Assistant URL(進階)",
"external_url": "外部 URL(選用)",
"webhook_id_override": "自訂 Webhook 密鑰(選用)",
"secret_path_override": "自訂直接存取路徑(選用)",
"regenerate_secrets": "立即重新產生連線密鑰",
"enable_webhook": "透過 Webhook 進行遠端存取",
"enable_llm_api": "對話代理 LLM API",
"llm_api_exposure": "對話代理工具暴露",
"enable_startup_notification": "啟動通知",
"enable_sidebar_panel": "側邊欄設定面板"
},
"data_description": {
"channel": "「穩定版 (Stable)」會安裝最新的穩定發布版本;「開發版 (Development)」會安裝最新的開發建置版本。當啟用自動更新時,重新載入或重新啟動,加上定期檢查,將會安裝所選管道的最新建置版本。下方覆寫的開發人員套件具有較高優先權,並會停用自動更新。",
"auto_update": "啟用時,將會自動安裝所選管道的最新發布版本——於重新載入或重新啟動時,以及透過定期檢查。關閉時,伺服器將維持在目前已安裝的版本,直到您重新開啟此功能為止。這僅控制 ha-mcp 伺服器套件;HA-MCP 自訂元件本身的更新依然透過 HACS 進行。",
"server_port": "此伺服器監聽的連接埠。ha-mcp App(附加元件)使用 9583,因此預設為 9584,讓兩者可同時執行;若未執行該 App(附加元件),可使用任何未占用的連接埠。",
"bind_host": "誰可以直接連線至 MCP 伺服器連接埠。預設值符合 app(附加元件):可在您的本機網路上存取,並以密鑰路徑作為憑證。選擇「回送 (loopback)」以僅允許來自 Home Assistant 電腦本身的連線——無論哪種方式,Webhook URL 與側邊欄面板都能正常運作。",
"webhook_auth": "MCP 用戶端如何在 Webhook URL 證明其身分。使用密鑰 URL 時,連結本身就是唯一的憑證——OAuth 仍會為使用它的用戶端執行,但每個登入都會自動核准:不需要登入,也不需要用戶端 ID 或密鑰。使用 Home Assistant 登入時,像 claude.ai 這類的用戶端會使用 Home Assistant 管理員帳號 (OAuth) 登入。使用舊版 OAuth 時,此整合會執行自己的登入服務,並發行用戶端 ID 與密鑰,以貼到需要它們的用戶端中——可能需要重新啟動 Home Assistant 才能完全啟用或停用它。",
"oauth_client_id_override": "取代自動產生的舊版 OAuth 用戶端 ID。保持空白以保留目前的值。僅在驗證模式設為舊版 OAuth 時使用。",
"oauth_client_secret_override": "取代自動產生的舊版 OAuth 用戶端密鑰。保持空白以保留目前的值。僅在驗證模式設為舊版 OAuth 時使用。",
"oauth_regenerate": "單次動作:為舊版 OAuth 模式產生全新的用戶端 ID(Client ID)與密鑰(Secret)。它只會在修復提示要求的 Home Assistant 重新啟動後生效——在重新啟動之前,先前的用戶端 ID 與密鑰會繼續運作,新的則不會。同時也會清除上方的兩個覆寫欄位。",
"pip_spec": "保持空白。僅用於測試特定的 ha-mcp 建置(例如預先發布版本釘選);會覆寫發布管道並停用自動更新,直到清除為止。",
"server_url": "處理程序內伺服器用來連線至 Home Assistant 的 URL(通常就是此執行個體)。留空會依此執行個體的連接埠與 SSL 設定自動推導;只有在伺服器必須改走其他路由時才需設定。",
"external_url": "顯示為主要的連線 URL ——當 Home Assistant 位於您自己的網域或反向代理背後時請使用此項。請輸入包含通訊協定的完整基礎位址。它必須直接指向 Home Assistant ——在瀏覽器中開啟它應該能到達您的 HA 登入頁面——且不得包含如 :8123(或任何其他連接埠)的連接埠,否則遠端 MCP 用戶端將無法連線。留空以自動使用 Nabu Casa / 本機位址。",
"webhook_id_override": "取代連線 URL (/api/webhook/...) 中的隨機 webhook 密鑰。URL 即為憑證——請使用長且難以猜測的值。留空以保留目前的值。",
"secret_path_override": "取代用於伺服器連接埠上直接存取的隨機路徑。相同的規則:路徑即為憑證。留空以保留目前的值。",
"regenerate_secrets": "單次動作:產生全新的隨機 webhook 密鑰與直接存取路徑,並立即使舊的連線 URL 失效。同時也會清除上方的兩個覆寫欄位。",
"enable_webhook": "為僅限本機模式關閉:完全不會註冊 Home Assistant webhook,因此任何事物(包含 Nabu Casa)都無法透過 Home Assistant 連線至伺服器。直接伺服器連接埠與側邊欄面板將繼續運作。",
"enable_llm_api": "提供完整工具集給 Home Assistant 對話代理程式(OpenAI、Google、Ollama 等):啟用時,代理程式可在「控制 Home Assistant」下方選取「HA-MCP Server」,並從 Assist 聊天與語音中驅動工具。啟用只會使其可被選取——在您於代理程式上挑選它之前,不會暴露任何內容。使用指南:{llm_api_docs_url}",
"llm_api_exposure": "提供給對話代理程式的工具集外觀。工具搜尋(預設)可保持代理程式的上下文簡短:具有釘選工具加上搜尋/執行中介工具的精簡 API。完整目錄直接列出每個暴露的工具——較適合大上下文模型。兩者會並排註冊,以便每個代理程式在「控制 Home Assistant」下方挑選自己的工具。個別工具暴露管理可在 HA-MCP 設定面板中進行;詳細資訊:{llm_api_docs_url}",
"enable_startup_notification": "每次伺服器啟動時顯示通知,指向僅限管理員的設定介面。關閉以安靜地啟動——連線 URL 仍會顯示在 Home Assistant 記錄檔中。",
"enable_sidebar_panel": "在側邊欄中顯示 HA-MCP 設定面板(僅限管理員)。關閉以移除側邊欄項目——伺服器選項仍可在此畫面上使用。"
}
}
}
},
"issues": {
"server_start_failed": {
"title": "HA-MCP 處理程序內伺服器啟動失敗",
"description": "無法在 Home Assistant 內啟動 HA-MCP 處理程序內伺服器:\n\n{detail}\n\n請檢查 Home Assistant 記錄檔,然後重新載入整合(或修正根本問題並重新載入)以重試。"
},
"server_package_install_failed": {
"title": "無法安裝 HA-MCP 處理程序內伺服器套件",
"description": "安裝用於處理程序內伺服器的 ha-mcp 套件失敗:\n\n{detail}\n\n請解決上述描述的相容性或安裝問題,然後重新載入整合以重試。"
},
"component_outdated": {
"title": "透過 HACS 更新 HA-MCP 自訂元件",
"description": "已安裝的 ha-mcp 伺服器需要 HA-MCP 自訂元件 {required} 或更新版本,但您目前安裝的是 {installed}。請透過 HACS 更新元件(開啟 HA-MCP 自訂元件項目,若尚未顯示更新則使用「更新資訊」)並重新整理 Home Assistant。伺服器在此期間會繼續執行,但在更新元件之前,某些較新功能可能無法運作。"
},
"server_update_held": {
"title": "HA-MCP 伺服器更新正在等待元件更新",
"description": "ha-mcp 伺服器 {latest} 可供使用,但該版本同時也更新了 HA-MCP 自訂元件(至 {shipped};您目前執行的是 {running})。為避免啟動執行中元件從未測試過的伺服器版本,自動伺服器更新將暫緩,直到元件更新為止。\n\n請透過 HACS 更新元件(開啟 HA-MCP 自訂元件項目,若尚未顯示更新則使用「更新資訊」),然後重新啟動 Home Assistant——伺服器更新隨後會自動安裝。若無論如何都要安裝伺服器更新,請按下 HA-MCP 伺服器更新實體上的「安裝」。"
},
"legacy_hacs_source": {
"title": "從舊版儲存庫安裝的元件",
"description": "HACS 正在追蹤此元件的主要 ha-mcp 伺服器儲存庫,因此 HACS 在此顯示的是伺服器的版本號碼與發布說明,而非元件本身的版本(2.x)。更新會繼續運作,但標籤會維持錯誤狀態。若要修正:從 HACS 中移除此儲存庫(您的整合設定與設定項目會被保留),將 homeassistant-ai/ha-mcp-integration 新增為自訂儲存庫,從中重新安裝元件,然後重新啟動 Home Assistant。"
},
"legacy_oauth_restart": {
"title": "重新啟動 Home Assistant 以套用舊版 OAuth 設定",
"fix_flow": {
"step": {
"confirm": {
"title": "要重新啟動 Home Assistant 嗎?",
"description": "送出此表單將會重新啟動 Home Assistant 並套用舊版 OAuth 設定。\n\n按一下**送出**以立即重新啟動。"
}
}
}
}
},
"selector": {
"server_channel": {
"options": {
"stable": "穩定版(建議)",
"dev": "開發版(最新建置)"
}
},
"server_webhook_auth": {
"options": {
"none": "機密 webhook URL(預設)",
"ha_auth": "使用 Home Assistant 登入(OAuth)",
"legacy": "舊版 OAuth(此整合會發行要貼上的用戶端 ID/密鑰)"
}
},
"llm_api_exposure": {
"options": {
"tool_search": "工具搜尋(精簡,預設)",
"full": "完整目錄",
"both": "兩者(依每個代理程式選擇)"
}
}
},
"entity": {
"update": {
"server_update": {
"name": "更新"
}
}
},
"common": {
"connect_direct_access": "來自 Home Assistant 機器的直接存取:{url}",
"connect_local_lan": "本機/區域網路 (當網路存取為 \"Local network\"):{url}",
"connect_remote_url": "遠端連線 URL:{url}",
"connect_urls_label": "連線 URL:",
"connect_urls_pending": "伺服器啟動後,連線 URL 將會顯示在此處(以及 Home Assistant 記錄檔中)。",
"connect_webhook_disabled": "透過 webhook 的遠端存取已停用(僅限本機模式)。",
"oauth_creds_pending": "伺服器啟動後,用戶端 ID 與用戶端密鑰將會顯示在此處。",
"oauth_not_serving": "舊版 OAuth 尚未提供這些憑證;請在 Home Assistant 提示時重新啟動以啟用。",
"oauth_select_legacy_mode": "將驗證模式設為上方的舊版 OAuth 並儲存,以產生用戶端 ID 與用戶端密鑰。",
"panel_hint": "開啟 [HA-MCP 設定面板](/ha-mcp)進行工具管理與伺服器設定。",
"tools_module_installed": "Beta/進階檔案與 YAML 工具模組(選用):已安裝",
"tools_module_not_installed": "Beta/進階檔案與 YAML 工具模組(選填):未安裝 — 請在此整合頁面上按一下「新增項目」並選擇 \"HA-MCP File & YAML Tools\" 來加以新增",
"tools_module_not_loaded": "Beta/進階檔案與 YAML 工具模組(選填):已安裝但未載載 — 請在此整合頁面上啟用或重新載入「HA-MCP File & YAML Tools」項目",
"version_line": "元件 {component_version} - 伺服器 ha-mcp {server_version}({channel} 通道)",
"version_not_installed": "尚未安裝",
"version_unknown": "未知"
}
}
+98 -40
View File
@@ -37,7 +37,7 @@ the info handshake carries no capability entry:
is byte-identical to the REST ``/api/states/<id>`` serialization by
construction; the server maps found/missing onto its per-id error contract.
* ``ha_mcp_tools/blueprint_get`` — the full body of one installed blueprint
(``{metadata, config}``), which core's ``blueprint/list`` never returns (it
(``{metadata, config, yaml}``), which core's ``blueprint/list`` never returns (it
serves only ``{metadata}``). The path is jailed under
``<config>/blueprints/<domain>/`` (symlink-safe containment, mirroring the
file-tool jail) and the file read + parse run off the event loop in the async
@@ -335,6 +335,7 @@ WS_ENTITY_LOOKUP = f"{WS_API_PREFIX}/entity_lookup"
WS_BACKUP_PREP = f"{WS_API_PREFIX}/backup_prep"
WS_REGISTRIES = f"{WS_API_PREFIX}/registries"
WS_DASHBOARDS = f"{WS_API_PREFIX}/dashboards"
WS_DASHBOARD_EDIT = f"{WS_API_PREFIX}/dashboard_edit"
WS_SERVICES_LIST = f"{WS_API_PREFIX}/services_list"
WS_REFERENCE_DATA = f"{WS_API_PREFIX}/reference_data"
WS_SERVER_ENTRY = f"{WS_API_PREFIX}/server_entry"
@@ -360,6 +361,11 @@ CAPABILITIES: list[str] = [
"helpers_list",
"states",
"blueprint_get",
# A flag on blueprint_get: gates its additive ``yaml`` result field (the raw
# on-disk blueprint text). The server only asks for the text when this is
# advertised, so an older build simply serves the parsed body and the server
# looks for the text elsewhere.
"blueprint_text",
"device_get",
"device_list",
# A semantic flag shared by every device-registry-backed read. Components
@@ -377,6 +383,7 @@ CAPABILITIES: list[str] = [
"backup_prep",
"registries",
"dashboards",
"dashboard_edit",
# A flag, not a standalone command: gates the additive whole-document
# search-result keys on ``ha_mcp_tools/dashboards`` mode=search
# (``document_matches`` + ``yaml_skipped`` + ``load_failed``, issue #2008).
@@ -570,9 +577,12 @@ def async_register_commands(hass: HomeAssistant) -> None:
HA-core state rather than anything the unloaded entry cached, HA core
authenticates the connection, and ``@require_admin`` gates each command — so
a caller reaching it can already do the same through HA's own WS API. The
write commands are no exception: their D1 domain block refuses
``domain == "ha_mcp_tools"`` unconditionally, so the leftover surface can
never reach the privileged filesystem/YAML services, which
service-dispatching writes enforce D1: they refuse
``domain == "ha_mcp_tools"`` unconditionally. Dashboard edits do not dispatch
services: they use Core's Lovelace storage API under the same admin gate as
``lovelace/config/save``. Server-entry updates retain their own live-entry
and option validation. These commands do not grant access to the privileged
filesystem/YAML services, which
:func:`~custom_components.ha_mcp_tools._async_unload_tools_entry` does remove
on unload. Admin-gated commands answering from live core state until the
next restart is the trade this makes.
@@ -613,6 +623,7 @@ def _command_specs() -> list[tuple[dict[Any, Any], Any, Any]]:
(_backup_prep_schema(), _do_backup_prep, None),
(_registries_schema(), _do_registries, None),
(_dashboards_schema(), _do_dashboards, _dashboards_prep),
(_dashboard_edit_schema(), _do_dashboard_edit, _dashboard_edit_prep),
(_services_list_schema(), _do_services_list, _services_list_prep),
(_reference_data_schema(), _do_reference_data, None),
(_server_entry_schema(), _do_server_entry, None),
@@ -625,7 +636,7 @@ def _command_specs() -> list[tuple[dict[Any, Any], Any, Any]]:
_do_server_entry_update,
_server_entry_update_prep,
),
# The first WRITE command: the dispatch + the bounded confirmation wait are
# The service WRITE command: dispatch + the bounded confirmation wait are
# inherently async, so ALL of the work lives in the ``_call_service_prep``
# async pre-step and ``_do_call_service`` is a pure response formatter.
(_call_service_schema(), _do_call_service, _call_service_prep),
@@ -3205,27 +3216,31 @@ def _do_blueprint_get(
params: dict[str, Any],
*,
body: dict[str, Any] | None = None,
text: str | None = None,
) -> dict[str, Any]:
"""Return one installed blueprint's full body as ``{metadata, config}``.
"""Return one installed blueprint as ``{metadata, config, yaml}``.
core's ``blueprint/list`` returns only ``{metadata}`` (no triggers /
conditions / actions / sequence), so the server can otherwise serve metadata
only. This reads the on-disk blueprint file and returns the parsed body:
``config`` is the full file (the server merges it additively over the
``blueprint/list`` metadata) and ``metadata`` is its ``blueprint:`` section.
When the file is missing, unparseable, or the requested path escapes the jail,
both come back ``None`` (the server keeps metadata-only) — see
:func:`_read_blueprint_file`.
conditions / actions / sequence, and never the file text), so the server can
otherwise serve metadata only. This reads the on-disk blueprint file once and
returns both views of it: ``config`` is the parsed file (the server merges it
additively over the ``blueprint/list`` metadata), ``metadata`` is its
``blueprint:`` section, and ``yaml`` is the raw text the server hands back for
a round trip through ``blueprint/save``. Each is ``None`` when it could not be
produced — a file that reads but does not parse still yields its ``yaml`` —
and all three are ``None`` when the file is missing or the requested path
escapes the jail (see :func:`_read_blueprint_file`).
Pure: the blocking jail-resolve + file read + YAML parse run in the executor
via :func:`_blueprint_get_prep`, which passes the parsed ``body`` in.
via :func:`_blueprint_get_prep`, which passes both views in.
"""
if not isinstance(body, dict):
return {"metadata": None, "config": None}
return {"metadata": None, "config": None, "yaml": text}
metadata = body.get("blueprint")
return {
"metadata": _plainify(metadata) if isinstance(metadata, dict) else None,
"config": _plainify(body),
"yaml": text,
}
@@ -3237,27 +3252,41 @@ async def _blueprint_get_prep(
The path jail (symlink-safe ``Path.resolve`` containment), the ``open()`` and
the YAML parse are all blocking filesystem work, so they run in the executor
via :meth:`hass.async_add_executor_job` — keeping :func:`_do_blueprint_get` a
pure assembler over the parsed ``body`` this returns (``None`` on any failure).
pure assembler over the raw text and parsed body this returns (both ``None``
on a failed read).
"""
domain = msg["domain"]
path = msg["path"]
body = await hass.async_add_executor_job(_read_blueprint_file, hass, domain, path)
return {"body": body}
read = await hass.async_add_executor_job(_read_blueprint_file, hass, domain, path)
return {"body": read.body, "text": read.text}
def _read_blueprint_file(
hass: HomeAssistant, domain: str, path: str
) -> dict[str, Any] | None:
"""Resolve + jail + read + parse one blueprint YAML file. ``None`` on failure.
class _BlueprintFile(NamedTuple):
"""One blueprint file read once: its raw ``text`` and its parsed ``body``.
``text`` is ``None`` when the file could not be read at all; ``body`` is
additionally ``None`` when it read but did not parse into a mapping.
"""
text: str | None
body: dict[str, Any] | None
_UNREADABLE_BLUEPRINT = _BlueprintFile(None, None)
def _read_blueprint_file(hass: HomeAssistant, domain: str, path: str) -> _BlueprintFile:
"""Resolve + jail + read + parse one blueprint YAML file, reading it once.
Blueprint files live under ``<config>/blueprints/<domain>/``. The requested
``path`` is joined under that root and resolved symlink-safe (mirrors the
file-tool jail's ``_resolves_within`` — resolve the RAW input, following
symlinks, THEN check containment, so ``<root>/<symlink>/..`` cannot escape). A
path escaping the root — via ``..``, an absolute path, or a symlink — yields
``None`` (rejected, never opened). A missing file, a non-file target, a read
error, or a YAML parse error also yields ``None``. Only a valid, contained,
parseable blueprint returns its full parsed body.
an empty result (rejected, never opened), as does a missing file, a non-file
target, or a read error. A file that reads but does not parse into a mapping
keeps its ``text`` and drops its ``body``, so the caller can still round-trip
the exact bytes it holds.
Parsed with :class:`_BlueprintLoader`: ``!input`` markers are preserved and
every other custom tag (``!secret`` / ``!include`` / …) is neutralized to
@@ -3267,29 +3296,32 @@ def _read_blueprint_file(
config = getattr(hass, "config", None)
path_fn = getattr(config, "path", None)
if not callable(path_fn):
return None
return _UNREADABLE_BLUEPRINT
try:
base = Path(path_fn("blueprints", domain))
candidate = Path(path) if path.startswith("/") else base / path
real = candidate.resolve()
base_real = base.resolve()
except (OSError, ValueError):
return None
return _UNREADABLE_BLUEPRINT
if not (real == base_real or real.is_relative_to(base_real)):
return None
return _UNREADABLE_BLUEPRINT
try:
with open(real, encoding="utf-8") as handle:
# Instance form (not yaml.load) mirrors the component's existing
# _PackagesDirLoader usage; _BlueprintLoader is a SafeLoader subclass,
# so no !!python/object can construct arbitrary types.
loader = _BlueprintLoader(handle)
try:
parsed = loader.get_single_data()
finally:
loader.dispose()
except (OSError, ValueError, yaml.YAMLError):
return None
return parsed if isinstance(parsed, dict) else None
text = real.read_text(encoding="utf-8")
except (OSError, ValueError):
return _UNREADABLE_BLUEPRINT
try:
# Instance form (not yaml.load) mirrors the component's existing
# _PackagesDirLoader usage; _BlueprintLoader is a SafeLoader subclass,
# so no !!python/object can construct arbitrary types.
loader = _BlueprintLoader(text)
try:
parsed = loader.get_single_data()
finally:
loader.dispose()
except (ValueError, yaml.YAMLError):
return _BlueprintFile(text, None)
return _BlueprintFile(text, parsed if isinstance(parsed, dict) else None)
def _construct_blueprint_input(loader: Any, node: Any) -> dict[str, str]:
@@ -6803,3 +6835,29 @@ def _dispatched_unconfirmed_bulk_result(
"dispatched": sum(1 for r in op_results if r["dispatched"]),
"failed": sum(1 for r in op_results if r.get("error") is not None),
}
def _dashboard_edit_schema() -> dict[Any, Any]:
"""The additive edit command; cross-field validation precedes any save."""
return {
vol.Required("type"): WS_DASHBOARD_EDIT,
vol.Optional("url_path"): vol.Any(str, None),
vol.Optional("expected_hash"): vol.Any(str, None),
vol.Optional("config"): dict,
vol.Optional("patch"): list,
}
async def _dashboard_edit_prep(
hass: HomeAssistant, msg: dict[str, Any]
) -> dict[str, Any]:
from .dashboard_edit import async_edit_dashboard
return {"result": await async_edit_dashboard(hass, msg)}
def _do_dashboard_edit(
hass: HomeAssistant, msg: dict[str, Any], *, result: dict[str, Any]
) -> dict[str, Any]:
"""Preserve the write outcome assembled by the async edit lifecycle."""
return result