d365fo-client 0.1.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.
Files changed (51) hide show
  1. d365fo_client/__init__.py +305 -0
  2. d365fo_client/auth.py +93 -0
  3. d365fo_client/cli.py +700 -0
  4. d365fo_client/client.py +1454 -0
  5. d365fo_client/config.py +304 -0
  6. d365fo_client/crud.py +200 -0
  7. d365fo_client/exceptions.py +49 -0
  8. d365fo_client/labels.py +528 -0
  9. d365fo_client/main.py +502 -0
  10. d365fo_client/mcp/__init__.py +16 -0
  11. d365fo_client/mcp/client_manager.py +276 -0
  12. d365fo_client/mcp/main.py +98 -0
  13. d365fo_client/mcp/models.py +371 -0
  14. d365fo_client/mcp/prompts/__init__.py +43 -0
  15. d365fo_client/mcp/prompts/action_execution.py +480 -0
  16. d365fo_client/mcp/prompts/sequence_analysis.py +349 -0
  17. d365fo_client/mcp/resources/__init__.py +15 -0
  18. d365fo_client/mcp/resources/database_handler.py +555 -0
  19. d365fo_client/mcp/resources/entity_handler.py +176 -0
  20. d365fo_client/mcp/resources/environment_handler.py +132 -0
  21. d365fo_client/mcp/resources/metadata_handler.py +283 -0
  22. d365fo_client/mcp/resources/query_handler.py +135 -0
  23. d365fo_client/mcp/server.py +432 -0
  24. d365fo_client/mcp/tools/__init__.py +17 -0
  25. d365fo_client/mcp/tools/connection_tools.py +175 -0
  26. d365fo_client/mcp/tools/crud_tools.py +579 -0
  27. d365fo_client/mcp/tools/database_tools.py +813 -0
  28. d365fo_client/mcp/tools/label_tools.py +189 -0
  29. d365fo_client/mcp/tools/metadata_tools.py +766 -0
  30. d365fo_client/mcp/tools/profile_tools.py +706 -0
  31. d365fo_client/metadata_api.py +793 -0
  32. d365fo_client/metadata_v2/__init__.py +59 -0
  33. d365fo_client/metadata_v2/cache_v2.py +1372 -0
  34. d365fo_client/metadata_v2/database_v2.py +585 -0
  35. d365fo_client/metadata_v2/global_version_manager.py +573 -0
  36. d365fo_client/metadata_v2/search_engine_v2.py +423 -0
  37. d365fo_client/metadata_v2/sync_manager_v2.py +819 -0
  38. d365fo_client/metadata_v2/version_detector.py +439 -0
  39. d365fo_client/models.py +862 -0
  40. d365fo_client/output.py +181 -0
  41. d365fo_client/profile_manager.py +342 -0
  42. d365fo_client/profiles.py +178 -0
  43. d365fo_client/query.py +162 -0
  44. d365fo_client/session.py +60 -0
  45. d365fo_client/utils.py +196 -0
  46. d365fo_client-0.1.0.dist-info/METADATA +1084 -0
  47. d365fo_client-0.1.0.dist-info/RECORD +51 -0
  48. d365fo_client-0.1.0.dist-info/WHEEL +5 -0
  49. d365fo_client-0.1.0.dist-info/entry_points.txt +3 -0
  50. d365fo_client-0.1.0.dist-info/licenses/LICENSE +21 -0
  51. d365fo_client-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,276 @@
1
+ """D365FO Client Manager for MCP Server.
2
+
3
+ Manages D365FO client instances and connection pooling for the MCP server.
4
+ Provides centralized client management with session reuse and error handling.
5
+ """
6
+
7
+ import asyncio
8
+ import logging
9
+ from datetime import datetime, timezone
10
+ from typing import Dict, Optional
11
+
12
+ from ..client import FOClient
13
+ from ..exceptions import AuthenticationError, FOClientError
14
+ from ..models import FOClientConfig
15
+ from ..profile_manager import ProfileManager
16
+
17
+ logger = logging.getLogger(__name__)
18
+
19
+
20
+ class D365FOClientManager:
21
+ """Manages D365FO client instances and connection pooling."""
22
+
23
+ def __init__(self, config: dict):
24
+ """Initialize the client manager.
25
+
26
+ Args:
27
+ config: Configuration dictionary with client settings
28
+ """
29
+ self.config = config
30
+ self._client_pool: Dict[str, FOClient] = {}
31
+ self._session_lock = asyncio.Lock()
32
+ self._last_health_check: Optional[datetime] = None
33
+ self.profile_manager = ProfileManager()
34
+
35
+ async def get_client(self, profile: str = "default") -> FOClient:
36
+ """Get or create a client for the specified profile.
37
+
38
+ Args:
39
+ profile: Configuration profile name
40
+
41
+ Returns:
42
+ FOClient instance
43
+
44
+ Raises:
45
+ ConnectionError: If unable to connect to D365FO
46
+ AuthenticationError: If authentication fails
47
+ """
48
+ async with self._session_lock:
49
+ if profile not in self._client_pool:
50
+ client_config = self._build_client_config(profile)
51
+ client = FOClient(client_config)
52
+
53
+ # Test connection
54
+ try:
55
+ await self._test_client_connection(client)
56
+ self._client_pool[profile] = client
57
+ logger.info(f"Created new D365FO client for profile: {profile}")
58
+ except Exception as e:
59
+ await client.close()
60
+ logger.error(f"Failed to create client for profile {profile}: {e}")
61
+ raise ConnectionError(
62
+ f"Failed to connect to D365FO: {profile}"
63
+ ) from e
64
+
65
+ return self._client_pool[profile]
66
+
67
+ async def test_connection(self, profile: str = "default") -> bool:
68
+ """Test connection for a specific profile.
69
+
70
+ Args:
71
+ profile: Configuration profile name
72
+
73
+ Returns:
74
+ True if connection is successful
75
+ """
76
+ try:
77
+ client = await self.get_client(profile)
78
+ return await self._test_client_connection(client)
79
+ except Exception as e:
80
+ logger.error(f"Connection test failed for profile {profile}: {e}")
81
+ return False
82
+
83
+ async def get_environment_info(self, profile: str = "default") -> dict:
84
+ """Get comprehensive environment information for a profile.
85
+
86
+ Args:
87
+ profile: Configuration profile name
88
+
89
+ Returns:
90
+ Dictionary with environment information including:
91
+ - base_url: The D365FO environment URL
92
+ - versions: Application, platform, and build version information
93
+ - connectivity: Connection status to the environment
94
+ - metadata_info: Comprehensive metadata and cache information from FOClient
95
+ """
96
+ client = await self.get_client(profile)
97
+
98
+ try:
99
+ # Get version information
100
+ app_version = await client.get_application_version()
101
+ platform_version = await client.get_platform_build_version()
102
+ build_version = await client.get_application_build_version()
103
+
104
+ # Test connectivity
105
+ connectivity = await self._test_client_connection(client)
106
+
107
+ # Get comprehensive metadata and cache information using FOClient's method
108
+ metadata_info = await client.get_metadata_info()
109
+
110
+ return {
111
+ "base_url": client.config.base_url,
112
+ "versions": {
113
+ "application": app_version,
114
+ "platform": platform_version,
115
+ "build": build_version,
116
+ },
117
+ "connectivity": connectivity,
118
+ "metadata_info": metadata_info,
119
+ }
120
+ except Exception as e:
121
+ logger.error(f"Failed to get environment info for profile {profile}: {e}")
122
+ raise
123
+
124
+ async def cleanup(self, profile: Optional[str] = None):
125
+ """Close client connections.
126
+
127
+ Args:
128
+ profile: Specific profile to cleanup, or None for all
129
+ """
130
+ async with self._session_lock:
131
+ if profile and profile in self._client_pool:
132
+ client = self._client_pool.pop(profile)
133
+ await client.close()
134
+ logger.info(f"Closed client for profile: {profile}")
135
+ elif profile is None:
136
+ # Close all clients
137
+ for profile_name, client in self._client_pool.items():
138
+ try:
139
+ await client.close()
140
+ logger.info(f"Closed client for profile: {profile_name}")
141
+ except Exception as e:
142
+ logger.error(
143
+ f"Error closing client for profile {profile_name}: {e}"
144
+ )
145
+ self._client_pool.clear()
146
+
147
+ def _build_client_config(self, profile: str) -> FOClientConfig:
148
+ """Build FOClientConfig from profile configuration.
149
+
150
+ Args:
151
+ profile: Configuration profile name
152
+
153
+ Returns:
154
+ FOClientConfig instance
155
+ """
156
+ # First try to get from profile manager (file-based profiles)
157
+ env_profile = None
158
+
159
+ # If requesting "default", resolve to the actual default profile
160
+ if profile == "default":
161
+ env_profile = self.profile_manager.get_default_profile()
162
+ if not env_profile:
163
+ # No default profile set, try to get a profile named "default"
164
+ env_profile = self.profile_manager.get_profile("default")
165
+ else:
166
+ # Get specific profile
167
+ env_profile = self.profile_manager.get_profile(profile)
168
+
169
+ if env_profile:
170
+ config = self.profile_manager.profile_to_client_config(env_profile)
171
+ # Validate that we have a base_url
172
+ if not config.base_url:
173
+ raise ValueError(
174
+ f"Profile '{env_profile.name}' has no base_url configured"
175
+ )
176
+ return config
177
+
178
+ # Fallback to legacy config-based profiles
179
+ profile_config = self.config.get("profiles", {}).get(profile, {})
180
+ default_config = self.config.get("default_environment", {})
181
+
182
+ # If requesting a specific profile name that doesn't exist in profiles, don't fall back
183
+ if (
184
+ profile != "default"
185
+ and profile not in self.config.get("profiles", {})
186
+ and not profile_config
187
+ ):
188
+ available_profiles = list(self.profile_manager.list_profiles().keys())
189
+ legacy_profiles = list(self.config.get("profiles", {}).keys())
190
+ all_profiles = sorted(set(available_profiles + legacy_profiles))
191
+
192
+ default_profile = self.profile_manager.get_default_profile()
193
+
194
+ error_msg = f"Profile '{profile}' not found"
195
+ if all_profiles:
196
+ error_msg += f". Available profiles: {', '.join(all_profiles)}"
197
+ if default_profile:
198
+ error_msg += f". Default profile is '{default_profile.name}'"
199
+ else:
200
+ error_msg += ". No default profile is set"
201
+
202
+ raise ValueError(error_msg)
203
+
204
+ # Merge configs (profile overrides default)
205
+ config = {**default_config, **profile_config}
206
+
207
+ # Validate base_url
208
+ base_url = config.get("base_url")
209
+ if not base_url:
210
+ available_profiles = list(self.profile_manager.list_profiles().keys())
211
+ default_profile = self.profile_manager.get_default_profile()
212
+
213
+ error_msg = f"No configuration found for profile '{profile}'"
214
+ if available_profiles:
215
+ error_msg += f". Available profiles: {', '.join(available_profiles)}"
216
+ if default_profile:
217
+ error_msg += f". Default profile is '{default_profile.name}'"
218
+ else:
219
+ error_msg += ". No default profile is set"
220
+
221
+ raise ValueError(error_msg)
222
+
223
+ return FOClientConfig(
224
+ base_url=base_url,
225
+ client_id=config.get("client_id"),
226
+ client_secret=config.get("client_secret"),
227
+ tenant_id=config.get("tenant_id"),
228
+ use_default_credentials=config.get("use_default_credentials", True),
229
+ timeout=config.get("timeout", 60),
230
+ verify_ssl=config.get("verify_ssl", True),
231
+ use_label_cache=config.get("use_label_cache", True),
232
+ metadata_cache_dir=config.get("metadata_cache_dir"),
233
+ use_cache_first=config.get("use_cache_first", True),
234
+ )
235
+
236
+ async def _test_client_connection(self, client: FOClient) -> bool:
237
+ """Test a client connection.
238
+
239
+ Args:
240
+ client: FOClient instance to test
241
+
242
+ Returns:
243
+ True if connection is successful
244
+ """
245
+ try:
246
+ # Try to get application version as a simple connectivity test
247
+ await client.get_application_version()
248
+ return True
249
+ except Exception as e:
250
+ logger.error(f"Client connection test failed: {e}")
251
+ return False
252
+
253
+ async def health_check(self) -> dict:
254
+ """Perform health check on all managed clients.
255
+
256
+ Returns:
257
+ Dictionary with health check results
258
+ """
259
+ results = {}
260
+ async with self._session_lock:
261
+ for profile, client in self._client_pool.items():
262
+ try:
263
+ is_healthy = await self._test_client_connection(client)
264
+ results[profile] = {
265
+ "healthy": is_healthy,
266
+ "last_checked": datetime.now(timezone.utc),
267
+ }
268
+ except Exception as e:
269
+ results[profile] = {
270
+ "healthy": False,
271
+ "error": str(e),
272
+ "last_checked": datetime.now(timezone.utc),
273
+ }
274
+
275
+ self._last_health_check = datetime.now(timezone.utc)
276
+ return results
@@ -0,0 +1,98 @@
1
+ #!/usr/bin/env python3
2
+ """Entry point for the D365FO MCP Server."""
3
+
4
+ import asyncio
5
+ import logging
6
+ import os
7
+ import sys
8
+ from pathlib import Path
9
+ from typing import Any, Dict, Optional
10
+
11
+ from d365fo_client.mcp import D365FOMCPServer
12
+
13
+
14
+ def setup_logging(level: str = "INFO") -> None:
15
+ """Set up logging for the MCP server.
16
+
17
+ Args:
18
+ level: Logging level
19
+ """
20
+ log_level = getattr(logging, level.upper(), logging.INFO)
21
+
22
+ # Create logs directory
23
+ log_dir = Path.home() / ".d365fo-mcp" / "logs"
24
+ log_dir.mkdir(parents=True, exist_ok=True)
25
+
26
+ # Configure logging
27
+ logging.basicConfig(
28
+ level=log_level,
29
+ format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
30
+ handlers=[
31
+ logging.FileHandler(log_dir / "mcp-server.log"),
32
+ logging.StreamHandler(sys.stderr),
33
+ ],
34
+ )
35
+
36
+
37
+ def load_config() -> Optional[Dict[str, Any]]:
38
+ """Load configuration from environment and config files.
39
+
40
+ Returns:
41
+ Configuration dictionary or None for defaults
42
+ """
43
+ config = {}
44
+
45
+ # Load from environment variables
46
+ if base_url := os.getenv("D365FO_BASE_URL"):
47
+ config.setdefault("default_environment", {})["base_url"] = base_url
48
+
49
+ if client_id := os.getenv("AZURE_CLIENT_ID"):
50
+ config.setdefault("default_environment", {})["client_id"] = client_id
51
+ config["default_environment"]["use_default_credentials"] = False
52
+
53
+ if client_secret := os.getenv("AZURE_CLIENT_SECRET"):
54
+ config.setdefault("default_environment", {})["client_secret"] = client_secret
55
+
56
+ if tenant_id := os.getenv("AZURE_TENANT_ID"):
57
+ config.setdefault("default_environment", {})["tenant_id"] = tenant_id
58
+
59
+ # Logging level
60
+ log_level = os.getenv("D365FO_LOG_LEVEL", "INFO")
61
+ setup_logging(log_level)
62
+
63
+ return config if config else None
64
+
65
+
66
+ async def async_main() -> None:
67
+ """Async main entry point for the MCP server."""
68
+ try:
69
+ # Load configuration
70
+ config = load_config()
71
+
72
+ # Create and run the MCP server
73
+ server = D365FOMCPServer(config)
74
+
75
+ logging.info("Starting D365FO MCP Server...")
76
+ await server.run()
77
+
78
+ except KeyboardInterrupt:
79
+ logging.info("Server stopped by user")
80
+ except Exception as e:
81
+ logging.error(f"Server error: {e}")
82
+ sys.exit(1)
83
+
84
+
85
+ def main() -> None:
86
+ """Main entry point for the MCP server."""
87
+ # Ensure event loop compatibility across platforms
88
+ if sys.platform == "win32":
89
+ asyncio.set_event_loop_policy(asyncio.WindowsProactorEventLoopPolicy())
90
+
91
+ try:
92
+ asyncio.run(async_main())
93
+ except KeyboardInterrupt:
94
+ pass # Graceful shutdown
95
+
96
+
97
+ if __name__ == "__main__":
98
+ main()