Skip to content

Commit 2948293

Browse files
feat: add MCP elicitation for interactive DBT_HOST configuration (#728)
## Summary Add MCP elicitation support so users can configure `DBT_HOST` interactively when it's not set, instead of getting an error. ## What Changed - New `elicitation.py` with reusable elicitation infrastructure (`get_elicitation_session`, `elicit_or_raise`) that any future elicitation can build on - First consumer: `ElicitingCredentialsProvider` wraps `CredentialsProvider` to prompt for `DBT_HOST` when missing, then feeds it into the existing OAuth flow - Elicited host persists to `~/.dbt/mcp.yml` via `DbtPlatformContext.dbt_host` — reuses the existing context file and `override()` merge logic (no separate config file) - `config.py`/`settings.py`/`server.py`: wire the wrapper, stop auto-disabling platform features, remove conditional guards on tool registration ## Why Users currently need to edit JSON config files to set `DBT_HOST` before the server works. For non-technical users, this is a barrier. With elicitation, the server prompts for the host interactively on first use, persists it, and never asks again. Clients that don't support elicitation (e.g., Claude Desktop) get the original error message — no regression. ## Related Issues Closes #488 ### Testing ### MCP INSPECTOR: <img width="1847" height="885" alt="image" src="https://github.com/user-attachments/assets/383299a8-6f38-40a0-be70-8f48e82576ba" /> <img width="1847" height="885" alt="image" src="https://github.com/user-attachments/assets/a7346cb9-8663-46da-bc09-85deb32263d2" /> <img width="1847" height="885" alt="image" src="https://github.com/user-attachments/assets/4fbfe937-e928-4cba-9425-a237abe3c573" /> ### CC <img width="1331" height="211" alt="image" src="https://github.com/user-attachments/assets/d87c72fd-874f-436d-983a-486e4f71b76a" /> <img width="1331" height="258" alt="image" src="https://github.com/user-attachments/assets/bca12012-f756-494f-9d24-1a4c00aa5580" /> <img width="1331" height="258" alt="image" src="https://github.com/user-attachments/assets/dd6ce39a-3372-4c50-a38a-fd31a4d4c54a" /> ## Checklist - [x] I have performed a self-review of my code - [x] I have made corresponding changes to the documentation (in https://github.com/dbt-labs/docs.getdbt.com) if required -> dbt-labs/docs.getdbt.com#9001 - [x] I have added tests that prove my fix is effective or that my feature works - [x] New and existing unit tests pass locally with my changes ## Additional Notes - Tested with MCP Inspector (elicitation form works), Claude Code (elicitation works inline), and Claude Desktop (graceful degradation — `elicitation=None` in capabilities) - Only `DBT_HOST` is elicited; `DBT_PROJECT_DIR` and `DBT_PATH` remain env-var-only for CLI users - Elicited host persists to `~/.dbt/mcp.yml` as `dbt_host` field on `DbtPlatformContext`; env vars always take precedence - `host_prefix` is also restored from persisted context on restart for multi-cell account URL construction --------- Co-authored-by: Jairus Martinez <114552516+jairus-m@users.noreply.github.com>
1 parent 5226ab8 commit 2948293

12 files changed

Lines changed: 1097 additions & 198 deletions

File tree

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
kind: Enhancement or New Feature
2+
body: Elicit DBT_HOST from user on first platform tool call when not set via env vars (#488)
3+
time: 2026-04-17T23:25:37.853311+01:00

src/dbt_mcp/config/config.py

Lines changed: 36 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
MultiProjectSemanticLayerConfigProvider,
1515
)
1616
from dbt_mcp.config.credentials import CredentialsProvider
17+
from dbt_mcp.config.elicitation import ElicitingCredentialsProvider
1718
from dbt_mcp.config.settings import (
1819
DbtMcpLogSettings,
1920
DbtMcpSettings,
@@ -87,14 +88,14 @@ class Config:
8788
proxied_tool_config_provider: DefaultProxiedToolConfigProvider | None
8889
dbt_cli_config: DbtCliConfig | None
8990
dbt_codegen_config: DbtCodegenConfig | None
90-
multi_project_discovery_config_provider: MultiProjectDiscoveryConfigProvider | None
91-
discovery_config_provider: DefaultDiscoveryConfigProvider | None
91+
multi_project_discovery_config_provider: MultiProjectDiscoveryConfigProvider
92+
discovery_config_provider: DefaultDiscoveryConfigProvider
9293
multi_project_semantic_layer_config_provider: (
93-
MultiProjectSemanticLayerConfigProvider | None
94+
MultiProjectSemanticLayerConfigProvider
9495
)
95-
semantic_layer_config_provider: DefaultSemanticLayerConfigProvider | None
96-
admin_api_config_provider: DefaultAdminApiConfigProvider | None
97-
credentials_provider: CredentialsProvider
96+
semantic_layer_config_provider: DefaultSemanticLayerConfigProvider
97+
admin_api_config_provider: DefaultAdminApiConfigProvider
98+
credentials_provider: ElicitingCredentialsProvider
9899
lsp_config: LspConfig | None
99100

100101

@@ -104,7 +105,9 @@ def load_config(enable_proxied_tools: bool = True) -> Config:
104105
file_logging=log_settings.file_logging, log_level=log_settings.log_level
105106
)
106107
settings = DbtMcpSettings() # type: ignore
107-
credentials_provider = CredentialsProvider(settings)
108+
109+
inner_credentials = CredentialsProvider(settings)
110+
credentials_provider = ElicitingCredentialsProvider(inner_credentials)
108111

109112
# Set default warn error options if not provided
110113
if settings.dbt_warn_error_options is None:
@@ -124,33 +127,39 @@ def load_config(enable_proxied_tools: bool = True) -> Config:
124127
if getattr(settings, attr_name, False)
125128
}
126129

130+
# Proxied tools still gated on explicit opt-in flag
127131
proxied_tool_config_provider = None
128-
if enable_proxied_tools and settings.actual_host:
132+
if enable_proxied_tools:
129133
proxied_tool_config_provider = DefaultProxiedToolConfigProvider(
130-
credentials_provider=credentials_provider
134+
credentials_provider=inner_credentials
131135
)
132136

133-
admin_api_config_provider = None
134-
multi_project_discovery_config_provider = None
135-
multi_project_semantic_layer_config_provider = None
136-
if settings.actual_host:
137-
admin_api_config_provider = DefaultAdminApiConfigProvider(
138-
credentials_provider=credentials_provider,
139-
)
140-
admin_client = DbtAdminAPIClient(admin_api_config_provider)
141-
multi_project_discovery_config_provider = MultiProjectDiscoveryConfigProvider(
142-
credentials_provider=credentials_provider,
137+
admin_api_config_provider = DefaultAdminApiConfigProvider(
138+
credentials_provider=inner_credentials,
139+
)
140+
admin_client = DbtAdminAPIClient(admin_api_config_provider)
141+
multi_project_discovery_config_provider = MultiProjectDiscoveryConfigProvider(
142+
credentials_provider=inner_credentials,
143+
admin_client=admin_client,
144+
)
145+
multi_project_semantic_layer_config_provider = (
146+
MultiProjectSemanticLayerConfigProvider(
147+
credentials_provider=inner_credentials,
143148
admin_client=admin_client,
149+
metrics_related_max=settings.sl_metrics_related_max,
150+
max_response_chars=settings.sl_metrics_max_response_chars,
144151
)
145-
multi_project_semantic_layer_config_provider = (
146-
MultiProjectSemanticLayerConfigProvider(
147-
credentials_provider=credentials_provider,
148-
admin_client=admin_client,
149-
metrics_related_max=settings.sl_metrics_related_max,
150-
max_response_chars=settings.sl_metrics_max_response_chars,
151-
)
152-
)
152+
)
153+
discovery_config_provider = DefaultDiscoveryConfigProvider(
154+
credentials_provider=inner_credentials,
155+
)
156+
semantic_layer_config_provider = DefaultSemanticLayerConfigProvider(
157+
credentials_provider=inner_credentials,
158+
metrics_related_max=settings.sl_metrics_related_max,
159+
max_response_chars=settings.sl_metrics_max_response_chars,
160+
)
153161

162+
# CLI/codegen/LSP — still conditional (need concrete paths at registration time)
154163
dbt_cli_config = None
155164
if settings.dbt_project_dir and settings.dbt_path:
156165
binary_type = detect_binary_type(settings.dbt_path)
@@ -171,20 +180,6 @@ def load_config(enable_proxied_tools: bool = True) -> Config:
171180
binary_type=binary_type,
172181
)
173182

174-
discovery_config_provider = None
175-
if settings.actual_host:
176-
discovery_config_provider = DefaultDiscoveryConfigProvider(
177-
credentials_provider=credentials_provider,
178-
)
179-
180-
semantic_layer_config_provider = None
181-
if settings.actual_host:
182-
semantic_layer_config_provider = DefaultSemanticLayerConfigProvider(
183-
credentials_provider=credentials_provider,
184-
metrics_related_max=settings.sl_metrics_related_max,
185-
max_response_chars=settings.sl_metrics_max_response_chars,
186-
)
187-
188183
lsp_config = None
189184
if settings.dbt_project_dir:
190185
lsp_binary_info = dbt_lsp_binary_info(

src/dbt_mcp/config/credentials.py

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
from filelock import FileLock
99

1010
from dbt_mcp.config.config_providers.admin_api import DefaultAdminApiConfigProvider
11+
from dbt_mcp.errors.common import MissingHostError
1112
from dbt_mcp.config.headers import TokenProvider
1213
from dbt_mcp.config.settings import (
1314
DbtMcpSettings,
@@ -184,7 +185,7 @@ async def get_dbt_platform_context(
184185

185186

186187
class CredentialsProvider:
187-
def __init__(self, settings: "DbtMcpSettings") -> None:
188+
def __init__(self, settings: DbtMcpSettings) -> None:
188189
self.settings = settings
189190
self.token_provider: TokenProvider | None = None
190191
self.authentication_method: AuthenticationMethod | None = None
@@ -231,7 +232,7 @@ def _log_settings(self) -> None:
231232
settings["dbt_token"] = "***redacted***"
232233
logger.info(f"Settings: {settings}")
233234

234-
async def get_credentials(self) -> "tuple[DbtMcpSettings, TokenProvider]":
235+
async def get_credentials(self) -> tuple[DbtMcpSettings, TokenProvider]:
235236
if self.token_provider is not None:
236237
# If token provider is already set, just return the cached values
237238
return self.settings, self.token_provider
@@ -248,13 +249,22 @@ async def get_credentials(self) -> "tuple[DbtMcpSettings, TokenProvider]":
248249
dbt_profiles_dir=self.settings.dbt_profiles_dir
249250
)
250251
config_location = dbt_user_dir / "mcp.yml"
252+
dbt_platform_context_manager = DbtPlatformContextManager(config_location)
253+
existing_context = dbt_platform_context_manager.read_context()
254+
if existing_context and not self.settings.actual_host:
255+
if existing_context.dbt_host:
256+
self.settings.dbt_host = existing_context.dbt_host
257+
if (
258+
existing_context.host_prefix
259+
and not self.settings.actual_host_prefix
260+
):
261+
self.settings.host_prefix = existing_context.host_prefix
251262
actual_host = self.settings.actual_host
252263
if not actual_host:
253-
raise ValueError("DBT_HOST is a required environment variable")
264+
raise MissingHostError("DBT_HOST is a required environment variable")
254265
dbt_platform_url = _build_dbt_platform_url(
255266
actual_host, self.settings.actual_host_prefix
256267
)
257-
dbt_platform_context_manager = DbtPlatformContextManager(config_location)
258268
dbt_platform_context = await get_dbt_platform_context(
259269
dbt_platform_context_manager=dbt_platform_context_manager,
260270
dbt_user_dir=dbt_user_dir,
@@ -277,6 +287,9 @@ async def get_credentials(self) -> "tuple[DbtMcpSettings, TokenProvider]":
277287
self.settings.host_prefix = dbt_platform_context.host_prefix
278288
self.settings.dbt_project_ids = dbt_platform_context.selected_project_ids
279289
self.settings.dbt_host = self.settings.base_host
290+
if isinstance(dbt_platform_context, DbtPlatformContext):
291+
dbt_platform_context.dbt_host = self.settings.actual_host
292+
dbt_platform_context_manager.write_context_to_file(dbt_platform_context)
280293
if not dbt_platform_context.decoded_access_token:
281294
raise ValueError("No decoded access token found in OAuth context")
282295

0 commit comments

Comments
 (0)