d365fo-client 0.2.3__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.3.dist-info → d365fo_client-0.3.0.dist-info}/METADATA +1261 -810
  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.3.dist-info/RECORD +0 -56
  55. d365fo_client-0.2.3.dist-info/entry_points.txt +0 -3
  56. {d365fo_client-0.2.3.dist-info → d365fo_client-0.3.0.dist-info}/WHEEL +0 -0
  57. {d365fo_client-0.2.3.dist-info → d365fo_client-0.3.0.dist-info}/licenses/LICENSE +0 -0
  58. {d365fo_client-0.2.3.dist-info → d365fo_client-0.3.0.dist-info}/top_level.txt +0 -0
d365fo_client/__init__.py CHANGED
@@ -172,7 +172,7 @@ from .labels import resolve_labels_generic, resolve_labels_generic_with_cache
172
172
  from .main import main
173
173
 
174
174
  # MCP Server
175
- from .mcp import D365FOClientManager, D365FOMCPServer
175
+ from .mcp import D365FOClientManager, D365FOMCPServer, FastD365FOMCPServer
176
176
 
177
177
  # V2 Metadata Cache (recommended - now the only implementation)
178
178
  from .metadata_v2 import MetadataCacheV2, VersionAwareSearchEngine
@@ -195,6 +195,7 @@ from .models import (
195
195
  from .output import OutputFormatter
196
196
  from .profile_manager import ProfileManager
197
197
  from .profiles import Profile
198
+ from .settings import D365FOSettings, get_settings, reset_settings
198
199
  from .utils import (
199
200
  ensure_directory_exists,
200
201
  extract_domain_from_url,
@@ -250,11 +251,16 @@ __all__ = [
250
251
  "Profile",
251
252
  "ProfileManager",
252
253
  "CLIManager",
254
+ # Settings
255
+ "D365FOSettings",
256
+ "get_settings",
257
+ "reset_settings",
253
258
  # Legacy aliases
254
259
  "CLIProfile",
255
260
  "EnvironmentProfile",
256
261
  # MCP Server
257
262
  "D365FOMCPServer",
263
+ "FastD365FOMCPServer",
258
264
  "D365FOClientManager",
259
265
  # Entry point
260
266
  "main",
d365fo_client/auth.py CHANGED
@@ -1,7 +1,7 @@
1
1
  """Authentication utilities for D365 F&O client."""
2
2
 
3
3
  from datetime import datetime
4
- from typing import Optional
4
+ from typing import Optional, Union
5
5
 
6
6
  from azure.identity import ClientSecretCredential, DefaultAzureCredential
7
7
 
@@ -22,13 +22,13 @@ class AuthenticationManager:
22
22
  self._token = None
23
23
  self._token_expires = None
24
24
  self._credential_manager = CredentialManager()
25
- self.credential = None # Will be set by _setup_credentials
25
+ self.credential: Optional[Union[ClientSecretCredential, DefaultAzureCredential]] = None # Will be set by _setup_credentials
26
26
 
27
27
  async def _setup_credentials(self):
28
28
  """Setup authentication credentials with support for credential sources"""
29
29
 
30
30
  # Check if credential source is specified in config
31
- credential_source = getattr(self.config, 'credential_source', None)
31
+ credential_source = self.config.credential_source
32
32
 
33
33
  if credential_source is not None:
34
34
  # Use credential source to get credentials
@@ -46,22 +46,8 @@ class AuthenticationManager:
46
46
 
47
47
  # Fallback to existing logic for backward compatibility
48
48
 
49
- if (
50
- self.config.client_id
51
- and self.config.client_secret
52
- and self.config.tenant_id
53
- ):
54
- self.credential = ClientSecretCredential(
55
- tenant_id=self.config.tenant_id,
56
- client_id=self.config.client_id,
57
- client_secret=self.config.client_secret,
58
- )
59
- elif self.config.use_default_credentials:
60
- self.credential = DefaultAzureCredential()
61
- else:
62
- raise ValueError(
63
- "Must provide either use_default_credentials=True, client credentials, or credential_source"
64
- )
49
+ self.credential = DefaultAzureCredential()
50
+
65
51
 
66
52
  async def get_token(self) -> str:
67
53
  """Get authentication token
@@ -77,6 +63,9 @@ class AuthenticationManager:
77
63
  if self.credential is None:
78
64
  await self._setup_credentials()
79
65
 
66
+ if self.credential is None:
67
+ raise ValueError("Authentication credentials are not set up.")
68
+
80
69
  if (
81
70
  self._token
82
71
  and self._token_expires
@@ -86,8 +75,7 @@ class AuthenticationManager:
86
75
 
87
76
  # Try different scopes
88
77
  scopes_to_try = [
89
- f"{self.config.base_url}/.default",
90
- f"{self.config.client_id}/.default" if self.config.client_id else None,
78
+ f"{self.config.base_url.rstrip('/')}/.default",
91
79
  ]
92
80
 
93
81
  for scope in scopes_to_try:
d365fo_client/cli.py CHANGED
@@ -532,10 +532,13 @@ class CLIManager:
532
532
 
533
533
  profile_list = []
534
534
  for name, profile in profiles.items():
535
+ # Determine auth mode based on credential source
536
+ auth_mode = "default" if profile.credential_source is None else "explicit"
537
+
535
538
  profile_info = {
536
539
  "name": name,
537
540
  "base_url": profile.base_url,
538
- "auth_mode": profile.auth_mode,
541
+ "auth_mode": auth_mode,
539
542
  "default": (
540
543
  "✓" if default_profile and default_profile.name == name else ""
541
544
  ),
@@ -563,22 +566,22 @@ class CLIManager:
563
566
  return 1
564
567
 
565
568
  # Convert profile to dict for display
569
+ auth_mode = "default" if profile.credential_source is None else "explicit"
570
+
566
571
  profile_dict = {
567
572
  "name": profile.name,
568
573
  "base_url": profile.base_url,
569
- "auth_mode": profile.auth_mode,
574
+ "auth_mode": auth_mode,
570
575
  "verify_ssl": profile.verify_ssl,
571
576
  "output_format": profile.output_format,
572
- "label_cache": profile.label_cache,
573
- "label_expiry": profile.label_expiry,
577
+ "label_cache": profile.use_label_cache,
578
+ "label_expiry": profile.label_cache_expiry_minutes,
574
579
  "language": profile.language,
575
580
  }
576
581
 
577
- # Only show auth details if they exist
578
- if profile.client_id:
579
- profile_dict["client_id"] = profile.client_id
580
- if profile.tenant_id:
581
- profile_dict["tenant_id"] = profile.tenant_id
582
+ # Only show credential source info if it exists
583
+ if profile.credential_source:
584
+ profile_dict["credential_source"] = profile.credential_source.source_type
582
585
 
583
586
  output = self.output_formatter.format_output(profile_dict)
584
587
  print(output)
@@ -607,14 +610,23 @@ class CLIManager:
607
610
  print(format_error_message(f"Profile already exists: {profile_name}"))
608
611
  return 1
609
612
 
613
+ # Handle legacy credential parameters
614
+ auth_mode = getattr(args, "auth_mode", "default")
615
+ client_id = getattr(args, "client_id", None)
616
+ client_secret = getattr(args, "client_secret", None)
617
+ tenant_id = getattr(args, "tenant_id", None)
618
+
619
+ # Create credential source from legacy parameters if needed
620
+ credential_source = None
621
+ if auth_mode != "default" and all([client_id, client_secret, tenant_id]):
622
+ from .credential_sources import EnvironmentCredentialSource
623
+ credential_source = EnvironmentCredentialSource()
624
+
610
625
  # Create new profile
611
626
  profile = Profile(
612
627
  name=profile_name,
613
628
  base_url=base_url,
614
- auth_mode=getattr(args, "auth_mode", "default"),
615
- client_id=getattr(args, "client_id", None),
616
- client_secret=getattr(args, "client_secret", None),
617
- tenant_id=getattr(args, "tenant_id", None),
629
+ credential_source=credential_source,
618
630
  verify_ssl=getattr(args, "verify_ssl", True),
619
631
  output_format=getattr(args, "output_format", "table"),
620
632
  use_label_cache=getattr(args, "label_cache", True),
d365fo_client/client.py CHANGED
@@ -5,6 +5,8 @@ import logging
5
5
  from pathlib import Path
6
6
  from typing import Any, Dict, List, Optional, Union
7
7
 
8
+ from d365fo_client.utils import get_default_cache_directory
9
+
8
10
  from .auth import AuthenticationManager
9
11
  from .crud import CrudOperations
10
12
  from .exceptions import FOClientError
@@ -72,6 +74,7 @@ class FOClient:
72
74
  self.metadata_api_ops = MetadataAPIOperations(
73
75
  self.session_manager, self.metadata_url, self.label_ops
74
76
  )
77
+
75
78
 
76
79
  async def close(self):
77
80
  """Close the client session"""
@@ -94,14 +97,15 @@ class FOClient:
94
97
  if not self._metadata_initialized and self.config.enable_metadata_cache:
95
98
  try:
96
99
 
97
- cache_dir = Path(self.config.metadata_cache_dir)
100
+ cache_dir = Path(self.config.metadata_cache_dir or get_default_cache_directory())
98
101
 
99
102
  # Initialize metadata cache v2
100
103
  self.metadata_cache = MetadataCacheV2(
101
104
  cache_dir, self.config.base_url, self.metadata_api_ops
102
105
  )
103
106
  # Initialize label operations v2 with cache support
104
- self.label_ops.set_label_cache(self.metadata_cache)
107
+
108
+ self.label_ops.set_label_cache(self.metadata_cache)
105
109
 
106
110
  await self.metadata_cache.initialize()
107
111
 
@@ -349,7 +353,7 @@ class FOClient:
349
353
  return False
350
354
 
351
355
  # Perform sync using the new sync manager
352
- from .models import SyncStrategy
356
+ from .sync_models import SyncStrategy
353
357
 
354
358
  strategy = SyncStrategy.FULL if force_refresh else SyncStrategy.INCREMENTAL
355
359
 
@@ -922,7 +926,7 @@ class FOClient:
922
926
  use_cache_first=use_cache_first,
923
927
  )
924
928
 
925
- return await resolve_labels_generic(entity, self.label_ops)
929
+ return await resolve_labels_generic(entity, self.label_ops) #type: ignore
926
930
 
927
931
  async def get_all_public_entities_with_details(
928
932
  self, resolve_labels: bool = False, language: str = "en-US"
d365fo_client/config.py CHANGED
@@ -92,7 +92,7 @@ class ConfigManager:
92
92
 
93
93
  profile_data = profiles[profile_name]
94
94
  try:
95
- return Profile.from_dict(profile_name, profile_data)
95
+ return Profile.create_from_dict(profile_name, profile_data)
96
96
  except Exception as e:
97
97
  logger.error(f"Error loading profile {profile_name}: {e}")
98
98
  return None
@@ -141,7 +141,7 @@ class ConfigManager:
141
141
  profiles = {}
142
142
  for name, data in self._config_data.get("profiles", {}).items():
143
143
  try:
144
- profiles[name] = Profile.from_dict(name, data)
144
+ profiles[name] = Profile.create_from_dict(name, data)
145
145
  except Exception as e:
146
146
  logger.error(f"Error loading profile {name}: {e}")
147
147
  continue
@@ -190,15 +190,19 @@ class ConfigManager:
190
190
  # Start with defaults
191
191
  config_params = {
192
192
  "base_url": None,
193
- "use_default_credentials": True,
194
- "client_id": None,
195
- "client_secret": None,
196
- "tenant_id": None,
197
193
  "verify_ssl": True,
198
194
  "use_label_cache": True,
199
195
  "label_cache_expiry_minutes": 60,
200
196
  "use_cache_first": True,
201
197
  "timeout": 60,
198
+ "credential_source": None,
199
+ }
200
+
201
+ # Temporary variables for legacy credential handling
202
+ legacy_credentials: Dict[str, Optional[str]] = {
203
+ "client_id": None,
204
+ "client_secret": None,
205
+ "tenant_id": None,
202
206
  }
203
207
 
204
208
  # Apply profile settings if specified
@@ -211,31 +215,36 @@ class ConfigManager:
211
215
  profile = self.get_default_profile()
212
216
 
213
217
  if profile:
218
+ # Convert profile to client config and extract relevant parameters
219
+ client_config = profile.to_client_config()
214
220
  config_params.update(
215
221
  {
216
- "base_url": profile.base_url,
217
- "client_id": profile.client_id,
218
- "client_secret": profile.client_secret,
219
- "tenant_id": profile.tenant_id,
220
- "verify_ssl": profile.verify_ssl,
221
- "use_label_cache": profile.use_label_cache,
222
- "label_cache_expiry_minutes": profile.label_cache_expiry_minutes,
223
- "use_cache_first": profile.use_cache_first,
224
- "timeout": profile.timeout,
222
+ "base_url": client_config.base_url,
223
+ "verify_ssl": client_config.verify_ssl,
224
+ "use_label_cache": client_config.use_label_cache,
225
+ "label_cache_expiry_minutes": client_config.label_cache_expiry_minutes,
226
+ "use_cache_first": client_config.use_cache_first,
227
+ "timeout": client_config.timeout,
228
+ "credential_source": client_config.credential_source,
225
229
  }
226
230
  )
227
231
 
228
232
  # Apply environment variables
229
233
  env_mappings = {
230
234
  "D365FO_BASE_URL": "base_url",
231
- "D365FO_CLIENT_ID": "client_id",
232
- "D365FO_CLIENT_SECRET": "client_secret",
233
- "D365FO_TENANT_ID": "tenant_id",
234
235
  "D365FO_VERIFY_SSL": "verify_ssl",
235
236
  "D365FO_LABEL_CACHE": "use_label_cache",
236
237
  "D365FO_LABEL_EXPIRY": "label_cache_expiry_minutes",
237
238
  "D365FO_USE_CACHE_FIRST": "use_cache_first",
238
239
  "D365FO_TIMEOUT": "timeout",
240
+ "D365FO_CACHE_DIR": "metadata_cache_dir",
241
+ }
242
+
243
+ # Environment variables for legacy credentials
244
+ legacy_env_mappings = {
245
+ "D365FO_CLIENT_ID": "client_id",
246
+ "D365FO_CLIENT_SECRET": "client_secret",
247
+ "D365FO_TENANT_ID": "tenant_id",
239
248
  }
240
249
 
241
250
  for env_var, param_name in env_mappings.items():
@@ -258,12 +267,15 @@ class ConfigManager:
258
267
  else:
259
268
  config_params[param_name] = env_value
260
269
 
270
+ # Handle legacy credential environment variables
271
+ for env_var, param_name in legacy_env_mappings.items():
272
+ env_value = os.getenv(env_var)
273
+ if env_value:
274
+ legacy_credentials[param_name] = env_value
275
+
261
276
  # Apply command line arguments (highest precedence)
262
277
  arg_mappings = {
263
278
  "base_url": "base_url",
264
- "client_id": "client_id",
265
- "client_secret": "client_secret",
266
- "tenant_id": "tenant_id",
267
279
  "verify_ssl": "verify_ssl",
268
280
  "label_cache": "use_label_cache",
269
281
  "label_expiry": "label_cache_expiry_minutes",
@@ -271,20 +283,30 @@ class ConfigManager:
271
283
  "timeout": "timeout",
272
284
  }
273
285
 
286
+ # Legacy credential CLI args
287
+ legacy_arg_mappings = {
288
+ "client_id": "client_id",
289
+ "client_secret": "client_secret",
290
+ "tenant_id": "tenant_id",
291
+ }
292
+
274
293
  for arg_name, param_name in arg_mappings.items():
275
294
  arg_value = getattr(args, arg_name, None)
276
295
  if arg_value is not None:
277
296
  config_params[param_name] = arg_value
278
297
 
279
- # Determine authentication mode
280
- if any(
281
- [
282
- config_params["client_id"],
283
- config_params["client_secret"],
284
- config_params["tenant_id"],
285
- ]
286
- ):
287
- config_params["use_default_credentials"] = False
298
+ # Handle legacy credential CLI arguments
299
+ for arg_name, param_name in legacy_arg_mappings.items():
300
+ arg_value = getattr(args, arg_name, None)
301
+ if arg_value is not None:
302
+ legacy_credentials[param_name] = arg_value
303
+
304
+ # Create credential source from legacy credentials if provided
305
+ if any(legacy_credentials.values()) and config_params["credential_source"] is None:
306
+ # Check if we have all required credentials
307
+ if all(legacy_credentials.values()):
308
+ from .credential_sources import EnvironmentCredentialSource
309
+ config_params["credential_source"] = EnvironmentCredentialSource()
288
310
 
289
311
  return FOClientConfig(**config_params)
290
312
 
@@ -210,6 +210,8 @@ class EnvironmentCredentialProvider(CredentialProvider):
210
210
  raise ValueError(f"Missing required environment variables: {', '.join(missing_vars)}")
211
211
 
212
212
  logger.debug(f"Retrieved credentials from environment variables: {source.client_id_var}, {source.client_secret_var}, {source.tenant_id_var}")
213
+ assert client_id and client_secret and tenant_id # For type checker
214
+
213
215
  return client_id, client_secret, tenant_id
214
216
 
215
217
 
@@ -252,6 +254,7 @@ class KeyVaultCredentialProvider(CredentialProvider):
252
254
  raise ValueError("One or more secrets retrieved from Key Vault are empty")
253
255
 
254
256
  logger.debug(f"Retrieved credentials from Key Vault: {source.vault_url}")
257
+ assert client_id and client_secret and tenant_id # For type checker
255
258
  return client_id, client_secret, tenant_id
256
259
 
257
260
  except Exception as e:
@@ -282,6 +285,8 @@ class KeyVaultCredentialProvider(CredentialProvider):
282
285
  if not all([source.keyvault_client_id, source.keyvault_client_secret, source.keyvault_tenant_id]):
283
286
  raise ValueError("Key Vault client_secret authentication requires keyvault_client_id, keyvault_client_secret, and keyvault_tenant_id")
284
287
 
288
+ assert source.keyvault_client_id and source.keyvault_client_secret and source.keyvault_tenant_id # For type checker
289
+
285
290
  credential = ClientSecretCredential(
286
291
  tenant_id=source.keyvault_tenant_id,
287
292
  client_id=source.keyvault_client_id,
d365fo_client/main.py CHANGED
@@ -12,7 +12,7 @@ async def example_usage():
12
12
  """Example usage of the F&O client with label functionality"""
13
13
  config = FOClientConfig(
14
14
  base_url="https://usnconeboxax1aos.cloud.onebox.dynamics.com",
15
- use_default_credentials=True,
15
+
16
16
  verify_ssl=False,
17
17
  use_label_cache=True,
18
18
  label_cache_expiry_minutes=60,
@@ -8,9 +8,11 @@ integration workflows through standardized MCP protocol.
8
8
  """
9
9
 
10
10
  from .client_manager import D365FOClientManager
11
+ from .fastmcp_server import FastD365FOMCPServer
11
12
  from .server import D365FOMCPServer
12
13
 
13
14
  __all__ = [
14
- "D365FOMCPServer",
15
+ "D365FOMCPServer", # Legacy MCP server (backward compatibility)
16
+ "FastD365FOMCPServer", # New FastMCP server with multi-transport support
15
17
  "D365FOClientManager",
16
18
  ]
@@ -0,0 +1,5 @@
1
+ """Server components for the D365FO MCP server."""
2
+
3
+ from .auth.providers.azure import AzureProvider
4
+
5
+ __all__ = ["AzureProvider"]
@@ -0,0 +1,30 @@
1
+ from .auth import (
2
+ OAuthProvider,
3
+ TokenVerifier,
4
+ RemoteAuthProvider,
5
+ AccessToken,
6
+ AuthProvider,
7
+ )
8
+ from .providers.jwt import JWTVerifier, StaticTokenVerifier
9
+ from .oauth_proxy import OAuthProxy
10
+
11
+
12
+ __all__ = [
13
+ "AuthProvider",
14
+ "OAuthProvider",
15
+ "TokenVerifier",
16
+ "JWTVerifier",
17
+ "StaticTokenVerifier",
18
+ "RemoteAuthProvider",
19
+ "AccessToken",
20
+ "OAuthProxy",
21
+ ]
22
+
23
+
24
+ def __getattr__(name: str):
25
+ # Defer import because it raises a deprecation warning
26
+ if name == "BearerAuthProvider":
27
+ from .providers.bearer import BearerAuthProvider
28
+
29
+ return BearerAuthProvider
30
+ raise AttributeError(f"module '{__name__}' has no attribute '{name}'")