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,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
|
-
#
|
24
|
-
|
25
|
-
|
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
|
-
#
|
33
|
-
logging.
|
34
|
-
|
35
|
-
|
36
|
-
|
37
|
-
|
38
|
-
|
39
|
-
|
40
|
-
|
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}
|