d365fo-client 0.1.0__tar.gz → 0.2.2__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.2}/PKG-INFO +5 -1
- {d365fo_client-0.1.0 → d365fo_client-0.2.2}/README.md +4 -0
- {d365fo_client-0.1.0 → d365fo_client-0.2.2}/pyproject.toml +1 -1
- {d365fo_client-0.1.0 → d365fo_client-0.2.2}/src/d365fo_client/__init__.py +4 -48
- {d365fo_client-0.1.0 → d365fo_client-0.2.2}/src/d365fo_client/client.py +102 -56
- {d365fo_client-0.1.0 → d365fo_client-0.2.2}/src/d365fo_client/config.py +8 -0
- {d365fo_client-0.1.0 → d365fo_client-0.2.2}/src/d365fo_client/labels.py +3 -3
- {d365fo_client-0.1.0 → d365fo_client-0.2.2}/src/d365fo_client/main.py +6 -6
- {d365fo_client-0.1.0 → d365fo_client-0.2.2}/src/d365fo_client/mcp/client_manager.py +30 -2
- {d365fo_client-0.1.0 → d365fo_client-0.2.2}/src/d365fo_client/mcp/main.py +20 -7
- {d365fo_client-0.1.0 → d365fo_client-0.2.2}/src/d365fo_client/mcp/models.py +2 -2
- {d365fo_client-0.1.0 → d365fo_client-0.2.2}/src/d365fo_client/mcp/server.py +90 -5
- {d365fo_client-0.1.0 → d365fo_client-0.2.2}/src/d365fo_client/mcp/tools/connection_tools.py +7 -0
- {d365fo_client-0.1.0 → d365fo_client-0.2.2}/src/d365fo_client/mcp/tools/profile_tools.py +15 -0
- {d365fo_client-0.1.0 → d365fo_client-0.2.2}/src/d365fo_client/metadata_api.py +223 -6
- {d365fo_client-0.1.0 → d365fo_client-0.2.2}/src/d365fo_client/metadata_v2/cache_v2.py +267 -97
- {d365fo_client-0.1.0 → d365fo_client-0.2.2}/src/d365fo_client/metadata_v2/database_v2.py +93 -2
- {d365fo_client-0.1.0 → d365fo_client-0.2.2}/src/d365fo_client/metadata_v2/global_version_manager.py +60 -0
- d365fo_client-0.2.2/src/d365fo_client/metadata_v2/label_utils.py +107 -0
- {d365fo_client-0.1.0 → d365fo_client-0.2.2}/src/d365fo_client/metadata_v2/sync_manager_v2.py +83 -9
- {d365fo_client-0.1.0 → d365fo_client-0.2.2}/src/d365fo_client/models.py +19 -10
- {d365fo_client-0.1.0 → d365fo_client-0.2.2}/src/d365fo_client/output.py +4 -4
- {d365fo_client-0.1.0 → d365fo_client-0.2.2}/src/d365fo_client/profile_manager.py +12 -0
- {d365fo_client-0.1.0 → d365fo_client-0.2.2/src/d365fo_client.egg-info}/PKG-INFO +5 -1
- {d365fo_client-0.1.0 → d365fo_client-0.2.2}/src/d365fo_client.egg-info/SOURCES.txt +1 -0
- {d365fo_client-0.1.0 → d365fo_client-0.2.2}/LICENSE +0 -0
- {d365fo_client-0.1.0 → d365fo_client-0.2.2}/setup.cfg +0 -0
- {d365fo_client-0.1.0 → d365fo_client-0.2.2}/src/d365fo_client/auth.py +0 -0
- {d365fo_client-0.1.0 → d365fo_client-0.2.2}/src/d365fo_client/cli.py +0 -0
- {d365fo_client-0.1.0 → d365fo_client-0.2.2}/src/d365fo_client/crud.py +0 -0
- {d365fo_client-0.1.0 → d365fo_client-0.2.2}/src/d365fo_client/exceptions.py +0 -0
- {d365fo_client-0.1.0 → d365fo_client-0.2.2}/src/d365fo_client/mcp/__init__.py +0 -0
- {d365fo_client-0.1.0 → d365fo_client-0.2.2}/src/d365fo_client/mcp/prompts/__init__.py +0 -0
- {d365fo_client-0.1.0 → d365fo_client-0.2.2}/src/d365fo_client/mcp/prompts/action_execution.py +0 -0
- {d365fo_client-0.1.0 → d365fo_client-0.2.2}/src/d365fo_client/mcp/prompts/sequence_analysis.py +0 -0
- {d365fo_client-0.1.0 → d365fo_client-0.2.2}/src/d365fo_client/mcp/resources/__init__.py +0 -0
- {d365fo_client-0.1.0 → d365fo_client-0.2.2}/src/d365fo_client/mcp/resources/database_handler.py +0 -0
- {d365fo_client-0.1.0 → d365fo_client-0.2.2}/src/d365fo_client/mcp/resources/entity_handler.py +0 -0
- {d365fo_client-0.1.0 → d365fo_client-0.2.2}/src/d365fo_client/mcp/resources/environment_handler.py +0 -0
- {d365fo_client-0.1.0 → d365fo_client-0.2.2}/src/d365fo_client/mcp/resources/metadata_handler.py +0 -0
- {d365fo_client-0.1.0 → d365fo_client-0.2.2}/src/d365fo_client/mcp/resources/query_handler.py +0 -0
- {d365fo_client-0.1.0 → d365fo_client-0.2.2}/src/d365fo_client/mcp/tools/__init__.py +0 -0
- {d365fo_client-0.1.0 → d365fo_client-0.2.2}/src/d365fo_client/mcp/tools/crud_tools.py +0 -0
- {d365fo_client-0.1.0 → d365fo_client-0.2.2}/src/d365fo_client/mcp/tools/database_tools.py +0 -0
- {d365fo_client-0.1.0 → d365fo_client-0.2.2}/src/d365fo_client/mcp/tools/label_tools.py +0 -0
- {d365fo_client-0.1.0 → d365fo_client-0.2.2}/src/d365fo_client/mcp/tools/metadata_tools.py +0 -0
- {d365fo_client-0.1.0 → d365fo_client-0.2.2}/src/d365fo_client/metadata_v2/__init__.py +0 -0
- {d365fo_client-0.1.0 → d365fo_client-0.2.2}/src/d365fo_client/metadata_v2/search_engine_v2.py +0 -0
- {d365fo_client-0.1.0 → d365fo_client-0.2.2}/src/d365fo_client/metadata_v2/version_detector.py +0 -0
- {d365fo_client-0.1.0 → d365fo_client-0.2.2}/src/d365fo_client/profiles.py +0 -0
- {d365fo_client-0.1.0 → d365fo_client-0.2.2}/src/d365fo_client/query.py +0 -0
- {d365fo_client-0.1.0 → d365fo_client-0.2.2}/src/d365fo_client/session.py +0 -0
- {d365fo_client-0.1.0 → d365fo_client-0.2.2}/src/d365fo_client/utils.py +0 -0
- {d365fo_client-0.1.0 → d365fo_client-0.2.2}/src/d365fo_client.egg-info/dependency_links.txt +0 -0
- {d365fo_client-0.1.0 → d365fo_client-0.2.2}/src/d365fo_client.egg-info/entry_points.txt +0 -0
- {d365fo_client-0.1.0 → d365fo_client-0.2.2}/src/d365fo_client.egg-info/requires.txt +0 -0
- {d365fo_client-0.1.0 → d365fo_client-0.2.2}/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.
|
3
|
+
Version: 0.2.2
|
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": {
|
@@ -174,55 +174,13 @@ from .main import main
|
|
174
174
|
# MCP Server
|
175
175
|
from .mcp import D365FOClientManager, D365FOMCPServer
|
176
176
|
|
177
|
-
# Legacy Metadata Cache (deprecated - use metadata_v2)
|
178
|
-
# REMOVED: Legacy classes have been replaced with V2 implementations
|
179
|
-
# from .metadata_cache import MetadataCache, MetadataSearchEngine
|
180
|
-
|
181
177
|
# V2 Metadata Cache (recommended - now the only implementation)
|
182
178
|
from .metadata_v2 import MetadataCacheV2, VersionAwareSearchEngine
|
183
179
|
|
184
180
|
# Provide backward compatibility with immediate import errors
|
185
|
-
|
186
|
-
|
187
|
-
|
188
|
-
"""Deprecated placeholder for MetadataCache - raises error on any access"""
|
189
|
-
def __init__(self, *args, **kwargs):
|
190
|
-
warnings.warn(
|
191
|
-
"MetadataCache is deprecated and has been removed. "
|
192
|
-
"Use MetadataCacheV2 from d365fo_client.metadata_v2 instead.",
|
193
|
-
DeprecationWarning,
|
194
|
-
stacklevel=2
|
195
|
-
)
|
196
|
-
raise ImportError(
|
197
|
-
"MetadataCache has been removed. Use MetadataCacheV2 from d365fo_client.metadata_v2 instead."
|
198
|
-
)
|
199
|
-
|
200
|
-
def __getattr__(self, name):
|
201
|
-
raise ImportError(
|
202
|
-
"MetadataCache has been removed. Use MetadataCacheV2 from d365fo_client.metadata_v2 instead."
|
203
|
-
)
|
204
|
-
|
205
|
-
class _DeprecatedMetadataSearchEngine:
|
206
|
-
"""Deprecated placeholder for MetadataSearchEngine - raises error on any access"""
|
207
|
-
def __init__(self, *args, **kwargs):
|
208
|
-
warnings.warn(
|
209
|
-
"MetadataSearchEngine is deprecated and has been removed. "
|
210
|
-
"Use VersionAwareSearchEngine from d365fo_client.metadata_v2 instead.",
|
211
|
-
DeprecationWarning,
|
212
|
-
stacklevel=2
|
213
|
-
)
|
214
|
-
raise ImportError(
|
215
|
-
"MetadataSearchEngine has been removed. Use VersionAwareSearchEngine from d365fo_client.metadata_v2 instead."
|
216
|
-
)
|
217
|
-
|
218
|
-
def __getattr__(self, name):
|
219
|
-
raise ImportError(
|
220
|
-
"MetadataSearchEngine has been removed. Use VersionAwareSearchEngine from d365fo_client.metadata_v2 instead."
|
221
|
-
)
|
222
|
-
|
223
|
-
# Create deprecated placeholder classes
|
224
|
-
MetadataCache = _DeprecatedMetadataCache
|
225
|
-
MetadataSearchEngine = _DeprecatedMetadataSearchEngine
|
181
|
+
|
182
|
+
|
183
|
+
|
226
184
|
from .models import (
|
227
185
|
ActionInfo,
|
228
186
|
DataEntityInfo,
|
@@ -255,9 +213,7 @@ __all__ = [
|
|
255
213
|
# Main client
|
256
214
|
"FOClient",
|
257
215
|
"create_client",
|
258
|
-
|
259
|
-
"MetadataCache",
|
260
|
-
"MetadataSearchEngine",
|
216
|
+
|
261
217
|
# V2 caching (now the primary implementation)
|
262
218
|
"MetadataCacheV2",
|
263
219
|
"VersionAwareSearchEngine",
|
@@ -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
|
|
@@ -114,23 +113,27 @@ class FOClient:
|
|
114
113
|
self.config.enable_metadata_cache = False
|
115
114
|
|
116
115
|
async def _trigger_background_sync_if_needed(self):
|
117
|
-
"""Trigger background sync if metadata is stale or missing"""
|
116
|
+
"""Trigger background sync if metadata is stale or missing (non-blocking)"""
|
118
117
|
if not self.config.enable_metadata_cache or not self._metadata_initialized:
|
119
118
|
return
|
120
119
|
|
120
|
+
# Don't trigger sync if already running
|
121
|
+
if self._is_background_sync_running():
|
122
|
+
return
|
123
|
+
|
121
124
|
try:
|
122
125
|
# Check if we need to sync using the new v2 API
|
126
|
+
# This should be a quick check, not actual sync work
|
123
127
|
sync_needed, global_version_id = (
|
124
128
|
await self.metadata_cache.check_version_and_sync(self.metadata_api_ops)
|
125
129
|
)
|
126
130
|
|
127
131
|
if sync_needed and global_version_id:
|
128
|
-
#
|
129
|
-
|
130
|
-
self.
|
131
|
-
|
132
|
-
|
133
|
-
self.logger.debug("Background metadata sync triggered")
|
132
|
+
# Start sync in background without awaiting it
|
133
|
+
self._background_sync_task = asyncio.create_task(
|
134
|
+
self._background_sync_worker(global_version_id)
|
135
|
+
)
|
136
|
+
self.logger.debug("Background metadata sync triggered")
|
134
137
|
except Exception as e:
|
135
138
|
self.logger.warning(f"Failed to check sync status: {e}")
|
136
139
|
|
@@ -158,6 +161,17 @@ class FOClient:
|
|
158
161
|
self.logger.error(f"Background sync error: {e}")
|
159
162
|
# Don't re-raise to avoid breaking the background task
|
160
163
|
|
164
|
+
def _is_background_sync_running(self) -> bool:
|
165
|
+
"""Check if background sync task is currently running
|
166
|
+
|
167
|
+
Returns:
|
168
|
+
True if background sync is actively running, False otherwise
|
169
|
+
"""
|
170
|
+
return (
|
171
|
+
self._background_sync_task is not None
|
172
|
+
and not self._background_sync_task.done()
|
173
|
+
)
|
174
|
+
|
161
175
|
async def _get_from_cache_first(
|
162
176
|
self,
|
163
177
|
cache_method,
|
@@ -179,8 +193,12 @@ class FOClient:
|
|
179
193
|
if use_cache_first is None:
|
180
194
|
use_cache_first = self.config.use_cache_first
|
181
195
|
|
182
|
-
# If cache-first is disabled, go straight to fallback
|
183
|
-
if
|
196
|
+
# If cache-first is disabled, metadata cache is disabled, or background sync is running, go straight to fallback
|
197
|
+
if (
|
198
|
+
not use_cache_first
|
199
|
+
or not self.config.enable_metadata_cache
|
200
|
+
or self._is_background_sync_running()
|
201
|
+
):
|
184
202
|
return (
|
185
203
|
await fallback_method(*args, **kwargs)
|
186
204
|
if asyncio.iscoroutinefunction(fallback_method)
|
@@ -208,7 +226,8 @@ class FOClient:
|
|
208
226
|
|
209
227
|
# If cache returns empty result, trigger sync and try fallback
|
210
228
|
if not result or (isinstance(result, list) and len(result) == 0):
|
211
|
-
|
229
|
+
# Trigger background sync without awaiting (fire-and-forget)
|
230
|
+
asyncio.create_task(self._trigger_background_sync_if_needed())
|
212
231
|
return (
|
213
232
|
await fallback_method(*args, **kwargs)
|
214
233
|
if asyncio.iscoroutinefunction(fallback_method)
|
@@ -219,8 +238,8 @@ class FOClient:
|
|
219
238
|
|
220
239
|
except Exception as e:
|
221
240
|
self.logger.warning(f"Cache lookup failed, using fallback: {e}")
|
222
|
-
# Trigger sync if cache failed
|
223
|
-
|
241
|
+
# Trigger sync if cache failed (fire-and-forget)
|
242
|
+
asyncio.create_task(self._trigger_background_sync_if_needed())
|
224
243
|
return (
|
225
244
|
await fallback_method(*args, **kwargs)
|
226
245
|
if asyncio.iscoroutinefunction(fallback_method)
|
@@ -375,7 +394,7 @@ class FOClient:
|
|
375
394
|
return await self._get_from_cache_first(
|
376
395
|
cache_search,
|
377
396
|
fallback_search,
|
378
|
-
use_cache_first=use_cache_first
|
397
|
+
use_cache_first=use_cache_first,
|
379
398
|
)
|
380
399
|
|
381
400
|
async def get_entity_info(
|
@@ -415,20 +434,34 @@ class FOClient:
|
|
415
434
|
await self._ensure_metadata_initialized()
|
416
435
|
|
417
436
|
async def cache_search():
|
418
|
-
#
|
419
|
-
|
420
|
-
|
437
|
+
# Use the v2 cache action search functionality
|
438
|
+
if not self.metadata_cache:
|
439
|
+
return []
|
440
|
+
|
441
|
+
# Convert regex pattern to SQL LIKE pattern for cache
|
442
|
+
cache_pattern = None
|
443
|
+
if pattern:
|
444
|
+
# Convert simple regex to SQL LIKE pattern
|
445
|
+
cache_pattern = pattern.replace(".*", "%").replace(".", "_")
|
446
|
+
if not cache_pattern.startswith("%") and not cache_pattern.endswith("%"):
|
447
|
+
cache_pattern = f"%{cache_pattern}%"
|
448
|
+
|
449
|
+
return await self.metadata_cache.search_actions(
|
450
|
+
pattern=cache_pattern,
|
451
|
+
entity_name=entity_name,
|
452
|
+
binding_kind=binding_kind,
|
453
|
+
)
|
421
454
|
|
422
455
|
async def fallback_search():
|
423
|
-
#
|
424
|
-
|
425
|
-
|
426
|
-
|
456
|
+
# Use the new metadata API operations for action search
|
457
|
+
return await self.metadata_api_ops.search_actions(
|
458
|
+
pattern, entity_name, binding_kind
|
459
|
+
)
|
427
460
|
|
428
461
|
actions = await self._get_from_cache_first(
|
429
462
|
cache_search,
|
430
463
|
fallback_search,
|
431
|
-
use_cache_first=use_cache_first
|
464
|
+
use_cache_first=use_cache_first,
|
432
465
|
)
|
433
466
|
|
434
467
|
return await resolve_labels_generic(actions, self.label_ops)
|
@@ -451,20 +484,25 @@ class FOClient:
|
|
451
484
|
"""
|
452
485
|
|
453
486
|
async def cache_lookup():
|
454
|
-
#
|
455
|
-
|
456
|
-
|
487
|
+
# Use the v2 cache action lookup functionality
|
488
|
+
if not self.metadata_cache:
|
489
|
+
return None
|
490
|
+
|
491
|
+
return await self.metadata_cache.get_action_info(
|
492
|
+
action_name=action_name,
|
493
|
+
entity_name=entity_name,
|
494
|
+
)
|
457
495
|
|
458
496
|
async def fallback_lookup():
|
459
|
-
#
|
460
|
-
|
461
|
-
# Return None for backward compatibility
|
462
|
-
return None
|
497
|
+
# Use the new metadata API operations for action lookup
|
498
|
+
return await self.metadata_api_ops.get_action_info(action_name, entity_name)
|
463
499
|
|
464
|
-
|
500
|
+
action = await self._get_from_cache_first(
|
465
501
|
cache_lookup, fallback_lookup, use_cache_first=use_cache_first
|
466
502
|
)
|
467
503
|
|
504
|
+
return await resolve_labels_generic(action, self.label_ops) if action else None
|
505
|
+
|
468
506
|
# CRUD Operations
|
469
507
|
|
470
508
|
async def get_entities(
|
@@ -736,7 +774,7 @@ class FOClient:
|
|
736
774
|
entities = await self._get_from_cache_first(
|
737
775
|
cache_search,
|
738
776
|
fallback_search,
|
739
|
-
use_cache_first=use_cache_first
|
777
|
+
use_cache_first=use_cache_first,
|
740
778
|
)
|
741
779
|
|
742
780
|
if self.metadata_cache:
|
@@ -861,7 +899,7 @@ class FOClient:
|
|
861
899
|
entity = await self._get_from_cache_first(
|
862
900
|
cache_lookup,
|
863
901
|
fallback_lookup,
|
864
|
-
use_cache_first=use_cache_first
|
902
|
+
use_cache_first=use_cache_first,
|
865
903
|
)
|
866
904
|
|
867
905
|
return await resolve_labels_generic(entity, self.label_ops)
|
@@ -924,7 +962,7 @@ class FOClient:
|
|
924
962
|
enums = await self._get_from_cache_first(
|
925
963
|
cache_search,
|
926
964
|
fallback_search,
|
927
|
-
use_cache_first=use_cache_first
|
965
|
+
use_cache_first=use_cache_first,
|
928
966
|
)
|
929
967
|
|
930
968
|
return await resolve_labels_generic(enums, self.label_ops)
|
@@ -961,7 +999,7 @@ class FOClient:
|
|
961
999
|
enum = await self._get_from_cache_first(
|
962
1000
|
cache_lookup,
|
963
1001
|
fallback_lookup,
|
964
|
-
use_cache_first=use_cache_first
|
1002
|
+
use_cache_first=use_cache_first,
|
965
1003
|
)
|
966
1004
|
return await resolve_labels_generic(enum, self.label_ops) if enum else None
|
967
1005
|
|
@@ -1004,29 +1042,44 @@ class FOClient:
|
|
1004
1042
|
try:
|
1005
1043
|
import asyncio
|
1006
1044
|
|
1007
|
-
#
|
1045
|
+
# Always check if we're in an async context first
|
1008
1046
|
try:
|
1009
|
-
loop = asyncio.
|
1010
|
-
|
1011
|
-
loop = asyncio.new_event_loop()
|
1012
|
-
asyncio.set_event_loop(loop)
|
1013
|
-
|
1014
|
-
if loop.is_running():
|
1015
|
-
# If we're in an async context, we can't run async code synchronously
|
1047
|
+
loop = asyncio.get_running_loop()
|
1048
|
+
# We're in an async context, can't run async code synchronously
|
1016
1049
|
return {
|
1017
1050
|
"enabled": True,
|
1018
1051
|
"cache_type": "metadata_v2",
|
1019
1052
|
"message": "Label caching enabled with v2 cache (statistics available via async method)",
|
1020
1053
|
}
|
1021
|
-
|
1022
|
-
#
|
1023
|
-
|
1024
|
-
|
1025
|
-
|
1054
|
+
except RuntimeError:
|
1055
|
+
# No running loop, we can safely create one
|
1056
|
+
pass
|
1057
|
+
|
1058
|
+
# Create a new event loop for synchronous execution
|
1059
|
+
try:
|
1060
|
+
loop = asyncio.new_event_loop()
|
1061
|
+
asyncio.set_event_loop(loop)
|
1062
|
+
try:
|
1063
|
+
stats = loop.run_until_complete(
|
1064
|
+
self.metadata_cache.get_label_cache_statistics()
|
1065
|
+
)
|
1066
|
+
return {
|
1067
|
+
"enabled": True,
|
1068
|
+
"cache_type": "metadata_v2",
|
1069
|
+
"statistics": stats,
|
1070
|
+
}
|
1071
|
+
finally:
|
1072
|
+
loop.close()
|
1073
|
+
# Remove the loop to clean up
|
1074
|
+
try:
|
1075
|
+
asyncio.set_event_loop(None)
|
1076
|
+
except:
|
1077
|
+
pass
|
1078
|
+
except Exception as e:
|
1026
1079
|
return {
|
1027
1080
|
"enabled": True,
|
1028
1081
|
"cache_type": "metadata_v2",
|
1029
|
-
"
|
1082
|
+
"error": f"Error getting statistics: {e}",
|
1030
1083
|
}
|
1031
1084
|
except Exception as e:
|
1032
1085
|
return {
|
@@ -1136,14 +1189,7 @@ class FOClient:
|
|
1136
1189
|
"cache_v2_enabled": True,
|
1137
1190
|
"cache_initialized": self._metadata_initialized,
|
1138
1191
|
"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
|
-
),
|
1192
|
+
"background_sync_running": self._is_background_sync_running(),
|
1147
1193
|
"statistics": stats,
|
1148
1194
|
}
|
1149
1195
|
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
|
|
@@ -2,7 +2,7 @@
|
|
2
2
|
|
3
3
|
from dataclasses import dataclass
|
4
4
|
from datetime import datetime
|
5
|
-
from enum import
|
5
|
+
from enum import StrEnum
|
6
6
|
from typing import Any, Dict, List, Optional, Union
|
7
7
|
|
8
8
|
# Resource Models
|
@@ -37,7 +37,7 @@ class EntityResourceContent:
|
|
37
37
|
last_updated: Optional[str] = None
|
38
38
|
|
39
39
|
|
40
|
-
class MetadataType(
|
40
|
+
class MetadataType(StrEnum):
|
41
41
|
ENTITIES = "entities"
|
42
42
|
ACTIONS = "actions"
|
43
43
|
ENUMERATIONS = "enumerations"
|