From 6ffb4bd6fafd04f1bdaa5be9812d840584d2edc6 Mon Sep 17 00:00:00 2001 From: Sai Kaushik Ponnekanti Date: Sun, 12 Apr 2026 14:34:12 -0700 Subject: [PATCH 1/3] fix: improve error messages for platform credential expiry in Semantic Layer tools When query_metrics or execute_sql fail due to expired dbt Cloud platform credentials, the error now clearly indicates this is a platform-side issue and directs users to refresh credentials in the dbt Cloud UI, rather than suggesting local Snowflake re-authentication. Closes #670 --- .changes/unreleased/Fixed-20260412-670.yaml | 3 + src/dbt_mcp/semantic_layer/client.py | 47 ++++++- .../test_platform_credential_errors.py | 125 ++++++++++++++++++ 3 files changed, 173 insertions(+), 2 deletions(-) create mode 100644 .changes/unreleased/Fixed-20260412-670.yaml create mode 100644 tests/unit/semantic_layer/test_platform_credential_errors.py diff --git a/.changes/unreleased/Fixed-20260412-670.yaml b/.changes/unreleased/Fixed-20260412-670.yaml new file mode 100644 index 000000000..ea95b14a8 --- /dev/null +++ b/.changes/unreleased/Fixed-20260412-670.yaml @@ -0,0 +1,3 @@ +kind: Fixed +body: Improve error messages when dbt Cloud platform credentials expire for Semantic Layer tools (query_metrics, execute_sql). The error now clearly indicates this is a platform-side credential issue and directs users to the dbt Cloud UI to refresh their development credentials. +time: 2026-04-12T21:00:00.000000-07:00 diff --git a/src/dbt_mcp/semantic_layer/client.py b/src/dbt_mcp/semantic_layer/client.py index 74b448cbf..9edf968ca 100644 --- a/src/dbt_mcp/semantic_layer/client.py +++ b/src/dbt_mcp/semantic_layer/client.py @@ -1,6 +1,7 @@ import asyncio import base64 import json +import re from collections.abc import Callable from contextlib import AbstractContextManager from datetime import date, datetime, time, timedelta @@ -38,6 +39,36 @@ ListMetricsResponse, ) +# Patterns that indicate expired dbt Cloud platform credentials rather than +# local authentication failures. These are matched case-insensitively against +# the raw error string returned by the Semantic Layer / data platform. +_PLATFORM_CREDENTIAL_ERROR_PATTERNS: list[re.Pattern[str]] = [ + re.compile(r"SSO authentication has expired", re.IGNORECASE), + re.compile(r"re-connect to Snowflake", re.IGNORECASE), + re.compile(r"refresh.snowflake.oauth.credentials", re.IGNORECASE), + re.compile(r"authentication token has expired", re.IGNORECASE), + re.compile(r"oauth.*token.*expired", re.IGNORECASE), + re.compile(r"token.*expired.*re-?authenticate", re.IGNORECASE), +] + +_PLATFORM_CREDENTIAL_HINT = ( + "\n\n" + "Hint: This error indicates that your dbt Cloud development credentials " + "have expired. This is a dbt Cloud platform credential issue, not a local " + "authentication problem.\n" + "To fix this, go to the dbt Cloud UI → Profile → Credentials and refresh " + "your development environment credentials for the relevant project.\n" + "See: https://docs.getdbt.com/docs/dbt-cloud-environments/develop-in-the-cloud#access-the-cloud-ide" +) + + +def _is_platform_credential_error(error_message: str) -> bool: + """Detect whether an error message indicates expired dbt Cloud platform credentials.""" + return any( + pattern.search(error_message) + for pattern in _PLATFORM_CREDENTIAL_ERROR_PATTERNS + ) + def DEFAULT_RESULT_FORMATTER(table: pa.Table) -> str: """Convert PyArrow Table to JSON string with ISO date formatting. @@ -268,7 +299,12 @@ async def get_entities( return self.entities_cache[metrics_key] def _format_semantic_layer_error(self, error: Exception) -> str: - """Format semantic layer errors by cleaning up common error message patterns.""" + """Format semantic layer errors by cleaning up common error message patterns. + + Additionally detects errors caused by expired dbt Cloud platform credentials + and appends an actionable hint directing users to refresh their credentials + in the dbt Cloud UI rather than re-authenticating locally. + """ error_str = str(error) formatted = ( error_str.replace("QueryFailedError(", "") @@ -285,7 +321,14 @@ def _format_semantic_layer_error(self, error: Exception) -> str: .strip() ) if not formatted: - return error_str or f"Semantic layer query failed: {type(error).__name__}" + formatted = ( + error_str or f"Semantic layer query failed: {type(error).__name__}" + ) + + # Detect platform credential expiry and append an actionable hint + if _is_platform_credential_error(error_str): + formatted += _PLATFORM_CREDENTIAL_HINT + return formatted def _format_get_metrics_compiled_sql_error( diff --git a/tests/unit/semantic_layer/test_platform_credential_errors.py b/tests/unit/semantic_layer/test_platform_credential_errors.py new file mode 100644 index 000000000..8a3cde888 --- /dev/null +++ b/tests/unit/semantic_layer/test_platform_credential_errors.py @@ -0,0 +1,125 @@ +"""Tests for platform credential error detection in Semantic Layer tools. + +Verifies that when query_metrics or get_metrics_compiled_sql fail due to +expired dbt Cloud platform credentials, the error message clearly indicates +this is a platform-side issue and directs users to the dbt Cloud UI. + +Related: https://github.com/dbt-labs/dbt-mcp/issues/670 +""" + +import pytest + +from dbt_mcp.semantic_layer.client import ( + SemanticLayerFetcher, + _PLATFORM_CREDENTIAL_HINT, + _is_platform_credential_error, +) + + +@pytest.fixture +def fetcher(): + from unittest.mock import AsyncMock + + return SemanticLayerFetcher(client_provider=AsyncMock()) + + +class TestPlatformCredentialErrorDetection: + """Tests for _is_platform_credential_error.""" + + def test_detects_sso_authentication_expired(self) -> None: + assert _is_platform_credential_error( + "SSO authentication has expired, please re-connect to Snowflake" + ) + + def test_detects_reconnect_to_snowflake(self) -> None: + assert _is_platform_credential_error( + "Please re-connect to Snowflake: https://docs.getdbt.com/faqs" + ) + + def test_detects_refresh_snowflake_oauth_url(self) -> None: + assert _is_platform_credential_error( + "https://docs.getdbt.com/faqs/Troubleshooting/refresh-snowflake-oauth-credentials" + ) + + def test_detects_authentication_token_expired(self) -> None: + assert _is_platform_credential_error( + "Authentication token has expired for the data warehouse" + ) + + def test_detects_oauth_token_expired(self) -> None: + assert _is_platform_credential_error( + "OAuth access token has expired for this connection" + ) + + def test_case_insensitive(self) -> None: + assert _is_platform_credential_error( + "sso AUTHENTICATION Has Expired" + ) + + def test_does_not_match_generic_query_error(self) -> None: + assert not _is_platform_credential_error( + "column 'revenue' not found in table" + ) + + def test_does_not_match_syntax_error(self) -> None: + assert not _is_platform_credential_error( + "SQL compilation error: syntax error at position 42" + ) + + def test_does_not_match_timeout_error(self) -> None: + assert not _is_platform_credential_error( + "Query timed out after 60 seconds" + ) + + def test_does_not_match_empty_string(self) -> None: + assert not _is_platform_credential_error("") + + +class TestFormatSemanticLayerErrorWithCredentialHint: + """Tests that _format_semantic_layer_error appends the credential hint.""" + + def test_sso_expired_includes_platform_hint(self, fetcher) -> None: + error = Exception( + "SSO authentication has expired, please re-connect to Snowflake: " + "https://docs.getdbt.com/faqs/Troubleshooting/refresh-snowflake-oauth-credentials" + ) + result = fetcher._format_semantic_layer_error(error) + assert "dbt Cloud" in result + assert "Profile → Credentials" in result + assert "not a local authentication problem" in result + + def test_sso_expired_preserves_original_message(self, fetcher) -> None: + error = Exception( + "SSO authentication has expired, please re-connect to Snowflake" + ) + result = fetcher._format_semantic_layer_error(error) + assert "SSO authentication has expired" in result + + def test_generic_error_does_not_include_platform_hint(self, fetcher) -> None: + error = Exception("column 'revenue' not found") + result = fetcher._format_semantic_layer_error(error) + assert _PLATFORM_CREDENTIAL_HINT not in result + assert "dbt Cloud UI" not in result + + def test_query_failed_error_with_credential_message_includes_hint( + self, fetcher + ) -> None: + """QueryFailedError wrapping a credential expiry should also get the hint.""" + error = Exception( + 'QueryFailedError(["SSO authentication has expired, ' + "please re-connect to Snowflake\"])" + ) + result = fetcher._format_semantic_layer_error(error) + assert "dbt Cloud" in result + assert "Profile → Credentials" in result + + def test_format_query_failed_error_with_credential_message(self, fetcher) -> None: + """_format_query_failed_error should also include the hint for credential errors.""" + from dbtsl.error import QueryFailedError + + error = QueryFailedError( + "SSO authentication has expired, please re-connect to Snowflake" + ) + result = fetcher._format_query_failed_error(error) + assert "dbt Cloud" in result.error + assert "Profile → Credentials" in result.error From bf4c6787f332680044168ac8cfdc1b96bb5f8a1e Mon Sep 17 00:00:00 2001 From: Sai Kaushik Ponnekanti Date: Wed, 15 Apr 2026 15:15:19 -0700 Subject: [PATCH 2/3] chore: fix changie entry kind from Fixed to Bug Fix for issue 670 --- .../{Fixed-20260412-670.yaml => BugFix-20260412-670.yaml} | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) rename .changes/unreleased/{Fixed-20260412-670.yaml => BugFix-20260412-670.yaml} (50%) diff --git a/.changes/unreleased/Fixed-20260412-670.yaml b/.changes/unreleased/BugFix-20260412-670.yaml similarity index 50% rename from .changes/unreleased/Fixed-20260412-670.yaml rename to .changes/unreleased/BugFix-20260412-670.yaml index ea95b14a8..a52f032e9 100644 --- a/.changes/unreleased/Fixed-20260412-670.yaml +++ b/.changes/unreleased/BugFix-20260412-670.yaml @@ -1,3 +1,3 @@ -kind: Fixed -body: Improve error messages when dbt Cloud platform credentials expire for Semantic Layer tools (query_metrics, execute_sql). The error now clearly indicates this is a platform-side credential issue and directs users to the dbt Cloud UI to refresh their development credentials. +kind: Bug Fix +body: Improve error messages when dbt Cloud platform credentials expire for Semantic Layer tools (query_metrics, execute_sql). The error now clearly indicates this is a platform-side issue and directs users to the dbt Cloud UI to refresh their development credentials. time: 2026-04-12T21:00:00.000000-07:00 From 8c6c4ffef5f6bd2cf26b2d957db0d891683c3ac1 Mon Sep 17 00:00:00 2001 From: Sai Kaushik Ponnekanti Date: Wed, 15 Apr 2026 17:06:09 -0700 Subject: [PATCH 3/3] fix: apply ruff formatting and fix QueryFailedError signature in tests --- src/dbt_mcp/semantic_layer/client.py | 3 +-- .../test_platform_credential_errors.py | 17 ++++++----------- 2 files changed, 7 insertions(+), 13 deletions(-) diff --git a/src/dbt_mcp/semantic_layer/client.py b/src/dbt_mcp/semantic_layer/client.py index 9edf968ca..1a6c89d4d 100644 --- a/src/dbt_mcp/semantic_layer/client.py +++ b/src/dbt_mcp/semantic_layer/client.py @@ -65,8 +65,7 @@ def _is_platform_credential_error(error_message: str) -> bool: """Detect whether an error message indicates expired dbt Cloud platform credentials.""" return any( - pattern.search(error_message) - for pattern in _PLATFORM_CREDENTIAL_ERROR_PATTERNS + pattern.search(error_message) for pattern in _PLATFORM_CREDENTIAL_ERROR_PATTERNS ) diff --git a/tests/unit/semantic_layer/test_platform_credential_errors.py b/tests/unit/semantic_layer/test_platform_credential_errors.py index 8a3cde888..995625d8d 100644 --- a/tests/unit/semantic_layer/test_platform_credential_errors.py +++ b/tests/unit/semantic_layer/test_platform_credential_errors.py @@ -52,14 +52,10 @@ def test_detects_oauth_token_expired(self) -> None: ) def test_case_insensitive(self) -> None: - assert _is_platform_credential_error( - "sso AUTHENTICATION Has Expired" - ) + assert _is_platform_credential_error("sso AUTHENTICATION Has Expired") def test_does_not_match_generic_query_error(self) -> None: - assert not _is_platform_credential_error( - "column 'revenue' not found in table" - ) + assert not _is_platform_credential_error("column 'revenue' not found in table") def test_does_not_match_syntax_error(self) -> None: assert not _is_platform_credential_error( @@ -67,9 +63,7 @@ def test_does_not_match_syntax_error(self) -> None: ) def test_does_not_match_timeout_error(self) -> None: - assert not _is_platform_credential_error( - "Query timed out after 60 seconds" - ) + assert not _is_platform_credential_error("Query timed out after 60 seconds") def test_does_not_match_empty_string(self) -> None: assert not _is_platform_credential_error("") @@ -107,7 +101,7 @@ def test_query_failed_error_with_credential_message_includes_hint( """QueryFailedError wrapping a credential expiry should also get the hint.""" error = Exception( 'QueryFailedError(["SSO authentication has expired, ' - "please re-connect to Snowflake\"])" + 'please re-connect to Snowflake"])' ) result = fetcher._format_semantic_layer_error(error) assert "dbt Cloud" in result @@ -118,7 +112,8 @@ def test_format_query_failed_error_with_credential_message(self, fetcher) -> Non from dbtsl.error import QueryFailedError error = QueryFailedError( - "SSO authentication has expired, please re-connect to Snowflake" + message="SSO authentication has expired, please re-connect to Snowflake", + status="FAILED", ) result = fetcher._format_query_failed_error(error) assert "dbt Cloud" in result.error