d365fo-client 0.2.1__py3-none-any.whl → 0.2.3__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.
- d365fo_client/__init__.py +4 -48
- d365fo_client/auth.py +48 -9
- d365fo_client/client.py +84 -44
- d365fo_client/credential_sources.py +431 -0
- d365fo_client/mcp/client_manager.py +8 -0
- d365fo_client/mcp/main.py +39 -17
- d365fo_client/mcp/models.py +2 -2
- d365fo_client/mcp/server.py +69 -22
- d365fo_client/mcp/tools/__init__.py +2 -0
- d365fo_client/mcp/tools/connection_tools.py +7 -0
- d365fo_client/mcp/tools/profile_tools.py +261 -2
- d365fo_client/mcp/tools/sync_tools.py +503 -0
- d365fo_client/metadata_api.py +68 -1
- d365fo_client/metadata_v2/cache_v2.py +26 -19
- d365fo_client/metadata_v2/database_v2.py +93 -0
- d365fo_client/metadata_v2/global_version_manager.py +62 -4
- d365fo_client/metadata_v2/sync_manager_v2.py +1 -1
- d365fo_client/metadata_v2/sync_session_manager.py +1043 -0
- d365fo_client/models.py +41 -13
- d365fo_client/profile_manager.py +7 -1
- d365fo_client/profiles.py +28 -1
- d365fo_client/sync_models.py +181 -0
- {d365fo_client-0.2.1.dist-info → d365fo_client-0.2.3.dist-info}/METADATA +48 -17
- {d365fo_client-0.2.1.dist-info → d365fo_client-0.2.3.dist-info}/RECORD +28 -24
- {d365fo_client-0.2.1.dist-info → d365fo_client-0.2.3.dist-info}/WHEEL +0 -0
- {d365fo_client-0.2.1.dist-info → d365fo_client-0.2.3.dist-info}/entry_points.txt +0 -0
- {d365fo_client-0.2.1.dist-info → d365fo_client-0.2.3.dist-info}/licenses/LICENSE +0 -0
- {d365fo_client-0.2.1.dist-info → d365fo_client-0.2.3.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,431 @@
|
|
1
|
+
"""Credential source management for D365 F&O client authentication."""
|
2
|
+
|
3
|
+
import logging
|
4
|
+
import os
|
5
|
+
from abc import ABC, abstractmethod
|
6
|
+
from dataclasses import dataclass
|
7
|
+
from datetime import datetime, timedelta
|
8
|
+
from typing import Any, Dict, Optional, Tuple, Union
|
9
|
+
|
10
|
+
from azure.identity import ClientSecretCredential, DefaultAzureCredential
|
11
|
+
from azure.keyvault.secrets import SecretClient
|
12
|
+
|
13
|
+
logger = logging.getLogger(__name__)
|
14
|
+
|
15
|
+
|
16
|
+
@dataclass
|
17
|
+
class CredentialSource:
|
18
|
+
"""Base credential source configuration."""
|
19
|
+
|
20
|
+
source_type: str # "environment", "keyvault", "file", etc.
|
21
|
+
|
22
|
+
def to_dict(self) -> Dict[str, str]:
|
23
|
+
"""Convert to dictionary for serialization."""
|
24
|
+
return {"source_type": self.source_type}
|
25
|
+
|
26
|
+
@classmethod
|
27
|
+
def from_dict(cls, data: Dict[str, Any]) -> "CredentialSource":
|
28
|
+
"""Create CredentialSource from dictionary."""
|
29
|
+
source_type = data.get("source_type")
|
30
|
+
|
31
|
+
if source_type == "environment":
|
32
|
+
return EnvironmentCredentialSource(
|
33
|
+
client_id_var=data.get("client_id_var", "D365FO_CLIENT_ID"),
|
34
|
+
client_secret_var=data.get("client_secret_var", "D365FO_CLIENT_SECRET"),
|
35
|
+
tenant_id_var=data.get("tenant_id_var", "D365FO_TENANT_ID")
|
36
|
+
)
|
37
|
+
elif source_type == "keyvault":
|
38
|
+
return KeyVaultCredentialSource(
|
39
|
+
vault_url=data.get("vault_url", ""),
|
40
|
+
client_id_secret_name=data.get("client_id_secret_name", "D365FO_CLIENT_ID"),
|
41
|
+
client_secret_secret_name=data.get("client_secret_secret_name", "D365FO_CLIENT_SECRET"),
|
42
|
+
tenant_id_secret_name=data.get("tenant_id_secret_name", "D365FO_TENANT_ID"),
|
43
|
+
keyvault_auth_mode=data.get("keyvault_auth_mode", "default"),
|
44
|
+
keyvault_client_id=data.get("keyvault_client_id"),
|
45
|
+
keyvault_client_secret=data.get("keyvault_client_secret"),
|
46
|
+
keyvault_tenant_id=data.get("keyvault_tenant_id")
|
47
|
+
)
|
48
|
+
else:
|
49
|
+
raise ValueError(f"Unknown credential source type: {source_type}")
|
50
|
+
|
51
|
+
|
52
|
+
@dataclass
|
53
|
+
class EnvironmentCredentialSource(CredentialSource):
|
54
|
+
"""Environment variable credential source."""
|
55
|
+
|
56
|
+
client_id_var: str = "D365FO_CLIENT_ID"
|
57
|
+
client_secret_var: str = "D365FO_CLIENT_SECRET"
|
58
|
+
tenant_id_var: str = "D365FO_TENANT_ID"
|
59
|
+
|
60
|
+
def __init__(self, client_id_var: str = "D365FO_CLIENT_ID",
|
61
|
+
client_secret_var: str = "D365FO_CLIENT_SECRET",
|
62
|
+
tenant_id_var: str = "D365FO_TENANT_ID"):
|
63
|
+
"""Initialize environment credential source."""
|
64
|
+
super().__init__(source_type="environment")
|
65
|
+
self.client_id_var = client_id_var
|
66
|
+
self.client_secret_var = client_secret_var
|
67
|
+
self.tenant_id_var = tenant_id_var
|
68
|
+
|
69
|
+
def to_dict(self) -> Dict[str, str]:
|
70
|
+
"""Convert to dictionary for serialization."""
|
71
|
+
return {
|
72
|
+
"source_type": self.source_type,
|
73
|
+
"client_id_var": self.client_id_var,
|
74
|
+
"client_secret_var": self.client_secret_var,
|
75
|
+
"tenant_id_var": self.tenant_id_var,
|
76
|
+
}
|
77
|
+
|
78
|
+
|
79
|
+
@dataclass
|
80
|
+
class KeyVaultCredentialSource(CredentialSource):
|
81
|
+
"""Azure Key Vault credential source."""
|
82
|
+
|
83
|
+
vault_url: str
|
84
|
+
client_id_secret_name: str
|
85
|
+
client_secret_secret_name: str
|
86
|
+
tenant_id_secret_name: str
|
87
|
+
# Authentication to Key Vault itself
|
88
|
+
keyvault_auth_mode: str = "default" # "default" or "client_secret"
|
89
|
+
keyvault_client_id: Optional[str] = None
|
90
|
+
keyvault_client_secret: Optional[str] = None
|
91
|
+
keyvault_tenant_id: Optional[str] = None
|
92
|
+
|
93
|
+
def __init__(self, vault_url: str, client_id_secret_name: str,
|
94
|
+
client_secret_secret_name: str, tenant_id_secret_name: str,
|
95
|
+
keyvault_auth_mode: str = "default",
|
96
|
+
keyvault_client_id: Optional[str] = None,
|
97
|
+
keyvault_client_secret: Optional[str] = None,
|
98
|
+
keyvault_tenant_id: Optional[str] = None):
|
99
|
+
"""Initialize Key Vault credential source."""
|
100
|
+
super().__init__(source_type="keyvault")
|
101
|
+
self.vault_url = vault_url
|
102
|
+
self.client_id_secret_name = client_id_secret_name
|
103
|
+
self.client_secret_secret_name = client_secret_secret_name
|
104
|
+
self.tenant_id_secret_name = tenant_id_secret_name
|
105
|
+
self.keyvault_auth_mode = keyvault_auth_mode
|
106
|
+
self.keyvault_client_id = keyvault_client_id
|
107
|
+
self.keyvault_client_secret = keyvault_client_secret
|
108
|
+
self.keyvault_tenant_id = keyvault_tenant_id
|
109
|
+
|
110
|
+
def to_dict(self) -> Dict[str, str]:
|
111
|
+
"""Convert to dictionary for serialization."""
|
112
|
+
result = {
|
113
|
+
"source_type": self.source_type,
|
114
|
+
"vault_url": self.vault_url,
|
115
|
+
"client_id_secret_name": self.client_id_secret_name,
|
116
|
+
"client_secret_secret_name": self.client_secret_secret_name,
|
117
|
+
"tenant_id_secret_name": self.tenant_id_secret_name,
|
118
|
+
"keyvault_auth_mode": self.keyvault_auth_mode,
|
119
|
+
}
|
120
|
+
|
121
|
+
# Only include Key Vault auth credentials if using client_secret mode
|
122
|
+
if self.keyvault_auth_mode == "client_secret":
|
123
|
+
result.update({
|
124
|
+
"keyvault_client_id": self.keyvault_client_id or "",
|
125
|
+
"keyvault_client_secret": self.keyvault_client_secret or "",
|
126
|
+
"keyvault_tenant_id": self.keyvault_tenant_id or "",
|
127
|
+
})
|
128
|
+
|
129
|
+
return result
|
130
|
+
|
131
|
+
|
132
|
+
@dataclass
|
133
|
+
class CachedCredentials:
|
134
|
+
"""Cached credential information with expiry."""
|
135
|
+
|
136
|
+
client_id: str
|
137
|
+
client_secret: str
|
138
|
+
tenant_id: str
|
139
|
+
expires_at: datetime
|
140
|
+
source_hash: str # Hash of source configuration for invalidation
|
141
|
+
|
142
|
+
def is_expired(self) -> bool:
|
143
|
+
"""Check if credentials are expired."""
|
144
|
+
return datetime.now() >= self.expires_at
|
145
|
+
|
146
|
+
def is_valid_for_source(self, source_hash: str) -> bool:
|
147
|
+
"""Check if cached credentials are valid for the given source."""
|
148
|
+
return not self.is_expired() and self.source_hash == source_hash
|
149
|
+
|
150
|
+
|
151
|
+
class CredentialProvider(ABC):
|
152
|
+
"""Base class for credential providers."""
|
153
|
+
|
154
|
+
@abstractmethod
|
155
|
+
async def get_credentials(self, source: CredentialSource) -> Tuple[str, str, str]:
|
156
|
+
"""
|
157
|
+
Retrieve credentials from the source.
|
158
|
+
|
159
|
+
Args:
|
160
|
+
source: Credential source configuration
|
161
|
+
|
162
|
+
Returns:
|
163
|
+
Tuple of (client_id, client_secret, tenant_id)
|
164
|
+
|
165
|
+
Raises:
|
166
|
+
ValueError: If credentials cannot be retrieved or are invalid
|
167
|
+
Exception: For other retrieval errors
|
168
|
+
"""
|
169
|
+
pass
|
170
|
+
|
171
|
+
def _get_source_hash(self, source: CredentialSource) -> str:
|
172
|
+
"""Generate a hash for the credential source configuration."""
|
173
|
+
import hashlib
|
174
|
+
source_data = str(source.to_dict())
|
175
|
+
return hashlib.sha256(source_data.encode()).hexdigest()[:16]
|
176
|
+
|
177
|
+
|
178
|
+
class EnvironmentCredentialProvider(CredentialProvider):
|
179
|
+
"""Provides credentials from environment variables."""
|
180
|
+
|
181
|
+
async def get_credentials(self, source: CredentialSource) -> Tuple[str, str, str]:
|
182
|
+
"""
|
183
|
+
Retrieve credentials from environment variables.
|
184
|
+
|
185
|
+
Args:
|
186
|
+
source: EnvironmentCredentialSource configuration
|
187
|
+
|
188
|
+
Returns:
|
189
|
+
Tuple of (client_id, client_secret, tenant_id)
|
190
|
+
|
191
|
+
Raises:
|
192
|
+
ValueError: If required environment variables are missing
|
193
|
+
"""
|
194
|
+
if not isinstance(source, EnvironmentCredentialSource):
|
195
|
+
raise ValueError(f"Expected EnvironmentCredentialSource, got {type(source)}")
|
196
|
+
|
197
|
+
client_id = os.getenv(source.client_id_var)
|
198
|
+
client_secret = os.getenv(source.client_secret_var)
|
199
|
+
tenant_id = os.getenv(source.tenant_id_var)
|
200
|
+
|
201
|
+
missing_vars = []
|
202
|
+
if not client_id:
|
203
|
+
missing_vars.append(source.client_id_var)
|
204
|
+
if not client_secret:
|
205
|
+
missing_vars.append(source.client_secret_var)
|
206
|
+
if not tenant_id:
|
207
|
+
missing_vars.append(source.tenant_id_var)
|
208
|
+
|
209
|
+
if missing_vars:
|
210
|
+
raise ValueError(f"Missing required environment variables: {', '.join(missing_vars)}")
|
211
|
+
|
212
|
+
logger.debug(f"Retrieved credentials from environment variables: {source.client_id_var}, {source.client_secret_var}, {source.tenant_id_var}")
|
213
|
+
return client_id, client_secret, tenant_id
|
214
|
+
|
215
|
+
|
216
|
+
class KeyVaultCredentialProvider(CredentialProvider):
|
217
|
+
"""Provides credentials from Azure Key Vault."""
|
218
|
+
|
219
|
+
def __init__(self):
|
220
|
+
"""Initialize Key Vault credential provider."""
|
221
|
+
self._secret_clients: Dict[str, SecretClient] = {}
|
222
|
+
|
223
|
+
async def get_credentials(self, source: CredentialSource) -> Tuple[str, str, str]:
|
224
|
+
"""
|
225
|
+
Retrieve credentials from Azure Key Vault.
|
226
|
+
|
227
|
+
Args:
|
228
|
+
source: KeyVaultCredentialSource configuration
|
229
|
+
|
230
|
+
Returns:
|
231
|
+
Tuple of (client_id, client_secret, tenant_id)
|
232
|
+
|
233
|
+
Raises:
|
234
|
+
ValueError: If Key Vault access fails or secrets are missing
|
235
|
+
"""
|
236
|
+
if not isinstance(source, KeyVaultCredentialSource):
|
237
|
+
raise ValueError(f"Expected KeyVaultCredentialSource, got {type(source)}")
|
238
|
+
|
239
|
+
try:
|
240
|
+
secret_client = self._get_secret_client(source)
|
241
|
+
|
242
|
+
# Retrieve secrets from Key Vault
|
243
|
+
client_id_secret = secret_client.get_secret(source.client_id_secret_name)
|
244
|
+
client_secret_secret = secret_client.get_secret(source.client_secret_secret_name)
|
245
|
+
tenant_id_secret = secret_client.get_secret(source.tenant_id_secret_name)
|
246
|
+
|
247
|
+
client_id = client_id_secret.value
|
248
|
+
client_secret = client_secret_secret.value
|
249
|
+
tenant_id = tenant_id_secret.value
|
250
|
+
|
251
|
+
if not all([client_id, client_secret, tenant_id]):
|
252
|
+
raise ValueError("One or more secrets retrieved from Key Vault are empty")
|
253
|
+
|
254
|
+
logger.debug(f"Retrieved credentials from Key Vault: {source.vault_url}")
|
255
|
+
return client_id, client_secret, tenant_id
|
256
|
+
|
257
|
+
except Exception as e:
|
258
|
+
logger.error(f"Failed to retrieve credentials from Key Vault {source.vault_url}: {e}")
|
259
|
+
raise ValueError(f"Key Vault credential retrieval failed: {e}")
|
260
|
+
|
261
|
+
def _get_secret_client(self, source: KeyVaultCredentialSource) -> SecretClient:
|
262
|
+
"""
|
263
|
+
Get or create a SecretClient for the Key Vault.
|
264
|
+
|
265
|
+
Args:
|
266
|
+
source: KeyVaultCredentialSource configuration
|
267
|
+
|
268
|
+
Returns:
|
269
|
+
SecretClient instance
|
270
|
+
"""
|
271
|
+
vault_url = source.vault_url
|
272
|
+
|
273
|
+
# Return cached client if available
|
274
|
+
if vault_url in self._secret_clients:
|
275
|
+
return self._secret_clients[vault_url]
|
276
|
+
|
277
|
+
# Create credential for Key Vault authentication
|
278
|
+
if source.keyvault_auth_mode == "default":
|
279
|
+
credential = DefaultAzureCredential()
|
280
|
+
logger.debug(f"Using default credentials for Key Vault authentication: {vault_url}")
|
281
|
+
elif source.keyvault_auth_mode == "client_secret":
|
282
|
+
if not all([source.keyvault_client_id, source.keyvault_client_secret, source.keyvault_tenant_id]):
|
283
|
+
raise ValueError("Key Vault client_secret authentication requires keyvault_client_id, keyvault_client_secret, and keyvault_tenant_id")
|
284
|
+
|
285
|
+
credential = ClientSecretCredential(
|
286
|
+
tenant_id=source.keyvault_tenant_id,
|
287
|
+
client_id=source.keyvault_client_id,
|
288
|
+
client_secret=source.keyvault_client_secret,
|
289
|
+
)
|
290
|
+
logger.debug(f"Using client secret credentials for Key Vault authentication: {vault_url}")
|
291
|
+
else:
|
292
|
+
raise ValueError(f"Invalid keyvault_auth_mode: {source.keyvault_auth_mode}. Must be 'default' or 'client_secret'")
|
293
|
+
|
294
|
+
# Create and cache the SecretClient
|
295
|
+
secret_client = SecretClient(vault_url=vault_url, credential=credential)
|
296
|
+
self._secret_clients[vault_url] = secret_client
|
297
|
+
|
298
|
+
return secret_client
|
299
|
+
|
300
|
+
|
301
|
+
class CredentialManager:
|
302
|
+
"""
|
303
|
+
Manages credential retrieval from multiple sources with caching.
|
304
|
+
|
305
|
+
This class provides a unified interface for retrieving credentials from
|
306
|
+
various sources (environment variables, Key Vault, etc.) with automatic
|
307
|
+
caching and validation.
|
308
|
+
"""
|
309
|
+
|
310
|
+
def __init__(self, cache_ttl_minutes: int = 30):
|
311
|
+
"""
|
312
|
+
Initialize credential manager.
|
313
|
+
|
314
|
+
Args:
|
315
|
+
cache_ttl_minutes: Time-to-live for cached credentials in minutes
|
316
|
+
"""
|
317
|
+
self.cache_ttl_minutes = cache_ttl_minutes
|
318
|
+
self._credential_cache: Dict[str, CachedCredentials] = {}
|
319
|
+
self._providers: Dict[str, CredentialProvider] = {
|
320
|
+
"environment": EnvironmentCredentialProvider(),
|
321
|
+
"keyvault": KeyVaultCredentialProvider(),
|
322
|
+
}
|
323
|
+
|
324
|
+
async def get_credentials(self, source: CredentialSource) -> Tuple[str, str, str]:
|
325
|
+
"""
|
326
|
+
Retrieve credentials from the specified source with caching.
|
327
|
+
|
328
|
+
Args:
|
329
|
+
source: Credential source configuration
|
330
|
+
|
331
|
+
Returns:
|
332
|
+
Tuple of (client_id, client_secret, tenant_id)
|
333
|
+
|
334
|
+
Raises:
|
335
|
+
ValueError: If source type is unsupported or credentials cannot be retrieved
|
336
|
+
"""
|
337
|
+
# Check cache first
|
338
|
+
source_hash = self._get_source_hash(source)
|
339
|
+
cache_key = f"{source.source_type}:{source_hash}"
|
340
|
+
|
341
|
+
if cache_key in self._credential_cache:
|
342
|
+
cached = self._credential_cache[cache_key]
|
343
|
+
if cached.is_valid_for_source(source_hash):
|
344
|
+
logger.debug(f"Using cached credentials for source type: {source.source_type}")
|
345
|
+
return cached.client_id, cached.client_secret, cached.tenant_id
|
346
|
+
else:
|
347
|
+
# Remove expired cache entry
|
348
|
+
del self._credential_cache[cache_key]
|
349
|
+
logger.debug(f"Expired cached credentials removed for source type: {source.source_type}")
|
350
|
+
|
351
|
+
# Retrieve from provider
|
352
|
+
provider = self._providers.get(source.source_type)
|
353
|
+
if not provider:
|
354
|
+
raise ValueError(f"Unsupported credential source type: {source.source_type}")
|
355
|
+
|
356
|
+
client_id, client_secret, tenant_id = await provider.get_credentials(source)
|
357
|
+
|
358
|
+
# Cache the credentials
|
359
|
+
expires_at = datetime.now() + timedelta(minutes=self.cache_ttl_minutes)
|
360
|
+
cached_credentials = CachedCredentials(
|
361
|
+
client_id=client_id,
|
362
|
+
client_secret=client_secret,
|
363
|
+
tenant_id=tenant_id,
|
364
|
+
expires_at=expires_at,
|
365
|
+
source_hash=source_hash,
|
366
|
+
)
|
367
|
+
self._credential_cache[cache_key] = cached_credentials
|
368
|
+
|
369
|
+
logger.debug(f"Retrieved and cached credentials from source type: {source.source_type}")
|
370
|
+
return client_id, client_secret, tenant_id
|
371
|
+
|
372
|
+
def _get_source_hash(self, source: CredentialSource) -> str:
|
373
|
+
"""Generate a hash for the credential source configuration."""
|
374
|
+
import hashlib
|
375
|
+
source_data = str(source.to_dict())
|
376
|
+
return hashlib.sha256(source_data.encode()).hexdigest()[:16]
|
377
|
+
|
378
|
+
def clear_cache(self, source_type: Optional[str] = None) -> None:
|
379
|
+
"""
|
380
|
+
Clear credential cache.
|
381
|
+
|
382
|
+
Args:
|
383
|
+
source_type: If specified, only clear cache for this source type.
|
384
|
+
If None, clear all cached credentials.
|
385
|
+
"""
|
386
|
+
if source_type is None:
|
387
|
+
self._credential_cache.clear()
|
388
|
+
logger.debug("Cleared all cached credentials")
|
389
|
+
else:
|
390
|
+
keys_to_remove = [key for key in self._credential_cache.keys() if key.startswith(f"{source_type}:")]
|
391
|
+
for key in keys_to_remove:
|
392
|
+
del self._credential_cache[key]
|
393
|
+
logger.debug(f"Cleared cached credentials for source type: {source_type}")
|
394
|
+
|
395
|
+
def get_cache_stats(self) -> Dict[str, int]:
|
396
|
+
"""
|
397
|
+
Get cache statistics.
|
398
|
+
|
399
|
+
Returns:
|
400
|
+
Dictionary with cache statistics
|
401
|
+
"""
|
402
|
+
total_cached = len(self._credential_cache)
|
403
|
+
expired_count = sum(1 for cached in self._credential_cache.values() if cached.is_expired())
|
404
|
+
|
405
|
+
return {
|
406
|
+
"total_cached": total_cached,
|
407
|
+
"expired": expired_count,
|
408
|
+
"active": total_cached - expired_count,
|
409
|
+
}
|
410
|
+
|
411
|
+
|
412
|
+
def create_credential_source(source_type: str, **kwargs) -> CredentialSource:
|
413
|
+
"""
|
414
|
+
Factory function to create credential source instances.
|
415
|
+
|
416
|
+
Args:
|
417
|
+
source_type: Type of credential source ("environment", "keyvault")
|
418
|
+
**kwargs: Source-specific configuration parameters
|
419
|
+
|
420
|
+
Returns:
|
421
|
+
CredentialSource instance
|
422
|
+
|
423
|
+
Raises:
|
424
|
+
ValueError: If source_type is unsupported
|
425
|
+
"""
|
426
|
+
if source_type == "environment":
|
427
|
+
return EnvironmentCredentialSource(**kwargs)
|
428
|
+
elif source_type == "keyvault":
|
429
|
+
return KeyVaultCredentialSource(**kwargs)
|
430
|
+
else:
|
431
|
+
raise ValueError(f"Unsupported credential source type: {source_type}")
|
@@ -201,6 +201,14 @@ class D365FOClientManager:
|
|
201
201
|
raise ValueError(
|
202
202
|
f"Profile '{env_profile.name}' has no base_url configured"
|
203
203
|
)
|
204
|
+
|
205
|
+
# Check if legacy config should override certain settings
|
206
|
+
default_config = self.config.get("default_environment", {})
|
207
|
+
if default_config and profile == "default":
|
208
|
+
# Override use_default_credentials if specified in legacy config
|
209
|
+
if "use_default_credentials" in default_config:
|
210
|
+
config.use_default_credentials = default_config["use_default_credentials"]
|
211
|
+
|
204
212
|
return config
|
205
213
|
|
206
214
|
# Fallback to legacy config-based profiles
|
d365fo_client/mcp/main.py
CHANGED
@@ -43,28 +43,50 @@ def setup_logging(level: str = "INFO") -> None:
|
|
43
43
|
|
44
44
|
def load_config() -> Dict[str, Any]:
|
45
45
|
"""Load configuration from environment and config files.
|
46
|
+
|
47
|
+
Handles three startup scenarios:
|
48
|
+
1. No environment variables: Profile-only mode
|
49
|
+
2. D365FO_BASE_URL only: Default auth mode
|
50
|
+
3. Full variables: Client credentials mode
|
46
51
|
|
47
52
|
Returns:
|
48
|
-
Configuration dictionary
|
53
|
+
Configuration dictionary with startup_mode indicator
|
49
54
|
"""
|
50
55
|
config = {}
|
51
|
-
|
52
|
-
#
|
53
|
-
|
56
|
+
|
57
|
+
# Get environment variables
|
58
|
+
base_url = os.getenv("D365FO_BASE_URL")
|
59
|
+
client_id = os.getenv("D365FO_CLIENT_ID")
|
60
|
+
client_secret = os.getenv("D365FO_CLIENT_SECRET")
|
61
|
+
tenant_id = os.getenv("D365FO_TENANT_ID")
|
62
|
+
|
63
|
+
# Determine startup mode based on available environment variables
|
64
|
+
if not base_url:
|
65
|
+
# Scenario 1: No environment variables - profile-only mode
|
66
|
+
config["startup_mode"] = "profile_only"
|
67
|
+
config["has_base_url"] = False
|
68
|
+
logging.info("Startup mode: profile-only (no D365FO_BASE_URL provided)")
|
69
|
+
|
70
|
+
elif base_url and not (client_id and client_secret and tenant_id):
|
71
|
+
# Scenario 2: Only base URL - default authentication
|
72
|
+
config["startup_mode"] = "default_auth"
|
73
|
+
config["has_base_url"] = True
|
54
74
|
config.setdefault("default_environment", {})["base_url"] = base_url
|
55
|
-
|
56
|
-
|
57
|
-
|
58
|
-
|
59
|
-
|
60
|
-
|
61
|
-
config
|
62
|
-
|
63
|
-
|
64
|
-
|
65
|
-
|
66
|
-
|
67
|
-
|
75
|
+
config["default_environment"]["use_default_credentials"] = True
|
76
|
+
logging.info("Startup mode: default authentication (D365FO_BASE_URL provided)")
|
77
|
+
|
78
|
+
else:
|
79
|
+
# Scenario 3: Full credentials - client credentials authentication
|
80
|
+
config["startup_mode"] = "client_credentials"
|
81
|
+
config["has_base_url"] = True
|
82
|
+
config.setdefault("default_environment", {}).update({
|
83
|
+
"base_url": base_url,
|
84
|
+
"client_id": client_id,
|
85
|
+
"client_secret": client_secret,
|
86
|
+
"tenant_id": tenant_id,
|
87
|
+
"use_default_credentials": False
|
88
|
+
})
|
89
|
+
logging.info("Startup mode: client credentials (full D365FO environment variables provided)")
|
68
90
|
|
69
91
|
return config
|
70
92
|
|
d365fo_client/mcp/models.py
CHANGED
@@ -2,7 +2,7 @@
|
|
2
2
|
|
3
3
|
from dataclasses import dataclass
|
4
4
|
from datetime import datetime
|
5
|
-
from enum import
|
5
|
+
from enum import StrEnum
|
6
6
|
from typing import Any, Dict, List, Optional, Union
|
7
7
|
|
8
8
|
# Resource Models
|
@@ -37,7 +37,7 @@ class EntityResourceContent:
|
|
37
37
|
last_updated: Optional[str] = None
|
38
38
|
|
39
39
|
|
40
|
-
class MetadataType(
|
40
|
+
class MetadataType(StrEnum):
|
41
41
|
ENTITIES = "entities"
|
42
42
|
ACTIONS = "actions"
|
43
43
|
ENUMERATIONS = "enumerations"
|