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.
- 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/azure.py +325 -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 +358 -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 +355 -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.0.dist-info}/METADATA +273 -18
- d365fo_client-0.3.0.dist-info/RECORD +84 -0
- d365fo_client-0.3.0.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.0.dist-info}/WHEEL +0 -0
- {d365fo_client-0.2.4.dist-info → d365fo_client-0.3.0.dist-info}/licenses/LICENSE +0 -0
- {d365fo_client-0.2.4.dist-info → d365fo_client-0.3.0.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,598 @@
|
|
1
|
+
"""FastMCP-based D365FO MCP Server implementation.
|
2
|
+
|
3
|
+
This module provides a FastMCP-based implementation of the D365FO MCP server
|
4
|
+
with support for multiple transports (stdio, SSE, streamable-HTTP) and
|
5
|
+
improved performance, scalability, and deployment flexibility.
|
6
|
+
|
7
|
+
Refactored using mixin pattern for modular tool organization.
|
8
|
+
"""
|
9
|
+
|
10
|
+
import asyncio
|
11
|
+
import json
|
12
|
+
import logging
|
13
|
+
import os
|
14
|
+
import time
|
15
|
+
from collections import defaultdict
|
16
|
+
from datetime import datetime, timedelta
|
17
|
+
from typing import Any, Dict, List, Optional
|
18
|
+
from weakref import WeakValueDictionary
|
19
|
+
|
20
|
+
from mcp.server.fastmcp import FastMCP
|
21
|
+
from mcp.types import TextContent
|
22
|
+
|
23
|
+
from .. import __version__
|
24
|
+
from ..profile_manager import ProfileManager
|
25
|
+
from .client_manager import D365FOClientManager
|
26
|
+
from .models import MCPServerConfig
|
27
|
+
from .mixins import (
|
28
|
+
ConnectionToolsMixin,
|
29
|
+
CrudToolsMixin,
|
30
|
+
DatabaseToolsMixin,
|
31
|
+
LabelToolsMixin,
|
32
|
+
MetadataToolsMixin,
|
33
|
+
PerformanceToolsMixin,
|
34
|
+
ProfileToolsMixin,
|
35
|
+
SyncToolsMixin,
|
36
|
+
)
|
37
|
+
|
38
|
+
logger = logging.getLogger(__name__)
|
39
|
+
|
40
|
+
|
41
|
+
class SessionContext:
|
42
|
+
"""Simple session context that can be weakly referenced."""
|
43
|
+
|
44
|
+
def __init__(self, session_id: str, stateless: bool = True):
|
45
|
+
self.session_id = session_id
|
46
|
+
self.created_at = datetime.now()
|
47
|
+
self.last_accessed = datetime.now()
|
48
|
+
self.stateless = stateless
|
49
|
+
self.request_count = 0
|
50
|
+
|
51
|
+
def to_dict(self) -> dict:
|
52
|
+
"""Convert to dictionary for API compatibility."""
|
53
|
+
return {
|
54
|
+
"session_id": self.session_id,
|
55
|
+
"created_at": self.created_at,
|
56
|
+
"last_accessed": self.last_accessed,
|
57
|
+
"stateless": self.stateless,
|
58
|
+
"request_count": self.request_count,
|
59
|
+
}
|
60
|
+
|
61
|
+
def __getitem__(self, key):
|
62
|
+
"""Support dict-like access for backward compatibility."""
|
63
|
+
return getattr(self, key, None)
|
64
|
+
|
65
|
+
def __setitem__(self, key, value):
|
66
|
+
"""Support dict-like access for backward compatibility."""
|
67
|
+
setattr(self, key, value)
|
68
|
+
|
69
|
+
|
70
|
+
class FastD365FOMCPServer(
|
71
|
+
DatabaseToolsMixin,
|
72
|
+
MetadataToolsMixin,
|
73
|
+
CrudToolsMixin,
|
74
|
+
ProfileToolsMixin,
|
75
|
+
SyncToolsMixin,
|
76
|
+
LabelToolsMixin,
|
77
|
+
ConnectionToolsMixin,
|
78
|
+
PerformanceToolsMixin,
|
79
|
+
):
|
80
|
+
"""FastMCP-based D365FO MCP Server with multi-transport support."""
|
81
|
+
|
82
|
+
def __init__(self, mcp: FastMCP, config: Optional[Dict[str, Any]] = None,profile_manager: Optional[ProfileManager] = None):
|
83
|
+
"""Initialize the FastMCP D365FO server.
|
84
|
+
|
85
|
+
Args:
|
86
|
+
config: Configuration dictionary with server and transport settings
|
87
|
+
"""
|
88
|
+
if config is not None:
|
89
|
+
self.config = config
|
90
|
+
else:
|
91
|
+
from .fastmcp_utils import load_default_config
|
92
|
+
self.config = load_default_config()
|
93
|
+
|
94
|
+
self.profile_manager = profile_manager or ProfileManager()
|
95
|
+
self.client_manager = D365FOClientManager(self.profile_manager)
|
96
|
+
|
97
|
+
|
98
|
+
self.mcp = mcp
|
99
|
+
|
100
|
+
# Setup dependency injection and features
|
101
|
+
self._setup_dependency_injection()
|
102
|
+
self._setup_production_features()
|
103
|
+
self._setup_mixin_tools()
|
104
|
+
self._startup_initialization()
|
105
|
+
|
106
|
+
# Register all components
|
107
|
+
self._register_tools()
|
108
|
+
self._register_resources()
|
109
|
+
self._register_prompts()
|
110
|
+
|
111
|
+
def _setup_dependency_injection(self):
|
112
|
+
"""Set up dependency injection for tools to access client manager."""
|
113
|
+
# Store client manager reference for use in tool functions
|
114
|
+
pass
|
115
|
+
|
116
|
+
def _setup_production_features(self):
|
117
|
+
"""Set up production features including performance monitoring and session management."""
|
118
|
+
# Performance monitoring
|
119
|
+
self._request_stats = {
|
120
|
+
"total_requests": 0,
|
121
|
+
"total_errors": 0,
|
122
|
+
"avg_response_time": 0.0,
|
123
|
+
"last_reset": datetime.now(),
|
124
|
+
}
|
125
|
+
self._request_times = []
|
126
|
+
self._max_request_history = 1000
|
127
|
+
|
128
|
+
# Connection pool monitoring
|
129
|
+
self._connection_pool_stats = {
|
130
|
+
"active_connections": 0,
|
131
|
+
"peak_connections": 0,
|
132
|
+
"connection_errors": 0,
|
133
|
+
"pool_hits": 0,
|
134
|
+
"pool_misses": 0,
|
135
|
+
}
|
136
|
+
|
137
|
+
# Stateless session management (for HTTP transport)
|
138
|
+
transport_config = self.config.get("server", {}).get("transport", {})
|
139
|
+
self._stateless_mode = transport_config.get("http", {}).get("stateless", False)
|
140
|
+
self._json_response_mode = transport_config.get("http", {}).get(
|
141
|
+
"json_response", False
|
142
|
+
)
|
143
|
+
|
144
|
+
if self._stateless_mode:
|
145
|
+
logger.info("Stateless HTTP mode enabled - sessions will not be persisted")
|
146
|
+
# Use weak references for stateless sessions to allow garbage collection
|
147
|
+
self._stateless_sessions = WeakValueDictionary()
|
148
|
+
else:
|
149
|
+
# Standard session management for stateful mode
|
150
|
+
self._active_sessions = {}
|
151
|
+
|
152
|
+
if self._json_response_mode:
|
153
|
+
logger.info("JSON response mode enabled - responses will be in JSON format")
|
154
|
+
|
155
|
+
# Performance optimization settings
|
156
|
+
perf_config = self.config.get("performance", {})
|
157
|
+
self._max_concurrent_requests = perf_config.get("max_concurrent_requests", 10)
|
158
|
+
self._request_timeout = perf_config.get("request_timeout", 30)
|
159
|
+
self._batch_size = perf_config.get("batch_size", 100)
|
160
|
+
|
161
|
+
# Connection pooling semaphore
|
162
|
+
self._request_semaphore = asyncio.Semaphore(self._max_concurrent_requests)
|
163
|
+
|
164
|
+
logger.info(f"Production features configured:")
|
165
|
+
logger.info(f" - Stateless mode: {self._stateless_mode}")
|
166
|
+
logger.info(f" - JSON response mode: {self._json_response_mode}")
|
167
|
+
logger.info(f" - Max concurrent requests: {self._max_concurrent_requests}")
|
168
|
+
logger.info(f" - Request timeout: {self._request_timeout}s")
|
169
|
+
|
170
|
+
def _setup_mixin_tools(self):
|
171
|
+
"""Setup tool-specific configurations for mixins."""
|
172
|
+
self.setup_database_tools()
|
173
|
+
# Add other tool setup calls as needed
|
174
|
+
|
175
|
+
def _register_tools(self):
|
176
|
+
"""Register all tools using mixins."""
|
177
|
+
logger.info("Registering tools from mixins...")
|
178
|
+
|
179
|
+
# Register tools from each mixin
|
180
|
+
self.register_database_tools()
|
181
|
+
self.register_metadata_tools()
|
182
|
+
self.register_crud_tools()
|
183
|
+
self.register_profile_tools()
|
184
|
+
self.register_sync_tools()
|
185
|
+
self.register_label_tools()
|
186
|
+
self.register_connection_tools()
|
187
|
+
self.register_performance_tools()
|
188
|
+
|
189
|
+
logger.info("All tools registered successfully")
|
190
|
+
|
191
|
+
def _performance_monitor(self, func):
|
192
|
+
"""Decorator to monitor performance of tool executions."""
|
193
|
+
|
194
|
+
async def wrapper(*args, **kwargs):
|
195
|
+
start_time = time.time()
|
196
|
+
|
197
|
+
# Increment request counter
|
198
|
+
self._request_stats["total_requests"] += 1
|
199
|
+
|
200
|
+
# Apply request limiting
|
201
|
+
async with self._request_semaphore:
|
202
|
+
try:
|
203
|
+
# Execute with timeout
|
204
|
+
result = await asyncio.wait_for(
|
205
|
+
func(*args, **kwargs), timeout=self._request_timeout
|
206
|
+
)
|
207
|
+
|
208
|
+
# Update performance stats
|
209
|
+
execution_time = time.time() - start_time
|
210
|
+
self._request_times.append(execution_time)
|
211
|
+
|
212
|
+
# Limit request history to prevent memory growth
|
213
|
+
if len(self._request_times) > self._max_request_history:
|
214
|
+
self._request_times = self._request_times[-self._max_request_history:]
|
215
|
+
|
216
|
+
# Update average response time
|
217
|
+
if self._request_times:
|
218
|
+
self._request_stats["avg_response_time"] = sum(self._request_times) / len(
|
219
|
+
self._request_times
|
220
|
+
)
|
221
|
+
|
222
|
+
return result
|
223
|
+
|
224
|
+
except asyncio.TimeoutError:
|
225
|
+
self._request_stats["total_errors"] += 1
|
226
|
+
logger.error(f"Request timeout after {self._request_timeout} seconds")
|
227
|
+
raise
|
228
|
+
|
229
|
+
except Exception as e:
|
230
|
+
self._request_stats["total_errors"] += 1
|
231
|
+
logger.error(f"Request execution failed: {e}")
|
232
|
+
raise
|
233
|
+
|
234
|
+
return wrapper
|
235
|
+
|
236
|
+
def get_performance_stats(self) -> dict:
|
237
|
+
"""Get current performance statistics.
|
238
|
+
|
239
|
+
Returns:
|
240
|
+
Dictionary containing performance metrics
|
241
|
+
"""
|
242
|
+
current_time = time.time()
|
243
|
+
|
244
|
+
# Calculate percentiles for response times
|
245
|
+
percentiles = {}
|
246
|
+
if self._request_times:
|
247
|
+
sorted_times = sorted(self._request_times)
|
248
|
+
percentiles = {
|
249
|
+
"p50": self._percentile(sorted_times, 50),
|
250
|
+
"p90": self._percentile(sorted_times, 90),
|
251
|
+
"p95": self._percentile(sorted_times, 95),
|
252
|
+
"p99": self._percentile(sorted_times, 99),
|
253
|
+
}
|
254
|
+
|
255
|
+
return {
|
256
|
+
"request_stats": self._request_stats.copy(),
|
257
|
+
"connection_pool_stats": self._connection_pool_stats.copy(),
|
258
|
+
"response_time_percentiles": percentiles,
|
259
|
+
"active_sessions": len(getattr(self, '_active_sessions', {})),
|
260
|
+
"stateless_sessions": len(getattr(self, '_stateless_sessions', {})),
|
261
|
+
"server_uptime_seconds": current_time - getattr(self, '_server_start_time', current_time),
|
262
|
+
"memory_usage": {
|
263
|
+
"request_history_count": len(self._request_times),
|
264
|
+
"max_request_history": self._max_request_history,
|
265
|
+
},
|
266
|
+
}
|
267
|
+
|
268
|
+
def _percentile(self, data: List[float], percentile: int) -> float:
|
269
|
+
"""Calculate the specified percentile of a dataset.
|
270
|
+
|
271
|
+
Args:
|
272
|
+
data: Sorted list of numeric values
|
273
|
+
percentile: Percentile to calculate (0-100)
|
274
|
+
|
275
|
+
Returns:
|
276
|
+
Percentile value
|
277
|
+
"""
|
278
|
+
if not data:
|
279
|
+
return 0.0
|
280
|
+
|
281
|
+
k = (len(data) - 1) * percentile / 100
|
282
|
+
f = int(k)
|
283
|
+
c = k - f
|
284
|
+
|
285
|
+
if f + 1 < len(data):
|
286
|
+
return data[f] + (c * (data[f + 1] - data[f]))
|
287
|
+
else:
|
288
|
+
return data[f]
|
289
|
+
|
290
|
+
def _cleanup_expired_sessions(self):
|
291
|
+
"""Clean up expired sessions for memory management."""
|
292
|
+
if hasattr(self, '_active_sessions'):
|
293
|
+
current_time = datetime.now()
|
294
|
+
expired_sessions = []
|
295
|
+
|
296
|
+
for session_id, session in self._active_sessions.items():
|
297
|
+
# Sessions expire after 1 hour of inactivity
|
298
|
+
if (current_time - session.last_accessed).total_seconds() > 3600:
|
299
|
+
expired_sessions.append(session_id)
|
300
|
+
|
301
|
+
for session_id in expired_sessions:
|
302
|
+
del self._active_sessions[session_id]
|
303
|
+
logger.debug(f"Cleaned up expired session: {session_id}")
|
304
|
+
|
305
|
+
if expired_sessions:
|
306
|
+
logger.info(f"Cleaned up {len(expired_sessions)} expired sessions")
|
307
|
+
|
308
|
+
def _get_session_context(self, session_id: Optional[str] = None) -> SessionContext:
|
309
|
+
"""Get or create session context for request tracking."""
|
310
|
+
if not session_id:
|
311
|
+
import uuid
|
312
|
+
session_id = str(uuid.uuid4())
|
313
|
+
|
314
|
+
if self._stateless_mode:
|
315
|
+
# For stateless mode, create temporary session
|
316
|
+
return SessionContext(session_id, stateless=True)
|
317
|
+
else:
|
318
|
+
# For stateful mode, track sessions
|
319
|
+
if session_id not in self._active_sessions:
|
320
|
+
self._active_sessions[session_id] = SessionContext(session_id, stateless=False)
|
321
|
+
return self._active_sessions[session_id]
|
322
|
+
|
323
|
+
def _record_request_time(self, execution_time: float):
|
324
|
+
"""Record request execution time for performance monitoring."""
|
325
|
+
self._request_times.append(execution_time)
|
326
|
+
|
327
|
+
# Limit request history to prevent memory growth
|
328
|
+
if len(self._request_times) > self._max_request_history:
|
329
|
+
self._request_times = self._request_times[-self._max_request_history:]
|
330
|
+
|
331
|
+
# Update average response time
|
332
|
+
if self._request_times:
|
333
|
+
self._request_stats["avg_response_time"] = sum(self._request_times) / len(
|
334
|
+
self._request_times
|
335
|
+
)
|
336
|
+
|
337
|
+
def _startup_initialization(self):
|
338
|
+
"""Perform startup initialization tasks."""
|
339
|
+
# Set server start time for uptime calculation
|
340
|
+
self._server_start_time = time.time()
|
341
|
+
|
342
|
+
# Initialize any additional startup tasks
|
343
|
+
logger.info("FastMCP server startup initialization completed")
|
344
|
+
|
345
|
+
async def cleanup(self):
|
346
|
+
"""Clean up server resources."""
|
347
|
+
# Clean up expired sessions
|
348
|
+
self._cleanup_expired_sessions()
|
349
|
+
|
350
|
+
# Reset performance stats if needed
|
351
|
+
logger.debug("Server cleanup completed")
|
352
|
+
|
353
|
+
def _register_resources(self):
|
354
|
+
"""Register D365FO resources using FastMCP decorators."""
|
355
|
+
|
356
|
+
@self.mcp.resource("d365fo://entities/{entity_name}")
|
357
|
+
async def entity_resource(entity_name: str) -> str:
|
358
|
+
"""Get entity metadata and sample data.
|
359
|
+
|
360
|
+
Args:
|
361
|
+
entity_name: Name of the entity to retrieve
|
362
|
+
|
363
|
+
Returns:
|
364
|
+
JSON string with entity information
|
365
|
+
"""
|
366
|
+
try:
|
367
|
+
client = await self.client_manager.get_client("default")
|
368
|
+
|
369
|
+
# Get entity schema
|
370
|
+
entity_info = await client.get_public_entity_info(entity_name)
|
371
|
+
if not entity_info:
|
372
|
+
return json.dumps({"error": f"Entity '{entity_name}' not found"})
|
373
|
+
|
374
|
+
# Get sample data if entity has data service enabled
|
375
|
+
sample_data = []
|
376
|
+
try:
|
377
|
+
from ..models import QueryOptions
|
378
|
+
options = QueryOptions(top=5) # Get 5 sample records
|
379
|
+
result = await client.get_entities(entity_name, options=options)
|
380
|
+
sample_data = result.get("value", [])
|
381
|
+
except Exception:
|
382
|
+
pass # Ignore errors getting sample data
|
383
|
+
|
384
|
+
return json.dumps({
|
385
|
+
"entity_name": entity_name,
|
386
|
+
"schema": entity_info.to_dict(),
|
387
|
+
"sample_data": sample_data,
|
388
|
+
"sample_count": len(sample_data),
|
389
|
+
})
|
390
|
+
|
391
|
+
except Exception as e:
|
392
|
+
logger.error(f"Entity resource failed: {e}")
|
393
|
+
return json.dumps({"error": str(e), "entity_name": entity_name})
|
394
|
+
|
395
|
+
@self.mcp.resource("d365fo://metadata/environment")
|
396
|
+
async def environment_metadata() -> str:
|
397
|
+
"""Get D365FO environment metadata.
|
398
|
+
|
399
|
+
Returns:
|
400
|
+
JSON string with environment information
|
401
|
+
"""
|
402
|
+
try:
|
403
|
+
# Get environment info from default profile
|
404
|
+
result = await self.client_manager.get_environment_info("default")
|
405
|
+
return json.dumps(result, indent=2)
|
406
|
+
|
407
|
+
except Exception as e:
|
408
|
+
logger.error(f"Environment metadata resource failed: {e}")
|
409
|
+
return json.dumps({"error": str(e)})
|
410
|
+
|
411
|
+
@self.mcp.resource("d365fo://profiles")
|
412
|
+
async def profiles_resource() -> str:
|
413
|
+
"""Get all available profiles.
|
414
|
+
|
415
|
+
Returns:
|
416
|
+
JSON string with profiles list
|
417
|
+
"""
|
418
|
+
try:
|
419
|
+
profiles = self.profile_manager.list_profiles()
|
420
|
+
# Convert Profile objects to dictionaries for JSON serialization
|
421
|
+
profiles_data = []
|
422
|
+
for profile in profiles:
|
423
|
+
if hasattr(profile, 'to_dict'):
|
424
|
+
profiles_data.append(profile.to_dict())
|
425
|
+
else:
|
426
|
+
# Fallback: convert to dict manually
|
427
|
+
profiles_data.append({
|
428
|
+
"name": getattr(profile, 'name', 'Unknown'),
|
429
|
+
"base_url": getattr(profile, 'base_url', ''),
|
430
|
+
"client_id": getattr(profile, 'client_id', ''),
|
431
|
+
"tenant_id": getattr(profile, 'tenant_id', ''),
|
432
|
+
# Add other relevant fields as needed
|
433
|
+
})
|
434
|
+
|
435
|
+
return json.dumps({
|
436
|
+
"profiles": profiles_data,
|
437
|
+
"total_count": len(profiles_data),
|
438
|
+
})
|
439
|
+
|
440
|
+
except Exception as e:
|
441
|
+
logger.error(f"Profiles resource failed: {e}")
|
442
|
+
return json.dumps({"error": str(e)})
|
443
|
+
|
444
|
+
@self.mcp.resource("d365fo://query/{entity_name}")
|
445
|
+
async def query_resource(entity_name: str) -> str:
|
446
|
+
"""Execute a simple query on an entity.
|
447
|
+
|
448
|
+
Args:
|
449
|
+
entity_name: Name of the entity to query
|
450
|
+
|
451
|
+
Returns:
|
452
|
+
JSON string with query results
|
453
|
+
"""
|
454
|
+
try:
|
455
|
+
client = await self.client_manager.get_client("default")
|
456
|
+
|
457
|
+
from ..models import QueryOptions
|
458
|
+
options = QueryOptions(top=10) # Get 10 records
|
459
|
+
result = await client.get_entities(entity_name, options=options)
|
460
|
+
|
461
|
+
return json.dumps({
|
462
|
+
"entity_name": entity_name,
|
463
|
+
"data": result.get("value", []),
|
464
|
+
"count": len(result.get("value", [])),
|
465
|
+
"has_more": "@odata.nextLink" in result,
|
466
|
+
})
|
467
|
+
|
468
|
+
except Exception as e:
|
469
|
+
logger.error(f"Query resource failed: {e}")
|
470
|
+
return json.dumps({"error": str(e), "entity_name": entity_name})
|
471
|
+
|
472
|
+
logger.info("Registered D365FO resources")
|
473
|
+
|
474
|
+
def _register_prompts(self):
|
475
|
+
"""Register D365FO prompts using FastMCP decorators."""
|
476
|
+
|
477
|
+
@self.mcp.prompt()
|
478
|
+
async def d365fo_entity_analysis(entity_name: str) -> str:
|
479
|
+
"""Analyze a D365FO data entity structure and provide insights.
|
480
|
+
|
481
|
+
Args:
|
482
|
+
entity_name: Name of the entity to analyze
|
483
|
+
|
484
|
+
Returns:
|
485
|
+
Analysis prompt text
|
486
|
+
"""
|
487
|
+
try:
|
488
|
+
client = await self.client_manager.get_client("default")
|
489
|
+
|
490
|
+
# Get entity information
|
491
|
+
entity_info = await client.get_public_entity_info(entity_name)
|
492
|
+
if not entity_info:
|
493
|
+
return f"Entity '{entity_name}' not found in D365 F&O."
|
494
|
+
|
495
|
+
# Build analysis
|
496
|
+
analysis = [
|
497
|
+
f"# D365 F&O Entity Analysis: {entity_name}",
|
498
|
+
f"",
|
499
|
+
f"**Entity Name**: {entity_info.name}",
|
500
|
+
f"**Label**: {entity_info.label_text or 'N/A'}",
|
501
|
+
f"**Category**: {getattr(entity_info, 'entity_category', 'Unknown')}",
|
502
|
+
f"**OData Enabled**: {'Yes' if getattr(entity_info, 'data_service_enabled', False) else 'No'}",
|
503
|
+
f"**DMF Enabled**: {'Yes' if getattr(entity_info, 'data_management_enabled', False) else 'No'}",
|
504
|
+
f"**Read Only**: {'Yes' if getattr(entity_info, 'is_read_only', False) else 'No'}",
|
505
|
+
f"",
|
506
|
+
]
|
507
|
+
|
508
|
+
# Add properties information
|
509
|
+
if hasattr(entity_info, 'properties') and entity_info.properties:
|
510
|
+
analysis.extend([
|
511
|
+
f"## Properties ({len(entity_info.properties)} total)",
|
512
|
+
f"",
|
513
|
+
])
|
514
|
+
|
515
|
+
# Key fields
|
516
|
+
key_props = [p for p in entity_info.properties if getattr(p, 'is_key', False)]
|
517
|
+
if key_props:
|
518
|
+
analysis.append("**Key Fields:**")
|
519
|
+
for prop in key_props:
|
520
|
+
analysis.append(f"- {prop.name} ({prop.data_type})")
|
521
|
+
analysis.append("")
|
522
|
+
|
523
|
+
# Required fields
|
524
|
+
required_props = [p for p in entity_info.properties if getattr(p, 'is_mandatory', False)]
|
525
|
+
if required_props:
|
526
|
+
analysis.append("**Required Fields:**")
|
527
|
+
for prop in required_props:
|
528
|
+
analysis.append(f"- {prop.name} ({prop.data_type})")
|
529
|
+
analysis.append("")
|
530
|
+
|
531
|
+
# Add actions information
|
532
|
+
if hasattr(entity_info, 'actions') and entity_info.actions:
|
533
|
+
analysis.extend([
|
534
|
+
f"## Available Actions ({len(entity_info.actions)} total)",
|
535
|
+
f"",
|
536
|
+
])
|
537
|
+
|
538
|
+
for action in entity_info.actions:
|
539
|
+
analysis.append(f"- **{action.name}**: {action.binding_kind}")
|
540
|
+
|
541
|
+
return "\n".join(analysis)
|
542
|
+
|
543
|
+
except Exception as e:
|
544
|
+
return f"Error analyzing entity '{entity_name}': {str(e)}"
|
545
|
+
|
546
|
+
@self.mcp.prompt()
|
547
|
+
async def d365fo_environment_summary() -> str:
|
548
|
+
"""Generate a summary of the D365FO environment.
|
549
|
+
|
550
|
+
Returns:
|
551
|
+
Environment summary prompt text
|
552
|
+
"""
|
553
|
+
try:
|
554
|
+
# Get environment info
|
555
|
+
env_info = await self.client_manager.get_environment_info("default")
|
556
|
+
|
557
|
+
summary = [
|
558
|
+
"# D365 Finance & Operations Environment Summary",
|
559
|
+
"",
|
560
|
+
f"**Environment**: {env_info.get('environment_name', 'Unknown')}",
|
561
|
+
f"**Base URL**: {env_info.get('base_url', 'N/A')}",
|
562
|
+
f"**Application Version**: {env_info.get('versions', {}).get('application', env_info.get('version', {}).get('application_version', 'Unknown'))}",
|
563
|
+
f"**Platform Version**: {env_info.get('versions', {}).get('platform', env_info.get('platform_version', 'Unknown'))}",
|
564
|
+
f"**Build Version**: {env_info.get('versions', {}).get('build', 'Unknown')}",
|
565
|
+
"",
|
566
|
+
"## Available Features",
|
567
|
+
"- OData API for data access",
|
568
|
+
"- Data Management Framework (DMF)",
|
569
|
+
"- Action methods for business operations",
|
570
|
+
"- Label system for multilingual support",
|
571
|
+
"",
|
572
|
+
"## Connection Status",
|
573
|
+
f"**Status**: {'Connected' if env_info.get('connection_status') == 'success' else 'Error'}",
|
574
|
+
f"**Authentication**: {env_info.get('auth_mode', 'Unknown')}",
|
575
|
+
"",
|
576
|
+
]
|
577
|
+
|
578
|
+
return "\n".join(summary)
|
579
|
+
|
580
|
+
except Exception as e:
|
581
|
+
return f"Error getting environment summary: {str(e)}"
|
582
|
+
|
583
|
+
logger.info("Registered D365FO prompts")
|
584
|
+
|
585
|
+
|
586
|
+
# Server lifecycle methods (preserved from original)
|
587
|
+
async def shutdown(self):
|
588
|
+
"""Gracefully shutdown the server."""
|
589
|
+
logger.info("Shutting down FastD365FOMCPServer...")
|
590
|
+
|
591
|
+
# Clean up sessions
|
592
|
+
self._cleanup_expired_sessions()
|
593
|
+
|
594
|
+
# Shutdown client manager
|
595
|
+
if hasattr(self.client_manager, 'shutdown'):
|
596
|
+
await self.client_manager.shutdown() # type: ignore
|
597
|
+
|
598
|
+
logger.info("FastD365FOMCPServer shutdown completed")
|