From f2fdd7cbcaef7b6fab25f60c55de7d0a3f531277 Mon Sep 17 00:00:00 2001 From: Muizz Lateef Date: Tue, 19 May 2026 19:02:55 +0100 Subject: [PATCH 1/5] refactor(config): widen provider constructors to CredentialsProviderProtocol --- .../config/config_providers/admin_api.py | 9 ++------- src/dbt_mcp/config/config_providers/base.py | 16 +++++++++++++++ .../config/config_providers/discovery.py | 12 +++++++---- .../config/config_providers/proxied_tool.py | 7 ++++--- .../config/config_providers/semantic_layer.py | 20 ++++++++++++++----- 5 files changed, 45 insertions(+), 19 deletions(-) diff --git a/src/dbt_mcp/config/config_providers/admin_api.py b/src/dbt_mcp/config/config_providers/admin_api.py index 11975e8cc..8f8542e60 100644 --- a/src/dbt_mcp/config/config_providers/admin_api.py +++ b/src/dbt_mcp/config/config_providers/admin_api.py @@ -1,17 +1,12 @@ from __future__ import annotations -from typing import TYPE_CHECKING - from dbt_mcp.config.headers import AdminApiHeadersProvider -from .base import AdminApiConfig, ConfigProvider - -if TYPE_CHECKING: - from dbt_mcp.config.credentials import CredentialsProvider +from .base import AdminApiConfig, ConfigProvider, CredentialsProviderProtocol class DefaultAdminApiConfigProvider(ConfigProvider[AdminApiConfig]): - def __init__(self, credentials_provider: CredentialsProvider): + def __init__(self, credentials_provider: CredentialsProviderProtocol): self.credentials_provider = credentials_provider async def get_config(self) -> AdminApiConfig: diff --git a/src/dbt_mcp/config/config_providers/base.py b/src/dbt_mcp/config/config_providers/base.py index ea5ba2662..5e5e720fd 100644 --- a/src/dbt_mcp/config/config_providers/base.py +++ b/src/dbt_mcp/config/config_providers/base.py @@ -1,5 +1,8 @@ +from __future__ import annotations + from abc import ABC, abstractmethod from dataclasses import dataclass +from typing import TYPE_CHECKING, Protocol from dbt_mcp.config.headers import ( HeadersProvider, @@ -7,6 +10,19 @@ TokenProvider, ) +if TYPE_CHECKING: + from dbt_mcp.config.settings import DbtMcpSettings + + +class CredentialsProviderProtocol(Protocol): + """Structural interface for credential providers. + + Both CredentialsProvider and ElicitingCredentialsProvider satisfy this + protocol. Config providers accept this protocol so either can be injected. + """ + + async def get_credentials(self) -> tuple[DbtMcpSettings, TokenProvider]: ... + class ConfigProvider[ConfigType](ABC): @abstractmethod diff --git a/src/dbt_mcp/config/config_providers/discovery.py b/src/dbt_mcp/config/config_providers/discovery.py index d92b302da..16f33c35c 100644 --- a/src/dbt_mcp/config/config_providers/discovery.py +++ b/src/dbt_mcp/config/config_providers/discovery.py @@ -6,14 +6,18 @@ from dbt_mcp.errors import NotFoundError if TYPE_CHECKING: - from dbt_mcp.config.credentials import CredentialsProvider from dbt_mcp.dbt_admin.client import DbtAdminAPIClient -from .base import ConfigProvider, DiscoveryConfig, MultiProjectConfigProvider +from .base import ( + ConfigProvider, + CredentialsProviderProtocol, + DiscoveryConfig, + MultiProjectConfigProvider, +) class DefaultDiscoveryConfigProvider(ConfigProvider[DiscoveryConfig]): - def __init__(self, credentials_provider: CredentialsProvider): + def __init__(self, credentials_provider: CredentialsProviderProtocol): self.credentials_provider = credentials_provider async def get_config(self) -> DiscoveryConfig: @@ -35,7 +39,7 @@ class MultiProjectDiscoveryConfigProvider(MultiProjectConfigProvider[DiscoveryCo def __init__( self, *, - credentials_provider: CredentialsProvider, + credentials_provider: CredentialsProviderProtocol, admin_client: DbtAdminAPIClient, ): self.credentials_provider = credentials_provider diff --git a/src/dbt_mcp/config/config_providers/proxied_tool.py b/src/dbt_mcp/config/config_providers/proxied_tool.py index 400cfbb3d..72cea19ec 100644 --- a/src/dbt_mcp/config/config_providers/proxied_tool.py +++ b/src/dbt_mcp/config/config_providers/proxied_tool.py @@ -1,12 +1,13 @@ +from __future__ import annotations + from dbt_mcp.config.headers import ProxiedToolHeadersProvider -from dbt_mcp.config.credentials import CredentialsProvider from dbt_mcp.errors.common import MissingHostError -from .base import ConfigProvider, ProxiedToolConfig +from .base import ConfigProvider, CredentialsProviderProtocol, ProxiedToolConfig class DefaultProxiedToolConfigProvider(ConfigProvider[ProxiedToolConfig]): - def __init__(self, credentials_provider: CredentialsProvider): + def __init__(self, credentials_provider: CredentialsProviderProtocol): self.credentials_provider = credentials_provider async def get_config(self) -> ProxiedToolConfig: diff --git a/src/dbt_mcp/config/config_providers/semantic_layer.py b/src/dbt_mcp/config/config_providers/semantic_layer.py index c5ac7ca60..305064285 100644 --- a/src/dbt_mcp/config/config_providers/semantic_layer.py +++ b/src/dbt_mcp/config/config_providers/semantic_layer.py @@ -1,17 +1,27 @@ -from dbt_mcp.config.credentials import CredentialsProvider +from __future__ import annotations + +from typing import TYPE_CHECKING + from dbt_mcp.config.headers import ( SemanticLayerHeadersProvider, ) -from dbt_mcp.dbt_admin.client import DbtAdminAPIClient from dbt_mcp.errors import NotFoundError -from .base import ConfigProvider, MultiProjectConfigProvider, SemanticLayerConfig +from .base import ( + ConfigProvider, + CredentialsProviderProtocol, + MultiProjectConfigProvider, + SemanticLayerConfig, +) + +if TYPE_CHECKING: + from dbt_mcp.dbt_admin.client import DbtAdminAPIClient class DefaultSemanticLayerConfigProvider(ConfigProvider[SemanticLayerConfig]): def __init__( self, - credentials_provider: CredentialsProvider, + credentials_provider: CredentialsProviderProtocol, *, metrics_related_max: int = 10, max_response_chars: int = 16000, @@ -50,7 +60,7 @@ class MultiProjectSemanticLayerConfigProvider( ): def __init__( self, - credentials_provider: CredentialsProvider, + credentials_provider: CredentialsProviderProtocol, admin_client: DbtAdminAPIClient, *, metrics_related_max: int = 10, From c72aa3b88c19d1e15be1525fad131cec32686b63 Mon Sep 17 00:00:00 2001 From: Muizz Lateef Date: Tue, 19 May 2026 20:18:58 +0100 Subject: [PATCH 2/5] =?UTF-8?q?fix(server):=20make=20=5Fis=5Fmulti=5Fproje?= =?UTF-8?q?ct=20sync=20=E2=80=94=20read=20settings=20directly,=20no=20cred?= =?UTF-8?q?ential=20fetch?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/dbt_mcp/mcp/server.py | 31 +++++------- tests/conftest.py | 3 +- tests/unit/mcp/test_dispatcher.py | 84 +++++++++++-------------------- 3 files changed, 42 insertions(+), 76 deletions(-) diff --git a/src/dbt_mcp/mcp/server.py b/src/dbt_mcp/mcp/server.py index 26d5901ec..d533ec8c2 100644 --- a/src/dbt_mcp/mcp/server.py +++ b/src/dbt_mcp/mcp/server.py @@ -63,21 +63,16 @@ def __init__( asyncio.Task[LSPConnectionProviderProtocol] | None ) = None - async def _is_multi_project(self) -> bool: - try: - ( - settings, - _, - ) = await self.config.credentials_provider.inner_provider.get_credentials() - except MissingHostError as e: - logger.warning( - "Could not resolve credentials — defaulting to single-project mode: %s", - e, - ) - return False - return bool( - settings.dbt_project_ids is not None and len(settings.dbt_project_ids) > 0 - ) + def _is_multi_project(self) -> bool: + """Check multi-project mode from settings. No credential fetch. + + Note: dbt_project_ids may be populated later by the OAuth flow + in CredentialsProvider.get_credentials(). Users who rely on OAuth-derived project IDs + without setting DBT_PROJECT_IDS env var will see single-project + mode until the first platform tool call triggers OAuth. + """ + project_ids = self.config.credentials_provider.settings.dbt_project_ids + return bool(project_ids is not None and len(project_ids) > 0) async def call_tool( self, name: str, arguments: dict[str, Any] @@ -86,7 +81,7 @@ async def call_tool( result = None start_time = int(time.time() * 1000) try: - if await self._is_multi_project(): + if self._is_multi_project(): result = await self.multi_project_mcp.call_tool(name, arguments) else: result = await self.single_project_mcp.call_tool(name, arguments) @@ -127,7 +122,7 @@ async def call_tool( return result async def list_tools(self) -> list[Tool]: - if await self._is_multi_project(): + if self._is_multi_project(): return await self.multi_project_mcp.list_tools() return await self.single_project_mcp.list_tools() @@ -143,7 +138,7 @@ async def app_lifespan(server: FastMCP[Any]) -> AsyncIterator[bool | None]: # this avoids anyio cancel scope violations (see issue #498) if ( server.config.proxied_tool_config_provider - and not await server._is_multi_project() + and not server._is_multi_project() ): try: logger.info("Registering proxied tools") diff --git a/tests/conftest.py b/tests/conftest.py index 36d475174..b804c7da5 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -4,7 +4,7 @@ from contextlib import contextmanager from pathlib import Path from typing import Any -from unittest.mock import AsyncMock, patch +from unittest.mock import patch import pytest @@ -110,7 +110,6 @@ def path(relpath: str): helpers.write_file(rel, content) with patch( "dbt_mcp.mcp.server.DbtMCP._is_multi_project", - new_callable=AsyncMock, return_value=False, ): try: diff --git a/tests/unit/mcp/test_dispatcher.py b/tests/unit/mcp/test_dispatcher.py index df6cc68d9..f0d3d315d 100644 --- a/tests/unit/mcp/test_dispatcher.py +++ b/tests/unit/mcp/test_dispatcher.py @@ -1,5 +1,6 @@ """Unit tests for DbtMCP tool dispatcher routing.""" +import inspect import logging import pytest from unittest.mock import AsyncMock, MagicMock, patch @@ -34,6 +35,7 @@ def _make_dispatcher( ) credentials_provider = MagicMock(spec=ElicitingCredentialsProvider) + credentials_provider.settings = settings credentials_provider.inner_provider = inner_credentials_provider credentials_provider.get_credentials = AsyncMock( return_value=(settings, StaticTokenProvider(token="test-token")) @@ -62,41 +64,21 @@ def _make_tool(name: str) -> Tool: class TestIsMultiProject: - async def test_returns_true_when_project_ids_set(self): - settings = DbtMcpSettings.model_construct(dbt_project_ids=[1, 2, 3]) - dispatcher = _make_dispatcher(settings=settings) - assert await dispatcher._is_multi_project() is True + def test_is_sync(self): + assert not inspect.iscoroutinefunction(DbtMCP._is_multi_project) - async def test_returns_false_when_project_ids_none(self): - settings = DbtMcpSettings.model_construct(dbt_project_ids=None) + @pytest.mark.parametrize( + "project_ids, expected", + [ + pytest.param([1, 2, 3], True, id="set"), + pytest.param(None, False, id="none"), + pytest.param([], False, id="empty"), + ], + ) + def test_returns_expected(self, project_ids, expected): + settings = DbtMcpSettings.model_construct(dbt_project_ids=project_ids) dispatcher = _make_dispatcher(settings=settings) - assert await dispatcher._is_multi_project() is False - - async def test_returns_false_when_project_ids_empty(self): - settings = DbtMcpSettings.model_construct(dbt_project_ids=[]) - dispatcher = _make_dispatcher(settings=settings) - assert await dispatcher._is_multi_project() is False - - async def test_returns_false_when_credentials_raise(self): - dispatcher = _make_dispatcher() - dispatcher.config.credentials_provider.inner_provider.get_credentials = ( - AsyncMock( - side_effect=MissingHostError( - "DBT_HOST is a required environment variable" - ) - ) - ) - assert await dispatcher._is_multi_project() is False - - async def test_raises_non_host_value_errors(self): - dispatcher = _make_dispatcher() - dispatcher.config.credentials_provider.inner_provider.get_credentials = ( - AsyncMock( - side_effect=ValueError("No decoded access token found in OAuth context") - ) - ) - with pytest.raises(ValueError, match="No decoded access token"): - await dispatcher._is_multi_project() + assert dispatcher._is_multi_project() is expected class TestListToolsRouting: @@ -120,7 +102,7 @@ async def test_routes_based_on_project_mode( multi_project_mcp=multi, single_project_mcp=single ) with patch.object( - dispatcher, "_is_multi_project", AsyncMock(return_value=is_multi) + dispatcher, "_is_multi_project", MagicMock(return_value=is_multi) ): tools = await dispatcher.list_tools() @@ -128,32 +110,22 @@ async def test_routes_based_on_project_mode( mcps[called].list_tools.assert_awaited_once() mcps[not_called].list_tools.assert_not_awaited() - async def test_list_tools_bypasses_eliciting_provider(self): - """list_tools() must use inner_provider only — never block on elicitation. + async def test_list_tools_does_not_fetch_credentials(self): + """list_tools() must not trigger credential fetching — prevents #759 regression. - Regression test for: v1.17.x shows 'No tools' in Cursor for dbt Core - users because ElicitingCredentialsProvider.get_credentials() was called - during ListToolsRequest, causing a timeout via the elicitation prompt. + Previously _is_multi_project() called get_credentials() which could block + on elicitation, causing a 17-second timeout in Cursor. """ single = MagicMock(spec=FastMCP) single.list_tools = AsyncMock(return_value=[_make_tool("single_tool")]) dispatcher = _make_dispatcher(single_project_mcp=single) - # Inner provider raises MissingHostError → is_multi = False → single-project - dispatcher.config.credentials_provider.inner_provider.get_credentials = ( - AsyncMock( - side_effect=MissingHostError( - "DBT_HOST is a required environment variable" - ) - ) - ) - tools = await dispatcher.list_tools() assert [t.name for t in tools] == ["single_tool"] - # The eliciting provider must never be called from list_tools - dispatcher.config.credentials_provider.get_credentials.assert_not_awaited() - dispatcher.config.credentials_provider.inner_provider.get_credentials.assert_awaited_once() + # Neither provider's get_credentials should be called from list_tools + dispatcher.config.credentials_provider.get_credentials.assert_not_called() + dispatcher.config.credentials_provider.inner_provider.get_credentials.assert_not_called() class TestCallToolRouting: @@ -183,7 +155,7 @@ async def test_routes_based_on_project_mode( multi_project_mcp=multi, single_project_mcp=single ) with patch.object( - dispatcher, "_is_multi_project", AsyncMock(return_value=is_multi) + dispatcher, "_is_multi_project", MagicMock(return_value=is_multi) ): result = await dispatcher.call_tool("some_tool", {"arg": "val"}) @@ -200,7 +172,7 @@ async def test_raises_on_tool_error(self): multi_project_mcp=multi, single_project_mcp=single ) with patch.object( - dispatcher, "_is_multi_project", AsyncMock(return_value=False) + dispatcher, "_is_multi_project", MagicMock(return_value=False) ): with pytest.raises(RuntimeError, match="something broke"): await dispatcher.call_tool("bad_tool", {}) @@ -216,7 +188,7 @@ async def test_tracking_failure_does_not_suppress_tool_error(self): side_effect=MissingHostError("tracking credentials missing") ) with patch.object( - dispatcher, "_is_multi_project", AsyncMock(return_value=False) + dispatcher, "_is_multi_project", MagicMock(return_value=False) ): with pytest.raises(MissingHostError, match="DBT_HOST"): await dispatcher.call_tool("some_tool", {}) @@ -239,7 +211,7 @@ async def test_lifespan_logs_exception_with_traceback(self, caplog): server.config.enable_tools = None server.config.enabled_toolsets = set() server.config.disabled_toolsets = set() - server._is_multi_project = AsyncMock(return_value=False) + server._is_multi_project = MagicMock(return_value=False) with patch( "dbt_mcp.mcp.server.register_proxied_tools", side_effect=AssertionError() @@ -273,7 +245,7 @@ async def test_sensitive_args_not_logged(self, caplog): with ( patch.object( - dispatcher, "_is_multi_project", AsyncMock(return_value=False) + dispatcher, "_is_multi_project", MagicMock(return_value=False) ), caplog.at_level(logging.INFO, logger="dbt_mcp.mcp.server"), ): From b8cc36394fa7dff2f6de1506bcfecd5cbe59d737 Mon Sep 17 00:00:00 2001 From: Muizz Lateef Date: Tue, 19 May 2026 20:28:23 +0100 Subject: [PATCH 3/5] fix(config): wire ElicitingCredentialsProvider to platform config providers --- src/dbt_mcp/config/config.py | 26 ++++++++++++++++++-------- src/dbt_mcp/config/settings.py | 34 ++++++++++++++++++++++++++++++++++ 2 files changed, 52 insertions(+), 8 deletions(-) diff --git a/src/dbt_mcp/config/config.py b/src/dbt_mcp/config/config.py index ba1c53357..7f11d6b9e 100644 --- a/src/dbt_mcp/config/config.py +++ b/src/dbt_mcp/config/config.py @@ -107,7 +107,16 @@ def load_config(enable_proxied_tools: bool = True) -> Config: settings = DbtMcpSettings() # type: ignore inner_credentials = CredentialsProvider(settings) - credentials_provider = ElicitingCredentialsProvider(inner_credentials) + eliciting_credentials = ElicitingCredentialsProvider(inner_credentials) + + # Platform providers get eliciting wrapper when platform toolsets are active. + # CLI-only users get raw credentials — defense-in-depth alongside register.py's + # allowlist gating which already prevents platform tool calls for these users. + platform_credentials = ( + eliciting_credentials + if settings.any_platform_toolset_active + else inner_credentials + ) # Set default warn error options if not provided if settings.dbt_warn_error_options is None: @@ -127,7 +136,8 @@ def load_config(enable_proxied_tools: bool = True) -> Config: if getattr(settings, attr_name, False) } - # Proxied tools still gated on explicit opt-in flag + # Proxied tools stay on inner_credentials — lifespan-time registration, + # request_ctx is None, elicitation impossible by design proxied_tool_config_provider = None if enable_proxied_tools: proxied_tool_config_provider = DefaultProxiedToolConfigProvider( @@ -135,26 +145,26 @@ def load_config(enable_proxied_tools: bool = True) -> Config: ) admin_api_config_provider = DefaultAdminApiConfigProvider( - credentials_provider=inner_credentials, + credentials_provider=platform_credentials, ) admin_client = DbtAdminAPIClient(admin_api_config_provider) multi_project_discovery_config_provider = MultiProjectDiscoveryConfigProvider( - credentials_provider=inner_credentials, + credentials_provider=platform_credentials, admin_client=admin_client, ) multi_project_semantic_layer_config_provider = ( MultiProjectSemanticLayerConfigProvider( - credentials_provider=inner_credentials, + credentials_provider=platform_credentials, admin_client=admin_client, metrics_related_max=settings.sl_metrics_related_max, max_response_chars=settings.sl_metrics_max_response_chars, ) ) discovery_config_provider = DefaultDiscoveryConfigProvider( - credentials_provider=inner_credentials, + credentials_provider=platform_credentials, ) semantic_layer_config_provider = DefaultSemanticLayerConfigProvider( - credentials_provider=inner_credentials, + credentials_provider=platform_credentials, metrics_related_max=settings.sl_metrics_related_max, max_response_chars=settings.sl_metrics_max_response_chars, ) @@ -211,6 +221,6 @@ def load_config(enable_proxied_tools: bool = True) -> Config: multi_project_semantic_layer_config_provider=multi_project_semantic_layer_config_provider, semantic_layer_config_provider=semantic_layer_config_provider, admin_api_config_provider=admin_api_config_provider, - credentials_provider=credentials_provider, + credentials_provider=eliciting_credentials, lsp_config=lsp_config, ) diff --git a/src/dbt_mcp/config/settings.py b/src/dbt_mcp/config/settings.py index 640e106bf..618e2284b 100644 --- a/src/dbt_mcp/config/settings.py +++ b/src/dbt_mcp/config/settings.py @@ -167,6 +167,40 @@ def actual_disable_sql(self) -> bool: return self.disable_remote return True + @property + def any_platform_toolset_active(self) -> bool: + """Whether any platform toolset is active under the current config mode. + + Three modes (mirrors register.py:should_register_tool and config.py + enabled_toolsets/disabled_toolsets computation): + 1. Allowlist mode (any ENABLE_* flag set): only explicitly enabled + platform toolsets count. + 2. Denylist/default mode: platform toolsets active unless disabled. + + Platform toolsets: semantic_layer, discovery, admin_api, sql. + Local toolsets: dbt_cli, dbt_codegen, lsp, product_docs, mcp_server_metadata. + """ + has_any_enable = self.enable_tools is not None or any(( + self.enable_semantic_layer, self.enable_discovery, + self.enable_admin_api, self.enable_sql, + self.enable_dbt_cli, self.enable_dbt_codegen, + self.enable_lsp, self.enable_product_docs, + self.enable_mcp_server_metadata, + )) + if has_any_enable: + return any(( + self.enable_semantic_layer, + self.enable_discovery, + self.enable_admin_api, + self.enable_sql, + )) + return any(( + not self.disable_semantic_layer, + not self.disable_discovery, + not self.disable_admin_api, + not self.actual_disable_sql, + )) + @property def actual_host_prefix(self) -> str | None: if self.host_prefix is not None: From 8b479410f0aca51474e05cf06cb05e188627667b Mon Sep 17 00:00:00 2001 From: Muizz Lateef Date: Tue, 19 May 2026 20:33:46 +0100 Subject: [PATCH 4/5] Rebase tests. test(config): add tests for any_platform_toolset_active and provider wiring test(config): add tests for any_platform_toolset_active and provider wiring --- tests/unit/config/test_config.py | 102 +++++++++++++++++++++++++++++++ 1 file changed, 102 insertions(+) diff --git a/tests/unit/config/test_config.py b/tests/unit/config/test_config.py index edd372e8c..d51b8bc78 100644 --- a/tests/unit/config/test_config.py +++ b/tests/unit/config/test_config.py @@ -8,6 +8,8 @@ DbtMcpSettings, load_config, ) +from dbt_mcp.config.credentials import CredentialsProvider +from dbt_mcp.config.elicitation import ElicitingCredentialsProvider from dbt_mcp.config.settings import ( DEFAULT_DBT_CLI_TIMEOUT, HostPrefixResult, @@ -556,3 +558,103 @@ def test_case_insensitive_environment_variables(self): config = self._load_config_with_env(env_vars) assert config.discovery_config_provider is not None assert config.credentials_provider is not None + + +class TestAnyPlatformToolsetActive: + """Tests for DbtMcpSettings.any_platform_toolset_active property. + + Uses model_construct() to test the property in isolation without + validators, env var loading, or filesystem checks. + """ + + @pytest.mark.parametrize( + "kwargs, expected", + [ + pytest.param( + {}, + True, + id="default_mode_all_active", + ), + pytest.param( + {"enable_dbt_cli": True}, + False, + id="cli_only_allowlist", + ), + pytest.param( + {"enable_semantic_layer": True}, + True, + id="platform_allowlist", + ), + pytest.param( + {"enable_dbt_cli": True, "enable_discovery": True}, + True, + id="mixed_allowlist", + ), + pytest.param( + { + "disable_semantic_layer": True, + "disable_discovery": True, + "disable_admin_api": True, + "disable_sql": True, + }, + False, + id="all_platform_disabled", + ), + pytest.param( + {"enable_tools": ["get_all_models"]}, + False, + id="individual_tool_no_toolset", + ), + ], + ) + def test_any_platform_toolset_active(self, kwargs, expected): + settings = DbtMcpSettings.model_construct(**kwargs) + assert settings.any_platform_toolset_active is expected + + +class TestProviderWiring(TestLoadConfig): + """Tests that load_config() passes the correct credentials provider to each provider. + + Extends TestLoadConfig to reuse _load_config_with_env and setup_method. + """ + + def test_platform_providers_get_eliciting_wrapper_default_mode(self): + """Default mode -- platform providers get ElicitingCredentialsProvider.""" + config = self._load_config_with_env({}) + assert isinstance( + config.discovery_config_provider.credentials_provider, + ElicitingCredentialsProvider, + ) + assert isinstance( + config.semantic_layer_config_provider.credentials_provider, + ElicitingCredentialsProvider, + ) + assert isinstance( + config.admin_api_config_provider.credentials_provider, + ElicitingCredentialsProvider, + ) + + def test_platform_providers_get_inner_when_cli_only(self): + """CLI-only allowlist -- platform providers get raw CredentialsProvider.""" + config = self._load_config_with_env({"DBT_MCP_ENABLE_DBT_CLI": "true"}) + assert isinstance( + config.discovery_config_provider.credentials_provider, + CredentialsProvider, + ) + assert not isinstance( + config.discovery_config_provider.credentials_provider, + ElicitingCredentialsProvider, + ) + + def test_proxied_tools_always_get_inner(self): + """Proxied tools never get eliciting wrapper regardless of mode.""" + config = self._load_config_with_env({}) + assert config.proxied_tool_config_provider is not None + assert isinstance( + config.proxied_tool_config_provider.credentials_provider, + CredentialsProvider, + ) + assert not isinstance( + config.proxied_tool_config_provider.credentials_provider, + ElicitingCredentialsProvider, + ) From 9a0c95e1ae8d187593284de4bbb993e6f1cf5b2e Mon Sep 17 00:00:00 2001 From: Muizz Lateef Date: Sat, 23 May 2026 14:20:21 +0100 Subject: [PATCH 5/5] test: fix TestProviderWiring inheriting duplicate tests from TestLoadConfig --- tests/unit/config/test_config.py | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/tests/unit/config/test_config.py b/tests/unit/config/test_config.py index d51b8bc78..25dfc4d05 100644 --- a/tests/unit/config/test_config.py +++ b/tests/unit/config/test_config.py @@ -612,11 +612,22 @@ def test_any_platform_toolset_active(self, kwargs, expected): assert settings.any_platform_toolset_active is expected -class TestProviderWiring(TestLoadConfig): - """Tests that load_config() passes the correct credentials provider to each provider. +class TestProviderWiring: + """Tests that load_config() passes the correct credentials provider to each provider.""" - Extends TestLoadConfig to reuse _load_config_with_env and setup_method. - """ + def _load_config_with_env(self, env_vars): + with ( + patch.dict(os.environ, env_vars), + patch("dbt_mcp.config.config.DbtMcpSettings") as mock_settings_class, + patch( + "dbt_mcp.config.config.detect_binary_type", + return_value=BinaryType.DBT_CORE, + ), + ): + with patch.dict(os.environ, env_vars, clear=True): + settings_instance = DbtMcpSettings(_env_file=None) + mock_settings_class.return_value = settings_instance + return load_config() def test_platform_providers_get_eliciting_wrapper_default_mode(self): """Default mode -- platform providers get ElicitingCredentialsProvider."""