11import os
2- from dataclasses import dataclass
32from pathlib import Path
43
54import 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
62103def 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