Skip to content

Commit c29ab1f

Browse files
swissmoclaudekingpanther13
authored
fix(component): give the loopback MCP session an actual generous timeout (#2276)
* fix(component): give the loopback MCP session an actual generous timeout llm_api._mcp_session handed the SDK Home Assistant's shared httpx client (helpers.httpx_client.get_async_client) so the SDK would never build its own client mid-event-loop (blocking CA-bundle load). But the SDK applies no timeout of its own when a caller-provided client is passed, and HA's shared client is built with no explicit timeout=, so it silently carried httpx's own hardcoded 5-second default — capping every tool call at 5s of read-idle no matter how generous _CALL_TOOL_TIMEOUT_SECONDS (300s) / _LIST_TOOLS_TIMEOUT_SECONDS (10s) looked. _mcp_session now builds its own short-lived client per session instead, with an explicit generous timeout and verify=False (the loopback URL is always plain http://127.0.0.1 and never negotiates TLS, so skipping verification is not a security relaxation — it just avoids the same blocking SSL-context setup the shared-client approach was working around). Found investigating a ~60s Assist-pipeline hang; ruled out as root cause for that specific report, but a real, independent bug regardless. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * fix(component): disable env proxy trust for the loopback MCP client httpx.AsyncClient defaults to trust_env=True, so under HTTP_PROXY without 127.0.0.1 in NO_PROXY, the loopback tool-call request would leave the listener entirely and go out through the configured proxy instead - also leaking url's embedded secret_path (the private endpoint credential) to that proxy, and breaking every Assist tool call in that environment. Pass trust_env=False; this client should never consult a proxy for a 127.0.0.1 request. Review finding (chatgpt-codex-connector) on #2276. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * fix(component): keep the pre-rename SDK fallback off env proxies too The trust_env=False fix only covered the canonical streamable_http_client path. The deprecated streamablehttp_client fallback (older fastmcp/mcp pinned via a pip-spec override) takes no http_client param, but it DOES accept an httpx_client_factory override for the client it builds internally - so it was still trusting HTTP_PROXY/NO_PROXY by default. Pass _loopback_httpx_client_factory there too, which builds the same verify=False/trust_env=False client as the canonical path. Review finding (chatgpt-codex-connector) on #2276. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * fix(component): mirror the SDK's None-timeout default in the fallback factory _loopback_httpx_client_factory forwarded timeout=None straight to httpx.AsyncClient, which disables every timeout rather than applying a sane default. create_mcp_http_client (the factory this substitutes for) treats None as "no timeout was supplied" and substitutes its own Timeout(30, read=300) - mirror that so a future zero-argument call to this factory doesn't get an unbounded loopback client. Also pin verify=False on both httpx.AsyncClient constructions via a constructor-kwargs spy (verify has no public accessor on a built client, so introspecting the result after construction can't cover it the way timeout/trust_env can). Review finding (Patch76) on #2276. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * fix(component): stop the fallback factory's own fix from breaking it The previous commit mirrored create_mcp_http_client's None-timeout handling by importing MCP_DEFAULT_TIMEOUT/MCP_DEFAULT_SSE_READ_TIMEOUT from mcp.shared._httpx_utils - but those constants don't exist before mcp 1.24, and _loopback_httpx_client_factory only ever runs on SDKs old enough to lack streamable_http_client (the canonical name tried first), which is exactly that pre-1.24 range. The unconditional import raised ImportError on every real install of the fallback it was meant to harden - verified against the actual SDK source at v1.23.3 and v1.24.0. Inline the substitute value instead: httpx.Timeout(30.0), matching what create_mcp_http_client's own None default actually is on the SDKs this fallback serves (the read=300 shape is itself a 1.24+ addition, so guessing it here would have been wrong anyway). Added a regression test that fakes mcp.shared._httpx_utils down to the pre-1.24 shape (create_mcp_http_client only) - confirmed it reproduces the exact ImportError against the previous commit before verifying the fix clears it. Review finding (Patch76) on #2276. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * docs(tests): add docstrings to functions touched by this PR's diff CodeRabbit's docstring-coverage pre-merge check flagged 38.89% on the 18 functions touched across this PR's two files (threshold 80%). All of the shortfall was in test_llm_api.py, in touched test methods and their nested fake-transport/fake-session helpers. Verified precisely by diffing the two files against the PR's merge base and checking every touched function for a docstring (19 touched, matching CodeRabbit's count within rounding) - all 13 missing ones now have one, without touching the existing rationale comments they sit alongside. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * docs(tests): narrow the "exact same calls" claim in the llm-api e2e docstring test_llm_api_client_path_full_catalog drives the webhook relay URL and lets the SDK build its own default httpx client, so it does not exercise the dedicated loopback client _mcp_session now hands the SDK. Say so instead of claiming the calls are exactly the same (optional review finding on this PR). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VrVRQZN3S9CsYhiqPnbKq1 --------- Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com> Co-authored-by: kingpanther13 <kingpanther13@users.noreply.github.com>
1 parent f2fc5f9 commit c29ab1f

4 files changed

Lines changed: 257 additions & 59 deletions

File tree

custom_components/ha_mcp_tools/llm_api.py

Lines changed: 111 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@
5050
import importlib
5151
import logging
5252
from collections.abc import AsyncIterator, Callable, Iterable
53-
from contextlib import asynccontextmanager
53+
from contextlib import AsyncExitStack, asynccontextmanager
5454
from dataclasses import dataclass
5555
from functools import cache
5656
from typing import TYPE_CHECKING, Any, cast
@@ -59,7 +59,6 @@
5959
from homeassistant.core import HomeAssistant
6060
from homeassistant.exceptions import HomeAssistantError
6161
from homeassistant.helpers import llm
62-
from homeassistant.helpers.httpx_client import get_async_client
6362

6463
from .const import (
6564
DATA_LLM_API_UNSUB,
@@ -72,6 +71,7 @@
7271
)
7372

7473
if TYPE_CHECKING:
74+
import httpx
7575
from homeassistant.config_entries import ConfigEntry
7676
from homeassistant.util.json import JsonObjectType
7777
from mcp import types as mcp_types
@@ -243,50 +243,127 @@ async def async_probe_mcp_sdk(hass: HomeAssistant) -> bool:
243243
return True
244244

245245

246+
def _loopback_httpx_client_factory(
247+
headers: dict[str, str] | None = None,
248+
timeout: httpx.Timeout | None = None,
249+
auth: httpx.Auth | None = None,
250+
) -> httpx.AsyncClient:
251+
"""``httpx_client_factory`` for the pre-rename SDK's ``streamablehttp_client``.
252+
253+
That deprecated entry point takes no ``http_client`` — it always builds
254+
its own via this factory — but the factory itself IS overridable, so the
255+
same ``verify=False`` / ``trust_env=False`` posture as the canonical path
256+
in :func:`_mcp_session` still applies: this fallback is loopback-only
257+
too, so a real SSL context is pure waste and this call must never be
258+
diverted through an environment proxy (which would also leak the URL's
259+
embedded ``secret_path`` to it).
260+
261+
``timeout`` mirrors ``create_mcp_http_client`` (the factory this
262+
substitutes for): a ``None`` here means "no timeout was supplied", not
263+
"disable timeouts" — the real ``streamablehttp_client`` caller always
264+
passes an explicit ``httpx.Timeout``, but a bare ``None`` reaching
265+
``httpx.AsyncClient`` directly disables every timeout outright (review
266+
finding), which is the wrong zero-argument default for a fallback aimed
267+
at unknown old environments.
268+
269+
The substitute value is inlined rather than imported from
270+
``mcp.shared._httpx_utils``: this factory only ever runs on SDKs old
271+
enough to lack ``streamable_http_client`` (the canonical name this
272+
module tries first), and ``MCP_DEFAULT_TIMEOUT`` /
273+
``MCP_DEFAULT_SSE_READ_TIMEOUT`` don't exist before mcp 1.24 either — an
274+
import would raise on every SDK version this fallback actually serves
275+
(round-2 review finding: the first attempt at this fix broke the exact
276+
path it was meant to harden). ``create_mcp_http_client``'s own ``None``
277+
default on those older SDKs is this same flat ``httpx.Timeout(30.0)``,
278+
with no separate read timeout (the ``read=300`` shape is itself a 1.24+
279+
addition) — matched here rather than guessed at.
280+
"""
281+
import httpx
282+
283+
if timeout is None:
284+
timeout = httpx.Timeout(30.0)
285+
286+
return httpx.AsyncClient(
287+
headers=headers, timeout=timeout, auth=auth, verify=False, trust_env=False
288+
)
289+
290+
246291
@asynccontextmanager
247292
async def _mcp_session(
248293
url: str,
249-
http_client: Any = None,
250294
) -> AsyncIterator[tuple[ClientSession, mcp_types.InitializeResult]]:
251295
"""Open an initialized MCP session against the loopback server.
252296
253297
Imports resolve from ``sys.modules`` — :func:`async_probe_mcp_sdk` did the
254298
real (blocking) import on the executor before the API was registered.
255299
256-
``http_client`` is Home Assistant's shared httpx client
257-
(``helpers.httpx_client.get_async_client``). Passing it is what keeps
258-
this loop-safe: without it the SDK constructs its own httpx client per
259-
session, whose SSL setup loads the CA bundle SYNCHRONOUSLY inside HA's
260-
event loop (live-found — HA's blocking-call monitor flagged this exact
261-
line). HA's shared client is built against the process-cached SSL
262-
context, and the SDK does not close caller-owned clients (HA core's mcp
263-
integration relies on the same contract).
300+
Builds a throwaway httpx client scoped to this one session rather than
301+
reusing Home Assistant's shared one (``helpers.httpx_client.
302+
get_async_client`` — the prior approach): the SDK applies NO timeout of
303+
its own when a caller-provided client is passed, so whatever timeout
304+
THAT client happens to carry becomes the real wire-level ceiling. HA's
305+
shared client is built with no explicit ``timeout=``, so it silently
306+
carries httpx's own hardcoded 5-second default — capping every tool call
307+
at 5 seconds of read-idle no matter how generous
308+
``_CALL_TOOL_TIMEOUT_SECONDS`` / ``_LIST_TOOLS_TIMEOUT_SECONDS`` looked
309+
(live-found investigating a ~60s Assist-pipeline hang: a real tool doing
310+
real work never got anywhere near its own asyncio budget).
311+
312+
``verify=False`` is not a security relaxation: ``url`` is always
313+
``http://127.0.0.1:<port>...`` (see ``async_register_llm_api``) — a
314+
plain-HTTP loopback call that never negotiates TLS — so building a real
315+
SSL context would be pure waste. It also keeps this loop-safe the same
316+
way the shared client did: an SSL context built with ``verify=True``
317+
loads the system CA bundle SYNCHRONOUSLY (live-found — HA's
318+
blocking-call monitor flagged this exact line when the SDK built its own
319+
default client), and skipping verification skips that load entirely.
320+
321+
``trust_env=False`` for the same "this is loopback, not the network"
322+
reason: httpx defaults to reading ``HTTP_PROXY``/``NO_PROXY`` from the
323+
environment, and ``127.0.0.1`` is not exempt unless ``NO_PROXY``
324+
explicitly lists it. Under an ``HTTP_PROXY`` that doesn't, this call
325+
would leave the loopback listener entirely and go out through the
326+
configured proxy instead — which also hands the proxy ``url``'s
327+
embedded ``secret_path`` (the private endpoint credential). Disabling
328+
env trust removes both failure modes; nothing here should ever consult
329+
a proxy.
330+
331+
The client is entered on the exit stack so it closes with the rest of
332+
the session.
264333
"""
265334
from mcp.client.session import ClientSession
266335

267-
try:
268-
from mcp.client.streamable_http import streamable_http_client
336+
async with AsyncExitStack() as stack:
337+
try:
338+
from mcp.client.streamable_http import streamable_http_client
339+
except ImportError:
340+
# Pre-rename SDK (an older ha-mcp resolved by a pip-spec override
341+
# pins an older fastmcp/mcp): same call shape, deprecated name,
342+
# and no http_client kwarg — but it does accept a factory for the
343+
# client it builds internally, so _loopback_httpx_client_factory
344+
# keeps this fallback on the same verify=False/trust_env=False
345+
# posture as the canonical path below.
346+
from mcp.client.streamable_http import streamablehttp_client
347+
348+
transport = streamablehttp_client(
349+
url=url, httpx_client_factory=_loopback_httpx_client_factory
350+
)
351+
else:
352+
import httpx
353+
354+
http_client = await stack.enter_async_context(
355+
httpx.AsyncClient(
356+
verify=False,
357+
trust_env=False,
358+
timeout=httpx.Timeout(_CALL_TOOL_TIMEOUT_SECONDS),
359+
)
360+
)
361+
transport = streamable_http_client(url=url, http_client=http_client)
269362

270-
transport = (
271-
streamable_http_client(url=url, http_client=http_client)
272-
if http_client is not None
273-
else streamable_http_client(url=url)
363+
read_stream, write_stream, _ = await stack.enter_async_context(transport)
364+
session = await stack.enter_async_context(
365+
ClientSession(read_stream, write_stream)
274366
)
275-
except ImportError:
276-
# Pre-rename SDK (an older ha-mcp resolved by a pip-spec override
277-
# pins an older fastmcp/mcp): same call shape, deprecated name, but
278-
# no http_client kwarg — it builds its own client, so on those old
279-
# SDKs the blocking-SSL-setup warning is the accepted cost.
280-
from mcp.client.streamable_http import (
281-
streamablehttp_client,
282-
)
283-
284-
transport = streamablehttp_client(url=url)
285-
286-
async with (
287-
transport as (read_stream, write_stream, _),
288-
ClientSession(read_stream, write_stream) as session,
289-
):
290367
init_result = await session.initialize()
291368
yield session, init_result
292369

@@ -370,7 +447,7 @@ async def _forward_tool_call(
370447
try:
371448
async with (
372449
asyncio.timeout(_CALL_TOOL_TIMEOUT_SECONDS),
373-
_mcp_session(server_url, get_async_client(hass)) as (session, _init),
450+
_mcp_session(server_url) as (session, _init),
374451
):
375452
result = await session.call_tool(name, arguments)
376453
except _transport_errors() as err:
@@ -516,7 +593,7 @@ async def async_get_api_instance(
516593
try:
517594
async with (
518595
asyncio.timeout(_LIST_TOOLS_TIMEOUT_SECONDS),
519-
_mcp_session(self.server_url, get_async_client(self.hass)) as (
596+
_mcp_session(self.server_url) as (
520597
session,
521598
init_result,
522599
),

tests/src/e2e/workflows/embedded/test_embedded_server.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -476,11 +476,15 @@ async def test_llm_api_client_path_full_catalog(self, embedded_ha):
476476
477477
``llm_api.py`` cannot be imported here (its module-level
478478
``homeassistant.*`` imports need a running HA), so this makes the
479-
exact same calls it makes, with the real SDK, against the real
479+
same call sequence it makes, with the real SDK, against the real
480480
server: streamable-HTTP session -> ``initialize`` (whose
481481
``instructions`` become the API prompt) -> ``tools/list`` ->
482482
``convert_to_voluptuous`` on EVERY tool's schema -> one read-only
483483
``call_tool`` dumped the way ``HaMcpTool.async_call`` returns it.
484+
The transport is NOT the same: this drives the webhook relay URL
485+
and lets the SDK build its own default httpx client, whereas
486+
``_mcp_session`` targets the loopback URL with a dedicated client
487+
(explicit generous timeout, ``verify=False``, ``trust_env=False``).
484488
485489
The zero-conversion-failures assertion is the point: at runtime an
486490
unconvertible schema is skipped per-tool with only a warning, so a

tests/src/unit/_embedded_stubs.py

Lines changed: 0 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -631,13 +631,6 @@ def setmod(name: str, **attrs: Any) -> ModuleType:
631631
"homeassistant.helpers.aiohttp_client",
632632
async_get_clientsession=MagicMock(name="async_get_clientsession"),
633633
)
634-
# Shared httpx client helper (#1745 llm_api): the SDK receives it as
635-
# http_client so session opens never build a client (and its blocking
636-
# SSL setup) inside the event loop.
637-
setmod(
638-
"homeassistant.helpers.httpx_client",
639-
get_async_client=MagicMock(name="get_async_client"),
640-
)
641634
setmod(
642635
"homeassistant.helpers.event",
643636
async_track_time_interval=MagicMock(

0 commit comments

Comments
 (0)