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.
- 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/apikey.py +83 -0
- d365fo_client/mcp/auth_server/auth/providers/azure.py +393 -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 +407 -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 +367 -0
- d365fo_client/sync_models.py +85 -2
- d365fo_client/utils.py +2 -1
- {d365fo_client-0.2.4.dist-info → d365fo_client-0.3.1.dist-info}/METADATA +273 -18
- d365fo_client-0.3.1.dist-info/RECORD +85 -0
- d365fo_client-0.3.1.dist-info/entry_points.txt +4 -0
- d365fo_client-0.2.4.dist-info/RECORD +0 -56
- d365fo_client-0.2.4.dist-info/entry_points.txt +0 -3
- {d365fo_client-0.2.4.dist-info → d365fo_client-0.3.1.dist-info}/WHEEL +0 -0
- {d365fo_client-0.2.4.dist-info → d365fo_client-0.3.1.dist-info}/licenses/LICENSE +0 -0
- {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,
|
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
|
-
|
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
|
-
|
215
|
-
|
216
|
-
|
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()
|