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.
Files changed (59) hide show
  1. d365fo_client/__init__.py +7 -1
  2. d365fo_client/auth.py +9 -21
  3. d365fo_client/cli.py +25 -13
  4. d365fo_client/client.py +8 -4
  5. d365fo_client/config.py +52 -30
  6. d365fo_client/credential_sources.py +5 -0
  7. d365fo_client/main.py +1 -1
  8. d365fo_client/mcp/__init__.py +3 -1
  9. d365fo_client/mcp/auth_server/__init__.py +5 -0
  10. d365fo_client/mcp/auth_server/auth/__init__.py +30 -0
  11. d365fo_client/mcp/auth_server/auth/auth.py +372 -0
  12. d365fo_client/mcp/auth_server/auth/oauth_proxy.py +989 -0
  13. d365fo_client/mcp/auth_server/auth/providers/__init__.py +0 -0
  14. d365fo_client/mcp/auth_server/auth/providers/apikey.py +83 -0
  15. d365fo_client/mcp/auth_server/auth/providers/azure.py +393 -0
  16. d365fo_client/mcp/auth_server/auth/providers/bearer.py +25 -0
  17. d365fo_client/mcp/auth_server/auth/providers/jwt.py +547 -0
  18. d365fo_client/mcp/auth_server/auth/redirect_validation.py +65 -0
  19. d365fo_client/mcp/auth_server/dependencies.py +136 -0
  20. d365fo_client/mcp/client_manager.py +16 -67
  21. d365fo_client/mcp/fastmcp_main.py +407 -0
  22. d365fo_client/mcp/fastmcp_server.py +598 -0
  23. d365fo_client/mcp/fastmcp_utils.py +431 -0
  24. d365fo_client/mcp/main.py +40 -13
  25. d365fo_client/mcp/mixins/__init__.py +24 -0
  26. d365fo_client/mcp/mixins/base_tools_mixin.py +55 -0
  27. d365fo_client/mcp/mixins/connection_tools_mixin.py +50 -0
  28. d365fo_client/mcp/mixins/crud_tools_mixin.py +311 -0
  29. d365fo_client/mcp/mixins/database_tools_mixin.py +685 -0
  30. d365fo_client/mcp/mixins/label_tools_mixin.py +87 -0
  31. d365fo_client/mcp/mixins/metadata_tools_mixin.py +565 -0
  32. d365fo_client/mcp/mixins/performance_tools_mixin.py +109 -0
  33. d365fo_client/mcp/mixins/profile_tools_mixin.py +713 -0
  34. d365fo_client/mcp/mixins/sync_tools_mixin.py +321 -0
  35. d365fo_client/mcp/prompts/action_execution.py +1 -1
  36. d365fo_client/mcp/prompts/sequence_analysis.py +1 -1
  37. d365fo_client/mcp/tools/crud_tools.py +3 -3
  38. d365fo_client/mcp/tools/sync_tools.py +1 -1
  39. d365fo_client/mcp/utilities/__init__.py +1 -0
  40. d365fo_client/mcp/utilities/auth.py +34 -0
  41. d365fo_client/mcp/utilities/logging.py +58 -0
  42. d365fo_client/mcp/utilities/types.py +426 -0
  43. d365fo_client/metadata_v2/sync_manager_v2.py +2 -0
  44. d365fo_client/metadata_v2/sync_session_manager.py +7 -7
  45. d365fo_client/models.py +139 -139
  46. d365fo_client/output.py +2 -2
  47. d365fo_client/profile_manager.py +62 -27
  48. d365fo_client/profiles.py +118 -113
  49. d365fo_client/settings.py +367 -0
  50. d365fo_client/sync_models.py +85 -2
  51. d365fo_client/utils.py +2 -1
  52. {d365fo_client-0.2.4.dist-info → d365fo_client-0.3.1.dist-info}/METADATA +273 -18
  53. d365fo_client-0.3.1.dist-info/RECORD +85 -0
  54. d365fo_client-0.3.1.dist-info/entry_points.txt +4 -0
  55. d365fo_client-0.2.4.dist-info/RECORD +0 -56
  56. d365fo_client-0.2.4.dist-info/entry_points.txt +0 -3
  57. {d365fo_client-0.2.4.dist-info → d365fo_client-0.3.1.dist-info}/WHEEL +0 -0
  58. {d365fo_client-0.2.4.dist-info → d365fo_client-0.3.1.dist-info}/licenses/LICENSE +0 -0
  59. {d365fo_client-0.2.4.dist-info → d365fo_client-0.3.1.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")