d365fo-client 0.2.3__py3-none-any.whl → 0.3.0__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 +7 -1
- d365fo_client/auth.py +9 -21
- d365fo_client/cli.py +25 -13
- d365fo_client/client.py +8 -4
- d365fo_client/config.py +52 -30
- d365fo_client/credential_sources.py +5 -0
- d365fo_client/main.py +1 -1
- d365fo_client/mcp/__init__.py +3 -1
- d365fo_client/mcp/auth_server/__init__.py +5 -0
- d365fo_client/mcp/auth_server/auth/__init__.py +30 -0
- d365fo_client/mcp/auth_server/auth/auth.py +372 -0
- d365fo_client/mcp/auth_server/auth/oauth_proxy.py +989 -0
- d365fo_client/mcp/auth_server/auth/providers/__init__.py +0 -0
- d365fo_client/mcp/auth_server/auth/providers/azure.py +325 -0
- d365fo_client/mcp/auth_server/auth/providers/bearer.py +25 -0
- d365fo_client/mcp/auth_server/auth/providers/jwt.py +547 -0
- d365fo_client/mcp/auth_server/auth/redirect_validation.py +65 -0
- d365fo_client/mcp/auth_server/dependencies.py +136 -0
- d365fo_client/mcp/client_manager.py +16 -67
- d365fo_client/mcp/fastmcp_main.py +358 -0
- d365fo_client/mcp/fastmcp_server.py +598 -0
- d365fo_client/mcp/fastmcp_utils.py +431 -0
- d365fo_client/mcp/main.py +40 -13
- d365fo_client/mcp/mixins/__init__.py +24 -0
- d365fo_client/mcp/mixins/base_tools_mixin.py +55 -0
- d365fo_client/mcp/mixins/connection_tools_mixin.py +50 -0
- d365fo_client/mcp/mixins/crud_tools_mixin.py +311 -0
- d365fo_client/mcp/mixins/database_tools_mixin.py +685 -0
- d365fo_client/mcp/mixins/label_tools_mixin.py +87 -0
- d365fo_client/mcp/mixins/metadata_tools_mixin.py +565 -0
- d365fo_client/mcp/mixins/performance_tools_mixin.py +109 -0
- d365fo_client/mcp/mixins/profile_tools_mixin.py +713 -0
- d365fo_client/mcp/mixins/sync_tools_mixin.py +321 -0
- d365fo_client/mcp/prompts/action_execution.py +1 -1
- d365fo_client/mcp/prompts/sequence_analysis.py +1 -1
- d365fo_client/mcp/tools/crud_tools.py +3 -3
- d365fo_client/mcp/tools/sync_tools.py +1 -1
- d365fo_client/mcp/utilities/__init__.py +1 -0
- d365fo_client/mcp/utilities/auth.py +34 -0
- d365fo_client/mcp/utilities/logging.py +58 -0
- d365fo_client/mcp/utilities/types.py +426 -0
- d365fo_client/metadata_v2/sync_manager_v2.py +2 -0
- d365fo_client/metadata_v2/sync_session_manager.py +7 -7
- d365fo_client/models.py +139 -139
- d365fo_client/output.py +2 -2
- d365fo_client/profile_manager.py +62 -27
- d365fo_client/profiles.py +118 -113
- d365fo_client/settings.py +355 -0
- d365fo_client/sync_models.py +85 -2
- d365fo_client/utils.py +2 -1
- {d365fo_client-0.2.3.dist-info → d365fo_client-0.3.0.dist-info}/METADATA +1261 -810
- d365fo_client-0.3.0.dist-info/RECORD +84 -0
- d365fo_client-0.3.0.dist-info/entry_points.txt +4 -0
- d365fo_client-0.2.3.dist-info/RECORD +0 -56
- d365fo_client-0.2.3.dist-info/entry_points.txt +0 -3
- {d365fo_client-0.2.3.dist-info → d365fo_client-0.3.0.dist-info}/WHEEL +0 -0
- {d365fo_client-0.2.3.dist-info → d365fo_client-0.3.0.dist-info}/licenses/LICENSE +0 -0
- {d365fo_client-0.2.3.dist-info → d365fo_client-0.3.0.dist-info}/top_level.txt +0 -0
d365fo_client/profiles.py
CHANGED
@@ -4,98 +4,91 @@ import logging
|
|
4
4
|
from dataclasses import dataclass
|
5
5
|
from typing import TYPE_CHECKING, Any, Dict, List, Optional
|
6
6
|
|
7
|
+
from .models import FOClientConfig
|
8
|
+
|
7
9
|
if TYPE_CHECKING:
|
8
|
-
from .models import FOClientConfig
|
9
10
|
from .credential_sources import CredentialSource
|
10
11
|
|
11
12
|
logger = logging.getLogger(__name__)
|
12
13
|
|
13
14
|
|
14
15
|
@dataclass
|
15
|
-
class Profile:
|
16
|
-
"""Unified profile for CLI and MCP operations.
|
17
|
-
|
18
|
-
|
19
|
-
name
|
16
|
+
class Profile(FOClientConfig):
|
17
|
+
"""Unified profile for CLI and MCP operations.
|
18
|
+
|
19
|
+
Inherits from FOClientConfig and adds profile-specific functionality
|
20
|
+
like name, description, and CLI output formatting.
|
21
|
+
"""
|
22
|
+
|
23
|
+
# Profile-specific identification fields
|
24
|
+
name: str = ""
|
20
25
|
description: Optional[str] = None
|
21
26
|
|
22
|
-
# Connection settings
|
23
|
-
base_url: str = ""
|
24
|
-
auth_mode: str = "default"
|
25
|
-
client_id: Optional[str] = None
|
26
|
-
client_secret: Optional[str] = None
|
27
|
-
tenant_id: Optional[str] = None
|
28
|
-
use_default_credentials: Optional[bool] = None # None means derive from auth_mode
|
29
|
-
verify_ssl: bool = True
|
30
|
-
timeout: int = 60
|
31
|
-
credential_source: Optional["CredentialSource"] = None
|
32
|
-
|
33
|
-
# Cache settings
|
34
|
-
use_label_cache: bool = True
|
35
|
-
label_cache_expiry_minutes: int = 60
|
36
|
-
use_cache_first: bool = True
|
37
|
-
cache_dir: Optional[str] = None
|
38
|
-
|
39
|
-
# Localization
|
40
|
-
language: str = "en-US"
|
41
|
-
|
42
27
|
# CLI-specific settings (with defaults for MCP)
|
43
28
|
output_format: str = "table"
|
44
29
|
|
45
|
-
def
|
46
|
-
"""
|
47
|
-
|
48
|
-
|
49
|
-
|
50
|
-
|
51
|
-
|
52
|
-
|
53
|
-
|
54
|
-
|
55
|
-
|
56
|
-
|
57
|
-
|
58
|
-
|
59
|
-
|
60
|
-
|
61
|
-
|
62
|
-
|
63
|
-
|
64
|
-
|
65
|
-
|
66
|
-
|
67
|
-
|
68
|
-
|
69
|
-
|
30
|
+
def __post_init__(self):
|
31
|
+
"""Override parent post_init to handle profile-specific validation."""
|
32
|
+
# Call parent validation first
|
33
|
+
super().__post_init__()
|
34
|
+
|
35
|
+
# Additional profile-specific validation
|
36
|
+
if not self.name:
|
37
|
+
raise ValueError("Profile name is required")
|
38
|
+
|
39
|
+
def to_client_config(self) -> FOClientConfig:
|
40
|
+
"""Convert profile to FOClientConfig.
|
41
|
+
|
42
|
+
Since Profile now inherits from FOClientConfig, we can return a copy
|
43
|
+
of self with only the FOClientConfig fields.
|
44
|
+
"""
|
45
|
+
from dataclasses import fields
|
46
|
+
|
47
|
+
# Get all FOClientConfig field names
|
48
|
+
fo_client_fields = {f.name for f in fields(FOClientConfig)}
|
49
|
+
|
50
|
+
# Create a dict with only FOClientConfig fields from this instance
|
51
|
+
# Use getattr to preserve object types (especially credential_source)
|
52
|
+
client_data = {}
|
53
|
+
for field_name in fo_client_fields:
|
54
|
+
if hasattr(self, field_name):
|
55
|
+
client_data[field_name] = getattr(self, field_name)
|
56
|
+
|
57
|
+
return FOClientConfig(**client_data)
|
70
58
|
|
71
59
|
def validate(self) -> List[str]:
|
72
|
-
"""Validate profile configuration.
|
60
|
+
"""Validate profile configuration.
|
61
|
+
|
62
|
+
Since Profile inherits from FOClientConfig, we leverage the parent's
|
63
|
+
validation and add profile-specific checks.
|
64
|
+
"""
|
73
65
|
errors = []
|
74
66
|
|
75
|
-
|
76
|
-
|
67
|
+
# Profile-specific validation
|
68
|
+
if not self.name:
|
69
|
+
errors.append("Profile name is required")
|
77
70
|
|
78
|
-
|
79
|
-
|
80
|
-
|
81
|
-
|
82
|
-
|
83
|
-
"Client secret is required for client_credentials auth mode"
|
84
|
-
)
|
85
|
-
if not self.tenant_id:
|
86
|
-
errors.append("Tenant ID is required for client_credentials auth mode")
|
87
|
-
|
88
|
-
if self.timeout <= 0:
|
89
|
-
errors.append("Timeout must be greater than 0")
|
71
|
+
# Leverage parent's validation by attempting to create the config
|
72
|
+
try:
|
73
|
+
self._validate_config()
|
74
|
+
except ValueError as e:
|
75
|
+
errors.append(str(e))
|
90
76
|
|
91
|
-
|
92
|
-
|
77
|
+
# Validate credential_source if provided
|
78
|
+
if self.credential_source is not None:
|
79
|
+
# Basic validation - credential source should have a valid source_type
|
80
|
+
if not hasattr(self.credential_source, 'source_type') or not self.credential_source.source_type:
|
81
|
+
errors.append("Credential source must have a valid source_type")
|
93
82
|
|
94
83
|
return errors
|
95
84
|
|
96
85
|
@classmethod
|
97
|
-
def
|
98
|
-
"""Create Profile from dictionary data with migration support.
|
86
|
+
def create_from_dict(cls, name: str, data: Dict[str, Any]) -> "Profile":
|
87
|
+
"""Create Profile from dictionary data with migration support.
|
88
|
+
|
89
|
+
This method maintains the same interface as the original from_dict but
|
90
|
+
works with the inheritance structure.
|
91
|
+
"""
|
99
92
|
|
100
93
|
# Handle parameter migration from legacy formats
|
101
94
|
migrated_data = cls._migrate_legacy_parameters(data.copy())
|
@@ -103,50 +96,48 @@ class Profile:
|
|
103
96
|
# Ensure name is set
|
104
97
|
migrated_data["name"] = name
|
105
98
|
|
106
|
-
# Add defaults for missing parameters
|
107
|
-
|
99
|
+
# Add defaults for missing parameters, focusing on Profile-specific ones
|
100
|
+
profile_defaults = {
|
101
|
+
"name": name,
|
108
102
|
"description": None,
|
109
|
-
"base_url": "",
|
110
|
-
"auth_mode": "default",
|
111
|
-
"client_id": None,
|
112
|
-
"client_secret": None,
|
113
|
-
"tenant_id": None,
|
114
|
-
"use_default_credentials": None,
|
115
|
-
"verify_ssl": True,
|
116
|
-
"timeout": 60,
|
117
|
-
"use_label_cache": True,
|
118
|
-
"label_cache_expiry_minutes": 60,
|
119
|
-
"use_cache_first": True,
|
120
|
-
"cache_dir": None,
|
121
|
-
"language": "en-US",
|
122
103
|
"output_format": "table",
|
123
|
-
"credential_source": None,
|
124
104
|
}
|
125
105
|
|
126
|
-
for key, default_value in
|
106
|
+
for key, default_value in profile_defaults.items():
|
127
107
|
if key not in migrated_data:
|
128
108
|
migrated_data[key] = default_value
|
129
109
|
|
130
|
-
#
|
131
|
-
valid_params = {
|
132
|
-
k: v for k, v in migrated_data.items() if k in cls.__dataclass_fields__
|
133
|
-
}
|
134
|
-
|
135
|
-
# Handle credential_source deserialization
|
136
|
-
if "credential_source" in valid_params and valid_params["credential_source"] is not None:
|
137
|
-
from .credential_sources import CredentialSource
|
138
|
-
credential_source_data = valid_params["credential_source"]
|
139
|
-
try:
|
140
|
-
valid_params["credential_source"] = CredentialSource.from_dict(credential_source_data)
|
141
|
-
except Exception as e:
|
142
|
-
logger.error(f"Error deserializing credential_source: {e}")
|
143
|
-
valid_params["credential_source"] = None
|
144
|
-
|
110
|
+
# Use parent's from_dict for FOClientConfig fields, then add Profile fields
|
145
111
|
try:
|
112
|
+
# Create FOClientConfig from the data first
|
113
|
+
fo_config = super().from_dict(migrated_data)
|
114
|
+
|
115
|
+
# Convert back to dict and add Profile-specific fields
|
116
|
+
fo_data = fo_config.to_dict()
|
117
|
+
fo_data.update({
|
118
|
+
"name": migrated_data["name"],
|
119
|
+
"description": migrated_data.get("description"),
|
120
|
+
"output_format": migrated_data.get("output_format", "table"),
|
121
|
+
})
|
122
|
+
|
123
|
+
# Filter out any unknown parameters
|
124
|
+
valid_params = {
|
125
|
+
k: v for k, v in fo_data.items() if k in cls.__dataclass_fields__
|
126
|
+
}
|
127
|
+
|
128
|
+
# Handle credential_source deserialization if it's still a dict
|
129
|
+
if "credential_source" in valid_params and isinstance(valid_params["credential_source"], dict):
|
130
|
+
from .credential_sources import CredentialSource
|
131
|
+
try:
|
132
|
+
valid_params["credential_source"] = CredentialSource.from_dict(valid_params["credential_source"])
|
133
|
+
except Exception as e:
|
134
|
+
logger.error(f"Error deserializing credential_source: {e}")
|
135
|
+
valid_params["credential_source"] = None
|
136
|
+
|
146
137
|
return cls(**valid_params)
|
147
138
|
except Exception as e:
|
148
139
|
logger.error(f"Error creating profile {name}: {e}")
|
149
|
-
logger.error(f"Data: {
|
140
|
+
logger.error(f"Data: {migrated_data}")
|
150
141
|
raise
|
151
142
|
|
152
143
|
@classmethod
|
@@ -157,6 +148,7 @@ class Profile:
|
|
157
148
|
parameter_migrations = {
|
158
149
|
"label_cache": "use_label_cache",
|
159
150
|
"label_expiry": "label_cache_expiry_minutes",
|
151
|
+
"cache_dir": "metadata_cache_dir", # Profile's cache_dir maps to FOClientConfig's metadata_cache_dir
|
160
152
|
}
|
161
153
|
|
162
154
|
for old_name, new_name in parameter_migrations.items():
|
@@ -164,20 +156,31 @@ class Profile:
|
|
164
156
|
data[new_name] = data.pop(old_name)
|
165
157
|
logger.debug(f"Migrated parameter {old_name} -> {new_name}")
|
166
158
|
|
159
|
+
# Special handling for Profile's legacy credential migration
|
160
|
+
# If auth_mode is "client_credentials", treat it as explicit credentials
|
161
|
+
if data.get("auth_mode") == "client_credentials":
|
162
|
+
data["use_default_credentials"] = False
|
163
|
+
logger.debug("Setting use_default_credentials=False for client_credentials auth_mode")
|
164
|
+
|
165
|
+
# Migrate legacy credential fields to credential_source (use parent method)
|
166
|
+
data = FOClientConfig._migrate_legacy_credentials(data)
|
167
|
+
|
167
168
|
return data
|
168
169
|
|
169
170
|
def to_dict(self) -> Dict[str, Any]:
|
170
171
|
"""Convert profile to dictionary for storage."""
|
171
|
-
|
172
|
-
|
173
|
-
|
174
|
-
|
172
|
+
# Use parent's to_dict method and remove profile-specific fields that shouldn't be in storage
|
173
|
+
data = super().to_dict()
|
174
|
+
|
175
|
+
# Add profile-specific fields
|
176
|
+
data.update({
|
177
|
+
"description": self.description,
|
178
|
+
"output_format": self.output_format,
|
179
|
+
})
|
180
|
+
|
181
|
+
# Remove name from storage (it's stored as the key)
|
175
182
|
data.pop("name", None)
|
176
183
|
|
177
|
-
# Handle credential_source serialization
|
178
|
-
if self.credential_source is not None:
|
179
|
-
data["credential_source"] = self.credential_source.to_dict()
|
180
|
-
|
181
184
|
return data
|
182
185
|
|
183
186
|
def clone(self, name: str, **overrides) -> "Profile":
|
@@ -195,11 +198,13 @@ class Profile:
|
|
195
198
|
|
196
199
|
def __str__(self) -> str:
|
197
200
|
"""String representation of the profile."""
|
198
|
-
|
201
|
+
cred_info = "default_credentials" if self.credential_source is None else f"credential_source={self.credential_source.source_type}"
|
202
|
+
return f"Profile(name='{self.name}', base_url='{self.base_url}', auth={cred_info})"
|
199
203
|
|
200
204
|
def __repr__(self) -> str:
|
201
205
|
"""Detailed string representation of the profile."""
|
206
|
+
cred_info = "default_credentials" if self.credential_source is None else f"credential_source={self.credential_source.source_type}"
|
202
207
|
return (
|
203
208
|
f"Profile(name='{self.name}', base_url='{self.base_url}', "
|
204
|
-
f"
|
209
|
+
f"auth={cred_info}, description='{self.description}')"
|
205
210
|
)
|
@@ -0,0 +1,355 @@
|
|
1
|
+
"""Pydantic settings model for environment variable management."""
|
2
|
+
|
3
|
+
import os
|
4
|
+
from enum import Enum
|
5
|
+
from pathlib import Path
|
6
|
+
from typing import Literal, Optional
|
7
|
+
|
8
|
+
from pydantic import Field, field_validator
|
9
|
+
from pydantic_settings import BaseSettings, SettingsConfigDict
|
10
|
+
|
11
|
+
from .utils import get_default_cache_directory
|
12
|
+
|
13
|
+
|
14
|
+
class LogLevel(str, Enum):
|
15
|
+
"""Valid logging levels."""
|
16
|
+
|
17
|
+
DEBUG = "DEBUG"
|
18
|
+
INFO = "INFO"
|
19
|
+
WARNING = "WARNING"
|
20
|
+
ERROR = "ERROR"
|
21
|
+
CRITICAL = "CRITICAL"
|
22
|
+
|
23
|
+
|
24
|
+
class TransportProtocol(str, Enum):
|
25
|
+
"""Valid MCP transport protocols."""
|
26
|
+
|
27
|
+
STDIO = "stdio"
|
28
|
+
SSE = "sse"
|
29
|
+
HTTP = "http"
|
30
|
+
STREAMABLE_HTTP = "streamable-http"
|
31
|
+
|
32
|
+
|
33
|
+
class D365FOSettings(BaseSettings):
|
34
|
+
"""Pydantic settings model for D365FO environment variables.
|
35
|
+
|
36
|
+
This model provides type-safe access to all D365FO environment variables
|
37
|
+
with proper validation, defaults, and documentation.
|
38
|
+
"""
|
39
|
+
|
40
|
+
model_config = SettingsConfigDict(
|
41
|
+
env_prefix="D365FO_",
|
42
|
+
env_file=".env",
|
43
|
+
env_file_encoding="utf-8",
|
44
|
+
case_sensitive=True,
|
45
|
+
extra="ignore",
|
46
|
+
)
|
47
|
+
|
48
|
+
# === Core D365FO Connection Settings ===
|
49
|
+
|
50
|
+
base_url: Optional[str] = Field(
|
51
|
+
default="https://usnconeboxax1aos.cloud.onebox.dynamics.com",
|
52
|
+
description="D365FO environment URL",
|
53
|
+
alias="D365FO_BASE_URL"
|
54
|
+
)
|
55
|
+
|
56
|
+
# === Azure AD Authentication Settings ===
|
57
|
+
|
58
|
+
client_id: Optional[str] = Field(
|
59
|
+
default=None,
|
60
|
+
description="Azure AD client ID (optional, used with client credentials flow)",
|
61
|
+
alias="D365FO_CLIENT_ID"
|
62
|
+
)
|
63
|
+
|
64
|
+
client_secret: Optional[str] = Field(
|
65
|
+
default=None,
|
66
|
+
description="Azure AD client secret (optional, used with client credentials flow)",
|
67
|
+
alias="D365FO_CLIENT_SECRET"
|
68
|
+
)
|
69
|
+
|
70
|
+
tenant_id: Optional[str] = Field(
|
71
|
+
default=None,
|
72
|
+
description="Azure AD tenant ID (optional, used with client credentials flow)",
|
73
|
+
alias="D365FO_TENANT_ID"
|
74
|
+
)
|
75
|
+
|
76
|
+
# === MCP Authentication Settings ===
|
77
|
+
|
78
|
+
mcp_auth_client_id: Optional[str] = Field(
|
79
|
+
default=None,
|
80
|
+
description="MCP authentication client ID",
|
81
|
+
alias="D365FO_MCP_AUTH_CLIENT_ID"
|
82
|
+
)
|
83
|
+
|
84
|
+
mcp_auth_client_secret: Optional[str] = Field(
|
85
|
+
default=None,
|
86
|
+
description="MCP authentication client secret",
|
87
|
+
alias="D365FO_MCP_AUTH_CLIENT_SECRET"
|
88
|
+
)
|
89
|
+
|
90
|
+
mcp_auth_tenant_id: Optional[str] = Field(
|
91
|
+
default=None,
|
92
|
+
description="MCP authentication tenant ID",
|
93
|
+
alias="D365FO_MCP_AUTH_TENANT_ID"
|
94
|
+
)
|
95
|
+
|
96
|
+
mcp_auth_base_url: str = Field(
|
97
|
+
default="http://localhost:8000",
|
98
|
+
description="MCP authentication base URL",
|
99
|
+
alias="D365FO_MCP_AUTH_BASE_URL"
|
100
|
+
)
|
101
|
+
|
102
|
+
mcp_auth_required_scopes: str = Field(
|
103
|
+
default="User.Read,email,openid,profile",
|
104
|
+
description="MCP authentication required scopes (comma-separated)",
|
105
|
+
alias="D365FO_MCP_AUTH_REQUIRED_SCOPES"
|
106
|
+
)
|
107
|
+
|
108
|
+
# === MCP Server Transport Settings ===
|
109
|
+
|
110
|
+
mcp_transport: TransportProtocol = Field(
|
111
|
+
default=TransportProtocol.STDIO,
|
112
|
+
description="Default transport protocol (stdio, sse, http, streamable-http)",
|
113
|
+
alias="D365FO_MCP_TRANSPORT"
|
114
|
+
)
|
115
|
+
|
116
|
+
http_host: str = Field(
|
117
|
+
default="127.0.0.1",
|
118
|
+
description="Default HTTP host",
|
119
|
+
alias="D365FO_MCP_HTTP_HOST"
|
120
|
+
)
|
121
|
+
|
122
|
+
http_port: int = Field(
|
123
|
+
default=8000,
|
124
|
+
gt=0,
|
125
|
+
le=65535,
|
126
|
+
description="Default HTTP port",
|
127
|
+
alias="D365FO_MCP_HTTP_PORT"
|
128
|
+
)
|
129
|
+
|
130
|
+
http_stateless: bool = Field(
|
131
|
+
default=False,
|
132
|
+
description="Enable stateless mode (true/false)",
|
133
|
+
alias="D365FO_MCP_HTTP_STATELESS"
|
134
|
+
)
|
135
|
+
|
136
|
+
http_json: bool = Field(
|
137
|
+
default=False,
|
138
|
+
description="Enable JSON response mode (true/false)",
|
139
|
+
alias="D365FO_MCP_HTTP_JSON"
|
140
|
+
)
|
141
|
+
|
142
|
+
# === Connection and Performance Settings ===
|
143
|
+
|
144
|
+
max_concurrent_requests: int = Field(
|
145
|
+
default=10,
|
146
|
+
gt=0,
|
147
|
+
description="Max concurrent requests",
|
148
|
+
alias="D365FO_MCP_MAX_CONCURRENT_REQUESTS"
|
149
|
+
)
|
150
|
+
|
151
|
+
request_timeout: int = Field(
|
152
|
+
default=30,
|
153
|
+
gt=0,
|
154
|
+
description="Request timeout in seconds",
|
155
|
+
alias="D365FO_MCP_REQUEST_TIMEOUT"
|
156
|
+
)
|
157
|
+
|
158
|
+
timeout: int = Field(
|
159
|
+
default=60,
|
160
|
+
gt=0,
|
161
|
+
description="General timeout in seconds",
|
162
|
+
alias="D365FO_TIMEOUT"
|
163
|
+
)
|
164
|
+
|
165
|
+
verify_ssl: bool = Field(
|
166
|
+
default=True,
|
167
|
+
description="Verify SSL certificates",
|
168
|
+
alias="D365FO_VERIFY_SSL"
|
169
|
+
)
|
170
|
+
|
171
|
+
# === Logging Settings ===
|
172
|
+
|
173
|
+
log_level: LogLevel = Field(
|
174
|
+
default=LogLevel.INFO,
|
175
|
+
description="Logging level (DEBUG, INFO, WARNING, ERROR, CRITICAL)",
|
176
|
+
alias="D365FO_LOG_LEVEL"
|
177
|
+
)
|
178
|
+
|
179
|
+
log_file: Optional[str] = Field(
|
180
|
+
default=None,
|
181
|
+
description="Custom log file path (default: ~/.d365fo-mcp/logs/fastmcp-server.log)",
|
182
|
+
alias="D365FO_LOG_FILE"
|
183
|
+
)
|
184
|
+
|
185
|
+
# === Cache and Metadata Settings ===
|
186
|
+
|
187
|
+
cache_dir: Optional[str] = Field(
|
188
|
+
default=None,
|
189
|
+
description="General cache directory",
|
190
|
+
alias="D365FO_CACHE_DIR"
|
191
|
+
)
|
192
|
+
|
193
|
+
meta_cache_dir: Optional[str] = Field(
|
194
|
+
default=None,
|
195
|
+
description="Metadata cache directory (default: ~/.d365fo-mcp/cache)",
|
196
|
+
alias="D365FO_META_CACHE_DIR"
|
197
|
+
)
|
198
|
+
|
199
|
+
use_label_cache: bool = Field(
|
200
|
+
default=True,
|
201
|
+
description="Enable label caching",
|
202
|
+
alias="D365FO_LABEL_CACHE"
|
203
|
+
)
|
204
|
+
|
205
|
+
label_cache_expiry_minutes: int = Field(
|
206
|
+
default=1440, # 24 hours
|
207
|
+
gt=0,
|
208
|
+
description="Label cache expiry in minutes",
|
209
|
+
alias="D365FO_LABEL_EXPIRY"
|
210
|
+
)
|
211
|
+
|
212
|
+
use_cache_first: bool = Field(
|
213
|
+
default=True,
|
214
|
+
description="Use cache first before making API calls",
|
215
|
+
alias="D365FO_USE_CACHE_FIRST"
|
216
|
+
)
|
217
|
+
|
218
|
+
# === Debug and Development Settings ===
|
219
|
+
|
220
|
+
debug: bool = Field(
|
221
|
+
default=False,
|
222
|
+
description="Enable debug mode",
|
223
|
+
alias="DEBUG"
|
224
|
+
)
|
225
|
+
|
226
|
+
@field_validator("log_file", mode="before")
|
227
|
+
@classmethod
|
228
|
+
def validate_log_file(cls, v):
|
229
|
+
"""Validate and set default log file path."""
|
230
|
+
if v is None:
|
231
|
+
# Return default log file path
|
232
|
+
log_dir = Path.home() / ".d365fo-mcp" / "logs"
|
233
|
+
return str(log_dir / "fastmcp-server.log")
|
234
|
+
return v
|
235
|
+
|
236
|
+
@field_validator("cache_dir", mode="before")
|
237
|
+
@classmethod
|
238
|
+
def validate_cache_dir(cls, v):
|
239
|
+
"""Validate and set default cache directory."""
|
240
|
+
if v is None:
|
241
|
+
return get_default_cache_directory()
|
242
|
+
return v
|
243
|
+
|
244
|
+
@field_validator("meta_cache_dir", mode="before")
|
245
|
+
@classmethod
|
246
|
+
def validate_meta_cache_dir(cls, v):
|
247
|
+
"""Validate and set default metadata cache directory."""
|
248
|
+
if v is None:
|
249
|
+
cache_dir = Path.home() / ".d365fo-mcp" / "cache"
|
250
|
+
return str(cache_dir)
|
251
|
+
return v
|
252
|
+
|
253
|
+
@field_validator("http_stateless", "http_json", "use_label_cache", "use_cache_first", "verify_ssl", "debug", mode="before")
|
254
|
+
@classmethod
|
255
|
+
def validate_boolean_env_vars(cls, v):
|
256
|
+
"""Convert string environment variables to booleans."""
|
257
|
+
if isinstance(v, str):
|
258
|
+
return v.lower() in ("true", "1", "yes", "on")
|
259
|
+
return v
|
260
|
+
|
261
|
+
def get_default_log_file(self) -> str:
|
262
|
+
"""Get the default log file path."""
|
263
|
+
log_dir = Path.home() / ".d365fo-mcp" / "logs"
|
264
|
+
log_dir.mkdir(parents=True, exist_ok=True)
|
265
|
+
return str(log_dir / "fastmcp-server.log")
|
266
|
+
|
267
|
+
def ensure_directories(self) -> None:
|
268
|
+
"""Ensure all required directories exist."""
|
269
|
+
# Ensure log file directory exists
|
270
|
+
if self.log_file:
|
271
|
+
log_dir = Path(self.log_file).parent
|
272
|
+
log_dir.mkdir(parents=True, exist_ok=True)
|
273
|
+
|
274
|
+
# Ensure cache directories exist
|
275
|
+
if self.cache_dir:
|
276
|
+
Path(self.cache_dir).mkdir(parents=True, exist_ok=True)
|
277
|
+
|
278
|
+
if self.meta_cache_dir:
|
279
|
+
Path(self.meta_cache_dir).mkdir(parents=True, exist_ok=True)
|
280
|
+
|
281
|
+
def has_client_credentials(self) -> bool:
|
282
|
+
"""Check if client credentials are configured."""
|
283
|
+
return all([self.client_id, self.client_secret, self.tenant_id])
|
284
|
+
|
285
|
+
def has_mcp_auth_credentials(self) -> bool:
|
286
|
+
"""Check if MCP authentication credentials are configured."""
|
287
|
+
return all([self.mcp_auth_client_id, self.mcp_auth_client_secret, self.mcp_auth_tenant_id])
|
288
|
+
|
289
|
+
def get_startup_mode(self) -> Literal["profile_only", "default_auth", "client_credentials"]:
|
290
|
+
"""Determine startup mode based on configuration."""
|
291
|
+
if self.base_url and self.base_url != "https://usnconeboxax1aos.cloud.onebox.dynamics.com":
|
292
|
+
if self.has_client_credentials():
|
293
|
+
return "client_credentials"
|
294
|
+
else:
|
295
|
+
return "default_auth"
|
296
|
+
return "profile_only"
|
297
|
+
|
298
|
+
def mcp_auth_required_scopes_list(self) -> list[str]:
|
299
|
+
"""Get MCP authentication required scopes as a list."""
|
300
|
+
return [scope.strip() for scope in self.mcp_auth_required_scopes.split(",") if scope.strip()]
|
301
|
+
|
302
|
+
def to_dict(self) -> dict:
|
303
|
+
"""Convert settings to dictionary."""
|
304
|
+
return self.dict()
|
305
|
+
|
306
|
+
def to_env_dict(self) -> dict:
|
307
|
+
"""Convert settings to environment variable dictionary."""
|
308
|
+
env_dict = {}
|
309
|
+
for field_name, field_info in self.model_fields.items():
|
310
|
+
value = getattr(self, field_name)
|
311
|
+
if value is not None:
|
312
|
+
# Use the alias if available, otherwise construct the env var name
|
313
|
+
if field_info.alias:
|
314
|
+
env_var_name = field_info.alias
|
315
|
+
else:
|
316
|
+
env_var_name = f"D365FO_{field_name.upper()}"
|
317
|
+
|
318
|
+
# Convert boolean values to string
|
319
|
+
if isinstance(value, bool):
|
320
|
+
env_dict[env_var_name] = "true" if value else "false"
|
321
|
+
else:
|
322
|
+
env_dict[env_var_name] = str(value)
|
323
|
+
|
324
|
+
return env_dict
|
325
|
+
|
326
|
+
|
327
|
+
# Global settings instance
|
328
|
+
_settings: Optional[D365FOSettings] = None
|
329
|
+
|
330
|
+
|
331
|
+
def get_settings(reload: bool = False) -> D365FOSettings:
|
332
|
+
"""Get the global settings instance.
|
333
|
+
|
334
|
+
Args:
|
335
|
+
reload: If True, force reload settings from environment
|
336
|
+
|
337
|
+
Returns:
|
338
|
+
D365FOSettings instance
|
339
|
+
"""
|
340
|
+
global _settings
|
341
|
+
|
342
|
+
if _settings is None or reload:
|
343
|
+
_settings = D365FOSettings()
|
344
|
+
_settings.ensure_directories()
|
345
|
+
|
346
|
+
return _settings
|
347
|
+
|
348
|
+
|
349
|
+
def reset_settings() -> None:
|
350
|
+
"""Reset the global settings instance.
|
351
|
+
|
352
|
+
Useful for testing when you want to reload settings.
|
353
|
+
"""
|
354
|
+
global _settings
|
355
|
+
_settings = None
|