d365fo-client 0.3.0__tar.gz → 0.3.1__tar.gz
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-0.3.0/src/d365fo_client.egg-info → d365fo_client-0.3.1}/PKG-INFO +1 -1
- {d365fo_client-0.3.0 → d365fo_client-0.3.1}/pyproject.toml +1 -1
- d365fo_client-0.3.1/src/d365fo_client/mcp/auth_server/auth/providers/apikey.py +83 -0
- {d365fo_client-0.3.0 → d365fo_client-0.3.1}/src/d365fo_client/mcp/auth_server/auth/providers/azure.py +91 -23
- {d365fo_client-0.3.0 → d365fo_client-0.3.1}/src/d365fo_client/mcp/fastmcp_main.py +92 -43
- {d365fo_client-0.3.0 → d365fo_client-0.3.1}/src/d365fo_client/settings.py +14 -2
- {d365fo_client-0.3.0 → d365fo_client-0.3.1/src/d365fo_client.egg-info}/PKG-INFO +1 -1
- {d365fo_client-0.3.0 → d365fo_client-0.3.1}/src/d365fo_client.egg-info/SOURCES.txt +3 -1
- d365fo_client-0.3.1/tests/test_azure_provider_persistence.py +394 -0
- {d365fo_client-0.3.0 → d365fo_client-0.3.1}/LICENSE +0 -0
- {d365fo_client-0.3.0 → d365fo_client-0.3.1}/README.md +0 -0
- {d365fo_client-0.3.0 → d365fo_client-0.3.1}/setup.cfg +0 -0
- {d365fo_client-0.3.0 → d365fo_client-0.3.1}/src/d365fo_client/__init__.py +0 -0
- {d365fo_client-0.3.0 → d365fo_client-0.3.1}/src/d365fo_client/auth.py +0 -0
- {d365fo_client-0.3.0 → d365fo_client-0.3.1}/src/d365fo_client/cli.py +0 -0
- {d365fo_client-0.3.0 → d365fo_client-0.3.1}/src/d365fo_client/client.py +0 -0
- {d365fo_client-0.3.0 → d365fo_client-0.3.1}/src/d365fo_client/config.py +0 -0
- {d365fo_client-0.3.0 → d365fo_client-0.3.1}/src/d365fo_client/credential_sources.py +0 -0
- {d365fo_client-0.3.0 → d365fo_client-0.3.1}/src/d365fo_client/crud.py +0 -0
- {d365fo_client-0.3.0 → d365fo_client-0.3.1}/src/d365fo_client/exceptions.py +0 -0
- {d365fo_client-0.3.0 → d365fo_client-0.3.1}/src/d365fo_client/labels.py +0 -0
- {d365fo_client-0.3.0 → d365fo_client-0.3.1}/src/d365fo_client/main.py +0 -0
- {d365fo_client-0.3.0 → d365fo_client-0.3.1}/src/d365fo_client/mcp/__init__.py +0 -0
- {d365fo_client-0.3.0 → d365fo_client-0.3.1}/src/d365fo_client/mcp/auth_server/__init__.py +0 -0
- {d365fo_client-0.3.0 → d365fo_client-0.3.1}/src/d365fo_client/mcp/auth_server/auth/__init__.py +0 -0
- {d365fo_client-0.3.0 → d365fo_client-0.3.1}/src/d365fo_client/mcp/auth_server/auth/auth.py +0 -0
- {d365fo_client-0.3.0 → d365fo_client-0.3.1}/src/d365fo_client/mcp/auth_server/auth/oauth_proxy.py +0 -0
- {d365fo_client-0.3.0 → d365fo_client-0.3.1}/src/d365fo_client/mcp/auth_server/auth/providers/__init__.py +0 -0
- {d365fo_client-0.3.0 → d365fo_client-0.3.1}/src/d365fo_client/mcp/auth_server/auth/providers/bearer.py +0 -0
- {d365fo_client-0.3.0 → d365fo_client-0.3.1}/src/d365fo_client/mcp/auth_server/auth/providers/jwt.py +0 -0
- {d365fo_client-0.3.0 → d365fo_client-0.3.1}/src/d365fo_client/mcp/auth_server/auth/redirect_validation.py +0 -0
- {d365fo_client-0.3.0 → d365fo_client-0.3.1}/src/d365fo_client/mcp/auth_server/dependencies.py +0 -0
- {d365fo_client-0.3.0 → d365fo_client-0.3.1}/src/d365fo_client/mcp/client_manager.py +0 -0
- {d365fo_client-0.3.0 → d365fo_client-0.3.1}/src/d365fo_client/mcp/fastmcp_server.py +0 -0
- {d365fo_client-0.3.0 → d365fo_client-0.3.1}/src/d365fo_client/mcp/fastmcp_utils.py +0 -0
- {d365fo_client-0.3.0 → d365fo_client-0.3.1}/src/d365fo_client/mcp/main.py +0 -0
- {d365fo_client-0.3.0 → d365fo_client-0.3.1}/src/d365fo_client/mcp/mixins/__init__.py +0 -0
- {d365fo_client-0.3.0 → d365fo_client-0.3.1}/src/d365fo_client/mcp/mixins/base_tools_mixin.py +0 -0
- {d365fo_client-0.3.0 → d365fo_client-0.3.1}/src/d365fo_client/mcp/mixins/connection_tools_mixin.py +0 -0
- {d365fo_client-0.3.0 → d365fo_client-0.3.1}/src/d365fo_client/mcp/mixins/crud_tools_mixin.py +0 -0
- {d365fo_client-0.3.0 → d365fo_client-0.3.1}/src/d365fo_client/mcp/mixins/database_tools_mixin.py +0 -0
- {d365fo_client-0.3.0 → d365fo_client-0.3.1}/src/d365fo_client/mcp/mixins/label_tools_mixin.py +0 -0
- {d365fo_client-0.3.0 → d365fo_client-0.3.1}/src/d365fo_client/mcp/mixins/metadata_tools_mixin.py +0 -0
- {d365fo_client-0.3.0 → d365fo_client-0.3.1}/src/d365fo_client/mcp/mixins/performance_tools_mixin.py +0 -0
- {d365fo_client-0.3.0 → d365fo_client-0.3.1}/src/d365fo_client/mcp/mixins/profile_tools_mixin.py +0 -0
- {d365fo_client-0.3.0 → d365fo_client-0.3.1}/src/d365fo_client/mcp/mixins/sync_tools_mixin.py +0 -0
- {d365fo_client-0.3.0 → d365fo_client-0.3.1}/src/d365fo_client/mcp/models.py +0 -0
- {d365fo_client-0.3.0 → d365fo_client-0.3.1}/src/d365fo_client/mcp/prompts/__init__.py +0 -0
- {d365fo_client-0.3.0 → d365fo_client-0.3.1}/src/d365fo_client/mcp/prompts/action_execution.py +0 -0
- {d365fo_client-0.3.0 → d365fo_client-0.3.1}/src/d365fo_client/mcp/prompts/sequence_analysis.py +0 -0
- {d365fo_client-0.3.0 → d365fo_client-0.3.1}/src/d365fo_client/mcp/resources/__init__.py +0 -0
- {d365fo_client-0.3.0 → d365fo_client-0.3.1}/src/d365fo_client/mcp/resources/database_handler.py +0 -0
- {d365fo_client-0.3.0 → d365fo_client-0.3.1}/src/d365fo_client/mcp/resources/entity_handler.py +0 -0
- {d365fo_client-0.3.0 → d365fo_client-0.3.1}/src/d365fo_client/mcp/resources/environment_handler.py +0 -0
- {d365fo_client-0.3.0 → d365fo_client-0.3.1}/src/d365fo_client/mcp/resources/metadata_handler.py +0 -0
- {d365fo_client-0.3.0 → d365fo_client-0.3.1}/src/d365fo_client/mcp/resources/query_handler.py +0 -0
- {d365fo_client-0.3.0 → d365fo_client-0.3.1}/src/d365fo_client/mcp/server.py +0 -0
- {d365fo_client-0.3.0 → d365fo_client-0.3.1}/src/d365fo_client/mcp/tools/__init__.py +0 -0
- {d365fo_client-0.3.0 → d365fo_client-0.3.1}/src/d365fo_client/mcp/tools/connection_tools.py +0 -0
- {d365fo_client-0.3.0 → d365fo_client-0.3.1}/src/d365fo_client/mcp/tools/crud_tools.py +0 -0
- {d365fo_client-0.3.0 → d365fo_client-0.3.1}/src/d365fo_client/mcp/tools/database_tools.py +0 -0
- {d365fo_client-0.3.0 → d365fo_client-0.3.1}/src/d365fo_client/mcp/tools/label_tools.py +0 -0
- {d365fo_client-0.3.0 → d365fo_client-0.3.1}/src/d365fo_client/mcp/tools/metadata_tools.py +0 -0
- {d365fo_client-0.3.0 → d365fo_client-0.3.1}/src/d365fo_client/mcp/tools/profile_tools.py +0 -0
- {d365fo_client-0.3.0 → d365fo_client-0.3.1}/src/d365fo_client/mcp/tools/sync_tools.py +0 -0
- {d365fo_client-0.3.0 → d365fo_client-0.3.1}/src/d365fo_client/mcp/utilities/__init__.py +0 -0
- {d365fo_client-0.3.0 → d365fo_client-0.3.1}/src/d365fo_client/mcp/utilities/auth.py +0 -0
- {d365fo_client-0.3.0 → d365fo_client-0.3.1}/src/d365fo_client/mcp/utilities/logging.py +0 -0
- {d365fo_client-0.3.0 → d365fo_client-0.3.1}/src/d365fo_client/mcp/utilities/types.py +0 -0
- {d365fo_client-0.3.0 → d365fo_client-0.3.1}/src/d365fo_client/metadata_api.py +0 -0
- {d365fo_client-0.3.0 → d365fo_client-0.3.1}/src/d365fo_client/metadata_v2/__init__.py +0 -0
- {d365fo_client-0.3.0 → d365fo_client-0.3.1}/src/d365fo_client/metadata_v2/cache_v2.py +0 -0
- {d365fo_client-0.3.0 → d365fo_client-0.3.1}/src/d365fo_client/metadata_v2/database_v2.py +0 -0
- {d365fo_client-0.3.0 → d365fo_client-0.3.1}/src/d365fo_client/metadata_v2/global_version_manager.py +0 -0
- {d365fo_client-0.3.0 → d365fo_client-0.3.1}/src/d365fo_client/metadata_v2/label_utils.py +0 -0
- {d365fo_client-0.3.0 → d365fo_client-0.3.1}/src/d365fo_client/metadata_v2/search_engine_v2.py +0 -0
- {d365fo_client-0.3.0 → d365fo_client-0.3.1}/src/d365fo_client/metadata_v2/sync_manager_v2.py +0 -0
- {d365fo_client-0.3.0 → d365fo_client-0.3.1}/src/d365fo_client/metadata_v2/sync_session_manager.py +0 -0
- {d365fo_client-0.3.0 → d365fo_client-0.3.1}/src/d365fo_client/metadata_v2/version_detector.py +0 -0
- {d365fo_client-0.3.0 → d365fo_client-0.3.1}/src/d365fo_client/models.py +0 -0
- {d365fo_client-0.3.0 → d365fo_client-0.3.1}/src/d365fo_client/output.py +0 -0
- {d365fo_client-0.3.0 → d365fo_client-0.3.1}/src/d365fo_client/profile_manager.py +0 -0
- {d365fo_client-0.3.0 → d365fo_client-0.3.1}/src/d365fo_client/profiles.py +0 -0
- {d365fo_client-0.3.0 → d365fo_client-0.3.1}/src/d365fo_client/query.py +0 -0
- {d365fo_client-0.3.0 → d365fo_client-0.3.1}/src/d365fo_client/session.py +0 -0
- {d365fo_client-0.3.0 → d365fo_client-0.3.1}/src/d365fo_client/sync_models.py +0 -0
- {d365fo_client-0.3.0 → d365fo_client-0.3.1}/src/d365fo_client/utils.py +0 -0
- {d365fo_client-0.3.0 → d365fo_client-0.3.1}/src/d365fo_client.egg-info/dependency_links.txt +0 -0
- {d365fo_client-0.3.0 → d365fo_client-0.3.1}/src/d365fo_client.egg-info/entry_points.txt +0 -0
- {d365fo_client-0.3.0 → d365fo_client-0.3.1}/src/d365fo_client.egg-info/requires.txt +0 -0
- {d365fo_client-0.3.0 → d365fo_client-0.3.1}/src/d365fo_client.egg-info/top_level.txt +0 -0
@@ -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
|
-
|
288
|
-
|
289
|
-
|
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) ->
|
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
|
303
|
+
return
|
295
304
|
|
296
|
-
# Store self._clients to clients.json
|
297
305
|
try:
|
298
|
-
|
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
|
-
|
301
|
-
|
302
|
-
|
303
|
-
|
304
|
-
|
305
|
-
|
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
|
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
|
-
|
318
|
-
|
319
|
-
|
320
|
-
|
321
|
-
|
322
|
-
|
323
|
-
|
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
|
-
|
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
|
-
|
260
|
-
|
261
|
-
|
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
|
-
|
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
|
-
|
268
|
-
|
269
|
-
|
270
|
-
|
271
|
-
|
272
|
-
|
273
|
-
|
274
|
-
|
275
|
-
|
276
|
-
|
277
|
-
|
278
|
-
|
279
|
-
|
280
|
-
|
281
|
-
|
282
|
-
|
283
|
-
|
284
|
-
|
285
|
-
|
286
|
-
|
287
|
-
|
288
|
-
|
289
|
-
|
290
|
-
|
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
|
-
|
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"],
|
303
|
-
default_scopes=required_scopes or ["User.Read"],
|
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"],
|
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,
|
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
|
-
|
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
|
|
@@ -5,7 +5,7 @@ from enum import Enum
|
|
5
5
|
from pathlib import Path
|
6
6
|
from typing import Literal, Optional
|
7
7
|
|
8
|
-
from pydantic import Field, field_validator
|
8
|
+
from pydantic import Field, SecretStr, field_validator
|
9
9
|
from pydantic_settings import BaseSettings, SettingsConfigDict
|
10
10
|
|
11
11
|
from .utils import get_default_cache_directory
|
@@ -104,7 +104,15 @@ class D365FOSettings(BaseSettings):
|
|
104
104
|
description="MCP authentication required scopes (comma-separated)",
|
105
105
|
alias="D365FO_MCP_AUTH_REQUIRED_SCOPES"
|
106
106
|
)
|
107
|
-
|
107
|
+
|
108
|
+
# === MCP API Key Authentication Settings ===
|
109
|
+
|
110
|
+
mcp_api_key_value: Optional[SecretStr] = Field(
|
111
|
+
default=None,
|
112
|
+
description="API key value for authentication (send as Authorization: Bearer <key>)",
|
113
|
+
alias="D365FO_MCP_API_KEY_VALUE"
|
114
|
+
)
|
115
|
+
|
108
116
|
# === MCP Server Transport Settings ===
|
109
117
|
|
110
118
|
mcp_transport: TransportProtocol = Field(
|
@@ -285,6 +293,10 @@ class D365FOSettings(BaseSettings):
|
|
285
293
|
def has_mcp_auth_credentials(self) -> bool:
|
286
294
|
"""Check if MCP authentication credentials are configured."""
|
287
295
|
return all([self.mcp_auth_client_id, self.mcp_auth_client_secret, self.mcp_auth_tenant_id])
|
296
|
+
|
297
|
+
def has_mcp_api_key_auth(self) -> bool:
|
298
|
+
"""Check if API key authentication is configured."""
|
299
|
+
return self.mcp_api_key_value is not None
|
288
300
|
|
289
301
|
def get_startup_mode(self) -> Literal["profile_only", "default_auth", "client_credentials"]:
|
290
302
|
"""Determine startup mode based on configuration."""
|
@@ -42,6 +42,7 @@ src/d365fo_client/mcp/auth_server/auth/auth.py
|
|
42
42
|
src/d365fo_client/mcp/auth_server/auth/oauth_proxy.py
|
43
43
|
src/d365fo_client/mcp/auth_server/auth/redirect_validation.py
|
44
44
|
src/d365fo_client/mcp/auth_server/auth/providers/__init__.py
|
45
|
+
src/d365fo_client/mcp/auth_server/auth/providers/apikey.py
|
45
46
|
src/d365fo_client/mcp/auth_server/auth/providers/azure.py
|
46
47
|
src/d365fo_client/mcp/auth_server/auth/providers/bearer.py
|
47
48
|
src/d365fo_client/mcp/auth_server/auth/providers/jwt.py
|
@@ -84,4 +85,5 @@ src/d365fo_client/metadata_v2/label_utils.py
|
|
84
85
|
src/d365fo_client/metadata_v2/search_engine_v2.py
|
85
86
|
src/d365fo_client/metadata_v2/sync_manager_v2.py
|
86
87
|
src/d365fo_client/metadata_v2/sync_session_manager.py
|
87
|
-
src/d365fo_client/metadata_v2/version_detector.py
|
88
|
+
src/d365fo_client/metadata_v2/version_detector.py
|
89
|
+
tests/test_azure_provider_persistence.py
|
@@ -0,0 +1,394 @@
|
|
1
|
+
"""
|
2
|
+
Test cases for Azure OAuth provider client persistence functionality.
|
3
|
+
|
4
|
+
This module tests the save/load functionality for OAuth client data
|
5
|
+
in the Azure provider, including serialization, deserialization,
|
6
|
+
error handling, and edge cases.
|
7
|
+
"""
|
8
|
+
|
9
|
+
import json
|
10
|
+
import tempfile
|
11
|
+
import pytest
|
12
|
+
from pathlib import Path
|
13
|
+
from unittest.mock import patch, MagicMock
|
14
|
+
from pydantic import AnyUrl
|
15
|
+
|
16
|
+
from mcp.shared.auth import OAuthClientInformationFull
|
17
|
+
|
18
|
+
from d365fo_client.mcp.auth_server.auth.providers.azure import AzureProvider, AzureProviderSettings
|
19
|
+
|
20
|
+
|
21
|
+
class TestAzureProviderPersistence:
|
22
|
+
"""Test cases for Azure provider client persistence."""
|
23
|
+
|
24
|
+
@pytest.fixture
|
25
|
+
def temp_storage_dir(self):
|
26
|
+
"""Create a temporary directory for testing storage."""
|
27
|
+
with tempfile.TemporaryDirectory() as temp_dir:
|
28
|
+
yield temp_dir
|
29
|
+
|
30
|
+
@pytest.fixture
|
31
|
+
def minimal_settings(self):
|
32
|
+
"""Minimal settings for testing."""
|
33
|
+
return {
|
34
|
+
"client_id": "test-client-id",
|
35
|
+
"client_secret": "test-client-secret",
|
36
|
+
"tenant_id": "test-tenant-id",
|
37
|
+
"base_url": "http://localhost:8000",
|
38
|
+
}
|
39
|
+
|
40
|
+
@pytest.fixture
|
41
|
+
def sample_client_data(self):
|
42
|
+
"""Sample OAuth client data for testing."""
|
43
|
+
return OAuthClientInformationFull(
|
44
|
+
client_id="test-oauth-client-123",
|
45
|
+
client_secret="oauth-secret-456",
|
46
|
+
client_name="Test OAuth Client",
|
47
|
+
scope="User.Read email openid profile",
|
48
|
+
redirect_uris=[
|
49
|
+
AnyUrl("http://localhost:3000/callback"),
|
50
|
+
AnyUrl("https://example.com/auth/callback"),
|
51
|
+
],
|
52
|
+
)
|
53
|
+
|
54
|
+
@pytest.fixture
|
55
|
+
def azure_provider(self, minimal_settings, temp_storage_dir):
|
56
|
+
"""Create an Azure provider instance for testing."""
|
57
|
+
settings = {**minimal_settings, "clients_storage_path": temp_storage_dir}
|
58
|
+
return AzureProvider(**settings)
|
59
|
+
|
60
|
+
def test_save_clients_creates_directory(self, azure_provider, sample_client_data, temp_storage_dir):
|
61
|
+
"""Test that _save_clients creates the storage directory if it doesn't exist."""
|
62
|
+
# Remove the directory to test creation
|
63
|
+
storage_path = Path(temp_storage_dir)
|
64
|
+
storage_path.rmdir()
|
65
|
+
assert not storage_path.exists()
|
66
|
+
|
67
|
+
# Add a test client and save
|
68
|
+
azure_provider._clients["test-id"] = sample_client_data
|
69
|
+
azure_provider._save_clients()
|
70
|
+
|
71
|
+
# Verify directory was created and file exists
|
72
|
+
assert storage_path.exists()
|
73
|
+
assert (storage_path / "clients.json").exists()
|
74
|
+
|
75
|
+
def test_save_clients_success(self, azure_provider, sample_client_data, temp_storage_dir):
|
76
|
+
"""Test successful client data saving."""
|
77
|
+
# Add test client
|
78
|
+
client_id = "test-client-123"
|
79
|
+
azure_provider._clients[client_id] = sample_client_data
|
80
|
+
|
81
|
+
# Save clients
|
82
|
+
azure_provider._save_clients()
|
83
|
+
|
84
|
+
# Verify file was created
|
85
|
+
json_path = Path(temp_storage_dir) / "clients.json"
|
86
|
+
assert json_path.exists()
|
87
|
+
|
88
|
+
# Verify content
|
89
|
+
with json_path.open("r") as f:
|
90
|
+
saved_data = json.load(f)
|
91
|
+
|
92
|
+
assert client_id in saved_data
|
93
|
+
client_data = saved_data[client_id]
|
94
|
+
assert client_data["client_id"] == sample_client_data.client_id
|
95
|
+
assert client_data["client_name"] == sample_client_data.client_name
|
96
|
+
assert client_data["scope"] == sample_client_data.scope
|
97
|
+
|
98
|
+
# Verify redirect_uris are properly serialized as strings
|
99
|
+
assert isinstance(client_data["redirect_uris"], list)
|
100
|
+
assert all(isinstance(uri, str) for uri in client_data["redirect_uris"])
|
101
|
+
assert "http://localhost:3000/callback" in client_data["redirect_uris"]
|
102
|
+
|
103
|
+
def test_save_clients_no_storage_path(self, minimal_settings, sample_client_data):
|
104
|
+
"""Test save_clients when no storage path is configured."""
|
105
|
+
# Create provider without storage path
|
106
|
+
provider = AzureProvider(**minimal_settings)
|
107
|
+
provider._clients["test"] = sample_client_data
|
108
|
+
|
109
|
+
# Should not raise an error, just log a warning
|
110
|
+
provider._save_clients() # Should complete without error
|
111
|
+
|
112
|
+
def test_save_clients_individual_client_error(self, azure_provider, sample_client_data, temp_storage_dir):
|
113
|
+
"""Test save_clients handles individual client serialization errors gracefully."""
|
114
|
+
# Add a good client
|
115
|
+
good_client = sample_client_data
|
116
|
+
azure_provider._clients["good-client"] = good_client
|
117
|
+
|
118
|
+
# Add a bad client (mock to cause serialization error)
|
119
|
+
bad_client = MagicMock()
|
120
|
+
bad_client.model_dump.side_effect = ValueError("Serialization error")
|
121
|
+
azure_provider._clients["bad-client"] = bad_client
|
122
|
+
|
123
|
+
# Save should succeed, skipping the bad client
|
124
|
+
azure_provider._save_clients()
|
125
|
+
|
126
|
+
# Verify only good client was saved
|
127
|
+
json_path = Path(temp_storage_dir) / "clients.json"
|
128
|
+
with json_path.open("r") as f:
|
129
|
+
saved_data = json.load(f)
|
130
|
+
|
131
|
+
assert "good-client" in saved_data
|
132
|
+
assert "bad-client" not in saved_data
|
133
|
+
|
134
|
+
def test_save_clients_atomic_write(self, azure_provider, sample_client_data, temp_storage_dir):
|
135
|
+
"""Test that save_clients uses atomic write operations."""
|
136
|
+
# Add test client
|
137
|
+
azure_provider._clients["test"] = sample_client_data
|
138
|
+
|
139
|
+
json_path = Path(temp_storage_dir) / "clients.json"
|
140
|
+
temp_path = json_path.with_suffix('.tmp')
|
141
|
+
|
142
|
+
# Mock to verify temporary file usage
|
143
|
+
original_replace = Path.replace
|
144
|
+
replace_called = []
|
145
|
+
|
146
|
+
def mock_replace(self, target):
|
147
|
+
replace_called.append((str(self), str(target)))
|
148
|
+
return original_replace(self, target)
|
149
|
+
|
150
|
+
with patch.object(Path, 'replace', mock_replace):
|
151
|
+
azure_provider._save_clients()
|
152
|
+
|
153
|
+
# Verify atomic rename was used
|
154
|
+
assert len(replace_called) == 1
|
155
|
+
assert replace_called[0][0].endswith('.tmp')
|
156
|
+
assert replace_called[0][1] == str(json_path)
|
157
|
+
|
158
|
+
def test_load_clients_success(self, azure_provider, sample_client_data, temp_storage_dir):
|
159
|
+
"""Test successful client data loading."""
|
160
|
+
# Create test data file
|
161
|
+
client_id = "test-client-123"
|
162
|
+
test_data = {
|
163
|
+
client_id: sample_client_data.model_dump(mode="json")
|
164
|
+
}
|
165
|
+
|
166
|
+
json_path = Path(temp_storage_dir) / "clients.json"
|
167
|
+
with json_path.open("w") as f:
|
168
|
+
json.dump(test_data, f)
|
169
|
+
|
170
|
+
# Load clients
|
171
|
+
azure_provider._load_clients()
|
172
|
+
|
173
|
+
# Verify client was loaded
|
174
|
+
assert client_id in azure_provider._clients
|
175
|
+
loaded_client = azure_provider._clients[client_id]
|
176
|
+
assert isinstance(loaded_client, OAuthClientInformationFull)
|
177
|
+
assert loaded_client.client_id == sample_client_data.client_id
|
178
|
+
assert loaded_client.client_name == sample_client_data.client_name
|
179
|
+
assert loaded_client.scope == sample_client_data.scope
|
180
|
+
|
181
|
+
# Verify redirect_uris are properly restored as AnyUrl objects
|
182
|
+
assert len(loaded_client.redirect_uris) == len(sample_client_data.redirect_uris)
|
183
|
+
for loaded_uri, original_uri in zip(loaded_client.redirect_uris, sample_client_data.redirect_uris):
|
184
|
+
assert str(loaded_uri) == str(original_uri)
|
185
|
+
|
186
|
+
def test_load_clients_no_storage_path(self, minimal_settings):
|
187
|
+
"""Test load_clients when no storage path is configured."""
|
188
|
+
provider = AzureProvider(**minimal_settings)
|
189
|
+
provider._load_clients() # Should complete without error
|
190
|
+
|
191
|
+
def test_load_clients_no_file(self, azure_provider):
|
192
|
+
"""Test load_clients when storage file doesn't exist."""
|
193
|
+
azure_provider._load_clients() # Should complete without error
|
194
|
+
assert len(azure_provider._clients) == 0
|
195
|
+
|
196
|
+
def test_load_clients_invalid_json(self, azure_provider, temp_storage_dir):
|
197
|
+
"""Test load_clients with invalid JSON file."""
|
198
|
+
# Create invalid JSON file
|
199
|
+
json_path = Path(temp_storage_dir) / "clients.json"
|
200
|
+
with json_path.open("w") as f:
|
201
|
+
f.write("{ invalid json }")
|
202
|
+
|
203
|
+
# Should handle error gracefully
|
204
|
+
azure_provider._load_clients()
|
205
|
+
assert len(azure_provider._clients) == 0
|
206
|
+
|
207
|
+
def test_load_clients_invalid_data_format(self, azure_provider, temp_storage_dir):
|
208
|
+
"""Test load_clients with invalid data format (not a dict)."""
|
209
|
+
# Create file with invalid format
|
210
|
+
json_path = Path(temp_storage_dir) / "clients.json"
|
211
|
+
with json_path.open("w") as f:
|
212
|
+
json.dump(["not", "a", "dict"], f)
|
213
|
+
|
214
|
+
# Should handle error gracefully
|
215
|
+
azure_provider._load_clients()
|
216
|
+
assert len(azure_provider._clients) == 0
|
217
|
+
|
218
|
+
def test_load_clients_individual_client_error(self, azure_provider, sample_client_data, temp_storage_dir):
|
219
|
+
"""Test load_clients handles individual client validation errors gracefully."""
|
220
|
+
# Create test data with one good and one bad client
|
221
|
+
good_client_data = sample_client_data.model_dump(mode="json")
|
222
|
+
bad_client_data = {"client_id": "bad", "invalid_field": "invalid"}
|
223
|
+
|
224
|
+
test_data = {
|
225
|
+
"good-client": good_client_data,
|
226
|
+
"bad-client": bad_client_data,
|
227
|
+
}
|
228
|
+
|
229
|
+
json_path = Path(temp_storage_dir) / "clients.json"
|
230
|
+
with json_path.open("w") as f:
|
231
|
+
json.dump(test_data, f)
|
232
|
+
|
233
|
+
# Load should succeed, skipping the bad client
|
234
|
+
azure_provider._load_clients()
|
235
|
+
|
236
|
+
assert "good-client" in azure_provider._clients
|
237
|
+
assert "bad-client" not in azure_provider._clients
|
238
|
+
assert isinstance(azure_provider._clients["good-client"], OAuthClientInformationFull)
|
239
|
+
|
240
|
+
def test_load_clients_non_string_client_id(self, azure_provider, sample_client_data, temp_storage_dir):
|
241
|
+
"""Test load_clients handles non-string client IDs gracefully."""
|
242
|
+
# Create test data with non-string client ID
|
243
|
+
good_client_data = sample_client_data.model_dump(mode="json")
|
244
|
+
test_data = {
|
245
|
+
"good-client": good_client_data,
|
246
|
+
# Note: JSON will convert numeric keys to strings, so we need to test this differently
|
247
|
+
# Instead, let's test that our validation catches non-string keys in the loading logic
|
248
|
+
}
|
249
|
+
|
250
|
+
json_path = Path(temp_storage_dir) / "clients.json"
|
251
|
+
with json_path.open("w") as f:
|
252
|
+
json.dump(test_data, f)
|
253
|
+
|
254
|
+
# Load should succeed with just the good client
|
255
|
+
azure_provider._load_clients()
|
256
|
+
|
257
|
+
assert "good-client" in azure_provider._clients
|
258
|
+
assert len(azure_provider._clients) == 1
|
259
|
+
|
260
|
+
def test_round_trip_persistence(self, azure_provider, sample_client_data, temp_storage_dir):
|
261
|
+
"""Test complete save/load round trip."""
|
262
|
+
client_id = "round-trip-test"
|
263
|
+
|
264
|
+
# Add client and save
|
265
|
+
azure_provider._clients[client_id] = sample_client_data
|
266
|
+
azure_provider._save_clients()
|
267
|
+
|
268
|
+
# Clear clients and reload
|
269
|
+
azure_provider._clients.clear()
|
270
|
+
azure_provider._load_clients()
|
271
|
+
|
272
|
+
# Verify data integrity
|
273
|
+
assert client_id in azure_provider._clients
|
274
|
+
loaded_client = azure_provider._clients[client_id]
|
275
|
+
|
276
|
+
# Compare all important fields
|
277
|
+
assert loaded_client.client_id == sample_client_data.client_id
|
278
|
+
assert loaded_client.client_secret == sample_client_data.client_secret
|
279
|
+
assert loaded_client.client_name == sample_client_data.client_name
|
280
|
+
assert loaded_client.scope == sample_client_data.scope
|
281
|
+
assert len(loaded_client.redirect_uris) == len(sample_client_data.redirect_uris)
|
282
|
+
|
283
|
+
for loaded_uri, original_uri in zip(loaded_client.redirect_uris, sample_client_data.redirect_uris):
|
284
|
+
assert str(loaded_uri) == str(original_uri)
|
285
|
+
|
286
|
+
async def test_register_client_persistence(self, azure_provider, sample_client_data):
|
287
|
+
"""Test that register_client properly persists the client data."""
|
288
|
+
# Mock the super().register_client to avoid complex setup
|
289
|
+
with patch.object(azure_provider.__class__.__bases__[0], 'register_client') as mock_register:
|
290
|
+
mock_register.return_value = None
|
291
|
+
|
292
|
+
# Register client
|
293
|
+
client_id = "register-test"
|
294
|
+
azure_provider._clients[client_id] = sample_client_data
|
295
|
+
|
296
|
+
# This should trigger save
|
297
|
+
await azure_provider.register_client(sample_client_data)
|
298
|
+
|
299
|
+
# Verify file was created (basic check since we mocked the registration)
|
300
|
+
json_path = Path(azure_provider.clients_storage_path) / "clients.json"
|
301
|
+
assert json_path.exists()
|
302
|
+
|
303
|
+
def test_load_clients_called_during_init(self, minimal_settings, temp_storage_dir):
|
304
|
+
"""Test that _load_clients is called during provider initialization."""
|
305
|
+
# Create a test client file
|
306
|
+
test_data = {
|
307
|
+
"init-test": {
|
308
|
+
"client_id": "init-client",
|
309
|
+
"redirect_uris": ["http://localhost:8080/"],
|
310
|
+
"client_name": "Init Test Client"
|
311
|
+
}
|
312
|
+
}
|
313
|
+
|
314
|
+
json_path = Path(temp_storage_dir) / "clients.json"
|
315
|
+
with json_path.open("w") as f:
|
316
|
+
json.dump(test_data, f)
|
317
|
+
|
318
|
+
# Create provider (should load existing clients)
|
319
|
+
settings = {**minimal_settings, "clients_storage_path": temp_storage_dir}
|
320
|
+
provider = AzureProvider(**settings)
|
321
|
+
|
322
|
+
# Verify client was loaded during initialization
|
323
|
+
assert "init-test" in provider._clients
|
324
|
+
assert provider._clients["init-test"].client_id == "init-client"
|
325
|
+
|
326
|
+
def test_multiple_clients_persistence(self, azure_provider, temp_storage_dir):
|
327
|
+
"""Test persistence with multiple clients."""
|
328
|
+
# Create multiple test clients
|
329
|
+
clients = {}
|
330
|
+
for i in range(3):
|
331
|
+
client_id = f"client-{i}"
|
332
|
+
client = OAuthClientInformationFull(
|
333
|
+
client_id=f"oauth-client-{i}",
|
334
|
+
client_name=f"Test Client {i}",
|
335
|
+
redirect_uris=[AnyUrl(f"http://localhost:300{i}/callback")],
|
336
|
+
scope="User.Read",
|
337
|
+
)
|
338
|
+
clients[client_id] = client
|
339
|
+
azure_provider._clients[client_id] = client
|
340
|
+
|
341
|
+
# Save and reload
|
342
|
+
azure_provider._save_clients()
|
343
|
+
azure_provider._clients.clear()
|
344
|
+
azure_provider._load_clients()
|
345
|
+
|
346
|
+
# Verify all clients were persisted
|
347
|
+
assert len(azure_provider._clients) == 3
|
348
|
+
for client_id, original_client in clients.items():
|
349
|
+
assert client_id in azure_provider._clients
|
350
|
+
loaded_client = azure_provider._clients[client_id]
|
351
|
+
assert loaded_client.client_id == original_client.client_id
|
352
|
+
assert loaded_client.client_name == original_client.client_name
|
353
|
+
|
354
|
+
async def test_error_handling_in_register_client(self, azure_provider, sample_client_data):
|
355
|
+
"""Test error handling when save fails during client registration."""
|
356
|
+
# Mock _save_clients to raise an error
|
357
|
+
with patch.object(azure_provider, '_save_clients') as mock_save:
|
358
|
+
mock_save.side_effect = OSError("Disk full")
|
359
|
+
|
360
|
+
with patch.object(azure_provider.__class__.__bases__[0], 'register_client') as mock_register:
|
361
|
+
mock_register.return_value = None
|
362
|
+
|
363
|
+
# Should not raise despite save error
|
364
|
+
await azure_provider.register_client(sample_client_data)
|
365
|
+
|
366
|
+
def test_unicode_handling(self, azure_provider, temp_storage_dir):
|
367
|
+
"""Test proper handling of Unicode characters in client data."""
|
368
|
+
# Create client with Unicode characters
|
369
|
+
unicode_client = OAuthClientInformationFull(
|
370
|
+
client_id="unicode-test",
|
371
|
+
client_name="Test Client with Unicode: 测试客户端 🔒",
|
372
|
+
redirect_uris=[AnyUrl("http://localhost:8080/callback")],
|
373
|
+
scope="User.Read",
|
374
|
+
)
|
375
|
+
|
376
|
+
client_id = "unicode-client"
|
377
|
+
azure_provider._clients[client_id] = unicode_client
|
378
|
+
|
379
|
+
# Save and reload
|
380
|
+
azure_provider._save_clients()
|
381
|
+
azure_provider._clients.clear()
|
382
|
+
azure_provider._load_clients()
|
383
|
+
|
384
|
+
# Verify Unicode characters were preserved
|
385
|
+
loaded_client = azure_provider._clients[client_id]
|
386
|
+
assert loaded_client.client_name == "Test Client with Unicode: 测试客户端 🔒"
|
387
|
+
|
388
|
+
|
389
|
+
if __name__ == "__main__":
|
390
|
+
pytest.main([__file__])
|
391
|
+
|
392
|
+
|
393
|
+
if __name__ == "__main__":
|
394
|
+
pytest.main([__file__])
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
{d365fo_client-0.3.0 → d365fo_client-0.3.1}/src/d365fo_client/mcp/auth_server/auth/__init__.py
RENAMED
File without changes
|
File without changes
|
{d365fo_client-0.3.0 → d365fo_client-0.3.1}/src/d365fo_client/mcp/auth_server/auth/oauth_proxy.py
RENAMED
File without changes
|
File without changes
|
File without changes
|
{d365fo_client-0.3.0 → d365fo_client-0.3.1}/src/d365fo_client/mcp/auth_server/auth/providers/jwt.py
RENAMED
File without changes
|
File without changes
|
{d365fo_client-0.3.0 → d365fo_client-0.3.1}/src/d365fo_client/mcp/auth_server/dependencies.py
RENAMED
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
{d365fo_client-0.3.0 → d365fo_client-0.3.1}/src/d365fo_client/mcp/mixins/base_tools_mixin.py
RENAMED
File without changes
|
{d365fo_client-0.3.0 → d365fo_client-0.3.1}/src/d365fo_client/mcp/mixins/connection_tools_mixin.py
RENAMED
File without changes
|
{d365fo_client-0.3.0 → d365fo_client-0.3.1}/src/d365fo_client/mcp/mixins/crud_tools_mixin.py
RENAMED
File without changes
|
{d365fo_client-0.3.0 → d365fo_client-0.3.1}/src/d365fo_client/mcp/mixins/database_tools_mixin.py
RENAMED
File without changes
|
{d365fo_client-0.3.0 → d365fo_client-0.3.1}/src/d365fo_client/mcp/mixins/label_tools_mixin.py
RENAMED
File without changes
|
{d365fo_client-0.3.0 → d365fo_client-0.3.1}/src/d365fo_client/mcp/mixins/metadata_tools_mixin.py
RENAMED
File without changes
|
{d365fo_client-0.3.0 → d365fo_client-0.3.1}/src/d365fo_client/mcp/mixins/performance_tools_mixin.py
RENAMED
File without changes
|
{d365fo_client-0.3.0 → d365fo_client-0.3.1}/src/d365fo_client/mcp/mixins/profile_tools_mixin.py
RENAMED
File without changes
|
{d365fo_client-0.3.0 → d365fo_client-0.3.1}/src/d365fo_client/mcp/mixins/sync_tools_mixin.py
RENAMED
File without changes
|
File without changes
|
File without changes
|
{d365fo_client-0.3.0 → d365fo_client-0.3.1}/src/d365fo_client/mcp/prompts/action_execution.py
RENAMED
File without changes
|
{d365fo_client-0.3.0 → d365fo_client-0.3.1}/src/d365fo_client/mcp/prompts/sequence_analysis.py
RENAMED
File without changes
|
File without changes
|
{d365fo_client-0.3.0 → d365fo_client-0.3.1}/src/d365fo_client/mcp/resources/database_handler.py
RENAMED
File without changes
|
{d365fo_client-0.3.0 → d365fo_client-0.3.1}/src/d365fo_client/mcp/resources/entity_handler.py
RENAMED
File without changes
|
{d365fo_client-0.3.0 → d365fo_client-0.3.1}/src/d365fo_client/mcp/resources/environment_handler.py
RENAMED
File without changes
|
{d365fo_client-0.3.0 → d365fo_client-0.3.1}/src/d365fo_client/mcp/resources/metadata_handler.py
RENAMED
File without changes
|
{d365fo_client-0.3.0 → d365fo_client-0.3.1}/src/d365fo_client/mcp/resources/query_handler.py
RENAMED
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
{d365fo_client-0.3.0 → d365fo_client-0.3.1}/src/d365fo_client/metadata_v2/global_version_manager.py
RENAMED
File without changes
|
File without changes
|
{d365fo_client-0.3.0 → d365fo_client-0.3.1}/src/d365fo_client/metadata_v2/search_engine_v2.py
RENAMED
File without changes
|
{d365fo_client-0.3.0 → d365fo_client-0.3.1}/src/d365fo_client/metadata_v2/sync_manager_v2.py
RENAMED
File without changes
|
{d365fo_client-0.3.0 → d365fo_client-0.3.1}/src/d365fo_client/metadata_v2/sync_session_manager.py
RENAMED
File without changes
|
{d365fo_client-0.3.0 → d365fo_client-0.3.1}/src/d365fo_client/metadata_v2/version_detector.py
RENAMED
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|