Skip to content

Commit 4b4eb1c

Browse files
feat: Lazy initialization (#1492)
* refactor: Remove proactive imports from top-level __init__.py Branch: LazyImports Signed-off-by: Gabe Goodhart <[email protected]> * feat: Add client_mode to settings This allows client-side apps such as the CLI to use the settings without all of the server-side initialization and validation. Branch: LazySettings Signed-off-by: Gabe Goodhart <[email protected]> * feat: Create a lazy wrapper around the `settings` singleton The underlying object will only be instantiated when an attribute is accessed. This will enable non-server uses of the library to carefully set up settings before triggering the creation of the singleton. NOTE: We still need to plumb-out all of the import-time settings use! Branch: LazySettings Signed-off-by: Gabe Goodhart <[email protected]> * fix: Fix warning skipping for settings in client mode Branch: LazyImports Signed-off-by: Gabe Goodhart <[email protected]> * test: Re-clear cache after testing LRU with the Settings class mocked Branch: LazyImports Signed-off-by: Gabe Goodhart <[email protected]> * fix: Fix flake8 docstring issues Also remove the ability to give an arbitrary get_settings function to the lazy wrapper. This for some reason was not passing flake8 even with the docstring fixed. Branch: LazyImports Signed-off-by: Gabe Goodhart <[email protected]> * fix: Correct type annotation for get_settings kwargs parameter The **kwargs type hint was incorrectly specified as Dict[str, Any], which implies each value is a Dict. The correct annotation is Any, indicating each value in kwargs can be of any type. Signed-off-by: Mihai Criveti <[email protected]> --------- Signed-off-by: Gabe Goodhart <[email protected]> Signed-off-by: Mihai Criveti <[email protected]> Co-authored-by: Mihai Criveti <[email protected]>
1 parent 36aaf65 commit 4b4eb1c

File tree

3 files changed

+86
-64
lines changed

3 files changed

+86
-64
lines changed

mcpgateway/__init__.py

Lines changed: 0 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -15,15 +15,3 @@
1515
__url__ = "https://ibm.github.io/mcp-context-forge/"
1616
__download_url__ = "https://github.com/IBM/mcp-context-forge"
1717
__packages__ = ["mcpgateway"]
18-
19-
from mcpgateway import reverse_proxy, wrapper, translate
20-
21-
# Export main components for easier imports
22-
__all__ = [
23-
"__version__",
24-
"__author__",
25-
"__license__",
26-
"reverse_proxy",
27-
"wrapper",
28-
"translate",
29-
]

mcpgateway/config.py

Lines changed: 73 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,7 @@ class Settings(BaseSettings):
152152
app_name: str = "MCP_Gateway"
153153
host: str = "127.0.0.1"
154154
port: PositiveInt = Field(default=4444, ge=1, le=65535)
155+
client_mode: bool = False
155156
docs_allow_basic_auth: bool = False # Allow basic auth for docs
156157
database_url: str = "sqlite:///./mcp.db"
157158

@@ -448,28 +449,30 @@ def validate_secrets(cls, v: Any, info: ValidationInfo) -> SecretStr:
448449
value = str(v)
449450

450451
# Check for default/weak secrets
451-
weak_secrets = ["my-test-key", "my-test-salt", "changeme", "secret", "password"]
452-
if value.lower() in weak_secrets:
453-
logger.warning(f"🔓 SECURITY WARNING - {field_name}: Default/weak secret detected! Please set a strong, unique value for production.")
452+
if not info.data.get("client_mode"):
453+
weak_secrets = ["my-test-key", "my-test-salt", "changeme", "secret", "password"]
454+
if value.lower() in weak_secrets:
455+
logger.warning(f"🔓 SECURITY WARNING - {field_name}: Default/weak secret detected! Please set a strong, unique value for production.")
454456

455-
# Check minimum length
456-
if len(value) < 32:
457-
logger.warning(f"⚠️ SECURITY WARNING - {field_name}: Secret should be at least 32 characters long. Current length: {len(value)}")
457+
# Check minimum length
458+
if len(value) < 32:
459+
logger.warning(f"⚠️ SECURITY WARNING - {field_name}: Secret should be at least 32 characters long. Current length: {len(value)}")
458460

459-
# Basic entropy check (at least 10 unique characters)
460-
if len(set(value)) < 10:
461-
logger.warning(f"🔑 SECURITY WARNING - {field_name}: Secret has low entropy. Consider using a more random value.")
461+
# Basic entropy check (at least 10 unique characters)
462+
if len(set(value)) < 10:
463+
logger.warning(f"🔑 SECURITY WARNING - {field_name}: Secret has low entropy. Consider using a more random value.")
462464

463465
# Always return SecretStr to keep it secret-safe
464466
return v if isinstance(v, SecretStr) else SecretStr(value)
465467

466468
@field_validator("basic_auth_password")
467469
@classmethod
468-
def validate_admin_password(cls, v: str | SecretStr) -> SecretStr:
470+
def validate_admin_password(cls, v: str | SecretStr, info: ValidationInfo) -> SecretStr:
469471
"""Validate admin password meets security requirements.
470472
471473
Args:
472474
v: The admin password value to validate.
475+
info: ValidationInfo containing field data.
473476
474477
Returns:
475478
SecretStr: The validated admin password value, wrapped as SecretStr.
@@ -480,35 +483,37 @@ def validate_admin_password(cls, v: str | SecretStr) -> SecretStr:
480483
else:
481484
value = v
482485

483-
if value == "changeme": # nosec B105 - checking for default value
484-
logger.warning("🔓 SECURITY WARNING: Default admin password detected! Please change the BASIC_AUTH_PASSWORD immediately.")
486+
if not info.data.get("client_mode"):
487+
if value == "changeme": # nosec B105 - checking for default value
488+
logger.warning("🔓 SECURITY WARNING: Default admin password detected! Please change the BASIC_AUTH_PASSWORD immediately.")
485489

486-
# Note: We can't access password_min_length here as it's not set yet during validation
487-
# Using default value of 8 to match the field default
488-
min_length = 8 # This matches the default in password_min_length field
489-
if len(value) < min_length:
490-
logger.warning(f"⚠️ SECURITY WARNING: Admin password should be at least {min_length} characters long. Current length: {len(value)}")
490+
# Note: We can't access password_min_length here as it's not set yet during validation
491+
# Using default value of 8 to match the field default
492+
min_length = 8 # This matches the default in password_min_length field
493+
if len(value) < min_length:
494+
logger.warning(f"⚠️ SECURITY WARNING: Admin password should be at least {min_length} characters long. Current length: {len(value)}")
491495

492-
# Check password complexity
493-
has_upper = any(c.isupper() for c in value)
494-
has_lower = any(c.islower() for c in value)
495-
has_digit = any(c.isdigit() for c in value)
496-
has_special = bool(re.search(r'[!@#$%^&*(),.?":{}|<>]', value))
496+
# Check password complexity
497+
has_upper = any(c.isupper() for c in value)
498+
has_lower = any(c.islower() for c in value)
499+
has_digit = any(c.isdigit() for c in value)
500+
has_special = bool(re.search(r'[!@#$%^&*(),.?":{}|<>]', value))
497501

498-
complexity_score = sum([has_upper, has_lower, has_digit, has_special])
499-
if complexity_score < 3:
500-
logger.warning("🔐 SECURITY WARNING: Admin password has low complexity. Should contain at least 3 of: uppercase, lowercase, digits, special characters")
502+
complexity_score = sum([has_upper, has_lower, has_digit, has_special])
503+
if complexity_score < 3:
504+
logger.warning("🔐 SECURITY WARNING: Admin password has low complexity. Should contain at least 3 of: uppercase, lowercase, digits, special characters")
501505

502506
# Always return SecretStr to keep it secret-safe
503507
return v if isinstance(v, SecretStr) else SecretStr(value)
504508

505509
@field_validator("allowed_origins")
506510
@classmethod
507-
def validate_cors_origins(cls, v: Any) -> set[str] | None:
511+
def validate_cors_origins(cls, v: Any, info: ValidationInfo) -> set[str] | None:
508512
"""Validate CORS allowed origins.
509513
510514
Args:
511515
v: The set of allowed origins to validate.
516+
info: ValidationInfo containing field data.
512517
513518
Returns:
514519
set: The validated set of allowed origins.
@@ -522,35 +527,38 @@ def validate_cors_origins(cls, v: Any) -> set[str] | None:
522527
raise ValueError("allowed_origins must be a set or list of strings")
523528

524529
dangerous_origins = ["*", "null", ""]
525-
for origin in v:
526-
if origin in dangerous_origins:
527-
logger.warning(f"🌐 SECURITY WARNING: Dangerous CORS origin '{origin}' detected. Consider specifying explicit origins instead of wildcards.")
530+
if not info.data.get("client_mode"):
531+
for origin in v:
532+
if origin in dangerous_origins:
533+
logger.warning(f"🌐 SECURITY WARNING: Dangerous CORS origin '{origin}' detected. Consider specifying explicit origins instead of wildcards.")
528534

529-
# Validate URL format
530-
if not origin.startswith(("http://", "https://")) and origin not in dangerous_origins:
531-
logger.warning(f"⚠️ SECURITY WARNING: Invalid origin format '{origin}'. Origins should start with http:// or https://")
535+
# Validate URL format
536+
if not origin.startswith(("http://", "https://")) and origin not in dangerous_origins:
537+
logger.warning(f"⚠️ SECURITY WARNING: Invalid origin format '{origin}'. Origins should start with http:// or https://")
532538

533539
return set({str(origin) for origin in v})
534540

535541
@field_validator("database_url")
536542
@classmethod
537-
def validate_database_url(cls, v: str) -> str:
543+
def validate_database_url(cls, v: str, info: ValidationInfo) -> str:
538544
"""Validate database connection string security.
539545
540546
Args:
541547
v: The database URL to validate.
548+
info: ValidationInfo containing field data.
542549
543550
Returns:
544551
str: The validated database URL.
545552
"""
546553
# Check for hardcoded passwords in non-SQLite databases
547-
if not v.startswith("sqlite"):
548-
if "password" in v and any(weak in v for weak in ["password", "123", "admin", "test"]):
549-
logger.warning("Potentially weak database password detected. Consider using a stronger password.")
554+
if not info.data.get("client_mode"):
555+
if not v.startswith("sqlite"):
556+
if "password" in v and any(weak in v for weak in ["password", "123", "admin", "test"]):
557+
logger.warning("Potentially weak database password detected. Consider using a stronger password.")
550558

551-
# Warn about SQLite in production
552-
if v.startswith("sqlite"):
553-
logger.info("Using SQLite database. Consider PostgreSQL or MySQL for production.")
559+
# Warn about SQLite in production
560+
if v.startswith("sqlite"):
561+
logger.info("Using SQLite database. Consider PostgreSQL or MySQL for production.")
554562

555563
return v
556564

@@ -561,6 +569,9 @@ def validate_security_combinations(self) -> Self:
561569
Returns:
562570
Itself.
563571
"""
572+
if self.client_mode:
573+
return self
574+
564575
# Check for dangerous combinations - only log warnings, don't raise errors
565576
if not self.auth_required and self.mcpgateway_ui_enabled:
566577
logger.warning("🔓 SECURITY WARNING: Admin UI is enabled without authentication. Consider setting AUTH_REQUIRED=true for production.")
@@ -1526,9 +1537,12 @@ def log_summary(self) -> None:
15261537

15271538

15281539
@lru_cache()
1529-
def get_settings() -> Settings:
1540+
def get_settings(**kwargs: Any) -> Settings:
15301541
"""Get cached settings instance.
15311542
1543+
Args:
1544+
**kwargs: Keyword arguments to pass to the Settings setup.
1545+
15321546
Returns:
15331547
Settings: A cached instance of the Settings class.
15341548
@@ -1543,7 +1557,7 @@ def get_settings() -> Settings:
15431557
"""
15441558
# Instantiate a fresh Pydantic Settings object,
15451559
# loading from env vars or .env exactly once.
1546-
cfg = Settings()
1560+
cfg = Settings(**kwargs)
15471561
# Validate that transport_type is correct; will
15481562
# raise if mis-configured.
15491563
cfg.validate_transport()
@@ -1565,7 +1579,24 @@ def generate_settings_schema() -> dict[str, Any]:
15651579
return Settings.model_json_schema(mode="validation")
15661580

15671581

1568-
settings = get_settings()
1582+
# Lazy "instance" of settings
1583+
class LazySettingsWrapper:
1584+
"""Lazily initialize settings singleton on getattr"""
1585+
1586+
def __getattr__(self, key: str) -> Any:
1587+
"""Get the real settings object and forward to it
1588+
1589+
Args:
1590+
key: The key to fetch from settings
1591+
1592+
Returns:
1593+
Any: The value of the attribute on the settings
1594+
"""
1595+
return getattr(get_settings(), key)
1596+
1597+
1598+
settings = LazySettingsWrapper()
1599+
15691600

15701601
if __name__ == "__main__":
15711602
if "--schema" in sys.argv:

tests/unit/mcpgateway/test_config.py

Lines changed: 13 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -108,16 +108,19 @@ def test_get_settings_is_lru_cached(mock_settings):
108108
"""Constructor must run only once regardless of repeated calls."""
109109
get_settings.cache_clear()
110110

111-
inst1 = MagicMock()
112-
inst1.validate_transport.return_value = None
113-
inst1.validate_database.return_value = None
114-
115-
inst2 = MagicMock()
116-
mock_settings.side_effect = [inst1, inst2]
117-
118-
assert get_settings() is inst1
119-
assert get_settings() is inst1 # cached
120-
assert mock_settings.call_count == 1
111+
try:
112+
inst1 = MagicMock()
113+
inst1.validate_transport.return_value = None
114+
inst1.validate_database.return_value = None
115+
116+
inst2 = MagicMock()
117+
mock_settings.side_effect = [inst1, inst2]
118+
119+
assert get_settings() is inst1
120+
assert get_settings() is inst1 # cached
121+
assert mock_settings.call_count == 1
122+
finally:
123+
get_settings.cache_clear()
121124

122125

123126
# --------------------------------------------------------------------------- #

0 commit comments

Comments
 (0)