d365fo-client 0.1.0__py3-none-any.whl → 0.2.1__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/client.py CHANGED
@@ -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 not use_cache_first or not self.config.enable_metadata_cache:
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 or self.config.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
- # 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 []
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
- # Actions are not directly available through metadata API
424
- # They are part of entity definitions
425
- # Return empty list for backward compatibility
426
- return []
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 or self.config.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
- # 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
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
- # Actions are not directly available through metadata API
460
- # They are part of entity definitions
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
- return await self._get_from_cache_first(
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 or self.config.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 or self.config.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 or self.config.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 or self.config.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)
d365fo_client/config.py CHANGED
@@ -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
 
d365fo_client/labels.py CHANGED
@@ -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
 
d365fo_client/main.py CHANGED
@@ -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
 
d365fo_client/mcp/main.py CHANGED
@@ -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
 
@@ -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.client_manager = D365FOClientManager(self.config)
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 health checks
327
- await self._startup_health_checks()
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="1.0.0",
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,