d365fo-client 0.3.1__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/fastmcp_main.py +1 -1
- 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-0.3.1.dist-info → d365fo_client-0.3.2.dist-info}/METADATA +114 -3
- {d365fo_client-0.3.1.dist-info → d365fo_client-0.3.2.dist-info}/RECORD +20 -18
- {d365fo_client-0.3.1.dist-info → d365fo_client-0.3.2.dist-info}/WHEEL +0 -0
- {d365fo_client-0.3.1.dist-info → d365fo_client-0.3.2.dist-info}/entry_points.txt +0 -0
- {d365fo_client-0.3.1.dist-info → d365fo_client-0.3.2.dist-info}/licenses/LICENSE +0 -0
- {d365fo_client-0.3.1.dist-info → d365fo_client-0.3.2.dist-info}/top_level.txt +0 -0
d365fo_client/crud.py
CHANGED
@@ -1,13 +1,14 @@
|
|
1
1
|
"""CRUD operations for D365 F&O client."""
|
2
2
|
|
3
|
-
from typing import Any, Dict,
|
4
|
-
|
5
|
-
import aiohttp
|
3
|
+
from typing import Any, Dict, Optional, Union, TYPE_CHECKING
|
6
4
|
|
7
5
|
from .models import QueryOptions
|
8
6
|
from .query import QueryBuilder
|
9
7
|
from .session import SessionManager
|
10
8
|
|
9
|
+
if TYPE_CHECKING:
|
10
|
+
from .models import PublicEntityInfo
|
11
|
+
|
11
12
|
|
12
13
|
class CrudOperations:
|
13
14
|
"""Handles CRUD operations for F&O entities"""
|
@@ -23,13 +24,17 @@ class CrudOperations:
|
|
23
24
|
self.base_url = base_url
|
24
25
|
|
25
26
|
async def get_entities(
|
26
|
-
self,
|
27
|
+
self,
|
28
|
+
entity_name: str,
|
29
|
+
options: Optional[QueryOptions] = None,
|
30
|
+
entity_schema: Optional["PublicEntityInfo"] = None
|
27
31
|
) -> Dict[str, Any]:
|
28
32
|
"""Get entities with OData query options
|
29
33
|
|
30
34
|
Args:
|
31
35
|
entity_name: Name of the entity set
|
32
36
|
options: OData query options
|
37
|
+
entity_schema: Optional entity schema for validation/optimization
|
33
38
|
|
34
39
|
Returns:
|
35
40
|
Response containing entities
|
@@ -52,6 +57,7 @@ class CrudOperations:
|
|
52
57
|
entity_name: str,
|
53
58
|
key: Union[str, Dict[str, Any]],
|
54
59
|
options: Optional[QueryOptions] = None,
|
60
|
+
entity_schema: Optional["PublicEntityInfo"] = None
|
55
61
|
) -> Dict[str, Any]:
|
56
62
|
"""Get single entity by key
|
57
63
|
|
@@ -59,13 +65,15 @@ class CrudOperations:
|
|
59
65
|
entity_name: Name of the entity set
|
60
66
|
key: Entity key value (string for simple keys, dict for composite keys)
|
61
67
|
options: OData query options
|
68
|
+
entity_schema: Optional entity schema for type-aware key encoding
|
62
69
|
|
63
70
|
Returns:
|
64
71
|
Entity data
|
65
72
|
"""
|
66
73
|
session = await self.session_manager.get_session()
|
67
74
|
query_string = QueryBuilder.build_query_string(options)
|
68
|
-
|
75
|
+
# Use schema-aware URL building for proper key encoding
|
76
|
+
url = QueryBuilder.build_entity_url(self.base_url, entity_name, key, entity_schema)
|
69
77
|
url += query_string
|
70
78
|
|
71
79
|
async with session.get(url) as response:
|
@@ -78,13 +86,17 @@ class CrudOperations:
|
|
78
86
|
)
|
79
87
|
|
80
88
|
async def create_entity(
|
81
|
-
self,
|
89
|
+
self,
|
90
|
+
entity_name: str,
|
91
|
+
data: Dict[str, Any],
|
92
|
+
entity_schema: Optional["PublicEntityInfo"] = None
|
82
93
|
) -> Dict[str, Any]:
|
83
94
|
"""Create new entity
|
84
95
|
|
85
96
|
Args:
|
86
97
|
entity_name: Name of the entity set
|
87
98
|
data: Entity data to create
|
99
|
+
entity_schema: Optional entity schema for validation
|
88
100
|
|
89
101
|
Returns:
|
90
102
|
Created entity data
|
@@ -107,6 +119,7 @@ class CrudOperations:
|
|
107
119
|
key: Union[str, Dict[str, Any]],
|
108
120
|
data: Dict[str, Any],
|
109
121
|
method: str = "PATCH",
|
122
|
+
entity_schema: Optional["PublicEntityInfo"] = None
|
110
123
|
) -> Dict[str, Any]:
|
111
124
|
"""Update existing entity
|
112
125
|
|
@@ -115,12 +128,14 @@ class CrudOperations:
|
|
115
128
|
key: Entity key value (string for simple keys, dict for composite keys)
|
116
129
|
data: Updated entity data
|
117
130
|
method: HTTP method (PATCH or PUT)
|
131
|
+
entity_schema: Optional entity schema for type-aware key encoding and validation
|
118
132
|
|
119
133
|
Returns:
|
120
134
|
Updated entity data
|
121
135
|
"""
|
122
136
|
session = await self.session_manager.get_session()
|
123
|
-
|
137
|
+
# Use schema-aware URL building for proper key encoding
|
138
|
+
url = QueryBuilder.build_entity_url(self.base_url, entity_name, key, entity_schema)
|
124
139
|
|
125
140
|
async with session.request(method, url, json=data) as response:
|
126
141
|
if response.status in [200, 204]:
|
@@ -134,19 +149,24 @@ class CrudOperations:
|
|
134
149
|
)
|
135
150
|
|
136
151
|
async def delete_entity(
|
137
|
-
self,
|
152
|
+
self,
|
153
|
+
entity_name: str,
|
154
|
+
key: Union[str, Dict[str, Any]],
|
155
|
+
entity_schema: Optional["PublicEntityInfo"] = None
|
138
156
|
) -> bool:
|
139
157
|
"""Delete entity
|
140
158
|
|
141
159
|
Args:
|
142
160
|
entity_name: Name of the entity set
|
143
161
|
key: Entity key value (string for simple keys, dict for composite keys)
|
162
|
+
entity_schema: Optional entity schema for type-aware key encoding
|
144
163
|
|
145
164
|
Returns:
|
146
165
|
True if successful
|
147
166
|
"""
|
148
167
|
session = await self.session_manager.get_session()
|
149
|
-
|
168
|
+
# Use schema-aware URL building for proper key encoding
|
169
|
+
url = QueryBuilder.build_entity_url(self.base_url, entity_name, key, entity_schema)
|
150
170
|
|
151
171
|
async with session.delete(url) as response:
|
152
172
|
if response.status in [200, 204]:
|
@@ -163,6 +183,7 @@ class CrudOperations:
|
|
163
183
|
parameters: Optional[Dict[str, Any]] = None,
|
164
184
|
entity_name: Optional[str] = None,
|
165
185
|
entity_key: Optional[Union[str, Dict[str, Any]]] = None,
|
186
|
+
entity_schema: Optional["PublicEntityInfo"] = None
|
166
187
|
) -> Any:
|
167
188
|
"""Call OData action method
|
168
189
|
|
@@ -171,13 +192,15 @@ class CrudOperations:
|
|
171
192
|
parameters: Action parameters
|
172
193
|
entity_name: Entity name for bound actions
|
173
194
|
entity_key: Entity key for bound actions (string for simple keys, dict for composite keys)
|
195
|
+
entity_schema: Optional entity schema for type-aware key encoding in bound actions
|
174
196
|
|
175
197
|
Returns:
|
176
198
|
Action result
|
177
199
|
"""
|
178
200
|
session = await self.session_manager.get_session()
|
201
|
+
# Use schema-aware URL building for entity-bound actions
|
179
202
|
url = QueryBuilder.build_action_url(
|
180
|
-
self.base_url, action_name, entity_name, entity_key
|
203
|
+
self.base_url, action_name, entity_name, entity_key, entity_schema
|
181
204
|
)
|
182
205
|
|
183
206
|
# Prepare request body
|
d365fo_client/main.py
CHANGED
@@ -236,6 +236,7 @@ def create_argument_parser() -> argparse.ArgumentParser:
|
|
236
236
|
_add_metadata_commands(subparsers)
|
237
237
|
_add_entity_commands(subparsers)
|
238
238
|
_add_action_commands(subparsers)
|
239
|
+
_add_service_commands(subparsers)
|
239
240
|
_add_config_commands(subparsers)
|
240
241
|
|
241
242
|
return parser
|
@@ -449,6 +450,63 @@ def _add_config_commands(subparsers) -> None:
|
|
449
450
|
default_parser.add_argument("profile_name", help="Profile name")
|
450
451
|
|
451
452
|
|
453
|
+
def _add_service_commands(subparsers) -> None:
|
454
|
+
"""Add JSON service commands."""
|
455
|
+
service_parser = subparsers.add_parser("service", help="JSON service operations")
|
456
|
+
service_subs = service_parser.add_subparsers(
|
457
|
+
dest="service_subcommand", help="Service subcommands"
|
458
|
+
)
|
459
|
+
|
460
|
+
# call subcommand - generic service call
|
461
|
+
call_parser = service_subs.add_parser(
|
462
|
+
"call", help="Call a generic JSON service endpoint"
|
463
|
+
)
|
464
|
+
call_parser.add_argument(
|
465
|
+
"service_group", help="Service group name (e.g., 'SysSqlDiagnosticService')"
|
466
|
+
)
|
467
|
+
call_parser.add_argument(
|
468
|
+
"service_name",
|
469
|
+
help="Service name (e.g., 'SysSqlDiagnosticServiceOperations')"
|
470
|
+
)
|
471
|
+
call_parser.add_argument(
|
472
|
+
"operation_name", help="Operation name (e.g., 'GetAxSqlExecuting')"
|
473
|
+
)
|
474
|
+
call_parser.add_argument(
|
475
|
+
"--parameters",
|
476
|
+
help="JSON string with parameters to send in POST body",
|
477
|
+
)
|
478
|
+
|
479
|
+
# sql-diagnostic subcommand - convenience wrapper for SQL diagnostic operations
|
480
|
+
sql_parser = service_subs.add_parser(
|
481
|
+
"sql-diagnostic", help="Call SQL diagnostic service operations"
|
482
|
+
)
|
483
|
+
sql_parser.add_argument(
|
484
|
+
"operation",
|
485
|
+
choices=[
|
486
|
+
"GetAxSqlExecuting",
|
487
|
+
"GetAxSqlResourceStats",
|
488
|
+
"GetAxSqlBlocking",
|
489
|
+
"GetAxSqlLockInfo",
|
490
|
+
"GetAxSqlDisabledIndexes",
|
491
|
+
],
|
492
|
+
help="SQL diagnostic operation to execute",
|
493
|
+
)
|
494
|
+
sql_parser.add_argument(
|
495
|
+
"--since-minutes",
|
496
|
+
type=int,
|
497
|
+
default=10,
|
498
|
+
help="For GetAxSqlResourceStats: get stats for last N minutes (default: 10)",
|
499
|
+
)
|
500
|
+
sql_parser.add_argument(
|
501
|
+
"--start-time",
|
502
|
+
help="For GetAxSqlResourceStats: start time (ISO format)",
|
503
|
+
)
|
504
|
+
sql_parser.add_argument(
|
505
|
+
"--end-time",
|
506
|
+
help="For GetAxSqlResourceStats: end time (ISO format)",
|
507
|
+
)
|
508
|
+
|
509
|
+
|
452
510
|
def main() -> None:
|
453
511
|
"""Enhanced main entry point with CLI support."""
|
454
512
|
parser = create_argument_parser()
|
@@ -341,7 +341,7 @@ if is_remote_transport:
|
|
341
341
|
# Initialize FastMCP server with configuration
|
342
342
|
mcp = FastMCP(
|
343
343
|
name=server_config.get("name", "d365fo-mcp-server"),
|
344
|
-
auth_server_provider=auth_provider if isinstance(auth_provider, AzureProvider) else None
|
344
|
+
auth_server_provider=auth_provider if isinstance(auth_provider, AzureProvider) else None,# type: ignore
|
345
345
|
token_verifier=auth_provider if isinstance(auth_provider, APIKeyVerifier) else None,
|
346
346
|
auth=auth,
|
347
347
|
instructions=server_config.get(
|
@@ -144,6 +144,10 @@ def create_default_profile_if_needed(profile_manager:"ProfileManager", config:Di
|
|
144
144
|
if not base_url:
|
145
145
|
logger.warning("Cannot create default profile - D365FO_BASE_URL not set")
|
146
146
|
return False
|
147
|
+
|
148
|
+
if base_url.startswith("https://usnconeboxax1aos.cloud.onebox.dynamics.com"):
|
149
|
+
logger.warning("D365FO_BASE_URL is set to the default onebox URL - please set it to your actual environment URL")
|
150
|
+
return False
|
147
151
|
|
148
152
|
# Determine authentication mode based on startup mode
|
149
153
|
startup_mode = config.get("startup_mode", "profile_only")
|
@@ -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
|
|