d365fo-client 0.1.0__tar.gz → 0.2.1__tar.gz
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-0.1.0/src/d365fo_client.egg-info → d365fo_client-0.2.1}/PKG-INFO +5 -1
- {d365fo_client-0.1.0 → d365fo_client-0.2.1}/README.md +4 -0
- {d365fo_client-0.1.0 → d365fo_client-0.2.1}/pyproject.toml +1 -1
- {d365fo_client-0.1.0 → d365fo_client-0.2.1}/src/d365fo_client/client.py +58 -32
- {d365fo_client-0.1.0 → d365fo_client-0.2.1}/src/d365fo_client/config.py +8 -0
- {d365fo_client-0.1.0 → d365fo_client-0.2.1}/src/d365fo_client/labels.py +3 -3
- {d365fo_client-0.1.0 → d365fo_client-0.2.1}/src/d365fo_client/main.py +6 -6
- {d365fo_client-0.1.0 → d365fo_client-0.2.1}/src/d365fo_client/mcp/client_manager.py +30 -2
- {d365fo_client-0.1.0 → d365fo_client-0.2.1}/src/d365fo_client/mcp/main.py +20 -7
- {d365fo_client-0.1.0 → d365fo_client-0.2.1}/src/d365fo_client/mcp/server.py +90 -5
- {d365fo_client-0.1.0 → d365fo_client-0.2.1}/src/d365fo_client/mcp/tools/profile_tools.py +15 -0
- {d365fo_client-0.1.0 → d365fo_client-0.2.1}/src/d365fo_client/metadata_api.py +223 -6
- {d365fo_client-0.1.0 → d365fo_client-0.2.1}/src/d365fo_client/metadata_v2/cache_v2.py +253 -88
- {d365fo_client-0.1.0 → d365fo_client-0.2.1}/src/d365fo_client/metadata_v2/database_v2.py +0 -2
- d365fo_client-0.2.1/src/d365fo_client/metadata_v2/label_utils.py +107 -0
- {d365fo_client-0.1.0 → d365fo_client-0.2.1}/src/d365fo_client/metadata_v2/sync_manager_v2.py +83 -9
- {d365fo_client-0.1.0 → d365fo_client-0.2.1}/src/d365fo_client/models.py +2 -2
- {d365fo_client-0.1.0 → d365fo_client-0.2.1}/src/d365fo_client/output.py +4 -4
- {d365fo_client-0.1.0 → d365fo_client-0.2.1}/src/d365fo_client/profile_manager.py +12 -0
- {d365fo_client-0.1.0 → d365fo_client-0.2.1/src/d365fo_client.egg-info}/PKG-INFO +5 -1
- {d365fo_client-0.1.0 → d365fo_client-0.2.1}/src/d365fo_client.egg-info/SOURCES.txt +1 -0
- {d365fo_client-0.1.0 → d365fo_client-0.2.1}/LICENSE +0 -0
- {d365fo_client-0.1.0 → d365fo_client-0.2.1}/setup.cfg +0 -0
- {d365fo_client-0.1.0 → d365fo_client-0.2.1}/src/d365fo_client/__init__.py +0 -0
- {d365fo_client-0.1.0 → d365fo_client-0.2.1}/src/d365fo_client/auth.py +0 -0
- {d365fo_client-0.1.0 → d365fo_client-0.2.1}/src/d365fo_client/cli.py +0 -0
- {d365fo_client-0.1.0 → d365fo_client-0.2.1}/src/d365fo_client/crud.py +0 -0
- {d365fo_client-0.1.0 → d365fo_client-0.2.1}/src/d365fo_client/exceptions.py +0 -0
- {d365fo_client-0.1.0 → d365fo_client-0.2.1}/src/d365fo_client/mcp/__init__.py +0 -0
- {d365fo_client-0.1.0 → d365fo_client-0.2.1}/src/d365fo_client/mcp/models.py +0 -0
- {d365fo_client-0.1.0 → d365fo_client-0.2.1}/src/d365fo_client/mcp/prompts/__init__.py +0 -0
- {d365fo_client-0.1.0 → d365fo_client-0.2.1}/src/d365fo_client/mcp/prompts/action_execution.py +0 -0
- {d365fo_client-0.1.0 → d365fo_client-0.2.1}/src/d365fo_client/mcp/prompts/sequence_analysis.py +0 -0
- {d365fo_client-0.1.0 → d365fo_client-0.2.1}/src/d365fo_client/mcp/resources/__init__.py +0 -0
- {d365fo_client-0.1.0 → d365fo_client-0.2.1}/src/d365fo_client/mcp/resources/database_handler.py +0 -0
- {d365fo_client-0.1.0 → d365fo_client-0.2.1}/src/d365fo_client/mcp/resources/entity_handler.py +0 -0
- {d365fo_client-0.1.0 → d365fo_client-0.2.1}/src/d365fo_client/mcp/resources/environment_handler.py +0 -0
- {d365fo_client-0.1.0 → d365fo_client-0.2.1}/src/d365fo_client/mcp/resources/metadata_handler.py +0 -0
- {d365fo_client-0.1.0 → d365fo_client-0.2.1}/src/d365fo_client/mcp/resources/query_handler.py +0 -0
- {d365fo_client-0.1.0 → d365fo_client-0.2.1}/src/d365fo_client/mcp/tools/__init__.py +0 -0
- {d365fo_client-0.1.0 → d365fo_client-0.2.1}/src/d365fo_client/mcp/tools/connection_tools.py +0 -0
- {d365fo_client-0.1.0 → d365fo_client-0.2.1}/src/d365fo_client/mcp/tools/crud_tools.py +0 -0
- {d365fo_client-0.1.0 → d365fo_client-0.2.1}/src/d365fo_client/mcp/tools/database_tools.py +0 -0
- {d365fo_client-0.1.0 → d365fo_client-0.2.1}/src/d365fo_client/mcp/tools/label_tools.py +0 -0
- {d365fo_client-0.1.0 → d365fo_client-0.2.1}/src/d365fo_client/mcp/tools/metadata_tools.py +0 -0
- {d365fo_client-0.1.0 → d365fo_client-0.2.1}/src/d365fo_client/metadata_v2/__init__.py +0 -0
- {d365fo_client-0.1.0 → d365fo_client-0.2.1}/src/d365fo_client/metadata_v2/global_version_manager.py +0 -0
- {d365fo_client-0.1.0 → d365fo_client-0.2.1}/src/d365fo_client/metadata_v2/search_engine_v2.py +0 -0
- {d365fo_client-0.1.0 → d365fo_client-0.2.1}/src/d365fo_client/metadata_v2/version_detector.py +0 -0
- {d365fo_client-0.1.0 → d365fo_client-0.2.1}/src/d365fo_client/profiles.py +0 -0
- {d365fo_client-0.1.0 → d365fo_client-0.2.1}/src/d365fo_client/query.py +0 -0
- {d365fo_client-0.1.0 → d365fo_client-0.2.1}/src/d365fo_client/session.py +0 -0
- {d365fo_client-0.1.0 → d365fo_client-0.2.1}/src/d365fo_client/utils.py +0 -0
- {d365fo_client-0.1.0 → d365fo_client-0.2.1}/src/d365fo_client.egg-info/dependency_links.txt +0 -0
- {d365fo_client-0.1.0 → d365fo_client-0.2.1}/src/d365fo_client.egg-info/entry_points.txt +0 -0
- {d365fo_client-0.1.0 → d365fo_client-0.2.1}/src/d365fo_client.egg-info/requires.txt +0 -0
- {d365fo_client-0.1.0 → d365fo_client-0.2.1}/src/d365fo_client.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
|
|
1
1
|
Metadata-Version: 2.4
|
2
2
|
Name: d365fo-client
|
3
|
-
Version: 0.1
|
3
|
+
Version: 0.2.1
|
4
4
|
Summary: Microsoft Dynamics 365 Finance & Operations client
|
5
5
|
Author-email: Muhammad Afzaal <mo@thedataguy.pro>
|
6
6
|
License-Expression: MIT
|
@@ -911,6 +911,8 @@ Add to your VS Code `mcp.json` for GitHub Copilot with MCP:
|
|
911
911
|
"type": "stdio",
|
912
912
|
"command": "uvx",
|
913
913
|
"args": [
|
914
|
+
"--from",
|
915
|
+
"d365fo-client",
|
914
916
|
"d365fo-mcp-server"
|
915
917
|
],
|
916
918
|
"env": {
|
@@ -932,6 +934,8 @@ For environments requiring service principal authentication:
|
|
932
934
|
"type": "stdio",
|
933
935
|
"command": "uvx",
|
934
936
|
"args": [
|
937
|
+
"--from",
|
938
|
+
"d365fo-client",
|
935
939
|
"d365fo-mcp-server"
|
936
940
|
],
|
937
941
|
"env": {
|
@@ -870,6 +870,8 @@ Add to your VS Code `mcp.json` for GitHub Copilot with MCP:
|
|
870
870
|
"type": "stdio",
|
871
871
|
"command": "uvx",
|
872
872
|
"args": [
|
873
|
+
"--from",
|
874
|
+
"d365fo-client",
|
873
875
|
"d365fo-mcp-server"
|
874
876
|
],
|
875
877
|
"env": {
|
@@ -891,6 +893,8 @@ For environments requiring service principal authentication:
|
|
891
893
|
"type": "stdio",
|
892
894
|
"command": "uvx",
|
893
895
|
"args": [
|
896
|
+
"--from",
|
897
|
+
"d365fo-client",
|
894
898
|
"d365fo-mcp-server"
|
895
899
|
],
|
896
900
|
"env": {
|
@@ -2,7 +2,6 @@
|
|
2
2
|
|
3
3
|
import asyncio
|
4
4
|
import logging
|
5
|
-
import os
|
6
5
|
from pathlib import Path
|
7
6
|
from typing import Any, Dict, List, Optional, Union
|
8
7
|
|
@@ -158,6 +157,17 @@ class FOClient:
|
|
158
157
|
self.logger.error(f"Background sync error: {e}")
|
159
158
|
# Don't re-raise to avoid breaking the background task
|
160
159
|
|
160
|
+
def _is_background_sync_running(self) -> bool:
|
161
|
+
"""Check if background sync task is currently running
|
162
|
+
|
163
|
+
Returns:
|
164
|
+
True if background sync is actively running, False otherwise
|
165
|
+
"""
|
166
|
+
return (
|
167
|
+
self._background_sync_task is not None
|
168
|
+
and not self._background_sync_task.done()
|
169
|
+
)
|
170
|
+
|
161
171
|
async def _get_from_cache_first(
|
162
172
|
self,
|
163
173
|
cache_method,
|
@@ -179,8 +189,12 @@ class FOClient:
|
|
179
189
|
if use_cache_first is None:
|
180
190
|
use_cache_first = self.config.use_cache_first
|
181
191
|
|
182
|
-
# If cache-first is disabled, go straight to fallback
|
183
|
-
if
|
192
|
+
# If cache-first is disabled, metadata cache is disabled, or background sync is running, go straight to fallback
|
193
|
+
if (
|
194
|
+
not use_cache_first
|
195
|
+
or not self.config.enable_metadata_cache
|
196
|
+
or self._is_background_sync_running()
|
197
|
+
):
|
184
198
|
return (
|
185
199
|
await fallback_method(*args, **kwargs)
|
186
200
|
if asyncio.iscoroutinefunction(fallback_method)
|
@@ -375,7 +389,7 @@ class FOClient:
|
|
375
389
|
return await self._get_from_cache_first(
|
376
390
|
cache_search,
|
377
391
|
fallback_search,
|
378
|
-
use_cache_first=use_cache_first
|
392
|
+
use_cache_first=use_cache_first,
|
379
393
|
)
|
380
394
|
|
381
395
|
async def get_entity_info(
|
@@ -415,20 +429,34 @@ class FOClient:
|
|
415
429
|
await self._ensure_metadata_initialized()
|
416
430
|
|
417
431
|
async def cache_search():
|
418
|
-
#
|
419
|
-
|
420
|
-
|
432
|
+
# Use the v2 cache action search functionality
|
433
|
+
if not self.metadata_cache:
|
434
|
+
return []
|
435
|
+
|
436
|
+
# Convert regex pattern to SQL LIKE pattern for cache
|
437
|
+
cache_pattern = None
|
438
|
+
if pattern:
|
439
|
+
# Convert simple regex to SQL LIKE pattern
|
440
|
+
cache_pattern = pattern.replace(".*", "%").replace(".", "_")
|
441
|
+
if not cache_pattern.startswith("%") and not cache_pattern.endswith("%"):
|
442
|
+
cache_pattern = f"%{cache_pattern}%"
|
443
|
+
|
444
|
+
return await self.metadata_cache.search_actions(
|
445
|
+
pattern=cache_pattern,
|
446
|
+
entity_name=entity_name,
|
447
|
+
binding_kind=binding_kind,
|
448
|
+
)
|
421
449
|
|
422
450
|
async def fallback_search():
|
423
|
-
#
|
424
|
-
|
425
|
-
|
426
|
-
|
451
|
+
# Use the new metadata API operations for action search
|
452
|
+
return await self.metadata_api_ops.search_actions(
|
453
|
+
pattern, entity_name, binding_kind
|
454
|
+
)
|
427
455
|
|
428
456
|
actions = await self._get_from_cache_first(
|
429
457
|
cache_search,
|
430
458
|
fallback_search,
|
431
|
-
use_cache_first=use_cache_first
|
459
|
+
use_cache_first=use_cache_first,
|
432
460
|
)
|
433
461
|
|
434
462
|
return await resolve_labels_generic(actions, self.label_ops)
|
@@ -451,20 +479,25 @@ class FOClient:
|
|
451
479
|
"""
|
452
480
|
|
453
481
|
async def cache_lookup():
|
454
|
-
#
|
455
|
-
|
456
|
-
|
482
|
+
# Use the v2 cache action lookup functionality
|
483
|
+
if not self.metadata_cache:
|
484
|
+
return None
|
485
|
+
|
486
|
+
return await self.metadata_cache.get_action_info(
|
487
|
+
action_name=action_name,
|
488
|
+
entity_name=entity_name,
|
489
|
+
)
|
457
490
|
|
458
491
|
async def fallback_lookup():
|
459
|
-
#
|
460
|
-
|
461
|
-
# Return None for backward compatibility
|
462
|
-
return None
|
492
|
+
# Use the new metadata API operations for action lookup
|
493
|
+
return await self.metadata_api_ops.get_action_info(action_name, entity_name)
|
463
494
|
|
464
|
-
|
495
|
+
action = await self._get_from_cache_first(
|
465
496
|
cache_lookup, fallback_lookup, use_cache_first=use_cache_first
|
466
497
|
)
|
467
498
|
|
499
|
+
return await resolve_labels_generic(action, self.label_ops) if action else None
|
500
|
+
|
468
501
|
# CRUD Operations
|
469
502
|
|
470
503
|
async def get_entities(
|
@@ -736,7 +769,7 @@ class FOClient:
|
|
736
769
|
entities = await self._get_from_cache_first(
|
737
770
|
cache_search,
|
738
771
|
fallback_search,
|
739
|
-
use_cache_first=use_cache_first
|
772
|
+
use_cache_first=use_cache_first,
|
740
773
|
)
|
741
774
|
|
742
775
|
if self.metadata_cache:
|
@@ -861,7 +894,7 @@ class FOClient:
|
|
861
894
|
entity = await self._get_from_cache_first(
|
862
895
|
cache_lookup,
|
863
896
|
fallback_lookup,
|
864
|
-
use_cache_first=use_cache_first
|
897
|
+
use_cache_first=use_cache_first,
|
865
898
|
)
|
866
899
|
|
867
900
|
return await resolve_labels_generic(entity, self.label_ops)
|
@@ -924,7 +957,7 @@ class FOClient:
|
|
924
957
|
enums = await self._get_from_cache_first(
|
925
958
|
cache_search,
|
926
959
|
fallback_search,
|
927
|
-
use_cache_first=use_cache_first
|
960
|
+
use_cache_first=use_cache_first,
|
928
961
|
)
|
929
962
|
|
930
963
|
return await resolve_labels_generic(enums, self.label_ops)
|
@@ -961,7 +994,7 @@ class FOClient:
|
|
961
994
|
enum = await self._get_from_cache_first(
|
962
995
|
cache_lookup,
|
963
996
|
fallback_lookup,
|
964
|
-
use_cache_first=use_cache_first
|
997
|
+
use_cache_first=use_cache_first,
|
965
998
|
)
|
966
999
|
return await resolve_labels_generic(enum, self.label_ops) if enum else None
|
967
1000
|
|
@@ -1136,14 +1169,7 @@ class FOClient:
|
|
1136
1169
|
"cache_v2_enabled": True,
|
1137
1170
|
"cache_initialized": self._metadata_initialized,
|
1138
1171
|
"sync_manager_available": self.sync_manager is not None,
|
1139
|
-
"background_sync_running": (
|
1140
|
-
(
|
1141
|
-
self._background_sync_task
|
1142
|
-
and not self._background_sync_task.done()
|
1143
|
-
)
|
1144
|
-
if self._background_sync_task
|
1145
|
-
else False
|
1146
|
-
),
|
1172
|
+
"background_sync_running": self._is_background_sync_running(),
|
1147
1173
|
"statistics": stats,
|
1148
1174
|
}
|
1149
1175
|
info.update(cache_info)
|
@@ -69,6 +69,14 @@ class ConfigManager:
|
|
69
69
|
except Exception as e:
|
70
70
|
print(f"Error saving config file {self.config_path}: {e}")
|
71
71
|
|
72
|
+
def reload_config(self) -> None:
|
73
|
+
"""Reload configuration from file.
|
74
|
+
|
75
|
+
This is useful when the config file has been modified externally
|
76
|
+
or by another instance of the ConfigManager.
|
77
|
+
"""
|
78
|
+
self._config_data = self._load_config()
|
79
|
+
|
72
80
|
def get_profile(self, profile_name: str) -> Optional[Profile]:
|
73
81
|
"""Get a specific configuration profile.
|
74
82
|
|
@@ -401,14 +401,14 @@ async def _resolve_labels_batch(
|
|
401
401
|
# Log individual results at debug level
|
402
402
|
for label_id in label_ids:
|
403
403
|
if label_id in label_texts:
|
404
|
-
logger.debug(f"
|
404
|
+
logger.debug(f"[OK] Resolved '{label_id}' -> '{label_texts[label_id]}'")
|
405
405
|
else:
|
406
|
-
logger.debug(f"
|
406
|
+
logger.debug(f"[MISS] No text found for label ID '{label_id}'")
|
407
407
|
|
408
408
|
return label_texts
|
409
409
|
|
410
410
|
except Exception as e:
|
411
|
-
logger.warning(f"
|
411
|
+
logger.warning(f"[ERROR] Error in batch label resolution: {e}")
|
412
412
|
return {}
|
413
413
|
|
414
414
|
|
@@ -20,26 +20,26 @@ async def example_usage():
|
|
20
20
|
|
21
21
|
async with FOClient(config) as client:
|
22
22
|
# Test connections
|
23
|
-
print("
|
23
|
+
print("[INFO] Testing connections...")
|
24
24
|
if await client.test_connection():
|
25
|
-
print("
|
25
|
+
print("[OK] Connected to F&O OData successfully")
|
26
26
|
|
27
27
|
if await client.test_metadata_connection():
|
28
|
-
print("
|
28
|
+
print("[OK] Connected to F&O Metadata API successfully")
|
29
29
|
|
30
30
|
# Download metadata
|
31
31
|
print("\n📥 Downloading metadata...")
|
32
32
|
await client.download_metadata()
|
33
33
|
|
34
34
|
# Search entities
|
35
|
-
print("\n
|
35
|
+
print("\n[SEARCH] Searching entities...")
|
36
36
|
customer_entities = await client.search_entities("customer")
|
37
37
|
print(f"Found {len(customer_entities)} customer-related entities")
|
38
38
|
for entity in customer_entities[:5]: # Show first 5
|
39
39
|
print(f" - {entity}")
|
40
40
|
|
41
41
|
# Get entity info with labels
|
42
|
-
print("\n
|
42
|
+
print("\n[INFO] Getting entity information...")
|
43
43
|
customers_info = await client.get_entity_info_with_labels("Customer")
|
44
44
|
if customers_info:
|
45
45
|
print(f"Customers entity: {customers_info.name}")
|
@@ -128,7 +128,7 @@ async def example_usage():
|
|
128
128
|
print("\n🆕 New Metadata APIs:")
|
129
129
|
|
130
130
|
# Data Entities API
|
131
|
-
print("\n
|
131
|
+
print("\n[API] Data Entities API:")
|
132
132
|
data_entities = await client.search_data_entities(
|
133
133
|
"customer", entity_category="Master"
|
134
134
|
)
|
@@ -20,17 +20,18 @@ logger = logging.getLogger(__name__)
|
|
20
20
|
class D365FOClientManager:
|
21
21
|
"""Manages D365FO client instances and connection pooling."""
|
22
22
|
|
23
|
-
def __init__(self, config: dict):
|
23
|
+
def __init__(self, config: dict, profile_manager: Optional[ProfileManager] = None):
|
24
24
|
"""Initialize the client manager.
|
25
25
|
|
26
26
|
Args:
|
27
27
|
config: Configuration dictionary with client settings
|
28
|
+
profile_manager: Optional shared ProfileManager instance
|
28
29
|
"""
|
29
30
|
self.config = config
|
30
31
|
self._client_pool: Dict[str, FOClient] = {}
|
31
32
|
self._session_lock = asyncio.Lock()
|
32
33
|
self._last_health_check: Optional[datetime] = None
|
33
|
-
self.profile_manager = ProfileManager()
|
34
|
+
self.profile_manager = profile_manager or ProfileManager()
|
34
35
|
|
35
36
|
async def get_client(self, profile: str = "default") -> FOClient:
|
36
37
|
"""Get or create a client for the specified profile.
|
@@ -144,6 +145,33 @@ class D365FOClientManager:
|
|
144
145
|
)
|
145
146
|
self._client_pool.clear()
|
146
147
|
|
148
|
+
async def refresh_profile(self, profile: str):
|
149
|
+
"""Refresh a specific profile by clearing its cached client.
|
150
|
+
|
151
|
+
This forces the client manager to recreate the client with
|
152
|
+
updated profile configuration on next access.
|
153
|
+
|
154
|
+
Args:
|
155
|
+
profile: Profile name to refresh
|
156
|
+
"""
|
157
|
+
logger.info(f"Refreshing profile: {profile}")
|
158
|
+
# Reload configuration to see any recent changes
|
159
|
+
self.profile_manager.reload_config()
|
160
|
+
# Clear the cached client for this profile
|
161
|
+
await self.cleanup(profile)
|
162
|
+
|
163
|
+
async def refresh_all_profiles(self):
|
164
|
+
"""Refresh all profiles by clearing the entire client pool.
|
165
|
+
|
166
|
+
This forces the client manager to recreate all clients with
|
167
|
+
updated profile configurations on next access.
|
168
|
+
"""
|
169
|
+
logger.info("Refreshing all profiles")
|
170
|
+
# Reload configuration to see any recent changes
|
171
|
+
self.profile_manager.reload_config()
|
172
|
+
# Clear all cached clients
|
173
|
+
await self.cleanup()
|
174
|
+
|
147
175
|
def _build_client_config(self, profile: str) -> FOClientConfig:
|
148
176
|
"""Build FOClientConfig from profile configuration.
|
149
177
|
|
@@ -6,8 +6,9 @@ import logging
|
|
6
6
|
import os
|
7
7
|
import sys
|
8
8
|
from pathlib import Path
|
9
|
-
from typing import Any, Dict
|
9
|
+
from typing import Any, Dict
|
10
10
|
|
11
|
+
from d365fo_client import __version__
|
11
12
|
from d365fo_client.mcp import D365FOMCPServer
|
12
13
|
|
13
14
|
|
@@ -23,6 +24,11 @@ def setup_logging(level: str = "INFO") -> None:
|
|
23
24
|
log_dir = Path.home() / ".d365fo-mcp" / "logs"
|
24
25
|
log_dir.mkdir(parents=True, exist_ok=True)
|
25
26
|
|
27
|
+
# Clear existing handlers to avoid duplicate logging
|
28
|
+
root_logger = logging.getLogger()
|
29
|
+
for handler in root_logger.handlers[:]:
|
30
|
+
root_logger.removeHandler(handler)
|
31
|
+
|
26
32
|
# Configure logging
|
27
33
|
logging.basicConfig(
|
28
34
|
level=log_level,
|
@@ -31,14 +37,15 @@ def setup_logging(level: str = "INFO") -> None:
|
|
31
37
|
logging.FileHandler(log_dir / "mcp-server.log"),
|
32
38
|
logging.StreamHandler(sys.stderr),
|
33
39
|
],
|
40
|
+
force=True, # Force reconfiguration even if logging is already configured
|
34
41
|
)
|
35
42
|
|
36
43
|
|
37
|
-
def load_config() ->
|
44
|
+
def load_config() -> Dict[str, Any]:
|
38
45
|
"""Load configuration from environment and config files.
|
39
46
|
|
40
47
|
Returns:
|
41
|
-
Configuration dictionary
|
48
|
+
Configuration dictionary
|
42
49
|
"""
|
43
50
|
config = {}
|
44
51
|
|
@@ -56,16 +63,22 @@ def load_config() -> Optional[Dict[str, Any]]:
|
|
56
63
|
if tenant_id := os.getenv("AZURE_TENANT_ID"):
|
57
64
|
config.setdefault("default_environment", {})["tenant_id"] = tenant_id
|
58
65
|
|
59
|
-
#
|
60
|
-
|
61
|
-
setup_logging(log_level)
|
66
|
+
# Check if D365FO_BASE_URL is configured for startup behavior
|
67
|
+
config["has_base_url"] = bool(os.getenv("D365FO_BASE_URL"))
|
62
68
|
|
63
|
-
return config
|
69
|
+
return config
|
64
70
|
|
65
71
|
|
66
72
|
async def async_main() -> None:
|
67
73
|
"""Async main entry point for the MCP server."""
|
68
74
|
try:
|
75
|
+
# Set up logging first based on environment variable
|
76
|
+
log_level = os.getenv("D365FO_LOG_LEVEL", "INFO")
|
77
|
+
setup_logging(log_level)
|
78
|
+
|
79
|
+
# Print server version at startup
|
80
|
+
logging.info(f"D365FO MCP Server v{__version__}")
|
81
|
+
|
69
82
|
# Load configuration
|
70
83
|
config = load_config()
|
71
84
|
|
@@ -7,6 +7,9 @@ from typing import Any, Dict, List, Optional
|
|
7
7
|
|
8
8
|
from mcp import GetPromptResult, Resource, Tool
|
9
9
|
from mcp.server import InitializationOptions, Server
|
10
|
+
|
11
|
+
from .. import __version__
|
12
|
+
from ..profile_manager import ProfileManager
|
10
13
|
from mcp.server.lowlevel.server import NotificationOptions
|
11
14
|
from mcp.types import Prompt, PromptArgument, PromptMessage, TextContent
|
12
15
|
|
@@ -36,7 +39,8 @@ class D365FOMCPServer:
|
|
36
39
|
"""
|
37
40
|
self.config = config or self._load_default_config()
|
38
41
|
self.server = Server("d365fo-mcp-server")
|
39
|
-
self.
|
42
|
+
self.profile_manager = ProfileManager()
|
43
|
+
self.client_manager = D365FOClientManager(self.config, self.profile_manager)
|
40
44
|
|
41
45
|
# Initialize resource handlers
|
42
46
|
self.entity_handler = EntityResourceHandler(self.client_manager)
|
@@ -321,10 +325,10 @@ class D365FOMCPServer:
|
|
321
325
|
transport_type: Transport type (stdio, sse, etc.)
|
322
326
|
"""
|
323
327
|
try:
|
324
|
-
logger.info("Starting D365FO MCP Server...")
|
328
|
+
logger.info(f"Starting D365FO MCP Server v{__version__}...")
|
325
329
|
|
326
|
-
# Perform
|
327
|
-
await self.
|
330
|
+
# Perform conditional startup initialization
|
331
|
+
await self._startup_initialization()
|
328
332
|
|
329
333
|
if transport_type == "stdio":
|
330
334
|
from mcp.server.stdio import stdio_server
|
@@ -335,7 +339,7 @@ class D365FOMCPServer:
|
|
335
339
|
write_stream,
|
336
340
|
InitializationOptions(
|
337
341
|
server_name="d365fo-mcp-server",
|
338
|
-
server_version=
|
342
|
+
server_version=__version__,
|
339
343
|
capabilities=self.server.get_capabilities(
|
340
344
|
notification_options=NotificationOptions(),
|
341
345
|
experimental_capabilities={},
|
@@ -351,6 +355,87 @@ class D365FOMCPServer:
|
|
351
355
|
finally:
|
352
356
|
await self.cleanup()
|
353
357
|
|
358
|
+
async def _startup_initialization(self):
|
359
|
+
"""Perform startup initialization based on configuration."""
|
360
|
+
try:
|
361
|
+
# Check if D365FO_BASE_URL is configured
|
362
|
+
has_base_url = self.config.get("has_base_url", False)
|
363
|
+
|
364
|
+
if has_base_url:
|
365
|
+
logger.info("D365FO_BASE_URL environment variable detected - performing health checks and profile setup")
|
366
|
+
|
367
|
+
# Perform health checks
|
368
|
+
await self._startup_health_checks()
|
369
|
+
|
370
|
+
# Create default profile if environment variables are configured
|
371
|
+
await self._create_default_profile_if_needed()
|
372
|
+
else:
|
373
|
+
logger.info("D365FO_BASE_URL not configured - server started in profile-only mode")
|
374
|
+
logger.info("Use profile management tools to configure D365FO connections")
|
375
|
+
|
376
|
+
except Exception as e:
|
377
|
+
logger.error(f"Startup initialization failed: {e}")
|
378
|
+
# Don't fail startup on initialization failures
|
379
|
+
|
380
|
+
async def _create_default_profile_if_needed(self):
|
381
|
+
"""Create a default profile from environment variables if needed."""
|
382
|
+
try:
|
383
|
+
# Check if default profile already exists
|
384
|
+
existing_default = self.profile_manager.get_default_profile()
|
385
|
+
if existing_default:
|
386
|
+
logger.info(f"Default profile already exists: {existing_default.name}")
|
387
|
+
return
|
388
|
+
|
389
|
+
# Get environment variables
|
390
|
+
base_url = os.getenv("D365FO_BASE_URL")
|
391
|
+
client_id = os.getenv("AZURE_CLIENT_ID")
|
392
|
+
client_secret = os.getenv("AZURE_CLIENT_SECRET")
|
393
|
+
tenant_id = os.getenv("AZURE_TENANT_ID")
|
394
|
+
|
395
|
+
if not base_url:
|
396
|
+
logger.warning("Cannot create default profile - D365FO_BASE_URL not set")
|
397
|
+
return
|
398
|
+
|
399
|
+
# Determine authentication mode
|
400
|
+
auth_mode = "default"
|
401
|
+
if client_id and client_secret and tenant_id:
|
402
|
+
auth_mode = "client_credentials"
|
403
|
+
|
404
|
+
# Create default profile with unique name
|
405
|
+
profile_name = "default-from-env"
|
406
|
+
|
407
|
+
# Check if profile with this name already exists
|
408
|
+
existing_profile = self.profile_manager.get_profile(profile_name)
|
409
|
+
if existing_profile:
|
410
|
+
logger.info(f"Profile '{profile_name}' already exists, setting as default")
|
411
|
+
self.profile_manager.set_default_profile(profile_name)
|
412
|
+
return
|
413
|
+
|
414
|
+
success = self.profile_manager.create_profile(
|
415
|
+
name=profile_name,
|
416
|
+
base_url=base_url,
|
417
|
+
auth_mode=auth_mode,
|
418
|
+
client_id=client_id,
|
419
|
+
client_secret=client_secret,
|
420
|
+
tenant_id=tenant_id,
|
421
|
+
description="Auto-created from environment variables at startup",
|
422
|
+
use_label_cache=True,
|
423
|
+
timeout=60,
|
424
|
+
verify_ssl=True
|
425
|
+
)
|
426
|
+
|
427
|
+
if success:
|
428
|
+
# Set as default profile
|
429
|
+
self.profile_manager.set_default_profile(profile_name)
|
430
|
+
logger.info(f"Created and set default profile: {profile_name}")
|
431
|
+
logger.info(f"Profile configured for: {base_url}")
|
432
|
+
logger.info(f"Authentication mode: {auth_mode}")
|
433
|
+
else:
|
434
|
+
logger.warning(f"Failed to create default profile: {profile_name}")
|
435
|
+
|
436
|
+
except Exception as e:
|
437
|
+
logger.error(f"Error creating default profile: {e}")
|
438
|
+
|
354
439
|
async def _startup_health_checks(self):
|
355
440
|
"""Perform startup health checks."""
|
356
441
|
try:
|
@@ -423,6 +423,12 @@ class ProfileTools:
|
|
423
423
|
if set_as_default:
|
424
424
|
self.profile_manager.set_default_profile(name)
|
425
425
|
|
426
|
+
# Refresh the client manager to pick up the new profile
|
427
|
+
await self.client_manager.refresh_profile(name)
|
428
|
+
if set_as_default:
|
429
|
+
# If setting as default, also refresh the default profile
|
430
|
+
await self.client_manager.refresh_profile("default")
|
431
|
+
|
426
432
|
response = {
|
427
433
|
"success": True,
|
428
434
|
"profileName": name,
|
@@ -485,6 +491,9 @@ class ProfileTools:
|
|
485
491
|
TextContent(type="text", text=json.dumps(error_response, indent=2))
|
486
492
|
]
|
487
493
|
|
494
|
+
# Refresh the client manager to pick up the updated profile
|
495
|
+
await self.client_manager.refresh_profile(name)
|
496
|
+
|
488
497
|
response = {
|
489
498
|
"success": True,
|
490
499
|
"profileName": name,
|
@@ -525,6 +534,9 @@ class ProfileTools:
|
|
525
534
|
TextContent(type="text", text=json.dumps(error_response, indent=2))
|
526
535
|
]
|
527
536
|
|
537
|
+
# Refresh the client manager to remove the deleted profile
|
538
|
+
await self.client_manager.refresh_profile(profile_name)
|
539
|
+
|
528
540
|
response = {
|
529
541
|
"success": True,
|
530
542
|
"profileName": profile_name,
|
@@ -564,6 +576,9 @@ class ProfileTools:
|
|
564
576
|
TextContent(type="text", text=json.dumps(error_response, indent=2))
|
565
577
|
]
|
566
578
|
|
579
|
+
# Refresh the default profile in client manager
|
580
|
+
await self.client_manager.refresh_profile("default")
|
581
|
+
|
567
582
|
response = {
|
568
583
|
"success": True,
|
569
584
|
"profileName": profile_name,
|