Files
core/tests/components/mcp/test_config_flow.py
T

1351 lines
42 KiB
Python

"""Test the Model Context Protocol config flow."""
import json
from typing import Any
from unittest.mock import AsyncMock, Mock
import httpx
import pytest
import respx
from homeassistant import config_entries
from homeassistant.components.mcp.auth import AuthenticateHeader
from homeassistant.components.mcp.const import (
CONF_AUTHORIZATION_URL,
CONF_SCOPE,
CONF_SLUG,
CONF_TOKEN_URL,
DOMAIN,
)
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,
CLIENT_ID,
MCP_SERVER_URL,
OAUTH_AUTHORIZE_URL,
OAUTH_TOKEN_URL,
TEST_API_NAME,
)
from tests.common import MockConfigEntry
from tests.test_util.aiohttp import AiohttpClientMocker
from tests.typing import ClientSessionGenerator
MCP_SERVER_BASE_URL = "http://1.1.1.1:8080"
OAUTH_DISCOVERY_ENDPOINT = (
f"{MCP_SERVER_BASE_URL}/.well-known/oauth-authorization-server/mcp"
)
AUTHORIZATION_SERVER = "https://example-auth-server.com"
OAUTH_AUTHORIZATION_SERVER_DISCOVERY_ENDPOINT = (
f"{AUTHORIZATION_SERVER}/.well-known/oauth-authorization-server"
)
SCOPES_SUPPORTED = ["profile", "email", "phone"]
OAUTH_PROTECTED_RESOURCE_METADATA_RESPONSE = httpx.Response(
status_code=200,
json={
"resource": MCP_SERVER_URL,
"authorization_servers": [
AUTHORIZATION_SERVER,
],
"scopes_supported": SCOPES_SUPPORTED,
"bearer_methods_supported": ["header"],
},
)
OAUTH_SERVER_METADATA_RESPONSE = httpx.Response(
status_code=200,
text=json.dumps(
{
"authorization_endpoint": OAUTH_AUTHORIZE_URL,
"token_endpoint": OAUTH_TOKEN_URL,
"scopes_supported": ["read", "write"],
}
),
)
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",
"type": "Bearer",
"expires_in": 60,
"scope": " ".join(SCOPES),
}
def encode_state(hass: HomeAssistant, flow_id: str) -> str:
"""Encode the OAuth JWT."""
return config_entry_oauth2_flow._encode_jwt(
hass,
{
"flow_id": flow_id,
"redirect_uri": OAUTH_CALLBACK_URL,
},
)
async def test_form(
hass: HomeAssistant, mock_setup_entry: AsyncMock, mock_mcp_client: Mock
) -> None:
"""Test the complete configuration flow."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
assert result["type"] is FlowResultType.FORM
assert result["errors"] == {}
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"],
{
CONF_URL: MCP_SERVER_URL,
},
)
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["title"] == TEST_API_NAME
assert result["data"] == {
CONF_URL: MCP_SERVER_URL,
}
# Config entry does not have a unique id
assert result["result"]
assert result["result"].unique_id is None
assert len(mock_setup_entry.mock_calls) == 1
@pytest.mark.parametrize(
("side_effect", "expected_error"),
[
(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"),
],
)
async def test_form_mcp_client_error(
hass: HomeAssistant,
mock_setup_entry: AsyncMock,
mock_mcp_client: Mock,
side_effect: Exception,
expected_error: str,
) -> None:
"""Test we handle different client library errors."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
mock_mcp_client.side_effect = side_effect
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
CONF_URL: MCP_SERVER_URL,
},
)
assert result["type"] is FlowResultType.FORM
assert result["errors"] == {"base": expected_error}
# Reset the error and make sure the config flow can resume successfully.
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"],
{
CONF_URL: MCP_SERVER_URL,
},
)
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["title"] == TEST_API_NAME
assert result["data"] == {
CONF_URL: MCP_SERVER_URL,
}
assert len(mock_setup_entry.mock_calls) == 1
@pytest.mark.parametrize(
"user_input",
[
({CONF_URL: "not a url"}),
({CONF_URL: "rtsp://1.1.1.1"}),
],
)
async def test_input_form_validation_error(
hass: HomeAssistant,
mock_setup_entry: AsyncMock,
mock_mcp_client: Mock,
user_input: dict[str, Any],
) -> None:
"""Test we handle invalid auth."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input,
)
assert result["type"] is FlowResultType.FORM
assert result["errors"] == {CONF_URL: "invalid_url"}
# Reset the error and make sure the config flow can resume successfully.
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"],
{
CONF_URL: MCP_SERVER_URL,
},
)
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["title"] == TEST_API_NAME
assert result["data"] == {
CONF_URL: MCP_SERVER_URL,
}
assert len(mock_setup_entry.mock_calls) == 1
@pytest.mark.usefixtures("mock_setup_entry")
async def test_unique_url(hass: HomeAssistant, mock_mcp_client: Mock) -> None:
"""Test that the same url cannot be configured twice."""
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_USER}
)
assert result["type"] is FlowResultType.FORM
assert result["errors"] == {}
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"],
{
CONF_URL: MCP_SERVER_URL,
},
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "already_configured"
@pytest.mark.usefixtures("mock_setup_entry")
async def test_server_missing_capbilities(
hass: HomeAssistant, mock_mcp_client: Mock
) -> None:
"""Test we handle different client library errors."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
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"],
{
CONF_URL: MCP_SERVER_URL,
},
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "missing_capabilities"
@respx.mock
@pytest.mark.usefixtures("mock_setup_entry")
async def test_oauth_discovery_flow_without_credentials(
hass: HomeAssistant, mock_mcp_client: Mock
) -> None:
"""Test OAuth discoveryflow when user has no credentials yet."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
# MCP Server returns 401 indicating the client needs to authenticate
mock_mcp_client.side_effect = httpx.HTTPStatusError(
"Authentication required", request=None, response=httpx.Response(401)
)
# Prepare the OAuth Server metadata
respx.get(OAUTH_DISCOVERY_ENDPOINT).mock(
return_value=OAUTH_SERVER_METADATA_RESPONSE
)
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
CONF_URL: MCP_SERVER_URL,
},
)
# The config flow will abort and the user will be taken to the
# application credentials UI to enter their credentials.
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "missing_credentials"
async def perform_oauth_flow(
hass: HomeAssistant,
aioclient_mock: AiohttpClientMocker,
hass_client_no_auth: ClientSessionGenerator,
result: config_entries.ConfigFlowResult,
authorize_url: str = OAUTH_AUTHORIZE_URL,
token_url: str = OAUTH_TOKEN_URL,
scopes: list[str] | None = None,
) -> config_entries.ConfigFlowResult:
"""Perform the common steps of the OAuth flow.
Expects to be called from the step where the user selects credentials.
"""
state = config_entry_oauth2_flow._encode_jwt(
hass,
{
"flow_id": result["flow_id"],
"redirect_uri": OAUTH_CALLBACK_URL,
},
)
scope_param = ""
if scopes:
scope_param = "&scope=" + "+".join(scopes)
assert result["url"] == (
f"{authorize_url}?response_type=code&client_id={CLIENT_ID}"
f"&redirect_uri={OAUTH_CALLBACK_URL}"
f"&state={state}"
# Asked for so the server hands back a refresh token
f"&access_type=offline&prompt=consent{scope_param}"
)
client = await hass_client_no_auth()
resp = await client.get(f"{CALLBACK_PATH}?code={OAUTH_CODE}&state={state}")
assert resp.status == 200
assert resp.headers["content-type"] == "text/html; charset=utf-8"
aioclient_mock.post(
token_url,
json=OAUTH_TOKEN_PAYLOAD,
)
return result
@pytest.mark.parametrize(
(
"oauth_server_metadata_response",
"expected_authorize_url",
"expected_token_url",
"scopes",
),
[
(OAUTH_SERVER_METADATA_RESPONSE, OAUTH_AUTHORIZE_URL, OAUTH_TOKEN_URL, SCOPES),
(
httpx.Response(
status_code=200,
text=json.dumps(
{
"authorization_endpoint": "/authorize-path",
"token_endpoint": "/token-path",
}
),
),
f"{MCP_SERVER_BASE_URL}/authorize-path",
f"{MCP_SERVER_BASE_URL}/token-path",
None,
),
(
httpx.Response(status_code=404),
f"{MCP_SERVER_BASE_URL}/authorize",
f"{MCP_SERVER_BASE_URL}/token",
None,
),
],
ids=(
"discovery",
"relative_paths",
"no_discovery_metadata",
),
)
@pytest.mark.usefixtures("current_request_with_host")
@respx.mock
async def test_authentication_flow(
hass: HomeAssistant,
mock_setup_entry: AsyncMock,
mock_mcp_client: Mock,
credential: None,
aioclient_mock: AiohttpClientMocker,
hass_client_no_auth: ClientSessionGenerator,
oauth_server_metadata_response: httpx.Response,
expected_authorize_url: str,
expected_token_url: str,
scopes: list[str] | None,
) -> None:
"""Test for an OAuth authentication flow for an MCP server."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
# MCP Server returns 401 indicating the client needs to authenticate
mock_mcp_client.side_effect = httpx.HTTPStatusError(
"Authentication required", request=None, response=httpx.Response(401)
)
# Prepare the OAuth Server metadata
respx.get(OAUTH_DISCOVERY_ENDPOINT).mock(
return_value=oauth_server_metadata_response
)
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
CONF_URL: MCP_SERVER_URL,
},
)
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,
authorize_url=expected_authorize_url,
token_url=expected_token_url,
scopes=scopes,
)
# Client now accepts credentials
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["title"] == TEST_API_NAME
data = result["data"]
token = data.pop(CONF_TOKEN)
assert data == {
"auth_implementation": AUTH_DOMAIN,
CONF_URL: MCP_SERVER_URL,
CONF_AUTHORIZATION_URL: expected_authorize_url,
CONF_TOKEN_URL: expected_token_url,
CONF_SCOPE: scopes,
}
assert token
token.pop("expires_at")
assert token == OAUTH_TOKEN_PAYLOAD
assert len(mock_setup_entry.mock_calls) == 1
@pytest.mark.usefixtures("current_request_with_host")
@respx.mock
@pytest.mark.parametrize(
("authenticate_header", "resource_metadata_url", "expected_scopes"),
[
(
'Bearer error="invalid_token", resource_metadata="https://example.com/custom-discovery"',
"https://example.com/custom-discovery",
SCOPES_SUPPORTED,
),
(
'Bearer error="invalid_token", resource_metadata="/custom-discovery"',
f"{MCP_SERVER_BASE_URL}/custom-discovery",
SCOPES_SUPPORTED,
),
(
'Bearer error="invalid_token",'
' resource_metadata="https://example.com/custom-discovery"'
' scope="read write"',
"https://example.com/custom-discovery",
["read", "write"],
),
],
ids=[
"absolute_url",
"relative_url",
"with_scopes",
],
)
async def test_authentication_discovery_via_header(
hass: HomeAssistant,
mock_setup_entry: AsyncMock,
mock_mcp_client: Mock,
credential: None,
aioclient_mock: AiohttpClientMocker,
hass_client_no_auth: ClientSessionGenerator,
authenticate_header: str,
resource_metadata_url: str,
expected_scopes: list[str],
) -> None:
"""Test for an OAuth discovery flow using the WWW-Authenticate header."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
# MCP Server returns 401 when first trying to connect via config
# flow validate_input. The response value has a WWW-Authenticate
# header with a full URL for the resource metadata.
mock_mcp_client.side_effect = httpx.HTTPStatusError(
"Authentication required",
request=None,
response=httpx.Response(
401,
headers={
"WWW-Authenticate": authenticate_header,
},
),
)
# Discovery process starts. It hits the custom discovery URL directly.
respx.get(resource_metadata_url).mock(
return_value=OAUTH_PROTECTED_RESOURCE_METADATA_RESPONSE
)
respx.get(OAUTH_AUTHORIZATION_SERVER_DISCOVERY_ENDPOINT).mock(
return_value=OAUTH_SERVER_METADATA_RESPONSE
)
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
CONF_URL: MCP_SERVER_URL,
},
)
# Should proceed to credentials choice
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,
authorize_url=OAUTH_AUTHORIZE_URL,
token_url=OAUTH_TOKEN_URL,
scopes=expected_scopes,
)
# Client now accepts credentials
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["title"] == TEST_API_NAME
data = result["data"]
token = data.pop(CONF_TOKEN)
assert data == {
"auth_implementation": AUTH_DOMAIN,
CONF_URL: MCP_SERVER_URL,
CONF_AUTHORIZATION_URL: OAUTH_AUTHORIZE_URL,
CONF_TOKEN_URL: OAUTH_TOKEN_URL,
CONF_SCOPE: expected_scopes,
}
assert token
token.pop("expires_at")
assert token == OAUTH_TOKEN_PAYLOAD
assert len(mock_setup_entry.mock_calls) == 1
@pytest.mark.usefixtures("current_request_with_host", "mock_setup_entry")
@respx.mock
@pytest.mark.parametrize(
("resource_metadata"),
[
{
"authorization_servers": [
AUTHORIZATION_SERVER,
],
"scopes_supported": SCOPES_SUPPORTED,
"bearer_methods_supported": ["header"],
},
{
"resource": "https://different-resource.com",
"authorization_servers": [
AUTHORIZATION_SERVER,
],
"scopes_supported": SCOPES_SUPPORTED,
"bearer_methods_supported": ["header"],
},
{
"resource": MCP_SERVER_URL,
"scopes_supported": SCOPES_SUPPORTED,
"bearer_methods_supported": ["header"],
},
],
ids=[
"missing_resource",
"mismatched_resource",
"no_authorization_servers",
],
)
async def test_invalid_protected_resource_metadata(
hass: HomeAssistant,
mock_mcp_client: Mock,
credential: None,
aioclient_mock: AiohttpClientMocker,
hass_client_no_auth: ClientSessionGenerator,
resource_metadata: dict[str, Any],
) -> None:
"""Test for an OAuth discovery flow using the WWW-Authenticate header."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
# MCP Server returns 401 when first trying to connect via config
# flow validate_input. The response value has a WWW-Authenticate
# header with a full URL for the resource metadata.
resource_metadata_url = "https://example.com/custom-discovery"
mock_mcp_client.side_effect = httpx.HTTPStatusError(
"Authentication required",
request=None,
response=httpx.Response(
401,
headers={
"WWW-Authenticate": (
'Bearer error="invalid_token",'
f' resource_metadata="{resource_metadata_url}"'
),
},
),
)
# Discovery process starts. It hits the custom discovery URL directly.
respx.get(resource_metadata_url).mock(
return_value=httpx.Response(
status_code=200,
json=resource_metadata,
)
)
respx.get(OAUTH_AUTHORIZATION_SERVER_DISCOVERY_ENDPOINT).mock(
return_value=OAUTH_SERVER_METADATA_RESPONSE
)
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
CONF_URL: MCP_SERVER_URL,
},
)
assert result.get("type") is FlowResultType.ABORT
assert result.get("reason") == "cannot_connect"
@pytest.mark.parametrize(
("side_effect", "expected_error"),
[
(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("current_request_with_host", "mock_setup_entry")
@respx.mock
async def test_oauth_discovery_failure(
hass: HomeAssistant,
mock_mcp_client: Mock,
credential: None,
aioclient_mock: AiohttpClientMocker,
hass_client_no_auth: ClientSessionGenerator,
side_effect: Exception,
expected_error: str,
) -> None:
"""Test for an OAuth authentication flow for an MCP server."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
# MCP Server returns 401 indicating the client needs to authenticate
mock_mcp_client.side_effect = httpx.HTTPStatusError(
"Authentication required", request=None, response=httpx.Response(401)
)
# Prepare the OAuth Server metadata
respx.get(OAUTH_DISCOVERY_ENDPOINT).mock(side_effect=side_effect)
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
CONF_URL: MCP_SERVER_URL,
},
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == expected_error
@pytest.mark.parametrize(
("side_effect", "expected_error"),
[
(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("current_request_with_host", "mock_setup_entry")
@respx.mock
async def test_authentication_flow_server_failure_abort(
hass: HomeAssistant,
mock_mcp_client: Mock,
credential: None,
aioclient_mock: AiohttpClientMocker,
hass_client_no_auth: ClientSessionGenerator,
side_effect: Exception,
expected_error: str,
) -> None:
"""Test for an OAuth authentication flow for an MCP server."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
# MCP Server returns 401 indicating the client needs to authenticate
mock_mcp_client.side_effect = httpx.HTTPStatusError(
"Authentication required", request=None, response=httpx.Response(401)
)
# Prepare the OAuth Server metadata
respx.get(OAUTH_DISCOVERY_ENDPOINT).mock(
return_value=OAUTH_SERVER_METADATA_RESPONSE
)
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
CONF_URL: MCP_SERVER_URL,
},
)
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,
)
# Client fails with an error
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_error
@pytest.mark.usefixtures("current_request_with_host", "mock_setup_entry")
@respx.mock
async def test_authentication_flow_server_missing_tool_capabilities(
hass: HomeAssistant,
mock_mcp_client: Mock,
credential: None,
aioclient_mock: AiohttpClientMocker,
hass_client_no_auth: ClientSessionGenerator,
) -> None:
"""Test for an OAuth authentication flow for an MCP server."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
# MCP Server returns 401 indicating the client needs to authenticate
mock_mcp_client.side_effect = httpx.HTTPStatusError(
"Authentication required", request=None, response=httpx.Response(401)
)
# Prepare the OAuth Server metadata
respx.get(OAUTH_DISCOVERY_ENDPOINT).mock(
return_value=OAUTH_SERVER_METADATA_RESPONSE
)
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
CONF_URL: MCP_SERVER_URL,
},
)
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,
)
# Client can now authenticate
mock_mcp_client.side_effect = None
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"
@pytest.mark.usefixtures("current_request_with_host")
@respx.mock
async def test_reauth_flow(
hass: HomeAssistant,
mock_setup_entry: AsyncMock,
mock_mcp_client: Mock,
credential: None,
config_entry_with_auth: MockConfigEntry,
aioclient_mock: AiohttpClientMocker,
hass_client_no_auth: ClientSessionGenerator,
) -> None:
"""Test for an OAuth authentication flow for an MCP server."""
config_entry_with_auth.async_start_reauth(hass)
await hass.async_block_till_done()
flows = hass.config_entries.flow.async_progress()
assert len(flows) == 1
result = flows[0]
assert result["step_id"] == "reauth_confirm"
result = await hass.config_entries.flow.async_configure(result["flow_id"], {})
result = await perform_oauth_flow(
hass, aioclient_mock, hass_client_no_auth, result, scopes=SCOPES
)
# Verify we can connect to the server
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.ABORT
assert result["reason"] == "reauth_successful"
assert config_entry_with_auth.unique_id == AUTH_DOMAIN
assert config_entry_with_auth.title == TEST_API_NAME
data = {**config_entry_with_auth.data}
token = data.pop(CONF_TOKEN)
assert data == {
"auth_implementation": AUTH_DOMAIN,
CONF_URL: MCP_SERVER_URL,
CONF_AUTHORIZATION_URL: OAUTH_AUTHORIZE_URL,
CONF_TOKEN_URL: OAUTH_TOKEN_URL,
CONF_SCOPE: ["read", "write"],
}
assert token
token.pop("expires_at")
assert token == OAUTH_TOKEN_PAYLOAD
assert len(mock_setup_entry.mock_calls) == 1
@pytest.mark.usefixtures("current_request_with_host")
@respx.mock
async def test_reauth_flow_upgrade_to_oauth(
hass: HomeAssistant,
mock_setup_entry: AsyncMock,
mock_mcp_client: Mock,
credential: None,
aioclient_mock: AiohttpClientMocker,
hass_client_no_auth: ClientSessionGenerator,
) -> None:
"""Test reauth flow upgrading a no-auth entry to OAuth."""
config_entry = MockConfigEntry(
domain=DOMAIN,
data={CONF_URL: MCP_SERVER_URL},
title=TEST_API_NAME,
)
config_entry.add_to_hass(hass)
auth_header = AuthenticateHeader(
resource_metadata_url="https://example.com/custom-discovery",
scopes=SCOPES_SUPPORTED,
)
# Start reauth flow passing auth_header
config_entry.async_start_reauth(hass, data={"auth_header": auth_header})
await hass.async_block_till_done()
flows = hass.config_entries.flow.async_progress()
assert len(flows) == 1
result = flows[0]
assert result["step_id"] == "reauth_confirm"
# Mock discovery URLs (bypassing connection validation)
respx.get("https://example.com/custom-discovery").mock(
return_value=OAUTH_PROTECTED_RESOURCE_METADATA_RESPONSE
)
respx.get(OAUTH_AUTHORIZATION_SERVER_DISCOVERY_ENDPOINT).mock(
return_value=OAUTH_SERVER_METADATA_RESPONSE
)
# Click Submit on reauth_confirm
result = await hass.config_entries.flow.async_configure(result["flow_id"], {})
# Flow should proceed to credentials choice
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,
authorize_url=OAUTH_AUTHORIZE_URL,
token_url=OAUTH_TOKEN_URL,
scopes=SCOPES_SUPPORTED,
)
# Verify we can connect to the server now with the token
response = Mock()
response.serverInfo.name = TEST_API_NAME
# Return success for validation in async_oauth_create_entry
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"] == "reauth_successful"
assert config_entry.unique_id is None
assert config_entry.title == TEST_API_NAME
data = {**config_entry.data}
token = data.pop(CONF_TOKEN)
assert data == {
"auth_implementation": AUTH_DOMAIN,
CONF_URL: MCP_SERVER_URL,
CONF_AUTHORIZATION_URL: OAUTH_AUTHORIZE_URL,
CONF_TOKEN_URL: OAUTH_TOKEN_URL,
CONF_SCOPE: SCOPES_SUPPORTED,
}
assert token
token.pop("expires_at")
assert token == OAUTH_TOKEN_PAYLOAD
assert len(mock_setup_entry.mock_calls) == 1
@pytest.mark.usefixtures("current_request_with_host")
@respx.mock
async def test_reauth_flow_upgrade_to_oauth_no_auth_header(
hass: HomeAssistant,
mock_setup_entry: AsyncMock,
mock_mcp_client: Mock,
credential: None,
aioclient_mock: AiohttpClientMocker,
hass_client_no_auth: ClientSessionGenerator,
) -> None:
"""Test reauth flow upgrading a no-auth entry to OAuth when no auth header is passed (fallback)."""
config_entry = MockConfigEntry(
domain=DOMAIN,
data={CONF_URL: MCP_SERVER_URL},
title=TEST_API_NAME,
)
config_entry.add_to_hass(hass)
# Start reauth flow without passing auth_header
config_entry.async_start_reauth(hass)
await hass.async_block_till_done()
flows = hass.config_entries.flow.async_progress()
assert len(flows) == 1
result = flows[0]
assert result["step_id"] == "reauth_confirm"
# Mock discovery on the default server URL (since there is no auth_header)
respx.get(OAUTH_DISCOVERY_ENDPOINT).mock(
return_value=OAUTH_SERVER_METADATA_RESPONSE
)
# Click Submit on reauth_confirm
result = await hass.config_entries.flow.async_configure(result["flow_id"], {})
# Flow should proceed directly to credentials choice menu (without validate_input)
assert result["type"] is FlowResultType.MENU
assert result["step_id"] == "credentials_choice"
@pytest.mark.usefixtures("current_request_with_host")
@respx.mock
async def test_reauth_flow_missing_implementation(
hass: HomeAssistant,
mock_setup_entry: AsyncMock,
mock_mcp_client: Mock,
credential: None,
aioclient_mock: AiohttpClientMocker,
hass_client_no_auth: ClientSessionGenerator,
) -> None:
"""Test reauth recovers when the stored implementation was removed."""
config_entry = MockConfigEntry(
domain=DOMAIN,
data={
"auth_implementation": "removed",
CONF_URL: MCP_SERVER_URL,
CONF_AUTHORIZATION_URL: OAUTH_AUTHORIZE_URL,
CONF_TOKEN_URL: OAUTH_TOKEN_URL,
},
title=TEST_API_NAME,
)
config_entry.add_to_hass(hass)
config_entry.async_start_reauth(hass)
await hass.async_block_till_done()
flows = hass.config_entries.flow.async_progress()
assert len(flows) == 1
result = flows[0]
assert result["step_id"] == "reauth_confirm"
respx.get(OAUTH_DISCOVERY_ENDPOINT).mock(
return_value=OAUTH_SERVER_METADATA_RESPONSE
)
result = await hass.config_entries.flow.async_configure(result["flow_id"], {})
# Instead of erroring out, the user can pick or create credentials again
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,
authorize_url=OAUTH_AUTHORIZE_URL,
token_url=OAUTH_TOKEN_URL,
scopes=SCOPES,
)
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.ABORT
assert result["reason"] == "reauth_successful"
# The entry now points at an implementation that exists again
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