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.
- d365fo_client/__init__.py +4 -48
- d365fo_client/client.py +102 -56
- d365fo_client/config.py +8 -0
- d365fo_client/labels.py +3 -3
- d365fo_client/main.py +6 -6
- d365fo_client/mcp/client_manager.py +30 -2
- d365fo_client/mcp/main.py +20 -7
- d365fo_client/mcp/models.py +2 -2
- d365fo_client/mcp/server.py +90 -5
- d365fo_client/mcp/tools/connection_tools.py +7 -0
- d365fo_client/mcp/tools/profile_tools.py +15 -0
- d365fo_client/metadata_api.py +223 -6
- d365fo_client/metadata_v2/cache_v2.py +267 -97
- d365fo_client/metadata_v2/database_v2.py +93 -2
- d365fo_client/metadata_v2/global_version_manager.py +60 -0
- d365fo_client/metadata_v2/label_utils.py +107 -0
- d365fo_client/metadata_v2/sync_manager_v2.py +83 -9
- d365fo_client/models.py +19 -10
- d365fo_client/output.py +4 -4
- d365fo_client/profile_manager.py +12 -0
- {d365fo_client-0.1.0.dist-info → d365fo_client-0.2.2.dist-info}/METADATA +5 -1
- {d365fo_client-0.1.0.dist-info → d365fo_client-0.2.2.dist-info}/RECORD +26 -25
- {d365fo_client-0.1.0.dist-info → d365fo_client-0.2.2.dist-info}/WHEEL +0 -0
- {d365fo_client-0.1.0.dist-info → d365fo_client-0.2.2.dist-info}/entry_points.txt +0 -0
- {d365fo_client-0.1.0.dist-info → d365fo_client-0.2.2.dist-info}/licenses/LICENSE +0 -0
- {d365fo_client-0.1.0.dist-info → d365fo_client-0.2.2.dist-info}/top_level.txt +0 -0
d365fo_client/mcp/server.py
CHANGED
@@ -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.
|
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
|
327
|
-
await self.
|
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=
|
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,
|
d365fo_client/metadata_api.py
CHANGED
@@ -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=
|
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=
|
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=
|
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=
|
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
|