Skip to content

Commit ef2463e

Browse files
Merge branch 'master' into fix/supervisor-log-timeout
2 parents 20c09b8 + 112b10b commit ef2463e

40 files changed

Lines changed: 3611 additions & 569 deletions

.github/workflows/renovate.yml

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,13 +48,21 @@ jobs:
4848
# dead: an installation token that lists any permission-* input drops
4949
# every permission it does not name, including vulnerability_alerts.
5050
permission-vulnerability-alerts: read
51+
# Renovate publishes a stability status check on every branch it
52+
# writes (minimumReleaseAge). Without commit-status access GitHub
53+
# answers 404 rather than 403, Renovate reads that as the repository
54+
# having changed, and aborts the WHOLE run right after writing its
55+
# first branch: no PR, no dependency dashboard, nothing else
56+
# processed. The app installation must grant this too; the token can
57+
# only ever narrow what the installation already has.
58+
permission-statuses: write
5159
permission-workflows: write
5260

5361
- name: Self-hosted Renovate
5462
uses: renovatebot/github-action@e09d604f8f803bb527bd8321ed5be06c460b8682 # v46.2.2
5563
with:
5664
configurationFile: renovate.json
57-
renovate-version: 44.26.0
65+
renovate-version: 44.46.2
5866
token: ${{ steps.app-token.outputs.token }}
5967
env:
6068
LOG_LEVEL: ${{ inputs.logLevel || 'info' }}

AGENTS.md

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -417,6 +417,16 @@ uv run hamcp-test-env --no-interactive # For automation
417417

418418
Test token centralized in `tests/test_constants.py`.
419419

420+
**Unit tests** (`tests/src/unit/`, no Docker) — run them in parallel, as CI does
421+
(`pr.yml`); serial takes 25+ minutes for ~11k tests:
422+
423+
```bash
424+
cd tests && uv run pytest src/unit/ -n auto --tb=short
425+
```
426+
427+
`tests/pytest.ini` sets `--maxfail=3`, so a run reporting "3 failed" has stopped
428+
early rather than finished — pass `--maxfail=0` when you need the full picture.
429+
420430
### Code Quality
421431

422432
C901 (mccabe complexity ≤10) is enforced repo-wide with zero per-file exemptions (issue #925 cleared the grandfathered list) — never reintroduce a `["C901"]` per-file-ignore; extract helpers instead.
@@ -896,7 +906,10 @@ empty, so the English a `tools` entry translates is read from the tool
896906
definition in `src/ha_mcp/tools/` — the `title=` kwarg and the summary
897907
paragraph of the docstring, or the `FEATURE_GATED_TOOLS` stub where a gated
898908
tool shows one instead. Editing that summary moves the English out from under
899-
six catalogs; the pipeline retranslates them. One deliberate exception: a
909+
six catalogs; the pipeline retranslates them. A parameter's
910+
`Field(description=...)` is NOT in the baseline — only the title and the
911+
docstring summary are — so editing one owes no translation work. One
912+
deliberate exception: a
900913
change to a feature-gated tool's PARSED docstring (its stub unchanged) is
901914
stub-review work, not translation work — the pipeline holds that baseline key
902915
stale, and the locale-sync run stays red until a human confirms the stub

Dockerfile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ RUN --mount=type=cache,target=/root/.cache/uv \
2323
uv sync --locked --no-dev
2424

2525
# --- Runtime stage: clean image without uv ---
26-
FROM python:3.13-slim@sha256:3de9a8d7aedbb7984dc18f2dff178a7850f16c1ae7c34ba9d7ecc23d0755e35f
26+
FROM python:3.13-slim@sha256:7e3a6aca9d74f93cca21a91d86a8dad8c34749afd5b4a98ee481c9c47b9f5ed4
2727

2828
LABEL org.opencontainers.image.title="Home Assistant MCP Server" \
2929
org.opencontainers.image.description="AI assistant integration for Home Assistant via Model Context Protocol" \

custom_components/ha_mcp_tools/embedded_server.py

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -244,6 +244,49 @@ def _worker_startup_failure(exc: BaseException) -> EmbeddedServerError:
244244
return failure
245245

246246

247+
def _install_log_filters_if_available() -> None:
248+
"""Attach the shared MCP SDK/fastmcp log-noise filters, if this ha-mcp has them.
249+
250+
Mirrors the ``register_browser_landing`` guard just above ``_serve``'s call
251+
site: the installed server version is user-controlled (channel choice,
252+
pip-spec override), so an older ha-mcp without ``ha_mcp.log_filters`` must
253+
keep serving -- the filters are simply absent there, as they are today.
254+
255+
Only a ``ModuleNotFoundError`` for exactly ``ha_mcp.log_filters`` is that
256+
"older ha-mcp" case and is swallowed silently. Anything else -- a version
257+
mismatch in a third-party dependency this import touches (fastmcp,
258+
pydantic), which ``_purge_ha_mcp_modules`` deliberately does NOT
259+
reinstall per config entry, so a stale one left over from a previous
260+
install can break this import -- is logged instead of silently doing
261+
nothing: log cosmetics must never block startup, but they also must
262+
never fail invisibly.
263+
"""
264+
try:
265+
from ha_mcp.log_filters import install_sdk_log_filters
266+
except ModuleNotFoundError as err:
267+
if err.name != "ha_mcp.log_filters":
268+
_LOGGER.warning(
269+
"Could not install MCP SDK log-noise filters (missing "
270+
"dependency %s); continuing without them: %s",
271+
err.name,
272+
err,
273+
)
274+
return
275+
except ImportError as err:
276+
_LOGGER.warning(
277+
"Could not install MCP SDK log-noise filters; continuing without them: %s",
278+
err,
279+
)
280+
return
281+
try:
282+
install_sdk_log_filters()
283+
except Exception as err:
284+
_LOGGER.warning(
285+
"Could not install MCP SDK log-noise filters; continuing without them: %s",
286+
err,
287+
)
288+
289+
247290
class EmbeddedServerManager:
248291
"""Manage the lifecycle of the in-process ha-mcp server for one config entry."""
249292

@@ -1669,6 +1712,12 @@ async def _serve(self, access_token: str, stop_event: asyncio.Event) -> None:
16691712
else:
16701713
register_browser_landing(server.mcp, self._secret_path)
16711714

1715+
# Parity with the CLI HTTP runner: demote the MCP SDK/fastmcp log
1716+
# noise (routine stateless teardown, benign tool-validation
1717+
# tracebacks, disconnect-caused "session crashed" tracebacks) that
1718+
# every other HTTP launcher already filters.
1719+
_install_log_filters_if_available()
1720+
16721721
# Own the uvicorn server instead of calling mcp.run_async(): cancelling
16731722
# run_async's task does NOT release the listening socket in-process
16741723
# (live-found: the next bring-up failed with EADDRINUSE and uvicorn's

custom_components/ha_mcp_tools/llm_api.py

Lines changed: 30 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -49,17 +49,17 @@
4949
import asyncio
5050
import importlib
5151
import logging
52-
from collections.abc import AsyncIterator, Iterable
52+
from collections.abc import AsyncIterator, Callable, Iterable
5353
from contextlib import asynccontextmanager
5454
from dataclasses import dataclass
55+
from functools import cache
5556
from typing import TYPE_CHECKING, Any, cast
5657

5758
import voluptuous as vol
5859
from homeassistant.core import HomeAssistant
5960
from homeassistant.exceptions import HomeAssistantError
6061
from homeassistant.helpers import llm
6162
from homeassistant.helpers.httpx_client import get_async_client
62-
from voluptuous_openapi import convert_to_voluptuous
6363

6464
from .const import (
6565
DATA_LLM_API_UNSUB,
@@ -128,6 +128,25 @@
128128
_CALL_TOOL_NAME = "ha_call_tool"
129129
_SEARCH_RESULT_LIMIT = 8
130130

131+
132+
@cache
133+
def _schema_converter() -> Callable[[Any], Any]:
134+
"""Resolve the Core-provided schema converter once, off the event loop."""
135+
try:
136+
legacy = importlib.import_module("voluptuous_openapi")
137+
except ModuleNotFoundError as err:
138+
if err.name != "voluptuous_openapi":
139+
raise
140+
probatio = importlib.import_module("probatio")
141+
return cast(Callable[[Any], Any], probatio.from_openapi)
142+
return cast(Callable[[Any], Any], legacy.convert_to_voluptuous)
143+
144+
145+
def convert_to_voluptuous(schema: Any) -> vol.Schema:
146+
"""Convert an OpenAPI schema on stable and Probatio-based HA Core."""
147+
return cast(vol.Schema, _schema_converter()(schema))
148+
149+
131150
# Used when the server's initialize result carries no instructions (it always
132151
# should — ha-mcp ships server-level instructions — but never render an empty
133152
# prompt if a build does not).
@@ -199,24 +218,25 @@ def _is_transport_failure(err: BaseException) -> bool:
199218

200219

201220
def _import_mcp_sdk() -> None:
202-
"""Import the mcp client SDK modules (blocking; run on the executor).
221+
"""Import lazy LLM dependencies (blocking; run on the executor).
203222
204-
Raises ImportError when the SDK is not importable — the caller decides
205-
whether that skips registration (SDK missing entirely) or surfaces as a
206-
conversation error.
223+
Raises ImportError when the MCP SDK or Core's schema converter is not
224+
importable — the caller decides whether that skips registration or
225+
surfaces as a conversation error.
207226
"""
208227
importlib.import_module("mcp.client.session")
209228
importlib.import_module("mcp.client.streamable_http")
229+
_schema_converter()
210230

211231

212232
async def async_probe_mcp_sdk(hass: HomeAssistant) -> bool:
213-
"""Return True when the mcp client SDK imports (first import off-loop)."""
233+
"""Return True when lazy LLM dependencies import (first import off-loop)."""
214234
try:
215235
await hass.async_add_executor_job(_import_mcp_sdk)
216236
except ImportError as err:
217237
_LOGGER.warning(
218-
"The installed server package provides no importable 'mcp' client "
219-
"SDK (%s); the conversation-agent LLM API will not be available",
238+
"A required LLM dependency is not importable (%s); the "
239+
"conversation-agent LLM API will not be available",
220240
err,
221241
)
222242
return False
@@ -538,9 +558,7 @@ async def async_get_api_instance(
538558
def _convert_parameters(self, tool: Any) -> vol.Schema | None:
539559
"""Convert one tool's JSON schema, or None (logged) when it fails."""
540560
try:
541-
# cast: voluptuous_openapi is an untyped (ignored) import, so the
542-
# call returns Any; its documented return type is vol.Schema.
543-
return cast(vol.Schema, convert_to_voluptuous(tool.inputSchema))
561+
return convert_to_voluptuous(tool.inputSchema)
544562
except Exception:
545563
# One unconvertible schema must not take down the whole
546564
# toolset for the conversation — skip that tool, loudly.

homeassistant-addon-dev/Dockerfile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ RUN --mount=type=cache,target=/root/.cache/uv \
2222
uv sync --locked --no-dev --no-editable
2323

2424
# --- Runtime stage: clean image without uv ---
25-
FROM python:3.13-slim@sha256:3de9a8d7aedbb7984dc18f2dff178a7850f16c1ae7c34ba9d7ecc23d0755e35f
25+
FROM python:3.13-slim@sha256:7e3a6aca9d74f93cca21a91d86a8dad8c34749afd5b4a98ee481c9c47b9f5ed4
2626

2727
WORKDIR /app
2828

homeassistant-addon-dev/config.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
name: "Home Assistant MCP Server (Dev)"
22
description: "Development channel - AI assistant integration via MCP (unstable)"
3-
version: "8.3.0.dev2422"
3+
version: "8.3.0.dev2433"
44
slug: "ha_mcp_dev"
55
url: "https://github.com/homeassistant-ai/ha-mcp"
66
stage: experimental

homeassistant-addon/Dockerfile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ RUN --mount=type=cache,target=/root/.cache/uv \
2323
uv sync --locked --no-dev --no-editable
2424

2525
# --- Runtime stage: clean image without uv ---
26-
FROM python:3.13-slim@sha256:3de9a8d7aedbb7984dc18f2dff178a7850f16c1ae7c34ba9d7ecc23d0755e35f
26+
FROM python:3.13-slim@sha256:7e3a6aca9d74f93cca21a91d86a8dad8c34749afd5b4a98ee481c9c47b9f5ed4
2727

2828
WORKDIR /app
2929

homeassistant-addon/start.py

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -914,13 +914,13 @@ def main() -> int:
914914
# Import and register browser landing before server start
915915
log_info("Importing ha_mcp module...")
916916
from ha_mcp.__main__ import (
917-
StatelessSessionLogFilter,
918917
_get_server,
919918
_get_timestamped_uvicorn_log_config,
920919
_log_startup_version,
921920
mcp,
922921
register_browser_landing,
923922
)
923+
from ha_mcp.log_filters import install_sdk_log_filters
924924
from ha_mcp.settings_ui import register_settings_routes
925925

926926
# Importing ha_mcp pulled in fastmcp, which attached its rich log
@@ -966,9 +966,7 @@ def main() -> int:
966966
register_settings_routes(
967967
server_instance.mcp, server_instance, secret_path=secret_path
968968
)
969-
logging.getLogger("mcp.server.streamable_http").addFilter(
970-
StatelessSessionLogFilter()
971-
)
969+
install_sdk_log_filters()
972970

973971
# fastmcp's DNS-rebinding guard is defaulted off in ha_mcp's _create_server
974972
# (reached above via _get_server() / the `mcp` proxy, before the app is

renovate.json

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,10 @@
1818
"matchMessage": "Git error - aborting",
1919
"newLogLevel": "error"
2020
},
21+
{
22+
"matchMessage": "Repository has changed during renovation - aborting",
23+
"newLogLevel": "error"
24+
},
2125
{
2226
"matchMessage": "/^Some release\\(s\\) did not have a releaseTimestamp, but as we're running with minimumReleaseAgeBehaviour=timestamp-optional, proceeding\\. See debug logs for more information$/",
2327
"newLogLevel": "info"
@@ -50,7 +54,7 @@
5054
"datasourceTemplate": "{{{datasource}}}"
5155
},
5256
{
53-
"description": "Vendored library pins. src/ha_mcp/_vendor/ holds third-party code copied verbatim from the pinned sdist, so no package manager sees it: enabledManagers excludes pip_requirements and dependabot's uv ecosystem reads only pyproject.toml/uv.lock. Without this manager a CVE fix would never open a PR. A bump alone does not regenerate the tree tests/src/unit/test_vendored_websockets.py fails the renovate PR until scripts/vendor_websockets.py is re-run and the result committed, which is the intended handoff.",
57+
"description": "Vendored library pins. src/ha_mcp/_vendor/ holds third-party code copied verbatim from the pinned sdist, so no package manager sees it: enabledManagers excludes pip_requirements and dependabot's uv ecosystem reads only pyproject.toml/uv.lock. Without this manager a CVE fix would never open a PR. A bump alone does not regenerate the tree \u2014 tests/src/unit/test_vendored_websockets.py fails the renovate PR until scripts/vendor_websockets.py is re-run and the result committed, which is the intended handoff.",
5458
"customType": "regex",
5559
"managerFilePatterns": [
5660
"/^src/ha_mcp/_vendor/requirements\\.txt$/"

0 commit comments

Comments
 (0)