d365fo-client 0.2.4__py3-none-any.whl → 0.3.1__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 (59) 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/apikey.py +83 -0
  15. d365fo_client/mcp/auth_server/auth/providers/azure.py +393 -0
  16. d365fo_client/mcp/auth_server/auth/providers/bearer.py +25 -0
  17. d365fo_client/mcp/auth_server/auth/providers/jwt.py +547 -0
  18. d365fo_client/mcp/auth_server/auth/redirect_validation.py +65 -0
  19. d365fo_client/mcp/auth_server/dependencies.py +136 -0
  20. d365fo_client/mcp/client_manager.py +16 -67
  21. d365fo_client/mcp/fastmcp_main.py +407 -0
  22. d365fo_client/mcp/fastmcp_server.py +598 -0
  23. d365fo_client/mcp/fastmcp_utils.py +431 -0
  24. d365fo_client/mcp/main.py +40 -13
  25. d365fo_client/mcp/mixins/__init__.py +24 -0
  26. d365fo_client/mcp/mixins/base_tools_mixin.py +55 -0
  27. d365fo_client/mcp/mixins/connection_tools_mixin.py +50 -0
  28. d365fo_client/mcp/mixins/crud_tools_mixin.py +311 -0
  29. d365fo_client/mcp/mixins/database_tools_mixin.py +685 -0
  30. d365fo_client/mcp/mixins/label_tools_mixin.py +87 -0
  31. d365fo_client/mcp/mixins/metadata_tools_mixin.py +565 -0
  32. d365fo_client/mcp/mixins/performance_tools_mixin.py +109 -0
  33. d365fo_client/mcp/mixins/profile_tools_mixin.py +713 -0
  34. d365fo_client/mcp/mixins/sync_tools_mixin.py +321 -0
  35. d365fo_client/mcp/prompts/action_execution.py +1 -1
  36. d365fo_client/mcp/prompts/sequence_analysis.py +1 -1
  37. d365fo_client/mcp/tools/crud_tools.py +3 -3
  38. d365fo_client/mcp/tools/sync_tools.py +1 -1
  39. d365fo_client/mcp/utilities/__init__.py +1 -0
  40. d365fo_client/mcp/utilities/auth.py +34 -0
  41. d365fo_client/mcp/utilities/logging.py +58 -0
  42. d365fo_client/mcp/utilities/types.py +426 -0
  43. d365fo_client/metadata_v2/sync_manager_v2.py +2 -0
  44. d365fo_client/metadata_v2/sync_session_manager.py +7 -7
  45. d365fo_client/models.py +139 -139
  46. d365fo_client/output.py +2 -2
  47. d365fo_client/profile_manager.py +62 -27
  48. d365fo_client/profiles.py +118 -113
  49. d365fo_client/settings.py +367 -0
  50. d365fo_client/sync_models.py +85 -2
  51. d365fo_client/utils.py +2 -1
  52. {d365fo_client-0.2.4.dist-info → d365fo_client-0.3.1.dist-info}/METADATA +273 -18
  53. d365fo_client-0.3.1.dist-info/RECORD +85 -0
  54. d365fo_client-0.3.1.dist-info/entry_points.txt +4 -0
  55. d365fo_client-0.2.4.dist-info/RECORD +0 -56
  56. d365fo_client-0.2.4.dist-info/entry_points.txt +0 -3
  57. {d365fo_client-0.2.4.dist-info → d365fo_client-0.3.1.dist-info}/WHEEL +0 -0
  58. {d365fo_client-0.2.4.dist-info → d365fo_client-0.3.1.dist-info}/licenses/LICENSE +0 -0
  59. {d365fo_client-0.2.4.dist-info → d365fo_client-0.3.1.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,713 @@
1
+ """Profile tools mixin for FastMCP server."""
2
+
3
+ import logging
4
+ from typing import Any, Dict, Optional
5
+
6
+ from .base_tools_mixin import BaseToolsMixin
7
+
8
+ logger = logging.getLogger(__name__)
9
+
10
+
11
+ class ProfileToolsMixin(BaseToolsMixin):
12
+ """Profile management tools for FastMCP server.
13
+
14
+ Provides 14 comprehensive profile management tools for AI assistants to manage
15
+ D365 F&O environment connections. Supports unified credential management through
16
+ credential sources, full configuration control, profile cloning, import/export, and intelligent search.
17
+
18
+ AUTHENTICATION MODES:
19
+ - Default Credentials: When credentialSource is null/omitted, uses Azure Default Credentials
20
+ - Credential Source: When credentialSource is provided, uses specific credential source
21
+
22
+ CREDENTIAL SOURCE EXAMPLES for AI assistants:
23
+
24
+ 1. Environment Variables (default variable names):
25
+ {
26
+ "sourceType": "environment"
27
+ }
28
+
29
+ 2. Environment Variables (custom variable names):
30
+ {
31
+ "sourceType": "environment",
32
+ "clientIdVar": "MY_CLIENT_ID",
33
+ "clientSecretVar": "MY_CLIENT_SECRET",
34
+ "tenantIdVar": "MY_TENANT_ID"
35
+ }
36
+
37
+ 3. Azure Key Vault with Default Auth:
38
+ {
39
+ "sourceType": "keyvault",
40
+ "vaultUrl": "https://myvault.vault.azure.net/",
41
+ "clientIdSecretName": "D365FO_CLIENT_ID",
42
+ "clientSecretSecretName": "D365FO_CLIENT_SECRET",
43
+ "tenantIdSecretName": "D365FO_TENANT_ID"
44
+ }
45
+
46
+ 4. Azure Key Vault with Custom Secret Names:
47
+ {
48
+ "sourceType": "keyvault",
49
+ "vaultUrl": "https://myvault.vault.azure.net/",
50
+ "clientIdSecretName": "d365-client-id",
51
+ "clientSecretSecretName": "d365-client-secret",
52
+ "tenantIdSecretName": "d365-tenant-id",
53
+ "keyvaultAuthMode": "default"
54
+ }
55
+
56
+ AI assistants can use these patterns to help users set up secure credential management.
57
+ Legacy credential fields (auth_mode, client_id, client_secret, tenant_id) are automatically
58
+ migrated to appropriate credential sources for backward compatibility.
59
+ """
60
+
61
+ def register_profile_tools(self) -> None:
62
+ """Register all profile tools with FastMCP."""
63
+
64
+ @self.mcp.tool()
65
+ async def d365fo_list_profiles() -> Dict[str, Any]:
66
+ """Get list of all available D365FO environment profiles.
67
+
68
+ Returns:
69
+ Dictionary with list of profiles
70
+ """
71
+ try:
72
+ profiles = self.profile_manager.list_profiles()
73
+
74
+ profile_list = []
75
+ for name, profile in profiles.items():
76
+ profile_dict = profile.to_dict() if hasattr(profile, 'to_dict') else {"name": name, "baseUrl": getattr(profile, 'base_url', '')}
77
+ profile_list.append(profile_dict)
78
+
79
+ return {
80
+ "totalProfiles": len(profiles),
81
+ "profiles": profile_list,
82
+ }
83
+
84
+ except Exception as e:
85
+ logger.error(f"List profiles failed: {e}")
86
+ return {"error": str(e)}
87
+
88
+ @self.mcp.tool()
89
+ async def d365fo_get_profile(profileName: str) -> Dict[str, Any]:
90
+ """Get details of a specific D365FO environment profile.
91
+
92
+ Args:
93
+ profileName: Name of the profile to retrieve
94
+
95
+ Returns:
96
+ Dictionary with profile details
97
+ """
98
+ try:
99
+ profile = self.profile_manager.get_profile(profileName)
100
+
101
+ if profile:
102
+ return {"profileName": profileName, "profile": profile.to_dict()}
103
+ else:
104
+ return {
105
+ "error": f"Profile '{profileName}' not found",
106
+ "profileName": profileName,
107
+ }
108
+
109
+ except Exception as e:
110
+ logger.error(f"Get profile failed: {e}")
111
+ return {"error": str(e), "profileName": profileName}
112
+
113
+ @self.mcp.tool()
114
+ async def d365fo_create_profile(
115
+ name: str,
116
+ baseUrl: str,
117
+ description: Optional[str] = None,
118
+ verifySsl: bool = True,
119
+ timeout: int = 60,
120
+ useLabelCache: bool = True,
121
+ labelCacheExpiryMinutes: int = 60,
122
+ useCacheFirst: bool = True,
123
+ language: str = "en-US",
124
+ cacheDir: Optional[str] = None,
125
+ outputFormat: str = "table",
126
+ setAsDefault: bool = False,
127
+ credentialSource: Optional[Dict[str, Any]] = None,
128
+ ) -> Dict[str, Any]:
129
+ """Create a new D365FO environment profile with full configuration options.
130
+
131
+ Args:
132
+ name: Profile name
133
+ baseUrl: D365FO base URL
134
+ description: Profile description
135
+ verifySsl: Whether to verify SSL certificates (default: True)
136
+ timeout: Request timeout in seconds (default: 60)
137
+ useLabelCache: Whether to enable label caching (default: True)
138
+ labelCacheExpiryMinutes: Label cache expiry in minutes (default: 60)
139
+ useCacheFirst: Whether to use cache-first behavior (default: True)
140
+ language: Default language code (default: "en-US")
141
+ cacheDir: Custom cache directory path (optional)
142
+ outputFormat: Default output format for CLI operations (default: "table")
143
+ setAsDefault: Set as default profile (default: False)
144
+ credentialSource: Credential source configuration. If None, uses Azure Default Credentials. Can be:
145
+ - Environment variables: {"sourceType": "environment", "clientIdVar": "MY_CLIENT_ID", "clientSecretVar": "MY_CLIENT_SECRET", "tenantIdVar": "MY_TENANT_ID"}
146
+ - Azure Key Vault: {"sourceType": "keyvault", "vaultUrl": "https://vault.vault.azure.net/", "clientIdSecretName": "D365FO_CLIENT_ID", "clientSecretSecretName": "D365FO_CLIENT_SECRET", "tenantIdSecretName": "D365FO_TENANT_ID"}
147
+
148
+ Returns:
149
+ Dictionary with creation result
150
+ """
151
+ try:
152
+ # Handle credential source conversion
153
+ credential_source_obj = None
154
+ if credentialSource:
155
+ credential_source_obj = self._convert_credential_source(credentialSource)
156
+
157
+ success = self.profile_manager.create_profile(
158
+ name=name,
159
+ base_url=baseUrl,
160
+ description=description,
161
+ verify_ssl=verifySsl,
162
+ timeout=timeout,
163
+ use_label_cache=useLabelCache,
164
+ label_cache_expiry_minutes=labelCacheExpiryMinutes,
165
+ use_cache_first=useCacheFirst,
166
+ language=language,
167
+ cache_dir=cacheDir,
168
+ credential_source=credential_source_obj,
169
+ )
170
+
171
+ # Set as default if requested
172
+ if success and setAsDefault:
173
+ self.profile_manager.set_default_profile(name)
174
+
175
+ # Get the created profile for detailed response
176
+ created_profile = None
177
+ if success:
178
+ created_profile = self.profile_manager.get_profile(name)
179
+
180
+ return {
181
+ "profileName": name,
182
+ "created": success,
183
+ "setAsDefault": setAsDefault and success,
184
+ "profile": created_profile.to_dict() if created_profile else None,
185
+ "authType": "default_credentials" if not credentialSource else credentialSource.get("sourceType", "unknown"),
186
+ }
187
+
188
+ except Exception as e:
189
+ logger.error(f"Create profile failed: {e}")
190
+ return {"error": str(e), "profileName": name, "created": False}
191
+
192
+ @self.mcp.tool()
193
+ async def d365fo_update_profile(
194
+ name: str,
195
+ baseUrl: Optional[str] = None,
196
+ description: Optional[str] = None,
197
+ verifySsl: Optional[bool] = None,
198
+ timeout: Optional[int] = None,
199
+ useLabelCache: Optional[bool] = None,
200
+ labelCacheExpiryMinutes: Optional[int] = None,
201
+ useCacheFirst: Optional[bool] = None,
202
+ language: Optional[str] = None,
203
+ cacheDir: Optional[str] = None,
204
+ outputFormat: Optional[str] = None,
205
+ credentialSource: Optional[Dict[str, Any]] = None,
206
+ ) -> Dict[str, Any]:
207
+ """Update an existing D365FO environment profile with full configuration options.
208
+
209
+ Automatically invalidates all cached client connections to ensure they pick up
210
+ the new profile settings on next use.
211
+
212
+ Args:
213
+ name: Profile name
214
+ baseUrl: D365FO base URL
215
+ description: Profile description
216
+ verifySsl: Whether to verify SSL certificates
217
+ timeout: Request timeout in seconds
218
+ useLabelCache: Whether to enable label caching
219
+ labelCacheExpiryMinutes: Label cache expiry in minutes
220
+ useCacheFirst: Whether to use cache-first behavior
221
+ language: Default language code
222
+ cacheDir: Custom cache directory path
223
+ outputFormat: Default output format for CLI operations
224
+ credentialSource: Credential source configuration. Set to null to use Azure Default Credentials. Can be:
225
+ - Environment variables: {"sourceType": "environment", "clientIdVar": "MY_CLIENT_ID", "clientSecretVar": "MY_CLIENT_SECRET", "tenantIdVar": "MY_TENANT_ID"}
226
+ - Azure Key Vault: {"sourceType": "keyvault", "vaultUrl": "https://vault.vault.azure.net/", "clientIdSecretName": "D365FO_CLIENT_ID", "clientSecretSecretName": "D365FO_CLIENT_SECRET", "tenantIdSecretName": "D365FO_TENANT_ID"}
227
+
228
+ Returns:
229
+ Dictionary with update result including number of clients invalidated
230
+ """
231
+ try:
232
+ # Convert parameter names and handle credential source
233
+ update_params = {}
234
+ if baseUrl is not None:
235
+ update_params['base_url'] = baseUrl
236
+ if description is not None:
237
+ update_params['description'] = description
238
+ if verifySsl is not None:
239
+ update_params['verify_ssl'] = verifySsl
240
+ if timeout is not None:
241
+ update_params['timeout'] = timeout
242
+ if useLabelCache is not None:
243
+ update_params['use_label_cache'] = useLabelCache
244
+ if labelCacheExpiryMinutes is not None:
245
+ update_params['label_cache_expiry_minutes'] = labelCacheExpiryMinutes
246
+ if useCacheFirst is not None:
247
+ update_params['use_cache_first'] = useCacheFirst
248
+ if language is not None:
249
+ update_params['language'] = language
250
+ if cacheDir is not None:
251
+ update_params['cache_dir'] = cacheDir
252
+ if outputFormat is not None:
253
+ update_params['output_format'] = outputFormat
254
+ if credentialSource is not None:
255
+ update_params['credential_source'] = self._convert_credential_source(credentialSource)
256
+
257
+ success = self.profile_manager.update_profile(name, **update_params)
258
+
259
+ # Refresh all clients to ensure they pick up the new profile settings
260
+ clients_refreshed = success
261
+ if success:
262
+ try:
263
+ await self.client_manager.refresh_all_profiles()
264
+ logger.info(f"Refreshed all clients due to profile update: {name}")
265
+ except Exception as client_error:
266
+ logger.warning(f"Failed to refresh clients after profile update: {client_error}")
267
+ clients_refreshed = False
268
+ # Continue with success response, client refresh failure is not critical
269
+
270
+ # Get the updated profile for detailed response
271
+ updated_profile = None
272
+ if success:
273
+ updated_profile = self.profile_manager.get_profile(name)
274
+
275
+ return {
276
+ "profileName": name,
277
+ "updated": success,
278
+ "updatedFields": list(update_params.keys()) if success else [],
279
+ "profile": updated_profile.to_dict() if updated_profile else None,
280
+ "clientsRefreshed": clients_refreshed,
281
+ }
282
+
283
+ except Exception as e:
284
+ logger.error(f"Update profile failed: {e}")
285
+ return {
286
+ "error": str(e),
287
+ "profileName": name,
288
+ "updated": False,
289
+ "attemptedFields": [],
290
+ "clientsRefreshed": False,
291
+ }
292
+
293
+ @self.mcp.tool()
294
+ async def d365fo_delete_profile(profileName: str) -> Dict[str, Any]:
295
+ """Delete a D365FO environment profile.
296
+
297
+ Automatically invalidates all cached client connections since the profile
298
+ is no longer available.
299
+
300
+ Args:
301
+ profileName: Name of the profile to delete
302
+
303
+ Returns:
304
+ Dictionary with deletion result including number of clients invalidated
305
+ """
306
+ try:
307
+ success = self.profile_manager.delete_profile(profileName)
308
+
309
+ # Refresh all clients since the profile is no longer available
310
+ clients_refreshed = success
311
+ if success:
312
+ try:
313
+ await self.client_manager.refresh_all_profiles()
314
+ logger.info(f"Refreshed all clients due to profile deletion: {profileName}")
315
+ except Exception as client_error:
316
+ logger.warning(f"Failed to refresh clients after profile deletion: {client_error}")
317
+ clients_refreshed = False
318
+ # Continue with success response, client refresh failure is not critical
319
+
320
+ return {
321
+ "profileName": profileName,
322
+ "deleted": success,
323
+ "clientsRefreshed": clients_refreshed,
324
+ }
325
+
326
+ except Exception as e:
327
+ logger.error(f"Delete profile failed: {e}")
328
+ return {
329
+ "error": str(e),
330
+ "profileName": profileName,
331
+ "deleted": False,
332
+ "clientsRefreshed": False,
333
+ }
334
+
335
+ @self.mcp.tool()
336
+ async def d365fo_set_default_profile(profileName: str) -> Dict[str, Any]:
337
+ """Set the default D365FO environment profile.
338
+
339
+ Automatically refreshes all cached client connections since changing the default
340
+ profile may affect client resolution for operations that use the default profile.
341
+
342
+ Args:
343
+ profileName: Name of the profile to set as default
344
+
345
+ Returns:
346
+ Dictionary with result including client refresh status
347
+ """
348
+ try:
349
+ success = self.profile_manager.set_default_profile(profileName)
350
+
351
+ # Refresh all clients since the default profile change may affect connections
352
+ clients_refreshed = success
353
+ if success:
354
+ try:
355
+ await self.client_manager.refresh_all_profiles()
356
+ logger.info(f"Refreshed all clients due to default profile change: {profileName}")
357
+ except Exception as client_error:
358
+ logger.warning(f"Failed to refresh clients after default profile change: {client_error}")
359
+ clients_refreshed = False
360
+ # Continue with success response, client refresh failure is not critical
361
+
362
+ return {
363
+ "profileName": profileName,
364
+ "setAsDefault": success,
365
+ "clientsRefreshed": clients_refreshed,
366
+ }
367
+
368
+ except Exception as e:
369
+ logger.error(f"Set default profile failed: {e}")
370
+ return {
371
+ "error": str(e),
372
+ "profileName": profileName,
373
+ "setAsDefault": False,
374
+ "clientsRefreshed": False,
375
+ }
376
+
377
+ @self.mcp.tool()
378
+ async def d365fo_get_default_profile() -> Dict[str, Any]:
379
+ """Get the current default D365FO environment profile.
380
+
381
+ Returns:
382
+ Dictionary with default profile
383
+ """
384
+ try:
385
+ profile = self.profile_manager.get_default_profile()
386
+
387
+ if profile:
388
+ return {"defaultProfile": profile.to_dict()}
389
+ else:
390
+ return {"error": "No default profile set"}
391
+
392
+ except Exception as e:
393
+ logger.error(f"Get default profile failed: {e}")
394
+ return {"error": str(e)}
395
+
396
+ @self.mcp.tool()
397
+ async def d365fo_validate_profile(profileName: str) -> Dict[str, Any]:
398
+ """Validate a D365FO environment profile configuration.
399
+
400
+ Args:
401
+ profileName: Name of the profile to validate
402
+
403
+ Returns:
404
+ Dictionary with validation result
405
+ """
406
+ try:
407
+ profile = self.profile_manager.get_profile(profileName)
408
+ if not profile:
409
+ return {"error": f"Profile '{profileName}' not found", "profileName": profileName, "isValid": False}
410
+
411
+ errors = self.profile_manager.validate_profile(profile)
412
+ is_valid = len(errors) == 0
413
+
414
+ return {"profileName": profileName, "isValid": is_valid, "errors": errors}
415
+
416
+ except Exception as e:
417
+ logger.error(f"Validate profile failed: {e}")
418
+ return {"error": str(e), "profileName": profileName, "isValid": False}
419
+
420
+ @self.mcp.tool()
421
+ async def d365fo_test_profile_connection(profileName: str) -> Dict[str, Any]:
422
+ """Test connection for a specific D365FO environment profile.
423
+
424
+ Args:
425
+ profileName: Name of the profile to test
426
+
427
+ Returns:
428
+ Dictionary with connection test result
429
+ """
430
+ try:
431
+ client = await self.client_manager.get_client(profileName)
432
+ result = await client.test_connection()
433
+
434
+ return {"profileName": profileName, "connectionTest": result}
435
+
436
+ except Exception as e:
437
+ logger.error(f"Test profile connection failed: {e}")
438
+ return {
439
+ "error": str(e),
440
+ "profileName": profileName,
441
+ "connectionSuccessful": False,
442
+ }
443
+
444
+ @self.mcp.tool()
445
+ async def d365fo_clone_profile(
446
+ sourceProfileName: str,
447
+ newProfileName: str,
448
+ description: Optional[str] = None,
449
+ ) -> Dict[str, Any]:
450
+ """Clone an existing D365FO environment profile with optional modifications.
451
+
452
+ Args:
453
+ sourceProfileName: Name of the profile to clone
454
+ newProfileName: Name for the new profile
455
+ description: Description for the new profile
456
+
457
+ Returns:
458
+ Dictionary with cloning result
459
+ """
460
+ try:
461
+ source_profile = self.profile_manager.get_profile(sourceProfileName)
462
+ if not source_profile:
463
+ return {"error": f"Source profile '{sourceProfileName}' not found", "profileName": sourceProfileName}
464
+
465
+ # Prepare overrides
466
+ clone_overrides = {}
467
+ if description is not None:
468
+ clone_overrides['description'] = description
469
+
470
+ # Clone the profile
471
+ new_profile = source_profile.clone(newProfileName, **clone_overrides)
472
+
473
+ # Save the cloned profile
474
+ try:
475
+ self.profile_manager.config_manager.save_profile(new_profile)
476
+ self.profile_manager.config_manager._save_config()
477
+ success = True
478
+ except Exception:
479
+ success = False
480
+
481
+ return {
482
+ "profileName": newProfileName,
483
+ "sourceProfile": sourceProfileName,
484
+ "cloned": success,
485
+ "description": new_profile.description,
486
+ }
487
+
488
+ except Exception as e:
489
+ logger.error(f"Clone profile failed: {e}")
490
+ return {"error": str(e), "sourceProfile": sourceProfileName, "newProfile": newProfileName, "cloned": False}
491
+
492
+ @self.mcp.tool()
493
+ async def d365fo_export_profiles(filePath: str) -> Dict[str, Any]:
494
+ """Export all D365FO environment profiles to a file.
495
+
496
+ Args:
497
+ filePath: Path where to export the profiles
498
+
499
+ Returns:
500
+ Dictionary with export result
501
+ """
502
+ try:
503
+ success = self.profile_manager.export_profiles(filePath)
504
+ profiles = self.profile_manager.list_profiles()
505
+
506
+ return {
507
+ "filePath": filePath,
508
+ "exported": success,
509
+ "profileCount": len(profiles),
510
+ "message": f"Exported {len(profiles)} profiles to {filePath}" if success else "Export failed",
511
+ }
512
+
513
+ except Exception as e:
514
+ logger.error(f"Export profiles failed: {e}")
515
+ return {"error": str(e), "filePath": filePath, "exported": False}
516
+
517
+ @self.mcp.tool()
518
+ async def d365fo_import_profiles(
519
+ filePath: str,
520
+ overwrite: bool = False
521
+ ) -> Dict[str, Any]:
522
+ """Import D365FO environment profiles from a file.
523
+
524
+ Args:
525
+ filePath: Path to the file containing profiles to import
526
+ overwrite: Whether to overwrite existing profiles with the same name
527
+
528
+ Returns:
529
+ Dictionary with import results
530
+ """
531
+ try:
532
+ results = self.profile_manager.import_profiles(filePath, overwrite)
533
+
534
+ successful_imports = [name for name, success in results.items() if success]
535
+ failed_imports = [name for name, success in results.items() if not success]
536
+
537
+ return {
538
+ "filePath": filePath,
539
+ "overwrite": overwrite,
540
+ "totalProfiles": len(results),
541
+ "successfulImports": len(successful_imports),
542
+ "failedImports": len(failed_imports),
543
+ "results": results,
544
+ "successful": successful_imports,
545
+ "failed": failed_imports,
546
+ "message": f"Imported {len(successful_imports)} profiles successfully, {len(failed_imports)} failed",
547
+ }
548
+
549
+ except Exception as e:
550
+ logger.error(f"Import profiles failed: {e}")
551
+ return {"error": str(e), "filePath": filePath, "imported": False}
552
+
553
+ @self.mcp.tool()
554
+ async def d365fo_search_profiles(
555
+ pattern: Optional[str] = None,
556
+ hasCredentialSource: Optional[bool] = None,
557
+ credentialSourceType: Optional[str] = None
558
+ ) -> Dict[str, Any]:
559
+ """Search D365FO environment profiles based on criteria.
560
+
561
+ Args:
562
+ pattern: Pattern to match in profile name, description, or base URL
563
+ hasCredentialSource: Filter by presence of credential source (True=has credential source, False=uses default credentials)
564
+ credentialSourceType: Filter by credential source type ("environment", "keyvault")
565
+
566
+ Returns:
567
+ Dictionary with matching profiles
568
+ """
569
+ try:
570
+ all_profiles = self.profile_manager.list_profiles()
571
+ matching_profiles = []
572
+
573
+ for name, profile in all_profiles.items():
574
+ # Check pattern match
575
+ if pattern:
576
+ pattern_lower = pattern.lower()
577
+ if not any([
578
+ pattern_lower in name.lower(),
579
+ pattern_lower in (profile.description or "").lower(),
580
+ pattern_lower in profile.base_url.lower()
581
+ ]):
582
+ continue
583
+
584
+ # Skip auth mode check as it's no longer a field in Profile
585
+ # Authentication is now handled through credential_source
586
+
587
+ # Check credential source
588
+ if hasCredentialSource is not None:
589
+ has_cred_source = profile.credential_source is not None
590
+ if hasCredentialSource != has_cred_source:
591
+ continue
592
+
593
+ # Check credential source type
594
+ if credentialSourceType is not None:
595
+ if profile.credential_source is None:
596
+ continue # Profile uses default credentials, no source type to match
597
+ if profile.credential_source.source_type != credentialSourceType:
598
+ continue
599
+
600
+ # Profile matches all criteria
601
+ profile_info = {
602
+ "name": profile.name,
603
+ "baseUrl": profile.base_url,
604
+ "description": profile.description,
605
+ "hasCredentialSource": profile.credential_source is not None,
606
+ "authType": "credential_source" if profile.credential_source is not None else "default_credentials",
607
+ }
608
+ if profile.credential_source:
609
+ profile_info["credentialSourceType"] = profile.credential_source.source_type
610
+
611
+ matching_profiles.append(profile_info)
612
+
613
+ return {
614
+ "searchCriteria": {
615
+ "pattern": pattern,
616
+ "hasCredentialSource": hasCredentialSource,
617
+ "credentialSourceType": credentialSourceType,
618
+ },
619
+ "totalMatches": len(matching_profiles),
620
+ "profiles": matching_profiles,
621
+ }
622
+
623
+ except Exception as e:
624
+ logger.error(f"Search profiles failed: {e}")
625
+ return {"error": str(e), "searchCriteria": {"pattern": pattern, "hasCredentialSource": hasCredentialSource, "credentialSourceType": credentialSourceType}}
626
+
627
+ @self.mcp.tool()
628
+ async def d365fo_get_profile_names() -> Dict[str, Any]:
629
+ """Get list of all D365FO environment profile names.
630
+
631
+ Returns:
632
+ Dictionary with profile names
633
+ """
634
+ try:
635
+ profile_names = self.profile_manager.get_profile_names()
636
+ default_profile = self.profile_manager.get_default_profile()
637
+
638
+ return {
639
+ "profileNames": profile_names,
640
+ "totalCount": len(profile_names),
641
+ "defaultProfile": default_profile.name if default_profile else None,
642
+ }
643
+
644
+ except Exception as e:
645
+ logger.error(f"Get profile names failed: {e}")
646
+ return {"error": str(e)}
647
+
648
+ def _convert_credential_source(self, cred_source_data: Dict[str, Any]) -> Any:
649
+ """Convert credential source data from API format to internal format.
650
+
651
+ Args:
652
+ cred_source_data: Credential source data in API format. Examples:
653
+ - Environment variables:
654
+ {
655
+ "sourceType": "environment",
656
+ "clientIdVar": "D365FO_CLIENT_ID", # optional, defaults to D365FO_CLIENT_ID
657
+ "clientSecretVar": "D365FO_CLIENT_SECRET", # optional, defaults to D365FO_CLIENT_SECRET
658
+ "tenantIdVar": "D365FO_TENANT_ID" # optional, defaults to D365FO_TENANT_ID
659
+ }
660
+ - Azure Key Vault:
661
+ {
662
+ "sourceType": "keyvault",
663
+ "vaultUrl": "https://myvault.vault.azure.net/",
664
+ "clientIdSecretName": "client-id", # optional, defaults to D365FO_CLIENT_ID
665
+ "clientSecretSecretName": "client-secret", # optional, defaults to D365FO_CLIENT_SECRET
666
+ "tenantIdSecretName": "tenant-id", # optional, defaults to D365FO_TENANT_ID
667
+ "keyvaultAuthMode": "default", # optional: "default" or "client_secret"
668
+ "keyvaultClientId": "kv-client-id", # required if keyvaultAuthMode is "client_secret"
669
+ "keyvaultClientSecret": "kv-secret", # required if keyvaultAuthMode is "client_secret"
670
+ "keyvaultTenantId": "kv-tenant-id" # required if keyvaultAuthMode is "client_secret"
671
+ }
672
+
673
+ Returns:
674
+ CredentialSource instance
675
+ """
676
+ from d365fo_client.credential_sources import create_credential_source
677
+
678
+ source_type = cred_source_data.get("sourceType")
679
+ if not source_type:
680
+ raise ValueError("Missing sourceType in credential source data")
681
+
682
+ kwargs = {}
683
+
684
+ if source_type == "environment":
685
+ if "clientIdVar" in cred_source_data:
686
+ kwargs["client_id_var"] = cred_source_data["clientIdVar"]
687
+ if "clientSecretVar" in cred_source_data:
688
+ kwargs["client_secret_var"] = cred_source_data["clientSecretVar"]
689
+ if "tenantIdVar" in cred_source_data:
690
+ kwargs["tenant_id_var"] = cred_source_data["tenantIdVar"]
691
+ elif source_type == "keyvault":
692
+ if "vaultUrl" not in cred_source_data:
693
+ raise ValueError("vaultUrl is required for keyvault credential source")
694
+ kwargs["vault_url"] = cred_source_data["vaultUrl"]
695
+
696
+ # Optional parameters
697
+ optional_params = {
698
+ "clientIdSecretName": "client_id_secret_name",
699
+ "clientSecretSecretName": "client_secret_secret_name",
700
+ "tenantIdSecretName": "tenant_id_secret_name",
701
+ "keyvaultAuthMode": "keyvault_auth_mode",
702
+ "keyvaultClientId": "keyvault_client_id",
703
+ "keyvaultClientSecret": "keyvault_client_secret",
704
+ "keyvaultTenantId": "keyvault_tenant_id"
705
+ }
706
+
707
+ for api_param, internal_param in optional_params.items():
708
+ if api_param in cred_source_data:
709
+ kwargs[internal_param] = cred_source_data[api_param]
710
+ else:
711
+ raise ValueError(f"Unsupported credential source type: {source_type}")
712
+
713
+ return create_credential_source(source_type, **kwargs)