d365fo-client 0.2.3__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.3.dist-info → d365fo_client-0.3.0.dist-info}/METADATA +1261 -810
  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.3.dist-info/RECORD +0 -56
  55. d365fo_client-0.2.3.dist-info/entry_points.txt +0 -3
  56. {d365fo_client-0.2.3.dist-info → d365fo_client-0.3.0.dist-info}/WHEEL +0 -0
  57. {d365fo_client-0.2.3.dist-info → d365fo_client-0.3.0.dist-info}/licenses/LICENSE +0 -0
  58. {d365fo_client-0.2.3.dist-info → d365fo_client-0.3.0.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,321 @@
1
+ """Sync tools mixin for FastMCP server."""
2
+
3
+ import logging
4
+ from typing import Optional
5
+
6
+ from .base_tools_mixin import BaseToolsMixin
7
+ from ...sync_models import SyncStrategy, SyncStatus
8
+
9
+ logger = logging.getLogger(__name__)
10
+
11
+
12
+ class SyncToolsMixin(BaseToolsMixin):
13
+ """Metadata synchronization tools for FastMCP server."""
14
+
15
+ def register_sync_tools(self):
16
+ """Register all sync tools with FastMCP."""
17
+
18
+ @self.mcp.tool()
19
+ async def d365fo_start_sync(
20
+ strategy: str = "full_without_labels",
21
+ global_version_id: Optional[int] = None,
22
+ profile: str = "default",
23
+ ) -> dict:
24
+ """Start a metadata synchronization session and return a session ID for tracking progress.
25
+
26
+ This downloads and caches metadata from D365 F&O including entities, schemas, enumerations, and labels.
27
+
28
+ Args:
29
+ strategy: Sync strategy to use. 'full' downloads all metadata, 'entities_only' downloads just entities for quick refresh,
30
+ 'labels_only' downloads only labels, 'full_without_labels' downloads all metadata except labels,
31
+ 'sharing_mode' copies from compatible versions, 'incremental' updates only changes (fallback to full).
32
+ global_version_id: Specific global version ID to sync. If not provided, will detect current version automatically.
33
+ profile: Configuration profile to use (optional - uses default profile if not specified)
34
+
35
+ Returns:
36
+ Dictionary with sync session details
37
+ """
38
+ try:
39
+ client = await self._get_client(profile)
40
+
41
+ # Initialize metadata first to ensure all components are available
42
+ await client.initialize_metadata()
43
+
44
+ if not hasattr(client, 'sync_session_manager'):
45
+ error_response = {
46
+ "success": False,
47
+ "error": "Sync session management not available in this client version",
48
+ "message": "Upgrade to session-based sync manager to access sync functionality"
49
+ }
50
+ return error_response
51
+
52
+ strategy_enum = SyncStrategy(strategy)
53
+ sync_needed = True
54
+ session_id = None
55
+
56
+ # Auto-detect version if not provided
57
+ if global_version_id is None:
58
+ if not hasattr(client, 'metadata_cache') or client.metadata_cache is None:
59
+ error_response = {
60
+ "success": False,
61
+ "error": "Metadata cache not available in this client version",
62
+ "message": "Cannot auto-detect version without metadata cache"
63
+ }
64
+ return error_response
65
+
66
+ sync_needed, detected_version_id = await client.metadata_cache.check_version_and_sync()
67
+ if detected_version_id is None:
68
+ raise ValueError("Could not detect global version ID")
69
+ global_version_id = detected_version_id
70
+
71
+ if sync_needed or strategy_enum == SyncStrategy.LABELS_ONLY:
72
+ # Start sync session
73
+ session_id = await client.sync_session_manager.start_sync_session(
74
+ global_version_id=global_version_id,
75
+ strategy=strategy_enum,
76
+ initiated_by="mcp"
77
+ )
78
+
79
+ response = {
80
+ "success": True,
81
+ "session_id": session_id if sync_needed or strategy_enum == SyncStrategy.LABELS_ONLY else None,
82
+ "global_version_id": global_version_id,
83
+ "strategy": strategy,
84
+ "message": f"Sync session {session_id} started successfully" if sync_needed or strategy_enum == SyncStrategy.LABELS_ONLY else f"Metadata already up to date at version {global_version_id}, no sync needed",
85
+ "instructions": f"Use d365fo_get_sync_progress with session_id '{session_id}' to monitor progress" if sync_needed or strategy_enum == SyncStrategy.LABELS_ONLY else None
86
+ }
87
+
88
+ return response
89
+
90
+ except Exception as e:
91
+ logger.error(f"Start sync failed: {e}")
92
+ error_response = {
93
+ "success": False,
94
+ "error": str(e),
95
+ "tool": "d365fo_start_sync",
96
+ "arguments": {
97
+ "strategy": strategy,
98
+ "global_version_id": global_version_id,
99
+ "profile": profile
100
+ }
101
+ }
102
+ return error_response
103
+
104
+ @self.mcp.tool()
105
+ async def d365fo_get_sync_progress(
106
+ session_id: str, profile: str = "default"
107
+ ) -> dict:
108
+ """Get detailed progress information for a specific sync session including current phase, completion percentage, items processed, and estimated time remaining.
109
+
110
+ Args:
111
+ session_id: Session ID of the sync operation to check progress for
112
+ profile: Configuration profile to use (optional - uses default profile if not specified)
113
+
114
+ Returns:
115
+ Dictionary with sync progress
116
+ """
117
+ try:
118
+ client = await self._get_client(profile)
119
+
120
+ # Initialize metadata to ensure sync session manager is available
121
+ await client.initialize_metadata()
122
+
123
+ if not hasattr(client, 'sync_session_manager'):
124
+ error_response = {
125
+ "success": False,
126
+ "error": "Sync session management not available in this client version",
127
+ "session_id": session_id
128
+ }
129
+ return error_response
130
+
131
+ # Get session details
132
+ session = client.sync_session_manager.get_sync_session(session_id)
133
+
134
+ if not session:
135
+ error_response = {
136
+ "success": False,
137
+ "error": f"Session {session_id} not found",
138
+ "session_id": session_id
139
+ }
140
+ return error_response
141
+
142
+ # Convert session to detailed progress response
143
+ response = {
144
+ "success": True,
145
+ "session": session.to_dict(),
146
+ "summary": {
147
+ "status": session.status,
148
+ "progress_percent": round(session.progress_percent, 1),
149
+ "current_phase": session.current_phase,
150
+ "current_activity": session.current_activity,
151
+ "estimated_remaining_seconds": session.estimate_remaining_time(),
152
+ "is_running": session.status == SyncStatus.RUNNING,
153
+ "can_cancel": session.can_cancel
154
+ }
155
+ }
156
+
157
+ return response
158
+
159
+ except Exception as e:
160
+ logger.error(f"Get sync progress failed: {e}")
161
+ error_response = {
162
+ "success": False,
163
+ "error": str(e),
164
+ "tool": "d365fo_get_sync_progress",
165
+ "arguments": {"session_id": session_id, "profile": profile}
166
+ }
167
+ return error_response
168
+
169
+ @self.mcp.tool()
170
+ async def d365fo_cancel_sync(session_id: str, profile: str = "default") -> dict:
171
+ """Cancel a running sync session. Only sessions that are currently running and marked as cancellable can be cancelled.
172
+
173
+ Args:
174
+ session_id: Session ID of the sync operation to cancel
175
+ profile: Configuration profile to use (optional - uses default profile if not specified)
176
+
177
+ Returns:
178
+ Dictionary with cancellation result
179
+ """
180
+ try:
181
+ client = await self._get_client(profile)
182
+
183
+ # Initialize metadata to ensure sync session manager is available
184
+ await client.initialize_metadata()
185
+
186
+ if not hasattr(client, 'sync_session_manager'):
187
+ error_response = {
188
+ "success": False,
189
+ "error": "Sync session management not available in this client version",
190
+ "session_id": session_id
191
+ }
192
+ return error_response
193
+
194
+ # Cancel session
195
+ cancelled = await client.sync_session_manager.cancel_sync_session(session_id)
196
+
197
+ response = {
198
+ "success": cancelled,
199
+ "session_id": session_id,
200
+ "message": f"Session {session_id} {'cancelled' if cancelled else 'could not be cancelled'}",
201
+ "details": "Session may not be cancellable if already completed or failed"
202
+ }
203
+
204
+ return response
205
+
206
+ except Exception as e:
207
+ logger.error(f"Cancel sync failed: {e}")
208
+ error_response = {
209
+ "success": False,
210
+ "error": str(e),
211
+ "tool": "d365fo_cancel_sync",
212
+ "arguments": {"session_id": session_id, "profile": profile}
213
+ }
214
+ return error_response
215
+
216
+ @self.mcp.tool()
217
+ async def d365fo_list_sync_sessions(profile: str = "default") -> dict:
218
+ """Get a list of all currently active sync sessions with their status, progress, and details.
219
+
220
+ Args:
221
+ profile: Configuration profile to use (optional - uses default profile if not specified)
222
+
223
+ Returns:
224
+ Dictionary with active sync sessions
225
+ """
226
+ try:
227
+ client = await self._get_client(profile)
228
+
229
+ # Initialize metadata to ensure sync session manager is available
230
+ await client.initialize_metadata()
231
+
232
+ if not hasattr(client, 'sync_session_manager'):
233
+ error_response = {
234
+ "success": False,
235
+ "error": "Sync session management not available in this client version",
236
+ "message": "Upgrade to session-based sync manager to access session listing"
237
+ }
238
+ return error_response
239
+
240
+ # Get active sessions
241
+ active_sessions = client.sync_session_manager.get_active_sessions()
242
+
243
+ response = {
244
+ "success": True,
245
+ "active_sessions": [session.to_dict() for session in active_sessions],
246
+ "total_count": len(active_sessions),
247
+ "running_count": len([s for s in active_sessions if s.status == SyncStatus.RUNNING]),
248
+ "summary": {
249
+ "has_running_sessions": any(s.status == SyncStatus.RUNNING for s in active_sessions),
250
+ "latest_session": active_sessions[-1].to_dict() if active_sessions else None
251
+ }
252
+ }
253
+
254
+ return response
255
+
256
+ except Exception as e:
257
+ logger.error(f"List sync sessions failed: {e}")
258
+ error_response = {
259
+ "success": False,
260
+ "error": str(e),
261
+ "tool": "d365fo_list_sync_sessions",
262
+ "arguments": {"profile": profile}
263
+ }
264
+ return error_response
265
+
266
+ @self.mcp.tool()
267
+ async def d365fo_get_sync_history(
268
+ limit: int = 20, profile: str = "default"
269
+ ) -> dict:
270
+ """Get the history of completed sync sessions including success/failure status, duration, and statistics.
271
+
272
+ Args:
273
+ limit: Maximum number of historical sessions to return (default: 20, max: 100)
274
+ profile: Configuration profile to use (optional - uses default profile if not specified)
275
+
276
+ Returns:
277
+ Dictionary with sync history
278
+ """
279
+ try:
280
+ # Validate limit parameter
281
+ limit = max(1, min(limit, 100)) # Clamp between 1 and 100
282
+
283
+ client = await self._get_client(profile)
284
+
285
+ # Initialize metadata to ensure sync session manager is available
286
+ await client.initialize_metadata()
287
+
288
+ if not hasattr(client, 'sync_session_manager'):
289
+ error_response = {
290
+ "success": False,
291
+ "error": "Sync session management not available in this client version",
292
+ "message": "Upgrade to session-based sync manager to access history"
293
+ }
294
+ return error_response
295
+
296
+ # Get session history
297
+ history = client.sync_session_manager.get_session_history(limit)
298
+
299
+ response = {
300
+ "success": True,
301
+ "history": [session.to_dict() for session in history],
302
+ "total_count": len(history),
303
+ "summary": {
304
+ "successful_syncs": len([s for s in history if s.status == SyncStatus.COMPLETED]),
305
+ "failed_syncs": len([s for s in history if s.status == SyncStatus.FAILED]),
306
+ "cancelled_syncs": len([s for s in history if s.status == SyncStatus.CANCELLED]),
307
+ "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
308
+ }
309
+ }
310
+
311
+ return response
312
+
313
+ except Exception as e:
314
+ logger.error(f"Get sync history failed: {e}")
315
+ error_response = {
316
+ "success": False,
317
+ "error": str(e),
318
+ "tool": "d365fo_get_sync_history",
319
+ "arguments": {"limit": limit, "profile": profile}
320
+ }
321
+ return error_response
@@ -75,7 +75,7 @@ Help users discover, understand, and execute D365 Finance & Operations OData act
75
75
 
76
76
  **Supporting Tools:**
77
77
  - `d365fo_search_entities` - Find entities that may have actions
78
- - `d365fo_query_entities` - Query entity data for context
78
+ - `d365fo_query_entities` - Query entity data with simplified 'eq' filtering
79
79
  - `d365fo_get_entity_record` - Get specific entity records for bound actions
80
80
  - `d365fo_test_connection` - Test connection to D365FO environment
81
81
  - `d365fo_get_environment_info` - Get D365FO version and environment details
@@ -94,7 +94,7 @@ Analyze D365 Finance & Operations number sequences to identify configuration iss
94
94
  ## Available MCP Tools
95
95
 
96
96
  **Entity Query Tools:**
97
- - `d365fo_query_entities` - Query D365FO entities (limited OData support)
97
+ - `d365fo_query_entities` - Query D365FO entities (simplified 'eq' filtering with wildcards only)
98
98
  - `d365fo_get_entity_by_key` - Get specific entity record by key
99
99
  - `d365fo_search_entities` - Search for entities by name pattern
100
100
  - `d365fo_get_entity_schema` - Get entity metadata and schema information
@@ -44,13 +44,13 @@ class CrudTools:
44
44
  """Get query entities tool definition."""
45
45
  return Tool(
46
46
  name="d365fo_query_entities",
47
- description="Query and retrieve multiple records from D365 Finance & Operations data entities using OData standards. Supports advanced filtering, sorting, field selection, and pagination. Use this tool to search for specific records, generate reports, or perform bulk data analysis. Returns structured JSON data with optional metadata like record counts and pagination links.",
47
+ description="Query and retrieve multiple records from D365 Finance & Operations data entities using simplified OData filtering. Supports field selection, sorting, and pagination, but uses simplified 'eq' filtering with wildcard patterns only (no complex OData operators). Ideal for basic searches and bulk data retrieval. For complex filtering requirements, retrieve data first and filter programmatically. Returns structured JSON data with optional metadata like record counts and pagination links.",
48
48
  inputSchema={
49
49
  "type": "object",
50
50
  "properties": {
51
51
  "entityName": {
52
52
  "type": "string",
53
- "description": "The name of the D365FO data entity to query. This should be the public collection name or the entity set name (e.g., 'CustomersV3', 'SalesOrderHeadersV2', 'ItemsV2'). Use metadata discovery tools first to find the correct entity name if unsure.",
53
+ "description": "The name of the D365FO data entity to query. This must be the entity's public collection name or entity set name (e.g., 'CustomersV3', 'SalesOrderHeadersV2', 'DataManagementEntities'). Use metadata discovery tools first to find the correct entity name and verify it supports OData operations (data_service_enabled=true).",
54
54
  },
55
55
  "profile": {
56
56
  "type": "string",
@@ -64,7 +64,7 @@ class CrudTools:
64
64
  },
65
65
  "filter": {
66
66
  "type": "string",
67
- "description": 'OData filter expression to restrict which records are returned (OData $filter). Supports standard OData operators: eq (equals), ne (not equals), gt (greater than), ge (greater or equal), lt (less than), le (less or equal), and (), or, not. Examples: "CustomerAccount eq \'CUST001\'", "CreditLimit gt 10000", "Name contains \'Corp\'".',
67
+ "description": 'Simplified filter expression using only "eq" (equals) operation with wildcard support. Supported patterns: Basic equality: "FieldName eq \'value\'"; Starts with: "FieldName eq \'value*\'"; Ends with: "FieldName eq \'*value\'"; Contains: "FieldName eq \'*value*\'"; Enum values require full namespace: "StatusField eq Microsoft.Dynamics.DataEntities.EnumType\'EnumValue\'". Examples: "CustomerAccount eq \'CUST001\'", "Name eq \'*Corp*\'", "SalesOrderStatus eq Microsoft.Dynamics.DataEntities.SalesStatus\'OpenOrder\'". Note: Standard OData operators (gt, lt, and, or) are NOT supported.',
68
68
  },
69
69
  "expand": {
70
70
  "type": "array",
@@ -8,7 +8,7 @@ from mcp import Tool
8
8
  from mcp.types import TextContent
9
9
 
10
10
  from ..client_manager import D365FOClientManager
11
- from ...models import SyncStrategy
11
+ from ...sync_models import SyncStrategy
12
12
  from ...sync_models import SyncStatus
13
13
 
14
14
  logger = logging.getLogger(__name__)
@@ -0,0 +1 @@
1
+ """FastMCP utility modules."""
@@ -0,0 +1,34 @@
1
+ """Authentication utility helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from typing import Any
7
+
8
+
9
+ def parse_scopes(value: Any) -> list[str] | None:
10
+ """Parse scopes from environment variables or settings values.
11
+
12
+ Accepts either a JSON array string, a comma- or space-separated string,
13
+ a list of strings, or ``None``. Returns a list of scopes or ``None`` if
14
+ no value is provided.
15
+ """
16
+ if value is None or value == "":
17
+ return None if value is None else []
18
+ if isinstance(value, list):
19
+ return [str(v).strip() for v in value if str(v).strip()]
20
+ if isinstance(value, str):
21
+ value = value.strip()
22
+ if not value:
23
+ return []
24
+ # Try JSON array first
25
+ if value.startswith("["):
26
+ try:
27
+ data = json.loads(value)
28
+ if isinstance(data, list):
29
+ return [str(v).strip() for v in data if str(v).strip()]
30
+ except Exception:
31
+ pass
32
+ # Fallback to comma/space separated list
33
+ return [s.strip() for s in value.replace(",", " ").split() if s.strip()]
34
+ return value
@@ -0,0 +1,58 @@
1
+ """Logging utilities for FastMCP."""
2
+
3
+ import logging
4
+ from typing import Any, Literal
5
+
6
+ from rich.console import Console
7
+ from rich.logging import RichHandler
8
+
9
+
10
+ def get_logger(name: str) -> logging.Logger:
11
+ """Get a logger nested under FastMCP namespace.
12
+
13
+ Args:
14
+ name: the name of the logger, which will be prefixed with 'FastMCP.'
15
+
16
+ Returns:
17
+ a configured logger instance
18
+ """
19
+ return logging.getLogger(f"FastMCP.{name}")
20
+
21
+
22
+ def configure_logging(
23
+ level: Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] | int = "INFO",
24
+ logger: logging.Logger | None = None,
25
+ enable_rich_tracebacks: bool = True,
26
+ **rich_kwargs: Any,
27
+ ) -> None:
28
+ """
29
+ Configure logging for FastMCP.
30
+
31
+ Args:
32
+ logger: the logger to configure
33
+ level: the log level to use
34
+ rich_kwargs: the parameters to use for creating RichHandler
35
+ """
36
+
37
+ if logger is None:
38
+ logger = logging.getLogger("FastMCP")
39
+
40
+ # Only configure the FastMCP logger namespace
41
+ handler = RichHandler(
42
+ console=Console(stderr=True),
43
+ rich_tracebacks=enable_rich_tracebacks,
44
+ **rich_kwargs,
45
+ )
46
+ formatter = logging.Formatter("%(message)s")
47
+ handler.setFormatter(formatter)
48
+
49
+ logger.setLevel(level)
50
+
51
+ # Remove any existing handlers to avoid duplicates on reconfiguration
52
+ for hdlr in logger.handlers[:]:
53
+ logger.removeHandler(hdlr)
54
+
55
+ logger.addHandler(handler)
56
+
57
+ # Don't propagate to the root logger
58
+ logger.propagate = False