d365fo-client 0.2.1__py3-none-any.whl → 0.2.3__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.
@@ -0,0 +1,503 @@
1
+ """Sync management tools for MCP server."""
2
+
3
+ import json
4
+ import logging
5
+ from typing import List
6
+
7
+ from mcp import Tool
8
+ from mcp.types import TextContent
9
+
10
+ from ..client_manager import D365FOClientManager
11
+ from ...models import SyncStrategy
12
+ from ...sync_models import SyncStatus
13
+
14
+ logger = logging.getLogger(__name__)
15
+
16
+
17
+ class SyncTools:
18
+ """Sync management tools for the MCP server."""
19
+
20
+ def __init__(self, client_manager: D365FOClientManager):
21
+ """Initialize sync 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 sync tools.
30
+
31
+ Returns:
32
+ List of Tool definitions
33
+ """
34
+ return [
35
+ self._get_start_sync_tool(),
36
+ self._get_sync_progress_tool(),
37
+ self._get_cancel_sync_tool(),
38
+ self._get_list_sync_sessions_tool(),
39
+ self._get_sync_history_tool(),
40
+ ]
41
+
42
+ def _get_start_sync_tool(self) -> Tool:
43
+ """Get start sync tool definition."""
44
+ return Tool(
45
+ name="d365fo_start_sync",
46
+ description="Start a metadata synchronization session and return a session ID for tracking progress. This downloads and caches metadata from D365 F&O including entities, schemas, enumerations, and labels.",
47
+ inputSchema={
48
+ "type": "object",
49
+ "properties": {
50
+ "strategy": {
51
+ "type": "string",
52
+ "enum": ["full", "entities_only", "labels_only", "full_without_labels", "sharing_mode", "incremental"],
53
+ "description": "Sync strategy to use. 'full' downloads all metadata, 'entities_only' downloads just entities for quick refresh, 'labels_only' downloads only labels, 'full_without_labels' downloads all metadata except labels, 'sharing_mode' copies from compatible versions, 'incremental' updates only changes (fallback to full).",
54
+ "default": "full_without_labels"
55
+ },
56
+ "global_version_id": {
57
+ "type": "integer",
58
+ "description": "Specific global version ID to sync. If not provided, will detect current version automatically."
59
+ },
60
+ "profile": {
61
+ "type": "string",
62
+ "description": "Configuration profile to use (optional - uses default profile if not specified)"
63
+ }
64
+ },
65
+ "additionalProperties": False,
66
+ },
67
+ )
68
+
69
+ def _get_sync_progress_tool(self) -> Tool:
70
+ """Get sync progress tool definition."""
71
+ return Tool(
72
+ name="d365fo_get_sync_progress",
73
+ description="Get detailed progress information for a specific sync session including current phase, completion percentage, items processed, and estimated time remaining.",
74
+ inputSchema={
75
+ "type": "object",
76
+ "properties": {
77
+ "session_id": {
78
+ "type": "string",
79
+ "description": "Session ID of the sync operation to check progress for"
80
+ },
81
+ "profile": {
82
+ "type": "string",
83
+ "description": "Configuration profile to use (optional - uses default profile if not specified)"
84
+ }
85
+ },
86
+ "required": ["session_id"],
87
+ "additionalProperties": False,
88
+ },
89
+ )
90
+
91
+ def _get_cancel_sync_tool(self) -> Tool:
92
+ """Get cancel sync tool definition."""
93
+ return Tool(
94
+ name="d365fo_cancel_sync",
95
+ description="Cancel a running sync session. Only sessions that are currently running and marked as cancellable can be cancelled.",
96
+ inputSchema={
97
+ "type": "object",
98
+ "properties": {
99
+ "session_id": {
100
+ "type": "string",
101
+ "description": "Session ID of the sync operation to cancel"
102
+ },
103
+ "profile": {
104
+ "type": "string",
105
+ "description": "Configuration profile to use (optional - uses default profile if not specified)"
106
+ }
107
+ },
108
+ "required": ["session_id"],
109
+ "additionalProperties": False,
110
+ },
111
+ )
112
+
113
+ def _get_list_sync_sessions_tool(self) -> Tool:
114
+ """Get list sync sessions tool definition."""
115
+ return Tool(
116
+ name="d365fo_list_sync_sessions",
117
+ description="Get a list of all currently active sync sessions with their status, progress, and details.",
118
+ inputSchema={
119
+ "type": "object",
120
+ "properties": {
121
+ "profile": {
122
+ "type": "string",
123
+ "description": "Configuration profile to use (optional - uses default profile if not specified)"
124
+ }
125
+ },
126
+ "additionalProperties": False,
127
+ },
128
+ )
129
+
130
+ def _get_sync_history_tool(self) -> Tool:
131
+ """Get sync history tool definition."""
132
+ return Tool(
133
+ name="d365fo_get_sync_history",
134
+ description="Get the history of completed sync sessions including success/failure status, duration, and statistics.",
135
+ inputSchema={
136
+ "type": "object",
137
+ "properties": {
138
+ "limit": {
139
+ "type": "integer",
140
+ "description": "Maximum number of historical sessions to return (default: 20, max: 100)",
141
+ "minimum": 1,
142
+ "maximum": 100,
143
+ "default": 20
144
+ },
145
+ "profile": {
146
+ "type": "string",
147
+ "description": "Configuration profile to use (optional - uses default profile if not specified)"
148
+ }
149
+ },
150
+ "additionalProperties": False,
151
+ },
152
+ )
153
+
154
+ async def execute_start_sync(self, arguments: dict) -> List[TextContent]:
155
+ """Execute start sync operation."""
156
+ try:
157
+ profile = arguments.get("profile", "default")
158
+ client = await self.client_manager.get_client(profile)
159
+
160
+ # Initialize metadata first to ensure all components are available
161
+ await client.initialize_metadata()
162
+
163
+ if not hasattr(client, 'sync_session_manager'):
164
+ # Fall back to the original sync manager if session manager not available
165
+ return await self._fallback_start_sync(client, arguments)
166
+
167
+ strategy_str = arguments.get("strategy", "full_without_labels")
168
+ strategy = SyncStrategy(strategy_str)
169
+ global_version_id = arguments.get("global_version_id")
170
+ sync_needed = True
171
+ session_id = None
172
+
173
+ # Auto-detect version if not provided
174
+ if global_version_id is None:
175
+ sync_needed, detected_version_id = await client.metadata_cache.check_version_and_sync()
176
+ if detected_version_id is None:
177
+ raise ValueError("Could not detect global version ID")
178
+ global_version_id = detected_version_id
179
+
180
+ if sync_needed or strategy == SyncStrategy.LABELS_ONLY:
181
+ # Start sync session
182
+ session_id = await client.sync_session_manager.start_sync_session(
183
+ global_version_id=global_version_id,
184
+ strategy=strategy,
185
+ initiated_by="mcp"
186
+ )
187
+
188
+ response = {
189
+ "success": True,
190
+ "session_id": session_id if sync_needed or strategy == SyncStrategy.LABELS_ONLY else None,
191
+ "global_version_id": global_version_id,
192
+ "strategy": strategy_str,
193
+ "message": f"Sync session {session_id} started successfully" if sync_needed or strategy == SyncStrategy.LABELS_ONLY else f"Metadata already up to date at version {global_version_id}, no sync needed",
194
+ "instructions": f"Use d365fo_get_sync_progress with session_id '{session_id}' to monitor progress" if sync_needed or strategy == SyncStrategy.LABELS_ONLY else None
195
+ }
196
+
197
+ return [TextContent(type="text", text=json.dumps(response, indent=2))]
198
+
199
+ except Exception as e:
200
+ logger.error(f"Start sync failed: {e}")
201
+ error_response = {
202
+ "success": False,
203
+ "error": str(e),
204
+ "tool": "d365fo_start_sync",
205
+ "arguments": arguments,
206
+ }
207
+ return [TextContent(type="text", text=json.dumps(error_response, indent=2))]
208
+
209
+ async def execute_get_sync_progress(self, arguments: dict) -> List[TextContent]:
210
+ """Execute get sync progress operation."""
211
+ try:
212
+ profile = arguments.get("profile", "default")
213
+ client = await self.client_manager.get_client(profile)
214
+ session_id = arguments["session_id"]
215
+
216
+ # Initialize metadata to ensure sync session manager is available
217
+ await client.initialize_metadata()
218
+
219
+ if not hasattr(client, 'sync_session_manager'):
220
+ # Fall back to basic progress check
221
+ return await self._fallback_get_progress(client, session_id)
222
+
223
+ # Get session details
224
+ session = client.sync_session_manager.get_sync_session(session_id)
225
+
226
+ if not session:
227
+ error_response = {
228
+ "success": False,
229
+ "error": f"Session {session_id} not found",
230
+ "session_id": session_id
231
+ }
232
+ return [TextContent(type="text", text=json.dumps(error_response, indent=2))]
233
+
234
+ # Convert session to detailed progress response
235
+ response = {
236
+ "success": True,
237
+ "session": session.to_dict(),
238
+ "summary": {
239
+ "status": session.status,
240
+ "progress_percent": round(session.progress_percent, 1),
241
+ "current_phase": session.current_phase,
242
+ "current_activity": session.current_activity,
243
+ "estimated_remaining_seconds": session.estimate_remaining_time(),
244
+ "is_running": session.status == SyncStatus.RUNNING,
245
+ "can_cancel": session.can_cancel
246
+ }
247
+ }
248
+
249
+ return [TextContent(type="text", text=json.dumps(response, indent=2))]
250
+
251
+ except Exception as e:
252
+ logger.error(f"Get sync progress failed: {e}")
253
+ error_response = {
254
+ "success": False,
255
+ "error": str(e),
256
+ "tool": "d365fo_get_sync_progress",
257
+ "arguments": arguments,
258
+ }
259
+ return [TextContent(type="text", text=json.dumps(error_response, indent=2))]
260
+
261
+ async def execute_cancel_sync(self, arguments: dict) -> List[TextContent]:
262
+ """Execute cancel sync operation."""
263
+ try:
264
+ profile = arguments.get("profile", "default")
265
+ client = await self.client_manager.get_client(profile)
266
+ session_id = arguments["session_id"]
267
+
268
+ # Initialize metadata to ensure sync session manager is available
269
+ await client.initialize_metadata()
270
+
271
+ if not hasattr(client, 'sync_session_manager'):
272
+ error_response = {
273
+ "success": False,
274
+ "error": "Sync session management not available in this client version",
275
+ "session_id": session_id
276
+ }
277
+ return [TextContent(type="text", text=json.dumps(error_response, indent=2))]
278
+
279
+ # Cancel session
280
+ cancelled = await client.sync_session_manager.cancel_sync_session(session_id)
281
+
282
+ response = {
283
+ "success": cancelled,
284
+ "session_id": session_id,
285
+ "message": f"Session {session_id} {'cancelled' if cancelled else 'could not be cancelled'}",
286
+ "details": "Session may not be cancellable if already completed or failed"
287
+ }
288
+
289
+ return [TextContent(type="text", text=json.dumps(response, indent=2))]
290
+
291
+ except Exception as e:
292
+ logger.error(f"Cancel sync failed: {e}")
293
+ error_response = {
294
+ "success": False,
295
+ "error": str(e),
296
+ "tool": "d365fo_cancel_sync",
297
+ "arguments": arguments,
298
+ }
299
+ return [TextContent(type="text", text=json.dumps(error_response, indent=2))]
300
+
301
+ async def execute_list_sync_sessions(self, arguments: dict) -> List[TextContent]:
302
+ """Execute list sync sessions operation."""
303
+ try:
304
+ profile = arguments.get("profile", "default")
305
+ client = await self.client_manager.get_client(profile)
306
+
307
+ # Initialize metadata to ensure sync session manager is available
308
+ await client.initialize_metadata()
309
+
310
+ if not hasattr(client, 'sync_session_manager'):
311
+ # Check if legacy sync manager is running
312
+ return await self._fallback_list_sessions(client)
313
+
314
+ # Get active sessions
315
+ active_sessions = client.sync_session_manager.get_active_sessions()
316
+
317
+ response = {
318
+ "success": True,
319
+ "active_sessions": [session.to_dict() for session in active_sessions],
320
+ "total_count": len(active_sessions),
321
+ "running_count": len([s for s in active_sessions if s.status == SyncStatus.RUNNING]),
322
+ "summary": {
323
+ "has_running_sessions": any(s.status == SyncStatus.RUNNING for s in active_sessions),
324
+ "latest_session": active_sessions[-1].to_dict() if active_sessions else None
325
+ }
326
+ }
327
+
328
+ return [TextContent(type="text", text=json.dumps(response, indent=2))]
329
+
330
+ except Exception as e:
331
+ logger.error(f"List sync sessions failed: {e}")
332
+ error_response = {
333
+ "success": False,
334
+ "error": str(e),
335
+ "tool": "d365fo_list_sync_sessions",
336
+ "arguments": arguments,
337
+ }
338
+ return [TextContent(type="text", text=json.dumps(error_response, indent=2))]
339
+
340
+ async def execute_get_sync_history(self, arguments: dict) -> List[TextContent]:
341
+ """Execute get sync history operation."""
342
+ try:
343
+ profile = arguments.get("profile", "default")
344
+ client = await self.client_manager.get_client(profile)
345
+ limit = arguments.get("limit", 20)
346
+
347
+ # Initialize metadata to ensure sync session manager is available
348
+ await client.initialize_metadata()
349
+
350
+ if not hasattr(client, 'sync_session_manager'):
351
+ error_response = {
352
+ "success": False,
353
+ "error": "Sync session management not available in this client version",
354
+ "message": "Upgrade to session-based sync manager to access history"
355
+ }
356
+ return [TextContent(type="text", text=json.dumps(error_response, indent=2))]
357
+
358
+ # Get session history
359
+ history = client.sync_session_manager.get_session_history(limit)
360
+
361
+ response = {
362
+ "success": True,
363
+ "history": [session.to_dict() for session in history],
364
+ "total_count": len(history),
365
+ "summary": {
366
+ "successful_syncs": len([s for s in history if s.status == SyncStatus.COMPLETED]),
367
+ "failed_syncs": len([s for s in history if s.status == SyncStatus.FAILED]),
368
+ "cancelled_syncs": len([s for s in history if s.status == SyncStatus.CANCELLED]),
369
+ "average_duration": sum(s.duration_seconds for s in history if s.duration_seconds) / len([s for s in history if s.duration_seconds]) if history else 0
370
+ }
371
+ }
372
+
373
+ return [TextContent(type="text", text=json.dumps(response, indent=2))]
374
+
375
+ except Exception as e:
376
+ logger.error(f"Get sync history failed: {e}")
377
+ error_response = {
378
+ "success": False,
379
+ "error": str(e),
380
+ "tool": "d365fo_get_sync_history",
381
+ "arguments": arguments,
382
+ }
383
+ return [TextContent(type="text", text=json.dumps(error_response, indent=2))]
384
+
385
+ async def _fallback_start_sync(self, client, arguments: dict) -> List[TextContent]:
386
+ """Fallback to original sync manager for starting sync."""
387
+ strategy_str = arguments.get("strategy", "full")
388
+ strategy = SyncStrategy(strategy_str)
389
+ global_version_id = arguments.get("global_version_id")
390
+
391
+ # Auto-detect version if not provided
392
+ if global_version_id is None:
393
+ sync_needed, detected_version_id = await client.metadata_cache.check_version_and_sync()
394
+ if detected_version_id is None:
395
+ raise ValueError("Could not detect global version ID")
396
+ global_version_id = detected_version_id
397
+
398
+ # Check if sync already running
399
+ if hasattr(client, 'smart_sync_manager') and client.smart_sync_manager.is_syncing():
400
+ error_response = {
401
+ "success": False,
402
+ "error": "Sync already in progress",
403
+ "message": "Wait for current sync to complete before starting a new one"
404
+ }
405
+ return [TextContent(type="text", text=json.dumps(error_response, indent=2))]
406
+
407
+ # Start sync without session tracking
408
+ import asyncio
409
+ sync_task = asyncio.create_task(
410
+ client.smart_sync_manager.sync_metadata(global_version_id, strategy)
411
+ )
412
+
413
+ response = {
414
+ "success": True,
415
+ "session_id": f"legacy_{global_version_id}_{strategy_str}",
416
+ "global_version_id": global_version_id,
417
+ "strategy": strategy_str,
418
+ "message": "Sync started (legacy mode)",
419
+ "instructions": "Use d365fo_get_sync_progress to monitor progress"
420
+ }
421
+
422
+ return [TextContent(type="text", text=json.dumps(response, indent=2))]
423
+
424
+ async def _fallback_get_progress(self, client, session_id: str) -> List[TextContent]:
425
+ """Fallback progress check for original sync manager."""
426
+ if hasattr(client, 'smart_sync_manager'):
427
+ progress = client.smart_sync_manager.get_sync_progress()
428
+ is_syncing = client.smart_sync_manager.is_syncing()
429
+
430
+ if progress:
431
+ response = {
432
+ "success": True,
433
+ "session_id": session_id,
434
+ "legacy_mode": True,
435
+ "progress": {
436
+ "global_version_id": progress.global_version_id,
437
+ "strategy": progress.strategy,
438
+ "phase": progress.phase,
439
+ "total_steps": progress.total_steps,
440
+ "completed_steps": progress.completed_steps,
441
+ "current_operation": progress.current_operation,
442
+ "start_time": progress.start_time.isoformat() if progress.start_time else None,
443
+ "estimated_completion": progress.estimated_completion.isoformat() if progress.estimated_completion else None,
444
+ "error": progress.error,
445
+ "is_running": is_syncing
446
+ }
447
+ }
448
+ else:
449
+ response = {
450
+ "success": True,
451
+ "session_id": session_id,
452
+ "legacy_mode": True,
453
+ "message": "No sync currently running",
454
+ "is_running": False
455
+ }
456
+
457
+ return [TextContent(type="text", text=json.dumps(response, indent=2))]
458
+
459
+ error_response = {
460
+ "success": False,
461
+ "error": "No sync manager available",
462
+ "session_id": session_id
463
+ }
464
+ return [TextContent(type="text", text=json.dumps(error_response, indent=2))]
465
+
466
+ async def _fallback_list_sessions(self, client) -> List[TextContent]:
467
+ """Fallback session list for original sync manager."""
468
+ if hasattr(client, 'smart_sync_manager'):
469
+ is_syncing = client.smart_sync_manager.is_syncing()
470
+ progress = client.smart_sync_manager.get_sync_progress()
471
+
472
+ active_sessions = []
473
+ if is_syncing and progress:
474
+ session = {
475
+ "session_id": f"legacy_{progress.global_version_id}_{progress.strategy}",
476
+ "global_version_id": progress.global_version_id,
477
+ "strategy": progress.strategy,
478
+ "status": "running",
479
+ "start_time": progress.start_time.isoformat() if progress.start_time else None,
480
+ "progress_percent": (progress.completed_steps / progress.total_steps * 100) if progress.total_steps > 0 else 0,
481
+ "current_phase": progress.phase,
482
+ "current_activity": progress.current_operation,
483
+ "initiated_by": "legacy",
484
+ "legacy_mode": True
485
+ }
486
+ active_sessions.append(session)
487
+
488
+ response = {
489
+ "success": True,
490
+ "active_sessions": active_sessions,
491
+ "total_count": len(active_sessions),
492
+ "running_count": len(active_sessions),
493
+ "legacy_mode": True,
494
+ "message": "Using legacy sync manager - limited session information available"
495
+ }
496
+
497
+ return [TextContent(type="text", text=json.dumps(response, indent=2))]
498
+
499
+ error_response = {
500
+ "success": False,
501
+ "error": "No sync manager available"
502
+ }
503
+ return [TextContent(type="text", text=json.dumps(error_response, indent=2))]
@@ -360,6 +360,73 @@ class MetadataAPIOperations:
360
360
 
361
361
  return entities
362
362
 
363
+ async def get_all_data_entities(self) -> List[DataEntityInfo]:
364
+ """Get all data entities without pagination limits
365
+
366
+ Returns:
367
+ List of all data entities
368
+ """
369
+ try:
370
+ # Use the search method without any filters to get all entities
371
+ # We could also use get_data_entities() with no top limit, but search handles pagination better
372
+ entities = []
373
+
374
+ # Get all entities by calling the raw endpoint with no top limit
375
+ options = QueryOptions()
376
+ options.select = [
377
+ "Name",
378
+ "PublicEntityName",
379
+ "PublicCollectionName",
380
+ "LabelId",
381
+ "DataServiceEnabled",
382
+ "DataManagementEnabled",
383
+ "EntityCategory",
384
+ "IsReadOnly"
385
+ ]
386
+
387
+ data = await self.get_data_entities(options)
388
+
389
+ for item in data.get("value", []):
390
+ # Convert entity category string to enum
391
+ entity_category_str = item.get("EntityCategory")
392
+ entity_category = None
393
+ if entity_category_str:
394
+ try:
395
+ # Try to map the string to the enum
396
+ if entity_category_str == "Master":
397
+ entity_category = EntityCategory.MASTER
398
+ elif entity_category_str == "Transaction":
399
+ entity_category = EntityCategory.TRANSACTION
400
+ elif entity_category_str == "Configuration":
401
+ entity_category = EntityCategory.CONFIGURATION
402
+ elif entity_category_str == "Reference":
403
+ entity_category = EntityCategory.REFERENCE
404
+ elif entity_category_str == "Document":
405
+ entity_category = EntityCategory.DOCUMENT
406
+ elif entity_category_str == "Parameters":
407
+ entity_category = EntityCategory.PARAMETERS
408
+ # If no match, leave as None
409
+ except Exception:
410
+ entity_category = None
411
+
412
+ entity = DataEntityInfo(
413
+ name=item.get("Name", ""),
414
+ public_entity_name=item.get("PublicEntityName", ""),
415
+ public_collection_name=item.get("PublicCollectionName", ""),
416
+ label_id=item.get("LabelId"),
417
+ data_service_enabled=item.get("DataServiceEnabled", False),
418
+ data_management_enabled=item.get("DataManagementEnabled", False),
419
+ entity_category=entity_category,
420
+ is_read_only=item.get("IsReadOnly", False),
421
+ )
422
+ entities.append(entity)
423
+
424
+ return entities
425
+
426
+ except Exception as e:
427
+ logger.error(f"Error getting all data entities: {e}")
428
+ raise
429
+
363
430
  async def get_data_entity_info(
364
431
  self, entity_name: str, resolve_labels: bool = True, language: str = "en-US"
365
432
  ) -> Optional[DataEntityInfo]:
@@ -940,7 +1007,7 @@ class MetadataAPIOperations:
940
1007
  continue
941
1008
 
942
1009
  # Filter by binding kind
943
- if binding_kind and action.binding_kind.value != binding_kind:
1010
+ if binding_kind and action.binding_kind != binding_kind:
944
1011
  continue
945
1012
 
946
1013
  # Create ActionInfo with entity context