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.
@@ -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:
@@ -8,6 +8,7 @@ from typing import List
8
8
  from mcp import Tool
9
9
  from mcp.types import TextContent
10
10
 
11
+ from ... import __version__
11
12
  from ..client_manager import D365FOClientManager
12
13
 
13
14
  logger = logging.getLogger(__name__)
@@ -95,6 +96,7 @@ class ConnectionTools:
95
96
  response = {
96
97
  "success": success,
97
98
  "profile": profile,
99
+ "clientVersion": __version__,
98
100
  "endpoints": {
99
101
  "data": success,
100
102
  "metadata": success, # Simplification for now
@@ -113,6 +115,7 @@ class ConnectionTools:
113
115
  error_response = {
114
116
  "success": False,
115
117
  "profile": arguments.get("profile", "default"),
118
+ "clientVersion": __version__,
116
119
  "endpoints": {"data": False, "metadata": False},
117
120
  "responseTime": 0.0,
118
121
  "error": str(e),
@@ -124,6 +127,7 @@ class ConnectionTools:
124
127
  error_response = {
125
128
  "success": False,
126
129
  "profile": arguments.get("profile", "default"),
130
+ "clientVersion": __version__,
127
131
  "endpoints": {"data": False, "metadata": False},
128
132
  "responseTime": 0.0,
129
133
  "error": str(e),
@@ -146,6 +150,7 @@ class ConnectionTools:
146
150
  # Format response according to specification with enhanced metadata info
147
151
  response = {
148
152
  "baseUrl": env_info["base_url"],
153
+ "clientVersion": __version__,
149
154
  "versions": env_info["versions"],
150
155
  "connectivity": env_info["connectivity"],
151
156
  "metadataInfo": env_info["metadata_info"],
@@ -160,6 +165,7 @@ class ConnectionTools:
160
165
  )
161
166
  error_response = {
162
167
  "error": str(e),
168
+ "clientVersion": __version__,
163
169
  "tool": "d365fo_get_environment_info",
164
170
  "arguments": arguments,
165
171
  "suggestion": "Please create a profile or set a default profile using the profile management tools.",
@@ -169,6 +175,7 @@ class ConnectionTools:
169
175
  logger.error(f"Get environment info failed: {e}")
170
176
  error_response = {
171
177
  "error": str(e),
178
+ "clientVersion": __version__,
172
179
  "tool": "d365fo_get_environment_info",
173
180
  "arguments": arguments,
174
181
  }
@@ -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,
@@ -8,19 +8,25 @@ from d365fo_client.crud import CrudOperations
8
8
 
9
9
  from .labels import LabelOperations
10
10
  from .models import (
11
+ ActionInfo,
11
12
  ActionParameterInfo,
12
13
  ActionParameterTypeInfo,
13
14
  ActionReturnTypeInfo,
15
+ Cardinality,
14
16
  DataEntityInfo,
17
+ EntityCategory,
15
18
  EnumerationInfo,
16
19
  EnumerationMemberInfo,
20
+ FixedConstraintInfo,
17
21
  NavigationPropertyInfo,
22
+ ODataBindingKind,
18
23
  PropertyGroupInfo,
19
24
  PublicEntityActionInfo,
20
25
  PublicEntityInfo,
21
26
  PublicEntityPropertyInfo,
22
27
  QueryOptions,
23
28
  ReferentialConstraintInfo,
29
+ RelatedFixedConstraintInfo,
24
30
  )
25
31
  from .query import QueryBuilder
26
32
  from .session import SessionManager
@@ -97,26 +103,57 @@ class MetadataAPIOperations:
97
103
 
98
104
  # Process navigation properties
99
105
  for nav_data in item.get("NavigationProperties", []):
106
+ # Convert cardinality string to enum
107
+ cardinality_str = nav_data.get("Cardinality", "Single")
108
+ try:
109
+ if cardinality_str == "Single":
110
+ cardinality = Cardinality.SINGLE
111
+ elif cardinality_str == "Multiple":
112
+ cardinality = Cardinality.MULTIPLE
113
+ else:
114
+ cardinality = Cardinality.SINGLE # Default
115
+ except Exception:
116
+ cardinality = Cardinality.SINGLE
117
+
100
118
  nav_prop = NavigationPropertyInfo(
101
119
  name=nav_data.get("Name", ""),
102
120
  related_entity=nav_data.get("RelatedEntity", ""),
103
121
  related_relation_name=nav_data.get("RelatedRelationName"),
104
- cardinality=nav_data.get("Cardinality", "Single"),
122
+ cardinality=cardinality,
105
123
  )
106
124
 
107
125
  # Process constraints
108
126
  for constraint_data in nav_data.get("Constraints", []):
109
- # Check for ReferentialConstraint type (most common)
110
127
  odata_type = constraint_data.get("@odata.type", "")
128
+
111
129
  if "ReferentialConstraint" in odata_type:
130
+ # Referential constraint (foreign key relationship)
112
131
  constraint = ReferentialConstraintInfo(
113
- constraint_type="Referential",
114
132
  property=constraint_data.get("Property", ""),
115
133
  referenced_property=constraint_data.get(
116
134
  "ReferencedProperty", ""
117
135
  ),
118
136
  )
119
137
  nav_prop.constraints.append(constraint)
138
+
139
+ elif "RelatedFixedConstraint" in odata_type:
140
+ # Related fixed constraint (check this before FixedConstraint)
141
+ constraint = RelatedFixedConstraintInfo(
142
+ related_property=constraint_data.get("RelatedProperty", ""),
143
+ value=constraint_data.get("Value"),
144
+ value_str=constraint_data.get("ValueStr", constraint_data.get("StringValue")),
145
+ )
146
+ nav_prop.constraints.append(constraint)
147
+
148
+ elif "FixedConstraint" in odata_type:
149
+ # Fixed value constraint
150
+ constraint = FixedConstraintInfo(
151
+ property=constraint_data.get("Property", ""),
152
+ value=constraint_data.get("Value"),
153
+ value_str=constraint_data.get("ValueStr", constraint_data.get("StringValue")),
154
+ )
155
+ nav_prop.constraints.append(constraint)
156
+ nav_prop.constraints.append(constraint)
120
157
 
121
158
  entity.navigation_properties.append(nav_prop)
122
159
 
@@ -130,9 +167,25 @@ class MetadataAPIOperations:
130
167
 
131
168
  # Process actions
132
169
  for action_data in item.get("Actions", []):
170
+ # Convert binding kind string to enum
171
+ binding_kind_str = action_data.get("BindingKind", "BoundToEntitySet")
172
+ try:
173
+ # Try to map the string to the enum
174
+ if binding_kind_str == "BoundToEntityInstance":
175
+ binding_kind = ODataBindingKind.BOUND_TO_ENTITY_INSTANCE
176
+ elif binding_kind_str == "BoundToEntitySet":
177
+ binding_kind = ODataBindingKind.BOUND_TO_ENTITY_SET
178
+ elif binding_kind_str == "Unbound":
179
+ binding_kind = ODataBindingKind.UNBOUND
180
+ else:
181
+ # Default to BoundToEntitySet if unknown
182
+ binding_kind = ODataBindingKind.BOUND_TO_ENTITY_SET
183
+ except Exception:
184
+ binding_kind = ODataBindingKind.BOUND_TO_ENTITY_SET
185
+
133
186
  action = PublicEntityActionInfo(
134
187
  name=action_data.get("Name", ""),
135
- binding_kind=action_data.get("BindingKind", ""),
188
+ binding_kind=binding_kind,
136
189
  field_lookup=action_data.get("FieldLookup"),
137
190
  )
138
191
 
@@ -266,6 +319,28 @@ class MetadataAPIOperations:
266
319
 
267
320
  entities = []
268
321
  for item in data.get("value", []):
322
+ # Convert entity category string to enum
323
+ entity_category_str = item.get("EntityCategory")
324
+ entity_category = None
325
+ if entity_category_str:
326
+ try:
327
+ # Try to map the string to the enum
328
+ if entity_category_str == "Master":
329
+ entity_category = EntityCategory.MASTER
330
+ elif entity_category_str == "Transaction":
331
+ entity_category = EntityCategory.TRANSACTION
332
+ elif entity_category_str == "Configuration":
333
+ entity_category = EntityCategory.CONFIGURATION
334
+ elif entity_category_str == "Reference":
335
+ entity_category = EntityCategory.REFERENCE
336
+ elif entity_category_str == "Document":
337
+ entity_category = EntityCategory.DOCUMENT
338
+ elif entity_category_str == "Parameters":
339
+ entity_category = EntityCategory.PARAMETERS
340
+ # If no match, leave as None
341
+ except Exception:
342
+ entity_category = None
343
+
269
344
  entity = DataEntityInfo(
270
345
  name=item.get("Name", ""),
271
346
  public_entity_name=item.get("PublicEntityName", ""),
@@ -273,7 +348,7 @@ class MetadataAPIOperations:
273
348
  label_id=item.get("LabelId"),
274
349
  data_service_enabled=item.get("DataServiceEnabled", False),
275
350
  data_management_enabled=item.get("DataManagementEnabled", False),
276
- entity_category=item.get("EntityCategory"),
351
+ entity_category=entity_category,
277
352
  is_read_only=item.get("IsReadOnly", False),
278
353
  )
279
354
  entities.append(entity)
@@ -305,6 +380,29 @@ class MetadataAPIOperations:
305
380
  async with session.get(url) as response:
306
381
  if response.status == 200:
307
382
  item = await response.json()
383
+
384
+ # Convert entity category string to enum
385
+ entity_category_str = item.get("EntityCategory")
386
+ entity_category = None
387
+ if entity_category_str:
388
+ try:
389
+ # Try to map the string to the enum
390
+ if entity_category_str == "Master":
391
+ entity_category = EntityCategory.MASTER
392
+ elif entity_category_str == "Transaction":
393
+ entity_category = EntityCategory.TRANSACTION
394
+ elif entity_category_str == "Configuration":
395
+ entity_category = EntityCategory.CONFIGURATION
396
+ elif entity_category_str == "Reference":
397
+ entity_category = EntityCategory.REFERENCE
398
+ elif entity_category_str == "Document":
399
+ entity_category = EntityCategory.DOCUMENT
400
+ elif entity_category_str == "Parameters":
401
+ entity_category = EntityCategory.PARAMETERS
402
+ # If no match, leave as None
403
+ except Exception:
404
+ entity_category = None
405
+
308
406
  entity = DataEntityInfo(
309
407
  name=item.get("Name", ""),
310
408
  public_entity_name=item.get("PublicEntityName", ""),
@@ -314,7 +412,7 @@ class MetadataAPIOperations:
314
412
  data_management_enabled=item.get(
315
413
  "DataManagementEnabled", False
316
414
  ),
317
- entity_category=item.get("EntityCategory"),
415
+ entity_category=entity_category,
318
416
  is_read_only=item.get("IsReadOnly", False),
319
417
  )
320
418
 
@@ -791,3 +889,122 @@ class MetadataAPIOperations:
791
889
  except Exception as e:
792
890
  logger.error(f"Failed to get installed modules: {e}")
793
891
  raise
892
+
893
+ # Action Operations
894
+
895
+ async def search_actions(
896
+ self,
897
+ pattern: str = "",
898
+ entity_name: Optional[str] = None,
899
+ binding_kind: Optional[str] = None,
900
+ ) -> List["ActionInfo"]:
901
+ """Search actions across all public entities
902
+
903
+ Args:
904
+ pattern: Search pattern for action name (regex supported)
905
+ entity_name: Filter actions that are bound to a specific entity
906
+ binding_kind: Filter by binding type (Unbound, BoundToEntitySet, BoundToEntityInstance)
907
+
908
+ Returns:
909
+ List of ActionInfo objects with full details
910
+ """
911
+ from .models import ActionInfo
912
+
913
+ actions = []
914
+
915
+ try:
916
+ # Compile regex pattern if provided
917
+ regex_pattern = None
918
+ if pattern:
919
+ try:
920
+ regex_pattern = re.compile(pattern, re.IGNORECASE)
921
+ except re.error:
922
+ # If regex is invalid, treat as literal string
923
+ pattern_lower = pattern.lower()
924
+ regex_pattern = lambda x: pattern_lower in x.lower()
925
+ else:
926
+ regex_pattern = regex_pattern.search
927
+
928
+ # Get all public entities with full details
929
+ entities = await self.get_all_public_entities_with_details(resolve_labels=False)
930
+
931
+ for entity in entities:
932
+ # Filter by entity name if specified
933
+ if entity_name and entity.name != entity_name:
934
+ continue
935
+
936
+ # Process all actions in this entity
937
+ for action in entity.actions:
938
+ # Filter by action name pattern
939
+ if regex_pattern and not regex_pattern(action.name):
940
+ continue
941
+
942
+ # Filter by binding kind
943
+ if binding_kind and action.binding_kind != binding_kind:
944
+ continue
945
+
946
+ # Create ActionInfo with entity context
947
+ action_info = ActionInfo(
948
+ name=action.name,
949
+ binding_kind=action.binding_kind,
950
+ entity_name=entity.name, # public entity name
951
+ entity_set_name=entity.entity_set_name, # entity set name for OData URLs
952
+ parameters=action.parameters,
953
+ return_type=action.return_type,
954
+ field_lookup=action.field_lookup,
955
+ )
956
+ actions.append(action_info)
957
+
958
+ except Exception as e:
959
+ logger.error(f"Failed to search actions: {e}")
960
+ # Return empty list on error rather than raising
961
+ return []
962
+
963
+ return actions
964
+
965
+ async def get_action_info(
966
+ self,
967
+ action_name: str,
968
+ entity_name: Optional[str] = None,
969
+ ) -> Optional["ActionInfo"]:
970
+ """Get detailed information about a specific action
971
+
972
+ Args:
973
+ action_name: Name of the action
974
+ entity_name: Optional entity name for bound actions
975
+
976
+ Returns:
977
+ ActionInfo object or None if not found
978
+ """
979
+ from .models import ActionInfo
980
+
981
+ try:
982
+ if entity_name:
983
+ # If entity name is provided, get that specific entity
984
+ entity = await self.get_public_entity_info(entity_name, resolve_labels=False)
985
+ if not entity:
986
+ return None
987
+
988
+ # Find the action in this entity
989
+ for action in entity.actions:
990
+ if action.name == action_name:
991
+ return ActionInfo(
992
+ name=action.name,
993
+ binding_kind=action.binding_kind,
994
+ entity_name=entity.name,
995
+ entity_set_name=entity.entity_set_name,
996
+ parameters=action.parameters,
997
+ return_type=action.return_type,
998
+ field_lookup=action.field_lookup,
999
+ )
1000
+ else:
1001
+ # Search across all entities for the action
1002
+ actions = await self.search_actions(pattern=f"^{re.escape(action_name)}$")
1003
+ if actions:
1004
+ # Return the first match (actions should be unique across entities)
1005
+ return actions[0]
1006
+
1007
+ except Exception as e:
1008
+ logger.error(f"Failed to get action info for '{action_name}': {e}")
1009
+
1010
+ return None