d365fo-client 0.2.4__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.
Files changed (58) hide show
  1. d365fo_client/__init__.py +7 -1
  2. d365fo_client/auth.py +9 -21
  3. d365fo_client/cli.py +25 -13
  4. d365fo_client/client.py +8 -4
  5. d365fo_client/config.py +52 -30
  6. d365fo_client/credential_sources.py +5 -0
  7. d365fo_client/main.py +1 -1
  8. d365fo_client/mcp/__init__.py +3 -1
  9. d365fo_client/mcp/auth_server/__init__.py +5 -0
  10. d365fo_client/mcp/auth_server/auth/__init__.py +30 -0
  11. d365fo_client/mcp/auth_server/auth/auth.py +372 -0
  12. d365fo_client/mcp/auth_server/auth/oauth_proxy.py +989 -0
  13. d365fo_client/mcp/auth_server/auth/providers/__init__.py +0 -0
  14. d365fo_client/mcp/auth_server/auth/providers/azure.py +325 -0
  15. d365fo_client/mcp/auth_server/auth/providers/bearer.py +25 -0
  16. d365fo_client/mcp/auth_server/auth/providers/jwt.py +547 -0
  17. d365fo_client/mcp/auth_server/auth/redirect_validation.py +65 -0
  18. d365fo_client/mcp/auth_server/dependencies.py +136 -0
  19. d365fo_client/mcp/client_manager.py +16 -67
  20. d365fo_client/mcp/fastmcp_main.py +358 -0
  21. d365fo_client/mcp/fastmcp_server.py +598 -0
  22. d365fo_client/mcp/fastmcp_utils.py +431 -0
  23. d365fo_client/mcp/main.py +40 -13
  24. d365fo_client/mcp/mixins/__init__.py +24 -0
  25. d365fo_client/mcp/mixins/base_tools_mixin.py +55 -0
  26. d365fo_client/mcp/mixins/connection_tools_mixin.py +50 -0
  27. d365fo_client/mcp/mixins/crud_tools_mixin.py +311 -0
  28. d365fo_client/mcp/mixins/database_tools_mixin.py +685 -0
  29. d365fo_client/mcp/mixins/label_tools_mixin.py +87 -0
  30. d365fo_client/mcp/mixins/metadata_tools_mixin.py +565 -0
  31. d365fo_client/mcp/mixins/performance_tools_mixin.py +109 -0
  32. d365fo_client/mcp/mixins/profile_tools_mixin.py +713 -0
  33. d365fo_client/mcp/mixins/sync_tools_mixin.py +321 -0
  34. d365fo_client/mcp/prompts/action_execution.py +1 -1
  35. d365fo_client/mcp/prompts/sequence_analysis.py +1 -1
  36. d365fo_client/mcp/tools/crud_tools.py +3 -3
  37. d365fo_client/mcp/tools/sync_tools.py +1 -1
  38. d365fo_client/mcp/utilities/__init__.py +1 -0
  39. d365fo_client/mcp/utilities/auth.py +34 -0
  40. d365fo_client/mcp/utilities/logging.py +58 -0
  41. d365fo_client/mcp/utilities/types.py +426 -0
  42. d365fo_client/metadata_v2/sync_manager_v2.py +2 -0
  43. d365fo_client/metadata_v2/sync_session_manager.py +7 -7
  44. d365fo_client/models.py +139 -139
  45. d365fo_client/output.py +2 -2
  46. d365fo_client/profile_manager.py +62 -27
  47. d365fo_client/profiles.py +118 -113
  48. d365fo_client/settings.py +355 -0
  49. d365fo_client/sync_models.py +85 -2
  50. d365fo_client/utils.py +2 -1
  51. {d365fo_client-0.2.4.dist-info → d365fo_client-0.3.0.dist-info}/METADATA +273 -18
  52. d365fo_client-0.3.0.dist-info/RECORD +84 -0
  53. d365fo_client-0.3.0.dist-info/entry_points.txt +4 -0
  54. d365fo_client-0.2.4.dist-info/RECORD +0 -56
  55. d365fo_client-0.2.4.dist-info/entry_points.txt +0 -3
  56. {d365fo_client-0.2.4.dist-info → d365fo_client-0.3.0.dist-info}/WHEEL +0 -0
  57. {d365fo_client-0.2.4.dist-info → d365fo_client-0.3.0.dist-info}/licenses/LICENSE +0 -0
  58. {d365fo_client-0.2.4.dist-info → d365fo_client-0.3.0.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,136 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import TYPE_CHECKING
4
+
5
+ from mcp.server.auth.middleware.auth_context import (
6
+ get_access_token as _sdk_get_access_token,
7
+ )
8
+ from mcp.server.auth.provider import (
9
+ AccessToken as _SDKAccessToken,
10
+ )
11
+ from starlette.requests import Request
12
+
13
+ from .auth import AccessToken
14
+
15
+ # if TYPE_CHECKING:
16
+ # from fastmcp.server.context import Context
17
+
18
+ __all__ = [
19
+ #"get_context",
20
+ "get_http_request",
21
+ "get_http_headers",
22
+ "get_access_token",
23
+ "AccessToken",
24
+ ]
25
+
26
+
27
+ # --- Context ---
28
+
29
+
30
+ # def get_context() -> Context:
31
+ # from fastmcp.server.context import _current_context
32
+
33
+ # context = _current_context.get()
34
+ # if context is None:
35
+ # raise RuntimeError("No active context found.")
36
+ # return context
37
+
38
+
39
+ # --- HTTP Request ---
40
+
41
+
42
+ def get_http_request() -> Request:
43
+ from mcp.server.lowlevel.server import request_ctx
44
+
45
+ request = None
46
+ try:
47
+ request = request_ctx.get().request
48
+ except LookupError:
49
+ pass
50
+
51
+ if request is None:
52
+ raise RuntimeError("No active HTTP request found.")
53
+ return request
54
+
55
+
56
+ def get_http_headers(include_all: bool = False) -> dict[str, str]:
57
+ """
58
+ Extract headers from the current HTTP request if available.
59
+
60
+ Never raises an exception, even if there is no active HTTP request (in which case
61
+ an empty dict is returned).
62
+
63
+ By default, strips problematic headers like `content-length` that cause issues if forwarded to downstream clients.
64
+ If `include_all` is True, all headers are returned.
65
+ """
66
+ if include_all:
67
+ exclude_headers = set()
68
+ else:
69
+ exclude_headers = {
70
+ "host",
71
+ "content-length",
72
+ "connection",
73
+ "transfer-encoding",
74
+ "upgrade",
75
+ "te",
76
+ "keep-alive",
77
+ "expect",
78
+ "accept",
79
+ # Proxy-related headers
80
+ "proxy-authenticate",
81
+ "proxy-authorization",
82
+ "proxy-connection",
83
+ # MCP-related headers
84
+ "mcp-session-id",
85
+ }
86
+ # (just in case)
87
+ if not all(h.lower() == h for h in exclude_headers):
88
+ raise ValueError("Excluded headers must be lowercase")
89
+ headers = {}
90
+
91
+ try:
92
+ request = get_http_request()
93
+ for name, value in request.headers.items():
94
+ lower_name = name.lower()
95
+ if lower_name not in exclude_headers:
96
+ headers[lower_name] = str(value)
97
+ return headers
98
+ except RuntimeError:
99
+ return {}
100
+
101
+
102
+ # --- Access Token ---
103
+
104
+
105
+ def get_access_token() -> AccessToken | None:
106
+ """
107
+ Get the FastMCP access token from the current context.
108
+
109
+ Returns:
110
+ The access token if an authenticated user is available, None otherwise.
111
+ """
112
+ #
113
+ access_token: _SDKAccessToken | None = _sdk_get_access_token()
114
+
115
+ if access_token is None or isinstance(access_token, AccessToken):
116
+ return access_token
117
+
118
+ # If the object is not a FastMCP AccessToken, convert it to one if the fields are compatible
119
+ # This is a workaround for the case where the SDK returns a different type
120
+ # If it fails, it will raise a TypeError
121
+ try:
122
+ access_token_as_dict = access_token.model_dump()
123
+ return AccessToken(
124
+ token=access_token_as_dict["token"],
125
+ client_id=access_token_as_dict["client_id"],
126
+ scopes=access_token_as_dict["scopes"],
127
+ # Optional fields
128
+ expires_at=access_token_as_dict.get("expires_at"),
129
+ resource_owner=access_token_as_dict.get("resource_owner"),# type: ignore[arg-type]
130
+ claims=access_token_as_dict.get("claims"),
131
+ )
132
+ except Exception as e:
133
+ raise TypeError(
134
+ f"Expected fastmcp.server.auth.auth.AccessToken, got {type(access_token).__name__}. "
135
+ "Ensure the SDK is using the correct AccessToken type."
136
+ ) from e
@@ -20,14 +20,13 @@ logger = logging.getLogger(__name__)
20
20
  class D365FOClientManager:
21
21
  """Manages D365FO client instances and connection pooling."""
22
22
 
23
- def __init__(self, config: dict, profile_manager: Optional[ProfileManager] = None):
23
+ def __init__(self, profile_manager: Optional[ProfileManager] = None):
24
24
  """Initialize the client manager.
25
25
 
26
26
  Args:
27
27
  config: Configuration dictionary with client settings
28
28
  profile_manager: Optional shared ProfileManager instance
29
29
  """
30
- self.config = config
31
30
  self._client_pool: Dict[str, FOClient] = {}
32
31
  self._session_lock = asyncio.Lock()
33
32
  self._last_health_check: Optional[datetime] = None
@@ -49,7 +48,11 @@ class D365FOClientManager:
49
48
  async with self._session_lock:
50
49
  if profile not in self._client_pool:
51
50
  client_config = self._build_client_config(profile)
51
+ if not client_config:
52
+ raise ValueError(f"Profile '{profile}' configuration is invalid")
53
+
52
54
  client = FOClient(client_config)
55
+ await client.initialize_metadata()
53
56
 
54
57
  # Test connection
55
58
  try:
@@ -172,7 +175,7 @@ class D365FOClientManager:
172
175
  # Clear all cached clients
173
176
  await self.cleanup()
174
177
 
175
- def _build_client_config(self, profile: str) -> FOClientConfig:
178
+ def _build_client_config(self, profile: str) -> Optional[FOClientConfig]:
176
179
  """Build FOClientConfig from profile configuration.
177
180
 
178
181
  Args:
@@ -185,7 +188,7 @@ class D365FOClientManager:
185
188
  env_profile = None
186
189
 
187
190
  # If requesting "default", resolve to the actual default profile
188
- if profile == "default":
191
+ if profile == "default" or not profile:
189
192
  env_profile = self.profile_manager.get_default_profile()
190
193
  if not env_profile:
191
194
  # No default profile set, try to get a profile named "default"
@@ -203,72 +206,18 @@ class D365FOClientManager:
203
206
  )
204
207
 
205
208
  # Check if legacy config should override certain settings
206
- default_config = self.config.get("default_environment", {})
207
- if default_config and profile == "default":
208
- # Override use_default_credentials if specified in legacy config
209
- if "use_default_credentials" in default_config:
210
- config.use_default_credentials = default_config["use_default_credentials"]
209
+
211
210
 
212
211
  return config
212
+
213
+ raise ValueError(
214
+ f"Profile '{profile}' not found in profile manager"
215
+ )
213
216
 
214
- # Fallback to legacy config-based profiles
215
- profile_config = self.config.get("profiles", {}).get(profile, {})
216
- default_config = self.config.get("default_environment", {})
217
-
218
- # If requesting a specific profile name that doesn't exist in profiles, don't fall back
219
- if (
220
- profile != "default"
221
- and profile not in self.config.get("profiles", {})
222
- and not profile_config
223
- ):
224
- available_profiles = list(self.profile_manager.list_profiles().keys())
225
- legacy_profiles = list(self.config.get("profiles", {}).keys())
226
- all_profiles = sorted(set(available_profiles + legacy_profiles))
227
-
228
- default_profile = self.profile_manager.get_default_profile()
229
-
230
- error_msg = f"Profile '{profile}' not found"
231
- if all_profiles:
232
- error_msg += f". Available profiles: {', '.join(all_profiles)}"
233
- if default_profile:
234
- error_msg += f". Default profile is '{default_profile.name}'"
235
- else:
236
- error_msg += ". No default profile is set"
237
-
238
- raise ValueError(error_msg)
239
-
240
- # Merge configs (profile overrides default)
241
- config = {**default_config, **profile_config}
242
-
243
- # Validate base_url
244
- base_url = config.get("base_url")
245
- if not base_url:
246
- available_profiles = list(self.profile_manager.list_profiles().keys())
247
- default_profile = self.profile_manager.get_default_profile()
248
-
249
- error_msg = f"No configuration found for profile '{profile}'"
250
- if available_profiles:
251
- error_msg += f". Available profiles: {', '.join(available_profiles)}"
252
- if default_profile:
253
- error_msg += f". Default profile is '{default_profile.name}'"
254
- else:
255
- error_msg += ". No default profile is set"
256
-
257
- raise ValueError(error_msg)
258
-
259
- return FOClientConfig(
260
- base_url=base_url,
261
- client_id=config.get("client_id"),
262
- client_secret=config.get("client_secret"),
263
- tenant_id=config.get("tenant_id"),
264
- use_default_credentials=config.get("use_default_credentials", True),
265
- timeout=config.get("timeout", 60),
266
- verify_ssl=config.get("verify_ssl", True),
267
- use_label_cache=config.get("use_label_cache", True),
268
- metadata_cache_dir=config.get("metadata_cache_dir"),
269
- use_cache_first=config.get("use_cache_first", True),
270
- )
271
-
217
+ async def shutdown(self):
218
+ """Shutdown the client manager and close all connections."""
219
+ await self.cleanup()
220
+
272
221
  async def _test_client_connection(self, client: FOClient) -> bool:
273
222
  """Test a client connection.
274
223
 
@@ -0,0 +1,358 @@
1
+ #!/usr/bin/env python3
2
+ """Entry point for the FastMCP-based D365FO MCP Server."""
3
+
4
+ import argparse
5
+ import asyncio
6
+ import logging
7
+ import logging.handlers
8
+ import os
9
+ import sys
10
+ from pathlib import Path
11
+ from typing import Literal, Optional
12
+ from mcp.server.fastmcp import FastMCP
13
+ from pydantic import AnyHttpUrl, AnyUrl
14
+
15
+ from d365fo_client import __version__
16
+ from d365fo_client.mcp.auth_server.auth.providers.azure import AzureProvider
17
+ from d365fo_client.mcp import FastD365FOMCPServer
18
+ from d365fo_client.mcp.fastmcp_utils import create_default_profile_if_needed, load_default_config, migrate_legacy_config
19
+ from d365fo_client.profile_manager import ProfileManager
20
+ from d365fo_client.settings import get_settings
21
+ from mcp.server.auth.settings import AuthSettings,ClientRegistrationOptions
22
+
23
+
24
+
25
+ def setup_logging(level: str = "INFO", log_file_path: Optional[str] = None) -> None:
26
+ """Set up logging configuration with 24-hour log rotation.
27
+
28
+ Args:
29
+ level: Logging level (DEBUG, INFO, WARNING, ERROR, CRITICAL)
30
+ log_file_path: Custom log file path, if None uses default from settings
31
+ """
32
+ log_level = getattr(logging, level.upper(), logging.INFO)
33
+
34
+ # Get log file path from parameter or settings
35
+ if log_file_path:
36
+ log_file = Path(log_file_path)
37
+ # Ensure parent directory exists
38
+ log_file.parent.mkdir(parents=True, exist_ok=True)
39
+ else:
40
+ # Use default log file path from settings
41
+ settings = get_settings()
42
+ log_file = Path(settings.log_file) #type: ignore
43
+ # Settings already ensures directories exist
44
+ log_file.parent.mkdir(parents=True, exist_ok=True)
45
+
46
+ # Clear existing handlers to avoid duplicate logging
47
+ root_logger = logging.getLogger()
48
+ for handler in root_logger.handlers[:]:
49
+ root_logger.removeHandler(handler)
50
+
51
+ # Create rotating file handler - rotates every 24 hours (midnight)
52
+ file_handler = logging.handlers.TimedRotatingFileHandler(
53
+ filename=str(log_file),
54
+ when='midnight', # Rotate at midnight
55
+ interval=1, # Every 1 day
56
+ backupCount=30, # Keep 30 days of logs
57
+ encoding='utf-8', # Use UTF-8 encoding
58
+ utc=False # Use local time for rotation
59
+ )
60
+ file_handler.setLevel(log_level)
61
+
62
+ # Create console handler
63
+ console_handler = logging.StreamHandler(sys.stderr)
64
+ console_handler.setLevel(log_level)
65
+
66
+ # Create formatter
67
+ formatter = logging.Formatter(
68
+ "%(asctime)s - %(name)s - %(levelname)s - %(message)s"
69
+ )
70
+ file_handler.setFormatter(formatter)
71
+ console_handler.setFormatter(formatter)
72
+
73
+ # Configure root logger
74
+ root_logger.setLevel(log_level)
75
+ root_logger.addHandler(file_handler)
76
+ root_logger.addHandler(console_handler)
77
+
78
+
79
+ def parse_arguments() -> argparse.Namespace:
80
+ """Parse command line arguments.
81
+
82
+ Returns:
83
+ Parsed command line arguments
84
+ """
85
+ parser = argparse.ArgumentParser(
86
+ description="FastMCP-based D365FO MCP Server with multi-transport support",
87
+ formatter_class=argparse.RawDescriptionHelpFormatter,
88
+ epilog="""
89
+ Transport Options:
90
+ stdio Standard input/output (default, for development and CLI tools)
91
+ sse Server-Sent Events (for web applications and browsers)
92
+ http Streamable HTTP (for production deployments and microservices)
93
+
94
+
95
+ Production Examples:
96
+ # Development (default)
97
+ %(prog)s
98
+
99
+ # Web development
100
+ %(prog)s --transport sse --port 8000 --debug
101
+
102
+ # Basic production HTTP
103
+ %(prog)s --transport http --host 0.0.0.0 --port 8000
104
+
105
+
106
+ Environment Variables:
107
+ D365FO_BASE_URL D365FO environment URL
108
+ D365FO_CLIENT_ID Azure AD client ID (optional)
109
+ D365FO_CLIENT_SECRET Azure AD client secret (optional)
110
+ D365FO_TENANT_ID Azure AD tenant ID (optional)
111
+ D365FO_MCP_TRANSPORT Default transport protocol (stdio, sse, http, streamable-http)
112
+ D365FO_MCP_HTTP_HOST Default HTTP host (default: 127.0.0.1)
113
+ D365FO_MCP_HTTP_PORT Default HTTP port (default: 8000)
114
+ D365FO_MCP_HTTP_STATELESS Enable stateless mode (true/false)
115
+ D365FO_MCP_HTTP_JSON Enable JSON response mode (true/false)
116
+ D365FO_MCP_MAX_CONCURRENT_REQUESTS Max concurrent requests (default: 10)
117
+ D365FO_MCP_REQUEST_TIMEOUT Request timeout in seconds (default: 30)
118
+ D365FO_MCP_AUTH_CLIENT_ID Azure AD client ID for authentication
119
+ D365FO_MCP_AUTH_CLIENT_SECRET Azure AD client secret for authentication
120
+ D365FO_MCP_AUTH_TENANT_ID Azure AD tenant ID for authentication
121
+ D365FO_MCP_AUTH_BASE_URL http://localhost:8000
122
+ D365FO_MCP_AUTH_REQUIRED_SCOPES User.Read,email,openid,profile
123
+ D365FO_LOG_LEVEL Logging level (DEBUG, INFO, WARNING, ERROR)
124
+ D365FO_LOG_FILE Custom log file path (default: ~/.d365fo-mcp/logs/fastmcp-server.log)
125
+ D365FO_META_CACHE_DIR Metadata cache directory (default: ~/.d365fo-mcp/cache)
126
+
127
+ """
128
+ )
129
+
130
+ parser.add_argument(
131
+ "--version",
132
+ action="version",
133
+ version=f"%(prog)s {__version__}"
134
+ )
135
+
136
+ # Get settings for defaults
137
+ settings = get_settings()
138
+
139
+ parser.add_argument(
140
+ "--transport",
141
+ type=str,
142
+ choices=["stdio", "sse", "http", "streamable-http"],
143
+ default=settings.mcp_transport.value,
144
+ help=f"Transport protocol to use (default: {settings.mcp_transport.value}, from D365FO_MCP_TRANSPORT env var)"
145
+ )
146
+
147
+ parser.add_argument(
148
+ "--host",
149
+ type=str,
150
+ default=settings.http_host,
151
+ help=f"Host to bind to for SSE/HTTP transports (default: {settings.http_host})"
152
+ )
153
+
154
+ parser.add_argument(
155
+ "--port",
156
+ type=int,
157
+ default=settings.http_port,
158
+ help=f"Port to bind to for SSE/HTTP transports (default: {settings.http_port})"
159
+ )
160
+
161
+ parser.add_argument(
162
+ "--stateless",
163
+ action="store_true",
164
+ help="Enable stateless HTTP mode for horizontal scaling and load balancing. " +
165
+ "In stateless mode, each request is independent and sessions are not persisted."
166
+ )
167
+
168
+ parser.add_argument(
169
+ "--json-response",
170
+ action="store_true",
171
+ help="Use JSON responses instead of SSE streams (HTTP transport only). " +
172
+ "Useful for API gateways and clients that prefer standard JSON responses."
173
+ )
174
+
175
+ parser.add_argument(
176
+ "--debug",
177
+ action="store_true",
178
+ help="Enable debug mode with verbose logging and detailed error information"
179
+ )
180
+
181
+ parser.add_argument(
182
+ "--log-level",
183
+ type=str,
184
+ choices=["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"],
185
+ default=settings.log_level.value,
186
+ help=f"Set logging level (default: {settings.log_level.value}, from D365FO_LOG_LEVEL env var)"
187
+ )
188
+
189
+
190
+
191
+ return parser.parse_args()
192
+
193
+
194
+ # Get settings first
195
+ settings = get_settings()
196
+
197
+ # Parse arguments
198
+ args = parse_arguments()
199
+ arg_transport = args.transport
200
+
201
+ # Set up logging and load configuration
202
+ if arg_transport == "http":
203
+ arg_transport = "streamable-http"
204
+
205
+ transport: Literal["stdio", "sse", "streamable-http"] = arg_transport
206
+
207
+ # Use settings for logging setup
208
+ setup_logging(args.log_level or settings.log_level.value, settings.log_file)
209
+ logger = logging.getLogger(__name__)
210
+
211
+ logger.info(f"Starting FastD365FOMCPServer v{__version__} with transport: {transport}")
212
+
213
+ # Use load_default_config with args instead of separate load_config function
214
+ config = load_default_config(args)
215
+
216
+ default_fo = config.get("default_environment", {})
217
+
218
+ config_path = default_fo.get("metadata_cache_dir", settings.meta_cache_dir)
219
+
220
+
221
+ # Create profile manager with config path
222
+ profile_manager = ProfileManager(str(Path(config_path) / "config.yaml"))
223
+
224
+ # Migrate legacy configuration if needed
225
+ if migrate_legacy_config(profile_manager):
226
+ logger.info("Legacy configuration migrated successfully")
227
+ else:
228
+ logger.debug("No legacy configuration migration needed")
229
+
230
+ if not create_default_profile_if_needed(profile_manager, config):
231
+ logger.debug("Default profile already exists or creation not needed")
232
+
233
+ # Extract server configuration
234
+ server_config = config.get("server", {})
235
+ transport_config = server_config.get("transport", {})
236
+
237
+ is_remote_transport = transport in ["sse", "streamable-http"]
238
+
239
+ # Log startup configuration details
240
+ logger.info("=== Server Startup Configuration ===")
241
+ logger.info(f"Transport: {transport}")
242
+ if is_remote_transport:
243
+ logger.info(f"Host: {transport_config.get('http', {}).get('host', '127.0.0.1')}")
244
+ logger.info(f"Port: {transport_config.get('http', {}).get('port', 8000)}")
245
+
246
+ logger.info(f"Debug mode: {server_config.get('debug', False)}")
247
+ logger.info(f"JSON response: {transport_config.get('http', {}).get('json_response', False)}")
248
+ logger.info(f"Stateless HTTP: {transport_config.get('http', {}).get('stateless', False)}")
249
+ logger.info(f"Log level: {args.log_level}")
250
+ logger.info(f"Config path: {config_path}")
251
+ logger.info(f"D365FO Base URL: {default_fo.get('base_url', settings.base_url)}")
252
+ logger.info(f"Settings Base URL: {settings.base_url}")
253
+ logger.info(f"Startup Mode: {settings.get_startup_mode()}")
254
+ logger.info(f"Client Credentials: {'Configured' if settings.has_client_credentials() else 'Not configured'}")
255
+ logger.info("====================================")
256
+
257
+ if is_remote_transport and not settings.has_mcp_auth_credentials():
258
+
259
+ logger.error("Warning: Client credentials (D365FO_MCP_AUTH_CLIENT_ID and D365FO_MCP_AUTH_CLIENT_SECRET, D365FO_MCP_AUTH_TENANT_ID,D365FO_MCP_AUTH_BASE_URL and D365FO_MCP_AUTH_REQUIRED_SCOPES) are not set. " +
260
+ "Remote transports require authentication to the D365FO environment.")
261
+ sys.exit(1)
262
+
263
+ auth_provider: AzureProvider | None = None
264
+ auth: AuthSettings | None = None
265
+
266
+ if is_remote_transport:
267
+ assert settings.mcp_auth_client_id is not None
268
+ assert settings.mcp_auth_client_secret is not None
269
+ assert settings.mcp_auth_tenant_id is not None
270
+ assert settings.mcp_auth_base_url is not None
271
+ assert settings.mcp_auth_required_scopes is not None
272
+ required_scopes=settings.mcp_auth_required_scopes_list()
273
+
274
+ # if "AX.FullAccess" not in required_scopes:
275
+ # logger.warning("Warning: 'AX.FullAccess' scope is not supported. Adding it automatically.")
276
+ # required_scopes.append("https://erp.dynamics.com/AX.FullAccess")
277
+
278
+ # Initialize authorization settings
279
+ auth_provider = AzureProvider(
280
+ client_id=settings.mcp_auth_client_id, # Your Azure App Client ID
281
+ client_secret=settings.mcp_auth_client_secret, # Your Azure App Client Secret
282
+ tenant_id=settings.mcp_auth_tenant_id, # Your Azure Tenant ID (REQUIRED)
283
+ base_url=settings.mcp_auth_base_url, # Must match your App registration
284
+ required_scopes=required_scopes or ["User.Read"], # type: ignore # Scopes your app needs
285
+ redirect_path="/auth/callback", # Ensure callback path is explicit
286
+ clients_storage_path=config_path or ...
287
+ )
288
+ from mcp.shared.auth import OAuthClientInformationFull
289
+
290
+ # auth_provider._clients["eae68fdd-610b-47c5-9907-709c7452f1b3"] = OAuthClientInformationFull(
291
+ # client_id="5eae68fdd-610b-47c5-9907-709c7452f1b3",
292
+ # client_name="Test Client",
293
+ # redirect_uris=[AnyUrl("http://127.0.0.1:33418")],
294
+ # scope=",".join(required_scopes)
295
+ # )
296
+
297
+
298
+ auth=AuthSettings(
299
+ issuer_url=AnyHttpUrl(settings.mcp_auth_base_url),
300
+ client_registration_options=ClientRegistrationOptions(
301
+ enabled=True,
302
+ valid_scopes=required_scopes or ["User.Read"], # type: ignore
303
+ default_scopes=required_scopes or ["User.Read"], # type: ignore
304
+ ),
305
+ required_scopes=required_scopes or ["User.Read"], # type: ignore
306
+ resource_server_url=AnyHttpUrl(settings.mcp_auth_base_url),
307
+
308
+ )
309
+
310
+ # Initialize FastMCP server with configuration
311
+ mcp = FastMCP(
312
+ name=server_config.get("name", "d365fo-mcp-server"),
313
+ auth_server_provider=auth_provider,
314
+ auth=auth,
315
+ instructions=server_config.get(
316
+ "instructions",
317
+ "Microsoft Dynamics 365 Finance & Operations MCP Server providing comprehensive access to D365FO data, metadata, and operations",
318
+ ),
319
+ host=transport_config.get("http", {}).get("host", "127.0.0.1"),
320
+ port=transport_config.get("http", {}).get("port", 8000),
321
+ debug=server_config.get("debug", False),
322
+ json_response=transport_config.get("http", {}).get("json_response", False),
323
+ stateless_http=transport_config.get("http", {}).get("stateless", False),
324
+ streamable_http_path= "/" if transport == "streamable-http" else "/mcp",
325
+ sse_path="/" if transport == "streamable-http" else "/sse",
326
+
327
+ )
328
+
329
+ if is_remote_transport:
330
+ from starlette.requests import Request
331
+ from starlette.responses import RedirectResponse
332
+
333
+ @mcp.custom_route(path=auth_provider._redirect_path, methods=["GET"]) # type: ignore
334
+ async def handle_idp_callback(request: Request) -> RedirectResponse:
335
+ return await auth_provider._handle_idp_callback(request) # type: ignore
336
+
337
+ server = FastD365FOMCPServer(mcp, config, profile_manager=profile_manager)
338
+
339
+ logger.info("FastD365FOMCPServer initialized successfully")
340
+
341
+
342
+ def main() -> None:
343
+ """Main entry point for the FastMCP server."""
344
+
345
+ # Handle Windows event loop policy
346
+ if sys.platform == "win32":
347
+ asyncio.set_event_loop_policy(asyncio.WindowsProactorEventLoopPolicy())
348
+ try:
349
+ mcp.run(transport=transport)
350
+ except KeyboardInterrupt:
351
+ logger.info("Server stopped by user (Ctrl+C)")
352
+ except Exception as e:
353
+ logger.error(f"Fatal error: {e}", exc_info=True)
354
+ print(f"Fatal error: {e}", file=sys.stderr)
355
+ sys.exit(1)
356
+
357
+ if __name__ == "__main__":
358
+ main()