diff --git a/airbyte/_direct_connectors/api_util.py b/airbyte/_direct_connectors/api_util.py index c55ff4238..7b818af40 100644 --- a/airbyte/_direct_connectors/api_util.py +++ b/airbyte/_direct_connectors/api_util.py @@ -27,6 +27,7 @@ ) from airbyte.exceptions import ( AirbyteAgentsUnavailableError, + AirbyteCloudApiError, AirbyteError, PyAirbyteInputError, ) @@ -83,6 +84,16 @@ def _error_guidance(*, response: requests.Response) -> str | None: "Authentication succeeded but access was denied; the workspace or connector " "may not be enabled for agent access in Airbyte Cloud." ) + if response.status_code in { + HTTPStatus.BAD_GATEWAY, + HTTPStatus.SERVICE_UNAVAILABLE, + HTTPStatus.GATEWAY_TIMEOUT, + }: + return ( + "Airbyte Cloud accepted the request but its upstream direct-access " + "service failed or timed out. This is not a credentials or permissions " + "problem; retry later or contact Airbyte support if it persists." + ) return None @@ -94,7 +105,7 @@ def is_not_enabled_error(error: AirbyteError) -> bool: Auth, server, and malformed-response failures carry other statuses and must not be read as "not enabled". """ - return (error.context or {}).get("status_code") in { + return isinstance(error, AirbyteCloudApiError) and error.status_code in { HTTPStatus.FORBIDDEN, HTTPStatus.NOT_FOUND, } @@ -115,8 +126,8 @@ def make_cloud_agent_request( workspace, so no organization header is sent. Raises `AirbyteAgentsUnavailableError` when the credentials' API roots have no - Context layer API, and `AirbyteError` with the status code and response text on - non-2xx responses, or when the response is not a JSON object. + Context layer API, `AirbyteCloudApiError` with the status code and response text on + non-2xx responses, or `AirbyteError` when the response is not a JSON object. """ if not deployment.is_agents_api_available( public_api_root=credentials.public_api_root, @@ -151,7 +162,8 @@ def make_cloud_agent_request( timeout=_REQUEST_TIMEOUT_SECONDS, ) if not status_ok(response.status_code): - raise AirbyteError( + raise AirbyteCloudApiError( + status_code=response.status_code, message=_error_message(response=response, full_url=full_url), guidance=_error_guidance(response=response), context={ diff --git a/airbyte/exceptions.py b/airbyte/exceptions.py index a9f7da05f..0178c04a6 100644 --- a/airbyte/exceptions.py +++ b/airbyte/exceptions.py @@ -585,6 +585,13 @@ class AirbyteAgentsUnavailableError(AirbyteError): ) +@dataclass +class AirbyteCloudApiError(AirbyteError): + """The Airbyte Cloud Config API returned a non-2xx response.""" + + status_code: int | None = None + + @dataclass class AirbyteExternalAccessNotEnabledError(PyAirbyteError): """The connector is not enabled for external access, so it cannot execute direct actions.""" diff --git a/airbyte/mcp/cloud.py b/airbyte/mcp/cloud.py index 313b335b9..c2a70c8c8 100644 --- a/airbyte/mcp/cloud.py +++ b/airbyte/mcp/cloud.py @@ -66,6 +66,7 @@ ) from airbyte.destinations.util import get_noop_destination from airbyte.exceptions import ( + AirbyteCloudApiError, AirbyteConnectorNotRegisteredError, AirbyteDeferredSetupError, AirbyteError, @@ -174,11 +175,15 @@ def _get_connector_check_message(check_result: CheckResult) -> str | None: "query. `enabled_features` is only resolved and returned when this filter is set; " "use `direct_access` to find every connector with any external-access feature " "enabled and see its full feature list. Omit to list every connector with " - "`enabled_features='not_checked'` (no feature check performed)." + "`enabled_features='not_checked'` (no feature check performed). Connectors whose " + "feature lookup fails are returned with `enabled_features='unknown'` and a " + "`warnings` entry, so they can still be inspected or tried." ) FEATURES_NOT_CHECKED: Final = "not_checked" FeaturesNotChecked = Literal["not_checked"] +FEATURES_UNKNOWN: Final = "unknown" +FeaturesUnknown = Literal["unknown"] CONNECTOR_TYPE_TIP_TEXT = ( @@ -1094,9 +1099,15 @@ class CloudConnectorResult(BaseModel): """The connector's display name.""" url: str """The connector's page in the Airbyte Cloud UI.""" - enabled_features: list[ConnectorFeature] | FeaturesNotChecked = FEATURES_NOT_CHECKED + enabled_features: list[ConnectorFeature] | FeaturesNotChecked | FeaturesUnknown = ( + FEATURES_NOT_CHECKED + ) """Features enabled for this connector. `"not_checked"` means no feature check was - performed (no `feature_filter`); an empty list means checked with nothing enabled.""" + performed (no `feature_filter`); `"unknown"` means the feature lookup failed (see + `warnings`); an empty list means checked with nothing enabled.""" + + warnings: list[str] = Field(default_factory=list) + """Non-fatal issues encountered while resolving this connector's features.""" @mcp_tool( @@ -1151,27 +1162,89 @@ def list_cloud_connectors( matching connectors with their `enabled_features` resolved; without it, `enabled_features` is `"not_checked"`. A returned `"not_checked"` means no feature check was performed; `[]` means checked and no features enabled. + + When a connector's feature lookup fails for a reason other than "not enabled", + it is returned with `enabled_features="unknown"` plus a `warnings` entry. A + 502/503/504 or transport failure means the endpoint is down for the whole + workspace, so remaining connectors are returned as `"unknown"` without further + probes; other failures mark only that connector `"unknown"`. `"unknown"` + results can still be inspected or tried via `describe_cloud_connector` or the + execute tools. """ + if limit is not None and limit <= 0: + raise PyAirbyteInputError(message="`limit` must be greater than 0.") workspace: CloudWorkspace = _get_cloud_workspace(ctx, workspace_id) connectors = workspace.list_connectors( connector_type=connector_type, - feature_filter=feature_filter, name_contains=name_contains, - limit=limit, + limit=None if feature_filter is not None else limit, ) - # Note: name and url are guaranteed non-null from list API responses - return [ - CloudConnectorResult( - id=connector.connector_id, - connector_type=connector.connector_type.value, - name=cast(str, connector.name), - url=connector.connector_url, - enabled_features=sorted(connector.enabled_features) - if feature_filter is not None - else FEATURES_NOT_CHECKED, + if feature_filter is None: + # Note: name and url are guaranteed non-null from list API responses + return [ + CloudConnectorResult( + id=connector.connector_id, + connector_type=connector.connector_type.value, + name=cast(str, connector.name), + url=connector.connector_url, + ) + for connector in connectors + ] + + results: list[CloudConnectorResult] = [] + probe_failure: str | None = None + for connector in connectors: + features: frozenset[ConnectorFeature] | None = None + if probe_failure is None: + try: + features = connector.enabled_features + except (AirbyteError, requests.RequestException) as error: + warning = f"Connector feature lookup failed; enabled features are unknown: {error}" + if isinstance(error, requests.RequestException) or ( + isinstance(error, AirbyteCloudApiError) + and error.status_code + in { + HTTPStatus.BAD_GATEWAY, + HTTPStatus.SERVICE_UNAVAILABLE, + HTTPStatus.GATEWAY_TIMEOUT, + } + ): + probe_failure = warning + else: + results.append( + CloudConnectorResult( + id=connector.connector_id, + connector_type=connector.connector_type.value, + name=cast(str, connector.name), + url=connector.connector_url, + enabled_features=FEATURES_UNKNOWN, + warnings=[warning], + ) + ) + if limit is not None and len(results) >= limit: + break + continue + if probe_failure is not None: + enabled_features: list[ConnectorFeature] | FeaturesUnknown = FEATURES_UNKNOWN + connector_warnings = [probe_failure] + elif features is not None and feature_filter in features: + enabled_features = sorted(features) + connector_warnings = [] + else: + continue + results.append( + CloudConnectorResult( + id=connector.connector_id, + connector_type=connector.connector_type.value, + name=cast(str, connector.name), + url=connector.connector_url, + enabled_features=enabled_features, + warnings=connector_warnings, + ) ) - for connector in connectors - ] + if limit is not None and len(results) >= limit: + break + return results class CloudConnectorDetailsResult(BaseModel): @@ -1200,8 +1273,9 @@ class CloudConnectorDetailsResult(BaseModel): integration_name: str | None = None """Name of the underlying integration, for example `GitHub` or `Snowflake`.""" - enabled_features: list[ConnectorFeature] = Field(default_factory=list) - """Features enabled for this connector; see `ConnectorFeature`.""" + enabled_features: list[ConnectorFeature] | FeaturesUnknown = Field(default_factory=list) + """Features enabled for this connector; see `ConnectorFeature`. `"unknown"` means + the feature lookup failed (see `warnings`).""" config: dict[str, Any] | None = None """The connector configuration, populated only by `with_config`. @@ -1249,18 +1323,22 @@ def _describe_cloud_connector( connector_url=connector.connector_url, connector_definition_id=connector.definition_id, integration_name=integration_name, - enabled_features=sorted(connector.enabled_features), ) - if ( - connector.is_feature_enabled(ConnectorFeature.DIRECT_ACCESS) - and connector.workspace._has_context_layer_api() # noqa: SLF001 - ): - context_layer = connector._context_layer_inspect( # noqa: SLF001 - warnings=warnings, - ) - if context_layer is not None: - warnings.extend(str(warning) for warning in context_layer.warnings) + try: + result.enabled_features = sorted(connector.enabled_features) + if ( + connector.is_feature_enabled(ConnectorFeature.DIRECT_ACCESS) + and connector.workspace._has_context_layer_api() # noqa: SLF001 + ): + context_layer = connector._context_layer_inspect( # noqa: SLF001 + warnings=warnings, + ) + if context_layer is not None: + warnings.extend(str(warning) for warning in context_layer.warnings) + except (AirbyteError, requests.RequestException) as error: + result.enabled_features = FEATURES_UNKNOWN + warnings.append(f"Connector feature lookup failed; enabled features are unknown: {error}") if with_config and connector_type == ConnectorType.DESTINATION: try: diff --git a/tests/unit_tests/test_cloud_credentials.py b/tests/unit_tests/test_cloud_credentials.py index 2be4aad95..519baca04 100644 --- a/tests/unit_tests/test_cloud_credentials.py +++ b/tests/unit_tests/test_cloud_credentials.py @@ -35,6 +35,7 @@ from airbyte.cloud import workspaces as cloud_workspaces from airbyte.cloud.workspaces import CloudWorkspace from airbyte.exceptions import ( + AirbyteCloudApiError, AirbyteError, AirbyteMissingResourceError, PyAirbyteInputError, @@ -1702,7 +1703,7 @@ def fake_read_cloud_skill_docs(**kwargs: object) -> dict[str, object]: skill_id = str(kwargs["skill_id"]) connector_id = skill_id.rsplit(":", 1)[-1] if connector_id not in enabled_ids: - raise AirbyteError(context={"status_code": 404}) + raise AirbyteCloudApiError(status_code=404) return { "metadata": { "id": skill_id, diff --git a/tests/unit_tests/test_cloud_describe.py b/tests/unit_tests/test_cloud_describe.py index 40659a83f..08e9f5566 100644 --- a/tests/unit_tests/test_cloud_describe.py +++ b/tests/unit_tests/test_cloud_describe.py @@ -33,6 +33,7 @@ ) from airbyte.cloud.workspaces import CloudWorkspace from airbyte.exceptions import ( + AirbyteCloudApiError, AirbyteError, AirbyteExternalAccessNotEnabledError, PyAirbyteInputError, @@ -259,8 +260,8 @@ def test_direct_access_guidance_id_source_without_context_layer( @pytest.mark.parametrize( "probe_error", [ - pytest.param(AirbyteError(context={"status_code": 404}), id="not_found"), - pytest.param(AirbyteError(context={"status_code": 403}), id="forbidden"), + pytest.param(AirbyteCloudApiError(status_code=404), id="not_found"), + pytest.param(AirbyteCloudApiError(status_code=403), id="forbidden"), ], ) def test_direct_access_guidance_id_source_probe_not_enabled( @@ -351,8 +352,8 @@ def test_get_direct_access_guidance_non_passthrough_destination_raises( @pytest.mark.parametrize( "probe_error", [ - pytest.param(AirbyteError(context={"status_code": 404}), id="not_found"), - pytest.param(AirbyteError(context={"status_code": 403}), id="forbidden"), + pytest.param(AirbyteCloudApiError(status_code=404), id="not_found"), + pytest.param(AirbyteCloudApiError(status_code=403), id="forbidden"), ], ) def test_get_direct_access_guidance_destination_not_enabled_adds_notice( @@ -705,7 +706,7 @@ def test_enabled_features_disabled_connector( monkeypatch.setattr( agents_api_util, "read_cloud_skill_docs", - lambda **_: (_ for _ in ()).throw(AirbyteError(context={"status_code": 404})), + lambda **_: (_ for _ in ()).throw(AirbyteCloudApiError(status_code=404)), ) source = _seed_source(workspace, "source-1", "GitHub") diff --git a/tests/unit_tests/test_cloud_direct_execute.py b/tests/unit_tests/test_cloud_direct_execute.py index 107cfba05..1a574a158 100644 --- a/tests/unit_tests/test_cloud_direct_execute.py +++ b/tests/unit_tests/test_cloud_direct_execute.py @@ -30,7 +30,9 @@ CloudSourceInfo, ) from airbyte.cloud.workspaces import CloudWorkspace +from airbyte.cloud._credentials import _AirbyteCredentials from airbyte.exceptions import ( + AirbyteCloudApiError, AirbyteError, AirbyteExternalAccessNotEnabledError, AirbyteMissingResourceError, @@ -308,7 +310,10 @@ def test_execute_error_handling( _patch_execute( monkeypatch, {}, - error=AirbyteError(context={"status_code": execute_status}), + error=AirbyteCloudApiError( + status_code=execute_status, + context={"status_code": execute_status}, + ), ) source = _seed_source(workspace, "source-1", "GitHub Issues") @@ -331,7 +336,8 @@ def test_execute_error_handling( else: assert isinstance(exc_info.value, AirbyteError) assert not isinstance(exc_info.value, AirbyteExternalAccessNotEnabledError) - assert (exc_info.value.context or {})["status_code"] == expected_status + assert isinstance(exc_info.value, AirbyteCloudApiError) + assert exc_info.value.status_code == expected_status @pytest.mark.parametrize("method_name", ["execute_api_query", "execute_api_action"]) @@ -693,6 +699,70 @@ def test_untyped_connector_execute_resolves_kind_for_routing( assert probes == ["source"] +@pytest.mark.parametrize( + ("status_code", "expected_phrase", "expects_upstream_guidance"), + [ + pytest.param(502, None, True, id="bad_gateway"), + pytest.param(503, None, True, id="service_unavailable"), + pytest.param(504, None, True, id="gateway_timeout"), + pytest.param(401, "Unauthorized", False, id="unauthorized"), + pytest.param(403, "Forbidden", False, id="forbidden"), + ], +) +def test_agent_request_error_message_and_guidance( + status_code: int, + expected_phrase: str | None, + expects_upstream_guidance: bool, +) -> None: + """Upstream 5xx failures get non-credentials guidance and no status phrase.""" + raw_response = requests.Response() + raw_response.status_code = status_code + raw_response.url = "https://cloud.airbyte.com/api/v1/sources/source-1/execute" + + message = agents_api_util._error_message( # noqa: SLF001 + response=raw_response, full_url=raw_response.url + ) + guidance = agents_api_util._error_guidance(response=raw_response) # noqa: SLF001 + + if expected_phrase is None: + assert f"{status_code} when accessing" in message + else: + assert f"({expected_phrase})" in message + if expects_upstream_guidance: + assert guidance is not None + assert "upstream" in guidance + else: + assert guidance is None or "upstream" not in guidance + + +def test_agent_request_non_2xx_raises_cloud_api_error( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A non-2xx Config API response raises `AirbyteCloudApiError` with `status_code`.""" + _patch_context_layer(monkeypatch) + raw_response = requests.Response() + raw_response.status_code = 503 + raw_response.url = "https://cloud.airbyte.com/api/v1/sources/source-1/execute" + monkeypatch.setattr(agents_api_util.requests, "request", lambda **_: raw_response) + + credentials = _AirbyteCredentials.from_auth( + bearer_token="token", + public_api_root="https://api.airbyte.com/v1", + env_vars=False, + ) + with pytest.raises(AirbyteCloudApiError) as exc_info: + agents_api_util.make_cloud_agent_request( + method="POST", + path="/sources/source-1/execute", + credentials=credentials, + json={}, + ) + + assert exc_info.value.status_code == 503 + assert "(Service Unavailable)" not in exc_info.value.get_message() + assert "upstream" in (exc_info.value.guidance or "") + + @pytest.mark.parametrize("kind", ["source", "destination"]) @pytest.mark.parametrize( "data", diff --git a/tests/unit_tests/test_mcp_cloud.py b/tests/unit_tests/test_mcp_cloud.py index 65238b703..9841f2d34 100644 --- a/tests/unit_tests/test_mcp_cloud.py +++ b/tests/unit_tests/test_mcp_cloud.py @@ -37,7 +37,11 @@ CloudConnectorResult, SyncJobResult, ) -from airbyte.exceptions import AirbyteError, PyAirbyteInputError +from airbyte.exceptions import ( + AirbyteCloudApiError, + AirbyteError, + PyAirbyteInputError, +) from fastmcp import Context @@ -1052,7 +1056,7 @@ def test_list_cloud_connectors_filters( ) assert workspace.calls[0]["connector_type"] == ConnectorType.DESTINATION - assert workspace.calls[0]["feature_filter"] is ConnectorFeature.DIRECT_SQL_QUERY + assert workspace.calls[0]["feature_filter"] is None assert [(r.id, r.connector_type) for r in results] == [ ("destination-1", "destination") ] @@ -1700,3 +1704,245 @@ def test_describe_helper_data_replication_docs_failure_warns() -> None: assert result.data_replication_docs is None assert any("Data replication docs are unavailable" in w for w in result.warnings) + + +class _ProbingConnector: + """Connector double whose `enabled_features` lookup can fail, with call counting.""" + + def __init__( + self, + connector_id: str, + probe_calls: list[str], + *, + features: frozenset[ConnectorFeature] = frozenset(), + error: Exception | None = None, + ) -> None: + self.connector_id = connector_id + self.connector_type = ConnectorType.SOURCE + self.name = connector_id + self.connector_url = f"https://cloud.airbyte.com/{connector_id}" + self._features = features + self._error = error + self._probe_calls = probe_calls + + @property + def enabled_features(self) -> frozenset[ConnectorFeature]: + self._probe_calls.append(self.connector_id) + if self._error is not None: + raise self._error + return self._features + + +class _ProbeListingWorkspace: + """Fake `CloudWorkspace` returning a fixed connector list for feature-filter tests.""" + + def __init__(self, connectors: list[_ProbingConnector]) -> None: + self._connectors = connectors + + def list_connectors( + self, + *, + connector_type: ConnectorType | None = None, + feature_filter: ConnectorFeature | None = None, + name_contains: str | None = None, + limit: int | None = None, + ) -> list[_ProbingConnector]: + assert feature_filter is None + items = self._connectors + if connector_type is not None: + items = [item for item in items if item.connector_type == connector_type] + return items if limit is None else items[:limit] + + +def _patch_probe_listing( + monkeypatch: pytest.MonkeyPatch, connectors: list[_ProbingConnector] +) -> None: + workspace = _ProbeListingWorkspace(connectors) + monkeypatch.setattr(cloud_mcp, "_get_cloud_workspace", lambda _ctx, _id: workspace) + + +def test_list_cloud_connectors_feature_filter_probe_failure_marks_unknown( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A non-'not enabled' probe failure marks remaining connectors `"unknown"`.""" + probe_calls: list[str] = [] + _patch_probe_listing( + monkeypatch, + [ + _ProbingConnector( + "source-1", + probe_calls, + features=frozenset({ + ConnectorFeature.DIRECT_ACCESS, + ConnectorFeature.DIRECT_API_QUERY, + }), + ), + _ProbingConnector( + "source-2", + probe_calls, + error=AirbyteCloudApiError(status_code=502), + ), + _ProbingConnector( + "source-3", + probe_calls, + features=frozenset({ + ConnectorFeature.DIRECT_ACCESS, + ConnectorFeature.DIRECT_API_QUERY, + }), + ), + ], + ) + + results = cloud_mcp.list_cloud_connectors( + None, + workspace_id=None, + name_contains=None, + limit=None, + feature_filter=ConnectorFeature.DIRECT_ACCESS, + ) + + assert len(results) == 3 + assert results[0].enabled_features == [ + ConnectorFeature.DIRECT_ACCESS, + ConnectorFeature.DIRECT_API_QUERY, + ] + for result in results[1:]: + assert result.enabled_features == cloud_mcp.FEATURES_UNKNOWN + assert any("enabled features are unknown" in w for w in result.warnings) + assert probe_calls == ["source-1", "source-2"] + + +def test_list_cloud_connectors_feature_filter_connector_failure_keeps_probing( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A connector-specific probe failure marks only that connector `"unknown"`.""" + probe_calls: list[str] = [] + _patch_probe_listing( + monkeypatch, + [ + _ProbingConnector( + "source-1", + probe_calls, + error=AirbyteCloudApiError(status_code=422), + ), + _ProbingConnector( + "source-2", + probe_calls, + features=frozenset(), + ), + _ProbingConnector( + "source-3", + probe_calls, + features=frozenset({ConnectorFeature.DIRECT_ACCESS}), + ), + ], + ) + + results = cloud_mcp.list_cloud_connectors( + None, + workspace_id=None, + name_contains=None, + limit=None, + feature_filter=ConnectorFeature.DIRECT_ACCESS, + ) + + assert [result.id for result in results] == ["source-1", "source-3"] + assert results[0].enabled_features == cloud_mcp.FEATURES_UNKNOWN + assert any("enabled features are unknown" in w for w in results[0].warnings) + assert results[1].enabled_features == [ConnectorFeature.DIRECT_ACCESS] + assert probe_calls == ["source-1", "source-2", "source-3"] + + +def test_list_cloud_connectors_limit_must_be_positive( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """`limit=0` raises `PyAirbyteInputError` before listing connectors.""" + with pytest.raises(PyAirbyteInputError, match="`limit` must be greater than 0"): + cloud_mcp.list_cloud_connectors( + None, + workspace_id=None, + name_contains=None, + limit=0, + feature_filter=ConnectorFeature.DIRECT_ACCESS, + ) + + +def test_list_cloud_connectors_feature_filter_not_enabled_still_excluded( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A 403/404 probe failure still means 'not enabled': excluded, not `"unknown"`.""" + probe_calls: list[str] = [] + _patch_probe_listing( + monkeypatch, + [ + _ProbingConnector( + "source-1", + probe_calls, + features=frozenset(), + ), + _ProbingConnector( + "source-2", + probe_calls, + features=frozenset({ConnectorFeature.DIRECT_ACCESS}), + ), + ], + ) + + results = cloud_mcp.list_cloud_connectors( + None, + workspace_id=None, + name_contains=None, + limit=None, + feature_filter=ConnectorFeature.DIRECT_ACCESS, + ) + + assert [result.id for result in results] == ["source-2"] + assert probe_calls == ["source-1", "source-2"] + + +def test_list_cloud_connectors_feature_filter_limit_counts_unknown( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """`"unknown"` results count toward `limit`.""" + probe_calls: list[str] = [] + _patch_probe_listing( + monkeypatch, + [ + _ProbingConnector( + "source-1", + probe_calls, + error=AirbyteCloudApiError(status_code=502), + ), + _ProbingConnector("source-2", probe_calls), + ], + ) + + results = cloud_mcp.list_cloud_connectors( + None, + workspace_id=None, + name_contains=None, + limit=1, + feature_filter=ConnectorFeature.DIRECT_ACCESS, + ) + + assert len(results) == 1 + assert results[0].enabled_features == cloud_mcp.FEATURES_UNKNOWN + assert probe_calls == ["source-1"] + + +def test_describe_cloud_connector_probe_failure_marks_unknown() -> None: + """A feature-probe failure marks `enabled_features` `"unknown"` with a warning.""" + + class _FailingFeaturesConnector(_DescribedConnector): + @property + def enabled_features(self) -> frozenset[ConnectorFeature]: + raise AirbyteCloudApiError(status_code=504) + + @enabled_features.setter + def enabled_features(self, value: frozenset[ConnectorFeature]) -> None: + pass + + result = _describe(_FailingFeaturesConnector()) + + assert result.enabled_features == cloud_mcp.FEATURES_UNKNOWN + assert any("enabled features are unknown" in w for w in result.warnings)