d365fo-client 0.2.4__py3-none-any.whl → 0.3.1__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 +7 -1
- d365fo_client/auth.py +9 -21
- d365fo_client/cli.py +25 -13
- d365fo_client/client.py +8 -4
- d365fo_client/config.py +52 -30
- d365fo_client/credential_sources.py +5 -0
- d365fo_client/main.py +1 -1
- d365fo_client/mcp/__init__.py +3 -1
- d365fo_client/mcp/auth_server/__init__.py +5 -0
- d365fo_client/mcp/auth_server/auth/__init__.py +30 -0
- d365fo_client/mcp/auth_server/auth/auth.py +372 -0
- d365fo_client/mcp/auth_server/auth/oauth_proxy.py +989 -0
- d365fo_client/mcp/auth_server/auth/providers/__init__.py +0 -0
- d365fo_client/mcp/auth_server/auth/providers/apikey.py +83 -0
- d365fo_client/mcp/auth_server/auth/providers/azure.py +393 -0
- d365fo_client/mcp/auth_server/auth/providers/bearer.py +25 -0
- d365fo_client/mcp/auth_server/auth/providers/jwt.py +547 -0
- d365fo_client/mcp/auth_server/auth/redirect_validation.py +65 -0
- d365fo_client/mcp/auth_server/dependencies.py +136 -0
- d365fo_client/mcp/client_manager.py +16 -67
- d365fo_client/mcp/fastmcp_main.py +407 -0
- d365fo_client/mcp/fastmcp_server.py +598 -0
- d365fo_client/mcp/fastmcp_utils.py +431 -0
- d365fo_client/mcp/main.py +40 -13
- d365fo_client/mcp/mixins/__init__.py +24 -0
- d365fo_client/mcp/mixins/base_tools_mixin.py +55 -0
- d365fo_client/mcp/mixins/connection_tools_mixin.py +50 -0
- d365fo_client/mcp/mixins/crud_tools_mixin.py +311 -0
- d365fo_client/mcp/mixins/database_tools_mixin.py +685 -0
- d365fo_client/mcp/mixins/label_tools_mixin.py +87 -0
- d365fo_client/mcp/mixins/metadata_tools_mixin.py +565 -0
- d365fo_client/mcp/mixins/performance_tools_mixin.py +109 -0
- d365fo_client/mcp/mixins/profile_tools_mixin.py +713 -0
- d365fo_client/mcp/mixins/sync_tools_mixin.py +321 -0
- d365fo_client/mcp/prompts/action_execution.py +1 -1
- d365fo_client/mcp/prompts/sequence_analysis.py +1 -1
- d365fo_client/mcp/tools/crud_tools.py +3 -3
- d365fo_client/mcp/tools/sync_tools.py +1 -1
- d365fo_client/mcp/utilities/__init__.py +1 -0
- d365fo_client/mcp/utilities/auth.py +34 -0
- d365fo_client/mcp/utilities/logging.py +58 -0
- d365fo_client/mcp/utilities/types.py +426 -0
- d365fo_client/metadata_v2/sync_manager_v2.py +2 -0
- d365fo_client/metadata_v2/sync_session_manager.py +7 -7
- d365fo_client/models.py +139 -139
- d365fo_client/output.py +2 -2
- d365fo_client/profile_manager.py +62 -27
- d365fo_client/profiles.py +118 -113
- d365fo_client/settings.py +367 -0
- d365fo_client/sync_models.py +85 -2
- d365fo_client/utils.py +2 -1
- {d365fo_client-0.2.4.dist-info → d365fo_client-0.3.1.dist-info}/METADATA +273 -18
- d365fo_client-0.3.1.dist-info/RECORD +85 -0
- d365fo_client-0.3.1.dist-info/entry_points.txt +4 -0
- d365fo_client-0.2.4.dist-info/RECORD +0 -56
- d365fo_client-0.2.4.dist-info/entry_points.txt +0 -3
- {d365fo_client-0.2.4.dist-info → d365fo_client-0.3.1.dist-info}/WHEEL +0 -0
- {d365fo_client-0.2.4.dist-info → d365fo_client-0.3.1.dist-info}/licenses/LICENSE +0 -0
- {d365fo_client-0.2.4.dist-info → d365fo_client-0.3.1.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}
|