d365fo-client 0.1.0__py3-none-any.whl → 0.2.2__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.
@@ -296,7 +296,6 @@ class DatabaseSchemaV2:
296
296
  language TEXT NOT NULL DEFAULT 'en-US',
297
297
  label_text TEXT,
298
298
  created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
299
- expires_at TIMESTAMP,
300
299
  hit_count INTEGER DEFAULT 0,
301
300
  last_accessed TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
302
301
  UNIQUE(global_version_id, label_id, language)
@@ -345,7 +344,6 @@ class DatabaseSchemaV2:
345
344
  "CREATE INDEX IF NOT EXISTS idx_enumerations_version ON enumerations(global_version_id, name)",
346
345
  # Labels indexes
347
346
  "CREATE INDEX IF NOT EXISTS idx_labels_version_lookup ON labels_cache(global_version_id, label_id, language)",
348
- "CREATE INDEX IF NOT EXISTS idx_labels_expires ON labels_cache(expires_at)",
349
347
  # Search performance indexes
350
348
  "CREATE INDEX IF NOT EXISTS idx_data_entities_search ON data_entities(global_version_id, data_service_enabled, entity_category)",
351
349
  "CREATE INDEX IF NOT EXISTS idx_public_entities_search ON public_entities(global_version_id, is_read_only)",
@@ -544,6 +542,99 @@ class MetadataDatabaseV2:
544
542
 
545
543
  return stats
546
544
 
545
+ async def get_statistics(self) -> Dict[str, Any]:
546
+ """Alias for get_database_statistics for backward compatibility
547
+
548
+ Returns:
549
+ Dictionary with database statistics
550
+ """
551
+ return await self.get_database_statistics()
552
+
553
+ async def get_environment_database_statistics(self, environment_id: int) -> Dict[str, Any]:
554
+ """Get database statistics scoped to a specific environment
555
+
556
+ Args:
557
+ environment_id: Environment ID to get statistics for
558
+
559
+ Returns:
560
+ Dictionary with environment-scoped database statistics
561
+ """
562
+ async with aiosqlite.connect(self.db_path) as db:
563
+ stats = {}
564
+
565
+ # Get active global versions for this environment
566
+ cursor = await db.execute(
567
+ """SELECT DISTINCT global_version_id
568
+ FROM environment_versions
569
+ WHERE environment_id = ? AND is_active = 1""",
570
+ (environment_id,)
571
+ )
572
+ active_versions = [row[0] for row in await cursor.fetchall()]
573
+
574
+ if not active_versions:
575
+ # No active versions, return zero counts
576
+ return {
577
+ "data_entities_count": 0,
578
+ "public_entities_count": 0,
579
+ "entity_properties_count": 0,
580
+ "navigation_properties_count": 0,
581
+ "entity_actions_count": 0,
582
+ "enumerations_count": 0,
583
+ "labels_cache_count": 0,
584
+ "environment_statistics": {
585
+ "total_environments": 1,
586
+ "linked_versions": 0,
587
+ },
588
+ "database_size_bytes": None,
589
+ "database_size_mb": None,
590
+ }
591
+
592
+ # Create placeholders for SQL IN clause
593
+ version_placeholders = ",".join("?" for _ in active_versions)
594
+
595
+ # Environment-scoped metadata counts
596
+ tables = [
597
+ ("data_entities", "entities"),
598
+ ("public_entities", "public_entities"),
599
+ ("entity_properties", "properties"),
600
+ ("navigation_properties", "navigation_properties"),
601
+ ("entity_actions", "actions"),
602
+ ("enumerations", "enumerations"),
603
+ ("labels_cache", "labels"),
604
+ ]
605
+
606
+ for table, key in tables:
607
+ cursor = await db.execute(
608
+ f"SELECT COUNT(*) FROM {table} WHERE global_version_id IN ({version_placeholders})",
609
+ active_versions
610
+ )
611
+ stats[f"{table}_count"] = (await cursor.fetchone())[0]
612
+
613
+ # Environment-specific statistics
614
+ cursor = await db.execute(
615
+ """SELECT
616
+ COUNT(DISTINCT ev.global_version_id) as linked_versions
617
+ FROM environment_versions ev
618
+ WHERE ev.environment_id = ? AND ev.is_active = 1""",
619
+ (environment_id,)
620
+ )
621
+ env_stats = await cursor.fetchone()
622
+ stats["environment_statistics"] = {
623
+ "total_environments": 1, # Current environment only
624
+ "linked_versions": env_stats[0] or 0,
625
+ }
626
+
627
+ # Database file size (shared across all environments)
628
+ try:
629
+ db_size = self.db_path.stat().st_size
630
+ stats["database_size_bytes"] = db_size
631
+ stats["database_size_mb"] = round(db_size / (1024 * 1024), 2)
632
+ except Exception:
633
+ stats["database_size_bytes"] = None
634
+ stats["database_size_mb"] = None
635
+
636
+ return stats
637
+
547
638
  async def vacuum_database(self) -> bool:
548
639
  """Vacuum database to reclaim space
549
640
 
@@ -571,3 +571,63 @@ class GlobalVersionManager:
571
571
  }
572
572
 
573
573
  return stats
574
+
575
+ async def get_environment_version_statistics(self, environment_id: int) -> Dict[str, Any]:
576
+ """Get version statistics scoped to a specific environment
577
+
578
+ Args:
579
+ environment_id: Environment ID to get statistics for
580
+
581
+ Returns:
582
+ Dictionary with environment-scoped version statistics
583
+ """
584
+ async with aiosqlite.connect(self.db_path) as db:
585
+ stats = {}
586
+
587
+ # Get versions for this specific environment
588
+ cursor = await db.execute(
589
+ """SELECT COUNT(DISTINCT global_version_id)
590
+ FROM environment_versions
591
+ WHERE environment_id = ? AND is_active = 1""",
592
+ (environment_id,)
593
+ )
594
+ stats["total_versions"] = (await cursor.fetchone())[0]
595
+
596
+ # This environment only
597
+ stats["total_environments"] = 1
598
+
599
+ # Reference statistics for this environment's versions
600
+ cursor = await db.execute(
601
+ """SELECT
602
+ SUM(gv.reference_count) as total_references,
603
+ AVG(gv.reference_count) as avg_references,
604
+ MAX(gv.reference_count) as max_references,
605
+ COUNT(*) as versions_with_refs
606
+ FROM global_versions gv
607
+ INNER JOIN environment_versions ev ON gv.id = ev.global_version_id
608
+ WHERE ev.environment_id = ? AND ev.is_active = 1 AND gv.reference_count > 0""",
609
+ (environment_id,)
610
+ )
611
+ ref_stats = await cursor.fetchone()
612
+ stats["reference_statistics"] = {
613
+ "total_references": ref_stats[0] or 0,
614
+ "avg_references": round(ref_stats[1] or 0, 2),
615
+ "max_references": ref_stats[2] or 0,
616
+ "versions_with_references": ref_stats[3] or 0,
617
+ }
618
+
619
+ # Version age statistics for this environment
620
+ cursor = await db.execute(
621
+ """SELECT
622
+ COUNT(*) as recent_versions
623
+ FROM global_versions gv
624
+ INNER JOIN environment_versions ev ON gv.id = ev.global_version_id
625
+ WHERE ev.environment_id = ? AND ev.is_active = 1
626
+ AND gv.last_used_at >= datetime('now', '-7 days')""",
627
+ (environment_id,)
628
+ )
629
+ stats["recent_activity"] = {
630
+ "versions_used_last_7_days": (await cursor.fetchone())[0]
631
+ }
632
+
633
+ return stats
@@ -0,0 +1,107 @@
1
+ """Label processing utilities for metadata v2.
2
+
3
+ This module provides utilities for processing D365 Finance & Operations labels
4
+ with fallback logic for when label_id doesn't start with '@'.
5
+
6
+ The main principle is:
7
+ - If label_text is already set, preserve it
8
+ - If label_id doesn't start with '@', it's already display text - use it as label_text
9
+ - If label_id starts with '@', it's a label reference that needs API resolution
10
+
11
+ This ensures that direct text labels (like "Customer Groups") are properly
12
+ converted to label_text, while label references (like "@SYS123") are preserved
13
+ for later resolution through the label API.
14
+
15
+ Functions:
16
+ process_label_fallback: Core label processing logic
17
+ process_data_entity_labels: Process DataEntityInfo labels
18
+ process_public_entity_labels: Process PublicEntityInfo and property labels
19
+ process_enumeration_labels: Process EnumerationInfo and member labels
20
+ apply_label_fallback: Alias for process_label_fallback (retrieval context)
21
+ """
22
+
23
+ from typing import Optional
24
+
25
+ from ..models import DataEntityInfo, EnumerationInfo, PublicEntityInfo
26
+
27
+
28
+ def process_label_fallback(label_id: Optional[str], label_text: Optional[str]) -> Optional[str]:
29
+ """Process label text with fallback to label_id when it doesn't start with '@'
30
+
31
+ This function implements the core logic for handling D365 F&O labels where:
32
+ - If label_text is already set, use it
33
+ - If label_id doesn't start with '@', it's already display text
34
+ - If label_id starts with '@', it needs resolution via label API
35
+
36
+ Args:
37
+ label_id: Label identifier (may be actual text or @-prefixed ID)
38
+ label_text: Existing label text (may be None)
39
+
40
+ Returns:
41
+ Processed label text with fallback applied
42
+ """
43
+ # If label_text is already set, use it
44
+ if label_text:
45
+ return label_text
46
+
47
+ # If label_id doesn't start with '@', it's already the text
48
+ if label_id and not label_id.startswith('@'):
49
+ return label_id
50
+
51
+ # Return None for @-prefixed labels without resolved text
52
+ return None
53
+
54
+
55
+ def process_data_entity_labels(entity: DataEntityInfo) -> DataEntityInfo:
56
+ """Process data entity labels with fallback logic
57
+
58
+ Args:
59
+ entity: Data entity info to process
60
+
61
+ Returns:
62
+ Processed data entity info with label fallback applied
63
+ """
64
+ entity.label_text = process_label_fallback(entity.label_id, entity.label_text)
65
+ return entity
66
+
67
+
68
+ def process_public_entity_labels(entity: PublicEntityInfo) -> PublicEntityInfo:
69
+ """Process public entity labels with fallback logic
70
+
71
+ Args:
72
+ entity: Public entity info to process
73
+
74
+ Returns:
75
+ Processed public entity info with label fallback applied
76
+ """
77
+ # Process entity label
78
+ entity.label_text = process_label_fallback(entity.label_id, entity.label_text)
79
+
80
+ # Process property labels
81
+ for prop in entity.properties:
82
+ prop.label_text = process_label_fallback(prop.label_id, prop.label_text)
83
+
84
+ return entity
85
+
86
+
87
+ def process_enumeration_labels(enumeration: EnumerationInfo) -> EnumerationInfo:
88
+ """Process enumeration labels with fallback logic
89
+
90
+ Args:
91
+ enumeration: Enumeration info to process
92
+
93
+ Returns:
94
+ Processed enumeration info with label fallback applied
95
+ """
96
+ # Process enumeration label
97
+ enumeration.label_text = process_label_fallback(enumeration.label_id, enumeration.label_text)
98
+
99
+ # Process member labels
100
+ for member in enumeration.members:
101
+ member.label_text = process_label_fallback(member.label_id, member.label_text)
102
+
103
+ return enumeration
104
+
105
+
106
+ # Alias for backward compatibility during data retrieval
107
+ apply_label_fallback = process_label_fallback
@@ -1,13 +1,11 @@
1
1
  """Smart sync manager with intelligent metadata synchronization strategies."""
2
2
 
3
- import asyncio
4
3
  import hashlib
5
4
  import json
6
5
  import logging
7
6
  import time
8
7
  from datetime import datetime, timezone
9
- from pathlib import Path
10
- from typing import TYPE_CHECKING, Any, Callable, Dict, List, Optional, Set
8
+ from typing import TYPE_CHECKING, Callable, Dict, List, Optional
11
9
 
12
10
  # Use TYPE_CHECKING to avoid circular import
13
11
  if TYPE_CHECKING:
@@ -19,7 +17,6 @@ from ..models import (
19
17
  LabelInfo,
20
18
  MetadataVersionInfo,
21
19
  PublicEntityInfo,
22
- QueryOptions,
23
20
  SyncProgress,
24
21
  SyncResult,
25
22
  SyncStrategy,
@@ -509,14 +506,20 @@ class SmartSyncManagerV2:
509
506
  counts = {}
510
507
 
511
508
  async with aiosqlite.connect(self.cache.db_path) as db:
512
- # Copy data entities
509
+ # Copy data entities with label processing
513
510
  await db.execute(
514
511
  """INSERT INTO data_entities
515
512
  (global_version_id, name, public_entity_name, public_collection_name,
516
513
  label_id, label_text, entity_category, data_service_enabled,
517
514
  data_management_enabled, is_read_only)
518
515
  SELECT ?, name, public_entity_name, public_collection_name,
519
- label_id, label_text, entity_category, data_service_enabled,
516
+ label_id,
517
+ CASE
518
+ WHEN label_text IS NOT NULL AND label_text != '' THEN label_text
519
+ WHEN label_id IS NOT NULL AND label_id != '' AND NOT label_id LIKE '@%' THEN label_id
520
+ ELSE label_text
521
+ END as processed_label_text,
522
+ entity_category, data_service_enabled,
520
523
  data_management_enabled, is_read_only
521
524
  FROM data_entities
522
525
  WHERE global_version_id = ?""",
@@ -524,19 +527,90 @@ class SmartSyncManagerV2:
524
527
  )
525
528
  counts["entities"] = db.total_changes
526
529
 
527
- # Copy enumerations
530
+ # Copy enumerations with label processing
528
531
  await db.execute(
529
532
  """INSERT INTO enumerations
530
533
  (global_version_id, name, label_id, label_text)
531
- SELECT ?, name, label_id, label_text
534
+ SELECT ?, name, label_id,
535
+ CASE
536
+ WHEN label_text IS NOT NULL AND label_text != '' THEN label_text
537
+ WHEN label_id IS NOT NULL AND label_id != '' AND NOT label_id LIKE '@%' THEN label_id
538
+ ELSE label_text
539
+ END as processed_label_text
532
540
  FROM enumerations
533
541
  WHERE global_version_id = ?""",
534
542
  (target_version_id, source_version_id),
535
543
  )
536
544
  counts["enumerations"] = db.total_changes
537
545
 
546
+ # Copy public entities with label processing
547
+ await db.execute(
548
+ """INSERT INTO public_entities
549
+ (global_version_id, name, entity_set_name, label_id, label_text,
550
+ is_read_only, configuration_enabled)
551
+ SELECT ?, name, entity_set_name, label_id,
552
+ CASE
553
+ WHEN label_text IS NOT NULL AND label_text != '' THEN label_text
554
+ WHEN label_id IS NOT NULL AND label_id != '' AND NOT label_id LIKE '@%' THEN label_id
555
+ ELSE label_text
556
+ END as processed_label_text,
557
+ is_read_only, configuration_enabled
558
+ FROM public_entities
559
+ WHERE global_version_id = ?""",
560
+ (target_version_id, source_version_id),
561
+ )
562
+
563
+ # Copy entity properties with label processing
564
+ # Note: We need to get the new entity IDs for the relationships
565
+ await db.execute(
566
+ """INSERT INTO entity_properties
567
+ (entity_id, global_version_id, name, type_name, data_type,
568
+ odata_xpp_type, label_id, label_text, is_key, is_mandatory,
569
+ configuration_enabled, allow_edit, allow_edit_on_create,
570
+ is_dimension, dimension_relation, is_dynamic_dimension,
571
+ dimension_legal_entity_property, dimension_type_property,
572
+ property_order)
573
+ SELECT pe_new.id as entity_id, ?, ep.name, ep.type_name, ep.data_type,
574
+ ep.odata_xpp_type, ep.label_id,
575
+ CASE
576
+ WHEN ep.label_text IS NOT NULL AND ep.label_text != '' THEN ep.label_text
577
+ WHEN ep.label_id IS NOT NULL AND ep.label_id != '' AND NOT ep.label_id LIKE '@%' THEN ep.label_id
578
+ ELSE ep.label_text
579
+ END as processed_label_text,
580
+ ep.is_key, ep.is_mandatory,
581
+ ep.configuration_enabled, ep.allow_edit, ep.allow_edit_on_create,
582
+ ep.is_dimension, ep.dimension_relation, ep.is_dynamic_dimension,
583
+ ep.dimension_legal_entity_property, ep.dimension_type_property,
584
+ ep.property_order
585
+ FROM entity_properties ep
586
+ JOIN public_entities pe_old ON ep.entity_id = pe_old.id
587
+ JOIN public_entities pe_new ON pe_new.name = pe_old.name AND pe_new.global_version_id = ?
588
+ WHERE ep.global_version_id = ?""",
589
+ (target_version_id, target_version_id, source_version_id),
590
+ )
591
+
592
+ # Copy enumeration members with label processing
593
+ await db.execute(
594
+ """INSERT INTO enumeration_members
595
+ (enumeration_id, global_version_id, name, value,
596
+ label_id, label_text, configuration_enabled, member_order)
597
+ SELECT e_new.id as enumeration_id, ?, em.name, em.value,
598
+ em.label_id,
599
+ CASE
600
+ WHEN em.label_text IS NOT NULL AND em.label_text != '' THEN em.label_text
601
+ WHEN em.label_id IS NOT NULL AND em.label_id != '' AND NOT em.label_id LIKE '@%' THEN em.label_id
602
+ ELSE em.label_text
603
+ END as processed_label_text,
604
+ em.configuration_enabled, em.member_order
605
+ FROM enumeration_members em
606
+ JOIN enumerations e_old ON em.enumeration_id = e_old.id
607
+ JOIN enumerations e_new ON e_new.name = e_old.name AND e_new.global_version_id = ?
608
+ WHERE em.global_version_id = ?""",
609
+ (target_version_id, target_version_id, source_version_id),
610
+ )
611
+
538
612
  # Copy other metadata tables as needed...
539
- # This is a simplified implementation
613
+ # This is a more comprehensive implementation with label processing
540
614
 
541
615
  await db.commit()
542
616
 
d365fo_client/models.py CHANGED
@@ -4,7 +4,7 @@ import hashlib
4
4
  import json
5
5
  from dataclasses import dataclass, field
6
6
  from datetime import datetime, timezone
7
- from enum import Enum
7
+ from enum import Enum, StrEnum
8
8
  from pathlib import Path
9
9
  from typing import TYPE_CHECKING, Any, Dict, List, Optional, Union
10
10
 
@@ -14,7 +14,16 @@ if TYPE_CHECKING:
14
14
  from typing import ForwardRef
15
15
 
16
16
 
17
- class EntityCategory(Enum):
17
+ def _ensure_str_for_json(field):
18
+ """Ensure field is JSON-serializable as string.
19
+
20
+ StrEnum fields automatically serialize as strings, but this handles
21
+ the edge case where a field might be None or already a string.
22
+ """
23
+ return field # StrEnum automatically converts to string, None stays None
24
+
25
+
26
+ class EntityCategory(StrEnum):
18
27
  """D365 F&O Entity Categories"""
19
28
 
20
29
  MASTER = "Master"
@@ -25,7 +34,7 @@ class EntityCategory(Enum):
25
34
  PARAMETERS = "Parameters"
26
35
 
27
36
 
28
- class ODataXppType(Enum):
37
+ class ODataXppType(StrEnum):
29
38
  """D365 F&O OData XPP Types"""
30
39
 
31
40
  CONTAINER = "Container"
@@ -42,7 +51,7 @@ class ODataXppType(Enum):
42
51
  VOID = "Void"
43
52
 
44
53
 
45
- class ODataBindingKind(Enum):
54
+ class ODataBindingKind(StrEnum):
46
55
  """D365 F&O Action Binding Types"""
47
56
 
48
57
  BOUND_TO_ENTITY_INSTANCE = "BoundToEntityInstance"
@@ -50,7 +59,7 @@ class ODataBindingKind(Enum):
50
59
  UNBOUND = "Unbound"
51
60
 
52
61
 
53
- class SyncStrategy(Enum):
62
+ class SyncStrategy(StrEnum):
54
63
  """Metadata synchronization strategies"""
55
64
 
56
65
  FULL = "full"
@@ -59,7 +68,7 @@ class SyncStrategy(Enum):
59
68
  SHARING_MODE = "sharing_mode"
60
69
 
61
70
 
62
- class Cardinality(Enum):
71
+ class Cardinality(StrEnum):
63
72
  """Navigation Property Cardinality"""
64
73
 
65
74
  SINGLE = "Single"
@@ -191,7 +200,7 @@ class PublicEntityActionInfo:
191
200
  def to_dict(self) -> Dict[str, Any]:
192
201
  return {
193
202
  "name": self.name,
194
- "binding_kind": self.binding_kind.value, # Convert enum to string value
203
+ "binding_kind": self.binding_kind, # StrEnum automatically serializes as string
195
204
  "parameters": [param.to_dict() for param in self.parameters],
196
205
  "return_type": self.return_type.to_dict() if self.return_type else None,
197
206
  "field_lookup": self.field_lookup,
@@ -221,7 +230,7 @@ class DataEntityInfo:
221
230
  "label_text": self.label_text,
222
231
  "data_service_enabled": self.data_service_enabled,
223
232
  "data_management_enabled": self.data_management_enabled,
224
- "entity_category": self.entity_category,
233
+ "entity_category": self.entity_category, # StrEnum automatically serializes as string
225
234
  "is_read_only": self.is_read_only,
226
235
  }
227
236
 
@@ -438,7 +447,7 @@ class NavigationPropertyInfo:
438
447
  "name": self.name,
439
448
  "related_entity": self.related_entity,
440
449
  "related_relation_name": self.related_relation_name,
441
- "cardinality": self.cardinality.value, # Convert enum to string value
450
+ "cardinality": self.cardinality, # StrEnum automatically serializes as string
442
451
  "constraints": [constraint.to_dict() for constraint in self.constraints],
443
452
  }
444
453
 
@@ -503,7 +512,7 @@ class ActionInfo:
503
512
  def to_dict(self) -> Dict[str, Any]:
504
513
  return {
505
514
  "name": self.name,
506
- "binding_kind": self.binding_kind,
515
+ "binding_kind": self.binding_kind, # StrEnum automatically serializes as string
507
516
  "entity_name": self.entity_name,
508
517
  "entity_set_name": self.entity_set_name,
509
518
  "parameters": [param.to_dict() for param in self.parameters],
d365fo_client/output.py CHANGED
@@ -163,19 +163,19 @@ class OutputFormatter:
163
163
 
164
164
  def format_success_message(message: str) -> str:
165
165
  """Format a success message with checkmark."""
166
- return f" {message}"
166
+ return f"[OK] {message}"
167
167
 
168
168
 
169
169
  def format_error_message(message: str) -> str:
170
170
  """Format an error message with X mark."""
171
- return f" {message}"
171
+ return f"[ERROR] {message}"
172
172
 
173
173
 
174
174
  def format_info_message(message: str) -> str:
175
175
  """Format an info message with info icon."""
176
- return f"ℹ️ {message}"
176
+ return f"[INFO] {message}"
177
177
 
178
178
 
179
179
  def format_warning_message(message: str) -> str:
180
180
  """Format a warning message with warning icon."""
181
- return f"⚠️ {message}"
181
+ return f"[WARNING] {message}"
@@ -195,6 +195,18 @@ class ProfileManager:
195
195
  logger.error(f"Error getting default profile: {e}")
196
196
  return None
197
197
 
198
+ def reload_config(self) -> None:
199
+ """Reload configuration from file.
200
+
201
+ This is useful when profiles have been modified and we need
202
+ to refresh the in-memory configuration data.
203
+ """
204
+ try:
205
+ self.config_manager.reload_config()
206
+ logger.debug("Reloaded profile configuration")
207
+ except Exception as e:
208
+ logger.error(f"Error reloading configuration: {e}")
209
+
198
210
  def set_default_profile(self, profile_name: str) -> bool:
199
211
  """Set the default profile.
200
212
 
@@ -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": {