d365fo-client 0.1.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 +305 -0
- d365fo_client/auth.py +93 -0
- d365fo_client/cli.py +700 -0
- d365fo_client/client.py +1454 -0
- d365fo_client/config.py +304 -0
- d365fo_client/crud.py +200 -0
- d365fo_client/exceptions.py +49 -0
- d365fo_client/labels.py +528 -0
- d365fo_client/main.py +502 -0
- d365fo_client/mcp/__init__.py +16 -0
- d365fo_client/mcp/client_manager.py +276 -0
- d365fo_client/mcp/main.py +98 -0
- d365fo_client/mcp/models.py +371 -0
- d365fo_client/mcp/prompts/__init__.py +43 -0
- d365fo_client/mcp/prompts/action_execution.py +480 -0
- d365fo_client/mcp/prompts/sequence_analysis.py +349 -0
- d365fo_client/mcp/resources/__init__.py +15 -0
- d365fo_client/mcp/resources/database_handler.py +555 -0
- d365fo_client/mcp/resources/entity_handler.py +176 -0
- d365fo_client/mcp/resources/environment_handler.py +132 -0
- d365fo_client/mcp/resources/metadata_handler.py +283 -0
- d365fo_client/mcp/resources/query_handler.py +135 -0
- d365fo_client/mcp/server.py +432 -0
- d365fo_client/mcp/tools/__init__.py +17 -0
- d365fo_client/mcp/tools/connection_tools.py +175 -0
- d365fo_client/mcp/tools/crud_tools.py +579 -0
- d365fo_client/mcp/tools/database_tools.py +813 -0
- d365fo_client/mcp/tools/label_tools.py +189 -0
- d365fo_client/mcp/tools/metadata_tools.py +766 -0
- d365fo_client/mcp/tools/profile_tools.py +706 -0
- d365fo_client/metadata_api.py +793 -0
- d365fo_client/metadata_v2/__init__.py +59 -0
- d365fo_client/metadata_v2/cache_v2.py +1372 -0
- d365fo_client/metadata_v2/database_v2.py +585 -0
- d365fo_client/metadata_v2/global_version_manager.py +573 -0
- d365fo_client/metadata_v2/search_engine_v2.py +423 -0
- d365fo_client/metadata_v2/sync_manager_v2.py +819 -0
- d365fo_client/metadata_v2/version_detector.py +439 -0
- d365fo_client/models.py +862 -0
- d365fo_client/output.py +181 -0
- d365fo_client/profile_manager.py +342 -0
- d365fo_client/profiles.py +178 -0
- d365fo_client/query.py +162 -0
- d365fo_client/session.py +60 -0
- d365fo_client/utils.py +196 -0
- d365fo_client-0.1.0.dist-info/METADATA +1084 -0
- d365fo_client-0.1.0.dist-info/RECORD +51 -0
- d365fo_client-0.1.0.dist-info/WHEEL +5 -0
- d365fo_client-0.1.0.dist-info/entry_points.txt +3 -0
- d365fo_client-0.1.0.dist-info/licenses/LICENSE +21 -0
- d365fo_client-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,766 @@
|
|
1
|
+
"""Metadata tools for MCP server."""
|
2
|
+
|
3
|
+
import json
|
4
|
+
import logging
|
5
|
+
import time
|
6
|
+
from datetime import datetime
|
7
|
+
from typing import List
|
8
|
+
|
9
|
+
from mcp import Tool
|
10
|
+
from mcp.types import TextContent
|
11
|
+
|
12
|
+
from ..client_manager import D365FOClientManager
|
13
|
+
|
14
|
+
logger = logging.getLogger(__name__)
|
15
|
+
|
16
|
+
|
17
|
+
class MetadataTools:
|
18
|
+
"""Metadata management tools for the MCP server."""
|
19
|
+
|
20
|
+
def __init__(self, client_manager: D365FOClientManager):
|
21
|
+
"""Initialize metadata tools.
|
22
|
+
|
23
|
+
Args:
|
24
|
+
client_manager: D365FO client manager instance
|
25
|
+
"""
|
26
|
+
self.client_manager = client_manager
|
27
|
+
|
28
|
+
def get_tools(self) -> List[Tool]:
|
29
|
+
"""Get list of metadata tools.
|
30
|
+
|
31
|
+
Returns:
|
32
|
+
List of Tool definitions
|
33
|
+
"""
|
34
|
+
return [
|
35
|
+
self._get_search_entities_tool(),
|
36
|
+
self._get_entity_schema_tool(),
|
37
|
+
self._get_search_actions_tool(),
|
38
|
+
self._get_search_enumerations_tool(),
|
39
|
+
self._get_enumeration_fields_tool(),
|
40
|
+
self._get_installed_modules_tool(),
|
41
|
+
]
|
42
|
+
|
43
|
+
def _get_search_entities_tool(self) -> Tool:
|
44
|
+
"""Get search entities tool definition."""
|
45
|
+
return Tool(
|
46
|
+
name="d365fo_search_entities",
|
47
|
+
description="""Search for D365 F&O data entities using simple keyword-based search.
|
48
|
+
|
49
|
+
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:
|
50
|
+
|
51
|
+
1. Extract individual keywords from the request (e.g., "data management entities" → "data", "management", "entities")
|
52
|
+
2. Perform separate searches for each significant keyword using simple text matching
|
53
|
+
3. Combine and analyze results from all searches
|
54
|
+
4. Look for entities that match the combination of concepts
|
55
|
+
|
56
|
+
SEARCH STRATEGY EXAMPLES:
|
57
|
+
- "data management entities" → Search for "data", then "management", then find entities matching both concepts
|
58
|
+
- "customer groups" → Search for "customer", then "group", then find intersection
|
59
|
+
- "sales orders" → Search for "sales", then "order", then combine results
|
60
|
+
|
61
|
+
Use simple keywords, not complex patterns. The search will find entities containing those keywords.""",
|
62
|
+
inputSchema={
|
63
|
+
"type": "object",
|
64
|
+
"properties": {
|
65
|
+
"pattern": {
|
66
|
+
"type": "string",
|
67
|
+
"description": "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.",
|
68
|
+
},
|
69
|
+
"entity_category": {
|
70
|
+
"type": "string",
|
71
|
+
"description": "Filter entities by their functional category (e.g., Master, Transaction).",
|
72
|
+
"enum": [
|
73
|
+
"Master",
|
74
|
+
"Document",
|
75
|
+
"Transaction",
|
76
|
+
"Reference",
|
77
|
+
"Parameter",
|
78
|
+
],
|
79
|
+
},
|
80
|
+
"data_service_enabled": {
|
81
|
+
"type": "boolean",
|
82
|
+
"description": "Filter entities that are enabled for OData API access (e.g., for querying).",
|
83
|
+
},
|
84
|
+
"data_management_enabled": {
|
85
|
+
"type": "boolean",
|
86
|
+
"description": "Filter entities that can be used with the Data Management Framework (DMF).",
|
87
|
+
},
|
88
|
+
"is_read_only": {
|
89
|
+
"type": "boolean",
|
90
|
+
"description": "Filter entities based on whether they are read-only or support write operations.",
|
91
|
+
},
|
92
|
+
"limit": {
|
93
|
+
"type": "integer",
|
94
|
+
"minimum": 1,
|
95
|
+
"maximum": 500,
|
96
|
+
"default": 100,
|
97
|
+
"description": "Maximum number of matching entities to return. Use smaller values (10-50) for initial exploration, larger values (100-500) for comprehensive searches.",
|
98
|
+
},
|
99
|
+
"profile": {
|
100
|
+
"type": "string",
|
101
|
+
"description": "Configuration profile to use (optional - uses default profile if not specified)",
|
102
|
+
},
|
103
|
+
},
|
104
|
+
"required": ["pattern"],
|
105
|
+
},
|
106
|
+
)
|
107
|
+
|
108
|
+
def _get_entity_schema_tool(self) -> Tool:
|
109
|
+
"""Get entity schema tool definition."""
|
110
|
+
return Tool(
|
111
|
+
name="d365fo_get_entity_schema",
|
112
|
+
description="Get the detailed schema for a specific D365 F&O data entity, including properties, keys, and available actions.",
|
113
|
+
inputSchema={
|
114
|
+
"type": "object",
|
115
|
+
"properties": {
|
116
|
+
"entityName": {
|
117
|
+
"type": "string",
|
118
|
+
"description": "The public name of the entity (e.g., 'CustomersV3').",
|
119
|
+
},
|
120
|
+
"include_properties": {
|
121
|
+
"type": "boolean",
|
122
|
+
"default": True,
|
123
|
+
"description": "Set to true to include detailed information about each property (field) in the entity.",
|
124
|
+
},
|
125
|
+
"resolve_labels": {
|
126
|
+
"type": "boolean",
|
127
|
+
"default": True,
|
128
|
+
"description": "Set to true to resolve and include human-readable labels for the entity and its properties.",
|
129
|
+
},
|
130
|
+
"language": {
|
131
|
+
"type": "string",
|
132
|
+
"default": "en-US",
|
133
|
+
"description": "The language to use for resolving labels (e.g., 'en-US', 'fr-FR').",
|
134
|
+
},
|
135
|
+
"profile": {
|
136
|
+
"type": "string",
|
137
|
+
"description": "Configuration profile to use (optional - uses default profile if not specified)",
|
138
|
+
},
|
139
|
+
},
|
140
|
+
"required": ["entityName"],
|
141
|
+
},
|
142
|
+
)
|
143
|
+
|
144
|
+
def _get_search_actions_tool(self) -> Tool:
|
145
|
+
"""Get search actions tool definition."""
|
146
|
+
return Tool(
|
147
|
+
name="d365fo_search_actions",
|
148
|
+
description="""Search for available OData actions in D365 F&O using simple keyword-based search.
|
149
|
+
|
150
|
+
IMPORTANT: When searching for actions, break down user requests into individual keywords and perform MULTIPLE searches:
|
151
|
+
|
152
|
+
1. Extract keywords from requests (e.g., "posting actions" → "post", "posting")
|
153
|
+
2. Perform separate searches for each keyword using simple text matching
|
154
|
+
3. Combine and analyze results from all searches
|
155
|
+
4. Look for actions that match the combination of concepts
|
156
|
+
|
157
|
+
SEARCH STRATEGY EXAMPLES:
|
158
|
+
- "posting actions" → Search for "post", then look for posting-related actions
|
159
|
+
- "validation functions" → Search for "valid" and "check", then find validation actions
|
160
|
+
- "workflow actions" → Search for "workflow" and "approve", then combine results
|
161
|
+
|
162
|
+
Use simple keywords, not complex patterns. Actions are operations that can be performed on entities or globally.""",
|
163
|
+
inputSchema={
|
164
|
+
"type": "object",
|
165
|
+
"properties": {
|
166
|
+
"pattern": {
|
167
|
+
"type": "string",
|
168
|
+
"description": "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.",
|
169
|
+
},
|
170
|
+
"entityName": {
|
171
|
+
"type": "string",
|
172
|
+
"description": "Optional. Filter actions that are bound to a specific data entity (e.g., 'CustomersV3').",
|
173
|
+
},
|
174
|
+
"bindingKind": {
|
175
|
+
"type": "string",
|
176
|
+
"description": "Optional. Filter by binding type: 'Unbound' (can call directly), 'BoundToEntitySet' (operates on entity collections), 'BoundToEntityInstance' (requires specific entity key).",
|
177
|
+
"enum": [
|
178
|
+
"Unbound",
|
179
|
+
"BoundToEntitySet",
|
180
|
+
"BoundToEntityInstance",
|
181
|
+
],
|
182
|
+
},
|
183
|
+
"isFunction": {
|
184
|
+
"type": "boolean",
|
185
|
+
"description": "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.",
|
186
|
+
},
|
187
|
+
"limit": {
|
188
|
+
"type": "integer",
|
189
|
+
"minimum": 1,
|
190
|
+
"maximum": 500,
|
191
|
+
"default": 100,
|
192
|
+
"description": "Maximum number of matching actions to return.",
|
193
|
+
},
|
194
|
+
"profile": {
|
195
|
+
"type": "string",
|
196
|
+
"description": "Configuration profile to use (optional - uses default profile if not specified)",
|
197
|
+
},
|
198
|
+
},
|
199
|
+
"required": ["pattern"],
|
200
|
+
},
|
201
|
+
)
|
202
|
+
|
203
|
+
def _get_search_enumerations_tool(self) -> Tool:
|
204
|
+
"""Get search enumerations tool definition."""
|
205
|
+
return Tool(
|
206
|
+
name="d365fo_search_enumerations",
|
207
|
+
description="""Search for enumerations (enums) in D365 F&O using simple keyword-based search.
|
208
|
+
|
209
|
+
IMPORTANT: When searching for enumerations, break down user requests into individual keywords and perform MULTIPLE searches:
|
210
|
+
|
211
|
+
1. Extract keywords from requests (e.g., "customer status enums" → "customer", "status")
|
212
|
+
2. Perform separate searches for each keyword using simple text matching
|
213
|
+
3. Combine and analyze results from all searches
|
214
|
+
4. Look for enums that match the combination of concepts
|
215
|
+
|
216
|
+
SEARCH STRATEGY EXAMPLES:
|
217
|
+
- "customer status enums" → Search for "customer", then "status", then find status-related customer enums
|
218
|
+
- "blocking reasons" → Search for "block" and "reason", then combine results
|
219
|
+
- "approval states" → Search for "approval" and "state", then find approval-related enums
|
220
|
+
|
221
|
+
Use simple keywords, not complex patterns. Enums represent lists of named constants (e.g., NoYes, CustVendorBlocked).""",
|
222
|
+
inputSchema={
|
223
|
+
"type": "object",
|
224
|
+
"properties": {
|
225
|
+
"pattern": {
|
226
|
+
"type": "string",
|
227
|
+
"description": "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.",
|
228
|
+
},
|
229
|
+
"limit": {
|
230
|
+
"type": "integer",
|
231
|
+
"minimum": 1,
|
232
|
+
"maximum": 500,
|
233
|
+
"default": 100,
|
234
|
+
"description": "Maximum number of matching enumerations to return.",
|
235
|
+
},
|
236
|
+
"profile": {
|
237
|
+
"type": "string",
|
238
|
+
"description": "Configuration profile to use (optional - uses default profile if not specified)",
|
239
|
+
},
|
240
|
+
},
|
241
|
+
"required": ["pattern"],
|
242
|
+
},
|
243
|
+
)
|
244
|
+
|
245
|
+
def _get_enumeration_fields_tool(self) -> Tool:
|
246
|
+
"""Get enumeration fields tool definition."""
|
247
|
+
return Tool(
|
248
|
+
name="d365fo_get_enumeration_fields",
|
249
|
+
description="Get the detailed members (fields) and their values for a specific D365 F&O enumeration.",
|
250
|
+
inputSchema={
|
251
|
+
"type": "object",
|
252
|
+
"properties": {
|
253
|
+
"enumeration_name": {
|
254
|
+
"type": "string",
|
255
|
+
"description": "The exact name of the enumeration (e.g., 'NoYes', 'CustVendorBlocked').",
|
256
|
+
},
|
257
|
+
"resolve_labels": {
|
258
|
+
"type": "boolean",
|
259
|
+
"default": True,
|
260
|
+
"description": "Set to true to resolve and include human-readable labels for the enumeration and its members.",
|
261
|
+
},
|
262
|
+
"language": {
|
263
|
+
"type": "string",
|
264
|
+
"default": "en-US",
|
265
|
+
"description": "The language to use for resolving labels (e.g., 'en-US', 'fr-FR').",
|
266
|
+
},
|
267
|
+
"profile": {
|
268
|
+
"type": "string",
|
269
|
+
"description": "Configuration profile to use (optional - uses default profile if not specified)",
|
270
|
+
},
|
271
|
+
},
|
272
|
+
"required": ["enumeration_name"],
|
273
|
+
},
|
274
|
+
)
|
275
|
+
|
276
|
+
def _get_installed_modules_tool(self) -> Tool:
|
277
|
+
"""Get installed modules tool definition."""
|
278
|
+
return Tool(
|
279
|
+
name="d365fo_get_installed_modules",
|
280
|
+
description="Get the list of installed modules in the D365 F&O environment with their details including name, version, module ID, publisher, and display name.",
|
281
|
+
inputSchema={
|
282
|
+
"type": "object",
|
283
|
+
"properties": {
|
284
|
+
"profile": {
|
285
|
+
"type": "string",
|
286
|
+
"description": "Configuration profile to use (optional - uses default profile if not specified)",
|
287
|
+
},
|
288
|
+
},
|
289
|
+
"additionalProperties": False,
|
290
|
+
},
|
291
|
+
)
|
292
|
+
|
293
|
+
async def _try_fts_search(self, client, pattern: str) -> List[dict]:
|
294
|
+
"""Try FTS5 full-text search when regex search fails
|
295
|
+
|
296
|
+
Args:
|
297
|
+
client: FOClient instance with metadata cache
|
298
|
+
pattern: Original search pattern
|
299
|
+
|
300
|
+
Returns:
|
301
|
+
List of entity dictionaries from FTS search
|
302
|
+
"""
|
303
|
+
try:
|
304
|
+
# Import here to avoid circular imports
|
305
|
+
from ...metadata_v2 import VersionAwareSearchEngine
|
306
|
+
from ...models import SearchQuery
|
307
|
+
|
308
|
+
# Create search engine if metadata cache is available (V2)
|
309
|
+
if not hasattr(client, "metadata_cache") or not client.metadata_cache:
|
310
|
+
return []
|
311
|
+
|
312
|
+
# Always use V2 search engine (legacy has been removed)
|
313
|
+
if hasattr(client.metadata_cache, "create_search_engine"):
|
314
|
+
# V2 cache - use the convenient factory method
|
315
|
+
search_engine = client.metadata_cache.create_search_engine()
|
316
|
+
else:
|
317
|
+
# Fallback - create directly (in case factory method isn't available)
|
318
|
+
search_engine = VersionAwareSearchEngine(client.metadata_cache)
|
319
|
+
|
320
|
+
# Extract search terms from regex pattern
|
321
|
+
search_text = self._extract_search_terms(pattern)
|
322
|
+
if not search_text:
|
323
|
+
return []
|
324
|
+
|
325
|
+
# Create search query for data entities
|
326
|
+
query = SearchQuery(
|
327
|
+
text=search_text,
|
328
|
+
entity_types=["data_entity"],
|
329
|
+
limit=5, # Limit FTS suggestions
|
330
|
+
use_fulltext=True,
|
331
|
+
)
|
332
|
+
|
333
|
+
# Execute FTS search
|
334
|
+
fts_results = await search_engine.search(query)
|
335
|
+
|
336
|
+
# Convert search results to entity info
|
337
|
+
fts_entities = []
|
338
|
+
for result in fts_results.results:
|
339
|
+
try:
|
340
|
+
# Get full entity info for each FTS result
|
341
|
+
entity_info = await client.get_data_entity_info(result.name)
|
342
|
+
if entity_info:
|
343
|
+
entity_dict = entity_info.to_dict()
|
344
|
+
# Add FTS metadata
|
345
|
+
entity_dict["fts_relevance"] = result.relevance
|
346
|
+
entity_dict["fts_snippet"] = result.snippet
|
347
|
+
fts_entities.append(entity_dict)
|
348
|
+
except Exception:
|
349
|
+
# If entity info retrieval fails, skip this result
|
350
|
+
continue
|
351
|
+
|
352
|
+
return fts_entities
|
353
|
+
|
354
|
+
except Exception as e:
|
355
|
+
logger.debug(f"FTS search failed: {e}")
|
356
|
+
return []
|
357
|
+
|
358
|
+
def _extract_search_terms(self, pattern: str) -> str:
|
359
|
+
"""Extract meaningful search terms from regex pattern
|
360
|
+
|
361
|
+
Args:
|
362
|
+
pattern: Regex pattern to extract terms from
|
363
|
+
|
364
|
+
Returns:
|
365
|
+
Space-separated search terms
|
366
|
+
"""
|
367
|
+
import re
|
368
|
+
|
369
|
+
# Remove regex operators and extract word-like terms
|
370
|
+
# First, handle character classes like [Cc] -> C, [Gg] -> G
|
371
|
+
cleaned = re.sub(
|
372
|
+
r"\[([A-Za-z])\1\]",
|
373
|
+
r"\1",
|
374
|
+
pattern.replace("[Cc]", "C").replace("[Gg]", "G"),
|
375
|
+
)
|
376
|
+
|
377
|
+
# Remove other regex characters
|
378
|
+
cleaned = re.sub(r"[.*\\{}()|^$+?]", " ", cleaned)
|
379
|
+
|
380
|
+
# Extract words (sequences of letters, minimum 3 chars)
|
381
|
+
words = re.findall(r"[A-Za-z]{3,}", cleaned)
|
382
|
+
|
383
|
+
# Remove duplicates while preserving order
|
384
|
+
seen = set()
|
385
|
+
unique_words = []
|
386
|
+
for word in words:
|
387
|
+
word_lower = word.lower()
|
388
|
+
if word_lower not in seen and len(word) >= 3:
|
389
|
+
seen.add(word_lower)
|
390
|
+
unique_words.append(word)
|
391
|
+
|
392
|
+
return " ".join(unique_words[:3]) # Limit to first 3 unique terms
|
393
|
+
|
394
|
+
async def execute_search_entities(self, arguments: dict) -> List[TextContent]:
|
395
|
+
"""Execute search entities tool.
|
396
|
+
|
397
|
+
Args:
|
398
|
+
arguments: Tool arguments
|
399
|
+
|
400
|
+
Returns:
|
401
|
+
List of TextContent responses
|
402
|
+
"""
|
403
|
+
try:
|
404
|
+
profile = arguments.get("profile", "default")
|
405
|
+
client = await self.client_manager.get_client(profile)
|
406
|
+
|
407
|
+
start_time = time.time()
|
408
|
+
|
409
|
+
# Use search_data_entities to support all the filtering options
|
410
|
+
entities = await client.search_data_entities(
|
411
|
+
pattern=arguments["pattern"],
|
412
|
+
entity_category=arguments.get("entity_category"),
|
413
|
+
data_service_enabled=arguments.get("data_service_enabled"),
|
414
|
+
data_management_enabled=arguments.get(
|
415
|
+
"data_management_enabled"
|
416
|
+
), # Add this for completeness
|
417
|
+
is_read_only=arguments.get("is_read_only"),
|
418
|
+
)
|
419
|
+
|
420
|
+
# Convert DataEntityInfo objects to dictionaries for JSON serialization
|
421
|
+
entity_dicts = []
|
422
|
+
for entity in entities:
|
423
|
+
entity_dict = entity.to_dict()
|
424
|
+
entity_dicts.append(entity_dict)
|
425
|
+
|
426
|
+
# Apply limit
|
427
|
+
limit = arguments.get("limit")
|
428
|
+
filtered_entities = entity_dicts
|
429
|
+
if limit is not None:
|
430
|
+
filtered_entities = entity_dicts[:limit]
|
431
|
+
|
432
|
+
# If no results and pattern seems specific, try a broader search for suggestions
|
433
|
+
broader_suggestions = []
|
434
|
+
fts_suggestions = []
|
435
|
+
if len(filtered_entities) == 0:
|
436
|
+
try:
|
437
|
+
# Try FTS5 search if metadata cache is available
|
438
|
+
if client.metadata_cache:
|
439
|
+
fts_suggestions = await self._try_fts_search(
|
440
|
+
client, arguments["pattern"]
|
441
|
+
)
|
442
|
+
|
443
|
+
except Exception:
|
444
|
+
pass # Ignore errors in suggestion search
|
445
|
+
|
446
|
+
search_time = time.time() - start_time
|
447
|
+
|
448
|
+
# Add helpful guidance when no results found
|
449
|
+
suggestions = []
|
450
|
+
if len(filtered_entities) == 0:
|
451
|
+
suggestions = [
|
452
|
+
"Try broader or simpler keywords to increase matches.",
|
453
|
+
"Consider using category filters such as entity_category.",
|
454
|
+
"Use data_service_enabled=True to find API-accessible entities.",
|
455
|
+
]
|
456
|
+
|
457
|
+
# Add FTS-specific suggestions if FTS results were found
|
458
|
+
if fts_suggestions:
|
459
|
+
suggestions.insert(
|
460
|
+
0,
|
461
|
+
f"Found {len(fts_suggestions)} entities using full-text search (see ftsMatches below)",
|
462
|
+
)
|
463
|
+
elif client.metadata_cache:
|
464
|
+
suggestions.append(
|
465
|
+
"Full-text search attempted but found no matches - try simpler terms"
|
466
|
+
)
|
467
|
+
|
468
|
+
response = {
|
469
|
+
"entities": filtered_entities,
|
470
|
+
"totalCount": len(entities),
|
471
|
+
"returnedCount": len(filtered_entities),
|
472
|
+
"searchTime": round(search_time, 3),
|
473
|
+
"pattern": arguments["pattern"],
|
474
|
+
"limit": limit,
|
475
|
+
"filters": {
|
476
|
+
"entity_Category": arguments.get("entity_Category"),
|
477
|
+
"data_Service_Enabled": arguments.get("data_Service_Enabled"),
|
478
|
+
"data_Management_Enabled": arguments.get("data_Management_Enabled"),
|
479
|
+
"is_Read_Only": arguments.get("is_Read_Only"),
|
480
|
+
},
|
481
|
+
"suggestions": suggestions if suggestions else None,
|
482
|
+
"broaderMatches": broader_suggestions if broader_suggestions else None,
|
483
|
+
"ftsMatches": fts_suggestions if fts_suggestions else None,
|
484
|
+
}
|
485
|
+
|
486
|
+
return [TextContent(type="text", text=json.dumps(response, indent=2))]
|
487
|
+
|
488
|
+
except Exception as e:
|
489
|
+
logger.error(f"Search entities failed: {e}")
|
490
|
+
error_response = {
|
491
|
+
"error": str(e),
|
492
|
+
"tool": "d365fo_search_entities",
|
493
|
+
"arguments": arguments,
|
494
|
+
}
|
495
|
+
return [TextContent(type="text", text=json.dumps(error_response, indent=2))]
|
496
|
+
|
497
|
+
async def execute_get_entity_schema(self, arguments: dict) -> List[TextContent]:
|
498
|
+
"""Execute get entity schema tool.
|
499
|
+
|
500
|
+
Args:
|
501
|
+
arguments: Tool arguments
|
502
|
+
|
503
|
+
Returns:
|
504
|
+
List of TextContent responses
|
505
|
+
"""
|
506
|
+
try:
|
507
|
+
profile = arguments.get("profile", "default")
|
508
|
+
client = await self.client_manager.get_client(profile)
|
509
|
+
|
510
|
+
entity_name = arguments["entityName"]
|
511
|
+
entity_info = await client.get_public_entity_info(entity_name)
|
512
|
+
|
513
|
+
if not entity_info:
|
514
|
+
raise ValueError(f"Entity not found: {entity_name}")
|
515
|
+
|
516
|
+
logger.info(f"Retrieved entity info for {entity_info}")
|
517
|
+
entity_info_dict = entity_info.to_dict()
|
518
|
+
|
519
|
+
return [
|
520
|
+
TextContent(type="text", text=json.dumps(entity_info_dict, indent=2))
|
521
|
+
]
|
522
|
+
|
523
|
+
except Exception as e:
|
524
|
+
logger.error(f"Get entity schema failed: {e}")
|
525
|
+
error_response = {
|
526
|
+
"error": str(e),
|
527
|
+
"tool": "d365fo_get_entity_schema",
|
528
|
+
"arguments": arguments,
|
529
|
+
}
|
530
|
+
return [TextContent(type="text", text=json.dumps(error_response, indent=2))]
|
531
|
+
|
532
|
+
async def execute_search_actions(self, arguments: dict) -> List[TextContent]:
|
533
|
+
"""Execute search actions tool.
|
534
|
+
|
535
|
+
Args:
|
536
|
+
arguments: Tool arguments
|
537
|
+
|
538
|
+
Returns:
|
539
|
+
List of TextContent responses
|
540
|
+
"""
|
541
|
+
try:
|
542
|
+
profile = arguments.get("profile", "default")
|
543
|
+
client = await self.client_manager.get_client(profile)
|
544
|
+
|
545
|
+
start_time = time.time()
|
546
|
+
|
547
|
+
# Extract search parameters
|
548
|
+
pattern = arguments["pattern"]
|
549
|
+
entity_name = arguments.get("entityName")
|
550
|
+
binding_kind = arguments.get("bindingKind")
|
551
|
+
|
552
|
+
# Search actions with full details
|
553
|
+
actions = await client.search_actions(
|
554
|
+
pattern=pattern, entity_name=entity_name, binding_kind=binding_kind
|
555
|
+
)
|
556
|
+
|
557
|
+
# Apply limit
|
558
|
+
limit = arguments.get("limit")
|
559
|
+
filtered_actions = actions[:limit] if limit is not None else actions
|
560
|
+
|
561
|
+
# Convert ActionInfo objects to dictionaries for JSON serialization
|
562
|
+
detailed_actions = []
|
563
|
+
for action in filtered_actions:
|
564
|
+
action_dict = action.to_dict()
|
565
|
+
|
566
|
+
# Add additional metadata for better usability
|
567
|
+
action_dict.update(
|
568
|
+
{
|
569
|
+
"parameter_count": len(action.parameters),
|
570
|
+
"has_return_value": action.return_type is not None,
|
571
|
+
"return_type_name": (
|
572
|
+
action.return_type.type_name if action.return_type else None
|
573
|
+
),
|
574
|
+
"is_bound": action.binding_kind != "Unbound",
|
575
|
+
"can_call_directly": action.binding_kind == "Unbound",
|
576
|
+
"requires_entity_key": action.binding_kind
|
577
|
+
== "BoundToEntityInstance",
|
578
|
+
}
|
579
|
+
)
|
580
|
+
|
581
|
+
detailed_actions.append(action_dict)
|
582
|
+
|
583
|
+
search_time = time.time() - start_time
|
584
|
+
|
585
|
+
response = {
|
586
|
+
"actions": detailed_actions,
|
587
|
+
"total_count": len(actions),
|
588
|
+
"returned_count": len(filtered_actions),
|
589
|
+
"search_time": round(search_time, 3),
|
590
|
+
"search_parameters": {
|
591
|
+
"pattern": pattern,
|
592
|
+
"entity_name": entity_name,
|
593
|
+
"binding_kind": binding_kind,
|
594
|
+
"limit": limit,
|
595
|
+
},
|
596
|
+
"summary": {
|
597
|
+
"unbound_actions": len(
|
598
|
+
[a for a in filtered_actions if a.binding_kind == "Unbound"]
|
599
|
+
),
|
600
|
+
"entity_set_bound": len(
|
601
|
+
[
|
602
|
+
a
|
603
|
+
for a in filtered_actions
|
604
|
+
if a.binding_kind == "BoundToEntitySet"
|
605
|
+
]
|
606
|
+
),
|
607
|
+
"entity_instance_bound": len(
|
608
|
+
[
|
609
|
+
a
|
610
|
+
for a in filtered_actions
|
611
|
+
if a.binding_kind == "BoundToEntityInstance"
|
612
|
+
]
|
613
|
+
),
|
614
|
+
"unique_entities": len(
|
615
|
+
set(a.entity_name for a in filtered_actions if a.entity_name)
|
616
|
+
),
|
617
|
+
},
|
618
|
+
}
|
619
|
+
|
620
|
+
return [TextContent(type="text", text=json.dumps(response, indent=2))]
|
621
|
+
|
622
|
+
except Exception as e:
|
623
|
+
logger.error(f"Search actions failed: {e}")
|
624
|
+
error_response = {
|
625
|
+
"error": str(e),
|
626
|
+
"tool": "d365fo_search_actions",
|
627
|
+
"arguments": arguments,
|
628
|
+
}
|
629
|
+
return [TextContent(type="text", text=json.dumps(error_response, indent=2))]
|
630
|
+
|
631
|
+
async def execute_search_enumerations(self, arguments: dict) -> List[TextContent]:
|
632
|
+
"""Execute search enumerations tool.
|
633
|
+
|
634
|
+
Args:
|
635
|
+
arguments: Tool arguments
|
636
|
+
|
637
|
+
Returns:
|
638
|
+
List of TextContent responses
|
639
|
+
"""
|
640
|
+
try:
|
641
|
+
profile = arguments.get("profile", "default")
|
642
|
+
client = await self.client_manager.get_client(profile)
|
643
|
+
|
644
|
+
start_time = time.time()
|
645
|
+
|
646
|
+
# Search for enumerations using the pattern
|
647
|
+
enumerations = await client.search_public_enumerations(
|
648
|
+
pattern=arguments["pattern"]
|
649
|
+
)
|
650
|
+
|
651
|
+
# Convert EnumerationInfo objects to dictionaries for JSON serialization
|
652
|
+
enum_dicts = []
|
653
|
+
for enum in enumerations:
|
654
|
+
enum_dict = enum.to_dict()
|
655
|
+
enum_dicts.append(enum_dict)
|
656
|
+
|
657
|
+
# Apply limit
|
658
|
+
limit = arguments.get("limit")
|
659
|
+
|
660
|
+
filtered_enums = enum_dicts if limit is None else enum_dicts[:limit]
|
661
|
+
search_time = time.time() - start_time
|
662
|
+
|
663
|
+
response = {
|
664
|
+
"enumerations": filtered_enums,
|
665
|
+
"totalCount": len(enumerations),
|
666
|
+
"searchTime": round(search_time, 3),
|
667
|
+
"pattern": arguments["pattern"],
|
668
|
+
"limit": limit,
|
669
|
+
}
|
670
|
+
|
671
|
+
return [TextContent(type="text", text=json.dumps(response, indent=2))]
|
672
|
+
|
673
|
+
except Exception as e:
|
674
|
+
logger.error(f"Search enumerations failed: {e}")
|
675
|
+
error_response = {
|
676
|
+
"error": str(e),
|
677
|
+
"tool": "d365fo_search_enumerations",
|
678
|
+
"arguments": arguments,
|
679
|
+
}
|
680
|
+
return [TextContent(type="text", text=json.dumps(error_response, indent=2))]
|
681
|
+
|
682
|
+
async def execute_get_enumeration_fields(
|
683
|
+
self, arguments: dict
|
684
|
+
) -> List[TextContent]:
|
685
|
+
"""Execute get enumeration fields tool.
|
686
|
+
|
687
|
+
Args:
|
688
|
+
arguments: Tool arguments
|
689
|
+
|
690
|
+
Returns:
|
691
|
+
List of TextContent responses
|
692
|
+
"""
|
693
|
+
try:
|
694
|
+
profile = arguments.get("profile", "default")
|
695
|
+
client = await self.client_manager.get_client(profile)
|
696
|
+
|
697
|
+
enumeration_name = arguments["enumeration_name"]
|
698
|
+
resolve_labels = arguments.get("resolve_labels", True)
|
699
|
+
language = arguments.get("language", "en-US")
|
700
|
+
|
701
|
+
# Get detailed enumeration information
|
702
|
+
enum_info = await client.get_public_enumeration_info(
|
703
|
+
enumeration_name=enumeration_name,
|
704
|
+
resolve_labels=resolve_labels,
|
705
|
+
language=language,
|
706
|
+
)
|
707
|
+
|
708
|
+
if not enum_info:
|
709
|
+
raise ValueError(f"Enumeration not found: {enumeration_name}")
|
710
|
+
|
711
|
+
# Convert to dictionary for JSON serialization
|
712
|
+
enum_dict = enum_info.to_dict()
|
713
|
+
|
714
|
+
# Add additional metadata
|
715
|
+
response = {
|
716
|
+
"enumeration": enum_dict,
|
717
|
+
"memberCount": len(enum_info.members),
|
718
|
+
"hasLabels": bool(enum_info.label_text),
|
719
|
+
"language": language if resolve_labels else None,
|
720
|
+
}
|
721
|
+
|
722
|
+
return [TextContent(type="text", text=json.dumps(response, indent=2))]
|
723
|
+
|
724
|
+
except Exception as e:
|
725
|
+
logger.error(f"Get enumeration fields failed: {e}")
|
726
|
+
error_response = {
|
727
|
+
"error": str(e),
|
728
|
+
"tool": "d365fo_get_enumeration_fields",
|
729
|
+
"arguments": arguments,
|
730
|
+
}
|
731
|
+
return [TextContent(type="text", text=json.dumps(error_response, indent=2))]
|
732
|
+
|
733
|
+
async def execute_get_installed_modules(self, arguments: dict) -> List[TextContent]:
|
734
|
+
"""Execute get installed modules tool.
|
735
|
+
|
736
|
+
Args:
|
737
|
+
arguments: Tool arguments
|
738
|
+
|
739
|
+
Returns:
|
740
|
+
List of TextContent responses
|
741
|
+
"""
|
742
|
+
try:
|
743
|
+
profile = arguments.get("profile", "default")
|
744
|
+
client = await self.client_manager.get_client(profile)
|
745
|
+
logger.info("Getting installed modules from D365 F&O environment")
|
746
|
+
|
747
|
+
# Get the list of installed modules
|
748
|
+
modules = await client.get_installed_modules()
|
749
|
+
|
750
|
+
# Convert to more structured format for better readability
|
751
|
+
response = {
|
752
|
+
"modules": modules,
|
753
|
+
"moduleCount": len(modules),
|
754
|
+
"retrievedAt": f"{datetime.now().isoformat()}Z",
|
755
|
+
}
|
756
|
+
|
757
|
+
return [TextContent(type="text", text=json.dumps(response, indent=2))]
|
758
|
+
|
759
|
+
except Exception as e:
|
760
|
+
logger.error(f"Get installed modules failed: {e}")
|
761
|
+
error_response = {
|
762
|
+
"error": str(e),
|
763
|
+
"tool": "d365fo_get_installed_modules",
|
764
|
+
"arguments": arguments,
|
765
|
+
}
|
766
|
+
return [TextContent(type="text", text=json.dumps(error_response, indent=2))]
|