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.
Files changed (57) hide show
  1. {d365fo_client-0.1.0/src/d365fo_client.egg-info → d365fo_client-0.2.2}/PKG-INFO +5 -1
  2. {d365fo_client-0.1.0 → d365fo_client-0.2.2}/README.md +4 -0
  3. {d365fo_client-0.1.0 → d365fo_client-0.2.2}/pyproject.toml +1 -1
  4. {d365fo_client-0.1.0 → d365fo_client-0.2.2}/src/d365fo_client/__init__.py +4 -48
  5. {d365fo_client-0.1.0 → d365fo_client-0.2.2}/src/d365fo_client/client.py +102 -56
  6. {d365fo_client-0.1.0 → d365fo_client-0.2.2}/src/d365fo_client/config.py +8 -0
  7. {d365fo_client-0.1.0 → d365fo_client-0.2.2}/src/d365fo_client/labels.py +3 -3
  8. {d365fo_client-0.1.0 → d365fo_client-0.2.2}/src/d365fo_client/main.py +6 -6
  9. {d365fo_client-0.1.0 → d365fo_client-0.2.2}/src/d365fo_client/mcp/client_manager.py +30 -2
  10. {d365fo_client-0.1.0 → d365fo_client-0.2.2}/src/d365fo_client/mcp/main.py +20 -7
  11. {d365fo_client-0.1.0 → d365fo_client-0.2.2}/src/d365fo_client/mcp/models.py +2 -2
  12. {d365fo_client-0.1.0 → d365fo_client-0.2.2}/src/d365fo_client/mcp/server.py +90 -5
  13. {d365fo_client-0.1.0 → d365fo_client-0.2.2}/src/d365fo_client/mcp/tools/connection_tools.py +7 -0
  14. {d365fo_client-0.1.0 → d365fo_client-0.2.2}/src/d365fo_client/mcp/tools/profile_tools.py +15 -0
  15. {d365fo_client-0.1.0 → d365fo_client-0.2.2}/src/d365fo_client/metadata_api.py +223 -6
  16. {d365fo_client-0.1.0 → d365fo_client-0.2.2}/src/d365fo_client/metadata_v2/cache_v2.py +267 -97
  17. {d365fo_client-0.1.0 → d365fo_client-0.2.2}/src/d365fo_client/metadata_v2/database_v2.py +93 -2
  18. {d365fo_client-0.1.0 → d365fo_client-0.2.2}/src/d365fo_client/metadata_v2/global_version_manager.py +60 -0
  19. d365fo_client-0.2.2/src/d365fo_client/metadata_v2/label_utils.py +107 -0
  20. {d365fo_client-0.1.0 → d365fo_client-0.2.2}/src/d365fo_client/metadata_v2/sync_manager_v2.py +83 -9
  21. {d365fo_client-0.1.0 → d365fo_client-0.2.2}/src/d365fo_client/models.py +19 -10
  22. {d365fo_client-0.1.0 → d365fo_client-0.2.2}/src/d365fo_client/output.py +4 -4
  23. {d365fo_client-0.1.0 → d365fo_client-0.2.2}/src/d365fo_client/profile_manager.py +12 -0
  24. {d365fo_client-0.1.0 → d365fo_client-0.2.2/src/d365fo_client.egg-info}/PKG-INFO +5 -1
  25. {d365fo_client-0.1.0 → d365fo_client-0.2.2}/src/d365fo_client.egg-info/SOURCES.txt +1 -0
  26. {d365fo_client-0.1.0 → d365fo_client-0.2.2}/LICENSE +0 -0
  27. {d365fo_client-0.1.0 → d365fo_client-0.2.2}/setup.cfg +0 -0
  28. {d365fo_client-0.1.0 → d365fo_client-0.2.2}/src/d365fo_client/auth.py +0 -0
  29. {d365fo_client-0.1.0 → d365fo_client-0.2.2}/src/d365fo_client/cli.py +0 -0
  30. {d365fo_client-0.1.0 → d365fo_client-0.2.2}/src/d365fo_client/crud.py +0 -0
  31. {d365fo_client-0.1.0 → d365fo_client-0.2.2}/src/d365fo_client/exceptions.py +0 -0
  32. {d365fo_client-0.1.0 → d365fo_client-0.2.2}/src/d365fo_client/mcp/__init__.py +0 -0
  33. {d365fo_client-0.1.0 → d365fo_client-0.2.2}/src/d365fo_client/mcp/prompts/__init__.py +0 -0
  34. {d365fo_client-0.1.0 → d365fo_client-0.2.2}/src/d365fo_client/mcp/prompts/action_execution.py +0 -0
  35. {d365fo_client-0.1.0 → d365fo_client-0.2.2}/src/d365fo_client/mcp/prompts/sequence_analysis.py +0 -0
  36. {d365fo_client-0.1.0 → d365fo_client-0.2.2}/src/d365fo_client/mcp/resources/__init__.py +0 -0
  37. {d365fo_client-0.1.0 → d365fo_client-0.2.2}/src/d365fo_client/mcp/resources/database_handler.py +0 -0
  38. {d365fo_client-0.1.0 → d365fo_client-0.2.2}/src/d365fo_client/mcp/resources/entity_handler.py +0 -0
  39. {d365fo_client-0.1.0 → d365fo_client-0.2.2}/src/d365fo_client/mcp/resources/environment_handler.py +0 -0
  40. {d365fo_client-0.1.0 → d365fo_client-0.2.2}/src/d365fo_client/mcp/resources/metadata_handler.py +0 -0
  41. {d365fo_client-0.1.0 → d365fo_client-0.2.2}/src/d365fo_client/mcp/resources/query_handler.py +0 -0
  42. {d365fo_client-0.1.0 → d365fo_client-0.2.2}/src/d365fo_client/mcp/tools/__init__.py +0 -0
  43. {d365fo_client-0.1.0 → d365fo_client-0.2.2}/src/d365fo_client/mcp/tools/crud_tools.py +0 -0
  44. {d365fo_client-0.1.0 → d365fo_client-0.2.2}/src/d365fo_client/mcp/tools/database_tools.py +0 -0
  45. {d365fo_client-0.1.0 → d365fo_client-0.2.2}/src/d365fo_client/mcp/tools/label_tools.py +0 -0
  46. {d365fo_client-0.1.0 → d365fo_client-0.2.2}/src/d365fo_client/mcp/tools/metadata_tools.py +0 -0
  47. {d365fo_client-0.1.0 → d365fo_client-0.2.2}/src/d365fo_client/metadata_v2/__init__.py +0 -0
  48. {d365fo_client-0.1.0 → d365fo_client-0.2.2}/src/d365fo_client/metadata_v2/search_engine_v2.py +0 -0
  49. {d365fo_client-0.1.0 → d365fo_client-0.2.2}/src/d365fo_client/metadata_v2/version_detector.py +0 -0
  50. {d365fo_client-0.1.0 → d365fo_client-0.2.2}/src/d365fo_client/profiles.py +0 -0
  51. {d365fo_client-0.1.0 → d365fo_client-0.2.2}/src/d365fo_client/query.py +0 -0
  52. {d365fo_client-0.1.0 → d365fo_client-0.2.2}/src/d365fo_client/session.py +0 -0
  53. {d365fo_client-0.1.0 → d365fo_client-0.2.2}/src/d365fo_client/utils.py +0 -0
  54. {d365fo_client-0.1.0 → d365fo_client-0.2.2}/src/d365fo_client.egg-info/dependency_links.txt +0 -0
  55. {d365fo_client-0.1.0 → d365fo_client-0.2.2}/src/d365fo_client.egg-info/entry_points.txt +0 -0
  56. {d365fo_client-0.1.0 → d365fo_client-0.2.2}/src/d365fo_client.egg-info/requires.txt +0 -0
  57. {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.1.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": {
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "d365fo-client"
3
- version = "0.1.0"
3
+ version = "0.2.2"
4
4
  description = "Microsoft Dynamics 365 Finance & Operations client"
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -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
- import warnings
186
-
187
- class _DeprecatedMetadataCache:
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
- # Legacy caching (deprecated placeholders - raise errors when used)
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
- # Only start sync if not already running
129
- if not self._background_sync_task or self._background_sync_task.done():
130
- self._background_sync_task = asyncio.create_task(
131
- self._background_sync_worker(global_version_id)
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 not use_cache_first or not self.config.enable_metadata_cache:
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
- await self._trigger_background_sync_if_needed()
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
- await self._trigger_background_sync_if_needed()
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 or self.config.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
- # TODO: v2 cache doesn't have action search yet - will be implemented in future phase
419
- # For now, always return empty to force fallback to API
420
- return []
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
- # Actions are not directly available through metadata API
424
- # They are part of entity definitions
425
- # Return empty list for backward compatibility
426
- return []
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 or self.config.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
- # TODO: v2 cache doesn't have action lookup yet - will be implemented in future phase
455
- # For now, always return None to force fallback to API
456
- return None
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
- # Actions are not directly available through metadata API
460
- # They are part of entity definitions
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
- return await self._get_from_cache_first(
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 or self.config.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 or self.config.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 or self.config.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 or self.config.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
- # Get the current event loop or create a new one
1045
+ # Always check if we're in an async context first
1008
1046
  try:
1009
- loop = asyncio.get_event_loop()
1010
- except RuntimeError:
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
- else:
1022
- # If we're not in an async context, we can get the statistics
1023
- stats = loop.run_until_complete(
1024
- self.metadata_cache.get_label_cache_statistics()
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
- "statistics": stats,
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" Resolved '{label_id}' -> '{label_texts[label_id]}'")
404
+ logger.debug(f"[OK] Resolved '{label_id}' -> '{label_texts[label_id]}'")
405
405
  else:
406
- logger.debug(f" No text found for label ID '{label_id}'")
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" Error in batch label resolution: {e}")
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("🔗 Testing connections...")
23
+ print("[INFO] Testing connections...")
24
24
  if await client.test_connection():
25
- print(" Connected to F&O OData successfully")
25
+ print("[OK] Connected to F&O OData successfully")
26
26
 
27
27
  if await client.test_metadata_connection():
28
- print(" Connected to F&O Metadata API successfully")
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🔍 Searching entities...")
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📊 Getting entity information...")
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📊 Data Entities API:")
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, Optional
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() -> Optional[Dict[str, Any]]:
44
+ def load_config() -> Dict[str, Any]:
38
45
  """Load configuration from environment and config files.
39
46
 
40
47
  Returns:
41
- Configuration dictionary or None for defaults
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
- # Logging level
60
- log_level = os.getenv("D365FO_LOG_LEVEL", "INFO")
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 if config else None
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 Enum
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(Enum):
40
+ class MetadataType(StrEnum):
41
41
  ENTITIES = "entities"
42
42
  ACTIONS = "actions"
43
43
  ENUMERATIONS = "enumerations"