From a0af35f2dccff8c5b820ddffb9b63061346cb35c Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Fri, 13 Feb 2026 10:39:34 -0800 Subject: [PATCH] Improve MCP SSE fallback error handling (#162655) --- homeassistant/components/mcp/coordinator.py | 8 +++- tests/components/mcp/test_init.py | 41 ++++++++++++++------- 2 files changed, 35 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/mcp/coordinator.py b/homeassistant/components/mcp/coordinator.py index 6c3303c647d..2e299a06605 100644 --- a/homeassistant/components/mcp/coordinator.py +++ b/homeassistant/components/mcp/coordinator.py @@ -7,6 +7,7 @@ import datetime import logging import httpx +from mcp import McpError from mcp.client.session import ClientSession from mcp.client.sse import sse_client from mcp.client.streamable_http import streamable_http_client @@ -63,10 +64,15 @@ async def mcp_client( # Method not Allowed likely means this is not a streamable HTTP server, # but it may be an SSE server. This is part of the MCP Transport # backwards compatibility specification. + # We also handle other generic McpErrors since proxies may not respond + # consistently with a 405. if ( isinstance(main_error, httpx.HTTPStatusError) and main_error.response.status_code == 405 - ): + ) or isinstance(main_error, McpError): + _LOGGER.debug( + "Streamable HTTP client failed, attempting SSE client: %s", main_error + ) try: async with ( sse_client(url=url, headers=headers) as streams, diff --git a/tests/components/mcp/test_init.py b/tests/components/mcp/test_init.py index 24aebe0101f..1f063e445ee 100644 --- a/tests/components/mcp/test_init.py +++ b/tests/components/mcp/test_init.py @@ -4,7 +4,8 @@ import re from unittest.mock import AsyncMock, Mock, patch import httpx -from mcp.types import CallToolResult, ListToolsResult, TextContent, Tool +from mcp import McpError +from mcp.types import CallToolResult, ErrorData, ListToolsResult, TextContent, Tool import pytest import voluptuous as vol @@ -136,30 +137,44 @@ async def test_mcp_server_sse_transport_failure( "Connection error", [httpx.ConnectError("Connection failed")] ) - await hass.config_entries.async_setup(config_entry.entry_id) - assert config_entry.state is ConfigEntryState.SETUP_RETRY - +@pytest.mark.parametrize( + ("side_effect"), + [ + ( + ExceptionGroup( + "Method not allowed", + [ + httpx.HTTPStatusError( + "Method not allowed", + request=None, + response=httpx.Response(405), + ) + ], + ), + ), + ( + ExceptionGroup( + "Some exception group", + [McpError(ErrorData(code=500, message="Session terminated"))], + ) + ), + ], +) async def test_mcp_client_fallback_to_sse_success( hass: HomeAssistant, config_entry: MockConfigEntry, mock_http_streamable_client: AsyncMock, mock_sse_client: AsyncMock, mock_mcp_client: Mock, + side_effect: Exception, ) -> None: - """Test mcp_client falls back to SSE on method not allowed error. + """Test mcp_client falls back to SSE on some errors. This exercises the backwards compatibility part of the MCP Transport specification. """ - http_405 = httpx.HTTPStatusError( - "Method not allowed", - request=None, # type: ignore[arg-type] - response=httpx.Response(405), - ) - mock_http_streamable_client.side_effect = ExceptionGroup( - "Method not allowed", [http_405] - ) + mock_http_streamable_client.side_effect = side_effect # Setup mocks for SSE fallback mock_sse_client.return_value.__aenter__.return_value = ("read", "write")