d365fo-client 0.2.4__py3-none-any.whl → 0.3.1__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 (59) 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/apikey.py +83 -0
  15. d365fo_client/mcp/auth_server/auth/providers/azure.py +393 -0
  16. d365fo_client/mcp/auth_server/auth/providers/bearer.py +25 -0
  17. d365fo_client/mcp/auth_server/auth/providers/jwt.py +547 -0
  18. d365fo_client/mcp/auth_server/auth/redirect_validation.py +65 -0
  19. d365fo_client/mcp/auth_server/dependencies.py +136 -0
  20. d365fo_client/mcp/client_manager.py +16 -67
  21. d365fo_client/mcp/fastmcp_main.py +407 -0
  22. d365fo_client/mcp/fastmcp_server.py +598 -0
  23. d365fo_client/mcp/fastmcp_utils.py +431 -0
  24. d365fo_client/mcp/main.py +40 -13
  25. d365fo_client/mcp/mixins/__init__.py +24 -0
  26. d365fo_client/mcp/mixins/base_tools_mixin.py +55 -0
  27. d365fo_client/mcp/mixins/connection_tools_mixin.py +50 -0
  28. d365fo_client/mcp/mixins/crud_tools_mixin.py +311 -0
  29. d365fo_client/mcp/mixins/database_tools_mixin.py +685 -0
  30. d365fo_client/mcp/mixins/label_tools_mixin.py +87 -0
  31. d365fo_client/mcp/mixins/metadata_tools_mixin.py +565 -0
  32. d365fo_client/mcp/mixins/performance_tools_mixin.py +109 -0
  33. d365fo_client/mcp/mixins/profile_tools_mixin.py +713 -0
  34. d365fo_client/mcp/mixins/sync_tools_mixin.py +321 -0
  35. d365fo_client/mcp/prompts/action_execution.py +1 -1
  36. d365fo_client/mcp/prompts/sequence_analysis.py +1 -1
  37. d365fo_client/mcp/tools/crud_tools.py +3 -3
  38. d365fo_client/mcp/tools/sync_tools.py +1 -1
  39. d365fo_client/mcp/utilities/__init__.py +1 -0
  40. d365fo_client/mcp/utilities/auth.py +34 -0
  41. d365fo_client/mcp/utilities/logging.py +58 -0
  42. d365fo_client/mcp/utilities/types.py +426 -0
  43. d365fo_client/metadata_v2/sync_manager_v2.py +2 -0
  44. d365fo_client/metadata_v2/sync_session_manager.py +7 -7
  45. d365fo_client/models.py +139 -139
  46. d365fo_client/output.py +2 -2
  47. d365fo_client/profile_manager.py +62 -27
  48. d365fo_client/profiles.py +118 -113
  49. d365fo_client/settings.py +367 -0
  50. d365fo_client/sync_models.py +85 -2
  51. d365fo_client/utils.py +2 -1
  52. {d365fo_client-0.2.4.dist-info → d365fo_client-0.3.1.dist-info}/METADATA +273 -18
  53. d365fo_client-0.3.1.dist-info/RECORD +85 -0
  54. d365fo_client-0.3.1.dist-info/entry_points.txt +4 -0
  55. d365fo_client-0.2.4.dist-info/RECORD +0 -56
  56. d365fo_client-0.2.4.dist-info/entry_points.txt +0 -3
  57. {d365fo_client-0.2.4.dist-info → d365fo_client-0.3.1.dist-info}/WHEEL +0 -0
  58. {d365fo_client-0.2.4.dist-info → d365fo_client-0.3.1.dist-info}/licenses/LICENSE +0 -0
  59. {d365fo_client-0.2.4.dist-info → d365fo_client-0.3.1.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,407 @@
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
+ from d365fo_client.mcp.auth_server.auth.providers.apikey import APIKeyVerifier
23
+
24
+
25
+
26
+ def setup_logging(level: str = "INFO", log_file_path: Optional[str] = None) -> None:
27
+ """Set up logging configuration with 24-hour log rotation.
28
+
29
+ Args:
30
+ level: Logging level (DEBUG, INFO, WARNING, ERROR, CRITICAL)
31
+ log_file_path: Custom log file path, if None uses default from settings
32
+ """
33
+ log_level = getattr(logging, level.upper(), logging.INFO)
34
+
35
+ # Get log file path from parameter or settings
36
+ if log_file_path:
37
+ log_file = Path(log_file_path)
38
+ # Ensure parent directory exists
39
+ log_file.parent.mkdir(parents=True, exist_ok=True)
40
+ else:
41
+ # Use default log file path from settings
42
+ settings = get_settings()
43
+ log_file = Path(settings.log_file) #type: ignore
44
+ # Settings already ensures directories exist
45
+ log_file.parent.mkdir(parents=True, exist_ok=True)
46
+
47
+ # Clear existing handlers to avoid duplicate logging
48
+ root_logger = logging.getLogger()
49
+ for handler in root_logger.handlers[:]:
50
+ root_logger.removeHandler(handler)
51
+
52
+ # Create rotating file handler - rotates every 24 hours (midnight)
53
+ file_handler = logging.handlers.TimedRotatingFileHandler(
54
+ filename=str(log_file),
55
+ when='midnight', # Rotate at midnight
56
+ interval=1, # Every 1 day
57
+ backupCount=30, # Keep 30 days of logs
58
+ encoding='utf-8', # Use UTF-8 encoding
59
+ utc=False # Use local time for rotation
60
+ )
61
+ file_handler.setLevel(log_level)
62
+
63
+ # Create console handler
64
+ console_handler = logging.StreamHandler(sys.stderr)
65
+ console_handler.setLevel(log_level)
66
+
67
+ # Create formatter
68
+ formatter = logging.Formatter(
69
+ "%(asctime)s - %(name)s - %(levelname)s - %(message)s"
70
+ )
71
+ file_handler.setFormatter(formatter)
72
+ console_handler.setFormatter(formatter)
73
+
74
+ # Configure root logger
75
+ root_logger.setLevel(log_level)
76
+ root_logger.addHandler(file_handler)
77
+ root_logger.addHandler(console_handler)
78
+
79
+
80
+ def parse_arguments() -> argparse.Namespace:
81
+ """Parse command line arguments.
82
+
83
+ Returns:
84
+ Parsed command line arguments
85
+ """
86
+ parser = argparse.ArgumentParser(
87
+ description="FastMCP-based D365FO MCP Server with multi-transport support",
88
+ formatter_class=argparse.RawDescriptionHelpFormatter,
89
+ epilog="""
90
+ Transport Options:
91
+ stdio Standard input/output (default, for development and CLI tools)
92
+ sse Server-Sent Events (for web applications and browsers)
93
+ http Streamable HTTP (for production deployments and microservices)
94
+
95
+
96
+ Production Examples:
97
+ # Development (default)
98
+ %(prog)s
99
+
100
+ # Web development
101
+ %(prog)s --transport sse --port 8000 --debug
102
+
103
+ # Basic production HTTP
104
+ %(prog)s --transport http --host 0.0.0.0 --port 8000
105
+
106
+
107
+ Environment Variables:
108
+ D365FO_BASE_URL D365FO environment URL
109
+ D365FO_CLIENT_ID Azure AD client ID (optional)
110
+ D365FO_CLIENT_SECRET Azure AD client secret (optional)
111
+ D365FO_TENANT_ID Azure AD tenant ID (optional)
112
+ D365FO_MCP_TRANSPORT Default transport protocol (stdio, sse, http, streamable-http)
113
+ D365FO_MCP_HTTP_HOST Default HTTP host (default: 127.0.0.1)
114
+ D365FO_MCP_HTTP_PORT Default HTTP port (default: 8000)
115
+ D365FO_MCP_HTTP_STATELESS Enable stateless mode (true/false)
116
+ D365FO_MCP_HTTP_JSON Enable JSON response mode (true/false)
117
+ D365FO_MCP_MAX_CONCURRENT_REQUESTS Max concurrent requests (default: 10)
118
+ D365FO_MCP_REQUEST_TIMEOUT Request timeout in seconds (default: 30)
119
+ D365FO_MCP_AUTH_CLIENT_ID Azure AD client ID for authentication
120
+ D365FO_MCP_AUTH_CLIENT_SECRET Azure AD client secret for authentication
121
+ D365FO_MCP_AUTH_TENANT_ID Azure AD tenant ID for authentication
122
+ D365FO_MCP_AUTH_BASE_URL http://localhost:8000
123
+ D365FO_MCP_AUTH_REQUIRED_SCOPES User.Read,email,openid,profile
124
+ D365FO_MCP_API_KEY_VALUE API key for authentication (send as: Authorization: Bearer <key>)
125
+ D365FO_LOG_LEVEL Logging level (DEBUG, INFO, WARNING, ERROR)
126
+ D365FO_LOG_FILE Custom log file path (default: ~/.d365fo-mcp/logs/fastmcp-server.log)
127
+ D365FO_META_CACHE_DIR Metadata cache directory (default: ~/.d365fo-mcp/cache)
128
+
129
+ """
130
+ )
131
+
132
+ parser.add_argument(
133
+ "--version",
134
+ action="version",
135
+ version=f"%(prog)s {__version__}"
136
+ )
137
+
138
+ # Get settings for defaults
139
+ settings = get_settings()
140
+
141
+ parser.add_argument(
142
+ "--transport",
143
+ type=str,
144
+ choices=["stdio", "sse", "http", "streamable-http"],
145
+ default=settings.mcp_transport.value,
146
+ help=f"Transport protocol to use (default: {settings.mcp_transport.value}, from D365FO_MCP_TRANSPORT env var)"
147
+ )
148
+
149
+ parser.add_argument(
150
+ "--host",
151
+ type=str,
152
+ default=settings.http_host,
153
+ help=f"Host to bind to for SSE/HTTP transports (default: {settings.http_host})"
154
+ )
155
+
156
+ parser.add_argument(
157
+ "--port",
158
+ type=int,
159
+ default=settings.http_port,
160
+ help=f"Port to bind to for SSE/HTTP transports (default: {settings.http_port})"
161
+ )
162
+
163
+ parser.add_argument(
164
+ "--stateless",
165
+ action="store_true",
166
+ help="Enable stateless HTTP mode for horizontal scaling and load balancing. " +
167
+ "In stateless mode, each request is independent and sessions are not persisted."
168
+ )
169
+
170
+ parser.add_argument(
171
+ "--json-response",
172
+ action="store_true",
173
+ help="Use JSON responses instead of SSE streams (HTTP transport only). " +
174
+ "Useful for API gateways and clients that prefer standard JSON responses."
175
+ )
176
+
177
+ parser.add_argument(
178
+ "--debug",
179
+ action="store_true",
180
+ help="Enable debug mode with verbose logging and detailed error information"
181
+ )
182
+
183
+ parser.add_argument(
184
+ "--log-level",
185
+ type=str,
186
+ choices=["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"],
187
+ default=settings.log_level.value,
188
+ help=f"Set logging level (default: {settings.log_level.value}, from D365FO_LOG_LEVEL env var)"
189
+ )
190
+
191
+
192
+
193
+ return parser.parse_args()
194
+
195
+
196
+ # Get settings first
197
+ settings = get_settings()
198
+
199
+ # Parse arguments
200
+ args = parse_arguments()
201
+ arg_transport = args.transport
202
+
203
+ # Set up logging and load configuration
204
+ if arg_transport == "http":
205
+ arg_transport = "streamable-http"
206
+
207
+ transport: Literal["stdio", "sse", "streamable-http"] = arg_transport
208
+
209
+ # Use settings for logging setup
210
+ setup_logging(args.log_level or settings.log_level.value, settings.log_file)
211
+ logger = logging.getLogger(__name__)
212
+
213
+ logger.info(f"Starting FastD365FOMCPServer v{__version__} with transport: {transport}")
214
+
215
+ # Use load_default_config with args instead of separate load_config function
216
+ config = load_default_config(args)
217
+
218
+ default_fo = config.get("default_environment", {})
219
+
220
+ config_path = default_fo.get("metadata_cache_dir", settings.meta_cache_dir)
221
+
222
+
223
+ # Create profile manager with config path
224
+ profile_manager = ProfileManager(str(Path(config_path) / "config.yaml"))
225
+
226
+ # Migrate legacy configuration if needed
227
+ if migrate_legacy_config(profile_manager):
228
+ logger.info("Legacy configuration migrated successfully")
229
+ else:
230
+ logger.debug("No legacy configuration migration needed")
231
+
232
+ if not create_default_profile_if_needed(profile_manager, config):
233
+ logger.debug("Default profile already exists or creation not needed")
234
+
235
+ # Extract server configuration
236
+ server_config = config.get("server", {})
237
+ transport_config = server_config.get("transport", {})
238
+
239
+ is_remote_transport = transport in ["sse", "streamable-http"]
240
+
241
+ # Log startup configuration details
242
+ logger.info("=== Server Startup Configuration ===")
243
+ logger.info(f"Transport: {transport}")
244
+ if is_remote_transport:
245
+ logger.info(f"Host: {transport_config.get('http', {}).get('host', '127.0.0.1')}")
246
+ logger.info(f"Port: {transport_config.get('http', {}).get('port', 8000)}")
247
+
248
+ logger.info(f"Debug mode: {server_config.get('debug', False)}")
249
+ logger.info(f"JSON response: {transport_config.get('http', {}).get('json_response', False)}")
250
+ logger.info(f"Stateless HTTP: {transport_config.get('http', {}).get('stateless', False)}")
251
+ logger.info(f"Log level: {args.log_level}")
252
+ logger.info(f"Config path: {config_path}")
253
+ logger.info(f"D365FO Base URL: {default_fo.get('base_url', settings.base_url)}")
254
+ logger.info(f"Settings Base URL: {settings.base_url}")
255
+ logger.info(f"Startup Mode: {settings.get_startup_mode()}")
256
+ logger.info(f"Client Credentials: {'Configured' if settings.has_client_credentials() else 'Not configured'}")
257
+ logger.info("====================================")
258
+
259
+ # Validate authentication for remote transports
260
+ if is_remote_transport:
261
+ has_oauth = settings.has_mcp_auth_credentials()
262
+ has_api_key = settings.has_mcp_api_key_auth()
263
+
264
+ # Must have either OAuth or API key
265
+ if not has_oauth and not has_api_key:
266
+ logger.error(
267
+ "Error: Remote transports (SSE/HTTP) require authentication. "
268
+ "Please configure either:\n"
269
+ " OAuth: D365FO_MCP_AUTH_CLIENT_ID, D365FO_MCP_AUTH_CLIENT_SECRET, "
270
+ "D365FO_MCP_AUTH_TENANT_ID, D365FO_MCP_AUTH_BASE_URL, D365FO_MCP_AUTH_REQUIRED_SCOPES\n"
271
+ " OR\n"
272
+ " API Key: D365FO_MCP_API_KEY_VALUE, D365FO_MCP_API_KEY_HEADER_NAME (optional)"
273
+ )
274
+ sys.exit(1)
275
+
276
+ # OAuth takes precedence if both are configured
277
+ if has_oauth and has_api_key:
278
+ logger.warning(
279
+ "Both OAuth and API Key authentication configured. "
280
+ "Using OAuth (takes precedence)."
281
+ )
282
+
283
+ # Initialize authentication provider
284
+ auth_provider: AzureProvider | APIKeyVerifier | None = None # type: ignore
285
+ auth: AuthSettings | None = None
286
+
287
+ if is_remote_transport:
288
+ has_oauth = settings.has_mcp_auth_credentials()
289
+ has_api_key = settings.has_mcp_api_key_auth()
290
+
291
+ if has_oauth:
292
+ # OAuth authentication setup
293
+ logger.info("Initializing OAuth authentication with Azure AD")
294
+
295
+ assert settings.mcp_auth_client_id is not None
296
+ assert settings.mcp_auth_client_secret is not None
297
+ assert settings.mcp_auth_tenant_id is not None
298
+ assert settings.mcp_auth_base_url is not None
299
+ assert settings.mcp_auth_required_scopes is not None
300
+ required_scopes = settings.mcp_auth_required_scopes_list()
301
+
302
+ # Initialize authorization settings
303
+ auth_provider = AzureProvider(
304
+ client_id=settings.mcp_auth_client_id,
305
+ client_secret=settings.mcp_auth_client_secret,
306
+ tenant_id=settings.mcp_auth_tenant_id,
307
+ base_url=settings.mcp_auth_base_url,
308
+ required_scopes=required_scopes or ["User.Read"], # type: ignore
309
+ redirect_path="/auth/callback",
310
+ clients_storage_path=config_path or ...
311
+ )
312
+
313
+ auth = AuthSettings(
314
+ issuer_url=AnyHttpUrl(settings.mcp_auth_base_url),
315
+ client_registration_options=ClientRegistrationOptions(
316
+ enabled=True,
317
+ valid_scopes=required_scopes or ["User.Read"], # type: ignore
318
+ default_scopes=required_scopes or ["User.Read"], # type: ignore
319
+ ),
320
+ required_scopes=required_scopes or ["User.Read"], # type: ignore
321
+ resource_server_url=AnyHttpUrl(settings.mcp_auth_base_url),
322
+ )
323
+
324
+ elif has_api_key:
325
+ # API Key authentication setup
326
+ logger.info("Initializing API Key authentication")
327
+
328
+ from d365fo_client.mcp.auth_server.auth.providers.apikey import APIKeyVerifier
329
+
330
+ auth_provider = APIKeyVerifier( # type: ignore
331
+ api_key=settings.mcp_api_key_value, # type: ignore
332
+ base_url=settings.mcp_auth_base_url,
333
+ )
334
+
335
+ # For API Key authentication
336
+ auth = AuthSettings(
337
+ issuer_url=AnyHttpUrl(settings.mcp_auth_base_url) if settings.mcp_auth_base_url else AnyHttpUrl("http://localhost"),
338
+ resource_server_url=None
339
+ )
340
+
341
+ # Initialize FastMCP server with configuration
342
+ mcp = FastMCP(
343
+ name=server_config.get("name", "d365fo-mcp-server"),
344
+ auth_server_provider=auth_provider if isinstance(auth_provider, AzureProvider) else None,
345
+ token_verifier=auth_provider if isinstance(auth_provider, APIKeyVerifier) else None,
346
+ auth=auth,
347
+ instructions=server_config.get(
348
+ "instructions",
349
+ "Microsoft Dynamics 365 Finance & Operations MCP Server providing comprehensive access to D365FO data, metadata, and operations",
350
+ ),
351
+ host=transport_config.get("http", {}).get("host", "127.0.0.1"),
352
+ port=transport_config.get("http", {}).get("port", 8000),
353
+ debug=server_config.get("debug", False),
354
+ json_response=transport_config.get("http", {}).get("json_response", False),
355
+ stateless_http=transport_config.get("http", {}).get("stateless", False),
356
+ streamable_http_path= "/" if transport == "streamable-http" else "/mcp",
357
+ sse_path="/" if transport == "streamable-http" else "/sse",
358
+
359
+ )
360
+
361
+ # Add OAuth callback route only for Azure OAuth provider
362
+ if is_remote_transport and isinstance(auth_provider, AzureProvider):
363
+ from starlette.requests import Request
364
+ from starlette.responses import RedirectResponse
365
+
366
+ @mcp.custom_route(path=auth_provider._redirect_path, methods=["GET"]) # type: ignore
367
+ async def handle_idp_callback(request: Request) -> RedirectResponse:
368
+ return await auth_provider._handle_idp_callback(request) # type: ignore
369
+
370
+ # Initialize FastD365FOMCPServer
371
+ server = FastD365FOMCPServer(mcp, config, profile_manager=profile_manager)
372
+
373
+ # Configure API Key authentication if enabled
374
+ if is_remote_transport:
375
+ from d365fo_client.mcp.auth_server.auth.providers.apikey import APIKeyVerifier
376
+
377
+ if isinstance(auth_provider, APIKeyVerifier):
378
+
379
+ logger.info("API Key authentication configured successfully")
380
+ logger.info("=" * 60)
381
+ logger.info("IMPORTANT: Clients must authenticate using:")
382
+ logger.info(" Authorization: Bearer <your-api-key>")
383
+ logger.info("")
384
+ logger.info("Example:")
385
+ logger.info(f" curl -H 'Authorization: Bearer YOUR_KEY' http://localhost:{transport_config.get('http', {}).get('port', 8000)}/")
386
+ logger.info("=" * 60)
387
+
388
+ logger.info("FastD365FOMCPServer initialized successfully")
389
+
390
+
391
+ def main() -> None:
392
+ """Main entry point for the FastMCP server."""
393
+
394
+ # Handle Windows event loop policy
395
+ if sys.platform == "win32":
396
+ asyncio.set_event_loop_policy(asyncio.WindowsProactorEventLoopPolicy())
397
+ try:
398
+ mcp.run(transport=transport)
399
+ except KeyboardInterrupt:
400
+ logger.info("Server stopped by user (Ctrl+C)")
401
+ except Exception as e:
402
+ logger.error(f"Fatal error: {e}", exc_info=True)
403
+ print(f"Fatal error: {e}", file=sys.stderr)
404
+ sys.exit(1)
405
+
406
+ if __name__ == "__main__":
407
+ main()