diff --git a/airbyte/_direct_connectors/connector_docs.py b/airbyte/_direct_connectors/connector_docs.py index ab2d303f3..3402e6a40 100644 --- a/airbyte/_direct_connectors/connector_docs.py +++ b/airbyte/_direct_connectors/connector_docs.py @@ -215,6 +215,7 @@ def build_direct_access_sql_guidance( title=f"{destination.name} (SQL passthrough destination)", summary=f"Table layout and naming for the `{destination.name}` destination.", tags=["destination"], + warnings=[sql_passthrough_notice] if sql_passthrough_notice else [], ) connections = _destination_connections(destination) content: list[dict[str, Any]] = [] @@ -247,7 +248,6 @@ def build_direct_access_sql_guidance( outline=[], section_id=None, content=content, - warnings=[sql_passthrough_notice] if sql_passthrough_notice else [], ) diff --git a/airbyte/cloud/connectors.py b/airbyte/cloud/connectors.py index 0c66db1d7..7bce63afb 100644 --- a/airbyte/cloud/connectors.py +++ b/airbyte/cloud/connectors.py @@ -742,7 +742,7 @@ def _context_layer_inspect( The connector's docs skill is probed: a skill doc exists only for connectors the Context layer knows. A 403 or 404 `AirbyteError` from the docs read means the - connector is not enabled for agent access, so a warning is appended to + docs are unavailable, so a warning is appended to `warnings` and `None` is returned. Any other failure (auth, server, malformed response, transport) is raised to the caller. """ @@ -764,7 +764,9 @@ def _context_layer_inspect( except exc.AirbyteError as error: if not agents_api_util.is_not_enabled_error(error): raise - warnings.append(f"Connector direct-access docs lookup failed: {error}") + warnings.append( + "Connector direct-access docs are unavailable (access denied or not found)." + ) return None parsed = _DirectConnectorInspectResult( connector_id=self.connector_id, @@ -937,6 +939,8 @@ def get_direct_access_guidance(self, *, section: str | None = None) -> DirectAcc except exc.AirbyteError as error: if not agents_api_util.is_not_enabled_error(error) or section is not None: raise + # Keep the not-enabled notice for this docs fallback. A rare missing-docs + # 404 can reach it even when access is enabled. return connector_docs.build_direct_access_sql_guidance( destination, sql_passthrough_notice=connector_docs.SQL_PASSTHROUGH_NOT_ENABLED_NOTICE, diff --git a/airbyte/mcp/cloud.py b/airbyte/mcp/cloud.py index 85a1c5eeb..57855e137 100644 --- a/airbyte/mcp/cloud.py +++ b/airbyte/mcp/cloud.py @@ -1359,7 +1359,7 @@ def _describe_cloud_connector( try: result.enabled_features = sorted(connector.enabled_features) - except (AirbyteError, requests.RequestException) as error: + except (AirbyteError, requests.RequestException, ValueError) as error: result.enabled_features = FEATURES_UNKNOWN warnings.append(_feature_lookup_warning(error)) @@ -1372,8 +1372,8 @@ def _describe_cloud_connector( context_layer = connector._context_layer_inspect( # noqa: SLF001 warnings=warnings, ) - except (AirbyteError, requests.RequestException) as error: - warnings.append(f"Connector direct-access docs lookup failed: {error}") + except (AirbyteError, requests.RequestException, ValueError): + warnings.append("Connector direct-access docs lookup failed.") else: if context_layer is not None: warnings.extend(str(warning) for warning in context_layer.warnings) @@ -1393,8 +1393,8 @@ def _describe_cloud_connector( if with_direct_access_guidance: try: docs = connector.get_direct_access_guidance() - except (PyAirbyteError, requests.RequestException) as error: - warnings.append(f"Direct access docs are unavailable: {error}") + except (PyAirbyteError, requests.RequestException, ValueError): + warnings.append("Direct access docs are unavailable.") else: result.direct_access_guidance = render_connector_docs_result(docs) diff --git a/tests/unit_tests/test_cloud_describe.py b/tests/unit_tests/test_cloud_describe.py index 4c7d4711b..ac797ef70 100644 --- a/tests/unit_tests/test_cloud_describe.py +++ b/tests/unit_tests/test_cloud_describe.py @@ -14,6 +14,11 @@ from airbyte._direct_connectors import api_util as agents_api_util from airbyte._direct_connectors import connector_docs from airbyte.cloud import workspaces as cloud_workspaces +from airbyte.mcp import cloud as cloud_mcp +from airbyte.mcp._docs_results import ( + render_connector_docs_result, + render_agent_skill_docs_result, +) from airbyte.cloud.connections import CloudConnection from airbyte.cloud.connectors import ( CloudConnector, @@ -408,7 +413,9 @@ def test_get_direct_access_guidance_destination_not_enabled_adds_notice( notice = connector_docs.SQL_PASSTHROUGH_NOT_ENABLED_NOTICE guidance = destination.get_direct_access_guidance() assert guidance.content[0] == {"type": "paragraph", "text": notice} - assert guidance.warnings == [notice] + assert guidance.metadata.warnings == [notice] + assert render_connector_docs_result(guidance).warnings == [notice] + assert render_agent_skill_docs_result(guidance).warnings == [notice] assert guidance.outline == [] assert any(block.get("type") == "table" for block in guidance.content) assert "execute_external_sql_query" not in _content_text(guidance) @@ -545,7 +552,9 @@ def test_get_direct_access_guidance_destination_no_context_layer_notices( guidance = destination.get_direct_access_guidance() read_docs.assert_not_called() assert guidance.content[0] == {"type": "paragraph", "text": notice} - assert guidance.warnings == [notice] + assert guidance.metadata.warnings == [notice] + assert render_connector_docs_result(guidance).warnings == [notice] + assert render_agent_skill_docs_result(guidance).warnings == [notice] assert guidance.outline == [] assert "execute_external_sql_query" not in _content_text(guidance) assert "SHOW TABLES" not in _content_text(guidance) @@ -807,3 +816,97 @@ def test_is_feature_enabled_wrong_connector_kind_short_circuits( assert connector.is_feature_enabled(feature) is False get_features.assert_not_called() + + +@pytest.mark.parametrize("cache_path", ["fresh", "inspect"]) +def test_describe_stringifies_structured_probe_warnings( + monkeypatch: pytest.MonkeyPatch, cache_path: str +) -> None: + workspace = _make_workspace(monkeypatch) + _patch_context_layer(monkeypatch) + _patch_enablement(monkeypatch) + warning = {"code": "partial", "message": "Partial metadata"} + response = { + **SKILL_DOCS_RESPONSE, + "metadata": {**SKILL_DOCS_RESPONSE["metadata"], "warnings": [warning]}, + } + read_docs = MagicMock(return_value=response) + monkeypatch.setattr(agents_api_util, "read_cloud_skill_docs", read_docs) + source = _seed_source(workspace, "source-1", "GitHub") + source._connector_definition = SimpleNamespace(name="GitHub") # noqa: SLF001 + if cache_path == "inspect": + source._context_layer_inspect(warnings=[]) # noqa: SLF001 + + result = cloud_mcp._describe_cloud_connector( # noqa: SLF001 + source, + with_config=False, + with_replication_details=False, + with_direct_access_guidance=False, + with_data_replication_docs=False, + ) + + assert result.warnings == [str(warning)] + assert result.model_dump()["warnings"] == [str(warning)] + assert read_docs.call_count == 1 + + +@pytest.mark.parametrize("with_guidance", [False, True]) +def test_describe_malformed_docs_preserves_enabled_features( + monkeypatch: pytest.MonkeyPatch, with_guidance: bool +) -> None: + workspace = _make_workspace(monkeypatch) + _patch_context_layer(monkeypatch) + _patch_enablement(monkeypatch) + monkeypatch.setattr(agents_api_util, "read_cloud_skill_docs", lambda **_: {}) + source = _seed_source(workspace, "source-1", "GitHub") + source._connector_definition = SimpleNamespace(name="GitHub") # noqa: SLF001 + + result = cloud_mcp._describe_cloud_connector( # noqa: SLF001 + source, + with_config=False, + with_replication_details=False, + with_direct_access_guidance=with_guidance, + with_data_replication_docs=False, + ) + + assert result.enabled_features == sorted([ + ConnectorFeature.DIRECT_ACCESS, + ConnectorFeature.DIRECT_API_QUERY, + ]) + assert result.warnings == [ + "Connector direct-access docs lookup failed.", + *(["Direct access docs are unavailable."] if with_guidance else []), + ] + + +@pytest.mark.parametrize("status", [403, 404]) +def test_unavailable_docs_do_not_hide_enabled_features_or_leak_errors( + monkeypatch: pytest.MonkeyPatch, status: int +) -> None: + workspace = _make_workspace(monkeypatch) + _patch_context_layer(monkeypatch) + _patch_enablement(monkeypatch) + read_docs = MagicMock( + side_effect=AirbyteCloudApiError( + status_code=status, context={"response_text": "secret"} + ) + ) + monkeypatch.setattr(agents_api_util, "read_cloud_skill_docs", read_docs) + source = _seed_source(workspace, "source-1", "GitHub") + source._connector_definition = SimpleNamespace(name="GitHub") # noqa: SLF001 + + result = cloud_mcp._describe_cloud_connector( # noqa: SLF001 + source, + with_config=False, + with_replication_details=False, + with_direct_access_guidance=False, + with_data_replication_docs=False, + ) + + assert result.enabled_features == sorted([ + ConnectorFeature.DIRECT_ACCESS, + ConnectorFeature.DIRECT_API_QUERY, + ]) + assert result.warnings == [ + "Connector direct-access docs are unavailable (access denied or not found)." + ] diff --git a/tests/unit_tests/test_cloud_direct_execute.py b/tests/unit_tests/test_cloud_direct_execute.py index 514e2e283..53ff35c03 100644 --- a/tests/unit_tests/test_cloud_direct_execute.py +++ b/tests/unit_tests/test_cloud_direct_execute.py @@ -1492,11 +1492,11 @@ def fail(**kwargs: Any) -> Any: # noqa: ANN401 @pytest.mark.parametrize("status_code", [403, 404]) -def test_docs_probe_not_enabled_handling_unchanged( +def test_docs_probe_unavailable_emits_neutral_warning( monkeypatch: pytest.MonkeyPatch, status_code: int, ) -> None: - """The skills-docs probe still reads a 403 or 404 as "not enabled", with a warning.""" + """Unavailable docs produce a warning without asserting disabled access.""" workspace = _make_workspace(monkeypatch) def fail(**_: Any) -> Any: # noqa: ANN401 @@ -1507,5 +1507,6 @@ def fail(**_: Any) -> Any: # noqa: ANN401 warnings: list[str] = [] assert source._context_layer_inspect(warnings=warnings) is None # noqa: SLF001 - assert len(warnings) == 1 - assert "docs lookup failed" in warnings[0] + assert warnings == [ + "Connector direct-access docs are unavailable (access denied or not found)." + ]