d365fo-client 0.2.3__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.
Files changed (58) 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/azure.py +325 -0
  15. d365fo_client/mcp/auth_server/auth/providers/bearer.py +25 -0
  16. d365fo_client/mcp/auth_server/auth/providers/jwt.py +547 -0
  17. d365fo_client/mcp/auth_server/auth/redirect_validation.py +65 -0
  18. d365fo_client/mcp/auth_server/dependencies.py +136 -0
  19. d365fo_client/mcp/client_manager.py +16 -67
  20. d365fo_client/mcp/fastmcp_main.py +358 -0
  21. d365fo_client/mcp/fastmcp_server.py +598 -0
  22. d365fo_client/mcp/fastmcp_utils.py +431 -0
  23. d365fo_client/mcp/main.py +40 -13
  24. d365fo_client/mcp/mixins/__init__.py +24 -0
  25. d365fo_client/mcp/mixins/base_tools_mixin.py +55 -0
  26. d365fo_client/mcp/mixins/connection_tools_mixin.py +50 -0
  27. d365fo_client/mcp/mixins/crud_tools_mixin.py +311 -0
  28. d365fo_client/mcp/mixins/database_tools_mixin.py +685 -0
  29. d365fo_client/mcp/mixins/label_tools_mixin.py +87 -0
  30. d365fo_client/mcp/mixins/metadata_tools_mixin.py +565 -0
  31. d365fo_client/mcp/mixins/performance_tools_mixin.py +109 -0
  32. d365fo_client/mcp/mixins/profile_tools_mixin.py +713 -0
  33. d365fo_client/mcp/mixins/sync_tools_mixin.py +321 -0
  34. d365fo_client/mcp/prompts/action_execution.py +1 -1
  35. d365fo_client/mcp/prompts/sequence_analysis.py +1 -1
  36. d365fo_client/mcp/tools/crud_tools.py +3 -3
  37. d365fo_client/mcp/tools/sync_tools.py +1 -1
  38. d365fo_client/mcp/utilities/__init__.py +1 -0
  39. d365fo_client/mcp/utilities/auth.py +34 -0
  40. d365fo_client/mcp/utilities/logging.py +58 -0
  41. d365fo_client/mcp/utilities/types.py +426 -0
  42. d365fo_client/metadata_v2/sync_manager_v2.py +2 -0
  43. d365fo_client/metadata_v2/sync_session_manager.py +7 -7
  44. d365fo_client/models.py +139 -139
  45. d365fo_client/output.py +2 -2
  46. d365fo_client/profile_manager.py +62 -27
  47. d365fo_client/profiles.py +118 -113
  48. d365fo_client/settings.py +355 -0
  49. d365fo_client/sync_models.py +85 -2
  50. d365fo_client/utils.py +2 -1
  51. {d365fo_client-0.2.3.dist-info → d365fo_client-0.3.0.dist-info}/METADATA +1261 -810
  52. d365fo_client-0.3.0.dist-info/RECORD +84 -0
  53. d365fo_client-0.3.0.dist-info/entry_points.txt +4 -0
  54. d365fo_client-0.2.3.dist-info/RECORD +0 -56
  55. d365fo_client-0.2.3.dist-info/entry_points.txt +0 -3
  56. {d365fo_client-0.2.3.dist-info → d365fo_client-0.3.0.dist-info}/WHEEL +0 -0
  57. {d365fo_client-0.2.3.dist-info → d365fo_client-0.3.0.dist-info}/licenses/LICENSE +0 -0
  58. {d365fo_client-0.2.3.dist-info → d365fo_client-0.3.0.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,431 @@
1
+ """Utility functions for FastMCP server configuration and setup."""
2
+ import argparse
3
+ import logging
4
+ import os
5
+ from pathlib import Path
6
+ from typing import Any, Dict, Optional
7
+ import yaml
8
+ from d365fo_client import __version__
9
+ from d365fo_client.credential_sources import EnvironmentCredentialSource
10
+ from d365fo_client.profile_manager import ProfileManager
11
+ from d365fo_client.settings import get_settings
12
+ from d365fo_client.utils import get_default_cache_directory
13
+
14
+ logger = logging.getLogger(__name__)
15
+
16
+ def load_default_config(args: Optional[argparse.Namespace] = None) -> Dict[str, Any]:
17
+ """Load configuration for FastMCP server from arguments and environment.
18
+
19
+ Args:
20
+ args: Optional parsed command line arguments. If not provided,
21
+ defaults will be used for all argument-based configuration.
22
+
23
+ Returns:
24
+ Configuration dictionary
25
+ """
26
+ # Get settings instance
27
+ settings = get_settings()
28
+
29
+ # Extract values from args with settings fallbacks
30
+ if args is not None:
31
+ transport = args.transport
32
+ host = args.host if hasattr(args, 'host') else settings.http_host
33
+ port = args.port if hasattr(args, 'port') else settings.http_port
34
+ stateless = getattr(args, 'stateless', False) or settings.http_stateless
35
+ json_response = getattr(args, 'json_response', False) or settings.http_json
36
+ debug = getattr(args, 'debug', False) or settings.debug
37
+ else:
38
+ transport = settings.mcp_transport.value
39
+ host = settings.http_host
40
+ port = settings.http_port
41
+ stateless = settings.http_stateless
42
+ json_response = settings.http_json
43
+ debug = settings.debug
44
+
45
+ # Get startup mode from settings
46
+ startup_mode = settings.get_startup_mode()
47
+
48
+ # Build default environment from settings
49
+ default_environment = {
50
+ "use_default_credentials": True,
51
+ "use_cache_first": settings.use_cache_first,
52
+ "timeout": settings.timeout,
53
+ "verify_ssl": settings.verify_ssl,
54
+ "use_label_cache": settings.use_label_cache,
55
+ "label_cache_expiry_minutes": settings.label_cache_expiry_minutes,
56
+ "metadata_cache_dir": settings.cache_dir,
57
+ "base_url": settings.base_url,
58
+ "client_id": settings.client_id,
59
+ "client_secret": settings.client_secret,
60
+ "tenant_id": settings.tenant_id,
61
+ }
62
+
63
+
64
+
65
+ return {
66
+ "startup_mode": startup_mode,
67
+ "server": {
68
+ "name": "d365fo-fastmcp-server",
69
+ "version": __version__,
70
+ "debug": debug or os.getenv("DEBUG", "").lower() in ("true", "1", "yes"),
71
+ "transport": {
72
+ "default": transport,
73
+ "stdio": {
74
+ "enabled": True
75
+ },
76
+ "sse": {
77
+ "enabled": True,
78
+ "host": host,
79
+ "port": port,
80
+ "cors": {
81
+ "enabled": True,
82
+ "origins": ["*"],
83
+ "methods": ["GET", "POST"],
84
+ "headers": ["*"]
85
+ }
86
+ },
87
+ "http": {
88
+ "enabled": True,
89
+ "host": host,
90
+ "port": port,
91
+ "stateless": stateless,
92
+ "json_response": json_response,
93
+ "cors": {
94
+ "enabled": True,
95
+ "origins": ["*"],
96
+ "methods": ["GET", "POST", "DELETE"],
97
+ "headers": ["*"]
98
+ }
99
+ },
100
+ }
101
+ },
102
+ "default_environment": default_environment,
103
+ "performance": {
104
+ "max_concurrent_requests": settings.max_concurrent_requests,
105
+ "connection_pool_size": int(os.getenv("MCP_CONNECTION_POOL_SIZE", "5")),
106
+ "request_timeout": settings.request_timeout,
107
+ "batch_size": int(os.getenv("MCP_BATCH_SIZE", "100")),
108
+ "enable_performance_monitoring": os.getenv(
109
+ "MCP_PERFORMANCE_MONITORING", "true"
110
+ ).lower()
111
+ in ("true", "1", "yes"),
112
+ "session_cleanup_interval": int(
113
+ os.getenv("MCP_SESSION_CLEANUP_INTERVAL", "300")
114
+ ),
115
+ "max_request_history": int(
116
+ os.getenv("MCP_MAX_REQUEST_HISTORY", "1000")
117
+ ),
118
+ },
119
+ "security": {
120
+ "encrypt_cached_tokens": True,
121
+ "token_expiry_buffer_minutes": 5,
122
+ "max_retry_attempts": 3,
123
+ },
124
+ }
125
+
126
+ def create_default_profile_if_needed(profile_manager:"ProfileManager", config:Dict) -> Optional[bool]:
127
+ """Create a default profile from environment variables if needed."""
128
+ try:
129
+ # Check if default profile already exists
130
+ existing_default = profile_manager.get_default_profile()
131
+ if existing_default:
132
+ logger.info(f"Default profile already exists: {existing_default.name}")
133
+ return False
134
+
135
+ # Get default environment configuration
136
+ default_environment = config.get("default_environment", {})
137
+
138
+ # Get settings for direct access
139
+ settings = get_settings()
140
+
141
+ # Get base URL from environment or config
142
+ base_url = default_environment.get("base_url") or settings.base_url
143
+
144
+ if not base_url:
145
+ logger.warning("Cannot create default profile - D365FO_BASE_URL not set")
146
+ return False
147
+
148
+ # Determine authentication mode based on startup mode
149
+ startup_mode = config.get("startup_mode", "profile_only")
150
+
151
+ # Check for legacy credentials in environment
152
+ client_id = default_environment.get("client_id") or settings.client_id
153
+ client_secret = default_environment.get("client_secret") or settings.client_secret
154
+ tenant_id = default_environment.get("tenant_id") or settings.tenant_id
155
+
156
+ if startup_mode == "client_credentials":
157
+ auth_mode = "client_credentials"
158
+ if not all([client_id, client_secret, tenant_id]):
159
+ logger.error("Client credentials mode requires D365FO_CLIENT_ID, D365FO_CLIENT_SECRET, and D365FO_TENANT_ID")
160
+ return
161
+ else:
162
+ auth_mode = "default"
163
+ # Clear client credentials for default auth mode
164
+ client_id = None
165
+ client_secret = None
166
+ tenant_id = None
167
+
168
+ # Create default profile with unique name
169
+ profile_name = "default-from-env"
170
+
171
+ # Check if profile with this name already exists
172
+ existing_profile = profile_manager.get_profile(profile_name)
173
+ if existing_profile:
174
+ logger.info(f"Profile '{profile_name}' already exists, setting as default")
175
+ profile_manager.set_default_profile(profile_name)
176
+ return
177
+
178
+ credential_source = None
179
+ if startup_mode == "client_credentials":
180
+ credential_source = EnvironmentCredentialSource()
181
+
182
+ # Use configuration values with proper defaults
183
+ use_label_cache = default_environment.get("use_label_cache", True)
184
+ timeout = default_environment.get("timeout", 60)
185
+ verify_ssl = default_environment.get("verify_ssl", True)
186
+
187
+ success = profile_manager.create_profile(
188
+ name=profile_name,
189
+ base_url=base_url,
190
+ auth_mode=auth_mode,
191
+ client_id=None, # use from env var
192
+ client_secret=None, # use from env var
193
+ tenant_id=None, # use from env var
194
+ description=f"Auto-created from environment variables at startup (mode: {startup_mode})",
195
+ use_label_cache=use_label_cache,
196
+ timeout=timeout,
197
+ verify_ssl=verify_ssl,
198
+ credential_source=credential_source
199
+ )
200
+
201
+ if success:
202
+ # Set as default profile
203
+ profile_manager.set_default_profile(profile_name)
204
+ logger.info(f"Created and set default profile: {profile_name}")
205
+ logger.info(f"Profile configured for: {base_url}")
206
+ logger.info(f"Authentication mode: {auth_mode}")
207
+ logger.info(f"Use label cache: {use_label_cache}")
208
+ logger.info(f"Timeout: {timeout}s")
209
+ logger.info(f"Verify SSL: {verify_ssl}")
210
+
211
+ if auth_mode == "client_credentials":
212
+ logger.info(f"Client ID: {client_id}")
213
+ logger.info(f"Tenant ID: {tenant_id}")
214
+
215
+ else:
216
+ logger.warning(f"Failed to create default profile: {profile_name}")
217
+
218
+ return success
219
+
220
+ except Exception as e:
221
+ logger.error(f"Error creating default profile: {e}")
222
+
223
+
224
+ def migrate_legacy_config(profile_manager: "ProfileManager") -> bool:
225
+ """Migrate legacy configuration file to new format if needed.
226
+
227
+ This function detects legacy config files and migrates them to the new format
228
+ that supports modern credential_source structures and proper field names.
229
+
230
+ Args:
231
+ profile_manager: ProfileManager instance to use for migration
232
+
233
+ Returns:
234
+ True if migration was performed, False if no migration needed
235
+ """
236
+ try:
237
+ config_path = Path(profile_manager.config_manager.config_path)
238
+
239
+ if not config_path.exists():
240
+ logger.debug("No config file found - no migration needed")
241
+ return False
242
+
243
+ # Load raw config data to check for legacy format
244
+ with open(config_path, 'r', encoding='utf-8') as f:
245
+ config_data = yaml.safe_load(f) or {}
246
+
247
+ if not config_data.get('profiles'):
248
+ logger.debug("No profiles found in config - no migration needed")
249
+ return False
250
+
251
+ # Check if migration is needed by examining profile structures
252
+ needs_migration = _is_legacy_config_format(config_data)
253
+
254
+ if not needs_migration:
255
+ logger.debug("Config is already in new format - no migration needed")
256
+ return False
257
+
258
+ logger.info("Legacy config format detected - starting migration...")
259
+
260
+ # Create backup of original config
261
+ backup_path = config_path.with_suffix('.yaml.backup')
262
+ config_path.rename(backup_path)
263
+ logger.info(f"Created backup of original config: {backup_path}")
264
+
265
+ # Migrate each profile
266
+ migration_results = {}
267
+ migrated_profiles = {}
268
+
269
+ for profile_name, profile_data in config_data.get('profiles', {}).items():
270
+ try:
271
+ # Apply legacy field migrations
272
+ migrated_data = _migrate_legacy_profile_data(profile_data.copy())
273
+
274
+ # Create profile using the migration-aware method
275
+ from d365fo_client.profiles import Profile
276
+ profile = Profile.create_from_dict(profile_name, migrated_data)
277
+
278
+ # Store migrated profile
279
+ migrated_profiles[profile_name] = profile
280
+ migration_results[profile_name] = True
281
+ logger.info(f"Successfully migrated profile: {profile_name}")
282
+
283
+ except Exception as e:
284
+ logger.error(f"Failed to migrate profile {profile_name}: {e}")
285
+ migration_results[profile_name] = False
286
+
287
+ # Save all migrated profiles
288
+ successful_migrations = 0
289
+ for profile_name, profile in migrated_profiles.items():
290
+ try:
291
+ profile_manager.config_manager.save_profile(profile)
292
+ successful_migrations += 1
293
+ except Exception as e:
294
+ logger.error(f"Failed to save migrated profile {profile_name}: {e}")
295
+
296
+ # Migrate global settings (default_profile)
297
+ if 'default_profile' in config_data:
298
+ try:
299
+ default_profile_name = config_data['default_profile']
300
+ if default_profile_name in migrated_profiles:
301
+ profile_manager.set_default_profile(default_profile_name)
302
+ logger.info(f"Migrated default profile setting: {default_profile_name}")
303
+ except Exception as e:
304
+ logger.error(f"Failed to migrate default profile setting: {e}")
305
+
306
+ # Log migration summary
307
+ total_profiles = len(config_data.get('profiles', {}))
308
+ logger.info(f"Migration completed: {successful_migrations}/{total_profiles} profiles migrated successfully")
309
+
310
+ if successful_migrations > 0:
311
+ logger.info(f"Original config backed up to: {backup_path}")
312
+ logger.info("You can remove the backup file once you've verified the migration worked correctly")
313
+ return True
314
+ else:
315
+ # Restore backup if no profiles were migrated successfully
316
+ logger.error("Migration failed - restoring original config")
317
+ backup_path.rename(config_path)
318
+ return False
319
+
320
+ except Exception as e:
321
+ logger.error(f"Error during config migration: {e}")
322
+ return False
323
+
324
+
325
+ def _is_legacy_config_format(config_data: Dict[str, Any]) -> bool:
326
+ """Check if config data is in legacy format that needs migration.
327
+
328
+ Args:
329
+ config_data: Raw config data loaded from YAML
330
+
331
+ Returns:
332
+ True if migration is needed, False otherwise
333
+ """
334
+ profiles = config_data.get('profiles', {})
335
+
336
+ if not profiles:
337
+ return False
338
+
339
+ # Check for legacy format indicators
340
+ for profile_name, profile_data in profiles.items():
341
+ # Legacy format indicators:
342
+ # 1. Missing verify_ssl field (was added later)
343
+ # 2. Has auth_mode field but missing use_default_credentials field
344
+ # 3. credential_source field structure differences
345
+ # 4. cache_dir instead of metadata_cache_dir
346
+
347
+ # Check for missing verify_ssl (common in legacy configs)
348
+ if 'verify_ssl' not in profile_data:
349
+ logger.debug(f"Legacy format detected: profile {profile_name} missing verify_ssl")
350
+ return True
351
+
352
+ # Check for auth_mode without use_default_credentials (legacy pattern)
353
+ if 'auth_mode' in profile_data and 'use_default_credentials' not in profile_data:
354
+ logger.debug(f"Legacy format detected: profile {profile_name} has auth_mode but missing use_default_credentials")
355
+ return True
356
+
357
+ # Check for cache_dir instead of metadata_cache_dir
358
+ if 'cache_dir' in profile_data and 'metadata_cache_dir' not in profile_data:
359
+ logger.debug(f"Legacy format detected: profile {profile_name} uses cache_dir instead of metadata_cache_dir")
360
+ return True
361
+
362
+ # Check credential_source format - legacy might have different structure or be missing
363
+ credential_source = profile_data.get('credential_source')
364
+ if credential_source is not None and isinstance(credential_source, dict):
365
+ # Legacy credential_source might be missing required fields
366
+ required_fields = ['source_type']
367
+ if not all(field in credential_source for field in required_fields):
368
+ logger.debug(f"Legacy format detected: profile {profile_name} has incomplete credential_source")
369
+ return True
370
+
371
+ return False
372
+
373
+
374
+ def _migrate_legacy_profile_data(profile_data: Dict[str, Any]) -> Dict[str, Any]:
375
+ """Migrate legacy profile data to new format.
376
+
377
+ Args:
378
+ profile_data: Legacy profile data
379
+
380
+ Returns:
381
+ Migrated profile data
382
+ """
383
+ migrated_data = profile_data.copy()
384
+
385
+ # Add missing verify_ssl field with default value
386
+ if 'verify_ssl' not in migrated_data:
387
+ migrated_data['verify_ssl'] = True
388
+ logger.debug("Added missing verify_ssl field with default value True")
389
+
390
+ # Migrate cache_dir to metadata_cache_dir
391
+ if 'cache_dir' in migrated_data and 'metadata_cache_dir' not in migrated_data:
392
+ migrated_data['metadata_cache_dir'] = migrated_data.pop('cache_dir')
393
+ logger.debug("Migrated cache_dir to metadata_cache_dir")
394
+
395
+ # Handle auth_mode migration to use_default_credentials
396
+ auth_mode = migrated_data.get('auth_mode')
397
+ if auth_mode and 'use_default_credentials' not in migrated_data:
398
+ if auth_mode == 'default':
399
+ migrated_data['use_default_credentials'] = True
400
+ logger.debug("Migrated auth_mode='default' to use_default_credentials=True")
401
+ elif auth_mode == 'client_credentials':
402
+ migrated_data['use_default_credentials'] = False
403
+ logger.debug("Migrated auth_mode='client_credentials' to use_default_credentials=False")
404
+ else:
405
+ # Unknown auth_mode, default to True for safety
406
+ migrated_data['use_default_credentials'] = True
407
+ logger.warning(f"Unknown auth_mode '{auth_mode}', defaulting to use_default_credentials=True")
408
+
409
+ # Clean up auth_mode field as it's no longer needed
410
+ migrated_data.pop('auth_mode', None)
411
+
412
+ # Ensure credential_source is properly structured if present
413
+ credential_source = migrated_data.get('credential_source')
414
+ if credential_source is not None and isinstance(credential_source, dict):
415
+ # Ensure source_type is present (environment is the most common)
416
+ if 'source_type' not in credential_source:
417
+ # Try to infer source_type from the structure
418
+ if any(key.endswith('_var') for key in credential_source.keys()):
419
+ credential_source['source_type'] = 'environment'
420
+ logger.debug("Inferred credential_source.source_type as 'environment'")
421
+ else:
422
+ # Remove invalid credential_source
423
+ migrated_data['credential_source'] = None
424
+ logger.warning("Removed invalid credential_source structure")
425
+
426
+ # Remove fields that are no longer needed or handled differently
427
+ obsolete_fields = ['auth_mode'] # Already handled above
428
+ for field in obsolete_fields:
429
+ migrated_data.pop(field, None)
430
+
431
+ return migrated_data
d365fo_client/mcp/main.py CHANGED
@@ -3,6 +3,7 @@
3
3
 
4
4
  import asyncio
5
5
  import logging
6
+ import logging.handlers
6
7
  import os
7
8
  import sys
8
9
  from pathlib import Path
@@ -13,32 +14,58 @@ from d365fo_client.mcp import D365FOMCPServer
13
14
 
14
15
 
15
16
  def setup_logging(level: str = "INFO") -> None:
16
- """Set up logging for the MCP server.
17
+ """Set up logging for the MCP server with 24-hour log rotation.
17
18
 
18
19
  Args:
19
20
  level: Logging level
20
21
  """
21
22
  log_level = getattr(logging, level.upper(), logging.INFO)
22
23
 
23
- # Create logs directory
24
- log_dir = Path.home() / ".d365fo-mcp" / "logs"
25
- log_dir.mkdir(parents=True, exist_ok=True)
24
+ # Get log file path from environment variable or use default
25
+ log_file_path = os.getenv("D365FO_LOG_FILE")
26
+
27
+ if log_file_path:
28
+ # Use custom log file path from environment variable
29
+ log_file = Path(log_file_path)
30
+ # Ensure parent directory exists
31
+ log_file.parent.mkdir(parents=True, exist_ok=True)
32
+ else:
33
+ # Use default log file path
34
+ log_dir = Path.home() / ".d365fo-mcp" / "logs"
35
+ log_dir.mkdir(parents=True, exist_ok=True)
36
+ log_file = log_dir / "mcp-server.log"
26
37
 
27
38
  # Clear existing handlers to avoid duplicate logging
28
39
  root_logger = logging.getLogger()
29
40
  for handler in root_logger.handlers[:]:
30
41
  root_logger.removeHandler(handler)
31
42
 
32
- # Configure logging
33
- logging.basicConfig(
34
- level=log_level,
35
- format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
36
- handlers=[
37
- logging.FileHandler(log_dir / "mcp-server.log"),
38
- logging.StreamHandler(sys.stderr),
39
- ],
40
- force=True, # Force reconfiguration even if logging is already configured
43
+ # Create rotating file handler - rotates every 24 hours (midnight)
44
+ file_handler = logging.handlers.TimedRotatingFileHandler(
45
+ filename=str(log_file),
46
+ when='midnight', # Rotate at midnight
47
+ interval=1, # Every 1 day
48
+ backupCount=30, # Keep 30 days of logs
49
+ encoding='utf-8', # Use UTF-8 encoding
50
+ utc=False # Use local time for rotation
51
+ )
52
+ file_handler.setLevel(log_level)
53
+
54
+ # Create console handler
55
+ console_handler = logging.StreamHandler(sys.stderr)
56
+ console_handler.setLevel(log_level)
57
+
58
+ # Create formatter
59
+ formatter = logging.Formatter(
60
+ "%(asctime)s - %(name)s - %(levelname)s - %(message)s"
41
61
  )
62
+ file_handler.setFormatter(formatter)
63
+ console_handler.setFormatter(formatter)
64
+
65
+ # Configure root logger
66
+ root_logger.setLevel(log_level)
67
+ root_logger.addHandler(file_handler)
68
+ root_logger.addHandler(console_handler)
42
69
 
43
70
 
44
71
  def load_config() -> Dict[str, Any]:
@@ -0,0 +1,24 @@
1
+ """FastMCP tool mixins."""
2
+
3
+ from .base_tools_mixin import BaseToolsMixin
4
+ from .connection_tools_mixin import ConnectionToolsMixin
5
+ from .crud_tools_mixin import CrudToolsMixin
6
+ from .database_tools_mixin import DatabaseToolsMixin, DatabaseQuerySafetyError
7
+ from .label_tools_mixin import LabelToolsMixin
8
+ from .metadata_tools_mixin import MetadataToolsMixin
9
+ from .performance_tools_mixin import PerformanceToolsMixin
10
+ from .profile_tools_mixin import ProfileToolsMixin
11
+ from .sync_tools_mixin import SyncToolsMixin
12
+
13
+ __all__ = [
14
+ 'BaseToolsMixin',
15
+ 'ConnectionToolsMixin',
16
+ 'CrudToolsMixin',
17
+ 'DatabaseToolsMixin',
18
+ 'DatabaseQuerySafetyError',
19
+ 'LabelToolsMixin',
20
+ 'MetadataToolsMixin',
21
+ 'PerformanceToolsMixin',
22
+ 'ProfileToolsMixin',
23
+ 'SyncToolsMixin',
24
+ ]
@@ -0,0 +1,55 @@
1
+ """Base mixin class for FastMCP tool categories."""
2
+
3
+ import logging
4
+ from typing import Optional
5
+
6
+ from d365fo_client.client import FOClient
7
+ from d365fo_client.profile_manager import ProfileManager
8
+ from ..client_manager import D365FOClientManager
9
+ from mcp.server.fastmcp import FastMCP
10
+
11
+ logger = logging.getLogger(__name__)
12
+
13
+
14
+ class BaseToolsMixin:
15
+ """Base mixin for FastMCP tool categories.
16
+
17
+ Provides common functionality and client access patterns
18
+ for all tool category mixins.
19
+ """
20
+
21
+ # These will be injected by the main server class
22
+ client_manager: D365FOClientManager
23
+ mcp: FastMCP
24
+ profile_manager: ProfileManager
25
+
26
+ async def _get_client(self, profile: str = "default") -> FOClient:
27
+ """Get D365FO client for specified profile.
28
+
29
+ Args:
30
+ profile: Profile name to use
31
+
32
+ Returns:
33
+ Configured D365FO client instance
34
+ """
35
+ if not hasattr(self, 'client_manager') or not self.client_manager:
36
+ raise RuntimeError("Client manager not initialized")
37
+ return await self.client_manager.get_client(profile)
38
+
39
+ def _create_error_response(self, error: Exception, tool_name: str, arguments: dict) -> dict:
40
+ """Create standardized error response.
41
+
42
+ Args:
43
+ error: Exception that occurred
44
+ tool_name: Name of the tool that failed
45
+ arguments: Arguments passed to the tool
46
+
47
+ Returns:
48
+ Dictionary with error details
49
+ """
50
+ return {
51
+ "error": str(error),
52
+ "tool": tool_name,
53
+ "arguments": arguments,
54
+ "error_type": type(error).__name__,
55
+ }
@@ -0,0 +1,50 @@
1
+ """Connection tools mixin for FastMCP server."""
2
+
3
+ import json
4
+ import logging
5
+
6
+ from .base_tools_mixin import BaseToolsMixin
7
+
8
+ logger = logging.getLogger(__name__)
9
+
10
+
11
+ class ConnectionToolsMixin(BaseToolsMixin):
12
+ """Connection and environment tools for FastMCP server."""
13
+
14
+ def register_connection_tools(self):
15
+ """Register all connection tools with FastMCP."""
16
+
17
+ @self.mcp.tool()
18
+ async def d365fo_test_connection(profile: str = "default") -> dict:
19
+ """Test connection to D365FO environment.
20
+
21
+ Args:
22
+ profile: Optional profile name to test (uses default if not specified)
23
+
24
+ Returns:
25
+ JSON string with connection test results
26
+ """
27
+ try:
28
+ result = await self.client_manager.test_connection(profile)
29
+
30
+ return {"status":result}
31
+ except Exception as e:
32
+ logger.error(f"Connection test failed: {e}")
33
+ return {"status": "error", "error": str(e), "profile": profile}
34
+
35
+ @self.mcp.tool()
36
+ async def d365fo_get_environment_info(profile: str = "default") -> dict:
37
+ """Get D365FO environment information and version details.
38
+
39
+ Args:
40
+ profile: Optional profile name (uses default if not specified)
41
+
42
+ Returns:
43
+ JSON string with environment information
44
+ """
45
+ try:
46
+ result = await self.client_manager.get_environment_info(profile)
47
+ return result
48
+ except Exception as e:
49
+ logger.error(f"Failed to get environment info: {e}")
50
+ return {"error": str(e), "profile": profile}