Skip to content

Commit 589d4a2

Browse files
Merge branch 'master' into docs/graph-ranking-comment
2 parents be94263 + af7a4d0 commit 589d4a2

10 files changed

Lines changed: 174 additions & 35 deletions

File tree

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/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.dev2429"
3+
version: "8.3.0.dev2436"
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

src/ha_mcp/client/rest_client.py

Lines changed: 13 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -823,18 +823,20 @@ async def _supervisor_logs_get(self, path: str, lines: int | None = None) -> str
823823
)
824824

825825
try:
826-
async with make_supervisor_httpx_client(
827-
timeout=httpx.Timeout(self.timeout),
828-
verify=self.verify_ssl,
829-
) as client:
830-
response = await client.get(
831-
relative_path,
832-
headers={"Accept": "text/plain"},
833-
params={"lines": lines} if lines is not None else None,
834-
)
835-
except httpx.TimeoutException as e:
826+
async with asyncio.timeout(self.timeout):
827+
async with make_supervisor_httpx_client(
828+
timeout=httpx.Timeout(self.timeout),
829+
verify=self.verify_ssl,
830+
) as client:
831+
response = await client.get(
832+
relative_path,
833+
headers={"Accept": "text/plain"},
834+
params={"lines": lines} if lines is not None else None,
835+
)
836+
except (TimeoutError, httpx.TimeoutException) as e:
836837
raise HomeAssistantConnectionError(
837-
f"Timeout fetching /{path}/logs from Supervisor: {e}"
838+
f"Timeout fetching /{path}/logs from Supervisor after "
839+
f"{self.timeout}s: {str(e) or type(e).__name__}"
838840
) from e
839841
except httpx.HTTPError as e:
840842
raise HomeAssistantConnectionError(

tests/haos_image_build/screenshot_engine_mock/Dockerfile

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,13 @@
55
# (a bad token fails, exactly like the real engine reaching only the login
66
# screen). python:3.13-slim is the same fast, reliable base the dev +
77
# webhook-proxy add-ons build from in this bake. No apt, no npm, no Chromium.
8-
# Digest-pinned (matches the webhook-proxy addon) so the in-Supervisor build is
9-
# reproducible and Renovate-bumpable — an unpinned tag would reintroduce the
10-
# floating-base bake failure this change exists to eliminate.
11-
FROM python:3.13-slim@sha256:3de9a8d7aedbb7984dc18f2dff178a7850f16c1ae7c34ba9d7ecc23d0755e35f
8+
# Digest-pinned (tracks the coordinated Python base every managed Dockerfile
9+
# shares -- see the "python" packageRule in renovate.json) so the
10+
# in-Supervisor build is reproducible and Renovate-bumpable. The
11+
# webhook-proxy add-ons are excluded from that automation and update only
12+
# through their own dev-first promotion workflow, so their pin can lag this
13+
# one. An unpinned tag here would reintroduce the floating-base bake failure
14+
# this change exists to eliminate.
15+
FROM python:3.13-slim@sha256:7e3a6aca9d74f93cca21a91d86a8dad8c34749afd5b4a98ee481c9c47b9f5ed4
1216
COPY server.py /server.py
1317
CMD ["python", "/server.py"]

tests/src/unit/_embedded_stubs.py

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -613,13 +613,18 @@ def setmod(name: str, **attrs: Any) -> ModuleType:
613613
async_register_api=_llm_async_register_api,
614614
)
615615
_pin_llm_on_helpers()
616-
# voluptuous_openapi (an HA-core runtime dependency, not installed in this
617-
# test environment). Pass-through conversion: the unit tests assert wiring,
618-
# not the real JSON-schema -> voluptuous translation.
616+
# HA Core's schema conversion dependency differs across supported releases:
617+
# stable uses voluptuous_openapi while 2026.9+ provides Probatio. Pass-through
618+
# conversion keeps these unit tests focused on wiring; compatibility selection
619+
# itself is covered in test_llm_api.
619620
setmod(
620621
"voluptuous_openapi",
621622
convert_to_voluptuous=lambda schema: {"_converted": schema},
622623
)
624+
setmod(
625+
"probatio",
626+
from_openapi=lambda schema: {"_converted": schema},
627+
)
623628
# aiohttp_client + event helpers for the periodic auto-update check
624629
# (embedded_setup fetches PyPI; embedded_entry registers the interval).
625630
setmod(

tests/src/unit/test_llm_api.py

Lines changed: 90 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414

1515
import asyncio
1616
import logging
17+
import threading
1718
from contextlib import asynccontextmanager
1819
from types import SimpleNamespace
1920
from typing import Any
@@ -260,7 +261,95 @@ def _boom() -> None:
260261

261262
assert fake_llm_apis(hass) == {}
262263
assert DATA_LLM_API_UNSUB not in hass.data.get(DOMAIN, {})
263-
assert "no importable 'mcp' client SDK" in caplog.text
264+
assert "required LLM dependency is not importable" in caplog.text
265+
266+
267+
class TestSchemaConversionCompatibility:
268+
@pytest.fixture(autouse=True)
269+
def _clear_schema_converter_cache(self):
270+
llm_api._schema_converter.cache_clear()
271+
yield
272+
llm_api._schema_converter.cache_clear()
273+
274+
def test_prefers_stable_core_converter_when_available(self, monkeypatch):
275+
schema = {"type": "object"}
276+
legacy = SimpleNamespace(
277+
convert_to_voluptuous=lambda value: {"voluptuous_openapi": value}
278+
)
279+
280+
def _import_module(name):
281+
assert name == "voluptuous_openapi"
282+
return legacy
283+
284+
monkeypatch.setattr(llm_api.importlib, "import_module", _import_module)
285+
286+
assert llm_api.convert_to_voluptuous(schema) == {"voluptuous_openapi": schema}
287+
288+
def test_falls_back_to_probatio_on_newer_core(self, monkeypatch):
289+
schema = {"type": "object"}
290+
probatio = SimpleNamespace(from_openapi=lambda value: {"probatio": value})
291+
292+
def _import_module(name):
293+
if name == "voluptuous_openapi":
294+
raise ModuleNotFoundError(
295+
"No module named 'voluptuous_openapi'",
296+
name="voluptuous_openapi",
297+
)
298+
assert name == "probatio"
299+
return probatio
300+
301+
monkeypatch.setattr(llm_api.importlib, "import_module", _import_module)
302+
303+
assert llm_api.convert_to_voluptuous(schema) == {"probatio": schema}
304+
305+
def test_reraises_nested_module_not_found(self, monkeypatch):
306+
def _import_module(name):
307+
assert name == "voluptuous_openapi"
308+
raise ModuleNotFoundError(
309+
"No module named 'legacy_dependency'",
310+
name="legacy_dependency",
311+
)
312+
313+
monkeypatch.setattr(llm_api.importlib, "import_module", _import_module)
314+
315+
with pytest.raises(ModuleNotFoundError, match="legacy_dependency"):
316+
llm_api.convert_to_voluptuous({"type": "object"})
317+
318+
async def test_converter_import_is_warmed_once_off_event_loop(self, monkeypatch):
319+
main_thread = threading.get_ident()
320+
imports: list[tuple[str, int]] = []
321+
legacy = SimpleNamespace(
322+
convert_to_voluptuous=lambda value: {"voluptuous_openapi": value}
323+
)
324+
325+
def _import_module(name):
326+
imports.append((name, threading.get_ident()))
327+
if name.startswith("mcp."):
328+
return SimpleNamespace()
329+
if name == "voluptuous_openapi":
330+
return legacy
331+
if name == "probatio":
332+
raise ModuleNotFoundError("No module named 'probatio'", name="probatio")
333+
raise AssertionError(name)
334+
335+
async def _executor(func, *args):
336+
return await asyncio.to_thread(func, *args)
337+
338+
hass = _make_hass()
339+
hass.async_add_executor_job = AsyncMock(side_effect=_executor)
340+
monkeypatch.setattr(llm_api.importlib, "import_module", _import_module)
341+
342+
assert await llm_api.async_probe_mcp_sdk(hass)
343+
schema = {"type": "object"}
344+
assert llm_api.convert_to_voluptuous(schema) == {"voluptuous_openapi": schema}
345+
assert llm_api.convert_to_voluptuous(schema) == {"voluptuous_openapi": schema}
346+
347+
assert [name for name, _ in imports] == [
348+
"mcp.client.session",
349+
"mcp.client.streamable_http",
350+
"voluptuous_openapi",
351+
]
352+
assert all(thread_id != main_thread for _, thread_id in imports)
264353

265354

266355
class TestFullModeInstance:

tests/src/unit/test_tools_utility_supervisor_logs.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
error translation.
1818
"""
1919

20+
import asyncio
2021
import json
2122
import re
2223
from pathlib import Path
@@ -435,6 +436,26 @@ async def test_raises_connection_error_on_timeout_with_distinct_message(
435436

436437
assert "Timeout" in str(exc_info.value)
437438

439+
@pytest.mark.asyncio
440+
@pytest.mark.timeout(10)
441+
async def test_supervisor_log_fetch_has_overall_deadline(
442+
self, mock_client, addon_install, mock_async_client_class
443+
):
444+
"""A stalled Supervisor log body must not wait forever on per-chunk IO."""
445+
inner_client, _ = mock_async_client_class
446+
mock_client.timeout = 0.01
447+
448+
async def _hang_forever(*_args, **_kwargs):
449+
"""Model a Supervisor response that never completes."""
450+
await asyncio.Event().wait()
451+
452+
inner_client.get.side_effect = _hang_forever
453+
454+
with pytest.raises(HomeAssistantConnectionError) as exc_info:
455+
await mock_client.get_addon_logs("core_mosquitto")
456+
457+
assert "after 0.01s: TimeoutError" in str(exc_info.value)
458+
438459
@pytest.mark.asyncio
439460
async def test_raises_connection_error_on_network_failure_with_distinct_message(
440461
self, mock_client, addon_install, mock_async_client_class

0 commit comments

Comments
 (0)