d365fo-client 0.2.4__py3-none-any.whl → 0.3.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. d365fo_client/__init__.py +7 -1
  2. d365fo_client/auth.py +9 -21
  3. d365fo_client/cli.py +25 -13
  4. d365fo_client/client.py +8 -4
  5. d365fo_client/config.py +52 -30
  6. d365fo_client/credential_sources.py +5 -0
  7. d365fo_client/main.py +1 -1
  8. d365fo_client/mcp/__init__.py +3 -1
  9. d365fo_client/mcp/auth_server/__init__.py +5 -0
  10. d365fo_client/mcp/auth_server/auth/__init__.py +30 -0
  11. d365fo_client/mcp/auth_server/auth/auth.py +372 -0
  12. d365fo_client/mcp/auth_server/auth/oauth_proxy.py +989 -0
  13. d365fo_client/mcp/auth_server/auth/providers/__init__.py +0 -0
  14. d365fo_client/mcp/auth_server/auth/providers/azure.py +325 -0
  15. d365fo_client/mcp/auth_server/auth/providers/bearer.py +25 -0
  16. d365fo_client/mcp/auth_server/auth/providers/jwt.py +547 -0
  17. d365fo_client/mcp/auth_server/auth/redirect_validation.py +65 -0
  18. d365fo_client/mcp/auth_server/dependencies.py +136 -0
  19. d365fo_client/mcp/client_manager.py +16 -67
  20. d365fo_client/mcp/fastmcp_main.py +358 -0
  21. d365fo_client/mcp/fastmcp_server.py +598 -0
  22. d365fo_client/mcp/fastmcp_utils.py +431 -0
  23. d365fo_client/mcp/main.py +40 -13
  24. d365fo_client/mcp/mixins/__init__.py +24 -0
  25. d365fo_client/mcp/mixins/base_tools_mixin.py +55 -0
  26. d365fo_client/mcp/mixins/connection_tools_mixin.py +50 -0
  27. d365fo_client/mcp/mixins/crud_tools_mixin.py +311 -0
  28. d365fo_client/mcp/mixins/database_tools_mixin.py +685 -0
  29. d365fo_client/mcp/mixins/label_tools_mixin.py +87 -0
  30. d365fo_client/mcp/mixins/metadata_tools_mixin.py +565 -0
  31. d365fo_client/mcp/mixins/performance_tools_mixin.py +109 -0
  32. d365fo_client/mcp/mixins/profile_tools_mixin.py +713 -0
  33. d365fo_client/mcp/mixins/sync_tools_mixin.py +321 -0
  34. d365fo_client/mcp/prompts/action_execution.py +1 -1
  35. d365fo_client/mcp/prompts/sequence_analysis.py +1 -1
  36. d365fo_client/mcp/tools/crud_tools.py +3 -3
  37. d365fo_client/mcp/tools/sync_tools.py +1 -1
  38. d365fo_client/mcp/utilities/__init__.py +1 -0
  39. d365fo_client/mcp/utilities/auth.py +34 -0
  40. d365fo_client/mcp/utilities/logging.py +58 -0
  41. d365fo_client/mcp/utilities/types.py +426 -0
  42. d365fo_client/metadata_v2/sync_manager_v2.py +2 -0
  43. d365fo_client/metadata_v2/sync_session_manager.py +7 -7
  44. d365fo_client/models.py +139 -139
  45. d365fo_client/output.py +2 -2
  46. d365fo_client/profile_manager.py +62 -27
  47. d365fo_client/profiles.py +118 -113
  48. d365fo_client/settings.py +355 -0
  49. d365fo_client/sync_models.py +85 -2
  50. d365fo_client/utils.py +2 -1
  51. {d365fo_client-0.2.4.dist-info → d365fo_client-0.3.0.dist-info}/METADATA +273 -18
  52. d365fo_client-0.3.0.dist-info/RECORD +84 -0
  53. d365fo_client-0.3.0.dist-info/entry_points.txt +4 -0
  54. d365fo_client-0.2.4.dist-info/RECORD +0 -56
  55. d365fo_client-0.2.4.dist-info/entry_points.txt +0 -3
  56. {d365fo_client-0.2.4.dist-info → d365fo_client-0.3.0.dist-info}/WHEEL +0 -0
  57. {d365fo_client-0.2.4.dist-info → d365fo_client-0.3.0.dist-info}/licenses/LICENSE +0 -0
  58. {d365fo_client-0.2.4.dist-info → d365fo_client-0.3.0.dist-info}/top_level.txt +0 -0
d365fo_client/profiles.py CHANGED
@@ -4,98 +4,91 @@ import logging
4
4
  from dataclasses import dataclass
5
5
  from typing import TYPE_CHECKING, Any, Dict, List, Optional
6
6
 
7
+ from .models import FOClientConfig
8
+
7
9
  if TYPE_CHECKING:
8
- from .models import FOClientConfig
9
10
  from .credential_sources import CredentialSource
10
11
 
11
12
  logger = logging.getLogger(__name__)
12
13
 
13
14
 
14
15
  @dataclass
15
- class Profile:
16
- """Unified profile for CLI and MCP operations."""
17
-
18
- # Core identification
19
- name: str
16
+ class Profile(FOClientConfig):
17
+ """Unified profile for CLI and MCP operations.
18
+
19
+ Inherits from FOClientConfig and adds profile-specific functionality
20
+ like name, description, and CLI output formatting.
21
+ """
22
+
23
+ # Profile-specific identification fields
24
+ name: str = ""
20
25
  description: Optional[str] = None
21
26
 
22
- # Connection settings
23
- base_url: str = ""
24
- auth_mode: str = "default"
25
- client_id: Optional[str] = None
26
- client_secret: Optional[str] = None
27
- tenant_id: Optional[str] = None
28
- use_default_credentials: Optional[bool] = None # None means derive from auth_mode
29
- verify_ssl: bool = True
30
- timeout: int = 60
31
- credential_source: Optional["CredentialSource"] = None
32
-
33
- # Cache settings
34
- use_label_cache: bool = True
35
- label_cache_expiry_minutes: int = 60
36
- use_cache_first: bool = True
37
- cache_dir: Optional[str] = None
38
-
39
- # Localization
40
- language: str = "en-US"
41
-
42
27
  # CLI-specific settings (with defaults for MCP)
43
28
  output_format: str = "table"
44
29
 
45
- def to_client_config(self) -> "FOClientConfig":
46
- """Convert profile to FOClientConfig."""
47
- from .models import FOClientConfig
48
-
49
- # Determine use_default_credentials: explicit setting takes precedence over auth_mode
50
- if self.use_default_credentials is not None:
51
- use_default_creds = self.use_default_credentials
52
- else:
53
- use_default_creds = self.auth_mode == "default"
54
-
55
- return FOClientConfig(
56
- base_url=self.base_url,
57
- auth_mode=self.auth_mode,
58
- client_id=self.client_id,
59
- client_secret=self.client_secret,
60
- tenant_id=self.tenant_id,
61
- use_default_credentials=use_default_creds,
62
- timeout=self.timeout,
63
- verify_ssl=self.verify_ssl,
64
- use_label_cache=self.use_label_cache,
65
- label_cache_expiry_minutes=self.label_cache_expiry_minutes,
66
- use_cache_first=self.use_cache_first,
67
- metadata_cache_dir=self.cache_dir,
68
- credential_source=self.credential_source,
69
- )
30
+ def __post_init__(self):
31
+ """Override parent post_init to handle profile-specific validation."""
32
+ # Call parent validation first
33
+ super().__post_init__()
34
+
35
+ # Additional profile-specific validation
36
+ if not self.name:
37
+ raise ValueError("Profile name is required")
38
+
39
+ def to_client_config(self) -> FOClientConfig:
40
+ """Convert profile to FOClientConfig.
41
+
42
+ Since Profile now inherits from FOClientConfig, we can return a copy
43
+ of self with only the FOClientConfig fields.
44
+ """
45
+ from dataclasses import fields
46
+
47
+ # Get all FOClientConfig field names
48
+ fo_client_fields = {f.name for f in fields(FOClientConfig)}
49
+
50
+ # Create a dict with only FOClientConfig fields from this instance
51
+ # Use getattr to preserve object types (especially credential_source)
52
+ client_data = {}
53
+ for field_name in fo_client_fields:
54
+ if hasattr(self, field_name):
55
+ client_data[field_name] = getattr(self, field_name)
56
+
57
+ return FOClientConfig(**client_data)
70
58
 
71
59
  def validate(self) -> List[str]:
72
- """Validate profile configuration."""
60
+ """Validate profile configuration.
61
+
62
+ Since Profile inherits from FOClientConfig, we leverage the parent's
63
+ validation and add profile-specific checks.
64
+ """
73
65
  errors = []
74
66
 
75
- if not self.base_url:
76
- errors.append("Base URL is required")
67
+ # Profile-specific validation
68
+ if not self.name:
69
+ errors.append("Profile name is required")
77
70
 
78
- if self.auth_mode == "client_credentials":
79
- if not self.client_id:
80
- errors.append("Client ID is required for client_credentials auth mode")
81
- if not self.client_secret:
82
- errors.append(
83
- "Client secret is required for client_credentials auth mode"
84
- )
85
- if not self.tenant_id:
86
- errors.append("Tenant ID is required for client_credentials auth mode")
87
-
88
- if self.timeout <= 0:
89
- errors.append("Timeout must be greater than 0")
71
+ # Leverage parent's validation by attempting to create the config
72
+ try:
73
+ self._validate_config()
74
+ except ValueError as e:
75
+ errors.append(str(e))
90
76
 
91
- if self.label_cache_expiry_minutes <= 0:
92
- errors.append("Label cache expiry must be greater than 0")
77
+ # Validate credential_source if provided
78
+ if self.credential_source is not None:
79
+ # Basic validation - credential source should have a valid source_type
80
+ if not hasattr(self.credential_source, 'source_type') or not self.credential_source.source_type:
81
+ errors.append("Credential source must have a valid source_type")
93
82
 
94
83
  return errors
95
84
 
96
85
  @classmethod
97
- def from_dict(cls, name: str, data: Dict[str, Any]) -> "Profile":
98
- """Create Profile from dictionary data with migration support."""
86
+ def create_from_dict(cls, name: str, data: Dict[str, Any]) -> "Profile":
87
+ """Create Profile from dictionary data with migration support.
88
+
89
+ This method maintains the same interface as the original from_dict but
90
+ works with the inheritance structure.
91
+ """
99
92
 
100
93
  # Handle parameter migration from legacy formats
101
94
  migrated_data = cls._migrate_legacy_parameters(data.copy())
@@ -103,50 +96,48 @@ class Profile:
103
96
  # Ensure name is set
104
97
  migrated_data["name"] = name
105
98
 
106
- # Add defaults for missing parameters
107
- defaults = {
99
+ # Add defaults for missing parameters, focusing on Profile-specific ones
100
+ profile_defaults = {
101
+ "name": name,
108
102
  "description": None,
109
- "base_url": "",
110
- "auth_mode": "default",
111
- "client_id": None,
112
- "client_secret": None,
113
- "tenant_id": None,
114
- "use_default_credentials": None,
115
- "verify_ssl": True,
116
- "timeout": 60,
117
- "use_label_cache": True,
118
- "label_cache_expiry_minutes": 60,
119
- "use_cache_first": True,
120
- "cache_dir": None,
121
- "language": "en-US",
122
103
  "output_format": "table",
123
- "credential_source": None,
124
104
  }
125
105
 
126
- for key, default_value in defaults.items():
106
+ for key, default_value in profile_defaults.items():
127
107
  if key not in migrated_data:
128
108
  migrated_data[key] = default_value
129
109
 
130
- # Filter out any unknown parameters
131
- valid_params = {
132
- k: v for k, v in migrated_data.items() if k in cls.__dataclass_fields__
133
- }
134
-
135
- # Handle credential_source deserialization
136
- if "credential_source" in valid_params and valid_params["credential_source"] is not None:
137
- from .credential_sources import CredentialSource
138
- credential_source_data = valid_params["credential_source"]
139
- try:
140
- valid_params["credential_source"] = CredentialSource.from_dict(credential_source_data)
141
- except Exception as e:
142
- logger.error(f"Error deserializing credential_source: {e}")
143
- valid_params["credential_source"] = None
144
-
110
+ # Use parent's from_dict for FOClientConfig fields, then add Profile fields
145
111
  try:
112
+ # Create FOClientConfig from the data first
113
+ fo_config = super().from_dict(migrated_data)
114
+
115
+ # Convert back to dict and add Profile-specific fields
116
+ fo_data = fo_config.to_dict()
117
+ fo_data.update({
118
+ "name": migrated_data["name"],
119
+ "description": migrated_data.get("description"),
120
+ "output_format": migrated_data.get("output_format", "table"),
121
+ })
122
+
123
+ # Filter out any unknown parameters
124
+ valid_params = {
125
+ k: v for k, v in fo_data.items() if k in cls.__dataclass_fields__
126
+ }
127
+
128
+ # Handle credential_source deserialization if it's still a dict
129
+ if "credential_source" in valid_params and isinstance(valid_params["credential_source"], dict):
130
+ from .credential_sources import CredentialSource
131
+ try:
132
+ valid_params["credential_source"] = CredentialSource.from_dict(valid_params["credential_source"])
133
+ except Exception as e:
134
+ logger.error(f"Error deserializing credential_source: {e}")
135
+ valid_params["credential_source"] = None
136
+
146
137
  return cls(**valid_params)
147
138
  except Exception as e:
148
139
  logger.error(f"Error creating profile {name}: {e}")
149
- logger.error(f"Data: {valid_params}")
140
+ logger.error(f"Data: {migrated_data}")
150
141
  raise
151
142
 
152
143
  @classmethod
@@ -157,6 +148,7 @@ class Profile:
157
148
  parameter_migrations = {
158
149
  "label_cache": "use_label_cache",
159
150
  "label_expiry": "label_cache_expiry_minutes",
151
+ "cache_dir": "metadata_cache_dir", # Profile's cache_dir maps to FOClientConfig's metadata_cache_dir
160
152
  }
161
153
 
162
154
  for old_name, new_name in parameter_migrations.items():
@@ -164,20 +156,31 @@ class Profile:
164
156
  data[new_name] = data.pop(old_name)
165
157
  logger.debug(f"Migrated parameter {old_name} -> {new_name}")
166
158
 
159
+ # Special handling for Profile's legacy credential migration
160
+ # If auth_mode is "client_credentials", treat it as explicit credentials
161
+ if data.get("auth_mode") == "client_credentials":
162
+ data["use_default_credentials"] = False
163
+ logger.debug("Setting use_default_credentials=False for client_credentials auth_mode")
164
+
165
+ # Migrate legacy credential fields to credential_source (use parent method)
166
+ data = FOClientConfig._migrate_legacy_credentials(data)
167
+
167
168
  return data
168
169
 
169
170
  def to_dict(self) -> Dict[str, Any]:
170
171
  """Convert profile to dictionary for storage."""
171
- from dataclasses import asdict
172
-
173
- # Convert to dict and remove name (stored as key)
174
- data = asdict(self)
172
+ # Use parent's to_dict method and remove profile-specific fields that shouldn't be in storage
173
+ data = super().to_dict()
174
+
175
+ # Add profile-specific fields
176
+ data.update({
177
+ "description": self.description,
178
+ "output_format": self.output_format,
179
+ })
180
+
181
+ # Remove name from storage (it's stored as the key)
175
182
  data.pop("name", None)
176
183
 
177
- # Handle credential_source serialization
178
- if self.credential_source is not None:
179
- data["credential_source"] = self.credential_source.to_dict()
180
-
181
184
  return data
182
185
 
183
186
  def clone(self, name: str, **overrides) -> "Profile":
@@ -195,11 +198,13 @@ class Profile:
195
198
 
196
199
  def __str__(self) -> str:
197
200
  """String representation of the profile."""
198
- return f"Profile(name='{self.name}', base_url='{self.base_url}', auth_mode='{self.auth_mode}')"
201
+ cred_info = "default_credentials" if self.credential_source is None else f"credential_source={self.credential_source.source_type}"
202
+ return f"Profile(name='{self.name}', base_url='{self.base_url}', auth={cred_info})"
199
203
 
200
204
  def __repr__(self) -> str:
201
205
  """Detailed string representation of the profile."""
206
+ cred_info = "default_credentials" if self.credential_source is None else f"credential_source={self.credential_source.source_type}"
202
207
  return (
203
208
  f"Profile(name='{self.name}', base_url='{self.base_url}', "
204
- f"auth_mode='{self.auth_mode}', description='{self.description}')"
209
+ f"auth={cred_info}, description='{self.description}')"
205
210
  )
@@ -0,0 +1,355 @@
1
+ """Pydantic settings model for environment variable management."""
2
+
3
+ import os
4
+ from enum import Enum
5
+ from pathlib import Path
6
+ from typing import Literal, Optional
7
+
8
+ from pydantic import Field, field_validator
9
+ from pydantic_settings import BaseSettings, SettingsConfigDict
10
+
11
+ from .utils import get_default_cache_directory
12
+
13
+
14
+ class LogLevel(str, Enum):
15
+ """Valid logging levels."""
16
+
17
+ DEBUG = "DEBUG"
18
+ INFO = "INFO"
19
+ WARNING = "WARNING"
20
+ ERROR = "ERROR"
21
+ CRITICAL = "CRITICAL"
22
+
23
+
24
+ class TransportProtocol(str, Enum):
25
+ """Valid MCP transport protocols."""
26
+
27
+ STDIO = "stdio"
28
+ SSE = "sse"
29
+ HTTP = "http"
30
+ STREAMABLE_HTTP = "streamable-http"
31
+
32
+
33
+ class D365FOSettings(BaseSettings):
34
+ """Pydantic settings model for D365FO environment variables.
35
+
36
+ This model provides type-safe access to all D365FO environment variables
37
+ with proper validation, defaults, and documentation.
38
+ """
39
+
40
+ model_config = SettingsConfigDict(
41
+ env_prefix="D365FO_",
42
+ env_file=".env",
43
+ env_file_encoding="utf-8",
44
+ case_sensitive=True,
45
+ extra="ignore",
46
+ )
47
+
48
+ # === Core D365FO Connection Settings ===
49
+
50
+ base_url: Optional[str] = Field(
51
+ default="https://usnconeboxax1aos.cloud.onebox.dynamics.com",
52
+ description="D365FO environment URL",
53
+ alias="D365FO_BASE_URL"
54
+ )
55
+
56
+ # === Azure AD Authentication Settings ===
57
+
58
+ client_id: Optional[str] = Field(
59
+ default=None,
60
+ description="Azure AD client ID (optional, used with client credentials flow)",
61
+ alias="D365FO_CLIENT_ID"
62
+ )
63
+
64
+ client_secret: Optional[str] = Field(
65
+ default=None,
66
+ description="Azure AD client secret (optional, used with client credentials flow)",
67
+ alias="D365FO_CLIENT_SECRET"
68
+ )
69
+
70
+ tenant_id: Optional[str] = Field(
71
+ default=None,
72
+ description="Azure AD tenant ID (optional, used with client credentials flow)",
73
+ alias="D365FO_TENANT_ID"
74
+ )
75
+
76
+ # === MCP Authentication Settings ===
77
+
78
+ mcp_auth_client_id: Optional[str] = Field(
79
+ default=None,
80
+ description="MCP authentication client ID",
81
+ alias="D365FO_MCP_AUTH_CLIENT_ID"
82
+ )
83
+
84
+ mcp_auth_client_secret: Optional[str] = Field(
85
+ default=None,
86
+ description="MCP authentication client secret",
87
+ alias="D365FO_MCP_AUTH_CLIENT_SECRET"
88
+ )
89
+
90
+ mcp_auth_tenant_id: Optional[str] = Field(
91
+ default=None,
92
+ description="MCP authentication tenant ID",
93
+ alias="D365FO_MCP_AUTH_TENANT_ID"
94
+ )
95
+
96
+ mcp_auth_base_url: str = Field(
97
+ default="http://localhost:8000",
98
+ description="MCP authentication base URL",
99
+ alias="D365FO_MCP_AUTH_BASE_URL"
100
+ )
101
+
102
+ mcp_auth_required_scopes: str = Field(
103
+ default="User.Read,email,openid,profile",
104
+ description="MCP authentication required scopes (comma-separated)",
105
+ alias="D365FO_MCP_AUTH_REQUIRED_SCOPES"
106
+ )
107
+
108
+ # === MCP Server Transport Settings ===
109
+
110
+ mcp_transport: TransportProtocol = Field(
111
+ default=TransportProtocol.STDIO,
112
+ description="Default transport protocol (stdio, sse, http, streamable-http)",
113
+ alias="D365FO_MCP_TRANSPORT"
114
+ )
115
+
116
+ http_host: str = Field(
117
+ default="127.0.0.1",
118
+ description="Default HTTP host",
119
+ alias="D365FO_MCP_HTTP_HOST"
120
+ )
121
+
122
+ http_port: int = Field(
123
+ default=8000,
124
+ gt=0,
125
+ le=65535,
126
+ description="Default HTTP port",
127
+ alias="D365FO_MCP_HTTP_PORT"
128
+ )
129
+
130
+ http_stateless: bool = Field(
131
+ default=False,
132
+ description="Enable stateless mode (true/false)",
133
+ alias="D365FO_MCP_HTTP_STATELESS"
134
+ )
135
+
136
+ http_json: bool = Field(
137
+ default=False,
138
+ description="Enable JSON response mode (true/false)",
139
+ alias="D365FO_MCP_HTTP_JSON"
140
+ )
141
+
142
+ # === Connection and Performance Settings ===
143
+
144
+ max_concurrent_requests: int = Field(
145
+ default=10,
146
+ gt=0,
147
+ description="Max concurrent requests",
148
+ alias="D365FO_MCP_MAX_CONCURRENT_REQUESTS"
149
+ )
150
+
151
+ request_timeout: int = Field(
152
+ default=30,
153
+ gt=0,
154
+ description="Request timeout in seconds",
155
+ alias="D365FO_MCP_REQUEST_TIMEOUT"
156
+ )
157
+
158
+ timeout: int = Field(
159
+ default=60,
160
+ gt=0,
161
+ description="General timeout in seconds",
162
+ alias="D365FO_TIMEOUT"
163
+ )
164
+
165
+ verify_ssl: bool = Field(
166
+ default=True,
167
+ description="Verify SSL certificates",
168
+ alias="D365FO_VERIFY_SSL"
169
+ )
170
+
171
+ # === Logging Settings ===
172
+
173
+ log_level: LogLevel = Field(
174
+ default=LogLevel.INFO,
175
+ description="Logging level (DEBUG, INFO, WARNING, ERROR, CRITICAL)",
176
+ alias="D365FO_LOG_LEVEL"
177
+ )
178
+
179
+ log_file: Optional[str] = Field(
180
+ default=None,
181
+ description="Custom log file path (default: ~/.d365fo-mcp/logs/fastmcp-server.log)",
182
+ alias="D365FO_LOG_FILE"
183
+ )
184
+
185
+ # === Cache and Metadata Settings ===
186
+
187
+ cache_dir: Optional[str] = Field(
188
+ default=None,
189
+ description="General cache directory",
190
+ alias="D365FO_CACHE_DIR"
191
+ )
192
+
193
+ meta_cache_dir: Optional[str] = Field(
194
+ default=None,
195
+ description="Metadata cache directory (default: ~/.d365fo-mcp/cache)",
196
+ alias="D365FO_META_CACHE_DIR"
197
+ )
198
+
199
+ use_label_cache: bool = Field(
200
+ default=True,
201
+ description="Enable label caching",
202
+ alias="D365FO_LABEL_CACHE"
203
+ )
204
+
205
+ label_cache_expiry_minutes: int = Field(
206
+ default=1440, # 24 hours
207
+ gt=0,
208
+ description="Label cache expiry in minutes",
209
+ alias="D365FO_LABEL_EXPIRY"
210
+ )
211
+
212
+ use_cache_first: bool = Field(
213
+ default=True,
214
+ description="Use cache first before making API calls",
215
+ alias="D365FO_USE_CACHE_FIRST"
216
+ )
217
+
218
+ # === Debug and Development Settings ===
219
+
220
+ debug: bool = Field(
221
+ default=False,
222
+ description="Enable debug mode",
223
+ alias="DEBUG"
224
+ )
225
+
226
+ @field_validator("log_file", mode="before")
227
+ @classmethod
228
+ def validate_log_file(cls, v):
229
+ """Validate and set default log file path."""
230
+ if v is None:
231
+ # Return default log file path
232
+ log_dir = Path.home() / ".d365fo-mcp" / "logs"
233
+ return str(log_dir / "fastmcp-server.log")
234
+ return v
235
+
236
+ @field_validator("cache_dir", mode="before")
237
+ @classmethod
238
+ def validate_cache_dir(cls, v):
239
+ """Validate and set default cache directory."""
240
+ if v is None:
241
+ return get_default_cache_directory()
242
+ return v
243
+
244
+ @field_validator("meta_cache_dir", mode="before")
245
+ @classmethod
246
+ def validate_meta_cache_dir(cls, v):
247
+ """Validate and set default metadata cache directory."""
248
+ if v is None:
249
+ cache_dir = Path.home() / ".d365fo-mcp" / "cache"
250
+ return str(cache_dir)
251
+ return v
252
+
253
+ @field_validator("http_stateless", "http_json", "use_label_cache", "use_cache_first", "verify_ssl", "debug", mode="before")
254
+ @classmethod
255
+ def validate_boolean_env_vars(cls, v):
256
+ """Convert string environment variables to booleans."""
257
+ if isinstance(v, str):
258
+ return v.lower() in ("true", "1", "yes", "on")
259
+ return v
260
+
261
+ def get_default_log_file(self) -> str:
262
+ """Get the default log file path."""
263
+ log_dir = Path.home() / ".d365fo-mcp" / "logs"
264
+ log_dir.mkdir(parents=True, exist_ok=True)
265
+ return str(log_dir / "fastmcp-server.log")
266
+
267
+ def ensure_directories(self) -> None:
268
+ """Ensure all required directories exist."""
269
+ # Ensure log file directory exists
270
+ if self.log_file:
271
+ log_dir = Path(self.log_file).parent
272
+ log_dir.mkdir(parents=True, exist_ok=True)
273
+
274
+ # Ensure cache directories exist
275
+ if self.cache_dir:
276
+ Path(self.cache_dir).mkdir(parents=True, exist_ok=True)
277
+
278
+ if self.meta_cache_dir:
279
+ Path(self.meta_cache_dir).mkdir(parents=True, exist_ok=True)
280
+
281
+ def has_client_credentials(self) -> bool:
282
+ """Check if client credentials are configured."""
283
+ return all([self.client_id, self.client_secret, self.tenant_id])
284
+
285
+ def has_mcp_auth_credentials(self) -> bool:
286
+ """Check if MCP authentication credentials are configured."""
287
+ return all([self.mcp_auth_client_id, self.mcp_auth_client_secret, self.mcp_auth_tenant_id])
288
+
289
+ def get_startup_mode(self) -> Literal["profile_only", "default_auth", "client_credentials"]:
290
+ """Determine startup mode based on configuration."""
291
+ if self.base_url and self.base_url != "https://usnconeboxax1aos.cloud.onebox.dynamics.com":
292
+ if self.has_client_credentials():
293
+ return "client_credentials"
294
+ else:
295
+ return "default_auth"
296
+ return "profile_only"
297
+
298
+ def mcp_auth_required_scopes_list(self) -> list[str]:
299
+ """Get MCP authentication required scopes as a list."""
300
+ return [scope.strip() for scope in self.mcp_auth_required_scopes.split(",") if scope.strip()]
301
+
302
+ def to_dict(self) -> dict:
303
+ """Convert settings to dictionary."""
304
+ return self.dict()
305
+
306
+ def to_env_dict(self) -> dict:
307
+ """Convert settings to environment variable dictionary."""
308
+ env_dict = {}
309
+ for field_name, field_info in self.model_fields.items():
310
+ value = getattr(self, field_name)
311
+ if value is not None:
312
+ # Use the alias if available, otherwise construct the env var name
313
+ if field_info.alias:
314
+ env_var_name = field_info.alias
315
+ else:
316
+ env_var_name = f"D365FO_{field_name.upper()}"
317
+
318
+ # Convert boolean values to string
319
+ if isinstance(value, bool):
320
+ env_dict[env_var_name] = "true" if value else "false"
321
+ else:
322
+ env_dict[env_var_name] = str(value)
323
+
324
+ return env_dict
325
+
326
+
327
+ # Global settings instance
328
+ _settings: Optional[D365FOSettings] = None
329
+
330
+
331
+ def get_settings(reload: bool = False) -> D365FOSettings:
332
+ """Get the global settings instance.
333
+
334
+ Args:
335
+ reload: If True, force reload settings from environment
336
+
337
+ Returns:
338
+ D365FOSettings instance
339
+ """
340
+ global _settings
341
+
342
+ if _settings is None or reload:
343
+ _settings = D365FOSettings()
344
+ _settings.ensure_directories()
345
+
346
+ return _settings
347
+
348
+
349
+ def reset_settings() -> None:
350
+ """Reset the global settings instance.
351
+
352
+ Useful for testing when you want to reload settings.
353
+ """
354
+ global _settings
355
+ _settings = None