d365fo-client 0.3.0__py3-none-any.whl → 0.3.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
d365fo_client/crud.py CHANGED
@@ -1,13 +1,14 @@
1
1
  """CRUD operations for D365 F&O client."""
2
2
 
3
- from typing import Any, Dict, List, Optional, Union
4
-
5
- import aiohttp
3
+ from typing import Any, Dict, Optional, Union, TYPE_CHECKING
6
4
 
7
5
  from .models import QueryOptions
8
6
  from .query import QueryBuilder
9
7
  from .session import SessionManager
10
8
 
9
+ if TYPE_CHECKING:
10
+ from .models import PublicEntityInfo
11
+
11
12
 
12
13
  class CrudOperations:
13
14
  """Handles CRUD operations for F&O entities"""
@@ -23,13 +24,17 @@ class CrudOperations:
23
24
  self.base_url = base_url
24
25
 
25
26
  async def get_entities(
26
- self, entity_name: str, options: Optional[QueryOptions] = None
27
+ self,
28
+ entity_name: str,
29
+ options: Optional[QueryOptions] = None,
30
+ entity_schema: Optional["PublicEntityInfo"] = None
27
31
  ) -> Dict[str, Any]:
28
32
  """Get entities with OData query options
29
33
 
30
34
  Args:
31
35
  entity_name: Name of the entity set
32
36
  options: OData query options
37
+ entity_schema: Optional entity schema for validation/optimization
33
38
 
34
39
  Returns:
35
40
  Response containing entities
@@ -52,6 +57,7 @@ class CrudOperations:
52
57
  entity_name: str,
53
58
  key: Union[str, Dict[str, Any]],
54
59
  options: Optional[QueryOptions] = None,
60
+ entity_schema: Optional["PublicEntityInfo"] = None
55
61
  ) -> Dict[str, Any]:
56
62
  """Get single entity by key
57
63
 
@@ -59,13 +65,15 @@ class CrudOperations:
59
65
  entity_name: Name of the entity set
60
66
  key: Entity key value (string for simple keys, dict for composite keys)
61
67
  options: OData query options
68
+ entity_schema: Optional entity schema for type-aware key encoding
62
69
 
63
70
  Returns:
64
71
  Entity data
65
72
  """
66
73
  session = await self.session_manager.get_session()
67
74
  query_string = QueryBuilder.build_query_string(options)
68
- url = QueryBuilder.build_entity_url(self.base_url, entity_name, key)
75
+ # Use schema-aware URL building for proper key encoding
76
+ url = QueryBuilder.build_entity_url(self.base_url, entity_name, key, entity_schema)
69
77
  url += query_string
70
78
 
71
79
  async with session.get(url) as response:
@@ -78,13 +86,17 @@ class CrudOperations:
78
86
  )
79
87
 
80
88
  async def create_entity(
81
- self, entity_name: str, data: Dict[str, Any]
89
+ self,
90
+ entity_name: str,
91
+ data: Dict[str, Any],
92
+ entity_schema: Optional["PublicEntityInfo"] = None
82
93
  ) -> Dict[str, Any]:
83
94
  """Create new entity
84
95
 
85
96
  Args:
86
97
  entity_name: Name of the entity set
87
98
  data: Entity data to create
99
+ entity_schema: Optional entity schema for validation
88
100
 
89
101
  Returns:
90
102
  Created entity data
@@ -107,6 +119,7 @@ class CrudOperations:
107
119
  key: Union[str, Dict[str, Any]],
108
120
  data: Dict[str, Any],
109
121
  method: str = "PATCH",
122
+ entity_schema: Optional["PublicEntityInfo"] = None
110
123
  ) -> Dict[str, Any]:
111
124
  """Update existing entity
112
125
 
@@ -115,12 +128,14 @@ class CrudOperations:
115
128
  key: Entity key value (string for simple keys, dict for composite keys)
116
129
  data: Updated entity data
117
130
  method: HTTP method (PATCH or PUT)
131
+ entity_schema: Optional entity schema for type-aware key encoding and validation
118
132
 
119
133
  Returns:
120
134
  Updated entity data
121
135
  """
122
136
  session = await self.session_manager.get_session()
123
- url = QueryBuilder.build_entity_url(self.base_url, entity_name, key)
137
+ # Use schema-aware URL building for proper key encoding
138
+ url = QueryBuilder.build_entity_url(self.base_url, entity_name, key, entity_schema)
124
139
 
125
140
  async with session.request(method, url, json=data) as response:
126
141
  if response.status in [200, 204]:
@@ -134,19 +149,24 @@ class CrudOperations:
134
149
  )
135
150
 
136
151
  async def delete_entity(
137
- self, entity_name: str, key: Union[str, Dict[str, Any]]
152
+ self,
153
+ entity_name: str,
154
+ key: Union[str, Dict[str, Any]],
155
+ entity_schema: Optional["PublicEntityInfo"] = None
138
156
  ) -> bool:
139
157
  """Delete entity
140
158
 
141
159
  Args:
142
160
  entity_name: Name of the entity set
143
161
  key: Entity key value (string for simple keys, dict for composite keys)
162
+ entity_schema: Optional entity schema for type-aware key encoding
144
163
 
145
164
  Returns:
146
165
  True if successful
147
166
  """
148
167
  session = await self.session_manager.get_session()
149
- url = QueryBuilder.build_entity_url(self.base_url, entity_name, key)
168
+ # Use schema-aware URL building for proper key encoding
169
+ url = QueryBuilder.build_entity_url(self.base_url, entity_name, key, entity_schema)
150
170
 
151
171
  async with session.delete(url) as response:
152
172
  if response.status in [200, 204]:
@@ -163,6 +183,7 @@ class CrudOperations:
163
183
  parameters: Optional[Dict[str, Any]] = None,
164
184
  entity_name: Optional[str] = None,
165
185
  entity_key: Optional[Union[str, Dict[str, Any]]] = None,
186
+ entity_schema: Optional["PublicEntityInfo"] = None
166
187
  ) -> Any:
167
188
  """Call OData action method
168
189
 
@@ -171,13 +192,15 @@ class CrudOperations:
171
192
  parameters: Action parameters
172
193
  entity_name: Entity name for bound actions
173
194
  entity_key: Entity key for bound actions (string for simple keys, dict for composite keys)
195
+ entity_schema: Optional entity schema for type-aware key encoding in bound actions
174
196
 
175
197
  Returns:
176
198
  Action result
177
199
  """
178
200
  session = await self.session_manager.get_session()
201
+ # Use schema-aware URL building for entity-bound actions
179
202
  url = QueryBuilder.build_action_url(
180
- self.base_url, action_name, entity_name, entity_key
203
+ self.base_url, action_name, entity_name, entity_key, entity_schema
181
204
  )
182
205
 
183
206
  # Prepare request body
d365fo_client/main.py CHANGED
@@ -236,6 +236,7 @@ def create_argument_parser() -> argparse.ArgumentParser:
236
236
  _add_metadata_commands(subparsers)
237
237
  _add_entity_commands(subparsers)
238
238
  _add_action_commands(subparsers)
239
+ _add_service_commands(subparsers)
239
240
  _add_config_commands(subparsers)
240
241
 
241
242
  return parser
@@ -449,6 +450,63 @@ def _add_config_commands(subparsers) -> None:
449
450
  default_parser.add_argument("profile_name", help="Profile name")
450
451
 
451
452
 
453
+ def _add_service_commands(subparsers) -> None:
454
+ """Add JSON service commands."""
455
+ service_parser = subparsers.add_parser("service", help="JSON service operations")
456
+ service_subs = service_parser.add_subparsers(
457
+ dest="service_subcommand", help="Service subcommands"
458
+ )
459
+
460
+ # call subcommand - generic service call
461
+ call_parser = service_subs.add_parser(
462
+ "call", help="Call a generic JSON service endpoint"
463
+ )
464
+ call_parser.add_argument(
465
+ "service_group", help="Service group name (e.g., 'SysSqlDiagnosticService')"
466
+ )
467
+ call_parser.add_argument(
468
+ "service_name",
469
+ help="Service name (e.g., 'SysSqlDiagnosticServiceOperations')"
470
+ )
471
+ call_parser.add_argument(
472
+ "operation_name", help="Operation name (e.g., 'GetAxSqlExecuting')"
473
+ )
474
+ call_parser.add_argument(
475
+ "--parameters",
476
+ help="JSON string with parameters to send in POST body",
477
+ )
478
+
479
+ # sql-diagnostic subcommand - convenience wrapper for SQL diagnostic operations
480
+ sql_parser = service_subs.add_parser(
481
+ "sql-diagnostic", help="Call SQL diagnostic service operations"
482
+ )
483
+ sql_parser.add_argument(
484
+ "operation",
485
+ choices=[
486
+ "GetAxSqlExecuting",
487
+ "GetAxSqlResourceStats",
488
+ "GetAxSqlBlocking",
489
+ "GetAxSqlLockInfo",
490
+ "GetAxSqlDisabledIndexes",
491
+ ],
492
+ help="SQL diagnostic operation to execute",
493
+ )
494
+ sql_parser.add_argument(
495
+ "--since-minutes",
496
+ type=int,
497
+ default=10,
498
+ help="For GetAxSqlResourceStats: get stats for last N minutes (default: 10)",
499
+ )
500
+ sql_parser.add_argument(
501
+ "--start-time",
502
+ help="For GetAxSqlResourceStats: start time (ISO format)",
503
+ )
504
+ sql_parser.add_argument(
505
+ "--end-time",
506
+ help="For GetAxSqlResourceStats: end time (ISO format)",
507
+ )
508
+
509
+
452
510
  def main() -> None:
453
511
  """Enhanced main entry point with CLI support."""
454
512
  parser = create_argument_parser()
@@ -0,0 +1,83 @@
1
+ """API Key authentication provider for FastMCP.
2
+
3
+ This provider implements simple API key authentication using the Authorization header.
4
+ Suitable for service-to-service authentication and simpler deployment scenarios.
5
+
6
+ IMPORTANT: FastMCP uses BearerAuthBackend which extracts tokens from the Authorization header
7
+ and calls token_verifier.verify_token(). Clients must send the API key as:
8
+ Authorization: Bearer <your-api-key>
9
+
10
+ The token_verifier.verify_token() method performs constant-time comparison of the API key.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import secrets
16
+
17
+ from pydantic import SecretStr
18
+
19
+ from ..auth import AccessToken, TokenVerifier
20
+ from d365fo_client.mcp.utilities.logging import get_logger
21
+
22
+ logger = get_logger(__name__)
23
+
24
+
25
+ class APIKeyVerifier(TokenVerifier):
26
+ """API Key token verifier for FastMCP.
27
+
28
+ This is a TokenVerifier that validates API keys sent as Bearer tokens.
29
+ FastMCP's BearerAuthBackend extracts the token from "Authorization: Bearer <token>"
30
+ and passes it to this verifier's verify_token() method.
31
+
32
+ This is a simpler alternative to OAuth for scenarios where:
33
+ - Service-to-service authentication is needed
34
+ - Simplified deployment without OAuth infrastructure
35
+ - Single-user or trusted client scenarios
36
+
37
+ Security features:
38
+ - Constant-time comparison to prevent timing attacks
39
+ - SecretStr storage to prevent accidental logging
40
+ - No token expiration (suitable for long-lived API keys)
41
+ """
42
+
43
+ def __init__(
44
+ self,
45
+ api_key: SecretStr,
46
+ base_url: str | None = None,
47
+ required_scopes: list[str] | None = None,
48
+ ):
49
+ """Initialize API key provider.
50
+
51
+ Args:
52
+ api_key: The secret API key value
53
+ base_url: Base URL of the server
54
+ required_scopes: Required scopes (for compatibility, not enforced for API keys)
55
+ """
56
+ super().__init__(base_url=base_url, required_scopes=required_scopes)
57
+ self.api_key = api_key
58
+
59
+ async def verify_token(self, token: str) -> AccessToken | None:
60
+ """Verify API key token.
61
+
62
+ This method is called by FastMCP's BearerAuthBackend after extracting
63
+ the token from "Authorization: Bearer <token>" header.
64
+
65
+ Args:
66
+ token: The API key extracted from the Authorization header
67
+
68
+ Returns:
69
+ AccessToken if valid, None otherwise
70
+ """
71
+ # Constant-time comparison to prevent timing attacks
72
+ if secrets.compare_digest(token, self.api_key.get_secret_value()):
73
+ logger.debug("API key authentication successful")
74
+ return AccessToken(
75
+ token=token,
76
+ scopes=self.required_scopes or [],
77
+ client_id="api_key_client", # Fixed client_id for API key auth
78
+ expires_at=None, # API keys don't expire
79
+ resource=None,
80
+ )
81
+
82
+ logger.warning("Invalid API key provided")
83
+ return None
@@ -284,42 +284,110 @@ class AzureProvider(OAuthProxy):
284
284
 
285
285
  async def register_client(self, client_info: OAuthClientInformationFull) -> None:
286
286
  """Register a new MCP client, validating redirect URIs if configured."""
287
- result = await super().register_client(client_info)
288
- self._save_clients()
289
- return result
287
+ await super().register_client(client_info)
288
+ try:
289
+ self._save_clients()
290
+ except Exception as e:
291
+ logger.error(f"Failed to persist client registration: {e}")
292
+ # Don't raise here as the client is already registered in memory
290
293
 
291
- def _save_clients(self) -> str | None:
294
+ def _save_clients(self) -> None:
295
+ """Save client data to persistent storage.
296
+
297
+ Raises:
298
+ ValueError: If clients_storage_path is not configured
299
+ OSError: If file operations fail
300
+ """
292
301
  if not self.clients_storage_path:
293
302
  logger.warning("No clients storage path configured. Skipping client save.")
294
- return None
303
+ return
295
304
 
296
- # Store self._clients to clients.json
297
305
  try:
298
- client_json_path = Path(self.clients_storage_path) / "clients.json"
306
+ # Ensure the storage directory exists
307
+ storage_dir = Path(self.clients_storage_path)
308
+ storage_dir.mkdir(parents=True, exist_ok=True)
309
+
310
+ client_json_path = storage_dir / "clients.json"
311
+
299
312
  # Convert OAuthClientInformationFull objects to dictionaries for JSON serialization
300
- clients_dict = {
301
- client_id: client.model_dump() if hasattr(client, 'model_dump') else client.__dict__
302
- for client_id, client in self._clients.items()
303
- }
304
- with client_json_path.open("w") as f:
305
- json.dump(clients_dict, f, indent=2)
313
+ # Use mode="json" to properly serialize complex types like AnyUrl
314
+ clients_dict = {}
315
+ for client_id, client in self._clients.items():
316
+ try:
317
+ if hasattr(client, 'model_dump'):
318
+ # Use json mode to ensure proper serialization of complex types (e.g., AnyUrl)
319
+ clients_dict[client_id] = client.model_dump(mode="json")
320
+ else:
321
+ # Fallback for non-Pydantic objects (shouldn't happen with OAuthClientInformationFull)
322
+ clients_dict[client_id] = client.__dict__
323
+ except Exception as client_error:
324
+ logger.error(f"Failed to serialize client {client_id}: {client_error}")
325
+ continue
326
+
327
+ # Write to temporary file first, then rename for atomic operation
328
+ temp_path = client_json_path.with_suffix('.tmp')
329
+ with temp_path.open("w") as f:
330
+ json.dump(clients_dict, f, indent=2, ensure_ascii=False)
331
+
332
+ # Atomic rename
333
+ temp_path.replace(client_json_path)
334
+
335
+ logger.debug(f"Successfully saved {len(clients_dict)} clients to {client_json_path}")
336
+
306
337
  except Exception as e:
307
- logger.error(f"Failed to write client data to {client_json_path}: {e}")
338
+ logger.error(f"Failed to save client data to {self.clients_storage_path}: {e}")
339
+ raise
308
340
 
309
341
  def _load_clients(self) -> None:
342
+ """Load client data from persistent storage.
343
+
344
+ Loads clients from the JSON file if it exists and is valid.
345
+ Invalid client data is logged and skipped.
346
+ """
310
347
  if not self.clients_storage_path:
348
+ logger.debug("No clients storage path configured. Skipping client load.")
311
349
  return
312
350
 
313
- # Load existing clients from storage if path is provided
314
-
315
351
  try:
316
352
  client_json_path = Path(self.clients_storage_path) / "clients.json"
317
- if client_json_path.exists():
318
- with client_json_path.open("r") as f:
319
- clients_data = json.load(f)
320
- for client_id, client_info in clients_data.items():
321
- # Ensure client_id is a string (it should be from JSON, but type checking requires this)
322
- if isinstance(client_id, str):
323
- self._clients[client_id] = OAuthClientInformationFull.model_validate(client_info)
353
+
354
+ if not client_json_path.exists():
355
+ logger.debug(f"Client storage file {client_json_path} does not exist. Starting with empty client registry.")
356
+ return
357
+
358
+ # Read and parse the JSON file
359
+ with client_json_path.open("r", encoding="utf-8") as f:
360
+ clients_data = json.load(f)
361
+
362
+ if not isinstance(clients_data, dict):
363
+ logger.error(f"Invalid client data format in {client_json_path}: expected dict, got {type(clients_data)}")
364
+ return
365
+
366
+ loaded_count = 0
367
+ for client_id, client_info in clients_data.items():
368
+ try:
369
+ # Validate client_id is a string
370
+ if not isinstance(client_id, str):
371
+ logger.warning(f"Skipping client with non-string ID: {client_id} (type: {type(client_id)})")
372
+ continue
373
+
374
+ # Validate and restore the client object
375
+ if not isinstance(client_info, dict):
376
+ logger.warning(f"Skipping client {client_id}: invalid data format (expected dict, got {type(client_info)})")
377
+ continue
378
+
379
+ # Use Pydantic model_validate to restore the object with proper validation
380
+ client_obj = OAuthClientInformationFull.model_validate(client_info)
381
+ self._clients[client_id] = client_obj
382
+ loaded_count += 1
383
+
384
+ except Exception as client_error:
385
+ logger.error(f"Failed to load client {client_id}: {client_error}")
386
+ continue
387
+
388
+ logger.info(f"Successfully loaded {loaded_count} clients from {client_json_path}")
389
+
390
+ except json.JSONDecodeError as e:
391
+ logger.error(f"Invalid JSON in client storage file {client_json_path}: {e}")
324
392
  except Exception as e:
325
393
  logger.error(f"Failed to load clients from {client_json_path}: {e}")
@@ -19,6 +19,7 @@ from d365fo_client.mcp.fastmcp_utils import create_default_profile_if_needed, lo
19
19
  from d365fo_client.profile_manager import ProfileManager
20
20
  from d365fo_client.settings import get_settings
21
21
  from mcp.server.auth.settings import AuthSettings,ClientRegistrationOptions
22
+ from d365fo_client.mcp.auth_server.auth.providers.apikey import APIKeyVerifier
22
23
 
23
24
 
24
25
 
@@ -120,6 +121,7 @@ Environment Variables:
120
121
  D365FO_MCP_AUTH_TENANT_ID Azure AD tenant ID for authentication
121
122
  D365FO_MCP_AUTH_BASE_URL http://localhost:8000
122
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>)
123
125
  D365FO_LOG_LEVEL Logging level (DEBUG, INFO, WARNING, ERROR)
124
126
  D365FO_LOG_FILE Custom log file path (default: ~/.d365fo-mcp/logs/fastmcp-server.log)
125
127
  D365FO_META_CACHE_DIR Metadata cache directory (default: ~/.d365fo-mcp/cache)
@@ -254,63 +256,93 @@ logger.info(f"Startup Mode: {settings.get_startup_mode()}")
254
256
  logger.info(f"Client Credentials: {'Configured' if settings.has_client_credentials() else 'Not configured'}")
255
257
  logger.info("====================================")
256
258
 
257
- if is_remote_transport and not settings.has_mcp_auth_credentials():
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)
258
275
 
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)
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
+ )
262
282
 
263
- auth_provider: AzureProvider | None = None
283
+ # Initialize authentication provider
284
+ auth_provider: AzureProvider | APIKeyVerifier | None = None # type: ignore
264
285
  auth: AuthSettings | None = None
265
286
 
266
287
  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
-
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
+ )
297
312
 
298
- auth=AuthSettings(
313
+ auth = AuthSettings(
299
314
  issuer_url=AnyHttpUrl(settings.mcp_auth_base_url),
300
315
  client_registration_options=ClientRegistrationOptions(
301
316
  enabled=True,
302
- valid_scopes=required_scopes or ["User.Read"], # type: ignore
303
- default_scopes=required_scopes or ["User.Read"], # type: ignore
317
+ valid_scopes=required_scopes or ["User.Read"], # type: ignore
318
+ default_scopes=required_scopes or ["User.Read"], # type: ignore
304
319
  ),
305
- required_scopes=required_scopes or ["User.Read"], # type: ignore
320
+ required_scopes=required_scopes or ["User.Read"], # type: ignore
306
321
  resource_server_url=AnyHttpUrl(settings.mcp_auth_base_url),
307
-
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
308
339
  )
309
340
 
310
341
  # Initialize FastMCP server with configuration
311
342
  mcp = FastMCP(
312
343
  name=server_config.get("name", "d365fo-mcp-server"),
313
- auth_server_provider=auth_provider,
344
+ auth_server_provider=auth_provider if isinstance(auth_provider, AzureProvider) else None,# type: ignore
345
+ token_verifier=auth_provider if isinstance(auth_provider, APIKeyVerifier) else None,
314
346
  auth=auth,
315
347
  instructions=server_config.get(
316
348
  "instructions",
@@ -326,16 +358,33 @@ mcp = FastMCP(
326
358
 
327
359
  )
328
360
 
329
- if is_remote_transport:
361
+ # Add OAuth callback route only for Azure OAuth provider
362
+ if is_remote_transport and isinstance(auth_provider, AzureProvider):
330
363
  from starlette.requests import Request
331
364
  from starlette.responses import RedirectResponse
332
-
365
+
333
366
  @mcp.custom_route(path=auth_provider._redirect_path, methods=["GET"]) # type: ignore
334
367
  async def handle_idp_callback(request: Request) -> RedirectResponse:
335
368
  return await auth_provider._handle_idp_callback(request) # type: ignore
336
369
 
370
+ # Initialize FastD365FOMCPServer
337
371
  server = FastD365FOMCPServer(mcp, config, profile_manager=profile_manager)
338
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
+
339
388
  logger.info("FastD365FOMCPServer initialized successfully")
340
389
 
341
390
 
@@ -144,6 +144,10 @@ def create_default_profile_if_needed(profile_manager:"ProfileManager", config:Di
144
144
  if not base_url:
145
145
  logger.warning("Cannot create default profile - D365FO_BASE_URL not set")
146
146
  return False
147
+
148
+ if base_url.startswith("https://usnconeboxax1aos.cloud.onebox.dynamics.com"):
149
+ logger.warning("D365FO_BASE_URL is set to the default onebox URL - please set it to your actual environment URL")
150
+ return False
147
151
 
148
152
  # Determine authentication mode based on startup mode
149
153
  startup_mode = config.get("startup_mode", "profile_only")