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.
- d365fo_client/__init__.py +305 -0
- d365fo_client/auth.py +93 -0
- d365fo_client/cli.py +700 -0
- d365fo_client/client.py +1454 -0
- d365fo_client/config.py +304 -0
- d365fo_client/crud.py +200 -0
- d365fo_client/exceptions.py +49 -0
- d365fo_client/labels.py +528 -0
- d365fo_client/main.py +502 -0
- d365fo_client/mcp/__init__.py +16 -0
- d365fo_client/mcp/client_manager.py +276 -0
- d365fo_client/mcp/main.py +98 -0
- d365fo_client/mcp/models.py +371 -0
- d365fo_client/mcp/prompts/__init__.py +43 -0
- d365fo_client/mcp/prompts/action_execution.py +480 -0
- d365fo_client/mcp/prompts/sequence_analysis.py +349 -0
- d365fo_client/mcp/resources/__init__.py +15 -0
- d365fo_client/mcp/resources/database_handler.py +555 -0
- d365fo_client/mcp/resources/entity_handler.py +176 -0
- d365fo_client/mcp/resources/environment_handler.py +132 -0
- d365fo_client/mcp/resources/metadata_handler.py +283 -0
- d365fo_client/mcp/resources/query_handler.py +135 -0
- d365fo_client/mcp/server.py +432 -0
- d365fo_client/mcp/tools/__init__.py +17 -0
- d365fo_client/mcp/tools/connection_tools.py +175 -0
- d365fo_client/mcp/tools/crud_tools.py +579 -0
- d365fo_client/mcp/tools/database_tools.py +813 -0
- d365fo_client/mcp/tools/label_tools.py +189 -0
- d365fo_client/mcp/tools/metadata_tools.py +766 -0
- d365fo_client/mcp/tools/profile_tools.py +706 -0
- d365fo_client/metadata_api.py +793 -0
- d365fo_client/metadata_v2/__init__.py +59 -0
- d365fo_client/metadata_v2/cache_v2.py +1372 -0
- d365fo_client/metadata_v2/database_v2.py +585 -0
- d365fo_client/metadata_v2/global_version_manager.py +573 -0
- d365fo_client/metadata_v2/search_engine_v2.py +423 -0
- d365fo_client/metadata_v2/sync_manager_v2.py +819 -0
- d365fo_client/metadata_v2/version_detector.py +439 -0
- d365fo_client/models.py +862 -0
- d365fo_client/output.py +181 -0
- d365fo_client/profile_manager.py +342 -0
- d365fo_client/profiles.py +178 -0
- d365fo_client/query.py +162 -0
- d365fo_client/session.py +60 -0
- d365fo_client/utils.py +196 -0
- d365fo_client-0.1.0.dist-info/METADATA +1084 -0
- d365fo_client-0.1.0.dist-info/RECORD +51 -0
- d365fo_client-0.1.0.dist-info/WHEEL +5 -0
- d365fo_client-0.1.0.dist-info/entry_points.txt +3 -0
- d365fo_client-0.1.0.dist-info/licenses/LICENSE +21 -0
- 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()
|