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.
@@ -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: ProfileManager
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, 'client_manager') or not self.client_manager:
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(self, error: Exception, tool_name: str, arguments: dict) -> dict:
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
- # Execute query
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
- # 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
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
- return {"error": str(e), "entityName": entity_name, "key_fields": key_fields, "key_values": key_values, "key": key}
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
- # Create entity record
159
- result = await client.create_entity(
160
- entity_name, data
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
- client = await self._get_client(profile)
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
- raise ValueError("Key fields and values length mismatch")
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
- # Update entity record
205
- result = await client.update_entity(
206
- entity_name, key, data
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, # type: ignore
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
- client = await self._get_client(profile)
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
- raise ValueError("Key fields and values length mismatch")
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
- # Delete entity record
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, # type: ignore
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,# type: ignore
270
- parameters: dict = None,# type: ignore
271
- key_fields: List[str] = None,# type: ignore
272
- key_values: List[str] = None,# type: ignore
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,# type: ignore
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
+ }
@@ -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
  ]