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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. d365fo_client/__init__.py +7 -1
  2. d365fo_client/auth.py +9 -21
  3. d365fo_client/cli.py +25 -13
  4. d365fo_client/client.py +8 -4
  5. d365fo_client/config.py +52 -30
  6. d365fo_client/credential_sources.py +5 -0
  7. d365fo_client/main.py +1 -1
  8. d365fo_client/mcp/__init__.py +3 -1
  9. d365fo_client/mcp/auth_server/__init__.py +5 -0
  10. d365fo_client/mcp/auth_server/auth/__init__.py +30 -0
  11. d365fo_client/mcp/auth_server/auth/auth.py +372 -0
  12. d365fo_client/mcp/auth_server/auth/oauth_proxy.py +989 -0
  13. d365fo_client/mcp/auth_server/auth/providers/__init__.py +0 -0
  14. d365fo_client/mcp/auth_server/auth/providers/azure.py +325 -0
  15. d365fo_client/mcp/auth_server/auth/providers/bearer.py +25 -0
  16. d365fo_client/mcp/auth_server/auth/providers/jwt.py +547 -0
  17. d365fo_client/mcp/auth_server/auth/redirect_validation.py +65 -0
  18. d365fo_client/mcp/auth_server/dependencies.py +136 -0
  19. d365fo_client/mcp/client_manager.py +16 -67
  20. d365fo_client/mcp/fastmcp_main.py +358 -0
  21. d365fo_client/mcp/fastmcp_server.py +598 -0
  22. d365fo_client/mcp/fastmcp_utils.py +431 -0
  23. d365fo_client/mcp/main.py +40 -13
  24. d365fo_client/mcp/mixins/__init__.py +24 -0
  25. d365fo_client/mcp/mixins/base_tools_mixin.py +55 -0
  26. d365fo_client/mcp/mixins/connection_tools_mixin.py +50 -0
  27. d365fo_client/mcp/mixins/crud_tools_mixin.py +311 -0
  28. d365fo_client/mcp/mixins/database_tools_mixin.py +685 -0
  29. d365fo_client/mcp/mixins/label_tools_mixin.py +87 -0
  30. d365fo_client/mcp/mixins/metadata_tools_mixin.py +565 -0
  31. d365fo_client/mcp/mixins/performance_tools_mixin.py +109 -0
  32. d365fo_client/mcp/mixins/profile_tools_mixin.py +713 -0
  33. d365fo_client/mcp/mixins/sync_tools_mixin.py +321 -0
  34. d365fo_client/mcp/prompts/action_execution.py +1 -1
  35. d365fo_client/mcp/prompts/sequence_analysis.py +1 -1
  36. d365fo_client/mcp/tools/crud_tools.py +3 -3
  37. d365fo_client/mcp/tools/sync_tools.py +1 -1
  38. d365fo_client/mcp/utilities/__init__.py +1 -0
  39. d365fo_client/mcp/utilities/auth.py +34 -0
  40. d365fo_client/mcp/utilities/logging.py +58 -0
  41. d365fo_client/mcp/utilities/types.py +426 -0
  42. d365fo_client/metadata_v2/sync_manager_v2.py +2 -0
  43. d365fo_client/metadata_v2/sync_session_manager.py +7 -7
  44. d365fo_client/models.py +139 -139
  45. d365fo_client/output.py +2 -2
  46. d365fo_client/profile_manager.py +62 -27
  47. d365fo_client/profiles.py +118 -113
  48. d365fo_client/settings.py +355 -0
  49. d365fo_client/sync_models.py +85 -2
  50. d365fo_client/utils.py +2 -1
  51. {d365fo_client-0.2.4.dist-info → d365fo_client-0.3.0.dist-info}/METADATA +273 -18
  52. d365fo_client-0.3.0.dist-info/RECORD +84 -0
  53. d365fo_client-0.3.0.dist-info/entry_points.txt +4 -0
  54. d365fo_client-0.2.4.dist-info/RECORD +0 -56
  55. d365fo_client-0.2.4.dist-info/entry_points.txt +0 -3
  56. {d365fo_client-0.2.4.dist-info → d365fo_client-0.3.0.dist-info}/WHEEL +0 -0
  57. {d365fo_client-0.2.4.dist-info → d365fo_client-0.3.0.dist-info}/licenses/LICENSE +0 -0
  58. {d365fo_client-0.2.4.dist-info → d365fo_client-0.3.0.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,87 @@
1
+ """Label tools mixin for FastMCP server."""
2
+
3
+ import logging
4
+ from typing import List
5
+
6
+ from .base_tools_mixin import BaseToolsMixin
7
+
8
+ logger = logging.getLogger(__name__)
9
+
10
+
11
+ class LabelToolsMixin(BaseToolsMixin):
12
+ """Label retrieval tools for FastMCP server."""
13
+
14
+ def register_label_tools(self):
15
+ """Register all label tools with FastMCP."""
16
+
17
+ @self.mcp.tool()
18
+ async def d365fo_get_label(
19
+ labelId: str,
20
+ language: str = "en-US",
21
+ profile: str = "default",
22
+ ) -> dict:
23
+ """Get label text by label ID.
24
+
25
+ Args:
26
+ labelId: Label ID (e.g., @SYS1234)
27
+ language: Language code for label text
28
+ fallbackToEnglish: Fallback to English if translation not found
29
+ profile: Optional profile name
30
+
31
+ Returns:
32
+ Dictionary with label text
33
+ """
34
+ try:
35
+ client = await self._get_client(profile)
36
+
37
+ # Get label
38
+ label_text = await client.get_label_text(
39
+ label_id=labelId,
40
+ language=language,
41
+ )
42
+
43
+ return {
44
+ "labelId": labelId,
45
+ "language": language,
46
+ "labelText": label_text,
47
+ }
48
+
49
+ except Exception as e:
50
+ logger.error(f"Get label failed: {e}")
51
+ return {"error": str(e), "labelId": labelId}
52
+
53
+ @self.mcp.tool()
54
+ async def d365fo_get_labels_batch(
55
+ labelIds: List[str],
56
+ language: str = "en-US",
57
+ profile: str = "default",
58
+ ) -> dict:
59
+ """Get multiple labels in a single request.
60
+
61
+ Args:
62
+ labelIds: List of label IDs to retrieve
63
+ language: Language code for label texts
64
+ fallbackToEnglish: Fallback to English if translation not found
65
+ profile: Optional profile name
66
+
67
+ Returns:
68
+ Dictionary with label texts
69
+ """
70
+ try:
71
+ client = await self._get_client(profile)
72
+
73
+ # Get labels batch
74
+ labels = await client.get_labels_batch(
75
+ label_ids=labelIds,
76
+ language=language,
77
+ )
78
+
79
+ return {
80
+ "language": language,
81
+ "totalRequested": len(labelIds),
82
+ "labels": labels,
83
+ }
84
+
85
+ except Exception as e:
86
+ logger.error(f"Get labels batch failed: {e}")
87
+ return {"error": str(e), "labelIds": labelIds}
@@ -0,0 +1,565 @@
1
+ """Metadata tools mixin for FastMCP server."""
2
+
3
+ import logging
4
+ import time
5
+ from typing import List, Optional
6
+ from datetime import datetime
7
+
8
+ from .base_tools_mixin import BaseToolsMixin
9
+
10
+ logger = logging.getLogger(__name__)
11
+
12
+
13
+ class MetadataToolsMixin(BaseToolsMixin):
14
+ """Metadata search and discovery tools for FastMCP server."""
15
+
16
+ def register_metadata_tools(self):
17
+ """Register all metadata tools with FastMCP."""
18
+
19
+ @self.mcp.tool()
20
+ async def d365fo_search_entities(
21
+ pattern: str,
22
+ entity_category: Optional[str] = None,
23
+ data_service_enabled: Optional[bool] = None,
24
+ data_management_enabled: Optional[bool] = None,
25
+ is_read_only: Optional[bool] = None,
26
+ limit: int = 100,
27
+ profile: str = "default",
28
+ ) -> dict:
29
+ """Search for D365 F&O data entities using simple keyword-based search.
30
+
31
+ IMPORTANT: When a user asks for something like "Get data management entities" or "Find customer group entities", break the request into individual keywords and perform MULTIPLE searches, then analyze all results:
32
+
33
+ 1. Extract individual keywords from the request (e.g. "data management entities" → "data", "management", "entities")
34
+ 2. Perform separate searches for each significant keyword using simple text matching
35
+ 3. Combine and analyze results from all searches
36
+ 4. Look for entities that match the combination of concepts
37
+
38
+ SEARCH STRATEGY EXAMPLES:
39
+ - "data management entities" → Search for "data", then "management", then find entities matching both concepts
40
+ - "customer groups" → Search for "customer", then "group", then find intersection
41
+ - "sales orders" → Search for "sales", then "order", then combine results
42
+
43
+ Use simple keywords, not complex patterns. The search will find entities containing those keywords.
44
+
45
+ Args:
46
+ pattern: Simple keyword or text to search for in entity names. Use plain text keywords, not regex patterns. For multi-word requests like 'data management entities': 1) Break into keywords: 'data', 'management' 2) Search for each keyword separately: 'data' then 'management' 3) Run separate searches for each keyword 4) Analyze combined results. Examples: use 'customer' to find customer entities, 'group' to find group entities.
47
+ entity_category: Filter entities by their functional category (e.g., Master, Transaction).
48
+ data_service_enabled: Filter entities that are enabled for OData API access (e.g., for querying).
49
+ data_management_enabled: Filter entities that can be used with the Data Management Framework (DMF).
50
+ is_read_only: Filter entities based on whether they are read-only or support write operations.
51
+ limit: Maximum number of matching entities to return. Use smaller values (10-50) for initial exploration, larger values (100-500) for comprehensive searches.
52
+ profile: Configuration profile to use (optional - uses default profile if not specified)
53
+
54
+ Returns:
55
+ Dictionary with matching entities
56
+ """
57
+ try:
58
+ client = await self._get_client(profile)
59
+
60
+ start_time = time.time()
61
+
62
+ # Use search_data_entities to support all the filtering options
63
+ entities = await client.search_data_entities(
64
+ pattern=pattern,
65
+ entity_category=entity_category,
66
+ data_service_enabled=data_service_enabled,
67
+ data_management_enabled=data_management_enabled,
68
+ is_read_only=is_read_only,
69
+ )
70
+
71
+ # Convert DataEntityInfo objects to dictionaries for JSON serialization
72
+ entity_dicts = []
73
+ for entity in entities:
74
+ entity_dict = entity.to_dict()
75
+ entity_dicts.append(entity_dict)
76
+
77
+ # Apply limit
78
+ filtered_entities = entity_dicts
79
+ if limit is not None:
80
+ filtered_entities = entity_dicts[:limit]
81
+
82
+ # If no results and pattern seems specific, try a broader search for suggestions
83
+ broader_suggestions = []
84
+ fts_suggestions = []
85
+ if len(filtered_entities) == 0:
86
+ try:
87
+ # Try FTS5 search if metadata cache is available
88
+ if hasattr(client, 'metadata_cache') and client.metadata_cache:
89
+ fts_suggestions = await self._try_fts_search(client, pattern)
90
+ except Exception:
91
+ pass # Ignore errors in suggestion search
92
+
93
+ search_time = time.time() - start_time
94
+
95
+ # Add helpful guidance when no results found
96
+ suggestions = []
97
+ if len(filtered_entities) == 0:
98
+ suggestions = [
99
+ "Try broader or simpler keywords to increase matches.",
100
+ "Consider using category filters such as entity_category.",
101
+ "Use data_service_enabled=True to find API-accessible entities.",
102
+ ]
103
+
104
+ # Add FTS-specific suggestions if FTS results were found
105
+ if fts_suggestions:
106
+ suggestions.insert(
107
+ 0,
108
+ f"Found {len(fts_suggestions)} entities using full-text search (see ftsMatches below)",
109
+ )
110
+ elif client.metadata_cache:
111
+ suggestions.append(
112
+ "Full-text search attempted but found no matches - try simpler terms"
113
+ )
114
+
115
+ response = {
116
+ "entities": filtered_entities,
117
+ "totalCount": len(entities),
118
+ "returnedCount": len(filtered_entities),
119
+ "searchTime": round(search_time, 3),
120
+ "pattern": pattern,
121
+ "limit": limit,
122
+ "filters": {
123
+ "entity_Category": entity_category,
124
+ "data_Service_Enabled": data_service_enabled,
125
+ "data_Management_Enabled": data_management_enabled,
126
+ "is_Read_Only": is_read_only,
127
+ },
128
+ "suggestions": suggestions if suggestions else None,
129
+ "broaderMatches": broader_suggestions if broader_suggestions else None,
130
+ "ftsMatches": fts_suggestions if fts_suggestions else None,
131
+ }
132
+
133
+ return response
134
+
135
+ except Exception as e:
136
+ logger.error(f"Search entities failed: {e}")
137
+ return self._create_error_response(e, "d365fo_search_entities", {
138
+ "pattern": pattern,
139
+ "entity_category": entity_category,
140
+ "data_service_enabled": data_service_enabled,
141
+ "data_management_enabled": data_management_enabled,
142
+ "is_read_only": is_read_only,
143
+ "limit": limit,
144
+ "profile": profile
145
+ })
146
+
147
+ @self.mcp.tool()
148
+ async def d365fo_get_entity_schema(
149
+ entityName: str,
150
+ include_properties: bool = True,
151
+ resolve_labels: bool = True,
152
+ language: str = "en-US",
153
+ profile: str = "default",
154
+ ) -> dict:
155
+ """Get the detailed schema for a specific D365 F&O data entity, including properties, keys, and available actions.
156
+
157
+ Args:
158
+ entityName: The public name of the entity (e.g., 'CustomerV3').
159
+ include_properties: Set to true to include detailed information about each property (field) in the entity.
160
+ resolve_labels: Set to true to resolve and include human-readable labels for the entity and its properties.
161
+ language: The language to use for resolving labels (e.g., 'en-US', 'fr-FR').
162
+ profile: Configuration profile to use (optional - uses default profile if not specified)
163
+
164
+ Returns:
165
+ Dictionary with entity schema
166
+ """
167
+ try:
168
+ client = await self._get_client(profile)
169
+
170
+ entity_info = await client.get_public_entity_info(entityName)
171
+
172
+ if not entity_info:
173
+ raise ValueError(f"Entity not found: {entityName}")
174
+
175
+ logger.info(f"Retrieved entity info for {entity_info}")
176
+ entity_info_dict = entity_info.to_dict()
177
+
178
+ return entity_info_dict
179
+
180
+ except Exception as e:
181
+ logger.error(f"Get entity schema failed: {e}")
182
+ return self._create_error_response(e, "d365fo_get_entity_schema", {
183
+ "entityName": entityName,
184
+ "include_properties": include_properties,
185
+ "resolve_labels": resolve_labels,
186
+ "language": language,
187
+ "profile": profile
188
+ })
189
+
190
+ @self.mcp.tool()
191
+ async def d365fo_search_actions(
192
+ pattern: str,
193
+ entityName: Optional[str] = None,
194
+ bindingKind: Optional[str] = None,
195
+ isFunction: Optional[bool] = None,
196
+ limit: int = 100,
197
+ profile: str = "default",
198
+ ) -> dict:
199
+ """Search for available OData actions in D365 F&O using simple keyword-based search.
200
+
201
+ IMPORTANT: When searching for actions, break down user requests into individual keywords and perform MULTIPLE searches:
202
+
203
+ 1. Extract keywords from requests (e.g., \"posting actions\" → \"post\", \"posting\")
204
+ 2. Perform separate searches for each keyword using simple text matching
205
+ 3. Combine and analyze results from all searches
206
+ 4. Look for actions that match the combination of concepts
207
+
208
+ SEARCH STRATEGY EXAMPLES:
209
+ - \"posting actions\" → Search for \"post\", then look for posting-related actions
210
+ - \"validation functions\" → Search for \"valid\" and \"check\", then find validation actions
211
+ - \"workflow actions\" → Search for \"workflow\" and \"approve\", then combine results
212
+
213
+ Use simple keywords, not complex patterns. Actions are operations that can be performed on entities or globally.
214
+
215
+ Args:
216
+ pattern: Simple keyword or text to search for in action names. Use plain text keywords, not regex patterns. For requests like 'posting actions': 1) Extract keywords: 'post', 'posting' 2) Search for each keyword: 'post' 3) Perform multiple searches for related terms 4) Analyze combined results. Use simple text matching.
217
+ entityName: Optional. Filter actions that are bound to a specific data entity (e.g., 'CustomersV3').
218
+ bindingKind: Optional. Filter by binding type: 'Unbound' (can call directly), 'BoundToEntitySet' (operates on entity collections), 'BoundToEntityInstance' (requires specific entity key).
219
+ isFunction: Optional. Filter by type: 'true' for functions (read-only), 'false' for actions (may have side-effects). Note: This filter may not be fully supported yet.
220
+ limit: Maximum number of matching actions to return.
221
+ profile: Configuration profile to use (optional - uses default profile if not specified)
222
+
223
+ Returns:
224
+ Dictionary with matching actions
225
+ """
226
+ try:
227
+ client = await self._get_client(profile)
228
+
229
+ start_time = time.time()
230
+
231
+ # Extract search parameters
232
+ entity_name = entityName
233
+ binding_kind = bindingKind
234
+
235
+ # Search actions with full details
236
+ actions = await client.search_actions(
237
+ pattern=pattern, entity_name=entity_name, binding_kind=binding_kind
238
+ )
239
+
240
+ # Apply limit
241
+ filtered_actions = actions[:limit] if limit is not None else actions
242
+
243
+ # Convert ActionInfo objects to dictionaries for JSON serialization
244
+ detailed_actions = []
245
+ for action in filtered_actions:
246
+ action_dict = action.to_dict()
247
+
248
+ # Add additional metadata for better usability
249
+ action_dict.update(
250
+ {
251
+ "parameter_count": len(action.parameters),
252
+ "has_return_value": action.return_type is not None,
253
+ "return_type_name": (
254
+ action.return_type.type_name if action.return_type else None
255
+ ),
256
+ "is_bound": action.binding_kind != "Unbound",
257
+ "can_call_directly": action.binding_kind == "Unbound",
258
+ "requires_entity_key": action.binding_kind
259
+ == "BoundToEntityInstance",
260
+ }
261
+ )
262
+
263
+ detailed_actions.append(action_dict)
264
+
265
+ search_time = time.time() - start_time
266
+
267
+ response = {
268
+ "actions": detailed_actions,
269
+ "total_count": len(actions),
270
+ "returned_count": len(filtered_actions),
271
+ "search_time": round(search_time, 3),
272
+ "search_parameters": {
273
+ "pattern": pattern,
274
+ "entity_name": entity_name,
275
+ "binding_kind": binding_kind,
276
+ "limit": limit,
277
+ },
278
+ "summary": {
279
+ "unbound_actions": len(
280
+ [a for a in filtered_actions if a.binding_kind == "Unbound"]
281
+ ),
282
+ "entity_set_bound": len(
283
+ [
284
+ a
285
+ for a in filtered_actions
286
+ if a.binding_kind == "BoundToEntitySet"
287
+ ]
288
+ ),
289
+ "entity_instance_bound": len(
290
+ [
291
+ a
292
+ for a in filtered_actions
293
+ if a.binding_kind == "BoundToEntityInstance"
294
+ ]
295
+ ),
296
+ "unique_entities": len(
297
+ set(a.entity_name for a in filtered_actions if a.entity_name)
298
+ ),
299
+ },
300
+ }
301
+
302
+ return response
303
+
304
+ except Exception as e:
305
+ logger.error(f"Search actions failed: {e}")
306
+ return self._create_error_response(e, "d365fo_search_actions", {
307
+ "pattern": pattern,
308
+ "entityName": entityName,
309
+ "bindingKind": bindingKind,
310
+ "isFunction": isFunction,
311
+ "limit": limit,
312
+ "profile": profile
313
+ })
314
+
315
+ @self.mcp.tool()
316
+ async def d365fo_search_enumerations(
317
+ pattern: str, limit: int = 100, profile: str = "default"
318
+ ) -> dict:
319
+ """Search for enumerations (enums) in D365 F&O using simple keyword-based search.
320
+
321
+ IMPORTANT: When searching for enumerations, break down user requests into individual keywords and perform MULTIPLE searches:
322
+
323
+ 1. Extract keywords from requests (e.g., \"customer status enums\" → \"customer\", \"status\")
324
+ 2. Perform separate searches for each keyword using simple text matching
325
+ 3. Combine and analyze results from all searches
326
+ 4. Look for enums that match the combination of concepts
327
+
328
+ SEARCH STRATEGY EXAMPLES:
329
+ - \"customer status enums\" → Search for \"customer\", then \"status\", then find status-related customer enums
330
+ - \"blocking reasons\" → Search for \"block\" and \"reason\", then combine results
331
+ - \"approval states\" → Search for \"approval\" and \"state\", then find approval-related enums
332
+
333
+ Use simple keywords, not complex patterns. Enums represent lists of named constants (e.g., NoYes, CustVendorBlocked).
334
+
335
+ Args:
336
+ pattern: Simple keyword or text to search for in enumeration names. Use plain text keywords, not regex patterns. For requests like 'customer blocking enums': 1) Extract keywords: 'customer', 'blocking' 2) Search for each keyword: 'customer' then 'blocking' 3) Perform multiple searches 4) Analyze combined results. Use simple text matching.
337
+ limit: Maximum number of matching enumerations to return.
338
+ profile: Configuration profile to use (optional - uses default profile if not specified)
339
+
340
+ Returns:
341
+ Dictionary with matching enumerations
342
+ """
343
+ try:
344
+ client = await self._get_client(profile)
345
+
346
+ start_time = time.time()
347
+
348
+ # Search for enumerations using the pattern
349
+ enumerations = await client.search_public_enumerations(
350
+ pattern=pattern
351
+ )
352
+
353
+ # Convert EnumerationInfo objects to dictionaries for JSON serialization
354
+ enum_dicts = []
355
+ for enum in enumerations:
356
+ enum_dict = enum.to_dict()
357
+ enum_dicts.append(enum_dict)
358
+
359
+ # Apply limit
360
+ filtered_enums = enum_dicts if limit is None else enum_dicts[:limit]
361
+ search_time = time.time() - start_time
362
+
363
+ response = {
364
+ "enumerations": filtered_enums,
365
+ "totalCount": len(enumerations),
366
+ "searchTime": round(search_time, 3),
367
+ "pattern": pattern,
368
+ "limit": limit,
369
+ }
370
+
371
+ return response
372
+
373
+ except Exception as e:
374
+ logger.error(f"Search enumerations failed: {e}")
375
+ return self._create_error_response(e, "d365fo_search_enumerations", {
376
+ "pattern": pattern,
377
+ "limit": limit,
378
+ "profile": profile
379
+ })
380
+
381
+ @self.mcp.tool()
382
+ async def d365fo_get_enumeration_fields(
383
+ enumeration_name: str,
384
+ resolve_labels: bool = True,
385
+ language: str = "en-US",
386
+ profile: str = "default",
387
+ ) -> dict:
388
+ """Get the detailed members (fields) and their values for a specific D365 F&O enumeration.
389
+
390
+ Args:
391
+ enumeration_name: The exact name of the enumeration (e.g., 'NoYes', 'CustVendorBlocked').
392
+ resolve_labels: Set to true to resolve and include human-readable labels for the enumeration and its members.
393
+ language: The language to use for resolving labels (e.g., 'en-US', 'fr-FR').
394
+ profile: Configuration profile to use (optional - uses default profile if not specified)
395
+
396
+ Returns:
397
+ Dictionary with enumeration details
398
+ """
399
+ try:
400
+ client = await self._get_client(profile)
401
+
402
+ # Get detailed enumeration information
403
+ enum_info = await client.get_public_enumeration_info(
404
+ enumeration_name=enumeration_name,
405
+ resolve_labels=resolve_labels,
406
+ language=language,
407
+ )
408
+
409
+ if not enum_info:
410
+ raise ValueError(f"Enumeration not found: {enumeration_name}")
411
+
412
+ # Convert to dictionary for JSON serialization
413
+ enum_dict = enum_info.to_dict()
414
+
415
+ # Add additional metadata
416
+ response = {
417
+ "enumeration": enum_dict,
418
+ "memberCount": len(enum_info.members),
419
+ "hasLabels": bool(enum_info.label_text),
420
+ "language": language if resolve_labels else None,
421
+ }
422
+
423
+ return response
424
+
425
+ except Exception as e:
426
+ logger.error(f"Get enumeration fields failed: {e}")
427
+ return self._create_error_response(e, "d365fo_get_enumeration_fields", {
428
+ "enumeration_name": enumeration_name,
429
+ "resolve_labels": resolve_labels,
430
+ "language": language,
431
+ "profile": profile
432
+ })
433
+
434
+ @self.mcp.tool()
435
+ async def d365fo_get_installed_modules(profile: str = "default") -> dict:
436
+ """Get the list of installed modules in the D365 F&O environment with their details including name, version, module ID, publisher, and display name.
437
+
438
+ Args:
439
+ profile: Configuration profile to use (optional - uses default profile if not specified)
440
+
441
+ Returns:
442
+ Dictionary with installed modules
443
+ """
444
+ try:
445
+ client = await self._get_client(profile)
446
+ logger.info("Getting installed modules from D365 F&O environment")
447
+
448
+ # Get the list of installed modules
449
+ modules = await client.get_installed_modules()
450
+
451
+ # Convert to more structured format for better readability
452
+ response = {
453
+ "modules": modules,
454
+ "moduleCount": len(modules),
455
+ "retrievedAt": f"{datetime.now().isoformat()}Z",
456
+ }
457
+
458
+ return response
459
+
460
+ except Exception as e:
461
+ logger.error(f"Get installed modules failed: {e}")
462
+ return self._create_error_response(e, "d365fo_get_installed_modules", {
463
+ "profile": profile
464
+ })
465
+
466
+ async def _try_fts_search(self, client, pattern: str) -> List[dict]:
467
+ """Try FTS5 full-text search when regex search fails
468
+
469
+ Args:
470
+ client: FOClient instance with metadata cache
471
+ pattern: Original search pattern
472
+
473
+ Returns:
474
+ List of entity dictionaries from FTS search
475
+ """
476
+ try:
477
+ # Import here to avoid circular imports
478
+ from ...metadata_v2 import VersionAwareSearchEngine
479
+ from ...models import SearchQuery
480
+
481
+ # Create search engine if metadata cache is available (V2)
482
+ if not hasattr(client, "metadata_cache") or not client.metadata_cache:
483
+ return []
484
+
485
+ # Always use V2 search engine (legacy has been removed)
486
+ if hasattr(client.metadata_cache, "create_search_engine"):
487
+ # V2 cache - use the convenient factory method
488
+ search_engine = client.metadata_cache.create_search_engine()
489
+ else:
490
+ # Fallback - create directly (in case factory method isn't available)
491
+ search_engine = VersionAwareSearchEngine(client.metadata_cache)
492
+
493
+ # Extract search terms from regex pattern
494
+ search_text = self._extract_search_terms(pattern)
495
+ if not search_text:
496
+ return []
497
+
498
+ # Create search query for data entities
499
+ query = SearchQuery(
500
+ text=search_text,
501
+ entity_types=["data_entity"],
502
+ limit=5, # Limit FTS suggestions
503
+ use_fulltext=True,
504
+ )
505
+
506
+ # Execute FTS search
507
+ fts_results = await search_engine.search(query)
508
+
509
+ # Convert search results to entity info
510
+ fts_entities = []
511
+ for result in fts_results.results:
512
+ try:
513
+ # Get full entity info for each FTS result
514
+ entity_info = await client.get_data_entity_info(result.name)
515
+ if entity_info:
516
+ entity_dict = entity_info.to_dict()
517
+ # Add FTS metadata
518
+ entity_dict["fts_relevance"] = result.relevance
519
+ entity_dict["fts_snippet"] = result.snippet
520
+ fts_entities.append(entity_dict)
521
+ except Exception:
522
+ # If entity info retrieval fails, skip this result
523
+ continue
524
+
525
+ return fts_entities
526
+
527
+ except Exception as e:
528
+ logger.debug(f"FTS search failed: {e}")
529
+ return []
530
+
531
+ def _extract_search_terms(self, pattern: str) -> str:
532
+ """Extract meaningful search terms from regex pattern
533
+
534
+ Args:
535
+ pattern: Regex pattern to extract terms from
536
+
537
+ Returns:
538
+ Space-separated search terms
539
+ """
540
+ import re
541
+
542
+ # Remove regex operators and extract word-like terms
543
+ # First, handle character classes like [Cc] -> C, [Gg] -> G
544
+ cleaned = re.sub(
545
+ r"\[([A-Za-z])\1\]",
546
+ r"\1",
547
+ pattern.replace("[Cc]", "C").replace("[Gg]", "G"),
548
+ )
549
+
550
+ # Remove other regex characters
551
+ cleaned = re.sub(r"[.*\\{}()|^$+?]", " ", cleaned)
552
+
553
+ # Extract words (sequences of letters, minimum 3 chars)
554
+ words = re.findall(r"[A-Za-z]{3,}", cleaned)
555
+
556
+ # Remove duplicates while preserving order
557
+ seen = set()
558
+ unique_words = []
559
+ for word in words:
560
+ word_lower = word.lower()
561
+ if word_lower not in seen and len(word) >= 3:
562
+ seen.add(word_lower)
563
+ unique_words.append(word)
564
+
565
+ return " ".join(unique_words[:3]) # Limit to first 3 unique terms