d365fo-client 0.3.0__py3-none-any.whl → 0.3.2__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/cli.py +135 -0
- d365fo_client/client.py +371 -19
- d365fo_client/crud.py +33 -10
- d365fo_client/main.py +58 -0
- d365fo_client/mcp/auth_server/auth/providers/apikey.py +83 -0
- d365fo_client/mcp/auth_server/auth/providers/azure.py +91 -23
- d365fo_client/mcp/fastmcp_main.py +92 -43
- d365fo_client/mcp/fastmcp_utils.py +4 -0
- d365fo_client/mcp/mixins/base_tools_mixin.py +13 -12
- d365fo_client/mcp/mixins/crud_tools_mixin.py +181 -40
- d365fo_client/mcp/server.py +10 -1
- d365fo_client/mcp/tools/__init__.py +2 -0
- d365fo_client/mcp/tools/json_service_tools.py +326 -0
- d365fo_client/models.py +45 -0
- d365fo_client/odata_serializer.py +300 -0
- d365fo_client/query.py +30 -20
- d365fo_client/settings.py +14 -2
- {d365fo_client-0.3.0.dist-info → d365fo_client-0.3.2.dist-info}/METADATA +114 -3
- {d365fo_client-0.3.0.dist-info → d365fo_client-0.3.2.dist-info}/RECORD +23 -20
- {d365fo_client-0.3.0.dist-info → d365fo_client-0.3.2.dist-info}/WHEEL +0 -0
- {d365fo_client-0.3.0.dist-info → d365fo_client-0.3.2.dist-info}/entry_points.txt +0 -0
- {d365fo_client-0.3.0.dist-info → d365fo_client-0.3.2.dist-info}/licenses/LICENSE +0 -0
- {d365fo_client-0.3.0.dist-info → d365fo_client-0.3.2.dist-info}/top_level.txt +0 -0
@@ -1,7 +1,6 @@
|
|
1
1
|
"""Base mixin class for FastMCP tool categories."""
|
2
2
|
|
3
3
|
import logging
|
4
|
-
from typing import Optional
|
5
4
|
|
6
5
|
from d365fo_client.client import FOClient
|
7
6
|
from d365fo_client.profile_manager import ProfileManager
|
@@ -13,30 +12,32 @@ logger = logging.getLogger(__name__)
|
|
13
12
|
|
14
13
|
class BaseToolsMixin:
|
15
14
|
"""Base mixin for FastMCP tool categories.
|
16
|
-
|
15
|
+
|
17
16
|
Provides common functionality and client access patterns
|
18
17
|
for all tool category mixins.
|
19
18
|
"""
|
20
|
-
|
19
|
+
|
21
20
|
# These will be injected by the main server class
|
22
21
|
client_manager: D365FOClientManager
|
23
|
-
mcp: FastMCP
|
24
|
-
profile_manager:
|
25
|
-
|
22
|
+
mcp: FastMCP
|
23
|
+
profile_manager: ProfileManager
|
24
|
+
|
26
25
|
async def _get_client(self, profile: str = "default") -> FOClient:
|
27
26
|
"""Get D365FO client for specified profile.
|
28
|
-
|
27
|
+
|
29
28
|
Args:
|
30
29
|
profile: Profile name to use
|
31
|
-
|
30
|
+
|
32
31
|
Returns:
|
33
32
|
Configured D365FO client instance
|
34
33
|
"""
|
35
|
-
if not hasattr(self,
|
34
|
+
if not hasattr(self, "client_manager") or not self.client_manager:
|
36
35
|
raise RuntimeError("Client manager not initialized")
|
37
36
|
return await self.client_manager.get_client(profile)
|
38
|
-
|
39
|
-
def _create_error_response(
|
37
|
+
|
38
|
+
def _create_error_response(
|
39
|
+
self, error: Exception, tool_name: str, arguments: dict
|
40
|
+
) -> dict:
|
40
41
|
"""Create standardized error response.
|
41
42
|
|
42
43
|
Args:
|
@@ -52,4 +53,4 @@ class BaseToolsMixin:
|
|
52
53
|
"tool": tool_name,
|
53
54
|
"arguments": arguments,
|
54
55
|
"error_type": type(error).__name__,
|
55
|
-
}
|
56
|
+
}
|
@@ -10,10 +10,10 @@ logger = logging.getLogger(__name__)
|
|
10
10
|
|
11
11
|
class CrudToolsMixin(BaseToolsMixin):
|
12
12
|
"""CRUD (Create, Read, Update, Delete) tools for FastMCP server."""
|
13
|
-
|
13
|
+
|
14
14
|
def register_crud_tools(self):
|
15
15
|
"""Register all CRUD tools with FastMCP."""
|
16
|
-
|
16
|
+
|
17
17
|
@self.mcp.tool()
|
18
18
|
async def d365fo_query_entities(
|
19
19
|
entity_name: str,
|
@@ -67,7 +67,7 @@ class CrudToolsMixin(BaseToolsMixin):
|
|
67
67
|
expand=expand,
|
68
68
|
)
|
69
69
|
|
70
|
-
#
|
70
|
+
# FOClient now handles validation and schema fetching
|
71
71
|
result = await client.get_entities(entity_name, options=options)
|
72
72
|
|
73
73
|
return {
|
@@ -109,6 +109,18 @@ class CrudToolsMixin(BaseToolsMixin):
|
|
109
109
|
Dictionary with the entity record
|
110
110
|
"""
|
111
111
|
try:
|
112
|
+
# Validate key_fields and key_values match
|
113
|
+
if len(key_fields) != len(key_values):
|
114
|
+
return {
|
115
|
+
"error": "Key fields and values length mismatch",
|
116
|
+
"entityName": entity_name,
|
117
|
+
"key_fields": key_fields,
|
118
|
+
"key_values": key_values,
|
119
|
+
}
|
120
|
+
|
121
|
+
# Build key dict
|
122
|
+
key = {k: v for k, v in zip(key_fields, key_values)}
|
123
|
+
|
112
124
|
client = await self._get_client(profile)
|
113
125
|
|
114
126
|
# Build query options
|
@@ -120,19 +132,32 @@ class CrudToolsMixin(BaseToolsMixin):
|
|
120
132
|
else None
|
121
133
|
)
|
122
134
|
|
123
|
-
#
|
124
|
-
|
125
|
-
|
126
|
-
|
127
|
-
|
128
|
-
# Get entity record
|
129
|
-
result = await client.get_entity_by_key(entity_name, key, options) # type: ignore
|
135
|
+
# FOClient now handles:
|
136
|
+
# - Schema lookup via get_public_entity_schema_by_entityset()
|
137
|
+
# - Entity validation (raises FOClientError if not found)
|
138
|
+
# - Schema-aware key encoding via QueryBuilder
|
139
|
+
result = await client.get_entity(entity_name, key, options)
|
130
140
|
|
131
141
|
return {"entityName": entity_name, "key": key, "data": result}
|
132
142
|
|
133
143
|
except Exception as e:
|
134
144
|
logger.error(f"Get entity record failed: {e}")
|
135
|
-
|
145
|
+
# Build key dict for error response if possible
|
146
|
+
try:
|
147
|
+
key = (
|
148
|
+
{k: v for k, v in zip(key_fields, key_values)}
|
149
|
+
if len(key_fields) == len(key_values)
|
150
|
+
else None
|
151
|
+
)
|
152
|
+
except Exception:
|
153
|
+
key = None
|
154
|
+
return {
|
155
|
+
"error": str(e),
|
156
|
+
"entityName": entity_name,
|
157
|
+
"key_fields": key_fields,
|
158
|
+
"key_values": key_values,
|
159
|
+
"key": key,
|
160
|
+
}
|
136
161
|
|
137
162
|
@self.mcp.tool()
|
138
163
|
async def d365fo_create_entity_record(
|
@@ -155,10 +180,12 @@ class CrudToolsMixin(BaseToolsMixin):
|
|
155
180
|
try:
|
156
181
|
client = await self._get_client(profile)
|
157
182
|
|
158
|
-
#
|
159
|
-
|
160
|
-
|
161
|
-
)
|
183
|
+
# FOClient now handles:
|
184
|
+
# - Schema validation via get_public_entity_schema_by_entityset()
|
185
|
+
# - Entity existence check (raises FOClientError if not found)
|
186
|
+
# - Read-only validation (raises FOClientError if read-only)
|
187
|
+
# - OData serialization via crud_ops
|
188
|
+
result = await client.create_entity(entity_name, data)
|
162
189
|
|
163
190
|
return {
|
164
191
|
"entityName": entity_name,
|
@@ -193,18 +220,27 @@ class CrudToolsMixin(BaseToolsMixin):
|
|
193
220
|
Dictionary with update result
|
194
221
|
"""
|
195
222
|
try:
|
196
|
-
|
197
|
-
|
198
|
-
# Construct key field=value mapping
|
223
|
+
# Validate key_fields and key_values match
|
199
224
|
if len(key_fields) != len(key_values):
|
200
|
-
|
201
|
-
|
225
|
+
return {
|
226
|
+
"error": "Key fields and values length mismatch",
|
227
|
+
"entityName": entity_name,
|
228
|
+
"key_fields": key_fields,
|
229
|
+
"key_values": key_values,
|
230
|
+
"updated": False,
|
231
|
+
}
|
232
|
+
|
233
|
+
# Build key dict
|
202
234
|
key = {k: v for k, v in zip(key_fields, key_values)}
|
203
235
|
|
204
|
-
|
205
|
-
|
206
|
-
|
207
|
-
)
|
236
|
+
client = await self._get_client(profile)
|
237
|
+
|
238
|
+
# FOClient now handles:
|
239
|
+
# - Schema validation via get_public_entity_schema_by_entityset()
|
240
|
+
# - Entity existence check (raises FOClientError if not found)
|
241
|
+
# - Read-only validation (raises FOClientError if read-only)
|
242
|
+
# - Schema-aware key encoding via QueryBuilder
|
243
|
+
result = await client.update_entity(entity_name, key, data)
|
208
244
|
|
209
245
|
return {
|
210
246
|
"entityName": entity_name,
|
@@ -215,10 +251,19 @@ class CrudToolsMixin(BaseToolsMixin):
|
|
215
251
|
|
216
252
|
except Exception as e:
|
217
253
|
logger.error(f"Update entity record failed: {e}")
|
254
|
+
# Build key dict for error response if possible
|
255
|
+
try:
|
256
|
+
key = (
|
257
|
+
{k: v for k, v in zip(key_fields, key_values)}
|
258
|
+
if len(key_fields) == len(key_values)
|
259
|
+
else None
|
260
|
+
)
|
261
|
+
except Exception:
|
262
|
+
key = None
|
218
263
|
return {
|
219
264
|
"error": str(e),
|
220
265
|
"entityName": entity_name,
|
221
|
-
"key": key,
|
266
|
+
"key": key,
|
222
267
|
"updated": False,
|
223
268
|
}
|
224
269
|
|
@@ -227,7 +272,7 @@ class CrudToolsMixin(BaseToolsMixin):
|
|
227
272
|
entity_name: str,
|
228
273
|
key_fields: List[str],
|
229
274
|
key_values: List[str],
|
230
|
-
profile: str = "default"
|
275
|
+
profile: str = "default",
|
231
276
|
) -> dict:
|
232
277
|
"""Delete a record from a D365 Finance & Operations data entity.
|
233
278
|
|
@@ -241,35 +286,55 @@ class CrudToolsMixin(BaseToolsMixin):
|
|
241
286
|
Dictionary with deletion result
|
242
287
|
"""
|
243
288
|
try:
|
244
|
-
|
245
|
-
|
246
|
-
# Construct key field=value mapping
|
289
|
+
# Validate key_fields and key_values match
|
247
290
|
if len(key_fields) != len(key_values):
|
248
|
-
|
249
|
-
|
291
|
+
return {
|
292
|
+
"error": "Key fields and values length mismatch",
|
293
|
+
"entityName": entity_name,
|
294
|
+
"key_fields": key_fields,
|
295
|
+
"key_values": key_values,
|
296
|
+
"deleted": False,
|
297
|
+
}
|
298
|
+
|
299
|
+
# Build key dict
|
250
300
|
key = {k: v for k, v in zip(key_fields, key_values)}
|
251
301
|
|
252
|
-
|
302
|
+
client = await self._get_client(profile)
|
303
|
+
|
304
|
+
# FOClient now handles:
|
305
|
+
# - Schema validation via get_public_entity_schema_by_entityset()
|
306
|
+
# - Entity existence check (raises FOClientError if not found)
|
307
|
+
# - Read-only validation (raises FOClientError if read-only)
|
308
|
+
# - Schema-aware key encoding via QueryBuilder
|
253
309
|
await client.delete_entity(entity_name, key)
|
254
310
|
|
255
311
|
return {"entityName": entity_name, "key": key, "deleted": True}
|
256
312
|
|
257
313
|
except Exception as e:
|
258
314
|
logger.error(f"Delete entity record failed: {e}")
|
315
|
+
# Build key dict for error response if possible
|
316
|
+
try:
|
317
|
+
key = (
|
318
|
+
{k: v for k, v in zip(key_fields, key_values)}
|
319
|
+
if len(key_fields) == len(key_values)
|
320
|
+
else None
|
321
|
+
)
|
322
|
+
except Exception:
|
323
|
+
key = None
|
259
324
|
return {
|
260
325
|
"error": str(e),
|
261
326
|
"entityName": entity_name,
|
262
|
-
"key": key,
|
327
|
+
"key": key,
|
263
328
|
"deleted": False,
|
264
329
|
}
|
265
330
|
|
266
331
|
@self.mcp.tool()
|
267
332
|
async def d365fo_call_action(
|
268
333
|
action_name: str,
|
269
|
-
entity_name: str = None
|
270
|
-
parameters: dict = None
|
271
|
-
key_fields: List[str] = None
|
272
|
-
key_values: List[str] = None
|
334
|
+
entity_name: str = None, # type: ignore
|
335
|
+
parameters: dict = None, # type: ignore
|
336
|
+
key_fields: List[str] = None, # type: ignore
|
337
|
+
key_values: List[str] = None, # type: ignore
|
273
338
|
profile: str = "default",
|
274
339
|
) -> dict:
|
275
340
|
"""Execute an OData action method in D365 Finance & Operations.
|
@@ -297,15 +362,91 @@ class CrudToolsMixin(BaseToolsMixin):
|
|
297
362
|
key = {k: v for k, v in zip(key_fields, key_values)}
|
298
363
|
|
299
364
|
result = await client.call_action(
|
300
|
-
action_name=action_name
|
365
|
+
action_name=action_name, # type: ignore
|
301
366
|
parameters=parameters or {},
|
302
367
|
entity_name=entity_name,
|
303
368
|
entity_key=key,
|
304
|
-
|
305
369
|
)
|
306
370
|
|
307
371
|
return {"actionName": action_name, "success": True, "result": result}
|
308
372
|
|
309
373
|
except Exception as e:
|
310
374
|
logger.error(f"Call action failed: {e}")
|
311
|
-
return {"error": str(e), "actionName": action_name, "success": False}
|
375
|
+
return {"error": str(e), "actionName": action_name, "success": False}
|
376
|
+
|
377
|
+
@self.mcp.tool()
|
378
|
+
async def d365fo_call_json_service(
|
379
|
+
service_group: str,
|
380
|
+
service_name: str,
|
381
|
+
operation_name: str,
|
382
|
+
parameters: Optional[dict] = None,
|
383
|
+
profile: str = "default",
|
384
|
+
) -> dict:
|
385
|
+
"""Call a D365 F&O JSON service endpoint using the /api/services pattern.
|
386
|
+
|
387
|
+
This provides a generic way to invoke any JSON service operation in D365 F&O.
|
388
|
+
|
389
|
+
Args:
|
390
|
+
service_group: Service group name (e.g., 'SysSqlDiagnosticService')
|
391
|
+
service_name: Service name (e.g., 'SysSqlDiagnosticServiceOperations')
|
392
|
+
operation_name: Operation name (e.g., 'GetAxSqlExecuting')
|
393
|
+
parameters: Optional parameters to send in the POST body
|
394
|
+
profile: Configuration profile to use
|
395
|
+
|
396
|
+
Returns:
|
397
|
+
Dictionary with service response data and metadata
|
398
|
+
|
399
|
+
Example:
|
400
|
+
Call a service without parameters:
|
401
|
+
{
|
402
|
+
"service_group": "SysSqlDiagnosticService",
|
403
|
+
"service_name": "SysSqlDiagnosticServiceOperations",
|
404
|
+
"operation_name": "GetAxSqlExecuting"
|
405
|
+
}
|
406
|
+
|
407
|
+
Call a service with parameters:
|
408
|
+
{
|
409
|
+
"service_group": "SysSqlDiagnosticService",
|
410
|
+
"service_name": "SysSqlDiagnosticServiceOperations",
|
411
|
+
"operation_name": "GetAxSqlResourceStats",
|
412
|
+
"parameters": {
|
413
|
+
"start": "2023-01-01T00:00:00Z",
|
414
|
+
"end": "2023-01-02T00:00:00Z"
|
415
|
+
}
|
416
|
+
}
|
417
|
+
"""
|
418
|
+
try:
|
419
|
+
client = await self._get_client(profile)
|
420
|
+
|
421
|
+
# Call the JSON service
|
422
|
+
response = await client.post_json_service(
|
423
|
+
service_group=service_group,
|
424
|
+
service_name=service_name,
|
425
|
+
operation_name=operation_name,
|
426
|
+
parameters=parameters,
|
427
|
+
)
|
428
|
+
|
429
|
+
# Format response
|
430
|
+
result = {
|
431
|
+
"success": response.success,
|
432
|
+
"statusCode": response.status_code,
|
433
|
+
"data": response.data,
|
434
|
+
"serviceGroup": service_group,
|
435
|
+
"serviceName": service_name,
|
436
|
+
"operationName": operation_name,
|
437
|
+
}
|
438
|
+
|
439
|
+
if response.error_message:
|
440
|
+
result["errorMessage"] = response.error_message
|
441
|
+
|
442
|
+
return result
|
443
|
+
|
444
|
+
except Exception as e:
|
445
|
+
logger.error(f"JSON service call failed: {e}")
|
446
|
+
return {
|
447
|
+
"success": False,
|
448
|
+
"error": str(e),
|
449
|
+
"serviceGroup": service_group,
|
450
|
+
"serviceName": service_name,
|
451
|
+
"operationName": operation_name,
|
452
|
+
}
|
d365fo_client/mcp/server.py
CHANGED
@@ -25,7 +25,7 @@ from .resources import (
|
|
25
25
|
MetadataResourceHandler,
|
26
26
|
QueryResourceHandler,
|
27
27
|
)
|
28
|
-
from .tools import ConnectionTools, CrudTools, DatabaseTools, LabelTools, MetadataTools, ProfileTools, SyncTools
|
28
|
+
from .tools import ConnectionTools, CrudTools, DatabaseTools, JsonServiceTools, LabelTools, MetadataTools, ProfileTools, SyncTools
|
29
29
|
|
30
30
|
logger = logging.getLogger(__name__)
|
31
31
|
|
@@ -59,6 +59,7 @@ class D365FOMCPServer:
|
|
59
59
|
self.profile_tools = ProfileTools(self.client_manager)
|
60
60
|
self.database_tools = DatabaseTools(self.client_manager)
|
61
61
|
self.sync_tools = SyncTools(self.client_manager)
|
62
|
+
self.json_service_tools = JsonServiceTools(self.client_manager)
|
62
63
|
|
63
64
|
# Tool registry for execution
|
64
65
|
self.tool_registry = {}
|
@@ -222,6 +223,10 @@ class D365FOMCPServer:
|
|
222
223
|
sync_tools = self.sync_tools.get_tools()
|
223
224
|
tools.extend(sync_tools)
|
224
225
|
|
226
|
+
# Add JSON service tools
|
227
|
+
json_service_tools = self.json_service_tools.get_tools()
|
228
|
+
tools.extend(json_service_tools)
|
229
|
+
|
225
230
|
# Register tools for execution
|
226
231
|
for tool in tools:
|
227
232
|
self.tool_registry[tool.name] = tool
|
@@ -327,6 +332,10 @@ class D365FOMCPServer:
|
|
327
332
|
return await self.sync_tools.execute_list_sync_sessions(arguments)
|
328
333
|
elif name == "d365fo_get_sync_history":
|
329
334
|
return await self.sync_tools.execute_get_sync_history(arguments)
|
335
|
+
elif name == "d365fo_call_json_service":
|
336
|
+
return await self.json_service_tools.execute_call_json_service(arguments)
|
337
|
+
elif name == "d365fo_call_sql_diagnostic_service":
|
338
|
+
return await self.json_service_tools.execute_call_sql_diagnostic_service(arguments)
|
330
339
|
else:
|
331
340
|
raise ValueError(f"Unknown tool: {name}")
|
332
341
|
|
@@ -3,6 +3,7 @@
|
|
3
3
|
from .connection_tools import ConnectionTools
|
4
4
|
from .crud_tools import CrudTools
|
5
5
|
from .database_tools import DatabaseTools
|
6
|
+
from .json_service_tools import JsonServiceTools
|
6
7
|
from .label_tools import LabelTools
|
7
8
|
from .metadata_tools import MetadataTools
|
8
9
|
from .profile_tools import ProfileTools
|
@@ -16,4 +17,5 @@ __all__ = [
|
|
16
17
|
"ProfileTools",
|
17
18
|
"DatabaseTools",
|
18
19
|
"SyncTools",
|
20
|
+
"JsonServiceTools",
|
19
21
|
]
|