|
50 | 50 | import importlib |
51 | 51 | import logging |
52 | 52 | from collections.abc import AsyncIterator, Iterable |
53 | | -from contextlib import asynccontextmanager |
| 53 | +from contextlib import AsyncExitStack, asynccontextmanager |
54 | 54 | from dataclasses import dataclass |
55 | 55 | from typing import TYPE_CHECKING, Any, cast |
56 | 56 |
|
57 | 57 | import voluptuous as vol |
58 | 58 | from homeassistant.core import HomeAssistant |
59 | 59 | from homeassistant.exceptions import HomeAssistantError |
60 | 60 | from homeassistant.helpers import llm |
61 | | -from homeassistant.helpers.httpx_client import get_async_client |
62 | 61 | from voluptuous_openapi import convert_to_voluptuous |
63 | 62 |
|
64 | 63 | from .const import ( |
@@ -226,47 +225,63 @@ async def async_probe_mcp_sdk(hass: HomeAssistant) -> bool: |
226 | 225 | @asynccontextmanager |
227 | 226 | async def _mcp_session( |
228 | 227 | url: str, |
229 | | - http_client: Any = None, |
230 | 228 | ) -> AsyncIterator[tuple[ClientSession, mcp_types.InitializeResult]]: |
231 | 229 | """Open an initialized MCP session against the loopback server. |
232 | 230 |
|
233 | 231 | Imports resolve from ``sys.modules`` — :func:`async_probe_mcp_sdk` did the |
234 | 232 | real (blocking) import on the executor before the API was registered. |
235 | 233 |
|
236 | | - ``http_client`` is Home Assistant's shared httpx client |
237 | | - (``helpers.httpx_client.get_async_client``). Passing it is what keeps |
238 | | - this loop-safe: without it the SDK constructs its own httpx client per |
239 | | - session, whose SSL setup loads the CA bundle SYNCHRONOUSLY inside HA's |
240 | | - event loop (live-found — HA's blocking-call monitor flagged this exact |
241 | | - line). HA's shared client is built against the process-cached SSL |
242 | | - context, and the SDK does not close caller-owned clients (HA core's mcp |
243 | | - integration relies on the same contract). |
| 234 | + Builds a throwaway httpx client scoped to this one session rather than |
| 235 | + reusing Home Assistant's shared one (``helpers.httpx_client. |
| 236 | + get_async_client`` — the prior approach): the SDK applies NO timeout of |
| 237 | + its own when a caller-provided client is passed, so whatever timeout |
| 238 | + THAT client happens to carry becomes the real wire-level ceiling. HA's |
| 239 | + shared client is built with no explicit ``timeout=``, so it silently |
| 240 | + carries httpx's own hardcoded 5-second default — capping every tool call |
| 241 | + at 5 seconds of read-idle no matter how generous |
| 242 | + ``_CALL_TOOL_TIMEOUT_SECONDS`` / ``_LIST_TOOLS_TIMEOUT_SECONDS`` looked |
| 243 | + (live-found investigating a ~60s Assist-pipeline hang: a real tool doing |
| 244 | + real work never got anywhere near its own asyncio budget). |
| 245 | +
|
| 246 | + ``verify=False`` is not a security relaxation: ``url`` is always |
| 247 | + ``http://127.0.0.1:<port>...`` (see ``async_register_llm_api``) — a |
| 248 | + plain-HTTP loopback call that never negotiates TLS — so building a real |
| 249 | + SSL context would be pure waste. It also keeps this loop-safe the same |
| 250 | + way the shared client did: an SSL context built with ``verify=True`` |
| 251 | + loads the system CA bundle SYNCHRONOUSLY (live-found — HA's |
| 252 | + blocking-call monitor flagged this exact line when the SDK built its own |
| 253 | + default client), and skipping verification skips that load entirely. |
| 254 | + The client is entered on the exit stack so it closes with the rest of |
| 255 | + the session. |
244 | 256 | """ |
245 | 257 | from mcp.client.session import ClientSession |
246 | 258 |
|
247 | | - try: |
248 | | - from mcp.client.streamable_http import streamable_http_client |
249 | | - |
250 | | - transport = ( |
251 | | - streamable_http_client(url=url, http_client=http_client) |
252 | | - if http_client is not None |
253 | | - else streamable_http_client(url=url) |
254 | | - ) |
255 | | - except ImportError: |
256 | | - # Pre-rename SDK (an older ha-mcp resolved by a pip-spec override |
257 | | - # pins an older fastmcp/mcp): same call shape, deprecated name, but |
258 | | - # no http_client kwarg — it builds its own client, so on those old |
259 | | - # SDKs the blocking-SSL-setup warning is the accepted cost. |
260 | | - from mcp.client.streamable_http import ( |
261 | | - streamablehttp_client, |
262 | | - ) |
| 259 | + async with AsyncExitStack() as stack: |
| 260 | + try: |
| 261 | + from mcp.client.streamable_http import streamable_http_client |
| 262 | + except ImportError: |
| 263 | + # Pre-rename SDK (an older ha-mcp resolved by a pip-spec override |
| 264 | + # pins an older fastmcp/mcp): same call shape, deprecated name, |
| 265 | + # and no http_client kwarg — it builds its own default client, so |
| 266 | + # on those old SDKs the blocking-SSL-setup cost is unavoidable. |
| 267 | + from mcp.client.streamable_http import streamablehttp_client |
| 268 | + |
| 269 | + transport = streamablehttp_client(url=url) |
| 270 | + else: |
| 271 | + import httpx |
263 | 272 |
|
264 | | - transport = streamablehttp_client(url=url) |
| 273 | + http_client = await stack.enter_async_context( |
| 274 | + httpx.AsyncClient( |
| 275 | + verify=False, |
| 276 | + timeout=httpx.Timeout(_CALL_TOOL_TIMEOUT_SECONDS), |
| 277 | + ) |
| 278 | + ) |
| 279 | + transport = streamable_http_client(url=url, http_client=http_client) |
265 | 280 |
|
266 | | - async with ( |
267 | | - transport as (read_stream, write_stream, _), |
268 | | - ClientSession(read_stream, write_stream) as session, |
269 | | - ): |
| 281 | + read_stream, write_stream, _ = await stack.enter_async_context(transport) |
| 282 | + session = await stack.enter_async_context( |
| 283 | + ClientSession(read_stream, write_stream) |
| 284 | + ) |
270 | 285 | init_result = await session.initialize() |
271 | 286 | yield session, init_result |
272 | 287 |
|
@@ -350,7 +365,7 @@ async def _forward_tool_call( |
350 | 365 | try: |
351 | 366 | async with ( |
352 | 367 | asyncio.timeout(_CALL_TOOL_TIMEOUT_SECONDS), |
353 | | - _mcp_session(server_url, get_async_client(hass)) as (session, _init), |
| 368 | + _mcp_session(server_url) as (session, _init), |
354 | 369 | ): |
355 | 370 | result = await session.call_tool(name, arguments) |
356 | 371 | except _transport_errors() as err: |
@@ -496,7 +511,7 @@ async def async_get_api_instance( |
496 | 511 | try: |
497 | 512 | async with ( |
498 | 513 | asyncio.timeout(_LIST_TOOLS_TIMEOUT_SECONDS), |
499 | | - _mcp_session(self.server_url, get_async_client(self.hass)) as ( |
| 514 | + _mcp_session(self.server_url) as ( |
500 | 515 | session, |
501 | 516 | init_result, |
502 | 517 | ), |
|
0 commit comments