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,311 @@
1
+ """CRUD tools mixin for FastMCP server."""
2
+
3
+ import logging
4
+ from typing import List, Optional
5
+
6
+ from .base_tools_mixin import BaseToolsMixin
7
+
8
+ logger = logging.getLogger(__name__)
9
+
10
+
11
+ class CrudToolsMixin(BaseToolsMixin):
12
+ """CRUD (Create, Read, Update, Delete) tools for FastMCP server."""
13
+
14
+ def register_crud_tools(self):
15
+ """Register all CRUD tools with FastMCP."""
16
+
17
+ @self.mcp.tool()
18
+ async def d365fo_query_entities(
19
+ entity_name: str,
20
+ select: Optional[List[str]] = None,
21
+ filter: Optional[str] = None,
22
+ order_by: Optional[List[str]] = None,
23
+ top: int = 100,
24
+ skip: Optional[int] = None,
25
+ count: bool = False,
26
+ expand: Optional[List[str]] = None,
27
+ profile: str = "default",
28
+ ) -> dict:
29
+ """Query D365FO data entities with simplified filtering capabilities.
30
+
31
+ Args:
32
+ entity_name: The entity's public collection name or entity set name (e.g., "CustomersV3", "SalesOrders", "DataManagementEntities")
33
+ select: List of field names to include in response
34
+ filter: Simplified filter expression using only "eq" operation with wildcard support:
35
+ - Basic equality: "FieldName eq 'value'"
36
+ - Starts with: "FieldName eq 'value*'"
37
+ - Ends with: "FieldName eq '*value'"
38
+ - Contains: "FieldName eq '*value*'"
39
+ - Enum values: "StatusField eq Microsoft.Dynamics.DataEntities.EnumType'EnumValue'"
40
+ Example: "SalesOrderStatus eq Microsoft.Dynamics.DataEntities.SalesStatus'OpenOrder'"
41
+ order_by: List of field names to sort by (e.g., ["CreatedDateTime desc", "SalesId"])
42
+ top: Maximum number of records to return (default: 100)
43
+ skip: Number of records to skip for pagination
44
+ count: Whether to include total count in response
45
+ expand: List of navigation properties to expand
46
+ profile: Profile name for connection configuration
47
+
48
+ Returns:
49
+ Dictionary with query results including data array, count, and pagination info
50
+
51
+ Note: This tool uses simplified OData filtering that only supports "eq" operations with wildcard patterns.
52
+ For complex queries, retrieve data first and filter programmatically.
53
+ """
54
+ try:
55
+ client = await self._get_client(profile)
56
+
57
+ # Build query options
58
+ from ...models import QueryOptions
59
+
60
+ options = QueryOptions(
61
+ select=select,
62
+ filter=filter,
63
+ orderby=order_by,
64
+ top=top,
65
+ skip=skip,
66
+ count=count,
67
+ expand=expand,
68
+ )
69
+
70
+ # Execute query
71
+ result = await client.get_entities(entity_name, options=options)
72
+
73
+ return {
74
+ "entityName": entity_name,
75
+ "data": result.get("value", []),
76
+ "count": result.get("@odata.count"),
77
+ "nextLink": result.get("@odata.nextLink"),
78
+ "totalRecords": len(result.get("value", [])),
79
+ }
80
+
81
+ except Exception as e:
82
+ logger.error(f"Query entities failed: {e}")
83
+ return {
84
+ "error": str(e),
85
+ "entityName": entity_name,
86
+ "parameters": {"select": select, "filter": filter, "top": top},
87
+ }
88
+
89
+ @self.mcp.tool()
90
+ async def d365fo_get_entity_record(
91
+ entity_name: str,
92
+ key_fields: List[str],
93
+ key_values: List[str],
94
+ select: Optional[List[str]] = None,
95
+ expand: Optional[List[str]] = None,
96
+ profile: str = "default",
97
+ ) -> dict:
98
+ """Get a specific record from a D365FO data entity.
99
+
100
+ Args:
101
+ entity_name: The entity's public collection name or entity set name (e.g., "CustomersV3", "SalesOrders", "DataManagementEntities")
102
+ key_fields: List of key field names for composite keys
103
+ key_values: List of key values corresponding to key fields
104
+ select: List of fields to include in response
105
+ expand: List of navigation properties to expand
106
+ profile: Optional profile name
107
+
108
+ Returns:
109
+ Dictionary with the entity record
110
+ """
111
+ try:
112
+ client = await self._get_client(profile)
113
+
114
+ # Build query options
115
+ from ...models import QueryOptions
116
+
117
+ options = (
118
+ QueryOptions(select=select, expand=expand)
119
+ if select or expand
120
+ else None
121
+ )
122
+
123
+ # Construct key field=value mapping
124
+ if len(key_fields) != len(key_values):
125
+ raise ValueError("Key fields and values length mismatch")
126
+ key = {k: v for k, v in zip(key_fields, key_values)}
127
+
128
+ # Get entity record
129
+ result = await client.get_entity_by_key(entity_name, key, options) # type: ignore
130
+
131
+ return {"entityName": entity_name, "key": key, "data": result}
132
+
133
+ except Exception as e:
134
+ logger.error(f"Get entity record failed: {e}")
135
+ return {"error": str(e), "entityName": entity_name, "key_fields": key_fields, "key_values": key_values, "key": key}
136
+
137
+ @self.mcp.tool()
138
+ async def d365fo_create_entity_record(
139
+ entity_name: str,
140
+ data: dict,
141
+ return_record: bool = False,
142
+ profile: str = "default",
143
+ ) -> dict:
144
+ """Create a new record in a D365 Finance & Operations data entity.
145
+
146
+ Args:
147
+ entity_name: The entity's public collection name or entity set name (e.g., "CustomersV3", "SalesOrders", "DataManagementEntities")
148
+ data: Record data containing field names and values
149
+ return_record: Whether to return the complete created record
150
+ profile: Optional profile name
151
+
152
+ Returns:
153
+ Dictionary with creation result
154
+ """
155
+ try:
156
+ client = await self._get_client(profile)
157
+
158
+ # Create entity record
159
+ result = await client.create_entity(
160
+ entity_name, data
161
+ )
162
+
163
+ return {
164
+ "entityName": entity_name,
165
+ "created": True,
166
+ "data": result if return_record else data,
167
+ }
168
+
169
+ except Exception as e:
170
+ logger.error(f"Create entity record failed: {e}")
171
+ return {"error": str(e), "entityName": entity_name, "created": False}
172
+
173
+ @self.mcp.tool()
174
+ async def d365fo_update_entity_record(
175
+ entity_name: str,
176
+ key_fields: List[str],
177
+ key_values: List[str],
178
+ data: dict,
179
+ return_record: bool = False,
180
+ profile: str = "default",
181
+ ) -> dict:
182
+ """Update an existing record in a D365 Finance & Operations data entity.
183
+
184
+ Args:
185
+ entity_name: The entity's public collection name or entity set name (e.g., "CustomersV3", "SalesOrders", "DataManagementEntities")
186
+ key_fields: List of key field names for composite keys
187
+ key_values: List of key values corresponding to key fields
188
+ data: Record data containing fields to update
189
+ return_record: Whether to return the complete updated record
190
+ profile: Optional profile name
191
+
192
+ Returns:
193
+ Dictionary with update result
194
+ """
195
+ try:
196
+ client = await self._get_client(profile)
197
+
198
+ # Construct key field=value mapping
199
+ if len(key_fields) != len(key_values):
200
+ raise ValueError("Key fields and values length mismatch")
201
+
202
+ key = {k: v for k, v in zip(key_fields, key_values)}
203
+
204
+ # Update entity record
205
+ result = await client.update_entity(
206
+ entity_name, key, data
207
+ )
208
+
209
+ return {
210
+ "entityName": entity_name,
211
+ "key": key,
212
+ "updated": True,
213
+ "data": result if return_record else data,
214
+ }
215
+
216
+ except Exception as e:
217
+ logger.error(f"Update entity record failed: {e}")
218
+ return {
219
+ "error": str(e),
220
+ "entityName": entity_name,
221
+ "key": key, # type: ignore
222
+ "updated": False,
223
+ }
224
+
225
+ @self.mcp.tool()
226
+ async def d365fo_delete_entity_record(
227
+ entity_name: str,
228
+ key_fields: List[str],
229
+ key_values: List[str],
230
+ profile: str = "default"
231
+ ) -> dict:
232
+ """Delete a record from a D365 Finance & Operations data entity.
233
+
234
+ Args:
235
+ entity_name: The entity's public collection name or entity set name (e.g., "CustomersV3", "SalesOrders", "DataManagementEntities")
236
+ key_fields: List of key field names for composite keys
237
+ key_values: List of key values corresponding to key fields
238
+ profile: Optional profile name
239
+
240
+ Returns:
241
+ Dictionary with deletion result
242
+ """
243
+ try:
244
+ client = await self._get_client(profile)
245
+
246
+ # Construct key field=value mapping
247
+ if len(key_fields) != len(key_values):
248
+ raise ValueError("Key fields and values length mismatch")
249
+
250
+ key = {k: v for k, v in zip(key_fields, key_values)}
251
+
252
+ # Delete entity record
253
+ await client.delete_entity(entity_name, key)
254
+
255
+ return {"entityName": entity_name, "key": key, "deleted": True}
256
+
257
+ except Exception as e:
258
+ logger.error(f"Delete entity record failed: {e}")
259
+ return {
260
+ "error": str(e),
261
+ "entityName": entity_name,
262
+ "key": key, # type: ignore
263
+ "deleted": False,
264
+ }
265
+
266
+ @self.mcp.tool()
267
+ async def d365fo_call_action(
268
+ action_name: str,
269
+ entity_name: str = None,# type: ignore
270
+ parameters: dict = None,# type: ignore
271
+ key_fields: List[str] = None,# type: ignore
272
+ key_values: List[str] = None,# type: ignore
273
+ profile: str = "default",
274
+ ) -> dict:
275
+ """Execute an OData action method in D365 Finance & Operations.
276
+
277
+ Args:
278
+ action_name: Full name of the OData action to invoke
279
+ parameters: Action parameters as key-value pairs
280
+ entity_name: The entity's public collection name or entity set name (e.g., "CustomersV3", "SalesOrders", "DataManagementEntities")
281
+ key_fields: Primary key fields for entity-bound actions
282
+ key_values: Primary key values for entity-bound actions
283
+ profile: Optional profile name
284
+
285
+ Returns:
286
+ Dictionary with action result
287
+ """
288
+ try:
289
+ client = await self._get_client(profile)
290
+
291
+ # Call action
292
+ # Construct key field=value mapping (only if both key_fields and key_values are provided)
293
+ key = None
294
+ if key_fields is not None and key_values is not None:
295
+ if len(key_fields) != len(key_values):
296
+ raise ValueError("Key fields and values length mismatch")
297
+ key = {k: v for k, v in zip(key_fields, key_values)}
298
+
299
+ result = await client.call_action(
300
+ action_name=action_name,# type: ignore
301
+ parameters=parameters or {},
302
+ entity_name=entity_name,
303
+ entity_key=key,
304
+
305
+ )
306
+
307
+ return {"actionName": action_name, "success": True, "result": result}
308
+
309
+ except Exception as e:
310
+ logger.error(f"Call action failed: {e}")
311
+ return {"error": str(e), "actionName": action_name, "success": False}