diff --git a/homeassistant/components/mcp/__init__.py b/homeassistant/components/mcp/__init__.py index d14033bded36..573bb7bd7583 100644 --- a/homeassistant/components/mcp/__init__.py +++ b/homeassistant/components/mcp/__init__.py @@ -10,7 +10,7 @@ from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import config_entry_oauth2_flow, llm from .application_credentials import authorization_server_context -from .const import CONF_AUTHORIZATION_URL, CONF_TOKEN_URL, DOMAIN +from .const import CONF_AUTHORIZATION_URL, CONF_SLUG, CONF_TOKEN_URL, DOMAIN from .coordinator import ModelContextProtocolCoordinator, TokenManager from .types import ModelContextProtocolConfigEntry @@ -72,11 +72,12 @@ async def async_setup_entry( coordinator = ModelContextProtocolCoordinator(hass, entry, token_manager) await coordinator.async_config_entry_first_refresh() + api_id = f"{DOMAIN}-{entry.data.get(CONF_SLUG, entry.entry_id)}" unsub = llm.async_register_api( hass, ModelContextProtocolAPI( hass=hass, - id=f"{DOMAIN}-{entry.entry_id}", + id=api_id, name=entry.title, coordinator=coordinator, ), diff --git a/homeassistant/components/mcp/config_flow.py b/homeassistant/components/mcp/config_flow.py index 46bc93934490..d0b0747494e6 100644 --- a/homeassistant/components/mcp/config_flow.py +++ b/homeassistant/components/mcp/config_flow.py @@ -20,11 +20,12 @@ from homeassistant.helpers.config_entry_oauth2_flow import ( AbstractOAuth2FlowHandler, async_get_implementations, ) +from homeassistant.helpers.service_info.hassio import HassioServiceInfo from . import async_get_config_entry_implementation from .application_credentials import authorization_server_context from .auth import AuthenticateHeader -from .const import CONF_AUTHORIZATION_URL, CONF_SCOPE, CONF_TOKEN_URL, DOMAIN +from .const import CONF_AUTHORIZATION_URL, CONF_SCOPE, CONF_SLUG, CONF_TOKEN_URL, DOMAIN from .coordinator import TokenManager, mcp_client _LOGGER = logging.getLogger(__name__) @@ -153,6 +154,7 @@ class ModelContextProtocolConfigFlow(AbstractOAuth2FlowHandler, domain=DOMAIN): self.data: dict[str, Any] = {} self.oauth_config: OAuthConfig | None = None self.auth_header: AuthenticateHeader | None = None + self.addon_name: str = "" @override async def async_step_user( @@ -189,6 +191,59 @@ class ModelContextProtocolConfigFlow(AbstractOAuth2FlowHandler, domain=DOMAIN): description_placeholders={"example_url": EXAMPLE_URL}, ) + @override + async def async_step_hassio( + self, discovery_info: HassioServiceInfo + ) -> ConfigFlowResult: + """Handle discovery of an MCP server provided by an app.""" + url = discovery_info.config.get(CONF_URL) + try: + # An unparsable URL, such as an unmatched IPv6 bracket, raises ValueError + url = cv.url(url) + except vol.Invalid, ValueError: + _LOGGER.debug( + "Ignoring discovery from app %s with invalid URL: %s", + discovery_info.slug, + url, + ) + return self.async_abort(reason="invalid_discovery_info") + + await self.async_set_unique_id(discovery_info.uuid) + self._abort_if_unique_id_configured(updates={CONF_URL: url}) + self._async_abort_entries_match({CONF_URL: url}) + self.data[CONF_URL] = url + self.data[CONF_SLUG] = discovery_info.slug + self.addon_name = discovery_info.name + return await self.async_step_hassio_confirm() + + async def async_step_hassio_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Confirm the MCP server provided by an app.""" + if user_input is None: + self._set_confirm_only() + return self.async_show_form( + step_id="hassio_confirm", + description_placeholders={"addon": self.addon_name}, + ) + + try: + info = await validate_input(self.hass, self.data) + except TimeoutConnectError: + return self.async_abort(reason="timeout_connect") + except CannotConnect: + return self.async_abort(reason="cannot_connect") + except InvalidAuth as err: + self.auth_header = err.metadata + return await self.async_step_auth_discovery() + except MissingCapabilities: + return self.async_abort(reason="missing_capabilities") + except Exception: + _LOGGER.exception("Unexpected exception") + return self.async_abort(reason="unknown") + + return self.async_create_entry(title=info["title"], data=self.data) + async def async_step_auth_discovery( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: @@ -326,12 +381,15 @@ class ModelContextProtocolConfigFlow(AbstractOAuth2FlowHandler, domain=DOMAIN): _LOGGER.exception("Unexpected exception") return self.async_abort(reason="unknown") - # Unique id based on the application credentials OAuth Client ID if self.source == SOURCE_REAUTH: return self.async_update_reload_and_abort( self._get_reauth_entry(), data=config_entry_data ) - await self.async_set_unique_id(config_entry_data["auth_implementation"]) + if self.unique_id is None: + # Unique id based on the application credentials OAuth Client ID. A + # discovered server keeps the Supervisor uuid instead, so that the + # entry is removed together with the app. + await self.async_set_unique_id(config_entry_data["auth_implementation"]) return self.async_create_entry( title=info["title"], data=config_entry_data, diff --git a/homeassistant/components/mcp/const.py b/homeassistant/components/mcp/const.py index 2170976e084a..1750ba4251e1 100644 --- a/homeassistant/components/mcp/const.py +++ b/homeassistant/components/mcp/const.py @@ -5,3 +5,4 @@ DOMAIN = "mcp" CONF_AUTHORIZATION_URL = "authorization_url" CONF_TOKEN_URL = "token_url" CONF_SCOPE = "scope" +CONF_SLUG = "slug" diff --git a/homeassistant/components/mcp/quality_scale.yaml b/homeassistant/components/mcp/quality_scale.yaml index 3f3a08e46cb3..9b1e65f415b1 100644 --- a/homeassistant/components/mcp/quality_scale.yaml +++ b/homeassistant/components/mcp/quality_scale.yaml @@ -58,8 +58,8 @@ rules: status: exempt comment: Integration does not have devices. diagnostics: todo - discovery-update-info: todo - discovery: todo + discovery-update-info: done + discovery: done docs-data-update: done docs-examples: done docs-known-limitations: done diff --git a/homeassistant/components/mcp/strings.json b/homeassistant/components/mcp/strings.json index a736b3b41da2..f4a2229fd508 100644 --- a/homeassistant/components/mcp/strings.json +++ b/homeassistant/components/mcp/strings.json @@ -4,6 +4,7 @@ "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "invalid_discovery_info": "Invalid discovery information received", "missing_capabilities": "The MCP server does not support a required capability (Tools)", "reauth_account_mismatch": "The authenticated user does not match the MCP Server user that needed re-authentication.", "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", @@ -29,6 +30,10 @@ }, "title": "Choose how to authenticate with the MCP server" }, + "hassio_confirm": { + "description": "Do you want to configure Home Assistant to connect to the Model Context Protocol server provided by the app: {addon}?", + "title": "Model Context Protocol server via Home Assistant app" + }, "pick_implementation": { "data": { "implementation": "[%key:common::config_flow::data::implementation%]" diff --git a/tests/components/mcp/test_config_flow.py b/tests/components/mcp/test_config_flow.py index 1b2b6c53cb85..17303fe0bf7a 100644 --- a/tests/components/mcp/test_config_flow.py +++ b/tests/components/mcp/test_config_flow.py @@ -13,6 +13,7 @@ from homeassistant.components.mcp.auth import AuthenticateHeader from homeassistant.components.mcp.const import ( CONF_AUTHORIZATION_URL, CONF_SCOPE, + CONF_SLUG, CONF_TOKEN_URL, DOMAIN, ) @@ -20,6 +21,7 @@ from homeassistant.const import CONF_TOKEN, CONF_URL from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers import config_entry_oauth2_flow +from homeassistant.helpers.service_info.hassio import HassioServiceInfo from .conftest import ( AUTH_DOMAIN, @@ -68,6 +70,13 @@ SCOPES = ["read", "write"] CALLBACK_PATH = "/auth/external/callback" OAUTH_CALLBACK_URL = f"https://example.com{CALLBACK_PATH}" OAUTH_CODE = "abcd" +ADDON_NAME = "Example MCP Server" +ADDON_DISCOVERY_INFO = HassioServiceInfo( + config={"addon": ADDON_NAME, CONF_URL: MCP_SERVER_URL}, + name=ADDON_NAME, + slug="example_mcp_server", + uuid="1234", +) OAUTH_TOKEN_PAYLOAD = { "refresh_token": "mock-refresh-token", "access_token": "mock-access-token", @@ -1095,3 +1104,245 @@ async def test_reauth_flow_missing_implementation( assert config_entry.data["auth_implementation"] == AUTH_DOMAIN assert config_entry.data[CONF_TOKEN] assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_hassio_discovery_flow( + hass: HomeAssistant, mock_setup_entry: AsyncMock, mock_mcp_client: Mock +) -> None: + """Test the discovery flow for an MCP server provided by an app.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_HASSIO}, + data=ADDON_DISCOVERY_INFO, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "hassio_confirm" + assert result["description_placeholders"] == {"addon": ADDON_NAME} + + response = Mock() + response.serverInfo.name = TEST_API_NAME + mock_mcp_client.return_value.initialize.return_value = response + + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == TEST_API_NAME + assert result["data"] == { + CONF_URL: MCP_SERVER_URL, + CONF_SLUG: ADDON_DISCOVERY_INFO.slug, + } + # The discovery uuid lets Supervisor remove the entry with the app + assert result["result"] + assert result["result"].unique_id == ADDON_DISCOVERY_INFO.uuid + assert len(mock_setup_entry.mock_calls) == 1 + + +@pytest.mark.parametrize( + "config", + [ + pytest.param({}, id="missing_url"), + pytest.param({CONF_URL: "not a url"}, id="invalid_url"), + pytest.param({CONF_URL: "http://[::1/mcp"}, id="unparsable_url"), + ], +) +@pytest.mark.usefixtures("mock_setup_entry") +async def test_hassio_discovery_invalid_url( + hass: HomeAssistant, config: dict[str, Any] +) -> None: + """Test an app that sends discovery info without a usable URL.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_HASSIO}, + data=HassioServiceInfo( + config=config, + name=ADDON_NAME, + slug="example_mcp_server", + uuid="1234", + ), + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "invalid_discovery_info" + + +@pytest.mark.parametrize( + "entry_url", + [ + pytest.param("http://1.1.1.1:9999/mcp", id="app_moved"), + pytest.param(MCP_SERVER_URL, id="app_restarted"), + ], +) +@pytest.mark.usefixtures("mock_setup_entry") +async def test_hassio_discovery_updates_url( + hass: HomeAssistant, entry_url: str +) -> None: + """Test discovery of an already configured app keeps its entry up to date.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + unique_id=ADDON_DISCOVERY_INFO.uuid, + data={CONF_URL: entry_url}, + title=TEST_API_NAME, + ) + config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_HASSIO}, + data=ADDON_DISCOVERY_INFO, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + assert config_entry.data == {CONF_URL: MCP_SERVER_URL} + + +@pytest.mark.usefixtures("mock_setup_entry") +async def test_hassio_discovery_already_configured(hass: HomeAssistant) -> None: + """Test the discovered MCP server is already configured.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_URL: MCP_SERVER_URL}, + title=TEST_API_NAME, + ) + config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_HASSIO}, + data=ADDON_DISCOVERY_INFO, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +@pytest.mark.parametrize( + ("side_effect", "expected_reason"), + [ + (httpx.TimeoutException("Some timeout"), "timeout_connect"), + ( + httpx.HTTPStatusError("", request=None, response=httpx.Response(500)), + "cannot_connect", + ), + (httpx.HTTPError("Some HTTP error"), "cannot_connect"), + (Exception, "unknown"), + ], +) +@pytest.mark.usefixtures("mock_setup_entry") +async def test_hassio_discovery_mcp_client_error( + hass: HomeAssistant, + mock_mcp_client: Mock, + side_effect: Exception, + expected_reason: str, +) -> None: + """Test the discovered MCP server cannot be reached.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_HASSIO}, + data=ADDON_DISCOVERY_INFO, + ) + mock_mcp_client.side_effect = side_effect + + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == expected_reason + + +@pytest.mark.usefixtures("mock_setup_entry") +async def test_hassio_discovery_missing_capabilities( + hass: HomeAssistant, mock_mcp_client: Mock +) -> None: + """Test the discovered MCP server does not support tools.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_HASSIO}, + data=ADDON_DISCOVERY_INFO, + ) + response = Mock() + response.serverInfo.name = TEST_API_NAME + response.capabilities.tools = None + mock_mcp_client.return_value.initialize.return_value = response + + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "missing_capabilities" + + +@respx.mock +@pytest.mark.usefixtures("mock_setup_entry") +async def test_hassio_discovery_requires_authentication( + hass: HomeAssistant, mock_mcp_client: Mock +) -> None: + """Test the discovered MCP server continues into the OAuth flow.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_HASSIO}, + data=ADDON_DISCOVERY_INFO, + ) + mock_mcp_client.side_effect = httpx.HTTPStatusError( + "Authentication required", request=None, response=httpx.Response(401) + ) + respx.get(OAUTH_DISCOVERY_ENDPOINT).mock( + return_value=OAUTH_SERVER_METADATA_RESPONSE + ) + + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + + # The user is taken to the application credentials UI to enter credentials. + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "missing_credentials" + + +@pytest.mark.usefixtures("current_request_with_host") +@respx.mock +async def test_hassio_discovery_authentication_flow( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_mcp_client: Mock, + credential: None, + aioclient_mock: AiohttpClientMocker, + hass_client_no_auth: ClientSessionGenerator, +) -> None: + """Test an OAuth flow for a discovered MCP server keeps the discovery uuid.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_HASSIO}, + data=ADDON_DISCOVERY_INFO, + ) + mock_mcp_client.side_effect = httpx.HTTPStatusError( + "Authentication required", request=None, response=httpx.Response(401) + ) + respx.get(OAUTH_DISCOVERY_ENDPOINT).mock( + return_value=OAUTH_SERVER_METADATA_RESPONSE + ) + + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "credentials_choice" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"next_step_id": "pick_implementation"}, + ) + assert result["type"] is FlowResultType.EXTERNAL_STEP + result = await perform_oauth_flow( + hass, + aioclient_mock, + hass_client_no_auth, + result, + scopes=SCOPES, + ) + + mock_mcp_client.side_effect = None + response = Mock() + response.serverInfo.name = TEST_API_NAME + mock_mcp_client.return_value.initialize.return_value = response + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["result"] + assert result["result"].unique_id == ADDON_DISCOVERY_INFO.uuid + assert len(mock_setup_entry.mock_calls) == 1 diff --git a/tests/components/mcp/test_init.py b/tests/components/mcp/test_init.py index 845e33d99c89..745d47870f92 100644 --- a/tests/components/mcp/test_init.py +++ b/tests/components/mcp/test_init.py @@ -10,8 +10,9 @@ from mcp.types import CallToolResult, ErrorData, ListToolsResult, TextContent, T import pytest import voluptuous as vol -from homeassistant.components.mcp.const import DOMAIN +from homeassistant.components.mcp.const import CONF_SLUG, DOMAIN from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import CONF_URL from homeassistant.core import Context, HomeAssistant from homeassistant.exceptions import ( ConfigEntryAuthFailed, @@ -734,3 +735,40 @@ async def test_sse_client_does_not_build_ssl_context( assert not mock_load_certs.called await client.aclose() + + +async def test_llm_api_id(hass: HomeAssistant, mock_mcp_client: Mock) -> None: + """Test the LLM API id of a discovered server survives a reinstall of the app.""" + mock_mcp_client.return_value.list_tools.return_value = ListToolsResult( + tools=[SEARCH_MEMORY_TOOL], + ) + config_entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_URL: "http://1.1.1.1/mcp", CONF_SLUG: "a0d7b954_mcp"}, + title=TEST_API_NAME, + ) + config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(config_entry.entry_id) + assert config_entry.state is ConfigEntryState.LOADED + + apis = llm.async_get_apis(hass) + api = next(iter([api for api in apis if api.name == TEST_API_NAME])) + assert api.id == "mcp-a0d7b954_mcp" + + await hass.config_entries.async_remove(config_entry.entry_id) + + # Reinstalling the app discovers the server again as a new config entry + config_entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_URL: "http://1.1.1.1/mcp", CONF_SLUG: "a0d7b954_mcp"}, + title=TEST_API_NAME, + ) + config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(config_entry.entry_id) + assert config_entry.state is ConfigEntryState.LOADED + + apis = llm.async_get_apis(hass) + api = next(iter([api for api in apis if api.name == TEST_API_NAME])) + assert api.id == "mcp-a0d7b954_mcp"