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.
- d365fo_client/__init__.py +7 -1
- d365fo_client/auth.py +9 -21
- d365fo_client/cli.py +25 -13
- d365fo_client/client.py +8 -4
- d365fo_client/config.py +52 -30
- d365fo_client/credential_sources.py +5 -0
- d365fo_client/main.py +1 -1
- d365fo_client/mcp/__init__.py +3 -1
- d365fo_client/mcp/auth_server/__init__.py +5 -0
- d365fo_client/mcp/auth_server/auth/__init__.py +30 -0
- d365fo_client/mcp/auth_server/auth/auth.py +372 -0
- d365fo_client/mcp/auth_server/auth/oauth_proxy.py +989 -0
- d365fo_client/mcp/auth_server/auth/providers/__init__.py +0 -0
- d365fo_client/mcp/auth_server/auth/providers/azure.py +325 -0
- d365fo_client/mcp/auth_server/auth/providers/bearer.py +25 -0
- d365fo_client/mcp/auth_server/auth/providers/jwt.py +547 -0
- d365fo_client/mcp/auth_server/auth/redirect_validation.py +65 -0
- d365fo_client/mcp/auth_server/dependencies.py +136 -0
- d365fo_client/mcp/client_manager.py +16 -67
- d365fo_client/mcp/fastmcp_main.py +358 -0
- d365fo_client/mcp/fastmcp_server.py +598 -0
- d365fo_client/mcp/fastmcp_utils.py +431 -0
- d365fo_client/mcp/main.py +40 -13
- d365fo_client/mcp/mixins/__init__.py +24 -0
- d365fo_client/mcp/mixins/base_tools_mixin.py +55 -0
- d365fo_client/mcp/mixins/connection_tools_mixin.py +50 -0
- d365fo_client/mcp/mixins/crud_tools_mixin.py +311 -0
- d365fo_client/mcp/mixins/database_tools_mixin.py +685 -0
- d365fo_client/mcp/mixins/label_tools_mixin.py +87 -0
- d365fo_client/mcp/mixins/metadata_tools_mixin.py +565 -0
- d365fo_client/mcp/mixins/performance_tools_mixin.py +109 -0
- d365fo_client/mcp/mixins/profile_tools_mixin.py +713 -0
- d365fo_client/mcp/mixins/sync_tools_mixin.py +321 -0
- d365fo_client/mcp/prompts/action_execution.py +1 -1
- d365fo_client/mcp/prompts/sequence_analysis.py +1 -1
- d365fo_client/mcp/tools/crud_tools.py +3 -3
- d365fo_client/mcp/tools/sync_tools.py +1 -1
- d365fo_client/mcp/utilities/__init__.py +1 -0
- d365fo_client/mcp/utilities/auth.py +34 -0
- d365fo_client/mcp/utilities/logging.py +58 -0
- d365fo_client/mcp/utilities/types.py +426 -0
- d365fo_client/metadata_v2/sync_manager_v2.py +2 -0
- d365fo_client/metadata_v2/sync_session_manager.py +7 -7
- d365fo_client/models.py +139 -139
- d365fo_client/output.py +2 -2
- d365fo_client/profile_manager.py +62 -27
- d365fo_client/profiles.py +118 -113
- d365fo_client/settings.py +355 -0
- d365fo_client/sync_models.py +85 -2
- d365fo_client/utils.py +2 -1
- {d365fo_client-0.2.4.dist-info → d365fo_client-0.3.0.dist-info}/METADATA +273 -18
- d365fo_client-0.3.0.dist-info/RECORD +84 -0
- d365fo_client-0.3.0.dist-info/entry_points.txt +4 -0
- d365fo_client-0.2.4.dist-info/RECORD +0 -56
- d365fo_client-0.2.4.dist-info/entry_points.txt +0 -3
- {d365fo_client-0.2.4.dist-info → d365fo_client-0.3.0.dist-info}/WHEEL +0 -0
- {d365fo_client-0.2.4.dist-info → d365fo_client-0.3.0.dist-info}/licenses/LICENSE +0 -0
- {d365fo_client-0.2.4.dist-info → d365fo_client-0.3.0.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)
|