Skip to main content
Glama
settings.py12.3 kB
"""Configuration settings module for SSO MCP Server. Loads configuration from environment variables with validation. Supports dual-mode authentication: LOCAL (OAuth client) and CLOUD (Resource Server). """ from __future__ import annotations import os from dataclasses import dataclass, field from enum import Enum from pathlib import Path from typing import ClassVar from dotenv import load_dotenv class AuthMode(Enum): """Authentication mode for the MCP server. LOCAL: Server acts as OAuth client, acquires tokens via browser SSO. CLOUD: Server acts as Resource Server, validates incoming Bearer tokens. AUTO: Automatically detect mode based on request context. """ LOCAL = "local" CLOUD = "cloud" AUTO = "auto" class ConfigurationError(Exception): """Raised when configuration is invalid or missing. Attributes: message: Description of the configuration error. action: Actionable guidance on how to fix the error. """ def __init__(self, message: str, action: str | None = None) -> None: """Initialize ConfigurationError. Args: message: Description of the configuration error. action: Actionable guidance on how to fix the error. """ self.message = message self.action = action super().__init__(message) def __str__(self) -> str: """Return formatted error message with action guidance.""" if self.action: return f"{self.message}\n Action: {self.action}" return self.message @dataclass class Settings: """Application settings loaded from environment variables. Supports dual-mode authentication: - LOCAL mode: Server acts as OAuth client (requires azure_client_id, azure_tenant_id) - CLOUD mode: Server acts as Resource Server (requires resource_identifier, allowed_issuers) Attributes: auth_mode: Authentication mode (LOCAL, CLOUD, AUTO). azure_client_id: Azure App Registration client ID (LOCAL mode). azure_tenant_id: Azure tenant ID (LOCAL mode). resource_identifier: This server's resource URL for audience validation (CLOUD mode). allowed_issuers: List of allowed token issuers (CLOUD mode). jwks_cache_ttl: JWKS cache time-to-live in seconds (CLOUD mode). scopes_supported: List of supported scopes to advertise (CLOUD mode). checklist_dir: Path to checklist files directory. process_dir: Path to process files directory. mcp_port: Port for MCP HTTP server. log_level: Logging level (DEBUG, INFO, WARNING, ERROR). token_cache_path: Path to encrypted token cache file (LOCAL mode). """ # Authentication mode auth_mode: AuthMode = AuthMode.LOCAL # LOCAL mode settings (OAuth client) azure_client_id: str = "" azure_tenant_id: str = "" token_cache_path: Path = field( default_factory=lambda: Path.home() / ".sso-mcp-server" / "token_cache.bin" ) # CLOUD mode settings (Resource Server) resource_identifier: str = "" allowed_issuers: list[str] = field(default_factory=list) jwks_cache_ttl: int = 3600 scopes_supported: list[str] = field(default_factory=list) # Common settings checklist_dir: Path = field(default_factory=lambda: Path("./checklists")) process_dir: Path = field(default_factory=lambda: Path("./processes")) mcp_port: int = 8080 log_level: str = "INFO" # Valid log levels VALID_LOG_LEVELS: ClassVar[set[str]] = {"DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"} def __post_init__(self) -> None: """Validate settings after initialization.""" self._validate() def _validate(self) -> None: """Validate all settings. Validates mode-specific requirements: - LOCAL mode: requires azure_client_id and azure_tenant_id - CLOUD mode: requires resource_identifier and allowed_issuers - AUTO mode: allows either configuration Raises: ConfigurationError: If any setting is invalid. """ if self.auth_mode == AuthMode.LOCAL: self._validate_local_mode() elif self.auth_mode == AuthMode.CLOUD: self._validate_cloud_mode() self._validate_common() def _validate_local_mode(self) -> None: """Validate LOCAL mode requirements.""" if not self.azure_client_id: raise ConfigurationError( "AZURE_CLIENT_ID is required for LOCAL auth mode", action="Set AZURE_CLIENT_ID environment variable or add it to .env file. " "Get this from Azure Portal > App registrations > Application (client) ID.", ) if not self.azure_tenant_id: raise ConfigurationError( "AZURE_TENANT_ID is required for LOCAL auth mode", action="Set AZURE_TENANT_ID environment variable or add it to .env file. " "Get this from Azure Portal > App registrations > Directory (tenant) ID.", ) def _validate_cloud_mode(self) -> None: """Validate CLOUD mode requirements.""" if not self.resource_identifier: raise ConfigurationError( "RESOURCE_IDENTIFIER is required for CLOUD auth mode", action=( "Set RESOURCE_IDENTIFIER to this server's URL " "(e.g., https://mcp.example.com). " "This is used for token audience validation (RFC 8707)." ), ) if not self.allowed_issuers: raise ConfigurationError( "ALLOWED_ISSUERS is required for CLOUD auth mode", action="Set ALLOWED_ISSUERS to comma-separated list of trusted token issuers. " "Example: https://login.microsoftonline.com/your-tenant/v2.0", ) if not self.resource_identifier.startswith(("http://", "https://")): raise ConfigurationError( f"RESOURCE_IDENTIFIER must be a valid URL, got: {self.resource_identifier}", action="Set RESOURCE_IDENTIFIER to a full URL including scheme (https://...).", ) for issuer in self.allowed_issuers: if not issuer.startswith(("http://", "https://")): raise ConfigurationError( f"ALLOWED_ISSUERS must contain valid URLs, got: {issuer}", action="Ensure all issuers are full URLs including scheme (https://...).", ) def _validate_common(self) -> None: """Validate common settings for all modes.""" if self.jwks_cache_ttl < 0: raise ConfigurationError( f"JWKS_CACHE_TTL must be non-negative, got {self.jwks_cache_ttl}", action="Set JWKS_CACHE_TTL to a positive number of seconds (default: 3600).", ) if not self.checklist_dir.exists(): raise ConfigurationError( f"CHECKLIST_DIR does not exist: {self.checklist_dir}", action=f"Create the directory: mkdir -p {self.checklist_dir}", ) if not self.checklist_dir.is_dir(): raise ConfigurationError( f"CHECKLIST_DIR is not a directory: {self.checklist_dir}", action="Ensure CHECKLIST_DIR points to a directory, not a file.", ) # Process directory validation - lenient (doesn't fail if missing) if self.process_dir.exists() and not self.process_dir.is_dir(): raise ConfigurationError( f"PROCESS_DIR is not a directory: {self.process_dir}", action="Ensure PROCESS_DIR points to a directory, not a file.", ) if not 1024 <= self.mcp_port <= 65535: raise ConfigurationError( f"MCP_PORT must be between 1024 and 65535, got {self.mcp_port}", action="Use a port number between 1024 and 65535. Default is 8080.", ) if self.log_level.upper() not in self.VALID_LOG_LEVELS: raise ConfigurationError( f"LOG_LEVEL must be one of {sorted(self.VALID_LOG_LEVELS)}, got {self.log_level}", action="Set LOG_LEVEL to one of: DEBUG, INFO, WARNING, ERROR, CRITICAL.", ) @classmethod def from_env(cls, env_file: Path | None = None) -> Settings: """Load settings from environment variables. Args: env_file: Optional path to .env file to load. Returns: Settings instance with values from environment. Raises: ConfigurationError: If required environment variables are missing or invalid. """ if env_file: load_dotenv(env_file) else: load_dotenv() # Parse auth mode auth_mode_str = os.getenv("AUTH_MODE", "local").lower() try: auth_mode = AuthMode(auth_mode_str) except ValueError as e: valid_modes = [m.value for m in AuthMode] raise ConfigurationError( f"AUTH_MODE must be one of {valid_modes}, got '{auth_mode_str}'", action="Set AUTH_MODE to 'local', 'cloud', or 'auto'.", ) from e # Parse checklist directory checklist_dir_str = os.getenv("CHECKLIST_DIR", "./checklists") checklist_dir = Path(checklist_dir_str).resolve() # Parse process directory process_dir_str = os.getenv("PROCESS_DIR", "./processes") process_dir = Path(process_dir_str).resolve() # Parse MCP port mcp_port_str = os.getenv("MCP_PORT", "8080") try: mcp_port = int(mcp_port_str) except ValueError as e: raise ConfigurationError( f"MCP_PORT must be an integer, got '{mcp_port_str}'", action="Set MCP_PORT to a valid integer port number (e.g., 8080).", ) from e # Parse token cache path (LOCAL mode) token_cache_str = os.getenv("TOKEN_CACHE_PATH") if token_cache_str: token_cache_path = Path(token_cache_str).expanduser() else: token_cache_path = Path.home() / ".sso-mcp-server" / "token_cache.bin" # Parse allowed issuers (CLOUD mode) - comma-separated allowed_issuers_str = os.getenv("ALLOWED_ISSUERS", "") allowed_issuers = [ issuer.strip() for issuer in allowed_issuers_str.split(",") if issuer.strip() ] # Parse supported scopes (CLOUD mode) - comma-separated scopes_supported_str = os.getenv("SCOPES_SUPPORTED", "") scopes_supported = [ scope.strip() for scope in scopes_supported_str.split(",") if scope.strip() ] # Parse JWKS cache TTL jwks_cache_ttl_str = os.getenv("JWKS_CACHE_TTL", "3600") try: jwks_cache_ttl = int(jwks_cache_ttl_str) except ValueError as e: raise ConfigurationError( f"JWKS_CACHE_TTL must be an integer, got '{jwks_cache_ttl_str}'", action="Set JWKS_CACHE_TTL to a valid integer (seconds).", ) from e return cls( auth_mode=auth_mode, azure_client_id=os.getenv("AZURE_CLIENT_ID", ""), azure_tenant_id=os.getenv("AZURE_TENANT_ID", ""), token_cache_path=token_cache_path, resource_identifier=os.getenv("RESOURCE_IDENTIFIER", ""), allowed_issuers=allowed_issuers, jwks_cache_ttl=jwks_cache_ttl, scopes_supported=scopes_supported, checklist_dir=checklist_dir, process_dir=process_dir, mcp_port=mcp_port, log_level=os.getenv("LOG_LEVEL", "INFO").upper(), ) # Global settings instance (lazily initialized) _settings: Settings | None = None def get_settings() -> Settings: """Get the global settings instance. Returns: Settings instance loaded from environment. """ global _settings # noqa: PLW0603 if _settings is None: _settings = Settings.from_env() return _settings def reset_settings() -> None: """Reset the global settings instance (for testing).""" global _settings # noqa: PLW0603 _settings = None

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/DauQuangThanh/sso-mcp-server'

If you have feedback or need assistance with the MCP directory API, please join our Discord server