Skip to content

Commit 7799aad

Browse files
committed
Move env var loading to pydantic_settings
1 parent ed3cff4 commit 7799aad

4 files changed

Lines changed: 724 additions & 706 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: Move env var parsing to pydantic_settings for better validation
3+
time: 2025-07-21T10:40:53.271118+02:00

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ dependencies = [
2121
"dbtlabs-vortex==0.2.0",
2222
"mcp[cli]==1.10.1",
2323
"pandas==2.2.3",
24-
"python-dotenv==1.0.1",
24+
"pydantic-settings==2.10.1",
2525
"pyyaml==6.0.2",
2626
"requests==2.32.4",
2727
]

src/dbt_mcp/config/config.py

Lines changed: 128 additions & 114 deletions
Original file line numberDiff line numberDiff line change
@@ -1,214 +1,228 @@
11
import os
2-
from dataclasses import dataclass
32
from pathlib import Path
43

54
import yaml
6-
from dotenv import load_dotenv
5+
from pydantic import BaseModel, Field, field_validator
6+
from pydantic_settings import BaseSettings, SettingsConfigDict
77

88

9-
@dataclass
10-
class TrackingConfig:
11-
host: str | None
12-
multicell_account_prefix: str | None
13-
prod_environment_id: int | None
14-
dev_environment_id: int | None
15-
dbt_cloud_user_id: int | None
16-
local_user_id: str | None
9+
class TrackingConfig(BaseModel):
10+
host: str | None = None
11+
multicell_account_prefix: str | None = None
12+
prod_environment_id: int | None = None
13+
dev_environment_id: int | None = None
14+
dbt_cloud_user_id: int | None = None
15+
local_user_id: str | None = None
1716

1817

19-
@dataclass
20-
class SemanticLayerConfig:
18+
class SemanticLayerConfig(BaseModel):
2119
url: str
2220
host: str
2321
prod_environment_id: int
2422
service_token: str
2523
headers: dict[str, str]
2624

2725

28-
@dataclass
29-
class DiscoveryConfig:
26+
class DiscoveryConfig(BaseModel):
3027
url: str
3128
headers: dict[str, str]
3229
environment_id: int
3330

3431

35-
@dataclass
36-
class DbtCliConfig:
32+
class DbtCliConfig(BaseModel):
3733
project_dir: str
3834
dbt_path: str
3935
dbt_cli_timeout: int
4036

4137

42-
@dataclass
43-
class RemoteConfig:
44-
multicell_account_prefix: str | None
38+
class RemoteConfig(BaseModel):
39+
multicell_account_prefix: str | None = None
4540
host: str
4641
user_id: int
4742
dev_environment_id: int
4843
prod_environment_id: int
4944
token: str
5045

5146

52-
@dataclass
53-
class Config:
47+
class DbtMcpSettings(BaseSettings):
48+
model_config = SettingsConfigDict(
49+
env_prefix="",
50+
case_sensitive=False,
51+
env_file=".env",
52+
env_file_encoding="utf-8",
53+
extra="ignore"
54+
)
55+
56+
# Environment variables with proper field configuration
57+
dbt_host: str | None = Field(None, alias="DBT_HOST")
58+
dbt_mcp_host: str | None = Field(None, alias="DBT_MCP_HOST")
59+
dbt_prod_env_id: int | None = Field(None, alias="DBT_PROD_ENV_ID")
60+
dbt_env_id: int | None = Field(None, alias="DBT_ENV_ID") # legacy support
61+
dbt_dev_env_id: int | None = Field(None, alias="DBT_DEV_ENV_ID")
62+
dbt_user_id: int | None = Field(None, alias="DBT_USER_ID")
63+
dbt_token: str | None = Field(None, alias="DBT_TOKEN")
64+
dbt_project_dir: str | None = Field(None, alias="DBT_PROJECT_DIR")
65+
dbt_path: str = Field("dbt", alias="DBT_PATH")
66+
dbt_cli_timeout: int = Field(10, alias="DBT_CLI_TIMEOUT")
67+
dbt_warn_error_options: str | None = Field(None, alias="DBT_WARN_ERROR_OPTIONS")
68+
69+
disable_dbt_cli: bool = Field(False, alias="DISABLE_DBT_CLI")
70+
disable_semantic_layer: bool = Field(False, alias="DISABLE_SEMANTIC_LAYER")
71+
disable_discovery: bool = Field(False, alias="DISABLE_DISCOVERY")
72+
disable_remote: bool = Field(True, alias="DISABLE_REMOTE")
73+
disable_tools: list[str] = Field(default_factory=list, alias="DISABLE_TOOLS")
74+
75+
multicell_account_prefix: str | None = Field(None, alias="MULTICELL_ACCOUNT_PREFIX")
76+
77+
@property
78+
def actual_host(self) -> str | None:
79+
return self.dbt_host or self.dbt_mcp_host
80+
81+
@property
82+
def actual_prod_environment_id(self) -> int | None:
83+
return self.dbt_prod_env_id or self.dbt_env_id
84+
85+
@field_validator("disable_tools", mode="before")
86+
@classmethod
87+
def parse_disable_tools(cls, value: str | list[str]) -> list[str]:
88+
if isinstance(value, str):
89+
return [tool.strip() for tool in value.split(",") if tool.strip()]
90+
return value
91+
92+
93+
94+
class Config(BaseModel):
5495
tracking_config: TrackingConfig
55-
remote_config: RemoteConfig | None
56-
dbt_cli_config: DbtCliConfig | None
57-
discovery_config: DiscoveryConfig | None
58-
semantic_layer_config: SemanticLayerConfig | None
96+
remote_config: RemoteConfig | None = None
97+
dbt_cli_config: DbtCliConfig | None = None
98+
discovery_config: DiscoveryConfig | None = None
99+
semantic_layer_config: SemanticLayerConfig | None = None
59100
disable_tools: list[str]
60101

61102

62103
def load_config() -> Config:
63-
load_dotenv()
64-
65-
host = os.environ.get("DBT_HOST")
66-
cursor_host = os.environ.get("DBT_MCP_HOST")
67-
prod_environment_id = os.environ.get("DBT_PROD_ENV_ID")
68-
legacy_prod_environment_id = os.environ.get("DBT_ENV_ID")
69-
dev_environment_id = os.environ.get("DBT_DEV_ENV_ID")
70-
user_id = os.environ.get("DBT_USER_ID")
71-
token = os.environ.get("DBT_TOKEN")
72-
project_dir = os.environ.get("DBT_PROJECT_DIR")
73-
dbt_path = os.environ.get("DBT_PATH", "dbt")
74-
disable_dbt_cli = os.environ.get("DISABLE_DBT_CLI", "false") == "true"
75-
disable_semantic_layer = os.environ.get("DISABLE_SEMANTIC_LAYER", "false") == "true"
76-
disable_discovery = os.environ.get("DISABLE_DISCOVERY", "false") == "true"
77-
disable_remote = os.environ.get("DISABLE_REMOTE", "true") == "true"
78-
multicell_account_prefix = os.environ.get("MULTICELL_ACCOUNT_PREFIX", None)
79-
dbt_cli_timeout = int(os.environ.get("DBT_CLI_TIMEOUT", 10))
80-
disable_tools = os.environ.get("DISABLE_TOOLS", "").split(",")
81-
82-
# set default warn error options if not provided
83-
if os.environ.get("DBT_WARN_ERROR_OPTIONS") is None:
104+
# Load settings from environment variables using pydantic_settings
105+
settings = DbtMcpSettings() # type: ignore[call-arg]
106+
107+
# Set default warn error options if not provided
108+
if settings.dbt_warn_error_options is None:
84109
warn_error_options = '{"error": ["NoNodesForSelectionCriteria"]}'
85110
os.environ["DBT_WARN_ERROR_OPTIONS"] = warn_error_options
86111

87-
# Devon: I messed up and uploaded the wrong
88-
# env var here https://docs.cursor.com/tools.
89-
# So, we are supporting this alias now.
90-
actual_host = host or cursor_host
91-
92-
errors = []
93-
if not disable_semantic_layer or not disable_discovery or not disable_remote:
94-
if not actual_host:
112+
# Validation
113+
errors: list[str] = []
114+
if not settings.disable_semantic_layer or not settings.disable_discovery or not settings.disable_remote:
115+
if not settings.actual_host:
95116
errors.append(
96117
"DBT_HOST environment variable is required when semantic layer, discovery, or remote tools are enabled."
97118
)
98-
if not prod_environment_id and not legacy_prod_environment_id:
119+
if not settings.actual_prod_environment_id:
99120
errors.append(
100121
"DBT_PROD_ENV_ID environment variable is required when semantic layer, discovery, or remote tools are enabled."
101122
)
102-
if not token:
123+
if not settings.dbt_token:
103124
errors.append(
104125
"DBT_TOKEN environment variable is required when semantic layer, discovery, or remote tools are enabled."
105126
)
106-
if actual_host and (
107-
actual_host.startswith("metadata")
108-
or actual_host.startswith("semantic-layer")
127+
if settings.actual_host and (
128+
settings.actual_host.startswith("metadata")
129+
or settings.actual_host.startswith("semantic-layer")
109130
):
110131
errors.append(
111132
"DBT_HOST must not start with 'metadata' or 'semantic-layer'."
112133
)
113-
if not disable_remote:
114-
if not dev_environment_id:
134+
if not settings.disable_remote:
135+
if not settings.dbt_dev_env_id:
115136
errors.append(
116137
"DBT_DEV_ENV_ID environment variable is required when remote tools are enabled."
117138
)
118-
if not user_id:
139+
if not settings.dbt_user_id:
119140
errors.append(
120141
"DBT_USER_ID environment variable is required when remote tools are enabled."
121142
)
122-
if not disable_dbt_cli:
123-
if not project_dir:
143+
if not settings.disable_dbt_cli:
144+
if not settings.dbt_project_dir:
124145
errors.append(
125146
"DBT_PROJECT_DIR environment variable is required when dbt CLI tools are enabled."
126147
)
127-
if not dbt_path:
148+
if not settings.dbt_path:
128149
errors.append(
129150
"DBT_PATH environment variable is required when dbt CLI tools are enabled."
130151
)
131152

132153
if errors:
133154
raise ValueError("Errors found in configuration:\n\n" + "\n".join(errors))
134155

135-
# Allowing for configuration with legacy DBT_ENV_ID environment variable
136-
actual_prod_environment_id = (
137-
int(prod_environment_id)
138-
if prod_environment_id
139-
else int(legacy_prod_environment_id)
140-
if legacy_prod_environment_id
141-
else None
142-
)
143-
156+
# Build configurations
144157
remote_config = None
145158
if (
146-
not disable_remote
147-
and user_id
148-
and token
149-
and dev_environment_id
150-
and actual_prod_environment_id
151-
and actual_host
159+
not settings.disable_remote
160+
and settings.dbt_user_id
161+
and settings.dbt_token
162+
and settings.dbt_dev_env_id
163+
and settings.actual_prod_environment_id
164+
and settings.actual_host
152165
):
153166
remote_config = RemoteConfig(
154-
multicell_account_prefix=multicell_account_prefix,
155-
user_id=int(user_id),
156-
token=token,
157-
dev_environment_id=int(dev_environment_id),
158-
prod_environment_id=actual_prod_environment_id,
159-
host=actual_host,
167+
multicell_account_prefix=settings.multicell_account_prefix,
168+
user_id=settings.dbt_user_id,
169+
token=settings.dbt_token,
170+
dev_environment_id=settings.dbt_dev_env_id,
171+
prod_environment_id=settings.actual_prod_environment_id,
172+
host=settings.actual_host,
160173
)
161174

162175
dbt_cli_config = None
163-
if not disable_dbt_cli and project_dir and dbt_path:
176+
if not settings.disable_dbt_cli and settings.dbt_project_dir and settings.dbt_path:
164177
dbt_cli_config = DbtCliConfig(
165-
project_dir=project_dir,
166-
dbt_path=dbt_path,
167-
dbt_cli_timeout=dbt_cli_timeout,
178+
project_dir=settings.dbt_project_dir,
179+
dbt_path=settings.dbt_path,
180+
dbt_cli_timeout=settings.dbt_cli_timeout,
168181
)
169182

170183
discovery_config = None
171-
if not disable_discovery and actual_host and actual_prod_environment_id and token:
172-
if multicell_account_prefix:
173-
url = f"https://{multicell_account_prefix}.metadata.{actual_host}/graphql"
184+
if not settings.disable_discovery and settings.actual_host and settings.actual_prod_environment_id and settings.dbt_token:
185+
if settings.multicell_account_prefix:
186+
url = f"https://{settings.multicell_account_prefix}.metadata.{settings.actual_host}/graphql"
174187
else:
175-
url = f"https://metadata.{actual_host}/graphql"
188+
url = f"https://metadata.{settings.actual_host}/graphql"
176189
discovery_config = DiscoveryConfig(
177190
url=url,
178191
headers={
179-
"Authorization": f"Bearer {token}",
192+
"Authorization": f"Bearer {settings.dbt_token}",
180193
"Content-Type": "application/json",
181194
},
182-
environment_id=actual_prod_environment_id,
195+
environment_id=settings.actual_prod_environment_id,
183196
)
184197

185198
semantic_layer_config = None
186199
if (
187-
not disable_semantic_layer
188-
and actual_host
189-
and actual_prod_environment_id
190-
and token
200+
not settings.disable_semantic_layer
201+
and settings.actual_host
202+
and settings.actual_prod_environment_id
203+
and settings.dbt_token
191204
):
192-
is_local = actual_host and actual_host.startswith("localhost")
205+
is_local = settings.actual_host and settings.actual_host.startswith("localhost")
193206
if is_local:
194-
host = actual_host
195-
elif multicell_account_prefix:
196-
host = f"{multicell_account_prefix}.semantic-layer.{actual_host}"
207+
host = settings.actual_host
208+
elif settings.multicell_account_prefix:
209+
host = f"{settings.multicell_account_prefix}.semantic-layer.{settings.actual_host}"
197210
else:
198-
host = f"semantic-layer.{actual_host}"
211+
host = f"semantic-layer.{settings.actual_host}"
199212
assert host is not None
200213

201214
semantic_layer_config = SemanticLayerConfig(
202215
url=f"http://{host}" if is_local else f"https://{host}" + "/api/graphql",
203216
host=host,
204-
prod_environment_id=actual_prod_environment_id,
205-
service_token=token,
217+
prod_environment_id=settings.actual_prod_environment_id,
218+
service_token=settings.dbt_token,
206219
headers={
207-
"Authorization": f"Bearer {token}",
220+
"Authorization": f"Bearer {settings.dbt_token}",
208221
"x-dbt-partner-source": "dbt-mcp",
209222
},
210223
)
211224

225+
# Load local user ID from dbt profile
212226
local_user_id = None
213227
try:
214228
home = os.environ.get("HOME")
@@ -221,16 +235,16 @@ def load_config() -> Config:
221235

222236
return Config(
223237
tracking_config=TrackingConfig(
224-
host=actual_host,
225-
multicell_account_prefix=multicell_account_prefix,
226-
prod_environment_id=actual_prod_environment_id,
227-
dev_environment_id=int(dev_environment_id) if dev_environment_id else None,
228-
dbt_cloud_user_id=int(user_id) if user_id else None,
238+
host=settings.actual_host,
239+
multicell_account_prefix=settings.multicell_account_prefix,
240+
prod_environment_id=settings.actual_prod_environment_id,
241+
dev_environment_id=settings.dbt_dev_env_id,
242+
dbt_cloud_user_id=settings.dbt_user_id,
229243
local_user_id=local_user_id,
230244
),
231245
remote_config=remote_config,
232246
dbt_cli_config=dbt_cli_config,
233247
discovery_config=discovery_config,
234248
semantic_layer_config=semantic_layer_config,
235-
disable_tools=disable_tools,
249+
disable_tools=settings.disable_tools,
236250
)

0 commit comments

Comments
 (0)