From 8a2a3da34a0db8ccceb190e098d55903be9ffc77 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Thu, 27 Aug 2026 07:49:01 +0200 Subject: [PATCH] Avoid blocking SSL context creation in the MCP SSE transport (#180367) --- homeassistant/components/mcp/coordinator.py | 27 ++++++++++++++++++- tests/components/mcp/test_init.py | 29 +++++++++++++++++++++ 2 files changed, 55 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/mcp/coordinator.py b/homeassistant/components/mcp/coordinator.py index 2866cce270a8..52c4ed275934 100644 --- a/homeassistant/components/mcp/coordinator.py +++ b/homeassistant/components/mcp/coordinator.py @@ -27,6 +27,7 @@ from homeassistant.helpers import llm from homeassistant.helpers.httpx_client import create_async_httpx_client from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from homeassistant.util.json import JsonObjectType +from homeassistant.util.ssl import SSL_ALPN_HTTP11, SSLCipherList, client_context from .auth import AuthenticateHeader from .const import DOMAIN @@ -39,6 +40,26 @@ TIMEOUT = 10 type TokenManager = Callable[[], Awaitable[str]] +def _create_sse_httpx_client( + headers: dict[str, str] | None = None, + timeout: httpx.Timeout | None = None, + auth: httpx.Auth | None = None, +) -> httpx.AsyncClient: + """Create the httpx client used by the SSE transport. + + The SSE transport closes the client itself, so it cannot be handed one of + the Home Assistant managed clients. Building it here keeps it off the SDK + default, which reads the CA bundle from disk inside the event loop. + """ + return httpx.AsyncClient( + verify=client_context(SSLCipherList.PYTHON_DEFAULT, SSL_ALPN_HTTP11), + follow_redirects=True, + headers=headers, + timeout=timeout, + auth=auth, + ) + + @asynccontextmanager async def mcp_client( hass: HomeAssistant, @@ -81,7 +102,11 @@ async def mcp_client( ) try: async with ( - sse_client(url=url, headers=headers) as streams, + sse_client( + url=url, + headers=headers, + httpx_client_factory=_create_sse_httpx_client, + ) as streams, ClientSession(*streams) as session, ): await session.initialize() diff --git a/tests/components/mcp/test_init.py b/tests/components/mcp/test_init.py index ca11ce53679f..845e33d99c89 100644 --- a/tests/components/mcp/test_init.py +++ b/tests/components/mcp/test_init.py @@ -1,6 +1,7 @@ """Tests for the Model Context Protocol component.""" import re +import ssl from unittest.mock import AsyncMock, Mock, patch import httpx @@ -705,3 +706,31 @@ async def test_tool_call_http_error( ), create_llm_context(), ) + + +async def test_sse_client_does_not_build_ssl_context( + hass: HomeAssistant, + config_entry: MockConfigEntry, + mock_http_streamable_client: AsyncMock, + mock_sse_client: AsyncMock, +) -> None: + """Test the SSE transport does not load certificates in the event loop.""" + http_405 = httpx.HTTPStatusError( + "Method not allowed", request=None, response=httpx.Response(405) + ) + mock_http_streamable_client.side_effect = ExceptionGroup( + "Method not allowed", [http_405] + ) + mock_sse_client.side_effect = ExceptionGroup( + "Connection error", [httpx.ConnectError("Connection failed")] + ) + + await hass.config_entries.async_setup(config_entry.entry_id) + assert config_entry.state is ConfigEntryState.SETUP_RETRY + + client_factory = mock_sse_client.call_args.kwargs["httpx_client_factory"] + with patch.object(ssl.SSLContext, "load_verify_locations") as mock_load_certs: + client = client_factory(headers={}, timeout=httpx.Timeout(5)) + + assert not mock_load_certs.called + await client.aclose()